@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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +27 -54
- package/agents/code-reviewer.md +2 -2
- package/agents/doc-keeper.md +19 -0
- package/agents/precheck-reader.md +1 -1
- package/agents/qa-reviewer.md +2 -2
- package/agents/reference-analyst.md +25 -0
- package/agents/reference-verifier.md +19 -0
- package/agents/spec-writer.md +1 -1
- package/agents/ux-reviewer.md +1 -1
- package/bin/cadence.js +9 -1
- package/dist/audit.js +16 -4
- package/dist/check.js +24 -2
- package/dist/cli.js +258 -30
- package/dist/config.js +101 -5
- package/dist/corrections.js +24 -2
- package/dist/deliver.js +125 -37
- package/dist/docsync.js +19 -3
- package/dist/git.js +16 -3
- package/dist/lead.js +45 -12
- package/dist/migrate.js +166 -0
- package/dist/orchestrate/briefs.js +46 -6
- package/dist/orchestrate/changelog-merge.js +94 -0
- package/dist/orchestrate/command.js +358 -41
- package/dist/orchestrate/cycle.js +922 -285
- package/dist/orchestrate/guard.js +2 -2
- package/dist/orchestrate/integrate.js +129 -0
- package/dist/orchestrate/launch.js +48 -6
- package/dist/orchestrate/mutex.js +30 -0
- package/dist/orchestrate/no-test.js +41 -0
- package/dist/orchestrate/pool.js +74 -36
- package/dist/orchestrate/registry.js +146 -14
- package/dist/orchestrate/result.js +1 -1
- package/dist/orchestrate/snapshot.js +54 -1
- package/dist/orchestrate/spec.js +89 -5
- package/dist/orchestrate/state.js +56 -15
- package/dist/orchestrate/table.js +74 -3
- package/dist/orchestrate/timeouts.js +40 -0
- package/dist/orchestrate/worktree.js +368 -0
- package/dist/perf.js +188 -25
- package/dist/plan.js +112 -7
- package/dist/proc.js +6 -0
- package/dist/ready-free.js +109 -0
- package/dist/ref/add.js +129 -0
- package/dist/ref/analyse.js +220 -0
- package/dist/ref/check.js +338 -0
- package/dist/ref/command.js +92 -0
- package/dist/ref/corpus-render.js +83 -0
- package/dist/ref/corpus.js +201 -0
- package/dist/ref/diff.js +262 -0
- package/dist/ref/evaluate.js +241 -0
- package/dist/ref/figures.js +252 -0
- package/dist/ref/measure.js +330 -0
- package/dist/ref/pdf.js +116 -0
- package/dist/ref/png.js +125 -0
- package/dist/ref/regard.js +102 -0
- package/dist/ref/session.js +34 -0
- package/dist/ref/store.js +40 -0
- package/dist/ref/tools.js +51 -0
- package/dist/resources.js +51 -0
- package/dist/session.js +5 -2
- package/dist/state.js +75 -7
- package/dist/testgate.js +15 -0
- package/docs/cadence-hud.md +12 -0
- package/docs/cycle-vague-dark.svg +1 -1
- package/docs/cycle-vague-light.svg +1 -1
- package/docs/deliver.md +76 -5
- package/docs/fonctionnement-wave-dark.svg +1 -1
- package/docs/fonctionnement-wave-light.svg +1 -1
- package/docs/fonctionnement.md +6 -6
- package/docs/lead.md +40 -13
- package/docs/news.md +30 -1
- package/docs/orchestrate.md +275 -33
- package/docs/overview.md +65 -1
- package/docs/perf.md +14 -8
- package/docs/raf.md +90 -11
- package/docs/ref.md +415 -0
- package/docs/releasing.md +3 -3
- package/docs/session.md +2 -2
- package/docs/skills.md +21 -2
- package/docs/spec.md +1 -1
- package/docs/stabilite.md +4 -4
- package/docs/whats-new.md +30 -1
- package/package.json +2 -1
- package/skills/deliver/SKILL.md +6 -1
- package/skills/lead/SKILL.md +28 -10
- package/skills/session-close/SKILL.md +1 -1
- package/skills/session-start/SKILL.md +2 -2
- package/templates/inventaire.json +96 -0
- package/templates/orchestrate/accept-back.md +1 -0
- package/templates/orchestrate/accept.md +1 -0
- package/templates/orchestrate/fiche.md +1 -0
- package/templates/orchestrate/fix-docs.md +18 -0
- package/templates/orchestrate/fix-minors.md +2 -1
- package/templates/orchestrate/fix.md +5 -2
- package/templates/orchestrate/implement.md +7 -4
- package/templates/orchestrate/maquette.md +2 -1
- package/templates/orchestrate/precheck.md +2 -1
- package/templates/orchestrate/review-recheck.md +2 -0
- package/templates/orchestrate/review-small.md +5 -1
- package/templates/orchestrate/review.md +2 -0
- package/templates/orchestrate/spec-back.md +2 -1
- package/templates/orchestrate/spec.md +4 -3
- 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`)
|
|
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
|
|
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
|
|
152
|
-
export function
|
|
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) =>
|
|
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
|
+
}
|
package/dist/orchestrate/spec.js
CHANGED
|
@@ -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
|
-
/**
|
|
79
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
/**
|
|
59
|
-
static
|
|
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
|
|
63
|
-
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
/**
|
|
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
|
|
140
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|