@nickmeriano/task 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/README.md +137 -22
  2. package/dist/asks.test.d.ts +17 -0
  3. package/dist/asks.test.d.ts.map +1 -0
  4. package/dist/asks.test.js +278 -0
  5. package/dist/asks.test.js.map +1 -0
  6. package/dist/check.d.ts +3 -2
  7. package/dist/check.d.ts.map +1 -1
  8. package/dist/check.js +99 -60
  9. package/dist/check.js.map +1 -1
  10. package/dist/check.test.js +99 -60
  11. package/dist/check.test.js.map +1 -1
  12. package/dist/claim-io.d.ts +73 -0
  13. package/dist/claim-io.d.ts.map +1 -0
  14. package/dist/claim-io.js +344 -0
  15. package/dist/claim-io.js.map +1 -0
  16. package/dist/claim.d.ts +108 -23
  17. package/dist/claim.d.ts.map +1 -1
  18. package/dist/claim.js +320 -86
  19. package/dist/claim.js.map +1 -1
  20. package/dist/claim.test.d.ts +10 -2
  21. package/dist/claim.test.d.ts.map +1 -1
  22. package/dist/claim.test.js +282 -96
  23. package/dist/claim.test.js.map +1 -1
  24. package/dist/cli.js +346 -68
  25. package/dist/cli.js.map +1 -1
  26. package/dist/file-store.d.ts +70 -24
  27. package/dist/file-store.d.ts.map +1 -1
  28. package/dist/file-store.js +319 -141
  29. package/dist/file-store.js.map +1 -1
  30. package/dist/git-serve.d.ts +183 -0
  31. package/dist/git-serve.d.ts.map +1 -0
  32. package/dist/git-serve.js +503 -0
  33. package/dist/git-serve.js.map +1 -0
  34. package/dist/git-serve.test.d.ts +16 -0
  35. package/dist/git-serve.test.d.ts.map +1 -0
  36. package/dist/git-serve.test.js +183 -0
  37. package/dist/git-serve.test.js.map +1 -0
  38. package/dist/git.d.ts +65 -0
  39. package/dist/git.d.ts.map +1 -0
  40. package/dist/git.js +114 -0
  41. package/dist/git.js.map +1 -0
  42. package/dist/id.d.ts +39 -0
  43. package/dist/id.d.ts.map +1 -0
  44. package/dist/id.js +67 -0
  45. package/dist/id.js.map +1 -0
  46. package/dist/inbox.d.ts +41 -0
  47. package/dist/inbox.d.ts.map +1 -0
  48. package/dist/inbox.js +56 -0
  49. package/dist/inbox.js.map +1 -0
  50. package/dist/overview.d.ts +9 -0
  51. package/dist/overview.d.ts.map +1 -1
  52. package/dist/overview.js +19 -11
  53. package/dist/overview.js.map +1 -1
  54. package/dist/overview.test.js +6 -5
  55. package/dist/overview.test.js.map +1 -1
  56. package/dist/promote.test.d.ts +3 -2
  57. package/dist/promote.test.d.ts.map +1 -1
  58. package/dist/promote.test.js +29 -31
  59. package/dist/promote.test.js.map +1 -1
  60. package/dist/publish.d.ts +2 -17
  61. package/dist/publish.d.ts.map +1 -1
  62. package/dist/publish.js +4 -49
  63. package/dist/publish.js.map +1 -1
  64. package/dist/search.d.ts +3 -1
  65. package/dist/search.d.ts.map +1 -1
  66. package/dist/search.js +15 -2
  67. package/dist/search.js.map +1 -1
  68. package/dist/search.test.js +9 -9
  69. package/dist/search.test.js.map +1 -1
  70. package/dist/server.d.ts.map +1 -1
  71. package/dist/server.js +218 -21
  72. package/dist/server.js.map +1 -1
  73. package/dist/store.d.ts +28 -14
  74. package/dist/store.d.ts.map +1 -1
  75. package/dist/store.js.map +1 -1
  76. package/dist/store.test.js +88 -75
  77. package/dist/store.test.js.map +1 -1
  78. package/dist/ticket-doc.d.ts +26 -10
  79. package/dist/ticket-doc.d.ts.map +1 -1
  80. package/dist/ticket-doc.js +71 -27
  81. package/dist/ticket-doc.js.map +1 -1
  82. package/dist/types.d.ts +51 -17
  83. package/dist/types.d.ts.map +1 -1
  84. package/dist/types.js.map +1 -1
  85. package/package.json +1 -1
  86. package/skill/SKILL.md +96 -24
  87. package/src/asks.test.ts +355 -0
  88. package/src/check.test.ts +129 -72
  89. package/src/check.ts +101 -58
  90. package/src/claim-io.ts +401 -0
  91. package/src/claim.test.ts +372 -101
  92. package/src/claim.ts +418 -99
  93. package/src/cli.ts +370 -67
  94. package/src/file-store.ts +357 -145
  95. package/src/git-serve.test.ts +240 -0
  96. package/src/git-serve.ts +595 -0
  97. package/src/git.ts +141 -0
  98. package/src/id.ts +68 -0
  99. package/src/inbox.ts +77 -0
  100. package/src/overview.test.ts +6 -5
  101. package/src/overview.ts +25 -10
  102. package/src/promote.test.ts +40 -28
  103. package/src/publish.ts +6 -53
  104. package/src/search.test.ts +11 -11
  105. package/src/search.ts +17 -3
  106. package/src/server.ts +221 -19
  107. package/src/store.test.ts +95 -77
  108. package/src/store.ts +28 -13
  109. package/src/ticket-doc.ts +93 -30
  110. package/src/types.ts +52 -17
  111. package/ui/dist/assets/index-BjsorZOU.js +229 -0
  112. package/ui/dist/assets/index-CoKCUYic.css +1 -0
  113. package/ui/dist/index.html +2 -2
  114. package/ui/dist/assets/index-BJmOsOdR.js +0 -229
  115. package/ui/dist/assets/index-BoqQlqSU.css +0 -1
package/src/git.ts ADDED
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Every git spawn in the CLI goes through here (TAS-40, folded into TAS-42):
3
+ * one place that guarantees a spawn can never wedge the process. Each call is
4
+ * non-interactive — `GIT_TERMINAL_PROMPT=0` turns a would-be credential
5
+ * prompt into a fast failure — and bounded by a timeout, because several of
6
+ * these run inside `task serve` request handlers, where a hung prompt or a
7
+ * wedged remote would hang the board for everyone looking at it.
8
+ *
9
+ * Three calling styles, because callers genuinely differ on what failure is:
10
+ * `git` returns the full result (claim flows branch on exit codes), `gitMust`
11
+ * throws (steps with no soft outcome), `gitOut` returns null (read paths that
12
+ * degrade to "can't ask git" — the serve/inbox rule).
13
+ */
14
+
15
+ import { spawnSync } from "node:child_process"
16
+
17
+ export interface GitResult {
18
+ status: number
19
+ stdout: string
20
+ stderr: string
21
+ }
22
+
23
+ export interface GitOptions {
24
+ /** Extra env on top of the non-interactive baseline — e.g. GIT_INDEX_FILE. */
25
+ env?: Record<string, string>
26
+ /** Piped to stdin — e.g. `hash-object -w --stdin`. */
27
+ input?: string
28
+ timeoutMs?: number
29
+ }
30
+
31
+ /** Local operations answer fast or something is genuinely wrong. */
32
+ export const GIT_TIMEOUT_MS = 30_000
33
+ /** Network operations (a full fetch, a push) get real-world headroom. */
34
+ export const GIT_NETWORK_TIMEOUT_MS = 120_000
35
+
36
+ /**
37
+ * The one spawn. A failed spawn (git missing, timeout hit) reads as status 1
38
+ * with the reason in stderr — callers treat it exactly like a failed command,
39
+ * which for every caller here is the truth that matters.
40
+ */
41
+ export function runGit(cwd: string, args: string[], options: GitOptions = {}): GitResult {
42
+ const result = spawnSync("git", args, {
43
+ cwd,
44
+ encoding: "utf8",
45
+ input: options.input,
46
+ timeout: options.timeoutMs ?? GIT_TIMEOUT_MS,
47
+ env: { ...process.env, GIT_TERMINAL_PROMPT: "0", ...options.env },
48
+ })
49
+ if (result.error) {
50
+ return { status: 1, stdout: "", stderr: result.error.message }
51
+ }
52
+ return {
53
+ status: result.status ?? 1,
54
+ stdout: (result.stdout ?? "").trim(),
55
+ stderr: (result.stderr ?? "").trim(),
56
+ }
57
+ }
58
+
59
+ export function git(cwd: string, ...args: string[]): GitResult {
60
+ return runGit(cwd, args)
61
+ }
62
+
63
+ /** Run git and throw on failure — for the steps that have no soft outcome. */
64
+ export function gitMust(cwd: string, ...args: string[]): string {
65
+ const result = runGit(cwd, args)
66
+ if (result.status !== 0) {
67
+ throw new Error(`git ${args[0]} failed: ${result.stderr || result.stdout}`)
68
+ }
69
+ return result.stdout
70
+ }
71
+
72
+ /** Soft git: stdout on success, null on any failure — callers decide if that's an error. */
73
+ export function gitOut(cwd: string, ...args: string[]): string | null {
74
+ const result = runGit(cwd, args)
75
+ return result.status === 0 ? result.stdout : null
76
+ }
77
+
78
+ /**
79
+ * The base a claim or board branch starts from: origin's default branch.
80
+ * Resolved from `origin/HEAD` when the clone recorded it, with a main/master
81
+ * fallback for repos wired up by hand (`git remote add` + push never sets
82
+ * origin/HEAD).
83
+ */
84
+ export function defaultBase(cwd: string): string {
85
+ const head = git(cwd, "symbolic-ref", "--quiet", "refs/remotes/origin/HEAD")
86
+ if (head.status === 0) return head.stdout.replace(/^refs\/remotes\//, "")
87
+ for (const name of ["main", "master"]) {
88
+ if (git(cwd, "show-ref", "--verify", "--quiet", `refs/remotes/origin/${name}`).status === 0) {
89
+ return `origin/${name}`
90
+ }
91
+ }
92
+ throw new Error(
93
+ "couldn't resolve origin's default branch — origin/HEAD is unset and neither origin/main nor origin/master exists",
94
+ )
95
+ }
96
+
97
+ // ── Remote identity ──────────────────────────────────────────────────────────
98
+
99
+ export interface RepoRef {
100
+ owner: string
101
+ repo: string
102
+ }
103
+
104
+ /**
105
+ * The GitHub repo this working copy pushes to.
106
+ *
107
+ * `origin` first, then the only remote if there's exactly one — a clone with
108
+ * `upstream` and a fork remote should not silently pick the wrong one, so
109
+ * ambiguity reads as "no repo" and the caller asks the user.
110
+ */
111
+ export function detectRepo(cwd: string): RepoRef | null {
112
+ const remotes = gitRemotes(cwd)
113
+ const url = remotes.get("origin") ?? (remotes.size === 1 ? [...remotes.values()][0] : null)
114
+ return url ? parseRemote(url) : null
115
+ }
116
+
117
+ function gitRemotes(cwd: string): Map<string, string> {
118
+ const out = new Map<string, string>()
119
+ const raw = gitOut(cwd, "remote", "-v")
120
+ if (raw === null) return out
121
+ for (const line of raw.split("\n")) {
122
+ const match = /^(\S+)\s+(\S+)\s+\(fetch\)$/.exec(line.trim())
123
+ if (match) out.set(match[1], match[2])
124
+ }
125
+ return out
126
+ }
127
+
128
+ /**
129
+ * Every shape git writes a GitHub remote in: https, ssh, `git@`-scp, with or
130
+ * without `.git`, and with or without credentials embedded in the host.
131
+ */
132
+ export function parseRemote(url: string): RepoRef | null {
133
+ const cleaned = url.trim().replace(/\.git$/, "")
134
+ const match =
135
+ /^(?:https?:\/\/|ssh:\/\/)?(?:[^@/]+@)?github\.com[:/]([^/]+)\/([^/]+?)\/?$/.exec(cleaned)
136
+ if (!match) return null
137
+ const [, owner, repo] = match
138
+ if (!/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/.test(owner)) return null
139
+ if (!/^[A-Za-z0-9._-]{1,100}$/.test(repo)) return null
140
+ return { owner, repo }
141
+ }
package/src/id.ts ADDED
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Ticket keys — the branch-safe identity scheme (TAS-42).
3
+ *
4
+ * A ticket's key is 5 random characters from a 27-letter alphabet, minted at
5
+ * `task add` with no coordination: two checkouts that have never seen each
6
+ * other's commits can't mint the same key except by 1-in-14M chance per pair,
7
+ * and `task check`'s duplicate lint catches the miracle at merge. The key IS
8
+ * the ticket's directory name under `.task/tickets/`, so allocation is safe by
9
+ * construction — git can't merge two different tickets into one directory.
10
+ *
11
+ * The alphabet drops vowels (keys can't spell words), 0/1/i/l/o/u (no
12
+ * confusable glyphs when a key is read aloud or typed from a screen), and is
13
+ * all-lowercase (keys land in claim branch names, which are case-sensitive
14
+ * refs). A key always contains a letter — an all-digit roll is re-rolled — so
15
+ * a purely numeric directory is unambiguously a pre-migration ticket, which
16
+ * is exactly how the check lint tells the two apart.
17
+ *
18
+ * Keys deliberately carry no timestamp and no sequence: identity must be
19
+ * dumber than metadata. Creation order lives in `created:`; board order lives
20
+ * in `position:`. Anything the key "said" beyond naming the ticket could be
21
+ * wrong one day, and a wrong id breaks every reference to it.
22
+ */
23
+
24
+ export const KEY_ALPHABET = "23456789bcdfghjkmnpqrstvwxz"
25
+ export const KEY_LENGTH = 5
26
+
27
+ /**
28
+ * What a ticket key may look like — the shape readers accept, wider than what
29
+ * minting produces so a future length change doesn't strand old boards. Must
30
+ * contain a letter: an all-digit name is a pre-migration ticket number, and
31
+ * the two must never be confusable.
32
+ */
33
+ export const KEY_PATTERN = /^(?=.*[a-z])[a-z0-9]{4,12}$/
34
+
35
+ export function isTicketKey(value: string): boolean {
36
+ return KEY_PATTERN.test(value)
37
+ }
38
+
39
+ /**
40
+ * One random roll — Web Crypto (global in Node ≥19 and in Workers, so this
41
+ * module stays importable by the hosted board), with rejection sampling so
42
+ * the 27-letter alphabet gets a uniform draw rather than a modulo bias.
43
+ */
44
+ function roll(): string {
45
+ const limit = 256 - (256 % KEY_ALPHABET.length)
46
+ let key = ""
47
+ while (key.length < KEY_LENGTH) {
48
+ const bytes = crypto.getRandomValues(new Uint8Array(KEY_LENGTH * 2))
49
+ for (const byte of bytes) {
50
+ if (byte >= limit || key.length >= KEY_LENGTH) continue
51
+ key += KEY_ALPHABET[byte % KEY_ALPHABET.length]
52
+ }
53
+ }
54
+ return key
55
+ }
56
+
57
+ /**
58
+ * Mint a key no ticket on this checkout uses. The local-collision retry is
59
+ * near-free insurance; the cross-branch case has no lock by design and rides
60
+ * on the odds (27^5 ≈ 14M keys) plus the check lint.
61
+ */
62
+ export function mintKey(taken: ReadonlySet<string>): string {
63
+ for (;;) {
64
+ const key = roll()
65
+ if (!KEY_PATTERN.test(key)) continue // all-digit roll — must stay pre-migration-only
66
+ if (!taken.has(key)) return key
67
+ }
68
+ }
package/src/inbox.ts ADDED
@@ -0,0 +1,77 @@
1
+ /**
2
+ * `task inbox` — every open ask on effectively-in-progress tickets, repo-wide.
3
+ * The one list a person checks to unblock work NOW (TAS-38): each entry is a
4
+ * stalled worker or stalled work, nothing else.
5
+ *
6
+ * "Effectively in progress" is TAS-26's read core: between claim and merge the
7
+ * default branch still says `todo` — the claim branch is the truth about
8
+ * in-flight work, and the branch *name* carries the ticket id, so resolving it
9
+ * costs one fetch of the claim refs, never a general branch scan. The asks a
10
+ * worker files mid-run live on its claim branch too, so the inbox reads each
11
+ * claimed ticket's ask files from the branch and unions them with the local
12
+ * ones by id (asks are one file each, so the union is exact; see claim-io.ts,
13
+ * which also owns the write side of the same rule). Offline, or with no
14
+ * origin at all, everything degrades silently to file truth — a board you
15
+ * can't fetch is still a board.
16
+ *
17
+ * Backlog and todo asks are deliberately out of scope: unstarted work owes
18
+ * nobody anything yet, and claiming is what promotes an ask into the inbox.
19
+ */
20
+
21
+ import { claimBranch, claimNamespace } from "./claim.ts"
22
+ import { mergedAsks, remoteClaimTips, syncClaimRefs } from "./claim-io.ts"
23
+ import type { Store } from "./store.ts"
24
+ import type { Ask, Task } from "./types.ts"
25
+
26
+ /** A board handed to buildInbox: the store plus the id the UI navigates with. */
27
+ export interface InboxBoard {
28
+ /** Path relative to the repo's board scope — "." for the root board. */
29
+ id: string
30
+ store: Store
31
+ }
32
+
33
+ export interface InboxEntry {
34
+ board: string
35
+ prefix: string
36
+ /** File truth — a claimed ticket may still read `todo` here; `claim` says why it counts. */
37
+ task: Task
38
+ /** Present when the ticket's claim branch exists on origin. */
39
+ claim: { branch: string } | null
40
+ /** Open asks only, oldest first; ordinals match `task show`'s (the union's, when claimed). */
41
+ asks: Ask[]
42
+ }
43
+
44
+ /** Oldest open ask decides an entry's urgency — and the whole list's order. */
45
+ function oldestAsk(entry: InboxEntry): string {
46
+ return entry.asks[0]?.createdAt ?? ""
47
+ }
48
+
49
+ export function buildInbox(boards: InboxBoard[]): InboxEntry[] {
50
+ const entries: InboxEntry[] = []
51
+ for (const { id, store } of boards) {
52
+ // One bounded ls-remote per board; branches are then fetched only when
53
+ // their tip moved (syncClaimRefs), so a refetch-happy UI costs one round
54
+ // trip, not a fetch storm. Null = origin unreachable = file truth only.
55
+ const tips = remoteClaimTips(store)
56
+ const claimed = tips ? new Set(tips.keys()) : null
57
+ if (tips && tips.size > 0) {
58
+ try {
59
+ syncClaimRefs(store, claimNamespace(store.config), tips)
60
+ } catch {
61
+ // Namespace validated by remoteClaimTips already; nothing to do.
62
+ }
63
+ }
64
+ // in_progress is in by file truth; todo is in only via a live claim.
65
+ for (const task of store.list({ statuses: ["in_progress", "todo"] })) {
66
+ const branch = claimBranch(store.config, task.key)
67
+ const claim = claimed?.has(branch) ? { branch } : null
68
+ if (task.status !== "in_progress" && !claim) continue
69
+ const asks = (claim ? mergedAsks(store, branch, task) : task.asks).filter(
70
+ (a) => !a.resolvedAt,
71
+ )
72
+ if (asks.length === 0) continue
73
+ entries.push({ board: id, prefix: store.config.prefix, task, claim, asks })
74
+ }
75
+ }
76
+ return entries.sort((a, b) => oldestAsk(a).localeCompare(oldestAsk(b)))
77
+ }
@@ -25,12 +25,13 @@ test("overview derives board health at read time", () => {
25
25
  store.createGoal({ title: "Launch" })
26
26
  const a = store.create({ title: "Oldest", status: "todo", goal: "launch" })
27
27
  const b = store.create({ title: "Shipped", status: "todo", goal: "launch" })
28
- store.update(b.number, { status: "done" })
29
- store.create({ title: "Waiting on a person", status: "todo", needsHuman: true })
28
+ store.update(b.key, { status: "done" })
29
+ const person = store.create({ title: "Waiting on a person", status: "todo" })
30
+ store.addAsk(person.key, "Sign the SOC2 form", "claude")
30
31
  store.create({ title: "Still parked", status: "backlog" })
31
32
 
32
33
  // Age the first ticket by hand — the store always writes "now".
33
- const ticketPath = join(root, ".task", "tickets", String(a.number), "ticket.md")
34
+ const ticketPath = join(root, ".task", "tickets", a.key, "ticket.md")
34
35
  const aged = readFileSync(ticketPath, "utf8")
35
36
  .replace(/created: .*/, "created: 2020-01-01T00:00:00.000Z")
36
37
  .replace(/updated: .*/, "updated: 2020-01-01T00:00:00.000Z")
@@ -46,6 +47,6 @@ test("overview derives board health at read time", () => {
46
47
  assert.equal(overview.claimed, null)
47
48
  assert.deepEqual(overview.goals, [{ slug: "launch", title: "Launch", done: 1, total: 2 }])
48
49
  // Only the aged ticket is stale at that clock (the others were written just now).
49
- assert.deepEqual(overview.stale.map((t) => t.id), ["HLT-1"])
50
- assert.equal(overview.oldestOpen?.id, "HLT-1")
50
+ assert.deepEqual(overview.stale.map((t) => t.id), [a.id])
51
+ assert.equal(overview.oldestOpen?.id, a.id)
51
52
  })
package/src/overview.ts CHANGED
@@ -5,7 +5,8 @@
5
5
  * derived from the board at read time, the same rule goals follow.
6
6
  */
7
7
 
8
- import { claimBranch, claimNamespace, remoteClaims } from "./claim.ts"
8
+ import { claimBranch } from "./claim.ts"
9
+ import { remoteClaimTips } from "./claim-io.ts"
9
10
  import type { Store } from "./store.ts"
10
11
  import { STATUSES, type Status } from "./types.ts"
11
12
 
@@ -32,7 +33,12 @@ export interface Overview {
32
33
  /** Live board only — the archive is history, not health. */
33
34
  statuses: Record<Status, number>
34
35
  open: number
36
+ /** Open tickets a person owes something — i.e. carrying open asks. */
35
37
  needsHuman: number
38
+ /** Open asks across the live board's open tickets. */
39
+ openAsks: number
40
+ /** The ask that's been owed longest — the standup's guilty number. */
41
+ oldestAsk: { taskId: string; text: string; createdAt: string } | null
36
42
  /**
37
43
  * Open tickets whose claim branch exists on origin right now; null when
38
44
  * origin can't be asked (no remote, offline) — unknown, not zero.
@@ -50,15 +56,12 @@ export function buildOverview(store: Store, now: number = Date.now()): Overview
50
56
  for (const task of tasks) statuses[task.status]++
51
57
  const open = tasks.filter((t) => OPEN_STATUSES.includes(t.status))
52
58
 
53
- let claimed: string[] | null = null
54
- try {
55
- const branches = remoteClaims(store.root, claimNamespace(store.config))
56
- claimed = open
57
- .filter((t) => branches.has(claimBranch(store.config, t.number)))
58
- .map((t) => t.id)
59
- } catch {
60
- claimed = null
61
- }
59
+ // The bounded read a board health check must never hang on a credential
60
+ // prompt; unreachable origin reads as "claimed ?" (null), not zero.
61
+ const tips = remoteClaimTips(store)
62
+ const claimed = tips
63
+ ? open.filter((t) => tips.has(claimBranch(store.config, t.key))).map((t) => t.id)
64
+ : null
62
65
 
63
66
  const goals = store.goals().map((goal) => {
64
67
  const mine = tasks.filter((t) => t.goal === goal.slug)
@@ -74,12 +77,24 @@ export function buildOverview(store: Store, now: number = Date.now()): Overview
74
77
 
75
78
  const oldest = [...open].sort((a, b) => Date.parse(a.createdAt) - Date.parse(b.createdAt))[0]
76
79
 
80
+ const openAsks = open
81
+ .flatMap((t) => t.asks.filter((a) => !a.resolvedAt).map((a) => ({ taskId: t.id, ask: a })))
82
+ .sort((a, b) => a.ask.createdAt.localeCompare(b.ask.createdAt))
83
+
77
84
  return {
78
85
  board: store.config.name,
79
86
  prefix: store.config.prefix,
80
87
  statuses,
81
88
  open: open.length,
82
89
  needsHuman: open.filter((t) => t.needsHuman).length,
90
+ openAsks: openAsks.length,
91
+ oldestAsk: openAsks[0]
92
+ ? {
93
+ taskId: openAsks[0].taskId,
94
+ text: openAsks[0].ask.text,
95
+ createdAt: openAsks[0].ask.createdAt,
96
+ }
97
+ : null,
83
98
  claimed,
84
99
  goals,
85
100
  stale,
@@ -5,8 +5,9 @@
5
5
  * 1. Any backlog ticket promotes to todo, exit 0 — there is no quality gate;
6
6
  * planning happens at claim time.
7
7
  * 2. Only backlog tickets promote — todo/in_progress/done are refused.
8
- * 3. Blocked and needs-human tickets still promote, with the hold reported
9
- * (stdout note, `holds` in --json) — those are claim-time gates.
8
+ * 3. Blocked tickets still promote, with the hold reported (stdout note,
9
+ * `holds` in --json) — blockers are claim-time gates. Needs-human isn't
10
+ * one at all anymore: asks gate done, not claiming.
10
11
  * 4. `task add` with no --status lands in backlog: the dump list is the
11
12
  * default, todo is the deliberate choice promote makes.
12
13
  */
@@ -52,22 +53,31 @@ function cli(cwd: string, ...args: string[]): Promise<CliResult> {
52
53
  /**
53
54
  * A board with every promotion shape. Promotion never touches git, so a bare
54
55
  * .task/ directory is the whole fixture:
55
- * 1 "One-liner" backlog (no tags — nothing else required)
56
- * 2 "Also backlog" backlog
57
- * 3 "Blocked" backlog, blocked by 2
58
- * 4 "For a person" backlog, needs-human
59
- * 5 "Already queued" todo
56
+ * liner "One-liner" backlog (no tags — nothing else required)
57
+ * also "Also backlog" backlog
58
+ * blocked "Blocked" backlog, blocked by `also`
59
+ * person "For a person" backlog, carrying an open ask
60
+ * queued "Already queued" todo
60
61
  */
61
- function fixture(): string {
62
+ interface Fixture {
63
+ dir: string
64
+ ids: { liner: string; also: string; blocked: string; person: string; queued: string }
65
+ }
66
+
67
+ function fixture(): Fixture {
62
68
  const dir = tempDir()
63
69
  const store = initProject(dir, { name: "promote board", prefix: "PRO" })
64
- store.create({ title: "One-liner", status: "backlog" })
65
- store.create({ title: "Also backlog", status: "backlog" })
66
- store.create({ title: "Blocked", status: "backlog", blockedBy: [2] })
67
- store.create({ title: "For a person", status: "backlog", needsHuman: true })
68
- store.create({ title: "Already queued", status: "todo" })
70
+ const liner = store.create({ title: "One-liner", status: "backlog" })
71
+ const also = store.create({ title: "Also backlog", status: "backlog" })
72
+ const blocked = store.create({ title: "Blocked", status: "backlog", blockedBy: [also.key] })
73
+ const person = store.create({ title: "For a person", status: "backlog" })
74
+ store.addAsk(person.key, "Pick the vendor", "claude")
75
+ const queued = store.create({ title: "Already queued", status: "todo" })
69
76
  store.close()
70
- return dir
77
+ return {
78
+ dir,
79
+ ids: { liner: liner.id, also: also.id, blocked: blocked.id, person: person.id, queued: queued.id },
80
+ }
71
81
  }
72
82
 
73
83
  async function show(dir: string, id: string): Promise<Task> {
@@ -77,38 +87,40 @@ async function show(dir: string, id: string): Promise<Task> {
77
87
  }
78
88
 
79
89
  test("any backlog ticket promotes to todo — no quality gate", async () => {
80
- const dir = fixture()
81
- const result = await cli(dir, "promote", "PRO-1", "--json")
90
+ const { dir, ids } = fixture()
91
+ const result = await cli(dir, "promote", ids.liner, "--json")
82
92
  assert.equal(result.code, 0, result.stderr)
83
93
  const { task, holds } = JSON.parse(result.stdout) as { task: Task; holds: string[] }
84
94
  assert.equal(task.status, "todo")
85
95
  assert.deepEqual(holds, [])
86
- assert.equal((await show(dir, "PRO-1")).status, "todo")
96
+ assert.equal((await show(dir, ids.liner)).status, "todo")
87
97
  })
88
98
 
89
99
  test("only backlog tickets promote", async () => {
90
- const dir = fixture()
91
- const result = await cli(dir, "promote", "PRO-5")
100
+ const { dir, ids } = fixture()
101
+ const result = await cli(dir, "promote", ids.queued)
92
102
  assert.equal(result.code, 2)
93
103
  assert.match(result.stderr, /is todo — only backlog tickets/)
94
104
  })
95
105
 
96
- test("blocked and needs-human tickets promote with the hold reported", async () => {
97
- const dir = fixture()
98
- const blocked = await cli(dir, "promote", "PRO-3", "--json")
106
+ test("blocked tickets promote with the hold reported; needs-human isn't a hold", async () => {
107
+ const { dir, ids } = fixture()
108
+ const blocked = await cli(dir, "promote", ids.blocked, "--json")
99
109
  assert.equal(blocked.code, 0, blocked.stderr)
100
110
  const parsed = JSON.parse(blocked.stdout) as { task: Task; holds: string[] }
101
111
  assert.equal(parsed.task.status, "todo")
102
- assert.deepEqual(parsed.holds, ["blocked by PRO-2"])
112
+ assert.deepEqual(parsed.holds, [`blocked by ${ids.also}`])
103
113
 
104
- const person = await cli(dir, "promote", "PRO-4")
114
+ // Asks gate done, not claiming — so a human-owing ticket promotes with
115
+ // nothing to report.
116
+ const person = await cli(dir, "promote", ids.person, "--json")
105
117
  assert.equal(person.code, 0, person.stderr)
106
- assert.match(person.stdout, /needs-human in todo, but not claimable/)
107
- assert.equal((await show(dir, "PRO-4")).status, "todo")
118
+ assert.deepEqual((JSON.parse(person.stdout) as { holds: string[] }).holds, [])
119
+ assert.equal((await show(dir, ids.person)).status, "todo")
108
120
  })
109
121
 
110
122
  test("task add with no --status lands in backlog", async () => {
111
- const dir = fixture()
123
+ const { dir } = fixture()
112
124
  const added = await cli(dir, "add", "Dumped in passing", "--json")
113
125
  assert.equal(added.code, 0, added.stderr)
114
126
  const { task } = JSON.parse(added.stdout) as { task: Task }
@@ -116,7 +128,7 @@ test("task add with no --status lands in backlog", async () => {
116
128
  })
117
129
 
118
130
  test("--description-file reads markdown from disk; combining both flags refuses", async () => {
119
- const dir = fixture()
131
+ const { dir } = fixture()
120
132
  const body = "## Plan\n\nA description with \"quotes\", `backticks`,\nand $(subshells) that never touch a shell.\n"
121
133
  writeFileSync(join(dir, "desc.md"), body)
122
134
 
package/src/publish.ts CHANGED
@@ -18,9 +18,14 @@
18
18
  * same operation as re-running it here.
19
19
  */
20
20
 
21
- import { execFileSync } from "node:child_process"
22
21
  import process from "node:process"
23
22
 
23
+ import { detectRepo, parseRemote, type RepoRef } from "./git.ts"
24
+
25
+ // Re-exported so the CLI keeps one import site for "which repo is this";
26
+ // the implementations moved to git.ts with the rest of the git access.
27
+ export { detectRepo, parseRemote, type RepoRef }
28
+
24
29
  /** Overridable so the flow can be exercised against a local `vite dev`. */
25
30
  export const DEFAULT_HOST = "https://task.nickmeriano.com"
26
31
 
@@ -28,58 +33,6 @@ export type Visibility = "private" | "public"
28
33
 
29
34
  export type Action = "publish" | "unpublish"
30
35
 
31
- export interface RepoRef {
32
- owner: string
33
- repo: string
34
- }
35
-
36
- /**
37
- * The GitHub repo this working copy pushes to.
38
- *
39
- * `origin` first, then the only remote if there's exactly one — a clone with
40
- * `upstream` and a fork remote should not silently publish the wrong one, so
41
- * ambiguity is an error the user resolves with `--repo`.
42
- */
43
- export function detectRepo(cwd: string): RepoRef | null {
44
- const remotes = gitRemotes(cwd)
45
- const url = remotes.get("origin") ?? (remotes.size === 1 ? [...remotes.values()][0] : null)
46
- return url ? parseRemote(url) : null
47
- }
48
-
49
- function gitRemotes(cwd: string): Map<string, string> {
50
- const out = new Map<string, string>()
51
- let raw: string
52
- try {
53
- raw = execFileSync("git", ["remote", "-v"], {
54
- cwd,
55
- encoding: "utf8",
56
- stdio: ["ignore", "pipe", "ignore"],
57
- })
58
- } catch {
59
- return out
60
- }
61
- for (const line of raw.split("\n")) {
62
- const match = /^(\S+)\s+(\S+)\s+\(fetch\)$/.exec(line.trim())
63
- if (match) out.set(match[1], match[2])
64
- }
65
- return out
66
- }
67
-
68
- /**
69
- * Every shape git writes a GitHub remote in: https, ssh, `git@`-scp, with or
70
- * without `.git`, and with or without credentials embedded in the host.
71
- */
72
- export function parseRemote(url: string): RepoRef | null {
73
- const cleaned = url.trim().replace(/\.git$/, "")
74
- const match =
75
- /^(?:https?:\/\/|ssh:\/\/)?(?:[^@/]+@)?github\.com[:/]([^/]+)\/([^/]+?)\/?$/.exec(cleaned)
76
- if (!match) return null
77
- const [, owner, repo] = match
78
- if (!/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/.test(owner)) return null
79
- if (!/^[A-Za-z0-9._-]{1,100}$/.test(repo)) return null
80
- return { owner, repo }
81
- }
82
-
83
36
  /** `owner/repo`, for `--repo`. */
84
37
  export function parseSlug(slug: string): RepoRef | null {
85
38
  const [owner, repo, ...rest] = slug.split("/")
@@ -18,17 +18,17 @@ test("search covers titles, descriptions and comments, case-insensitively", () =
18
18
  const b = store.create({ title: "Unrelated", description: "The webhook queue drains here." })
19
19
  const c = store.create({ title: "Also unrelated" })
20
20
  store.create({ title: "Noise" })
21
- store.addComment(c.number, "root cause: the WEBHOOK signature check", "claude")
21
+ store.addComment(c.key, "root cause: the WEBHOOK signature check", "claude")
22
22
 
23
23
  const results = searchStore(store, "webhook", {}, false)
24
24
  assert.deepEqual(
25
- results.map((r) => r.task.number).sort(),
26
- [a.number, b.number, c.number],
25
+ results.map((r) => r.task.key).sort(),
26
+ [a.key, b.key, c.key].sort(),
27
27
  )
28
- const byNumber = new Map(results.map((r) => [r.task.number, r.matches]))
29
- assert.deepEqual(byNumber.get(a.number)!.map((m) => m.field), ["title"])
30
- assert.equal(byNumber.get(b.number)![0].snippet, "The webhook queue drains here.")
31
- const comment = byNumber.get(c.number)![0]
28
+ const byKey = new Map(results.map((r) => [r.task.key, r.matches]))
29
+ assert.deepEqual(byKey.get(a.key)!.map((m) => m.field), ["title"])
30
+ assert.equal(byKey.get(b.key)![0].snippet, "The webhook queue drains here.")
31
+ const comment = byKey.get(c.key)![0]
32
32
  assert.equal(comment.field, "comment")
33
33
  assert.equal(comment.author, "claude")
34
34
  assert.match(comment.snippet, /WEBHOOK signature/)
@@ -39,14 +39,14 @@ test("filters scope the search; --archived adds the archive in", () => {
39
39
  const live = store.create({ title: "deploy pipeline", tags: ["infra"] })
40
40
  store.create({ title: "deploy docs", tags: ["docs"] })
41
41
  const old = store.create({ title: "deploy v1", status: "done" })
42
- store.archive(old.number)
42
+ store.archive(old.key)
43
43
 
44
44
  assert.deepEqual(
45
- searchStore(store, "deploy", { tags: ["infra"] }, false).map((r) => r.task.number),
46
- [live.number],
45
+ searchStore(store, "deploy", { tags: ["infra"] }, false).map((r) => r.task.key),
46
+ [live.key],
47
47
  )
48
48
  const withArchive = searchStore(store, "deploy", {}, true)
49
- assert.ok(withArchive.some((r) => r.task.number === old.number && r.task.archived))
49
+ assert.ok(withArchive.some((r) => r.task.key === old.key && r.task.archived))
50
50
  assert.equal(withArchive.length, 3)
51
51
  // Without the flag the archived ticket stays out.
52
52
  assert.equal(searchStore(store, "deploy", {}, false).length, 2)
package/src/search.ts CHANGED
@@ -13,12 +13,14 @@ import type { Store } from "./store.ts"
13
13
  import { STATUSES, type Task, type TaskFilter } from "./types.ts"
14
14
 
15
15
  export interface SearchMatch {
16
- field: "title" | "description" | "comment"
16
+ field: "title" | "description" | "comment" | "ask"
17
17
  /** The matching line, trimmed and windowed around the first hit. */
18
18
  snippet: string
19
19
  /** Set on comment matches. */
20
20
  commentId?: string
21
21
  author?: string
22
+ /** Set on ask matches — the ordinal `task resolve` takes. */
23
+ askOrdinal?: number
22
24
  }
23
25
 
24
26
  export interface SearchResult {
@@ -67,7 +69,18 @@ export function searchStore(
67
69
  if (task.description.toLowerCase().includes(needle)) {
68
70
  matches.push({ field: "description", snippet: snippetOf(task.description, needle) })
69
71
  }
70
- for (const comment of store.comments(task.number)) {
72
+ // Asks ride along on the task, so no extra read — and a hit names the
73
+ // ordinal, which is the handle you'd resolve it by.
74
+ for (const ask of task.asks) {
75
+ if (!ask.text.toLowerCase().includes(needle)) continue
76
+ matches.push({
77
+ field: "ask",
78
+ snippet: snippetOf(ask.text, needle),
79
+ askOrdinal: ask.ordinal,
80
+ ...(ask.author ? { author: ask.author } : {}),
81
+ })
82
+ }
83
+ for (const comment of store.comments(task.key)) {
71
84
  if (!comment.body.toLowerCase().includes(needle)) continue
72
85
  matches.push({
73
86
  field: "comment",
@@ -85,7 +98,8 @@ export function searchStore(
85
98
  (a, b) =>
86
99
  order.get(a.task.status)! - order.get(b.task.status)! ||
87
100
  a.task.position - b.task.position ||
88
- a.task.number - b.task.number,
101
+ a.task.createdAt.localeCompare(b.task.createdAt) ||
102
+ a.task.key.localeCompare(b.task.key),
89
103
  )
90
104
  return results
91
105
  }