@gh-symphony/cli 0.0.11 → 0.0.13

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/README.md CHANGED
@@ -62,6 +62,38 @@ You can further customize the agent's behavior by editing `WORKFLOW.md` — this
62
62
 
63
63
  > Currently supported runtimes: **Codex**, **Claude Code**
64
64
 
65
+ ### Project `.env` Mapping
66
+
67
+ If your hooks or worker runs need staging hosts, database URLs, Playwright base URLs, or other runtime-only values, store them in the project runtime directory instead of hardcoding them in `WORKFLOW.md`.
68
+
69
+ 1. Find the project id from `gh-symphony project list`.
70
+ 2. Create the runtime env file:
71
+
72
+ ```bash
73
+ mkdir -p ~/.gh-symphony/projects/<project-id>
74
+ cat > ~/.gh-symphony/projects/<project-id>/.env <<'EOF'
75
+ STAGING_API_HOST=https://staging.example.com
76
+ DATABASE_URL=postgres://user:pass@staging-db:5432/app
77
+ PLAYWRIGHT_BASE_URL=http://localhost:3000
78
+ EOF
79
+ ```
80
+
81
+ 3. Reference those variables from `WORKFLOW.md` hooks or repository setup scripts:
82
+
83
+ ```yaml
84
+ hooks:
85
+ after_create: 'echo "API_HOST=$STAGING_API_HOST" >> .env.development'
86
+ before_run: 'echo "BASE_URL=$PLAYWRIGHT_BASE_URL" > playwright.env'
87
+ ```
88
+
89
+ Env precedence during hook execution and worker spawn is:
90
+
91
+ - `project .env` as the base
92
+ - system env as the override layer
93
+ - Symphony context vars such as `SYMPHONY_*` as the highest-priority layer
94
+
95
+ If you use `--config <dir>`, replace `~/.gh-symphony` with that directory.
96
+
65
97
  ## 3. Set Orchestrator Runner (Project)
66
98
 
67
99
  On the machine where you want the orchestrator to run, register a project:
@@ -1,4 +1,4 @@
1
- import { readFile, readdir } from "node:fs/promises";
1
+ import { readFile, readdir, stat } from "node:fs/promises";
2
2
  import { join, resolve } from "node:path";
3
3
  import { createReadStream } from "node:fs";
4
4
  import { createInterface } from "node:readline";
@@ -32,9 +32,17 @@ function parseLogsArgs(args) {
32
32
  }
33
33
  const handler = async (args, options) => {
34
34
  const parsed = parseLogsArgs(args);
35
+ const runtimeRoot = resolve(options.configDir);
35
36
  // If --run is specified, read that run's events
36
37
  if (parsed.run) {
37
- const eventsPath = join(resolve(options.configDir), "orchestrator", "runs", parsed.run, "events.ndjson");
38
+ const eventsPath = parsed.projectId
39
+ ? join(runtimeRoot, "projects", parsed.projectId, "runs", parsed.run, "events.ndjson")
40
+ : await resolveRunEventsPath(runtimeRoot, parsed.run);
41
+ if (!eventsPath) {
42
+ process.stderr.write(`No events found for run: ${parsed.run}\n`);
43
+ process.exitCode = 1;
44
+ return;
45
+ }
38
46
  try {
39
47
  const content = await readFile(eventsPath, "utf8");
40
48
  const lines = content.trim().split("\n").filter(Boolean);
@@ -96,31 +104,43 @@ const handler = async (args, options) => {
96
104
  return;
97
105
  }
98
106
  // Scan all run events
99
- const runsDir = join(resolve(options.configDir), "orchestrator", "runs");
107
+ const runRoots = parsed.projectId
108
+ ? [join(runtimeRoot, "projects", parsed.projectId, "runs")]
109
+ : await listProjectRunRoots(runtimeRoot);
110
+ let foundRuns = false;
100
111
  try {
101
- const entries = await readdir(runsDir);
102
- for (const entry of entries) {
103
- const eventsPath = join(runsDir, entry, "events.ndjson");
104
- try {
105
- const content = await readFile(eventsPath, "utf8");
106
- const lines = content.trim().split("\n").filter(Boolean);
107
- for (const line of lines) {
108
- const event = JSON.parse(line);
109
- if (parsed.projectId && event.projectId !== parsed.projectId)
110
- continue;
111
- if (parsed.level && event.level !== parsed.level)
112
- continue;
113
- if (parsed.issue && event.issueIdentifier !== parsed.issue)
114
- continue;
115
- process.stdout.write(formatEvent(event) + "\n");
116
- }
112
+ for (const runsDir of runRoots) {
113
+ const entries = await safeReadDir(runsDir);
114
+ if (entries.length === 0) {
115
+ continue;
117
116
  }
118
- catch {
119
- // Skip runs without events
117
+ foundRuns = true;
118
+ for (const entry of entries) {
119
+ const eventsPath = join(runsDir, entry, "events.ndjson");
120
+ try {
121
+ const content = await readFile(eventsPath, "utf8");
122
+ const lines = content.trim().split("\n").filter(Boolean);
123
+ for (const line of lines) {
124
+ const event = JSON.parse(line);
125
+ if (parsed.projectId && event.projectId !== parsed.projectId)
126
+ continue;
127
+ if (parsed.level && event.level !== parsed.level)
128
+ continue;
129
+ if (parsed.issue && event.issueIdentifier !== parsed.issue)
130
+ continue;
131
+ process.stdout.write(formatEvent(event) + "\n");
132
+ }
133
+ }
134
+ catch {
135
+ // Skip runs without events
136
+ }
120
137
  }
121
138
  }
122
139
  }
123
140
  catch {
141
+ // fall through to friendly error below
142
+ }
143
+ if (!foundRuns) {
124
144
  process.stderr.write("No runs found. Start the orchestrator first.\n");
125
145
  }
126
146
  };
@@ -132,3 +152,33 @@ function formatEvent(event) {
132
152
  const extra = event.error ? ` error=${event.error}` : "";
133
153
  return `[${at}] ${eventType} ${issue}${extra}`;
134
154
  }
155
+ async function listProjectRunRoots(runtimeRoot) {
156
+ try {
157
+ const projectIds = await readdir(join(runtimeRoot, "projects"));
158
+ return projectIds.map((projectId) => join(runtimeRoot, "projects", projectId, "runs"));
159
+ }
160
+ catch {
161
+ return [];
162
+ }
163
+ }
164
+ async function resolveRunEventsPath(runtimeRoot, runId) {
165
+ for (const runsDir of await listProjectRunRoots(runtimeRoot)) {
166
+ const eventsPath = join(runsDir, runId, "events.ndjson");
167
+ try {
168
+ await stat(eventsPath);
169
+ return eventsPath;
170
+ }
171
+ catch {
172
+ // Continue searching.
173
+ }
174
+ }
175
+ return null;
176
+ }
177
+ async function safeReadDir(path) {
178
+ try {
179
+ return await readdir(path);
180
+ }
181
+ catch {
182
+ return [];
183
+ }
184
+ }
@@ -174,7 +174,7 @@ function isProcessRunning(pid) {
174
174
  async function readPersistedSnapshot(configDir, projectId) {
175
175
  try {
176
176
  const runtimeRoot = resolveRuntimeRoot(configDir);
177
- const content = await readFile(join(runtimeRoot, "orchestrator", "projects", projectId, "status.json"), "utf8");
177
+ const content = await readFile(join(runtimeRoot, "projects", projectId, "status.json"), "utf8");
178
178
  return JSON.parse(content);
179
179
  }
180
180
  catch {
@@ -1,7 +1,7 @@
1
1
  import { readFile, readdir } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
3
  import { runCli as orchestratorRunCli } from "@gh-symphony/orchestrator";
4
- import { resolveRuntimeRoot, syncProjectToRuntime, } from "../orchestrator-runtime.js";
4
+ import { resolveRuntimeRoot, } from "../orchestrator-runtime.js";
5
5
  import { handleMissingManagedProjectConfig, resolveManagedProjectConfig, } from "../project-selection.js";
6
6
  function parseRecoverArgs(args) {
7
7
  const parsed = { dryRun: false };
@@ -29,7 +29,6 @@ const handler = async (args, options) => {
29
29
  }
30
30
  const runtimeRoot = resolveRuntimeRoot(options.configDir);
31
31
  const projectId = projectConfig.projectId;
32
- await syncProjectToRuntime(options.configDir, projectConfig);
33
32
  if (parsed.dryRun) {
34
33
  process.stdout.write("Dry run — scanning for stalled runs...\n");
35
34
  const candidates = await listRecoverCandidates(runtimeRoot, projectId);
@@ -57,7 +56,7 @@ const handler = async (args, options) => {
57
56
  };
58
57
  export default handler;
59
58
  async function listRecoverCandidates(runtimeRoot, projectId) {
60
- const runsDir = join(runtimeRoot, "orchestrator", "runs");
59
+ const runsDir = join(runtimeRoot, "projects", projectId, "runs");
61
60
  const candidates = [];
62
61
  let entries = [];
63
62
  try {
@@ -1,5 +1,5 @@
1
1
  import { runCli as orchestratorRunCli } from "@gh-symphony/orchestrator";
2
- import { resolveRuntimeRoot, syncProjectToRuntime, } from "../orchestrator-runtime.js";
2
+ import { resolveRuntimeRoot, } from "../orchestrator-runtime.js";
3
3
  import { handleMissingManagedProjectConfig, resolveManagedProjectConfig, } from "../project-selection.js";
4
4
  function parseRunArgs(args) {
5
5
  const parsed = {
@@ -11,18 +11,41 @@ function parseRunArgs(args) {
11
11
  parsed.watch = true;
12
12
  }
13
13
  else if (arg === "--project" || arg === "--project-id") {
14
- parsed.projectId = args[i + 1];
14
+ const value = args[i + 1];
15
+ if (!value || value.startsWith("-")) {
16
+ parsed.error = `Option '${arg}' argument missing`;
17
+ return parsed;
18
+ }
19
+ parsed.projectId = value;
15
20
  i += 1;
16
21
  }
17
- else if (!arg?.startsWith("--")) {
22
+ else if (arg === "--log-level") {
23
+ const value = args[i + 1];
24
+ if (!value || value.startsWith("-")) {
25
+ parsed.error = `Option '${arg}' argument missing`;
26
+ return parsed;
27
+ }
28
+ parsed.logLevel = value;
29
+ i += 1;
30
+ }
31
+ else if (!arg?.startsWith("-")) {
18
32
  // Positional arg = issue identifier
19
33
  parsed.issue = arg;
20
34
  }
35
+ else {
36
+ parsed.error = `Unknown option '${arg}'`;
37
+ return parsed;
38
+ }
21
39
  }
22
40
  return parsed;
23
41
  }
24
42
  const handler = async (args, options) => {
25
43
  const parsed = parseRunArgs(args);
44
+ if (parsed.error) {
45
+ process.stderr.write(`${parsed.error}\n`);
46
+ process.exitCode = 2;
47
+ return;
48
+ }
26
49
  if (!parsed.issue) {
27
50
  process.stderr.write("Usage: gh-symphony run <owner/repo#number>\n");
28
51
  process.exitCode = 2;
@@ -38,7 +61,6 @@ const handler = async (args, options) => {
38
61
  }
39
62
  const runtimeRoot = resolveRuntimeRoot(options.configDir);
40
63
  const projectId = projectConfig.projectId;
41
- await syncProjectToRuntime(options.configDir, projectConfig);
42
64
  // Validate the issue identifier belongs to a configured repo
43
65
  const [repoSpec] = parsed.issue.split("#");
44
66
  if (repoSpec &&
@@ -57,6 +79,7 @@ const handler = async (args, options) => {
57
79
  projectId,
58
80
  "--issue",
59
81
  parsed.issue,
82
+ ...(parsed.logLevel ? ["--log-level", parsed.logLevel] : []),
60
83
  ]);
61
84
  if (parsed.watch) {
62
85
  process.stdout.write("\nWatching for status changes...\n");
@@ -1,13 +1,19 @@
1
1
  import { rm } from "node:fs/promises";
2
2
  import type { GlobalOptions } from "../index.js";
3
+ import { releaseProjectLock, type ProjectLockHandle } from "@gh-symphony/orchestrator";
3
4
  type ForegroundShutdownOptions = {
4
5
  configDir: string;
5
6
  projectId: string;
6
7
  statusServer: {
7
8
  close(): void;
8
9
  };
10
+ projectLock?: ProjectLockHandle | null;
11
+ service?: {
12
+ shutdown(): Promise<void>;
13
+ };
9
14
  exit?: (code?: number) => never;
10
15
  removePortFile?: typeof rm;
16
+ releaseLock?: typeof releaseProjectLock;
11
17
  };
12
18
  declare const handler: (args: string[], options: GlobalOptions) => Promise<void>;
13
19
  export declare function shutdownForegroundOrchestrator(input: ForegroundShutdownOptions): Promise<never>;
@@ -3,8 +3,8 @@ import { dirname, join } from "node:path";
3
3
  import { spawn } from "node:child_process";
4
4
  import { once } from "node:events";
5
5
  import { daemonPidPath, orchestratorLogPath, orchestratorPortPath, } from "../config.js";
6
- import { OrchestratorService, createStore, startOrchestratorStatusServer, } from "@gh-symphony/orchestrator";
7
- import { resolveRuntimeRoot, syncProjectToRuntime, } from "../orchestrator-runtime.js";
6
+ import { OrchestratorService, acquireProjectLock, createStore, releaseProjectLock, resolveOrchestratorLogLevel, startOrchestratorStatusServer, } from "@gh-symphony/orchestrator";
7
+ import { resolveRuntimeRoot, } from "../orchestrator-runtime.js";
8
8
  import { handleMissingManagedProjectConfig, resolveManagedProjectConfig, } from "../project-selection.js";
9
9
  import { bold, dim, green, red, yellow, cyan, setNoColor } from "../ansi.js";
10
10
  import { getGhToken } from "../github/gh-auth.js";
@@ -39,6 +39,16 @@ function parseStartArgs(args) {
39
39
  i += 1;
40
40
  continue;
41
41
  }
42
+ if (arg === "--log-level") {
43
+ const value = args[i + 1];
44
+ if (!value || value.startsWith("-")) {
45
+ parsed.error = `Option '${arg}' argument missing`;
46
+ return parsed;
47
+ }
48
+ parsed.logLevel = value;
49
+ i += 1;
50
+ continue;
51
+ }
42
52
  if (arg?.startsWith("-")) {
43
53
  parsed.error = `Unknown option '${arg}'`;
44
54
  return parsed;
@@ -133,9 +143,17 @@ const handler = async (args, options) => {
133
143
  }
134
144
  const runtimeRoot = resolveRuntimeRoot(options.configDir);
135
145
  const projectId = projectConfig.projectId;
136
- await syncProjectToRuntime(options.configDir, projectConfig);
146
+ let logLevel;
147
+ try {
148
+ logLevel = resolveOrchestratorLogLevel(parsed.logLevel ?? process.env.SYMPHONY_LOG_LEVEL);
149
+ }
150
+ catch (error) {
151
+ process.stderr.write(`${error instanceof Error ? error.message : "Unsupported log level"}\n`);
152
+ process.exitCode = 2;
153
+ return;
154
+ }
137
155
  if (parsed.daemon) {
138
- await startDaemon(options, projectId);
156
+ await startDaemon(options, projectId, parsed.logLevel);
139
157
  return;
140
158
  }
141
159
  // ── 5.1: Foreground mode with live logging ────────────────────────────────
@@ -148,65 +166,99 @@ const handler = async (args, options) => {
148
166
  // Workers will fail if token is needed but not available
149
167
  }
150
168
  }
151
- const store = createStore(runtimeRoot);
152
- const service = new OrchestratorService(store, projectConfig);
153
- const statusServer = startOrchestratorStatusServer({
154
- host: "127.0.0.1",
155
- port: 0,
156
- getProjectStatus: () => service.status(),
157
- onRefresh: async () => {
158
- await service.runOnce();
159
- },
160
- });
161
- await persistStatusServerPort(options.configDir, projectId, statusServer);
162
- logLine(green("\u25B2"), `Starting orchestrator for project: ${bold(projectId)}`);
163
- logLine(dim("\u00B7"), dim("Press Ctrl+C to stop"));
164
- let running = true;
165
- let shuttingDown = false;
166
- const shutdown = async () => {
167
- if (shuttingDown) {
168
- return;
169
- }
170
- shuttingDown = true;
171
- running = false;
172
- await shutdownForegroundOrchestrator({
173
- configDir: options.configDir,
169
+ let projectLock = null;
170
+ try {
171
+ projectLock = await acquireProjectLock({
172
+ runtimeRoot,
174
173
  projectId,
175
- statusServer,
176
174
  });
177
- };
178
- process.on("SIGINT", () => {
179
- void shutdown();
180
- });
181
- process.on("SIGTERM", () => {
182
- void shutdown();
183
- });
184
- let prevSnapshot = null;
185
- let isFirst = true;
186
- while (running) {
187
- try {
188
- const snapshot = await service.runOnce();
189
- logTickResult(snapshot, prevSnapshot, isFirst);
190
- if (!isFirst) {
191
- const currentRunIds = new Set(snapshot.activeRuns.map((run) => run.runId));
192
- for (const prevRun of prevSnapshot?.activeRuns ?? []) {
193
- if (!currentRunIds.has(prevRun.runId)) {
194
- await tailWorkerLog(runtimeRoot, prevRun.runId, prevRun.issueIdentifier);
175
+ const store = createStore(runtimeRoot);
176
+ const service = new OrchestratorService(store, projectConfig, {
177
+ logLevel,
178
+ });
179
+ const statusServer = startOrchestratorStatusServer({
180
+ host: "127.0.0.1",
181
+ port: 0,
182
+ getProjectStatus: () => service.status(),
183
+ onRefresh: async () => {
184
+ await service.runOnce();
185
+ },
186
+ });
187
+ await persistStatusServerPort(options.configDir, projectId, statusServer);
188
+ logLine(green("\u25B2"), `Starting orchestrator for project: ${bold(projectId)}`);
189
+ logLine(dim("\u00B7"), dim("Press Ctrl+C to stop"));
190
+ let running = true;
191
+ let shuttingDown = false;
192
+ let shutdownPromise = null;
193
+ const shutdown = async () => {
194
+ if (shuttingDown) {
195
+ return shutdownPromise;
196
+ }
197
+ shuttingDown = true;
198
+ running = false;
199
+ const heldLock = projectLock;
200
+ projectLock = null;
201
+ shutdownPromise = shutdownForegroundOrchestrator({
202
+ configDir: options.configDir,
203
+ projectId,
204
+ statusServer,
205
+ projectLock: heldLock,
206
+ service,
207
+ });
208
+ return shutdownPromise;
209
+ };
210
+ process.on("SIGINT", () => {
211
+ void shutdown();
212
+ });
213
+ process.on("SIGTERM", () => {
214
+ void shutdown();
215
+ });
216
+ let prevSnapshot = null;
217
+ let isFirst = true;
218
+ while (running) {
219
+ try {
220
+ const snapshot = await service.runOnce();
221
+ logTickResult(snapshot, prevSnapshot, isFirst);
222
+ if (!isFirst) {
223
+ const currentRunIds = new Set(snapshot.activeRuns.map((run) => run.runId));
224
+ for (const prevRun of prevSnapshot?.activeRuns ?? []) {
225
+ if (!currentRunIds.has(prevRun.runId)) {
226
+ await tailWorkerLog(runtimeRoot, projectId, prevRun.runId, prevRun.issueIdentifier);
227
+ }
195
228
  }
196
229
  }
230
+ prevSnapshot = snapshot;
231
+ isFirst = false;
197
232
  }
198
- prevSnapshot = snapshot;
199
- isFirst = false;
200
- }
201
- catch (error) {
202
- logLine(red("\u2717"), red(`Tick error: ${error instanceof Error ? error.message : "Unknown error"}`));
233
+ catch (error) {
234
+ logLine(red("\u2717"), red(`Tick error: ${error instanceof Error ? error.message : "Unknown error"}`));
235
+ }
236
+ if (!running) {
237
+ if (shutdownPromise) {
238
+ await shutdownPromise;
239
+ }
240
+ break;
241
+ }
242
+ // Poll interval: default 30s
243
+ await new Promise((r) => setTimeout(r, 30_000));
203
244
  }
204
- // Poll interval: default 30s
205
- await new Promise((r) => setTimeout(r, 30_000));
245
+ }
246
+ finally {
247
+ await releaseProjectLock(projectLock);
206
248
  }
207
249
  };
208
250
  export async function shutdownForegroundOrchestrator(input) {
209
251
  logLine(yellow("\u25BC"), "Shutting down...");
252
+ // Drain active workers before tearing down infrastructure so that child
253
+ // processes receive SIGTERM/SIGKILL and do not become orphans.
254
+ if (input.service) {
255
+ try {
256
+ await input.service.shutdown();
257
+ }
258
+ catch (error) {
259
+ logLine(red("\u2717"), red(`Failed to shut down workers: ${error instanceof Error ? error.message : "Unknown error"}`));
260
+ }
261
+ }
210
262
  try {
211
263
  input.statusServer.close();
212
264
  }
@@ -221,11 +273,17 @@ export async function shutdownForegroundOrchestrator(input) {
221
273
  catch (error) {
222
274
  logLine(yellow("\u26A0"), `Failed to remove persisted status port: ${error instanceof Error ? error.message : "Unknown error"}`);
223
275
  }
276
+ try {
277
+ await (input.releaseLock ?? releaseProjectLock)(input.projectLock);
278
+ }
279
+ catch (error) {
280
+ logLine(yellow("\u26A0"), `Failed to release project lock: ${error instanceof Error ? error.message : "Unknown error"}`);
281
+ }
224
282
  return (input.exit ?? process.exit)(0);
225
283
  }
226
- async function tailWorkerLog(runtimeRoot, runId, issueIdentifier) {
284
+ async function tailWorkerLog(runtimeRoot, projectId, runId, issueIdentifier) {
227
285
  try {
228
- const logPath = join(runtimeRoot, "orchestrator", "runs", runId, "worker.log");
286
+ const logPath = join(runtimeRoot, "projects", projectId, "runs", runId, "worker.log");
229
287
  const content = await readFile(logPath, "utf8");
230
288
  const lines = content.split("\n").filter((l) => l.trim());
231
289
  if (lines.length === 0)
@@ -242,12 +300,18 @@ async function tailWorkerLog(runtimeRoot, runId, issueIdentifier) {
242
300
  }
243
301
  export default handler;
244
302
  // ── 5.2: Daemon mode ─────────────────────────────────────────────────────────
245
- async function startDaemon(options, projectId) {
303
+ async function startDaemon(options, projectId, logLevel) {
246
304
  const logPath = orchestratorLogPath(options.configDir, projectId);
247
305
  await mkdir(dirname(logPath), { recursive: true });
248
306
  const { openSync } = await import("node:fs");
249
307
  const logFd = openSync(logPath, "a");
250
- const child = spawn(process.execPath, [process.argv[1], "start", "--project", projectId], {
308
+ const child = spawn(process.execPath, [
309
+ process.argv[1],
310
+ "start",
311
+ "--project",
312
+ projectId,
313
+ ...(logLevel ? ["--log-level", logLevel] : []),
314
+ ], {
251
315
  cwd: process.cwd(),
252
316
  env: {
253
317
  ...process.env,
@@ -1,6 +1,6 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
- import { resolveRuntimeRoot, syncProjectToRuntime, } from "../orchestrator-runtime.js";
3
+ import { resolveRuntimeRoot, } from "../orchestrator-runtime.js";
4
4
  import { handleMissingManagedProjectConfig, resolveManagedProjectConfig, } from "../project-selection.js";
5
5
  import { bold, dim, green, red, yellow, cyan, stripAnsi } from "../ansi.js";
6
6
  import { clearScreen, showCursor, hideCursor } from "../ansi.js";
@@ -137,7 +137,7 @@ function parseStatusArgs(args) {
137
137
  }
138
138
  async function readStatusSnapshot(runtimeRoot, projectId) {
139
139
  try {
140
- const statusPath = join(runtimeRoot, "orchestrator", "projects", projectId, "status.json");
140
+ const statusPath = join(runtimeRoot, "projects", projectId, "status.json");
141
141
  const content = await readFile(statusPath, "utf-8");
142
142
  return JSON.parse(content);
143
143
  }
@@ -163,7 +163,6 @@ const handler = async (args, options) => {
163
163
  }
164
164
  const runtimeRoot = resolveRuntimeRoot(options.configDir);
165
165
  const projectId = projectConfig.projectId;
166
- await syncProjectToRuntime(options.configDir, projectConfig);
167
166
  if (parsed.watch) {
168
167
  const isTTY = process.stdout.isTTY === true;
169
168
  let terminalWidth = process.stdout.columns ?? 115;
package/dist/config.d.ts CHANGED
@@ -8,9 +8,10 @@ export type CliGlobalConfig = {
8
8
  activeProject: string | null;
9
9
  projects: string[];
10
10
  };
11
- export type CliProjectTrackerSettings = Record<string, string | boolean> & {
11
+ export type CliProjectTrackerSettings = Record<string, string | number | boolean> & {
12
12
  projectId?: string;
13
13
  assignedOnly?: boolean;
14
+ timeoutMs?: number;
14
15
  };
15
16
  export type CliProjectConfig = Omit<OrchestratorProjectConfig, "tracker"> & {
16
17
  displayName?: string;
package/dist/index.js CHANGED
@@ -103,6 +103,7 @@ function createProgram() {
103
103
  .command("start")
104
104
  .description("Start the orchestrator")
105
105
  .option("-d, --daemon", "Start in daemon mode")
106
+ .option("--log-level <level>", "Orchestrator lifecycle log level")
106
107
  .option("--project-id <projectId>", "Project identifier")
107
108
  .addOption(new Option("--project <projectId>").hideHelp())
108
109
  .allowExcessArguments(false)).action(async function () {
@@ -111,6 +112,7 @@ function createProgram() {
111
112
  const args = [];
112
113
  pushOption(args, "--project-id", resolveProjectId(values));
113
114
  pushOption(args, "--daemon", values.daemon);
115
+ pushOption(args, "--log-level", values.logLevel);
114
116
  await invokeHandler("start", args, values);
115
117
  });
116
118
  addGlobalOptions(program
@@ -145,6 +147,7 @@ function createProgram() {
145
147
  .command("run")
146
148
  .description("Dispatch a single issue")
147
149
  .argument("<issue>", "Issue identifier")
150
+ .option("--log-level <level>", "Orchestrator lifecycle log level")
148
151
  .option("-w, --watch", "Watch status after dispatch")
149
152
  .option("--project-id <projectId>", "Project identifier")
150
153
  .addOption(new Option("--project <projectId>").hideHelp())
@@ -153,6 +156,7 @@ function createProgram() {
153
156
  const values = this.optsWithGlobals();
154
157
  const args = [issue];
155
158
  pushOption(args, "--project-id", resolveProjectId(values));
159
+ pushOption(args, "--log-level", values.logLevel);
156
160
  pushOption(args, "--watch", values.watch);
157
161
  await invokeHandler("run", args, values);
158
162
  });
@@ -229,6 +233,7 @@ function createProgram() {
229
233
  .command("start")
230
234
  .description("Start a specific project")
231
235
  .option("-d, --daemon", "Start in daemon mode")
236
+ .option("--log-level <level>", "Orchestrator lifecycle log level")
232
237
  .option("--project-id <projectId>", "Project identifier")
233
238
  .addOption(new Option("--project <projectId>").hideHelp())
234
239
  .allowExcessArguments(false)).action(async function () {
@@ -237,6 +242,7 @@ function createProgram() {
237
242
  const args = ["start"];
238
243
  pushOption(args, "--project-id", resolveProjectId(values));
239
244
  pushOption(args, "--daemon", values.daemon);
245
+ pushOption(args, "--log-level", values.logLevel);
240
246
  await invokeHandler("project", args, values);
241
247
  });
242
248
  addGlobalOptions(project
@@ -1,5 +1 @@
1
- import { type CliProjectConfig } from "./config.js";
2
1
  export declare function resolveRuntimeRoot(configDir: string): string;
3
- export declare function resolveProjectConfig(configDir: string, requestedProjectId?: string): Promise<CliProjectConfig | null>;
4
- export declare function orchestratorProjectConfigPath(runtimeRoot: string, projectId: string): string;
5
- export declare function syncProjectToRuntime(configDir: string, projectConfig: CliProjectConfig): Promise<string>;
@@ -1,26 +1,4 @@
1
- import { mkdir, writeFile } from "node:fs/promises";
2
- import { dirname, join, resolve } from "node:path";
3
- import { loadGlobalConfig, loadProjectConfig, } from "./config.js";
1
+ import { resolve } from "node:path";
4
2
  export function resolveRuntimeRoot(configDir) {
5
3
  return resolve(configDir);
6
4
  }
7
- export async function resolveProjectConfig(configDir, requestedProjectId) {
8
- if (requestedProjectId) {
9
- return loadProjectConfig(configDir, requestedProjectId);
10
- }
11
- const global = await loadGlobalConfig(configDir);
12
- if (!global?.activeProject) {
13
- return null;
14
- }
15
- return loadProjectConfig(configDir, global.activeProject);
16
- }
17
- export function orchestratorProjectConfigPath(runtimeRoot, projectId) {
18
- return join(runtimeRoot, "orchestrator", "projects", projectId, "config.json");
19
- }
20
- export async function syncProjectToRuntime(configDir, projectConfig) {
21
- const runtimeRoot = resolveRuntimeRoot(configDir);
22
- const configPath = orchestratorProjectConfigPath(runtimeRoot, projectConfig.projectId);
23
- await mkdir(dirname(configPath), { recursive: true });
24
- await writeFile(configPath, JSON.stringify(projectConfig, null, 2) + "\n");
25
- return runtimeRoot;
26
- }
@@ -83,7 +83,7 @@ export function generateGhSymphonySkill(ctx) {
83
83
  lines.push("agent:");
84
84
  lines.push(" max_concurrent_agents: 10");
85
85
  lines.push(" max_retry_backoff_ms: 30000");
86
- lines.push(" retry_base_delay_ms: 1000");
86
+ lines.push(" retry_base_delay_ms: 10000");
87
87
  lines.push(" max_turns: 20");
88
88
  lines.push("codex:");
89
89
  lines.push(" command: codex app-server");
@@ -63,7 +63,7 @@ export function generateReferenceWorkflow(input) {
63
63
  lines.push("agent:");
64
64
  lines.push(" max_concurrent_agents: 10");
65
65
  lines.push(" max_retry_backoff_ms: 30000");
66
- lines.push(" retry_base_delay_ms: 1000");
66
+ lines.push(" retry_base_delay_ms: 10000");
67
67
  lines.push(" max_turns: 20");
68
68
  lines.push("");
69
69
  lines.push("codex:");
@@ -37,7 +37,7 @@ function buildFrontMatter(input) {
37
37
  lines.push("agent:");
38
38
  lines.push(" max_concurrent_agents: 10");
39
39
  lines.push(" max_retry_backoff_ms: 30000");
40
- lines.push(" retry_base_delay_ms: 1000");
40
+ lines.push(" retry_base_delay_ms: 10000");
41
41
  lines.push("codex:");
42
42
  lines.push(` command: ${agentCommand}`);
43
43
  lines.push(" read_timeout_ms: 5000");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gh-symphony/cli",
3
- "version": "0.0.11",
3
+ "version": "0.0.13",
4
4
  "license": "MIT",
5
5
  "author": "hojinzs",
6
6
  "description": "Interactive CLI for GitHub Symphony orchestration",
@@ -37,10 +37,10 @@
37
37
  "dependencies": {
38
38
  "@clack/prompts": "^0.9.1",
39
39
  "commander": "^14.0.1",
40
- "@gh-symphony/orchestrator": "0.0.11",
41
- "@gh-symphony/core": "0.0.11",
42
- "@gh-symphony/tracker-github": "0.0.11",
43
- "@gh-symphony/worker": "0.0.11"
40
+ "@gh-symphony/core": "0.0.13",
41
+ "@gh-symphony/orchestrator": "0.0.13",
42
+ "@gh-symphony/tracker-github": "0.0.13",
43
+ "@gh-symphony/worker": "0.0.13"
44
44
  },
45
45
  "scripts": {
46
46
  "build": "tsc -p tsconfig.json",
@@ -1,6 +0,0 @@
1
- import { parseArgs, type ParseArgsOptionsConfig } from "node:util";
2
- type ParseCliArgsResult = ReturnType<typeof parseArgs> | {
3
- error: string;
4
- };
5
- export declare function parseCliArgs(args: string[], options: ParseArgsOptionsConfig): ParseCliArgsResult;
6
- export {};
@@ -1,20 +0,0 @@
1
- import { parseArgs } from "node:util";
2
- export function parseCliArgs(args, options) {
3
- try {
4
- return parseArgs({
5
- args,
6
- options,
7
- allowPositionals: false,
8
- strict: true,
9
- });
10
- }
11
- catch (error) {
12
- return { error: formatParseArgsError(error) };
13
- }
14
- }
15
- function formatParseArgsError(error) {
16
- if (error instanceof Error) {
17
- return error.message;
18
- }
19
- return "Invalid arguments";
20
- }
@@ -1,3 +0,0 @@
1
- import type { GlobalOptions } from "../index.js";
2
- declare const handler: (args: string[], options: GlobalOptions) => Promise<void>;
3
- export default handler;
@@ -1,348 +0,0 @@
1
- import * as p from "@clack/prompts";
2
- import { createClient, validateToken, checkRequiredScopes, listUserProjects, getProjectDetail, GitHubScopeError, } from "../github/client.js";
3
- import { ensureGhAuth, getGhToken, GhAuthError } from "../github/gh-auth.js";
4
- import { loadGlobalConfig, saveGlobalConfig, loadTenantConfig, tenantConfigDir, } from "../config.js";
5
- import { writeConfig, generateTenantId, abortIfCancelled } from "./init.js";
6
- // ── Scope error display ───────────────────────────────────────────────────────
7
- const KNOWN_REQUIRED_SCOPES = ["repo", "read:org", "project"];
8
- function displayScopeError(error, retryCommand) {
9
- const plural = error.requiredScopes.length === 1 ? "" : "s";
10
- p.log.error(`Token is missing required scope${plural}: ${error.requiredScopes.join(", ")}`);
11
- const currentSet = new Set(error.currentScopes.map((s) => s.toLowerCase()));
12
- const scopesToAdd = KNOWN_REQUIRED_SCOPES.filter((s) => !currentSet.has(s));
13
- const scopeArg = scopesToAdd.length > 0
14
- ? scopesToAdd.join(",")
15
- : error.requiredScopes.join(",");
16
- p.note(`gh auth refresh --scopes ${scopeArg}\n\nThen re-run: ${retryCommand}`, "Fix missing scope");
17
- }
18
- function parseTenantAddFlags(args) {
19
- const flags = { nonInteractive: false, assignedOnly: false };
20
- for (let i = 0; i < args.length; i += 1) {
21
- const arg = args[i];
22
- const next = args[i + 1];
23
- switch (arg) {
24
- case "--non-interactive":
25
- flags.nonInteractive = true;
26
- break;
27
- case "--project":
28
- flags.project = next;
29
- i += 1;
30
- break;
31
- case "--workspace-dir":
32
- flags.workspaceDir = next;
33
- i += 1;
34
- break;
35
- case "--assigned-only":
36
- flags.assignedOnly = true;
37
- break;
38
- }
39
- }
40
- return flags;
41
- }
42
- // ── Tenant command handler ────────────────────────────────────────────────────
43
- const handler = async (args, options) => {
44
- const [subcommand, ...rest] = args;
45
- switch (subcommand) {
46
- case "add":
47
- await tenantAdd(rest, options);
48
- return;
49
- case "list":
50
- await tenantList(options);
51
- return;
52
- case "remove":
53
- await tenantRemove(rest, options);
54
- return;
55
- default:
56
- process.stdout.write("Usage: gh-symphony tenant <add|list|remove>\n");
57
- }
58
- };
59
- export default handler;
60
- // ── tenant add ───────────────────────────────────────────────────────────────
61
- async function tenantAdd(args, options) {
62
- const flags = parseTenantAddFlags(args);
63
- if (flags.nonInteractive) {
64
- await tenantAddNonInteractive(flags, options);
65
- return;
66
- }
67
- await tenantAddInteractive(options);
68
- }
69
- // ── Non-interactive tenant add ───────────────────────────────────────────────
70
- async function tenantAddNonInteractive(flags, options) {
71
- let token;
72
- try {
73
- token = getGhToken();
74
- }
75
- catch {
76
- process.stderr.write("Error: GitHub token not found. Run 'gh auth login --scopes repo,read:org,project' or set GITHUB_GRAPHQL_TOKEN.\n");
77
- process.exitCode = 1;
78
- return;
79
- }
80
- const client = createClient(token);
81
- // Validate token
82
- let viewer;
83
- try {
84
- viewer = await validateToken(client);
85
- }
86
- catch {
87
- process.stderr.write("Error: Invalid GitHub token.\n");
88
- process.exitCode = 1;
89
- return;
90
- }
91
- const scopeCheck = checkRequiredScopes(viewer.scopes);
92
- if (!scopeCheck.valid) {
93
- process.stderr.write(`Error: Missing required PAT scopes: ${scopeCheck.missing.join(", ")}\n`);
94
- process.exitCode = 1;
95
- return;
96
- }
97
- // Find project
98
- const projects = await listUserProjects(client);
99
- let project;
100
- if (flags.project) {
101
- const match = projects.find((p) => p.id === flags.project || p.url === flags.project);
102
- if (!match) {
103
- process.stderr.write(`Error: Project not found: ${flags.project}\n`);
104
- process.exitCode = 1;
105
- return;
106
- }
107
- project = await getProjectDetail(client, match.id);
108
- }
109
- else if (projects.length === 1) {
110
- project = await getProjectDetail(client, projects[0].id);
111
- }
112
- else {
113
- process.stderr.write("Error: --project is required when multiple projects exist.\n");
114
- process.exitCode = 1;
115
- return;
116
- }
117
- const tenantId = generateTenantId(project.title, project.id);
118
- const workspaceDir = flags.workspaceDir ?? `${options.configDir}/workspaces`;
119
- await writeConfig(options.configDir, {
120
- tenantId,
121
- project,
122
- repos: project.linkedRepositories,
123
- workspaceDir,
124
- assignedOnly: flags.assignedOnly,
125
- });
126
- if (options.json) {
127
- process.stdout.write(JSON.stringify({ tenantId, status: "created" }) + "\n");
128
- }
129
- else {
130
- process.stdout.write(`Tenant created: ${tenantId}\n`);
131
- process.stdout.write(`Run 'gh-symphony start' to begin orchestration.\n`);
132
- }
133
- }
134
- // ── Interactive tenant add ───────────────────────────────────────────────────
135
- async function tenantAddInteractive(options) {
136
- p.intro("gh-symphony — Tenant Setup");
137
- // Detect existing config
138
- const existingConfig = await loadGlobalConfig(options.configDir);
139
- if (existingConfig) {
140
- const action = await abortIfCancelled(p.select({
141
- message: "Existing configuration detected. What would you like to do?",
142
- options: [
143
- { value: "add", label: "Add a new tenant" },
144
- { value: "overwrite", label: "Start fresh (overwrite)" },
145
- ],
146
- }));
147
- if (action === "overwrite") {
148
- // Continue with fresh setup — will overwrite config
149
- }
150
- // "add" continues to create a new tenant alongside existing ones
151
- }
152
- // ── Step 1: gh CLI authentication ─────────────────────────────────────────────
153
- const s1 = p.spinner();
154
- s1.start("Checking gh CLI authentication...");
155
- let login;
156
- let client;
157
- try {
158
- const { login: ghLogin, token } = ensureGhAuth();
159
- login = ghLogin;
160
- client = createClient(token);
161
- s1.stop(`Authenticated as ${login}`);
162
- }
163
- catch (error) {
164
- s1.stop("Authentication failed.");
165
- if (error instanceof GhAuthError) {
166
- if (error.code === "not_installed") {
167
- p.log.error("gh CLI가 설치되어 있지 않습니다. https://cli.github.com 에서 설치하세요.");
168
- }
169
- else if (error.code === "not_authenticated") {
170
- p.log.error("gh auth login --scopes repo,read:org,project 를 실행하세요.");
171
- }
172
- else if (error.code === "missing_scopes") {
173
- p.log.error("gh auth refresh --scopes repo,read:org,project 를 실행하세요.");
174
- }
175
- else {
176
- p.log.error(error.message);
177
- }
178
- }
179
- else {
180
- p.log.error(error instanceof Error ? error.message : "Unknown error");
181
- }
182
- process.exitCode = 1;
183
- return;
184
- }
185
- // ── Step 2: Project selection ───────────────────────────────────────────────
186
- const s2 = p.spinner();
187
- s2.start("Loading projects...");
188
- let projects;
189
- try {
190
- projects = await listUserProjects(client);
191
- s2.stop(`Found ${projects.length} project${projects.length === 1 ? "" : "s"}`);
192
- }
193
- catch (error) {
194
- s2.stop("Failed to load projects.");
195
- if (error instanceof GitHubScopeError) {
196
- displayScopeError(error, "gh-symphony tenant add");
197
- }
198
- else {
199
- p.log.error(error instanceof Error ? error.message : "Unknown error");
200
- }
201
- process.exitCode = 1;
202
- return;
203
- }
204
- if (projects.length === 0) {
205
- p.log.error("No GitHub Projects found. Create a project at https://github.com/orgs/YOUR_ORG/projects and re-run.");
206
- process.exitCode = 1;
207
- return;
208
- }
209
- const selectedProjectId = await abortIfCancelled(p.select({
210
- message: "Step 1/4 — Select a GitHub Project:",
211
- options: projects.map((proj) => ({
212
- value: proj.id,
213
- label: `${proj.owner.login}/${proj.title}`,
214
- hint: `${proj.openItemCount} items`,
215
- })),
216
- maxItems: 15,
217
- }));
218
- const s2d = p.spinner();
219
- s2d.start("Loading project details...");
220
- let projectDetail;
221
- try {
222
- projectDetail = await getProjectDetail(client, selectedProjectId);
223
- s2d.stop(`Loaded: ${projectDetail.title}`);
224
- }
225
- catch (error) {
226
- s2d.stop("Failed to load project details.");
227
- p.log.error(error instanceof Error ? error.message : "Unknown error");
228
- process.exitCode = 1;
229
- return;
230
- }
231
- // ── Step 2: Repository selection ────────────────────────────────────────────
232
- if (projectDetail.linkedRepositories.length === 0) {
233
- p.log.warn("No linked repositories found in this project. Add issues from repositories to the project first.");
234
- process.exitCode = 1;
235
- return;
236
- }
237
- const selectedRepos = await abortIfCancelled(p.multiselect({
238
- message: "Step 2/4 — Select repositories to orchestrate:",
239
- options: projectDetail.linkedRepositories.map((repo) => ({
240
- value: repo,
241
- label: `${repo.owner}/${repo.name}`,
242
- })),
243
- required: true,
244
- }));
245
- // ── Step 3: Assignment filter ────────────────────────────────────────────────
246
- const assignedOnly = await abortIfCancelled(p.confirm({
247
- message: "Step 3/4 — Only process issues assigned to the authenticated GitHub user?",
248
- initialValue: false,
249
- }));
250
- const workspaceDir = await abortIfCancelled(p.text({
251
- message: "Step 4/4 — Workspace root directory:",
252
- placeholder: `${options.configDir}/workspaces`,
253
- defaultValue: `${options.configDir}/workspaces`,
254
- validate(value) {
255
- return value.trim().length > 0
256
- ? undefined
257
- : "Workspace directory is required.";
258
- },
259
- }));
260
- // ── Confirmation ─────────────────────────────────────────────────────────────
261
- p.note([
262
- `User: ${login}`,
263
- `Project: ${projectDetail.title}`,
264
- `Repos: ${selectedRepos.map((r) => `${r.owner}/${r.name}`).join(", ")}`,
265
- `Assigned: ${assignedOnly ? `Only issues assigned to ${login}` : "All project issues"}`,
266
- `Workspace: ${workspaceDir}`,
267
- ].join("\n"), "Configuration Summary");
268
- const confirmed = await abortIfCancelled(p.confirm({ message: "Apply this configuration?" }));
269
- if (!confirmed) {
270
- p.cancel("Setup cancelled.");
271
- process.exitCode = 130;
272
- return;
273
- }
274
- // ── Write config files ────────────────────────────────────────────────────────
275
- const tenantId = generateTenantId(projectDetail.title, projectDetail.id);
276
- const s6 = p.spinner();
277
- s6.start("Writing configuration...");
278
- try {
279
- await writeConfig(options.configDir, {
280
- tenantId,
281
- project: projectDetail,
282
- repos: selectedRepos,
283
- workspaceDir,
284
- assignedOnly,
285
- });
286
- s6.stop("Configuration saved.");
287
- }
288
- catch (error) {
289
- s6.stop("Failed to write configuration.");
290
- p.log.error(error instanceof Error ? error.message : "Unknown error");
291
- process.exitCode = 1;
292
- return;
293
- }
294
- p.outro(`Tenant "${tenantId}" created!\n Run 'gh-symphony start' to begin orchestration.`);
295
- }
296
- // ── tenant list ───────────────────────────────────────────────────────────────
297
- async function tenantList(options) {
298
- const global = await loadGlobalConfig(options.configDir);
299
- if (!global?.tenants?.length) {
300
- process.stdout.write("No tenants configured.\n");
301
- return;
302
- }
303
- process.stdout.write("Configured tenants:\n");
304
- const configs = await Promise.all(global.tenants.map((id) => loadTenantConfig(options.configDir, id)));
305
- for (let i = 0; i < global.tenants.length; i++) {
306
- const tenantId = global.tenants[i];
307
- const config = configs[i];
308
- const active = global.activeTenant === tenantId ? " (active)" : "";
309
- const slug = config?.slug ?? tenantId;
310
- process.stdout.write(` ${slug}${active}\n`);
311
- }
312
- }
313
- // ── tenant remove ─────────────────────────────────────────────────────────────
314
- async function tenantRemove(args, options) {
315
- const tenantId = args[0];
316
- if (!tenantId) {
317
- process.stderr.write("Usage: gh-symphony tenant remove <tenant-id>\n");
318
- process.exitCode = 1;
319
- return;
320
- }
321
- const global = await loadGlobalConfig(options.configDir);
322
- if (!global) {
323
- process.stderr.write("No configuration found.\n");
324
- process.exitCode = 1;
325
- return;
326
- }
327
- const updatedTenants = (global.tenants ?? []).filter((t) => t !== tenantId);
328
- if (updatedTenants.length === global.tenants.length) {
329
- process.stderr.write(`Tenant "${tenantId}" not found.\n`);
330
- process.exitCode = 1;
331
- return;
332
- }
333
- const updatedConfig = {
334
- ...global,
335
- tenants: updatedTenants,
336
- activeTenant: global.activeTenant === tenantId ? null : global.activeTenant,
337
- };
338
- await saveGlobalConfig(options.configDir, updatedConfig);
339
- const { rm } = await import("node:fs/promises");
340
- const dir = tenantConfigDir(options.configDir, tenantId);
341
- try {
342
- await rm(dir, { recursive: true, force: true });
343
- }
344
- catch {
345
- // Directory may not exist
346
- }
347
- process.stdout.write(`Tenant "${tenantId}" removed.\n`);
348
- }