sidecar-cli 0.1.2 → 0.1.3-beta.1

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.
@@ -0,0 +1,59 @@
1
+ import { nowIso } from '../lib/format.js';
2
+ import { compileTaskPrompt } from '../prompts/prompt-service.js';
3
+ import { getTaskPacket } from '../tasks/task-service.js';
4
+ import { getRunnerAdapter } from '../runners/factory.js';
5
+ import { loadRunnerPreferences } from '../runners/config.js';
6
+ import { updateRunRecordEntry } from '../runs/run-service.js';
7
+ import { saveTaskPacket } from '../tasks/task-service.js';
8
+ export function runTaskExecution(input) {
9
+ const prefs = loadRunnerPreferences(input.rootPath);
10
+ const dryRun = Boolean(input.dryRun);
11
+ const task = getTaskPacket(input.rootPath, input.taskId);
12
+ const runner = input.runner ?? task.tracking.assigned_runner ?? prefs.default_runner;
13
+ const agentRole = input.agentRole ?? task.tracking.assigned_agent_role ?? prefs.default_agent_role;
14
+ const compiled = compileTaskPrompt({
15
+ rootPath: input.rootPath,
16
+ taskId: task.task_id,
17
+ runner,
18
+ agentRole,
19
+ });
20
+ const adapter = getRunnerAdapter(runner);
21
+ saveTaskPacket(input.rootPath, { ...task, status: 'running' });
22
+ updateRunRecordEntry(input.rootPath, compiled.run_id, {
23
+ status: 'running',
24
+ branch: task.tracking.branch,
25
+ worktree: task.tracking.worktree || input.rootPath,
26
+ });
27
+ const prepared = adapter.prepare({
28
+ runId: compiled.run_id,
29
+ taskId: task.task_id,
30
+ agentRole,
31
+ promptPath: compiled.prompt_path,
32
+ projectRoot: input.rootPath,
33
+ });
34
+ const executed = adapter.execute({ prepared, dryRun });
35
+ const collected = adapter.collectResult(executed);
36
+ const finishedStatus = collected.ok ? 'completed' : 'failed';
37
+ const nextTaskStatus = collected.ok ? 'review' : 'blocked';
38
+ saveTaskPacket(input.rootPath, { ...getTaskPacket(input.rootPath, task.task_id), status: nextTaskStatus });
39
+ updateRunRecordEntry(input.rootPath, compiled.run_id, {
40
+ status: finishedStatus,
41
+ completed_at: nowIso(),
42
+ summary: collected.summary,
43
+ commands_run: collected.commandsRun,
44
+ validation_results: collected.validationResults,
45
+ blockers: collected.blockers,
46
+ follow_ups: collected.followUps,
47
+ });
48
+ return {
49
+ task_id: task.task_id,
50
+ run_id: compiled.run_id,
51
+ runner_type: runner,
52
+ agent_role: agentRole,
53
+ prompt_path: compiled.prompt_path,
54
+ status: finishedStatus,
55
+ dry_run: dryRun,
56
+ shell_command: prepared.shellLine,
57
+ summary: collected.summary,
58
+ };
59
+ }
@@ -0,0 +1,76 @@
1
+ import { SidecarError } from '../lib/errors.js';
2
+ import { nowIso } from '../lib/format.js';
3
+ import { getRunRecord, listRunRecords, updateRunRecordEntry } from '../runs/run-service.js';
4
+ import { createTaskPacketRecord, getTaskPacket, saveTaskPacket } from '../tasks/task-service.js';
5
+ function taskStatusForReview(state) {
6
+ if (state === 'approved')
7
+ return 'review';
8
+ if (state === 'needs_changes')
9
+ return 'ready';
10
+ if (state === 'blocked')
11
+ return 'blocked';
12
+ if (state === 'merged')
13
+ return 'done';
14
+ return 'review';
15
+ }
16
+ export function reviewRun(rootPath, runId, state, options) {
17
+ const run = getRunRecord(rootPath, runId);
18
+ if (run.status !== 'completed' && run.status !== 'failed' && run.status !== 'blocked') {
19
+ throw new SidecarError('Run must be completed, failed, or blocked before review actions');
20
+ }
21
+ updateRunRecordEntry(rootPath, run.run_id, {
22
+ review_state: state,
23
+ reviewed_at: nowIso(),
24
+ reviewed_by: options?.by ?? 'human',
25
+ review_note: options?.note ?? '',
26
+ });
27
+ const task = getTaskPacket(rootPath, run.task_id);
28
+ const nextTaskStatus = taskStatusForReview(state);
29
+ saveTaskPacket(rootPath, { ...task, status: nextTaskStatus });
30
+ return {
31
+ run_id: run.run_id,
32
+ task_id: run.task_id,
33
+ review_state: state,
34
+ task_status: nextTaskStatus,
35
+ };
36
+ }
37
+ export function createFollowupTaskFromRun(rootPath, runId) {
38
+ const run = getRunRecord(rootPath, runId);
39
+ const sourceTask = getTaskPacket(rootPath, run.task_id);
40
+ const suggestions = run.follow_ups.length > 0 ? run.follow_ups : ['Investigate run issues and apply required changes'];
41
+ const created = createTaskPacketRecord(rootPath, {
42
+ title: `Follow-up: ${sourceTask.title}`,
43
+ summary: run.review_note || run.summary || 'Follow-up work from reviewed run',
44
+ goal: suggestions.join('; '),
45
+ type: sourceTask.type,
46
+ status: 'draft',
47
+ priority: sourceTask.priority,
48
+ dependencies: [sourceTask.task_id],
49
+ tags: Array.from(new Set([...sourceTask.tags, 'follow-up'])),
50
+ target_areas: sourceTask.target_areas,
51
+ files_to_read: sourceTask.implementation.files_to_read,
52
+ files_to_avoid: sourceTask.implementation.files_to_avoid,
53
+ technical_constraints: sourceTask.constraints.technical,
54
+ design_constraints: sourceTask.constraints.design,
55
+ validation_commands: sourceTask.execution.commands.validation,
56
+ definition_of_done: [...sourceTask.definition_of_done, ...suggestions],
57
+ });
58
+ return {
59
+ source_run_id: run.run_id,
60
+ task_id: created.task.task_id,
61
+ title: created.task.title,
62
+ };
63
+ }
64
+ export function buildReviewSummary(rootPath) {
65
+ const runs = listRunRecords(rootPath);
66
+ return {
67
+ completed_runs: runs.filter((r) => r.status === 'completed').length,
68
+ blocked_runs: runs.filter((r) => r.status === 'blocked' || r.review_state === 'blocked').length,
69
+ suggested_follow_ups: runs.reduce((acc, r) => acc + r.follow_ups.length, 0),
70
+ recently_merged: runs
71
+ .filter((r) => r.review_state === 'merged' && r.reviewed_at)
72
+ .sort((a, b) => String(b.reviewed_at).localeCompare(String(a.reviewed_at)))
73
+ .slice(0, 10)
74
+ .map((r) => ({ run_id: r.run_id, task_id: r.task_id, reviewed_at: r.reviewed_at || '' })),
75
+ };
76
+ }
@@ -0,0 +1,94 @@
1
+ import { nowIso } from '../lib/format.js';
2
+ import { getTaskPacket, listTaskPackets, saveTaskPacket } from '../tasks/task-service.js';
3
+ function hasUiSignal(task) {
4
+ const joined = [
5
+ ...task.tags,
6
+ ...task.target_areas,
7
+ ...task.implementation.files_to_read,
8
+ ...task.implementation.files_to_avoid,
9
+ ]
10
+ .join(' ')
11
+ .toLowerCase();
12
+ return /(ui|frontend|css|html|react|view|component)/.test(joined);
13
+ }
14
+ function pickRole(task) {
15
+ if (task.type === 'research')
16
+ return { role: 'planner', reason: 'task type is research' };
17
+ if (task.tags.some((t) => t.toLowerCase() === 'test') || task.target_areas.some((a) => /test/i.test(a))) {
18
+ return { role: 'tester', reason: 'tags/target_areas indicate testing' };
19
+ }
20
+ if (task.tags.some((t) => /review/i.test(t)) || task.type === 'bug') {
21
+ return { role: 'reviewer', reason: 'bug/review signal present' };
22
+ }
23
+ if (hasUiSignal(task))
24
+ return { role: 'builder-ui', reason: 'ui/frontend signal detected' };
25
+ return { role: 'builder-app', reason: 'default app implementation path' };
26
+ }
27
+ function defaultRunnerForRole(role) {
28
+ if (role === 'reviewer' || role === 'planner')
29
+ return 'claude';
30
+ return 'codex';
31
+ }
32
+ export function dependenciesMet(task, tasksById) {
33
+ const missing = task.dependencies.filter((depId) => tasksById.get(depId)?.status !== 'done');
34
+ return { ok: missing.length === 0, missing };
35
+ }
36
+ export function assignTask(rootPath, taskId, override) {
37
+ const task = getTaskPacket(rootPath, taskId);
38
+ const auto = pickRole(task);
39
+ const role = override?.role ?? auto.role;
40
+ const runner = override?.runner ?? defaultRunnerForRole(role);
41
+ const reason = override?.role || override?.runner ? 'manual override' : auto.reason;
42
+ const updated = {
43
+ ...task,
44
+ tracking: {
45
+ ...task.tracking,
46
+ assigned_agent_role: role,
47
+ assigned_runner: runner,
48
+ assignment_reason: reason,
49
+ assigned_at: nowIso(),
50
+ },
51
+ };
52
+ saveTaskPacket(rootPath, updated);
53
+ return { task_id: task.task_id, agent_role: role, runner, reason };
54
+ }
55
+ export function queueReadyTasks(rootPath) {
56
+ const tasks = listTaskPackets(rootPath);
57
+ const byId = new Map(tasks.map((t) => [t.task_id, t]));
58
+ const decisions = [];
59
+ for (const task of tasks) {
60
+ if (task.status !== 'ready')
61
+ continue;
62
+ const dep = dependenciesMet(task, byId);
63
+ if (!dep.ok) {
64
+ saveTaskPacket(rootPath, { ...task, status: 'blocked' });
65
+ decisions.push({ task_id: task.task_id, queued: false, reason: `blocked by dependencies: ${dep.missing.join(', ')}` });
66
+ continue;
67
+ }
68
+ const assignment = task.tracking.assigned_agent_role && task.tracking.assigned_runner
69
+ ? {
70
+ role: task.tracking.assigned_agent_role,
71
+ runner: task.tracking.assigned_runner,
72
+ }
73
+ : (() => {
74
+ const decided = assignTask(rootPath, task.task_id);
75
+ return { role: decided.agent_role, runner: decided.runner };
76
+ })();
77
+ const latest = getTaskPacket(rootPath, task.task_id);
78
+ saveTaskPacket(rootPath, {
79
+ ...latest,
80
+ status: 'queued',
81
+ tracking: {
82
+ ...latest.tracking,
83
+ assigned_agent_role: assignment.role,
84
+ assigned_runner: assignment.runner,
85
+ },
86
+ });
87
+ decisions.push({
88
+ task_id: task.task_id,
89
+ queued: true,
90
+ reason: `queued for ${assignment.role} via ${assignment.runner}`,
91
+ });
92
+ }
93
+ return decisions;
94
+ }
@@ -0,0 +1,132 @@
1
+ import { z } from 'zod';
2
+ export const TASK_PACKET_VERSION = '1.0';
3
+ const taskIdSchema = z.string().regex(/^T-\d{3,}$/, 'Task id must look like T-001');
4
+ export const taskPacketStatusSchema = z.enum(['draft', 'ready', 'queued', 'running', 'review', 'blocked', 'done']);
5
+ export const taskPacketPrioritySchema = z.enum(['low', 'medium', 'high']);
6
+ export const taskPacketTypeSchema = z.enum(['feature', 'bug', 'chore', 'research']);
7
+ export const taskAgentRoleSchema = z.enum(['planner', 'builder-ui', 'builder-app', 'reviewer', 'tester']);
8
+ export const taskRunnerSchema = z.enum(['codex', 'claude']);
9
+ export const taskPacketSchema = z
10
+ .object({
11
+ version: z.string().default(TASK_PACKET_VERSION),
12
+ task_id: taskIdSchema,
13
+ title: z.string().min(1, 'title is required'),
14
+ type: taskPacketTypeSchema.default('chore'),
15
+ status: z
16
+ .preprocess((value) => {
17
+ if (value === 'open')
18
+ return 'draft';
19
+ if (value === 'in_progress')
20
+ return 'running';
21
+ return value;
22
+ }, taskPacketStatusSchema)
23
+ .default('draft'),
24
+ priority: taskPacketPrioritySchema.default('medium'),
25
+ summary: z.string().min(1, 'summary is required'),
26
+ goal: z.string().min(1, 'goal is required'),
27
+ scope: z.object({
28
+ in_scope: z.array(z.string()).default([]),
29
+ out_of_scope: z.array(z.string()).default([]),
30
+ }),
31
+ context: z.object({
32
+ related_decisions: z.array(z.string()).default([]),
33
+ related_notes: z.array(z.string()).default([]),
34
+ }),
35
+ implementation: z.object({
36
+ files_to_read: z.array(z.string()).default([]),
37
+ files_to_avoid: z.array(z.string()).default([]),
38
+ }),
39
+ constraints: z.object({
40
+ technical: z.array(z.string()).default([]),
41
+ design: z.array(z.string()).default([]),
42
+ }),
43
+ execution: z.object({
44
+ commands: z.object({
45
+ validation: z.array(z.string()).default([]),
46
+ }),
47
+ }),
48
+ dependencies: z.array(taskIdSchema).default([]),
49
+ tags: z.array(z.string()).default([]),
50
+ target_areas: z.array(z.string()).default([]),
51
+ definition_of_done: z.array(z.string()).default([]),
52
+ tracking: z.object({
53
+ branch: z.string().default(''),
54
+ worktree: z.string().default(''),
55
+ assigned_agent_role: taskAgentRoleSchema.nullable().default(null),
56
+ assigned_runner: taskRunnerSchema.nullable().default(null),
57
+ assignment_reason: z.string().default(''),
58
+ assigned_at: z.string().datetime({ offset: true }).nullable().default(null),
59
+ }),
60
+ result: z.object({
61
+ summary: z.string().default(''),
62
+ changed_files: z.array(z.string()).default([]),
63
+ validation_results: z.array(z.string()).default([]),
64
+ }),
65
+ })
66
+ .strict();
67
+ export const taskPacketInputSchema = taskPacketSchema.omit({ task_id: true }).partial({
68
+ version: true,
69
+ type: true,
70
+ status: true,
71
+ priority: true,
72
+ scope: true,
73
+ context: true,
74
+ implementation: true,
75
+ constraints: true,
76
+ execution: true,
77
+ dependencies: true,
78
+ tags: true,
79
+ target_areas: true,
80
+ definition_of_done: true,
81
+ tracking: true,
82
+ result: true,
83
+ });
84
+ export function createTaskPacket(taskId, input) {
85
+ const normalized = {
86
+ ...input,
87
+ version: input.version ?? TASK_PACKET_VERSION,
88
+ task_id: taskId,
89
+ type: input.type ?? 'chore',
90
+ status: input.status ?? 'draft',
91
+ priority: input.priority ?? 'medium',
92
+ scope: {
93
+ in_scope: input.scope?.in_scope ?? [],
94
+ out_of_scope: input.scope?.out_of_scope ?? [],
95
+ },
96
+ context: {
97
+ related_decisions: input.context?.related_decisions ?? [],
98
+ related_notes: input.context?.related_notes ?? [],
99
+ },
100
+ implementation: {
101
+ files_to_read: input.implementation?.files_to_read ?? [],
102
+ files_to_avoid: input.implementation?.files_to_avoid ?? [],
103
+ },
104
+ constraints: {
105
+ technical: input.constraints?.technical ?? [],
106
+ design: input.constraints?.design ?? [],
107
+ },
108
+ execution: {
109
+ commands: {
110
+ validation: input.execution?.commands?.validation ?? [],
111
+ },
112
+ },
113
+ dependencies: input.dependencies ?? [],
114
+ tags: input.tags ?? [],
115
+ target_areas: input.target_areas ?? [],
116
+ definition_of_done: input.definition_of_done ?? [],
117
+ tracking: {
118
+ branch: input.tracking?.branch ?? '',
119
+ worktree: input.tracking?.worktree ?? '',
120
+ assigned_agent_role: input.tracking?.assigned_agent_role ?? null,
121
+ assigned_runner: input.tracking?.assigned_runner ?? null,
122
+ assignment_reason: input.tracking?.assignment_reason ?? '',
123
+ assigned_at: input.tracking?.assigned_at ?? null,
124
+ },
125
+ result: {
126
+ summary: input.result?.summary ?? '',
127
+ changed_files: input.result?.changed_files ?? [],
128
+ validation_results: input.result?.validation_results ?? [],
129
+ },
130
+ };
131
+ return taskPacketSchema.parse(normalized);
132
+ }
@@ -0,0 +1,78 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { getSidecarPaths } from '../lib/paths.js';
4
+ import { SidecarError } from '../lib/errors.js';
5
+ import { stringifyJson } from '../lib/format.js';
6
+ import { taskPacketSchema } from './task-packet.js';
7
+ function taskFilePath(tasksPath, taskId) {
8
+ return path.join(tasksPath, `${taskId}.json`);
9
+ }
10
+ function parseTaskIdOrdinal(taskId) {
11
+ const match = /^T-(\d+)$/.exec(taskId);
12
+ return match ? Number.parseInt(match[1], 10) : 0;
13
+ }
14
+ export class TaskPacketRepository {
15
+ rootPath;
16
+ constructor(rootPath) {
17
+ this.rootPath = rootPath;
18
+ }
19
+ get tasksPath() {
20
+ return getSidecarPaths(this.rootPath).tasksPath;
21
+ }
22
+ ensureStorage() {
23
+ fs.mkdirSync(this.tasksPath, { recursive: true });
24
+ }
25
+ generateNextTaskId() {
26
+ this.ensureStorage();
27
+ const files = fs.readdirSync(this.tasksPath, { withFileTypes: true });
28
+ let max = 0;
29
+ for (const file of files) {
30
+ if (!file.isFile() || !file.name.endsWith('.json'))
31
+ continue;
32
+ const id = file.name.slice(0, -'.json'.length);
33
+ max = Math.max(max, parseTaskIdOrdinal(id));
34
+ }
35
+ return `T-${String(max + 1).padStart(3, '0')}`;
36
+ }
37
+ save(packet) {
38
+ this.ensureStorage();
39
+ const validated = taskPacketSchema.parse(packet);
40
+ const filePath = taskFilePath(this.tasksPath, validated.task_id);
41
+ fs.writeFileSync(filePath, `${stringifyJson(validated)}\n`, 'utf8');
42
+ return filePath;
43
+ }
44
+ get(taskId) {
45
+ const filePath = taskFilePath(this.tasksPath, taskId);
46
+ if (!fs.existsSync(filePath)) {
47
+ throw new SidecarError(`Task not found: ${taskId}`);
48
+ }
49
+ try {
50
+ const raw = JSON.parse(fs.readFileSync(filePath, 'utf8'));
51
+ return taskPacketSchema.parse(raw);
52
+ }
53
+ catch (err) {
54
+ const message = err instanceof Error ? err.message : String(err);
55
+ throw new SidecarError(`Invalid task packet at ${filePath}: ${message}`);
56
+ }
57
+ }
58
+ list() {
59
+ this.ensureStorage();
60
+ const files = fs
61
+ .readdirSync(this.tasksPath, { withFileTypes: true })
62
+ .filter((entry) => entry.isFile() && entry.name.endsWith('.json'))
63
+ .map((entry) => path.join(this.tasksPath, entry.name))
64
+ .sort();
65
+ const packets = [];
66
+ for (const filePath of files) {
67
+ try {
68
+ const raw = JSON.parse(fs.readFileSync(filePath, 'utf8'));
69
+ packets.push(taskPacketSchema.parse(raw));
70
+ }
71
+ catch (err) {
72
+ const message = err instanceof Error ? err.message : String(err);
73
+ throw new SidecarError(`Invalid task packet at ${filePath}: ${message}`);
74
+ }
75
+ }
76
+ return packets;
77
+ }
78
+ }
@@ -0,0 +1,79 @@
1
+ import { TaskPacketRepository } from './task-repository.js';
2
+ import { createTaskPacket, } from './task-packet.js';
3
+ export function createTaskPacketRecord(rootPath, input) {
4
+ const repo = new TaskPacketRepository(rootPath);
5
+ const taskId = repo.generateNextTaskId();
6
+ const packetInput = {
7
+ title: input.title,
8
+ summary: input.summary,
9
+ goal: input.goal,
10
+ type: input.type,
11
+ status: input.status,
12
+ priority: input.priority,
13
+ scope: {
14
+ in_scope: input.scope_in_scope ?? [],
15
+ out_of_scope: input.scope_out_of_scope ?? [],
16
+ },
17
+ context: {
18
+ related_decisions: input.related_decisions ?? [],
19
+ related_notes: input.related_notes ?? [],
20
+ },
21
+ implementation: {
22
+ files_to_read: input.files_to_read ?? [],
23
+ files_to_avoid: input.files_to_avoid ?? [],
24
+ },
25
+ constraints: {
26
+ technical: input.technical_constraints ?? [],
27
+ design: input.design_constraints ?? [],
28
+ },
29
+ execution: {
30
+ commands: {
31
+ validation: input.validation_commands ?? [],
32
+ },
33
+ },
34
+ dependencies: input.dependencies ?? [],
35
+ tags: input.tags ?? [],
36
+ target_areas: input.target_areas ?? [],
37
+ definition_of_done: input.definition_of_done ?? [],
38
+ tracking: {
39
+ branch: input.branch ?? '',
40
+ worktree: input.worktree ?? '',
41
+ assigned_agent_role: null,
42
+ assigned_runner: null,
43
+ assignment_reason: '',
44
+ assigned_at: null,
45
+ },
46
+ };
47
+ const packet = createTaskPacket(taskId, packetInput);
48
+ const filePath = repo.save(packet);
49
+ return { task: packet, path: filePath };
50
+ }
51
+ export function listTaskPackets(rootPath) {
52
+ const repo = new TaskPacketRepository(rootPath);
53
+ const order = {
54
+ draft: 0,
55
+ ready: 1,
56
+ queued: 2,
57
+ running: 3,
58
+ review: 4,
59
+ blocked: 5,
60
+ done: 6,
61
+ };
62
+ return repo
63
+ .list()
64
+ .slice()
65
+ .sort((a, b) => {
66
+ const byStatus = order[a.status] - order[b.status];
67
+ if (byStatus !== 0)
68
+ return byStatus;
69
+ return a.task_id.localeCompare(b.task_id, undefined, { numeric: true });
70
+ });
71
+ }
72
+ export function getTaskPacket(rootPath, taskId) {
73
+ const repo = new TaskPacketRepository(rootPath);
74
+ return repo.get(taskId);
75
+ }
76
+ export function saveTaskPacket(rootPath, task) {
77
+ const repo = new TaskPacketRepository(rootPath);
78
+ return repo.save(task);
79
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sidecar-cli",
3
- "version": "0.1.2",
3
+ "version": "0.1.3-beta.1",
4
4
  "description": "Local-first project memory and recording tool",
5
5
  "scripts": {
6
6
  "build": "npm run clean && tsc -p tsconfig.json",