@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.
- package/README.md +73 -25
- package/dist/asks.test.d.ts +17 -0
- package/dist/asks.test.d.ts.map +1 -0
- package/dist/asks.test.js +278 -0
- package/dist/asks.test.js.map +1 -0
- package/dist/check.d.ts +43 -0
- package/dist/check.d.ts.map +1 -0
- package/dist/check.js +403 -0
- package/dist/check.js.map +1 -0
- package/dist/check.test.d.ts +9 -0
- package/dist/check.test.d.ts.map +1 -0
- package/dist/check.test.js +248 -0
- package/dist/check.test.js.map +1 -0
- package/dist/claim-io.d.ts +73 -0
- package/dist/claim-io.d.ts.map +1 -0
- package/dist/claim-io.js +344 -0
- package/dist/claim-io.js.map +1 -0
- package/dist/claim.d.ts +61 -9
- package/dist/claim.d.ts.map +1 -1
- package/dist/claim.js +197 -67
- package/dist/claim.js.map +1 -1
- package/dist/claim.test.d.ts +2 -2
- package/dist/claim.test.js +235 -64
- package/dist/claim.test.js.map +1 -1
- package/dist/cli.js +724 -136
- package/dist/cli.js.map +1 -1
- package/dist/file-store.d.ts +110 -38
- package/dist/file-store.d.ts.map +1 -1
- package/dist/file-store.js +514 -238
- package/dist/file-store.js.map +1 -1
- package/dist/git-serve.d.ts +183 -0
- package/dist/git-serve.d.ts.map +1 -0
- package/dist/git-serve.js +503 -0
- package/dist/git-serve.js.map +1 -0
- package/dist/git-serve.test.d.ts +16 -0
- package/dist/git-serve.test.d.ts.map +1 -0
- package/dist/git-serve.test.js +183 -0
- package/dist/git-serve.test.js.map +1 -0
- package/dist/git.d.ts +65 -0
- package/dist/git.d.ts.map +1 -0
- package/dist/git.js +114 -0
- package/dist/git.js.map +1 -0
- package/dist/id.d.ts +39 -0
- package/dist/id.d.ts.map +1 -0
- package/dist/id.js +67 -0
- package/dist/id.js.map +1 -0
- package/dist/inbox.d.ts +41 -0
- package/dist/inbox.d.ts.map +1 -0
- package/dist/inbox.js +56 -0
- package/dist/inbox.js.map +1 -0
- package/dist/index.d.ts +5 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -3
- package/dist/index.js.map +1 -1
- package/dist/overview.d.ts +52 -0
- package/dist/overview.d.ts.map +1 -0
- package/dist/overview.js +61 -0
- package/dist/overview.js.map +1 -0
- package/dist/overview.test.d.ts +8 -0
- package/dist/overview.test.d.ts.map +1 -0
- package/dist/overview.test.js +48 -0
- package/dist/overview.test.js.map +1 -0
- package/dist/promote.test.d.ts +15 -0
- package/dist/promote.test.d.ts.map +1 -0
- package/dist/promote.test.js +104 -0
- package/dist/promote.test.js.map +1 -0
- package/dist/publish.d.ts +2 -17
- package/dist/publish.d.ts.map +1 -1
- package/dist/publish.js +4 -49
- package/dist/publish.js.map +1 -1
- package/dist/search.d.ts +34 -0
- package/dist/search.d.ts.map +1 -0
- package/dist/search.js +79 -0
- package/dist/search.js.map +1 -0
- package/dist/search.test.d.ts +2 -0
- package/dist/search.test.d.ts.map +1 -0
- package/dist/search.test.js +53 -0
- package/dist/search.test.js.map +1 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +228 -23
- package/dist/server.js.map +1 -1
- package/dist/store.d.ts +43 -63
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +0 -368
- package/dist/store.js.map +1 -1
- package/dist/store.test.d.ts +1 -2
- package/dist/store.test.d.ts.map +1 -1
- package/dist/store.test.js +148 -106
- package/dist/store.test.js.map +1 -1
- package/dist/ticket-doc.d.ts +74 -5
- package/dist/ticket-doc.d.ts.map +1 -1
- package/dist/ticket-doc.js +229 -15
- package/dist/ticket-doc.js.map +1 -1
- package/dist/types.d.ts +115 -28
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +1 -1
- package/skill/SKILL.md +153 -40
- package/src/asks.test.ts +355 -0
- package/src/check.test.ts +328 -0
- package/src/check.ts +497 -0
- package/src/claim-io.ts +401 -0
- package/src/claim.test.ts +301 -71
- package/src/claim.ts +238 -81
- package/src/cli.ts +740 -131
- package/src/file-store.ts +572 -254
- package/src/git-serve.test.ts +240 -0
- package/src/git-serve.ts +595 -0
- package/src/git.ts +141 -0
- package/src/id.ts +68 -0
- package/src/inbox.ts +77 -0
- package/src/index.ts +4 -2
- package/src/overview.test.ts +52 -0
- package/src/overview.ts +105 -0
- package/src/promote.test.ts +143 -0
- package/src/publish.ts +6 -53
- package/src/search.test.ts +64 -0
- package/src/search.ts +105 -0
- package/src/server.ts +232 -21
- package/src/store.test.ts +166 -116
- package/src/store.ts +46 -444
- package/src/ticket-doc.ts +284 -21
- package/src/types.ts +120 -28
- package/ui/dist/assets/index-BjsorZOU.js +229 -0
- package/ui/dist/assets/index-CoKCUYic.css +1 -0
- package/ui/dist/index.html +2 -2
- package/ui/dist/assets/index-COunM-QN.css +0 -1
- 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] [--
|
|
38
|
-
task show <id> --json # full task + comments
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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 --
|
|
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-
|
|
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-
|
|
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 ""`, `--
|
|
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 `<
|
|
91
|
-
(`
|
|
92
|
-
ticket to `in_progress` as the branch's first commit, and pushes. The
|
|
93
|
-
existing on origin *is* the claim — atomic, because the push only
|
|
94
|
-
the branch doesn't exist yet. Exit codes: `0` claimed (you're on
|
|
95
|
-
start working), `1` already claimed (pick another ticket), `2`
|
|
96
|
-
(not todo, blocked,
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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. `
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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-
|
|
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
|
|
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
|
|
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.**
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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.
|
|
153
|
-
|
|
154
|
-
|
|
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.
|
package/src/asks.test.ts
ADDED
|
@@ -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
|
+
})
|