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