@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/README.md +41 -0
- package/dist/claim.d.ts +76 -0
- package/dist/claim.d.ts.map +1 -0
- package/dist/claim.js +237 -0
- package/dist/claim.js.map +1 -0
- package/dist/claim.test.d.ts +15 -0
- package/dist/claim.test.d.ts.map +1 -0
- package/dist/claim.test.js +185 -0
- package/dist/claim.test.js.map +1 -0
- package/dist/cli.js +85 -1
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/types.d.ts +8 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +1 -1
- package/skill/SKILL.md +22 -0
- package/src/claim.test.ts +242 -0
- package/src/claim.ts +302 -0
- package/src/cli.ts +78 -1
- package/src/index.ts +12 -0
- package/src/types.ts +8 -0
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 {
|