@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
|
@@ -0,0 +1,673 @@
|
|
|
1
|
+
import { execFileSync, spawn } from 'node:child_process';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { join, relative } from 'node:path';
|
|
4
|
+
import { writeFileSync } from 'node:fs';
|
|
5
|
+
import { lotCommits, lotWork } from '../audit.js';
|
|
6
|
+
import { readCommits, resolveCommit } from '../git.js';
|
|
7
|
+
import { citedRefs } from '../link.js';
|
|
8
|
+
import { isOpen } from '../plan.js';
|
|
9
|
+
import { isPlanOnly } from '../audit.js';
|
|
10
|
+
import { objective, renderBrief } from './briefs.js';
|
|
11
|
+
import { journalTokens, peakContext, runSession, trackGroup } from './launch.js';
|
|
12
|
+
import { pushed, snapshot } from './guard.js';
|
|
13
|
+
import { checkShape, REVIEW_SCHEMA, schemaFor, WORK_SCHEMA } from './schemas.js';
|
|
14
|
+
import { lotKey } from './state.js';
|
|
15
|
+
/** Passes de correction au plus, puis la main est rendue au lead. */
|
|
16
|
+
export const MAX_PASSES = 2;
|
|
17
|
+
export class Budget {
|
|
18
|
+
limit;
|
|
19
|
+
consumed = 0;
|
|
20
|
+
cacheRead = 0;
|
|
21
|
+
constructor(limit) {
|
|
22
|
+
this.limit = limit;
|
|
23
|
+
}
|
|
24
|
+
add(t) {
|
|
25
|
+
this.consumed += t.counted;
|
|
26
|
+
this.cacheRead += t.cacheRead;
|
|
27
|
+
}
|
|
28
|
+
get exhausted() {
|
|
29
|
+
return this.consumed >= this.limit;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Compte au budget les tokens d'une étape tuée (signal) ou retrouvée morte (reprise), relus dans le journal de sa
|
|
34
|
+
* session (`--session-id`). Une étape déjà comptée (`tokens`) ne l'est jamais deux fois. Vrai quand quelque chose a été compté.
|
|
35
|
+
*/
|
|
36
|
+
export function countInterrupted(claudeHome, repo, step, budget) {
|
|
37
|
+
if (!claudeHome || !step.sessionId || step.tokens)
|
|
38
|
+
return false;
|
|
39
|
+
const spent = journalTokens(claudeHome, repo, step.sessionId);
|
|
40
|
+
if (!spent)
|
|
41
|
+
return false;
|
|
42
|
+
step.tokens = spent.tokens;
|
|
43
|
+
step.peakContext = peakContext(claudeHome, repo, step.sessionId);
|
|
44
|
+
budget.add(spent.tokens);
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
const TERMINAL = new Set(['ready', 'handed-back', 'failed']);
|
|
48
|
+
const short = (c) => `${c.sha.slice(0, 7)} ${c.subject}`;
|
|
49
|
+
function git(repo, ...args) {
|
|
50
|
+
return execFileSync('git', args, { cwd: repo, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
|
|
51
|
+
}
|
|
52
|
+
function save(c) {
|
|
53
|
+
c.wave.store.writeLot(c.lot);
|
|
54
|
+
}
|
|
55
|
+
function transition(c, status, outcome) {
|
|
56
|
+
c.lot.status = status;
|
|
57
|
+
if (outcome !== undefined)
|
|
58
|
+
c.lot.outcome = outcome;
|
|
59
|
+
save(c);
|
|
60
|
+
c.wave.log(`${lotKey(c.lot.project, c.lot.lot)} → ${status}${outcome ? ` — ${outcome}` : ''}`);
|
|
61
|
+
}
|
|
62
|
+
const stop = (c, status, outcome) => {
|
|
63
|
+
c.lot.next = null;
|
|
64
|
+
transition(c, status, outcome);
|
|
65
|
+
return null;
|
|
66
|
+
};
|
|
67
|
+
/** Fichiers suivis modifiés (chemins), d'après `git status --porcelain`. */
|
|
68
|
+
function trackedPaths(s) {
|
|
69
|
+
return s.tracked.map((l) => l.slice(3).replace(/^.* -> /, '').replace(/^"|"$/g, ''));
|
|
70
|
+
}
|
|
71
|
+
/** Commit d'entretien du plan, chemins explicites. Rend un message d'arbre sale quand autre chose que le plan a bougé. */
|
|
72
|
+
async function commitPlan(c, message) {
|
|
73
|
+
const repo = c.lot.repo;
|
|
74
|
+
const plan = c.loadPlan();
|
|
75
|
+
const own = new Set([relative(repo, plan.path), ...plan.files]);
|
|
76
|
+
const dirty = trackedPaths(await snapshot(repo, { remote: false }));
|
|
77
|
+
if (dirty.length === 0)
|
|
78
|
+
return null;
|
|
79
|
+
const foreign = dirty.filter((f) => !own.has(f));
|
|
80
|
+
if (foreign.length)
|
|
81
|
+
return `arbre sale (fichiers hors plan) : ${foreign.join(', ')}`;
|
|
82
|
+
git(repo, 'add', '--', ...dirty);
|
|
83
|
+
git(repo, 'commit', '-q', '-m', message, '--', ...dirty);
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
const shEscape = (s) => s.replace(/\s+/g, ' ').replace(/[\\"$`]/g, '\\$&');
|
|
87
|
+
/** Commande du projet, asynchrone : la boucle d'événements reste libre (délai de l'autre créneau, signaux). Son groupe est tué au délai et au signal. */
|
|
88
|
+
function sh(c, command, timeoutMs = 30 * 60_000) {
|
|
89
|
+
return new Promise((resolve) => {
|
|
90
|
+
const child = spawn('sh', ['-c', command], { cwd: c.lot.repo, stdio: ['ignore', 'pipe', 'pipe'], detached: true });
|
|
91
|
+
const pid = child.pid;
|
|
92
|
+
if (pid === undefined) {
|
|
93
|
+
child.once('error', (e) => resolve({ code: 127, output: String(e.message) }));
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
const untrack = trackGroup(pid);
|
|
97
|
+
const chunks = [];
|
|
98
|
+
let size = 0;
|
|
99
|
+
const keep = (d) => {
|
|
100
|
+
if (size < 64 * 1024 * 1024)
|
|
101
|
+
chunks.push(d);
|
|
102
|
+
size += d.length;
|
|
103
|
+
};
|
|
104
|
+
child.stdout.on('data', keep);
|
|
105
|
+
child.stderr.on('data', keep);
|
|
106
|
+
const timer = setTimeout(() => {
|
|
107
|
+
try {
|
|
108
|
+
process.kill(-pid, 'SIGKILL');
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
// déjà mort
|
|
112
|
+
}
|
|
113
|
+
}, timeoutMs);
|
|
114
|
+
child.once('close', (code, signal) => {
|
|
115
|
+
clearTimeout(timer);
|
|
116
|
+
untrack();
|
|
117
|
+
try {
|
|
118
|
+
process.kill(-pid, 'SIGKILL');
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
// groupe vide
|
|
122
|
+
}
|
|
123
|
+
resolve({ code: code ?? (signal ? 137 : 1), output: Buffer.concat(chunks).toString('utf8') });
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
/** Démarre le lot : raf start (plan écrit par raf) ou la commande du projet (plan en lecture seule), puis commit du plan. */
|
|
128
|
+
async function startLot(c) {
|
|
129
|
+
const plan = c.loadPlan();
|
|
130
|
+
const lot = plan.lot(c.lot.lot);
|
|
131
|
+
if (!isOpen(lot.status))
|
|
132
|
+
return `le lot est ${lot.status}`;
|
|
133
|
+
if (lot.status === 'todo') {
|
|
134
|
+
if (plan.readonly) {
|
|
135
|
+
if (!c.config.start)
|
|
136
|
+
return 'plan en lecture seule : démarrer le lot avec l\'outil du projet';
|
|
137
|
+
const r = await sh(c, c.config.start.replaceAll('{lot}', c.lot.lot));
|
|
138
|
+
if (r.code !== 0)
|
|
139
|
+
return `orchestrate.start en échec (code ${r.code}) : ${r.output.trim().split('\n').slice(-3).join(' ')}`;
|
|
140
|
+
}
|
|
141
|
+
else {
|
|
142
|
+
plan.setStatus(c.lot.lot, 'doing', c.wave.today);
|
|
143
|
+
plan.save();
|
|
144
|
+
}
|
|
145
|
+
const dirty = await commitPlan(c, `plan: ${c.lot.lot} démarré (orchestrate ${c.wave.id})`);
|
|
146
|
+
if (dirty)
|
|
147
|
+
return dirty;
|
|
148
|
+
}
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
function lotCommitLines(c) {
|
|
152
|
+
const plan = c.loadPlan();
|
|
153
|
+
return lotWork(plan, c.lot.repo, c.lot.lot).reverse().map((k) => `- ${short(k)}`).join('\n');
|
|
154
|
+
}
|
|
155
|
+
function constatLines(constats) {
|
|
156
|
+
return constats
|
|
157
|
+
.map((k) => `- [${k.gravite}${k.source === 'code' ? '' : ` / ${k.source}`}] ${k.fichier ? `${k.fichier}${k.ligne ? `:${k.ligne}` : ''} — ` : ''}${k.texte}`)
|
|
158
|
+
.join('\n');
|
|
159
|
+
}
|
|
160
|
+
function uxText(c) {
|
|
161
|
+
const ux = c.config.ux;
|
|
162
|
+
if (!ux) {
|
|
163
|
+
return 'No way to run the app is declared (cadence.yaml: orchestrate.ux): review the interface from the code, and list under "nonVerifie" what you could not see.';
|
|
164
|
+
}
|
|
165
|
+
return [
|
|
166
|
+
ux.url ? `The running app is at ${ux.url}.` : '',
|
|
167
|
+
ux.command ? `Start the app with: ${ux.command} (from the repository root), and stop it when you are done.` : '',
|
|
168
|
+
'Take screenshots at 1440 and 390 px width (into the shared tmp folder, never in the repository).',
|
|
169
|
+
]
|
|
170
|
+
.filter(Boolean)
|
|
171
|
+
.join(' ');
|
|
172
|
+
}
|
|
173
|
+
function choixText(choix) {
|
|
174
|
+
if (!choix.length)
|
|
175
|
+
return '';
|
|
176
|
+
return ['The author decided these interpretation questions on its own; re-read each choice against the lot, its notes and the code, and report a finding if one is wrong or changes the scope:', ...choix.map((x) => `- ${x}`)].join('\n');
|
|
177
|
+
}
|
|
178
|
+
/** Gabarit d'une étape : la passe des mineurs et la revue courte qui la suit ont chacune le leur. */
|
|
179
|
+
function briefName(l, kind) {
|
|
180
|
+
if (kind === 'fix' && l.minorFix)
|
|
181
|
+
return 'fix-minors';
|
|
182
|
+
if (kind === 'review-small' && l.minorPass && !(l.small && l.visible))
|
|
183
|
+
return 'review-recheck';
|
|
184
|
+
return kind;
|
|
185
|
+
}
|
|
186
|
+
function briefFor(c, kind) {
|
|
187
|
+
const plan = c.loadPlan();
|
|
188
|
+
const l = c.lot;
|
|
189
|
+
const lot = plan.lot(l.lot);
|
|
190
|
+
const vars = { chemin: l.repo, lot: l.lot, titre: lot.title, objectif: objective(lot), commits: '', reponse: '', constats: '', ux: uxText(c), choix: choixText(c.lot.choix ?? []) };
|
|
191
|
+
if (kind === 'implement' || kind === 'fix') {
|
|
192
|
+
const interrupted = [...l.steps].reverse().find((s) => s.kind === kind && s.status === 'interrupted');
|
|
193
|
+
if (kind === 'fix') {
|
|
194
|
+
vars.commits = lotCommitLines(c);
|
|
195
|
+
vars.constats = constatLines(l.constats);
|
|
196
|
+
}
|
|
197
|
+
else if (interrupted) {
|
|
198
|
+
const mine = lotCommitLines(c);
|
|
199
|
+
vars.commits = mine
|
|
200
|
+
? `A previous session was interrupted; resume from these commits of the lot already present:\n${mine}`
|
|
201
|
+
: 'A previous session was interrupted before any commit of the lot; start again from the current state of the repository.';
|
|
202
|
+
}
|
|
203
|
+
if (l.pendingAnswer)
|
|
204
|
+
vars.reponse = `Answer from the human to your earlier question: ${l.pendingAnswer}`;
|
|
205
|
+
}
|
|
206
|
+
return renderBrief(briefName(l, kind), vars, c.wave.templates);
|
|
207
|
+
}
|
|
208
|
+
/** Pourquoi plus aucune session ne doit partir (incident, quota, budget), sinon null. */
|
|
209
|
+
function halted(w) {
|
|
210
|
+
if (w.incident)
|
|
211
|
+
return `vague arrêtée : ${w.incident}`;
|
|
212
|
+
if (w.quota.hit)
|
|
213
|
+
return 'quota atteint';
|
|
214
|
+
if (w.budget.exhausted)
|
|
215
|
+
return 'budget atteint';
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
/** Une session : budget et quota vérifiés avant, état écrit avant et après, journal gardé, contrôles du dépôt après. */
|
|
219
|
+
async function session(c, kind) {
|
|
220
|
+
const w = c.wave;
|
|
221
|
+
const l = c.lot;
|
|
222
|
+
const halt = halted(w);
|
|
223
|
+
if (halt)
|
|
224
|
+
return suspend(c, halt);
|
|
225
|
+
const write = kind === 'implement' || kind === 'fix';
|
|
226
|
+
const model = write ? l.model : 'opus';
|
|
227
|
+
const before = await snapshot(l.repo);
|
|
228
|
+
const n = l.steps.length + 1;
|
|
229
|
+
const sessionId = randomUUID();
|
|
230
|
+
const step = { n, kind, model, status: 'running', sessionId, started: new Date().toISOString(), headBefore: before.head ?? undefined };
|
|
231
|
+
l.steps.push(step);
|
|
232
|
+
const label = { implement: 'implementing', fix: 'fixing', review: 'reviewing', ux: 'reviewing', 'review-small': 'reviewing' };
|
|
233
|
+
transition(c, label[kind]);
|
|
234
|
+
w.log(`${lotKey(l.project, l.lot)} · session ${n} ${kind} (${model})`);
|
|
235
|
+
let brief;
|
|
236
|
+
try {
|
|
237
|
+
brief = briefFor(c, kind);
|
|
238
|
+
}
|
|
239
|
+
catch (e) {
|
|
240
|
+
step.status = 'failed';
|
|
241
|
+
step.cause = e.message;
|
|
242
|
+
return stop(c, 'failed', `brief de ${kind} : ${e.message}`);
|
|
243
|
+
}
|
|
244
|
+
l.pendingAnswer = null; // la réponse est dans le brief : elle ne repart pas avec la session suivante
|
|
245
|
+
const outcome = await runSession({
|
|
246
|
+
kind,
|
|
247
|
+
sessionId,
|
|
248
|
+
brief,
|
|
249
|
+
model,
|
|
250
|
+
schema: schemaFor(kind),
|
|
251
|
+
agent: kind === 'ux' ? 'ux-reviewer' : write ? undefined : 'code-reviewer',
|
|
252
|
+
cwd: l.repo,
|
|
253
|
+
wave: w.id,
|
|
254
|
+
permissionMode: c.config.permissionMode,
|
|
255
|
+
addDirs: c.config.addDirs,
|
|
256
|
+
timeoutMs: write ? c.config.timeouts.work : c.config.timeouts.review,
|
|
257
|
+
}, {
|
|
258
|
+
claude: w.claude,
|
|
259
|
+
agents: w.agents,
|
|
260
|
+
onSpawn: (pid) => {
|
|
261
|
+
step.pid = pid;
|
|
262
|
+
save(c);
|
|
263
|
+
},
|
|
264
|
+
});
|
|
265
|
+
step.ended = new Date().toISOString();
|
|
266
|
+
const dir = w.store.lotDir(l.project, l.lot);
|
|
267
|
+
const base = `${n}-${kind}`;
|
|
268
|
+
if (outcome.kind !== 'ok') {
|
|
269
|
+
// Une session en échec, au quota ou tuée au délai a consommé des tokens : ceux de sa sortie, sinon ceux de son journal.
|
|
270
|
+
const spent = outcome.tokens ? { tokens: outcome.tokens, sessionId: outcome.sessionId } : w.claudeHome ? journalTokens(w.claudeHome, l.repo, outcome.sessionId ?? sessionId) : null;
|
|
271
|
+
if (spent) {
|
|
272
|
+
step.tokens = spent.tokens;
|
|
273
|
+
if (spent.sessionId)
|
|
274
|
+
step.sessionId = spent.sessionId;
|
|
275
|
+
w.budget.add(spent.tokens);
|
|
276
|
+
w.saveWave();
|
|
277
|
+
if (w.claudeHome && spent.sessionId)
|
|
278
|
+
step.peakContext = peakContext(w.claudeHome, l.repo, spent.sessionId);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
if (outcome.kind === 'failed') {
|
|
282
|
+
if (outcome.firstStdout !== undefined)
|
|
283
|
+
writeFileSync(join(dir, `${base}.first.json`), outcome.firstStdout);
|
|
284
|
+
writeFileSync(join(dir, `${base}.json`), outcome.stdout);
|
|
285
|
+
writeFileSync(join(dir, `${base}.err`), outcome.stderr);
|
|
286
|
+
step.report = `${base}.json`;
|
|
287
|
+
step.status = 'failed';
|
|
288
|
+
step.cause = outcome.cause;
|
|
289
|
+
return stop(c, 'failed', `${kind} sans résultat : ${outcome.cause}`);
|
|
290
|
+
}
|
|
291
|
+
if (outcome.kind === 'quota') {
|
|
292
|
+
if (outcome.firstStdout !== undefined)
|
|
293
|
+
writeFileSync(join(dir, `${base}.first.json`), outcome.firstStdout);
|
|
294
|
+
step.status = 'interrupted';
|
|
295
|
+
step.cause = outcome.message;
|
|
296
|
+
w.quota = { hit: true, message: outcome.message };
|
|
297
|
+
w.saveWave();
|
|
298
|
+
c.lot.next = kind;
|
|
299
|
+
transition(c, 'suspended', 'quota atteint');
|
|
300
|
+
return null;
|
|
301
|
+
}
|
|
302
|
+
const res = outcome.result;
|
|
303
|
+
if (outcome.firstStdout !== undefined)
|
|
304
|
+
writeFileSync(join(dir, `${base}.first.json`), outcome.firstStdout);
|
|
305
|
+
writeFileSync(join(dir, `${base}.json`), JSON.stringify({ ...res, structured: res.structured }, null, 2));
|
|
306
|
+
step.report = `${base}.json`;
|
|
307
|
+
step.sessionId = res.sessionId;
|
|
308
|
+
step.tokens = res.tokens;
|
|
309
|
+
if (res.formattingRetry) {
|
|
310
|
+
step.formatRetry = true;
|
|
311
|
+
w.log(`${lotKey(l.project, l.lot)} · session ${n} ${kind} : rapport sans sortie structurée, relance de mise en forme`);
|
|
312
|
+
}
|
|
313
|
+
w.budget.add(res.tokens);
|
|
314
|
+
w.saveWave();
|
|
315
|
+
if (w.claudeHome)
|
|
316
|
+
step.peakContext = peakContext(w.claudeHome, l.repo, res.sessionId);
|
|
317
|
+
const after = await snapshot(l.repo);
|
|
318
|
+
step.headAfter = after.head ?? undefined;
|
|
319
|
+
if (pushed(before, after)) {
|
|
320
|
+
step.status = 'failed';
|
|
321
|
+
step.cause = 'push détecté';
|
|
322
|
+
w.incident = `push détecté pendant ${lotKey(l.project, l.lot)} (${kind})`;
|
|
323
|
+
w.saveWave();
|
|
324
|
+
return stop(c, 'failed', `incident : ${w.incident}`);
|
|
325
|
+
}
|
|
326
|
+
if (before.guard && !after.guard) {
|
|
327
|
+
step.status = 'failed';
|
|
328
|
+
step.cause = 'le hook pre-push de garde a disparu';
|
|
329
|
+
w.incident = `le hook pre-push de garde a été supprimé pendant ${lotKey(l.project, l.lot)} (${kind})`;
|
|
330
|
+
w.saveWave();
|
|
331
|
+
return stop(c, 'failed', `incident : ${w.incident}`);
|
|
332
|
+
}
|
|
333
|
+
if (!write && (before.head !== after.head || before.tracked.join() !== after.tracked.join() || before.untracked.join() !== after.untracked.join())) {
|
|
334
|
+
step.status = 'failed';
|
|
335
|
+
step.cause = 'le dépôt a changé pendant une revue';
|
|
336
|
+
w.incident = `${kind} de ${lotKey(l.project, l.lot)} a modifié le dépôt`;
|
|
337
|
+
w.saveWave();
|
|
338
|
+
return stop(c, 'failed', `incident : ${w.incident}`);
|
|
339
|
+
}
|
|
340
|
+
step.status = 'ok';
|
|
341
|
+
save(c);
|
|
342
|
+
return { step, report: res.structured, before, after };
|
|
343
|
+
}
|
|
344
|
+
function suspend(c, why) {
|
|
345
|
+
transition(c, 'suspended', why);
|
|
346
|
+
return null;
|
|
347
|
+
}
|
|
348
|
+
function summarize(rep, head) {
|
|
349
|
+
// Rien d'un rapport n'est cru sur parole : un constat de gravité majeure compte même si les totaux annoncés disent 0.
|
|
350
|
+
const count = (g) => rep.constats.filter((k) => k.gravite === g).length;
|
|
351
|
+
const bloquants = Math.max(rep.bloquants, count('bloquant'));
|
|
352
|
+
const majeurs = Math.max(rep.majeurs, count('majeur'));
|
|
353
|
+
return { conforme: bloquants === 0 && majeurs === 0, bloquants, majeurs, mineurs: Math.max(rep.mineurs, count('mineur')), verdict: rep.verdict.trim(), sousTaches: rep.sousTaches, nonVerifie: rep.nonVerifie, head };
|
|
354
|
+
}
|
|
355
|
+
function propose(c, text) {
|
|
356
|
+
if (!c.lot.proposals.includes(text))
|
|
357
|
+
c.lot.proposals.push(text);
|
|
358
|
+
}
|
|
359
|
+
const minorLine = (source, k) => `[mineur ${source}] ${k.fichier ? `${k.fichier}${k.ligne ? `:${k.ligne}` : ''} — ` : ''}${k.texte}`;
|
|
360
|
+
const withoutLine = (line) => line.replace(/^(\[mineur \w+\] .*?):\d+ — /, '$1 — ');
|
|
361
|
+
function addProposals(c, rep, source, withMinors = true) {
|
|
362
|
+
if (withMinors)
|
|
363
|
+
for (const k of rep.constats.filter((k) => k.gravite === 'mineur'))
|
|
364
|
+
propose(c, minorLine(source, k));
|
|
365
|
+
for (const t of rep.sousTaches)
|
|
366
|
+
propose(c, `[sous-tâche ${source}] ${t}`);
|
|
367
|
+
}
|
|
368
|
+
function minorConstats(rep) {
|
|
369
|
+
return rep.constats.filter((k) => k.gravite === 'mineur').map((k) => ({ source: 'code', gravite: k.gravite, fichier: k.fichier, ligne: k.ligne, texte: k.texte }));
|
|
370
|
+
}
|
|
371
|
+
function blockingConstats(rep, source) {
|
|
372
|
+
return rep.constats.filter((k) => k.gravite === 'bloquant' || k.gravite === 'majeur').map((k) => ({ source, gravite: k.gravite, fichier: k.fichier, ligne: k.ligne, texte: k.texte }));
|
|
373
|
+
}
|
|
374
|
+
function badReport(c, step, e) {
|
|
375
|
+
step.status = 'failed';
|
|
376
|
+
step.cause = e.message;
|
|
377
|
+
return stop(c, 'failed', `${step.kind} : ${e.message}`);
|
|
378
|
+
}
|
|
379
|
+
/** Étape d'écriture (implémentation, correction) : contrôles, puis étape suivante décidée. */
|
|
380
|
+
async function work(c, kind) {
|
|
381
|
+
const l = c.lot;
|
|
382
|
+
const minorsPass = kind === 'fix' && l.minorFix === true;
|
|
383
|
+
const done = await session(c, kind);
|
|
384
|
+
if (!done)
|
|
385
|
+
return;
|
|
386
|
+
l.minorFix = false; // la passe des mineurs est jouée : la suite se décide sur son rapport
|
|
387
|
+
let rep;
|
|
388
|
+
try {
|
|
389
|
+
rep = checkShape(done.report, WORK_SCHEMA);
|
|
390
|
+
}
|
|
391
|
+
catch (e) {
|
|
392
|
+
badReport(c, done.step, e);
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
const plan = c.loadPlan();
|
|
396
|
+
const range = done.step.headBefore ? `${done.step.headBefore}..HEAD` : undefined;
|
|
397
|
+
const commits = readCommits(l.repo, { range });
|
|
398
|
+
done.step.commits = commits.map(short).reverse();
|
|
399
|
+
// La liste de git fait foi ; l'écart avec celle du rapport est signalé.
|
|
400
|
+
const real = new Set(commits.map((k) => k.sha));
|
|
401
|
+
for (const a of rep.commits) {
|
|
402
|
+
if ([...real].some((s) => s.startsWith(a.sha) || a.sha.startsWith(s.slice(0, 7))))
|
|
403
|
+
continue;
|
|
404
|
+
// Reprise : le rapport cite aussi les commits d'une session précédente du lot, hors de la plage de celle-ci.
|
|
405
|
+
const full = /^[0-9a-f]{4,40}$/i.test(a.sha) ? resolveCommit(l.repo, a.sha) : null;
|
|
406
|
+
if (full && lotCommits(plan, l.repo, l.lot).some((k) => k.sha === full))
|
|
407
|
+
continue;
|
|
408
|
+
l.warnings.push(full ? `commit annoncé n'appartient pas à ${l.lot} : ${a.sha} ${a.sujet}` : `commit annoncé absent de git : ${a.sha} ${a.sujet}`);
|
|
409
|
+
}
|
|
410
|
+
for (const k of commits) {
|
|
411
|
+
if (isPlanOnly(k.sha, plan, l.repo))
|
|
412
|
+
continue;
|
|
413
|
+
if (!citedRefs(k, plan.refs).some((r) => r.lot === l.lot))
|
|
414
|
+
l.warnings.push(`commit qui ne cite pas ${l.lot} : ${short(k)}`);
|
|
415
|
+
}
|
|
416
|
+
save(c);
|
|
417
|
+
for (const x of rep.choix ?? [])
|
|
418
|
+
if (!(l.choix ??= []).includes(x))
|
|
419
|
+
l.choix.push(x);
|
|
420
|
+
save(c);
|
|
421
|
+
// La passe des mineurs ne bloque jamais le lot (revue conforme déjà acquise) : une question est rendue en proposition.
|
|
422
|
+
if (minorsPass)
|
|
423
|
+
for (const q of rep.questions)
|
|
424
|
+
propose(c, `[question passe des mineurs] ${q}`);
|
|
425
|
+
if (!minorsPass && rep.questions.length) {
|
|
426
|
+
l.questions = rep.questions;
|
|
427
|
+
l.next = kind;
|
|
428
|
+
transition(c, 'question', rep.questions[0]);
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
const dirt = [...trackedPaths(done.after), ...done.after.untracked.filter((f) => !done.before.untracked.includes(f))];
|
|
432
|
+
if (dirt.length) {
|
|
433
|
+
stop(c, 'handed-back', `dépôt sale après ${kind} : ${dirt.join(', ')}`);
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
// Reprise : une session précédente de l'étape (correction ordinaire ou passe des mineurs) a pu commiter avant d'être coupée ; la revue d'origine ne vaut alors plus.
|
|
437
|
+
// Référence : le HEAD de départ de la première session coupée de la série qui précède celle-ci (pas la revue, qui peut ne pas exister encore).
|
|
438
|
+
let resumedFrom;
|
|
439
|
+
for (const s of l.steps.slice(0, -1).reverse()) {
|
|
440
|
+
if (s.kind !== 'fix' || s.status !== 'interrupted')
|
|
441
|
+
break;
|
|
442
|
+
resumedFrom = s.headBefore;
|
|
443
|
+
}
|
|
444
|
+
const headMoved = kind === 'fix' && commits.length === 0 && !!resumedFrom && git(l.repo, 'rev-parse', 'HEAD') !== resumedFrom;
|
|
445
|
+
if (minorsPass && commits.length === 0 && !headMoved) {
|
|
446
|
+
// Rien à corriger (mineurs jugés faux, listés en « choix ») : la revue conforme d'origine vaut, HEAD n'a pas bougé.
|
|
447
|
+
for (const k of l.constats.filter((k) => k.gravite === 'mineur'))
|
|
448
|
+
propose(c, minorLine('code', k));
|
|
449
|
+
l.constats = [];
|
|
450
|
+
l.warnings.push('passe des mineurs sans commit : le lot conclut sur la revue conforme d\'origine, mineurs rendus en propositions');
|
|
451
|
+
if (l.code)
|
|
452
|
+
await conclude(c, l.code, ' sans commit');
|
|
453
|
+
else
|
|
454
|
+
stop(c, 'handed-back', 'passe des mineurs sans commit, mais aucune revue conforme gardée');
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
// Après une réponse du lead, la session peut n'avoir plus rien à commiter : le travail du lot est déjà dans git.
|
|
458
|
+
const alreadyDone = kind === 'implement' && l.answers.length > 0 && lotWork(plan, l.repo, l.lot).length > 0;
|
|
459
|
+
if (commits.length === 0 && !alreadyDone && !headMoved) {
|
|
460
|
+
stop(c, 'handed-back', `${kind} sans commit`);
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
if (headMoved)
|
|
464
|
+
l.warnings.push(`${minorsPass ? 'passe des mineurs' : 'correction'} reprise sans nouveau commit : un commit d'une session précédente est relu par la revue`);
|
|
465
|
+
else if (commits.length === 0)
|
|
466
|
+
l.warnings.push(`${kind} sans nouveau commit : revue lancée sur les commits déjà faits du lot`);
|
|
467
|
+
const red = [];
|
|
468
|
+
if (!rep.tests.vert)
|
|
469
|
+
red.push({ source: 'tests', gravite: 'bloquant', texte: `tests annoncés rouges par la session : ${rep.tests.commande} — ${rep.tests.resultat}` });
|
|
470
|
+
if (!rep.build.vert)
|
|
471
|
+
red.push({ source: 'tests', gravite: 'bloquant', texte: `build annoncé rouge par la session : ${rep.build.commande} — ${rep.build.resultat}` });
|
|
472
|
+
if (red.length === 0 && c.config.test) {
|
|
473
|
+
const r = await sh(c, c.config.test);
|
|
474
|
+
if (r.code !== 0)
|
|
475
|
+
red.push({ source: 'tests', gravite: 'bloquant', texte: `${c.config.test} en échec (code ${r.code}) :\n${r.output.trim().split('\n').slice(-40).join('\n')}` });
|
|
476
|
+
}
|
|
477
|
+
if (red.length) {
|
|
478
|
+
toFix(c, red, 'tests rouges');
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
l.constats = [];
|
|
482
|
+
l.next = firstReview(c);
|
|
483
|
+
save(c);
|
|
484
|
+
}
|
|
485
|
+
/** Revue de code qui clôt le cycle : complète, ou courte une fois la passe des mineurs jouée. */
|
|
486
|
+
const codeReview = (l) => (l.minorPass ? 'review-small' : 'review');
|
|
487
|
+
/**
|
|
488
|
+
* Première étape après un travail d'écriture. Le code vient de changer : l'UX d'un lot visible est donc toujours rejouée
|
|
489
|
+
* (une revue antérieure serait périmée), puis une revue de code clôt le cycle.
|
|
490
|
+
*/
|
|
491
|
+
function firstReview(c) {
|
|
492
|
+
const l = c.lot;
|
|
493
|
+
if (l.small)
|
|
494
|
+
return l.visible || l.minorPass ? 'review-small' : 'review'; // passe unique code + ergonomie ; sans écran, une revue de code
|
|
495
|
+
if (l.visible) {
|
|
496
|
+
if (!c.config.ux) {
|
|
497
|
+
l.uxNote = 'UX à faire par le lead (aucune application déclarée : cadence.yaml orchestrate.ux)';
|
|
498
|
+
return codeReview(l);
|
|
499
|
+
}
|
|
500
|
+
return 'ux';
|
|
501
|
+
}
|
|
502
|
+
return codeReview(l);
|
|
503
|
+
}
|
|
504
|
+
/** Une passe de correction de plus, ou la main rendue au lead quand les deux sont faites. */
|
|
505
|
+
function toFix(c, constats, what) {
|
|
506
|
+
const l = c.lot;
|
|
507
|
+
l.constats = constats;
|
|
508
|
+
if (l.pass >= MAX_PASSES) {
|
|
509
|
+
stop(c, 'handed-back', `${what} après ${MAX_PASSES} passe(s) de correction`);
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
l.pass += 1;
|
|
513
|
+
l.next = 'fix';
|
|
514
|
+
save(c);
|
|
515
|
+
}
|
|
516
|
+
async function review(c, kind) {
|
|
517
|
+
const l = c.lot;
|
|
518
|
+
const done = await session(c, kind);
|
|
519
|
+
if (!done)
|
|
520
|
+
return;
|
|
521
|
+
let rep;
|
|
522
|
+
try {
|
|
523
|
+
rep = checkShape(done.report, REVIEW_SCHEMA);
|
|
524
|
+
}
|
|
525
|
+
catch (e) {
|
|
526
|
+
badReport(c, done.step, e);
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
const summary = summarize(rep, done.after.head ?? '');
|
|
530
|
+
if (kind === 'ux') {
|
|
531
|
+
l.ux = summary;
|
|
532
|
+
addProposals(c, rep, 'ux');
|
|
533
|
+
l.uxVerdict = summary.verdict;
|
|
534
|
+
l.constats = blockingConstats(rep, 'ux');
|
|
535
|
+
l.next = codeReview(l); // la revue de code clôt toujours le cycle
|
|
536
|
+
save(c);
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
l.code = summary;
|
|
540
|
+
const w = c.wave;
|
|
541
|
+
if (l.small && l.visible) {
|
|
542
|
+
// La passe unique d'un petit lot porte aussi l'ergonomie : son verdict vaut pour les deux. Sur un lot non petit, le verdict UX reste celui de l'agent UX.
|
|
543
|
+
l.ux = summary;
|
|
544
|
+
l.uxVerdict = summary.verdict;
|
|
545
|
+
}
|
|
546
|
+
const uxOk = l.small || !l.ux || l.ux.conforme;
|
|
547
|
+
const minors = minorConstats(rep);
|
|
548
|
+
// Revue conforme avec mineurs : une seule passe de correction des mineurs, avant de conclure (rien sous le tapis).
|
|
549
|
+
// Budget épuisé : la passe n'aurait aucune session pour la jouer, le lot conclut sur la revue conforme et rend les mineurs.
|
|
550
|
+
const wanted = summary.conforme && uxOk && !l.minorPass && minors.length > 0;
|
|
551
|
+
const noBudget = wanted && w.budget.exhausted && !w.incident && !w.quota.hit;
|
|
552
|
+
const minorPass = wanted && !noBudget;
|
|
553
|
+
if (noBudget)
|
|
554
|
+
l.warnings.push('budget atteint : la passe des mineurs n\'a pas eu lieu, mineurs rendus en propositions');
|
|
555
|
+
// Les mineurs confiés à la passe ont pu être proposés par une revue non conforme antérieure : si la revue qui la suit est
|
|
556
|
+
// conforme, ils sont traités et ne restent pas en propositions (ceux que cette revue signale encore sont ajoutés juste après).
|
|
557
|
+
if (l.minorPass && !minorPass && summary.conforme && uxOk && l.minorLines?.length) {
|
|
558
|
+
// Chaque mineur confié retire UNE proposition : la même ligne d'abord, sinon une de même fichier et même texte (le correctif a pu décaler la ligne).
|
|
559
|
+
// Un autre mineur de même fichier et même texte, à une autre ligne, reste donc proposé. Limite connue : un texte reformulé n'est pas reconnu et reste proposé.
|
|
560
|
+
const left = [...l.proposals];
|
|
561
|
+
const take = (match) => {
|
|
562
|
+
const i = left.findIndex(match);
|
|
563
|
+
if (i >= 0)
|
|
564
|
+
left.splice(i, 1);
|
|
565
|
+
return i >= 0;
|
|
566
|
+
};
|
|
567
|
+
const rest = l.minorLines.filter((m) => !take((p) => p === m));
|
|
568
|
+
for (const m of rest)
|
|
569
|
+
take((p) => withoutLine(p) === withoutLine(m));
|
|
570
|
+
l.proposals = left;
|
|
571
|
+
l.minorLines = [];
|
|
572
|
+
}
|
|
573
|
+
addProposals(c, rep, 'code', !minorPass);
|
|
574
|
+
if (minorPass) {
|
|
575
|
+
l.minorPass = true;
|
|
576
|
+
l.minorLines = rep.constats.filter((k) => k.gravite === 'mineur').map((k) => minorLine('code', k));
|
|
577
|
+
l.minorFix = true;
|
|
578
|
+
l.constats = minors;
|
|
579
|
+
l.next = 'fix';
|
|
580
|
+
save(c);
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
if (summary.conforme && uxOk) {
|
|
584
|
+
await conclude(c, summary);
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
const constats = [...blockingConstats(rep, 'code'), ...(!l.small && l.ux && !l.ux.conforme ? uxConstatsKept(c) : [])];
|
|
588
|
+
toFix(c, constats, 'revue non conforme');
|
|
589
|
+
}
|
|
590
|
+
/** Constats UX de la dernière revue UX, gardés dans l'état par la revue elle-même (l.constats avant la revue de code). */
|
|
591
|
+
function uxConstatsKept(c) {
|
|
592
|
+
return c.lot.constats.filter((k) => k.source === 'ux');
|
|
593
|
+
}
|
|
594
|
+
/** Revue conforme : le verdict est enregistré avec le sha relu, puis le commit du plan. `raf done` reste au lead. */
|
|
595
|
+
async function conclude(c, code, minorNote = '') {
|
|
596
|
+
const l = c.lot;
|
|
597
|
+
const plan = c.loadPlan();
|
|
598
|
+
const verdict = `${code.verdict} — orchestré (vague ${c.wave.id}, ${l.pass} passe(s) de correction${l.minorPass ? ` + passe des mineurs${minorNote}` : ''})`;
|
|
599
|
+
l.verdict = verdict;
|
|
600
|
+
const newer = lotWork(plan, l.repo, l.lot)[0]?.sha ?? null;
|
|
601
|
+
if (code.head && newer !== null && git(l.repo, 'rev-parse', 'HEAD') !== code.head) {
|
|
602
|
+
stop(c, 'handed-back', 'un commit est postérieur à la revue : verdict non enregistré');
|
|
603
|
+
return;
|
|
604
|
+
}
|
|
605
|
+
l.constats = [];
|
|
606
|
+
if (!plan.readonly) {
|
|
607
|
+
plan.recordReview(l.lot, verdict, c.wave.today, newer);
|
|
608
|
+
plan.save();
|
|
609
|
+
const dirty = await commitPlan(c, `plan: ${l.lot} revue de code enregistrée (orchestrate ${c.wave.id})`);
|
|
610
|
+
if (dirty) {
|
|
611
|
+
stop(c, 'handed-back', dirty);
|
|
612
|
+
return;
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
else if (c.config.verdict) {
|
|
616
|
+
const r = await sh(c, c.config.verdict.replaceAll('{lot}', l.lot).replaceAll('{verdict}', shEscape(verdict)));
|
|
617
|
+
if (r.code !== 0)
|
|
618
|
+
l.warnings.push(`orchestrate.verdict en échec (code ${r.code}) : le lead reporte le verdict`);
|
|
619
|
+
else {
|
|
620
|
+
const dirty = await commitPlan(c, `plan: ${l.lot} revue de code notée (orchestrate ${c.wave.id})`);
|
|
621
|
+
if (dirty) {
|
|
622
|
+
stop(c, 'handed-back', dirty);
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
l.next = null;
|
|
628
|
+
transition(c, 'ready', 'prêt à livrer');
|
|
629
|
+
}
|
|
630
|
+
/** Joue le cycle d'un lot jusqu'à son terme, ou jusqu'à l'arrêt (question, budget, quota). Ne lève jamais. */
|
|
631
|
+
export async function runLot(c) {
|
|
632
|
+
const l = c.lot;
|
|
633
|
+
try {
|
|
634
|
+
if (TERMINAL.has(l.status))
|
|
635
|
+
return;
|
|
636
|
+
if (l.status === 'question' && !l.pendingAnswer)
|
|
637
|
+
return;
|
|
638
|
+
if (l.next === null && l.steps.length === 0) {
|
|
639
|
+
// Pas de raf start ni de commit du plan pour un lot qu'aucune session ne suivrait.
|
|
640
|
+
const halt = halted(c.wave);
|
|
641
|
+
if (halt) {
|
|
642
|
+
suspend(c, halt);
|
|
643
|
+
return;
|
|
644
|
+
}
|
|
645
|
+
const before = await snapshot(l.repo, { remote: false });
|
|
646
|
+
if (before.tracked.length) {
|
|
647
|
+
stop(c, 'handed-back', `dépôt sale avant le lot : ${trackedPaths(before).join(', ')}`);
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
650
|
+
const why = await startLot(c);
|
|
651
|
+
if (why) {
|
|
652
|
+
stop(c, 'handed-back', why);
|
|
653
|
+
return;
|
|
654
|
+
}
|
|
655
|
+
l.startedSha = (await snapshot(l.repo, { remote: false })).head ?? undefined;
|
|
656
|
+
l.next = 'implement';
|
|
657
|
+
save(c);
|
|
658
|
+
}
|
|
659
|
+
while (l.next && !TERMINAL.has(l.status)) {
|
|
660
|
+
const kind = l.next;
|
|
661
|
+
if (kind === 'implement' || kind === 'fix')
|
|
662
|
+
await work(c, kind);
|
|
663
|
+
else
|
|
664
|
+
await review(c, kind);
|
|
665
|
+
if (l.status === 'suspended' || l.status === 'question')
|
|
666
|
+
return;
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
catch (e) {
|
|
670
|
+
l.next = null;
|
|
671
|
+
transition(c, 'failed', `erreur : ${e.message}`);
|
|
672
|
+
}
|
|
673
|
+
}
|