@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.
- package/README.md +73 -25
- package/dist/asks.test.d.ts +17 -0
- package/dist/asks.test.d.ts.map +1 -0
- package/dist/asks.test.js +278 -0
- package/dist/asks.test.js.map +1 -0
- package/dist/check.d.ts +43 -0
- package/dist/check.d.ts.map +1 -0
- package/dist/check.js +403 -0
- package/dist/check.js.map +1 -0
- package/dist/check.test.d.ts +9 -0
- package/dist/check.test.d.ts.map +1 -0
- package/dist/check.test.js +248 -0
- package/dist/check.test.js.map +1 -0
- package/dist/claim-io.d.ts +73 -0
- package/dist/claim-io.d.ts.map +1 -0
- package/dist/claim-io.js +344 -0
- package/dist/claim-io.js.map +1 -0
- package/dist/claim.d.ts +61 -9
- package/dist/claim.d.ts.map +1 -1
- package/dist/claim.js +197 -67
- package/dist/claim.js.map +1 -1
- package/dist/claim.test.d.ts +2 -2
- package/dist/claim.test.js +235 -64
- package/dist/claim.test.js.map +1 -1
- package/dist/cli.js +724 -136
- package/dist/cli.js.map +1 -1
- package/dist/file-store.d.ts +110 -38
- package/dist/file-store.d.ts.map +1 -1
- package/dist/file-store.js +514 -238
- package/dist/file-store.js.map +1 -1
- package/dist/git-serve.d.ts +183 -0
- package/dist/git-serve.d.ts.map +1 -0
- package/dist/git-serve.js +503 -0
- package/dist/git-serve.js.map +1 -0
- package/dist/git-serve.test.d.ts +16 -0
- package/dist/git-serve.test.d.ts.map +1 -0
- package/dist/git-serve.test.js +183 -0
- package/dist/git-serve.test.js.map +1 -0
- package/dist/git.d.ts +65 -0
- package/dist/git.d.ts.map +1 -0
- package/dist/git.js +114 -0
- package/dist/git.js.map +1 -0
- package/dist/id.d.ts +39 -0
- package/dist/id.d.ts.map +1 -0
- package/dist/id.js +67 -0
- package/dist/id.js.map +1 -0
- package/dist/inbox.d.ts +41 -0
- package/dist/inbox.d.ts.map +1 -0
- package/dist/inbox.js +56 -0
- package/dist/inbox.js.map +1 -0
- package/dist/index.d.ts +5 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -3
- package/dist/index.js.map +1 -1
- package/dist/overview.d.ts +52 -0
- package/dist/overview.d.ts.map +1 -0
- package/dist/overview.js +61 -0
- package/dist/overview.js.map +1 -0
- package/dist/overview.test.d.ts +8 -0
- package/dist/overview.test.d.ts.map +1 -0
- package/dist/overview.test.js +48 -0
- package/dist/overview.test.js.map +1 -0
- package/dist/promote.test.d.ts +15 -0
- package/dist/promote.test.d.ts.map +1 -0
- package/dist/promote.test.js +104 -0
- package/dist/promote.test.js.map +1 -0
- package/dist/publish.d.ts +2 -17
- package/dist/publish.d.ts.map +1 -1
- package/dist/publish.js +4 -49
- package/dist/publish.js.map +1 -1
- package/dist/search.d.ts +34 -0
- package/dist/search.d.ts.map +1 -0
- package/dist/search.js +79 -0
- package/dist/search.js.map +1 -0
- package/dist/search.test.d.ts +2 -0
- package/dist/search.test.d.ts.map +1 -0
- package/dist/search.test.js +53 -0
- package/dist/search.test.js.map +1 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +228 -23
- package/dist/server.js.map +1 -1
- package/dist/store.d.ts +43 -63
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +0 -368
- package/dist/store.js.map +1 -1
- package/dist/store.test.d.ts +1 -2
- package/dist/store.test.d.ts.map +1 -1
- package/dist/store.test.js +148 -106
- package/dist/store.test.js.map +1 -1
- package/dist/ticket-doc.d.ts +74 -5
- package/dist/ticket-doc.d.ts.map +1 -1
- package/dist/ticket-doc.js +229 -15
- package/dist/ticket-doc.js.map +1 -1
- package/dist/types.d.ts +115 -28
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +1 -1
- package/skill/SKILL.md +153 -40
- package/src/asks.test.ts +355 -0
- package/src/check.test.ts +328 -0
- package/src/check.ts +497 -0
- package/src/claim-io.ts +401 -0
- package/src/claim.test.ts +301 -71
- package/src/claim.ts +238 -81
- package/src/cli.ts +740 -131
- package/src/file-store.ts +572 -254
- package/src/git-serve.test.ts +240 -0
- package/src/git-serve.ts +595 -0
- package/src/git.ts +141 -0
- package/src/id.ts +68 -0
- package/src/inbox.ts +77 -0
- package/src/index.ts +4 -2
- package/src/overview.test.ts +52 -0
- package/src/overview.ts +105 -0
- package/src/promote.test.ts +143 -0
- package/src/publish.ts +6 -53
- package/src/search.test.ts +64 -0
- package/src/search.ts +105 -0
- package/src/server.ts +232 -21
- package/src/store.test.ts +166 -116
- package/src/store.ts +46 -444
- package/src/ticket-doc.ts +284 -21
- package/src/types.ts +120 -28
- package/ui/dist/assets/index-BjsorZOU.js +229 -0
- package/ui/dist/assets/index-CoKCUYic.css +1 -0
- package/ui/dist/index.html +2 -2
- package/ui/dist/assets/index-COunM-QN.css +0 -1
- package/ui/dist/assets/index-D4homvrQ.js +0 -229
package/src/cli.ts
CHANGED
|
@@ -1,25 +1,37 @@
|
|
|
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
|
|
4
|
-
//
|
|
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 --
|
|
7
|
+
// task add "Wire up webhooks" --tags api,infra --goal launch
|
|
9
8
|
// task list --status todo,in_progress
|
|
10
|
-
// task
|
|
9
|
+
// task move TAS-x7k4m done
|
|
11
10
|
// task serve
|
|
12
11
|
|
|
13
12
|
import { spawn } from "node:child_process"
|
|
14
13
|
import { readFileSync } from "node:fs"
|
|
15
14
|
import type { Server } from "node:http"
|
|
16
|
-
import { basename, join } from "node:path"
|
|
15
|
+
import { basename, join, relative, sep } from "node:path"
|
|
17
16
|
import process from "node:process"
|
|
18
17
|
import { resolveAuthor } from "./author.ts"
|
|
19
|
-
import {
|
|
18
|
+
import { checkBoards, checkRoot } from "./check.ts"
|
|
19
|
+
import {
|
|
20
|
+
ClaimError,
|
|
21
|
+
claim,
|
|
22
|
+
claimNext,
|
|
23
|
+
claimableTasks,
|
|
24
|
+
promote,
|
|
25
|
+
release,
|
|
26
|
+
selectionBoards,
|
|
27
|
+
} from "./claim.ts"
|
|
28
|
+
import { addAskRouted, setAskResolvedRouted } from "./claim-io.ts"
|
|
29
|
+
import { buildInbox, type InboxEntry } from "./inbox.ts"
|
|
30
|
+
import { STALE_DAYS, buildOverview } from "./overview.ts"
|
|
31
|
+
import { searchStore } from "./search.ts"
|
|
20
32
|
import { detectRepo, parseSlug, publish, resolveHost } from "./publish.ts"
|
|
21
33
|
import { createTaskServer } from "./server.ts"
|
|
22
|
-
import { initProject,
|
|
34
|
+
import { initProject, openBoard } from "./file-store.ts"
|
|
23
35
|
import {
|
|
24
36
|
CONFIG_FILE,
|
|
25
37
|
TASK_DIR,
|
|
@@ -32,13 +44,6 @@ import {
|
|
|
32
44
|
} from "./store.ts"
|
|
33
45
|
import { STATUSES, isStatus, type Status, type Task, type TaskPatch } from "./types.ts"
|
|
34
46
|
|
|
35
|
-
// node:sqlite still emits an ExperimentalWarning on Node 22 — noise in a CLI
|
|
36
|
-
// that runs it on every invocation. Filter that one warning, keep the rest.
|
|
37
|
-
process.removeAllListeners("warning")
|
|
38
|
-
process.on("warning", (warning) => {
|
|
39
|
-
if (warning.name !== "ExperimentalWarning") console.error(warning)
|
|
40
|
-
})
|
|
41
|
-
|
|
42
47
|
/**
|
|
43
48
|
* Read from package.json rather than duplicated as a literal. Releases here are
|
|
44
49
|
* cut by changesets, which bumps package.json and nothing else — so a hardcoded
|
|
@@ -63,6 +68,8 @@ const PORT_ATTEMPTS = 20
|
|
|
63
68
|
interface Args {
|
|
64
69
|
positional: string[]
|
|
65
70
|
flags: Record<string, string | boolean>
|
|
71
|
+
/** Values of MULTI_FLAGS, in given order — e.g. multi.ask from `--ask "…"`. */
|
|
72
|
+
multi: Record<string, string[]>
|
|
66
73
|
}
|
|
67
74
|
|
|
68
75
|
// Every valueless flag has to be listed here: an unregistered one swallows the
|
|
@@ -74,7 +81,6 @@ const BOOLEAN_FLAGS = new Set([
|
|
|
74
81
|
"version",
|
|
75
82
|
"yes",
|
|
76
83
|
"needs-human",
|
|
77
|
-
"no-needs-human",
|
|
78
84
|
"archived",
|
|
79
85
|
"claimable",
|
|
80
86
|
"release",
|
|
@@ -84,28 +90,56 @@ const BOOLEAN_FLAGS = new Set([
|
|
|
84
90
|
"public",
|
|
85
91
|
"private",
|
|
86
92
|
"no-wait",
|
|
93
|
+
"fix",
|
|
94
|
+
"next",
|
|
95
|
+
"force",
|
|
96
|
+
"reopen",
|
|
87
97
|
])
|
|
88
98
|
|
|
99
|
+
/**
|
|
100
|
+
* Value flags that may repeat — `--board NIC --board TAS` accumulates in
|
|
101
|
+
* given order, equivalent to `--board NIC,TAS`. Everything else keeps
|
|
102
|
+
* last-one-wins.
|
|
103
|
+
*/
|
|
104
|
+
const REPEATABLE_FLAGS = new Set(["board"])
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Repeatable flags whose values are free text — collected verbatim into a
|
|
108
|
+
* list, never comma-joined, because an ask like "check A, then B" must
|
|
109
|
+
* survive as one ask.
|
|
110
|
+
*/
|
|
111
|
+
const MULTI_FLAGS = new Set(["ask"])
|
|
112
|
+
|
|
89
113
|
function parseArgs(argv: string[]): Args {
|
|
90
114
|
const positional: string[] = []
|
|
91
115
|
const flags: Record<string, string | boolean> = {}
|
|
116
|
+
const multi: Record<string, string[]> = {}
|
|
92
117
|
for (let i = 0; i < argv.length; i++) {
|
|
93
118
|
const arg = argv[i]
|
|
94
119
|
if (arg.startsWith("--")) {
|
|
95
120
|
const name = arg.slice(2)
|
|
121
|
+
const set = (key: string, value: string): void => {
|
|
122
|
+
if (MULTI_FLAGS.has(key)) {
|
|
123
|
+
;(multi[key] ??= []).push(value)
|
|
124
|
+
return
|
|
125
|
+
}
|
|
126
|
+
const prior = flags[key]
|
|
127
|
+
flags[key] =
|
|
128
|
+
REPEATABLE_FLAGS.has(key) && typeof prior === "string" ? `${prior},${value}` : value
|
|
129
|
+
}
|
|
96
130
|
const eq = name.indexOf("=")
|
|
97
131
|
if (eq >= 0) {
|
|
98
|
-
|
|
132
|
+
set(name.slice(0, eq), name.slice(eq + 1))
|
|
99
133
|
} else if (BOOLEAN_FLAGS.has(name) || i + 1 >= argv.length || argv[i + 1].startsWith("--")) {
|
|
100
134
|
flags[name] = true
|
|
101
135
|
} else {
|
|
102
|
-
|
|
136
|
+
set(name, argv[++i])
|
|
103
137
|
}
|
|
104
138
|
} else {
|
|
105
139
|
positional.push(arg)
|
|
106
140
|
}
|
|
107
141
|
}
|
|
108
|
-
return { positional, flags }
|
|
142
|
+
return { positional, flags, multi }
|
|
109
143
|
}
|
|
110
144
|
|
|
111
145
|
function str(flags: Args["flags"], name: string): string | undefined {
|
|
@@ -124,19 +158,19 @@ function openStore(): Store {
|
|
|
124
158
|
return openBoard(root)
|
|
125
159
|
}
|
|
126
160
|
|
|
127
|
-
/** The prefix a ref carries, if any: "TAS-
|
|
161
|
+
/** The prefix a ref carries, if any: "TAS-x7k4m" → "TAS", "x7k4m" → null. */
|
|
128
162
|
function refPrefix(ref: string): string | null {
|
|
129
|
-
const match = /^([A-Za-z0-9]+)
|
|
163
|
+
const match = /^([A-Za-z0-9]+)-[A-Za-z0-9]+$/.exec(ref.trim())
|
|
130
164
|
return match ? match[1].toUpperCase() : null
|
|
131
165
|
}
|
|
132
166
|
|
|
133
167
|
/**
|
|
134
|
-
* The board a ref belongs to. A bare
|
|
168
|
+
* The board a ref belongs to. A bare key means the nearest board, as ever —
|
|
135
169
|
* but a prefixed id is an address, and it routes: if the prefix isn't the
|
|
136
170
|
* nearest board's, every board in the repo (walking up to the outermost board
|
|
137
|
-
* root, then down) is searched for it, so `task show TAS-
|
|
171
|
+
* root, then down) is searched for it, so `task show TAS-x7k4m` works from
|
|
138
172
|
* anywhere in a monorepo. What this must never do is what it used to: silently
|
|
139
|
-
* strip a foreign prefix and act on the nearest board's ticket of that
|
|
173
|
+
* strip a foreign prefix and act on the nearest board's ticket of that key.
|
|
140
174
|
*/
|
|
141
175
|
function openStoreFor(ref: string | undefined): Store {
|
|
142
176
|
const prefix = ref ? refPrefix(ref) : null
|
|
@@ -174,7 +208,7 @@ function openStoreFor(ref: string | undefined): Store {
|
|
|
174
208
|
* instead of reinterpreting it — this guards the second id in `task link`,
|
|
175
209
|
* where the board was picked by the first.
|
|
176
210
|
*/
|
|
177
|
-
function parseRefOn(store: Store, ref: string):
|
|
211
|
+
function parseRefOn(store: Store, ref: string): string {
|
|
178
212
|
const prefix = refPrefix(ref)
|
|
179
213
|
if (prefix && prefix !== store.config.prefix.toUpperCase()) {
|
|
180
214
|
fail(`${ref} is not on the ${store.config.prefix} board — links can't cross boards`)
|
|
@@ -199,15 +233,27 @@ function patchFromFlags(flags: Args["flags"]): TaskPatch {
|
|
|
199
233
|
const title = str(flags, "title")
|
|
200
234
|
if (title !== undefined) patch.title = title
|
|
201
235
|
const description = str(flags, "description") ?? str(flags, "desc")
|
|
236
|
+
// Long markdown doesn't survive shell quoting — `--description-file` keeps
|
|
237
|
+
// agents writing plans and specs on the CLI path instead of hand-editing
|
|
238
|
+
// ticket files. `-` reads stdin.
|
|
239
|
+
const descriptionFile = str(flags, "description-file")
|
|
240
|
+
if (description !== undefined && descriptionFile !== undefined) {
|
|
241
|
+
fail("pass --description or --description-file, not both")
|
|
242
|
+
}
|
|
202
243
|
if (description !== undefined) patch.description = description
|
|
244
|
+
if (descriptionFile !== undefined) {
|
|
245
|
+
try {
|
|
246
|
+
patch.description = readFileSync(descriptionFile === "-" ? 0 : descriptionFile, "utf8")
|
|
247
|
+
} catch {
|
|
248
|
+
fail(`--description-file: can't read ${descriptionFile === "-" ? "stdin" : descriptionFile}`)
|
|
249
|
+
}
|
|
250
|
+
}
|
|
203
251
|
const status = str(flags, "status")
|
|
204
252
|
if (status !== undefined) patch.status = parseStatus(status)
|
|
205
253
|
const tags = str(flags, "tags") ?? str(flags, "tag")
|
|
206
254
|
if (tags !== undefined) patch.tags = parseTags(tags)
|
|
207
|
-
const
|
|
208
|
-
if (
|
|
209
|
-
if (flags["needs-human"]) patch.needsHuman = true
|
|
210
|
-
if (flags["no-needs-human"]) patch.needsHuman = false
|
|
255
|
+
const goal = str(flags, "goal")
|
|
256
|
+
if (goal !== undefined) patch.goal = goal.trim() || null
|
|
211
257
|
// Replace semantics like --tags; `--pr` (append one) is handled per-command
|
|
212
258
|
// because appending needs the task's current list.
|
|
213
259
|
const prs = str(flags, "prs")
|
|
@@ -241,7 +287,7 @@ function taskRow(t: Task): string[] {
|
|
|
241
287
|
t.needsHuman ? "⚑" : "",
|
|
242
288
|
t.title,
|
|
243
289
|
t.tags.join(","),
|
|
244
|
-
t.
|
|
290
|
+
t.goal ?? "",
|
|
245
291
|
]
|
|
246
292
|
}
|
|
247
293
|
|
|
@@ -272,28 +318,51 @@ function cmdInit(args: Args): void {
|
|
|
272
318
|
|
|
273
319
|
function cmdAdd(args: Args): void {
|
|
274
320
|
const title = args.positional.join(" ").trim()
|
|
275
|
-
if (!title) fail(`usage: task add <title> [--description …] [--status …] [--tags a,b] [--
|
|
321
|
+
if (!title) fail(`usage: task add <title> [--description …] [--status …] [--tags a,b] [--goal <slug>] [--ask "<text>"]…`)
|
|
276
322
|
const store = openStore()
|
|
277
323
|
const patch = patchFromFlags(args.flags)
|
|
278
324
|
const pr = str(args.flags, "pr")
|
|
279
325
|
if (pr) patch.prs = [...(patch.prs ?? []), pr]
|
|
280
|
-
|
|
326
|
+
let task = store.create({ title, ...patch })
|
|
327
|
+
const asks = (args.multi.ask ?? []).map((a) => a.trim()).filter(Boolean)
|
|
328
|
+
if (asks.length > 0) {
|
|
329
|
+
const author = resolveAuthor(str(args.flags, "author")).name
|
|
330
|
+
for (const text of asks) store.addAsk(task.key, text, author)
|
|
331
|
+
task = store.get(task.key)!
|
|
332
|
+
}
|
|
281
333
|
if (args.flags.json) {
|
|
282
334
|
console.log(JSON.stringify({ task }, null, 2))
|
|
283
335
|
} else {
|
|
284
336
|
printTask(task)
|
|
337
|
+
for (const ask of task.asks) console.log(` ask [${ask.ordinal}] ${ask.text.split("\n")[0]}`)
|
|
285
338
|
}
|
|
286
339
|
}
|
|
287
340
|
|
|
341
|
+
/** `--board NIC --board TAS` (or `--board NIC,TAS`) → ["NIC", "TAS"]. */
|
|
342
|
+
function boardFlag(args: Args): string[] | undefined {
|
|
343
|
+
const raw = str(args.flags, "board")
|
|
344
|
+
if (raw === undefined) return undefined
|
|
345
|
+
const prefixes = raw.split(",").map((p) => p.trim()).filter(Boolean)
|
|
346
|
+
if (prefixes.length === 0) fail("--board needs a prefix, e.g. --board TAS")
|
|
347
|
+
return prefixes
|
|
348
|
+
}
|
|
349
|
+
|
|
288
350
|
function cmdList(args: Args): void {
|
|
289
|
-
const store = openStore()
|
|
290
351
|
if (args.flags.claimable) {
|
|
291
352
|
// The dispatcher's queue view (TAS-21): claimable already pins the status
|
|
292
353
|
// set and consults origin, so the board-shaping flags don't compose.
|
|
293
354
|
for (const flag of ["status", "all", "archived"]) {
|
|
294
355
|
if (args.flags[flag] !== undefined) fail(`--claimable can't be combined with --${flag}`)
|
|
295
356
|
}
|
|
296
|
-
|
|
357
|
+
// Selection scope, same resolution as `claim --next`: --board flag > root
|
|
358
|
+
// board's `boards` list > nearest board. Board order is priority order.
|
|
359
|
+
let tasks: Task[]
|
|
360
|
+
try {
|
|
361
|
+
tasks = selectionBoards(process.cwd(), boardFlag(args)).flatMap((s) => claimableTasks(s))
|
|
362
|
+
} catch (error) {
|
|
363
|
+
if (error instanceof ClaimError) fail(error.message)
|
|
364
|
+
throw error
|
|
365
|
+
}
|
|
297
366
|
if (args.flags.json) {
|
|
298
367
|
console.log(JSON.stringify({ tasks }, null, 2))
|
|
299
368
|
} else if (tasks.length === 0) {
|
|
@@ -303,6 +372,8 @@ function cmdList(args: Args): void {
|
|
|
303
372
|
}
|
|
304
373
|
return
|
|
305
374
|
}
|
|
375
|
+
if (args.flags.board !== undefined) fail("--board only applies to list --claimable")
|
|
376
|
+
const store = openStore()
|
|
306
377
|
const archived = Boolean(args.flags.archived)
|
|
307
378
|
const statusFlag = str(args.flags, "status")
|
|
308
379
|
const statuses = statusFlag
|
|
@@ -316,7 +387,7 @@ function cmdList(args: Args): void {
|
|
|
316
387
|
const tasks = store.list({
|
|
317
388
|
statuses,
|
|
318
389
|
tags: tagFlag ? parseTags(tagFlag) : undefined,
|
|
319
|
-
|
|
390
|
+
goal: str(args.flags, "goal"),
|
|
320
391
|
needsHuman: args.flags["needs-human"] ? true : undefined,
|
|
321
392
|
archived: archived || undefined,
|
|
322
393
|
})
|
|
@@ -338,6 +409,53 @@ function cmdList(args: Args): void {
|
|
|
338
409
|
}
|
|
339
410
|
}
|
|
340
411
|
|
|
412
|
+
/**
|
|
413
|
+
* `task search <query>` — find tickets by what they say. Case-insensitive
|
|
414
|
+
* substring over titles, descriptions and comments, scoped by the usual list
|
|
415
|
+
* filters; `--archived` searches the archive *in addition to* the board (a
|
|
416
|
+
* search is a question about everything ever written down, unlike
|
|
417
|
+
* `list --archived`, which is a view of one place).
|
|
418
|
+
*/
|
|
419
|
+
function cmdSearch(args: Args): void {
|
|
420
|
+
const query = args.positional.join(" ").trim()
|
|
421
|
+
if (!query) {
|
|
422
|
+
fail(
|
|
423
|
+
"usage: task search <query> [--status <s1,s2>] [--tag <a,b>] [--goal <slug>] [--needs-human] [--archived]",
|
|
424
|
+
)
|
|
425
|
+
}
|
|
426
|
+
const store = openStore()
|
|
427
|
+
const statusFlag = str(args.flags, "status")
|
|
428
|
+
const tagFlag = str(args.flags, "tags") ?? str(args.flags, "tag")
|
|
429
|
+
const results = searchStore(
|
|
430
|
+
store,
|
|
431
|
+
query,
|
|
432
|
+
{
|
|
433
|
+
statuses: statusFlag ? statusFlag.split(",").map(parseStatus) : undefined,
|
|
434
|
+
tags: tagFlag ? parseTags(tagFlag) : undefined,
|
|
435
|
+
goal: str(args.flags, "goal"),
|
|
436
|
+
needsHuman: args.flags["needs-human"] ? true : undefined,
|
|
437
|
+
},
|
|
438
|
+
Boolean(args.flags.archived),
|
|
439
|
+
)
|
|
440
|
+
if (args.flags.json) {
|
|
441
|
+
console.log(JSON.stringify({ query, results }, null, 2))
|
|
442
|
+
return
|
|
443
|
+
}
|
|
444
|
+
if (results.length === 0) {
|
|
445
|
+
console.log(`no matches for "${query}"${args.flags.archived ? "" : " (--archived searches the archive too)"}`)
|
|
446
|
+
return
|
|
447
|
+
}
|
|
448
|
+
for (const { task, matches } of results) {
|
|
449
|
+
console.log(
|
|
450
|
+
`${task.id} ${STATUS_GLYPH[task.status]} ${task.status}${task.archived ? " (archived)" : ""} ${task.title}`,
|
|
451
|
+
)
|
|
452
|
+
for (const match of matches) {
|
|
453
|
+
const label = match.field === "comment" ? `comment${match.author ? ` by ${match.author}` : ""}` : match.field
|
|
454
|
+
console.log(` ${label}: ${match.snippet}`)
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
341
459
|
/**
|
|
342
460
|
* `task boards` — every board in this repo, found the way `task serve` finds
|
|
343
461
|
* them but anchored at the *outermost* board root, so it answers from anywhere
|
|
@@ -403,7 +521,7 @@ function cmdArchive(args: Args): void {
|
|
|
403
521
|
|
|
404
522
|
const store = openStore()
|
|
405
523
|
const finished = store.list({ statuses: ["done", "canceled"] })
|
|
406
|
-
const archived = finished.map((t) => store.archive(t.
|
|
524
|
+
const archived = finished.map((t) => store.archive(t.key))
|
|
407
525
|
if (args.flags.json) {
|
|
408
526
|
console.log(JSON.stringify({ archived }, null, 2))
|
|
409
527
|
} else if (archived.length === 0) {
|
|
@@ -429,34 +547,54 @@ function cmdShow(args: Args): void {
|
|
|
429
547
|
const ref = args.positional[0]
|
|
430
548
|
if (!ref) fail("usage: task show <id>")
|
|
431
549
|
const store = openStoreFor(ref)
|
|
432
|
-
const
|
|
433
|
-
const task = store.get(
|
|
434
|
-
if (!task) fail(`no such task: ${store.displayId(
|
|
435
|
-
const comments = store.comments(
|
|
550
|
+
const key = store.parseId(ref)
|
|
551
|
+
const task = store.get(key)
|
|
552
|
+
if (!task) fail(`no such task: ${store.displayId(key)}`)
|
|
553
|
+
const comments = store.comments(key)
|
|
554
|
+
// The goal rides along in full — title *and* description — so an agent
|
|
555
|
+
// picking the ticket up cold inherits the shared context without it being
|
|
556
|
+
// pasted into every ticket. null when unset or when the ref dangles.
|
|
557
|
+
const goal = task.goal ? store.getGoal(task.goal) : null
|
|
436
558
|
if (args.flags.json) {
|
|
437
|
-
console.log(JSON.stringify({ task, comments }, null, 2))
|
|
559
|
+
console.log(JSON.stringify({ task, comments, goal }, null, 2))
|
|
438
560
|
return
|
|
439
561
|
}
|
|
440
562
|
// A linked task is only as useful as knowing whether it's still in the way.
|
|
441
|
-
const describeLinks = (
|
|
442
|
-
|
|
443
|
-
.map((
|
|
444
|
-
const other = store.get(
|
|
445
|
-
return other ? `${other.id} (${other.status})` : store.displayId(
|
|
563
|
+
const describeLinks = (keys: string[]): string =>
|
|
564
|
+
keys
|
|
565
|
+
.map((k) => {
|
|
566
|
+
const other = store.get(k)
|
|
567
|
+
return other ? `${other.id} (${other.status})` : store.displayId(k)
|
|
446
568
|
})
|
|
447
569
|
.join(", ")
|
|
448
570
|
console.log(`${task.id} ${task.title}`)
|
|
449
571
|
console.log(`status ${task.status}`)
|
|
450
572
|
if (task.archived) console.log(`archived yes — \`task unarchive ${task.id}\` to edit`)
|
|
451
|
-
|
|
573
|
+
const openAsks = task.asks.filter((a) => !a.resolvedAt)
|
|
574
|
+
if (openAsks.length) {
|
|
575
|
+
console.log(`needs a human — ${openAsks.length} open ask${openAsks.length === 1 ? "" : "s"}`)
|
|
576
|
+
}
|
|
577
|
+
for (const ask of task.asks) {
|
|
578
|
+
const state = ask.resolvedAt
|
|
579
|
+
? `✓ resolved${ask.resolvedBy ? ` by ${ask.resolvedBy}` : ""}`
|
|
580
|
+
: `○ open${ask.author ? ` · ${ask.author}` : ""} · ${ask.createdAt.slice(0, 10)}`
|
|
581
|
+
console.log(`ask [${ask.ordinal}] ${ask.text.split("\n").join("\n ")}`)
|
|
582
|
+
console.log(` ${state}`)
|
|
583
|
+
}
|
|
452
584
|
if (task.tags.length) console.log(`tags ${task.tags.join(", ")}`)
|
|
453
|
-
if (task.
|
|
585
|
+
if (task.goal) {
|
|
586
|
+
console.log(`goal ${task.goal}${goal ? ` — ${goal.title}${goal.archived ? " (archived)" : ""}` : ""}`)
|
|
587
|
+
}
|
|
454
588
|
if (task.blockedBy.length) console.log(`blocked by ${describeLinks(task.blockedBy)}`)
|
|
455
589
|
if (task.blocks.length) console.log(`blocks ${describeLinks(task.blocks)}`)
|
|
456
590
|
for (const pr of task.prs) console.log(`pr ${pr}`)
|
|
457
591
|
console.log(`created ${task.createdAt}`)
|
|
458
592
|
console.log(`updated ${task.updatedAt}`)
|
|
459
593
|
if (task.description) console.log(`\n${task.description}`)
|
|
594
|
+
if (goal?.description) {
|
|
595
|
+
console.log(`\n── goal: ${goal.title} (${goal.slug})`)
|
|
596
|
+
console.log(goal.description.split("\n").map((line) => ` ${line}`).join("\n"))
|
|
597
|
+
}
|
|
460
598
|
if (comments.length) {
|
|
461
599
|
console.log(``)
|
|
462
600
|
for (const c of comments) {
|
|
@@ -466,21 +604,20 @@ function cmdShow(args: Args): void {
|
|
|
466
604
|
}
|
|
467
605
|
}
|
|
468
606
|
|
|
469
|
-
function cmdUpdate(args: Args
|
|
607
|
+
function cmdUpdate(args: Args): void {
|
|
470
608
|
const ref = args.positional[0]
|
|
471
609
|
if (!ref) fail("usage: task update <id> [--status …] [--title …] …")
|
|
472
610
|
const store = openStoreFor(ref)
|
|
473
|
-
const
|
|
611
|
+
const key = store.parseId(ref)
|
|
474
612
|
const patch = patchFromFlags(args.flags)
|
|
475
|
-
if (forcedStatus) patch.status = forcedStatus
|
|
476
613
|
const pr = str(args.flags, "pr")
|
|
477
614
|
if (pr) {
|
|
478
615
|
// Append, dedup — `--pr <url>` is "attach this PR", not "replace the list".
|
|
479
|
-
const current = patch.prs ?? store.get(
|
|
616
|
+
const current = patch.prs ?? store.get(key)?.prs ?? []
|
|
480
617
|
patch.prs = current.includes(pr) ? current : [...current, pr]
|
|
481
618
|
}
|
|
482
619
|
if (Object.keys(patch).length === 0) fail("nothing to update — pass at least one flag")
|
|
483
|
-
const task = store.update(
|
|
620
|
+
const task = store.update(key, patch)
|
|
484
621
|
if (args.flags.json) {
|
|
485
622
|
console.log(JSON.stringify({ task }, null, 2))
|
|
486
623
|
} else {
|
|
@@ -491,7 +628,158 @@ function cmdUpdate(args: Args, forcedStatus?: Status): void {
|
|
|
491
628
|
function cmdMove(args: Args): void {
|
|
492
629
|
const [ref, status] = args.positional
|
|
493
630
|
if (!ref || !status) fail("usage: task move <id> <status>")
|
|
494
|
-
cmdUpdate({ positional: [ref], flags: { ...args.flags, status } })
|
|
631
|
+
cmdUpdate({ positional: [ref], flags: { ...args.flags, status }, multi: {} })
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/**
|
|
635
|
+
* `task promote <id>` — backlog → todo, the human "build this" call; see
|
|
636
|
+
* claim.ts for why blocked / needs-human are holds (reported), not refusals.
|
|
637
|
+
* Exit codes follow claim's contract: 0 promoted, 2 refused (fix something).
|
|
638
|
+
*/
|
|
639
|
+
function cmdPromote(args: Args): void {
|
|
640
|
+
const ref = args.positional[0]
|
|
641
|
+
if (!ref) fail("usage: task promote <id>")
|
|
642
|
+
const store = openStoreFor(ref)
|
|
643
|
+
try {
|
|
644
|
+
const result = promote(store, store.parseId(ref))
|
|
645
|
+
if (args.flags.json) {
|
|
646
|
+
console.log(JSON.stringify({ task: result.task, holds: result.holds }, null, 2))
|
|
647
|
+
} else {
|
|
648
|
+
printTask(result.task)
|
|
649
|
+
for (const hold of result.holds) {
|
|
650
|
+
console.log(`note: ${hold} — in todo, but not claimable until that clears`)
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
} catch (error) {
|
|
654
|
+
if (error instanceof ClaimError) {
|
|
655
|
+
console.error(`error: ${error.message}`)
|
|
656
|
+
process.exit(2)
|
|
657
|
+
}
|
|
658
|
+
throw error
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/**
|
|
663
|
+
* `task ask <id> <text>` — file one thing a person owes this ticket. The ask
|
|
664
|
+
* is the primitive every human requirement funnels through: it derives the
|
|
665
|
+
* needs-human badge, feeds `task inbox`, and gates `done` until resolved.
|
|
666
|
+
* On a claimed ticket the write lands on the claim branch as a pushed commit
|
|
667
|
+
* (see claim-io.ts) — the worker's done gate has to see debt filed mid-claim.
|
|
668
|
+
*/
|
|
669
|
+
function cmdAsk(args: Args): void {
|
|
670
|
+
const [ref, ...rest] = args.positional
|
|
671
|
+
const text = rest.join(" ").trim()
|
|
672
|
+
if (!ref || !text) fail(`usage: task ask <id> "<what a person needs to do>" [--author <who>]`)
|
|
673
|
+
const store = openStoreFor(ref)
|
|
674
|
+
const author = resolveAuthor(str(args.flags, "author")).name
|
|
675
|
+
const { ask, branch } = addAskRouted(store, store.parseId(ref), text, author)
|
|
676
|
+
if (args.flags.json) {
|
|
677
|
+
console.log(JSON.stringify({ ask, branch }, null, 2))
|
|
678
|
+
} else {
|
|
679
|
+
console.log(`asked on ${ask.taskId} [${ask.ordinal}] ${ask.text.split("\n")[0]}`)
|
|
680
|
+
if (branch) console.log(`committed and pushed to ${branch} — the claim owns this ticket's asks`)
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/**
|
|
685
|
+
* `task resolve <id> <n>` — check an ask off (`--reopen` puts it back). The
|
|
686
|
+
* ordinal is the one `task show` prints; the optional --comment lands as an
|
|
687
|
+
* attributed comment so the answer travels with the ticket, not just the
|
|
688
|
+
* checkbox. Resolving the last open ask is the moment a ticket usually moves
|
|
689
|
+
* to done — the hint below says so rather than doing it, because finishing a
|
|
690
|
+
* ticket is a statement about the *work*, not the asks.
|
|
691
|
+
*/
|
|
692
|
+
function cmdResolve(args: Args): void {
|
|
693
|
+
const [ref, askRef] = args.positional
|
|
694
|
+
if (!ref || !askRef) {
|
|
695
|
+
fail('usage: task resolve <id> <ordinal> [--comment "<note>"] [--reopen] [--author <who>]')
|
|
696
|
+
}
|
|
697
|
+
const store = openStoreFor(ref)
|
|
698
|
+
const key = store.parseId(ref)
|
|
699
|
+
const author = resolveAuthor(str(args.flags, "author")).name
|
|
700
|
+
const { ask, branch } = setAskResolvedRouted(
|
|
701
|
+
store,
|
|
702
|
+
key,
|
|
703
|
+
askRef,
|
|
704
|
+
!args.flags.reopen,
|
|
705
|
+
author,
|
|
706
|
+
)
|
|
707
|
+
const note = str(args.flags, "comment")?.trim()
|
|
708
|
+
const comment = note
|
|
709
|
+
? store.addComment(
|
|
710
|
+
key,
|
|
711
|
+
`${args.flags.reopen ? "Reopened" : "Resolved"} ask [${ask.ordinal}] (${ask.text.split("\n")[0]}): ${note}`,
|
|
712
|
+
author,
|
|
713
|
+
)
|
|
714
|
+
: null
|
|
715
|
+
const task = store.get(key)!
|
|
716
|
+
// Branch-resolved asks aren't in the local files — count what's still open
|
|
717
|
+
// on the side that was just written.
|
|
718
|
+
const remaining = branch
|
|
719
|
+
? undefined
|
|
720
|
+
: task.asks.filter((a) => !a.resolvedAt).length
|
|
721
|
+
if (args.flags.json) {
|
|
722
|
+
console.log(JSON.stringify({ ask, branch, comment, openAsks: remaining ?? null }, null, 2))
|
|
723
|
+
return
|
|
724
|
+
}
|
|
725
|
+
const verb = args.flags.reopen ? "reopened" : "resolved"
|
|
726
|
+
console.log(`${verb} ${ask.taskId} [${ask.ordinal}] ${ask.text.split("\n")[0]}`)
|
|
727
|
+
if (branch) {
|
|
728
|
+
console.log(`committed and pushed to ${branch} — the worker sees it on its next pull`)
|
|
729
|
+
return
|
|
730
|
+
}
|
|
731
|
+
if (args.flags.reopen) return
|
|
732
|
+
if (remaining !== undefined && remaining > 0) {
|
|
733
|
+
console.log(`${remaining} ask${remaining === 1 ? "" : "s"} still open on ${ask.taskId}`)
|
|
734
|
+
} else if (task.status === "in_progress") {
|
|
735
|
+
console.log(`that was the last open ask — \`task move ${ask.taskId} done\` if the work is finished too`)
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
/**
|
|
740
|
+
* `task inbox` — what do I need to do NOW to unblock work: every open ask on
|
|
741
|
+
* effectively-in-progress tickets (file status, or a live claim branch —
|
|
742
|
+
* see inbox.ts), across the repo's boards, oldest owed first. Deliberately
|
|
743
|
+
* cross-board, like `claim --next`: the queue's attention is per-board, a
|
|
744
|
+
* person's isn't.
|
|
745
|
+
*/
|
|
746
|
+
function cmdInbox(args: Args): void {
|
|
747
|
+
let entries: InboxEntry[]
|
|
748
|
+
try {
|
|
749
|
+
const scope = findScopeRoot(process.cwd())
|
|
750
|
+
const boards = selectionBoards(process.cwd(), boardFlag(args)).map((store) => {
|
|
751
|
+
const rel = relative(scope, store.root).split(sep).join("/")
|
|
752
|
+
return { id: rel === "" ? "." : rel, store }
|
|
753
|
+
})
|
|
754
|
+
entries = buildInbox(boards)
|
|
755
|
+
} catch (error) {
|
|
756
|
+
if (error instanceof ClaimError) fail(error.message)
|
|
757
|
+
throw error
|
|
758
|
+
}
|
|
759
|
+
if (args.flags.json) {
|
|
760
|
+
console.log(JSON.stringify({ inbox: entries }, null, 2))
|
|
761
|
+
return
|
|
762
|
+
}
|
|
763
|
+
if (entries.length === 0) {
|
|
764
|
+
console.log("inbox zero — no open asks on in-progress work")
|
|
765
|
+
return
|
|
766
|
+
}
|
|
767
|
+
const open = entries.reduce((sum, e) => sum + e.asks.length, 0)
|
|
768
|
+
console.log(
|
|
769
|
+
`${open} open ask${open === 1 ? "" : "s"} across ${entries.length} ticket${entries.length === 1 ? "" : "s"}`,
|
|
770
|
+
)
|
|
771
|
+
const age = (iso: string): string => {
|
|
772
|
+
const days = Math.floor((Date.now() - Date.parse(iso)) / 86_400_000)
|
|
773
|
+
return days > 0 ? `${days}d` : "today"
|
|
774
|
+
}
|
|
775
|
+
for (const entry of entries) {
|
|
776
|
+
const via = entry.claim ? ` (claimed: ${entry.claim.branch})` : ""
|
|
777
|
+
console.log(`\n${entry.task.id} ${entry.task.title}${via}`)
|
|
778
|
+
for (const ask of entry.asks) {
|
|
779
|
+
const who = ask.author ? ` · ${ask.author}` : ""
|
|
780
|
+
console.log(` [${ask.ordinal}] ${ask.text.split("\n")[0]} (${age(ask.createdAt)}${who})`)
|
|
781
|
+
}
|
|
782
|
+
}
|
|
495
783
|
}
|
|
496
784
|
|
|
497
785
|
function cmdComment(args: Args): void {
|
|
@@ -523,9 +811,9 @@ function cmdLink(args: Args, action: "link" | "unlink"): void {
|
|
|
523
811
|
const store = openStoreFor(ref)
|
|
524
812
|
const relation = blocks ? "blocks" : "blocked_by"
|
|
525
813
|
const target = parseRefOn(store, (blocks ?? blockedBy)!)
|
|
526
|
-
const
|
|
814
|
+
const key = store.parseId(ref)
|
|
527
815
|
const task =
|
|
528
|
-
action === "link" ? store.link(
|
|
816
|
+
action === "link" ? store.link(key, relation, target) : store.unlink(key, relation, target)
|
|
529
817
|
if (args.flags.json) {
|
|
530
818
|
console.log(JSON.stringify({ task }, null, 2))
|
|
531
819
|
} else {
|
|
@@ -538,35 +826,77 @@ function cmdLink(args: Args, action: "link" | "unlink"): void {
|
|
|
538
826
|
}
|
|
539
827
|
}
|
|
540
828
|
|
|
829
|
+
const CLAIM_USAGE =
|
|
830
|
+
'usage: task claim <id> | task claim --next | task claim --release <id> --comment "<why>"'
|
|
831
|
+
|
|
541
832
|
/**
|
|
542
|
-
* `task claim <id>` / `task claim --release <id>` — see
|
|
543
|
-
* mechanics. Exit codes are the contract callers script
|
|
544
|
-
* 1 already claimed
|
|
833
|
+
* `task claim <id>` / `task claim --next` / `task claim --release <id>` — see
|
|
834
|
+
* claim.ts for the mechanics. Exit codes are the contract callers script
|
|
835
|
+
* against: 0 claimed, 1 already claimed / queue empty (nothing to do here —
|
|
836
|
+
* a scheduled worker stops cleanly), 2 preconditions failed (fix something).
|
|
545
837
|
*/
|
|
546
838
|
function cmdClaim(args: Args): void {
|
|
547
839
|
const ref = args.positional[0]
|
|
548
|
-
|
|
549
|
-
const
|
|
550
|
-
|
|
840
|
+
const options = { force: Boolean(args.flags.force) }
|
|
841
|
+
const printClaim = (result: { task: Task; branch: string; base: string }): void => {
|
|
842
|
+
if (args.flags.json) {
|
|
843
|
+
console.log(JSON.stringify({ task: result.task, branch: result.branch }, null, 2))
|
|
844
|
+
} else {
|
|
845
|
+
console.log(`claimed ${result.task.id} — on ${result.branch} (from ${result.base}), status in_progress`)
|
|
846
|
+
}
|
|
847
|
+
}
|
|
551
848
|
try {
|
|
849
|
+
if (args.flags.next) {
|
|
850
|
+
// The first line of every scheduled worker: claim the top claimable
|
|
851
|
+
// ticket across the selection scope (--board > root `boards` > nearest),
|
|
852
|
+
// retrying past lost races internally.
|
|
853
|
+
if (ref) fail("pass an id or --next, not both")
|
|
854
|
+
if (args.flags.release) fail(CLAIM_USAGE)
|
|
855
|
+
const result = claimNext(selectionBoards(process.cwd(), boardFlag(args)), options)
|
|
856
|
+
if (!result) {
|
|
857
|
+
if (args.flags.json) {
|
|
858
|
+
console.log(JSON.stringify({ task: null }, null, 2))
|
|
859
|
+
} else {
|
|
860
|
+
console.log("nothing claimable — no unblocked, unclaimed todo tickets in scope")
|
|
861
|
+
}
|
|
862
|
+
process.exit(1)
|
|
863
|
+
}
|
|
864
|
+
printClaim(result)
|
|
865
|
+
return
|
|
866
|
+
}
|
|
867
|
+
if (!ref) fail(CLAIM_USAGE)
|
|
868
|
+
const store = openStoreFor(ref)
|
|
869
|
+
const key = store.parseId(ref)
|
|
552
870
|
if (args.flags.release) {
|
|
553
|
-
|
|
871
|
+
// A released claim must leave its failure context behind: the branch
|
|
872
|
+
// (and whatever was tried on it) is about to evaporate, so the reason
|
|
873
|
+
// is required, and lands as an ordinary attributed comment.
|
|
874
|
+
const why = str(args.flags, "comment")?.trim()
|
|
875
|
+
if (!why) {
|
|
876
|
+
fail(
|
|
877
|
+
'task claim --release requires --comment "<why>" — the next worker inherits what was tried',
|
|
878
|
+
)
|
|
879
|
+
}
|
|
880
|
+
const result = release(store, key)
|
|
881
|
+
const commented = result.remote || result.local
|
|
882
|
+
// After the branch deletion, so the comment survives it: release leaves
|
|
883
|
+
// the checkout on the default branch, and the comment file sits there
|
|
884
|
+
// uncommitted, to ride along with the next commit.
|
|
885
|
+
const comment = commented
|
|
886
|
+
? store.addComment(key, why, resolveAuthor(str(args.flags, "author")).name)
|
|
887
|
+
: null
|
|
554
888
|
if (args.flags.json) {
|
|
555
|
-
console.log(JSON.stringify({ released: result }, null, 2))
|
|
556
|
-
} else if (!
|
|
557
|
-
console.log(`${store.displayId(
|
|
889
|
+
console.log(JSON.stringify({ released: result, comment }, null, 2))
|
|
890
|
+
} else if (!commented) {
|
|
891
|
+
console.log(`${store.displayId(key)} wasn't claimed — no ${result.branch} to delete`)
|
|
558
892
|
} else {
|
|
559
893
|
const where = [result.remote && "origin", result.local && "local"].filter(Boolean)
|
|
560
|
-
console.log(`released ${store.displayId(
|
|
894
|
+
console.log(`released ${store.displayId(key)} — deleted ${result.branch} (${where.join(" and ")})`)
|
|
895
|
+
console.log(`left the reason as a comment on ${store.displayId(key)} — uncommitted, commit it with your next change`)
|
|
561
896
|
}
|
|
562
897
|
return
|
|
563
898
|
}
|
|
564
|
-
|
|
565
|
-
if (args.flags.json) {
|
|
566
|
-
console.log(JSON.stringify({ task: result.task, branch: result.branch }, null, 2))
|
|
567
|
-
} else {
|
|
568
|
-
console.log(`claimed ${result.task.id} — on ${result.branch} (from ${result.base}), status in_progress`)
|
|
569
|
-
}
|
|
899
|
+
printClaim(claim(store, key, options))
|
|
570
900
|
} catch (error) {
|
|
571
901
|
if (error instanceof ClaimError) {
|
|
572
902
|
console.error(`error: ${error.message}`)
|
|
@@ -576,6 +906,164 @@ function cmdClaim(args: Args): void {
|
|
|
576
906
|
}
|
|
577
907
|
}
|
|
578
908
|
|
|
909
|
+
/** `task instructions` — the shipped agent conventions, self-served at runtime. */
|
|
910
|
+
function cmdInstructions(): void {
|
|
911
|
+
// skill/ ships in the npm package; `../` from both dist/cli.js and
|
|
912
|
+
// src/cli.ts is the package root, same trick VERSION uses.
|
|
913
|
+
process.stdout.write(readFileSync(new URL("../skill/SKILL.md", import.meta.url), "utf8"))
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
/** `task overview [--json]` — one screen of board health. See overview.ts. */
|
|
917
|
+
function cmdOverview(args: Args): void {
|
|
918
|
+
const overview = buildOverview(openStore())
|
|
919
|
+
if (args.flags.json) {
|
|
920
|
+
console.log(JSON.stringify({ overview }, null, 2))
|
|
921
|
+
return
|
|
922
|
+
}
|
|
923
|
+
console.log(`${overview.board} (${overview.prefix}) — ${overview.open} open`)
|
|
924
|
+
console.log(
|
|
925
|
+
` ${STATUSES.map((s) => `${STATUS_GLYPH[s]} ${s} ${overview.statuses[s]}`).join(" ")}`,
|
|
926
|
+
)
|
|
927
|
+
const claimed =
|
|
928
|
+
overview.claimed === null
|
|
929
|
+
? "claimed ? (origin unreachable)"
|
|
930
|
+
: `claimed ${overview.claimed.length}${overview.claimed.length ? ` (${overview.claimed.join(", ")})` : ""}`
|
|
931
|
+
const asks = overview.oldestAsk
|
|
932
|
+
? `asks ${overview.openAsks} open (oldest ${overview.oldestAsk.taskId}, ${overview.oldestAsk.createdAt.slice(0, 10)})`
|
|
933
|
+
: "asks 0 open"
|
|
934
|
+
console.log(` ${claimed} ${asks}`)
|
|
935
|
+
if (overview.goals.length) {
|
|
936
|
+
console.log("goals")
|
|
937
|
+
table(
|
|
938
|
+
overview.goals.map((g) => [
|
|
939
|
+
` ${g.slug}`,
|
|
940
|
+
g.title,
|
|
941
|
+
`${g.done}/${g.total} done${g.total ? ` (${Math.round((g.done / g.total) * 100)}%)` : ""}`,
|
|
942
|
+
]),
|
|
943
|
+
)
|
|
944
|
+
}
|
|
945
|
+
if (overview.stale.length) {
|
|
946
|
+
console.log(`stale (untouched ${STALE_DAYS}+ days)`)
|
|
947
|
+
table(overview.stale.map((t) => [` ${t.id}`, t.title, t.updatedAt.slice(0, 10)]))
|
|
948
|
+
}
|
|
949
|
+
if (overview.oldestOpen) {
|
|
950
|
+
console.log(
|
|
951
|
+
`oldest open ${overview.oldestOpen.id} ${overview.oldestOpen.title} (created ${overview.oldestOpen.createdAt.slice(0, 10)})`,
|
|
952
|
+
)
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
/**
|
|
957
|
+
* `task goal …` — the goals a board's tasks belong to. A goal is a titled,
|
|
958
|
+
* described destination (`.task/goals/<slug>.md`); a task carries at most one.
|
|
959
|
+
* There is no goal status anywhere here on purpose: progress is derived from
|
|
960
|
+
* the tasks, and `archive` is the only lifecycle a goal has.
|
|
961
|
+
*/
|
|
962
|
+
function cmdGoal(args: Args): void {
|
|
963
|
+
const [sub, ...rest] = args.positional
|
|
964
|
+
const usage =
|
|
965
|
+
"usage: task goal add <title> [--slug <s>] [--description …] | list [--archived] | " +
|
|
966
|
+
"show <slug> | update <slug> [--title …] [--description …] | archive <slug> | " +
|
|
967
|
+
"unarchive <slug> | delete <slug>"
|
|
968
|
+
if (!sub) fail(usage)
|
|
969
|
+
const store = openStore()
|
|
970
|
+
|
|
971
|
+
const slugArg = (): string => {
|
|
972
|
+
const slug = rest[0]
|
|
973
|
+
if (!slug) fail(`usage: task goal ${sub} <slug>`)
|
|
974
|
+
return slug
|
|
975
|
+
}
|
|
976
|
+
const printGoal = (goal: { slug: string; title: string; archived?: boolean }, verb: string) =>
|
|
977
|
+
console.log(`${verb} ${goal.slug} ${goal.title}${goal.archived ? " (archived)" : ""}`)
|
|
978
|
+
|
|
979
|
+
switch (sub) {
|
|
980
|
+
case "add": {
|
|
981
|
+
const title = rest.join(" ").trim()
|
|
982
|
+
if (!title) fail("usage: task goal add <title> [--slug <s>] [--description …]")
|
|
983
|
+
const goal = store.createGoal({
|
|
984
|
+
title,
|
|
985
|
+
slug: str(args.flags, "slug"),
|
|
986
|
+
description: str(args.flags, "description") ?? str(args.flags, "desc"),
|
|
987
|
+
})
|
|
988
|
+
if (args.flags.json) console.log(JSON.stringify({ goal }, null, 2))
|
|
989
|
+
else printGoal(goal, "created")
|
|
990
|
+
return
|
|
991
|
+
}
|
|
992
|
+
case "list": {
|
|
993
|
+
const archived = Boolean(args.flags.archived)
|
|
994
|
+
const goals = store.goals(archived)
|
|
995
|
+
// Progress is derived here, at read time — a goal file never stores it.
|
|
996
|
+
const tasks = store.list({ statuses: undefined })
|
|
997
|
+
const rows = goals.map((goal) => {
|
|
998
|
+
const mine = tasks.filter((t) => t.goal === goal.slug)
|
|
999
|
+
const done = mine.filter((t) => t.status === "done" || t.status === "canceled").length
|
|
1000
|
+
return { ...goal, open: mine.length - done, done, total: mine.length }
|
|
1001
|
+
})
|
|
1002
|
+
if (args.flags.json) {
|
|
1003
|
+
console.log(JSON.stringify({ goals: rows }, null, 2))
|
|
1004
|
+
} else if (rows.length === 0) {
|
|
1005
|
+
console.log(archived ? "no archived goals" : "no goals — create one with `task goal add`")
|
|
1006
|
+
} else {
|
|
1007
|
+
table(rows.map((g) => [g.slug, g.title, `${g.done}/${g.total} done`]))
|
|
1008
|
+
}
|
|
1009
|
+
return
|
|
1010
|
+
}
|
|
1011
|
+
case "show": {
|
|
1012
|
+
const slug = slugArg()
|
|
1013
|
+
const goal = store.getGoal(slug)
|
|
1014
|
+
if (!goal) fail(`no such goal: "${slug}"`)
|
|
1015
|
+
const tasks = store.list({ statuses: undefined, goal: slug })
|
|
1016
|
+
if (args.flags.json) {
|
|
1017
|
+
console.log(JSON.stringify({ goal, tasks }, null, 2))
|
|
1018
|
+
return
|
|
1019
|
+
}
|
|
1020
|
+
console.log(`${goal.slug} ${goal.title}`)
|
|
1021
|
+
if (goal.archived) console.log(`archived yes — \`task goal unarchive ${goal.slug}\` to edit`)
|
|
1022
|
+
console.log(`created ${goal.createdAt}`)
|
|
1023
|
+
console.log(`updated ${goal.updatedAt}`)
|
|
1024
|
+
if (goal.description) console.log(`\n${goal.description}`)
|
|
1025
|
+
if (tasks.length) {
|
|
1026
|
+
console.log("")
|
|
1027
|
+
table(tasks.map(taskRow))
|
|
1028
|
+
}
|
|
1029
|
+
return
|
|
1030
|
+
}
|
|
1031
|
+
case "update": {
|
|
1032
|
+
const patch = {
|
|
1033
|
+
...(str(args.flags, "title") !== undefined && { title: str(args.flags, "title") }),
|
|
1034
|
+
...((str(args.flags, "description") ?? str(args.flags, "desc")) !== undefined && {
|
|
1035
|
+
description: str(args.flags, "description") ?? str(args.flags, "desc"),
|
|
1036
|
+
}),
|
|
1037
|
+
}
|
|
1038
|
+
if (Object.keys(patch).length === 0) fail("nothing to update — pass --title or --description")
|
|
1039
|
+
const goal = store.updateGoal(slugArg(), patch)
|
|
1040
|
+
if (args.flags.json) console.log(JSON.stringify({ goal }, null, 2))
|
|
1041
|
+
else printGoal(goal, "updated")
|
|
1042
|
+
return
|
|
1043
|
+
}
|
|
1044
|
+
case "archive": {
|
|
1045
|
+
const goal = store.archiveGoal(slugArg())
|
|
1046
|
+
if (args.flags.json) console.log(JSON.stringify({ goal }, null, 2))
|
|
1047
|
+
else printGoal(goal, "archived")
|
|
1048
|
+
return
|
|
1049
|
+
}
|
|
1050
|
+
case "unarchive": {
|
|
1051
|
+
const goal = store.unarchiveGoal(slugArg())
|
|
1052
|
+
if (args.flags.json) console.log(JSON.stringify({ goal }, null, 2))
|
|
1053
|
+
else printGoal(goal, "unarchived")
|
|
1054
|
+
return
|
|
1055
|
+
}
|
|
1056
|
+
case "delete": {
|
|
1057
|
+
const slug = slugArg()
|
|
1058
|
+
store.deleteGoal(slug)
|
|
1059
|
+
console.log(`deleted goal ${slug}`)
|
|
1060
|
+
return
|
|
1061
|
+
}
|
|
1062
|
+
default:
|
|
1063
|
+
fail(usage)
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
|
|
579
1067
|
const AUTHOR_SOURCE: Record<string, string> = {
|
|
580
1068
|
flag: "--author",
|
|
581
1069
|
env: "$TASK_AUTHOR",
|
|
@@ -594,36 +1082,51 @@ function cmdWhoami(args: Args): void {
|
|
|
594
1082
|
}
|
|
595
1083
|
|
|
596
1084
|
/**
|
|
597
|
-
* `task
|
|
598
|
-
*
|
|
599
|
-
*
|
|
600
|
-
*
|
|
1085
|
+
* `task check [--fix]` — lint the board files. Git merges and hand-edits are
|
|
1086
|
+
* write paths no CLI code sees, so validation is a command you can run (and
|
|
1087
|
+
* put in pre-commit or CI, where merge-introduced drift gets caught). Covers
|
|
1088
|
+
* every board at or below here, the same set `task serve` would serve. Exits
|
|
1089
|
+
* non-zero while problems remain; `--fix` first applies the mechanical
|
|
1090
|
+
* repairs — pruning dangling references, rewriting files into canonical form.
|
|
601
1091
|
*/
|
|
602
|
-
function
|
|
603
|
-
const root =
|
|
604
|
-
|
|
605
|
-
const result =
|
|
1092
|
+
function cmdCheck(args: Args): void {
|
|
1093
|
+
const root = checkRoot(process.cwd())
|
|
1094
|
+
const fix = Boolean(args.flags.fix)
|
|
1095
|
+
const result = checkBoards(root, fix)
|
|
1096
|
+
if (result.boards === 0) {
|
|
1097
|
+
fail("no .task directory found in this directory, any parent, or below — run `task init` first")
|
|
1098
|
+
}
|
|
1099
|
+
// In fix mode the fixable issues were just repaired; what's left needs a human.
|
|
1100
|
+
const remaining = fix ? result.issues.filter((issue) => !issue.fixable) : result.issues
|
|
1101
|
+
|
|
606
1102
|
if (args.flags.json) {
|
|
607
|
-
console.log(JSON.stringify({
|
|
608
|
-
|
|
1103
|
+
console.log(JSON.stringify({ ...result, remaining }, null, 2))
|
|
1104
|
+
} else {
|
|
1105
|
+
for (const file of result.fixed) console.log(`fixed ${file}`)
|
|
1106
|
+
for (const issue of remaining) {
|
|
1107
|
+
console.log(`${issue.fixable ? "fixable" : "problem"} ${issue.file}`)
|
|
1108
|
+
console.log(` ${issue.message}`)
|
|
1109
|
+
}
|
|
1110
|
+
const scope = `${result.files} file${result.files === 1 ? "" : "s"} across ${result.boards} board${result.boards === 1 ? "" : "s"}`
|
|
1111
|
+
if (remaining.length === 0) {
|
|
1112
|
+
console.log(result.fixed.length ? `fixed ${result.fixed.length}, ${scope} clean` : `ok — ${scope}`)
|
|
1113
|
+
} else {
|
|
1114
|
+
const fixable = remaining.filter((issue) => issue.fixable).length
|
|
1115
|
+
console.log(
|
|
1116
|
+
`${remaining.length} problem${remaining.length === 1 ? "" : "s"} in ${scope}${fixable ? ` (${fixable} fixable — run \`task check --fix\`)` : ""}`,
|
|
1117
|
+
)
|
|
1118
|
+
}
|
|
609
1119
|
}
|
|
610
|
-
|
|
611
|
-
`Migrated ${result.tasks} task${result.tasks === 1 ? "" : "s"} and ${result.comments} comment${result.comments === 1 ? "" : "s"} to .task/tickets/`,
|
|
612
|
-
)
|
|
613
|
-
console.log(`tasks.db stays on disk as a backup, but it's ignored now — the files are the state.`)
|
|
614
|
-
console.log(``)
|
|
615
|
-
console.log(`Next:`)
|
|
616
|
-
console.log(` git rm --cached ${join(TASK_DIR, "tasks.db")} stop tracking the database`)
|
|
617
|
-
console.log(` git add ${TASK_DIR} commit the tickets`)
|
|
1120
|
+
if (remaining.length > 0) process.exitCode = 1
|
|
618
1121
|
}
|
|
619
1122
|
|
|
620
1123
|
function cmdDelete(args: Args): void {
|
|
621
1124
|
const ref = args.positional[0]
|
|
622
1125
|
if (!ref) fail("usage: task delete <id>")
|
|
623
1126
|
const store = openStoreFor(ref)
|
|
624
|
-
const
|
|
625
|
-
store.delete(
|
|
626
|
-
console.log(`deleted ${store.displayId(
|
|
1127
|
+
const key = store.parseId(ref)
|
|
1128
|
+
store.delete(key)
|
|
1129
|
+
console.log(`deleted ${store.displayId(key)}`)
|
|
627
1130
|
}
|
|
628
1131
|
|
|
629
1132
|
/**
|
|
@@ -818,51 +1321,142 @@ subdirectory (it walks up to find .task/, like git).
|
|
|
818
1321
|
|
|
819
1322
|
Usage
|
|
820
1323
|
task init [--name <name>] [--prefix <PREFIX>]
|
|
821
|
-
task add <title> [--description <text>
|
|
822
|
-
[--
|
|
823
|
-
|
|
1324
|
+
task add <title> [--description <text> | --description-file <path|->]
|
|
1325
|
+
[--status <s>] [--tags <a,b>] [--goal <slug>]
|
|
1326
|
+
[--ask "<text>"]…
|
|
1327
|
+
new tickets land in backlog (the dump list)
|
|
1328
|
+
unless --status says otherwise;
|
|
1329
|
+
--description-file reads markdown from a file
|
|
1330
|
+
(or stdin with -) — no shell-quoting fights;
|
|
1331
|
+
--ask (repeatable) files what a person owes
|
|
1332
|
+
the ticket, right at creation
|
|
1333
|
+
task list [--status <s1,s2>] [--tag <a,b>] [--goal <slug>]
|
|
824
1334
|
[--needs-human] [--all] [--archived] [--claimable]
|
|
825
|
-
task
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
1335
|
+
task search <query> [--status <s1,s2>] [--tag <a,b>] [--goal <slug>]
|
|
1336
|
+
[--needs-human] [--archived]
|
|
1337
|
+
find tickets by what they say — case-insensitive
|
|
1338
|
+
match over titles, descriptions and comments,
|
|
1339
|
+
with a snippet of the matching line. --archived
|
|
1340
|
+
searches the archive too, on top of the board
|
|
1341
|
+
task show <id> the ticket, its comments — and its goal's title
|
|
1342
|
+
and description inline, so a cold pickup gets
|
|
1343
|
+
the shared context without chasing it
|
|
1344
|
+
task update <id> [--title <t>] [--description <text> | --description-file <path|->]
|
|
1345
|
+
[--status <s>] [--tags <a,b>] [--goal <slug>]
|
|
829
1346
|
[--pr <url>] [--prs <url1,url2>]
|
|
830
|
-
task move <id> <status>
|
|
831
|
-
|
|
832
|
-
|
|
1347
|
+
task move <id> <status> change a ticket's status — the one general
|
|
1348
|
+
status API (backlog todo in_progress done
|
|
1349
|
+
canceled). Moving to done is refused while
|
|
1350
|
+
the ticket has open asks: done means nobody
|
|
1351
|
+
owes anything. The named verbs below exist
|
|
1352
|
+
only where a transition does more than write
|
|
1353
|
+
the status field
|
|
1354
|
+
task ask <id> "<text>" file an ask: one thing a person owes this
|
|
1355
|
+
ticket before it can be done. Asks derive the
|
|
1356
|
+
needs-human badge, feed \`task inbox\`, and
|
|
1357
|
+
gate \`done\` — they never block claiming (an
|
|
1358
|
+
agent works the code while a person mints the
|
|
1359
|
+
token; a true precondition is a blocked_by on
|
|
1360
|
+
a thin human ticket instead)
|
|
1361
|
+
task resolve <id> <n> [--comment "<note>"] [--reopen]
|
|
1362
|
+
check ask <n> off (\`task show\` prints the
|
|
1363
|
+
ordinals); the note lands as an attributed
|
|
1364
|
+
comment. --reopen puts a resolved ask back
|
|
1365
|
+
task inbox [--board <P>] what needs YOU, now: every open ask on
|
|
1366
|
+
effectively in-progress tickets — file status
|
|
1367
|
+
in_progress, or a live claim branch on origin
|
|
1368
|
+
(a claimed ticket is in progress whatever the
|
|
1369
|
+
merged file says, and its asks are read from
|
|
1370
|
+
the branch). Cross-board like claim --next;
|
|
1371
|
+
oldest owed first. Backlog/todo asks are out
|
|
1372
|
+
by design — claiming is what makes them yours
|
|
1373
|
+
task promote <id> backlog → todo, the human "build this" call.
|
|
1374
|
+
No quality gate — planning happens at claim
|
|
1375
|
+
time. Blocked tickets still promote —
|
|
1376
|
+
blockers are claim-time gates, reported as a
|
|
1377
|
+
note — so promoting can queue work behind
|
|
1378
|
+
its blockers. Exit codes: 0 promoted, 2
|
|
1379
|
+
refused (not a backlog ticket)
|
|
833
1380
|
task link <id> --blocked-by <id>
|
|
834
1381
|
task link <id> --blocks <id> mark a dependency — one relation, visible from
|
|
835
1382
|
both tasks (A blocked by B ⇔ B blocks A)
|
|
836
1383
|
task unlink <id> (--blocks <id> | --blocked-by <id>)
|
|
837
|
-
task claim <id>
|
|
838
|
-
<
|
|
839
|
-
branch, ticket → in_progress as its
|
|
840
|
-
commit, pushed. The namespace defaults
|
|
841
|
-
task/claim/ — set
|
|
1384
|
+
task claim <id> [--force] claim a ticket before working it: branch
|
|
1385
|
+
<branchPrefix><prefix>-<key> off origin's
|
|
1386
|
+
default branch, ticket → in_progress as its
|
|
1387
|
+
first commit, pushed. The namespace defaults
|
|
1388
|
+
to task/claim/ — set claims.branchPrefix in
|
|
842
1389
|
.task/config.json to change it (e.g.
|
|
843
1390
|
"claude/task/", which Claude cloud sessions
|
|
844
1391
|
can push). Branch on origin = claimed —
|
|
845
1392
|
git's atomic ref creation is the lock, so two
|
|
846
|
-
concurrent claimers can't both win.
|
|
1393
|
+
concurrent claimers can't both win. While the
|
|
1394
|
+
root board sets claims.maxOpenCount and that
|
|
1395
|
+
many claim branches exist on origin, claiming
|
|
1396
|
+
is refused (--force overrides). Exit codes:
|
|
847
1397
|
0 claimed, 1 already claimed, 2 not claimable
|
|
848
|
-
(not todo, blocked,
|
|
849
|
-
task claim --
|
|
1398
|
+
(not todo, blocked, dirty tree, at the cap)
|
|
1399
|
+
task claim --next [--board <P>] [--force]
|
|
1400
|
+
claim the top claimable ticket in one call,
|
|
1401
|
+
retrying past lost races internally — the
|
|
1402
|
+
first line of every scheduled worker. Scope:
|
|
1403
|
+
--board prefixes (repeatable, given order =
|
|
1404
|
+
priority) > the root board's "boards" list >
|
|
1405
|
+
the nearest board. Exit codes: 0 claimed
|
|
1406
|
+
(prints the ticket), 1 queue empty, 2
|
|
1407
|
+
preconditions failed
|
|
1408
|
+
task claim --release <id> --comment "<why>"
|
|
1409
|
+
abandon a claim: delete the branch on origin
|
|
850
1410
|
and locally — the status flip only lived on
|
|
851
|
-
the branch, so deleting it is the revert
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
1411
|
+
the branch, so deleting it is the revert. The
|
|
1412
|
+
comment is required and lands on the ticket
|
|
1413
|
+
(attributed like task comment, uncommitted),
|
|
1414
|
+
so the next worker inherits what was tried
|
|
1415
|
+
task list --claimable [--board <P>]
|
|
1416
|
+
the claim queue: todo tickets in position
|
|
1417
|
+
order, minus blocked / already claimed on
|
|
1418
|
+
origin. Top entry is next up. Same board
|
|
1419
|
+
scope as claim --next
|
|
1420
|
+
task instructions print the shipped agent conventions
|
|
1421
|
+
(skill/SKILL.md) — agents without the skill
|
|
1422
|
+
installed self-serve the house rules
|
|
1423
|
+
task overview [--json] one screen of board health: counts per
|
|
1424
|
+
status, per-goal progress, claimed and
|
|
1425
|
+
open-ask counts, stale tickets (untouched
|
|
1426
|
+
${STALE_DAYS}+ days), oldest open ticket
|
|
855
1427
|
task comment <id> <text> [--author <who>]
|
|
856
1428
|
task delete <id>
|
|
1429
|
+
task goal add <title> [--slug <s>] [--description <text>]
|
|
1430
|
+
create a goal: a titled, described destination
|
|
1431
|
+
tasks belong to (.task/goals/<slug>.md). The
|
|
1432
|
+
description is the strategic why — the context
|
|
1433
|
+
every ticket under it shares
|
|
1434
|
+
task goal list [--archived] every goal with derived progress (done/total —
|
|
1435
|
+
a goal stores no status of its own, ever)
|
|
1436
|
+
task goal show <slug> the goal, its description, and its tasks
|
|
1437
|
+
task goal update <slug> [--title <t>] [--description <text>]
|
|
1438
|
+
task goal archive <slug> shelve a finished/abandoned goal — refused
|
|
1439
|
+
while it still has open tasks
|
|
1440
|
+
task goal unarchive <slug>
|
|
1441
|
+
task goal delete <slug> only when no live task references it
|
|
857
1442
|
task boards every board in this repo — prefix, name, path,
|
|
858
1443
|
open count; * marks the one commands target here
|
|
859
1444
|
task archive <id> move a done/canceled ticket to .task/archive/,
|
|
860
1445
|
out of the board and off the hot path — still
|
|
861
1446
|
readable via show and list --archived, and its
|
|
862
|
-
|
|
1447
|
+
key stays reserved
|
|
863
1448
|
task archive --all archive everything done or canceled
|
|
864
1449
|
task unarchive <id> put an archived ticket back on the board
|
|
865
1450
|
task whoami who your comments are attributed to
|
|
1451
|
+
task check [--fix] lint the board files — merges and hand-edits
|
|
1452
|
+
are write paths no CLI code sees. Reports
|
|
1453
|
+
unparseable files, dangling blocked_by refs,
|
|
1454
|
+
bad timestamps, unknown frontmatter keys and
|
|
1455
|
+
formatting drift, for every board at or below
|
|
1456
|
+
here; exits non-zero while problems remain.
|
|
1457
|
+
--fix applies the mechanical repairs first
|
|
1458
|
+
(prune dangling refs, rewrite to canonical
|
|
1459
|
+
form). Cheap enough for pre-commit and CI
|
|
866
1460
|
task serve [--port <n>] [--no-open] [--strict-port]
|
|
867
1461
|
board + table UI with live updates, opened in
|
|
868
1462
|
your browser (default port ${DEFAULT_PORT}; taken ports
|
|
@@ -880,19 +1474,22 @@ Usage
|
|
|
880
1474
|
task unpublish [--repo <owner/name>]
|
|
881
1475
|
take that URL down. Removes the board, not the
|
|
882
1476
|
tasks — those are in .task/ either way
|
|
883
|
-
task migrate move a pre-0.6 board off its committed SQLite
|
|
884
|
-
database and onto text files in .task/tickets/.
|
|
885
|
-
The database stays on disk as an ignored backup
|
|
886
1477
|
|
|
887
1478
|
Values
|
|
888
|
-
<id> TAS-
|
|
889
|
-
|
|
890
|
-
|
|
1479
|
+
<id> TAS-x7k4m, x7k4m, or any unique prefix of a key (task show x7
|
|
1480
|
+
works, git-style — ambiguity is an error naming the matches).
|
|
1481
|
+
Ids are random 5-character keys, minted branch-safely at
|
|
1482
|
+
\`task add\`. A bare key means the nearest board; a prefixed id
|
|
1483
|
+
routes to whichever board in the repo owns that prefix, so
|
|
1484
|
+
TAS-x7k4m works from anywhere in a monorepo
|
|
891
1485
|
status ${STATUSES.join(" ")}
|
|
892
1486
|
--tag a,b matches a task carrying *either* tag
|
|
893
|
-
--
|
|
1487
|
+
--goal <slug> a task belongs to at most one goal. Goals answer "where is
|
|
1488
|
+
this going" (one); tags answer "what kind" (many) — something
|
|
1489
|
+
that wants two goals is a tag
|
|
1490
|
+
--needs-human filter: tickets with open asks — something a person owes
|
|
894
1491
|
--pr <url> attach a pull request (appends); --prs replaces the whole list
|
|
895
|
-
clearing --tags "" drops all tags, --
|
|
1492
|
+
clearing --tags "" drops all tags, --goal "" clears it, --prs ""
|
|
896
1493
|
detaches all PRs
|
|
897
1494
|
|
|
898
1495
|
Comment authors resolve --author → $TASK_AUTHOR → git config user.name →
|
|
@@ -923,22 +1520,34 @@ function main(): void | Promise<void> {
|
|
|
923
1520
|
return cmdAdd(args)
|
|
924
1521
|
case "list":
|
|
925
1522
|
return cmdList(args)
|
|
1523
|
+
case "search":
|
|
1524
|
+
return cmdSearch(args)
|
|
926
1525
|
case "show":
|
|
927
1526
|
return cmdShow(args)
|
|
928
1527
|
case "update":
|
|
929
1528
|
return cmdUpdate(args)
|
|
930
1529
|
case "move":
|
|
931
1530
|
return cmdMove(args)
|
|
932
|
-
case "
|
|
933
|
-
return
|
|
934
|
-
case "done":
|
|
935
|
-
return cmdUpdate(args, "done")
|
|
1531
|
+
case "promote":
|
|
1532
|
+
return cmdPromote(args)
|
|
936
1533
|
case "link":
|
|
937
1534
|
return cmdLink(args, "link")
|
|
938
1535
|
case "unlink":
|
|
939
1536
|
return cmdLink(args, "unlink")
|
|
940
1537
|
case "claim":
|
|
941
1538
|
return cmdClaim(args)
|
|
1539
|
+
case "instructions":
|
|
1540
|
+
return cmdInstructions()
|
|
1541
|
+
case "ask":
|
|
1542
|
+
return cmdAsk(args)
|
|
1543
|
+
case "resolve":
|
|
1544
|
+
return cmdResolve(args)
|
|
1545
|
+
case "inbox":
|
|
1546
|
+
return cmdInbox(args)
|
|
1547
|
+
case "overview":
|
|
1548
|
+
return cmdOverview(args)
|
|
1549
|
+
case "goal":
|
|
1550
|
+
return cmdGoal(args)
|
|
942
1551
|
case "comment":
|
|
943
1552
|
return cmdComment(args)
|
|
944
1553
|
case "whoami":
|
|
@@ -951,8 +1560,8 @@ function main(): void | Promise<void> {
|
|
|
951
1560
|
return cmdArchive(args)
|
|
952
1561
|
case "unarchive":
|
|
953
1562
|
return cmdUnarchive(args)
|
|
954
|
-
case "
|
|
955
|
-
return
|
|
1563
|
+
case "check":
|
|
1564
|
+
return cmdCheck(args)
|
|
956
1565
|
case "serve":
|
|
957
1566
|
return cmdServe(args)
|
|
958
1567
|
case "publish":
|