@nickmeriano/task 0.7.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/README.md +12 -7
  2. package/dist/check.d.ts +42 -0
  3. package/dist/check.d.ts.map +1 -0
  4. package/dist/check.js +364 -0
  5. package/dist/check.js.map +1 -0
  6. package/dist/check.test.d.ts +9 -0
  7. package/dist/check.test.d.ts.map +1 -0
  8. package/dist/check.test.js +209 -0
  9. package/dist/check.test.js.map +1 -0
  10. package/dist/claim.d.ts +56 -3
  11. package/dist/claim.d.ts.map +1 -1
  12. package/dist/claim.js +164 -9
  13. package/dist/claim.js.map +1 -1
  14. package/dist/claim.test.js +168 -14
  15. package/dist/claim.test.js.map +1 -1
  16. package/dist/cli.js +504 -93
  17. package/dist/cli.js.map +1 -1
  18. package/dist/file-store.d.ts +41 -15
  19. package/dist/file-store.d.ts.map +1 -1
  20. package/dist/file-store.js +197 -99
  21. package/dist/file-store.js.map +1 -1
  22. package/dist/index.d.ts +5 -3
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +5 -3
  25. package/dist/index.js.map +1 -1
  26. package/dist/overview.d.ts +43 -0
  27. package/dist/overview.d.ts.map +1 -0
  28. package/dist/overview.js +53 -0
  29. package/dist/overview.js.map +1 -0
  30. package/dist/overview.test.d.ts +8 -0
  31. package/dist/overview.test.d.ts.map +1 -0
  32. package/dist/overview.test.js +47 -0
  33. package/dist/overview.test.js.map +1 -0
  34. package/dist/promote.test.d.ts +14 -0
  35. package/dist/promote.test.d.ts.map +1 -0
  36. package/dist/promote.test.js +106 -0
  37. package/dist/promote.test.js.map +1 -0
  38. package/dist/search.d.ts +32 -0
  39. package/dist/search.d.ts.map +1 -0
  40. package/dist/search.js +66 -0
  41. package/dist/search.js.map +1 -0
  42. package/dist/search.test.d.ts +2 -0
  43. package/dist/search.test.d.ts.map +1 -0
  44. package/dist/search.test.js +53 -0
  45. package/dist/search.test.js.map +1 -0
  46. package/dist/server.d.ts.map +1 -1
  47. package/dist/server.js +10 -2
  48. package/dist/server.js.map +1 -1
  49. package/dist/store.d.ts +16 -50
  50. package/dist/store.d.ts.map +1 -1
  51. package/dist/store.js +0 -368
  52. package/dist/store.js.map +1 -1
  53. package/dist/store.test.d.ts +1 -2
  54. package/dist/store.test.d.ts.map +1 -1
  55. package/dist/store.test.js +77 -48
  56. package/dist/store.test.js.map +1 -1
  57. package/dist/ticket-doc.d.ts +55 -2
  58. package/dist/ticket-doc.d.ts.map +1 -1
  59. package/dist/ticket-doc.js +177 -7
  60. package/dist/ticket-doc.js.map +1 -1
  61. package/dist/types.d.ts +67 -14
  62. package/dist/types.d.ts.map +1 -1
  63. package/dist/types.js.map +1 -1
  64. package/package.json +1 -1
  65. package/skill/SKILL.md +97 -31
  66. package/src/check.test.ts +271 -0
  67. package/src/check.ts +454 -0
  68. package/src/claim.test.ts +202 -14
  69. package/src/claim.ts +194 -10
  70. package/src/cli.ts +510 -89
  71. package/src/file-store.ts +217 -111
  72. package/src/index.ts +4 -2
  73. package/src/overview.test.ts +51 -0
  74. package/src/overview.ts +90 -0
  75. package/src/promote.test.ts +131 -0
  76. package/src/search.test.ts +64 -0
  77. package/src/search.ts +91 -0
  78. package/src/server.ts +11 -2
  79. package/src/store.test.ts +89 -57
  80. package/src/store.ts +18 -431
  81. package/src/ticket-doc.ts +210 -10
  82. package/src/types.ts +71 -14
  83. package/ui/dist/assets/{index-D4homvrQ.js → index-BJmOsOdR.js} +73 -73
  84. package/ui/dist/assets/index-BoqQlqSU.css +1 -0
  85. package/ui/dist/index.html +2 -2
  86. package/ui/dist/assets/index-COunM-QN.css +0 -1
package/dist/cli.js CHANGED
@@ -1,32 +1,27 @@
1
1
  #!/usr/bin/env node
2
2
  // The `task` CLI — tasks that live in your repo. Zero dependencies: hand-rolled
3
- // flag parsing, plain-text tickets in .task/tickets/ for storage (node:sqlite
4
- // still reads boards from before `task migrate`), plain text out (--json for
5
- // agents).
3
+ // flag parsing, plain-text tickets in .task/tickets/ for storage, plain text
4
+ // out (--json for agents).
6
5
  //
7
6
  // task init
8
- // task add "Wire up webhooks" --tags api,infra --milestone launch
7
+ // task add "Wire up webhooks" --tags api,infra --goal launch
9
8
  // task list --status todo,in_progress
10
- // task start TAS-3 && task done TAS-3
9
+ // task move TAS-3 done
11
10
  // task serve
12
11
  import { spawn } from "node:child_process";
13
12
  import { readFileSync } from "node:fs";
14
13
  import { basename, join } from "node:path";
15
14
  import process from "node:process";
16
15
  import { resolveAuthor } from "./author.js";
17
- import { ClaimError, claim, claimableTasks, release } from "./claim.js";
16
+ import { checkBoards, checkRoot } from "./check.js";
17
+ import { ClaimError, claim, claimNext, claimableTasks, promote, release, selectionBoards, } from "./claim.js";
18
+ import { STALE_DAYS, buildOverview } from "./overview.js";
19
+ import { searchStore } from "./search.js";
18
20
  import { detectRepo, parseSlug, publish, resolveHost } from "./publish.js";
19
21
  import { createTaskServer } from "./server.js";
20
- import { initProject, migrateBoard, openBoard } from "./file-store.js";
22
+ import { initProject, openBoard } from "./file-store.js";
21
23
  import { CONFIG_FILE, TASK_DIR, boardConfig, findBoards, findBoardsByPrefix, findRoot, findScopeRoot, } from "./store.js";
22
24
  import { STATUSES, isStatus } from "./types.js";
23
- // node:sqlite still emits an ExperimentalWarning on Node 22 — noise in a CLI
24
- // that runs it on every invocation. Filter that one warning, keep the rest.
25
- process.removeAllListeners("warning");
26
- process.on("warning", (warning) => {
27
- if (warning.name !== "ExperimentalWarning")
28
- console.error(warning);
29
- });
30
25
  /**
31
26
  * Read from package.json rather than duplicated as a literal. Releases here are
32
27
  * cut by changesets, which bumps package.json and nothing else — so a hardcoded
@@ -60,7 +55,16 @@ const BOOLEAN_FLAGS = new Set([
60
55
  "public",
61
56
  "private",
62
57
  "no-wait",
58
+ "fix",
59
+ "next",
60
+ "force",
63
61
  ]);
62
+ /**
63
+ * Value flags that may repeat — `--board NIC --board TAS` accumulates in
64
+ * given order, equivalent to `--board NIC,TAS`. Everything else keeps
65
+ * last-one-wins.
66
+ */
67
+ const REPEATABLE_FLAGS = new Set(["board"]);
64
68
  function parseArgs(argv) {
65
69
  const positional = [];
66
70
  const flags = {};
@@ -68,15 +72,20 @@ function parseArgs(argv) {
68
72
  const arg = argv[i];
69
73
  if (arg.startsWith("--")) {
70
74
  const name = arg.slice(2);
75
+ const set = (key, value) => {
76
+ const prior = flags[key];
77
+ flags[key] =
78
+ REPEATABLE_FLAGS.has(key) && typeof prior === "string" ? `${prior},${value}` : value;
79
+ };
71
80
  const eq = name.indexOf("=");
72
81
  if (eq >= 0) {
73
- flags[name.slice(0, eq)] = name.slice(eq + 1);
82
+ set(name.slice(0, eq), name.slice(eq + 1));
74
83
  }
75
84
  else if (BOOLEAN_FLAGS.has(name) || i + 1 >= argv.length || argv[i + 1].startsWith("--")) {
76
85
  flags[name] = true;
77
86
  }
78
87
  else {
79
- flags[name] = argv[++i];
88
+ set(name, argv[++i]);
80
89
  }
81
90
  }
82
91
  else {
@@ -170,17 +179,32 @@ function patchFromFlags(flags) {
170
179
  if (title !== undefined)
171
180
  patch.title = title;
172
181
  const description = str(flags, "description") ?? str(flags, "desc");
182
+ // Long markdown doesn't survive shell quoting — `--description-file` keeps
183
+ // agents writing plans and specs on the CLI path instead of hand-editing
184
+ // ticket files. `-` reads stdin.
185
+ const descriptionFile = str(flags, "description-file");
186
+ if (description !== undefined && descriptionFile !== undefined) {
187
+ fail("pass --description or --description-file, not both");
188
+ }
173
189
  if (description !== undefined)
174
190
  patch.description = description;
191
+ if (descriptionFile !== undefined) {
192
+ try {
193
+ patch.description = readFileSync(descriptionFile === "-" ? 0 : descriptionFile, "utf8");
194
+ }
195
+ catch {
196
+ fail(`--description-file: can't read ${descriptionFile === "-" ? "stdin" : descriptionFile}`);
197
+ }
198
+ }
175
199
  const status = str(flags, "status");
176
200
  if (status !== undefined)
177
201
  patch.status = parseStatus(status);
178
202
  const tags = str(flags, "tags") ?? str(flags, "tag");
179
203
  if (tags !== undefined)
180
204
  patch.tags = parseTags(tags);
181
- const milestone = str(flags, "milestone");
182
- if (milestone !== undefined)
183
- patch.milestone = milestone.trim() || null;
205
+ const goal = str(flags, "goal");
206
+ if (goal !== undefined)
207
+ patch.goal = goal.trim() || null;
184
208
  if (flags["needs-human"])
185
209
  patch.needsHuman = true;
186
210
  if (flags["no-needs-human"])
@@ -216,7 +240,7 @@ function taskRow(t) {
216
240
  t.needsHuman ? "⚑" : "",
217
241
  t.title,
218
242
  t.tags.join(","),
219
- t.milestone ?? "",
243
+ t.goal ?? "",
220
244
  ];
221
245
  }
222
246
  function printTask(t) {
@@ -244,7 +268,7 @@ function cmdInit(args) {
244
268
  function cmdAdd(args) {
245
269
  const title = args.positional.join(" ").trim();
246
270
  if (!title)
247
- fail(`usage: task add <title> [--description …] [--status …] [--tags a,b] [--milestone ] [--needs-human]`);
271
+ fail(`usage: task add <title> [--description …] [--status …] [--tags a,b] [--goal <slug>] [--needs-human]`);
248
272
  const store = openStore();
249
273
  const patch = patchFromFlags(args.flags);
250
274
  const pr = str(args.flags, "pr");
@@ -258,8 +282,17 @@ function cmdAdd(args) {
258
282
  printTask(task);
259
283
  }
260
284
  }
285
+ /** `--board NIC --board TAS` (or `--board NIC,TAS`) → ["NIC", "TAS"]. */
286
+ function boardFlag(args) {
287
+ const raw = str(args.flags, "board");
288
+ if (raw === undefined)
289
+ return undefined;
290
+ const prefixes = raw.split(",").map((p) => p.trim()).filter(Boolean);
291
+ if (prefixes.length === 0)
292
+ fail("--board needs a prefix, e.g. --board TAS");
293
+ return prefixes;
294
+ }
261
295
  function cmdList(args) {
262
- const store = openStore();
263
296
  if (args.flags.claimable) {
264
297
  // The dispatcher's queue view (TAS-21): claimable already pins the status
265
298
  // set and consults origin, so the board-shaping flags don't compose.
@@ -267,7 +300,17 @@ function cmdList(args) {
267
300
  if (args.flags[flag] !== undefined)
268
301
  fail(`--claimable can't be combined with --${flag}`);
269
302
  }
270
- const tasks = claimableTasks(store);
303
+ // Selection scope, same resolution as `claim --next`: --board flag > root
304
+ // board's `boards` list > nearest board. Board order is priority order.
305
+ let tasks;
306
+ try {
307
+ tasks = selectionBoards(process.cwd(), boardFlag(args)).flatMap((s) => claimableTasks(s));
308
+ }
309
+ catch (error) {
310
+ if (error instanceof ClaimError)
311
+ fail(error.message);
312
+ throw error;
313
+ }
271
314
  if (args.flags.json) {
272
315
  console.log(JSON.stringify({ tasks }, null, 2));
273
316
  }
@@ -279,6 +322,9 @@ function cmdList(args) {
279
322
  }
280
323
  return;
281
324
  }
325
+ if (args.flags.board !== undefined)
326
+ fail("--board only applies to list --claimable");
327
+ const store = openStore();
282
328
  const archived = Boolean(args.flags.archived);
283
329
  const statusFlag = str(args.flags, "status");
284
330
  const statuses = statusFlag
@@ -292,7 +338,7 @@ function cmdList(args) {
292
338
  const tasks = store.list({
293
339
  statuses,
294
340
  tags: tagFlag ? parseTags(tagFlag) : undefined,
295
- milestone: str(args.flags, "milestone"),
341
+ goal: str(args.flags, "goal"),
296
342
  needsHuman: args.flags["needs-human"] ? true : undefined,
297
343
  archived: archived || undefined,
298
344
  });
@@ -313,6 +359,43 @@ function cmdList(args) {
313
359
  table(tasks.map(taskRow));
314
360
  }
315
361
  }
362
+ /**
363
+ * `task search <query>` — find tickets by what they say. Case-insensitive
364
+ * substring over titles, descriptions and comments, scoped by the usual list
365
+ * filters; `--archived` searches the archive *in addition to* the board (a
366
+ * search is a question about everything ever written down, unlike
367
+ * `list --archived`, which is a view of one place).
368
+ */
369
+ function cmdSearch(args) {
370
+ const query = args.positional.join(" ").trim();
371
+ if (!query) {
372
+ fail("usage: task search <query> [--status <s1,s2>] [--tag <a,b>] [--goal <slug>] [--needs-human] [--archived]");
373
+ }
374
+ const store = openStore();
375
+ const statusFlag = str(args.flags, "status");
376
+ const tagFlag = str(args.flags, "tags") ?? str(args.flags, "tag");
377
+ const results = searchStore(store, query, {
378
+ statuses: statusFlag ? statusFlag.split(",").map(parseStatus) : undefined,
379
+ tags: tagFlag ? parseTags(tagFlag) : undefined,
380
+ goal: str(args.flags, "goal"),
381
+ needsHuman: args.flags["needs-human"] ? true : undefined,
382
+ }, Boolean(args.flags.archived));
383
+ if (args.flags.json) {
384
+ console.log(JSON.stringify({ query, results }, null, 2));
385
+ return;
386
+ }
387
+ if (results.length === 0) {
388
+ console.log(`no matches for "${query}"${args.flags.archived ? "" : " (--archived searches the archive too)"}`);
389
+ return;
390
+ }
391
+ for (const { task, matches } of results) {
392
+ console.log(`${task.id} ${STATUS_GLYPH[task.status]} ${task.status}${task.archived ? " (archived)" : ""} ${task.title}`);
393
+ for (const match of matches) {
394
+ const label = match.field === "comment" ? `comment${match.author ? ` by ${match.author}` : ""}` : match.field;
395
+ console.log(` ${label}: ${match.snippet}`);
396
+ }
397
+ }
398
+ }
316
399
  /**
317
400
  * `task boards` — every board in this repo, found the way `task serve` finds
318
401
  * them but anchored at the *outermost* board root, so it answers from anywhere
@@ -412,8 +495,12 @@ function cmdShow(args) {
412
495
  if (!task)
413
496
  fail(`no such task: ${store.displayId(number)}`);
414
497
  const comments = store.comments(number);
498
+ // The goal rides along in full — title *and* description — so an agent
499
+ // picking the ticket up cold inherits the shared context without it being
500
+ // pasted into every ticket. null when unset or when the ref dangles.
501
+ const goal = task.goal ? store.getGoal(task.goal) : null;
415
502
  if (args.flags.json) {
416
- console.log(JSON.stringify({ task, comments }, null, 2));
503
+ console.log(JSON.stringify({ task, comments, goal }, null, 2));
417
504
  return;
418
505
  }
419
506
  // A linked task is only as useful as knowing whether it's still in the way.
@@ -431,8 +518,9 @@ function cmdShow(args) {
431
518
  console.log(`needs a human`);
432
519
  if (task.tags.length)
433
520
  console.log(`tags ${task.tags.join(", ")}`);
434
- if (task.milestone)
435
- console.log(`milestone ${task.milestone}`);
521
+ if (task.goal) {
522
+ console.log(`goal ${task.goal}${goal ? ` — ${goal.title}${goal.archived ? " (archived)" : ""}` : ""}`);
523
+ }
436
524
  if (task.blockedBy.length)
437
525
  console.log(`blocked by ${describeLinks(task.blockedBy)}`);
438
526
  if (task.blocks.length)
@@ -443,6 +531,10 @@ function cmdShow(args) {
443
531
  console.log(`updated ${task.updatedAt}`);
444
532
  if (task.description)
445
533
  console.log(`\n${task.description}`);
534
+ if (goal?.description) {
535
+ console.log(`\n── goal: ${goal.title} (${goal.slug})`);
536
+ console.log(goal.description.split("\n").map((line) => ` ${line}`).join("\n"));
537
+ }
446
538
  if (comments.length) {
447
539
  console.log(``);
448
540
  for (const c of comments) {
@@ -451,15 +543,13 @@ function cmdShow(args) {
451
543
  }
452
544
  }
453
545
  }
454
- function cmdUpdate(args, forcedStatus) {
546
+ function cmdUpdate(args) {
455
547
  const ref = args.positional[0];
456
548
  if (!ref)
457
549
  fail("usage: task update <id> [--status …] [--title …] …");
458
550
  const store = openStoreFor(ref);
459
551
  const number = store.parseId(ref);
460
552
  const patch = patchFromFlags(args.flags);
461
- if (forcedStatus)
462
- patch.status = forcedStatus;
463
553
  const pr = str(args.flags, "pr");
464
554
  if (pr) {
465
555
  // Append, dedup — `--pr <url>` is "attach this PR", not "replace the list".
@@ -482,6 +572,36 @@ function cmdMove(args) {
482
572
  fail("usage: task move <id> <status>");
483
573
  cmdUpdate({ positional: [ref], flags: { ...args.flags, status } });
484
574
  }
575
+ /**
576
+ * `task promote <id>` — backlog → todo, the human "build this" call; see
577
+ * claim.ts for why blocked / needs-human are holds (reported), not refusals.
578
+ * Exit codes follow claim's contract: 0 promoted, 2 refused (fix something).
579
+ */
580
+ function cmdPromote(args) {
581
+ const ref = args.positional[0];
582
+ if (!ref)
583
+ fail("usage: task promote <id>");
584
+ const store = openStoreFor(ref);
585
+ try {
586
+ const result = promote(store, store.parseId(ref));
587
+ if (args.flags.json) {
588
+ console.log(JSON.stringify({ task: result.task, holds: result.holds }, null, 2));
589
+ }
590
+ else {
591
+ printTask(result.task);
592
+ for (const hold of result.holds) {
593
+ console.log(`note: ${hold} — in todo, but not claimable until that clears`);
594
+ }
595
+ }
596
+ }
597
+ catch (error) {
598
+ if (error instanceof ClaimError) {
599
+ console.error(`error: ${error.message}`);
600
+ process.exit(2);
601
+ }
602
+ throw error;
603
+ }
604
+ }
485
605
  function cmdComment(args) {
486
606
  const [ref, ...rest] = args.positional;
487
607
  const body = rest.join(" ").trim();
@@ -525,39 +645,80 @@ function cmdLink(args, action) {
525
645
  console.log(`${subject} ${verb} blocks ${object}`);
526
646
  }
527
647
  }
648
+ const CLAIM_USAGE = 'usage: task claim <id> | task claim --next | task claim --release <id> --comment "<why>"';
528
649
  /**
529
- * `task claim <id>` / `task claim --release <id>` — see claim.ts for the
530
- * mechanics. Exit codes are the contract callers script against: 0 claimed,
531
- * 1 already claimed (pick the next ticket), 2 not claimable (fix something).
650
+ * `task claim <id>` / `task claim --next` / `task claim --release <id>` — see
651
+ * claim.ts for the mechanics. Exit codes are the contract callers script
652
+ * against: 0 claimed, 1 already claimed / queue empty (nothing to do here
653
+ * a scheduled worker stops cleanly), 2 preconditions failed (fix something).
532
654
  */
533
655
  function cmdClaim(args) {
534
656
  const ref = args.positional[0];
535
- if (!ref)
536
- fail("usage: task claim <id> | task claim --release <id>");
537
- const store = openStoreFor(ref);
538
- const number = store.parseId(ref);
657
+ const options = { force: Boolean(args.flags.force) };
658
+ const printClaim = (result) => {
659
+ if (args.flags.json) {
660
+ console.log(JSON.stringify({ task: result.task, branch: result.branch }, null, 2));
661
+ }
662
+ else {
663
+ console.log(`claimed ${result.task.id} — on ${result.branch} (from ${result.base}), status in_progress`);
664
+ }
665
+ };
539
666
  try {
667
+ if (args.flags.next) {
668
+ // The first line of every scheduled worker: claim the top claimable
669
+ // ticket across the selection scope (--board > root `boards` > nearest),
670
+ // retrying past lost races internally.
671
+ if (ref)
672
+ fail("pass an id or --next, not both");
673
+ if (args.flags.release)
674
+ fail(CLAIM_USAGE);
675
+ const result = claimNext(selectionBoards(process.cwd(), boardFlag(args)), options);
676
+ if (!result) {
677
+ if (args.flags.json) {
678
+ console.log(JSON.stringify({ task: null }, null, 2));
679
+ }
680
+ else {
681
+ console.log("nothing claimable — no unblocked, unclaimed todo tickets in scope");
682
+ }
683
+ process.exit(1);
684
+ }
685
+ printClaim(result);
686
+ return;
687
+ }
688
+ if (!ref)
689
+ fail(CLAIM_USAGE);
690
+ const store = openStoreFor(ref);
691
+ const number = store.parseId(ref);
540
692
  if (args.flags.release) {
693
+ // A released claim must leave its failure context behind: the branch
694
+ // (and whatever was tried on it) is about to evaporate, so the reason
695
+ // is required, and lands as an ordinary attributed comment.
696
+ const why = str(args.flags, "comment")?.trim();
697
+ if (!why) {
698
+ fail('task claim --release requires --comment "<why>" — the next worker inherits what was tried');
699
+ }
541
700
  const result = release(store, number);
701
+ const commented = result.remote || result.local;
702
+ // After the branch deletion, so the comment survives it: release leaves
703
+ // the checkout on the default branch, and the comment file sits there
704
+ // uncommitted, to ride along with the next commit.
705
+ const comment = commented
706
+ ? store.addComment(number, why, resolveAuthor(str(args.flags, "author")).name)
707
+ : null;
542
708
  if (args.flags.json) {
543
- console.log(JSON.stringify({ released: result }, null, 2));
709
+ console.log(JSON.stringify({ released: result, comment }, null, 2));
544
710
  }
545
- else if (!result.remote && !result.local) {
711
+ else if (!commented) {
546
712
  console.log(`${store.displayId(number)} wasn't claimed — no ${result.branch} to delete`);
547
713
  }
548
714
  else {
549
715
  const where = [result.remote && "origin", result.local && "local"].filter(Boolean);
550
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`);
551
718
  }
552
719
  return;
553
720
  }
554
- const result = claim(store, number);
555
- if (args.flags.json) {
556
- console.log(JSON.stringify({ task: result.task, branch: result.branch }, null, 2));
557
- }
558
- else {
559
- console.log(`claimed ${result.task.id} — on ${result.branch} (from ${result.base}), status in_progress`);
560
- }
721
+ printClaim(claim(store, number, options));
561
722
  }
562
723
  catch (error) {
563
724
  if (error instanceof ClaimError) {
@@ -567,6 +728,164 @@ function cmdClaim(args) {
567
728
  throw error;
568
729
  }
569
730
  }
731
+ /** `task instructions` — the shipped agent conventions, self-served at runtime. */
732
+ function cmdInstructions() {
733
+ // skill/ ships in the npm package; `../` from both dist/cli.js and
734
+ // src/cli.ts is the package root, same trick VERSION uses.
735
+ process.stdout.write(readFileSync(new URL("../skill/SKILL.md", import.meta.url), "utf8"));
736
+ }
737
+ /** `task overview [--json]` — one screen of board health. See overview.ts. */
738
+ function cmdOverview(args) {
739
+ const overview = buildOverview(openStore());
740
+ if (args.flags.json) {
741
+ console.log(JSON.stringify({ overview }, null, 2));
742
+ return;
743
+ }
744
+ console.log(`${overview.board} (${overview.prefix}) — ${overview.open} open`);
745
+ console.log(` ${STATUSES.map((s) => `${STATUS_GLYPH[s]} ${s} ${overview.statuses[s]}`).join(" ")}`);
746
+ const claimed = overview.claimed === null
747
+ ? "claimed ? (origin unreachable)"
748
+ : `claimed ${overview.claimed.length}${overview.claimed.length ? ` (${overview.claimed.join(", ")})` : ""}`;
749
+ console.log(` ${claimed} needs-human ${overview.needsHuman}`);
750
+ if (overview.goals.length) {
751
+ console.log("goals");
752
+ table(overview.goals.map((g) => [
753
+ ` ${g.slug}`,
754
+ g.title,
755
+ `${g.done}/${g.total} done${g.total ? ` (${Math.round((g.done / g.total) * 100)}%)` : ""}`,
756
+ ]));
757
+ }
758
+ if (overview.stale.length) {
759
+ console.log(`stale (untouched ${STALE_DAYS}+ days)`);
760
+ table(overview.stale.map((t) => [` ${t.id}`, t.title, t.updatedAt.slice(0, 10)]));
761
+ }
762
+ if (overview.oldestOpen) {
763
+ console.log(`oldest open ${overview.oldestOpen.id} ${overview.oldestOpen.title} (created ${overview.oldestOpen.createdAt.slice(0, 10)})`);
764
+ }
765
+ }
766
+ /**
767
+ * `task goal …` — the goals a board's tasks belong to. A goal is a titled,
768
+ * described destination (`.task/goals/<slug>.md`); a task carries at most one.
769
+ * There is no goal status anywhere here on purpose: progress is derived from
770
+ * the tasks, and `archive` is the only lifecycle a goal has.
771
+ */
772
+ function cmdGoal(args) {
773
+ const [sub, ...rest] = args.positional;
774
+ const usage = "usage: task goal add <title> [--slug <s>] [--description …] | list [--archived] | " +
775
+ "show <slug> | update <slug> [--title …] [--description …] | archive <slug> | " +
776
+ "unarchive <slug> | delete <slug>";
777
+ if (!sub)
778
+ fail(usage);
779
+ const store = openStore();
780
+ const slugArg = () => {
781
+ const slug = rest[0];
782
+ if (!slug)
783
+ fail(`usage: task goal ${sub} <slug>`);
784
+ return slug;
785
+ };
786
+ const printGoal = (goal, verb) => console.log(`${verb} ${goal.slug} ${goal.title}${goal.archived ? " (archived)" : ""}`);
787
+ switch (sub) {
788
+ case "add": {
789
+ const title = rest.join(" ").trim();
790
+ if (!title)
791
+ fail("usage: task goal add <title> [--slug <s>] [--description …]");
792
+ const goal = store.createGoal({
793
+ title,
794
+ slug: str(args.flags, "slug"),
795
+ description: str(args.flags, "description") ?? str(args.flags, "desc"),
796
+ });
797
+ if (args.flags.json)
798
+ console.log(JSON.stringify({ goal }, null, 2));
799
+ else
800
+ printGoal(goal, "created");
801
+ return;
802
+ }
803
+ case "list": {
804
+ const archived = Boolean(args.flags.archived);
805
+ const goals = store.goals(archived);
806
+ // Progress is derived here, at read time — a goal file never stores it.
807
+ const tasks = store.list({ statuses: undefined });
808
+ const rows = goals.map((goal) => {
809
+ const mine = tasks.filter((t) => t.goal === goal.slug);
810
+ const done = mine.filter((t) => t.status === "done" || t.status === "canceled").length;
811
+ return { ...goal, open: mine.length - done, done, total: mine.length };
812
+ });
813
+ if (args.flags.json) {
814
+ console.log(JSON.stringify({ goals: rows }, null, 2));
815
+ }
816
+ else if (rows.length === 0) {
817
+ console.log(archived ? "no archived goals" : "no goals — create one with `task goal add`");
818
+ }
819
+ else {
820
+ table(rows.map((g) => [g.slug, g.title, `${g.done}/${g.total} done`]));
821
+ }
822
+ return;
823
+ }
824
+ case "show": {
825
+ const slug = slugArg();
826
+ const goal = store.getGoal(slug);
827
+ if (!goal)
828
+ fail(`no such goal: "${slug}"`);
829
+ const tasks = store.list({ statuses: undefined, goal: slug });
830
+ if (args.flags.json) {
831
+ console.log(JSON.stringify({ goal, tasks }, null, 2));
832
+ return;
833
+ }
834
+ console.log(`${goal.slug} ${goal.title}`);
835
+ if (goal.archived)
836
+ console.log(`archived yes — \`task goal unarchive ${goal.slug}\` to edit`);
837
+ console.log(`created ${goal.createdAt}`);
838
+ console.log(`updated ${goal.updatedAt}`);
839
+ if (goal.description)
840
+ console.log(`\n${goal.description}`);
841
+ if (tasks.length) {
842
+ console.log("");
843
+ table(tasks.map(taskRow));
844
+ }
845
+ return;
846
+ }
847
+ case "update": {
848
+ const patch = {
849
+ ...(str(args.flags, "title") !== undefined && { title: str(args.flags, "title") }),
850
+ ...((str(args.flags, "description") ?? str(args.flags, "desc")) !== undefined && {
851
+ description: str(args.flags, "description") ?? str(args.flags, "desc"),
852
+ }),
853
+ };
854
+ if (Object.keys(patch).length === 0)
855
+ fail("nothing to update — pass --title or --description");
856
+ const goal = store.updateGoal(slugArg(), patch);
857
+ if (args.flags.json)
858
+ console.log(JSON.stringify({ goal }, null, 2));
859
+ else
860
+ printGoal(goal, "updated");
861
+ return;
862
+ }
863
+ case "archive": {
864
+ const goal = store.archiveGoal(slugArg());
865
+ if (args.flags.json)
866
+ console.log(JSON.stringify({ goal }, null, 2));
867
+ else
868
+ printGoal(goal, "archived");
869
+ return;
870
+ }
871
+ case "unarchive": {
872
+ const goal = store.unarchiveGoal(slugArg());
873
+ if (args.flags.json)
874
+ console.log(JSON.stringify({ goal }, null, 2));
875
+ else
876
+ printGoal(goal, "unarchived");
877
+ return;
878
+ }
879
+ case "delete": {
880
+ const slug = slugArg();
881
+ store.deleteGoal(slug);
882
+ console.log(`deleted goal ${slug}`);
883
+ return;
884
+ }
885
+ default:
886
+ fail(usage);
887
+ }
888
+ }
570
889
  const AUTHOR_SOURCE = {
571
890
  flag: "--author",
572
891
  env: "$TASK_AUTHOR",
@@ -584,26 +903,43 @@ function cmdWhoami(args) {
584
903
  }
585
904
  }
586
905
  /**
587
- * `task migrate` — from the legacy committed SQLite database to text-canonical
588
- * storage: one markdown file per ticket and per comment under .task/tickets/,
589
- * with the database left on disk as an ignored backup. Additive and safe to
590
- * re-run planning-wise: it refuses to run twice.
906
+ * `task check [--fix]` — lint the board files. Git merges and hand-edits are
907
+ * write paths no CLI code sees, so validation is a command you can run (and
908
+ * put in pre-commit or CI, where merge-introduced drift gets caught). Covers
909
+ * every board at or below here, the same set `task serve` would serve. Exits
910
+ * non-zero while problems remain; `--fix` first applies the mechanical
911
+ * repairs — pruning dangling references, rewriting files into canonical form.
591
912
  */
592
- function cmdMigrate(args) {
593
- const root = findRoot(process.cwd());
594
- if (!root)
595
- fail("no .task directory found in this directory or any parent — run `task init` first");
596
- const result = migrateBoard(root);
913
+ function cmdCheck(args) {
914
+ const root = checkRoot(process.cwd());
915
+ const fix = Boolean(args.flags.fix);
916
+ const result = checkBoards(root, fix);
917
+ if (result.boards === 0) {
918
+ fail("no .task directory found in this directory, any parent, or below — run `task init` first");
919
+ }
920
+ // In fix mode the fixable issues were just repaired; what's left needs a human.
921
+ const remaining = fix ? result.issues.filter((issue) => !issue.fixable) : result.issues;
597
922
  if (args.flags.json) {
598
- console.log(JSON.stringify({ migrated: result }, null, 2));
599
- return;
923
+ console.log(JSON.stringify({ ...result, remaining }, null, 2));
924
+ }
925
+ else {
926
+ for (const file of result.fixed)
927
+ console.log(`fixed ${file}`);
928
+ for (const issue of remaining) {
929
+ console.log(`${issue.fixable ? "fixable" : "problem"} ${issue.file}`);
930
+ console.log(` ${issue.message}`);
931
+ }
932
+ const scope = `${result.files} file${result.files === 1 ? "" : "s"} across ${result.boards} board${result.boards === 1 ? "" : "s"}`;
933
+ if (remaining.length === 0) {
934
+ console.log(result.fixed.length ? `fixed ${result.fixed.length}, ${scope} clean` : `ok — ${scope}`);
935
+ }
936
+ else {
937
+ const fixable = remaining.filter((issue) => issue.fixable).length;
938
+ console.log(`${remaining.length} problem${remaining.length === 1 ? "" : "s"} in ${scope}${fixable ? ` (${fixable} fixable — run \`task check --fix\`)` : ""}`);
939
+ }
600
940
  }
601
- console.log(`Migrated ${result.tasks} task${result.tasks === 1 ? "" : "s"} and ${result.comments} comment${result.comments === 1 ? "" : "s"} to .task/tickets/`);
602
- console.log(`tasks.db stays on disk as a backup, but it's ignored now — the files are the state.`);
603
- console.log(``);
604
- console.log(`Next:`);
605
- console.log(` git rm --cached ${join(TASK_DIR, "tasks.db")} stop tracking the database`);
606
- console.log(` git add ${TASK_DIR} commit the tickets`);
941
+ if (remaining.length > 0)
942
+ process.exitCode = 1;
607
943
  }
608
944
  function cmdDelete(args) {
609
945
  const ref = args.positional[0];
@@ -790,42 +1126,102 @@ subdirectory (it walks up to find .task/, like git).
790
1126
 
791
1127
  Usage
792
1128
  task init [--name <name>] [--prefix <PREFIX>]
793
- task add <title> [--description <text>] [--status <s>] [--tags <a,b>]
794
- [--milestone <m>] [--needs-human]
795
- task list [--status <s1,s2>] [--tag <a,b>] [--milestone <m>]
1129
+ task add <title> [--description <text> | --description-file <path|->]
1130
+ [--status <s>] [--tags <a,b>] [--goal <slug>] [--needs-human]
1131
+ new tickets land in backlog (the dump list)
1132
+ unless --status says otherwise;
1133
+ --description-file reads markdown from a file
1134
+ (or stdin with -) — no shell-quoting fights
1135
+ task list [--status <s1,s2>] [--tag <a,b>] [--goal <slug>]
796
1136
  [--needs-human] [--all] [--archived] [--claimable]
797
- task show <id>
798
- task update <id> [--title <t>] [--description <text>] [--status <s>]
799
- [--tags <a,b>] [--milestone <m>]
1137
+ task search <query> [--status <s1,s2>] [--tag <a,b>] [--goal <slug>]
1138
+ [--needs-human] [--archived]
1139
+ find tickets by what they say — case-insensitive
1140
+ match over titles, descriptions and comments,
1141
+ with a snippet of the matching line. --archived
1142
+ searches the archive too, on top of the board
1143
+ task show <id> the ticket, its comments — and its goal's title
1144
+ and description inline, so a cold pickup gets
1145
+ the shared context without chasing it
1146
+ task update <id> [--title <t>] [--description <text> | --description-file <path|->]
1147
+ [--status <s>] [--tags <a,b>] [--goal <slug>]
800
1148
  [--needs-human | --no-needs-human]
801
1149
  [--pr <url>] [--prs <url1,url2>]
802
- task move <id> <status> shorthand for update --status
803
- task start <id> → in_progress
804
- task done <id> → done
1150
+ task move <id> <status> change a ticket's status — the one general
1151
+ 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
1155
+ task promote <id> backlog → todo, the human "build this" call.
1156
+ 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
1160
+ its blockers. Exit codes: 0 promoted, 2
1161
+ refused (not a backlog ticket)
805
1162
  task link <id> --blocked-by <id>
806
1163
  task link <id> --blocks <id> mark a dependency — one relation, visible from
807
1164
  both tasks (A blocked by B ⇔ B blocks A)
808
1165
  task unlink <id> (--blocks <id> | --blocked-by <id>)
809
- task claim <id> claim a ticket before working it: branch
810
- <claimPrefix><prefix>-<n> off origin's default
811
- branch, ticket → in_progress as its first
812
- commit, pushed. The namespace defaults to
813
- task/claim/ — set "claimPrefix" in
1166
+ task claim <id> [--force] claim a ticket before working it: branch
1167
+ <branchPrefix><prefix>-<n> off origin's
1168
+ default branch, ticket → in_progress as its
1169
+ first commit, pushed. The namespace defaults
1170
+ to task/claim/ — set claims.branchPrefix in
814
1171
  .task/config.json to change it (e.g.
815
1172
  "claude/task/", which Claude cloud sessions
816
1173
  can push). Branch on origin = claimed —
817
1174
  git's atomic ref creation is the lock, so two
818
- concurrent claimers can't both win. Exit codes:
1175
+ concurrent claimers can't both win. While the
1176
+ root board sets claims.maxOpenCount and that
1177
+ many claim branches exist on origin, claiming
1178
+ is refused (--force overrides). Exit codes:
819
1179
  0 claimed, 1 already claimed, 2 not claimable
820
- (not todo, blocked, needs-human, dirty tree)
821
- task claim --release <id> abandon a claim: delete the branch on origin
1180
+ (not todo, blocked, needs-human, dirty tree,
1181
+ at the cap)
1182
+ task claim --next [--board <P>] [--force]
1183
+ claim the top claimable ticket in one call,
1184
+ retrying past lost races internally — the
1185
+ first line of every scheduled worker. Scope:
1186
+ --board prefixes (repeatable, given order =
1187
+ priority) > the root board's "boards" list >
1188
+ the nearest board. Exit codes: 0 claimed
1189
+ (prints the ticket), 1 queue empty, 2
1190
+ preconditions failed
1191
+ task claim --release <id> --comment "<why>"
1192
+ abandon a claim: delete the branch on origin
822
1193
  and locally — the status flip only lived on
823
- the branch, so deleting it is the revert
824
- task list --claimable the claim queue: todo tickets in position
1194
+ the branch, so deleting it is the revert. The
1195
+ comment is required and lands on the ticket
1196
+ (attributed like task comment, uncommitted),
1197
+ so the next worker inherits what was tried
1198
+ task list --claimable [--board <P>]
1199
+ the claim queue: todo tickets in position
825
1200
  order, minus blocked / needs-human / already
826
- claimed on origin. Top entry is next up
1201
+ claimed on origin. Top entry is next up. Same
1202
+ board scope as claim --next
1203
+ task instructions print the shipped agent conventions
1204
+ (skill/SKILL.md) — agents without the skill
1205
+ installed self-serve the house rules
1206
+ task overview [--json] one screen of board health: counts per
1207
+ status, per-goal progress, claimed and
1208
+ needs-human counts, stale tickets (untouched
1209
+ ${STALE_DAYS}+ days), oldest open ticket
827
1210
  task comment <id> <text> [--author <who>]
828
1211
  task delete <id>
1212
+ task goal add <title> [--slug <s>] [--description <text>]
1213
+ create a goal: a titled, described destination
1214
+ tasks belong to (.task/goals/<slug>.md). The
1215
+ description is the strategic why — the context
1216
+ every ticket under it shares
1217
+ task goal list [--archived] every goal with derived progress (done/total —
1218
+ a goal stores no status of its own, ever)
1219
+ task goal show <slug> the goal, its description, and its tasks
1220
+ task goal update <slug> [--title <t>] [--description <text>]
1221
+ task goal archive <slug> shelve a finished/abandoned goal — refused
1222
+ while it still has open tasks
1223
+ task goal unarchive <slug>
1224
+ task goal delete <slug> only when no live task references it
829
1225
  task boards every board in this repo — prefix, name, path,
830
1226
  open count; * marks the one commands target here
831
1227
  task archive <id> move a done/canceled ticket to .task/archive/,
@@ -835,6 +1231,15 @@ Usage
835
1231
  task archive --all archive everything done or canceled
836
1232
  task unarchive <id> put an archived ticket back on the board
837
1233
  task whoami who your comments are attributed to
1234
+ task check [--fix] lint the board files — merges and hand-edits
1235
+ are write paths no CLI code sees. Reports
1236
+ unparseable files, dangling blocked_by refs,
1237
+ bad timestamps, unknown frontmatter keys and
1238
+ formatting drift, for every board at or below
1239
+ here; exits non-zero while problems remain.
1240
+ --fix applies the mechanical repairs first
1241
+ (prune dangling refs, rewrite to canonical
1242
+ form). Cheap enough for pre-commit and CI
838
1243
  task serve [--port <n>] [--no-open] [--strict-port]
839
1244
  board + table UI with live updates, opened in
840
1245
  your browser (default port ${DEFAULT_PORT}; taken ports
@@ -852,9 +1257,6 @@ Usage
852
1257
  task unpublish [--repo <owner/name>]
853
1258
  take that URL down. Removes the board, not the
854
1259
  tasks — those are in .task/ either way
855
- task migrate move a pre-0.6 board off its committed SQLite
856
- database and onto text files in .task/tickets/.
857
- The database stays on disk as an ignored backup
858
1260
 
859
1261
  Values
860
1262
  <id> TAS-12, or just 12. A bare number means the nearest board; a
@@ -862,9 +1264,12 @@ Values
862
1264
  prefix, so TAS-12 works from anywhere in a monorepo
863
1265
  status ${STATUSES.join(" ")}
864
1266
  --tag a,b matches a task carrying *either* tag
1267
+ --goal <slug> a task belongs to at most one goal. Goals answer "where is
1268
+ this going" (one); tags answer "what kind" (many) — something
1269
+ that wants two goals is a tag
865
1270
  --needs-human this can't be finished by an agent alone
866
1271
  --pr <url> attach a pull request (appends); --prs replaces the whole list
867
- clearing --tags "" drops all tags, --milestone "" clears it, --prs ""
1272
+ clearing --tags "" drops all tags, --goal "" clears it, --prs ""
868
1273
  detaches all PRs
869
1274
 
870
1275
  Comment authors resolve --author → $TASK_AUTHOR → git config user.name →
@@ -892,22 +1297,28 @@ function main() {
892
1297
  return cmdAdd(args);
893
1298
  case "list":
894
1299
  return cmdList(args);
1300
+ case "search":
1301
+ return cmdSearch(args);
895
1302
  case "show":
896
1303
  return cmdShow(args);
897
1304
  case "update":
898
1305
  return cmdUpdate(args);
899
1306
  case "move":
900
1307
  return cmdMove(args);
901
- case "start":
902
- return cmdUpdate(args, "in_progress");
903
- case "done":
904
- return cmdUpdate(args, "done");
1308
+ case "promote":
1309
+ return cmdPromote(args);
905
1310
  case "link":
906
1311
  return cmdLink(args, "link");
907
1312
  case "unlink":
908
1313
  return cmdLink(args, "unlink");
909
1314
  case "claim":
910
1315
  return cmdClaim(args);
1316
+ case "instructions":
1317
+ return cmdInstructions();
1318
+ case "overview":
1319
+ return cmdOverview(args);
1320
+ case "goal":
1321
+ return cmdGoal(args);
911
1322
  case "comment":
912
1323
  return cmdComment(args);
913
1324
  case "whoami":
@@ -920,8 +1331,8 @@ function main() {
920
1331
  return cmdArchive(args);
921
1332
  case "unarchive":
922
1333
  return cmdUnarchive(args);
923
- case "migrate":
924
- return cmdMigrate(args);
1334
+ case "check":
1335
+ return cmdCheck(args);
925
1336
  case "serve":
926
1337
  return cmdServe(args);
927
1338
  case "publish":