better-symphony 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/CLAUDE.md +60 -0
  2. package/LICENSE +21 -0
  3. package/README.md +292 -0
  4. package/dist/web/app.css +2 -0
  5. package/dist/web/index.html +13 -0
  6. package/dist/web/main.js +235 -0
  7. package/package.json +62 -0
  8. package/src/agent/claude-runner.ts +576 -0
  9. package/src/agent/protocol.ts +2 -0
  10. package/src/agent/runner.ts +2 -0
  11. package/src/agent/session.ts +113 -0
  12. package/src/cli.ts +354 -0
  13. package/src/config/loader.ts +379 -0
  14. package/src/config/types.ts +382 -0
  15. package/src/index.ts +53 -0
  16. package/src/linear-cli.ts +414 -0
  17. package/src/logging/logger.ts +143 -0
  18. package/src/orchestrator/multi-orchestrator.ts +266 -0
  19. package/src/orchestrator/orchestrator.ts +1357 -0
  20. package/src/orchestrator/scheduler.ts +195 -0
  21. package/src/orchestrator/state.ts +201 -0
  22. package/src/prompts/github-system-prompt.md +51 -0
  23. package/src/prompts/linear-system-prompt.md +44 -0
  24. package/src/tracker/client.ts +577 -0
  25. package/src/tracker/github-issues-tracker.ts +280 -0
  26. package/src/tracker/github-pr-tracker.ts +298 -0
  27. package/src/tracker/index.ts +9 -0
  28. package/src/tracker/interface.ts +76 -0
  29. package/src/tracker/linear-tracker.ts +147 -0
  30. package/src/tracker/queries.ts +281 -0
  31. package/src/tracker/types.ts +125 -0
  32. package/src/tui/App.tsx +157 -0
  33. package/src/tui/LogView.tsx +120 -0
  34. package/src/tui/StatusBar.tsx +72 -0
  35. package/src/tui/TabBar.tsx +55 -0
  36. package/src/tui/sink.ts +47 -0
  37. package/src/tui/types.ts +6 -0
  38. package/src/tui/useOrchestrator.ts +244 -0
  39. package/src/web/server.ts +182 -0
  40. package/src/web/sink.ts +67 -0
  41. package/src/web-ui/App.tsx +60 -0
  42. package/src/web-ui/components/agent-table.tsx +57 -0
  43. package/src/web-ui/components/header.tsx +72 -0
  44. package/src/web-ui/components/log-stream.tsx +111 -0
  45. package/src/web-ui/components/retry-table.tsx +58 -0
  46. package/src/web-ui/components/stats-cards.tsx +142 -0
  47. package/src/web-ui/components/ui/badge.tsx +30 -0
  48. package/src/web-ui/components/ui/button.tsx +39 -0
  49. package/src/web-ui/components/ui/card.tsx +32 -0
  50. package/src/web-ui/globals.css +27 -0
  51. package/src/web-ui/index.html +13 -0
  52. package/src/web-ui/lib/use-sse.ts +98 -0
  53. package/src/web-ui/lib/utils.ts +25 -0
  54. package/src/web-ui/main.tsx +4 -0
  55. package/src/workspace/hooks.ts +97 -0
  56. package/src/workspace/manager.ts +211 -0
  57. package/src/workspace/render-hook.ts +13 -0
  58. package/workflows/dev.md +127 -0
  59. package/workflows/github-issues.md +107 -0
  60. package/workflows/pr-review.md +89 -0
  61. package/workflows/prd.md +170 -0
  62. package/workflows/ralph.md +95 -0
  63. package/workflows/smoke.md +66 -0
@@ -0,0 +1,379 @@
1
+ /**
2
+ * Workflow Loader
3
+ * Parses WORKFLOW.md with YAML front matter + prompt template
4
+ */
5
+
6
+ import { readFileSync, existsSync, watchFile, unwatchFile } from "fs";
7
+ import { parse as parseYaml } from "yaml";
8
+ import { tmpdir, homedir } from "os";
9
+ import { resolve, join, isAbsolute, sep } from "path";
10
+ import { Liquid } from "liquidjs";
11
+ import type {
12
+ WorkflowDefinition,
13
+ WorkflowConfig,
14
+ ServiceConfig,
15
+ AgentBinary,
16
+ Issue,
17
+ ChildIssue,
18
+ WorkflowError,
19
+ } from "./types.js";
20
+ import { WorkflowError as WFError } from "./types.js";
21
+
22
+ const liquid = new Liquid({ strictVariables: true, strictFilters: true });
23
+
24
+ // ── Defaults ────────────────────────────────────────────────────
25
+
26
+ const DEFAULT_ACTIVE_STATES = ["Todo", "In Progress"];
27
+ const DEFAULT_TERMINAL_STATES = ["Closed", "Cancelled", "Canceled", "Duplicate", "Done"];
28
+ const DEFAULT_ERROR_STATES = ["Error"];
29
+ const DEFAULT_POLL_INTERVAL_MS = 30000;
30
+ const DEFAULT_HOOK_TIMEOUT_MS = 60000;
31
+ const DEFAULT_MAX_CONCURRENT_AGENTS = 10;
32
+ const DEFAULT_MAX_TURNS = 0; // Infinite
33
+ const DEFAULT_MAX_RETRIES = 3;
34
+ const DEFAULT_MAX_RETRY_BACKOFF_MS = 300000;
35
+ const DEFAULT_TURN_TIMEOUT_MS = 3600000;
36
+ const DEFAULT_STALL_TIMEOUT_MS = 300000;
37
+ const DEFAULT_LINEAR_ENDPOINT = "https://api.linear.app/graphql";
38
+ const DEFAULT_CLAUDE_BINARY = "claude";
39
+ const DEFAULT_PERMISSION_MODE = "acceptEdits";
40
+
41
+ // ── Loader ──────────────────────────────────────────────────────
42
+
43
+ export function loadWorkflow(filePath: string): WorkflowDefinition {
44
+ if (!existsSync(filePath)) {
45
+ throw new WFError("missing_workflow_file", `Workflow file not found: ${filePath}`);
46
+ }
47
+
48
+ let content: string;
49
+ try {
50
+ content = readFileSync(filePath, "utf-8");
51
+ } catch (err) {
52
+ throw new WFError("missing_workflow_file", `Failed to read workflow file: ${filePath}`);
53
+ }
54
+
55
+ let config: WorkflowConfig = {};
56
+ let prompt_template = content;
57
+
58
+ // Parse YAML front matter if present
59
+ if (content.startsWith("---")) {
60
+ const endIndex = content.indexOf("\n---", 3);
61
+ if (endIndex !== -1) {
62
+ const frontMatter = content.slice(4, endIndex);
63
+ prompt_template = content.slice(endIndex + 4).trim();
64
+
65
+ try {
66
+ const parsed = parseYaml(frontMatter);
67
+ if (parsed !== null && typeof parsed !== "object") {
68
+ throw new WFError(
69
+ "workflow_front_matter_not_a_map",
70
+ "YAML front matter must be a map/object"
71
+ );
72
+ }
73
+ config = (parsed as WorkflowConfig) || {};
74
+ } catch (err) {
75
+ if (err instanceof WFError) throw err;
76
+ throw new WFError(
77
+ "workflow_parse_error",
78
+ `Failed to parse YAML front matter: ${(err as Error).message}`
79
+ );
80
+ }
81
+ }
82
+ }
83
+
84
+ return { config, prompt_template };
85
+ }
86
+
87
+ // ── Environment Expansion ───────────────────────────────────────
88
+
89
+ function expandEnvVar(value: string | undefined): string | undefined {
90
+ if (!value) return value;
91
+ if (value.startsWith("$")) {
92
+ const envName = value.slice(1);
93
+ const envValue = process.env[envName];
94
+ return envValue || undefined;
95
+ }
96
+ return value;
97
+ }
98
+
99
+ function expandPath(value: string | undefined): string | undefined {
100
+ if (!value) return value;
101
+
102
+ // Expand $VAR
103
+ if (value.startsWith("$")) {
104
+ const envValue = expandEnvVar(value);
105
+ if (envValue) value = envValue;
106
+ }
107
+
108
+ // Expand ~
109
+ if (value.startsWith("~")) {
110
+ value = join(homedir(), value.slice(1));
111
+ }
112
+
113
+ // Resolve to absolute if it contains path separators
114
+ if (value.includes(sep) || value.includes("/")) {
115
+ return resolve(value);
116
+ }
117
+
118
+ return value;
119
+ }
120
+
121
+ function parseIntOr<T extends number>(value: T | string | undefined, defaultValue: T): T {
122
+ if (value === undefined) return defaultValue;
123
+ if (typeof value === "number") return value as T;
124
+ const parsed = parseInt(value, 10);
125
+ return (isNaN(parsed) ? defaultValue : parsed) as T;
126
+ }
127
+
128
+ function parseStateList(value: string[] | string | undefined, defaults: string[]): string[] {
129
+ if (!value) return defaults;
130
+ if (Array.isArray(value)) return value;
131
+ return value.split(",").map((s) => s.trim());
132
+ }
133
+
134
+ // ── Service Config Builder ──────────────────────────────────────
135
+
136
+ export function buildServiceConfig(workflow: WorkflowDefinition): ServiceConfig {
137
+ const cfg = workflow.config;
138
+
139
+ // Tracker config
140
+ const trackerKind = cfg.tracker?.kind || "linear";
141
+ if (trackerKind !== "linear" && trackerKind !== "github-pr" && trackerKind !== "github-issues") {
142
+ throw new WFError("workflow_parse_error", `Unsupported tracker kind: ${trackerKind}`);
143
+ }
144
+
145
+ const apiKey = expandEnvVar(cfg.tracker?.api_key) || process.env.LINEAR_API_KEY || "";
146
+ const projectSlug = cfg.tracker?.project_slug || "";
147
+ const repo = cfg.tracker?.repo || "";
148
+
149
+ // Workspace root
150
+ let workspaceRoot = expandPath(cfg.workspace?.root);
151
+ if (!workspaceRoot) {
152
+ workspaceRoot = join(tmpdir(), "symphony_workspaces");
153
+ }
154
+
155
+ // Parse state-based concurrency limits
156
+ const byStateMap = new Map<string, number>();
157
+ const byState = cfg.agent?.max_concurrent_agents_by_state;
158
+ if (byState) {
159
+ for (const [state, limit] of Object.entries(byState)) {
160
+ const normalizedState = state.trim().toLowerCase();
161
+ const parsedLimit = parseIntOr(limit, 0);
162
+ if (parsedLimit > 0) {
163
+ byStateMap.set(normalizedState, parsedLimit);
164
+ }
165
+ }
166
+ }
167
+
168
+ // Resolve binary: `binary` takes precedence over deprecated `harness`
169
+ const binary = (cfg.agent?.binary || cfg.agent?.harness || DEFAULT_CLAUDE_BINARY) as AgentBinary;
170
+
171
+ return {
172
+ tracker: {
173
+ kind: trackerKind as "linear" | "github-pr",
174
+ // Linear-specific (empty for github-pr)
175
+ endpoint: cfg.tracker?.endpoint || DEFAULT_LINEAR_ENDPOINT,
176
+ api_key: apiKey,
177
+ project_slug: projectSlug,
178
+ active_states: parseStateList(cfg.tracker?.active_states,
179
+ trackerKind === "github-pr" ? ["Open"] :
180
+ trackerKind === "github-issues" ? ["open"] :
181
+ DEFAULT_ACTIVE_STATES),
182
+ terminal_states: parseStateList(cfg.tracker?.terminal_states,
183
+ trackerKind === "github-pr" ? ["Closed"] :
184
+ trackerKind === "github-issues" ? ["closed"] :
185
+ DEFAULT_TERMINAL_STATES),
186
+ error_states: parseStateList(cfg.tracker?.error_states, DEFAULT_ERROR_STATES),
187
+ // GitHub-specific (empty for linear)
188
+ repo: repo,
189
+ // Shared
190
+ required_labels: parseStateList(cfg.tracker?.required_labels, []),
191
+ excluded_labels: parseStateList(cfg.tracker?.excluded_labels, []),
192
+ },
193
+ polling: {
194
+ interval_ms: parseIntOr(cfg.polling?.interval_ms, DEFAULT_POLL_INTERVAL_MS),
195
+ },
196
+ workspace: {
197
+ root: workspaceRoot,
198
+ },
199
+ hooks: {
200
+ after_create: cfg.hooks?.after_create || null,
201
+ before_run: cfg.hooks?.before_run || null,
202
+ after_run: cfg.hooks?.after_run || null,
203
+ before_remove: cfg.hooks?.before_remove || null,
204
+ timeout_ms: parseIntOr(cfg.hooks?.timeout_ms, DEFAULT_HOOK_TIMEOUT_MS),
205
+ },
206
+ agent: {
207
+ binary,
208
+ mode: cfg.agent?.mode === "ralph_loop" ? "ralph_loop" : "default",
209
+ max_concurrent_agents: parseIntOr(cfg.agent?.max_concurrent_agents, DEFAULT_MAX_CONCURRENT_AGENTS),
210
+ max_turns: parseIntOr(cfg.agent?.max_turns, DEFAULT_MAX_TURNS),
211
+ max_retries: parseIntOr(cfg.agent?.max_retries, DEFAULT_MAX_RETRIES),
212
+ max_retry_backoff_ms: parseIntOr(cfg.agent?.max_retry_backoff_ms, DEFAULT_MAX_RETRY_BACKOFF_MS),
213
+ max_concurrent_agents_by_state: byStateMap,
214
+ turn_timeout_ms: parseIntOr(cfg.agent?.turn_timeout_ms, DEFAULT_TURN_TIMEOUT_MS),
215
+ stall_timeout_ms: parseIntOr(cfg.agent?.stall_timeout_ms, DEFAULT_STALL_TIMEOUT_MS),
216
+ max_iterations: parseIntOr(cfg.agent?.max_iterations, 0),
217
+ yolobox: cfg.agent?.yolobox === true,
218
+ yolobox_arguments: Array.isArray(cfg.agent?.yolobox_arguments) ? cfg.agent.yolobox_arguments : [],
219
+ permission_mode: cfg.agent?.permission_mode || DEFAULT_PERMISSION_MODE,
220
+ append_system_prompt: cfg.agent?.append_system_prompt || null,
221
+ },
222
+ };
223
+ }
224
+
225
+ // ── Validation ──────────────────────────────────────────────────
226
+
227
+ export interface ValidationResult {
228
+ valid: boolean;
229
+ errors: string[];
230
+ }
231
+
232
+ export function validateServiceConfig(config: ServiceConfig): ValidationResult {
233
+ const errors: string[] = [];
234
+
235
+ if (!config.tracker.kind) {
236
+ errors.push("tracker.kind is required");
237
+ }
238
+
239
+ if (config.tracker.kind === "linear") {
240
+ if (!config.tracker.api_key) {
241
+ errors.push("tracker.api_key is required (set LINEAR_API_KEY or tracker.api_key in WORKFLOW.md)");
242
+ }
243
+ if (!config.tracker.project_slug) {
244
+ errors.push("tracker.project_slug is required for Linear tracker");
245
+ }
246
+ }
247
+
248
+ if (config.tracker.kind === "github-pr") {
249
+ if (!config.tracker.repo) {
250
+ errors.push("tracker.repo is required for GitHub PR tracker (e.g., 'owner/repo')");
251
+ }
252
+ }
253
+
254
+ if (config.tracker.kind === "github-issues") {
255
+ if (!config.tracker.repo) {
256
+ errors.push("tracker.repo is required for GitHub Issues tracker (e.g., 'owner/repo')");
257
+ }
258
+ }
259
+
260
+ const validBinaries = ["claude", "codex", "opencode"];
261
+ if (!validBinaries.includes(config.agent.binary)) {
262
+ errors.push(`agent.binary must be one of: ${validBinaries.join(", ")}`);
263
+ }
264
+
265
+ return {
266
+ valid: errors.length === 0,
267
+ errors,
268
+ };
269
+ }
270
+
271
+ // ── Prompt Rendering ────────────────────────────────────────────
272
+
273
+ export async function renderPrompt(
274
+ template: string,
275
+ issue: Issue,
276
+ attempt: number | null
277
+ ): Promise<string> {
278
+ if (!template.trim()) {
279
+ return "You are working on an issue from Linear.";
280
+ }
281
+
282
+ try {
283
+ const result = await liquid.parseAndRender(template, {
284
+ issue: {
285
+ ...issue,
286
+ labels: issue.labels,
287
+ blocked_by: issue.blocked_by,
288
+ comments: issue.comments,
289
+ },
290
+ attempt,
291
+ });
292
+ return result;
293
+ } catch (err) {
294
+ throw new WFError(
295
+ "template_render_error",
296
+ `Failed to render prompt template: ${(err as Error).message}`
297
+ );
298
+ }
299
+ }
300
+
301
+ /**
302
+ * Render prompt for ralph_loop mode (subtask-aware)
303
+ */
304
+ export async function renderSubtaskPrompt(
305
+ template: string,
306
+ parentIssue: Issue,
307
+ currentSubtask: ChildIssue,
308
+ subtaskIndex: number,
309
+ totalSubtasks: number,
310
+ attempt: number | null
311
+ ): Promise<string> {
312
+ if (!template.trim()) {
313
+ return `You are working on subtask ${currentSubtask.identifier}: ${currentSubtask.title}`;
314
+ }
315
+
316
+ try {
317
+ const result = await liquid.parseAndRender(template, {
318
+ // Parent issue context
319
+ parent: {
320
+ ...parentIssue,
321
+ labels: parentIssue.labels,
322
+ blocked_by: parentIssue.blocked_by,
323
+ children: parentIssue.children,
324
+ comments: parentIssue.comments,
325
+ },
326
+ // Also expose as 'issue' for backward compat
327
+ issue: {
328
+ ...parentIssue,
329
+ labels: parentIssue.labels,
330
+ blocked_by: parentIssue.blocked_by,
331
+ children: parentIssue.children,
332
+ comments: parentIssue.comments,
333
+ },
334
+ // Current subtask being worked on
335
+ current_subtask: currentSubtask,
336
+ subtask: currentSubtask, // alias
337
+ // Loop context
338
+ subtask_index: subtaskIndex,
339
+ total_subtasks: totalSubtasks,
340
+ is_first_subtask: subtaskIndex === 1,
341
+ is_last_subtask: subtaskIndex === totalSubtasks,
342
+ // Retry info
343
+ attempt,
344
+ });
345
+ return result;
346
+ } catch (err) {
347
+ throw new WFError(
348
+ "template_render_error",
349
+ `Failed to render subtask prompt template: ${(err as Error).message}`
350
+ );
351
+ }
352
+ }
353
+
354
+ // ── File Watcher ────────────────────────────────────────────────
355
+
356
+ export type WorkflowChangeCallback = (workflow: WorkflowDefinition, config: ServiceConfig) => void;
357
+
358
+ export function watchWorkflow(
359
+ filePath: string,
360
+ onChange: WorkflowChangeCallback,
361
+ onError: (err: Error) => void
362
+ ): () => void {
363
+ const handleChange = () => {
364
+ try {
365
+ const workflow = loadWorkflow(filePath);
366
+ const config = buildServiceConfig(workflow);
367
+ onChange(workflow, config);
368
+ } catch (err) {
369
+ onError(err as Error);
370
+ }
371
+ };
372
+
373
+ // Use chokidar for robust file watching
374
+ const watcher = watchFile(filePath, { interval: 1000 }, handleChange);
375
+
376
+ return () => {
377
+ unwatchFile(filePath, handleChange);
378
+ };
379
+ }