@nickmeriano/task 0.1.0
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/LICENSE +21 -0
- package/README.md +68 -0
- package/dist/author.d.ts +17 -0
- package/dist/author.d.ts.map +1 -0
- package/dist/author.js +52 -0
- package/dist/author.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +415 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -0
- package/dist/server.d.ts +14 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +292 -0
- package/dist/server.js.map +1 -0
- package/dist/store.d.ts +63 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/store.js +346 -0
- package/dist/store.js.map +1 -0
- package/dist/types.d.ts +61 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +13 -0
- package/dist/types.js.map +1 -0
- package/package.json +81 -0
- package/skill/SKILL.md +109 -0
- package/src/author.ts +57 -0
- package/src/cli.ts +419 -0
- package/src/index.ts +6 -0
- package/src/server.ts +311 -0
- package/src/store.ts +418 -0
- package/src/types.ts +75 -0
- package/ui/dist/assets/index-BopXdeSy.js +229 -0
- package/ui/dist/assets/index-Cl_P2tLU.css +1 -0
- package/ui/dist/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
- package/ui/dist/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
- package/ui/dist/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
- package/ui/dist/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
- package/ui/dist/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
- package/ui/dist/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
- package/ui/dist/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
- package/ui/dist/icon.svg +4 -0
- package/ui/dist/index.html +21 -0
package/src/store.ts
ADDED
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
import { DatabaseSync } from "node:sqlite"
|
|
2
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"
|
|
3
|
+
import { dirname, join, relative, sep } from "node:path"
|
|
4
|
+
import type {
|
|
5
|
+
Comment,
|
|
6
|
+
ProjectConfig,
|
|
7
|
+
Status,
|
|
8
|
+
Task,
|
|
9
|
+
TaskFilter,
|
|
10
|
+
TaskInput,
|
|
11
|
+
TaskPatch,
|
|
12
|
+
} from "./types.js"
|
|
13
|
+
|
|
14
|
+
export const TASK_DIR = ".task"
|
|
15
|
+
export const DB_FILE = "tasks.db"
|
|
16
|
+
export const CONFIG_FILE = "config.json"
|
|
17
|
+
|
|
18
|
+
/** Spacing between adjacent board positions — leaves room to drop between. */
|
|
19
|
+
const POSITION_GAP = 1024
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Walk up from `from` looking for a `.task/config.json`, the way git finds its
|
|
23
|
+
* `.git`. Returns the directory that *contains* `.task`, or null.
|
|
24
|
+
*/
|
|
25
|
+
export function findRoot(from: string): string | null {
|
|
26
|
+
let dir = from
|
|
27
|
+
for (;;) {
|
|
28
|
+
if (existsSync(join(dir, TASK_DIR, CONFIG_FILE))) return dir
|
|
29
|
+
const parent = dirname(dir)
|
|
30
|
+
if (parent === dir) return null
|
|
31
|
+
dir = parent
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface BoardRef {
|
|
36
|
+
/** POSIX-style path relative to the serve root — "." for the root board. */
|
|
37
|
+
id: string
|
|
38
|
+
/** Absolute directory containing `.task/`. */
|
|
39
|
+
root: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Directories that never contain a board worth serving. */
|
|
43
|
+
const SKIP_DIRS = new Set(["node_modules", "dist", "build", "out", "coverage", "target"])
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The multi-board complement of `findRoot`: walk *down* from `serveRoot`
|
|
47
|
+
* collecting every directory that holds a `.task/config.json`, so one
|
|
48
|
+
* `task serve` at a monorepo root can serve every nested board. Boards are
|
|
49
|
+
* identified by relative path — unique where names need not be. Dot-dirs,
|
|
50
|
+
* dependency/build dirs and symlinks are skipped; unreadable dirs are ignored.
|
|
51
|
+
*/
|
|
52
|
+
export function findBoards(serveRoot: string, maxDepth = 6): BoardRef[] {
|
|
53
|
+
const boards: BoardRef[] = []
|
|
54
|
+
let level = [serveRoot]
|
|
55
|
+
for (let depth = 0; depth <= maxDepth && level.length > 0; depth++) {
|
|
56
|
+
const next: string[] = []
|
|
57
|
+
for (const dir of level) {
|
|
58
|
+
if (existsSync(join(dir, TASK_DIR, CONFIG_FILE))) {
|
|
59
|
+
const rel = relative(serveRoot, dir).split(sep).join("/")
|
|
60
|
+
boards.push({ id: rel === "" ? "." : rel, root: dir })
|
|
61
|
+
}
|
|
62
|
+
let entries
|
|
63
|
+
try {
|
|
64
|
+
entries = readdirSync(dir, { withFileTypes: true })
|
|
65
|
+
} catch {
|
|
66
|
+
continue
|
|
67
|
+
}
|
|
68
|
+
for (const entry of entries) {
|
|
69
|
+
// isDirectory() is false for symlinks — that's the cycle/escape guard.
|
|
70
|
+
if (!entry.isDirectory()) continue
|
|
71
|
+
if (entry.name.startsWith(".") || SKIP_DIRS.has(entry.name)) continue
|
|
72
|
+
next.push(join(dir, entry.name))
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
level = next
|
|
76
|
+
}
|
|
77
|
+
// "." first, then lexicographic: the default board is deterministic.
|
|
78
|
+
boards.sort((a, b) => (a.id === "." ? -1 : b.id === "." ? 1 : a.id < b.id ? -1 : 1))
|
|
79
|
+
return boards
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Derive an id prefix from a project name: "phone agent" → "PHONE". */
|
|
83
|
+
export function derivePrefix(name: string): string {
|
|
84
|
+
const word = name.toUpperCase().replace(/[^A-Z0-9]+/g, " ").trim().split(" ")[0]
|
|
85
|
+
return (word || "TASK").slice(0, 10)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
interface TaskRow {
|
|
89
|
+
id: number
|
|
90
|
+
title: string
|
|
91
|
+
description: string
|
|
92
|
+
status: string
|
|
93
|
+
tags: string
|
|
94
|
+
milestone: string | null
|
|
95
|
+
needs_human: number
|
|
96
|
+
position: number
|
|
97
|
+
created_at: string
|
|
98
|
+
updated_at: string
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
interface CommentRow {
|
|
102
|
+
id: number
|
|
103
|
+
task_id: number
|
|
104
|
+
author: string
|
|
105
|
+
body: string
|
|
106
|
+
created_at: string
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function now(): string {
|
|
110
|
+
return new Date().toISOString()
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The whole persistence layer: one SQLite database inside `.task/`, opened
|
|
115
|
+
* synchronously via node:sqlite (built into Node ≥22.13 — zero dependencies,
|
|
116
|
+
* nothing to compile, safe for `npx`).
|
|
117
|
+
*/
|
|
118
|
+
export class TaskStore {
|
|
119
|
+
readonly root: string
|
|
120
|
+
readonly taskDir: string
|
|
121
|
+
readonly config: ProjectConfig
|
|
122
|
+
private db: DatabaseSync
|
|
123
|
+
|
|
124
|
+
constructor(root: string) {
|
|
125
|
+
this.root = root
|
|
126
|
+
this.taskDir = join(root, TASK_DIR)
|
|
127
|
+
this.config = JSON.parse(
|
|
128
|
+
readFileSync(join(this.taskDir, CONFIG_FILE), "utf8"),
|
|
129
|
+
) as ProjectConfig
|
|
130
|
+
this.db = new DatabaseSync(join(this.taskDir, DB_FILE))
|
|
131
|
+
// Multiple writers (CLI + serve + agents) are expected; wait for locks
|
|
132
|
+
// instead of failing fast with SQLITE_BUSY.
|
|
133
|
+
this.db.exec("PRAGMA busy_timeout = 3000")
|
|
134
|
+
this.db.exec("PRAGMA foreign_keys = ON")
|
|
135
|
+
migrate(this.db)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
close(): void {
|
|
139
|
+
this.db.close()
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
displayId(number: number): string {
|
|
143
|
+
return `${this.config.prefix}-${number}`
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Accepts "PHONE-12", "phone-12" or "12". */
|
|
147
|
+
parseId(ref: string): number {
|
|
148
|
+
const match = /^(?:[A-Za-z0-9]+-)?(\d+)$/.exec(ref.trim())
|
|
149
|
+
if (!match) throw new Error(`invalid task id: ${ref}`)
|
|
150
|
+
return Number(match[1])
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
private toTask(row: TaskRow): Task {
|
|
154
|
+
return {
|
|
155
|
+
id: this.displayId(row.id),
|
|
156
|
+
number: row.id,
|
|
157
|
+
title: row.title,
|
|
158
|
+
description: row.description,
|
|
159
|
+
status: row.status as Status,
|
|
160
|
+
tags: JSON.parse(row.tags) as string[],
|
|
161
|
+
milestone: row.milestone,
|
|
162
|
+
needsHuman: row.needs_human !== 0,
|
|
163
|
+
position: row.position,
|
|
164
|
+
createdAt: row.created_at,
|
|
165
|
+
updatedAt: row.updated_at,
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
private toComment(row: CommentRow): Comment {
|
|
170
|
+
return {
|
|
171
|
+
id: row.id,
|
|
172
|
+
taskId: this.displayId(row.task_id),
|
|
173
|
+
author: row.author,
|
|
174
|
+
body: row.body,
|
|
175
|
+
createdAt: row.created_at,
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
list(filter: TaskFilter = {}): Task[] {
|
|
180
|
+
const where: string[] = []
|
|
181
|
+
const params: (string | number)[] = []
|
|
182
|
+
if (filter.statuses?.length) {
|
|
183
|
+
where.push(`status IN (${filter.statuses.map(() => "?").join(", ")})`)
|
|
184
|
+
params.push(...filter.statuses)
|
|
185
|
+
}
|
|
186
|
+
if (filter.milestone) {
|
|
187
|
+
where.push("milestone = ?")
|
|
188
|
+
params.push(filter.milestone)
|
|
189
|
+
}
|
|
190
|
+
if (filter.needsHuman !== undefined) {
|
|
191
|
+
where.push("needs_human = ?")
|
|
192
|
+
params.push(filter.needsHuman ? 1 : 0)
|
|
193
|
+
}
|
|
194
|
+
const sql = `SELECT * FROM tasks${where.length ? ` WHERE ${where.join(" AND ")}` : ""} ORDER BY position, id`
|
|
195
|
+
let rows = this.db.prepare(sql).all(...params) as unknown as TaskRow[]
|
|
196
|
+
if (filter.tags?.length) {
|
|
197
|
+
// Tags are a JSON blob, not a table — match them in JS, any-of.
|
|
198
|
+
const wanted = new Set(filter.tags)
|
|
199
|
+
rows = rows.filter((r) => (JSON.parse(r.tags) as string[]).some((t) => wanted.has(t)))
|
|
200
|
+
}
|
|
201
|
+
return rows.map((r) => this.toTask(r))
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
get(number: number): Task | null {
|
|
205
|
+
const row = this.db.prepare("SELECT * FROM tasks WHERE id = ?").get(number) as
|
|
206
|
+
| TaskRow
|
|
207
|
+
| undefined
|
|
208
|
+
return row ? this.toTask(row) : null
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
create(input: TaskInput): Task {
|
|
212
|
+
const status = input.status ?? "todo"
|
|
213
|
+
// New tasks land at the bottom of their column.
|
|
214
|
+
const max = this.db
|
|
215
|
+
.prepare("SELECT MAX(position) AS max FROM tasks WHERE status = ?")
|
|
216
|
+
.get(status) as { max: number | null }
|
|
217
|
+
const timestamp = now()
|
|
218
|
+
const result = this.db
|
|
219
|
+
.prepare(
|
|
220
|
+
`INSERT INTO tasks (title, description, status, tags, milestone, needs_human, position, created_at, updated_at)
|
|
221
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
222
|
+
)
|
|
223
|
+
.run(
|
|
224
|
+
input.title,
|
|
225
|
+
input.description ?? "",
|
|
226
|
+
status,
|
|
227
|
+
JSON.stringify(input.tags ?? []),
|
|
228
|
+
input.milestone ?? null,
|
|
229
|
+
input.needsHuman ? 1 : 0,
|
|
230
|
+
(max.max ?? 0) + POSITION_GAP,
|
|
231
|
+
timestamp,
|
|
232
|
+
timestamp,
|
|
233
|
+
)
|
|
234
|
+
return this.get(Number(result.lastInsertRowid))!
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
update(number: number, patch: TaskPatch): Task {
|
|
238
|
+
const existing = this.get(number)
|
|
239
|
+
if (!existing) throw new Error(`no such task: ${this.displayId(number)}`)
|
|
240
|
+
|
|
241
|
+
const sets: string[] = []
|
|
242
|
+
const params: (string | number | null)[] = []
|
|
243
|
+
const set = (column: string, value: string | number | null) => {
|
|
244
|
+
sets.push(`${column} = ?`)
|
|
245
|
+
params.push(value)
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (patch.title !== undefined) set("title", patch.title)
|
|
249
|
+
if (patch.description !== undefined) set("description", patch.description)
|
|
250
|
+
if (patch.tags !== undefined) set("tags", JSON.stringify(patch.tags))
|
|
251
|
+
if (patch.milestone !== undefined) set("milestone", patch.milestone)
|
|
252
|
+
if (patch.needsHuman !== undefined) set("needs_human", patch.needsHuman ? 1 : 0)
|
|
253
|
+
if (patch.status !== undefined) {
|
|
254
|
+
set("status", patch.status)
|
|
255
|
+
if (patch.position === undefined && patch.status !== existing.status) {
|
|
256
|
+
// Moved columns without an explicit slot → land on top, where the
|
|
257
|
+
// freshest movement is visible (Linear's behavior).
|
|
258
|
+
const min = this.db
|
|
259
|
+
.prepare("SELECT MIN(position) AS min FROM tasks WHERE status = ?")
|
|
260
|
+
.get(patch.status) as { min: number | null }
|
|
261
|
+
set("position", (min.min ?? 0) - POSITION_GAP)
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
if (patch.position !== undefined) set("position", patch.position)
|
|
265
|
+
|
|
266
|
+
if (sets.length === 0) return existing
|
|
267
|
+
set("updated_at", now())
|
|
268
|
+
params.push(number)
|
|
269
|
+
this.db.prepare(`UPDATE tasks SET ${sets.join(", ")} WHERE id = ?`).run(...params)
|
|
270
|
+
return this.get(number)!
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
delete(number: number): void {
|
|
274
|
+
const result = this.db.prepare("DELETE FROM tasks WHERE id = ?").run(number)
|
|
275
|
+
if (result.changes === 0) throw new Error(`no such task: ${this.displayId(number)}`)
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
comments(number: number): Comment[] {
|
|
279
|
+
const rows = this.db
|
|
280
|
+
.prepare("SELECT * FROM comments WHERE task_id = ? ORDER BY id")
|
|
281
|
+
.all(number) as unknown as CommentRow[]
|
|
282
|
+
return rows.map((r) => this.toComment(r))
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
addComment(number: number, body: string, author = ""): Comment {
|
|
286
|
+
if (!this.get(number)) throw new Error(`no such task: ${this.displayId(number)}`)
|
|
287
|
+
const result = this.db
|
|
288
|
+
.prepare("INSERT INTO comments (task_id, author, body, created_at) VALUES (?, ?, ?, ?)")
|
|
289
|
+
.run(number, author, body, now())
|
|
290
|
+
const row = this.db
|
|
291
|
+
.prepare("SELECT * FROM comments WHERE id = ?")
|
|
292
|
+
.get(Number(result.lastInsertRowid)) as unknown as CommentRow
|
|
293
|
+
return this.toComment(row)
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
deleteComment(number: number, commentId: number): Comment {
|
|
297
|
+
const row = this.db
|
|
298
|
+
.prepare("SELECT * FROM comments WHERE id = ? AND task_id = ?")
|
|
299
|
+
.get(commentId, number) as unknown as CommentRow | undefined
|
|
300
|
+
if (!row) throw new Error(`no such comment on ${this.displayId(number)}: ${commentId}`)
|
|
301
|
+
this.db.prepare("DELETE FROM comments WHERE id = ?").run(commentId)
|
|
302
|
+
return this.toComment(row)
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
commentCounts(): Map<number, number> {
|
|
306
|
+
const rows = this.db
|
|
307
|
+
.prepare("SELECT task_id, COUNT(*) AS count FROM comments GROUP BY task_id")
|
|
308
|
+
.all() as unknown as { task_id: number; count: number }[]
|
|
309
|
+
return new Map(rows.map((r) => [r.task_id, r.count]))
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Schema history, one entry per version, applied in order from whatever
|
|
315
|
+
* `PRAGMA user_version` the file is at. `.task/tasks.db` is committed to git,
|
|
316
|
+
* so an existing database is the normal case, not the exception: every step
|
|
317
|
+
* has to be safe to run against a file someone else's checkout wrote.
|
|
318
|
+
*
|
|
319
|
+
* There is one linear path — a fresh database runs *every* step, so it lands
|
|
320
|
+
* on a schema byte-identical to a migrated one, and there's no second
|
|
321
|
+
* definition of "current" to keep in sync.
|
|
322
|
+
*/
|
|
323
|
+
const MIGRATIONS: string[] = [
|
|
324
|
+
// v1 — the original schema. `IF NOT EXISTS` is load-bearing: user_version 0
|
|
325
|
+
// means "fresh *or* written before versioning existed", and this is the step
|
|
326
|
+
// that makes both of those safe to start from.
|
|
327
|
+
//
|
|
328
|
+
// The odd indentation is deliberate and load-bearing: SQLite stores the
|
|
329
|
+
// CREATE statement *verbatim* in sqlite_master, so keeping this byte-for-byte
|
|
330
|
+
// as it originally shipped is what lets a freshly-created database and a
|
|
331
|
+
// migrated one end up with an identical schema rather than one that only
|
|
332
|
+
// matches in structure.
|
|
333
|
+
`
|
|
334
|
+
CREATE TABLE IF NOT EXISTS tasks (
|
|
335
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
336
|
+
title TEXT NOT NULL,
|
|
337
|
+
description TEXT NOT NULL DEFAULT '',
|
|
338
|
+
status TEXT NOT NULL DEFAULT 'todo',
|
|
339
|
+
priority TEXT NOT NULL DEFAULT 'none',
|
|
340
|
+
assignee TEXT,
|
|
341
|
+
labels TEXT NOT NULL DEFAULT '[]',
|
|
342
|
+
position REAL NOT NULL DEFAULT 0,
|
|
343
|
+
created_at TEXT NOT NULL,
|
|
344
|
+
updated_at TEXT NOT NULL
|
|
345
|
+
);
|
|
346
|
+
CREATE TABLE IF NOT EXISTS comments (
|
|
347
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
348
|
+
task_id INTEGER NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
|
|
349
|
+
author TEXT NOT NULL DEFAULT '',
|
|
350
|
+
body TEXT NOT NULL,
|
|
351
|
+
created_at TEXT NOT NULL
|
|
352
|
+
);
|
|
353
|
+
`,
|
|
354
|
+
|
|
355
|
+
// v2 — labels become tags, priority/assignee give way to milestone and a
|
|
356
|
+
// needs-human flag. RENAME COLUMN (SQLite 3.25+) and DROP COLUMN (3.35+) are
|
|
357
|
+
// both available on Node ≥ 22.13, which bundles 3.49.
|
|
358
|
+
//
|
|
359
|
+
// If DROP COLUMN ever weren't available the escape hatch is the usual table
|
|
360
|
+
// rebuild — but note it would need `PRAGMA foreign_keys = OFF` issued
|
|
361
|
+
// *outside* the transaction (the pragma is a no-op inside one), because
|
|
362
|
+
// comments.task_id references tasks(id).
|
|
363
|
+
`ALTER TABLE tasks RENAME COLUMN labels TO tags;
|
|
364
|
+
ALTER TABLE tasks ADD COLUMN milestone TEXT;
|
|
365
|
+
ALTER TABLE tasks ADD COLUMN needs_human INTEGER NOT NULL DEFAULT 0;
|
|
366
|
+
ALTER TABLE tasks DROP COLUMN priority;
|
|
367
|
+
ALTER TABLE tasks DROP COLUMN assignee;`,
|
|
368
|
+
]
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Bring `db` up to the newest schema. DDL and `PRAGMA user_version` are both
|
|
372
|
+
* transactional in SQLite, so a failure halfway leaves the file exactly as it
|
|
373
|
+
* was — schema *and* version.
|
|
374
|
+
*/
|
|
375
|
+
function migrate(db: DatabaseSync): void {
|
|
376
|
+
const { user_version: version } = db.prepare("PRAGMA user_version").get() as {
|
|
377
|
+
user_version: number
|
|
378
|
+
}
|
|
379
|
+
if (version >= MIGRATIONS.length) return
|
|
380
|
+
db.exec("BEGIN")
|
|
381
|
+
try {
|
|
382
|
+
for (const step of MIGRATIONS.slice(version)) db.exec(step)
|
|
383
|
+
db.exec(`PRAGMA user_version = ${MIGRATIONS.length}`)
|
|
384
|
+
db.exec("COMMIT")
|
|
385
|
+
} catch (error) {
|
|
386
|
+
db.exec("ROLLBACK")
|
|
387
|
+
throw error
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
export interface InitOptions {
|
|
392
|
+
name: string
|
|
393
|
+
prefix?: string
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Create `.task/` in `root`: config, an empty database, and a .gitignore for
|
|
398
|
+
* SQLite's transient sidecar files (the database itself is meant to be
|
|
399
|
+
* committed — that's the point).
|
|
400
|
+
*/
|
|
401
|
+
export function initProject(root: string, options: InitOptions): TaskStore {
|
|
402
|
+
const taskDir = join(root, TASK_DIR)
|
|
403
|
+
if (existsSync(join(taskDir, CONFIG_FILE))) {
|
|
404
|
+
throw new Error(`already initialized: ${join(taskDir, CONFIG_FILE)} exists`)
|
|
405
|
+
}
|
|
406
|
+
mkdirSync(taskDir, { recursive: true })
|
|
407
|
+
const config: ProjectConfig = {
|
|
408
|
+
name: options.name,
|
|
409
|
+
prefix: options.prefix ?? derivePrefix(options.name),
|
|
410
|
+
version: 1,
|
|
411
|
+
}
|
|
412
|
+
writeFileSync(join(taskDir, CONFIG_FILE), `${JSON.stringify(config, null, 2)}\n`)
|
|
413
|
+
writeFileSync(
|
|
414
|
+
join(taskDir, ".gitignore"),
|
|
415
|
+
"# SQLite transients — tasks.db itself is committed.\n*.db-journal\n*.db-wal\n*.db-shm\n",
|
|
416
|
+
)
|
|
417
|
+
return new TaskStore(root)
|
|
418
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/** The five workflow states, in board order. Fixed on purpose — a task manager
|
|
2
|
+
* you can configure is a task manager you have to configure. */
|
|
3
|
+
export const STATUSES = [
|
|
4
|
+
"backlog",
|
|
5
|
+
"todo",
|
|
6
|
+
"in_progress",
|
|
7
|
+
"done",
|
|
8
|
+
"canceled",
|
|
9
|
+
] as const
|
|
10
|
+
export type Status = (typeof STATUSES)[number]
|
|
11
|
+
|
|
12
|
+
export interface Task {
|
|
13
|
+
/** Display id — `<prefix>-<number>`, e.g. "PHONE-12". */
|
|
14
|
+
id: string
|
|
15
|
+
number: number
|
|
16
|
+
title: string
|
|
17
|
+
description: string
|
|
18
|
+
status: Status
|
|
19
|
+
tags: string[]
|
|
20
|
+
milestone: string | null
|
|
21
|
+
/** This ticket can't be finished by an agent alone. */
|
|
22
|
+
needsHuman: boolean
|
|
23
|
+
/** Sort key within a status column; smaller sorts first. */
|
|
24
|
+
position: number
|
|
25
|
+
createdAt: string
|
|
26
|
+
updatedAt: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface Comment {
|
|
30
|
+
id: number
|
|
31
|
+
taskId: string
|
|
32
|
+
author: string
|
|
33
|
+
body: string
|
|
34
|
+
createdAt: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface ProjectConfig {
|
|
38
|
+
name: string
|
|
39
|
+
/** Uppercase id prefix, e.g. "PHONE" → PHONE-1, PHONE-2, … */
|
|
40
|
+
prefix: string
|
|
41
|
+
version: number
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface TaskFilter {
|
|
45
|
+
statuses?: Status[]
|
|
46
|
+
/** Any-of: a task matches if it carries at least one of these tags. */
|
|
47
|
+
tags?: string[]
|
|
48
|
+
milestone?: string
|
|
49
|
+
/** undefined = don't filter on it at all. */
|
|
50
|
+
needsHuman?: boolean
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface TaskInput {
|
|
54
|
+
title: string
|
|
55
|
+
description?: string
|
|
56
|
+
status?: Status
|
|
57
|
+
tags?: string[]
|
|
58
|
+
milestone?: string | null
|
|
59
|
+
needsHuman?: boolean
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface TaskPatch {
|
|
63
|
+
title?: string
|
|
64
|
+
description?: string
|
|
65
|
+
status?: Status
|
|
66
|
+
tags?: string[]
|
|
67
|
+
milestone?: string | null
|
|
68
|
+
needsHuman?: boolean
|
|
69
|
+
/** Explicit board position (used by drag-and-drop in the UI). */
|
|
70
|
+
position?: number
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function isStatus(value: string): value is Status {
|
|
74
|
+
return (STATUSES as readonly string[]).includes(value)
|
|
75
|
+
}
|