@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/check.ts ADDED
@@ -0,0 +1,497 @@
1
+ /**
2
+ * `task check` — lint the board files.
3
+ *
4
+ * A database enforced its constraints at write time; a text store has two
5
+ * extra write paths no CLI code ever sees — git merges and hand-edits — so
6
+ * validation has to be a lint you can run. This strictly parses every ticket
7
+ * comment and goal file on every board at or below the check root (the same
8
+ * walk `task serve` does) and reports what would degrade or throw at read
9
+ * time: files that don't parse, invalid timestamps, dangling `blocked_by`
10
+ * references (one branch deleted a ticket another branch linked to), `goal:`
11
+ * refs naming a goal that doesn't exist, tickets duplicated between the board
12
+ * and the archive, unknown frontmatter keys, and formatting drift from the
13
+ * canonical serialization. Retired pre-0.8 spellings (`milestone:`,
14
+ * `claimPrefix`) are refused by the parsers with the rename in the error —
15
+ * this lint just surfaces those messages; there is no migration machinery.
16
+ *
17
+ * The mechanical repairs — pruning dangling or self references, deduping and
18
+ * sorting `blocked_by`, rewriting a file into canonical form — are applied by
19
+ * `--fix`. Anything that needs a decision (a status that isn't a status, a
20
+ * missing title) is reported and left alone. Cheap enough for pre-commit and
21
+ * CI, which is where merge-introduced drift gets caught.
22
+ */
23
+
24
+ import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs"
25
+ import { join } from "node:path"
26
+ import {
27
+ ARCHIVE_DIR,
28
+ ASKS_DIR,
29
+ COMMENTS_DIR,
30
+ GOALS_ARCHIVE_DIR,
31
+ GOALS_DIR,
32
+ TICKETS_DIR,
33
+ TICKET_FILE,
34
+ } from "./file-store.ts"
35
+ import {
36
+ ASK_FIELDS,
37
+ COMMENT_FIELDS,
38
+ TicketParseError,
39
+ frontmatterKeys,
40
+ isGoalSlug,
41
+ parseAsk,
42
+ parseComment,
43
+ parseGoal,
44
+ parseTicket,
45
+ serializeAsk,
46
+ serializeComment,
47
+ serializeGoal,
48
+ serializeTicket,
49
+ type TicketDoc,
50
+ } from "./ticket-doc.ts"
51
+ import {
52
+ CONFIG_FILE,
53
+ TASK_DIR,
54
+ boardConfig,
55
+ findBoards,
56
+ findRoot,
57
+ findScopeRoot,
58
+ type BoardRef,
59
+ } from "./store.ts"
60
+ import { claimNamespace } from "./claim.ts"
61
+ import { isTicketKey } from "./id.ts"
62
+ import type { ProjectConfig } from "./types.ts"
63
+
64
+ export interface CheckIssue {
65
+ /** Board id relative to the check root — "." for the root board. */
66
+ board: string
67
+ /** File (or directory) the problem is in, relative to the check root. */
68
+ file: string
69
+ message: string
70
+ /** `task check --fix` repairs this mechanically. */
71
+ fixable: boolean
72
+ }
73
+
74
+ export interface CheckResult {
75
+ boards: number
76
+ /** Ticket and comment files inspected. */
77
+ files: number
78
+ issues: CheckIssue[]
79
+ /** Files rewritten, relative to the check root. Empty unless fixing. */
80
+ fixed: string[]
81
+ }
82
+
83
+ /** The boards `task check` covers from `cwd` — the same set `task serve` has. */
84
+ export function checkRoot(cwd: string): string {
85
+ return findRoot(cwd) ?? cwd
86
+ }
87
+
88
+ interface TicketEntry {
89
+ key: string
90
+ /** Path relative to the check root, for reporting. */
91
+ file: string
92
+ /** Absolute path, for fixing. */
93
+ absolute: string
94
+ archived: boolean
95
+ text: string
96
+ doc: TicketDoc | null
97
+ }
98
+
99
+ function posixJoin(...parts: string[]): string {
100
+ return parts.filter((p) => p !== "" && p !== ".").join("/")
101
+ }
102
+
103
+ /** Strip the `where: ` prefix strict parsers put in messages — we report the file. */
104
+ function reason(error: unknown, where: string): string {
105
+ const message = error instanceof Error ? error.message : String(error)
106
+ return message.startsWith(`${where}: `) ? message.slice(where.length + 2) : message
107
+ }
108
+
109
+ function isValidTimestamp(value: string): boolean {
110
+ return value !== "" && !Number.isNaN(Date.parse(value))
111
+ }
112
+
113
+ export function checkBoards(root: string, fix: boolean): CheckResult {
114
+ const boards = findBoards(root)
115
+ // The repo's root board is the reference for everything repo-scoped: the
116
+ // `boards` key may live only there, and every board's claim namespace must
117
+ // match its. It can sit *above* the check root (check run inside a nested
118
+ // board), so it's resolved by walking up, not taken from the walk down.
119
+ const scopeRoot = findScopeRoot(root)
120
+ // Normalized through claimNamespace so "claude/task" and "claude/task/"
121
+ // compare equal; an unreadable or malformed root config means "no
122
+ // reference" (the root board's own check reports the problem).
123
+ let rootBranchPrefix: string | null = null
124
+ try {
125
+ rootBranchPrefix = claimNamespace(boardConfig(scopeRoot))
126
+ } catch {
127
+ rootBranchPrefix = null
128
+ }
129
+ const issues: CheckIssue[] = []
130
+ const fixed: string[] = []
131
+ let files = 0
132
+
133
+ for (const board of boards) {
134
+ files += checkBoard(board, scopeRoot, rootBranchPrefix, fix, issues, fixed)
135
+ }
136
+ return { boards: boards.length, files, issues, fixed }
137
+ }
138
+
139
+ /**
140
+ * Config lints (TAS-27): a `boards` list on a non-root board (root-only by
141
+ * design, so vendored boards can't opt into a repo's automation), a malformed
142
+ * one, an unusable branch prefix, a branch prefix that disagrees with the
143
+ * root board's (workflows match claim branches by pattern — one repo, one
144
+ * namespace), and a leftover pre-0.8 `claimPrefix` key. The retired spelling
145
+ * is not migrated anymore (TAS-41: no compat) — with no `claims.branchPrefix`
146
+ * beside it, `claimNamespace` refuses by name and that error surfaces here;
147
+ * with one, the stray key is inert and gets its own line.
148
+ */
149
+ function checkConfig(
150
+ board: BoardRef,
151
+ scopeRoot: string,
152
+ rootBranchPrefix: string | null,
153
+ push: (file: string, message: string, fixable?: boolean) => void,
154
+ ): ProjectConfig | null {
155
+ const absolute = join(board.root, TASK_DIR, CONFIG_FILE)
156
+ const file = posixJoin(board.id, TASK_DIR, CONFIG_FILE)
157
+ let raw: Record<string, unknown>
158
+ try {
159
+ raw = JSON.parse(readFileSync(absolute, "utf8")) as Record<string, unknown>
160
+ } catch (error) {
161
+ push(file, reason(error, ""))
162
+ return null
163
+ }
164
+ const config = raw as unknown as ProjectConfig
165
+
166
+ if (raw.claimPrefix !== undefined && config.claims?.branchPrefix !== undefined) {
167
+ push(
168
+ file,
169
+ "`claimPrefix` is the pre-0.8 spelling and is no longer read — `claims.branchPrefix` wins; delete the leftover key",
170
+ )
171
+ }
172
+
173
+ if (raw.boards !== undefined) {
174
+ if (board.root !== scopeRoot) {
175
+ push(
176
+ file,
177
+ '"boards" is root-only — automation reads it from the repo\'s root board; remove it here',
178
+ )
179
+ } else if (
180
+ !Array.isArray(raw.boards) ||
181
+ raw.boards.length === 0 ||
182
+ raw.boards.some((b) => typeof b !== "string")
183
+ ) {
184
+ push(file, '"boards" must be a non-empty array of board prefixes, e.g. ["NIC", "TAS"]')
185
+ }
186
+ }
187
+
188
+ let branchPrefix: string | null = null
189
+ try {
190
+ branchPrefix = claimNamespace(config)
191
+ } catch (error) {
192
+ push(file, reason(error, ""))
193
+ }
194
+ if (
195
+ branchPrefix !== null &&
196
+ rootBranchPrefix !== null &&
197
+ board.root !== scopeRoot &&
198
+ branchPrefix !== rootBranchPrefix
199
+ ) {
200
+ push(
201
+ file,
202
+ `claims.branchPrefix ${JSON.stringify(branchPrefix)} disagrees with the root board's ${JSON.stringify(rootBranchPrefix)} — workflows match claim branches by pattern, keep one namespace per repo`,
203
+ )
204
+ }
205
+
206
+ return config
207
+ }
208
+
209
+ function checkBoard(
210
+ board: BoardRef,
211
+ scopeRoot: string,
212
+ rootBranchPrefix: string | null,
213
+ fix: boolean,
214
+ issues: CheckIssue[],
215
+ fixed: string[],
216
+ ): number {
217
+ const taskDir = join(board.root, TASK_DIR)
218
+ const relTaskDir = posixJoin(board.id, TASK_DIR)
219
+ const push = (file: string, message: string, fixable = false) =>
220
+ issues.push({ board: board.id, file, message, fixable })
221
+
222
+ const config = checkConfig(board, scopeRoot, rootBranchPrefix, push)
223
+ const prefix = config?.prefix ?? "TASK"
224
+ const displayId = (k: string) => `${prefix}-${k}`
225
+
226
+ // ── Collect every ticket, live and archived ────────────────────────────────
227
+ const tickets: TicketEntry[] = []
228
+ let files = 0
229
+ for (const [dir, archived] of [
230
+ [TICKETS_DIR, false],
231
+ [ARCHIVE_DIR, true],
232
+ ] as const) {
233
+ const home = join(taskDir, dir)
234
+ if (!existsSync(home)) continue
235
+ for (const entry of readdirSync(home, { withFileTypes: true })) {
236
+ if (entry.name.startsWith(".")) continue
237
+ const relEntry = posixJoin(relTaskDir, dir, entry.name)
238
+ // A purely numeric directory is a pre-TAS-42 ticket — the old
239
+ // sequential-id format, which nothing reads anymore. The likely way one
240
+ // appears post-migration is merging a branch cut before the cutover;
241
+ // the fix is mechanical and named here.
242
+ if (entry.isDirectory() && /^\d+$/.test(entry.name)) {
243
+ push(
244
+ relEntry,
245
+ "numeric ticket directory — the pre-TAS-42 sequential-id format, which is no longer read; migrate it with `node tools/migrate-ids/migrate.mjs --ticket <board-dir> " +
246
+ entry.name +
247
+ "`",
248
+ )
249
+ continue
250
+ }
251
+ if (!entry.isDirectory() || !isTicketKey(entry.name)) {
252
+ push(relEntry, "unexpected entry — ticket directories are named by their key (e.g. x7k4m)")
253
+ continue
254
+ }
255
+ const absolute = join(home, entry.name, TICKET_FILE)
256
+ const file = posixJoin(relEntry, TICKET_FILE)
257
+ if (!existsSync(absolute)) {
258
+ push(file, "ticket directory without a ticket.md")
259
+ continue
260
+ }
261
+ files++
262
+ const text = readFileSync(absolute, "utf8")
263
+ let doc: TicketDoc | null = null
264
+ try {
265
+ doc = parseTicket(text, file)
266
+ } catch (error) {
267
+ if (!(error instanceof TicketParseError)) throw error
268
+ push(file, reason(error, file))
269
+ }
270
+ tickets.push({ key: entry.name, file, absolute, archived, text, doc })
271
+ }
272
+ }
273
+
274
+ // A ticket present in both tickets/ and archive/ is a merge artifact: one
275
+ // branch archived it while another kept editing it. Which copy wins is a
276
+ // human call, so it's reported, never auto-fixed. (This doubles as the
277
+ // duplicate-key lint: within either directory git itself makes duplicates
278
+ // impossible, so board-and-archive is the only place one can exist.)
279
+ const live = new Set(tickets.filter((t) => !t.archived).map((t) => t.key))
280
+ const valid = new Set(tickets.map((t) => t.key))
281
+ for (const ticket of tickets) {
282
+ if (ticket.archived && live.has(ticket.key)) {
283
+ push(
284
+ ticket.file,
285
+ `${displayId(ticket.key)} also exists in ${TICKETS_DIR}/ — a merge kept both copies; delete one`,
286
+ )
287
+ }
288
+ }
289
+
290
+ // ── Goals ──────────────────────────────────────────────────────────────────
291
+ // Same strict pass as tickets: parse, timestamps, unknown keys, canonical
292
+ // form. `goalSlugs` (live and archived together) is what ticket refs are
293
+ // checked against below — an archived goal still resolves.
294
+ const goalSlugs = new Set<string>()
295
+ const liveGoalSlugs = new Set<string>()
296
+ for (const [dir, goalArchived] of [
297
+ [GOALS_DIR, false],
298
+ [`${GOALS_DIR}/${GOALS_ARCHIVE_DIR}`, true],
299
+ ] as const) {
300
+ const home = join(taskDir, dir)
301
+ if (!existsSync(home)) continue
302
+ for (const entry of readdirSync(home, { withFileTypes: true })) {
303
+ if (entry.name.startsWith(".")) continue
304
+ if (entry.isDirectory() && !goalArchived && entry.name === GOALS_ARCHIVE_DIR) continue
305
+ const file = posixJoin(relTaskDir, dir, entry.name)
306
+ if (entry.isDirectory() || !entry.name.endsWith(".md")) {
307
+ push(file, "unexpected entry — goals/ holds one <slug>.md per goal")
308
+ continue
309
+ }
310
+ files++
311
+ const slug = entry.name.slice(0, -3)
312
+ const text = readFileSync(join(home, entry.name), "utf8")
313
+ let doc
314
+ try {
315
+ doc = parseGoal(text, file)
316
+ } catch (error) {
317
+ if (!(error instanceof TicketParseError)) throw error
318
+ push(file, reason(error, file))
319
+ continue
320
+ }
321
+ if (goalArchived && liveGoalSlugs.has(slug)) {
322
+ push(file, `goal "${slug}" also exists in ${GOALS_DIR}/ — a merge kept both copies; delete one`)
323
+ }
324
+ goalSlugs.add(slug)
325
+ if (!goalArchived) liveGoalSlugs.add(slug)
326
+ if (!isGoalSlug(slug)) {
327
+ push(file, `"${slug}" is not a valid goal slug — lowercase letters, digits and dashes`)
328
+ }
329
+ for (const [key, at] of [
330
+ [doc.createdAt, "created"],
331
+ [doc.updatedAt, "updated"],
332
+ ] as const) {
333
+ if (!isValidTimestamp(key)) push(file, `${at} is not a valid timestamp`)
334
+ }
335
+ for (const [key] of doc.extras ?? []) {
336
+ push(file, `unknown frontmatter key "${key}" — preserved on rewrite, but nothing reads it`)
337
+ }
338
+ const canonical = serializeGoal(doc)
339
+ if (canonical !== text) {
340
+ push(file, "not in canonical form — `task check --fix` rewrites it", true)
341
+ if (fix) {
342
+ writeFileSync(join(home, entry.name), canonical)
343
+ fixed.push(file)
344
+ }
345
+ }
346
+ }
347
+ }
348
+
349
+ // ── Per-ticket content checks ──────────────────────────────────────────────
350
+ for (const ticket of tickets) {
351
+ const { doc, file, key } = ticket
352
+ if (!doc) continue
353
+
354
+ for (const [key, at] of [
355
+ [doc.createdAt, "created"],
356
+ [doc.updatedAt, "updated"],
357
+ ] as const) {
358
+ if (!isValidTimestamp(key)) push(file, `${at} is not a valid timestamp`)
359
+ }
360
+ for (const [key] of doc.extras ?? []) {
361
+ push(file, `unknown frontmatter key "${key}" — preserved on rewrite, but nothing reads it`)
362
+ }
363
+ if (ticket.archived && doc.status !== "done" && doc.status !== "canceled") {
364
+ push(
365
+ file,
366
+ `archived but still ${doc.status} — only done or canceled tickets belong in the archive`,
367
+ )
368
+ }
369
+
370
+ // A dangling goal ref is deliberately NOT fixable: unlike a blocked_by
371
+ // pointing at a deleted ticket (pure mechanics), a goal ref is
372
+ // organization — whether to create the missing goal or clear the ref is a
373
+ // human call, so the lint states both options and touches nothing.
374
+ if (doc.goal && !goalSlugs.has(doc.goal)) {
375
+ push(
376
+ file,
377
+ `goal "${doc.goal}" doesn't exist — create it with \`task goal add\`, or clear the ref with \`--goal ""\``,
378
+ )
379
+ }
380
+
381
+ // The repaired relation list: no self references, no dangling targets, no
382
+ // duplicates, sorted. Each divergence is its own issue; all are mechanical.
383
+ const pruned = [...new Set(doc.blockedBy)]
384
+ .filter((k) => k !== key && valid.has(k))
385
+ .sort((a, b) => a.localeCompare(b))
386
+ if (doc.blockedBy.includes(key)) {
387
+ push(file, `${displayId(key)} is blocked by itself`, true)
388
+ }
389
+ for (const target of new Set(doc.blockedBy)) {
390
+ if (target !== key && !valid.has(target)) {
391
+ push(file, `blocked_by references ${displayId(target)}, which doesn't exist`, true)
392
+ }
393
+ }
394
+
395
+ // Everything else — duplicate or unsorted blocked_by, hand-edited field
396
+ // order or quoting, stray whitespace — is formatting drift: the parse is
397
+ // fine, the bytes just aren't what the CLI would write. `normalized` keeps
398
+ // the dangling refs so drift isn't double-reported on top of them. (The
399
+ // pre-0.8 `milestone:` key never reaches here — the strict parse refuses
400
+ // it by name, and that error is the whole report.)
401
+ const normalized = serializeTicket({
402
+ ...doc,
403
+ blockedBy: [...new Set(doc.blockedBy)].sort((a, b) => a.localeCompare(b)),
404
+ })
405
+ if (normalized !== ticket.text) {
406
+ push(file, "not in canonical form — `task check --fix` rewrites it", true)
407
+ }
408
+ const canonical = serializeTicket({ ...doc, blockedBy: pruned })
409
+ if (fix && canonical !== ticket.text) {
410
+ writeFileSync(ticket.absolute, canonical)
411
+ fixed.push(file)
412
+ }
413
+ }
414
+
415
+ // ── Asks ───────────────────────────────────────────────────────────────────
416
+ // Same pass as comments: strict parse, timestamps, unknown keys, canonical
417
+ // drift. An empty ask body gets its own line — an ask that doesn't say what's
418
+ // owed is the old boolean wearing a new file.
419
+ const knownAskKeys = new Set<string>(ASK_FIELDS)
420
+ for (const ticket of tickets) {
421
+ const dir = join(ticket.absolute, "..", ASKS_DIR)
422
+ if (!existsSync(dir)) continue
423
+ for (const entry of readdirSync(dir)) {
424
+ if (!entry.endsWith(".md") || entry.startsWith(".")) continue
425
+ files++
426
+ const file = posixJoin(ticket.file.slice(0, -TICKET_FILE.length - 1), ASKS_DIR, entry)
427
+ const text = readFileSync(join(dir, entry), "utf8")
428
+ let parsed
429
+ try {
430
+ parsed = parseAsk(text, file)
431
+ } catch (error) {
432
+ if (!(error instanceof TicketParseError)) throw error
433
+ push(file, reason(error, file))
434
+ continue
435
+ }
436
+ if (!isValidTimestamp(parsed.createdAt)) push(file, "created is not a valid timestamp")
437
+ if (parsed.resolvedAt !== null && !isValidTimestamp(parsed.resolvedAt)) {
438
+ push(file, "resolved is not a valid timestamp")
439
+ }
440
+ if (!parsed.body.trim()) push(file, "empty ask — say what a person owes, or delete it")
441
+ let unknownKeys = false
442
+ for (const key of frontmatterKeys(text) ?? []) {
443
+ if (knownAskKeys.has(key)) continue
444
+ unknownKeys = true
445
+ push(file, `unknown frontmatter key "${key}"`)
446
+ }
447
+ const canonical = serializeAsk(parsed)
448
+ if (canonical !== text && !unknownKeys) {
449
+ push(file, "not in canonical form — `task check --fix` rewrites it", true)
450
+ if (fix) {
451
+ writeFileSync(join(dir, entry), canonical)
452
+ fixed.push(file)
453
+ }
454
+ }
455
+ }
456
+ }
457
+
458
+ // ── Comments ───────────────────────────────────────────────────────────────
459
+ const knownCommentKeys = new Set<string>(COMMENT_FIELDS)
460
+ for (const ticket of tickets) {
461
+ const dir = join(ticket.absolute, "..", COMMENTS_DIR)
462
+ if (!existsSync(dir)) continue
463
+ for (const entry of readdirSync(dir)) {
464
+ if (!entry.endsWith(".md") || entry.startsWith(".")) continue
465
+ files++
466
+ const file = posixJoin(ticket.file.slice(0, -TICKET_FILE.length - 1), COMMENTS_DIR, entry)
467
+ const text = readFileSync(join(dir, entry), "utf8")
468
+ let parsed
469
+ try {
470
+ parsed = parseComment(text, file)
471
+ } catch (error) {
472
+ if (!(error instanceof TicketParseError)) throw error
473
+ push(file, reason(error, file))
474
+ continue
475
+ }
476
+ if (!isValidTimestamp(parsed.createdAt)) push(file, "created is not a valid timestamp")
477
+ let unknownKeys = false
478
+ for (const key of frontmatterKeys(text) ?? []) {
479
+ if (knownCommentKeys.has(key)) continue
480
+ unknownKeys = true
481
+ push(file, `unknown frontmatter key "${key}"`)
482
+ }
483
+ // A comment's serialization carries only the known fields, so rewriting
484
+ // one with unknown keys would delete them — skip the drift fix there.
485
+ const canonical = serializeComment(parsed)
486
+ if (canonical !== text && !unknownKeys) {
487
+ push(file, "not in canonical form — `task check --fix` rewrites it", true)
488
+ if (fix) {
489
+ writeFileSync(join(dir, entry), canonical)
490
+ fixed.push(file)
491
+ }
492
+ }
493
+ }
494
+ }
495
+
496
+ return files
497
+ }