@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.test.ts CHANGED
@@ -10,12 +10,20 @@
10
10
  * and already-claimed tickets — open asks never exclude a ticket.
11
11
  * 4. `task claim --release` deletes the branch both sides and the ticket is
12
12
  * claimable again.
13
+ * 5. `task claim --lock-only` takes the same lock without touching the
14
+ * checkout — a branch-pinned (even dirty) session can hold it — and
15
+ * `--release` works on it symmetrically.
16
+ * 6. Deletions are verified: against a remote that silently drops deletion
17
+ * pushes (TAS-8b7s9), release and sweep fail loudly instead of lying.
18
+ * 7. `task sweep` deletes claim branches whose tickets are done, canceled or
19
+ * missing on the current checkout, and leaves live ones — and any branch
20
+ * named outside the claim scheme — alone.
13
21
  */
14
22
 
15
23
  import assert from "node:assert/strict"
16
24
  import { test } from "node:test"
17
25
  import { execFile, spawnSync } from "node:child_process"
18
- import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"
26
+ import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"
19
27
  import { readFileSync } from "node:fs"
20
28
  import { tmpdir } from "node:os"
21
29
  import { join } from "node:path"
@@ -46,11 +54,16 @@ interface CliResult {
46
54
 
47
55
  /** The CLI as callers see it — a subprocess with an exit code. */
48
56
  function cli(cwd: string, ...args: string[]): Promise<CliResult> {
57
+ return cliEnv(cwd, {}, ...args)
58
+ }
59
+
60
+ /** Like cli(), with extra env — the delete-dropping git shim rides in PATH. */
61
+ function cliEnv(cwd: string, env: Record<string, string>, ...args: string[]): Promise<CliResult> {
49
62
  return new Promise((resolve) => {
50
63
  execFile(
51
64
  process.execPath,
52
65
  ["--experimental-strip-types", CLI, ...args],
53
- { cwd },
66
+ { cwd, env: { ...process.env, ...env } },
54
67
  (error, stdout, stderr) => {
55
68
  resolve({ code: error ? ((error as { code?: number }).code ?? 1) : 0, stdout, stderr })
56
69
  },
@@ -58,6 +71,26 @@ function cli(cwd: string, ...args: string[]): Promise<CliResult> {
58
71
  })
59
72
  }
60
73
 
74
+ /**
75
+ * A PATH whose `git` silently swallows branch-deletion pushes — exit 0,
76
+ * nothing deleted — and delegates everything else to the real git. This is
77
+ * the observed cloud-proxy behavior TAS-8b7s9 documents: the deletion "works"
78
+ * while the ref survives, which is exactly what verified release must catch.
79
+ * POSIX-only by construction, like the failure it reproduces.
80
+ */
81
+ function deleteDroppingPath(): string {
82
+ const dir = mkdtempSync(join(tmpdir(), "task-fake-git-"))
83
+ process.on("exit", () => rmSync(dir, { recursive: true, force: true }))
84
+ const real = spawnSync("sh", ["-c", "command -v git"], { encoding: "utf8" }).stdout.trim()
85
+ const shim = join(dir, "git")
86
+ writeFileSync(
87
+ shim,
88
+ `#!/bin/sh\nfor a in "$@"; do case "$a" in --delete) exit 0;; esac; done\nexec "${real}" "$@"\n`,
89
+ )
90
+ chmodSync(shim, 0o755)
91
+ return `${dir}:${process.env.PATH ?? ""}`
92
+ }
93
+
61
94
  interface Fixture {
62
95
  bare: string
63
96
  clone: () => string
@@ -150,9 +183,54 @@ test("two concurrent claims of one ticket: exactly one wins", async () => {
150
183
  sh(winner, "git", "show", `origin/main:.task/tickets/${keys.one}/ticket.md`),
151
184
  /status: todo/,
152
185
  )
186
+ // The trailer contract (trailers.ts): the flip commit names its op and
187
+ // ticket in machine-readable trailers — pinned here because automation
188
+ // keys on them.
189
+ assert.equal(sh(winner, "git", "log", "-1", "--format=%(trailers:key=Task-Op,valueonly)"), "claim")
190
+ assert.equal(
191
+ sh(winner, "git", "log", "-1", "--format=%(trailers:key=Task-Id,valueonly)"),
192
+ `CLM-${keys.one}`,
193
+ )
153
194
  void bare
154
195
  })
155
196
 
197
+ test("claim --dry-run: the real gates, the real exit codes, zero writes", async () => {
198
+ const { clone, keys } = fixture()
199
+ const dir = clone()
200
+ const branch = `task/claim/clm-${keys.one}`
201
+
202
+ // Would claim: exit 0 — and nothing moved. No branch local or remote, the
203
+ // checkout still on main, the ticket still todo.
204
+ const yes = await cli(dir, "claim", keys.one, "--dry-run")
205
+ assert.equal(yes.code, 0, yes.stderr)
206
+ assert.match(yes.stdout, /would claim CLM-/)
207
+ assert.equal(sh(dir, "git", "rev-parse", "--abbrev-ref", "HEAD"), "main")
208
+ assert.equal(sh(dir, "git", "branch", "--list", branch), "")
209
+ assert.equal(sh(dir, "git", "ls-remote", "--heads", "origin", branch), "")
210
+ assert.match(readFileSync(join(dir, ".task", "tickets", keys.one, "ticket.md"), "utf8"), /status: todo/)
211
+
212
+ // The gates are the real command's gates: blocked exits 2, a ticket someone
213
+ // else claimed exits 1 — the codes a scheduler scripts against.
214
+ assert.equal((await cli(dir, "claim", keys.blocked, "--dry-run")).code, 2)
215
+ const other = clone()
216
+ assert.equal((await cli(other, "claim", keys.one)).code, 0)
217
+ assert.equal((await cli(dir, "claim", keys.one, "--dry-run")).code, 1)
218
+
219
+ // --next --dry-run names the top of the queue without taking it.
220
+ const next = await cli(dir, "claim", "--next", "--dry-run", "--json")
221
+ assert.equal(next.code, 0, next.stderr)
222
+ const picked = JSON.parse(next.stdout) as { task: { key: string }; dryRun: boolean }
223
+ assert.equal(picked.dryRun, true)
224
+ assert.equal(picked.task.key, keys.jumper)
225
+ assert.equal(sh(dir, "git", "ls-remote", "--heads", "origin", `task/claim/clm-${keys.jumper}`), "")
226
+
227
+ // A dry run reserves nothing — the real claim right after still works.
228
+ assert.equal((await cli(dir, "claim", keys.jumper)).code, 0)
229
+
230
+ // No stop-before-writing seam exists for --release or --lock-only.
231
+ assert.equal((await cli(other, "claim", keys.two, "--lock-only", "--dry-run")).code, 1)
232
+ })
233
+
156
234
  test("unclaimable tickets exit 2, before anything touches origin", async () => {
157
235
  const { clone, keys } = fixture()
158
236
  const dir = clone()
@@ -470,3 +548,209 @@ test("release requires a reason and leaves it as a comment on the ticket", async
470
548
  const after = await cli(dir, "show", keys.one, "--json")
471
549
  assert.equal((JSON.parse(after.stdout) as { comments: unknown[] }).comments.length, 1)
472
550
  })
551
+
552
+ test("--lock-only claims from a dirty, branch-pinned checkout without touching it", async () => {
553
+ const { clone, keys } = fixture()
554
+ const dir = clone()
555
+ const branch = `task/claim/clm-${keys.one}`
556
+
557
+ // The branch-pinned session: a provisioned branch, uncommitted tracked work.
558
+ sh(dir, "git", "checkout", "--quiet", "-b", "pinned")
559
+ const gitignore = join(dir, ".task", ".gitignore")
560
+ writeFileSync(gitignore, `${readFileSync(gitignore, "utf8")}\nwip\n`)
561
+
562
+ const claimed = await cli(dir, "claim", keys.one, "--lock-only", "--json")
563
+ assert.equal(claimed.code, 0, claimed.stderr)
564
+ const parsed = JSON.parse(claimed.stdout) as {
565
+ task: { status: string }
566
+ branch: string
567
+ deliveredBy: string
568
+ }
569
+ assert.equal(parsed.branch, branch)
570
+ assert.equal(parsed.deliveredBy, "pinned")
571
+ assert.equal(parsed.task.status, "in_progress")
572
+
573
+ // The checkout is exactly where it was: same branch, same dirty file, and
574
+ // the local ticket file never flipped — the claim lives on origin only.
575
+ assert.equal(sh(dir, "git", "rev-parse", "--abbrev-ref", "HEAD"), "pinned")
576
+ assert.match(sh(dir, "git", "status", "--porcelain", "-uno"), /\.task\/\.gitignore/)
577
+ assert.match(readFileSync(join(dir, ".task", "tickets", keys.one, "ticket.md"), "utf8"), /status: todo/)
578
+
579
+ // On origin the claim is a real flip commit built on the default branch,
580
+ // carrying the delivery branch as a trailer.
581
+ assert.ok(sh(dir, "git", "ls-remote", "--heads", "origin", branch).includes(`refs/heads/${branch}`))
582
+ assert.equal(
583
+ sh(dir, "git", "rev-parse", `origin/${branch}~1`),
584
+ sh(dir, "git", "rev-parse", "origin/main"),
585
+ )
586
+ assert.match(sh(dir, "git", "show", `origin/${branch}:.task/tickets/${keys.one}/ticket.md`), /status: in_progress/)
587
+ const message = sh(dir, "git", "log", "-1", "--format=%B", `origin/${branch}`)
588
+ assert.match(message, /lock-only/)
589
+ assert.match(message, new RegExp(`Delivered-By: pinned`))
590
+ // Delivered-By shares one contiguous trailer block with the Task-Op/Task-Id
591
+ // contract (trailers.ts) — git must parse all three.
592
+ assert.equal(
593
+ sh(dir, "git", "log", "-1", "--format=%(trailers:key=Task-Op,valueonly)", `origin/${branch}`),
594
+ "claim",
595
+ )
596
+ assert.equal(
597
+ sh(dir, "git", "log", "-1", "--format=%(trailers:key=Delivered-By,valueonly)", `origin/${branch}`),
598
+ "pinned",
599
+ )
600
+
601
+ // The lock is the same lock: both claim flavors lose to it from elsewhere.
602
+ const other = clone()
603
+ assert.equal((await cli(other, "claim", keys.one)).code, 1)
604
+ assert.equal((await cli(other, "claim", keys.one, "--lock-only")).code, 1)
605
+
606
+ // Release is symmetric: no local branch to delete, the origin one goes, and
607
+ // the pinned checkout still isn't touched.
608
+ const released = await cli(dir, "claim", "--release", keys.one, "--comment", "handing back a lock-only claim")
609
+ assert.equal(released.code, 0, released.stderr)
610
+ assert.equal(sh(dir, "git", "rev-parse", "--abbrev-ref", "HEAD"), "pinned")
611
+ assert.equal(sh(dir, "git", "ls-remote", "--heads", "origin", branch), "")
612
+ assert.equal((await cli(other, "claim", keys.one)).code, 0)
613
+ })
614
+
615
+ test("--lock-only validates against origin's default branch, not the checkout", async () => {
616
+ const { clone, keys } = fixture()
617
+ const dir = clone()
618
+
619
+ // Not todo on the base tree → exit 2, and nothing minted on origin.
620
+ const parked = await cli(dir, "claim", keys.parked, "--lock-only")
621
+ assert.equal(parked.code, 2)
622
+ assert.match(parked.stderr, /is backlog on origin\/main/)
623
+
624
+ // A local promote that hasn't landed on the default branch doesn't count:
625
+ // the flip commit is built on origin/main, where the ticket is still backlog.
626
+ assert.equal((await cli(dir, "promote", keys.parked)).code, 0)
627
+ const promoted = await cli(dir, "claim", keys.parked, "--lock-only")
628
+ assert.equal(promoted.code, 2)
629
+ assert.match(promoted.stderr, /is backlog on origin\/main/)
630
+ assert.equal(sh(dir, "git", "ls-remote", "--heads", "origin", `task/claim/clm-${keys.parked}`), "")
631
+
632
+ // Blocked on the base tree refuses too.
633
+ const blocked = await cli(dir, "claim", keys.blocked, "--lock-only")
634
+ assert.equal(blocked.code, 2)
635
+ assert.match(blocked.stderr, new RegExp(`blocked by CLM-${keys.one}`))
636
+ })
637
+
638
+ test("plain claim warns when it moves the checkout off a non-default branch", async () => {
639
+ const { clone, keys } = fixture()
640
+ const dir = clone()
641
+ sh(dir, "git", "checkout", "--quiet", "-b", "feature")
642
+
643
+ const claimed = await cli(dir, "claim", keys.one)
644
+ assert.equal(claimed.code, 0, claimed.stderr)
645
+ assert.match(claimed.stdout, /moved you off feature/)
646
+ assert.match(claimed.stdout, /--lock-only/)
647
+
648
+ // From the default branch there is nothing to warn about.
649
+ const other = clone()
650
+ const quiet = await cli(other, "claim", keys.two)
651
+ assert.equal(quiet.code, 0, quiet.stderr)
652
+ assert.doesNotMatch(quiet.stdout, /moved you off/)
653
+ })
654
+
655
+ test("sweep deletes done/canceled/missing claims and keeps live ones", async () => {
656
+ const { clone, keys } = fixture()
657
+ const a = clone()
658
+ assert.equal((await cli(a, "claim", keys.one)).code, 0)
659
+ assert.equal((await cli(a, "claim", keys.two)).code, 0)
660
+ assert.equal((await cli(a, "claim", keys.person)).code, 0)
661
+
662
+ // A second checkout is where the tickets' fates land (a merge, normally):
663
+ // one finishes, person is canceled, two stays live — plus a branch under
664
+ // the namespace whose ticket never existed.
665
+ const b = clone()
666
+ assert.equal((await cli(b, "move", keys.one, "done")).code, 0)
667
+ assert.equal((await cli(b, "move", keys.person, "canceled")).code, 0)
668
+ sh(b, "git", "push", "--quiet", "origin", "main:refs/heads/task/claim/clm-zz9zz")
669
+ // Namespace squatters sweep must NOT touch: a pre-migration numeric claim
670
+ // (its branch may head an open PR carrying unmerged work — the TAS-31 case)
671
+ // and a branch matching no board's prefix. Skipped and reported, never
672
+ // deleted — sweep's jurisdiction is exactly the names `claim` mints.
673
+ sh(b, "git", "push", "--quiet", "origin", "main:refs/heads/task/claim/clm-31")
674
+ sh(b, "git", "push", "--quiet", "origin", "main:refs/heads/task/claim/other-zz9zz")
675
+
676
+ const swept = await cli(b, "sweep", "--json")
677
+ assert.equal(swept.code, 0, swept.stderr)
678
+ const { sweep } = JSON.parse(swept.stdout) as {
679
+ sweep: {
680
+ swept: { branch: string; reason: string }[]
681
+ kept: { branch: string; status: string }[]
682
+ skipped: string[]
683
+ failed: unknown[]
684
+ }
685
+ }
686
+ assert.deepEqual(
687
+ sweep.swept.map((s) => [s.branch, s.reason]).sort(),
688
+ [
689
+ [`task/claim/clm-${keys.one}`, "done"],
690
+ [`task/claim/clm-${keys.person}`, "canceled"],
691
+ ["task/claim/clm-zz9zz", "missing"],
692
+ ].sort(),
693
+ )
694
+ assert.deepEqual(sweep.kept, [
695
+ { branch: `task/claim/clm-${keys.two}`, ticket: `CLM-${keys.two}`, status: "todo" },
696
+ ])
697
+ assert.deepEqual(sweep.skipped.sort(), ["task/claim/clm-31", "task/claim/other-zz9zz"])
698
+ assert.equal(sweep.failed.length, 0)
699
+
700
+ // Origin agrees: the live claim and the out-of-scheme branches survive,
701
+ // and a re-run is a no-op.
702
+ const remote = sh(b, "git", "ls-remote", "--heads", "origin", "task/claim/*")
703
+ assert.deepEqual(
704
+ remote.split("\n").map((l) => l.split("refs/heads/")[1]).sort(),
705
+ ["task/claim/clm-31", `task/claim/clm-${keys.two}`, "task/claim/other-zz9zz"].sort(),
706
+ )
707
+ const again = await cli(b, "sweep", "--json")
708
+ assert.equal(again.code, 0)
709
+ assert.equal((JSON.parse(again.stdout) as { sweep: { swept: unknown[] } }).sweep.swept.length, 0)
710
+ })
711
+
712
+ test(
713
+ "verified deletion: a remote that drops deletions makes release and sweep fail loudly",
714
+ { skip: process.platform === "win32" },
715
+ async () => {
716
+ const { clone, keys } = fixture()
717
+ const path = deleteDroppingPath()
718
+ const dir = clone()
719
+ assert.equal((await cli(dir, "claim", keys.one)).code, 0)
720
+
721
+ // Release under the shim: git says the deletion worked, the ref survives —
722
+ // pre-TAS-8b7s9 this reported "released" and left a stale lock behind.
723
+ const released = await cliEnv(
724
+ dir, { PATH: path },
725
+ "claim", "--release", keys.one, "--comment", "should not land",
726
+ )
727
+ assert.notEqual(released.code, 0)
728
+ assert.match(released.stderr, /origin still has/)
729
+ assert.doesNotMatch(released.stdout, /released CLM/)
730
+ assert.ok(
731
+ sh(dir, "git", "ls-remote", "--heads", "origin", `task/claim/clm-${keys.one}`).includes(
732
+ `clm-${keys.one}`,
733
+ ),
734
+ )
735
+ // No release, no comment: the failure must not read as a done release.
736
+ const shown = await cli(dir, "show", keys.one, "--json")
737
+ assert.equal((JSON.parse(shown.stdout) as { comments: unknown[] }).comments.length, 0)
738
+
739
+ // Sweep under the shim reports the failed deletion and exits non-zero.
740
+ const b = clone()
741
+ assert.equal((await cli(b, "move", keys.one, "done")).code, 0)
742
+ const blocked = await cliEnv(b, { PATH: path }, "sweep", "--json")
743
+ assert.equal(blocked.code, 1)
744
+ const { sweep } = JSON.parse(blocked.stdout) as {
745
+ sweep: { swept: unknown[]; failed: { branch: string; error: string }[] }
746
+ }
747
+ assert.equal(sweep.swept.length, 0)
748
+ assert.equal(sweep.failed.length, 1)
749
+ assert.match(sweep.failed[0].error, /origin still has/)
750
+
751
+ // A capable runner heals it: the same sweep with real git succeeds.
752
+ const healed = await cli(b, "sweep")
753
+ assert.equal(healed.code, 0, healed.stderr)
754
+ assert.equal(sh(b, "git", "ls-remote", "--heads", "origin", `task/claim/clm-${keys.one}`), "")
755
+ },
756
+ )