@nickmeriano/task 0.9.0 → 0.11.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 (46) hide show
  1. package/README.md +129 -0
  2. package/dist/asks.test.js +6 -0
  3. package/dist/asks.test.js.map +1 -1
  4. package/dist/claim-io.d.ts.map +1 -1
  5. package/dist/claim-io.js +4 -3
  6. package/dist/claim-io.js.map +1 -1
  7. package/dist/claim.d.ts +99 -1
  8. package/dist/claim.d.ts.map +1 -1
  9. package/dist/claim.js +271 -6
  10. package/dist/claim.js.map +1 -1
  11. package/dist/claim.test.d.ts +8 -0
  12. package/dist/claim.test.d.ts.map +1 -1
  13. package/dist/claim.test.js +211 -2
  14. package/dist/claim.test.js.map +1 -1
  15. package/dist/cli.js +193 -7
  16. package/dist/cli.js.map +1 -1
  17. package/dist/export.d.ts +132 -0
  18. package/dist/export.d.ts.map +1 -0
  19. package/dist/export.js +199 -0
  20. package/dist/export.js.map +1 -0
  21. package/dist/export.test.d.ts +12 -0
  22. package/dist/export.test.d.ts.map +1 -0
  23. package/dist/export.test.js +185 -0
  24. package/dist/export.test.js.map +1 -0
  25. package/dist/server.d.ts +6 -0
  26. package/dist/server.d.ts.map +1 -1
  27. package/dist/server.js +12 -3
  28. package/dist/server.js.map +1 -1
  29. package/dist/trailers.d.ts +51 -0
  30. package/dist/trailers.d.ts.map +1 -0
  31. package/dist/trailers.js +32 -0
  32. package/dist/trailers.js.map +1 -0
  33. package/package.json +1 -1
  34. package/skill/SKILL.md +34 -1
  35. package/src/asks.test.ts +15 -0
  36. package/src/claim-io.ts +12 -3
  37. package/src/claim.test.ts +286 -2
  38. package/src/claim.ts +375 -6
  39. package/src/cli.ts +208 -7
  40. package/src/export.test.ts +254 -0
  41. package/src/export.ts +276 -0
  42. package/src/server.ts +12 -3
  43. package/src/trailers.ts +65 -0
  44. package/ui/dist/assets/{index-CoKCUYic.css → index-eHsqltgs.css} +1 -1
  45. package/ui/dist/assets/{index-BjsorZOU.js → index-mJmm4sWq.js} +53 -53
  46. package/ui/dist/index.html +3 -3
package/src/export.ts ADDED
@@ -0,0 +1,276 @@
1
+ /**
2
+ * `task export` — the board as a directory of files.
3
+ *
4
+ * The third deployment of this app (after `task serve` and the hosted board)
5
+ * is a static host: the same prebuilt UI the package already ships for serve,
6
+ * plus a `snapshot.json` this module writes out of the local `.task/`. There
7
+ * is no server behind it, so the app reads its answers out of that file —
8
+ * `ui/src/snapshot.ts` is the reader, and this is the writer.
9
+ *
10
+ * The whole correctness question is one sentence: **the snapshot must answer
11
+ * what `routeApi` answers**. Every field below is sourced from the same store
12
+ * call the matching `/api/…` route makes, so a board rendered from a file and
13
+ * a board rendered from the server differ only in how stale they are.
14
+ *
15
+ * Split in two on purpose: `buildSnapshot` is the shape (no writes, so it is
16
+ * testable without a built UI, and the UI's own test can round-trip it through
17
+ * `parseSnapshot`), `writeExport` is the directory.
18
+ */
19
+
20
+ import { execFileSync } from "node:child_process"
21
+ import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"
22
+ import { join, resolve } from "node:path"
23
+ import { resolveAuthor } from "./author.ts"
24
+ import { openBoard } from "./file-store.ts"
25
+ import { findBoards } from "./store.ts"
26
+ import { STATUSES, type Comment, type Goal, type Status, type Task } from "./types.ts"
27
+
28
+ /**
29
+ * The version the reader accepts, restated from `ui/src/snapshot.ts`. The UI
30
+ * is built separately from this NodeNext package, so the contract is mirrored
31
+ * rather than cross-imported — the same precedent `ui/src/api.ts` sets for
32
+ * `types.ts`. `ui/src/deployment.test.ts` imports `buildSnapshot` and feeds
33
+ * the result to the real `parseSnapshot`, which is what keeps the two honest.
34
+ */
35
+ export const SNAPSHOT_VERSION = 2
36
+
37
+ /** The file's name in the output directory, matching `SNAPSHOT_PATH`. */
38
+ export const SNAPSHOT_FILE = "snapshot.json"
39
+
40
+ /** Where an export goes when `--out` doesn't say. */
41
+ export const DEFAULT_OUT_DIR = "task-export"
42
+
43
+ /** The sentence `serveStatic` prints for the same missing directory. */
44
+ const NO_UI_BUILD = "UI build not found — run the package build (vite build ui)."
45
+
46
+ /**
47
+ * The assets are built with relative refs (`base: "./"` in ui/vite.config.ts)
48
+ * because one build ships three ways and can't know where it will live. The
49
+ * `<base href>` each deployment stamps into index.html is what completes them:
50
+ * the browser resolves `./assets/…` — and the app resolves its routes, API and
51
+ * snapshot fetch — against it instead of the page's own URL, which is what
52
+ * keeps deep links working under any mount point.
53
+ */
54
+
55
+ /** `board` / `/board` / `board/` → `/board/`; `""` and `/` → `/`. */
56
+ export function normalizeBase(base: string): string {
57
+ let path = base.trim()
58
+ if (path === "" || path === "/") return "/"
59
+ if (!path.startsWith("/")) path = `/${path}`
60
+ if (!path.endsWith("/")) path = `${path}/`
61
+ if (path.includes("//") || /[\s<>"']/.test(path)) {
62
+ throw new Error(`--base must be a plain URL path like /board/ — got ${JSON.stringify(base)}`)
63
+ }
64
+ return path
65
+ }
66
+
67
+ /**
68
+ * Stamp the deployment's mount point into the built page. The built
69
+ * index.html always has exactly one `<head>` for this to anchor on; refusing
70
+ * a page without one beats silently shipping a board that 404s its assets.
71
+ */
72
+ export function injectBase(html: string, base: string): string {
73
+ if (!html.includes("<head>")) {
74
+ throw new Error("index.html has no <head> to carry the <base> tag — is this the built UI?")
75
+ }
76
+ return html.replace("<head>", `<head><base href="${normalizeBase(base)}" />`)
77
+ }
78
+
79
+ export interface SnapshotMeta {
80
+ /** The commit exported from (`git rev-parse HEAD`), or null outside a repo. */
81
+ commit: string | null
82
+ /** When the export ran, ISO-8601 — the other half of the staleness banner. */
83
+ builtAt: string
84
+ }
85
+
86
+ /** What `/api/boards` answers, per board. */
87
+ export interface SnapshotBoardInfo {
88
+ id: string
89
+ name: string
90
+ prefix: string
91
+ }
92
+
93
+ /** What `/api/project` answers. `readOnly` is the reader's call, not ours. */
94
+ export interface SnapshotProject {
95
+ name: string
96
+ prefix: string
97
+ statuses: readonly Status[]
98
+ author: string
99
+ }
100
+
101
+ /** One board's baked answers, in the shapes the serve API returns. */
102
+ export interface SnapshotBoard {
103
+ project: SnapshotProject
104
+ tasks: Task[]
105
+ goals: Goal[]
106
+ /** Task key to its comments, oldest first; absent means none. */
107
+ comments: Record<string, Comment[]>
108
+ }
109
+
110
+ export interface Snapshot {
111
+ version: number
112
+ meta: SnapshotMeta
113
+ boards: SnapshotBoardInfo[]
114
+ data: Record<string, SnapshotBoard>
115
+ }
116
+
117
+ export interface BuildSnapshotOptions {
118
+ /** Overridable so tests get a fixed build time. */
119
+ builtAt?: string
120
+ }
121
+
122
+ /**
123
+ * The commit this export is of.
124
+ *
125
+ * Every way this can fail — git not installed, not a repository, a repo with
126
+ * no commits yet — is the same non-answer, so they all collapse to null. The
127
+ * reader's type already says `string | null` and its banner degrades to
128
+ * "snapshot, built …", which is the honest thing to show.
129
+ */
130
+ export function exportedCommit(cwd: string): string | null {
131
+ try {
132
+ const out = execFileSync("git", ["rev-parse", "HEAD"], {
133
+ cwd,
134
+ encoding: "utf8",
135
+ timeout: 2000,
136
+ stdio: ["ignore", "pipe", "ignore"],
137
+ })
138
+ return out.trim() || null
139
+ } catch {
140
+ return null
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Bake every board at or below `serveRoot` into one snapshot.
146
+ *
147
+ * Anchoring matches `cmdServe` exactly (`findRoot` ?? cwd, then `findBoards`),
148
+ * so "export what serve would serve" is true by construction — one board in a
149
+ * package, every nested board at a monorepo root, `.` first either way.
150
+ */
151
+ export function buildSnapshot(serveRoot: string, options: BuildSnapshotOptions = {}): Snapshot {
152
+ const refs = findBoards(serveRoot)
153
+ if (refs.length === 0) {
154
+ throw new Error(
155
+ `no .task directory found at or below ${serveRoot} — run \`task init\` first`,
156
+ )
157
+ }
158
+ // Resolved once, server-side, exactly as `createTaskServer` does it: all the
159
+ // boards live in one checkout, so one resolution covers them.
160
+ const author = resolveAuthor(undefined, serveRoot).name
161
+
162
+ const boards: SnapshotBoardInfo[] = []
163
+ const data: Record<string, SnapshotBoard> = {}
164
+ for (const ref of refs) {
165
+ const store = openBoard(ref.root)
166
+ try {
167
+ boards.push({ id: ref.id, name: store.config.name, prefix: store.config.prefix })
168
+ const counts = store.commentCounts()
169
+ const tasks = store.list().map((task) => ({
170
+ ...task,
171
+ commentCount: counts.get(task.key) ?? 0,
172
+ }))
173
+ const comments: Record<string, Comment[]> = {}
174
+ for (const task of tasks) {
175
+ const list = store.comments(task.key)
176
+ // Omitted rather than empty: the reader defaults a missing key to [],
177
+ // and a snapshot is a file people open.
178
+ if (list.length > 0) comments[task.key] = list
179
+ }
180
+ data[ref.id] = {
181
+ project: { name: store.config.name, prefix: store.config.prefix, statuses: STATUSES, author },
182
+ tasks,
183
+ // Archived goals ride along flagged, as `/api/goals` sends them, so an
184
+ // old chip on a task can still resolve a title.
185
+ goals: [...store.goals(), ...store.goals(true)],
186
+ comments,
187
+ }
188
+ } finally {
189
+ store.close()
190
+ }
191
+ }
192
+
193
+ return {
194
+ version: SNAPSHOT_VERSION,
195
+ meta: {
196
+ commit: exportedCommit(serveRoot),
197
+ builtAt: options.builtAt ?? new Date().toISOString(),
198
+ },
199
+ boards,
200
+ data,
201
+ }
202
+ }
203
+
204
+ export interface WriteExportOptions {
205
+ serveRoot: string
206
+ outDir: string
207
+ /** The built SPA to copy. Injectable so tests never need a vite build. */
208
+ uiDir: string
209
+ /** Write into a directory that isn't a previous export. Never deletes. */
210
+ force?: boolean
211
+ /** URL path the export will be served under (default `/`) — see `injectBase`. */
212
+ base?: string
213
+ builtAt?: string
214
+ }
215
+
216
+ export interface ExportSummary {
217
+ outDir: string
218
+ base: string
219
+ boards: number
220
+ tasks: number
221
+ commit: string | null
222
+ builtAt: string
223
+ }
224
+
225
+ /**
226
+ * Write the assets and the snapshot into `outDir`.
227
+ *
228
+ * Refresh is the interesting case. Re-exporting over a previous export
229
+ * replaces the UI's own entries and `snapshot.json` and leaves everything else
230
+ * alone — notably a `functions/` directory, so a deployed live-proxy function
231
+ * survives a re-export. Anything else in a non-empty directory is refused
232
+ * rather than overwritten: `--out .` against a repo is the one genuinely
233
+ * destructive mistake available here.
234
+ */
235
+ export function writeExport(options: WriteExportOptions): ExportSummary {
236
+ const { serveRoot, outDir, uiDir, force = false } = options
237
+ const base = normalizeBase(options.base ?? "/")
238
+ if (!existsSync(uiDir)) throw new Error(NO_UI_BUILD)
239
+ // Exporting onto the assets it copies from would clear them first and then
240
+ // copy an empty directory — the one way this can destroy the package itself.
241
+ if (resolve(outDir) === resolve(uiDir)) {
242
+ throw new Error(`${outDir} is the UI build itself — export somewhere else`)
243
+ }
244
+
245
+ const snapshot = buildSnapshot(serveRoot, { builtAt: options.builtAt })
246
+
247
+ const existing = existsSync(outDir) ? readdirSync(outDir) : null
248
+ if (existing && existing.length > 0 && !existing.includes(SNAPSHOT_FILE) && !force) {
249
+ throw new Error(
250
+ `${outDir} is not empty and isn't a previous export — pass --force to write into it anyway`,
251
+ )
252
+ }
253
+
254
+ mkdirSync(outDir, { recursive: true })
255
+ // Clear only what this export is about to write: a stale `assets/` full of
256
+ // last build's hashed bundles is dead weight, and everything else in there
257
+ // belongs to whoever put it there.
258
+ for (const entry of readdirSync(uiDir)) {
259
+ rmSync(join(outDir, entry), { recursive: true, force: true })
260
+ }
261
+ cpSync(uiDir, outDir, { recursive: true })
262
+ const indexPath = join(outDir, "index.html")
263
+ writeFileSync(indexPath, injectBase(readFileSync(indexPath, "utf8"), base))
264
+ // Pretty-printed: this lands in CI artifacts and gets diffed, and at this
265
+ // size the whitespace costs nothing.
266
+ writeFileSync(join(outDir, SNAPSHOT_FILE), `${JSON.stringify(snapshot, null, 2)}\n`)
267
+
268
+ return {
269
+ outDir,
270
+ base,
271
+ boards: snapshot.boards.length,
272
+ tasks: Object.values(snapshot.data).reduce((n, board) => n + board.tasks.length, 0),
273
+ commit: snapshot.meta.commit,
274
+ builtAt: snapshot.meta.builtAt,
275
+ }
276
+ }
package/src/server.ts CHANGED
@@ -4,14 +4,19 @@ import { extname, join, normalize } from "node:path"
4
4
  import { fileURLToPath } from "node:url"
5
5
  import { resolveAuthor } from "./author.ts"
6
6
  import { addAskRouted, deleteAskRouted, setAskResolvedRouted } from "./claim-io.ts"
7
+ import { injectBase } from "./export.ts"
7
8
  import { openBoard } from "./file-store.ts"
8
9
  import { GitServe, GitServeError } from "./git-serve.ts"
9
10
  import { buildInbox } from "./inbox.ts"
10
11
  import { boardConfig, findBoards, type BoardRef, type Store } from "./store.ts"
11
12
  import { STATUSES, isStatus, type TaskPatch } from "./types.ts"
12
13
 
13
- /** The prebuilt SPA, shipped inside the package next to dist/. */
14
- const UI_DIR = fileURLToPath(new URL("../ui/dist", import.meta.url))
14
+ /**
15
+ * The prebuilt SPA, shipped inside the package next to dist/. Exported because
16
+ * `task export` copies the same assets it serves — one definition of where the
17
+ * UI lives, or the two deployments drift.
18
+ */
19
+ export const UI_DIR = fileURLToPath(new URL("../ui/dist", import.meta.url))
15
20
 
16
21
  const MIME: Record<string, string> = {
17
22
  ".html": "text/html; charset=utf-8",
@@ -535,7 +540,11 @@ export function createTaskServer(serveRoot: string): Server {
535
540
  "Content-Type": MIME[extname(file)] ?? "application/octet-stream",
536
541
  "Cache-Control": "no-cache",
537
542
  })
538
- res.end(readFileSync(file))
543
+ // The build's asset refs are relative (ui/vite.config.ts) and the page —
544
+ // this one included, when it's the SPA fallback for a deep link — needs a
545
+ // `<base>` to resolve them against. serve always mounts the app at the
546
+ // origin root; `task export --base` is where any other answer lives.
547
+ res.end(file.endsWith("index.html") ? injectBase(readFileSync(file, "utf8"), "/") : readFileSync(file))
539
548
  }
540
549
 
541
550
  return server
@@ -0,0 +1,65 @@
1
+ /**
2
+ * The commit-trailer contract: every commit the CLI itself authors for a
3
+ * board operation carries machine-readable git trailers naming the operation
4
+ * and the ticket, so automation can react to board events without parsing
5
+ * human prose. Subjects like "chore(board): resolve ask [1] on TAS-x" are
6
+ * presentation and may be reworded freely; the trailers are the interface.
7
+ *
8
+ * chore(board): resolve ask [1] on TAS-x7k4m
9
+ *
10
+ * Task-Op: resolve-ask
11
+ * Task-Id: TAS-x7k4m
12
+ *
13
+ * Git parses trailers natively — consumers read them with
14
+ * `git log -1 --format='%(trailers:key=Task-Op,valueonly)'`, or match
15
+ * `Task-Op: <op>` in a forge webhook's head-commit message (each routed
16
+ * write is its own single-commit push, so the head commit is the operation).
17
+ *
18
+ * STABILITY: `Task-Op` and `Task-Id` are a documented, test-pinned contract
19
+ * (see the "commit trailers" tests). Existing op names never change meaning
20
+ * or disappear; new ops may be added. Renaming an op or dropping a trailer
21
+ * is a breaking change to every consumer's automation.
22
+ */
23
+
24
+ /**
25
+ * The operation vocabulary. Emitted today:
26
+ *
27
+ * - `claim` — the claim branch's first commit, the in_progress flip
28
+ * (plain and --lock-only alike)
29
+ * - `ask` — an ask filed onto a claim branch (routed write)
30
+ * - `resolve-ask` — an ask checked off on a claim branch: the signal that a
31
+ * stalled claim may be implementable again
32
+ * - `reopen-ask` — a resolved ask put back
33
+ * - `delete-ask` — an ask removed from a claim branch
34
+ *
35
+ * Reserved, not yet emitted:
36
+ *
37
+ * - `begin-implement` — a worker marking the start of an implement pass on a
38
+ * claim (an empty commit pushed at tick start), so "this claim is being
39
+ * worked right now" becomes board state a dispatcher can read instead of a
40
+ * blind spot. Designed for the mid-flight-visibility problem; deliberately
41
+ * deferred until collisions prove worth the extra moving part. Consumers
42
+ * should treat unknown ops as "not the op I'm looking for", never an error.
43
+ */
44
+ export type TaskOp =
45
+ | "claim"
46
+ | "ask"
47
+ | "resolve-ask"
48
+ | "reopen-ask"
49
+ | "delete-ask"
50
+ | "begin-implement"
51
+
52
+ /**
53
+ * Compose a board commit message: the human subject, then one trailer block.
54
+ * `extra` rides in the same block (e.g. lock-only's `Delivered-By:`) — git
55
+ * only recognizes a *contiguous* trailing block, so every trailer must go
56
+ * through this one seam.
57
+ */
58
+ export function boardCommitMessage(
59
+ subject: string,
60
+ op: TaskOp,
61
+ id: string,
62
+ extra: string[] = [],
63
+ ): string {
64
+ return `${subject}\n\n${[...extra, `Task-Op: ${op}`, `Task-Id: ${id}`].join("\n")}`
65
+ }