@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/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
  */
@@ -15,7 +15,7 @@
15
15
  import assert from "node:assert/strict"
16
16
  import { test } from "node:test"
17
17
  import { execFile, spawnSync } from "node:child_process"
18
- import { mkdtempSync, rmSync, writeFileSync } from "node:fs"
18
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"
19
19
  import { readFileSync } from "node:fs"
20
20
  import { tmpdir } from "node:os"
21
21
  import { join } from "node:path"
@@ -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 "Not groomed" 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" })
94
- store.create({ title: "Ready two" })
95
- store.create({ title: "Blocked", blockedBy: [1] })
96
- store.create({ title: "For a person", needsHuman: true })
97
- store.create({ title: "Not groomed", 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,117 +137,336 @@ 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
- // A dirty tree refuses too — claiming switches branches.
155
- writeFileSync(join(dir, "scratch.txt"), "wip")
156
- const dirty = await cli(dir, "claim", "2")
168
+ // A dirty tree refuses too — claiming switches branches. Only *tracked*
169
+ // modifications count: untracked files ride a branch switch untouched
170
+ // (release deliberately leaves one the reason comment).
171
+ const gitignore = join(dir, ".task", ".gitignore")
172
+ writeFileSync(gitignore, `${readFileSync(gitignore, "utf8")}\nwip\n`)
173
+ const dirty = await cli(dir, "claim", keys.two)
157
174
  assert.equal(dirty.code, 2)
158
175
  assert.match(dirty.stderr, /working tree is dirty/)
159
- rmSync(join(dir, "scratch.txt"))
160
- assert.equal((await cli(dir, "claim", "2")).code, 0)
176
+ sh(dir, "git", "checkout", "--", ".task/.gitignore")
177
+ writeFileSync(join(dir, "scratch.txt"), "untracked is fine")
178
+ assert.equal((await cli(dir, "claim", keys.two)).code, 0)
161
179
 
162
180
  // Nothing above created a stray claim branch for the failed ids.
163
181
  const remote = sh(dir, "git", "ls-remote", "--heads", "origin", "task/claim/*")
164
182
  assert.deepEqual(
165
183
  remote.split("\n").map((l) => l.split("refs/heads/")[1]),
166
- ["task/claim/clm-2"],
184
+ [`task/claim/clm-${keys.two}`],
167
185
  )
168
186
  })
169
187
 
170
188
  test("list --claimable is the queue: position order, minus everything unclaimable", async () => {
171
- const { clone } = fixture()
189
+ const { clone, keys } = fixture()
172
190
  const a = clone()
173
- assert.equal((await cli(a, "claim", "6")).code, 0)
191
+ assert.equal((await cli(a, "claim", keys.jumper)).code, 0)
174
192
 
175
193
  const b = clone()
176
194
  const listed = await cli(b, "list", "--claimable", "--json")
177
195
  assert.equal(listed.code, 0)
178
- const { tasks } = JSON.parse(listed.stdout) as { tasks: { number: number }[] }
179
- // 6 claimed, 3 blocked, 4 needs-human, 5 not todo — position order keeps
180
- // 1 before 2 (creation order within the column).
181
- 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])
182
201
 
183
202
  assert.equal((await cli(b, "list", "--claimable", "--status", "todo")).code, 1)
184
203
  })
185
204
 
186
205
  test("release deletes the branch everywhere and reopens the claim", async () => {
187
- const { clone } = fixture()
206
+ const { clone, keys } = fixture()
188
207
  const dir = clone()
189
- assert.equal((await cli(dir, "claim", "2")).code, 0)
190
- 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
191
210
 
192
- const released = await cli(dir, "claim", "--release", "2")
211
+ const released = await cli(dir, "claim", "--release", keys.two, "--comment", "trying another shape")
193
212
  assert.equal(released.code, 0, released.stderr)
194
- assert.match(released.stdout, /released CLM-2/)
213
+ assert.ok(released.stdout.includes(`released CLM-${keys.two}`), released.stdout)
195
214
 
196
215
  // Back on the default branch, branch gone on both sides, ticket todo again.
197
216
  assert.equal(sh(dir, "git", "rev-parse", "--abbrev-ref", "HEAD"), "main")
198
- 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}`), "")
199
218
  assert.match(
200
- readFileSync(join(dir, ".task", "tickets", "2", "ticket.md"), "utf8"),
219
+ readFileSync(join(dir, ".task", "tickets", keys.two, "ticket.md"), "utf8"),
201
220
  /status: todo/,
202
221
  )
203
- assert.equal((await cli(dir, "claim", "2")).code, 0)
222
+ assert.equal((await cli(dir, "claim", keys.two)).code, 0)
204
223
 
205
224
  // Releasing an unclaimed ticket is a quiet no-op, not an error.
206
- const noop = await cli(dir, "claim", "--release", "1")
225
+ const noop = await cli(dir, "claim", "--release", keys.one, "--comment", "noop")
207
226
  assert.equal(noop.code, 0)
208
227
  assert.match(noop.stdout, /wasn't claimed/)
209
228
  })
210
229
 
211
- test("a configured claimPrefix moves the whole namespace", async () => {
212
- const { clone } = fixture("claude/task/")
230
+ test("a configured claims.branchPrefix moves the whole namespace", async () => {
231
+ const { clone, keys } = fixture("claude/task/")
213
232
  const dir = clone()
214
233
 
215
- assert.equal((await cli(dir, "claim", "1")).code, 0)
216
- assert.equal(sh(dir, "git", "rev-parse", "--abbrev-ref", "HEAD"), "claude/task/clm-1")
217
- assert.match(
218
- sh(dir, "git", "ls-remote", "--heads", "origin", "claude/task/*"),
219
- /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
+ ),
220
240
  )
221
241
 
222
242
  // Every side of the feature reads the same key: the queue looks for claims
223
243
  // under the configured namespace, and release deletes there too.
224
244
  const other = clone()
225
245
  const listed = await cli(other, "list", "--claimable", "--json")
226
- const { tasks } = JSON.parse(listed.stdout) as { tasks: { number: number }[] }
227
- assert.deepEqual(tasks.map((t) => t.number), [6, 2])
228
- assert.equal((await cli(other, "claim", "1")).code, 1)
229
- assert.equal((await cli(dir, "claim", "--release", "1")).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
+ )
230
253
  assert.equal(sh(dir, "git", "ls-remote", "--heads", "origin", "claude/task/*"), "")
231
254
 
232
255
  // A missing trailing slash is forgiven; a malformed value fails loudly.
233
256
  const bad = clone()
234
257
  const configPath = join(bad, ".task", "config.json")
235
258
  const config = JSON.parse(readFileSync(configPath, "utf8")) as Record<string, unknown>
236
- config.claimPrefix = "spaces are not refs/"
259
+ config.claims = { branchPrefix: "spaces are not refs/" }
237
260
  writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`)
238
261
  sh(bad, "git", "commit", "--quiet", "-am", "bad prefix")
239
- const invalid = await cli(bad, "claim", "2")
262
+ const invalid = await cli(bad, "claim", keys.two)
240
263
  assert.equal(invalid.code, 2)
241
- assert.match(invalid.stderr, /invalid claimPrefix/)
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/)
279
+ })
280
+
281
+ /**
282
+ * A monorepo with two boards for the multi-board selection paths (TAS-27):
283
+ * a root board (ROO, one ready ticket) and a nested one under pkg/ (NES,
284
+ * one ready ticket). The root config carries the new `claims` block and,
285
+ * optionally, the top-level `boards` list — NES deliberately ahead of ROO,
286
+ * so priority provably follows list order, not nearness.
287
+ */
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 {
296
+ const home = tempDir()
297
+ const bare = join(home, "origin.git")
298
+ sh(home, "git", "init", "--quiet", "--bare", "-b", "main", bare)
299
+
300
+ const seed = join(home, "seed")
301
+ sh(home, "git", "clone", "--quiet", bare, seed)
302
+ sh(seed, "git", "config", "user.name", "Test")
303
+ sh(seed, "git", "config", "user.email", "test@example.com")
304
+
305
+ const root = initProject(seed, { name: "root board", prefix: "ROO" })
306
+ const configPath = join(seed, ".task", "config.json")
307
+ const config = JSON.parse(readFileSync(configPath, "utf8")) as Record<string, unknown>
308
+ if (options.boards) config.boards = options.boards
309
+ config.claims = { branchPrefix: "task/claim/", maxOpenCount: options.maxOpenCount }
310
+ writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`)
311
+ const rootKey = root.create({ title: "Root ready", status: "todo" }).key
312
+
313
+ mkdirSync(join(seed, "pkg"), { recursive: true })
314
+ const nested = initProject(join(seed, "pkg"), { name: "nested board", prefix: "NES" })
315
+ const nestedKey = nested.create({ title: "Nested ready", status: "todo" }).key
316
+
317
+ sh(seed, "git", "add", "-A")
318
+ sh(seed, "git", "commit", "--quiet", "-m", "boards")
319
+ sh(seed, "git", "push", "--quiet", "-u", "origin", "main")
320
+
321
+ let clones = 0
322
+ const clone = (): string => {
323
+ const dir = join(home, `clone-${++clones}`)
324
+ sh(home, "git", "clone", "--quiet", bare, dir)
325
+ sh(dir, "git", "config", "user.name", "Test")
326
+ sh(dir, "git", "config", "user.email", "test@example.com")
327
+ return dir
328
+ }
329
+ return { bare, clone, rootKey, nestedKey }
330
+ }
331
+
332
+ test("claim --next drains the queue in position order and exits 1 when empty", async () => {
333
+ const { clone, keys } = fixture()
334
+ const dir = clone()
335
+
336
+ const order: string[] = []
337
+ for (;;) {
338
+ const result = await cli(dir, "claim", "--next", "--json")
339
+ if (result.code === 1) {
340
+ assert.equal(JSON.parse(result.stdout).task, null)
341
+ break
342
+ }
343
+ assert.equal(result.code, 0, result.stderr)
344
+ const { task, branch } = JSON.parse(result.stdout) as { task: { key: string }; branch: string }
345
+ order.push(task.key)
346
+ assert.equal(sh(dir, "git", "rev-parse", "--abbrev-ref", "HEAD"), branch)
347
+ }
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])
352
+ })
353
+
354
+ test("two concurrent claim --next calls land on different tickets", async () => {
355
+ const { clone } = fixture()
356
+ const a = clone()
357
+ const b = clone()
358
+
359
+ const [ra, rb] = await Promise.all([
360
+ cli(a, "claim", "--next", "--json"),
361
+ cli(b, "claim", "--next", "--json"),
362
+ ])
363
+ assert.equal(ra.code, 0, ra.stderr)
364
+ assert.equal(rb.code, 0, rb.stderr)
365
+ const ids = [ra, rb].map((r) => (JSON.parse(r.stdout) as { task: { id: string } }).task.id)
366
+ assert.notEqual(ids[0], ids[1], `both claimed ${ids[0]}`)
367
+ })
368
+
369
+ test("--next walks the root boards list in priority order; --board overrides it", async () => {
370
+ const { clone, rootKey, nestedKey } = multiFixture({ boards: ["NES", "ROO"] })
371
+ const a = clone()
372
+
373
+ // list --claimable reads the same scope, in the same order.
374
+ const listed = await cli(a, "list", "--claimable", "--json")
375
+ assert.equal(listed.code, 0, listed.stderr)
376
+ const { tasks } = JSON.parse(listed.stdout) as { tasks: { id: string }[] }
377
+ assert.deepEqual(tasks.map((t) => t.id), [`NES-${nestedKey}`, `ROO-${rootKey}`])
378
+
379
+ // The nested board wins despite the root board being nearest.
380
+ const next = await cli(a, "claim", "--next", "--json")
381
+ assert.equal(next.code, 0, next.stderr)
382
+ const claimed = JSON.parse(next.stdout) as { task: { id: string }; branch: string }
383
+ assert.equal(claimed.task.id, `NES-${nestedKey}`)
384
+ assert.equal(claimed.branch, `task/claim/nes-${nestedKey}`)
385
+
386
+ // An explicit --board list is the whole scope, in the given order.
387
+ const b = clone()
388
+ const overridden = await cli(b, "claim", "--next", "--board", "ROO", "--json")
389
+ assert.equal(overridden.code, 0, overridden.stderr)
390
+ assert.equal((JSON.parse(overridden.stdout) as { task: { id: string } }).task.id, `ROO-${rootKey}`)
391
+
392
+ const unknown = await cli(b, "claim", "--next", "--board", "NOPE")
393
+ assert.equal(unknown.code, 2)
394
+ assert.match(unknown.stderr, /no board with prefix NOPE/)
395
+ })
396
+
397
+ test("without a boards list, --next sees the nearest board only", async () => {
398
+ const { clone, rootKey } = multiFixture()
399
+ const dir = clone()
400
+
401
+ const next = await cli(dir, "claim", "--next", "--json")
402
+ assert.equal(next.code, 0, next.stderr)
403
+ assert.equal((JSON.parse(next.stdout) as { task: { id: string } }).task.id, `ROO-${rootKey}`)
404
+
405
+ // ROO's queue is drained and NES never entered the scope.
406
+ const empty = await cli(dir, "claim", "--next", "--json")
407
+ assert.equal(empty.code, 1)
408
+ })
409
+
410
+ test("maxOpenCount caps every claim repo-wide; --force is the escape", async () => {
411
+ const { clone, rootKey } = multiFixture({ boards: ["NES", "ROO"], maxOpenCount: 1 })
412
+ const a = clone()
413
+ assert.equal((await cli(a, "claim", "--next", "--json")).code, 0)
414
+
415
+ // At the cap, --next and explicit-id claims both refuse with exit 2 —
416
+ // the fix is to finish or release a claim, not to pick another ticket.
417
+ const b = clone()
418
+ const capped = await cli(b, "claim", "--next")
419
+ assert.equal(capped.code, 2)
420
+ assert.match(capped.stderr, /maxOpenCount cap \(1\)/)
421
+ const explicit = await cli(b, "claim", `ROO-${rootKey}`)
422
+ assert.equal(explicit.code, 2)
423
+ assert.match(explicit.stderr, /maxOpenCount cap \(1\)/)
424
+
425
+ const forced = await cli(b, "claim", "--next", "--force", "--json")
426
+ assert.equal(forced.code, 0, forced.stderr)
427
+ assert.equal((JSON.parse(forced.stdout) as { task: { id: string } }).task.id, `ROO-${rootKey}`)
428
+ })
429
+
430
+ test("release requires a reason and leaves it as a comment on the ticket", async () => {
431
+ const { clone, keys } = fixture()
432
+ const dir = clone()
433
+ assert.equal((await cli(dir, "claim", keys.one)).code, 0)
434
+
435
+ // No comment, and an empty one, are both refused before anything happens.
436
+ const missing = await cli(dir, "claim", "--release", keys.one)
437
+ assert.equal(missing.code, 1)
438
+ assert.match(missing.stderr, /requires --comment/)
439
+ const empty = await cli(dir, "claim", "--release", keys.one, "--comment", "")
440
+ assert.equal(empty.code, 1)
441
+ assert.match(empty.stderr, /requires --comment/)
442
+ assert.ok(
443
+ sh(dir, "git", "ls-remote", "--heads", "origin", `task/claim/clm-${keys.one}`).includes(
444
+ `clm-${keys.one}`,
445
+ ),
446
+ )
447
+
448
+ const released = await cli(
449
+ dir, "claim", "--release", keys.one,
450
+ "--comment", "the migration kept flaking — needs a fixture, not a retry",
451
+ "--author", "claude",
452
+ )
453
+ assert.equal(released.code, 0, released.stderr)
454
+
455
+ // The reason survives the branch: an ordinary attributed comment, sitting
456
+ // uncommitted in the working tree on the default branch.
457
+ const shown = await cli(dir, "show", keys.one, "--json")
458
+ const { comments } = JSON.parse(shown.stdout) as {
459
+ comments: { author: string; body: string }[]
460
+ }
461
+ assert.equal(comments.length, 1)
462
+ assert.equal(comments[0].author, "claude")
463
+ assert.match(comments[0].body, /kept flaking/)
464
+ assert.ok(sh(dir, "git", "status", "--porcelain").includes(`tickets/${keys.one}/comments`))
465
+
466
+ // Releasing an unclaimed ticket stays a no-op — no second comment.
467
+ const noop = await cli(dir, "claim", "--release", keys.one, "--comment", "again")
468
+ assert.equal(noop.code, 0)
469
+ assert.match(noop.stdout, /wasn't claimed/)
470
+ const after = await cli(dir, "show", keys.one, "--json")
471
+ assert.equal((JSON.parse(after.stdout) as { comments: unknown[] }).comments.length, 1)
242
472
  })