sidecar-cli 0.1.5-beta.1 → 0.1.5-beta.2
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/README.md +234 -17
- package/dist/cli.js +667 -66
- package/dist/lib/banner.js +17 -1
- package/dist/lib/color.js +30 -0
- package/dist/lib/format.js +7 -1
- package/dist/lib/table.js +97 -0
- package/dist/prompts/packet-sections.js +203 -0
- package/dist/prompts/prompt-compiler.js +90 -163
- package/dist/prompts/prompt-service.js +7 -0
- package/dist/prompts/prompt-spec.js +128 -0
- package/dist/prompts/sections.js +194 -0
- package/dist/runners/claude-runner.js +7 -28
- package/dist/runners/codex-runner.js +7 -28
- package/dist/runners/config.js +75 -0
- package/dist/runners/runner-exec.js +152 -0
- package/dist/runs/capture.js +429 -0
- package/dist/runs/run-record.js +42 -0
- package/dist/runs/run-repository.js +1 -0
- package/dist/services/hook-service.js +130 -0
- package/dist/services/run-orchestrator-service.js +210 -11
- package/dist/services/run-review-service.js +1 -1
- package/dist/tasks/task-packet.js +18 -1
- package/dist/tasks/task-service.js +4 -1
- package/dist/templates/hooks.js +34 -0
- package/package.json +2 -1
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { appendFileSync, mkdirSync } from 'node:fs';
|
|
3
|
+
import { dirname } from 'node:path';
|
|
4
|
+
export const VALIDATION_KINDS = ['typecheck', 'lint', 'test', 'build', 'custom'];
|
|
5
|
+
// Per-kind default timeouts. `custom` keeps the historical 5-minute budget; tests/builds
|
|
6
|
+
// get a longer default because they're the common cause of the 5m ceiling biting.
|
|
7
|
+
export const DEFAULT_VALIDATION_TIMEOUT_MS = {
|
|
8
|
+
typecheck: 3 * 60 * 1000,
|
|
9
|
+
lint: 3 * 60 * 1000,
|
|
10
|
+
test: 10 * 60 * 1000,
|
|
11
|
+
build: 10 * 60 * 1000,
|
|
12
|
+
custom: 5 * 60 * 1000,
|
|
13
|
+
};
|
|
14
|
+
const KILL_GRACE_MS = 5 * 1000;
|
|
15
|
+
const SNIPPET_LIMIT = 1000;
|
|
16
|
+
export function isValidationKind(value) {
|
|
17
|
+
return typeof value === 'string' && VALIDATION_KINDS.includes(value);
|
|
18
|
+
}
|
|
19
|
+
export function normalizeValidationStep(entry) {
|
|
20
|
+
if (typeof entry === 'string') {
|
|
21
|
+
const raw = entry.trim();
|
|
22
|
+
if (raw.length === 0)
|
|
23
|
+
return null;
|
|
24
|
+
// Support "kind:command" shorthand — but only when the prefix before the first
|
|
25
|
+
// colon is a known kind. Anything else (URLs, paths, env vars, bash substitutions)
|
|
26
|
+
// falls through as a plain custom command.
|
|
27
|
+
// Also accepts "kind@<duration>:command" where <duration> is "30s", "2m", or "1500ms"
|
|
28
|
+
// to override the per-kind default timeout.
|
|
29
|
+
const colonIdx = raw.indexOf(':');
|
|
30
|
+
if (colonIdx > 0) {
|
|
31
|
+
const prefix = raw.slice(0, colonIdx).trim();
|
|
32
|
+
const rest = raw.slice(colonIdx + 1).trim();
|
|
33
|
+
const atIdx = prefix.indexOf('@');
|
|
34
|
+
if (atIdx > 0) {
|
|
35
|
+
const kindPart = prefix.slice(0, atIdx).trim();
|
|
36
|
+
const durationPart = prefix.slice(atIdx + 1).trim();
|
|
37
|
+
if (isValidationKind(kindPart) && rest.length > 0) {
|
|
38
|
+
const parsed = parseDurationToMs(durationPart);
|
|
39
|
+
const step = { kind: kindPart, command: rest };
|
|
40
|
+
if (parsed != null)
|
|
41
|
+
step.timeout_ms = parsed;
|
|
42
|
+
return step;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
if (isValidationKind(prefix) && rest.length > 0) {
|
|
46
|
+
return { kind: prefix, command: rest };
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return { kind: 'custom', command: raw };
|
|
50
|
+
}
|
|
51
|
+
if (!entry || typeof entry !== 'object')
|
|
52
|
+
return null;
|
|
53
|
+
const command = typeof entry.command === 'string' ? entry.command.trim() : '';
|
|
54
|
+
if (command.length === 0)
|
|
55
|
+
return null;
|
|
56
|
+
const kind = isValidationKind(entry.kind) ? entry.kind : 'custom';
|
|
57
|
+
const step = { kind, command };
|
|
58
|
+
if (typeof entry.name === 'string' && entry.name.trim().length > 0)
|
|
59
|
+
step.name = entry.name.trim();
|
|
60
|
+
const rawTimeout = typeof entry.timeout_ms === 'number'
|
|
61
|
+
? entry.timeout_ms
|
|
62
|
+
: typeof entry.timeoutMs === 'number'
|
|
63
|
+
? entry.timeoutMs
|
|
64
|
+
: undefined;
|
|
65
|
+
if (typeof rawTimeout === 'number' && Number.isFinite(rawTimeout) && rawTimeout > 0) {
|
|
66
|
+
step.timeout_ms = Math.floor(rawTimeout);
|
|
67
|
+
}
|
|
68
|
+
return step;
|
|
69
|
+
}
|
|
70
|
+
export function resolveValidationTimeoutMs(step) {
|
|
71
|
+
if (typeof step.timeout_ms === 'number' && step.timeout_ms > 0)
|
|
72
|
+
return step.timeout_ms;
|
|
73
|
+
return DEFAULT_VALIDATION_TIMEOUT_MS[step.kind];
|
|
74
|
+
}
|
|
75
|
+
// Parse "30s", "2m", "1500ms" → milliseconds. Returns null on unknown format.
|
|
76
|
+
export function parseDurationToMs(raw) {
|
|
77
|
+
const match = /^(\d+)(ms|s|m)$/i.exec(raw.trim());
|
|
78
|
+
if (!match)
|
|
79
|
+
return null;
|
|
80
|
+
const value = Number.parseInt(match[1], 10);
|
|
81
|
+
if (!Number.isFinite(value) || value <= 0)
|
|
82
|
+
return null;
|
|
83
|
+
const unit = match[2].toLowerCase();
|
|
84
|
+
if (unit === 'ms')
|
|
85
|
+
return value;
|
|
86
|
+
if (unit === 's')
|
|
87
|
+
return value * 1000;
|
|
88
|
+
if (unit === 'm')
|
|
89
|
+
return value * 60 * 1000;
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
export async function captureHeadSha(cwd) {
|
|
93
|
+
try {
|
|
94
|
+
const { stdout, exitCode } = await runGit(cwd, ['rev-parse', 'HEAD']);
|
|
95
|
+
if (exitCode !== 0)
|
|
96
|
+
return null;
|
|
97
|
+
const trimmed = stdout.trim();
|
|
98
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
export async function captureWorkingTreeSnapshot(cwd) {
|
|
105
|
+
let trackedRef = null;
|
|
106
|
+
try {
|
|
107
|
+
const stash = await runGit(cwd, ['stash', 'create']);
|
|
108
|
+
const candidate = stash.exitCode === 0 ? stash.stdout.trim() : '';
|
|
109
|
+
if (candidate.length > 0) {
|
|
110
|
+
trackedRef = candidate;
|
|
111
|
+
}
|
|
112
|
+
else {
|
|
113
|
+
const head = await runGit(cwd, ['rev-parse', 'HEAD']);
|
|
114
|
+
if (head.exitCode === 0) {
|
|
115
|
+
const h = head.stdout.trim();
|
|
116
|
+
trackedRef = h.length > 0 ? h : null;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
trackedRef = null;
|
|
122
|
+
}
|
|
123
|
+
const untracked = await listUntracked(cwd);
|
|
124
|
+
return { trackedRef, untracked };
|
|
125
|
+
}
|
|
126
|
+
export async function captureFilesChangedSince(cwd, snapshot) {
|
|
127
|
+
const files = new Set();
|
|
128
|
+
if (snapshot.trackedRef) {
|
|
129
|
+
try {
|
|
130
|
+
const { stdout, exitCode } = await runGit(cwd, ['diff', '--name-only', snapshot.trackedRef]);
|
|
131
|
+
if (exitCode === 0) {
|
|
132
|
+
for (const line of stdout.split('\n')) {
|
|
133
|
+
const trimmed = line.trim();
|
|
134
|
+
if (trimmed.length > 0)
|
|
135
|
+
files.add(trimmed);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
// ignore
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
const untrackedNow = await listUntracked(cwd);
|
|
144
|
+
const before = new Set(snapshot.untracked);
|
|
145
|
+
for (const f of untrackedNow) {
|
|
146
|
+
if (!before.has(f))
|
|
147
|
+
files.add(f);
|
|
148
|
+
}
|
|
149
|
+
return dedupeSort(Array.from(files));
|
|
150
|
+
}
|
|
151
|
+
async function listUntracked(cwd) {
|
|
152
|
+
try {
|
|
153
|
+
const { stdout, exitCode } = await runGit(cwd, ['ls-files', '--others', '--exclude-standard']);
|
|
154
|
+
if (exitCode !== 0)
|
|
155
|
+
return [];
|
|
156
|
+
return stdout
|
|
157
|
+
.split('\n')
|
|
158
|
+
.map((l) => l.trim())
|
|
159
|
+
.filter((l) => l.length > 0);
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
return [];
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
export async function captureChangedFiles(cwd, sinceRef) {
|
|
166
|
+
try {
|
|
167
|
+
if (sinceRef && sinceRef.length > 0) {
|
|
168
|
+
const { stdout, exitCode } = await runGit(cwd, ['diff', '--name-only', sinceRef]);
|
|
169
|
+
if (exitCode !== 0)
|
|
170
|
+
return [];
|
|
171
|
+
return dedupeSort(stdout
|
|
172
|
+
.split('\n')
|
|
173
|
+
.map((line) => line.trim())
|
|
174
|
+
.filter((line) => line.length > 0));
|
|
175
|
+
}
|
|
176
|
+
const { stdout, exitCode } = await runGit(cwd, ['status', '--porcelain']);
|
|
177
|
+
if (exitCode !== 0)
|
|
178
|
+
return [];
|
|
179
|
+
const files = [];
|
|
180
|
+
for (const rawLine of stdout.split('\n')) {
|
|
181
|
+
const line = rawLine.replace(/\r$/, '');
|
|
182
|
+
if (line.length === 0)
|
|
183
|
+
continue;
|
|
184
|
+
const payload = line.length > 3 ? line.slice(3) : '';
|
|
185
|
+
if (payload.length === 0)
|
|
186
|
+
continue;
|
|
187
|
+
const arrowIdx = payload.indexOf(' -> ');
|
|
188
|
+
const path = arrowIdx >= 0 ? payload.slice(arrowIdx + 4) : payload;
|
|
189
|
+
const unquoted = unquoteGitPath(path.trim());
|
|
190
|
+
if (unquoted.length > 0)
|
|
191
|
+
files.push(unquoted);
|
|
192
|
+
}
|
|
193
|
+
return dedupeSort(files);
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
return [];
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
export async function runValidationCommands(cwd, steps, logPath) {
|
|
200
|
+
const results = [];
|
|
201
|
+
if (logPath)
|
|
202
|
+
ensureLogDir(logPath);
|
|
203
|
+
for (const step of steps) {
|
|
204
|
+
const result = await runOneValidation(cwd, step, logPath);
|
|
205
|
+
results.push(result);
|
|
206
|
+
}
|
|
207
|
+
return results;
|
|
208
|
+
}
|
|
209
|
+
export function formatValidationResultsForRecord(results) {
|
|
210
|
+
if (results.length === 0)
|
|
211
|
+
return [];
|
|
212
|
+
return results.map((r) => {
|
|
213
|
+
const label = r.name ? `${r.kind}:${r.name}` : r.kind;
|
|
214
|
+
if (r.timedOut)
|
|
215
|
+
return `${label} (${r.command}): timed out (${r.durationMs}ms)`;
|
|
216
|
+
return r.ok
|
|
217
|
+
? `${label} (${r.command}): ok (${r.durationMs}ms)`
|
|
218
|
+
: `${label} (${r.command}): failed (exit ${r.exitCode}, ${r.durationMs}ms)`;
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
async function runOneValidation(cwd, step, logPath) {
|
|
222
|
+
const start = Date.now();
|
|
223
|
+
const timeoutMs = resolveValidationTimeoutMs(step);
|
|
224
|
+
const { kind, command, name } = step;
|
|
225
|
+
try {
|
|
226
|
+
if (logPath) {
|
|
227
|
+
const label = name ? `${kind}:${name}` : kind;
|
|
228
|
+
appendFileSync(logPath, `\n\n--- validation [${label}]: ${command} ---\n`);
|
|
229
|
+
}
|
|
230
|
+
return await new Promise((resolve) => {
|
|
231
|
+
let child;
|
|
232
|
+
try {
|
|
233
|
+
child = spawn(command, {
|
|
234
|
+
cwd,
|
|
235
|
+
shell: true,
|
|
236
|
+
env: { ...process.env },
|
|
237
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
catch (err) {
|
|
241
|
+
resolve({
|
|
242
|
+
kind,
|
|
243
|
+
command,
|
|
244
|
+
...(name ? { name } : {}),
|
|
245
|
+
exitCode: -1,
|
|
246
|
+
ok: false,
|
|
247
|
+
timedOut: false,
|
|
248
|
+
durationMs: Date.now() - start,
|
|
249
|
+
timeoutMs,
|
|
250
|
+
outputSnippet: errorMessage(err),
|
|
251
|
+
});
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
let buffer = '';
|
|
255
|
+
let timedOut = false;
|
|
256
|
+
let killTimer = null;
|
|
257
|
+
let settled = false;
|
|
258
|
+
const appendToBuffer = (chunk) => {
|
|
259
|
+
buffer += chunk.toString('utf8');
|
|
260
|
+
if (buffer.length > SNIPPET_LIMIT * 4) {
|
|
261
|
+
buffer = buffer.slice(buffer.length - SNIPPET_LIMIT * 4);
|
|
262
|
+
}
|
|
263
|
+
};
|
|
264
|
+
const teeToLog = (chunk) => {
|
|
265
|
+
if (!logPath)
|
|
266
|
+
return;
|
|
267
|
+
try {
|
|
268
|
+
appendFileSync(logPath, chunk);
|
|
269
|
+
}
|
|
270
|
+
catch {
|
|
271
|
+
// ignore log write errors
|
|
272
|
+
}
|
|
273
|
+
};
|
|
274
|
+
child.stdout?.on('data', (chunk) => {
|
|
275
|
+
appendToBuffer(chunk);
|
|
276
|
+
teeToLog(chunk);
|
|
277
|
+
});
|
|
278
|
+
child.stderr?.on('data', (chunk) => {
|
|
279
|
+
appendToBuffer(chunk);
|
|
280
|
+
teeToLog(chunk);
|
|
281
|
+
});
|
|
282
|
+
const timeoutTimer = setTimeout(() => {
|
|
283
|
+
timedOut = true;
|
|
284
|
+
try {
|
|
285
|
+
child.kill('SIGTERM');
|
|
286
|
+
}
|
|
287
|
+
catch {
|
|
288
|
+
// ignore
|
|
289
|
+
}
|
|
290
|
+
killTimer = setTimeout(() => {
|
|
291
|
+
try {
|
|
292
|
+
child.kill('SIGKILL');
|
|
293
|
+
}
|
|
294
|
+
catch {
|
|
295
|
+
// ignore
|
|
296
|
+
}
|
|
297
|
+
}, KILL_GRACE_MS);
|
|
298
|
+
}, timeoutMs);
|
|
299
|
+
const finalize = (exitCode) => {
|
|
300
|
+
if (settled)
|
|
301
|
+
return;
|
|
302
|
+
settled = true;
|
|
303
|
+
clearTimeout(timeoutTimer);
|
|
304
|
+
if (killTimer)
|
|
305
|
+
clearTimeout(killTimer);
|
|
306
|
+
const durationMs = Date.now() - start;
|
|
307
|
+
let snippet = buffer.length > SNIPPET_LIMIT ? buffer.slice(buffer.length - SNIPPET_LIMIT) : buffer;
|
|
308
|
+
snippet = snippet.trim();
|
|
309
|
+
if (timedOut) {
|
|
310
|
+
const marker = `[timed out after ${Math.round(timeoutMs / 1000)}s]`;
|
|
311
|
+
snippet = snippet.length > 0 ? `${snippet}\n${marker}` : marker;
|
|
312
|
+
resolve({
|
|
313
|
+
kind,
|
|
314
|
+
command,
|
|
315
|
+
...(name ? { name } : {}),
|
|
316
|
+
exitCode: 124,
|
|
317
|
+
ok: false,
|
|
318
|
+
timedOut: true,
|
|
319
|
+
durationMs,
|
|
320
|
+
timeoutMs,
|
|
321
|
+
outputSnippet: snippet,
|
|
322
|
+
});
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
resolve({
|
|
326
|
+
kind,
|
|
327
|
+
command,
|
|
328
|
+
...(name ? { name } : {}),
|
|
329
|
+
exitCode,
|
|
330
|
+
ok: exitCode === 0,
|
|
331
|
+
timedOut: false,
|
|
332
|
+
durationMs,
|
|
333
|
+
timeoutMs,
|
|
334
|
+
outputSnippet: snippet,
|
|
335
|
+
});
|
|
336
|
+
};
|
|
337
|
+
child.on('error', (err) => {
|
|
338
|
+
if (settled)
|
|
339
|
+
return;
|
|
340
|
+
settled = true;
|
|
341
|
+
clearTimeout(timeoutTimer);
|
|
342
|
+
if (killTimer)
|
|
343
|
+
clearTimeout(killTimer);
|
|
344
|
+
resolve({
|
|
345
|
+
kind,
|
|
346
|
+
command,
|
|
347
|
+
...(name ? { name } : {}),
|
|
348
|
+
exitCode: -1,
|
|
349
|
+
ok: false,
|
|
350
|
+
timedOut: false,
|
|
351
|
+
durationMs: Date.now() - start,
|
|
352
|
+
timeoutMs,
|
|
353
|
+
outputSnippet: errorMessage(err),
|
|
354
|
+
});
|
|
355
|
+
});
|
|
356
|
+
child.on('close', (code, signal) => {
|
|
357
|
+
const exitCode = typeof code === 'number' ? code : signal ? 128 : -1;
|
|
358
|
+
finalize(exitCode);
|
|
359
|
+
});
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
catch (err) {
|
|
363
|
+
return {
|
|
364
|
+
kind,
|
|
365
|
+
command,
|
|
366
|
+
...(name ? { name } : {}),
|
|
367
|
+
exitCode: -1,
|
|
368
|
+
ok: false,
|
|
369
|
+
timedOut: false,
|
|
370
|
+
durationMs: Date.now() - start,
|
|
371
|
+
timeoutMs,
|
|
372
|
+
outputSnippet: errorMessage(err),
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
function runGit(cwd, args) {
|
|
377
|
+
return new Promise((resolve) => {
|
|
378
|
+
let child;
|
|
379
|
+
try {
|
|
380
|
+
child = spawn('git', args, {
|
|
381
|
+
cwd,
|
|
382
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
383
|
+
env: { ...process.env },
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
catch (err) {
|
|
387
|
+
resolve({ stdout: '', stderr: errorMessage(err), exitCode: -1 });
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
let stdout = '';
|
|
391
|
+
let stderr = '';
|
|
392
|
+
child.stdout?.on('data', (chunk) => {
|
|
393
|
+
stdout += chunk.toString('utf8');
|
|
394
|
+
});
|
|
395
|
+
child.stderr?.on('data', (chunk) => {
|
|
396
|
+
stderr += chunk.toString('utf8');
|
|
397
|
+
});
|
|
398
|
+
child.on('error', (err) => {
|
|
399
|
+
resolve({ stdout, stderr: stderr + errorMessage(err), exitCode: -1 });
|
|
400
|
+
});
|
|
401
|
+
child.on('close', (code, signal) => {
|
|
402
|
+
const exitCode = typeof code === 'number' ? code : signal ? 128 : -1;
|
|
403
|
+
resolve({ stdout, stderr, exitCode });
|
|
404
|
+
});
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
function dedupeSort(items) {
|
|
408
|
+
return Array.from(new Set(items)).sort((a, b) => a.localeCompare(b));
|
|
409
|
+
}
|
|
410
|
+
function unquoteGitPath(path) {
|
|
411
|
+
if (path.length >= 2 && path.startsWith('"') && path.endsWith('"')) {
|
|
412
|
+
const inner = path.slice(1, -1);
|
|
413
|
+
return inner.replace(/\\(.)/g, '$1');
|
|
414
|
+
}
|
|
415
|
+
return path;
|
|
416
|
+
}
|
|
417
|
+
function ensureLogDir(logPath) {
|
|
418
|
+
try {
|
|
419
|
+
mkdirSync(dirname(logPath), { recursive: true });
|
|
420
|
+
}
|
|
421
|
+
catch {
|
|
422
|
+
// ignore
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
function errorMessage(err) {
|
|
426
|
+
if (err instanceof Error)
|
|
427
|
+
return err.message;
|
|
428
|
+
return String(err);
|
|
429
|
+
}
|
package/dist/runs/run-record.js
CHANGED
|
@@ -5,6 +5,22 @@ export const runIdSchema = z.string().regex(/^R-\d{3,}$/, 'Run id must look like
|
|
|
5
5
|
export const runStatusSchema = z.enum(['queued', 'preparing', 'running', 'review', 'blocked', 'completed', 'failed']);
|
|
6
6
|
export const runnerTypeSchema = z.enum(['codex', 'claude']);
|
|
7
7
|
export const runReviewStateSchema = z.enum(['pending', 'approved', 'needs_changes', 'blocked', 'merged']);
|
|
8
|
+
export const runValidationKindSchema = z.enum(['typecheck', 'lint', 'test', 'build', 'custom']);
|
|
9
|
+
export const runValidationEntrySchema = z
|
|
10
|
+
.object({
|
|
11
|
+
kind: runValidationKindSchema,
|
|
12
|
+
command: z.string(),
|
|
13
|
+
name: z.string().optional(),
|
|
14
|
+
exit_code: z.number().int(),
|
|
15
|
+
ok: z.boolean(),
|
|
16
|
+
timed_out: z.boolean().default(false),
|
|
17
|
+
duration_ms: z.number().int().nonnegative().default(0),
|
|
18
|
+
// Resolved timeout for this step (defaulting by kind when not set explicitly).
|
|
19
|
+
// Persisted so the UI can show "ran in 12s / limit 60s" without re-deriving.
|
|
20
|
+
timeout_ms: z.number().int().nonnegative().default(0),
|
|
21
|
+
output_snippet: z.string().default(''),
|
|
22
|
+
})
|
|
23
|
+
.strict();
|
|
8
24
|
export const runRecordSchema = z
|
|
9
25
|
.object({
|
|
10
26
|
version: z.string().default(RUN_RECORD_VERSION),
|
|
@@ -22,6 +38,7 @@ export const runRecordSchema = z
|
|
|
22
38
|
changed_files: z.array(z.string()).default([]),
|
|
23
39
|
commands_run: z.array(z.string()).default([]),
|
|
24
40
|
validation_results: z.array(z.string()).default([]),
|
|
41
|
+
validation: z.array(runValidationEntrySchema).default([]),
|
|
25
42
|
blockers: z.array(z.string()).default([]),
|
|
26
43
|
follow_ups: z.array(z.string()).default([]),
|
|
27
44
|
review_state: runReviewStateSchema.default('pending'),
|
|
@@ -32,6 +49,13 @@ export const runRecordSchema = z
|
|
|
32
49
|
prompt_tokens_estimated_after: z.number().int().nonnegative().default(0),
|
|
33
50
|
prompt_budget_target: z.number().int().nonnegative().default(0),
|
|
34
51
|
prompt_trimmed_sections: z.array(z.string()).default([]),
|
|
52
|
+
parent_run_id: runIdSchema.nullable().default(null),
|
|
53
|
+
replay_reason: z.string().default(''),
|
|
54
|
+
// Dual-runner pipeline: siblings share a `pipeline_id` and carry 1-based
|
|
55
|
+
// step + total so downstream consumers can reconstruct the chain.
|
|
56
|
+
pipeline_id: z.string().nullable().default(null),
|
|
57
|
+
pipeline_step: z.number().int().positive().nullable().default(null),
|
|
58
|
+
pipeline_total: z.number().int().positive().nullable().default(null),
|
|
35
59
|
})
|
|
36
60
|
.strict();
|
|
37
61
|
export const runRecordCreateInputSchema = runRecordSchema
|
|
@@ -47,6 +71,7 @@ export const runRecordCreateInputSchema = runRecordSchema
|
|
|
47
71
|
changed_files: true,
|
|
48
72
|
commands_run: true,
|
|
49
73
|
validation_results: true,
|
|
74
|
+
validation: true,
|
|
50
75
|
blockers: true,
|
|
51
76
|
follow_ups: true,
|
|
52
77
|
review_state: true,
|
|
@@ -57,6 +82,11 @@ export const runRecordCreateInputSchema = runRecordSchema
|
|
|
57
82
|
prompt_tokens_estimated_after: true,
|
|
58
83
|
prompt_budget_target: true,
|
|
59
84
|
prompt_trimmed_sections: true,
|
|
85
|
+
parent_run_id: true,
|
|
86
|
+
replay_reason: true,
|
|
87
|
+
pipeline_id: true,
|
|
88
|
+
pipeline_step: true,
|
|
89
|
+
pipeline_total: true,
|
|
60
90
|
});
|
|
61
91
|
export const runRecordUpdateInputSchema = z
|
|
62
92
|
.object({
|
|
@@ -69,6 +99,7 @@ export const runRecordUpdateInputSchema = z
|
|
|
69
99
|
changed_files: z.array(z.string()).optional(),
|
|
70
100
|
commands_run: z.array(z.string()).optional(),
|
|
71
101
|
validation_results: z.array(z.string()).optional(),
|
|
102
|
+
validation: z.array(runValidationEntrySchema).optional(),
|
|
72
103
|
blockers: z.array(z.string()).optional(),
|
|
73
104
|
follow_ups: z.array(z.string()).optional(),
|
|
74
105
|
review_state: runReviewStateSchema.optional(),
|
|
@@ -79,6 +110,11 @@ export const runRecordUpdateInputSchema = z
|
|
|
79
110
|
prompt_tokens_estimated_after: z.number().int().nonnegative().optional(),
|
|
80
111
|
prompt_budget_target: z.number().int().nonnegative().optional(),
|
|
81
112
|
prompt_trimmed_sections: z.array(z.string()).optional(),
|
|
113
|
+
parent_run_id: runIdSchema.nullable().optional(),
|
|
114
|
+
replay_reason: z.string().optional(),
|
|
115
|
+
pipeline_id: z.string().nullable().optional(),
|
|
116
|
+
pipeline_step: z.number().int().positive().nullable().optional(),
|
|
117
|
+
pipeline_total: z.number().int().positive().nullable().optional(),
|
|
82
118
|
})
|
|
83
119
|
.strict();
|
|
84
120
|
export function createRunRecord(runId, input) {
|
|
@@ -98,6 +134,7 @@ export function createRunRecord(runId, input) {
|
|
|
98
134
|
changed_files: input.changed_files ?? [],
|
|
99
135
|
commands_run: input.commands_run ?? [],
|
|
100
136
|
validation_results: input.validation_results ?? [],
|
|
137
|
+
validation: input.validation ?? [],
|
|
101
138
|
blockers: input.blockers ?? [],
|
|
102
139
|
follow_ups: input.follow_ups ?? [],
|
|
103
140
|
review_state: input.review_state ?? 'pending',
|
|
@@ -108,6 +145,11 @@ export function createRunRecord(runId, input) {
|
|
|
108
145
|
prompt_tokens_estimated_after: input.prompt_tokens_estimated_after ?? 0,
|
|
109
146
|
prompt_budget_target: input.prompt_budget_target ?? 0,
|
|
110
147
|
prompt_trimmed_sections: input.prompt_trimmed_sections ?? [],
|
|
148
|
+
parent_run_id: input.parent_run_id ?? null,
|
|
149
|
+
replay_reason: input.replay_reason ?? '',
|
|
150
|
+
pipeline_id: input.pipeline_id ?? null,
|
|
151
|
+
pipeline_step: input.pipeline_step ?? null,
|
|
152
|
+
pipeline_total: input.pipeline_total ?? null,
|
|
111
153
|
};
|
|
112
154
|
return runRecordSchema.parse(normalized);
|
|
113
155
|
}
|
|
@@ -51,6 +51,7 @@ export class RunRecordRepository {
|
|
|
51
51
|
changed_files: parsedPatch.changed_files ?? existing.changed_files,
|
|
52
52
|
commands_run: parsedPatch.commands_run ?? existing.commands_run,
|
|
53
53
|
validation_results: parsedPatch.validation_results ?? existing.validation_results,
|
|
54
|
+
validation: parsedPatch.validation ?? existing.validation,
|
|
54
55
|
blockers: parsedPatch.blockers ?? existing.blockers,
|
|
55
56
|
follow_ups: parsedPatch.follow_ups ?? existing.follow_ups,
|
|
56
57
|
prompt_trimmed_sections: parsedPatch.prompt_trimmed_sections ?? existing.prompt_trimmed_sections,
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import { startSession, endSession, currentSession } from './session-service.js';
|
|
5
|
+
import { addWorklog, addNote, getActiveSessionId } from './event-service.js';
|
|
6
|
+
import { addArtifact } from './artifact-service.js';
|
|
7
|
+
export const HOOK_EVENTS = ['session-start', 'session-end', 'file-edit', 'user-prompt'];
|
|
8
|
+
export const hookEventSchema = z.enum(HOOK_EVENTS);
|
|
9
|
+
// Claude Code hook payload — all fields optional since the hook can be triggered by different
|
|
10
|
+
// events and other sources (Codex) may supply a subset.
|
|
11
|
+
export const hookPayloadSchema = z
|
|
12
|
+
.object({
|
|
13
|
+
session_id: z.string().optional(),
|
|
14
|
+
transcript_path: z.string().optional(),
|
|
15
|
+
cwd: z.string().optional(),
|
|
16
|
+
hook_event_name: z.string().optional(),
|
|
17
|
+
tool_name: z.string().optional(),
|
|
18
|
+
tool_input: z.record(z.string(), z.unknown()).optional(),
|
|
19
|
+
tool_response: z.record(z.string(), z.unknown()).optional(),
|
|
20
|
+
prompt: z.string().optional(),
|
|
21
|
+
message: z.string().optional(),
|
|
22
|
+
})
|
|
23
|
+
.passthrough();
|
|
24
|
+
const EDIT_TOOL_NAMES = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit']);
|
|
25
|
+
const MAX_PROMPT_LEN = 200;
|
|
26
|
+
function deriveActorName(payload, override) {
|
|
27
|
+
if (override && override.trim())
|
|
28
|
+
return override.trim();
|
|
29
|
+
const sid = typeof payload.session_id === 'string' ? payload.session_id.slice(0, 8) : '';
|
|
30
|
+
return sid ? `claude-code:${sid}` : 'claude-code';
|
|
31
|
+
}
|
|
32
|
+
function canonical(p) {
|
|
33
|
+
try {
|
|
34
|
+
return fs.realpathSync(p);
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return path.resolve(p);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
function toRelativePath(projectRoot, filePath) {
|
|
41
|
+
try {
|
|
42
|
+
const rel = path.relative(canonical(projectRoot), canonical(filePath));
|
|
43
|
+
if (rel && !rel.startsWith('..'))
|
|
44
|
+
return rel.replaceAll('\\', '/');
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
// fall through
|
|
48
|
+
}
|
|
49
|
+
return filePath;
|
|
50
|
+
}
|
|
51
|
+
// Lazy-open an agent session when an ambient event fires with no active one.
|
|
52
|
+
// Keeps ambient capture working even if SessionStart never ran (e.g. hooks installed
|
|
53
|
+
// mid-session). Returns the resolved session id (existing or newly created).
|
|
54
|
+
function ensureSession(db, projectId, actorName) {
|
|
55
|
+
const existing = getActiveSessionId(db, projectId);
|
|
56
|
+
if (existing)
|
|
57
|
+
return existing;
|
|
58
|
+
const started = startSession(db, { projectId, actor: 'agent', name: actorName });
|
|
59
|
+
if (!started.ok)
|
|
60
|
+
return null;
|
|
61
|
+
return started.sessionId;
|
|
62
|
+
}
|
|
63
|
+
export function handleHookEvent(input) {
|
|
64
|
+
const { db, projectId, projectRoot, event, payload } = input;
|
|
65
|
+
const actorName = deriveActorName(payload, input.actorName);
|
|
66
|
+
if (event === 'session-start') {
|
|
67
|
+
const active = currentSession(db, projectId);
|
|
68
|
+
if (active) {
|
|
69
|
+
return { ok: true, event, action: 'noop', detail: 'session already active', session_id: active.id };
|
|
70
|
+
}
|
|
71
|
+
const started = startSession(db, { projectId, actor: 'agent', name: actorName });
|
|
72
|
+
if (!started.ok) {
|
|
73
|
+
return { ok: true, event, action: 'noop', detail: started.reason };
|
|
74
|
+
}
|
|
75
|
+
return { ok: true, event, action: 'started', session_id: started.sessionId };
|
|
76
|
+
}
|
|
77
|
+
if (event === 'session-end') {
|
|
78
|
+
const active = currentSession(db, projectId);
|
|
79
|
+
if (!active) {
|
|
80
|
+
return { ok: true, event, action: 'noop', detail: 'no active session' };
|
|
81
|
+
}
|
|
82
|
+
const summary = typeof payload.message === 'string' && payload.message.trim()
|
|
83
|
+
? payload.message.trim()
|
|
84
|
+
: `Hook: session ended (${actorName})`;
|
|
85
|
+
const ended = endSession(db, { projectId, summary });
|
|
86
|
+
if (!ended.ok) {
|
|
87
|
+
return { ok: true, event, action: 'noop', detail: ended.reason };
|
|
88
|
+
}
|
|
89
|
+
return { ok: true, event, action: 'ended', session_id: ended.sessionId };
|
|
90
|
+
}
|
|
91
|
+
if (event === 'file-edit') {
|
|
92
|
+
const toolName = typeof payload.tool_name === 'string' ? payload.tool_name : '';
|
|
93
|
+
if (toolName && !EDIT_TOOL_NAMES.has(toolName)) {
|
|
94
|
+
return { ok: true, event, action: 'noop', detail: `ignored tool: ${toolName}` };
|
|
95
|
+
}
|
|
96
|
+
const toolInput = (payload.tool_input ?? {});
|
|
97
|
+
const rawPath = typeof toolInput.file_path === 'string' ? toolInput.file_path : '';
|
|
98
|
+
if (!rawPath) {
|
|
99
|
+
return { ok: true, event, action: 'noop', detail: 'no file_path in tool_input' };
|
|
100
|
+
}
|
|
101
|
+
const relPath = toRelativePath(projectRoot, rawPath);
|
|
102
|
+
const sessionId = ensureSession(db, projectId, actorName);
|
|
103
|
+
const { eventId } = addWorklog(db, {
|
|
104
|
+
projectId,
|
|
105
|
+
done: `Edited ${relPath} via ${toolName || 'Claude Code'}`,
|
|
106
|
+
files: relPath,
|
|
107
|
+
by: 'agent',
|
|
108
|
+
sessionId,
|
|
109
|
+
});
|
|
110
|
+
addArtifact(db, { projectId, path: relPath, kind: 'file' });
|
|
111
|
+
return { ok: true, event, action: 'recorded', session_id: sessionId, event_id: eventId };
|
|
112
|
+
}
|
|
113
|
+
if (event === 'user-prompt') {
|
|
114
|
+
const prompt = typeof payload.prompt === 'string' ? payload.prompt.trim() : '';
|
|
115
|
+
if (!prompt) {
|
|
116
|
+
return { ok: true, event, action: 'noop', detail: 'empty prompt' };
|
|
117
|
+
}
|
|
118
|
+
const truncated = prompt.length > MAX_PROMPT_LEN ? `${prompt.slice(0, MAX_PROMPT_LEN)}…` : prompt;
|
|
119
|
+
const sessionId = ensureSession(db, projectId, actorName);
|
|
120
|
+
const eventId = addNote(db, {
|
|
121
|
+
projectId,
|
|
122
|
+
title: 'User prompt',
|
|
123
|
+
text: truncated,
|
|
124
|
+
by: 'agent',
|
|
125
|
+
sessionId,
|
|
126
|
+
});
|
|
127
|
+
return { ok: true, event, action: 'recorded', session_id: sessionId, event_id: eventId };
|
|
128
|
+
}
|
|
129
|
+
return { ok: true, event, action: 'noop', detail: 'unknown event' };
|
|
130
|
+
}
|