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.
- package/CLAUDE.md +60 -0
- package/LICENSE +21 -0
- package/README.md +292 -0
- package/dist/web/app.css +2 -0
- package/dist/web/index.html +13 -0
- package/dist/web/main.js +235 -0
- package/package.json +62 -0
- package/src/agent/claude-runner.ts +576 -0
- package/src/agent/protocol.ts +2 -0
- package/src/agent/runner.ts +2 -0
- package/src/agent/session.ts +113 -0
- package/src/cli.ts +354 -0
- package/src/config/loader.ts +379 -0
- package/src/config/types.ts +382 -0
- package/src/index.ts +53 -0
- package/src/linear-cli.ts +414 -0
- package/src/logging/logger.ts +143 -0
- package/src/orchestrator/multi-orchestrator.ts +266 -0
- package/src/orchestrator/orchestrator.ts +1357 -0
- package/src/orchestrator/scheduler.ts +195 -0
- package/src/orchestrator/state.ts +201 -0
- package/src/prompts/github-system-prompt.md +51 -0
- package/src/prompts/linear-system-prompt.md +44 -0
- package/src/tracker/client.ts +577 -0
- package/src/tracker/github-issues-tracker.ts +280 -0
- package/src/tracker/github-pr-tracker.ts +298 -0
- package/src/tracker/index.ts +9 -0
- package/src/tracker/interface.ts +76 -0
- package/src/tracker/linear-tracker.ts +147 -0
- package/src/tracker/queries.ts +281 -0
- package/src/tracker/types.ts +125 -0
- package/src/tui/App.tsx +157 -0
- package/src/tui/LogView.tsx +120 -0
- package/src/tui/StatusBar.tsx +72 -0
- package/src/tui/TabBar.tsx +55 -0
- package/src/tui/sink.ts +47 -0
- package/src/tui/types.ts +6 -0
- package/src/tui/useOrchestrator.ts +244 -0
- package/src/web/server.ts +182 -0
- package/src/web/sink.ts +67 -0
- package/src/web-ui/App.tsx +60 -0
- package/src/web-ui/components/agent-table.tsx +57 -0
- package/src/web-ui/components/header.tsx +72 -0
- package/src/web-ui/components/log-stream.tsx +111 -0
- package/src/web-ui/components/retry-table.tsx +58 -0
- package/src/web-ui/components/stats-cards.tsx +142 -0
- package/src/web-ui/components/ui/badge.tsx +30 -0
- package/src/web-ui/components/ui/button.tsx +39 -0
- package/src/web-ui/components/ui/card.tsx +32 -0
- package/src/web-ui/globals.css +27 -0
- package/src/web-ui/index.html +13 -0
- package/src/web-ui/lib/use-sse.ts +98 -0
- package/src/web-ui/lib/utils.ts +25 -0
- package/src/web-ui/main.tsx +4 -0
- package/src/workspace/hooks.ts +97 -0
- package/src/workspace/manager.ts +211 -0
- package/src/workspace/render-hook.ts +13 -0
- package/workflows/dev.md +127 -0
- package/workflows/github-issues.md +107 -0
- package/workflows/pr-review.md +89 -0
- package/workflows/prd.md +170 -0
- package/workflows/ralph.md +95 -0
- 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`)
|