@nickmeriano/task 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/README.md +65 -22
  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 +3 -2
  7. package/dist/check.d.ts.map +1 -1
  8. package/dist/check.js +99 -60
  9. package/dist/check.js.map +1 -1
  10. package/dist/check.test.js +99 -60
  11. package/dist/check.test.js.map +1 -1
  12. package/dist/claim-io.d.ts +73 -0
  13. package/dist/claim-io.d.ts.map +1 -0
  14. package/dist/claim-io.js +344 -0
  15. package/dist/claim-io.js.map +1 -0
  16. package/dist/claim.d.ts +21 -22
  17. package/dist/claim.d.ts.map +1 -1
  18. package/dist/claim.js +58 -83
  19. package/dist/claim.js.map +1 -1
  20. package/dist/claim.test.d.ts +2 -2
  21. package/dist/claim.test.js +111 -94
  22. package/dist/claim.test.js.map +1 -1
  23. package/dist/cli.js +241 -64
  24. package/dist/cli.js.map +1 -1
  25. package/dist/file-store.d.ts +70 -24
  26. package/dist/file-store.d.ts.map +1 -1
  27. package/dist/file-store.js +319 -141
  28. package/dist/file-store.js.map +1 -1
  29. package/dist/git-serve.d.ts +183 -0
  30. package/dist/git-serve.d.ts.map +1 -0
  31. package/dist/git-serve.js +503 -0
  32. package/dist/git-serve.js.map +1 -0
  33. package/dist/git-serve.test.d.ts +16 -0
  34. package/dist/git-serve.test.d.ts.map +1 -0
  35. package/dist/git-serve.test.js +183 -0
  36. package/dist/git-serve.test.js.map +1 -0
  37. package/dist/git.d.ts +65 -0
  38. package/dist/git.d.ts.map +1 -0
  39. package/dist/git.js +114 -0
  40. package/dist/git.js.map +1 -0
  41. package/dist/id.d.ts +39 -0
  42. package/dist/id.d.ts.map +1 -0
  43. package/dist/id.js +67 -0
  44. package/dist/id.js.map +1 -0
  45. package/dist/inbox.d.ts +41 -0
  46. package/dist/inbox.d.ts.map +1 -0
  47. package/dist/inbox.js +56 -0
  48. package/dist/inbox.js.map +1 -0
  49. package/dist/overview.d.ts +9 -0
  50. package/dist/overview.d.ts.map +1 -1
  51. package/dist/overview.js +19 -11
  52. package/dist/overview.js.map +1 -1
  53. package/dist/overview.test.js +6 -5
  54. package/dist/overview.test.js.map +1 -1
  55. package/dist/promote.test.d.ts +3 -2
  56. package/dist/promote.test.d.ts.map +1 -1
  57. package/dist/promote.test.js +29 -31
  58. package/dist/promote.test.js.map +1 -1
  59. package/dist/publish.d.ts +2 -17
  60. package/dist/publish.d.ts.map +1 -1
  61. package/dist/publish.js +4 -49
  62. package/dist/publish.js.map +1 -1
  63. package/dist/search.d.ts +3 -1
  64. package/dist/search.d.ts.map +1 -1
  65. package/dist/search.js +15 -2
  66. package/dist/search.js.map +1 -1
  67. package/dist/search.test.js +9 -9
  68. package/dist/search.test.js.map +1 -1
  69. package/dist/server.d.ts.map +1 -1
  70. package/dist/server.js +218 -21
  71. package/dist/server.js.map +1 -1
  72. package/dist/store.d.ts +28 -14
  73. package/dist/store.d.ts.map +1 -1
  74. package/dist/store.js.map +1 -1
  75. package/dist/store.test.js +88 -75
  76. package/dist/store.test.js.map +1 -1
  77. package/dist/ticket-doc.d.ts +26 -10
  78. package/dist/ticket-doc.d.ts.map +1 -1
  79. package/dist/ticket-doc.js +71 -27
  80. package/dist/ticket-doc.js.map +1 -1
  81. package/dist/types.d.ts +51 -17
  82. package/dist/types.d.ts.map +1 -1
  83. package/dist/types.js.map +1 -1
  84. package/package.json +1 -1
  85. package/skill/SKILL.md +70 -23
  86. package/src/asks.test.ts +355 -0
  87. package/src/check.test.ts +129 -72
  88. package/src/check.ts +101 -58
  89. package/src/claim-io.ts +401 -0
  90. package/src/claim.test.ts +141 -99
  91. package/src/claim.ts +69 -96
  92. package/src/cli.ts +251 -63
  93. package/src/file-store.ts +357 -145
  94. package/src/git-serve.test.ts +240 -0
  95. package/src/git-serve.ts +595 -0
  96. package/src/git.ts +141 -0
  97. package/src/id.ts +68 -0
  98. package/src/inbox.ts +77 -0
  99. package/src/overview.test.ts +6 -5
  100. package/src/overview.ts +25 -10
  101. package/src/promote.test.ts +40 -28
  102. package/src/publish.ts +6 -53
  103. package/src/search.test.ts +11 -11
  104. package/src/search.ts +17 -3
  105. package/src/server.ts +221 -19
  106. package/src/store.test.ts +95 -77
  107. package/src/store.ts +28 -13
  108. package/src/ticket-doc.ts +93 -30
  109. package/src/types.ts +52 -17
  110. package/ui/dist/assets/index-BjsorZOU.js +229 -0
  111. package/ui/dist/assets/index-CoKCUYic.css +1 -0
  112. package/ui/dist/index.html +2 -2
  113. package/ui/dist/assets/index-BJmOsOdR.js +0 -229
  114. package/ui/dist/assets/index-BoqQlqSU.css +0 -1
package/src/claim.test.ts CHANGED
@@ -6,8 +6,8 @@
6
6
  * 1. Two concurrent `task claim <same-id>`: exactly one exits 0, the other
7
7
  * exits 1 — git's atomic ref creation is the lock.
8
8
  * 2. Validation failures exit 2 before anything touches git.
9
- * 3. `task list --claimable` is the queue: position order, minus blocked,
10
- * needs-human, and already-claimed tickets.
9
+ * 3. `task list --claimable` is the queue: position order, minus blocked
10
+ * and already-claimed tickets — open asks never exclude a ticket.
11
11
  * 4. `task claim --release` deletes the branch both sides and the ticket is
12
12
  * claimable again.
13
13
  */
@@ -61,20 +61,22 @@ function cli(cwd: string, ...args: string[]): Promise<CliResult> {
61
61
  interface Fixture {
62
62
  bare: string
63
63
  clone: () => string
64
+ /** Keys of the seeded tickets, by role. */
65
+ keys: { one: string; two: string; blocked: string; person: string; parked: string; jumper: string }
64
66
  }
65
67
 
66
68
  /**
67
69
  * A bare origin holding a board with the claimable shapes:
68
- * 1 "Ready one" todo
69
- * 2 "Ready two" todo
70
- * 3 "Blocked" todo, blocked by 1
71
- * 4 "For a person" todo, needs-human
72
- * 5 "Still parked" backlog
73
- * 6 "Jumped queue" todo, moved to the top of the column after creation —
74
- * claimable order must be [6, 1, 2], proving position
75
- * order beats number order.
70
+ * one "Ready one" todo
71
+ * two "Ready two" todo
72
+ * blocked "Blocked" todo, blocked by `one`
73
+ * person "For a person" todo, carrying an open ask
74
+ * parked "Still parked" backlog
75
+ * jumper "Jumped queue" todo, moved to the top of the column after
76
+ * creation — claimable order must be [jumper, one, two], proving
77
+ * position order beats creation order.
76
78
  */
77
- function fixture(claimPrefix?: string): Fixture {
79
+ function fixture(branchPrefix?: string): Fixture {
78
80
  const home = tempDir()
79
81
  const bare = join(home, "origin.git")
80
82
  sh(home, "git", "init", "--quiet", "--bare", "-b", "main", bare)
@@ -84,20 +86,29 @@ function fixture(claimPrefix?: string): Fixture {
84
86
  sh(seed, "git", "config", "user.name", "Test")
85
87
  sh(seed, "git", "config", "user.email", "test@example.com")
86
88
  const store = initProject(seed, { name: "claim board", prefix: "CLM" })
87
- if (claimPrefix !== undefined) {
89
+ if (branchPrefix !== undefined) {
88
90
  const configPath = join(seed, ".task", "config.json")
89
91
  const config = JSON.parse(readFileSync(configPath, "utf8")) as Record<string, unknown>
90
- config.claimPrefix = claimPrefix
92
+ config.claims = { branchPrefix }
91
93
  writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`)
92
94
  }
93
- store.create({ title: "Ready one", status: "todo" })
94
- store.create({ title: "Ready two", status: "todo" })
95
- store.create({ title: "Blocked", status: "todo", blockedBy: [1] })
96
- store.create({ title: "For a person", status: "todo", needsHuman: true })
97
- store.create({ title: "Still parked", status: "backlog" })
95
+ const one = store.create({ title: "Ready one", status: "todo" })
96
+ const two = store.create({ title: "Ready two", status: "todo" })
97
+ const blocked = store.create({ title: "Blocked", status: "todo", blockedBy: [one.key] })
98
+ const person = store.create({ title: "For a person", status: "todo" })
99
+ store.addAsk(person.key, "Approve the vendor contract", "claude")
100
+ const parked = store.create({ title: "Still parked", status: "backlog" })
98
101
  const jumper = store.create({ title: "Jumped queue", status: "backlog" })
99
102
  // A status move without an explicit slot lands on top of the new column.
100
- store.update(jumper.number, { status: "todo" })
103
+ store.update(jumper.key, { status: "todo" })
104
+ const keys = {
105
+ one: one.key,
106
+ two: two.key,
107
+ blocked: blocked.key,
108
+ person: person.key,
109
+ parked: parked.key,
110
+ jumper: jumper.key,
111
+ }
101
112
  sh(seed, "git", "add", "-A")
102
113
  sh(seed, "git", "commit", "--quiet", "-m", "board")
103
114
  sh(seed, "git", "push", "--quiet", "-u", "origin", "main")
@@ -110,15 +121,15 @@ function fixture(claimPrefix?: string): Fixture {
110
121
  sh(dir, "git", "config", "user.email", "test@example.com")
111
122
  return dir
112
123
  }
113
- return { bare, clone }
124
+ return { bare, clone, keys }
114
125
  }
115
126
 
116
127
  test("two concurrent claims of one ticket: exactly one wins", async () => {
117
- const { bare, clone } = fixture()
128
+ const { bare, clone, keys } = fixture()
118
129
  const a = clone()
119
130
  const b = clone()
120
131
 
121
- const [ra, rb] = await Promise.all([cli(a, "claim", "1"), cli(b, "claim", "1")])
132
+ const [ra, rb] = await Promise.all([cli(a, "claim", keys.one), cli(b, "claim", keys.one)])
122
133
  assert.deepEqual([ra.code, rb.code].sort(), [0, 1], `a: ${ra.stderr}\nb: ${rb.stderr}`)
123
134
 
124
135
  const winner = ra.code === 0 ? a : b
@@ -126,123 +137,145 @@ test("two concurrent claims of one ticket: exactly one wins", async () => {
126
137
  assert.match(loser.stderr, /already claimed|rejected/)
127
138
 
128
139
  // The winner is left on the claim branch, flip committed and pushed.
129
- assert.equal(sh(winner, "git", "rev-parse", "--abbrev-ref", "HEAD"), "task/claim/clm-1")
140
+ const branch = `task/claim/clm-${keys.one}`
141
+ assert.equal(sh(winner, "git", "rev-parse", "--abbrev-ref", "HEAD"), branch)
130
142
  assert.match(
131
- readFileSync(join(winner, ".task", "tickets", "1", "ticket.md"), "utf8"),
143
+ readFileSync(join(winner, ".task", "tickets", keys.one, "ticket.md"), "utf8"),
132
144
  /status: in_progress/,
133
145
  )
134
- assert.match(
135
- sh(winner, "git", "ls-remote", "--heads", "origin", "task/claim/clm-1"),
136
- /refs\/heads\/task\/claim\/clm-1/,
137
- )
146
+ assert.ok(sh(winner, "git", "ls-remote", "--heads", "origin", branch).includes(`refs/heads/${branch}`))
138
147
  // Origin's default branch never saw the flip — the claim rides the branch.
139
148
  assert.equal(sh(winner, "git", "diff", "--stat", "origin/main..HEAD", "--", "README.md"), "")
140
- assert.match(sh(winner, "git", "show", "origin/main:.task/tickets/1/ticket.md"), /status: todo/)
149
+ assert.match(
150
+ sh(winner, "git", "show", `origin/main:.task/tickets/${keys.one}/ticket.md`),
151
+ /status: todo/,
152
+ )
141
153
  void bare
142
154
  })
143
155
 
144
156
  test("unclaimable tickets exit 2, before anything touches origin", async () => {
145
- const { clone } = fixture()
157
+ const { clone, keys } = fixture()
146
158
  const dir = clone()
147
159
 
148
- assert.equal((await cli(dir, "claim", "5")).code, 2) // backlog
149
- assert.equal((await cli(dir, "claim", "3")).code, 2) // blocked
150
- assert.equal((await cli(dir, "claim", "4")).code, 2) // needs a human
151
- assert.equal((await cli(dir, "claim", "99")).code, 2) // missing
152
- assert.match((await cli(dir, "claim", "3")).stderr, /blocked by CLM-1/)
160
+ assert.equal((await cli(dir, "claim", keys.parked)).code, 2) // backlog
161
+ assert.equal((await cli(dir, "claim", keys.blocked)).code, 2) // blocked
162
+ assert.equal((await cli(dir, "claim", "zz9zz")).code, 2) // missing
163
+ assert.match(
164
+ (await cli(dir, "claim", keys.blocked)).stderr,
165
+ new RegExp(`blocked by CLM-${keys.one}`),
166
+ )
153
167
 
154
168
  // A dirty tree refuses too — claiming switches branches. Only *tracked*
155
169
  // modifications count: untracked files ride a branch switch untouched
156
170
  // (release deliberately leaves one — the reason comment).
157
171
  const gitignore = join(dir, ".task", ".gitignore")
158
172
  writeFileSync(gitignore, `${readFileSync(gitignore, "utf8")}\nwip\n`)
159
- const dirty = await cli(dir, "claim", "2")
173
+ const dirty = await cli(dir, "claim", keys.two)
160
174
  assert.equal(dirty.code, 2)
161
175
  assert.match(dirty.stderr, /working tree is dirty/)
162
176
  sh(dir, "git", "checkout", "--", ".task/.gitignore")
163
177
  writeFileSync(join(dir, "scratch.txt"), "untracked is fine")
164
- assert.equal((await cli(dir, "claim", "2")).code, 0)
178
+ assert.equal((await cli(dir, "claim", keys.two)).code, 0)
165
179
 
166
180
  // Nothing above created a stray claim branch for the failed ids.
167
181
  const remote = sh(dir, "git", "ls-remote", "--heads", "origin", "task/claim/*")
168
182
  assert.deepEqual(
169
183
  remote.split("\n").map((l) => l.split("refs/heads/")[1]),
170
- ["task/claim/clm-2"],
184
+ [`task/claim/clm-${keys.two}`],
171
185
  )
172
186
  })
173
187
 
174
188
  test("list --claimable is the queue: position order, minus everything unclaimable", async () => {
175
- const { clone } = fixture()
189
+ const { clone, keys } = fixture()
176
190
  const a = clone()
177
- assert.equal((await cli(a, "claim", "6")).code, 0)
191
+ assert.equal((await cli(a, "claim", keys.jumper)).code, 0)
178
192
 
179
193
  const b = clone()
180
194
  const listed = await cli(b, "list", "--claimable", "--json")
181
195
  assert.equal(listed.code, 0)
182
- const { tasks } = JSON.parse(listed.stdout) as { tasks: { number: number }[] }
183
- // 6 claimed, 3 blocked, 4 needs-human, 5 not todo — position order keeps
184
- // 1 before 2 (creation order within the column).
185
- assert.deepEqual(tasks.map((t) => t.number), [1, 2])
196
+ const { tasks } = JSON.parse(listed.stdout) as { tasks: { key: string }[] }
197
+ // jumper claimed, blocked blocked, parked not todo — position order keeps
198
+ // one before two (creation order within the column). person carries an open
199
+ // ask and is claimable anyway: asks gate done, not claim.
200
+ assert.deepEqual(tasks.map((t) => t.key), [keys.one, keys.two, keys.person])
186
201
 
187
202
  assert.equal((await cli(b, "list", "--claimable", "--status", "todo")).code, 1)
188
203
  })
189
204
 
190
205
  test("release deletes the branch everywhere and reopens the claim", async () => {
191
- const { clone } = fixture()
206
+ const { clone, keys } = fixture()
192
207
  const dir = clone()
193
- assert.equal((await cli(dir, "claim", "2")).code, 0)
194
- assert.equal((await cli(dir, "claim", "2")).code, 1) // locally claimed too
208
+ assert.equal((await cli(dir, "claim", keys.two)).code, 0)
209
+ assert.equal((await cli(dir, "claim", keys.two)).code, 1) // locally claimed too
195
210
 
196
- const released = await cli(dir, "claim", "--release", "2", "--comment", "trying another shape")
211
+ const released = await cli(dir, "claim", "--release", keys.two, "--comment", "trying another shape")
197
212
  assert.equal(released.code, 0, released.stderr)
198
- assert.match(released.stdout, /released CLM-2/)
213
+ assert.ok(released.stdout.includes(`released CLM-${keys.two}`), released.stdout)
199
214
 
200
215
  // Back on the default branch, branch gone on both sides, ticket todo again.
201
216
  assert.equal(sh(dir, "git", "rev-parse", "--abbrev-ref", "HEAD"), "main")
202
- assert.equal(sh(dir, "git", "ls-remote", "--heads", "origin", "task/claim/clm-2"), "")
217
+ assert.equal(sh(dir, "git", "ls-remote", "--heads", "origin", `task/claim/clm-${keys.two}`), "")
203
218
  assert.match(
204
- readFileSync(join(dir, ".task", "tickets", "2", "ticket.md"), "utf8"),
219
+ readFileSync(join(dir, ".task", "tickets", keys.two, "ticket.md"), "utf8"),
205
220
  /status: todo/,
206
221
  )
207
- assert.equal((await cli(dir, "claim", "2")).code, 0)
222
+ assert.equal((await cli(dir, "claim", keys.two)).code, 0)
208
223
 
209
224
  // Releasing an unclaimed ticket is a quiet no-op, not an error.
210
- const noop = await cli(dir, "claim", "--release", "1", "--comment", "noop")
225
+ const noop = await cli(dir, "claim", "--release", keys.one, "--comment", "noop")
211
226
  assert.equal(noop.code, 0)
212
227
  assert.match(noop.stdout, /wasn't claimed/)
213
228
  })
214
229
 
215
- test("a configured claimPrefix moves the whole namespace", async () => {
216
- const { clone } = fixture("claude/task/")
230
+ test("a configured claims.branchPrefix moves the whole namespace", async () => {
231
+ const { clone, keys } = fixture("claude/task/")
217
232
  const dir = clone()
218
233
 
219
- assert.equal((await cli(dir, "claim", "1")).code, 0)
220
- assert.equal(sh(dir, "git", "rev-parse", "--abbrev-ref", "HEAD"), "claude/task/clm-1")
221
- assert.match(
222
- sh(dir, "git", "ls-remote", "--heads", "origin", "claude/task/*"),
223
- /refs\/heads\/claude\/task\/clm-1/,
234
+ assert.equal((await cli(dir, "claim", keys.one)).code, 0)
235
+ assert.equal(sh(dir, "git", "rev-parse", "--abbrev-ref", "HEAD"), `claude/task/clm-${keys.one}`)
236
+ assert.ok(
237
+ sh(dir, "git", "ls-remote", "--heads", "origin", "claude/task/*").includes(
238
+ `refs/heads/claude/task/clm-${keys.one}`,
239
+ ),
224
240
  )
225
241
 
226
242
  // Every side of the feature reads the same key: the queue looks for claims
227
243
  // under the configured namespace, and release deletes there too.
228
244
  const other = clone()
229
245
  const listed = await cli(other, "list", "--claimable", "--json")
230
- const { tasks } = JSON.parse(listed.stdout) as { tasks: { number: number }[] }
231
- assert.deepEqual(tasks.map((t) => t.number), [6, 2])
232
- assert.equal((await cli(other, "claim", "1")).code, 1)
233
- assert.equal((await cli(dir, "claim", "--release", "1", "--comment", "handing it back")).code, 0)
246
+ const { tasks } = JSON.parse(listed.stdout) as { tasks: { key: string }[] }
247
+ assert.deepEqual(tasks.map((t) => t.key), [keys.jumper, keys.two, keys.person])
248
+ assert.equal((await cli(other, "claim", keys.one)).code, 1)
249
+ assert.equal(
250
+ (await cli(dir, "claim", "--release", keys.one, "--comment", "handing it back")).code,
251
+ 0,
252
+ )
234
253
  assert.equal(sh(dir, "git", "ls-remote", "--heads", "origin", "claude/task/*"), "")
235
254
 
236
255
  // A missing trailing slash is forgiven; a malformed value fails loudly.
237
256
  const bad = clone()
238
257
  const configPath = join(bad, ".task", "config.json")
239
258
  const config = JSON.parse(readFileSync(configPath, "utf8")) as Record<string, unknown>
240
- config.claimPrefix = "spaces are not refs/"
259
+ config.claims = { branchPrefix: "spaces are not refs/" }
241
260
  writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`)
242
261
  sh(bad, "git", "commit", "--quiet", "-am", "bad prefix")
243
- const invalid = await cli(bad, "claim", "2")
262
+ const invalid = await cli(bad, "claim", keys.two)
244
263
  assert.equal(invalid.code, 2)
245
264
  assert.match(invalid.stderr, /invalid claims\.branchPrefix/)
265
+
266
+ // The retired pre-0.8 `claimPrefix` spelling is not honored (TAS-41): a
267
+ // board carrying only it refuses by name rather than silently defaulting
268
+ // the lock convention out from under its workers.
269
+ const retired = clone()
270
+ const retiredPath = join(retired, ".task", "config.json")
271
+ const retiredConfig = JSON.parse(readFileSync(retiredPath, "utf8")) as Record<string, unknown>
272
+ delete retiredConfig.claims
273
+ retiredConfig.claimPrefix = "claude/task/"
274
+ writeFileSync(retiredPath, `${JSON.stringify(retiredConfig, null, 2)}\n`)
275
+ sh(retired, "git", "commit", "--quiet", "-am", "retired key")
276
+ const refused = await cli(retired, "claim", keys.two)
277
+ assert.equal(refused.code, 2)
278
+ assert.match(refused.stderr, /pre-0\.8 spelling and is no longer read/)
246
279
  })
247
280
 
248
281
  /**
@@ -252,7 +285,14 @@ test("a configured claimPrefix moves the whole namespace", async () => {
252
285
  * optionally, the top-level `boards` list — NES deliberately ahead of ROO,
253
286
  * so priority provably follows list order, not nearness.
254
287
  */
255
- function multiFixture(options: { boards?: string[]; maxOpenCount?: number } = {}): Fixture {
288
+ interface MultiFixture {
289
+ bare: string
290
+ clone: () => string
291
+ rootKey: string
292
+ nestedKey: string
293
+ }
294
+
295
+ function multiFixture(options: { boards?: string[]; maxOpenCount?: number } = {}): MultiFixture {
256
296
  const home = tempDir()
257
297
  const bare = join(home, "origin.git")
258
298
  sh(home, "git", "init", "--quiet", "--bare", "-b", "main", bare)
@@ -268,11 +308,11 @@ function multiFixture(options: { boards?: string[]; maxOpenCount?: number } = {}
268
308
  if (options.boards) config.boards = options.boards
269
309
  config.claims = { branchPrefix: "task/claim/", maxOpenCount: options.maxOpenCount }
270
310
  writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`)
271
- root.create({ title: "Root ready", status: "todo" })
311
+ const rootKey = root.create({ title: "Root ready", status: "todo" }).key
272
312
 
273
313
  mkdirSync(join(seed, "pkg"), { recursive: true })
274
314
  const nested = initProject(join(seed, "pkg"), { name: "nested board", prefix: "NES" })
275
- nested.create({ title: "Nested ready", status: "todo" })
315
+ const nestedKey = nested.create({ title: "Nested ready", status: "todo" }).key
276
316
 
277
317
  sh(seed, "git", "add", "-A")
278
318
  sh(seed, "git", "commit", "--quiet", "-m", "boards")
@@ -286,14 +326,14 @@ function multiFixture(options: { boards?: string[]; maxOpenCount?: number } = {}
286
326
  sh(dir, "git", "config", "user.email", "test@example.com")
287
327
  return dir
288
328
  }
289
- return { bare, clone }
329
+ return { bare, clone, rootKey, nestedKey }
290
330
  }
291
331
 
292
332
  test("claim --next drains the queue in position order and exits 1 when empty", async () => {
293
- const { clone } = fixture()
333
+ const { clone, keys } = fixture()
294
334
  const dir = clone()
295
335
 
296
- const order: number[] = []
336
+ const order: string[] = []
297
337
  for (;;) {
298
338
  const result = await cli(dir, "claim", "--next", "--json")
299
339
  if (result.code === 1) {
@@ -301,13 +341,14 @@ test("claim --next drains the queue in position order and exits 1 when empty", a
301
341
  break
302
342
  }
303
343
  assert.equal(result.code, 0, result.stderr)
304
- const { task, branch } = JSON.parse(result.stdout) as { task: { number: number }; branch: string }
305
- order.push(task.number)
344
+ const { task, branch } = JSON.parse(result.stdout) as { task: { key: string }; branch: string }
345
+ order.push(task.key)
306
346
  assert.equal(sh(dir, "git", "rev-parse", "--abbrev-ref", "HEAD"), branch)
307
347
  }
308
- // The jumper leads (position order), then creation order; blocked,
309
- // needs-human and backlog tickets never surface.
310
- assert.deepEqual(order, [6, 1, 2])
348
+ // The jumper leads (position order), then creation order; blocked and
349
+ // backlog tickets never surface, while the open ask on `person` doesn't
350
+ // keep it out of the queue.
351
+ assert.deepEqual(order, [keys.jumper, keys.one, keys.two, keys.person])
311
352
  })
312
353
 
313
354
  test("two concurrent claim --next calls land on different tickets", async () => {
@@ -326,27 +367,27 @@ test("two concurrent claim --next calls land on different tickets", async () =>
326
367
  })
327
368
 
328
369
  test("--next walks the root boards list in priority order; --board overrides it", async () => {
329
- const { clone } = multiFixture({ boards: ["NES", "ROO"] })
370
+ const { clone, rootKey, nestedKey } = multiFixture({ boards: ["NES", "ROO"] })
330
371
  const a = clone()
331
372
 
332
373
  // list --claimable reads the same scope, in the same order.
333
374
  const listed = await cli(a, "list", "--claimable", "--json")
334
375
  assert.equal(listed.code, 0, listed.stderr)
335
376
  const { tasks } = JSON.parse(listed.stdout) as { tasks: { id: string }[] }
336
- assert.deepEqual(tasks.map((t) => t.id), ["NES-1", "ROO-1"])
377
+ assert.deepEqual(tasks.map((t) => t.id), [`NES-${nestedKey}`, `ROO-${rootKey}`])
337
378
 
338
379
  // The nested board wins despite the root board being nearest.
339
380
  const next = await cli(a, "claim", "--next", "--json")
340
381
  assert.equal(next.code, 0, next.stderr)
341
382
  const claimed = JSON.parse(next.stdout) as { task: { id: string }; branch: string }
342
- assert.equal(claimed.task.id, "NES-1")
343
- assert.equal(claimed.branch, "task/claim/nes-1")
383
+ assert.equal(claimed.task.id, `NES-${nestedKey}`)
384
+ assert.equal(claimed.branch, `task/claim/nes-${nestedKey}`)
344
385
 
345
386
  // An explicit --board list is the whole scope, in the given order.
346
387
  const b = clone()
347
388
  const overridden = await cli(b, "claim", "--next", "--board", "ROO", "--json")
348
389
  assert.equal(overridden.code, 0, overridden.stderr)
349
- assert.equal((JSON.parse(overridden.stdout) as { task: { id: string } }).task.id, "ROO-1")
390
+ assert.equal((JSON.parse(overridden.stdout) as { task: { id: string } }).task.id, `ROO-${rootKey}`)
350
391
 
351
392
  const unknown = await cli(b, "claim", "--next", "--board", "NOPE")
352
393
  assert.equal(unknown.code, 2)
@@ -354,12 +395,12 @@ test("--next walks the root boards list in priority order; --board overrides it"
354
395
  })
355
396
 
356
397
  test("without a boards list, --next sees the nearest board only", async () => {
357
- const { clone } = multiFixture()
398
+ const { clone, rootKey } = multiFixture()
358
399
  const dir = clone()
359
400
 
360
401
  const next = await cli(dir, "claim", "--next", "--json")
361
402
  assert.equal(next.code, 0, next.stderr)
362
- assert.equal((JSON.parse(next.stdout) as { task: { id: string } }).task.id, "ROO-1")
403
+ assert.equal((JSON.parse(next.stdout) as { task: { id: string } }).task.id, `ROO-${rootKey}`)
363
404
 
364
405
  // ROO's queue is drained and NES never entered the scope.
365
406
  const empty = await cli(dir, "claim", "--next", "--json")
@@ -367,7 +408,7 @@ test("without a boards list, --next sees the nearest board only", async () => {
367
408
  })
368
409
 
369
410
  test("maxOpenCount caps every claim repo-wide; --force is the escape", async () => {
370
- const { clone } = multiFixture({ boards: ["NES", "ROO"], maxOpenCount: 1 })
411
+ const { clone, rootKey } = multiFixture({ boards: ["NES", "ROO"], maxOpenCount: 1 })
371
412
  const a = clone()
372
413
  assert.equal((await cli(a, "claim", "--next", "--json")).code, 0)
373
414
 
@@ -377,34 +418,35 @@ test("maxOpenCount caps every claim repo-wide; --force is the escape", async ()
377
418
  const capped = await cli(b, "claim", "--next")
378
419
  assert.equal(capped.code, 2)
379
420
  assert.match(capped.stderr, /maxOpenCount cap \(1\)/)
380
- const explicit = await cli(b, "claim", "ROO-1")
421
+ const explicit = await cli(b, "claim", `ROO-${rootKey}`)
381
422
  assert.equal(explicit.code, 2)
382
423
  assert.match(explicit.stderr, /maxOpenCount cap \(1\)/)
383
424
 
384
425
  const forced = await cli(b, "claim", "--next", "--force", "--json")
385
426
  assert.equal(forced.code, 0, forced.stderr)
386
- assert.equal((JSON.parse(forced.stdout) as { task: { id: string } }).task.id, "ROO-1")
427
+ assert.equal((JSON.parse(forced.stdout) as { task: { id: string } }).task.id, `ROO-${rootKey}`)
387
428
  })
388
429
 
389
430
  test("release requires a reason and leaves it as a comment on the ticket", async () => {
390
- const { clone } = fixture()
431
+ const { clone, keys } = fixture()
391
432
  const dir = clone()
392
- assert.equal((await cli(dir, "claim", "1")).code, 0)
433
+ assert.equal((await cli(dir, "claim", keys.one)).code, 0)
393
434
 
394
435
  // No comment, and an empty one, are both refused before anything happens.
395
- const missing = await cli(dir, "claim", "--release", "1")
436
+ const missing = await cli(dir, "claim", "--release", keys.one)
396
437
  assert.equal(missing.code, 1)
397
438
  assert.match(missing.stderr, /requires --comment/)
398
- const empty = await cli(dir, "claim", "--release", "1", "--comment", "")
439
+ const empty = await cli(dir, "claim", "--release", keys.one, "--comment", "")
399
440
  assert.equal(empty.code, 1)
400
441
  assert.match(empty.stderr, /requires --comment/)
401
- assert.match(
402
- sh(dir, "git", "ls-remote", "--heads", "origin", "task/claim/clm-1"),
403
- /clm-1/,
442
+ assert.ok(
443
+ sh(dir, "git", "ls-remote", "--heads", "origin", `task/claim/clm-${keys.one}`).includes(
444
+ `clm-${keys.one}`,
445
+ ),
404
446
  )
405
447
 
406
448
  const released = await cli(
407
- dir, "claim", "--release", "1",
449
+ dir, "claim", "--release", keys.one,
408
450
  "--comment", "the migration kept flaking — needs a fixture, not a retry",
409
451
  "--author", "claude",
410
452
  )
@@ -412,19 +454,19 @@ test("release requires a reason and leaves it as a comment on the ticket", async
412
454
 
413
455
  // The reason survives the branch: an ordinary attributed comment, sitting
414
456
  // uncommitted in the working tree on the default branch.
415
- const shown = await cli(dir, "show", "1", "--json")
457
+ const shown = await cli(dir, "show", keys.one, "--json")
416
458
  const { comments } = JSON.parse(shown.stdout) as {
417
459
  comments: { author: string; body: string }[]
418
460
  }
419
461
  assert.equal(comments.length, 1)
420
462
  assert.equal(comments[0].author, "claude")
421
463
  assert.match(comments[0].body, /kept flaking/)
422
- assert.match(sh(dir, "git", "status", "--porcelain"), /tickets\/1\/comments/)
464
+ assert.ok(sh(dir, "git", "status", "--porcelain").includes(`tickets/${keys.one}/comments`))
423
465
 
424
466
  // Releasing an unclaimed ticket stays a no-op — no second comment.
425
- const noop = await cli(dir, "claim", "--release", "1", "--comment", "again")
467
+ const noop = await cli(dir, "claim", "--release", keys.one, "--comment", "again")
426
468
  assert.equal(noop.code, 0)
427
469
  assert.match(noop.stdout, /wasn't claimed/)
428
- const after = await cli(dir, "show", "1", "--json")
470
+ const after = await cli(dir, "show", keys.one, "--json")
429
471
  assert.equal((JSON.parse(after.stdout) as { comments: unknown[] }).comments.length, 1)
430
472
  })