@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
@@ -0,0 +1,271 @@
1
+ /**
2
+ * `task check` exists for the write paths the CLI never sees — git merges and
3
+ * hand-edits — so its tests are those: files written slightly wrong, links
4
+ * whose target one branch deleted, tickets a merge duplicated into the board
5
+ * *and* the archive. Plus the sharp edge fixed alongside it: unknown
6
+ * frontmatter keys now survive a rewrite instead of being dropped.
7
+ */
8
+
9
+ import assert from "node:assert/strict"
10
+ import { test } from "node:test"
11
+ import { mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"
12
+ import { tmpdir } from "node:os"
13
+ import { join } from "node:path"
14
+ import { checkBoards } from "./check.ts"
15
+ import { initProject } from "./file-store.ts"
16
+ import { parseTicket, serializeTicket } from "./ticket-doc.ts"
17
+
18
+ function tempRoot(): string {
19
+ const dir = mkdtempSync(join(tmpdir(), "task-check-"))
20
+ process.on("exit", () => rmSync(dir, { recursive: true, force: true }))
21
+ return dir
22
+ }
23
+
24
+ test("a board the CLI wrote is clean", () => {
25
+ const root = tempRoot()
26
+ const store = initProject(root, { name: "clean", prefix: "CLN" })
27
+ store.createGoal({ title: "Ship it", description: "the why" })
28
+ const a = store.create({ title: "First", description: "with a body", goal: "ship-it" })
29
+ const b = store.create({ title: "Second", status: "backlog", tags: ["x"] })
30
+ store.link(b.number, "blocked_by", a.number)
31
+ store.addComment(a.number, "hello", "claude")
32
+ store.update(a.number, { status: "done" })
33
+ store.archive(a.number)
34
+
35
+ const result = checkBoards(root, false)
36
+ assert.deepEqual(result.issues, [])
37
+ assert.equal(result.boards, 1)
38
+ assert.equal(result.files, 4) // two tickets + one comment + one goal
39
+ })
40
+
41
+ test("finds what merges and hand-edits leave behind, and --fix repairs the mechanical part", () => {
42
+ const root = tempRoot()
43
+ const store = initProject(root, { name: "rough", prefix: "RGH" })
44
+ const a = store.create({ title: "Kept", status: "todo" })
45
+ const b = store.create({ title: "Points at a ghost", status: "todo" })
46
+ store.create({ title: "Broken later", status: "todo" })
47
+
48
+ // A dangling reference: link b to a ticket, then lose the ticket the way a
49
+ // merge would — the directory vanishes without the store's delete cascade.
50
+ store.link(b.number, "blocked_by", a.number)
51
+ const ticketPath = (n: number) => join(root, ".task", "tickets", String(n), "ticket.md")
52
+ const bText = readFileSync(ticketPath(b.number), "utf8")
53
+ writeFileSync(ticketPath(b.number), bText.replace("blocked_by: [1]", "blocked_by: [1, 99, 99]"))
54
+
55
+ // A hand-edit that still parses but isn't canonical (bare goal string).
56
+ store.createGoal({ title: "Launch" })
57
+ const cText = readFileSync(ticketPath(3), "utf8")
58
+ writeFileSync(ticketPath(3), cText.replace("status: todo", "status: todo\ngoal: launch"))
59
+
60
+ // A file that doesn't parse at all.
61
+ mkdirSync(join(root, ".task", "tickets", "4"))
62
+ writeFileSync(ticketPath(4), "---\nstatus: todoo\nposition: 0\ncreated: x\nupdated: x\n---\n\n# Bad status\n")
63
+
64
+ const found = checkBoards(root, false)
65
+ const messages = found.issues.map((issue) => issue.message)
66
+ assert.ok(messages.some((m) => /RGH-99, which doesn't exist/.test(m)), "dangling ref reported")
67
+ assert.ok(messages.some((m) => /not in canonical form/.test(m)), "drift reported")
68
+ assert.ok(messages.some((m) => /invalid status "todoo"/.test(m)), "parse error reported")
69
+ assert.ok(messages.some((m) => /not a valid timestamp/.test(m)) === false, "unparseable file isn't double-reported")
70
+
71
+ const fixed = checkBoards(root, true)
72
+ assert.ok(fixed.fixed.length >= 2, "fix rewrote the repairable files")
73
+ // The dangling ref is pruned, the real one kept; the drift is normalized.
74
+ assert.match(readFileSync(ticketPath(b.number), "utf8"), /blocked_by: \[1\]\n/)
75
+ assert.match(readFileSync(ticketPath(3), "utf8"), /goal: "launch"/)
76
+
77
+ // What remains is exactly what needs a human: the unparseable file.
78
+ const after = checkBoards(root, false)
79
+ assert.deepEqual(
80
+ after.issues.filter((issue) => issue.fixable),
81
+ [],
82
+ )
83
+ assert.equal(after.issues.length, 1)
84
+ assert.match(after.issues[0].message, /invalid status/)
85
+ })
86
+
87
+ test("merge artifacts across the archive are reported, never auto-fixed", () => {
88
+ const root = tempRoot()
89
+ const store = initProject(root, { name: "dup", prefix: "DUP" })
90
+ const t = store.create({ title: "Archived on one branch" })
91
+ store.update(t.number, { status: "done" })
92
+ store.archive(t.number)
93
+ // The other branch kept editing it — the merge keeps both directories.
94
+ mkdirSync(join(root, ".task", "tickets", String(t.number)), { recursive: true })
95
+ writeFileSync(
96
+ join(root, ".task", "tickets", String(t.number), "ticket.md"),
97
+ readFileSync(join(root, ".task", "archive", String(t.number), "ticket.md")),
98
+ )
99
+
100
+ const result = checkBoards(root, true)
101
+ assert.ok(result.issues.some((issue) => /also exists in tickets\//.test(issue.message)))
102
+ assert.deepEqual(result.fixed, [])
103
+ })
104
+
105
+ test("a ticket parked in the archive while still open is flagged", () => {
106
+ const root = tempRoot()
107
+ const store = initProject(root, { name: "arc", prefix: "ARC" })
108
+ const t = store.create({ title: "Half archived", status: "todo" })
109
+ renameSync(
110
+ join(root, ".task", "tickets", String(t.number)),
111
+ (mkdirSync(join(root, ".task", "archive"), { recursive: true }),
112
+ join(root, ".task", "archive", String(t.number))),
113
+ )
114
+ const result = checkBoards(root, false)
115
+ assert.ok(result.issues.some((issue) => /archived but still todo/.test(issue.message)))
116
+ })
117
+
118
+ test("unknown frontmatter keys are reported — and survive a rewrite round-trip", () => {
119
+ const root = tempRoot()
120
+ const store = initProject(root, { name: "extra", prefix: "EXT" })
121
+ const t = store.create({ title: "Carries a custom field", status: "todo" })
122
+ const path = join(root, ".task", "tickets", String(t.number), "ticket.md")
123
+ writeFileSync(
124
+ path,
125
+ readFileSync(path, "utf8").replace("status: todo", 'status: todo\nowner: "nick"'),
126
+ )
127
+
128
+ const result = checkBoards(root, false)
129
+ assert.ok(result.issues.some((issue) => /unknown frontmatter key "owner"/.test(issue.message)))
130
+
131
+ // The sharp edge this fixed: an ordinary update used to drop the key.
132
+ store.update(t.number, { title: "Renamed" })
133
+ const text = readFileSync(path, "utf8")
134
+ assert.match(text, /owner: "nick"/)
135
+ assert.match(text, /# Renamed/)
136
+ // And the parse carries it explicitly.
137
+ assert.deepEqual(parseTicket(text, "test").extras, [["owner", '"nick"']])
138
+ // Round-trip is bytes-stable, so check won't flag the rewritten file.
139
+ assert.equal(serializeTicket(parseTicket(text, "test")), text)
140
+ assert.deepEqual(checkBoards(root, false).issues.map((issue) => issue.message), [
141
+ 'unknown frontmatter key "owner" — preserved on rewrite, but nothing reads it',
142
+ ])
143
+ })
144
+
145
+ test("the pre-0.8 milestone key is flagged by name, and --fix is the migration", () => {
146
+ const root = tempRoot()
147
+ const store = initProject(root, { name: "old", prefix: "OLD" })
148
+ const t = store.create({ title: "From an old board", status: "todo" })
149
+ const path = join(root, ".task", "tickets", String(t.number), "ticket.md")
150
+ writeFileSync(
151
+ path,
152
+ readFileSync(path, "utf8").replace("status: todo", 'status: todo\nmilestone: "launch"'),
153
+ )
154
+
155
+ const found = checkBoards(root, false)
156
+ const migration = found.issues.find((issue) => /`milestone:` is now `goal:`/.test(issue.message))
157
+ assert.ok(migration?.fixable, "migration flagged as fixable")
158
+ // The old key parses as the goal, so the ref lint also fires until the goal
159
+ // file exists — stated with both ways out, never auto-pruned.
160
+ assert.ok(
161
+ found.issues.some((issue) => /goal "launch" doesn't exist/.test(issue.message) && !issue.fixable),
162
+ )
163
+
164
+ store.createGoal({ title: "Launch" })
165
+ checkBoards(root, true)
166
+ const text = readFileSync(path, "utf8")
167
+ assert.match(text, /goal: "launch"/)
168
+ assert.doesNotMatch(text, /milestone/)
169
+ assert.deepEqual(checkBoards(root, false).issues, [])
170
+ })
171
+
172
+ test("goal files are linted, and drift in them is fixable", () => {
173
+ const root = tempRoot()
174
+ const store = initProject(root, { name: "gol", prefix: "GOL" })
175
+ store.createGoal({ title: "Tidy", description: "fine" })
176
+ // A hand-dropped goal: parseable, bad timestamp, non-canonical spacing, and
177
+ // a slug the CLI would never mint.
178
+ writeFileSync(
179
+ join(root, ".task", "goals", "Bad Slug.md"),
180
+ "---\ncreated: not-a-date\nupdated: 2026-08-15T17:00:00.000Z\n---\n# Handmade\n\nbody\n",
181
+ )
182
+
183
+ const result = checkBoards(root, false)
184
+ const messages = result.issues.map((issue) => issue.message)
185
+ assert.ok(messages.some((m) => /not a valid goal slug/.test(m)))
186
+ assert.ok(messages.some((m) => /created is not a valid timestamp/.test(m)))
187
+ assert.ok(result.issues.some((issue) => issue.fixable), "spacing drift is fixable")
188
+
189
+ checkBoards(root, true)
190
+ assert.match(
191
+ readFileSync(join(root, ".task", "goals", "Bad Slug.md"), "utf8"),
192
+ /---\n\n# Handmade/,
193
+ )
194
+ })
195
+
196
+ test("comment files are linted too", () => {
197
+ const root = tempRoot()
198
+ const store = initProject(root, { name: "com", prefix: "COM" })
199
+ const t = store.create({ title: "Talked about" })
200
+ store.addComment(t.number, "fine", "claude")
201
+ const dir = join(root, ".task", "tickets", String(t.number), "comments")
202
+ // A hand-dropped comment: parseable, wrong created, non-canonical spacing.
203
+ writeFileSync(join(dir, "2026-08-15T170000-other.md"), '---\nauthor: "other"\ncreated: not-a-date\n---\nbody\n')
204
+
205
+ const result = checkBoards(root, false)
206
+ assert.ok(result.issues.some((issue) => /created is not a valid timestamp/.test(issue.message)))
207
+ })
208
+
209
+ test("config lints: legacy claimPrefix migrates, boards is root-only, prefixes must agree", () => {
210
+ const root = tempRoot()
211
+ initProject(root, { name: "cfg", prefix: "CFG" })
212
+ const rootConfig = join(root, ".task", "config.json")
213
+ const raw = JSON.parse(readFileSync(rootConfig, "utf8")) as Record<string, unknown>
214
+ raw.claimPrefix = "claude/task/"
215
+ raw.boards = ["CFG", "SUB"]
216
+ writeFileSync(rootConfig, `${JSON.stringify(raw, null, 2)}\n`)
217
+
218
+ // A nested board that disagrees on the namespace and carries its own
219
+ // boards list — both are repo-level decisions the root board owns.
220
+ mkdirSync(join(root, "pkg"), { recursive: true })
221
+ initProject(join(root, "pkg"), { name: "sub", prefix: "SUB" })
222
+ const subConfig = join(root, "pkg", ".task", "config.json")
223
+ const subRaw = JSON.parse(readFileSync(subConfig, "utf8")) as Record<string, unknown>
224
+ subRaw.boards = ["SUB"]
225
+ subRaw.claims = { branchPrefix: "task/claim/" }
226
+ writeFileSync(subConfig, `${JSON.stringify(subRaw, null, 2)}\n`)
227
+
228
+ const found = checkBoards(root, false)
229
+ const messages = found.issues.map((issue) => issue.message)
230
+ assert.ok(messages.some((m) => /`claimPrefix` is now `claims\.branchPrefix`/.test(m)))
231
+ assert.ok(messages.some((m) => /"boards" is root-only/.test(m)))
232
+ assert.ok(messages.some((m) => /disagrees with the root board's "claude\/task\/"/.test(m)))
233
+
234
+ // --fix migrates the legacy key in place; the human decisions remain.
235
+ const fixed = checkBoards(root, true)
236
+ assert.ok(fixed.fixed.includes(".task/config.json"))
237
+ const migrated = JSON.parse(readFileSync(rootConfig, "utf8")) as {
238
+ claimPrefix?: string
239
+ claims?: { branchPrefix?: string }
240
+ boards?: string[]
241
+ prefix: string
242
+ }
243
+ assert.equal(migrated.claimPrefix, undefined)
244
+ assert.equal(migrated.claims?.branchPrefix, "claude/task/")
245
+ assert.deepEqual(migrated.boards, ["CFG", "SUB"])
246
+ assert.equal(migrated.prefix, "CFG")
247
+
248
+ const remaining = checkBoards(root, false).issues.map((issue) => issue.message)
249
+ assert.ok(!remaining.some((m) => /`claimPrefix` is now/.test(m)), "nag gone after migration")
250
+ assert.ok(remaining.some((m) => /"boards" is root-only/.test(m)), "root-only lint stays")
251
+ assert.ok(remaining.some((m) => /disagrees with the root board's/.test(m)), "mismatch stays")
252
+ })
253
+
254
+ test("an existing claims.branchPrefix wins over the legacy key on migration", () => {
255
+ const root = tempRoot()
256
+ initProject(root, { name: "dup", prefix: "DUP" })
257
+ const configPath = join(root, ".task", "config.json")
258
+ const raw = JSON.parse(readFileSync(configPath, "utf8")) as Record<string, unknown>
259
+ raw.claimPrefix = "old/ns/"
260
+ raw.claims = { branchPrefix: "new/ns/", maxOpenCount: 2 }
261
+ writeFileSync(configPath, `${JSON.stringify(raw, null, 2)}\n`)
262
+
263
+ checkBoards(root, true)
264
+ const migrated = JSON.parse(readFileSync(configPath, "utf8")) as {
265
+ claimPrefix?: string
266
+ claims?: { branchPrefix?: string; maxOpenCount?: number }
267
+ }
268
+ assert.equal(migrated.claimPrefix, undefined)
269
+ assert.equal(migrated.claims?.branchPrefix, "new/ns/")
270
+ assert.equal(migrated.claims?.maxOpenCount, 2)
271
+ })