@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.
Files changed (45) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +68 -0
  3. package/dist/author.d.ts +17 -0
  4. package/dist/author.d.ts.map +1 -0
  5. package/dist/author.js +52 -0
  6. package/dist/author.js.map +1 -0
  7. package/dist/cli.d.ts +3 -0
  8. package/dist/cli.d.ts.map +1 -0
  9. package/dist/cli.js +415 -0
  10. package/dist/cli.js.map +1 -0
  11. package/dist/index.d.ts +5 -0
  12. package/dist/index.d.ts.map +1 -0
  13. package/dist/index.js +7 -0
  14. package/dist/index.js.map +1 -0
  15. package/dist/server.d.ts +14 -0
  16. package/dist/server.d.ts.map +1 -0
  17. package/dist/server.js +292 -0
  18. package/dist/server.js.map +1 -0
  19. package/dist/store.d.ts +63 -0
  20. package/dist/store.d.ts.map +1 -0
  21. package/dist/store.js +346 -0
  22. package/dist/store.js.map +1 -0
  23. package/dist/types.d.ts +61 -0
  24. package/dist/types.d.ts.map +1 -0
  25. package/dist/types.js +13 -0
  26. package/dist/types.js.map +1 -0
  27. package/package.json +81 -0
  28. package/skill/SKILL.md +109 -0
  29. package/src/author.ts +57 -0
  30. package/src/cli.ts +419 -0
  31. package/src/index.ts +6 -0
  32. package/src/server.ts +311 -0
  33. package/src/store.ts +418 -0
  34. package/src/types.ts +75 -0
  35. package/ui/dist/assets/index-BopXdeSy.js +229 -0
  36. package/ui/dist/assets/index-Cl_P2tLU.css +1 -0
  37. package/ui/dist/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
  38. package/ui/dist/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
  39. package/ui/dist/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
  40. package/ui/dist/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
  41. package/ui/dist/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
  42. package/ui/dist/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
  43. package/ui/dist/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
  44. package/ui/dist/icon.svg +4 -0
  45. 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
+ }