// ticket-page: the only writer of a ticket's page data. See ../SITE.md for when the // ticket skill calls each command. Run `ticket-page help` for the command list. import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, join, relative, resolve } from "node:path"; import { spawnSync } from "node:child_process"; import { createHash } from "node:crypto"; import { htmlToText, importIssue, type JiraIssue, scanResources } from "./jira-import"; import { type Criterion, type Epic, type Finding, type Mock, now, type Plan, progress, readData, type Step, storyStatus, type Ticket, writeData, } from "./ticket-data"; const SITE_TEMPLATES = resolve(import.meta.dir, "../templates/site"); class UsageError extends Error {} function fail(msg: string): never { throw new UsageError(msg); } /** --flag value pairs and bare --switches after the positional arguments. */ function parse(argv: string[]) { const pos: string[] = []; const opt = new Map(); for (let i = 0; i < argv.length; i++) { const a = argv[i]; if (!a.startsWith("--")) { pos.push(a); continue; } const name = a.slice(2); const next = argv[i + 1]; const value = next === undefined || next.startsWith("--") ? "true" : (i++, next); opt.set(name, [...(opt.get(name) ?? []), value]); } return { pos, one: (k: string) => opt.get(k)?.at(-1), all: (k: string) => opt.get(k) ?? [], has: (k: string) => opt.has(k) }; } const list = (s?: string) => (s ? s.split(",").map(x => x.trim()).filter(Boolean) : []); // ── Where things live ──────────────────────────────────────────────────────── export function docsRoot(): string { if (process.env.TICKET_DOCS) return resolve(process.env.TICKET_DOCS); const git = spawnSync("git", ["rev-parse", "--show-toplevel"], { encoding: "utf8" }); if (git.status !== 0) fail("not inside a git repository, and TICKET_DOCS is not set"); return join(git.stdout.trim(), ".claude/docs/epics"); } function ticketDir(key: string): string { const root = docsRoot(); const hit = existsSync(root) && readdirSync(root).map(d => join(root, d, key)).find(d => existsSync(join(d, "ticket.data.js"))); return hit || fail(`no ticket page for ${key} under ${root}. Run: ticket-page init ${key} --issue `); } const ticketFile = (dir: string) => join(dir, "ticket.data.js"); const epicFile = (epicKey: string) => join(docsRoot(), epicKey, "epic.data.js"); /** Copies the shared renderer into _site/ when the skill's copy changed, and writes the * page shell. Run on every write so an old ticket picks up the current renderer. */ function refreshSite(pageDir: string, kind: "ticket" | "epic", title: string) { const site = join(docsRoot(), "_site"); mkdirSync(site, { recursive: true }); const version = createHash("sha1"); for (const f of ["site.css", "site.js"]) { const from = join(SITE_TEMPLATES, f), to = join(site, f); const body = readFileSync(from, "utf8"); version.update(body); if (!existsSync(to) || readFileSync(to, "utf8") !== body) copyFileSync(from, to); } // The shell names the renderer by content hash, so a browser that cached an older // renderer loads the new one the next time the page opens. const shell = readFileSync(join(SITE_TEMPLATES, `${kind}.html`), "utf8") .replaceAll("{{SITE}}", relative(pageDir, site) || ".") .replaceAll("{{VERSION}}", version.digest("hex").slice(0, 10)) .replaceAll("{{TITLE}}", title.replace(/[<&]/g, c => (c === "<" ? "<" : "&"))); const page = join(pageDir, "index.html"); if (!existsSync(page) || readFileSync(page, "utf8") !== shell) writeFileSync(page, shell); } // ── Ticket reads and writes ────────────────────────────────────────────────── function load(key: string) { const dir = ticketDir(key); return { dir, t: readData(ticketFile(dir), "ticket") }; } function save(dir: string, t: Ticket) { writeData(ticketFile(dir), "ticket", t); refreshSite(dir, "ticket", `${t.key}: ${t.title}`); if (t.epic) syncStory(t); } function log(t: Ticket, kind: string, text: string, commit?: string) { t.events.push({ at: now(), kind, text, ...(commit ? { commit } : {}) }); } /** Keeps this ticket's row on its epic index current. Creates the epic page if the * ticket is the first one worked under it. A story marked done stays done. */ function syncStory(t: Ticket) { const epic = t.epic!; const file = epicFile(epic.key); const e: Epic = existsSync(file) ? readData(file, "epic") : { schema: 1, key: epic.key, title: epic.title, jira: t.jira.replace(t.key, epic.key), why: [], stories: [], closed: [], updatedAt: now() }; let s = e.stories.find(x => x.key === t.key); if (!s) { s = { key: t.key, title: t.title, after: [], status: "todo" }; e.stories.push(s); } s.title = t.title; s.page = true; s.progress = progress(t); if (s.status !== "done") s.status = storyStatus(t); writeData(file, "epic", e); refreshSite(dirname(file), "epic", `${e.key}: ${e.title}`); } // ── Commands ───────────────────────────────────────────────────────────────── type Args = ReturnType; const commands: Record void }> = {}; const command = (name: string, usage: string, run: (a: Args) => void) => { commands[name] = { usage, run }; }; command("init", "init --issue [--reset] import the ticket, keeping any plan unless --reset", a => { const [key] = a.pos; const issuePath = a.one("issue") ?? fail("init needs --issue "); const issue: JiraIssue = JSON.parse(readFileSync(issuePath, "utf8")); if (issue.key !== key) fail(`${issuePath} holds ${issue.key}, not ${key}`); const imported = importIssue(issue, process.env.JIRA_BASE_URL ?? "https://ksense-tech.atlassian.net"); const { unplaced, ...meta } = imported; const dir = join(docsRoot(), meta.epic?.key ?? "tickets", key); mkdirSync(join(dir, "resources"), { recursive: true }); const prior = existsSync(ticketFile(dir)) ? readData(ticketFile(dir), "ticket") : null; const keep = prior && !a.has("reset") ? prior : null; const t: Ticket = { schema: 1, ...meta, branch: keep?.branch, classified: keep?.classified, // Notes the agent wrote survive a re-import. Jira's own sections are replaced. source: { ...meta.source, sections: [...meta.source.sections, ...(keep?.source.sections.filter(s => s.origin === "note") ?? [])] }, attachments: scanResources(dir), plan: keep?.plan ?? null, proof: keep?.proof ?? [], review: keep?.review ?? { findings: [], preMr: null }, jiraFields: keep?.jiraFields ?? [], mr: keep?.mr ?? null, events: keep?.events ?? [], updatedAt: now(), }; log(t, "fetch", prior ? (keep ? "Re-imported the ticket from Jira, plan kept" : "Re-imported the ticket from Jira, plan cleared") : "Imported the ticket from Jira"); save(dir, t); console.log(dir); if (unplaced.length) console.log(`\nPopulated fields not on the page. Add any that matter with \`ticket-page note\`:\n ${unplaced.join("\n ")}`); }); command("scan", "scan re-read resources/ after downloads, transcripts or frames change", a => { const { dir, t } = load(a.pos[0]); t.attachments = scanResources(dir); const withTranscript = t.attachments.filter(x => x.transcript?.length).length; log(t, "fetch", `Scanned ${t.attachments.length} attachments, ${withTranscript} with a transcript`); save(dir, t); }); command("meta", "meta [--branch B] [--classified bounded|architectural]", a => { const { dir, t } = load(a.pos[0]); if (a.has("branch")) t.branch = a.one("branch"); const c = a.one("classified"); if (c) t.classified = c === "bounded" || c === "architectural" ? c : fail("--classified is bounded or architectural"); save(dir, t); }); command("note", "note --file <notes.html> add or replace an agent-written section on the Ticket tab", a => { const [key, title] = a.pos; if (!title) fail("note needs a title"); const { dir, t } = load(key); const html = readFileSync(a.one("file") ?? fail("note needs --file"), "utf8"); const id = "note-" + title.toLowerCase().replace(/[^a-z0-9]+/g, "-"); t.source.sections = [...t.source.sections.filter(s => s.id !== id), { id, title, html, origin: "note" }]; save(dir, t); }); command("plan", "plan <KEY> --file <plan.json> [--amend] write the plan. A rewrite resets progress and proof; --amend keeps both for the steps and criteria that remain", a => { const { dir, t } = load(a.pos[0]); type StepIn = Omit<Step, "status" | "commits" | "doneAt"> & Partial<Pick<Step, "kind">>; type CritIn = Omit<Criterion, "status" | "doneAt" | "evidence">; const input = JSON.parse(readFileSync(a.one("file") ?? fail("plan needs --file"), "utf8")) as Partial<Omit<Plan, "steps" | "criteria">> & { steps: StepIn[]; criteria: CritIn[] }; const prior = t.plan; const amend = a.has("amend"); if (amend && !prior) fail("--amend needs an existing plan"); const was = <T,>(list: T[] | undefined, match: (x: T) => boolean) => (amend ? list?.find(match) : undefined); const plan: Plan = { writtenAt: now(), ...(amend && prior?.approvedAt ? { approvedAt: prior.approvedAt } : {}), criteria: (input.criteria ?? fail("plan.json needs criteria")).map(c => { const old = was(prior?.criteria, x => x.id === c.id); return { ...c, steps: c.steps ?? [], status: old?.status ?? "todo", ...(old?.doneAt ? { doneAt: old.doneAt } : {}), ...(old?.evidence ? { evidence: old.evidence } : {}) }; }), questions: input.questions ?? [], approach: input.approach ?? "", decisions: input.decisions ?? [], steps: (input.steps ?? fail("plan.json needs steps")).map(s => { const old = was(prior?.steps, x => x.n === s.n && x.text === s.text); return { ...s, kind: s.kind ?? "code", status: old?.status ?? "todo", commits: old?.commits ?? [], ...(old?.doneAt ? { doneAt: old.doneAt } : {}) }; }), risks: input.risks ?? [], tests: input.tests ?? [], mocks: (input.mocks ?? prior?.mocks ?? []).map(m => ({ ...m, criteria: m.criteria ?? [] })), }; t.plan = plan; const ids = new Set(plan.criteria.map(c => c.id)); t.proof = amend ? t.proof.map(p => ({ ...p, proves: p.proves.filter(c => ids.has(c)) })).filter(p => p.proves.length) : []; log(t, "plan", amend ? `Amended the plan: ${plan.criteria.length} criteria, ${plan.steps.length} steps` : `Wrote the plan: ${plan.criteria.length} criteria, ${plan.steps.length} steps${t.classified ? `, classified ${t.classified}` : ""}`); save(dir, t); }); command("plan-json", "plan-json <KEY> print the current plan as plan.json, to edit and reload with plan --amend", a => { const pl = load(a.pos[0]).t.plan ?? fail("there is no plan yet"); console.log(JSON.stringify({ criteria: pl.criteria.map(({ id, text, steps }) => ({ id, text, steps })), questions: pl.questions, approach: pl.approach, decisions: pl.decisions, steps: pl.steps.map(({ n, text, kind, tdd }) => ({ n, text, kind, ...(tdd ? { tdd } : {}) })), risks: pl.risks, tests: pl.tests, }, null, 2)); }); command("mock", "mock <KEY> <id> --title T --criteria AC1,AC2 --file <artboard.html> [--note N] add or replace a mock artboard", a => { const [key, id] = a.pos; const { dir, t } = load(key); if (!t.plan) fail("write the plan before its mocks"); const m: Mock = { id: id ?? fail("mock needs an id"), title: a.one("title") ?? fail("mock needs --title"), note: a.one("note") ?? "", html: readFileSync(a.one("file") ?? fail("mock needs --file"), "utf8"), criteria: list(a.one("criteria")).length ? list(a.one("criteria")) : fail("mock needs --criteria, the criteria it illustrates") }; t.plan!.mocks = [...t.plan!.mocks.filter(x => x.id !== id), m]; save(dir, t); }); command("approve", "approve <KEY> mark the plan approved; Phase 2 starts", a => { const { dir, t } = load(a.pos[0]); if (!t.plan) fail("there is no plan to approve"); t.plan!.approvedAt = now(); log(t, "approve", "Plan approved"); save(dir, t); }); command("step", "step <KEY> <n> running|done|todo [--commit SHA]... done needs at least one commit, or --no-commit for a step with none", a => { const [key, n, status] = a.pos; const { dir, t } = load(key); const step = t.plan?.steps.find(s => s.n === Number(n)) ?? fail(`no step ${n}`); if (status !== "running" && status !== "done" && status !== "todo") fail("status is running, done or todo"); const commits = a.all("commit"); if (status === "done" && !commits.length && !step.commits.length && !a.has("no-commit")) fail(`step ${n} is done only once its commit exists: pass --commit <sha>`); if (status === "running") t.plan!.steps.forEach(s => { if (s.status === "running") s.status = "todo"; }); step.status = status; step.commits = [...new Set([...step.commits, ...commits])]; step.doneAt = status === "done" ? now() : undefined; if (status === "running") log(t, "start", `Started step ${n}: ${step.text}`); if (status === "done") log(t, "commit", `Finished step ${n}`, commits.at(-1)); save(dir, t); }); command("criterion", "criterion <KEY> <id> done|todo [--evidence TEXT]", a => { const [key, id, status] = a.pos; const { dir, t } = load(key); const c = t.plan?.criteria.find(x => x.id === id) ?? fail(`no criterion ${id}`); if (status !== "done" && status !== "todo") fail("status is done or todo"); c.status = status; c.doneAt = status === "done" ? now() : undefined; if (a.has("evidence")) c.evidence = a.one("evidence"); if (status === "done") log(t, "criterion", `Met ${id}${c.evidence ? `: ${c.evidence}` : ""}`); save(dir, t); }); command("proof", "proof <KEY> (--file proof/<name> | --test NAME) --proves AC1,AC2 --caption TEXT [--mock ID]", a => { const { dir, t } = load(a.pos[0]); const file = a.one("file"), test = a.one("test"); if (!file === !test) fail("proof takes exactly one of --file or --test"); const proves = list(a.one("proves")); if (!proves.length) fail("proof needs --proves"); const caption = a.one("caption") ?? fail("proof needs --caption"); if (file && !existsSync(join(dir, file))) fail(`${file} does not exist in ${dir}`); const isText = !!file && /\.(txt|log|md|json|csv|diff)$/i.test(file); t.proof = t.proof.filter(p => (file ? p.file !== file : p.test !== test)); t.proof.push({ kind: test ? "test" : isText ? "text" : "image", ...(file ? { file } : { test }), ...(isText ? { text: readFileSync(join(dir, file!), "utf8") } : {}), proves, caption, ...(a.one("mock") ? { mock: a.one("mock") } : {}), at: now(), }); log(t, "proof", `Captured proof for ${proves.join(", ")}: ${caption}`); save(dir, t); }); command("unproof", "unproof <KEY> <file-or-test> drop a proof entry, for files cut from proof/", a => { const [key, what] = a.pos; const { dir, t } = load(key); t.proof = t.proof.filter(p => p.file !== what && p.test !== what); save(dir, t); }); command("finding", "finding <KEY> add TEXT --severity CONFIRMED|PLAUSIBLE [--where file:line] | finding <KEY> <id> fixed --commit SHA | rejected --reason TEXT", a => { const [key, idOrAdd, arg] = a.pos; const { dir, t } = load(key); if (idOrAdd === "add") { const severity = a.one("severity"); if (severity !== "CONFIRMED" && severity !== "PLAUSIBLE") fail("--severity is CONFIRMED or PLAUSIBLE"); const f: Finding = { id: `F${t.review.findings.length + 1}`, severity, text: arg ?? fail("finding add needs its text"), status: "open", at: now(), ...(a.one("where") ? { where: a.one("where") } : {}) }; t.review.findings.push(f); log(t, "review", `Review finding ${f.id}: ${f.text}`); console.log(f.id); } else { const f = t.review.findings.find(x => x.id === idOrAdd) ?? fail(`no finding ${idOrAdd}`); if (arg === "fixed") { f.status = "fixed"; f.commit = a.one("commit") ?? fail("fixed needs --commit"); log(t, "fix", `Fixed ${f.id}: ${f.text}`, f.commit); } else if (arg === "rejected") { f.status = "rejected"; f.reason = a.one("reason") ?? fail("rejected needs --reason"); log(t, "review", `Rejected ${f.id}: ${f.reason}`); } else fail("finding <id> takes fixed or rejected"); f.at = now(); } save(dir, t); }); command("verdict", "verdict <KEY> <verdict> [--recommendation R] [--head SHA] record a pre-mr-review run", a => { const [key, ...words] = a.pos; const { dir, t } = load(key); const verdict = words.join(" ") || fail("verdict needs the verdict text"); t.review.preMr = { verdict, recommendation: a.one("recommendation"), head: a.one("head"), runs: (t.review.preMr?.runs ?? 0) + 1, at: now() }; log(t, "verdict", `pre-mr-review: ${verdict}`); save(dir, t); }); command("jira", "jira <KEY> <field name> record a Jira field as filled", a => { const [key, ...name] = a.pos; const { dir, t } = load(key); const field = name.join(" ") || fail("jira needs the field name"); t.jiraFields = [...t.jiraFields.filter(f => f.name !== field), { name: field, at: now() }]; log(t, "jira", `Filled ${field} in Jira`); save(dir, t); }); command("mr", "mr <KEY> record that mr.md is written", a => { const { dir, t } = load(a.pos[0]); t.mr = { at: now() }; log(t, "mr", "Wrote mr.md"); save(dir, t); }); command("event", "event <KEY> TEXT [--kind K] [--commit SHA] add a line to the live log", a => { const [key, ...text] = a.pos; const { dir, t } = load(key); log(t, a.one("kind") ?? "note", text.join(" ") || fail("event needs text"), a.one("commit")); save(dir, t); }); command("show", "show <KEY> print where the ticket stands", a => { const { dir, t } = load(a.pos[0]); const p = progress(t)!; console.log(`${t.key} ${storyStatus(t)} criteria ${p.criteria.join("/")} steps ${p.steps.join("/")} proof ${t.proof.length}\n${join(dir, "index.html")}`); for (const s of t.plan?.steps ?? []) console.log(` ${s.status === "done" ? "x" : s.status === "running" ? ">" : " "} ${s.n}. ${s.text}${s.commits.length ? ` ${s.commits.join(" ")}` : ""}`); }); command("path", "path <KEY> print the page path", a => console.log(join(ticketDir(a.pos[0]), "index.html"))); command("open", "open <KEY> open the page in the browser", a => { spawnSync("xdg-open", [join(ticketDir(a.pos[0]), "index.html")], { stdio: "ignore" }); }); command("md", "md <KEY> print the ticket and plan as markdown, for an agent's reading order", a => { const { t } = load(a.pos[0]); const out: string[] = [`# ${t.key}: ${t.title}`, "", `${t.type}, ${t.status}. ${t.jira}`, ""]; for (const [k, v] of t.source.fields) out.push(`- ${k}: ${v}`); for (const s of t.source.sections) out.push("", `## ${s.title}${s.origin === "note" ? " (notes)" : ""}`, "", htmlToText(s.html)); const pl = t.plan; if (pl) { out.push("", "# Plan", "", "## Acceptance criteria"); for (const c of pl.criteria) out.push(`- [${c.status === "done" ? "x" : " "}] ${c.id}: ${c.text} (steps ${c.steps.join(", ")})`); out.push("", "## Open questions", ...(pl.questions.length ? pl.questions.map(q => `- ${htmlToText(q)}`) : ["None"])); out.push("", "## Approach", "", htmlToText(pl.approach)); if (pl.decisions.length) out.push("", "## Decisions", ...pl.decisions.map(([q, c, w]) => `- ${q}: ${c}. ${w}`)); out.push("", "## Steps", ...pl.steps.map(s => `- [${s.status === "done" ? "x" : " "}] ${s.n}. ${s.text}${s.kind !== "code" ? ` (${s.kind})` : ""}${s.commits.length ? ` ${s.commits.join(" ")}` : ""}`)); out.push("", "## Risks", ...pl.risks.map(r => `- ${r.risk}: ${r.handling}${r.fromTicket ? " (from the ticket)" : ""}`)); out.push("", "## Test plan", ...pl.tests.map(x => `- ${x.kind}: ${x.text}${x.fromTicket ? " (from the ticket)" : ""}`)); } if (t.proof.length) out.push("", "# Proof", ...t.proof.map(p => `- ${p.file ?? p.test}: ${p.caption} (proves ${p.proves.join(", ")})`)); console.log(out.join("\n")); }); command("check", "check <KEY> validate the data file", a => { const { t } = load(a.pos[0]); console.log(`${t.key} is valid`); }); // Epic commands work on the epic's own data file. function withEpic(key: string, fn: (e: Epic) => void) { const file = epicFile(key); const e = existsSync(file) ? readData(file, "epic") : fail(`no epic page for ${key}. Run: ticket-page epic ${key} init --title T`); fn(e); writeData(file, "epic", e); refreshSite(dirname(file), "epic", `${e.key}: ${e.title}`); } command("epic", "epic <EPIC> init --title T [--jira URL] | story <KEY> [--title T --hours H --risk R --after K1,K2 --status S] | order <KEY>... | why --file <reasons.txt> | closed <KEY> --title T", a => { const [key, sub, ...rest] = a.pos; if (sub === "init") { const file = epicFile(key); const e: Epic = existsSync(file) ? readData(file, "epic") : { schema: 1, key, title: "", jira: "", why: [], stories: [], closed: [], updatedAt: now() }; e.title = a.one("title") ?? (e.title || fail("epic init needs --title")); e.jira = a.one("jira") ?? (e.jira || `${process.env.JIRA_BASE_URL ?? "https://ksense-tech.atlassian.net"}/browse/${key}`); writeData(file, "epic", e); refreshSite(dirname(file), "epic", `${e.key}: ${e.title}`); return console.log(join(dirname(file), "index.html")); } withEpic(key, e => { if (sub === "story") { const [storyKey] = rest; let s = e.stories.find(x => x.key === storyKey); if (!s) { s = { key: storyKey ?? fail("story needs a key"), title: "", after: [], status: "todo" }; e.stories.push(s); } if (a.has("title")) s.title = a.one("title")!; if (a.has("hours")) s.hours = Number(a.one("hours")); if (a.has("risk")) s.risk = a.one("risk"); if (a.has("after")) s.after = list(a.one("after")); const st = a.one("status"); if (st) s.status = (["todo", "planned", "building", "verifying", "ready", "done"] as const).find(x => x === st) ?? fail("unknown status"); if (!s.title) fail(`${s.key} needs --title`); } else if (sub === "order") { const missing = e.stories.filter(s => !rest.includes(s.key)).map(s => s.key); if (missing.length || rest.length !== e.stories.length) fail(`order must list every story exactly once. Missing: ${missing.join(", ") || "none"}`); e.stories = rest.map(k => e.stories.find(s => s.key === k) ?? fail(`${k} is not in the epic`)); } else if (sub === "why") { e.why = readFileSync(a.one("file") ?? fail("why needs --file"), "utf8").split("\n").map(x => x.trim()).filter(Boolean); } else if (sub === "closed") { const [k] = rest; e.closed = [...e.closed.filter(c => c.key !== k), { key: k ?? fail("closed needs a key"), title: a.one("title") ?? fail("closed needs --title") }]; e.stories = e.stories.filter(s => s.key !== k); } else fail("epic takes init, story, order, why or closed"); }); }); function help() { console.log("ticket-page <command> ...\n"); for (const c of Object.values(commands)) console.log(" " + c.usage); } if (import.meta.main) { const [name, ...rest] = process.argv.slice(2); if (!name || name === "help" || !commands[name]) { help(); process.exit(name && name !== "help" ? 2 : 0); } try { const args = parse(rest); if (name !== "epic" && !/^[A-Z][A-Z0-9]+-\d+$/.test(args.pos[0] ?? "")) fail(`${name} needs a ticket key like KACP-12345 first`); commands[name].run(args); } catch (err) { console.error(`ticket-page ${name}: ${err instanceof Error ? err.message : err}`); process.exit(err instanceof UsageError ? 2 : 1); } }