@nickmeriano/task 0.7.0 → 0.8.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 (86) hide show
  1. package/README.md +12 -7
  2. package/dist/check.d.ts +42 -0
  3. package/dist/check.d.ts.map +1 -0
  4. package/dist/check.js +364 -0
  5. package/dist/check.js.map +1 -0
  6. package/dist/check.test.d.ts +9 -0
  7. package/dist/check.test.d.ts.map +1 -0
  8. package/dist/check.test.js +209 -0
  9. package/dist/check.test.js.map +1 -0
  10. package/dist/claim.d.ts +56 -3
  11. package/dist/claim.d.ts.map +1 -1
  12. package/dist/claim.js +164 -9
  13. package/dist/claim.js.map +1 -1
  14. package/dist/claim.test.js +168 -14
  15. package/dist/claim.test.js.map +1 -1
  16. package/dist/cli.js +504 -93
  17. package/dist/cli.js.map +1 -1
  18. package/dist/file-store.d.ts +41 -15
  19. package/dist/file-store.d.ts.map +1 -1
  20. package/dist/file-store.js +197 -99
  21. package/dist/file-store.js.map +1 -1
  22. package/dist/index.d.ts +5 -3
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +5 -3
  25. package/dist/index.js.map +1 -1
  26. package/dist/overview.d.ts +43 -0
  27. package/dist/overview.d.ts.map +1 -0
  28. package/dist/overview.js +53 -0
  29. package/dist/overview.js.map +1 -0
  30. package/dist/overview.test.d.ts +8 -0
  31. package/dist/overview.test.d.ts.map +1 -0
  32. package/dist/overview.test.js +47 -0
  33. package/dist/overview.test.js.map +1 -0
  34. package/dist/promote.test.d.ts +14 -0
  35. package/dist/promote.test.d.ts.map +1 -0
  36. package/dist/promote.test.js +106 -0
  37. package/dist/promote.test.js.map +1 -0
  38. package/dist/search.d.ts +32 -0
  39. package/dist/search.d.ts.map +1 -0
  40. package/dist/search.js +66 -0
  41. package/dist/search.js.map +1 -0
  42. package/dist/search.test.d.ts +2 -0
  43. package/dist/search.test.d.ts.map +1 -0
  44. package/dist/search.test.js +53 -0
  45. package/dist/search.test.js.map +1 -0
  46. package/dist/server.d.ts.map +1 -1
  47. package/dist/server.js +10 -2
  48. package/dist/server.js.map +1 -1
  49. package/dist/store.d.ts +16 -50
  50. package/dist/store.d.ts.map +1 -1
  51. package/dist/store.js +0 -368
  52. package/dist/store.js.map +1 -1
  53. package/dist/store.test.d.ts +1 -2
  54. package/dist/store.test.d.ts.map +1 -1
  55. package/dist/store.test.js +77 -48
  56. package/dist/store.test.js.map +1 -1
  57. package/dist/ticket-doc.d.ts +55 -2
  58. package/dist/ticket-doc.d.ts.map +1 -1
  59. package/dist/ticket-doc.js +177 -7
  60. package/dist/ticket-doc.js.map +1 -1
  61. package/dist/types.d.ts +67 -14
  62. package/dist/types.d.ts.map +1 -1
  63. package/dist/types.js.map +1 -1
  64. package/package.json +1 -1
  65. package/skill/SKILL.md +97 -31
  66. package/src/check.test.ts +271 -0
  67. package/src/check.ts +454 -0
  68. package/src/claim.test.ts +202 -14
  69. package/src/claim.ts +194 -10
  70. package/src/cli.ts +510 -89
  71. package/src/file-store.ts +217 -111
  72. package/src/index.ts +4 -2
  73. package/src/overview.test.ts +51 -0
  74. package/src/overview.ts +90 -0
  75. package/src/promote.test.ts +131 -0
  76. package/src/search.test.ts +64 -0
  77. package/src/search.ts +91 -0
  78. package/src/server.ts +11 -2
  79. package/src/store.test.ts +89 -57
  80. package/src/store.ts +18 -431
  81. package/src/ticket-doc.ts +210 -10
  82. package/src/types.ts +71 -14
  83. package/ui/dist/assets/{index-oJzomUDL.js → index-BJmOsOdR.js} +76 -76
  84. package/ui/dist/assets/index-BoqQlqSU.css +1 -0
  85. package/ui/dist/index.html +2 -2
  86. package/ui/dist/assets/index-CXW8uT5f.css +0 -1
package/src/claim.test.ts CHANGED
@@ -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"
@@ -69,7 +69,7 @@ interface Fixture {
69
69
  * 2 "Ready two" todo
70
70
  * 3 "Blocked" todo, blocked by 1
71
71
  * 4 "For a person" todo, needs-human
72
- * 5 "Not groomed" backlog
72
+ * 5 "Still parked" backlog
73
73
  * 6 "Jumped queue" todo, moved to the top of the column after creation —
74
74
  * claimable order must be [6, 1, 2], proving position
75
75
  * order beats number order.
@@ -90,11 +90,11 @@ function fixture(claimPrefix?: string): Fixture {
90
90
  config.claimPrefix = claimPrefix
91
91
  writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`)
92
92
  }
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" })
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" })
98
98
  const jumper = store.create({ title: "Jumped queue", status: "backlog" })
99
99
  // A status move without an explicit slot lands on top of the new column.
100
100
  store.update(jumper.number, { status: "todo" })
@@ -151,12 +151,16 @@ test("unclaimable tickets exit 2, before anything touches origin", async () => {
151
151
  assert.equal((await cli(dir, "claim", "99")).code, 2) // missing
152
152
  assert.match((await cli(dir, "claim", "3")).stderr, /blocked by CLM-1/)
153
153
 
154
- // A dirty tree refuses too — claiming switches branches.
155
- writeFileSync(join(dir, "scratch.txt"), "wip")
154
+ // A dirty tree refuses too — claiming switches branches. Only *tracked*
155
+ // modifications count: untracked files ride a branch switch untouched
156
+ // (release deliberately leaves one — the reason comment).
157
+ const gitignore = join(dir, ".task", ".gitignore")
158
+ writeFileSync(gitignore, `${readFileSync(gitignore, "utf8")}\nwip\n`)
156
159
  const dirty = await cli(dir, "claim", "2")
157
160
  assert.equal(dirty.code, 2)
158
161
  assert.match(dirty.stderr, /working tree is dirty/)
159
- rmSync(join(dir, "scratch.txt"))
162
+ sh(dir, "git", "checkout", "--", ".task/.gitignore")
163
+ writeFileSync(join(dir, "scratch.txt"), "untracked is fine")
160
164
  assert.equal((await cli(dir, "claim", "2")).code, 0)
161
165
 
162
166
  // Nothing above created a stray claim branch for the failed ids.
@@ -189,7 +193,7 @@ test("release deletes the branch everywhere and reopens the claim", async () =>
189
193
  assert.equal((await cli(dir, "claim", "2")).code, 0)
190
194
  assert.equal((await cli(dir, "claim", "2")).code, 1) // locally claimed too
191
195
 
192
- const released = await cli(dir, "claim", "--release", "2")
196
+ const released = await cli(dir, "claim", "--release", "2", "--comment", "trying another shape")
193
197
  assert.equal(released.code, 0, released.stderr)
194
198
  assert.match(released.stdout, /released CLM-2/)
195
199
 
@@ -203,7 +207,7 @@ test("release deletes the branch everywhere and reopens the claim", async () =>
203
207
  assert.equal((await cli(dir, "claim", "2")).code, 0)
204
208
 
205
209
  // Releasing an unclaimed ticket is a quiet no-op, not an error.
206
- const noop = await cli(dir, "claim", "--release", "1")
210
+ const noop = await cli(dir, "claim", "--release", "1", "--comment", "noop")
207
211
  assert.equal(noop.code, 0)
208
212
  assert.match(noop.stdout, /wasn't claimed/)
209
213
  })
@@ -226,7 +230,7 @@ test("a configured claimPrefix moves the whole namespace", async () => {
226
230
  const { tasks } = JSON.parse(listed.stdout) as { tasks: { number: number }[] }
227
231
  assert.deepEqual(tasks.map((t) => t.number), [6, 2])
228
232
  assert.equal((await cli(other, "claim", "1")).code, 1)
229
- assert.equal((await cli(dir, "claim", "--release", "1")).code, 0)
233
+ assert.equal((await cli(dir, "claim", "--release", "1", "--comment", "handing it back")).code, 0)
230
234
  assert.equal(sh(dir, "git", "ls-remote", "--heads", "origin", "claude/task/*"), "")
231
235
 
232
236
  // A missing trailing slash is forgiven; a malformed value fails loudly.
@@ -238,5 +242,189 @@ test("a configured claimPrefix moves the whole namespace", async () => {
238
242
  sh(bad, "git", "commit", "--quiet", "-am", "bad prefix")
239
243
  const invalid = await cli(bad, "claim", "2")
240
244
  assert.equal(invalid.code, 2)
241
- assert.match(invalid.stderr, /invalid claimPrefix/)
245
+ assert.match(invalid.stderr, /invalid claims\.branchPrefix/)
246
+ })
247
+
248
+ /**
249
+ * A monorepo with two boards for the multi-board selection paths (TAS-27):
250
+ * a root board (ROO, one ready ticket) and a nested one under pkg/ (NES,
251
+ * one ready ticket). The root config carries the new `claims` block and,
252
+ * optionally, the top-level `boards` list — NES deliberately ahead of ROO,
253
+ * so priority provably follows list order, not nearness.
254
+ */
255
+ function multiFixture(options: { boards?: string[]; maxOpenCount?: number } = {}): Fixture {
256
+ const home = tempDir()
257
+ const bare = join(home, "origin.git")
258
+ sh(home, "git", "init", "--quiet", "--bare", "-b", "main", bare)
259
+
260
+ const seed = join(home, "seed")
261
+ sh(home, "git", "clone", "--quiet", bare, seed)
262
+ sh(seed, "git", "config", "user.name", "Test")
263
+ sh(seed, "git", "config", "user.email", "test@example.com")
264
+
265
+ const root = initProject(seed, { name: "root board", prefix: "ROO" })
266
+ const configPath = join(seed, ".task", "config.json")
267
+ const config = JSON.parse(readFileSync(configPath, "utf8")) as Record<string, unknown>
268
+ if (options.boards) config.boards = options.boards
269
+ config.claims = { branchPrefix: "task/claim/", maxOpenCount: options.maxOpenCount }
270
+ writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`)
271
+ root.create({ title: "Root ready", status: "todo" })
272
+
273
+ mkdirSync(join(seed, "pkg"), { recursive: true })
274
+ const nested = initProject(join(seed, "pkg"), { name: "nested board", prefix: "NES" })
275
+ nested.create({ title: "Nested ready", status: "todo" })
276
+
277
+ sh(seed, "git", "add", "-A")
278
+ sh(seed, "git", "commit", "--quiet", "-m", "boards")
279
+ sh(seed, "git", "push", "--quiet", "-u", "origin", "main")
280
+
281
+ let clones = 0
282
+ const clone = (): string => {
283
+ const dir = join(home, `clone-${++clones}`)
284
+ sh(home, "git", "clone", "--quiet", bare, dir)
285
+ sh(dir, "git", "config", "user.name", "Test")
286
+ sh(dir, "git", "config", "user.email", "test@example.com")
287
+ return dir
288
+ }
289
+ return { bare, clone }
290
+ }
291
+
292
+ test("claim --next drains the queue in position order and exits 1 when empty", async () => {
293
+ const { clone } = fixture()
294
+ const dir = clone()
295
+
296
+ const order: number[] = []
297
+ for (;;) {
298
+ const result = await cli(dir, "claim", "--next", "--json")
299
+ if (result.code === 1) {
300
+ assert.equal(JSON.parse(result.stdout).task, null)
301
+ break
302
+ }
303
+ 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)
306
+ assert.equal(sh(dir, "git", "rev-parse", "--abbrev-ref", "HEAD"), branch)
307
+ }
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])
311
+ })
312
+
313
+ test("two concurrent claim --next calls land on different tickets", async () => {
314
+ const { clone } = fixture()
315
+ const a = clone()
316
+ const b = clone()
317
+
318
+ const [ra, rb] = await Promise.all([
319
+ cli(a, "claim", "--next", "--json"),
320
+ cli(b, "claim", "--next", "--json"),
321
+ ])
322
+ assert.equal(ra.code, 0, ra.stderr)
323
+ assert.equal(rb.code, 0, rb.stderr)
324
+ const ids = [ra, rb].map((r) => (JSON.parse(r.stdout) as { task: { id: string } }).task.id)
325
+ assert.notEqual(ids[0], ids[1], `both claimed ${ids[0]}`)
326
+ })
327
+
328
+ test("--next walks the root boards list in priority order; --board overrides it", async () => {
329
+ const { clone } = multiFixture({ boards: ["NES", "ROO"] })
330
+ const a = clone()
331
+
332
+ // list --claimable reads the same scope, in the same order.
333
+ const listed = await cli(a, "list", "--claimable", "--json")
334
+ assert.equal(listed.code, 0, listed.stderr)
335
+ const { tasks } = JSON.parse(listed.stdout) as { tasks: { id: string }[] }
336
+ assert.deepEqual(tasks.map((t) => t.id), ["NES-1", "ROO-1"])
337
+
338
+ // The nested board wins despite the root board being nearest.
339
+ const next = await cli(a, "claim", "--next", "--json")
340
+ assert.equal(next.code, 0, next.stderr)
341
+ 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")
344
+
345
+ // An explicit --board list is the whole scope, in the given order.
346
+ const b = clone()
347
+ const overridden = await cli(b, "claim", "--next", "--board", "ROO", "--json")
348
+ assert.equal(overridden.code, 0, overridden.stderr)
349
+ assert.equal((JSON.parse(overridden.stdout) as { task: { id: string } }).task.id, "ROO-1")
350
+
351
+ const unknown = await cli(b, "claim", "--next", "--board", "NOPE")
352
+ assert.equal(unknown.code, 2)
353
+ assert.match(unknown.stderr, /no board with prefix NOPE/)
354
+ })
355
+
356
+ test("without a boards list, --next sees the nearest board only", async () => {
357
+ const { clone } = multiFixture()
358
+ const dir = clone()
359
+
360
+ const next = await cli(dir, "claim", "--next", "--json")
361
+ assert.equal(next.code, 0, next.stderr)
362
+ assert.equal((JSON.parse(next.stdout) as { task: { id: string } }).task.id, "ROO-1")
363
+
364
+ // ROO's queue is drained and NES never entered the scope.
365
+ const empty = await cli(dir, "claim", "--next", "--json")
366
+ assert.equal(empty.code, 1)
367
+ })
368
+
369
+ test("maxOpenCount caps every claim repo-wide; --force is the escape", async () => {
370
+ const { clone } = multiFixture({ boards: ["NES", "ROO"], maxOpenCount: 1 })
371
+ const a = clone()
372
+ assert.equal((await cli(a, "claim", "--next", "--json")).code, 0)
373
+
374
+ // At the cap, --next and explicit-id claims both refuse with exit 2 —
375
+ // the fix is to finish or release a claim, not to pick another ticket.
376
+ const b = clone()
377
+ const capped = await cli(b, "claim", "--next")
378
+ assert.equal(capped.code, 2)
379
+ assert.match(capped.stderr, /maxOpenCount cap \(1\)/)
380
+ const explicit = await cli(b, "claim", "ROO-1")
381
+ assert.equal(explicit.code, 2)
382
+ assert.match(explicit.stderr, /maxOpenCount cap \(1\)/)
383
+
384
+ const forced = await cli(b, "claim", "--next", "--force", "--json")
385
+ assert.equal(forced.code, 0, forced.stderr)
386
+ assert.equal((JSON.parse(forced.stdout) as { task: { id: string } }).task.id, "ROO-1")
387
+ })
388
+
389
+ test("release requires a reason and leaves it as a comment on the ticket", async () => {
390
+ const { clone } = fixture()
391
+ const dir = clone()
392
+ assert.equal((await cli(dir, "claim", "1")).code, 0)
393
+
394
+ // No comment, and an empty one, are both refused before anything happens.
395
+ const missing = await cli(dir, "claim", "--release", "1")
396
+ assert.equal(missing.code, 1)
397
+ assert.match(missing.stderr, /requires --comment/)
398
+ const empty = await cli(dir, "claim", "--release", "1", "--comment", "")
399
+ assert.equal(empty.code, 1)
400
+ assert.match(empty.stderr, /requires --comment/)
401
+ assert.match(
402
+ sh(dir, "git", "ls-remote", "--heads", "origin", "task/claim/clm-1"),
403
+ /clm-1/,
404
+ )
405
+
406
+ const released = await cli(
407
+ dir, "claim", "--release", "1",
408
+ "--comment", "the migration kept flaking — needs a fixture, not a retry",
409
+ "--author", "claude",
410
+ )
411
+ assert.equal(released.code, 0, released.stderr)
412
+
413
+ // The reason survives the branch: an ordinary attributed comment, sitting
414
+ // uncommitted in the working tree on the default branch.
415
+ const shown = await cli(dir, "show", "1", "--json")
416
+ const { comments } = JSON.parse(shown.stdout) as {
417
+ comments: { author: string; body: string }[]
418
+ }
419
+ assert.equal(comments.length, 1)
420
+ assert.equal(comments[0].author, "claude")
421
+ assert.match(comments[0].body, /kept flaking/)
422
+ assert.match(sh(dir, "git", "status", "--porcelain"), /tickets\/1\/comments/)
423
+
424
+ // Releasing an unclaimed ticket stays a no-op — no second comment.
425
+ const noop = await cli(dir, "claim", "--release", "1", "--comment", "again")
426
+ assert.equal(noop.code, 0)
427
+ assert.match(noop.stdout, /wasn't claimed/)
428
+ const after = await cli(dir, "show", "1", "--json")
429
+ assert.equal((JSON.parse(after.stdout) as { comments: unknown[] }).comments.length, 1)
242
430
  })
package/src/claim.ts CHANGED
@@ -12,8 +12,14 @@
12
12
 
13
13
  import { spawnSync } from "node:child_process"
14
14
  import { join } from "node:path"
15
- import { FileStore, TICKETS_DIR } from "./file-store.ts"
16
- import type { Store } from "./store.ts"
15
+ import { FileStore, TICKETS_DIR, openBoard } from "./file-store.ts"
16
+ import {
17
+ boardConfig,
18
+ findBoardsByPrefix,
19
+ findRoot,
20
+ findScopeRoot,
21
+ type Store,
22
+ } from "./store.ts"
17
23
  import type { ProjectConfig, Task } from "./types.ts"
18
24
 
19
25
  /**
@@ -31,22 +37,48 @@ export const DEFAULT_CLAIM_PREFIX = "task/claim/"
31
37
  const CLAIM_PREFIX_SHAPE = /^([A-Za-z0-9._-]+\/)+$/
32
38
 
33
39
  /**
34
- * The board's claim namespace: configured `claimPrefix` (a trailing slash is
40
+ * The board's configured claim namespace before validation or defaulting
41
+ * `claims.branchPrefix`, falling back to the pre-0.8 top-level `claimPrefix`
42
+ * (still read for one release so a version bump can't strand in-flight
43
+ * claims; `task check` nags, `--fix` migrates). Exported for `task check`,
44
+ * which wants the raw value to lint without the throw below.
45
+ */
46
+ export function configuredBranchPrefix(config: ProjectConfig): string | undefined {
47
+ return config.claims?.branchPrefix ?? config.claimPrefix
48
+ }
49
+
50
+ /**
51
+ * The board's claim namespace: configured branch prefix (a trailing slash is
35
52
  * implied) or the default. Validated here because it becomes a git ref and an
36
53
  * ls-remote glob — a malformed value must fail the claim, not corrupt it.
37
54
  */
38
55
  export function claimNamespace(config: ProjectConfig): string {
39
- const raw = config.claimPrefix ?? DEFAULT_CLAIM_PREFIX
56
+ const raw = configuredBranchPrefix(config) ?? DEFAULT_CLAIM_PREFIX
40
57
  const prefix = raw.endsWith("/") ? raw : `${raw}/`
41
58
  if (!CLAIM_PREFIX_SHAPE.test(prefix) || prefix.includes("..") || /(^|\/)\./.test(prefix)) {
42
59
  throw new ClaimError(
43
- `invalid claimPrefix in .task/config.json: ${JSON.stringify(raw)} — use slash-separated segments like "task/claim/" or "claude/task/"`,
60
+ `invalid claims.branchPrefix in .task/config.json: ${JSON.stringify(raw)} — use slash-separated segments like "task/claim/" or "claude/task/"`,
44
61
  "invalid",
45
62
  )
46
63
  }
47
64
  return prefix
48
65
  }
49
66
 
67
+ /**
68
+ * The repo-wide cap on open claims: `claims.maxOpenCount` read from the
69
+ * *root* board's config (the outermost `.task/` above `root`), never the
70
+ * nearest one — a WIP cap each board could set for itself is no cap at all.
71
+ * Absent, unparseable or nonsensical values all mean "no cap".
72
+ */
73
+ export function openClaimCap(root: string): number | undefined {
74
+ try {
75
+ const cap = boardConfig(findScopeRoot(root)).claims?.maxOpenCount
76
+ return typeof cap === "number" && Number.isInteger(cap) && cap > 0 ? cap : undefined
77
+ } catch {
78
+ return undefined
79
+ }
80
+ }
81
+
50
82
  /** { prefix: "TAS" }, 21 → "task/claim/tas-21" (or under the configured namespace). */
51
83
  export function claimBranch(config: ProjectConfig, number: number): string {
52
84
  return `${claimNamespace(config)}${config.prefix.toLowerCase()}-${number}`
@@ -151,6 +183,29 @@ export interface ClaimResult {
151
183
  base: string
152
184
  }
153
185
 
186
+ export interface ClaimOptions {
187
+ /** Claim past the root board's `claims.maxOpenCount` cap. */
188
+ force?: boolean
189
+ }
190
+
191
+ /**
192
+ * The WIP-cap gate every claim passes through: while `maxOpenCount` claim
193
+ * branches exist on origin, claiming more is refused (exit 2 — the fix is to
194
+ * finish or release one, not to pick a different ticket). Existence on origin
195
+ * is the definition of "open": a claim branch dies at merge or release, so a
196
+ * lingering merged branch counts — deliberate pressure to clean it up. The
197
+ * cap applies to humans and agents alike; `--force` is the escape.
198
+ */
199
+ function assertUnderCap(openClaims: Set<string>, root: string, namespace: string): void {
200
+ const cap = openClaimCap(root)
201
+ if (cap !== undefined && openClaims.size >= cap) {
202
+ throw new ClaimError(
203
+ `${openClaims.size} claim branch${openClaims.size === 1 ? "" : "es"} already open under ${namespace} — at the root board's maxOpenCount cap (${cap}); finish or \`task claim --release\` one, or pass --force`,
204
+ "invalid",
205
+ )
206
+ }
207
+ }
208
+
154
209
  /**
155
210
  * Claim `number`: validate, branch off origin's default branch, flip the
156
211
  * ticket to in_progress as the branch's first commit, and push. The push
@@ -160,9 +215,14 @@ export interface ClaimResult {
160
215
  * someone pre-created without a claim commit can't be hijacked by a plain
161
216
  * fast-forward. Leaves the winner checked out on the claim branch.
162
217
  */
163
- export function claim(store: Store, number: number): ClaimResult {
218
+ export function claim(store: Store, number: number, options: ClaimOptions = {}): ClaimResult {
219
+ // Unreachable since openBoard refuses pre-0.6 boards, kept as the narrowing
220
+ // that lets the rest of this function assume text-canonical semantics.
164
221
  if (!(store instanceof FileStore)) {
165
- throw new ClaimError("claiming needs a text-format board — run `task migrate` first", "invalid")
222
+ throw new ClaimError(
223
+ "claiming needs a text-format board — run `npx @nickmeriano/task@0.6 migrate` once",
224
+ "invalid",
225
+ )
166
226
  }
167
227
  const cwd = store.root
168
228
  const branch = claimBranch(store.config, number)
@@ -171,7 +231,11 @@ export function claim(store: Store, number: number): ClaimResult {
171
231
  // The local-branch check comes before ticket validation on purpose: on the
172
232
  // claim branch itself the ticket reads in_progress, and "already claimed"
173
233
  // (exit 1, move on) is the truthful answer there — not a validation failure.
174
- const tree = git(cwd, "status", "--porcelain")
234
+ // -uno everywhere a claim reads the tree: only *tracked* modifications block
235
+ // a branch switch. Untracked files ride along untouched — and the release
236
+ // flow deliberately leaves one (the reason comment), so counting them would
237
+ // wedge the release-then-claim-next loop this exists for.
238
+ const tree = git(cwd, "status", "--porcelain", "-uno")
175
239
  if (tree.status !== 0) {
176
240
  throw new ClaimError(`not a git repository: ${cwd}`, "invalid")
177
241
  }
@@ -191,9 +255,15 @@ export function claim(store: Store, number: number): ClaimResult {
191
255
  }
192
256
 
193
257
  gitMust(cwd, "fetch", "--quiet", "origin")
194
- if (remoteBranchExists(cwd, branch)) {
258
+ // One ls-remote answers both gates: is this ticket taken, and is the repo
259
+ // at its WIP cap. "Already claimed" wins — that's exit 1 (pick the next
260
+ // ticket), where the cap is exit 2 (fix something).
261
+ const namespace = claimNamespace(store.config)
262
+ const openClaims = remoteClaims(cwd, namespace)
263
+ if (openClaims.has(branch)) {
195
264
  throw new ClaimError(`${id} is already claimed — ${branch} exists on origin`, "claimed")
196
265
  }
266
+ if (!options.force) assertUnderCap(openClaims, cwd, namespace)
197
267
 
198
268
  const base = defaultBase(cwd)
199
269
  // So a failed claim can put the checkout back exactly where it was —
@@ -263,7 +333,7 @@ export function release(store: Store, number: number): ReleaseResult {
263
333
  const onBranch =
264
334
  git(cwd, "symbolic-ref", "--quiet", "--short", "HEAD").stdout === branch
265
335
  if (onBranch) {
266
- const tree = gitMust(cwd, "status", "--porcelain")
336
+ const tree = gitMust(cwd, "status", "--porcelain", "-uno")
267
337
  if (tree !== "") {
268
338
  throw new ClaimError(
269
339
  `working tree on ${branch} is dirty — commit elsewhere or discard before releasing`,
@@ -300,3 +370,117 @@ export function claimableTasks(store: Store): Task[] {
300
370
  .filter((t) => openBlockers(store, t).length === 0)
301
371
  .filter((t) => !claimed.has(claimBranch(store.config, t.number)))
302
372
  }
373
+
374
+ export interface PromoteResult {
375
+ task: Task
376
+ /** Claim-time gates still in the way — promoted, but not claimable yet. */
377
+ holds: string[]
378
+ }
379
+
380
+ /**
381
+ * `task promote` — the human "build this" call, as a verb: `backlog` → `todo`.
382
+ * No quality gate: planning happens at claim time (the worker's plan tick),
383
+ * so a one-line backlog ticket is promotable. Blocked and needs-human are
384
+ * claim-time facts that `claim`/`--claimable` already enforce live, so
385
+ * promoting a blocked ticket is legitimate sequencing ("build this once
386
+ * NIC-11 lands") — reported as a hold, never refused. Writes the ticket like
387
+ * `update` does; committing is the caller's job.
388
+ */
389
+ export function promote(store: Store, number: number): PromoteResult {
390
+ const task = store.get(number)
391
+ const id = store.displayId(number)
392
+ if (!task) throw new ClaimError(`no such task: ${id}`, "invalid")
393
+ if (task.status !== "backlog") {
394
+ throw new ClaimError(
395
+ `${id} is ${task.status} — only backlog tickets can be promoted`,
396
+ "invalid",
397
+ )
398
+ }
399
+ const updated = store.update(number, { status: "todo" })
400
+ const holds: string[] = []
401
+ if (updated.needsHuman) holds.push("flagged needs-human")
402
+ const blockers = openBlockers(store, updated)
403
+ if (blockers.length) holds.push(`blocked by ${blockers.join(", ")}`)
404
+ return { task: updated, holds }
405
+ }
406
+
407
+ /**
408
+ * The boards `claim --next` and `list --claimable` select over, in priority
409
+ * order. Resolution: an explicit `--board` list > the root board's top-level
410
+ * `boards` key > the nearest board alone. The list is *selection scope*, not
411
+ * permission — `task claim <id>` works on any board regardless — and it's
412
+ * read from the repo's root board only, so a vendored board can't opt itself
413
+ * into a repo's automation by shipping a config.
414
+ */
415
+ export function selectionBoards(cwd: string, prefixes?: string[]): Store[] {
416
+ const scope = findScopeRoot(cwd)
417
+ let wanted = prefixes?.length ? prefixes : undefined
418
+ if (!wanted) {
419
+ try {
420
+ const boards = boardConfig(scope).boards
421
+ if (Array.isArray(boards) && boards.length > 0) wanted = boards.map(String)
422
+ } catch {
423
+ // No readable root board — same answer as no boards list.
424
+ }
425
+ }
426
+ if (!wanted) {
427
+ const nearest = findRoot(cwd)
428
+ if (!nearest) {
429
+ throw new ClaimError(
430
+ "no .task directory found in this directory or any parent — run `task init` first",
431
+ "invalid",
432
+ )
433
+ }
434
+ return [openBoard(nearest)]
435
+ }
436
+ return wanted.map((prefix) => {
437
+ const matches = findBoardsByPrefix(scope, prefix)
438
+ if (matches.length === 1) return openBoard(matches[0].root)
439
+ throw new ClaimError(
440
+ matches.length === 0
441
+ ? `no board with prefix ${prefix.toUpperCase()} in this repo — check --board and the root board's "boards" list`
442
+ : `prefix ${prefix.toUpperCase()} is ambiguous — boards at: ${matches.map((m) => m.id).join(", ")}`,
443
+ "invalid",
444
+ )
445
+ })
446
+ }
447
+
448
+ /**
449
+ * `task claim --next`: walk the selection scope in priority order and claim
450
+ * the first ticket that sticks. Losing a race for one ticket (another worker
451
+ * pushed its branch first) means *try the next*, so every ClaimError inside
452
+ * the loop is a skip — which is why the repo-level preconditions (dirty tree,
453
+ * malformed prefix, WIP cap) are asserted up front, where they still mean
454
+ * "stop and fix something" (exit 2) instead of draining the queue to a bogus
455
+ * "nothing claimable". Returns null when the queue really is empty (exit 1).
456
+ */
457
+ export function claimNext(stores: Store[], options: ClaimOptions = {}): ClaimResult | null {
458
+ const first = stores[0]
459
+ const tree = git(first.root, "status", "--porcelain", "-uno")
460
+ if (tree.status !== 0) {
461
+ throw new ClaimError(`not a git repository: ${first.root}`, "invalid")
462
+ }
463
+ if (tree.stdout !== "") {
464
+ throw new ClaimError(
465
+ "working tree is dirty — commit or stash before claiming, the claim switches branches",
466
+ "invalid",
467
+ )
468
+ }
469
+ for (const store of stores) claimNamespace(store.config)
470
+ if (!options.force) {
471
+ const namespace = claimNamespace(first.config)
472
+ assertUnderCap(remoteClaims(first.root, namespace), first.root, namespace)
473
+ }
474
+ for (const store of stores) {
475
+ for (const task of claimableTasks(store)) {
476
+ try {
477
+ return claim(store, task.number, options)
478
+ } catch (error) {
479
+ // Claimed out from under us, or changed since listing — next in line.
480
+ if (error instanceof ClaimError) continue
481
+ throw error
482
+ }
483
+ }
484
+ }
485
+ return null
486
+ }