@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.ts CHANGED
@@ -10,10 +10,22 @@
10
10
  * (the implement-task skill, a human).
11
11
  */
12
12
 
13
- import { spawnSync } from "node:child_process"
14
13
  import { join } from "node:path"
15
- import { FileStore, TICKETS_DIR } from "./file-store.ts"
16
- import type { Store } from "./store.ts"
14
+ import { FileStore, TICKETS_DIR, openBoard } from "./file-store.ts"
15
+ import {
16
+ GIT_NETWORK_TIMEOUT_MS,
17
+ defaultBase,
18
+ git,
19
+ gitMust,
20
+ runGit,
21
+ } from "./git.ts"
22
+ import {
23
+ boardConfig,
24
+ findBoardsByPrefix,
25
+ findRoot,
26
+ findScopeRoot,
27
+ type Store,
28
+ } from "./store.ts"
17
29
  import type { ProjectConfig, Task } from "./types.ts"
18
30
 
19
31
  /**
@@ -31,25 +43,51 @@ export const DEFAULT_CLAIM_PREFIX = "task/claim/"
31
43
  const CLAIM_PREFIX_SHAPE = /^([A-Za-z0-9._-]+\/)+$/
32
44
 
33
45
  /**
34
- * The board's claim namespace: configured `claimPrefix` (a trailing slash is
46
+ * The board's claim namespace: `claims.branchPrefix` (a trailing slash is
35
47
  * implied) or the default. Validated here because it becomes a git ref and an
36
48
  * ls-remote glob — a malformed value must fail the claim, not corrupt it.
49
+ *
50
+ * The pre-0.8 top-level `claimPrefix` spelling is not honored — no consumers,
51
+ * no compat (TAS-41) — but silently *defaulting* over it would be worse than
52
+ * a shim: the board's committed lock convention would change under every
53
+ * worker at once. So its presence refuses, loudly, by name.
37
54
  */
38
55
  export function claimNamespace(config: ProjectConfig): string {
39
- const raw = config.claimPrefix ?? DEFAULT_CLAIM_PREFIX
56
+ if (config.claimPrefix !== undefined && config.claims?.branchPrefix === undefined) {
57
+ throw new ClaimError(
58
+ '`claimPrefix` in .task/config.json is the pre-0.8 spelling and is no longer read — rename it to `claims: { "branchPrefix": … }`',
59
+ "invalid",
60
+ )
61
+ }
62
+ const raw = config.claims?.branchPrefix ?? DEFAULT_CLAIM_PREFIX
40
63
  const prefix = raw.endsWith("/") ? raw : `${raw}/`
41
64
  if (!CLAIM_PREFIX_SHAPE.test(prefix) || prefix.includes("..") || /(^|\/)\./.test(prefix)) {
42
65
  throw new ClaimError(
43
- `invalid claimPrefix in .task/config.json: ${JSON.stringify(raw)} — use slash-separated segments like "task/claim/" or "claude/task/"`,
66
+ `invalid claims.branchPrefix in .task/config.json: ${JSON.stringify(raw)} — use slash-separated segments like "task/claim/" or "claude/task/"`,
44
67
  "invalid",
45
68
  )
46
69
  }
47
70
  return prefix
48
71
  }
49
72
 
50
- /** { prefix: "TAS" }, 21 → "task/claim/tas-21" (or under the configured namespace). */
51
- export function claimBranch(config: ProjectConfig, number: number): string {
52
- return `${claimNamespace(config)}${config.prefix.toLowerCase()}-${number}`
73
+ /**
74
+ * The repo-wide cap on open claims: `claims.maxOpenCount` read from the
75
+ * *root* board's config (the outermost `.task/` above `root`), never the
76
+ * nearest one — a WIP cap each board could set for itself is no cap at all.
77
+ * Absent, unparseable or nonsensical values all mean "no cap".
78
+ */
79
+ export function openClaimCap(root: string): number | undefined {
80
+ try {
81
+ const cap = boardConfig(findScopeRoot(root)).claims?.maxOpenCount
82
+ return typeof cap === "number" && Number.isInteger(cap) && cap > 0 ? cap : undefined
83
+ } catch {
84
+ return undefined
85
+ }
86
+ }
87
+
88
+ /** { prefix: "TAS" }, "x7k4m" → "task/claim/tas-x7k4m" (or under the configured namespace). */
89
+ export function claimBranch(config: ProjectConfig, key: string): string {
90
+ return `${claimNamespace(config)}${config.prefix.toLowerCase()}-${key}`
53
91
  }
54
92
 
55
93
  /**
@@ -65,52 +103,18 @@ export class ClaimError extends Error {
65
103
  }
66
104
  }
67
105
 
68
- interface GitResult {
69
- status: number
70
- stdout: string
71
- stderr: string
72
- }
73
-
74
- function git(cwd: string, ...args: string[]): GitResult {
75
- const result = spawnSync("git", args, { cwd, encoding: "utf8" })
76
- if (result.error) throw result.error
77
- return {
78
- status: result.status ?? 1,
79
- stdout: (result.stdout ?? "").trim(),
80
- stderr: (result.stderr ?? "").trim(),
81
- }
82
- }
83
-
84
- /** Run git and throw on failure — for the steps that have no soft outcome. */
85
- function gitMust(cwd: string, ...args: string[]): string {
86
- const result = git(cwd, ...args)
106
+ /** Network git that throws on failure, with the timeout a real remote deserves. */
107
+ function gitNetMust(cwd: string, ...args: string[]): string {
108
+ const result = runGit(cwd, args, { timeoutMs: GIT_NETWORK_TIMEOUT_MS })
87
109
  if (result.status !== 0) {
88
110
  throw new Error(`git ${args[0]} failed: ${result.stderr || result.stdout}`)
89
111
  }
90
112
  return result.stdout
91
113
  }
92
114
 
93
- /**
94
- * The base every claim branch starts from: origin's default branch. Resolved
95
- * from `origin/HEAD` when the clone recorded it, with a main/master fallback
96
- * for repos wired up by hand (`git remote add` + push never sets origin/HEAD).
97
- */
98
- function defaultBase(cwd: string): string {
99
- const head = git(cwd, "symbolic-ref", "--quiet", "refs/remotes/origin/HEAD")
100
- if (head.status === 0) return head.stdout.replace(/^refs\/remotes\//, "")
101
- for (const name of ["main", "master"]) {
102
- if (git(cwd, "show-ref", "--verify", "--quiet", `refs/remotes/origin/${name}`).status === 0) {
103
- return `origin/${name}`
104
- }
105
- }
106
- throw new Error(
107
- "couldn't resolve origin's default branch — origin/HEAD is unset and neither origin/main nor origin/master exists",
108
- )
109
- }
110
-
111
115
  /** Every claim branch that exists on origin right now — one network call. */
112
116
  export function remoteClaims(cwd: string, namespace: string): Set<string> {
113
- const out = gitMust(cwd, "ls-remote", "--heads", "origin", `${namespace}*`)
117
+ const out = gitNetMust(cwd, "ls-remote", "--heads", "origin", `${namespace}*`)
114
118
  const names = new Set<string>()
115
119
  for (const line of out.split("\n")) {
116
120
  const ref = line.split("\t")[1]
@@ -120,25 +124,30 @@ export function remoteClaims(cwd: string, namespace: string): Set<string> {
120
124
  }
121
125
 
122
126
  function remoteBranchExists(cwd: string, branch: string): boolean {
123
- return gitMust(cwd, "ls-remote", "--heads", "origin", branch) !== ""
127
+ return gitNetMust(cwd, "ls-remote", "--heads", "origin", branch) !== ""
124
128
  }
125
129
 
126
130
  /** Blockers still in the way — anything not done or canceled still blocks. */
127
131
  function openBlockers(store: Store, task: Task): string[] {
128
132
  return task.blockedBy
129
- .map((n) => store.get(n))
133
+ .map((k) => store.get(k))
130
134
  .filter((b): b is Task => b !== null && b.status !== "done" && b.status !== "canceled")
131
135
  .map((b) => b.id)
132
136
  }
133
137
 
134
- /** Throws ClaimError("invalid") unless `task` is claimable right now. */
135
- function assertClaimable(store: Store, task: Task | null, number: number): asserts task is Task {
136
- const id = store.displayId(number)
138
+ /**
139
+ * Throws ClaimError("invalid") unless `task` is claimable right now. Open asks
140
+ * deliberately don't gate here: a ticket can be agent-implementable while a
141
+ * person owes it something in parallel — the done gate keeps that honest. An
142
+ * ask that must land *before* the work starts is a `blocked_by` on a thin
143
+ * human ticket, which the blocker check below already enforces.
144
+ */
145
+ function assertClaimable(store: Store, task: Task | null, key: string): asserts task is Task {
146
+ const id = store.displayId(key)
137
147
  if (!task) throw new ClaimError(`no such task: ${id}`, "invalid")
138
148
  if (task.status !== "todo") {
139
149
  throw new ClaimError(`${id} is ${task.status} — only todo tickets can be claimed`, "invalid")
140
150
  }
141
- if (task.needsHuman) throw new ClaimError(`${id} needs a human — not claimable`, "invalid")
142
151
  const blockers = openBlockers(store, task)
143
152
  if (blockers.length) {
144
153
  throw new ClaimError(`${id} is blocked by ${blockers.join(", ")} — not claimable`, "invalid")
@@ -151,6 +160,29 @@ export interface ClaimResult {
151
160
  base: string
152
161
  }
153
162
 
163
+ export interface ClaimOptions {
164
+ /** Claim past the root board's `claims.maxOpenCount` cap. */
165
+ force?: boolean
166
+ }
167
+
168
+ /**
169
+ * The WIP-cap gate every claim passes through: while `maxOpenCount` claim
170
+ * branches exist on origin, claiming more is refused (exit 2 — the fix is to
171
+ * finish or release one, not to pick a different ticket). Existence on origin
172
+ * is the definition of "open": a claim branch dies at merge or release, so a
173
+ * lingering merged branch counts — deliberate pressure to clean it up. The
174
+ * cap applies to humans and agents alike; `--force` is the escape.
175
+ */
176
+ function assertUnderCap(openClaims: Set<string>, root: string, namespace: string): void {
177
+ const cap = openClaimCap(root)
178
+ if (cap !== undefined && openClaims.size >= cap) {
179
+ throw new ClaimError(
180
+ `${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`,
181
+ "invalid",
182
+ )
183
+ }
184
+ }
185
+
154
186
  /**
155
187
  * Claim `number`: validate, branch off origin's default branch, flip the
156
188
  * ticket to in_progress as the branch's first commit, and push. The push
@@ -160,18 +192,27 @@ export interface ClaimResult {
160
192
  * someone pre-created without a claim commit can't be hijacked by a plain
161
193
  * fast-forward. Leaves the winner checked out on the claim branch.
162
194
  */
163
- export function claim(store: Store, number: number): ClaimResult {
195
+ export function claim(store: Store, key: string, options: ClaimOptions = {}): ClaimResult {
196
+ // Unreachable since openBoard refuses pre-0.6 boards, kept as the narrowing
197
+ // that lets the rest of this function assume text-canonical semantics.
164
198
  if (!(store instanceof FileStore)) {
165
- throw new ClaimError("claiming needs a text-format board — run `task migrate` first", "invalid")
199
+ throw new ClaimError(
200
+ "claiming needs a text-format board — run `npx @nickmeriano/task@0.6 migrate` once",
201
+ "invalid",
202
+ )
166
203
  }
167
204
  const cwd = store.root
168
- const branch = claimBranch(store.config, number)
169
- const id = store.displayId(number)
205
+ const branch = claimBranch(store.config, key)
206
+ const id = store.displayId(key)
170
207
 
171
208
  // The local-branch check comes before ticket validation on purpose: on the
172
209
  // claim branch itself the ticket reads in_progress, and "already claimed"
173
210
  // (exit 1, move on) is the truthful answer there — not a validation failure.
174
- const tree = git(cwd, "status", "--porcelain")
211
+ // -uno everywhere a claim reads the tree: only *tracked* modifications block
212
+ // a branch switch. Untracked files ride along untouched — and the release
213
+ // flow deliberately leaves one (the reason comment), so counting them would
214
+ // wedge the release-then-claim-next loop this exists for.
215
+ const tree = git(cwd, "status", "--porcelain", "-uno")
175
216
  if (tree.status !== 0) {
176
217
  throw new ClaimError(`not a git repository: ${cwd}`, "invalid")
177
218
  }
@@ -182,7 +223,7 @@ export function claim(store: Store, number: number): ClaimResult {
182
223
  )
183
224
  }
184
225
 
185
- assertClaimable(store, store.get(number), number)
226
+ assertClaimable(store, store.get(key), key)
186
227
  if (tree.stdout !== "") {
187
228
  throw new ClaimError(
188
229
  "working tree is dirty — commit or stash before claiming, the claim switches branches",
@@ -190,10 +231,16 @@ export function claim(store: Store, number: number): ClaimResult {
190
231
  )
191
232
  }
192
233
 
193
- gitMust(cwd, "fetch", "--quiet", "origin")
194
- if (remoteBranchExists(cwd, branch)) {
234
+ gitNetMust(cwd, "fetch", "--quiet", "origin")
235
+ // One ls-remote answers both gates: is this ticket taken, and is the repo
236
+ // at its WIP cap. "Already claimed" wins — that's exit 1 (pick the next
237
+ // ticket), where the cap is exit 2 (fix something).
238
+ const namespace = claimNamespace(store.config)
239
+ const openClaims = remoteClaims(cwd, namespace)
240
+ if (openClaims.has(branch)) {
195
241
  throw new ClaimError(`${id} is already claimed — ${branch} exists on origin`, "claimed")
196
242
  }
243
+ if (!options.force) assertUnderCap(openClaims, cwd, namespace)
197
244
 
198
245
  const base = defaultBase(cwd)
199
246
  // So a failed claim can put the checkout back exactly where it was —
@@ -212,23 +259,19 @@ export function claim(store: Store, number: number): ClaimResult {
212
259
  // Re-validate against the base branch: the pre-checkout validation read
213
260
  // whatever happened to be checked out, this one reads the truth the claim
214
261
  // will actually be built on.
215
- assertClaimable(store, store.get(number), number)
216
- store.update(number, { status: "in_progress" })
217
- gitMust(cwd, "add", "--", join(store.taskDir, TICKETS_DIR, String(number)))
262
+ assertClaimable(store, store.get(key), key)
263
+ store.update(key, { status: "in_progress" })
264
+ gitMust(cwd, "add", "--", join(store.taskDir, TICKETS_DIR, key))
218
265
  gitMust(cwd, "commit", "--quiet", "-m", `chore(board): claim ${id} → in_progress`)
219
266
  } catch (error) {
220
267
  undo()
221
268
  throw error
222
269
  }
223
270
 
224
- const push = git(
271
+ const push = runGit(
225
272
  cwd,
226
- "push",
227
- "--quiet",
228
- "-u",
229
- "origin",
230
- branch,
231
- `--force-with-lease=refs/heads/${branch}:`,
273
+ ["push", "--quiet", "-u", "origin", branch, `--force-with-lease=refs/heads/${branch}:`],
274
+ { timeoutMs: GIT_NETWORK_TIMEOUT_MS },
232
275
  )
233
276
  if (push.status !== 0) {
234
277
  undo()
@@ -241,7 +284,7 @@ export function claim(store: Store, number: number): ClaimResult {
241
284
  )
242
285
  }
243
286
 
244
- return { task: store.get(number)!, branch, base }
287
+ return { task: store.get(key)!, branch, base }
245
288
  }
246
289
 
247
290
  export interface ReleaseResult {
@@ -256,14 +299,14 @@ export interface ReleaseResult {
256
299
  * flip only ever existed as a commit on that branch, so deleting it *is* the
257
300
  * revert — the default branch never saw in_progress.
258
301
  */
259
- export function release(store: Store, number: number): ReleaseResult {
302
+ export function release(store: Store, key: string): ReleaseResult {
260
303
  const cwd = store.root
261
- const branch = claimBranch(store.config, number)
304
+ const branch = claimBranch(store.config, key)
262
305
 
263
306
  const onBranch =
264
307
  git(cwd, "symbolic-ref", "--quiet", "--short", "HEAD").stdout === branch
265
308
  if (onBranch) {
266
- const tree = gitMust(cwd, "status", "--porcelain")
309
+ const tree = gitMust(cwd, "status", "--porcelain", "-uno")
267
310
  if (tree !== "") {
268
311
  throw new ClaimError(
269
312
  `working tree on ${branch} is dirty — commit elsewhere or discard before releasing`,
@@ -283,20 +326,134 @@ export function release(store: Store, number: number): ReleaseResult {
283
326
 
284
327
  const local = git(cwd, "branch", "--quiet", "-D", branch).status === 0
285
328
  const remote = remoteBranchExists(cwd, branch)
286
- if (remote) gitMust(cwd, "push", "--quiet", "origin", "--delete", branch)
329
+ if (remote) gitNetMust(cwd, "push", "--quiet", "origin", "--delete", branch)
287
330
  return { branch, remote, local }
288
331
  }
289
332
 
290
333
  /**
291
- * The dispatcher's queue view: `todo` in position order, minus needs-human,
292
- * minus blocked, minus tickets whose claim branch already exists on origin
293
- * (one ls-remote for the whole namespace). The top entry is next up.
334
+ * The dispatcher's queue view: `todo` in position order, minus blocked, minus
335
+ * tickets whose claim branch already exists on origin (one ls-remote for the
336
+ * whole namespace). The top entry is next up. Open asks don't exclude a
337
+ * ticket — see assertClaimable.
294
338
  */
295
339
  export function claimableTasks(store: Store): Task[] {
296
340
  const claimed = remoteClaims(store.root, claimNamespace(store.config))
297
341
  return store
298
342
  .list({ statuses: ["todo"] })
299
- .filter((t) => !t.needsHuman)
300
343
  .filter((t) => openBlockers(store, t).length === 0)
301
- .filter((t) => !claimed.has(claimBranch(store.config, t.number)))
344
+ .filter((t) => !claimed.has(claimBranch(store.config, t.key)))
345
+ }
346
+
347
+ export interface PromoteResult {
348
+ task: Task
349
+ /** Claim-time gates still in the way — promoted, but not claimable yet. */
350
+ holds: string[]
351
+ }
352
+
353
+ /**
354
+ * `task promote` — the human "build this" call, as a verb: `backlog` → `todo`.
355
+ * No quality gate: planning happens at claim time (the worker's plan tick),
356
+ * so a one-line backlog ticket is promotable. Blockers are claim-time facts
357
+ * that `claim`/`--claimable` already enforce live, so promoting a blocked
358
+ * ticket is legitimate sequencing ("build this once NIC-11 lands") — reported
359
+ * as a hold, never refused. Open asks aren't a hold at all: they don't gate
360
+ * claiming, only done. Writes the ticket like `update` does; committing is
361
+ * the caller's job.
362
+ */
363
+ export function promote(store: Store, key: string): PromoteResult {
364
+ const task = store.get(key)
365
+ const id = store.displayId(key)
366
+ if (!task) throw new ClaimError(`no such task: ${id}`, "invalid")
367
+ if (task.status !== "backlog") {
368
+ throw new ClaimError(
369
+ `${id} is ${task.status} — only backlog tickets can be promoted`,
370
+ "invalid",
371
+ )
372
+ }
373
+ const updated = store.update(key, { status: "todo" })
374
+ const holds: string[] = []
375
+ const blockers = openBlockers(store, updated)
376
+ if (blockers.length) holds.push(`blocked by ${blockers.join(", ")}`)
377
+ return { task: updated, holds }
378
+ }
379
+
380
+ /**
381
+ * The boards `claim --next` and `list --claimable` select over, in priority
382
+ * order. Resolution: an explicit `--board` list > the root board's top-level
383
+ * `boards` key > the nearest board alone. The list is *selection scope*, not
384
+ * permission — `task claim <id>` works on any board regardless — and it's
385
+ * read from the repo's root board only, so a vendored board can't opt itself
386
+ * into a repo's automation by shipping a config.
387
+ */
388
+ export function selectionBoards(cwd: string, prefixes?: string[]): Store[] {
389
+ const scope = findScopeRoot(cwd)
390
+ let wanted = prefixes?.length ? prefixes : undefined
391
+ if (!wanted) {
392
+ try {
393
+ const boards = boardConfig(scope).boards
394
+ if (Array.isArray(boards) && boards.length > 0) wanted = boards.map(String)
395
+ } catch {
396
+ // No readable root board — same answer as no boards list.
397
+ }
398
+ }
399
+ if (!wanted) {
400
+ const nearest = findRoot(cwd)
401
+ if (!nearest) {
402
+ throw new ClaimError(
403
+ "no .task directory found in this directory or any parent — run `task init` first",
404
+ "invalid",
405
+ )
406
+ }
407
+ return [openBoard(nearest)]
408
+ }
409
+ return wanted.map((prefix) => {
410
+ const matches = findBoardsByPrefix(scope, prefix)
411
+ if (matches.length === 1) return openBoard(matches[0].root)
412
+ throw new ClaimError(
413
+ matches.length === 0
414
+ ? `no board with prefix ${prefix.toUpperCase()} in this repo — check --board and the root board's "boards" list`
415
+ : `prefix ${prefix.toUpperCase()} is ambiguous — boards at: ${matches.map((m) => m.id).join(", ")}`,
416
+ "invalid",
417
+ )
418
+ })
419
+ }
420
+
421
+ /**
422
+ * `task claim --next`: walk the selection scope in priority order and claim
423
+ * the first ticket that sticks. Losing a race for one ticket (another worker
424
+ * pushed its branch first) means *try the next*, so every ClaimError inside
425
+ * the loop is a skip — which is why the repo-level preconditions (dirty tree,
426
+ * malformed prefix, WIP cap) are asserted up front, where they still mean
427
+ * "stop and fix something" (exit 2) instead of draining the queue to a bogus
428
+ * "nothing claimable". Returns null when the queue really is empty (exit 1).
429
+ */
430
+ export function claimNext(stores: Store[], options: ClaimOptions = {}): ClaimResult | null {
431
+ const first = stores[0]
432
+ const tree = git(first.root, "status", "--porcelain", "-uno")
433
+ if (tree.status !== 0) {
434
+ throw new ClaimError(`not a git repository: ${first.root}`, "invalid")
435
+ }
436
+ if (tree.stdout !== "") {
437
+ throw new ClaimError(
438
+ "working tree is dirty — commit or stash before claiming, the claim switches branches",
439
+ "invalid",
440
+ )
441
+ }
442
+ for (const store of stores) claimNamespace(store.config)
443
+ if (!options.force) {
444
+ const namespace = claimNamespace(first.config)
445
+ assertUnderCap(remoteClaims(first.root, namespace), first.root, namespace)
446
+ }
447
+ for (const store of stores) {
448
+ for (const task of claimableTasks(store)) {
449
+ try {
450
+ return claim(store, task.key, options)
451
+ } catch (error) {
452
+ // Claimed out from under us, or changed since listing — next in line.
453
+ if (error instanceof ClaimError) continue
454
+ throw error
455
+ }
456
+ }
457
+ }
458
+ return null
302
459
  }