@nickmeriano/task 0.4.2 → 0.6.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 (43) hide show
  1. package/README.md +77 -9
  2. package/dist/cli.js +290 -19
  3. package/dist/cli.js.map +1 -1
  4. package/dist/file-store.d.ts +113 -0
  5. package/dist/file-store.d.ts.map +1 -0
  6. package/dist/file-store.js +604 -0
  7. package/dist/file-store.js.map +1 -0
  8. package/dist/index.d.ts +6 -4
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +3 -1
  11. package/dist/index.js.map +1 -1
  12. package/dist/server.d.ts.map +1 -1
  13. package/dist/server.js +30 -7
  14. package/dist/server.js.map +1 -1
  15. package/dist/store.d.ts +69 -16
  16. package/dist/store.d.ts.map +1 -1
  17. package/dist/store.js +169 -39
  18. package/dist/store.js.map +1 -1
  19. package/dist/store.test.d.ts +12 -0
  20. package/dist/store.test.d.ts.map +1 -0
  21. package/dist/store.test.js +252 -0
  22. package/dist/store.test.js.map +1 -0
  23. package/dist/ticket-doc.d.ts +57 -0
  24. package/dist/ticket-doc.d.ts.map +1 -0
  25. package/dist/ticket-doc.js +197 -0
  26. package/dist/ticket-doc.js.map +1 -0
  27. package/dist/types.d.ts +35 -3
  28. package/dist/types.d.ts.map +1 -1
  29. package/dist/types.js.map +1 -1
  30. package/package.json +4 -4
  31. package/skill/SKILL.md +40 -9
  32. package/src/cli.ts +304 -22
  33. package/src/file-store.ts +693 -0
  34. package/src/index.ts +30 -4
  35. package/src/server.ts +31 -11
  36. package/src/store.test.ts +305 -0
  37. package/src/store.ts +210 -49
  38. package/src/ticket-doc.ts +226 -0
  39. package/src/types.ts +35 -3
  40. package/ui/dist/assets/index-CXW8uT5f.css +1 -0
  41. package/ui/dist/assets/{index-Dm3ToURf.js → index-oJzomUDL.js} +67 -67
  42. package/ui/dist/index.html +2 -2
  43. package/ui/dist/assets/index-DXFbw9bM.css +0 -1
@@ -0,0 +1,693 @@
1
+ import {
2
+ existsSync,
3
+ mkdirSync,
4
+ readdirSync,
5
+ readFileSync,
6
+ renameSync,
7
+ rmSync,
8
+ writeFileSync,
9
+ } from "node:fs"
10
+ import { basename, dirname, join } from "node:path"
11
+ import {
12
+ commentStem,
13
+ parseComment,
14
+ parseTicket,
15
+ serializeComment,
16
+ serializeTicket,
17
+ type TicketDoc,
18
+ } from "./ticket-doc.ts"
19
+ import {
20
+ CONFIG_FILE,
21
+ DB_FILE,
22
+ POSITION_GAP,
23
+ TASK_DIR,
24
+ TaskStore,
25
+ derivePrefix,
26
+ type Store,
27
+ } from "./store.ts"
28
+ import type {
29
+ Comment,
30
+ ProjectConfig,
31
+ Status,
32
+ Task,
33
+ TaskFilter,
34
+ TaskInput,
35
+ TaskPatch,
36
+ } from "./types.ts"
37
+
38
+ export const TICKETS_DIR = "tickets"
39
+ /** Finished tickets moved out of the hot path — same per-ticket layout. */
40
+ export const ARCHIVE_DIR = "archive"
41
+ const TICKET_FILE = "ticket.md"
42
+ const COMMENTS_DIR = "comments"
43
+
44
+ function now(): string {
45
+ return new Date().toISOString()
46
+ }
47
+
48
+ /**
49
+ * Write-then-rename, so a reader (another CLI, the serve watcher, a crashed
50
+ * process) never sees half a ticket.
51
+ */
52
+ function writeAtomic(path: string, text: string): void {
53
+ const tmp = join(dirname(path), `.${basename(path)}.${process.pid}.tmp`)
54
+ writeFileSync(tmp, text)
55
+ renameSync(tmp, path)
56
+ }
57
+
58
+ interface Ticket {
59
+ number: number
60
+ doc: TicketDoc
61
+ }
62
+
63
+ /**
64
+ * The canonical persistence layer: one directory per ticket under
65
+ * `.task/tickets/`, holding a frontmatter+markdown `ticket.md` and one file
66
+ * per comment. Everything is plain text committed to git, which is the point —
67
+ * ticket edits diff, review, and merge like code, and two branches touching
68
+ * different tickets (or adding comments to the same one) merge cleanly by
69
+ * construction.
70
+ *
71
+ * There is no database and no cache: boards are dozens of tickets, and
72
+ * re-reading a handful of small files per operation is cheaper than opening
73
+ * SQLite ever was. If boards outgrow that, a derived (gitignored) index can be
74
+ * added without changing the format — the files stay the source of truth.
75
+ */
76
+ export class FileStore implements Store {
77
+ readonly root: string
78
+ readonly taskDir: string
79
+ readonly config: ProjectConfig
80
+ private ticketsDir: string
81
+ private archiveDir: string
82
+
83
+ constructor(root: string) {
84
+ this.root = root
85
+ this.taskDir = join(root, TASK_DIR)
86
+ this.config = JSON.parse(
87
+ readFileSync(join(this.taskDir, CONFIG_FILE), "utf8"),
88
+ ) as ProjectConfig
89
+ this.ticketsDir = join(this.taskDir, TICKETS_DIR)
90
+ this.archiveDir = join(this.taskDir, ARCHIVE_DIR)
91
+ }
92
+
93
+ close(): void {}
94
+
95
+ displayId(number: number): string {
96
+ return `${this.config.prefix}-${number}`
97
+ }
98
+
99
+ /** Accepts "PHONE-12", "phone-12" or "12". */
100
+ parseId(ref: string): number {
101
+ const match = /^(?:[A-Za-z0-9]+-)?(\d+)$/.exec(ref.trim())
102
+ if (!match) throw new Error(`invalid task id: ${ref}`)
103
+ return Number(match[1])
104
+ }
105
+
106
+ // ── Reading ────────────────────────────────────────────────────────────────
107
+
108
+ private ticketPath(number: number): string {
109
+ return join(this.ticketsDir, String(number), TICKET_FILE)
110
+ }
111
+
112
+ /**
113
+ * A ticket's comments live wherever the ticket does — the whole directory
114
+ * moves on archive, so an archived ticket's discussion stays readable.
115
+ */
116
+ private commentsPath(number: number): string {
117
+ const home = existsSync(join(this.archiveDir, String(number)))
118
+ ? this.archiveDir
119
+ : this.ticketsDir
120
+ return join(home, String(number), COMMENTS_DIR)
121
+ }
122
+
123
+ private readDir(dir: string): Ticket[] {
124
+ if (!existsSync(dir)) return []
125
+ const tickets: Ticket[] = []
126
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
127
+ if (!entry.isDirectory() || !/^\d+$/.test(entry.name)) continue
128
+ const path = join(dir, entry.name, TICKET_FILE)
129
+ if (!existsSync(path)) continue
130
+ tickets.push({ number: Number(entry.name), doc: parseTicket(readFileSync(path, "utf8"), path) })
131
+ }
132
+ tickets.sort((a, b) => a.number - b.number)
133
+ return tickets
134
+ }
135
+
136
+ /** Every live ticket on the board, freshly parsed — the files are the state. */
137
+ private readAll(): Ticket[] {
138
+ return this.readDir(this.ticketsDir)
139
+ }
140
+
141
+ private readOne(number: number): Ticket | null {
142
+ const path = this.ticketPath(number)
143
+ if (!existsSync(path)) return null
144
+ return { number, doc: parseTicket(readFileSync(path, "utf8"), path) }
145
+ }
146
+
147
+ /** Cheap presence check — the archive's numbers, without parsing anything. */
148
+ private archivedNumbers(): number[] {
149
+ if (!existsSync(this.archiveDir)) return []
150
+ return readdirSync(this.archiveDir)
151
+ .filter((name) => /^\d+$/.test(name))
152
+ .map(Number)
153
+ }
154
+
155
+ private isArchived(number: number): boolean {
156
+ return existsSync(join(this.archiveDir, String(number), TICKET_FILE))
157
+ }
158
+
159
+ /** The error every write path throws instead of touching the archive. */
160
+ private assertNotArchived(number: number): void {
161
+ if (this.isArchived(number)) {
162
+ const id = this.displayId(number)
163
+ throw new Error(`${id} is archived — run \`task unarchive ${id}\` first`)
164
+ }
165
+ }
166
+
167
+ private writeTicket(ticket: Ticket): void {
168
+ mkdirSync(dirname(this.ticketPath(ticket.number)), { recursive: true })
169
+ writeAtomic(this.ticketPath(ticket.number), serializeTicket(ticket.doc))
170
+ }
171
+
172
+ /**
173
+ * Only `blocked_by` is stored (on the blocked ticket); the `blocks` side is
174
+ * derived here, so the two views can never disagree.
175
+ */
176
+ private toTask(ticket: Ticket, all: Ticket[]): Task {
177
+ const blocks = all
178
+ .filter((t) => t.doc.blockedBy.includes(ticket.number))
179
+ .map((t) => t.number)
180
+ return {
181
+ id: this.displayId(ticket.number),
182
+ number: ticket.number,
183
+ title: ticket.doc.title,
184
+ description: ticket.doc.description,
185
+ status: ticket.doc.status,
186
+ tags: ticket.doc.tags,
187
+ milestone: ticket.doc.milestone,
188
+ needsHuman: ticket.doc.needsHuman,
189
+ blocks,
190
+ blockedBy: [...ticket.doc.blockedBy].sort((a, b) => a - b),
191
+ prs: ticket.doc.prs,
192
+ position: ticket.doc.position,
193
+ createdAt: ticket.doc.createdAt,
194
+ updatedAt: ticket.doc.updatedAt,
195
+ }
196
+ }
197
+
198
+ private applyFilter(tickets: Ticket[], filter: TaskFilter): Ticket[] {
199
+ if (filter.statuses?.length) {
200
+ const wanted = new Set<Status>(filter.statuses)
201
+ tickets = tickets.filter((t) => wanted.has(t.doc.status))
202
+ }
203
+ if (filter.milestone) {
204
+ tickets = tickets.filter((t) => t.doc.milestone === filter.milestone)
205
+ }
206
+ if (filter.needsHuman !== undefined) {
207
+ tickets = tickets.filter((t) => t.doc.needsHuman === filter.needsHuman)
208
+ }
209
+ if (filter.tags?.length) {
210
+ const wanted = new Set(filter.tags)
211
+ tickets = tickets.filter((t) => t.doc.tags.some((tag) => wanted.has(tag)))
212
+ }
213
+ return tickets
214
+ }
215
+
216
+ list(filter: TaskFilter = {}): Task[] {
217
+ if (filter.archived) {
218
+ // Relations are derived across both sets so an archived ticket still
219
+ // shows what it blocked; `archived: true` is only ever set here, so the
220
+ // board's payloads carry no new field.
221
+ const archived = this.readDir(this.archiveDir)
222
+ const everything = [...this.readAll(), ...archived]
223
+ return this.applyFilter(archived, filter)
224
+ .map((t) => ({ ...this.toTask(t, everything), archived: true }))
225
+ .sort((a, b) => a.position - b.position || a.number - b.number)
226
+ }
227
+ const all = this.readAll()
228
+ return this.applyFilter(all, filter)
229
+ .map((t) => this.toTask(t, all))
230
+ .sort((a, b) => a.position - b.position || a.number - b.number)
231
+ }
232
+
233
+ get(number: number): Task | null {
234
+ const all = this.readAll()
235
+ const ticket = all.find((t) => t.number === number)
236
+ if (ticket) return this.toTask(ticket, all)
237
+ // `show` should reach the archive without ceremony — reads are safe.
238
+ const path = join(this.archiveDir, String(number), TICKET_FILE)
239
+ if (!existsSync(path)) return null
240
+ const archived: Ticket = { number, doc: parseTicket(readFileSync(path, "utf8"), path) }
241
+ return { ...this.toTask(archived, [...all, archived]), archived: true }
242
+ }
243
+
244
+ // ── Writing ────────────────────────────────────────────────────────────────
245
+
246
+ create(input: TaskInput): Task {
247
+ const all = this.readAll()
248
+ // Archived numbers stay reserved — a new ticket must never take a number
249
+ // that old comments or PR titles still point at.
250
+ const taken = [...all.map((t) => t.number), ...this.archivedNumbers()]
251
+ const number = taken.reduce((max, n) => Math.max(max, n), 0) + 1
252
+ const status = input.status ?? "todo"
253
+ for (const target of [...(input.blocks ?? []), ...(input.blockedBy ?? [])]) {
254
+ if (!all.some((t) => t.number === target)) {
255
+ throw new Error(`no such task: ${this.displayId(target)}`)
256
+ }
257
+ }
258
+ // New tasks land at the bottom of their column.
259
+ const column = all.filter((t) => t.doc.status === status)
260
+ const bottom = column.length ? Math.max(...column.map((t) => t.doc.position)) : 0
261
+ const timestamp = now()
262
+ const ticket: Ticket = {
263
+ number,
264
+ doc: {
265
+ title: input.title,
266
+ description: input.description ?? "",
267
+ status,
268
+ tags: input.tags ?? [],
269
+ milestone: input.milestone ?? null,
270
+ needsHuman: input.needsHuman ?? false,
271
+ blockedBy: [...new Set(input.blockedBy ?? [])].sort((a, b) => a - b),
272
+ prs: input.prs ?? [],
273
+ position: bottom + POSITION_GAP,
274
+ createdAt: timestamp,
275
+ updatedAt: timestamp,
276
+ },
277
+ }
278
+ this.writeTicket(ticket)
279
+ if (input.blocks !== undefined) {
280
+ this.reconcileBlocks(number, [...all, ticket], input.blocks, timestamp)
281
+ }
282
+ return this.get(number)!
283
+ }
284
+
285
+ update(number: number, patch: TaskPatch): Task {
286
+ const all = this.readAll()
287
+ const ticket = all.find((t) => t.number === number)
288
+ if (!ticket) {
289
+ this.assertNotArchived(number)
290
+ throw new Error(`no such task: ${this.displayId(number)}`)
291
+ }
292
+
293
+ // Validate link targets before any file is written, so a bad target
294
+ // rejects the whole patch — the transactional behavior the SQLite store
295
+ // got for free.
296
+ for (const target of [...(patch.blocks ?? []), ...(patch.blockedBy ?? [])]) {
297
+ if (target === number) {
298
+ throw new Error(`a task can't block itself: ${this.displayId(number)}`)
299
+ }
300
+ if (!all.some((t) => t.number === target)) {
301
+ this.assertNotArchived(target)
302
+ throw new Error(`no such task: ${this.displayId(target)}`)
303
+ }
304
+ }
305
+
306
+ const timestamp = now()
307
+ let linksChanged = false
308
+ if (patch.blocks !== undefined) {
309
+ linksChanged = this.reconcileBlocks(number, all, patch.blocks, timestamp)
310
+ }
311
+ if (patch.blockedBy !== undefined) {
312
+ const wanted = [...new Set(patch.blockedBy)].sort((a, b) => a - b)
313
+ const current = ticket.doc.blockedBy
314
+ const touched = [
315
+ ...current.filter((n) => !wanted.includes(n)),
316
+ ...wanted.filter((n) => !current.includes(n)),
317
+ ]
318
+ if (touched.length > 0) {
319
+ linksChanged = true
320
+ ticket.doc.blockedBy = wanted
321
+ // The other end of every added or removed link gets its `updated`
322
+ // bumped too — its derived `blocks` view just changed. A dangling
323
+ // reference (hand-edit pointing at a deleted ticket) has no other end.
324
+ for (const n of touched) {
325
+ const other = all.find((t) => t.number === n)
326
+ if (!other) continue
327
+ other.doc.updatedAt = timestamp
328
+ this.writeTicket(other)
329
+ }
330
+ }
331
+ }
332
+
333
+ const doc = ticket.doc
334
+ let fieldsChanged = false
335
+ const set = <K extends keyof TicketDoc>(key: K, value: TicketDoc[K]) => {
336
+ doc[key] = value
337
+ fieldsChanged = true
338
+ }
339
+
340
+ if (patch.title !== undefined) set("title", patch.title)
341
+ if (patch.description !== undefined) set("description", patch.description)
342
+ if (patch.tags !== undefined) set("tags", patch.tags)
343
+ if (patch.milestone !== undefined) set("milestone", patch.milestone)
344
+ if (patch.needsHuman !== undefined) set("needsHuman", patch.needsHuman)
345
+ if (patch.prs !== undefined) set("prs", patch.prs)
346
+ if (patch.status !== undefined) {
347
+ const previous = doc.status
348
+ set("status", patch.status)
349
+ if (patch.position === undefined && patch.status !== previous) {
350
+ // Moved columns without an explicit slot → land on top, where the
351
+ // freshest movement is visible (Linear's behavior).
352
+ const column = all.filter((t) => t.number !== number && t.doc.status === patch.status)
353
+ const top = column.length ? Math.min(...column.map((t) => t.doc.position)) : 0
354
+ set("position", top - POSITION_GAP)
355
+ }
356
+ }
357
+ if (patch.position !== undefined) set("position", patch.position)
358
+
359
+ if (!fieldsChanged && !linksChanged) return this.toTask(ticket, all)
360
+ if (fieldsChanged || linksChanged) doc.updatedAt = timestamp
361
+ this.writeTicket(ticket)
362
+ return this.get(number)!
363
+ }
364
+
365
+ /**
366
+ * Make every other ticket's `blocked_by` agree with "this task blocks
367
+ * exactly `targets`". Returns whether anything changed; every touched ticket
368
+ * (and, via the caller, this one) gets its `updated` bumped.
369
+ */
370
+ private reconcileBlocks(
371
+ number: number,
372
+ all: Ticket[],
373
+ targets: number[],
374
+ timestamp: string,
375
+ ): boolean {
376
+ const wanted = new Set(targets)
377
+ if (wanted.has(number)) {
378
+ throw new Error(`a task can't block itself: ${this.displayId(number)}`)
379
+ }
380
+ let changed = false
381
+ for (const other of all) {
382
+ if (other.number === number) continue
383
+ const has = other.doc.blockedBy.includes(number)
384
+ const should = wanted.has(other.number)
385
+ if (has === should) continue
386
+ other.doc.blockedBy = should
387
+ ? [...other.doc.blockedBy, number].sort((a, b) => a - b)
388
+ : other.doc.blockedBy.filter((n) => n !== number)
389
+ other.doc.updatedAt = timestamp
390
+ this.writeTicket(other)
391
+ changed = true
392
+ }
393
+ if (changed) {
394
+ const self = all.find((t) => t.number === number)
395
+ if (self) {
396
+ self.doc.updatedAt = timestamp
397
+ this.writeTicket(self)
398
+ }
399
+ }
400
+ return changed
401
+ }
402
+
403
+ /** `task link A --blocks B` and friends — additive, unlike the patch form. */
404
+ link(number: number, relation: "blocks" | "blocked_by", target: number): Task {
405
+ const task = this.get(number)
406
+ if (!task) throw new Error(`no such task: ${this.displayId(number)}`)
407
+ const current = relation === "blocks" ? task.blocks : task.blockedBy
408
+ const patch: TaskPatch =
409
+ relation === "blocks"
410
+ ? { blocks: [...current, target] }
411
+ : { blockedBy: [...current, target] }
412
+ return this.update(number, patch)
413
+ }
414
+
415
+ unlink(number: number, relation: "blocks" | "blocked_by", target: number): Task {
416
+ const task = this.get(number)
417
+ if (!task) throw new Error(`no such task: ${this.displayId(number)}`)
418
+ const current = relation === "blocks" ? task.blocks : task.blockedBy
419
+ const kept = current.filter((n) => n !== target)
420
+ const patch: TaskPatch = relation === "blocks" ? { blocks: kept } : { blockedBy: kept }
421
+ return this.update(number, patch)
422
+ }
423
+
424
+ delete(number: number): void {
425
+ if (!this.readOne(number)) {
426
+ this.assertNotArchived(number)
427
+ throw new Error(`no such task: ${this.displayId(number)}`)
428
+ }
429
+ rmSync(join(this.ticketsDir, String(number)), { recursive: true })
430
+ // Links pointing at the deleted ticket go with it — the same cascade the
431
+ // relation table had, so quiet on the other tickets' `updated`.
432
+ for (const other of this.readAll()) {
433
+ if (!other.doc.blockedBy.includes(number)) continue
434
+ other.doc.blockedBy = other.doc.blockedBy.filter((n) => n !== number)
435
+ this.writeTicket(other)
436
+ }
437
+ }
438
+
439
+ // ── Archive ────────────────────────────────────────────────────────────────
440
+
441
+ /**
442
+ * Move a finished ticket's whole directory to `.task/archive/` — one rename,
443
+ * which git records as a move, so history follows the ticket. Archived
444
+ * tickets leave every hot path (`list`, the board, link derivation) but stay
445
+ * readable via `get`/`comments` and keep their number reserved forever.
446
+ * Links other tickets hold on this one are left in place: they're
447
+ * dangling-tolerant everywhere, and unarchiving puts them back in force.
448
+ */
449
+ archive(number: number): Task {
450
+ const ticket = this.readOne(number)
451
+ if (!ticket) {
452
+ if (this.isArchived(number)) {
453
+ throw new Error(`${this.displayId(number)} is already archived`)
454
+ }
455
+ throw new Error(`no such task: ${this.displayId(number)}`)
456
+ }
457
+ if (ticket.doc.status !== "done" && ticket.doc.status !== "canceled") {
458
+ throw new Error(
459
+ `${this.displayId(number)} is ${ticket.doc.status} — only done or canceled tickets can be archived`,
460
+ )
461
+ }
462
+ mkdirSync(this.archiveDir, { recursive: true })
463
+ renameSync(join(this.ticketsDir, String(number)), join(this.archiveDir, String(number)))
464
+ return this.get(number)!
465
+ }
466
+
467
+ unarchive(number: number): Task {
468
+ if (!this.isArchived(number)) {
469
+ throw new Error(
470
+ this.readOne(number)
471
+ ? `${this.displayId(number)} isn't archived`
472
+ : `no such task: ${this.displayId(number)}`,
473
+ )
474
+ }
475
+ // A checkout where everything is archived has no tickets/ at all — git
476
+ // doesn't keep empty directories.
477
+ mkdirSync(this.ticketsDir, { recursive: true })
478
+ renameSync(join(this.archiveDir, String(number)), join(this.ticketsDir, String(number)))
479
+ return this.get(number)!
480
+ }
481
+
482
+ // ── Comments ───────────────────────────────────────────────────────────────
483
+
484
+ /**
485
+ * One file per comment, append-only: two writers commenting concurrently on
486
+ * the same ticket produce two files and merge without a conflict — which is
487
+ * why they aren't lines inside `ticket.md`.
488
+ */
489
+ comments(number: number): Comment[] {
490
+ const dir = this.commentsPath(number)
491
+ if (!existsSync(dir)) return []
492
+ const comments: Comment[] = []
493
+ for (const entry of readdirSync(dir)) {
494
+ if (!entry.endsWith(".md") || entry.startsWith(".")) continue
495
+ const path = join(dir, entry)
496
+ const doc = parseComment(readFileSync(path, "utf8"), path)
497
+ comments.push({
498
+ id: entry.slice(0, -3),
499
+ taskId: this.displayId(number),
500
+ author: doc.author,
501
+ body: doc.body,
502
+ createdAt: doc.createdAt,
503
+ })
504
+ }
505
+ comments.sort(
506
+ (a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id),
507
+ )
508
+ return comments
509
+ }
510
+
511
+ addComment(number: number, body: string, author = ""): Comment {
512
+ if (!this.readOne(number)) {
513
+ this.assertNotArchived(number)
514
+ throw new Error(`no such task: ${this.displayId(number)}`)
515
+ }
516
+ const createdAt = now()
517
+ const dir = this.commentsPath(number)
518
+ mkdirSync(dir, { recursive: true })
519
+ const stem = commentStem(createdAt, author)
520
+ let id = stem
521
+ for (let n = 2; existsSync(join(dir, `${id}.md`)); n++) id = `${stem}-${n}`
522
+ writeAtomic(join(dir, `${id}.md`), serializeComment({ author, createdAt, body }))
523
+ return { id, taskId: this.displayId(number), author, body, createdAt }
524
+ }
525
+
526
+ deleteComment(number: number, commentId: string): Comment {
527
+ // Resolve the id against the directory listing rather than building a path
528
+ // from it — the id arrived over HTTP and must not be able to point
529
+ // anywhere but at an actual comment of this task.
530
+ const existing = this.comments(number).find((c) => c.id === commentId)
531
+ if (!existing) {
532
+ throw new Error(`no such comment on ${this.displayId(number)}: ${commentId}`)
533
+ }
534
+ rmSync(join(this.commentsPath(number), `${commentId}.md`))
535
+ return existing
536
+ }
537
+
538
+ commentCounts(): Map<number, number> {
539
+ const counts = new Map<number, number>()
540
+ for (const ticket of this.readAll()) {
541
+ const dir = this.commentsPath(ticket.number)
542
+ if (!existsSync(dir)) continue
543
+ const count = readdirSync(dir).filter(
544
+ (f) => f.endsWith(".md") && !f.startsWith("."),
545
+ ).length
546
+ if (count > 0) counts.set(ticket.number, count)
547
+ }
548
+ return counts
549
+ }
550
+ }
551
+
552
+ // ── Opening, initializing, migrating ─────────────────────────────────────────
553
+
554
+ /** What `.task/.gitignore` says on a text-canonical board. */
555
+ const GITIGNORE = `# Board state is the text files in tickets/ — the database (if one is still
556
+ # around from before \`task migrate\`) is derived/legacy and stays out of git.
557
+ ${DB_FILE}
558
+ *.db-journal
559
+ *.db-wal
560
+ *.db-shm
561
+ *.tmp
562
+ `
563
+
564
+ /**
565
+ * The board in `root`, on whichever backend it uses: text files when the
566
+ * `tickets/` tree exists or the config says version ≥ 2, the legacy SQLite
567
+ * store otherwise. Old boards keep working untouched until `task migrate`.
568
+ */
569
+ export function openBoard(root: string): Store {
570
+ const taskDir = join(root, TASK_DIR)
571
+ if (existsSync(join(taskDir, TICKETS_DIR))) return new FileStore(root)
572
+ const config = JSON.parse(readFileSync(join(taskDir, CONFIG_FILE), "utf8")) as ProjectConfig
573
+ if ((config.version ?? 1) >= 2) return new FileStore(root)
574
+ return new TaskStore(root)
575
+ }
576
+
577
+ export interface InitOptions {
578
+ name: string
579
+ prefix?: string
580
+ }
581
+
582
+ /**
583
+ * Create `.task/` in `root`: config and a .gitignore. New boards are
584
+ * text-canonical from the start — tickets appear under `tickets/` as they're
585
+ * created, and everything in `.task/` except the ignores belongs in git.
586
+ */
587
+ export function initProject(root: string, options: InitOptions): Store {
588
+ const taskDir = join(root, TASK_DIR)
589
+ if (existsSync(join(taskDir, CONFIG_FILE))) {
590
+ throw new Error(`already initialized: ${join(taskDir, CONFIG_FILE)} exists`)
591
+ }
592
+ mkdirSync(taskDir, { recursive: true })
593
+ const config: ProjectConfig = {
594
+ name: options.name,
595
+ prefix: options.prefix ?? derivePrefix(options.name),
596
+ version: 2,
597
+ }
598
+ writeFileSync(join(taskDir, CONFIG_FILE), `${JSON.stringify(config, null, 2)}\n`)
599
+ writeFileSync(join(taskDir, ".gitignore"), GITIGNORE)
600
+ return new FileStore(root)
601
+ }
602
+
603
+ export interface MigrateResult {
604
+ tasks: number
605
+ comments: number
606
+ }
607
+
608
+ /**
609
+ * `task migrate` — export every ticket and comment from a legacy `tasks.db`
610
+ * into the text layout, flip the config to version 2, and gitignore the
611
+ * database. The database file itself is left on disk untouched, as a backup;
612
+ * it just stops being the state.
613
+ */
614
+ export function migrateBoard(root: string): MigrateResult {
615
+ const taskDir = join(root, TASK_DIR)
616
+ if (existsSync(join(taskDir, TICKETS_DIR))) {
617
+ throw new Error("already migrated — .task/tickets/ exists")
618
+ }
619
+ if (!existsSync(join(taskDir, DB_FILE))) {
620
+ throw new Error(`nothing to migrate — no ${DB_FILE} in .task/`)
621
+ }
622
+
623
+ const legacy = new TaskStore(root)
624
+ let comments = 0
625
+ try {
626
+ const tasks = legacy.list({})
627
+ for (const task of tasks) {
628
+ const dir = join(taskDir, TICKETS_DIR, String(task.number))
629
+ mkdirSync(dir, { recursive: true })
630
+ writeFileSync(
631
+ join(dir, TICKET_FILE),
632
+ serializeTicket({
633
+ title: task.title,
634
+ description: task.description,
635
+ status: task.status,
636
+ tags: task.tags,
637
+ milestone: task.milestone,
638
+ needsHuman: task.needsHuman,
639
+ blockedBy: task.blockedBy,
640
+ prs: task.prs,
641
+ position: task.position,
642
+ createdAt: task.createdAt,
643
+ updatedAt: task.updatedAt,
644
+ }),
645
+ )
646
+ const taskComments = legacy.comments(task.number)
647
+ if (taskComments.length === 0) continue
648
+ const commentsDir = join(dir, COMMENTS_DIR)
649
+ mkdirSync(commentsDir)
650
+ let previous: { createdAt: string; id: string } | null = null
651
+ for (const comment of taskComments) {
652
+ // `comments()` sorts by (createdAt, id), so ids must sort in legacy
653
+ // (rowid) order wherever timestamps tie — and a fresh stem for a
654
+ // different author can sort *before* the previous one. Chain off the
655
+ // previous id instead: the collision suffix below then appends `-2`,
656
+ // which sorts after. The frontmatter stays authoritative for the
657
+ // author; the filename is only an ordering key.
658
+ let stem = commentStem(comment.createdAt, comment.author)
659
+ if (
660
+ previous &&
661
+ comment.createdAt === previous.createdAt &&
662
+ stem.localeCompare(previous.id) < 0
663
+ ) {
664
+ stem = previous.id
665
+ }
666
+ let id = stem
667
+ for (let n = 2; existsSync(join(commentsDir, `${id}.md`)); n++) id = `${stem}-${n}`
668
+ previous = { createdAt: comment.createdAt, id }
669
+ writeFileSync(
670
+ join(commentsDir, `${id}.md`),
671
+ serializeComment({
672
+ author: comment.author,
673
+ createdAt: comment.createdAt,
674
+ body: comment.body,
675
+ }),
676
+ )
677
+ comments++
678
+ }
679
+ }
680
+
681
+ // Preserve whatever else the config carries; only the version flips.
682
+ const config = JSON.parse(readFileSync(join(taskDir, CONFIG_FILE), "utf8")) as Record<
683
+ string,
684
+ unknown
685
+ >
686
+ config.version = 2
687
+ writeFileSync(join(taskDir, CONFIG_FILE), `${JSON.stringify(config, null, 2)}\n`)
688
+ writeFileSync(join(taskDir, ".gitignore"), GITIGNORE)
689
+ return { tasks: tasks.length, comments }
690
+ } finally {
691
+ legacy.close()
692
+ }
693
+ }