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,266 @@
1
+ /**
2
+ * Multi-Workflow Orchestrator
3
+ * Shares a single LinearClient and poll loop across multiple workflows
4
+ * to avoid hammering the Linear API with N independent pollers.
5
+ */
6
+
7
+ import type { Issue, ServiceConfig } from "../config/types.js";
8
+ import { loadWorkflow, buildServiceConfig, validateServiceConfig } from "../config/loader.js";
9
+ import { LinearClient } from "../tracker/client.js";
10
+ import { logger } from "../logging/logger.js";
11
+ import { Orchestrator } from "./orchestrator.js";
12
+ import type { RuntimeSnapshot } from "./state.js";
13
+
14
+ export interface MultiOrchestratorOptions {
15
+ workflowPaths: string[];
16
+ debug?: boolean;
17
+ }
18
+
19
+ interface WorkflowEntry {
20
+ path: string;
21
+ orchestrator: Orchestrator;
22
+ }
23
+
24
+ export class MultiOrchestrator {
25
+ private entries: WorkflowEntry[] = [];
26
+ private linearClient: LinearClient | null = null;
27
+ private pollTimer: Timer | null = null;
28
+ private running = false;
29
+ private workflowPaths: string[];
30
+ private debug: boolean;
31
+
32
+ constructor(options: MultiOrchestratorOptions) {
33
+ this.workflowPaths = options.workflowPaths;
34
+ this.debug = options.debug ?? false;
35
+ }
36
+
37
+ // ── Lifecycle ─────────────────────────────────────────────────
38
+
39
+ async start(): Promise<void> {
40
+ logger.info("Starting multi-workflow orchestrator", {
41
+ workflows: this.workflowPaths.length,
42
+ });
43
+
44
+ // Load first workflow to bootstrap the shared LinearClient
45
+ const firstWorkflow = loadWorkflow(this.workflowPaths[0]);
46
+ const firstConfig = buildServiceConfig(firstWorkflow);
47
+ const firstValidation = validateServiceConfig(firstConfig);
48
+
49
+ if (!firstValidation.valid) {
50
+ throw new Error(`First workflow validation failed: ${firstValidation.errors.join(", ")}`);
51
+ }
52
+
53
+ // Create shared LinearClient
54
+ this.linearClient = new LinearClient(
55
+ firstConfig.tracker.endpoint,
56
+ firstConfig.tracker.api_key
57
+ );
58
+ this.linearClient.onRateLimit = (attempt, waitSecs) => {
59
+ logger.warn(`Linear rate limit hit, retrying in ${waitSecs}s`, { attempt });
60
+ };
61
+ this.linearClient.onThrottle = (remaining, limit) => {
62
+ logger.debug(`Throttling Linear requests`, { remaining, limit });
63
+ };
64
+
65
+ // Start each orchestrator in managed mode
66
+ for (const path of this.workflowPaths) {
67
+ const orchestrator = new Orchestrator({
68
+ workflowPath: path,
69
+ linearClient: this.linearClient,
70
+ managedPolling: true,
71
+ debug: this.debug,
72
+ });
73
+
74
+ await orchestrator.start();
75
+ this.entries.push({ path, orchestrator });
76
+
77
+ logger.info(`Loaded workflow: ${path}`);
78
+ }
79
+
80
+ // Start shared poll loop
81
+ this.running = true;
82
+ this.schedulePoll(0);
83
+
84
+ logger.info("Multi-workflow orchestrator started", {
85
+ workflows: this.entries.length,
86
+ });
87
+ }
88
+
89
+ async stop(): Promise<void> {
90
+ logger.info("Stopping multi-workflow orchestrator");
91
+ this.running = false;
92
+
93
+ if (this.pollTimer) {
94
+ clearTimeout(this.pollTimer);
95
+ this.pollTimer = null;
96
+ }
97
+
98
+ // Stop all child orchestrators
99
+ await Promise.all(this.entries.map((e) => e.orchestrator.stop()));
100
+
101
+ logger.info("Multi-workflow orchestrator stopped");
102
+ }
103
+
104
+ /** Force an immediate poll tick, resetting the poll timer */
105
+ async forcePoll(): Promise<void> {
106
+ if (!this.running) return;
107
+ if (this.pollTimer) {
108
+ clearTimeout(this.pollTimer);
109
+ this.pollTimer = null;
110
+ }
111
+ logger.info("Force refresh triggered");
112
+ await this.pollTick();
113
+ this.schedulePoll(this.getPollInterval());
114
+ }
115
+
116
+ // ── Shared Poll Loop ──────────────────────────────────────────
117
+
118
+ private schedulePoll(delayMs: number): void {
119
+ if (!this.running) return;
120
+
121
+ this.pollTimer = setTimeout(async () => {
122
+ await this.pollTick();
123
+ this.schedulePoll(this.getPollInterval());
124
+ }, delayMs);
125
+ }
126
+
127
+ private async pollTick(): Promise<void> {
128
+ if (!this.linearClient) return;
129
+
130
+ try {
131
+ // Step 1: Stall detection on all orchestrators
132
+ for (const entry of this.entries) {
133
+ entry.orchestrator.runStallDetection();
134
+ }
135
+
136
+ // Step 2: Batched reconciliation — collect all running IDs
137
+ const allRunningIds: string[] = [];
138
+ for (const entry of this.entries) {
139
+ allRunningIds.push(...entry.orchestrator.getRunningIssueIds());
140
+ }
141
+
142
+ if (allRunningIds.length > 0) {
143
+ try {
144
+ const stateMap = await this.linearClient.fetchIssueStatesByIds(allRunningIds);
145
+ for (const entry of this.entries) {
146
+ await entry.orchestrator.applyReconcileStates(stateMap);
147
+ }
148
+ } catch (err) {
149
+ logger.warn(`Shared state refresh failed: ${(err as Error).message}`);
150
+ }
151
+ }
152
+
153
+ // Step 3: Refresh configs on all orchestrators
154
+ for (const entry of this.entries) {
155
+ entry.orchestrator.refreshConfig();
156
+ }
157
+
158
+ // Step 4: Fetch candidates — group by project_slug to minimize API calls
159
+ const slugGroups = this.groupByProjectSlug();
160
+
161
+ for (const [slug, group] of slugGroups) {
162
+ // Union all active_states across workflows targeting this slug
163
+ const unionStates = new Set<string>();
164
+ for (const { config } of group) {
165
+ for (const s of config.tracker.active_states) {
166
+ unionStates.add(s);
167
+ }
168
+ }
169
+
170
+ // One fetch per unique project_slug
171
+ const issues = await this.linearClient.fetchCandidateIssues(
172
+ slug,
173
+ Array.from(unionStates)
174
+ );
175
+
176
+ logger.debug(`Fetched ${issues.length} issues for project ${slug}`, {
177
+ workflows: group.length,
178
+ });
179
+
180
+ // Step 5: Distribute to each workflow's scheduler
181
+ let totalDispatched = 0;
182
+ for (const { entry } of group) {
183
+ const dispatched = entry.orchestrator.dispatchFromIssues(issues);
184
+ totalDispatched += dispatched;
185
+ }
186
+
187
+ if (totalDispatched > 0) {
188
+ logger.info(`Dispatched ${totalDispatched} issues across workflows for ${slug}`);
189
+ }
190
+ }
191
+ } catch (err) {
192
+ logger.error(`Multi-orchestrator poll tick failed: ${(err as Error).message}`);
193
+ }
194
+ }
195
+
196
+ // ── Helpers ───────────────────────────────────────────────────
197
+
198
+ private groupByProjectSlug(): Map<string, Array<{ entry: WorkflowEntry; config: ServiceConfig }>> {
199
+ const groups = new Map<string, Array<{ entry: WorkflowEntry; config: ServiceConfig }>>();
200
+
201
+ for (const entry of this.entries) {
202
+ const config = entry.orchestrator.getServiceConfig();
203
+ if (!config) continue;
204
+
205
+ const slug = config.tracker.project_slug;
206
+ if (!groups.has(slug)) {
207
+ groups.set(slug, []);
208
+ }
209
+ groups.get(slug)!.push({ entry, config });
210
+ }
211
+
212
+ return groups;
213
+ }
214
+
215
+ /** Use the minimum poll interval across all workflows */
216
+ private getPollInterval(): number {
217
+ let min = 30000;
218
+ for (const entry of this.entries) {
219
+ const config = entry.orchestrator.getServiceConfig();
220
+ if (config && config.polling.interval_ms < min) {
221
+ min = config.polling.interval_ms;
222
+ }
223
+ }
224
+ return min;
225
+ }
226
+
227
+ // ── Observability ─────────────────────────────────────────────
228
+
229
+ /** Aggregate snapshot across all workflows */
230
+ getSnapshot(): RuntimeSnapshot | null {
231
+ const snapshots: RuntimeSnapshot[] = [];
232
+
233
+ for (const entry of this.entries) {
234
+ const snap = entry.orchestrator.getSnapshot();
235
+ if (snap) snapshots.push(snap);
236
+ }
237
+
238
+ if (snapshots.length === 0) return null;
239
+
240
+ return {
241
+ running: snapshots.flatMap((s) => s.running),
242
+ retrying: snapshots.flatMap((s) => s.retrying),
243
+ workflows: snapshots.flatMap((s) => s.workflows),
244
+ token_totals: {
245
+ input_tokens: snapshots.reduce((sum, s) => sum + s.token_totals.input_tokens, 0),
246
+ output_tokens: snapshots.reduce((sum, s) => sum + s.token_totals.output_tokens, 0),
247
+ total_tokens: snapshots.reduce((sum, s) => sum + s.token_totals.total_tokens, 0),
248
+ seconds_running: snapshots.reduce((sum, s) => sum + s.token_totals.seconds_running, 0),
249
+ },
250
+ rate_limits: (() => {
251
+ const rl = this.linearClient?.getRateLimitState();
252
+ return rl
253
+ ? {
254
+ requests_limit: rl.requestsLimit,
255
+ requests_remaining: rl.requestsRemaining,
256
+ requests_reset: rl.requestsReset,
257
+ }
258
+ : snapshots[0].rate_limits;
259
+ })(),
260
+ };
261
+ }
262
+
263
+ isRunning(): boolean {
264
+ return this.running;
265
+ }
266
+ }