@nickmeriano/task 0.5.0 → 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 +63 -8
  2. package/dist/cli.js +220 -17
  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 +11 -7
  14. package/dist/server.js.map +1 -1
  15. package/dist/store.d.ts +55 -15
  16. package/dist/store.d.ts.map +1 -1
  17. package/dist/store.js +63 -29
  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 +14 -1
  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 +23 -8
  32. package/src/cli.ts +236 -20
  33. package/src/file-store.ts +693 -0
  34. package/src/index.ts +30 -4
  35. package/src/server.ts +15 -11
  36. package/src/store.test.ts +305 -0
  37. package/src/store.ts +99 -40
  38. package/src/ticket-doc.ts +226 -0
  39. package/src/types.ts +14 -1
  40. package/ui/dist/assets/index-CXW8uT5f.css +1 -0
  41. package/ui/dist/assets/{index-D_qmmh3D.js → index-oJzomUDL.js} +58 -58
  42. package/ui/dist/index.html +2 -2
  43. package/ui/dist/assets/index-Da14ye1f.css +0 -1
package/skill/SKILL.md CHANGED
@@ -2,7 +2,7 @@
2
2
  name: tasks
3
3
  description: >-
4
4
  Work with this repository's built-in task manager (the `task` CLI — a .task/
5
- directory with a SQLite database, from @nickmeriano/task). Use this
5
+ directory of plain-text tickets, from @nickmeriano/task). Use this
6
6
  skill whenever the user mentions tasks, tickets, issues, todos, backlog,
7
7
  kanban, board, "what's next", "what should I work on", or asks you to plan,
8
8
  track, or report progress on work — and also on your own initiative: when you
@@ -13,11 +13,14 @@ description: >-
13
13
 
14
14
  # tasks — the repo's task manager
15
15
 
16
- This repository tracks its work in `.task/` at the project root: a SQLite
17
- database driven by the `task` CLI. Tasks live in the repo, next to the code
18
- they describe — when you update a task, the user sees it instantly (the board
19
- UI at `task serve` updates in realtime), and the state is committed with the
20
- code, so it survives sessions and travels with branches.
16
+ This repository tracks its work in `.task/` at the project root: one markdown
17
+ file per ticket under `.task/tickets/`, driven by the `task` CLI. Tasks live in
18
+ the repo, next to the code they describe — when you update a task, the user
19
+ sees it instantly (the board UI at `task serve` updates in realtime), and the
20
+ state is committed with the code, so it survives sessions, travels with
21
+ branches, and shows up as readable diffs in PRs. Always write through the CLI
22
+ rather than editing ticket files by hand — it keeps numbering, positions and
23
+ relations consistent.
21
24
 
22
25
  Run `task` from anywhere in the repo; it walks up to find `.task/` like git
23
26
  finds `.git`. If the CLI isn't on PATH, use `npx @nickmeriano/task` instead.
@@ -45,6 +48,10 @@ task unlink <id> --blocked-by <id> # remove one (or --blocks)
45
48
  task update <id> --pr <url> # attach a pull request (appends)
46
49
  task comment <id> "text" --author claude
47
50
  task delete <id>
51
+ task boards --json # every board in the repo, with prefixes
52
+ task archive <id> --json # done/canceled ticket -> .task/archive/
53
+ task archive --all --json # archive everything done or canceled
54
+ task unarchive <id> --json # put one back on the board
48
55
  task whoami # who comments are attributed to
49
56
  ```
50
57
 
@@ -53,11 +60,19 @@ the user can read it from a phone, and take it away again. Both open a browser
53
60
  and wait for a human, so **never run either on your own initiative**; suggest
54
61
  `task publish` if the user wants to see the board remotely.
55
62
 
56
- - `<id>` is `PREFIX-12` or just `12`.
63
+ - `<id>` is `PREFIX-12` or just `12`. A bare number means the nearest board;
64
+ a prefixed id routes to whichever board in the repo owns that prefix, so
65
+ `TAS-12` works from any directory. `task boards --json` lists them all.
57
66
  - Statuses: `backlog` `todo` `in_progress` `done` `canceled`.
58
67
  - `--tag a,b` matches a task carrying *either* tag, not both.
59
68
  - Clear a field by passing it empty: `--tags ""`, `--milestone ""`, `--prs ""`.
60
- - `task list` hides done/canceled by default; `--all` shows everything.
69
+ - `task list` hides done/canceled by default; `--all` shows everything, and
70
+ `--archived` lists the archive instead of the board.
71
+ - Archiving is history, not deletion: only done/canceled tickets qualify,
72
+ their numbers stay reserved, and `task show` still reads them. Archived
73
+ tickets refuse edits until unarchived. Don't archive on your own
74
+ initiative — suggest `task archive --all` when finished work is piling up,
75
+ and let the user decide.
61
76
 
62
77
  **Dependencies.** `task link A --blocked-by B` and `task link B --blocks A`
63
78
  write the same relation — each task's `task show` lists both its `blocked`
package/src/cli.ts CHANGED
@@ -1,6 +1,8 @@
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
@@ -13,11 +15,21 @@ import { readFileSync } from "node:fs"
13
15
  import type { Server } from "node:http"
14
16
  import { basename, join } from "node:path"
15
17
  import process from "node:process"
16
- import { resolveAuthor } from "./author.js"
17
- import { detectRepo, parseSlug, publish, resolveHost } from "./publish.js"
18
- import { createTaskServer } from "./server.js"
19
- import { CONFIG_FILE, TASK_DIR, TaskStore, findBoards, findRoot, initProject } from "./store.js"
20
- import { STATUSES, isStatus, type Status, type Task, type TaskPatch } from "./types.js"
18
+ import { resolveAuthor } from "./author.ts"
19
+ import { detectRepo, parseSlug, publish, resolveHost } from "./publish.ts"
20
+ import { createTaskServer } from "./server.ts"
21
+ import { initProject, migrateBoard, openBoard } from "./file-store.ts"
22
+ import {
23
+ CONFIG_FILE,
24
+ TASK_DIR,
25
+ boardConfig,
26
+ findBoards,
27
+ findBoardsByPrefix,
28
+ findRoot,
29
+ findScopeRoot,
30
+ type Store,
31
+ } from "./store.ts"
32
+ import { STATUSES, isStatus, type Status, type Task, type TaskPatch } from "./types.ts"
21
33
 
22
34
  // node:sqlite still emits an ExperimentalWarning on Node 22 — noise in a CLI
23
35
  // that runs it on every invocation. Filter that one warning, keep the rest.
@@ -62,6 +74,7 @@ const BOOLEAN_FLAGS = new Set([
62
74
  "yes",
63
75
  "needs-human",
64
76
  "no-needs-human",
77
+ "archived",
65
78
  "open",
66
79
  "no-open",
67
80
  "strict-port",
@@ -102,10 +115,68 @@ function fail(message: string): never {
102
115
  process.exit(1)
103
116
  }
104
117
 
105
- function openStore(): TaskStore {
118
+ function openStore(): Store {
106
119
  const root = findRoot(process.cwd())
107
120
  if (!root) fail("no .task directory found in this directory or any parent — run `task init` first")
108
- return new TaskStore(root)
121
+ return openBoard(root)
122
+ }
123
+
124
+ /** The prefix a ref carries, if any: "TAS-12" → "TAS", "12" → null. */
125
+ function refPrefix(ref: string): string | null {
126
+ const match = /^([A-Za-z0-9]+)-\d+$/.exec(ref.trim())
127
+ return match ? match[1].toUpperCase() : null
128
+ }
129
+
130
+ /**
131
+ * The board a ref belongs to. A bare number means the nearest board, as ever —
132
+ * but a prefixed id is an address, and it routes: if the prefix isn't the
133
+ * nearest board's, every board in the repo (walking up to the outermost board
134
+ * root, then down) is searched for it, so `task show TAS-12` works from
135
+ * anywhere in a monorepo. What this must never do is what it used to: silently
136
+ * strip a foreign prefix and act on the nearest board's ticket of that number.
137
+ */
138
+ function openStoreFor(ref: string | undefined): Store {
139
+ const prefix = ref ? refPrefix(ref) : null
140
+ if (!prefix) return openStore()
141
+
142
+ const nearest = findRoot(process.cwd())
143
+ if (nearest) {
144
+ const store = openBoard(nearest)
145
+ if (store.config.prefix.toUpperCase() === prefix) return store
146
+ store.close()
147
+ }
148
+
149
+ const scope = findScopeRoot(process.cwd())
150
+ const matches = findBoardsByPrefix(scope, prefix)
151
+ if (matches.length === 1) return openBoard(matches[0].root)
152
+ if (matches.length > 1) {
153
+ fail(`prefix ${prefix} is ambiguous — boards at: ${matches.map((m) => m.id).join(", ")}`)
154
+ }
155
+ const known = findBoards(scope)
156
+ .map((b) => {
157
+ try {
158
+ return `${boardConfig(b.root).prefix} (${b.id})`
159
+ } catch {
160
+ return null
161
+ }
162
+ })
163
+ .filter(Boolean)
164
+ fail(
165
+ `no board with prefix ${prefix} in this repo${known.length ? ` — boards here: ${known.join(", ")}` : ""}`,
166
+ )
167
+ }
168
+
169
+ /**
170
+ * Parse a ref against an already-chosen board, refusing a foreign prefix
171
+ * instead of reinterpreting it — this guards the second id in `task link`,
172
+ * where the board was picked by the first.
173
+ */
174
+ function parseRefOn(store: Store, ref: string): number {
175
+ const prefix = refPrefix(ref)
176
+ if (prefix && prefix !== store.config.prefix.toUpperCase()) {
177
+ fail(`${ref} is not on the ${store.config.prefix} board — links can't cross boards`)
178
+ }
179
+ return store.parseId(ref)
109
180
  }
110
181
 
111
182
  // ── Input normalization ──────────────────────────────────────────────────────
@@ -213,10 +284,13 @@ function cmdAdd(args: Args): void {
213
284
 
214
285
  function cmdList(args: Args): void {
215
286
  const store = openStore()
287
+ const archived = Boolean(args.flags.archived)
216
288
  const statusFlag = str(args.flags, "status")
217
289
  const statuses = statusFlag
218
290
  ? statusFlag.split(",").map(parseStatus)
219
- : args.flags.all
291
+ : // The archive is all done/canceled, and --all already means everything —
292
+ // the open-tickets default only applies to the plain board listing.
293
+ args.flags.all || archived
220
294
  ? undefined
221
295
  : (["backlog", "todo", "in_progress"] as Status[])
222
296
  const tagFlag = str(args.flags, "tags") ?? str(args.flags, "tag")
@@ -225,6 +299,7 @@ function cmdList(args: Args): void {
225
299
  tags: tagFlag ? parseTags(tagFlag) : undefined,
226
300
  milestone: str(args.flags, "milestone"),
227
301
  needsHuman: args.flags["needs-human"] ? true : undefined,
302
+ archived: archived || undefined,
228
303
  })
229
304
  // Present in board order: grouped by status column, then position.
230
305
  const order = new Map(STATUSES.map((s, i) => [s, i]))
@@ -232,16 +307,109 @@ function cmdList(args: Args): void {
232
307
  if (args.flags.json) {
233
308
  console.log(JSON.stringify({ tasks }, null, 2))
234
309
  } else if (tasks.length === 0) {
235
- console.log(args.flags.all ? "no tasks" : "no open tasks (--all includes done/canceled)")
310
+ console.log(
311
+ archived
312
+ ? "no archived tasks"
313
+ : args.flags.all
314
+ ? "no tasks"
315
+ : "no open tasks (--all includes done/canceled)",
316
+ )
236
317
  } else {
237
318
  table(tasks.map(taskRow))
238
319
  }
239
320
  }
240
321
 
322
+ /**
323
+ * `task boards` — every board in this repo, found the way `task serve` finds
324
+ * them but anchored at the *outermost* board root, so it answers from anywhere
325
+ * in a monorepo. The `*` marks the board the other commands would target from
326
+ * here.
327
+ */
328
+ function cmdBoards(args: Args): void {
329
+ const cwd = process.cwd()
330
+ const scope = findScopeRoot(cwd)
331
+ const boards = findBoards(scope)
332
+ if (boards.length === 0) {
333
+ fail("no .task directory found in this directory, any parent, or below — run `task init` first")
334
+ }
335
+ const nearest = findRoot(cwd)
336
+ const rows = boards.map((ref) => {
337
+ const store = openBoard(ref.root)
338
+ const open = store.list({ statuses: ["backlog", "todo", "in_progress"] }).length
339
+ store.close()
340
+ return {
341
+ id: ref.id,
342
+ name: store.config.name,
343
+ prefix: store.config.prefix,
344
+ open,
345
+ current: ref.root === nearest,
346
+ }
347
+ })
348
+ if (args.flags.json) {
349
+ console.log(JSON.stringify({ boards: rows }, null, 2))
350
+ } else {
351
+ table(
352
+ rows.map((b) => [
353
+ b.current ? "*" : "",
354
+ b.prefix,
355
+ b.name,
356
+ b.id,
357
+ `${b.open} open`,
358
+ ]),
359
+ )
360
+ }
361
+ }
362
+
363
+ /**
364
+ * `task archive <id>` / `task archive --all` — move finished tickets to
365
+ * `.task/archive/`, out of the board and off every hot path. History, not a
366
+ * hiding place: only done/canceled tickets qualify, everything stays readable
367
+ * via `show` and `list --archived`, and `task unarchive` puts one back.
368
+ */
369
+ function cmdArchive(args: Args): void {
370
+ const ref = args.positional[0]
371
+ if (args.flags.all && ref) fail("pass an id or --all, not both")
372
+ if (!args.flags.all && !ref) fail("usage: task archive <id> | task archive --all")
373
+
374
+ if (ref) {
375
+ const store = openStoreFor(ref)
376
+ const task = store.archive(store.parseId(ref))
377
+ if (args.flags.json) {
378
+ console.log(JSON.stringify({ task }, null, 2))
379
+ } else {
380
+ console.log(`archived ${task.id} ${task.title}`)
381
+ }
382
+ return
383
+ }
384
+
385
+ const store = openStore()
386
+ const finished = store.list({ statuses: ["done", "canceled"] })
387
+ const archived = finished.map((t) => store.archive(t.number))
388
+ if (args.flags.json) {
389
+ console.log(JSON.stringify({ archived }, null, 2))
390
+ } else if (archived.length === 0) {
391
+ console.log("nothing to archive — no done or canceled tasks on the board")
392
+ } else {
393
+ for (const task of archived) console.log(`archived ${task.id} ${task.title}`)
394
+ }
395
+ }
396
+
397
+ function cmdUnarchive(args: Args): void {
398
+ const ref = args.positional[0]
399
+ if (!ref) fail("usage: task unarchive <id>")
400
+ const store = openStoreFor(ref)
401
+ const task = store.unarchive(store.parseId(ref))
402
+ if (args.flags.json) {
403
+ console.log(JSON.stringify({ task }, null, 2))
404
+ } else {
405
+ printTask(task)
406
+ }
407
+ }
408
+
241
409
  function cmdShow(args: Args): void {
242
410
  const ref = args.positional[0]
243
411
  if (!ref) fail("usage: task show <id>")
244
- const store = openStore()
412
+ const store = openStoreFor(ref)
245
413
  const number = store.parseId(ref)
246
414
  const task = store.get(number)
247
415
  if (!task) fail(`no such task: ${store.displayId(number)}`)
@@ -260,6 +428,7 @@ function cmdShow(args: Args): void {
260
428
  .join(", ")
261
429
  console.log(`${task.id} ${task.title}`)
262
430
  console.log(`status ${task.status}`)
431
+ if (task.archived) console.log(`archived yes — \`task unarchive ${task.id}\` to edit`)
263
432
  if (task.needsHuman) console.log(`needs a human`)
264
433
  if (task.tags.length) console.log(`tags ${task.tags.join(", ")}`)
265
434
  if (task.milestone) console.log(`milestone ${task.milestone}`)
@@ -281,7 +450,7 @@ function cmdShow(args: Args): void {
281
450
  function cmdUpdate(args: Args, forcedStatus?: Status): void {
282
451
  const ref = args.positional[0]
283
452
  if (!ref) fail("usage: task update <id> [--status …] [--title …] …")
284
- const store = openStore()
453
+ const store = openStoreFor(ref)
285
454
  const number = store.parseId(ref)
286
455
  const patch = patchFromFlags(args.flags)
287
456
  if (forcedStatus) patch.status = forcedStatus
@@ -310,7 +479,7 @@ function cmdComment(args: Args): void {
310
479
  const [ref, ...rest] = args.positional
311
480
  const body = rest.join(" ").trim()
312
481
  if (!ref || !body) fail(`usage: task comment <id> <text> [--author <who>]`)
313
- const store = openStore()
482
+ const store = openStoreFor(ref)
314
483
  const author = resolveAuthor(str(args.flags, "author")).name
315
484
  const comment = store.addComment(store.parseId(ref), body, author)
316
485
  if (args.flags.json) {
@@ -332,9 +501,9 @@ function cmdLink(args: Args, action: "link" | "unlink"): void {
332
501
  if (!ref || (blocks ? blockedBy : !blockedBy)) {
333
502
  fail(`usage: task ${action} <id> (--blocks <id> | --blocked-by <id>)`)
334
503
  }
335
- const store = openStore()
504
+ const store = openStoreFor(ref)
336
505
  const relation = blocks ? "blocks" : "blocked_by"
337
- const target = store.parseId((blocks ?? blockedBy)!)
506
+ const target = parseRefOn(store, (blocks ?? blockedBy)!)
338
507
  const number = store.parseId(ref)
339
508
  const task =
340
509
  action === "link" ? store.link(number, relation, target) : store.unlink(number, relation, target)
@@ -367,10 +536,34 @@ function cmdWhoami(args: Args): void {
367
536
  }
368
537
  }
369
538
 
539
+ /**
540
+ * `task migrate` — from the legacy committed SQLite database to text-canonical
541
+ * storage: one markdown file per ticket and per comment under .task/tickets/,
542
+ * with the database left on disk as an ignored backup. Additive and safe to
543
+ * re-run planning-wise: it refuses to run twice.
544
+ */
545
+ function cmdMigrate(args: Args): void {
546
+ const root = findRoot(process.cwd())
547
+ if (!root) fail("no .task directory found in this directory or any parent — run `task init` first")
548
+ const result = migrateBoard(root)
549
+ if (args.flags.json) {
550
+ console.log(JSON.stringify({ migrated: result }, null, 2))
551
+ return
552
+ }
553
+ console.log(
554
+ `Migrated ${result.tasks} task${result.tasks === 1 ? "" : "s"} and ${result.comments} comment${result.comments === 1 ? "" : "s"} to .task/tickets/`,
555
+ )
556
+ console.log(`tasks.db stays on disk as a backup, but it's ignored now — the files are the state.`)
557
+ console.log(``)
558
+ console.log(`Next:`)
559
+ console.log(` git rm --cached ${join(TASK_DIR, "tasks.db")} stop tracking the database`)
560
+ console.log(` git add ${TASK_DIR} commit the tickets`)
561
+ }
562
+
370
563
  function cmdDelete(args: Args): void {
371
564
  const ref = args.positional[0]
372
565
  if (!ref) fail("usage: task delete <id>")
373
- const store = openStore()
566
+ const store = openStoreFor(ref)
374
567
  const number = store.parseId(ref)
375
568
  store.delete(number)
376
569
  console.log(`deleted ${store.displayId(number)}`)
@@ -560,8 +753,10 @@ function runPublishFlow(
560
753
 
561
754
  const HELP = `task — a task manager that lives in your repo
562
755
 
563
- State is a SQLite database in .task/ at the project root. Commit it: tasks and
564
- their status travel with the code they describe. Any command works from any
756
+ State is plain text in .task/ at the project root one markdown file per
757
+ ticket and per comment under .task/tickets/. Commit it: tasks travel with the
758
+ code they describe, ticket changes show up as readable diffs in PRs, and two
759
+ branches editing different tickets merge cleanly. Any command works from any
565
760
  subdirectory (it walks up to find .task/, like git).
566
761
 
567
762
  Usage
@@ -569,7 +764,7 @@ Usage
569
764
  task add <title> [--description <text>] [--status <s>] [--tags <a,b>]
570
765
  [--milestone <m>] [--needs-human]
571
766
  task list [--status <s1,s2>] [--tag <a,b>] [--milestone <m>]
572
- [--needs-human] [--all]
767
+ [--needs-human] [--all] [--archived]
573
768
  task show <id>
574
769
  task update <id> [--title <t>] [--description <text>] [--status <s>]
575
770
  [--tags <a,b>] [--milestone <m>]
@@ -584,6 +779,14 @@ Usage
584
779
  task unlink <id> (--blocks <id> | --blocked-by <id>)
585
780
  task comment <id> <text> [--author <who>]
586
781
  task delete <id>
782
+ task boards every board in this repo — prefix, name, path,
783
+ open count; * marks the one commands target here
784
+ task archive <id> move a done/canceled ticket to .task/archive/,
785
+ out of the board and off the hot path — still
786
+ readable via show and list --archived, and its
787
+ number stays reserved
788
+ task archive --all archive everything done or canceled
789
+ task unarchive <id> put an archived ticket back on the board
587
790
  task whoami who your comments are attributed to
588
791
  task serve [--port <n>] [--no-open] [--strict-port]
589
792
  board + table UI with live updates, opened in
@@ -602,9 +805,14 @@ Usage
602
805
  task unpublish [--repo <owner/name>]
603
806
  take that URL down. Removes the board, not the
604
807
  tasks — those are in .task/ either way
808
+ task migrate move a pre-0.6 board off its committed SQLite
809
+ database and onto text files in .task/tickets/.
810
+ The database stays on disk as an ignored backup
605
811
 
606
812
  Values
607
- <id> TAS-12, or just 12
813
+ <id> TAS-12, or just 12. A bare number means the nearest board; a
814
+ prefixed id routes to whichever board in the repo owns that
815
+ prefix, so TAS-12 works from anywhere in a monorepo
608
816
  status ${STATUSES.join(" ")}
609
817
  --tag a,b matches a task carrying *either* tag
610
818
  --needs-human this can't be finished by an agent alone
@@ -660,6 +868,14 @@ function main(): void | Promise<void> {
660
868
  return cmdWhoami(args)
661
869
  case "delete":
662
870
  return cmdDelete(args)
871
+ case "boards":
872
+ return cmdBoards(args)
873
+ case "archive":
874
+ return cmdArchive(args)
875
+ case "unarchive":
876
+ return cmdUnarchive(args)
877
+ case "migrate":
878
+ return cmdMigrate(args)
663
879
  case "serve":
664
880
  return cmdServe(args)
665
881
  case "publish":