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,195 @@
1
+ /**
2
+ * Scheduler
3
+ * Issue sorting and dispatch eligibility
4
+ */
5
+
6
+ import type { Issue, ServiceConfig, OrchestratorState } from "../config/types.js";
7
+ import * as state from "./state.js";
8
+
9
+ // ── Candidate Filtering ─────────────────────────────────────────
10
+
11
+ export interface CandidateResult {
12
+ eligible: Issue[];
13
+ skipped: Array<{
14
+ issue: Issue;
15
+ reason: string;
16
+ }>;
17
+ }
18
+
19
+ /**
20
+ * Filter and sort candidate issues for dispatch
21
+ */
22
+ export function selectCandidates(
23
+ issues: Issue[],
24
+ orchState: OrchestratorState,
25
+ config: ServiceConfig
26
+ ): CandidateResult {
27
+ const eligible: Issue[] = [];
28
+ const skipped: Array<{ issue: Issue; reason: string }> = [];
29
+
30
+ const activeStates = new Set(config.tracker.active_states.map((s) => s.trim().toLowerCase()));
31
+ const terminalStates = new Set(config.tracker.terminal_states.map((s) => s.trim().toLowerCase()));
32
+
33
+ for (const issue of issues) {
34
+ const result = checkEligibility(issue, orchState, config, activeStates, terminalStates);
35
+ if (result.eligible) {
36
+ eligible.push(issue);
37
+ } else {
38
+ skipped.push({ issue, reason: result.reason });
39
+ }
40
+ }
41
+
42
+ // Sort by priority
43
+ sortByDispatchPriority(eligible);
44
+
45
+ return { eligible, skipped };
46
+ }
47
+
48
+ interface EligibilityResult {
49
+ eligible: boolean;
50
+ reason: string;
51
+ }
52
+
53
+ function checkEligibility(
54
+ issue: Issue,
55
+ orchState: OrchestratorState,
56
+ config: ServiceConfig,
57
+ activeStates: Set<string>,
58
+ terminalStates: Set<string>
59
+ ): EligibilityResult {
60
+ // Required fields
61
+ if (!issue.id || !issue.identifier || !issue.title || !issue.state) {
62
+ return { eligible: false, reason: "missing required fields" };
63
+ }
64
+
65
+ const normalizedState = issue.state.trim().toLowerCase();
66
+
67
+ // State checks
68
+ if (!activeStates.has(normalizedState)) {
69
+ return { eligible: false, reason: `state "${issue.state}" not in active states` };
70
+ }
71
+
72
+ if (terminalStates.has(normalizedState)) {
73
+ return { eligible: false, reason: `state "${issue.state}" is terminal` };
74
+ }
75
+
76
+ // Already running check
77
+ if (state.isIssueRunning(orchState, issue.id)) {
78
+ return { eligible: false, reason: "already running" };
79
+ }
80
+
81
+ // Already claimed check
82
+ if (state.isIssueClaimed(orchState, issue.id)) {
83
+ return { eligible: false, reason: "already claimed" };
84
+ }
85
+
86
+ // Global concurrency check
87
+ const runningCount = state.getRunningCount(orchState);
88
+ if (runningCount >= config.agent.max_concurrent_agents) {
89
+ return { eligible: false, reason: "global concurrency limit reached" };
90
+ }
91
+
92
+ // Per-state concurrency check
93
+ const stateLimit = config.agent.max_concurrent_agents_by_state.get(normalizedState);
94
+ if (stateLimit !== undefined) {
95
+ const stateCount = state.getRunningByState(orchState, normalizedState);
96
+ if (stateCount >= stateLimit) {
97
+ return { eligible: false, reason: `per-state concurrency limit reached for "${issue.state}"` };
98
+ }
99
+ }
100
+
101
+ // Blocker check for Todo state
102
+ if (normalizedState === "todo" && issue.blocked_by.length > 0) {
103
+ for (const blocker of issue.blocked_by) {
104
+ if (blocker.state) {
105
+ const blockerState = blocker.state.trim().toLowerCase();
106
+ if (!terminalStates.has(blockerState)) {
107
+ return {
108
+ eligible: false,
109
+ reason: `blocked by ${blocker.identifier || blocker.id} (state: ${blocker.state})`,
110
+ };
111
+ }
112
+ }
113
+ }
114
+ }
115
+
116
+ // Required labels check
117
+ const issueLabels = new Set(issue.labels.map((l) => l.toLowerCase()));
118
+ if (config.tracker.required_labels.length > 0) {
119
+ for (const requiredLabel of config.tracker.required_labels) {
120
+ if (!issueLabels.has(requiredLabel.toLowerCase())) {
121
+ return {
122
+ eligible: false,
123
+ reason: `missing required label "${requiredLabel}"`,
124
+ };
125
+ }
126
+ }
127
+ }
128
+
129
+ // Excluded labels check
130
+ if (config.tracker.excluded_labels.length > 0) {
131
+ for (const excludedLabel of config.tracker.excluded_labels) {
132
+ if (issueLabels.has(excludedLabel.toLowerCase())) {
133
+ return {
134
+ eligible: false,
135
+ reason: `has excluded label "${excludedLabel}"`,
136
+ };
137
+ }
138
+ }
139
+ }
140
+
141
+ return { eligible: true, reason: "" };
142
+ }
143
+
144
+ /**
145
+ * Sort issues by dispatch priority (in-place)
146
+ * 1. priority ascending (1..4 preferred, null last)
147
+ * 2. created_at oldest first
148
+ * 3. identifier lexicographic tie-breaker
149
+ */
150
+ export function sortByDispatchPriority(issues: Issue[]): void {
151
+ issues.sort((a, b) => {
152
+ // Priority: lower is better, null sorts last
153
+ const aPrio = a.priority ?? 999;
154
+ const bPrio = b.priority ?? 999;
155
+ if (aPrio !== bPrio) {
156
+ return aPrio - bPrio;
157
+ }
158
+
159
+ // Created at: older is better
160
+ const aTime = a.created_at?.getTime() ?? Date.now();
161
+ const bTime = b.created_at?.getTime() ?? Date.now();
162
+ if (aTime !== bTime) {
163
+ return aTime - bTime;
164
+ }
165
+
166
+ // Identifier: lexicographic
167
+ return a.identifier.localeCompare(b.identifier);
168
+ });
169
+ }
170
+
171
+ // ── Concurrency Helpers ─────────────────────────────────────────
172
+
173
+ /**
174
+ * Get available global slots
175
+ */
176
+ export function getAvailableSlots(
177
+ orchState: OrchestratorState,
178
+ config: ServiceConfig
179
+ ): number {
180
+ return Math.max(0, config.agent.max_concurrent_agents - state.getRunningCount(orchState));
181
+ }
182
+
183
+ /**
184
+ * Calculate backoff delay for retry
185
+ */
186
+ export function calculateBackoffDelay(attempt: number, maxBackoffMs: number): number {
187
+ // delay = min(10000 * 2^(attempt - 1), max_retry_backoff_ms)
188
+ const delay = 10000 * Math.pow(2, attempt - 1);
189
+ return Math.min(delay, maxBackoffMs);
190
+ }
191
+
192
+ /**
193
+ * Continuation retry delay (after successful completion)
194
+ */
195
+ export const CONTINUATION_RETRY_DELAY_MS = 1000;
@@ -0,0 +1,201 @@
1
+ /**
2
+ * Orchestrator State Management
3
+ */
4
+
5
+ import type {
6
+ OrchestratorState,
7
+ RunningEntry,
8
+ RetryEntry,
9
+ Issue,
10
+ TokenTotals,
11
+ RateLimitInfo,
12
+ } from "../config/types.js";
13
+ import { createEmptyTotals } from "../agent/session.js";
14
+
15
+ export function createOrchestratorState(
16
+ pollIntervalMs: number,
17
+ maxConcurrentAgents: number
18
+ ): OrchestratorState {
19
+ return {
20
+ poll_interval_ms: pollIntervalMs,
21
+ max_concurrent_agents: maxConcurrentAgents,
22
+ running: new Map(),
23
+ claimed: new Set(),
24
+ retry_attempts: new Map(),
25
+ completed: new Set(),
26
+ token_totals: createEmptyTotals(),
27
+ rate_limits: null,
28
+ ended_seconds: 0,
29
+ };
30
+ }
31
+
32
+ // ── Claim Management ────────────────────────────────────────────
33
+
34
+ export function claimIssue(state: OrchestratorState, issueId: string): boolean {
35
+ if (state.claimed.has(issueId)) {
36
+ return false;
37
+ }
38
+ state.claimed.add(issueId);
39
+ return true;
40
+ }
41
+
42
+ export function releaseClaim(state: OrchestratorState, issueId: string): void {
43
+ state.claimed.delete(issueId);
44
+ }
45
+
46
+ export function isIssueClaimed(state: OrchestratorState, issueId: string): boolean {
47
+ return state.claimed.has(issueId);
48
+ }
49
+
50
+ // ── Running Management ──────────────────────────────────────────
51
+
52
+ export function addRunning(state: OrchestratorState, entry: RunningEntry): void {
53
+ state.running.set(entry.issue.id, entry);
54
+ }
55
+
56
+ export function removeRunning(state: OrchestratorState, issueId: string): RunningEntry | undefined {
57
+ const entry = state.running.get(issueId);
58
+ if (entry) {
59
+ state.running.delete(issueId);
60
+
61
+ // Add runtime to ended seconds
62
+ const runtimeMs = Date.now() - entry.attempt.started_at.getTime();
63
+ state.ended_seconds += runtimeMs / 1000;
64
+ }
65
+ return entry;
66
+ }
67
+
68
+ export function getRunning(state: OrchestratorState, issueId: string): RunningEntry | undefined {
69
+ return state.running.get(issueId);
70
+ }
71
+
72
+ export function isIssueRunning(state: OrchestratorState, issueId: string): boolean {
73
+ return state.running.has(issueId);
74
+ }
75
+
76
+ export function getRunningCount(state: OrchestratorState): number {
77
+ return state.running.size;
78
+ }
79
+
80
+ export function getRunningByState(state: OrchestratorState, stateName: string): number {
81
+ const normalized = stateName.trim().toLowerCase();
82
+ let count = 0;
83
+ for (const entry of state.running.values()) {
84
+ if (entry.issue.state.trim().toLowerCase() === normalized) {
85
+ count++;
86
+ }
87
+ }
88
+ return count;
89
+ }
90
+
91
+ // ── Retry Management ────────────────────────────────────────────
92
+
93
+ export function addRetry(state: OrchestratorState, entry: RetryEntry): void {
94
+ // Cancel existing retry if any
95
+ const existing = state.retry_attempts.get(entry.issue_id);
96
+ if (existing) {
97
+ clearTimeout(existing.timer_handle);
98
+ }
99
+ state.retry_attempts.set(entry.issue_id, entry);
100
+ }
101
+
102
+ export function removeRetry(state: OrchestratorState, issueId: string): RetryEntry | undefined {
103
+ const entry = state.retry_attempts.get(issueId);
104
+ if (entry) {
105
+ clearTimeout(entry.timer_handle);
106
+ state.retry_attempts.delete(issueId);
107
+ }
108
+ return entry;
109
+ }
110
+
111
+ export function getRetry(state: OrchestratorState, issueId: string): RetryEntry | undefined {
112
+ return state.retry_attempts.get(issueId);
113
+ }
114
+
115
+ // ── Token/Rate Limit Updates ────────────────────────────────────
116
+
117
+ export function updateTotals(
118
+ state: OrchestratorState,
119
+ deltas: { delta_input: number; delta_output: number; delta_total: number }
120
+ ): void {
121
+ state.token_totals.input_tokens += deltas.delta_input;
122
+ state.token_totals.output_tokens += deltas.delta_output;
123
+ state.token_totals.total_tokens += deltas.delta_total;
124
+ }
125
+
126
+ export function updateRateLimits(state: OrchestratorState, limits: RateLimitInfo): void {
127
+ state.rate_limits = limits;
128
+ }
129
+
130
+ // ── Snapshot for Observability ──────────────────────────────────
131
+
132
+ export interface RuntimeSnapshot {
133
+ running: Array<{
134
+ issue_id: string;
135
+ issue_identifier: string;
136
+ state: string;
137
+ started_at: Date;
138
+ turn_count: number;
139
+ session_id: string | null;
140
+ workflow: string | null;
141
+ }>;
142
+ retrying: Array<{
143
+ issue_id: string;
144
+ identifier: string;
145
+ attempt: number;
146
+ due_at: Date;
147
+ error: string | null;
148
+ workflow: string | null;
149
+ }>;
150
+ workflows: Array<{
151
+ name: string;
152
+ max_concurrent_agents: number;
153
+ running_count: number;
154
+ }>;
155
+ token_totals: TokenTotals;
156
+ rate_limits: RateLimitInfo | null;
157
+ }
158
+
159
+ export function createSnapshot(state: OrchestratorState, workflowName: string | null = null): RuntimeSnapshot {
160
+ const now = Date.now();
161
+
162
+ // Calculate live seconds_running including active sessions
163
+ let activeSeconds = 0;
164
+ for (const entry of state.running.values()) {
165
+ activeSeconds += (now - entry.attempt.started_at.getTime()) / 1000;
166
+ }
167
+
168
+ const running = Array.from(state.running.values()).map((entry) => ({
169
+ issue_id: entry.issue.id,
170
+ issue_identifier: entry.issue.identifier,
171
+ state: entry.issue.state,
172
+ started_at: entry.attempt.started_at,
173
+ turn_count: entry.session?.turn_count ?? 0,
174
+ session_id: entry.session?.session_id ?? null,
175
+ workflow: workflowName,
176
+ }));
177
+
178
+ const retrying = Array.from(state.retry_attempts.values()).map((entry) => ({
179
+ issue_id: entry.issue_id,
180
+ identifier: entry.identifier,
181
+ attempt: entry.attempt,
182
+ due_at: new Date(entry.due_at_ms),
183
+ error: entry.error,
184
+ workflow: workflowName,
185
+ }));
186
+
187
+ return {
188
+ running,
189
+ retrying,
190
+ workflows: [{
191
+ name: workflowName ?? "default",
192
+ max_concurrent_agents: state.max_concurrent_agents,
193
+ running_count: running.length,
194
+ }],
195
+ token_totals: {
196
+ ...state.token_totals,
197
+ seconds_running: state.ended_seconds + activeSeconds,
198
+ },
199
+ rate_limits: state.rate_limits,
200
+ };
201
+ }
@@ -0,0 +1,51 @@
1
+ ## GitHub CLI
2
+
3
+ You have access to the GitHub CLI (`gh`) for working with issues.
4
+
5
+ ### Common Commands
6
+
7
+ ```
8
+ gh issue view <NUMBER> --json number,title,body,author,labels,state,assignees,milestone,createdAt,updatedAt,comments
9
+ gh issue create --title "..." [--body "..."] [--label "..."] [--assignee "..."]
10
+ gh issue edit <NUMBER> [--title "..."] [--body "..."]
11
+ gh issue comment <NUMBER> --body "comment body"
12
+ gh issue edit <NUMBER> --add-label "label-name"
13
+ gh issue edit <NUMBER> --remove-label "label-name"
14
+ gh issue close <NUMBER>
15
+ gh issue reopen <NUMBER>
16
+ ```
17
+
18
+ ### Notes
19
+ - `<NUMBER>` is the issue number (e.g., 123)
20
+ - Labels and assignees can be specified multiple times (e.g., `--label "bug" --label "priority:high"`)
21
+ - The `GH_REPO` environment variable is set automatically by Symphony
22
+ - Use labels to track workflow stages (e.g., `agent:dev` -> `agent:dev:progress` -> `agent:dev:done`)
23
+
24
+ ### Error Handling
25
+ If you encounter an unrecoverable error (e.g. missing dependencies, broken build that you cannot fix, unclear requirements), add an "error" label so that the orchestrator can retry later:
26
+ ```
27
+ gh issue edit <NUMBER> --add-label "error"
28
+ ```
29
+
30
+ ### Examples
31
+
32
+ ```bash
33
+ # Get issue details
34
+ gh issue view 42 --json number,title,body,author,labels,state,assignees,milestone,createdAt,updatedAt,comments
35
+
36
+ # Create a new issue
37
+ gh issue create --title "Fix login bug" --body "Users can't login" --label "bug"
38
+
39
+ # Add a progress label
40
+ gh issue edit 42 --add-label "agent:dev:progress"
41
+
42
+ # Post a status comment
43
+ gh issue comment 42 --body "Started working on the fix"
44
+
45
+ # Remove old label and add completion label
46
+ gh issue edit 42 --remove-label "agent:dev"
47
+ gh issue edit 42 --add-label "agent:dev:done"
48
+
49
+ # Close the issue when done
50
+ gh issue close 42
51
+ ```
@@ -0,0 +1,44 @@
1
+ ## Linear CLI
2
+
3
+ You have access to a Linear project management CLI via `bun $SYMPHONY_LINEAR <command>`.
4
+
5
+ ### Available Commands
6
+
7
+ ```
8
+ bun $SYMPHONY_LINEAR get-issue <IDENTIFIER> # Get issue details (JSON)
9
+ bun $SYMPHONY_LINEAR get-comments <IDENTIFIER> # Get issue comments (JSON)
10
+ bun $SYMPHONY_LINEAR create-issue --parent <ID> --title "..." # Create a child issue
11
+ [--description "..."] [--priority N]
12
+ bun $SYMPHONY_LINEAR update-issue <IDENTIFIER> # Update an issue
13
+ [--title "..."] [--description "..."] [--state "..."]
14
+ bun $SYMPHONY_LINEAR create-comment <IDENTIFIER> "body" # Post a comment
15
+ bun $SYMPHONY_LINEAR add-label <IDENTIFIER> "label-name" # Add a label
16
+ bun $SYMPHONY_LINEAR remove-label <IDENTIFIER> "label-name" # Remove a label
17
+ bun $SYMPHONY_LINEAR swap-label <IDENTIFIER> # Swap labels atomically
18
+ --remove "old-label" --add "new-label"
19
+ ```
20
+
21
+ ### Downloading Attachments
22
+
23
+ If an issue description or comments contain images (screenshots, diagrams, mockups, etc.), download them so you can view them:
24
+
25
+ ```
26
+ bun $SYMPHONY_LINEAR download-attachments <IDENTIFIER> --output ./attachments
27
+ ```
28
+
29
+ This extracts all image URLs from the issue description, comments, and Linear attachments, then downloads them to the specified directory. Output is a JSON manifest mapping original URLs to local file paths.
30
+
31
+ **When to use:** Always download attachments before starting work on an issue that references visual content (UI mockups, screenshots of bugs, design specs, diagrams). The downloaded files can then be read directly to understand the visual context.
32
+
33
+ ### Error Handling
34
+ If you encounter an unrecoverable error (e.g. missing dependencies, broken build that you cannot fix, unclear requirements), set the issue state to "Error" so that the orchestrator can retry later:
35
+ ```
36
+ bun $SYMPHONY_LINEAR update-issue <IDENTIFIER> --state "Error"
37
+ ```
38
+
39
+ ### Notes
40
+ - `<IDENTIFIER>` is the issue identifier (e.g. SYM-123) or UUID
41
+ - Priority values: 1=urgent, 2=high, 3=medium, 4=low
42
+ - For `create-issue`, `--parent` takes an identifier and resolves team/project automatically
43
+ - All commands output JSON on success
44
+ - Use `swap-label` when transitioning between workflow stages (e.g. `agent:prd` -> `agent:prd:progress`)