@nickmeriano/task 0.8.0 → 0.10.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 (115) hide show
  1. package/README.md +137 -22
  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 +3 -2
  7. package/dist/check.d.ts.map +1 -1
  8. package/dist/check.js +99 -60
  9. package/dist/check.js.map +1 -1
  10. package/dist/check.test.js +99 -60
  11. package/dist/check.test.js.map +1 -1
  12. package/dist/claim-io.d.ts +73 -0
  13. package/dist/claim-io.d.ts.map +1 -0
  14. package/dist/claim-io.js +344 -0
  15. package/dist/claim-io.js.map +1 -0
  16. package/dist/claim.d.ts +108 -23
  17. package/dist/claim.d.ts.map +1 -1
  18. package/dist/claim.js +320 -86
  19. package/dist/claim.js.map +1 -1
  20. package/dist/claim.test.d.ts +10 -2
  21. package/dist/claim.test.d.ts.map +1 -1
  22. package/dist/claim.test.js +282 -96
  23. package/dist/claim.test.js.map +1 -1
  24. package/dist/cli.js +346 -68
  25. package/dist/cli.js.map +1 -1
  26. package/dist/file-store.d.ts +70 -24
  27. package/dist/file-store.d.ts.map +1 -1
  28. package/dist/file-store.js +319 -141
  29. package/dist/file-store.js.map +1 -1
  30. package/dist/git-serve.d.ts +183 -0
  31. package/dist/git-serve.d.ts.map +1 -0
  32. package/dist/git-serve.js +503 -0
  33. package/dist/git-serve.js.map +1 -0
  34. package/dist/git-serve.test.d.ts +16 -0
  35. package/dist/git-serve.test.d.ts.map +1 -0
  36. package/dist/git-serve.test.js +183 -0
  37. package/dist/git-serve.test.js.map +1 -0
  38. package/dist/git.d.ts +65 -0
  39. package/dist/git.d.ts.map +1 -0
  40. package/dist/git.js +114 -0
  41. package/dist/git.js.map +1 -0
  42. package/dist/id.d.ts +39 -0
  43. package/dist/id.d.ts.map +1 -0
  44. package/dist/id.js +67 -0
  45. package/dist/id.js.map +1 -0
  46. package/dist/inbox.d.ts +41 -0
  47. package/dist/inbox.d.ts.map +1 -0
  48. package/dist/inbox.js +56 -0
  49. package/dist/inbox.js.map +1 -0
  50. package/dist/overview.d.ts +9 -0
  51. package/dist/overview.d.ts.map +1 -1
  52. package/dist/overview.js +19 -11
  53. package/dist/overview.js.map +1 -1
  54. package/dist/overview.test.js +6 -5
  55. package/dist/overview.test.js.map +1 -1
  56. package/dist/promote.test.d.ts +3 -2
  57. package/dist/promote.test.d.ts.map +1 -1
  58. package/dist/promote.test.js +29 -31
  59. package/dist/promote.test.js.map +1 -1
  60. package/dist/publish.d.ts +2 -17
  61. package/dist/publish.d.ts.map +1 -1
  62. package/dist/publish.js +4 -49
  63. package/dist/publish.js.map +1 -1
  64. package/dist/search.d.ts +3 -1
  65. package/dist/search.d.ts.map +1 -1
  66. package/dist/search.js +15 -2
  67. package/dist/search.js.map +1 -1
  68. package/dist/search.test.js +9 -9
  69. package/dist/search.test.js.map +1 -1
  70. package/dist/server.d.ts.map +1 -1
  71. package/dist/server.js +218 -21
  72. package/dist/server.js.map +1 -1
  73. package/dist/store.d.ts +28 -14
  74. package/dist/store.d.ts.map +1 -1
  75. package/dist/store.js.map +1 -1
  76. package/dist/store.test.js +88 -75
  77. package/dist/store.test.js.map +1 -1
  78. package/dist/ticket-doc.d.ts +26 -10
  79. package/dist/ticket-doc.d.ts.map +1 -1
  80. package/dist/ticket-doc.js +71 -27
  81. package/dist/ticket-doc.js.map +1 -1
  82. package/dist/types.d.ts +51 -17
  83. package/dist/types.d.ts.map +1 -1
  84. package/dist/types.js.map +1 -1
  85. package/package.json +1 -1
  86. package/skill/SKILL.md +96 -24
  87. package/src/asks.test.ts +355 -0
  88. package/src/check.test.ts +129 -72
  89. package/src/check.ts +101 -58
  90. package/src/claim-io.ts +401 -0
  91. package/src/claim.test.ts +372 -101
  92. package/src/claim.ts +418 -99
  93. package/src/cli.ts +370 -67
  94. package/src/file-store.ts +357 -145
  95. package/src/git-serve.test.ts +240 -0
  96. package/src/git-serve.ts +595 -0
  97. package/src/git.ts +141 -0
  98. package/src/id.ts +68 -0
  99. package/src/inbox.ts +77 -0
  100. package/src/overview.test.ts +6 -5
  101. package/src/overview.ts +25 -10
  102. package/src/promote.test.ts +40 -28
  103. package/src/publish.ts +6 -53
  104. package/src/search.test.ts +11 -11
  105. package/src/search.ts +17 -3
  106. package/src/server.ts +221 -19
  107. package/src/store.test.ts +95 -77
  108. package/src/store.ts +28 -13
  109. package/src/ticket-doc.ts +93 -30
  110. package/src/types.ts +52 -17
  111. package/ui/dist/assets/index-BjsorZOU.js +229 -0
  112. package/ui/dist/assets/index-CoKCUYic.css +1 -0
  113. package/ui/dist/index.html +2 -2
  114. package/ui/dist/assets/index-BJmOsOdR.js +0 -229
  115. package/ui/dist/assets/index-BoqQlqSU.css +0 -1
package/src/claim.ts CHANGED
@@ -10,17 +10,28 @@
10
10
  * (the implement-task skill, a human).
11
11
  */
12
12
 
13
- import { spawnSync } from "node:child_process"
14
- import { join } from "node:path"
15
- import { FileStore, TICKETS_DIR, openBoard } from "./file-store.ts"
13
+ import { rmSync } from "node:fs"
14
+ import { tmpdir } from "node:os"
15
+ import { join, relative, sep } from "node:path"
16
+ import { FileStore, TICKET_FILE, TICKETS_DIR, openBoard } from "./file-store.ts"
17
+ import {
18
+ GIT_NETWORK_TIMEOUT_MS,
19
+ defaultBase,
20
+ git,
21
+ gitMust,
22
+ runGit,
23
+ } from "./git.ts"
16
24
  import {
17
25
  boardConfig,
26
+ findBoards,
18
27
  findBoardsByPrefix,
19
28
  findRoot,
20
29
  findScopeRoot,
21
30
  type Store,
22
31
  } from "./store.ts"
23
- import type { ProjectConfig, Task } from "./types.ts"
32
+ import { isTicketKey } from "./id.ts"
33
+ import { parseTicket, parseTicketLenient, serializeTicket, type TicketDoc } from "./ticket-doc.ts"
34
+ import type { ProjectConfig, Status, Task } from "./types.ts"
24
35
 
25
36
  /**
26
37
  * The branch namespace claims live under when the board doesn't configure
@@ -37,23 +48,23 @@ export const DEFAULT_CLAIM_PREFIX = "task/claim/"
37
48
  const CLAIM_PREFIX_SHAPE = /^([A-Za-z0-9._-]+\/)+$/
38
49
 
39
50
  /**
40
- * The board's configured claim namespace before validation or defaulting
41
- * `claims.branchPrefix`, falling back to the pre-0.8 top-level `claimPrefix`
42
- * (still read for one release so a version bump can't strand in-flight
43
- * claims; `task check` nags, `--fix` migrates). Exported for `task check`,
44
- * which wants the raw value to lint without the throw below.
45
- */
46
- export function configuredBranchPrefix(config: ProjectConfig): string | undefined {
47
- return config.claims?.branchPrefix ?? config.claimPrefix
48
- }
49
-
50
- /**
51
- * The board's claim namespace: configured branch prefix (a trailing slash is
51
+ * The board's claim namespace: `claims.branchPrefix` (a trailing slash is
52
52
  * implied) or the default. Validated here because it becomes a git ref and an
53
53
  * ls-remote glob — a malformed value must fail the claim, not corrupt it.
54
+ *
55
+ * The pre-0.8 top-level `claimPrefix` spelling is not honored — no consumers,
56
+ * no compat (TAS-41) — but silently *defaulting* over it would be worse than
57
+ * a shim: the board's committed lock convention would change under every
58
+ * worker at once. So its presence refuses, loudly, by name.
54
59
  */
55
60
  export function claimNamespace(config: ProjectConfig): string {
56
- const raw = configuredBranchPrefix(config) ?? DEFAULT_CLAIM_PREFIX
61
+ if (config.claimPrefix !== undefined && config.claims?.branchPrefix === undefined) {
62
+ throw new ClaimError(
63
+ '`claimPrefix` in .task/config.json is the pre-0.8 spelling and is no longer read — rename it to `claims: { "branchPrefix": … }`',
64
+ "invalid",
65
+ )
66
+ }
67
+ const raw = config.claims?.branchPrefix ?? DEFAULT_CLAIM_PREFIX
57
68
  const prefix = raw.endsWith("/") ? raw : `${raw}/`
58
69
  if (!CLAIM_PREFIX_SHAPE.test(prefix) || prefix.includes("..") || /(^|\/)\./.test(prefix)) {
59
70
  throw new ClaimError(
@@ -79,9 +90,9 @@ export function openClaimCap(root: string): number | undefined {
79
90
  }
80
91
  }
81
92
 
82
- /** { prefix: "TAS" }, 21 → "task/claim/tas-21" (or under the configured namespace). */
83
- export function claimBranch(config: ProjectConfig, number: number): string {
84
- return `${claimNamespace(config)}${config.prefix.toLowerCase()}-${number}`
93
+ /** { prefix: "TAS" }, "x7k4m" → "task/claim/tas-x7k4m" (or under the configured namespace). */
94
+ export function claimBranch(config: ProjectConfig, key: string): string {
95
+ return `${claimNamespace(config)}${config.prefix.toLowerCase()}-${key}`
85
96
  }
86
97
 
87
98
  /**
@@ -97,52 +108,18 @@ export class ClaimError extends Error {
97
108
  }
98
109
  }
99
110
 
100
- interface GitResult {
101
- status: number
102
- stdout: string
103
- stderr: string
104
- }
105
-
106
- function git(cwd: string, ...args: string[]): GitResult {
107
- const result = spawnSync("git", args, { cwd, encoding: "utf8" })
108
- if (result.error) throw result.error
109
- return {
110
- status: result.status ?? 1,
111
- stdout: (result.stdout ?? "").trim(),
112
- stderr: (result.stderr ?? "").trim(),
113
- }
114
- }
115
-
116
- /** Run git and throw on failure — for the steps that have no soft outcome. */
117
- function gitMust(cwd: string, ...args: string[]): string {
118
- const result = git(cwd, ...args)
111
+ /** Network git that throws on failure, with the timeout a real remote deserves. */
112
+ function gitNetMust(cwd: string, ...args: string[]): string {
113
+ const result = runGit(cwd, args, { timeoutMs: GIT_NETWORK_TIMEOUT_MS })
119
114
  if (result.status !== 0) {
120
115
  throw new Error(`git ${args[0]} failed: ${result.stderr || result.stdout}`)
121
116
  }
122
117
  return result.stdout
123
118
  }
124
119
 
125
- /**
126
- * The base every claim branch starts from: origin's default branch. Resolved
127
- * from `origin/HEAD` when the clone recorded it, with a main/master fallback
128
- * for repos wired up by hand (`git remote add` + push never sets origin/HEAD).
129
- */
130
- function defaultBase(cwd: string): string {
131
- const head = git(cwd, "symbolic-ref", "--quiet", "refs/remotes/origin/HEAD")
132
- if (head.status === 0) return head.stdout.replace(/^refs\/remotes\//, "")
133
- for (const name of ["main", "master"]) {
134
- if (git(cwd, "show-ref", "--verify", "--quiet", `refs/remotes/origin/${name}`).status === 0) {
135
- return `origin/${name}`
136
- }
137
- }
138
- throw new Error(
139
- "couldn't resolve origin's default branch — origin/HEAD is unset and neither origin/main nor origin/master exists",
140
- )
141
- }
142
-
143
120
  /** Every claim branch that exists on origin right now — one network call. */
144
121
  export function remoteClaims(cwd: string, namespace: string): Set<string> {
145
- const out = gitMust(cwd, "ls-remote", "--heads", "origin", `${namespace}*`)
122
+ const out = gitNetMust(cwd, "ls-remote", "--heads", "origin", `${namespace}*`)
146
123
  const names = new Set<string>()
147
124
  for (const line of out.split("\n")) {
148
125
  const ref = line.split("\t")[1]
@@ -152,25 +129,48 @@ export function remoteClaims(cwd: string, namespace: string): Set<string> {
152
129
  }
153
130
 
154
131
  function remoteBranchExists(cwd: string, branch: string): boolean {
155
- return gitMust(cwd, "ls-remote", "--heads", "origin", branch) !== ""
132
+ return gitNetMust(cwd, "ls-remote", "--heads", "origin", branch) !== ""
133
+ }
134
+
135
+ /**
136
+ * Delete a remote branch and verify the ref is actually gone (TAS-8b7s9).
137
+ * Some environments — git egress proxies in cloud sessions, notably — drop
138
+ * branch-deletion pushes while git reports success ("Everything up-to-date"),
139
+ * so trusting the exit code lets `--release` report a release that didn't
140
+ * happen: the branch lives on, occupying a WIP-cap slot and advertising a
141
+ * stale claim to every worker. One bounded ls-remote on a path that already
142
+ * made network calls turns that lie into a loud failure naming the remedies.
143
+ */
144
+ function deleteRemoteBranchVerified(cwd: string, branch: string): void {
145
+ gitNetMust(cwd, "push", "--quiet", "origin", "--delete", branch)
146
+ if (remoteBranchExists(cwd, branch)) {
147
+ throw new Error(
148
+ `origin still has ${branch} after a deletion git reported as successful — this environment silently drops branch deletions. Delete it from one that can: locally, \`task sweep\` on a capable runner (CI), or the forge API/UI.`,
149
+ )
150
+ }
156
151
  }
157
152
 
158
153
  /** Blockers still in the way — anything not done or canceled still blocks. */
159
154
  function openBlockers(store: Store, task: Task): string[] {
160
155
  return task.blockedBy
161
- .map((n) => store.get(n))
156
+ .map((k) => store.get(k))
162
157
  .filter((b): b is Task => b !== null && b.status !== "done" && b.status !== "canceled")
163
158
  .map((b) => b.id)
164
159
  }
165
160
 
166
- /** Throws ClaimError("invalid") unless `task` is claimable right now. */
167
- function assertClaimable(store: Store, task: Task | null, number: number): asserts task is Task {
168
- const id = store.displayId(number)
161
+ /**
162
+ * Throws ClaimError("invalid") unless `task` is claimable right now. Open asks
163
+ * deliberately don't gate here: a ticket can be agent-implementable while a
164
+ * person owes it something in parallel — the done gate keeps that honest. An
165
+ * ask that must land *before* the work starts is a `blocked_by` on a thin
166
+ * human ticket, which the blocker check below already enforces.
167
+ */
168
+ function assertClaimable(store: Store, task: Task | null, key: string): asserts task is Task {
169
+ const id = store.displayId(key)
169
170
  if (!task) throw new ClaimError(`no such task: ${id}`, "invalid")
170
171
  if (task.status !== "todo") {
171
172
  throw new ClaimError(`${id} is ${task.status} — only todo tickets can be claimed`, "invalid")
172
173
  }
173
- if (task.needsHuman) throw new ClaimError(`${id} needs a human — not claimable`, "invalid")
174
174
  const blockers = openBlockers(store, task)
175
175
  if (blockers.length) {
176
176
  throw new ClaimError(`${id} is blocked by ${blockers.join(", ")} — not claimable`, "invalid")
@@ -181,6 +181,13 @@ export interface ClaimResult {
181
181
  task: Task
182
182
  branch: string
183
183
  base: string
184
+ /**
185
+ * Where the checkout was before the claim switched it — a branch name
186
+ * usually, a bare sha when HEAD was detached. The CLI uses it to warn a
187
+ * branch-pinned session that a plain claim just moved it off its assigned
188
+ * branch (the `--lock-only` case).
189
+ */
190
+ previous: string
184
191
  }
185
192
 
186
193
  export interface ClaimOptions {
@@ -215,7 +222,7 @@ function assertUnderCap(openClaims: Set<string>, root: string, namespace: string
215
222
  * someone pre-created without a claim commit can't be hijacked by a plain
216
223
  * fast-forward. Leaves the winner checked out on the claim branch.
217
224
  */
218
- export function claim(store: Store, number: number, options: ClaimOptions = {}): ClaimResult {
225
+ export function claim(store: Store, key: string, options: ClaimOptions = {}): ClaimResult {
219
226
  // Unreachable since openBoard refuses pre-0.6 boards, kept as the narrowing
220
227
  // that lets the rest of this function assume text-canonical semantics.
221
228
  if (!(store instanceof FileStore)) {
@@ -225,8 +232,8 @@ export function claim(store: Store, number: number, options: ClaimOptions = {}):
225
232
  )
226
233
  }
227
234
  const cwd = store.root
228
- const branch = claimBranch(store.config, number)
229
- const id = store.displayId(number)
235
+ const branch = claimBranch(store.config, key)
236
+ const id = store.displayId(key)
230
237
 
231
238
  // The local-branch check comes before ticket validation on purpose: on the
232
239
  // claim branch itself the ticket reads in_progress, and "already claimed"
@@ -246,7 +253,7 @@ export function claim(store: Store, number: number, options: ClaimOptions = {}):
246
253
  )
247
254
  }
248
255
 
249
- assertClaimable(store, store.get(number), number)
256
+ assertClaimable(store, store.get(key), key)
250
257
  if (tree.stdout !== "") {
251
258
  throw new ClaimError(
252
259
  "working tree is dirty — commit or stash before claiming, the claim switches branches",
@@ -254,7 +261,7 @@ export function claim(store: Store, number: number, options: ClaimOptions = {}):
254
261
  )
255
262
  }
256
263
 
257
- gitMust(cwd, "fetch", "--quiet", "origin")
264
+ gitNetMust(cwd, "fetch", "--quiet", "origin")
258
265
  // One ls-remote answers both gates: is this ticket taken, and is the repo
259
266
  // at its WIP cap. "Already claimed" wins — that's exit 1 (pick the next
260
267
  // ticket), where the cap is exit 2 (fix something).
@@ -282,23 +289,19 @@ export function claim(store: Store, number: number, options: ClaimOptions = {}):
282
289
  // Re-validate against the base branch: the pre-checkout validation read
283
290
  // whatever happened to be checked out, this one reads the truth the claim
284
291
  // will actually be built on.
285
- assertClaimable(store, store.get(number), number)
286
- store.update(number, { status: "in_progress" })
287
- gitMust(cwd, "add", "--", join(store.taskDir, TICKETS_DIR, String(number)))
292
+ assertClaimable(store, store.get(key), key)
293
+ store.update(key, { status: "in_progress" })
294
+ gitMust(cwd, "add", "--", join(store.taskDir, TICKETS_DIR, key))
288
295
  gitMust(cwd, "commit", "--quiet", "-m", `chore(board): claim ${id} → in_progress`)
289
296
  } catch (error) {
290
297
  undo()
291
298
  throw error
292
299
  }
293
300
 
294
- const push = git(
301
+ const push = runGit(
295
302
  cwd,
296
- "push",
297
- "--quiet",
298
- "-u",
299
- "origin",
300
- branch,
301
- `--force-with-lease=refs/heads/${branch}:`,
303
+ ["push", "--quiet", "-u", "origin", branch, `--force-with-lease=refs/heads/${branch}:`],
304
+ { timeoutMs: GIT_NETWORK_TIMEOUT_MS },
302
305
  )
303
306
  if (push.status !== 0) {
304
307
  undo()
@@ -311,7 +314,210 @@ export function claim(store: Store, number: number, options: ClaimOptions = {}):
311
314
  )
312
315
  }
313
316
 
314
- return { task: store.get(number)!, branch, base }
317
+ return { task: store.get(key)!, branch, base, previous }
318
+ }
319
+
320
+ // ── Lock-only claims ─────────────────────────────────────────────────────────
321
+
322
+ /** A file's path relative to the git toplevel — git tree reads speak toplevel paths. */
323
+ function ticketPathFromToplevel(store: Store, key: string): string {
324
+ const toplevel = gitMust(store.root, "rev-parse", "--show-toplevel")
325
+ return [relative(toplevel, store.taskDir).split(sep).join("/"), TICKETS_DIR, key, TICKET_FILE]
326
+ .filter(Boolean)
327
+ .join("/")
328
+ }
329
+
330
+ export interface LockOnlyClaimResult {
331
+ task: Task
332
+ branch: string
333
+ base: string
334
+ /** The checkout's branch at claim time, recorded in the flip commit's `delivered_by:` trailer. */
335
+ deliveredBy: string
336
+ }
337
+
338
+ /**
339
+ * `task claim <id> --lock-only`: mint the claim branch on origin without
340
+ * touching the checkout. For sessions pinned to a provisioned branch (a
341
+ * Claude web session, a CI job) the three roles plain `claim` bundles — the
342
+ * lock, the workspace, and the delivery branch — don't coincide: the session
343
+ * can't switch branches without breaking its harness contract, and working
344
+ * outside the lock is how two workers end up on the same ticket.
345
+ *
346
+ * So this takes just the lock: the `in_progress` flip commit is built against
347
+ * origin's default-branch tree in a temporary index (read-tree → hash-object →
348
+ * update-index → write-tree → commit-tree) and pushed with the same
349
+ * must-not-exist lease plain `claim` uses — the compare-and-swap is identical,
350
+ * only the checkout stays where it is (dirty or pinned, doesn't matter). The
351
+ * flip commit carries a `delivered_by: <branch>` trailer naming the checkout's
352
+ * branch, so a lock-only claim is legible state — anyone reading the claim
353
+ * branch sees where the work will actually land. Exit-code contract and
354
+ * `--release` behave exactly like plain claim.
355
+ *
356
+ * Validation reads the *base tree*, not the checkout: the commit is built on
357
+ * origin's default branch, and the pinned checkout's copy of the ticket may be
358
+ * stale or diverged — a promote that hasn't merged yet isn't claimable yet.
359
+ */
360
+ export function claimLockOnly(
361
+ store: Store,
362
+ key: string,
363
+ options: ClaimOptions = {},
364
+ ): LockOnlyClaimResult {
365
+ if (!(store instanceof FileStore)) {
366
+ throw new ClaimError(
367
+ "claiming needs a text-format board — run `npx @nickmeriano/task@0.6 migrate` once",
368
+ "invalid",
369
+ )
370
+ }
371
+ const cwd = store.root
372
+ const branch = claimBranch(store.config, key)
373
+ const id = store.displayId(key)
374
+
375
+ if (git(cwd, "rev-parse", "--git-dir").status !== 0) {
376
+ throw new ClaimError(`not a git repository: ${cwd}`, "invalid")
377
+ }
378
+ // A local claim branch means some flow here already claimed it the plain way.
379
+ if (git(cwd, "show-ref", "--verify", "--quiet", `refs/heads/${branch}`).status === 0) {
380
+ throw new ClaimError(
381
+ `${id} is already claimed — ${branch} exists locally (finish it, or \`task claim --release ${id}\`)`,
382
+ "claimed",
383
+ )
384
+ }
385
+
386
+ gitNetMust(cwd, "fetch", "--quiet", "origin")
387
+ const namespace = claimNamespace(store.config)
388
+ const openClaims = remoteClaims(cwd, namespace)
389
+ if (openClaims.has(branch)) {
390
+ throw new ClaimError(`${id} is already claimed — ${branch} exists on origin`, "claimed")
391
+ }
392
+ if (!options.force) assertUnderCap(openClaims, cwd, namespace)
393
+
394
+ const base = defaultBase(cwd)
395
+ const baseSha = gitMust(cwd, "rev-parse", base)
396
+ const path = ticketPathFromToplevel(store, key)
397
+ const bytes = git(cwd, "show", `${baseSha}:${path}`)
398
+ if (bytes.status !== 0) {
399
+ throw new ClaimError(`no such task on ${base}: ${id} — a lock-only claim is built on origin's default branch, so the ticket must exist there`, "invalid")
400
+ }
401
+ // Strict parse first — it carries unknown frontmatter through, so the flip
402
+ // commit can't silently drop a hand-added field — falling back to lenient
403
+ // for bytes another checkout or an older CLI wrote oddly (branch reads
404
+ // degrade, they don't refuse the tree).
405
+ let doc: TicketDoc | null
406
+ try {
407
+ doc = parseTicket(bytes.stdout, `${base}:${path}`)
408
+ } catch {
409
+ doc = parseTicketLenient(bytes.stdout)
410
+ }
411
+ if (!doc) {
412
+ throw new ClaimError(`${id} on ${base} isn't a readable ticket file`, "invalid")
413
+ }
414
+ if (doc.status !== "todo") {
415
+ throw new ClaimError(
416
+ `${id} is ${doc.status} on ${base} — only todo tickets can be claimed (a local promote counts once it lands on the default branch)`,
417
+ "invalid",
418
+ )
419
+ }
420
+ const blockers = doc.blockedBy.filter((blocker) => {
421
+ const raw = git(cwd, "show", `${baseSha}:${ticketPathFromToplevel(store, blocker)}`)
422
+ if (raw.status !== 0) return false // missing blocker doesn't block, as in openBlockers
423
+ const status = parseTicketLenient(raw.stdout)?.status
424
+ return status !== undefined && status !== "done" && status !== "canceled"
425
+ })
426
+ if (blockers.length) {
427
+ throw new ClaimError(
428
+ `${id} is blocked by ${blockers.map((b) => store.displayId(b)).join(", ")} — not claimable`,
429
+ "invalid",
430
+ )
431
+ }
432
+
433
+ const deliveredBy =
434
+ git(cwd, "symbolic-ref", "--quiet", "--short", "HEAD").stdout ||
435
+ gitMust(cwd, "rev-parse", "HEAD")
436
+ const flipped = serializeTicket({
437
+ ...doc,
438
+ status: "in_progress",
439
+ updatedAt: new Date().toISOString(),
440
+ })
441
+
442
+ // The temp-index dance claim-io.ts proved out: build the flip commit against
443
+ // the base tree without ever touching the checkout or its index.
444
+ const index = join(tmpdir(), `task-claim-index-${process.pid}-${Date.now()}`)
445
+ const env = { GIT_INDEX_FILE: index }
446
+ let commit: string
447
+ try {
448
+ if (runGit(cwd, ["read-tree", baseSha], { env }).status !== 0) {
449
+ throw new Error(`git read-tree failed on ${base}`)
450
+ }
451
+ const blob = runGit(cwd, ["hash-object", "-w", "--stdin"], { input: flipped })
452
+ if (blob.status !== 0) throw new Error("git hash-object failed")
453
+ if (
454
+ runGit(cwd, ["update-index", "--add", "--cacheinfo", `100644,${blob.stdout},${path}`], { env })
455
+ .status !== 0
456
+ ) {
457
+ throw new Error(`git update-index failed for ${path}`)
458
+ }
459
+ const tree = runGit(cwd, ["write-tree"], { env })
460
+ if (tree.status !== 0) throw new Error("git write-tree failed")
461
+ const made = git(
462
+ cwd,
463
+ "commit-tree",
464
+ tree.stdout,
465
+ "-p",
466
+ baseSha,
467
+ "-m",
468
+ `chore(board): claim ${id} → in_progress (lock-only)\n\ndelivered_by: ${deliveredBy}`,
469
+ )
470
+ if (made.status !== 0) {
471
+ throw new Error("git commit-tree failed — is git user.name/user.email configured?")
472
+ }
473
+ commit = made.stdout
474
+ } finally {
475
+ rmSync(index, { force: true })
476
+ }
477
+
478
+ // The same compare-and-swap as plain claim: the empty lease means "the ref
479
+ // must not exist", so two concurrent claimers still get exactly one winner.
480
+ const push = runGit(
481
+ cwd,
482
+ ["push", "--quiet", "origin", `${commit}:refs/heads/${branch}`, `--force-with-lease=refs/heads/${branch}:`],
483
+ { timeoutMs: GIT_NETWORK_TIMEOUT_MS },
484
+ )
485
+ if (push.status !== 0) {
486
+ if (remoteBranchExists(cwd, branch)) {
487
+ throw new ClaimError(`${id} is already claimed — ${branch} was just created on origin`, "claimed")
488
+ }
489
+ throw new ClaimError(
490
+ `${id}: push of ${branch} was rejected (likely a concurrent claim) — ${push.stderr || "no detail from git"}`,
491
+ "claimed",
492
+ )
493
+ }
494
+ // Keep the remote-tracking ref in step so an immediate read (the inbox, the
495
+ // serve UI) sees the claim without another fetch.
496
+ git(cwd, "update-ref", `refs/remotes/origin/${branch}`, commit)
497
+
498
+ // The checkout's copy of the ticket wasn't touched — present the claim as
499
+ // made, preferring the local ticket (asks, relations) when it exists.
500
+ const local = store.get(key)
501
+ const task: Task = local
502
+ ? { ...local, status: "in_progress" }
503
+ : {
504
+ id,
505
+ key,
506
+ title: doc.title,
507
+ description: doc.description,
508
+ status: "in_progress",
509
+ tags: doc.tags,
510
+ goal: doc.goal,
511
+ needsHuman: false,
512
+ asks: [],
513
+ blocks: [],
514
+ blockedBy: doc.blockedBy,
515
+ prs: doc.prs,
516
+ position: doc.position,
517
+ createdAt: doc.createdAt,
518
+ updatedAt: doc.updatedAt,
519
+ }
520
+ return { task, branch, base, deliveredBy }
315
521
  }
316
522
 
317
523
  export interface ReleaseResult {
@@ -326,9 +532,9 @@ export interface ReleaseResult {
326
532
  * flip only ever existed as a commit on that branch, so deleting it *is* the
327
533
  * revert — the default branch never saw in_progress.
328
534
  */
329
- export function release(store: Store, number: number): ReleaseResult {
535
+ export function release(store: Store, key: string): ReleaseResult {
330
536
  const cwd = store.root
331
- const branch = claimBranch(store.config, number)
537
+ const branch = claimBranch(store.config, key)
332
538
 
333
539
  const onBranch =
334
540
  git(cwd, "symbolic-ref", "--quiet", "--short", "HEAD").stdout === branch
@@ -353,22 +559,135 @@ export function release(store: Store, number: number): ReleaseResult {
353
559
 
354
560
  const local = git(cwd, "branch", "--quiet", "-D", branch).status === 0
355
561
  const remote = remoteBranchExists(cwd, branch)
356
- if (remote) gitMust(cwd, "push", "--quiet", "origin", "--delete", branch)
562
+ if (remote) deleteRemoteBranchVerified(cwd, branch)
357
563
  return { branch, remote, local }
358
564
  }
359
565
 
566
+ // ── Sweep — the janitor verb ─────────────────────────────────────────────────
567
+
568
+ export interface SweptBranch {
569
+ branch: string
570
+ /** Display id of the ticket the branch locks — for "missing", the id the branch name claims. */
571
+ ticket: string
572
+ reason: "done" | "canceled" | "missing"
573
+ }
574
+
575
+ export interface SweepResult {
576
+ swept: SweptBranch[]
577
+ kept: { branch: string; ticket: string; status: Status }[]
578
+ /**
579
+ * Branches under the claim namespace whose name the claim protocol could
580
+ * not have minted — no board's `<prefix>-`, or a segment that isn't a valid
581
+ * ticket key (pre-migration numeric claims like `tas-31`, hand-made
582
+ * branches). Deliberately never deleted: sweep's jurisdiction is exactly
583
+ * the branches `task claim` creates, and a name outside the scheme may be
584
+ * an open PR's head or someone's parked work. Reported so a human can
585
+ * clean them up knowingly.
586
+ */
587
+ skipped: string[]
588
+ failed: { branch: string; error: string }[]
589
+ }
590
+
591
+ /**
592
+ * `task sweep`: for every claim branch on origin whose ticket is done,
593
+ * canceled, or missing on the current checkout, delete the branch — with the
594
+ * same deletion verification as `--release`, so a survivor is a failure, never
595
+ * a silent lie. This is the liveness half of the lock: environments that can't
596
+ * delete remote branches leave stale claims behind (`--release` there fails
597
+ * loudly rather than lying), and sweep, run from a runner that *can* delete
598
+ * (CI on push-to-main or cron, a laptop cron), heals them.
599
+ *
600
+ * Deliberately pure git and zero judgment — no vendor awareness, no agent in
601
+ * the loop — and scoped to exactly the branches the claim protocol mints:
602
+ * `<namespace><prefix>-<key>` with a valid ticket key. A branch under the
603
+ * namespace that doesn't fit the scheme (a pre-migration numeric claim, a
604
+ * hand-made branch) is *skipped and reported*, never deleted — it may be an
605
+ * open PR's head, and deleting what claiming didn't create isn't janitorial
606
+ * work, it's a judgment call. Within the scheme, the ticket's status on this
607
+ * checkout decides: done/canceled/missing → delete, anything live → keep.
608
+ * Boards are discovered repo-wide, the way `task serve` finds them, so one
609
+ * sweep covers every board's namespace.
610
+ */
611
+ export function sweep(cwd: string): SweepResult {
612
+ const scope = findScopeRoot(cwd)
613
+ const boards: Store[] = []
614
+ for (const ref of findBoards(scope)) {
615
+ try {
616
+ boards.push(openBoard(ref.root))
617
+ } catch {
618
+ // An unreadable board can't vouch for its branches — skip it.
619
+ }
620
+ }
621
+ if (boards.length === 0) {
622
+ throw new ClaimError(
623
+ "no .task directory found in this directory or any parent — run `task init` first",
624
+ "invalid",
625
+ )
626
+ }
627
+
628
+ // One ls-remote per distinct namespace (normally exactly one — `task check`
629
+ // lints that boards agree), each branch matched to the board whose
630
+ // `<prefix>-` leads its final segment.
631
+ const namespaces = new Map<string, Store[]>()
632
+ for (const store of boards) {
633
+ let namespace: string
634
+ try {
635
+ namespace = claimNamespace(store.config)
636
+ } catch {
637
+ continue // a malformed prefix can't have minted branches worth sweeping
638
+ }
639
+ const list = namespaces.get(namespace) ?? []
640
+ list.push(store)
641
+ namespaces.set(namespace, list)
642
+ }
643
+
644
+ const result: SweepResult = { swept: [], kept: [], skipped: [], failed: [] }
645
+ for (const [namespace, stores] of namespaces) {
646
+ for (const branch of [...remoteClaims(scope, namespace)].sort()) {
647
+ const rest = branch.slice(namespace.length)
648
+ const store = stores.find((s) =>
649
+ rest.startsWith(`${s.config.prefix.toLowerCase()}-`),
650
+ )
651
+ const key = store ? rest.slice(store.config.prefix.length + 1) : null
652
+ if (!store || !key || !isTicketKey(key)) {
653
+ result.skipped.push(branch)
654
+ continue
655
+ }
656
+ const task = store.get(key)
657
+ if (task && task.status !== "done" && task.status !== "canceled") {
658
+ result.kept.push({ branch, ticket: task.id, status: task.status })
659
+ continue
660
+ }
661
+ const entry: SweptBranch = task
662
+ ? { branch, ticket: task.id, reason: task.status as "done" | "canceled" }
663
+ : { branch, ticket: store.displayId(key), reason: "missing" }
664
+ try {
665
+ deleteRemoteBranchVerified(scope, branch)
666
+ git(scope, "update-ref", "-d", `refs/remotes/origin/${branch}`)
667
+ result.swept.push(entry)
668
+ } catch (error) {
669
+ result.failed.push({
670
+ branch,
671
+ error: error instanceof Error ? error.message : String(error),
672
+ })
673
+ }
674
+ }
675
+ }
676
+ return result
677
+ }
678
+
360
679
  /**
361
- * The dispatcher's queue view: `todo` in position order, minus needs-human,
362
- * minus blocked, minus tickets whose claim branch already exists on origin
363
- * (one ls-remote for the whole namespace). The top entry is next up.
680
+ * The dispatcher's queue view: `todo` in position order, minus blocked, minus
681
+ * tickets whose claim branch already exists on origin (one ls-remote for the
682
+ * whole namespace). The top entry is next up. Open asks don't exclude a
683
+ * ticket — see assertClaimable.
364
684
  */
365
685
  export function claimableTasks(store: Store): Task[] {
366
686
  const claimed = remoteClaims(store.root, claimNamespace(store.config))
367
687
  return store
368
688
  .list({ statuses: ["todo"] })
369
- .filter((t) => !t.needsHuman)
370
689
  .filter((t) => openBlockers(store, t).length === 0)
371
- .filter((t) => !claimed.has(claimBranch(store.config, t.number)))
690
+ .filter((t) => !claimed.has(claimBranch(store.config, t.key)))
372
691
  }
373
692
 
374
693
  export interface PromoteResult {
@@ -380,15 +699,16 @@ export interface PromoteResult {
380
699
  /**
381
700
  * `task promote` — the human "build this" call, as a verb: `backlog` → `todo`.
382
701
  * No quality gate: planning happens at claim time (the worker's plan tick),
383
- * so a one-line backlog ticket is promotable. Blocked and needs-human are
384
- * claim-time facts that `claim`/`--claimable` already enforce live, so
385
- * promoting a blocked ticket is legitimate sequencing ("build this once
386
- * NIC-11 lands") — reported as a hold, never refused. Writes the ticket like
387
- * `update` does; committing is the caller's job.
702
+ * so a one-line backlog ticket is promotable. Blockers are claim-time facts
703
+ * that `claim`/`--claimable` already enforce live, so promoting a blocked
704
+ * ticket is legitimate sequencing ("build this once NIC-11 lands") — reported
705
+ * as a hold, never refused. Open asks aren't a hold at all: they don't gate
706
+ * claiming, only done. Writes the ticket like `update` does; committing is
707
+ * the caller's job.
388
708
  */
389
- export function promote(store: Store, number: number): PromoteResult {
390
- const task = store.get(number)
391
- const id = store.displayId(number)
709
+ export function promote(store: Store, key: string): PromoteResult {
710
+ const task = store.get(key)
711
+ const id = store.displayId(key)
392
712
  if (!task) throw new ClaimError(`no such task: ${id}`, "invalid")
393
713
  if (task.status !== "backlog") {
394
714
  throw new ClaimError(
@@ -396,9 +716,8 @@ export function promote(store: Store, number: number): PromoteResult {
396
716
  "invalid",
397
717
  )
398
718
  }
399
- const updated = store.update(number, { status: "todo" })
719
+ const updated = store.update(key, { status: "todo" })
400
720
  const holds: string[] = []
401
- if (updated.needsHuman) holds.push("flagged needs-human")
402
721
  const blockers = openBlockers(store, updated)
403
722
  if (blockers.length) holds.push(`blocked by ${blockers.join(", ")}`)
404
723
  return { task: updated, holds }
@@ -474,7 +793,7 @@ export function claimNext(stores: Store[], options: ClaimOptions = {}): ClaimRes
474
793
  for (const store of stores) {
475
794
  for (const task of claimableTasks(store)) {
476
795
  try {
477
- return claim(store, task.number, options)
796
+ return claim(store, task.key, options)
478
797
  } catch (error) {
479
798
  // Claimed out from under us, or changed since listing — next in line.
480
799
  if (error instanceof ClaimError) continue