@gh-symphony/cli 0.0.1 → 0.0.3

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 +29 -7
  6. package/dist/commands/init.js +292 -287
  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,32 @@
1
1
  import * as p from "@clack/prompts";
2
2
  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";
3
+ import { mkdir, rename, writeFile } from "node:fs/promises";
4
+ import { basename, dirname, join, relative, resolve } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { createClient, validateToken, checkRequiredScopes, listUserProjects, getProjectDetail, GitHubScopeError, } from "../github/client.js";
7
+ import { inferAllStateRoles, toWorkflowLifecycleConfig, validateStateMapping, } from "../mapping/smart-defaults.js";
8
+ import { generateWorkflowMarkdown } from "../workflow/generate-workflow-md.js";
9
+ import { loadGlobalConfig, saveGlobalConfig, saveTenantConfig, saveWorkflowMapping, } from "../config.js";
10
+ import { getGhToken, ensureGhAuth, GhAuthError } from "../github/gh-auth.js";
11
+ import { detectEnvironment } from "../detection/environment-detector.js";
12
+ import { buildContextYaml, writeContextYaml, } from "../context/generate-context-yaml.js";
13
+ import { generateReferenceWorkflow } from "../workflow/generate-reference-workflow.js";
14
+ import { resolveSkillsDir, writeAllSkills } from "../skills/skill-writer.js";
15
+ import { ALL_SKILL_TEMPLATES } from "../skills/templates/index.js";
16
+ // ── Scope error display ───────────────────────────────────────────────────────
17
+ const KNOWN_REQUIRED_SCOPES = ["repo", "read:org", "project"];
18
+ function displayScopeError(error, retryCommand) {
19
+ const plural = error.requiredScopes.length === 1 ? "" : "s";
20
+ p.log.error(`Token is missing required scope${plural}: ${error.requiredScopes.join(", ")}`);
21
+ const currentSet = new Set(error.currentScopes.map((s) => s.toLowerCase()));
22
+ const scopesToAdd = KNOWN_REQUIRED_SCOPES.filter((s) => !currentSet.has(s));
23
+ const scopeArg = scopesToAdd.length > 0
24
+ ? scopesToAdd.join(",")
25
+ : error.requiredScopes.join(",");
26
+ p.note(`gh auth refresh --scopes ${scopeArg}\n\nThen re-run: ${retryCommand}`, "Fix missing scope");
27
+ }
6
28
  // ── Cancellation utility ─────────────────────────────────────────────────────
7
- async function abortIfCancelled(input) {
29
+ export async function abortIfCancelled(input) {
8
30
  const result = await input;
9
31
  if (p.isCancel(result)) {
10
32
  p.cancel("Setup cancelled.");
@@ -13,7 +35,11 @@ async function abortIfCancelled(input) {
13
35
  return result;
14
36
  }
15
37
  function parseInitFlags(args) {
16
- const flags = { nonInteractive: false };
38
+ const flags = {
39
+ nonInteractive: false,
40
+ skipSkills: false,
41
+ skipContext: false,
42
+ };
17
43
  for (let i = 0; i < args.length; i += 1) {
18
44
  const arg = args[i];
19
45
  const next = args[i + 1];
@@ -21,18 +47,20 @@ function parseInitFlags(args) {
21
47
  case "--non-interactive":
22
48
  flags.nonInteractive = true;
23
49
  break;
24
- case "--token":
25
- flags.token = next;
26
- i += 1;
27
- break;
28
50
  case "--project":
29
51
  flags.project = next;
30
52
  i += 1;
31
53
  break;
32
- case "--runtime":
33
- flags.runtime = next;
54
+ case "--output":
55
+ flags.output = next;
34
56
  i += 1;
35
57
  break;
58
+ case "--skip-skills":
59
+ flags.skipSkills = true;
60
+ break;
61
+ case "--skip-context":
62
+ flags.skipContext = true;
63
+ break;
36
64
  }
37
65
  }
38
66
  return flags;
@@ -47,14 +75,131 @@ const handler = async (args, options) => {
47
75
  await runInteractive(options);
48
76
  };
49
77
  export default handler;
50
- // ── 4.8: Non-interactive mode ────────────────────────────────────────────────
78
+ export async function writeEcosystem(opts) {
79
+ const { cwd, projectDetail, statusField, runtime, skipSkills, skipContext } = opts;
80
+ const ghSymphonyDir = join(cwd, ".gh-symphony");
81
+ await mkdir(ghSymphonyDir, { recursive: true });
82
+ // 1. Detect environment
83
+ const env = await detectEnvironment(cwd);
84
+ // 2. Write context.yaml (unless --skip-context)
85
+ let contextYamlWritten = false;
86
+ if (!skipContext) {
87
+ const contextYaml = buildContextYaml({
88
+ projectDetail,
89
+ statusField,
90
+ detectedEnvironment: env,
91
+ runtime: {
92
+ agent: runtime,
93
+ agent_command: runtime === "codex"
94
+ ? "bash -lc codex app-server"
95
+ : runtime === "claude-code"
96
+ ? "bash -lc claude-code"
97
+ : runtime,
98
+ },
99
+ });
100
+ await writeContextYaml(cwd, contextYaml);
101
+ contextYamlWritten = true;
102
+ }
103
+ // 3. Write reference-workflow.md
104
+ const refWorkflow = generateReferenceWorkflow({
105
+ runtime,
106
+ statusColumns: statusField.options.map((o) => ({
107
+ name: o.name,
108
+ role: null,
109
+ })),
110
+ projectId: projectDetail.id,
111
+ });
112
+ const refPath = join(ghSymphonyDir, "reference-workflow.md");
113
+ const tmpRef = refPath + ".tmp";
114
+ await writeFile(tmpRef, refWorkflow, "utf8");
115
+ await rename(tmpRef, refPath);
116
+ // 4. Write skills (unless --skip-skills)
117
+ const skillsDir = resolveSkillsDir(cwd, runtime);
118
+ let skillsWritten = [];
119
+ let skillsSkipped = [];
120
+ if (!skipSkills && skillsDir) {
121
+ const result = await writeAllSkills(cwd, runtime, ALL_SKILL_TEMPLATES, {
122
+ runtime,
123
+ projectId: projectDetail.id,
124
+ projectTitle: projectDetail.title,
125
+ repositories: projectDetail.linkedRepositories.map((r) => ({
126
+ owner: r.owner,
127
+ name: r.name,
128
+ })),
129
+ statusColumns: statusField.options.map((o) => ({
130
+ id: o.id,
131
+ name: o.name,
132
+ role: null,
133
+ })),
134
+ statusFieldId: statusField.id,
135
+ contextYamlPath: ".gh-symphony/context.yaml",
136
+ referenceWorkflowPath: ".gh-symphony/reference-workflow.md",
137
+ });
138
+ skillsWritten = result.written.map((p) => basename(dirname(p)));
139
+ skillsSkipped = result.skipped.map((p) => basename(dirname(p)));
140
+ }
141
+ return {
142
+ projectId: projectDetail.id,
143
+ projectTitle: projectDetail.title,
144
+ runtime,
145
+ skillsDir,
146
+ contextYamlWritten,
147
+ referenceWorkflowWritten: true,
148
+ skillsWritten,
149
+ skillsSkipped,
150
+ };
151
+ }
152
+ // ── Ecosystem summary output ─────────────────────────────────────────────────
153
+ function printEcosystemSummary(result, workflowPath, opts) {
154
+ const cwd = process.cwd();
155
+ const relWorkflow = relative(cwd, workflowPath) || "WORKFLOW.md";
156
+ const lines = [];
157
+ lines.push(`Project ${result.projectTitle} (${result.projectId})`);
158
+ lines.push(`Runtime ${result.runtime}`);
159
+ lines.push("");
160
+ lines.push("Generated files");
161
+ lines.push(` ✓ WORKFLOW.md ${relWorkflow}`);
162
+ if (result.contextYamlWritten) {
163
+ lines.push(" ✓ Context metadata .gh-symphony/context.yaml");
164
+ }
165
+ if (result.referenceWorkflowWritten) {
166
+ lines.push(" ✓ Reference workflow .gh-symphony/reference-workflow.md");
167
+ }
168
+ if (result.skillsDir) {
169
+ const relSkillsDir = relative(cwd, result.skillsDir);
170
+ lines.push("");
171
+ lines.push(`Skills → ${relSkillsDir}/`);
172
+ for (const name of result.skillsWritten) {
173
+ lines.push(` ✓ ${name}`);
174
+ }
175
+ for (const name of result.skillsSkipped) {
176
+ lines.push(` – ${name} (already exists, skipped)`);
177
+ }
178
+ }
179
+ else if (result.runtime !== "codex" && result.runtime !== "claude-code") {
180
+ lines.push("");
181
+ lines.push("Skills → (skipped — custom runtime)");
182
+ }
183
+ if (opts.interactive) {
184
+ p.note(lines.join("\n"), "Setup complete");
185
+ p.outro(opts.nextSteps ?? "Ready.");
186
+ }
187
+ else {
188
+ process.stdout.write(lines.map((l) => ` ${l}`).join("\n") + "\n");
189
+ }
190
+ }
191
+ // ── Non-interactive mode: WORKFLOW.md only ───────────────────────────────────
51
192
  async function runNonInteractive(flags, options) {
52
- if (!flags.token) {
53
- process.stderr.write("Error: --token is required in non-interactive mode.\n");
193
+ let token;
194
+ try {
195
+ token = getGhToken();
196
+ }
197
+ catch {
198
+ process.stderr.write("Error: GitHub token not found. Run 'gh auth login --scopes repo,read:org,project' or set GITHUB_GRAPHQL_TOKEN.\n");
54
199
  process.exitCode = 1;
55
200
  return;
56
201
  }
57
- const client = createClient(flags.token);
202
+ const client = createClient(token);
58
203
  // Validate token
59
204
  let viewer;
60
205
  try {
@@ -75,7 +220,7 @@ async function runNonInteractive(flags, options) {
75
220
  const projects = await listUserProjects(client);
76
221
  let project;
77
222
  if (flags.project) {
78
- const match = projects.find((p) => p.id === flags.project || p.url === flags.project);
223
+ const match = projects.find((proj) => proj.id === flags.project || proj.url === flags.project);
79
224
  if (!match) {
80
225
  process.stderr.write(`Error: Project not found: ${flags.project}\n`);
81
226
  process.exitCode = 1;
@@ -100,92 +245,85 @@ async function runNonInteractive(flags, options) {
100
245
  return;
101
246
  }
102
247
  const columnNames = statusField.options.map((o) => o.name);
103
- const inferred = inferAllColumnRoles(columnNames);
104
- const roles = {};
248
+ const inferred = inferAllStateRoles(columnNames);
249
+ const mappings = {};
105
250
  for (const mapping of inferred) {
106
251
  if (mapping.role) {
107
- roles[mapping.columnName] = mapping.role;
252
+ mappings[mapping.columnName] = { role: mapping.role };
108
253
  }
109
254
  }
110
- const validation = validateMapping(roles);
255
+ const validation = validateStateMapping(mappings);
111
256
  if (!validation.valid) {
112
257
  process.stderr.write(`Error: Cannot auto-map columns. ${validation.errors.join("; ")}\nRun without --non-interactive for manual mapping.\n`);
113
258
  process.exitCode = 1;
114
259
  return;
115
260
  }
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,
261
+ const lifecycleConfig = toWorkflowLifecycleConfig(statusField.name, mappings);
262
+ const outputPath = resolve(flags.output ?? "WORKFLOW.md");
263
+ const workflowMd = generateWorkflowMarkdown({
264
+ projectId: project.id,
265
+ stateFieldName: statusField.name,
266
+ mappings,
267
+ lifecycle: lifecycleConfig,
268
+ runtime: "codex",
269
+ });
270
+ await writeFile(outputPath, workflowMd, "utf8");
271
+ const ecosystemResult = await writeEcosystem({
272
+ cwd: process.cwd(),
273
+ projectDetail: project,
123
274
  statusField,
124
- roles,
125
- humanReviewMode: "plan-and-pr",
126
- runtime,
275
+ runtime: "codex",
276
+ skipSkills: flags.skipSkills,
277
+ skipContext: flags.skipContext,
127
278
  });
128
279
  if (options.json) {
129
- process.stdout.write(JSON.stringify({ workspaceId, status: "created" }) + "\n");
280
+ process.stdout.write(JSON.stringify({ output: outputPath, status: "created" }) + "\n");
130
281
  }
131
282
  else {
132
- process.stdout.write(`Workspace created: ${workspaceId}\n`);
133
- process.stdout.write(`Run 'gh-symphony start' to begin orchestration.\n`);
283
+ printEcosystemSummary(ecosystemResult, outputPath, {
284
+ interactive: false,
285
+ nextSteps: "Run 'gh-symphony tenant add' to register a tenant.",
286
+ });
134
287
  }
135
288
  }
136
- // ── Interactive mode ─────────────────────────────────────────────────────────
289
+ // ── Interactive mode: WORKFLOW.md generation ─────────────────────────────────
137
290
  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
- ],
148
- }));
149
- if (action === "overwrite") {
150
- // Continue with fresh setup — will overwrite config
151
- }
152
- // "add" continues to create a new workspace alongside existing ones
153
- }
154
- // ── Step 1: PAT input with async validation (4.1) ─────────────────────────
155
- let token;
156
- let viewer;
291
+ p.intro("gh-symphony — WORKFLOW.md Setup");
292
+ await runInteractiveStandalone(options);
293
+ }
294
+ // ── Interactive WORKFLOW.md generation ────────────────────────────────────────
295
+ async function runInteractiveStandalone(_options) {
296
+ const s1 = p.spinner();
297
+ s1.start("Checking gh CLI authentication...");
157
298
  let client;
158
- while (true) {
159
- const rawToken = await abortIfCancelled(p.password({
160
- message: "Step 1/6 — Enter 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...");
171
- 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;
299
+ try {
300
+ const { token } = ensureGhAuth();
301
+ client = createClient(token);
302
+ s1.stop("Authenticated via gh CLI");
303
+ }
304
+ catch (error) {
305
+ s1.stop("Authentication failed.");
306
+ if (error instanceof GhAuthError) {
307
+ if (error.code === "not_installed") {
308
+ p.log.error("gh CLI가 설치되어 있지 않습니다. https://cli.github.com 에서 설치하세요.");
309
+ }
310
+ else if (error.code === "not_authenticated") {
311
+ p.log.error("gh auth login --scopes repo,read:org,project 를 실행하세요.");
312
+ }
313
+ else if (error.code === "missing_scopes") {
314
+ p.log.error("gh auth refresh --scopes repo,read:org,project 를 실행하세요.");
315
+ }
316
+ else {
317
+ p.log.error(error.message);
178
318
  }
179
- s.stop(`Authenticated as ${viewer.login}${viewer.name ? ` (${viewer.name})` : ""}`);
180
- token = rawToken;
181
- break;
182
319
  }
183
- catch (error) {
184
- s.stop(`Token invalid: ${error instanceof Error ? error.message : "Unknown error"}`);
185
- p.log.warn("Please try a different token.");
320
+ else {
321
+ p.log.error(error instanceof Error ? error.message : "Unknown error");
186
322
  }
323
+ process.exitCode = 1;
324
+ return;
187
325
  }
188
- // ── Step 2: Project selection (4.2) ────────────────────────────────────────
326
+ // Step 1/2: Project selection
189
327
  const s2 = p.spinner();
190
328
  s2.start("Loading projects...");
191
329
  let projects;
@@ -195,17 +333,22 @@ async function runInteractive(options) {
195
333
  }
196
334
  catch (error) {
197
335
  s2.stop("Failed to load projects.");
198
- p.log.error(error instanceof Error ? error.message : "Unknown error");
336
+ if (error instanceof GitHubScopeError) {
337
+ displayScopeError(error, "gh-symphony init");
338
+ }
339
+ else {
340
+ p.log.error(error instanceof Error ? error.message : "Unknown error");
341
+ }
199
342
  process.exitCode = 1;
200
343
  return;
201
344
  }
202
345
  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.");
346
+ p.log.error("No GitHub Projects found. Create a project at https://github.com/orgs/YOUR_ORG/projects and re-run.");
204
347
  process.exitCode = 1;
205
348
  return;
206
349
  }
207
350
  const selectedProjectId = await abortIfCancelled(p.select({
208
- message: "Step 2/6 — Select a GitHub Project:",
351
+ message: "Step 1/2 — Select a GitHub Project:",
209
352
  options: projects.map((proj) => ({
210
353
  value: proj.id,
211
354
  label: `${proj.owner.login}/${proj.title}`,
@@ -226,21 +369,7 @@ async function runInteractive(options) {
226
369
  process.exitCode = 1;
227
370
  return;
228
371
  }
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) ────────────────────────────────────
372
+ // Step 3: Status column mapping
244
373
  const statusField = projectDetail.statusFields.find((f) => f.name.toLowerCase() === "status") ??
245
374
  projectDetail.statusFields[0];
246
375
  if (!statusField) {
@@ -249,33 +378,29 @@ async function runInteractive(options) {
249
378
  return;
250
379
  }
251
380
  const columnNames = statusField.options.map((o) => o.name);
252
- const inferred = inferAllColumnRoles(columnNames);
381
+ const inferred = inferAllStateRoles(columnNames);
253
382
  p.log.info(`Found ${columnNames.length} status columns on field "${statusField.name}".`);
254
- // Show smart defaults and let user adjust
255
- const roles = {};
383
+ const mappings = {};
256
384
  for (const mapping of inferred) {
257
385
  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)" },
386
+ { value: "active", label: "Active (agent works on this)" },
387
+ { value: "wait", label: "Wait (human review / hold)" },
388
+ { value: "terminal", label: "Terminal (completed)" },
263
389
  ];
264
- const defaultRole = mapping.role ?? "ignored";
265
- // Put default first
390
+ const defaultRole = mapping.role ?? "wait";
266
391
  const sortedOptions = [
267
392
  roleOptions.find((o) => o.value === defaultRole),
268
393
  ...roleOptions.filter((o) => o.value !== defaultRole),
269
394
  ];
270
395
  const selectedRole = await abortIfCancelled(p.select({
271
- message: `Step 4/6 — Map column "${mapping.columnName}":${mapping.confidence === "high" ? " (auto-detected)" : ""}`,
396
+ message: `Step 2/2 — Map column "${mapping.columnName}":${mapping.confidence === "high" ? " (auto-detected)" : ""}`,
272
397
  options: sortedOptions,
273
398
  }));
274
399
  if (selectedRole !== "skip") {
275
- roles[mapping.columnName] = selectedRole;
400
+ mappings[mapping.columnName] = { role: selectedRole };
276
401
  }
277
402
  }
278
- const validation = validateMapping(roles);
403
+ const validation = validateStateMapping(mappings);
279
404
  if (!validation.valid) {
280
405
  p.log.error("Mapping validation failed:");
281
406
  for (const err of validation.errors) {
@@ -287,170 +412,53 @@ async function runInteractive(options) {
287
412
  for (const warn of validation.warnings) {
288
413
  p.log.warn(` ⚠ ${warn}`);
289
414
  }
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...");
415
+ const lifecycleConfig = toWorkflowLifecycleConfig(statusField.name, mappings);
416
+ // Generate WORKFLOW.md only — no config files written
417
+ const workflowMd = generateWorkflowMarkdown({
418
+ projectId: projectDetail.id,
419
+ stateFieldName: statusField.name,
420
+ mappings,
421
+ lifecycle: lifecycleConfig,
422
+ runtime: "codex",
423
+ });
424
+ const outputPath = resolve("WORKFLOW.md");
425
+ await writeFile(outputPath, workflowMd, "utf8");
426
+ const ecosystemResult = await writeEcosystem({
427
+ cwd: process.cwd(),
428
+ projectDetail,
429
+ statusField,
430
+ runtime: "codex",
431
+ skipSkills: false,
432
+ skipContext: false,
433
+ });
434
+ printEcosystemSummary(ecosystemResult, outputPath, {
435
+ interactive: true,
436
+ nextSteps: "Run 'gh-symphony tenant add' to register a tenant.",
437
+ });
438
+ }
439
+ function resolveWorkerCommand() {
410
440
  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.");
441
+ const url = import.meta.resolve("@gh-symphony/worker/dist/index.js");
442
+ return `node ${fileURLToPath(url)}`;
429
443
  }
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;
444
+ catch {
445
+ return undefined;
435
446
  }
436
- p.outro(`Workspace "${workspaceId}" created!\n Run 'gh-symphony start' to begin orchestration.`);
437
447
  }
438
448
  export async function writeConfig(configDir, input) {
439
- const lifecycleConfig = toWorkflowLifecycleConfig(input.statusField.name, input.roles, input.humanReviewMode);
449
+ const lifecycleConfig = toWorkflowLifecycleConfig(input.statusField.name, input.mappings);
440
450
  // Save workflow mapping
441
451
  const mappingConfig = {
442
452
  stateFieldName: input.statusField.name,
443
- columnRoles: input.roles,
444
- humanReviewMode: input.humanReviewMode,
453
+ mappings: input.mappings,
445
454
  lifecycle: lifecycleConfig,
446
455
  };
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: "",
456
+ await saveWorkflowMapping(configDir, input.tenantId, mappingConfig);
457
+ // Save tenant config (OrchestratorTenantConfig shape)
458
+ const runtimeDir = `${configDir}/tenants/${input.tenantId}/runtime`;
459
+ await saveTenantConfig(configDir, input.tenantId, {
460
+ tenantId: input.tenantId,
461
+ slug: input.tenantId,
454
462
  repositories: input.repos.map((r) => ({
455
463
  owner: r.owner,
456
464
  name: r.name,
@@ -461,48 +469,45 @@ export async function writeConfig(configDir, input) {
461
469
  bindingId: input.project.id,
462
470
  settings: {
463
471
  projectId: input.project.id,
464
- token: input.token,
465
472
  },
466
473
  },
467
474
  runtime: {
468
475
  driver: "local",
469
476
  workspaceRuntimeDir: runtimeDir,
470
477
  projectRoot: process.cwd(),
471
- workerCommand: input.workerCommand,
472
- },
473
- workflow: buildWorkflowOverrides(lifecycleConfig, input),
474
- orchestrator: {
475
- concurrency: input.concurrency,
476
- maxAttempts: input.maxAttempts,
478
+ workerCommand: input.workerCommand ?? resolveWorkerCommand(),
477
479
  },
478
480
  workflowMapping: mappingConfig,
479
481
  });
480
482
  // Save/update global config
481
483
  const existing = await loadGlobalConfig(configDir);
482
484
  const globalConfig = {
483
- activeWorkspace: input.workspaceId,
484
- token: input.token,
485
- workspaces: [
486
- ...(existing?.workspaces ?? []).filter((w) => w !== input.workspaceId),
487
- input.workspaceId,
485
+ activeTenant: input.tenantId,
486
+ tenants: [
487
+ ...(existing?.tenants ?? []).filter((t) => t !== input.tenantId),
488
+ input.tenantId,
488
489
  ],
489
490
  };
490
491
  await saveGlobalConfig(configDir, globalConfig);
492
+ // Generate WORKFLOW.md for tenant-level fallback
493
+ const workflowMd = generateWorkflowMarkdown({
494
+ projectId: input.project.id,
495
+ stateFieldName: input.statusField.name,
496
+ mappings: input.mappings,
497
+ lifecycle: lifecycleConfig,
498
+ runtime: input.agentCommand ?? input.runtime,
499
+ pollIntervalMs: input.pollIntervalMs,
500
+ concurrency: input.concurrency,
501
+ });
502
+ const workflowMdPath = join(configDir, "tenants", input.tenantId, "WORKFLOW.md");
503
+ await writeFile(workflowMdPath, workflowMd, "utf8");
491
504
  }
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) {
505
+ export function generateTenantId(projectTitle, uniqueKey) {
501
506
  const slug = projectTitle
502
507
  .toLowerCase()
503
508
  .replace(/[^a-z0-9]+/g, "-")
504
509
  .replace(/^-|-$/g, "")
505
510
  .slice(0, 32);
506
511
  const suffix = createHash("sha1").update(uniqueKey).digest("hex").slice(0, 8);
507
- return [slug || "workspace", suffix].join("-");
512
+ return [slug || "tenant", suffix].join("-");
508
513
  }