@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,131 @@
1
+ /**
2
+ * `task promote` — from the outside (real CLI subprocesses, exit codes are
3
+ * the contract):
4
+ *
5
+ * 1. Any backlog ticket promotes to todo, exit 0 — there is no quality gate;
6
+ * planning happens at claim time.
7
+ * 2. Only backlog tickets promote — todo/in_progress/done are refused.
8
+ * 3. Blocked and needs-human tickets still promote, with the hold reported
9
+ * (stdout note, `holds` in --json) — those are claim-time gates.
10
+ * 4. `task add` with no --status lands in backlog: the dump list is the
11
+ * default, todo is the deliberate choice promote makes.
12
+ */
13
+
14
+ import assert from "node:assert/strict"
15
+ import { test } from "node:test"
16
+ import { execFile } from "node:child_process"
17
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs"
18
+ import { tmpdir } from "node:os"
19
+ import { join } from "node:path"
20
+ import { fileURLToPath } from "node:url"
21
+ import { initProject } from "./file-store.ts"
22
+ import type { Task } from "./types.ts"
23
+
24
+ const CLI = fileURLToPath(new URL("./cli.ts", import.meta.url))
25
+
26
+ function tempDir(): string {
27
+ const dir = mkdtempSync(join(tmpdir(), "task-promote-test-"))
28
+ process.on("exit", () => rmSync(dir, { recursive: true, force: true }))
29
+ return dir
30
+ }
31
+
32
+ interface CliResult {
33
+ code: number
34
+ stdout: string
35
+ stderr: string
36
+ }
37
+
38
+ /** The CLI as callers see it — a subprocess with an exit code. */
39
+ function cli(cwd: string, ...args: string[]): Promise<CliResult> {
40
+ return new Promise((resolve) => {
41
+ execFile(
42
+ process.execPath,
43
+ ["--experimental-strip-types", CLI, ...args],
44
+ { cwd },
45
+ (error, stdout, stderr) => {
46
+ resolve({ code: error ? ((error as { code?: number }).code ?? 1) : 0, stdout, stderr })
47
+ },
48
+ )
49
+ })
50
+ }
51
+
52
+ /**
53
+ * A board with every promotion shape. Promotion never touches git, so a bare
54
+ * .task/ directory is the whole fixture:
55
+ * 1 "One-liner" backlog (no tags — nothing else required)
56
+ * 2 "Also backlog" backlog
57
+ * 3 "Blocked" backlog, blocked by 2
58
+ * 4 "For a person" backlog, needs-human
59
+ * 5 "Already queued" todo
60
+ */
61
+ function fixture(): string {
62
+ const dir = tempDir()
63
+ const store = initProject(dir, { name: "promote board", prefix: "PRO" })
64
+ store.create({ title: "One-liner", status: "backlog" })
65
+ store.create({ title: "Also backlog", status: "backlog" })
66
+ store.create({ title: "Blocked", status: "backlog", blockedBy: [2] })
67
+ store.create({ title: "For a person", status: "backlog", needsHuman: true })
68
+ store.create({ title: "Already queued", status: "todo" })
69
+ store.close()
70
+ return dir
71
+ }
72
+
73
+ async function show(dir: string, id: string): Promise<Task> {
74
+ const result = await cli(dir, "show", id, "--json")
75
+ assert.equal(result.code, 0, result.stderr)
76
+ return (JSON.parse(result.stdout) as { task: Task }).task
77
+ }
78
+
79
+ test("any backlog ticket promotes to todo — no quality gate", async () => {
80
+ const dir = fixture()
81
+ const result = await cli(dir, "promote", "PRO-1", "--json")
82
+ assert.equal(result.code, 0, result.stderr)
83
+ const { task, holds } = JSON.parse(result.stdout) as { task: Task; holds: string[] }
84
+ assert.equal(task.status, "todo")
85
+ assert.deepEqual(holds, [])
86
+ assert.equal((await show(dir, "PRO-1")).status, "todo")
87
+ })
88
+
89
+ test("only backlog tickets promote", async () => {
90
+ const dir = fixture()
91
+ const result = await cli(dir, "promote", "PRO-5")
92
+ assert.equal(result.code, 2)
93
+ assert.match(result.stderr, /is todo — only backlog tickets/)
94
+ })
95
+
96
+ test("blocked and needs-human tickets promote with the hold reported", async () => {
97
+ const dir = fixture()
98
+ const blocked = await cli(dir, "promote", "PRO-3", "--json")
99
+ assert.equal(blocked.code, 0, blocked.stderr)
100
+ const parsed = JSON.parse(blocked.stdout) as { task: Task; holds: string[] }
101
+ assert.equal(parsed.task.status, "todo")
102
+ assert.deepEqual(parsed.holds, ["blocked by PRO-2"])
103
+
104
+ const person = await cli(dir, "promote", "PRO-4")
105
+ assert.equal(person.code, 0, person.stderr)
106
+ assert.match(person.stdout, /needs-human — in todo, but not claimable/)
107
+ assert.equal((await show(dir, "PRO-4")).status, "todo")
108
+ })
109
+
110
+ test("task add with no --status lands in backlog", async () => {
111
+ const dir = fixture()
112
+ const added = await cli(dir, "add", "Dumped in passing", "--json")
113
+ assert.equal(added.code, 0, added.stderr)
114
+ const { task } = JSON.parse(added.stdout) as { task: Task }
115
+ assert.equal(task.status, "backlog")
116
+ })
117
+
118
+ test("--description-file reads markdown from disk; combining both flags refuses", async () => {
119
+ const dir = fixture()
120
+ const body = "## Plan\n\nA description with \"quotes\", `backticks`,\nand $(subshells) that never touch a shell.\n"
121
+ writeFileSync(join(dir, "desc.md"), body)
122
+
123
+ const added = await cli(dir, "add", "From a file", "--description-file", "desc.md", "--json")
124
+ assert.equal(added.code, 0, added.stderr)
125
+ const { task } = JSON.parse(added.stdout) as { task: Task }
126
+ assert.match(task.description ?? "", /"quotes", `backticks`,\nand \$\(subshells\)/)
127
+
128
+ const both = await cli(dir, "update", task.id, "--description", "x", "--description-file", "desc.md")
129
+ assert.equal(both.code, 1)
130
+ assert.match(both.stderr, /not both/)
131
+ })
@@ -0,0 +1,64 @@
1
+ import assert from "node:assert/strict"
2
+ import { test } from "node:test"
3
+ import { mkdtempSync, rmSync } from "node:fs"
4
+ import { tmpdir } from "node:os"
5
+ import { join } from "node:path"
6
+ import { initProject } from "./file-store.ts"
7
+ import { searchStore } from "./search.ts"
8
+
9
+ function tempRoot(): string {
10
+ const dir = mkdtempSync(join(tmpdir(), "task-search-"))
11
+ process.on("exit", () => rmSync(dir, { recursive: true, force: true }))
12
+ return dir
13
+ }
14
+
15
+ test("search covers titles, descriptions and comments, case-insensitively", () => {
16
+ const store = initProject(tempRoot(), { name: "search", prefix: "SRC" })
17
+ const a = store.create({ title: "Wire up Webhook retries", description: "exponential backoff" })
18
+ const b = store.create({ title: "Unrelated", description: "The webhook queue drains here." })
19
+ const c = store.create({ title: "Also unrelated" })
20
+ store.create({ title: "Noise" })
21
+ store.addComment(c.number, "root cause: the WEBHOOK signature check", "claude")
22
+
23
+ const results = searchStore(store, "webhook", {}, false)
24
+ assert.deepEqual(
25
+ results.map((r) => r.task.number).sort(),
26
+ [a.number, b.number, c.number],
27
+ )
28
+ const byNumber = new Map(results.map((r) => [r.task.number, r.matches]))
29
+ assert.deepEqual(byNumber.get(a.number)!.map((m) => m.field), ["title"])
30
+ assert.equal(byNumber.get(b.number)![0].snippet, "The webhook queue drains here.")
31
+ const comment = byNumber.get(c.number)![0]
32
+ assert.equal(comment.field, "comment")
33
+ assert.equal(comment.author, "claude")
34
+ assert.match(comment.snippet, /WEBHOOK signature/)
35
+ })
36
+
37
+ test("filters scope the search; --archived adds the archive in", () => {
38
+ const store = initProject(tempRoot(), { name: "scope", prefix: "SCP" })
39
+ const live = store.create({ title: "deploy pipeline", tags: ["infra"] })
40
+ store.create({ title: "deploy docs", tags: ["docs"] })
41
+ const old = store.create({ title: "deploy v1", status: "done" })
42
+ store.archive(old.number)
43
+
44
+ assert.deepEqual(
45
+ searchStore(store, "deploy", { tags: ["infra"] }, false).map((r) => r.task.number),
46
+ [live.number],
47
+ )
48
+ const withArchive = searchStore(store, "deploy", {}, true)
49
+ assert.ok(withArchive.some((r) => r.task.number === old.number && r.task.archived))
50
+ assert.equal(withArchive.length, 3)
51
+ // Without the flag the archived ticket stays out.
52
+ assert.equal(searchStore(store, "deploy", {}, false).length, 2)
53
+ })
54
+
55
+ test("long lines are windowed so the hit is visible", () => {
56
+ const store = initProject(tempRoot(), { name: "win", prefix: "WIN" })
57
+ const filler = "x".repeat(150)
58
+ store.create({ title: "Long", description: `${filler} needle in here ${filler}` })
59
+ const [result] = searchStore(store, "needle", {}, false)
60
+ assert.ok(result.matches[0].snippet.length <= 102) // window + ellipses
61
+ assert.match(result.matches[0].snippet, /needle in here/)
62
+ assert.match(result.matches[0].snippet, /^…/)
63
+ assert.match(result.matches[0].snippet, /…$/)
64
+ })
package/src/search.ts ADDED
@@ -0,0 +1,91 @@
1
+ /**
2
+ * `task search` — find tickets by what they say, not just what they're tagged.
3
+ *
4
+ * Case-insensitive substring match over titles, descriptions and comments,
5
+ * scoped by the usual list filters. At current board sizes a straight scan of
6
+ * the parsed files is plenty; if boards ever grow to thousands of tickets,
7
+ * this is the one feature that would justify a derived FTS index — rebuilt
8
+ * from the files, never as state, which the text-canonical format was designed
9
+ * to allow.
10
+ */
11
+
12
+ import type { Store } from "./store.ts"
13
+ import { STATUSES, type Task, type TaskFilter } from "./types.ts"
14
+
15
+ export interface SearchMatch {
16
+ field: "title" | "description" | "comment"
17
+ /** The matching line, trimmed and windowed around the first hit. */
18
+ snippet: string
19
+ /** Set on comment matches. */
20
+ commentId?: string
21
+ author?: string
22
+ }
23
+
24
+ export interface SearchResult {
25
+ task: Task
26
+ matches: SearchMatch[]
27
+ }
28
+
29
+ /** How much of a matching line a snippet shows. */
30
+ const SNIPPET_MAX = 100
31
+
32
+ /** The first line containing the needle, windowed so the hit is visible. */
33
+ function snippetOf(text: string, needle: string): string {
34
+ const lines = text.split("\n")
35
+ const line = (lines.find((l) => l.toLowerCase().includes(needle)) ?? lines[0]).trim()
36
+ if (line.length <= SNIPPET_MAX) return line
37
+ const at = line.toLowerCase().indexOf(needle)
38
+ const start = Math.max(0, Math.min(at - 30, line.length - SNIPPET_MAX))
39
+ const end = Math.min(line.length, start + SNIPPET_MAX)
40
+ return `${start > 0 ? "…" : ""}${line.slice(start, end).trim()}${end < line.length ? "…" : ""}`
41
+ }
42
+
43
+ /**
44
+ * Every task matching `query`, with one snippet per matching field (and one
45
+ * per matching comment). `includeArchived` searches the archive *in addition
46
+ * to* the board — unlike `list --archived`, which shows the archive instead,
47
+ * a search is a question about everything ever written down.
48
+ */
49
+ export function searchStore(
50
+ store: Store,
51
+ query: string,
52
+ filter: TaskFilter,
53
+ includeArchived: boolean,
54
+ ): SearchResult[] {
55
+ const needle = query.toLowerCase()
56
+ const pool = [
57
+ ...store.list(filter),
58
+ ...(includeArchived ? store.list({ ...filter, archived: true }) : []),
59
+ ]
60
+
61
+ const results: SearchResult[] = []
62
+ for (const task of pool) {
63
+ const matches: SearchMatch[] = []
64
+ if (task.title.toLowerCase().includes(needle)) {
65
+ matches.push({ field: "title", snippet: snippetOf(task.title, needle) })
66
+ }
67
+ if (task.description.toLowerCase().includes(needle)) {
68
+ matches.push({ field: "description", snippet: snippetOf(task.description, needle) })
69
+ }
70
+ for (const comment of store.comments(task.number)) {
71
+ if (!comment.body.toLowerCase().includes(needle)) continue
72
+ matches.push({
73
+ field: "comment",
74
+ snippet: snippetOf(comment.body, needle),
75
+ commentId: comment.id,
76
+ ...(comment.author ? { author: comment.author } : {}),
77
+ })
78
+ }
79
+ if (matches.length > 0) results.push({ task, matches })
80
+ }
81
+
82
+ // Board order — grouped by status column, then position — same as `list`.
83
+ const order = new Map(STATUSES.map((s, i) => [s, i]))
84
+ results.sort(
85
+ (a, b) =>
86
+ order.get(a.task.status)! - order.get(b.task.status)! ||
87
+ a.task.position - b.task.position ||
88
+ a.task.number - b.task.number,
89
+ )
90
+ return results
91
+ }
package/src/server.ts CHANGED
@@ -45,8 +45,8 @@ function parsePatch(body: Record<string, unknown>): TaskPatch {
45
45
  patch.status = body.status
46
46
  }
47
47
  if (Array.isArray(body.tags)) patch.tags = body.tags.map(String)
48
- if (typeof body.milestone === "string" || body.milestone === null) {
49
- patch.milestone = body.milestone === "" ? null : body.milestone
48
+ if (typeof body.goal === "string" || body.goal === null) {
49
+ patch.goal = body.goal === "" ? null : body.goal
50
50
  }
51
51
  if (typeof body.needsHuman === "boolean") patch.needsHuman = body.needsHuman
52
52
  if (Array.isArray(body.blocks)) patch.blocks = body.blocks.map(taskNumber)
@@ -223,6 +223,15 @@ export function createTaskServer(serveRoot: string): Server {
223
223
  return
224
224
  }
225
225
 
226
+ if (path === "/api/goals" && req.method === "GET") {
227
+ // Read-only on purpose: a goal is created deliberately, with a
228
+ // description, via `task goal add` — a picker that mints goals from
229
+ // typed strings would just be milestones with extra steps. Archived
230
+ // goals ride along flagged, so old chips can still resolve a title.
231
+ json(res, 200, { goals: [...store.goals(), ...store.goals(true)] })
232
+ return
233
+ }
234
+
226
235
  if (path === "/api/tasks" && req.method === "GET") {
227
236
  const counts = store.commentCounts()
228
237
  const tasks = store
package/src/store.test.ts CHANGED
@@ -4,8 +4,7 @@
4
4
  * 1. The text format round-trips — parse(serialize(x)) loses nothing.
5
5
  * 2. `FileStore` behaves like the store always has (positions, links,
6
6
  * comments, cascade on delete).
7
- * 3. `task migrate` reproduces a legacy SQLite board in files, exactly.
8
- * 4. Concurrent-writer shapes merge by construction: a comment is a new file,
7
+ * 3. Concurrent-writer shapes merge by construction: a comment is a new file,
9
8
  * a claim is a one-line status diff.
10
9
  */
11
10
 
@@ -14,9 +13,9 @@ import { test } from "node:test"
14
13
  import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"
15
14
  import { tmpdir } from "node:os"
16
15
  import { join } from "node:path"
17
- import { FileStore, initProject, migrateBoard, openBoard } from "./file-store.ts"
16
+ import { FileStore, initProject, openBoard } from "./file-store.ts"
18
17
  import { parseTicket, serializeTicket, type TicketDoc } from "./ticket-doc.ts"
19
- import { TaskStore, findBoardsByPrefix, findScopeRoot } from "./store.ts"
18
+ import { findBoardsByPrefix, findScopeRoot } from "./store.ts"
20
19
 
21
20
  function tempRoot(): string {
22
21
  const dir = mkdtempSync(join(tmpdir(), "task-test-"))
@@ -31,7 +30,7 @@ test("ticket documents round-trip, including the awkward bodies", () => {
31
30
  description: "One line.",
32
31
  status: "todo",
33
32
  tags: [],
34
- milestone: null,
33
+ goal: null,
35
34
  needsHuman: false,
36
35
  blockedBy: [],
37
36
  prs: [],
@@ -46,7 +45,7 @@ test("ticket documents round-trip, including the awkward bodies", () => {
46
45
  description: "# Not the title\n\n---\n\nstatus: not-a-field\n\n```md\n---\n```",
47
46
  status: "in_progress",
48
47
  tags: ["storage", "architecture"],
49
- milestone: "v2 storage",
48
+ goal: "v2-storage",
50
49
  needsHuman: true,
51
50
  blockedBy: [3, 7],
52
51
  prs: ["https://github.com/x/y/pull/1"],
@@ -59,7 +58,7 @@ test("ticket documents round-trip, including the awkward bodies", () => {
59
58
  description: "",
60
59
  status: "backlog",
61
60
  tags: [],
62
- milestone: null,
61
+ goal: null,
63
62
  needsHuman: false,
64
63
  blockedBy: [],
65
64
  prs: [],
@@ -77,7 +76,7 @@ test("hand-edits are forgiven where they're unambiguous", () => {
77
76
  const text = [
78
77
  "---",
79
78
  "status: todo",
80
- "milestone: launch", // unquoted string — the YAML instinct
79
+ "goal: launch", // unquoted string — the YAML instinct
81
80
  "position: 8",
82
81
  "created: 2026-08-15T16:00:33.758Z",
83
82
  "updated: 2026-08-15T16:00:33.758Z",
@@ -88,9 +87,16 @@ test("hand-edits are forgiven where they're unambiguous", () => {
88
87
  "Body.",
89
88
  ].join("\n")
90
89
  const doc = parseTicket(text, "test")
91
- assert.equal(doc.milestone, "launch")
90
+ assert.equal(doc.goal, "launch")
92
91
  assert.equal(doc.position, 8)
93
92
 
93
+ // The pre-0.8 spelling still reads as the goal — old boards keep rendering,
94
+ // and `task check --fix`'s canonical rewrite is the migration.
95
+ const legacy = parseTicket(text.replace("goal: launch", "milestone: launch"), "test")
96
+ assert.equal(legacy.goal, "launch")
97
+ assert.match(serializeTicket(legacy), /goal: "launch"/)
98
+ assert.doesNotMatch(serializeTicket(legacy), /milestone/)
99
+
94
100
  assert.throws(
95
101
  () => parseTicket(text.replace("status: todo", "status: todoo"), "here"),
96
102
  /here.*invalid status/,
@@ -104,7 +110,7 @@ test("FileStore keeps the store's contract", () => {
104
110
  assert.equal(store.config.prefix, "SAM")
105
111
  assert.ok(store instanceof FileStore)
106
112
 
107
- const a = store.create({ title: "First", description: "alpha", tags: ["api"] })
113
+ const a = store.create({ title: "First", description: "alpha", tags: ["api"], status: "todo" })
108
114
  const b = store.create({ title: "Second", status: "todo" })
109
115
  const c = store.create({ title: "Third", status: "backlog" })
110
116
  assert.deepEqual([a.number, b.number, c.number], [1, 2, 3])
@@ -153,70 +159,39 @@ test("FileStore keeps the store's contract", () => {
153
159
  assert.equal(d.number, 4)
154
160
  })
155
161
 
156
- test("openBoard picks the backend by what's on disk", () => {
162
+ test("openBoard reads text boards, and refuses a pre-0.6 SQLite board loudly", () => {
157
163
  const fileRoot = tempRoot()
158
164
  initProject(fileRoot, { name: "files" })
159
165
  assert.ok(openBoard(fileRoot) instanceof FileStore)
160
166
 
167
+ // A pre-0.6 board: version-1 config, committed database, no tickets/ tree.
168
+ // Reading it as an empty text board would silently strand every ticket in
169
+ // the database, so it must refuse instead — pointing at the last release
170
+ // that still carries `task migrate`.
161
171
  const legacyRoot = tempRoot()
162
172
  mkdirSync(join(legacyRoot, ".task"))
163
173
  writeFileSync(
164
174
  join(legacyRoot, ".task", "config.json"),
165
175
  `${JSON.stringify({ name: "legacy", prefix: "LEG", version: 1 }, null, 2)}\n`,
166
176
  )
167
- const legacy = openBoard(legacyRoot)
168
- assert.ok(legacy instanceof TaskStore)
169
- legacy.close()
170
- })
177
+ writeFileSync(join(legacyRoot, ".task", "tasks.db"), "not really a database")
178
+ assert.throws(() => openBoard(legacyRoot), /pre-0\.6 SQLite database.*migrate/)
171
179
 
172
- test("migrate reproduces a legacy board, byte-visible and loss-free", () => {
173
- const root = tempRoot()
174
- mkdirSync(join(root, ".task"))
180
+ // A version-2 board with no tickets yet is just an empty board, not legacy.
181
+ const emptyRoot = tempRoot()
182
+ mkdirSync(join(emptyRoot, ".task"))
175
183
  writeFileSync(
176
- join(root, ".task", "config.json"),
177
- `${JSON.stringify({ name: "legacy board", prefix: "LEG", version: 1 }, null, 2)}\n`,
178
- )
179
- const legacy = new TaskStore(root)
180
- const one = legacy.create({ title: "Keep me", description: "with *body*", tags: ["a", "b"] })
181
- const two = legacy.create({
182
- title: "Blocked one",
183
- status: "in_progress",
184
- milestone: "launch",
185
- needsHuman: true,
186
- blockedBy: [one.number],
187
- prs: ["https://github.com/x/y/pull/9"],
188
- })
189
- legacy.addComment(one.number, "hello", "nick")
190
- legacy.addComment(one.number, "and back", "claude")
191
- const expectedTasks = legacy.list({})
192
- const expectedComments = legacy.comments(one.number)
193
- legacy.close()
194
-
195
- const result = migrateBoard(root)
196
- assert.deepEqual(result, { tasks: 2, comments: 2 })
197
- assert.throws(() => migrateBoard(root), /already migrated/)
198
-
199
- const store = openBoard(root)
200
- assert.ok(store instanceof FileStore)
201
- assert.deepEqual(store.list({}), expectedTasks)
202
- assert.deepEqual(
203
- store.comments(one.number).map(({ author, body, createdAt }) => ({ author, body, createdAt })),
204
- expectedComments.map(({ author, body, createdAt }) => ({ author, body, createdAt })),
205
- )
206
- assert.deepEqual(store.get(two.number)!.blockedBy, [one.number])
207
- assert.match(readFileSync(join(root, ".task", ".gitignore"), "utf8"), /tasks\.db/)
208
- assert.equal(
209
- (JSON.parse(readFileSync(join(root, ".task", "config.json"), "utf8")) as { version: number })
210
- .version,
211
- 2,
184
+ join(emptyRoot, ".task", "config.json"),
185
+ `${JSON.stringify({ name: "empty", prefix: "EMP", version: 2 }, null, 2)}\n`,
212
186
  )
187
+ assert.deepEqual(openBoard(emptyRoot).list({}), [])
213
188
  })
214
189
 
215
190
  test("archive moves finished tickets off the hot path, losing nothing", () => {
216
191
  const root = tempRoot()
217
192
  const store = initProject(root, { name: "archive test", prefix: "ARC" })
218
- const keep = store.create({ title: "Still open" })
219
- const old = store.create({ title: "Finished work" })
193
+ const keep = store.create({ title: "Still open", status: "todo" })
194
+ const old = store.create({ title: "Finished work", status: "todo" })
220
195
  store.link(keep.number, "blocked_by", old.number)
221
196
  store.addComment(old.number, "how it went", "nick")
222
197
 
@@ -258,6 +233,63 @@ test("archive moves finished tickets off the hot path, losing nothing", () => {
258
233
  assert.throws(() => store.unarchive(old.number), /isn't archived/)
259
234
  })
260
235
 
236
+ test("goals: titled and described, one per task, progress always derived", () => {
237
+ const root = tempRoot()
238
+ const store = initProject(root, { name: "goals test", prefix: "GOA" })
239
+
240
+ // Created deliberately, slug derived from the title.
241
+ const goal = store.createGoal({
242
+ title: "Trust & guardrail layer",
243
+ description: "Why: DialMCP context lives here once, not in every ticket.",
244
+ })
245
+ assert.equal(goal.slug, "trust-guardrail-layer")
246
+ assert.throws(() => store.createGoal({ title: "Trust & guardrail layer" }), /already exists/)
247
+ assert.throws(() => store.createGoal({ title: "x", slug: "archive" }), /invalid goal slug/)
248
+ assert.throws(() => store.createGoal({ title: "x", slug: "Not A Slug" }), /invalid goal slug/)
249
+
250
+ // Assignment is validated: a goal must exist before a task can belong to it.
251
+ assert.throws(() => store.create({ title: "T", goal: "nope" }), /no such goal/)
252
+ const a = store.create({ title: "First", goal: goal.slug })
253
+ const b = store.create({ title: "Second" })
254
+ store.update(b.number, { goal: goal.slug })
255
+ assert.equal(store.get(b.number)!.goal, goal.slug)
256
+ assert.deepEqual(store.list({ goal: goal.slug }).map((t) => t.number), [a.number, b.number])
257
+
258
+ // The file is the state: goal ref serialized on the ticket, goal file on disk.
259
+ assert.match(
260
+ readFileSync(join(root, ".task", "tickets", "1", "ticket.md"), "utf8"),
261
+ /goal: "trust-guardrail-layer"/,
262
+ )
263
+ const goalFile = readFileSync(join(root, ".task", "goals", "trust-guardrail-layer.md"), "utf8")
264
+ assert.match(goalFile, /# Trust & guardrail layer/)
265
+
266
+ // Update touches the file; clearing a task's goal is `goal: null`.
267
+ store.updateGoal(goal.slug, { description: "Sharper why." })
268
+ assert.equal(store.getGoal(goal.slug)!.description, "Sharper why.")
269
+ store.update(b.number, { goal: null })
270
+ assert.equal(store.get(b.number)!.goal, null)
271
+
272
+ // Archive refuses while open work references the goal, allows after.
273
+ assert.throws(() => store.archiveGoal(goal.slug), /still has open tasks \(GOA-1\)/)
274
+ store.update(a.number, { status: "done" })
275
+ const shelved = store.archiveGoal(goal.slug)
276
+ assert.equal(shelved.archived, true)
277
+ assert.deepEqual(store.goals().map((g) => g.slug), [])
278
+ assert.deepEqual(store.goals(true).map((g) => g.slug), [goal.slug])
279
+ // Still resolvable (done tickets keep their ref readable), not assignable.
280
+ assert.equal(store.getGoal(goal.slug)!.title, "Trust & guardrail layer")
281
+ assert.throws(() => store.update(b.number, { goal: goal.slug }), /is archived — run/)
282
+ assert.throws(() => store.updateGoal(goal.slug, { title: "x" }), /is archived — run/)
283
+
284
+ // Delete needs every live ref cleared first — pruning organization is a
285
+ // human call, so the human makes it.
286
+ store.unarchiveGoal(goal.slug)
287
+ assert.throws(() => store.deleteGoal(goal.slug), /referenced by GOA-1/)
288
+ store.update(a.number, { goal: null })
289
+ store.deleteGoal(goal.slug)
290
+ assert.equal(store.getGoal(goal.slug), null)
291
+ })
292
+
261
293
  test("prefix routing helpers find the right board from anywhere", () => {
262
294
  const repo = tempRoot()
263
295
  initProject(repo, { name: "root board", prefix: "NIC" })
@@ -276,7 +308,7 @@ test("prefix routing helpers find the right board from anywhere", () => {
276
308
  test("concurrent-writer shapes merge by construction", () => {
277
309
  const root = tempRoot()
278
310
  const store = initProject(root, { name: "merge" })
279
- const task = store.create({ title: "Contended" })
311
+ const task = store.create({ title: "Contended", status: "todo" })
280
312
 
281
313
  // Two agents commenting at once = two files; simulate the second writer by
282
314
  // dropping its file in directly, the way a git merge would.