@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
package/src/store.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { DatabaseSync } from "node:sqlite"
2
- import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"
2
+ import { existsSync, readdirSync, readFileSync } from "node:fs"
3
3
  import { dirname, join, relative, sep } from "node:path"
4
4
  import type {
5
5
  Comment,
@@ -9,14 +9,43 @@ import type {
9
9
  TaskFilter,
10
10
  TaskInput,
11
11
  TaskPatch,
12
- } from "./types.js"
12
+ } from "./types.ts"
13
13
 
14
14
  export const TASK_DIR = ".task"
15
15
  export const DB_FILE = "tasks.db"
16
16
  export const CONFIG_FILE = "config.json"
17
17
 
18
18
  /** Spacing between adjacent board positions — leaves room to drop between. */
19
- const POSITION_GAP = 1024
19
+ export const POSITION_GAP = 1024
20
+
21
+ /**
22
+ * What a board's persistence layer looks like, whichever representation backs
23
+ * it. Two implementations: `FileStore` (text files in `.task/tickets/`, the
24
+ * canonical format) and the legacy `TaskStore` (a committed SQLite database),
25
+ * which survives so un-migrated boards keep working. `openBoard` picks.
26
+ */
27
+ export interface Store {
28
+ readonly root: string
29
+ readonly taskDir: string
30
+ readonly config: ProjectConfig
31
+ close(): void
32
+ displayId(number: number): string
33
+ parseId(ref: string): number
34
+ list(filter?: TaskFilter): Task[]
35
+ get(number: number): Task | null
36
+ create(input: TaskInput): Task
37
+ update(number: number, patch: TaskPatch): Task
38
+ link(number: number, relation: "blocks" | "blocked_by", target: number): Task
39
+ unlink(number: number, relation: "blocks" | "blocked_by", target: number): Task
40
+ delete(number: number): void
41
+ /** Move a done/canceled ticket to `.task/archive/`, out of the board's hot path. */
42
+ archive(number: number): Task
43
+ unarchive(number: number): Task
44
+ comments(number: number): Comment[]
45
+ addComment(number: number, body: string, author?: string): Comment
46
+ deleteComment(number: number, commentId: string): Comment
47
+ commentCounts(): Map<number, number>
48
+ }
20
49
 
21
50
  /**
22
51
  * Walk up from `from` looking for a `.task/config.json`, the way git finds its
@@ -39,6 +68,46 @@ export interface BoardRef {
39
68
  root: string
40
69
  }
41
70
 
71
+ /**
72
+ * The widest board scope `from` belongs to: the *outermost* ancestor holding a
73
+ * `.task/` (a monorepo's root board), or `from` itself when no ancestor has
74
+ * one. Where `findRoot` answers "which board am I on", this answers "which
75
+ * repo of boards am I in" — the anchor for anything cross-board: prefix
76
+ * routing and `task boards`.
77
+ */
78
+ export function findScopeRoot(from: string): string {
79
+ let dir = from
80
+ let top: string | null = null
81
+ for (;;) {
82
+ if (existsSync(join(dir, TASK_DIR, CONFIG_FILE))) top = dir
83
+ const parent = dirname(dir)
84
+ if (parent === dir) break
85
+ dir = parent
86
+ }
87
+ return top ?? from
88
+ }
89
+
90
+ /** A board's config, read without opening a store. Throws on a broken file. */
91
+ export function boardConfig(root: string): ProjectConfig {
92
+ return JSON.parse(readFileSync(join(root, TASK_DIR, CONFIG_FILE), "utf8")) as ProjectConfig
93
+ }
94
+
95
+ /**
96
+ * Every board in scope whose prefix is `prefix` (case-insensitive). Normally
97
+ * zero or one — two boards sharing a prefix is a repo mistake this surfaces
98
+ * rather than resolves. Boards with unreadable configs can't match.
99
+ */
100
+ export function findBoardsByPrefix(scopeRoot: string, prefix: string): BoardRef[] {
101
+ const wanted = prefix.toUpperCase()
102
+ return findBoards(scopeRoot).filter((ref) => {
103
+ try {
104
+ return boardConfig(ref.root).prefix.toUpperCase() === wanted
105
+ } catch {
106
+ return false
107
+ }
108
+ })
109
+ }
110
+
42
111
  /** Directories that never contain a board worth serving. */
43
112
  const SKIP_DIRS = new Set(["node_modules", "dist", "build", "out", "coverage", "target"])
44
113
 
@@ -79,10 +148,10 @@ export function findBoards(serveRoot: string, maxDepth = 6): BoardRef[] {
79
148
  return boards
80
149
  }
81
150
 
82
- /** Derive an id prefix from a project name: "phone agent" → "PHONE". */
151
+ /** Derive an id prefix from a project name: "phone agent" → "PHO". */
83
152
  export function derivePrefix(name: string): string {
84
153
  const word = name.toUpperCase().replace(/[^A-Z0-9]+/g, " ").trim().split(" ")[0]
85
- return (word || "TASK").slice(0, 10)
154
+ return (word || "TAS").slice(0, 3)
86
155
  }
87
156
 
88
157
  interface TaskRow {
@@ -93,6 +162,7 @@ interface TaskRow {
93
162
  tags: string
94
163
  milestone: string | null
95
164
  needs_human: number
165
+ prs: string
96
166
  position: number
97
167
  created_at: string
98
168
  updated_at: string
@@ -111,11 +181,12 @@ function now(): string {
111
181
  }
112
182
 
113
183
  /**
114
- * The whole persistence layer: one SQLite database inside `.task/`, opened
184
+ * The legacy persistence layer: one SQLite database inside `.task/`, opened
115
185
  * synchronously via node:sqlite (built into Node ≥22.13 — zero dependencies,
116
- * nothing to compile, safe for `npx`).
186
+ * nothing to compile, safe for `npx`). Boards created before the text format
187
+ * still run on this until `task migrate` moves them over.
117
188
  */
118
- export class TaskStore {
189
+ export class TaskStore implements Store {
119
190
  readonly root: string
120
191
  readonly taskDir: string
121
192
  readonly config: ProjectConfig
@@ -160,15 +231,27 @@ export class TaskStore {
160
231
  tags: JSON.parse(row.tags) as string[],
161
232
  milestone: row.milestone,
162
233
  needsHuman: row.needs_human !== 0,
234
+ blocks: this.linkedNumbers("blocker_id", row.id),
235
+ blockedBy: this.linkedNumbers("blocked_id", row.id),
236
+ prs: JSON.parse(row.prs) as string[],
163
237
  position: row.position,
164
238
  createdAt: row.created_at,
165
239
  updatedAt: row.updated_at,
166
240
  }
167
241
  }
168
242
 
243
+ /** The other end of every link where `column` is this task. */
244
+ private linkedNumbers(column: "blocker_id" | "blocked_id", number: number): number[] {
245
+ const other = column === "blocker_id" ? "blocked_id" : "blocker_id"
246
+ const rows = this.db
247
+ .prepare(`SELECT ${other} AS n FROM task_links WHERE ${column} = ? ORDER BY ${other}`)
248
+ .all(number) as unknown as { n: number }[]
249
+ return rows.map((r) => r.n)
250
+ }
251
+
169
252
  private toComment(row: CommentRow): Comment {
170
253
  return {
171
- id: row.id,
254
+ id: String(row.id),
172
255
  taskId: this.displayId(row.task_id),
173
256
  author: row.author,
174
257
  body: row.body,
@@ -177,6 +260,9 @@ export class TaskStore {
177
260
  }
178
261
 
179
262
  list(filter: TaskFilter = {}): Task[] {
263
+ if (filter.archived) {
264
+ throw new Error("archiving needs a text-format board — run `task migrate` first")
265
+ }
180
266
  const where: string[] = []
181
267
  const params: (string | number)[] = []
182
268
  if (filter.statuses?.length) {
@@ -217,8 +303,8 @@ export class TaskStore {
217
303
  const timestamp = now()
218
304
  const result = this.db
219
305
  .prepare(
220
- `INSERT INTO tasks (title, description, status, tags, milestone, needs_human, position, created_at, updated_at)
221
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
306
+ `INSERT INTO tasks (title, description, status, tags, milestone, needs_human, prs, position, created_at, updated_at)
307
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
222
308
  )
223
309
  .run(
224
310
  input.title,
@@ -227,17 +313,31 @@ export class TaskStore {
227
313
  JSON.stringify(input.tags ?? []),
228
314
  input.milestone ?? null,
229
315
  input.needsHuman ? 1 : 0,
316
+ JSON.stringify(input.prs ?? []),
230
317
  (max.max ?? 0) + POSITION_GAP,
231
318
  timestamp,
232
319
  timestamp,
233
320
  )
234
- return this.get(Number(result.lastInsertRowid))!
321
+ const number = Number(result.lastInsertRowid)
322
+ if (input.blocks !== undefined) this.reconcileLinks("blocker_id", number, input.blocks)
323
+ if (input.blockedBy !== undefined) this.reconcileLinks("blocked_id", number, input.blockedBy)
324
+ return this.get(number)!
235
325
  }
236
326
 
237
327
  update(number: number, patch: TaskPatch): Task {
238
328
  const existing = this.get(number)
239
329
  if (!existing) throw new Error(`no such task: ${this.displayId(number)}`)
240
330
 
331
+ // Reconciled outside the UPDATE — links live in their own table. Done
332
+ // first so a bad target rejects the whole patch before anything writes.
333
+ let linksChanged = false
334
+ if (patch.blocks !== undefined) {
335
+ linksChanged = this.reconcileLinks("blocker_id", number, patch.blocks)
336
+ }
337
+ if (patch.blockedBy !== undefined) {
338
+ linksChanged = this.reconcileLinks("blocked_id", number, patch.blockedBy) || linksChanged
339
+ }
340
+
241
341
  const sets: string[] = []
242
342
  const params: (string | number | null)[] = []
243
343
  const set = (column: string, value: string | number | null) => {
@@ -250,6 +350,7 @@ export class TaskStore {
250
350
  if (patch.tags !== undefined) set("tags", JSON.stringify(patch.tags))
251
351
  if (patch.milestone !== undefined) set("milestone", patch.milestone)
252
352
  if (patch.needsHuman !== undefined) set("needs_human", patch.needsHuman ? 1 : 0)
353
+ if (patch.prs !== undefined) set("prs", JSON.stringify(patch.prs))
253
354
  if (patch.status !== undefined) {
254
355
  set("status", patch.status)
255
356
  if (patch.position === undefined && patch.status !== existing.status) {
@@ -263,10 +364,73 @@ export class TaskStore {
263
364
  }
264
365
  if (patch.position !== undefined) set("position", patch.position)
265
366
 
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)
367
+ if (sets.length === 0 && !linksChanged) return existing
368
+ if (sets.length > 0) {
369
+ set("updated_at", now())
370
+ params.push(number)
371
+ this.db.prepare(`UPDATE tasks SET ${sets.join(", ")} WHERE id = ?`).run(...params)
372
+ }
373
+ return this.get(number)!
374
+ }
375
+
376
+ /**
377
+ * Make the link table agree with `targets` for one side of one task —
378
+ * `blocker_id` when setting what `number` blocks, `blocked_id` when setting
379
+ * what blocks it. The other task's view is the same row read from the other
380
+ * end, so there is no second write to keep consistent. Every task touched
381
+ * (both ends of every added or removed link) gets its `updated_at` bumped.
382
+ */
383
+ private reconcileLinks(
384
+ column: "blocker_id" | "blocked_id",
385
+ number: number,
386
+ targets: number[],
387
+ ): boolean {
388
+ const other = column === "blocker_id" ? "blocked_id" : "blocker_id"
389
+ const wanted = [...new Set(targets)]
390
+ for (const target of wanted) {
391
+ if (target === number) {
392
+ throw new Error(`a task can't block itself: ${this.displayId(number)}`)
393
+ }
394
+ if (!this.db.prepare("SELECT id FROM tasks WHERE id = ?").get(target)) {
395
+ throw new Error(`no such task: ${this.displayId(target)}`)
396
+ }
397
+ }
398
+ const current = new Set(this.linkedNumbers(column, number))
399
+ const removed = [...current].filter((n) => !wanted.includes(n))
400
+ const added = wanted.filter((n) => !current.has(n))
401
+ for (const n of removed) {
402
+ this.db
403
+ .prepare(`DELETE FROM task_links WHERE ${column} = ? AND ${other} = ?`)
404
+ .run(number, n)
405
+ }
406
+ for (const n of added) {
407
+ this.db
408
+ .prepare(`INSERT OR IGNORE INTO task_links (${column}, ${other}) VALUES (?, ?)`)
409
+ .run(number, n)
410
+ }
411
+ const touched = [...removed, ...added]
412
+ if (touched.length > 0) {
413
+ const timestamp = now()
414
+ const bump = this.db.prepare("UPDATE tasks SET updated_at = ? WHERE id = ?")
415
+ for (const n of [...touched, number]) bump.run(timestamp, n)
416
+ }
417
+ return touched.length > 0
418
+ }
419
+
420
+ /** `task link A --blocks B` and friends — additive, unlike the patch form. */
421
+ link(number: number, relation: "blocks" | "blocked_by", target: number): Task {
422
+ if (!this.get(number)) throw new Error(`no such task: ${this.displayId(number)}`)
423
+ const column = relation === "blocks" ? "blocker_id" : "blocked_id"
424
+ const current = this.linkedNumbers(column, number)
425
+ this.reconcileLinks(column, number, [...current, target])
426
+ return this.get(number)!
427
+ }
428
+
429
+ unlink(number: number, relation: "blocks" | "blocked_by", target: number): Task {
430
+ if (!this.get(number)) throw new Error(`no such task: ${this.displayId(number)}`)
431
+ const column = relation === "blocks" ? "blocker_id" : "blocked_id"
432
+ const current = this.linkedNumbers(column, number)
433
+ this.reconcileLinks(column, number, current.filter((n) => n !== target))
270
434
  return this.get(number)!
271
435
  }
272
436
 
@@ -275,6 +439,15 @@ export class TaskStore {
275
439
  if (result.changes === 0) throw new Error(`no such task: ${this.displayId(number)}`)
276
440
  }
277
441
 
442
+ /** The archive is a text-format feature — a directory move, not a table. */
443
+ archive(_number: number): Task {
444
+ throw new Error("archiving needs a text-format board — run `task migrate` first")
445
+ }
446
+
447
+ unarchive(_number: number): Task {
448
+ throw new Error("archiving needs a text-format board — run `task migrate` first")
449
+ }
450
+
278
451
  comments(number: number): Comment[] {
279
452
  const rows = this.db
280
453
  .prepare("SELECT * FROM comments WHERE task_id = ? ORDER BY id")
@@ -293,12 +466,17 @@ export class TaskStore {
293
466
  return this.toComment(row)
294
467
  }
295
468
 
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
469
+ deleteComment(number: number, commentId: string): Comment {
470
+ const rowid = Number(commentId)
471
+ const row = (
472
+ Number.isInteger(rowid)
473
+ ? this.db
474
+ .prepare("SELECT * FROM comments WHERE id = ? AND task_id = ?")
475
+ .get(rowid, number)
476
+ : undefined
477
+ ) as CommentRow | undefined
300
478
  if (!row) throw new Error(`no such comment on ${this.displayId(number)}: ${commentId}`)
301
- this.db.prepare("DELETE FROM comments WHERE id = ?").run(commentId)
479
+ this.db.prepare("DELETE FROM comments WHERE id = ?").run(rowid)
302
480
  return this.toComment(row)
303
481
  }
304
482
 
@@ -365,6 +543,17 @@ const MIGRATIONS: string[] = [
365
543
  ALTER TABLE tasks ADD COLUMN needs_human INTEGER NOT NULL DEFAULT 0;
366
544
  ALTER TABLE tasks DROP COLUMN priority;
367
545
  ALTER TABLE tasks DROP COLUMN assignee;`,
546
+
547
+ // v3 — dependencies and pull requests. One direction of "blocks" is the
548
+ // whole relation: (blocker, blocked) read from the blocked side is
549
+ // "blocked by", so the mirror is a query, not a second row. PRs are a JSON
550
+ // list of URLs, same shape as tags.
551
+ `CREATE TABLE task_links (
552
+ blocker_id INTEGER NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
553
+ blocked_id INTEGER NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
554
+ PRIMARY KEY (blocker_id, blocked_id)
555
+ );
556
+ ALTER TABLE tasks ADD COLUMN prs TEXT NOT NULL DEFAULT '[]';`,
368
557
  ]
369
558
 
370
559
  /**
@@ -388,31 +577,3 @@ function migrate(db: DatabaseSync): void {
388
577
  }
389
578
  }
390
579
 
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
- }
@@ -0,0 +1,226 @@
1
+ /**
2
+ * The text format a board is made of: one markdown file per ticket, one per
3
+ * comment, each carrying a small frontmatter block. This module is pure
4
+ * (de)serialization — no filesystem — so the store, the migration, and the
5
+ * hosted board's worker can all agree on bytes by construction.
6
+ *
7
+ * The frontmatter is a deliberately tiny YAML subset: every value on one line,
8
+ * written either bare (timestamps, statuses) or as JSON (strings that need
9
+ * quoting, arrays, numbers, booleans). JSON is valid YAML, so the files render
10
+ * as ordinary frontmatter on GitHub while the parser stays `JSON.parse` plus a
11
+ * bare-string fallback — no dependency, no ambiguity.
12
+ *
13
+ * The ticket's title is the body's first `# ` heading and the description is
14
+ * everything after it. That keeps the whole file readable as a document — a PR
15
+ * diff of a new ticket *is* the ticket.
16
+ */
17
+
18
+ import { STATUSES, isStatus, type Status } from "./types.ts"
19
+
20
+ /** Everything `ticket.md` stores. Relations live on the blocked side only. */
21
+ export interface TicketDoc {
22
+ title: string
23
+ description: string
24
+ status: Status
25
+ tags: string[]
26
+ milestone: string | null
27
+ needsHuman: boolean
28
+ /** Task numbers blocking this one — the single stored side of the relation. */
29
+ blockedBy: number[]
30
+ prs: string[]
31
+ position: number
32
+ createdAt: string
33
+ updatedAt: string
34
+ }
35
+
36
+ export interface CommentDoc {
37
+ author: string
38
+ createdAt: string
39
+ body: string
40
+ }
41
+
42
+ /** A parse that names the file it failed in — these files invite hand-editing. */
43
+ export class TicketParseError extends Error {}
44
+
45
+ // ── The frontmatter subset ───────────────────────────────────────────────────
46
+
47
+ interface RawDoc {
48
+ fields: Map<string, string>
49
+ body: string
50
+ }
51
+
52
+ /**
53
+ * One scalar: JSON if it parses, the raw string otherwise. The fallback is
54
+ * what lets timestamps and simple strings be written bare — and what forgives
55
+ * a hand-edit that writes `milestone: launch` without quotes.
56
+ */
57
+ function parseScalar(raw: string): unknown {
58
+ const trimmed = raw.trim()
59
+ if (trimmed === "") return ""
60
+ try {
61
+ return JSON.parse(trimmed) as unknown
62
+ } catch {
63
+ return trimmed
64
+ }
65
+ }
66
+
67
+ function splitRaw(text: string, where: string): RawDoc {
68
+ const lines = text.replace(/\r\n/g, "\n").split("\n")
69
+ if (lines[0] !== "---") {
70
+ throw new TicketParseError(`${where}: expected the file to start with a --- frontmatter block`)
71
+ }
72
+ const end = lines.indexOf("---", 1)
73
+ if (end < 0) {
74
+ throw new TicketParseError(`${where}: unterminated frontmatter — no closing ---`)
75
+ }
76
+ const fields = new Map<string, string>()
77
+ for (const line of lines.slice(1, end)) {
78
+ if (!line.trim()) continue
79
+ const colon = line.indexOf(":")
80
+ if (colon < 0) {
81
+ throw new TicketParseError(`${where}: not a "key: value" frontmatter line: ${line}`)
82
+ }
83
+ fields.set(line.slice(0, colon).trim(), line.slice(colon + 1))
84
+ }
85
+ const body = lines.slice(end + 1).join("\n")
86
+ return { fields, body: body.replace(/^\n+/, "").replace(/\s+$/, "") }
87
+ }
88
+
89
+ function field(raw: RawDoc, key: string): unknown {
90
+ const value = raw.fields.get(key)
91
+ return value === undefined ? undefined : parseScalar(value)
92
+ }
93
+
94
+ function stringField(raw: RawDoc, key: string, where: string, fallback: string): string {
95
+ const value = field(raw, key)
96
+ if (value === undefined) return fallback
97
+ if (typeof value === "string") return value
98
+ throw new TicketParseError(`${where}: ${key} should be a string`)
99
+ }
100
+
101
+ function stringList(raw: RawDoc, key: string, where: string): string[] {
102
+ const value = field(raw, key)
103
+ if (value === undefined) return []
104
+ if (Array.isArray(value) && value.every((v) => typeof v === "string")) return value
105
+ throw new TicketParseError(`${where}: ${key} should be a list of strings, e.g. ["a", "b"]`)
106
+ }
107
+
108
+ function numberList(raw: RawDoc, key: string, where: string): number[] {
109
+ const value = field(raw, key)
110
+ if (value === undefined) return []
111
+ if (Array.isArray(value) && value.every((v) => typeof v === "number" && Number.isInteger(v))) {
112
+ return value as number[]
113
+ }
114
+ throw new TicketParseError(`${where}: ${key} should be a list of task numbers, e.g. [3, 7]`)
115
+ }
116
+
117
+ // ── Tickets ──────────────────────────────────────────────────────────────────
118
+
119
+ /**
120
+ * Title ↔ first `# ` heading. Only the very first line can be the title, so a
121
+ * description that itself opens with a heading round-trips unambiguously.
122
+ */
123
+ function splitTitle(body: string): { title: string | null; description: string } {
124
+ if (!body.startsWith("# ")) return { title: null, description: body }
125
+ const newline = body.indexOf("\n")
126
+ if (newline < 0) return { title: body.slice(2).trim(), description: "" }
127
+ return {
128
+ title: body.slice(2, newline).trim(),
129
+ description: body.slice(newline + 1).replace(/^\n+/, ""),
130
+ }
131
+ }
132
+
133
+ export function parseTicket(text: string, where: string): TicketDoc {
134
+ const raw = splitRaw(text, where)
135
+ const status = stringField(raw, "status", where, "todo")
136
+ if (!isStatus(status)) {
137
+ throw new TicketParseError(
138
+ `${where}: invalid status "${status}" — one of: ${STATUSES.join(", ")}`,
139
+ )
140
+ }
141
+ const milestone = field(raw, "milestone")
142
+ if (milestone !== undefined && milestone !== null && typeof milestone !== "string") {
143
+ throw new TicketParseError(`${where}: milestone should be a string`)
144
+ }
145
+ const needsHuman = field(raw, "needs_human")
146
+ const position = field(raw, "position")
147
+ if (position !== undefined && typeof position !== "number") {
148
+ throw new TicketParseError(`${where}: position should be a number`)
149
+ }
150
+ const { title, description } = splitTitle(raw.body)
151
+ if (title === null) {
152
+ throw new TicketParseError(`${where}: expected the body to start with "# <title>"`)
153
+ }
154
+ return {
155
+ title,
156
+ description,
157
+ status,
158
+ tags: stringList(raw, "tags", where),
159
+ milestone: typeof milestone === "string" && milestone !== "" ? milestone : null,
160
+ needsHuman: needsHuman === true,
161
+ blockedBy: numberList(raw, "blocked_by", where),
162
+ prs: stringList(raw, "prs", where),
163
+ position: typeof position === "number" ? position : 0,
164
+ createdAt: stringField(raw, "created", where, ""),
165
+ updatedAt: stringField(raw, "updated", where, ""),
166
+ }
167
+ }
168
+
169
+ /** JSON array on one line — `["a", "b"]` — which is also valid YAML flow style. */
170
+ function list(values: (string | number)[]): string {
171
+ return `[${values.map((v) => JSON.stringify(v)).join(", ")}]`
172
+ }
173
+
174
+ /**
175
+ * The canonical bytes for a ticket. Field order is fixed and empty fields are
176
+ * omitted, so an unchanged ticket always serializes identically — diffs show
177
+ * edits, never churn.
178
+ */
179
+ export function serializeTicket(doc: TicketDoc): string {
180
+ const lines = ["---", `status: ${doc.status}`]
181
+ if (doc.tags.length) lines.push(`tags: ${list(doc.tags)}`)
182
+ if (doc.milestone) lines.push(`milestone: ${JSON.stringify(doc.milestone)}`)
183
+ if (doc.needsHuman) lines.push(`needs_human: true`)
184
+ if (doc.blockedBy.length) lines.push(`blocked_by: ${list(doc.blockedBy)}`)
185
+ if (doc.prs.length) lines.push(`prs: ${list(doc.prs)}`)
186
+ lines.push(`position: ${doc.position}`)
187
+ lines.push(`created: ${doc.createdAt}`)
188
+ lines.push(`updated: ${doc.updatedAt}`)
189
+ lines.push("---", "", `# ${doc.title}`)
190
+ if (doc.description) lines.push("", doc.description)
191
+ return `${lines.join("\n")}\n`
192
+ }
193
+
194
+ // ── Comments ─────────────────────────────────────────────────────────────────
195
+
196
+ export function parseComment(text: string, where: string): CommentDoc {
197
+ const raw = splitRaw(text, where)
198
+ return {
199
+ author: stringField(raw, "author", where, ""),
200
+ createdAt: stringField(raw, "created", where, ""),
201
+ body: raw.body,
202
+ }
203
+ }
204
+
205
+ export function serializeComment(doc: CommentDoc): string {
206
+ const lines = ["---"]
207
+ if (doc.author) lines.push(`author: ${JSON.stringify(doc.author)}`)
208
+ lines.push(`created: ${doc.createdAt}`, "---", "", doc.body)
209
+ return `${lines.join("\n")}\n`
210
+ }
211
+
212
+ /**
213
+ * A comment's filename stem — readable, sortable, and unique enough that two
214
+ * branches almost never mint the same one: `2026-08-15T165943-claude`. The
215
+ * frontmatter stays authoritative for author and timestamp; the name is for
216
+ * humans scanning a directory and for the comment's stable id.
217
+ */
218
+ export function commentStem(createdAt: string, author: string): string {
219
+ const compact = `${createdAt.slice(0, 10)}T${createdAt.slice(11, 19).replace(/:/g, "")}`
220
+ const slug = author
221
+ .toLowerCase()
222
+ .replace(/[^a-z0-9]+/g, "-")
223
+ .replace(/^-+|-+$/g, "")
224
+ .slice(0, 24)
225
+ return slug ? `${compact}-${slug}` : compact
226
+ }