@nickmeriano/task 0.5.0 → 0.6.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 +63 -8
- package/dist/cli.js +220 -17
- package/dist/cli.js.map +1 -1
- package/dist/file-store.d.ts +113 -0
- package/dist/file-store.d.ts.map +1 -0
- package/dist/file-store.js +604 -0
- package/dist/file-store.js.map +1 -0
- package/dist/index.d.ts +6 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +11 -7
- package/dist/server.js.map +1 -1
- package/dist/store.d.ts +55 -15
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +63 -29
- package/dist/store.js.map +1 -1
- package/dist/store.test.d.ts +12 -0
- package/dist/store.test.d.ts.map +1 -0
- package/dist/store.test.js +252 -0
- package/dist/store.test.js.map +1 -0
- package/dist/ticket-doc.d.ts +57 -0
- package/dist/ticket-doc.d.ts.map +1 -0
- package/dist/ticket-doc.js +197 -0
- package/dist/ticket-doc.js.map +1 -0
- package/dist/types.d.ts +14 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +4 -4
- package/skill/SKILL.md +23 -8
- package/src/cli.ts +236 -20
- package/src/file-store.ts +693 -0
- package/src/index.ts +30 -4
- package/src/server.ts +15 -11
- package/src/store.test.ts +305 -0
- package/src/store.ts +99 -40
- package/src/ticket-doc.ts +226 -0
- package/src/types.ts +14 -1
- package/ui/dist/assets/index-CXW8uT5f.css +1 -0
- package/ui/dist/assets/{index-D_qmmh3D.js → index-oJzomUDL.js} +58 -58
- package/ui/dist/index.html +2 -2
- package/ui/dist/assets/index-Da14ye1f.css +0 -1
package/src/index.ts
CHANGED
|
@@ -1,6 +1,32 @@
|
|
|
1
1
|
// Programmatic access to the same store the CLI and `task serve` use — for
|
|
2
2
|
// scripts that want to read or write .task/ without shelling out.
|
|
3
|
-
export {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
export {
|
|
4
|
+
TaskStore,
|
|
5
|
+
boardConfig,
|
|
6
|
+
derivePrefix,
|
|
7
|
+
findBoards,
|
|
8
|
+
findBoardsByPrefix,
|
|
9
|
+
findRoot,
|
|
10
|
+
findScopeRoot,
|
|
11
|
+
type BoardRef,
|
|
12
|
+
type Store,
|
|
13
|
+
} from "./store.ts"
|
|
14
|
+
export {
|
|
15
|
+
ARCHIVE_DIR,
|
|
16
|
+
FileStore,
|
|
17
|
+
TICKETS_DIR,
|
|
18
|
+
initProject,
|
|
19
|
+
migrateBoard,
|
|
20
|
+
openBoard,
|
|
21
|
+
} from "./file-store.ts"
|
|
22
|
+
export {
|
|
23
|
+
parseTicket,
|
|
24
|
+
serializeTicket,
|
|
25
|
+
parseComment,
|
|
26
|
+
serializeComment,
|
|
27
|
+
type TicketDoc,
|
|
28
|
+
type CommentDoc,
|
|
29
|
+
} from "./ticket-doc.ts"
|
|
30
|
+
export { createTaskServer } from "./server.ts"
|
|
31
|
+
export { resolveAuthor, ANONYMOUS, type Author, type AuthorSource } from "./author.ts"
|
|
32
|
+
export * from "./types.ts"
|
package/src/server.ts
CHANGED
|
@@ -2,9 +2,10 @@ import { createServer, type IncomingMessage, type Server, type ServerResponse }
|
|
|
2
2
|
import { watch, existsSync, readFileSync, statSync, type FSWatcher } from "node:fs"
|
|
3
3
|
import { extname, join, normalize } from "node:path"
|
|
4
4
|
import { fileURLToPath } from "node:url"
|
|
5
|
-
import { resolveAuthor } from "./author.
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
5
|
+
import { resolveAuthor } from "./author.ts"
|
|
6
|
+
import { openBoard } from "./file-store.ts"
|
|
7
|
+
import { findBoards, type BoardRef, type Store } from "./store.ts"
|
|
8
|
+
import { STATUSES, isStatus, type TaskPatch } from "./types.ts"
|
|
8
9
|
|
|
9
10
|
/** The prebuilt SPA, shipped inside the package next to dist/. */
|
|
10
11
|
const UI_DIR = fileURLToPath(new URL("../ui/dist", import.meta.url))
|
|
@@ -105,15 +106,18 @@ export function createTaskServer(serveRoot: string): Server {
|
|
|
105
106
|
}
|
|
106
107
|
|
|
107
108
|
interface Board {
|
|
108
|
-
store:
|
|
109
|
+
store: Store
|
|
109
110
|
watcher: FSWatcher
|
|
110
111
|
}
|
|
111
112
|
const boards = new Map<string, Board>()
|
|
112
113
|
let ordered: string[] = []
|
|
113
114
|
|
|
114
|
-
const
|
|
115
|
-
const store =
|
|
116
|
-
|
|
115
|
+
const attachBoard = (ref: BoardRef): void => {
|
|
116
|
+
const store = openBoard(ref.root)
|
|
117
|
+
// Recursive: text-canonical state is a tree (tickets/<n>/comments/…), and
|
|
118
|
+
// a write anywhere in it is a board change. Node supports this on Linux,
|
|
119
|
+
// macOS and Windows for the versions this package requires.
|
|
120
|
+
const watcher = watch(store.taskDir, { recursive: true }, () => noteChange(ref.id))
|
|
117
121
|
// A deleted board dir can make fs.watch emit; the next rescan cleans up.
|
|
118
122
|
watcher.on("error", () => {})
|
|
119
123
|
boards.set(ref.id, { store, watcher })
|
|
@@ -130,7 +134,7 @@ export function createTaskServer(serveRoot: string): Server {
|
|
|
130
134
|
boards.delete(id)
|
|
131
135
|
}
|
|
132
136
|
}
|
|
133
|
-
for (const ref of found) if (!boards.has(ref.id))
|
|
137
|
+
for (const ref of found) if (!boards.has(ref.id)) attachBoard(ref)
|
|
134
138
|
ordered = found.map((ref) => ref.id)
|
|
135
139
|
return found
|
|
136
140
|
}
|
|
@@ -204,7 +208,7 @@ export function createTaskServer(serveRoot: string): Server {
|
|
|
204
208
|
}
|
|
205
209
|
|
|
206
210
|
function routeApi(
|
|
207
|
-
store:
|
|
211
|
+
store: Store,
|
|
208
212
|
req: IncomingMessage,
|
|
209
213
|
res: ServerResponse,
|
|
210
214
|
path: string,
|
|
@@ -242,11 +246,11 @@ export function createTaskServer(serveRoot: string): Server {
|
|
|
242
246
|
return
|
|
243
247
|
}
|
|
244
248
|
|
|
245
|
-
const taskMatch = /^\/api\/tasks\/([^/]+)(\/comments(?:\/(
|
|
249
|
+
const taskMatch = /^\/api\/tasks\/([^/]+)(\/comments(?:\/([^/]+))?)?$/.exec(path)
|
|
246
250
|
if (taskMatch) {
|
|
247
251
|
const number = store.parseId(decodeURIComponent(taskMatch[1]))
|
|
248
252
|
if (taskMatch[3] && req.method === "DELETE") {
|
|
249
|
-
const commentId =
|
|
253
|
+
const commentId = decodeURIComponent(taskMatch[3])
|
|
250
254
|
const existing = store.comments(number).find((c) => c.id === commentId)
|
|
251
255
|
if (!existing) {
|
|
252
256
|
json(res, 404, { error: `no such comment: ${commentId}` })
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The properties TAS-12 exists for, checked from the outside:
|
|
3
|
+
*
|
|
4
|
+
* 1. The text format round-trips — parse(serialize(x)) loses nothing.
|
|
5
|
+
* 2. `FileStore` behaves like the store always has (positions, links,
|
|
6
|
+
* comments, cascade on delete).
|
|
7
|
+
* 3. `task migrate` reproduces a legacy SQLite board in files, exactly.
|
|
8
|
+
* 4. Concurrent-writer shapes merge by construction: a comment is a new file,
|
|
9
|
+
* a claim is a one-line status diff.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import assert from "node:assert/strict"
|
|
13
|
+
import { test } from "node:test"
|
|
14
|
+
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"
|
|
15
|
+
import { tmpdir } from "node:os"
|
|
16
|
+
import { join } from "node:path"
|
|
17
|
+
import { FileStore, initProject, migrateBoard, openBoard } from "./file-store.ts"
|
|
18
|
+
import { parseTicket, serializeTicket, type TicketDoc } from "./ticket-doc.ts"
|
|
19
|
+
import { TaskStore, findBoardsByPrefix, findScopeRoot } from "./store.ts"
|
|
20
|
+
|
|
21
|
+
function tempRoot(): string {
|
|
22
|
+
const dir = mkdtempSync(join(tmpdir(), "task-test-"))
|
|
23
|
+
process.on("exit", () => rmSync(dir, { recursive: true, force: true }))
|
|
24
|
+
return dir
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
test("ticket documents round-trip, including the awkward bodies", () => {
|
|
28
|
+
const docs: TicketDoc[] = [
|
|
29
|
+
{
|
|
30
|
+
title: "Plain ticket",
|
|
31
|
+
description: "One line.",
|
|
32
|
+
status: "todo",
|
|
33
|
+
tags: [],
|
|
34
|
+
milestone: null,
|
|
35
|
+
needsHuman: false,
|
|
36
|
+
blockedBy: [],
|
|
37
|
+
prs: [],
|
|
38
|
+
position: 1024,
|
|
39
|
+
createdAt: "2026-08-15T16:00:33.758Z",
|
|
40
|
+
updatedAt: "2026-08-15T16:00:33.758Z",
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
title: 'Tricky: "quotes", colons: yes — and unicode ✓',
|
|
44
|
+
// A description that opens with a heading, contains a frontmatter-like
|
|
45
|
+
// fence, and ends without punctuation.
|
|
46
|
+
description: "# Not the title\n\n---\n\nstatus: not-a-field\n\n```md\n---\n```",
|
|
47
|
+
status: "in_progress",
|
|
48
|
+
tags: ["storage", "architecture"],
|
|
49
|
+
milestone: "v2 storage",
|
|
50
|
+
needsHuman: true,
|
|
51
|
+
blockedBy: [3, 7],
|
|
52
|
+
prs: ["https://github.com/x/y/pull/1"],
|
|
53
|
+
position: -512.5,
|
|
54
|
+
createdAt: "2026-08-15T16:00:33.758Z",
|
|
55
|
+
updatedAt: "2026-08-15T17:12:00.001Z",
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
title: "Empty description",
|
|
59
|
+
description: "",
|
|
60
|
+
status: "backlog",
|
|
61
|
+
tags: [],
|
|
62
|
+
milestone: null,
|
|
63
|
+
needsHuman: false,
|
|
64
|
+
blockedBy: [],
|
|
65
|
+
prs: [],
|
|
66
|
+
position: 0,
|
|
67
|
+
createdAt: "2026-08-15T16:00:33.758Z",
|
|
68
|
+
updatedAt: "2026-08-15T16:00:33.758Z",
|
|
69
|
+
},
|
|
70
|
+
]
|
|
71
|
+
for (const doc of docs) {
|
|
72
|
+
assert.deepEqual(parseTicket(serializeTicket(doc), "test"), doc, doc.title)
|
|
73
|
+
}
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
test("hand-edits are forgiven where they're unambiguous", () => {
|
|
77
|
+
const text = [
|
|
78
|
+
"---",
|
|
79
|
+
"status: todo",
|
|
80
|
+
"milestone: launch", // unquoted string — the YAML instinct
|
|
81
|
+
"position: 8",
|
|
82
|
+
"created: 2026-08-15T16:00:33.758Z",
|
|
83
|
+
"updated: 2026-08-15T16:00:33.758Z",
|
|
84
|
+
"---",
|
|
85
|
+
"",
|
|
86
|
+
"# Hand-written ticket",
|
|
87
|
+
"",
|
|
88
|
+
"Body.",
|
|
89
|
+
].join("\n")
|
|
90
|
+
const doc = parseTicket(text, "test")
|
|
91
|
+
assert.equal(doc.milestone, "launch")
|
|
92
|
+
assert.equal(doc.position, 8)
|
|
93
|
+
|
|
94
|
+
assert.throws(
|
|
95
|
+
() => parseTicket(text.replace("status: todo", "status: todoo"), "here"),
|
|
96
|
+
/here.*invalid status/,
|
|
97
|
+
)
|
|
98
|
+
assert.throws(() => parseTicket("no frontmatter", "here"), /here.*frontmatter/)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
test("FileStore keeps the store's contract", () => {
|
|
102
|
+
const root = tempRoot()
|
|
103
|
+
const store = initProject(root, { name: "sample project" })
|
|
104
|
+
assert.equal(store.config.prefix, "SAM")
|
|
105
|
+
assert.ok(store instanceof FileStore)
|
|
106
|
+
|
|
107
|
+
const a = store.create({ title: "First", description: "alpha", tags: ["api"] })
|
|
108
|
+
const b = store.create({ title: "Second", status: "todo" })
|
|
109
|
+
const c = store.create({ title: "Third", status: "backlog" })
|
|
110
|
+
assert.deepEqual([a.number, b.number, c.number], [1, 2, 3])
|
|
111
|
+
// New tasks land at the bottom of their column.
|
|
112
|
+
assert.ok(b.position > a.position)
|
|
113
|
+
|
|
114
|
+
// Status move without an explicit slot lands on top of the new column.
|
|
115
|
+
const moved = store.update(c.number, { status: "todo" })
|
|
116
|
+
assert.ok(moved.position < a.position)
|
|
117
|
+
assert.equal(store.list({ statuses: ["todo"] })[0].number, c.number)
|
|
118
|
+
|
|
119
|
+
// One relation, two views, stored on one side only.
|
|
120
|
+
store.link(a.number, "blocked_by", b.number)
|
|
121
|
+
assert.deepEqual(store.get(b.number)!.blocks, [a.number])
|
|
122
|
+
const onDisk = readFileSync(join(root, ".task", "tickets", "1", "ticket.md"), "utf8")
|
|
123
|
+
assert.match(onDisk, /blocked_by: \[2\]/)
|
|
124
|
+
assert.doesNotMatch(
|
|
125
|
+
readFileSync(join(root, ".task", "tickets", "2", "ticket.md"), "utf8"),
|
|
126
|
+
/blocked_by/,
|
|
127
|
+
)
|
|
128
|
+
store.unlink(b.number, "blocks", a.number)
|
|
129
|
+
assert.deepEqual(store.get(a.number)!.blockedBy, [])
|
|
130
|
+
|
|
131
|
+
assert.throws(() => store.link(a.number, "blocks", a.number), /can't block itself/)
|
|
132
|
+
assert.throws(() => store.update(a.number, { blockedBy: [99] }), /no such task: SAM-99/)
|
|
133
|
+
|
|
134
|
+
// Comments: one file each, stable ids, author preserved.
|
|
135
|
+
const comment = store.addComment(a.number, "first!\n\nwith **markdown**", "claude")
|
|
136
|
+
assert.match(comment.id, /^\d{4}-\d{2}-\d{2}T\d{6}-claude$/)
|
|
137
|
+
assert.equal(store.comments(a.number)[0].body, "first!\n\nwith **markdown**")
|
|
138
|
+
assert.equal(store.commentCounts().get(a.number), 1)
|
|
139
|
+
const second = store.addComment(a.number, "again", "claude")
|
|
140
|
+
assert.notEqual(second.id, comment.id)
|
|
141
|
+
store.deleteComment(a.number, second.id)
|
|
142
|
+
assert.equal(store.comments(a.number).length, 1)
|
|
143
|
+
assert.throws(() => store.deleteComment(a.number, "../../escape"), /no such comment/)
|
|
144
|
+
|
|
145
|
+
// Delete cascades the relation away, like the FK did.
|
|
146
|
+
store.link(b.number, "blocked_by", a.number)
|
|
147
|
+
store.delete(a.number)
|
|
148
|
+
assert.equal(store.get(a.number), null)
|
|
149
|
+
assert.deepEqual(store.get(b.number)!.blockedBy, [])
|
|
150
|
+
|
|
151
|
+
// Numbers never move backwards past an existing ticket.
|
|
152
|
+
const d = store.create({ title: "Fourth" })
|
|
153
|
+
assert.equal(d.number, 4)
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
test("openBoard picks the backend by what's on disk", () => {
|
|
157
|
+
const fileRoot = tempRoot()
|
|
158
|
+
initProject(fileRoot, { name: "files" })
|
|
159
|
+
assert.ok(openBoard(fileRoot) instanceof FileStore)
|
|
160
|
+
|
|
161
|
+
const legacyRoot = tempRoot()
|
|
162
|
+
mkdirSync(join(legacyRoot, ".task"))
|
|
163
|
+
writeFileSync(
|
|
164
|
+
join(legacyRoot, ".task", "config.json"),
|
|
165
|
+
`${JSON.stringify({ name: "legacy", prefix: "LEG", version: 1 }, null, 2)}\n`,
|
|
166
|
+
)
|
|
167
|
+
const legacy = openBoard(legacyRoot)
|
|
168
|
+
assert.ok(legacy instanceof TaskStore)
|
|
169
|
+
legacy.close()
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
test("migrate reproduces a legacy board, byte-visible and loss-free", () => {
|
|
173
|
+
const root = tempRoot()
|
|
174
|
+
mkdirSync(join(root, ".task"))
|
|
175
|
+
writeFileSync(
|
|
176
|
+
join(root, ".task", "config.json"),
|
|
177
|
+
`${JSON.stringify({ name: "legacy board", prefix: "LEG", version: 1 }, null, 2)}\n`,
|
|
178
|
+
)
|
|
179
|
+
const legacy = new TaskStore(root)
|
|
180
|
+
const one = legacy.create({ title: "Keep me", description: "with *body*", tags: ["a", "b"] })
|
|
181
|
+
const two = legacy.create({
|
|
182
|
+
title: "Blocked one",
|
|
183
|
+
status: "in_progress",
|
|
184
|
+
milestone: "launch",
|
|
185
|
+
needsHuman: true,
|
|
186
|
+
blockedBy: [one.number],
|
|
187
|
+
prs: ["https://github.com/x/y/pull/9"],
|
|
188
|
+
})
|
|
189
|
+
legacy.addComment(one.number, "hello", "nick")
|
|
190
|
+
legacy.addComment(one.number, "and back", "claude")
|
|
191
|
+
const expectedTasks = legacy.list({})
|
|
192
|
+
const expectedComments = legacy.comments(one.number)
|
|
193
|
+
legacy.close()
|
|
194
|
+
|
|
195
|
+
const result = migrateBoard(root)
|
|
196
|
+
assert.deepEqual(result, { tasks: 2, comments: 2 })
|
|
197
|
+
assert.throws(() => migrateBoard(root), /already migrated/)
|
|
198
|
+
|
|
199
|
+
const store = openBoard(root)
|
|
200
|
+
assert.ok(store instanceof FileStore)
|
|
201
|
+
assert.deepEqual(store.list({}), expectedTasks)
|
|
202
|
+
assert.deepEqual(
|
|
203
|
+
store.comments(one.number).map(({ author, body, createdAt }) => ({ author, body, createdAt })),
|
|
204
|
+
expectedComments.map(({ author, body, createdAt }) => ({ author, body, createdAt })),
|
|
205
|
+
)
|
|
206
|
+
assert.deepEqual(store.get(two.number)!.blockedBy, [one.number])
|
|
207
|
+
assert.match(readFileSync(join(root, ".task", ".gitignore"), "utf8"), /tasks\.db/)
|
|
208
|
+
assert.equal(
|
|
209
|
+
(JSON.parse(readFileSync(join(root, ".task", "config.json"), "utf8")) as { version: number })
|
|
210
|
+
.version,
|
|
211
|
+
2,
|
|
212
|
+
)
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
test("archive moves finished tickets off the hot path, losing nothing", () => {
|
|
216
|
+
const root = tempRoot()
|
|
217
|
+
const store = initProject(root, { name: "archive test", prefix: "ARC" })
|
|
218
|
+
const keep = store.create({ title: "Still open" })
|
|
219
|
+
const old = store.create({ title: "Finished work" })
|
|
220
|
+
store.link(keep.number, "blocked_by", old.number)
|
|
221
|
+
store.addComment(old.number, "how it went", "nick")
|
|
222
|
+
|
|
223
|
+
// Only finished work qualifies — archive is history, not a hiding place.
|
|
224
|
+
assert.throws(() => store.archive(old.number), /is todo/)
|
|
225
|
+
store.update(old.number, { status: "done" })
|
|
226
|
+
const archived = store.archive(old.number)
|
|
227
|
+
assert.equal(archived.archived, true)
|
|
228
|
+
|
|
229
|
+
// Off the board and out of every default read…
|
|
230
|
+
assert.deepEqual(store.list({}).map((t) => t.number), [keep.number])
|
|
231
|
+
assert.equal(store.commentCounts().get(old.number), undefined)
|
|
232
|
+
// …but reads still reach it, comments included, and its links still show.
|
|
233
|
+
const shown = store.get(old.number)!
|
|
234
|
+
assert.equal(shown.archived, true)
|
|
235
|
+
assert.deepEqual(shown.blocks, [keep.number])
|
|
236
|
+
assert.equal(store.comments(old.number)[0].body, "how it went")
|
|
237
|
+
const listed = store.list({ archived: true })
|
|
238
|
+
assert.deepEqual(listed.map((t) => [t.number, t.archived]), [[old.number, true]])
|
|
239
|
+
|
|
240
|
+
// Writes refuse until unarchived, with a message that says what to do.
|
|
241
|
+
assert.throws(() => store.update(old.number, { title: "x" }), /archived — run/)
|
|
242
|
+
assert.throws(() => store.addComment(old.number, "x", "y"), /archived — run/)
|
|
243
|
+
assert.throws(() => store.delete(old.number), /archived — run/)
|
|
244
|
+
assert.throws(() => store.archive(old.number), /already archived/)
|
|
245
|
+
// Linking *to* an archived target refuses; pruning a stale link succeeds.
|
|
246
|
+
assert.throws(() => store.link(keep.number, "blocked_by", old.number), /archived — run/)
|
|
247
|
+
assert.deepEqual(store.update(keep.number, { blockedBy: [] }).blockedBy, [])
|
|
248
|
+
|
|
249
|
+
// Numbers stay reserved — the next ticket never reuses an archived one.
|
|
250
|
+
assert.equal(store.create({ title: "Next" }).number, 3)
|
|
251
|
+
|
|
252
|
+
// Round trip: unarchive puts it back exactly as it was.
|
|
253
|
+
const back = store.unarchive(old.number)
|
|
254
|
+
assert.equal(back.archived, undefined)
|
|
255
|
+
assert.equal(back.status, "done")
|
|
256
|
+
assert.deepEqual(store.list({ archived: true }), [])
|
|
257
|
+
assert.equal(store.comments(old.number).length, 1)
|
|
258
|
+
assert.throws(() => store.unarchive(old.number), /isn't archived/)
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
test("prefix routing helpers find the right board from anywhere", () => {
|
|
262
|
+
const repo = tempRoot()
|
|
263
|
+
initProject(repo, { name: "root board", prefix: "NIC" })
|
|
264
|
+
mkdirSync(join(repo, "projects", "widget"), { recursive: true })
|
|
265
|
+
initProject(join(repo, "projects", "widget"), { name: "widget", prefix: "WID" })
|
|
266
|
+
|
|
267
|
+
// From deep inside a package, the scope is still the repo root.
|
|
268
|
+
assert.equal(findScopeRoot(join(repo, "projects", "widget")), repo)
|
|
269
|
+
assert.equal(findScopeRoot(repo), repo)
|
|
270
|
+
|
|
271
|
+
const hit = findBoardsByPrefix(repo, "wid")
|
|
272
|
+
assert.deepEqual(hit.map((b) => b.id), ["projects/widget"])
|
|
273
|
+
assert.deepEqual(findBoardsByPrefix(repo, "NOPE"), [])
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
test("concurrent-writer shapes merge by construction", () => {
|
|
277
|
+
const root = tempRoot()
|
|
278
|
+
const store = initProject(root, { name: "merge" })
|
|
279
|
+
const task = store.create({ title: "Contended" })
|
|
280
|
+
|
|
281
|
+
// Two agents commenting at once = two files; simulate the second writer by
|
|
282
|
+
// dropping its file in directly, the way a git merge would.
|
|
283
|
+
store.addComment(task.number, "from session one", "claude")
|
|
284
|
+
const commentsDir = join(root, ".task", "tickets", String(task.number), "comments")
|
|
285
|
+
writeFileSync(
|
|
286
|
+
join(commentsDir, "2026-08-15T170000-other.md"),
|
|
287
|
+
"---\nauthor: \"other\"\ncreated: 2026-08-15T17:00:00.000Z\n---\n\nfrom session two\n",
|
|
288
|
+
)
|
|
289
|
+
assert.equal(store.comments(task.number).length, 2)
|
|
290
|
+
|
|
291
|
+
// A claim is a one-line diff: exactly one meaningful frontmatter line
|
|
292
|
+
// changes. `updated:` is excluded because it only changes when the update
|
|
293
|
+
// lands in a different millisecond than the create — true except when this
|
|
294
|
+
// test runs fast enough, which is exactly the kind of coin-flip a test must
|
|
295
|
+
// not depend on.
|
|
296
|
+
const path = join(root, ".task", "tickets", String(task.number), "ticket.md")
|
|
297
|
+
const before = readFileSync(path, "utf8").split("\n")
|
|
298
|
+
store.update(task.number, { status: "in_progress", position: task.position })
|
|
299
|
+
const after = readFileSync(path, "utf8").split("\n")
|
|
300
|
+
const changed = before.filter((line, i) => after[i] !== line)
|
|
301
|
+
assert.deepEqual(
|
|
302
|
+
changed.filter((line) => !line.startsWith("updated: ")),
|
|
303
|
+
["status: todo"],
|
|
304
|
+
)
|
|
305
|
+
})
|
package/src/store.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { DatabaseSync } from "node:sqlite"
|
|
2
|
-
import { existsSync,
|
|
2
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs"
|
|
3
3
|
import { dirname, join, relative, sep } from "node:path"
|
|
4
4
|
import type {
|
|
5
5
|
Comment,
|
|
@@ -9,14 +9,43 @@ import type {
|
|
|
9
9
|
TaskFilter,
|
|
10
10
|
TaskInput,
|
|
11
11
|
TaskPatch,
|
|
12
|
-
} from "./types.
|
|
12
|
+
} from "./types.ts"
|
|
13
13
|
|
|
14
14
|
export const TASK_DIR = ".task"
|
|
15
15
|
export const DB_FILE = "tasks.db"
|
|
16
16
|
export const CONFIG_FILE = "config.json"
|
|
17
17
|
|
|
18
18
|
/** Spacing between adjacent board positions — leaves room to drop between. */
|
|
19
|
-
const POSITION_GAP = 1024
|
|
19
|
+
export const POSITION_GAP = 1024
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* What a board's persistence layer looks like, whichever representation backs
|
|
23
|
+
* it. Two implementations: `FileStore` (text files in `.task/tickets/`, the
|
|
24
|
+
* canonical format) and the legacy `TaskStore` (a committed SQLite database),
|
|
25
|
+
* which survives so un-migrated boards keep working. `openBoard` picks.
|
|
26
|
+
*/
|
|
27
|
+
export interface Store {
|
|
28
|
+
readonly root: string
|
|
29
|
+
readonly taskDir: string
|
|
30
|
+
readonly config: ProjectConfig
|
|
31
|
+
close(): void
|
|
32
|
+
displayId(number: number): string
|
|
33
|
+
parseId(ref: string): number
|
|
34
|
+
list(filter?: TaskFilter): Task[]
|
|
35
|
+
get(number: number): Task | null
|
|
36
|
+
create(input: TaskInput): Task
|
|
37
|
+
update(number: number, patch: TaskPatch): Task
|
|
38
|
+
link(number: number, relation: "blocks" | "blocked_by", target: number): Task
|
|
39
|
+
unlink(number: number, relation: "blocks" | "blocked_by", target: number): Task
|
|
40
|
+
delete(number: number): void
|
|
41
|
+
/** Move a done/canceled ticket to `.task/archive/`, out of the board's hot path. */
|
|
42
|
+
archive(number: number): Task
|
|
43
|
+
unarchive(number: number): Task
|
|
44
|
+
comments(number: number): Comment[]
|
|
45
|
+
addComment(number: number, body: string, author?: string): Comment
|
|
46
|
+
deleteComment(number: number, commentId: string): Comment
|
|
47
|
+
commentCounts(): Map<number, number>
|
|
48
|
+
}
|
|
20
49
|
|
|
21
50
|
/**
|
|
22
51
|
* Walk up from `from` looking for a `.task/config.json`, the way git finds its
|
|
@@ -39,6 +68,46 @@ export interface BoardRef {
|
|
|
39
68
|
root: string
|
|
40
69
|
}
|
|
41
70
|
|
|
71
|
+
/**
|
|
72
|
+
* The widest board scope `from` belongs to: the *outermost* ancestor holding a
|
|
73
|
+
* `.task/` (a monorepo's root board), or `from` itself when no ancestor has
|
|
74
|
+
* one. Where `findRoot` answers "which board am I on", this answers "which
|
|
75
|
+
* repo of boards am I in" — the anchor for anything cross-board: prefix
|
|
76
|
+
* routing and `task boards`.
|
|
77
|
+
*/
|
|
78
|
+
export function findScopeRoot(from: string): string {
|
|
79
|
+
let dir = from
|
|
80
|
+
let top: string | null = null
|
|
81
|
+
for (;;) {
|
|
82
|
+
if (existsSync(join(dir, TASK_DIR, CONFIG_FILE))) top = dir
|
|
83
|
+
const parent = dirname(dir)
|
|
84
|
+
if (parent === dir) break
|
|
85
|
+
dir = parent
|
|
86
|
+
}
|
|
87
|
+
return top ?? from
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** A board's config, read without opening a store. Throws on a broken file. */
|
|
91
|
+
export function boardConfig(root: string): ProjectConfig {
|
|
92
|
+
return JSON.parse(readFileSync(join(root, TASK_DIR, CONFIG_FILE), "utf8")) as ProjectConfig
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Every board in scope whose prefix is `prefix` (case-insensitive). Normally
|
|
97
|
+
* zero or one — two boards sharing a prefix is a repo mistake this surfaces
|
|
98
|
+
* rather than resolves. Boards with unreadable configs can't match.
|
|
99
|
+
*/
|
|
100
|
+
export function findBoardsByPrefix(scopeRoot: string, prefix: string): BoardRef[] {
|
|
101
|
+
const wanted = prefix.toUpperCase()
|
|
102
|
+
return findBoards(scopeRoot).filter((ref) => {
|
|
103
|
+
try {
|
|
104
|
+
return boardConfig(ref.root).prefix.toUpperCase() === wanted
|
|
105
|
+
} catch {
|
|
106
|
+
return false
|
|
107
|
+
}
|
|
108
|
+
})
|
|
109
|
+
}
|
|
110
|
+
|
|
42
111
|
/** Directories that never contain a board worth serving. */
|
|
43
112
|
const SKIP_DIRS = new Set(["node_modules", "dist", "build", "out", "coverage", "target"])
|
|
44
113
|
|
|
@@ -112,11 +181,12 @@ function now(): string {
|
|
|
112
181
|
}
|
|
113
182
|
|
|
114
183
|
/**
|
|
115
|
-
* The
|
|
184
|
+
* The legacy persistence layer: one SQLite database inside `.task/`, opened
|
|
116
185
|
* synchronously via node:sqlite (built into Node ≥22.13 — zero dependencies,
|
|
117
|
-
* nothing to compile, safe for `npx`).
|
|
186
|
+
* nothing to compile, safe for `npx`). Boards created before the text format
|
|
187
|
+
* still run on this until `task migrate` moves them over.
|
|
118
188
|
*/
|
|
119
|
-
export class TaskStore {
|
|
189
|
+
export class TaskStore implements Store {
|
|
120
190
|
readonly root: string
|
|
121
191
|
readonly taskDir: string
|
|
122
192
|
readonly config: ProjectConfig
|
|
@@ -181,7 +251,7 @@ export class TaskStore {
|
|
|
181
251
|
|
|
182
252
|
private toComment(row: CommentRow): Comment {
|
|
183
253
|
return {
|
|
184
|
-
id: row.id,
|
|
254
|
+
id: String(row.id),
|
|
185
255
|
taskId: this.displayId(row.task_id),
|
|
186
256
|
author: row.author,
|
|
187
257
|
body: row.body,
|
|
@@ -190,6 +260,9 @@ export class TaskStore {
|
|
|
190
260
|
}
|
|
191
261
|
|
|
192
262
|
list(filter: TaskFilter = {}): Task[] {
|
|
263
|
+
if (filter.archived) {
|
|
264
|
+
throw new Error("archiving needs a text-format board — run `task migrate` first")
|
|
265
|
+
}
|
|
193
266
|
const where: string[] = []
|
|
194
267
|
const params: (string | number)[] = []
|
|
195
268
|
if (filter.statuses?.length) {
|
|
@@ -366,6 +439,15 @@ export class TaskStore {
|
|
|
366
439
|
if (result.changes === 0) throw new Error(`no such task: ${this.displayId(number)}`)
|
|
367
440
|
}
|
|
368
441
|
|
|
442
|
+
/** The archive is a text-format feature — a directory move, not a table. */
|
|
443
|
+
archive(_number: number): Task {
|
|
444
|
+
throw new Error("archiving needs a text-format board — run `task migrate` first")
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
unarchive(_number: number): Task {
|
|
448
|
+
throw new Error("archiving needs a text-format board — run `task migrate` first")
|
|
449
|
+
}
|
|
450
|
+
|
|
369
451
|
comments(number: number): Comment[] {
|
|
370
452
|
const rows = this.db
|
|
371
453
|
.prepare("SELECT * FROM comments WHERE task_id = ? ORDER BY id")
|
|
@@ -384,12 +466,17 @@ export class TaskStore {
|
|
|
384
466
|
return this.toComment(row)
|
|
385
467
|
}
|
|
386
468
|
|
|
387
|
-
deleteComment(number: number, commentId:
|
|
388
|
-
const
|
|
389
|
-
|
|
390
|
-
.
|
|
469
|
+
deleteComment(number: number, commentId: string): Comment {
|
|
470
|
+
const rowid = Number(commentId)
|
|
471
|
+
const row = (
|
|
472
|
+
Number.isInteger(rowid)
|
|
473
|
+
? this.db
|
|
474
|
+
.prepare("SELECT * FROM comments WHERE id = ? AND task_id = ?")
|
|
475
|
+
.get(rowid, number)
|
|
476
|
+
: undefined
|
|
477
|
+
) as CommentRow | undefined
|
|
391
478
|
if (!row) throw new Error(`no such comment on ${this.displayId(number)}: ${commentId}`)
|
|
392
|
-
this.db.prepare("DELETE FROM comments WHERE id = ?").run(
|
|
479
|
+
this.db.prepare("DELETE FROM comments WHERE id = ?").run(rowid)
|
|
393
480
|
return this.toComment(row)
|
|
394
481
|
}
|
|
395
482
|
|
|
@@ -490,31 +577,3 @@ function migrate(db: DatabaseSync): void {
|
|
|
490
577
|
}
|
|
491
578
|
}
|
|
492
579
|
|
|
493
|
-
export interface InitOptions {
|
|
494
|
-
name: string
|
|
495
|
-
prefix?: string
|
|
496
|
-
}
|
|
497
|
-
|
|
498
|
-
/**
|
|
499
|
-
* Create `.task/` in `root`: config, an empty database, and a .gitignore for
|
|
500
|
-
* SQLite's transient sidecar files (the database itself is meant to be
|
|
501
|
-
* committed — that's the point).
|
|
502
|
-
*/
|
|
503
|
-
export function initProject(root: string, options: InitOptions): TaskStore {
|
|
504
|
-
const taskDir = join(root, TASK_DIR)
|
|
505
|
-
if (existsSync(join(taskDir, CONFIG_FILE))) {
|
|
506
|
-
throw new Error(`already initialized: ${join(taskDir, CONFIG_FILE)} exists`)
|
|
507
|
-
}
|
|
508
|
-
mkdirSync(taskDir, { recursive: true })
|
|
509
|
-
const config: ProjectConfig = {
|
|
510
|
-
name: options.name,
|
|
511
|
-
prefix: options.prefix ?? derivePrefix(options.name),
|
|
512
|
-
version: 1,
|
|
513
|
-
}
|
|
514
|
-
writeFileSync(join(taskDir, CONFIG_FILE), `${JSON.stringify(config, null, 2)}\n`)
|
|
515
|
-
writeFileSync(
|
|
516
|
-
join(taskDir, ".gitignore"),
|
|
517
|
-
"# SQLite transients — tasks.db itself is committed.\n*.db-journal\n*.db-wal\n*.db-shm\n",
|
|
518
|
-
)
|
|
519
|
-
return new TaskStore(root)
|
|
520
|
-
}
|