@nickmeriano/task 0.7.1 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. package/README.md +73 -25
  2. package/dist/asks.test.d.ts +17 -0
  3. package/dist/asks.test.d.ts.map +1 -0
  4. package/dist/asks.test.js +278 -0
  5. package/dist/asks.test.js.map +1 -0
  6. package/dist/check.d.ts +43 -0
  7. package/dist/check.d.ts.map +1 -0
  8. package/dist/check.js +403 -0
  9. package/dist/check.js.map +1 -0
  10. package/dist/check.test.d.ts +9 -0
  11. package/dist/check.test.d.ts.map +1 -0
  12. package/dist/check.test.js +248 -0
  13. package/dist/check.test.js.map +1 -0
  14. package/dist/claim-io.d.ts +73 -0
  15. package/dist/claim-io.d.ts.map +1 -0
  16. package/dist/claim-io.js +344 -0
  17. package/dist/claim-io.js.map +1 -0
  18. package/dist/claim.d.ts +61 -9
  19. package/dist/claim.d.ts.map +1 -1
  20. package/dist/claim.js +197 -67
  21. package/dist/claim.js.map +1 -1
  22. package/dist/claim.test.d.ts +2 -2
  23. package/dist/claim.test.js +235 -64
  24. package/dist/claim.test.js.map +1 -1
  25. package/dist/cli.js +724 -136
  26. package/dist/cli.js.map +1 -1
  27. package/dist/file-store.d.ts +110 -38
  28. package/dist/file-store.d.ts.map +1 -1
  29. package/dist/file-store.js +514 -238
  30. package/dist/file-store.js.map +1 -1
  31. package/dist/git-serve.d.ts +183 -0
  32. package/dist/git-serve.d.ts.map +1 -0
  33. package/dist/git-serve.js +503 -0
  34. package/dist/git-serve.js.map +1 -0
  35. package/dist/git-serve.test.d.ts +16 -0
  36. package/dist/git-serve.test.d.ts.map +1 -0
  37. package/dist/git-serve.test.js +183 -0
  38. package/dist/git-serve.test.js.map +1 -0
  39. package/dist/git.d.ts +65 -0
  40. package/dist/git.d.ts.map +1 -0
  41. package/dist/git.js +114 -0
  42. package/dist/git.js.map +1 -0
  43. package/dist/id.d.ts +39 -0
  44. package/dist/id.d.ts.map +1 -0
  45. package/dist/id.js +67 -0
  46. package/dist/id.js.map +1 -0
  47. package/dist/inbox.d.ts +41 -0
  48. package/dist/inbox.d.ts.map +1 -0
  49. package/dist/inbox.js +56 -0
  50. package/dist/inbox.js.map +1 -0
  51. package/dist/index.d.ts +5 -3
  52. package/dist/index.d.ts.map +1 -1
  53. package/dist/index.js +5 -3
  54. package/dist/index.js.map +1 -1
  55. package/dist/overview.d.ts +52 -0
  56. package/dist/overview.d.ts.map +1 -0
  57. package/dist/overview.js +61 -0
  58. package/dist/overview.js.map +1 -0
  59. package/dist/overview.test.d.ts +8 -0
  60. package/dist/overview.test.d.ts.map +1 -0
  61. package/dist/overview.test.js +48 -0
  62. package/dist/overview.test.js.map +1 -0
  63. package/dist/promote.test.d.ts +15 -0
  64. package/dist/promote.test.d.ts.map +1 -0
  65. package/dist/promote.test.js +104 -0
  66. package/dist/promote.test.js.map +1 -0
  67. package/dist/publish.d.ts +2 -17
  68. package/dist/publish.d.ts.map +1 -1
  69. package/dist/publish.js +4 -49
  70. package/dist/publish.js.map +1 -1
  71. package/dist/search.d.ts +34 -0
  72. package/dist/search.d.ts.map +1 -0
  73. package/dist/search.js +79 -0
  74. package/dist/search.js.map +1 -0
  75. package/dist/search.test.d.ts +2 -0
  76. package/dist/search.test.d.ts.map +1 -0
  77. package/dist/search.test.js +53 -0
  78. package/dist/search.test.js.map +1 -0
  79. package/dist/server.d.ts.map +1 -1
  80. package/dist/server.js +228 -23
  81. package/dist/server.js.map +1 -1
  82. package/dist/store.d.ts +43 -63
  83. package/dist/store.d.ts.map +1 -1
  84. package/dist/store.js +0 -368
  85. package/dist/store.js.map +1 -1
  86. package/dist/store.test.d.ts +1 -2
  87. package/dist/store.test.d.ts.map +1 -1
  88. package/dist/store.test.js +148 -106
  89. package/dist/store.test.js.map +1 -1
  90. package/dist/ticket-doc.d.ts +74 -5
  91. package/dist/ticket-doc.d.ts.map +1 -1
  92. package/dist/ticket-doc.js +229 -15
  93. package/dist/ticket-doc.js.map +1 -1
  94. package/dist/types.d.ts +115 -28
  95. package/dist/types.d.ts.map +1 -1
  96. package/dist/types.js.map +1 -1
  97. package/package.json +1 -1
  98. package/skill/SKILL.md +153 -40
  99. package/src/asks.test.ts +355 -0
  100. package/src/check.test.ts +328 -0
  101. package/src/check.ts +497 -0
  102. package/src/claim-io.ts +401 -0
  103. package/src/claim.test.ts +301 -71
  104. package/src/claim.ts +238 -81
  105. package/src/cli.ts +740 -131
  106. package/src/file-store.ts +572 -254
  107. package/src/git-serve.test.ts +240 -0
  108. package/src/git-serve.ts +595 -0
  109. package/src/git.ts +141 -0
  110. package/src/id.ts +68 -0
  111. package/src/inbox.ts +77 -0
  112. package/src/index.ts +4 -2
  113. package/src/overview.test.ts +52 -0
  114. package/src/overview.ts +105 -0
  115. package/src/promote.test.ts +143 -0
  116. package/src/publish.ts +6 -53
  117. package/src/search.test.ts +64 -0
  118. package/src/search.ts +105 -0
  119. package/src/server.ts +232 -21
  120. package/src/store.test.ts +166 -116
  121. package/src/store.ts +46 -444
  122. package/src/ticket-doc.ts +284 -21
  123. package/src/types.ts +120 -28
  124. package/ui/dist/assets/index-BjsorZOU.js +229 -0
  125. package/ui/dist/assets/index-CoKCUYic.css +1 -0
  126. package/ui/dist/index.html +2 -2
  127. package/ui/dist/assets/index-COunM-QN.css +0 -1
  128. package/ui/dist/assets/index-D4homvrQ.js +0 -229
package/src/cli.ts CHANGED
@@ -1,25 +1,37 @@
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-x7k4m done
11
10
  // task serve
12
11
 
13
12
  import { spawn } from "node:child_process"
14
13
  import { readFileSync } from "node:fs"
15
14
  import type { Server } from "node:http"
16
- import { basename, join } from "node:path"
15
+ import { basename, join, relative, sep } 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 { addAskRouted, setAskResolvedRouted } from "./claim-io.ts"
29
+ import { buildInbox, type InboxEntry } from "./inbox.ts"
30
+ import { STALE_DAYS, buildOverview } from "./overview.ts"
31
+ import { searchStore } from "./search.ts"
20
32
  import { detectRepo, parseSlug, publish, resolveHost } from "./publish.ts"
21
33
  import { createTaskServer } from "./server.ts"
22
- import { initProject, migrateBoard, openBoard } from "./file-store.ts"
34
+ import { initProject, openBoard } from "./file-store.ts"
23
35
  import {
24
36
  CONFIG_FILE,
25
37
  TASK_DIR,
@@ -32,13 +44,6 @@ import {
32
44
  } from "./store.ts"
33
45
  import { STATUSES, isStatus, type Status, type Task, type TaskPatch } from "./types.ts"
34
46
 
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
47
  /**
43
48
  * Read from package.json rather than duplicated as a literal. Releases here are
44
49
  * cut by changesets, which bumps package.json and nothing else — so a hardcoded
@@ -63,6 +68,8 @@ const PORT_ATTEMPTS = 20
63
68
  interface Args {
64
69
  positional: string[]
65
70
  flags: Record<string, string | boolean>
71
+ /** Values of MULTI_FLAGS, in given order — e.g. multi.ask from `--ask "…"`. */
72
+ multi: Record<string, string[]>
66
73
  }
67
74
 
68
75
  // Every valueless flag has to be listed here: an unregistered one swallows the
@@ -74,7 +81,6 @@ const BOOLEAN_FLAGS = new Set([
74
81
  "version",
75
82
  "yes",
76
83
  "needs-human",
77
- "no-needs-human",
78
84
  "archived",
79
85
  "claimable",
80
86
  "release",
@@ -84,28 +90,56 @@ const BOOLEAN_FLAGS = new Set([
84
90
  "public",
85
91
  "private",
86
92
  "no-wait",
93
+ "fix",
94
+ "next",
95
+ "force",
96
+ "reopen",
87
97
  ])
88
98
 
99
+ /**
100
+ * Value flags that may repeat — `--board NIC --board TAS` accumulates in
101
+ * given order, equivalent to `--board NIC,TAS`. Everything else keeps
102
+ * last-one-wins.
103
+ */
104
+ const REPEATABLE_FLAGS = new Set(["board"])
105
+
106
+ /**
107
+ * Repeatable flags whose values are free text — collected verbatim into a
108
+ * list, never comma-joined, because an ask like "check A, then B" must
109
+ * survive as one ask.
110
+ */
111
+ const MULTI_FLAGS = new Set(["ask"])
112
+
89
113
  function parseArgs(argv: string[]): Args {
90
114
  const positional: string[] = []
91
115
  const flags: Record<string, string | boolean> = {}
116
+ const multi: Record<string, string[]> = {}
92
117
  for (let i = 0; i < argv.length; i++) {
93
118
  const arg = argv[i]
94
119
  if (arg.startsWith("--")) {
95
120
  const name = arg.slice(2)
121
+ const set = (key: string, value: string): void => {
122
+ if (MULTI_FLAGS.has(key)) {
123
+ ;(multi[key] ??= []).push(value)
124
+ return
125
+ }
126
+ const prior = flags[key]
127
+ flags[key] =
128
+ REPEATABLE_FLAGS.has(key) && typeof prior === "string" ? `${prior},${value}` : value
129
+ }
96
130
  const eq = name.indexOf("=")
97
131
  if (eq >= 0) {
98
- flags[name.slice(0, eq)] = name.slice(eq + 1)
132
+ set(name.slice(0, eq), name.slice(eq + 1))
99
133
  } else if (BOOLEAN_FLAGS.has(name) || i + 1 >= argv.length || argv[i + 1].startsWith("--")) {
100
134
  flags[name] = true
101
135
  } else {
102
- flags[name] = argv[++i]
136
+ set(name, argv[++i])
103
137
  }
104
138
  } else {
105
139
  positional.push(arg)
106
140
  }
107
141
  }
108
- return { positional, flags }
142
+ return { positional, flags, multi }
109
143
  }
110
144
 
111
145
  function str(flags: Args["flags"], name: string): string | undefined {
@@ -124,19 +158,19 @@ function openStore(): Store {
124
158
  return openBoard(root)
125
159
  }
126
160
 
127
- /** The prefix a ref carries, if any: "TAS-12" → "TAS", "12" → null. */
161
+ /** The prefix a ref carries, if any: "TAS-x7k4m" → "TAS", "x7k4m" → null. */
128
162
  function refPrefix(ref: string): string | null {
129
- const match = /^([A-Za-z0-9]+)-\d+$/.exec(ref.trim())
163
+ const match = /^([A-Za-z0-9]+)-[A-Za-z0-9]+$/.exec(ref.trim())
130
164
  return match ? match[1].toUpperCase() : null
131
165
  }
132
166
 
133
167
  /**
134
- * The board a ref belongs to. A bare number means the nearest board, as ever —
168
+ * The board a ref belongs to. A bare key means the nearest board, as ever —
135
169
  * but a prefixed id is an address, and it routes: if the prefix isn't the
136
170
  * nearest board's, every board in the repo (walking up to the outermost board
137
- * root, then down) is searched for it, so `task show TAS-12` works from
171
+ * root, then down) is searched for it, so `task show TAS-x7k4m` works from
138
172
  * anywhere in a monorepo. What this must never do is what it used to: silently
139
- * strip a foreign prefix and act on the nearest board's ticket of that number.
173
+ * strip a foreign prefix and act on the nearest board's ticket of that key.
140
174
  */
141
175
  function openStoreFor(ref: string | undefined): Store {
142
176
  const prefix = ref ? refPrefix(ref) : null
@@ -174,7 +208,7 @@ function openStoreFor(ref: string | undefined): Store {
174
208
  * instead of reinterpreting it — this guards the second id in `task link`,
175
209
  * where the board was picked by the first.
176
210
  */
177
- function parseRefOn(store: Store, ref: string): number {
211
+ function parseRefOn(store: Store, ref: string): string {
178
212
  const prefix = refPrefix(ref)
179
213
  if (prefix && prefix !== store.config.prefix.toUpperCase()) {
180
214
  fail(`${ref} is not on the ${store.config.prefix} board — links can't cross boards`)
@@ -199,15 +233,27 @@ function patchFromFlags(flags: Args["flags"]): TaskPatch {
199
233
  const title = str(flags, "title")
200
234
  if (title !== undefined) patch.title = title
201
235
  const description = str(flags, "description") ?? str(flags, "desc")
236
+ // Long markdown doesn't survive shell quoting — `--description-file` keeps
237
+ // agents writing plans and specs on the CLI path instead of hand-editing
238
+ // ticket files. `-` reads stdin.
239
+ const descriptionFile = str(flags, "description-file")
240
+ if (description !== undefined && descriptionFile !== undefined) {
241
+ fail("pass --description or --description-file, not both")
242
+ }
202
243
  if (description !== undefined) patch.description = description
244
+ if (descriptionFile !== undefined) {
245
+ try {
246
+ patch.description = readFileSync(descriptionFile === "-" ? 0 : descriptionFile, "utf8")
247
+ } catch {
248
+ fail(`--description-file: can't read ${descriptionFile === "-" ? "stdin" : descriptionFile}`)
249
+ }
250
+ }
203
251
  const status = str(flags, "status")
204
252
  if (status !== undefined) patch.status = parseStatus(status)
205
253
  const tags = str(flags, "tags") ?? str(flags, "tag")
206
254
  if (tags !== undefined) patch.tags = parseTags(tags)
207
- const milestone = str(flags, "milestone")
208
- if (milestone !== undefined) patch.milestone = milestone.trim() || null
209
- if (flags["needs-human"]) patch.needsHuman = true
210
- if (flags["no-needs-human"]) patch.needsHuman = false
255
+ const goal = str(flags, "goal")
256
+ if (goal !== undefined) patch.goal = goal.trim() || null
211
257
  // Replace semantics like --tags; `--pr` (append one) is handled per-command
212
258
  // because appending needs the task's current list.
213
259
  const prs = str(flags, "prs")
@@ -241,7 +287,7 @@ function taskRow(t: Task): string[] {
241
287
  t.needsHuman ? "⚑" : "",
242
288
  t.title,
243
289
  t.tags.join(","),
244
- t.milestone ?? "",
290
+ t.goal ?? "",
245
291
  ]
246
292
  }
247
293
 
@@ -272,28 +318,51 @@ function cmdInit(args: Args): void {
272
318
 
273
319
  function cmdAdd(args: Args): void {
274
320
  const title = args.positional.join(" ").trim()
275
- if (!title) fail(`usage: task add <title> [--description …] [--status …] [--tags a,b] [--milestone ] [--needs-human]`)
321
+ if (!title) fail(`usage: task add <title> [--description …] [--status …] [--tags a,b] [--goal <slug>] [--ask "<text>"]…`)
276
322
  const store = openStore()
277
323
  const patch = patchFromFlags(args.flags)
278
324
  const pr = str(args.flags, "pr")
279
325
  if (pr) patch.prs = [...(patch.prs ?? []), pr]
280
- const task = store.create({ title, ...patch })
326
+ let task = store.create({ title, ...patch })
327
+ const asks = (args.multi.ask ?? []).map((a) => a.trim()).filter(Boolean)
328
+ if (asks.length > 0) {
329
+ const author = resolveAuthor(str(args.flags, "author")).name
330
+ for (const text of asks) store.addAsk(task.key, text, author)
331
+ task = store.get(task.key)!
332
+ }
281
333
  if (args.flags.json) {
282
334
  console.log(JSON.stringify({ task }, null, 2))
283
335
  } else {
284
336
  printTask(task)
337
+ for (const ask of task.asks) console.log(` ask [${ask.ordinal}] ${ask.text.split("\n")[0]}`)
285
338
  }
286
339
  }
287
340
 
341
+ /** `--board NIC --board TAS` (or `--board NIC,TAS`) → ["NIC", "TAS"]. */
342
+ function boardFlag(args: Args): string[] | undefined {
343
+ const raw = str(args.flags, "board")
344
+ if (raw === undefined) return undefined
345
+ const prefixes = raw.split(",").map((p) => p.trim()).filter(Boolean)
346
+ if (prefixes.length === 0) fail("--board needs a prefix, e.g. --board TAS")
347
+ return prefixes
348
+ }
349
+
288
350
  function cmdList(args: Args): void {
289
- const store = openStore()
290
351
  if (args.flags.claimable) {
291
352
  // The dispatcher's queue view (TAS-21): claimable already pins the status
292
353
  // set and consults origin, so the board-shaping flags don't compose.
293
354
  for (const flag of ["status", "all", "archived"]) {
294
355
  if (args.flags[flag] !== undefined) fail(`--claimable can't be combined with --${flag}`)
295
356
  }
296
- const tasks = claimableTasks(store)
357
+ // Selection scope, same resolution as `claim --next`: --board flag > root
358
+ // board's `boards` list > nearest board. Board order is priority order.
359
+ let tasks: Task[]
360
+ try {
361
+ tasks = selectionBoards(process.cwd(), boardFlag(args)).flatMap((s) => claimableTasks(s))
362
+ } catch (error) {
363
+ if (error instanceof ClaimError) fail(error.message)
364
+ throw error
365
+ }
297
366
  if (args.flags.json) {
298
367
  console.log(JSON.stringify({ tasks }, null, 2))
299
368
  } else if (tasks.length === 0) {
@@ -303,6 +372,8 @@ function cmdList(args: Args): void {
303
372
  }
304
373
  return
305
374
  }
375
+ if (args.flags.board !== undefined) fail("--board only applies to list --claimable")
376
+ const store = openStore()
306
377
  const archived = Boolean(args.flags.archived)
307
378
  const statusFlag = str(args.flags, "status")
308
379
  const statuses = statusFlag
@@ -316,7 +387,7 @@ function cmdList(args: Args): void {
316
387
  const tasks = store.list({
317
388
  statuses,
318
389
  tags: tagFlag ? parseTags(tagFlag) : undefined,
319
- milestone: str(args.flags, "milestone"),
390
+ goal: str(args.flags, "goal"),
320
391
  needsHuman: args.flags["needs-human"] ? true : undefined,
321
392
  archived: archived || undefined,
322
393
  })
@@ -338,6 +409,53 @@ function cmdList(args: Args): void {
338
409
  }
339
410
  }
340
411
 
412
+ /**
413
+ * `task search <query>` — find tickets by what they say. Case-insensitive
414
+ * substring over titles, descriptions and comments, scoped by the usual list
415
+ * filters; `--archived` searches the archive *in addition to* the board (a
416
+ * search is a question about everything ever written down, unlike
417
+ * `list --archived`, which is a view of one place).
418
+ */
419
+ function cmdSearch(args: Args): void {
420
+ const query = args.positional.join(" ").trim()
421
+ if (!query) {
422
+ fail(
423
+ "usage: task search <query> [--status <s1,s2>] [--tag <a,b>] [--goal <slug>] [--needs-human] [--archived]",
424
+ )
425
+ }
426
+ const store = openStore()
427
+ const statusFlag = str(args.flags, "status")
428
+ const tagFlag = str(args.flags, "tags") ?? str(args.flags, "tag")
429
+ const results = searchStore(
430
+ store,
431
+ query,
432
+ {
433
+ statuses: statusFlag ? statusFlag.split(",").map(parseStatus) : undefined,
434
+ tags: tagFlag ? parseTags(tagFlag) : undefined,
435
+ goal: str(args.flags, "goal"),
436
+ needsHuman: args.flags["needs-human"] ? true : undefined,
437
+ },
438
+ Boolean(args.flags.archived),
439
+ )
440
+ if (args.flags.json) {
441
+ console.log(JSON.stringify({ query, results }, null, 2))
442
+ return
443
+ }
444
+ if (results.length === 0) {
445
+ console.log(`no matches for "${query}"${args.flags.archived ? "" : " (--archived searches the archive too)"}`)
446
+ return
447
+ }
448
+ for (const { task, matches } of results) {
449
+ console.log(
450
+ `${task.id} ${STATUS_GLYPH[task.status]} ${task.status}${task.archived ? " (archived)" : ""} ${task.title}`,
451
+ )
452
+ for (const match of matches) {
453
+ const label = match.field === "comment" ? `comment${match.author ? ` by ${match.author}` : ""}` : match.field
454
+ console.log(` ${label}: ${match.snippet}`)
455
+ }
456
+ }
457
+ }
458
+
341
459
  /**
342
460
  * `task boards` — every board in this repo, found the way `task serve` finds
343
461
  * them but anchored at the *outermost* board root, so it answers from anywhere
@@ -403,7 +521,7 @@ function cmdArchive(args: Args): void {
403
521
 
404
522
  const store = openStore()
405
523
  const finished = store.list({ statuses: ["done", "canceled"] })
406
- const archived = finished.map((t) => store.archive(t.number))
524
+ const archived = finished.map((t) => store.archive(t.key))
407
525
  if (args.flags.json) {
408
526
  console.log(JSON.stringify({ archived }, null, 2))
409
527
  } else if (archived.length === 0) {
@@ -429,34 +547,54 @@ function cmdShow(args: Args): void {
429
547
  const ref = args.positional[0]
430
548
  if (!ref) fail("usage: task show <id>")
431
549
  const store = openStoreFor(ref)
432
- const number = store.parseId(ref)
433
- const task = store.get(number)
434
- if (!task) fail(`no such task: ${store.displayId(number)}`)
435
- const comments = store.comments(number)
550
+ const key = store.parseId(ref)
551
+ const task = store.get(key)
552
+ if (!task) fail(`no such task: ${store.displayId(key)}`)
553
+ const comments = store.comments(key)
554
+ // The goal rides along in full — title *and* description — so an agent
555
+ // picking the ticket up cold inherits the shared context without it being
556
+ // pasted into every ticket. null when unset or when the ref dangles.
557
+ const goal = task.goal ? store.getGoal(task.goal) : null
436
558
  if (args.flags.json) {
437
- console.log(JSON.stringify({ task, comments }, null, 2))
559
+ console.log(JSON.stringify({ task, comments, goal }, null, 2))
438
560
  return
439
561
  }
440
562
  // A linked task is only as useful as knowing whether it's still in the way.
441
- const describeLinks = (numbers: number[]): string =>
442
- numbers
443
- .map((n) => {
444
- const other = store.get(n)
445
- return other ? `${other.id} (${other.status})` : store.displayId(n)
563
+ const describeLinks = (keys: string[]): string =>
564
+ keys
565
+ .map((k) => {
566
+ const other = store.get(k)
567
+ return other ? `${other.id} (${other.status})` : store.displayId(k)
446
568
  })
447
569
  .join(", ")
448
570
  console.log(`${task.id} ${task.title}`)
449
571
  console.log(`status ${task.status}`)
450
572
  if (task.archived) console.log(`archived yes — \`task unarchive ${task.id}\` to edit`)
451
- if (task.needsHuman) console.log(`needs a human`)
573
+ const openAsks = task.asks.filter((a) => !a.resolvedAt)
574
+ if (openAsks.length) {
575
+ console.log(`needs a human — ${openAsks.length} open ask${openAsks.length === 1 ? "" : "s"}`)
576
+ }
577
+ for (const ask of task.asks) {
578
+ const state = ask.resolvedAt
579
+ ? `✓ resolved${ask.resolvedBy ? ` by ${ask.resolvedBy}` : ""}`
580
+ : `○ open${ask.author ? ` · ${ask.author}` : ""} · ${ask.createdAt.slice(0, 10)}`
581
+ console.log(`ask [${ask.ordinal}] ${ask.text.split("\n").join("\n ")}`)
582
+ console.log(` ${state}`)
583
+ }
452
584
  if (task.tags.length) console.log(`tags ${task.tags.join(", ")}`)
453
- if (task.milestone) console.log(`milestone ${task.milestone}`)
585
+ if (task.goal) {
586
+ console.log(`goal ${task.goal}${goal ? ` — ${goal.title}${goal.archived ? " (archived)" : ""}` : ""}`)
587
+ }
454
588
  if (task.blockedBy.length) console.log(`blocked by ${describeLinks(task.blockedBy)}`)
455
589
  if (task.blocks.length) console.log(`blocks ${describeLinks(task.blocks)}`)
456
590
  for (const pr of task.prs) console.log(`pr ${pr}`)
457
591
  console.log(`created ${task.createdAt}`)
458
592
  console.log(`updated ${task.updatedAt}`)
459
593
  if (task.description) console.log(`\n${task.description}`)
594
+ if (goal?.description) {
595
+ console.log(`\n── goal: ${goal.title} (${goal.slug})`)
596
+ console.log(goal.description.split("\n").map((line) => ` ${line}`).join("\n"))
597
+ }
460
598
  if (comments.length) {
461
599
  console.log(``)
462
600
  for (const c of comments) {
@@ -466,21 +604,20 @@ function cmdShow(args: Args): void {
466
604
  }
467
605
  }
468
606
 
469
- function cmdUpdate(args: Args, forcedStatus?: Status): void {
607
+ function cmdUpdate(args: Args): void {
470
608
  const ref = args.positional[0]
471
609
  if (!ref) fail("usage: task update <id> [--status …] [--title …] …")
472
610
  const store = openStoreFor(ref)
473
- const number = store.parseId(ref)
611
+ const key = store.parseId(ref)
474
612
  const patch = patchFromFlags(args.flags)
475
- if (forcedStatus) patch.status = forcedStatus
476
613
  const pr = str(args.flags, "pr")
477
614
  if (pr) {
478
615
  // Append, dedup — `--pr <url>` is "attach this PR", not "replace the list".
479
- const current = patch.prs ?? store.get(number)?.prs ?? []
616
+ const current = patch.prs ?? store.get(key)?.prs ?? []
480
617
  patch.prs = current.includes(pr) ? current : [...current, pr]
481
618
  }
482
619
  if (Object.keys(patch).length === 0) fail("nothing to update — pass at least one flag")
483
- const task = store.update(number, patch)
620
+ const task = store.update(key, patch)
484
621
  if (args.flags.json) {
485
622
  console.log(JSON.stringify({ task }, null, 2))
486
623
  } else {
@@ -491,7 +628,158 @@ function cmdUpdate(args: Args, forcedStatus?: Status): void {
491
628
  function cmdMove(args: Args): void {
492
629
  const [ref, status] = args.positional
493
630
  if (!ref || !status) fail("usage: task move <id> <status>")
494
- cmdUpdate({ positional: [ref], flags: { ...args.flags, status } })
631
+ cmdUpdate({ positional: [ref], flags: { ...args.flags, status }, multi: {} })
632
+ }
633
+
634
+ /**
635
+ * `task promote <id>` — backlog → todo, the human "build this" call; see
636
+ * claim.ts for why blocked / needs-human are holds (reported), not refusals.
637
+ * Exit codes follow claim's contract: 0 promoted, 2 refused (fix something).
638
+ */
639
+ function cmdPromote(args: Args): void {
640
+ const ref = args.positional[0]
641
+ if (!ref) fail("usage: task promote <id>")
642
+ const store = openStoreFor(ref)
643
+ try {
644
+ const result = promote(store, store.parseId(ref))
645
+ if (args.flags.json) {
646
+ console.log(JSON.stringify({ task: result.task, holds: result.holds }, null, 2))
647
+ } else {
648
+ printTask(result.task)
649
+ for (const hold of result.holds) {
650
+ console.log(`note: ${hold} — in todo, but not claimable until that clears`)
651
+ }
652
+ }
653
+ } catch (error) {
654
+ if (error instanceof ClaimError) {
655
+ console.error(`error: ${error.message}`)
656
+ process.exit(2)
657
+ }
658
+ throw error
659
+ }
660
+ }
661
+
662
+ /**
663
+ * `task ask <id> <text>` — file one thing a person owes this ticket. The ask
664
+ * is the primitive every human requirement funnels through: it derives the
665
+ * needs-human badge, feeds `task inbox`, and gates `done` until resolved.
666
+ * On a claimed ticket the write lands on the claim branch as a pushed commit
667
+ * (see claim-io.ts) — the worker's done gate has to see debt filed mid-claim.
668
+ */
669
+ function cmdAsk(args: Args): void {
670
+ const [ref, ...rest] = args.positional
671
+ const text = rest.join(" ").trim()
672
+ if (!ref || !text) fail(`usage: task ask <id> "<what a person needs to do>" [--author <who>]`)
673
+ const store = openStoreFor(ref)
674
+ const author = resolveAuthor(str(args.flags, "author")).name
675
+ const { ask, branch } = addAskRouted(store, store.parseId(ref), text, author)
676
+ if (args.flags.json) {
677
+ console.log(JSON.stringify({ ask, branch }, null, 2))
678
+ } else {
679
+ console.log(`asked on ${ask.taskId} [${ask.ordinal}] ${ask.text.split("\n")[0]}`)
680
+ if (branch) console.log(`committed and pushed to ${branch} — the claim owns this ticket's asks`)
681
+ }
682
+ }
683
+
684
+ /**
685
+ * `task resolve <id> <n>` — check an ask off (`--reopen` puts it back). The
686
+ * ordinal is the one `task show` prints; the optional --comment lands as an
687
+ * attributed comment so the answer travels with the ticket, not just the
688
+ * checkbox. Resolving the last open ask is the moment a ticket usually moves
689
+ * to done — the hint below says so rather than doing it, because finishing a
690
+ * ticket is a statement about the *work*, not the asks.
691
+ */
692
+ function cmdResolve(args: Args): void {
693
+ const [ref, askRef] = args.positional
694
+ if (!ref || !askRef) {
695
+ fail('usage: task resolve <id> <ordinal> [--comment "<note>"] [--reopen] [--author <who>]')
696
+ }
697
+ const store = openStoreFor(ref)
698
+ const key = store.parseId(ref)
699
+ const author = resolveAuthor(str(args.flags, "author")).name
700
+ const { ask, branch } = setAskResolvedRouted(
701
+ store,
702
+ key,
703
+ askRef,
704
+ !args.flags.reopen,
705
+ author,
706
+ )
707
+ const note = str(args.flags, "comment")?.trim()
708
+ const comment = note
709
+ ? store.addComment(
710
+ key,
711
+ `${args.flags.reopen ? "Reopened" : "Resolved"} ask [${ask.ordinal}] (${ask.text.split("\n")[0]}): ${note}`,
712
+ author,
713
+ )
714
+ : null
715
+ const task = store.get(key)!
716
+ // Branch-resolved asks aren't in the local files — count what's still open
717
+ // on the side that was just written.
718
+ const remaining = branch
719
+ ? undefined
720
+ : task.asks.filter((a) => !a.resolvedAt).length
721
+ if (args.flags.json) {
722
+ console.log(JSON.stringify({ ask, branch, comment, openAsks: remaining ?? null }, null, 2))
723
+ return
724
+ }
725
+ const verb = args.flags.reopen ? "reopened" : "resolved"
726
+ console.log(`${verb} ${ask.taskId} [${ask.ordinal}] ${ask.text.split("\n")[0]}`)
727
+ if (branch) {
728
+ console.log(`committed and pushed to ${branch} — the worker sees it on its next pull`)
729
+ return
730
+ }
731
+ if (args.flags.reopen) return
732
+ if (remaining !== undefined && remaining > 0) {
733
+ console.log(`${remaining} ask${remaining === 1 ? "" : "s"} still open on ${ask.taskId}`)
734
+ } else if (task.status === "in_progress") {
735
+ console.log(`that was the last open ask — \`task move ${ask.taskId} done\` if the work is finished too`)
736
+ }
737
+ }
738
+
739
+ /**
740
+ * `task inbox` — what do I need to do NOW to unblock work: every open ask on
741
+ * effectively-in-progress tickets (file status, or a live claim branch —
742
+ * see inbox.ts), across the repo's boards, oldest owed first. Deliberately
743
+ * cross-board, like `claim --next`: the queue's attention is per-board, a
744
+ * person's isn't.
745
+ */
746
+ function cmdInbox(args: Args): void {
747
+ let entries: InboxEntry[]
748
+ try {
749
+ const scope = findScopeRoot(process.cwd())
750
+ const boards = selectionBoards(process.cwd(), boardFlag(args)).map((store) => {
751
+ const rel = relative(scope, store.root).split(sep).join("/")
752
+ return { id: rel === "" ? "." : rel, store }
753
+ })
754
+ entries = buildInbox(boards)
755
+ } catch (error) {
756
+ if (error instanceof ClaimError) fail(error.message)
757
+ throw error
758
+ }
759
+ if (args.flags.json) {
760
+ console.log(JSON.stringify({ inbox: entries }, null, 2))
761
+ return
762
+ }
763
+ if (entries.length === 0) {
764
+ console.log("inbox zero — no open asks on in-progress work")
765
+ return
766
+ }
767
+ const open = entries.reduce((sum, e) => sum + e.asks.length, 0)
768
+ console.log(
769
+ `${open} open ask${open === 1 ? "" : "s"} across ${entries.length} ticket${entries.length === 1 ? "" : "s"}`,
770
+ )
771
+ const age = (iso: string): string => {
772
+ const days = Math.floor((Date.now() - Date.parse(iso)) / 86_400_000)
773
+ return days > 0 ? `${days}d` : "today"
774
+ }
775
+ for (const entry of entries) {
776
+ const via = entry.claim ? ` (claimed: ${entry.claim.branch})` : ""
777
+ console.log(`\n${entry.task.id} ${entry.task.title}${via}`)
778
+ for (const ask of entry.asks) {
779
+ const who = ask.author ? ` · ${ask.author}` : ""
780
+ console.log(` [${ask.ordinal}] ${ask.text.split("\n")[0]} (${age(ask.createdAt)}${who})`)
781
+ }
782
+ }
495
783
  }
496
784
 
497
785
  function cmdComment(args: Args): void {
@@ -523,9 +811,9 @@ function cmdLink(args: Args, action: "link" | "unlink"): void {
523
811
  const store = openStoreFor(ref)
524
812
  const relation = blocks ? "blocks" : "blocked_by"
525
813
  const target = parseRefOn(store, (blocks ?? blockedBy)!)
526
- const number = store.parseId(ref)
814
+ const key = store.parseId(ref)
527
815
  const task =
528
- action === "link" ? store.link(number, relation, target) : store.unlink(number, relation, target)
816
+ action === "link" ? store.link(key, relation, target) : store.unlink(key, relation, target)
529
817
  if (args.flags.json) {
530
818
  console.log(JSON.stringify({ task }, null, 2))
531
819
  } else {
@@ -538,35 +826,77 @@ function cmdLink(args: Args, action: "link" | "unlink"): void {
538
826
  }
539
827
  }
540
828
 
829
+ const CLAIM_USAGE =
830
+ 'usage: task claim <id> | task claim --next | task claim --release <id> --comment "<why>"'
831
+
541
832
  /**
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).
833
+ * `task claim <id>` / `task claim --next` / `task claim --release <id>` — see
834
+ * claim.ts for the mechanics. Exit codes are the contract callers script
835
+ * against: 0 claimed, 1 already claimed / queue empty (nothing to do here
836
+ * a scheduled worker stops cleanly), 2 preconditions failed (fix something).
545
837
  */
546
838
  function cmdClaim(args: Args): void {
547
839
  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)
840
+ const options = { force: Boolean(args.flags.force) }
841
+ const printClaim = (result: { task: Task; branch: string; base: string }): void => {
842
+ if (args.flags.json) {
843
+ console.log(JSON.stringify({ task: result.task, branch: result.branch }, null, 2))
844
+ } else {
845
+ console.log(`claimed ${result.task.id} — on ${result.branch} (from ${result.base}), status in_progress`)
846
+ }
847
+ }
551
848
  try {
849
+ if (args.flags.next) {
850
+ // The first line of every scheduled worker: claim the top claimable
851
+ // ticket across the selection scope (--board > root `boards` > nearest),
852
+ // retrying past lost races internally.
853
+ if (ref) fail("pass an id or --next, not both")
854
+ if (args.flags.release) fail(CLAIM_USAGE)
855
+ const result = claimNext(selectionBoards(process.cwd(), boardFlag(args)), options)
856
+ if (!result) {
857
+ if (args.flags.json) {
858
+ console.log(JSON.stringify({ task: null }, null, 2))
859
+ } else {
860
+ console.log("nothing claimable — no unblocked, unclaimed todo tickets in scope")
861
+ }
862
+ process.exit(1)
863
+ }
864
+ printClaim(result)
865
+ return
866
+ }
867
+ if (!ref) fail(CLAIM_USAGE)
868
+ const store = openStoreFor(ref)
869
+ const key = store.parseId(ref)
552
870
  if (args.flags.release) {
553
- const result = release(store, number)
871
+ // A released claim must leave its failure context behind: the branch
872
+ // (and whatever was tried on it) is about to evaporate, so the reason
873
+ // is required, and lands as an ordinary attributed comment.
874
+ const why = str(args.flags, "comment")?.trim()
875
+ if (!why) {
876
+ fail(
877
+ 'task claim --release requires --comment "<why>" — the next worker inherits what was tried',
878
+ )
879
+ }
880
+ const result = release(store, key)
881
+ const commented = result.remote || result.local
882
+ // After the branch deletion, so the comment survives it: release leaves
883
+ // the checkout on the default branch, and the comment file sits there
884
+ // uncommitted, to ride along with the next commit.
885
+ const comment = commented
886
+ ? store.addComment(key, why, resolveAuthor(str(args.flags, "author")).name)
887
+ : null
554
888
  if (args.flags.json) {
555
- console.log(JSON.stringify({ released: result }, null, 2))
556
- } else if (!result.remote && !result.local) {
557
- console.log(`${store.displayId(number)} wasn't claimed — no ${result.branch} to delete`)
889
+ console.log(JSON.stringify({ released: result, comment }, null, 2))
890
+ } else if (!commented) {
891
+ console.log(`${store.displayId(key)} wasn't claimed — no ${result.branch} to delete`)
558
892
  } else {
559
893
  const where = [result.remote && "origin", result.local && "local"].filter(Boolean)
560
- console.log(`released ${store.displayId(number)} — deleted ${result.branch} (${where.join(" and ")})`)
894
+ console.log(`released ${store.displayId(key)} — deleted ${result.branch} (${where.join(" and ")})`)
895
+ console.log(`left the reason as a comment on ${store.displayId(key)} — uncommitted, commit it with your next change`)
561
896
  }
562
897
  return
563
898
  }
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
- }
899
+ printClaim(claim(store, key, options))
570
900
  } catch (error) {
571
901
  if (error instanceof ClaimError) {
572
902
  console.error(`error: ${error.message}`)
@@ -576,6 +906,164 @@ function cmdClaim(args: Args): void {
576
906
  }
577
907
  }
578
908
 
909
+ /** `task instructions` — the shipped agent conventions, self-served at runtime. */
910
+ function cmdInstructions(): void {
911
+ // skill/ ships in the npm package; `../` from both dist/cli.js and
912
+ // src/cli.ts is the package root, same trick VERSION uses.
913
+ process.stdout.write(readFileSync(new URL("../skill/SKILL.md", import.meta.url), "utf8"))
914
+ }
915
+
916
+ /** `task overview [--json]` — one screen of board health. See overview.ts. */
917
+ function cmdOverview(args: Args): void {
918
+ const overview = buildOverview(openStore())
919
+ if (args.flags.json) {
920
+ console.log(JSON.stringify({ overview }, null, 2))
921
+ return
922
+ }
923
+ console.log(`${overview.board} (${overview.prefix}) — ${overview.open} open`)
924
+ console.log(
925
+ ` ${STATUSES.map((s) => `${STATUS_GLYPH[s]} ${s} ${overview.statuses[s]}`).join(" ")}`,
926
+ )
927
+ const claimed =
928
+ overview.claimed === null
929
+ ? "claimed ? (origin unreachable)"
930
+ : `claimed ${overview.claimed.length}${overview.claimed.length ? ` (${overview.claimed.join(", ")})` : ""}`
931
+ const asks = overview.oldestAsk
932
+ ? `asks ${overview.openAsks} open (oldest ${overview.oldestAsk.taskId}, ${overview.oldestAsk.createdAt.slice(0, 10)})`
933
+ : "asks 0 open"
934
+ console.log(` ${claimed} ${asks}`)
935
+ if (overview.goals.length) {
936
+ console.log("goals")
937
+ table(
938
+ overview.goals.map((g) => [
939
+ ` ${g.slug}`,
940
+ g.title,
941
+ `${g.done}/${g.total} done${g.total ? ` (${Math.round((g.done / g.total) * 100)}%)` : ""}`,
942
+ ]),
943
+ )
944
+ }
945
+ if (overview.stale.length) {
946
+ console.log(`stale (untouched ${STALE_DAYS}+ days)`)
947
+ table(overview.stale.map((t) => [` ${t.id}`, t.title, t.updatedAt.slice(0, 10)]))
948
+ }
949
+ if (overview.oldestOpen) {
950
+ console.log(
951
+ `oldest open ${overview.oldestOpen.id} ${overview.oldestOpen.title} (created ${overview.oldestOpen.createdAt.slice(0, 10)})`,
952
+ )
953
+ }
954
+ }
955
+
956
+ /**
957
+ * `task goal …` — the goals a board's tasks belong to. A goal is a titled,
958
+ * described destination (`.task/goals/<slug>.md`); a task carries at most one.
959
+ * There is no goal status anywhere here on purpose: progress is derived from
960
+ * the tasks, and `archive` is the only lifecycle a goal has.
961
+ */
962
+ function cmdGoal(args: Args): void {
963
+ const [sub, ...rest] = args.positional
964
+ const usage =
965
+ "usage: task goal add <title> [--slug <s>] [--description …] | list [--archived] | " +
966
+ "show <slug> | update <slug> [--title …] [--description …] | archive <slug> | " +
967
+ "unarchive <slug> | delete <slug>"
968
+ if (!sub) fail(usage)
969
+ const store = openStore()
970
+
971
+ const slugArg = (): string => {
972
+ const slug = rest[0]
973
+ if (!slug) fail(`usage: task goal ${sub} <slug>`)
974
+ return slug
975
+ }
976
+ const printGoal = (goal: { slug: string; title: string; archived?: boolean }, verb: string) =>
977
+ console.log(`${verb} ${goal.slug} ${goal.title}${goal.archived ? " (archived)" : ""}`)
978
+
979
+ switch (sub) {
980
+ case "add": {
981
+ const title = rest.join(" ").trim()
982
+ if (!title) fail("usage: task goal add <title> [--slug <s>] [--description …]")
983
+ const goal = store.createGoal({
984
+ title,
985
+ slug: str(args.flags, "slug"),
986
+ description: str(args.flags, "description") ?? str(args.flags, "desc"),
987
+ })
988
+ if (args.flags.json) console.log(JSON.stringify({ goal }, null, 2))
989
+ else printGoal(goal, "created")
990
+ return
991
+ }
992
+ case "list": {
993
+ const archived = Boolean(args.flags.archived)
994
+ const goals = store.goals(archived)
995
+ // Progress is derived here, at read time — a goal file never stores it.
996
+ const tasks = store.list({ statuses: undefined })
997
+ const rows = goals.map((goal) => {
998
+ const mine = tasks.filter((t) => t.goal === goal.slug)
999
+ const done = mine.filter((t) => t.status === "done" || t.status === "canceled").length
1000
+ return { ...goal, open: mine.length - done, done, total: mine.length }
1001
+ })
1002
+ if (args.flags.json) {
1003
+ console.log(JSON.stringify({ goals: rows }, null, 2))
1004
+ } else if (rows.length === 0) {
1005
+ console.log(archived ? "no archived goals" : "no goals — create one with `task goal add`")
1006
+ } else {
1007
+ table(rows.map((g) => [g.slug, g.title, `${g.done}/${g.total} done`]))
1008
+ }
1009
+ return
1010
+ }
1011
+ case "show": {
1012
+ const slug = slugArg()
1013
+ const goal = store.getGoal(slug)
1014
+ if (!goal) fail(`no such goal: "${slug}"`)
1015
+ const tasks = store.list({ statuses: undefined, goal: slug })
1016
+ if (args.flags.json) {
1017
+ console.log(JSON.stringify({ goal, tasks }, null, 2))
1018
+ return
1019
+ }
1020
+ console.log(`${goal.slug} ${goal.title}`)
1021
+ if (goal.archived) console.log(`archived yes — \`task goal unarchive ${goal.slug}\` to edit`)
1022
+ console.log(`created ${goal.createdAt}`)
1023
+ console.log(`updated ${goal.updatedAt}`)
1024
+ if (goal.description) console.log(`\n${goal.description}`)
1025
+ if (tasks.length) {
1026
+ console.log("")
1027
+ table(tasks.map(taskRow))
1028
+ }
1029
+ return
1030
+ }
1031
+ case "update": {
1032
+ const patch = {
1033
+ ...(str(args.flags, "title") !== undefined && { title: str(args.flags, "title") }),
1034
+ ...((str(args.flags, "description") ?? str(args.flags, "desc")) !== undefined && {
1035
+ description: str(args.flags, "description") ?? str(args.flags, "desc"),
1036
+ }),
1037
+ }
1038
+ if (Object.keys(patch).length === 0) fail("nothing to update — pass --title or --description")
1039
+ const goal = store.updateGoal(slugArg(), patch)
1040
+ if (args.flags.json) console.log(JSON.stringify({ goal }, null, 2))
1041
+ else printGoal(goal, "updated")
1042
+ return
1043
+ }
1044
+ case "archive": {
1045
+ const goal = store.archiveGoal(slugArg())
1046
+ if (args.flags.json) console.log(JSON.stringify({ goal }, null, 2))
1047
+ else printGoal(goal, "archived")
1048
+ return
1049
+ }
1050
+ case "unarchive": {
1051
+ const goal = store.unarchiveGoal(slugArg())
1052
+ if (args.flags.json) console.log(JSON.stringify({ goal }, null, 2))
1053
+ else printGoal(goal, "unarchived")
1054
+ return
1055
+ }
1056
+ case "delete": {
1057
+ const slug = slugArg()
1058
+ store.deleteGoal(slug)
1059
+ console.log(`deleted goal ${slug}`)
1060
+ return
1061
+ }
1062
+ default:
1063
+ fail(usage)
1064
+ }
1065
+ }
1066
+
579
1067
  const AUTHOR_SOURCE: Record<string, string> = {
580
1068
  flag: "--author",
581
1069
  env: "$TASK_AUTHOR",
@@ -594,36 +1082,51 @@ function cmdWhoami(args: Args): void {
594
1082
  }
595
1083
 
596
1084
  /**
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.
1085
+ * `task check [--fix]` — lint the board files. Git merges and hand-edits are
1086
+ * write paths no CLI code sees, so validation is a command you can run (and
1087
+ * put in pre-commit or CI, where merge-introduced drift gets caught). Covers
1088
+ * every board at or below here, the same set `task serve` would serve. Exits
1089
+ * non-zero while problems remain; `--fix` first applies the mechanical
1090
+ * repairs — pruning dangling references, rewriting files into canonical form.
601
1091
  */
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)
1092
+ function cmdCheck(args: Args): void {
1093
+ const root = checkRoot(process.cwd())
1094
+ const fix = Boolean(args.flags.fix)
1095
+ const result = checkBoards(root, fix)
1096
+ if (result.boards === 0) {
1097
+ fail("no .task directory found in this directory, any parent, or below — run `task init` first")
1098
+ }
1099
+ // In fix mode the fixable issues were just repaired; what's left needs a human.
1100
+ const remaining = fix ? result.issues.filter((issue) => !issue.fixable) : result.issues
1101
+
606
1102
  if (args.flags.json) {
607
- console.log(JSON.stringify({ migrated: result }, null, 2))
608
- return
1103
+ console.log(JSON.stringify({ ...result, remaining }, null, 2))
1104
+ } else {
1105
+ for (const file of result.fixed) console.log(`fixed ${file}`)
1106
+ for (const issue of remaining) {
1107
+ console.log(`${issue.fixable ? "fixable" : "problem"} ${issue.file}`)
1108
+ console.log(` ${issue.message}`)
1109
+ }
1110
+ const scope = `${result.files} file${result.files === 1 ? "" : "s"} across ${result.boards} board${result.boards === 1 ? "" : "s"}`
1111
+ if (remaining.length === 0) {
1112
+ console.log(result.fixed.length ? `fixed ${result.fixed.length}, ${scope} clean` : `ok — ${scope}`)
1113
+ } else {
1114
+ const fixable = remaining.filter((issue) => issue.fixable).length
1115
+ console.log(
1116
+ `${remaining.length} problem${remaining.length === 1 ? "" : "s"} in ${scope}${fixable ? ` (${fixable} fixable — run \`task check --fix\`)` : ""}`,
1117
+ )
1118
+ }
609
1119
  }
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`)
1120
+ if (remaining.length > 0) process.exitCode = 1
618
1121
  }
619
1122
 
620
1123
  function cmdDelete(args: Args): void {
621
1124
  const ref = args.positional[0]
622
1125
  if (!ref) fail("usage: task delete <id>")
623
1126
  const store = openStoreFor(ref)
624
- const number = store.parseId(ref)
625
- store.delete(number)
626
- console.log(`deleted ${store.displayId(number)}`)
1127
+ const key = store.parseId(ref)
1128
+ store.delete(key)
1129
+ console.log(`deleted ${store.displayId(key)}`)
627
1130
  }
628
1131
 
629
1132
  /**
@@ -818,51 +1321,142 @@ subdirectory (it walks up to find .task/, like git).
818
1321
 
819
1322
  Usage
820
1323
  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>]
1324
+ task add <title> [--description <text> | --description-file <path|->]
1325
+ [--status <s>] [--tags <a,b>] [--goal <slug>]
1326
+ [--ask "<text>"]
1327
+ new tickets land in backlog (the dump list)
1328
+ unless --status says otherwise;
1329
+ --description-file reads markdown from a file
1330
+ (or stdin with -) — no shell-quoting fights;
1331
+ --ask (repeatable) files what a person owes
1332
+ the ticket, right at creation
1333
+ task list [--status <s1,s2>] [--tag <a,b>] [--goal <slug>]
824
1334
  [--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>]
828
- [--needs-human | --no-needs-human]
1335
+ task search <query> [--status <s1,s2>] [--tag <a,b>] [--goal <slug>]
1336
+ [--needs-human] [--archived]
1337
+ find tickets by what they say — case-insensitive
1338
+ match over titles, descriptions and comments,
1339
+ with a snippet of the matching line. --archived
1340
+ searches the archive too, on top of the board
1341
+ task show <id> the ticket, its comments — and its goal's title
1342
+ and description inline, so a cold pickup gets
1343
+ the shared context without chasing it
1344
+ task update <id> [--title <t>] [--description <text> | --description-file <path|->]
1345
+ [--status <s>] [--tags <a,b>] [--goal <slug>]
829
1346
  [--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
1347
+ task move <id> <status> change a ticket's status — the one general
1348
+ status API (backlog todo in_progress done
1349
+ canceled). Moving to done is refused while
1350
+ the ticket has open asks: done means nobody
1351
+ owes anything. The named verbs below exist
1352
+ only where a transition does more than write
1353
+ the status field
1354
+ task ask <id> "<text>" file an ask: one thing a person owes this
1355
+ ticket before it can be done. Asks derive the
1356
+ needs-human badge, feed \`task inbox\`, and
1357
+ gate \`done\` — they never block claiming (an
1358
+ agent works the code while a person mints the
1359
+ token; a true precondition is a blocked_by on
1360
+ a thin human ticket instead)
1361
+ task resolve <id> <n> [--comment "<note>"] [--reopen]
1362
+ check ask <n> off (\`task show\` prints the
1363
+ ordinals); the note lands as an attributed
1364
+ comment. --reopen puts a resolved ask back
1365
+ task inbox [--board <P>] what needs YOU, now: every open ask on
1366
+ effectively in-progress tickets — file status
1367
+ in_progress, or a live claim branch on origin
1368
+ (a claimed ticket is in progress whatever the
1369
+ merged file says, and its asks are read from
1370
+ the branch). Cross-board like claim --next;
1371
+ oldest owed first. Backlog/todo asks are out
1372
+ by design — claiming is what makes them yours
1373
+ task promote <id> backlog → todo, the human "build this" call.
1374
+ No quality gate — planning happens at claim
1375
+ time. Blocked tickets still promote —
1376
+ blockers are claim-time gates, reported as a
1377
+ note — so promoting can queue work behind
1378
+ its blockers. Exit codes: 0 promoted, 2
1379
+ refused (not a backlog ticket)
833
1380
  task link <id> --blocked-by <id>
834
1381
  task link <id> --blocks <id> mark a dependency — one relation, visible from
835
1382
  both tasks (A blocked by B ⇔ B blocks A)
836
1383
  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
1384
+ task claim <id> [--force] claim a ticket before working it: branch
1385
+ <branchPrefix><prefix>-<key> off origin's
1386
+ default branch, ticket → in_progress as its
1387
+ first commit, pushed. The namespace defaults
1388
+ to task/claim/ — set claims.branchPrefix in
842
1389
  .task/config.json to change it (e.g.
843
1390
  "claude/task/", which Claude cloud sessions
844
1391
  can push). Branch on origin = claimed —
845
1392
  git's atomic ref creation is the lock, so two
846
- concurrent claimers can't both win. Exit codes:
1393
+ concurrent claimers can't both win. While the
1394
+ root board sets claims.maxOpenCount and that
1395
+ many claim branches exist on origin, claiming
1396
+ is refused (--force overrides). Exit codes:
847
1397
  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
1398
+ (not todo, blocked, dirty tree, at the cap)
1399
+ task claim --next [--board <P>] [--force]
1400
+ claim the top claimable ticket in one call,
1401
+ retrying past lost races internally — the
1402
+ first line of every scheduled worker. Scope:
1403
+ --board prefixes (repeatable, given order =
1404
+ priority) > the root board's "boards" list >
1405
+ the nearest board. Exit codes: 0 claimed
1406
+ (prints the ticket), 1 queue empty, 2
1407
+ preconditions failed
1408
+ task claim --release <id> --comment "<why>"
1409
+ abandon a claim: delete the branch on origin
850
1410
  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
853
- order, minus blocked / needs-human / already
854
- claimed on origin. Top entry is next up
1411
+ the branch, so deleting it is the revert. The
1412
+ comment is required and lands on the ticket
1413
+ (attributed like task comment, uncommitted),
1414
+ so the next worker inherits what was tried
1415
+ task list --claimable [--board <P>]
1416
+ the claim queue: todo tickets in position
1417
+ order, minus blocked / already claimed on
1418
+ origin. Top entry is next up. Same board
1419
+ scope as claim --next
1420
+ task instructions print the shipped agent conventions
1421
+ (skill/SKILL.md) — agents without the skill
1422
+ installed self-serve the house rules
1423
+ task overview [--json] one screen of board health: counts per
1424
+ status, per-goal progress, claimed and
1425
+ open-ask counts, stale tickets (untouched
1426
+ ${STALE_DAYS}+ days), oldest open ticket
855
1427
  task comment <id> <text> [--author <who>]
856
1428
  task delete <id>
1429
+ task goal add <title> [--slug <s>] [--description <text>]
1430
+ create a goal: a titled, described destination
1431
+ tasks belong to (.task/goals/<slug>.md). The
1432
+ description is the strategic why — the context
1433
+ every ticket under it shares
1434
+ task goal list [--archived] every goal with derived progress (done/total —
1435
+ a goal stores no status of its own, ever)
1436
+ task goal show <slug> the goal, its description, and its tasks
1437
+ task goal update <slug> [--title <t>] [--description <text>]
1438
+ task goal archive <slug> shelve a finished/abandoned goal — refused
1439
+ while it still has open tasks
1440
+ task goal unarchive <slug>
1441
+ task goal delete <slug> only when no live task references it
857
1442
  task boards every board in this repo — prefix, name, path,
858
1443
  open count; * marks the one commands target here
859
1444
  task archive <id> move a done/canceled ticket to .task/archive/,
860
1445
  out of the board and off the hot path — still
861
1446
  readable via show and list --archived, and its
862
- number stays reserved
1447
+ key stays reserved
863
1448
  task archive --all archive everything done or canceled
864
1449
  task unarchive <id> put an archived ticket back on the board
865
1450
  task whoami who your comments are attributed to
1451
+ task check [--fix] lint the board files — merges and hand-edits
1452
+ are write paths no CLI code sees. Reports
1453
+ unparseable files, dangling blocked_by refs,
1454
+ bad timestamps, unknown frontmatter keys and
1455
+ formatting drift, for every board at or below
1456
+ here; exits non-zero while problems remain.
1457
+ --fix applies the mechanical repairs first
1458
+ (prune dangling refs, rewrite to canonical
1459
+ form). Cheap enough for pre-commit and CI
866
1460
  task serve [--port <n>] [--no-open] [--strict-port]
867
1461
  board + table UI with live updates, opened in
868
1462
  your browser (default port ${DEFAULT_PORT}; taken ports
@@ -880,19 +1474,22 @@ Usage
880
1474
  task unpublish [--repo <owner/name>]
881
1475
  take that URL down. Removes the board, not the
882
1476
  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
1477
 
887
1478
  Values
888
- <id> TAS-12, or just 12. A bare number means the nearest board; a
889
- prefixed id routes to whichever board in the repo owns that
890
- prefix, so TAS-12 works from anywhere in a monorepo
1479
+ <id> TAS-x7k4m, x7k4m, or any unique prefix of a key (task show x7
1480
+ works, git-style ambiguity is an error naming the matches).
1481
+ Ids are random 5-character keys, minted branch-safely at
1482
+ \`task add\`. A bare key means the nearest board; a prefixed id
1483
+ routes to whichever board in the repo owns that prefix, so
1484
+ TAS-x7k4m works from anywhere in a monorepo
891
1485
  status ${STATUSES.join(" ")}
892
1486
  --tag a,b matches a task carrying *either* tag
893
- --needs-human this can't be finished by an agent alone
1487
+ --goal <slug> a task belongs to at most one goal. Goals answer "where is
1488
+ this going" (one); tags answer "what kind" (many) — something
1489
+ that wants two goals is a tag
1490
+ --needs-human filter: tickets with open asks — something a person owes
894
1491
  --pr <url> attach a pull request (appends); --prs replaces the whole list
895
- clearing --tags "" drops all tags, --milestone "" clears it, --prs ""
1492
+ clearing --tags "" drops all tags, --goal "" clears it, --prs ""
896
1493
  detaches all PRs
897
1494
 
898
1495
  Comment authors resolve --author → $TASK_AUTHOR → git config user.name →
@@ -923,22 +1520,34 @@ function main(): void | Promise<void> {
923
1520
  return cmdAdd(args)
924
1521
  case "list":
925
1522
  return cmdList(args)
1523
+ case "search":
1524
+ return cmdSearch(args)
926
1525
  case "show":
927
1526
  return cmdShow(args)
928
1527
  case "update":
929
1528
  return cmdUpdate(args)
930
1529
  case "move":
931
1530
  return cmdMove(args)
932
- case "start":
933
- return cmdUpdate(args, "in_progress")
934
- case "done":
935
- return cmdUpdate(args, "done")
1531
+ case "promote":
1532
+ return cmdPromote(args)
936
1533
  case "link":
937
1534
  return cmdLink(args, "link")
938
1535
  case "unlink":
939
1536
  return cmdLink(args, "unlink")
940
1537
  case "claim":
941
1538
  return cmdClaim(args)
1539
+ case "instructions":
1540
+ return cmdInstructions()
1541
+ case "ask":
1542
+ return cmdAsk(args)
1543
+ case "resolve":
1544
+ return cmdResolve(args)
1545
+ case "inbox":
1546
+ return cmdInbox(args)
1547
+ case "overview":
1548
+ return cmdOverview(args)
1549
+ case "goal":
1550
+ return cmdGoal(args)
942
1551
  case "comment":
943
1552
  return cmdComment(args)
944
1553
  case "whoami":
@@ -951,8 +1560,8 @@ function main(): void | Promise<void> {
951
1560
  return cmdArchive(args)
952
1561
  case "unarchive":
953
1562
  return cmdUnarchive(args)
954
- case "migrate":
955
- return cmdMigrate(args)
1563
+ case "check":
1564
+ return cmdCheck(args)
956
1565
  case "serve":
957
1566
  return cmdServe(args)
958
1567
  case "publish":