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