@sylad/cadence 0.10.1 → 0.12.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 (41) hide show
  1. package/.claude-plugin/marketplace.json +16 -3
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.md +219 -21
  4. package/agents/code-reviewer.md +8 -3
  5. package/agents/precheck-reader.md +14 -0
  6. package/agents/qa-reviewer.md +19 -2
  7. package/agents/ux-reviewer.md +1 -1
  8. package/bin/cadence.js +3 -2
  9. package/dist/audit.js +34 -7
  10. package/dist/cli.js +113 -13
  11. package/dist/config.js +104 -15
  12. package/dist/deliver.js +10 -3
  13. package/dist/news.js +17 -1
  14. package/dist/orchestrate/app.js +189 -0
  15. package/dist/orchestrate/briefs.js +16 -0
  16. package/dist/orchestrate/command.js +224 -45
  17. package/dist/orchestrate/cycle.js +208 -26
  18. package/dist/orchestrate/guard.js +148 -3
  19. package/dist/orchestrate/launch.js +43 -5
  20. package/dist/orchestrate/lock.js +19 -5
  21. package/dist/orchestrate/node-env.js +77 -0
  22. package/dist/orchestrate/pool.js +4 -3
  23. package/dist/orchestrate/registry.js +120 -0
  24. package/dist/orchestrate/schemas.js +7 -1
  25. package/dist/orchestrate/snapshot.js +99 -0
  26. package/dist/orchestrate/state.js +22 -0
  27. package/dist/orchestrate/table.js +11 -1
  28. package/dist/plan.js +29 -0
  29. package/dist/proc.js +19 -0
  30. package/dist/session.js +6 -3
  31. package/dist/state.js +34 -14
  32. package/package.json +1 -1
  33. package/skills/lead/SKILL.md +17 -8
  34. package/templates/orchestrate/fix-minors.md +3 -0
  35. package/templates/orchestrate/fix.md +4 -0
  36. package/templates/orchestrate/implement.md +5 -1
  37. package/templates/orchestrate/precheck.md +10 -0
  38. package/templates/orchestrate/review-recheck.md +2 -0
  39. package/templates/orchestrate/review-small.md +3 -0
  40. package/templates/orchestrate/review.md +2 -0
  41. package/templates/orchestrate/ux.md +2 -0
@@ -0,0 +1,189 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { closeSync, openSync, readFileSync, rmSync, statSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { delimiter, join } from 'node:path';
5
+ import { linkNewFile, lockFault, pidAlive, removeStaleFile } from '../state.js';
6
+ import { withoutLaunchVars } from './snapshot.js';
7
+ /** Défaut de `orchestrate.ux.timeout` : secondes d'attente de la réponse de l'application. */
8
+ export const APP_TIMEOUT_S = 300;
9
+ /** Délai entre le SIGTERM et le SIGKILL du groupe de l'application. */
10
+ export const APP_KILL_AFTER_MS = 10_000;
11
+ /** L'environnement de l'application : celui de l'orchestrateur, avec le Node du projet en tête du PATH quand il y en a un (sans préfixe nvm, qui sort en code 3 sous sh). */
12
+ function appEnv(nodeBin) {
13
+ const env = withoutLaunchVars(process.env);
14
+ return nodeBin ? { ...env, PATH: [nodeBin, env.PATH ?? ''].filter(Boolean).join(delimiter) } : env;
15
+ }
16
+ const live = new Set();
17
+ /** Au signal de la vague : arrête les applications lancées (SIGTERM, puis SIGKILL après le délai). L'appelant l'attend avant de mourir. */
18
+ export async function stopApps() {
19
+ await Promise.all([...live].map((stop) => stop()));
20
+ }
21
+ function signal(pid, sig) {
22
+ try {
23
+ process.kill(-pid, sig);
24
+ return true;
25
+ }
26
+ catch {
27
+ return false;
28
+ }
29
+ }
30
+ /** Vrai quand l'URL répond en HTTP avec un statut inférieur à 500. */
31
+ export async function responds(url) {
32
+ try {
33
+ const r = await fetch(url, { signal: AbortSignal.timeout(2_000), redirect: 'manual' });
34
+ await r.body?.cancel();
35
+ return r.status < 500;
36
+ }
37
+ catch {
38
+ return false;
39
+ }
40
+ }
41
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
42
+ function tail(file) {
43
+ try {
44
+ const lines = readFileSync(file, 'utf8').trimEnd().split('\n').slice(-15).join('\n');
45
+ return lines.slice(-1500) || '(journal vide)';
46
+ }
47
+ catch {
48
+ return '(journal illisible)';
49
+ }
50
+ }
51
+ /** Verrou inter-processus de l'URL (nom : le port seul pour la machine locale, sinon hôte:port) : deux vagues (deux processus) qui déclarent la même URL ne lancent pas deux applications sur le même port. */
52
+ export function uxLockFile(url) {
53
+ const u = new URL(url);
54
+ const port = u.port || (u.protocol === 'https:' ? '443' : '80');
55
+ // localhost, 127.0.0.1 et ::1 désignent la même machine : même port, même verrou
56
+ const local = ['localhost', '127.0.0.1', '[::1]'].includes(u.hostname);
57
+ return join(tmpdir(), `cadence-ux-${local ? '' : `${u.hostname.replace(/[^\w.-]/g, '_')}-`}${port}.lock`);
58
+ }
59
+ /** Contenu du verrou : un pid, ou null quand le fichier est vide, illisible, ou rendu entre-temps. */
60
+ function readUrlLock(file) {
61
+ try {
62
+ return { text: readFileSync(file, 'utf8').trim(), age: Date.now() - statSync(file).mtimeMs };
63
+ }
64
+ catch {
65
+ return null;
66
+ }
67
+ }
68
+ /**
69
+ * Pose du verrou : lien vers un fichier complet (le verrou n'existe jamais à moitié écrit, comme `writeLock` de state.ts).
70
+ * Un verrou d'un pid mort est écarté par renommage, puis retiré seulement s'il est encore celui qu'on a lu — sinon
71
+ * il est rendu à son nouveau porteur (comme `removeStaleLock`) : deux vagues ne se volent pas le verrou.
72
+ * Faux quand un processus vivant le tient. `pid` : celui du porteur (le nôtre, sauf test).
73
+ */
74
+ export function takeUrlLock(file, pid = process.pid) {
75
+ for (let attempt = 0; attempt < 3; attempt++) {
76
+ if (linkNewFile(file, String(pid), `${pid}.${attempt}`))
77
+ return true;
78
+ const seen = readUrlLock(file);
79
+ if (!seen)
80
+ continue; // rendu entre-temps
81
+ // Un fichier vide est un verrou posé par une version qui l'écrivait en deux temps : on ne le vole qu'au bout de 5 s.
82
+ if (seen.text === '' ? seen.age < 5_000 : pidAlive(Number(seen.text)))
83
+ return false;
84
+ removeStaleFile(file, (aside) => readUrlLock(aside)?.text === seen.text);
85
+ }
86
+ return false;
87
+ }
88
+ function releaseUrlLock(file) {
89
+ try {
90
+ if (Number(readFileSync(file, 'utf8').trim()) === process.pid)
91
+ rmSync(file, { force: true });
92
+ }
93
+ catch {
94
+ /* déjà rendu */
95
+ }
96
+ }
97
+ /**
98
+ * Lance l'application de la revue UX : prend le verrou de l'URL (attente comptée dans le délai), sonde l'URL (qui répond
99
+ * déjà : rien n'est lancé), lance la commande depuis `cwd` dans son propre groupe de processus (sortie dans `log`),
100
+ * puis sonde jusqu'à une réponse HTTP < 500 — le groupe lancé encore vivant — ou au délai. Le verrou est rendu à `stop`.
101
+ */
102
+ export async function startApp(o) {
103
+ const none = async () => { };
104
+ const deadline = Date.now() + o.timeoutMs;
105
+ const lock = uxLockFile(o.url);
106
+ const held = { state: { kind: 'unverified', cause: `url tenue par une autre vague (${o.url})` }, stop: none };
107
+ try {
108
+ while (!takeUrlLock(lock)) {
109
+ if (Date.now() >= deadline)
110
+ return held;
111
+ await sleep(o.every ?? 500);
112
+ }
113
+ }
114
+ catch (e) {
115
+ // Verrou impossible à poser (pas de liens physiques…) : comme un journal illisible, la revue UX n'a pas lieu, la cause est dite.
116
+ return { state: { kind: 'unverified', cause: lockFault(lock, e) }, stop: none };
117
+ }
118
+ const release = () => releaseUrlLock(lock);
119
+ if (Date.now() >= deadline) {
120
+ release(); // obtenu après le délai : toute l'attente a été passée à celle du verrou, il ne reste rien pour lancer l'application
121
+ return held;
122
+ }
123
+ try {
124
+ return await launch(o, deadline, release);
125
+ }
126
+ catch (e) {
127
+ release();
128
+ throw e;
129
+ }
130
+ }
131
+ /** `release` rend le verrou : dès que rien ne tourne (busy, échec), sinon à la fin de `stop`, une fois le groupe disparu. */
132
+ async function launch(o, deadline, release) {
133
+ const none = async () => { };
134
+ const available = Math.max(0, deadline - Date.now()); // le délai de l'étape moins l'attente du verrou : c'est lui que le message annonce
135
+ if (await responds(o.url)) {
136
+ release();
137
+ return { state: { kind: 'busy' }, stop: none };
138
+ }
139
+ let fd;
140
+ try {
141
+ fd = openSync(o.log, 'a');
142
+ }
143
+ catch (e) {
144
+ release();
145
+ return { state: { kind: 'unverified', cause: `journal ${o.log} : ${e.message}` }, stop: none };
146
+ }
147
+ const child = spawn('sh', ['-c', o.command], { cwd: o.cwd, env: appEnv(o.nodeBin), stdio: ['ignore', fd, fd], detached: true });
148
+ closeSync(fd);
149
+ const pid = child.pid;
150
+ if (pid === undefined) {
151
+ const cause = await new Promise((r) => child.once('error', (e) => r(e.message)));
152
+ release();
153
+ return { state: { kind: 'unverified', cause: `lancement impossible : ${cause}` }, stop: none };
154
+ }
155
+ const gone = {};
156
+ child.once('error', (e) => (gone.why = `lancement impossible : ${e.message}`));
157
+ child.once('close', (code, sig) => (gone.why = `la commande s'est arrêtée (${sig ?? `code ${code}`})`));
158
+ const stop = async () => {
159
+ live.delete(stop);
160
+ try {
161
+ if (!signal(pid, 'SIGTERM'))
162
+ return;
163
+ const limit = Date.now() + (o.killAfterMs ?? APP_KILL_AFTER_MS);
164
+ while (Date.now() < limit && signal(pid, 0))
165
+ await sleep(50);
166
+ signal(pid, 'SIGKILL');
167
+ for (let i = 0; i < 40 && signal(pid, 0); i++)
168
+ await sleep(25); // le SIGKILL n'est pas instantané : le groupe disparu, pas seulement signalé
169
+ }
170
+ finally {
171
+ release(); // après l'arrêt de l'application seulement : l'autre vague ne trouve pas un port encore tenu
172
+ }
173
+ };
174
+ live.add(stop);
175
+ for (;;) {
176
+ if (await responds(o.url)) {
177
+ if (signal(pid, 0))
178
+ return { state: { kind: 'ready' }, stop };
179
+ gone.why ??= 'la commande s\'est arrêtée alors que l\'URL répond (autre processus ?)'; // la réponse n'est pas la nôtre
180
+ break;
181
+ }
182
+ if (gone.why || Date.now() >= deadline)
183
+ break;
184
+ await sleep(o.every ?? 500);
185
+ }
186
+ const why = gone.why ?? `l'URL n'a pas répondu en ${Math.round(available / 1000)} s`;
187
+ await stop();
188
+ return { state: { kind: 'unverified', cause: `${why}. Fin du journal :\n${tail(o.log)}` }, stop: none };
189
+ }
@@ -4,7 +4,23 @@ import { fileURLToPath } from 'node:url';
4
4
  import { RafError } from '../plan.js';
5
5
  /** Gabarits livrés avec le paquet (à la racine, à côté de dist/ et src/). */
6
6
  export const TEMPLATES_DIR = fileURLToPath(new URL('../../templates/orchestrate', import.meta.url));
7
+ /** Consigne d'implémentation d'un lot `visible` : son entrée Nouveautés (convention de `cadence news`, voir le README). */
8
+ export function newsText(lot, captureDir) {
9
+ return [
10
+ `This lot is visible: write its News entry. Run \`cadence news new ${lot}\` (it creates \`docs/nouveautes/<date>-<slug>.md\`), then write the text in terms of what changes for the user, factual (what the user sees or can now do, no implementation detail, no promise).`,
11
+ `The entry's title is user-side: \`cadence news new\` takes the lot's public title when it has one; otherwise rewrite the title in the user's words with \`--title\` (never the internal title of the lot).`,
12
+ captureDir
13
+ ? `Add a screenshot of the result: copy it into \`docs/nouveautes/captures/\` and list it under \`captures:\` (a Playwright capture lands in the wave's output directory, outside the repository: \`${captureDir}\`, which this session may read; the file you keep for the entry is the one exception, copied into \`docs/nouveautes/captures/\`); if a screenshot makes no sense (or the app cannot be run), write \`nocapture: <reason>\` instead.`
14
+ : `Add a screenshot of the result: save it under the OS temp directory (never inside the repository), then copy it into \`docs/nouveautes/captures/\` and list it under \`captures:\` (the file you keep for the entry is the one exception to "nothing inside the repository"); if a screenshot makes no sense (or the app cannot be run), write \`nocapture: <reason>\` instead.`,
15
+ 'Commit the entry with the lot (`cadence news check` must pass).',
16
+ ].join('\n');
17
+ }
18
+ /** Consigne des briefs de correction d'un lot `visible` : où la vague range les captures Playwright (même dossier que l'étape implement). */
19
+ export function capturesText(captureDir) {
20
+ return `Playwright captures of this wave land in \`${captureDir}\` (outside the repository; this session may read it): a screenshot a finding asks for is copied from there into \`docs/nouveautes/captures/\`.`;
21
+ }
7
22
  const FILES = {
23
+ precheck: 'precheck.md',
8
24
  implement: 'implement.md',
9
25
  fix: 'fix.md',
10
26
  'fix-minors': 'fix-minors.md',