@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
@@ -8,6 +8,16 @@ export class GitHubApiError extends Error {
8
8
  this.name = "GitHubApiError";
9
9
  }
10
10
  }
11
+ export class GitHubScopeError extends GitHubApiError {
12
+ requiredScopes;
13
+ currentScopes;
14
+ constructor(message, requiredScopes, currentScopes) {
15
+ super(message);
16
+ this.requiredScopes = requiredScopes;
17
+ this.currentScopes = currentScopes;
18
+ this.name = "GitHubScopeError";
19
+ }
20
+ }
11
21
  export function createClient(token, options) {
12
22
  return {
13
23
  token,
@@ -94,19 +104,29 @@ export async function getProjectDetail(client, projectId) {
94
104
  throw new GitHubApiError(`Project not found: ${projectId}`);
95
105
  }
96
106
  const statusFields = [];
107
+ const textFields = [];
97
108
  for (const field of project.fields?.nodes ?? []) {
98
- if (!field || field.__typename !== "ProjectV2SingleSelectField")
109
+ if (!field)
99
110
  continue;
100
- statusFields.push({
101
- id: field.id,
102
- name: field.name,
103
- options: (field.options ?? []).map((opt) => ({
104
- id: opt.id,
105
- name: opt.name,
106
- description: opt.description ?? null,
107
- color: opt.color ?? null,
108
- })),
109
- });
111
+ if (field.__typename === "ProjectV2SingleSelectField") {
112
+ statusFields.push({
113
+ id: field.id,
114
+ name: field.name,
115
+ options: (field.options ?? []).map((opt) => ({
116
+ id: opt.id,
117
+ name: opt.name,
118
+ description: opt.description ?? null,
119
+ color: opt.color ?? null,
120
+ })),
121
+ });
122
+ }
123
+ else if (field.__typename === "ProjectV2Field" && field.dataType) {
124
+ textFields.push({
125
+ id: field.id,
126
+ name: field.name,
127
+ dataType: field.dataType,
128
+ });
129
+ }
110
130
  }
111
131
  const repoMap = new Map();
112
132
  let cursor = null;
@@ -156,6 +176,7 @@ export async function getProjectDetail(client, projectId) {
156
176
  title: project.title,
157
177
  url: project.url,
158
178
  statusFields,
179
+ textFields,
159
180
  linkedRepositories: [...repoMap.values()],
160
181
  };
161
182
  }
@@ -175,6 +196,28 @@ async function graphql(client, query, variables) {
175
196
  }
176
197
  const payload = (await response.json());
177
198
  if (payload.errors?.length) {
199
+ const scopeMessages = payload.errors
200
+ .map((e) => e.message)
201
+ .filter((m) => m.includes("has not been granted the required scopes"));
202
+ if (scopeMessages.length > 0) {
203
+ const requiredScopes = new Set();
204
+ let currentScopes = [];
205
+ for (const msg of scopeMessages) {
206
+ for (const match of msg.matchAll(/requires one of the following scopes: \['([^']+)'\]/g)) {
207
+ requiredScopes.add(match[1]);
208
+ }
209
+ if (currentScopes.length === 0) {
210
+ const currMatch = /has only been granted the: \[([^\]]+)\]/.exec(msg);
211
+ if (currMatch) {
212
+ currentScopes = currMatch[1]
213
+ .split(",")
214
+ .map((s) => s.trim().replace(/'/g, ""))
215
+ .filter(Boolean);
216
+ }
217
+ }
218
+ }
219
+ throw new GitHubScopeError("Token is missing required GitHub scopes.", [...requiredScopes], currentScopes);
220
+ }
178
221
  throw new GitHubApiError(`GraphQL errors: ${payload.errors.map((e) => e.message).join("; ")}`);
179
222
  }
180
223
  if (!payload.data) {
@@ -234,6 +277,11 @@ const PROJECT_DETAIL_QUERY = `
234
277
  color
235
278
  }
236
279
  }
280
+ ... on ProjectV2Field {
281
+ id
282
+ name
283
+ dataType
284
+ }
237
285
  }
238
286
  }
239
287
  items(first: 100) {
@@ -0,0 +1,34 @@
1
+ import { execFileSync, spawnSync } from "node:child_process";
2
+ type ExecImpl = typeof execFileSync;
3
+ type SpawnImpl = typeof spawnSync;
4
+ export declare class GhAuthError extends Error {
5
+ readonly code: "not_installed" | "not_authenticated" | "missing_scopes" | "token_failed";
6
+ constructor(code: "not_installed" | "not_authenticated" | "missing_scopes" | "token_failed", message: string);
7
+ }
8
+ export declare function checkGhInstalled(opts?: {
9
+ execImpl?: ExecImpl;
10
+ }): boolean;
11
+ export declare function checkGhAuthenticated(opts?: {
12
+ spawnImpl?: SpawnImpl;
13
+ }): {
14
+ authenticated: boolean;
15
+ login?: string;
16
+ };
17
+ export declare function checkGhScopes(opts?: {
18
+ spawnImpl?: SpawnImpl;
19
+ }): {
20
+ valid: boolean;
21
+ missing: string[];
22
+ scopes: string[];
23
+ };
24
+ export declare function getGhToken(opts?: {
25
+ execImpl?: ExecImpl;
26
+ }): string;
27
+ export declare function ensureGhAuth(opts?: {
28
+ execImpl?: ExecImpl;
29
+ spawnImpl?: SpawnImpl;
30
+ }): {
31
+ login: string;
32
+ token: string;
33
+ };
34
+ export {};
@@ -0,0 +1,110 @@
1
+ import { execFileSync, spawnSync } from "node:child_process";
2
+ const REQUIRED_SCOPES = ["repo", "read:org", "project"];
3
+ export class GhAuthError extends Error {
4
+ code;
5
+ constructor(code, message) {
6
+ super(message);
7
+ this.code = code;
8
+ this.name = "GhAuthError";
9
+ }
10
+ }
11
+ export function checkGhInstalled(opts) {
12
+ const execImpl = opts?.execImpl ?? execFileSync;
13
+ try {
14
+ execImpl("gh", ["--version"], { stdio: "pipe" });
15
+ return true;
16
+ }
17
+ catch (error) {
18
+ const execError = error;
19
+ if (execError.code === "ENOENT") {
20
+ return false;
21
+ }
22
+ throw error;
23
+ }
24
+ }
25
+ export function checkGhAuthenticated(opts) {
26
+ const spawnImpl = opts?.spawnImpl ?? spawnSync;
27
+ const result = spawnImpl("gh", ["auth", "status"], {
28
+ encoding: "utf8",
29
+ stdio: ["pipe", "pipe", "pipe"],
30
+ });
31
+ if ((result.status ?? 1) !== 0) {
32
+ return { authenticated: false };
33
+ }
34
+ const login = parseLogin((result.stderr ?? "").toString());
35
+ return { authenticated: true, login };
36
+ }
37
+ export function checkGhScopes(opts) {
38
+ const spawnImpl = opts?.spawnImpl ?? spawnSync;
39
+ const result = spawnImpl("gh", ["auth", "status"], {
40
+ encoding: "utf8",
41
+ stdio: ["pipe", "pipe", "pipe"],
42
+ });
43
+ const output = (result.stderr ?? "").toString();
44
+ const scopes = parseScopes(output);
45
+ if (scopes.length === 0) {
46
+ return { valid: true, missing: [], scopes: [] };
47
+ }
48
+ const normalized = scopes.map((scope) => scope.toLowerCase());
49
+ const missing = REQUIRED_SCOPES.filter((scope) => !normalized.includes(scope));
50
+ return {
51
+ valid: missing.length === 0,
52
+ missing: [...missing],
53
+ scopes,
54
+ };
55
+ }
56
+ export function getGhToken(opts) {
57
+ if (process.env.GITHUB_GRAPHQL_TOKEN) {
58
+ return process.env.GITHUB_GRAPHQL_TOKEN;
59
+ }
60
+ const execImpl = opts?.execImpl ?? execFileSync;
61
+ try {
62
+ const token = execImpl("gh", ["auth", "token"], {
63
+ encoding: "utf8",
64
+ stdio: ["pipe", "pipe", "pipe"],
65
+ })
66
+ .toString()
67
+ .trim();
68
+ if (!token) {
69
+ throw new GhAuthError("token_failed", "gh auth token 실패. gh auth status 를 확인하세요.");
70
+ }
71
+ return token;
72
+ }
73
+ catch (error) {
74
+ if (error instanceof GhAuthError) {
75
+ throw error;
76
+ }
77
+ throw new GhAuthError("token_failed", "gh auth token 실패. gh auth status 를 확인하세요.");
78
+ }
79
+ }
80
+ export function ensureGhAuth(opts) {
81
+ const execImpl = opts?.execImpl ?? execFileSync;
82
+ const spawnImpl = opts?.spawnImpl ?? spawnSync;
83
+ if (!checkGhInstalled({ execImpl })) {
84
+ throw new GhAuthError("not_installed", "gh CLI가 설치되어 있지 않습니다. https://cli.github.com 에서 설치하세요.");
85
+ }
86
+ const auth = checkGhAuthenticated({ spawnImpl });
87
+ if (!auth.authenticated) {
88
+ throw new GhAuthError("not_authenticated", "gh auth login --scopes repo,read:org,project 를 실행하세요.");
89
+ }
90
+ const scopeCheck = checkGhScopes({ spawnImpl });
91
+ if (!scopeCheck.valid) {
92
+ throw new GhAuthError("missing_scopes", `gh auth refresh --scopes repo,read:org,project 를 실행하세요. (missing: ${scopeCheck.missing.join(", ")})`);
93
+ }
94
+ const token = getGhToken({ execImpl });
95
+ return { login: auth.login ?? "unknown", token };
96
+ }
97
+ function parseLogin(output) {
98
+ const matched = output.match(/Logged in to github\.com account\s+\*?\*?([A-Za-z0-9_-]+)\*?\*?/i);
99
+ return matched?.[1];
100
+ }
101
+ function parseScopes(output) {
102
+ const matched = output.match(/Token scopes:\s*(.+)/i);
103
+ if (!matched) {
104
+ return [];
105
+ }
106
+ return matched[1]
107
+ .split(",")
108
+ .map((scope) => scope.trim().replace(/^'+|'+$/g, ""))
109
+ .filter((scope) => scope.length > 0);
110
+ }
package/dist/index.js CHANGED
@@ -48,6 +48,7 @@ const COMMANDS = {
48
48
  logs: () => import("./commands/logs.js"),
49
49
  project: () => import("./commands/project.js"),
50
50
  repo: () => import("./commands/repo.js"),
51
+ tenant: () => import("./commands/tenant.js"),
51
52
  config: () => import("./commands/config-cmd.js"),
52
53
  help: () => import("./commands/help.js"),
53
54
  version: () => import("./commands/version.js"),
@@ -1,33 +1,17 @@
1
- import type { WorkflowLifecycleConfig } from "@hojinzs/gh-symphony-core";
2
- import type { ColumnRole, HumanReviewMode } from "../config.js";
3
- export type ColumnMapping = {
1
+ import type { WorkflowLifecycleConfig } from "@gh-symphony/core";
2
+ import type { StateRole, StateMapping } from "../config.js";
3
+ export type StateRoleMapping = {
4
4
  columnName: string;
5
- role: ColumnRole | null;
5
+ role: StateRole | null;
6
6
  confidence: "high" | "low";
7
7
  };
8
- export declare function inferColumnRole(columnName: string): ColumnMapping;
9
- export declare function inferAllColumnRoles(columnNames: string[]): ColumnMapping[];
10
- export type PhaseMapping = {
11
- planningStates: string[];
12
- humanReviewStates: string[];
13
- implementationStates: string[];
14
- awaitingMergeStates: string[];
15
- completedStates: string[];
16
- };
17
- /**
18
- * Map column roles to Symphony execution phases based on the human-review mode.
19
- *
20
- * Modes:
21
- * - plan-and-pr: Human reviews both plans and PRs (full review pipeline)
22
- * - plan-only: Human reviews plans, PRs auto-merge
23
- * - pr-only: No plan review, human reviews PRs
24
- * - none: No human review at all (full auto)
25
- */
26
- export declare function buildPhaseMapping(roles: Record<string, ColumnRole>, mode: HumanReviewMode): PhaseMapping;
27
- export declare function toWorkflowLifecycleConfig(stateFieldName: string, roles: Record<string, ColumnRole>, mode: HumanReviewMode): WorkflowLifecycleConfig;
8
+ export declare function inferStateRole(columnName: string): StateRoleMapping;
9
+ export declare function inferAllStateRoles(columnNames: string[]): StateRoleMapping[];
10
+ export declare function toWorkflowLifecycleConfig(stateFieldName: string, mappings: Record<string, StateMapping>): WorkflowLifecycleConfig;
28
11
  export type MappingValidationResult = {
29
12
  valid: boolean;
30
13
  errors: string[];
31
14
  warnings: string[];
32
15
  };
33
- export declare function validateMapping(roles: Record<string, ColumnRole>): MappingValidationResult;
16
+ export declare function validateStateMapping(mappings: Record<string, StateMapping>): MappingValidationResult;
17
+ export declare function generateStatusMap(mappings: Record<string, StateMapping>): string;
@@ -1,27 +1,19 @@
1
1
  // ── 3.1: Smart defaults pattern matching ─────────────────────────────────────
2
2
  const ROLE_PATTERNS = [
3
3
  {
4
- role: "trigger",
5
- pattern: /^(todo|to.do|to-do|ready|queued|open|new|triage)$/i,
4
+ role: "active",
5
+ pattern: /^(todo|to.do|to-do|ready|queued|open|new|triage|in.progress|working|active|doing|in.development|developing|wip)$/i,
6
6
  },
7
7
  {
8
- role: "working",
9
- pattern: /^(in.progress|working|active|doing|in.development|developing|wip)$/i,
8
+ role: "wait",
9
+ pattern: /^(review|in.review|pr.review|needs.review|plan.review|awaiting.review|code.review|icebox|someday|later|blocked|on.hold|paused|deferred|draft|backlog)$/i,
10
10
  },
11
11
  {
12
- role: "human-review",
13
- pattern: /^(review|in.review|pr.review|needs.review|plan.review|awaiting.review|code.review)$/i,
14
- },
15
- {
16
- role: "done",
17
- pattern: /^(done|completed?|closed|merged|shipped|resolved|finished)$/i,
18
- },
19
- {
20
- role: "ignored",
21
- pattern: /^(icebox|someday|later|blocked|on.hold|paused|won.?t.do|cancelled|deferred|draft|backlog)$/i,
12
+ role: "terminal",
13
+ pattern: /^(done|completed?|closed|merged|shipped|resolved|finished|won.?t.do|cancelled)$/i,
22
14
  },
23
15
  ];
24
- export function inferColumnRole(columnName) {
16
+ export function inferStateRole(columnName) {
25
17
  const normalized = columnName.trim();
26
18
  for (const { role, pattern } of ROLE_PATTERNS) {
27
19
  if (pattern.test(normalized)) {
@@ -30,130 +22,65 @@ export function inferColumnRole(columnName) {
30
22
  }
31
23
  return { columnName: normalized, role: null, confidence: "low" };
32
24
  }
33
- export function inferAllColumnRoles(columnNames) {
34
- return columnNames.map(inferColumnRole);
25
+ export function inferAllStateRoles(columnNames) {
26
+ return columnNames.map(inferStateRole);
35
27
  }
36
- /**
37
- * Map column roles to Symphony execution phases based on the human-review mode.
38
- *
39
- * Modes:
40
- * - plan-and-pr: Human reviews both plans and PRs (full review pipeline)
41
- * - plan-only: Human reviews plans, PRs auto-merge
42
- * - pr-only: No plan review, human reviews PRs
43
- * - none: No human review at all (full auto)
44
- */
45
- export function buildPhaseMapping(roles, mode) {
46
- const planningStates = [];
47
- const humanReviewStates = [];
48
- const implementationStates = [];
49
- const awaitingMergeStates = [];
50
- const completedStates = [];
51
- for (const [columnName, role] of Object.entries(roles)) {
52
- switch (role) {
53
- case "trigger":
54
- planningStates.push(columnName);
55
- break;
56
- case "working":
57
- implementationStates.push(columnName);
28
+ // ── 3.2: Mapping → WorkflowLifecycleConfig conversion ───────────────────────
29
+ export function toWorkflowLifecycleConfig(stateFieldName, mappings) {
30
+ const activeStates = [];
31
+ const terminalStates = [];
32
+ const blockerCheckStates = [];
33
+ for (const [columnName, mapping] of Object.entries(mappings)) {
34
+ switch (mapping.role) {
35
+ case "active":
36
+ activeStates.push(columnName);
58
37
  break;
59
- case "human-review":
60
- switch (mode) {
61
- case "plan-and-pr":
62
- humanReviewStates.push(columnName);
63
- break;
64
- case "plan-only":
65
- humanReviewStates.push(columnName);
66
- break;
67
- case "pr-only":
68
- awaitingMergeStates.push(columnName);
69
- break;
70
- case "none":
71
- // In "none" mode, review columns are treated as implementation
72
- implementationStates.push(columnName);
73
- break;
74
- }
38
+ case "terminal":
39
+ terminalStates.push(columnName);
75
40
  break;
76
- case "done":
77
- completedStates.push(columnName);
78
- break;
79
- case "ignored":
80
- // Ignored columns don't map to any phase
41
+ case "wait":
42
+ // Wait states are neither active nor terminal
81
43
  break;
82
44
  }
83
45
  }
84
- return {
85
- planningStates,
86
- humanReviewStates,
87
- implementationStates,
88
- awaitingMergeStates,
89
- completedStates,
90
- };
91
- }
92
- // ── 3.3: Mapping → WorkflowLifecycleConfig conversion ───────────────────────
93
- export function toWorkflowLifecycleConfig(stateFieldName, roles, mode) {
94
- const phases = buildPhaseMapping(roles, mode);
95
- // Transition targets: where issues move when a phase completes
96
- const planningCompleteState = resolveTransitionTarget(phases, "planning", mode);
97
- const implementationCompleteState = resolveTransitionTarget(phases, "implementation", mode);
98
- const mergeCompleteState = phases.completedStates[0] ?? phases.awaitingMergeStates[0] ?? "Done";
46
+ // Default blocker check: first active state (typically "Todo"-like)
47
+ if (activeStates.length > 0) {
48
+ blockerCheckStates.push(activeStates[0]);
49
+ }
99
50
  return {
100
51
  stateFieldName,
101
- planningStates: phases.planningStates,
102
- humanReviewStates: phases.humanReviewStates,
103
- implementationStates: phases.implementationStates,
104
- awaitingMergeStates: phases.awaitingMergeStates,
105
- completedStates: phases.completedStates,
106
- planningCompleteState,
107
- implementationCompleteState,
108
- mergeCompleteState,
52
+ activeStates,
53
+ terminalStates,
54
+ blockerCheckStates,
109
55
  };
110
56
  }
111
- function resolveTransitionTarget(phases, fromPhase, mode) {
112
- if (fromPhase === "planning") {
113
- // After planning: go to human-review (if exists) or implementation
114
- if ((mode === "plan-and-pr" || mode === "plan-only") &&
115
- phases.humanReviewStates.length > 0) {
116
- return phases.humanReviewStates[0];
117
- }
118
- return phases.implementationStates[0] ?? "In Progress";
119
- }
120
- // After implementation: go to awaiting-merge (if exists) or completed
121
- if ((mode === "plan-and-pr" || mode === "pr-only") &&
122
- phases.awaitingMergeStates.length > 0) {
123
- return phases.awaitingMergeStates[0];
124
- }
125
- return phases.completedStates[0] ?? "Done";
126
- }
127
- export function validateMapping(roles) {
57
+ export function validateStateMapping(mappings) {
128
58
  const errors = [];
129
59
  const warnings = [];
130
- const roleEntries = Object.entries(roles);
131
- const triggerColumns = roleEntries.filter(([, r]) => r === "trigger");
132
- const workingColumns = roleEntries.filter(([, r]) => r === "working");
133
- const doneColumns = roleEntries.filter(([, r]) => r === "done");
134
- const reviewColumns = roleEntries.filter(([, r]) => r === "human-review");
135
- // Required roles
136
- if (triggerColumns.length === 0) {
137
- errors.push("Missing required role: 'trigger' — at least one column must trigger work.");
138
- }
139
- if (workingColumns.length === 0) {
140
- errors.push("Missing required role: 'working' — at least one column must represent active work.");
60
+ const entries = Object.entries(mappings);
61
+ const activeEntries = entries.filter(([, m]) => m.role === "active");
62
+ const terminalEntries = entries.filter(([, m]) => m.role === "terminal");
63
+ // Required: at least one active and one terminal
64
+ if (activeEntries.length === 0) {
65
+ errors.push("Missing required role: 'active' — at least one state must be active.");
141
66
  }
142
- if (doneColumns.length === 0) {
143
- errors.push("Missing required role: 'done' — at least one column must represent completion.");
67
+ if (terminalEntries.length === 0) {
68
+ errors.push("Missing required role: 'terminal' — at least one state must be terminal.");
144
69
  }
145
- // Warnings for unusual setups
146
- if (triggerColumns.length > 1) {
147
- warnings.push(`Multiple trigger columns: ${triggerColumns.map(([n]) => n).join(", ")}. ` +
148
- "All will be treated as planning states.");
149
- }
150
- if (doneColumns.length > 1) {
151
- warnings.push(`Multiple done columns: ${doneColumns.map(([n]) => n).join(", ")}. ` +
152
- "All will be treated as completed states.");
153
- }
154
- if (reviewColumns.length > 2) {
155
- warnings.push(`${reviewColumns.length} review columns detected. ` +
156
- "Consider simplifying to one or two review stages.");
70
+ // Warnings
71
+ if (terminalEntries.length > 1) {
72
+ warnings.push(`Multiple terminal states: ${terminalEntries.map(([n]) => n).join(", ")}. ` +
73
+ "All will be treated as terminal states.");
157
74
  }
158
75
  return { valid: errors.length === 0, errors, warnings };
159
76
  }
77
+ // ── 3.5: Status Map generation ──────────────────────────────────────────────
78
+ export function generateStatusMap(mappings) {
79
+ const lines = ["## Status Map", ""];
80
+ for (const [columnName, mapping] of Object.entries(mappings)) {
81
+ const rolePart = `[${mapping.role}]`;
82
+ const goalPart = mapping.goal ? ` — ${mapping.goal}` : "";
83
+ lines.push(`- **${columnName}** ${rolePart}${goalPart}`);
84
+ }
85
+ return lines.join("\n");
86
+ }
@@ -1,5 +1,5 @@
1
- import { type CliWorkspaceConfig } from "./config.js";
1
+ import { type CliTenantConfig } from "./config.js";
2
2
  export declare function resolveRuntimeRoot(configDir: string): string;
3
- export declare function resolveWorkspaceConfig(configDir: string, requestedWorkspaceId?: string): Promise<CliWorkspaceConfig | null>;
4
- export declare function orchestratorWorkspaceConfigPath(runtimeRoot: string, workspaceId: string): string;
5
- export declare function syncWorkspaceToRuntime(configDir: string, workspaceConfig: CliWorkspaceConfig): Promise<string>;
3
+ export declare function resolveTenantConfig(configDir: string, requestedTenantId?: string): Promise<CliTenantConfig | null>;
4
+ export declare function orchestratorTenantConfigPath(runtimeRoot: string, tenantId: string): string;
5
+ export declare function syncTenantToRuntime(configDir: string, tenantConfig: CliTenantConfig): Promise<string>;
@@ -1,26 +1,41 @@
1
- import { mkdir, writeFile } from "node:fs/promises";
1
+ import { copyFile, mkdir, writeFile } from "node:fs/promises";
2
2
  import { dirname, join, resolve } from "node:path";
3
- import { loadGlobalConfig, loadWorkspaceConfig, } from "./config.js";
3
+ import { loadGlobalConfig, loadTenantConfig, } from "./config.js";
4
4
  export function resolveRuntimeRoot(configDir) {
5
5
  return resolve(configDir);
6
6
  }
7
- export async function resolveWorkspaceConfig(configDir, requestedWorkspaceId) {
8
- if (requestedWorkspaceId) {
9
- return loadWorkspaceConfig(configDir, requestedWorkspaceId);
7
+ export async function resolveTenantConfig(configDir, requestedTenantId) {
8
+ if (requestedTenantId) {
9
+ return loadTenantConfig(configDir, requestedTenantId);
10
10
  }
11
11
  const global = await loadGlobalConfig(configDir);
12
- if (!global?.activeWorkspace) {
12
+ if (!global?.activeTenant) {
13
13
  return null;
14
14
  }
15
- return loadWorkspaceConfig(configDir, global.activeWorkspace);
15
+ return loadTenantConfig(configDir, global.activeTenant);
16
16
  }
17
- export function orchestratorWorkspaceConfigPath(runtimeRoot, workspaceId) {
18
- return join(runtimeRoot, "orchestrator", "workspaces", workspaceId, "config.json");
17
+ export function orchestratorTenantConfigPath(runtimeRoot, tenantId) {
18
+ return join(runtimeRoot, "orchestrator", "tenants", tenantId, "config.json");
19
19
  }
20
- export async function syncWorkspaceToRuntime(configDir, workspaceConfig) {
20
+ export async function syncTenantToRuntime(configDir, tenantConfig) {
21
21
  const runtimeRoot = resolveRuntimeRoot(configDir);
22
- const configPath = orchestratorWorkspaceConfigPath(runtimeRoot, workspaceConfig.workspaceId);
22
+ const configPath = orchestratorTenantConfigPath(runtimeRoot, tenantConfig.tenantId);
23
23
  await mkdir(dirname(configPath), { recursive: true });
24
- await writeFile(configPath, JSON.stringify(workspaceConfig, null, 2) + "\n");
24
+ await writeFile(configPath, JSON.stringify(tenantConfig, null, 2) + "\n");
25
+ // Copy tenant WORKFLOW.md to runtime if it exists
26
+ const workflowSrc = join(configDir, "tenants", tenantConfig.tenantId, "WORKFLOW.md");
27
+ const workflowDst = join(dirname(configPath), "WORKFLOW.md");
28
+ try {
29
+ await copyFile(workflowSrc, workflowDst);
30
+ }
31
+ catch (error) {
32
+ // ENOENT is expected for tenants created before WORKFLOW.md scaffolding
33
+ if (!(error &&
34
+ typeof error === "object" &&
35
+ "code" in error &&
36
+ error.code === "ENOENT")) {
37
+ throw error;
38
+ }
39
+ }
25
40
  return runtimeRoot;
26
41
  }
@@ -0,0 +1,14 @@
1
+ import type { SkillTemplate, SkillTemplateContext } from "./types.js";
2
+ export declare function resolveSkillsDir(repoRoot: string, runtime: string): string | null;
3
+ export declare function writeSkillFile(skillsDir: string, template: SkillTemplate, context: SkillTemplateContext, options?: {
4
+ overwrite?: boolean;
5
+ }): Promise<{
6
+ written: boolean;
7
+ path: string;
8
+ }>;
9
+ export declare function writeAllSkills(repoRoot: string, runtime: string, templates: SkillTemplate[], context: SkillTemplateContext, options?: {
10
+ overwrite?: boolean;
11
+ }): Promise<{
12
+ written: string[];
13
+ skipped: string[];
14
+ }>;
@@ -0,0 +1,62 @@
1
+ import { mkdir, writeFile, readFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ function normalizeRuntimeForSkills(runtime) {
4
+ if (runtime === "claude-code" || runtime.includes("claude-code")) {
5
+ return "claude-code";
6
+ }
7
+ if (runtime === "codex" || runtime.includes("codex")) {
8
+ return "codex";
9
+ }
10
+ return null;
11
+ }
12
+ export function resolveSkillsDir(repoRoot, runtime) {
13
+ const normalizedRuntime = normalizeRuntimeForSkills(runtime);
14
+ if (normalizedRuntime === "claude-code") {
15
+ return join(repoRoot, ".claude", "skills");
16
+ }
17
+ if (normalizedRuntime === "codex") {
18
+ return join(repoRoot, ".codex", "skills");
19
+ }
20
+ return null;
21
+ }
22
+ export async function writeSkillFile(skillsDir, template, context, options) {
23
+ const skillDir = join(skillsDir, template.name);
24
+ const filePath = join(skillDir, template.fileName);
25
+ if (!options?.overwrite) {
26
+ try {
27
+ await readFile(filePath, "utf8");
28
+ return { written: false, path: filePath };
29
+ }
30
+ catch (error) {
31
+ const err = error;
32
+ if (err.code !== "ENOENT") {
33
+ throw error;
34
+ }
35
+ }
36
+ }
37
+ await mkdir(skillDir, { recursive: true });
38
+ const content = template.generate(context);
39
+ const temporaryPath = `${filePath}.tmp`;
40
+ await writeFile(temporaryPath, content, "utf8");
41
+ const { rename } = await import("node:fs/promises");
42
+ await rename(temporaryPath, filePath);
43
+ return { written: true, path: filePath };
44
+ }
45
+ export async function writeAllSkills(repoRoot, runtime, templates, context, options) {
46
+ const skillsDir = resolveSkillsDir(repoRoot, runtime);
47
+ if (!skillsDir) {
48
+ return { written: [], skipped: [] };
49
+ }
50
+ const written = [];
51
+ const skipped = [];
52
+ for (const template of templates) {
53
+ const result = await writeSkillFile(skillsDir, template, context, options);
54
+ if (result.written) {
55
+ written.push(result.path);
56
+ }
57
+ else {
58
+ skipped.push(result.path);
59
+ }
60
+ }
61
+ return { written, skipped };
62
+ }
@@ -0,0 +1,2 @@
1
+ import type { SkillTemplateContext } from "../types.js";
2
+ export declare function generateCommitSkill(_ctx: SkillTemplateContext): string;