@nickmeriano/task 0.8.0 → 0.10.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 (115) hide show
  1. package/README.md +137 -22
  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 +3 -2
  7. package/dist/check.d.ts.map +1 -1
  8. package/dist/check.js +99 -60
  9. package/dist/check.js.map +1 -1
  10. package/dist/check.test.js +99 -60
  11. package/dist/check.test.js.map +1 -1
  12. package/dist/claim-io.d.ts +73 -0
  13. package/dist/claim-io.d.ts.map +1 -0
  14. package/dist/claim-io.js +344 -0
  15. package/dist/claim-io.js.map +1 -0
  16. package/dist/claim.d.ts +108 -23
  17. package/dist/claim.d.ts.map +1 -1
  18. package/dist/claim.js +320 -86
  19. package/dist/claim.js.map +1 -1
  20. package/dist/claim.test.d.ts +10 -2
  21. package/dist/claim.test.d.ts.map +1 -1
  22. package/dist/claim.test.js +282 -96
  23. package/dist/claim.test.js.map +1 -1
  24. package/dist/cli.js +346 -68
  25. package/dist/cli.js.map +1 -1
  26. package/dist/file-store.d.ts +70 -24
  27. package/dist/file-store.d.ts.map +1 -1
  28. package/dist/file-store.js +319 -141
  29. package/dist/file-store.js.map +1 -1
  30. package/dist/git-serve.d.ts +183 -0
  31. package/dist/git-serve.d.ts.map +1 -0
  32. package/dist/git-serve.js +503 -0
  33. package/dist/git-serve.js.map +1 -0
  34. package/dist/git-serve.test.d.ts +16 -0
  35. package/dist/git-serve.test.d.ts.map +1 -0
  36. package/dist/git-serve.test.js +183 -0
  37. package/dist/git-serve.test.js.map +1 -0
  38. package/dist/git.d.ts +65 -0
  39. package/dist/git.d.ts.map +1 -0
  40. package/dist/git.js +114 -0
  41. package/dist/git.js.map +1 -0
  42. package/dist/id.d.ts +39 -0
  43. package/dist/id.d.ts.map +1 -0
  44. package/dist/id.js +67 -0
  45. package/dist/id.js.map +1 -0
  46. package/dist/inbox.d.ts +41 -0
  47. package/dist/inbox.d.ts.map +1 -0
  48. package/dist/inbox.js +56 -0
  49. package/dist/inbox.js.map +1 -0
  50. package/dist/overview.d.ts +9 -0
  51. package/dist/overview.d.ts.map +1 -1
  52. package/dist/overview.js +19 -11
  53. package/dist/overview.js.map +1 -1
  54. package/dist/overview.test.js +6 -5
  55. package/dist/overview.test.js.map +1 -1
  56. package/dist/promote.test.d.ts +3 -2
  57. package/dist/promote.test.d.ts.map +1 -1
  58. package/dist/promote.test.js +29 -31
  59. package/dist/promote.test.js.map +1 -1
  60. package/dist/publish.d.ts +2 -17
  61. package/dist/publish.d.ts.map +1 -1
  62. package/dist/publish.js +4 -49
  63. package/dist/publish.js.map +1 -1
  64. package/dist/search.d.ts +3 -1
  65. package/dist/search.d.ts.map +1 -1
  66. package/dist/search.js +15 -2
  67. package/dist/search.js.map +1 -1
  68. package/dist/search.test.js +9 -9
  69. package/dist/search.test.js.map +1 -1
  70. package/dist/server.d.ts.map +1 -1
  71. package/dist/server.js +218 -21
  72. package/dist/server.js.map +1 -1
  73. package/dist/store.d.ts +28 -14
  74. package/dist/store.d.ts.map +1 -1
  75. package/dist/store.js.map +1 -1
  76. package/dist/store.test.js +88 -75
  77. package/dist/store.test.js.map +1 -1
  78. package/dist/ticket-doc.d.ts +26 -10
  79. package/dist/ticket-doc.d.ts.map +1 -1
  80. package/dist/ticket-doc.js +71 -27
  81. package/dist/ticket-doc.js.map +1 -1
  82. package/dist/types.d.ts +51 -17
  83. package/dist/types.d.ts.map +1 -1
  84. package/dist/types.js.map +1 -1
  85. package/package.json +1 -1
  86. package/skill/SKILL.md +96 -24
  87. package/src/asks.test.ts +355 -0
  88. package/src/check.test.ts +129 -72
  89. package/src/check.ts +101 -58
  90. package/src/claim-io.ts +401 -0
  91. package/src/claim.test.ts +372 -101
  92. package/src/claim.ts +418 -99
  93. package/src/cli.ts +370 -67
  94. package/src/file-store.ts +357 -145
  95. package/src/git-serve.test.ts +240 -0
  96. package/src/git-serve.ts +595 -0
  97. package/src/git.ts +141 -0
  98. package/src/id.ts +68 -0
  99. package/src/inbox.ts +77 -0
  100. package/src/overview.test.ts +6 -5
  101. package/src/overview.ts +25 -10
  102. package/src/promote.test.ts +40 -28
  103. package/src/publish.ts +6 -53
  104. package/src/search.test.ts +11 -11
  105. package/src/search.ts +17 -3
  106. package/src/server.ts +221 -19
  107. package/src/store.test.ts +95 -77
  108. package/src/store.ts +28 -13
  109. package/src/ticket-doc.ts +93 -30
  110. package/src/types.ts +52 -17
  111. package/ui/dist/assets/index-BjsorZOU.js +229 -0
  112. package/ui/dist/assets/index-CoKCUYic.css +1 -0
  113. package/ui/dist/index.html +2 -2
  114. package/ui/dist/assets/index-BJmOsOdR.js +0 -229
  115. package/ui/dist/assets/index-BoqQlqSU.css +0 -1
package/src/check.test.ts CHANGED
@@ -27,10 +27,10 @@ test("a board the CLI wrote is clean", () => {
27
27
  store.createGoal({ title: "Ship it", description: "the why" })
28
28
  const a = store.create({ title: "First", description: "with a body", goal: "ship-it" })
29
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)
30
+ store.link(b.key, "blocked_by", a.key)
31
+ store.addComment(a.key, "hello", "claude")
32
+ store.update(a.key, { status: "done" })
33
+ store.archive(a.key)
34
34
 
35
35
  const result = checkBoards(root, false)
36
36
  assert.deepEqual(result.issues, [])
@@ -38,32 +38,70 @@ test("a board the CLI wrote is clean", () => {
38
38
  assert.equal(result.files, 4) // two tickets + one comment + one goal
39
39
  })
40
40
 
41
+ test("ask files are linted like comments; the retired flag reads as an unknown key", () => {
42
+ const root = tempRoot()
43
+ const store = initProject(root, { name: "owed", prefix: "OWE" })
44
+ const t = store.create({ title: "Owes things", status: "in_progress" })
45
+ store.addAsk(t.key, "Mint the token", "claude")
46
+ assert.deepEqual(checkBoards(root, false).issues, [], "a CLI-written ask is clean")
47
+
48
+ const asksDir = join(root, ".task", "tickets", t.key, "asks")
49
+ // Hand-edit shapes: an ask that says nothing, a resolution that isn't a
50
+ // timestamp, a key nothing reads, and mere drift (unquoted author).
51
+ writeFileSync(join(asksDir, "z-empty.md"), '---\nauthor: "x"\ncreated: 2026-08-27T00:00:00.000Z\n---\n\n\n')
52
+ writeFileSync(
53
+ join(asksDir, "z-bad.md"),
54
+ '---\nauthor: "x"\ncreated: 2026-08-27T00:00:00.000Z\nresolved: yesterday-ish\nurgency: high\n---\n\nDo the thing\n',
55
+ )
56
+ const found = checkBoards(root, false)
57
+ const messages = found.issues.map((issue) => issue.message)
58
+ assert.ok(messages.some((m) => /empty ask/.test(m)), "empty ask reported")
59
+ assert.ok(messages.some((m) => /resolved is not a valid timestamp/.test(m)), "bad resolution reported")
60
+ assert.ok(messages.some((m) => /unknown frontmatter key "urgency"/.test(m)), "unknown key reported")
61
+
62
+ // The retired pre-0.8 boolean isn't a field anymore — a file still carrying
63
+ // it (deep history, an ancient branch) reads as an unknown key: flagged,
64
+ // preserved through rewrites, never interpreted.
65
+ const ticketPath = join(root, ".task", "tickets", t.key, "ticket.md")
66
+ writeFileSync(
67
+ ticketPath,
68
+ readFileSync(ticketPath, "utf8").replace("status: in_progress", "status: in_progress\nneeds_human: true"),
69
+ )
70
+ const flagged = checkBoards(root, true)
71
+ const unknown = flagged.issues.find((issue) => /unknown frontmatter key "needs_human"/.test(issue.message))
72
+ assert.ok(unknown, "retired key flagged as unknown")
73
+ assert.match(readFileSync(ticketPath, "utf8"), /needs_human: true/)
74
+ })
75
+
41
76
  test("finds what merges and hand-edits leave behind, and --fix repairs the mechanical part", () => {
42
77
  const root = tempRoot()
43
78
  const store = initProject(root, { name: "rough", prefix: "RGH" })
44
79
  const a = store.create({ title: "Kept", status: "todo" })
45
80
  const b = store.create({ title: "Points at a ghost", status: "todo" })
46
- store.create({ title: "Broken later", status: "todo" })
81
+ const c = store.create({ title: "Broken later", status: "todo" })
47
82
 
48
83
  // A dangling reference: link b to a ticket, then lose the ticket the way a
49
84
  // 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]"))
85
+ store.link(b.key, "blocked_by", a.key)
86
+ const ticketPath = (key: string) => join(root, ".task", "tickets", key, "ticket.md")
87
+ const bText = readFileSync(ticketPath(b.key), "utf8")
88
+ writeFileSync(
89
+ ticketPath(b.key),
90
+ bText.replace(`blocked_by: ["${a.key}"]`, `blocked_by: ["${a.key}", "zz9zz", "zz9zz"]`),
91
+ )
54
92
 
55
93
  // A hand-edit that still parses but isn't canonical (bare goal string).
56
94
  store.createGoal({ title: "Launch" })
57
- const cText = readFileSync(ticketPath(3), "utf8")
58
- writeFileSync(ticketPath(3), cText.replace("status: todo", "status: todo\ngoal: launch"))
95
+ const cText = readFileSync(ticketPath(c.key), "utf8")
96
+ writeFileSync(ticketPath(c.key), cText.replace("status: todo", "status: todo\ngoal: launch"))
59
97
 
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")
98
+ // A file that doesn't parse at all, in a well-formed key directory.
99
+ mkdirSync(join(root, ".task", "tickets", "zzz9x"))
100
+ writeFileSync(ticketPath("zzz9x"), "---\nstatus: todoo\nposition: 0\ncreated: x\nupdated: x\n---\n\n# Bad status\n")
63
101
 
64
102
  const found = checkBoards(root, false)
65
103
  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")
104
+ assert.ok(messages.some((m) => /RGH-zz9zz, which doesn't exist/.test(m)), "dangling ref reported")
67
105
  assert.ok(messages.some((m) => /not in canonical form/.test(m)), "drift reported")
68
106
  assert.ok(messages.some((m) => /invalid status "todoo"/.test(m)), "parse error reported")
69
107
  assert.ok(messages.some((m) => /not a valid timestamp/.test(m)) === false, "unparseable file isn't double-reported")
@@ -71,8 +109,8 @@ test("finds what merges and hand-edits leave behind, and --fix repairs the mecha
71
109
  const fixed = checkBoards(root, true)
72
110
  assert.ok(fixed.fixed.length >= 2, "fix rewrote the repairable files")
73
111
  // 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"/)
112
+ assert.ok(readFileSync(ticketPath(b.key), "utf8").includes(`blocked_by: ["${a.key}"]\n`))
113
+ assert.match(readFileSync(ticketPath(c.key), "utf8"), /goal: "launch"/)
76
114
 
77
115
  // What remains is exactly what needs a human: the unparseable file.
78
116
  const after = checkBoards(root, false)
@@ -88,13 +126,13 @@ test("merge artifacts across the archive are reported, never auto-fixed", () =>
88
126
  const root = tempRoot()
89
127
  const store = initProject(root, { name: "dup", prefix: "DUP" })
90
128
  const t = store.create({ title: "Archived on one branch" })
91
- store.update(t.number, { status: "done" })
92
- store.archive(t.number)
129
+ store.update(t.key, { status: "done" })
130
+ store.archive(t.key)
93
131
  // The other branch kept editing it — the merge keeps both directories.
94
- mkdirSync(join(root, ".task", "tickets", String(t.number)), { recursive: true })
132
+ mkdirSync(join(root, ".task", "tickets", t.key), { recursive: true })
95
133
  writeFileSync(
96
- join(root, ".task", "tickets", String(t.number), "ticket.md"),
97
- readFileSync(join(root, ".task", "archive", String(t.number), "ticket.md")),
134
+ join(root, ".task", "tickets", t.key, "ticket.md"),
135
+ readFileSync(join(root, ".task", "archive", t.key, "ticket.md")),
98
136
  )
99
137
 
100
138
  const result = checkBoards(root, true)
@@ -107,9 +145,9 @@ test("a ticket parked in the archive while still open is flagged", () => {
107
145
  const store = initProject(root, { name: "arc", prefix: "ARC" })
108
146
  const t = store.create({ title: "Half archived", status: "todo" })
109
147
  renameSync(
110
- join(root, ".task", "tickets", String(t.number)),
148
+ join(root, ".task", "tickets", t.key),
111
149
  (mkdirSync(join(root, ".task", "archive"), { recursive: true }),
112
- join(root, ".task", "archive", String(t.number))),
150
+ join(root, ".task", "archive", t.key)),
113
151
  )
114
152
  const result = checkBoards(root, false)
115
153
  assert.ok(result.issues.some((issue) => /archived but still todo/.test(issue.message)))
@@ -119,7 +157,7 @@ test("unknown frontmatter keys are reported — and survive a rewrite round-trip
119
157
  const root = tempRoot()
120
158
  const store = initProject(root, { name: "extra", prefix: "EXT" })
121
159
  const t = store.create({ title: "Carries a custom field", status: "todo" })
122
- const path = join(root, ".task", "tickets", String(t.number), "ticket.md")
160
+ const path = join(root, ".task", "tickets", t.key, "ticket.md")
123
161
  writeFileSync(
124
162
  path,
125
163
  readFileSync(path, "utf8").replace("status: todo", 'status: todo\nowner: "nick"'),
@@ -129,7 +167,7 @@ test("unknown frontmatter keys are reported — and survive a rewrite round-trip
129
167
  assert.ok(result.issues.some((issue) => /unknown frontmatter key "owner"/.test(issue.message)))
130
168
 
131
169
  // The sharp edge this fixed: an ordinary update used to drop the key.
132
- store.update(t.number, { title: "Renamed" })
170
+ store.update(t.key, { title: "Renamed" })
133
171
  const text = readFileSync(path, "utf8")
134
172
  assert.match(text, /owner: "nick"/)
135
173
  assert.match(text, /# Renamed/)
@@ -142,30 +180,30 @@ test("unknown frontmatter keys are reported — and survive a rewrite round-trip
142
180
  ])
143
181
  })
144
182
 
145
- test("the pre-0.8 milestone key is flagged by name, and --fix is the migration", () => {
183
+ test("the pre-0.8 milestone key is refused by name no migration machinery", () => {
146
184
  const root = tempRoot()
147
185
  const store = initProject(root, { name: "old", prefix: "OLD" })
148
186
  const t = store.create({ title: "From an old board", status: "todo" })
149
- const path = join(root, ".task", "tickets", String(t.number), "ticket.md")
187
+ const path = join(root, ".task", "tickets", t.key, "ticket.md")
150
188
  writeFileSync(
151
189
  path,
152
190
  readFileSync(path, "utf8").replace("status: todo", 'status: todo\nmilestone: "launch"'),
153
191
  )
154
192
 
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),
193
+ // The strict parse refuses the retired spelling with the rename in the
194
+ // error; check surfaces exactly that, unfixable, and --fix leaves the file
195
+ // alone renaming the key is the human's one edit.
196
+ const found = checkBoards(root, true)
197
+ const refusal = found.issues.find((issue) =>
198
+ /`milestone:` is the pre-0\.8 spelling.*rename the key to `goal:`/.test(issue.message),
162
199
  )
200
+ assert.ok(refusal, "retired spelling refused by name")
201
+ assert.equal(refusal.fixable, false)
202
+ assert.match(readFileSync(path, "utf8"), /milestone: "launch"/)
163
203
 
204
+ writeFileSync(path, readFileSync(path, "utf8").replace('milestone: "launch"', 'goal: "launch"'))
164
205
  store.createGoal({ title: "Launch" })
165
206
  checkBoards(root, true)
166
- const text = readFileSync(path, "utf8")
167
- assert.match(text, /goal: "launch"/)
168
- assert.doesNotMatch(text, /milestone/)
169
207
  assert.deepEqual(checkBoards(root, false).issues, [])
170
208
  })
171
209
 
@@ -197,8 +235,8 @@ test("comment files are linted too", () => {
197
235
  const root = tempRoot()
198
236
  const store = initProject(root, { name: "com", prefix: "COM" })
199
237
  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")
238
+ store.addComment(t.key, "fine", "claude")
239
+ const dir = join(root, ".task", "tickets", t.key, "comments")
202
240
  // A hand-dropped comment: parseable, wrong created, non-canonical spacing.
203
241
  writeFileSync(join(dir, "2026-08-15T170000-other.md"), '---\nauthor: "other"\ncreated: not-a-date\n---\nbody\n')
204
242
 
@@ -206,7 +244,7 @@ test("comment files are linted too", () => {
206
244
  assert.ok(result.issues.some((issue) => /created is not a valid timestamp/.test(issue.message)))
207
245
  })
208
246
 
209
- test("config lints: legacy claimPrefix migrates, boards is root-only, prefixes must agree", () => {
247
+ test("config lints: retired claimPrefix refuses, boards is root-only, prefixes must agree", () => {
210
248
  const root = tempRoot()
211
249
  initProject(root, { name: "cfg", prefix: "CFG" })
212
250
  const rootConfig = join(root, ".task", "config.json")
@@ -225,33 +263,21 @@ test("config lints: legacy claimPrefix migrates, boards is root-only, prefixes m
225
263
  subRaw.claims = { branchPrefix: "task/claim/" }
226
264
  writeFileSync(subConfig, `${JSON.stringify(subRaw, null, 2)}\n`)
227
265
 
228
- const found = checkBoards(root, false)
266
+ // The retired key with no claims.branchPrefix beside it: claimNamespace
267
+ // refuses by name, check surfaces that refusal, and --fix touches nothing —
268
+ // renaming the key is the human's one edit. The root's namespace being
269
+ // unreadable also means the sub-board mismatch lint has no reference to
270
+ // compare against; the root-only boards lint still fires.
271
+ const found = checkBoards(root, true)
229
272
  const messages = found.issues.map((issue) => issue.message)
230
- assert.ok(messages.some((m) => /`claimPrefix` is now `claims\.branchPrefix`/.test(m)))
273
+ assert.ok(messages.some((m) => /`claimPrefix` in \.task\/config\.json is the pre-0\.8 spelling/.test(m)))
231
274
  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")
275
+ assert.ok(found.issues.every((issue) => !issue.fixable || !/claimPrefix/.test(issue.message)))
276
+ const untouched = JSON.parse(readFileSync(rootConfig, "utf8")) as { claimPrefix?: string }
277
+ assert.equal(untouched.claimPrefix, "claude/task/")
252
278
  })
253
279
 
254
- test("an existing claims.branchPrefix wins over the legacy key on migration", () => {
280
+ test("a stray claimPrefix beside claims.branchPrefix is inert and flagged", () => {
255
281
  const root = tempRoot()
256
282
  initProject(root, { name: "dup", prefix: "DUP" })
257
283
  const configPath = join(root, ".task", "config.json")
@@ -260,12 +286,43 @@ test("an existing claims.branchPrefix wins over the legacy key on migration", ()
260
286
  raw.claims = { branchPrefix: "new/ns/", maxOpenCount: 2 }
261
287
  writeFileSync(configPath, `${JSON.stringify(raw, null, 2)}\n`)
262
288
 
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)
289
+ const messages = checkBoards(root, false).issues.map((issue) => issue.message)
290
+ assert.ok(messages.some((m) => /`claims\.branchPrefix` wins; delete the leftover key/.test(m)))
291
+ })
292
+
293
+ test("numeric ticket dirs are called out as pre-TAS-42 — junk entries separately", () => {
294
+ const root = tempRoot()
295
+ const store = initProject(root, { name: "mig", prefix: "MIG" })
296
+ store.create({ title: "A modern ticket", status: "todo" })
297
+
298
+ // A pre-migration ticket, imported by merging a branch cut before the id
299
+ // cutover: numeric directory, numeric blocked_by.
300
+ mkdirSync(join(root, ".task", "tickets", "42"), { recursive: true })
301
+ writeFileSync(
302
+ join(root, ".task", "tickets", "42", "ticket.md"),
303
+ "---\nstatus: todo\nposition: 0\ncreated: 2026-08-15T16:00:33.758Z\nupdated: 2026-08-15T16:00:33.758Z\n---\n\n# Old-format ticket\n",
304
+ )
305
+ // And something that was never a ticket directory at all.
306
+ mkdirSync(join(root, ".task", "tickets", "Not A Key"), { recursive: true })
307
+
308
+ const messages = checkBoards(root, false).issues.map((issue) => issue.message)
309
+ assert.ok(
310
+ messages.some((m) => /numeric ticket directory — the pre-TAS-42 sequential-id format/.test(m)),
311
+ "pre-migration dir named with the migration fix",
312
+ )
313
+ assert.ok(
314
+ messages.some((m) => /ticket directories are named by their key/.test(m)),
315
+ "junk entry reported as unexpected",
316
+ )
317
+
318
+ // A modern file whose blocked_by still carries numbers is refused by the
319
+ // strict parser with the migration named — surfaced through check.
320
+ const modern = store.create({ title: "Bad link format", status: "todo" })
321
+ const path = join(root, ".task", "tickets", modern.key, "ticket.md")
322
+ writeFileSync(
323
+ path,
324
+ readFileSync(path, "utf8").replace("status: todo", "status: todo\nblocked_by: [7]"),
325
+ )
326
+ const after = checkBoards(root, false).issues.map((issue) => issue.message)
327
+ assert.ok(after.some((m) => /pre-TAS-42 id format, which is no longer read/.test(m)))
271
328
  })
package/src/check.ts CHANGED
@@ -10,8 +10,9 @@
10
10
  * references (one branch deleted a ticket another branch linked to), `goal:`
11
11
  * refs naming a goal that doesn't exist, tickets duplicated between the board
12
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).
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.
15
16
  *
16
17
  * The mechanical repairs — pruning dangling or self references, deduping and
17
18
  * sorting `blocked_by`, rewriting a file into canonical form — are applied by
@@ -24,6 +25,7 @@ import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs"
24
25
  import { join } from "node:path"
25
26
  import {
26
27
  ARCHIVE_DIR,
28
+ ASKS_DIR,
27
29
  COMMENTS_DIR,
28
30
  GOALS_ARCHIVE_DIR,
29
31
  GOALS_DIR,
@@ -31,13 +33,16 @@ import {
31
33
  TICKET_FILE,
32
34
  } from "./file-store.ts"
33
35
  import {
36
+ ASK_FIELDS,
34
37
  COMMENT_FIELDS,
35
38
  TicketParseError,
36
39
  frontmatterKeys,
37
40
  isGoalSlug,
41
+ parseAsk,
38
42
  parseComment,
39
43
  parseGoal,
40
44
  parseTicket,
45
+ serializeAsk,
41
46
  serializeComment,
42
47
  serializeGoal,
43
48
  serializeTicket,
@@ -53,6 +58,7 @@ import {
53
58
  type BoardRef,
54
59
  } from "./store.ts"
55
60
  import { claimNamespace } from "./claim.ts"
61
+ import { isTicketKey } from "./id.ts"
56
62
  import type { ProjectConfig } from "./types.ts"
57
63
 
58
64
  export interface CheckIssue {
@@ -80,7 +86,7 @@ export function checkRoot(cwd: string): string {
80
86
  }
81
87
 
82
88
  interface TicketEntry {
83
- number: number
89
+ key: string
84
90
  /** Path relative to the check root, for reporting. */
85
91
  file: string
86
92
  /** Absolute path, for fixing. */
@@ -131,20 +137,20 @@ export function checkBoards(root: string, fix: boolean): CheckResult {
131
137
  }
132
138
 
133
139
  /**
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 patternone repo, one namespace).
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.
140
148
  */
141
149
  function checkConfig(
142
150
  board: BoardRef,
143
151
  scopeRoot: string,
144
152
  rootBranchPrefix: string | null,
145
- fix: boolean,
146
153
  push: (file: string, message: string, fixable?: boolean) => void,
147
- fixed: string[],
148
154
  ): ProjectConfig | null {
149
155
  const absolute = join(board.root, TASK_DIR, CONFIG_FILE)
150
156
  const file = posixJoin(board.id, TASK_DIR, CONFIG_FILE)
@@ -157,28 +163,11 @@ function checkConfig(
157
163
  }
158
164
  const config = raw as unknown as ProjectConfig
159
165
 
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
- }
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
+ )
182
171
  }
183
172
 
184
173
  if (raw.boards !== undefined) {
@@ -230,9 +219,9 @@ function checkBoard(
230
219
  const push = (file: string, message: string, fixable = false) =>
231
220
  issues.push({ board: board.id, file, message, fixable })
232
221
 
233
- const config = checkConfig(board, scopeRoot, rootBranchPrefix, fix, push, fixed)
222
+ const config = checkConfig(board, scopeRoot, rootBranchPrefix, push)
234
223
  const prefix = config?.prefix ?? "TASK"
235
- const displayId = (n: number) => `${prefix}-${n}`
224
+ const displayId = (k: string) => `${prefix}-${k}`
236
225
 
237
226
  // ── Collect every ticket, live and archived ────────────────────────────────
238
227
  const tickets: TicketEntry[] = []
@@ -246,8 +235,21 @@ function checkBoard(
246
235
  for (const entry of readdirSync(home, { withFileTypes: true })) {
247
236
  if (entry.name.startsWith(".")) continue
248
237
  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")
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)")
251
253
  continue
252
254
  }
253
255
  const absolute = join(home, entry.name, TICKET_FILE)
@@ -265,20 +267,22 @@ function checkBoard(
265
267
  if (!(error instanceof TicketParseError)) throw error
266
268
  push(file, reason(error, file))
267
269
  }
268
- tickets.push({ number: Number(entry.name), file, absolute, archived, text, doc })
270
+ tickets.push({ key: entry.name, file, absolute, archived, text, doc })
269
271
  }
270
272
  }
271
273
 
272
274
  // A ticket present in both tickets/ and archive/ is a merge artifact: one
273
275
  // 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))
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))
277
281
  for (const ticket of tickets) {
278
- if (ticket.archived && live.has(ticket.number)) {
282
+ if (ticket.archived && live.has(ticket.key)) {
279
283
  push(
280
284
  ticket.file,
281
- `${displayId(ticket.number)} also exists in ${TICKETS_DIR}/ — a merge kept both copies; delete one`,
285
+ `${displayId(ticket.key)} also exists in ${TICKETS_DIR}/ — a merge kept both copies; delete one`,
282
286
  )
283
287
  }
284
288
  }
@@ -344,7 +348,7 @@ function checkBoard(
344
348
 
345
349
  // ── Per-ticket content checks ──────────────────────────────────────────────
346
350
  for (const ticket of tickets) {
347
- const { doc, file, number } = ticket
351
+ const { doc, file, key } = ticket
348
352
  if (!doc) continue
349
353
 
350
354
  for (const [key, at] of [
@@ -377,13 +381,13 @@ function checkBoard(
377
381
  // The repaired relation list: no self references, no dangling targets, no
378
382
  // duplicates, sorted. Each divergence is its own issue; all are mechanical.
379
383
  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
+ .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)
384
388
  }
385
389
  for (const target of new Set(doc.blockedBy)) {
386
- if (target !== number && !valid.has(target)) {
390
+ if (target !== key && !valid.has(target)) {
387
391
  push(file, `blocked_by references ${displayId(target)}, which doesn't exist`, true)
388
392
  }
389
393
  }
@@ -391,18 +395,14 @@ function checkBoard(
391
395
  // Everything else — duplicate or unsorted blocked_by, hand-edited field
392
396
  // order or quoting, stray whitespace — is formatting drift: the parse is
393
397
  // 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.
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.)
399
401
  const normalized = serializeTicket({
400
402
  ...doc,
401
- blockedBy: [...new Set(doc.blockedBy)].sort((a, b) => a - b),
403
+ blockedBy: [...new Set(doc.blockedBy)].sort((a, b) => a.localeCompare(b)),
402
404
  })
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) {
405
+ if (normalized !== ticket.text) {
406
406
  push(file, "not in canonical form — `task check --fix` rewrites it", true)
407
407
  }
408
408
  const canonical = serializeTicket({ ...doc, blockedBy: pruned })
@@ -412,6 +412,49 @@ function checkBoard(
412
412
  }
413
413
  }
414
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
+
415
458
  // ── Comments ───────────────────────────────────────────────────────────────
416
459
  const knownCommentKeys = new Set<string>(COMMENT_FIELDS)
417
460
  for (const ticket of tickets) {