@nickmeriano/task 0.7.1 → 0.9.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 (128) hide show
  1. package/README.md +73 -25
  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 +43 -0
  7. package/dist/check.d.ts.map +1 -0
  8. package/dist/check.js +403 -0
  9. package/dist/check.js.map +1 -0
  10. package/dist/check.test.d.ts +9 -0
  11. package/dist/check.test.d.ts.map +1 -0
  12. package/dist/check.test.js +248 -0
  13. package/dist/check.test.js.map +1 -0
  14. package/dist/claim-io.d.ts +73 -0
  15. package/dist/claim-io.d.ts.map +1 -0
  16. package/dist/claim-io.js +344 -0
  17. package/dist/claim-io.js.map +1 -0
  18. package/dist/claim.d.ts +61 -9
  19. package/dist/claim.d.ts.map +1 -1
  20. package/dist/claim.js +197 -67
  21. package/dist/claim.js.map +1 -1
  22. package/dist/claim.test.d.ts +2 -2
  23. package/dist/claim.test.js +235 -64
  24. package/dist/claim.test.js.map +1 -1
  25. package/dist/cli.js +724 -136
  26. package/dist/cli.js.map +1 -1
  27. package/dist/file-store.d.ts +110 -38
  28. package/dist/file-store.d.ts.map +1 -1
  29. package/dist/file-store.js +514 -238
  30. package/dist/file-store.js.map +1 -1
  31. package/dist/git-serve.d.ts +183 -0
  32. package/dist/git-serve.d.ts.map +1 -0
  33. package/dist/git-serve.js +503 -0
  34. package/dist/git-serve.js.map +1 -0
  35. package/dist/git-serve.test.d.ts +16 -0
  36. package/dist/git-serve.test.d.ts.map +1 -0
  37. package/dist/git-serve.test.js +183 -0
  38. package/dist/git-serve.test.js.map +1 -0
  39. package/dist/git.d.ts +65 -0
  40. package/dist/git.d.ts.map +1 -0
  41. package/dist/git.js +114 -0
  42. package/dist/git.js.map +1 -0
  43. package/dist/id.d.ts +39 -0
  44. package/dist/id.d.ts.map +1 -0
  45. package/dist/id.js +67 -0
  46. package/dist/id.js.map +1 -0
  47. package/dist/inbox.d.ts +41 -0
  48. package/dist/inbox.d.ts.map +1 -0
  49. package/dist/inbox.js +56 -0
  50. package/dist/inbox.js.map +1 -0
  51. package/dist/index.d.ts +5 -3
  52. package/dist/index.d.ts.map +1 -1
  53. package/dist/index.js +5 -3
  54. package/dist/index.js.map +1 -1
  55. package/dist/overview.d.ts +52 -0
  56. package/dist/overview.d.ts.map +1 -0
  57. package/dist/overview.js +61 -0
  58. package/dist/overview.js.map +1 -0
  59. package/dist/overview.test.d.ts +8 -0
  60. package/dist/overview.test.d.ts.map +1 -0
  61. package/dist/overview.test.js +48 -0
  62. package/dist/overview.test.js.map +1 -0
  63. package/dist/promote.test.d.ts +15 -0
  64. package/dist/promote.test.d.ts.map +1 -0
  65. package/dist/promote.test.js +104 -0
  66. package/dist/promote.test.js.map +1 -0
  67. package/dist/publish.d.ts +2 -17
  68. package/dist/publish.d.ts.map +1 -1
  69. package/dist/publish.js +4 -49
  70. package/dist/publish.js.map +1 -1
  71. package/dist/search.d.ts +34 -0
  72. package/dist/search.d.ts.map +1 -0
  73. package/dist/search.js +79 -0
  74. package/dist/search.js.map +1 -0
  75. package/dist/search.test.d.ts +2 -0
  76. package/dist/search.test.d.ts.map +1 -0
  77. package/dist/search.test.js +53 -0
  78. package/dist/search.test.js.map +1 -0
  79. package/dist/server.d.ts.map +1 -1
  80. package/dist/server.js +228 -23
  81. package/dist/server.js.map +1 -1
  82. package/dist/store.d.ts +43 -63
  83. package/dist/store.d.ts.map +1 -1
  84. package/dist/store.js +0 -368
  85. package/dist/store.js.map +1 -1
  86. package/dist/store.test.d.ts +1 -2
  87. package/dist/store.test.d.ts.map +1 -1
  88. package/dist/store.test.js +148 -106
  89. package/dist/store.test.js.map +1 -1
  90. package/dist/ticket-doc.d.ts +74 -5
  91. package/dist/ticket-doc.d.ts.map +1 -1
  92. package/dist/ticket-doc.js +229 -15
  93. package/dist/ticket-doc.js.map +1 -1
  94. package/dist/types.d.ts +115 -28
  95. package/dist/types.d.ts.map +1 -1
  96. package/dist/types.js.map +1 -1
  97. package/package.json +1 -1
  98. package/skill/SKILL.md +153 -40
  99. package/src/asks.test.ts +355 -0
  100. package/src/check.test.ts +328 -0
  101. package/src/check.ts +497 -0
  102. package/src/claim-io.ts +401 -0
  103. package/src/claim.test.ts +301 -71
  104. package/src/claim.ts +238 -81
  105. package/src/cli.ts +740 -131
  106. package/src/file-store.ts +572 -254
  107. package/src/git-serve.test.ts +240 -0
  108. package/src/git-serve.ts +595 -0
  109. package/src/git.ts +141 -0
  110. package/src/id.ts +68 -0
  111. package/src/inbox.ts +77 -0
  112. package/src/index.ts +4 -2
  113. package/src/overview.test.ts +52 -0
  114. package/src/overview.ts +105 -0
  115. package/src/promote.test.ts +143 -0
  116. package/src/publish.ts +6 -53
  117. package/src/search.test.ts +64 -0
  118. package/src/search.ts +105 -0
  119. package/src/server.ts +232 -21
  120. package/src/store.test.ts +166 -116
  121. package/src/store.ts +46 -444
  122. package/src/ticket-doc.ts +284 -21
  123. package/src/types.ts +120 -28
  124. package/ui/dist/assets/index-BjsorZOU.js +229 -0
  125. package/ui/dist/assets/index-CoKCUYic.css +1 -0
  126. package/ui/dist/index.html +2 -2
  127. package/ui/dist/assets/index-COunM-QN.css +0 -1
  128. package/ui/dist/assets/index-D4homvrQ.js +0 -229
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("/")
@@ -0,0 +1,64 @@
1
+ import assert from "node:assert/strict"
2
+ import { test } from "node:test"
3
+ import { mkdtempSync, rmSync } from "node:fs"
4
+ import { tmpdir } from "node:os"
5
+ import { join } from "node:path"
6
+ import { initProject } from "./file-store.ts"
7
+ import { searchStore } from "./search.ts"
8
+
9
+ function tempRoot(): string {
10
+ const dir = mkdtempSync(join(tmpdir(), "task-search-"))
11
+ process.on("exit", () => rmSync(dir, { recursive: true, force: true }))
12
+ return dir
13
+ }
14
+
15
+ test("search covers titles, descriptions and comments, case-insensitively", () => {
16
+ const store = initProject(tempRoot(), { name: "search", prefix: "SRC" })
17
+ const a = store.create({ title: "Wire up Webhook retries", description: "exponential backoff" })
18
+ const b = store.create({ title: "Unrelated", description: "The webhook queue drains here." })
19
+ const c = store.create({ title: "Also unrelated" })
20
+ store.create({ title: "Noise" })
21
+ store.addComment(c.key, "root cause: the WEBHOOK signature check", "claude")
22
+
23
+ const results = searchStore(store, "webhook", {}, false)
24
+ assert.deepEqual(
25
+ results.map((r) => r.task.key).sort(),
26
+ [a.key, b.key, c.key].sort(),
27
+ )
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
+ assert.equal(comment.field, "comment")
33
+ assert.equal(comment.author, "claude")
34
+ assert.match(comment.snippet, /WEBHOOK signature/)
35
+ })
36
+
37
+ test("filters scope the search; --archived adds the archive in", () => {
38
+ const store = initProject(tempRoot(), { name: "scope", prefix: "SCP" })
39
+ const live = store.create({ title: "deploy pipeline", tags: ["infra"] })
40
+ store.create({ title: "deploy docs", tags: ["docs"] })
41
+ const old = store.create({ title: "deploy v1", status: "done" })
42
+ store.archive(old.key)
43
+
44
+ assert.deepEqual(
45
+ searchStore(store, "deploy", { tags: ["infra"] }, false).map((r) => r.task.key),
46
+ [live.key],
47
+ )
48
+ const withArchive = searchStore(store, "deploy", {}, true)
49
+ assert.ok(withArchive.some((r) => r.task.key === old.key && r.task.archived))
50
+ assert.equal(withArchive.length, 3)
51
+ // Without the flag the archived ticket stays out.
52
+ assert.equal(searchStore(store, "deploy", {}, false).length, 2)
53
+ })
54
+
55
+ test("long lines are windowed so the hit is visible", () => {
56
+ const store = initProject(tempRoot(), { name: "win", prefix: "WIN" })
57
+ const filler = "x".repeat(150)
58
+ store.create({ title: "Long", description: `${filler} needle in here ${filler}` })
59
+ const [result] = searchStore(store, "needle", {}, false)
60
+ assert.ok(result.matches[0].snippet.length <= 102) // window + ellipses
61
+ assert.match(result.matches[0].snippet, /needle in here/)
62
+ assert.match(result.matches[0].snippet, /^…/)
63
+ assert.match(result.matches[0].snippet, /…$/)
64
+ })
package/src/search.ts ADDED
@@ -0,0 +1,105 @@
1
+ /**
2
+ * `task search` — find tickets by what they say, not just what they're tagged.
3
+ *
4
+ * Case-insensitive substring match over titles, descriptions and comments,
5
+ * scoped by the usual list filters. At current board sizes a straight scan of
6
+ * the parsed files is plenty; if boards ever grow to thousands of tickets,
7
+ * this is the one feature that would justify a derived FTS index — rebuilt
8
+ * from the files, never as state, which the text-canonical format was designed
9
+ * to allow.
10
+ */
11
+
12
+ import type { Store } from "./store.ts"
13
+ import { STATUSES, type Task, type TaskFilter } from "./types.ts"
14
+
15
+ export interface SearchMatch {
16
+ field: "title" | "description" | "comment" | "ask"
17
+ /** The matching line, trimmed and windowed around the first hit. */
18
+ snippet: string
19
+ /** Set on comment matches. */
20
+ commentId?: string
21
+ author?: string
22
+ /** Set on ask matches — the ordinal `task resolve` takes. */
23
+ askOrdinal?: number
24
+ }
25
+
26
+ export interface SearchResult {
27
+ task: Task
28
+ matches: SearchMatch[]
29
+ }
30
+
31
+ /** How much of a matching line a snippet shows. */
32
+ const SNIPPET_MAX = 100
33
+
34
+ /** The first line containing the needle, windowed so the hit is visible. */
35
+ function snippetOf(text: string, needle: string): string {
36
+ const lines = text.split("\n")
37
+ const line = (lines.find((l) => l.toLowerCase().includes(needle)) ?? lines[0]).trim()
38
+ if (line.length <= SNIPPET_MAX) return line
39
+ const at = line.toLowerCase().indexOf(needle)
40
+ const start = Math.max(0, Math.min(at - 30, line.length - SNIPPET_MAX))
41
+ const end = Math.min(line.length, start + SNIPPET_MAX)
42
+ return `${start > 0 ? "…" : ""}${line.slice(start, end).trim()}${end < line.length ? "…" : ""}`
43
+ }
44
+
45
+ /**
46
+ * Every task matching `query`, with one snippet per matching field (and one
47
+ * per matching comment). `includeArchived` searches the archive *in addition
48
+ * to* the board — unlike `list --archived`, which shows the archive instead,
49
+ * a search is a question about everything ever written down.
50
+ */
51
+ export function searchStore(
52
+ store: Store,
53
+ query: string,
54
+ filter: TaskFilter,
55
+ includeArchived: boolean,
56
+ ): SearchResult[] {
57
+ const needle = query.toLowerCase()
58
+ const pool = [
59
+ ...store.list(filter),
60
+ ...(includeArchived ? store.list({ ...filter, archived: true }) : []),
61
+ ]
62
+
63
+ const results: SearchResult[] = []
64
+ for (const task of pool) {
65
+ const matches: SearchMatch[] = []
66
+ if (task.title.toLowerCase().includes(needle)) {
67
+ matches.push({ field: "title", snippet: snippetOf(task.title, needle) })
68
+ }
69
+ if (task.description.toLowerCase().includes(needle)) {
70
+ matches.push({ field: "description", snippet: snippetOf(task.description, needle) })
71
+ }
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)) {
84
+ if (!comment.body.toLowerCase().includes(needle)) continue
85
+ matches.push({
86
+ field: "comment",
87
+ snippet: snippetOf(comment.body, needle),
88
+ commentId: comment.id,
89
+ ...(comment.author ? { author: comment.author } : {}),
90
+ })
91
+ }
92
+ if (matches.length > 0) results.push({ task, matches })
93
+ }
94
+
95
+ // Board order — grouped by status column, then position — same as `list`.
96
+ const order = new Map(STATUSES.map((s, i) => [s, i]))
97
+ results.sort(
98
+ (a, b) =>
99
+ order.get(a.task.status)! - order.get(b.task.status)! ||
100
+ a.task.position - b.task.position ||
101
+ a.task.createdAt.localeCompare(b.task.createdAt) ||
102
+ a.task.key.localeCompare(b.task.key),
103
+ )
104
+ return results
105
+ }
package/src/server.ts CHANGED
@@ -3,8 +3,11 @@ import { watch, existsSync, readFileSync, statSync, type FSWatcher } from "node:
3
3
  import { extname, join, normalize } from "node:path"
4
4
  import { fileURLToPath } from "node:url"
5
5
  import { resolveAuthor } from "./author.ts"
6
+ import { addAskRouted, deleteAskRouted, setAskResolvedRouted } from "./claim-io.ts"
6
7
  import { openBoard } from "./file-store.ts"
7
- import { findBoards, type BoardRef, type Store } from "./store.ts"
8
+ import { GitServe, GitServeError } from "./git-serve.ts"
9
+ import { buildInbox } from "./inbox.ts"
10
+ import { boardConfig, findBoards, type BoardRef, type Store } from "./store.ts"
8
11
  import { STATUSES, isStatus, type TaskPatch } from "./types.ts"
9
12
 
10
13
  /** The prebuilt SPA, shipped inside the package next to dist/. */
@@ -45,12 +48,11 @@ function parsePatch(body: Record<string, unknown>): TaskPatch {
45
48
  patch.status = body.status
46
49
  }
47
50
  if (Array.isArray(body.tags)) patch.tags = body.tags.map(String)
48
- if (typeof body.milestone === "string" || body.milestone === null) {
49
- patch.milestone = body.milestone === "" ? null : body.milestone
51
+ if (typeof body.goal === "string" || body.goal === null) {
52
+ patch.goal = body.goal === "" ? null : body.goal
50
53
  }
51
- if (typeof body.needsHuman === "boolean") patch.needsHuman = body.needsHuman
52
- if (Array.isArray(body.blocks)) patch.blocks = body.blocks.map(taskNumber)
53
- if (Array.isArray(body.blockedBy)) patch.blockedBy = body.blockedBy.map(taskNumber)
54
+ if (Array.isArray(body.blocks)) patch.blocks = body.blocks.map(taskKey)
55
+ if (Array.isArray(body.blockedBy)) patch.blockedBy = body.blockedBy.map(taskKey)
54
56
  if (Array.isArray(body.prs)) {
55
57
  patch.prs = body.prs.map((pr) => {
56
58
  const url = String(pr).trim()
@@ -63,10 +65,32 @@ function parsePatch(body: Record<string, unknown>): TaskPatch {
63
65
  return patch
64
66
  }
65
67
 
66
- function taskNumber(value: unknown): number {
67
- const n = Number(value)
68
- if (!Number.isInteger(n) || n <= 0) throw new Error(`invalid task number: ${String(value)}`)
69
- return n
68
+ function taskKey(value: unknown): string {
69
+ if (typeof value !== "string" || !/^[a-z0-9]+$/.test(value)) {
70
+ throw new Error(`invalid task key: ${String(value)}`)
71
+ }
72
+ return value
73
+ }
74
+
75
+ /**
76
+ * The CSRF gate. `task serve` is an unauthenticated HTTP server, and any
77
+ * webpage in the same browser can aim a POST at it — before this check, a
78
+ * drive-by page could edit the board, and with the git surface it could
79
+ * commit and push. Same-origin is the whole auth model, so a write has to
80
+ * prove it: browsers attach `Origin` to every cross-origin request (and to
81
+ * all fetch POSTs), and the only origin allowed to write is the board's own —
82
+ * whatever host the request itself was addressed to. No Origin header means
83
+ * no browser page is speaking (curl, scripts), which is the local-trust case
84
+ * the server already accepts.
85
+ */
86
+ function sameOrigin(req: IncomingMessage): boolean {
87
+ const origin = req.headers.origin
88
+ if (origin === undefined) return true
89
+ try {
90
+ return new URL(origin).host === req.headers.host
91
+ } catch {
92
+ return false // "null" and other unparseable origins are nobody we trust
93
+ }
70
94
  }
71
95
 
72
96
  /**
@@ -144,6 +168,28 @@ export function createTaskServer(serveRoot: string): Server {
144
168
  throw new Error(`no .task directory found at or below ${serveRoot} — run \`task init\` first`)
145
169
  }
146
170
 
171
+ const gitServe = new GitServe(serveRoot, () =>
172
+ ordered.map((id) => ({ id, store: boards.get(id)!.store })),
173
+ )
174
+
175
+ // Branch switches don't touch `.task/` files, but they change what every
176
+ // board file *says* — so watch the git dir for HEAD moving (a checkout,
177
+ // from this server or a terminal) and ping every board's SSE listeners.
178
+ // Non-recursive on the resolved git dir (worktrees keep HEAD there), and
179
+ // best-effort: a repo this can't watch simply refreshes on the next edit.
180
+ let gitWatcher: FSWatcher | null = null
181
+ const gitDir = gitServe.gitDir()
182
+ if (gitDir) {
183
+ try {
184
+ gitWatcher = watch(gitDir, (_event, filename) => {
185
+ if (filename === "HEAD") for (const id of ordered) noteChange(id)
186
+ })
187
+ gitWatcher.on("error", () => {})
188
+ } catch {
189
+ gitWatcher = null
190
+ }
191
+ }
192
+
147
193
  const heartbeat = setInterval(() => {
148
194
  for (const client of clients) client.write(`: ping\n\n`)
149
195
  }, 30_000)
@@ -159,6 +205,7 @@ export function createTaskServer(serveRoot: string): Server {
159
205
  server.on("close", () => {
160
206
  if (pending) clearTimeout(pending)
161
207
  clearInterval(heartbeat)
208
+ gitWatcher?.close()
162
209
  for (const board of boards.values()) {
163
210
  board.watcher.close()
164
211
  board.store.close()
@@ -170,6 +217,19 @@ export function createTaskServer(serveRoot: string): Server {
170
217
  const url = new URL(req.url ?? "/", "http://localhost")
171
218
  const path = url.pathname
172
219
 
220
+ // Every mutation, one gate — the ask routes push commits and the git
221
+ // routes run git, so this cannot be per-endpoint courtesy.
222
+ const method = req.method ?? "GET"
223
+ if (
224
+ path.startsWith("/api/") &&
225
+ method !== "GET" &&
226
+ method !== "HEAD" &&
227
+ !sameOrigin(req)
228
+ ) {
229
+ json(res, 403, { error: "cross-origin request refused — the board only writes for its own pages" })
230
+ return
231
+ }
232
+
173
233
  if (path === "/api/events") {
174
234
  res.writeHead(200, {
175
235
  "Content-Type": "text/event-stream",
@@ -193,6 +253,94 @@ export function createTaskServer(serveRoot: string): Server {
193
253
  return
194
254
  }
195
255
 
256
+ if (path === "/api/inbox") {
257
+ // Deliberately cross-board — the header badge is repo-wide attention.
258
+ // Scope follows the CLI's selection rule: the root board's `boards`
259
+ // list when it has one (matched by prefix), every served board
260
+ // otherwise — so a vendored board can't put asks in your face unless
261
+ // the root board opted it in.
262
+ syncBoards()
263
+ const all = ordered.map((id) => ({ id, store: boards.get(id)!.store }))
264
+ let scoped = all
265
+ try {
266
+ const wanted = boardConfig(all[0].store.root).boards
267
+ if (Array.isArray(wanted) && wanted.length > 0) {
268
+ const prefixes = new Set(wanted.map((p) => String(p).toUpperCase()))
269
+ const matched = all.filter((b) => prefixes.has(b.store.config.prefix.toUpperCase()))
270
+ if (matched.length > 0) scoped = matched
271
+ }
272
+ } catch {
273
+ // Unreadable root config — serve everything rather than nothing.
274
+ }
275
+ json(res, 200, { inbox: buildInbox(scoped) })
276
+ return
277
+ }
278
+
279
+ // The git surface (TAS-42). Repo-level, not board-level — one working
280
+ // tree holds every served board — so it lives beside /api/inbox rather
281
+ // than inside routeApi. Every handler 404s ("no git surface here") when
282
+ // the capability is off, which is also what the prebuilt UI probes.
283
+ if (path.startsWith("/api/git/")) {
284
+ const fail = (error: unknown): void => {
285
+ const status = error instanceof GitServeError ? error.status : 400
286
+ json(res, status, { error: error instanceof Error ? error.message : String(error) })
287
+ }
288
+ try {
289
+ if (path === "/api/git/status" && req.method === "GET") {
290
+ json(res, 200, gitServe.status())
291
+ return
292
+ }
293
+ if (path === "/api/git/branches" && req.method === "GET") {
294
+ json(res, 200, { branches: gitServe.branches() })
295
+ return
296
+ }
297
+ if (path === "/api/git/pr" && req.method === "GET") {
298
+ const branch = url.searchParams.get("branch")
299
+ if (!branch) {
300
+ json(res, 400, { error: "branch is required" })
301
+ return
302
+ }
303
+ json(res, 200, gitServe.prFor(branch))
304
+ return
305
+ }
306
+ if (path === "/api/git/commit" && req.method === "POST") {
307
+ void readBody(req)
308
+ .then((body) => json(res, 200, gitServe.commit(body)))
309
+ .catch(fail)
310
+ return
311
+ }
312
+ if (path === "/api/git/checkout" && req.method === "POST") {
313
+ void readBody(req)
314
+ .then((body) => json(res, 200, gitServe.checkout(body)))
315
+ .catch(fail)
316
+ return
317
+ }
318
+ if (path === "/api/git/pr" && req.method === "POST") {
319
+ void readBody(req)
320
+ .then((body) => json(res, 200, gitServe.pr(body)))
321
+ .catch(fail)
322
+ return
323
+ }
324
+ if (path === "/api/git/release" && req.method === "POST") {
325
+ const id = url.searchParams.get("board") ?? ordered[0]
326
+ const board = boards.get(id)
327
+ if (!board) {
328
+ json(res, 404, { error: `no such board: ${id}` })
329
+ return
330
+ }
331
+ void readBody(req)
332
+ .then((body) => json(res, 200, gitServe.releaseClaim(board.store, body, author)))
333
+ .catch(fail)
334
+ return
335
+ }
336
+ } catch (error) {
337
+ fail(error)
338
+ return
339
+ }
340
+ json(res, 404, { error: "not found" })
341
+ return
342
+ }
343
+
196
344
  if (path.startsWith("/api/")) {
197
345
  const id = url.searchParams.get("board") ?? ordered[0]
198
346
  const board = boards.get(id)
@@ -219,15 +367,28 @@ export function createTaskServer(serveRoot: string): Server {
219
367
  prefix: store.config.prefix,
220
368
  statuses: STATUSES,
221
369
  author,
370
+ // The git capability, or nothing: its absence is how the hosted and
371
+ // snapshot deployments (whose /project never carries it) and a serve
372
+ // with no origin all tell the UI to draw no git surface at all.
373
+ git: gitServe.capability() ?? undefined,
222
374
  })
223
375
  return
224
376
  }
225
377
 
378
+ if (path === "/api/goals" && req.method === "GET") {
379
+ // Read-only on purpose: a goal is created deliberately, with a
380
+ // description, via `task goal add` — a picker that mints goals from
381
+ // typed strings would just be milestones with extra steps. Archived
382
+ // goals ride along flagged, so old chips can still resolve a title.
383
+ json(res, 200, { goals: [...store.goals(), ...store.goals(true)] })
384
+ return
385
+ }
386
+
226
387
  if (path === "/api/tasks" && req.method === "GET") {
227
388
  const counts = store.commentCounts()
228
389
  const tasks = store
229
390
  .list()
230
- .map((t) => ({ ...t, commentCount: counts.get(t.number) ?? 0 }))
391
+ .map((t) => ({ ...t, commentCount: counts.get(t.key) ?? 0 }))
231
392
  json(res, 200, { tasks })
232
393
  return
233
394
  }
@@ -239,19 +400,69 @@ export function createTaskServer(serveRoot: string): Server {
239
400
  throw new Error("title is required")
240
401
  }
241
402
  const patch = parsePatch(body)
242
- const task = store.create({ title: body.title.trim(), ...patch })
403
+ let task = store.create({ title: body.title.trim(), ...patch })
404
+ // Asks at create time — the dialog's chip. Author is the server's
405
+ // resolved identity, same trust rule as comments.
406
+ if (Array.isArray(body.asks)) {
407
+ for (const text of body.asks) {
408
+ if (typeof text === "string" && text.trim()) {
409
+ store.addAsk(task.key, text.trim(), author)
410
+ }
411
+ }
412
+ task = store.get(task.key)!
413
+ }
243
414
  json(res, 201, { task })
244
415
  })
245
416
  .catch((error: Error) => json(res, 400, { error: error.message }))
246
417
  return
247
418
  }
248
419
 
420
+ // Routed, not raw store calls: on a claimed ticket the write lands on the
421
+ // claim branch as a pushed commit (claim-io.ts) — the branch owns a
422
+ // claimed ticket's asks, checkbox and CLI alike.
423
+ const askMatch = /^\/api\/tasks\/([^/]+)\/asks(?:\/([^/]+))?$/.exec(path)
424
+ if (askMatch) {
425
+ const key = store.parseId(decodeURIComponent(askMatch[1]))
426
+ if (!askMatch[2] && req.method === "POST") {
427
+ void readBody(req)
428
+ .then((body) => {
429
+ if (typeof body.text !== "string" || !body.text.trim()) {
430
+ throw new Error("text is required")
431
+ }
432
+ const { ask, branch } = addAskRouted(store, key, body.text.trim(), author)
433
+ json(res, 201, { ask, branch })
434
+ })
435
+ .catch((error: Error) => json(res, 400, { error: error.message }))
436
+ return
437
+ }
438
+ if (askMatch[2] && req.method === "PATCH") {
439
+ const ref = decodeURIComponent(askMatch[2])
440
+ void readBody(req)
441
+ .then((body) => {
442
+ if (typeof body.resolved !== "boolean") {
443
+ throw new Error("resolved must be true or false")
444
+ }
445
+ const { ask, branch } = setAskResolvedRouted(store, key, ref, body.resolved, author)
446
+ json(res, 200, { ask, branch })
447
+ })
448
+ .catch((error: Error) => json(res, 400, { error: error.message }))
449
+ return
450
+ }
451
+ if (askMatch[2] && req.method === "DELETE") {
452
+ const { branch } = deleteAskRouted(store, key, decodeURIComponent(askMatch[2]))
453
+ json(res, 200, { ok: true, branch })
454
+ return
455
+ }
456
+ json(res, 404, { error: "not found" })
457
+ return
458
+ }
459
+
249
460
  const taskMatch = /^\/api\/tasks\/([^/]+)(\/comments(?:\/([^/]+))?)?$/.exec(path)
250
461
  if (taskMatch) {
251
- const number = store.parseId(decodeURIComponent(taskMatch[1]))
462
+ const key = store.parseId(decodeURIComponent(taskMatch[1]))
252
463
  if (taskMatch[3] && req.method === "DELETE") {
253
464
  const commentId = decodeURIComponent(taskMatch[3])
254
- const existing = store.comments(number).find((c) => c.id === commentId)
465
+ const existing = store.comments(key).find((c) => c.id === commentId)
255
466
  if (!existing) {
256
467
  json(res, 404, { error: `no such comment: ${commentId}` })
257
468
  return
@@ -262,7 +473,7 @@ export function createTaskServer(serveRoot: string): Server {
262
473
  json(res, 403, { error: "only your own comments can be deleted" })
263
474
  return
264
475
  }
265
- store.deleteComment(number, commentId)
476
+ store.deleteComment(key, commentId)
266
477
  json(res, 200, { ok: true })
267
478
  return
268
479
  }
@@ -273,7 +484,7 @@ export function createTaskServer(serveRoot: string): Server {
273
484
  throw new Error("body is required")
274
485
  }
275
486
  // `body.author` is ignored on purpose — unauthenticated noise.
276
- const comment = store.addComment(number, body.body.trim(), author)
487
+ const comment = store.addComment(key, body.body.trim(), author)
277
488
  json(res, 201, { comment })
278
489
  })
279
490
  .catch((error: Error) => json(res, 400, { error: error.message }))
@@ -281,25 +492,25 @@ export function createTaskServer(serveRoot: string): Server {
281
492
  }
282
493
  if (!taskMatch[2]) {
283
494
  if (req.method === "GET") {
284
- const task = store.get(number)
495
+ const task = store.get(key)
285
496
  if (!task) {
286
- json(res, 404, { error: `no such task: ${store.displayId(number)}` })
497
+ json(res, 404, { error: `no such task: ${store.displayId(key)}` })
287
498
  return
288
499
  }
289
- json(res, 200, { task, comments: store.comments(number) })
500
+ json(res, 200, { task, comments: store.comments(key) })
290
501
  return
291
502
  }
292
503
  if (req.method === "PATCH") {
293
504
  void readBody(req)
294
505
  .then((body) => {
295
- const task = store.update(number, parsePatch(body))
506
+ const task = store.update(key, parsePatch(body))
296
507
  json(res, 200, { task })
297
508
  })
298
509
  .catch((error: Error) => json(res, 400, { error: error.message }))
299
510
  return
300
511
  }
301
512
  if (req.method === "DELETE") {
302
- store.delete(number)
513
+ store.delete(key)
303
514
  json(res, 200, { ok: true })
304
515
  return
305
516
  }