@nickmeriano/task 0.8.0 → 0.9.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 (114) hide show
  1. package/README.md +65 -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 +21 -22
  17. package/dist/claim.d.ts.map +1 -1
  18. package/dist/claim.js +58 -83
  19. package/dist/claim.js.map +1 -1
  20. package/dist/claim.test.d.ts +2 -2
  21. package/dist/claim.test.js +111 -94
  22. package/dist/claim.test.js.map +1 -1
  23. package/dist/cli.js +241 -64
  24. package/dist/cli.js.map +1 -1
  25. package/dist/file-store.d.ts +70 -24
  26. package/dist/file-store.d.ts.map +1 -1
  27. package/dist/file-store.js +319 -141
  28. package/dist/file-store.js.map +1 -1
  29. package/dist/git-serve.d.ts +183 -0
  30. package/dist/git-serve.d.ts.map +1 -0
  31. package/dist/git-serve.js +503 -0
  32. package/dist/git-serve.js.map +1 -0
  33. package/dist/git-serve.test.d.ts +16 -0
  34. package/dist/git-serve.test.d.ts.map +1 -0
  35. package/dist/git-serve.test.js +183 -0
  36. package/dist/git-serve.test.js.map +1 -0
  37. package/dist/git.d.ts +65 -0
  38. package/dist/git.d.ts.map +1 -0
  39. package/dist/git.js +114 -0
  40. package/dist/git.js.map +1 -0
  41. package/dist/id.d.ts +39 -0
  42. package/dist/id.d.ts.map +1 -0
  43. package/dist/id.js +67 -0
  44. package/dist/id.js.map +1 -0
  45. package/dist/inbox.d.ts +41 -0
  46. package/dist/inbox.d.ts.map +1 -0
  47. package/dist/inbox.js +56 -0
  48. package/dist/inbox.js.map +1 -0
  49. package/dist/overview.d.ts +9 -0
  50. package/dist/overview.d.ts.map +1 -1
  51. package/dist/overview.js +19 -11
  52. package/dist/overview.js.map +1 -1
  53. package/dist/overview.test.js +6 -5
  54. package/dist/overview.test.js.map +1 -1
  55. package/dist/promote.test.d.ts +3 -2
  56. package/dist/promote.test.d.ts.map +1 -1
  57. package/dist/promote.test.js +29 -31
  58. package/dist/promote.test.js.map +1 -1
  59. package/dist/publish.d.ts +2 -17
  60. package/dist/publish.d.ts.map +1 -1
  61. package/dist/publish.js +4 -49
  62. package/dist/publish.js.map +1 -1
  63. package/dist/search.d.ts +3 -1
  64. package/dist/search.d.ts.map +1 -1
  65. package/dist/search.js +15 -2
  66. package/dist/search.js.map +1 -1
  67. package/dist/search.test.js +9 -9
  68. package/dist/search.test.js.map +1 -1
  69. package/dist/server.d.ts.map +1 -1
  70. package/dist/server.js +218 -21
  71. package/dist/server.js.map +1 -1
  72. package/dist/store.d.ts +28 -14
  73. package/dist/store.d.ts.map +1 -1
  74. package/dist/store.js.map +1 -1
  75. package/dist/store.test.js +88 -75
  76. package/dist/store.test.js.map +1 -1
  77. package/dist/ticket-doc.d.ts +26 -10
  78. package/dist/ticket-doc.d.ts.map +1 -1
  79. package/dist/ticket-doc.js +71 -27
  80. package/dist/ticket-doc.js.map +1 -1
  81. package/dist/types.d.ts +51 -17
  82. package/dist/types.d.ts.map +1 -1
  83. package/dist/types.js.map +1 -1
  84. package/package.json +1 -1
  85. package/skill/SKILL.md +70 -23
  86. package/src/asks.test.ts +355 -0
  87. package/src/check.test.ts +129 -72
  88. package/src/check.ts +101 -58
  89. package/src/claim-io.ts +401 -0
  90. package/src/claim.test.ts +141 -99
  91. package/src/claim.ts +69 -96
  92. package/src/cli.ts +251 -63
  93. package/src/file-store.ts +357 -145
  94. package/src/git-serve.test.ts +240 -0
  95. package/src/git-serve.ts +595 -0
  96. package/src/git.ts +141 -0
  97. package/src/id.ts +68 -0
  98. package/src/inbox.ts +77 -0
  99. package/src/overview.test.ts +6 -5
  100. package/src/overview.ts +25 -10
  101. package/src/promote.test.ts +40 -28
  102. package/src/publish.ts +6 -53
  103. package/src/search.test.ts +11 -11
  104. package/src/search.ts +17 -3
  105. package/src/server.ts +221 -19
  106. package/src/store.test.ts +95 -77
  107. package/src/store.ts +28 -13
  108. package/src/ticket-doc.ts +93 -30
  109. package/src/types.ts +52 -17
  110. package/ui/dist/assets/index-BjsorZOU.js +229 -0
  111. package/ui/dist/assets/index-CoKCUYic.css +1 -0
  112. package/ui/dist/index.html +2 -2
  113. package/ui/dist/assets/index-BJmOsOdR.js +0 -229
  114. package/ui/dist/assets/index-BoqQlqSU.css +0 -1
package/src/file-store.ts CHANGED
@@ -11,16 +11,20 @@ import { basename, dirname, join } from "node:path"
11
11
  import {
12
12
  commentStem,
13
13
  isGoalSlug,
14
+ parseAsk,
14
15
  parseComment,
15
16
  parseGoal,
16
17
  parseTicket,
18
+ serializeAsk,
17
19
  serializeComment,
18
20
  serializeGoal,
19
21
  serializeTicket,
20
22
  slugifyGoal,
23
+ type AskDoc,
21
24
  type GoalDoc,
22
25
  type TicketDoc,
23
26
  } from "./ticket-doc.ts"
27
+ import { isTicketKey, mintKey } from "./id.ts"
24
28
  import {
25
29
  CONFIG_FILE,
26
30
  POSITION_GAP,
@@ -29,6 +33,7 @@ import {
29
33
  type Store,
30
34
  } from "./store.ts"
31
35
  import type {
36
+ Ask,
32
37
  Comment,
33
38
  Goal,
34
39
  GoalInput,
@@ -46,6 +51,8 @@ export const TICKETS_DIR = "tickets"
46
51
  export const ARCHIVE_DIR = "archive"
47
52
  export const TICKET_FILE = "ticket.md"
48
53
  export const COMMENTS_DIR = "comments"
54
+ /** One file per ask — `tickets/<key>/asks/<stem>.md`, the comment layout. */
55
+ export const ASKS_DIR = "asks"
49
56
  /** One file per goal — `.task/goals/<slug>.md`; archived ones one level down. */
50
57
  export const GOALS_DIR = "goals"
51
58
  export const GOALS_ARCHIVE_DIR = "archive"
@@ -65,17 +72,28 @@ function writeAtomic(path: string, text: string): void {
65
72
  }
66
73
 
67
74
  interface Ticket {
68
- number: number
75
+ key: string
69
76
  doc: TicketDoc
70
77
  }
71
78
 
79
+ /** Stable sort for key lists — lexicographic, since keys carry no order. */
80
+ function sortKeys(keys: string[]): string[] {
81
+ return keys.sort((a, b) => a.localeCompare(b))
82
+ }
83
+
84
+ /** Board order's tiebreak: creation time, then key — keys themselves say nothing. */
85
+ function byCreation(a: Ticket, b: Ticket): number {
86
+ return a.doc.createdAt.localeCompare(b.doc.createdAt) || a.key.localeCompare(b.key)
87
+ }
88
+
72
89
  /**
73
90
  * The canonical persistence layer: one directory per ticket under
74
- * `.task/tickets/`, holding a frontmatter+markdown `ticket.md` and one file
75
- * per comment. Everything is plain text committed to git, which is the point —
76
- * ticket edits diff, review, and merge like code, and two branches touching
77
- * different tickets (or adding comments to the same one) merge cleanly by
78
- * construction.
91
+ * `.task/tickets/`, named by the ticket's key (see id.ts), holding a
92
+ * frontmatter+markdown `ticket.md` and one file per comment. Everything is
93
+ * plain text committed to git, which is the point ticket edits diff, review,
94
+ * and merge like code, and two branches creating tickets (or adding comments
95
+ * to the same one) merge cleanly by construction: keys are random, so parallel
96
+ * branches can't mint the same directory.
79
97
  *
80
98
  * There is no database and no cache: boards are dozens of tickets, and
81
99
  * re-reading a handful of small files per operation is cheaper than opening
@@ -105,44 +123,57 @@ export class FileStore implements Store {
105
123
 
106
124
  close(): void {}
107
125
 
108
- displayId(number: number): string {
109
- return `${this.config.prefix}-${number}`
126
+ displayId(key: string): string {
127
+ return `${this.config.prefix}-${key}`
110
128
  }
111
129
 
112
- /** Accepts "PHONE-12", "phone-12" or "12". */
113
- parseId(ref: string): number {
114
- const match = /^(?:[A-Za-z0-9]+-)?(\d+)$/.exec(ref.trim())
130
+ /**
131
+ * "PHO-x7k4m", "x7k4m", or any unique prefix of a key ("x7") resolved
132
+ * git-style against the board's live and archived tickets, case-insensitive.
133
+ * An ambiguous prefix throws naming the candidates; an unknown ref comes
134
+ * back verbatim, so the caller's lookup says "no such task" with it.
135
+ */
136
+ parseId(ref: string): string {
137
+ const match = /^(?:[A-Za-z0-9]+-)?([A-Za-z0-9]+)$/.exec(ref.trim())
115
138
  if (!match) throw new Error(`invalid task id: ${ref}`)
116
- return Number(match[1])
139
+ const key = match[1].toLowerCase()
140
+ const known = [...this.liveKeys(), ...this.archivedKeys()]
141
+ if (known.includes(key)) return key
142
+ const hits = sortKeys(known.filter((k) => k.startsWith(key)))
143
+ if (hits.length === 1) return hits[0]
144
+ if (hits.length > 1) {
145
+ throw new Error(
146
+ `${ref} is ambiguous — matches ${hits.map((k) => this.displayId(k)).join(", ")}`,
147
+ )
148
+ }
149
+ return key
117
150
  }
118
151
 
119
152
  // ── Reading ────────────────────────────────────────────────────────────────
120
153
 
121
- private ticketPath(number: number): string {
122
- return join(this.ticketsDir, String(number), TICKET_FILE)
154
+ private ticketPath(key: string): string {
155
+ return join(this.ticketsDir, key, TICKET_FILE)
123
156
  }
124
157
 
125
158
  /**
126
159
  * A ticket's comments live wherever the ticket does — the whole directory
127
160
  * moves on archive, so an archived ticket's discussion stays readable.
128
161
  */
129
- private commentsPath(number: number): string {
130
- const home = existsSync(join(this.archiveDir, String(number)))
131
- ? this.archiveDir
132
- : this.ticketsDir
133
- return join(home, String(number), COMMENTS_DIR)
162
+ private commentsPath(key: string): string {
163
+ const home = existsSync(join(this.archiveDir, key)) ? this.archiveDir : this.ticketsDir
164
+ return join(home, key, COMMENTS_DIR)
134
165
  }
135
166
 
136
167
  private readDir(dir: string): Ticket[] {
137
168
  if (!existsSync(dir)) return []
138
169
  const tickets: Ticket[] = []
139
170
  for (const entry of readdirSync(dir, { withFileTypes: true })) {
140
- if (!entry.isDirectory() || !/^\d+$/.test(entry.name)) continue
171
+ if (!entry.isDirectory() || !isTicketKey(entry.name)) continue
141
172
  const path = join(dir, entry.name, TICKET_FILE)
142
173
  if (!existsSync(path)) continue
143
- tickets.push({ number: Number(entry.name), doc: parseTicket(readFileSync(path, "utf8"), path) })
174
+ tickets.push({ key: entry.name, doc: parseTicket(readFileSync(path, "utf8"), path) })
144
175
  }
145
- tickets.sort((a, b) => a.number - b.number)
176
+ tickets.sort(byCreation)
146
177
  return tickets
147
178
  }
148
179
 
@@ -151,56 +182,66 @@ export class FileStore implements Store {
151
182
  return this.readDir(this.ticketsDir)
152
183
  }
153
184
 
154
- private readOne(number: number): Ticket | null {
155
- const path = this.ticketPath(number)
156
- if (!existsSync(path)) return null
157
- return { number, doc: parseTicket(readFileSync(path, "utf8"), path) }
185
+ private readOne(key: string): Ticket | null {
186
+ const path = this.ticketPath(key)
187
+ if (!isTicketKey(key) || !existsSync(path)) return null
188
+ return { key, doc: parseTicket(readFileSync(path, "utf8"), path) }
158
189
  }
159
190
 
160
- /** Cheap presence check the archive's numbers, without parsing anything. */
161
- private archivedNumbers(): number[] {
162
- if (!existsSync(this.archiveDir)) return []
163
- return readdirSync(this.archiveDir)
164
- .filter((name) => /^\d+$/.test(name))
165
- .map(Number)
191
+ /** Directory names that are well-formed keys, without parsing anything. */
192
+ private keysIn(dir: string): string[] {
193
+ if (!existsSync(dir)) return []
194
+ return readdirSync(dir).filter(isTicketKey)
166
195
  }
167
196
 
168
- private isArchived(number: number): boolean {
169
- return existsSync(join(this.archiveDir, String(number), TICKET_FILE))
197
+ private liveKeys(): string[] {
198
+ return this.keysIn(this.ticketsDir)
199
+ }
200
+
201
+ /** Cheap presence check — the archive's keys, without parsing anything. */
202
+ private archivedKeys(): string[] {
203
+ return this.keysIn(this.archiveDir)
204
+ }
205
+
206
+ private isArchived(key: string): boolean {
207
+ return isTicketKey(key) && existsSync(join(this.archiveDir, key, TICKET_FILE))
170
208
  }
171
209
 
172
210
  /** The error every write path throws instead of touching the archive. */
173
- private assertNotArchived(number: number): void {
174
- if (this.isArchived(number)) {
175
- const id = this.displayId(number)
211
+ private assertNotArchived(key: string): void {
212
+ if (this.isArchived(key)) {
213
+ const id = this.displayId(key)
176
214
  throw new Error(`${id} is archived — run \`task unarchive ${id}\` first`)
177
215
  }
178
216
  }
179
217
 
180
218
  private writeTicket(ticket: Ticket): void {
181
- mkdirSync(dirname(this.ticketPath(ticket.number)), { recursive: true })
182
- writeAtomic(this.ticketPath(ticket.number), serializeTicket(ticket.doc))
219
+ mkdirSync(dirname(this.ticketPath(ticket.key)), { recursive: true })
220
+ writeAtomic(this.ticketPath(ticket.key), serializeTicket(ticket.doc))
183
221
  }
184
222
 
185
223
  /**
186
224
  * Only `blocked_by` is stored (on the blocked ticket); the `blocks` side is
187
- * derived here, so the two views can never disagree.
225
+ * derived here, so the two views can never disagree. `needsHuman` is derived
226
+ * too — open asks, nothing else — so a flagged ticket with no stated reason
227
+ * cannot exist.
188
228
  */
189
- private toTask(ticket: Ticket, all: Ticket[]): Task {
190
- const blocks = all
191
- .filter((t) => t.doc.blockedBy.includes(ticket.number))
192
- .map((t) => t.number)
229
+ private toTask(ticket: Ticket, all: Ticket[], asks: Ask[] = this.asks(ticket.key)): Task {
230
+ const blocks = sortKeys(
231
+ all.filter((t) => t.doc.blockedBy.includes(ticket.key)).map((t) => t.key),
232
+ )
193
233
  return {
194
- id: this.displayId(ticket.number),
195
- number: ticket.number,
234
+ id: this.displayId(ticket.key),
235
+ key: ticket.key,
196
236
  title: ticket.doc.title,
197
237
  description: ticket.doc.description,
198
238
  status: ticket.doc.status,
199
239
  tags: ticket.doc.tags,
200
240
  goal: ticket.doc.goal,
201
- needsHuman: ticket.doc.needsHuman,
241
+ needsHuman: asks.some((a) => !a.resolvedAt),
242
+ asks,
202
243
  blocks,
203
- blockedBy: [...ticket.doc.blockedBy].sort((a, b) => a - b),
244
+ blockedBy: sortKeys([...ticket.doc.blockedBy]),
204
245
  prs: ticket.doc.prs,
205
246
  position: ticket.doc.position,
206
247
  createdAt: ticket.doc.createdAt,
@@ -217,7 +258,11 @@ export class FileStore implements Store {
217
258
  tickets = tickets.filter((t) => t.doc.goal === filter.goal)
218
259
  }
219
260
  if (filter.needsHuman !== undefined) {
220
- tickets = tickets.filter((t) => t.doc.needsHuman === filter.needsHuman)
261
+ // The derived flag, same definition as toTask — filtering must never
262
+ // disagree with what the payload says.
263
+ tickets = tickets.filter(
264
+ (t) => this.asks(t.key).some((a) => !a.resolvedAt) === filter.needsHuman,
265
+ )
221
266
  }
222
267
  if (filter.tags?.length) {
223
268
  const wanted = new Set(filter.tags)
@@ -235,22 +280,23 @@ export class FileStore implements Store {
235
280
  const everything = [...this.readAll(), ...archived]
236
281
  return this.applyFilter(archived, filter)
237
282
  .map((t) => ({ ...this.toTask(t, everything), archived: true }))
238
- .sort((a, b) => a.position - b.position || a.number - b.number)
283
+ .sort((a, b) => a.position - b.position || byCreationTask(a, b))
239
284
  }
240
285
  const all = this.readAll()
241
286
  return this.applyFilter(all, filter)
242
287
  .map((t) => this.toTask(t, all))
243
- .sort((a, b) => a.position - b.position || a.number - b.number)
288
+ .sort((a, b) => a.position - b.position || byCreationTask(a, b))
244
289
  }
245
290
 
246
- get(number: number): Task | null {
291
+ get(key: string): Task | null {
247
292
  const all = this.readAll()
248
- const ticket = all.find((t) => t.number === number)
293
+ const ticket = all.find((t) => t.key === key)
249
294
  if (ticket) return this.toTask(ticket, all)
250
295
  // `show` should reach the archive without ceremony — reads are safe.
251
- const path = join(this.archiveDir, String(number), TICKET_FILE)
296
+ if (!isTicketKey(key)) return null
297
+ const path = join(this.archiveDir, key, TICKET_FILE)
252
298
  if (!existsSync(path)) return null
253
- const archived: Ticket = { number, doc: parseTicket(readFileSync(path, "utf8"), path) }
299
+ const archived: Ticket = { key, doc: parseTicket(readFileSync(path, "utf8"), path) }
254
300
  return { ...this.toTask(archived, [...all, archived]), archived: true }
255
301
  }
256
302
 
@@ -258,15 +304,16 @@ export class FileStore implements Store {
258
304
 
259
305
  create(input: TaskInput): Task {
260
306
  const all = this.readAll()
261
- // Archived numbers stay reserved a new ticket must never take a number
262
- // that old comments or PR titles still point at.
263
- const taken = [...all.map((t) => t.number), ...this.archivedNumbers()]
264
- const number = taken.reduce((max, n) => Math.max(max, n), 0) + 1
307
+ // Random, not sequential (TAS-42): the key must be safe to mint on any
308
+ // branch with no coordination. Archived keys stay reserved — a new ticket
309
+ // must never take a name old comments or PR titles still point at.
310
+ const taken = new Set([...all.map((t) => t.key), ...this.archivedKeys()])
311
+ const key = mintKey(taken)
265
312
  // Dump by default: new tickets land in backlog, the no-quality-bar list;
266
313
  // `todo` (the ready list) is an explicit choice — usually `task promote`.
267
314
  const status = input.status ?? "backlog"
268
315
  for (const target of [...(input.blocks ?? []), ...(input.blockedBy ?? [])]) {
269
- if (!all.some((t) => t.number === target)) {
316
+ if (!all.some((t) => t.key === target)) {
270
317
  throw new Error(`no such task: ${this.displayId(target)}`)
271
318
  }
272
319
  }
@@ -276,15 +323,14 @@ export class FileStore implements Store {
276
323
  const bottom = column.length ? Math.max(...column.map((t) => t.doc.position)) : 0
277
324
  const timestamp = now()
278
325
  const ticket: Ticket = {
279
- number,
326
+ key,
280
327
  doc: {
281
328
  title: input.title,
282
329
  description: input.description ?? "",
283
330
  status,
284
331
  tags: input.tags ?? [],
285
332
  goal: input.goal ?? null,
286
- needsHuman: input.needsHuman ?? false,
287
- blockedBy: [...new Set(input.blockedBy ?? [])].sort((a, b) => a - b),
333
+ blockedBy: sortKeys([...new Set(input.blockedBy ?? [])]),
288
334
  prs: input.prs ?? [],
289
335
  position: bottom + POSITION_GAP,
290
336
  createdAt: timestamp,
@@ -293,27 +339,27 @@ export class FileStore implements Store {
293
339
  }
294
340
  this.writeTicket(ticket)
295
341
  if (input.blocks !== undefined) {
296
- this.reconcileBlocks(number, [...all, ticket], input.blocks, timestamp)
342
+ this.reconcileBlocks(key, [...all, ticket], input.blocks, timestamp)
297
343
  }
298
- return this.get(number)!
344
+ return this.get(key)!
299
345
  }
300
346
 
301
- update(number: number, patch: TaskPatch): Task {
347
+ update(key: string, patch: TaskPatch): Task {
302
348
  const all = this.readAll()
303
- const ticket = all.find((t) => t.number === number)
349
+ const ticket = all.find((t) => t.key === key)
304
350
  if (!ticket) {
305
- this.assertNotArchived(number)
306
- throw new Error(`no such task: ${this.displayId(number)}`)
351
+ this.assertNotArchived(key)
352
+ throw new Error(`no such task: ${this.displayId(key)}`)
307
353
  }
308
354
 
309
355
  // Validate link targets before any file is written, so a bad target
310
356
  // rejects the whole patch — the transactional behavior the SQLite store
311
357
  // got for free.
312
358
  for (const target of [...(patch.blocks ?? []), ...(patch.blockedBy ?? [])]) {
313
- if (target === number) {
314
- throw new Error(`a task can't block itself: ${this.displayId(number)}`)
359
+ if (target === key) {
360
+ throw new Error(`a task can't block itself: ${this.displayId(key)}`)
315
361
  }
316
- if (!all.some((t) => t.number === target)) {
362
+ if (!all.some((t) => t.key === target)) {
317
363
  this.assertNotArchived(target)
318
364
  throw new Error(`no such task: ${this.displayId(target)}`)
319
365
  }
@@ -322,14 +368,14 @@ export class FileStore implements Store {
322
368
  const timestamp = now()
323
369
  let linksChanged = false
324
370
  if (patch.blocks !== undefined) {
325
- linksChanged = this.reconcileBlocks(number, all, patch.blocks, timestamp)
371
+ linksChanged = this.reconcileBlocks(key, all, patch.blocks, timestamp)
326
372
  }
327
373
  if (patch.blockedBy !== undefined) {
328
- const wanted = [...new Set(patch.blockedBy)].sort((a, b) => a - b)
374
+ const wanted = sortKeys([...new Set(patch.blockedBy)])
329
375
  const current = ticket.doc.blockedBy
330
376
  const touched = [
331
- ...current.filter((n) => !wanted.includes(n)),
332
- ...wanted.filter((n) => !current.includes(n)),
377
+ ...current.filter((k) => !wanted.includes(k)),
378
+ ...wanted.filter((k) => !current.includes(k)),
333
379
  ]
334
380
  if (touched.length > 0) {
335
381
  linksChanged = true
@@ -337,8 +383,8 @@ export class FileStore implements Store {
337
383
  // The other end of every added or removed link gets its `updated`
338
384
  // bumped too — its derived `blocks` view just changed. A dangling
339
385
  // reference (hand-edit pointing at a deleted ticket) has no other end.
340
- for (const n of touched) {
341
- const other = all.find((t) => t.number === n)
386
+ for (const k of touched) {
387
+ const other = all.find((t) => t.key === k)
342
388
  if (!other) continue
343
389
  other.doc.updatedAt = timestamp
344
390
  this.writeTicket(other)
@@ -348,8 +394,8 @@ export class FileStore implements Store {
348
394
 
349
395
  const doc = ticket.doc
350
396
  let fieldsChanged = false
351
- const set = <K extends keyof TicketDoc>(key: K, value: TicketDoc[K]) => {
352
- doc[key] = value
397
+ const set = <K extends keyof TicketDoc>(field: K, value: TicketDoc[K]) => {
398
+ doc[field] = value
353
399
  fieldsChanged = true
354
400
  }
355
401
 
@@ -360,15 +406,28 @@ export class FileStore implements Store {
360
406
  if (patch.goal) this.assertGoalAssignable(patch.goal)
361
407
  set("goal", patch.goal)
362
408
  }
363
- if (patch.needsHuman !== undefined) set("needsHuman", patch.needsHuman)
364
409
  if (patch.prs !== undefined) set("prs", patch.prs)
365
410
  if (patch.status !== undefined) {
411
+ // Done is gated on nothing owed: open asks refuse the move. Canceled
412
+ // stays open — a dead ticket's asks die with it and leave the inbox on
413
+ // their own.
414
+ if (patch.status === "done" && doc.status !== "done") {
415
+ const open = this.asks(key).filter((a) => !a.resolvedAt)
416
+ if (open.length > 0) {
417
+ const id = this.displayId(key)
418
+ throw new Error(
419
+ `${id} still has ${open.length} open ask${open.length === 1 ? "" : "s"} — done means nobody owes anything. ` +
420
+ `Resolve them (\`task resolve ${id} <n>\`) or move what's still owed to a new ticket:\n` +
421
+ open.map((a) => ` [${a.ordinal}] ${a.text.split("\n")[0]}`).join("\n"),
422
+ )
423
+ }
424
+ }
366
425
  const previous = doc.status
367
426
  set("status", patch.status)
368
427
  if (patch.position === undefined && patch.status !== previous) {
369
428
  // Moved columns without an explicit slot → land on top, where the
370
429
  // freshest movement is visible (Linear's behavior).
371
- const column = all.filter((t) => t.number !== number && t.doc.status === patch.status)
430
+ const column = all.filter((t) => t.key !== key && t.doc.status === patch.status)
372
431
  const top = column.length ? Math.min(...column.map((t) => t.doc.position)) : 0
373
432
  set("position", top - POSITION_GAP)
374
433
  }
@@ -378,7 +437,7 @@ export class FileStore implements Store {
378
437
  if (!fieldsChanged && !linksChanged) return this.toTask(ticket, all)
379
438
  if (fieldsChanged || linksChanged) doc.updatedAt = timestamp
380
439
  this.writeTicket(ticket)
381
- return this.get(number)!
440
+ return this.get(key)!
382
441
  }
383
442
 
384
443
  /**
@@ -387,30 +446,30 @@ export class FileStore implements Store {
387
446
  * (and, via the caller, this one) gets its `updated` bumped.
388
447
  */
389
448
  private reconcileBlocks(
390
- number: number,
449
+ key: string,
391
450
  all: Ticket[],
392
- targets: number[],
451
+ targets: string[],
393
452
  timestamp: string,
394
453
  ): boolean {
395
454
  const wanted = new Set(targets)
396
- if (wanted.has(number)) {
397
- throw new Error(`a task can't block itself: ${this.displayId(number)}`)
455
+ if (wanted.has(key)) {
456
+ throw new Error(`a task can't block itself: ${this.displayId(key)}`)
398
457
  }
399
458
  let changed = false
400
459
  for (const other of all) {
401
- if (other.number === number) continue
402
- const has = other.doc.blockedBy.includes(number)
403
- const should = wanted.has(other.number)
460
+ if (other.key === key) continue
461
+ const has = other.doc.blockedBy.includes(key)
462
+ const should = wanted.has(other.key)
404
463
  if (has === should) continue
405
464
  other.doc.blockedBy = should
406
- ? [...other.doc.blockedBy, number].sort((a, b) => a - b)
407
- : other.doc.blockedBy.filter((n) => n !== number)
465
+ ? sortKeys([...other.doc.blockedBy, key])
466
+ : other.doc.blockedBy.filter((k) => k !== key)
408
467
  other.doc.updatedAt = timestamp
409
468
  this.writeTicket(other)
410
469
  changed = true
411
470
  }
412
471
  if (changed) {
413
- const self = all.find((t) => t.number === number)
472
+ const self = all.find((t) => t.key === key)
414
473
  if (self) {
415
474
  self.doc.updatedAt = timestamp
416
475
  this.writeTicket(self)
@@ -420,37 +479,37 @@ export class FileStore implements Store {
420
479
  }
421
480
 
422
481
  /** `task link A --blocks B` and friends — additive, unlike the patch form. */
423
- link(number: number, relation: "blocks" | "blocked_by", target: number): Task {
424
- const task = this.get(number)
425
- if (!task) throw new Error(`no such task: ${this.displayId(number)}`)
482
+ link(key: string, relation: "blocks" | "blocked_by", target: string): Task {
483
+ const task = this.get(key)
484
+ if (!task) throw new Error(`no such task: ${this.displayId(key)}`)
426
485
  const current = relation === "blocks" ? task.blocks : task.blockedBy
427
486
  const patch: TaskPatch =
428
487
  relation === "blocks"
429
488
  ? { blocks: [...current, target] }
430
489
  : { blockedBy: [...current, target] }
431
- return this.update(number, patch)
490
+ return this.update(key, patch)
432
491
  }
433
492
 
434
- unlink(number: number, relation: "blocks" | "blocked_by", target: number): Task {
435
- const task = this.get(number)
436
- if (!task) throw new Error(`no such task: ${this.displayId(number)}`)
493
+ unlink(key: string, relation: "blocks" | "blocked_by", target: string): Task {
494
+ const task = this.get(key)
495
+ if (!task) throw new Error(`no such task: ${this.displayId(key)}`)
437
496
  const current = relation === "blocks" ? task.blocks : task.blockedBy
438
- const kept = current.filter((n) => n !== target)
497
+ const kept = current.filter((k) => k !== target)
439
498
  const patch: TaskPatch = relation === "blocks" ? { blocks: kept } : { blockedBy: kept }
440
- return this.update(number, patch)
499
+ return this.update(key, patch)
441
500
  }
442
501
 
443
- delete(number: number): void {
444
- if (!this.readOne(number)) {
445
- this.assertNotArchived(number)
446
- throw new Error(`no such task: ${this.displayId(number)}`)
502
+ delete(key: string): void {
503
+ if (!this.readOne(key)) {
504
+ this.assertNotArchived(key)
505
+ throw new Error(`no such task: ${this.displayId(key)}`)
447
506
  }
448
- rmSync(join(this.ticketsDir, String(number)), { recursive: true })
507
+ rmSync(join(this.ticketsDir, key), { recursive: true })
449
508
  // Links pointing at the deleted ticket go with it — the same cascade the
450
509
  // relation table had, so quiet on the other tickets' `updated`.
451
510
  for (const other of this.readAll()) {
452
- if (!other.doc.blockedBy.includes(number)) continue
453
- other.doc.blockedBy = other.doc.blockedBy.filter((n) => n !== number)
511
+ if (!other.doc.blockedBy.includes(key)) continue
512
+ other.doc.blockedBy = other.doc.blockedBy.filter((k) => k !== key)
454
513
  this.writeTicket(other)
455
514
  }
456
515
  }
@@ -461,41 +520,41 @@ export class FileStore implements Store {
461
520
  * Move a finished ticket's whole directory to `.task/archive/` — one rename,
462
521
  * which git records as a move, so history follows the ticket. Archived
463
522
  * tickets leave every hot path (`list`, the board, link derivation) but stay
464
- * readable via `get`/`comments` and keep their number reserved forever.
523
+ * readable via `get`/`comments` and keep their key reserved forever.
465
524
  * Links other tickets hold on this one are left in place: they're
466
525
  * dangling-tolerant everywhere, and unarchiving puts them back in force.
467
526
  */
468
- archive(number: number): Task {
469
- const ticket = this.readOne(number)
527
+ archive(key: string): Task {
528
+ const ticket = this.readOne(key)
470
529
  if (!ticket) {
471
- if (this.isArchived(number)) {
472
- throw new Error(`${this.displayId(number)} is already archived`)
530
+ if (this.isArchived(key)) {
531
+ throw new Error(`${this.displayId(key)} is already archived`)
473
532
  }
474
- throw new Error(`no such task: ${this.displayId(number)}`)
533
+ throw new Error(`no such task: ${this.displayId(key)}`)
475
534
  }
476
535
  if (ticket.doc.status !== "done" && ticket.doc.status !== "canceled") {
477
536
  throw new Error(
478
- `${this.displayId(number)} is ${ticket.doc.status} — only done or canceled tickets can be archived`,
537
+ `${this.displayId(key)} is ${ticket.doc.status} — only done or canceled tickets can be archived`,
479
538
  )
480
539
  }
481
540
  mkdirSync(this.archiveDir, { recursive: true })
482
- renameSync(join(this.ticketsDir, String(number)), join(this.archiveDir, String(number)))
483
- return this.get(number)!
541
+ renameSync(join(this.ticketsDir, key), join(this.archiveDir, key))
542
+ return this.get(key)!
484
543
  }
485
544
 
486
- unarchive(number: number): Task {
487
- if (!this.isArchived(number)) {
545
+ unarchive(key: string): Task {
546
+ if (!this.isArchived(key)) {
488
547
  throw new Error(
489
- this.readOne(number)
490
- ? `${this.displayId(number)} isn't archived`
491
- : `no such task: ${this.displayId(number)}`,
548
+ this.readOne(key)
549
+ ? `${this.displayId(key)} isn't archived`
550
+ : `no such task: ${this.displayId(key)}`,
492
551
  )
493
552
  }
494
553
  // A checkout where everything is archived has no tickets/ at all — git
495
554
  // doesn't keep empty directories.
496
555
  mkdirSync(this.ticketsDir, { recursive: true })
497
- renameSync(join(this.archiveDir, String(number)), join(this.ticketsDir, String(number)))
498
- return this.get(number)!
556
+ renameSync(join(this.archiveDir, key), join(this.ticketsDir, key))
557
+ return this.get(key)!
499
558
  }
500
559
 
501
560
  // ── Goals ──────────────────────────────────────────────────────────────────
@@ -621,7 +680,7 @@ export class FileStore implements Store {
621
680
  (t) => t.doc.goal === slug && t.doc.status !== "done" && t.doc.status !== "canceled",
622
681
  )
623
682
  if (open.length > 0) {
624
- const ids = open.map((t) => this.displayId(t.number)).join(", ")
683
+ const ids = open.map((t) => this.displayId(t.key)).join(", ")
625
684
  throw new Error(`goal "${slug}" still has open tasks (${ids}) — finish or reassign them first`)
626
685
  }
627
686
  mkdirSync(this.goalsArchiveDir, { recursive: true })
@@ -659,7 +718,7 @@ export class FileStore implements Store {
659
718
  }
660
719
  const referencing = this.readAll().filter((t) => t.doc.goal === slug)
661
720
  if (referencing.length > 0) {
662
- const ids = referencing.map((t) => this.displayId(t.number)).join(", ")
721
+ const ids = referencing.map((t) => this.displayId(t.key)).join(", ")
663
722
  throw new Error(
664
723
  `goal "${slug}" is referenced by ${ids} — clear those with \`task update <id> --goal ""\` first`,
665
724
  )
@@ -674,8 +733,8 @@ export class FileStore implements Store {
674
733
  * the same ticket produce two files and merge without a conflict — which is
675
734
  * why they aren't lines inside `ticket.md`.
676
735
  */
677
- comments(number: number): Comment[] {
678
- const dir = this.commentsPath(number)
736
+ comments(key: string): Comment[] {
737
+ const dir = this.commentsPath(key)
679
738
  if (!existsSync(dir)) return []
680
739
  const comments: Comment[] = []
681
740
  for (const entry of readdirSync(dir)) {
@@ -684,7 +743,7 @@ export class FileStore implements Store {
684
743
  const doc = parseComment(readFileSync(path, "utf8"), path)
685
744
  comments.push({
686
745
  id: entry.slice(0, -3),
687
- taskId: this.displayId(number),
746
+ taskId: this.displayId(key),
688
747
  author: doc.author,
689
748
  body: doc.body,
690
749
  createdAt: doc.createdAt,
@@ -696,45 +755,187 @@ export class FileStore implements Store {
696
755
  return comments
697
756
  }
698
757
 
699
- addComment(number: number, body: string, author = ""): Comment {
700
- if (!this.readOne(number)) {
701
- this.assertNotArchived(number)
702
- throw new Error(`no such task: ${this.displayId(number)}`)
758
+ addComment(key: string, body: string, author = ""): Comment {
759
+ if (!this.readOne(key)) {
760
+ this.assertNotArchived(key)
761
+ throw new Error(`no such task: ${this.displayId(key)}`)
703
762
  }
704
763
  const createdAt = now()
705
- const dir = this.commentsPath(number)
764
+ const dir = this.commentsPath(key)
706
765
  mkdirSync(dir, { recursive: true })
707
766
  const stem = commentStem(createdAt, author)
708
767
  let id = stem
709
768
  for (let n = 2; existsSync(join(dir, `${id}.md`)); n++) id = `${stem}-${n}`
710
769
  writeAtomic(join(dir, `${id}.md`), serializeComment({ author, createdAt, body }))
711
- return { id, taskId: this.displayId(number), author, body, createdAt }
770
+ return { id, taskId: this.displayId(key), author, body, createdAt }
712
771
  }
713
772
 
714
- deleteComment(number: number, commentId: string): Comment {
773
+ deleteComment(key: string, commentId: string): Comment {
715
774
  // Resolve the id against the directory listing rather than building a path
716
775
  // from it — the id arrived over HTTP and must not be able to point
717
776
  // anywhere but at an actual comment of this task.
718
- const existing = this.comments(number).find((c) => c.id === commentId)
777
+ const existing = this.comments(key).find((c) => c.id === commentId)
719
778
  if (!existing) {
720
- throw new Error(`no such comment on ${this.displayId(number)}: ${commentId}`)
779
+ throw new Error(`no such comment on ${this.displayId(key)}: ${commentId}`)
721
780
  }
722
- rmSync(join(this.commentsPath(number), `${commentId}.md`))
781
+ rmSync(join(this.commentsPath(key), `${commentId}.md`))
723
782
  return existing
724
783
  }
725
784
 
726
- commentCounts(): Map<number, number> {
727
- const counts = new Map<number, number>()
785
+ commentCounts(): Map<string, number> {
786
+ const counts = new Map<string, number>()
728
787
  for (const ticket of this.readAll()) {
729
- const dir = this.commentsPath(ticket.number)
788
+ const dir = this.commentsPath(ticket.key)
730
789
  if (!existsSync(dir)) continue
731
790
  const count = readdirSync(dir).filter(
732
791
  (f) => f.endsWith(".md") && !f.startsWith("."),
733
792
  ).length
734
- if (count > 0) counts.set(ticket.number, count)
793
+ if (count > 0) counts.set(ticket.key, count)
735
794
  }
736
795
  return counts
737
796
  }
797
+
798
+ // ── Asks ───────────────────────────────────────────────────────────────────
799
+
800
+ /** Like comments, an archived ticket keeps its asks readable in place. */
801
+ private asksPath(key: string): string {
802
+ const home = existsSync(join(this.archiveDir, key)) ? this.archiveDir : this.ticketsDir
803
+ return join(home, key, ASKS_DIR)
804
+ }
805
+
806
+ /**
807
+ * Every ask on a ticket, open and resolved, in creation order — the order
808
+ * that fixes each ask's ordinal, the short handle `task resolve` takes.
809
+ * Ordinals are positions in this listing rather than stored, so two branches
810
+ * adding asks concurrently can't mint a duplicate; they shift only if an ask
811
+ * is deleted outright, which is why resolving (not deleting) is the normal
812
+ * way an ask ends.
813
+ */
814
+ asks(key: string): Ask[] {
815
+ const dir = this.asksPath(key)
816
+ if (!existsSync(dir)) return []
817
+ const asks: Omit<Ask, "ordinal">[] = []
818
+ for (const entry of readdirSync(dir)) {
819
+ if (!entry.endsWith(".md") || entry.startsWith(".")) continue
820
+ const path = join(dir, entry)
821
+ const doc = parseAsk(readFileSync(path, "utf8"), path)
822
+ asks.push({
823
+ id: entry.slice(0, -3),
824
+ taskId: this.displayId(key),
825
+ text: doc.body,
826
+ author: doc.author,
827
+ createdAt: doc.createdAt,
828
+ resolvedAt: doc.resolvedAt,
829
+ resolvedBy: doc.resolvedBy,
830
+ })
831
+ }
832
+ asks.sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id))
833
+ return asks.map((ask, index) => ({ ...ask, ordinal: index + 1 }))
834
+ }
835
+
836
+ /**
837
+ * File an ask — allowed only while the ticket is open. A done ticket can't
838
+ * quietly start owing things again (that's the NIC-17 failure this feature
839
+ * exists to end): reopen it, or file the ask as a new ticket.
840
+ */
841
+ addAsk(key: string, text: string, author = ""): Ask {
842
+ const ticket = this.readOne(key)
843
+ if (!ticket) {
844
+ this.assertNotArchived(key)
845
+ throw new Error(`no such task: ${this.displayId(key)}`)
846
+ }
847
+ if (ticket.doc.status === "done" || ticket.doc.status === "canceled") {
848
+ throw new Error(
849
+ `${this.displayId(key)} is ${ticket.doc.status} — asks belong on open tickets. Reopen it, or file the ask as a new ticket`,
850
+ )
851
+ }
852
+ const createdAt = now()
853
+ const dir = this.asksPath(key)
854
+ mkdirSync(dir, { recursive: true })
855
+ const stem = commentStem(createdAt, author)
856
+ let id = stem
857
+ for (let n = 2; existsSync(join(dir, `${id}.md`)); n++) id = `${stem}-${n}`
858
+ const doc: AskDoc = { author, createdAt, resolvedAt: null, resolvedBy: null, body: text }
859
+ writeAtomic(join(dir, `${id}.md`), serializeAsk(doc))
860
+ return this.asks(key).find((a) => a.id === id)!
861
+ }
862
+
863
+ /**
864
+ * Resolve an ask against the current listing — `ref` is an ordinal ("2") or
865
+ * the ask's id. Resolution edits the ask's own file, so the person's answer
866
+ * and the worker's question live in one place whatever branch each came from.
867
+ */
868
+ resolveAsk(key: string, ref: string, resolvedBy = ""): Ask {
869
+ return this.setAskResolution(key, ref, now(), resolvedBy)
870
+ }
871
+
872
+ /** Reopen a resolved ask — same refusals as adding one. */
873
+ reopenAsk(key: string, ref: string): Ask {
874
+ return this.setAskResolution(key, ref, null, null)
875
+ }
876
+
877
+ private setAskResolution(
878
+ key: string,
879
+ ref: string,
880
+ resolvedAt: string | null,
881
+ resolvedBy: string | null,
882
+ ): Ask {
883
+ this.assertNotArchived(key)
884
+ const ask = this.findAsk(key, ref)
885
+ const ticket = this.readOne(key)
886
+ if (
887
+ resolvedAt === null &&
888
+ (ticket?.doc.status === "done" || ticket?.doc.status === "canceled")
889
+ ) {
890
+ throw new Error(
891
+ `${this.displayId(key)} is ${ticket.doc.status} — reopening an ask would leave a finished ticket owing something. Reopen the ticket first, or file a new one`,
892
+ )
893
+ }
894
+ const doc: AskDoc = {
895
+ author: ask.author,
896
+ createdAt: ask.createdAt,
897
+ resolvedAt,
898
+ resolvedBy: resolvedAt === null ? null : resolvedBy,
899
+ body: ask.text,
900
+ }
901
+ writeAtomic(join(this.asksPath(key), `${ask.id}.md`), serializeAsk(doc))
902
+ return this.asks(key).find((a) => a.id === ask.id)!
903
+ }
904
+
905
+ deleteAsk(key: string, ref: string): Ask {
906
+ this.assertNotArchived(key)
907
+ const ask = this.findAsk(key, ref)
908
+ rmSync(join(this.asksPath(key), `${ask.id}.md`))
909
+ return ask
910
+ }
911
+
912
+ /**
913
+ * Resolve an ordinal or id against the directory listing rather than
914
+ * building a path from it — same rule as comment ids, the ref arrived over
915
+ * HTTP or a shell and must not point anywhere but at this ticket's asks.
916
+ */
917
+ private findAsk(key: string, ref: string): Ask {
918
+ const asks = this.asks(key)
919
+ const trimmed = ref.trim()
920
+ const byOrdinal = /^\d+$/.test(trimmed)
921
+ ? asks.find((a) => a.ordinal === Number(trimmed))
922
+ : undefined
923
+ const ask = byOrdinal ?? asks.find((a) => a.id === trimmed)
924
+ if (!ask) {
925
+ const id = this.displayId(key)
926
+ throw new Error(
927
+ asks.length === 0
928
+ ? `${id} has no asks`
929
+ : `no such ask on ${id}: ${ref} — ordinals run 1-${asks.length} (\`task show ${id}\` lists them)`,
930
+ )
931
+ }
932
+ return ask
933
+ }
934
+ }
935
+
936
+ /** The list() tiebreak, restated over Task — creation time, then key. */
937
+ function byCreationTask(a: Task, b: Task): number {
938
+ return a.createdAt.localeCompare(b.createdAt) || a.key.localeCompare(b.key)
738
939
  }
739
940
 
740
941
  // ── Opening and initializing ─────────────────────────────────────────────────
@@ -767,6 +968,18 @@ export function openBoard(root: string): Store {
767
968
  "this board still stores its state in a pre-0.6 SQLite database — run `npx @nickmeriano/task@0.6 migrate` once, then retry",
768
969
  )
769
970
  }
971
+ } else {
972
+ // A board whose tickets are ALL numeric directories predates the TAS-42
973
+ // id cutover. Reading it as empty (the reader skips numeric dirs) would
974
+ // silently strand every ticket — refuse loudly instead, same policy as
975
+ // the SQLite case. A mixed board (a stray numeric dir merged in from an
976
+ // old branch) still opens; `task check` names the straggler.
977
+ const entries = readdirSync(join(taskDir, TICKETS_DIR))
978
+ if (entries.some((name) => /^\d+$/.test(name)) && !entries.some(isTicketKey)) {
979
+ throw new Error(
980
+ "this board's tickets use the pre-TAS-42 sequential-id format, which is no longer read — run the id migration (tools/migrate-ids/migrate.mjs in the repo that ships this board, or the migration script from the release notes) once, then retry",
981
+ )
982
+ }
770
983
  }
771
984
  return new FileStore(root)
772
985
  }
@@ -796,4 +1009,3 @@ export function initProject(root: string, options: InitOptions): Store {
796
1009
  writeFileSync(join(taskDir, ".gitignore"), GITIGNORE)
797
1010
  return new FileStore(root)
798
1011
  }
799
-