@nickmeriano/task 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/README.md +65 -22
  2. package/dist/asks.test.d.ts +17 -0
  3. package/dist/asks.test.d.ts.map +1 -0
  4. package/dist/asks.test.js +278 -0
  5. package/dist/asks.test.js.map +1 -0
  6. package/dist/check.d.ts +3 -2
  7. package/dist/check.d.ts.map +1 -1
  8. package/dist/check.js +99 -60
  9. package/dist/check.js.map +1 -1
  10. package/dist/check.test.js +99 -60
  11. package/dist/check.test.js.map +1 -1
  12. package/dist/claim-io.d.ts +73 -0
  13. package/dist/claim-io.d.ts.map +1 -0
  14. package/dist/claim-io.js +344 -0
  15. package/dist/claim-io.js.map +1 -0
  16. package/dist/claim.d.ts +21 -22
  17. package/dist/claim.d.ts.map +1 -1
  18. package/dist/claim.js +58 -83
  19. package/dist/claim.js.map +1 -1
  20. package/dist/claim.test.d.ts +2 -2
  21. package/dist/claim.test.js +111 -94
  22. package/dist/claim.test.js.map +1 -1
  23. package/dist/cli.js +241 -64
  24. package/dist/cli.js.map +1 -1
  25. package/dist/file-store.d.ts +70 -24
  26. package/dist/file-store.d.ts.map +1 -1
  27. package/dist/file-store.js +319 -141
  28. package/dist/file-store.js.map +1 -1
  29. package/dist/git-serve.d.ts +183 -0
  30. package/dist/git-serve.d.ts.map +1 -0
  31. package/dist/git-serve.js +503 -0
  32. package/dist/git-serve.js.map +1 -0
  33. package/dist/git-serve.test.d.ts +16 -0
  34. package/dist/git-serve.test.d.ts.map +1 -0
  35. package/dist/git-serve.test.js +183 -0
  36. package/dist/git-serve.test.js.map +1 -0
  37. package/dist/git.d.ts +65 -0
  38. package/dist/git.d.ts.map +1 -0
  39. package/dist/git.js +114 -0
  40. package/dist/git.js.map +1 -0
  41. package/dist/id.d.ts +39 -0
  42. package/dist/id.d.ts.map +1 -0
  43. package/dist/id.js +67 -0
  44. package/dist/id.js.map +1 -0
  45. package/dist/inbox.d.ts +41 -0
  46. package/dist/inbox.d.ts.map +1 -0
  47. package/dist/inbox.js +56 -0
  48. package/dist/inbox.js.map +1 -0
  49. package/dist/overview.d.ts +9 -0
  50. package/dist/overview.d.ts.map +1 -1
  51. package/dist/overview.js +19 -11
  52. package/dist/overview.js.map +1 -1
  53. package/dist/overview.test.js +6 -5
  54. package/dist/overview.test.js.map +1 -1
  55. package/dist/promote.test.d.ts +3 -2
  56. package/dist/promote.test.d.ts.map +1 -1
  57. package/dist/promote.test.js +29 -31
  58. package/dist/promote.test.js.map +1 -1
  59. package/dist/publish.d.ts +2 -17
  60. package/dist/publish.d.ts.map +1 -1
  61. package/dist/publish.js +4 -49
  62. package/dist/publish.js.map +1 -1
  63. package/dist/search.d.ts +3 -1
  64. package/dist/search.d.ts.map +1 -1
  65. package/dist/search.js +15 -2
  66. package/dist/search.js.map +1 -1
  67. package/dist/search.test.js +9 -9
  68. package/dist/search.test.js.map +1 -1
  69. package/dist/server.d.ts.map +1 -1
  70. package/dist/server.js +218 -21
  71. package/dist/server.js.map +1 -1
  72. package/dist/store.d.ts +28 -14
  73. package/dist/store.d.ts.map +1 -1
  74. package/dist/store.js.map +1 -1
  75. package/dist/store.test.js +88 -75
  76. package/dist/store.test.js.map +1 -1
  77. package/dist/ticket-doc.d.ts +26 -10
  78. package/dist/ticket-doc.d.ts.map +1 -1
  79. package/dist/ticket-doc.js +71 -27
  80. package/dist/ticket-doc.js.map +1 -1
  81. package/dist/types.d.ts +51 -17
  82. package/dist/types.d.ts.map +1 -1
  83. package/dist/types.js.map +1 -1
  84. package/package.json +1 -1
  85. package/skill/SKILL.md +70 -23
  86. package/src/asks.test.ts +355 -0
  87. package/src/check.test.ts +129 -72
  88. package/src/check.ts +101 -58
  89. package/src/claim-io.ts +401 -0
  90. package/src/claim.test.ts +141 -99
  91. package/src/claim.ts +69 -96
  92. package/src/cli.ts +251 -63
  93. package/src/file-store.ts +357 -145
  94. package/src/git-serve.test.ts +240 -0
  95. package/src/git-serve.ts +595 -0
  96. package/src/git.ts +141 -0
  97. package/src/id.ts +68 -0
  98. package/src/inbox.ts +77 -0
  99. package/src/overview.test.ts +6 -5
  100. package/src/overview.ts +25 -10
  101. package/src/promote.test.ts +40 -28
  102. package/src/publish.ts +6 -53
  103. package/src/search.test.ts +11 -11
  104. package/src/search.ts +17 -3
  105. package/src/server.ts +221 -19
  106. package/src/store.test.ts +95 -77
  107. package/src/store.ts +28 -13
  108. package/src/ticket-doc.ts +93 -30
  109. package/src/types.ts +52 -17
  110. package/ui/dist/assets/index-BjsorZOU.js +229 -0
  111. package/ui/dist/assets/index-CoKCUYic.css +1 -0
  112. package/ui/dist/index.html +2 -2
  113. package/ui/dist/assets/index-BJmOsOdR.js +0 -229
  114. package/ui/dist/assets/index-BoqQlqSU.css +0 -1
@@ -1,12 +1,15 @@
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, isGoalSlug, parseComment, parseGoal, parseTicket, serializeComment, serializeGoal, serializeTicket, slugifyGoal, } from "./ticket-doc.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";
4
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
9
  export const TICKET_FILE = "ticket.md";
9
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";
10
13
  /** One file per goal — `.task/goals/<slug>.md`; archived ones one level down. */
11
14
  export const GOALS_DIR = "goals";
12
15
  export const GOALS_ARCHIVE_DIR = "archive";
@@ -22,13 +25,22 @@ function writeAtomic(path, text) {
22
25
  writeFileSync(tmp, text);
23
26
  renameSync(tmp, path);
24
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
+ }
25
36
  /**
26
37
  * The canonical persistence layer: one directory per ticket under
27
- * `.task/tickets/`, holding a frontmatter+markdown `ticket.md` and one file
28
- * per comment. Everything is plain text committed to git, which is the point —
29
- * ticket edits diff, review, and merge like code, and two branches touching
30
- * different tickets (or adding comments to the same one) merge cleanly by
31
- * 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.
32
44
  *
33
45
  * There is no database and no cache: boards are dozens of tickets, and
34
46
  * re-reading a handful of small files per operation is cheaper than opening
@@ -53,96 +65,115 @@ export class FileStore {
53
65
  this.goalsArchiveDir = join(this.goalsDir, GOALS_ARCHIVE_DIR);
54
66
  }
55
67
  close() { }
56
- displayId(number) {
57
- return `${this.config.prefix}-${number}`;
68
+ displayId(key) {
69
+ return `${this.config.prefix}-${key}`;
58
70
  }
59
- /** 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
+ */
60
77
  parseId(ref) {
61
- const match = /^(?:[A-Za-z0-9]+-)?(\d+)$/.exec(ref.trim());
78
+ const match = /^(?:[A-Za-z0-9]+-)?([A-Za-z0-9]+)$/.exec(ref.trim());
62
79
  if (!match)
63
80
  throw new Error(`invalid task id: ${ref}`);
64
- 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;
65
92
  }
66
93
  // ── Reading ────────────────────────────────────────────────────────────────
67
- ticketPath(number) {
68
- return join(this.ticketsDir, String(number), TICKET_FILE);
94
+ ticketPath(key) {
95
+ return join(this.ticketsDir, key, TICKET_FILE);
69
96
  }
70
97
  /**
71
98
  * A ticket's comments live wherever the ticket does — the whole directory
72
99
  * moves on archive, so an archived ticket's discussion stays readable.
73
100
  */
74
- commentsPath(number) {
75
- const home = existsSync(join(this.archiveDir, String(number)))
76
- ? this.archiveDir
77
- : this.ticketsDir;
78
- 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);
79
104
  }
80
105
  readDir(dir) {
81
106
  if (!existsSync(dir))
82
107
  return [];
83
108
  const tickets = [];
84
109
  for (const entry of readdirSync(dir, { withFileTypes: true })) {
85
- if (!entry.isDirectory() || !/^\d+$/.test(entry.name))
110
+ if (!entry.isDirectory() || !isTicketKey(entry.name))
86
111
  continue;
87
112
  const path = join(dir, entry.name, TICKET_FILE);
88
113
  if (!existsSync(path))
89
114
  continue;
90
- tickets.push({ number: Number(entry.name), doc: parseTicket(readFileSync(path, "utf8"), path) });
115
+ tickets.push({ key: entry.name, doc: parseTicket(readFileSync(path, "utf8"), path) });
91
116
  }
92
- tickets.sort((a, b) => a.number - b.number);
117
+ tickets.sort(byCreation);
93
118
  return tickets;
94
119
  }
95
120
  /** Every live ticket on the board, freshly parsed — the files are the state. */
96
121
  readAll() {
97
122
  return this.readDir(this.ticketsDir);
98
123
  }
99
- readOne(number) {
100
- const path = this.ticketPath(number);
101
- if (!existsSync(path))
124
+ readOne(key) {
125
+ const path = this.ticketPath(key);
126
+ if (!isTicketKey(key) || !existsSync(path))
102
127
  return null;
103
- return { number, doc: parseTicket(readFileSync(path, "utf8"), path) };
128
+ return { key, doc: parseTicket(readFileSync(path, "utf8"), path) };
104
129
  }
105
- /** Cheap presence check the archive's numbers, without parsing anything. */
106
- archivedNumbers() {
107
- if (!existsSync(this.archiveDir))
130
+ /** Directory names that are well-formed keys, without parsing anything. */
131
+ keysIn(dir) {
132
+ if (!existsSync(dir))
108
133
  return [];
109
- return readdirSync(this.archiveDir)
110
- .filter((name) => /^\d+$/.test(name))
111
- .map(Number);
134
+ return readdirSync(dir).filter(isTicketKey);
135
+ }
136
+ liveKeys() {
137
+ return this.keysIn(this.ticketsDir);
112
138
  }
113
- isArchived(number) {
114
- return existsSync(join(this.archiveDir, String(number), TICKET_FILE));
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));
115
145
  }
116
146
  /** The error every write path throws instead of touching the archive. */
117
- assertNotArchived(number) {
118
- if (this.isArchived(number)) {
119
- const id = this.displayId(number);
147
+ assertNotArchived(key) {
148
+ if (this.isArchived(key)) {
149
+ const id = this.displayId(key);
120
150
  throw new Error(`${id} is archived — run \`task unarchive ${id}\` first`);
121
151
  }
122
152
  }
123
153
  writeTicket(ticket) {
124
- mkdirSync(dirname(this.ticketPath(ticket.number)), { recursive: true });
125
- 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));
126
156
  }
127
157
  /**
128
158
  * Only `blocked_by` is stored (on the blocked ticket); the `blocks` side is
129
- * 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.
130
162
  */
131
- toTask(ticket, all) {
132
- const blocks = all
133
- .filter((t) => t.doc.blockedBy.includes(ticket.number))
134
- .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));
135
165
  return {
136
- id: this.displayId(ticket.number),
137
- number: ticket.number,
166
+ id: this.displayId(ticket.key),
167
+ key: ticket.key,
138
168
  title: ticket.doc.title,
139
169
  description: ticket.doc.description,
140
170
  status: ticket.doc.status,
141
171
  tags: ticket.doc.tags,
142
172
  goal: ticket.doc.goal,
143
- needsHuman: ticket.doc.needsHuman,
173
+ needsHuman: asks.some((a) => !a.resolvedAt),
174
+ asks,
144
175
  blocks,
145
- blockedBy: [...ticket.doc.blockedBy].sort((a, b) => a - b),
176
+ blockedBy: sortKeys([...ticket.doc.blockedBy]),
146
177
  prs: ticket.doc.prs,
147
178
  position: ticket.doc.position,
148
179
  createdAt: ticket.doc.createdAt,
@@ -158,7 +189,9 @@ export class FileStore {
158
189
  tickets = tickets.filter((t) => t.doc.goal === filter.goal);
159
190
  }
160
191
  if (filter.needsHuman !== undefined) {
161
- 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);
162
195
  }
163
196
  if (filter.tags?.length) {
164
197
  const wanted = new Set(filter.tags);
@@ -175,37 +208,40 @@ export class FileStore {
175
208
  const everything = [...this.readAll(), ...archived];
176
209
  return this.applyFilter(archived, filter)
177
210
  .map((t) => ({ ...this.toTask(t, everything), archived: true }))
178
- .sort((a, b) => a.position - b.position || a.number - b.number);
211
+ .sort((a, b) => a.position - b.position || byCreationTask(a, b));
179
212
  }
180
213
  const all = this.readAll();
181
214
  return this.applyFilter(all, filter)
182
215
  .map((t) => this.toTask(t, all))
183
- .sort((a, b) => a.position - b.position || a.number - b.number);
216
+ .sort((a, b) => a.position - b.position || byCreationTask(a, b));
184
217
  }
185
- get(number) {
218
+ get(key) {
186
219
  const all = this.readAll();
187
- const ticket = all.find((t) => t.number === number);
220
+ const ticket = all.find((t) => t.key === key);
188
221
  if (ticket)
189
222
  return this.toTask(ticket, all);
190
223
  // `show` should reach the archive without ceremony — reads are safe.
191
- 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);
192
227
  if (!existsSync(path))
193
228
  return null;
194
- const archived = { number, doc: parseTicket(readFileSync(path, "utf8"), path) };
229
+ const archived = { key, doc: parseTicket(readFileSync(path, "utf8"), path) };
195
230
  return { ...this.toTask(archived, [...all, archived]), archived: true };
196
231
  }
197
232
  // ── Writing ────────────────────────────────────────────────────────────────
198
233
  create(input) {
199
234
  const all = this.readAll();
200
- // Archived numbers stay reserved a new ticket must never take a number
201
- // that old comments or PR titles still point at.
202
- const taken = [...all.map((t) => t.number), ...this.archivedNumbers()];
203
- const number = taken.reduce((max, n) => Math.max(max, n), 0) + 1;
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);
204
240
  // Dump by default: new tickets land in backlog, the no-quality-bar list;
205
241
  // `todo` (the ready list) is an explicit choice — usually `task promote`.
206
242
  const status = input.status ?? "backlog";
207
243
  for (const target of [...(input.blocks ?? []), ...(input.blockedBy ?? [])]) {
208
- if (!all.some((t) => t.number === target)) {
244
+ if (!all.some((t) => t.key === target)) {
209
245
  throw new Error(`no such task: ${this.displayId(target)}`);
210
246
  }
211
247
  }
@@ -216,15 +252,14 @@ export class FileStore {
216
252
  const bottom = column.length ? Math.max(...column.map((t) => t.doc.position)) : 0;
217
253
  const timestamp = now();
218
254
  const ticket = {
219
- number,
255
+ key,
220
256
  doc: {
221
257
  title: input.title,
222
258
  description: input.description ?? "",
223
259
  status,
224
260
  tags: input.tags ?? [],
225
261
  goal: input.goal ?? null,
226
- needsHuman: input.needsHuman ?? false,
227
- blockedBy: [...new Set(input.blockedBy ?? [])].sort((a, b) => a - b),
262
+ blockedBy: sortKeys([...new Set(input.blockedBy ?? [])]),
228
263
  prs: input.prs ?? [],
229
264
  position: bottom + POSITION_GAP,
230
265
  createdAt: timestamp,
@@ -233,25 +268,25 @@ export class FileStore {
233
268
  };
234
269
  this.writeTicket(ticket);
235
270
  if (input.blocks !== undefined) {
236
- this.reconcileBlocks(number, [...all, ticket], input.blocks, timestamp);
271
+ this.reconcileBlocks(key, [...all, ticket], input.blocks, timestamp);
237
272
  }
238
- return this.get(number);
273
+ return this.get(key);
239
274
  }
240
- update(number, patch) {
275
+ update(key, patch) {
241
276
  const all = this.readAll();
242
- const ticket = all.find((t) => t.number === number);
277
+ const ticket = all.find((t) => t.key === key);
243
278
  if (!ticket) {
244
- this.assertNotArchived(number);
245
- throw new Error(`no such task: ${this.displayId(number)}`);
279
+ this.assertNotArchived(key);
280
+ throw new Error(`no such task: ${this.displayId(key)}`);
246
281
  }
247
282
  // Validate link targets before any file is written, so a bad target
248
283
  // rejects the whole patch — the transactional behavior the SQLite store
249
284
  // got for free.
250
285
  for (const target of [...(patch.blocks ?? []), ...(patch.blockedBy ?? [])]) {
251
- if (target === number) {
252
- 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)}`);
253
288
  }
254
- if (!all.some((t) => t.number === target)) {
289
+ if (!all.some((t) => t.key === target)) {
255
290
  this.assertNotArchived(target);
256
291
  throw new Error(`no such task: ${this.displayId(target)}`);
257
292
  }
@@ -259,14 +294,14 @@ export class FileStore {
259
294
  const timestamp = now();
260
295
  let linksChanged = false;
261
296
  if (patch.blocks !== undefined) {
262
- linksChanged = this.reconcileBlocks(number, all, patch.blocks, timestamp);
297
+ linksChanged = this.reconcileBlocks(key, all, patch.blocks, timestamp);
263
298
  }
264
299
  if (patch.blockedBy !== undefined) {
265
- const wanted = [...new Set(patch.blockedBy)].sort((a, b) => a - b);
300
+ const wanted = sortKeys([...new Set(patch.blockedBy)]);
266
301
  const current = ticket.doc.blockedBy;
267
302
  const touched = [
268
- ...current.filter((n) => !wanted.includes(n)),
269
- ...wanted.filter((n) => !current.includes(n)),
303
+ ...current.filter((k) => !wanted.includes(k)),
304
+ ...wanted.filter((k) => !current.includes(k)),
270
305
  ];
271
306
  if (touched.length > 0) {
272
307
  linksChanged = true;
@@ -274,8 +309,8 @@ export class FileStore {
274
309
  // The other end of every added or removed link gets its `updated`
275
310
  // bumped too — its derived `blocks` view just changed. A dangling
276
311
  // reference (hand-edit pointing at a deleted ticket) has no other end.
277
- for (const n of touched) {
278
- const other = all.find((t) => t.number === n);
312
+ for (const k of touched) {
313
+ const other = all.find((t) => t.key === k);
279
314
  if (!other)
280
315
  continue;
281
316
  other.doc.updatedAt = timestamp;
@@ -285,8 +320,8 @@ export class FileStore {
285
320
  }
286
321
  const doc = ticket.doc;
287
322
  let fieldsChanged = false;
288
- const set = (key, value) => {
289
- doc[key] = value;
323
+ const set = (field, value) => {
324
+ doc[field] = value;
290
325
  fieldsChanged = true;
291
326
  };
292
327
  if (patch.title !== undefined)
@@ -300,17 +335,27 @@ export class FileStore {
300
335
  this.assertGoalAssignable(patch.goal);
301
336
  set("goal", patch.goal);
302
337
  }
303
- if (patch.needsHuman !== undefined)
304
- set("needsHuman", patch.needsHuman);
305
338
  if (patch.prs !== undefined)
306
339
  set("prs", patch.prs);
307
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
+ }
308
353
  const previous = doc.status;
309
354
  set("status", patch.status);
310
355
  if (patch.position === undefined && patch.status !== previous) {
311
356
  // Moved columns without an explicit slot → land on top, where the
312
357
  // freshest movement is visible (Linear's behavior).
313
- 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);
314
359
  const top = column.length ? Math.min(...column.map((t) => t.doc.position)) : 0;
315
360
  set("position", top - POSITION_GAP);
316
361
  }
@@ -322,35 +367,35 @@ export class FileStore {
322
367
  if (fieldsChanged || linksChanged)
323
368
  doc.updatedAt = timestamp;
324
369
  this.writeTicket(ticket);
325
- return this.get(number);
370
+ return this.get(key);
326
371
  }
327
372
  /**
328
373
  * Make every other ticket's `blocked_by` agree with "this task blocks
329
374
  * exactly `targets`". Returns whether anything changed; every touched ticket
330
375
  * (and, via the caller, this one) gets its `updated` bumped.
331
376
  */
332
- reconcileBlocks(number, all, targets, timestamp) {
377
+ reconcileBlocks(key, all, targets, timestamp) {
333
378
  const wanted = new Set(targets);
334
- if (wanted.has(number)) {
335
- 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)}`);
336
381
  }
337
382
  let changed = false;
338
383
  for (const other of all) {
339
- if (other.number === number)
384
+ if (other.key === key)
340
385
  continue;
341
- const has = other.doc.blockedBy.includes(number);
342
- const should = wanted.has(other.number);
386
+ const has = other.doc.blockedBy.includes(key);
387
+ const should = wanted.has(other.key);
343
388
  if (has === should)
344
389
  continue;
345
390
  other.doc.blockedBy = should
346
- ? [...other.doc.blockedBy, number].sort((a, b) => a - b)
347
- : other.doc.blockedBy.filter((n) => n !== number);
391
+ ? sortKeys([...other.doc.blockedBy, key])
392
+ : other.doc.blockedBy.filter((k) => k !== key);
348
393
  other.doc.updatedAt = timestamp;
349
394
  this.writeTicket(other);
350
395
  changed = true;
351
396
  }
352
397
  if (changed) {
353
- const self = all.find((t) => t.number === number);
398
+ const self = all.find((t) => t.key === key);
354
399
  if (self) {
355
400
  self.doc.updatedAt = timestamp;
356
401
  this.writeTicket(self);
@@ -359,37 +404,37 @@ export class FileStore {
359
404
  return changed;
360
405
  }
361
406
  /** `task link A --blocks B` and friends — additive, unlike the patch form. */
362
- link(number, relation, target) {
363
- const task = this.get(number);
407
+ link(key, relation, target) {
408
+ const task = this.get(key);
364
409
  if (!task)
365
- throw new Error(`no such task: ${this.displayId(number)}`);
410
+ throw new Error(`no such task: ${this.displayId(key)}`);
366
411
  const current = relation === "blocks" ? task.blocks : task.blockedBy;
367
412
  const patch = relation === "blocks"
368
413
  ? { blocks: [...current, target] }
369
414
  : { blockedBy: [...current, target] };
370
- return this.update(number, patch);
415
+ return this.update(key, patch);
371
416
  }
372
- unlink(number, relation, target) {
373
- const task = this.get(number);
417
+ unlink(key, relation, target) {
418
+ const task = this.get(key);
374
419
  if (!task)
375
- throw new Error(`no such task: ${this.displayId(number)}`);
420
+ throw new Error(`no such task: ${this.displayId(key)}`);
376
421
  const current = relation === "blocks" ? task.blocks : task.blockedBy;
377
- const kept = current.filter((n) => n !== target);
422
+ const kept = current.filter((k) => k !== target);
378
423
  const patch = relation === "blocks" ? { blocks: kept } : { blockedBy: kept };
379
- return this.update(number, patch);
424
+ return this.update(key, patch);
380
425
  }
381
- delete(number) {
382
- if (!this.readOne(number)) {
383
- this.assertNotArchived(number);
384
- 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)}`);
385
430
  }
386
- rmSync(join(this.ticketsDir, String(number)), { recursive: true });
431
+ rmSync(join(this.ticketsDir, key), { recursive: true });
387
432
  // Links pointing at the deleted ticket go with it — the same cascade the
388
433
  // relation table had, so quiet on the other tickets' `updated`.
389
434
  for (const other of this.readAll()) {
390
- if (!other.doc.blockedBy.includes(number))
435
+ if (!other.doc.blockedBy.includes(key))
391
436
  continue;
392
- other.doc.blockedBy = other.doc.blockedBy.filter((n) => n !== number);
437
+ other.doc.blockedBy = other.doc.blockedBy.filter((k) => k !== key);
393
438
  this.writeTicket(other);
394
439
  }
395
440
  }
@@ -398,36 +443,36 @@ export class FileStore {
398
443
  * Move a finished ticket's whole directory to `.task/archive/` — one rename,
399
444
  * which git records as a move, so history follows the ticket. Archived
400
445
  * tickets leave every hot path (`list`, the board, link derivation) but stay
401
- * readable via `get`/`comments` and keep their number reserved forever.
446
+ * readable via `get`/`comments` and keep their key reserved forever.
402
447
  * Links other tickets hold on this one are left in place: they're
403
448
  * dangling-tolerant everywhere, and unarchiving puts them back in force.
404
449
  */
405
- archive(number) {
406
- const ticket = this.readOne(number);
450
+ archive(key) {
451
+ const ticket = this.readOne(key);
407
452
  if (!ticket) {
408
- if (this.isArchived(number)) {
409
- throw new Error(`${this.displayId(number)} is already archived`);
453
+ if (this.isArchived(key)) {
454
+ throw new Error(`${this.displayId(key)} is already archived`);
410
455
  }
411
- throw new Error(`no such task: ${this.displayId(number)}`);
456
+ throw new Error(`no such task: ${this.displayId(key)}`);
412
457
  }
413
458
  if (ticket.doc.status !== "done" && ticket.doc.status !== "canceled") {
414
- 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`);
415
460
  }
416
461
  mkdirSync(this.archiveDir, { recursive: true });
417
- renameSync(join(this.ticketsDir, String(number)), join(this.archiveDir, String(number)));
418
- return this.get(number);
462
+ renameSync(join(this.ticketsDir, key), join(this.archiveDir, key));
463
+ return this.get(key);
419
464
  }
420
- unarchive(number) {
421
- if (!this.isArchived(number)) {
422
- throw new Error(this.readOne(number)
423
- ? `${this.displayId(number)} isn't archived`
424
- : `no such task: ${this.displayId(number)}`);
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)}`);
425
470
  }
426
471
  // A checkout where everything is archived has no tickets/ at all — git
427
472
  // doesn't keep empty directories.
428
473
  mkdirSync(this.ticketsDir, { recursive: true });
429
- renameSync(join(this.archiveDir, String(number)), join(this.ticketsDir, String(number)));
430
- return this.get(number);
474
+ renameSync(join(this.archiveDir, key), join(this.ticketsDir, key));
475
+ return this.get(key);
431
476
  }
432
477
  // ── Goals ──────────────────────────────────────────────────────────────────
433
478
  goalPath(slug, archived) {
@@ -546,7 +591,7 @@ export class FileStore {
546
591
  }
547
592
  const open = this.readAll().filter((t) => t.doc.goal === slug && t.doc.status !== "done" && t.doc.status !== "canceled");
548
593
  if (open.length > 0) {
549
- const ids = open.map((t) => this.displayId(t.number)).join(", ");
594
+ const ids = open.map((t) => this.displayId(t.key)).join(", ");
550
595
  throw new Error(`goal "${slug}" still has open tasks (${ids}) — finish or reassign them first`);
551
596
  }
552
597
  mkdirSync(this.goalsArchiveDir, { recursive: true });
@@ -580,7 +625,7 @@ export class FileStore {
580
625
  }
581
626
  const referencing = this.readAll().filter((t) => t.doc.goal === slug);
582
627
  if (referencing.length > 0) {
583
- const ids = referencing.map((t) => this.displayId(t.number)).join(", ");
628
+ const ids = referencing.map((t) => this.displayId(t.key)).join(", ");
584
629
  throw new Error(`goal "${slug}" is referenced by ${ids} — clear those with \`task update <id> --goal ""\` first`);
585
630
  }
586
631
  rmSync(path);
@@ -591,8 +636,8 @@ export class FileStore {
591
636
  * the same ticket produce two files and merge without a conflict — which is
592
637
  * why they aren't lines inside `ticket.md`.
593
638
  */
594
- comments(number) {
595
- const dir = this.commentsPath(number);
639
+ comments(key) {
640
+ const dir = this.commentsPath(key);
596
641
  if (!existsSync(dir))
597
642
  return [];
598
643
  const comments = [];
@@ -603,7 +648,7 @@ export class FileStore {
603
648
  const doc = parseComment(readFileSync(path, "utf8"), path);
604
649
  comments.push({
605
650
  id: entry.slice(0, -3),
606
- taskId: this.displayId(number),
651
+ taskId: this.displayId(key),
607
652
  author: doc.author,
608
653
  body: doc.body,
609
654
  createdAt: doc.createdAt,
@@ -612,44 +657,166 @@ export class FileStore {
612
657
  comments.sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id));
613
658
  return comments;
614
659
  }
615
- addComment(number, body, author = "") {
616
- if (!this.readOne(number)) {
617
- this.assertNotArchived(number);
618
- 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)}`);
619
664
  }
620
665
  const createdAt = now();
621
- const dir = this.commentsPath(number);
666
+ const dir = this.commentsPath(key);
622
667
  mkdirSync(dir, { recursive: true });
623
668
  const stem = commentStem(createdAt, author);
624
669
  let id = stem;
625
670
  for (let n = 2; existsSync(join(dir, `${id}.md`)); n++)
626
671
  id = `${stem}-${n}`;
627
672
  writeAtomic(join(dir, `${id}.md`), serializeComment({ author, createdAt, body }));
628
- return { id, taskId: this.displayId(number), author, body, createdAt };
673
+ return { id, taskId: this.displayId(key), author, body, createdAt };
629
674
  }
630
- deleteComment(number, commentId) {
675
+ deleteComment(key, commentId) {
631
676
  // Resolve the id against the directory listing rather than building a path
632
677
  // from it — the id arrived over HTTP and must not be able to point
633
678
  // anywhere but at an actual comment of this task.
634
- const existing = this.comments(number).find((c) => c.id === commentId);
679
+ const existing = this.comments(key).find((c) => c.id === commentId);
635
680
  if (!existing) {
636
- throw new Error(`no such comment on ${this.displayId(number)}: ${commentId}`);
681
+ throw new Error(`no such comment on ${this.displayId(key)}: ${commentId}`);
637
682
  }
638
- rmSync(join(this.commentsPath(number), `${commentId}.md`));
683
+ rmSync(join(this.commentsPath(key), `${commentId}.md`));
639
684
  return existing;
640
685
  }
641
686
  commentCounts() {
642
687
  const counts = new Map();
643
688
  for (const ticket of this.readAll()) {
644
- const dir = this.commentsPath(ticket.number);
689
+ const dir = this.commentsPath(ticket.key);
645
690
  if (!existsSync(dir))
646
691
  continue;
647
692
  const count = readdirSync(dir).filter((f) => f.endsWith(".md") && !f.startsWith(".")).length;
648
693
  if (count > 0)
649
- counts.set(ticket.number, count);
694
+ counts.set(ticket.key, count);
650
695
  }
651
696
  return counts;
652
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
+ }
816
+ }
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);
653
820
  }
654
821
  // ── Opening and initializing ─────────────────────────────────────────────────
655
822
  /** The database a pre-0.6 board committed. Never read anymore, only ignored. */
@@ -677,6 +844,17 @@ export function openBoard(root) {
677
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");
678
845
  }
679
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
+ }
680
858
  return new FileStore(root);
681
859
  }
682
860
  /**