@nickmeriano/task 0.7.0 → 0.8.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 (86) hide show
  1. package/README.md +12 -7
  2. package/dist/check.d.ts +42 -0
  3. package/dist/check.d.ts.map +1 -0
  4. package/dist/check.js +364 -0
  5. package/dist/check.js.map +1 -0
  6. package/dist/check.test.d.ts +9 -0
  7. package/dist/check.test.d.ts.map +1 -0
  8. package/dist/check.test.js +209 -0
  9. package/dist/check.test.js.map +1 -0
  10. package/dist/claim.d.ts +56 -3
  11. package/dist/claim.d.ts.map +1 -1
  12. package/dist/claim.js +164 -9
  13. package/dist/claim.js.map +1 -1
  14. package/dist/claim.test.js +168 -14
  15. package/dist/claim.test.js.map +1 -1
  16. package/dist/cli.js +504 -93
  17. package/dist/cli.js.map +1 -1
  18. package/dist/file-store.d.ts +41 -15
  19. package/dist/file-store.d.ts.map +1 -1
  20. package/dist/file-store.js +197 -99
  21. package/dist/file-store.js.map +1 -1
  22. package/dist/index.d.ts +5 -3
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +5 -3
  25. package/dist/index.js.map +1 -1
  26. package/dist/overview.d.ts +43 -0
  27. package/dist/overview.d.ts.map +1 -0
  28. package/dist/overview.js +53 -0
  29. package/dist/overview.js.map +1 -0
  30. package/dist/overview.test.d.ts +8 -0
  31. package/dist/overview.test.d.ts.map +1 -0
  32. package/dist/overview.test.js +47 -0
  33. package/dist/overview.test.js.map +1 -0
  34. package/dist/promote.test.d.ts +14 -0
  35. package/dist/promote.test.d.ts.map +1 -0
  36. package/dist/promote.test.js +106 -0
  37. package/dist/promote.test.js.map +1 -0
  38. package/dist/search.d.ts +32 -0
  39. package/dist/search.d.ts.map +1 -0
  40. package/dist/search.js +66 -0
  41. package/dist/search.js.map +1 -0
  42. package/dist/search.test.d.ts +2 -0
  43. package/dist/search.test.d.ts.map +1 -0
  44. package/dist/search.test.js +53 -0
  45. package/dist/search.test.js.map +1 -0
  46. package/dist/server.d.ts.map +1 -1
  47. package/dist/server.js +10 -2
  48. package/dist/server.js.map +1 -1
  49. package/dist/store.d.ts +16 -50
  50. package/dist/store.d.ts.map +1 -1
  51. package/dist/store.js +0 -368
  52. package/dist/store.js.map +1 -1
  53. package/dist/store.test.d.ts +1 -2
  54. package/dist/store.test.d.ts.map +1 -1
  55. package/dist/store.test.js +77 -48
  56. package/dist/store.test.js.map +1 -1
  57. package/dist/ticket-doc.d.ts +55 -2
  58. package/dist/ticket-doc.d.ts.map +1 -1
  59. package/dist/ticket-doc.js +177 -7
  60. package/dist/ticket-doc.js.map +1 -1
  61. package/dist/types.d.ts +67 -14
  62. package/dist/types.d.ts.map +1 -1
  63. package/dist/types.js.map +1 -1
  64. package/package.json +1 -1
  65. package/skill/SKILL.md +97 -31
  66. package/src/check.test.ts +271 -0
  67. package/src/check.ts +454 -0
  68. package/src/claim.test.ts +202 -14
  69. package/src/claim.ts +194 -10
  70. package/src/cli.ts +510 -89
  71. package/src/file-store.ts +217 -111
  72. package/src/index.ts +4 -2
  73. package/src/overview.test.ts +51 -0
  74. package/src/overview.ts +90 -0
  75. package/src/promote.test.ts +131 -0
  76. package/src/search.test.ts +64 -0
  77. package/src/search.ts +91 -0
  78. package/src/server.ts +11 -2
  79. package/src/store.test.ts +89 -57
  80. package/src/store.ts +18 -431
  81. package/src/ticket-doc.ts +210 -10
  82. package/src/types.ts +71 -14
  83. package/ui/dist/assets/{index-oJzomUDL.js → index-BJmOsOdR.js} +76 -76
  84. package/ui/dist/assets/index-BoqQlqSU.css +1 -0
  85. package/ui/dist/index.html +2 -2
  86. package/ui/dist/assets/index-CXW8uT5f.css +0 -1
package/src/cli.ts CHANGED
@@ -1,13 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
  // The `task` CLI — tasks that live in your repo. Zero dependencies: hand-rolled
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).
3
+ // flag parsing, plain-text tickets in .task/tickets/ for storage, plain text
4
+ // out (--json for agents).
6
5
  //
7
6
  // task init
8
- // task add "Wire up webhooks" --tags api,infra --milestone launch
7
+ // task add "Wire up webhooks" --tags api,infra --goal launch
9
8
  // task list --status todo,in_progress
10
- // task start TAS-3 && task done TAS-3
9
+ // task move TAS-3 done
11
10
  // task serve
12
11
 
13
12
  import { spawn } from "node:child_process"
@@ -16,10 +15,21 @@ import type { Server } from "node:http"
16
15
  import { basename, join } from "node:path"
17
16
  import process from "node:process"
18
17
  import { resolveAuthor } from "./author.ts"
19
- import { ClaimError, claim, claimableTasks, release } from "./claim.ts"
18
+ import { checkBoards, checkRoot } from "./check.ts"
19
+ import {
20
+ ClaimError,
21
+ claim,
22
+ claimNext,
23
+ claimableTasks,
24
+ promote,
25
+ release,
26
+ selectionBoards,
27
+ } from "./claim.ts"
28
+ import { STALE_DAYS, buildOverview } from "./overview.ts"
29
+ import { searchStore } from "./search.ts"
20
30
  import { detectRepo, parseSlug, publish, resolveHost } from "./publish.ts"
21
31
  import { createTaskServer } from "./server.ts"
22
- import { initProject, migrateBoard, openBoard } from "./file-store.ts"
32
+ import { initProject, openBoard } from "./file-store.ts"
23
33
  import {
24
34
  CONFIG_FILE,
25
35
  TASK_DIR,
@@ -32,13 +42,6 @@ import {
32
42
  } from "./store.ts"
33
43
  import { STATUSES, isStatus, type Status, type Task, type TaskPatch } from "./types.ts"
34
44
 
35
- // node:sqlite still emits an ExperimentalWarning on Node 22 — noise in a CLI
36
- // that runs it on every invocation. Filter that one warning, keep the rest.
37
- process.removeAllListeners("warning")
38
- process.on("warning", (warning) => {
39
- if (warning.name !== "ExperimentalWarning") console.error(warning)
40
- })
41
-
42
45
  /**
43
46
  * Read from package.json rather than duplicated as a literal. Releases here are
44
47
  * cut by changesets, which bumps package.json and nothing else — so a hardcoded
@@ -84,8 +87,18 @@ const BOOLEAN_FLAGS = new Set([
84
87
  "public",
85
88
  "private",
86
89
  "no-wait",
90
+ "fix",
91
+ "next",
92
+ "force",
87
93
  ])
88
94
 
95
+ /**
96
+ * Value flags that may repeat — `--board NIC --board TAS` accumulates in
97
+ * given order, equivalent to `--board NIC,TAS`. Everything else keeps
98
+ * last-one-wins.
99
+ */
100
+ const REPEATABLE_FLAGS = new Set(["board"])
101
+
89
102
  function parseArgs(argv: string[]): Args {
90
103
  const positional: string[] = []
91
104
  const flags: Record<string, string | boolean> = {}
@@ -93,13 +106,18 @@ function parseArgs(argv: string[]): Args {
93
106
  const arg = argv[i]
94
107
  if (arg.startsWith("--")) {
95
108
  const name = arg.slice(2)
109
+ const set = (key: string, value: string): void => {
110
+ const prior = flags[key]
111
+ flags[key] =
112
+ REPEATABLE_FLAGS.has(key) && typeof prior === "string" ? `${prior},${value}` : value
113
+ }
96
114
  const eq = name.indexOf("=")
97
115
  if (eq >= 0) {
98
- flags[name.slice(0, eq)] = name.slice(eq + 1)
116
+ set(name.slice(0, eq), name.slice(eq + 1))
99
117
  } else if (BOOLEAN_FLAGS.has(name) || i + 1 >= argv.length || argv[i + 1].startsWith("--")) {
100
118
  flags[name] = true
101
119
  } else {
102
- flags[name] = argv[++i]
120
+ set(name, argv[++i])
103
121
  }
104
122
  } else {
105
123
  positional.push(arg)
@@ -199,13 +217,27 @@ function patchFromFlags(flags: Args["flags"]): TaskPatch {
199
217
  const title = str(flags, "title")
200
218
  if (title !== undefined) patch.title = title
201
219
  const description = str(flags, "description") ?? str(flags, "desc")
220
+ // Long markdown doesn't survive shell quoting — `--description-file` keeps
221
+ // agents writing plans and specs on the CLI path instead of hand-editing
222
+ // ticket files. `-` reads stdin.
223
+ const descriptionFile = str(flags, "description-file")
224
+ if (description !== undefined && descriptionFile !== undefined) {
225
+ fail("pass --description or --description-file, not both")
226
+ }
202
227
  if (description !== undefined) patch.description = description
228
+ if (descriptionFile !== undefined) {
229
+ try {
230
+ patch.description = readFileSync(descriptionFile === "-" ? 0 : descriptionFile, "utf8")
231
+ } catch {
232
+ fail(`--description-file: can't read ${descriptionFile === "-" ? "stdin" : descriptionFile}`)
233
+ }
234
+ }
203
235
  const status = str(flags, "status")
204
236
  if (status !== undefined) patch.status = parseStatus(status)
205
237
  const tags = str(flags, "tags") ?? str(flags, "tag")
206
238
  if (tags !== undefined) patch.tags = parseTags(tags)
207
- const milestone = str(flags, "milestone")
208
- if (milestone !== undefined) patch.milestone = milestone.trim() || null
239
+ const goal = str(flags, "goal")
240
+ if (goal !== undefined) patch.goal = goal.trim() || null
209
241
  if (flags["needs-human"]) patch.needsHuman = true
210
242
  if (flags["no-needs-human"]) patch.needsHuman = false
211
243
  // Replace semantics like --tags; `--pr` (append one) is handled per-command
@@ -241,7 +273,7 @@ function taskRow(t: Task): string[] {
241
273
  t.needsHuman ? "⚑" : "",
242
274
  t.title,
243
275
  t.tags.join(","),
244
- t.milestone ?? "",
276
+ t.goal ?? "",
245
277
  ]
246
278
  }
247
279
 
@@ -272,7 +304,7 @@ function cmdInit(args: Args): void {
272
304
 
273
305
  function cmdAdd(args: Args): void {
274
306
  const title = args.positional.join(" ").trim()
275
- if (!title) fail(`usage: task add <title> [--description …] [--status …] [--tags a,b] [--milestone ] [--needs-human]`)
307
+ if (!title) fail(`usage: task add <title> [--description …] [--status …] [--tags a,b] [--goal <slug>] [--needs-human]`)
276
308
  const store = openStore()
277
309
  const patch = patchFromFlags(args.flags)
278
310
  const pr = str(args.flags, "pr")
@@ -285,15 +317,31 @@ function cmdAdd(args: Args): void {
285
317
  }
286
318
  }
287
319
 
320
+ /** `--board NIC --board TAS` (or `--board NIC,TAS`) → ["NIC", "TAS"]. */
321
+ function boardFlag(args: Args): string[] | undefined {
322
+ const raw = str(args.flags, "board")
323
+ if (raw === undefined) return undefined
324
+ const prefixes = raw.split(",").map((p) => p.trim()).filter(Boolean)
325
+ if (prefixes.length === 0) fail("--board needs a prefix, e.g. --board TAS")
326
+ return prefixes
327
+ }
328
+
288
329
  function cmdList(args: Args): void {
289
- const store = openStore()
290
330
  if (args.flags.claimable) {
291
331
  // The dispatcher's queue view (TAS-21): claimable already pins the status
292
332
  // set and consults origin, so the board-shaping flags don't compose.
293
333
  for (const flag of ["status", "all", "archived"]) {
294
334
  if (args.flags[flag] !== undefined) fail(`--claimable can't be combined with --${flag}`)
295
335
  }
296
- const tasks = claimableTasks(store)
336
+ // Selection scope, same resolution as `claim --next`: --board flag > root
337
+ // board's `boards` list > nearest board. Board order is priority order.
338
+ let tasks: Task[]
339
+ try {
340
+ tasks = selectionBoards(process.cwd(), boardFlag(args)).flatMap((s) => claimableTasks(s))
341
+ } catch (error) {
342
+ if (error instanceof ClaimError) fail(error.message)
343
+ throw error
344
+ }
297
345
  if (args.flags.json) {
298
346
  console.log(JSON.stringify({ tasks }, null, 2))
299
347
  } else if (tasks.length === 0) {
@@ -303,6 +351,8 @@ function cmdList(args: Args): void {
303
351
  }
304
352
  return
305
353
  }
354
+ if (args.flags.board !== undefined) fail("--board only applies to list --claimable")
355
+ const store = openStore()
306
356
  const archived = Boolean(args.flags.archived)
307
357
  const statusFlag = str(args.flags, "status")
308
358
  const statuses = statusFlag
@@ -316,7 +366,7 @@ function cmdList(args: Args): void {
316
366
  const tasks = store.list({
317
367
  statuses,
318
368
  tags: tagFlag ? parseTags(tagFlag) : undefined,
319
- milestone: str(args.flags, "milestone"),
369
+ goal: str(args.flags, "goal"),
320
370
  needsHuman: args.flags["needs-human"] ? true : undefined,
321
371
  archived: archived || undefined,
322
372
  })
@@ -338,6 +388,53 @@ function cmdList(args: Args): void {
338
388
  }
339
389
  }
340
390
 
391
+ /**
392
+ * `task search <query>` — find tickets by what they say. Case-insensitive
393
+ * substring over titles, descriptions and comments, scoped by the usual list
394
+ * filters; `--archived` searches the archive *in addition to* the board (a
395
+ * search is a question about everything ever written down, unlike
396
+ * `list --archived`, which is a view of one place).
397
+ */
398
+ function cmdSearch(args: Args): void {
399
+ const query = args.positional.join(" ").trim()
400
+ if (!query) {
401
+ fail(
402
+ "usage: task search <query> [--status <s1,s2>] [--tag <a,b>] [--goal <slug>] [--needs-human] [--archived]",
403
+ )
404
+ }
405
+ const store = openStore()
406
+ const statusFlag = str(args.flags, "status")
407
+ const tagFlag = str(args.flags, "tags") ?? str(args.flags, "tag")
408
+ const results = searchStore(
409
+ store,
410
+ query,
411
+ {
412
+ statuses: statusFlag ? statusFlag.split(",").map(parseStatus) : undefined,
413
+ tags: tagFlag ? parseTags(tagFlag) : undefined,
414
+ goal: str(args.flags, "goal"),
415
+ needsHuman: args.flags["needs-human"] ? true : undefined,
416
+ },
417
+ Boolean(args.flags.archived),
418
+ )
419
+ if (args.flags.json) {
420
+ console.log(JSON.stringify({ query, results }, null, 2))
421
+ return
422
+ }
423
+ if (results.length === 0) {
424
+ console.log(`no matches for "${query}"${args.flags.archived ? "" : " (--archived searches the archive too)"}`)
425
+ return
426
+ }
427
+ for (const { task, matches } of results) {
428
+ console.log(
429
+ `${task.id} ${STATUS_GLYPH[task.status]} ${task.status}${task.archived ? " (archived)" : ""} ${task.title}`,
430
+ )
431
+ for (const match of matches) {
432
+ const label = match.field === "comment" ? `comment${match.author ? ` by ${match.author}` : ""}` : match.field
433
+ console.log(` ${label}: ${match.snippet}`)
434
+ }
435
+ }
436
+ }
437
+
341
438
  /**
342
439
  * `task boards` — every board in this repo, found the way `task serve` finds
343
440
  * them but anchored at the *outermost* board root, so it answers from anywhere
@@ -433,8 +530,12 @@ function cmdShow(args: Args): void {
433
530
  const task = store.get(number)
434
531
  if (!task) fail(`no such task: ${store.displayId(number)}`)
435
532
  const comments = store.comments(number)
533
+ // The goal rides along in full — title *and* description — so an agent
534
+ // picking the ticket up cold inherits the shared context without it being
535
+ // pasted into every ticket. null when unset or when the ref dangles.
536
+ const goal = task.goal ? store.getGoal(task.goal) : null
436
537
  if (args.flags.json) {
437
- console.log(JSON.stringify({ task, comments }, null, 2))
538
+ console.log(JSON.stringify({ task, comments, goal }, null, 2))
438
539
  return
439
540
  }
440
541
  // A linked task is only as useful as knowing whether it's still in the way.
@@ -450,13 +551,19 @@ function cmdShow(args: Args): void {
450
551
  if (task.archived) console.log(`archived yes — \`task unarchive ${task.id}\` to edit`)
451
552
  if (task.needsHuman) console.log(`needs a human`)
452
553
  if (task.tags.length) console.log(`tags ${task.tags.join(", ")}`)
453
- if (task.milestone) console.log(`milestone ${task.milestone}`)
554
+ if (task.goal) {
555
+ console.log(`goal ${task.goal}${goal ? ` — ${goal.title}${goal.archived ? " (archived)" : ""}` : ""}`)
556
+ }
454
557
  if (task.blockedBy.length) console.log(`blocked by ${describeLinks(task.blockedBy)}`)
455
558
  if (task.blocks.length) console.log(`blocks ${describeLinks(task.blocks)}`)
456
559
  for (const pr of task.prs) console.log(`pr ${pr}`)
457
560
  console.log(`created ${task.createdAt}`)
458
561
  console.log(`updated ${task.updatedAt}`)
459
562
  if (task.description) console.log(`\n${task.description}`)
563
+ if (goal?.description) {
564
+ console.log(`\n── goal: ${goal.title} (${goal.slug})`)
565
+ console.log(goal.description.split("\n").map((line) => ` ${line}`).join("\n"))
566
+ }
460
567
  if (comments.length) {
461
568
  console.log(``)
462
569
  for (const c of comments) {
@@ -466,13 +573,12 @@ function cmdShow(args: Args): void {
466
573
  }
467
574
  }
468
575
 
469
- function cmdUpdate(args: Args, forcedStatus?: Status): void {
576
+ function cmdUpdate(args: Args): void {
470
577
  const ref = args.positional[0]
471
578
  if (!ref) fail("usage: task update <id> [--status …] [--title …] …")
472
579
  const store = openStoreFor(ref)
473
580
  const number = store.parseId(ref)
474
581
  const patch = patchFromFlags(args.flags)
475
- if (forcedStatus) patch.status = forcedStatus
476
582
  const pr = str(args.flags, "pr")
477
583
  if (pr) {
478
584
  // Append, dedup — `--pr <url>` is "attach this PR", not "replace the list".
@@ -494,6 +600,34 @@ function cmdMove(args: Args): void {
494
600
  cmdUpdate({ positional: [ref], flags: { ...args.flags, status } })
495
601
  }
496
602
 
603
+ /**
604
+ * `task promote <id>` — backlog → todo, the human "build this" call; see
605
+ * claim.ts for why blocked / needs-human are holds (reported), not refusals.
606
+ * Exit codes follow claim's contract: 0 promoted, 2 refused (fix something).
607
+ */
608
+ function cmdPromote(args: Args): void {
609
+ const ref = args.positional[0]
610
+ if (!ref) fail("usage: task promote <id>")
611
+ const store = openStoreFor(ref)
612
+ try {
613
+ const result = promote(store, store.parseId(ref))
614
+ if (args.flags.json) {
615
+ console.log(JSON.stringify({ task: result.task, holds: result.holds }, null, 2))
616
+ } else {
617
+ printTask(result.task)
618
+ for (const hold of result.holds) {
619
+ console.log(`note: ${hold} — in todo, but not claimable until that clears`)
620
+ }
621
+ }
622
+ } catch (error) {
623
+ if (error instanceof ClaimError) {
624
+ console.error(`error: ${error.message}`)
625
+ process.exit(2)
626
+ }
627
+ throw error
628
+ }
629
+ }
630
+
497
631
  function cmdComment(args: Args): void {
498
632
  const [ref, ...rest] = args.positional
499
633
  const body = rest.join(" ").trim()
@@ -538,35 +672,77 @@ function cmdLink(args: Args, action: "link" | "unlink"): void {
538
672
  }
539
673
  }
540
674
 
675
+ const CLAIM_USAGE =
676
+ 'usage: task claim <id> | task claim --next | task claim --release <id> --comment "<why>"'
677
+
541
678
  /**
542
- * `task claim <id>` / `task claim --release <id>` — see claim.ts for the
543
- * mechanics. Exit codes are the contract callers script against: 0 claimed,
544
- * 1 already claimed (pick the next ticket), 2 not claimable (fix something).
679
+ * `task claim <id>` / `task claim --next` / `task claim --release <id>` — see
680
+ * claim.ts for the mechanics. Exit codes are the contract callers script
681
+ * against: 0 claimed, 1 already claimed / queue empty (nothing to do here
682
+ * a scheduled worker stops cleanly), 2 preconditions failed (fix something).
545
683
  */
546
684
  function cmdClaim(args: Args): void {
547
685
  const ref = args.positional[0]
548
- if (!ref) fail("usage: task claim <id> | task claim --release <id>")
549
- const store = openStoreFor(ref)
550
- const number = store.parseId(ref)
686
+ const options = { force: Boolean(args.flags.force) }
687
+ const printClaim = (result: { task: Task; branch: string; base: string }): void => {
688
+ if (args.flags.json) {
689
+ console.log(JSON.stringify({ task: result.task, branch: result.branch }, null, 2))
690
+ } else {
691
+ console.log(`claimed ${result.task.id} — on ${result.branch} (from ${result.base}), status in_progress`)
692
+ }
693
+ }
551
694
  try {
695
+ if (args.flags.next) {
696
+ // The first line of every scheduled worker: claim the top claimable
697
+ // ticket across the selection scope (--board > root `boards` > nearest),
698
+ // retrying past lost races internally.
699
+ if (ref) fail("pass an id or --next, not both")
700
+ if (args.flags.release) fail(CLAIM_USAGE)
701
+ const result = claimNext(selectionBoards(process.cwd(), boardFlag(args)), options)
702
+ if (!result) {
703
+ if (args.flags.json) {
704
+ console.log(JSON.stringify({ task: null }, null, 2))
705
+ } else {
706
+ console.log("nothing claimable — no unblocked, unclaimed todo tickets in scope")
707
+ }
708
+ process.exit(1)
709
+ }
710
+ printClaim(result)
711
+ return
712
+ }
713
+ if (!ref) fail(CLAIM_USAGE)
714
+ const store = openStoreFor(ref)
715
+ const number = store.parseId(ref)
552
716
  if (args.flags.release) {
717
+ // A released claim must leave its failure context behind: the branch
718
+ // (and whatever was tried on it) is about to evaporate, so the reason
719
+ // is required, and lands as an ordinary attributed comment.
720
+ const why = str(args.flags, "comment")?.trim()
721
+ if (!why) {
722
+ fail(
723
+ 'task claim --release requires --comment "<why>" — the next worker inherits what was tried',
724
+ )
725
+ }
553
726
  const result = release(store, number)
727
+ const commented = result.remote || result.local
728
+ // After the branch deletion, so the comment survives it: release leaves
729
+ // the checkout on the default branch, and the comment file sits there
730
+ // uncommitted, to ride along with the next commit.
731
+ const comment = commented
732
+ ? store.addComment(number, why, resolveAuthor(str(args.flags, "author")).name)
733
+ : null
554
734
  if (args.flags.json) {
555
- console.log(JSON.stringify({ released: result }, null, 2))
556
- } else if (!result.remote && !result.local) {
735
+ console.log(JSON.stringify({ released: result, comment }, null, 2))
736
+ } else if (!commented) {
557
737
  console.log(`${store.displayId(number)} wasn't claimed — no ${result.branch} to delete`)
558
738
  } else {
559
739
  const where = [result.remote && "origin", result.local && "local"].filter(Boolean)
560
740
  console.log(`released ${store.displayId(number)} — deleted ${result.branch} (${where.join(" and ")})`)
741
+ console.log(`left the reason as a comment on ${store.displayId(number)} — uncommitted, commit it with your next change`)
561
742
  }
562
743
  return
563
744
  }
564
- const result = claim(store, number)
565
- if (args.flags.json) {
566
- console.log(JSON.stringify({ task: result.task, branch: result.branch }, null, 2))
567
- } else {
568
- console.log(`claimed ${result.task.id} — on ${result.branch} (from ${result.base}), status in_progress`)
569
- }
745
+ printClaim(claim(store, number, options))
570
746
  } catch (error) {
571
747
  if (error instanceof ClaimError) {
572
748
  console.error(`error: ${error.message}`)
@@ -576,6 +752,161 @@ function cmdClaim(args: Args): void {
576
752
  }
577
753
  }
578
754
 
755
+ /** `task instructions` — the shipped agent conventions, self-served at runtime. */
756
+ function cmdInstructions(): void {
757
+ // skill/ ships in the npm package; `../` from both dist/cli.js and
758
+ // src/cli.ts is the package root, same trick VERSION uses.
759
+ process.stdout.write(readFileSync(new URL("../skill/SKILL.md", import.meta.url), "utf8"))
760
+ }
761
+
762
+ /** `task overview [--json]` — one screen of board health. See overview.ts. */
763
+ function cmdOverview(args: Args): void {
764
+ const overview = buildOverview(openStore())
765
+ if (args.flags.json) {
766
+ console.log(JSON.stringify({ overview }, null, 2))
767
+ return
768
+ }
769
+ console.log(`${overview.board} (${overview.prefix}) — ${overview.open} open`)
770
+ console.log(
771
+ ` ${STATUSES.map((s) => `${STATUS_GLYPH[s]} ${s} ${overview.statuses[s]}`).join(" ")}`,
772
+ )
773
+ const claimed =
774
+ overview.claimed === null
775
+ ? "claimed ? (origin unreachable)"
776
+ : `claimed ${overview.claimed.length}${overview.claimed.length ? ` (${overview.claimed.join(", ")})` : ""}`
777
+ console.log(` ${claimed} needs-human ${overview.needsHuman}`)
778
+ if (overview.goals.length) {
779
+ console.log("goals")
780
+ table(
781
+ overview.goals.map((g) => [
782
+ ` ${g.slug}`,
783
+ g.title,
784
+ `${g.done}/${g.total} done${g.total ? ` (${Math.round((g.done / g.total) * 100)}%)` : ""}`,
785
+ ]),
786
+ )
787
+ }
788
+ if (overview.stale.length) {
789
+ console.log(`stale (untouched ${STALE_DAYS}+ days)`)
790
+ table(overview.stale.map((t) => [` ${t.id}`, t.title, t.updatedAt.slice(0, 10)]))
791
+ }
792
+ if (overview.oldestOpen) {
793
+ console.log(
794
+ `oldest open ${overview.oldestOpen.id} ${overview.oldestOpen.title} (created ${overview.oldestOpen.createdAt.slice(0, 10)})`,
795
+ )
796
+ }
797
+ }
798
+
799
+ /**
800
+ * `task goal …` — the goals a board's tasks belong to. A goal is a titled,
801
+ * described destination (`.task/goals/<slug>.md`); a task carries at most one.
802
+ * There is no goal status anywhere here on purpose: progress is derived from
803
+ * the tasks, and `archive` is the only lifecycle a goal has.
804
+ */
805
+ function cmdGoal(args: Args): void {
806
+ const [sub, ...rest] = args.positional
807
+ const usage =
808
+ "usage: task goal add <title> [--slug <s>] [--description …] | list [--archived] | " +
809
+ "show <slug> | update <slug> [--title …] [--description …] | archive <slug> | " +
810
+ "unarchive <slug> | delete <slug>"
811
+ if (!sub) fail(usage)
812
+ const store = openStore()
813
+
814
+ const slugArg = (): string => {
815
+ const slug = rest[0]
816
+ if (!slug) fail(`usage: task goal ${sub} <slug>`)
817
+ return slug
818
+ }
819
+ const printGoal = (goal: { slug: string; title: string; archived?: boolean }, verb: string) =>
820
+ console.log(`${verb} ${goal.slug} ${goal.title}${goal.archived ? " (archived)" : ""}`)
821
+
822
+ switch (sub) {
823
+ case "add": {
824
+ const title = rest.join(" ").trim()
825
+ if (!title) fail("usage: task goal add <title> [--slug <s>] [--description …]")
826
+ const goal = store.createGoal({
827
+ title,
828
+ slug: str(args.flags, "slug"),
829
+ description: str(args.flags, "description") ?? str(args.flags, "desc"),
830
+ })
831
+ if (args.flags.json) console.log(JSON.stringify({ goal }, null, 2))
832
+ else printGoal(goal, "created")
833
+ return
834
+ }
835
+ case "list": {
836
+ const archived = Boolean(args.flags.archived)
837
+ const goals = store.goals(archived)
838
+ // Progress is derived here, at read time — a goal file never stores it.
839
+ const tasks = store.list({ statuses: undefined })
840
+ const rows = goals.map((goal) => {
841
+ const mine = tasks.filter((t) => t.goal === goal.slug)
842
+ const done = mine.filter((t) => t.status === "done" || t.status === "canceled").length
843
+ return { ...goal, open: mine.length - done, done, total: mine.length }
844
+ })
845
+ if (args.flags.json) {
846
+ console.log(JSON.stringify({ goals: rows }, null, 2))
847
+ } else if (rows.length === 0) {
848
+ console.log(archived ? "no archived goals" : "no goals — create one with `task goal add`")
849
+ } else {
850
+ table(rows.map((g) => [g.slug, g.title, `${g.done}/${g.total} done`]))
851
+ }
852
+ return
853
+ }
854
+ case "show": {
855
+ const slug = slugArg()
856
+ const goal = store.getGoal(slug)
857
+ if (!goal) fail(`no such goal: "${slug}"`)
858
+ const tasks = store.list({ statuses: undefined, goal: slug })
859
+ if (args.flags.json) {
860
+ console.log(JSON.stringify({ goal, tasks }, null, 2))
861
+ return
862
+ }
863
+ console.log(`${goal.slug} ${goal.title}`)
864
+ if (goal.archived) console.log(`archived yes — \`task goal unarchive ${goal.slug}\` to edit`)
865
+ console.log(`created ${goal.createdAt}`)
866
+ console.log(`updated ${goal.updatedAt}`)
867
+ if (goal.description) console.log(`\n${goal.description}`)
868
+ if (tasks.length) {
869
+ console.log("")
870
+ table(tasks.map(taskRow))
871
+ }
872
+ return
873
+ }
874
+ case "update": {
875
+ const patch = {
876
+ ...(str(args.flags, "title") !== undefined && { title: str(args.flags, "title") }),
877
+ ...((str(args.flags, "description") ?? str(args.flags, "desc")) !== undefined && {
878
+ description: str(args.flags, "description") ?? str(args.flags, "desc"),
879
+ }),
880
+ }
881
+ if (Object.keys(patch).length === 0) fail("nothing to update — pass --title or --description")
882
+ const goal = store.updateGoal(slugArg(), patch)
883
+ if (args.flags.json) console.log(JSON.stringify({ goal }, null, 2))
884
+ else printGoal(goal, "updated")
885
+ return
886
+ }
887
+ case "archive": {
888
+ const goal = store.archiveGoal(slugArg())
889
+ if (args.flags.json) console.log(JSON.stringify({ goal }, null, 2))
890
+ else printGoal(goal, "archived")
891
+ return
892
+ }
893
+ case "unarchive": {
894
+ const goal = store.unarchiveGoal(slugArg())
895
+ if (args.flags.json) console.log(JSON.stringify({ goal }, null, 2))
896
+ else printGoal(goal, "unarchived")
897
+ return
898
+ }
899
+ case "delete": {
900
+ const slug = slugArg()
901
+ store.deleteGoal(slug)
902
+ console.log(`deleted goal ${slug}`)
903
+ return
904
+ }
905
+ default:
906
+ fail(usage)
907
+ }
908
+ }
909
+
579
910
  const AUTHOR_SOURCE: Record<string, string> = {
580
911
  flag: "--author",
581
912
  env: "$TASK_AUTHOR",
@@ -594,27 +925,42 @@ function cmdWhoami(args: Args): void {
594
925
  }
595
926
 
596
927
  /**
597
- * `task migrate` — from the legacy committed SQLite database to text-canonical
598
- * storage: one markdown file per ticket and per comment under .task/tickets/,
599
- * with the database left on disk as an ignored backup. Additive and safe to
600
- * re-run planning-wise: it refuses to run twice.
928
+ * `task check [--fix]` — lint the board files. Git merges and hand-edits are
929
+ * write paths no CLI code sees, so validation is a command you can run (and
930
+ * put in pre-commit or CI, where merge-introduced drift gets caught). Covers
931
+ * every board at or below here, the same set `task serve` would serve. Exits
932
+ * non-zero while problems remain; `--fix` first applies the mechanical
933
+ * repairs — pruning dangling references, rewriting files into canonical form.
601
934
  */
602
- function cmdMigrate(args: Args): void {
603
- const root = findRoot(process.cwd())
604
- if (!root) fail("no .task directory found in this directory or any parent — run `task init` first")
605
- const result = migrateBoard(root)
935
+ function cmdCheck(args: Args): void {
936
+ const root = checkRoot(process.cwd())
937
+ const fix = Boolean(args.flags.fix)
938
+ const result = checkBoards(root, fix)
939
+ if (result.boards === 0) {
940
+ fail("no .task directory found in this directory, any parent, or below — run `task init` first")
941
+ }
942
+ // In fix mode the fixable issues were just repaired; what's left needs a human.
943
+ const remaining = fix ? result.issues.filter((issue) => !issue.fixable) : result.issues
944
+
606
945
  if (args.flags.json) {
607
- console.log(JSON.stringify({ migrated: result }, null, 2))
608
- return
946
+ console.log(JSON.stringify({ ...result, remaining }, null, 2))
947
+ } else {
948
+ for (const file of result.fixed) console.log(`fixed ${file}`)
949
+ for (const issue of remaining) {
950
+ console.log(`${issue.fixable ? "fixable" : "problem"} ${issue.file}`)
951
+ console.log(` ${issue.message}`)
952
+ }
953
+ const scope = `${result.files} file${result.files === 1 ? "" : "s"} across ${result.boards} board${result.boards === 1 ? "" : "s"}`
954
+ if (remaining.length === 0) {
955
+ console.log(result.fixed.length ? `fixed ${result.fixed.length}, ${scope} clean` : `ok — ${scope}`)
956
+ } else {
957
+ const fixable = remaining.filter((issue) => issue.fixable).length
958
+ console.log(
959
+ `${remaining.length} problem${remaining.length === 1 ? "" : "s"} in ${scope}${fixable ? ` (${fixable} fixable — run \`task check --fix\`)` : ""}`,
960
+ )
961
+ }
609
962
  }
610
- console.log(
611
- `Migrated ${result.tasks} task${result.tasks === 1 ? "" : "s"} and ${result.comments} comment${result.comments === 1 ? "" : "s"} to .task/tickets/`,
612
- )
613
- console.log(`tasks.db stays on disk as a backup, but it's ignored now — the files are the state.`)
614
- console.log(``)
615
- console.log(`Next:`)
616
- console.log(` git rm --cached ${join(TASK_DIR, "tasks.db")} stop tracking the database`)
617
- console.log(` git add ${TASK_DIR} commit the tickets`)
963
+ if (remaining.length > 0) process.exitCode = 1
618
964
  }
619
965
 
620
966
  function cmdDelete(args: Args): void {
@@ -818,42 +1164,102 @@ subdirectory (it walks up to find .task/, like git).
818
1164
 
819
1165
  Usage
820
1166
  task init [--name <name>] [--prefix <PREFIX>]
821
- task add <title> [--description <text>] [--status <s>] [--tags <a,b>]
822
- [--milestone <m>] [--needs-human]
823
- task list [--status <s1,s2>] [--tag <a,b>] [--milestone <m>]
1167
+ task add <title> [--description <text> | --description-file <path|->]
1168
+ [--status <s>] [--tags <a,b>] [--goal <slug>] [--needs-human]
1169
+ new tickets land in backlog (the dump list)
1170
+ unless --status says otherwise;
1171
+ --description-file reads markdown from a file
1172
+ (or stdin with -) — no shell-quoting fights
1173
+ task list [--status <s1,s2>] [--tag <a,b>] [--goal <slug>]
824
1174
  [--needs-human] [--all] [--archived] [--claimable]
825
- task show <id>
826
- task update <id> [--title <t>] [--description <text>] [--status <s>]
827
- [--tags <a,b>] [--milestone <m>]
1175
+ task search <query> [--status <s1,s2>] [--tag <a,b>] [--goal <slug>]
1176
+ [--needs-human] [--archived]
1177
+ find tickets by what they say — case-insensitive
1178
+ match over titles, descriptions and comments,
1179
+ with a snippet of the matching line. --archived
1180
+ searches the archive too, on top of the board
1181
+ task show <id> the ticket, its comments — and its goal's title
1182
+ and description inline, so a cold pickup gets
1183
+ the shared context without chasing it
1184
+ task update <id> [--title <t>] [--description <text> | --description-file <path|->]
1185
+ [--status <s>] [--tags <a,b>] [--goal <slug>]
828
1186
  [--needs-human | --no-needs-human]
829
1187
  [--pr <url>] [--prs <url1,url2>]
830
- task move <id> <status> shorthand for update --status
831
- task start <id> → in_progress
832
- task done <id> → done
1188
+ task move <id> <status> change a ticket's status — the one general
1189
+ status API (backlog todo in_progress done
1190
+ canceled). The named verbs below exist only
1191
+ where a transition does more than write the
1192
+ status field
1193
+ task promote <id> backlog → todo, the human "build this" call.
1194
+ No quality gate — planning happens at claim
1195
+ time. Blocked / needs-human tickets still
1196
+ promote — those are claim-time gates, reported
1197
+ as a note — so promoting can queue work behind
1198
+ its blockers. Exit codes: 0 promoted, 2
1199
+ refused (not a backlog ticket)
833
1200
  task link <id> --blocked-by <id>
834
1201
  task link <id> --blocks <id> mark a dependency — one relation, visible from
835
1202
  both tasks (A blocked by B ⇔ B blocks A)
836
1203
  task unlink <id> (--blocks <id> | --blocked-by <id>)
837
- task claim <id> claim a ticket before working it: branch
838
- <claimPrefix><prefix>-<n> off origin's default
839
- branch, ticket → in_progress as its first
840
- commit, pushed. The namespace defaults to
841
- task/claim/ — set "claimPrefix" in
1204
+ task claim <id> [--force] claim a ticket before working it: branch
1205
+ <branchPrefix><prefix>-<n> off origin's
1206
+ default branch, ticket → in_progress as its
1207
+ first commit, pushed. The namespace defaults
1208
+ to task/claim/ — set claims.branchPrefix in
842
1209
  .task/config.json to change it (e.g.
843
1210
  "claude/task/", which Claude cloud sessions
844
1211
  can push). Branch on origin = claimed —
845
1212
  git's atomic ref creation is the lock, so two
846
- concurrent claimers can't both win. Exit codes:
1213
+ concurrent claimers can't both win. While the
1214
+ root board sets claims.maxOpenCount and that
1215
+ many claim branches exist on origin, claiming
1216
+ is refused (--force overrides). Exit codes:
847
1217
  0 claimed, 1 already claimed, 2 not claimable
848
- (not todo, blocked, needs-human, dirty tree)
849
- task claim --release <id> abandon a claim: delete the branch on origin
1218
+ (not todo, blocked, needs-human, dirty tree,
1219
+ at the cap)
1220
+ task claim --next [--board <P>] [--force]
1221
+ claim the top claimable ticket in one call,
1222
+ retrying past lost races internally — the
1223
+ first line of every scheduled worker. Scope:
1224
+ --board prefixes (repeatable, given order =
1225
+ priority) > the root board's "boards" list >
1226
+ the nearest board. Exit codes: 0 claimed
1227
+ (prints the ticket), 1 queue empty, 2
1228
+ preconditions failed
1229
+ task claim --release <id> --comment "<why>"
1230
+ abandon a claim: delete the branch on origin
850
1231
  and locally — the status flip only lived on
851
- the branch, so deleting it is the revert
852
- task list --claimable the claim queue: todo tickets in position
1232
+ the branch, so deleting it is the revert. The
1233
+ comment is required and lands on the ticket
1234
+ (attributed like task comment, uncommitted),
1235
+ so the next worker inherits what was tried
1236
+ task list --claimable [--board <P>]
1237
+ the claim queue: todo tickets in position
853
1238
  order, minus blocked / needs-human / already
854
- claimed on origin. Top entry is next up
1239
+ claimed on origin. Top entry is next up. Same
1240
+ board scope as claim --next
1241
+ task instructions print the shipped agent conventions
1242
+ (skill/SKILL.md) — agents without the skill
1243
+ installed self-serve the house rules
1244
+ task overview [--json] one screen of board health: counts per
1245
+ status, per-goal progress, claimed and
1246
+ needs-human counts, stale tickets (untouched
1247
+ ${STALE_DAYS}+ days), oldest open ticket
855
1248
  task comment <id> <text> [--author <who>]
856
1249
  task delete <id>
1250
+ task goal add <title> [--slug <s>] [--description <text>]
1251
+ create a goal: a titled, described destination
1252
+ tasks belong to (.task/goals/<slug>.md). The
1253
+ description is the strategic why — the context
1254
+ every ticket under it shares
1255
+ task goal list [--archived] every goal with derived progress (done/total —
1256
+ a goal stores no status of its own, ever)
1257
+ task goal show <slug> the goal, its description, and its tasks
1258
+ task goal update <slug> [--title <t>] [--description <text>]
1259
+ task goal archive <slug> shelve a finished/abandoned goal — refused
1260
+ while it still has open tasks
1261
+ task goal unarchive <slug>
1262
+ task goal delete <slug> only when no live task references it
857
1263
  task boards every board in this repo — prefix, name, path,
858
1264
  open count; * marks the one commands target here
859
1265
  task archive <id> move a done/canceled ticket to .task/archive/,
@@ -863,6 +1269,15 @@ Usage
863
1269
  task archive --all archive everything done or canceled
864
1270
  task unarchive <id> put an archived ticket back on the board
865
1271
  task whoami who your comments are attributed to
1272
+ task check [--fix] lint the board files — merges and hand-edits
1273
+ are write paths no CLI code sees. Reports
1274
+ unparseable files, dangling blocked_by refs,
1275
+ bad timestamps, unknown frontmatter keys and
1276
+ formatting drift, for every board at or below
1277
+ here; exits non-zero while problems remain.
1278
+ --fix applies the mechanical repairs first
1279
+ (prune dangling refs, rewrite to canonical
1280
+ form). Cheap enough for pre-commit and CI
866
1281
  task serve [--port <n>] [--no-open] [--strict-port]
867
1282
  board + table UI with live updates, opened in
868
1283
  your browser (default port ${DEFAULT_PORT}; taken ports
@@ -880,9 +1295,6 @@ Usage
880
1295
  task unpublish [--repo <owner/name>]
881
1296
  take that URL down. Removes the board, not the
882
1297
  tasks — those are in .task/ either way
883
- task migrate move a pre-0.6 board off its committed SQLite
884
- database and onto text files in .task/tickets/.
885
- The database stays on disk as an ignored backup
886
1298
 
887
1299
  Values
888
1300
  <id> TAS-12, or just 12. A bare number means the nearest board; a
@@ -890,9 +1302,12 @@ Values
890
1302
  prefix, so TAS-12 works from anywhere in a monorepo
891
1303
  status ${STATUSES.join(" ")}
892
1304
  --tag a,b matches a task carrying *either* tag
1305
+ --goal <slug> a task belongs to at most one goal. Goals answer "where is
1306
+ this going" (one); tags answer "what kind" (many) — something
1307
+ that wants two goals is a tag
893
1308
  --needs-human this can't be finished by an agent alone
894
1309
  --pr <url> attach a pull request (appends); --prs replaces the whole list
895
- clearing --tags "" drops all tags, --milestone "" clears it, --prs ""
1310
+ clearing --tags "" drops all tags, --goal "" clears it, --prs ""
896
1311
  detaches all PRs
897
1312
 
898
1313
  Comment authors resolve --author → $TASK_AUTHOR → git config user.name →
@@ -923,22 +1338,28 @@ function main(): void | Promise<void> {
923
1338
  return cmdAdd(args)
924
1339
  case "list":
925
1340
  return cmdList(args)
1341
+ case "search":
1342
+ return cmdSearch(args)
926
1343
  case "show":
927
1344
  return cmdShow(args)
928
1345
  case "update":
929
1346
  return cmdUpdate(args)
930
1347
  case "move":
931
1348
  return cmdMove(args)
932
- case "start":
933
- return cmdUpdate(args, "in_progress")
934
- case "done":
935
- return cmdUpdate(args, "done")
1349
+ case "promote":
1350
+ return cmdPromote(args)
936
1351
  case "link":
937
1352
  return cmdLink(args, "link")
938
1353
  case "unlink":
939
1354
  return cmdLink(args, "unlink")
940
1355
  case "claim":
941
1356
  return cmdClaim(args)
1357
+ case "instructions":
1358
+ return cmdInstructions()
1359
+ case "overview":
1360
+ return cmdOverview(args)
1361
+ case "goal":
1362
+ return cmdGoal(args)
942
1363
  case "comment":
943
1364
  return cmdComment(args)
944
1365
  case "whoami":
@@ -951,8 +1372,8 @@ function main(): void | Promise<void> {
951
1372
  return cmdArchive(args)
952
1373
  case "unarchive":
953
1374
  return cmdUnarchive(args)
954
- case "migrate":
955
- return cmdMigrate(args)
1375
+ case "check":
1376
+ return cmdCheck(args)
956
1377
  case "serve":
957
1378
  return cmdServe(args)
958
1379
  case "publish":