@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/README.md +72 -0
- package/dist/claim.d.ts +87 -1
- package/dist/claim.d.ts.map +1 -1
- package/dist/claim.js +264 -5
- package/dist/claim.js.map +1 -1
- package/dist/claim.test.d.ts +8 -0
- package/dist/claim.test.d.ts.map +1 -1
- package/dist/claim.test.js +171 -2
- package/dist/claim.test.js.map +1 -1
- package/dist/cli.js +105 -4
- package/dist/cli.js.map +1 -1
- package/package.json +1 -1
- package/skill/SKILL.md +26 -1
- package/src/claim.test.ts +231 -2
- package/src/claim.ts +351 -5
- package/src/cli.ts +119 -4
package/src/claim.ts
CHANGED
|
@@ -10,8 +10,10 @@
|
|
|
10
10
|
* (the implement-task skill, a human).
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
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
|
|
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)
|
|
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":
|