@nickmeriano/task 0.8.0 → 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 (114) hide show
  1. package/README.md +65 -22
  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 +3 -2
  7. package/dist/check.d.ts.map +1 -1
  8. package/dist/check.js +99 -60
  9. package/dist/check.js.map +1 -1
  10. package/dist/check.test.js +99 -60
  11. package/dist/check.test.js.map +1 -1
  12. package/dist/claim-io.d.ts +73 -0
  13. package/dist/claim-io.d.ts.map +1 -0
  14. package/dist/claim-io.js +344 -0
  15. package/dist/claim-io.js.map +1 -0
  16. package/dist/claim.d.ts +21 -22
  17. package/dist/claim.d.ts.map +1 -1
  18. package/dist/claim.js +58 -83
  19. package/dist/claim.js.map +1 -1
  20. package/dist/claim.test.d.ts +2 -2
  21. package/dist/claim.test.js +111 -94
  22. package/dist/claim.test.js.map +1 -1
  23. package/dist/cli.js +241 -64
  24. package/dist/cli.js.map +1 -1
  25. package/dist/file-store.d.ts +70 -24
  26. package/dist/file-store.d.ts.map +1 -1
  27. package/dist/file-store.js +319 -141
  28. package/dist/file-store.js.map +1 -1
  29. package/dist/git-serve.d.ts +183 -0
  30. package/dist/git-serve.d.ts.map +1 -0
  31. package/dist/git-serve.js +503 -0
  32. package/dist/git-serve.js.map +1 -0
  33. package/dist/git-serve.test.d.ts +16 -0
  34. package/dist/git-serve.test.d.ts.map +1 -0
  35. package/dist/git-serve.test.js +183 -0
  36. package/dist/git-serve.test.js.map +1 -0
  37. package/dist/git.d.ts +65 -0
  38. package/dist/git.d.ts.map +1 -0
  39. package/dist/git.js +114 -0
  40. package/dist/git.js.map +1 -0
  41. package/dist/id.d.ts +39 -0
  42. package/dist/id.d.ts.map +1 -0
  43. package/dist/id.js +67 -0
  44. package/dist/id.js.map +1 -0
  45. package/dist/inbox.d.ts +41 -0
  46. package/dist/inbox.d.ts.map +1 -0
  47. package/dist/inbox.js +56 -0
  48. package/dist/inbox.js.map +1 -0
  49. package/dist/overview.d.ts +9 -0
  50. package/dist/overview.d.ts.map +1 -1
  51. package/dist/overview.js +19 -11
  52. package/dist/overview.js.map +1 -1
  53. package/dist/overview.test.js +6 -5
  54. package/dist/overview.test.js.map +1 -1
  55. package/dist/promote.test.d.ts +3 -2
  56. package/dist/promote.test.d.ts.map +1 -1
  57. package/dist/promote.test.js +29 -31
  58. package/dist/promote.test.js.map +1 -1
  59. package/dist/publish.d.ts +2 -17
  60. package/dist/publish.d.ts.map +1 -1
  61. package/dist/publish.js +4 -49
  62. package/dist/publish.js.map +1 -1
  63. package/dist/search.d.ts +3 -1
  64. package/dist/search.d.ts.map +1 -1
  65. package/dist/search.js +15 -2
  66. package/dist/search.js.map +1 -1
  67. package/dist/search.test.js +9 -9
  68. package/dist/search.test.js.map +1 -1
  69. package/dist/server.d.ts.map +1 -1
  70. package/dist/server.js +218 -21
  71. package/dist/server.js.map +1 -1
  72. package/dist/store.d.ts +28 -14
  73. package/dist/store.d.ts.map +1 -1
  74. package/dist/store.js.map +1 -1
  75. package/dist/store.test.js +88 -75
  76. package/dist/store.test.js.map +1 -1
  77. package/dist/ticket-doc.d.ts +26 -10
  78. package/dist/ticket-doc.d.ts.map +1 -1
  79. package/dist/ticket-doc.js +71 -27
  80. package/dist/ticket-doc.js.map +1 -1
  81. package/dist/types.d.ts +51 -17
  82. package/dist/types.d.ts.map +1 -1
  83. package/dist/types.js.map +1 -1
  84. package/package.json +1 -1
  85. package/skill/SKILL.md +70 -23
  86. package/src/asks.test.ts +355 -0
  87. package/src/check.test.ts +129 -72
  88. package/src/check.ts +101 -58
  89. package/src/claim-io.ts +401 -0
  90. package/src/claim.test.ts +141 -99
  91. package/src/claim.ts +69 -96
  92. package/src/cli.ts +251 -63
  93. package/src/file-store.ts +357 -145
  94. package/src/git-serve.test.ts +240 -0
  95. package/src/git-serve.ts +595 -0
  96. package/src/git.ts +141 -0
  97. package/src/id.ts +68 -0
  98. package/src/inbox.ts +77 -0
  99. package/src/overview.test.ts +6 -5
  100. package/src/overview.ts +25 -10
  101. package/src/promote.test.ts +40 -28
  102. package/src/publish.ts +6 -53
  103. package/src/search.test.ts +11 -11
  104. package/src/search.ts +17 -3
  105. package/src/server.ts +221 -19
  106. package/src/store.test.ts +95 -77
  107. package/src/store.ts +28 -13
  108. package/src/ticket-doc.ts +93 -30
  109. package/src/types.ts +52 -17
  110. package/ui/dist/assets/index-BjsorZOU.js +229 -0
  111. package/ui/dist/assets/index-CoKCUYic.css +1 -0
  112. package/ui/dist/index.html +2 -2
  113. package/ui/dist/assets/index-BJmOsOdR.js +0 -229
  114. package/ui/dist/assets/index-BoqQlqSU.css +0 -1
package/src/claim.ts CHANGED
@@ -10,9 +10,15 @@
10
10
  * (the implement-task skill, a human).
11
11
  */
12
12
 
13
- import { spawnSync } from "node:child_process"
14
13
  import { join } from "node:path"
15
14
  import { FileStore, TICKETS_DIR, openBoard } from "./file-store.ts"
15
+ import {
16
+ GIT_NETWORK_TIMEOUT_MS,
17
+ defaultBase,
18
+ git,
19
+ gitMust,
20
+ runGit,
21
+ } from "./git.ts"
16
22
  import {
17
23
  boardConfig,
18
24
  findBoardsByPrefix,
@@ -37,23 +43,23 @@ export const DEFAULT_CLAIM_PREFIX = "task/claim/"
37
43
  const CLAIM_PREFIX_SHAPE = /^([A-Za-z0-9._-]+\/)+$/
38
44
 
39
45
  /**
40
- * The board's configured claim namespace before validation or defaulting
41
- * `claims.branchPrefix`, falling back to the pre-0.8 top-level `claimPrefix`
42
- * (still read for one release so a version bump can't strand in-flight
43
- * claims; `task check` nags, `--fix` migrates). Exported for `task check`,
44
- * which wants the raw value to lint without the throw below.
45
- */
46
- export function configuredBranchPrefix(config: ProjectConfig): string | undefined {
47
- return config.claims?.branchPrefix ?? config.claimPrefix
48
- }
49
-
50
- /**
51
- * The board's claim namespace: configured branch prefix (a trailing slash is
46
+ * The board's claim namespace: `claims.branchPrefix` (a trailing slash is
52
47
  * implied) or the default. Validated here because it becomes a git ref and an
53
48
  * ls-remote glob — a malformed value must fail the claim, not corrupt it.
49
+ *
50
+ * The pre-0.8 top-level `claimPrefix` spelling is not honored — no consumers,
51
+ * no compat (TAS-41) — but silently *defaulting* over it would be worse than
52
+ * a shim: the board's committed lock convention would change under every
53
+ * worker at once. So its presence refuses, loudly, by name.
54
54
  */
55
55
  export function claimNamespace(config: ProjectConfig): string {
56
- const raw = configuredBranchPrefix(config) ?? DEFAULT_CLAIM_PREFIX
56
+ if (config.claimPrefix !== undefined && config.claims?.branchPrefix === undefined) {
57
+ throw new ClaimError(
58
+ '`claimPrefix` in .task/config.json is the pre-0.8 spelling and is no longer read — rename it to `claims: { "branchPrefix": … }`',
59
+ "invalid",
60
+ )
61
+ }
62
+ const raw = config.claims?.branchPrefix ?? DEFAULT_CLAIM_PREFIX
57
63
  const prefix = raw.endsWith("/") ? raw : `${raw}/`
58
64
  if (!CLAIM_PREFIX_SHAPE.test(prefix) || prefix.includes("..") || /(^|\/)\./.test(prefix)) {
59
65
  throw new ClaimError(
@@ -79,9 +85,9 @@ export function openClaimCap(root: string): number | undefined {
79
85
  }
80
86
  }
81
87
 
82
- /** { prefix: "TAS" }, 21 → "task/claim/tas-21" (or under the configured namespace). */
83
- export function claimBranch(config: ProjectConfig, number: number): string {
84
- return `${claimNamespace(config)}${config.prefix.toLowerCase()}-${number}`
88
+ /** { prefix: "TAS" }, "x7k4m" → "task/claim/tas-x7k4m" (or under the configured namespace). */
89
+ export function claimBranch(config: ProjectConfig, key: string): string {
90
+ return `${claimNamespace(config)}${config.prefix.toLowerCase()}-${key}`
85
91
  }
86
92
 
87
93
  /**
@@ -97,52 +103,18 @@ export class ClaimError extends Error {
97
103
  }
98
104
  }
99
105
 
100
- interface GitResult {
101
- status: number
102
- stdout: string
103
- stderr: string
104
- }
105
-
106
- function git(cwd: string, ...args: string[]): GitResult {
107
- const result = spawnSync("git", args, { cwd, encoding: "utf8" })
108
- if (result.error) throw result.error
109
- return {
110
- status: result.status ?? 1,
111
- stdout: (result.stdout ?? "").trim(),
112
- stderr: (result.stderr ?? "").trim(),
113
- }
114
- }
115
-
116
- /** Run git and throw on failure — for the steps that have no soft outcome. */
117
- function gitMust(cwd: string, ...args: string[]): string {
118
- const result = git(cwd, ...args)
106
+ /** Network git that throws on failure, with the timeout a real remote deserves. */
107
+ function gitNetMust(cwd: string, ...args: string[]): string {
108
+ const result = runGit(cwd, args, { timeoutMs: GIT_NETWORK_TIMEOUT_MS })
119
109
  if (result.status !== 0) {
120
110
  throw new Error(`git ${args[0]} failed: ${result.stderr || result.stdout}`)
121
111
  }
122
112
  return result.stdout
123
113
  }
124
114
 
125
- /**
126
- * The base every claim branch starts from: origin's default branch. Resolved
127
- * from `origin/HEAD` when the clone recorded it, with a main/master fallback
128
- * for repos wired up by hand (`git remote add` + push never sets origin/HEAD).
129
- */
130
- function defaultBase(cwd: string): string {
131
- const head = git(cwd, "symbolic-ref", "--quiet", "refs/remotes/origin/HEAD")
132
- if (head.status === 0) return head.stdout.replace(/^refs\/remotes\//, "")
133
- for (const name of ["main", "master"]) {
134
- if (git(cwd, "show-ref", "--verify", "--quiet", `refs/remotes/origin/${name}`).status === 0) {
135
- return `origin/${name}`
136
- }
137
- }
138
- throw new Error(
139
- "couldn't resolve origin's default branch — origin/HEAD is unset and neither origin/main nor origin/master exists",
140
- )
141
- }
142
-
143
115
  /** Every claim branch that exists on origin right now — one network call. */
144
116
  export function remoteClaims(cwd: string, namespace: string): Set<string> {
145
- const out = gitMust(cwd, "ls-remote", "--heads", "origin", `${namespace}*`)
117
+ const out = gitNetMust(cwd, "ls-remote", "--heads", "origin", `${namespace}*`)
146
118
  const names = new Set<string>()
147
119
  for (const line of out.split("\n")) {
148
120
  const ref = line.split("\t")[1]
@@ -152,25 +124,30 @@ export function remoteClaims(cwd: string, namespace: string): Set<string> {
152
124
  }
153
125
 
154
126
  function remoteBranchExists(cwd: string, branch: string): boolean {
155
- return gitMust(cwd, "ls-remote", "--heads", "origin", branch) !== ""
127
+ return gitNetMust(cwd, "ls-remote", "--heads", "origin", branch) !== ""
156
128
  }
157
129
 
158
130
  /** Blockers still in the way — anything not done or canceled still blocks. */
159
131
  function openBlockers(store: Store, task: Task): string[] {
160
132
  return task.blockedBy
161
- .map((n) => store.get(n))
133
+ .map((k) => store.get(k))
162
134
  .filter((b): b is Task => b !== null && b.status !== "done" && b.status !== "canceled")
163
135
  .map((b) => b.id)
164
136
  }
165
137
 
166
- /** Throws ClaimError("invalid") unless `task` is claimable right now. */
167
- function assertClaimable(store: Store, task: Task | null, number: number): asserts task is Task {
168
- const id = store.displayId(number)
138
+ /**
139
+ * Throws ClaimError("invalid") unless `task` is claimable right now. Open asks
140
+ * deliberately don't gate here: a ticket can be agent-implementable while a
141
+ * person owes it something in parallel — the done gate keeps that honest. An
142
+ * ask that must land *before* the work starts is a `blocked_by` on a thin
143
+ * human ticket, which the blocker check below already enforces.
144
+ */
145
+ function assertClaimable(store: Store, task: Task | null, key: string): asserts task is Task {
146
+ const id = store.displayId(key)
169
147
  if (!task) throw new ClaimError(`no such task: ${id}`, "invalid")
170
148
  if (task.status !== "todo") {
171
149
  throw new ClaimError(`${id} is ${task.status} — only todo tickets can be claimed`, "invalid")
172
150
  }
173
- if (task.needsHuman) throw new ClaimError(`${id} needs a human — not claimable`, "invalid")
174
151
  const blockers = openBlockers(store, task)
175
152
  if (blockers.length) {
176
153
  throw new ClaimError(`${id} is blocked by ${blockers.join(", ")} — not claimable`, "invalid")
@@ -215,7 +192,7 @@ function assertUnderCap(openClaims: Set<string>, root: string, namespace: string
215
192
  * someone pre-created without a claim commit can't be hijacked by a plain
216
193
  * fast-forward. Leaves the winner checked out on the claim branch.
217
194
  */
218
- export function claim(store: Store, number: number, options: ClaimOptions = {}): ClaimResult {
195
+ export function claim(store: Store, key: string, options: ClaimOptions = {}): ClaimResult {
219
196
  // Unreachable since openBoard refuses pre-0.6 boards, kept as the narrowing
220
197
  // that lets the rest of this function assume text-canonical semantics.
221
198
  if (!(store instanceof FileStore)) {
@@ -225,8 +202,8 @@ export function claim(store: Store, number: number, options: ClaimOptions = {}):
225
202
  )
226
203
  }
227
204
  const cwd = store.root
228
- const branch = claimBranch(store.config, number)
229
- const id = store.displayId(number)
205
+ const branch = claimBranch(store.config, key)
206
+ const id = store.displayId(key)
230
207
 
231
208
  // The local-branch check comes before ticket validation on purpose: on the
232
209
  // claim branch itself the ticket reads in_progress, and "already claimed"
@@ -246,7 +223,7 @@ export function claim(store: Store, number: number, options: ClaimOptions = {}):
246
223
  )
247
224
  }
248
225
 
249
- assertClaimable(store, store.get(number), number)
226
+ assertClaimable(store, store.get(key), key)
250
227
  if (tree.stdout !== "") {
251
228
  throw new ClaimError(
252
229
  "working tree is dirty — commit or stash before claiming, the claim switches branches",
@@ -254,7 +231,7 @@ export function claim(store: Store, number: number, options: ClaimOptions = {}):
254
231
  )
255
232
  }
256
233
 
257
- gitMust(cwd, "fetch", "--quiet", "origin")
234
+ gitNetMust(cwd, "fetch", "--quiet", "origin")
258
235
  // One ls-remote answers both gates: is this ticket taken, and is the repo
259
236
  // at its WIP cap. "Already claimed" wins — that's exit 1 (pick the next
260
237
  // ticket), where the cap is exit 2 (fix something).
@@ -282,23 +259,19 @@ export function claim(store: Store, number: number, options: ClaimOptions = {}):
282
259
  // Re-validate against the base branch: the pre-checkout validation read
283
260
  // whatever happened to be checked out, this one reads the truth the claim
284
261
  // will actually be built on.
285
- assertClaimable(store, store.get(number), number)
286
- store.update(number, { status: "in_progress" })
287
- gitMust(cwd, "add", "--", join(store.taskDir, TICKETS_DIR, String(number)))
262
+ assertClaimable(store, store.get(key), key)
263
+ store.update(key, { status: "in_progress" })
264
+ gitMust(cwd, "add", "--", join(store.taskDir, TICKETS_DIR, key))
288
265
  gitMust(cwd, "commit", "--quiet", "-m", `chore(board): claim ${id} → in_progress`)
289
266
  } catch (error) {
290
267
  undo()
291
268
  throw error
292
269
  }
293
270
 
294
- const push = git(
271
+ const push = runGit(
295
272
  cwd,
296
- "push",
297
- "--quiet",
298
- "-u",
299
- "origin",
300
- branch,
301
- `--force-with-lease=refs/heads/${branch}:`,
273
+ ["push", "--quiet", "-u", "origin", branch, `--force-with-lease=refs/heads/${branch}:`],
274
+ { timeoutMs: GIT_NETWORK_TIMEOUT_MS },
302
275
  )
303
276
  if (push.status !== 0) {
304
277
  undo()
@@ -311,7 +284,7 @@ export function claim(store: Store, number: number, options: ClaimOptions = {}):
311
284
  )
312
285
  }
313
286
 
314
- return { task: store.get(number)!, branch, base }
287
+ return { task: store.get(key)!, branch, base }
315
288
  }
316
289
 
317
290
  export interface ReleaseResult {
@@ -326,9 +299,9 @@ export interface ReleaseResult {
326
299
  * flip only ever existed as a commit on that branch, so deleting it *is* the
327
300
  * revert — the default branch never saw in_progress.
328
301
  */
329
- export function release(store: Store, number: number): ReleaseResult {
302
+ export function release(store: Store, key: string): ReleaseResult {
330
303
  const cwd = store.root
331
- const branch = claimBranch(store.config, number)
304
+ const branch = claimBranch(store.config, key)
332
305
 
333
306
  const onBranch =
334
307
  git(cwd, "symbolic-ref", "--quiet", "--short", "HEAD").stdout === branch
@@ -353,22 +326,22 @@ export function release(store: Store, number: number): ReleaseResult {
353
326
 
354
327
  const local = git(cwd, "branch", "--quiet", "-D", branch).status === 0
355
328
  const remote = remoteBranchExists(cwd, branch)
356
- if (remote) gitMust(cwd, "push", "--quiet", "origin", "--delete", branch)
329
+ if (remote) gitNetMust(cwd, "push", "--quiet", "origin", "--delete", branch)
357
330
  return { branch, remote, local }
358
331
  }
359
332
 
360
333
  /**
361
- * The dispatcher's queue view: `todo` in position order, minus needs-human,
362
- * minus blocked, minus tickets whose claim branch already exists on origin
363
- * (one ls-remote for the whole namespace). The top entry is next up.
334
+ * The dispatcher's queue view: `todo` in position order, minus blocked, minus
335
+ * tickets whose claim branch already exists on origin (one ls-remote for the
336
+ * whole namespace). The top entry is next up. Open asks don't exclude a
337
+ * ticket — see assertClaimable.
364
338
  */
365
339
  export function claimableTasks(store: Store): Task[] {
366
340
  const claimed = remoteClaims(store.root, claimNamespace(store.config))
367
341
  return store
368
342
  .list({ statuses: ["todo"] })
369
- .filter((t) => !t.needsHuman)
370
343
  .filter((t) => openBlockers(store, t).length === 0)
371
- .filter((t) => !claimed.has(claimBranch(store.config, t.number)))
344
+ .filter((t) => !claimed.has(claimBranch(store.config, t.key)))
372
345
  }
373
346
 
374
347
  export interface PromoteResult {
@@ -380,15 +353,16 @@ export interface PromoteResult {
380
353
  /**
381
354
  * `task promote` — the human "build this" call, as a verb: `backlog` → `todo`.
382
355
  * No quality gate: planning happens at claim time (the worker's plan tick),
383
- * so a one-line backlog ticket is promotable. Blocked and needs-human are
384
- * claim-time facts that `claim`/`--claimable` already enforce live, so
385
- * promoting a blocked ticket is legitimate sequencing ("build this once
386
- * NIC-11 lands") — reported as a hold, never refused. Writes the ticket like
387
- * `update` does; committing is the caller's job.
356
+ * so a one-line backlog ticket is promotable. Blockers are claim-time facts
357
+ * that `claim`/`--claimable` already enforce live, so promoting a blocked
358
+ * ticket is legitimate sequencing ("build this once NIC-11 lands") — reported
359
+ * as a hold, never refused. Open asks aren't a hold at all: they don't gate
360
+ * claiming, only done. Writes the ticket like `update` does; committing is
361
+ * the caller's job.
388
362
  */
389
- export function promote(store: Store, number: number): PromoteResult {
390
- const task = store.get(number)
391
- const id = store.displayId(number)
363
+ export function promote(store: Store, key: string): PromoteResult {
364
+ const task = store.get(key)
365
+ const id = store.displayId(key)
392
366
  if (!task) throw new ClaimError(`no such task: ${id}`, "invalid")
393
367
  if (task.status !== "backlog") {
394
368
  throw new ClaimError(
@@ -396,9 +370,8 @@ export function promote(store: Store, number: number): PromoteResult {
396
370
  "invalid",
397
371
  )
398
372
  }
399
- const updated = store.update(number, { status: "todo" })
373
+ const updated = store.update(key, { status: "todo" })
400
374
  const holds: string[] = []
401
- if (updated.needsHuman) holds.push("flagged needs-human")
402
375
  const blockers = openBlockers(store, updated)
403
376
  if (blockers.length) holds.push(`blocked by ${blockers.join(", ")}`)
404
377
  return { task: updated, holds }
@@ -474,7 +447,7 @@ export function claimNext(stores: Store[], options: ClaimOptions = {}): ClaimRes
474
447
  for (const store of stores) {
475
448
  for (const task of claimableTasks(store)) {
476
449
  try {
477
- return claim(store, task.number, options)
450
+ return claim(store, task.key, options)
478
451
  } catch (error) {
479
452
  // Claimed out from under us, or changed since listing — next in line.
480
453
  if (error instanceof ClaimError) continue