@nickmeriano/task 0.5.0 → 0.7.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 (53) hide show
  1. package/README.md +104 -8
  2. package/dist/claim.d.ts +76 -0
  3. package/dist/claim.d.ts.map +1 -0
  4. package/dist/claim.js +237 -0
  5. package/dist/claim.js.map +1 -0
  6. package/dist/claim.test.d.ts +15 -0
  7. package/dist/claim.test.d.ts.map +1 -0
  8. package/dist/claim.test.js +185 -0
  9. package/dist/claim.test.js.map +1 -0
  10. package/dist/cli.js +304 -17
  11. package/dist/cli.js.map +1 -1
  12. package/dist/file-store.d.ts +113 -0
  13. package/dist/file-store.d.ts.map +1 -0
  14. package/dist/file-store.js +604 -0
  15. package/dist/file-store.js.map +1 -0
  16. package/dist/index.d.ts +7 -4
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +4 -1
  19. package/dist/index.js.map +1 -1
  20. package/dist/server.d.ts.map +1 -1
  21. package/dist/server.js +11 -7
  22. package/dist/server.js.map +1 -1
  23. package/dist/store.d.ts +55 -15
  24. package/dist/store.d.ts.map +1 -1
  25. package/dist/store.js +63 -29
  26. package/dist/store.js.map +1 -1
  27. package/dist/store.test.d.ts +12 -0
  28. package/dist/store.test.d.ts.map +1 -0
  29. package/dist/store.test.js +252 -0
  30. package/dist/store.test.js.map +1 -0
  31. package/dist/ticket-doc.d.ts +57 -0
  32. package/dist/ticket-doc.d.ts.map +1 -0
  33. package/dist/ticket-doc.js +197 -0
  34. package/dist/ticket-doc.js.map +1 -0
  35. package/dist/types.d.ts +22 -1
  36. package/dist/types.d.ts.map +1 -1
  37. package/dist/types.js.map +1 -1
  38. package/package.json +4 -4
  39. package/skill/SKILL.md +45 -8
  40. package/src/claim.test.ts +242 -0
  41. package/src/claim.ts +302 -0
  42. package/src/cli.ts +313 -20
  43. package/src/file-store.ts +693 -0
  44. package/src/index.ts +42 -4
  45. package/src/server.ts +15 -11
  46. package/src/store.test.ts +305 -0
  47. package/src/store.ts +99 -40
  48. package/src/ticket-doc.ts +226 -0
  49. package/src/types.ts +22 -1
  50. package/ui/dist/assets/index-CXW8uT5f.css +1 -0
  51. package/ui/dist/assets/{index-D_qmmh3D.js → index-oJzomUDL.js} +58 -58
  52. package/ui/dist/index.html +2 -2
  53. package/ui/dist/assets/index-Da14ye1f.css +0 -1
package/skill/SKILL.md CHANGED
@@ -2,7 +2,7 @@
2
2
  name: tasks
3
3
  description: >-
4
4
  Work with this repository's built-in task manager (the `task` CLI — a .task/
5
- directory with a SQLite database, from @nickmeriano/task). Use this
5
+ directory of plain-text tickets, from @nickmeriano/task). Use this
6
6
  skill whenever the user mentions tasks, tickets, issues, todos, backlog,
7
7
  kanban, board, "what's next", "what should I work on", or asks you to plan,
8
8
  track, or report progress on work — and also on your own initiative: when you
@@ -13,11 +13,14 @@ description: >-
13
13
 
14
14
  # tasks — the repo's task manager
15
15
 
16
- This repository tracks its work in `.task/` at the project root: a SQLite
17
- database driven by the `task` CLI. Tasks live in the repo, next to the code
18
- they describe — when you update a task, the user sees it instantly (the board
19
- UI at `task serve` updates in realtime), and the state is committed with the
20
- code, so it survives sessions and travels with branches.
16
+ This repository tracks its work in `.task/` at the project root: one markdown
17
+ file per ticket under `.task/tickets/`, driven by the `task` CLI. Tasks live in
18
+ the repo, next to the code they describe — when you update a task, the user
19
+ sees it instantly (the board UI at `task serve` updates in realtime), and the
20
+ state is committed with the code, so it survives sessions, travels with
21
+ branches, and shows up as readable diffs in PRs. Always write through the CLI
22
+ rather than editing ticket files by hand — it keeps numbering, positions and
23
+ relations consistent.
21
24
 
22
25
  Run `task` from anywhere in the repo; it walks up to find `.task/` like git
23
26
  finds `.git`. If the CLI isn't on PATH, use `npx @nickmeriano/task` instead.
@@ -43,8 +46,15 @@ task move <id> <status> # any status change
43
46
  task link <id> --blocked-by <id> # mark a dependency (or --blocks)
44
47
  task unlink <id> --blocked-by <id> # remove one (or --blocks)
45
48
  task update <id> --pr <url> # attach a pull request (appends)
49
+ task claim <id> # claim a ticket before working it
50
+ task claim --release <id> # abandon a claim cleanly
51
+ task list --claimable --json # the claim queue, top entry = next up
46
52
  task comment <id> "text" --author claude
47
53
  task delete <id>
54
+ task boards --json # every board in the repo, with prefixes
55
+ task archive <id> --json # done/canceled ticket -> .task/archive/
56
+ task archive --all --json # archive everything done or canceled
57
+ task unarchive <id> --json # put one back on the board
48
58
  task whoami # who comments are attributed to
49
59
  ```
50
60
 
@@ -53,11 +63,19 @@ the user can read it from a phone, and take it away again. Both open a browser
53
63
  and wait for a human, so **never run either on your own initiative**; suggest
54
64
  `task publish` if the user wants to see the board remotely.
55
65
 
56
- - `<id>` is `PREFIX-12` or just `12`.
66
+ - `<id>` is `PREFIX-12` or just `12`. A bare number means the nearest board;
67
+ a prefixed id routes to whichever board in the repo owns that prefix, so
68
+ `TAS-12` works from any directory. `task boards --json` lists them all.
57
69
  - Statuses: `backlog` `todo` `in_progress` `done` `canceled`.
58
70
  - `--tag a,b` matches a task carrying *either* tag, not both.
59
71
  - Clear a field by passing it empty: `--tags ""`, `--milestone ""`, `--prs ""`.
60
- - `task list` hides done/canceled by default; `--all` shows everything.
72
+ - `task list` hides done/canceled by default; `--all` shows everything, and
73
+ `--archived` lists the archive instead of the board.
74
+ - Archiving is history, not deletion: only done/canceled tickets qualify,
75
+ their numbers stay reserved, and `task show` still reads them. Archived
76
+ tickets refuse edits until unarchived. Don't archive on your own
77
+ initiative — suggest `task archive --all` when finished work is piling up,
78
+ and let the user decide.
61
79
 
62
80
  **Dependencies.** `task link A --blocked-by B` and `task link B --blocks A`
63
81
  write the same relation — each task's `task show` lists both its `blocked`
@@ -67,6 +85,25 @@ ordering ("this can't land until X does"), record it with `task link` instead
67
85
  of prose in a comment. Blocked tasks wear a red "Blocked" badge on the board
68
86
  until every blocker is done or canceled.
69
87
 
88
+ **Claiming.** On a board worked by several sessions (scheduled agents, other
89
+ humans), `task claim <id>` is the lock that keeps two workers off the same
90
+ ticket: it branches `<claimPrefix><prefix>-<n>` off origin's default branch
91
+ (`claimPrefix` in `.task/config.json`, default `task/claim/`), flips the
92
+ ticket to `in_progress` as the branch's first commit, and pushes. The branch
93
+ existing on origin *is* the claim — atomic, because the push only succeeds if
94
+ the branch doesn't exist yet. Exit codes: `0` claimed (you're on the branch,
95
+ start working), `1` already claimed (pick another ticket), `2` not claimable
96
+ (not todo, blocked, needs-human, or dirty tree). **Claim before you work**:
97
+ if you're about to implement a `todo` ticket in a repo that claims (a
98
+ `claimPrefix` in the config, or claims visible in
99
+ `git ls-remote origin '<claimPrefix>*'`), run `task claim <id>` first so no
100
+ scheduled run picks it up under you — and do the whole ticket on the claim
101
+ branch, `task done` + `--pr` included, so the merge lands code and status
102
+ together. `task claim --release <id>` abandons a claim (deletes the branch
103
+ both sides); `task list --claimable --json` is the queue. Never edit
104
+ `claimPrefix` on your own initiative — it's the shared lock convention, and
105
+ changing it strands every in-flight claim.
106
+
70
107
  **Pull requests.** When you open a PR for a task, attach it:
71
108
  `task update <id> --pr <url>`. Put the task id in the PR title too (e.g.
72
109
  `[TAS-6] Fix retry loop`) so the two reference each other from both sides.
@@ -0,0 +1,242 @@
1
+ /**
2
+ * The properties TAS-21 exists for, checked from the outside — against a real
3
+ * bare "origin" and real CLI subprocesses, because the whole point of claiming
4
+ * is what happens between two independent checkouts:
5
+ *
6
+ * 1. Two concurrent `task claim <same-id>`: exactly one exits 0, the other
7
+ * exits 1 — git's atomic ref creation is the lock.
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.
11
+ * 4. `task claim --release` deletes the branch both sides and the ticket is
12
+ * claimable again.
13
+ */
14
+
15
+ import assert from "node:assert/strict"
16
+ import { test } from "node:test"
17
+ import { execFile, spawnSync } from "node:child_process"
18
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs"
19
+ import { readFileSync } from "node:fs"
20
+ import { tmpdir } from "node:os"
21
+ import { join } from "node:path"
22
+ import { fileURLToPath } from "node:url"
23
+ import { initProject } from "./file-store.ts"
24
+
25
+ const CLI = fileURLToPath(new URL("./cli.ts", import.meta.url))
26
+
27
+ function tempDir(): string {
28
+ const dir = mkdtempSync(join(tmpdir(), "task-claim-test-"))
29
+ process.on("exit", () => rmSync(dir, { recursive: true, force: true }))
30
+ return dir
31
+ }
32
+
33
+ function sh(cwd: string, command: string, ...args: string[]): string {
34
+ const result = spawnSync(command, args, { cwd, encoding: "utf8" })
35
+ if (result.status !== 0) {
36
+ throw new Error(`${command} ${args.join(" ")} failed: ${result.stderr}`)
37
+ }
38
+ return result.stdout.trim()
39
+ }
40
+
41
+ interface CliResult {
42
+ code: number
43
+ stdout: string
44
+ stderr: string
45
+ }
46
+
47
+ /** The CLI as callers see it — a subprocess with an exit code. */
48
+ function cli(cwd: string, ...args: string[]): Promise<CliResult> {
49
+ return new Promise((resolve) => {
50
+ execFile(
51
+ process.execPath,
52
+ ["--experimental-strip-types", CLI, ...args],
53
+ { cwd },
54
+ (error, stdout, stderr) => {
55
+ resolve({ code: error ? ((error as { code?: number }).code ?? 1) : 0, stdout, stderr })
56
+ },
57
+ )
58
+ })
59
+ }
60
+
61
+ interface Fixture {
62
+ bare: string
63
+ clone: () => string
64
+ }
65
+
66
+ /**
67
+ * A bare origin holding a board with the claimable shapes:
68
+ * 1 "Ready one" todo
69
+ * 2 "Ready two" todo
70
+ * 3 "Blocked" todo, blocked by 1
71
+ * 4 "For a person" todo, needs-human
72
+ * 5 "Not groomed" backlog
73
+ * 6 "Jumped queue" todo, moved to the top of the column after creation —
74
+ * claimable order must be [6, 1, 2], proving position
75
+ * order beats number order.
76
+ */
77
+ function fixture(claimPrefix?: string): Fixture {
78
+ const home = tempDir()
79
+ const bare = join(home, "origin.git")
80
+ sh(home, "git", "init", "--quiet", "--bare", "-b", "main", bare)
81
+
82
+ const seed = join(home, "seed")
83
+ sh(home, "git", "clone", "--quiet", bare, seed)
84
+ sh(seed, "git", "config", "user.name", "Test")
85
+ sh(seed, "git", "config", "user.email", "test@example.com")
86
+ const store = initProject(seed, { name: "claim board", prefix: "CLM" })
87
+ if (claimPrefix !== undefined) {
88
+ const configPath = join(seed, ".task", "config.json")
89
+ const config = JSON.parse(readFileSync(configPath, "utf8")) as Record<string, unknown>
90
+ config.claimPrefix = claimPrefix
91
+ writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`)
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" })
98
+ const jumper = store.create({ title: "Jumped queue", status: "backlog" })
99
+ // A status move without an explicit slot lands on top of the new column.
100
+ store.update(jumper.number, { status: "todo" })
101
+ sh(seed, "git", "add", "-A")
102
+ sh(seed, "git", "commit", "--quiet", "-m", "board")
103
+ sh(seed, "git", "push", "--quiet", "-u", "origin", "main")
104
+
105
+ let clones = 0
106
+ const clone = (): string => {
107
+ const dir = join(home, `clone-${++clones}`)
108
+ sh(home, "git", "clone", "--quiet", bare, dir)
109
+ sh(dir, "git", "config", "user.name", "Test")
110
+ sh(dir, "git", "config", "user.email", "test@example.com")
111
+ return dir
112
+ }
113
+ return { bare, clone }
114
+ }
115
+
116
+ test("two concurrent claims of one ticket: exactly one wins", async () => {
117
+ const { bare, clone } = fixture()
118
+ const a = clone()
119
+ const b = clone()
120
+
121
+ const [ra, rb] = await Promise.all([cli(a, "claim", "1"), cli(b, "claim", "1")])
122
+ assert.deepEqual([ra.code, rb.code].sort(), [0, 1], `a: ${ra.stderr}\nb: ${rb.stderr}`)
123
+
124
+ const winner = ra.code === 0 ? a : b
125
+ const loser = ra.code === 0 ? rb : ra
126
+ assert.match(loser.stderr, /already claimed|rejected/)
127
+
128
+ // 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")
130
+ assert.match(
131
+ readFileSync(join(winner, ".task", "tickets", "1", "ticket.md"), "utf8"),
132
+ /status: in_progress/,
133
+ )
134
+ assert.match(
135
+ sh(winner, "git", "ls-remote", "--heads", "origin", "task/claim/clm-1"),
136
+ /refs\/heads\/task\/claim\/clm-1/,
137
+ )
138
+ // Origin's default branch never saw the flip — the claim rides the branch.
139
+ 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/)
141
+ void bare
142
+ })
143
+
144
+ test("unclaimable tickets exit 2, before anything touches origin", async () => {
145
+ const { clone } = fixture()
146
+ const dir = clone()
147
+
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/)
153
+
154
+ // A dirty tree refuses too — claiming switches branches.
155
+ writeFileSync(join(dir, "scratch.txt"), "wip")
156
+ const dirty = await cli(dir, "claim", "2")
157
+ assert.equal(dirty.code, 2)
158
+ assert.match(dirty.stderr, /working tree is dirty/)
159
+ rmSync(join(dir, "scratch.txt"))
160
+ assert.equal((await cli(dir, "claim", "2")).code, 0)
161
+
162
+ // Nothing above created a stray claim branch for the failed ids.
163
+ const remote = sh(dir, "git", "ls-remote", "--heads", "origin", "task/claim/*")
164
+ assert.deepEqual(
165
+ remote.split("\n").map((l) => l.split("refs/heads/")[1]),
166
+ ["task/claim/clm-2"],
167
+ )
168
+ })
169
+
170
+ test("list --claimable is the queue: position order, minus everything unclaimable", async () => {
171
+ const { clone } = fixture()
172
+ const a = clone()
173
+ assert.equal((await cli(a, "claim", "6")).code, 0)
174
+
175
+ const b = clone()
176
+ const listed = await cli(b, "list", "--claimable", "--json")
177
+ assert.equal(listed.code, 0)
178
+ const { tasks } = JSON.parse(listed.stdout) as { tasks: { number: number }[] }
179
+ // 6 claimed, 3 blocked, 4 needs-human, 5 not todo — position order keeps
180
+ // 1 before 2 (creation order within the column).
181
+ assert.deepEqual(tasks.map((t) => t.number), [1, 2])
182
+
183
+ assert.equal((await cli(b, "list", "--claimable", "--status", "todo")).code, 1)
184
+ })
185
+
186
+ test("release deletes the branch everywhere and reopens the claim", async () => {
187
+ const { clone } = fixture()
188
+ const dir = clone()
189
+ assert.equal((await cli(dir, "claim", "2")).code, 0)
190
+ assert.equal((await cli(dir, "claim", "2")).code, 1) // locally claimed too
191
+
192
+ const released = await cli(dir, "claim", "--release", "2")
193
+ assert.equal(released.code, 0, released.stderr)
194
+ assert.match(released.stdout, /released CLM-2/)
195
+
196
+ // Back on the default branch, branch gone on both sides, ticket todo again.
197
+ assert.equal(sh(dir, "git", "rev-parse", "--abbrev-ref", "HEAD"), "main")
198
+ assert.equal(sh(dir, "git", "ls-remote", "--heads", "origin", "task/claim/clm-2"), "")
199
+ assert.match(
200
+ readFileSync(join(dir, ".task", "tickets", "2", "ticket.md"), "utf8"),
201
+ /status: todo/,
202
+ )
203
+ assert.equal((await cli(dir, "claim", "2")).code, 0)
204
+
205
+ // Releasing an unclaimed ticket is a quiet no-op, not an error.
206
+ const noop = await cli(dir, "claim", "--release", "1")
207
+ assert.equal(noop.code, 0)
208
+ assert.match(noop.stdout, /wasn't claimed/)
209
+ })
210
+
211
+ test("a configured claimPrefix moves the whole namespace", async () => {
212
+ const { clone } = fixture("claude/task/")
213
+ const dir = clone()
214
+
215
+ assert.equal((await cli(dir, "claim", "1")).code, 0)
216
+ assert.equal(sh(dir, "git", "rev-parse", "--abbrev-ref", "HEAD"), "claude/task/clm-1")
217
+ assert.match(
218
+ sh(dir, "git", "ls-remote", "--heads", "origin", "claude/task/*"),
219
+ /refs\/heads\/claude\/task\/clm-1/,
220
+ )
221
+
222
+ // Every side of the feature reads the same key: the queue looks for claims
223
+ // under the configured namespace, and release deletes there too.
224
+ const other = clone()
225
+ const listed = await cli(other, "list", "--claimable", "--json")
226
+ const { tasks } = JSON.parse(listed.stdout) as { tasks: { number: number }[] }
227
+ assert.deepEqual(tasks.map((t) => t.number), [6, 2])
228
+ assert.equal((await cli(other, "claim", "1")).code, 1)
229
+ assert.equal((await cli(dir, "claim", "--release", "1")).code, 0)
230
+ assert.equal(sh(dir, "git", "ls-remote", "--heads", "origin", "claude/task/*"), "")
231
+
232
+ // A missing trailing slash is forgiven; a malformed value fails loudly.
233
+ const bad = clone()
234
+ const configPath = join(bad, ".task", "config.json")
235
+ const config = JSON.parse(readFileSync(configPath, "utf8")) as Record<string, unknown>
236
+ config.claimPrefix = "spaces are not refs/"
237
+ writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`)
238
+ sh(bad, "git", "commit", "--quiet", "-am", "bad prefix")
239
+ const invalid = await cli(bad, "claim", "2")
240
+ assert.equal(invalid.code, 2)
241
+ assert.match(invalid.stderr, /invalid claimPrefix/)
242
+ })
package/src/claim.ts ADDED
@@ -0,0 +1,302 @@
1
+ /**
2
+ * `task claim` — atomic ticket claiming, for humans and scheduled agents alike
3
+ * (NIC-7 / TAS-21). The claim *is* the ticket's work branch: one deterministic
4
+ * name per ticket, creating it on origin is claiming it, and git's atomic ref
5
+ * creation is the lock. Branch existence = claimed; the branch dies at merge,
6
+ * so there is no claim state to clean up.
7
+ *
8
+ * The CLI stays board-level and forge-agnostic — everything here is plain git
9
+ * against `origin`. PR/session awareness belongs to whatever drives the claim
10
+ * (the implement-task skill, a human).
11
+ */
12
+
13
+ import { spawnSync } from "node:child_process"
14
+ import { join } from "node:path"
15
+ import { FileStore, TICKETS_DIR } from "./file-store.ts"
16
+ import type { Store } from "./store.ts"
17
+ import type { ProjectConfig, Task } from "./types.ts"
18
+
19
+ /**
20
+ * The branch namespace claims live under when the board doesn't configure
21
+ * one. Deliberately vendor-neutral: repos whose workers are Claude Code cloud
22
+ * sessions set `"claimPrefix": "claude/task/"` in `.task/config.json` — the
23
+ * one namespace that runtime can push without extra ceremony — and any other
24
+ * convention is equally valid. The key is committed with the board so every
25
+ * worker and every clone agree on what "claimed" looks like; a lock two
26
+ * sides spell differently is no lock at all.
27
+ */
28
+ export const DEFAULT_CLAIM_PREFIX = "task/claim/"
29
+
30
+ /** Slash-terminated path segments of ref-safe characters. */
31
+ const CLAIM_PREFIX_SHAPE = /^([A-Za-z0-9._-]+\/)+$/
32
+
33
+ /**
34
+ * The board's claim namespace: configured `claimPrefix` (a trailing slash is
35
+ * implied) or the default. Validated here because it becomes a git ref and an
36
+ * ls-remote glob — a malformed value must fail the claim, not corrupt it.
37
+ */
38
+ export function claimNamespace(config: ProjectConfig): string {
39
+ const raw = config.claimPrefix ?? DEFAULT_CLAIM_PREFIX
40
+ const prefix = raw.endsWith("/") ? raw : `${raw}/`
41
+ if (!CLAIM_PREFIX_SHAPE.test(prefix) || prefix.includes("..") || /(^|\/)\./.test(prefix)) {
42
+ throw new ClaimError(
43
+ `invalid claimPrefix in .task/config.json: ${JSON.stringify(raw)} — use slash-separated segments like "task/claim/" or "claude/task/"`,
44
+ "invalid",
45
+ )
46
+ }
47
+ return prefix
48
+ }
49
+
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}`
53
+ }
54
+
55
+ /**
56
+ * Why a claim was refused: "claimed" (someone holds the branch — exit 1, pick
57
+ * the next ticket) vs "invalid" (the ticket or the tree isn't claimable —
58
+ * exit 2, fix something).
59
+ */
60
+ export class ClaimError extends Error {
61
+ readonly kind: "claimed" | "invalid"
62
+ constructor(message: string, kind: "claimed" | "invalid") {
63
+ super(message)
64
+ this.kind = kind
65
+ }
66
+ }
67
+
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)
87
+ if (result.status !== 0) {
88
+ throw new Error(`git ${args[0]} failed: ${result.stderr || result.stdout}`)
89
+ }
90
+ return result.stdout
91
+ }
92
+
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
+ /** Every claim branch that exists on origin right now — one network call. */
112
+ export function remoteClaims(cwd: string, namespace: string): Set<string> {
113
+ const out = gitMust(cwd, "ls-remote", "--heads", "origin", `${namespace}*`)
114
+ const names = new Set<string>()
115
+ for (const line of out.split("\n")) {
116
+ const ref = line.split("\t")[1]
117
+ if (ref?.startsWith("refs/heads/")) names.add(ref.slice("refs/heads/".length))
118
+ }
119
+ return names
120
+ }
121
+
122
+ function remoteBranchExists(cwd: string, branch: string): boolean {
123
+ return gitMust(cwd, "ls-remote", "--heads", "origin", branch) !== ""
124
+ }
125
+
126
+ /** Blockers still in the way — anything not done or canceled still blocks. */
127
+ function openBlockers(store: Store, task: Task): string[] {
128
+ return task.blockedBy
129
+ .map((n) => store.get(n))
130
+ .filter((b): b is Task => b !== null && b.status !== "done" && b.status !== "canceled")
131
+ .map((b) => b.id)
132
+ }
133
+
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)
137
+ if (!task) throw new ClaimError(`no such task: ${id}`, "invalid")
138
+ if (task.status !== "todo") {
139
+ throw new ClaimError(`${id} is ${task.status} — only todo tickets can be claimed`, "invalid")
140
+ }
141
+ if (task.needsHuman) throw new ClaimError(`${id} needs a human — not claimable`, "invalid")
142
+ const blockers = openBlockers(store, task)
143
+ if (blockers.length) {
144
+ throw new ClaimError(`${id} is blocked by ${blockers.join(", ")} — not claimable`, "invalid")
145
+ }
146
+ }
147
+
148
+ export interface ClaimResult {
149
+ task: Task
150
+ branch: string
151
+ base: string
152
+ }
153
+
154
+ /**
155
+ * Claim `number`: validate, branch off origin's default branch, flip the
156
+ * ticket to in_progress as the branch's first commit, and push. The push
157
+ * carries `--force-with-lease=<branch>:` (empty expectation = "the ref must
158
+ * not exist"), so creating the remote branch is a compare-and-swap: two
159
+ * concurrent claimers of the same ticket, exactly one wins — and a branch
160
+ * someone pre-created without a claim commit can't be hijacked by a plain
161
+ * fast-forward. Leaves the winner checked out on the claim branch.
162
+ */
163
+ export function claim(store: Store, number: number): ClaimResult {
164
+ if (!(store instanceof FileStore)) {
165
+ throw new ClaimError("claiming needs a text-format board — run `task migrate` first", "invalid")
166
+ }
167
+ const cwd = store.root
168
+ const branch = claimBranch(store.config, number)
169
+ const id = store.displayId(number)
170
+
171
+ // The local-branch check comes before ticket validation on purpose: on the
172
+ // claim branch itself the ticket reads in_progress, and "already claimed"
173
+ // (exit 1, move on) is the truthful answer there — not a validation failure.
174
+ const tree = git(cwd, "status", "--porcelain")
175
+ if (tree.status !== 0) {
176
+ throw new ClaimError(`not a git repository: ${cwd}`, "invalid")
177
+ }
178
+ if (git(cwd, "show-ref", "--verify", "--quiet", `refs/heads/${branch}`).status === 0) {
179
+ throw new ClaimError(
180
+ `${id} is already claimed — ${branch} exists locally (finish it, or \`task claim --release ${id}\`)`,
181
+ "claimed",
182
+ )
183
+ }
184
+
185
+ assertClaimable(store, store.get(number), number)
186
+ if (tree.stdout !== "") {
187
+ throw new ClaimError(
188
+ "working tree is dirty — commit or stash before claiming, the claim switches branches",
189
+ "invalid",
190
+ )
191
+ }
192
+
193
+ gitMust(cwd, "fetch", "--quiet", "origin")
194
+ if (remoteBranchExists(cwd, branch)) {
195
+ throw new ClaimError(`${id} is already claimed — ${branch} exists on origin`, "claimed")
196
+ }
197
+
198
+ const base = defaultBase(cwd)
199
+ // So a failed claim can put the checkout back exactly where it was —
200
+ // a branch name usually, a bare sha when HEAD was detached.
201
+ const previous =
202
+ git(cwd, "symbolic-ref", "--quiet", "--short", "HEAD").stdout ||
203
+ gitMust(cwd, "rev-parse", "HEAD")
204
+ gitMust(cwd, "checkout", "--quiet", "-b", branch, base)
205
+
206
+ const undo = (): void => {
207
+ git(cwd, "checkout", "--quiet", previous)
208
+ git(cwd, "branch", "--quiet", "-D", branch)
209
+ }
210
+
211
+ try {
212
+ // Re-validate against the base branch: the pre-checkout validation read
213
+ // whatever happened to be checked out, this one reads the truth the claim
214
+ // 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)))
218
+ gitMust(cwd, "commit", "--quiet", "-m", `chore(board): claim ${id} → in_progress`)
219
+ } catch (error) {
220
+ undo()
221
+ throw error
222
+ }
223
+
224
+ const push = git(
225
+ cwd,
226
+ "push",
227
+ "--quiet",
228
+ "-u",
229
+ "origin",
230
+ branch,
231
+ `--force-with-lease=refs/heads/${branch}:`,
232
+ )
233
+ if (push.status !== 0) {
234
+ undo()
235
+ if (remoteBranchExists(cwd, branch)) {
236
+ throw new ClaimError(`${id} is already claimed — ${branch} was just created on origin`, "claimed")
237
+ }
238
+ throw new ClaimError(
239
+ `${id}: push of ${branch} was rejected (likely a concurrent claim) — ${push.stderr || "no detail from git"}`,
240
+ "claimed",
241
+ )
242
+ }
243
+
244
+ return { task: store.get(number)!, branch, base }
245
+ }
246
+
247
+ export interface ReleaseResult {
248
+ branch: string
249
+ /** Whether a branch was actually there to delete, per side. */
250
+ remote: boolean
251
+ local: boolean
252
+ }
253
+
254
+ /**
255
+ * Abandon a claim cleanly: delete the branch on origin and locally. The status
256
+ * flip only ever existed as a commit on that branch, so deleting it *is* the
257
+ * revert — the default branch never saw in_progress.
258
+ */
259
+ export function release(store: Store, number: number): ReleaseResult {
260
+ const cwd = store.root
261
+ const branch = claimBranch(store.config, number)
262
+
263
+ const onBranch =
264
+ git(cwd, "symbolic-ref", "--quiet", "--short", "HEAD").stdout === branch
265
+ if (onBranch) {
266
+ const tree = gitMust(cwd, "status", "--porcelain")
267
+ if (tree !== "") {
268
+ throw new ClaimError(
269
+ `working tree on ${branch} is dirty — commit elsewhere or discard before releasing`,
270
+ "invalid",
271
+ )
272
+ }
273
+ // Step off the branch so it can be deleted: onto the local default branch
274
+ // when there is one, detached onto the remote base otherwise.
275
+ const base = defaultBase(cwd)
276
+ const local = base.replace(/^origin\//, "")
277
+ if (git(cwd, "show-ref", "--verify", "--quiet", `refs/heads/${local}`).status === 0) {
278
+ gitMust(cwd, "checkout", "--quiet", local)
279
+ } else {
280
+ gitMust(cwd, "checkout", "--quiet", "--detach", base)
281
+ }
282
+ }
283
+
284
+ const local = git(cwd, "branch", "--quiet", "-D", branch).status === 0
285
+ const remote = remoteBranchExists(cwd, branch)
286
+ if (remote) gitMust(cwd, "push", "--quiet", "origin", "--delete", branch)
287
+ return { branch, remote, local }
288
+ }
289
+
290
+ /**
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.
294
+ */
295
+ export function claimableTasks(store: Store): Task[] {
296
+ const claimed = remoteClaims(store.root, claimNamespace(store.config))
297
+ return store
298
+ .list({ statuses: ["todo"] })
299
+ .filter((t) => !t.needsHuman)
300
+ .filter((t) => openBlockers(store, t).length === 0)
301
+ .filter((t) => !claimed.has(claimBranch(store.config, t.number)))
302
+ }