@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
@@ -0,0 +1,401 @@
1
+ /**
2
+ * Ask I/O against claim branches (TAS-38, second round): a claimed ticket's
3
+ * truth lives on its claim branch, so ask *writes* on a claimed ticket go to
4
+ * the branch too — resolve, reopen, add, delete — as real commits, made and
5
+ * pushed without ever touching the checkout. The owner clicks a checkbox on
6
+ * main's board; the commit lands on `claude/task/tas-40`; the worker's next
7
+ * tick pulls it and sees the answer in the one place it was ever going to
8
+ * look. No parallel copies, no merge collision, no relay through comments.
9
+ *
10
+ * The mechanics are plain git plumbing: build the new tree in a temporary
11
+ * index (`read-tree` → `update-index` → `write-tree`), commit it with the
12
+ * branch tip as parent, and push fast-forward. A concurrent worker push just
13
+ * rejects ours; we refetch and reapply once against the new tip — the
14
+ * mutation is expressed as a function of the file's current bytes, so a
15
+ * retry can notice the work is already done and quietly stand down.
16
+ *
17
+ * Reads (the inbox's view of a claimed ticket) live here too, so reading and
18
+ * writing can never disagree about where an ask file is.
19
+ */
20
+
21
+ import { rmSync } from "node:fs"
22
+ import { tmpdir } from "node:os"
23
+ import { join, relative, sep } from "node:path"
24
+ import { claimBranch, claimNamespace } from "./claim.ts"
25
+ import { gitOut, runGit } from "./git.ts"
26
+ import { commentStem, parseAskLenient, serializeAsk, type AskDoc } from "./ticket-doc.ts"
27
+ import { ASKS_DIR, TICKETS_DIR } from "./file-store.ts"
28
+ import type { Store } from "./store.ts"
29
+ import type { Ask, Task } from "./types.ts"
30
+
31
+ /**
32
+ * Soft git (from git.ts): null on any failure — callers decide whether that's
33
+ * an error. These run inside `task serve` request handlers, so a network call
34
+ * must never wait on a human: the shared module's no-prompt env turns a
35
+ * would-be credential prompt into a fast failure, and its timeout bounds a
36
+ * wedged remote. The 30s default is kept even for the fetches and pushes
37
+ * here — a serve request that can't finish in that is better off failing.
38
+ */
39
+ const git = gitOut
40
+
41
+ /** Like `git`, but with extra env — the temp-index dance needs GIT_INDEX_FILE. */
42
+ function gitEnv(cwd: string, env: Record<string, string>, ...args: string[]): string | null {
43
+ const result = runGit(cwd, args, { env })
44
+ return result.status === 0 ? result.stdout : null
45
+ }
46
+
47
+ /**
48
+ * Every claim branch on origin right now, with its tip sha — one bounded
49
+ * `ls-remote`, the single unavoidable network call behind live claim status.
50
+ * Null means "origin can't be asked" (offline, no remote, auth would have
51
+ * prompted), which every caller treats as "act on file truth". This is the
52
+ * read-path twin of claim.ts's `remoteClaims`, deliberately separate: reads
53
+ * must never prompt for credentials — this runs inside `task serve` request
54
+ * handlers — while `task claim` itself may legitimately ask a human to log in.
55
+ */
56
+ export function remoteClaimTips(store: Store): Map<string, string> | null {
57
+ let namespace: string
58
+ try {
59
+ namespace = claimNamespace(store.config)
60
+ } catch {
61
+ return null
62
+ }
63
+ const out = git(store.root, "ls-remote", "--heads", "origin", `${namespace}*`)
64
+ if (out === null) return null
65
+ const tips = new Map<string, string>()
66
+ for (const line of out.split("\n")) {
67
+ const [sha, ref] = line.split("\t")
68
+ if (ref?.startsWith("refs/heads/")) tips.set(ref.slice("refs/heads/".length), sha)
69
+ }
70
+ return tips
71
+ }
72
+
73
+ /**
74
+ * Make the local remote-tracking claim refs agree with the tips `ls-remote`
75
+ * just reported — fetching *only the branches that moved* (the tip sha says
76
+ * whether anything changed, so an unchanged claim costs no fetch at all) and
77
+ * deleting local refs for claims origin no longer has, so a released claim
78
+ * can't linger as a ghost. This is what keeps the inbox cheap under the UI's
79
+ * refetch-on-every-change behavior: steady state is one ls-remote and zero
80
+ * fetches.
81
+ */
82
+ export function syncClaimRefs(store: Store, namespace: string, tips: Map<string, string>): void {
83
+ const stale = [...tips].filter(
84
+ ([branch, sha]) =>
85
+ git(store.root, "rev-parse", "--verify", "--quiet", `refs/remotes/origin/${branch}`) !== sha,
86
+ )
87
+ if (stale.length > 0) {
88
+ git(
89
+ store.root,
90
+ "fetch",
91
+ "--quiet",
92
+ "origin",
93
+ ...stale.map(([branch]) => `+refs/heads/${branch}:refs/remotes/origin/${branch}`),
94
+ )
95
+ }
96
+ const local = git(store.root, "for-each-ref", "--format=%(refname:short)", `refs/remotes/origin/${namespace}*`)
97
+ for (const short of local?.split("\n") ?? []) {
98
+ const branch = short.replace(/^origin\//, "")
99
+ if (branch && !tips.has(branch)) {
100
+ git(store.root, "update-ref", "-d", `refs/remotes/origin/${branch}`)
101
+ }
102
+ }
103
+ }
104
+
105
+ /** Force-refresh one claim branch's local ref — the write path's retry needs the moved tip. */
106
+ function fetchClaimBranch(store: Store, branch: string): void {
107
+ git(store.root, "fetch", "--quiet", "origin", `+refs/heads/${branch}:refs/remotes/origin/${branch}`)
108
+ }
109
+
110
+ /**
111
+ * The ticket's claim branch if one exists on origin right now, else null —
112
+ * null also for "origin unreachable". Syncs the local claim refs as a side
113
+ * effect when a claim is found, so subsequent branch reads see the tip the
114
+ * answer was based on.
115
+ */
116
+ export function claimFor(store: Store, key: string): string | null {
117
+ try {
118
+ const branch = claimBranch(store.config, key)
119
+ const tips = remoteClaimTips(store)
120
+ if (!tips?.has(branch)) return null
121
+ syncClaimRefs(store, claimNamespace(store.config), tips)
122
+ return branch
123
+ } catch {
124
+ return null
125
+ }
126
+ }
127
+
128
+ /** True when this checkout *is* the claim branch — then local writes are branch writes. */
129
+ export function onClaimBranch(store: Store, branch: string): boolean {
130
+ return git(store.root, "symbolic-ref", "--quiet", "--short", "HEAD") === branch
131
+ }
132
+
133
+ /** The ask directory's path relative to the git toplevel — git speaks toplevel paths. */
134
+ function askDirOnBranch(store: Store, key: string): string | null {
135
+ const toplevel = git(store.root, "rev-parse", "--show-toplevel")
136
+ if (!toplevel) return null
137
+ return [relative(toplevel, store.taskDir).split(sep).join("/"), TICKETS_DIR, key, ASKS_DIR]
138
+ .filter(Boolean)
139
+ .join("/")
140
+ }
141
+
142
+ /**
143
+ * The ask files of one ticket as its claim branch has them, leniently — the
144
+ * branch's bytes were written by another checkout and possibly an older CLI.
145
+ * Any git failure (ref gone between fetch and read, no asks dir) reads as
146
+ * "no asks on the branch", never an error.
147
+ */
148
+ export function branchAsks(store: Store, branch: string, key: string): Omit<Ask, "ordinal">[] {
149
+ const dir = askDirOnBranch(store, key)
150
+ if (!dir) return []
151
+ const listing = git(store.root, "ls-tree", "--name-only", `origin/${branch}:${dir}`)
152
+ if (!listing) return []
153
+ const asks: Omit<Ask, "ordinal">[] = []
154
+ for (const name of listing.split("\n")) {
155
+ if (!name.endsWith(".md") || name.startsWith(".")) continue
156
+ const text = git(store.root, "show", `origin/${branch}:${dir}/${name}`)
157
+ if (text === null) continue
158
+ const doc = parseAskLenient(text)
159
+ if (!doc) continue
160
+ asks.push({
161
+ id: name.slice(0, -3),
162
+ taskId: store.displayId(key),
163
+ text: doc.body,
164
+ author: doc.author,
165
+ createdAt: doc.createdAt,
166
+ resolvedAt: doc.resolvedAt,
167
+ resolvedBy: doc.resolvedBy,
168
+ })
169
+ }
170
+ return asks
171
+ }
172
+
173
+ /**
174
+ * Local and branch asks, unioned by id with the branch winning — the branch
175
+ * holds the claim, so its version of a shared ask is the fresher one — then
176
+ * re-ordinaled over the union. These are the ordinals the inbox prints and
177
+ * the ones a resolve against a claimed ticket accepts.
178
+ */
179
+ export function mergedAsks(store: Store, branch: string, task: Task): Ask[] {
180
+ const merged = new Map(task.asks.map((a) => [a.id, a as Omit<Ask, "ordinal">]))
181
+ for (const ask of branchAsks(store, branch, task.key)) merged.set(ask.id, ask)
182
+ return [...merged.values()]
183
+ .sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id))
184
+ .map((ask, index) => ({ ...ask, ordinal: index + 1 }))
185
+ }
186
+
187
+ /**
188
+ * Commit one file change onto the claim branch and push it, without touching
189
+ * the checkout. `mutate` maps the file's current bytes (null = absent) to its
190
+ * next bytes (null = delete) — expressed that way so the fast-forward-reject
191
+ * retry can recompute against the moved tip, and return `null → no change`
192
+ * when someone else already made the edit.
193
+ */
194
+ function writeOnClaimBranch(
195
+ store: Store,
196
+ branch: string,
197
+ path: string,
198
+ message: string,
199
+ mutate: (current: string | null) => string | null,
200
+ ): void {
201
+ const cwd = store.root
202
+ for (let attempt = 0; ; attempt++) {
203
+ const tip = git(cwd, "rev-parse", `refs/remotes/origin/${branch}`)
204
+ if (!tip) throw new Error(`can't read origin/${branch} — is origin reachable?`)
205
+
206
+ const current = git(cwd, "show", `${tip}:${path}`)
207
+ const next = mutate(current)
208
+ if (next === current || (next === null && current === null)) return // already there
209
+
210
+ const index = join(tmpdir(), `task-claim-index-${process.pid}-${Date.now()}`)
211
+ const env = { GIT_INDEX_FILE: index }
212
+ try {
213
+ if (gitEnv(cwd, env, "read-tree", tip) === null) {
214
+ throw new Error(`git read-tree failed on origin/${branch}`)
215
+ }
216
+ if (next === null) {
217
+ if (gitEnv(cwd, env, "update-index", "--force-remove", path) === null) {
218
+ throw new Error(`git update-index --force-remove failed for ${path}`)
219
+ }
220
+ } else {
221
+ const blob = runGit(cwd, ["hash-object", "-w", "--stdin"], { input: next })
222
+ const sha = blob.status === 0 ? blob.stdout : null
223
+ if (!sha) throw new Error("git hash-object failed")
224
+ if (
225
+ gitEnv(cwd, env, "update-index", "--add", "--cacheinfo", `100644,${sha},${path}`) === null
226
+ ) {
227
+ throw new Error(`git update-index failed for ${path}`)
228
+ }
229
+ }
230
+ const tree = gitEnv(cwd, env, "write-tree")
231
+ if (!tree) throw new Error("git write-tree failed")
232
+ const commit = git(cwd, "commit-tree", tree, "-p", tip, "-m", message)
233
+ if (!commit) {
234
+ throw new Error("git commit-tree failed — is git user.name/user.email configured?")
235
+ }
236
+ // The lease pins the remote ref to the tip this commit was built on:
237
+ // a moved branch rejects (retry below), and — the case a plain push
238
+ // gets dangerously wrong — a *deleted* branch rejects too, instead of
239
+ // being quietly re-created. A claim released mid-click must stay
240
+ // released; resurrecting the branch would re-lock the ticket.
241
+ if (
242
+ git(
243
+ cwd,
244
+ "push",
245
+ "--quiet",
246
+ "origin",
247
+ `${commit}:refs/heads/${branch}`,
248
+ `--force-with-lease=refs/heads/${branch}:${tip}`,
249
+ ) !== null
250
+ ) {
251
+ // Keep the local remote-tracking ref in step so an immediate re-read
252
+ // (the UI refetching the inbox) sees what was just pushed.
253
+ git(cwd, "update-ref", `refs/remotes/origin/${branch}`, commit)
254
+ return
255
+ }
256
+ } finally {
257
+ rmSync(index, { force: true })
258
+ }
259
+ // Push rejected — a worker moved the branch under us. Refetch and
260
+ // reapply once; a second rejection is contention worth surfacing.
261
+ if (attempt >= 1) {
262
+ throw new Error(
263
+ `origin/${branch} keeps moving — a worker is pushing; retry in a moment`,
264
+ )
265
+ }
266
+ fetchClaimBranch(store, branch)
267
+ }
268
+ }
269
+
270
+ // ── The routed operations ────────────────────────────────────────────────────
271
+ //
272
+ // One rule: writes about a claimed ticket's asks go where the ask's file is.
273
+ // Resolve/reopen/delete follow the file (branch copy wins when both have it);
274
+ // add goes to the branch, so the worker's done gate can see debt the owner
275
+ // filed mid-claim. Unclaimed tickets — and the claim branch's own checkout —
276
+ // stay plain local writes, uncommitted like every other CLI edit.
277
+
278
+ /** How a routed write happened — `branch` set when it landed as a pushed commit. */
279
+ export interface RoutedAsk {
280
+ ask: Ask
281
+ branch: string | null
282
+ }
283
+
284
+ export function addAskRouted(store: Store, key: string, text: string, author = ""): RoutedAsk {
285
+ const branch = claimFor(store, key)
286
+ if (!branch || onClaimBranch(store, branch)) {
287
+ return { ask: store.addAsk(key, text, author), branch: null }
288
+ }
289
+ const dir = askDirOnBranch(store, key)
290
+ if (!dir) return { ask: store.addAsk(key, text, author), branch: null }
291
+ const createdAt = new Date().toISOString()
292
+ const existing = new Set(branchAsks(store, branch, key).map((a) => a.id))
293
+ const stem = commentStem(createdAt, author)
294
+ let id = stem
295
+ for (let n = 2; existing.has(id); n++) id = `${stem}-${n}`
296
+ const doc: AskDoc = { author, createdAt, resolvedAt: null, resolvedBy: null, body: text }
297
+ writeOnClaimBranch(
298
+ store,
299
+ branch,
300
+ `${dir}/${id}.md`,
301
+ `chore(board): ask on ${store.displayId(key)}`,
302
+ // Absent → create; if a racing write took the name, keep theirs.
303
+ (current) => current ?? serializeAsk(doc),
304
+ )
305
+ const ask = mergedAsks(store, branch, store.get(key)!).find((a) => a.id === id)
306
+ if (!ask) throw new Error(`ask ${id} didn't land on ${branch} — retry`)
307
+ return { ask, branch }
308
+ }
309
+
310
+ export function setAskResolvedRouted(
311
+ store: Store,
312
+ key: string,
313
+ ref: string,
314
+ resolved: boolean,
315
+ resolvedBy = "",
316
+ ): RoutedAsk {
317
+ const branch = claimFor(store, key)
318
+ if (!branch || onClaimBranch(store, branch)) {
319
+ return {
320
+ ask: resolved ? store.resolveAsk(key, ref, resolvedBy) : store.reopenAsk(key, ref),
321
+ branch: null,
322
+ }
323
+ }
324
+ const task = store.get(key)
325
+ if (!task) throw new Error(`no such task: ${store.displayId(key)}`)
326
+ const union = mergedAsks(store, branch, task)
327
+ const target = findInUnion(store, key, union, ref)
328
+ const onBranch = branchAsks(store, branch, key).some((a) => a.id === target.id)
329
+ if (!onBranch) {
330
+ return {
331
+ ask: resolved
332
+ ? store.resolveAsk(key, target.id, resolvedBy)
333
+ : store.reopenAsk(key, target.id),
334
+ branch: null,
335
+ }
336
+ }
337
+ const dir = askDirOnBranch(store, key)!
338
+ const resolvedAt = new Date().toISOString()
339
+ writeOnClaimBranch(
340
+ store,
341
+ branch,
342
+ `${dir}/${target.id}.md`,
343
+ `chore(board): ${resolved ? "resolve" : "reopen"} ask [${target.ordinal}] on ${store.displayId(key)}`,
344
+ (current) => {
345
+ if (current === null) return null // deleted under us — nothing to write
346
+ const doc = parseAskLenient(current)
347
+ if (!doc) return current
348
+ if (resolved && doc.resolvedAt) return current // already resolved — stand down
349
+ if (!resolved && !doc.resolvedAt) return current
350
+ return serializeAsk({
351
+ ...doc,
352
+ resolvedAt: resolved ? resolvedAt : null,
353
+ resolvedBy: resolved ? resolvedBy : null,
354
+ })
355
+ },
356
+ )
357
+ const ask = mergedAsks(store, branch, task).find((a) => a.id === target.id)
358
+ return { ask: ask ?? { ...target, resolvedAt: resolved ? resolvedAt : null, resolvedBy: resolved ? resolvedBy : null }, branch }
359
+ }
360
+
361
+ export function deleteAskRouted(store: Store, key: string, ref: string): RoutedAsk {
362
+ const branch = claimFor(store, key)
363
+ if (!branch || onClaimBranch(store, branch)) {
364
+ return { ask: store.deleteAsk(key, ref), branch: null }
365
+ }
366
+ const task = store.get(key)
367
+ if (!task) throw new Error(`no such task: ${store.displayId(key)}`)
368
+ const union = mergedAsks(store, branch, task)
369
+ const target = findInUnion(store, key, union, ref)
370
+ const onBranch = branchAsks(store, branch, key).some((a) => a.id === target.id)
371
+ if (!onBranch) return { ask: store.deleteAsk(key, target.id), branch: null }
372
+ const dir = askDirOnBranch(store, key)!
373
+ writeOnClaimBranch(
374
+ store,
375
+ branch,
376
+ `${dir}/${target.id}.md`,
377
+ `chore(board): delete ask [${target.ordinal}] on ${store.displayId(key)}`,
378
+ () => null,
379
+ )
380
+ // The local copy of a both-sides ask goes too, or the union resurrects it.
381
+ if (task.asks.some((a) => a.id === target.id)) store.deleteAsk(key, target.id)
382
+ return { ask: target, branch }
383
+ }
384
+
385
+ /** Ordinal-or-id against the union — the numbering the inbox shows for claimed tickets. */
386
+ function findInUnion(store: Store, key: string, union: Ask[], ref: string): Ask {
387
+ const trimmed = ref.trim()
388
+ const byOrdinal = /^\d+$/.test(trimmed)
389
+ ? union.find((a) => a.ordinal === Number(trimmed))
390
+ : undefined
391
+ const ask = byOrdinal ?? union.find((a) => a.id === trimmed)
392
+ if (!ask) {
393
+ const id = store.displayId(key)
394
+ throw new Error(
395
+ union.length === 0
396
+ ? `${id} has no asks`
397
+ : `no such ask on ${id}: ${ref} — ordinals run 1-${union.length} (\`task inbox\` lists them for claimed tickets)`,
398
+ )
399
+ }
400
+ return ask
401
+ }