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
|
@@ -1,28 +1,84 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
1
4
|
import { nowIso } from '../lib/format.js';
|
|
2
5
|
import { compileTaskPrompt } from '../prompts/prompt-service.js';
|
|
3
6
|
import { getTaskPacket } from '../tasks/task-service.js';
|
|
4
7
|
import { getRunnerAdapter } from '../runners/factory.js';
|
|
5
|
-
import { loadRunnerPreferences } from '../runners/config.js';
|
|
6
|
-
import { updateRunRecordEntry } from '../runs/run-service.js';
|
|
8
|
+
import { loadRunnerPreferences, loadReviewPreferences } from '../runners/config.js';
|
|
9
|
+
import { getRunRecord, updateRunRecordEntry } from '../runs/run-service.js';
|
|
7
10
|
import { saveTaskPacket } from '../tasks/task-service.js';
|
|
8
|
-
|
|
11
|
+
import { captureWorkingTreeSnapshot, captureFilesChangedSince, runValidationCommands, formatValidationResultsForRecord, normalizeValidationStep, } from '../runs/capture.js';
|
|
12
|
+
// Open the compiled prompt in the user's editor before executing. Blocks the run until the
|
|
13
|
+
// editor exits so the runner reads the saved edits. Falls back silently if no editor is
|
|
14
|
+
// available or the spawn fails — the run proceeds with the unedited prompt.
|
|
15
|
+
async function openPromptInEditor(promptPath) {
|
|
16
|
+
const editor = process.env.VISUAL || process.env.EDITOR || 'vi';
|
|
17
|
+
console.error(`Opening prompt in ${editor}: ${promptPath}`);
|
|
18
|
+
console.error('Save and exit the editor to continue the run.');
|
|
19
|
+
await new Promise((resolve) => {
|
|
20
|
+
try {
|
|
21
|
+
const child = spawn(editor, [promptPath], { stdio: 'inherit' });
|
|
22
|
+
child.on('close', () => resolve());
|
|
23
|
+
child.on('error', () => resolve());
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
resolve();
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
function toRunValidationEntry(r) {
|
|
31
|
+
return {
|
|
32
|
+
kind: r.kind,
|
|
33
|
+
command: r.command,
|
|
34
|
+
...(r.name ? { name: r.name } : {}),
|
|
35
|
+
exit_code: r.exitCode,
|
|
36
|
+
ok: r.ok,
|
|
37
|
+
timed_out: r.timedOut,
|
|
38
|
+
duration_ms: r.durationMs,
|
|
39
|
+
timeout_ms: r.timeoutMs,
|
|
40
|
+
output_snippet: r.outputSnippet,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
export async function runTaskExecution(input) {
|
|
9
44
|
const prefs = loadRunnerPreferences(input.rootPath);
|
|
10
45
|
const dryRun = Boolean(input.dryRun);
|
|
11
46
|
const task = getTaskPacket(input.rootPath, input.taskId);
|
|
12
47
|
const runner = input.runner ?? task.tracking.assigned_runner ?? prefs.default_runner;
|
|
13
48
|
const agentRole = input.agentRole ?? task.tracking.assigned_agent_role ?? prefs.default_agent_role;
|
|
49
|
+
const worktree = (task.tracking.worktree ?? '').trim();
|
|
50
|
+
let cwd;
|
|
51
|
+
if (worktree.length > 0) {
|
|
52
|
+
if (!fs.existsSync(worktree)) {
|
|
53
|
+
throw new Error(`Task ${task.task_id} tracking.worktree points to ${worktree} but the directory does not exist.`);
|
|
54
|
+
}
|
|
55
|
+
cwd = worktree;
|
|
56
|
+
}
|
|
57
|
+
else {
|
|
58
|
+
cwd = input.rootPath;
|
|
59
|
+
}
|
|
14
60
|
const compiled = compileTaskPrompt({
|
|
15
61
|
rootPath: input.rootPath,
|
|
16
62
|
taskId: task.task_id,
|
|
17
63
|
runner,
|
|
18
64
|
agentRole,
|
|
65
|
+
...(input.parentRunId ? { parentRunId: input.parentRunId } : {}),
|
|
66
|
+
...(input.replayReason ? { replayReason: input.replayReason } : {}),
|
|
67
|
+
...(input.linkedContext ? { linkedContext: input.linkedContext } : {}),
|
|
68
|
+
...(input.pipelineId ? { pipelineId: input.pipelineId } : {}),
|
|
69
|
+
...(input.pipelineStep ? { pipelineStep: input.pipelineStep } : {}),
|
|
70
|
+
...(input.pipelineTotal ? { pipelineTotal: input.pipelineTotal } : {}),
|
|
19
71
|
});
|
|
72
|
+
if (input.editPrompt && !dryRun) {
|
|
73
|
+
await openPromptInEditor(compiled.prompt_path);
|
|
74
|
+
}
|
|
75
|
+
const logPath = path.resolve(path.join(input.rootPath, '.sidecar', 'runs', 'logs', `${compiled.run_id}.log`));
|
|
20
76
|
const adapter = getRunnerAdapter(runner);
|
|
21
77
|
saveTaskPacket(input.rootPath, { ...task, status: 'running' });
|
|
22
78
|
updateRunRecordEntry(input.rootPath, compiled.run_id, {
|
|
23
79
|
status: 'running',
|
|
24
80
|
branch: task.tracking.branch,
|
|
25
|
-
worktree:
|
|
81
|
+
worktree: cwd,
|
|
26
82
|
});
|
|
27
83
|
const prepared = adapter.prepare({
|
|
28
84
|
runId: compiled.run_id,
|
|
@@ -31,20 +87,81 @@ export function runTaskExecution(input) {
|
|
|
31
87
|
promptPath: compiled.prompt_path,
|
|
32
88
|
projectRoot: input.rootPath,
|
|
33
89
|
});
|
|
34
|
-
const
|
|
90
|
+
const preRunSnapshot = !dryRun ? await captureWorkingTreeSnapshot(cwd) : null;
|
|
91
|
+
const executed = await adapter.execute({
|
|
92
|
+
prepared,
|
|
93
|
+
dryRun,
|
|
94
|
+
cwd,
|
|
95
|
+
logPath,
|
|
96
|
+
streamOutput: input.streamOutput,
|
|
97
|
+
});
|
|
35
98
|
const collected = adapter.collectResult(executed);
|
|
36
|
-
|
|
37
|
-
const
|
|
38
|
-
|
|
99
|
+
let ok = collected.ok;
|
|
100
|
+
const blockers = [...collected.blockers];
|
|
101
|
+
let validationResults = collected.validationResults;
|
|
102
|
+
let validationEntries = [];
|
|
103
|
+
let validationFailed = false;
|
|
104
|
+
let validationAttempted = false;
|
|
105
|
+
let changedFiles = [];
|
|
106
|
+
if (!dryRun && collected.executed && preRunSnapshot) {
|
|
107
|
+
changedFiles = await captureFilesChangedSince(cwd, preRunSnapshot);
|
|
108
|
+
}
|
|
109
|
+
if (!dryRun && collected.executed && ok) {
|
|
110
|
+
const configured = task.execution?.commands?.validation ?? [];
|
|
111
|
+
const steps = [];
|
|
112
|
+
for (const entry of configured) {
|
|
113
|
+
const normalized = normalizeValidationStep(entry);
|
|
114
|
+
if (normalized)
|
|
115
|
+
steps.push(normalized);
|
|
116
|
+
}
|
|
117
|
+
if (steps.length > 0) {
|
|
118
|
+
validationAttempted = true;
|
|
119
|
+
const results = await runValidationCommands(cwd, steps, logPath);
|
|
120
|
+
validationResults = formatValidationResultsForRecord(results);
|
|
121
|
+
validationEntries = results.map(toRunValidationEntry);
|
|
122
|
+
const failed = results.filter((r) => !r.ok);
|
|
123
|
+
if (failed.length > 0) {
|
|
124
|
+
validationFailed = true;
|
|
125
|
+
ok = false;
|
|
126
|
+
for (const f of failed) {
|
|
127
|
+
blockers.push(`validation failed (${f.kind}): ${f.command}`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
const finishedStatus = ok ? 'completed' : 'failed';
|
|
133
|
+
const nextTaskStatus = ok ? 'review' : 'blocked';
|
|
134
|
+
// Auto-approve on all-green is opt-in via preferences. We only auto-approve when at least
|
|
135
|
+
// one validation step actually ran and every step passed — a runner-only success with no
|
|
136
|
+
// validation configured still requires a human click.
|
|
137
|
+
const reviewPrefs = loadReviewPreferences(input.rootPath);
|
|
138
|
+
const shouldAutoApprove = reviewPrefs.auto_approve_on_all_green && ok && validationAttempted && validationEntries.every((e) => e.ok);
|
|
139
|
+
saveTaskPacket(input.rootPath, {
|
|
140
|
+
...getTaskPacket(input.rootPath, task.task_id),
|
|
141
|
+
status: nextTaskStatus,
|
|
142
|
+
});
|
|
39
143
|
updateRunRecordEntry(input.rootPath, compiled.run_id, {
|
|
40
144
|
status: finishedStatus,
|
|
41
145
|
completed_at: nowIso(),
|
|
42
146
|
summary: collected.summary,
|
|
43
147
|
commands_run: collected.commandsRun,
|
|
44
|
-
validation_results:
|
|
45
|
-
|
|
148
|
+
validation_results: validationResults,
|
|
149
|
+
validation: validationEntries,
|
|
150
|
+
blockers,
|
|
46
151
|
follow_ups: collected.followUps,
|
|
152
|
+
changed_files: changedFiles,
|
|
153
|
+
...(shouldAutoApprove
|
|
154
|
+
? {
|
|
155
|
+
review_state: 'approved',
|
|
156
|
+
reviewed_at: nowIso(),
|
|
157
|
+
reviewed_by: 'sidecar:auto',
|
|
158
|
+
review_note: `Auto-approved: ${validationEntries.length} validation step(s) passed`,
|
|
159
|
+
}
|
|
160
|
+
: {}),
|
|
47
161
|
});
|
|
162
|
+
const summary = validationFailed
|
|
163
|
+
? `Runner ok, but validation failed. ${collected.summary}`
|
|
164
|
+
: collected.summary;
|
|
48
165
|
return {
|
|
49
166
|
task_id: task.task_id,
|
|
50
167
|
run_id: compiled.run_id,
|
|
@@ -54,6 +171,88 @@ export function runTaskExecution(input) {
|
|
|
54
171
|
status: finishedStatus,
|
|
55
172
|
dry_run: dryRun,
|
|
56
173
|
shell_command: prepared.shellLine,
|
|
57
|
-
summary
|
|
174
|
+
summary,
|
|
175
|
+
changed_files: changedFiles,
|
|
176
|
+
log_path: dryRun ? null : logPath,
|
|
177
|
+
duration_ms: executed.durationMs ?? 0,
|
|
58
178
|
};
|
|
59
179
|
}
|
|
180
|
+
function generatePipelineId() {
|
|
181
|
+
const ts = Date.now().toString(36);
|
|
182
|
+
const rand = Math.random().toString(36).slice(2, 8);
|
|
183
|
+
return `PL-${ts}-${rand}`;
|
|
184
|
+
}
|
|
185
|
+
function tailLogFile(logPath, maxChars) {
|
|
186
|
+
if (!logPath)
|
|
187
|
+
return '';
|
|
188
|
+
try {
|
|
189
|
+
const raw = fs.readFileSync(logPath, 'utf8');
|
|
190
|
+
return raw.length > maxChars ? raw.slice(raw.length - maxChars) : raw;
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
return '';
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
function summarizeRunForPipeline(rootPath, step) {
|
|
197
|
+
let validationSummary;
|
|
198
|
+
try {
|
|
199
|
+
const record = getRunRecord(rootPath, step.run_id);
|
|
200
|
+
const v = record.validation ?? [];
|
|
201
|
+
if (v.length > 0) {
|
|
202
|
+
const ok = v.filter((x) => x.ok).length;
|
|
203
|
+
const failed = v.filter((x) => !x.ok && !x.timed_out).length;
|
|
204
|
+
const timed = v.filter((x) => x.timed_out).length;
|
|
205
|
+
const parts = [`${ok}/${v.length} ok`];
|
|
206
|
+
if (failed > 0)
|
|
207
|
+
parts.push(`${failed} failed`);
|
|
208
|
+
if (timed > 0)
|
|
209
|
+
parts.push(`${timed} timed-out`);
|
|
210
|
+
validationSummary = parts.join(', ');
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
catch {
|
|
214
|
+
// missing record shouldn't block the next step — leave summary blank
|
|
215
|
+
}
|
|
216
|
+
return {
|
|
217
|
+
run_id: step.run_id,
|
|
218
|
+
runner: step.runner_type,
|
|
219
|
+
agent_role: step.agent_role,
|
|
220
|
+
status: step.status,
|
|
221
|
+
summary: step.summary,
|
|
222
|
+
changed_files: step.changed_files,
|
|
223
|
+
...(validationSummary ? { validation_summary: validationSummary } : {}),
|
|
224
|
+
log_tail: tailLogFile(step.log_path, 1500),
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
// Sequential dual-runner pipeline. Each step sees all prior steps' summaries
|
|
228
|
+
// (run id, runner, validation outcome, changed files, log tail) as
|
|
229
|
+
// `previous_runs` linked context on its compiled prompt. Runs share a
|
|
230
|
+
// `pipeline_id` and carry 1-based `pipeline_step` + `pipeline_total` so the
|
|
231
|
+
// chain is reconstructable. A step that fails does NOT short-circuit the
|
|
232
|
+
// pipeline — downstream runners may be set up explicitly to fix failures.
|
|
233
|
+
export async function runPipelineExecution(input) {
|
|
234
|
+
if (input.runners.length === 0)
|
|
235
|
+
throw new Error('runPipelineExecution requires at least one runner');
|
|
236
|
+
const pipelineId = generatePipelineId();
|
|
237
|
+
const steps = [];
|
|
238
|
+
for (let i = 0; i < input.runners.length; i++) {
|
|
239
|
+
const runner = input.runners[i];
|
|
240
|
+
const previousRuns = steps.map((s) => summarizeRunForPipeline(input.rootPath, s));
|
|
241
|
+
const linkedContext = previousRuns.length > 0 ? { previous_runs: previousRuns } : undefined;
|
|
242
|
+
const stepResult = await runTaskExecution({
|
|
243
|
+
rootPath: input.rootPath,
|
|
244
|
+
taskId: input.taskId,
|
|
245
|
+
runner,
|
|
246
|
+
...(input.agentRole ? { agentRole: input.agentRole } : {}),
|
|
247
|
+
...(input.dryRun != null ? { dryRun: input.dryRun } : {}),
|
|
248
|
+
...(input.streamOutput ? { streamOutput: input.streamOutput } : {}),
|
|
249
|
+
...(input.editPrompt != null ? { editPrompt: input.editPrompt } : {}),
|
|
250
|
+
...(linkedContext ? { linkedContext } : {}),
|
|
251
|
+
pipelineId,
|
|
252
|
+
pipelineStep: i + 1,
|
|
253
|
+
pipelineTotal: input.runners.length,
|
|
254
|
+
});
|
|
255
|
+
steps.push(stepResult);
|
|
256
|
+
}
|
|
257
|
+
return { pipeline_id: pipelineId, steps };
|
|
258
|
+
}
|
|
@@ -52,7 +52,7 @@ export function createFollowupTaskFromRun(rootPath, runId) {
|
|
|
52
52
|
files_to_avoid: sourceTask.implementation.files_to_avoid,
|
|
53
53
|
technical_constraints: sourceTask.constraints.technical,
|
|
54
54
|
design_constraints: sourceTask.constraints.design,
|
|
55
|
-
validation_commands: sourceTask.execution.commands.validation,
|
|
55
|
+
validation_commands: sourceTask.execution.commands.validation.map((v) => ({ ...v })),
|
|
56
56
|
definition_of_done: [...sourceTask.definition_of_done, ...suggestions],
|
|
57
57
|
});
|
|
58
58
|
return {
|
|
@@ -6,6 +6,23 @@ export const taskPacketPrioritySchema = z.enum(['low', 'medium', 'high']);
|
|
|
6
6
|
export const taskPacketTypeSchema = z.enum(['feature', 'bug', 'chore', 'research']);
|
|
7
7
|
export const taskAgentRoleSchema = z.enum(['planner', 'builder-ui', 'builder-app', 'reviewer', 'tester']);
|
|
8
8
|
export const taskRunnerSchema = z.enum(['codex', 'claude']);
|
|
9
|
+
export const validationKindSchema = z.enum(['typecheck', 'lint', 'test', 'build', 'custom']);
|
|
10
|
+
// Accept string entries ("npm test") or object entries ({kind,command,...}). String entries
|
|
11
|
+
// are promoted to { kind: 'custom', command }. This preserves the v1 packet shape while
|
|
12
|
+
// giving new packets first-class typed validation steps.
|
|
13
|
+
export const validationStepSchema = z.preprocess((raw) => {
|
|
14
|
+
if (typeof raw === 'string') {
|
|
15
|
+
return { kind: 'custom', command: raw };
|
|
16
|
+
}
|
|
17
|
+
return raw;
|
|
18
|
+
}, z
|
|
19
|
+
.object({
|
|
20
|
+
kind: validationKindSchema.default('custom'),
|
|
21
|
+
command: z.string().min(1, 'validation command is required'),
|
|
22
|
+
name: z.string().optional(),
|
|
23
|
+
timeout_ms: z.number().int().positive().optional(),
|
|
24
|
+
})
|
|
25
|
+
.strict());
|
|
9
26
|
export const taskPacketSchema = z
|
|
10
27
|
.object({
|
|
11
28
|
version: z.string().default(TASK_PACKET_VERSION),
|
|
@@ -42,7 +59,7 @@ export const taskPacketSchema = z
|
|
|
42
59
|
}),
|
|
43
60
|
execution: z.object({
|
|
44
61
|
commands: z.object({
|
|
45
|
-
validation: z.array(
|
|
62
|
+
validation: z.array(validationStepSchema).default([]),
|
|
46
63
|
}),
|
|
47
64
|
}),
|
|
48
65
|
dependencies: z.array(taskIdSchema).default([]),
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { TaskPacketRepository } from './task-repository.js';
|
|
2
2
|
import { createTaskPacket, } from './task-packet.js';
|
|
3
|
+
import { normalizeValidationStep } from '../runs/capture.js';
|
|
3
4
|
export function createTaskPacketRecord(rootPath, input) {
|
|
4
5
|
const repo = new TaskPacketRepository(rootPath);
|
|
5
6
|
const taskId = repo.generateNextTaskId();
|
|
@@ -28,7 +29,9 @@ export function createTaskPacketRecord(rootPath, input) {
|
|
|
28
29
|
},
|
|
29
30
|
execution: {
|
|
30
31
|
commands: {
|
|
31
|
-
validation: input.validation_commands ?? []
|
|
32
|
+
validation: (input.validation_commands ?? [])
|
|
33
|
+
.map((v) => normalizeValidationStep(v))
|
|
34
|
+
.filter((v) => v !== null),
|
|
32
35
|
},
|
|
33
36
|
},
|
|
34
37
|
dependencies: input.dependencies ?? [],
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Claude Code hook settings template for Sidecar ambient capture.
|
|
2
|
+
// Drop into .claude/settings.json (project) or ~/.claude/settings.json (user) —
|
|
3
|
+
// Claude Code merges arrays across scopes.
|
|
4
|
+
//
|
|
5
|
+
// Also referenced from the README "Ambient capture via hooks" section and emitted
|
|
6
|
+
// by `sidecar hooks print` for copy/paste.
|
|
7
|
+
export const CLAUDE_CODE_HOOK_SETTINGS = {
|
|
8
|
+
hooks: {
|
|
9
|
+
SessionStart: [
|
|
10
|
+
{
|
|
11
|
+
hooks: [{ type: 'command', command: 'sidecar hook session-start' }],
|
|
12
|
+
},
|
|
13
|
+
],
|
|
14
|
+
SessionEnd: [
|
|
15
|
+
{
|
|
16
|
+
hooks: [{ type: 'command', command: 'sidecar hook session-end' }],
|
|
17
|
+
},
|
|
18
|
+
],
|
|
19
|
+
PostToolUse: [
|
|
20
|
+
{
|
|
21
|
+
matcher: 'Edit|Write|MultiEdit|NotebookEdit',
|
|
22
|
+
hooks: [{ type: 'command', command: 'sidecar hook file-edit' }],
|
|
23
|
+
},
|
|
24
|
+
],
|
|
25
|
+
UserPromptSubmit: [
|
|
26
|
+
{
|
|
27
|
+
hooks: [{ type: 'command', command: 'sidecar hook user-prompt' }],
|
|
28
|
+
},
|
|
29
|
+
],
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
export function renderClaudeCodeHooksJson() {
|
|
33
|
+
return JSON.stringify(CLAUDE_CODE_HOOK_SETTINGS, null, 2);
|
|
34
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sidecar-cli",
|
|
3
|
-
"version": "0.1.5-beta.
|
|
3
|
+
"version": "0.1.5-beta.2",
|
|
4
4
|
"description": "Local-first project memory and recording tool",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"build": "npm run clean && tsc -p tsconfig.json",
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
"chalk": "^5.6.2",
|
|
30
30
|
"commander": "^14.0.3",
|
|
31
31
|
"semver": "^7.7.4",
|
|
32
|
+
"yaml": "^2.8.3",
|
|
32
33
|
"zod": "^4.3.6"
|
|
33
34
|
},
|
|
34
35
|
"devDependencies": {
|