@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/store.test.ts
CHANGED
|
@@ -4,8 +4,7 @@
|
|
|
4
4
|
* 1. The text format round-trips — parse(serialize(x)) loses nothing.
|
|
5
5
|
* 2. `FileStore` behaves like the store always has (positions, links,
|
|
6
6
|
* comments, cascade on delete).
|
|
7
|
-
* 3.
|
|
8
|
-
* 4. Concurrent-writer shapes merge by construction: a comment is a new file,
|
|
7
|
+
* 3. Concurrent-writer shapes merge by construction: a comment is a new file,
|
|
9
8
|
* a claim is a one-line status diff.
|
|
10
9
|
*/
|
|
11
10
|
|
|
@@ -14,9 +13,10 @@ import { test } from "node:test"
|
|
|
14
13
|
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"
|
|
15
14
|
import { tmpdir } from "node:os"
|
|
16
15
|
import { join } from "node:path"
|
|
17
|
-
import { FileStore, initProject,
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
16
|
+
import { FileStore, initProject, openBoard } from "./file-store.ts"
|
|
17
|
+
import { KEY_PATTERN } from "./id.ts"
|
|
18
|
+
import { parseTicket, parseTicketLenient, serializeTicket, type TicketDoc } from "./ticket-doc.ts"
|
|
19
|
+
import { findBoardsByPrefix, findScopeRoot } from "./store.ts"
|
|
20
20
|
|
|
21
21
|
function tempRoot(): string {
|
|
22
22
|
const dir = mkdtempSync(join(tmpdir(), "task-test-"))
|
|
@@ -31,8 +31,7 @@ test("ticket documents round-trip, including the awkward bodies", () => {
|
|
|
31
31
|
description: "One line.",
|
|
32
32
|
status: "todo",
|
|
33
33
|
tags: [],
|
|
34
|
-
|
|
35
|
-
needsHuman: false,
|
|
34
|
+
goal: null,
|
|
36
35
|
blockedBy: [],
|
|
37
36
|
prs: [],
|
|
38
37
|
position: 1024,
|
|
@@ -46,9 +45,8 @@ test("ticket documents round-trip, including the awkward bodies", () => {
|
|
|
46
45
|
description: "# Not the title\n\n---\n\nstatus: not-a-field\n\n```md\n---\n```",
|
|
47
46
|
status: "in_progress",
|
|
48
47
|
tags: ["storage", "architecture"],
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
blockedBy: [3, 7],
|
|
48
|
+
goal: "v2-storage",
|
|
49
|
+
blockedBy: ["k3mfp", "x7c2d"],
|
|
52
50
|
prs: ["https://github.com/x/y/pull/1"],
|
|
53
51
|
position: -512.5,
|
|
54
52
|
createdAt: "2026-08-15T16:00:33.758Z",
|
|
@@ -59,8 +57,7 @@ test("ticket documents round-trip, including the awkward bodies", () => {
|
|
|
59
57
|
description: "",
|
|
60
58
|
status: "backlog",
|
|
61
59
|
tags: [],
|
|
62
|
-
|
|
63
|
-
needsHuman: false,
|
|
60
|
+
goal: null,
|
|
64
61
|
blockedBy: [],
|
|
65
62
|
prs: [],
|
|
66
63
|
position: 0,
|
|
@@ -77,7 +74,7 @@ test("hand-edits are forgiven where they're unambiguous", () => {
|
|
|
77
74
|
const text = [
|
|
78
75
|
"---",
|
|
79
76
|
"status: todo",
|
|
80
|
-
"
|
|
77
|
+
"goal: launch", // unquoted string — the YAML instinct
|
|
81
78
|
"position: 8",
|
|
82
79
|
"created: 2026-08-15T16:00:33.758Z",
|
|
83
80
|
"updated: 2026-08-15T16:00:33.758Z",
|
|
@@ -88,9 +85,19 @@ test("hand-edits are forgiven where they're unambiguous", () => {
|
|
|
88
85
|
"Body.",
|
|
89
86
|
].join("\n")
|
|
90
87
|
const doc = parseTicket(text, "test")
|
|
91
|
-
assert.equal(doc.
|
|
88
|
+
assert.equal(doc.goal, "launch")
|
|
92
89
|
assert.equal(doc.position, 8)
|
|
93
90
|
|
|
91
|
+
// The pre-0.8 `milestone:` spelling is refused by name (TAS-41: no compat)
|
|
92
|
+
// — reading past it would silently drop the goal, so the error carries the
|
|
93
|
+
// one edit that fixes the file. The lenient parser just ignores it.
|
|
94
|
+
assert.throws(
|
|
95
|
+
() => parseTicket(text.replace("goal: launch", "milestone: launch"), "test"),
|
|
96
|
+
/`milestone:` is the pre-0\.8 spelling.*rename the key to `goal:`/,
|
|
97
|
+
)
|
|
98
|
+
const degraded = parseTicketLenient(text.replace("goal: launch", "milestone: launch"))
|
|
99
|
+
assert.equal(degraded?.goal, null)
|
|
100
|
+
|
|
94
101
|
assert.throws(
|
|
95
102
|
() => parseTicket(text.replace("status: todo", "status: todoo"), "here"),
|
|
96
103
|
/here.*invalid status/,
|
|
@@ -104,158 +111,201 @@ test("FileStore keeps the store's contract", () => {
|
|
|
104
111
|
assert.equal(store.config.prefix, "SAM")
|
|
105
112
|
assert.ok(store instanceof FileStore)
|
|
106
113
|
|
|
107
|
-
const a = store.create({ title: "First", description: "alpha", tags: ["api"] })
|
|
114
|
+
const a = store.create({ title: "First", description: "alpha", tags: ["api"], status: "todo" })
|
|
108
115
|
const b = store.create({ title: "Second", status: "todo" })
|
|
109
116
|
const c = store.create({ title: "Third", status: "backlog" })
|
|
110
|
-
|
|
117
|
+
// Keys are random, well-formed, and distinct — never sequential (TAS-42).
|
|
118
|
+
for (const t of [a, b, c]) assert.match(t.key, KEY_PATTERN)
|
|
119
|
+
assert.equal(new Set([a.key, b.key, c.key]).size, 3)
|
|
120
|
+
assert.equal(a.id, `SAM-${a.key}`)
|
|
111
121
|
// New tasks land at the bottom of their column.
|
|
112
122
|
assert.ok(b.position > a.position)
|
|
113
123
|
|
|
114
124
|
// Status move without an explicit slot lands on top of the new column.
|
|
115
|
-
const moved = store.update(c.
|
|
125
|
+
const moved = store.update(c.key, { status: "todo" })
|
|
116
126
|
assert.ok(moved.position < a.position)
|
|
117
|
-
assert.equal(store.list({ statuses: ["todo"] })[0].
|
|
127
|
+
assert.equal(store.list({ statuses: ["todo"] })[0].key, c.key)
|
|
118
128
|
|
|
119
129
|
// One relation, two views, stored on one side only.
|
|
120
|
-
store.link(a.
|
|
121
|
-
assert.deepEqual(store.get(b.
|
|
122
|
-
const onDisk = readFileSync(join(root, ".task", "tickets",
|
|
123
|
-
assert.
|
|
130
|
+
store.link(a.key, "blocked_by", b.key)
|
|
131
|
+
assert.deepEqual(store.get(b.key)!.blocks, [a.key])
|
|
132
|
+
const onDisk = readFileSync(join(root, ".task", "tickets", a.key, "ticket.md"), "utf8")
|
|
133
|
+
assert.ok(onDisk.includes(`blocked_by: ["${b.key}"]`))
|
|
124
134
|
assert.doesNotMatch(
|
|
125
|
-
readFileSync(join(root, ".task", "tickets",
|
|
135
|
+
readFileSync(join(root, ".task", "tickets", b.key, "ticket.md"), "utf8"),
|
|
126
136
|
/blocked_by/,
|
|
127
137
|
)
|
|
128
|
-
store.unlink(b.
|
|
129
|
-
assert.deepEqual(store.get(a.
|
|
138
|
+
store.unlink(b.key, "blocks", a.key)
|
|
139
|
+
assert.deepEqual(store.get(a.key)!.blockedBy, [])
|
|
130
140
|
|
|
131
|
-
assert.throws(() => store.link(a.
|
|
132
|
-
assert.throws(() => store.update(a.
|
|
141
|
+
assert.throws(() => store.link(a.key, "blocks", a.key), /can't block itself/)
|
|
142
|
+
assert.throws(() => store.update(a.key, { blockedBy: ["zz9zz"] }), /no such task: SAM-zz9zz/)
|
|
143
|
+
|
|
144
|
+
// parseId: exact keys, PREFIX-key, and unique git-style prefixes all land.
|
|
145
|
+
assert.equal(store.parseId(a.key), a.key)
|
|
146
|
+
assert.equal(store.parseId(`SAM-${a.key}`), a.key)
|
|
147
|
+
assert.equal(store.parseId(a.key.toUpperCase()), a.key)
|
|
148
|
+
const uniquePrefix = [a, b, c].every((t) => t.key[0] !== a.key[0] || t.key === a.key)
|
|
149
|
+
? a.key[0]
|
|
150
|
+
: a.key.slice(0, 4)
|
|
151
|
+
if ([a, b, c].filter((t) => t.key.startsWith(uniquePrefix)).length === 1) {
|
|
152
|
+
assert.equal(store.parseId(uniquePrefix), a.key)
|
|
153
|
+
}
|
|
154
|
+
assert.throws(() => store.parseId("not an id!"), /invalid task id/)
|
|
133
155
|
|
|
134
156
|
// Comments: one file each, stable ids, author preserved.
|
|
135
|
-
const comment = store.addComment(a.
|
|
157
|
+
const comment = store.addComment(a.key, "first!\n\nwith **markdown**", "claude")
|
|
136
158
|
assert.match(comment.id, /^\d{4}-\d{2}-\d{2}T\d{6}-claude$/)
|
|
137
|
-
assert.equal(store.comments(a.
|
|
138
|
-
assert.equal(store.commentCounts().get(a.
|
|
139
|
-
const second = store.addComment(a.
|
|
159
|
+
assert.equal(store.comments(a.key)[0].body, "first!\n\nwith **markdown**")
|
|
160
|
+
assert.equal(store.commentCounts().get(a.key), 1)
|
|
161
|
+
const second = store.addComment(a.key, "again", "claude")
|
|
140
162
|
assert.notEqual(second.id, comment.id)
|
|
141
|
-
store.deleteComment(a.
|
|
142
|
-
assert.equal(store.comments(a.
|
|
143
|
-
assert.throws(() => store.deleteComment(a.
|
|
163
|
+
store.deleteComment(a.key, second.id)
|
|
164
|
+
assert.equal(store.comments(a.key).length, 1)
|
|
165
|
+
assert.throws(() => store.deleteComment(a.key, "../../escape"), /no such comment/)
|
|
144
166
|
|
|
145
167
|
// Delete cascades the relation away, like the FK did.
|
|
146
|
-
store.link(b.
|
|
147
|
-
store.delete(a.
|
|
148
|
-
assert.equal(store.get(a.
|
|
149
|
-
assert.deepEqual(store.get(b.
|
|
150
|
-
|
|
151
|
-
// Numbers never move backwards past an existing ticket.
|
|
152
|
-
const d = store.create({ title: "Fourth" })
|
|
153
|
-
assert.equal(d.number, 4)
|
|
168
|
+
store.link(b.key, "blocked_by", a.key)
|
|
169
|
+
store.delete(a.key)
|
|
170
|
+
assert.equal(store.get(a.key), null)
|
|
171
|
+
assert.deepEqual(store.get(b.key)!.blockedBy, [])
|
|
154
172
|
})
|
|
155
173
|
|
|
156
|
-
test("openBoard
|
|
174
|
+
test("openBoard reads text boards, and refuses a pre-0.6 SQLite board loudly", () => {
|
|
157
175
|
const fileRoot = tempRoot()
|
|
158
176
|
initProject(fileRoot, { name: "files" })
|
|
159
177
|
assert.ok(openBoard(fileRoot) instanceof FileStore)
|
|
160
178
|
|
|
179
|
+
// A pre-0.6 board: version-1 config, committed database, no tickets/ tree.
|
|
180
|
+
// Reading it as an empty text board would silently strand every ticket in
|
|
181
|
+
// the database, so it must refuse instead — pointing at the last release
|
|
182
|
+
// that still carries `task migrate`.
|
|
161
183
|
const legacyRoot = tempRoot()
|
|
162
184
|
mkdirSync(join(legacyRoot, ".task"))
|
|
163
185
|
writeFileSync(
|
|
164
186
|
join(legacyRoot, ".task", "config.json"),
|
|
165
187
|
`${JSON.stringify({ name: "legacy", prefix: "LEG", version: 1 }, null, 2)}\n`,
|
|
166
188
|
)
|
|
167
|
-
|
|
168
|
-
assert.
|
|
169
|
-
legacy.close()
|
|
170
|
-
})
|
|
189
|
+
writeFileSync(join(legacyRoot, ".task", "tasks.db"), "not really a database")
|
|
190
|
+
assert.throws(() => openBoard(legacyRoot), /pre-0\.6 SQLite database.*migrate/)
|
|
171
191
|
|
|
172
|
-
|
|
173
|
-
const
|
|
174
|
-
mkdirSync(join(
|
|
192
|
+
// A version-2 board with no tickets yet is just an empty board, not legacy.
|
|
193
|
+
const emptyRoot = tempRoot()
|
|
194
|
+
mkdirSync(join(emptyRoot, ".task"))
|
|
175
195
|
writeFileSync(
|
|
176
|
-
join(
|
|
177
|
-
`${JSON.stringify({ name: "
|
|
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,
|
|
196
|
+
join(emptyRoot, ".task", "config.json"),
|
|
197
|
+
`${JSON.stringify({ name: "empty", prefix: "EMP", version: 2 }, null, 2)}\n`,
|
|
212
198
|
)
|
|
199
|
+
assert.deepEqual(openBoard(emptyRoot).list({}), [])
|
|
213
200
|
})
|
|
214
201
|
|
|
215
202
|
test("archive moves finished tickets off the hot path, losing nothing", () => {
|
|
216
203
|
const root = tempRoot()
|
|
217
204
|
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.
|
|
221
|
-
store.addComment(old.
|
|
205
|
+
const keep = store.create({ title: "Still open", status: "todo" })
|
|
206
|
+
const old = store.create({ title: "Finished work", status: "todo" })
|
|
207
|
+
store.link(keep.key, "blocked_by", old.key)
|
|
208
|
+
store.addComment(old.key, "how it went", "nick")
|
|
222
209
|
|
|
223
210
|
// Only finished work qualifies — archive is history, not a hiding place.
|
|
224
|
-
assert.throws(() => store.archive(old.
|
|
225
|
-
store.update(old.
|
|
226
|
-
const archived = store.archive(old.
|
|
211
|
+
assert.throws(() => store.archive(old.key), /is todo/)
|
|
212
|
+
store.update(old.key, { status: "done" })
|
|
213
|
+
const archived = store.archive(old.key)
|
|
227
214
|
assert.equal(archived.archived, true)
|
|
228
215
|
|
|
229
216
|
// Off the board and out of every default read…
|
|
230
|
-
assert.deepEqual(store.list({}).map((t) => t.
|
|
231
|
-
assert.equal(store.commentCounts().get(old.
|
|
217
|
+
assert.deepEqual(store.list({}).map((t) => t.key), [keep.key])
|
|
218
|
+
assert.equal(store.commentCounts().get(old.key), undefined)
|
|
232
219
|
// …but reads still reach it, comments included, and its links still show.
|
|
233
|
-
const shown = store.get(old.
|
|
220
|
+
const shown = store.get(old.key)!
|
|
234
221
|
assert.equal(shown.archived, true)
|
|
235
|
-
assert.deepEqual(shown.blocks, [keep.
|
|
236
|
-
assert.equal(store.comments(old.
|
|
222
|
+
assert.deepEqual(shown.blocks, [keep.key])
|
|
223
|
+
assert.equal(store.comments(old.key)[0].body, "how it went")
|
|
237
224
|
const listed = store.list({ archived: true })
|
|
238
|
-
assert.deepEqual(listed.map((t) => [t.
|
|
225
|
+
assert.deepEqual(listed.map((t) => [t.key, t.archived]), [[old.key, true]])
|
|
239
226
|
|
|
240
227
|
// Writes refuse until unarchived, with a message that says what to do.
|
|
241
|
-
assert.throws(() => store.update(old.
|
|
242
|
-
assert.throws(() => store.addComment(old.
|
|
243
|
-
assert.throws(() => store.delete(old.
|
|
244
|
-
assert.throws(() => store.archive(old.
|
|
228
|
+
assert.throws(() => store.update(old.key, { title: "x" }), /archived — run/)
|
|
229
|
+
assert.throws(() => store.addComment(old.key, "x", "y"), /archived — run/)
|
|
230
|
+
assert.throws(() => store.delete(old.key), /archived — run/)
|
|
231
|
+
assert.throws(() => store.archive(old.key), /already archived/)
|
|
245
232
|
// Linking *to* an archived target refuses; pruning a stale link succeeds.
|
|
246
|
-
assert.throws(() => store.link(keep.
|
|
247
|
-
assert.deepEqual(store.update(keep.
|
|
233
|
+
assert.throws(() => store.link(keep.key, "blocked_by", old.key), /archived — run/)
|
|
234
|
+
assert.deepEqual(store.update(keep.key, { blockedBy: [] }).blockedBy, [])
|
|
248
235
|
|
|
249
|
-
//
|
|
250
|
-
|
|
236
|
+
// Keys stay reserved — a new mint can never take an archived ticket's name.
|
|
237
|
+
const next = store.create({ title: "Next" })
|
|
238
|
+
assert.notEqual(next.key, old.key)
|
|
239
|
+
// An archived key still resolves through parseId (exact match).
|
|
240
|
+
assert.equal(store.parseId(`ARC-${old.key}`), old.key)
|
|
251
241
|
|
|
252
242
|
// Round trip: unarchive puts it back exactly as it was.
|
|
253
|
-
const back = store.unarchive(old.
|
|
243
|
+
const back = store.unarchive(old.key)
|
|
254
244
|
assert.equal(back.archived, undefined)
|
|
255
245
|
assert.equal(back.status, "done")
|
|
256
246
|
assert.deepEqual(store.list({ archived: true }), [])
|
|
257
|
-
assert.equal(store.comments(old.
|
|
258
|
-
assert.throws(() => store.unarchive(old.
|
|
247
|
+
assert.equal(store.comments(old.key).length, 1)
|
|
248
|
+
assert.throws(() => store.unarchive(old.key), /isn't archived/)
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
test("goals: titled and described, one per task, progress always derived", () => {
|
|
252
|
+
const root = tempRoot()
|
|
253
|
+
const store = initProject(root, { name: "goals test", prefix: "GOA" })
|
|
254
|
+
|
|
255
|
+
// Created deliberately, slug derived from the title.
|
|
256
|
+
const goal = store.createGoal({
|
|
257
|
+
title: "Trust & guardrail layer",
|
|
258
|
+
description: "Why: DialMCP context lives here once, not in every ticket.",
|
|
259
|
+
})
|
|
260
|
+
assert.equal(goal.slug, "trust-guardrail-layer")
|
|
261
|
+
assert.throws(() => store.createGoal({ title: "Trust & guardrail layer" }), /already exists/)
|
|
262
|
+
assert.throws(() => store.createGoal({ title: "x", slug: "archive" }), /invalid goal slug/)
|
|
263
|
+
assert.throws(() => store.createGoal({ title: "x", slug: "Not A Slug" }), /invalid goal slug/)
|
|
264
|
+
|
|
265
|
+
// Assignment is validated: a goal must exist before a task can belong to it.
|
|
266
|
+
assert.throws(() => store.create({ title: "T", goal: "nope" }), /no such goal/)
|
|
267
|
+
const a = store.create({ title: "First", goal: goal.slug })
|
|
268
|
+
const b = store.create({ title: "Second" })
|
|
269
|
+
store.update(b.key, { goal: goal.slug })
|
|
270
|
+
assert.equal(store.get(b.key)!.goal, goal.slug)
|
|
271
|
+
assert.deepEqual(store.list({ goal: goal.slug }).map((t) => t.key), [a.key, b.key])
|
|
272
|
+
|
|
273
|
+
// The file is the state: goal ref serialized on the ticket, goal file on disk.
|
|
274
|
+
assert.match(
|
|
275
|
+
readFileSync(join(root, ".task", "tickets", a.key, "ticket.md"), "utf8"),
|
|
276
|
+
/goal: "trust-guardrail-layer"/,
|
|
277
|
+
)
|
|
278
|
+
const goalFile = readFileSync(join(root, ".task", "goals", "trust-guardrail-layer.md"), "utf8")
|
|
279
|
+
assert.match(goalFile, /# Trust & guardrail layer/)
|
|
280
|
+
|
|
281
|
+
// Update touches the file; clearing a task's goal is `goal: null`.
|
|
282
|
+
store.updateGoal(goal.slug, { description: "Sharper why." })
|
|
283
|
+
assert.equal(store.getGoal(goal.slug)!.description, "Sharper why.")
|
|
284
|
+
store.update(b.key, { goal: null })
|
|
285
|
+
assert.equal(store.get(b.key)!.goal, null)
|
|
286
|
+
|
|
287
|
+
// Archive refuses while open work references the goal, allows after.
|
|
288
|
+
assert.throws(
|
|
289
|
+
() => store.archiveGoal(goal.slug),
|
|
290
|
+
new RegExp(`still has open tasks \\(GOA-${a.key}\\)`),
|
|
291
|
+
)
|
|
292
|
+
store.update(a.key, { status: "done" })
|
|
293
|
+
const shelved = store.archiveGoal(goal.slug)
|
|
294
|
+
assert.equal(shelved.archived, true)
|
|
295
|
+
assert.deepEqual(store.goals().map((g) => g.slug), [])
|
|
296
|
+
assert.deepEqual(store.goals(true).map((g) => g.slug), [goal.slug])
|
|
297
|
+
// Still resolvable (done tickets keep their ref readable), not assignable.
|
|
298
|
+
assert.equal(store.getGoal(goal.slug)!.title, "Trust & guardrail layer")
|
|
299
|
+
assert.throws(() => store.update(b.key, { goal: goal.slug }), /is archived — run/)
|
|
300
|
+
assert.throws(() => store.updateGoal(goal.slug, { title: "x" }), /is archived — run/)
|
|
301
|
+
|
|
302
|
+
// Delete needs every live ref cleared first — pruning organization is a
|
|
303
|
+
// human call, so the human makes it.
|
|
304
|
+
store.unarchiveGoal(goal.slug)
|
|
305
|
+
assert.throws(() => store.deleteGoal(goal.slug), new RegExp(`referenced by GOA-${a.key}`))
|
|
306
|
+
store.update(a.key, { goal: null })
|
|
307
|
+
store.deleteGoal(goal.slug)
|
|
308
|
+
assert.equal(store.getGoal(goal.slug), null)
|
|
259
309
|
})
|
|
260
310
|
|
|
261
311
|
test("prefix routing helpers find the right board from anywhere", () => {
|
|
@@ -276,26 +326,26 @@ test("prefix routing helpers find the right board from anywhere", () => {
|
|
|
276
326
|
test("concurrent-writer shapes merge by construction", () => {
|
|
277
327
|
const root = tempRoot()
|
|
278
328
|
const store = initProject(root, { name: "merge" })
|
|
279
|
-
const task = store.create({ title: "Contended" })
|
|
329
|
+
const task = store.create({ title: "Contended", status: "todo" })
|
|
280
330
|
|
|
281
331
|
// Two agents commenting at once = two files; simulate the second writer by
|
|
282
332
|
// dropping its file in directly, the way a git merge would.
|
|
283
|
-
store.addComment(task.
|
|
284
|
-
const commentsDir = join(root, ".task", "tickets",
|
|
333
|
+
store.addComment(task.key, "from session one", "claude")
|
|
334
|
+
const commentsDir = join(root, ".task", "tickets", task.key, "comments")
|
|
285
335
|
writeFileSync(
|
|
286
336
|
join(commentsDir, "2026-08-15T170000-other.md"),
|
|
287
337
|
"---\nauthor: \"other\"\ncreated: 2026-08-15T17:00:00.000Z\n---\n\nfrom session two\n",
|
|
288
338
|
)
|
|
289
|
-
assert.equal(store.comments(task.
|
|
339
|
+
assert.equal(store.comments(task.key).length, 2)
|
|
290
340
|
|
|
291
341
|
// A claim is a one-line diff: exactly one meaningful frontmatter line
|
|
292
342
|
// changes. `updated:` is excluded because it only changes when the update
|
|
293
343
|
// lands in a different millisecond than the create — true except when this
|
|
294
344
|
// test runs fast enough, which is exactly the kind of coin-flip a test must
|
|
295
345
|
// not depend on.
|
|
296
|
-
const path = join(root, ".task", "tickets",
|
|
346
|
+
const path = join(root, ".task", "tickets", task.key, "ticket.md")
|
|
297
347
|
const before = readFileSync(path, "utf8").split("\n")
|
|
298
|
-
store.update(task.
|
|
348
|
+
store.update(task.key, { status: "in_progress", position: task.position })
|
|
299
349
|
const after = readFileSync(path, "utf8").split("\n")
|
|
300
350
|
const changed = before.filter((line, i) => after[i] !== line)
|
|
301
351
|
assert.deepEqual(
|