@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.
- package/README.md +73 -25
- 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 +43 -0
- package/dist/check.d.ts.map +1 -0
- package/dist/check.js +403 -0
- package/dist/check.js.map +1 -0
- package/dist/check.test.d.ts +9 -0
- package/dist/check.test.d.ts.map +1 -0
- package/dist/check.test.js +248 -0
- package/dist/check.test.js.map +1 -0
- 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 +61 -9
- package/dist/claim.d.ts.map +1 -1
- package/dist/claim.js +197 -67
- package/dist/claim.js.map +1 -1
- package/dist/claim.test.d.ts +2 -2
- package/dist/claim.test.js +235 -64
- package/dist/claim.test.js.map +1 -1
- package/dist/cli.js +724 -136
- package/dist/cli.js.map +1 -1
- package/dist/file-store.d.ts +110 -38
- package/dist/file-store.d.ts.map +1 -1
- package/dist/file-store.js +514 -238
- 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/index.d.ts +5 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -3
- package/dist/index.js.map +1 -1
- package/dist/overview.d.ts +52 -0
- package/dist/overview.d.ts.map +1 -0
- package/dist/overview.js +61 -0
- package/dist/overview.js.map +1 -0
- package/dist/overview.test.d.ts +8 -0
- package/dist/overview.test.d.ts.map +1 -0
- package/dist/overview.test.js +48 -0
- package/dist/overview.test.js.map +1 -0
- package/dist/promote.test.d.ts +15 -0
- package/dist/promote.test.d.ts.map +1 -0
- package/dist/promote.test.js +104 -0
- package/dist/promote.test.js.map +1 -0
- 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 +34 -0
- package/dist/search.d.ts.map +1 -0
- package/dist/search.js +79 -0
- package/dist/search.js.map +1 -0
- package/dist/search.test.d.ts +2 -0
- package/dist/search.test.d.ts.map +1 -0
- package/dist/search.test.js +53 -0
- package/dist/search.test.js.map +1 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +228 -23
- package/dist/server.js.map +1 -1
- package/dist/store.d.ts +43 -63
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +0 -368
- package/dist/store.js.map +1 -1
- package/dist/store.test.d.ts +1 -2
- package/dist/store.test.d.ts.map +1 -1
- package/dist/store.test.js +148 -106
- package/dist/store.test.js.map +1 -1
- package/dist/ticket-doc.d.ts +74 -5
- package/dist/ticket-doc.d.ts.map +1 -1
- package/dist/ticket-doc.js +229 -15
- package/dist/ticket-doc.js.map +1 -1
- package/dist/types.d.ts +115 -28
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +1 -1
- package/skill/SKILL.md +153 -40
- package/src/asks.test.ts +355 -0
- package/src/check.test.ts +328 -0
- package/src/check.ts +497 -0
- package/src/claim-io.ts +401 -0
- package/src/claim.test.ts +301 -71
- package/src/claim.ts +238 -81
- package/src/cli.ts +740 -131
- package/src/file-store.ts +572 -254
- 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/index.ts +4 -2
- package/src/overview.test.ts +52 -0
- package/src/overview.ts +105 -0
- package/src/promote.test.ts +143 -0
- package/src/publish.ts +6 -53
- package/src/search.test.ts +64 -0
- package/src/search.ts +105 -0
- package/src/server.ts +232 -21
- package/src/store.test.ts +166 -116
- package/src/store.ts +46 -444
- package/src/ticket-doc.ts +284 -21
- package/src/types.ts +120 -28
- 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-COunM-QN.css +0 -1
- 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
|
+
})
|
package/src/overview.ts
ADDED
|
@@ -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
|
+
})
|