@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
@@ -1,12 +1,18 @@
1
1
  import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync, } from "node:fs";
2
2
  import { basename, dirname, join } from "node:path";
3
- import { commentStem, parseComment, parseTicket, serializeComment, serializeTicket, } from "./ticket-doc.js";
4
- import { CONFIG_FILE, DB_FILE, POSITION_GAP, TASK_DIR, TaskStore, derivePrefix, } from "./store.js";
3
+ import { commentStem, isGoalSlug, parseAsk, parseComment, parseGoal, parseTicket, serializeAsk, serializeComment, serializeGoal, serializeTicket, slugifyGoal, } from "./ticket-doc.js";
4
+ import { isTicketKey, mintKey } from "./id.js";
5
+ import { CONFIG_FILE, POSITION_GAP, TASK_DIR, derivePrefix, } from "./store.js";
5
6
  export const TICKETS_DIR = "tickets";
6
7
  /** Finished tickets moved out of the hot path — same per-ticket layout. */
7
8
  export const ARCHIVE_DIR = "archive";
8
- const TICKET_FILE = "ticket.md";
9
- const COMMENTS_DIR = "comments";
9
+ export const TICKET_FILE = "ticket.md";
10
+ export const COMMENTS_DIR = "comments";
11
+ /** One file per ask — `tickets/<key>/asks/<stem>.md`, the comment layout. */
12
+ export const ASKS_DIR = "asks";
13
+ /** One file per goal — `.task/goals/<slug>.md`; archived ones one level down. */
14
+ export const GOALS_DIR = "goals";
15
+ export const GOALS_ARCHIVE_DIR = "archive";
10
16
  function now() {
11
17
  return new Date().toISOString();
12
18
  }
@@ -19,13 +25,22 @@ function writeAtomic(path, text) {
19
25
  writeFileSync(tmp, text);
20
26
  renameSync(tmp, path);
21
27
  }
28
+ /** Stable sort for key lists — lexicographic, since keys carry no order. */
29
+ function sortKeys(keys) {
30
+ return keys.sort((a, b) => a.localeCompare(b));
31
+ }
32
+ /** Board order's tiebreak: creation time, then key — keys themselves say nothing. */
33
+ function byCreation(a, b) {
34
+ return a.doc.createdAt.localeCompare(b.doc.createdAt) || a.key.localeCompare(b.key);
35
+ }
22
36
  /**
23
37
  * The canonical persistence layer: one directory per ticket under
24
- * `.task/tickets/`, holding a frontmatter+markdown `ticket.md` and one file
25
- * per comment. Everything is plain text committed to git, which is the point —
26
- * ticket edits diff, review, and merge like code, and two branches touching
27
- * different tickets (or adding comments to the same one) merge cleanly by
28
- * construction.
38
+ * `.task/tickets/`, named by the ticket's key (see id.ts), holding a
39
+ * frontmatter+markdown `ticket.md` and one file per comment. Everything is
40
+ * plain text committed to git, which is the point ticket edits diff, review,
41
+ * and merge like code, and two branches creating tickets (or adding comments
42
+ * to the same one) merge cleanly by construction: keys are random, so parallel
43
+ * branches can't mint the same directory.
29
44
  *
30
45
  * There is no database and no cache: boards are dozens of tickets, and
31
46
  * re-reading a handful of small files per operation is cheaper than opening
@@ -38,104 +53,127 @@ export class FileStore {
38
53
  config;
39
54
  ticketsDir;
40
55
  archiveDir;
56
+ goalsDir;
57
+ goalsArchiveDir;
41
58
  constructor(root) {
42
59
  this.root = root;
43
60
  this.taskDir = join(root, TASK_DIR);
44
61
  this.config = JSON.parse(readFileSync(join(this.taskDir, CONFIG_FILE), "utf8"));
45
62
  this.ticketsDir = join(this.taskDir, TICKETS_DIR);
46
63
  this.archiveDir = join(this.taskDir, ARCHIVE_DIR);
64
+ this.goalsDir = join(this.taskDir, GOALS_DIR);
65
+ this.goalsArchiveDir = join(this.goalsDir, GOALS_ARCHIVE_DIR);
47
66
  }
48
67
  close() { }
49
- displayId(number) {
50
- return `${this.config.prefix}-${number}`;
68
+ displayId(key) {
69
+ return `${this.config.prefix}-${key}`;
51
70
  }
52
- /** Accepts "PHONE-12", "phone-12" or "12". */
71
+ /**
72
+ * "PHO-x7k4m", "x7k4m", or any unique prefix of a key ("x7") — resolved
73
+ * git-style against the board's live and archived tickets, case-insensitive.
74
+ * An ambiguous prefix throws naming the candidates; an unknown ref comes
75
+ * back verbatim, so the caller's lookup says "no such task" with it.
76
+ */
53
77
  parseId(ref) {
54
- const match = /^(?:[A-Za-z0-9]+-)?(\d+)$/.exec(ref.trim());
78
+ const match = /^(?:[A-Za-z0-9]+-)?([A-Za-z0-9]+)$/.exec(ref.trim());
55
79
  if (!match)
56
80
  throw new Error(`invalid task id: ${ref}`);
57
- return Number(match[1]);
81
+ const key = match[1].toLowerCase();
82
+ const known = [...this.liveKeys(), ...this.archivedKeys()];
83
+ if (known.includes(key))
84
+ return key;
85
+ const hits = sortKeys(known.filter((k) => k.startsWith(key)));
86
+ if (hits.length === 1)
87
+ return hits[0];
88
+ if (hits.length > 1) {
89
+ throw new Error(`${ref} is ambiguous — matches ${hits.map((k) => this.displayId(k)).join(", ")}`);
90
+ }
91
+ return key;
58
92
  }
59
93
  // ── Reading ────────────────────────────────────────────────────────────────
60
- ticketPath(number) {
61
- return join(this.ticketsDir, String(number), TICKET_FILE);
94
+ ticketPath(key) {
95
+ return join(this.ticketsDir, key, TICKET_FILE);
62
96
  }
63
97
  /**
64
98
  * A ticket's comments live wherever the ticket does — the whole directory
65
99
  * moves on archive, so an archived ticket's discussion stays readable.
66
100
  */
67
- commentsPath(number) {
68
- const home = existsSync(join(this.archiveDir, String(number)))
69
- ? this.archiveDir
70
- : this.ticketsDir;
71
- return join(home, String(number), COMMENTS_DIR);
101
+ commentsPath(key) {
102
+ const home = existsSync(join(this.archiveDir, key)) ? this.archiveDir : this.ticketsDir;
103
+ return join(home, key, COMMENTS_DIR);
72
104
  }
73
105
  readDir(dir) {
74
106
  if (!existsSync(dir))
75
107
  return [];
76
108
  const tickets = [];
77
109
  for (const entry of readdirSync(dir, { withFileTypes: true })) {
78
- if (!entry.isDirectory() || !/^\d+$/.test(entry.name))
110
+ if (!entry.isDirectory() || !isTicketKey(entry.name))
79
111
  continue;
80
112
  const path = join(dir, entry.name, TICKET_FILE);
81
113
  if (!existsSync(path))
82
114
  continue;
83
- tickets.push({ number: Number(entry.name), doc: parseTicket(readFileSync(path, "utf8"), path) });
115
+ tickets.push({ key: entry.name, doc: parseTicket(readFileSync(path, "utf8"), path) });
84
116
  }
85
- tickets.sort((a, b) => a.number - b.number);
117
+ tickets.sort(byCreation);
86
118
  return tickets;
87
119
  }
88
120
  /** Every live ticket on the board, freshly parsed — the files are the state. */
89
121
  readAll() {
90
122
  return this.readDir(this.ticketsDir);
91
123
  }
92
- readOne(number) {
93
- const path = this.ticketPath(number);
94
- if (!existsSync(path))
124
+ readOne(key) {
125
+ const path = this.ticketPath(key);
126
+ if (!isTicketKey(key) || !existsSync(path))
95
127
  return null;
96
- return { number, doc: parseTicket(readFileSync(path, "utf8"), path) };
128
+ return { key, doc: parseTicket(readFileSync(path, "utf8"), path) };
97
129
  }
98
- /** Cheap presence check the archive's numbers, without parsing anything. */
99
- archivedNumbers() {
100
- if (!existsSync(this.archiveDir))
130
+ /** Directory names that are well-formed keys, without parsing anything. */
131
+ keysIn(dir) {
132
+ if (!existsSync(dir))
101
133
  return [];
102
- return readdirSync(this.archiveDir)
103
- .filter((name) => /^\d+$/.test(name))
104
- .map(Number);
134
+ return readdirSync(dir).filter(isTicketKey);
105
135
  }
106
- isArchived(number) {
107
- return existsSync(join(this.archiveDir, String(number), TICKET_FILE));
136
+ liveKeys() {
137
+ return this.keysIn(this.ticketsDir);
138
+ }
139
+ /** Cheap presence check — the archive's keys, without parsing anything. */
140
+ archivedKeys() {
141
+ return this.keysIn(this.archiveDir);
142
+ }
143
+ isArchived(key) {
144
+ return isTicketKey(key) && existsSync(join(this.archiveDir, key, TICKET_FILE));
108
145
  }
109
146
  /** The error every write path throws instead of touching the archive. */
110
- assertNotArchived(number) {
111
- if (this.isArchived(number)) {
112
- const id = this.displayId(number);
147
+ assertNotArchived(key) {
148
+ if (this.isArchived(key)) {
149
+ const id = this.displayId(key);
113
150
  throw new Error(`${id} is archived — run \`task unarchive ${id}\` first`);
114
151
  }
115
152
  }
116
153
  writeTicket(ticket) {
117
- mkdirSync(dirname(this.ticketPath(ticket.number)), { recursive: true });
118
- writeAtomic(this.ticketPath(ticket.number), serializeTicket(ticket.doc));
154
+ mkdirSync(dirname(this.ticketPath(ticket.key)), { recursive: true });
155
+ writeAtomic(this.ticketPath(ticket.key), serializeTicket(ticket.doc));
119
156
  }
120
157
  /**
121
158
  * Only `blocked_by` is stored (on the blocked ticket); the `blocks` side is
122
- * derived here, so the two views can never disagree.
159
+ * derived here, so the two views can never disagree. `needsHuman` is derived
160
+ * too — open asks, nothing else — so a flagged ticket with no stated reason
161
+ * cannot exist.
123
162
  */
124
- toTask(ticket, all) {
125
- const blocks = all
126
- .filter((t) => t.doc.blockedBy.includes(ticket.number))
127
- .map((t) => t.number);
163
+ toTask(ticket, all, asks = this.asks(ticket.key)) {
164
+ const blocks = sortKeys(all.filter((t) => t.doc.blockedBy.includes(ticket.key)).map((t) => t.key));
128
165
  return {
129
- id: this.displayId(ticket.number),
130
- number: ticket.number,
166
+ id: this.displayId(ticket.key),
167
+ key: ticket.key,
131
168
  title: ticket.doc.title,
132
169
  description: ticket.doc.description,
133
170
  status: ticket.doc.status,
134
171
  tags: ticket.doc.tags,
135
- milestone: ticket.doc.milestone,
136
- needsHuman: ticket.doc.needsHuman,
172
+ goal: ticket.doc.goal,
173
+ needsHuman: asks.some((a) => !a.resolvedAt),
174
+ asks,
137
175
  blocks,
138
- blockedBy: [...ticket.doc.blockedBy].sort((a, b) => a - b),
176
+ blockedBy: sortKeys([...ticket.doc.blockedBy]),
139
177
  prs: ticket.doc.prs,
140
178
  position: ticket.doc.position,
141
179
  createdAt: ticket.doc.createdAt,
@@ -147,11 +185,13 @@ export class FileStore {
147
185
  const wanted = new Set(filter.statuses);
148
186
  tickets = tickets.filter((t) => wanted.has(t.doc.status));
149
187
  }
150
- if (filter.milestone) {
151
- tickets = tickets.filter((t) => t.doc.milestone === filter.milestone);
188
+ if (filter.goal) {
189
+ tickets = tickets.filter((t) => t.doc.goal === filter.goal);
152
190
  }
153
191
  if (filter.needsHuman !== undefined) {
154
- tickets = tickets.filter((t) => t.doc.needsHuman === filter.needsHuman);
192
+ // The derived flag, same definition as toTask — filtering must never
193
+ // disagree with what the payload says.
194
+ tickets = tickets.filter((t) => this.asks(t.key).some((a) => !a.resolvedAt) === filter.needsHuman);
155
195
  }
156
196
  if (filter.tags?.length) {
157
197
  const wanted = new Set(filter.tags);
@@ -168,52 +208,58 @@ export class FileStore {
168
208
  const everything = [...this.readAll(), ...archived];
169
209
  return this.applyFilter(archived, filter)
170
210
  .map((t) => ({ ...this.toTask(t, everything), archived: true }))
171
- .sort((a, b) => a.position - b.position || a.number - b.number);
211
+ .sort((a, b) => a.position - b.position || byCreationTask(a, b));
172
212
  }
173
213
  const all = this.readAll();
174
214
  return this.applyFilter(all, filter)
175
215
  .map((t) => this.toTask(t, all))
176
- .sort((a, b) => a.position - b.position || a.number - b.number);
216
+ .sort((a, b) => a.position - b.position || byCreationTask(a, b));
177
217
  }
178
- get(number) {
218
+ get(key) {
179
219
  const all = this.readAll();
180
- const ticket = all.find((t) => t.number === number);
220
+ const ticket = all.find((t) => t.key === key);
181
221
  if (ticket)
182
222
  return this.toTask(ticket, all);
183
223
  // `show` should reach the archive without ceremony — reads are safe.
184
- const path = join(this.archiveDir, String(number), TICKET_FILE);
224
+ if (!isTicketKey(key))
225
+ return null;
226
+ const path = join(this.archiveDir, key, TICKET_FILE);
185
227
  if (!existsSync(path))
186
228
  return null;
187
- const archived = { number, doc: parseTicket(readFileSync(path, "utf8"), path) };
229
+ const archived = { key, doc: parseTicket(readFileSync(path, "utf8"), path) };
188
230
  return { ...this.toTask(archived, [...all, archived]), archived: true };
189
231
  }
190
232
  // ── Writing ────────────────────────────────────────────────────────────────
191
233
  create(input) {
192
234
  const all = this.readAll();
193
- // Archived numbers stay reserved a new ticket must never take a number
194
- // that old comments or PR titles still point at.
195
- const taken = [...all.map((t) => t.number), ...this.archivedNumbers()];
196
- const number = taken.reduce((max, n) => Math.max(max, n), 0) + 1;
197
- const status = input.status ?? "todo";
235
+ // Random, not sequential (TAS-42): the key must be safe to mint on any
236
+ // branch with no coordination. Archived keys stay reserved — a new ticket
237
+ // must never take a name old comments or PR titles still point at.
238
+ const taken = new Set([...all.map((t) => t.key), ...this.archivedKeys()]);
239
+ const key = mintKey(taken);
240
+ // Dump by default: new tickets land in backlog, the no-quality-bar list;
241
+ // `todo` (the ready list) is an explicit choice — usually `task promote`.
242
+ const status = input.status ?? "backlog";
198
243
  for (const target of [...(input.blocks ?? []), ...(input.blockedBy ?? [])]) {
199
- if (!all.some((t) => t.number === target)) {
244
+ if (!all.some((t) => t.key === target)) {
200
245
  throw new Error(`no such task: ${this.displayId(target)}`);
201
246
  }
202
247
  }
248
+ if (input.goal)
249
+ this.assertGoalAssignable(input.goal);
203
250
  // New tasks land at the bottom of their column.
204
251
  const column = all.filter((t) => t.doc.status === status);
205
252
  const bottom = column.length ? Math.max(...column.map((t) => t.doc.position)) : 0;
206
253
  const timestamp = now();
207
254
  const ticket = {
208
- number,
255
+ key,
209
256
  doc: {
210
257
  title: input.title,
211
258
  description: input.description ?? "",
212
259
  status,
213
260
  tags: input.tags ?? [],
214
- milestone: input.milestone ?? null,
215
- needsHuman: input.needsHuman ?? false,
216
- blockedBy: [...new Set(input.blockedBy ?? [])].sort((a, b) => a - b),
261
+ goal: input.goal ?? null,
262
+ blockedBy: sortKeys([...new Set(input.blockedBy ?? [])]),
217
263
  prs: input.prs ?? [],
218
264
  position: bottom + POSITION_GAP,
219
265
  createdAt: timestamp,
@@ -222,25 +268,25 @@ export class FileStore {
222
268
  };
223
269
  this.writeTicket(ticket);
224
270
  if (input.blocks !== undefined) {
225
- this.reconcileBlocks(number, [...all, ticket], input.blocks, timestamp);
271
+ this.reconcileBlocks(key, [...all, ticket], input.blocks, timestamp);
226
272
  }
227
- return this.get(number);
273
+ return this.get(key);
228
274
  }
229
- update(number, patch) {
275
+ update(key, patch) {
230
276
  const all = this.readAll();
231
- const ticket = all.find((t) => t.number === number);
277
+ const ticket = all.find((t) => t.key === key);
232
278
  if (!ticket) {
233
- this.assertNotArchived(number);
234
- throw new Error(`no such task: ${this.displayId(number)}`);
279
+ this.assertNotArchived(key);
280
+ throw new Error(`no such task: ${this.displayId(key)}`);
235
281
  }
236
282
  // Validate link targets before any file is written, so a bad target
237
283
  // rejects the whole patch — the transactional behavior the SQLite store
238
284
  // got for free.
239
285
  for (const target of [...(patch.blocks ?? []), ...(patch.blockedBy ?? [])]) {
240
- if (target === number) {
241
- throw new Error(`a task can't block itself: ${this.displayId(number)}`);
286
+ if (target === key) {
287
+ throw new Error(`a task can't block itself: ${this.displayId(key)}`);
242
288
  }
243
- if (!all.some((t) => t.number === target)) {
289
+ if (!all.some((t) => t.key === target)) {
244
290
  this.assertNotArchived(target);
245
291
  throw new Error(`no such task: ${this.displayId(target)}`);
246
292
  }
@@ -248,14 +294,14 @@ export class FileStore {
248
294
  const timestamp = now();
249
295
  let linksChanged = false;
250
296
  if (patch.blocks !== undefined) {
251
- linksChanged = this.reconcileBlocks(number, all, patch.blocks, timestamp);
297
+ linksChanged = this.reconcileBlocks(key, all, patch.blocks, timestamp);
252
298
  }
253
299
  if (patch.blockedBy !== undefined) {
254
- const wanted = [...new Set(patch.blockedBy)].sort((a, b) => a - b);
300
+ const wanted = sortKeys([...new Set(patch.blockedBy)]);
255
301
  const current = ticket.doc.blockedBy;
256
302
  const touched = [
257
- ...current.filter((n) => !wanted.includes(n)),
258
- ...wanted.filter((n) => !current.includes(n)),
303
+ ...current.filter((k) => !wanted.includes(k)),
304
+ ...wanted.filter((k) => !current.includes(k)),
259
305
  ];
260
306
  if (touched.length > 0) {
261
307
  linksChanged = true;
@@ -263,8 +309,8 @@ export class FileStore {
263
309
  // The other end of every added or removed link gets its `updated`
264
310
  // bumped too — its derived `blocks` view just changed. A dangling
265
311
  // reference (hand-edit pointing at a deleted ticket) has no other end.
266
- for (const n of touched) {
267
- const other = all.find((t) => t.number === n);
312
+ for (const k of touched) {
313
+ const other = all.find((t) => t.key === k);
268
314
  if (!other)
269
315
  continue;
270
316
  other.doc.updatedAt = timestamp;
@@ -274,8 +320,8 @@ export class FileStore {
274
320
  }
275
321
  const doc = ticket.doc;
276
322
  let fieldsChanged = false;
277
- const set = (key, value) => {
278
- doc[key] = value;
323
+ const set = (field, value) => {
324
+ doc[field] = value;
279
325
  fieldsChanged = true;
280
326
  };
281
327
  if (patch.title !== undefined)
@@ -284,19 +330,32 @@ export class FileStore {
284
330
  set("description", patch.description);
285
331
  if (patch.tags !== undefined)
286
332
  set("tags", patch.tags);
287
- if (patch.milestone !== undefined)
288
- set("milestone", patch.milestone);
289
- if (patch.needsHuman !== undefined)
290
- set("needsHuman", patch.needsHuman);
333
+ if (patch.goal !== undefined) {
334
+ if (patch.goal)
335
+ this.assertGoalAssignable(patch.goal);
336
+ set("goal", patch.goal);
337
+ }
291
338
  if (patch.prs !== undefined)
292
339
  set("prs", patch.prs);
293
340
  if (patch.status !== undefined) {
341
+ // Done is gated on nothing owed: open asks refuse the move. Canceled
342
+ // stays open — a dead ticket's asks die with it and leave the inbox on
343
+ // their own.
344
+ if (patch.status === "done" && doc.status !== "done") {
345
+ const open = this.asks(key).filter((a) => !a.resolvedAt);
346
+ if (open.length > 0) {
347
+ const id = this.displayId(key);
348
+ throw new Error(`${id} still has ${open.length} open ask${open.length === 1 ? "" : "s"} — done means nobody owes anything. ` +
349
+ `Resolve them (\`task resolve ${id} <n>\`) or move what's still owed to a new ticket:\n` +
350
+ open.map((a) => ` [${a.ordinal}] ${a.text.split("\n")[0]}`).join("\n"));
351
+ }
352
+ }
294
353
  const previous = doc.status;
295
354
  set("status", patch.status);
296
355
  if (patch.position === undefined && patch.status !== previous) {
297
356
  // Moved columns without an explicit slot → land on top, where the
298
357
  // freshest movement is visible (Linear's behavior).
299
- const column = all.filter((t) => t.number !== number && t.doc.status === patch.status);
358
+ const column = all.filter((t) => t.key !== key && t.doc.status === patch.status);
300
359
  const top = column.length ? Math.min(...column.map((t) => t.doc.position)) : 0;
301
360
  set("position", top - POSITION_GAP);
302
361
  }
@@ -308,35 +367,35 @@ export class FileStore {
308
367
  if (fieldsChanged || linksChanged)
309
368
  doc.updatedAt = timestamp;
310
369
  this.writeTicket(ticket);
311
- return this.get(number);
370
+ return this.get(key);
312
371
  }
313
372
  /**
314
373
  * Make every other ticket's `blocked_by` agree with "this task blocks
315
374
  * exactly `targets`". Returns whether anything changed; every touched ticket
316
375
  * (and, via the caller, this one) gets its `updated` bumped.
317
376
  */
318
- reconcileBlocks(number, all, targets, timestamp) {
377
+ reconcileBlocks(key, all, targets, timestamp) {
319
378
  const wanted = new Set(targets);
320
- if (wanted.has(number)) {
321
- throw new Error(`a task can't block itself: ${this.displayId(number)}`);
379
+ if (wanted.has(key)) {
380
+ throw new Error(`a task can't block itself: ${this.displayId(key)}`);
322
381
  }
323
382
  let changed = false;
324
383
  for (const other of all) {
325
- if (other.number === number)
384
+ if (other.key === key)
326
385
  continue;
327
- const has = other.doc.blockedBy.includes(number);
328
- const should = wanted.has(other.number);
386
+ const has = other.doc.blockedBy.includes(key);
387
+ const should = wanted.has(other.key);
329
388
  if (has === should)
330
389
  continue;
331
390
  other.doc.blockedBy = should
332
- ? [...other.doc.blockedBy, number].sort((a, b) => a - b)
333
- : other.doc.blockedBy.filter((n) => n !== number);
391
+ ? sortKeys([...other.doc.blockedBy, key])
392
+ : other.doc.blockedBy.filter((k) => k !== key);
334
393
  other.doc.updatedAt = timestamp;
335
394
  this.writeTicket(other);
336
395
  changed = true;
337
396
  }
338
397
  if (changed) {
339
- const self = all.find((t) => t.number === number);
398
+ const self = all.find((t) => t.key === key);
340
399
  if (self) {
341
400
  self.doc.updatedAt = timestamp;
342
401
  this.writeTicket(self);
@@ -345,37 +404,37 @@ export class FileStore {
345
404
  return changed;
346
405
  }
347
406
  /** `task link A --blocks B` and friends — additive, unlike the patch form. */
348
- link(number, relation, target) {
349
- const task = this.get(number);
407
+ link(key, relation, target) {
408
+ const task = this.get(key);
350
409
  if (!task)
351
- throw new Error(`no such task: ${this.displayId(number)}`);
410
+ throw new Error(`no such task: ${this.displayId(key)}`);
352
411
  const current = relation === "blocks" ? task.blocks : task.blockedBy;
353
412
  const patch = relation === "blocks"
354
413
  ? { blocks: [...current, target] }
355
414
  : { blockedBy: [...current, target] };
356
- return this.update(number, patch);
415
+ return this.update(key, patch);
357
416
  }
358
- unlink(number, relation, target) {
359
- const task = this.get(number);
417
+ unlink(key, relation, target) {
418
+ const task = this.get(key);
360
419
  if (!task)
361
- throw new Error(`no such task: ${this.displayId(number)}`);
420
+ throw new Error(`no such task: ${this.displayId(key)}`);
362
421
  const current = relation === "blocks" ? task.blocks : task.blockedBy;
363
- const kept = current.filter((n) => n !== target);
422
+ const kept = current.filter((k) => k !== target);
364
423
  const patch = relation === "blocks" ? { blocks: kept } : { blockedBy: kept };
365
- return this.update(number, patch);
424
+ return this.update(key, patch);
366
425
  }
367
- delete(number) {
368
- if (!this.readOne(number)) {
369
- this.assertNotArchived(number);
370
- throw new Error(`no such task: ${this.displayId(number)}`);
426
+ delete(key) {
427
+ if (!this.readOne(key)) {
428
+ this.assertNotArchived(key);
429
+ throw new Error(`no such task: ${this.displayId(key)}`);
371
430
  }
372
- rmSync(join(this.ticketsDir, String(number)), { recursive: true });
431
+ rmSync(join(this.ticketsDir, key), { recursive: true });
373
432
  // Links pointing at the deleted ticket go with it — the same cascade the
374
433
  // relation table had, so quiet on the other tickets' `updated`.
375
434
  for (const other of this.readAll()) {
376
- if (!other.doc.blockedBy.includes(number))
435
+ if (!other.doc.blockedBy.includes(key))
377
436
  continue;
378
- other.doc.blockedBy = other.doc.blockedBy.filter((n) => n !== number);
437
+ other.doc.blockedBy = other.doc.blockedBy.filter((k) => k !== key);
379
438
  this.writeTicket(other);
380
439
  }
381
440
  }
@@ -384,36 +443,192 @@ export class FileStore {
384
443
  * Move a finished ticket's whole directory to `.task/archive/` — one rename,
385
444
  * which git records as a move, so history follows the ticket. Archived
386
445
  * tickets leave every hot path (`list`, the board, link derivation) but stay
387
- * readable via `get`/`comments` and keep their number reserved forever.
446
+ * readable via `get`/`comments` and keep their key reserved forever.
388
447
  * Links other tickets hold on this one are left in place: they're
389
448
  * dangling-tolerant everywhere, and unarchiving puts them back in force.
390
449
  */
391
- archive(number) {
392
- const ticket = this.readOne(number);
450
+ archive(key) {
451
+ const ticket = this.readOne(key);
393
452
  if (!ticket) {
394
- if (this.isArchived(number)) {
395
- throw new Error(`${this.displayId(number)} is already archived`);
453
+ if (this.isArchived(key)) {
454
+ throw new Error(`${this.displayId(key)} is already archived`);
396
455
  }
397
- throw new Error(`no such task: ${this.displayId(number)}`);
456
+ throw new Error(`no such task: ${this.displayId(key)}`);
398
457
  }
399
458
  if (ticket.doc.status !== "done" && ticket.doc.status !== "canceled") {
400
- throw new Error(`${this.displayId(number)} is ${ticket.doc.status} — only done or canceled tickets can be archived`);
459
+ throw new Error(`${this.displayId(key)} is ${ticket.doc.status} — only done or canceled tickets can be archived`);
401
460
  }
402
461
  mkdirSync(this.archiveDir, { recursive: true });
403
- renameSync(join(this.ticketsDir, String(number)), join(this.archiveDir, String(number)));
404
- return this.get(number);
405
- }
406
- unarchive(number) {
407
- if (!this.isArchived(number)) {
408
- throw new Error(this.readOne(number)
409
- ? `${this.displayId(number)} isn't archived`
410
- : `no such task: ${this.displayId(number)}`);
462
+ renameSync(join(this.ticketsDir, key), join(this.archiveDir, key));
463
+ return this.get(key);
464
+ }
465
+ unarchive(key) {
466
+ if (!this.isArchived(key)) {
467
+ throw new Error(this.readOne(key)
468
+ ? `${this.displayId(key)} isn't archived`
469
+ : `no such task: ${this.displayId(key)}`);
411
470
  }
412
471
  // A checkout where everything is archived has no tickets/ at all — git
413
472
  // doesn't keep empty directories.
414
473
  mkdirSync(this.ticketsDir, { recursive: true });
415
- renameSync(join(this.archiveDir, String(number)), join(this.ticketsDir, String(number)));
416
- return this.get(number);
474
+ renameSync(join(this.archiveDir, key), join(this.ticketsDir, key));
475
+ return this.get(key);
476
+ }
477
+ // ── Goals ──────────────────────────────────────────────────────────────────
478
+ goalPath(slug, archived) {
479
+ return join(archived ? this.goalsArchiveDir : this.goalsDir, `${slug}.md`);
480
+ }
481
+ readGoalDir(archived) {
482
+ const dir = archived ? this.goalsArchiveDir : this.goalsDir;
483
+ if (!existsSync(dir))
484
+ return [];
485
+ const goals = [];
486
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
487
+ if (!entry.isFile() || !entry.name.endsWith(".md") || entry.name.startsWith("."))
488
+ continue;
489
+ const slug = entry.name.slice(0, -3);
490
+ const path = join(dir, entry.name);
491
+ const doc = parseGoal(readFileSync(path, "utf8"), path);
492
+ goals.push(this.toGoal(slug, doc, archived));
493
+ }
494
+ goals.sort((a, b) => a.slug.localeCompare(b.slug));
495
+ return goals;
496
+ }
497
+ toGoal(slug, doc, archived) {
498
+ return {
499
+ slug,
500
+ title: doc.title,
501
+ description: doc.description,
502
+ createdAt: doc.createdAt,
503
+ updatedAt: doc.updatedAt,
504
+ // Absent on live goals, so existing payloads carry no new field.
505
+ ...(archived ? { archived: true } : {}),
506
+ };
507
+ }
508
+ goals(archived = false) {
509
+ return this.readGoalDir(archived);
510
+ }
511
+ getGoal(slug) {
512
+ for (const archived of [false, true]) {
513
+ const path = this.goalPath(slug, archived);
514
+ if (!existsSync(path))
515
+ continue;
516
+ return this.toGoal(slug, parseGoal(readFileSync(path, "utf8"), path), archived);
517
+ }
518
+ return null;
519
+ }
520
+ /**
521
+ * The gate every ticket write goes through: a `goal:` may only reference a
522
+ * *live* goal. Naming an archived one is refused too — assigning new work to
523
+ * a goal that's been shelved is the same mistake as editing an archived
524
+ * ticket, and gets the same answer.
525
+ */
526
+ assertGoalAssignable(slug) {
527
+ if (existsSync(this.goalPath(slug, false)))
528
+ return;
529
+ if (existsSync(this.goalPath(slug, true))) {
530
+ throw new Error(`goal "${slug}" is archived — run \`task goal unarchive ${slug}\` first`);
531
+ }
532
+ throw new Error(`no such goal: "${slug}" — create it with \`task goal add\``);
533
+ }
534
+ writeGoal(slug, doc, archived) {
535
+ mkdirSync(archived ? this.goalsArchiveDir : this.goalsDir, { recursive: true });
536
+ writeAtomic(this.goalPath(slug, archived), serializeGoal(doc));
537
+ }
538
+ createGoal(input) {
539
+ const title = input.title.trim();
540
+ if (!title)
541
+ throw new Error("a goal needs a title");
542
+ const slug = input.slug ?? slugifyGoal(title);
543
+ if (!isGoalSlug(slug)) {
544
+ throw new Error(`invalid goal slug: "${slug}" — lowercase letters, digits and dashes ("archive" is reserved)`);
545
+ }
546
+ if (this.getGoal(slug))
547
+ throw new Error(`goal "${slug}" already exists`);
548
+ const timestamp = now();
549
+ const doc = {
550
+ title,
551
+ description: input.description ?? "",
552
+ createdAt: timestamp,
553
+ updatedAt: timestamp,
554
+ };
555
+ this.writeGoal(slug, doc, false);
556
+ return this.toGoal(slug, doc, false);
557
+ }
558
+ updateGoal(slug, patch) {
559
+ const path = this.goalPath(slug, false);
560
+ if (!existsSync(path)) {
561
+ if (existsSync(this.goalPath(slug, true))) {
562
+ throw new Error(`goal "${slug}" is archived — run \`task goal unarchive ${slug}\` first`);
563
+ }
564
+ throw new Error(`no such goal: "${slug}"`);
565
+ }
566
+ const doc = parseGoal(readFileSync(path, "utf8"), path);
567
+ if (patch.title !== undefined) {
568
+ const title = patch.title.trim();
569
+ if (!title)
570
+ throw new Error("a goal needs a title");
571
+ doc.title = title;
572
+ }
573
+ if (patch.description !== undefined)
574
+ doc.description = patch.description;
575
+ doc.updatedAt = now();
576
+ this.writeGoal(slug, doc, false);
577
+ return this.toGoal(slug, doc, false);
578
+ }
579
+ /**
580
+ * A goal leaves the board only after its work has: archiving is refused
581
+ * while any open task still points here. Done/canceled stragglers are fine —
582
+ * their references stay readable from the archive either way.
583
+ */
584
+ archiveGoal(slug) {
585
+ const path = this.goalPath(slug, false);
586
+ if (!existsSync(path)) {
587
+ if (existsSync(this.goalPath(slug, true))) {
588
+ throw new Error(`goal "${slug}" is already archived`);
589
+ }
590
+ throw new Error(`no such goal: "${slug}"`);
591
+ }
592
+ const open = this.readAll().filter((t) => t.doc.goal === slug && t.doc.status !== "done" && t.doc.status !== "canceled");
593
+ if (open.length > 0) {
594
+ const ids = open.map((t) => this.displayId(t.key)).join(", ");
595
+ throw new Error(`goal "${slug}" still has open tasks (${ids}) — finish or reassign them first`);
596
+ }
597
+ mkdirSync(this.goalsArchiveDir, { recursive: true });
598
+ renameSync(path, this.goalPath(slug, true));
599
+ return this.getGoal(slug);
600
+ }
601
+ unarchiveGoal(slug) {
602
+ const path = this.goalPath(slug, true);
603
+ if (!existsSync(path)) {
604
+ throw new Error(existsSync(this.goalPath(slug, false))
605
+ ? `goal "${slug}" isn't archived`
606
+ : `no such goal: "${slug}"`);
607
+ }
608
+ mkdirSync(this.goalsDir, { recursive: true });
609
+ renameSync(path, this.goalPath(slug, false));
610
+ return this.getGoal(slug);
611
+ }
612
+ /**
613
+ * Deleting is for goals that never happened. Any live reference — whatever
614
+ * its status — blocks it: pruning organization off finished tickets is a
615
+ * human call, so the human makes it by clearing the refs first. Archived
616
+ * tickets' references are left to dangle; every reader tolerates that.
617
+ */
618
+ deleteGoal(slug) {
619
+ const path = this.goalPath(slug, false);
620
+ if (!existsSync(path)) {
621
+ if (existsSync(this.goalPath(slug, true))) {
622
+ throw new Error(`goal "${slug}" is archived — run \`task goal unarchive ${slug}\` first`);
623
+ }
624
+ throw new Error(`no such goal: "${slug}"`);
625
+ }
626
+ const referencing = this.readAll().filter((t) => t.doc.goal === slug);
627
+ if (referencing.length > 0) {
628
+ const ids = referencing.map((t) => this.displayId(t.key)).join(", ");
629
+ throw new Error(`goal "${slug}" is referenced by ${ids} — clear those with \`task update <id> --goal ""\` first`);
630
+ }
631
+ rmSync(path);
417
632
  }
418
633
  // ── Comments ───────────────────────────────────────────────────────────────
419
634
  /**
@@ -421,8 +636,8 @@ export class FileStore {
421
636
  * the same ticket produce two files and merge without a conflict — which is
422
637
  * why they aren't lines inside `ticket.md`.
423
638
  */
424
- comments(number) {
425
- const dir = this.commentsPath(number);
639
+ comments(key) {
640
+ const dir = this.commentsPath(key);
426
641
  if (!existsSync(dir))
427
642
  return [];
428
643
  const comments = [];
@@ -433,7 +648,7 @@ export class FileStore {
433
648
  const doc = parseComment(readFileSync(path, "utf8"), path);
434
649
  comments.push({
435
650
  id: entry.slice(0, -3),
436
- taskId: this.displayId(number),
651
+ taskId: this.displayId(key),
437
652
  author: doc.author,
438
653
  body: doc.body,
439
654
  createdAt: doc.createdAt,
@@ -442,68 +657,205 @@ export class FileStore {
442
657
  comments.sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id));
443
658
  return comments;
444
659
  }
445
- addComment(number, body, author = "") {
446
- if (!this.readOne(number)) {
447
- this.assertNotArchived(number);
448
- throw new Error(`no such task: ${this.displayId(number)}`);
660
+ addComment(key, body, author = "") {
661
+ if (!this.readOne(key)) {
662
+ this.assertNotArchived(key);
663
+ throw new Error(`no such task: ${this.displayId(key)}`);
449
664
  }
450
665
  const createdAt = now();
451
- const dir = this.commentsPath(number);
666
+ const dir = this.commentsPath(key);
452
667
  mkdirSync(dir, { recursive: true });
453
668
  const stem = commentStem(createdAt, author);
454
669
  let id = stem;
455
670
  for (let n = 2; existsSync(join(dir, `${id}.md`)); n++)
456
671
  id = `${stem}-${n}`;
457
672
  writeAtomic(join(dir, `${id}.md`), serializeComment({ author, createdAt, body }));
458
- return { id, taskId: this.displayId(number), author, body, createdAt };
673
+ return { id, taskId: this.displayId(key), author, body, createdAt };
459
674
  }
460
- deleteComment(number, commentId) {
675
+ deleteComment(key, commentId) {
461
676
  // Resolve the id against the directory listing rather than building a path
462
677
  // from it — the id arrived over HTTP and must not be able to point
463
678
  // anywhere but at an actual comment of this task.
464
- const existing = this.comments(number).find((c) => c.id === commentId);
679
+ const existing = this.comments(key).find((c) => c.id === commentId);
465
680
  if (!existing) {
466
- throw new Error(`no such comment on ${this.displayId(number)}: ${commentId}`);
681
+ throw new Error(`no such comment on ${this.displayId(key)}: ${commentId}`);
467
682
  }
468
- rmSync(join(this.commentsPath(number), `${commentId}.md`));
683
+ rmSync(join(this.commentsPath(key), `${commentId}.md`));
469
684
  return existing;
470
685
  }
471
686
  commentCounts() {
472
687
  const counts = new Map();
473
688
  for (const ticket of this.readAll()) {
474
- const dir = this.commentsPath(ticket.number);
689
+ const dir = this.commentsPath(ticket.key);
475
690
  if (!existsSync(dir))
476
691
  continue;
477
692
  const count = readdirSync(dir).filter((f) => f.endsWith(".md") && !f.startsWith(".")).length;
478
693
  if (count > 0)
479
- counts.set(ticket.number, count);
694
+ counts.set(ticket.key, count);
480
695
  }
481
696
  return counts;
482
697
  }
698
+ // ── Asks ───────────────────────────────────────────────────────────────────
699
+ /** Like comments, an archived ticket keeps its asks readable in place. */
700
+ asksPath(key) {
701
+ const home = existsSync(join(this.archiveDir, key)) ? this.archiveDir : this.ticketsDir;
702
+ return join(home, key, ASKS_DIR);
703
+ }
704
+ /**
705
+ * Every ask on a ticket, open and resolved, in creation order — the order
706
+ * that fixes each ask's ordinal, the short handle `task resolve` takes.
707
+ * Ordinals are positions in this listing rather than stored, so two branches
708
+ * adding asks concurrently can't mint a duplicate; they shift only if an ask
709
+ * is deleted outright, which is why resolving (not deleting) is the normal
710
+ * way an ask ends.
711
+ */
712
+ asks(key) {
713
+ const dir = this.asksPath(key);
714
+ if (!existsSync(dir))
715
+ return [];
716
+ const asks = [];
717
+ for (const entry of readdirSync(dir)) {
718
+ if (!entry.endsWith(".md") || entry.startsWith("."))
719
+ continue;
720
+ const path = join(dir, entry);
721
+ const doc = parseAsk(readFileSync(path, "utf8"), path);
722
+ asks.push({
723
+ id: entry.slice(0, -3),
724
+ taskId: this.displayId(key),
725
+ text: doc.body,
726
+ author: doc.author,
727
+ createdAt: doc.createdAt,
728
+ resolvedAt: doc.resolvedAt,
729
+ resolvedBy: doc.resolvedBy,
730
+ });
731
+ }
732
+ asks.sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id));
733
+ return asks.map((ask, index) => ({ ...ask, ordinal: index + 1 }));
734
+ }
735
+ /**
736
+ * File an ask — allowed only while the ticket is open. A done ticket can't
737
+ * quietly start owing things again (that's the NIC-17 failure this feature
738
+ * exists to end): reopen it, or file the ask as a new ticket.
739
+ */
740
+ addAsk(key, text, author = "") {
741
+ const ticket = this.readOne(key);
742
+ if (!ticket) {
743
+ this.assertNotArchived(key);
744
+ throw new Error(`no such task: ${this.displayId(key)}`);
745
+ }
746
+ if (ticket.doc.status === "done" || ticket.doc.status === "canceled") {
747
+ throw new Error(`${this.displayId(key)} is ${ticket.doc.status} — asks belong on open tickets. Reopen it, or file the ask as a new ticket`);
748
+ }
749
+ const createdAt = now();
750
+ const dir = this.asksPath(key);
751
+ mkdirSync(dir, { recursive: true });
752
+ const stem = commentStem(createdAt, author);
753
+ let id = stem;
754
+ for (let n = 2; existsSync(join(dir, `${id}.md`)); n++)
755
+ id = `${stem}-${n}`;
756
+ const doc = { author, createdAt, resolvedAt: null, resolvedBy: null, body: text };
757
+ writeAtomic(join(dir, `${id}.md`), serializeAsk(doc));
758
+ return this.asks(key).find((a) => a.id === id);
759
+ }
760
+ /**
761
+ * Resolve an ask against the current listing — `ref` is an ordinal ("2") or
762
+ * the ask's id. Resolution edits the ask's own file, so the person's answer
763
+ * and the worker's question live in one place whatever branch each came from.
764
+ */
765
+ resolveAsk(key, ref, resolvedBy = "") {
766
+ return this.setAskResolution(key, ref, now(), resolvedBy);
767
+ }
768
+ /** Reopen a resolved ask — same refusals as adding one. */
769
+ reopenAsk(key, ref) {
770
+ return this.setAskResolution(key, ref, null, null);
771
+ }
772
+ setAskResolution(key, ref, resolvedAt, resolvedBy) {
773
+ this.assertNotArchived(key);
774
+ const ask = this.findAsk(key, ref);
775
+ const ticket = this.readOne(key);
776
+ if (resolvedAt === null &&
777
+ (ticket?.doc.status === "done" || ticket?.doc.status === "canceled")) {
778
+ throw new Error(`${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`);
779
+ }
780
+ const doc = {
781
+ author: ask.author,
782
+ createdAt: ask.createdAt,
783
+ resolvedAt,
784
+ resolvedBy: resolvedAt === null ? null : resolvedBy,
785
+ body: ask.text,
786
+ };
787
+ writeAtomic(join(this.asksPath(key), `${ask.id}.md`), serializeAsk(doc));
788
+ return this.asks(key).find((a) => a.id === ask.id);
789
+ }
790
+ deleteAsk(key, ref) {
791
+ this.assertNotArchived(key);
792
+ const ask = this.findAsk(key, ref);
793
+ rmSync(join(this.asksPath(key), `${ask.id}.md`));
794
+ return ask;
795
+ }
796
+ /**
797
+ * Resolve an ordinal or id against the directory listing rather than
798
+ * building a path from it — same rule as comment ids, the ref arrived over
799
+ * HTTP or a shell and must not point anywhere but at this ticket's asks.
800
+ */
801
+ findAsk(key, ref) {
802
+ const asks = this.asks(key);
803
+ const trimmed = ref.trim();
804
+ const byOrdinal = /^\d+$/.test(trimmed)
805
+ ? asks.find((a) => a.ordinal === Number(trimmed))
806
+ : undefined;
807
+ const ask = byOrdinal ?? asks.find((a) => a.id === trimmed);
808
+ if (!ask) {
809
+ const id = this.displayId(key);
810
+ throw new Error(asks.length === 0
811
+ ? `${id} has no asks`
812
+ : `no such ask on ${id}: ${ref} — ordinals run 1-${asks.length} (\`task show ${id}\` lists them)`);
813
+ }
814
+ return ask;
815
+ }
483
816
  }
484
- // ── Opening, initializing, migrating ─────────────────────────────────────────
817
+ /** The list() tiebreak, restated over Task — creation time, then key. */
818
+ function byCreationTask(a, b) {
819
+ return a.createdAt.localeCompare(b.createdAt) || a.key.localeCompare(b.key);
820
+ }
821
+ // ── Opening and initializing ─────────────────────────────────────────────────
822
+ /** The database a pre-0.6 board committed. Never read anymore, only ignored. */
823
+ const LEGACY_DB_FILE = "tasks.db";
485
824
  /** What `.task/.gitignore` says on a text-canonical board. */
486
825
  const GITIGNORE = `# Board state is the text files in tickets/ — the database (if one is still
487
- # around from before \`task migrate\`) is derived/legacy and stays out of git.
488
- ${DB_FILE}
826
+ # around from a pre-0.6 board) is legacy and stays out of git.
827
+ ${LEGACY_DB_FILE}
489
828
  *.db-journal
490
829
  *.db-wal
491
830
  *.db-shm
492
831
  *.tmp
493
832
  `;
494
833
  /**
495
- * The board in `root`, on whichever backend it uses: text files when the
496
- * `tickets/` tree exists or the config says version 2, the legacy SQLite
497
- * store otherwise. Old boards keep working untouched until `task migrate`.
834
+ * The board in `root`. Text files are the only backend; a board whose state is
835
+ * still a pre-0.6 committed SQLite database is refused loudly rather than read
836
+ * as empty silently starting a parallel text board next to a database full
837
+ * of tickets is the one failure mode worse than an error.
498
838
  */
499
839
  export function openBoard(root) {
500
840
  const taskDir = join(root, TASK_DIR);
501
- if (existsSync(join(taskDir, TICKETS_DIR)))
502
- return new FileStore(root);
503
- const config = JSON.parse(readFileSync(join(taskDir, CONFIG_FILE), "utf8"));
504
- if ((config.version ?? 1) >= 2)
505
- return new FileStore(root);
506
- return new TaskStore(root);
841
+ if (!existsSync(join(taskDir, TICKETS_DIR))) {
842
+ const config = JSON.parse(readFileSync(join(taskDir, CONFIG_FILE), "utf8"));
843
+ if ((config.version ?? 1) < 2 && existsSync(join(taskDir, LEGACY_DB_FILE))) {
844
+ throw new Error("this board still stores its state in a pre-0.6 SQLite database run `npx @nickmeriano/task@0.6 migrate` once, then retry");
845
+ }
846
+ }
847
+ else {
848
+ // A board whose tickets are ALL numeric directories predates the TAS-42
849
+ // id cutover. Reading it as empty (the reader skips numeric dirs) would
850
+ // silently strand every ticket — refuse loudly instead, same policy as
851
+ // the SQLite case. A mixed board (a stray numeric dir merged in from an
852
+ // old branch) still opens; `task check` names the straggler.
853
+ const entries = readdirSync(join(taskDir, TICKETS_DIR));
854
+ if (entries.some((name) => /^\d+$/.test(name)) && !entries.some(isTicketKey)) {
855
+ throw new Error("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");
856
+ }
857
+ }
858
+ return new FileStore(root);
507
859
  }
508
860
  /**
509
861
  * Create `.task/` in `root`: config and a .gitignore. New boards are
@@ -525,80 +877,4 @@ export function initProject(root, options) {
525
877
  writeFileSync(join(taskDir, ".gitignore"), GITIGNORE);
526
878
  return new FileStore(root);
527
879
  }
528
- /**
529
- * `task migrate` — export every ticket and comment from a legacy `tasks.db`
530
- * into the text layout, flip the config to version 2, and gitignore the
531
- * database. The database file itself is left on disk untouched, as a backup;
532
- * it just stops being the state.
533
- */
534
- export function migrateBoard(root) {
535
- const taskDir = join(root, TASK_DIR);
536
- if (existsSync(join(taskDir, TICKETS_DIR))) {
537
- throw new Error("already migrated — .task/tickets/ exists");
538
- }
539
- if (!existsSync(join(taskDir, DB_FILE))) {
540
- throw new Error(`nothing to migrate — no ${DB_FILE} in .task/`);
541
- }
542
- const legacy = new TaskStore(root);
543
- let comments = 0;
544
- try {
545
- const tasks = legacy.list({});
546
- for (const task of tasks) {
547
- const dir = join(taskDir, TICKETS_DIR, String(task.number));
548
- mkdirSync(dir, { recursive: true });
549
- writeFileSync(join(dir, TICKET_FILE), serializeTicket({
550
- title: task.title,
551
- description: task.description,
552
- status: task.status,
553
- tags: task.tags,
554
- milestone: task.milestone,
555
- needsHuman: task.needsHuman,
556
- blockedBy: task.blockedBy,
557
- prs: task.prs,
558
- position: task.position,
559
- createdAt: task.createdAt,
560
- updatedAt: task.updatedAt,
561
- }));
562
- const taskComments = legacy.comments(task.number);
563
- if (taskComments.length === 0)
564
- continue;
565
- const commentsDir = join(dir, COMMENTS_DIR);
566
- mkdirSync(commentsDir);
567
- let previous = null;
568
- for (const comment of taskComments) {
569
- // `comments()` sorts by (createdAt, id), so ids must sort in legacy
570
- // (rowid) order wherever timestamps tie — and a fresh stem for a
571
- // different author can sort *before* the previous one. Chain off the
572
- // previous id instead: the collision suffix below then appends `-2`,
573
- // which sorts after. The frontmatter stays authoritative for the
574
- // author; the filename is only an ordering key.
575
- let stem = commentStem(comment.createdAt, comment.author);
576
- if (previous &&
577
- comment.createdAt === previous.createdAt &&
578
- stem.localeCompare(previous.id) < 0) {
579
- stem = previous.id;
580
- }
581
- let id = stem;
582
- for (let n = 2; existsSync(join(commentsDir, `${id}.md`)); n++)
583
- id = `${stem}-${n}`;
584
- previous = { createdAt: comment.createdAt, id };
585
- writeFileSync(join(commentsDir, `${id}.md`), serializeComment({
586
- author: comment.author,
587
- createdAt: comment.createdAt,
588
- body: comment.body,
589
- }));
590
- comments++;
591
- }
592
- }
593
- // Preserve whatever else the config carries; only the version flips.
594
- const config = JSON.parse(readFileSync(join(taskDir, CONFIG_FILE), "utf8"));
595
- config.version = 2;
596
- writeFileSync(join(taskDir, CONFIG_FILE), `${JSON.stringify(config, null, 2)}\n`);
597
- writeFileSync(join(taskDir, ".gitignore"), GITIGNORE);
598
- return { tasks: tasks.length, comments };
599
- }
600
- finally {
601
- legacy.close();
602
- }
603
- }
604
880
  //# sourceMappingURL=file-store.js.map