@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/dist/cli.js CHANGED
@@ -6,15 +6,17 @@
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
  import { spawn } from "node:child_process";
12
12
  import { readFileSync } from "node:fs";
13
- import { basename, join } from "node:path";
13
+ import { basename, join, relative, sep } from "node:path";
14
14
  import process from "node:process";
15
15
  import { resolveAuthor } from "./author.js";
16
16
  import { checkBoards, checkRoot } from "./check.js";
17
- import { ClaimError, claim, claimNext, claimableTasks, promote, release, selectionBoards, } from "./claim.js";
17
+ import { ClaimError, claim, claimLockOnly, claimNext, claimableTasks, promote, release, selectionBoards, sweep, } from "./claim.js";
18
+ import { addAskRouted, setAskResolvedRouted } from "./claim-io.js";
19
+ import { buildInbox } from "./inbox.js";
18
20
  import { STALE_DAYS, buildOverview } from "./overview.js";
19
21
  import { searchStore } from "./search.js";
20
22
  import { detectRepo, parseSlug, publish, resolveHost } from "./publish.js";
@@ -45,7 +47,6 @@ const BOOLEAN_FLAGS = new Set([
45
47
  "version",
46
48
  "yes",
47
49
  "needs-human",
48
- "no-needs-human",
49
50
  "archived",
50
51
  "claimable",
51
52
  "release",
@@ -58,6 +59,8 @@ const BOOLEAN_FLAGS = new Set([
58
59
  "fix",
59
60
  "next",
60
61
  "force",
62
+ "reopen",
63
+ "lock-only",
61
64
  ]);
62
65
  /**
63
66
  * Value flags that may repeat — `--board NIC --board TAS` accumulates in
@@ -65,14 +68,26 @@ const BOOLEAN_FLAGS = new Set([
65
68
  * last-one-wins.
66
69
  */
67
70
  const REPEATABLE_FLAGS = new Set(["board"]);
71
+ /**
72
+ * Repeatable flags whose values are free text — collected verbatim into a
73
+ * list, never comma-joined, because an ask like "check A, then B" must
74
+ * survive as one ask.
75
+ */
76
+ const MULTI_FLAGS = new Set(["ask"]);
68
77
  function parseArgs(argv) {
69
78
  const positional = [];
70
79
  const flags = {};
80
+ const multi = {};
71
81
  for (let i = 0; i < argv.length; i++) {
72
82
  const arg = argv[i];
73
83
  if (arg.startsWith("--")) {
74
84
  const name = arg.slice(2);
75
85
  const set = (key, value) => {
86
+ if (MULTI_FLAGS.has(key)) {
87
+ ;
88
+ (multi[key] ??= []).push(value);
89
+ return;
90
+ }
76
91
  const prior = flags[key];
77
92
  flags[key] =
78
93
  REPEATABLE_FLAGS.has(key) && typeof prior === "string" ? `${prior},${value}` : value;
@@ -92,7 +107,7 @@ function parseArgs(argv) {
92
107
  positional.push(arg);
93
108
  }
94
109
  }
95
- return { positional, flags };
110
+ return { positional, flags, multi };
96
111
  }
97
112
  function str(flags, name) {
98
113
  const v = flags[name];
@@ -108,18 +123,18 @@ function openStore() {
108
123
  fail("no .task directory found in this directory or any parent — run `task init` first");
109
124
  return openBoard(root);
110
125
  }
111
- /** The prefix a ref carries, if any: "TAS-12" → "TAS", "12" → null. */
126
+ /** The prefix a ref carries, if any: "TAS-x7k4m" → "TAS", "x7k4m" → null. */
112
127
  function refPrefix(ref) {
113
- const match = /^([A-Za-z0-9]+)-\d+$/.exec(ref.trim());
128
+ const match = /^([A-Za-z0-9]+)-[A-Za-z0-9]+$/.exec(ref.trim());
114
129
  return match ? match[1].toUpperCase() : null;
115
130
  }
116
131
  /**
117
- * The board a ref belongs to. A bare number means the nearest board, as ever —
132
+ * The board a ref belongs to. A bare key means the nearest board, as ever —
118
133
  * but a prefixed id is an address, and it routes: if the prefix isn't the
119
134
  * nearest board's, every board in the repo (walking up to the outermost board
120
- * root, then down) is searched for it, so `task show TAS-12` works from
135
+ * root, then down) is searched for it, so `task show TAS-x7k4m` works from
121
136
  * anywhere in a monorepo. What this must never do is what it used to: silently
122
- * strip a foreign prefix and act on the nearest board's ticket of that number.
137
+ * strip a foreign prefix and act on the nearest board's ticket of that key.
123
138
  */
124
139
  function openStoreFor(ref) {
125
140
  const prefix = ref ? refPrefix(ref) : null;
@@ -205,10 +220,6 @@ function patchFromFlags(flags) {
205
220
  const goal = str(flags, "goal");
206
221
  if (goal !== undefined)
207
222
  patch.goal = goal.trim() || null;
208
- if (flags["needs-human"])
209
- patch.needsHuman = true;
210
- if (flags["no-needs-human"])
211
- patch.needsHuman = false;
212
223
  // Replace semantics like --tags; `--pr` (append one) is handled per-command
213
224
  // because appending needs the task's current list.
214
225
  const prs = str(flags, "prs");
@@ -268,18 +279,27 @@ function cmdInit(args) {
268
279
  function cmdAdd(args) {
269
280
  const title = args.positional.join(" ").trim();
270
281
  if (!title)
271
- fail(`usage: task add <title> [--description …] [--status …] [--tags a,b] [--goal <slug>] [--needs-human]`);
282
+ fail(`usage: task add <title> [--description …] [--status …] [--tags a,b] [--goal <slug>] [--ask "<text>"]…`);
272
283
  const store = openStore();
273
284
  const patch = patchFromFlags(args.flags);
274
285
  const pr = str(args.flags, "pr");
275
286
  if (pr)
276
287
  patch.prs = [...(patch.prs ?? []), pr];
277
- const task = store.create({ title, ...patch });
288
+ let task = store.create({ title, ...patch });
289
+ const asks = (args.multi.ask ?? []).map((a) => a.trim()).filter(Boolean);
290
+ if (asks.length > 0) {
291
+ const author = resolveAuthor(str(args.flags, "author")).name;
292
+ for (const text of asks)
293
+ store.addAsk(task.key, text, author);
294
+ task = store.get(task.key);
295
+ }
278
296
  if (args.flags.json) {
279
297
  console.log(JSON.stringify({ task }, null, 2));
280
298
  }
281
299
  else {
282
300
  printTask(task);
301
+ for (const ask of task.asks)
302
+ console.log(` ask [${ask.ordinal}] ${ask.text.split("\n")[0]}`);
283
303
  }
284
304
  }
285
305
  /** `--board NIC --board TAS` (or `--board NIC,TAS`) → ["NIC", "TAS"]. */
@@ -460,7 +480,7 @@ function cmdArchive(args) {
460
480
  }
461
481
  const store = openStore();
462
482
  const finished = store.list({ statuses: ["done", "canceled"] });
463
- const archived = finished.map((t) => store.archive(t.number));
483
+ const archived = finished.map((t) => store.archive(t.key));
464
484
  if (args.flags.json) {
465
485
  console.log(JSON.stringify({ archived }, null, 2));
466
486
  }
@@ -490,11 +510,11 @@ function cmdShow(args) {
490
510
  if (!ref)
491
511
  fail("usage: task show <id>");
492
512
  const store = openStoreFor(ref);
493
- const number = store.parseId(ref);
494
- const task = store.get(number);
513
+ const key = store.parseId(ref);
514
+ const task = store.get(key);
495
515
  if (!task)
496
- fail(`no such task: ${store.displayId(number)}`);
497
- const comments = store.comments(number);
516
+ fail(`no such task: ${store.displayId(key)}`);
517
+ const comments = store.comments(key);
498
518
  // The goal rides along in full — title *and* description — so an agent
499
519
  // picking the ticket up cold inherits the shared context without it being
500
520
  // pasted into every ticket. null when unset or when the ref dangles.
@@ -504,18 +524,27 @@ function cmdShow(args) {
504
524
  return;
505
525
  }
506
526
  // A linked task is only as useful as knowing whether it's still in the way.
507
- const describeLinks = (numbers) => numbers
508
- .map((n) => {
509
- const other = store.get(n);
510
- return other ? `${other.id} (${other.status})` : store.displayId(n);
527
+ const describeLinks = (keys) => keys
528
+ .map((k) => {
529
+ const other = store.get(k);
530
+ return other ? `${other.id} (${other.status})` : store.displayId(k);
511
531
  })
512
532
  .join(", ");
513
533
  console.log(`${task.id} ${task.title}`);
514
534
  console.log(`status ${task.status}`);
515
535
  if (task.archived)
516
536
  console.log(`archived yes — \`task unarchive ${task.id}\` to edit`);
517
- if (task.needsHuman)
518
- console.log(`needs a human`);
537
+ const openAsks = task.asks.filter((a) => !a.resolvedAt);
538
+ if (openAsks.length) {
539
+ console.log(`needs a human — ${openAsks.length} open ask${openAsks.length === 1 ? "" : "s"}`);
540
+ }
541
+ for (const ask of task.asks) {
542
+ const state = ask.resolvedAt
543
+ ? `✓ resolved${ask.resolvedBy ? ` by ${ask.resolvedBy}` : ""}`
544
+ : `○ open${ask.author ? ` · ${ask.author}` : ""} · ${ask.createdAt.slice(0, 10)}`;
545
+ console.log(`ask [${ask.ordinal}] ${ask.text.split("\n").join("\n ")}`);
546
+ console.log(` ${state}`);
547
+ }
519
548
  if (task.tags.length)
520
549
  console.log(`tags ${task.tags.join(", ")}`);
521
550
  if (task.goal) {
@@ -548,17 +577,17 @@ function cmdUpdate(args) {
548
577
  if (!ref)
549
578
  fail("usage: task update <id> [--status …] [--title …] …");
550
579
  const store = openStoreFor(ref);
551
- const number = store.parseId(ref);
580
+ const key = store.parseId(ref);
552
581
  const patch = patchFromFlags(args.flags);
553
582
  const pr = str(args.flags, "pr");
554
583
  if (pr) {
555
584
  // Append, dedup — `--pr <url>` is "attach this PR", not "replace the list".
556
- const current = patch.prs ?? store.get(number)?.prs ?? [];
585
+ const current = patch.prs ?? store.get(key)?.prs ?? [];
557
586
  patch.prs = current.includes(pr) ? current : [...current, pr];
558
587
  }
559
588
  if (Object.keys(patch).length === 0)
560
589
  fail("nothing to update — pass at least one flag");
561
- const task = store.update(number, patch);
590
+ const task = store.update(key, patch);
562
591
  if (args.flags.json) {
563
592
  console.log(JSON.stringify({ task }, null, 2));
564
593
  }
@@ -570,7 +599,7 @@ function cmdMove(args) {
570
599
  const [ref, status] = args.positional;
571
600
  if (!ref || !status)
572
601
  fail("usage: task move <id> <status>");
573
- cmdUpdate({ positional: [ref], flags: { ...args.flags, status } });
602
+ cmdUpdate({ positional: [ref], flags: { ...args.flags, status }, multi: {} });
574
603
  }
575
604
  /**
576
605
  * `task promote <id>` — backlog → todo, the human "build this" call; see
@@ -602,6 +631,121 @@ function cmdPromote(args) {
602
631
  throw error;
603
632
  }
604
633
  }
634
+ /**
635
+ * `task ask <id> <text>` — file one thing a person owes this ticket. The ask
636
+ * is the primitive every human requirement funnels through: it derives the
637
+ * needs-human badge, feeds `task inbox`, and gates `done` until resolved.
638
+ * On a claimed ticket the write lands on the claim branch as a pushed commit
639
+ * (see claim-io.ts) — the worker's done gate has to see debt filed mid-claim.
640
+ */
641
+ function cmdAsk(args) {
642
+ const [ref, ...rest] = args.positional;
643
+ const text = rest.join(" ").trim();
644
+ if (!ref || !text)
645
+ fail(`usage: task ask <id> "<what a person needs to do>" [--author <who>]`);
646
+ const store = openStoreFor(ref);
647
+ const author = resolveAuthor(str(args.flags, "author")).name;
648
+ const { ask, branch } = addAskRouted(store, store.parseId(ref), text, author);
649
+ if (args.flags.json) {
650
+ console.log(JSON.stringify({ ask, branch }, null, 2));
651
+ }
652
+ else {
653
+ console.log(`asked on ${ask.taskId} [${ask.ordinal}] ${ask.text.split("\n")[0]}`);
654
+ if (branch)
655
+ console.log(`committed and pushed to ${branch} — the claim owns this ticket's asks`);
656
+ }
657
+ }
658
+ /**
659
+ * `task resolve <id> <n>` — check an ask off (`--reopen` puts it back). The
660
+ * ordinal is the one `task show` prints; the optional --comment lands as an
661
+ * attributed comment so the answer travels with the ticket, not just the
662
+ * checkbox. Resolving the last open ask is the moment a ticket usually moves
663
+ * to done — the hint below says so rather than doing it, because finishing a
664
+ * ticket is a statement about the *work*, not the asks.
665
+ */
666
+ function cmdResolve(args) {
667
+ const [ref, askRef] = args.positional;
668
+ if (!ref || !askRef) {
669
+ fail('usage: task resolve <id> <ordinal> [--comment "<note>"] [--reopen] [--author <who>]');
670
+ }
671
+ const store = openStoreFor(ref);
672
+ const key = store.parseId(ref);
673
+ const author = resolveAuthor(str(args.flags, "author")).name;
674
+ const { ask, branch } = setAskResolvedRouted(store, key, askRef, !args.flags.reopen, author);
675
+ const note = str(args.flags, "comment")?.trim();
676
+ const comment = note
677
+ ? store.addComment(key, `${args.flags.reopen ? "Reopened" : "Resolved"} ask [${ask.ordinal}] (${ask.text.split("\n")[0]}): ${note}`, author)
678
+ : null;
679
+ const task = store.get(key);
680
+ // Branch-resolved asks aren't in the local files — count what's still open
681
+ // on the side that was just written.
682
+ const remaining = branch
683
+ ? undefined
684
+ : task.asks.filter((a) => !a.resolvedAt).length;
685
+ if (args.flags.json) {
686
+ console.log(JSON.stringify({ ask, branch, comment, openAsks: remaining ?? null }, null, 2));
687
+ return;
688
+ }
689
+ const verb = args.flags.reopen ? "reopened" : "resolved";
690
+ console.log(`${verb} ${ask.taskId} [${ask.ordinal}] ${ask.text.split("\n")[0]}`);
691
+ if (branch) {
692
+ console.log(`committed and pushed to ${branch} — the worker sees it on its next pull`);
693
+ return;
694
+ }
695
+ if (args.flags.reopen)
696
+ return;
697
+ if (remaining !== undefined && remaining > 0) {
698
+ console.log(`${remaining} ask${remaining === 1 ? "" : "s"} still open on ${ask.taskId}`);
699
+ }
700
+ else if (task.status === "in_progress") {
701
+ console.log(`that was the last open ask — \`task move ${ask.taskId} done\` if the work is finished too`);
702
+ }
703
+ }
704
+ /**
705
+ * `task inbox` — what do I need to do NOW to unblock work: every open ask on
706
+ * effectively-in-progress tickets (file status, or a live claim branch —
707
+ * see inbox.ts), across the repo's boards, oldest owed first. Deliberately
708
+ * cross-board, like `claim --next`: the queue's attention is per-board, a
709
+ * person's isn't.
710
+ */
711
+ function cmdInbox(args) {
712
+ let entries;
713
+ try {
714
+ const scope = findScopeRoot(process.cwd());
715
+ const boards = selectionBoards(process.cwd(), boardFlag(args)).map((store) => {
716
+ const rel = relative(scope, store.root).split(sep).join("/");
717
+ return { id: rel === "" ? "." : rel, store };
718
+ });
719
+ entries = buildInbox(boards);
720
+ }
721
+ catch (error) {
722
+ if (error instanceof ClaimError)
723
+ fail(error.message);
724
+ throw error;
725
+ }
726
+ if (args.flags.json) {
727
+ console.log(JSON.stringify({ inbox: entries }, null, 2));
728
+ return;
729
+ }
730
+ if (entries.length === 0) {
731
+ console.log("inbox zero — no open asks on in-progress work");
732
+ return;
733
+ }
734
+ const open = entries.reduce((sum, e) => sum + e.asks.length, 0);
735
+ console.log(`${open} open ask${open === 1 ? "" : "s"} across ${entries.length} ticket${entries.length === 1 ? "" : "s"}`);
736
+ const age = (iso) => {
737
+ const days = Math.floor((Date.now() - Date.parse(iso)) / 86_400_000);
738
+ return days > 0 ? `${days}d` : "today";
739
+ };
740
+ for (const entry of entries) {
741
+ const via = entry.claim ? ` (claimed: ${entry.claim.branch})` : "";
742
+ console.log(`\n${entry.task.id} ${entry.task.title}${via}`);
743
+ for (const ask of entry.asks) {
744
+ const who = ask.author ? ` · ${ask.author}` : "";
745
+ console.log(` [${ask.ordinal}] ${ask.text.split("\n")[0]} (${age(ask.createdAt)}${who})`);
746
+ }
747
+ }
748
+ }
605
749
  function cmdComment(args) {
606
750
  const [ref, ...rest] = args.positional;
607
751
  const body = rest.join(" ").trim();
@@ -632,8 +776,8 @@ function cmdLink(args, action) {
632
776
  const store = openStoreFor(ref);
633
777
  const relation = blocks ? "blocks" : "blocked_by";
634
778
  const target = parseRefOn(store, (blocks ?? blockedBy));
635
- const number = store.parseId(ref);
636
- const task = action === "link" ? store.link(number, relation, target) : store.unlink(number, relation, target);
779
+ const key = store.parseId(ref);
780
+ const task = action === "link" ? store.link(key, relation, target) : store.unlink(key, relation, target);
637
781
  if (args.flags.json) {
638
782
  console.log(JSON.stringify({ task }, null, 2));
639
783
  }
@@ -645,7 +789,7 @@ function cmdLink(args, action) {
645
789
  console.log(`${subject} ${verb} blocks ${object}`);
646
790
  }
647
791
  }
648
- const CLAIM_USAGE = 'usage: task claim <id> | task claim --next | task claim --release <id> --comment "<why>"';
792
+ const CLAIM_USAGE = 'usage: task claim <id> [--lock-only] | task claim --next | task claim --release <id> --comment "<why>"';
649
793
  /**
650
794
  * `task claim <id>` / `task claim --next` / `task claim --release <id>` — see
651
795
  * claim.ts for the mechanics. Exit codes are the contract callers script
@@ -661,6 +805,14 @@ function cmdClaim(args) {
661
805
  }
662
806
  else {
663
807
  console.log(`claimed ${result.task.id} — on ${result.branch} (from ${result.base}), status in_progress`);
808
+ // The backstop for branch-pinned sessions: a harness that assigned this
809
+ // checkout a branch expects the work to land there, and a plain claim
810
+ // just moved HEAD somewhere else. The skill carries the rule; this line
811
+ // is for the worker that hasn't read it.
812
+ const defaultLocal = result.base.replace(/^origin\//, "");
813
+ if (result.previous !== defaultLocal && result.previous !== result.branch) {
814
+ console.log(`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`);
815
+ }
664
816
  }
665
817
  };
666
818
  try {
@@ -670,7 +822,7 @@ function cmdClaim(args) {
670
822
  // retrying past lost races internally.
671
823
  if (ref)
672
824
  fail("pass an id or --next, not both");
673
- if (args.flags.release)
825
+ if (args.flags.release || args.flags["lock-only"])
674
826
  fail(CLAIM_USAGE);
675
827
  const result = claimNext(selectionBoards(process.cwd(), boardFlag(args)), options);
676
828
  if (!result) {
@@ -688,7 +840,23 @@ function cmdClaim(args) {
688
840
  if (!ref)
689
841
  fail(CLAIM_USAGE);
690
842
  const store = openStoreFor(ref);
691
- const number = store.parseId(ref);
843
+ const key = store.parseId(ref);
844
+ if (args.flags["lock-only"]) {
845
+ // The branch-pinned session's claim: same lock, no checkout switch —
846
+ // the work delivers on the branch this session was handed, and the flip
847
+ // commit's delivered_by: trailer says so. See claimLockOnly.
848
+ if (args.flags.release)
849
+ fail(CLAIM_USAGE);
850
+ const result = claimLockOnly(store, key, options);
851
+ if (args.flags.json) {
852
+ console.log(JSON.stringify({ task: result.task, branch: result.branch, deliveredBy: result.deliveredBy }, null, 2));
853
+ }
854
+ else {
855
+ console.log(`claimed ${result.task.id} — ${result.branch} on origin (lock only, from ${result.base}), status in_progress`);
856
+ console.log(`checkout untouched — keep working on ${result.deliveredBy} (recorded as delivered_by in the claim commit)`);
857
+ }
858
+ return;
859
+ }
692
860
  if (args.flags.release) {
693
861
  // A released claim must leave its failure context behind: the branch
694
862
  // (and whatever was tried on it) is about to evaporate, so the reason
@@ -697,28 +865,28 @@ function cmdClaim(args) {
697
865
  if (!why) {
698
866
  fail('task claim --release requires --comment "<why>" — the next worker inherits what was tried');
699
867
  }
700
- const result = release(store, number);
868
+ const result = release(store, key);
701
869
  const commented = result.remote || result.local;
702
870
  // After the branch deletion, so the comment survives it: release leaves
703
871
  // the checkout on the default branch, and the comment file sits there
704
872
  // uncommitted, to ride along with the next commit.
705
873
  const comment = commented
706
- ? store.addComment(number, why, resolveAuthor(str(args.flags, "author")).name)
874
+ ? store.addComment(key, why, resolveAuthor(str(args.flags, "author")).name)
707
875
  : null;
708
876
  if (args.flags.json) {
709
877
  console.log(JSON.stringify({ released: result, comment }, null, 2));
710
878
  }
711
879
  else if (!commented) {
712
- console.log(`${store.displayId(number)} wasn't claimed — no ${result.branch} to delete`);
880
+ console.log(`${store.displayId(key)} wasn't claimed — no ${result.branch} to delete`);
713
881
  }
714
882
  else {
715
883
  const where = [result.remote && "origin", result.local && "local"].filter(Boolean);
716
- console.log(`released ${store.displayId(number)} — deleted ${result.branch} (${where.join(" and ")})`);
717
- console.log(`left the reason as a comment on ${store.displayId(number)} — uncommitted, commit it with your next change`);
884
+ console.log(`released ${store.displayId(key)} — deleted ${result.branch} (${where.join(" and ")})`);
885
+ console.log(`left the reason as a comment on ${store.displayId(key)} — uncommitted, commit it with your next change`);
718
886
  }
719
887
  return;
720
888
  }
721
- printClaim(claim(store, number, options));
889
+ printClaim(claim(store, key, options));
722
890
  }
723
891
  catch (error) {
724
892
  if (error instanceof ClaimError) {
@@ -728,6 +896,48 @@ function cmdClaim(args) {
728
896
  throw error;
729
897
  }
730
898
  }
899
+ /**
900
+ * `task sweep` — the janitor: delete every claim branch on origin whose ticket
901
+ * is done, canceled, or missing on the current checkout. Zero judgment on
902
+ * purpose — the trigger belongs to a runner (CI on push-to-main or cron), not
903
+ * an agent. Exit codes: 0 clean (including nothing to do), 1 a deletion was
904
+ * attempted and failed — a failing sweep means this runner can't delete
905
+ * branches either, which is worth an alert.
906
+ */
907
+ function cmdSweep(args) {
908
+ try {
909
+ const result = sweep(process.cwd());
910
+ if (args.flags.json) {
911
+ console.log(JSON.stringify({ sweep: result }, null, 2));
912
+ }
913
+ else {
914
+ for (const entry of result.swept) {
915
+ console.log(`swept ${entry.branch} — ${entry.reason === "missing" ? `no ticket ${entry.ticket}` : `${entry.ticket} is ${entry.reason}`}`);
916
+ }
917
+ for (const entry of result.kept) {
918
+ console.log(`kept ${entry.branch} — ${entry.ticket} is ${entry.status}`);
919
+ }
920
+ for (const branch of result.skipped) {
921
+ console.log(`skipped ${branch} — not a name \`task claim\` mints (foreign or pre-migration); delete it by hand if it's stale`);
922
+ }
923
+ for (const entry of result.failed) {
924
+ console.error(`error: couldn't delete ${entry.branch} — ${entry.error}`);
925
+ }
926
+ if (!result.swept.length && !result.kept.length && !result.skipped.length && !result.failed.length) {
927
+ console.log("no claim branches on origin — nothing to sweep");
928
+ }
929
+ }
930
+ if (result.failed.length)
931
+ process.exit(1);
932
+ }
933
+ catch (error) {
934
+ if (error instanceof ClaimError) {
935
+ console.error(`error: ${error.message}`);
936
+ process.exit(2);
937
+ }
938
+ throw error;
939
+ }
940
+ }
731
941
  /** `task instructions` — the shipped agent conventions, self-served at runtime. */
732
942
  function cmdInstructions() {
733
943
  // skill/ ships in the npm package; `../` from both dist/cli.js and
@@ -746,7 +956,10 @@ function cmdOverview(args) {
746
956
  const claimed = overview.claimed === null
747
957
  ? "claimed ? (origin unreachable)"
748
958
  : `claimed ${overview.claimed.length}${overview.claimed.length ? ` (${overview.claimed.join(", ")})` : ""}`;
749
- console.log(` ${claimed} needs-human ${overview.needsHuman}`);
959
+ const asks = overview.oldestAsk
960
+ ? `asks ${overview.openAsks} open (oldest ${overview.oldestAsk.taskId}, ${overview.oldestAsk.createdAt.slice(0, 10)})`
961
+ : "asks 0 open";
962
+ console.log(` ${claimed} ${asks}`);
750
963
  if (overview.goals.length) {
751
964
  console.log("goals");
752
965
  table(overview.goals.map((g) => [
@@ -946,9 +1159,9 @@ function cmdDelete(args) {
946
1159
  if (!ref)
947
1160
  fail("usage: task delete <id>");
948
1161
  const store = openStoreFor(ref);
949
- const number = store.parseId(ref);
950
- store.delete(number);
951
- console.log(`deleted ${store.displayId(number)}`);
1162
+ const key = store.parseId(ref);
1163
+ store.delete(key);
1164
+ console.log(`deleted ${store.displayId(key)}`);
952
1165
  }
953
1166
  /**
954
1167
  * Bind the first free port at or after `first`, the way dev servers do: a board
@@ -1127,11 +1340,14 @@ subdirectory (it walks up to find .task/, like git).
1127
1340
  Usage
1128
1341
  task init [--name <name>] [--prefix <PREFIX>]
1129
1342
  task add <title> [--description <text> | --description-file <path|->]
1130
- [--status <s>] [--tags <a,b>] [--goal <slug>] [--needs-human]
1343
+ [--status <s>] [--tags <a,b>] [--goal <slug>]
1344
+ [--ask "<text>"]…
1131
1345
  new tickets land in backlog (the dump list)
1132
1346
  unless --status says otherwise;
1133
1347
  --description-file reads markdown from a file
1134
- (or stdin with -) — no shell-quoting fights
1348
+ (or stdin with -) — no shell-quoting fights;
1349
+ --ask (repeatable) files what a person owes
1350
+ the ticket, right at creation
1135
1351
  task list [--status <s1,s2>] [--tag <a,b>] [--goal <slug>]
1136
1352
  [--needs-human] [--all] [--archived] [--claimable]
1137
1353
  task search <query> [--status <s1,s2>] [--tag <a,b>] [--goal <slug>]
@@ -1145,18 +1361,38 @@ Usage
1145
1361
  the shared context without chasing it
1146
1362
  task update <id> [--title <t>] [--description <text> | --description-file <path|->]
1147
1363
  [--status <s>] [--tags <a,b>] [--goal <slug>]
1148
- [--needs-human | --no-needs-human]
1149
1364
  [--pr <url>] [--prs <url1,url2>]
1150
1365
  task move <id> <status> change a ticket's status — the one general
1151
1366
  status API (backlog todo in_progress done
1152
- canceled). The named verbs below exist only
1153
- where a transition does more than write the
1154
- status field
1367
+ canceled). Moving to done is refused while
1368
+ the ticket has open asks: done means nobody
1369
+ owes anything. The named verbs below exist
1370
+ only where a transition does more than write
1371
+ the status field
1372
+ task ask <id> "<text>" file an ask: one thing a person owes this
1373
+ ticket before it can be done. Asks derive the
1374
+ needs-human badge, feed \`task inbox\`, and
1375
+ gate \`done\` — they never block claiming (an
1376
+ agent works the code while a person mints the
1377
+ token; a true precondition is a blocked_by on
1378
+ a thin human ticket instead)
1379
+ task resolve <id> <n> [--comment "<note>"] [--reopen]
1380
+ check ask <n> off (\`task show\` prints the
1381
+ ordinals); the note lands as an attributed
1382
+ comment. --reopen puts a resolved ask back
1383
+ task inbox [--board <P>] what needs YOU, now: every open ask on
1384
+ effectively in-progress tickets — file status
1385
+ in_progress, or a live claim branch on origin
1386
+ (a claimed ticket is in progress whatever the
1387
+ merged file says, and its asks are read from
1388
+ the branch). Cross-board like claim --next;
1389
+ oldest owed first. Backlog/todo asks are out
1390
+ by design — claiming is what makes them yours
1155
1391
  task promote <id> backlog → todo, the human "build this" call.
1156
1392
  No quality gate — planning happens at claim
1157
- time. Blocked / needs-human tickets still
1158
- promote — those are claim-time gates, reported
1159
- as a note — so promoting can queue work behind
1393
+ time. Blocked tickets still promote
1394
+ blockers are claim-time gates, reported as a
1395
+ note — so promoting can queue work behind
1160
1396
  its blockers. Exit codes: 0 promoted, 2
1161
1397
  refused (not a backlog ticket)
1162
1398
  task link <id> --blocked-by <id>
@@ -1164,7 +1400,7 @@ Usage
1164
1400
  both tasks (A blocked by B ⇔ B blocks A)
1165
1401
  task unlink <id> (--blocks <id> | --blocked-by <id>)
1166
1402
  task claim <id> [--force] claim a ticket before working it: branch
1167
- <branchPrefix><prefix>-<n> off origin's
1403
+ <branchPrefix><prefix>-<key> off origin's
1168
1404
  default branch, ticket → in_progress as its
1169
1405
  first commit, pushed. The namespace defaults
1170
1406
  to task/claim/ — set claims.branchPrefix in
@@ -1177,8 +1413,21 @@ Usage
1177
1413
  many claim branches exist on origin, claiming
1178
1414
  is refused (--force overrides). Exit codes:
1179
1415
  0 claimed, 1 already claimed, 2 not claimable
1180
- (not todo, blocked, needs-human, dirty tree,
1181
- at the cap)
1416
+ (not todo, blocked, dirty tree, at the cap)
1417
+ task claim <id> --lock-only [--force]
1418
+ take the lock without touching the checkout:
1419
+ the in_progress flip commit is built against
1420
+ origin's default branch in a temporary index
1421
+ and pushed as the claim branch — same atomic
1422
+ lock, same exit codes, but HEAD, the working
1423
+ tree and the index stay exactly where they
1424
+ are (dirty is fine). For sessions pinned to
1425
+ a provisioned branch (CI, a Claude web
1426
+ session): the lock lives on the claim
1427
+ branch, the work delivers on yours, and the
1428
+ flip commit's delivered_by: trailer records
1429
+ which. --release works on a lock-only claim
1430
+ the same way
1182
1431
  task claim --next [--board <P>] [--force]
1183
1432
  claim the top claimable ticket in one call,
1184
1433
  retrying past lost races internally — the
@@ -1194,18 +1443,36 @@ Usage
1194
1443
  the branch, so deleting it is the revert. The
1195
1444
  comment is required and lands on the ticket
1196
1445
  (attributed like task comment, uncommitted),
1197
- so the next worker inherits what was tried
1446
+ so the next worker inherits what was tried.
1447
+ The deletion is verified with ls-remote: an
1448
+ environment that silently drops deletion
1449
+ pushes fails loudly instead of reporting a
1450
+ release that didn't happen
1451
+ task sweep the janitor: delete every claim branch on
1452
+ origin whose ticket is done, canceled, or
1453
+ missing on the current checkout, across all
1454
+ the repo's boards. Scoped to names \`claim\`
1455
+ mints (<prefix>-<valid key>): anything else
1456
+ under the namespace — pre-migration numeric
1457
+ claims, hand-made branches — is reported and
1458
+ left alone, since it may be an open PR's
1459
+ head. Pure git, zero judgment — run it from
1460
+ a runner that can delete remote branches (CI
1461
+ on push-to-main or cron) to heal stale
1462
+ claims left by environments that can't.
1463
+ Prints swept/kept/skipped; exits non-zero if
1464
+ a deletion was attempted and failed
1198
1465
  task list --claimable [--board <P>]
1199
1466
  the claim queue: todo tickets in position
1200
- order, minus blocked / needs-human / already
1201
- claimed on origin. Top entry is next up. Same
1202
- board scope as claim --next
1467
+ order, minus blocked / already claimed on
1468
+ origin. Top entry is next up. Same board
1469
+ scope as claim --next
1203
1470
  task instructions print the shipped agent conventions
1204
1471
  (skill/SKILL.md) — agents without the skill
1205
1472
  installed self-serve the house rules
1206
1473
  task overview [--json] one screen of board health: counts per
1207
1474
  status, per-goal progress, claimed and
1208
- needs-human counts, stale tickets (untouched
1475
+ open-ask counts, stale tickets (untouched
1209
1476
  ${STALE_DAYS}+ days), oldest open ticket
1210
1477
  task comment <id> <text> [--author <who>]
1211
1478
  task delete <id>
@@ -1227,7 +1494,7 @@ Usage
1227
1494
  task archive <id> move a done/canceled ticket to .task/archive/,
1228
1495
  out of the board and off the hot path — still
1229
1496
  readable via show and list --archived, and its
1230
- number stays reserved
1497
+ key stays reserved
1231
1498
  task archive --all archive everything done or canceled
1232
1499
  task unarchive <id> put an archived ticket back on the board
1233
1500
  task whoami who your comments are attributed to
@@ -1259,15 +1526,18 @@ Usage
1259
1526
  tasks — those are in .task/ either way
1260
1527
 
1261
1528
  Values
1262
- <id> TAS-12, or just 12. A bare number means the nearest board; a
1263
- prefixed id routes to whichever board in the repo owns that
1264
- prefix, so TAS-12 works from anywhere in a monorepo
1529
+ <id> TAS-x7k4m, x7k4m, or any unique prefix of a key (task show x7
1530
+ works, git-style ambiguity is an error naming the matches).
1531
+ Ids are random 5-character keys, minted branch-safely at
1532
+ \`task add\`. A bare key means the nearest board; a prefixed id
1533
+ routes to whichever board in the repo owns that prefix, so
1534
+ TAS-x7k4m works from anywhere in a monorepo
1265
1535
  status ${STATUSES.join(" ")}
1266
1536
  --tag a,b matches a task carrying *either* tag
1267
1537
  --goal <slug> a task belongs to at most one goal. Goals answer "where is
1268
1538
  this going" (one); tags answer "what kind" (many) — something
1269
1539
  that wants two goals is a tag
1270
- --needs-human this can't be finished by an agent alone
1540
+ --needs-human filter: tickets with open asks something a person owes
1271
1541
  --pr <url> attach a pull request (appends); --prs replaces the whole list
1272
1542
  clearing --tags "" drops all tags, --goal "" clears it, --prs ""
1273
1543
  detaches all PRs
@@ -1313,8 +1583,16 @@ function main() {
1313
1583
  return cmdLink(args, "unlink");
1314
1584
  case "claim":
1315
1585
  return cmdClaim(args);
1586
+ case "sweep":
1587
+ return cmdSweep(args);
1316
1588
  case "instructions":
1317
1589
  return cmdInstructions();
1590
+ case "ask":
1591
+ return cmdAsk(args);
1592
+ case "resolve":
1593
+ return cmdResolve(args);
1594
+ case "inbox":
1595
+ return cmdInbox(args);
1318
1596
  case "overview":
1319
1597
  return cmdOverview(args);
1320
1598
  case "goal":