@gh-symphony/cli 0.0.17 → 0.0.19

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 (32) hide show
  1. package/README.md +105 -9
  2. package/dist/{chunk-EFMFGOWM.js → chunk-6CI3UUMH.js} +282 -57
  3. package/dist/chunk-C7G7RJ4G.js +146 -0
  4. package/dist/{chunk-MHIWAIVD.js → chunk-GKENCODJ.js} +141 -53
  5. package/dist/{project-557FE2GD.js → chunk-H2YXSYOZ.js} +108 -92
  6. package/dist/{chunk-TF3QNWNC.js → chunk-M3IFVLQS.js} +246 -212
  7. package/dist/{chunk-IWR4UQEJ.js → chunk-RN2PACNV.js} +350 -523
  8. package/dist/chunk-TILHWBP6.js +638 -0
  9. package/dist/{chunk-6HBZC3BE.js → chunk-XN5ABWZ6.js} +23 -5
  10. package/dist/{chunk-76QPITKI.js → chunk-Y6TYJMNT.js} +1 -1
  11. package/dist/{config-cmd-AZ7POMAA.js → config-cmd-DNXNL26Z.js} +3 -1
  12. package/dist/doctor-IYHCFXOZ.js +1126 -0
  13. package/dist/index.js +157 -19
  14. package/dist/init-KZT6YNOH.js +33 -0
  15. package/dist/{logs-6LNGT2GF.js → logs-6JKKYDGJ.js} +1 -1
  16. package/dist/project-DNALEWO3.js +22 -0
  17. package/dist/{recover-LVBI2TGH.js → recover-C3V2QAUB.js} +3 -3
  18. package/dist/repo-HDDE7OUI.js +321 -0
  19. package/dist/{run-WITYAYFZ.js → run-XI2S5Y4V.js} +3 -3
  20. package/dist/setup-K4CYYJBF.js +431 -0
  21. package/dist/{start-JUFKNL3N.js → start-M6IQGRFO.js} +5 -5
  22. package/dist/{status-3WK5BWRZ.js → status-QSCFVGRQ.js} +2 -2
  23. package/dist/{stop-AA3AP5M6.js → stop-7MFCBQVW.js} +2 -2
  24. package/dist/upgrade-F4VE4XBS.js +165 -0
  25. package/dist/{version-YVM2A25J.js → version-Y5RYNWMF.js} +1 -1
  26. package/dist/worker-entry.js +39 -11
  27. package/dist/workflow-TBIFY5MO.js +497 -0
  28. package/package.json +4 -4
  29. package/dist/chunk-JO3AXHQI.js +0 -130
  30. package/dist/chunk-TH5QPO3Y.js +0 -67
  31. package/dist/init-EZXQAXZM.js +0 -17
  32. package/dist/repo-R3XBIVAX.js +0 -121
@@ -1,9 +1,15 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  GhAuthError,
4
- ensureGhAuth,
5
- getGhToken
6
- } from "./chunk-JO3AXHQI.js";
4
+ GitHubScopeError,
5
+ checkRequiredScopes,
6
+ createClient,
7
+ getGhTokenWithSource,
8
+ getProjectDetail,
9
+ listUserProjects,
10
+ resolveGitHubAuth,
11
+ validateToken
12
+ } from "./chunk-TILHWBP6.js";
7
13
  import {
8
14
  loadGlobalConfig,
9
15
  saveGlobalConfig,
@@ -13,360 +19,9 @@ import {
13
19
  // src/commands/init.ts
14
20
  import * as p from "@clack/prompts";
15
21
  import { createHash } from "crypto";
16
- import { mkdir as mkdir3, rename, writeFile as writeFile3 } from "fs/promises";
22
+ import { mkdir as mkdir3, readFile as readFile3, rename as rename2, writeFile as writeFile3 } from "fs/promises";
17
23
  import { basename, dirname as dirname2, join as join3, relative, resolve } from "path";
18
24
 
19
- // src/github/client.ts
20
- var DEFAULT_API_URL = "https://api.github.com/graphql";
21
- var REST_API_URL = "https://api.github.com";
22
- var GitHubApiError = class extends Error {
23
- constructor(message, status) {
24
- super(message);
25
- this.status = status;
26
- this.name = "GitHubApiError";
27
- }
28
- };
29
- var GitHubScopeError = class extends GitHubApiError {
30
- constructor(message, requiredScopes, currentScopes) {
31
- super(message);
32
- this.requiredScopes = requiredScopes;
33
- this.currentScopes = currentScopes;
34
- this.name = "GitHubScopeError";
35
- }
36
- };
37
- function createClient(token, options) {
38
- return {
39
- token,
40
- apiUrl: options?.apiUrl ?? DEFAULT_API_URL,
41
- fetchImpl: options?.fetchImpl ?? fetch
42
- };
43
- }
44
- async function validateToken(client) {
45
- const restUrl = client.apiUrl.replace("/graphql", "");
46
- const baseUrl = restUrl === client.apiUrl ? REST_API_URL : restUrl;
47
- const response = await client.fetchImpl(`${baseUrl}/user`, {
48
- headers: {
49
- authorization: `Bearer ${client.token}`,
50
- accept: "application/vnd.github+json"
51
- }
52
- });
53
- if (!response.ok) {
54
- if (response.status === 401) {
55
- throw new GitHubApiError("Invalid token: authentication failed.", 401);
56
- }
57
- throw new GitHubApiError(
58
- `GitHub API error: ${response.status} ${response.statusText}`,
59
- response.status
60
- );
61
- }
62
- const scopes = response.headers.get("x-oauth-scopes")?.split(",").map((s) => s.trim()).filter(Boolean) ?? [];
63
- const user = await response.json();
64
- return {
65
- login: user.login,
66
- name: user.name,
67
- scopes
68
- };
69
- }
70
- function checkRequiredScopes(scopes) {
71
- const required = ["repo", "read:org", "project"];
72
- const normalizedScopes = scopes.map((s) => s.toLowerCase());
73
- const missing = required.filter((r) => !normalizedScopes.includes(r));
74
- return { valid: missing.length === 0, missing };
75
- }
76
- async function listUserProjects(client) {
77
- const data = await graphql(
78
- client,
79
- VIEWER_PROJECTS_QUERY
80
- );
81
- const projects = [];
82
- for (const node of data.viewer.projectsV2?.nodes ?? []) {
83
- if (!node) continue;
84
- projects.push(
85
- normalizeProjectSummary(node, {
86
- login: data.viewer.login,
87
- type: "User"
88
- })
89
- );
90
- }
91
- for (const orgNode of data.viewer.organizations?.nodes ?? []) {
92
- if (!orgNode) continue;
93
- for (const projNode of orgNode.projectsV2?.nodes ?? []) {
94
- if (!projNode) continue;
95
- projects.push(
96
- normalizeProjectSummary(projNode, {
97
- login: orgNode.login,
98
- type: "Organization"
99
- })
100
- );
101
- }
102
- }
103
- return projects;
104
- }
105
- function normalizeProjectSummary(node, owner) {
106
- return {
107
- id: node.id,
108
- title: node.title,
109
- shortDescription: node.shortDescription ?? "",
110
- url: node.url,
111
- openItemCount: node.items?.totalCount ?? 0,
112
- owner
113
- };
114
- }
115
- async function getProjectDetail(client, projectId) {
116
- const data = await graphql(
117
- client,
118
- PROJECT_DETAIL_QUERY,
119
- { projectId }
120
- );
121
- const project = data.node;
122
- if (!project || project.__typename !== "ProjectV2") {
123
- throw new GitHubApiError(`Project not found: ${projectId}`);
124
- }
125
- const statusFields = [];
126
- const textFields = [];
127
- for (const field of project.fields?.nodes ?? []) {
128
- if (!field) continue;
129
- if (field.__typename === "ProjectV2SingleSelectField") {
130
- statusFields.push({
131
- id: field.id,
132
- name: field.name,
133
- options: (field.options ?? []).map((opt) => ({
134
- id: opt.id,
135
- name: opt.name,
136
- description: opt.description ?? null,
137
- color: opt.color ?? null
138
- }))
139
- });
140
- } else if (field.__typename === "ProjectV2Field" && field.dataType) {
141
- textFields.push({
142
- id: field.id,
143
- name: field.name,
144
- dataType: field.dataType
145
- });
146
- }
147
- }
148
- const repoMap = /* @__PURE__ */ new Map();
149
- let cursor = null;
150
- let hasMore = true;
151
- for (const item of project.items?.nodes ?? []) {
152
- const repo = item?.content?.repository;
153
- if (!repo) continue;
154
- const key = `${repo.owner.login}/${repo.name}`;
155
- if (!repoMap.has(key)) {
156
- repoMap.set(key, {
157
- owner: repo.owner.login,
158
- name: repo.name,
159
- url: repo.url,
160
- cloneUrl: repo.url.endsWith(".git") ? repo.url : `${repo.url}.git`
161
- });
162
- }
163
- }
164
- hasMore = project.items?.pageInfo?.hasNextPage ?? false;
165
- cursor = project.items?.pageInfo?.endCursor ?? null;
166
- while (hasMore && cursor) {
167
- const pageData = await graphql(
168
- client,
169
- PROJECT_ITEMS_PAGE_QUERY,
170
- { projectId, cursor }
171
- );
172
- const items = pageData.node?.items;
173
- if (!items) break;
174
- for (const item of items.nodes ?? []) {
175
- const repo = item?.content?.repository;
176
- if (!repo) continue;
177
- const key = `${repo.owner.login}/${repo.name}`;
178
- if (!repoMap.has(key)) {
179
- repoMap.set(key, {
180
- owner: repo.owner.login,
181
- name: repo.name,
182
- url: repo.url,
183
- cloneUrl: repo.url.endsWith(".git") ? repo.url : `${repo.url}.git`
184
- });
185
- }
186
- }
187
- hasMore = items.pageInfo?.hasNextPage ?? false;
188
- cursor = items.pageInfo?.endCursor ?? null;
189
- }
190
- return {
191
- id: project.id,
192
- title: project.title,
193
- url: project.url,
194
- statusFields,
195
- textFields,
196
- linkedRepositories: [...repoMap.values()]
197
- };
198
- }
199
- async function graphql(client, query, variables) {
200
- const response = await client.fetchImpl(client.apiUrl, {
201
- method: "POST",
202
- headers: {
203
- "content-type": "application/json",
204
- authorization: `Bearer ${client.token}`
205
- },
206
- body: JSON.stringify({ query, variables })
207
- });
208
- if (!response.ok) {
209
- const text = await response.text().catch(() => "");
210
- throw new GitHubApiError(
211
- `GitHub GraphQL request failed: ${response.status} ${response.statusText}. ${text}`,
212
- response.status
213
- );
214
- }
215
- const payload = await response.json();
216
- if (payload.errors?.length) {
217
- const scopeMessages = payload.errors.map((e) => e.message).filter((m) => m.includes("has not been granted the required scopes"));
218
- if (scopeMessages.length > 0) {
219
- const requiredScopes = /* @__PURE__ */ new Set();
220
- let currentScopes = [];
221
- for (const msg of scopeMessages) {
222
- for (const match of msg.matchAll(
223
- /requires one of the following scopes: \['([^']+)'\]/g
224
- )) {
225
- requiredScopes.add(match[1]);
226
- }
227
- if (currentScopes.length === 0) {
228
- const currMatch = /has only been granted the: \[([^\]]+)\]/.exec(msg);
229
- if (currMatch) {
230
- currentScopes = currMatch[1].split(",").map((s) => s.trim().replace(/'/g, "")).filter(Boolean);
231
- }
232
- }
233
- }
234
- throw new GitHubScopeError(
235
- "Token is missing required GitHub scopes.",
236
- [...requiredScopes],
237
- currentScopes
238
- );
239
- }
240
- throw new GitHubApiError(
241
- `GraphQL errors: ${payload.errors.map((e) => e.message).join("; ")}`
242
- );
243
- }
244
- if (!payload.data) {
245
- throw new GitHubApiError("GraphQL response missing data.");
246
- }
247
- return payload.data;
248
- }
249
- var VIEWER_PROJECTS_QUERY = `
250
- query ViewerProjects {
251
- viewer {
252
- login
253
- projectsV2(first: 50) {
254
- nodes {
255
- id
256
- title
257
- shortDescription
258
- url
259
- items { totalCount }
260
- }
261
- }
262
- organizations(first: 20) {
263
- nodes {
264
- login
265
- projectsV2(first: 50) {
266
- nodes {
267
- id
268
- title
269
- shortDescription
270
- url
271
- items { totalCount }
272
- }
273
- }
274
- }
275
- }
276
- }
277
- }
278
- `;
279
- var PROJECT_DETAIL_QUERY = `
280
- query ProjectDetail($projectId: ID!) {
281
- node(id: $projectId) {
282
- __typename
283
- ... on ProjectV2 {
284
- id
285
- title
286
- url
287
- fields(first: 50) {
288
- nodes {
289
- __typename
290
- ... on ProjectV2SingleSelectField {
291
- id
292
- name
293
- options {
294
- id
295
- name
296
- description
297
- color
298
- }
299
- }
300
- ... on ProjectV2Field {
301
- id
302
- name
303
- dataType
304
- }
305
- }
306
- }
307
- items(first: 100) {
308
- nodes {
309
- content {
310
- __typename
311
- ... on Issue {
312
- repository {
313
- name
314
- url
315
- owner { login }
316
- }
317
- }
318
- ... on PullRequest {
319
- repository {
320
- name
321
- url
322
- owner { login }
323
- }
324
- }
325
- }
326
- }
327
- pageInfo {
328
- endCursor
329
- hasNextPage
330
- }
331
- }
332
- }
333
- }
334
- }
335
- `;
336
- var PROJECT_ITEMS_PAGE_QUERY = `
337
- query ProjectItemsPage($projectId: ID!, $cursor: String) {
338
- node(id: $projectId) {
339
- ... on ProjectV2 {
340
- items(first: 100, after: $cursor) {
341
- nodes {
342
- content {
343
- __typename
344
- ... on Issue {
345
- repository {
346
- name
347
- url
348
- owner { login }
349
- }
350
- }
351
- ... on PullRequest {
352
- repository {
353
- name
354
- url
355
- owner { login }
356
- }
357
- }
358
- }
359
- }
360
- pageInfo {
361
- endCursor
362
- hasNextPage
363
- }
364
- }
365
- }
366
- }
367
- }
368
- `;
369
-
370
25
  // src/mapping/smart-defaults.ts
371
26
  var ROLE_PATTERNS = [
372
27
  {
@@ -811,16 +466,6 @@ function generateContextYamlString(context) {
811
466
  lines.push(` agent_command: ${yamlQuote(context.runtime.agent_command)}`);
812
467
  return lines.join("\n") + "\n";
813
468
  }
814
- async function writeContextYaml(outputDir, context) {
815
- await mkdir(outputDir, { recursive: true });
816
- const contextPath = `${outputDir}/.gh-symphony/context.yaml`;
817
- await mkdir(dirname(contextPath), { recursive: true });
818
- const temporaryPath = `${contextPath}.tmp`;
819
- const yamlContent = generateContextYamlString(context);
820
- await writeFile(temporaryPath, yamlContent, "utf8");
821
- const { rename: rename2 } = await import("fs/promises");
822
- await rename2(temporaryPath, contextPath);
823
- }
824
469
  function buildContextYaml(params) {
825
470
  const columns = params.statusField.options.map((option) => {
826
471
  const roleMapping = inferStateRole(option.name);
@@ -1201,7 +846,7 @@ function resolveRoleAction(role) {
1201
846
  }
1202
847
 
1203
848
  // src/skills/skill-writer.ts
1204
- import { mkdir as mkdir2, writeFile as writeFile2, readFile as readFile2 } from "fs/promises";
849
+ import { mkdir as mkdir2, readFile as readFile2, rename, writeFile as writeFile2 } from "fs/promises";
1205
850
  import { join as join2 } from "path";
1206
851
  function normalizeRuntimeForSkills(runtime) {
1207
852
  if (runtime === "claude-code" || runtime.includes("claude-code")) {
@@ -1222,44 +867,18 @@ function resolveSkillsDir(repoRoot, runtime) {
1222
867
  }
1223
868
  return null;
1224
869
  }
1225
- async function writeSkillFile(skillsDir, template, context, options) {
1226
- const skillDir = join2(skillsDir, template.name);
1227
- const filePath = join2(skillDir, template.fileName);
1228
- if (!options?.overwrite) {
1229
- try {
1230
- await readFile2(filePath, "utf8");
1231
- return { written: false, path: filePath };
1232
- } catch (error) {
1233
- const err = error;
1234
- if (err.code !== "ENOENT") {
1235
- throw error;
1236
- }
1237
- }
1238
- }
1239
- await mkdir2(skillDir, { recursive: true });
1240
- const content = template.generate(context);
1241
- const temporaryPath = `${filePath}.tmp`;
1242
- await writeFile2(temporaryPath, content, "utf8");
1243
- const { rename: rename2 } = await import("fs/promises");
1244
- await rename2(temporaryPath, filePath);
1245
- return { written: true, path: filePath };
1246
- }
1247
- async function writeAllSkills(repoRoot, runtime, templates, context, options) {
870
+ function buildSkillFilePlans(repoRoot, runtime, templates, context) {
1248
871
  const skillsDir = resolveSkillsDir(repoRoot, runtime);
1249
872
  if (!skillsDir) {
1250
- return { written: [], skipped: [] };
1251
- }
1252
- const written = [];
1253
- const skipped = [];
1254
- for (const template of templates) {
1255
- const result = await writeSkillFile(skillsDir, template, context, options);
1256
- if (result.written) {
1257
- written.push(result.path);
1258
- } else {
1259
- skipped.push(result.path);
1260
- }
873
+ return { skillsDir: null, files: [] };
1261
874
  }
1262
- return { written, skipped };
875
+ return {
876
+ skillsDir,
877
+ files: templates.map((template) => ({
878
+ path: join2(skillsDir, template.name, template.fileName),
879
+ content: template.generate(context)
880
+ }))
881
+ };
1263
882
  }
1264
883
 
1265
884
  // src/skills/templates/document.ts
@@ -1797,6 +1416,7 @@ async function abortIfCancelled(input) {
1797
1416
  }
1798
1417
  function parseInitFlags(args) {
1799
1418
  const flags = {
1419
+ dryRun: false,
1800
1420
  nonInteractive: false,
1801
1421
  skipSkills: false,
1802
1422
  skipContext: false
@@ -1805,6 +1425,9 @@ function parseInitFlags(args) {
1805
1425
  const arg = args[i];
1806
1426
  const next = args[i + 1];
1807
1427
  switch (arg) {
1428
+ case "--dry-run":
1429
+ flags.dryRun = true;
1430
+ break;
1808
1431
  case "--non-interactive":
1809
1432
  flags.nonInteractive = true;
1810
1433
  break;
@@ -1832,29 +1455,145 @@ var handler = async (args, options) => {
1832
1455
  await runNonInteractive(flags, options);
1833
1456
  return;
1834
1457
  }
1835
- await runInteractive(options);
1458
+ await runInteractive(flags, options);
1836
1459
  };
1837
1460
  var init_default = handler;
1838
- async function writeEcosystem(opts) {
1461
+ async function resolveChangeStatus(path, content, mode) {
1462
+ try {
1463
+ const existing = await readFile3(path, "utf8");
1464
+ if (mode === "create-only") {
1465
+ return "unchanged";
1466
+ }
1467
+ return existing === content ? "unchanged" : "update";
1468
+ } catch (error) {
1469
+ const err = error;
1470
+ if (err.code === "ENOENT") {
1471
+ return "create";
1472
+ }
1473
+ throw error;
1474
+ }
1475
+ }
1476
+ async function planFileChange(input) {
1477
+ return {
1478
+ ...input,
1479
+ status: await resolveChangeStatus(input.path, input.content, input.mode)
1480
+ };
1481
+ }
1482
+ async function writePlannedFile(file) {
1483
+ if (file.status === "unchanged") {
1484
+ return false;
1485
+ }
1486
+ await mkdir3(dirname2(file.path), { recursive: true });
1487
+ const temporaryPath = `${file.path}.tmp`;
1488
+ await writeFile3(temporaryPath, file.content, "utf8");
1489
+ await rename2(temporaryPath, file.path);
1490
+ return true;
1491
+ }
1492
+ function resolveStatusField(projectDetail) {
1493
+ return projectDetail.statusFields.find((f) => f.name.toLowerCase() === "status") ?? projectDetail.statusFields[0] ?? null;
1494
+ }
1495
+ function buildAutomaticStateMappings(statusField) {
1496
+ const mappings = {};
1497
+ for (const mapping of inferAllStateRoles(statusField.options.map((o) => o.name))) {
1498
+ if (mapping.role) {
1499
+ mappings[mapping.columnName] = { role: mapping.role };
1500
+ }
1501
+ }
1502
+ return mappings;
1503
+ }
1504
+ async function promptStateMappings(statusField, options) {
1505
+ const mappings = {};
1506
+ const inferred = inferAllStateRoles(statusField.options.map((o) => o.name));
1507
+ p.log.info(
1508
+ `Found ${statusField.options.length} status columns on field "${statusField.name}".`
1509
+ );
1510
+ for (const mapping of inferred) {
1511
+ const roleOptions = [
1512
+ { value: "active", label: "Active (agent works on this)" },
1513
+ { value: "wait", label: "Wait (human review / hold)" },
1514
+ { value: "terminal", label: "Terminal (completed)" }
1515
+ ];
1516
+ const defaultRole = mapping.role ?? "wait";
1517
+ const sortedOptions = [
1518
+ roleOptions.find((o) => o.value === defaultRole),
1519
+ ...roleOptions.filter((o) => o.value !== defaultRole)
1520
+ ];
1521
+ const selectedRole = await abortIfCancelled(
1522
+ p.select({
1523
+ message: `${options?.stepLabel ?? "Step 2/2"} \u2014 Map column "${mapping.columnName}":${mapping.confidence === "high" ? " (auto-detected)" : ""}`,
1524
+ options: sortedOptions
1525
+ })
1526
+ );
1527
+ mappings[mapping.columnName] = { role: selectedRole };
1528
+ }
1529
+ return mappings;
1530
+ }
1531
+ async function planWorkflowArtifacts(opts) {
1532
+ const workflowMd = generateWorkflowMarkdown({
1533
+ projectId: opts.projectDetail.id,
1534
+ stateFieldName: opts.statusField.name,
1535
+ mappings: opts.mappings,
1536
+ lifecycle: toWorkflowLifecycleConfig(opts.statusField.name, opts.mappings),
1537
+ runtime: opts.runtime
1538
+ });
1539
+ const workflowPlan = await planFileChange({
1540
+ path: opts.outputPath,
1541
+ label: "WORKFLOW.md",
1542
+ content: workflowMd,
1543
+ mode: "overwrite"
1544
+ });
1545
+ const ecosystemPlan = await planEcosystem({
1546
+ cwd: opts.cwd,
1547
+ projectDetail: opts.projectDetail,
1548
+ statusField: opts.statusField,
1549
+ runtime: opts.runtime,
1550
+ skipSkills: opts.skipSkills,
1551
+ skipContext: opts.skipContext
1552
+ });
1553
+ return {
1554
+ outputPath: opts.outputPath,
1555
+ workflowMd,
1556
+ workflowPlan,
1557
+ ecosystemPlan
1558
+ };
1559
+ }
1560
+ async function writeWorkflowPlan(workflowPlan) {
1561
+ return writePlannedFile(workflowPlan);
1562
+ }
1563
+ function summarizeEnvironment(env) {
1564
+ return [
1565
+ `Package manager ${env.packageManager ?? "none"}${env.lockfile ? ` (${env.lockfile})` : ""}`,
1566
+ `Scripts test=${env.testCommand ?? "none"} | lint=${env.lintCommand ?? "none"} | build=${env.buildCommand ?? "none"}`,
1567
+ `CI ${env.ciPlatform ?? "none"}`,
1568
+ `Monorepo ${env.monorepo ? "yes" : "no"}`,
1569
+ `Existing skills ${env.existingSkills.length === 0 ? "none" : env.existingSkills.join(", ")}`
1570
+ ];
1571
+ }
1572
+ async function planEcosystem(opts) {
1839
1573
  const { cwd, projectDetail, statusField, runtime, skipSkills, skipContext } = opts;
1840
1574
  const ghSymphonyDir = join3(cwd, ".gh-symphony");
1841
- await mkdir3(ghSymphonyDir, { recursive: true });
1842
- const env = await detectEnvironment(cwd);
1843
- let contextYamlWritten = false;
1575
+ const environment = await detectEnvironment(cwd);
1576
+ const files = [];
1844
1577
  if (!skipContext) {
1845
1578
  const contextYaml = buildContextYaml({
1846
1579
  projectDetail,
1847
1580
  statusField,
1848
- detectedEnvironment: env,
1581
+ detectedEnvironment: environment,
1849
1582
  runtime: {
1850
1583
  agent: runtime,
1851
1584
  agent_command: runtime === "codex" ? "bash -lc codex app-server" : runtime === "claude-code" ? "bash -lc claude-code" : runtime
1852
1585
  }
1853
1586
  });
1854
- await writeContextYaml(cwd, contextYaml);
1855
- contextYamlWritten = true;
1587
+ files.push(
1588
+ await planFileChange({
1589
+ path: join3(ghSymphonyDir, "context.yaml"),
1590
+ label: "Context metadata",
1591
+ content: generateContextYamlString(contextYaml),
1592
+ mode: "overwrite"
1593
+ })
1594
+ );
1856
1595
  }
1857
- const refWorkflow = generateReferenceWorkflow({
1596
+ const referenceWorkflow = generateReferenceWorkflow({
1858
1597
  runtime,
1859
1598
  statusColumns: statusField.options.map((o) => ({
1860
1599
  name: o.name,
@@ -1862,43 +1601,99 @@ async function writeEcosystem(opts) {
1862
1601
  })),
1863
1602
  projectId: projectDetail.id
1864
1603
  });
1865
- const refPath = join3(ghSymphonyDir, "reference-workflow.md");
1866
- const tmpRef = refPath + ".tmp";
1867
- await writeFile3(tmpRef, refWorkflow, "utf8");
1868
- await rename(tmpRef, refPath);
1869
- const skillsDir = resolveSkillsDir(cwd, runtime);
1870
- let skillsWritten = [];
1871
- let skillsSkipped = [];
1604
+ files.push(
1605
+ await planFileChange({
1606
+ path: join3(ghSymphonyDir, "reference-workflow.md"),
1607
+ label: "Reference workflow",
1608
+ content: referenceWorkflow,
1609
+ mode: "overwrite"
1610
+ })
1611
+ );
1612
+ const skillsDir = skipSkills ? null : resolveSkillsDir(cwd, runtime);
1872
1613
  if (!skipSkills && skillsDir) {
1873
- const result = await writeAllSkills(cwd, runtime, ALL_SKILL_TEMPLATES, {
1614
+ const { files: plannedSkills } = buildSkillFilePlans(
1615
+ cwd,
1874
1616
  runtime,
1875
- projectId: projectDetail.id,
1876
- githubProjectTitle: projectDetail.title,
1877
- repositories: projectDetail.linkedRepositories.map((r) => ({
1878
- owner: r.owner,
1879
- name: r.name
1880
- })),
1881
- statusColumns: statusField.options.map((o) => ({
1882
- id: o.id,
1883
- name: o.name,
1884
- role: null
1885
- })),
1886
- statusFieldId: statusField.id,
1887
- contextYamlPath: ".gh-symphony/context.yaml",
1888
- referenceWorkflowPath: ".gh-symphony/reference-workflow.md"
1889
- });
1890
- skillsWritten = result.written.map((p2) => basename(dirname2(p2)));
1891
- skillsSkipped = result.skipped.map((p2) => basename(dirname2(p2)));
1617
+ ALL_SKILL_TEMPLATES,
1618
+ {
1619
+ runtime,
1620
+ projectId: projectDetail.id,
1621
+ githubProjectTitle: projectDetail.title,
1622
+ repositories: projectDetail.linkedRepositories.map((r) => ({
1623
+ owner: r.owner,
1624
+ name: r.name
1625
+ })),
1626
+ statusColumns: statusField.options.map((o) => ({
1627
+ id: o.id,
1628
+ name: o.name,
1629
+ role: null
1630
+ })),
1631
+ statusFieldId: statusField.id,
1632
+ contextYamlPath: ".gh-symphony/context.yaml",
1633
+ referenceWorkflowPath: ".gh-symphony/reference-workflow.md"
1634
+ }
1635
+ );
1636
+ for (const plannedSkill of plannedSkills) {
1637
+ files.push(
1638
+ await planFileChange({
1639
+ path: plannedSkill.path,
1640
+ label: `Skill ${basename(dirname2(plannedSkill.path))}`,
1641
+ content: plannedSkill.content,
1642
+ mode: "create-only"
1643
+ })
1644
+ );
1645
+ }
1892
1646
  }
1893
1647
  return {
1894
1648
  projectId: projectDetail.id,
1895
1649
  githubProjectTitle: projectDetail.title,
1896
1650
  runtime,
1897
1651
  skillsDir,
1652
+ environment,
1653
+ files
1654
+ };
1655
+ }
1656
+ async function writeEcosystem(opts) {
1657
+ const plan = await planEcosystem(opts);
1658
+ await mkdir3(join3(opts.cwd, ".gh-symphony"), { recursive: true });
1659
+ const contextYamlPath = join3(opts.cwd, ".gh-symphony", "context.yaml");
1660
+ const referenceWorkflowPath = join3(
1661
+ opts.cwd,
1662
+ ".gh-symphony",
1663
+ "reference-workflow.md"
1664
+ );
1665
+ let contextYamlWritten = false;
1666
+ let referenceWorkflowWritten = false;
1667
+ const skillsWritten = [];
1668
+ const skillsSkipped = [];
1669
+ for (const file of plan.files) {
1670
+ const written = await writePlannedFile(file);
1671
+ if (file.path === contextYamlPath) {
1672
+ contextYamlWritten = written;
1673
+ continue;
1674
+ }
1675
+ if (file.path === referenceWorkflowPath) {
1676
+ referenceWorkflowWritten = written;
1677
+ continue;
1678
+ }
1679
+ if (file.label.startsWith("Skill ")) {
1680
+ const skillName = basename(dirname2(file.path));
1681
+ if (written) {
1682
+ skillsWritten.push(skillName);
1683
+ } else {
1684
+ skillsSkipped.push(skillName);
1685
+ }
1686
+ }
1687
+ }
1688
+ return {
1689
+ projectId: plan.projectId,
1690
+ githubProjectTitle: plan.githubProjectTitle,
1691
+ runtime: plan.runtime,
1692
+ skillsDir: plan.skillsDir,
1898
1693
  contextYamlWritten,
1899
- referenceWorkflowWritten: true,
1900
- skillsWritten,
1901
- skillsSkipped
1694
+ referenceWorkflowWritten,
1695
+ skillsWritten: skillsWritten.sort(),
1696
+ skillsSkipped: skillsSkipped.sort()
1902
1697
  };
1903
1698
  }
1904
1699
  function printEcosystemSummary(result, workflowPath, opts) {
@@ -1941,10 +1736,65 @@ function printEcosystemSummary(result, workflowPath, opts) {
1941
1736
  process.stdout.write(lines.map((l) => ` ${l}`).join("\n") + "\n");
1942
1737
  }
1943
1738
  }
1739
+ function renderDryRunPreview(workflowPath, workflowPlan, ecosystemPlan) {
1740
+ const cwd = process.cwd();
1741
+ const relWorkflow = relative(cwd, workflowPath) || "WORKFLOW.md";
1742
+ const statusIcon = {
1743
+ create: "+",
1744
+ update: "~",
1745
+ unchanged: "="
1746
+ };
1747
+ const lines = [];
1748
+ lines.push("Init dry-run preview");
1749
+ lines.push(
1750
+ `GitHub Project ${ecosystemPlan.githubProjectTitle} (${ecosystemPlan.projectId})`
1751
+ );
1752
+ lines.push(`Runtime ${ecosystemPlan.runtime}`);
1753
+ lines.push("");
1754
+ lines.push("Planned file changes");
1755
+ lines.push(
1756
+ ` ${statusIcon[workflowPlan.status]} ${workflowPlan.status.padEnd(9)} WORKFLOW.md ${relWorkflow}`
1757
+ );
1758
+ for (const file of ecosystemPlan.files) {
1759
+ const relPath = relative(cwd, file.path) || file.path;
1760
+ lines.push(
1761
+ ` ${statusIcon[file.status]} ${file.status.padEnd(9)} ${file.label.padEnd(36)} ${relPath}`
1762
+ );
1763
+ }
1764
+ lines.push("");
1765
+ lines.push("Detected environment inputs");
1766
+ for (const line of summarizeEnvironment(ecosystemPlan.environment)) {
1767
+ lines.push(` ${line}`);
1768
+ }
1769
+ lines.push("");
1770
+ lines.push("Dry run only. No files were written.");
1771
+ return lines.join("\n") + "\n";
1772
+ }
1773
+ function buildDryRunJsonResult(workflowPath, workflowPlan, ecosystemPlan) {
1774
+ return {
1775
+ dryRun: true,
1776
+ output: workflowPath,
1777
+ projectId: ecosystemPlan.projectId,
1778
+ githubProjectTitle: ecosystemPlan.githubProjectTitle,
1779
+ runtime: ecosystemPlan.runtime,
1780
+ files: [workflowPlan, ...ecosystemPlan.files].map((file) => ({
1781
+ path: file.path,
1782
+ label: file.label,
1783
+ status: file.status,
1784
+ mode: file.mode
1785
+ })),
1786
+ environment: ecosystemPlan.environment
1787
+ };
1788
+ }
1789
+ function printDryRunPreview(workflowPath, workflowPlan, ecosystemPlan) {
1790
+ process.stdout.write(
1791
+ renderDryRunPreview(workflowPath, workflowPlan, ecosystemPlan)
1792
+ );
1793
+ }
1944
1794
  async function runNonInteractive(flags, options) {
1945
1795
  let token;
1946
1796
  try {
1947
- token = getGhToken();
1797
+ token = getGhTokenWithSource().token;
1948
1798
  } catch {
1949
1799
  process.stderr.write(
1950
1800
  "Error: GitHub token not found. Run 'gh auth login --scopes repo,read:org,project' or set GITHUB_GRAPHQL_TOKEN.\n"
@@ -1992,20 +1842,13 @@ async function runNonInteractive(flags, options) {
1992
1842
  process.exitCode = 1;
1993
1843
  return;
1994
1844
  }
1995
- const statusField = githubProject.statusFields.find((f) => f.name.toLowerCase() === "status") ?? githubProject.statusFields[0];
1845
+ const statusField = resolveStatusField(githubProject);
1996
1846
  if (!statusField) {
1997
1847
  process.stderr.write("Error: No status field found on the project.\n");
1998
1848
  process.exitCode = 1;
1999
1849
  return;
2000
1850
  }
2001
- const columnNames = statusField.options.map((o) => o.name);
2002
- const inferred = inferAllStateRoles(columnNames);
2003
- const mappings = {};
2004
- for (const mapping of inferred) {
2005
- if (mapping.role) {
2006
- mappings[mapping.columnName] = { role: mapping.role };
2007
- }
2008
- }
1851
+ const mappings = buildAutomaticStateMappings(statusField);
2009
1852
  const validation = validateStateMapping(mappings);
2010
1853
  if (!validation.valid) {
2011
1854
  process.stderr.write(
@@ -2016,16 +1859,30 @@ Run without --non-interactive for manual mapping.
2016
1859
  process.exitCode = 1;
2017
1860
  return;
2018
1861
  }
2019
- const lifecycleConfig = toWorkflowLifecycleConfig(statusField.name, mappings);
2020
1862
  const outputPath = resolve(flags.output ?? "WORKFLOW.md");
2021
- const workflowMd = generateWorkflowMarkdown({
2022
- projectId: githubProject.id,
2023
- stateFieldName: statusField.name,
1863
+ const { workflowPlan, ecosystemPlan } = await planWorkflowArtifacts({
1864
+ cwd: process.cwd(),
1865
+ outputPath,
1866
+ projectDetail: githubProject,
1867
+ statusField,
2024
1868
  mappings,
2025
- lifecycle: lifecycleConfig,
2026
- runtime: "codex"
1869
+ runtime: "codex",
1870
+ skipSkills: flags.skipSkills,
1871
+ skipContext: flags.skipContext
2027
1872
  });
2028
- await writeFile3(outputPath, workflowMd, "utf8");
1873
+ if (flags.dryRun) {
1874
+ if (options.json) {
1875
+ process.stdout.write(
1876
+ JSON.stringify(
1877
+ buildDryRunJsonResult(outputPath, workflowPlan, ecosystemPlan)
1878
+ ) + "\n"
1879
+ );
1880
+ return;
1881
+ }
1882
+ printDryRunPreview(outputPath, workflowPlan, ecosystemPlan);
1883
+ return;
1884
+ }
1885
+ await writeWorkflowPlan(workflowPlan);
2029
1886
  const ecosystemResult = await writeEcosystem({
2030
1887
  cwd: process.cwd(),
2031
1888
  projectDetail: githubProject,
@@ -2036,7 +1893,7 @@ Run without --non-interactive for manual mapping.
2036
1893
  });
2037
1894
  if (options.json) {
2038
1895
  process.stdout.write(
2039
- JSON.stringify({ output: outputPath, status: "created" }) + "\n"
1896
+ JSON.stringify({ output: outputPath, status: workflowPlan.status }) + "\n"
2040
1897
  );
2041
1898
  } else {
2042
1899
  printEcosystemSummary(ecosystemResult, outputPath, {
@@ -2045,36 +1902,23 @@ Run without --non-interactive for manual mapping.
2045
1902
  });
2046
1903
  }
2047
1904
  }
2048
- async function runInteractive(options) {
1905
+ async function runInteractive(flags, options) {
2049
1906
  p.intro("gh-symphony \u2014 WORKFLOW.md Setup");
2050
- await runInteractiveStandalone(options);
1907
+ await runInteractiveStandalone(flags, options);
2051
1908
  }
2052
- async function runInteractiveStandalone(_options) {
1909
+ async function runInteractiveStandalone(flags, _options) {
2053
1910
  const s1 = p.spinner();
2054
- s1.start("Checking gh CLI authentication...");
1911
+ s1.start("Checking GitHub authentication...");
2055
1912
  let client;
2056
1913
  try {
2057
- const { token } = ensureGhAuth();
2058
- client = createClient(token);
2059
- s1.stop("Authenticated via gh CLI");
1914
+ const auth = await resolveGitHubAuth();
1915
+ const sourceLabel = auth.source === "env" ? "GITHUB_GRAPHQL_TOKEN" : "gh CLI";
1916
+ client = createClient(auth.token);
1917
+ s1.stop(`Authenticated via ${sourceLabel} as ${auth.login}`);
2060
1918
  } catch (error) {
2061
1919
  s1.stop("Authentication failed.");
2062
1920
  if (error instanceof GhAuthError) {
2063
- if (error.code === "not_installed") {
2064
- p.log.error(
2065
- "gh CLI\uAC00 \uC124\uCE58\uB418\uC5B4 \uC788\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4. https://cli.github.com \uC5D0\uC11C \uC124\uCE58\uD558\uC138\uC694."
2066
- );
2067
- } else if (error.code === "not_authenticated") {
2068
- p.log.error(
2069
- "gh auth login --scopes repo,read:org,project \uB97C \uC2E4\uD589\uD558\uC138\uC694."
2070
- );
2071
- } else if (error.code === "missing_scopes") {
2072
- p.log.error(
2073
- "gh auth refresh --scopes repo,read:org,project \uB97C \uC2E4\uD589\uD558\uC138\uC694."
2074
- );
2075
- } else {
2076
- p.log.error(error.message);
2077
- }
1921
+ p.log.error(error.message);
2078
1922
  } else {
2079
1923
  p.log.error(error instanceof Error ? error.message : "Unknown error");
2080
1924
  }
@@ -2092,7 +1936,7 @@ async function runInteractiveStandalone(_options) {
2092
1936
  } catch (error) {
2093
1937
  s2.stop("Failed to load projects.");
2094
1938
  if (error instanceof GitHubScopeError) {
2095
- displayScopeError(error, "gh-symphony init");
1939
+ displayScopeError(error, "gh-symphony workflow init");
2096
1940
  } else {
2097
1941
  p.log.error(error instanceof Error ? error.message : "Unknown error");
2098
1942
  }
@@ -2129,7 +1973,7 @@ async function runInteractiveStandalone(_options) {
2129
1973
  process.exitCode = 1;
2130
1974
  return;
2131
1975
  }
2132
- const statusField = projectDetail.statusFields.find((f) => f.name.toLowerCase() === "status") ?? projectDetail.statusFields[0];
1976
+ const statusField = resolveStatusField(projectDetail);
2133
1977
  if (!statusField) {
2134
1978
  p.log.error(
2135
1979
  "No status field found on the project. The project needs a single-select 'Status' field."
@@ -2137,33 +1981,7 @@ async function runInteractiveStandalone(_options) {
2137
1981
  process.exitCode = 1;
2138
1982
  return;
2139
1983
  }
2140
- const columnNames = statusField.options.map((o) => o.name);
2141
- const inferred = inferAllStateRoles(columnNames);
2142
- p.log.info(
2143
- `Found ${columnNames.length} status columns on field "${statusField.name}".`
2144
- );
2145
- const mappings = {};
2146
- for (const mapping of inferred) {
2147
- const roleOptions = [
2148
- { value: "active", label: "Active (agent works on this)" },
2149
- { value: "wait", label: "Wait (human review / hold)" },
2150
- { value: "terminal", label: "Terminal (completed)" }
2151
- ];
2152
- const defaultRole = mapping.role ?? "wait";
2153
- const sortedOptions = [
2154
- roleOptions.find((o) => o.value === defaultRole),
2155
- ...roleOptions.filter((o) => o.value !== defaultRole)
2156
- ];
2157
- const selectedRole = await abortIfCancelled(
2158
- p.select({
2159
- message: `Step 2/2 \u2014 Map column "${mapping.columnName}":${mapping.confidence === "high" ? " (auto-detected)" : ""}`,
2160
- options: sortedOptions
2161
- })
2162
- );
2163
- if (selectedRole !== "skip") {
2164
- mappings[mapping.columnName] = { role: selectedRole };
2165
- }
2166
- }
1984
+ const mappings = await promptStateMappings(statusField);
2167
1985
  const validation = validateStateMapping(mappings);
2168
1986
  if (!validation.valid) {
2169
1987
  p.log.error("Mapping validation failed:");
@@ -2176,23 +1994,29 @@ async function runInteractiveStandalone(_options) {
2176
1994
  for (const warn of validation.warnings) {
2177
1995
  p.log.warn(` \u26A0 ${warn}`);
2178
1996
  }
2179
- const lifecycleConfig = toWorkflowLifecycleConfig(statusField.name, mappings);
2180
- const workflowMd = generateWorkflowMarkdown({
2181
- projectId: projectDetail.id,
2182
- stateFieldName: statusField.name,
1997
+ const outputPath = resolve(flags.output ?? "WORKFLOW.md");
1998
+ const { workflowPlan, ecosystemPlan } = await planWorkflowArtifacts({
1999
+ cwd: process.cwd(),
2000
+ outputPath,
2001
+ projectDetail,
2002
+ statusField,
2183
2003
  mappings,
2184
- lifecycle: lifecycleConfig,
2185
- runtime: "codex"
2004
+ runtime: "codex",
2005
+ skipSkills: flags.skipSkills,
2006
+ skipContext: flags.skipContext
2186
2007
  });
2187
- const outputPath = resolve("WORKFLOW.md");
2188
- await writeFile3(outputPath, workflowMd, "utf8");
2008
+ if (flags.dryRun) {
2009
+ printDryRunPreview(outputPath, workflowPlan, ecosystemPlan);
2010
+ return;
2011
+ }
2012
+ await writeWorkflowPlan(workflowPlan);
2189
2013
  const ecosystemResult = await writeEcosystem({
2190
2014
  cwd: process.cwd(),
2191
2015
  projectDetail,
2192
2016
  statusField,
2193
2017
  runtime: "codex",
2194
- skipSkills: false,
2195
- skipContext: false
2018
+ skipSkills: flags.skipSkills,
2019
+ skipContext: flags.skipContext
2196
2020
  });
2197
2021
  printEcosystemSummary(ecosystemResult, outputPath, {
2198
2022
  interactive: true,
@@ -2236,15 +2060,18 @@ function generateProjectId(githubProjectTitle, uniqueKey) {
2236
2060
  }
2237
2061
 
2238
2062
  export {
2239
- GitHubScopeError,
2240
- createClient,
2241
- validateToken,
2242
- checkRequiredScopes,
2243
- listUserProjects,
2244
- getProjectDetail,
2063
+ validateStateMapping,
2245
2064
  abortIfCancelled,
2246
2065
  init_default,
2066
+ resolveStatusField,
2067
+ buildAutomaticStateMappings,
2068
+ promptStateMappings,
2069
+ planWorkflowArtifacts,
2070
+ writeWorkflowPlan,
2071
+ planEcosystem,
2247
2072
  writeEcosystem,
2073
+ renderDryRunPreview,
2074
+ buildDryRunJsonResult,
2248
2075
  writeConfig,
2249
2076
  generateProjectId
2250
2077
  };