@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/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))
@@ -48,10 +49,26 @@ function parsePatch(body: Record<string, unknown>): TaskPatch {
48
49
  patch.milestone = body.milestone === "" ? null : body.milestone
49
50
  }
50
51
  if (typeof body.needsHuman === "boolean") patch.needsHuman = body.needsHuman
52
+ if (Array.isArray(body.blocks)) patch.blocks = body.blocks.map(taskNumber)
53
+ if (Array.isArray(body.blockedBy)) patch.blockedBy = body.blockedBy.map(taskNumber)
54
+ if (Array.isArray(body.prs)) {
55
+ patch.prs = body.prs.map((pr) => {
56
+ const url = String(pr).trim()
57
+ // Anything the UI would render as a clickable link has to be a link.
58
+ if (!/^https?:\/\//.test(url)) throw new Error(`not a PR url: ${url}`)
59
+ return url
60
+ })
61
+ }
51
62
  if (typeof body.position === "number") patch.position = body.position
52
63
  return patch
53
64
  }
54
65
 
66
+ function taskNumber(value: unknown): number {
67
+ const n = Number(value)
68
+ if (!Number.isInteger(n) || n <= 0) throw new Error(`invalid task number: ${String(value)}`)
69
+ return n
70
+ }
71
+
55
72
  /**
56
73
  * `task serve` — one plain node:http server for the static UI, the JSON API,
57
74
  * and an SSE stream that pings whenever anything writes to a `.task/` (the UI
@@ -89,15 +106,18 @@ export function createTaskServer(serveRoot: string): Server {
89
106
  }
90
107
 
91
108
  interface Board {
92
- store: TaskStore
109
+ store: Store
93
110
  watcher: FSWatcher
94
111
  }
95
112
  const boards = new Map<string, Board>()
96
113
  let ordered: string[] = []
97
114
 
98
- const openBoard = (ref: BoardRef): void => {
99
- const store = new TaskStore(ref.root)
100
- 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))
101
121
  // A deleted board dir can make fs.watch emit; the next rescan cleans up.
102
122
  watcher.on("error", () => {})
103
123
  boards.set(ref.id, { store, watcher })
@@ -114,7 +134,7 @@ export function createTaskServer(serveRoot: string): Server {
114
134
  boards.delete(id)
115
135
  }
116
136
  }
117
- 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)
118
138
  ordered = found.map((ref) => ref.id)
119
139
  return found
120
140
  }
@@ -188,7 +208,7 @@ export function createTaskServer(serveRoot: string): Server {
188
208
  }
189
209
 
190
210
  function routeApi(
191
- store: TaskStore,
211
+ store: Store,
192
212
  req: IncomingMessage,
193
213
  res: ServerResponse,
194
214
  path: string,
@@ -226,11 +246,11 @@ export function createTaskServer(serveRoot: string): Server {
226
246
  return
227
247
  }
228
248
 
229
- const taskMatch = /^\/api\/tasks\/([^/]+)(\/comments(?:\/(\d+))?)?$/.exec(path)
249
+ const taskMatch = /^\/api\/tasks\/([^/]+)(\/comments(?:\/([^/]+))?)?$/.exec(path)
230
250
  if (taskMatch) {
231
251
  const number = store.parseId(decodeURIComponent(taskMatch[1]))
232
252
  if (taskMatch[3] && req.method === "DELETE") {
233
- const commentId = Number(taskMatch[3])
253
+ const commentId = decodeURIComponent(taskMatch[3])
234
254
  const existing = store.comments(number).find((c) => c.id === commentId)
235
255
  if (!existing) {
236
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
+ })