@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.
- package/dist/ansi.d.ts +15 -0
- package/dist/ansi.js +53 -0
- package/dist/commands/config-cmd.js +11 -27
- package/dist/commands/help.js +14 -6
- package/dist/commands/init.d.ts +29 -7
- package/dist/commands/init.js +292 -287
- package/dist/commands/logs.js +4 -4
- package/dist/commands/project.js +34 -34
- package/dist/commands/recover.js +14 -14
- package/dist/commands/repo.js +13 -13
- package/dist/commands/run.js +16 -16
- package/dist/commands/start.js +61 -37
- package/dist/commands/status.js +60 -63
- package/dist/commands/tenant.d.ts +3 -0
- package/dist/commands/tenant.js +402 -0
- package/dist/config.d.ts +20 -19
- package/dist/config.js +17 -17
- package/dist/context/context-types.d.ts +36 -0
- package/dist/context/context-types.js +1 -0
- package/dist/context/generate-context-yaml.d.ts +15 -0
- package/dist/context/generate-context-yaml.js +129 -0
- package/dist/dashboard/renderer.d.ts +9 -0
- package/dist/dashboard/renderer.js +220 -0
- package/dist/detection/environment-detector.d.ts +11 -0
- package/dist/detection/environment-detector.js +140 -0
- package/dist/github/client.d.ts +11 -0
- package/dist/github/client.js +59 -11
- package/dist/github/gh-auth.d.ts +34 -0
- package/dist/github/gh-auth.js +110 -0
- package/dist/index.js +1 -0
- package/dist/mapping/smart-defaults.d.ts +9 -25
- package/dist/mapping/smart-defaults.js +52 -125
- package/dist/orchestrator-runtime.d.ts +4 -4
- package/dist/orchestrator-runtime.js +27 -12
- package/dist/skills/skill-writer.d.ts +14 -0
- package/dist/skills/skill-writer.js +62 -0
- package/dist/skills/templates/commit.d.ts +2 -0
- package/dist/skills/templates/commit.js +45 -0
- package/dist/skills/templates/document.d.ts +7 -0
- package/dist/skills/templates/document.js +16 -0
- package/dist/skills/templates/gh-project.d.ts +2 -0
- package/dist/skills/templates/gh-project.js +88 -0
- package/dist/skills/templates/gh-symphony.d.ts +2 -0
- package/dist/skills/templates/gh-symphony.js +125 -0
- package/dist/skills/templates/index.d.ts +8 -0
- package/dist/skills/templates/index.js +28 -0
- package/dist/skills/templates/land.d.ts +2 -0
- package/dist/skills/templates/land.js +59 -0
- package/dist/skills/templates/pull.d.ts +2 -0
- package/dist/skills/templates/pull.js +41 -0
- package/dist/skills/templates/push.d.ts +2 -0
- package/dist/skills/templates/push.js +36 -0
- package/dist/skills/types.d.ts +23 -0
- package/dist/skills/types.js +1 -0
- package/dist/workflow/generate-reference-workflow.d.ts +9 -0
- package/dist/workflow/generate-reference-workflow.js +261 -0
- package/dist/workflow/generate-workflow-md.d.ts +12 -0
- package/dist/workflow/generate-workflow-md.js +134 -0
- package/package.json +5 -4
package/dist/github/client.js
CHANGED
|
@@ -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
|
|
109
|
+
if (!field)
|
|
99
110
|
continue;
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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 "@
|
|
2
|
-
import type {
|
|
3
|
-
export type
|
|
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:
|
|
5
|
+
role: StateRole | null;
|
|
6
6
|
confidence: "high" | "low";
|
|
7
7
|
};
|
|
8
|
-
export declare function
|
|
9
|
-
export declare function
|
|
10
|
-
export
|
|
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
|
|
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: "
|
|
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: "
|
|
9
|
-
pattern: /^(in.
|
|
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: "
|
|
13
|
-
pattern: /^(
|
|
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
|
|
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
|
|
34
|
-
return columnNames.map(
|
|
25
|
+
export function inferAllStateRoles(columnNames) {
|
|
26
|
+
return columnNames.map(inferStateRole);
|
|
35
27
|
}
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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 "
|
|
60
|
-
|
|
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 "
|
|
77
|
-
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
|
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
|
|
131
|
-
const
|
|
132
|
-
const
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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 (
|
|
143
|
-
errors.push("Missing required role: '
|
|
67
|
+
if (terminalEntries.length === 0) {
|
|
68
|
+
errors.push("Missing required role: 'terminal' — at least one state must be terminal.");
|
|
144
69
|
}
|
|
145
|
-
// Warnings
|
|
146
|
-
if (
|
|
147
|
-
warnings.push(`Multiple
|
|
148
|
-
"All will be treated as
|
|
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
|
|
1
|
+
import { type CliTenantConfig } from "./config.js";
|
|
2
2
|
export declare function resolveRuntimeRoot(configDir: string): string;
|
|
3
|
-
export declare function
|
|
4
|
-
export declare function
|
|
5
|
-
export declare function
|
|
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,
|
|
3
|
+
import { loadGlobalConfig, loadTenantConfig, } from "./config.js";
|
|
4
4
|
export function resolveRuntimeRoot(configDir) {
|
|
5
5
|
return resolve(configDir);
|
|
6
6
|
}
|
|
7
|
-
export async function
|
|
8
|
-
if (
|
|
9
|
-
return
|
|
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?.
|
|
12
|
+
if (!global?.activeTenant) {
|
|
13
13
|
return null;
|
|
14
14
|
}
|
|
15
|
-
return
|
|
15
|
+
return loadTenantConfig(configDir, global.activeTenant);
|
|
16
16
|
}
|
|
17
|
-
export function
|
|
18
|
-
return join(runtimeRoot, "orchestrator", "
|
|
17
|
+
export function orchestratorTenantConfigPath(runtimeRoot, tenantId) {
|
|
18
|
+
return join(runtimeRoot, "orchestrator", "tenants", tenantId, "config.json");
|
|
19
19
|
}
|
|
20
|
-
export async function
|
|
20
|
+
export async function syncTenantToRuntime(configDir, tenantConfig) {
|
|
21
21
|
const runtimeRoot = resolveRuntimeRoot(configDir);
|
|
22
|
-
const configPath =
|
|
22
|
+
const configPath = orchestratorTenantConfigPath(runtimeRoot, tenantConfig.tenantId);
|
|
23
23
|
await mkdir(dirname(configPath), { recursive: true });
|
|
24
|
-
await writeFile(configPath, JSON.stringify(
|
|
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
|
+
}
|