@nickmeriano/task 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/README.md +12 -7
  2. package/dist/check.d.ts +42 -0
  3. package/dist/check.d.ts.map +1 -0
  4. package/dist/check.js +364 -0
  5. package/dist/check.js.map +1 -0
  6. package/dist/check.test.d.ts +9 -0
  7. package/dist/check.test.d.ts.map +1 -0
  8. package/dist/check.test.js +209 -0
  9. package/dist/check.test.js.map +1 -0
  10. package/dist/claim.d.ts +56 -3
  11. package/dist/claim.d.ts.map +1 -1
  12. package/dist/claim.js +164 -9
  13. package/dist/claim.js.map +1 -1
  14. package/dist/claim.test.js +168 -14
  15. package/dist/claim.test.js.map +1 -1
  16. package/dist/cli.js +504 -93
  17. package/dist/cli.js.map +1 -1
  18. package/dist/file-store.d.ts +41 -15
  19. package/dist/file-store.d.ts.map +1 -1
  20. package/dist/file-store.js +197 -99
  21. package/dist/file-store.js.map +1 -1
  22. package/dist/index.d.ts +5 -3
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +5 -3
  25. package/dist/index.js.map +1 -1
  26. package/dist/overview.d.ts +43 -0
  27. package/dist/overview.d.ts.map +1 -0
  28. package/dist/overview.js +53 -0
  29. package/dist/overview.js.map +1 -0
  30. package/dist/overview.test.d.ts +8 -0
  31. package/dist/overview.test.d.ts.map +1 -0
  32. package/dist/overview.test.js +47 -0
  33. package/dist/overview.test.js.map +1 -0
  34. package/dist/promote.test.d.ts +14 -0
  35. package/dist/promote.test.d.ts.map +1 -0
  36. package/dist/promote.test.js +106 -0
  37. package/dist/promote.test.js.map +1 -0
  38. package/dist/search.d.ts +32 -0
  39. package/dist/search.d.ts.map +1 -0
  40. package/dist/search.js +66 -0
  41. package/dist/search.js.map +1 -0
  42. package/dist/search.test.d.ts +2 -0
  43. package/dist/search.test.d.ts.map +1 -0
  44. package/dist/search.test.js +53 -0
  45. package/dist/search.test.js.map +1 -0
  46. package/dist/server.d.ts.map +1 -1
  47. package/dist/server.js +10 -2
  48. package/dist/server.js.map +1 -1
  49. package/dist/store.d.ts +16 -50
  50. package/dist/store.d.ts.map +1 -1
  51. package/dist/store.js +0 -368
  52. package/dist/store.js.map +1 -1
  53. package/dist/store.test.d.ts +1 -2
  54. package/dist/store.test.d.ts.map +1 -1
  55. package/dist/store.test.js +77 -48
  56. package/dist/store.test.js.map +1 -1
  57. package/dist/ticket-doc.d.ts +55 -2
  58. package/dist/ticket-doc.d.ts.map +1 -1
  59. package/dist/ticket-doc.js +177 -7
  60. package/dist/ticket-doc.js.map +1 -1
  61. package/dist/types.d.ts +67 -14
  62. package/dist/types.d.ts.map +1 -1
  63. package/dist/types.js.map +1 -1
  64. package/package.json +1 -1
  65. package/skill/SKILL.md +97 -31
  66. package/src/check.test.ts +271 -0
  67. package/src/check.ts +454 -0
  68. package/src/claim.test.ts +202 -14
  69. package/src/claim.ts +194 -10
  70. package/src/cli.ts +510 -89
  71. package/src/file-store.ts +217 -111
  72. package/src/index.ts +4 -2
  73. package/src/overview.test.ts +51 -0
  74. package/src/overview.ts +90 -0
  75. package/src/promote.test.ts +131 -0
  76. package/src/search.test.ts +64 -0
  77. package/src/search.ts +91 -0
  78. package/src/server.ts +11 -2
  79. package/src/store.test.ts +89 -57
  80. package/src/store.ts +18 -431
  81. package/src/ticket-doc.ts +210 -10
  82. package/src/types.ts +71 -14
  83. package/ui/dist/assets/{index-oJzomUDL.js → index-BJmOsOdR.js} +76 -76
  84. package/ui/dist/assets/index-BoqQlqSU.css +1 -0
  85. package/ui/dist/index.html +2 -2
  86. package/ui/dist/assets/index-CXW8uT5f.css +0 -1
package/src/file-store.ts CHANGED
@@ -10,23 +10,29 @@ import {
10
10
  import { basename, dirname, join } from "node:path"
11
11
  import {
12
12
  commentStem,
13
+ isGoalSlug,
13
14
  parseComment,
15
+ parseGoal,
14
16
  parseTicket,
15
17
  serializeComment,
18
+ serializeGoal,
16
19
  serializeTicket,
20
+ slugifyGoal,
21
+ type GoalDoc,
17
22
  type TicketDoc,
18
23
  } from "./ticket-doc.ts"
19
24
  import {
20
25
  CONFIG_FILE,
21
- DB_FILE,
22
26
  POSITION_GAP,
23
27
  TASK_DIR,
24
- TaskStore,
25
28
  derivePrefix,
26
29
  type Store,
27
30
  } from "./store.ts"
28
31
  import type {
29
32
  Comment,
33
+ Goal,
34
+ GoalInput,
35
+ GoalPatch,
30
36
  ProjectConfig,
31
37
  Status,
32
38
  Task,
@@ -38,8 +44,11 @@ import type {
38
44
  export const TICKETS_DIR = "tickets"
39
45
  /** Finished tickets moved out of the hot path — same per-ticket layout. */
40
46
  export const ARCHIVE_DIR = "archive"
41
- const TICKET_FILE = "ticket.md"
42
- const COMMENTS_DIR = "comments"
47
+ export const TICKET_FILE = "ticket.md"
48
+ export const COMMENTS_DIR = "comments"
49
+ /** One file per goal — `.task/goals/<slug>.md`; archived ones one level down. */
50
+ export const GOALS_DIR = "goals"
51
+ export const GOALS_ARCHIVE_DIR = "archive"
43
52
 
44
53
  function now(): string {
45
54
  return new Date().toISOString()
@@ -79,6 +88,8 @@ export class FileStore implements Store {
79
88
  readonly config: ProjectConfig
80
89
  private ticketsDir: string
81
90
  private archiveDir: string
91
+ private goalsDir: string
92
+ private goalsArchiveDir: string
82
93
 
83
94
  constructor(root: string) {
84
95
  this.root = root
@@ -88,6 +99,8 @@ export class FileStore implements Store {
88
99
  ) as ProjectConfig
89
100
  this.ticketsDir = join(this.taskDir, TICKETS_DIR)
90
101
  this.archiveDir = join(this.taskDir, ARCHIVE_DIR)
102
+ this.goalsDir = join(this.taskDir, GOALS_DIR)
103
+ this.goalsArchiveDir = join(this.goalsDir, GOALS_ARCHIVE_DIR)
91
104
  }
92
105
 
93
106
  close(): void {}
@@ -184,7 +197,7 @@ export class FileStore implements Store {
184
197
  description: ticket.doc.description,
185
198
  status: ticket.doc.status,
186
199
  tags: ticket.doc.tags,
187
- milestone: ticket.doc.milestone,
200
+ goal: ticket.doc.goal,
188
201
  needsHuman: ticket.doc.needsHuman,
189
202
  blocks,
190
203
  blockedBy: [...ticket.doc.blockedBy].sort((a, b) => a - b),
@@ -200,8 +213,8 @@ export class FileStore implements Store {
200
213
  const wanted = new Set<Status>(filter.statuses)
201
214
  tickets = tickets.filter((t) => wanted.has(t.doc.status))
202
215
  }
203
- if (filter.milestone) {
204
- tickets = tickets.filter((t) => t.doc.milestone === filter.milestone)
216
+ if (filter.goal) {
217
+ tickets = tickets.filter((t) => t.doc.goal === filter.goal)
205
218
  }
206
219
  if (filter.needsHuman !== undefined) {
207
220
  tickets = tickets.filter((t) => t.doc.needsHuman === filter.needsHuman)
@@ -249,12 +262,15 @@ export class FileStore implements Store {
249
262
  // that old comments or PR titles still point at.
250
263
  const taken = [...all.map((t) => t.number), ...this.archivedNumbers()]
251
264
  const number = taken.reduce((max, n) => Math.max(max, n), 0) + 1
252
- const status = input.status ?? "todo"
265
+ // Dump by default: new tickets land in backlog, the no-quality-bar list;
266
+ // `todo` (the ready list) is an explicit choice — usually `task promote`.
267
+ const status = input.status ?? "backlog"
253
268
  for (const target of [...(input.blocks ?? []), ...(input.blockedBy ?? [])]) {
254
269
  if (!all.some((t) => t.number === target)) {
255
270
  throw new Error(`no such task: ${this.displayId(target)}`)
256
271
  }
257
272
  }
273
+ if (input.goal) this.assertGoalAssignable(input.goal)
258
274
  // New tasks land at the bottom of their column.
259
275
  const column = all.filter((t) => t.doc.status === status)
260
276
  const bottom = column.length ? Math.max(...column.map((t) => t.doc.position)) : 0
@@ -266,7 +282,7 @@ export class FileStore implements Store {
266
282
  description: input.description ?? "",
267
283
  status,
268
284
  tags: input.tags ?? [],
269
- milestone: input.milestone ?? null,
285
+ goal: input.goal ?? null,
270
286
  needsHuman: input.needsHuman ?? false,
271
287
  blockedBy: [...new Set(input.blockedBy ?? [])].sort((a, b) => a - b),
272
288
  prs: input.prs ?? [],
@@ -340,7 +356,10 @@ export class FileStore implements Store {
340
356
  if (patch.title !== undefined) set("title", patch.title)
341
357
  if (patch.description !== undefined) set("description", patch.description)
342
358
  if (patch.tags !== undefined) set("tags", patch.tags)
343
- if (patch.milestone !== undefined) set("milestone", patch.milestone)
359
+ if (patch.goal !== undefined) {
360
+ if (patch.goal) this.assertGoalAssignable(patch.goal)
361
+ set("goal", patch.goal)
362
+ }
344
363
  if (patch.needsHuman !== undefined) set("needsHuman", patch.needsHuman)
345
364
  if (patch.prs !== undefined) set("prs", patch.prs)
346
365
  if (patch.status !== undefined) {
@@ -479,6 +498,175 @@ export class FileStore implements Store {
479
498
  return this.get(number)!
480
499
  }
481
500
 
501
+ // ── Goals ──────────────────────────────────────────────────────────────────
502
+
503
+ private goalPath(slug: string, archived: boolean): string {
504
+ return join(archived ? this.goalsArchiveDir : this.goalsDir, `${slug}.md`)
505
+ }
506
+
507
+ private readGoalDir(archived: boolean): Goal[] {
508
+ const dir = archived ? this.goalsArchiveDir : this.goalsDir
509
+ if (!existsSync(dir)) return []
510
+ const goals: Goal[] = []
511
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
512
+ if (!entry.isFile() || !entry.name.endsWith(".md") || entry.name.startsWith(".")) continue
513
+ const slug = entry.name.slice(0, -3)
514
+ const path = join(dir, entry.name)
515
+ const doc = parseGoal(readFileSync(path, "utf8"), path)
516
+ goals.push(this.toGoal(slug, doc, archived))
517
+ }
518
+ goals.sort((a, b) => a.slug.localeCompare(b.slug))
519
+ return goals
520
+ }
521
+
522
+ private toGoal(slug: string, doc: GoalDoc, archived: boolean): Goal {
523
+ return {
524
+ slug,
525
+ title: doc.title,
526
+ description: doc.description,
527
+ createdAt: doc.createdAt,
528
+ updatedAt: doc.updatedAt,
529
+ // Absent on live goals, so existing payloads carry no new field.
530
+ ...(archived ? { archived: true } : {}),
531
+ }
532
+ }
533
+
534
+ goals(archived = false): Goal[] {
535
+ return this.readGoalDir(archived)
536
+ }
537
+
538
+ getGoal(slug: string): Goal | null {
539
+ for (const archived of [false, true]) {
540
+ const path = this.goalPath(slug, archived)
541
+ if (!existsSync(path)) continue
542
+ return this.toGoal(slug, parseGoal(readFileSync(path, "utf8"), path), archived)
543
+ }
544
+ return null
545
+ }
546
+
547
+ /**
548
+ * The gate every ticket write goes through: a `goal:` may only reference a
549
+ * *live* goal. Naming an archived one is refused too — assigning new work to
550
+ * a goal that's been shelved is the same mistake as editing an archived
551
+ * ticket, and gets the same answer.
552
+ */
553
+ private assertGoalAssignable(slug: string): void {
554
+ if (existsSync(this.goalPath(slug, false))) return
555
+ if (existsSync(this.goalPath(slug, true))) {
556
+ throw new Error(`goal "${slug}" is archived — run \`task goal unarchive ${slug}\` first`)
557
+ }
558
+ throw new Error(`no such goal: "${slug}" — create it with \`task goal add\``)
559
+ }
560
+
561
+ private writeGoal(slug: string, doc: GoalDoc, archived: boolean): void {
562
+ mkdirSync(archived ? this.goalsArchiveDir : this.goalsDir, { recursive: true })
563
+ writeAtomic(this.goalPath(slug, archived), serializeGoal(doc))
564
+ }
565
+
566
+ createGoal(input: GoalInput): Goal {
567
+ const title = input.title.trim()
568
+ if (!title) throw new Error("a goal needs a title")
569
+ const slug = input.slug ?? slugifyGoal(title)
570
+ if (!isGoalSlug(slug)) {
571
+ throw new Error(
572
+ `invalid goal slug: "${slug}" — lowercase letters, digits and dashes ("archive" is reserved)`,
573
+ )
574
+ }
575
+ if (this.getGoal(slug)) throw new Error(`goal "${slug}" already exists`)
576
+ const timestamp = now()
577
+ const doc: GoalDoc = {
578
+ title,
579
+ description: input.description ?? "",
580
+ createdAt: timestamp,
581
+ updatedAt: timestamp,
582
+ }
583
+ this.writeGoal(slug, doc, false)
584
+ return this.toGoal(slug, doc, false)
585
+ }
586
+
587
+ updateGoal(slug: string, patch: GoalPatch): Goal {
588
+ const path = this.goalPath(slug, false)
589
+ if (!existsSync(path)) {
590
+ if (existsSync(this.goalPath(slug, true))) {
591
+ throw new Error(`goal "${slug}" is archived — run \`task goal unarchive ${slug}\` first`)
592
+ }
593
+ throw new Error(`no such goal: "${slug}"`)
594
+ }
595
+ const doc = parseGoal(readFileSync(path, "utf8"), path)
596
+ if (patch.title !== undefined) {
597
+ const title = patch.title.trim()
598
+ if (!title) throw new Error("a goal needs a title")
599
+ doc.title = title
600
+ }
601
+ if (patch.description !== undefined) doc.description = patch.description
602
+ doc.updatedAt = now()
603
+ this.writeGoal(slug, doc, false)
604
+ return this.toGoal(slug, doc, false)
605
+ }
606
+
607
+ /**
608
+ * A goal leaves the board only after its work has: archiving is refused
609
+ * while any open task still points here. Done/canceled stragglers are fine —
610
+ * their references stay readable from the archive either way.
611
+ */
612
+ archiveGoal(slug: string): Goal {
613
+ const path = this.goalPath(slug, false)
614
+ if (!existsSync(path)) {
615
+ if (existsSync(this.goalPath(slug, true))) {
616
+ throw new Error(`goal "${slug}" is already archived`)
617
+ }
618
+ throw new Error(`no such goal: "${slug}"`)
619
+ }
620
+ const open = this.readAll().filter(
621
+ (t) => t.doc.goal === slug && t.doc.status !== "done" && t.doc.status !== "canceled",
622
+ )
623
+ if (open.length > 0) {
624
+ const ids = open.map((t) => this.displayId(t.number)).join(", ")
625
+ throw new Error(`goal "${slug}" still has open tasks (${ids}) — finish or reassign them first`)
626
+ }
627
+ mkdirSync(this.goalsArchiveDir, { recursive: true })
628
+ renameSync(path, this.goalPath(slug, true))
629
+ return this.getGoal(slug)!
630
+ }
631
+
632
+ unarchiveGoal(slug: string): Goal {
633
+ const path = this.goalPath(slug, true)
634
+ if (!existsSync(path)) {
635
+ throw new Error(
636
+ existsSync(this.goalPath(slug, false))
637
+ ? `goal "${slug}" isn't archived`
638
+ : `no such goal: "${slug}"`,
639
+ )
640
+ }
641
+ mkdirSync(this.goalsDir, { recursive: true })
642
+ renameSync(path, this.goalPath(slug, false))
643
+ return this.getGoal(slug)!
644
+ }
645
+
646
+ /**
647
+ * Deleting is for goals that never happened. Any live reference — whatever
648
+ * its status — blocks it: pruning organization off finished tickets is a
649
+ * human call, so the human makes it by clearing the refs first. Archived
650
+ * tickets' references are left to dangle; every reader tolerates that.
651
+ */
652
+ deleteGoal(slug: string): void {
653
+ const path = this.goalPath(slug, false)
654
+ if (!existsSync(path)) {
655
+ if (existsSync(this.goalPath(slug, true))) {
656
+ throw new Error(`goal "${slug}" is archived — run \`task goal unarchive ${slug}\` first`)
657
+ }
658
+ throw new Error(`no such goal: "${slug}"`)
659
+ }
660
+ const referencing = this.readAll().filter((t) => t.doc.goal === slug)
661
+ if (referencing.length > 0) {
662
+ const ids = referencing.map((t) => this.displayId(t.number)).join(", ")
663
+ throw new Error(
664
+ `goal "${slug}" is referenced by ${ids} — clear those with \`task update <id> --goal ""\` first`,
665
+ )
666
+ }
667
+ rmSync(path)
668
+ }
669
+
482
670
  // ── Comments ───────────────────────────────────────────────────────────────
483
671
 
484
672
  /**
@@ -549,12 +737,15 @@ export class FileStore implements Store {
549
737
  }
550
738
  }
551
739
 
552
- // ── Opening, initializing, migrating ─────────────────────────────────────────
740
+ // ── Opening and initializing ─────────────────────────────────────────────────
741
+
742
+ /** The database a pre-0.6 board committed. Never read anymore, only ignored. */
743
+ const LEGACY_DB_FILE = "tasks.db"
553
744
 
554
745
  /** What `.task/.gitignore` says on a text-canonical board. */
555
746
  const GITIGNORE = `# Board state is the text files in tickets/ — the database (if one is still
556
- # around from before \`task migrate\`) is derived/legacy and stays out of git.
557
- ${DB_FILE}
747
+ # around from a pre-0.6 board) is legacy and stays out of git.
748
+ ${LEGACY_DB_FILE}
558
749
  *.db-journal
559
750
  *.db-wal
560
751
  *.db-shm
@@ -562,16 +753,22 @@ ${DB_FILE}
562
753
  `
563
754
 
564
755
  /**
565
- * The board in `root`, on whichever backend it uses: text files when the
566
- * `tickets/` tree exists or the config says version 2, the legacy SQLite
567
- * store otherwise. Old boards keep working untouched until `task migrate`.
756
+ * The board in `root`. Text files are the only backend; a board whose state is
757
+ * still a pre-0.6 committed SQLite database is refused loudly rather than read
758
+ * as empty silently starting a parallel text board next to a database full
759
+ * of tickets is the one failure mode worse than an error.
568
760
  */
569
761
  export function openBoard(root: string): Store {
570
762
  const taskDir = join(root, TASK_DIR)
571
- if (existsSync(join(taskDir, TICKETS_DIR))) return new FileStore(root)
572
- const config = JSON.parse(readFileSync(join(taskDir, CONFIG_FILE), "utf8")) as ProjectConfig
573
- if ((config.version ?? 1) >= 2) return new FileStore(root)
574
- return new TaskStore(root)
763
+ if (!existsSync(join(taskDir, TICKETS_DIR))) {
764
+ const config = JSON.parse(readFileSync(join(taskDir, CONFIG_FILE), "utf8")) as ProjectConfig
765
+ if ((config.version ?? 1) < 2 && existsSync(join(taskDir, LEGACY_DB_FILE))) {
766
+ throw new Error(
767
+ "this board still stores its state in a pre-0.6 SQLite database — run `npx @nickmeriano/task@0.6 migrate` once, then retry",
768
+ )
769
+ }
770
+ }
771
+ return new FileStore(root)
575
772
  }
576
773
 
577
774
  export interface InitOptions {
@@ -600,94 +797,3 @@ export function initProject(root: string, options: InitOptions): Store {
600
797
  return new FileStore(root)
601
798
  }
602
799
 
603
- export interface MigrateResult {
604
- tasks: number
605
- comments: number
606
- }
607
-
608
- /**
609
- * `task migrate` — export every ticket and comment from a legacy `tasks.db`
610
- * into the text layout, flip the config to version 2, and gitignore the
611
- * database. The database file itself is left on disk untouched, as a backup;
612
- * it just stops being the state.
613
- */
614
- export function migrateBoard(root: string): MigrateResult {
615
- const taskDir = join(root, TASK_DIR)
616
- if (existsSync(join(taskDir, TICKETS_DIR))) {
617
- throw new Error("already migrated — .task/tickets/ exists")
618
- }
619
- if (!existsSync(join(taskDir, DB_FILE))) {
620
- throw new Error(`nothing to migrate — no ${DB_FILE} in .task/`)
621
- }
622
-
623
- const legacy = new TaskStore(root)
624
- let comments = 0
625
- try {
626
- const tasks = legacy.list({})
627
- for (const task of tasks) {
628
- const dir = join(taskDir, TICKETS_DIR, String(task.number))
629
- mkdirSync(dir, { recursive: true })
630
- writeFileSync(
631
- join(dir, TICKET_FILE),
632
- serializeTicket({
633
- title: task.title,
634
- description: task.description,
635
- status: task.status,
636
- tags: task.tags,
637
- milestone: task.milestone,
638
- needsHuman: task.needsHuman,
639
- blockedBy: task.blockedBy,
640
- prs: task.prs,
641
- position: task.position,
642
- createdAt: task.createdAt,
643
- updatedAt: task.updatedAt,
644
- }),
645
- )
646
- const taskComments = legacy.comments(task.number)
647
- if (taskComments.length === 0) continue
648
- const commentsDir = join(dir, COMMENTS_DIR)
649
- mkdirSync(commentsDir)
650
- let previous: { createdAt: string; id: string } | null = null
651
- for (const comment of taskComments) {
652
- // `comments()` sorts by (createdAt, id), so ids must sort in legacy
653
- // (rowid) order wherever timestamps tie — and a fresh stem for a
654
- // different author can sort *before* the previous one. Chain off the
655
- // previous id instead: the collision suffix below then appends `-2`,
656
- // which sorts after. The frontmatter stays authoritative for the
657
- // author; the filename is only an ordering key.
658
- let stem = commentStem(comment.createdAt, comment.author)
659
- if (
660
- previous &&
661
- comment.createdAt === previous.createdAt &&
662
- stem.localeCompare(previous.id) < 0
663
- ) {
664
- stem = previous.id
665
- }
666
- let id = stem
667
- for (let n = 2; existsSync(join(commentsDir, `${id}.md`)); n++) id = `${stem}-${n}`
668
- previous = { createdAt: comment.createdAt, id }
669
- writeFileSync(
670
- join(commentsDir, `${id}.md`),
671
- serializeComment({
672
- author: comment.author,
673
- createdAt: comment.createdAt,
674
- body: comment.body,
675
- }),
676
- )
677
- comments++
678
- }
679
- }
680
-
681
- // Preserve whatever else the config carries; only the version flips.
682
- const config = JSON.parse(readFileSync(join(taskDir, CONFIG_FILE), "utf8")) as Record<
683
- string,
684
- unknown
685
- >
686
- config.version = 2
687
- writeFileSync(join(taskDir, CONFIG_FILE), `${JSON.stringify(config, null, 2)}\n`)
688
- writeFileSync(join(taskDir, ".gitignore"), GITIGNORE)
689
- return { tasks: tasks.length, comments }
690
- } finally {
691
- legacy.close()
692
- }
693
- }
package/src/index.ts CHANGED
@@ -1,7 +1,6 @@
1
1
  // Programmatic access to the same store the CLI and `task serve` use — for
2
2
  // scripts that want to read or write .task/ without shelling out.
3
3
  export {
4
- TaskStore,
5
4
  boardConfig,
6
5
  derivePrefix,
7
6
  findBoards,
@@ -16,17 +15,20 @@ export {
16
15
  FileStore,
17
16
  TICKETS_DIR,
18
17
  initProject,
19
- migrateBoard,
20
18
  openBoard,
21
19
  } from "./file-store.ts"
22
20
  export {
23
21
  parseTicket,
22
+ parseTicketLenient,
24
23
  serializeTicket,
25
24
  parseComment,
25
+ parseCommentLenient,
26
26
  serializeComment,
27
27
  type TicketDoc,
28
28
  type CommentDoc,
29
29
  } from "./ticket-doc.ts"
30
+ export { checkBoards, checkRoot, type CheckIssue, type CheckResult } from "./check.ts"
31
+ export { searchStore, type SearchMatch, type SearchResult } from "./search.ts"
30
32
  export {
31
33
  DEFAULT_CLAIM_PREFIX,
32
34
  ClaimError,
@@ -0,0 +1,51 @@
1
+ /**
2
+ * `task overview` derives everything at read time — these tests pin the
3
+ * derivations: per-status counts, per-goal progress, needs-human, staleness
4
+ * against an injected clock, oldest-open, and the "unknown, not zero" answer
5
+ * for claims when there is no origin to ask.
6
+ */
7
+
8
+ import assert from "node:assert/strict"
9
+ import { test } from "node:test"
10
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"
11
+ import { tmpdir } from "node:os"
12
+ import { join } from "node:path"
13
+ import { initProject } from "./file-store.ts"
14
+ import { STALE_DAYS, buildOverview } from "./overview.ts"
15
+
16
+ function tempRoot(): string {
17
+ const dir = mkdtempSync(join(tmpdir(), "task-overview-"))
18
+ process.on("exit", () => rmSync(dir, { recursive: true, force: true }))
19
+ return dir
20
+ }
21
+
22
+ test("overview derives board health at read time", () => {
23
+ const root = tempRoot()
24
+ const store = initProject(root, { name: "health", prefix: "HLT" })
25
+ store.createGoal({ title: "Launch" })
26
+ const a = store.create({ title: "Oldest", status: "todo", goal: "launch" })
27
+ const b = store.create({ title: "Shipped", status: "todo", goal: "launch" })
28
+ store.update(b.number, { status: "done" })
29
+ store.create({ title: "Waiting on a person", status: "todo", needsHuman: true })
30
+ store.create({ title: "Still parked", status: "backlog" })
31
+
32
+ // Age the first ticket by hand — the store always writes "now".
33
+ const ticketPath = join(root, ".task", "tickets", String(a.number), "ticket.md")
34
+ const aged = readFileSync(ticketPath, "utf8")
35
+ .replace(/created: .*/, "created: 2020-01-01T00:00:00.000Z")
36
+ .replace(/updated: .*/, "updated: 2020-01-01T00:00:00.000Z")
37
+ writeFileSync(ticketPath, aged)
38
+
39
+ const overview = buildOverview(store, Date.parse("2020-01-10T00:00:00.000Z") + STALE_DAYS * 86_400_000)
40
+ assert.equal(overview.board, "health")
41
+ assert.equal(overview.prefix, "HLT")
42
+ assert.deepEqual(overview.statuses, { backlog: 1, todo: 2, in_progress: 0, done: 1, canceled: 0 })
43
+ assert.equal(overview.open, 3)
44
+ assert.equal(overview.needsHuman, 1)
45
+ // No origin to ask — unknown, not zero.
46
+ assert.equal(overview.claimed, null)
47
+ assert.deepEqual(overview.goals, [{ slug: "launch", title: "Launch", done: 1, total: 2 }])
48
+ // Only the aged ticket is stale at that clock (the others were written just now).
49
+ assert.deepEqual(overview.stale.map((t) => t.id), ["HLT-1"])
50
+ assert.equal(overview.oldestOpen?.id, "HLT-1")
51
+ })
@@ -0,0 +1,90 @@
1
+ /**
2
+ * `task overview` — one screen of board health (TAS-27): the numbers an agent
3
+ * wants before choosing work and the numbers a human wants in a standup.
4
+ * Nothing configurable, no charts, and nothing stored — every figure is
5
+ * derived from the board at read time, the same rule goals follow.
6
+ */
7
+
8
+ import { claimBranch, claimNamespace, remoteClaims } from "./claim.ts"
9
+ import type { Store } from "./store.ts"
10
+ import { STATUSES, type Status } from "./types.ts"
11
+
12
+ /** Untouched this long, an open ticket is stale. Fixed on purpose. */
13
+ export const STALE_DAYS = 14
14
+
15
+ const OPEN_STATUSES: readonly Status[] = ["backlog", "todo", "in_progress"]
16
+
17
+ export interface GoalProgress {
18
+ slug: string
19
+ title: string
20
+ done: number
21
+ total: number
22
+ }
23
+
24
+ export interface TicketRef {
25
+ id: string
26
+ title: string
27
+ }
28
+
29
+ export interface Overview {
30
+ board: string
31
+ prefix: string
32
+ /** Live board only — the archive is history, not health. */
33
+ statuses: Record<Status, number>
34
+ open: number
35
+ needsHuman: number
36
+ /**
37
+ * Open tickets whose claim branch exists on origin right now; null when
38
+ * origin can't be asked (no remote, offline) — unknown, not zero.
39
+ */
40
+ claimed: string[] | null
41
+ goals: GoalProgress[]
42
+ /** Open tickets untouched for STALE_DAYS+, oldest first. */
43
+ stale: (TicketRef & { updatedAt: string })[]
44
+ oldestOpen: (TicketRef & { createdAt: string }) | null
45
+ }
46
+
47
+ export function buildOverview(store: Store, now: number = Date.now()): Overview {
48
+ const tasks = store.list({ statuses: undefined })
49
+ const statuses = Object.fromEntries(STATUSES.map((s) => [s, 0])) as Record<Status, number>
50
+ for (const task of tasks) statuses[task.status]++
51
+ const open = tasks.filter((t) => OPEN_STATUSES.includes(t.status))
52
+
53
+ let claimed: string[] | null = null
54
+ try {
55
+ const branches = remoteClaims(store.root, claimNamespace(store.config))
56
+ claimed = open
57
+ .filter((t) => branches.has(claimBranch(store.config, t.number)))
58
+ .map((t) => t.id)
59
+ } catch {
60
+ claimed = null
61
+ }
62
+
63
+ const goals = store.goals().map((goal) => {
64
+ const mine = tasks.filter((t) => t.goal === goal.slug)
65
+ const done = mine.filter((t) => t.status === "done" || t.status === "canceled").length
66
+ return { slug: goal.slug, title: goal.title, done, total: mine.length }
67
+ })
68
+
69
+ const staleBefore = now - STALE_DAYS * 24 * 60 * 60 * 1000
70
+ const stale = open
71
+ .filter((t) => Date.parse(t.updatedAt) < staleBefore)
72
+ .sort((a, b) => Date.parse(a.updatedAt) - Date.parse(b.updatedAt))
73
+ .map((t) => ({ id: t.id, title: t.title, updatedAt: t.updatedAt }))
74
+
75
+ const oldest = [...open].sort((a, b) => Date.parse(a.createdAt) - Date.parse(b.createdAt))[0]
76
+
77
+ return {
78
+ board: store.config.name,
79
+ prefix: store.config.prefix,
80
+ statuses,
81
+ open: open.length,
82
+ needsHuman: open.filter((t) => t.needsHuman).length,
83
+ claimed,
84
+ goals,
85
+ stale,
86
+ oldestOpen: oldest
87
+ ? { id: oldest.id, title: oldest.title, createdAt: oldest.createdAt }
88
+ : null,
89
+ }
90
+ }