@nickmeriano/task 0.8.0 → 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 (114) hide show
  1. package/README.md +65 -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 +21 -22
  17. package/dist/claim.d.ts.map +1 -1
  18. package/dist/claim.js +58 -83
  19. package/dist/claim.js.map +1 -1
  20. package/dist/claim.test.d.ts +2 -2
  21. package/dist/claim.test.js +111 -94
  22. package/dist/claim.test.js.map +1 -1
  23. package/dist/cli.js +241 -64
  24. package/dist/cli.js.map +1 -1
  25. package/dist/file-store.d.ts +70 -24
  26. package/dist/file-store.d.ts.map +1 -1
  27. package/dist/file-store.js +319 -141
  28. package/dist/file-store.js.map +1 -1
  29. package/dist/git-serve.d.ts +183 -0
  30. package/dist/git-serve.d.ts.map +1 -0
  31. package/dist/git-serve.js +503 -0
  32. package/dist/git-serve.js.map +1 -0
  33. package/dist/git-serve.test.d.ts +16 -0
  34. package/dist/git-serve.test.d.ts.map +1 -0
  35. package/dist/git-serve.test.js +183 -0
  36. package/dist/git-serve.test.js.map +1 -0
  37. package/dist/git.d.ts +65 -0
  38. package/dist/git.d.ts.map +1 -0
  39. package/dist/git.js +114 -0
  40. package/dist/git.js.map +1 -0
  41. package/dist/id.d.ts +39 -0
  42. package/dist/id.d.ts.map +1 -0
  43. package/dist/id.js +67 -0
  44. package/dist/id.js.map +1 -0
  45. package/dist/inbox.d.ts +41 -0
  46. package/dist/inbox.d.ts.map +1 -0
  47. package/dist/inbox.js +56 -0
  48. package/dist/inbox.js.map +1 -0
  49. package/dist/overview.d.ts +9 -0
  50. package/dist/overview.d.ts.map +1 -1
  51. package/dist/overview.js +19 -11
  52. package/dist/overview.js.map +1 -1
  53. package/dist/overview.test.js +6 -5
  54. package/dist/overview.test.js.map +1 -1
  55. package/dist/promote.test.d.ts +3 -2
  56. package/dist/promote.test.d.ts.map +1 -1
  57. package/dist/promote.test.js +29 -31
  58. package/dist/promote.test.js.map +1 -1
  59. package/dist/publish.d.ts +2 -17
  60. package/dist/publish.d.ts.map +1 -1
  61. package/dist/publish.js +4 -49
  62. package/dist/publish.js.map +1 -1
  63. package/dist/search.d.ts +3 -1
  64. package/dist/search.d.ts.map +1 -1
  65. package/dist/search.js +15 -2
  66. package/dist/search.js.map +1 -1
  67. package/dist/search.test.js +9 -9
  68. package/dist/search.test.js.map +1 -1
  69. package/dist/server.d.ts.map +1 -1
  70. package/dist/server.js +218 -21
  71. package/dist/server.js.map +1 -1
  72. package/dist/store.d.ts +28 -14
  73. package/dist/store.d.ts.map +1 -1
  74. package/dist/store.js.map +1 -1
  75. package/dist/store.test.js +88 -75
  76. package/dist/store.test.js.map +1 -1
  77. package/dist/ticket-doc.d.ts +26 -10
  78. package/dist/ticket-doc.d.ts.map +1 -1
  79. package/dist/ticket-doc.js +71 -27
  80. package/dist/ticket-doc.js.map +1 -1
  81. package/dist/types.d.ts +51 -17
  82. package/dist/types.d.ts.map +1 -1
  83. package/dist/types.js.map +1 -1
  84. package/package.json +1 -1
  85. package/skill/SKILL.md +70 -23
  86. package/src/asks.test.ts +355 -0
  87. package/src/check.test.ts +129 -72
  88. package/src/check.ts +101 -58
  89. package/src/claim-io.ts +401 -0
  90. package/src/claim.test.ts +141 -99
  91. package/src/claim.ts +69 -96
  92. package/src/cli.ts +251 -63
  93. package/src/file-store.ts +357 -145
  94. package/src/git-serve.test.ts +240 -0
  95. package/src/git-serve.ts +595 -0
  96. package/src/git.ts +141 -0
  97. package/src/id.ts +68 -0
  98. package/src/inbox.ts +77 -0
  99. package/src/overview.test.ts +6 -5
  100. package/src/overview.ts +25 -10
  101. package/src/promote.test.ts +40 -28
  102. package/src/publish.ts +6 -53
  103. package/src/search.test.ts +11 -11
  104. package/src/search.ts +17 -3
  105. package/src/server.ts +221 -19
  106. package/src/store.test.ts +95 -77
  107. package/src/store.ts +28 -13
  108. package/src/ticket-doc.ts +93 -30
  109. package/src/types.ts +52 -17
  110. package/ui/dist/assets/index-BjsorZOU.js +229 -0
  111. package/ui/dist/assets/index-CoKCUYic.css +1 -0
  112. package/ui/dist/index.html +2 -2
  113. package/ui/dist/assets/index-BJmOsOdR.js +0 -229
  114. package/ui/dist/assets/index-BoqQlqSU.css +0 -1
@@ -0,0 +1,355 @@
1
+ /**
2
+ * Asks (TAS-38): the checklist a person owes a ticket, and everything derived
3
+ * from it.
4
+ *
5
+ * 1. An ask is one file under tickets/<key>/asks/, comment-shaped; `needsHuman`
6
+ * is derived from open asks, never stored.
7
+ * 2. Done is gated: open asks refuse the move, resolving clears it, canceled
8
+ * voids without ceremony. A finished ticket can't start owing again —
9
+ * addAsk/reopen are refused on done/canceled.
10
+ * 3. Ordinals are creation-order positions — stable across resolution, the
11
+ * handle `task resolve <id> <n>` takes.
12
+ * 4. `task inbox` answers "what do I owe NOW": open asks on effectively
13
+ * in-progress tickets — file status, or a live claim branch, whose ask
14
+ * files are read from the branch itself.
15
+ */
16
+
17
+ import assert from "node:assert/strict"
18
+ import { test } from "node:test"
19
+ import { execFile, execFileSync } from "node:child_process"
20
+ import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync } from "node:fs"
21
+ import { tmpdir } from "node:os"
22
+ import { join } from "node:path"
23
+ import { fileURLToPath } from "node:url"
24
+ import { initProject } from "./file-store.ts"
25
+ import type { Ask, Task } from "./types.ts"
26
+ import type { InboxEntry } from "./inbox.ts"
27
+
28
+ const CLI = fileURLToPath(new URL("./cli.ts", import.meta.url))
29
+
30
+ function tempDir(): string {
31
+ const dir = mkdtempSync(join(tmpdir(), "task-asks-test-"))
32
+ process.on("exit", () => rmSync(dir, { recursive: true, force: true }))
33
+ return dir
34
+ }
35
+
36
+ interface CliResult {
37
+ code: number
38
+ stdout: string
39
+ stderr: string
40
+ }
41
+
42
+ function cli(cwd: string, ...args: string[]): Promise<CliResult> {
43
+ return new Promise((resolve) => {
44
+ execFile(
45
+ process.execPath,
46
+ ["--experimental-strip-types", CLI, ...args],
47
+ { cwd },
48
+ (error, stdout, stderr) => {
49
+ resolve({ code: error ? ((error as { code?: number }).code ?? 1) : 0, stdout, stderr })
50
+ },
51
+ )
52
+ })
53
+ }
54
+
55
+ // ── The store-level contract ─────────────────────────────────────────────────
56
+
57
+ test("an ask is a file; needsHuman derives from it and resolution round-trips", () => {
58
+ const dir = tempDir()
59
+ const store = initProject(dir, { name: "ask board", prefix: "ASK" })
60
+ const task = store.create({ title: "Wire the webhook", status: "in_progress" })
61
+ assert.equal(store.get(task.key)!.needsHuman, false)
62
+
63
+ const ask = store.addAsk(task.key, "Mint the API token and add it to secrets", "claude")
64
+ assert.equal(ask.ordinal, 1)
65
+ assert.equal(ask.resolvedAt, null)
66
+
67
+ // One file, comment-shaped, under asks/ — mergeable by construction.
68
+ const dirPath = join(dir, ".task", "tickets", task.key, "asks")
69
+ const files = readdirSync(dirPath)
70
+ assert.equal(files.length, 1)
71
+ const text = readFileSync(join(dirPath, files[0]), "utf8")
72
+ assert.match(text, /^---\nauthor: "claude"\ncreated: /)
73
+ assert.match(text, /Mint the API token/)
74
+
75
+ const withAsk = store.get(task.key)!
76
+ assert.equal(withAsk.needsHuman, true)
77
+ assert.equal(withAsk.asks.length, 1)
78
+
79
+ const resolved = store.resolveAsk(task.key, "1", "nick")
80
+ assert.equal(resolved.resolvedBy, "nick")
81
+ assert.notEqual(resolved.resolvedAt, null)
82
+ assert.equal(store.get(task.key)!.needsHuman, false)
83
+ assert.match(readFileSync(join(dirPath, files[0]), "utf8"), /resolved_by: "nick"/)
84
+
85
+ const reopened = store.reopenAsk(task.key, resolved.id)
86
+ assert.equal(reopened.resolvedAt, null)
87
+ assert.equal(store.get(task.key)!.needsHuman, true)
88
+ store.close()
89
+ })
90
+
91
+ test("ordinals are creation order and stay put as asks resolve", () => {
92
+ const dir = tempDir()
93
+ const store = initProject(dir, { name: "ask board", prefix: "ASK" })
94
+ const task = store.create({ title: "Ship it", status: "in_progress" })
95
+ store.addAsk(task.key, "First owed thing", "claude")
96
+ store.addAsk(task.key, "Second owed thing", "claude")
97
+ store.resolveAsk(task.key, "1", "nick")
98
+
99
+ const asks = store.asks(task.key)
100
+ assert.deepEqual(asks.map((a) => a.ordinal), [1, 2])
101
+ assert.equal(asks[0].resolvedAt !== null, true)
102
+ assert.equal(asks[1].text, "Second owed thing")
103
+
104
+ // The needs-human filter matches the derived flag.
105
+ assert.deepEqual(store.list({ needsHuman: true }).map((t) => t.key), [task.key])
106
+ store.close()
107
+ })
108
+
109
+ test("done is gated on zero open asks; canceled voids them", () => {
110
+ const dir = tempDir()
111
+ const store = initProject(dir, { name: "ask board", prefix: "ASK" })
112
+ const task = store.create({ title: "Gated", status: "in_progress" })
113
+ store.addAsk(task.key, "Approve the cutover window", "claude")
114
+
115
+ assert.throws(
116
+ () => store.update(task.key, { status: "done" }),
117
+ /1 open ask.*done means nobody owes anything/s,
118
+ )
119
+
120
+ // Canceling is allowed with the asks still open — a dead ticket's asks die
121
+ // with it — and once canceled, no new debt can accrue.
122
+ store.update(task.key, { status: "canceled" })
123
+ assert.throws(() => store.addAsk(task.key, "Too late", "claude"), /is canceled/)
124
+ store.update(task.key, { status: "in_progress" })
125
+
126
+ store.resolveAsk(task.key, "1", "nick")
127
+ const done = store.update(task.key, { status: "done" })
128
+ assert.equal(done.status, "done")
129
+
130
+ // A finished ticket can't quietly start owing things again.
131
+ assert.throws(() => store.addAsk(task.key, "One more thing", "claude"), /is done/)
132
+ assert.throws(() => store.reopenAsk(task.key, "1"), /reopening an ask/)
133
+ store.close()
134
+ })
135
+
136
+ // ── The CLI surface ──────────────────────────────────────────────────────────
137
+
138
+ test("task ask / resolve / show: the full loop, exit codes and ordinals", async () => {
139
+ const dir = tempDir()
140
+ initProject(dir, { name: "cli board", prefix: "CLI" }).close()
141
+ const created = await cli(dir, "add", "Needs a person", "--status", "in_progress", "--json")
142
+ const id = (JSON.parse(created.stdout) as { task: Task }).task.id
143
+
144
+ const asked = await cli(dir, "ask", id, "Run setup.sh once the token exists", "--author", "claude")
145
+ assert.equal(asked.code, 0, asked.stderr)
146
+ assert.ok(asked.stdout.includes(`asked on ${id} [1]`), asked.stdout)
147
+
148
+ const shown = await cli(dir, "show", id, "--json")
149
+ const { task } = JSON.parse(shown.stdout) as { task: Task }
150
+ assert.equal(task.needsHuman, true)
151
+ assert.equal(task.asks[0].author, "claude")
152
+
153
+ // Done refuses while the ask is open — the gate speaks CLI too.
154
+ const blocked = await cli(dir, "move", id, "done")
155
+ assert.equal(blocked.code, 1)
156
+ assert.match(blocked.stderr, /open ask/)
157
+
158
+ const resolved = await cli(dir, "resolve", id, "1", "--comment", "done, it's in 1Password", "--author", "nick")
159
+ assert.equal(resolved.code, 0, resolved.stderr)
160
+ assert.ok(resolved.stdout.includes(`resolved ${id} [1]`), resolved.stdout)
161
+ assert.match(resolved.stdout, /last open ask/)
162
+
163
+ // The note landed as an attributed comment naming the ask.
164
+ const detail = await cli(dir, "show", id, "--json")
165
+ const { comments } = JSON.parse(detail.stdout) as { comments: { author: string; body: string }[] }
166
+ assert.equal(comments.length, 1)
167
+ assert.equal(comments[0].author, "nick")
168
+ assert.match(comments[0].body, /Resolved ask \[1\].*1Password/)
169
+
170
+ assert.equal((await cli(dir, "move", id, "done")).code, 0)
171
+ assert.equal((await cli(dir, "resolve", id, "9")).code, 1)
172
+ })
173
+
174
+ test("task add --ask files asks at creation, commas and all", async () => {
175
+ const dir = tempDir()
176
+ initProject(dir, { name: "cli board", prefix: "CLI" }).close()
177
+ const added = await cli(
178
+ dir, "add", "Born owing", "--status", "todo",
179
+ "--ask", "Approve the plan, then the budget",
180
+ "--ask", "Add STRIPE_SECRET_KEY to the worker",
181
+ "--json",
182
+ )
183
+ assert.equal(added.code, 0, added.stderr)
184
+ const { task } = JSON.parse(added.stdout) as { task: Task }
185
+ // One ask per --ask, verbatim — a comma never splits an ask in two.
186
+ assert.deepEqual(
187
+ task.asks.map((a: Ask) => a.text),
188
+ ["Approve the plan, then the budget", "Add STRIPE_SECRET_KEY to the worker"],
189
+ )
190
+ assert.equal(task.needsHuman, true)
191
+ })
192
+
193
+ test("inbox is in-progress only: todo asks wait for their claim", async () => {
194
+ const dir = tempDir()
195
+ const store = initProject(dir, { name: "inbox board", prefix: "INB" })
196
+ const active = store.create({ title: "Being worked", status: "in_progress" })
197
+ const queued = store.create({ title: "Waiting its turn", status: "todo" })
198
+ store.addAsk(active.key, "Upgrade the org to Pro", "claude")
199
+ store.addAsk(queued.key, "Decide pricing", "claude")
200
+ store.close()
201
+
202
+ // Not a git repo — the resolver degrades silently to file truth.
203
+ const result = await cli(dir, "inbox", "--json")
204
+ assert.equal(result.code, 0, result.stderr)
205
+ const { inbox } = JSON.parse(result.stdout) as { inbox: InboxEntry[] }
206
+ assert.deepEqual(inbox.map((e) => e.task.key), [active.key])
207
+ assert.equal(inbox[0].claim, null)
208
+ assert.equal(inbox[0].asks[0].text, "Upgrade the org to Pro")
209
+ })
210
+
211
+ // ── The claim-branch read (TAS-26's core) ────────────────────────────────────
212
+
213
+ function sh(cwd: string, ...args: string[]): string {
214
+ return execFileSync(args[0], args.slice(1), { cwd, encoding: "utf8" }).trim()
215
+ }
216
+
217
+ test("a claimed ticket's asks reach the inbox from its branch", async () => {
218
+ const home = tempDir()
219
+ const bare = join(home, "origin.git")
220
+ sh(home, "git", "init", "--quiet", "--bare", "-b", "main", bare)
221
+
222
+ const seed = join(home, "seed")
223
+ sh(home, "git", "clone", "--quiet", bare, seed)
224
+ sh(seed, "git", "config", "user.name", "Test")
225
+ sh(seed, "git", "config", "user.email", "test@example.com")
226
+ const store = initProject(seed, { name: "claim inbox", prefix: "CIN" })
227
+ const key = store.create({ title: "Agent work with a human side", status: "todo" }).key
228
+ const branch = `task/claim/cin-${key}`
229
+ store.close()
230
+ sh(seed, "git", "add", "-A")
231
+ sh(seed, "git", "commit", "--quiet", "-m", "board")
232
+ sh(seed, "git", "push", "--quiet", "-u", "origin", "main")
233
+
234
+ // Worker: claim (flips the ticket on the branch), file an ask mid-run, push.
235
+ const worker = join(home, "worker")
236
+ sh(home, "git", "clone", "--quiet", bare, worker)
237
+ sh(worker, "git", "config", "user.name", "Worker")
238
+ sh(worker, "git", "config", "user.email", "worker@example.com")
239
+ assert.equal((await cli(worker, "claim", key)).code, 0)
240
+ assert.equal((await cli(worker, "ask", `CIN-${key}`, "Create the OAuth app in the dashboard", "--author", "claude")).code, 0)
241
+ sh(worker, "git", "add", "-A")
242
+ sh(worker, "git", "commit", "--quiet", "-m", "ask")
243
+ sh(worker, "git", "push", "--quiet", "origin", branch)
244
+
245
+ // Owner, on a fresh clone of main, where the ticket still reads `todo` and
246
+ // has no ask files at all: the inbox says what the claim branch knows.
247
+ const owner = join(home, "owner")
248
+ sh(home, "git", "clone", "--quiet", bare, owner)
249
+ sh(owner, "git", "config", "user.name", "Nick")
250
+ sh(owner, "git", "config", "user.email", "nick@example.com")
251
+ assert.equal(existsSync(join(owner, ".task", "tickets", key, "asks")), false)
252
+ const result = await cli(owner, "inbox", "--json")
253
+ assert.equal(result.code, 0, result.stderr)
254
+ const { inbox } = JSON.parse(result.stdout) as { inbox: InboxEntry[] }
255
+ assert.equal(inbox.length, 1)
256
+ assert.equal(inbox[0].task.status, "todo") // file truth, kept honest
257
+ assert.equal(inbox[0].claim?.branch, branch)
258
+ assert.deepEqual(inbox[0].asks.map((a) => a.text), ["Create the OAuth app in the dashboard"])
259
+
260
+ // The claim owns the ticket's asks, so the owner's writes land on the
261
+ // branch as pushed commits — the checkout never switches, the working
262
+ // tree never grows an ask file.
263
+ const resolved = await cli(owner, "resolve", `CIN-${key}`, "1", "--author", "nick")
264
+ assert.equal(resolved.code, 0, resolved.stderr)
265
+ assert.ok(resolved.stdout.includes(`committed and pushed to ${branch}`), resolved.stdout)
266
+ assert.equal(existsSync(join(owner, ".task", "tickets", key, "asks")), false)
267
+ assert.equal(sh(owner, "git", "rev-parse", "--abbrev-ref", "HEAD"), "main")
268
+
269
+ // A third clone reads the resolution straight off origin's branch.
270
+ const verify = join(home, "verify")
271
+ sh(home, "git", "clone", "--quiet", bare, verify)
272
+ sh(verify, "git", "fetch", "--quiet", "origin", branch)
273
+ const askFile = sh(verify, "git", "ls-tree", "--name-only", `FETCH_HEAD:.task/tickets/${key}/asks`)
274
+ assert.match(
275
+ sh(verify, "git", "show", `FETCH_HEAD:.task/tickets/${key}/asks/${askFile}`),
276
+ /resolved_by: "nick"/,
277
+ )
278
+ const cleared = await cli(owner, "inbox", "--json")
279
+ assert.equal((JSON.parse(cleared.stdout) as { inbox: InboxEntry[] }).inbox.length, 0)
280
+
281
+ // Adding to a claimed ticket routes to the branch too — the worker's done
282
+ // gate has to see debt the owner files mid-claim.
283
+ const added = await cli(owner, "ask", `CIN-${key}`, "Also rotate the old key", "--author", "nick")
284
+ assert.equal(added.code, 0, added.stderr)
285
+ assert.ok(added.stdout.includes(`committed and pushed to ${branch}`), added.stdout)
286
+ assert.equal(existsSync(join(owner, ".task", "tickets", key, "asks")), false)
287
+ sh(verify, "git", "fetch", "--quiet", "origin", branch)
288
+ assert.equal(
289
+ sh(verify, "git", "ls-tree", "--name-only", `FETCH_HEAD:.task/tickets/${key}/asks`).split("\n").length,
290
+ 2,
291
+ )
292
+ const again = await cli(owner, "inbox", "--json")
293
+ const entries = (JSON.parse(again.stdout) as { inbox: InboxEntry[] }).inbox
294
+ assert.deepEqual(entries[0].asks.map((a) => a.text), ["Also rotate the old key"])
295
+
296
+ // And the worker's next tick simply pulls its branch and has everything.
297
+ sh(worker, "git", "pull", "--quiet", "origin", branch)
298
+ const workerView = await cli(worker, "show", `CIN-${key}`, "--json")
299
+ const workerAsks = (JSON.parse(workerView.stdout) as { task: { asks: { text: string; resolvedBy: string | null }[] } }).task.asks
300
+ assert.deepEqual(
301
+ workerAsks.map((a) => [a.text, a.resolvedBy]),
302
+ [["Create the OAuth app in the dashboard", "nick"], ["Also rotate the old key", null]],
303
+ )
304
+ })
305
+
306
+ test("branch writes hold up in shallow clones and worktrees", async () => {
307
+ const home = tempDir()
308
+ const bare = join(home, "origin.git")
309
+ sh(home, "git", "init", "--quiet", "--bare", "-b", "main", bare)
310
+
311
+ const seed = join(home, "seed")
312
+ sh(home, "git", "clone", "--quiet", bare, seed)
313
+ sh(seed, "git", "config", "user.name", "Test")
314
+ sh(seed, "git", "config", "user.email", "test@example.com")
315
+ const store = initProject(seed, { name: "envs board", prefix: "ENV" })
316
+ const key = store.create({ title: "Worked while owed", status: "todo" }).key
317
+ const branch = `task/claim/env-${key}`
318
+ store.close()
319
+ sh(seed, "git", "add", "-A")
320
+ sh(seed, "git", "commit", "--quiet", "-m", "board")
321
+ sh(seed, "git", "push", "--quiet", "-u", "origin", "main")
322
+ assert.equal((await cli(seed, "claim", key)).code, 0)
323
+ assert.equal((await cli(seed, "ask", `ENV-${key}`, "Bless the rollout", "--author", "claude")).code, 0)
324
+ sh(seed, "git", "add", "-A")
325
+ sh(seed, "git", "commit", "--quiet", "-m", "ask")
326
+ sh(seed, "git", "push", "--quiet", "origin", branch)
327
+
328
+ // Shallow clone — what CI checkouts default to. The plumbing must build
329
+ // its commit from a fetched claim tip whose history it doesn't have.
330
+ const shallow = join(home, "shallow")
331
+ sh(home, "git", "clone", "--quiet", "--depth", "1", `file://${bare}`, shallow)
332
+ sh(shallow, "git", "config", "user.name", "Nick")
333
+ sh(shallow, "git", "config", "user.email", "nick@example.com")
334
+ const resolved = await cli(shallow, "resolve", `ENV-${key}`, "1", "--author", "nick")
335
+ assert.equal(resolved.code, 0, resolved.stderr)
336
+ assert.ok(resolved.stdout.includes(`committed and pushed to ${branch}`), resolved.stdout)
337
+
338
+ // Worktree — refs shared with the main checkout, index elsewhere.
339
+ const full = join(home, "full")
340
+ sh(home, "git", "clone", "--quiet", bare, full)
341
+ sh(full, "git", "config", "user.name", "Nick")
342
+ sh(full, "git", "config", "user.email", "nick@example.com")
343
+ const worktree = join(home, "wt")
344
+ sh(full, "git", "worktree", "add", "--quiet", "--detach", worktree)
345
+ const added = await cli(worktree, "ask", `ENV-${key}`, "Sign the SOC2 form", "--author", "nick")
346
+ assert.equal(added.code, 0, added.stderr)
347
+ assert.ok(added.stdout.includes(`committed and pushed to ${branch}`), added.stdout)
348
+
349
+ // Origin's branch carries both writes; the checkout that made them doesn't.
350
+ sh(full, "git", "fetch", "--quiet", "origin", branch)
351
+ const files = sh(full, "git", "ls-tree", "--name-only", `FETCH_HEAD:.task/tickets/${key}/asks`).split("\n")
352
+ assert.equal(files.length, 2)
353
+ assert.equal(existsSync(join(shallow, ".task", "tickets", key, "asks")), false)
354
+ assert.equal(existsSync(join(worktree, ".task", "tickets", key, "asks")), false)
355
+ })