@nickmeriano/task 0.9.0 → 0.11.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 (46) hide show
  1. package/README.md +129 -0
  2. package/dist/asks.test.js +6 -0
  3. package/dist/asks.test.js.map +1 -1
  4. package/dist/claim-io.d.ts.map +1 -1
  5. package/dist/claim-io.js +4 -3
  6. package/dist/claim-io.js.map +1 -1
  7. package/dist/claim.d.ts +99 -1
  8. package/dist/claim.d.ts.map +1 -1
  9. package/dist/claim.js +271 -6
  10. package/dist/claim.js.map +1 -1
  11. package/dist/claim.test.d.ts +8 -0
  12. package/dist/claim.test.d.ts.map +1 -1
  13. package/dist/claim.test.js +211 -2
  14. package/dist/claim.test.js.map +1 -1
  15. package/dist/cli.js +193 -7
  16. package/dist/cli.js.map +1 -1
  17. package/dist/export.d.ts +132 -0
  18. package/dist/export.d.ts.map +1 -0
  19. package/dist/export.js +199 -0
  20. package/dist/export.js.map +1 -0
  21. package/dist/export.test.d.ts +12 -0
  22. package/dist/export.test.d.ts.map +1 -0
  23. package/dist/export.test.js +185 -0
  24. package/dist/export.test.js.map +1 -0
  25. package/dist/server.d.ts +6 -0
  26. package/dist/server.d.ts.map +1 -1
  27. package/dist/server.js +12 -3
  28. package/dist/server.js.map +1 -1
  29. package/dist/trailers.d.ts +51 -0
  30. package/dist/trailers.d.ts.map +1 -0
  31. package/dist/trailers.js +32 -0
  32. package/dist/trailers.js.map +1 -0
  33. package/package.json +1 -1
  34. package/skill/SKILL.md +34 -1
  35. package/src/asks.test.ts +15 -0
  36. package/src/claim-io.ts +12 -3
  37. package/src/claim.test.ts +286 -2
  38. package/src/claim.ts +375 -6
  39. package/src/cli.ts +208 -7
  40. package/src/export.test.ts +254 -0
  41. package/src/export.ts +276 -0
  42. package/src/server.ts +12 -3
  43. package/src/trailers.ts +65 -0
  44. package/ui/dist/assets/{index-CoKCUYic.css → index-eHsqltgs.css} +1 -1
  45. package/ui/dist/assets/{index-BjsorZOU.js → index-mJmm4sWq.js} +53 -53
  46. package/ui/dist/index.html +3 -3
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,16 @@ 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 { boardCommitMessage } from "./trailers.ts"
34
+ import { parseTicket, parseTicketLenient, serializeTicket, type TicketDoc } from "./ticket-doc.ts"
35
+ import type { ProjectConfig, Status, Task } from "./types.ts"
30
36
 
31
37
  /**
32
38
  * The branch namespace claims live under when the board doesn't configure
@@ -127,6 +133,24 @@ function remoteBranchExists(cwd: string, branch: string): boolean {
127
133
  return gitNetMust(cwd, "ls-remote", "--heads", "origin", branch) !== ""
128
134
  }
129
135
 
136
+ /**
137
+ * Delete a remote branch and verify the ref is actually gone (TAS-8b7s9).
138
+ * Some environments — git egress proxies in cloud sessions, notably — drop
139
+ * branch-deletion pushes while git reports success ("Everything up-to-date"),
140
+ * so trusting the exit code lets `--release` report a release that didn't
141
+ * happen: the branch lives on, occupying a WIP-cap slot and advertising a
142
+ * stale claim to every worker. One bounded ls-remote on a path that already
143
+ * made network calls turns that lie into a loud failure naming the remedies.
144
+ */
145
+ function deleteRemoteBranchVerified(cwd: string, branch: string): void {
146
+ gitNetMust(cwd, "push", "--quiet", "origin", "--delete", branch)
147
+ if (remoteBranchExists(cwd, branch)) {
148
+ throw new Error(
149
+ `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.`,
150
+ )
151
+ }
152
+ }
153
+
130
154
  /** Blockers still in the way — anything not done or canceled still blocks. */
131
155
  function openBlockers(store: Store, task: Task): string[] {
132
156
  return task.blockedBy
@@ -158,11 +182,30 @@ export interface ClaimResult {
158
182
  task: Task
159
183
  branch: string
160
184
  base: string
185
+ /**
186
+ * Where the checkout was before the claim switched it — a branch name
187
+ * usually, a bare sha when HEAD was detached. The CLI uses it to warn a
188
+ * branch-pinned session that a plain claim just moved it off its assigned
189
+ * branch (the `--lock-only` case).
190
+ */
191
+ previous: string
161
192
  }
162
193
 
163
194
  export interface ClaimOptions {
164
195
  /** Claim past the root board's `claims.maxOpenCount` cap. */
165
196
  force?: boolean
197
+ /**
198
+ * Run the real claim's decision path — every validation, in order, against
199
+ * live origin state — and stop before the first write. No branch is
200
+ * created, no commit made, nothing pushed; the checkout is untouched. Exit
201
+ * semantics match the real command exactly, which is the point: automation
202
+ * (a scheduler deciding whether to wake a worker) asks "would a claim
203
+ * succeed right now?" through the same code that would perform it, so the
204
+ * answer can't drift from the rules. A "yes" is a reading, not a
205
+ * reservation — the dry run takes no lock, so a concurrent claimer can
206
+ * still win the ticket a moment later.
207
+ */
208
+ dryRun?: boolean
166
209
  }
167
210
 
168
211
  /**
@@ -248,6 +291,8 @@ export function claim(store: Store, key: string, options: ClaimOptions = {}): Cl
248
291
  const previous =
249
292
  git(cwd, "symbolic-ref", "--quiet", "--short", "HEAD").stdout ||
250
293
  gitMust(cwd, "rev-parse", "HEAD")
294
+ // Every gate is passed; the writes start here. A dry run stops instead.
295
+ if (options.dryRun) return { task: store.get(key)!, branch, base, previous }
251
296
  gitMust(cwd, "checkout", "--quiet", "-b", branch, base)
252
297
 
253
298
  const undo = (): void => {
@@ -262,7 +307,13 @@ export function claim(store: Store, key: string, options: ClaimOptions = {}): Cl
262
307
  assertClaimable(store, store.get(key), key)
263
308
  store.update(key, { status: "in_progress" })
264
309
  gitMust(cwd, "add", "--", join(store.taskDir, TICKETS_DIR, key))
265
- gitMust(cwd, "commit", "--quiet", "-m", `chore(board): claim ${id} → in_progress`)
310
+ gitMust(
311
+ cwd,
312
+ "commit",
313
+ "--quiet",
314
+ "-m",
315
+ boardCommitMessage(`chore(board): claim ${id} → in_progress`, "claim", id),
316
+ )
266
317
  } catch (error) {
267
318
  undo()
268
319
  throw error
@@ -284,7 +335,212 @@ export function claim(store: Store, key: string, options: ClaimOptions = {}): Cl
284
335
  )
285
336
  }
286
337
 
287
- return { task: store.get(key)!, branch, base }
338
+ return { task: store.get(key)!, branch, base, previous }
339
+ }
340
+
341
+ // ── Lock-only claims ─────────────────────────────────────────────────────────
342
+
343
+ /** A file's path relative to the git toplevel — git tree reads speak toplevel paths. */
344
+ function ticketPathFromToplevel(store: Store, key: string): string {
345
+ const toplevel = gitMust(store.root, "rev-parse", "--show-toplevel")
346
+ return [relative(toplevel, store.taskDir).split(sep).join("/"), TICKETS_DIR, key, TICKET_FILE]
347
+ .filter(Boolean)
348
+ .join("/")
349
+ }
350
+
351
+ export interface LockOnlyClaimResult {
352
+ task: Task
353
+ branch: string
354
+ base: string
355
+ /** The checkout's branch at claim time, recorded in the flip commit's `Delivered-By:` trailer. */
356
+ deliveredBy: string
357
+ }
358
+
359
+ /**
360
+ * `task claim <id> --lock-only`: mint the claim branch on origin without
361
+ * touching the checkout. For sessions pinned to a provisioned branch (a
362
+ * Claude web session, a CI job) the three roles plain `claim` bundles — the
363
+ * lock, the workspace, and the delivery branch — don't coincide: the session
364
+ * can't switch branches without breaking its harness contract, and working
365
+ * outside the lock is how two workers end up on the same ticket.
366
+ *
367
+ * So this takes just the lock: the `in_progress` flip commit is built against
368
+ * origin's default-branch tree in a temporary index (read-tree → hash-object →
369
+ * update-index → write-tree → commit-tree) and pushed with the same
370
+ * must-not-exist lease plain `claim` uses — the compare-and-swap is identical,
371
+ * only the checkout stays where it is (dirty or pinned, doesn't matter). The
372
+ * flip commit carries a `Delivered-By: <branch>` trailer naming the checkout's
373
+ * branch, so a lock-only claim is legible state — anyone reading the claim
374
+ * branch sees where the work will actually land. Exit-code contract and
375
+ * `--release` behave exactly like plain claim.
376
+ *
377
+ * Validation reads the *base tree*, not the checkout: the commit is built on
378
+ * origin's default branch, and the pinned checkout's copy of the ticket may be
379
+ * stale or diverged — a promote that hasn't merged yet isn't claimable yet.
380
+ */
381
+ export function claimLockOnly(
382
+ store: Store,
383
+ key: string,
384
+ options: ClaimOptions = {},
385
+ ): LockOnlyClaimResult {
386
+ if (!(store instanceof FileStore)) {
387
+ throw new ClaimError(
388
+ "claiming needs a text-format board — run `npx @nickmeriano/task@0.6 migrate` once",
389
+ "invalid",
390
+ )
391
+ }
392
+ const cwd = store.root
393
+ const branch = claimBranch(store.config, key)
394
+ const id = store.displayId(key)
395
+
396
+ if (git(cwd, "rev-parse", "--git-dir").status !== 0) {
397
+ throw new ClaimError(`not a git repository: ${cwd}`, "invalid")
398
+ }
399
+ // A local claim branch means some flow here already claimed it the plain way.
400
+ if (git(cwd, "show-ref", "--verify", "--quiet", `refs/heads/${branch}`).status === 0) {
401
+ throw new ClaimError(
402
+ `${id} is already claimed — ${branch} exists locally (finish it, or \`task claim --release ${id}\`)`,
403
+ "claimed",
404
+ )
405
+ }
406
+
407
+ gitNetMust(cwd, "fetch", "--quiet", "origin")
408
+ const namespace = claimNamespace(store.config)
409
+ const openClaims = remoteClaims(cwd, namespace)
410
+ if (openClaims.has(branch)) {
411
+ throw new ClaimError(`${id} is already claimed — ${branch} exists on origin`, "claimed")
412
+ }
413
+ if (!options.force) assertUnderCap(openClaims, cwd, namespace)
414
+
415
+ const base = defaultBase(cwd)
416
+ const baseSha = gitMust(cwd, "rev-parse", base)
417
+ const path = ticketPathFromToplevel(store, key)
418
+ const bytes = git(cwd, "show", `${baseSha}:${path}`)
419
+ if (bytes.status !== 0) {
420
+ 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")
421
+ }
422
+ // Strict parse first — it carries unknown frontmatter through, so the flip
423
+ // commit can't silently drop a hand-added field — falling back to lenient
424
+ // for bytes another checkout or an older CLI wrote oddly (branch reads
425
+ // degrade, they don't refuse the tree).
426
+ let doc: TicketDoc | null
427
+ try {
428
+ doc = parseTicket(bytes.stdout, `${base}:${path}`)
429
+ } catch {
430
+ doc = parseTicketLenient(bytes.stdout)
431
+ }
432
+ if (!doc) {
433
+ throw new ClaimError(`${id} on ${base} isn't a readable ticket file`, "invalid")
434
+ }
435
+ if (doc.status !== "todo") {
436
+ throw new ClaimError(
437
+ `${id} is ${doc.status} on ${base} — only todo tickets can be claimed (a local promote counts once it lands on the default branch)`,
438
+ "invalid",
439
+ )
440
+ }
441
+ const blockers = doc.blockedBy.filter((blocker) => {
442
+ const raw = git(cwd, "show", `${baseSha}:${ticketPathFromToplevel(store, blocker)}`)
443
+ if (raw.status !== 0) return false // missing blocker doesn't block, as in openBlockers
444
+ const status = parseTicketLenient(raw.stdout)?.status
445
+ return status !== undefined && status !== "done" && status !== "canceled"
446
+ })
447
+ if (blockers.length) {
448
+ throw new ClaimError(
449
+ `${id} is blocked by ${blockers.map((b) => store.displayId(b)).join(", ")} — not claimable`,
450
+ "invalid",
451
+ )
452
+ }
453
+
454
+ const deliveredBy =
455
+ git(cwd, "symbolic-ref", "--quiet", "--short", "HEAD").stdout ||
456
+ gitMust(cwd, "rev-parse", "HEAD")
457
+ const flipped = serializeTicket({
458
+ ...doc,
459
+ status: "in_progress",
460
+ updatedAt: new Date().toISOString(),
461
+ })
462
+
463
+ // The temp-index dance claim-io.ts proved out: build the flip commit against
464
+ // the base tree without ever touching the checkout or its index.
465
+ const index = join(tmpdir(), `task-claim-index-${process.pid}-${Date.now()}`)
466
+ const env = { GIT_INDEX_FILE: index }
467
+ let commit: string
468
+ try {
469
+ if (runGit(cwd, ["read-tree", baseSha], { env }).status !== 0) {
470
+ throw new Error(`git read-tree failed on ${base}`)
471
+ }
472
+ const blob = runGit(cwd, ["hash-object", "-w", "--stdin"], { input: flipped })
473
+ if (blob.status !== 0) throw new Error("git hash-object failed")
474
+ if (
475
+ runGit(cwd, ["update-index", "--add", "--cacheinfo", `100644,${blob.stdout},${path}`], { env })
476
+ .status !== 0
477
+ ) {
478
+ throw new Error(`git update-index failed for ${path}`)
479
+ }
480
+ const tree = runGit(cwd, ["write-tree"], { env })
481
+ if (tree.status !== 0) throw new Error("git write-tree failed")
482
+ const made = git(
483
+ cwd,
484
+ "commit-tree",
485
+ tree.stdout,
486
+ "-p",
487
+ baseSha,
488
+ "-m",
489
+ boardCommitMessage(`chore(board): claim ${id} → in_progress (lock-only)`, "claim", id, [
490
+ `Delivered-By: ${deliveredBy}`,
491
+ ]),
492
+ )
493
+ if (made.status !== 0) {
494
+ throw new Error("git commit-tree failed — is git user.name/user.email configured?")
495
+ }
496
+ commit = made.stdout
497
+ } finally {
498
+ rmSync(index, { force: true })
499
+ }
500
+
501
+ // The same compare-and-swap as plain claim: the empty lease means "the ref
502
+ // must not exist", so two concurrent claimers still get exactly one winner.
503
+ const push = runGit(
504
+ cwd,
505
+ ["push", "--quiet", "origin", `${commit}:refs/heads/${branch}`, `--force-with-lease=refs/heads/${branch}:`],
506
+ { timeoutMs: GIT_NETWORK_TIMEOUT_MS },
507
+ )
508
+ if (push.status !== 0) {
509
+ if (remoteBranchExists(cwd, branch)) {
510
+ throw new ClaimError(`${id} is already claimed — ${branch} was just created on origin`, "claimed")
511
+ }
512
+ throw new ClaimError(
513
+ `${id}: push of ${branch} was rejected (likely a concurrent claim) — ${push.stderr || "no detail from git"}`,
514
+ "claimed",
515
+ )
516
+ }
517
+ // Keep the remote-tracking ref in step so an immediate read (the inbox, the
518
+ // serve UI) sees the claim without another fetch.
519
+ git(cwd, "update-ref", `refs/remotes/origin/${branch}`, commit)
520
+
521
+ // The checkout's copy of the ticket wasn't touched — present the claim as
522
+ // made, preferring the local ticket (asks, relations) when it exists.
523
+ const local = store.get(key)
524
+ const task: Task = local
525
+ ? { ...local, status: "in_progress" }
526
+ : {
527
+ id,
528
+ key,
529
+ title: doc.title,
530
+ description: doc.description,
531
+ status: "in_progress",
532
+ tags: doc.tags,
533
+ goal: doc.goal,
534
+ needsHuman: false,
535
+ asks: [],
536
+ blocks: [],
537
+ blockedBy: doc.blockedBy,
538
+ prs: doc.prs,
539
+ position: doc.position,
540
+ createdAt: doc.createdAt,
541
+ updatedAt: doc.updatedAt,
542
+ }
543
+ return { task, branch, base, deliveredBy }
288
544
  }
289
545
 
290
546
  export interface ReleaseResult {
@@ -326,10 +582,123 @@ export function release(store: Store, key: string): ReleaseResult {
326
582
 
327
583
  const local = git(cwd, "branch", "--quiet", "-D", branch).status === 0
328
584
  const remote = remoteBranchExists(cwd, branch)
329
- if (remote) gitNetMust(cwd, "push", "--quiet", "origin", "--delete", branch)
585
+ if (remote) deleteRemoteBranchVerified(cwd, branch)
330
586
  return { branch, remote, local }
331
587
  }
332
588
 
589
+ // ── Sweep — the janitor verb ─────────────────────────────────────────────────
590
+
591
+ export interface SweptBranch {
592
+ branch: string
593
+ /** Display id of the ticket the branch locks — for "missing", the id the branch name claims. */
594
+ ticket: string
595
+ reason: "done" | "canceled" | "missing"
596
+ }
597
+
598
+ export interface SweepResult {
599
+ swept: SweptBranch[]
600
+ kept: { branch: string; ticket: string; status: Status }[]
601
+ /**
602
+ * Branches under the claim namespace whose name the claim protocol could
603
+ * not have minted — no board's `<prefix>-`, or a segment that isn't a valid
604
+ * ticket key (pre-migration numeric claims like `tas-31`, hand-made
605
+ * branches). Deliberately never deleted: sweep's jurisdiction is exactly
606
+ * the branches `task claim` creates, and a name outside the scheme may be
607
+ * an open PR's head or someone's parked work. Reported so a human can
608
+ * clean them up knowingly.
609
+ */
610
+ skipped: string[]
611
+ failed: { branch: string; error: string }[]
612
+ }
613
+
614
+ /**
615
+ * `task sweep`: for every claim branch on origin whose ticket is done,
616
+ * canceled, or missing on the current checkout, delete the branch — with the
617
+ * same deletion verification as `--release`, so a survivor is a failure, never
618
+ * a silent lie. This is the liveness half of the lock: environments that can't
619
+ * delete remote branches leave stale claims behind (`--release` there fails
620
+ * loudly rather than lying), and sweep, run from a runner that *can* delete
621
+ * (CI on push-to-main or cron, a laptop cron), heals them.
622
+ *
623
+ * Deliberately pure git and zero judgment — no vendor awareness, no agent in
624
+ * the loop — and scoped to exactly the branches the claim protocol mints:
625
+ * `<namespace><prefix>-<key>` with a valid ticket key. A branch under the
626
+ * namespace that doesn't fit the scheme (a pre-migration numeric claim, a
627
+ * hand-made branch) is *skipped and reported*, never deleted — it may be an
628
+ * open PR's head, and deleting what claiming didn't create isn't janitorial
629
+ * work, it's a judgment call. Within the scheme, the ticket's status on this
630
+ * checkout decides: done/canceled/missing → delete, anything live → keep.
631
+ * Boards are discovered repo-wide, the way `task serve` finds them, so one
632
+ * sweep covers every board's namespace.
633
+ */
634
+ export function sweep(cwd: string): SweepResult {
635
+ const scope = findScopeRoot(cwd)
636
+ const boards: Store[] = []
637
+ for (const ref of findBoards(scope)) {
638
+ try {
639
+ boards.push(openBoard(ref.root))
640
+ } catch {
641
+ // An unreadable board can't vouch for its branches — skip it.
642
+ }
643
+ }
644
+ if (boards.length === 0) {
645
+ throw new ClaimError(
646
+ "no .task directory found in this directory or any parent — run `task init` first",
647
+ "invalid",
648
+ )
649
+ }
650
+
651
+ // One ls-remote per distinct namespace (normally exactly one — `task check`
652
+ // lints that boards agree), each branch matched to the board whose
653
+ // `<prefix>-` leads its final segment.
654
+ const namespaces = new Map<string, Store[]>()
655
+ for (const store of boards) {
656
+ let namespace: string
657
+ try {
658
+ namespace = claimNamespace(store.config)
659
+ } catch {
660
+ continue // a malformed prefix can't have minted branches worth sweeping
661
+ }
662
+ const list = namespaces.get(namespace) ?? []
663
+ list.push(store)
664
+ namespaces.set(namespace, list)
665
+ }
666
+
667
+ const result: SweepResult = { swept: [], kept: [], skipped: [], failed: [] }
668
+ for (const [namespace, stores] of namespaces) {
669
+ for (const branch of [...remoteClaims(scope, namespace)].sort()) {
670
+ const rest = branch.slice(namespace.length)
671
+ const store = stores.find((s) =>
672
+ rest.startsWith(`${s.config.prefix.toLowerCase()}-`),
673
+ )
674
+ const key = store ? rest.slice(store.config.prefix.length + 1) : null
675
+ if (!store || !key || !isTicketKey(key)) {
676
+ result.skipped.push(branch)
677
+ continue
678
+ }
679
+ const task = store.get(key)
680
+ if (task && task.status !== "done" && task.status !== "canceled") {
681
+ result.kept.push({ branch, ticket: task.id, status: task.status })
682
+ continue
683
+ }
684
+ const entry: SweptBranch = task
685
+ ? { branch, ticket: task.id, reason: task.status as "done" | "canceled" }
686
+ : { branch, ticket: store.displayId(key), reason: "missing" }
687
+ try {
688
+ deleteRemoteBranchVerified(scope, branch)
689
+ git(scope, "update-ref", "-d", `refs/remotes/origin/${branch}`)
690
+ result.swept.push(entry)
691
+ } catch (error) {
692
+ result.failed.push({
693
+ branch,
694
+ error: error instanceof Error ? error.message : String(error),
695
+ })
696
+ }
697
+ }
698
+ }
699
+ return result
700
+ }
701
+
333
702
  /**
334
703
  * The dispatcher's queue view: `todo` in position order, minus blocked, minus
335
704
  * tickets whose claim branch already exists on origin (one ls-remote for the