@nickmeriano/task 0.8.0 → 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 (114) hide show
  1. package/README.md +65 -22
  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 +3 -2
  7. package/dist/check.d.ts.map +1 -1
  8. package/dist/check.js +99 -60
  9. package/dist/check.js.map +1 -1
  10. package/dist/check.test.js +99 -60
  11. package/dist/check.test.js.map +1 -1
  12. package/dist/claim-io.d.ts +73 -0
  13. package/dist/claim-io.d.ts.map +1 -0
  14. package/dist/claim-io.js +344 -0
  15. package/dist/claim-io.js.map +1 -0
  16. package/dist/claim.d.ts +21 -22
  17. package/dist/claim.d.ts.map +1 -1
  18. package/dist/claim.js +58 -83
  19. package/dist/claim.js.map +1 -1
  20. package/dist/claim.test.d.ts +2 -2
  21. package/dist/claim.test.js +111 -94
  22. package/dist/claim.test.js.map +1 -1
  23. package/dist/cli.js +241 -64
  24. package/dist/cli.js.map +1 -1
  25. package/dist/file-store.d.ts +70 -24
  26. package/dist/file-store.d.ts.map +1 -1
  27. package/dist/file-store.js +319 -141
  28. package/dist/file-store.js.map +1 -1
  29. package/dist/git-serve.d.ts +183 -0
  30. package/dist/git-serve.d.ts.map +1 -0
  31. package/dist/git-serve.js +503 -0
  32. package/dist/git-serve.js.map +1 -0
  33. package/dist/git-serve.test.d.ts +16 -0
  34. package/dist/git-serve.test.d.ts.map +1 -0
  35. package/dist/git-serve.test.js +183 -0
  36. package/dist/git-serve.test.js.map +1 -0
  37. package/dist/git.d.ts +65 -0
  38. package/dist/git.d.ts.map +1 -0
  39. package/dist/git.js +114 -0
  40. package/dist/git.js.map +1 -0
  41. package/dist/id.d.ts +39 -0
  42. package/dist/id.d.ts.map +1 -0
  43. package/dist/id.js +67 -0
  44. package/dist/id.js.map +1 -0
  45. package/dist/inbox.d.ts +41 -0
  46. package/dist/inbox.d.ts.map +1 -0
  47. package/dist/inbox.js +56 -0
  48. package/dist/inbox.js.map +1 -0
  49. package/dist/overview.d.ts +9 -0
  50. package/dist/overview.d.ts.map +1 -1
  51. package/dist/overview.js +19 -11
  52. package/dist/overview.js.map +1 -1
  53. package/dist/overview.test.js +6 -5
  54. package/dist/overview.test.js.map +1 -1
  55. package/dist/promote.test.d.ts +3 -2
  56. package/dist/promote.test.d.ts.map +1 -1
  57. package/dist/promote.test.js +29 -31
  58. package/dist/promote.test.js.map +1 -1
  59. package/dist/publish.d.ts +2 -17
  60. package/dist/publish.d.ts.map +1 -1
  61. package/dist/publish.js +4 -49
  62. package/dist/publish.js.map +1 -1
  63. package/dist/search.d.ts +3 -1
  64. package/dist/search.d.ts.map +1 -1
  65. package/dist/search.js +15 -2
  66. package/dist/search.js.map +1 -1
  67. package/dist/search.test.js +9 -9
  68. package/dist/search.test.js.map +1 -1
  69. package/dist/server.d.ts.map +1 -1
  70. package/dist/server.js +218 -21
  71. package/dist/server.js.map +1 -1
  72. package/dist/store.d.ts +28 -14
  73. package/dist/store.d.ts.map +1 -1
  74. package/dist/store.js.map +1 -1
  75. package/dist/store.test.js +88 -75
  76. package/dist/store.test.js.map +1 -1
  77. package/dist/ticket-doc.d.ts +26 -10
  78. package/dist/ticket-doc.d.ts.map +1 -1
  79. package/dist/ticket-doc.js +71 -27
  80. package/dist/ticket-doc.js.map +1 -1
  81. package/dist/types.d.ts +51 -17
  82. package/dist/types.d.ts.map +1 -1
  83. package/dist/types.js.map +1 -1
  84. package/package.json +1 -1
  85. package/skill/SKILL.md +70 -23
  86. package/src/asks.test.ts +355 -0
  87. package/src/check.test.ts +129 -72
  88. package/src/check.ts +101 -58
  89. package/src/claim-io.ts +401 -0
  90. package/src/claim.test.ts +141 -99
  91. package/src/claim.ts +69 -96
  92. package/src/cli.ts +251 -63
  93. package/src/file-store.ts +357 -145
  94. package/src/git-serve.test.ts +240 -0
  95. package/src/git-serve.ts +595 -0
  96. package/src/git.ts +141 -0
  97. package/src/id.ts +68 -0
  98. package/src/inbox.ts +77 -0
  99. package/src/overview.test.ts +6 -5
  100. package/src/overview.ts +25 -10
  101. package/src/promote.test.ts +40 -28
  102. package/src/publish.ts +6 -53
  103. package/src/search.test.ts +11 -11
  104. package/src/search.ts +17 -3
  105. package/src/server.ts +221 -19
  106. package/src/store.test.ts +95 -77
  107. package/src/store.ts +28 -13
  108. package/src/ticket-doc.ts +93 -30
  109. package/src/types.ts +52 -17
  110. package/ui/dist/assets/index-BjsorZOU.js +229 -0
  111. package/ui/dist/assets/index-CoKCUYic.css +1 -0
  112. package/ui/dist/index.html +2 -2
  113. package/ui/dist/assets/index-BJmOsOdR.js +0 -229
  114. package/ui/dist/assets/index-BoqQlqSU.css +0 -1
package/src/cli.ts CHANGED
@@ -6,13 +6,13 @@
6
6
  // task init
7
7
  // task add "Wire up webhooks" --tags api,infra --goal launch
8
8
  // task list --status todo,in_progress
9
- // task move TAS-3 done
9
+ // task move TAS-x7k4m done
10
10
  // task serve
11
11
 
12
12
  import { spawn } from "node:child_process"
13
13
  import { readFileSync } from "node:fs"
14
14
  import type { Server } from "node:http"
15
- import { basename, join } from "node:path"
15
+ import { basename, join, relative, sep } from "node:path"
16
16
  import process from "node:process"
17
17
  import { resolveAuthor } from "./author.ts"
18
18
  import { checkBoards, checkRoot } from "./check.ts"
@@ -25,6 +25,8 @@ import {
25
25
  release,
26
26
  selectionBoards,
27
27
  } from "./claim.ts"
28
+ import { addAskRouted, setAskResolvedRouted } from "./claim-io.ts"
29
+ import { buildInbox, type InboxEntry } from "./inbox.ts"
28
30
  import { STALE_DAYS, buildOverview } from "./overview.ts"
29
31
  import { searchStore } from "./search.ts"
30
32
  import { detectRepo, parseSlug, publish, resolveHost } from "./publish.ts"
@@ -66,6 +68,8 @@ const PORT_ATTEMPTS = 20
66
68
  interface Args {
67
69
  positional: string[]
68
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[]>
69
73
  }
70
74
 
71
75
  // Every valueless flag has to be listed here: an unregistered one swallows the
@@ -77,7 +81,6 @@ const BOOLEAN_FLAGS = new Set([
77
81
  "version",
78
82
  "yes",
79
83
  "needs-human",
80
- "no-needs-human",
81
84
  "archived",
82
85
  "claimable",
83
86
  "release",
@@ -90,6 +93,7 @@ const BOOLEAN_FLAGS = new Set([
90
93
  "fix",
91
94
  "next",
92
95
  "force",
96
+ "reopen",
93
97
  ])
94
98
 
95
99
  /**
@@ -99,14 +103,26 @@ const BOOLEAN_FLAGS = new Set([
99
103
  */
100
104
  const REPEATABLE_FLAGS = new Set(["board"])
101
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
+
102
113
  function parseArgs(argv: string[]): Args {
103
114
  const positional: string[] = []
104
115
  const flags: Record<string, string | boolean> = {}
116
+ const multi: Record<string, string[]> = {}
105
117
  for (let i = 0; i < argv.length; i++) {
106
118
  const arg = argv[i]
107
119
  if (arg.startsWith("--")) {
108
120
  const name = arg.slice(2)
109
121
  const set = (key: string, value: string): void => {
122
+ if (MULTI_FLAGS.has(key)) {
123
+ ;(multi[key] ??= []).push(value)
124
+ return
125
+ }
110
126
  const prior = flags[key]
111
127
  flags[key] =
112
128
  REPEATABLE_FLAGS.has(key) && typeof prior === "string" ? `${prior},${value}` : value
@@ -123,7 +139,7 @@ function parseArgs(argv: string[]): Args {
123
139
  positional.push(arg)
124
140
  }
125
141
  }
126
- return { positional, flags }
142
+ return { positional, flags, multi }
127
143
  }
128
144
 
129
145
  function str(flags: Args["flags"], name: string): string | undefined {
@@ -142,19 +158,19 @@ function openStore(): Store {
142
158
  return openBoard(root)
143
159
  }
144
160
 
145
- /** 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. */
146
162
  function refPrefix(ref: string): string | null {
147
- const match = /^([A-Za-z0-9]+)-\d+$/.exec(ref.trim())
163
+ const match = /^([A-Za-z0-9]+)-[A-Za-z0-9]+$/.exec(ref.trim())
148
164
  return match ? match[1].toUpperCase() : null
149
165
  }
150
166
 
151
167
  /**
152
- * 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 —
153
169
  * but a prefixed id is an address, and it routes: if the prefix isn't the
154
170
  * nearest board's, every board in the repo (walking up to the outermost board
155
- * 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
156
172
  * anywhere in a monorepo. What this must never do is what it used to: silently
157
- * 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.
158
174
  */
159
175
  function openStoreFor(ref: string | undefined): Store {
160
176
  const prefix = ref ? refPrefix(ref) : null
@@ -192,7 +208,7 @@ function openStoreFor(ref: string | undefined): Store {
192
208
  * instead of reinterpreting it — this guards the second id in `task link`,
193
209
  * where the board was picked by the first.
194
210
  */
195
- function parseRefOn(store: Store, ref: string): number {
211
+ function parseRefOn(store: Store, ref: string): string {
196
212
  const prefix = refPrefix(ref)
197
213
  if (prefix && prefix !== store.config.prefix.toUpperCase()) {
198
214
  fail(`${ref} is not on the ${store.config.prefix} board — links can't cross boards`)
@@ -238,8 +254,6 @@ function patchFromFlags(flags: Args["flags"]): TaskPatch {
238
254
  if (tags !== undefined) patch.tags = parseTags(tags)
239
255
  const goal = str(flags, "goal")
240
256
  if (goal !== undefined) patch.goal = goal.trim() || null
241
- if (flags["needs-human"]) patch.needsHuman = true
242
- if (flags["no-needs-human"]) patch.needsHuman = false
243
257
  // Replace semantics like --tags; `--pr` (append one) is handled per-command
244
258
  // because appending needs the task's current list.
245
259
  const prs = str(flags, "prs")
@@ -304,16 +318,23 @@ function cmdInit(args: Args): void {
304
318
 
305
319
  function cmdAdd(args: Args): void {
306
320
  const title = args.positional.join(" ").trim()
307
- if (!title) fail(`usage: task add <title> [--description …] [--status …] [--tags a,b] [--goal <slug>] [--needs-human]`)
321
+ if (!title) fail(`usage: task add <title> [--description …] [--status …] [--tags a,b] [--goal <slug>] [--ask "<text>"]…`)
308
322
  const store = openStore()
309
323
  const patch = patchFromFlags(args.flags)
310
324
  const pr = str(args.flags, "pr")
311
325
  if (pr) patch.prs = [...(patch.prs ?? []), pr]
312
- 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
+ }
313
333
  if (args.flags.json) {
314
334
  console.log(JSON.stringify({ task }, null, 2))
315
335
  } else {
316
336
  printTask(task)
337
+ for (const ask of task.asks) console.log(` ask [${ask.ordinal}] ${ask.text.split("\n")[0]}`)
317
338
  }
318
339
  }
319
340
 
@@ -500,7 +521,7 @@ function cmdArchive(args: Args): void {
500
521
 
501
522
  const store = openStore()
502
523
  const finished = store.list({ statuses: ["done", "canceled"] })
503
- const archived = finished.map((t) => store.archive(t.number))
524
+ const archived = finished.map((t) => store.archive(t.key))
504
525
  if (args.flags.json) {
505
526
  console.log(JSON.stringify({ archived }, null, 2))
506
527
  } else if (archived.length === 0) {
@@ -526,10 +547,10 @@ function cmdShow(args: Args): void {
526
547
  const ref = args.positional[0]
527
548
  if (!ref) fail("usage: task show <id>")
528
549
  const store = openStoreFor(ref)
529
- const number = store.parseId(ref)
530
- const task = store.get(number)
531
- if (!task) fail(`no such task: ${store.displayId(number)}`)
532
- 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)
533
554
  // The goal rides along in full — title *and* description — so an agent
534
555
  // picking the ticket up cold inherits the shared context without it being
535
556
  // pasted into every ticket. null when unset or when the ref dangles.
@@ -539,17 +560,27 @@ function cmdShow(args: Args): void {
539
560
  return
540
561
  }
541
562
  // A linked task is only as useful as knowing whether it's still in the way.
542
- const describeLinks = (numbers: number[]): string =>
543
- numbers
544
- .map((n) => {
545
- const other = store.get(n)
546
- 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)
547
568
  })
548
569
  .join(", ")
549
570
  console.log(`${task.id} ${task.title}`)
550
571
  console.log(`status ${task.status}`)
551
572
  if (task.archived) console.log(`archived yes — \`task unarchive ${task.id}\` to edit`)
552
- 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
+ }
553
584
  if (task.tags.length) console.log(`tags ${task.tags.join(", ")}`)
554
585
  if (task.goal) {
555
586
  console.log(`goal ${task.goal}${goal ? ` — ${goal.title}${goal.archived ? " (archived)" : ""}` : ""}`)
@@ -577,16 +608,16 @@ function cmdUpdate(args: Args): void {
577
608
  const ref = args.positional[0]
578
609
  if (!ref) fail("usage: task update <id> [--status …] [--title …] …")
579
610
  const store = openStoreFor(ref)
580
- const number = store.parseId(ref)
611
+ const key = store.parseId(ref)
581
612
  const patch = patchFromFlags(args.flags)
582
613
  const pr = str(args.flags, "pr")
583
614
  if (pr) {
584
615
  // Append, dedup — `--pr <url>` is "attach this PR", not "replace the list".
585
- const current = patch.prs ?? store.get(number)?.prs ?? []
616
+ const current = patch.prs ?? store.get(key)?.prs ?? []
586
617
  patch.prs = current.includes(pr) ? current : [...current, pr]
587
618
  }
588
619
  if (Object.keys(patch).length === 0) fail("nothing to update — pass at least one flag")
589
- const task = store.update(number, patch)
620
+ const task = store.update(key, patch)
590
621
  if (args.flags.json) {
591
622
  console.log(JSON.stringify({ task }, null, 2))
592
623
  } else {
@@ -597,7 +628,7 @@ function cmdUpdate(args: Args): void {
597
628
  function cmdMove(args: Args): void {
598
629
  const [ref, status] = args.positional
599
630
  if (!ref || !status) fail("usage: task move <id> <status>")
600
- cmdUpdate({ positional: [ref], flags: { ...args.flags, status } })
631
+ cmdUpdate({ positional: [ref], flags: { ...args.flags, status }, multi: {} })
601
632
  }
602
633
 
603
634
  /**
@@ -628,6 +659,129 @@ function cmdPromote(args: Args): void {
628
659
  }
629
660
  }
630
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
+ }
783
+ }
784
+
631
785
  function cmdComment(args: Args): void {
632
786
  const [ref, ...rest] = args.positional
633
787
  const body = rest.join(" ").trim()
@@ -657,9 +811,9 @@ function cmdLink(args: Args, action: "link" | "unlink"): void {
657
811
  const store = openStoreFor(ref)
658
812
  const relation = blocks ? "blocks" : "blocked_by"
659
813
  const target = parseRefOn(store, (blocks ?? blockedBy)!)
660
- const number = store.parseId(ref)
814
+ const key = store.parseId(ref)
661
815
  const task =
662
- 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)
663
817
  if (args.flags.json) {
664
818
  console.log(JSON.stringify({ task }, null, 2))
665
819
  } else {
@@ -712,7 +866,7 @@ function cmdClaim(args: Args): void {
712
866
  }
713
867
  if (!ref) fail(CLAIM_USAGE)
714
868
  const store = openStoreFor(ref)
715
- const number = store.parseId(ref)
869
+ const key = store.parseId(ref)
716
870
  if (args.flags.release) {
717
871
  // A released claim must leave its failure context behind: the branch
718
872
  // (and whatever was tried on it) is about to evaporate, so the reason
@@ -723,26 +877,26 @@ function cmdClaim(args: Args): void {
723
877
  'task claim --release requires --comment "<why>" — the next worker inherits what was tried',
724
878
  )
725
879
  }
726
- const result = release(store, number)
880
+ const result = release(store, key)
727
881
  const commented = result.remote || result.local
728
882
  // After the branch deletion, so the comment survives it: release leaves
729
883
  // the checkout on the default branch, and the comment file sits there
730
884
  // uncommitted, to ride along with the next commit.
731
885
  const comment = commented
732
- ? store.addComment(number, why, resolveAuthor(str(args.flags, "author")).name)
886
+ ? store.addComment(key, why, resolveAuthor(str(args.flags, "author")).name)
733
887
  : null
734
888
  if (args.flags.json) {
735
889
  console.log(JSON.stringify({ released: result, comment }, null, 2))
736
890
  } else if (!commented) {
737
- console.log(`${store.displayId(number)} wasn't claimed — no ${result.branch} to delete`)
891
+ console.log(`${store.displayId(key)} wasn't claimed — no ${result.branch} to delete`)
738
892
  } else {
739
893
  const where = [result.remote && "origin", result.local && "local"].filter(Boolean)
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`)
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`)
742
896
  }
743
897
  return
744
898
  }
745
- printClaim(claim(store, number, options))
899
+ printClaim(claim(store, key, options))
746
900
  } catch (error) {
747
901
  if (error instanceof ClaimError) {
748
902
  console.error(`error: ${error.message}`)
@@ -774,7 +928,10 @@ function cmdOverview(args: Args): void {
774
928
  overview.claimed === null
775
929
  ? "claimed ? (origin unreachable)"
776
930
  : `claimed ${overview.claimed.length}${overview.claimed.length ? ` (${overview.claimed.join(", ")})` : ""}`
777
- console.log(` ${claimed} needs-human ${overview.needsHuman}`)
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}`)
778
935
  if (overview.goals.length) {
779
936
  console.log("goals")
780
937
  table(
@@ -967,9 +1124,9 @@ function cmdDelete(args: Args): void {
967
1124
  const ref = args.positional[0]
968
1125
  if (!ref) fail("usage: task delete <id>")
969
1126
  const store = openStoreFor(ref)
970
- const number = store.parseId(ref)
971
- store.delete(number)
972
- console.log(`deleted ${store.displayId(number)}`)
1127
+ const key = store.parseId(ref)
1128
+ store.delete(key)
1129
+ console.log(`deleted ${store.displayId(key)}`)
973
1130
  }
974
1131
 
975
1132
  /**
@@ -1165,11 +1322,14 @@ subdirectory (it walks up to find .task/, like git).
1165
1322
  Usage
1166
1323
  task init [--name <name>] [--prefix <PREFIX>]
1167
1324
  task add <title> [--description <text> | --description-file <path|->]
1168
- [--status <s>] [--tags <a,b>] [--goal <slug>] [--needs-human]
1325
+ [--status <s>] [--tags <a,b>] [--goal <slug>]
1326
+ [--ask "<text>"]…
1169
1327
  new tickets land in backlog (the dump list)
1170
1328
  unless --status says otherwise;
1171
1329
  --description-file reads markdown from a file
1172
- (or stdin with -) — no shell-quoting fights
1330
+ (or stdin with -) — no shell-quoting fights;
1331
+ --ask (repeatable) files what a person owes
1332
+ the ticket, right at creation
1173
1333
  task list [--status <s1,s2>] [--tag <a,b>] [--goal <slug>]
1174
1334
  [--needs-human] [--all] [--archived] [--claimable]
1175
1335
  task search <query> [--status <s1,s2>] [--tag <a,b>] [--goal <slug>]
@@ -1183,18 +1343,38 @@ Usage
1183
1343
  the shared context without chasing it
1184
1344
  task update <id> [--title <t>] [--description <text> | --description-file <path|->]
1185
1345
  [--status <s>] [--tags <a,b>] [--goal <slug>]
1186
- [--needs-human | --no-needs-human]
1187
1346
  [--pr <url>] [--prs <url1,url2>]
1188
1347
  task move <id> <status> change a ticket's status — the one general
1189
1348
  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
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
1193
1373
  task promote <id> backlog → todo, the human "build this" call.
1194
1374
  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
1375
+ time. Blocked tickets still promote
1376
+ blockers are claim-time gates, reported as a
1377
+ note — so promoting can queue work behind
1198
1378
  its blockers. Exit codes: 0 promoted, 2
1199
1379
  refused (not a backlog ticket)
1200
1380
  task link <id> --blocked-by <id>
@@ -1202,7 +1382,7 @@ Usage
1202
1382
  both tasks (A blocked by B ⇔ B blocks A)
1203
1383
  task unlink <id> (--blocks <id> | --blocked-by <id>)
1204
1384
  task claim <id> [--force] claim a ticket before working it: branch
1205
- <branchPrefix><prefix>-<n> off origin's
1385
+ <branchPrefix><prefix>-<key> off origin's
1206
1386
  default branch, ticket → in_progress as its
1207
1387
  first commit, pushed. The namespace defaults
1208
1388
  to task/claim/ — set claims.branchPrefix in
@@ -1215,8 +1395,7 @@ Usage
1215
1395
  many claim branches exist on origin, claiming
1216
1396
  is refused (--force overrides). Exit codes:
1217
1397
  0 claimed, 1 already claimed, 2 not claimable
1218
- (not todo, blocked, needs-human, dirty tree,
1219
- at the cap)
1398
+ (not todo, blocked, dirty tree, at the cap)
1220
1399
  task claim --next [--board <P>] [--force]
1221
1400
  claim the top claimable ticket in one call,
1222
1401
  retrying past lost races internally — the
@@ -1235,15 +1414,15 @@ Usage
1235
1414
  so the next worker inherits what was tried
1236
1415
  task list --claimable [--board <P>]
1237
1416
  the claim queue: todo tickets in position
1238
- order, minus blocked / needs-human / already
1239
- claimed on origin. Top entry is next up. Same
1240
- board scope as claim --next
1417
+ order, minus blocked / already claimed on
1418
+ origin. Top entry is next up. Same board
1419
+ scope as claim --next
1241
1420
  task instructions print the shipped agent conventions
1242
1421
  (skill/SKILL.md) — agents without the skill
1243
1422
  installed self-serve the house rules
1244
1423
  task overview [--json] one screen of board health: counts per
1245
1424
  status, per-goal progress, claimed and
1246
- needs-human counts, stale tickets (untouched
1425
+ open-ask counts, stale tickets (untouched
1247
1426
  ${STALE_DAYS}+ days), oldest open ticket
1248
1427
  task comment <id> <text> [--author <who>]
1249
1428
  task delete <id>
@@ -1265,7 +1444,7 @@ Usage
1265
1444
  task archive <id> move a done/canceled ticket to .task/archive/,
1266
1445
  out of the board and off the hot path — still
1267
1446
  readable via show and list --archived, and its
1268
- number stays reserved
1447
+ key stays reserved
1269
1448
  task archive --all archive everything done or canceled
1270
1449
  task unarchive <id> put an archived ticket back on the board
1271
1450
  task whoami who your comments are attributed to
@@ -1297,15 +1476,18 @@ Usage
1297
1476
  tasks — those are in .task/ either way
1298
1477
 
1299
1478
  Values
1300
- <id> TAS-12, or just 12. A bare number means the nearest board; a
1301
- prefixed id routes to whichever board in the repo owns that
1302
- 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
1303
1485
  status ${STATUSES.join(" ")}
1304
1486
  --tag a,b matches a task carrying *either* tag
1305
1487
  --goal <slug> a task belongs to at most one goal. Goals answer "where is
1306
1488
  this going" (one); tags answer "what kind" (many) — something
1307
1489
  that wants two goals is a tag
1308
- --needs-human this can't be finished by an agent alone
1490
+ --needs-human filter: tickets with open asks something a person owes
1309
1491
  --pr <url> attach a pull request (appends); --prs replaces the whole list
1310
1492
  clearing --tags "" drops all tags, --goal "" clears it, --prs ""
1311
1493
  detaches all PRs
@@ -1356,6 +1538,12 @@ function main(): void | Promise<void> {
1356
1538
  return cmdClaim(args)
1357
1539
  case "instructions":
1358
1540
  return cmdInstructions()
1541
+ case "ask":
1542
+ return cmdAsk(args)
1543
+ case "resolve":
1544
+ return cmdResolve(args)
1545
+ case "inbox":
1546
+ return cmdInbox(args)
1359
1547
  case "overview":
1360
1548
  return cmdOverview(args)
1361
1549
  case "goal":