@nickmeriano/task 0.7.1 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. package/README.md +73 -25
  2. package/dist/asks.test.d.ts +17 -0
  3. package/dist/asks.test.d.ts.map +1 -0
  4. package/dist/asks.test.js +278 -0
  5. package/dist/asks.test.js.map +1 -0
  6. package/dist/check.d.ts +43 -0
  7. package/dist/check.d.ts.map +1 -0
  8. package/dist/check.js +403 -0
  9. package/dist/check.js.map +1 -0
  10. package/dist/check.test.d.ts +9 -0
  11. package/dist/check.test.d.ts.map +1 -0
  12. package/dist/check.test.js +248 -0
  13. package/dist/check.test.js.map +1 -0
  14. package/dist/claim-io.d.ts +73 -0
  15. package/dist/claim-io.d.ts.map +1 -0
  16. package/dist/claim-io.js +344 -0
  17. package/dist/claim-io.js.map +1 -0
  18. package/dist/claim.d.ts +61 -9
  19. package/dist/claim.d.ts.map +1 -1
  20. package/dist/claim.js +197 -67
  21. package/dist/claim.js.map +1 -1
  22. package/dist/claim.test.d.ts +2 -2
  23. package/dist/claim.test.js +235 -64
  24. package/dist/claim.test.js.map +1 -1
  25. package/dist/cli.js +724 -136
  26. package/dist/cli.js.map +1 -1
  27. package/dist/file-store.d.ts +110 -38
  28. package/dist/file-store.d.ts.map +1 -1
  29. package/dist/file-store.js +514 -238
  30. package/dist/file-store.js.map +1 -1
  31. package/dist/git-serve.d.ts +183 -0
  32. package/dist/git-serve.d.ts.map +1 -0
  33. package/dist/git-serve.js +503 -0
  34. package/dist/git-serve.js.map +1 -0
  35. package/dist/git-serve.test.d.ts +16 -0
  36. package/dist/git-serve.test.d.ts.map +1 -0
  37. package/dist/git-serve.test.js +183 -0
  38. package/dist/git-serve.test.js.map +1 -0
  39. package/dist/git.d.ts +65 -0
  40. package/dist/git.d.ts.map +1 -0
  41. package/dist/git.js +114 -0
  42. package/dist/git.js.map +1 -0
  43. package/dist/id.d.ts +39 -0
  44. package/dist/id.d.ts.map +1 -0
  45. package/dist/id.js +67 -0
  46. package/dist/id.js.map +1 -0
  47. package/dist/inbox.d.ts +41 -0
  48. package/dist/inbox.d.ts.map +1 -0
  49. package/dist/inbox.js +56 -0
  50. package/dist/inbox.js.map +1 -0
  51. package/dist/index.d.ts +5 -3
  52. package/dist/index.d.ts.map +1 -1
  53. package/dist/index.js +5 -3
  54. package/dist/index.js.map +1 -1
  55. package/dist/overview.d.ts +52 -0
  56. package/dist/overview.d.ts.map +1 -0
  57. package/dist/overview.js +61 -0
  58. package/dist/overview.js.map +1 -0
  59. package/dist/overview.test.d.ts +8 -0
  60. package/dist/overview.test.d.ts.map +1 -0
  61. package/dist/overview.test.js +48 -0
  62. package/dist/overview.test.js.map +1 -0
  63. package/dist/promote.test.d.ts +15 -0
  64. package/dist/promote.test.d.ts.map +1 -0
  65. package/dist/promote.test.js +104 -0
  66. package/dist/promote.test.js.map +1 -0
  67. package/dist/publish.d.ts +2 -17
  68. package/dist/publish.d.ts.map +1 -1
  69. package/dist/publish.js +4 -49
  70. package/dist/publish.js.map +1 -1
  71. package/dist/search.d.ts +34 -0
  72. package/dist/search.d.ts.map +1 -0
  73. package/dist/search.js +79 -0
  74. package/dist/search.js.map +1 -0
  75. package/dist/search.test.d.ts +2 -0
  76. package/dist/search.test.d.ts.map +1 -0
  77. package/dist/search.test.js +53 -0
  78. package/dist/search.test.js.map +1 -0
  79. package/dist/server.d.ts.map +1 -1
  80. package/dist/server.js +228 -23
  81. package/dist/server.js.map +1 -1
  82. package/dist/store.d.ts +43 -63
  83. package/dist/store.d.ts.map +1 -1
  84. package/dist/store.js +0 -368
  85. package/dist/store.js.map +1 -1
  86. package/dist/store.test.d.ts +1 -2
  87. package/dist/store.test.d.ts.map +1 -1
  88. package/dist/store.test.js +148 -106
  89. package/dist/store.test.js.map +1 -1
  90. package/dist/ticket-doc.d.ts +74 -5
  91. package/dist/ticket-doc.d.ts.map +1 -1
  92. package/dist/ticket-doc.js +229 -15
  93. package/dist/ticket-doc.js.map +1 -1
  94. package/dist/types.d.ts +115 -28
  95. package/dist/types.d.ts.map +1 -1
  96. package/dist/types.js.map +1 -1
  97. package/package.json +1 -1
  98. package/skill/SKILL.md +153 -40
  99. package/src/asks.test.ts +355 -0
  100. package/src/check.test.ts +328 -0
  101. package/src/check.ts +497 -0
  102. package/src/claim-io.ts +401 -0
  103. package/src/claim.test.ts +301 -71
  104. package/src/claim.ts +238 -81
  105. package/src/cli.ts +740 -131
  106. package/src/file-store.ts +572 -254
  107. package/src/git-serve.test.ts +240 -0
  108. package/src/git-serve.ts +595 -0
  109. package/src/git.ts +141 -0
  110. package/src/id.ts +68 -0
  111. package/src/inbox.ts +77 -0
  112. package/src/index.ts +4 -2
  113. package/src/overview.test.ts +52 -0
  114. package/src/overview.ts +105 -0
  115. package/src/promote.test.ts +143 -0
  116. package/src/publish.ts +6 -53
  117. package/src/search.test.ts +64 -0
  118. package/src/search.ts +105 -0
  119. package/src/server.ts +232 -21
  120. package/src/store.test.ts +166 -116
  121. package/src/store.ts +46 -444
  122. package/src/ticket-doc.ts +284 -21
  123. package/src/types.ts +120 -28
  124. package/ui/dist/assets/index-BjsorZOU.js +229 -0
  125. package/ui/dist/assets/index-CoKCUYic.css +1 -0
  126. package/ui/dist/index.html +2 -2
  127. package/ui/dist/assets/index-COunM-QN.css +0 -1
  128. package/ui/dist/assets/index-D4homvrQ.js +0 -229
package/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
- milestone: string | null
27
- needsHuman: boolean
28
- /** Task numbers blocking this one — the single stored side of the relation. */
29
- blockedBy: number[]
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
- function splitRaw(text: string, where: string): RawDoc {
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 numberList(raw: RawDoc, key: string, where: string): number[] {
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 === "number" && Number.isInteger(v))) {
112
- return value as number[]
158
+ if (Array.isArray(value) && value.every((v) => typeof v === "string")) {
159
+ return value as string[]
113
160
  }
114
- throw new TicketParseError(`${where}: ${key} should be a list of task numbers, e.g. [3, 7]`)
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
- const milestone = field(raw, "milestone")
142
- if (milestone !== undefined && milestone !== null && typeof milestone !== "string") {
143
- throw new TicketParseError(`${where}: milestone should be a string`)
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
- milestone: typeof milestone === "string" && milestone !== "" ? milestone : null,
160
- needsHuman: needsHuman === true,
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.milestone) lines.push(`milestone: ${JSON.stringify(doc.milestone)}`)
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>-<number>`, e.g. "PHO-12". */
38
+ /** Display id — `<prefix>-<key>`, e.g. "PHO-x7k4m". */
14
39
  id: string
15
- number: number
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
- milestone: string | null
21
- /** This ticket can't be finished by an agent alone. */
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
- * Task numbers this one blocks / is blocked by. One relation, two views:
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: number[]
29
- blockedBy: number[]
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
- export interface ProjectConfig {
58
- name: string
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
- milestone?: string
77
- /** undefined = don't filter on it at all. */
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
- milestone?: string | null
89
- needsHuman?: boolean
90
- blocks?: number[]
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
- milestone?: string | null
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?: number[]
107
- blockedBy?: number[]
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