@nickmeriano/task 0.8.0 → 0.10.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 (115) hide show
  1. package/README.md +137 -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 +108 -23
  17. package/dist/claim.d.ts.map +1 -1
  18. package/dist/claim.js +320 -86
  19. package/dist/claim.js.map +1 -1
  20. package/dist/claim.test.d.ts +10 -2
  21. package/dist/claim.test.d.ts.map +1 -1
  22. package/dist/claim.test.js +282 -96
  23. package/dist/claim.test.js.map +1 -1
  24. package/dist/cli.js +346 -68
  25. package/dist/cli.js.map +1 -1
  26. package/dist/file-store.d.ts +70 -24
  27. package/dist/file-store.d.ts.map +1 -1
  28. package/dist/file-store.js +319 -141
  29. package/dist/file-store.js.map +1 -1
  30. package/dist/git-serve.d.ts +183 -0
  31. package/dist/git-serve.d.ts.map +1 -0
  32. package/dist/git-serve.js +503 -0
  33. package/dist/git-serve.js.map +1 -0
  34. package/dist/git-serve.test.d.ts +16 -0
  35. package/dist/git-serve.test.d.ts.map +1 -0
  36. package/dist/git-serve.test.js +183 -0
  37. package/dist/git-serve.test.js.map +1 -0
  38. package/dist/git.d.ts +65 -0
  39. package/dist/git.d.ts.map +1 -0
  40. package/dist/git.js +114 -0
  41. package/dist/git.js.map +1 -0
  42. package/dist/id.d.ts +39 -0
  43. package/dist/id.d.ts.map +1 -0
  44. package/dist/id.js +67 -0
  45. package/dist/id.js.map +1 -0
  46. package/dist/inbox.d.ts +41 -0
  47. package/dist/inbox.d.ts.map +1 -0
  48. package/dist/inbox.js +56 -0
  49. package/dist/inbox.js.map +1 -0
  50. package/dist/overview.d.ts +9 -0
  51. package/dist/overview.d.ts.map +1 -1
  52. package/dist/overview.js +19 -11
  53. package/dist/overview.js.map +1 -1
  54. package/dist/overview.test.js +6 -5
  55. package/dist/overview.test.js.map +1 -1
  56. package/dist/promote.test.d.ts +3 -2
  57. package/dist/promote.test.d.ts.map +1 -1
  58. package/dist/promote.test.js +29 -31
  59. package/dist/promote.test.js.map +1 -1
  60. package/dist/publish.d.ts +2 -17
  61. package/dist/publish.d.ts.map +1 -1
  62. package/dist/publish.js +4 -49
  63. package/dist/publish.js.map +1 -1
  64. package/dist/search.d.ts +3 -1
  65. package/dist/search.d.ts.map +1 -1
  66. package/dist/search.js +15 -2
  67. package/dist/search.js.map +1 -1
  68. package/dist/search.test.js +9 -9
  69. package/dist/search.test.js.map +1 -1
  70. package/dist/server.d.ts.map +1 -1
  71. package/dist/server.js +218 -21
  72. package/dist/server.js.map +1 -1
  73. package/dist/store.d.ts +28 -14
  74. package/dist/store.d.ts.map +1 -1
  75. package/dist/store.js.map +1 -1
  76. package/dist/store.test.js +88 -75
  77. package/dist/store.test.js.map +1 -1
  78. package/dist/ticket-doc.d.ts +26 -10
  79. package/dist/ticket-doc.d.ts.map +1 -1
  80. package/dist/ticket-doc.js +71 -27
  81. package/dist/ticket-doc.js.map +1 -1
  82. package/dist/types.d.ts +51 -17
  83. package/dist/types.d.ts.map +1 -1
  84. package/dist/types.js.map +1 -1
  85. package/package.json +1 -1
  86. package/skill/SKILL.md +96 -24
  87. package/src/asks.test.ts +355 -0
  88. package/src/check.test.ts +129 -72
  89. package/src/check.ts +101 -58
  90. package/src/claim-io.ts +401 -0
  91. package/src/claim.test.ts +372 -101
  92. package/src/claim.ts +418 -99
  93. package/src/cli.ts +370 -67
  94. package/src/file-store.ts +357 -145
  95. package/src/git-serve.test.ts +240 -0
  96. package/src/git-serve.ts +595 -0
  97. package/src/git.ts +141 -0
  98. package/src/id.ts +68 -0
  99. package/src/inbox.ts +77 -0
  100. package/src/overview.test.ts +6 -5
  101. package/src/overview.ts +25 -10
  102. package/src/promote.test.ts +40 -28
  103. package/src/publish.ts +6 -53
  104. package/src/search.test.ts +11 -11
  105. package/src/search.ts +17 -3
  106. package/src/server.ts +221 -19
  107. package/src/store.test.ts +95 -77
  108. package/src/store.ts +28 -13
  109. package/src/ticket-doc.ts +93 -30
  110. package/src/types.ts +52 -17
  111. package/ui/dist/assets/index-BjsorZOU.js +229 -0
  112. package/ui/dist/assets/index-CoKCUYic.css +1 -0
  113. package/ui/dist/index.html +2 -2
  114. package/ui/dist/assets/index-BJmOsOdR.js +0 -229
  115. package/ui/dist/assets/index-BoqQlqSU.css +0 -1
package/src/cli.ts CHANGED
@@ -6,25 +6,29 @@
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"
19
19
  import {
20
20
  ClaimError,
21
21
  claim,
22
+ claimLockOnly,
22
23
  claimNext,
23
24
  claimableTasks,
24
25
  promote,
25
26
  release,
26
27
  selectionBoards,
28
+ sweep,
27
29
  } from "./claim.ts"
30
+ import { addAskRouted, setAskResolvedRouted } from "./claim-io.ts"
31
+ import { buildInbox, type InboxEntry } from "./inbox.ts"
28
32
  import { STALE_DAYS, buildOverview } from "./overview.ts"
29
33
  import { searchStore } from "./search.ts"
30
34
  import { detectRepo, parseSlug, publish, resolveHost } from "./publish.ts"
@@ -66,6 +70,8 @@ const PORT_ATTEMPTS = 20
66
70
  interface Args {
67
71
  positional: string[]
68
72
  flags: Record<string, string | boolean>
73
+ /** Values of MULTI_FLAGS, in given order — e.g. multi.ask from `--ask "…"`. */
74
+ multi: Record<string, string[]>
69
75
  }
70
76
 
71
77
  // Every valueless flag has to be listed here: an unregistered one swallows the
@@ -77,7 +83,6 @@ const BOOLEAN_FLAGS = new Set([
77
83
  "version",
78
84
  "yes",
79
85
  "needs-human",
80
- "no-needs-human",
81
86
  "archived",
82
87
  "claimable",
83
88
  "release",
@@ -90,6 +95,8 @@ const BOOLEAN_FLAGS = new Set([
90
95
  "fix",
91
96
  "next",
92
97
  "force",
98
+ "reopen",
99
+ "lock-only",
93
100
  ])
94
101
 
95
102
  /**
@@ -99,14 +106,26 @@ const BOOLEAN_FLAGS = new Set([
99
106
  */
100
107
  const REPEATABLE_FLAGS = new Set(["board"])
101
108
 
109
+ /**
110
+ * Repeatable flags whose values are free text — collected verbatim into a
111
+ * list, never comma-joined, because an ask like "check A, then B" must
112
+ * survive as one ask.
113
+ */
114
+ const MULTI_FLAGS = new Set(["ask"])
115
+
102
116
  function parseArgs(argv: string[]): Args {
103
117
  const positional: string[] = []
104
118
  const flags: Record<string, string | boolean> = {}
119
+ const multi: Record<string, string[]> = {}
105
120
  for (let i = 0; i < argv.length; i++) {
106
121
  const arg = argv[i]
107
122
  if (arg.startsWith("--")) {
108
123
  const name = arg.slice(2)
109
124
  const set = (key: string, value: string): void => {
125
+ if (MULTI_FLAGS.has(key)) {
126
+ ;(multi[key] ??= []).push(value)
127
+ return
128
+ }
110
129
  const prior = flags[key]
111
130
  flags[key] =
112
131
  REPEATABLE_FLAGS.has(key) && typeof prior === "string" ? `${prior},${value}` : value
@@ -123,7 +142,7 @@ function parseArgs(argv: string[]): Args {
123
142
  positional.push(arg)
124
143
  }
125
144
  }
126
- return { positional, flags }
145
+ return { positional, flags, multi }
127
146
  }
128
147
 
129
148
  function str(flags: Args["flags"], name: string): string | undefined {
@@ -142,19 +161,19 @@ function openStore(): Store {
142
161
  return openBoard(root)
143
162
  }
144
163
 
145
- /** The prefix a ref carries, if any: "TAS-12" → "TAS", "12" → null. */
164
+ /** The prefix a ref carries, if any: "TAS-x7k4m" → "TAS", "x7k4m" → null. */
146
165
  function refPrefix(ref: string): string | null {
147
- const match = /^([A-Za-z0-9]+)-\d+$/.exec(ref.trim())
166
+ const match = /^([A-Za-z0-9]+)-[A-Za-z0-9]+$/.exec(ref.trim())
148
167
  return match ? match[1].toUpperCase() : null
149
168
  }
150
169
 
151
170
  /**
152
- * The board a ref belongs to. A bare number means the nearest board, as ever —
171
+ * The board a ref belongs to. A bare key means the nearest board, as ever —
153
172
  * but a prefixed id is an address, and it routes: if the prefix isn't the
154
173
  * 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
174
+ * root, then down) is searched for it, so `task show TAS-x7k4m` works from
156
175
  * 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.
176
+ * strip a foreign prefix and act on the nearest board's ticket of that key.
158
177
  */
159
178
  function openStoreFor(ref: string | undefined): Store {
160
179
  const prefix = ref ? refPrefix(ref) : null
@@ -192,7 +211,7 @@ function openStoreFor(ref: string | undefined): Store {
192
211
  * instead of reinterpreting it — this guards the second id in `task link`,
193
212
  * where the board was picked by the first.
194
213
  */
195
- function parseRefOn(store: Store, ref: string): number {
214
+ function parseRefOn(store: Store, ref: string): string {
196
215
  const prefix = refPrefix(ref)
197
216
  if (prefix && prefix !== store.config.prefix.toUpperCase()) {
198
217
  fail(`${ref} is not on the ${store.config.prefix} board — links can't cross boards`)
@@ -238,8 +257,6 @@ function patchFromFlags(flags: Args["flags"]): TaskPatch {
238
257
  if (tags !== undefined) patch.tags = parseTags(tags)
239
258
  const goal = str(flags, "goal")
240
259
  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
260
  // Replace semantics like --tags; `--pr` (append one) is handled per-command
244
261
  // because appending needs the task's current list.
245
262
  const prs = str(flags, "prs")
@@ -304,16 +321,23 @@ function cmdInit(args: Args): void {
304
321
 
305
322
  function cmdAdd(args: Args): void {
306
323
  const title = args.positional.join(" ").trim()
307
- if (!title) fail(`usage: task add <title> [--description …] [--status …] [--tags a,b] [--goal <slug>] [--needs-human]`)
324
+ if (!title) fail(`usage: task add <title> [--description …] [--status …] [--tags a,b] [--goal <slug>] [--ask "<text>"]…`)
308
325
  const store = openStore()
309
326
  const patch = patchFromFlags(args.flags)
310
327
  const pr = str(args.flags, "pr")
311
328
  if (pr) patch.prs = [...(patch.prs ?? []), pr]
312
- const task = store.create({ title, ...patch })
329
+ let task = store.create({ title, ...patch })
330
+ const asks = (args.multi.ask ?? []).map((a) => a.trim()).filter(Boolean)
331
+ if (asks.length > 0) {
332
+ const author = resolveAuthor(str(args.flags, "author")).name
333
+ for (const text of asks) store.addAsk(task.key, text, author)
334
+ task = store.get(task.key)!
335
+ }
313
336
  if (args.flags.json) {
314
337
  console.log(JSON.stringify({ task }, null, 2))
315
338
  } else {
316
339
  printTask(task)
340
+ for (const ask of task.asks) console.log(` ask [${ask.ordinal}] ${ask.text.split("\n")[0]}`)
317
341
  }
318
342
  }
319
343
 
@@ -500,7 +524,7 @@ function cmdArchive(args: Args): void {
500
524
 
501
525
  const store = openStore()
502
526
  const finished = store.list({ statuses: ["done", "canceled"] })
503
- const archived = finished.map((t) => store.archive(t.number))
527
+ const archived = finished.map((t) => store.archive(t.key))
504
528
  if (args.flags.json) {
505
529
  console.log(JSON.stringify({ archived }, null, 2))
506
530
  } else if (archived.length === 0) {
@@ -526,10 +550,10 @@ function cmdShow(args: Args): void {
526
550
  const ref = args.positional[0]
527
551
  if (!ref) fail("usage: task show <id>")
528
552
  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)
553
+ const key = store.parseId(ref)
554
+ const task = store.get(key)
555
+ if (!task) fail(`no such task: ${store.displayId(key)}`)
556
+ const comments = store.comments(key)
533
557
  // The goal rides along in full — title *and* description — so an agent
534
558
  // picking the ticket up cold inherits the shared context without it being
535
559
  // pasted into every ticket. null when unset or when the ref dangles.
@@ -539,17 +563,27 @@ function cmdShow(args: Args): void {
539
563
  return
540
564
  }
541
565
  // 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)
566
+ const describeLinks = (keys: string[]): string =>
567
+ keys
568
+ .map((k) => {
569
+ const other = store.get(k)
570
+ return other ? `${other.id} (${other.status})` : store.displayId(k)
547
571
  })
548
572
  .join(", ")
549
573
  console.log(`${task.id} ${task.title}`)
550
574
  console.log(`status ${task.status}`)
551
575
  if (task.archived) console.log(`archived yes — \`task unarchive ${task.id}\` to edit`)
552
- if (task.needsHuman) console.log(`needs a human`)
576
+ const openAsks = task.asks.filter((a) => !a.resolvedAt)
577
+ if (openAsks.length) {
578
+ console.log(`needs a human — ${openAsks.length} open ask${openAsks.length === 1 ? "" : "s"}`)
579
+ }
580
+ for (const ask of task.asks) {
581
+ const state = ask.resolvedAt
582
+ ? `✓ resolved${ask.resolvedBy ? ` by ${ask.resolvedBy}` : ""}`
583
+ : `○ open${ask.author ? ` · ${ask.author}` : ""} · ${ask.createdAt.slice(0, 10)}`
584
+ console.log(`ask [${ask.ordinal}] ${ask.text.split("\n").join("\n ")}`)
585
+ console.log(` ${state}`)
586
+ }
553
587
  if (task.tags.length) console.log(`tags ${task.tags.join(", ")}`)
554
588
  if (task.goal) {
555
589
  console.log(`goal ${task.goal}${goal ? ` — ${goal.title}${goal.archived ? " (archived)" : ""}` : ""}`)
@@ -577,16 +611,16 @@ function cmdUpdate(args: Args): void {
577
611
  const ref = args.positional[0]
578
612
  if (!ref) fail("usage: task update <id> [--status …] [--title …] …")
579
613
  const store = openStoreFor(ref)
580
- const number = store.parseId(ref)
614
+ const key = store.parseId(ref)
581
615
  const patch = patchFromFlags(args.flags)
582
616
  const pr = str(args.flags, "pr")
583
617
  if (pr) {
584
618
  // Append, dedup — `--pr <url>` is "attach this PR", not "replace the list".
585
- const current = patch.prs ?? store.get(number)?.prs ?? []
619
+ const current = patch.prs ?? store.get(key)?.prs ?? []
586
620
  patch.prs = current.includes(pr) ? current : [...current, pr]
587
621
  }
588
622
  if (Object.keys(patch).length === 0) fail("nothing to update — pass at least one flag")
589
- const task = store.update(number, patch)
623
+ const task = store.update(key, patch)
590
624
  if (args.flags.json) {
591
625
  console.log(JSON.stringify({ task }, null, 2))
592
626
  } else {
@@ -597,7 +631,7 @@ function cmdUpdate(args: Args): void {
597
631
  function cmdMove(args: Args): void {
598
632
  const [ref, status] = args.positional
599
633
  if (!ref || !status) fail("usage: task move <id> <status>")
600
- cmdUpdate({ positional: [ref], flags: { ...args.flags, status } })
634
+ cmdUpdate({ positional: [ref], flags: { ...args.flags, status }, multi: {} })
601
635
  }
602
636
 
603
637
  /**
@@ -628,6 +662,129 @@ function cmdPromote(args: Args): void {
628
662
  }
629
663
  }
630
664
 
665
+ /**
666
+ * `task ask <id> <text>` — file one thing a person owes this ticket. The ask
667
+ * is the primitive every human requirement funnels through: it derives the
668
+ * needs-human badge, feeds `task inbox`, and gates `done` until resolved.
669
+ * On a claimed ticket the write lands on the claim branch as a pushed commit
670
+ * (see claim-io.ts) — the worker's done gate has to see debt filed mid-claim.
671
+ */
672
+ function cmdAsk(args: Args): void {
673
+ const [ref, ...rest] = args.positional
674
+ const text = rest.join(" ").trim()
675
+ if (!ref || !text) fail(`usage: task ask <id> "<what a person needs to do>" [--author <who>]`)
676
+ const store = openStoreFor(ref)
677
+ const author = resolveAuthor(str(args.flags, "author")).name
678
+ const { ask, branch } = addAskRouted(store, store.parseId(ref), text, author)
679
+ if (args.flags.json) {
680
+ console.log(JSON.stringify({ ask, branch }, null, 2))
681
+ } else {
682
+ console.log(`asked on ${ask.taskId} [${ask.ordinal}] ${ask.text.split("\n")[0]}`)
683
+ if (branch) console.log(`committed and pushed to ${branch} — the claim owns this ticket's asks`)
684
+ }
685
+ }
686
+
687
+ /**
688
+ * `task resolve <id> <n>` — check an ask off (`--reopen` puts it back). The
689
+ * ordinal is the one `task show` prints; the optional --comment lands as an
690
+ * attributed comment so the answer travels with the ticket, not just the
691
+ * checkbox. Resolving the last open ask is the moment a ticket usually moves
692
+ * to done — the hint below says so rather than doing it, because finishing a
693
+ * ticket is a statement about the *work*, not the asks.
694
+ */
695
+ function cmdResolve(args: Args): void {
696
+ const [ref, askRef] = args.positional
697
+ if (!ref || !askRef) {
698
+ fail('usage: task resolve <id> <ordinal> [--comment "<note>"] [--reopen] [--author <who>]')
699
+ }
700
+ const store = openStoreFor(ref)
701
+ const key = store.parseId(ref)
702
+ const author = resolveAuthor(str(args.flags, "author")).name
703
+ const { ask, branch } = setAskResolvedRouted(
704
+ store,
705
+ key,
706
+ askRef,
707
+ !args.flags.reopen,
708
+ author,
709
+ )
710
+ const note = str(args.flags, "comment")?.trim()
711
+ const comment = note
712
+ ? store.addComment(
713
+ key,
714
+ `${args.flags.reopen ? "Reopened" : "Resolved"} ask [${ask.ordinal}] (${ask.text.split("\n")[0]}): ${note}`,
715
+ author,
716
+ )
717
+ : null
718
+ const task = store.get(key)!
719
+ // Branch-resolved asks aren't in the local files — count what's still open
720
+ // on the side that was just written.
721
+ const remaining = branch
722
+ ? undefined
723
+ : task.asks.filter((a) => !a.resolvedAt).length
724
+ if (args.flags.json) {
725
+ console.log(JSON.stringify({ ask, branch, comment, openAsks: remaining ?? null }, null, 2))
726
+ return
727
+ }
728
+ const verb = args.flags.reopen ? "reopened" : "resolved"
729
+ console.log(`${verb} ${ask.taskId} [${ask.ordinal}] ${ask.text.split("\n")[0]}`)
730
+ if (branch) {
731
+ console.log(`committed and pushed to ${branch} — the worker sees it on its next pull`)
732
+ return
733
+ }
734
+ if (args.flags.reopen) return
735
+ if (remaining !== undefined && remaining > 0) {
736
+ console.log(`${remaining} ask${remaining === 1 ? "" : "s"} still open on ${ask.taskId}`)
737
+ } else if (task.status === "in_progress") {
738
+ console.log(`that was the last open ask — \`task move ${ask.taskId} done\` if the work is finished too`)
739
+ }
740
+ }
741
+
742
+ /**
743
+ * `task inbox` — what do I need to do NOW to unblock work: every open ask on
744
+ * effectively-in-progress tickets (file status, or a live claim branch —
745
+ * see inbox.ts), across the repo's boards, oldest owed first. Deliberately
746
+ * cross-board, like `claim --next`: the queue's attention is per-board, a
747
+ * person's isn't.
748
+ */
749
+ function cmdInbox(args: Args): void {
750
+ let entries: InboxEntry[]
751
+ try {
752
+ const scope = findScopeRoot(process.cwd())
753
+ const boards = selectionBoards(process.cwd(), boardFlag(args)).map((store) => {
754
+ const rel = relative(scope, store.root).split(sep).join("/")
755
+ return { id: rel === "" ? "." : rel, store }
756
+ })
757
+ entries = buildInbox(boards)
758
+ } catch (error) {
759
+ if (error instanceof ClaimError) fail(error.message)
760
+ throw error
761
+ }
762
+ if (args.flags.json) {
763
+ console.log(JSON.stringify({ inbox: entries }, null, 2))
764
+ return
765
+ }
766
+ if (entries.length === 0) {
767
+ console.log("inbox zero — no open asks on in-progress work")
768
+ return
769
+ }
770
+ const open = entries.reduce((sum, e) => sum + e.asks.length, 0)
771
+ console.log(
772
+ `${open} open ask${open === 1 ? "" : "s"} across ${entries.length} ticket${entries.length === 1 ? "" : "s"}`,
773
+ )
774
+ const age = (iso: string): string => {
775
+ const days = Math.floor((Date.now() - Date.parse(iso)) / 86_400_000)
776
+ return days > 0 ? `${days}d` : "today"
777
+ }
778
+ for (const entry of entries) {
779
+ const via = entry.claim ? ` (claimed: ${entry.claim.branch})` : ""
780
+ console.log(`\n${entry.task.id} ${entry.task.title}${via}`)
781
+ for (const ask of entry.asks) {
782
+ const who = ask.author ? ` · ${ask.author}` : ""
783
+ console.log(` [${ask.ordinal}] ${ask.text.split("\n")[0]} (${age(ask.createdAt)}${who})`)
784
+ }
785
+ }
786
+ }
787
+
631
788
  function cmdComment(args: Args): void {
632
789
  const [ref, ...rest] = args.positional
633
790
  const body = rest.join(" ").trim()
@@ -657,9 +814,9 @@ function cmdLink(args: Args, action: "link" | "unlink"): void {
657
814
  const store = openStoreFor(ref)
658
815
  const relation = blocks ? "blocks" : "blocked_by"
659
816
  const target = parseRefOn(store, (blocks ?? blockedBy)!)
660
- const number = store.parseId(ref)
817
+ const key = store.parseId(ref)
661
818
  const task =
662
- action === "link" ? store.link(number, relation, target) : store.unlink(number, relation, target)
819
+ action === "link" ? store.link(key, relation, target) : store.unlink(key, relation, target)
663
820
  if (args.flags.json) {
664
821
  console.log(JSON.stringify({ task }, null, 2))
665
822
  } else {
@@ -673,7 +830,7 @@ function cmdLink(args: Args, action: "link" | "unlink"): void {
673
830
  }
674
831
 
675
832
  const CLAIM_USAGE =
676
- 'usage: task claim <id> | task claim --next | task claim --release <id> --comment "<why>"'
833
+ 'usage: task claim <id> [--lock-only] | task claim --next | task claim --release <id> --comment "<why>"'
677
834
 
678
835
  /**
679
836
  * `task claim <id>` / `task claim --next` / `task claim --release <id>` — see
@@ -684,11 +841,21 @@ const CLAIM_USAGE =
684
841
  function cmdClaim(args: Args): void {
685
842
  const ref = args.positional[0]
686
843
  const options = { force: Boolean(args.flags.force) }
687
- const printClaim = (result: { task: Task; branch: string; base: string }): void => {
844
+ const printClaim = (result: { task: Task; branch: string; base: string; previous: string }): void => {
688
845
  if (args.flags.json) {
689
846
  console.log(JSON.stringify({ task: result.task, branch: result.branch }, null, 2))
690
847
  } else {
691
848
  console.log(`claimed ${result.task.id} — on ${result.branch} (from ${result.base}), status in_progress`)
849
+ // The backstop for branch-pinned sessions: a harness that assigned this
850
+ // checkout a branch expects the work to land there, and a plain claim
851
+ // just moved HEAD somewhere else. The skill carries the rule; this line
852
+ // is for the worker that hasn't read it.
853
+ const defaultLocal = result.base.replace(/^origin\//, "")
854
+ if (result.previous !== defaultLocal && result.previous !== result.branch) {
855
+ console.log(
856
+ `note: this moved you off ${result.previous} — if your work must deliver on ${result.previous}, \`task claim --release ${result.task.id} --comment "…"\` and re-claim with --lock-only`,
857
+ )
858
+ }
692
859
  }
693
860
  }
694
861
  try {
@@ -697,7 +864,7 @@ function cmdClaim(args: Args): void {
697
864
  // ticket across the selection scope (--board > root `boards` > nearest),
698
865
  // retrying past lost races internally.
699
866
  if (ref) fail("pass an id or --next, not both")
700
- if (args.flags.release) fail(CLAIM_USAGE)
867
+ if (args.flags.release || args.flags["lock-only"]) fail(CLAIM_USAGE)
701
868
  const result = claimNext(selectionBoards(process.cwd(), boardFlag(args)), options)
702
869
  if (!result) {
703
870
  if (args.flags.json) {
@@ -712,7 +879,31 @@ function cmdClaim(args: Args): void {
712
879
  }
713
880
  if (!ref) fail(CLAIM_USAGE)
714
881
  const store = openStoreFor(ref)
715
- const number = store.parseId(ref)
882
+ const key = store.parseId(ref)
883
+ if (args.flags["lock-only"]) {
884
+ // The branch-pinned session's claim: same lock, no checkout switch —
885
+ // the work delivers on the branch this session was handed, and the flip
886
+ // commit's delivered_by: trailer says so. See claimLockOnly.
887
+ if (args.flags.release) fail(CLAIM_USAGE)
888
+ const result = claimLockOnly(store, key, options)
889
+ if (args.flags.json) {
890
+ console.log(
891
+ JSON.stringify(
892
+ { task: result.task, branch: result.branch, deliveredBy: result.deliveredBy },
893
+ null,
894
+ 2,
895
+ ),
896
+ )
897
+ } else {
898
+ console.log(
899
+ `claimed ${result.task.id} — ${result.branch} on origin (lock only, from ${result.base}), status in_progress`,
900
+ )
901
+ console.log(
902
+ `checkout untouched — keep working on ${result.deliveredBy} (recorded as delivered_by in the claim commit)`,
903
+ )
904
+ }
905
+ return
906
+ }
716
907
  if (args.flags.release) {
717
908
  // A released claim must leave its failure context behind: the branch
718
909
  // (and whatever was tried on it) is about to evaporate, so the reason
@@ -723,26 +914,26 @@ function cmdClaim(args: Args): void {
723
914
  'task claim --release requires --comment "<why>" — the next worker inherits what was tried',
724
915
  )
725
916
  }
726
- const result = release(store, number)
917
+ const result = release(store, key)
727
918
  const commented = result.remote || result.local
728
919
  // After the branch deletion, so the comment survives it: release leaves
729
920
  // the checkout on the default branch, and the comment file sits there
730
921
  // uncommitted, to ride along with the next commit.
731
922
  const comment = commented
732
- ? store.addComment(number, why, resolveAuthor(str(args.flags, "author")).name)
923
+ ? store.addComment(key, why, resolveAuthor(str(args.flags, "author")).name)
733
924
  : null
734
925
  if (args.flags.json) {
735
926
  console.log(JSON.stringify({ released: result, comment }, null, 2))
736
927
  } else if (!commented) {
737
- console.log(`${store.displayId(number)} wasn't claimed — no ${result.branch} to delete`)
928
+ console.log(`${store.displayId(key)} wasn't claimed — no ${result.branch} to delete`)
738
929
  } else {
739
930
  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`)
931
+ console.log(`released ${store.displayId(key)} — deleted ${result.branch} (${where.join(" and ")})`)
932
+ console.log(`left the reason as a comment on ${store.displayId(key)} — uncommitted, commit it with your next change`)
742
933
  }
743
934
  return
744
935
  }
745
- printClaim(claim(store, number, options))
936
+ printClaim(claim(store, key, options))
746
937
  } catch (error) {
747
938
  if (error instanceof ClaimError) {
748
939
  console.error(`error: ${error.message}`)
@@ -752,6 +943,50 @@ function cmdClaim(args: Args): void {
752
943
  }
753
944
  }
754
945
 
946
+ /**
947
+ * `task sweep` — the janitor: delete every claim branch on origin whose ticket
948
+ * is done, canceled, or missing on the current checkout. Zero judgment on
949
+ * purpose — the trigger belongs to a runner (CI on push-to-main or cron), not
950
+ * an agent. Exit codes: 0 clean (including nothing to do), 1 a deletion was
951
+ * attempted and failed — a failing sweep means this runner can't delete
952
+ * branches either, which is worth an alert.
953
+ */
954
+ function cmdSweep(args: Args): void {
955
+ try {
956
+ const result = sweep(process.cwd())
957
+ if (args.flags.json) {
958
+ console.log(JSON.stringify({ sweep: result }, null, 2))
959
+ } else {
960
+ for (const entry of result.swept) {
961
+ console.log(
962
+ `swept ${entry.branch} — ${entry.reason === "missing" ? `no ticket ${entry.ticket}` : `${entry.ticket} is ${entry.reason}`}`,
963
+ )
964
+ }
965
+ for (const entry of result.kept) {
966
+ console.log(`kept ${entry.branch} — ${entry.ticket} is ${entry.status}`)
967
+ }
968
+ for (const branch of result.skipped) {
969
+ console.log(
970
+ `skipped ${branch} — not a name \`task claim\` mints (foreign or pre-migration); delete it by hand if it's stale`,
971
+ )
972
+ }
973
+ for (const entry of result.failed) {
974
+ console.error(`error: couldn't delete ${entry.branch} — ${entry.error}`)
975
+ }
976
+ if (!result.swept.length && !result.kept.length && !result.skipped.length && !result.failed.length) {
977
+ console.log("no claim branches on origin — nothing to sweep")
978
+ }
979
+ }
980
+ if (result.failed.length) process.exit(1)
981
+ } catch (error) {
982
+ if (error instanceof ClaimError) {
983
+ console.error(`error: ${error.message}`)
984
+ process.exit(2)
985
+ }
986
+ throw error
987
+ }
988
+ }
989
+
755
990
  /** `task instructions` — the shipped agent conventions, self-served at runtime. */
756
991
  function cmdInstructions(): void {
757
992
  // skill/ ships in the npm package; `../` from both dist/cli.js and
@@ -774,7 +1009,10 @@ function cmdOverview(args: Args): void {
774
1009
  overview.claimed === null
775
1010
  ? "claimed ? (origin unreachable)"
776
1011
  : `claimed ${overview.claimed.length}${overview.claimed.length ? ` (${overview.claimed.join(", ")})` : ""}`
777
- console.log(` ${claimed} needs-human ${overview.needsHuman}`)
1012
+ const asks = overview.oldestAsk
1013
+ ? `asks ${overview.openAsks} open (oldest ${overview.oldestAsk.taskId}, ${overview.oldestAsk.createdAt.slice(0, 10)})`
1014
+ : "asks 0 open"
1015
+ console.log(` ${claimed} ${asks}`)
778
1016
  if (overview.goals.length) {
779
1017
  console.log("goals")
780
1018
  table(
@@ -967,9 +1205,9 @@ function cmdDelete(args: Args): void {
967
1205
  const ref = args.positional[0]
968
1206
  if (!ref) fail("usage: task delete <id>")
969
1207
  const store = openStoreFor(ref)
970
- const number = store.parseId(ref)
971
- store.delete(number)
972
- console.log(`deleted ${store.displayId(number)}`)
1208
+ const key = store.parseId(ref)
1209
+ store.delete(key)
1210
+ console.log(`deleted ${store.displayId(key)}`)
973
1211
  }
974
1212
 
975
1213
  /**
@@ -1165,11 +1403,14 @@ subdirectory (it walks up to find .task/, like git).
1165
1403
  Usage
1166
1404
  task init [--name <name>] [--prefix <PREFIX>]
1167
1405
  task add <title> [--description <text> | --description-file <path|->]
1168
- [--status <s>] [--tags <a,b>] [--goal <slug>] [--needs-human]
1406
+ [--status <s>] [--tags <a,b>] [--goal <slug>]
1407
+ [--ask "<text>"]…
1169
1408
  new tickets land in backlog (the dump list)
1170
1409
  unless --status says otherwise;
1171
1410
  --description-file reads markdown from a file
1172
- (or stdin with -) — no shell-quoting fights
1411
+ (or stdin with -) — no shell-quoting fights;
1412
+ --ask (repeatable) files what a person owes
1413
+ the ticket, right at creation
1173
1414
  task list [--status <s1,s2>] [--tag <a,b>] [--goal <slug>]
1174
1415
  [--needs-human] [--all] [--archived] [--claimable]
1175
1416
  task search <query> [--status <s1,s2>] [--tag <a,b>] [--goal <slug>]
@@ -1183,18 +1424,38 @@ Usage
1183
1424
  the shared context without chasing it
1184
1425
  task update <id> [--title <t>] [--description <text> | --description-file <path|->]
1185
1426
  [--status <s>] [--tags <a,b>] [--goal <slug>]
1186
- [--needs-human | --no-needs-human]
1187
1427
  [--pr <url>] [--prs <url1,url2>]
1188
1428
  task move <id> <status> change a ticket's status — the one general
1189
1429
  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
1430
+ canceled). Moving to done is refused while
1431
+ the ticket has open asks: done means nobody
1432
+ owes anything. The named verbs below exist
1433
+ only where a transition does more than write
1434
+ the status field
1435
+ task ask <id> "<text>" file an ask: one thing a person owes this
1436
+ ticket before it can be done. Asks derive the
1437
+ needs-human badge, feed \`task inbox\`, and
1438
+ gate \`done\` — they never block claiming (an
1439
+ agent works the code while a person mints the
1440
+ token; a true precondition is a blocked_by on
1441
+ a thin human ticket instead)
1442
+ task resolve <id> <n> [--comment "<note>"] [--reopen]
1443
+ check ask <n> off (\`task show\` prints the
1444
+ ordinals); the note lands as an attributed
1445
+ comment. --reopen puts a resolved ask back
1446
+ task inbox [--board <P>] what needs YOU, now: every open ask on
1447
+ effectively in-progress tickets — file status
1448
+ in_progress, or a live claim branch on origin
1449
+ (a claimed ticket is in progress whatever the
1450
+ merged file says, and its asks are read from
1451
+ the branch). Cross-board like claim --next;
1452
+ oldest owed first. Backlog/todo asks are out
1453
+ by design — claiming is what makes them yours
1193
1454
  task promote <id> backlog → todo, the human "build this" call.
1194
1455
  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
1456
+ time. Blocked tickets still promote
1457
+ blockers are claim-time gates, reported as a
1458
+ note — so promoting can queue work behind
1198
1459
  its blockers. Exit codes: 0 promoted, 2
1199
1460
  refused (not a backlog ticket)
1200
1461
  task link <id> --blocked-by <id>
@@ -1202,7 +1463,7 @@ Usage
1202
1463
  both tasks (A blocked by B ⇔ B blocks A)
1203
1464
  task unlink <id> (--blocks <id> | --blocked-by <id>)
1204
1465
  task claim <id> [--force] claim a ticket before working it: branch
1205
- <branchPrefix><prefix>-<n> off origin's
1466
+ <branchPrefix><prefix>-<key> off origin's
1206
1467
  default branch, ticket → in_progress as its
1207
1468
  first commit, pushed. The namespace defaults
1208
1469
  to task/claim/ — set claims.branchPrefix in
@@ -1215,8 +1476,21 @@ Usage
1215
1476
  many claim branches exist on origin, claiming
1216
1477
  is refused (--force overrides). Exit codes:
1217
1478
  0 claimed, 1 already claimed, 2 not claimable
1218
- (not todo, blocked, needs-human, dirty tree,
1219
- at the cap)
1479
+ (not todo, blocked, dirty tree, at the cap)
1480
+ task claim <id> --lock-only [--force]
1481
+ take the lock without touching the checkout:
1482
+ the in_progress flip commit is built against
1483
+ origin's default branch in a temporary index
1484
+ and pushed as the claim branch — same atomic
1485
+ lock, same exit codes, but HEAD, the working
1486
+ tree and the index stay exactly where they
1487
+ are (dirty is fine). For sessions pinned to
1488
+ a provisioned branch (CI, a Claude web
1489
+ session): the lock lives on the claim
1490
+ branch, the work delivers on yours, and the
1491
+ flip commit's delivered_by: trailer records
1492
+ which. --release works on a lock-only claim
1493
+ the same way
1220
1494
  task claim --next [--board <P>] [--force]
1221
1495
  claim the top claimable ticket in one call,
1222
1496
  retrying past lost races internally — the
@@ -1232,18 +1506,36 @@ Usage
1232
1506
  the branch, so deleting it is the revert. The
1233
1507
  comment is required and lands on the ticket
1234
1508
  (attributed like task comment, uncommitted),
1235
- so the next worker inherits what was tried
1509
+ so the next worker inherits what was tried.
1510
+ The deletion is verified with ls-remote: an
1511
+ environment that silently drops deletion
1512
+ pushes fails loudly instead of reporting a
1513
+ release that didn't happen
1514
+ task sweep the janitor: delete every claim branch on
1515
+ origin whose ticket is done, canceled, or
1516
+ missing on the current checkout, across all
1517
+ the repo's boards. Scoped to names \`claim\`
1518
+ mints (<prefix>-<valid key>): anything else
1519
+ under the namespace — pre-migration numeric
1520
+ claims, hand-made branches — is reported and
1521
+ left alone, since it may be an open PR's
1522
+ head. Pure git, zero judgment — run it from
1523
+ a runner that can delete remote branches (CI
1524
+ on push-to-main or cron) to heal stale
1525
+ claims left by environments that can't.
1526
+ Prints swept/kept/skipped; exits non-zero if
1527
+ a deletion was attempted and failed
1236
1528
  task list --claimable [--board <P>]
1237
1529
  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
1530
+ order, minus blocked / already claimed on
1531
+ origin. Top entry is next up. Same board
1532
+ scope as claim --next
1241
1533
  task instructions print the shipped agent conventions
1242
1534
  (skill/SKILL.md) — agents without the skill
1243
1535
  installed self-serve the house rules
1244
1536
  task overview [--json] one screen of board health: counts per
1245
1537
  status, per-goal progress, claimed and
1246
- needs-human counts, stale tickets (untouched
1538
+ open-ask counts, stale tickets (untouched
1247
1539
  ${STALE_DAYS}+ days), oldest open ticket
1248
1540
  task comment <id> <text> [--author <who>]
1249
1541
  task delete <id>
@@ -1265,7 +1557,7 @@ Usage
1265
1557
  task archive <id> move a done/canceled ticket to .task/archive/,
1266
1558
  out of the board and off the hot path — still
1267
1559
  readable via show and list --archived, and its
1268
- number stays reserved
1560
+ key stays reserved
1269
1561
  task archive --all archive everything done or canceled
1270
1562
  task unarchive <id> put an archived ticket back on the board
1271
1563
  task whoami who your comments are attributed to
@@ -1297,15 +1589,18 @@ Usage
1297
1589
  tasks — those are in .task/ either way
1298
1590
 
1299
1591
  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
1592
+ <id> TAS-x7k4m, x7k4m, or any unique prefix of a key (task show x7
1593
+ works, git-style ambiguity is an error naming the matches).
1594
+ Ids are random 5-character keys, minted branch-safely at
1595
+ \`task add\`. A bare key means the nearest board; a prefixed id
1596
+ routes to whichever board in the repo owns that prefix, so
1597
+ TAS-x7k4m works from anywhere in a monorepo
1303
1598
  status ${STATUSES.join(" ")}
1304
1599
  --tag a,b matches a task carrying *either* tag
1305
1600
  --goal <slug> a task belongs to at most one goal. Goals answer "where is
1306
1601
  this going" (one); tags answer "what kind" (many) — something
1307
1602
  that wants two goals is a tag
1308
- --needs-human this can't be finished by an agent alone
1603
+ --needs-human filter: tickets with open asks something a person owes
1309
1604
  --pr <url> attach a pull request (appends); --prs replaces the whole list
1310
1605
  clearing --tags "" drops all tags, --goal "" clears it, --prs ""
1311
1606
  detaches all PRs
@@ -1354,8 +1649,16 @@ function main(): void | Promise<void> {
1354
1649
  return cmdLink(args, "unlink")
1355
1650
  case "claim":
1356
1651
  return cmdClaim(args)
1652
+ case "sweep":
1653
+ return cmdSweep(args)
1357
1654
  case "instructions":
1358
1655
  return cmdInstructions()
1656
+ case "ask":
1657
+ return cmdAsk(args)
1658
+ case "resolve":
1659
+ return cmdResolve(args)
1660
+ case "inbox":
1661
+ return cmdInbox(args)
1359
1662
  case "overview":
1360
1663
  return cmdOverview(args)
1361
1664
  case "goal":