@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.
Files changed (128) hide show
  1. package/README.md +73 -25
  2. package/dist/asks.test.d.ts +17 -0
  3. package/dist/asks.test.d.ts.map +1 -0
  4. package/dist/asks.test.js +278 -0
  5. package/dist/asks.test.js.map +1 -0
  6. package/dist/check.d.ts +43 -0
  7. package/dist/check.d.ts.map +1 -0
  8. package/dist/check.js +403 -0
  9. package/dist/check.js.map +1 -0
  10. package/dist/check.test.d.ts +9 -0
  11. package/dist/check.test.d.ts.map +1 -0
  12. package/dist/check.test.js +248 -0
  13. package/dist/check.test.js.map +1 -0
  14. package/dist/claim-io.d.ts +73 -0
  15. package/dist/claim-io.d.ts.map +1 -0
  16. package/dist/claim-io.js +344 -0
  17. package/dist/claim-io.js.map +1 -0
  18. package/dist/claim.d.ts +61 -9
  19. package/dist/claim.d.ts.map +1 -1
  20. package/dist/claim.js +197 -67
  21. package/dist/claim.js.map +1 -1
  22. package/dist/claim.test.d.ts +2 -2
  23. package/dist/claim.test.js +235 -64
  24. package/dist/claim.test.js.map +1 -1
  25. package/dist/cli.js +724 -136
  26. package/dist/cli.js.map +1 -1
  27. package/dist/file-store.d.ts +110 -38
  28. package/dist/file-store.d.ts.map +1 -1
  29. package/dist/file-store.js +514 -238
  30. package/dist/file-store.js.map +1 -1
  31. package/dist/git-serve.d.ts +183 -0
  32. package/dist/git-serve.d.ts.map +1 -0
  33. package/dist/git-serve.js +503 -0
  34. package/dist/git-serve.js.map +1 -0
  35. package/dist/git-serve.test.d.ts +16 -0
  36. package/dist/git-serve.test.d.ts.map +1 -0
  37. package/dist/git-serve.test.js +183 -0
  38. package/dist/git-serve.test.js.map +1 -0
  39. package/dist/git.d.ts +65 -0
  40. package/dist/git.d.ts.map +1 -0
  41. package/dist/git.js +114 -0
  42. package/dist/git.js.map +1 -0
  43. package/dist/id.d.ts +39 -0
  44. package/dist/id.d.ts.map +1 -0
  45. package/dist/id.js +67 -0
  46. package/dist/id.js.map +1 -0
  47. package/dist/inbox.d.ts +41 -0
  48. package/dist/inbox.d.ts.map +1 -0
  49. package/dist/inbox.js +56 -0
  50. package/dist/inbox.js.map +1 -0
  51. package/dist/index.d.ts +5 -3
  52. package/dist/index.d.ts.map +1 -1
  53. package/dist/index.js +5 -3
  54. package/dist/index.js.map +1 -1
  55. package/dist/overview.d.ts +52 -0
  56. package/dist/overview.d.ts.map +1 -0
  57. package/dist/overview.js +61 -0
  58. package/dist/overview.js.map +1 -0
  59. package/dist/overview.test.d.ts +8 -0
  60. package/dist/overview.test.d.ts.map +1 -0
  61. package/dist/overview.test.js +48 -0
  62. package/dist/overview.test.js.map +1 -0
  63. package/dist/promote.test.d.ts +15 -0
  64. package/dist/promote.test.d.ts.map +1 -0
  65. package/dist/promote.test.js +104 -0
  66. package/dist/promote.test.js.map +1 -0
  67. package/dist/publish.d.ts +2 -17
  68. package/dist/publish.d.ts.map +1 -1
  69. package/dist/publish.js +4 -49
  70. package/dist/publish.js.map +1 -1
  71. package/dist/search.d.ts +34 -0
  72. package/dist/search.d.ts.map +1 -0
  73. package/dist/search.js +79 -0
  74. package/dist/search.js.map +1 -0
  75. package/dist/search.test.d.ts +2 -0
  76. package/dist/search.test.d.ts.map +1 -0
  77. package/dist/search.test.js +53 -0
  78. package/dist/search.test.js.map +1 -0
  79. package/dist/server.d.ts.map +1 -1
  80. package/dist/server.js +228 -23
  81. package/dist/server.js.map +1 -1
  82. package/dist/store.d.ts +43 -63
  83. package/dist/store.d.ts.map +1 -1
  84. package/dist/store.js +0 -368
  85. package/dist/store.js.map +1 -1
  86. package/dist/store.test.d.ts +1 -2
  87. package/dist/store.test.d.ts.map +1 -1
  88. package/dist/store.test.js +148 -106
  89. package/dist/store.test.js.map +1 -1
  90. package/dist/ticket-doc.d.ts +74 -5
  91. package/dist/ticket-doc.d.ts.map +1 -1
  92. package/dist/ticket-doc.js +229 -15
  93. package/dist/ticket-doc.js.map +1 -1
  94. package/dist/types.d.ts +115 -28
  95. package/dist/types.d.ts.map +1 -1
  96. package/dist/types.js.map +1 -1
  97. package/package.json +1 -1
  98. package/skill/SKILL.md +153 -40
  99. package/src/asks.test.ts +355 -0
  100. package/src/check.test.ts +328 -0
  101. package/src/check.ts +497 -0
  102. package/src/claim-io.ts +401 -0
  103. package/src/claim.test.ts +301 -71
  104. package/src/claim.ts +238 -81
  105. package/src/cli.ts +740 -131
  106. package/src/file-store.ts +572 -254
  107. package/src/git-serve.test.ts +240 -0
  108. package/src/git-serve.ts +595 -0
  109. package/src/git.ts +141 -0
  110. package/src/id.ts +68 -0
  111. package/src/inbox.ts +77 -0
  112. package/src/index.ts +4 -2
  113. package/src/overview.test.ts +52 -0
  114. package/src/overview.ts +105 -0
  115. package/src/promote.test.ts +143 -0
  116. package/src/publish.ts +6 -53
  117. package/src/search.test.ts +64 -0
  118. package/src/search.ts +105 -0
  119. package/src/server.ts +232 -21
  120. package/src/store.test.ts +166 -116
  121. package/src/store.ts +46 -444
  122. package/src/ticket-doc.ts +284 -21
  123. package/src/types.ts +120 -28
  124. package/ui/dist/assets/index-BjsorZOU.js +229 -0
  125. package/ui/dist/assets/index-CoKCUYic.css +1 -0
  126. package/ui/dist/index.html +2 -2
  127. package/ui/dist/assets/index-COunM-QN.css +0 -1
  128. 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. `task migrate` reproduces a legacy SQLite board in files, exactly.
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, migrateBoard, openBoard } from "./file-store.ts"
18
- import { parseTicket, serializeTicket, type TicketDoc } from "./ticket-doc.ts"
19
- import { TaskStore, findBoardsByPrefix, findScopeRoot } from "./store.ts"
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
- milestone: null,
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
- milestone: "v2 storage",
50
- needsHuman: true,
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
- milestone: null,
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
- "milestone: launch", // unquoted string — the YAML instinct
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.milestone, "launch")
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
- assert.deepEqual([a.number, b.number, c.number], [1, 2, 3])
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.number, { status: "todo" })
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].number, c.number)
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.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\]/)
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", "2", "ticket.md"), "utf8"),
135
+ readFileSync(join(root, ".task", "tickets", b.key, "ticket.md"), "utf8"),
126
136
  /blocked_by/,
127
137
  )
128
- store.unlink(b.number, "blocks", a.number)
129
- assert.deepEqual(store.get(a.number)!.blockedBy, [])
138
+ store.unlink(b.key, "blocks", a.key)
139
+ assert.deepEqual(store.get(a.key)!.blockedBy, [])
130
140
 
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/)
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.number, "first!\n\nwith **markdown**", "claude")
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.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")
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.number, second.id)
142
- assert.equal(store.comments(a.number).length, 1)
143
- assert.throws(() => store.deleteComment(a.number, "../../escape"), /no such comment/)
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.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)
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 picks the backend by what's on disk", () => {
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
- const legacy = openBoard(legacyRoot)
168
- assert.ok(legacy instanceof TaskStore)
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
- test("migrate reproduces a legacy board, byte-visible and loss-free", () => {
173
- const root = tempRoot()
174
- mkdirSync(join(root, ".task"))
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(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,
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.number, "blocked_by", old.number)
221
- store.addComment(old.number, "how it went", "nick")
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.number), /is todo/)
225
- store.update(old.number, { status: "done" })
226
- const archived = store.archive(old.number)
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.number), [keep.number])
231
- assert.equal(store.commentCounts().get(old.number), undefined)
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.number)!
220
+ const shown = store.get(old.key)!
234
221
  assert.equal(shown.archived, true)
235
- assert.deepEqual(shown.blocks, [keep.number])
236
- assert.equal(store.comments(old.number)[0].body, "how it went")
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.number, t.archived]), [[old.number, true]])
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.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/)
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.number, "blocked_by", old.number), /archived — run/)
247
- assert.deepEqual(store.update(keep.number, { blockedBy: [] }).blockedBy, [])
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
- // Numbers stay reserved — the next ticket never reuses an archived one.
250
- assert.equal(store.create({ title: "Next" }).number, 3)
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.number)
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.number).length, 1)
258
- assert.throws(() => store.unarchive(old.number), /isn't archived/)
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.number, "from session one", "claude")
284
- const commentsDir = join(root, ".task", "tickets", String(task.number), "comments")
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.number).length, 2)
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", String(task.number), "ticket.md")
346
+ const path = join(root, ".task", "tickets", task.key, "ticket.md")
297
347
  const before = readFileSync(path, "utf8").split("\n")
298
- store.update(task.number, { status: "in_progress", position: task.position })
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(