@nickmeriano/task 0.4.2 → 0.6.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 +77 -9
- package/dist/cli.js +290 -19
- package/dist/cli.js.map +1 -1
- package/dist/file-store.d.ts +113 -0
- package/dist/file-store.d.ts.map +1 -0
- package/dist/file-store.js +604 -0
- package/dist/file-store.js.map +1 -0
- package/dist/index.d.ts +6 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +30 -7
- package/dist/server.js.map +1 -1
- package/dist/store.d.ts +69 -16
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +169 -39
- package/dist/store.js.map +1 -1
- package/dist/store.test.d.ts +12 -0
- package/dist/store.test.d.ts.map +1 -0
- package/dist/store.test.js +252 -0
- package/dist/store.test.js.map +1 -0
- package/dist/ticket-doc.d.ts +57 -0
- package/dist/ticket-doc.d.ts.map +1 -0
- package/dist/ticket-doc.js +197 -0
- package/dist/ticket-doc.js.map +1 -0
- package/dist/types.d.ts +35 -3
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +4 -4
- package/skill/SKILL.md +40 -9
- package/src/cli.ts +304 -22
- package/src/file-store.ts +693 -0
- package/src/index.ts +30 -4
- package/src/server.ts +31 -11
- package/src/store.test.ts +305 -0
- package/src/store.ts +210 -49
- package/src/ticket-doc.ts +226 -0
- package/src/types.ts +35 -3
- package/ui/dist/assets/index-CXW8uT5f.css +1 -0
- package/ui/dist/assets/{index-Dm3ToURf.js → index-oJzomUDL.js} +67 -67
- package/ui/dist/index.html +2 -2
- package/ui/dist/assets/index-DXFbw9bM.css +0 -1
package/README.md
CHANGED
|
@@ -3,10 +3,12 @@
|
|
|
3
3
|
A task manager that lives in your repo.
|
|
4
4
|
|
|
5
5
|
Tasks and their status are coupled to the code — so keep them next to it.
|
|
6
|
-
State is
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
6
|
+
State is plain text in `.task/` at your project root — one markdown file per
|
|
7
|
+
ticket and per comment: no accounts, no connectors, no drift between the board
|
|
8
|
+
and the branch. Ticket changes show up as readable diffs in PRs, and two
|
|
9
|
+
branches editing different tickets merge cleanly. Built for the way projects
|
|
10
|
+
work now — you *and* your AI agents share one backlog, and the board updates
|
|
11
|
+
live while agents move tickets from another terminal.
|
|
10
12
|
|
|
11
13
|
```bash
|
|
12
14
|
npx @nickmeriano/task init
|
|
@@ -43,7 +45,11 @@ task list # open tasks, board order
|
|
|
43
45
|
task list --needs-human # what's blocked on a person
|
|
44
46
|
task start 1 # → in_progress
|
|
45
47
|
task done 1 # → done
|
|
48
|
+
task link 2 --blocked-by 1 # dependency, visible from both tasks
|
|
49
|
+
task update 1 --pr https://github.com/you/repo/pull/42
|
|
46
50
|
task comment 1 "shipped in #42" --author claude
|
|
51
|
+
task boards # every board in the repo, with prefixes
|
|
52
|
+
task archive --all # move finished tickets to .task/archive/
|
|
47
53
|
task serve # opens the kanban + table UI, live
|
|
48
54
|
task publish # same board, at a URL you can open on a phone
|
|
49
55
|
```
|
|
@@ -81,11 +87,68 @@ one committing.
|
|
|
81
87
|
immediately; your tasks are untouched, because they were only ever read from
|
|
82
88
|
`.task/`. Changing your mind about *visibility* doesn't need that at all —
|
|
83
89
|
`task publish --private` is enough.
|
|
90
|
+
- **Any branch.** The board renders your default branch, but `?ref=<branch>`
|
|
91
|
+
renders any other — and the header grows a branch switcher when there's more
|
|
92
|
+
than one. That's PR review for board changes: see the board as the PR would
|
|
93
|
+
leave it, before merging. Locally none of this is needed — `task serve`
|
|
94
|
+
shows your working tree, so previewing a branch is `git checkout`.
|
|
95
|
+
|
|
96
|
+
Since the preview URL is predictable, a small workflow in *your* repo can
|
|
97
|
+
comment it on every PR that touches board state — the App itself stays
|
|
98
|
+
read-only and never writes to your repository:
|
|
99
|
+
|
|
100
|
+
```yaml
|
|
101
|
+
# .github/workflows/board-preview.yml
|
|
102
|
+
name: Board preview
|
|
103
|
+
on:
|
|
104
|
+
pull_request:
|
|
105
|
+
paths: [".task/**", "**/.task/**"]
|
|
106
|
+
permissions:
|
|
107
|
+
pull-requests: write
|
|
108
|
+
jobs:
|
|
109
|
+
comment:
|
|
110
|
+
if: github.event.pull_request.head.repo.full_name == github.repository
|
|
111
|
+
runs-on: ubuntu-latest
|
|
112
|
+
steps:
|
|
113
|
+
- uses: actions/github-script@v7
|
|
114
|
+
with:
|
|
115
|
+
script: |
|
|
116
|
+
const branch = context.payload.pull_request.head.ref
|
|
117
|
+
const url = `https://task.nickmeriano.com/${context.repo.owner}/${context.repo.repo}?ref=${encodeURIComponent(branch)}`
|
|
118
|
+
const marker = "<!-- board-preview -->"
|
|
119
|
+
const body = `${marker}\n📋 [Preview the board at \`${branch}\`](${url})`
|
|
120
|
+
const comments = await github.paginate(github.rest.issues.listComments, { ...context.repo, issue_number: context.issue.number })
|
|
121
|
+
const existing = comments.find((c) => c.body?.startsWith(marker))
|
|
122
|
+
if (existing) await github.rest.issues.updateComment({ ...context.repo, comment_id: existing.id, body })
|
|
123
|
+
else await github.rest.issues.createComment({ ...context.repo, issue_number: context.issue.number, body })
|
|
124
|
+
```
|
|
84
125
|
|
|
85
|
-
- Ids are `PREFIX-12` or just `12`; the prefix
|
|
126
|
+
- Ids are `PREFIX-12` or just `12`; the prefix is the first three letters of
|
|
127
|
+
the project name ("phone" → `PHO-1`), or whatever `task init --prefix` says.
|
|
128
|
+
- Ids route by prefix: a bare `12` means the nearest board, while `TAS-12`
|
|
129
|
+
reaches the TAS board from anywhere in the monorepo. An unknown or ambiguous
|
|
130
|
+
prefix fails loudly instead of guessing. `task boards` lists every board —
|
|
131
|
+
prefix, name, path, open count — with a `*` on the one commands target from
|
|
132
|
+
the current directory.
|
|
133
|
+
- `task archive <id>` moves a done/canceled ticket's directory to
|
|
134
|
+
`.task/archive/` — off the board and out of every hot path, so boards stay
|
|
135
|
+
fast as history accumulates. Archived tickets stay readable (`task show`,
|
|
136
|
+
`task list --archived`), keep their comments, and their numbers stay
|
|
137
|
+
reserved forever. `task archive --all` sweeps everything finished;
|
|
138
|
+
`task unarchive <id>` puts one back.
|
|
86
139
|
- Statuses: `backlog` `todo` `in_progress` `done` `canceled`.
|
|
87
140
|
- A task carries tags, at most one milestone, and a `--needs-human` flag for
|
|
88
141
|
work an agent can't finish alone. `--tag a,b` matches either tag.
|
|
142
|
+
- `task link A --blocked-by B` marks a dependency. It's one relation seen from
|
|
143
|
+
both ends — B's page says it blocks A — and the board badges A as blocked
|
|
144
|
+
until B is done or canceled.
|
|
145
|
+
- `--pr <url>` attaches a pull request to a task (`--prs` replaces the list);
|
|
146
|
+
attached PRs show in the task's rail and as a count on its card. The elegant
|
|
147
|
+
loop is an agent convention, not a webhook: put the id in the PR title
|
|
148
|
+
(`[TAS-6] …`) and run `task update TAS-6 --pr <url>` when opening the PR —
|
|
149
|
+
the shipped skill tells agents to do exactly that.
|
|
150
|
+
- Every task has **Copy link** — the icon next to its title, or right-click /
|
|
151
|
+
long-press any card or row — so handing a ticket to an agent is one paste.
|
|
89
152
|
- Comments are attributed from `git config user.name` with no setup —
|
|
90
153
|
override with `--author` or `$TASK_AUTHOR`, and check with `task whoami`.
|
|
91
154
|
- Every command takes `--json` — that's the agent interface.
|
|
@@ -100,13 +163,18 @@ one committing.
|
|
|
100
163
|
root and it serves every nested board from one server, with a board switcher
|
|
101
164
|
in the header. Run it inside a package and you get just that board. Every
|
|
102
165
|
other command stays scoped to the nearest `.task/`.
|
|
103
|
-
-
|
|
104
|
-
|
|
166
|
+
- Boards from before 0.6 stored their state as a committed SQLite database;
|
|
167
|
+
those keep working as-is, and `task migrate` moves one onto text files
|
|
168
|
+
(the database stays on disk as an ignored backup).
|
|
105
169
|
|
|
106
170
|
## How it's built
|
|
107
171
|
|
|
108
|
-
- **Zero dependencies.** Storage is
|
|
109
|
-
|
|
172
|
+
- **Zero dependencies.** Storage is markdown files with a tiny frontmatter
|
|
173
|
+
block — reviewable in a PR, mergeable by git, editable by hand. Comments are
|
|
174
|
+
one file each (append-only merges cleanly), and the `blocks`/`blocked-by`
|
|
175
|
+
relation is stored on one side only, so its two views can't disagree.
|
|
176
|
+
Legacy boards read through Node's built-in `node:sqlite` (Node ≥ 22.13) —
|
|
177
|
+
nothing to compile, so `npx` starts in about a second.
|
|
110
178
|
- **Realtime is a file watch.** `task serve` is one `node:http` process: the
|
|
111
179
|
prebuilt UI, a JSON API, and a server-sent-events stream that pings whenever
|
|
112
180
|
anything writes to `.task/` — this UI, another terminal, an agent mid-run.
|
package/dist/cli.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// The `task` CLI — tasks that live in your repo. Zero dependencies: hand-rolled
|
|
3
|
-
// flag parsing,
|
|
3
|
+
// flag parsing, plain-text tickets in .task/tickets/ for storage (node:sqlite
|
|
4
|
+
// still reads boards from before `task migrate`), plain text out (--json for
|
|
5
|
+
// agents).
|
|
4
6
|
//
|
|
5
7
|
// task init
|
|
6
8
|
// task add "Wire up webhooks" --tags api,infra --milestone launch
|
|
7
9
|
// task list --status todo,in_progress
|
|
8
|
-
// task start
|
|
10
|
+
// task start TAS-3 && task done TAS-3
|
|
9
11
|
// task serve
|
|
10
12
|
import { spawn } from "node:child_process";
|
|
11
13
|
import { readFileSync } from "node:fs";
|
|
@@ -14,7 +16,8 @@ import process from "node:process";
|
|
|
14
16
|
import { resolveAuthor } from "./author.js";
|
|
15
17
|
import { detectRepo, parseSlug, publish, resolveHost } from "./publish.js";
|
|
16
18
|
import { createTaskServer } from "./server.js";
|
|
17
|
-
import {
|
|
19
|
+
import { initProject, migrateBoard, openBoard } from "./file-store.js";
|
|
20
|
+
import { CONFIG_FILE, TASK_DIR, boardConfig, findBoards, findBoardsByPrefix, findRoot, findScopeRoot, } from "./store.js";
|
|
18
21
|
import { STATUSES, isStatus } from "./types.js";
|
|
19
22
|
// node:sqlite still emits an ExperimentalWarning on Node 22 — noise in a CLI
|
|
20
23
|
// that runs it on every invocation. Filter that one warning, keep the rest.
|
|
@@ -47,6 +50,7 @@ const BOOLEAN_FLAGS = new Set([
|
|
|
47
50
|
"yes",
|
|
48
51
|
"needs-human",
|
|
49
52
|
"no-needs-human",
|
|
53
|
+
"archived",
|
|
50
54
|
"open",
|
|
51
55
|
"no-open",
|
|
52
56
|
"strict-port",
|
|
@@ -90,7 +94,62 @@ function openStore() {
|
|
|
90
94
|
const root = findRoot(process.cwd());
|
|
91
95
|
if (!root)
|
|
92
96
|
fail("no .task directory found in this directory or any parent — run `task init` first");
|
|
93
|
-
return
|
|
97
|
+
return openBoard(root);
|
|
98
|
+
}
|
|
99
|
+
/** The prefix a ref carries, if any: "TAS-12" → "TAS", "12" → null. */
|
|
100
|
+
function refPrefix(ref) {
|
|
101
|
+
const match = /^([A-Za-z0-9]+)-\d+$/.exec(ref.trim());
|
|
102
|
+
return match ? match[1].toUpperCase() : null;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* The board a ref belongs to. A bare number means the nearest board, as ever —
|
|
106
|
+
* but a prefixed id is an address, and it routes: if the prefix isn't the
|
|
107
|
+
* nearest board's, every board in the repo (walking up to the outermost board
|
|
108
|
+
* root, then down) is searched for it, so `task show TAS-12` works from
|
|
109
|
+
* anywhere in a monorepo. What this must never do is what it used to: silently
|
|
110
|
+
* strip a foreign prefix and act on the nearest board's ticket of that number.
|
|
111
|
+
*/
|
|
112
|
+
function openStoreFor(ref) {
|
|
113
|
+
const prefix = ref ? refPrefix(ref) : null;
|
|
114
|
+
if (!prefix)
|
|
115
|
+
return openStore();
|
|
116
|
+
const nearest = findRoot(process.cwd());
|
|
117
|
+
if (nearest) {
|
|
118
|
+
const store = openBoard(nearest);
|
|
119
|
+
if (store.config.prefix.toUpperCase() === prefix)
|
|
120
|
+
return store;
|
|
121
|
+
store.close();
|
|
122
|
+
}
|
|
123
|
+
const scope = findScopeRoot(process.cwd());
|
|
124
|
+
const matches = findBoardsByPrefix(scope, prefix);
|
|
125
|
+
if (matches.length === 1)
|
|
126
|
+
return openBoard(matches[0].root);
|
|
127
|
+
if (matches.length > 1) {
|
|
128
|
+
fail(`prefix ${prefix} is ambiguous — boards at: ${matches.map((m) => m.id).join(", ")}`);
|
|
129
|
+
}
|
|
130
|
+
const known = findBoards(scope)
|
|
131
|
+
.map((b) => {
|
|
132
|
+
try {
|
|
133
|
+
return `${boardConfig(b.root).prefix} (${b.id})`;
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
})
|
|
139
|
+
.filter(Boolean);
|
|
140
|
+
fail(`no board with prefix ${prefix} in this repo${known.length ? ` — boards here: ${known.join(", ")}` : ""}`);
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Parse a ref against an already-chosen board, refusing a foreign prefix
|
|
144
|
+
* instead of reinterpreting it — this guards the second id in `task link`,
|
|
145
|
+
* where the board was picked by the first.
|
|
146
|
+
*/
|
|
147
|
+
function parseRefOn(store, ref) {
|
|
148
|
+
const prefix = refPrefix(ref);
|
|
149
|
+
if (prefix && prefix !== store.config.prefix.toUpperCase()) {
|
|
150
|
+
fail(`${ref} is not on the ${store.config.prefix} board — links can't cross boards`);
|
|
151
|
+
}
|
|
152
|
+
return store.parseId(ref);
|
|
94
153
|
}
|
|
95
154
|
// ── Input normalization ──────────────────────────────────────────────────────
|
|
96
155
|
function parseStatus(value) {
|
|
@@ -123,6 +182,11 @@ function patchFromFlags(flags) {
|
|
|
123
182
|
patch.needsHuman = true;
|
|
124
183
|
if (flags["no-needs-human"])
|
|
125
184
|
patch.needsHuman = false;
|
|
185
|
+
// Replace semantics like --tags; `--pr` (append one) is handled per-command
|
|
186
|
+
// because appending needs the task's current list.
|
|
187
|
+
const prs = str(flags, "prs");
|
|
188
|
+
if (prs !== undefined)
|
|
189
|
+
patch.prs = prs.split(",").map((p) => p.trim()).filter(Boolean);
|
|
126
190
|
return patch;
|
|
127
191
|
}
|
|
128
192
|
// ── Output helpers ───────────────────────────────────────────────────────────
|
|
@@ -179,7 +243,11 @@ function cmdAdd(args) {
|
|
|
179
243
|
if (!title)
|
|
180
244
|
fail(`usage: task add <title> [--description …] [--status …] [--tags a,b] [--milestone …] [--needs-human]`);
|
|
181
245
|
const store = openStore();
|
|
182
|
-
const
|
|
246
|
+
const patch = patchFromFlags(args.flags);
|
|
247
|
+
const pr = str(args.flags, "pr");
|
|
248
|
+
if (pr)
|
|
249
|
+
patch.prs = [...(patch.prs ?? []), pr];
|
|
250
|
+
const task = store.create({ title, ...patch });
|
|
183
251
|
if (args.flags.json) {
|
|
184
252
|
console.log(JSON.stringify({ task }, null, 2));
|
|
185
253
|
}
|
|
@@ -189,18 +257,22 @@ function cmdAdd(args) {
|
|
|
189
257
|
}
|
|
190
258
|
function cmdList(args) {
|
|
191
259
|
const store = openStore();
|
|
260
|
+
const archived = Boolean(args.flags.archived);
|
|
192
261
|
const statusFlag = str(args.flags, "status");
|
|
193
262
|
const statuses = statusFlag
|
|
194
263
|
? statusFlag.split(",").map(parseStatus)
|
|
195
|
-
:
|
|
196
|
-
|
|
197
|
-
|
|
264
|
+
: // The archive is all done/canceled, and --all already means everything —
|
|
265
|
+
// the open-tickets default only applies to the plain board listing.
|
|
266
|
+
args.flags.all || archived
|
|
267
|
+
? undefined
|
|
268
|
+
: ["backlog", "todo", "in_progress"];
|
|
198
269
|
const tagFlag = str(args.flags, "tags") ?? str(args.flags, "tag");
|
|
199
270
|
const tasks = store.list({
|
|
200
271
|
statuses,
|
|
201
272
|
tags: tagFlag ? parseTags(tagFlag) : undefined,
|
|
202
273
|
milestone: str(args.flags, "milestone"),
|
|
203
274
|
needsHuman: args.flags["needs-human"] ? true : undefined,
|
|
275
|
+
archived: archived || undefined,
|
|
204
276
|
});
|
|
205
277
|
// Present in board order: grouped by status column, then position.
|
|
206
278
|
const order = new Map(STATUSES.map((s, i) => [s, i]));
|
|
@@ -209,17 +281,110 @@ function cmdList(args) {
|
|
|
209
281
|
console.log(JSON.stringify({ tasks }, null, 2));
|
|
210
282
|
}
|
|
211
283
|
else if (tasks.length === 0) {
|
|
212
|
-
console.log(
|
|
284
|
+
console.log(archived
|
|
285
|
+
? "no archived tasks"
|
|
286
|
+
: args.flags.all
|
|
287
|
+
? "no tasks"
|
|
288
|
+
: "no open tasks (--all includes done/canceled)");
|
|
213
289
|
}
|
|
214
290
|
else {
|
|
215
291
|
table(tasks.map(taskRow));
|
|
216
292
|
}
|
|
217
293
|
}
|
|
294
|
+
/**
|
|
295
|
+
* `task boards` — every board in this repo, found the way `task serve` finds
|
|
296
|
+
* them but anchored at the *outermost* board root, so it answers from anywhere
|
|
297
|
+
* in a monorepo. The `*` marks the board the other commands would target from
|
|
298
|
+
* here.
|
|
299
|
+
*/
|
|
300
|
+
function cmdBoards(args) {
|
|
301
|
+
const cwd = process.cwd();
|
|
302
|
+
const scope = findScopeRoot(cwd);
|
|
303
|
+
const boards = findBoards(scope);
|
|
304
|
+
if (boards.length === 0) {
|
|
305
|
+
fail("no .task directory found in this directory, any parent, or below — run `task init` first");
|
|
306
|
+
}
|
|
307
|
+
const nearest = findRoot(cwd);
|
|
308
|
+
const rows = boards.map((ref) => {
|
|
309
|
+
const store = openBoard(ref.root);
|
|
310
|
+
const open = store.list({ statuses: ["backlog", "todo", "in_progress"] }).length;
|
|
311
|
+
store.close();
|
|
312
|
+
return {
|
|
313
|
+
id: ref.id,
|
|
314
|
+
name: store.config.name,
|
|
315
|
+
prefix: store.config.prefix,
|
|
316
|
+
open,
|
|
317
|
+
current: ref.root === nearest,
|
|
318
|
+
};
|
|
319
|
+
});
|
|
320
|
+
if (args.flags.json) {
|
|
321
|
+
console.log(JSON.stringify({ boards: rows }, null, 2));
|
|
322
|
+
}
|
|
323
|
+
else {
|
|
324
|
+
table(rows.map((b) => [
|
|
325
|
+
b.current ? "*" : "",
|
|
326
|
+
b.prefix,
|
|
327
|
+
b.name,
|
|
328
|
+
b.id,
|
|
329
|
+
`${b.open} open`,
|
|
330
|
+
]));
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
/**
|
|
334
|
+
* `task archive <id>` / `task archive --all` — move finished tickets to
|
|
335
|
+
* `.task/archive/`, out of the board and off every hot path. History, not a
|
|
336
|
+
* hiding place: only done/canceled tickets qualify, everything stays readable
|
|
337
|
+
* via `show` and `list --archived`, and `task unarchive` puts one back.
|
|
338
|
+
*/
|
|
339
|
+
function cmdArchive(args) {
|
|
340
|
+
const ref = args.positional[0];
|
|
341
|
+
if (args.flags.all && ref)
|
|
342
|
+
fail("pass an id or --all, not both");
|
|
343
|
+
if (!args.flags.all && !ref)
|
|
344
|
+
fail("usage: task archive <id> | task archive --all");
|
|
345
|
+
if (ref) {
|
|
346
|
+
const store = openStoreFor(ref);
|
|
347
|
+
const task = store.archive(store.parseId(ref));
|
|
348
|
+
if (args.flags.json) {
|
|
349
|
+
console.log(JSON.stringify({ task }, null, 2));
|
|
350
|
+
}
|
|
351
|
+
else {
|
|
352
|
+
console.log(`archived ${task.id} ${task.title}`);
|
|
353
|
+
}
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
const store = openStore();
|
|
357
|
+
const finished = store.list({ statuses: ["done", "canceled"] });
|
|
358
|
+
const archived = finished.map((t) => store.archive(t.number));
|
|
359
|
+
if (args.flags.json) {
|
|
360
|
+
console.log(JSON.stringify({ archived }, null, 2));
|
|
361
|
+
}
|
|
362
|
+
else if (archived.length === 0) {
|
|
363
|
+
console.log("nothing to archive — no done or canceled tasks on the board");
|
|
364
|
+
}
|
|
365
|
+
else {
|
|
366
|
+
for (const task of archived)
|
|
367
|
+
console.log(`archived ${task.id} ${task.title}`);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
function cmdUnarchive(args) {
|
|
371
|
+
const ref = args.positional[0];
|
|
372
|
+
if (!ref)
|
|
373
|
+
fail("usage: task unarchive <id>");
|
|
374
|
+
const store = openStoreFor(ref);
|
|
375
|
+
const task = store.unarchive(store.parseId(ref));
|
|
376
|
+
if (args.flags.json) {
|
|
377
|
+
console.log(JSON.stringify({ task }, null, 2));
|
|
378
|
+
}
|
|
379
|
+
else {
|
|
380
|
+
printTask(task);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
218
383
|
function cmdShow(args) {
|
|
219
384
|
const ref = args.positional[0];
|
|
220
385
|
if (!ref)
|
|
221
386
|
fail("usage: task show <id>");
|
|
222
|
-
const store =
|
|
387
|
+
const store = openStoreFor(ref);
|
|
223
388
|
const number = store.parseId(ref);
|
|
224
389
|
const task = store.get(number);
|
|
225
390
|
if (!task)
|
|
@@ -229,14 +394,29 @@ function cmdShow(args) {
|
|
|
229
394
|
console.log(JSON.stringify({ task, comments }, null, 2));
|
|
230
395
|
return;
|
|
231
396
|
}
|
|
397
|
+
// A linked task is only as useful as knowing whether it's still in the way.
|
|
398
|
+
const describeLinks = (numbers) => numbers
|
|
399
|
+
.map((n) => {
|
|
400
|
+
const other = store.get(n);
|
|
401
|
+
return other ? `${other.id} (${other.status})` : store.displayId(n);
|
|
402
|
+
})
|
|
403
|
+
.join(", ");
|
|
232
404
|
console.log(`${task.id} ${task.title}`);
|
|
233
405
|
console.log(`status ${task.status}`);
|
|
406
|
+
if (task.archived)
|
|
407
|
+
console.log(`archived yes — \`task unarchive ${task.id}\` to edit`);
|
|
234
408
|
if (task.needsHuman)
|
|
235
409
|
console.log(`needs a human`);
|
|
236
410
|
if (task.tags.length)
|
|
237
411
|
console.log(`tags ${task.tags.join(", ")}`);
|
|
238
412
|
if (task.milestone)
|
|
239
413
|
console.log(`milestone ${task.milestone}`);
|
|
414
|
+
if (task.blockedBy.length)
|
|
415
|
+
console.log(`blocked by ${describeLinks(task.blockedBy)}`);
|
|
416
|
+
if (task.blocks.length)
|
|
417
|
+
console.log(`blocks ${describeLinks(task.blocks)}`);
|
|
418
|
+
for (const pr of task.prs)
|
|
419
|
+
console.log(`pr ${pr}`);
|
|
240
420
|
console.log(`created ${task.createdAt}`);
|
|
241
421
|
console.log(`updated ${task.updatedAt}`);
|
|
242
422
|
if (task.description)
|
|
@@ -253,13 +433,20 @@ function cmdUpdate(args, forcedStatus) {
|
|
|
253
433
|
const ref = args.positional[0];
|
|
254
434
|
if (!ref)
|
|
255
435
|
fail("usage: task update <id> [--status …] [--title …] …");
|
|
256
|
-
const store =
|
|
436
|
+
const store = openStoreFor(ref);
|
|
437
|
+
const number = store.parseId(ref);
|
|
257
438
|
const patch = patchFromFlags(args.flags);
|
|
258
439
|
if (forcedStatus)
|
|
259
440
|
patch.status = forcedStatus;
|
|
441
|
+
const pr = str(args.flags, "pr");
|
|
442
|
+
if (pr) {
|
|
443
|
+
// Append, dedup — `--pr <url>` is "attach this PR", not "replace the list".
|
|
444
|
+
const current = patch.prs ?? store.get(number)?.prs ?? [];
|
|
445
|
+
patch.prs = current.includes(pr) ? current : [...current, pr];
|
|
446
|
+
}
|
|
260
447
|
if (Object.keys(patch).length === 0)
|
|
261
448
|
fail("nothing to update — pass at least one flag");
|
|
262
|
-
const task = store.update(
|
|
449
|
+
const task = store.update(number, patch);
|
|
263
450
|
if (args.flags.json) {
|
|
264
451
|
console.log(JSON.stringify({ task }, null, 2));
|
|
265
452
|
}
|
|
@@ -278,7 +465,7 @@ function cmdComment(args) {
|
|
|
278
465
|
const body = rest.join(" ").trim();
|
|
279
466
|
if (!ref || !body)
|
|
280
467
|
fail(`usage: task comment <id> <text> [--author <who>]`);
|
|
281
|
-
const store =
|
|
468
|
+
const store = openStoreFor(ref);
|
|
282
469
|
const author = resolveAuthor(str(args.flags, "author")).name;
|
|
283
470
|
const comment = store.addComment(store.parseId(ref), body, author);
|
|
284
471
|
if (args.flags.json) {
|
|
@@ -288,6 +475,34 @@ function cmdComment(args) {
|
|
|
288
475
|
console.log(`commented on ${comment.taskId}`);
|
|
289
476
|
}
|
|
290
477
|
}
|
|
478
|
+
/**
|
|
479
|
+
* `task link TAS-3 --blocked-by TAS-5` / `task unlink TAS-3 --blocks TAS-9`.
|
|
480
|
+
* One relation with two spellings: either flag writes the same (blocker,
|
|
481
|
+
* blocked) row, so the other task's `task show` reflects it immediately.
|
|
482
|
+
*/
|
|
483
|
+
function cmdLink(args, action) {
|
|
484
|
+
const ref = args.positional[0];
|
|
485
|
+
const blocks = str(args.flags, "blocks");
|
|
486
|
+
const blockedBy = str(args.flags, "blocked-by");
|
|
487
|
+
if (!ref || (blocks ? blockedBy : !blockedBy)) {
|
|
488
|
+
fail(`usage: task ${action} <id> (--blocks <id> | --blocked-by <id>)`);
|
|
489
|
+
}
|
|
490
|
+
const store = openStoreFor(ref);
|
|
491
|
+
const relation = blocks ? "blocks" : "blocked_by";
|
|
492
|
+
const target = parseRefOn(store, (blocks ?? blockedBy));
|
|
493
|
+
const number = store.parseId(ref);
|
|
494
|
+
const task = action === "link" ? store.link(number, relation, target) : store.unlink(number, relation, target);
|
|
495
|
+
if (args.flags.json) {
|
|
496
|
+
console.log(JSON.stringify({ task }, null, 2));
|
|
497
|
+
}
|
|
498
|
+
else {
|
|
499
|
+
const verb = action === "link" ? "now" : "no longer";
|
|
500
|
+
const [subject, object] = relation === "blocks"
|
|
501
|
+
? [task.id, store.displayId(target)]
|
|
502
|
+
: [store.displayId(target), task.id];
|
|
503
|
+
console.log(`${subject} ${verb} blocks ${object}`);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
291
506
|
const AUTHOR_SOURCE = {
|
|
292
507
|
flag: "--author",
|
|
293
508
|
env: "$TASK_AUTHOR",
|
|
@@ -304,11 +519,33 @@ function cmdWhoami(args) {
|
|
|
304
519
|
console.log(`${author.name} (${AUTHOR_SOURCE[author.source]})`);
|
|
305
520
|
}
|
|
306
521
|
}
|
|
522
|
+
/**
|
|
523
|
+
* `task migrate` — from the legacy committed SQLite database to text-canonical
|
|
524
|
+
* storage: one markdown file per ticket and per comment under .task/tickets/,
|
|
525
|
+
* with the database left on disk as an ignored backup. Additive and safe to
|
|
526
|
+
* re-run planning-wise: it refuses to run twice.
|
|
527
|
+
*/
|
|
528
|
+
function cmdMigrate(args) {
|
|
529
|
+
const root = findRoot(process.cwd());
|
|
530
|
+
if (!root)
|
|
531
|
+
fail("no .task directory found in this directory or any parent — run `task init` first");
|
|
532
|
+
const result = migrateBoard(root);
|
|
533
|
+
if (args.flags.json) {
|
|
534
|
+
console.log(JSON.stringify({ migrated: result }, null, 2));
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
console.log(`Migrated ${result.tasks} task${result.tasks === 1 ? "" : "s"} and ${result.comments} comment${result.comments === 1 ? "" : "s"} to .task/tickets/`);
|
|
538
|
+
console.log(`tasks.db stays on disk as a backup, but it's ignored now — the files are the state.`);
|
|
539
|
+
console.log(``);
|
|
540
|
+
console.log(`Next:`);
|
|
541
|
+
console.log(` git rm --cached ${join(TASK_DIR, "tasks.db")} stop tracking the database`);
|
|
542
|
+
console.log(` git add ${TASK_DIR} commit the tickets`);
|
|
543
|
+
}
|
|
307
544
|
function cmdDelete(args) {
|
|
308
545
|
const ref = args.positional[0];
|
|
309
546
|
if (!ref)
|
|
310
547
|
fail("usage: task delete <id>");
|
|
311
|
-
const store =
|
|
548
|
+
const store = openStoreFor(ref);
|
|
312
549
|
const number = store.parseId(ref);
|
|
313
550
|
store.delete(number);
|
|
314
551
|
console.log(`deleted ${store.displayId(number)}`);
|
|
@@ -481,8 +718,10 @@ function runPublishFlow(args, repo, action, visibility) {
|
|
|
481
718
|
// ── Help + dispatch ──────────────────────────────────────────────────────────
|
|
482
719
|
const HELP = `task — a task manager that lives in your repo
|
|
483
720
|
|
|
484
|
-
State is
|
|
485
|
-
|
|
721
|
+
State is plain text in .task/ at the project root — one markdown file per
|
|
722
|
+
ticket and per comment under .task/tickets/. Commit it: tasks travel with the
|
|
723
|
+
code they describe, ticket changes show up as readable diffs in PRs, and two
|
|
724
|
+
branches editing different tickets merge cleanly. Any command works from any
|
|
486
725
|
subdirectory (it walks up to find .task/, like git).
|
|
487
726
|
|
|
488
727
|
Usage
|
|
@@ -490,16 +729,29 @@ Usage
|
|
|
490
729
|
task add <title> [--description <text>] [--status <s>] [--tags <a,b>]
|
|
491
730
|
[--milestone <m>] [--needs-human]
|
|
492
731
|
task list [--status <s1,s2>] [--tag <a,b>] [--milestone <m>]
|
|
493
|
-
[--needs-human] [--all]
|
|
732
|
+
[--needs-human] [--all] [--archived]
|
|
494
733
|
task show <id>
|
|
495
734
|
task update <id> [--title <t>] [--description <text>] [--status <s>]
|
|
496
735
|
[--tags <a,b>] [--milestone <m>]
|
|
497
736
|
[--needs-human | --no-needs-human]
|
|
737
|
+
[--pr <url>] [--prs <url1,url2>]
|
|
498
738
|
task move <id> <status> shorthand for update --status
|
|
499
739
|
task start <id> → in_progress
|
|
500
740
|
task done <id> → done
|
|
741
|
+
task link <id> --blocked-by <id>
|
|
742
|
+
task link <id> --blocks <id> mark a dependency — one relation, visible from
|
|
743
|
+
both tasks (A blocked by B ⇔ B blocks A)
|
|
744
|
+
task unlink <id> (--blocks <id> | --blocked-by <id>)
|
|
501
745
|
task comment <id> <text> [--author <who>]
|
|
502
746
|
task delete <id>
|
|
747
|
+
task boards every board in this repo — prefix, name, path,
|
|
748
|
+
open count; * marks the one commands target here
|
|
749
|
+
task archive <id> move a done/canceled ticket to .task/archive/,
|
|
750
|
+
out of the board and off the hot path — still
|
|
751
|
+
readable via show and list --archived, and its
|
|
752
|
+
number stays reserved
|
|
753
|
+
task archive --all archive everything done or canceled
|
|
754
|
+
task unarchive <id> put an archived ticket back on the board
|
|
503
755
|
task whoami who your comments are attributed to
|
|
504
756
|
task serve [--port <n>] [--no-open] [--strict-port]
|
|
505
757
|
board + table UI with live updates, opened in
|
|
@@ -518,13 +770,20 @@ Usage
|
|
|
518
770
|
task unpublish [--repo <owner/name>]
|
|
519
771
|
take that URL down. Removes the board, not the
|
|
520
772
|
tasks — those are in .task/ either way
|
|
773
|
+
task migrate move a pre-0.6 board off its committed SQLite
|
|
774
|
+
database and onto text files in .task/tickets/.
|
|
775
|
+
The database stays on disk as an ignored backup
|
|
521
776
|
|
|
522
777
|
Values
|
|
523
|
-
<id>
|
|
778
|
+
<id> TAS-12, or just 12. A bare number means the nearest board; a
|
|
779
|
+
prefixed id routes to whichever board in the repo owns that
|
|
780
|
+
prefix, so TAS-12 works from anywhere in a monorepo
|
|
524
781
|
status ${STATUSES.join(" ")}
|
|
525
782
|
--tag a,b matches a task carrying *either* tag
|
|
526
783
|
--needs-human this can't be finished by an agent alone
|
|
527
|
-
|
|
784
|
+
--pr <url> attach a pull request (appends); --prs replaces the whole list
|
|
785
|
+
clearing --tags "" drops all tags, --milestone "" clears it, --prs ""
|
|
786
|
+
detaches all PRs
|
|
528
787
|
|
|
529
788
|
Comment authors resolve --author → $TASK_AUTHOR → git config user.name →
|
|
530
789
|
anonymous, so there is nothing to set up. \`task whoami\` shows which one won.
|
|
@@ -561,12 +820,24 @@ function main() {
|
|
|
561
820
|
return cmdUpdate(args, "in_progress");
|
|
562
821
|
case "done":
|
|
563
822
|
return cmdUpdate(args, "done");
|
|
823
|
+
case "link":
|
|
824
|
+
return cmdLink(args, "link");
|
|
825
|
+
case "unlink":
|
|
826
|
+
return cmdLink(args, "unlink");
|
|
564
827
|
case "comment":
|
|
565
828
|
return cmdComment(args);
|
|
566
829
|
case "whoami":
|
|
567
830
|
return cmdWhoami(args);
|
|
568
831
|
case "delete":
|
|
569
832
|
return cmdDelete(args);
|
|
833
|
+
case "boards":
|
|
834
|
+
return cmdBoards(args);
|
|
835
|
+
case "archive":
|
|
836
|
+
return cmdArchive(args);
|
|
837
|
+
case "unarchive":
|
|
838
|
+
return cmdUnarchive(args);
|
|
839
|
+
case "migrate":
|
|
840
|
+
return cmdMigrate(args);
|
|
570
841
|
case "serve":
|
|
571
842
|
return cmdServe(args);
|
|
572
843
|
case "publish":
|