@sylad/cadence 1.8.0 → 1.10.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.
Files changed (105) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.md +27 -54
  4. package/agents/code-reviewer.md +2 -2
  5. package/agents/doc-keeper.md +19 -0
  6. package/agents/precheck-reader.md +1 -1
  7. package/agents/qa-reviewer.md +2 -2
  8. package/agents/reference-analyst.md +25 -0
  9. package/agents/reference-verifier.md +19 -0
  10. package/agents/spec-writer.md +1 -1
  11. package/agents/ux-reviewer.md +1 -1
  12. package/bin/cadence.js +9 -1
  13. package/dist/audit.js +16 -4
  14. package/dist/check.js +24 -2
  15. package/dist/cli.js +258 -30
  16. package/dist/config.js +101 -5
  17. package/dist/corrections.js +24 -2
  18. package/dist/deliver.js +125 -37
  19. package/dist/docsync.js +19 -3
  20. package/dist/git.js +16 -3
  21. package/dist/lead.js +45 -12
  22. package/dist/migrate.js +166 -0
  23. package/dist/orchestrate/briefs.js +46 -6
  24. package/dist/orchestrate/changelog-merge.js +94 -0
  25. package/dist/orchestrate/command.js +358 -41
  26. package/dist/orchestrate/cycle.js +922 -285
  27. package/dist/orchestrate/guard.js +2 -2
  28. package/dist/orchestrate/integrate.js +129 -0
  29. package/dist/orchestrate/launch.js +48 -6
  30. package/dist/orchestrate/mutex.js +30 -0
  31. package/dist/orchestrate/no-test.js +41 -0
  32. package/dist/orchestrate/pool.js +74 -36
  33. package/dist/orchestrate/registry.js +146 -14
  34. package/dist/orchestrate/result.js +1 -1
  35. package/dist/orchestrate/snapshot.js +54 -1
  36. package/dist/orchestrate/spec.js +89 -5
  37. package/dist/orchestrate/state.js +56 -15
  38. package/dist/orchestrate/table.js +74 -3
  39. package/dist/orchestrate/timeouts.js +40 -0
  40. package/dist/orchestrate/worktree.js +368 -0
  41. package/dist/perf.js +188 -25
  42. package/dist/plan.js +112 -7
  43. package/dist/proc.js +6 -0
  44. package/dist/ready-free.js +109 -0
  45. package/dist/ref/add.js +129 -0
  46. package/dist/ref/analyse.js +220 -0
  47. package/dist/ref/check.js +338 -0
  48. package/dist/ref/command.js +92 -0
  49. package/dist/ref/corpus-render.js +83 -0
  50. package/dist/ref/corpus.js +201 -0
  51. package/dist/ref/diff.js +262 -0
  52. package/dist/ref/evaluate.js +241 -0
  53. package/dist/ref/figures.js +252 -0
  54. package/dist/ref/measure.js +330 -0
  55. package/dist/ref/pdf.js +116 -0
  56. package/dist/ref/png.js +125 -0
  57. package/dist/ref/regard.js +102 -0
  58. package/dist/ref/session.js +34 -0
  59. package/dist/ref/store.js +40 -0
  60. package/dist/ref/tools.js +51 -0
  61. package/dist/resources.js +51 -0
  62. package/dist/session.js +5 -2
  63. package/dist/state.js +75 -7
  64. package/dist/testgate.js +15 -0
  65. package/docs/cadence-hud.md +12 -0
  66. package/docs/cycle-vague-dark.svg +1 -1
  67. package/docs/cycle-vague-light.svg +1 -1
  68. package/docs/deliver.md +76 -5
  69. package/docs/fonctionnement-wave-dark.svg +1 -1
  70. package/docs/fonctionnement-wave-light.svg +1 -1
  71. package/docs/fonctionnement.md +6 -6
  72. package/docs/lead.md +40 -13
  73. package/docs/news.md +30 -1
  74. package/docs/orchestrate.md +275 -33
  75. package/docs/overview.md +65 -1
  76. package/docs/perf.md +14 -8
  77. package/docs/raf.md +90 -11
  78. package/docs/ref.md +415 -0
  79. package/docs/releasing.md +3 -3
  80. package/docs/session.md +2 -2
  81. package/docs/skills.md +21 -2
  82. package/docs/spec.md +1 -1
  83. package/docs/stabilite.md +4 -4
  84. package/docs/whats-new.md +30 -1
  85. package/package.json +2 -1
  86. package/skills/deliver/SKILL.md +6 -1
  87. package/skills/lead/SKILL.md +28 -10
  88. package/skills/session-close/SKILL.md +1 -1
  89. package/skills/session-start/SKILL.md +2 -2
  90. package/templates/inventaire.json +96 -0
  91. package/templates/orchestrate/accept-back.md +1 -0
  92. package/templates/orchestrate/accept.md +1 -0
  93. package/templates/orchestrate/fiche.md +1 -0
  94. package/templates/orchestrate/fix-docs.md +18 -0
  95. package/templates/orchestrate/fix-minors.md +2 -1
  96. package/templates/orchestrate/fix.md +5 -2
  97. package/templates/orchestrate/implement.md +7 -4
  98. package/templates/orchestrate/maquette.md +2 -1
  99. package/templates/orchestrate/precheck.md +2 -1
  100. package/templates/orchestrate/review-recheck.md +2 -0
  101. package/templates/orchestrate/review-small.md +5 -1
  102. package/templates/orchestrate/review.md +2 -0
  103. package/templates/orchestrate/spec-back.md +2 -1
  104. package/templates/orchestrate/spec.md +4 -3
  105. package/templates/orchestrate/ux.md +3 -1
@@ -1,4 +1,4 @@
1
- import { mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
1
+ import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs';
2
2
  import { homedir } from 'node:os';
3
3
  import { join } from 'node:path';
4
4
  import { processStart } from '../proc.js';
@@ -7,7 +7,8 @@ import { lotFinished, lotKey, lotRepoPaths, RunStore } from './state.js';
7
7
  import { activeLock, holderAlive, releaseLock, takeLock } from './lock.js';
8
8
  /**
9
9
  * État commun à toutes les vagues de l'utilisateur, quel que soit le dossier d'où elles partent : le registre
10
- * des vagues vivantes (`waves/<pid>.json`) et les créneaux de sessions simultanées (`slots/slot-<n>.lock`).
10
+ * des vagues vivantes (`waves/<pid>.json`), les créneaux de sessions simultanées (`slots/slot-<n>.lock`) et les
11
+ * sessions lancées à la main par le lead (`holds/<étiquette>.json`, L255).
11
12
  * Les verrous de DÉPÔT restent dans le dépôt (`.git/cadence/orchestrate.lock`).
12
13
  */
13
14
  export function cadenceHome(env = process.env) {
@@ -21,6 +22,8 @@ function readWave(file) {
21
22
  const w = { pid: Number(raw.pid), wave: String(raw.wave), started: String(raw.started), cwd: String(raw.cwd), repos: Array.isArray(raw.repos) ? raw.repos.map(String) : [] };
22
23
  if (raw.cap !== undefined)
23
24
  w.cap = Number(raw.cap);
25
+ if (raw.lanes !== undefined)
26
+ w.lanes = Number(raw.lanes);
24
27
  if (raw.start !== undefined)
25
28
  w.start = String(raw.start);
26
29
  return w;
@@ -48,13 +51,82 @@ export function liveWaves(home) {
48
51
  }
49
52
  /**
50
53
  * Les créneaux que le lead peut encore donner à des sous-agents : `total` moins, pour chaque vague vivante, le
51
- * plus petit de son plafond et du nombre de dépôts qu'elle tient encore (ses pas prennent un créneau et 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
52
55
  * rendent entre deux étapes : les sessions vivantes à l'instant ne disent pas ce que la vague va reprendre).
53
56
  * Une vague sans plafond connu compte pour `total`.
54
57
  */
55
- export function freeSlots(waves, total) {
56
- const held = waves.reduce((n, w) => n + Math.min(w.cap ?? total, w.repos.length), 0);
57
- return Math.max(0, total - held);
58
+ 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);
60
+ return Math.max(0, total - held - leadSessions);
61
+ }
62
+ const holdsDir = (home) => join(home, 'holds');
63
+ /** Le libellé est libre (espaces, « / »), le nom de fichier l'encode. */
64
+ const holdFile = (home, label) => join(holdsDir(home), `${encodeURIComponent(label)}.json`);
65
+ /** Inscrit la session `label` pour `ttlMinutes` minutes à partir de `now` ; la poser de nouveau la renouvelle. */
66
+ export function addHold(home, label, ttlMinutes, now) {
67
+ mkdirSync(holdsDir(home), { recursive: true });
68
+ const file = holdFile(home, label);
69
+ const hold = { label, since: now.toISOString(), expires: new Date(now.getTime() + ttlMinutes * 60_000).toISOString() };
70
+ writeFileSync(`${file}.tmp`, JSON.stringify(hold));
71
+ renameSync(`${file}.tmp`, file);
72
+ }
73
+ /** Retire la session `label` ; faux quand elle n'était pas inscrite. */
74
+ export function releaseHold(home, label) {
75
+ const file = holdFile(home, label);
76
+ if (!existsSync(file))
77
+ return false;
78
+ rmSync(file, { force: true });
79
+ return true;
80
+ }
81
+ /** Une session échue reste lisible (et signalée en ⚠) jusqu'à son `release` ou, oubliée, ce délai après son échéance. */
82
+ const EXPIRED_HOLD_KEEP_MS = 24 * 3600_000;
83
+ /** Lit les sessions du lead : celles dont l'échéance n'est pas passée à `now`, et celles échues depuis moins de 24 h ; le reste, et les fichiers illisibles, sont écartés au passage. */
84
+ function scanHolds(home, now) {
85
+ let names;
86
+ try {
87
+ names = readdirSync(holdsDir(home)).filter((n) => n.endsWith('.json'));
88
+ }
89
+ catch {
90
+ return { live: [], expired: [] };
91
+ }
92
+ const live = [];
93
+ const expired = [];
94
+ for (const n of names) {
95
+ const file = join(holdsDir(home), n);
96
+ let hold = null;
97
+ let overdue = false;
98
+ try {
99
+ const raw = JSON.parse(readFileSync(file, 'utf8'));
100
+ const end = Date.parse(raw.expires);
101
+ if (typeof raw.label === 'string' && Number.isFinite(end)) {
102
+ hold = { label: raw.label, since: String(raw.since), expires: String(raw.expires) };
103
+ overdue = end <= now.getTime();
104
+ if (now.getTime() - end >= EXPIRED_HOLD_KEEP_MS)
105
+ hold = null;
106
+ }
107
+ }
108
+ catch {
109
+ // illisible : écarté
110
+ }
111
+ if (!hold)
112
+ rmSync(file, { force: true });
113
+ else
114
+ (overdue ? expired : live).push(hold);
115
+ }
116
+ const byAge = (a, b) => a.since.localeCompare(b.since) || a.label.localeCompare(b.label);
117
+ return { live: live.sort(byAge), expired: expired.sort(byAge) };
118
+ }
119
+ /** Les sessions du lead dont l'échéance n'est pas passée à `now` : seules elles comptent dans les créneaux. */
120
+ export function liveHolds(home, now) {
121
+ return scanHolds(home, now).live;
122
+ }
123
+ /**
124
+ * Les sessions du lead dont le TTL est échu à `now` (L278) et que personne n'a rendues : plus de créneau pris, mais la
125
+ * session tourne peut-être encore. `lead tour`, `orchestrate --status` et la bande les signalent en ⚠ : le lead la
126
+ * reprend (`lead hold`) ou l'arrête (`lead release`). Rien ne l'arrête ici.
127
+ */
128
+ export function expiredHolds(home, now) {
129
+ return scanHolds(home, now).expired;
58
130
  }
59
131
  /** Inscrit la vague ; écarte au passage les entrées de processus morts (les `.tmp` d'une inscription en cours ne sont pas touchés). */
60
132
  export function registerWave(home, given) {
@@ -71,13 +143,13 @@ export function registerWave(home, given) {
71
143
  writeFileSync(`${file}.tmp`, JSON.stringify(w));
72
144
  renameSync(`${file}.tmp`, file);
73
145
  }
74
- /** Met à jour la liste des dépôts tenus par la vague (un dépôt libéré en cours de vague n'y figure plus). */
75
- export function updateWaveRepos(home, pid, repos) {
146
+ /** Met à jour la liste des dépôts tenus par la vague (un dépôt libéré en cours de vague n'y figure plus) et, quand elle la donne, le nombre de lots qu'elle joue à la fois. */
147
+ export function updateWaveRepos(home, pid, repos, lanes) {
76
148
  const file = join(wavesDir(home), `${pid}.json`);
77
149
  const w = readWave(file);
78
150
  if (!w)
79
151
  return;
80
- writeFileSync(`${file}.tmp`, JSON.stringify({ ...w, repos }));
152
+ writeFileSync(`${file}.tmp`, JSON.stringify({ ...w, repos, ...(lanes === undefined ? {} : { lanes }) }));
81
153
  renameSync(`${file}.tmp`, file);
82
154
  }
83
155
  export function unregisterWave(home, pid) {
@@ -98,6 +170,16 @@ export function liveSlots(home) {
98
170
  const MAX_SLOT_INDEX = 64;
99
171
  /** Intervalle des rappels d'attente dans le journal. */
100
172
  const WAIT_REPORT_MS = 60_000;
173
+ /** L'attente d'un créneau a été interrompue par `abandon` (L332) : aucun créneau n'a été pris, la session ne part pas. */
174
+ export class SlotAbandoned extends Error {
175
+ reason;
176
+ /** Renseigné par `runSession` quand une session précédente de l'étape a déjà tourné : sa sortie et ses jetons ne se perdent pas. */
177
+ after;
178
+ constructor(reason) {
179
+ super(`attente de créneau abandonnée : ${reason}`);
180
+ this.reason = reason;
181
+ }
182
+ }
101
183
  /**
102
184
  * Attend que le nombre de sessions vivantes, TOUTES vagues confondues et quel que soit l'index de leur créneau,
103
185
  * soit sous `cap` (le plafond de cette vague), puis prend le premier créneau libre (un pid mort est repris) et
@@ -108,6 +190,10 @@ const WAIT_REPORT_MS = 60_000;
108
190
  *
109
191
  * `onWait(holders, waitedMs)` : appelé quand on commence à attendre (0 ms), puis toutes les minutes.
110
192
  * `onGot(waitedMs)` : appelé à l'obtention, seulement si on a attendu.
193
+ * `abandon()` : relu seulement pendant une VRAIE attente, c'est-à-dire une fois qu'une première tentative n'a pas trouvé de créneau libre,
194
+ * puis à chaque tour ; une raison en retour interrompt l'attente par `SlotAbandoned`, sans prendre de créneau (L332 : `--drop` et
195
+ * `--stop-after-current` ne laissent pas un lot en attente). Un créneau libre est toujours pris, même si une demande est déjà posée :
196
+ * la relance d'une session coupée n'est pas une attente, la demande d'arrêt s'applique ensuite.
111
197
  */
112
198
  export async function acquireSlot(home, cap, opts) {
113
199
  mkdirSync(slotsDir(home), { recursive: true });
@@ -116,7 +202,14 @@ export async function acquireSlot(home, cap, opts) {
116
202
  let reportedAt = null;
117
203
  const pollMs = opts.pollMs ?? 2000;
118
204
  const live = opts.slots ?? liveSlots; // injectable : les tests simulent une vague qui prend un créneau entre les deux comptées
205
+ const checkAbandon = () => {
206
+ const why = opts.abandon?.();
207
+ if (why)
208
+ throw new SlotAbandoned(why);
209
+ };
119
210
  for (;;) {
211
+ if (reportedAt !== null)
212
+ checkAbandon(); // pas à la première tentative : un créneau libre est pris même si une demande est posée
120
213
  if (live(home).length < cap) {
121
214
  for (let k = 0; k < MAX_SLOT_INDEX; k++) {
122
215
  const file = join(slotsDir(home), `slot-${k}.lock`);
@@ -132,6 +225,8 @@ export async function acquireSlot(home, cap, opts) {
132
225
  break;
133
226
  }
134
227
  }
228
+ if (reportedAt === null)
229
+ checkAbandon(); // la première tentative a échoué : l'attente commence, la demande déjà posée l'interrompt sans attendre un tour
135
230
  if (reportedAt === null || now() - reportedAt >= (opts.reportMs ?? WAIT_REPORT_MS)) {
136
231
  reportedAt = now();
137
232
  opts.onWait?.(live(home), reportedAt - t0);
@@ -139,26 +234,63 @@ export async function acquireSlot(home, cap, opts) {
139
234
  await new Promise((r) => setTimeout(r, pollMs + Math.random() * pollMs * 0.25));
140
235
  }
141
236
  }
142
- /** .git commun du dépôt `dir` (partagé par ses worktrees) ; null si `dir` n'est pas un dépôt. */
237
+ /** .git commun du dépôt `dir` (partagé par ses worktrees), chemin réel (un lien symbolique vers le dépôt désigne le même) ; null si `dir` n'est pas un dépôt. */
143
238
  const commonDir = (dir) => {
144
239
  try {
145
- return gitCommonDir(dir);
240
+ return realpathSync(gitCommonDir(dir));
146
241
  }
147
242
  catch {
148
243
  return null;
149
244
  }
150
245
  };
151
- /** Lots de la vague `pid` qui travaillent encore dans `repo` (en cours, en file ou suspendus) ; null si l'état de la vague est illisible. */
152
- export function openLotsIn(home, pid, repo) {
246
+ /** Lots de la vague `pid` qui travaillent dans `repo` (ou l'un de ses worktrees), finis ou non ; null si l'état de la vague est illisible. */
247
+ export function waveLotsIn(home, pid, repo) {
153
248
  try {
154
249
  const w = liveWaves(home).find((x) => x.pid === pid);
155
250
  const store = w && RunStore.find(w.cwd, w.wave);
156
251
  if (!store)
157
252
  return null;
158
253
  const mine = commonDir(repo);
159
- return store.lots().filter((l) => !lotFinished(l) && lotRepoPaths(l).some((r) => r === repo || (mine !== null && commonDir(r) === mine))).map((l) => lotKey(l.project, l.lot));
254
+ return store.lots().filter((l) => lotRepoPaths(l).some((r) => r === repo || (mine !== null && commonDir(r) === mine)));
160
255
  }
161
256
  catch {
162
257
  return null;
163
258
  }
164
259
  }
260
+ /** Lots de la vague `pid` qui travaillent encore dans `repo` (en cours, en file ou suspendus) ; null si l'état de la vague est illisible. */
261
+ export function openLotsIn(home, pid, repo) {
262
+ return waveLotsIn(home, pid, repo)?.filter((l) => !lotFinished(l)).map((l) => lotKey(l.project, l.lot)) ?? null;
263
+ }
264
+ /**
265
+ * Vagues non terminées (`done` exclu : interrompues, suspendues, ou vivantes) lancées depuis `launchDir` qui gardent ouvert (état non terminal : question, suspendu, en file, en cours...)
266
+ * au moins un lot DU MÊME DÉPÔT que `repo` pour lequel `doing(lot)` est vrai (L238, `doing` : le lot est encore en cours au plan de ce dépôt). Un `--resume` rejouera ces lots sur ce dépôt :
267
+ * un rescellé commité entre-temps leur serait rendu comme « premier commit fautif ». Un lot livré au plan (vieille vague abandonnée) ou absent du plan ne compte jamais.
268
+ * Le dépôt d'un lot est celui de son projet (`lot.repo`), pas ses dépôts voisins : l'id d'un lot est propre au plan de son projet.
269
+ */
270
+ export function openWavesIn(launchDir, repo, doing, live = []) {
271
+ const base = RunStore.runsDir(launchDir);
272
+ if (!existsSync(base))
273
+ return [];
274
+ const mine = commonDir(repo);
275
+ const out = [];
276
+ for (const id of readdirSync(base).sort()) {
277
+ try {
278
+ const store = RunStore.find(launchDir, id);
279
+ if (!store || store.readWave()?.status === 'done')
280
+ continue;
281
+ const open = store
282
+ .lots()
283
+ .filter((l) => !lotFinished(l) && (l.repo === repo || (mine !== null && commonDir(l.repo) === mine)))
284
+ .map((l) => ({ in: doing(l), key: lotKey(l.project, l.lot), status: l.status }));
285
+ const lots = open.filter((l) => l.in).map(({ key, status }) => ({ key, status }));
286
+ const others = open.filter((l) => !l.in).map(({ key, status }) => ({ key, status }));
287
+ const alive = live.find((w) => w.wave === id && w.cwd === launchDir);
288
+ if (lots.length)
289
+ out.push({ wave: id, launchDir, live: !!alive, ...(alive ? { pid: alive.pid } : {}), lots, others });
290
+ }
291
+ catch {
292
+ // état illisible : une vague qu'on ne sait pas lire ne bloque pas un rescellé du lead
293
+ }
294
+ }
295
+ return out;
296
+ }
@@ -51,7 +51,7 @@ export function parseSession(stdout, opts) {
51
51
  }
52
52
  /** Le message d'une session arrêtée par la limite d'usage (quota) ; l'heure de remise suit « | » quand elle est donnée. */
53
53
  export function isQuotaMessage(text) {
54
- return /usage limit|limit reached|rate limit|quota/i.test(text);
54
+ return /usage limit|limit reached|hit your .{0,20}limit|rate limit|quota/i.test(text);
55
55
  }
56
56
  /** Heure de remise (epoch s) lue dans « …limit reached|1759600000 », sinon null. */
57
57
  export function quotaReset(text) {
@@ -1,5 +1,5 @@
1
1
  import { spawn } from 'node:child_process';
2
- import { chmodSync, cpSync, existsSync, mkdirSync, readFileSync, realpathSync, symlinkSync } from 'node:fs';
2
+ import { chmodSync, cpSync, existsSync, mkdirSync, readFileSync, readlinkSync, realpathSync, rmSync, symlinkSync } from 'node:fs';
3
3
  import { createRequire } from 'node:module';
4
4
  import { join, sep } from 'node:path';
5
5
  import { createInterface } from 'node:readline';
@@ -74,6 +74,34 @@ export function takeSnapshot(waveDir, packageRoot) {
74
74
  }
75
75
  return tool;
76
76
  }
77
+ /**
78
+ * L330 : `<vague>/tool/node_modules` vise le `node_modules` de la copie qui a lancé la vague ; la purge de `npm run service`
79
+ * retire cette copie quand elle sort des versions gardées, et le lien reste pendu (la reprise planterait sur `yaml`).
80
+ * Si le lien ne se résout plus, on le refait vers le `node_modules` du paquet courant (la copie en service) et on le dit
81
+ * (message rendu) ; s'il est irréparable, `SnapshotRefusal` : un refus clair, pas une trace de pile. Rien à faire : undefined.
82
+ */
83
+ export function repairSnapshotModules(waveDir, packageRoot) {
84
+ const link = join(toolDirOf(waveDir), 'node_modules');
85
+ if (existsSync(join(link, 'yaml', 'package.json')))
86
+ return undefined;
87
+ let was = 'absent';
88
+ try {
89
+ was = readlinkSync(link);
90
+ }
91
+ catch {
92
+ /* pas un lien (ou rien) : on le remplace quand même */
93
+ }
94
+ let modules;
95
+ try {
96
+ modules = resolveModulesDir(packageRoot);
97
+ }
98
+ catch (e) {
99
+ throw new SnapshotRefusal(`le node_modules de l'instantané de la vague (${was}) ne se résout plus et celui du paquet courant non plus, reprise impossible : ${e instanceof Error ? e.message : e}`);
100
+ }
101
+ rmSync(link, { recursive: true, force: true });
102
+ symlinkSync(modules, link, 'dir');
103
+ return `instantané : node_modules (${was}) ne se résolvait plus, la copie qui l'avait lancé a été retirée de la mise en service ; relié à ${modules}`;
104
+ }
77
105
  export const snapshotExists = (waveDir) => existsSync(join(toolDirOf(waveDir), 'bin', 'cadence.js'));
78
106
  /**
79
107
  * La vague lit-elle `control.log` (`--drop`, `--stop-after-current`, L79) ? Elle tourne sur son instantané : une vague lancée
@@ -103,6 +131,20 @@ export function waveReadsLotBudget(waveDir) {
103
131
  return false;
104
132
  }
105
133
  }
134
+ /**
135
+ * L'instantané de la vague connaît-il `--resume --integrate` (L279) ? Un instantané plus ancien refuserait l'option comme inconnue.
136
+ * Sans instantané : rien à relire, on suppose que oui.
137
+ */
138
+ export function waveReadsIntegrate(waveDir) {
139
+ if (!snapshotExists(waveDir))
140
+ return true;
141
+ try {
142
+ return readFileSync(join(toolDirOf(waveDir), 'dist', 'orchestrate', 'command.js'), 'utf8').includes('abandonCommits');
143
+ }
144
+ catch {
145
+ return false;
146
+ }
147
+ }
106
148
  /** Relance réelle : un processus node sur la copie, signaux transmis, code de sortie rendu. */
107
149
  export function spawnReexec(toolDir, argv, env, sink) {
108
150
  return new Promise((resolve, reject) => {
@@ -125,3 +167,14 @@ export function spawnReexec(toolDir, argv, env, sink) {
125
167
  });
126
168
  });
127
169
  }
170
+ /** L'instantané de la vague connaît-il `--resume --replay` (L279, reprise d'un lot rendu sur une écriture de plan refusée) ? Sans instantané : on suppose que oui. */
171
+ export function waveReadsReplay(waveDir) {
172
+ if (!snapshotExists(waveDir))
173
+ return true;
174
+ try {
175
+ return readFileSync(join(toolDirOf(waveDir), 'dist', 'orchestrate', 'command.js'), 'utf8').includes("'--replay'");
176
+ }
177
+ catch {
178
+ return false;
179
+ }
180
+ }
@@ -1,7 +1,7 @@
1
1
  import { execFileSync } from 'node:child_process';
2
- import { existsSync, readFileSync } from 'node:fs';
2
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
- import { parse } from 'yaml';
4
+ import { isMap, isNode, isScalar, isSeq, parse, parseDocument, stringify } from 'yaml';
5
5
  import { acceptationFile } from './accept.js';
6
6
  import { checkFiche, fichesDuDossier, LARGEURS } from '../spec.js';
7
7
  const isObj = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
@@ -75,13 +75,97 @@ export function ficheReady(repo, fiche, lot, parts = { screen: true, back: false
75
75
  return false;
76
76
  }
77
77
  }
78
- /** Consigne de reprise après la réponse du lead à des hypothèses coûteuses : la règle confirmée n'est plus une hypothèse. */
79
- export function answeredText(asked, answer) {
78
+ /** Début de la ligne qui contient l'offset. */
79
+ const lineStart = (text, at) => text.lastIndexOf('\n', at - 1) + 1;
80
+ /** Fin de la ligne qui contient l'offset, saut de ligne compris (ou la fin du texte). */
81
+ const lineEnd = (text, at) => {
82
+ if (at > 0 && text[at - 1] === '\n')
83
+ return at; // un scalaire bloc finit déjà en début de ligne
84
+ const nl = text.indexOf('\n', at);
85
+ return nl === -1 ? text.length : nl + 1;
86
+ };
87
+ /**
88
+ * Passe en `sourcee` les règles `hypothese` nommées (L297) : `source` dit d'où vient la réponse, `si_faux` disparaît. Seules les lignes `statut`,
89
+ * `source` et `si_faux` de ces règles sont éditées dans le texte (L302) : le reste de la fiche — ordre, commentaires, style de flux `[R2]` — est conservé
90
+ * à l'octet près, ce qu'une réécriture du document (`doc.toString`) ne garantit pas. Une règle écrite en style de flux (`{id: R1, …}`) n'est pas éditée : erreur.
91
+ * Rend les ids effectivement changés ; une règle absente ou déjà sourcée n'est pas touchée.
92
+ */
93
+ export function sourceRules(repo, fiche, ids, source) {
94
+ const file = join(repo, fiche);
95
+ const text = readFileSync(file, 'utf8');
96
+ const regles = parseDocument(text).get('regles');
97
+ if (!isSeq(regles))
98
+ return [];
99
+ const changed = [];
100
+ const edits = [];
101
+ const sourceValue = stringify(source).trimEnd();
102
+ for (const r of regles.items) {
103
+ if (!isMap(r))
104
+ continue;
105
+ const id = String(r.get('id'));
106
+ if (!ids.includes(id) || r.get('statut') !== 'hypothese')
107
+ continue;
108
+ if (r.flow)
109
+ throw new Error(`${id} est écrite en style de flux (${fiche}) : à passer en sourcée à la main`);
110
+ const pair = (key) => r.items.find((p) => isScalar(p.key) && p.key.value === key);
111
+ const statut = pair('statut');
112
+ const valueEnd = (p) => (isNode(p.value) && p.value.range ? p.value.range[1] : p.key.range[1]);
113
+ if (!statut || !isNode(statut.value) || !statut.value.range || !isNode(statut.key) || !statut.key.range)
114
+ continue;
115
+ edits.push({ start: statut.value.range[0], end: statut.value.range[1], text: 'sourcee' });
116
+ const src = pair('source');
117
+ if (src && isNode(src.value) && src.value.range)
118
+ edits.push({ start: src.value.range[0], end: src.value.range[1], text: sourceValue });
119
+ else {
120
+ const at = lineEnd(text, valueEnd(statut));
121
+ const indent = ' '.repeat(statut.key.range[0] - lineStart(text, statut.key.range[0]));
122
+ edits.push({ start: at, end: at, text: `${at > 0 && text[at - 1] !== '\n' ? '\n' : ''}${indent}source: ${sourceValue}\n` });
123
+ }
124
+ const siFaux = pair('si_faux');
125
+ if (siFaux && isNode(siFaux.key) && siFaux.key.range)
126
+ edits.push({ start: lineStart(text, siFaux.key.range[0]), end: lineEnd(text, valueEnd(siFaux)), text: '' });
127
+ changed.push(id);
128
+ }
129
+ if (!changed.length)
130
+ return changed;
131
+ let out = text;
132
+ for (const e of edits.sort((x, y) => y.start - x.start || y.end - x.end))
133
+ out = out.slice(0, e.start) + e.text + out.slice(e.end);
134
+ writeFileSync(file, out);
135
+ return changed;
136
+ }
137
+ /**
138
+ * La réponse du lead corrige-t-elle la règle (la supprime, la change, la remplace) au lieu de la confirmer ? Une lecture prudente (L302) : au moindre
139
+ * mot de correction ou de refus, la règle n'est pas passée en `sourcee` telle quelle, la session spec reprise l'applique. Se tromper dans ce sens
140
+ * ne coûte qu'une session ; l'autre sens source une règle que le lead vient de refuser.
141
+ */
142
+ export function answerAltersRule(answer) {
143
+ return /(?<![\p{L}])(non|no|pas|supprim\p{L}*|retir\p{L}*|enl[eè]v\p{L}*|abandonn\p{L}*|chang\p{L}*|modifi\p{L}*|remplac\p{L}*|corrig\p{L}*|plut[oô]t|à la place|sauf|remove|delet\p{L}*|drop|change|replace\p{L}*|instead|rather|except)(?![\p{L}])/iu.test(answer);
144
+ }
145
+ /** Les règles nommées que la fiche donne déjà pour sourcées par la réponse du lead (ce que `sourceRules` a posé). */
146
+ export function ruleIdsSourcedByLead(repo, fiche, ids) {
147
+ try {
148
+ const doc = parse(readFileSync(join(repo, fiche), 'utf8'));
149
+ return list(isObj(doc) ? doc.regles : []).filter((r) => ids.includes(String(r.id)) && r.statut === 'sourcee' && String(r.source).startsWith(LEAD_SOURCE)).map((r) => String(r.id));
150
+ }
151
+ catch {
152
+ return [];
153
+ }
154
+ }
155
+ /** Début de la source d'une règle tranchée par le lead : `réponse du lead, <date>, vague <id>`. */
156
+ export const LEAD_SOURCE = 'réponse du lead';
157
+ /**
158
+ * Consigne de reprise après la réponse du lead à des hypothèses coûteuses. Les règles que le programme a déjà passées en `sourcee` (`sourced`) ne sont plus à
159
+ * réécrire que si le lead les a corrigées ; les autres, l'agent les passe lui-même.
160
+ */
161
+ export function answeredText(asked, answer, sourced = []) {
80
162
  return [
81
163
  'The program asked the lead about expensive-to-undo hypotheses of your sheet:',
82
164
  ...asked.map((q) => `- ${q}`),
83
165
  `Answer from the lead: ${answer}`,
84
- 'Apply it to the sheet: a rule the lead confirms becomes `statut: sourcee` with `source: réponse du lead` (no `si_faux`); a rule the lead corrects is rewritten as they say (`sourcee`, same source). Do not leave it as `hypothese`, or the program asks the same question again.',
166
+ sourced.length
167
+ ? `The program already set ${sourced.join(', ')} to \`statut: sourcee\` with the lead's answer as its \`source\` (no \`si_faux\`). Touch the sheet only if the lead corrected a rule: rewrite its \`enonce\` as they say and keep the \`sourcee\` status and the \`source\`. Do not turn it back into a \`hypothese\`.`
168
+ : 'Apply it to the sheet: a rule the lead confirms becomes `statut: sourcee` with `source: réponse du lead` (no `si_faux`); a rule the lead corrects is rewritten as they say (`sourcee`, same source). Do not leave it as `hypothese`, or the program asks the same question again.',
85
169
  ].join('\n');
86
170
  }
87
171
  /** Fichiers suivis par git sous un dossier (chemins relatifs au dépôt). */
@@ -1,6 +1,12 @@
1
- import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync } from 'node:fs';
1
+ import { createHash } from 'node:crypto';
2
+ import { appendFileSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, renameSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
2
4
  import { join, relative } from 'node:path';
3
5
  import { gitCommonDir, gitRoot } from '../git.js';
6
+ /** Début de l'avertissement d'un rapport de précheck illisible (Sonnet) : le journal le garde, `cadence perf` s'en sert pour ne pas compter ce lot « non » (L293). */
7
+ /** Avertissement d'un rapport du précheck local illisible : l'étape reste `ok`, le lot se replie sur Sonnet (L322, lu par `cadence perf` pour compter l'échec du local). */
8
+ export const LOCAL_PRECHECK_UNREADABLE = 'contrôle préalable : rapport du modèle local illisible';
9
+ export const PRECHECK_UNREADABLE = 'contrôle préalable illisible, implémentation lancée';
4
10
  export function newLot(o) {
5
11
  return { ...o, status: 'queued', pass: 0, next: null, steps: [], constats: [], verdict: null, uxVerdict: null, questions: [], choix: [], answers: [], proposals: [], warnings: [], outcome: null };
6
12
  }
@@ -12,6 +18,8 @@ export const lotRepoPaths = (l) => [l.repo, ...(l.repos ?? []).map((r) => r.path
12
18
  export const neighbourDirs = (l) => (l.repos ?? []).map((r) => r.path);
13
19
  export const lotKey = (project, lot) => `${project}:${lot}`;
14
20
  /** Nom de dossier d'un lot dans la vague : le « / » d'un id de sous-tâche (`Q4/accueil-4-ux12`) ou d'un projet imbriqué (`groupe/gamma`) devient « __ » ; sans « / » le nom est inchangé. */
21
+ /** Racine des TMPDIR de session : `os.tmpdir()`, ou `/tmp` quand celui-ci est trop long pour laisser 60 caractères sous la limite de 107 des sockets Unix. */
22
+ const shortTmpBase = () => (join(tmpdir(), 'cadence-xxxxxxxx').length + 60 < 108 ? tmpdir() : '/tmp');
15
23
  export const lotSlug = (project, lot) => `${project.split('/').join('__')}--${lot.split('/').join('__')}`;
16
24
  const lotFile = (project, lot) => `${lotSlug(project, lot)}.json`;
17
25
  function atomicWrite(file, data) {
@@ -55,22 +63,25 @@ export class RunStore {
55
63
  static runsDir(launchDir) {
56
64
  return join(launchDir, '.cadence', 'runs');
57
65
  }
58
- /** La dernière vague lancée (par date de lancement : un identifiant libre comme `ol-gains-1` ne se range pas par ordre alphabétique) ; avec `unfinished`, la dernière qui n'est pas terminée. */
59
- static last(launchDir, opts = {}) {
66
+ /** Les vagues du dossier, la plus récente d'abord (par date de lancement : un identifiant libre comme `ol-gains-1` ne se range pas par ordre alphabétique). */
67
+ static all(launchDir) {
60
68
  const base = RunStore.runsDir(launchDir);
61
69
  if (!existsSync(base))
62
- return null;
63
- const stores = readdirSync(base)
70
+ return [];
71
+ return readdirSync(base)
64
72
  .filter((n) => existsSync(join(base, n, 'wave.json')))
65
73
  .map((id) => new RunStore(launchDir, id))
66
74
  .map((store) => ({ store, wave: store.readWave() }))
67
75
  // lancement le plus récent d'abord ; à date égale (ou illisible), l'identifiant le plus grand
68
76
  .sort((a, b) => (b.wave?.created ?? '').localeCompare(a.wave?.created ?? '') || b.store.id.localeCompare(a.store.id));
69
- for (const { store, wave } of stores) {
70
- if (!opts.unfinished || wave?.status !== 'done')
71
- return store;
72
- }
73
- return null;
77
+ }
78
+ /** La dernière vague lancée. */
79
+ static last(launchDir) {
80
+ return RunStore.all(launchDir)[0]?.store ?? null;
81
+ }
82
+ /** Les vagues qui ne sont pas terminées (une vague illisible compte : elle n'est pas `done`), la plus récente d'abord (L309). */
83
+ static unfinished(launchDir) {
84
+ return RunStore.all(launchDir).filter((x) => x.wave?.status !== 'done').map((x) => x.store);
74
85
  }
75
86
  static find(launchDir, id) {
76
87
  return existsSync(join(RunStore.runsDir(launchDir), id, 'wave.json')) ? new RunStore(launchDir, id) : null;
@@ -80,6 +91,28 @@ export class RunStore {
80
91
  mkdirSync(d, { recursive: true });
81
92
  return d;
82
93
  }
94
+ /**
95
+ * Dossier temporaire des sessions d'un lot (L243) : `<tmpdir>/cadence-<hash court du dossier du lot>`, hors du dépôt et hors de la vague, jamais suivi par git.
96
+ * Il est COURT à dessein : Chrome (Playwright MCP) range ses sockets Unix sous `TMPDIR` et refuse au-delà de 107 caractères
97
+ * (sous `.cadence/runs/<vague>/<projet>--<lot>/tmp`, L242, la revue UX échouait « Socket path too long »).
98
+ * Un lien `<lotDir>/tmp` y renvoie, pour le retrouver depuis le dossier de la vague.
99
+ */
100
+ tmpDir(project, lot) {
101
+ const lotDir = this.lotDir(project, lot);
102
+ const d = join(shortTmpBase(), `cadence-${createHash('sha1').update(lotDir).digest('hex').slice(0, 8)}`);
103
+ mkdirSync(d, { recursive: true });
104
+ const link = join(lotDir, 'tmp');
105
+ rmSync(link, { recursive: true, force: true });
106
+ symlinkSync(d, link);
107
+ return d;
108
+ }
109
+ /** Supprime le dossier temporaire du lot et son lien (fin de lot) ; sans effet s'ils n'existent pas. */
110
+ removeTmpDir(project, lot) {
111
+ const link = join(this.lotDir(project, lot), 'tmp');
112
+ if (lstatSync(link, { throwIfNoEntry: false })?.isSymbolicLink())
113
+ rmSync(readlinkSync(link), { recursive: true, force: true });
114
+ rmSync(link, { recursive: true, force: true });
115
+ }
83
116
  writeWave(w) {
84
117
  atomicWrite(join(this.dir, 'wave.json'), w);
85
118
  }
@@ -131,17 +164,25 @@ export class RunStore {
131
164
  appendFileSync(join(this.dir, 'journal.log'), `${new Date().toISOString()} ${line}\n`);
132
165
  }
133
166
  }
134
- /** Ajoute `.cadence/` à `.git/info/exclude` quand le dossier de lancement est dans un dépôt : l'état ne salit jamais un arbre. */
167
+ /**
168
+ * Ajoute `.cadence/runs/` (états de vague) et `.cadence/corrections.json` (cache de `cadence corrections`) à `.git/info/exclude`
169
+ * quand le dossier de lancement est dans un dépôt : l'état ne salit jamais un arbre. Le reste de `.cadence/` (le plan, ses notes)
170
+ * est versionné. L'ancienne ligne `/.cadence/`, écrite par une version précédente, est remplacée : elle cacherait aussi le plan.
171
+ */
135
172
  export function excludeState(launchDir) {
136
173
  const root = gitRoot(launchDir);
137
174
  if (!root)
138
175
  return;
139
- const rel = relative(root, join(launchDir, '.cadence')).split('\\').join('/');
140
- const pattern = `/${rel}/`;
176
+ const base = `/${relative(root, join(launchDir, '.cadence')).split('\\').join('/')}/`;
177
+ const wanted = [`${base}runs/`, `${base}corrections.json`];
141
178
  const file = join(gitCommonDir(launchDir), 'info', 'exclude');
142
179
  mkdirSync(join(gitCommonDir(launchDir), 'info'), { recursive: true });
143
180
  const text = existsSync(file) ? readFileSync(file, 'utf8') : '';
144
- if (text.split('\n').includes(pattern))
181
+ const lines = text.split('\n');
182
+ const missing = wanted.filter((w) => !lines.includes(w));
183
+ if (!lines.includes(base) && missing.length === 0)
145
184
  return;
146
- appendFileSync(file, `${text === '' || text.endsWith('\n') ? '' : '\n'}${pattern}\n`);
185
+ const kept = lines.filter((l) => l !== base);
186
+ const body = kept.join('\n');
187
+ writeFileSync(file, `${body}${body === '' || body.endsWith('\n') ? '' : '\n'}${missing.map((m) => `${m}\n`).join('')}`);
147
188
  }