skiv 0.0.5 → 0.0.6

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skiv",
3
- "version": "0.0.5",
3
+ "version": "0.0.6",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -22,7 +22,7 @@
22
22
  "types": "dist/esm/index.d.ts",
23
23
  "files": [
24
24
  "bin",
25
- "dist",
25
+ "skiv",
26
26
  "skeleton"
27
27
  ],
28
28
  "bin": {
@@ -0,0 +1,257 @@
1
+ import {Database} from "./db";
2
+ import {isIssuePriority, isIssueStatus, Issue, ISSUE_PRIORITIES, IssueStatus} from "./db/schema";
3
+ import {executeImmediate} from "./utils";
4
+
5
+ /**
6
+ * Provides services and operations for managing issues in the system.
7
+ */
8
+ export class IssueService {
9
+
10
+ constructor(private db: Database) {
11
+ }
12
+
13
+ /**
14
+ * Creates a new issue with the given title, priority, and optional description.
15
+ *
16
+ * @param {string} title - The title of the issue to be created. This parameter is required.
17
+ * @param {string} [priority="mid"] - The priority level of the issue. Defaults to "mid" if not provided.
18
+ * @param {string | null} description - A description of the issue. Defaults to null if not provided.
19
+ * @return {Promise<Issue>} A promise that resolves to the created Issue object.
20
+ * @throws {Error} If the title is not provided.
21
+ * @throws {Error} If the provided priority value is invalid.
22
+ * @throws {Error} If the issue creation fails.
23
+ */
24
+ public async create(title: string, priority: string = "mid", description: string | null = null): Promise<Issue> {
25
+ if (!title) {
26
+ throw new Error("title required")
27
+ }
28
+
29
+ if (!isIssuePriority(priority)) {
30
+ throw new Error(`Invalid priority: ${priority} (valid values: ${ISSUE_PRIORITIES.join(", ")})`)
31
+ }
32
+
33
+ const row = await this.db
34
+ .insertInto('issues')
35
+ .values({
36
+ status: "todo",
37
+ title,
38
+ assignee: null,
39
+ priority,
40
+ description,
41
+ created_at: new Date().toISOString(),
42
+ updated_at: new Date().toISOString()
43
+ })
44
+ .returning('id')
45
+ .executeTakeFirst()
46
+
47
+ if (!row) {
48
+ throw new Error("Failed to create issue")
49
+ }
50
+
51
+ const issue = await this.findById(row.id)
52
+ if (!issue) {
53
+ throw new Error("Failed to retrieve newly created issue")
54
+ }
55
+
56
+ return issue
57
+ }
58
+
59
+ /**
60
+ * Retrieves a list of issues filtered by their status.
61
+ *
62
+ * @param {string | undefined} status - The status to filter issues by. If undefined, retrieves all issues regardless of status.
63
+ * @return {Promise<Partial<Issue>[]>} A promise resolving to a list of issues matching the specified status.
64
+ */
65
+ public async listIssues(status?: IssueStatus): Promise<Partial<Issue>[]> {
66
+ if (status !== undefined && !isIssueStatus(status)) {
67
+ throw new Error(`Invalid status: ${status}`)
68
+ }
69
+ let query = this.db
70
+ .selectFrom('issues')
71
+ .selectAll()
72
+ if (status) {
73
+ query = query.where('status', '=', status)
74
+ }
75
+ return query
76
+ .orderBy('created_at', 'asc')
77
+ .execute()
78
+ }
79
+
80
+ /**
81
+ * Retrieves the first assigned issue for a given assignee where the issue status is 'in_progress'.
82
+ * If no assigned issue is found, it attempts to assign the next available issue.
83
+ *
84
+ * @param {string} assignee - The username or identifier of the assignee whose issue needs to be fetched.
85
+ * @param {IssueStatus} status - The status of the issue to be fetched. Defaults to 'in_progress'.
86
+ * @return {Promise<Issue | null>} A promise that resolves to the assigned issue if found, or null if no matching issue is found.
87
+ */
88
+ public async getAssignedIssue(assignee: string, status: IssueStatus): Promise<Issue | null> {
89
+ const row = await this.db.selectFrom('issues')
90
+ .select(['id'])
91
+ .where('status', '=', status)
92
+ .where('assignee', '=', assignee)
93
+ .orderBy('id', 'asc')
94
+ .executeTakeFirst()
95
+
96
+ if (!row) return null
97
+
98
+ return this.findById(row.id)
99
+ }
100
+
101
+ /**
102
+ * Retrieves the next available issue based on the current status and assigns it to the specified assignee.
103
+ * Updates the status and assignee of the issue upon retrieval.
104
+ *
105
+ * @param {IssueStatus} fromStatus - The current status of the issue to be retrieved.
106
+ * @param {IssueStatus} toStatus - The status to update the issue to after retrieval.
107
+ * @param {string} assignee - The identifier of the assignee for the issue.
108
+ * @return {Promise<Issue | null>} A promise that resolves to the next issue object or null if no such issue exists.
109
+ */
110
+ public async getNextIssue(fromStatus: IssueStatus, toStatus: IssueStatus, assignee: string): Promise<Issue | null> {
111
+
112
+ const id = await executeImmediate(this.db, async (trx) => {
113
+
114
+ let query = trx
115
+ .selectFrom('issues')
116
+ .selectAll()
117
+ .where('status', '=', fromStatus)
118
+ // .where('assignee', 'is', null)
119
+
120
+ query = query.orderBy('created_at', 'asc')
121
+
122
+ const nextIssue = await query.executeTakeFirst()
123
+
124
+ if (!nextIssue) return null
125
+
126
+ await trx.updateTable('issues')
127
+ .set({
128
+ status: toStatus,
129
+ assignee: assignee,
130
+ updated_at: new Date().toISOString()
131
+ })
132
+ .where('id', '=', nextIssue.id)
133
+ .execute()
134
+
135
+ return nextIssue.id
136
+ })
137
+
138
+ if (!id) return null
139
+ return this.findById(id)
140
+ }
141
+
142
+ /**
143
+ * Adds a comment to the specified issue.
144
+ *
145
+ * @param {number} issueId - The unique identifier of the issue to comment on.
146
+ * @param {string} by - The username or identifier of the person making the comment.
147
+ * @param {string} message - The content of the comment.
148
+ * @return {Promise<Issue>} A promise that resolves to the updated issue object with the new comment included.
149
+ */
150
+ public async comment(issueId: number, by: string, message: string): Promise<Issue> {
151
+ const issue = await this.findById(issueId)
152
+ if (!issue) {
153
+ throw new Error(`Issue #${issueId} not found`)
154
+ }
155
+
156
+ await this.db
157
+ .insertInto('comments')
158
+ .values({
159
+ issue_id: issue.id,
160
+ by,
161
+ message,
162
+ at: new Date().toISOString()
163
+ })
164
+ .execute()
165
+
166
+ const returnIssue = await this.findById(issueId)
167
+ if (!returnIssue) {
168
+ throw new Error(`Failed to retrieve issue #${issueId} after commenting`)
169
+ }
170
+
171
+ return returnIssue
172
+ }
173
+
174
+ /**
175
+ * Updates the status of an issue.
176
+ *
177
+ * @param {number} issueId - The ID of the issue to be updated.
178
+ * @param {IssueStatus} status - The new status to assign to the issue.
179
+ * @returns {Promise<Issue>} The updated issue.
180
+ * @throws {Error} If the issue is not found.
181
+ */
182
+ async updateStatus(issueId: number, status: IssueStatus): Promise<Issue> {
183
+ const issue = await this.findById(issueId)
184
+ if (!issue) {
185
+ throw new Error(`Issue #${issueId} not found`)
186
+ }
187
+
188
+ await this.db
189
+ .updateTable('issues')
190
+ .set({
191
+ status,
192
+ updated_at: new Date().toISOString()
193
+ })
194
+ .where('id', '=', issueId)
195
+ .execute()
196
+
197
+ const returnIssue = await this.findById(issueId)
198
+ if (!returnIssue) {
199
+ throw new Error(`Failed to retrieve issue #${issueId} after updating status`)
200
+ }
201
+
202
+ return returnIssue
203
+ }
204
+
205
+ /**
206
+ * Updates the assignee of a specific issue by its ID.
207
+ *
208
+ * @param {number} issueId - The unique identifier of the issue to update.
209
+ * @param {string | null} assignee - The new assignee for the issue. Pass null to unassign.
210
+ * @return {Promise<Issue>} A promise that resolves to the updated issue object.
211
+ * @throws {Error} If the issue with the given ID does not exist or cannot be retrieved after the update.
212
+ */
213
+ async updateAssignee(issueId: number, assignee: string | null): Promise<Issue> {
214
+ const issue = await this.findById(issueId)
215
+ if (!issue) {
216
+ throw new Error(`Issue #${issueId} not found`)
217
+ }
218
+
219
+ await this.db
220
+ .updateTable('issues')
221
+ .set({
222
+ assignee,
223
+ updated_at: new Date().toISOString()
224
+ })
225
+ .where('id', '=', issueId)
226
+ .execute()
227
+
228
+ const returnIssue = await this.findById(issueId)
229
+ if (!returnIssue) {
230
+ throw new Error(`Failed to retrieve issue #${issueId} after updating assignee`)
231
+ }
232
+
233
+ return returnIssue
234
+ }
235
+
236
+ /**
237
+ * Finds an issue by its unique identifier.
238
+ *
239
+ * @param {number} id - The unique identifier of the issue to retrieve.
240
+ * @return {Promise<Issue | null>} A promise that resolves to the issue with its associated comments if found, or null if no issue is found with the given identifier.
241
+ */
242
+ private async findById(id: number): Promise<Issue | null> {
243
+ const row = await this.db.selectFrom('issues')
244
+ .selectAll()
245
+ .where('id', '=', id)
246
+ .executeTakeFirst()
247
+ if (!row) return null
248
+
249
+ const comments = await this.db.selectFrom('comments')
250
+ .selectAll()
251
+ .where('issue_id', '=', id)
252
+ .orderBy('at', 'asc')
253
+ .execute()
254
+
255
+ return {...row, comments}
256
+ }
257
+ }
package/skiv/Logger.ts ADDED
@@ -0,0 +1,47 @@
1
+ const LogLevels = {
2
+ DEBUG: 0,
3
+ INFO: 1,
4
+ WARNING: 2,
5
+ ERROR: 3,
6
+ CRITICAL: 4,
7
+ } as const;
8
+
9
+ export type LogLevel = keyof typeof LogLevels;
10
+
11
+ export default class Logger {
12
+
13
+ constructor(
14
+ private level: LogLevel
15
+ ) {
16
+ }
17
+
18
+ private log(message: string | number | boolean | object, level: LogLevel) {
19
+ if (LogLevels[level] >= LogLevels[this.level]) {
20
+ console.log(message)
21
+ }
22
+ }
23
+
24
+ public debug(message: string | number | boolean | object) {
25
+ this.log(message, "DEBUG")
26
+ }
27
+
28
+ public info(message: string | number | boolean | object) {
29
+ this.log(message, "INFO")
30
+ }
31
+
32
+ public warn(message: string | number | boolean | object) {
33
+ this.log(message, "WARNING")
34
+ }
35
+
36
+ public error(message: string | number | boolean | object) {
37
+ this.log(`\x1b[31m${message}\x1b[0m`, "ERROR")
38
+ }
39
+
40
+ public critical(message: string | number | boolean | object) {
41
+ this.log(`\x1b[32m${message}\x1b[0m`, "CRITICAL")
42
+ }
43
+
44
+ public static log(message: string | number | boolean | object) {
45
+ console.log(message)
46
+ }
47
+ }
package/skiv/Worker.ts ADDED
@@ -0,0 +1,154 @@
1
+ import {spawn, SpawnOptions} from "child_process"
2
+ import fs from "fs";
3
+ import path from "path";
4
+ import {SimpleGit, simpleGit} from "simple-git";
5
+ import {Database} from "./db";
6
+ import {IssueService} from "./IssueService"
7
+ import Logger, {type LogLevel} from "./Logger";
8
+ import {clearScreen, sleep} from "./utils"
9
+
10
+ export default class Worker {
11
+
12
+ protected LOG_LEVEL: LogLevel = "INFO"
13
+
14
+ protected issueService: IssueService
15
+ protected logger: Logger
16
+ protected rootGit: SimpleGit
17
+ protected git: SimpleGit
18
+
19
+ protected NAME: string
20
+ protected WORKSPACE: string
21
+ protected WORKTREE: string
22
+ protected LOOP_INTERVAL_MSEC: number = 5000
23
+ protected MODEL: string
24
+ protected PROMPT: string
25
+
26
+ constructor(db: Database, name: string, workspace: string, model: string) {
27
+
28
+ clearScreen()
29
+ this.NAME = name
30
+ this.WORKSPACE = workspace
31
+ this.WORKTREE = path.resolve(workspace, 'worktree')
32
+ this.MODEL = model
33
+ this.PROMPT = `あなたの名前は${this.NAME}です。CLAUDE.mdに沿って処理をしてください。`
34
+
35
+ this.issueService = new IssueService(db)
36
+ this.logger = new Logger(this.LOG_LEVEL)
37
+ this.rootGit = simpleGit()
38
+
39
+ if (!fs.existsSync(this.WORKTREE)) {
40
+ fs.mkdirSync(this.WORKTREE, {recursive: true})
41
+ }
42
+ this.git = simpleGit(this.WORKTREE)
43
+
44
+ if (!this.NAME) {
45
+ throw new Error('arg 0 name required')
46
+ }
47
+ }
48
+
49
+ public async loop() {
50
+ while (true) {
51
+ // clearScreen()
52
+ this.logger.debug("LOOP START")
53
+
54
+ const beforeValue = await this.before()
55
+ if (beforeValue) {
56
+ const response = await this.execute()
57
+ await this.after(response)
58
+ }
59
+
60
+ this.logger.debug("LOOP END")
61
+ await sleep(this.LOOP_INTERVAL_MSEC)
62
+ }
63
+ }
64
+
65
+ public async before(): Promise<boolean> {
66
+ return Promise.resolve(true)
67
+ }
68
+
69
+ public async execute(): Promise<string> {
70
+ return this.spawn(
71
+ "claude",
72
+ [
73
+ "--model", this.MODEL,
74
+ "--dangerously-skip-permissions",
75
+ "-p", `"${this.PROMPT}"`
76
+ ],
77
+ {
78
+ stdio: ["inherit", "pipe", "pipe"],
79
+ }
80
+ )
81
+ }
82
+
83
+ public async after(response: string): Promise<void> {
84
+ }
85
+
86
+ protected async setupWorktree(branch: string, base: string = 'main'): Promise<void> {
87
+ this.logger.debug(`setupWorktree(${branch})`)
88
+
89
+ const branchExists = (await this.rootGit.branchLocal()).all.includes(branch)
90
+
91
+ const args = ['worktree', 'add', this.WORKTREE]
92
+
93
+ if (!branchExists) {
94
+ args.push('-B', branch, base);
95
+ } else {
96
+ args.push(branch);
97
+ }
98
+
99
+ try {
100
+ await this.rootGit.raw(args);
101
+ } catch (error: unknown) {
102
+ this.logger.error(`failed to setup worktree: ${error}`)
103
+ process.exit(1)
104
+ }
105
+ }
106
+
107
+ protected async cleanupWorktree(): Promise<void> {
108
+ this.logger.debug(`cleanupWorktree()`)
109
+ try {
110
+ await this.rootGit.raw(['worktree', 'remove', '--force', this.WORKTREE]);
111
+ } catch (e) {
112
+ // pass
113
+ }
114
+ try {
115
+ await this.rootGit.raw(['worktree', 'prune']);
116
+ fs.rmSync(this.WORKTREE, {recursive: true, force: true})
117
+ } catch (error: unknown) {
118
+ this.logger.error(`failed to cleanup worktree: ${error}`)
119
+ process.exit(1)
120
+ }
121
+ }
122
+
123
+ protected async spawn(command: string, args: string[], options: SpawnOptions = {}): Promise<string> {
124
+
125
+ return new Promise((resolve, reject) => {
126
+
127
+ Logger.log(`💻 ${command} ${args.join(' ')}`)
128
+
129
+ let stdout = ''
130
+ const child = spawn(command, args, options)
131
+
132
+ child.stdout?.on("data", (chunk) => {
133
+ stdout += chunk.toString();
134
+ this.logger.info(chunk.toString())
135
+ })
136
+
137
+ child.stderr?.on("data", (chunk) => {
138
+ this.logger.error(chunk.toString())
139
+ })
140
+
141
+ child.on("close", (code) => {
142
+ if (code === 0) {
143
+ resolve(stdout)
144
+ } else {
145
+ reject(new Error(`Process exited with code ${code}`));
146
+ }
147
+ })
148
+
149
+ child.on("error", (err) => {
150
+ reject(err);
151
+ })
152
+ })
153
+ }
154
+ }
package/skiv/cli.ts ADDED
@@ -0,0 +1,107 @@
1
+ import {program, Argument, Option} from 'commander'
2
+ import RunCommand from "./commands/RunCommand";
3
+ import InitCommand from "./commands/InitCommand";
4
+ import IssueCommand from "./commands/IssueCommand";
5
+ import StartCommand from "./commands/StartCommand";
6
+ import {ISSUE_STATUSES, IssueStatus} from './db/schema'
7
+
8
+ program
9
+ .name('skiv')
10
+ .description('Claude Code orchestration tool')
11
+ .version('0.0.1')
12
+
13
+ // init
14
+ program.command('init')
15
+ .description('Initialize skiv')
16
+ .action(async () => {
17
+ const command = new InitCommand()
18
+ await command.execute()
19
+ })
20
+
21
+ // start
22
+ program.command('start')
23
+ .description('run skiv')
24
+ .action(async () => {
25
+ const command = new StartCommand()
26
+ await command.execute()
27
+ })
28
+
29
+ // run
30
+ program.command('run')
31
+ .description('run member')
32
+ .argument('<name>', 'member name')
33
+ .argument('[model]', 'model name', 'sonnet')
34
+ .action(async (name: string, model: string) => {
35
+ const command = new RunCommand()
36
+ await command.execute(name, model)
37
+ })
38
+
39
+ // issue
40
+ const issue = program.command('issue')
41
+ .description('Issue manager')
42
+
43
+ // create
44
+ issue.command('create')
45
+ .description('create a new issue')
46
+ .argument('<title>', 'issue title')
47
+ .addArgument(
48
+ new Argument('[priority]', 'issue priority')
49
+ .choices(['low', 'mid', 'high'])
50
+ .default('mid')
51
+ )
52
+ .argument('[description]', 'spec')
53
+ .action(async (title: string, priority?: string, description?: string) => {
54
+ const command = new IssueCommand()
55
+ await command.create(title, priority, description)
56
+ })
57
+
58
+ // list
59
+ issue.command('list')
60
+ .description('list issues')
61
+ .addOption(
62
+ new Option('-s, --status <string>', 'filter by status')
63
+ .choices(ISSUE_STATUSES)
64
+ )
65
+ .action(async (options: { status?: IssueStatus }) => {
66
+ const command = new IssueCommand()
67
+ await command.list(options.status)
68
+ })
69
+
70
+ // assign
71
+ issue.command('assign')
72
+ .description('assign an issue')
73
+ .argument('<fromStatus>', 'search from status')
74
+ .argument('<toStatus>', 'change to status')
75
+ .argument('<assignee>', 'assignee name')
76
+ .action(async (fromStatus: IssueStatus, toStatus: IssueStatus, assignee: string) => {
77
+ const command = new IssueCommand()
78
+ await command.assign(fromStatus, toStatus, assignee)
79
+ })
80
+
81
+ // comment
82
+ issue.command('comment')
83
+ .description('add a comment to an issue')
84
+ .argument('<id>', 'issue id')
85
+ .argument('<by>', 'author name')
86
+ .argument('<message>', 'comment message')
87
+ .action(async (id: number, by: string, message: string) => {
88
+ const command = new IssueCommand()
89
+ await command.comment(id, by, message)
90
+ })
91
+
92
+ // update status
93
+ issue.command('update_status')
94
+ .description('update issue status')
95
+ .argument('<id>', 'issue id')
96
+ .addArgument(
97
+ new Argument('<status>', 'issue status')
98
+ .choices(ISSUE_STATUSES)
99
+ )
100
+ .action(async (id: number, status: IssueStatus) => {
101
+ const command = new IssueCommand()
102
+ await command.updateStatus(id, status)
103
+ })
104
+
105
+
106
+ // parse
107
+ program.parse()
@@ -0,0 +1,45 @@
1
+ import fs, {existsSync} from "fs"
2
+ import path, {dirname, join} from "path"
3
+ import {load} from 'js-yaml'
4
+ import {createDb, Database} from "../db"
5
+
6
+ interface ConfigInterface {
7
+ model: string
8
+ members: {
9
+ name: string
10
+ role: string
11
+ }[]
12
+ }
13
+
14
+ export default abstract class Command {
15
+
16
+ protected DIR_NAME: string = ".skiv"
17
+ protected CONFIG_FILE_NAME: string = "config.yaml"
18
+ protected DB_FILE_NAME: string = "data.db"
19
+
20
+ protected config!: ConfigInterface
21
+ protected db!: Database
22
+
23
+ protected initialize(): any {
24
+ const rootDir = this.getRootDir()
25
+ const yamlPath = path.resolve(rootDir, this.CONFIG_FILE_NAME)
26
+ const yaml = fs.readFileSync(yamlPath, 'utf-8')
27
+ this.config = load(yaml) as ConfigInterface
28
+
29
+ const dbPath = join(rootDir, this.DB_FILE_NAME)
30
+ this.db = createDb(dbPath)
31
+ }
32
+
33
+ protected getRootDir(): string {
34
+
35
+ let dir = process.cwd()
36
+
37
+ while (true) {
38
+ if (existsSync(join(dir, this.DIR_NAME))) return `${dir}/${this.DIR_NAME}`
39
+
40
+ const parent = dirname(dir)
41
+ if (parent === dir) throw new Error("root not found")
42
+ dir = parent
43
+ }
44
+ }
45
+ }
@@ -0,0 +1,23 @@
1
+ import path from "path"
2
+ import fs from "fs-extra"
3
+ import Command from "./Command"
4
+
5
+ export default class InitCommand extends Command {
6
+ public async execute() {
7
+ const cwd = process.cwd()
8
+ const dest = path.join(cwd, this.DIR_NAME)
9
+ const skeleton = path.resolve(__dirname, "../../../skeleton")
10
+
11
+ if (fs.existsSync(dest)) {
12
+ console.error(`Error: Directory ${dest} already exists.`)
13
+ return
14
+ }
15
+
16
+ try {
17
+ await fs.copy(skeleton, dest)
18
+ console.log(`Initialized skiv in ${dest}`)
19
+ } catch (err) {
20
+ console.error("Failed to initialize:", err)
21
+ }
22
+ }
23
+ }
@@ -0,0 +1,40 @@
1
+ import {IssueStatus} from "../db/schema"
2
+ import {IssueService} from "../IssueService"
3
+ import Command from "./Command"
4
+
5
+ export default class IssueCommand extends Command {
6
+
7
+ async create(title: string, priority?: string, description?: string) {
8
+ this.initialize()
9
+ const service = new IssueService(this.db)
10
+ const issue = await service.create(title, priority, description)
11
+ console.log(`issue created: ${issue.id}`)
12
+ }
13
+
14
+ async list(status?: IssueStatus) {
15
+ this.initialize()
16
+ const service = new IssueService(this.db)
17
+ const issues = await service.listIssues(status)
18
+ console.table(issues)
19
+ }
20
+
21
+ async assign(fromStatus: IssueStatus, toStatus: IssueStatus, assignee: string) {
22
+ this.initialize()
23
+ const service = new IssueService(this.db)
24
+ return service.getNextIssue(fromStatus, toStatus, assignee)
25
+ }
26
+
27
+ async comment(id: number, by: string, message: string) {
28
+ this.initialize()
29
+ const service = new IssueService(this.db)
30
+ const issue = await service.comment(id, by, message)
31
+ console.log(`commented: ${issue.id}`)
32
+ }
33
+
34
+ async updateStatus(id: number, status: IssueStatus) {
35
+ this.initialize()
36
+ const service = new IssueService(this.db)
37
+ const issue = await service.updateStatus(id, status)
38
+ console.log(`updated status: ${issue.id} ${issue.status}`)
39
+ }
40
+ }
@@ -0,0 +1,23 @@
1
+ import fs from "fs"
2
+ import path from "path"
3
+ import Worker from "../Worker"
4
+ import Command from "./Command"
5
+
6
+ export default class RunCommand extends Command {
7
+ public async execute(name: string, model: string) {
8
+ this.initialize()
9
+
10
+ const dir = this.getRootDir()
11
+ const workspaceDir = path.resolve(dir, 'workspaces', name)
12
+
13
+ const customMemberPath = path.join(workspaceDir, 'custom.ts')
14
+
15
+ const actor = fs.existsSync(customMemberPath)
16
+ ? new ((await import(customMemberPath)).default as typeof Worker)(this.db, name, workspaceDir, model)
17
+ : new Worker(this.db, name, workspaceDir, model)
18
+
19
+ actor.loop()
20
+ .catch(e => console.error(e))
21
+ .then(() => process.exit(0))
22
+ }
23
+ }
@@ -0,0 +1,73 @@
1
+ import fs from "fs"
2
+ import {execSync} from "child_process"
3
+ import path from "path"
4
+ import {migrateToLatest} from "../db"
5
+ import {sendKeys} from "../utils"
6
+ import Command from "./Command"
7
+
8
+ export default class StartCommand extends Command {
9
+
10
+ /**
11
+ * Executes the primary workflow, including initializing resources, migrating the database to the latest schema,
12
+ * setting up the tmux grid, and configuring individual workspaces for team members.
13
+ *
14
+ * @return {Promise<void>} A promise that resolves when the execution of the workflow is completed.
15
+ */
16
+ public async execute(): Promise<void> {
17
+ this.initialize()
18
+ await migrateToLatest(this.db)
19
+
20
+ const dir = this.getRootDir()
21
+
22
+ const memberCount = this.config.members?.length || 0
23
+ const model = this.config.model || 'sonnet'
24
+
25
+ this.setupTmuxGrid(1 + memberCount)
26
+
27
+ const seWorkspace = this.createWorkspace(dir, `SE`, `se`)
28
+ sendKeys(0, ["(", "cd", seWorkspace, "&&", "claude", "--model", model, ")"])
29
+
30
+ for (const member of this.config.members) {
31
+ const index = this.config.members.indexOf(member)
32
+ const pane = index + 1
33
+ const workspace = this.createWorkspace(dir, member.name, member.role)
34
+ sendKeys(pane, ["(", "cd", workspace, "&&", "npx", "skiv", "run", member.name, model, ")"])
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Creates a new workspace by copying role-specific files to the workspace directory.
40
+ *
41
+ * @param {string} rootDir - The root directory of the project.
42
+ * @param {string} name - The name of the workspace to be created.
43
+ * @param {string} role - The role associated with the workspace, used to determine the base template.
44
+ * @return {string} The path to the created workspace directory.
45
+ */
46
+ private createWorkspace(rootDir: string, name: string, role: string): string {
47
+ const workspaceDir = path.join(rootDir, 'workspaces', name)
48
+ const roleDir = path.join(rootDir, 'roles', role)
49
+
50
+ if (fs.existsSync(workspaceDir)) return workspaceDir
51
+
52
+ execSync(`cp -Rp ${roleDir} ${workspaceDir}`)
53
+
54
+ return workspaceDir
55
+ }
56
+
57
+ /**
58
+ * Sets up a tmux grid layout by splitting the tmux window vertically multiple times,
59
+ * arranging the panes in a main-horizontal layout, and adjusting the main pane's height.
60
+ *
61
+ * @param {number} paneCount - The total number of panes to create in the tmux grid.
62
+ * @return {void} No return value.
63
+ */
64
+ private setupTmuxGrid(paneCount: number): void {
65
+ for (let i = 1; i < paneCount; i++) {
66
+ execSync('tmux split-window -f -v')
67
+ }
68
+
69
+ execSync('tmux select-layout main-horizontal')
70
+ execSync('tmux set-window-option main-pane-height 40%')
71
+ execSync('tmux select-pane -t 0')
72
+ }
73
+ }
@@ -0,0 +1,25 @@
1
+ import {Kysely, Migrator, SqliteDialect} from 'kysely'
2
+ import SQLite from 'better-sqlite3'
3
+ import {DatabaseSchema} from './schema'
4
+ import {migrationProvider} from './migrations'
5
+
6
+ export const createDb = (path: string): Database => {
7
+
8
+ const db = new SQLite(path);
9
+ db.pragma('journal_mode = WAL');
10
+ db.pragma('busy_timeout = 5000');
11
+
12
+ return new Kysely<DatabaseSchema>({
13
+ dialect: new SqliteDialect({
14
+ database: db,
15
+ }),
16
+ })
17
+ }
18
+
19
+ export const migrateToLatest = async (db: Database) => {
20
+ const migrator = new Migrator({db, provider: migrationProvider})
21
+ const {error} = await migrator.migrateToLatest()
22
+ if (error) throw error
23
+ }
24
+
25
+ export type Database = Kysely<DatabaseSchema>
@@ -0,0 +1,37 @@
1
+ import {Kysely, Migration, MigrationProvider} from 'kysely'
2
+
3
+ const migrations: Record<string, Migration> = {}
4
+
5
+ export const migrationProvider: MigrationProvider = {
6
+ async getMigrations() {
7
+ return migrations
8
+ },
9
+ }
10
+
11
+ migrations['001'] = {
12
+ async up(db: Kysely<unknown>) {
13
+ await db.schema
14
+ .createTable('issues')
15
+ .addColumn('id', 'integer', (col) => col.primaryKey().autoIncrement())
16
+ .addColumn('status', 'varchar(255)', (col) => col.notNull())
17
+ .addColumn('title', 'varchar(255)', (col) => col.notNull())
18
+ .addColumn('assignee', 'varchar(255)')
19
+ .addColumn('priority', 'varchar(255)')
20
+ .addColumn('description', 'text')
21
+ .addColumn('created_at', 'timestamptz', (col) => col.notNull())
22
+ .addColumn('updated_at', 'timestamptz', (col) => col.notNull())
23
+ .execute()
24
+ await db.schema
25
+ .createTable('comments')
26
+ .addColumn('id', 'integer', (col) => col.primaryKey().autoIncrement())
27
+ .addColumn('issue_id', 'integer', (col) => col.notNull().references('issues.id').onDelete('cascade'))
28
+ .addColumn('by', 'varchar(255)', (col) => col.notNull())
29
+ .addColumn('message', 'text', (col) => col.notNull())
30
+ .addColumn('at', 'timestamptz', (col) => col.notNull())
31
+ .execute()
32
+ },
33
+ async down(db: Kysely<unknown>) {
34
+ await db.schema.dropTable('issues').execute()
35
+ await db.schema.dropTable('comments').execute()
36
+ },
37
+ }
@@ -0,0 +1,68 @@
1
+ import {Generated} from "kysely";
2
+
3
+ export type DatabaseSchema = {
4
+ issues: IssueTable,
5
+ comments: CommentTable
6
+ }
7
+
8
+ export const ISSUE_STATUSES = [
9
+ "todo",
10
+ "in_progress",
11
+ "pending_review",
12
+ "reviewing",
13
+ "approved",
14
+ "merging",
15
+ "done",
16
+ "failed",
17
+ ] as const
18
+ export type IssueStatus = typeof ISSUE_STATUSES[number]
19
+ export function isIssueStatus(value: unknown): value is IssueStatus {
20
+ return typeof value === "string" && ISSUE_STATUSES.includes(value as IssueStatus)
21
+ }
22
+
23
+ export const ISSUE_PRIORITIES = [
24
+ "low",
25
+ "mid",
26
+ "high",
27
+ ] as const
28
+ export type IssuePriority = typeof ISSUE_PRIORITIES[number]
29
+ export function isIssuePriority(value: unknown): value is IssuePriority {
30
+ return typeof value === "string" && ISSUE_PRIORITIES.includes(value as IssuePriority)
31
+ }
32
+
33
+ export type IssueTable = {
34
+ id: Generated<number>
35
+ status: IssueStatus
36
+ title: string
37
+ assignee: string | null
38
+ priority: IssuePriority | null
39
+ description: string | null
40
+ created_at: string
41
+ updated_at: string
42
+ }
43
+
44
+ export type CommentTable = {
45
+ id: Generated<number>
46
+ issue_id: number
47
+ by: string
48
+ message: string
49
+ at: string
50
+ }
51
+
52
+ export type Issue = {
53
+ id: number
54
+ status: IssueStatus
55
+ title: string
56
+ assignee: string | null
57
+ priority: IssuePriority | null
58
+ description: string | null
59
+ comments?: {
60
+ id: number
61
+ issue_id: number
62
+ by: string
63
+ message: string
64
+ at: string
65
+ }[]
66
+ created_at: string
67
+ updated_at: string
68
+ }
package/skiv/utils.ts ADDED
@@ -0,0 +1,34 @@
1
+ import {Kysely, sql} from "kysely";
2
+ import {execSync} from "child_process";
3
+ import {DatabaseSchema} from "./db/schema";
4
+
5
+ export const clearScreen = () => process.stdout.write('\x1Bc');
6
+
7
+ export function sendKeys(pane: number, command: string[]) {
8
+ const cmd = command.map(c => c.replaceAll(/"/g, '\\"')).join(' ')
9
+ execSync(`tmux send-keys -t ${pane} " ${cmd}" Enter`)
10
+ }
11
+
12
+ export function sleep(ms: number) {
13
+ return new Promise(r => setTimeout(r, ms))
14
+ }
15
+
16
+ export async function executeImmediate<T>(
17
+ db: Kysely<DatabaseSchema>,
18
+ callback: (trx: Kysely<DatabaseSchema>) => Promise<T>
19
+ ): Promise<T> {
20
+ // 1. Kyselyのインスタンスから直接 SQL を発行して BEGIN IMMEDIATE を開始
21
+ await sql`BEGIN IMMEDIATE`.execute(db);
22
+
23
+ try {
24
+ // 2. db インスタンスをそのまま callback に渡す
25
+ // (Kyselyの transaction ではないので、ネストエラーは出ない)
26
+ const result = await callback(db);
27
+
28
+ await sql`COMMIT`.execute(db);
29
+ return result;
30
+ } catch (error) {
31
+ await sql`ROLLBACK`.execute(db);
32
+ throw error;
33
+ }
34
+ }