@nickmeriano/task 0.8.0 → 0.10.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 (115) hide show
  1. package/README.md +137 -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 +108 -23
  17. package/dist/claim.d.ts.map +1 -1
  18. package/dist/claim.js +320 -86
  19. package/dist/claim.js.map +1 -1
  20. package/dist/claim.test.d.ts +10 -2
  21. package/dist/claim.test.d.ts.map +1 -1
  22. package/dist/claim.test.js +282 -96
  23. package/dist/claim.test.js.map +1 -1
  24. package/dist/cli.js +346 -68
  25. package/dist/cli.js.map +1 -1
  26. package/dist/file-store.d.ts +70 -24
  27. package/dist/file-store.d.ts.map +1 -1
  28. package/dist/file-store.js +319 -141
  29. package/dist/file-store.js.map +1 -1
  30. package/dist/git-serve.d.ts +183 -0
  31. package/dist/git-serve.d.ts.map +1 -0
  32. package/dist/git-serve.js +503 -0
  33. package/dist/git-serve.js.map +1 -0
  34. package/dist/git-serve.test.d.ts +16 -0
  35. package/dist/git-serve.test.d.ts.map +1 -0
  36. package/dist/git-serve.test.js +183 -0
  37. package/dist/git-serve.test.js.map +1 -0
  38. package/dist/git.d.ts +65 -0
  39. package/dist/git.d.ts.map +1 -0
  40. package/dist/git.js +114 -0
  41. package/dist/git.js.map +1 -0
  42. package/dist/id.d.ts +39 -0
  43. package/dist/id.d.ts.map +1 -0
  44. package/dist/id.js +67 -0
  45. package/dist/id.js.map +1 -0
  46. package/dist/inbox.d.ts +41 -0
  47. package/dist/inbox.d.ts.map +1 -0
  48. package/dist/inbox.js +56 -0
  49. package/dist/inbox.js.map +1 -0
  50. package/dist/overview.d.ts +9 -0
  51. package/dist/overview.d.ts.map +1 -1
  52. package/dist/overview.js +19 -11
  53. package/dist/overview.js.map +1 -1
  54. package/dist/overview.test.js +6 -5
  55. package/dist/overview.test.js.map +1 -1
  56. package/dist/promote.test.d.ts +3 -2
  57. package/dist/promote.test.d.ts.map +1 -1
  58. package/dist/promote.test.js +29 -31
  59. package/dist/promote.test.js.map +1 -1
  60. package/dist/publish.d.ts +2 -17
  61. package/dist/publish.d.ts.map +1 -1
  62. package/dist/publish.js +4 -49
  63. package/dist/publish.js.map +1 -1
  64. package/dist/search.d.ts +3 -1
  65. package/dist/search.d.ts.map +1 -1
  66. package/dist/search.js +15 -2
  67. package/dist/search.js.map +1 -1
  68. package/dist/search.test.js +9 -9
  69. package/dist/search.test.js.map +1 -1
  70. package/dist/server.d.ts.map +1 -1
  71. package/dist/server.js +218 -21
  72. package/dist/server.js.map +1 -1
  73. package/dist/store.d.ts +28 -14
  74. package/dist/store.d.ts.map +1 -1
  75. package/dist/store.js.map +1 -1
  76. package/dist/store.test.js +88 -75
  77. package/dist/store.test.js.map +1 -1
  78. package/dist/ticket-doc.d.ts +26 -10
  79. package/dist/ticket-doc.d.ts.map +1 -1
  80. package/dist/ticket-doc.js +71 -27
  81. package/dist/ticket-doc.js.map +1 -1
  82. package/dist/types.d.ts +51 -17
  83. package/dist/types.d.ts.map +1 -1
  84. package/dist/types.js.map +1 -1
  85. package/package.json +1 -1
  86. package/skill/SKILL.md +96 -24
  87. package/src/asks.test.ts +355 -0
  88. package/src/check.test.ts +129 -72
  89. package/src/check.ts +101 -58
  90. package/src/claim-io.ts +401 -0
  91. package/src/claim.test.ts +372 -101
  92. package/src/claim.ts +418 -99
  93. package/src/cli.ts +370 -67
  94. package/src/file-store.ts +357 -145
  95. package/src/git-serve.test.ts +240 -0
  96. package/src/git-serve.ts +595 -0
  97. package/src/git.ts +141 -0
  98. package/src/id.ts +68 -0
  99. package/src/inbox.ts +77 -0
  100. package/src/overview.test.ts +6 -5
  101. package/src/overview.ts +25 -10
  102. package/src/promote.test.ts +40 -28
  103. package/src/publish.ts +6 -53
  104. package/src/search.test.ts +11 -11
  105. package/src/search.ts +17 -3
  106. package/src/server.ts +221 -19
  107. package/src/store.test.ts +95 -77
  108. package/src/store.ts +28 -13
  109. package/src/ticket-doc.ts +93 -30
  110. package/src/types.ts +52 -17
  111. package/ui/dist/assets/index-BjsorZOU.js +229 -0
  112. package/ui/dist/assets/index-CoKCUYic.css +1 -0
  113. package/ui/dist/index.html +2 -2
  114. package/ui/dist/assets/index-BJmOsOdR.js +0 -229
  115. package/ui/dist/assets/index-BoqQlqSU.css +0 -1
package/src/claim.test.ts CHANGED
@@ -6,16 +6,24 @@
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
+ * 5. `task claim --lock-only` takes the same lock without touching the
14
+ * checkout — a branch-pinned (even dirty) session can hold it — and
15
+ * `--release` works on it symmetrically.
16
+ * 6. Deletions are verified: against a remote that silently drops deletion
17
+ * pushes (TAS-8b7s9), release and sweep fail loudly instead of lying.
18
+ * 7. `task sweep` deletes claim branches whose tickets are done, canceled or
19
+ * missing on the current checkout, and leaves live ones — and any branch
20
+ * named outside the claim scheme — alone.
13
21
  */
14
22
 
15
23
  import assert from "node:assert/strict"
16
24
  import { test } from "node:test"
17
25
  import { execFile, spawnSync } from "node:child_process"
18
- import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"
26
+ import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"
19
27
  import { readFileSync } from "node:fs"
20
28
  import { tmpdir } from "node:os"
21
29
  import { join } from "node:path"
@@ -46,11 +54,16 @@ interface CliResult {
46
54
 
47
55
  /** The CLI as callers see it — a subprocess with an exit code. */
48
56
  function cli(cwd: string, ...args: string[]): Promise<CliResult> {
57
+ return cliEnv(cwd, {}, ...args)
58
+ }
59
+
60
+ /** Like cli(), with extra env — the delete-dropping git shim rides in PATH. */
61
+ function cliEnv(cwd: string, env: Record<string, string>, ...args: string[]): Promise<CliResult> {
49
62
  return new Promise((resolve) => {
50
63
  execFile(
51
64
  process.execPath,
52
65
  ["--experimental-strip-types", CLI, ...args],
53
- { cwd },
66
+ { cwd, env: { ...process.env, ...env } },
54
67
  (error, stdout, stderr) => {
55
68
  resolve({ code: error ? ((error as { code?: number }).code ?? 1) : 0, stdout, stderr })
56
69
  },
@@ -58,23 +71,45 @@ function cli(cwd: string, ...args: string[]): Promise<CliResult> {
58
71
  })
59
72
  }
60
73
 
74
+ /**
75
+ * A PATH whose `git` silently swallows branch-deletion pushes — exit 0,
76
+ * nothing deleted — and delegates everything else to the real git. This is
77
+ * the observed cloud-proxy behavior TAS-8b7s9 documents: the deletion "works"
78
+ * while the ref survives, which is exactly what verified release must catch.
79
+ * POSIX-only by construction, like the failure it reproduces.
80
+ */
81
+ function deleteDroppingPath(): string {
82
+ const dir = mkdtempSync(join(tmpdir(), "task-fake-git-"))
83
+ process.on("exit", () => rmSync(dir, { recursive: true, force: true }))
84
+ const real = spawnSync("sh", ["-c", "command -v git"], { encoding: "utf8" }).stdout.trim()
85
+ const shim = join(dir, "git")
86
+ writeFileSync(
87
+ shim,
88
+ `#!/bin/sh\nfor a in "$@"; do case "$a" in --delete) exit 0;; esac; done\nexec "${real}" "$@"\n`,
89
+ )
90
+ chmodSync(shim, 0o755)
91
+ return `${dir}:${process.env.PATH ?? ""}`
92
+ }
93
+
61
94
  interface Fixture {
62
95
  bare: string
63
96
  clone: () => string
97
+ /** Keys of the seeded tickets, by role. */
98
+ keys: { one: string; two: string; blocked: string; person: string; parked: string; jumper: string }
64
99
  }
65
100
 
66
101
  /**
67
102
  * 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.
103
+ * one "Ready one" todo
104
+ * two "Ready two" todo
105
+ * blocked "Blocked" todo, blocked by `one`
106
+ * person "For a person" todo, carrying an open ask
107
+ * parked "Still parked" backlog
108
+ * jumper "Jumped queue" todo, moved to the top of the column after
109
+ * creation — claimable order must be [jumper, one, two], proving
110
+ * position order beats creation order.
76
111
  */
77
- function fixture(claimPrefix?: string): Fixture {
112
+ function fixture(branchPrefix?: string): Fixture {
78
113
  const home = tempDir()
79
114
  const bare = join(home, "origin.git")
80
115
  sh(home, "git", "init", "--quiet", "--bare", "-b", "main", bare)
@@ -84,20 +119,29 @@ function fixture(claimPrefix?: string): Fixture {
84
119
  sh(seed, "git", "config", "user.name", "Test")
85
120
  sh(seed, "git", "config", "user.email", "test@example.com")
86
121
  const store = initProject(seed, { name: "claim board", prefix: "CLM" })
87
- if (claimPrefix !== undefined) {
122
+ if (branchPrefix !== undefined) {
88
123
  const configPath = join(seed, ".task", "config.json")
89
124
  const config = JSON.parse(readFileSync(configPath, "utf8")) as Record<string, unknown>
90
- config.claimPrefix = claimPrefix
125
+ config.claims = { branchPrefix }
91
126
  writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`)
92
127
  }
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" })
128
+ const one = store.create({ title: "Ready one", status: "todo" })
129
+ const two = store.create({ title: "Ready two", status: "todo" })
130
+ const blocked = store.create({ title: "Blocked", status: "todo", blockedBy: [one.key] })
131
+ const person = store.create({ title: "For a person", status: "todo" })
132
+ store.addAsk(person.key, "Approve the vendor contract", "claude")
133
+ const parked = store.create({ title: "Still parked", status: "backlog" })
98
134
  const jumper = store.create({ title: "Jumped queue", status: "backlog" })
99
135
  // A status move without an explicit slot lands on top of the new column.
100
- store.update(jumper.number, { status: "todo" })
136
+ store.update(jumper.key, { status: "todo" })
137
+ const keys = {
138
+ one: one.key,
139
+ two: two.key,
140
+ blocked: blocked.key,
141
+ person: person.key,
142
+ parked: parked.key,
143
+ jumper: jumper.key,
144
+ }
101
145
  sh(seed, "git", "add", "-A")
102
146
  sh(seed, "git", "commit", "--quiet", "-m", "board")
103
147
  sh(seed, "git", "push", "--quiet", "-u", "origin", "main")
@@ -110,15 +154,15 @@ function fixture(claimPrefix?: string): Fixture {
110
154
  sh(dir, "git", "config", "user.email", "test@example.com")
111
155
  return dir
112
156
  }
113
- return { bare, clone }
157
+ return { bare, clone, keys }
114
158
  }
115
159
 
116
160
  test("two concurrent claims of one ticket: exactly one wins", async () => {
117
- const { bare, clone } = fixture()
161
+ const { bare, clone, keys } = fixture()
118
162
  const a = clone()
119
163
  const b = clone()
120
164
 
121
- const [ra, rb] = await Promise.all([cli(a, "claim", "1"), cli(b, "claim", "1")])
165
+ const [ra, rb] = await Promise.all([cli(a, "claim", keys.one), cli(b, "claim", keys.one)])
122
166
  assert.deepEqual([ra.code, rb.code].sort(), [0, 1], `a: ${ra.stderr}\nb: ${rb.stderr}`)
123
167
 
124
168
  const winner = ra.code === 0 ? a : b
@@ -126,123 +170,145 @@ test("two concurrent claims of one ticket: exactly one wins", async () => {
126
170
  assert.match(loser.stderr, /already claimed|rejected/)
127
171
 
128
172
  // 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")
173
+ const branch = `task/claim/clm-${keys.one}`
174
+ assert.equal(sh(winner, "git", "rev-parse", "--abbrev-ref", "HEAD"), branch)
130
175
  assert.match(
131
- readFileSync(join(winner, ".task", "tickets", "1", "ticket.md"), "utf8"),
176
+ readFileSync(join(winner, ".task", "tickets", keys.one, "ticket.md"), "utf8"),
132
177
  /status: in_progress/,
133
178
  )
134
- assert.match(
135
- sh(winner, "git", "ls-remote", "--heads", "origin", "task/claim/clm-1"),
136
- /refs\/heads\/task\/claim\/clm-1/,
137
- )
179
+ assert.ok(sh(winner, "git", "ls-remote", "--heads", "origin", branch).includes(`refs/heads/${branch}`))
138
180
  // Origin's default branch never saw the flip — the claim rides the branch.
139
181
  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/)
182
+ assert.match(
183
+ sh(winner, "git", "show", `origin/main:.task/tickets/${keys.one}/ticket.md`),
184
+ /status: todo/,
185
+ )
141
186
  void bare
142
187
  })
143
188
 
144
189
  test("unclaimable tickets exit 2, before anything touches origin", async () => {
145
- const { clone } = fixture()
190
+ const { clone, keys } = fixture()
146
191
  const dir = clone()
147
192
 
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/)
193
+ assert.equal((await cli(dir, "claim", keys.parked)).code, 2) // backlog
194
+ assert.equal((await cli(dir, "claim", keys.blocked)).code, 2) // blocked
195
+ assert.equal((await cli(dir, "claim", "zz9zz")).code, 2) // missing
196
+ assert.match(
197
+ (await cli(dir, "claim", keys.blocked)).stderr,
198
+ new RegExp(`blocked by CLM-${keys.one}`),
199
+ )
153
200
 
154
201
  // A dirty tree refuses too — claiming switches branches. Only *tracked*
155
202
  // modifications count: untracked files ride a branch switch untouched
156
203
  // (release deliberately leaves one — the reason comment).
157
204
  const gitignore = join(dir, ".task", ".gitignore")
158
205
  writeFileSync(gitignore, `${readFileSync(gitignore, "utf8")}\nwip\n`)
159
- const dirty = await cli(dir, "claim", "2")
206
+ const dirty = await cli(dir, "claim", keys.two)
160
207
  assert.equal(dirty.code, 2)
161
208
  assert.match(dirty.stderr, /working tree is dirty/)
162
209
  sh(dir, "git", "checkout", "--", ".task/.gitignore")
163
210
  writeFileSync(join(dir, "scratch.txt"), "untracked is fine")
164
- assert.equal((await cli(dir, "claim", "2")).code, 0)
211
+ assert.equal((await cli(dir, "claim", keys.two)).code, 0)
165
212
 
166
213
  // Nothing above created a stray claim branch for the failed ids.
167
214
  const remote = sh(dir, "git", "ls-remote", "--heads", "origin", "task/claim/*")
168
215
  assert.deepEqual(
169
216
  remote.split("\n").map((l) => l.split("refs/heads/")[1]),
170
- ["task/claim/clm-2"],
217
+ [`task/claim/clm-${keys.two}`],
171
218
  )
172
219
  })
173
220
 
174
221
  test("list --claimable is the queue: position order, minus everything unclaimable", async () => {
175
- const { clone } = fixture()
222
+ const { clone, keys } = fixture()
176
223
  const a = clone()
177
- assert.equal((await cli(a, "claim", "6")).code, 0)
224
+ assert.equal((await cli(a, "claim", keys.jumper)).code, 0)
178
225
 
179
226
  const b = clone()
180
227
  const listed = await cli(b, "list", "--claimable", "--json")
181
228
  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])
229
+ const { tasks } = JSON.parse(listed.stdout) as { tasks: { key: string }[] }
230
+ // jumper claimed, blocked blocked, parked not todo — position order keeps
231
+ // one before two (creation order within the column). person carries an open
232
+ // ask and is claimable anyway: asks gate done, not claim.
233
+ assert.deepEqual(tasks.map((t) => t.key), [keys.one, keys.two, keys.person])
186
234
 
187
235
  assert.equal((await cli(b, "list", "--claimable", "--status", "todo")).code, 1)
188
236
  })
189
237
 
190
238
  test("release deletes the branch everywhere and reopens the claim", async () => {
191
- const { clone } = fixture()
239
+ const { clone, keys } = fixture()
192
240
  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
241
+ assert.equal((await cli(dir, "claim", keys.two)).code, 0)
242
+ assert.equal((await cli(dir, "claim", keys.two)).code, 1) // locally claimed too
195
243
 
196
- const released = await cli(dir, "claim", "--release", "2", "--comment", "trying another shape")
244
+ const released = await cli(dir, "claim", "--release", keys.two, "--comment", "trying another shape")
197
245
  assert.equal(released.code, 0, released.stderr)
198
- assert.match(released.stdout, /released CLM-2/)
246
+ assert.ok(released.stdout.includes(`released CLM-${keys.two}`), released.stdout)
199
247
 
200
248
  // Back on the default branch, branch gone on both sides, ticket todo again.
201
249
  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"), "")
250
+ assert.equal(sh(dir, "git", "ls-remote", "--heads", "origin", `task/claim/clm-${keys.two}`), "")
203
251
  assert.match(
204
- readFileSync(join(dir, ".task", "tickets", "2", "ticket.md"), "utf8"),
252
+ readFileSync(join(dir, ".task", "tickets", keys.two, "ticket.md"), "utf8"),
205
253
  /status: todo/,
206
254
  )
207
- assert.equal((await cli(dir, "claim", "2")).code, 0)
255
+ assert.equal((await cli(dir, "claim", keys.two)).code, 0)
208
256
 
209
257
  // Releasing an unclaimed ticket is a quiet no-op, not an error.
210
- const noop = await cli(dir, "claim", "--release", "1", "--comment", "noop")
258
+ const noop = await cli(dir, "claim", "--release", keys.one, "--comment", "noop")
211
259
  assert.equal(noop.code, 0)
212
260
  assert.match(noop.stdout, /wasn't claimed/)
213
261
  })
214
262
 
215
- test("a configured claimPrefix moves the whole namespace", async () => {
216
- const { clone } = fixture("claude/task/")
263
+ test("a configured claims.branchPrefix moves the whole namespace", async () => {
264
+ const { clone, keys } = fixture("claude/task/")
217
265
  const dir = clone()
218
266
 
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/,
267
+ assert.equal((await cli(dir, "claim", keys.one)).code, 0)
268
+ assert.equal(sh(dir, "git", "rev-parse", "--abbrev-ref", "HEAD"), `claude/task/clm-${keys.one}`)
269
+ assert.ok(
270
+ sh(dir, "git", "ls-remote", "--heads", "origin", "claude/task/*").includes(
271
+ `refs/heads/claude/task/clm-${keys.one}`,
272
+ ),
224
273
  )
225
274
 
226
275
  // Every side of the feature reads the same key: the queue looks for claims
227
276
  // under the configured namespace, and release deletes there too.
228
277
  const other = clone()
229
278
  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)
279
+ const { tasks } = JSON.parse(listed.stdout) as { tasks: { key: string }[] }
280
+ assert.deepEqual(tasks.map((t) => t.key), [keys.jumper, keys.two, keys.person])
281
+ assert.equal((await cli(other, "claim", keys.one)).code, 1)
282
+ assert.equal(
283
+ (await cli(dir, "claim", "--release", keys.one, "--comment", "handing it back")).code,
284
+ 0,
285
+ )
234
286
  assert.equal(sh(dir, "git", "ls-remote", "--heads", "origin", "claude/task/*"), "")
235
287
 
236
288
  // A missing trailing slash is forgiven; a malformed value fails loudly.
237
289
  const bad = clone()
238
290
  const configPath = join(bad, ".task", "config.json")
239
291
  const config = JSON.parse(readFileSync(configPath, "utf8")) as Record<string, unknown>
240
- config.claimPrefix = "spaces are not refs/"
292
+ config.claims = { branchPrefix: "spaces are not refs/" }
241
293
  writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`)
242
294
  sh(bad, "git", "commit", "--quiet", "-am", "bad prefix")
243
- const invalid = await cli(bad, "claim", "2")
295
+ const invalid = await cli(bad, "claim", keys.two)
244
296
  assert.equal(invalid.code, 2)
245
297
  assert.match(invalid.stderr, /invalid claims\.branchPrefix/)
298
+
299
+ // The retired pre-0.8 `claimPrefix` spelling is not honored (TAS-41): a
300
+ // board carrying only it refuses by name rather than silently defaulting
301
+ // the lock convention out from under its workers.
302
+ const retired = clone()
303
+ const retiredPath = join(retired, ".task", "config.json")
304
+ const retiredConfig = JSON.parse(readFileSync(retiredPath, "utf8")) as Record<string, unknown>
305
+ delete retiredConfig.claims
306
+ retiredConfig.claimPrefix = "claude/task/"
307
+ writeFileSync(retiredPath, `${JSON.stringify(retiredConfig, null, 2)}\n`)
308
+ sh(retired, "git", "commit", "--quiet", "-am", "retired key")
309
+ const refused = await cli(retired, "claim", keys.two)
310
+ assert.equal(refused.code, 2)
311
+ assert.match(refused.stderr, /pre-0\.8 spelling and is no longer read/)
246
312
  })
247
313
 
248
314
  /**
@@ -252,7 +318,14 @@ test("a configured claimPrefix moves the whole namespace", async () => {
252
318
  * optionally, the top-level `boards` list — NES deliberately ahead of ROO,
253
319
  * so priority provably follows list order, not nearness.
254
320
  */
255
- function multiFixture(options: { boards?: string[]; maxOpenCount?: number } = {}): Fixture {
321
+ interface MultiFixture {
322
+ bare: string
323
+ clone: () => string
324
+ rootKey: string
325
+ nestedKey: string
326
+ }
327
+
328
+ function multiFixture(options: { boards?: string[]; maxOpenCount?: number } = {}): MultiFixture {
256
329
  const home = tempDir()
257
330
  const bare = join(home, "origin.git")
258
331
  sh(home, "git", "init", "--quiet", "--bare", "-b", "main", bare)
@@ -268,11 +341,11 @@ function multiFixture(options: { boards?: string[]; maxOpenCount?: number } = {}
268
341
  if (options.boards) config.boards = options.boards
269
342
  config.claims = { branchPrefix: "task/claim/", maxOpenCount: options.maxOpenCount }
270
343
  writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`)
271
- root.create({ title: "Root ready", status: "todo" })
344
+ const rootKey = root.create({ title: "Root ready", status: "todo" }).key
272
345
 
273
346
  mkdirSync(join(seed, "pkg"), { recursive: true })
274
347
  const nested = initProject(join(seed, "pkg"), { name: "nested board", prefix: "NES" })
275
- nested.create({ title: "Nested ready", status: "todo" })
348
+ const nestedKey = nested.create({ title: "Nested ready", status: "todo" }).key
276
349
 
277
350
  sh(seed, "git", "add", "-A")
278
351
  sh(seed, "git", "commit", "--quiet", "-m", "boards")
@@ -286,14 +359,14 @@ function multiFixture(options: { boards?: string[]; maxOpenCount?: number } = {}
286
359
  sh(dir, "git", "config", "user.email", "test@example.com")
287
360
  return dir
288
361
  }
289
- return { bare, clone }
362
+ return { bare, clone, rootKey, nestedKey }
290
363
  }
291
364
 
292
365
  test("claim --next drains the queue in position order and exits 1 when empty", async () => {
293
- const { clone } = fixture()
366
+ const { clone, keys } = fixture()
294
367
  const dir = clone()
295
368
 
296
- const order: number[] = []
369
+ const order: string[] = []
297
370
  for (;;) {
298
371
  const result = await cli(dir, "claim", "--next", "--json")
299
372
  if (result.code === 1) {
@@ -301,13 +374,14 @@ test("claim --next drains the queue in position order and exits 1 when empty", a
301
374
  break
302
375
  }
303
376
  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)
377
+ const { task, branch } = JSON.parse(result.stdout) as { task: { key: string }; branch: string }
378
+ order.push(task.key)
306
379
  assert.equal(sh(dir, "git", "rev-parse", "--abbrev-ref", "HEAD"), branch)
307
380
  }
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])
381
+ // The jumper leads (position order), then creation order; blocked and
382
+ // backlog tickets never surface, while the open ask on `person` doesn't
383
+ // keep it out of the queue.
384
+ assert.deepEqual(order, [keys.jumper, keys.one, keys.two, keys.person])
311
385
  })
312
386
 
313
387
  test("two concurrent claim --next calls land on different tickets", async () => {
@@ -326,27 +400,27 @@ test("two concurrent claim --next calls land on different tickets", async () =>
326
400
  })
327
401
 
328
402
  test("--next walks the root boards list in priority order; --board overrides it", async () => {
329
- const { clone } = multiFixture({ boards: ["NES", "ROO"] })
403
+ const { clone, rootKey, nestedKey } = multiFixture({ boards: ["NES", "ROO"] })
330
404
  const a = clone()
331
405
 
332
406
  // list --claimable reads the same scope, in the same order.
333
407
  const listed = await cli(a, "list", "--claimable", "--json")
334
408
  assert.equal(listed.code, 0, listed.stderr)
335
409
  const { tasks } = JSON.parse(listed.stdout) as { tasks: { id: string }[] }
336
- assert.deepEqual(tasks.map((t) => t.id), ["NES-1", "ROO-1"])
410
+ assert.deepEqual(tasks.map((t) => t.id), [`NES-${nestedKey}`, `ROO-${rootKey}`])
337
411
 
338
412
  // The nested board wins despite the root board being nearest.
339
413
  const next = await cli(a, "claim", "--next", "--json")
340
414
  assert.equal(next.code, 0, next.stderr)
341
415
  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")
416
+ assert.equal(claimed.task.id, `NES-${nestedKey}`)
417
+ assert.equal(claimed.branch, `task/claim/nes-${nestedKey}`)
344
418
 
345
419
  // An explicit --board list is the whole scope, in the given order.
346
420
  const b = clone()
347
421
  const overridden = await cli(b, "claim", "--next", "--board", "ROO", "--json")
348
422
  assert.equal(overridden.code, 0, overridden.stderr)
349
- assert.equal((JSON.parse(overridden.stdout) as { task: { id: string } }).task.id, "ROO-1")
423
+ assert.equal((JSON.parse(overridden.stdout) as { task: { id: string } }).task.id, `ROO-${rootKey}`)
350
424
 
351
425
  const unknown = await cli(b, "claim", "--next", "--board", "NOPE")
352
426
  assert.equal(unknown.code, 2)
@@ -354,12 +428,12 @@ test("--next walks the root boards list in priority order; --board overrides it"
354
428
  })
355
429
 
356
430
  test("without a boards list, --next sees the nearest board only", async () => {
357
- const { clone } = multiFixture()
431
+ const { clone, rootKey } = multiFixture()
358
432
  const dir = clone()
359
433
 
360
434
  const next = await cli(dir, "claim", "--next", "--json")
361
435
  assert.equal(next.code, 0, next.stderr)
362
- assert.equal((JSON.parse(next.stdout) as { task: { id: string } }).task.id, "ROO-1")
436
+ assert.equal((JSON.parse(next.stdout) as { task: { id: string } }).task.id, `ROO-${rootKey}`)
363
437
 
364
438
  // ROO's queue is drained and NES never entered the scope.
365
439
  const empty = await cli(dir, "claim", "--next", "--json")
@@ -367,7 +441,7 @@ test("without a boards list, --next sees the nearest board only", async () => {
367
441
  })
368
442
 
369
443
  test("maxOpenCount caps every claim repo-wide; --force is the escape", async () => {
370
- const { clone } = multiFixture({ boards: ["NES", "ROO"], maxOpenCount: 1 })
444
+ const { clone, rootKey } = multiFixture({ boards: ["NES", "ROO"], maxOpenCount: 1 })
371
445
  const a = clone()
372
446
  assert.equal((await cli(a, "claim", "--next", "--json")).code, 0)
373
447
 
@@ -377,34 +451,35 @@ test("maxOpenCount caps every claim repo-wide; --force is the escape", async ()
377
451
  const capped = await cli(b, "claim", "--next")
378
452
  assert.equal(capped.code, 2)
379
453
  assert.match(capped.stderr, /maxOpenCount cap \(1\)/)
380
- const explicit = await cli(b, "claim", "ROO-1")
454
+ const explicit = await cli(b, "claim", `ROO-${rootKey}`)
381
455
  assert.equal(explicit.code, 2)
382
456
  assert.match(explicit.stderr, /maxOpenCount cap \(1\)/)
383
457
 
384
458
  const forced = await cli(b, "claim", "--next", "--force", "--json")
385
459
  assert.equal(forced.code, 0, forced.stderr)
386
- assert.equal((JSON.parse(forced.stdout) as { task: { id: string } }).task.id, "ROO-1")
460
+ assert.equal((JSON.parse(forced.stdout) as { task: { id: string } }).task.id, `ROO-${rootKey}`)
387
461
  })
388
462
 
389
463
  test("release requires a reason and leaves it as a comment on the ticket", async () => {
390
- const { clone } = fixture()
464
+ const { clone, keys } = fixture()
391
465
  const dir = clone()
392
- assert.equal((await cli(dir, "claim", "1")).code, 0)
466
+ assert.equal((await cli(dir, "claim", keys.one)).code, 0)
393
467
 
394
468
  // No comment, and an empty one, are both refused before anything happens.
395
- const missing = await cli(dir, "claim", "--release", "1")
469
+ const missing = await cli(dir, "claim", "--release", keys.one)
396
470
  assert.equal(missing.code, 1)
397
471
  assert.match(missing.stderr, /requires --comment/)
398
- const empty = await cli(dir, "claim", "--release", "1", "--comment", "")
472
+ const empty = await cli(dir, "claim", "--release", keys.one, "--comment", "")
399
473
  assert.equal(empty.code, 1)
400
474
  assert.match(empty.stderr, /requires --comment/)
401
- assert.match(
402
- sh(dir, "git", "ls-remote", "--heads", "origin", "task/claim/clm-1"),
403
- /clm-1/,
475
+ assert.ok(
476
+ sh(dir, "git", "ls-remote", "--heads", "origin", `task/claim/clm-${keys.one}`).includes(
477
+ `clm-${keys.one}`,
478
+ ),
404
479
  )
405
480
 
406
481
  const released = await cli(
407
- dir, "claim", "--release", "1",
482
+ dir, "claim", "--release", keys.one,
408
483
  "--comment", "the migration kept flaking — needs a fixture, not a retry",
409
484
  "--author", "claude",
410
485
  )
@@ -412,19 +487,215 @@ test("release requires a reason and leaves it as a comment on the ticket", async
412
487
 
413
488
  // The reason survives the branch: an ordinary attributed comment, sitting
414
489
  // uncommitted in the working tree on the default branch.
415
- const shown = await cli(dir, "show", "1", "--json")
490
+ const shown = await cli(dir, "show", keys.one, "--json")
416
491
  const { comments } = JSON.parse(shown.stdout) as {
417
492
  comments: { author: string; body: string }[]
418
493
  }
419
494
  assert.equal(comments.length, 1)
420
495
  assert.equal(comments[0].author, "claude")
421
496
  assert.match(comments[0].body, /kept flaking/)
422
- assert.match(sh(dir, "git", "status", "--porcelain"), /tickets\/1\/comments/)
497
+ assert.ok(sh(dir, "git", "status", "--porcelain").includes(`tickets/${keys.one}/comments`))
423
498
 
424
499
  // Releasing an unclaimed ticket stays a no-op — no second comment.
425
- const noop = await cli(dir, "claim", "--release", "1", "--comment", "again")
500
+ const noop = await cli(dir, "claim", "--release", keys.one, "--comment", "again")
426
501
  assert.equal(noop.code, 0)
427
502
  assert.match(noop.stdout, /wasn't claimed/)
428
- const after = await cli(dir, "show", "1", "--json")
503
+ const after = await cli(dir, "show", keys.one, "--json")
429
504
  assert.equal((JSON.parse(after.stdout) as { comments: unknown[] }).comments.length, 1)
430
505
  })
506
+
507
+ test("--lock-only claims from a dirty, branch-pinned checkout without touching it", async () => {
508
+ const { clone, keys } = fixture()
509
+ const dir = clone()
510
+ const branch = `task/claim/clm-${keys.one}`
511
+
512
+ // The branch-pinned session: a provisioned branch, uncommitted tracked work.
513
+ sh(dir, "git", "checkout", "--quiet", "-b", "pinned")
514
+ const gitignore = join(dir, ".task", ".gitignore")
515
+ writeFileSync(gitignore, `${readFileSync(gitignore, "utf8")}\nwip\n`)
516
+
517
+ const claimed = await cli(dir, "claim", keys.one, "--lock-only", "--json")
518
+ assert.equal(claimed.code, 0, claimed.stderr)
519
+ const parsed = JSON.parse(claimed.stdout) as {
520
+ task: { status: string }
521
+ branch: string
522
+ deliveredBy: string
523
+ }
524
+ assert.equal(parsed.branch, branch)
525
+ assert.equal(parsed.deliveredBy, "pinned")
526
+ assert.equal(parsed.task.status, "in_progress")
527
+
528
+ // The checkout is exactly where it was: same branch, same dirty file, and
529
+ // the local ticket file never flipped — the claim lives on origin only.
530
+ assert.equal(sh(dir, "git", "rev-parse", "--abbrev-ref", "HEAD"), "pinned")
531
+ assert.match(sh(dir, "git", "status", "--porcelain", "-uno"), /\.task\/\.gitignore/)
532
+ assert.match(readFileSync(join(dir, ".task", "tickets", keys.one, "ticket.md"), "utf8"), /status: todo/)
533
+
534
+ // On origin the claim is a real flip commit built on the default branch,
535
+ // carrying the delivery branch as a trailer.
536
+ assert.ok(sh(dir, "git", "ls-remote", "--heads", "origin", branch).includes(`refs/heads/${branch}`))
537
+ assert.equal(
538
+ sh(dir, "git", "rev-parse", `origin/${branch}~1`),
539
+ sh(dir, "git", "rev-parse", "origin/main"),
540
+ )
541
+ assert.match(sh(dir, "git", "show", `origin/${branch}:.task/tickets/${keys.one}/ticket.md`), /status: in_progress/)
542
+ const message = sh(dir, "git", "log", "-1", "--format=%B", `origin/${branch}`)
543
+ assert.match(message, /lock-only/)
544
+ assert.match(message, new RegExp(`delivered_by: pinned`))
545
+
546
+ // The lock is the same lock: both claim flavors lose to it from elsewhere.
547
+ const other = clone()
548
+ assert.equal((await cli(other, "claim", keys.one)).code, 1)
549
+ assert.equal((await cli(other, "claim", keys.one, "--lock-only")).code, 1)
550
+
551
+ // Release is symmetric: no local branch to delete, the origin one goes, and
552
+ // the pinned checkout still isn't touched.
553
+ const released = await cli(dir, "claim", "--release", keys.one, "--comment", "handing back a lock-only claim")
554
+ assert.equal(released.code, 0, released.stderr)
555
+ assert.equal(sh(dir, "git", "rev-parse", "--abbrev-ref", "HEAD"), "pinned")
556
+ assert.equal(sh(dir, "git", "ls-remote", "--heads", "origin", branch), "")
557
+ assert.equal((await cli(other, "claim", keys.one)).code, 0)
558
+ })
559
+
560
+ test("--lock-only validates against origin's default branch, not the checkout", async () => {
561
+ const { clone, keys } = fixture()
562
+ const dir = clone()
563
+
564
+ // Not todo on the base tree → exit 2, and nothing minted on origin.
565
+ const parked = await cli(dir, "claim", keys.parked, "--lock-only")
566
+ assert.equal(parked.code, 2)
567
+ assert.match(parked.stderr, /is backlog on origin\/main/)
568
+
569
+ // A local promote that hasn't landed on the default branch doesn't count:
570
+ // the flip commit is built on origin/main, where the ticket is still backlog.
571
+ assert.equal((await cli(dir, "promote", keys.parked)).code, 0)
572
+ const promoted = await cli(dir, "claim", keys.parked, "--lock-only")
573
+ assert.equal(promoted.code, 2)
574
+ assert.match(promoted.stderr, /is backlog on origin\/main/)
575
+ assert.equal(sh(dir, "git", "ls-remote", "--heads", "origin", `task/claim/clm-${keys.parked}`), "")
576
+
577
+ // Blocked on the base tree refuses too.
578
+ const blocked = await cli(dir, "claim", keys.blocked, "--lock-only")
579
+ assert.equal(blocked.code, 2)
580
+ assert.match(blocked.stderr, new RegExp(`blocked by CLM-${keys.one}`))
581
+ })
582
+
583
+ test("plain claim warns when it moves the checkout off a non-default branch", async () => {
584
+ const { clone, keys } = fixture()
585
+ const dir = clone()
586
+ sh(dir, "git", "checkout", "--quiet", "-b", "feature")
587
+
588
+ const claimed = await cli(dir, "claim", keys.one)
589
+ assert.equal(claimed.code, 0, claimed.stderr)
590
+ assert.match(claimed.stdout, /moved you off feature/)
591
+ assert.match(claimed.stdout, /--lock-only/)
592
+
593
+ // From the default branch there is nothing to warn about.
594
+ const other = clone()
595
+ const quiet = await cli(other, "claim", keys.two)
596
+ assert.equal(quiet.code, 0, quiet.stderr)
597
+ assert.doesNotMatch(quiet.stdout, /moved you off/)
598
+ })
599
+
600
+ test("sweep deletes done/canceled/missing claims and keeps live ones", async () => {
601
+ const { clone, keys } = fixture()
602
+ const a = clone()
603
+ assert.equal((await cli(a, "claim", keys.one)).code, 0)
604
+ assert.equal((await cli(a, "claim", keys.two)).code, 0)
605
+ assert.equal((await cli(a, "claim", keys.person)).code, 0)
606
+
607
+ // A second checkout is where the tickets' fates land (a merge, normally):
608
+ // one finishes, person is canceled, two stays live — plus a branch under
609
+ // the namespace whose ticket never existed.
610
+ const b = clone()
611
+ assert.equal((await cli(b, "move", keys.one, "done")).code, 0)
612
+ assert.equal((await cli(b, "move", keys.person, "canceled")).code, 0)
613
+ sh(b, "git", "push", "--quiet", "origin", "main:refs/heads/task/claim/clm-zz9zz")
614
+ // Namespace squatters sweep must NOT touch: a pre-migration numeric claim
615
+ // (its branch may head an open PR carrying unmerged work — the TAS-31 case)
616
+ // and a branch matching no board's prefix. Skipped and reported, never
617
+ // deleted — sweep's jurisdiction is exactly the names `claim` mints.
618
+ sh(b, "git", "push", "--quiet", "origin", "main:refs/heads/task/claim/clm-31")
619
+ sh(b, "git", "push", "--quiet", "origin", "main:refs/heads/task/claim/other-zz9zz")
620
+
621
+ const swept = await cli(b, "sweep", "--json")
622
+ assert.equal(swept.code, 0, swept.stderr)
623
+ const { sweep } = JSON.parse(swept.stdout) as {
624
+ sweep: {
625
+ swept: { branch: string; reason: string }[]
626
+ kept: { branch: string; status: string }[]
627
+ skipped: string[]
628
+ failed: unknown[]
629
+ }
630
+ }
631
+ assert.deepEqual(
632
+ sweep.swept.map((s) => [s.branch, s.reason]).sort(),
633
+ [
634
+ [`task/claim/clm-${keys.one}`, "done"],
635
+ [`task/claim/clm-${keys.person}`, "canceled"],
636
+ ["task/claim/clm-zz9zz", "missing"],
637
+ ].sort(),
638
+ )
639
+ assert.deepEqual(sweep.kept, [
640
+ { branch: `task/claim/clm-${keys.two}`, ticket: `CLM-${keys.two}`, status: "todo" },
641
+ ])
642
+ assert.deepEqual(sweep.skipped.sort(), ["task/claim/clm-31", "task/claim/other-zz9zz"])
643
+ assert.equal(sweep.failed.length, 0)
644
+
645
+ // Origin agrees: the live claim and the out-of-scheme branches survive,
646
+ // and a re-run is a no-op.
647
+ const remote = sh(b, "git", "ls-remote", "--heads", "origin", "task/claim/*")
648
+ assert.deepEqual(
649
+ remote.split("\n").map((l) => l.split("refs/heads/")[1]).sort(),
650
+ ["task/claim/clm-31", `task/claim/clm-${keys.two}`, "task/claim/other-zz9zz"].sort(),
651
+ )
652
+ const again = await cli(b, "sweep", "--json")
653
+ assert.equal(again.code, 0)
654
+ assert.equal((JSON.parse(again.stdout) as { sweep: { swept: unknown[] } }).sweep.swept.length, 0)
655
+ })
656
+
657
+ test(
658
+ "verified deletion: a remote that drops deletions makes release and sweep fail loudly",
659
+ { skip: process.platform === "win32" },
660
+ async () => {
661
+ const { clone, keys } = fixture()
662
+ const path = deleteDroppingPath()
663
+ const dir = clone()
664
+ assert.equal((await cli(dir, "claim", keys.one)).code, 0)
665
+
666
+ // Release under the shim: git says the deletion worked, the ref survives —
667
+ // pre-TAS-8b7s9 this reported "released" and left a stale lock behind.
668
+ const released = await cliEnv(
669
+ dir, { PATH: path },
670
+ "claim", "--release", keys.one, "--comment", "should not land",
671
+ )
672
+ assert.notEqual(released.code, 0)
673
+ assert.match(released.stderr, /origin still has/)
674
+ assert.doesNotMatch(released.stdout, /released CLM/)
675
+ assert.ok(
676
+ sh(dir, "git", "ls-remote", "--heads", "origin", `task/claim/clm-${keys.one}`).includes(
677
+ `clm-${keys.one}`,
678
+ ),
679
+ )
680
+ // No release, no comment: the failure must not read as a done release.
681
+ const shown = await cli(dir, "show", keys.one, "--json")
682
+ assert.equal((JSON.parse(shown.stdout) as { comments: unknown[] }).comments.length, 0)
683
+
684
+ // Sweep under the shim reports the failed deletion and exits non-zero.
685
+ const b = clone()
686
+ assert.equal((await cli(b, "move", keys.one, "done")).code, 0)
687
+ const blocked = await cliEnv(b, { PATH: path }, "sweep", "--json")
688
+ assert.equal(blocked.code, 1)
689
+ const { sweep } = JSON.parse(blocked.stdout) as {
690
+ sweep: { swept: unknown[]; failed: { branch: string; error: string }[] }
691
+ }
692
+ assert.equal(sweep.swept.length, 0)
693
+ assert.equal(sweep.failed.length, 1)
694
+ assert.match(sweep.failed[0].error, /origin still has/)
695
+
696
+ // A capable runner heals it: the same sweep with real git succeeds.
697
+ const healed = await cli(b, "sweep")
698
+ assert.equal(healed.code, 0, healed.stderr)
699
+ assert.equal(sh(b, "git", "ls-remote", "--heads", "origin", `task/claim/clm-${keys.one}`), "")
700
+ },
701
+ )