@nickmeriano/task 0.7.1 → 0.9.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 (128) hide show
  1. package/README.md +73 -25
  2. package/dist/asks.test.d.ts +17 -0
  3. package/dist/asks.test.d.ts.map +1 -0
  4. package/dist/asks.test.js +278 -0
  5. package/dist/asks.test.js.map +1 -0
  6. package/dist/check.d.ts +43 -0
  7. package/dist/check.d.ts.map +1 -0
  8. package/dist/check.js +403 -0
  9. package/dist/check.js.map +1 -0
  10. package/dist/check.test.d.ts +9 -0
  11. package/dist/check.test.d.ts.map +1 -0
  12. package/dist/check.test.js +248 -0
  13. package/dist/check.test.js.map +1 -0
  14. package/dist/claim-io.d.ts +73 -0
  15. package/dist/claim-io.d.ts.map +1 -0
  16. package/dist/claim-io.js +344 -0
  17. package/dist/claim-io.js.map +1 -0
  18. package/dist/claim.d.ts +61 -9
  19. package/dist/claim.d.ts.map +1 -1
  20. package/dist/claim.js +197 -67
  21. package/dist/claim.js.map +1 -1
  22. package/dist/claim.test.d.ts +2 -2
  23. package/dist/claim.test.js +235 -64
  24. package/dist/claim.test.js.map +1 -1
  25. package/dist/cli.js +724 -136
  26. package/dist/cli.js.map +1 -1
  27. package/dist/file-store.d.ts +110 -38
  28. package/dist/file-store.d.ts.map +1 -1
  29. package/dist/file-store.js +514 -238
  30. package/dist/file-store.js.map +1 -1
  31. package/dist/git-serve.d.ts +183 -0
  32. package/dist/git-serve.d.ts.map +1 -0
  33. package/dist/git-serve.js +503 -0
  34. package/dist/git-serve.js.map +1 -0
  35. package/dist/git-serve.test.d.ts +16 -0
  36. package/dist/git-serve.test.d.ts.map +1 -0
  37. package/dist/git-serve.test.js +183 -0
  38. package/dist/git-serve.test.js.map +1 -0
  39. package/dist/git.d.ts +65 -0
  40. package/dist/git.d.ts.map +1 -0
  41. package/dist/git.js +114 -0
  42. package/dist/git.js.map +1 -0
  43. package/dist/id.d.ts +39 -0
  44. package/dist/id.d.ts.map +1 -0
  45. package/dist/id.js +67 -0
  46. package/dist/id.js.map +1 -0
  47. package/dist/inbox.d.ts +41 -0
  48. package/dist/inbox.d.ts.map +1 -0
  49. package/dist/inbox.js +56 -0
  50. package/dist/inbox.js.map +1 -0
  51. package/dist/index.d.ts +5 -3
  52. package/dist/index.d.ts.map +1 -1
  53. package/dist/index.js +5 -3
  54. package/dist/index.js.map +1 -1
  55. package/dist/overview.d.ts +52 -0
  56. package/dist/overview.d.ts.map +1 -0
  57. package/dist/overview.js +61 -0
  58. package/dist/overview.js.map +1 -0
  59. package/dist/overview.test.d.ts +8 -0
  60. package/dist/overview.test.d.ts.map +1 -0
  61. package/dist/overview.test.js +48 -0
  62. package/dist/overview.test.js.map +1 -0
  63. package/dist/promote.test.d.ts +15 -0
  64. package/dist/promote.test.d.ts.map +1 -0
  65. package/dist/promote.test.js +104 -0
  66. package/dist/promote.test.js.map +1 -0
  67. package/dist/publish.d.ts +2 -17
  68. package/dist/publish.d.ts.map +1 -1
  69. package/dist/publish.js +4 -49
  70. package/dist/publish.js.map +1 -1
  71. package/dist/search.d.ts +34 -0
  72. package/dist/search.d.ts.map +1 -0
  73. package/dist/search.js +79 -0
  74. package/dist/search.js.map +1 -0
  75. package/dist/search.test.d.ts +2 -0
  76. package/dist/search.test.d.ts.map +1 -0
  77. package/dist/search.test.js +53 -0
  78. package/dist/search.test.js.map +1 -0
  79. package/dist/server.d.ts.map +1 -1
  80. package/dist/server.js +228 -23
  81. package/dist/server.js.map +1 -1
  82. package/dist/store.d.ts +43 -63
  83. package/dist/store.d.ts.map +1 -1
  84. package/dist/store.js +0 -368
  85. package/dist/store.js.map +1 -1
  86. package/dist/store.test.d.ts +1 -2
  87. package/dist/store.test.d.ts.map +1 -1
  88. package/dist/store.test.js +148 -106
  89. package/dist/store.test.js.map +1 -1
  90. package/dist/ticket-doc.d.ts +74 -5
  91. package/dist/ticket-doc.d.ts.map +1 -1
  92. package/dist/ticket-doc.js +229 -15
  93. package/dist/ticket-doc.js.map +1 -1
  94. package/dist/types.d.ts +115 -28
  95. package/dist/types.d.ts.map +1 -1
  96. package/dist/types.js.map +1 -1
  97. package/package.json +1 -1
  98. package/skill/SKILL.md +153 -40
  99. package/src/asks.test.ts +355 -0
  100. package/src/check.test.ts +328 -0
  101. package/src/check.ts +497 -0
  102. package/src/claim-io.ts +401 -0
  103. package/src/claim.test.ts +301 -71
  104. package/src/claim.ts +238 -81
  105. package/src/cli.ts +740 -131
  106. package/src/file-store.ts +572 -254
  107. package/src/git-serve.test.ts +240 -0
  108. package/src/git-serve.ts +595 -0
  109. package/src/git.ts +141 -0
  110. package/src/id.ts +68 -0
  111. package/src/inbox.ts +77 -0
  112. package/src/index.ts +4 -2
  113. package/src/overview.test.ts +52 -0
  114. package/src/overview.ts +105 -0
  115. package/src/promote.test.ts +143 -0
  116. package/src/publish.ts +6 -53
  117. package/src/search.test.ts +64 -0
  118. package/src/search.ts +105 -0
  119. package/src/server.ts +232 -21
  120. package/src/store.test.ts +166 -116
  121. package/src/store.ts +46 -444
  122. package/src/ticket-doc.ts +284 -21
  123. package/src/types.ts +120 -28
  124. package/ui/dist/assets/index-BjsorZOU.js +229 -0
  125. package/ui/dist/assets/index-CoKCUYic.css +1 -0
  126. package/ui/dist/index.html +2 -2
  127. package/ui/dist/assets/index-COunM-QN.css +0 -1
  128. package/ui/dist/assets/index-D4homvrQ.js +0 -229
package/src/git.ts ADDED
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Every git spawn in the CLI goes through here (TAS-40, folded into TAS-42):
3
+ * one place that guarantees a spawn can never wedge the process. Each call is
4
+ * non-interactive — `GIT_TERMINAL_PROMPT=0` turns a would-be credential
5
+ * prompt into a fast failure — and bounded by a timeout, because several of
6
+ * these run inside `task serve` request handlers, where a hung prompt or a
7
+ * wedged remote would hang the board for everyone looking at it.
8
+ *
9
+ * Three calling styles, because callers genuinely differ on what failure is:
10
+ * `git` returns the full result (claim flows branch on exit codes), `gitMust`
11
+ * throws (steps with no soft outcome), `gitOut` returns null (read paths that
12
+ * degrade to "can't ask git" — the serve/inbox rule).
13
+ */
14
+
15
+ import { spawnSync } from "node:child_process"
16
+
17
+ export interface GitResult {
18
+ status: number
19
+ stdout: string
20
+ stderr: string
21
+ }
22
+
23
+ export interface GitOptions {
24
+ /** Extra env on top of the non-interactive baseline — e.g. GIT_INDEX_FILE. */
25
+ env?: Record<string, string>
26
+ /** Piped to stdin — e.g. `hash-object -w --stdin`. */
27
+ input?: string
28
+ timeoutMs?: number
29
+ }
30
+
31
+ /** Local operations answer fast or something is genuinely wrong. */
32
+ export const GIT_TIMEOUT_MS = 30_000
33
+ /** Network operations (a full fetch, a push) get real-world headroom. */
34
+ export const GIT_NETWORK_TIMEOUT_MS = 120_000
35
+
36
+ /**
37
+ * The one spawn. A failed spawn (git missing, timeout hit) reads as status 1
38
+ * with the reason in stderr — callers treat it exactly like a failed command,
39
+ * which for every caller here is the truth that matters.
40
+ */
41
+ export function runGit(cwd: string, args: string[], options: GitOptions = {}): GitResult {
42
+ const result = spawnSync("git", args, {
43
+ cwd,
44
+ encoding: "utf8",
45
+ input: options.input,
46
+ timeout: options.timeoutMs ?? GIT_TIMEOUT_MS,
47
+ env: { ...process.env, GIT_TERMINAL_PROMPT: "0", ...options.env },
48
+ })
49
+ if (result.error) {
50
+ return { status: 1, stdout: "", stderr: result.error.message }
51
+ }
52
+ return {
53
+ status: result.status ?? 1,
54
+ stdout: (result.stdout ?? "").trim(),
55
+ stderr: (result.stderr ?? "").trim(),
56
+ }
57
+ }
58
+
59
+ export function git(cwd: string, ...args: string[]): GitResult {
60
+ return runGit(cwd, args)
61
+ }
62
+
63
+ /** Run git and throw on failure — for the steps that have no soft outcome. */
64
+ export function gitMust(cwd: string, ...args: string[]): string {
65
+ const result = runGit(cwd, args)
66
+ if (result.status !== 0) {
67
+ throw new Error(`git ${args[0]} failed: ${result.stderr || result.stdout}`)
68
+ }
69
+ return result.stdout
70
+ }
71
+
72
+ /** Soft git: stdout on success, null on any failure — callers decide if that's an error. */
73
+ export function gitOut(cwd: string, ...args: string[]): string | null {
74
+ const result = runGit(cwd, args)
75
+ return result.status === 0 ? result.stdout : null
76
+ }
77
+
78
+ /**
79
+ * The base a claim or board branch starts from: origin's default branch.
80
+ * Resolved from `origin/HEAD` when the clone recorded it, with a main/master
81
+ * fallback for repos wired up by hand (`git remote add` + push never sets
82
+ * origin/HEAD).
83
+ */
84
+ export function defaultBase(cwd: string): string {
85
+ const head = git(cwd, "symbolic-ref", "--quiet", "refs/remotes/origin/HEAD")
86
+ if (head.status === 0) return head.stdout.replace(/^refs\/remotes\//, "")
87
+ for (const name of ["main", "master"]) {
88
+ if (git(cwd, "show-ref", "--verify", "--quiet", `refs/remotes/origin/${name}`).status === 0) {
89
+ return `origin/${name}`
90
+ }
91
+ }
92
+ throw new Error(
93
+ "couldn't resolve origin's default branch — origin/HEAD is unset and neither origin/main nor origin/master exists",
94
+ )
95
+ }
96
+
97
+ // ── Remote identity ──────────────────────────────────────────────────────────
98
+
99
+ export interface RepoRef {
100
+ owner: string
101
+ repo: string
102
+ }
103
+
104
+ /**
105
+ * The GitHub repo this working copy pushes to.
106
+ *
107
+ * `origin` first, then the only remote if there's exactly one — a clone with
108
+ * `upstream` and a fork remote should not silently pick the wrong one, so
109
+ * ambiguity reads as "no repo" and the caller asks the user.
110
+ */
111
+ export function detectRepo(cwd: string): RepoRef | null {
112
+ const remotes = gitRemotes(cwd)
113
+ const url = remotes.get("origin") ?? (remotes.size === 1 ? [...remotes.values()][0] : null)
114
+ return url ? parseRemote(url) : null
115
+ }
116
+
117
+ function gitRemotes(cwd: string): Map<string, string> {
118
+ const out = new Map<string, string>()
119
+ const raw = gitOut(cwd, "remote", "-v")
120
+ if (raw === null) return out
121
+ for (const line of raw.split("\n")) {
122
+ const match = /^(\S+)\s+(\S+)\s+\(fetch\)$/.exec(line.trim())
123
+ if (match) out.set(match[1], match[2])
124
+ }
125
+ return out
126
+ }
127
+
128
+ /**
129
+ * Every shape git writes a GitHub remote in: https, ssh, `git@`-scp, with or
130
+ * without `.git`, and with or without credentials embedded in the host.
131
+ */
132
+ export function parseRemote(url: string): RepoRef | null {
133
+ const cleaned = url.trim().replace(/\.git$/, "")
134
+ const match =
135
+ /^(?:https?:\/\/|ssh:\/\/)?(?:[^@/]+@)?github\.com[:/]([^/]+)\/([^/]+?)\/?$/.exec(cleaned)
136
+ if (!match) return null
137
+ const [, owner, repo] = match
138
+ if (!/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/.test(owner)) return null
139
+ if (!/^[A-Za-z0-9._-]{1,100}$/.test(repo)) return null
140
+ return { owner, repo }
141
+ }
package/src/id.ts ADDED
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Ticket keys — the branch-safe identity scheme (TAS-42).
3
+ *
4
+ * A ticket's key is 5 random characters from a 27-letter alphabet, minted at
5
+ * `task add` with no coordination: two checkouts that have never seen each
6
+ * other's commits can't mint the same key except by 1-in-14M chance per pair,
7
+ * and `task check`'s duplicate lint catches the miracle at merge. The key IS
8
+ * the ticket's directory name under `.task/tickets/`, so allocation is safe by
9
+ * construction — git can't merge two different tickets into one directory.
10
+ *
11
+ * The alphabet drops vowels (keys can't spell words), 0/1/i/l/o/u (no
12
+ * confusable glyphs when a key is read aloud or typed from a screen), and is
13
+ * all-lowercase (keys land in claim branch names, which are case-sensitive
14
+ * refs). A key always contains a letter — an all-digit roll is re-rolled — so
15
+ * a purely numeric directory is unambiguously a pre-migration ticket, which
16
+ * is exactly how the check lint tells the two apart.
17
+ *
18
+ * Keys deliberately carry no timestamp and no sequence: identity must be
19
+ * dumber than metadata. Creation order lives in `created:`; board order lives
20
+ * in `position:`. Anything the key "said" beyond naming the ticket could be
21
+ * wrong one day, and a wrong id breaks every reference to it.
22
+ */
23
+
24
+ export const KEY_ALPHABET = "23456789bcdfghjkmnpqrstvwxz"
25
+ export const KEY_LENGTH = 5
26
+
27
+ /**
28
+ * What a ticket key may look like — the shape readers accept, wider than what
29
+ * minting produces so a future length change doesn't strand old boards. Must
30
+ * contain a letter: an all-digit name is a pre-migration ticket number, and
31
+ * the two must never be confusable.
32
+ */
33
+ export const KEY_PATTERN = /^(?=.*[a-z])[a-z0-9]{4,12}$/
34
+
35
+ export function isTicketKey(value: string): boolean {
36
+ return KEY_PATTERN.test(value)
37
+ }
38
+
39
+ /**
40
+ * One random roll — Web Crypto (global in Node ≥19 and in Workers, so this
41
+ * module stays importable by the hosted board), with rejection sampling so
42
+ * the 27-letter alphabet gets a uniform draw rather than a modulo bias.
43
+ */
44
+ function roll(): string {
45
+ const limit = 256 - (256 % KEY_ALPHABET.length)
46
+ let key = ""
47
+ while (key.length < KEY_LENGTH) {
48
+ const bytes = crypto.getRandomValues(new Uint8Array(KEY_LENGTH * 2))
49
+ for (const byte of bytes) {
50
+ if (byte >= limit || key.length >= KEY_LENGTH) continue
51
+ key += KEY_ALPHABET[byte % KEY_ALPHABET.length]
52
+ }
53
+ }
54
+ return key
55
+ }
56
+
57
+ /**
58
+ * Mint a key no ticket on this checkout uses. The local-collision retry is
59
+ * near-free insurance; the cross-branch case has no lock by design and rides
60
+ * on the odds (27^5 ≈ 14M keys) plus the check lint.
61
+ */
62
+ export function mintKey(taken: ReadonlySet<string>): string {
63
+ for (;;) {
64
+ const key = roll()
65
+ if (!KEY_PATTERN.test(key)) continue // all-digit roll — must stay pre-migration-only
66
+ if (!taken.has(key)) return key
67
+ }
68
+ }
package/src/inbox.ts ADDED
@@ -0,0 +1,77 @@
1
+ /**
2
+ * `task inbox` — every open ask on effectively-in-progress tickets, repo-wide.
3
+ * The one list a person checks to unblock work NOW (TAS-38): each entry is a
4
+ * stalled worker or stalled work, nothing else.
5
+ *
6
+ * "Effectively in progress" is TAS-26's read core: between claim and merge the
7
+ * default branch still says `todo` — the claim branch is the truth about
8
+ * in-flight work, and the branch *name* carries the ticket id, so resolving it
9
+ * costs one fetch of the claim refs, never a general branch scan. The asks a
10
+ * worker files mid-run live on its claim branch too, so the inbox reads each
11
+ * claimed ticket's ask files from the branch and unions them with the local
12
+ * ones by id (asks are one file each, so the union is exact; see claim-io.ts,
13
+ * which also owns the write side of the same rule). Offline, or with no
14
+ * origin at all, everything degrades silently to file truth — a board you
15
+ * can't fetch is still a board.
16
+ *
17
+ * Backlog and todo asks are deliberately out of scope: unstarted work owes
18
+ * nobody anything yet, and claiming is what promotes an ask into the inbox.
19
+ */
20
+
21
+ import { claimBranch, claimNamespace } from "./claim.ts"
22
+ import { mergedAsks, remoteClaimTips, syncClaimRefs } from "./claim-io.ts"
23
+ import type { Store } from "./store.ts"
24
+ import type { Ask, Task } from "./types.ts"
25
+
26
+ /** A board handed to buildInbox: the store plus the id the UI navigates with. */
27
+ export interface InboxBoard {
28
+ /** Path relative to the repo's board scope — "." for the root board. */
29
+ id: string
30
+ store: Store
31
+ }
32
+
33
+ export interface InboxEntry {
34
+ board: string
35
+ prefix: string
36
+ /** File truth — a claimed ticket may still read `todo` here; `claim` says why it counts. */
37
+ task: Task
38
+ /** Present when the ticket's claim branch exists on origin. */
39
+ claim: { branch: string } | null
40
+ /** Open asks only, oldest first; ordinals match `task show`'s (the union's, when claimed). */
41
+ asks: Ask[]
42
+ }
43
+
44
+ /** Oldest open ask decides an entry's urgency — and the whole list's order. */
45
+ function oldestAsk(entry: InboxEntry): string {
46
+ return entry.asks[0]?.createdAt ?? ""
47
+ }
48
+
49
+ export function buildInbox(boards: InboxBoard[]): InboxEntry[] {
50
+ const entries: InboxEntry[] = []
51
+ for (const { id, store } of boards) {
52
+ // One bounded ls-remote per board; branches are then fetched only when
53
+ // their tip moved (syncClaimRefs), so a refetch-happy UI costs one round
54
+ // trip, not a fetch storm. Null = origin unreachable = file truth only.
55
+ const tips = remoteClaimTips(store)
56
+ const claimed = tips ? new Set(tips.keys()) : null
57
+ if (tips && tips.size > 0) {
58
+ try {
59
+ syncClaimRefs(store, claimNamespace(store.config), tips)
60
+ } catch {
61
+ // Namespace validated by remoteClaimTips already; nothing to do.
62
+ }
63
+ }
64
+ // in_progress is in by file truth; todo is in only via a live claim.
65
+ for (const task of store.list({ statuses: ["in_progress", "todo"] })) {
66
+ const branch = claimBranch(store.config, task.key)
67
+ const claim = claimed?.has(branch) ? { branch } : null
68
+ if (task.status !== "in_progress" && !claim) continue
69
+ const asks = (claim ? mergedAsks(store, branch, task) : task.asks).filter(
70
+ (a) => !a.resolvedAt,
71
+ )
72
+ if (asks.length === 0) continue
73
+ entries.push({ board: id, prefix: store.config.prefix, task, claim, asks })
74
+ }
75
+ }
76
+ return entries.sort((a, b) => oldestAsk(a).localeCompare(oldestAsk(b)))
77
+ }
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,52 @@
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.key, { status: "done" })
29
+ const person = store.create({ title: "Waiting on a person", status: "todo" })
30
+ store.addAsk(person.key, "Sign the SOC2 form", "claude")
31
+ store.create({ title: "Still parked", status: "backlog" })
32
+
33
+ // Age the first ticket by hand — the store always writes "now".
34
+ const ticketPath = join(root, ".task", "tickets", a.key, "ticket.md")
35
+ const aged = readFileSync(ticketPath, "utf8")
36
+ .replace(/created: .*/, "created: 2020-01-01T00:00:00.000Z")
37
+ .replace(/updated: .*/, "updated: 2020-01-01T00:00:00.000Z")
38
+ writeFileSync(ticketPath, aged)
39
+
40
+ const overview = buildOverview(store, Date.parse("2020-01-10T00:00:00.000Z") + STALE_DAYS * 86_400_000)
41
+ assert.equal(overview.board, "health")
42
+ assert.equal(overview.prefix, "HLT")
43
+ assert.deepEqual(overview.statuses, { backlog: 1, todo: 2, in_progress: 0, done: 1, canceled: 0 })
44
+ assert.equal(overview.open, 3)
45
+ assert.equal(overview.needsHuman, 1)
46
+ // No origin to ask — unknown, not zero.
47
+ assert.equal(overview.claimed, null)
48
+ assert.deepEqual(overview.goals, [{ slug: "launch", title: "Launch", done: 1, total: 2 }])
49
+ // Only the aged ticket is stale at that clock (the others were written just now).
50
+ assert.deepEqual(overview.stale.map((t) => t.id), [a.id])
51
+ assert.equal(overview.oldestOpen?.id, a.id)
52
+ })
@@ -0,0 +1,105 @@
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 } from "./claim.ts"
9
+ import { remoteClaimTips } from "./claim-io.ts"
10
+ import type { Store } from "./store.ts"
11
+ import { STATUSES, type Status } from "./types.ts"
12
+
13
+ /** Untouched this long, an open ticket is stale. Fixed on purpose. */
14
+ export const STALE_DAYS = 14
15
+
16
+ const OPEN_STATUSES: readonly Status[] = ["backlog", "todo", "in_progress"]
17
+
18
+ export interface GoalProgress {
19
+ slug: string
20
+ title: string
21
+ done: number
22
+ total: number
23
+ }
24
+
25
+ export interface TicketRef {
26
+ id: string
27
+ title: string
28
+ }
29
+
30
+ export interface Overview {
31
+ board: string
32
+ prefix: string
33
+ /** Live board only — the archive is history, not health. */
34
+ statuses: Record<Status, number>
35
+ open: number
36
+ /** Open tickets a person owes something — i.e. carrying open asks. */
37
+ needsHuman: number
38
+ /** Open asks across the live board's open tickets. */
39
+ openAsks: number
40
+ /** The ask that's been owed longest — the standup's guilty number. */
41
+ oldestAsk: { taskId: string; text: string; createdAt: string } | null
42
+ /**
43
+ * Open tickets whose claim branch exists on origin right now; null when
44
+ * origin can't be asked (no remote, offline) — unknown, not zero.
45
+ */
46
+ claimed: string[] | null
47
+ goals: GoalProgress[]
48
+ /** Open tickets untouched for STALE_DAYS+, oldest first. */
49
+ stale: (TicketRef & { updatedAt: string })[]
50
+ oldestOpen: (TicketRef & { createdAt: string }) | null
51
+ }
52
+
53
+ export function buildOverview(store: Store, now: number = Date.now()): Overview {
54
+ const tasks = store.list({ statuses: undefined })
55
+ const statuses = Object.fromEntries(STATUSES.map((s) => [s, 0])) as Record<Status, number>
56
+ for (const task of tasks) statuses[task.status]++
57
+ const open = tasks.filter((t) => OPEN_STATUSES.includes(t.status))
58
+
59
+ // The bounded read — a board health check must never hang on a credential
60
+ // prompt; unreachable origin reads as "claimed ?" (null), not zero.
61
+ const tips = remoteClaimTips(store)
62
+ const claimed = tips
63
+ ? open.filter((t) => tips.has(claimBranch(store.config, t.key))).map((t) => t.id)
64
+ : null
65
+
66
+ const goals = store.goals().map((goal) => {
67
+ const mine = tasks.filter((t) => t.goal === goal.slug)
68
+ const done = mine.filter((t) => t.status === "done" || t.status === "canceled").length
69
+ return { slug: goal.slug, title: goal.title, done, total: mine.length }
70
+ })
71
+
72
+ const staleBefore = now - STALE_DAYS * 24 * 60 * 60 * 1000
73
+ const stale = open
74
+ .filter((t) => Date.parse(t.updatedAt) < staleBefore)
75
+ .sort((a, b) => Date.parse(a.updatedAt) - Date.parse(b.updatedAt))
76
+ .map((t) => ({ id: t.id, title: t.title, updatedAt: t.updatedAt }))
77
+
78
+ const oldest = [...open].sort((a, b) => Date.parse(a.createdAt) - Date.parse(b.createdAt))[0]
79
+
80
+ const openAsks = open
81
+ .flatMap((t) => t.asks.filter((a) => !a.resolvedAt).map((a) => ({ taskId: t.id, ask: a })))
82
+ .sort((a, b) => a.ask.createdAt.localeCompare(b.ask.createdAt))
83
+
84
+ return {
85
+ board: store.config.name,
86
+ prefix: store.config.prefix,
87
+ statuses,
88
+ open: open.length,
89
+ needsHuman: open.filter((t) => t.needsHuman).length,
90
+ openAsks: openAsks.length,
91
+ oldestAsk: openAsks[0]
92
+ ? {
93
+ taskId: openAsks[0].taskId,
94
+ text: openAsks[0].ask.text,
95
+ createdAt: openAsks[0].ask.createdAt,
96
+ }
97
+ : null,
98
+ claimed,
99
+ goals,
100
+ stale,
101
+ oldestOpen: oldest
102
+ ? { id: oldest.id, title: oldest.title, createdAt: oldest.createdAt }
103
+ : null,
104
+ }
105
+ }
@@ -0,0 +1,143 @@
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 tickets still promote, with the hold reported (stdout note,
9
+ * `holds` in --json) — blockers are claim-time gates. Needs-human isn't
10
+ * one at all anymore: asks gate done, not claiming.
11
+ * 4. `task add` with no --status lands in backlog: the dump list is the
12
+ * default, todo is the deliberate choice promote makes.
13
+ */
14
+
15
+ import assert from "node:assert/strict"
16
+ import { test } from "node:test"
17
+ import { execFile } from "node:child_process"
18
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs"
19
+ import { tmpdir } from "node:os"
20
+ import { join } from "node:path"
21
+ import { fileURLToPath } from "node:url"
22
+ import { initProject } from "./file-store.ts"
23
+ import type { Task } from "./types.ts"
24
+
25
+ const CLI = fileURLToPath(new URL("./cli.ts", import.meta.url))
26
+
27
+ function tempDir(): string {
28
+ const dir = mkdtempSync(join(tmpdir(), "task-promote-test-"))
29
+ process.on("exit", () => rmSync(dir, { recursive: true, force: true }))
30
+ return dir
31
+ }
32
+
33
+ interface CliResult {
34
+ code: number
35
+ stdout: string
36
+ stderr: string
37
+ }
38
+
39
+ /** The CLI as callers see it — a subprocess with an exit code. */
40
+ function cli(cwd: string, ...args: string[]): Promise<CliResult> {
41
+ return new Promise((resolve) => {
42
+ execFile(
43
+ process.execPath,
44
+ ["--experimental-strip-types", CLI, ...args],
45
+ { cwd },
46
+ (error, stdout, stderr) => {
47
+ resolve({ code: error ? ((error as { code?: number }).code ?? 1) : 0, stdout, stderr })
48
+ },
49
+ )
50
+ })
51
+ }
52
+
53
+ /**
54
+ * A board with every promotion shape. Promotion never touches git, so a bare
55
+ * .task/ directory is the whole fixture:
56
+ * liner "One-liner" backlog (no tags — nothing else required)
57
+ * also "Also backlog" backlog
58
+ * blocked "Blocked" backlog, blocked by `also`
59
+ * person "For a person" backlog, carrying an open ask
60
+ * queued "Already queued" todo
61
+ */
62
+ interface Fixture {
63
+ dir: string
64
+ ids: { liner: string; also: string; blocked: string; person: string; queued: string }
65
+ }
66
+
67
+ function fixture(): Fixture {
68
+ const dir = tempDir()
69
+ const store = initProject(dir, { name: "promote board", prefix: "PRO" })
70
+ const liner = store.create({ title: "One-liner", status: "backlog" })
71
+ const also = store.create({ title: "Also backlog", status: "backlog" })
72
+ const blocked = store.create({ title: "Blocked", status: "backlog", blockedBy: [also.key] })
73
+ const person = store.create({ title: "For a person", status: "backlog" })
74
+ store.addAsk(person.key, "Pick the vendor", "claude")
75
+ const queued = store.create({ title: "Already queued", status: "todo" })
76
+ store.close()
77
+ return {
78
+ dir,
79
+ ids: { liner: liner.id, also: also.id, blocked: blocked.id, person: person.id, queued: queued.id },
80
+ }
81
+ }
82
+
83
+ async function show(dir: string, id: string): Promise<Task> {
84
+ const result = await cli(dir, "show", id, "--json")
85
+ assert.equal(result.code, 0, result.stderr)
86
+ return (JSON.parse(result.stdout) as { task: Task }).task
87
+ }
88
+
89
+ test("any backlog ticket promotes to todo — no quality gate", async () => {
90
+ const { dir, ids } = fixture()
91
+ const result = await cli(dir, "promote", ids.liner, "--json")
92
+ assert.equal(result.code, 0, result.stderr)
93
+ const { task, holds } = JSON.parse(result.stdout) as { task: Task; holds: string[] }
94
+ assert.equal(task.status, "todo")
95
+ assert.deepEqual(holds, [])
96
+ assert.equal((await show(dir, ids.liner)).status, "todo")
97
+ })
98
+
99
+ test("only backlog tickets promote", async () => {
100
+ const { dir, ids } = fixture()
101
+ const result = await cli(dir, "promote", ids.queued)
102
+ assert.equal(result.code, 2)
103
+ assert.match(result.stderr, /is todo — only backlog tickets/)
104
+ })
105
+
106
+ test("blocked tickets promote with the hold reported; needs-human isn't a hold", async () => {
107
+ const { dir, ids } = fixture()
108
+ const blocked = await cli(dir, "promote", ids.blocked, "--json")
109
+ assert.equal(blocked.code, 0, blocked.stderr)
110
+ const parsed = JSON.parse(blocked.stdout) as { task: Task; holds: string[] }
111
+ assert.equal(parsed.task.status, "todo")
112
+ assert.deepEqual(parsed.holds, [`blocked by ${ids.also}`])
113
+
114
+ // Asks gate done, not claiming — so a human-owing ticket promotes with
115
+ // nothing to report.
116
+ const person = await cli(dir, "promote", ids.person, "--json")
117
+ assert.equal(person.code, 0, person.stderr)
118
+ assert.deepEqual((JSON.parse(person.stdout) as { holds: string[] }).holds, [])
119
+ assert.equal((await show(dir, ids.person)).status, "todo")
120
+ })
121
+
122
+ test("task add with no --status lands in backlog", async () => {
123
+ const { dir } = fixture()
124
+ const added = await cli(dir, "add", "Dumped in passing", "--json")
125
+ assert.equal(added.code, 0, added.stderr)
126
+ const { task } = JSON.parse(added.stdout) as { task: Task }
127
+ assert.equal(task.status, "backlog")
128
+ })
129
+
130
+ test("--description-file reads markdown from disk; combining both flags refuses", async () => {
131
+ const { dir } = fixture()
132
+ const body = "## Plan\n\nA description with \"quotes\", `backticks`,\nand $(subshells) that never touch a shell.\n"
133
+ writeFileSync(join(dir, "desc.md"), body)
134
+
135
+ const added = await cli(dir, "add", "From a file", "--description-file", "desc.md", "--json")
136
+ assert.equal(added.code, 0, added.stderr)
137
+ const { task } = JSON.parse(added.stdout) as { task: Task }
138
+ assert.match(task.description ?? "", /"quotes", `backticks`,\nand \$\(subshells\)/)
139
+
140
+ const both = await cli(dir, "update", task.id, "--description", "x", "--description-file", "desc.md")
141
+ assert.equal(both.code, 1)
142
+ assert.match(both.stderr, /not both/)
143
+ })