@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.
Files changed (43) hide show
  1. package/README.md +77 -9
  2. package/dist/cli.js +290 -19
  3. package/dist/cli.js.map +1 -1
  4. package/dist/file-store.d.ts +113 -0
  5. package/dist/file-store.d.ts.map +1 -0
  6. package/dist/file-store.js +604 -0
  7. package/dist/file-store.js.map +1 -0
  8. package/dist/index.d.ts +6 -4
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +3 -1
  11. package/dist/index.js.map +1 -1
  12. package/dist/server.d.ts.map +1 -1
  13. package/dist/server.js +30 -7
  14. package/dist/server.js.map +1 -1
  15. package/dist/store.d.ts +69 -16
  16. package/dist/store.d.ts.map +1 -1
  17. package/dist/store.js +169 -39
  18. package/dist/store.js.map +1 -1
  19. package/dist/store.test.d.ts +12 -0
  20. package/dist/store.test.d.ts.map +1 -0
  21. package/dist/store.test.js +252 -0
  22. package/dist/store.test.js.map +1 -0
  23. package/dist/ticket-doc.d.ts +57 -0
  24. package/dist/ticket-doc.d.ts.map +1 -0
  25. package/dist/ticket-doc.js +197 -0
  26. package/dist/ticket-doc.js.map +1 -0
  27. package/dist/types.d.ts +35 -3
  28. package/dist/types.d.ts.map +1 -1
  29. package/dist/types.js.map +1 -1
  30. package/package.json +4 -4
  31. package/skill/SKILL.md +40 -9
  32. package/src/cli.ts +304 -22
  33. package/src/file-store.ts +693 -0
  34. package/src/index.ts +30 -4
  35. package/src/server.ts +31 -11
  36. package/src/store.test.ts +305 -0
  37. package/src/store.ts +210 -49
  38. package/src/ticket-doc.ts +226 -0
  39. package/src/types.ts +35 -3
  40. package/ui/dist/assets/index-CXW8uT5f.css +1 -0
  41. package/ui/dist/assets/{index-Dm3ToURf.js → index-oJzomUDL.js} +67 -67
  42. package/ui/dist/index.html +2 -2
  43. 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 a SQLite database in `.task/` at your project root: no accounts, no
7
- connectors, no drift between the board and the branch. Built for the way
8
- projects work now you *and* your AI agents share one backlog, and the board
9
- updates live while agents move tickets from another terminal.
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 comes from `task init --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
- - The database self-migrates when it's opened, so pulling a colleague's
104
- `.task/` and running any command brings it up to date in place.
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 Node's built-in `node:sqlite` (Node
109
- 22.13)nothing to compile, so `npx` starts in about a second.
172
+ - **Zero dependencies.** Storage is markdown files with a tiny frontmatter
173
+ blockreviewable 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, node:sqlite for storage, plain text out (--json for agents).
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 TASK-3 && task done TASK-3
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 { CONFIG_FILE, TASK_DIR, TaskStore, findBoards, findRoot, initProject } from "./store.js";
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 new TaskStore(root);
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 task = store.create({ title, ...patchFromFlags(args.flags) });
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
- : args.flags.all
196
- ? undefined
197
- : ["backlog", "todo", "in_progress"];
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(args.flags.all ? "no tasks" : "no open tasks (--all includes done/canceled)");
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 = openStore();
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 = openStore();
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(store.parseId(ref), patch);
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 = openStore();
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 = openStore();
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 a SQLite database in .task/ at the project root. Commit it: tasks and
485
- their status travel with the code they describe. Any command works from any
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> TASK-12, or just 12
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
- clearing --tags "" drops all tags, --milestone "" clears it
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":