@nickmeriano/task 0.6.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.
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
+ }
package/src/cli.ts CHANGED
@@ -16,6 +16,7 @@ import type { Server } from "node:http"
16
16
  import { basename, join } from "node:path"
17
17
  import process from "node:process"
18
18
  import { resolveAuthor } from "./author.ts"
19
+ import { ClaimError, claim, claimableTasks, release } from "./claim.ts"
19
20
  import { detectRepo, parseSlug, publish, resolveHost } from "./publish.ts"
20
21
  import { createTaskServer } from "./server.ts"
21
22
  import { initProject, migrateBoard, openBoard } from "./file-store.ts"
@@ -75,6 +76,8 @@ const BOOLEAN_FLAGS = new Set([
75
76
  "needs-human",
76
77
  "no-needs-human",
77
78
  "archived",
79
+ "claimable",
80
+ "release",
78
81
  "open",
79
82
  "no-open",
80
83
  "strict-port",
@@ -284,6 +287,22 @@ function cmdAdd(args: Args): void {
284
287
 
285
288
  function cmdList(args: Args): void {
286
289
  const store = openStore()
290
+ if (args.flags.claimable) {
291
+ // The dispatcher's queue view (TAS-21): claimable already pins the status
292
+ // set and consults origin, so the board-shaping flags don't compose.
293
+ for (const flag of ["status", "all", "archived"]) {
294
+ if (args.flags[flag] !== undefined) fail(`--claimable can't be combined with --${flag}`)
295
+ }
296
+ const tasks = claimableTasks(store)
297
+ if (args.flags.json) {
298
+ console.log(JSON.stringify({ tasks }, null, 2))
299
+ } else if (tasks.length === 0) {
300
+ console.log("nothing claimable — no unblocked, unclaimed todo tickets")
301
+ } else {
302
+ table(tasks.map(taskRow))
303
+ }
304
+ return
305
+ }
287
306
  const archived = Boolean(args.flags.archived)
288
307
  const statusFlag = str(args.flags, "status")
289
308
  const statuses = statusFlag
@@ -519,6 +538,44 @@ function cmdLink(args: Args, action: "link" | "unlink"): void {
519
538
  }
520
539
  }
521
540
 
541
+ /**
542
+ * `task claim <id>` / `task claim --release <id>` — see claim.ts for the
543
+ * mechanics. Exit codes are the contract callers script against: 0 claimed,
544
+ * 1 already claimed (pick the next ticket), 2 not claimable (fix something).
545
+ */
546
+ function cmdClaim(args: Args): void {
547
+ const ref = args.positional[0]
548
+ if (!ref) fail("usage: task claim <id> | task claim --release <id>")
549
+ const store = openStoreFor(ref)
550
+ const number = store.parseId(ref)
551
+ try {
552
+ if (args.flags.release) {
553
+ const result = release(store, number)
554
+ if (args.flags.json) {
555
+ console.log(JSON.stringify({ released: result }, null, 2))
556
+ } else if (!result.remote && !result.local) {
557
+ console.log(`${store.displayId(number)} wasn't claimed — no ${result.branch} to delete`)
558
+ } else {
559
+ const where = [result.remote && "origin", result.local && "local"].filter(Boolean)
560
+ console.log(`released ${store.displayId(number)} — deleted ${result.branch} (${where.join(" and ")})`)
561
+ }
562
+ return
563
+ }
564
+ const result = claim(store, number)
565
+ if (args.flags.json) {
566
+ console.log(JSON.stringify({ task: result.task, branch: result.branch }, null, 2))
567
+ } else {
568
+ console.log(`claimed ${result.task.id} — on ${result.branch} (from ${result.base}), status in_progress`)
569
+ }
570
+ } catch (error) {
571
+ if (error instanceof ClaimError) {
572
+ console.error(`error: ${error.message}`)
573
+ process.exit(error.kind === "claimed" ? 1 : 2)
574
+ }
575
+ throw error
576
+ }
577
+ }
578
+
522
579
  const AUTHOR_SOURCE: Record<string, string> = {
523
580
  flag: "--author",
524
581
  env: "$TASK_AUTHOR",
@@ -764,7 +821,7 @@ Usage
764
821
  task add <title> [--description <text>] [--status <s>] [--tags <a,b>]
765
822
  [--milestone <m>] [--needs-human]
766
823
  task list [--status <s1,s2>] [--tag <a,b>] [--milestone <m>]
767
- [--needs-human] [--all] [--archived]
824
+ [--needs-human] [--all] [--archived] [--claimable]
768
825
  task show <id>
769
826
  task update <id> [--title <t>] [--description <text>] [--status <s>]
770
827
  [--tags <a,b>] [--milestone <m>]
@@ -777,6 +834,24 @@ Usage
777
834
  task link <id> --blocks <id> mark a dependency — one relation, visible from
778
835
  both tasks (A blocked by B ⇔ B blocks A)
779
836
  task unlink <id> (--blocks <id> | --blocked-by <id>)
837
+ task claim <id> claim a ticket before working it: branch
838
+ <claimPrefix><prefix>-<n> off origin's default
839
+ branch, ticket → in_progress as its first
840
+ commit, pushed. The namespace defaults to
841
+ task/claim/ — set "claimPrefix" in
842
+ .task/config.json to change it (e.g.
843
+ "claude/task/", which Claude cloud sessions
844
+ can push). Branch on origin = claimed —
845
+ git's atomic ref creation is the lock, so two
846
+ concurrent claimers can't both win. Exit codes:
847
+ 0 claimed, 1 already claimed, 2 not claimable
848
+ (not todo, blocked, needs-human, dirty tree)
849
+ task claim --release <id> abandon a claim: delete the branch on origin
850
+ and locally — the status flip only lived on
851
+ the branch, so deleting it is the revert
852
+ task list --claimable the claim queue: todo tickets in position
853
+ order, minus blocked / needs-human / already
854
+ claimed on origin. Top entry is next up
780
855
  task comment <id> <text> [--author <who>]
781
856
  task delete <id>
782
857
  task boards every board in this repo — prefix, name, path,
@@ -862,6 +937,8 @@ function main(): void | Promise<void> {
862
937
  return cmdLink(args, "link")
863
938
  case "unlink":
864
939
  return cmdLink(args, "unlink")
940
+ case "claim":
941
+ return cmdClaim(args)
865
942
  case "comment":
866
943
  return cmdComment(args)
867
944
  case "whoami":
package/src/index.ts CHANGED
@@ -27,6 +27,18 @@ export {
27
27
  type TicketDoc,
28
28
  type CommentDoc,
29
29
  } from "./ticket-doc.ts"
30
+ export {
31
+ DEFAULT_CLAIM_PREFIX,
32
+ ClaimError,
33
+ claim,
34
+ claimBranch,
35
+ claimNamespace,
36
+ claimableTasks,
37
+ release,
38
+ remoteClaims,
39
+ type ClaimResult,
40
+ type ReleaseResult,
41
+ } from "./claim.ts"
30
42
  export { createTaskServer } from "./server.ts"
31
43
  export { resolveAuthor, ANONYMOUS, type Author, type AuthorSource } from "./author.ts"
32
44
  export * from "./types.ts"
package/src/types.ts CHANGED
@@ -59,6 +59,14 @@ export interface ProjectConfig {
59
59
  /** Uppercase id prefix, e.g. "PHO" → PHO-1, PHO-2, … */
60
60
  prefix: string
61
61
  version: number
62
+ /**
63
+ * Branch namespace `task claim` creates claim branches under — committed
64
+ * with the board on purpose, so every worker and every clone agree on what
65
+ * "claimed" looks like. Defaults to "task/claim/"; repos whose workers are
66
+ * Claude Code cloud sessions set "claude/task/", the one prefix that
67
+ * runtime can push without extra ceremony.
68
+ */
69
+ claimPrefix?: string
62
70
  }
63
71
 
64
72
  export interface TaskFilter {