@nickmeriano/task 0.8.0 → 0.10.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 (115) hide show
  1. package/README.md +137 -22
  2. package/dist/asks.test.d.ts +17 -0
  3. package/dist/asks.test.d.ts.map +1 -0
  4. package/dist/asks.test.js +278 -0
  5. package/dist/asks.test.js.map +1 -0
  6. package/dist/check.d.ts +3 -2
  7. package/dist/check.d.ts.map +1 -1
  8. package/dist/check.js +99 -60
  9. package/dist/check.js.map +1 -1
  10. package/dist/check.test.js +99 -60
  11. package/dist/check.test.js.map +1 -1
  12. package/dist/claim-io.d.ts +73 -0
  13. package/dist/claim-io.d.ts.map +1 -0
  14. package/dist/claim-io.js +344 -0
  15. package/dist/claim-io.js.map +1 -0
  16. package/dist/claim.d.ts +108 -23
  17. package/dist/claim.d.ts.map +1 -1
  18. package/dist/claim.js +320 -86
  19. package/dist/claim.js.map +1 -1
  20. package/dist/claim.test.d.ts +10 -2
  21. package/dist/claim.test.d.ts.map +1 -1
  22. package/dist/claim.test.js +282 -96
  23. package/dist/claim.test.js.map +1 -1
  24. package/dist/cli.js +346 -68
  25. package/dist/cli.js.map +1 -1
  26. package/dist/file-store.d.ts +70 -24
  27. package/dist/file-store.d.ts.map +1 -1
  28. package/dist/file-store.js +319 -141
  29. package/dist/file-store.js.map +1 -1
  30. package/dist/git-serve.d.ts +183 -0
  31. package/dist/git-serve.d.ts.map +1 -0
  32. package/dist/git-serve.js +503 -0
  33. package/dist/git-serve.js.map +1 -0
  34. package/dist/git-serve.test.d.ts +16 -0
  35. package/dist/git-serve.test.d.ts.map +1 -0
  36. package/dist/git-serve.test.js +183 -0
  37. package/dist/git-serve.test.js.map +1 -0
  38. package/dist/git.d.ts +65 -0
  39. package/dist/git.d.ts.map +1 -0
  40. package/dist/git.js +114 -0
  41. package/dist/git.js.map +1 -0
  42. package/dist/id.d.ts +39 -0
  43. package/dist/id.d.ts.map +1 -0
  44. package/dist/id.js +67 -0
  45. package/dist/id.js.map +1 -0
  46. package/dist/inbox.d.ts +41 -0
  47. package/dist/inbox.d.ts.map +1 -0
  48. package/dist/inbox.js +56 -0
  49. package/dist/inbox.js.map +1 -0
  50. package/dist/overview.d.ts +9 -0
  51. package/dist/overview.d.ts.map +1 -1
  52. package/dist/overview.js +19 -11
  53. package/dist/overview.js.map +1 -1
  54. package/dist/overview.test.js +6 -5
  55. package/dist/overview.test.js.map +1 -1
  56. package/dist/promote.test.d.ts +3 -2
  57. package/dist/promote.test.d.ts.map +1 -1
  58. package/dist/promote.test.js +29 -31
  59. package/dist/promote.test.js.map +1 -1
  60. package/dist/publish.d.ts +2 -17
  61. package/dist/publish.d.ts.map +1 -1
  62. package/dist/publish.js +4 -49
  63. package/dist/publish.js.map +1 -1
  64. package/dist/search.d.ts +3 -1
  65. package/dist/search.d.ts.map +1 -1
  66. package/dist/search.js +15 -2
  67. package/dist/search.js.map +1 -1
  68. package/dist/search.test.js +9 -9
  69. package/dist/search.test.js.map +1 -1
  70. package/dist/server.d.ts.map +1 -1
  71. package/dist/server.js +218 -21
  72. package/dist/server.js.map +1 -1
  73. package/dist/store.d.ts +28 -14
  74. package/dist/store.d.ts.map +1 -1
  75. package/dist/store.js.map +1 -1
  76. package/dist/store.test.js +88 -75
  77. package/dist/store.test.js.map +1 -1
  78. package/dist/ticket-doc.d.ts +26 -10
  79. package/dist/ticket-doc.d.ts.map +1 -1
  80. package/dist/ticket-doc.js +71 -27
  81. package/dist/ticket-doc.js.map +1 -1
  82. package/dist/types.d.ts +51 -17
  83. package/dist/types.d.ts.map +1 -1
  84. package/dist/types.js.map +1 -1
  85. package/package.json +1 -1
  86. package/skill/SKILL.md +96 -24
  87. package/src/asks.test.ts +355 -0
  88. package/src/check.test.ts +129 -72
  89. package/src/check.ts +101 -58
  90. package/src/claim-io.ts +401 -0
  91. package/src/claim.test.ts +372 -101
  92. package/src/claim.ts +418 -99
  93. package/src/cli.ts +370 -67
  94. package/src/file-store.ts +357 -145
  95. package/src/git-serve.test.ts +240 -0
  96. package/src/git-serve.ts +595 -0
  97. package/src/git.ts +141 -0
  98. package/src/id.ts +68 -0
  99. package/src/inbox.ts +77 -0
  100. package/src/overview.test.ts +6 -5
  101. package/src/overview.ts +25 -10
  102. package/src/promote.test.ts +40 -28
  103. package/src/publish.ts +6 -53
  104. package/src/search.test.ts +11 -11
  105. package/src/search.ts +17 -3
  106. package/src/server.ts +221 -19
  107. package/src/store.test.ts +95 -77
  108. package/src/store.ts +28 -13
  109. package/src/ticket-doc.ts +93 -30
  110. package/src/types.ts +52 -17
  111. package/ui/dist/assets/index-BjsorZOU.js +229 -0
  112. package/ui/dist/assets/index-CoKCUYic.css +1 -0
  113. package/ui/dist/index.html +2 -2
  114. package/ui/dist/assets/index-BJmOsOdR.js +0 -229
  115. package/ui/dist/assets/index-BoqQlqSU.css +0 -1
package/src/server.ts CHANGED
@@ -3,8 +3,11 @@ import { watch, existsSync, readFileSync, statSync, type FSWatcher } from "node:
3
3
  import { extname, join, normalize } from "node:path"
4
4
  import { fileURLToPath } from "node:url"
5
5
  import { resolveAuthor } from "./author.ts"
6
+ import { addAskRouted, deleteAskRouted, setAskResolvedRouted } from "./claim-io.ts"
6
7
  import { openBoard } from "./file-store.ts"
7
- import { findBoards, type BoardRef, type Store } from "./store.ts"
8
+ import { GitServe, GitServeError } from "./git-serve.ts"
9
+ import { buildInbox } from "./inbox.ts"
10
+ import { boardConfig, findBoards, type BoardRef, type Store } from "./store.ts"
8
11
  import { STATUSES, isStatus, type TaskPatch } from "./types.ts"
9
12
 
10
13
  /** The prebuilt SPA, shipped inside the package next to dist/. */
@@ -48,9 +51,8 @@ function parsePatch(body: Record<string, unknown>): TaskPatch {
48
51
  if (typeof body.goal === "string" || body.goal === null) {
49
52
  patch.goal = body.goal === "" ? null : body.goal
50
53
  }
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.blocks)) patch.blocks = body.blocks.map(taskKey)
55
+ if (Array.isArray(body.blockedBy)) patch.blockedBy = body.blockedBy.map(taskKey)
54
56
  if (Array.isArray(body.prs)) {
55
57
  patch.prs = body.prs.map((pr) => {
56
58
  const url = String(pr).trim()
@@ -63,10 +65,32 @@ function parsePatch(body: Record<string, unknown>): TaskPatch {
63
65
  return patch
64
66
  }
65
67
 
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
68
+ function taskKey(value: unknown): string {
69
+ if (typeof value !== "string" || !/^[a-z0-9]+$/.test(value)) {
70
+ throw new Error(`invalid task key: ${String(value)}`)
71
+ }
72
+ return value
73
+ }
74
+
75
+ /**
76
+ * The CSRF gate. `task serve` is an unauthenticated HTTP server, and any
77
+ * webpage in the same browser can aim a POST at it — before this check, a
78
+ * drive-by page could edit the board, and with the git surface it could
79
+ * commit and push. Same-origin is the whole auth model, so a write has to
80
+ * prove it: browsers attach `Origin` to every cross-origin request (and to
81
+ * all fetch POSTs), and the only origin allowed to write is the board's own —
82
+ * whatever host the request itself was addressed to. No Origin header means
83
+ * no browser page is speaking (curl, scripts), which is the local-trust case
84
+ * the server already accepts.
85
+ */
86
+ function sameOrigin(req: IncomingMessage): boolean {
87
+ const origin = req.headers.origin
88
+ if (origin === undefined) return true
89
+ try {
90
+ return new URL(origin).host === req.headers.host
91
+ } catch {
92
+ return false // "null" and other unparseable origins are nobody we trust
93
+ }
70
94
  }
71
95
 
72
96
  /**
@@ -144,6 +168,28 @@ export function createTaskServer(serveRoot: string): Server {
144
168
  throw new Error(`no .task directory found at or below ${serveRoot} — run \`task init\` first`)
145
169
  }
146
170
 
171
+ const gitServe = new GitServe(serveRoot, () =>
172
+ ordered.map((id) => ({ id, store: boards.get(id)!.store })),
173
+ )
174
+
175
+ // Branch switches don't touch `.task/` files, but they change what every
176
+ // board file *says* — so watch the git dir for HEAD moving (a checkout,
177
+ // from this server or a terminal) and ping every board's SSE listeners.
178
+ // Non-recursive on the resolved git dir (worktrees keep HEAD there), and
179
+ // best-effort: a repo this can't watch simply refreshes on the next edit.
180
+ let gitWatcher: FSWatcher | null = null
181
+ const gitDir = gitServe.gitDir()
182
+ if (gitDir) {
183
+ try {
184
+ gitWatcher = watch(gitDir, (_event, filename) => {
185
+ if (filename === "HEAD") for (const id of ordered) noteChange(id)
186
+ })
187
+ gitWatcher.on("error", () => {})
188
+ } catch {
189
+ gitWatcher = null
190
+ }
191
+ }
192
+
147
193
  const heartbeat = setInterval(() => {
148
194
  for (const client of clients) client.write(`: ping\n\n`)
149
195
  }, 30_000)
@@ -159,6 +205,7 @@ export function createTaskServer(serveRoot: string): Server {
159
205
  server.on("close", () => {
160
206
  if (pending) clearTimeout(pending)
161
207
  clearInterval(heartbeat)
208
+ gitWatcher?.close()
162
209
  for (const board of boards.values()) {
163
210
  board.watcher.close()
164
211
  board.store.close()
@@ -170,6 +217,19 @@ export function createTaskServer(serveRoot: string): Server {
170
217
  const url = new URL(req.url ?? "/", "http://localhost")
171
218
  const path = url.pathname
172
219
 
220
+ // Every mutation, one gate — the ask routes push commits and the git
221
+ // routes run git, so this cannot be per-endpoint courtesy.
222
+ const method = req.method ?? "GET"
223
+ if (
224
+ path.startsWith("/api/") &&
225
+ method !== "GET" &&
226
+ method !== "HEAD" &&
227
+ !sameOrigin(req)
228
+ ) {
229
+ json(res, 403, { error: "cross-origin request refused — the board only writes for its own pages" })
230
+ return
231
+ }
232
+
173
233
  if (path === "/api/events") {
174
234
  res.writeHead(200, {
175
235
  "Content-Type": "text/event-stream",
@@ -193,6 +253,94 @@ export function createTaskServer(serveRoot: string): Server {
193
253
  return
194
254
  }
195
255
 
256
+ if (path === "/api/inbox") {
257
+ // Deliberately cross-board — the header badge is repo-wide attention.
258
+ // Scope follows the CLI's selection rule: the root board's `boards`
259
+ // list when it has one (matched by prefix), every served board
260
+ // otherwise — so a vendored board can't put asks in your face unless
261
+ // the root board opted it in.
262
+ syncBoards()
263
+ const all = ordered.map((id) => ({ id, store: boards.get(id)!.store }))
264
+ let scoped = all
265
+ try {
266
+ const wanted = boardConfig(all[0].store.root).boards
267
+ if (Array.isArray(wanted) && wanted.length > 0) {
268
+ const prefixes = new Set(wanted.map((p) => String(p).toUpperCase()))
269
+ const matched = all.filter((b) => prefixes.has(b.store.config.prefix.toUpperCase()))
270
+ if (matched.length > 0) scoped = matched
271
+ }
272
+ } catch {
273
+ // Unreadable root config — serve everything rather than nothing.
274
+ }
275
+ json(res, 200, { inbox: buildInbox(scoped) })
276
+ return
277
+ }
278
+
279
+ // The git surface (TAS-42). Repo-level, not board-level — one working
280
+ // tree holds every served board — so it lives beside /api/inbox rather
281
+ // than inside routeApi. Every handler 404s ("no git surface here") when
282
+ // the capability is off, which is also what the prebuilt UI probes.
283
+ if (path.startsWith("/api/git/")) {
284
+ const fail = (error: unknown): void => {
285
+ const status = error instanceof GitServeError ? error.status : 400
286
+ json(res, status, { error: error instanceof Error ? error.message : String(error) })
287
+ }
288
+ try {
289
+ if (path === "/api/git/status" && req.method === "GET") {
290
+ json(res, 200, gitServe.status())
291
+ return
292
+ }
293
+ if (path === "/api/git/branches" && req.method === "GET") {
294
+ json(res, 200, { branches: gitServe.branches() })
295
+ return
296
+ }
297
+ if (path === "/api/git/pr" && req.method === "GET") {
298
+ const branch = url.searchParams.get("branch")
299
+ if (!branch) {
300
+ json(res, 400, { error: "branch is required" })
301
+ return
302
+ }
303
+ json(res, 200, gitServe.prFor(branch))
304
+ return
305
+ }
306
+ if (path === "/api/git/commit" && req.method === "POST") {
307
+ void readBody(req)
308
+ .then((body) => json(res, 200, gitServe.commit(body)))
309
+ .catch(fail)
310
+ return
311
+ }
312
+ if (path === "/api/git/checkout" && req.method === "POST") {
313
+ void readBody(req)
314
+ .then((body) => json(res, 200, gitServe.checkout(body)))
315
+ .catch(fail)
316
+ return
317
+ }
318
+ if (path === "/api/git/pr" && req.method === "POST") {
319
+ void readBody(req)
320
+ .then((body) => json(res, 200, gitServe.pr(body)))
321
+ .catch(fail)
322
+ return
323
+ }
324
+ if (path === "/api/git/release" && req.method === "POST") {
325
+ const id = url.searchParams.get("board") ?? ordered[0]
326
+ const board = boards.get(id)
327
+ if (!board) {
328
+ json(res, 404, { error: `no such board: ${id}` })
329
+ return
330
+ }
331
+ void readBody(req)
332
+ .then((body) => json(res, 200, gitServe.releaseClaim(board.store, body, author)))
333
+ .catch(fail)
334
+ return
335
+ }
336
+ } catch (error) {
337
+ fail(error)
338
+ return
339
+ }
340
+ json(res, 404, { error: "not found" })
341
+ return
342
+ }
343
+
196
344
  if (path.startsWith("/api/")) {
197
345
  const id = url.searchParams.get("board") ?? ordered[0]
198
346
  const board = boards.get(id)
@@ -219,6 +367,10 @@ export function createTaskServer(serveRoot: string): Server {
219
367
  prefix: store.config.prefix,
220
368
  statuses: STATUSES,
221
369
  author,
370
+ // The git capability, or nothing: its absence is how the hosted and
371
+ // snapshot deployments (whose /project never carries it) and a serve
372
+ // with no origin all tell the UI to draw no git surface at all.
373
+ git: gitServe.capability() ?? undefined,
222
374
  })
223
375
  return
224
376
  }
@@ -236,7 +388,7 @@ export function createTaskServer(serveRoot: string): Server {
236
388
  const counts = store.commentCounts()
237
389
  const tasks = store
238
390
  .list()
239
- .map((t) => ({ ...t, commentCount: counts.get(t.number) ?? 0 }))
391
+ .map((t) => ({ ...t, commentCount: counts.get(t.key) ?? 0 }))
240
392
  json(res, 200, { tasks })
241
393
  return
242
394
  }
@@ -248,19 +400,69 @@ export function createTaskServer(serveRoot: string): Server {
248
400
  throw new Error("title is required")
249
401
  }
250
402
  const patch = parsePatch(body)
251
- const task = store.create({ title: body.title.trim(), ...patch })
403
+ let task = store.create({ title: body.title.trim(), ...patch })
404
+ // Asks at create time — the dialog's chip. Author is the server's
405
+ // resolved identity, same trust rule as comments.
406
+ if (Array.isArray(body.asks)) {
407
+ for (const text of body.asks) {
408
+ if (typeof text === "string" && text.trim()) {
409
+ store.addAsk(task.key, text.trim(), author)
410
+ }
411
+ }
412
+ task = store.get(task.key)!
413
+ }
252
414
  json(res, 201, { task })
253
415
  })
254
416
  .catch((error: Error) => json(res, 400, { error: error.message }))
255
417
  return
256
418
  }
257
419
 
420
+ // Routed, not raw store calls: on a claimed ticket the write lands on the
421
+ // claim branch as a pushed commit (claim-io.ts) — the branch owns a
422
+ // claimed ticket's asks, checkbox and CLI alike.
423
+ const askMatch = /^\/api\/tasks\/([^/]+)\/asks(?:\/([^/]+))?$/.exec(path)
424
+ if (askMatch) {
425
+ const key = store.parseId(decodeURIComponent(askMatch[1]))
426
+ if (!askMatch[2] && req.method === "POST") {
427
+ void readBody(req)
428
+ .then((body) => {
429
+ if (typeof body.text !== "string" || !body.text.trim()) {
430
+ throw new Error("text is required")
431
+ }
432
+ const { ask, branch } = addAskRouted(store, key, body.text.trim(), author)
433
+ json(res, 201, { ask, branch })
434
+ })
435
+ .catch((error: Error) => json(res, 400, { error: error.message }))
436
+ return
437
+ }
438
+ if (askMatch[2] && req.method === "PATCH") {
439
+ const ref = decodeURIComponent(askMatch[2])
440
+ void readBody(req)
441
+ .then((body) => {
442
+ if (typeof body.resolved !== "boolean") {
443
+ throw new Error("resolved must be true or false")
444
+ }
445
+ const { ask, branch } = setAskResolvedRouted(store, key, ref, body.resolved, author)
446
+ json(res, 200, { ask, branch })
447
+ })
448
+ .catch((error: Error) => json(res, 400, { error: error.message }))
449
+ return
450
+ }
451
+ if (askMatch[2] && req.method === "DELETE") {
452
+ const { branch } = deleteAskRouted(store, key, decodeURIComponent(askMatch[2]))
453
+ json(res, 200, { ok: true, branch })
454
+ return
455
+ }
456
+ json(res, 404, { error: "not found" })
457
+ return
458
+ }
459
+
258
460
  const taskMatch = /^\/api\/tasks\/([^/]+)(\/comments(?:\/([^/]+))?)?$/.exec(path)
259
461
  if (taskMatch) {
260
- const number = store.parseId(decodeURIComponent(taskMatch[1]))
462
+ const key = store.parseId(decodeURIComponent(taskMatch[1]))
261
463
  if (taskMatch[3] && req.method === "DELETE") {
262
464
  const commentId = decodeURIComponent(taskMatch[3])
263
- const existing = store.comments(number).find((c) => c.id === commentId)
465
+ const existing = store.comments(key).find((c) => c.id === commentId)
264
466
  if (!existing) {
265
467
  json(res, 404, { error: `no such comment: ${commentId}` })
266
468
  return
@@ -271,7 +473,7 @@ export function createTaskServer(serveRoot: string): Server {
271
473
  json(res, 403, { error: "only your own comments can be deleted" })
272
474
  return
273
475
  }
274
- store.deleteComment(number, commentId)
476
+ store.deleteComment(key, commentId)
275
477
  json(res, 200, { ok: true })
276
478
  return
277
479
  }
@@ -282,7 +484,7 @@ export function createTaskServer(serveRoot: string): Server {
282
484
  throw new Error("body is required")
283
485
  }
284
486
  // `body.author` is ignored on purpose — unauthenticated noise.
285
- const comment = store.addComment(number, body.body.trim(), author)
487
+ const comment = store.addComment(key, body.body.trim(), author)
286
488
  json(res, 201, { comment })
287
489
  })
288
490
  .catch((error: Error) => json(res, 400, { error: error.message }))
@@ -290,25 +492,25 @@ export function createTaskServer(serveRoot: string): Server {
290
492
  }
291
493
  if (!taskMatch[2]) {
292
494
  if (req.method === "GET") {
293
- const task = store.get(number)
495
+ const task = store.get(key)
294
496
  if (!task) {
295
- json(res, 404, { error: `no such task: ${store.displayId(number)}` })
497
+ json(res, 404, { error: `no such task: ${store.displayId(key)}` })
296
498
  return
297
499
  }
298
- json(res, 200, { task, comments: store.comments(number) })
500
+ json(res, 200, { task, comments: store.comments(key) })
299
501
  return
300
502
  }
301
503
  if (req.method === "PATCH") {
302
504
  void readBody(req)
303
505
  .then((body) => {
304
- const task = store.update(number, parsePatch(body))
506
+ const task = store.update(key, parsePatch(body))
305
507
  json(res, 200, { task })
306
508
  })
307
509
  .catch((error: Error) => json(res, 400, { error: error.message }))
308
510
  return
309
511
  }
310
512
  if (req.method === "DELETE") {
311
- store.delete(number)
513
+ store.delete(key)
312
514
  json(res, 200, { ok: true })
313
515
  return
314
516
  }
package/src/store.test.ts CHANGED
@@ -14,7 +14,8 @@ import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "nod
14
14
  import { tmpdir } from "node:os"
15
15
  import { join } from "node:path"
16
16
  import { FileStore, initProject, openBoard } from "./file-store.ts"
17
- import { parseTicket, serializeTicket, type TicketDoc } from "./ticket-doc.ts"
17
+ import { KEY_PATTERN } from "./id.ts"
18
+ import { parseTicket, parseTicketLenient, serializeTicket, type TicketDoc } from "./ticket-doc.ts"
18
19
  import { findBoardsByPrefix, findScopeRoot } from "./store.ts"
19
20
 
20
21
  function tempRoot(): string {
@@ -31,7 +32,6 @@ test("ticket documents round-trip, including the awkward bodies", () => {
31
32
  status: "todo",
32
33
  tags: [],
33
34
  goal: null,
34
- needsHuman: false,
35
35
  blockedBy: [],
36
36
  prs: [],
37
37
  position: 1024,
@@ -46,8 +46,7 @@ test("ticket documents round-trip, including the awkward bodies", () => {
46
46
  status: "in_progress",
47
47
  tags: ["storage", "architecture"],
48
48
  goal: "v2-storage",
49
- needsHuman: true,
50
- blockedBy: [3, 7],
49
+ blockedBy: ["k3mfp", "x7c2d"],
51
50
  prs: ["https://github.com/x/y/pull/1"],
52
51
  position: -512.5,
53
52
  createdAt: "2026-08-15T16:00:33.758Z",
@@ -59,7 +58,6 @@ test("ticket documents round-trip, including the awkward bodies", () => {
59
58
  status: "backlog",
60
59
  tags: [],
61
60
  goal: null,
62
- needsHuman: false,
63
61
  blockedBy: [],
64
62
  prs: [],
65
63
  position: 0,
@@ -90,12 +88,15 @@ test("hand-edits are forgiven where they're unambiguous", () => {
90
88
  assert.equal(doc.goal, "launch")
91
89
  assert.equal(doc.position, 8)
92
90
 
93
- // The pre-0.8 spelling still reads as the goal old boards keep rendering,
94
- // and `task check --fix`'s canonical rewrite is the migration.
95
- const legacy = parseTicket(text.replace("goal: launch", "milestone: launch"), "test")
96
- assert.equal(legacy.goal, "launch")
97
- assert.match(serializeTicket(legacy), /goal: "launch"/)
98
- assert.doesNotMatch(serializeTicket(legacy), /milestone/)
91
+ // The pre-0.8 `milestone:` spelling is refused by name (TAS-41: no compat)
92
+ // reading past it would silently drop the goal, so the error carries the
93
+ // one edit that fixes the file. The lenient parser just ignores it.
94
+ assert.throws(
95
+ () => parseTicket(text.replace("goal: launch", "milestone: launch"), "test"),
96
+ /`milestone:` is the pre-0\.8 spelling.*rename the key to `goal:`/,
97
+ )
98
+ const degraded = parseTicketLenient(text.replace("goal: launch", "milestone: launch"))
99
+ assert.equal(degraded?.goal, null)
99
100
 
100
101
  assert.throws(
101
102
  () => parseTicket(text.replace("status: todo", "status: todoo"), "here"),
@@ -113,50 +114,61 @@ test("FileStore keeps the store's contract", () => {
113
114
  const a = store.create({ title: "First", description: "alpha", tags: ["api"], status: "todo" })
114
115
  const b = store.create({ title: "Second", status: "todo" })
115
116
  const c = store.create({ title: "Third", status: "backlog" })
116
- assert.deepEqual([a.number, b.number, c.number], [1, 2, 3])
117
+ // Keys are random, well-formed, and distinct — never sequential (TAS-42).
118
+ for (const t of [a, b, c]) assert.match(t.key, KEY_PATTERN)
119
+ assert.equal(new Set([a.key, b.key, c.key]).size, 3)
120
+ assert.equal(a.id, `SAM-${a.key}`)
117
121
  // New tasks land at the bottom of their column.
118
122
  assert.ok(b.position > a.position)
119
123
 
120
124
  // Status move without an explicit slot lands on top of the new column.
121
- const moved = store.update(c.number, { status: "todo" })
125
+ const moved = store.update(c.key, { status: "todo" })
122
126
  assert.ok(moved.position < a.position)
123
- assert.equal(store.list({ statuses: ["todo"] })[0].number, c.number)
127
+ assert.equal(store.list({ statuses: ["todo"] })[0].key, c.key)
124
128
 
125
129
  // One relation, two views, stored on one side only.
126
- store.link(a.number, "blocked_by", b.number)
127
- assert.deepEqual(store.get(b.number)!.blocks, [a.number])
128
- const onDisk = readFileSync(join(root, ".task", "tickets", "1", "ticket.md"), "utf8")
129
- assert.match(onDisk, /blocked_by: \[2\]/)
130
+ store.link(a.key, "blocked_by", b.key)
131
+ assert.deepEqual(store.get(b.key)!.blocks, [a.key])
132
+ const onDisk = readFileSync(join(root, ".task", "tickets", a.key, "ticket.md"), "utf8")
133
+ assert.ok(onDisk.includes(`blocked_by: ["${b.key}"]`))
130
134
  assert.doesNotMatch(
131
- readFileSync(join(root, ".task", "tickets", "2", "ticket.md"), "utf8"),
135
+ readFileSync(join(root, ".task", "tickets", b.key, "ticket.md"), "utf8"),
132
136
  /blocked_by/,
133
137
  )
134
- store.unlink(b.number, "blocks", a.number)
135
- assert.deepEqual(store.get(a.number)!.blockedBy, [])
136
-
137
- assert.throws(() => store.link(a.number, "blocks", a.number), /can't block itself/)
138
- assert.throws(() => store.update(a.number, { blockedBy: [99] }), /no such task: SAM-99/)
138
+ store.unlink(b.key, "blocks", a.key)
139
+ assert.deepEqual(store.get(a.key)!.blockedBy, [])
140
+
141
+ assert.throws(() => store.link(a.key, "blocks", a.key), /can't block itself/)
142
+ assert.throws(() => store.update(a.key, { blockedBy: ["zz9zz"] }), /no such task: SAM-zz9zz/)
143
+
144
+ // parseId: exact keys, PREFIX-key, and unique git-style prefixes all land.
145
+ assert.equal(store.parseId(a.key), a.key)
146
+ assert.equal(store.parseId(`SAM-${a.key}`), a.key)
147
+ assert.equal(store.parseId(a.key.toUpperCase()), a.key)
148
+ const uniquePrefix = [a, b, c].every((t) => t.key[0] !== a.key[0] || t.key === a.key)
149
+ ? a.key[0]
150
+ : a.key.slice(0, 4)
151
+ if ([a, b, c].filter((t) => t.key.startsWith(uniquePrefix)).length === 1) {
152
+ assert.equal(store.parseId(uniquePrefix), a.key)
153
+ }
154
+ assert.throws(() => store.parseId("not an id!"), /invalid task id/)
139
155
 
140
156
  // Comments: one file each, stable ids, author preserved.
141
- const comment = store.addComment(a.number, "first!\n\nwith **markdown**", "claude")
157
+ const comment = store.addComment(a.key, "first!\n\nwith **markdown**", "claude")
142
158
  assert.match(comment.id, /^\d{4}-\d{2}-\d{2}T\d{6}-claude$/)
143
- assert.equal(store.comments(a.number)[0].body, "first!\n\nwith **markdown**")
144
- assert.equal(store.commentCounts().get(a.number), 1)
145
- const second = store.addComment(a.number, "again", "claude")
159
+ assert.equal(store.comments(a.key)[0].body, "first!\n\nwith **markdown**")
160
+ assert.equal(store.commentCounts().get(a.key), 1)
161
+ const second = store.addComment(a.key, "again", "claude")
146
162
  assert.notEqual(second.id, comment.id)
147
- store.deleteComment(a.number, second.id)
148
- assert.equal(store.comments(a.number).length, 1)
149
- assert.throws(() => store.deleteComment(a.number, "../../escape"), /no such comment/)
163
+ store.deleteComment(a.key, second.id)
164
+ assert.equal(store.comments(a.key).length, 1)
165
+ assert.throws(() => store.deleteComment(a.key, "../../escape"), /no such comment/)
150
166
 
151
167
  // Delete cascades the relation away, like the FK did.
152
- store.link(b.number, "blocked_by", a.number)
153
- store.delete(a.number)
154
- assert.equal(store.get(a.number), null)
155
- assert.deepEqual(store.get(b.number)!.blockedBy, [])
156
-
157
- // Numbers never move backwards past an existing ticket.
158
- const d = store.create({ title: "Fourth" })
159
- assert.equal(d.number, 4)
168
+ store.link(b.key, "blocked_by", a.key)
169
+ store.delete(a.key)
170
+ assert.equal(store.get(a.key), null)
171
+ assert.deepEqual(store.get(b.key)!.blockedBy, [])
160
172
  })
161
173
 
162
174
  test("openBoard reads text boards, and refuses a pre-0.6 SQLite board loudly", () => {
@@ -192,45 +204,48 @@ test("archive moves finished tickets off the hot path, losing nothing", () => {
192
204
  const store = initProject(root, { name: "archive test", prefix: "ARC" })
193
205
  const keep = store.create({ title: "Still open", status: "todo" })
194
206
  const old = store.create({ title: "Finished work", status: "todo" })
195
- store.link(keep.number, "blocked_by", old.number)
196
- store.addComment(old.number, "how it went", "nick")
207
+ store.link(keep.key, "blocked_by", old.key)
208
+ store.addComment(old.key, "how it went", "nick")
197
209
 
198
210
  // Only finished work qualifies — archive is history, not a hiding place.
199
- assert.throws(() => store.archive(old.number), /is todo/)
200
- store.update(old.number, { status: "done" })
201
- const archived = store.archive(old.number)
211
+ assert.throws(() => store.archive(old.key), /is todo/)
212
+ store.update(old.key, { status: "done" })
213
+ const archived = store.archive(old.key)
202
214
  assert.equal(archived.archived, true)
203
215
 
204
216
  // Off the board and out of every default read…
205
- assert.deepEqual(store.list({}).map((t) => t.number), [keep.number])
206
- assert.equal(store.commentCounts().get(old.number), undefined)
217
+ assert.deepEqual(store.list({}).map((t) => t.key), [keep.key])
218
+ assert.equal(store.commentCounts().get(old.key), undefined)
207
219
  // …but reads still reach it, comments included, and its links still show.
208
- const shown = store.get(old.number)!
220
+ const shown = store.get(old.key)!
209
221
  assert.equal(shown.archived, true)
210
- assert.deepEqual(shown.blocks, [keep.number])
211
- assert.equal(store.comments(old.number)[0].body, "how it went")
222
+ assert.deepEqual(shown.blocks, [keep.key])
223
+ assert.equal(store.comments(old.key)[0].body, "how it went")
212
224
  const listed = store.list({ archived: true })
213
- assert.deepEqual(listed.map((t) => [t.number, t.archived]), [[old.number, true]])
225
+ assert.deepEqual(listed.map((t) => [t.key, t.archived]), [[old.key, true]])
214
226
 
215
227
  // Writes refuse until unarchived, with a message that says what to do.
216
- assert.throws(() => store.update(old.number, { title: "x" }), /archived — run/)
217
- assert.throws(() => store.addComment(old.number, "x", "y"), /archived — run/)
218
- assert.throws(() => store.delete(old.number), /archived — run/)
219
- assert.throws(() => store.archive(old.number), /already archived/)
228
+ assert.throws(() => store.update(old.key, { title: "x" }), /archived — run/)
229
+ assert.throws(() => store.addComment(old.key, "x", "y"), /archived — run/)
230
+ assert.throws(() => store.delete(old.key), /archived — run/)
231
+ assert.throws(() => store.archive(old.key), /already archived/)
220
232
  // Linking *to* an archived target refuses; pruning a stale link succeeds.
221
- assert.throws(() => store.link(keep.number, "blocked_by", old.number), /archived — run/)
222
- assert.deepEqual(store.update(keep.number, { blockedBy: [] }).blockedBy, [])
233
+ assert.throws(() => store.link(keep.key, "blocked_by", old.key), /archived — run/)
234
+ assert.deepEqual(store.update(keep.key, { blockedBy: [] }).blockedBy, [])
223
235
 
224
- // Numbers stay reserved — the next ticket never reuses an archived one.
225
- assert.equal(store.create({ title: "Next" }).number, 3)
236
+ // Keys stay reserved — a new mint can never take an archived ticket's name.
237
+ const next = store.create({ title: "Next" })
238
+ assert.notEqual(next.key, old.key)
239
+ // An archived key still resolves through parseId (exact match).
240
+ assert.equal(store.parseId(`ARC-${old.key}`), old.key)
226
241
 
227
242
  // Round trip: unarchive puts it back exactly as it was.
228
- const back = store.unarchive(old.number)
243
+ const back = store.unarchive(old.key)
229
244
  assert.equal(back.archived, undefined)
230
245
  assert.equal(back.status, "done")
231
246
  assert.deepEqual(store.list({ archived: true }), [])
232
- assert.equal(store.comments(old.number).length, 1)
233
- assert.throws(() => store.unarchive(old.number), /isn't archived/)
247
+ assert.equal(store.comments(old.key).length, 1)
248
+ assert.throws(() => store.unarchive(old.key), /isn't archived/)
234
249
  })
235
250
 
236
251
  test("goals: titled and described, one per task, progress always derived", () => {
@@ -251,13 +266,13 @@ test("goals: titled and described, one per task, progress always derived", () =>
251
266
  assert.throws(() => store.create({ title: "T", goal: "nope" }), /no such goal/)
252
267
  const a = store.create({ title: "First", goal: goal.slug })
253
268
  const b = store.create({ title: "Second" })
254
- store.update(b.number, { goal: goal.slug })
255
- assert.equal(store.get(b.number)!.goal, goal.slug)
256
- assert.deepEqual(store.list({ goal: goal.slug }).map((t) => t.number), [a.number, b.number])
269
+ store.update(b.key, { goal: goal.slug })
270
+ assert.equal(store.get(b.key)!.goal, goal.slug)
271
+ assert.deepEqual(store.list({ goal: goal.slug }).map((t) => t.key), [a.key, b.key])
257
272
 
258
273
  // The file is the state: goal ref serialized on the ticket, goal file on disk.
259
274
  assert.match(
260
- readFileSync(join(root, ".task", "tickets", "1", "ticket.md"), "utf8"),
275
+ readFileSync(join(root, ".task", "tickets", a.key, "ticket.md"), "utf8"),
261
276
  /goal: "trust-guardrail-layer"/,
262
277
  )
263
278
  const goalFile = readFileSync(join(root, ".task", "goals", "trust-guardrail-layer.md"), "utf8")
@@ -266,26 +281,29 @@ test("goals: titled and described, one per task, progress always derived", () =>
266
281
  // Update touches the file; clearing a task's goal is `goal: null`.
267
282
  store.updateGoal(goal.slug, { description: "Sharper why." })
268
283
  assert.equal(store.getGoal(goal.slug)!.description, "Sharper why.")
269
- store.update(b.number, { goal: null })
270
- assert.equal(store.get(b.number)!.goal, null)
284
+ store.update(b.key, { goal: null })
285
+ assert.equal(store.get(b.key)!.goal, null)
271
286
 
272
287
  // Archive refuses while open work references the goal, allows after.
273
- assert.throws(() => store.archiveGoal(goal.slug), /still has open tasks \(GOA-1\)/)
274
- store.update(a.number, { status: "done" })
288
+ assert.throws(
289
+ () => store.archiveGoal(goal.slug),
290
+ new RegExp(`still has open tasks \\(GOA-${a.key}\\)`),
291
+ )
292
+ store.update(a.key, { status: "done" })
275
293
  const shelved = store.archiveGoal(goal.slug)
276
294
  assert.equal(shelved.archived, true)
277
295
  assert.deepEqual(store.goals().map((g) => g.slug), [])
278
296
  assert.deepEqual(store.goals(true).map((g) => g.slug), [goal.slug])
279
297
  // Still resolvable (done tickets keep their ref readable), not assignable.
280
298
  assert.equal(store.getGoal(goal.slug)!.title, "Trust & guardrail layer")
281
- assert.throws(() => store.update(b.number, { goal: goal.slug }), /is archived — run/)
299
+ assert.throws(() => store.update(b.key, { goal: goal.slug }), /is archived — run/)
282
300
  assert.throws(() => store.updateGoal(goal.slug, { title: "x" }), /is archived — run/)
283
301
 
284
302
  // Delete needs every live ref cleared first — pruning organization is a
285
303
  // human call, so the human makes it.
286
304
  store.unarchiveGoal(goal.slug)
287
- assert.throws(() => store.deleteGoal(goal.slug), /referenced by GOA-1/)
288
- store.update(a.number, { goal: null })
305
+ assert.throws(() => store.deleteGoal(goal.slug), new RegExp(`referenced by GOA-${a.key}`))
306
+ store.update(a.key, { goal: null })
289
307
  store.deleteGoal(goal.slug)
290
308
  assert.equal(store.getGoal(goal.slug), null)
291
309
  })
@@ -312,22 +330,22 @@ test("concurrent-writer shapes merge by construction", () => {
312
330
 
313
331
  // Two agents commenting at once = two files; simulate the second writer by
314
332
  // dropping its file in directly, the way a git merge would.
315
- store.addComment(task.number, "from session one", "claude")
316
- const commentsDir = join(root, ".task", "tickets", String(task.number), "comments")
333
+ store.addComment(task.key, "from session one", "claude")
334
+ const commentsDir = join(root, ".task", "tickets", task.key, "comments")
317
335
  writeFileSync(
318
336
  join(commentsDir, "2026-08-15T170000-other.md"),
319
337
  "---\nauthor: \"other\"\ncreated: 2026-08-15T17:00:00.000Z\n---\n\nfrom session two\n",
320
338
  )
321
- assert.equal(store.comments(task.number).length, 2)
339
+ assert.equal(store.comments(task.key).length, 2)
322
340
 
323
341
  // A claim is a one-line diff: exactly one meaningful frontmatter line
324
342
  // changes. `updated:` is excluded because it only changes when the update
325
343
  // lands in a different millisecond than the create — true except when this
326
344
  // test runs fast enough, which is exactly the kind of coin-flip a test must
327
345
  // not depend on.
328
- const path = join(root, ".task", "tickets", String(task.number), "ticket.md")
346
+ const path = join(root, ".task", "tickets", task.key, "ticket.md")
329
347
  const before = readFileSync(path, "utf8").split("\n")
330
- store.update(task.number, { status: "in_progress", position: task.position })
348
+ store.update(task.key, { status: "in_progress", position: task.position })
331
349
  const after = readFileSync(path, "utf8").split("\n")
332
350
  const changed = before.filter((line, i) => after[i] !== line)
333
351
  assert.deepEqual(