@nickmeriano/task 0.10.0 → 0.12.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 (101) hide show
  1. package/README.md +232 -1
  2. package/dist/asks.test.js +6 -0
  3. package/dist/asks.test.js.map +1 -1
  4. package/dist/board/github.d.ts +68 -0
  5. package/dist/board/github.d.ts.map +1 -0
  6. package/dist/board/github.js +112 -0
  7. package/dist/board/github.js.map +1 -0
  8. package/dist/board/handler.d.ts +50 -0
  9. package/dist/board/handler.d.ts.map +1 -0
  10. package/dist/board/handler.js +183 -0
  11. package/dist/board/handler.js.map +1 -0
  12. package/dist/board/handler.test.d.ts +11 -0
  13. package/dist/board/handler.test.d.ts.map +1 -0
  14. package/dist/board/handler.test.js +229 -0
  15. package/dist/board/handler.test.js.map +1 -0
  16. package/dist/board/pages-function.d.ts +23 -0
  17. package/dist/board/pages-function.d.ts.map +1 -0
  18. package/dist/board/pages-function.js +34 -0
  19. package/dist/board/pages-function.js.map +1 -0
  20. package/dist/board/source.d.ts +118 -0
  21. package/dist/board/source.d.ts.map +1 -0
  22. package/dist/board/source.js +333 -0
  23. package/dist/board/source.js.map +1 -0
  24. package/dist/board/source.test.d.ts +12 -0
  25. package/dist/board/source.test.d.ts.map +1 -0
  26. package/dist/board/source.test.js +165 -0
  27. package/dist/board/source.test.js.map +1 -0
  28. package/dist/board/tar.d.ts +28 -0
  29. package/dist/board/tar.d.ts.map +1 -0
  30. package/dist/board/tar.js +188 -0
  31. package/dist/board/tar.js.map +1 -0
  32. package/dist/board/tar.test.d.ts +9 -0
  33. package/dist/board/tar.test.d.ts.map +1 -0
  34. package/dist/board/tar.test.js +110 -0
  35. package/dist/board/tar.test.js.map +1 -0
  36. package/dist/claim-io.d.ts.map +1 -1
  37. package/dist/claim-io.js +4 -3
  38. package/dist/claim-io.js.map +1 -1
  39. package/dist/claim.d.ts +14 -2
  40. package/dist/claim.d.ts.map +1 -1
  41. package/dist/claim.js +9 -3
  42. package/dist/claim.js.map +1 -1
  43. package/dist/claim.test.js +41 -1
  44. package/dist/claim.test.js.map +1 -1
  45. package/dist/cli.js +127 -7
  46. package/dist/cli.js.map +1 -1
  47. package/dist/export.d.ts +163 -0
  48. package/dist/export.d.ts.map +1 -0
  49. package/dist/export.js +259 -0
  50. package/dist/export.js.map +1 -0
  51. package/dist/export.test.d.ts +12 -0
  52. package/dist/export.test.d.ts.map +1 -0
  53. package/dist/export.test.js +264 -0
  54. package/dist/export.test.js.map +1 -0
  55. package/dist/functions/board.js +709 -0
  56. package/dist/git-serve.d.ts +14 -1
  57. package/dist/git-serve.d.ts.map +1 -1
  58. package/dist/git-serve.js +30 -2
  59. package/dist/git-serve.js.map +1 -1
  60. package/dist/git-serve.test.d.ts +1 -0
  61. package/dist/git-serve.test.d.ts.map +1 -1
  62. package/dist/git-serve.test.js +36 -1
  63. package/dist/git-serve.test.js.map +1 -1
  64. package/dist/index.d.ts +2 -0
  65. package/dist/index.d.ts.map +1 -1
  66. package/dist/index.js +6 -0
  67. package/dist/index.js.map +1 -1
  68. package/dist/server.d.ts +6 -0
  69. package/dist/server.d.ts.map +1 -1
  70. package/dist/server.js +12 -3
  71. package/dist/server.js.map +1 -1
  72. package/dist/trailers.d.ts +51 -0
  73. package/dist/trailers.d.ts.map +1 -0
  74. package/dist/trailers.js +32 -0
  75. package/dist/trailers.js.map +1 -0
  76. package/package.json +3 -3
  77. package/skill/SKILL.md +16 -1
  78. package/src/asks.test.ts +15 -0
  79. package/src/board/github.ts +151 -0
  80. package/src/board/handler.test.ts +276 -0
  81. package/src/board/handler.ts +228 -0
  82. package/src/board/pages-function.ts +42 -0
  83. package/src/board/source.test.ts +203 -0
  84. package/src/board/source.ts +422 -0
  85. package/src/board/tar.test.ts +128 -0
  86. package/src/board/tar.ts +199 -0
  87. package/src/claim-io.ts +12 -3
  88. package/src/claim.test.ts +56 -1
  89. package/src/claim.ts +27 -4
  90. package/src/cli.ts +127 -7
  91. package/src/export.test.ts +361 -0
  92. package/src/export.ts +354 -0
  93. package/src/git-serve.test.ts +41 -1
  94. package/src/git-serve.ts +30 -2
  95. package/src/index.ts +10 -0
  96. package/src/server.ts +12 -3
  97. package/src/trailers.ts +65 -0
  98. package/ui/dist/assets/index-B8M_DaOt.js +229 -0
  99. package/ui/dist/assets/{index-CoKCUYic.css → index-eHsqltgs.css} +1 -1
  100. package/ui/dist/index.html +3 -3
  101. package/ui/dist/assets/index-BjsorZOU.js +0 -229
@@ -0,0 +1,199 @@
1
+ /**
2
+ * A minimal streaming tar reader — just enough to walk a GitHub repository
3
+ * tarball. One pass, bounded memory: every entry's *path* is recorded, but
4
+ * bytes are kept only for the paths the caller's `keep` predicate selects;
5
+ * everything else is discarded chunk by chunk as it streams through, so a big
6
+ * repository costs no more memory than its boards.
7
+ *
8
+ * Understands what GitHub's tarballs actually contain: ustar headers with the
9
+ * split `prefix` field, a leading `pax_global_header`, and pax extended
10
+ * headers (`x`) or GNU longname entries (`L`) for paths longer than the
11
+ * header's 100 bytes. Checksums are not verified — the bytes come off a TLS
12
+ * connection from GitHub, not a tape from 1988.
13
+ */
14
+
15
+ export class TarError extends Error {}
16
+
17
+ const BLOCK = 512
18
+
19
+ /** Reads exact byte counts off a stream, buffering only what's unconsumed. */
20
+ class ByteReader {
21
+ private reader: ReadableStreamDefaultReader<Uint8Array>
22
+ private chunks: Uint8Array[] = []
23
+ private have = 0
24
+ private done = false
25
+
26
+ constructor(stream: ReadableStream<Uint8Array>) {
27
+ this.reader = stream.getReader()
28
+ }
29
+
30
+ private async pull(): Promise<boolean> {
31
+ if (this.done) return false
32
+ const { done, value } = await this.reader.read()
33
+ if (done) {
34
+ this.done = true
35
+ return false
36
+ }
37
+ if (value && value.length > 0) {
38
+ this.chunks.push(value)
39
+ this.have += value.length
40
+ }
41
+ return true
42
+ }
43
+
44
+ /** Exactly `n` bytes; null at a clean end-of-stream, throws mid-entry. */
45
+ async read(n: number): Promise<Uint8Array | null> {
46
+ while (this.have < n) {
47
+ if (!(await this.pull())) {
48
+ if (this.have === 0) return null
49
+ throw new TarError("truncated archive")
50
+ }
51
+ }
52
+ const out = new Uint8Array(n)
53
+ let filled = 0
54
+ while (filled < n) {
55
+ const head = this.chunks[0]
56
+ const take = Math.min(head.length, n - filled)
57
+ out.set(head.subarray(0, take), filled)
58
+ filled += take
59
+ if (take === head.length) this.chunks.shift()
60
+ else this.chunks[0] = head.subarray(take)
61
+ }
62
+ this.have -= n
63
+ return out
64
+ }
65
+
66
+ /** Discard `n` bytes without accumulating them. */
67
+ async skip(n: number): Promise<void> {
68
+ while (n > 0) {
69
+ if (this.chunks.length === 0) {
70
+ if (!(await this.pull())) throw new TarError("truncated archive")
71
+ continue
72
+ }
73
+ const head = this.chunks[0]
74
+ const take = Math.min(head.length, n)
75
+ if (take === head.length) this.chunks.shift()
76
+ else this.chunks[0] = head.subarray(take)
77
+ this.have -= take
78
+ n -= take
79
+ }
80
+ }
81
+ }
82
+
83
+ const decoder = new TextDecoder()
84
+
85
+ function field(header: Uint8Array, start: number, length: number): string {
86
+ const slice = header.subarray(start, start + length)
87
+ const nul = slice.indexOf(0)
88
+ return decoder.decode(nul < 0 ? slice : slice.subarray(0, nul))
89
+ }
90
+
91
+ function octal(header: Uint8Array, start: number, length: number): number {
92
+ const text = field(header, start, length).trim()
93
+ if (text === "") return 0
94
+ const value = parseInt(text, 8)
95
+ if (Number.isNaN(value) || value < 0) throw new TarError("bad size field")
96
+ return value
97
+ }
98
+
99
+ /** The `path=` record of a pax extended header, if it has one. */
100
+ function paxPath(data: Uint8Array): string | null {
101
+ // Records are "<len> <key>=<value>\n", len covering the whole record.
102
+ const text = decoder.decode(data)
103
+ for (let at = 0; at < text.length; ) {
104
+ const space = text.indexOf(" ", at)
105
+ if (space < 0) break
106
+ const length = Number(text.slice(at, space))
107
+ if (!Number.isInteger(length) || length <= 0) break
108
+ const record = text.slice(space + 1, at + length)
109
+ if (record.startsWith("path=")) return record.slice(5).replace(/\n$/, "")
110
+ at += length
111
+ }
112
+ return null
113
+ }
114
+
115
+ export interface TarContents {
116
+ /** Every regular file's path, root directory stripped. */
117
+ paths: string[]
118
+ /** Bytes of the paths `keep` selected. */
119
+ files: Map<string, Uint8Array>
120
+ }
121
+
122
+ /**
123
+ * Walk a (decompressed) tar stream. Paths are reported with the archive's
124
+ * root directory stripped — GitHub tarballs wrap everything in
125
+ * `<owner>-<repo>-<sha>/` — matching what a git tree listing would say.
126
+ */
127
+ export async function readTar(
128
+ stream: ReadableStream<Uint8Array>,
129
+ keep: (path: string) => boolean,
130
+ ): Promise<TarContents> {
131
+ const bytes = new ByteReader(stream)
132
+ const paths: string[] = []
133
+ const files = new Map<string, Uint8Array>()
134
+
135
+ let overridePath: string | null = null
136
+ for (;;) {
137
+ const header = await bytes.read(BLOCK)
138
+ if (header === null) break
139
+ if (header.every((b) => b === 0)) {
140
+ // End marker: two zero blocks, then maybe padding. Nothing left to parse.
141
+ break
142
+ }
143
+
144
+ const size = octal(header, 124, 12)
145
+ const padded = Math.ceil(size / BLOCK) * BLOCK
146
+ const type = header[156]
147
+
148
+ // Extended headers name the *next* entry; the global one ('g') is skipped.
149
+ if (type === 0x78 /* x */ || type === 0x4c /* L */) {
150
+ const data = await bytes.read(size)
151
+ if (data === null) throw new TarError("truncated archive")
152
+ overridePath =
153
+ type === 0x78 ? (paxPath(data) ?? overridePath) : decoder.decode(data).replace(/\0+$/, "")
154
+ await bytes.skip(padded - size)
155
+ continue
156
+ }
157
+
158
+ let name = field(header, 0, 100)
159
+ // ustar splits long paths into prefix + name.
160
+ const prefix = field(header, 257, 6).startsWith("ustar") ? field(header, 345, 155) : ""
161
+ if (prefix) name = `${prefix}/${name}`
162
+ if (overridePath !== null) {
163
+ name = overridePath
164
+ overridePath = null
165
+ }
166
+
167
+ // Regular file entries only — directories, links and the global header
168
+ // carry no board bytes. Type '0' or the ancient NUL both mean "file".
169
+ if (type !== 0x30 && type !== 0) {
170
+ await bytes.skip(padded)
171
+ continue
172
+ }
173
+
174
+ // Strip the tarball's root directory. A path without one (no slash) is
175
+ // the metadata GitHub puts at the top level — not repository content.
176
+ const slash = name.indexOf("/")
177
+ if (slash < 0) {
178
+ await bytes.skip(padded)
179
+ continue
180
+ }
181
+ const path = name.slice(slash + 1)
182
+ if (path === "") {
183
+ await bytes.skip(padded)
184
+ continue
185
+ }
186
+
187
+ paths.push(path)
188
+ if (keep(path)) {
189
+ const data = await bytes.read(size)
190
+ if (data === null) throw new TarError("truncated archive")
191
+ files.set(path, data)
192
+ await bytes.skip(padded - size)
193
+ } else {
194
+ await bytes.skip(padded)
195
+ }
196
+ }
197
+
198
+ return { paths, files }
199
+ }
package/src/claim-io.ts CHANGED
@@ -24,6 +24,7 @@ import { join, relative, sep } from "node:path"
24
24
  import { claimBranch, claimNamespace } from "./claim.ts"
25
25
  import { gitOut, runGit } from "./git.ts"
26
26
  import { commentStem, parseAskLenient, serializeAsk, type AskDoc } from "./ticket-doc.ts"
27
+ import { boardCommitMessage } from "./trailers.ts"
27
28
  import { ASKS_DIR, TICKETS_DIR } from "./file-store.ts"
28
29
  import type { Store } from "./store.ts"
29
30
  import type { Ask, Task } from "./types.ts"
@@ -298,7 +299,7 @@ export function addAskRouted(store: Store, key: string, text: string, author = "
298
299
  store,
299
300
  branch,
300
301
  `${dir}/${id}.md`,
301
- `chore(board): ask on ${store.displayId(key)}`,
302
+ boardCommitMessage(`chore(board): ask on ${store.displayId(key)}`, "ask", store.displayId(key)),
302
303
  // Absent → create; if a racing write took the name, keep theirs.
303
304
  (current) => current ?? serializeAsk(doc),
304
305
  )
@@ -340,7 +341,11 @@ export function setAskResolvedRouted(
340
341
  store,
341
342
  branch,
342
343
  `${dir}/${target.id}.md`,
343
- `chore(board): ${resolved ? "resolve" : "reopen"} ask [${target.ordinal}] on ${store.displayId(key)}`,
344
+ boardCommitMessage(
345
+ `chore(board): ${resolved ? "resolve" : "reopen"} ask [${target.ordinal}] on ${store.displayId(key)}`,
346
+ resolved ? "resolve-ask" : "reopen-ask",
347
+ store.displayId(key),
348
+ ),
344
349
  (current) => {
345
350
  if (current === null) return null // deleted under us — nothing to write
346
351
  const doc = parseAskLenient(current)
@@ -374,7 +379,11 @@ export function deleteAskRouted(store: Store, key: string, ref: string): RoutedA
374
379
  store,
375
380
  branch,
376
381
  `${dir}/${target.id}.md`,
377
- `chore(board): delete ask [${target.ordinal}] on ${store.displayId(key)}`,
382
+ boardCommitMessage(
383
+ `chore(board): delete ask [${target.ordinal}] on ${store.displayId(key)}`,
384
+ "delete-ask",
385
+ store.displayId(key),
386
+ ),
378
387
  () => null,
379
388
  )
380
389
  // The local copy of a both-sides ask goes too, or the union resurrects it.
package/src/claim.test.ts CHANGED
@@ -183,9 +183,54 @@ test("two concurrent claims of one ticket: exactly one wins", async () => {
183
183
  sh(winner, "git", "show", `origin/main:.task/tickets/${keys.one}/ticket.md`),
184
184
  /status: todo/,
185
185
  )
186
+ // The trailer contract (trailers.ts): the flip commit names its op and
187
+ // ticket in machine-readable trailers — pinned here because automation
188
+ // keys on them.
189
+ assert.equal(sh(winner, "git", "log", "-1", "--format=%(trailers:key=Task-Op,valueonly)"), "claim")
190
+ assert.equal(
191
+ sh(winner, "git", "log", "-1", "--format=%(trailers:key=Task-Id,valueonly)"),
192
+ `CLM-${keys.one}`,
193
+ )
186
194
  void bare
187
195
  })
188
196
 
197
+ test("claim --dry-run: the real gates, the real exit codes, zero writes", async () => {
198
+ const { clone, keys } = fixture()
199
+ const dir = clone()
200
+ const branch = `task/claim/clm-${keys.one}`
201
+
202
+ // Would claim: exit 0 — and nothing moved. No branch local or remote, the
203
+ // checkout still on main, the ticket still todo.
204
+ const yes = await cli(dir, "claim", keys.one, "--dry-run")
205
+ assert.equal(yes.code, 0, yes.stderr)
206
+ assert.match(yes.stdout, /would claim CLM-/)
207
+ assert.equal(sh(dir, "git", "rev-parse", "--abbrev-ref", "HEAD"), "main")
208
+ assert.equal(sh(dir, "git", "branch", "--list", branch), "")
209
+ assert.equal(sh(dir, "git", "ls-remote", "--heads", "origin", branch), "")
210
+ assert.match(readFileSync(join(dir, ".task", "tickets", keys.one, "ticket.md"), "utf8"), /status: todo/)
211
+
212
+ // The gates are the real command's gates: blocked exits 2, a ticket someone
213
+ // else claimed exits 1 — the codes a scheduler scripts against.
214
+ assert.equal((await cli(dir, "claim", keys.blocked, "--dry-run")).code, 2)
215
+ const other = clone()
216
+ assert.equal((await cli(other, "claim", keys.one)).code, 0)
217
+ assert.equal((await cli(dir, "claim", keys.one, "--dry-run")).code, 1)
218
+
219
+ // --next --dry-run names the top of the queue without taking it.
220
+ const next = await cli(dir, "claim", "--next", "--dry-run", "--json")
221
+ assert.equal(next.code, 0, next.stderr)
222
+ const picked = JSON.parse(next.stdout) as { task: { key: string }; dryRun: boolean }
223
+ assert.equal(picked.dryRun, true)
224
+ assert.equal(picked.task.key, keys.jumper)
225
+ assert.equal(sh(dir, "git", "ls-remote", "--heads", "origin", `task/claim/clm-${keys.jumper}`), "")
226
+
227
+ // A dry run reserves nothing — the real claim right after still works.
228
+ assert.equal((await cli(dir, "claim", keys.jumper)).code, 0)
229
+
230
+ // No stop-before-writing seam exists for --release or --lock-only.
231
+ assert.equal((await cli(other, "claim", keys.two, "--lock-only", "--dry-run")).code, 1)
232
+ })
233
+
189
234
  test("unclaimable tickets exit 2, before anything touches origin", async () => {
190
235
  const { clone, keys } = fixture()
191
236
  const dir = clone()
@@ -541,7 +586,17 @@ test("--lock-only claims from a dirty, branch-pinned checkout without touching i
541
586
  assert.match(sh(dir, "git", "show", `origin/${branch}:.task/tickets/${keys.one}/ticket.md`), /status: in_progress/)
542
587
  const message = sh(dir, "git", "log", "-1", "--format=%B", `origin/${branch}`)
543
588
  assert.match(message, /lock-only/)
544
- assert.match(message, new RegExp(`delivered_by: pinned`))
589
+ assert.match(message, new RegExp(`Delivered-By: pinned`))
590
+ // Delivered-By shares one contiguous trailer block with the Task-Op/Task-Id
591
+ // contract (trailers.ts) — git must parse all three.
592
+ assert.equal(
593
+ sh(dir, "git", "log", "-1", "--format=%(trailers:key=Task-Op,valueonly)", `origin/${branch}`),
594
+ "claim",
595
+ )
596
+ assert.equal(
597
+ sh(dir, "git", "log", "-1", "--format=%(trailers:key=Delivered-By,valueonly)", `origin/${branch}`),
598
+ "pinned",
599
+ )
545
600
 
546
601
  // The lock is the same lock: both claim flavors lose to it from elsewhere.
547
602
  const other = clone()
package/src/claim.ts CHANGED
@@ -30,6 +30,7 @@ import {
30
30
  type Store,
31
31
  } from "./store.ts"
32
32
  import { isTicketKey } from "./id.ts"
33
+ import { boardCommitMessage } from "./trailers.ts"
33
34
  import { parseTicket, parseTicketLenient, serializeTicket, type TicketDoc } from "./ticket-doc.ts"
34
35
  import type { ProjectConfig, Status, Task } from "./types.ts"
35
36
 
@@ -193,6 +194,18 @@ export interface ClaimResult {
193
194
  export interface ClaimOptions {
194
195
  /** Claim past the root board's `claims.maxOpenCount` cap. */
195
196
  force?: boolean
197
+ /**
198
+ * Run the real claim's decision path — every validation, in order, against
199
+ * live origin state — and stop before the first write. No branch is
200
+ * created, no commit made, nothing pushed; the checkout is untouched. Exit
201
+ * semantics match the real command exactly, which is the point: automation
202
+ * (a scheduler deciding whether to wake a worker) asks "would a claim
203
+ * succeed right now?" through the same code that would perform it, so the
204
+ * answer can't drift from the rules. A "yes" is a reading, not a
205
+ * reservation — the dry run takes no lock, so a concurrent claimer can
206
+ * still win the ticket a moment later.
207
+ */
208
+ dryRun?: boolean
196
209
  }
197
210
 
198
211
  /**
@@ -278,6 +291,8 @@ export function claim(store: Store, key: string, options: ClaimOptions = {}): Cl
278
291
  const previous =
279
292
  git(cwd, "symbolic-ref", "--quiet", "--short", "HEAD").stdout ||
280
293
  gitMust(cwd, "rev-parse", "HEAD")
294
+ // Every gate is passed; the writes start here. A dry run stops instead.
295
+ if (options.dryRun) return { task: store.get(key)!, branch, base, previous }
281
296
  gitMust(cwd, "checkout", "--quiet", "-b", branch, base)
282
297
 
283
298
  const undo = (): void => {
@@ -292,7 +307,13 @@ export function claim(store: Store, key: string, options: ClaimOptions = {}): Cl
292
307
  assertClaimable(store, store.get(key), key)
293
308
  store.update(key, { status: "in_progress" })
294
309
  gitMust(cwd, "add", "--", join(store.taskDir, TICKETS_DIR, key))
295
- gitMust(cwd, "commit", "--quiet", "-m", `chore(board): claim ${id} → in_progress`)
310
+ gitMust(
311
+ cwd,
312
+ "commit",
313
+ "--quiet",
314
+ "-m",
315
+ boardCommitMessage(`chore(board): claim ${id} → in_progress`, "claim", id),
316
+ )
296
317
  } catch (error) {
297
318
  undo()
298
319
  throw error
@@ -331,7 +352,7 @@ export interface LockOnlyClaimResult {
331
352
  task: Task
332
353
  branch: string
333
354
  base: string
334
- /** The checkout's branch at claim time, recorded in the flip commit's `delivered_by:` trailer. */
355
+ /** The checkout's branch at claim time, recorded in the flip commit's `Delivered-By:` trailer. */
335
356
  deliveredBy: string
336
357
  }
337
358
 
@@ -348,7 +369,7 @@ export interface LockOnlyClaimResult {
348
369
  * update-index → write-tree → commit-tree) and pushed with the same
349
370
  * must-not-exist lease plain `claim` uses — the compare-and-swap is identical,
350
371
  * only the checkout stays where it is (dirty or pinned, doesn't matter). The
351
- * flip commit carries a `delivered_by: <branch>` trailer naming the checkout's
372
+ * flip commit carries a `Delivered-By: <branch>` trailer naming the checkout's
352
373
  * branch, so a lock-only claim is legible state — anyone reading the claim
353
374
  * branch sees where the work will actually land. Exit-code contract and
354
375
  * `--release` behave exactly like plain claim.
@@ -465,7 +486,9 @@ export function claimLockOnly(
465
486
  "-p",
466
487
  baseSha,
467
488
  "-m",
468
- `chore(board): claim ${id} → in_progress (lock-only)\n\ndelivered_by: ${deliveredBy}`,
489
+ boardCommitMessage(`chore(board): claim ${id} → in_progress (lock-only)`, "claim", id, [
490
+ `Delivered-By: ${deliveredBy}`,
491
+ ]),
469
492
  )
470
493
  if (made.status !== 0) {
471
494
  throw new Error("git commit-tree failed — is git user.name/user.email configured?")
package/src/cli.ts CHANGED
@@ -12,7 +12,7 @@
12
12
  import { spawn } from "node:child_process"
13
13
  import { readFileSync } from "node:fs"
14
14
  import type { Server } from "node:http"
15
- import { basename, join, relative, sep } from "node:path"
15
+ import { basename, isAbsolute, join, relative, resolve, sep } from "node:path"
16
16
  import process from "node:process"
17
17
  import { resolveAuthor } from "./author.ts"
18
18
  import { checkBoards, checkRoot } from "./check.ts"
@@ -32,7 +32,8 @@ import { buildInbox, type InboxEntry } from "./inbox.ts"
32
32
  import { STALE_DAYS, buildOverview } from "./overview.ts"
33
33
  import { searchStore } from "./search.ts"
34
34
  import { detectRepo, parseSlug, publish, resolveHost } from "./publish.ts"
35
- import { createTaskServer } from "./server.ts"
35
+ import { DEFAULT_OUT_DIR, writeExport } from "./export.ts"
36
+ import { UI_DIR, createTaskServer } from "./server.ts"
36
37
  import { initProject, openBoard } from "./file-store.ts"
37
38
  import {
38
39
  CONFIG_FILE,
@@ -95,8 +96,10 @@ const BOOLEAN_FLAGS = new Set([
95
96
  "fix",
96
97
  "next",
97
98
  "force",
99
+ "no-functions",
98
100
  "reopen",
99
101
  "lock-only",
102
+ "dry-run",
100
103
  ])
101
104
 
102
105
  /**
@@ -830,7 +833,7 @@ function cmdLink(args: Args, action: "link" | "unlink"): void {
830
833
  }
831
834
 
832
835
  const CLAIM_USAGE =
833
- 'usage: task claim <id> [--lock-only] | task claim --next | task claim --release <id> --comment "<why>"'
836
+ 'usage: task claim <id> [--lock-only] [--dry-run] | task claim --next [--dry-run] | task claim --release <id> --comment "<why>"'
834
837
 
835
838
  /**
836
839
  * `task claim <id>` / `task claim --next` / `task claim --release <id>` — see
@@ -840,8 +843,19 @@ const CLAIM_USAGE =
840
843
  */
841
844
  function cmdClaim(args: Args): void {
842
845
  const ref = args.positional[0]
843
- const options = { force: Boolean(args.flags.force) }
846
+ const dryRun = Boolean(args.flags["dry-run"])
847
+ const options = { force: Boolean(args.flags.force), dryRun }
844
848
  const printClaim = (result: { task: Task; branch: string; base: string; previous: string }): void => {
849
+ if (dryRun) {
850
+ // Same exit-code contract as the real claim, nothing written: the
851
+ // scripted consumer branches on the code, the human reads the verdict.
852
+ if (args.flags.json) {
853
+ console.log(JSON.stringify({ task: result.task, branch: result.branch, dryRun: true }, null, 2))
854
+ } else {
855
+ console.log(`would claim ${result.task.id} — ${result.branch} (from ${result.base}); dry run, nothing written`)
856
+ }
857
+ return
858
+ }
845
859
  if (args.flags.json) {
846
860
  console.log(JSON.stringify({ task: result.task, branch: result.branch }, null, 2))
847
861
  } else {
@@ -878,12 +892,17 @@ function cmdClaim(args: Args): void {
878
892
  return
879
893
  }
880
894
  if (!ref) fail(CLAIM_USAGE)
895
+ if (dryRun && (args.flags["lock-only"] || args.flags.release)) {
896
+ // --lock-only builds its flip commit as it validates and --release only
897
+ // deletes; neither has a meaningful stop-before-writing seam.
898
+ fail("--dry-run applies to plain claim and --next only")
899
+ }
881
900
  const store = openStoreFor(ref)
882
901
  const key = store.parseId(ref)
883
902
  if (args.flags["lock-only"]) {
884
903
  // The branch-pinned session's claim: same lock, no checkout switch —
885
904
  // the work delivers on the branch this session was handed, and the flip
886
- // commit's delivered_by: trailer says so. See claimLockOnly.
905
+ // commit's Delivered-By: trailer says so. See claimLockOnly.
887
906
  if (args.flags.release) fail(CLAIM_USAGE)
888
907
  const result = claimLockOnly(store, key, options)
889
908
  if (args.flags.json) {
@@ -899,7 +918,7 @@ function cmdClaim(args: Args): void {
899
918
  `claimed ${result.task.id} — ${result.branch} on origin (lock only, from ${result.base}), status in_progress`,
900
919
  )
901
920
  console.log(
902
- `checkout untouched — keep working on ${result.deliveredBy} (recorded as delivered_by in the claim commit)`,
921
+ `checkout untouched — keep working on ${result.deliveredBy} (recorded as Delivered-By in the claim commit)`,
903
922
  )
904
923
  }
905
924
  return
@@ -1296,6 +1315,75 @@ function cmdServe(args: Args): void {
1296
1315
  })
1297
1316
  }
1298
1317
 
1318
+ /**
1319
+ * `task export` — the same board, as files a static host can serve.
1320
+ *
1321
+ * Anchored exactly like `cmdServe`, because that's the promise: what comes out
1322
+ * is what `task serve` would have served, read-only and honestly stale. The
1323
+ * output is the prebuilt UI plus a `snapshot.json` the app reads instead of an
1324
+ * API — no server, no network calls beyond the static files.
1325
+ */
1326
+ function cmdExport(args: Args): void {
1327
+ const serveRoot = findRoot(process.cwd()) ?? process.cwd()
1328
+ const boards = findBoards(serveRoot)
1329
+ if (boards.length === 0) {
1330
+ fail("no .task directory found in this directory, any parent, or below — run `task init` first")
1331
+ }
1332
+ // Deliberately not under `.task/`: `task serve` watches that tree
1333
+ // recursively, so an export written there would fire the change feed on
1334
+ // every asset copied.
1335
+ const out = str(args.flags, "out") ?? join(serveRoot, DEFAULT_OUT_DIR)
1336
+ const outDir = isAbsolute(out) ? out : resolve(process.cwd(), out)
1337
+
1338
+ // The live proxy is written unless told not to. It is inert until the host
1339
+ // has a token — it answers 503 and the board reads the snapshot — so the
1340
+ // default costs nothing, and turning a deploy live becomes one secret
1341
+ // rather than a re-export. The repository it reads is baked in: the export
1342
+ // already knows (the same remote detection `task publish` uses), and one
1343
+ // fewer setting on the host is one fewer thing to get wrong.
1344
+ let functions: { repo: string } | undefined
1345
+ let noRemote = false
1346
+ if (!args.flags["no-functions"]) {
1347
+ const slug = str(args.flags, "repo")
1348
+ const ref = slug ? parseSlug(slug) : detectRepo(serveRoot)
1349
+ if (slug && !ref) fail(`--repo must be owner/name — got ${JSON.stringify(slug)}`)
1350
+ if (ref) functions = { repo: `${ref.owner}/${ref.repo}` }
1351
+ else noRemote = true
1352
+ }
1353
+
1354
+ let summary
1355
+ try {
1356
+ summary = writeExport({
1357
+ serveRoot,
1358
+ outDir,
1359
+ uiDir: UI_DIR,
1360
+ force: Boolean(args.flags.force),
1361
+ base: str(args.flags, "base"),
1362
+ functions,
1363
+ })
1364
+ } catch (error) {
1365
+ fail(error instanceof Error ? error.message : String(error))
1366
+ }
1367
+
1368
+ if (args.flags.json) {
1369
+ console.log(JSON.stringify(summary, null, 2))
1370
+ return
1371
+ }
1372
+ const what = `${summary.tasks} task${summary.tasks === 1 ? "" : "s"} across ${summary.boards} board${summary.boards === 1 ? "" : "s"}`
1373
+ console.log(`Exported ${what}${summary.commit ? ` at ${summary.commit.slice(0, 7)}` : ""}`)
1374
+ console.log(` ${summary.outDir}`)
1375
+ console.log("Serve that directory from any static host — the board reads snapshot.json.")
1376
+ if (summary.functions && functions) {
1377
+ // One line, every run: this tier has no viewer auth, and the moment the
1378
+ // function is written is the moment to say so. The README says the rest.
1379
+ console.log(
1380
+ ` ${summary.functions} reads ${functions.repo} live once the host has a TASK_GITHUB_TOKEN secret — to anyone who can reach it (README: "Live data for an exported board")`,
1381
+ )
1382
+ } else if (noRemote) {
1383
+ console.log(" no live function written — no GitHub remote to read from; pass --repo owner/name to add one")
1384
+ }
1385
+ }
1386
+
1299
1387
  /**
1300
1388
  * `task publish` — the board, at a URL.
1301
1389
  *
@@ -1488,7 +1576,7 @@ Usage
1488
1576
  a provisioned branch (CI, a Claude web
1489
1577
  session): the lock lives on the claim
1490
1578
  branch, the work delivers on yours, and the
1491
- flip commit's delivered_by: trailer records
1579
+ flip commit's Delivered-By: trailer records
1492
1580
  which. --release works on a lock-only claim
1493
1581
  the same way
1494
1582
  task claim --next [--board <P>] [--force]
@@ -1500,6 +1588,16 @@ Usage
1500
1588
  the nearest board. Exit codes: 0 claimed
1501
1589
  (prints the ticket), 1 queue empty, 2
1502
1590
  preconditions failed
1591
+ task claim <id|--next> --dry-run
1592
+ the same decision path — every gate, against
1593
+ live origin state — stopped before the first
1594
+ write: no branch, no commit, checkout
1595
+ untouched. Same exit codes as the real
1596
+ command, so automation asks "would a claim
1597
+ succeed right now?" through the code that
1598
+ would perform it. A yes is a reading, not a
1599
+ reservation — no lock is taken. Plain claim
1600
+ and --next only
1503
1601
  task claim --release <id> --comment "<why>"
1504
1602
  abandon a claim: delete the branch on origin
1505
1603
  and locally — the status flip only lived on
@@ -1577,6 +1675,26 @@ Usage
1577
1675
  fails instead). Serves every board at or below
1578
1676
  here — in a monorepo the header becomes a
1579
1677
  board switcher
1678
+ task export [--out <dir>] [--base <path>] [--force] [--no-functions | --repo <owner/name>]
1679
+ write the board out as static files — the same
1680
+ UI plus a snapshot.json it reads instead of an
1681
+ API, so any static host serves it read-only
1682
+ with no server behind it. Same boards as
1683
+ \`task serve\`; defaults to ./${DEFAULT_OUT_DIR}.
1684
+ --base is the URL path the export will live
1685
+ under (default /) — pass /board/ when it's
1686
+ hosted at example.com/board/ instead of a
1687
+ domain root. Re-exporting refreshes an earlier
1688
+ export in place and leaves anything else in
1689
+ there alone; a non-empty directory that isn't
1690
+ one needs --force. Also writes a Cloudflare
1691
+ Pages Function that reads the repo live from
1692
+ GitHub: inert until the host has a
1693
+ TASK_GITHUB_TOKEN secret (a fine-grained PAT,
1694
+ Contents: read-only), at which point the same
1695
+ deploy upgrades itself — no viewer auth, mind.
1696
+ The repo comes from your remotes; --repo sets
1697
+ it, --no-functions skips the function
1580
1698
  task publish [--public | --private] [--repo <owner/name>]
1581
1699
  give this repo's board a URL at
1582
1700
  task.nickmeriano.com, read-only, updated from
@@ -1679,6 +1797,8 @@ function main(): void | Promise<void> {
1679
1797
  return cmdCheck(args)
1680
1798
  case "serve":
1681
1799
  return cmdServe(args)
1800
+ case "export":
1801
+ return cmdExport(args)
1682
1802
  case "publish":
1683
1803
  return cmdPublish(args)
1684
1804
  case "unpublish":