@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/skill/SKILL.md CHANGED
@@ -34,21 +34,47 @@ for parsing.
34
34
  ## Commands
35
35
 
36
36
  ```bash
37
- task list [--status s1,s2] [--tag a,b] [--milestone m] [--needs-human] [--all] --json
38
- task show <id> --json # full task + comments
39
- task add "Title" [--description text] [--status s] [--tags a,b]
40
- [--milestone m] [--needs-human] --json
41
- task update <id> [--title t] [--description text] [--status s] [--tags a,b]
42
- [--milestone m] [--needs-human | --no-needs-human] --json
43
- task start <id> # in_progress
44
- task done <id> # done
45
- task move <id> <status> # any status change
37
+ task list [--status s1,s2] [--tag a,b] [--goal g] [--needs-human] [--all] --json
38
+ task show <id> --json # full task + comments + its goal's
39
+ # title and description, inlined
40
+ task search <query> [--archived] --json # match titles, descriptions, comments;
41
+ # --archived searches the archive too
42
+ task add "Title" [--description text | --description-file path|-] [--status s]
43
+ [--tags a,b] [--goal g] [--ask "text"]… --json
44
+ # long markdown descriptions: write a
45
+ # file (or pipe stdin with -) instead
46
+ # of fighting shell quoting — never
47
+ # hand-edit ticket files
48
+ task update <id> [--title t] [--description text | --description-file path|-]
49
+ [--status s] [--tags a,b] [--goal g] --json
50
+ task move <id> <status> # the one status API — every plain
51
+ # status change goes through it;
52
+ # done is refused while asks are open
53
+ task ask <id> "what a person owes" --author claude --json
54
+ # file an ask — see "Asks" below
55
+ task resolve <id> <n> [--comment "note"] [--reopen] --json
56
+ # check ask <n> off (show prints the
57
+ # ordinals); the note lands as a comment
58
+ task inbox [--board P] --json # every open ask on effectively
59
+ # in-progress tickets, repo-wide —
60
+ # the human's "what do I owe" queue
61
+ task promote <id> --json # backlog → todo — the human "build
62
+ # this" call; see "Promotion" below
46
63
  task link <id> --blocked-by <id> # mark a dependency (or --blocks)
47
64
  task unlink <id> --blocked-by <id> # remove one (or --blocks)
48
65
  task update <id> --pr <url> # attach a pull request (appends)
66
+ task goal add "Title" [--slug s] [--description text] --json
67
+ task goal list [--archived] --json # every goal + derived done/total counts
68
+ task goal show <slug> --json # the goal, its description, its tasks
69
+ task goal update <slug> [--title t] [--description text] --json
70
+ task goal archive <slug> # refused while it has open tasks
71
+ task goal delete <slug> # refused while any live task references it
49
72
  task claim <id> # claim a ticket before working it
50
- task claim --release <id> # abandon a claim cleanly
73
+ task claim --next --json # claim the top claimable ticket in one call
74
+ task claim --release <id> --comment "why" # abandon a claim, reason required
51
75
  task list --claimable --json # the claim queue, top entry = next up
76
+ task overview --json # one screen of board health
77
+ task instructions # print these conventions (this file)
52
78
  task comment <id> "text" --author claude
53
79
  task delete <id>
54
80
  task boards --json # every board in the repo, with prefixes
@@ -56,6 +82,8 @@ task archive <id> --json # done/canceled ticket -> .task/archive/
56
82
  task archive --all --json # archive everything done or canceled
57
83
  task unarchive <id> --json # put one back on the board
58
84
  task whoami # who comments are attributed to
85
+ task check [--fix] --json # lint the board files (dangling refs,
86
+ # bad files); --fix repairs mechanically
59
87
  ```
60
88
 
61
89
  `task publish` and `task unpublish` also exist — they give the board a URL so
@@ -63,12 +91,22 @@ the user can read it from a phone, and take it away again. Both open a browser
63
91
  and wait for a human, so **never run either on your own initiative**; suggest
64
92
  `task publish` if the user wants to see the board remotely.
65
93
 
66
- - `<id>` is `PREFIX-12` or just `12`. A bare number means the nearest board;
94
+ - `<id>` is `PREFIX-x7k4m`, a bare `x7k4m`, or any unique prefix of a key
95
+ (`task show x7` resolves git-style; an ambiguous prefix errors naming the
96
+ matches). Ids are random 5-character keys minted branch-safely at `task
97
+ add` — never sequential. A bare key means the nearest board;
67
98
  a prefixed id routes to whichever board in the repo owns that prefix, so
68
- `TAS-12` works from any directory. `task boards --json` lists them all.
99
+ `TAS-x7k4m` works from any directory. `task boards --json` lists them all.
69
100
  - Statuses: `backlog` `todo` `in_progress` `done` `canceled`.
70
101
  - `--tag a,b` matches a task carrying *either* tag, not both.
71
- - Clear a field by passing it empty: `--tags ""`, `--milestone ""`, `--prs ""`.
102
+ - Clear a field by passing it empty: `--tags ""`, `--goal ""`, `--prs ""`.
103
+ - Long markdown goes through **stdin**, not shell quoting and never a
104
+ hand-edited ticket file — `task update NIC-x7k4m --description-file - <<'EOF'
105
+ … EOF`. This path is byte-faithful: what you pipe is exactly what's stored
106
+ (the web UI's rich-text editor, by contrast, re-serializes markdown on
107
+ save). To append (e.g. a plan), read the current text from `--json` first
108
+ and pipe back original + addition — the flag replaces the whole
109
+ description.
72
110
  - `task list` hides done/canceled by default; `--all` shows everything, and
73
111
  `--archived` lists the archive instead of the board.
74
112
  - Archiving is history, not deletion: only done/canceled tickets qualify,
@@ -85,34 +123,87 @@ ordering ("this can't land until X does"), record it with `task link` instead
85
123
  of prose in a comment. Blocked tasks wear a red "Blocked" badge on the board
86
124
  until every blocker is done or canceled.
87
125
 
126
+ **Asks.** An ask is one thing a *person* owes a ticket — a credential only
127
+ they can mint, a decision that isn't yours, an action against production.
128
+ File it the moment you hit it: `task ask <id> "Mint the OAuth app and put the
129
+ client id in repo secrets" --author claude`. Asks are the primitive every
130
+ human requirement funnels through: the amber needs-human badge is derived
131
+ from them, `task inbox` aggregates the open ones, and `task move <id> done`
132
+ is **refused while any ask is open** — done means nobody owes anything.
133
+ They deliberately do *not* block claiming or your own work: implement
134
+ everything that doesn't depend on the ask and let the person's part run in
135
+ parallel. If the work truly can't *start* before the ask (a design decision
136
+ the whole ticket hangs on), that's not an ask — put the decision on its own
137
+ ticket and `task link <id> --blocked-by` it. An ask that outlives its ticket
138
+ is a new ticket, not a lingering ask. Humans resolve asks (`task resolve`),
139
+ usually from the board UI; resolve one yourself only when the person has
140
+ told you it's done — attribution keeps that honest. On a **claimed** ticket
141
+ the branch owns the asks: ask writes from any other checkout land on the
142
+ claim branch as pushed commits (the CLI says so when it happens), so pull
143
+ your claim branch before reading it — resolutions arrive from outside.
144
+ When some other workflow of yours needs the owner's attention, prefer
145
+ filing an ask over inventing a new channel: the inbox is the one place
146
+ they look.
147
+
148
+ **Promotion.** `backlog` → `todo` is the human "build this" decision, and
149
+ `task promote <id>` is its verb. There is no quality gate — a one-line
150
+ backlog ticket promotes, because planning happens at claim time, not before.
151
+ Blocked tickets still promote (the hold is reported, in `holds` with
152
+ `--json`): blockers are claim-time gates that `claim` and `--claimable`
153
+ enforce live, so promoting a blocked ticket just queues it behind its
154
+ blockers. Prefer `promote` over `update --status todo` / `move` for this
155
+ transition — it refuses non-backlog sources and reports holds. **Never
156
+ promote on your own initiative**; a person promotes.
157
+
88
158
  **Claiming.** On a board worked by several sessions (scheduled agents, other
89
159
  humans), `task claim <id>` is the lock that keeps two workers off the same
90
- ticket: it branches `<claimPrefix><prefix>-<n>` off origin's default branch
91
- (`claimPrefix` in `.task/config.json`, default `task/claim/`), flips the
92
- ticket to `in_progress` as the branch's first commit, and pushes. The branch
93
- existing on origin *is* the claim — atomic, because the push only succeeds if
94
- the branch doesn't exist yet. Exit codes: `0` claimed (you're on the branch,
95
- start working), `1` already claimed (pick another ticket), `2` not claimable
96
- (not todo, blocked, needs-human, or dirty tree). **Claim before you work**:
97
- if you're about to implement a `todo` ticket in a repo that claims (a
98
- `claimPrefix` in the config, or claims visible in
99
- `git ls-remote origin '<claimPrefix>*'`), run `task claim <id>` first so no
160
+ ticket: it branches `<branchPrefix><prefix>-<key>` off origin's default branch
161
+ (`claims.branchPrefix` in `.task/config.json`, default `task/claim/`), flips
162
+ the ticket to `in_progress` as the branch's first commit, and pushes. The
163
+ branch existing on origin *is* the claim — atomic, because the push only
164
+ succeeds if the branch doesn't exist yet. Exit codes: `0` claimed (you're on
165
+ the branch, start working), `1` already claimed (pick another ticket), `2`
166
+ not claimable (not todo, blocked, dirty tree, or at the claim cap). Open
167
+ asks don't gate a claim a ticket can be yours to implement while a person
168
+ owes it something. **Claim before you work**: if you're about to implement a `todo` ticket
169
+ in a repo that claims (a `claims` block in the config, or claims visible in
170
+ `git ls-remote origin '<branchPrefix>*'`), run `task claim <id>` first so no
100
171
  scheduled run picks it up under you — and do the whole ticket on the claim
101
- branch, `task done` + `--pr` included, so the merge lands code and status
102
- together. `task claim --release <id>` abandons a claim (deletes the branch
103
- both sides); `task list --claimable --json` is the queue. Never edit
104
- `claimPrefix` on your own initiative — it's the shared lock convention, and
105
- changing it strands every in-flight claim.
172
+ branch, `task move <id> done` + `--pr` included, so the merge lands code and status
173
+ together. Never edit `claims.branchPrefix` on your own initiative it's the
174
+ shared lock convention, and changing it strands every in-flight claim.
175
+
176
+ `task claim --next --json` claims the top claimable ticket in one call and is
177
+ the first move of a scheduled worker: it walks the selection scope in
178
+ priority order, retries past lost races internally, and exits `0` claimed
179
+ (printing the ticket), `1` queue empty (stop cleanly — no work), `2`
180
+ preconditions failed. The scope comes from the root board's top-level
181
+ `"boards"` list in `.task/config.json` (board prefixes, list order =
182
+ priority; absent → the nearest board only); `--board TAS` (repeatable)
183
+ overrides it. The list is selection scope, not permission — an explicit
184
+ `task claim <id>` works on any board. `task list --claimable --json` is the
185
+ same queue without the claim.
186
+
187
+ The root board's `claims.maxOpenCount` is a repo-wide WIP cap: while that
188
+ many claim branches exist on origin, every new claim is refused (exit `2`).
189
+ Don't reach for `--force` on your own initiative — the cap usually means
190
+ merge or release something first.
191
+
192
+ **Releasing.** `task claim --release <id> --comment "why"` abandons a claim:
193
+ it deletes the branch on both sides, then records the required comment on the
194
+ ticket (attributed like `task comment`, left uncommitted in your working
195
+ tree) so the next worker inherits what was tried instead of rediscovering it.
196
+ Say what you attempted and where it went wrong, not just "releasing".
106
197
 
107
198
  **Pull requests.** When you open a PR for a task, attach it:
108
199
  `task update <id> --pr <url>`. Put the task id in the PR title too (e.g.
109
- `[TAS-6] Fix retry loop`) so the two reference each other from both sides.
200
+ `[TAS-x7k4m] Fix retry loop`) so the two reference each other from both sides.
110
201
  `--pr` appends one URL; `--prs url1,url2` replaces the whole list.
111
202
 
112
203
  ## How to work with the board
113
204
 
114
205
  **Starting work.** Before picking up a request, check `task list --json` —
115
- the work may already be tracked. If it is, `task start <id>` so the board
206
+ the work may already be tracked. If it is, `task move <id> in_progress` so the board
116
207
  shows it moving. If it isn't and the work is more than a quick edit, create a
117
208
  task first: the user watching the board should be able to tell what you're
118
209
  doing without reading your transcript.
@@ -124,18 +215,19 @@ new work you're not going to do now (a bug, a refactor, a follow-up), don't
124
215
  let it evaporate: `task add` it to `backlog` or `todo` with enough description
125
216
  that someone else could pick it up cold.
126
217
 
127
- **Finishing.** `task done <id>` only when the work is actually done — code
218
+ **Finishing.** `task move <id> done` only when the work is actually done — code
128
219
  written, checks passing. If you're stopping partway, leave it `in_progress`
129
220
  with a comment saying exactly where you stopped and what's left. If the work
130
221
  turned out to be unnecessary, `task move <id> canceled` with a comment saying
131
222
  why — a canceled task with a reason beats a deleted one.
132
223
 
133
- **When you can't finish it alone.** Some tickets need a person: a credential
134
- only they can issue, a design call that isn't yours to make, an action against
135
- production. Mark those `--needs-human`, comment saying exactly what you need,
136
- and leave the task `in_progress` not `done`. Moving it to `done` because
137
- your part is finished hides the one thing the board exists to surface. Clear
138
- the flag with `--no-needs-human` once the person has unblocked it.
224
+ **When you can't finish it alone.** File an ask `task ask <id> "exactly
225
+ what you need" --author claude` then keep going on everything that doesn't
226
+ depend on it, and leave the ticket `in_progress` when your part runs out.
227
+ Don't fight the done gate: a refused `task move <id> done` is the board
228
+ telling you a person still owes something, and that surfacing is the whole
229
+ point. There is no needs-human flag to set — the badge derives from open
230
+ asks, so saying what's owed is the only way to raise it.
139
231
 
140
232
  **Attribution.** Pass `--author claude` on your comments so the user can tell
141
233
  your updates from theirs. Their own comments are attributed automatically from
@@ -149,9 +241,18 @@ webhook", not "webhook bug". Descriptions carry the context you'd want if you
149
241
  picked the task up cold: the file paths involved, the constraint that makes it
150
242
  non-obvious, the definition of done. Tags are lowercase single words
151
243
  (`api`, `infra`, `ui`, `bug`) — reuse the repo's existing tags
152
- (`task list --all --json` shows what's in use) before inventing new ones. A
153
- milestone is the one release or push a task belongs to; there's at most one,
154
- and the same rule applies join an existing one rather than coining a variant.
244
+ (`task list --all --json` shows what's in use) before inventing new ones.
245
+
246
+ **Goals.** A goal is a titled, described destination (`task goal add`); a task
247
+ belongs to **at most one**. The split to keep straight: the goal answers
248
+ "where is this going" (one), tags answer "what kind" (many) — something that
249
+ wants to live in two goals is a tag. The goal's description carries the
250
+ strategic why that its tickets share; keep each ticket self-sufficient for the
251
+ how, and put the shared context in the goal instead of pasting it into every
252
+ ticket (`task show` inlines it automatically). Goals have no status — progress
253
+ is derived from their tasks — and `task goal archive` shelves a finished one
254
+ once nothing open references it. Don't mint goals casually: if it doesn't
255
+ deserve a description, it's a tag.
155
256
 
156
257
  ## The UI
157
258
 
@@ -167,3 +268,15 @@ package's `.task/`) from one server — the header becomes a board switcher.
167
268
  This changes nothing for the other commands: they still operate on the
168
269
  nearest `.task/` walking up from the current directory, so `cd` into the
169
270
  package whose board you mean before running them.
271
+
272
+ On a git checkout with an origin, the served UI also carries a git surface:
273
+ a branch chip (current branch, dirty board files, ahead/behind), board-scoped
274
+ commits (`chore(board): …`, pathspec-limited to `.task/` so unrelated dirty
275
+ code never rides along), a branch switcher that does a real checkout, and PR
276
+ affordances. Two consequences for you: board edits made through the UI are
277
+ working-tree writes the user can now see and commit themselves — so don't
278
+ commit board files for them unprompted; and dragging a claimed ticket out of
279
+ In Progress in the UI performs a real `task claim --release` (the claim
280
+ branch and its commits are deleted) after an explicit confirm — if your claim
281
+ disappears mid-run, check the ticket's comments for the release note before
282
+ assuming a race.
@@ -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
+ })