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 +2 -2
- package/skiv/IssueService.ts +257 -0
- package/skiv/Logger.ts +47 -0
- package/skiv/Worker.ts +154 -0
- package/skiv/cli.ts +107 -0
- package/skiv/commands/Command.ts +45 -0
- package/skiv/commands/InitCommand.ts +23 -0
- package/skiv/commands/IssueCommand.ts +40 -0
- package/skiv/commands/RunCommand.ts +23 -0
- package/skiv/commands/StartCommand.ts +73 -0
- package/skiv/db/index.ts +25 -0
- package/skiv/db/migrations.ts +37 -0
- package/skiv/db/schema.ts +68 -0
- package/skiv/utils.ts +34 -0
package/package.json
CHANGED
|
@@ -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
|
+
}
|
package/skiv/db/index.ts
ADDED
|
@@ -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
|
+
}
|