@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
@@ -0,0 +1,131 @@
1
+ import { chmodSync, existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { execFile } from 'node:child_process';
3
+ import { appendFileSync, mkdirSync } from 'node:fs';
4
+ import { dirname, isAbsolute, join, relative } from 'node:path';
5
+ import { gitCommonDir, gitRoot, hooksDir } from '../git.js';
6
+ const MARK = '# cadence orchestrate — hook pre-push temporaire (retiré à la fin de la vague)';
7
+ const waveLine = (wave) => `# wave: ${wave}`;
8
+ const hook = (wave) => `#!/bin/sh
9
+ ${MARK}
10
+ ${waveLine(wave)}
11
+ # Une session lancée par l'orchestrateur porte CADENCE_ORCHESTRATED : elle ne pousse jamais.
12
+ if [ -n "$CADENCE_ORCHESTRATED" ]; then
13
+ echo "cadence orchestrate : push refusé pendant la vague $CADENCE_ORCHESTRATED (le lead pousse)" >&2
14
+ exit 1
15
+ fi
16
+ exit 0
17
+ `;
18
+ const hookPath = (repo) => join(hooksDir(repo), 'pre-push');
19
+ /**
20
+ * Ligne d'exclusion du hook quand `core.hooksPath` pointe dans l'arbre suivi (maritime-atlas : `.githooks`) :
21
+ * sans elle le hook temporaire serait un fichier non suivi du dépôt. null quand le hook est hors de l'arbre
22
+ * (`.git/hooks`, dossier extérieur) : rien à exclure.
23
+ */
24
+ function excludeLine(repo) {
25
+ const root = gitRoot(repo);
26
+ if (!root)
27
+ return null;
28
+ const rel = relative(root, hookPath(repo));
29
+ if (rel === '' || rel.startsWith('..') || isAbsolute(rel))
30
+ return null;
31
+ const dotGit = relative(root, gitCommonDir(repo));
32
+ if (rel === dotGit || rel.startsWith(`${dotGit}/`))
33
+ return null;
34
+ return `/${rel.split('\\').join('/')}`;
35
+ }
36
+ const excludeFile = (repo) => join(gitCommonDir(repo), 'info', 'exclude');
37
+ function addExclude(repo) {
38
+ const line = excludeLine(repo);
39
+ if (!line)
40
+ return;
41
+ const file = excludeFile(repo);
42
+ mkdirSync(join(gitCommonDir(repo), 'info'), { recursive: true });
43
+ const text = existsSync(file) ? readFileSync(file, 'utf8') : '';
44
+ if (text.split('\n').includes(line))
45
+ return;
46
+ appendFileSync(file, `${text === '' || text.endsWith('\n') ? '' : '\n'}${line}\n`);
47
+ }
48
+ function removeExclude(repo) {
49
+ const line = excludeLine(repo);
50
+ const file = excludeFile(repo);
51
+ if (!line || !existsSync(file))
52
+ return;
53
+ const lines = readFileSync(file, 'utf8').split('\n');
54
+ if (!lines.includes(line))
55
+ return;
56
+ writeFileSync(file, lines.filter((l) => l !== line).join('\n'));
57
+ }
58
+ /**
59
+ * Pose le hook pre-push temporaire. Refus (raison) quand un autre hook pre-push existe : le remplacer
60
+ * modifierait un fichier suivi (husky) ou sauterait une règle du projet. Un hook laissé par une vague
61
+ * interrompue (même marque) est simplement remplacé.
62
+ */
63
+ export function installPrePush(repo, wave = '?') {
64
+ const file = hookPath(repo);
65
+ if (existsSync(file) && !readFileSync(file, 'utf8').includes(MARK)) {
66
+ return { ok: false, reason: `un hook pre-push existe déjà (${file}) : l'orchestrateur ne le remplace pas` };
67
+ }
68
+ mkdirSync(dirname(file), { recursive: true });
69
+ addExclude(repo);
70
+ writeFileSync(file, hook(wave));
71
+ chmodSync(file, 0o755);
72
+ return { ok: true };
73
+ }
74
+ /**
75
+ * Retire le hook s'il est le nôtre ; avec `wave`, seulement s'il a été posé par cette vague (jamais celui d'une
76
+ * autre vague vivante, qui garde aussi sa ligne d'exclusion). Hook supprimé ou remplacé par une session : la ligne
77
+ * d'exclusion part quand même, sinon un futur pre-push du projet resterait invisible à git.
78
+ */
79
+ export function removePrePush(repo, wave) {
80
+ const file = hookPath(repo);
81
+ if (existsSync(file)) {
82
+ const text = readFileSync(file, 'utf8');
83
+ if (text.includes(MARK)) {
84
+ if (wave !== undefined && !text.split('\n').includes(waveLine(wave)))
85
+ return;
86
+ rmSync(file, { force: true });
87
+ }
88
+ }
89
+ removeExclude(repo);
90
+ }
91
+ /** Pourrait-on poser le hook ? (préconditions, sans écrire) */
92
+ export function canInstallPrePush(repo) {
93
+ const file = hookPath(repo);
94
+ return existsSync(file) && !readFileSync(file, 'utf8').includes(MARK) ? `un hook pre-push existe déjà (${file}) : l'orchestrateur ne le remplace pas` : null;
95
+ }
96
+ /** git sans bloquer la boucle d'événements (un `ls-remote` peut durer 20 s) ; null en cas d'échec ou de délai. */
97
+ function out(repo, args, timeout = 20_000) {
98
+ return new Promise((resolve) => {
99
+ // trimEnd seulement : la première colonne d'un `status --porcelain` peut être une espace.
100
+ execFile('git', args, { cwd: repo, encoding: 'utf8', timeout, maxBuffer: 1024 * 1024 * 1024 }, (err, stdout) => resolve(err ? null : stdout.trimEnd()));
101
+ });
102
+ }
103
+ /** Le hook temporaire (reconnu à sa marque) est-il en place ? */
104
+ export function guardPresent(repo) {
105
+ const file = hookPath(repo);
106
+ return existsSync(file) && readFileSync(file, 'utf8').includes(MARK);
107
+ }
108
+ export async function snapshot(repo, opts = {}) {
109
+ const [status, head, upstream, remote] = await Promise.all([
110
+ out(repo, ['status', '--porcelain', '--untracked-files=all']),
111
+ out(repo, ['rev-parse', '--verify', '-q', 'HEAD']),
112
+ out(repo, ['rev-parse', '-q', '--verify', '@{u}']),
113
+ opts.remote === false ? Promise.resolve(null) : out(repo, ['ls-remote', '--heads']),
114
+ ]);
115
+ const lines = (status ?? '').split('\n').filter(Boolean);
116
+ const keep = (l) => !/^.. \.cadence\//.test(l) && !/^\?\? \.cadence\//.test(l);
117
+ return {
118
+ head: head?.trim() || null,
119
+ tracked: lines.filter((l) => !l.startsWith('??') && keep(l)),
120
+ untracked: lines.filter((l) => l.startsWith('??') && keep(l)).map((l) => l.slice(3)),
121
+ upstream,
122
+ guard: guardPresent(repo),
123
+ remote,
124
+ };
125
+ }
126
+ /** Écart de la référence amont ou du dépôt distant depuis `before` : un push est parti. */
127
+ export function pushed(before, after) {
128
+ if (before.upstream !== after.upstream)
129
+ return true;
130
+ return before.remote !== null && after.remote !== null && before.remote !== after.remote;
131
+ }
@@ -0,0 +1,221 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { parse } from 'yaml';
5
+ import { RafError } from '../plan.js';
6
+ import { isQuotaMessage, lacksStructuredOutput, parseSession, salvageUsage, sumTokens, tokensOf } from './result.js';
7
+ /** Outils interdits à toute session : pas de sous-agent, pas de push, de livraison ni de verdict. */
8
+ export const DISALLOWED = ['Agent', 'Bash(git push:*)', 'Bash(cadence deliver:*)', 'Bash(raf done:*)', 'Bash(raf review:*)', 'Bash(raf ux:*)'];
9
+ export function buildArgs(spec, agents) {
10
+ const args = ['-p', spec.brief, '--output-format', 'json', '--json-schema', JSON.stringify(spec.schema), '--model', spec.model];
11
+ if (spec.agent) {
12
+ if (!agents[spec.agent])
13
+ throw new RafError(`agent introuvable dans le paquet : ${spec.agent}`);
14
+ args.push('--agents', JSON.stringify(agents), '--agent', spec.agent);
15
+ }
16
+ args.push('--session-id', spec.sessionId, '--permission-mode', spec.permissionMode);
17
+ for (const d of spec.addDirs)
18
+ args.push('--add-dir', d);
19
+ args.push('--disallowedTools', ...DISALLOWED);
20
+ return args;
21
+ }
22
+ /** Définitions d'agents du paquet (`agents/*.md`) au format de --agents. */
23
+ export function readAgents(dir) {
24
+ const out = {};
25
+ if (!existsSync(dir))
26
+ return out;
27
+ for (const f of readdirSync(dir).filter((n) => n.endsWith('.md'))) {
28
+ const text = readFileSync(join(dir, f), 'utf8');
29
+ const m = /^---\n([\s\S]*?)\n---\n?([\s\S]*)$/.exec(text);
30
+ if (!m)
31
+ continue;
32
+ const meta = parse(m[1]);
33
+ if (!meta?.name || !meta.description)
34
+ continue;
35
+ out[meta.name] = {
36
+ description: meta.description,
37
+ prompt: m[2].trim(),
38
+ // Outils restreints : StructuredOutput ajouté, sinon --json-schema reste sans effet (la session finit en texte).
39
+ ...(meta.tools ? { tools: [...String(meta.tools).split(',').map((t) => t.trim()).filter(Boolean), 'StructuredOutput'] } : {}),
40
+ };
41
+ }
42
+ return out;
43
+ }
44
+ /** Consigne de la relance de mise en forme : rendre le rapport déjà établi au format demandé, rien d'autre. */
45
+ export const FORMAT_RETRY_PROMPT = 'Return your report now in the required format (the JSON structure of the schema), exactly as you concluded it. Do not do any further work, do not add anything else.';
46
+ /** Arguments de la relance : même session (--resume), mêmes schéma, modèle, agent (outils restreints), mode de permission, dossiers et interdits. */
47
+ export function buildRetryArgs(spec, sessionId, agents) {
48
+ const args = ['-p', FORMAT_RETRY_PROMPT, '--output-format', 'json', '--json-schema', JSON.stringify(spec.schema), '--model', spec.model];
49
+ if (spec.agent) {
50
+ if (!agents[spec.agent])
51
+ throw new RafError(`agent introuvable dans le paquet : ${spec.agent}`);
52
+ args.push('--agents', JSON.stringify(agents), '--agent', spec.agent);
53
+ }
54
+ args.push('--resume', sessionId, '--permission-mode', spec.permissionMode);
55
+ for (const d of spec.addDirs)
56
+ args.push('--add-dir', d);
57
+ args.push('--disallowedTools', ...DISALLOWED);
58
+ return args;
59
+ }
60
+ /**
61
+ * Lance une session et range son issue : ok, quota atteint, ou échec nommé (jamais de nouvel essai ici), à une
62
+ * exception près : une session réussie qui finit en texte sans `structured_output` est relancée UNE fois, par
63
+ * `--resume` de la même session, pour qu'elle rende son rapport au format demandé. Les jetons de la relance
64
+ * s'ajoutent à ceux de la session ; toujours rien après elle, c'est l'échec habituel.
65
+ */
66
+ export async function runSession(spec, deps) {
67
+ const launch = (args) => deps.claude(args, { cwd: spec.cwd, env: { CADENCE_ORCHESTRATED: spec.wave }, timeoutMs: spec.timeoutMs, onSpawn: deps.onSpawn });
68
+ const out = await launch(buildArgs(spec, deps.agents));
69
+ const first = classify(out);
70
+ const missing = first.kind === 'failed' && !out.timedOut && out.code === 0 ? lacksStructuredOutput(out.stdout) : null;
71
+ if (!missing)
72
+ return first;
73
+ const spentFirst = first.kind === 'failed' ? first.tokens : undefined;
74
+ const second = classify(await launch(buildRetryArgs(spec, missing.sessionId, deps.agents)));
75
+ const total = (t) => (spentFirst && t ? sumTokens(spentFirst, t) : (t ?? spentFirst));
76
+ if (second.kind === 'ok')
77
+ return { kind: 'ok', result: { ...second.result, tokens: total(second.result.tokens), formattingRetry: true }, firstStdout: out.stdout };
78
+ const tokens = total(second.tokens);
79
+ return { ...second, firstStdout: out.stdout, ...(tokens ? { tokens } : {}), sessionId: second.sessionId ?? missing.sessionId };
80
+ }
81
+ function classify(out) {
82
+ // Une session en échec a consommé des tokens : ceux de sa sortie, quand elle en donne, vont au budget.
83
+ const salvaged = salvageUsage(out.stdout);
84
+ const spent = salvaged ? { tokens: salvaged.tokens, sessionId: salvaged.sessionId } : {};
85
+ const failed = (cause) => ({ kind: 'failed', cause, stdout: out.stdout, stderr: out.stderr, ...spent });
86
+ if (out.timedOut)
87
+ return failed('délai dépassé');
88
+ let result;
89
+ try {
90
+ result = parseSession(out.stdout, { structured: true });
91
+ }
92
+ catch (e) {
93
+ // Une session arrêtée par le quota peut sortir sans structure : le message reste lisible dans stdout. Une session
94
+ // RÉUSSIE sans structure n'est jamais un quota, même si son texte en parle (une revue du code de quota) : relance.
95
+ if (!lacksStructuredOutput(out.stdout) && isQuotaMessage(out.stdout))
96
+ return { kind: 'quota', message: out.stdout.trim().slice(0, 300), ...spent };
97
+ return failed(out.code !== 0 ? `code ${out.code} — ${e.message}` : e.message);
98
+ }
99
+ if (result.isError) {
100
+ if (isQuotaMessage(result.result))
101
+ return { kind: 'quota', message: result.result, result, ...spent };
102
+ return failed(`${result.subtype || 'erreur'} : ${result.result.slice(0, 300)}${out.code !== 0 ? ` (code ${out.code})` : ''}`);
103
+ }
104
+ if (out.code !== 0)
105
+ return failed(`code ${out.code}`);
106
+ return { kind: 'ok', result };
107
+ }
108
+ const live = new Set();
109
+ /** Suit un groupe de processus lancé par l'orchestrateur (commande du projet) : tué avec les sessions au signal. */
110
+ export function trackGroup(pid) {
111
+ live.add(pid);
112
+ return () => live.delete(pid);
113
+ }
114
+ /** Tue les groupes de processus des sessions en cours (Ctrl-C, SIGTERM de l'orchestrateur). */
115
+ export function killSessions() {
116
+ for (const pid of live) {
117
+ try {
118
+ process.kill(-pid, 'SIGKILL');
119
+ }
120
+ catch {
121
+ // déjà mort
122
+ }
123
+ }
124
+ live.clear();
125
+ }
126
+ /** Vrai lanceur : `claude` (ou CADENCE_CLAUDE_BIN) dans son propre groupe de processus, tué en bloc au délai. */
127
+ export function realClaude(bin, base = process.env) {
128
+ return (args, opts) => new Promise((resolve) => {
129
+ const child = spawn(bin, args, { cwd: opts.cwd, env: { ...base, ...opts.env }, stdio: ['ignore', 'pipe', 'pipe'], detached: true });
130
+ const pid = child.pid;
131
+ if (pid === undefined) {
132
+ child.once('error', (e) => resolve({ code: 127, stdout: '', stderr: String(e.message), timedOut: false }));
133
+ return;
134
+ }
135
+ live.add(pid);
136
+ opts.onSpawn?.(pid);
137
+ const out = [];
138
+ const err = [];
139
+ child.stdout.on('data', (d) => out.push(d));
140
+ child.stderr.on('data', (d) => err.push(d));
141
+ let timedOut = false;
142
+ const timer = setTimeout(() => {
143
+ timedOut = true;
144
+ try {
145
+ process.kill(-pid, 'SIGKILL');
146
+ }
147
+ catch {
148
+ // déjà mort
149
+ }
150
+ }, Math.max(1_000, opts.timeoutMs));
151
+ child.once('close', (code, signal) => {
152
+ clearTimeout(timer);
153
+ live.delete(pid);
154
+ // Un enfant resté dans le groupe ne survit pas à la session.
155
+ try {
156
+ process.kill(-pid, 'SIGKILL');
157
+ }
158
+ catch {
159
+ // groupe vide
160
+ }
161
+ resolve({ code: code ?? (signal ? 137 : 1), stdout: Buffer.concat(out).toString('utf8'), stderr: Buffer.concat(err).toString('utf8'), timedOut });
162
+ });
163
+ });
164
+ }
165
+ /** Dossier des journaux de sessions d'un répertoire : `<home>/projects/<chemin aplati>`. */
166
+ export function projectLogDir(claudeHome, cwd) {
167
+ return join(claudeHome, 'projects', cwd.replace(/[^a-zA-Z0-9]/g, '-'));
168
+ }
169
+ /** Pic de contexte d'une session relu dans son journal (entrée + écriture + lecture de cache d'un tour) ; null si absent. */
170
+ export function peakContext(claudeHome, cwd, sessionId) {
171
+ const file = join(projectLogDir(claudeHome, cwd), `${sessionId}.jsonl`);
172
+ if (!existsSync(file))
173
+ return null;
174
+ let peak = 0;
175
+ for (const line of readFileSync(file, 'utf8').split('\n')) {
176
+ try {
177
+ const u = JSON.parse(line)?.message?.usage;
178
+ if (!u)
179
+ continue;
180
+ const n = (u.input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0);
181
+ if (n > peak)
182
+ peak = n;
183
+ }
184
+ catch {
185
+ // ligne partielle
186
+ }
187
+ }
188
+ return peak;
189
+ }
190
+ /**
191
+ * Consommation d'une session relue dans son journal, par son identifiant (`--session-id`) : somme des tours (un
192
+ * message écrit sur plusieurs lignes n'est compté qu'une fois). Une session tuée (délai, signal) n'a rien rendu sur
193
+ * sa sortie. Jamais de session devinée par date : un autre journal du même dossier n'est pas le sien. Null si rien n'est lisible.
194
+ */
195
+ export function journalTokens(claudeHome, cwd, sessionId) {
196
+ const dir = projectLogDir(claudeHome, cwd);
197
+ const id = sessionId;
198
+ const file = join(dir, `${id}.jsonl`);
199
+ if (!existsSync(file))
200
+ return null;
201
+ const byMessage = new Map();
202
+ let anon = 0;
203
+ for (const line of readFileSync(file, 'utf8').split('\n')) {
204
+ try {
205
+ const m = JSON.parse(line)?.message;
206
+ const u = m?.usage;
207
+ if (!u)
208
+ continue;
209
+ byMessage.set(typeof m.id === 'string' ? m.id : `anon-${anon++}`, [u.input_tokens ?? 0, u.cache_creation_input_tokens ?? 0, u.cache_read_input_tokens ?? 0, u.output_tokens ?? 0]);
210
+ }
211
+ catch {
212
+ // ligne partielle
213
+ }
214
+ }
215
+ if (byMessage.size === 0)
216
+ return null;
217
+ const sum = [0, 0, 0, 0];
218
+ for (const v of byMessage.values())
219
+ v.forEach((n, i) => (sum[i] += n));
220
+ return { tokens: tokensOf(sum[0], sum[1], sum[2], sum[3]), sessionId: id };
221
+ }
@@ -0,0 +1,85 @@
1
+ import { linkSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import { pidAlive } from '../state.js';
4
+ /** Nom du verrou d'orchestration d'un dépôt, dans l'état commun aux worktrees (celui de `deliver`). */
5
+ export const REPO_LOCK = 'orchestrate.lock';
6
+ function read(file) {
7
+ let text;
8
+ let ageMs;
9
+ try {
10
+ ageMs = Date.now() - statSync(file).mtimeMs;
11
+ text = readFileSync(file, 'utf8');
12
+ }
13
+ catch {
14
+ return null;
15
+ }
16
+ try {
17
+ const raw = JSON.parse(text);
18
+ return { pid: Number(raw.pid), wave: String(raw.wave), started: String(raw.started), ageMs };
19
+ }
20
+ catch {
21
+ return { pid: 0, wave: '?', started: '?', unreadable: true, ageMs };
22
+ }
23
+ }
24
+ const alive = (l) => (l.unreadable ? l.ageMs < 5_000 : pidAlive(l.pid));
25
+ /** Le verrou s'il est tenu par un processus vivant, sinon null (absent ou périmé). */
26
+ export function activeLock(file) {
27
+ const l = read(file);
28
+ return l && alive(l) ? l : null;
29
+ }
30
+ /**
31
+ * Pose le verrou (lien vers un fichier complet : atomique). Un verrou de pid mort est retiré — `stale` le
32
+ * dit pour avertir. `held` : le porteur vivant quand la pose échoue.
33
+ */
34
+ export function takeLock(file, lock) {
35
+ let stale;
36
+ for (let attempt = 0; attempt < 3; attempt++) {
37
+ const tmp = join(dirname(file), `.orchestrate.lock.${lock.pid}.${Date.now()}.${attempt}.tmp`);
38
+ writeFileSync(tmp, JSON.stringify(lock));
39
+ try {
40
+ linkSync(tmp, file);
41
+ return stale ? { ok: true, stale } : { ok: true };
42
+ }
43
+ catch {
44
+ // déjà là
45
+ }
46
+ finally {
47
+ rmSync(tmp, { force: true });
48
+ }
49
+ const seen = read(file);
50
+ if (!seen)
51
+ continue;
52
+ if (alive(seen))
53
+ return { ok: false, held: seen };
54
+ // périmé : l'écarter seulement s'il est encore celui qu'on a lu
55
+ const aside = `${file}.stale.${process.pid}`;
56
+ try {
57
+ renameSync(file, aside);
58
+ }
59
+ catch {
60
+ continue;
61
+ }
62
+ const now = read(aside);
63
+ if (now && now.pid === seen.pid && now.wave === seen.wave && now.started === seen.started) {
64
+ stale = { pid: seen.pid, wave: seen.wave, started: seen.started };
65
+ rmSync(aside, { force: true });
66
+ }
67
+ else {
68
+ try {
69
+ linkSync(aside, file);
70
+ }
71
+ catch {
72
+ // repris par un tiers
73
+ }
74
+ rmSync(aside, { force: true });
75
+ }
76
+ }
77
+ const held = read(file);
78
+ return { ok: false, held: held ?? lock };
79
+ }
80
+ /** Retire le verrou s'il appartient à ce pid. */
81
+ export function releaseLock(file, pid) {
82
+ const l = read(file);
83
+ if (l && l.pid === pid)
84
+ rmSync(file, { force: true });
85
+ }
@@ -0,0 +1,51 @@
1
+ import { runLot } from './cycle.js';
2
+ import { lotKey } from './state.js';
3
+ /** Deux sessions au plus en même temps (imposé ici, pas de --parallel) : deux dépôts à la fois. */
4
+ export const SLOTS = 2;
5
+ /**
6
+ * Ordonnanceur : une file par dépôt, les lots d'un dépôt l'un après l'autre dans l'ordre donné, deux dépôts
7
+ * au plus en parallèle. Le budget, le quota et les incidents sont vérifiés par le cycle avant chaque session.
8
+ */
9
+ export async function runPool(ctxs, slots = SLOTS, known = []) {
10
+ const queues = new Map();
11
+ for (const c of ctxs)
12
+ queues.set(c.lot.repo, [...(queues.get(c.lot.repo) ?? []), c]);
13
+ const pending = [...queues.values()];
14
+ const worker = async () => {
15
+ for (let queue = pending.shift(); queue; queue = pending.shift()) {
16
+ // Un lot en attente est revisité tant qu'un tour de la file en fait avancer un autre (sa dépendance peut venir après lui).
17
+ for (let todo = queue, progress = true; todo.length && progress;) {
18
+ const waiting = [];
19
+ progress = false;
20
+ for (const c of todo) {
21
+ const state = (id) => [...ctxs.map((o) => o.lot), ...known].find((o) => o.project === c.lot.project && o.lot === id)?.status;
22
+ const missing = (c.lot.dependsOn ?? []).filter((id) => state(id) !== 'ready');
23
+ if (missing.length && c.lot.steps.length === 0 && (c.lot.status === 'queued' || c.lot.status === 'suspended')) {
24
+ // Une dépendance suspendue, en question ou pas encore jouée (reprise) : le lot attend, il reste reprenable.
25
+ // Seule une dépendance rendue au lead ou en échec le rend aussi.
26
+ const dead = missing.filter((id) => ['handed-back', 'failed'].includes(state(id) ?? 'failed'));
27
+ if (dead.length === 0) {
28
+ c.lot.outcome = `en attente de : ${missing.join(', ')}`;
29
+ c.wave.store.writeLot(c.lot);
30
+ c.wave.log(`${lotKey(c.lot.project, c.lot.lot)} — ${c.lot.outcome}`);
31
+ waiting.push(c);
32
+ continue;
33
+ }
34
+ c.lot.status = 'handed-back';
35
+ c.lot.outcome = `dépendance non prête dans la vague : ${missing.join(', ')}`;
36
+ c.wave.store.writeLot(c.lot);
37
+ c.wave.log(`${lotKey(c.lot.project, c.lot.lot)} → handed-back — ${c.lot.outcome}`);
38
+ progress = true;
39
+ continue;
40
+ }
41
+ if (c.lot.outcome?.startsWith('en attente de : '))
42
+ c.lot.outcome = null;
43
+ progress = true;
44
+ await runLot(c);
45
+ }
46
+ todo = waiting;
47
+ }
48
+ }
49
+ };
50
+ await Promise.all(Array.from({ length: Math.min(slots, pending.length) }, worker));
51
+ }
@@ -0,0 +1,94 @@
1
+ import { RafError } from '../plan.js';
2
+ const isObject = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
3
+ function field(obj, path, kind) {
4
+ let cur = obj;
5
+ for (const k of path.split('.')) {
6
+ if (!isObject(cur) || !(k in cur))
7
+ throw new RafError(`sortie de claude : champ « ${path} » absent`);
8
+ cur = cur[k];
9
+ }
10
+ if (typeof cur !== kind)
11
+ throw new RafError(`sortie de claude : champ « ${path} » de type ${typeof cur} (attendu ${kind})`);
12
+ return cur;
13
+ }
14
+ /**
15
+ * Lit la sortie `claude -p --output-format json` (un objet unique). Les noms de champs sont ceux d'un
16
+ * échantillon réel (test/fixtures/claude-result.sample.json) : un champ absent est une erreur nommée,
17
+ * jamais un zéro. `structured` : la sortie de --json-schema est attendue (sauf quand la session a échoué).
18
+ */
19
+ export function parseSession(stdout, opts) {
20
+ if (stdout.trim() === '')
21
+ throw new RafError('sortie de claude vide');
22
+ let raw;
23
+ try {
24
+ raw = JSON.parse(stdout);
25
+ }
26
+ catch {
27
+ throw new RafError('sortie de claude illisible (JSON attendu)');
28
+ }
29
+ if (!isObject(raw))
30
+ throw new RafError('sortie de claude illisible (objet attendu)');
31
+ const isError = field(raw, 'is_error', 'boolean');
32
+ const input = field(raw, 'usage.input_tokens', 'number');
33
+ const cacheWrite = field(raw, 'usage.cache_creation_input_tokens', 'number');
34
+ const cacheRead = field(raw, 'usage.cache_read_input_tokens', 'number');
35
+ const output = field(raw, 'usage.output_tokens', 'number');
36
+ const result = {
37
+ isError,
38
+ subtype: field(raw, 'subtype', 'string'),
39
+ result: typeof raw.result === 'string' ? raw.result : '',
40
+ sessionId: field(raw, 'session_id', 'string'),
41
+ numTurns: field(raw, 'num_turns', 'number'),
42
+ durationMs: field(raw, 'duration_ms', 'number'),
43
+ tokens: { input, cacheWrite, cacheRead, output, counted: input + cacheWrite + output },
44
+ };
45
+ if (!isError && opts.structured) {
46
+ if (!('structured_output' in raw))
47
+ throw new RafError('sortie de claude : champ « structured_output » absent (--json-schema ignoré ?)');
48
+ result.structured = raw.structured_output;
49
+ }
50
+ return result;
51
+ }
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
+ export function isQuotaMessage(text) {
54
+ return /usage limit|limit reached|rate limit|quota/i.test(text);
55
+ }
56
+ /** Heure de remise (epoch s) lue dans « …limit reached|1759600000 », sinon null. */
57
+ export function quotaReset(text) {
58
+ const m = /\|(\d{9,})\s*$/.exec(text.trim());
59
+ return m ? new Date(Number(m[1]) * 1000) : null;
60
+ }
61
+ const num = (v) => (typeof v === 'number' && Number.isFinite(v) && v >= 0 ? v : 0);
62
+ export const tokensOf = (input, cacheWrite, cacheRead, output) => ({ input, cacheWrite, cacheRead, output, counted: input + cacheWrite + output });
63
+ /**
64
+ * Consommation lue sans rien exiger : une session en échec (is_error, code ≠ 0, sortie incomplète) a quand même
65
+ * consommé des tokens, que le budget doit compter. Null quand la sortie n'a aucun `usage` lisible.
66
+ */
67
+ export function salvageUsage(stdout) {
68
+ let raw;
69
+ try {
70
+ raw = JSON.parse(stdout);
71
+ }
72
+ catch {
73
+ return null;
74
+ }
75
+ if (!isObject(raw) || !isObject(raw.usage))
76
+ return null;
77
+ const u = raw.usage;
78
+ const tokens = tokensOf(num(u.input_tokens), num(u.cache_creation_input_tokens), num(u.cache_read_input_tokens), num(u.output_tokens));
79
+ return { tokens, sessionId: typeof raw.session_id === 'string' ? raw.session_id : undefined };
80
+ }
81
+ /** Session réussie (pas d'erreur, `success`) qui a fini en texte sans `structured_output` : seul cas d'une relance de mise en forme. */
82
+ export function lacksStructuredOutput(stdout) {
83
+ let raw;
84
+ try {
85
+ raw = JSON.parse(stdout);
86
+ }
87
+ catch {
88
+ return null;
89
+ }
90
+ if (!isObject(raw) || raw.is_error !== false || raw.subtype !== 'success' || 'structured_output' in raw)
91
+ return null;
92
+ return typeof raw.session_id === 'string' && raw.session_id ? { sessionId: raw.session_id } : null;
93
+ }
94
+ export const sumTokens = (a, b) => tokensOf(a.input + b.input, a.cacheWrite + b.cacheWrite, a.cacheRead + b.cacheRead, a.output + b.output);
@@ -0,0 +1,48 @@
1
+ const str = { type: 'string' };
2
+ const num = { type: 'number' };
3
+ const strings = { type: 'array', items: str };
4
+ const result = { type: 'object', properties: { commande: str, resultat: str, vert: { type: 'boolean' } }, required: ['commande', 'resultat', 'vert'] };
5
+ /** Sortie d'une implémentation ou d'une correction. */
6
+ export const WORK_SCHEMA = {
7
+ type: 'object',
8
+ properties: {
9
+ commits: { type: 'array', items: { type: 'object', properties: { sha: str, sujet: str }, required: ['sha', 'sujet'] } },
10
+ tests: result,
11
+ build: result,
12
+ nonVerifie: strings,
13
+ questions: strings,
14
+ choix: strings,
15
+ resume: str,
16
+ },
17
+ required: ['commits', 'tests', 'build', 'nonVerifie', 'questions', 'resume'],
18
+ };
19
+ /** Sortie d'une revue (code, UX ou passe unique) : `verdict` est la ligne pour `raf review` / `raf ux`. */
20
+ export const REVIEW_SCHEMA = {
21
+ type: 'object',
22
+ properties: {
23
+ bloquants: num,
24
+ majeurs: num,
25
+ mineurs: num,
26
+ constats: { type: 'array', items: { type: 'object', properties: { gravite: { type: 'string', enum: ['bloquant', 'majeur', 'mineur'] }, fichier: str, ligne: num, texte: str }, required: ['gravite', 'texte'] } },
27
+ sousTaches: strings,
28
+ nonVerifie: strings,
29
+ verdict: str,
30
+ },
31
+ required: ['bloquants', 'majeurs', 'mineurs', 'constats', 'sousTaches', 'nonVerifie', 'verdict'],
32
+ };
33
+ export const schemaFor = (kind) => (kind === 'implement' || kind === 'fix' ? WORK_SCHEMA : REVIEW_SCHEMA);
34
+ /** Contrôle la forme d'une sortie structurée : un champ absent ou mal typé est une erreur nommée. */
35
+ export function checkShape(value, schema) {
36
+ if (!value || typeof value !== 'object' || Array.isArray(value))
37
+ throw new Error('sortie structurée : objet attendu');
38
+ const o = value;
39
+ for (const k of schema.required) {
40
+ if (!(k in o))
41
+ throw new Error(`sortie structurée : champ « ${k} » absent`);
42
+ const want = schema.properties[k].type;
43
+ const ok = want === 'array' ? Array.isArray(o[k]) : typeof o[k] === want;
44
+ if (!ok)
45
+ throw new Error(`sortie structurée : champ « ${k} » mal typé (attendu ${want})`);
46
+ }
47
+ return value;
48
+ }