@nickmeriano/task 0.9.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.
package/src/claim.ts CHANGED
@@ -10,8 +10,10 @@
10
10
  * (the implement-task skill, a human).
11
11
  */
12
12
 
13
- import { join } from "node:path"
14
- 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"
15
17
  import {
16
18
  GIT_NETWORK_TIMEOUT_MS,
17
19
  defaultBase,
@@ -21,12 +23,15 @@ import {
21
23
  } from "./git.ts"
22
24
  import {
23
25
  boardConfig,
26
+ findBoards,
24
27
  findBoardsByPrefix,
25
28
  findRoot,
26
29
  findScopeRoot,
27
30
  type Store,
28
31
  } from "./store.ts"
29
- 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"
30
35
 
31
36
  /**
32
37
  * The branch namespace claims live under when the board doesn't configure
@@ -127,6 +132,24 @@ function remoteBranchExists(cwd: string, branch: string): boolean {
127
132
  return gitNetMust(cwd, "ls-remote", "--heads", "origin", branch) !== ""
128
133
  }
129
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
+ }
151
+ }
152
+
130
153
  /** Blockers still in the way — anything not done or canceled still blocks. */
131
154
  function openBlockers(store: Store, task: Task): string[] {
132
155
  return task.blockedBy
@@ -158,6 +181,13 @@ export interface ClaimResult {
158
181
  task: Task
159
182
  branch: string
160
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
161
191
  }
162
192
 
163
193
  export interface ClaimOptions {
@@ -284,7 +314,210 @@ export function claim(store: Store, key: string, options: ClaimOptions = {}): Cl
284
314
  )
285
315
  }
286
316
 
287
- return { task: store.get(key)!, 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 }
288
521
  }
289
522
 
290
523
  export interface ReleaseResult {
@@ -326,10 +559,123 @@ export function release(store: Store, key: string): ReleaseResult {
326
559
 
327
560
  const local = git(cwd, "branch", "--quiet", "-D", branch).status === 0
328
561
  const remote = remoteBranchExists(cwd, branch)
329
- if (remote) gitNetMust(cwd, "push", "--quiet", "origin", "--delete", branch)
562
+ if (remote) deleteRemoteBranchVerified(cwd, branch)
330
563
  return { branch, remote, local }
331
564
  }
332
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
+
333
679
  /**
334
680
  * The dispatcher's queue view: `todo` in position order, minus blocked, minus
335
681
  * tickets whose claim branch already exists on origin (one ls-remote for the
package/src/cli.ts CHANGED
@@ -19,11 +19,13 @@ import { checkBoards, checkRoot } from "./check.ts"
19
19
  import {
20
20
  ClaimError,
21
21
  claim,
22
+ claimLockOnly,
22
23
  claimNext,
23
24
  claimableTasks,
24
25
  promote,
25
26
  release,
26
27
  selectionBoards,
28
+ sweep,
27
29
  } from "./claim.ts"
28
30
  import { addAskRouted, setAskResolvedRouted } from "./claim-io.ts"
29
31
  import { buildInbox, type InboxEntry } from "./inbox.ts"
@@ -94,6 +96,7 @@ const BOOLEAN_FLAGS = new Set([
94
96
  "next",
95
97
  "force",
96
98
  "reopen",
99
+ "lock-only",
97
100
  ])
98
101
 
99
102
  /**
@@ -827,7 +830,7 @@ function cmdLink(args: Args, action: "link" | "unlink"): void {
827
830
  }
828
831
 
829
832
  const CLAIM_USAGE =
830
- 'usage: task claim <id> | task claim --next | task claim --release <id> --comment "<why>"'
833
+ 'usage: task claim <id> [--lock-only] | task claim --next | task claim --release <id> --comment "<why>"'
831
834
 
832
835
  /**
833
836
  * `task claim <id>` / `task claim --next` / `task claim --release <id>` — see
@@ -838,11 +841,21 @@ const CLAIM_USAGE =
838
841
  function cmdClaim(args: Args): void {
839
842
  const ref = args.positional[0]
840
843
  const options = { force: Boolean(args.flags.force) }
841
- const printClaim = (result: { task: Task; branch: string; base: string }): void => {
844
+ const printClaim = (result: { task: Task; branch: string; base: string; previous: string }): void => {
842
845
  if (args.flags.json) {
843
846
  console.log(JSON.stringify({ task: result.task, branch: result.branch }, null, 2))
844
847
  } else {
845
848
  console.log(`claimed ${result.task.id} — on ${result.branch} (from ${result.base}), status in_progress`)
849
+ // The backstop for branch-pinned sessions: a harness that assigned this
850
+ // checkout a branch expects the work to land there, and a plain claim
851
+ // just moved HEAD somewhere else. The skill carries the rule; this line
852
+ // is for the worker that hasn't read it.
853
+ const defaultLocal = result.base.replace(/^origin\//, "")
854
+ if (result.previous !== defaultLocal && result.previous !== result.branch) {
855
+ console.log(
856
+ `note: this moved you off ${result.previous} — if your work must deliver on ${result.previous}, \`task claim --release ${result.task.id} --comment "…"\` and re-claim with --lock-only`,
857
+ )
858
+ }
846
859
  }
847
860
  }
848
861
  try {
@@ -851,7 +864,7 @@ function cmdClaim(args: Args): void {
851
864
  // ticket across the selection scope (--board > root `boards` > nearest),
852
865
  // retrying past lost races internally.
853
866
  if (ref) fail("pass an id or --next, not both")
854
- if (args.flags.release) fail(CLAIM_USAGE)
867
+ if (args.flags.release || args.flags["lock-only"]) fail(CLAIM_USAGE)
855
868
  const result = claimNext(selectionBoards(process.cwd(), boardFlag(args)), options)
856
869
  if (!result) {
857
870
  if (args.flags.json) {
@@ -867,6 +880,30 @@ function cmdClaim(args: Args): void {
867
880
  if (!ref) fail(CLAIM_USAGE)
868
881
  const store = openStoreFor(ref)
869
882
  const key = store.parseId(ref)
883
+ if (args.flags["lock-only"]) {
884
+ // The branch-pinned session's claim: same lock, no checkout switch —
885
+ // the work delivers on the branch this session was handed, and the flip
886
+ // commit's delivered_by: trailer says so. See claimLockOnly.
887
+ if (args.flags.release) fail(CLAIM_USAGE)
888
+ const result = claimLockOnly(store, key, options)
889
+ if (args.flags.json) {
890
+ console.log(
891
+ JSON.stringify(
892
+ { task: result.task, branch: result.branch, deliveredBy: result.deliveredBy },
893
+ null,
894
+ 2,
895
+ ),
896
+ )
897
+ } else {
898
+ console.log(
899
+ `claimed ${result.task.id} — ${result.branch} on origin (lock only, from ${result.base}), status in_progress`,
900
+ )
901
+ console.log(
902
+ `checkout untouched — keep working on ${result.deliveredBy} (recorded as delivered_by in the claim commit)`,
903
+ )
904
+ }
905
+ return
906
+ }
870
907
  if (args.flags.release) {
871
908
  // A released claim must leave its failure context behind: the branch
872
909
  // (and whatever was tried on it) is about to evaporate, so the reason
@@ -906,6 +943,50 @@ function cmdClaim(args: Args): void {
906
943
  }
907
944
  }
908
945
 
946
+ /**
947
+ * `task sweep` — the janitor: delete every claim branch on origin whose ticket
948
+ * is done, canceled, or missing on the current checkout. Zero judgment on
949
+ * purpose — the trigger belongs to a runner (CI on push-to-main or cron), not
950
+ * an agent. Exit codes: 0 clean (including nothing to do), 1 a deletion was
951
+ * attempted and failed — a failing sweep means this runner can't delete
952
+ * branches either, which is worth an alert.
953
+ */
954
+ function cmdSweep(args: Args): void {
955
+ try {
956
+ const result = sweep(process.cwd())
957
+ if (args.flags.json) {
958
+ console.log(JSON.stringify({ sweep: result }, null, 2))
959
+ } else {
960
+ for (const entry of result.swept) {
961
+ console.log(
962
+ `swept ${entry.branch} — ${entry.reason === "missing" ? `no ticket ${entry.ticket}` : `${entry.ticket} is ${entry.reason}`}`,
963
+ )
964
+ }
965
+ for (const entry of result.kept) {
966
+ console.log(`kept ${entry.branch} — ${entry.ticket} is ${entry.status}`)
967
+ }
968
+ for (const branch of result.skipped) {
969
+ console.log(
970
+ `skipped ${branch} — not a name \`task claim\` mints (foreign or pre-migration); delete it by hand if it's stale`,
971
+ )
972
+ }
973
+ for (const entry of result.failed) {
974
+ console.error(`error: couldn't delete ${entry.branch} — ${entry.error}`)
975
+ }
976
+ if (!result.swept.length && !result.kept.length && !result.skipped.length && !result.failed.length) {
977
+ console.log("no claim branches on origin — nothing to sweep")
978
+ }
979
+ }
980
+ if (result.failed.length) process.exit(1)
981
+ } catch (error) {
982
+ if (error instanceof ClaimError) {
983
+ console.error(`error: ${error.message}`)
984
+ process.exit(2)
985
+ }
986
+ throw error
987
+ }
988
+ }
989
+
909
990
  /** `task instructions` — the shipped agent conventions, self-served at runtime. */
910
991
  function cmdInstructions(): void {
911
992
  // skill/ ships in the npm package; `../` from both dist/cli.js and
@@ -1396,6 +1477,20 @@ Usage
1396
1477
  is refused (--force overrides). Exit codes:
1397
1478
  0 claimed, 1 already claimed, 2 not claimable
1398
1479
  (not todo, blocked, dirty tree, at the cap)
1480
+ task claim <id> --lock-only [--force]
1481
+ take the lock without touching the checkout:
1482
+ the in_progress flip commit is built against
1483
+ origin's default branch in a temporary index
1484
+ and pushed as the claim branch — same atomic
1485
+ lock, same exit codes, but HEAD, the working
1486
+ tree and the index stay exactly where they
1487
+ are (dirty is fine). For sessions pinned to
1488
+ a provisioned branch (CI, a Claude web
1489
+ session): the lock lives on the claim
1490
+ branch, the work delivers on yours, and the
1491
+ flip commit's delivered_by: trailer records
1492
+ which. --release works on a lock-only claim
1493
+ the same way
1399
1494
  task claim --next [--board <P>] [--force]
1400
1495
  claim the top claimable ticket in one call,
1401
1496
  retrying past lost races internally — the
@@ -1411,7 +1506,25 @@ Usage
1411
1506
  the branch, so deleting it is the revert. The
1412
1507
  comment is required and lands on the ticket
1413
1508
  (attributed like task comment, uncommitted),
1414
- so the next worker inherits what was tried
1509
+ so the next worker inherits what was tried.
1510
+ The deletion is verified with ls-remote: an
1511
+ environment that silently drops deletion
1512
+ pushes fails loudly instead of reporting a
1513
+ release that didn't happen
1514
+ task sweep the janitor: delete every claim branch on
1515
+ origin whose ticket is done, canceled, or
1516
+ missing on the current checkout, across all
1517
+ the repo's boards. Scoped to names \`claim\`
1518
+ mints (<prefix>-<valid key>): anything else
1519
+ under the namespace — pre-migration numeric
1520
+ claims, hand-made branches — is reported and
1521
+ left alone, since it may be an open PR's
1522
+ head. Pure git, zero judgment — run it from
1523
+ a runner that can delete remote branches (CI
1524
+ on push-to-main or cron) to heal stale
1525
+ claims left by environments that can't.
1526
+ Prints swept/kept/skipped; exits non-zero if
1527
+ a deletion was attempted and failed
1415
1528
  task list --claimable [--board <P>]
1416
1529
  the claim queue: todo tickets in position
1417
1530
  order, minus blocked / already claimed on
@@ -1536,6 +1649,8 @@ function main(): void | Promise<void> {
1536
1649
  return cmdLink(args, "unlink")
1537
1650
  case "claim":
1538
1651
  return cmdClaim(args)
1652
+ case "sweep":
1653
+ return cmdSweep(args)
1539
1654
  case "instructions":
1540
1655
  return cmdInstructions()
1541
1656
  case "ask":