@gh-symphony/cli 0.0.1 → 0.0.2

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 (59) hide show
  1. package/dist/ansi.d.ts +15 -0
  2. package/dist/ansi.js +53 -0
  3. package/dist/commands/config-cmd.js +11 -27
  4. package/dist/commands/help.js +14 -6
  5. package/dist/commands/init.d.ts +30 -7
  6. package/dist/commands/init.js +421 -284
  7. package/dist/commands/logs.js +4 -4
  8. package/dist/commands/project.js +34 -34
  9. package/dist/commands/recover.js +14 -14
  10. package/dist/commands/repo.js +13 -13
  11. package/dist/commands/run.js +16 -16
  12. package/dist/commands/start.js +61 -37
  13. package/dist/commands/status.js +60 -63
  14. package/dist/commands/tenant.d.ts +3 -0
  15. package/dist/commands/tenant.js +402 -0
  16. package/dist/config.d.ts +20 -19
  17. package/dist/config.js +17 -17
  18. package/dist/context/context-types.d.ts +36 -0
  19. package/dist/context/context-types.js +1 -0
  20. package/dist/context/generate-context-yaml.d.ts +15 -0
  21. package/dist/context/generate-context-yaml.js +129 -0
  22. package/dist/dashboard/renderer.d.ts +9 -0
  23. package/dist/dashboard/renderer.js +220 -0
  24. package/dist/detection/environment-detector.d.ts +11 -0
  25. package/dist/detection/environment-detector.js +140 -0
  26. package/dist/github/client.d.ts +11 -0
  27. package/dist/github/client.js +59 -11
  28. package/dist/github/gh-auth.d.ts +34 -0
  29. package/dist/github/gh-auth.js +110 -0
  30. package/dist/index.js +1 -0
  31. package/dist/mapping/smart-defaults.d.ts +9 -25
  32. package/dist/mapping/smart-defaults.js +52 -125
  33. package/dist/orchestrator-runtime.d.ts +4 -4
  34. package/dist/orchestrator-runtime.js +27 -12
  35. package/dist/skills/skill-writer.d.ts +14 -0
  36. package/dist/skills/skill-writer.js +62 -0
  37. package/dist/skills/templates/commit.d.ts +2 -0
  38. package/dist/skills/templates/commit.js +45 -0
  39. package/dist/skills/templates/document.d.ts +7 -0
  40. package/dist/skills/templates/document.js +16 -0
  41. package/dist/skills/templates/gh-project.d.ts +2 -0
  42. package/dist/skills/templates/gh-project.js +88 -0
  43. package/dist/skills/templates/gh-symphony.d.ts +2 -0
  44. package/dist/skills/templates/gh-symphony.js +125 -0
  45. package/dist/skills/templates/index.d.ts +8 -0
  46. package/dist/skills/templates/index.js +28 -0
  47. package/dist/skills/templates/land.d.ts +2 -0
  48. package/dist/skills/templates/land.js +59 -0
  49. package/dist/skills/templates/pull.d.ts +2 -0
  50. package/dist/skills/templates/pull.js +41 -0
  51. package/dist/skills/templates/push.d.ts +2 -0
  52. package/dist/skills/templates/push.js +36 -0
  53. package/dist/skills/types.d.ts +23 -0
  54. package/dist/skills/types.js +1 -0
  55. package/dist/workflow/generate-reference-workflow.d.ts +9 -0
  56. package/dist/workflow/generate-reference-workflow.js +261 -0
  57. package/dist/workflow/generate-workflow-md.d.ts +12 -0
  58. package/dist/workflow/generate-workflow-md.js +134 -0
  59. package/package.json +5 -4
@@ -1,10 +1,33 @@
1
1
  import * as p from "@clack/prompts";
2
+ import { parseWorkflowMarkdown } from "@gh-symphony/core";
2
3
  import { createHash } from "node:crypto";
3
- import { createClient, validateToken, checkRequiredScopes, listUserProjects, getProjectDetail, } from "../github/client.js";
4
- import { inferAllColumnRoles, toWorkflowLifecycleConfig, validateMapping, } from "../mapping/smart-defaults.js";
5
- import { loadGlobalConfig, saveGlobalConfig, saveWorkspaceConfig, saveWorkflowMapping, } from "../config.js";
4
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
5
+ import { basename, dirname, join, relative, resolve } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ import { createClient, validateToken, checkRequiredScopes, listUserProjects, getProjectDetail, GitHubScopeError, } from "../github/client.js";
8
+ import { inferAllStateRoles, toWorkflowLifecycleConfig, validateStateMapping, } from "../mapping/smart-defaults.js";
9
+ import { generateWorkflowMarkdown } from "../workflow/generate-workflow-md.js";
10
+ import { loadGlobalConfig, loadTenantConfig, saveGlobalConfig, saveTenantConfig, saveWorkflowMapping, } from "../config.js";
11
+ import { getGhToken, ensureGhAuth, GhAuthError } from "../github/gh-auth.js";
12
+ import { detectEnvironment } from "../detection/environment-detector.js";
13
+ import { buildContextYaml, writeContextYaml, } from "../context/generate-context-yaml.js";
14
+ import { generateReferenceWorkflow } from "../workflow/generate-reference-workflow.js";
15
+ import { resolveSkillsDir, writeAllSkills } from "../skills/skill-writer.js";
16
+ import { ALL_SKILL_TEMPLATES } from "../skills/templates/index.js";
17
+ // ── Scope error display ───────────────────────────────────────────────────────
18
+ const KNOWN_REQUIRED_SCOPES = ["repo", "read:org", "project"];
19
+ function displayScopeError(error, retryCommand) {
20
+ const plural = error.requiredScopes.length === 1 ? "" : "s";
21
+ p.log.error(`Token is missing required scope${plural}: ${error.requiredScopes.join(", ")}`);
22
+ const currentSet = new Set(error.currentScopes.map((s) => s.toLowerCase()));
23
+ const scopesToAdd = KNOWN_REQUIRED_SCOPES.filter((s) => !currentSet.has(s));
24
+ const scopeArg = scopesToAdd.length > 0
25
+ ? scopesToAdd.join(",")
26
+ : error.requiredScopes.join(",");
27
+ p.note(`gh auth refresh --scopes ${scopeArg}\n\nThen re-run: ${retryCommand}`, "Fix missing scope");
28
+ }
6
29
  // ── Cancellation utility ─────────────────────────────────────────────────────
7
- async function abortIfCancelled(input) {
30
+ export async function abortIfCancelled(input) {
8
31
  const result = await input;
9
32
  if (p.isCancel(result)) {
10
33
  p.cancel("Setup cancelled.");
@@ -13,7 +36,11 @@ async function abortIfCancelled(input) {
13
36
  return result;
14
37
  }
15
38
  function parseInitFlags(args) {
16
- const flags = { nonInteractive: false };
39
+ const flags = {
40
+ nonInteractive: false,
41
+ skipSkills: false,
42
+ skipContext: false,
43
+ };
17
44
  for (let i = 0; i < args.length; i += 1) {
18
45
  const arg = args[i];
19
46
  const next = args[i + 1];
@@ -21,18 +48,20 @@ function parseInitFlags(args) {
21
48
  case "--non-interactive":
22
49
  flags.nonInteractive = true;
23
50
  break;
24
- case "--token":
25
- flags.token = next;
26
- i += 1;
27
- break;
28
51
  case "--project":
29
52
  flags.project = next;
30
53
  i += 1;
31
54
  break;
32
- case "--runtime":
33
- flags.runtime = next;
55
+ case "--output":
56
+ flags.output = next;
34
57
  i += 1;
35
58
  break;
59
+ case "--skip-skills":
60
+ flags.skipSkills = true;
61
+ break;
62
+ case "--skip-context":
63
+ flags.skipContext = true;
64
+ break;
36
65
  }
37
66
  }
38
67
  return flags;
@@ -47,14 +76,168 @@ const handler = async (args, options) => {
47
76
  await runInteractive(options);
48
77
  };
49
78
  export default handler;
50
- // ── 4.8: Non-interactive mode ────────────────────────────────────────────────
79
+ function inferAgentRuntimeFromCommand(command) {
80
+ if (!command) {
81
+ return null;
82
+ }
83
+ if (command.includes("claude-code")) {
84
+ return "claude-code";
85
+ }
86
+ if (command.includes("codex")) {
87
+ return "codex";
88
+ }
89
+ return null;
90
+ }
91
+ function isWorkerBootstrapCommand(command) {
92
+ return (command.includes("@gh-symphony/worker/dist/index.js") ||
93
+ command.includes("packages/worker/dist/index.js"));
94
+ }
95
+ function isMissingAgentEnvError(error) {
96
+ return (error instanceof Error &&
97
+ error.message.includes("Workflow front matter requires environment variable"));
98
+ }
99
+ export async function resolveTenantRuntime(configDir, tenantId, tenantWorkerCommand) {
100
+ const workflowPath = join(configDir, "tenants", tenantId, "WORKFLOW.md");
101
+ try {
102
+ const workflowMarkdown = await readFile(workflowPath, "utf8");
103
+ const agentCommand = parseWorkflowMarkdown(workflowMarkdown, {}).agentCommand;
104
+ if (!isWorkerBootstrapCommand(agentCommand)) {
105
+ return agentCommand;
106
+ }
107
+ }
108
+ catch (error) {
109
+ const err = error;
110
+ if (err.code !== "ENOENT" && !isMissingAgentEnvError(error)) {
111
+ throw error;
112
+ }
113
+ }
114
+ return inferAgentRuntimeFromCommand(tenantWorkerCommand) ?? "codex";
115
+ }
116
+ export async function writeEcosystem(opts) {
117
+ const { cwd, projectDetail, statusField, runtime, skipSkills, skipContext } = opts;
118
+ const ghSymphonyDir = join(cwd, ".gh-symphony");
119
+ await mkdir(ghSymphonyDir, { recursive: true });
120
+ // 1. Detect environment
121
+ const env = await detectEnvironment(cwd);
122
+ // 2. Write context.yaml (unless --skip-context)
123
+ let contextYamlWritten = false;
124
+ if (!skipContext) {
125
+ const contextYaml = buildContextYaml({
126
+ projectDetail,
127
+ statusField,
128
+ detectedEnvironment: env,
129
+ runtime: {
130
+ agent: runtime,
131
+ agent_command: runtime === "codex"
132
+ ? "bash -lc codex app-server"
133
+ : runtime === "claude-code"
134
+ ? "bash -lc claude-code"
135
+ : runtime,
136
+ },
137
+ });
138
+ await writeContextYaml(cwd, contextYaml);
139
+ contextYamlWritten = true;
140
+ }
141
+ // 3. Write reference-workflow.md
142
+ const refWorkflow = generateReferenceWorkflow({
143
+ runtime,
144
+ statusColumns: statusField.options.map((o) => ({
145
+ name: o.name,
146
+ role: null,
147
+ })),
148
+ projectId: projectDetail.id,
149
+ });
150
+ const refPath = join(ghSymphonyDir, "reference-workflow.md");
151
+ const tmpRef = refPath + ".tmp";
152
+ await writeFile(tmpRef, refWorkflow, "utf8");
153
+ await rename(tmpRef, refPath);
154
+ // 4. Write skills (unless --skip-skills)
155
+ const skillsDir = resolveSkillsDir(cwd, runtime);
156
+ let skillsWritten = [];
157
+ let skillsSkipped = [];
158
+ if (!skipSkills && skillsDir) {
159
+ const result = await writeAllSkills(cwd, runtime, ALL_SKILL_TEMPLATES, {
160
+ runtime,
161
+ projectId: projectDetail.id,
162
+ projectTitle: projectDetail.title,
163
+ repositories: projectDetail.linkedRepositories.map((r) => ({
164
+ owner: r.owner,
165
+ name: r.name,
166
+ })),
167
+ statusColumns: statusField.options.map((o) => ({
168
+ id: o.id,
169
+ name: o.name,
170
+ role: null,
171
+ })),
172
+ statusFieldId: statusField.id,
173
+ contextYamlPath: ".gh-symphony/context.yaml",
174
+ referenceWorkflowPath: ".gh-symphony/reference-workflow.md",
175
+ });
176
+ skillsWritten = result.written.map((p) => basename(dirname(p)));
177
+ skillsSkipped = result.skipped.map((p) => basename(dirname(p)));
178
+ }
179
+ return {
180
+ projectId: projectDetail.id,
181
+ projectTitle: projectDetail.title,
182
+ runtime,
183
+ skillsDir,
184
+ contextYamlWritten,
185
+ referenceWorkflowWritten: true,
186
+ skillsWritten,
187
+ skillsSkipped,
188
+ };
189
+ }
190
+ // ── Ecosystem summary output ─────────────────────────────────────────────────
191
+ function printEcosystemSummary(result, workflowPath, opts) {
192
+ const cwd = process.cwd();
193
+ const relWorkflow = relative(cwd, workflowPath) || "WORKFLOW.md";
194
+ const lines = [];
195
+ lines.push(`Project ${result.projectTitle} (${result.projectId})`);
196
+ lines.push(`Runtime ${result.runtime}`);
197
+ lines.push("");
198
+ lines.push("Generated files");
199
+ lines.push(` ✓ WORKFLOW.md ${relWorkflow}`);
200
+ if (result.contextYamlWritten) {
201
+ lines.push(" ✓ Context metadata .gh-symphony/context.yaml");
202
+ }
203
+ if (result.referenceWorkflowWritten) {
204
+ lines.push(" ✓ Reference workflow .gh-symphony/reference-workflow.md");
205
+ }
206
+ if (result.skillsDir) {
207
+ const relSkillsDir = relative(cwd, result.skillsDir);
208
+ lines.push("");
209
+ lines.push(`Skills → ${relSkillsDir}/`);
210
+ for (const name of result.skillsWritten) {
211
+ lines.push(` ✓ ${name}`);
212
+ }
213
+ for (const name of result.skillsSkipped) {
214
+ lines.push(` – ${name} (already exists, skipped)`);
215
+ }
216
+ }
217
+ else if (result.runtime !== "codex" && result.runtime !== "claude-code") {
218
+ lines.push("");
219
+ lines.push("Skills → (skipped — custom runtime)");
220
+ }
221
+ if (opts.interactive) {
222
+ p.note(lines.join("\n"), "Setup complete");
223
+ p.outro(opts.nextSteps ?? "Ready.");
224
+ }
225
+ else {
226
+ process.stdout.write(lines.map((l) => ` ${l}`).join("\n") + "\n");
227
+ }
228
+ }
229
+ // ── Non-interactive mode: WORKFLOW.md only ───────────────────────────────────
51
230
  async function runNonInteractive(flags, options) {
52
- if (!flags.token) {
53
- process.stderr.write("Error: --token is required in non-interactive mode.\n");
231
+ let token;
232
+ try {
233
+ token = getGhToken();
234
+ }
235
+ catch {
236
+ process.stderr.write("Error: GitHub token not found. Run 'gh auth login --scopes repo,read:org,project' or set GITHUB_GRAPHQL_TOKEN.\n");
54
237
  process.exitCode = 1;
55
238
  return;
56
239
  }
57
- const client = createClient(flags.token);
240
+ const client = createClient(token);
58
241
  // Validate token
59
242
  let viewer;
60
243
  try {
@@ -75,7 +258,7 @@ async function runNonInteractive(flags, options) {
75
258
  const projects = await listUserProjects(client);
76
259
  let project;
77
260
  if (flags.project) {
78
- const match = projects.find((p) => p.id === flags.project || p.url === flags.project);
261
+ const match = projects.find((proj) => proj.id === flags.project || proj.url === flags.project);
79
262
  if (!match) {
80
263
  process.stderr.write(`Error: Project not found: ${flags.project}\n`);
81
264
  process.exitCode = 1;
@@ -100,92 +283,179 @@ async function runNonInteractive(flags, options) {
100
283
  return;
101
284
  }
102
285
  const columnNames = statusField.options.map((o) => o.name);
103
- const inferred = inferAllColumnRoles(columnNames);
104
- const roles = {};
286
+ const inferred = inferAllStateRoles(columnNames);
287
+ const mappings = {};
105
288
  for (const mapping of inferred) {
106
289
  if (mapping.role) {
107
- roles[mapping.columnName] = mapping.role;
290
+ mappings[mapping.columnName] = { role: mapping.role };
108
291
  }
109
292
  }
110
- const validation = validateMapping(roles);
293
+ const validation = validateStateMapping(mappings);
111
294
  if (!validation.valid) {
112
295
  process.stderr.write(`Error: Cannot auto-map columns. ${validation.errors.join("; ")}\nRun without --non-interactive for manual mapping.\n`);
113
296
  process.exitCode = 1;
114
297
  return;
115
298
  }
116
- const runtime = flags.runtime ?? "codex";
117
- const workspaceId = generateWorkspaceId(project.title, project.id);
118
- await writeConfig(options.configDir, {
119
- workspaceId,
120
- token: flags.token,
121
- project,
122
- repos: project.linkedRepositories,
299
+ const lifecycleConfig = toWorkflowLifecycleConfig(statusField.name, mappings);
300
+ const outputPath = resolve(flags.output ?? "WORKFLOW.md");
301
+ const workflowMd = generateWorkflowMarkdown({
302
+ projectId: project.id,
303
+ stateFieldName: statusField.name,
304
+ mappings,
305
+ lifecycle: lifecycleConfig,
306
+ runtime: "codex",
307
+ });
308
+ await writeFile(outputPath, workflowMd, "utf8");
309
+ const ecosystemResult = await writeEcosystem({
310
+ cwd: process.cwd(),
311
+ projectDetail: project,
123
312
  statusField,
124
- roles,
125
- humanReviewMode: "plan-and-pr",
126
- runtime,
313
+ runtime: "codex",
314
+ skipSkills: flags.skipSkills,
315
+ skipContext: flags.skipContext,
127
316
  });
128
317
  if (options.json) {
129
- process.stdout.write(JSON.stringify({ workspaceId, status: "created" }) + "\n");
318
+ process.stdout.write(JSON.stringify({ output: outputPath, status: "created" }) + "\n");
130
319
  }
131
320
  else {
132
- process.stdout.write(`Workspace created: ${workspaceId}\n`);
133
- process.stdout.write(`Run 'gh-symphony start' to begin orchestration.\n`);
321
+ printEcosystemSummary(ecosystemResult, outputPath, {
322
+ interactive: false,
323
+ nextSteps: "Run 'gh-symphony tenant add' to register a tenant.",
324
+ });
134
325
  }
135
326
  }
136
- // ── Interactive mode ─────────────────────────────────────────────────────────
327
+ // ── Interactive mode: WORKFLOW.md generation ─────────────────────────────────
137
328
  async function runInteractive(options) {
138
- p.intro("gh-symphony — Workspace Setup");
139
- // 4.7: Detect existing config
140
- const existingConfig = await loadGlobalConfig(options.configDir);
141
- if (existingConfig) {
142
- const action = await abortIfCancelled(p.select({
143
- message: "Existing configuration detected. What would you like to do?",
144
- options: [
145
- { value: "add", label: "Add a new workspace" },
146
- { value: "overwrite", label: "Start fresh (overwrite)" },
147
- ],
329
+ p.intro("gh-symphony — WORKFLOW.md Setup");
330
+ // Case A: tenant(s) already configured
331
+ const globalConfig = await loadGlobalConfig(options.configDir);
332
+ if (globalConfig?.tenants?.length) {
333
+ await runInteractiveFromTenant(globalConfig, options);
334
+ return;
335
+ }
336
+ // Case B: no tenants standalone WORKFLOW.md generation
337
+ await runInteractiveStandalone(options);
338
+ }
339
+ // ── Case A: Generate WORKFLOW.md from existing tenant config ─────────────────
340
+ async function runInteractiveFromTenant(globalConfig, options) {
341
+ const tenants = globalConfig.tenants;
342
+ let tenantId;
343
+ if (tenants.length === 1) {
344
+ tenantId = tenants[0];
345
+ }
346
+ else {
347
+ // Multiple tenants: ask which one to base WORKFLOW.md on
348
+ const tenantConfigs = await Promise.all(tenants.map(async (id) => {
349
+ const cfg = await loadTenantConfig(options.configDir, id);
350
+ return { id, label: cfg?.slug ?? id };
148
351
  }));
149
- if (action === "overwrite") {
150
- // Continue with fresh setup will overwrite config
151
- }
152
- // "add" continues to create a new workspace alongside existing ones
352
+ tenantId = await abortIfCancelled(p.select({
353
+ message: "Select a tenant to base WORKFLOW.md on:",
354
+ options: tenantConfigs.map((t) => ({
355
+ value: t.id,
356
+ label: t.label,
357
+ hint: globalConfig.activeTenant === t.id ? "active" : undefined,
358
+ })),
359
+ }));
360
+ }
361
+ const tenantConfig = await loadTenantConfig(options.configDir, tenantId);
362
+ if (!tenantConfig) {
363
+ p.log.error(`Tenant config not found for "${tenantId}".`);
364
+ process.exitCode = 1;
365
+ return;
153
366
  }
154
- // ── Step 1: PAT input with async validation (4.1) ─────────────────────────
367
+ const lifecycle = tenantConfig.workflowMapping?.lifecycle;
368
+ if (!lifecycle) {
369
+ p.log.error(`Tenant "${tenantId}" has no workflow lifecycle config. Run 'gh-symphony tenant add' first.`);
370
+ process.exitCode = 1;
371
+ return;
372
+ }
373
+ const mappings = {};
374
+ const workflowMapping = tenantConfig.workflowMapping;
375
+ if (workflowMapping) {
376
+ Object.assign(mappings, workflowMapping.mappings);
377
+ }
378
+ const projectId = tenantConfig.tracker.settings?.projectId;
379
+ const stateFieldName = workflowMapping?.stateFieldName ?? lifecycle.stateFieldName;
380
+ const runtime = await resolveTenantRuntime(options.configDir, tenantId, tenantConfig.runtime.workerCommand);
381
+ const workflowMd = generateWorkflowMarkdown({
382
+ projectId: projectId ?? "",
383
+ stateFieldName,
384
+ mappings,
385
+ lifecycle,
386
+ runtime,
387
+ });
388
+ const outputPath = resolve("WORKFLOW.md");
389
+ await writeFile(outputPath, workflowMd, "utf8");
390
+ const projId = tenantConfig.tracker.settings?.projectId;
391
+ let ecosystemResult = null;
155
392
  let token;
156
- let viewer;
157
- let client;
158
- while (true) {
159
- const rawToken = await abortIfCancelled(p.password({
160
- message: "Step 1/6Enter your GitHub Personal Access Token:",
161
- validate: (v) => {
162
- if (!v)
163
- return "Token is required.";
164
- if (v.length < 40)
165
- return "Token too short.";
166
- },
167
- }));
168
- client = createClient(rawToken);
169
- const s = p.spinner();
170
- s.start("Validating token...");
393
+ try {
394
+ token = getGhToken();
395
+ }
396
+ catch {
397
+ // getGhToken failedtoken stays undefined; ecosystem write proceeds best-effort
398
+ }
399
+ if (token && projId) {
171
400
  try {
172
- viewer = await validateToken(client);
173
- const scopeCheck = checkRequiredScopes(viewer.scopes);
174
- if (!scopeCheck.valid) {
175
- s.stop(`Token valid (${viewer.login}), but missing scopes: ${scopeCheck.missing.join(", ")}`);
176
- p.log.warn("Required scopes: repo, read:org, project. Please create a new token with these scopes.");
177
- continue;
401
+ const client = createClient(token);
402
+ const fullProject = await getProjectDetail(client, projId);
403
+ const sf = fullProject.statusFields.find((f) => f.name.toLowerCase() === stateFieldName.toLowerCase()) ?? fullProject.statusFields[0];
404
+ if (sf) {
405
+ ecosystemResult = await writeEcosystem({
406
+ cwd: process.cwd(),
407
+ projectDetail: fullProject,
408
+ statusField: sf,
409
+ runtime,
410
+ skipSkills: false,
411
+ skipContext: false,
412
+ });
178
413
  }
179
- s.stop(`Authenticated as ${viewer.login}${viewer.name ? ` (${viewer.name})` : ""}`);
180
- token = rawToken;
181
- break;
182
414
  }
183
- catch (error) {
184
- s.stop(`Token invalid: ${error instanceof Error ? error.message : "Unknown error"}`);
185
- p.log.warn("Please try a different token.");
415
+ catch {
416
+ // best-effort: don't fail init if GitHub API is unreachable
186
417
  }
187
418
  }
188
- // ── Step 2: Project selection (4.2) ────────────────────────────────────────
419
+ if (ecosystemResult) {
420
+ printEcosystemSummary(ecosystemResult, outputPath, { interactive: true });
421
+ }
422
+ else {
423
+ p.outro(`WORKFLOW.md generated at ${outputPath}`);
424
+ }
425
+ }
426
+ // ── Case B: Standalone WORKFLOW.md generation (no tenant) ────────────────────
427
+ async function runInteractiveStandalone(_options) {
428
+ const s1 = p.spinner();
429
+ s1.start("Checking gh CLI authentication...");
430
+ let client;
431
+ try {
432
+ const { token } = ensureGhAuth();
433
+ client = createClient(token);
434
+ s1.stop("Authenticated via gh CLI");
435
+ }
436
+ catch (error) {
437
+ s1.stop("Authentication failed.");
438
+ if (error instanceof GhAuthError) {
439
+ if (error.code === "not_installed") {
440
+ p.log.error("gh CLI가 설치되어 있지 않습니다. https://cli.github.com 에서 설치하세요.");
441
+ }
442
+ else if (error.code === "not_authenticated") {
443
+ p.log.error("gh auth login --scopes repo,read:org,project 를 실행하세요.");
444
+ }
445
+ else if (error.code === "missing_scopes") {
446
+ p.log.error("gh auth refresh --scopes repo,read:org,project 를 실행하세요.");
447
+ }
448
+ else {
449
+ p.log.error(error.message);
450
+ }
451
+ }
452
+ else {
453
+ p.log.error(error instanceof Error ? error.message : "Unknown error");
454
+ }
455
+ process.exitCode = 1;
456
+ return;
457
+ }
458
+ // Step 1/2: Project selection
189
459
  const s2 = p.spinner();
190
460
  s2.start("Loading projects...");
191
461
  let projects;
@@ -195,17 +465,22 @@ async function runInteractive(options) {
195
465
  }
196
466
  catch (error) {
197
467
  s2.stop("Failed to load projects.");
198
- p.log.error(error instanceof Error ? error.message : "Unknown error");
468
+ if (error instanceof GitHubScopeError) {
469
+ displayScopeError(error, "gh-symphony init");
470
+ }
471
+ else {
472
+ p.log.error(error instanceof Error ? error.message : "Unknown error");
473
+ }
199
474
  process.exitCode = 1;
200
475
  return;
201
476
  }
202
477
  if (projects.length === 0) {
203
- p.log.error("No GitHub Projects found. Create a project at https://github.com/orgs/YOUR_ORG/projects and re-run init.");
478
+ p.log.error("No GitHub Projects found. Create a project at https://github.com/orgs/YOUR_ORG/projects and re-run.");
204
479
  process.exitCode = 1;
205
480
  return;
206
481
  }
207
482
  const selectedProjectId = await abortIfCancelled(p.select({
208
- message: "Step 2/6 — Select a GitHub Project:",
483
+ message: "Step 1/2 — Select a GitHub Project:",
209
484
  options: projects.map((proj) => ({
210
485
  value: proj.id,
211
486
  label: `${proj.owner.login}/${proj.title}`,
@@ -226,21 +501,7 @@ async function runInteractive(options) {
226
501
  process.exitCode = 1;
227
502
  return;
228
503
  }
229
- // ── Step 3: Repository selection (4.3) ─────────────────────────────────────
230
- if (projectDetail.linkedRepositories.length === 0) {
231
- p.log.warn("No linked repositories found in this project. Add issues from repositories to the project first.");
232
- process.exitCode = 1;
233
- return;
234
- }
235
- const selectedRepos = await abortIfCancelled(p.multiselect({
236
- message: "Step 3/6 — Select repositories to orchestrate:",
237
- options: projectDetail.linkedRepositories.map((repo) => ({
238
- value: repo,
239
- label: `${repo.owner}/${repo.name}`,
240
- })),
241
- required: true,
242
- }));
243
- // ── Step 4: Status column mapping (4.4) ────────────────────────────────────
504
+ // Step 3: Status column mapping
244
505
  const statusField = projectDetail.statusFields.find((f) => f.name.toLowerCase() === "status") ??
245
506
  projectDetail.statusFields[0];
246
507
  if (!statusField) {
@@ -249,33 +510,29 @@ async function runInteractive(options) {
249
510
  return;
250
511
  }
251
512
  const columnNames = statusField.options.map((o) => o.name);
252
- const inferred = inferAllColumnRoles(columnNames);
513
+ const inferred = inferAllStateRoles(columnNames);
253
514
  p.log.info(`Found ${columnNames.length} status columns on field "${statusField.name}".`);
254
- // Show smart defaults and let user adjust
255
- const roles = {};
515
+ const mappings = {};
256
516
  for (const mapping of inferred) {
257
517
  const roleOptions = [
258
- { value: "trigger", label: "Trigger (starts work)" },
259
- { value: "working", label: "Working (implementation)" },
260
- { value: "human-review", label: "Review (human approval)" },
261
- { value: "done", label: "Done (completed)" },
262
- { value: "ignored", label: "Ignored (skip)" },
518
+ { value: "active", label: "Active (agent works on this)" },
519
+ { value: "wait", label: "Wait (human review / hold)" },
520
+ { value: "terminal", label: "Terminal (completed)" },
263
521
  ];
264
- const defaultRole = mapping.role ?? "ignored";
265
- // Put default first
522
+ const defaultRole = mapping.role ?? "wait";
266
523
  const sortedOptions = [
267
524
  roleOptions.find((o) => o.value === defaultRole),
268
525
  ...roleOptions.filter((o) => o.value !== defaultRole),
269
526
  ];
270
527
  const selectedRole = await abortIfCancelled(p.select({
271
- message: `Step 4/6 — Map column "${mapping.columnName}":${mapping.confidence === "high" ? " (auto-detected)" : ""}`,
528
+ message: `Step 2/2 — Map column "${mapping.columnName}":${mapping.confidence === "high" ? " (auto-detected)" : ""}`,
272
529
  options: sortedOptions,
273
530
  }));
274
531
  if (selectedRole !== "skip") {
275
- roles[mapping.columnName] = selectedRole;
532
+ mappings[mapping.columnName] = { role: selectedRole };
276
533
  }
277
534
  }
278
- const validation = validateMapping(roles);
535
+ const validation = validateStateMapping(mappings);
279
536
  if (!validation.valid) {
280
537
  p.log.error("Mapping validation failed:");
281
538
  for (const err of validation.errors) {
@@ -287,170 +544,53 @@ async function runInteractive(options) {
287
544
  for (const warn of validation.warnings) {
288
545
  p.log.warn(` ⚠ ${warn}`);
289
546
  }
290
- // Human review mode selection
291
- const humanReviewMode = await abortIfCancelled(p.select({
292
- message: "Human review mode:",
293
- options: [
294
- {
295
- value: "plan-and-pr",
296
- label: "Plan & PR review",
297
- hint: "Human reviews both plans and PRs",
298
- },
299
- {
300
- value: "plan-only",
301
- label: "Plan review only",
302
- hint: "Human reviews plans, PRs auto-merge",
303
- },
304
- {
305
- value: "pr-only",
306
- label: "PR review only",
307
- hint: "No plan review, human reviews PRs",
308
- },
309
- {
310
- value: "none",
311
- label: "None (full auto)",
312
- hint: "No human review at all",
313
- },
314
- ],
315
- }));
316
- // Show visual flow summary
317
- const lifecycleConfig = toWorkflowLifecycleConfig(statusField.name, roles, humanReviewMode);
318
- const flowParts = [];
319
- if (lifecycleConfig.planningStates.length)
320
- flowParts.push(`[Planning: ${lifecycleConfig.planningStates.join(", ")}]`);
321
- if (lifecycleConfig.humanReviewStates.length)
322
- flowParts.push(`[Review: ${lifecycleConfig.humanReviewStates.join(", ")}]`);
323
- if (lifecycleConfig.implementationStates.length)
324
- flowParts.push(`[Implementation: ${lifecycleConfig.implementationStates.join(", ")}]`);
325
- if (lifecycleConfig.awaitingMergeStates.length)
326
- flowParts.push(`[Awaiting Merge: ${lifecycleConfig.awaitingMergeStates.join(", ")}]`);
327
- if (lifecycleConfig.completedStates.length)
328
- flowParts.push(`[Done: ${lifecycleConfig.completedStates.join(", ")}]`);
329
- p.note(flowParts.join(" → "), "Workflow Flow");
330
- // ── Step 5: Runtime selection (4.5) ────────────────────────────────────────
331
- const runtime = await abortIfCancelled(p.select({
332
- message: "Step 5/6 — Select AI runtime:",
333
- options: [
334
- { value: "codex", label: "OpenAI Codex", hint: "recommended" },
335
- { value: "claude-code", label: "Claude Code" },
336
- { value: "custom", label: "Custom command" },
337
- ],
338
- }));
339
- let workerCommand;
340
- if (runtime === "custom") {
341
- workerCommand = await abortIfCancelled(p.text({
342
- message: "Custom worker command:",
343
- placeholder: "node packages/worker/dist/index.js",
344
- }));
345
- }
346
- // ── Step 6: Options (4.5) ──────────────────────────────────────────────────
347
- const advancedOptions = await abortIfCancelled(p.confirm({
348
- message: "Step 6/6 — Configure advanced options? (poll interval, concurrency)",
349
- initialValue: false,
350
- }));
351
- let pollIntervalMs = 30_000;
352
- let concurrency = 3;
353
- let maxAttempts = 3;
354
- if (advancedOptions) {
355
- const pollStr = await abortIfCancelled(p.text({
356
- message: "Poll interval (seconds):",
357
- placeholder: "30",
358
- initialValue: "30",
359
- validate: (v) => {
360
- const n = Number(v);
361
- if (!v || isNaN(n) || n < 5)
362
- return "Must be at least 5 seconds.";
363
- },
364
- }));
365
- pollIntervalMs = Number(pollStr) * 1000;
366
- const concurrencyStr = await abortIfCancelled(p.text({
367
- message: "Max concurrent workers:",
368
- placeholder: "3",
369
- initialValue: "3",
370
- validate: (v) => {
371
- const n = Number(v);
372
- if (!v || isNaN(n) || n < 1)
373
- return "Must be at least 1.";
374
- },
375
- }));
376
- concurrency = Number(concurrencyStr);
377
- const attemptsStr = await abortIfCancelled(p.text({
378
- message: "Max retry attempts per issue:",
379
- placeholder: "3",
380
- initialValue: "3",
381
- validate: (v) => {
382
- const n = Number(v);
383
- if (!v || isNaN(n) || n < 1)
384
- return "Must be at least 1.";
385
- },
386
- }));
387
- maxAttempts = Number(attemptsStr);
388
- }
389
- // ── Confirmation ───────────────────────────────────────────────────────────
390
- p.note([
391
- `User: ${viewer.login}`,
392
- `Project: ${projectDetail.title}`,
393
- `Repos: ${selectedRepos.map((r) => `${r.owner}/${r.name}`).join(", ")}`,
394
- `Runtime: ${runtime}`,
395
- `Review: ${humanReviewMode}`,
396
- `Poll: ${pollIntervalMs / 1000}s`,
397
- `Concurrency: ${concurrency}`,
398
- `Max retries: ${maxAttempts}`,
399
- ].join("\n"), "Configuration Summary");
400
- const confirmed = await abortIfCancelled(p.confirm({ message: "Apply this configuration?" }));
401
- if (!confirmed) {
402
- p.cancel("Setup cancelled.");
403
- process.exitCode = 130;
404
- return;
405
- }
406
- // ── Write config files (4.6) ───────────────────────────────────────────────
407
- const workspaceId = generateWorkspaceId(projectDetail.title, projectDetail.id);
408
- const s6 = p.spinner();
409
- s6.start("Writing configuration...");
547
+ const lifecycleConfig = toWorkflowLifecycleConfig(statusField.name, mappings);
548
+ // Generate WORKFLOW.md only — no config files written
549
+ const workflowMd = generateWorkflowMarkdown({
550
+ projectId: projectDetail.id,
551
+ stateFieldName: statusField.name,
552
+ mappings,
553
+ lifecycle: lifecycleConfig,
554
+ runtime: "codex",
555
+ });
556
+ const outputPath = resolve("WORKFLOW.md");
557
+ await writeFile(outputPath, workflowMd, "utf8");
558
+ const ecosystemResult = await writeEcosystem({
559
+ cwd: process.cwd(),
560
+ projectDetail,
561
+ statusField,
562
+ runtime: "codex",
563
+ skipSkills: false,
564
+ skipContext: false,
565
+ });
566
+ printEcosystemSummary(ecosystemResult, outputPath, {
567
+ interactive: true,
568
+ nextSteps: "Run 'gh-symphony tenant add' to register a tenant.",
569
+ });
570
+ }
571
+ function resolveWorkerCommand() {
410
572
  try {
411
- await writeConfig(options.configDir, {
412
- workspaceId,
413
- token,
414
- project: projectDetail,
415
- repos: selectedRepos,
416
- statusField: {
417
- name: statusField.name,
418
- options: statusField.options,
419
- },
420
- roles,
421
- humanReviewMode,
422
- runtime,
423
- workerCommand,
424
- pollIntervalMs,
425
- concurrency,
426
- maxAttempts,
427
- });
428
- s6.stop("Configuration saved.");
573
+ const url = import.meta.resolve("@gh-symphony/worker/dist/index.js");
574
+ return `node ${fileURLToPath(url)}`;
429
575
  }
430
- catch (error) {
431
- s6.stop("Failed to write configuration.");
432
- p.log.error(error instanceof Error ? error.message : "Unknown error");
433
- process.exitCode = 1;
434
- return;
576
+ catch {
577
+ return undefined;
435
578
  }
436
- p.outro(`Workspace "${workspaceId}" created!\n Run 'gh-symphony start' to begin orchestration.`);
437
579
  }
438
580
  export async function writeConfig(configDir, input) {
439
- const lifecycleConfig = toWorkflowLifecycleConfig(input.statusField.name, input.roles, input.humanReviewMode);
581
+ const lifecycleConfig = toWorkflowLifecycleConfig(input.statusField.name, input.mappings);
440
582
  // Save workflow mapping
441
583
  const mappingConfig = {
442
584
  stateFieldName: input.statusField.name,
443
- columnRoles: input.roles,
444
- humanReviewMode: input.humanReviewMode,
585
+ mappings: input.mappings,
445
586
  lifecycle: lifecycleConfig,
446
587
  };
447
- await saveWorkflowMapping(configDir, input.workspaceId, mappingConfig);
448
- // Save workspace config (OrchestratorWorkspaceConfig shape)
449
- const runtimeDir = `${configDir}/workspaces/${input.workspaceId}/runtime`;
450
- await saveWorkspaceConfig(configDir, input.workspaceId, {
451
- workspaceId: input.workspaceId,
452
- slug: input.workspaceId,
453
- promptGuidelines: "",
588
+ await saveWorkflowMapping(configDir, input.tenantId, mappingConfig);
589
+ // Save tenant config (OrchestratorTenantConfig shape)
590
+ const runtimeDir = `${configDir}/tenants/${input.tenantId}/runtime`;
591
+ await saveTenantConfig(configDir, input.tenantId, {
592
+ tenantId: input.tenantId,
593
+ slug: input.tenantId,
454
594
  repositories: input.repos.map((r) => ({
455
595
  owner: r.owner,
456
596
  name: r.name,
@@ -461,48 +601,45 @@ export async function writeConfig(configDir, input) {
461
601
  bindingId: input.project.id,
462
602
  settings: {
463
603
  projectId: input.project.id,
464
- token: input.token,
465
604
  },
466
605
  },
467
606
  runtime: {
468
607
  driver: "local",
469
608
  workspaceRuntimeDir: runtimeDir,
470
609
  projectRoot: process.cwd(),
471
- workerCommand: input.workerCommand,
472
- },
473
- workflow: buildWorkflowOverrides(lifecycleConfig, input),
474
- orchestrator: {
475
- concurrency: input.concurrency,
476
- maxAttempts: input.maxAttempts,
610
+ workerCommand: input.workerCommand ?? resolveWorkerCommand(),
477
611
  },
478
612
  workflowMapping: mappingConfig,
479
613
  });
480
614
  // Save/update global config
481
615
  const existing = await loadGlobalConfig(configDir);
482
616
  const globalConfig = {
483
- activeWorkspace: input.workspaceId,
484
- token: input.token,
485
- workspaces: [
486
- ...(existing?.workspaces ?? []).filter((w) => w !== input.workspaceId),
487
- input.workspaceId,
617
+ activeTenant: input.tenantId,
618
+ tenants: [
619
+ ...(existing?.tenants ?? []).filter((t) => t !== input.tenantId),
620
+ input.tenantId,
488
621
  ],
489
622
  };
490
623
  await saveGlobalConfig(configDir, globalConfig);
624
+ // Generate WORKFLOW.md for tenant-level fallback
625
+ const workflowMd = generateWorkflowMarkdown({
626
+ projectId: input.project.id,
627
+ stateFieldName: input.statusField.name,
628
+ mappings: input.mappings,
629
+ lifecycle: lifecycleConfig,
630
+ runtime: input.agentCommand ?? input.runtime,
631
+ pollIntervalMs: input.pollIntervalMs,
632
+ concurrency: input.concurrency,
633
+ });
634
+ const workflowMdPath = join(configDir, "tenants", input.tenantId, "WORKFLOW.md");
635
+ await writeFile(workflowMdPath, workflowMd, "utf8");
491
636
  }
492
- function buildWorkflowOverrides(lifecycle, input) {
493
- return {
494
- lifecycle,
495
- scheduler: {
496
- pollIntervalMs: input.pollIntervalMs ?? 30_000,
497
- },
498
- };
499
- }
500
- export function generateWorkspaceId(projectTitle, uniqueKey) {
637
+ export function generateTenantId(projectTitle, uniqueKey) {
501
638
  const slug = projectTitle
502
639
  .toLowerCase()
503
640
  .replace(/[^a-z0-9]+/g, "-")
504
641
  .replace(/^-|-$/g, "")
505
642
  .slice(0, 32);
506
643
  const suffix = createHash("sha1").update(uniqueKey).digest("hex").slice(0, 8);
507
- return [slug || "workspace", suffix].join("-");
644
+ return [slug || "tenant", suffix].join("-");
508
645
  }