@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/ticket-doc.ts
CHANGED
|
@@ -23,22 +23,60 @@ export interface TicketDoc {
|
|
|
23
23
|
description: string
|
|
24
24
|
status: Status
|
|
25
25
|
tags: string[]
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
blockedBy:
|
|
26
|
+
/** Slug of the goal this ticket belongs to — at most one, by design. */
|
|
27
|
+
goal: string | null
|
|
28
|
+
/** Keys of tasks blocking this one — the single stored side of the relation. */
|
|
29
|
+
blockedBy: string[]
|
|
30
30
|
prs: string[]
|
|
31
31
|
position: number
|
|
32
32
|
createdAt: string
|
|
33
33
|
updatedAt: string
|
|
34
|
+
/**
|
|
35
|
+
* Frontmatter keys this version doesn't know, as raw `[key, value]` pairs in
|
|
36
|
+
* file order. Carried so a rewrite can't silently drop them — a hand-added
|
|
37
|
+
* field (or one written by a newer CLI) survives every update, re-emitted
|
|
38
|
+
* after the known fields. Present only when the file actually has any.
|
|
39
|
+
*/
|
|
40
|
+
extras?: [string, string][]
|
|
34
41
|
}
|
|
35
42
|
|
|
43
|
+
/** The frontmatter keys a ticket file may carry; anything else is an extra. */
|
|
44
|
+
export const TICKET_FIELDS = [
|
|
45
|
+
"status",
|
|
46
|
+
"tags",
|
|
47
|
+
"goal",
|
|
48
|
+
"blocked_by",
|
|
49
|
+
"prs",
|
|
50
|
+
"position",
|
|
51
|
+
"created",
|
|
52
|
+
"updated",
|
|
53
|
+
] as const
|
|
54
|
+
|
|
55
|
+
/** The frontmatter keys a comment file may carry. */
|
|
56
|
+
export const COMMENT_FIELDS = ["author", "created"] as const
|
|
57
|
+
|
|
36
58
|
export interface CommentDoc {
|
|
37
59
|
author: string
|
|
38
60
|
createdAt: string
|
|
39
61
|
body: string
|
|
40
62
|
}
|
|
41
63
|
|
|
64
|
+
/** The frontmatter keys an ask file may carry. */
|
|
65
|
+
export const ASK_FIELDS = ["author", "created", "resolved", "resolved_by"] as const
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* One ask on disk — `tickets/<n>/asks/<stem>.md`, the comment layout plus a
|
|
69
|
+
* resolution: `resolved`/`resolved_by` appear when the ask is closed and are
|
|
70
|
+
* simply absent while it's open, so an open ask's bytes never churn.
|
|
71
|
+
*/
|
|
72
|
+
export interface AskDoc {
|
|
73
|
+
author: string
|
|
74
|
+
createdAt: string
|
|
75
|
+
resolvedAt: string | null
|
|
76
|
+
resolvedBy: string | null
|
|
77
|
+
body: string
|
|
78
|
+
}
|
|
79
|
+
|
|
42
80
|
/** A parse that names the file it failed in — these files invite hand-editing. */
|
|
43
81
|
export class TicketParseError extends Error {}
|
|
44
82
|
|
|
@@ -64,13 +102,21 @@ function parseScalar(raw: string): unknown {
|
|
|
64
102
|
}
|
|
65
103
|
}
|
|
66
104
|
|
|
67
|
-
|
|
105
|
+
/**
|
|
106
|
+
* Split a file into frontmatter fields and body. Two callers, two stances:
|
|
107
|
+
* with a `where` the CLI is parsing and every malformation throws, naming the
|
|
108
|
+
* file; with `where: null` the hosted board is rendering and a file with no
|
|
109
|
+
* frontmatter returns null while a merely-odd line is skipped.
|
|
110
|
+
*/
|
|
111
|
+
function splitRaw(text: string, where: string | null): RawDoc | null {
|
|
68
112
|
const lines = text.replace(/\r\n/g, "\n").split("\n")
|
|
69
113
|
if (lines[0] !== "---") {
|
|
114
|
+
if (where === null) return null
|
|
70
115
|
throw new TicketParseError(`${where}: expected the file to start with a --- frontmatter block`)
|
|
71
116
|
}
|
|
72
117
|
const end = lines.indexOf("---", 1)
|
|
73
118
|
if (end < 0) {
|
|
119
|
+
if (where === null) return null
|
|
74
120
|
throw new TicketParseError(`${where}: unterminated frontmatter — no closing ---`)
|
|
75
121
|
}
|
|
76
122
|
const fields = new Map<string, string>()
|
|
@@ -78,6 +124,7 @@ function splitRaw(text: string, where: string): RawDoc {
|
|
|
78
124
|
if (!line.trim()) continue
|
|
79
125
|
const colon = line.indexOf(":")
|
|
80
126
|
if (colon < 0) {
|
|
127
|
+
if (where === null) continue
|
|
81
128
|
throw new TicketParseError(`${where}: not a "key: value" frontmatter line: ${line}`)
|
|
82
129
|
}
|
|
83
130
|
fields.set(line.slice(0, colon).trim(), line.slice(colon + 1))
|
|
@@ -105,13 +152,17 @@ function stringList(raw: RawDoc, key: string, where: string): string[] {
|
|
|
105
152
|
throw new TicketParseError(`${where}: ${key} should be a list of strings, e.g. ["a", "b"]`)
|
|
106
153
|
}
|
|
107
154
|
|
|
108
|
-
function
|
|
155
|
+
function keyList(raw: RawDoc, key: string, where: string): string[] {
|
|
109
156
|
const value = field(raw, key)
|
|
110
157
|
if (value === undefined) return []
|
|
111
|
-
if (Array.isArray(value) && value.every((v) => typeof v === "
|
|
112
|
-
return value as
|
|
158
|
+
if (Array.isArray(value) && value.every((v) => typeof v === "string")) {
|
|
159
|
+
return value as string[]
|
|
113
160
|
}
|
|
114
|
-
|
|
161
|
+
// A list of bare numbers is a pre-migration file (ids were sequential
|
|
162
|
+
// integers before TAS-42) — name the one edit that fixes it.
|
|
163
|
+
throw new TicketParseError(
|
|
164
|
+
`${where}: ${key} should be a list of ticket keys, e.g. ["x7k4m", "q3v8d"] — numeric entries are the pre-TAS-42 id format, which is no longer read`,
|
|
165
|
+
)
|
|
115
166
|
}
|
|
116
167
|
|
|
117
168
|
// ── Tickets ──────────────────────────────────────────────────────────────────
|
|
@@ -131,18 +182,25 @@ function splitTitle(body: string): { title: string | null; description: string }
|
|
|
131
182
|
}
|
|
132
183
|
|
|
133
184
|
export function parseTicket(text: string, where: string): TicketDoc {
|
|
134
|
-
const raw = splitRaw(text, where)
|
|
185
|
+
const raw = splitRaw(text, where)!
|
|
135
186
|
const status = stringField(raw, "status", where, "todo")
|
|
136
187
|
if (!isStatus(status)) {
|
|
137
188
|
throw new TicketParseError(
|
|
138
189
|
`${where}: invalid status "${status}" — one of: ${STATUSES.join(", ")}`,
|
|
139
190
|
)
|
|
140
191
|
}
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
192
|
+
// The pre-0.8 `milestone:` spelling is not honored (TAS-41: no compat), and
|
|
193
|
+
// reading past it would silently drop the ticket's goal — refuse by name
|
|
194
|
+
// instead, so the one edit that fixes the file is in the error.
|
|
195
|
+
if (raw.fields.has("milestone")) {
|
|
196
|
+
throw new TicketParseError(
|
|
197
|
+
`${where}: \`milestone:\` is the pre-0.8 spelling and is no longer read — rename the key to \`goal:\``,
|
|
198
|
+
)
|
|
199
|
+
}
|
|
200
|
+
const goal = field(raw, "goal")
|
|
201
|
+
if (goal !== undefined && goal !== null && typeof goal !== "string") {
|
|
202
|
+
throw new TicketParseError(`${where}: goal should be a string`)
|
|
144
203
|
}
|
|
145
|
-
const needsHuman = field(raw, "needs_human")
|
|
146
204
|
const position = field(raw, "position")
|
|
147
205
|
if (position !== undefined && typeof position !== "number") {
|
|
148
206
|
throw new TicketParseError(`${where}: position should be a number`)
|
|
@@ -151,18 +209,23 @@ export function parseTicket(text: string, where: string): TicketDoc {
|
|
|
151
209
|
if (title === null) {
|
|
152
210
|
throw new TicketParseError(`${where}: expected the body to start with "# <title>"`)
|
|
153
211
|
}
|
|
212
|
+
const known = new Set<string>(TICKET_FIELDS)
|
|
213
|
+
const extras: [string, string][] = []
|
|
214
|
+
for (const [key, value] of raw.fields) {
|
|
215
|
+
if (!known.has(key)) extras.push([key, value.trim()])
|
|
216
|
+
}
|
|
154
217
|
return {
|
|
155
218
|
title,
|
|
156
219
|
description,
|
|
157
220
|
status,
|
|
158
221
|
tags: stringList(raw, "tags", where),
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
blockedBy: numberList(raw, "blocked_by", where),
|
|
222
|
+
goal: typeof goal === "string" && goal !== "" ? goal : null,
|
|
223
|
+
blockedBy: keyList(raw, "blocked_by", where),
|
|
162
224
|
prs: stringList(raw, "prs", where),
|
|
163
225
|
position: typeof position === "number" ? position : 0,
|
|
164
226
|
createdAt: stringField(raw, "created", where, ""),
|
|
165
227
|
updatedAt: stringField(raw, "updated", where, ""),
|
|
228
|
+
...(extras.length ? { extras } : {}),
|
|
166
229
|
}
|
|
167
230
|
}
|
|
168
231
|
|
|
@@ -174,27 +237,111 @@ function list(values: (string | number)[]): string {
|
|
|
174
237
|
/**
|
|
175
238
|
* The canonical bytes for a ticket. Field order is fixed and empty fields are
|
|
176
239
|
* omitted, so an unchanged ticket always serializes identically — diffs show
|
|
177
|
-
* edits, never churn.
|
|
240
|
+
* edits, never churn. Unknown fields the parse carried through are re-emitted
|
|
241
|
+
* after the known ones, in their original order.
|
|
178
242
|
*/
|
|
179
243
|
export function serializeTicket(doc: TicketDoc): string {
|
|
180
244
|
const lines = ["---", `status: ${doc.status}`]
|
|
181
245
|
if (doc.tags.length) lines.push(`tags: ${list(doc.tags)}`)
|
|
182
|
-
if (doc.
|
|
183
|
-
if (doc.needsHuman) lines.push(`needs_human: true`)
|
|
246
|
+
if (doc.goal) lines.push(`goal: ${JSON.stringify(doc.goal)}`)
|
|
184
247
|
if (doc.blockedBy.length) lines.push(`blocked_by: ${list(doc.blockedBy)}`)
|
|
185
248
|
if (doc.prs.length) lines.push(`prs: ${list(doc.prs)}`)
|
|
186
249
|
lines.push(`position: ${doc.position}`)
|
|
187
250
|
lines.push(`created: ${doc.createdAt}`)
|
|
188
251
|
lines.push(`updated: ${doc.updatedAt}`)
|
|
252
|
+
for (const [key, value] of doc.extras ?? []) lines.push(`${key}: ${value}`)
|
|
253
|
+
lines.push("---", "", `# ${doc.title}`)
|
|
254
|
+
if (doc.description) lines.push("", doc.description)
|
|
255
|
+
return `${lines.join("\n")}\n`
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// ── Goals ────────────────────────────────────────────────────────────────────
|
|
259
|
+
//
|
|
260
|
+
// A goal is one file — `.task/goals/<slug>.md` — in the ticket format minus
|
|
261
|
+
// the workflow fields: no status (progress is derived from its tasks), no
|
|
262
|
+
// position (goals aren't a column). The slug is the filename stem and the
|
|
263
|
+
// identity tickets reference via `goal:`; the title inside can change freely.
|
|
264
|
+
|
|
265
|
+
/** Everything `goals/<slug>.md` stores. The slug is the filename, not a field. */
|
|
266
|
+
export interface GoalDoc {
|
|
267
|
+
title: string
|
|
268
|
+
description: string
|
|
269
|
+
createdAt: string
|
|
270
|
+
updatedAt: string
|
|
271
|
+
/** Unknown frontmatter, carried through rewrites — same deal as tickets. */
|
|
272
|
+
extras?: [string, string][]
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** The frontmatter keys a goal file may carry. */
|
|
276
|
+
export const GOAL_FIELDS = ["created", "updated"] as const
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* What a goal slug may look like — same alphabet as the claim-branch-safe
|
|
280
|
+
* ticket ids, because the slug ends up in frontmatter, URLs and prompts.
|
|
281
|
+
* "archive" is reserved: `goals/archive/` is where archived goals live.
|
|
282
|
+
*/
|
|
283
|
+
export const GOAL_SLUG_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/
|
|
284
|
+
|
|
285
|
+
export function isGoalSlug(value: string): boolean {
|
|
286
|
+
return GOAL_SLUG_PATTERN.test(value) && value !== "archive"
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** Derive a slug from a title: "Trust & guardrail layer" → "trust-guardrail-layer". */
|
|
290
|
+
export function slugifyGoal(title: string): string {
|
|
291
|
+
return title
|
|
292
|
+
.toLowerCase()
|
|
293
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
294
|
+
.replace(/^-+|-+$/g, "")
|
|
295
|
+
.slice(0, 64)
|
|
296
|
+
.replace(/-+$/g, "")
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
export function parseGoal(text: string, where: string): GoalDoc {
|
|
300
|
+
const raw = splitRaw(text, where)!
|
|
301
|
+
const { title, description } = splitTitle(raw.body)
|
|
302
|
+
if (title === null) {
|
|
303
|
+
throw new TicketParseError(`${where}: expected the body to start with "# <title>"`)
|
|
304
|
+
}
|
|
305
|
+
const known = new Set<string>(GOAL_FIELDS)
|
|
306
|
+
const extras: [string, string][] = []
|
|
307
|
+
for (const [key, value] of raw.fields) {
|
|
308
|
+
if (!known.has(key)) extras.push([key, value.trim()])
|
|
309
|
+
}
|
|
310
|
+
return {
|
|
311
|
+
title,
|
|
312
|
+
description,
|
|
313
|
+
createdAt: stringField(raw, "created", where, ""),
|
|
314
|
+
updatedAt: stringField(raw, "updated", where, ""),
|
|
315
|
+
...(extras.length ? { extras } : {}),
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** Canonical bytes for a goal — fixed field order, like tickets. */
|
|
320
|
+
export function serializeGoal(doc: GoalDoc): string {
|
|
321
|
+
const lines = ["---", `created: ${doc.createdAt}`, `updated: ${doc.updatedAt}`]
|
|
322
|
+
for (const [key, value] of doc.extras ?? []) lines.push(`${key}: ${value}`)
|
|
189
323
|
lines.push("---", "", `# ${doc.title}`)
|
|
190
324
|
if (doc.description) lines.push("", doc.description)
|
|
191
325
|
return `${lines.join("\n")}\n`
|
|
192
326
|
}
|
|
193
327
|
|
|
328
|
+
/** A goal, forgivingly — null when there's no frontmatter at all. */
|
|
329
|
+
export function parseGoalLenient(text: string): GoalDoc | null {
|
|
330
|
+
const raw = splitRaw(text, null)
|
|
331
|
+
if (!raw) return null
|
|
332
|
+
const { title, description } = splitTitle(raw.body)
|
|
333
|
+
return {
|
|
334
|
+
title: title ?? "",
|
|
335
|
+
description,
|
|
336
|
+
createdAt: lenientString(field(raw, "created")),
|
|
337
|
+
updatedAt: lenientString(field(raw, "updated")),
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
194
341
|
// ── Comments ─────────────────────────────────────────────────────────────────
|
|
195
342
|
|
|
196
343
|
export function parseComment(text: string, where: string): CommentDoc {
|
|
197
|
-
const raw = splitRaw(text, where)
|
|
344
|
+
const raw = splitRaw(text, where)!
|
|
198
345
|
return {
|
|
199
346
|
author: stringField(raw, "author", where, ""),
|
|
200
347
|
createdAt: stringField(raw, "created", where, ""),
|
|
@@ -209,6 +356,122 @@ export function serializeComment(doc: CommentDoc): string {
|
|
|
209
356
|
return `${lines.join("\n")}\n`
|
|
210
357
|
}
|
|
211
358
|
|
|
359
|
+
// ── Asks ─────────────────────────────────────────────────────────────────────
|
|
360
|
+
|
|
361
|
+
export function parseAsk(text: string, where: string): AskDoc {
|
|
362
|
+
const raw = splitRaw(text, where)!
|
|
363
|
+
const resolvedAt = field(raw, "resolved")
|
|
364
|
+
if (resolvedAt !== undefined && typeof resolvedAt !== "string") {
|
|
365
|
+
throw new TicketParseError(`${where}: resolved should be a timestamp string`)
|
|
366
|
+
}
|
|
367
|
+
return {
|
|
368
|
+
author: stringField(raw, "author", where, ""),
|
|
369
|
+
createdAt: stringField(raw, "created", where, ""),
|
|
370
|
+
resolvedAt: resolvedAt ?? null,
|
|
371
|
+
resolvedBy: resolvedAt === undefined ? null : stringField(raw, "resolved_by", where, ""),
|
|
372
|
+
body: raw.body,
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
export function serializeAsk(doc: AskDoc): string {
|
|
377
|
+
const lines = ["---"]
|
|
378
|
+
if (doc.author) lines.push(`author: ${JSON.stringify(doc.author)}`)
|
|
379
|
+
lines.push(`created: ${doc.createdAt}`)
|
|
380
|
+
if (doc.resolvedAt) {
|
|
381
|
+
lines.push(`resolved: ${doc.resolvedAt}`)
|
|
382
|
+
if (doc.resolvedBy) lines.push(`resolved_by: ${JSON.stringify(doc.resolvedBy)}`)
|
|
383
|
+
}
|
|
384
|
+
lines.push("---", "", doc.body)
|
|
385
|
+
return `${lines.join("\n")}\n`
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* An ask, forgivingly — null when there's no frontmatter at all. The lenient
|
|
390
|
+
* read matters more here than for comments: the inbox reads ask files straight
|
|
391
|
+
* off claim branches, which are exactly the bytes a strict parser can't refuse.
|
|
392
|
+
*/
|
|
393
|
+
export function parseAskLenient(text: string): AskDoc | null {
|
|
394
|
+
const raw = splitRaw(text, null)
|
|
395
|
+
if (!raw) return null
|
|
396
|
+
const resolvedAt = field(raw, "resolved")
|
|
397
|
+
return {
|
|
398
|
+
author: lenientString(field(raw, "author")),
|
|
399
|
+
createdAt: lenientString(field(raw, "created")),
|
|
400
|
+
resolvedAt: typeof resolvedAt === "string" && resolvedAt !== "" ? resolvedAt : null,
|
|
401
|
+
resolvedBy: lenientString(field(raw, "resolved_by")) || null,
|
|
402
|
+
body: raw.body,
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// ── Lenient parsing ──────────────────────────────────────────────────────────
|
|
407
|
+
//
|
|
408
|
+
// The hosted board's read of the same format. The CLI is strict — it wrote the
|
|
409
|
+
// file, so a malformation is a bug worth halting on — but a renderer is looking
|
|
410
|
+
// at whatever a hand-edit or a bad merge left behind, and it must degrade
|
|
411
|
+
// rather than throw: a broken ticket becomes a degraded ticket (unknown status
|
|
412
|
+
// → backlog, missing heading → empty title), never a broken board. Living here
|
|
413
|
+
// rather than restated in the worker keeps one definition of the format.
|
|
414
|
+
|
|
415
|
+
function lenientStrings(value: unknown): string[] {
|
|
416
|
+
return Array.isArray(value) ? value.filter((v): v is string => typeof v === "string") : []
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function lenientKeys(value: unknown): string[] {
|
|
420
|
+
// Numeric entries (the pre-TAS-42 id format) are dropped, not coerced: a
|
|
421
|
+
// rendered old branch degrades to unlinked tickets, never to wrong links.
|
|
422
|
+
return Array.isArray(value) ? value.filter((v): v is string => typeof v === "string") : []
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function lenientString(value: unknown): string {
|
|
426
|
+
return typeof value === "string" ? value : ""
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/** A ticket, forgivingly — or null when there's no frontmatter to read at all. */
|
|
430
|
+
export function parseTicketLenient(text: string): TicketDoc | null {
|
|
431
|
+
const raw = splitRaw(text, null)
|
|
432
|
+
if (!raw) return null
|
|
433
|
+
const status = field(raw, "status")
|
|
434
|
+
// No `milestone:` read here either — on the old branches the hosted board
|
|
435
|
+
// previews, a pre-0.8 ticket degrades to goalless, the same as any other
|
|
436
|
+
// retired field. History renders; it doesn't get reinterpreted.
|
|
437
|
+
const goal = field(raw, "goal")
|
|
438
|
+
const position = field(raw, "position")
|
|
439
|
+
const { title, description } = splitTitle(raw.body)
|
|
440
|
+
return {
|
|
441
|
+
title: title ?? "",
|
|
442
|
+
description,
|
|
443
|
+
status: typeof status === "string" && isStatus(status) ? status : "backlog",
|
|
444
|
+
tags: lenientStrings(field(raw, "tags")),
|
|
445
|
+
goal: typeof goal === "string" && goal !== "" ? goal : null,
|
|
446
|
+
blockedBy: [...new Set(lenientKeys(field(raw, "blocked_by")))].sort(),
|
|
447
|
+
prs: lenientStrings(field(raw, "prs")),
|
|
448
|
+
position: typeof position === "number" && Number.isFinite(position) ? position : 0,
|
|
449
|
+
createdAt: lenientString(field(raw, "created")),
|
|
450
|
+
updatedAt: lenientString(field(raw, "updated")),
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* The frontmatter keys a file carries, leniently — null when there's no
|
|
456
|
+
* frontmatter at all. `task check` uses this to spot unknown keys in files
|
|
457
|
+
* whose parsed shape (comments) doesn't carry them through.
|
|
458
|
+
*/
|
|
459
|
+
export function frontmatterKeys(text: string): string[] | null {
|
|
460
|
+
const raw = splitRaw(text, null)
|
|
461
|
+
return raw ? [...raw.fields.keys()] : null
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/** A comment, forgivingly — or null when there's no frontmatter at all. */
|
|
465
|
+
export function parseCommentLenient(text: string): CommentDoc | null {
|
|
466
|
+
const raw = splitRaw(text, null)
|
|
467
|
+
if (!raw) return null
|
|
468
|
+
return {
|
|
469
|
+
author: lenientString(field(raw, "author")),
|
|
470
|
+
createdAt: lenientString(field(raw, "created")),
|
|
471
|
+
body: raw.body,
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
212
475
|
/**
|
|
213
476
|
* A comment's filename stem — readable, sortable, and unique enough that two
|
|
214
477
|
* branches almost never mint the same one: `2026-08-15T165943-claude`. The
|
package/src/types.ts
CHANGED
|
@@ -9,24 +9,66 @@ export const STATUSES = [
|
|
|
9
9
|
] as const
|
|
10
10
|
export type Status = (typeof STATUSES)[number]
|
|
11
11
|
|
|
12
|
+
/**
|
|
13
|
+
* An ask: one thing a person owes this ticket before it can be done. Stored
|
|
14
|
+
* like a comment — one file per ask under `tickets/<n>/asks/` — so a worker on
|
|
15
|
+
* a claim branch can add one without ever conflicting with main. The ask is
|
|
16
|
+
* the primitive everything human-owed funnels through: `needsHuman` is derived
|
|
17
|
+
* from open asks, `task inbox` is the list of them, and `done` is gated on
|
|
18
|
+
* zero remaining.
|
|
19
|
+
*/
|
|
20
|
+
export interface Ask {
|
|
21
|
+
/** Stable opaque id — the ask's filename stem (e.g. "2026-08-27T104501-claude"). */
|
|
22
|
+
id: string
|
|
23
|
+
taskId: string
|
|
24
|
+
/**
|
|
25
|
+
* 1-based position in creation order across the ticket's asks, resolved and
|
|
26
|
+
* open alike — the short handle `task resolve <id> <ordinal>` takes.
|
|
27
|
+
*/
|
|
28
|
+
ordinal: number
|
|
29
|
+
text: string
|
|
30
|
+
author: string
|
|
31
|
+
createdAt: string
|
|
32
|
+
/** Both null while the ask is open. */
|
|
33
|
+
resolvedAt: string | null
|
|
34
|
+
resolvedBy: string | null
|
|
35
|
+
}
|
|
36
|
+
|
|
12
37
|
export interface Task {
|
|
13
|
-
/** Display id — `<prefix>-<
|
|
38
|
+
/** Display id — `<prefix>-<key>`, e.g. "PHO-x7k4m". */
|
|
14
39
|
id: string
|
|
15
|
-
|
|
40
|
+
/**
|
|
41
|
+
* The ticket's key: 5 random lowercase characters minted at creation (see
|
|
42
|
+
* id.ts), doubling as its directory name under `.task/tickets/`. Random on
|
|
43
|
+
* purpose — allocation must be safe across branches that have never seen
|
|
44
|
+
* each other — and opaque on purpose: creation order lives in `createdAt`,
|
|
45
|
+
* board order in `position`.
|
|
46
|
+
*/
|
|
47
|
+
key: string
|
|
16
48
|
title: string
|
|
17
49
|
description: string
|
|
18
50
|
status: Status
|
|
19
51
|
tags: string[]
|
|
20
|
-
|
|
21
|
-
|
|
52
|
+
/**
|
|
53
|
+
* Slug of the goal this task belongs to — at most one. Tags answer "what
|
|
54
|
+
* kind" (many); the goal answers "where is this going" (one). Something that
|
|
55
|
+
* wants to live in two goals is a tag.
|
|
56
|
+
*/
|
|
57
|
+
goal: string | null
|
|
58
|
+
/**
|
|
59
|
+
* Derived, never stored: true while any ask is open. There is no flag to
|
|
60
|
+
* set — a flag with no stated reason is the failure mode asks exist to end.
|
|
61
|
+
*/
|
|
22
62
|
needsHuman: boolean
|
|
63
|
+
/** Every ask on this ticket, open and resolved, in creation order. */
|
|
64
|
+
asks: Ask[]
|
|
23
65
|
/**
|
|
24
|
-
*
|
|
66
|
+
* Keys of tasks this one blocks / is blocked by. One relation, two views:
|
|
25
67
|
* a single `(blocker, blocked)` row shows up in the blocker's `blocks` and
|
|
26
68
|
* the blocked task's `blockedBy`, so the mirror can never drift.
|
|
27
69
|
*/
|
|
28
|
-
blocks:
|
|
29
|
-
blockedBy:
|
|
70
|
+
blocks: string[]
|
|
71
|
+
blockedBy: string[]
|
|
30
72
|
/** Pull request URLs attached to this task. */
|
|
31
73
|
prs: string[]
|
|
32
74
|
/** Sort key within a status column; smaller sorts first. */
|
|
@@ -42,11 +84,7 @@ export interface Task {
|
|
|
42
84
|
}
|
|
43
85
|
|
|
44
86
|
export interface Comment {
|
|
45
|
-
/**
|
|
46
|
-
* Stable opaque id. On file-backed boards it's the comment's filename stem
|
|
47
|
-
* (e.g. "2026-08-15T165943-claude"); on legacy SQLite boards, the rowid as a
|
|
48
|
-
* string.
|
|
49
|
-
*/
|
|
87
|
+
/** Stable opaque id — the comment's filename stem (e.g. "2026-08-15T165943-claude"). */
|
|
50
88
|
id: string
|
|
51
89
|
taskId: string
|
|
52
90
|
author: string
|
|
@@ -54,27 +92,83 @@ export interface Comment {
|
|
|
54
92
|
createdAt: string
|
|
55
93
|
}
|
|
56
94
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
/** Uppercase id prefix, e.g. "PHO" → PHO-1, PHO-2, … */
|
|
60
|
-
prefix: string
|
|
61
|
-
version: number
|
|
95
|
+
/** How this board's tickets get claimed — see `claim.ts` for the mechanics. */
|
|
96
|
+
export interface ClaimsConfig {
|
|
62
97
|
/**
|
|
63
98
|
* Branch namespace `task claim` creates claim branches under — committed
|
|
64
99
|
* with the board on purpose, so every worker and every clone agree on what
|
|
65
100
|
* "claimed" looks like. Defaults to "task/claim/"; repos whose workers are
|
|
66
101
|
* Claude Code cloud sessions set "claude/task/", the one prefix that
|
|
67
|
-
* runtime can push without extra ceremony.
|
|
102
|
+
* runtime can push without extra ceremony. Must agree across a repo's
|
|
103
|
+
* boards (`task check` lints it) — workflows match claim branches by
|
|
104
|
+
* pattern, and one repo with two patterns matches neither reliably.
|
|
105
|
+
*/
|
|
106
|
+
branchPrefix?: string
|
|
107
|
+
/**
|
|
108
|
+
* Repo-wide cap on open claims: `task claim` refuses (exit 2) while this
|
|
109
|
+
* many claim branches exist on origin, `--force` overrides. Read from the
|
|
110
|
+
* repo's *root* board only — a WIP cap per board is no cap at all.
|
|
111
|
+
*/
|
|
112
|
+
maxOpenCount?: number
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export interface ProjectConfig {
|
|
116
|
+
name: string
|
|
117
|
+
/** Uppercase id prefix, e.g. "PHO" → PHO-x7k4m, PHO-q3v8d, … */
|
|
118
|
+
prefix: string
|
|
119
|
+
version: number
|
|
120
|
+
/**
|
|
121
|
+
* Pre-0.8 spelling of `claims.branchPrefix`. Never honored — typed only so
|
|
122
|
+
* `claimNamespace` can refuse it by name instead of silently defaulting the
|
|
123
|
+
* board's committed lock convention out from under its workers.
|
|
68
124
|
*/
|
|
69
125
|
claimPrefix?: string
|
|
126
|
+
claims?: ClaimsConfig
|
|
127
|
+
/**
|
|
128
|
+
* Root board only: the repo's automation-visible boards, by id prefix, in
|
|
129
|
+
* priority order. This is *selection scope* — where `claim --next` and
|
|
130
|
+
* `list --claimable` look — not permission: explicit-id claims work on any
|
|
131
|
+
* board regardless. Absent → automation sees the nearest board only, so a
|
|
132
|
+
* vendored board can't opt itself in.
|
|
133
|
+
*/
|
|
134
|
+
boards?: string[]
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* A goal: a titled, described destination that tasks belong to. Progress is
|
|
139
|
+
* always derived from its tasks — a goal has no stored status on purpose,
|
|
140
|
+
* because a second state machine is a second thing that can lie.
|
|
141
|
+
*/
|
|
142
|
+
export interface Goal {
|
|
143
|
+
/** Filename stem under `.task/goals/` — the stable identity tickets reference. */
|
|
144
|
+
slug: string
|
|
145
|
+
title: string
|
|
146
|
+
/** The strategic why. Tickets keep the how; this is the context they share. */
|
|
147
|
+
description: string
|
|
148
|
+
createdAt: string
|
|
149
|
+
updatedAt: string
|
|
150
|
+
/** Set (to true) only on goals living in `.task/goals/archive/`. */
|
|
151
|
+
archived?: boolean
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export interface GoalInput {
|
|
155
|
+
title: string
|
|
156
|
+
/** Derived from the title when omitted. */
|
|
157
|
+
slug?: string
|
|
158
|
+
description?: string
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export interface GoalPatch {
|
|
162
|
+
title?: string
|
|
163
|
+
description?: string
|
|
70
164
|
}
|
|
71
165
|
|
|
72
166
|
export interface TaskFilter {
|
|
73
167
|
statuses?: Status[]
|
|
74
168
|
/** Any-of: a task matches if it carries at least one of these tags. */
|
|
75
169
|
tags?: string[]
|
|
76
|
-
|
|
77
|
-
/** undefined = don't filter
|
|
170
|
+
goal?: string
|
|
171
|
+
/** Matches the *derived* flag — open asks, nothing else. undefined = don't filter. */
|
|
78
172
|
needsHuman?: boolean
|
|
79
173
|
/** true = list the archive instead of the board. */
|
|
80
174
|
archived?: boolean
|
|
@@ -85,10 +179,9 @@ export interface TaskInput {
|
|
|
85
179
|
description?: string
|
|
86
180
|
status?: Status
|
|
87
181
|
tags?: string[]
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
blockedBy?: number[]
|
|
182
|
+
goal?: string | null
|
|
183
|
+
blocks?: string[]
|
|
184
|
+
blockedBy?: string[]
|
|
92
185
|
prs?: string[]
|
|
93
186
|
}
|
|
94
187
|
|
|
@@ -97,14 +190,13 @@ export interface TaskPatch {
|
|
|
97
190
|
description?: string
|
|
98
191
|
status?: Status
|
|
99
192
|
tags?: string[]
|
|
100
|
-
|
|
101
|
-
needsHuman?: boolean
|
|
193
|
+
goal?: string | null
|
|
102
194
|
/**
|
|
103
195
|
* Full replacement, like `tags` — the store reconciles the relation table
|
|
104
196
|
* to match. Writing either side updates the other task's mirror view.
|
|
105
197
|
*/
|
|
106
|
-
blocks?:
|
|
107
|
-
blockedBy?:
|
|
198
|
+
blocks?: string[]
|
|
199
|
+
blockedBy?: string[]
|
|
108
200
|
prs?: string[]
|
|
109
201
|
/** Explicit board position (used by drag-and-drop in the UI). */
|
|
110
202
|
position?: number
|