@nickmeriano/task 0.7.1 → 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 (128) hide show
  1. package/README.md +73 -25
  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 +43 -0
  7. package/dist/check.d.ts.map +1 -0
  8. package/dist/check.js +403 -0
  9. package/dist/check.js.map +1 -0
  10. package/dist/check.test.d.ts +9 -0
  11. package/dist/check.test.d.ts.map +1 -0
  12. package/dist/check.test.js +248 -0
  13. package/dist/check.test.js.map +1 -0
  14. package/dist/claim-io.d.ts +73 -0
  15. package/dist/claim-io.d.ts.map +1 -0
  16. package/dist/claim-io.js +344 -0
  17. package/dist/claim-io.js.map +1 -0
  18. package/dist/claim.d.ts +61 -9
  19. package/dist/claim.d.ts.map +1 -1
  20. package/dist/claim.js +197 -67
  21. package/dist/claim.js.map +1 -1
  22. package/dist/claim.test.d.ts +2 -2
  23. package/dist/claim.test.js +235 -64
  24. package/dist/claim.test.js.map +1 -1
  25. package/dist/cli.js +724 -136
  26. package/dist/cli.js.map +1 -1
  27. package/dist/file-store.d.ts +110 -38
  28. package/dist/file-store.d.ts.map +1 -1
  29. package/dist/file-store.js +514 -238
  30. package/dist/file-store.js.map +1 -1
  31. package/dist/git-serve.d.ts +183 -0
  32. package/dist/git-serve.d.ts.map +1 -0
  33. package/dist/git-serve.js +503 -0
  34. package/dist/git-serve.js.map +1 -0
  35. package/dist/git-serve.test.d.ts +16 -0
  36. package/dist/git-serve.test.d.ts.map +1 -0
  37. package/dist/git-serve.test.js +183 -0
  38. package/dist/git-serve.test.js.map +1 -0
  39. package/dist/git.d.ts +65 -0
  40. package/dist/git.d.ts.map +1 -0
  41. package/dist/git.js +114 -0
  42. package/dist/git.js.map +1 -0
  43. package/dist/id.d.ts +39 -0
  44. package/dist/id.d.ts.map +1 -0
  45. package/dist/id.js +67 -0
  46. package/dist/id.js.map +1 -0
  47. package/dist/inbox.d.ts +41 -0
  48. package/dist/inbox.d.ts.map +1 -0
  49. package/dist/inbox.js +56 -0
  50. package/dist/inbox.js.map +1 -0
  51. package/dist/index.d.ts +5 -3
  52. package/dist/index.d.ts.map +1 -1
  53. package/dist/index.js +5 -3
  54. package/dist/index.js.map +1 -1
  55. package/dist/overview.d.ts +52 -0
  56. package/dist/overview.d.ts.map +1 -0
  57. package/dist/overview.js +61 -0
  58. package/dist/overview.js.map +1 -0
  59. package/dist/overview.test.d.ts +8 -0
  60. package/dist/overview.test.d.ts.map +1 -0
  61. package/dist/overview.test.js +48 -0
  62. package/dist/overview.test.js.map +1 -0
  63. package/dist/promote.test.d.ts +15 -0
  64. package/dist/promote.test.d.ts.map +1 -0
  65. package/dist/promote.test.js +104 -0
  66. package/dist/promote.test.js.map +1 -0
  67. package/dist/publish.d.ts +2 -17
  68. package/dist/publish.d.ts.map +1 -1
  69. package/dist/publish.js +4 -49
  70. package/dist/publish.js.map +1 -1
  71. package/dist/search.d.ts +34 -0
  72. package/dist/search.d.ts.map +1 -0
  73. package/dist/search.js +79 -0
  74. package/dist/search.js.map +1 -0
  75. package/dist/search.test.d.ts +2 -0
  76. package/dist/search.test.d.ts.map +1 -0
  77. package/dist/search.test.js +53 -0
  78. package/dist/search.test.js.map +1 -0
  79. package/dist/server.d.ts.map +1 -1
  80. package/dist/server.js +228 -23
  81. package/dist/server.js.map +1 -1
  82. package/dist/store.d.ts +43 -63
  83. package/dist/store.d.ts.map +1 -1
  84. package/dist/store.js +0 -368
  85. package/dist/store.js.map +1 -1
  86. package/dist/store.test.d.ts +1 -2
  87. package/dist/store.test.d.ts.map +1 -1
  88. package/dist/store.test.js +148 -106
  89. package/dist/store.test.js.map +1 -1
  90. package/dist/ticket-doc.d.ts +74 -5
  91. package/dist/ticket-doc.d.ts.map +1 -1
  92. package/dist/ticket-doc.js +229 -15
  93. package/dist/ticket-doc.js.map +1 -1
  94. package/dist/types.d.ts +115 -28
  95. package/dist/types.d.ts.map +1 -1
  96. package/dist/types.js.map +1 -1
  97. package/package.json +1 -1
  98. package/skill/SKILL.md +153 -40
  99. package/src/asks.test.ts +355 -0
  100. package/src/check.test.ts +328 -0
  101. package/src/check.ts +497 -0
  102. package/src/claim-io.ts +401 -0
  103. package/src/claim.test.ts +301 -71
  104. package/src/claim.ts +238 -81
  105. package/src/cli.ts +740 -131
  106. package/src/file-store.ts +572 -254
  107. package/src/git-serve.test.ts +240 -0
  108. package/src/git-serve.ts +595 -0
  109. package/src/git.ts +141 -0
  110. package/src/id.ts +68 -0
  111. package/src/inbox.ts +77 -0
  112. package/src/index.ts +4 -2
  113. package/src/overview.test.ts +52 -0
  114. package/src/overview.ts +105 -0
  115. package/src/promote.test.ts +143 -0
  116. package/src/publish.ts +6 -53
  117. package/src/search.test.ts +64 -0
  118. package/src/search.ts +105 -0
  119. package/src/server.ts +232 -21
  120. package/src/store.test.ts +166 -116
  121. package/src/store.ts +46 -444
  122. package/src/ticket-doc.ts +284 -21
  123. package/src/types.ts +120 -28
  124. package/ui/dist/assets/index-BjsorZOU.js +229 -0
  125. package/ui/dist/assets/index-CoKCUYic.css +1 -0
  126. package/ui/dist/index.html +2 -2
  127. package/ui/dist/assets/index-COunM-QN.css +0 -1
  128. package/ui/dist/assets/index-D4homvrQ.js +0 -229
package/src/file-store.ts CHANGED
@@ -10,23 +10,34 @@ import {
10
10
  import { basename, dirname, join } from "node:path"
11
11
  import {
12
12
  commentStem,
13
+ isGoalSlug,
14
+ parseAsk,
13
15
  parseComment,
16
+ parseGoal,
14
17
  parseTicket,
18
+ serializeAsk,
15
19
  serializeComment,
20
+ serializeGoal,
16
21
  serializeTicket,
22
+ slugifyGoal,
23
+ type AskDoc,
24
+ type GoalDoc,
17
25
  type TicketDoc,
18
26
  } from "./ticket-doc.ts"
27
+ import { isTicketKey, mintKey } from "./id.ts"
19
28
  import {
20
29
  CONFIG_FILE,
21
- DB_FILE,
22
30
  POSITION_GAP,
23
31
  TASK_DIR,
24
- TaskStore,
25
32
  derivePrefix,
26
33
  type Store,
27
34
  } from "./store.ts"
28
35
  import type {
36
+ Ask,
29
37
  Comment,
38
+ Goal,
39
+ GoalInput,
40
+ GoalPatch,
30
41
  ProjectConfig,
31
42
  Status,
32
43
  Task,
@@ -38,8 +49,13 @@ import type {
38
49
  export const TICKETS_DIR = "tickets"
39
50
  /** Finished tickets moved out of the hot path — same per-ticket layout. */
40
51
  export const ARCHIVE_DIR = "archive"
41
- const TICKET_FILE = "ticket.md"
42
- const COMMENTS_DIR = "comments"
52
+ export const TICKET_FILE = "ticket.md"
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"
56
+ /** One file per goal — `.task/goals/<slug>.md`; archived ones one level down. */
57
+ export const GOALS_DIR = "goals"
58
+ export const GOALS_ARCHIVE_DIR = "archive"
43
59
 
44
60
  function now(): string {
45
61
  return new Date().toISOString()
@@ -56,17 +72,28 @@ function writeAtomic(path: string, text: string): void {
56
72
  }
57
73
 
58
74
  interface Ticket {
59
- number: number
75
+ key: string
60
76
  doc: TicketDoc
61
77
  }
62
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
+
63
89
  /**
64
90
  * The canonical persistence layer: one directory per ticket under
65
- * `.task/tickets/`, holding a frontmatter+markdown `ticket.md` and one file
66
- * per comment. Everything is plain text committed to git, which is the point —
67
- * ticket edits diff, review, and merge like code, and two branches touching
68
- * different tickets (or adding comments to the same one) merge cleanly by
69
- * 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.
70
97
  *
71
98
  * There is no database and no cache: boards are dozens of tickets, and
72
99
  * re-reading a handful of small files per operation is cheaper than opening
@@ -79,6 +106,8 @@ export class FileStore implements Store {
79
106
  readonly config: ProjectConfig
80
107
  private ticketsDir: string
81
108
  private archiveDir: string
109
+ private goalsDir: string
110
+ private goalsArchiveDir: string
82
111
 
83
112
  constructor(root: string) {
84
113
  this.root = root
@@ -88,48 +117,63 @@ export class FileStore implements Store {
88
117
  ) as ProjectConfig
89
118
  this.ticketsDir = join(this.taskDir, TICKETS_DIR)
90
119
  this.archiveDir = join(this.taskDir, ARCHIVE_DIR)
120
+ this.goalsDir = join(this.taskDir, GOALS_DIR)
121
+ this.goalsArchiveDir = join(this.goalsDir, GOALS_ARCHIVE_DIR)
91
122
  }
92
123
 
93
124
  close(): void {}
94
125
 
95
- displayId(number: number): string {
96
- return `${this.config.prefix}-${number}`
126
+ displayId(key: string): string {
127
+ return `${this.config.prefix}-${key}`
97
128
  }
98
129
 
99
- /** Accepts "PHONE-12", "phone-12" or "12". */
100
- parseId(ref: string): number {
101
- 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())
102
138
  if (!match) throw new Error(`invalid task id: ${ref}`)
103
- 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
104
150
  }
105
151
 
106
152
  // ── Reading ────────────────────────────────────────────────────────────────
107
153
 
108
- private ticketPath(number: number): string {
109
- return join(this.ticketsDir, String(number), TICKET_FILE)
154
+ private ticketPath(key: string): string {
155
+ return join(this.ticketsDir, key, TICKET_FILE)
110
156
  }
111
157
 
112
158
  /**
113
159
  * A ticket's comments live wherever the ticket does — the whole directory
114
160
  * moves on archive, so an archived ticket's discussion stays readable.
115
161
  */
116
- private commentsPath(number: number): string {
117
- const home = existsSync(join(this.archiveDir, String(number)))
118
- ? this.archiveDir
119
- : this.ticketsDir
120
- 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)
121
165
  }
122
166
 
123
167
  private readDir(dir: string): Ticket[] {
124
168
  if (!existsSync(dir)) return []
125
169
  const tickets: Ticket[] = []
126
170
  for (const entry of readdirSync(dir, { withFileTypes: true })) {
127
- if (!entry.isDirectory() || !/^\d+$/.test(entry.name)) continue
171
+ if (!entry.isDirectory() || !isTicketKey(entry.name)) continue
128
172
  const path = join(dir, entry.name, TICKET_FILE)
129
173
  if (!existsSync(path)) continue
130
- tickets.push({ number: Number(entry.name), doc: parseTicket(readFileSync(path, "utf8"), path) })
174
+ tickets.push({ key: entry.name, doc: parseTicket(readFileSync(path, "utf8"), path) })
131
175
  }
132
- tickets.sort((a, b) => a.number - b.number)
176
+ tickets.sort(byCreation)
133
177
  return tickets
134
178
  }
135
179
 
@@ -138,56 +182,66 @@ export class FileStore implements Store {
138
182
  return this.readDir(this.ticketsDir)
139
183
  }
140
184
 
141
- private readOne(number: number): Ticket | null {
142
- const path = this.ticketPath(number)
143
- if (!existsSync(path)) return null
144
- 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) }
145
189
  }
146
190
 
147
- /** Cheap presence check the archive's numbers, without parsing anything. */
148
- private archivedNumbers(): number[] {
149
- if (!existsSync(this.archiveDir)) return []
150
- return readdirSync(this.archiveDir)
151
- .filter((name) => /^\d+$/.test(name))
152
- .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)
195
+ }
196
+
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)
153
204
  }
154
205
 
155
- private isArchived(number: number): boolean {
156
- return existsSync(join(this.archiveDir, String(number), TICKET_FILE))
206
+ private isArchived(key: string): boolean {
207
+ return isTicketKey(key) && existsSync(join(this.archiveDir, key, TICKET_FILE))
157
208
  }
158
209
 
159
210
  /** The error every write path throws instead of touching the archive. */
160
- private assertNotArchived(number: number): void {
161
- if (this.isArchived(number)) {
162
- const id = this.displayId(number)
211
+ private assertNotArchived(key: string): void {
212
+ if (this.isArchived(key)) {
213
+ const id = this.displayId(key)
163
214
  throw new Error(`${id} is archived — run \`task unarchive ${id}\` first`)
164
215
  }
165
216
  }
166
217
 
167
218
  private writeTicket(ticket: Ticket): void {
168
- mkdirSync(dirname(this.ticketPath(ticket.number)), { recursive: true })
169
- 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))
170
221
  }
171
222
 
172
223
  /**
173
224
  * Only `blocked_by` is stored (on the blocked ticket); the `blocks` side is
174
- * 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.
175
228
  */
176
- private toTask(ticket: Ticket, all: Ticket[]): Task {
177
- const blocks = all
178
- .filter((t) => t.doc.blockedBy.includes(ticket.number))
179
- .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
+ )
180
233
  return {
181
- id: this.displayId(ticket.number),
182
- number: ticket.number,
234
+ id: this.displayId(ticket.key),
235
+ key: ticket.key,
183
236
  title: ticket.doc.title,
184
237
  description: ticket.doc.description,
185
238
  status: ticket.doc.status,
186
239
  tags: ticket.doc.tags,
187
- milestone: ticket.doc.milestone,
188
- needsHuman: ticket.doc.needsHuman,
240
+ goal: ticket.doc.goal,
241
+ needsHuman: asks.some((a) => !a.resolvedAt),
242
+ asks,
189
243
  blocks,
190
- blockedBy: [...ticket.doc.blockedBy].sort((a, b) => a - b),
244
+ blockedBy: sortKeys([...ticket.doc.blockedBy]),
191
245
  prs: ticket.doc.prs,
192
246
  position: ticket.doc.position,
193
247
  createdAt: ticket.doc.createdAt,
@@ -200,11 +254,15 @@ export class FileStore implements Store {
200
254
  const wanted = new Set<Status>(filter.statuses)
201
255
  tickets = tickets.filter((t) => wanted.has(t.doc.status))
202
256
  }
203
- if (filter.milestone) {
204
- tickets = tickets.filter((t) => t.doc.milestone === filter.milestone)
257
+ if (filter.goal) {
258
+ tickets = tickets.filter((t) => t.doc.goal === filter.goal)
205
259
  }
206
260
  if (filter.needsHuman !== undefined) {
207
- 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
+ )
208
266
  }
209
267
  if (filter.tags?.length) {
210
268
  const wanted = new Set(filter.tags)
@@ -222,22 +280,23 @@ export class FileStore implements Store {
222
280
  const everything = [...this.readAll(), ...archived]
223
281
  return this.applyFilter(archived, filter)
224
282
  .map((t) => ({ ...this.toTask(t, everything), archived: true }))
225
- .sort((a, b) => a.position - b.position || a.number - b.number)
283
+ .sort((a, b) => a.position - b.position || byCreationTask(a, b))
226
284
  }
227
285
  const all = this.readAll()
228
286
  return this.applyFilter(all, filter)
229
287
  .map((t) => this.toTask(t, all))
230
- .sort((a, b) => a.position - b.position || a.number - b.number)
288
+ .sort((a, b) => a.position - b.position || byCreationTask(a, b))
231
289
  }
232
290
 
233
- get(number: number): Task | null {
291
+ get(key: string): Task | null {
234
292
  const all = this.readAll()
235
- const ticket = all.find((t) => t.number === number)
293
+ const ticket = all.find((t) => t.key === key)
236
294
  if (ticket) return this.toTask(ticket, all)
237
295
  // `show` should reach the archive without ceremony — reads are safe.
238
- const path = join(this.archiveDir, String(number), TICKET_FILE)
296
+ if (!isTicketKey(key)) return null
297
+ const path = join(this.archiveDir, key, TICKET_FILE)
239
298
  if (!existsSync(path)) return null
240
- const archived: Ticket = { number, doc: parseTicket(readFileSync(path, "utf8"), path) }
299
+ const archived: Ticket = { key, doc: parseTicket(readFileSync(path, "utf8"), path) }
241
300
  return { ...this.toTask(archived, [...all, archived]), archived: true }
242
301
  }
243
302
 
@@ -245,30 +304,33 @@ export class FileStore implements Store {
245
304
 
246
305
  create(input: TaskInput): Task {
247
306
  const all = this.readAll()
248
- // Archived numbers stay reserved a new ticket must never take a number
249
- // that old comments or PR titles still point at.
250
- const taken = [...all.map((t) => t.number), ...this.archivedNumbers()]
251
- const number = taken.reduce((max, n) => Math.max(max, n), 0) + 1
252
- const status = input.status ?? "todo"
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)
312
+ // Dump by default: new tickets land in backlog, the no-quality-bar list;
313
+ // `todo` (the ready list) is an explicit choice — usually `task promote`.
314
+ const status = input.status ?? "backlog"
253
315
  for (const target of [...(input.blocks ?? []), ...(input.blockedBy ?? [])]) {
254
- if (!all.some((t) => t.number === target)) {
316
+ if (!all.some((t) => t.key === target)) {
255
317
  throw new Error(`no such task: ${this.displayId(target)}`)
256
318
  }
257
319
  }
320
+ if (input.goal) this.assertGoalAssignable(input.goal)
258
321
  // New tasks land at the bottom of their column.
259
322
  const column = all.filter((t) => t.doc.status === status)
260
323
  const bottom = column.length ? Math.max(...column.map((t) => t.doc.position)) : 0
261
324
  const timestamp = now()
262
325
  const ticket: Ticket = {
263
- number,
326
+ key,
264
327
  doc: {
265
328
  title: input.title,
266
329
  description: input.description ?? "",
267
330
  status,
268
331
  tags: input.tags ?? [],
269
- milestone: input.milestone ?? null,
270
- needsHuman: input.needsHuman ?? false,
271
- blockedBy: [...new Set(input.blockedBy ?? [])].sort((a, b) => a - b),
332
+ goal: input.goal ?? null,
333
+ blockedBy: sortKeys([...new Set(input.blockedBy ?? [])]),
272
334
  prs: input.prs ?? [],
273
335
  position: bottom + POSITION_GAP,
274
336
  createdAt: timestamp,
@@ -277,27 +339,27 @@ export class FileStore implements Store {
277
339
  }
278
340
  this.writeTicket(ticket)
279
341
  if (input.blocks !== undefined) {
280
- this.reconcileBlocks(number, [...all, ticket], input.blocks, timestamp)
342
+ this.reconcileBlocks(key, [...all, ticket], input.blocks, timestamp)
281
343
  }
282
- return this.get(number)!
344
+ return this.get(key)!
283
345
  }
284
346
 
285
- update(number: number, patch: TaskPatch): Task {
347
+ update(key: string, patch: TaskPatch): Task {
286
348
  const all = this.readAll()
287
- const ticket = all.find((t) => t.number === number)
349
+ const ticket = all.find((t) => t.key === key)
288
350
  if (!ticket) {
289
- this.assertNotArchived(number)
290
- throw new Error(`no such task: ${this.displayId(number)}`)
351
+ this.assertNotArchived(key)
352
+ throw new Error(`no such task: ${this.displayId(key)}`)
291
353
  }
292
354
 
293
355
  // Validate link targets before any file is written, so a bad target
294
356
  // rejects the whole patch — the transactional behavior the SQLite store
295
357
  // got for free.
296
358
  for (const target of [...(patch.blocks ?? []), ...(patch.blockedBy ?? [])]) {
297
- if (target === number) {
298
- 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)}`)
299
361
  }
300
- if (!all.some((t) => t.number === target)) {
362
+ if (!all.some((t) => t.key === target)) {
301
363
  this.assertNotArchived(target)
302
364
  throw new Error(`no such task: ${this.displayId(target)}`)
303
365
  }
@@ -306,14 +368,14 @@ export class FileStore implements Store {
306
368
  const timestamp = now()
307
369
  let linksChanged = false
308
370
  if (patch.blocks !== undefined) {
309
- linksChanged = this.reconcileBlocks(number, all, patch.blocks, timestamp)
371
+ linksChanged = this.reconcileBlocks(key, all, patch.blocks, timestamp)
310
372
  }
311
373
  if (patch.blockedBy !== undefined) {
312
- const wanted = [...new Set(patch.blockedBy)].sort((a, b) => a - b)
374
+ const wanted = sortKeys([...new Set(patch.blockedBy)])
313
375
  const current = ticket.doc.blockedBy
314
376
  const touched = [
315
- ...current.filter((n) => !wanted.includes(n)),
316
- ...wanted.filter((n) => !current.includes(n)),
377
+ ...current.filter((k) => !wanted.includes(k)),
378
+ ...wanted.filter((k) => !current.includes(k)),
317
379
  ]
318
380
  if (touched.length > 0) {
319
381
  linksChanged = true
@@ -321,8 +383,8 @@ export class FileStore implements Store {
321
383
  // The other end of every added or removed link gets its `updated`
322
384
  // bumped too — its derived `blocks` view just changed. A dangling
323
385
  // reference (hand-edit pointing at a deleted ticket) has no other end.
324
- for (const n of touched) {
325
- const other = all.find((t) => t.number === n)
386
+ for (const k of touched) {
387
+ const other = all.find((t) => t.key === k)
326
388
  if (!other) continue
327
389
  other.doc.updatedAt = timestamp
328
390
  this.writeTicket(other)
@@ -332,24 +394,40 @@ export class FileStore implements Store {
332
394
 
333
395
  const doc = ticket.doc
334
396
  let fieldsChanged = false
335
- const set = <K extends keyof TicketDoc>(key: K, value: TicketDoc[K]) => {
336
- doc[key] = value
397
+ const set = <K extends keyof TicketDoc>(field: K, value: TicketDoc[K]) => {
398
+ doc[field] = value
337
399
  fieldsChanged = true
338
400
  }
339
401
 
340
402
  if (patch.title !== undefined) set("title", patch.title)
341
403
  if (patch.description !== undefined) set("description", patch.description)
342
404
  if (patch.tags !== undefined) set("tags", patch.tags)
343
- if (patch.milestone !== undefined) set("milestone", patch.milestone)
344
- if (patch.needsHuman !== undefined) set("needsHuman", patch.needsHuman)
405
+ if (patch.goal !== undefined) {
406
+ if (patch.goal) this.assertGoalAssignable(patch.goal)
407
+ set("goal", patch.goal)
408
+ }
345
409
  if (patch.prs !== undefined) set("prs", patch.prs)
346
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
+ }
347
425
  const previous = doc.status
348
426
  set("status", patch.status)
349
427
  if (patch.position === undefined && patch.status !== previous) {
350
428
  // Moved columns without an explicit slot → land on top, where the
351
429
  // freshest movement is visible (Linear's behavior).
352
- 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)
353
431
  const top = column.length ? Math.min(...column.map((t) => t.doc.position)) : 0
354
432
  set("position", top - POSITION_GAP)
355
433
  }
@@ -359,7 +437,7 @@ export class FileStore implements Store {
359
437
  if (!fieldsChanged && !linksChanged) return this.toTask(ticket, all)
360
438
  if (fieldsChanged || linksChanged) doc.updatedAt = timestamp
361
439
  this.writeTicket(ticket)
362
- return this.get(number)!
440
+ return this.get(key)!
363
441
  }
364
442
 
365
443
  /**
@@ -368,30 +446,30 @@ export class FileStore implements Store {
368
446
  * (and, via the caller, this one) gets its `updated` bumped.
369
447
  */
370
448
  private reconcileBlocks(
371
- number: number,
449
+ key: string,
372
450
  all: Ticket[],
373
- targets: number[],
451
+ targets: string[],
374
452
  timestamp: string,
375
453
  ): boolean {
376
454
  const wanted = new Set(targets)
377
- if (wanted.has(number)) {
378
- 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)}`)
379
457
  }
380
458
  let changed = false
381
459
  for (const other of all) {
382
- if (other.number === number) continue
383
- const has = other.doc.blockedBy.includes(number)
384
- 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)
385
463
  if (has === should) continue
386
464
  other.doc.blockedBy = should
387
- ? [...other.doc.blockedBy, number].sort((a, b) => a - b)
388
- : other.doc.blockedBy.filter((n) => n !== number)
465
+ ? sortKeys([...other.doc.blockedBy, key])
466
+ : other.doc.blockedBy.filter((k) => k !== key)
389
467
  other.doc.updatedAt = timestamp
390
468
  this.writeTicket(other)
391
469
  changed = true
392
470
  }
393
471
  if (changed) {
394
- const self = all.find((t) => t.number === number)
472
+ const self = all.find((t) => t.key === key)
395
473
  if (self) {
396
474
  self.doc.updatedAt = timestamp
397
475
  this.writeTicket(self)
@@ -401,37 +479,37 @@ export class FileStore implements Store {
401
479
  }
402
480
 
403
481
  /** `task link A --blocks B` and friends — additive, unlike the patch form. */
404
- link(number: number, relation: "blocks" | "blocked_by", target: number): Task {
405
- const task = this.get(number)
406
- 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)}`)
407
485
  const current = relation === "blocks" ? task.blocks : task.blockedBy
408
486
  const patch: TaskPatch =
409
487
  relation === "blocks"
410
488
  ? { blocks: [...current, target] }
411
489
  : { blockedBy: [...current, target] }
412
- return this.update(number, patch)
490
+ return this.update(key, patch)
413
491
  }
414
492
 
415
- unlink(number: number, relation: "blocks" | "blocked_by", target: number): Task {
416
- const task = this.get(number)
417
- 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)}`)
418
496
  const current = relation === "blocks" ? task.blocks : task.blockedBy
419
- const kept = current.filter((n) => n !== target)
497
+ const kept = current.filter((k) => k !== target)
420
498
  const patch: TaskPatch = relation === "blocks" ? { blocks: kept } : { blockedBy: kept }
421
- return this.update(number, patch)
499
+ return this.update(key, patch)
422
500
  }
423
501
 
424
- delete(number: number): void {
425
- if (!this.readOne(number)) {
426
- this.assertNotArchived(number)
427
- 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)}`)
428
506
  }
429
- rmSync(join(this.ticketsDir, String(number)), { recursive: true })
507
+ rmSync(join(this.ticketsDir, key), { recursive: true })
430
508
  // Links pointing at the deleted ticket go with it — the same cascade the
431
509
  // relation table had, so quiet on the other tickets' `updated`.
432
510
  for (const other of this.readAll()) {
433
- if (!other.doc.blockedBy.includes(number)) continue
434
- 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)
435
513
  this.writeTicket(other)
436
514
  }
437
515
  }
@@ -442,41 +520,210 @@ export class FileStore implements Store {
442
520
  * Move a finished ticket's whole directory to `.task/archive/` — one rename,
443
521
  * which git records as a move, so history follows the ticket. Archived
444
522
  * tickets leave every hot path (`list`, the board, link derivation) but stay
445
- * readable via `get`/`comments` and keep their number reserved forever.
523
+ * readable via `get`/`comments` and keep their key reserved forever.
446
524
  * Links other tickets hold on this one are left in place: they're
447
525
  * dangling-tolerant everywhere, and unarchiving puts them back in force.
448
526
  */
449
- archive(number: number): Task {
450
- const ticket = this.readOne(number)
527
+ archive(key: string): Task {
528
+ const ticket = this.readOne(key)
451
529
  if (!ticket) {
452
- if (this.isArchived(number)) {
453
- throw new Error(`${this.displayId(number)} is already archived`)
530
+ if (this.isArchived(key)) {
531
+ throw new Error(`${this.displayId(key)} is already archived`)
454
532
  }
455
- throw new Error(`no such task: ${this.displayId(number)}`)
533
+ throw new Error(`no such task: ${this.displayId(key)}`)
456
534
  }
457
535
  if (ticket.doc.status !== "done" && ticket.doc.status !== "canceled") {
458
536
  throw new Error(
459
- `${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`,
460
538
  )
461
539
  }
462
540
  mkdirSync(this.archiveDir, { recursive: true })
463
- renameSync(join(this.ticketsDir, String(number)), join(this.archiveDir, String(number)))
464
- return this.get(number)!
541
+ renameSync(join(this.ticketsDir, key), join(this.archiveDir, key))
542
+ return this.get(key)!
465
543
  }
466
544
 
467
- unarchive(number: number): Task {
468
- if (!this.isArchived(number)) {
545
+ unarchive(key: string): Task {
546
+ if (!this.isArchived(key)) {
469
547
  throw new Error(
470
- this.readOne(number)
471
- ? `${this.displayId(number)} isn't archived`
472
- : `no such task: ${this.displayId(number)}`,
548
+ this.readOne(key)
549
+ ? `${this.displayId(key)} isn't archived`
550
+ : `no such task: ${this.displayId(key)}`,
473
551
  )
474
552
  }
475
553
  // A checkout where everything is archived has no tickets/ at all — git
476
554
  // doesn't keep empty directories.
477
555
  mkdirSync(this.ticketsDir, { recursive: true })
478
- renameSync(join(this.archiveDir, String(number)), join(this.ticketsDir, String(number)))
479
- return this.get(number)!
556
+ renameSync(join(this.archiveDir, key), join(this.ticketsDir, key))
557
+ return this.get(key)!
558
+ }
559
+
560
+ // ── Goals ──────────────────────────────────────────────────────────────────
561
+
562
+ private goalPath(slug: string, archived: boolean): string {
563
+ return join(archived ? this.goalsArchiveDir : this.goalsDir, `${slug}.md`)
564
+ }
565
+
566
+ private readGoalDir(archived: boolean): Goal[] {
567
+ const dir = archived ? this.goalsArchiveDir : this.goalsDir
568
+ if (!existsSync(dir)) return []
569
+ const goals: Goal[] = []
570
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
571
+ if (!entry.isFile() || !entry.name.endsWith(".md") || entry.name.startsWith(".")) continue
572
+ const slug = entry.name.slice(0, -3)
573
+ const path = join(dir, entry.name)
574
+ const doc = parseGoal(readFileSync(path, "utf8"), path)
575
+ goals.push(this.toGoal(slug, doc, archived))
576
+ }
577
+ goals.sort((a, b) => a.slug.localeCompare(b.slug))
578
+ return goals
579
+ }
580
+
581
+ private toGoal(slug: string, doc: GoalDoc, archived: boolean): Goal {
582
+ return {
583
+ slug,
584
+ title: doc.title,
585
+ description: doc.description,
586
+ createdAt: doc.createdAt,
587
+ updatedAt: doc.updatedAt,
588
+ // Absent on live goals, so existing payloads carry no new field.
589
+ ...(archived ? { archived: true } : {}),
590
+ }
591
+ }
592
+
593
+ goals(archived = false): Goal[] {
594
+ return this.readGoalDir(archived)
595
+ }
596
+
597
+ getGoal(slug: string): Goal | null {
598
+ for (const archived of [false, true]) {
599
+ const path = this.goalPath(slug, archived)
600
+ if (!existsSync(path)) continue
601
+ return this.toGoal(slug, parseGoal(readFileSync(path, "utf8"), path), archived)
602
+ }
603
+ return null
604
+ }
605
+
606
+ /**
607
+ * The gate every ticket write goes through: a `goal:` may only reference a
608
+ * *live* goal. Naming an archived one is refused too — assigning new work to
609
+ * a goal that's been shelved is the same mistake as editing an archived
610
+ * ticket, and gets the same answer.
611
+ */
612
+ private assertGoalAssignable(slug: string): void {
613
+ if (existsSync(this.goalPath(slug, false))) return
614
+ if (existsSync(this.goalPath(slug, true))) {
615
+ throw new Error(`goal "${slug}" is archived — run \`task goal unarchive ${slug}\` first`)
616
+ }
617
+ throw new Error(`no such goal: "${slug}" — create it with \`task goal add\``)
618
+ }
619
+
620
+ private writeGoal(slug: string, doc: GoalDoc, archived: boolean): void {
621
+ mkdirSync(archived ? this.goalsArchiveDir : this.goalsDir, { recursive: true })
622
+ writeAtomic(this.goalPath(slug, archived), serializeGoal(doc))
623
+ }
624
+
625
+ createGoal(input: GoalInput): Goal {
626
+ const title = input.title.trim()
627
+ if (!title) throw new Error("a goal needs a title")
628
+ const slug = input.slug ?? slugifyGoal(title)
629
+ if (!isGoalSlug(slug)) {
630
+ throw new Error(
631
+ `invalid goal slug: "${slug}" — lowercase letters, digits and dashes ("archive" is reserved)`,
632
+ )
633
+ }
634
+ if (this.getGoal(slug)) throw new Error(`goal "${slug}" already exists`)
635
+ const timestamp = now()
636
+ const doc: GoalDoc = {
637
+ title,
638
+ description: input.description ?? "",
639
+ createdAt: timestamp,
640
+ updatedAt: timestamp,
641
+ }
642
+ this.writeGoal(slug, doc, false)
643
+ return this.toGoal(slug, doc, false)
644
+ }
645
+
646
+ updateGoal(slug: string, patch: GoalPatch): Goal {
647
+ const path = this.goalPath(slug, false)
648
+ if (!existsSync(path)) {
649
+ if (existsSync(this.goalPath(slug, true))) {
650
+ throw new Error(`goal "${slug}" is archived — run \`task goal unarchive ${slug}\` first`)
651
+ }
652
+ throw new Error(`no such goal: "${slug}"`)
653
+ }
654
+ const doc = parseGoal(readFileSync(path, "utf8"), path)
655
+ if (patch.title !== undefined) {
656
+ const title = patch.title.trim()
657
+ if (!title) throw new Error("a goal needs a title")
658
+ doc.title = title
659
+ }
660
+ if (patch.description !== undefined) doc.description = patch.description
661
+ doc.updatedAt = now()
662
+ this.writeGoal(slug, doc, false)
663
+ return this.toGoal(slug, doc, false)
664
+ }
665
+
666
+ /**
667
+ * A goal leaves the board only after its work has: archiving is refused
668
+ * while any open task still points here. Done/canceled stragglers are fine —
669
+ * their references stay readable from the archive either way.
670
+ */
671
+ archiveGoal(slug: string): Goal {
672
+ const path = this.goalPath(slug, false)
673
+ if (!existsSync(path)) {
674
+ if (existsSync(this.goalPath(slug, true))) {
675
+ throw new Error(`goal "${slug}" is already archived`)
676
+ }
677
+ throw new Error(`no such goal: "${slug}"`)
678
+ }
679
+ const open = this.readAll().filter(
680
+ (t) => t.doc.goal === slug && t.doc.status !== "done" && t.doc.status !== "canceled",
681
+ )
682
+ if (open.length > 0) {
683
+ const ids = open.map((t) => this.displayId(t.key)).join(", ")
684
+ throw new Error(`goal "${slug}" still has open tasks (${ids}) — finish or reassign them first`)
685
+ }
686
+ mkdirSync(this.goalsArchiveDir, { recursive: true })
687
+ renameSync(path, this.goalPath(slug, true))
688
+ return this.getGoal(slug)!
689
+ }
690
+
691
+ unarchiveGoal(slug: string): Goal {
692
+ const path = this.goalPath(slug, true)
693
+ if (!existsSync(path)) {
694
+ throw new Error(
695
+ existsSync(this.goalPath(slug, false))
696
+ ? `goal "${slug}" isn't archived`
697
+ : `no such goal: "${slug}"`,
698
+ )
699
+ }
700
+ mkdirSync(this.goalsDir, { recursive: true })
701
+ renameSync(path, this.goalPath(slug, false))
702
+ return this.getGoal(slug)!
703
+ }
704
+
705
+ /**
706
+ * Deleting is for goals that never happened. Any live reference — whatever
707
+ * its status — blocks it: pruning organization off finished tickets is a
708
+ * human call, so the human makes it by clearing the refs first. Archived
709
+ * tickets' references are left to dangle; every reader tolerates that.
710
+ */
711
+ deleteGoal(slug: string): void {
712
+ const path = this.goalPath(slug, false)
713
+ if (!existsSync(path)) {
714
+ if (existsSync(this.goalPath(slug, true))) {
715
+ throw new Error(`goal "${slug}" is archived — run \`task goal unarchive ${slug}\` first`)
716
+ }
717
+ throw new Error(`no such goal: "${slug}"`)
718
+ }
719
+ const referencing = this.readAll().filter((t) => t.doc.goal === slug)
720
+ if (referencing.length > 0) {
721
+ const ids = referencing.map((t) => this.displayId(t.key)).join(", ")
722
+ throw new Error(
723
+ `goal "${slug}" is referenced by ${ids} — clear those with \`task update <id> --goal ""\` first`,
724
+ )
725
+ }
726
+ rmSync(path)
480
727
  }
481
728
 
482
729
  // ── Comments ───────────────────────────────────────────────────────────────
@@ -486,8 +733,8 @@ export class FileStore implements Store {
486
733
  * the same ticket produce two files and merge without a conflict — which is
487
734
  * why they aren't lines inside `ticket.md`.
488
735
  */
489
- comments(number: number): Comment[] {
490
- const dir = this.commentsPath(number)
736
+ comments(key: string): Comment[] {
737
+ const dir = this.commentsPath(key)
491
738
  if (!existsSync(dir)) return []
492
739
  const comments: Comment[] = []
493
740
  for (const entry of readdirSync(dir)) {
@@ -496,7 +743,7 @@ export class FileStore implements Store {
496
743
  const doc = parseComment(readFileSync(path, "utf8"), path)
497
744
  comments.push({
498
745
  id: entry.slice(0, -3),
499
- taskId: this.displayId(number),
746
+ taskId: this.displayId(key),
500
747
  author: doc.author,
501
748
  body: doc.body,
502
749
  createdAt: doc.createdAt,
@@ -508,53 +755,198 @@ export class FileStore implements Store {
508
755
  return comments
509
756
  }
510
757
 
511
- addComment(number: number, body: string, author = ""): Comment {
512
- if (!this.readOne(number)) {
513
- this.assertNotArchived(number)
514
- 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)}`)
515
762
  }
516
763
  const createdAt = now()
517
- const dir = this.commentsPath(number)
764
+ const dir = this.commentsPath(key)
518
765
  mkdirSync(dir, { recursive: true })
519
766
  const stem = commentStem(createdAt, author)
520
767
  let id = stem
521
768
  for (let n = 2; existsSync(join(dir, `${id}.md`)); n++) id = `${stem}-${n}`
522
769
  writeAtomic(join(dir, `${id}.md`), serializeComment({ author, createdAt, body }))
523
- return { id, taskId: this.displayId(number), author, body, createdAt }
770
+ return { id, taskId: this.displayId(key), author, body, createdAt }
524
771
  }
525
772
 
526
- deleteComment(number: number, commentId: string): Comment {
773
+ deleteComment(key: string, commentId: string): Comment {
527
774
  // Resolve the id against the directory listing rather than building a path
528
775
  // from it — the id arrived over HTTP and must not be able to point
529
776
  // anywhere but at an actual comment of this task.
530
- const existing = this.comments(number).find((c) => c.id === commentId)
777
+ const existing = this.comments(key).find((c) => c.id === commentId)
531
778
  if (!existing) {
532
- throw new Error(`no such comment on ${this.displayId(number)}: ${commentId}`)
779
+ throw new Error(`no such comment on ${this.displayId(key)}: ${commentId}`)
533
780
  }
534
- rmSync(join(this.commentsPath(number), `${commentId}.md`))
781
+ rmSync(join(this.commentsPath(key), `${commentId}.md`))
535
782
  return existing
536
783
  }
537
784
 
538
- commentCounts(): Map<number, number> {
539
- const counts = new Map<number, number>()
785
+ commentCounts(): Map<string, number> {
786
+ const counts = new Map<string, number>()
540
787
  for (const ticket of this.readAll()) {
541
- const dir = this.commentsPath(ticket.number)
788
+ const dir = this.commentsPath(ticket.key)
542
789
  if (!existsSync(dir)) continue
543
790
  const count = readdirSync(dir).filter(
544
791
  (f) => f.endsWith(".md") && !f.startsWith("."),
545
792
  ).length
546
- if (count > 0) counts.set(ticket.number, count)
793
+ if (count > 0) counts.set(ticket.key, count)
547
794
  }
548
795
  return counts
549
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)
550
939
  }
551
940
 
552
- // ── Opening, initializing, migrating ─────────────────────────────────────────
941
+ // ── Opening and initializing ─────────────────────────────────────────────────
942
+
943
+ /** The database a pre-0.6 board committed. Never read anymore, only ignored. */
944
+ const LEGACY_DB_FILE = "tasks.db"
553
945
 
554
946
  /** What `.task/.gitignore` says on a text-canonical board. */
555
947
  const GITIGNORE = `# Board state is the text files in tickets/ — the database (if one is still
556
- # around from before \`task migrate\`) is derived/legacy and stays out of git.
557
- ${DB_FILE}
948
+ # around from a pre-0.6 board) is legacy and stays out of git.
949
+ ${LEGACY_DB_FILE}
558
950
  *.db-journal
559
951
  *.db-wal
560
952
  *.db-shm
@@ -562,16 +954,34 @@ ${DB_FILE}
562
954
  `
563
955
 
564
956
  /**
565
- * The board in `root`, on whichever backend it uses: text files when the
566
- * `tickets/` tree exists or the config says version 2, the legacy SQLite
567
- * store otherwise. Old boards keep working untouched until `task migrate`.
957
+ * The board in `root`. Text files are the only backend; a board whose state is
958
+ * still a pre-0.6 committed SQLite database is refused loudly rather than read
959
+ * as empty silently starting a parallel text board next to a database full
960
+ * of tickets is the one failure mode worse than an error.
568
961
  */
569
962
  export function openBoard(root: string): Store {
570
963
  const taskDir = join(root, TASK_DIR)
571
- if (existsSync(join(taskDir, TICKETS_DIR))) return new FileStore(root)
572
- const config = JSON.parse(readFileSync(join(taskDir, CONFIG_FILE), "utf8")) as ProjectConfig
573
- if ((config.version ?? 1) >= 2) return new FileStore(root)
574
- return new TaskStore(root)
964
+ if (!existsSync(join(taskDir, TICKETS_DIR))) {
965
+ const config = JSON.parse(readFileSync(join(taskDir, CONFIG_FILE), "utf8")) as ProjectConfig
966
+ if ((config.version ?? 1) < 2 && existsSync(join(taskDir, LEGACY_DB_FILE))) {
967
+ throw new Error(
968
+ "this board still stores its state in a pre-0.6 SQLite database — run `npx @nickmeriano/task@0.6 migrate` once, then retry",
969
+ )
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
+ }
983
+ }
984
+ return new FileStore(root)
575
985
  }
576
986
 
577
987
  export interface InitOptions {
@@ -599,95 +1009,3 @@ export function initProject(root: string, options: InitOptions): Store {
599
1009
  writeFileSync(join(taskDir, ".gitignore"), GITIGNORE)
600
1010
  return new FileStore(root)
601
1011
  }
602
-
603
- export interface MigrateResult {
604
- tasks: number
605
- comments: number
606
- }
607
-
608
- /**
609
- * `task migrate` — export every ticket and comment from a legacy `tasks.db`
610
- * into the text layout, flip the config to version 2, and gitignore the
611
- * database. The database file itself is left on disk untouched, as a backup;
612
- * it just stops being the state.
613
- */
614
- export function migrateBoard(root: string): MigrateResult {
615
- const taskDir = join(root, TASK_DIR)
616
- if (existsSync(join(taskDir, TICKETS_DIR))) {
617
- throw new Error("already migrated — .task/tickets/ exists")
618
- }
619
- if (!existsSync(join(taskDir, DB_FILE))) {
620
- throw new Error(`nothing to migrate — no ${DB_FILE} in .task/`)
621
- }
622
-
623
- const legacy = new TaskStore(root)
624
- let comments = 0
625
- try {
626
- const tasks = legacy.list({})
627
- for (const task of tasks) {
628
- const dir = join(taskDir, TICKETS_DIR, String(task.number))
629
- mkdirSync(dir, { recursive: true })
630
- writeFileSync(
631
- join(dir, TICKET_FILE),
632
- serializeTicket({
633
- title: task.title,
634
- description: task.description,
635
- status: task.status,
636
- tags: task.tags,
637
- milestone: task.milestone,
638
- needsHuman: task.needsHuman,
639
- blockedBy: task.blockedBy,
640
- prs: task.prs,
641
- position: task.position,
642
- createdAt: task.createdAt,
643
- updatedAt: task.updatedAt,
644
- }),
645
- )
646
- const taskComments = legacy.comments(task.number)
647
- if (taskComments.length === 0) continue
648
- const commentsDir = join(dir, COMMENTS_DIR)
649
- mkdirSync(commentsDir)
650
- let previous: { createdAt: string; id: string } | null = null
651
- for (const comment of taskComments) {
652
- // `comments()` sorts by (createdAt, id), so ids must sort in legacy
653
- // (rowid) order wherever timestamps tie — and a fresh stem for a
654
- // different author can sort *before* the previous one. Chain off the
655
- // previous id instead: the collision suffix below then appends `-2`,
656
- // which sorts after. The frontmatter stays authoritative for the
657
- // author; the filename is only an ordering key.
658
- let stem = commentStem(comment.createdAt, comment.author)
659
- if (
660
- previous &&
661
- comment.createdAt === previous.createdAt &&
662
- stem.localeCompare(previous.id) < 0
663
- ) {
664
- stem = previous.id
665
- }
666
- let id = stem
667
- for (let n = 2; existsSync(join(commentsDir, `${id}.md`)); n++) id = `${stem}-${n}`
668
- previous = { createdAt: comment.createdAt, id }
669
- writeFileSync(
670
- join(commentsDir, `${id}.md`),
671
- serializeComment({
672
- author: comment.author,
673
- createdAt: comment.createdAt,
674
- body: comment.body,
675
- }),
676
- )
677
- comments++
678
- }
679
- }
680
-
681
- // Preserve whatever else the config carries; only the version flips.
682
- const config = JSON.parse(readFileSync(join(taskDir, CONFIG_FILE), "utf8")) as Record<
683
- string,
684
- unknown
685
- >
686
- config.version = 2
687
- writeFileSync(join(taskDir, CONFIG_FILE), `${JSON.stringify(config, null, 2)}\n`)
688
- writeFileSync(join(taskDir, ".gitignore"), GITIGNORE)
689
- return { tasks: tasks.length, comments }
690
- } finally {
691
- legacy.close()
692
- }
693
- }