@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.
- package/README.md +137 -22
- package/dist/asks.test.d.ts +17 -0
- package/dist/asks.test.d.ts.map +1 -0
- package/dist/asks.test.js +278 -0
- package/dist/asks.test.js.map +1 -0
- package/dist/check.d.ts +3 -2
- package/dist/check.d.ts.map +1 -1
- package/dist/check.js +99 -60
- package/dist/check.js.map +1 -1
- package/dist/check.test.js +99 -60
- package/dist/check.test.js.map +1 -1
- package/dist/claim-io.d.ts +73 -0
- package/dist/claim-io.d.ts.map +1 -0
- package/dist/claim-io.js +344 -0
- package/dist/claim-io.js.map +1 -0
- package/dist/claim.d.ts +108 -23
- package/dist/claim.d.ts.map +1 -1
- package/dist/claim.js +320 -86
- package/dist/claim.js.map +1 -1
- package/dist/claim.test.d.ts +10 -2
- package/dist/claim.test.d.ts.map +1 -1
- package/dist/claim.test.js +282 -96
- package/dist/claim.test.js.map +1 -1
- package/dist/cli.js +346 -68
- package/dist/cli.js.map +1 -1
- package/dist/file-store.d.ts +70 -24
- package/dist/file-store.d.ts.map +1 -1
- package/dist/file-store.js +319 -141
- package/dist/file-store.js.map +1 -1
- package/dist/git-serve.d.ts +183 -0
- package/dist/git-serve.d.ts.map +1 -0
- package/dist/git-serve.js +503 -0
- package/dist/git-serve.js.map +1 -0
- package/dist/git-serve.test.d.ts +16 -0
- package/dist/git-serve.test.d.ts.map +1 -0
- package/dist/git-serve.test.js +183 -0
- package/dist/git-serve.test.js.map +1 -0
- package/dist/git.d.ts +65 -0
- package/dist/git.d.ts.map +1 -0
- package/dist/git.js +114 -0
- package/dist/git.js.map +1 -0
- package/dist/id.d.ts +39 -0
- package/dist/id.d.ts.map +1 -0
- package/dist/id.js +67 -0
- package/dist/id.js.map +1 -0
- package/dist/inbox.d.ts +41 -0
- package/dist/inbox.d.ts.map +1 -0
- package/dist/inbox.js +56 -0
- package/dist/inbox.js.map +1 -0
- package/dist/overview.d.ts +9 -0
- package/dist/overview.d.ts.map +1 -1
- package/dist/overview.js +19 -11
- package/dist/overview.js.map +1 -1
- package/dist/overview.test.js +6 -5
- package/dist/overview.test.js.map +1 -1
- package/dist/promote.test.d.ts +3 -2
- package/dist/promote.test.d.ts.map +1 -1
- package/dist/promote.test.js +29 -31
- package/dist/promote.test.js.map +1 -1
- package/dist/publish.d.ts +2 -17
- package/dist/publish.d.ts.map +1 -1
- package/dist/publish.js +4 -49
- package/dist/publish.js.map +1 -1
- package/dist/search.d.ts +3 -1
- package/dist/search.d.ts.map +1 -1
- package/dist/search.js +15 -2
- package/dist/search.js.map +1 -1
- package/dist/search.test.js +9 -9
- package/dist/search.test.js.map +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +218 -21
- package/dist/server.js.map +1 -1
- package/dist/store.d.ts +28 -14
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js.map +1 -1
- package/dist/store.test.js +88 -75
- package/dist/store.test.js.map +1 -1
- package/dist/ticket-doc.d.ts +26 -10
- package/dist/ticket-doc.d.ts.map +1 -1
- package/dist/ticket-doc.js +71 -27
- package/dist/ticket-doc.js.map +1 -1
- package/dist/types.d.ts +51 -17
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +1 -1
- package/skill/SKILL.md +96 -24
- package/src/asks.test.ts +355 -0
- package/src/check.test.ts +129 -72
- package/src/check.ts +101 -58
- package/src/claim-io.ts +401 -0
- package/src/claim.test.ts +372 -101
- package/src/claim.ts +418 -99
- package/src/cli.ts +370 -67
- package/src/file-store.ts +357 -145
- package/src/git-serve.test.ts +240 -0
- package/src/git-serve.ts +595 -0
- package/src/git.ts +141 -0
- package/src/id.ts +68 -0
- package/src/inbox.ts +77 -0
- package/src/overview.test.ts +6 -5
- package/src/overview.ts +25 -10
- package/src/promote.test.ts +40 -28
- package/src/publish.ts +6 -53
- package/src/search.test.ts +11 -11
- package/src/search.ts +17 -3
- package/src/server.ts +221 -19
- package/src/store.test.ts +95 -77
- package/src/store.ts +28 -13
- package/src/ticket-doc.ts +93 -30
- package/src/types.ts +52 -17
- package/ui/dist/assets/index-BjsorZOU.js +229 -0
- package/ui/dist/assets/index-CoKCUYic.css +1 -0
- package/ui/dist/index.html +2 -2
- package/ui/dist/assets/index-BJmOsOdR.js +0 -229
- 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.
|
|
31
|
-
store.addComment(a.
|
|
32
|
-
store.update(a.
|
|
33
|
-
store.archive(a.
|
|
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.
|
|
51
|
-
const ticketPath = (
|
|
52
|
-
const bText = readFileSync(ticketPath(b.
|
|
53
|
-
writeFileSync(
|
|
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(
|
|
58
|
-
writeFileSync(ticketPath(
|
|
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", "
|
|
62
|
-
writeFileSync(ticketPath(
|
|
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-
|
|
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.
|
|
75
|
-
assert.match(readFileSync(ticketPath(
|
|
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.
|
|
92
|
-
store.archive(t.
|
|
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",
|
|
132
|
+
mkdirSync(join(root, ".task", "tickets", t.key), { recursive: true })
|
|
95
133
|
writeFileSync(
|
|
96
|
-
join(root, ".task", "tickets",
|
|
97
|
-
readFileSync(join(root, ".task", "archive",
|
|
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",
|
|
148
|
+
join(root, ".task", "tickets", t.key),
|
|
111
149
|
(mkdirSync(join(root, ".task", "archive"), { recursive: true }),
|
|
112
|
-
join(root, ".task", "archive",
|
|
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",
|
|
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.
|
|
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
|
|
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",
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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.
|
|
201
|
-
const dir = join(root, ".task", "tickets",
|
|
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:
|
|
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
|
-
|
|
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
|
|
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(
|
|
233
|
-
|
|
234
|
-
|
|
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("
|
|
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,
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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
|
|
14
|
-
*
|
|
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
|
-
|
|
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):
|
|
135
|
-
*
|
|
136
|
-
*
|
|
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.
|
|
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(
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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,
|
|
222
|
+
const config = checkConfig(board, scopeRoot, rootBranchPrefix, push)
|
|
234
223
|
const prefix = config?.prefix ?? "TASK"
|
|
235
|
-
const displayId = (
|
|
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
|
-
|
|
250
|
-
|
|
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({
|
|
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
|
-
|
|
276
|
-
|
|
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.
|
|
282
|
+
if (ticket.archived && live.has(ticket.key)) {
|
|
279
283
|
push(
|
|
280
284
|
ticket.file,
|
|
281
|
-
`${displayId(ticket.
|
|
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,
|
|
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((
|
|
381
|
-
.sort((a, b) => a
|
|
382
|
-
if (doc.blockedBy.includes(
|
|
383
|
-
push(file, `${displayId(
|
|
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 !==
|
|
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
|
-
//
|
|
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
|
|
403
|
+
blockedBy: [...new Set(doc.blockedBy)].sort((a, b) => a.localeCompare(b)),
|
|
402
404
|
})
|
|
403
|
-
if (
|
|
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) {
|