@nickmeriano/task 0.5.0 → 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 +63 -8
  2. package/dist/cli.js +220 -17
  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 +11 -7
  14. package/dist/server.js.map +1 -1
  15. package/dist/store.d.ts +55 -15
  16. package/dist/store.d.ts.map +1 -1
  17. package/dist/store.js +63 -29
  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 +14 -1
  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 +23 -8
  32. package/src/cli.ts +236 -20
  33. package/src/file-store.ts +693 -0
  34. package/src/index.ts +30 -4
  35. package/src/server.ts +15 -11
  36. package/src/store.test.ts +305 -0
  37. package/src/store.ts +99 -40
  38. package/src/ticket-doc.ts +226 -0
  39. package/src/types.ts +14 -1
  40. package/ui/dist/assets/index-CXW8uT5f.css +1 -0
  41. package/ui/dist/assets/{index-D_qmmh3D.js → index-oJzomUDL.js} +58 -58
  42. package/ui/dist/index.html +2 -2
  43. package/ui/dist/assets/index-Da14ye1f.css +0 -1
package/src/index.ts CHANGED
@@ -1,6 +1,32 @@
1
1
  // Programmatic access to the same store the CLI and `task serve` use — for
2
2
  // scripts that want to read or write .task/ without shelling out.
3
- export { TaskStore, findRoot, findBoards, initProject, derivePrefix, type BoardRef } from "./store.js"
4
- export { createTaskServer } from "./server.js"
5
- export { resolveAuthor, ANONYMOUS, type Author, type AuthorSource } from "./author.js"
6
- export * from "./types.js"
3
+ export {
4
+ TaskStore,
5
+ boardConfig,
6
+ derivePrefix,
7
+ findBoards,
8
+ findBoardsByPrefix,
9
+ findRoot,
10
+ findScopeRoot,
11
+ type BoardRef,
12
+ type Store,
13
+ } from "./store.ts"
14
+ export {
15
+ ARCHIVE_DIR,
16
+ FileStore,
17
+ TICKETS_DIR,
18
+ initProject,
19
+ migrateBoard,
20
+ openBoard,
21
+ } from "./file-store.ts"
22
+ export {
23
+ parseTicket,
24
+ serializeTicket,
25
+ parseComment,
26
+ serializeComment,
27
+ type TicketDoc,
28
+ type CommentDoc,
29
+ } from "./ticket-doc.ts"
30
+ export { createTaskServer } from "./server.ts"
31
+ export { resolveAuthor, ANONYMOUS, type Author, type AuthorSource } from "./author.ts"
32
+ export * from "./types.ts"
package/src/server.ts CHANGED
@@ -2,9 +2,10 @@ import { createServer, type IncomingMessage, type Server, type ServerResponse }
2
2
  import { watch, existsSync, readFileSync, statSync, type FSWatcher } from "node:fs"
3
3
  import { extname, join, normalize } from "node:path"
4
4
  import { fileURLToPath } from "node:url"
5
- import { resolveAuthor } from "./author.js"
6
- import { findBoards, TaskStore, type BoardRef } from "./store.js"
7
- import { STATUSES, isStatus, type TaskPatch } from "./types.js"
5
+ import { resolveAuthor } from "./author.ts"
6
+ import { openBoard } from "./file-store.ts"
7
+ import { findBoards, type BoardRef, type Store } from "./store.ts"
8
+ import { STATUSES, isStatus, type TaskPatch } from "./types.ts"
8
9
 
9
10
  /** The prebuilt SPA, shipped inside the package next to dist/. */
10
11
  const UI_DIR = fileURLToPath(new URL("../ui/dist", import.meta.url))
@@ -105,15 +106,18 @@ export function createTaskServer(serveRoot: string): Server {
105
106
  }
106
107
 
107
108
  interface Board {
108
- store: TaskStore
109
+ store: Store
109
110
  watcher: FSWatcher
110
111
  }
111
112
  const boards = new Map<string, Board>()
112
113
  let ordered: string[] = []
113
114
 
114
- const openBoard = (ref: BoardRef): void => {
115
- const store = new TaskStore(ref.root)
116
- const watcher = watch(store.taskDir, () => noteChange(ref.id))
115
+ const attachBoard = (ref: BoardRef): void => {
116
+ const store = openBoard(ref.root)
117
+ // Recursive: text-canonical state is a tree (tickets/<n>/comments/…), and
118
+ // a write anywhere in it is a board change. Node supports this on Linux,
119
+ // macOS and Windows for the versions this package requires.
120
+ const watcher = watch(store.taskDir, { recursive: true }, () => noteChange(ref.id))
117
121
  // A deleted board dir can make fs.watch emit; the next rescan cleans up.
118
122
  watcher.on("error", () => {})
119
123
  boards.set(ref.id, { store, watcher })
@@ -130,7 +134,7 @@ export function createTaskServer(serveRoot: string): Server {
130
134
  boards.delete(id)
131
135
  }
132
136
  }
133
- for (const ref of found) if (!boards.has(ref.id)) openBoard(ref)
137
+ for (const ref of found) if (!boards.has(ref.id)) attachBoard(ref)
134
138
  ordered = found.map((ref) => ref.id)
135
139
  return found
136
140
  }
@@ -204,7 +208,7 @@ export function createTaskServer(serveRoot: string): Server {
204
208
  }
205
209
 
206
210
  function routeApi(
207
- store: TaskStore,
211
+ store: Store,
208
212
  req: IncomingMessage,
209
213
  res: ServerResponse,
210
214
  path: string,
@@ -242,11 +246,11 @@ export function createTaskServer(serveRoot: string): Server {
242
246
  return
243
247
  }
244
248
 
245
- const taskMatch = /^\/api\/tasks\/([^/]+)(\/comments(?:\/(\d+))?)?$/.exec(path)
249
+ const taskMatch = /^\/api\/tasks\/([^/]+)(\/comments(?:\/([^/]+))?)?$/.exec(path)
246
250
  if (taskMatch) {
247
251
  const number = store.parseId(decodeURIComponent(taskMatch[1]))
248
252
  if (taskMatch[3] && req.method === "DELETE") {
249
- const commentId = Number(taskMatch[3])
253
+ const commentId = decodeURIComponent(taskMatch[3])
250
254
  const existing = store.comments(number).find((c) => c.id === commentId)
251
255
  if (!existing) {
252
256
  json(res, 404, { error: `no such comment: ${commentId}` })
@@ -0,0 +1,305 @@
1
+ /**
2
+ * The properties TAS-12 exists for, checked from the outside:
3
+ *
4
+ * 1. The text format round-trips — parse(serialize(x)) loses nothing.
5
+ * 2. `FileStore` behaves like the store always has (positions, links,
6
+ * comments, cascade on delete).
7
+ * 3. `task migrate` reproduces a legacy SQLite board in files, exactly.
8
+ * 4. Concurrent-writer shapes merge by construction: a comment is a new file,
9
+ * a claim is a one-line status diff.
10
+ */
11
+
12
+ import assert from "node:assert/strict"
13
+ import { test } from "node:test"
14
+ import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"
15
+ import { tmpdir } from "node:os"
16
+ import { join } from "node:path"
17
+ import { FileStore, initProject, migrateBoard, openBoard } from "./file-store.ts"
18
+ import { parseTicket, serializeTicket, type TicketDoc } from "./ticket-doc.ts"
19
+ import { TaskStore, findBoardsByPrefix, findScopeRoot } from "./store.ts"
20
+
21
+ function tempRoot(): string {
22
+ const dir = mkdtempSync(join(tmpdir(), "task-test-"))
23
+ process.on("exit", () => rmSync(dir, { recursive: true, force: true }))
24
+ return dir
25
+ }
26
+
27
+ test("ticket documents round-trip, including the awkward bodies", () => {
28
+ const docs: TicketDoc[] = [
29
+ {
30
+ title: "Plain ticket",
31
+ description: "One line.",
32
+ status: "todo",
33
+ tags: [],
34
+ milestone: null,
35
+ needsHuman: false,
36
+ blockedBy: [],
37
+ prs: [],
38
+ position: 1024,
39
+ createdAt: "2026-08-15T16:00:33.758Z",
40
+ updatedAt: "2026-08-15T16:00:33.758Z",
41
+ },
42
+ {
43
+ title: 'Tricky: "quotes", colons: yes — and unicode ✓',
44
+ // A description that opens with a heading, contains a frontmatter-like
45
+ // fence, and ends without punctuation.
46
+ description: "# Not the title\n\n---\n\nstatus: not-a-field\n\n```md\n---\n```",
47
+ status: "in_progress",
48
+ tags: ["storage", "architecture"],
49
+ milestone: "v2 storage",
50
+ needsHuman: true,
51
+ blockedBy: [3, 7],
52
+ prs: ["https://github.com/x/y/pull/1"],
53
+ position: -512.5,
54
+ createdAt: "2026-08-15T16:00:33.758Z",
55
+ updatedAt: "2026-08-15T17:12:00.001Z",
56
+ },
57
+ {
58
+ title: "Empty description",
59
+ description: "",
60
+ status: "backlog",
61
+ tags: [],
62
+ milestone: null,
63
+ needsHuman: false,
64
+ blockedBy: [],
65
+ prs: [],
66
+ position: 0,
67
+ createdAt: "2026-08-15T16:00:33.758Z",
68
+ updatedAt: "2026-08-15T16:00:33.758Z",
69
+ },
70
+ ]
71
+ for (const doc of docs) {
72
+ assert.deepEqual(parseTicket(serializeTicket(doc), "test"), doc, doc.title)
73
+ }
74
+ })
75
+
76
+ test("hand-edits are forgiven where they're unambiguous", () => {
77
+ const text = [
78
+ "---",
79
+ "status: todo",
80
+ "milestone: launch", // unquoted string — the YAML instinct
81
+ "position: 8",
82
+ "created: 2026-08-15T16:00:33.758Z",
83
+ "updated: 2026-08-15T16:00:33.758Z",
84
+ "---",
85
+ "",
86
+ "# Hand-written ticket",
87
+ "",
88
+ "Body.",
89
+ ].join("\n")
90
+ const doc = parseTicket(text, "test")
91
+ assert.equal(doc.milestone, "launch")
92
+ assert.equal(doc.position, 8)
93
+
94
+ assert.throws(
95
+ () => parseTicket(text.replace("status: todo", "status: todoo"), "here"),
96
+ /here.*invalid status/,
97
+ )
98
+ assert.throws(() => parseTicket("no frontmatter", "here"), /here.*frontmatter/)
99
+ })
100
+
101
+ test("FileStore keeps the store's contract", () => {
102
+ const root = tempRoot()
103
+ const store = initProject(root, { name: "sample project" })
104
+ assert.equal(store.config.prefix, "SAM")
105
+ assert.ok(store instanceof FileStore)
106
+
107
+ const a = store.create({ title: "First", description: "alpha", tags: ["api"] })
108
+ const b = store.create({ title: "Second", status: "todo" })
109
+ const c = store.create({ title: "Third", status: "backlog" })
110
+ assert.deepEqual([a.number, b.number, c.number], [1, 2, 3])
111
+ // New tasks land at the bottom of their column.
112
+ assert.ok(b.position > a.position)
113
+
114
+ // Status move without an explicit slot lands on top of the new column.
115
+ const moved = store.update(c.number, { status: "todo" })
116
+ assert.ok(moved.position < a.position)
117
+ assert.equal(store.list({ statuses: ["todo"] })[0].number, c.number)
118
+
119
+ // One relation, two views, stored on one side only.
120
+ store.link(a.number, "blocked_by", b.number)
121
+ assert.deepEqual(store.get(b.number)!.blocks, [a.number])
122
+ const onDisk = readFileSync(join(root, ".task", "tickets", "1", "ticket.md"), "utf8")
123
+ assert.match(onDisk, /blocked_by: \[2\]/)
124
+ assert.doesNotMatch(
125
+ readFileSync(join(root, ".task", "tickets", "2", "ticket.md"), "utf8"),
126
+ /blocked_by/,
127
+ )
128
+ store.unlink(b.number, "blocks", a.number)
129
+ assert.deepEqual(store.get(a.number)!.blockedBy, [])
130
+
131
+ assert.throws(() => store.link(a.number, "blocks", a.number), /can't block itself/)
132
+ assert.throws(() => store.update(a.number, { blockedBy: [99] }), /no such task: SAM-99/)
133
+
134
+ // Comments: one file each, stable ids, author preserved.
135
+ const comment = store.addComment(a.number, "first!\n\nwith **markdown**", "claude")
136
+ assert.match(comment.id, /^\d{4}-\d{2}-\d{2}T\d{6}-claude$/)
137
+ assert.equal(store.comments(a.number)[0].body, "first!\n\nwith **markdown**")
138
+ assert.equal(store.commentCounts().get(a.number), 1)
139
+ const second = store.addComment(a.number, "again", "claude")
140
+ assert.notEqual(second.id, comment.id)
141
+ store.deleteComment(a.number, second.id)
142
+ assert.equal(store.comments(a.number).length, 1)
143
+ assert.throws(() => store.deleteComment(a.number, "../../escape"), /no such comment/)
144
+
145
+ // Delete cascades the relation away, like the FK did.
146
+ store.link(b.number, "blocked_by", a.number)
147
+ store.delete(a.number)
148
+ assert.equal(store.get(a.number), null)
149
+ assert.deepEqual(store.get(b.number)!.blockedBy, [])
150
+
151
+ // Numbers never move backwards past an existing ticket.
152
+ const d = store.create({ title: "Fourth" })
153
+ assert.equal(d.number, 4)
154
+ })
155
+
156
+ test("openBoard picks the backend by what's on disk", () => {
157
+ const fileRoot = tempRoot()
158
+ initProject(fileRoot, { name: "files" })
159
+ assert.ok(openBoard(fileRoot) instanceof FileStore)
160
+
161
+ const legacyRoot = tempRoot()
162
+ mkdirSync(join(legacyRoot, ".task"))
163
+ writeFileSync(
164
+ join(legacyRoot, ".task", "config.json"),
165
+ `${JSON.stringify({ name: "legacy", prefix: "LEG", version: 1 }, null, 2)}\n`,
166
+ )
167
+ const legacy = openBoard(legacyRoot)
168
+ assert.ok(legacy instanceof TaskStore)
169
+ legacy.close()
170
+ })
171
+
172
+ test("migrate reproduces a legacy board, byte-visible and loss-free", () => {
173
+ const root = tempRoot()
174
+ mkdirSync(join(root, ".task"))
175
+ writeFileSync(
176
+ join(root, ".task", "config.json"),
177
+ `${JSON.stringify({ name: "legacy board", prefix: "LEG", version: 1 }, null, 2)}\n`,
178
+ )
179
+ const legacy = new TaskStore(root)
180
+ const one = legacy.create({ title: "Keep me", description: "with *body*", tags: ["a", "b"] })
181
+ const two = legacy.create({
182
+ title: "Blocked one",
183
+ status: "in_progress",
184
+ milestone: "launch",
185
+ needsHuman: true,
186
+ blockedBy: [one.number],
187
+ prs: ["https://github.com/x/y/pull/9"],
188
+ })
189
+ legacy.addComment(one.number, "hello", "nick")
190
+ legacy.addComment(one.number, "and back", "claude")
191
+ const expectedTasks = legacy.list({})
192
+ const expectedComments = legacy.comments(one.number)
193
+ legacy.close()
194
+
195
+ const result = migrateBoard(root)
196
+ assert.deepEqual(result, { tasks: 2, comments: 2 })
197
+ assert.throws(() => migrateBoard(root), /already migrated/)
198
+
199
+ const store = openBoard(root)
200
+ assert.ok(store instanceof FileStore)
201
+ assert.deepEqual(store.list({}), expectedTasks)
202
+ assert.deepEqual(
203
+ store.comments(one.number).map(({ author, body, createdAt }) => ({ author, body, createdAt })),
204
+ expectedComments.map(({ author, body, createdAt }) => ({ author, body, createdAt })),
205
+ )
206
+ assert.deepEqual(store.get(two.number)!.blockedBy, [one.number])
207
+ assert.match(readFileSync(join(root, ".task", ".gitignore"), "utf8"), /tasks\.db/)
208
+ assert.equal(
209
+ (JSON.parse(readFileSync(join(root, ".task", "config.json"), "utf8")) as { version: number })
210
+ .version,
211
+ 2,
212
+ )
213
+ })
214
+
215
+ test("archive moves finished tickets off the hot path, losing nothing", () => {
216
+ const root = tempRoot()
217
+ const store = initProject(root, { name: "archive test", prefix: "ARC" })
218
+ const keep = store.create({ title: "Still open" })
219
+ const old = store.create({ title: "Finished work" })
220
+ store.link(keep.number, "blocked_by", old.number)
221
+ store.addComment(old.number, "how it went", "nick")
222
+
223
+ // Only finished work qualifies — archive is history, not a hiding place.
224
+ assert.throws(() => store.archive(old.number), /is todo/)
225
+ store.update(old.number, { status: "done" })
226
+ const archived = store.archive(old.number)
227
+ assert.equal(archived.archived, true)
228
+
229
+ // Off the board and out of every default read…
230
+ assert.deepEqual(store.list({}).map((t) => t.number), [keep.number])
231
+ assert.equal(store.commentCounts().get(old.number), undefined)
232
+ // …but reads still reach it, comments included, and its links still show.
233
+ const shown = store.get(old.number)!
234
+ assert.equal(shown.archived, true)
235
+ assert.deepEqual(shown.blocks, [keep.number])
236
+ assert.equal(store.comments(old.number)[0].body, "how it went")
237
+ const listed = store.list({ archived: true })
238
+ assert.deepEqual(listed.map((t) => [t.number, t.archived]), [[old.number, true]])
239
+
240
+ // Writes refuse until unarchived, with a message that says what to do.
241
+ assert.throws(() => store.update(old.number, { title: "x" }), /archived — run/)
242
+ assert.throws(() => store.addComment(old.number, "x", "y"), /archived — run/)
243
+ assert.throws(() => store.delete(old.number), /archived — run/)
244
+ assert.throws(() => store.archive(old.number), /already archived/)
245
+ // Linking *to* an archived target refuses; pruning a stale link succeeds.
246
+ assert.throws(() => store.link(keep.number, "blocked_by", old.number), /archived — run/)
247
+ assert.deepEqual(store.update(keep.number, { blockedBy: [] }).blockedBy, [])
248
+
249
+ // Numbers stay reserved — the next ticket never reuses an archived one.
250
+ assert.equal(store.create({ title: "Next" }).number, 3)
251
+
252
+ // Round trip: unarchive puts it back exactly as it was.
253
+ const back = store.unarchive(old.number)
254
+ assert.equal(back.archived, undefined)
255
+ assert.equal(back.status, "done")
256
+ assert.deepEqual(store.list({ archived: true }), [])
257
+ assert.equal(store.comments(old.number).length, 1)
258
+ assert.throws(() => store.unarchive(old.number), /isn't archived/)
259
+ })
260
+
261
+ test("prefix routing helpers find the right board from anywhere", () => {
262
+ const repo = tempRoot()
263
+ initProject(repo, { name: "root board", prefix: "NIC" })
264
+ mkdirSync(join(repo, "projects", "widget"), { recursive: true })
265
+ initProject(join(repo, "projects", "widget"), { name: "widget", prefix: "WID" })
266
+
267
+ // From deep inside a package, the scope is still the repo root.
268
+ assert.equal(findScopeRoot(join(repo, "projects", "widget")), repo)
269
+ assert.equal(findScopeRoot(repo), repo)
270
+
271
+ const hit = findBoardsByPrefix(repo, "wid")
272
+ assert.deepEqual(hit.map((b) => b.id), ["projects/widget"])
273
+ assert.deepEqual(findBoardsByPrefix(repo, "NOPE"), [])
274
+ })
275
+
276
+ test("concurrent-writer shapes merge by construction", () => {
277
+ const root = tempRoot()
278
+ const store = initProject(root, { name: "merge" })
279
+ const task = store.create({ title: "Contended" })
280
+
281
+ // Two agents commenting at once = two files; simulate the second writer by
282
+ // dropping its file in directly, the way a git merge would.
283
+ store.addComment(task.number, "from session one", "claude")
284
+ const commentsDir = join(root, ".task", "tickets", String(task.number), "comments")
285
+ writeFileSync(
286
+ join(commentsDir, "2026-08-15T170000-other.md"),
287
+ "---\nauthor: \"other\"\ncreated: 2026-08-15T17:00:00.000Z\n---\n\nfrom session two\n",
288
+ )
289
+ assert.equal(store.comments(task.number).length, 2)
290
+
291
+ // A claim is a one-line diff: exactly one meaningful frontmatter line
292
+ // changes. `updated:` is excluded because it only changes when the update
293
+ // lands in a different millisecond than the create — true except when this
294
+ // test runs fast enough, which is exactly the kind of coin-flip a test must
295
+ // not depend on.
296
+ const path = join(root, ".task", "tickets", String(task.number), "ticket.md")
297
+ const before = readFileSync(path, "utf8").split("\n")
298
+ store.update(task.number, { status: "in_progress", position: task.position })
299
+ const after = readFileSync(path, "utf8").split("\n")
300
+ const changed = before.filter((line, i) => after[i] !== line)
301
+ assert.deepEqual(
302
+ changed.filter((line) => !line.startsWith("updated: ")),
303
+ ["status: todo"],
304
+ )
305
+ })
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
 
@@ -112,11 +181,12 @@ function now(): string {
112
181
  }
113
182
 
114
183
  /**
115
- * The whole persistence layer: one SQLite database inside `.task/`, opened
184
+ * The legacy persistence layer: one SQLite database inside `.task/`, opened
116
185
  * synchronously via node:sqlite (built into Node ≥22.13 — zero dependencies,
117
- * 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.
118
188
  */
119
- export class TaskStore {
189
+ export class TaskStore implements Store {
120
190
  readonly root: string
121
191
  readonly taskDir: string
122
192
  readonly config: ProjectConfig
@@ -181,7 +251,7 @@ export class TaskStore {
181
251
 
182
252
  private toComment(row: CommentRow): Comment {
183
253
  return {
184
- id: row.id,
254
+ id: String(row.id),
185
255
  taskId: this.displayId(row.task_id),
186
256
  author: row.author,
187
257
  body: row.body,
@@ -190,6 +260,9 @@ export class TaskStore {
190
260
  }
191
261
 
192
262
  list(filter: TaskFilter = {}): Task[] {
263
+ if (filter.archived) {
264
+ throw new Error("archiving needs a text-format board — run `task migrate` first")
265
+ }
193
266
  const where: string[] = []
194
267
  const params: (string | number)[] = []
195
268
  if (filter.statuses?.length) {
@@ -366,6 +439,15 @@ export class TaskStore {
366
439
  if (result.changes === 0) throw new Error(`no such task: ${this.displayId(number)}`)
367
440
  }
368
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
+
369
451
  comments(number: number): Comment[] {
370
452
  const rows = this.db
371
453
  .prepare("SELECT * FROM comments WHERE task_id = ? ORDER BY id")
@@ -384,12 +466,17 @@ export class TaskStore {
384
466
  return this.toComment(row)
385
467
  }
386
468
 
387
- deleteComment(number: number, commentId: number): Comment {
388
- const row = this.db
389
- .prepare("SELECT * FROM comments WHERE id = ? AND task_id = ?")
390
- .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
391
478
  if (!row) throw new Error(`no such comment on ${this.displayId(number)}: ${commentId}`)
392
- this.db.prepare("DELETE FROM comments WHERE id = ?").run(commentId)
479
+ this.db.prepare("DELETE FROM comments WHERE id = ?").run(rowid)
393
480
  return this.toComment(row)
394
481
  }
395
482
 
@@ -490,31 +577,3 @@ function migrate(db: DatabaseSync): void {
490
577
  }
491
578
  }
492
579
 
493
- export interface InitOptions {
494
- name: string
495
- prefix?: string
496
- }
497
-
498
- /**
499
- * Create `.task/` in `root`: config, an empty database, and a .gitignore for
500
- * SQLite's transient sidecar files (the database itself is meant to be
501
- * committed — that's the point).
502
- */
503
- export function initProject(root: string, options: InitOptions): TaskStore {
504
- const taskDir = join(root, TASK_DIR)
505
- if (existsSync(join(taskDir, CONFIG_FILE))) {
506
- throw new Error(`already initialized: ${join(taskDir, CONFIG_FILE)} exists`)
507
- }
508
- mkdirSync(taskDir, { recursive: true })
509
- const config: ProjectConfig = {
510
- name: options.name,
511
- prefix: options.prefix ?? derivePrefix(options.name),
512
- version: 1,
513
- }
514
- writeFileSync(join(taskDir, CONFIG_FILE), `${JSON.stringify(config, null, 2)}\n`)
515
- writeFileSync(
516
- join(taskDir, ".gitignore"),
517
- "# SQLite transients — tasks.db itself is committed.\n*.db-journal\n*.db-wal\n*.db-shm\n",
518
- )
519
- return new TaskStore(root)
520
- }