@sylad/cadence 0.7.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +274 -17
- package/agents/qa-reviewer.md +15 -6
- package/bin/cadence.js +6 -1
- package/dist/audit.js +41 -19
- package/dist/check.js +5 -2
- package/dist/clean.js +225 -0
- package/dist/cli.js +113 -12
- package/dist/config.js +82 -6
- package/dist/deliver.js +83 -32
- package/dist/git.js +12 -2
- package/dist/link.js +15 -1
- package/dist/orchestrate/briefs.js +52 -0
- package/dist/orchestrate/command.js +498 -0
- package/dist/orchestrate/cycle.js +673 -0
- package/dist/orchestrate/guard.js +131 -0
- package/dist/orchestrate/launch.js +221 -0
- package/dist/orchestrate/lock.js +85 -0
- package/dist/orchestrate/pool.js +51 -0
- package/dist/orchestrate/result.js +94 -0
- package/dist/orchestrate/schemas.js +48 -0
- package/dist/orchestrate/state.js +93 -0
- package/dist/orchestrate/table.js +73 -0
- package/dist/plan.js +61 -2
- package/dist/proc.js +260 -0
- package/dist/recurring.js +19 -0
- package/dist/schedule.js +4 -1
- package/dist/session.js +18 -1
- package/dist/verify.js +114 -0
- package/package.json +18 -3
- package/skills/lead/SKILL.md +33 -13
- package/skills/session-close/SKILL.md +25 -4
- package/skills/session-start/SKILL.md +3 -0
- package/templates/orchestrate/fix-minors.md +18 -0
- package/templates/orchestrate/fix.md +16 -0
- package/templates/orchestrate/implement.md +20 -0
- package/templates/orchestrate/review-recheck.md +11 -0
- package/templates/orchestrate/review-small.md +11 -0
- package/templates/orchestrate/review.md +8 -0
- package/templates/orchestrate/ux.md +10 -0
package/dist/deliver.js
CHANGED
|
@@ -1,11 +1,16 @@
|
|
|
1
|
-
import { execFileSync,
|
|
1
|
+
import { execFileSync, spawn } from 'node:child_process';
|
|
2
|
+
import { constants } from 'node:os';
|
|
2
3
|
import { parse } from 'yaml';
|
|
4
|
+
import { onTermination, readProcs, SIGNAL_GRACE_MS, TreeTracker } from './proc.js';
|
|
5
|
+
import { isPlanOnly } from './audit.js';
|
|
3
6
|
import { headSha, isAncestor, onRemote, readCommits, repoStatus, resolveCommit } from './git.js';
|
|
7
|
+
import { citedRefs } from './link.js';
|
|
4
8
|
import { RafError } from './plan.js';
|
|
5
9
|
import { appendDelivery, lastDelivery, lockAlive, lockPath, readLock, releaseLock, removeStaleLock, writeLock } from './state.js';
|
|
6
10
|
const POLL_CI = 15_000;
|
|
7
11
|
const CI_APPEAR = 300_000;
|
|
8
|
-
|
|
12
|
+
/** Intervalle de réessai d'une vérification : celui de deliver ET de `cadence verify --retry`. */
|
|
13
|
+
export const POLL_VERIFY = 10_000;
|
|
9
14
|
const CI_OK = new Set(['success', 'skipped', 'neutral']);
|
|
10
15
|
const GH_TIMEOUT = 60_000;
|
|
11
16
|
export const TIMED_OUT = 124;
|
|
@@ -90,8 +95,8 @@ export function parseDeliverConfig(text, file) {
|
|
|
90
95
|
deployTimeout: seconds('deployTimeout', 1800),
|
|
91
96
|
};
|
|
92
97
|
}
|
|
93
|
-
/** Dépendances réelles : sh, gh, fetch, horloge. */
|
|
94
|
-
export function realDeps(root) {
|
|
98
|
+
/** Dépendances réelles : sh, gh, fetch, horloge. `read` : le relevé des processus du suivi (injectable pour les tests). */
|
|
99
|
+
export function realDeps(root, read = readProcs) {
|
|
95
100
|
const gh = (args) => {
|
|
96
101
|
try {
|
|
97
102
|
return execFileSync('gh', args, { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: GH_TIMEOUT });
|
|
@@ -106,18 +111,51 @@ export function realDeps(root) {
|
|
|
106
111
|
}
|
|
107
112
|
};
|
|
108
113
|
return {
|
|
109
|
-
exec: (cmd, env, timeoutMs) => {
|
|
110
|
-
const
|
|
114
|
+
exec: (cmd, env, timeoutMs) => new Promise((resolve) => {
|
|
115
|
+
const child = spawn('sh', ['-c', cmd], {
|
|
111
116
|
cwd: root,
|
|
112
117
|
env: { ...process.env, ...env },
|
|
113
118
|
stdio: ['ignore', 'inherit', 'inherit'],
|
|
114
|
-
|
|
115
|
-
|
|
119
|
+
// PAS de groupe détaché : la commande reste dans le groupe de premier plan et la session de cadence.
|
|
120
|
+
// Ctrl-C et le raccrochage l'atteignent avec cadence, et /dev/tty reste là pour ssh, sudo, pinentry.
|
|
116
121
|
});
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
122
|
+
const pid = child.pid;
|
|
123
|
+
if (pid === undefined) {
|
|
124
|
+
child.once('error', () => resolve(127));
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
// L'arbre de la commande est suivi pendant toute son exécution (relevé périodique, pid + heure de
|
|
128
|
+
// démarrage) : un enfant dont le parent meurt reste connu. Au délai, ou si cadence est tué (Ctrl-C,
|
|
129
|
+
// SIGTERM, raccrochage), TOUS les processus suivis encore vivants meurent avant que cadence ne rende la
|
|
130
|
+
// main ou ne meure — sh fait un fork par commande, et un descendant survivant livrerait encore pendant
|
|
131
|
+
// qu'une seconde livraison prend le verrou libéré ou périmé. Ctrl-C et raccrochage : l'arbre a déjà
|
|
132
|
+
// reçu le signal du terminal, un court délai de grâce laisse finir ses trap (et git son index.lock)
|
|
133
|
+
// avant le kill ; SIGTERM (à cadence seul) et le délai : kill immédiat.
|
|
134
|
+
const tree = new TreeTracker(pid, read, () => process.stderr.write('deliver : relevé des processus indisponible — le kill n\'est dégradé que tant que le relevé échoue : seule la commande racine sera tuée, ses descendants survivront\n'), () => process.stderr.write('deliver : kill replié sur la commande racine faute de relevé des processus — des descendants ont pu survivre\n'));
|
|
135
|
+
// Terminaison en cours : exec ne rend pas la main avant qu'elle ne soit finie — sinon deliver libère
|
|
136
|
+
// le verrou pendant la grâce, alors que des descendants tournent encore.
|
|
137
|
+
let ending = null;
|
|
138
|
+
const forget = onTermination((sig) => (ending = sig === 'SIGTERM' ? Promise.resolve(tree.kill()) : tree.end(SIGNAL_GRACE_MS)));
|
|
139
|
+
let timedOut = false;
|
|
140
|
+
const timer = setTimeout(() => {
|
|
141
|
+
timedOut = true;
|
|
142
|
+
tree.kill();
|
|
143
|
+
}, Math.max(1_000, timeoutMs));
|
|
144
|
+
child.once('exit', (code, signal) => {
|
|
145
|
+
clearTimeout(timer);
|
|
146
|
+
tree.rootExited(); // son pid peut être repris : plus d'adoption tardive de la racine pendant la grâce
|
|
147
|
+
const result = timedOut ? TIMED_OUT : signal ? 128 + (constants.signals[signal] ?? 0) : (code ?? 1);
|
|
148
|
+
const settle = () => {
|
|
149
|
+
tree.stop();
|
|
150
|
+
forget();
|
|
151
|
+
resolve(result);
|
|
152
|
+
};
|
|
153
|
+
if (ending)
|
|
154
|
+
void ending.then(settle);
|
|
155
|
+
else
|
|
156
|
+
settle();
|
|
157
|
+
});
|
|
158
|
+
}),
|
|
121
159
|
gh: (sha) => JSON.parse(gh(['run', 'list', '--commit', sha, '--json', 'name,status,conclusion'])),
|
|
122
160
|
ghReady: () => {
|
|
123
161
|
try {
|
|
@@ -128,8 +166,8 @@ export function realDeps(root) {
|
|
|
128
166
|
return e.message;
|
|
129
167
|
}
|
|
130
168
|
},
|
|
131
|
-
fetch: async (url) => {
|
|
132
|
-
const res = await fetch(url, { redirect: 'follow', signal: AbortSignal.timeout(20_000) });
|
|
169
|
+
fetch: async (url, timeoutMs = 20_000) => {
|
|
170
|
+
const res = await fetch(url, { redirect: 'follow', signal: AbortSignal.timeout(Math.max(1_000, Math.min(20_000, timeoutMs))) });
|
|
133
171
|
return { status: res.status, text: await res.text() };
|
|
134
172
|
},
|
|
135
173
|
sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
@@ -143,7 +181,7 @@ function substitute(text, sha) {
|
|
|
143
181
|
function shellQuote(arg) {
|
|
144
182
|
return /^[A-Za-z0-9_@%+=:,./-]+$/.test(arg) ? arg : `'${arg.replaceAll("'", `'\\''`)}'`;
|
|
145
183
|
}
|
|
146
|
-
function describeCheck(c, sha) {
|
|
184
|
+
export function describeCheck(c, sha) {
|
|
147
185
|
if (c.command !== undefined)
|
|
148
186
|
return c.command;
|
|
149
187
|
return `GET ${substitute(c.url, sha)} → ${c.status ?? 200}${c.contains === undefined ? '' : `, contient « ${substitute(c.contains, sha)} »`}`;
|
|
@@ -217,7 +255,7 @@ export async function deliver(ctx, deps) {
|
|
|
217
255
|
out(`Livraison de ${env.CADENCE_SHORT} (${branch})`);
|
|
218
256
|
if (script !== null) {
|
|
219
257
|
out(`→ script du projet : ${script}`);
|
|
220
|
-
const code = deps.exec(script, env, config.deployTimeout * 1000);
|
|
258
|
+
const code = await deps.exec(script, env, config.deployTimeout * 1000);
|
|
221
259
|
if (code !== 0)
|
|
222
260
|
return fail(`script de livraison en échec (${codeText(code)}) : ${script}`);
|
|
223
261
|
}
|
|
@@ -227,7 +265,7 @@ export async function deliver(ctx, deps) {
|
|
|
227
265
|
return fail(ci);
|
|
228
266
|
for (const [i, cmd] of config.deploy.entries()) {
|
|
229
267
|
out(`→ déploiement ${i + 1}/${config.deploy.length} : ${cmd}`);
|
|
230
|
-
const code = deps.exec(cmd, env, config.deployTimeout * 1000);
|
|
268
|
+
const code = await deps.exec(cmd, env, config.deployTimeout * 1000);
|
|
231
269
|
if (code !== 0)
|
|
232
270
|
return fail(`déploiement en échec (${codeText(code)}) : ${cmd}`);
|
|
233
271
|
}
|
|
@@ -261,7 +299,7 @@ async function waitCi(ctx, deps, sha, env) {
|
|
|
261
299
|
return null;
|
|
262
300
|
if (typeof ci === 'object') {
|
|
263
301
|
ctx.out(`→ CI : ${ci.command}`);
|
|
264
|
-
const code = deps.exec(ci.command, env, ciTimeout * 1000);
|
|
302
|
+
const code = await deps.exec(ci.command, env, ciTimeout * 1000);
|
|
265
303
|
return code === 0 ? null : `CI en échec (${codeText(code)}) : ${ci.command}`;
|
|
266
304
|
}
|
|
267
305
|
ctx.out(`→ CI : attente des runs GitHub de ${sha.slice(0, 7)}`);
|
|
@@ -306,13 +344,14 @@ async function waitCi(ctx, deps, sha, env) {
|
|
|
306
344
|
await deps.sleep(POLL_CI);
|
|
307
345
|
}
|
|
308
346
|
}
|
|
309
|
-
|
|
347
|
+
/** Un essai d'une vérification : cause de l'échec, ou null. Partagé par deliver et `cadence verify`. */
|
|
348
|
+
export async function tryCheck(c, deps, sha, env, budgetMs) {
|
|
310
349
|
if (c.command !== undefined) {
|
|
311
|
-
const code = deps.exec(c.command, env, budgetMs);
|
|
350
|
+
const code = await deps.exec(c.command, env, budgetMs);
|
|
312
351
|
return code === 0 ? null : codeText(code);
|
|
313
352
|
}
|
|
314
353
|
try {
|
|
315
|
-
const res = await deps.fetch(substitute(c.url, sha));
|
|
354
|
+
const res = await deps.fetch(substitute(c.url, sha), budgetMs);
|
|
316
355
|
const want = c.status ?? 200;
|
|
317
356
|
if (res.status !== want)
|
|
318
357
|
return `statut ${res.status} (attendu ${want})`;
|
|
@@ -324,22 +363,32 @@ async function tryCheck(c, deps, sha, env, budgetMs) {
|
|
|
324
363
|
return `erreur réseau : ${e.message}`;
|
|
325
364
|
}
|
|
326
365
|
}
|
|
366
|
+
/**
|
|
367
|
+
* UNE boucle de réessai, pour deliver et pour `cadence verify` : essaie, puis réessaie toutes les POLL_VERIFY
|
|
368
|
+
* tant que `until` n'est pas atteint. `attemptMs()` donne le délai de chaque essai. Rend la dernière cause
|
|
369
|
+
* d'échec, ou null dès que l'effet est celui attendu.
|
|
370
|
+
*/
|
|
371
|
+
export async function retryCheck(c, deps, sha, env, until, attemptMs) {
|
|
372
|
+
for (;;) {
|
|
373
|
+
const reason = await tryCheck(c, deps, sha, env, attemptMs());
|
|
374
|
+
if (reason === null)
|
|
375
|
+
return null;
|
|
376
|
+
const left = until - deps.now();
|
|
377
|
+
if (left <= 0)
|
|
378
|
+
return reason;
|
|
379
|
+
// jamais au-delà du délai : une commande tuée « à l'échéance » peut rendre la main un rien avant elle
|
|
380
|
+
await deps.sleep(Math.min(POLL_VERIFY, left));
|
|
381
|
+
}
|
|
382
|
+
}
|
|
327
383
|
/** Chaque vérification est réessayée jusqu'au délai commun ; message d'échec ou null. */
|
|
328
384
|
async function verifyAll(ctx, deps, sha, env) {
|
|
329
385
|
const deadline = deps.now() + ctx.config.verifyTimeout * 1000;
|
|
330
386
|
for (const [i, c] of ctx.config.verify.entries()) {
|
|
331
387
|
const label = describeCheck(c, sha);
|
|
332
388
|
ctx.out(`→ vérification ${i + 1}/${ctx.config.verify.length} : ${label}`);
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
break;
|
|
337
|
-
const left = deadline - deps.now();
|
|
338
|
-
if (left <= 0)
|
|
339
|
-
return `vérification en échec après ${ctx.config.verifyTimeout} s : ${label} — ${reason}`;
|
|
340
|
-
// jamais au-delà du délai : une commande tuée « à l'échéance » peut rendre la main un rien avant elle
|
|
341
|
-
await deps.sleep(Math.min(POLL_VERIFY, left));
|
|
342
|
-
}
|
|
389
|
+
const reason = await retryCheck(c, deps, sha, env, deadline, () => deadline - deps.now());
|
|
390
|
+
if (reason !== null)
|
|
391
|
+
return `vérification en échec après ${ctx.config.verifyTimeout} s : ${label} — ${reason}`;
|
|
343
392
|
}
|
|
344
393
|
return null;
|
|
345
394
|
}
|
|
@@ -361,7 +410,9 @@ function deliveredLots(ctx, prev, sha) {
|
|
|
361
410
|
const known = new Set((ctx.plan.readonly ? lots.filter((l) => l.status === 'doing') : lots).map((l) => l.id));
|
|
362
411
|
const ids = new Set();
|
|
363
412
|
for (const c of readCommits(ctx.root, { range: `${prev}..${sha}` })) {
|
|
364
|
-
|
|
413
|
+
if (isPlanOnly(c.sha, ctx.plan, ctx.root))
|
|
414
|
+
continue; // entretien du plan : ne livre rien, même s'il cite des lots
|
|
415
|
+
for (const r of citedRefs(c, ctx.plan.refs))
|
|
365
416
|
if (known.has(r.lot))
|
|
366
417
|
ids.add(r.lot);
|
|
367
418
|
}
|
package/dist/git.js
CHANGED
|
@@ -45,9 +45,19 @@ export function hooksDir(cwd) {
|
|
|
45
45
|
const dir = resolve(cwd, git(cwd, ['rev-parse', '--git-path', 'hooks']).trim());
|
|
46
46
|
return basename(dir) === '_' && basename(dirname(dir)) === '.husky' ? dirname(dir) : dir;
|
|
47
47
|
}
|
|
48
|
-
/** Fichiers modifiés par un commit, relatifs à la racine du dépôt. */
|
|
48
|
+
/** Fichiers modifiés par un commit, relatifs à la racine du dépôt (noms accentués ou à espaces compris). */
|
|
49
49
|
export function changedFiles(cwd, sha) {
|
|
50
|
-
|
|
50
|
+
// -z : noms séparés par NUL, jamais échappés ; quotepath=off en plus pour les sorties qui citeraient quand même.
|
|
51
|
+
return git(cwd, ['-c', 'core.quotepath=off', 'diff-tree', '--root', '--no-commit-id', '--name-only', '-r', '-z', sha]).split('\0').filter(Boolean);
|
|
52
|
+
}
|
|
53
|
+
/** Contenu d'un fichier à un commit (`<sha>^` pour l'état d'avant) ; null s'il n'existe pas à ce commit. */
|
|
54
|
+
export function fileAt(cwd, rev, file) {
|
|
55
|
+
try {
|
|
56
|
+
return git(cwd, ['show', `${rev}:${file}`]);
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
51
61
|
}
|
|
52
62
|
function tryGit(cwd, args) {
|
|
53
63
|
try {
|
package/dist/link.js
CHANGED
|
@@ -1,6 +1,20 @@
|
|
|
1
1
|
export function isMerge(c) {
|
|
2
2
|
return /^Merge\b/.test(c.subject);
|
|
3
3
|
}
|
|
4
|
+
/** Portée d'un sujet « type(L24,L25)! : … » ; null sans parenthèses. */
|
|
5
|
+
function scopeOf(subject) {
|
|
6
|
+
return /^[\w-]+\(([^)]*)\)!?\s*:/.exec(subject)?.[1] ?? null;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Références qui décident à quels lots appartient un commit : celles de la portée « type(L24) » quand elle
|
|
10
|
+
* en cite ; sinon, celles du message entier. Les mentions en passage (« page équipe (L27) »), les plages
|
|
11
|
+
* (« L28–L31 ») et le corps du message ne comptent donc pas dès que la portée désigne les lots.
|
|
12
|
+
*/
|
|
13
|
+
export function citedRefs(c, refsOf) {
|
|
14
|
+
const scope = scopeOf(c.subject);
|
|
15
|
+
const scoped = scope === null ? [] : refsOf(scope);
|
|
16
|
+
return scoped.length > 0 ? scoped : refsOf(`${c.subject}\n${c.body}`);
|
|
17
|
+
}
|
|
4
18
|
/** `refs` lit les références d'un message : `plan.refs`. */
|
|
5
19
|
export function linkCommits(lots, commits, refsOf) {
|
|
6
20
|
const ids = new Set(lots.map((l) => l.id));
|
|
@@ -9,7 +23,7 @@ export function linkCommits(lots, commits, refsOf) {
|
|
|
9
23
|
const orphans = [];
|
|
10
24
|
const unknown = [];
|
|
11
25
|
for (const c of commits) {
|
|
12
|
-
const refs =
|
|
26
|
+
const refs = citedRefs(c, refsOf);
|
|
13
27
|
if (refs.length === 0) {
|
|
14
28
|
if (!isMerge(c))
|
|
15
29
|
orphans.push(c);
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { RafError } from '../plan.js';
|
|
5
|
+
/** Gabarits livrés avec le paquet (à la racine, à côté de dist/ et src/). */
|
|
6
|
+
export const TEMPLATES_DIR = fileURLToPath(new URL('../../templates/orchestrate', import.meta.url));
|
|
7
|
+
const FILES = {
|
|
8
|
+
implement: 'implement.md',
|
|
9
|
+
fix: 'fix.md',
|
|
10
|
+
'fix-minors': 'fix-minors.md',
|
|
11
|
+
review: 'review.md',
|
|
12
|
+
ux: 'ux.md',
|
|
13
|
+
'review-small': 'review-small.md',
|
|
14
|
+
'review-recheck': 'review-recheck.md',
|
|
15
|
+
};
|
|
16
|
+
/** Lit tous les gabarits du dossier. Un gabarit absent est une erreur, avant que quoi que ce soit ne parte. */
|
|
17
|
+
export function loadTemplates(dir = TEMPLATES_DIR) {
|
|
18
|
+
const out = {};
|
|
19
|
+
for (const [name, file] of Object.entries(FILES)) {
|
|
20
|
+
try {
|
|
21
|
+
out[name] = readFileSync(join(dir, file), 'utf8');
|
|
22
|
+
}
|
|
23
|
+
catch (e) {
|
|
24
|
+
throw new RafError(`gabarit ${file} illisible dans ${dir} : ${e.message}`);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return out;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Rend le gabarit d'une étape par substitution de `{{nom}}`. Un nom sans valeur est une erreur.
|
|
31
|
+
* `source` : un instantané (`loadTemplates`, ce que fait une vague) ou un dossier lu à l'appel.
|
|
32
|
+
*/
|
|
33
|
+
export function renderBrief(kind, vars, source = TEMPLATES_DIR) {
|
|
34
|
+
const text = typeof source === 'string' ? loadTemplates(source)[kind] : source[kind];
|
|
35
|
+
const out = text.replace(/\{\{(\w+)\}\}/g, (_m, name) => {
|
|
36
|
+
const v = vars[name];
|
|
37
|
+
if (v === undefined)
|
|
38
|
+
throw new RafError(`gabarit ${FILES[kind]} : valeur manquante pour {{${name}}}`);
|
|
39
|
+
return v;
|
|
40
|
+
});
|
|
41
|
+
// Un marqueur vide ne laisse pas de trou : lignes vides en double ramenées à une.
|
|
42
|
+
return `${out.replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim()}\n`;
|
|
43
|
+
}
|
|
44
|
+
/** `{{objectif}}` : le titre du lot, ses notes et ses sous-tâches — Sylvain n'écrit rien d'autre. */
|
|
45
|
+
export function objective(lot) {
|
|
46
|
+
const lines = [`${lot.title}`];
|
|
47
|
+
if (lot.notes.length)
|
|
48
|
+
lines.push('', 'Notes of the lot (decisions included):', ...lot.notes.map((n) => `- ${n.date}: ${n.text}`));
|
|
49
|
+
if (lot.tasks.length)
|
|
50
|
+
lines.push('', 'Sub-tasks:', ...lot.tasks.map((t) => `- ${t.id} [${t.status}] ${t.title}`));
|
|
51
|
+
return lines.join('\n');
|
|
52
|
+
}
|