@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.
Files changed (41) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.md +274 -17
  4. package/agents/qa-reviewer.md +15 -6
  5. package/bin/cadence.js +6 -1
  6. package/dist/audit.js +41 -19
  7. package/dist/check.js +5 -2
  8. package/dist/clean.js +225 -0
  9. package/dist/cli.js +113 -12
  10. package/dist/config.js +82 -6
  11. package/dist/deliver.js +83 -32
  12. package/dist/git.js +12 -2
  13. package/dist/link.js +15 -1
  14. package/dist/orchestrate/briefs.js +52 -0
  15. package/dist/orchestrate/command.js +498 -0
  16. package/dist/orchestrate/cycle.js +673 -0
  17. package/dist/orchestrate/guard.js +131 -0
  18. package/dist/orchestrate/launch.js +221 -0
  19. package/dist/orchestrate/lock.js +85 -0
  20. package/dist/orchestrate/pool.js +51 -0
  21. package/dist/orchestrate/result.js +94 -0
  22. package/dist/orchestrate/schemas.js +48 -0
  23. package/dist/orchestrate/state.js +93 -0
  24. package/dist/orchestrate/table.js +73 -0
  25. package/dist/plan.js +61 -2
  26. package/dist/proc.js +260 -0
  27. package/dist/recurring.js +19 -0
  28. package/dist/schedule.js +4 -1
  29. package/dist/session.js +18 -1
  30. package/dist/verify.js +114 -0
  31. package/package.json +18 -3
  32. package/skills/lead/SKILL.md +33 -13
  33. package/skills/session-close/SKILL.md +25 -4
  34. package/skills/session-start/SKILL.md +3 -0
  35. package/templates/orchestrate/fix-minors.md +18 -0
  36. package/templates/orchestrate/fix.md +16 -0
  37. package/templates/orchestrate/implement.md +20 -0
  38. package/templates/orchestrate/review-recheck.md +11 -0
  39. package/templates/orchestrate/review-small.md +11 -0
  40. package/templates/orchestrate/review.md +8 -0
  41. package/templates/orchestrate/ux.md +10 -0
package/dist/deliver.js CHANGED
@@ -1,11 +1,16 @@
1
- import { execFileSync, spawnSync } from 'node:child_process';
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
- const POLL_VERIFY = 10_000;
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 r = spawnSync('sh', ['-c', cmd], {
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
- timeout: Math.max(1_000, timeoutMs),
115
- killSignal: 'SIGKILL',
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
- if (r.error?.code === 'ETIMEDOUT' || r.signal)
118
- return TIMED_OUT;
119
- return r.status ?? 1;
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
- async function tryCheck(c, deps, sha, env, budgetMs) {
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
- for (;;) {
334
- const reason = await tryCheck(c, deps, sha, env, deadline - deps.now());
335
- if (reason === null)
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
- for (const r of ctx.plan.refs(`${c.subject}\n${c.body}`))
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
- return git(cwd, ['diff-tree', '--root', '--no-commit-id', '--name-only', '-r', sha]).split('\n').filter(Boolean);
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 = refsOf(`${c.subject}\n${c.body}`);
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
+ }