@nickmeriano/task 0.7.1 → 0.9.0

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