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,113 @@
1
+ /**
2
+ * Agent Session
3
+ * Manages a single coding agent session lifecycle
4
+ */
5
+
6
+ import type { LiveSession, TokenTotals, RateLimitInfo } from "../config/types.js";
7
+
8
+ export function createSession(threadId: string, turnId: string, pid: string | null): LiveSession {
9
+ return {
10
+ session_id: `${threadId}-${turnId}`,
11
+ thread_id: threadId,
12
+ turn_id: turnId,
13
+ process_pid: pid,
14
+ last_event: null,
15
+ last_activity_at: null,
16
+ last_message: null,
17
+ input_tokens: 0,
18
+ output_tokens: 0,
19
+ total_tokens: 0,
20
+ last_reported_input_tokens: 0,
21
+ last_reported_output_tokens: 0,
22
+ last_reported_total_tokens: 0,
23
+ turn_count: 1,
24
+ cost_usd: 0,
25
+ duration_ms: 0,
26
+ };
27
+ }
28
+
29
+ export function updateSessionTurnId(session: LiveSession, turnId: string): void {
30
+ session.turn_id = turnId;
31
+ session.session_id = `${session.thread_id}-${turnId}`;
32
+ session.turn_count++;
33
+ }
34
+
35
+ export function updateSessionEvent(
36
+ session: LiveSession,
37
+ event: string,
38
+ message?: string
39
+ ): void {
40
+ session.last_event = event;
41
+ session.last_activity_at = new Date();
42
+ if (message) {
43
+ session.last_message = message.slice(0, 500);
44
+ }
45
+ }
46
+
47
+ export function updateSessionTokens(
48
+ session: LiveSession,
49
+ usage: {
50
+ input_tokens?: number;
51
+ output_tokens?: number;
52
+ total_tokens?: number;
53
+ }
54
+ ): {
55
+ delta_input: number;
56
+ delta_output: number;
57
+ delta_total: number;
58
+ } {
59
+ const inputTokens = usage.input_tokens ?? 0;
60
+ const outputTokens = usage.output_tokens ?? 0;
61
+ const totalTokens = usage.total_tokens ?? 0;
62
+
63
+ // Calculate deltas from last reported (for absolute totals)
64
+ const delta_input = Math.max(0, inputTokens - session.last_reported_input_tokens);
65
+ const delta_output = Math.max(0, outputTokens - session.last_reported_output_tokens);
66
+ const delta_total = Math.max(0, totalTokens - session.last_reported_total_tokens);
67
+
68
+ // Update session totals
69
+ session.input_tokens = inputTokens;
70
+ session.output_tokens = outputTokens;
71
+ session.total_tokens = totalTokens;
72
+
73
+ // Update last reported for delta tracking
74
+ session.last_reported_input_tokens = inputTokens;
75
+ session.last_reported_output_tokens = outputTokens;
76
+ session.last_reported_total_tokens = totalTokens;
77
+
78
+ return { delta_input, delta_output, delta_total };
79
+ }
80
+
81
+ export function createEmptyTotals(): TokenTotals {
82
+ return {
83
+ input_tokens: 0,
84
+ output_tokens: 0,
85
+ total_tokens: 0,
86
+ seconds_running: 0,
87
+ };
88
+ }
89
+
90
+ export function updateTotals(
91
+ totals: TokenTotals,
92
+ deltas: { delta_input: number; delta_output: number; delta_total: number }
93
+ ): void {
94
+ totals.input_tokens += deltas.delta_input;
95
+ totals.output_tokens += deltas.delta_output;
96
+ totals.total_tokens += deltas.delta_total;
97
+ }
98
+
99
+ export function parseRateLimits(payload: unknown): RateLimitInfo | null {
100
+ if (!payload || typeof payload !== "object") return null;
101
+
102
+ const info: RateLimitInfo = {};
103
+ const p = payload as Record<string, unknown>;
104
+
105
+ if (typeof p.requests_limit === "number") info.requests_limit = p.requests_limit;
106
+ if (typeof p.requests_remaining === "number") info.requests_remaining = p.requests_remaining;
107
+ if (typeof p.requests_reset === "number") info.requests_reset = p.requests_reset;
108
+ if (typeof p.tokens_limit === "number") info.tokens_limit = p.tokens_limit;
109
+ if (typeof p.tokens_remaining === "number") info.tokens_remaining = p.tokens_remaining;
110
+ if (typeof p.tokens_reset === "number") info.tokens_reset = p.tokens_reset;
111
+
112
+ return Object.keys(info).length > 0 ? info : null;
113
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,354 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Symphony CLI
4
+ * Entry point for the Symphony orchestrator service with TUI
5
+ */
6
+
7
+ import { resolve, join, basename } from "path";
8
+ import { existsSync, readdirSync } from "fs";
9
+ import { createCliRenderer } from "@opentui/core";
10
+ import { createRoot } from "@opentui/react";
11
+ import React from "react";
12
+ import { App } from "./tui/App.js";
13
+ import { logger, createFileSink } from "./logging/logger.js";
14
+ import pkg from "../package.json";
15
+
16
+ // ── CLI Parsing ─────────────────────────────────────────────────
17
+
18
+ interface CLIOptions {
19
+ workflowPaths: string[];
20
+ filters: string[];
21
+ logFile?: string;
22
+ debug: boolean;
23
+ dryRun: boolean;
24
+ headless: boolean;
25
+ web: boolean;
26
+ webPort: number;
27
+ webHost: string;
28
+ }
29
+
30
+ // Resolve paths relative to the caller's cwd, not the script's cwd
31
+ const callerCwd = process.env.SYMPHONY_CWD || process.cwd();
32
+
33
+ function parseArgs(): CLIOptions {
34
+ const args = process.argv.slice(2);
35
+ const options: CLIOptions = {
36
+ workflowPaths: [],
37
+ filters: [],
38
+ debug: false,
39
+ dryRun: false,
40
+ headless: false,
41
+ web: false,
42
+ webPort: 3000,
43
+ webHost: "0.0.0.0",
44
+ };
45
+
46
+ for (let i = 0; i < args.length; i++) {
47
+ const arg = args[i];
48
+
49
+ if (arg === "--workflow" || arg === "-w") {
50
+ // Consume all following non-flag arguments as workflow paths
51
+ while (i + 1 < args.length && !args[i + 1].startsWith("-")) {
52
+ options.workflowPaths.push(resolve(callerCwd, args[++i]));
53
+ }
54
+ } else if (arg === "--filter" || arg === "-f") {
55
+ // Consume all following non-flag arguments as filter strings
56
+ while (i + 1 < args.length && !args[i + 1].startsWith("-")) {
57
+ options.filters.push(args[++i]);
58
+ }
59
+ } else if (arg === "--log" || arg === "-l") {
60
+ options.logFile = args[++i];
61
+ } else if (arg === "--debug" || arg === "-d") {
62
+ options.debug = true;
63
+ } else if (arg === "--dry-run") {
64
+ options.dryRun = true;
65
+ } else if (arg === "--headless") {
66
+ options.headless = true;
67
+ } else if (arg === "--web") {
68
+ options.web = true;
69
+ } else if (arg === "--web-port") {
70
+ options.webPort = parseInt(args[++i], 10);
71
+ } else if (arg === "--web-host") {
72
+ options.webHost = args[++i];
73
+ } else if (arg === "--help" || arg === "-h") {
74
+ printHelp();
75
+ process.exit(0);
76
+ } else if (arg === "--version" || arg === "-v") {
77
+ console.log(`Symphony v${pkg.version}`);
78
+ process.exit(0);
79
+ } else if (!arg.startsWith("-")) {
80
+ options.workflowPaths.push(resolve(callerCwd, arg));
81
+ }
82
+ }
83
+
84
+ // Auto-detect workflows/*.md if no paths specified
85
+ if (options.workflowPaths.length === 0) {
86
+ const workflowsDir = resolve(callerCwd, "workflows");
87
+ if (existsSync(workflowsDir)) {
88
+ let mdFiles = readdirSync(workflowsDir)
89
+ .filter((f) => f.endsWith(".md"))
90
+ .sort()
91
+ .map((f) => join(workflowsDir, f));
92
+
93
+ // Apply filters if specified
94
+ if (options.filters.length > 0) {
95
+ const allFiles = mdFiles;
96
+ mdFiles = mdFiles.filter((f) => {
97
+ const name = basename(f).toLowerCase();
98
+ return options.filters.some((filter) => name.includes(filter.toLowerCase()));
99
+ });
100
+ if (mdFiles.length === 0) {
101
+ const available = allFiles.map((f) => basename(f)).join(", ");
102
+ console.error(`No workflows matched filter(s): ${options.filters.join(", ")}`);
103
+ console.error(`Available workflows: ${available}`);
104
+ process.exit(1);
105
+ }
106
+ }
107
+
108
+ if (mdFiles.length > 0) {
109
+ options.workflowPaths.push(...mdFiles);
110
+ }
111
+ }
112
+ if (options.workflowPaths.length === 0) {
113
+ console.error("No workflow files found. Create a workflows/ directory with .md files, or specify paths explicitly.");
114
+ console.error("Run 'symphony --help' for usage information.");
115
+ process.exit(1);
116
+ }
117
+ }
118
+
119
+ return options;
120
+ }
121
+
122
+ function printHelp(): void {
123
+ console.log(`
124
+ Symphony - Coding Agent Orchestrator
125
+
126
+ Usage: symphony [options] [workflow-paths...]
127
+
128
+ Options:
129
+ -w, --workflow <paths...> Workflow file(s) (default: workflows/*.md)
130
+ -f, --filter <strings...> Filter auto-discovered workflows by filename substring
131
+ -l, --log <path> Log file path (appends JSON lines)
132
+ --dry-run Render prompts for matching issues and print them (no agent launched)
133
+ --headless Run without TUI (plain log output)
134
+ --web Start web dashboard (implies --headless)
135
+ --web-port <port> Web dashboard port (default: 3000)
136
+ --web-host <host> Web dashboard bind address (default: 0.0.0.0)
137
+ -d, --debug Debug mode: verbose logging + save prompts and agent transcripts to ~/.symphony/logs/
138
+ -h, --help Show this help message
139
+ -v, --version Show version
140
+
141
+ Examples:
142
+ symphony # Auto-detect workflows/*.md
143
+ symphony -f github # Only github-related workflows
144
+ symphony -f review -f dev # Review and dev workflows
145
+ symphony ./my-workflow.md # Run with custom workflow
146
+ symphony -w dev.md qa.md # Override with specific workflows
147
+ symphony --headless # Run without TUI
148
+ symphony --web # Run with web dashboard
149
+ symphony --web --web-port 8080 # Web dashboard on port 8080
150
+ symphony --dry-run # Preview rendered prompts
151
+
152
+ Environment Variables:
153
+ LINEAR_API_KEY Linear API key (required)
154
+ `);
155
+ }
156
+
157
+ // ── Main ────────────────────────────────────────────────────────
158
+
159
+ async function main(): Promise<void> {
160
+ const options = parseArgs();
161
+
162
+ // Validate all workflow files exist
163
+ for (const path of options.workflowPaths) {
164
+ if (!existsSync(path)) {
165
+ console.error(`Workflow file not found: ${path}`);
166
+ process.exit(1);
167
+ }
168
+ }
169
+
170
+ // Dry run mode always runs headless (only supports single workflow)
171
+ if (options.dryRun) {
172
+ await runDryRun(options);
173
+ return;
174
+ }
175
+
176
+ if (options.web) {
177
+ await runWeb(options);
178
+ } else if (options.headless) {
179
+ await runHeadless(options);
180
+ } else {
181
+ await runTui(options);
182
+ }
183
+ }
184
+
185
+ // ── TUI Mode ────────────────────────────────────────────────────
186
+
187
+ async function runTui(options: CLIOptions): Promise<void> {
188
+ // Remove default console sink — TUI will handle all rendering
189
+ logger.clearSinks();
190
+
191
+ const renderer = await createCliRenderer({
192
+ exitOnCtrlC: false,
193
+ });
194
+
195
+ createRoot(renderer).render(
196
+ React.createElement(App, {
197
+ workflowPaths: options.workflowPaths,
198
+ logFile: options.logFile,
199
+ debug: options.debug,
200
+ renderer,
201
+ })
202
+ );
203
+ }
204
+
205
+ // ── Dry Run Mode ────────────────────────────────────────────────
206
+
207
+ async function runDryRun(options: CLIOptions): Promise<void> {
208
+ const { Orchestrator } = await import("./orchestrator/orchestrator.js");
209
+
210
+ if (options.debug) {
211
+ logger.setMinLevel("debug");
212
+ }
213
+
214
+ // Dry run uses first workflow only
215
+ const orchestrator = new Orchestrator({
216
+ workflowPath: options.workflowPaths[0],
217
+ });
218
+
219
+ try {
220
+ await orchestrator.dryRun();
221
+ } catch (err) {
222
+ logger.error(`Dry run failed: ${(err as Error).message}`);
223
+ process.exit(1);
224
+ }
225
+ }
226
+
227
+ // ── Shared Orchestrator Creation ─────────────────────────────────
228
+
229
+ interface OrchestratorHandle {
230
+ start(): Promise<void>;
231
+ stop(): Promise<void>;
232
+ forcePoll(): Promise<void>;
233
+ getSnapshot(): any;
234
+ }
235
+
236
+ async function createOrchestrator(options: CLIOptions): Promise<OrchestratorHandle> {
237
+ if (options.workflowPaths.length > 1) {
238
+ const { MultiOrchestrator } = await import("./orchestrator/multi-orchestrator.js");
239
+ return new MultiOrchestrator({ workflowPaths: options.workflowPaths, debug: options.debug });
240
+ }
241
+ const { Orchestrator } = await import("./orchestrator/orchestrator.js");
242
+ return new Orchestrator({ workflowPath: options.workflowPaths[0], debug: options.debug });
243
+ }
244
+
245
+ // ── Web Mode ────────────────────────────────────────────────────
246
+
247
+ async function runWeb(options: CLIOptions): Promise<void> {
248
+ if (options.debug) {
249
+ logger.setMinLevel("debug");
250
+ }
251
+
252
+ if (options.logFile) {
253
+ logger.addSink(createFileSink(options.logFile));
254
+ }
255
+
256
+ const orchestrator = await createOrchestrator(options);
257
+
258
+ const { startWebServer } = await import("./web/server.js");
259
+ const webServer = startWebServer({
260
+ port: options.webPort,
261
+ host: options.webHost,
262
+ orchestrator,
263
+ });
264
+
265
+ const shutdown = async (signal: string) => {
266
+ logger.info(`Received ${signal}, shutting down...`);
267
+ webServer.stop();
268
+ await orchestrator.stop();
269
+ process.exit(0);
270
+ };
271
+
272
+ process.on("SIGINT", () => shutdown("SIGINT"));
273
+ process.on("SIGTERM", () => shutdown("SIGTERM"));
274
+
275
+ process.on("uncaughtException", (err) => {
276
+ logger.error(`Uncaught exception: ${err.message}`, { stack: err.stack });
277
+ process.exit(1);
278
+ });
279
+
280
+ process.on("unhandledRejection", (reason) => {
281
+ logger.error(`Unhandled rejection: ${reason}`);
282
+ process.exit(1);
283
+ });
284
+
285
+ try {
286
+ await orchestrator.start();
287
+ const label = options.workflowPaths.length > 1
288
+ ? `${options.workflowPaths.length} workflows`
289
+ : "1 workflow";
290
+ logger.info(`Symphony is running (${label}) with web dashboard.`);
291
+ } catch (err) {
292
+ logger.error(`Failed to start: ${(err as Error).message}`);
293
+ webServer.stop();
294
+ process.exit(1);
295
+ }
296
+ }
297
+
298
+ // ── Headless Mode ───────────────────────────────────────────────
299
+
300
+ async function runHeadless(options: CLIOptions): Promise<void> {
301
+ if (options.debug) {
302
+ logger.setMinLevel("debug");
303
+ }
304
+
305
+ if (options.logFile) {
306
+ logger.addSink(createFileSink(options.logFile));
307
+ }
308
+
309
+ const orchestrator = await createOrchestrator(options);
310
+
311
+ const shutdown = async (signal: string) => {
312
+ logger.info(`Received ${signal}, shutting down...`);
313
+ await orchestrator.stop();
314
+ process.exit(0);
315
+ };
316
+
317
+ process.on("SIGINT", () => shutdown("SIGINT"));
318
+ process.on("SIGTERM", () => shutdown("SIGTERM"));
319
+
320
+ process.on("uncaughtException", (err) => {
321
+ logger.error(`Uncaught exception: ${err.message}`, { stack: err.stack });
322
+ process.exit(1);
323
+ });
324
+
325
+ process.on("unhandledRejection", (reason) => {
326
+ logger.error(`Unhandled rejection: ${reason}`);
327
+ process.exit(1);
328
+ });
329
+
330
+ try {
331
+ await orchestrator.start();
332
+ const label = options.workflowPaths.length > 1
333
+ ? `${options.workflowPaths.length} workflows`
334
+ : "1 workflow";
335
+ logger.info(`Symphony is running (${label}). Press Ctrl+C to stop.`);
336
+
337
+ setInterval(() => {
338
+ const snapshot = orchestrator.getSnapshot();
339
+ if (snapshot) {
340
+ logger.info("Status", {
341
+ running: snapshot.running.length,
342
+ retrying: snapshot.retrying.length,
343
+ total_tokens: snapshot.token_totals.total_tokens,
344
+ seconds_running: Math.round(snapshot.token_totals.seconds_running),
345
+ });
346
+ }
347
+ }, 60000);
348
+ } catch (err) {
349
+ logger.error(`Failed to start: ${(err as Error).message}`);
350
+ process.exit(1);
351
+ }
352
+ }
353
+
354
+ main();