@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.
- package/README.md +65 -22
- package/dist/asks.test.d.ts +17 -0
- package/dist/asks.test.d.ts.map +1 -0
- package/dist/asks.test.js +278 -0
- package/dist/asks.test.js.map +1 -0
- package/dist/check.d.ts +3 -2
- package/dist/check.d.ts.map +1 -1
- package/dist/check.js +99 -60
- package/dist/check.js.map +1 -1
- package/dist/check.test.js +99 -60
- package/dist/check.test.js.map +1 -1
- package/dist/claim-io.d.ts +73 -0
- package/dist/claim-io.d.ts.map +1 -0
- package/dist/claim-io.js +344 -0
- package/dist/claim-io.js.map +1 -0
- package/dist/claim.d.ts +21 -22
- package/dist/claim.d.ts.map +1 -1
- package/dist/claim.js +58 -83
- package/dist/claim.js.map +1 -1
- package/dist/claim.test.d.ts +2 -2
- package/dist/claim.test.js +111 -94
- package/dist/claim.test.js.map +1 -1
- package/dist/cli.js +241 -64
- package/dist/cli.js.map +1 -1
- package/dist/file-store.d.ts +70 -24
- package/dist/file-store.d.ts.map +1 -1
- package/dist/file-store.js +319 -141
- package/dist/file-store.js.map +1 -1
- package/dist/git-serve.d.ts +183 -0
- package/dist/git-serve.d.ts.map +1 -0
- package/dist/git-serve.js +503 -0
- package/dist/git-serve.js.map +1 -0
- package/dist/git-serve.test.d.ts +16 -0
- package/dist/git-serve.test.d.ts.map +1 -0
- package/dist/git-serve.test.js +183 -0
- package/dist/git-serve.test.js.map +1 -0
- package/dist/git.d.ts +65 -0
- package/dist/git.d.ts.map +1 -0
- package/dist/git.js +114 -0
- package/dist/git.js.map +1 -0
- package/dist/id.d.ts +39 -0
- package/dist/id.d.ts.map +1 -0
- package/dist/id.js +67 -0
- package/dist/id.js.map +1 -0
- package/dist/inbox.d.ts +41 -0
- package/dist/inbox.d.ts.map +1 -0
- package/dist/inbox.js +56 -0
- package/dist/inbox.js.map +1 -0
- package/dist/overview.d.ts +9 -0
- package/dist/overview.d.ts.map +1 -1
- package/dist/overview.js +19 -11
- package/dist/overview.js.map +1 -1
- package/dist/overview.test.js +6 -5
- package/dist/overview.test.js.map +1 -1
- package/dist/promote.test.d.ts +3 -2
- package/dist/promote.test.d.ts.map +1 -1
- package/dist/promote.test.js +29 -31
- package/dist/promote.test.js.map +1 -1
- package/dist/publish.d.ts +2 -17
- package/dist/publish.d.ts.map +1 -1
- package/dist/publish.js +4 -49
- package/dist/publish.js.map +1 -1
- package/dist/search.d.ts +3 -1
- package/dist/search.d.ts.map +1 -1
- package/dist/search.js +15 -2
- package/dist/search.js.map +1 -1
- package/dist/search.test.js +9 -9
- package/dist/search.test.js.map +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +218 -21
- package/dist/server.js.map +1 -1
- package/dist/store.d.ts +28 -14
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js.map +1 -1
- package/dist/store.test.js +88 -75
- package/dist/store.test.js.map +1 -1
- package/dist/ticket-doc.d.ts +26 -10
- package/dist/ticket-doc.d.ts.map +1 -1
- package/dist/ticket-doc.js +71 -27
- package/dist/ticket-doc.js.map +1 -1
- package/dist/types.d.ts +51 -17
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +1 -1
- package/skill/SKILL.md +70 -23
- package/src/asks.test.ts +355 -0
- package/src/check.test.ts +129 -72
- package/src/check.ts +101 -58
- package/src/claim-io.ts +401 -0
- package/src/claim.test.ts +141 -99
- package/src/claim.ts +69 -96
- package/src/cli.ts +251 -63
- package/src/file-store.ts +357 -145
- package/src/git-serve.test.ts +240 -0
- package/src/git-serve.ts +595 -0
- package/src/git.ts +141 -0
- package/src/id.ts +68 -0
- package/src/inbox.ts +77 -0
- package/src/overview.test.ts +6 -5
- package/src/overview.ts +25 -10
- package/src/promote.test.ts +40 -28
- package/src/publish.ts +6 -53
- package/src/search.test.ts +11 -11
- package/src/search.ts +17 -3
- package/src/server.ts +221 -19
- package/src/store.test.ts +95 -77
- package/src/store.ts +28 -13
- package/src/ticket-doc.ts +93 -30
- package/src/types.ts +52 -17
- package/ui/dist/assets/index-BjsorZOU.js +229 -0
- package/ui/dist/assets/index-CoKCUYic.css +1 -0
- package/ui/dist/index.html +2 -2
- package/ui/dist/assets/index-BJmOsOdR.js +0 -229
- package/ui/dist/assets/index-BoqQlqSU.css +0 -1
package/dist/file-store.js
CHANGED
|
@@ -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/`,
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
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(
|
|
57
|
-
return `${this.config.prefix}-${
|
|
68
|
+
displayId(key) {
|
|
69
|
+
return `${this.config.prefix}-${key}`;
|
|
58
70
|
}
|
|
59
|
-
/**
|
|
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]+-)?(
|
|
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
|
-
|
|
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(
|
|
68
|
-
return join(this.ticketsDir,
|
|
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(
|
|
75
|
-
const home = existsSync(join(this.archiveDir,
|
|
76
|
-
|
|
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() ||
|
|
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({
|
|
115
|
+
tickets.push({ key: entry.name, doc: parseTicket(readFileSync(path, "utf8"), path) });
|
|
91
116
|
}
|
|
92
|
-
tickets.sort(
|
|
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(
|
|
100
|
-
const path = this.ticketPath(
|
|
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 {
|
|
128
|
+
return { key, doc: parseTicket(readFileSync(path, "utf8"), path) };
|
|
104
129
|
}
|
|
105
|
-
/**
|
|
106
|
-
|
|
107
|
-
if (!existsSync(
|
|
130
|
+
/** Directory names that are well-formed keys, without parsing anything. */
|
|
131
|
+
keysIn(dir) {
|
|
132
|
+
if (!existsSync(dir))
|
|
108
133
|
return [];
|
|
109
|
-
return readdirSync(
|
|
110
|
-
|
|
111
|
-
|
|
134
|
+
return readdirSync(dir).filter(isTicketKey);
|
|
135
|
+
}
|
|
136
|
+
liveKeys() {
|
|
137
|
+
return this.keysIn(this.ticketsDir);
|
|
112
138
|
}
|
|
113
|
-
|
|
114
|
-
|
|
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(
|
|
118
|
-
if (this.isArchived(
|
|
119
|
-
const id = this.displayId(
|
|
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.
|
|
125
|
-
writeAtomic(this.ticketPath(ticket.
|
|
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.
|
|
137
|
-
|
|
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:
|
|
173
|
+
needsHuman: asks.some((a) => !a.resolvedAt),
|
|
174
|
+
asks,
|
|
144
175
|
blocks,
|
|
145
|
-
blockedBy: [...ticket.doc.blockedBy]
|
|
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
|
-
|
|
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
|
|
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
|
|
216
|
+
.sort((a, b) => a.position - b.position || byCreationTask(a, b));
|
|
184
217
|
}
|
|
185
|
-
get(
|
|
218
|
+
get(key) {
|
|
186
219
|
const all = this.readAll();
|
|
187
|
-
const ticket = all.find((t) => t.
|
|
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
|
-
|
|
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 = {
|
|
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
|
-
//
|
|
201
|
-
//
|
|
202
|
-
|
|
203
|
-
const
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
271
|
+
this.reconcileBlocks(key, [...all, ticket], input.blocks, timestamp);
|
|
237
272
|
}
|
|
238
|
-
return this.get(
|
|
273
|
+
return this.get(key);
|
|
239
274
|
}
|
|
240
|
-
update(
|
|
275
|
+
update(key, patch) {
|
|
241
276
|
const all = this.readAll();
|
|
242
|
-
const ticket = all.find((t) => t.
|
|
277
|
+
const ticket = all.find((t) => t.key === key);
|
|
243
278
|
if (!ticket) {
|
|
244
|
-
this.assertNotArchived(
|
|
245
|
-
throw new Error(`no such task: ${this.displayId(
|
|
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 ===
|
|
252
|
-
throw new Error(`a task can't block itself: ${this.displayId(
|
|
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.
|
|
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(
|
|
297
|
+
linksChanged = this.reconcileBlocks(key, all, patch.blocks, timestamp);
|
|
263
298
|
}
|
|
264
299
|
if (patch.blockedBy !== undefined) {
|
|
265
|
-
const wanted = [...new Set(patch.blockedBy)]
|
|
300
|
+
const wanted = sortKeys([...new Set(patch.blockedBy)]);
|
|
266
301
|
const current = ticket.doc.blockedBy;
|
|
267
302
|
const touched = [
|
|
268
|
-
...current.filter((
|
|
269
|
-
...wanted.filter((
|
|
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
|
|
278
|
-
const other = all.find((t) => t.
|
|
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 = (
|
|
289
|
-
doc[
|
|
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.
|
|
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(
|
|
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(
|
|
377
|
+
reconcileBlocks(key, all, targets, timestamp) {
|
|
333
378
|
const wanted = new Set(targets);
|
|
334
|
-
if (wanted.has(
|
|
335
|
-
throw new Error(`a task can't block itself: ${this.displayId(
|
|
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.
|
|
384
|
+
if (other.key === key)
|
|
340
385
|
continue;
|
|
341
|
-
const has = other.doc.blockedBy.includes(
|
|
342
|
-
const should = wanted.has(other.
|
|
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,
|
|
347
|
-
: other.doc.blockedBy.filter((
|
|
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.
|
|
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(
|
|
363
|
-
const task = this.get(
|
|
407
|
+
link(key, relation, target) {
|
|
408
|
+
const task = this.get(key);
|
|
364
409
|
if (!task)
|
|
365
|
-
throw new Error(`no such task: ${this.displayId(
|
|
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(
|
|
415
|
+
return this.update(key, patch);
|
|
371
416
|
}
|
|
372
|
-
unlink(
|
|
373
|
-
const task = this.get(
|
|
417
|
+
unlink(key, relation, target) {
|
|
418
|
+
const task = this.get(key);
|
|
374
419
|
if (!task)
|
|
375
|
-
throw new Error(`no such task: ${this.displayId(
|
|
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((
|
|
422
|
+
const kept = current.filter((k) => k !== target);
|
|
378
423
|
const patch = relation === "blocks" ? { blocks: kept } : { blockedBy: kept };
|
|
379
|
-
return this.update(
|
|
424
|
+
return this.update(key, patch);
|
|
380
425
|
}
|
|
381
|
-
delete(
|
|
382
|
-
if (!this.readOne(
|
|
383
|
-
this.assertNotArchived(
|
|
384
|
-
throw new Error(`no such task: ${this.displayId(
|
|
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,
|
|
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(
|
|
435
|
+
if (!other.doc.blockedBy.includes(key))
|
|
391
436
|
continue;
|
|
392
|
-
other.doc.blockedBy = other.doc.blockedBy.filter((
|
|
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
|
|
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(
|
|
406
|
-
const ticket = this.readOne(
|
|
450
|
+
archive(key) {
|
|
451
|
+
const ticket = this.readOne(key);
|
|
407
452
|
if (!ticket) {
|
|
408
|
-
if (this.isArchived(
|
|
409
|
-
throw new Error(`${this.displayId(
|
|
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(
|
|
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(
|
|
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,
|
|
418
|
-
return this.get(
|
|
462
|
+
renameSync(join(this.ticketsDir, key), join(this.archiveDir, key));
|
|
463
|
+
return this.get(key);
|
|
419
464
|
}
|
|
420
|
-
unarchive(
|
|
421
|
-
if (!this.isArchived(
|
|
422
|
-
throw new Error(this.readOne(
|
|
423
|
-
? `${this.displayId(
|
|
424
|
-
: `no such task: ${this.displayId(
|
|
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,
|
|
430
|
-
return this.get(
|
|
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.
|
|
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.
|
|
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(
|
|
595
|
-
const dir = this.commentsPath(
|
|
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(
|
|
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(
|
|
616
|
-
if (!this.readOne(
|
|
617
|
-
this.assertNotArchived(
|
|
618
|
-
throw new Error(`no such task: ${this.displayId(
|
|
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(
|
|
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(
|
|
673
|
+
return { id, taskId: this.displayId(key), author, body, createdAt };
|
|
629
674
|
}
|
|
630
|
-
deleteComment(
|
|
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(
|
|
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(
|
|
681
|
+
throw new Error(`no such comment on ${this.displayId(key)}: ${commentId}`);
|
|
637
682
|
}
|
|
638
|
-
rmSync(join(this.commentsPath(
|
|
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.
|
|
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.
|
|
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
|
/**
|