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,414 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Symphony Linear CLI
4
+ * Standalone CLI for interacting with Linear from agent workspaces.
5
+ * Used by Claude agents instead of requiring external skills.
6
+ *
7
+ * Usage:
8
+ * symphony-linear get-issue SYM-123
9
+ * symphony-linear create-issue --parent SYM-123 --title "Task title" [--description "..."] [--priority 2]
10
+ * symphony-linear update-issue SYM-123 [--title "..."] [--description "..."] [--state "In Progress"]
11
+ * symphony-linear create-comment SYM-123 "Comment body"
12
+ * symphony-linear add-label SYM-123 "agent:prd:done"
13
+ * symphony-linear remove-label SYM-123 "agent:prd"
14
+ * symphony-linear swap-label SYM-123 --remove "agent:prd" --add "agent:prd:done"
15
+ */
16
+
17
+ import { mkdirSync, writeFileSync } from "fs";
18
+ import { join, extname } from "path";
19
+ import { LinearClient } from "./tracker/client.js";
20
+
21
+ const LINEAR_ENDPOINT = "https://api.linear.app/graphql";
22
+
23
+ function getApiKey(): string {
24
+ const key = process.env.LINEAR_API_KEY;
25
+ if (!key) {
26
+ console.error("Error: LINEAR_API_KEY environment variable is required");
27
+ process.exit(1);
28
+ }
29
+ return key;
30
+ }
31
+
32
+ function createClient(): LinearClient {
33
+ return new LinearClient(LINEAR_ENDPOINT, getApiKey());
34
+ }
35
+
36
+ /** Extract uploaded file URLs from Markdown text (images, file links, HTML img tags) */
37
+ function extractUploadUrls(text: string): string[] {
38
+ const urls: Set<string> = new Set();
39
+
40
+ // ![alt](url) — images
41
+ const mdImageRe = /!\[[^\]]*\]\(([^)]+)\)/g;
42
+ for (const match of text.matchAll(mdImageRe)) {
43
+ urls.add(match[1]);
44
+ }
45
+
46
+ // [text](url) — file links (only Linear uploads, not arbitrary links)
47
+ const mdLinkRe = /(?<!!)\[[^\]]*\]\(([^)]+)\)/g;
48
+ for (const match of text.matchAll(mdLinkRe)) {
49
+ const url = match[1];
50
+ if (url.includes("uploads.linear.app")) {
51
+ urls.add(url);
52
+ }
53
+ }
54
+
55
+ // <img src="url"> or <img src='url'>
56
+ const htmlImgRe = /<img[^>]+src=["']([^"']+)["'][^>]*>/gi;
57
+ for (const match of text.matchAll(htmlImgRe)) {
58
+ urls.add(match[1]);
59
+ }
60
+
61
+ return [...urls];
62
+ }
63
+
64
+ /** Guess file extension from content-type header or URL */
65
+ function guessExtension(url: string, contentType: string | null): string {
66
+ const ctMap: Record<string, string> = {
67
+ "image/png": ".png",
68
+ "image/jpeg": ".jpg",
69
+ "image/gif": ".gif",
70
+ "image/webp": ".webp",
71
+ "image/svg+xml": ".svg",
72
+ "application/pdf": ".pdf",
73
+ "application/zip": ".zip",
74
+ "text/plain": ".txt",
75
+ };
76
+ if (contentType) {
77
+ const base = contentType.split(";")[0].trim().toLowerCase();
78
+ if (ctMap[base]) return ctMap[base];
79
+ }
80
+ // Fall back to URL extension
81
+ try {
82
+ const pathname = new URL(url).pathname;
83
+ const ext = extname(pathname).toLowerCase();
84
+ if (ext) return ext;
85
+ } catch {}
86
+ // Default to .bin for unknown types
87
+ return ".bin";
88
+ }
89
+
90
+ /** Extract a filename stem from a URL — use last UUID-like segment or last path segment */
91
+ function extractStem(url: string): string {
92
+ try {
93
+ const segments = new URL(url).pathname.split("/").filter(Boolean);
94
+ // Walk backwards to find a UUID-like segment
95
+ for (let i = segments.length - 1; i >= 0; i--) {
96
+ if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(segments[i])) {
97
+ return segments[i];
98
+ }
99
+ }
100
+ // Fall back to last segment without extension
101
+ const last = segments[segments.length - 1] || "image";
102
+ const dot = last.lastIndexOf(".");
103
+ return dot > 0 ? last.slice(0, dot) : last;
104
+ } catch {}
105
+ return `image-${Date.now()}`;
106
+ }
107
+
108
+ /** Download a single image, using auth for Linear-hosted URLs */
109
+ async function downloadImage(
110
+ url: string,
111
+ outputDir: string,
112
+ apiKey: string
113
+ ): Promise<{ original_url: string; local_path: string } | { original_url: string; error: string }> {
114
+ try {
115
+ const headers: Record<string, string> = {};
116
+ if (url.includes("uploads.linear.app") || url.includes("linear.app")) {
117
+ headers["Authorization"] = apiKey;
118
+ }
119
+
120
+ const response = await fetch(url, { headers, redirect: "follow" });
121
+ if (!response.ok) {
122
+ return { original_url: url, error: `HTTP ${response.status}` };
123
+ }
124
+
125
+ const contentType = response.headers.get("content-type");
126
+ const ext = guessExtension(url, contentType);
127
+ const stem = extractStem(url);
128
+ const filename = `${stem}${ext}`;
129
+ const localPath = join(outputDir, filename);
130
+
131
+ const buffer = await response.arrayBuffer();
132
+ writeFileSync(localPath, Buffer.from(buffer));
133
+
134
+ return { original_url: url, local_path: localPath };
135
+ } catch (err) {
136
+ return { original_url: url, error: (err as Error).message };
137
+ }
138
+ }
139
+
140
+ function usage(): void {
141
+ console.log(`Symphony Linear CLI
142
+
143
+ Commands:
144
+ get-issue <IDENTIFIER> Get issue details (JSON)
145
+ get-comments <IDENTIFIER> Get issue comments (JSON)
146
+ create-issue --parent <ID> --title "..." Create a child issue
147
+ [--description "..."] [--priority N]
148
+ update-issue <IDENTIFIER> Update an issue
149
+ [--title "..."] [--description "..."] [--state "..."]
150
+ create-comment <IDENTIFIER> "body" Post a comment
151
+ add-label <IDENTIFIER> "label-name" Add a label
152
+ remove-label <IDENTIFIER> "label-name" Remove a label
153
+ swap-label <IDENTIFIER> --remove "x" --add "y" Swap labels atomically
154
+ download-attachments <IDENTIFIER> [--output dir] Download all attachments from issue
155
+
156
+ Environment:
157
+ LINEAR_API_KEY Required. Linear API key.
158
+
159
+ Notes:
160
+ - <IDENTIFIER> can be issue identifier (SYM-123) or UUID
161
+ - For create-issue, --parent takes an identifier (SYM-123) and resolves it
162
+ - Priority: 1=urgent, 2=high, 3=medium, 4=low`);
163
+ }
164
+
165
+ function parseArgs(args: string[]): { flags: Record<string, string>; positional: string[] } {
166
+ const flags: Record<string, string> = {};
167
+ const positional: string[] = [];
168
+
169
+ for (let i = 0; i < args.length; i++) {
170
+ const arg = args[i];
171
+ if (arg.startsWith("--")) {
172
+ const key = arg.slice(2);
173
+ const next = args[i + 1];
174
+ if (next && !next.startsWith("--")) {
175
+ flags[key] = next;
176
+ i++;
177
+ } else {
178
+ flags[key] = "true";
179
+ }
180
+ } else {
181
+ positional.push(arg);
182
+ }
183
+ }
184
+
185
+ return { flags, positional };
186
+ }
187
+
188
+ async function resolveIssue(client: LinearClient, identifier: string) {
189
+ const issue = await client.getIssue(identifier);
190
+ if (!issue) {
191
+ console.error(`Error: Issue ${identifier} not found`);
192
+ process.exit(1);
193
+ }
194
+ return issue;
195
+ }
196
+
197
+ async function main(): Promise<void> {
198
+ const args = process.argv.slice(2);
199
+
200
+ if (args.length === 0 || args[0] === "--help" || args[0] === "-h") {
201
+ usage();
202
+ process.exit(0);
203
+ }
204
+
205
+ const command = args[0];
206
+ const { flags, positional } = parseArgs(args.slice(1));
207
+ const client = createClient();
208
+
209
+ switch (command) {
210
+ case "get-issue": {
211
+ const identifier = positional[0];
212
+ if (!identifier) {
213
+ console.error("Error: Issue identifier required");
214
+ process.exit(1);
215
+ }
216
+ const issue = await resolveIssue(client, identifier);
217
+ console.log(JSON.stringify(issue, null, 2));
218
+ break;
219
+ }
220
+
221
+ case "get-comments": {
222
+ const identifier = positional[0];
223
+ if (!identifier) {
224
+ console.error("Error: Issue identifier required");
225
+ process.exit(1);
226
+ }
227
+ const comments = await client.getComments(identifier);
228
+ console.log(JSON.stringify(comments, null, 2));
229
+ break;
230
+ }
231
+
232
+ case "create-issue": {
233
+ const parentIdentifier = flags.parent;
234
+ const title = flags.title;
235
+ if (!parentIdentifier || !title) {
236
+ console.error("Error: --parent and --title are required");
237
+ process.exit(1);
238
+ }
239
+
240
+ const parent = await resolveIssue(client, parentIdentifier);
241
+
242
+ const input: Record<string, unknown> = {
243
+ teamId: parent.team.id,
244
+ parentId: parent.id,
245
+ title,
246
+ };
247
+
248
+ if (flags.description) input.description = flags.description;
249
+ if (flags.priority) input.priority = parseInt(flags.priority, 10);
250
+
251
+ // Set state to Todo if possible
252
+ try {
253
+ const stateId = await client.findStateId(parent.team.id, "Todo");
254
+ if (stateId) input.stateId = stateId;
255
+ } catch {}
256
+
257
+ const created = await client.createIssue(input);
258
+ console.log(JSON.stringify(created, null, 2));
259
+ break;
260
+ }
261
+
262
+ case "update-issue": {
263
+ const identifier = positional[0];
264
+ if (!identifier) {
265
+ console.error("Error: Issue identifier required");
266
+ process.exit(1);
267
+ }
268
+
269
+ const issue = await resolveIssue(client, identifier);
270
+ const input: Record<string, unknown> = {};
271
+
272
+ if (flags.title) input.title = flags.title;
273
+ if (flags.description) input.description = flags.description;
274
+
275
+ if (flags.state) {
276
+ const stateId = await client.findStateId(issue.team.id, flags.state);
277
+ if (stateId) {
278
+ input.stateId = stateId;
279
+ } else {
280
+ console.error(`Warning: State "${flags.state}" not found, skipping state update`);
281
+ }
282
+ }
283
+
284
+ if (Object.keys(input).length === 0) {
285
+ console.error("Error: At least one of --title, --description, or --state required");
286
+ process.exit(1);
287
+ }
288
+
289
+ await client.updateIssue(issue.id, input);
290
+ console.log(JSON.stringify({ success: true, identifier: issue.identifier }));
291
+ break;
292
+ }
293
+
294
+ case "create-comment": {
295
+ const identifier = positional[0];
296
+ const body = positional[1] || flags.body;
297
+ if (!identifier || !body) {
298
+ console.error("Error: Issue identifier and comment body required");
299
+ process.exit(1);
300
+ }
301
+
302
+ const issue = await resolveIssue(client, identifier);
303
+ const commentId = await client.createComment(issue.id, body);
304
+ console.log(JSON.stringify({ success: true, comment_id: commentId }));
305
+ break;
306
+ }
307
+
308
+ case "add-label": {
309
+ const identifier = positional[0];
310
+ const labelName = positional[1] || flags.label;
311
+ if (!identifier || !labelName) {
312
+ console.error("Error: Issue identifier and label name required");
313
+ process.exit(1);
314
+ }
315
+
316
+ const issue = await resolveIssue(client, identifier);
317
+ await client.addLabel(issue.id, labelName, issue.team.id);
318
+ console.log(JSON.stringify({ success: true, label: labelName }));
319
+ break;
320
+ }
321
+
322
+ case "remove-label": {
323
+ const identifier = positional[0];
324
+ const labelName = positional[1] || flags.label;
325
+ if (!identifier || !labelName) {
326
+ console.error("Error: Issue identifier and label name required");
327
+ process.exit(1);
328
+ }
329
+
330
+ const issue = await resolveIssue(client, identifier);
331
+ await client.removeLabel(issue.id, labelName);
332
+ console.log(JSON.stringify({ success: true, label: labelName }));
333
+ break;
334
+ }
335
+
336
+ case "swap-label": {
337
+ const identifier = positional[0];
338
+ const removeName = flags.remove;
339
+ const addName = flags.add;
340
+ if (!identifier || !removeName || !addName) {
341
+ console.error("Error: Issue identifier, --remove, and --add required");
342
+ process.exit(1);
343
+ }
344
+
345
+ const issue = await resolveIssue(client, identifier);
346
+ await client.swapLabel(issue.id, removeName, addName, issue.team.id);
347
+ console.log(JSON.stringify({ success: true, removed: removeName, added: addName }));
348
+ break;
349
+ }
350
+
351
+ case "download-attachments": {
352
+ const identifier = positional[0];
353
+ if (!identifier) {
354
+ console.error("Error: Issue identifier required");
355
+ process.exit(1);
356
+ }
357
+
358
+ const outputDir = flags.output || ".";
359
+ mkdirSync(outputDir, { recursive: true });
360
+
361
+ const issue = await resolveIssue(client, identifier);
362
+
363
+ // Collect image URLs from description and comments
364
+ const allUrls: Set<string> = new Set();
365
+
366
+ if (issue.description) {
367
+ for (const url of extractUploadUrls(issue.description)) {
368
+ allUrls.add(url);
369
+ }
370
+ }
371
+
372
+ if (issue.comments?.nodes) {
373
+ for (const comment of issue.comments.nodes) {
374
+ if (comment.body) {
375
+ for (const url of extractUploadUrls(comment.body)) {
376
+ allUrls.add(url);
377
+ }
378
+ }
379
+ }
380
+ }
381
+
382
+ // Also fetch attachments
383
+ const attachments = await client.getAttachments(issue.id);
384
+ for (const att of attachments) {
385
+ if (att.url) allUrls.add(att.url);
386
+ }
387
+
388
+ if (allUrls.size === 0) {
389
+ console.log(JSON.stringify({ images: [], message: "No images found" }));
390
+ break;
391
+ }
392
+
393
+ const apiKey = getApiKey();
394
+ const results = [];
395
+ for (const url of allUrls) {
396
+ const result = await downloadImage(url, outputDir, apiKey);
397
+ results.push(result);
398
+ }
399
+
400
+ console.log(JSON.stringify({ images: results }, null, 2));
401
+ break;
402
+ }
403
+
404
+ default:
405
+ console.error(`Unknown command: ${command}`);
406
+ usage();
407
+ process.exit(1);
408
+ }
409
+ }
410
+
411
+ main().catch((err) => {
412
+ console.error(`Error: ${err.message}`);
413
+ process.exit(1);
414
+ });
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Symphony Logger
3
+ * Structured logging with key=value format
4
+ */
5
+
6
+ export type LogLevel = "debug" | "info" | "warn" | "error";
7
+
8
+ export interface LogContext {
9
+ issue_id?: string;
10
+ issue_identifier?: string;
11
+ session_id?: string;
12
+ [key: string]: unknown;
13
+ }
14
+
15
+ export interface LogEntry {
16
+ level: LogLevel;
17
+ timestamp: Date;
18
+ message: string;
19
+ context: LogContext;
20
+ }
21
+
22
+ export type LogSink = (entry: LogEntry) => void;
23
+
24
+ class Logger {
25
+ private sinks: LogSink[] = [];
26
+ private minLevel: LogLevel = "info";
27
+
28
+ private levelOrder: Record<LogLevel, number> = {
29
+ debug: 0,
30
+ info: 1,
31
+ warn: 2,
32
+ error: 3,
33
+ };
34
+
35
+ addSink(sink: LogSink): void {
36
+ this.sinks.push(sink);
37
+ }
38
+
39
+ removeSink(sink: LogSink): void {
40
+ const index = this.sinks.indexOf(sink);
41
+ if (index !== -1) {
42
+ this.sinks.splice(index, 1);
43
+ }
44
+ }
45
+
46
+ clearSinks(): void {
47
+ this.sinks.length = 0;
48
+ }
49
+
50
+ setMinLevel(level: LogLevel): void {
51
+ this.minLevel = level;
52
+ }
53
+
54
+ private shouldLog(level: LogLevel): boolean {
55
+ return this.levelOrder[level] >= this.levelOrder[this.minLevel];
56
+ }
57
+
58
+ private emit(level: LogLevel, message: string, context: LogContext = {}): void {
59
+ if (!this.shouldLog(level)) return;
60
+
61
+ const entry: LogEntry = {
62
+ level,
63
+ timestamp: new Date(),
64
+ message,
65
+ context,
66
+ };
67
+
68
+ for (const sink of this.sinks) {
69
+ try {
70
+ sink(entry);
71
+ } catch {
72
+ // Sink failure should not crash the service
73
+ }
74
+ }
75
+ }
76
+
77
+ debug(message: string, context?: LogContext): void {
78
+ this.emit("debug", message, context);
79
+ }
80
+
81
+ info(message: string, context?: LogContext): void {
82
+ this.emit("info", message, context);
83
+ }
84
+
85
+ warn(message: string, context?: LogContext): void {
86
+ this.emit("warn", message, context);
87
+ }
88
+
89
+ error(message: string, context?: LogContext): void {
90
+ this.emit("error", message, context);
91
+ }
92
+ }
93
+
94
+ // Default console sink with key=value format
95
+ export function createConsoleSink(): LogSink {
96
+ return (entry: LogEntry) => {
97
+ const ts = entry.timestamp.toISOString();
98
+ const level = entry.level.toUpperCase().padEnd(5);
99
+
100
+ let contextStr = "";
101
+ for (const [key, value] of Object.entries(entry.context)) {
102
+ if (value !== undefined && value !== null) {
103
+ contextStr += ` ${key}=${JSON.stringify(value)}`;
104
+ }
105
+ }
106
+
107
+ const output = `[${ts}] ${level} ${entry.message}${contextStr}`;
108
+
109
+ switch (entry.level) {
110
+ case "error":
111
+ console.error(output);
112
+ break;
113
+ case "warn":
114
+ console.warn(output);
115
+ break;
116
+ default:
117
+ console.log(output);
118
+ }
119
+ };
120
+ }
121
+
122
+ // File sink (appends to file)
123
+ export function createFileSink(filePath: string): LogSink {
124
+ const file = Bun.file(filePath);
125
+ const writer = file.writer();
126
+
127
+ return (entry: LogEntry) => {
128
+ const jsonLine = JSON.stringify({
129
+ timestamp: entry.timestamp.toISOString(),
130
+ level: entry.level,
131
+ message: entry.message,
132
+ ...entry.context,
133
+ });
134
+ writer.write(jsonLine + "\n");
135
+ writer.flush();
136
+ };
137
+ }
138
+
139
+ // Global logger instance
140
+ export const logger = new Logger();
141
+
142
+ // Add default console sink
143
+ logger.addSink(createConsoleSink());