Add the ticket page data model and its ticket-page CLI

This commit is contained in:
Gabriel Brown
2026-09-22 14:54:05 -04:00
parent 1843c0495a
commit dfc009e422
6 changed files with 955 additions and 422 deletions
@@ -0,0 +1,154 @@
// Turns a fetched Jira issue (jira-fetch-issue.sh output) and the ticket's resources/
// directory into the source half of a Ticket: its metadata, every rich-text field as
// sanitized HTML, and its attachments with transcripts and frames.
import { readdirSync, readFileSync, statSync } from "node:fs";
import { extname, join } from "node:path";
import type { Attachment, Section, Segment, Ticket } from "./ticket-data";
import { now } from "./ticket-data";
type Named = { name?: string; value?: string; displayName?: string };
interface JiraLink { type: { inward: string; outward: string }; inwardIssue?: LinkedIssue; outwardIssue?: LinkedIssue }
interface LinkedIssue { key: string; fields?: { summary?: string } }
/** The parts of jira-fetch-issue.sh's response this importer reads. Every other field
* stays `unknown` and only ever passes through `plain()`. */
export interface JiraIssue {
key: string;
fields: {
summary: string;
issuetype?: Named; status?: Named; priority?: Named; assignee?: Named; reporter?: Named;
timeoriginalestimate?: number | null;
parent?: LinkedIssue;
issuelinks?: JiraLink[];
attachment?: { id: string | number; filename: string }[];
[field: string]: unknown;
};
renderedFields: Record<string, unknown>;
names: Record<string, string>;
}
/** Rich-text fields in the order a reader wants them. Anything populated that matches
* none of these still gets imported, after them, so a field with an unexpected name
* is never dropped. */
const FIELD_ORDER = [/^description$/i, /acceptance criteria/i, /review instructions/i, /test cases|working feature proof/i, /risk mitigation$|^risk mitigation/i];
/** Strips anything executable and rewrites attachment links to the downloaded copies,
* so the page renders Jira's HTML offline without running any of it. */
export function sanitize(html: string, attachments: Map<string, string>): string {
return html
.replace(/<(script|style|iframe|object|embed)\b[\s\S]*?<\/\1\s*>/gi, "")
.replace(/<(script|style|iframe|object|embed|link|meta)\b[^>]*\/?>/gi, "")
.replace(/\son[a-z]+\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, "")
.replace(/(href|src)\s*=\s*(["'])\s*javascript:[^"']*\2/gi, '$1="#"')
.replace(/<img\b[^>]*class="icon"[^>]*>/gi, "")
.replace(/https?:\/\/[^"'\s]+\/(?:rest\/api\/\d\/attachment\/content|secure\/attachment)\/(\d+)(?:\/[^"'\s]*)?/g,
(url, id: string) => attachments.get(id) ?? url)
.replace(/<a\s+(?=[^>]*href="https?:)/gi, '<a target="_blank" rel="noreferrer" ');
}
/** Workflow bookkeeping, matched against both field ids and display names. */
const PLUMBING = /^(created|updated|last ?viewed|status ?category|watches|votes|progress|aggregate|work ?ratio|creator|project|time ?tracking|worklog|comment|subtasks|security|environment|resolution|due ?date|rank|epic link|development|children count|remaining estimate|time spent|sprint|flagged)/i;
const hours = (seconds?: number | null) => (seconds ? `${+(seconds / 3600).toFixed(1)}h` : undefined);
/** A plain value for the details table, or null when the field is empty or plumbing. */
function plain(value: unknown): string | null {
if (value == null || value === "" || (Array.isArray(value) && !value.length)) return null;
if (typeof value === "string" || typeof value === "number") return String(value);
if (Array.isArray(value)) return value.map(plain).filter(Boolean).join(", ") || null;
if (typeof value === "object") { const v = value as Named; return v.displayName ?? v.value ?? v.name ?? null; }
return null;
}
export function importIssue(issue: JiraIssue, baseUrl: string): Pick<Ticket, "key" | "title" | "type" | "status" | "priority" | "estimate" | "assignee" | "epic" | "jira" | "links" | "source"> & { unplaced: string[] } {
const f = issue.fields;
const files = new Map((f.attachment ?? []).map(a => [String(a.id), `resources/${a.filename}`]));
const rich = Object.entries(issue.renderedFields)
.filter((e): e is [string, string] => typeof e[1] === "string" && /<[a-z]/i.test(e[1]))
.map(([id, html]) => ({ id, title: issue.names[id] ?? id, html }));
const rank = (title: string) => { const i = FIELD_ORDER.findIndex(r => r.test(title)); return i < 0 ? FIELD_ORDER.length : i; };
rich.sort((a, b) => rank(a.title) - rank(b.title));
const sections: Section[] = rich.map(r => ({ id: r.id, title: r.title, html: sanitize(r.html, files), origin: "jira" }));
const shown: [string, string | undefined | null][] = [
["Type", f.issuetype?.name], ["Status", f.status?.name], ["Priority", f.priority?.name],
["Assignee", f.assignee?.displayName], ["Reporter", f.reporter?.displayName],
["Estimate", hours(f.timeoriginalestimate)], ["Labels", plain(f.labels)], ["Components", plain(f.components)],
["Fix versions", plain(f.fixVersions)], ["Story points", plain(f.customfield_10016)],
];
const fields = shown.filter((x): x is [string, string] => !!x[1]);
// Populated plain fields the page does not show, named for the agent's sweep.
const known = new Set(["summary", "issuetype", "status", "priority", "assignee", "reporter", "timeoriginalestimate", "labels",
"components", "fixVersions", "customfield_10016", "parent", "issuelinks", "attachment", "description", ...rich.map(r => r.id)]);
const unplaced = Object.entries(f)
.filter(([k, v]) => !known.has(k) && plain(v) && !PLUMBING.test(k) && !PLUMBING.test(issue.names[k] ?? ""))
.map(([k, v]) => `${issue.names[k] ?? k}: ${String(plain(v)).slice(0, 80)}`);
const links = (f.issuelinks ?? []).flatMap(l => {
const other = l.outwardIssue ?? l.inwardIssue;
return other ? [{ type: l.outwardIssue ? l.type.outward : l.type.inward, key: other.key, title: other.fields?.summary ?? "" }] : [];
});
return {
key: issue.key,
title: f.summary,
type: f.issuetype?.name ?? "Issue",
status: f.status?.name ?? "",
priority: f.priority?.name ?? "",
estimate: hours(f.timeoriginalestimate),
assignee: f.assignee?.displayName,
epic: f.parent ? { key: f.parent.key, title: f.parent.fields?.summary ?? "" } : null,
jira: `${baseUrl.replace(/\/$/, "")}/browse/${issue.key}`,
links,
source: { importedAt: now(), fields, sections },
unplaced,
};
}
export function parseSrt(srt: string): Segment[] {
const secs = (t: string) => { const [h, m, s] = t.replace(",", ".").split(":"); return Math.round((+h * 3600 + +m * 60 + +s) * 10) / 10; };
return srt.trim().split(/\r?\n\s*\r?\n/).flatMap(block => {
const lines = block.trim().split(/\r?\n/);
const time = lines.find(l => l.includes("-->"));
if (!time) return [];
const text = lines.slice(lines.indexOf(time) + 1).join(" ").trim();
return text.length > 1 ? [{ t: secs(time.split("-->")[0].trim()), text }] : [];
});
}
const KIND: Record<string, Attachment["kind"]> = {
".png": "image", ".jpg": "image", ".jpeg": "image", ".gif": "image", ".webp": "image", ".svg": "image",
".mp4": "video", ".webm": "video", ".mov": "video", ".mkv": "video",
".mp3": "audio", ".m4a": "audio", ".wav": "audio", ".ogg": "audio",
};
/** Every file in resources/, with each video's transcript and frames attached to it. */
export function scanResources(ticketDir: string): Attachment[] {
const dir = join(ticketDir, "resources");
let names: string[];
try { names = readdirSync(dir); } catch { return []; }
const skip = (n: string) => n === "issue.raw.json" || /\.transcript\.(txt|srt|vtt)$/.test(n) || n.endsWith(".frames") || n.startsWith(".");
return names.filter(n => !skip(n) && statSync(join(dir, n)).isFile()).sort().map(name => {
const kind = KIND[extname(name).toLowerCase()] ?? "file";
const base = name.slice(0, -extname(name).length);
const att: Attachment = { name, path: `resources/${name}`, kind, bytes: statSync(join(dir, name)).size };
if (kind === "video" || kind === "audio") {
try { att.transcript = parseSrt(readFileSync(join(dir, `${base}.transcript.srt`), "utf8")); } catch {}
try { att.frames = readdirSync(join(dir, `${base}.frames`)).filter(n => /\.(png|jpe?g)$/i.test(n)).sort().map(n => `resources/${base}.frames/${n}`); } catch {}
}
return att;
});
}
/** Readable text from Jira-ish HTML, for the markdown export an agent reads. */
export function htmlToText(html: string): string {
return html
.replace(/<(br|\/p|\/div|\/h\d|\/li|\/tr)\b[^>]*>/gi, "\n")
.replace(/<\/t[dh]>/gi, " | ")
.replace(/<li\b[^>]*>/gi, "- ")
.replace(/<[^>]+>/g, "")
.replace(/&nbsp;/g, " ").replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&#39;/g, "'")
.replace(/[ \t]+\n/g, "\n").replace(/\n{3,}/g, "\n\n").trim();
}
@@ -0,0 +1,243 @@
// The ticket page's data model: one `ticket.data.js` per ticket and one `epic.data.js`
// per epic, each a single `window.X = <strict JSON>;` assignment so a page opened from
// file:// can load it with a <script> tag. Everything that reads or writes those files
// goes through this module, so the shape is defined once and validated on every write.
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
export type StepStatus = "todo" | "running" | "done";
export type StepKind = "code" | "mock" | "deliverable";
export interface Section {
id: string;
title: string;
html: string;
/** "jira" is imported verbatim from the ticket; "note" is written by the agent. */
origin: "jira" | "note";
}
export interface Segment { t: number; text: string }
export interface Attachment {
name: string;
/** Relative to the ticket directory. */
path: string;
kind: "image" | "video" | "audio" | "file";
bytes: number;
transcript?: Segment[];
frames?: string[];
}
export interface Criterion {
id: string;
text: string;
steps: number[];
status: "todo" | "done";
doneAt?: string;
evidence?: string;
}
export interface Step {
n: number;
text: string;
kind: StepKind;
tdd?: boolean;
status: StepStatus;
commits: string[];
doneAt?: string;
}
export interface Risk { risk: string; handling: string; criteria?: string[]; fromTicket?: boolean }
export interface Test { text: string; kind: string; criteria?: string[]; fromTicket?: boolean }
/** A design artboard drawn at plan time. `criteria` names what it illustrates, so the
* Progress tab can show it beside the proof for those criteria. */
export interface Mock { id: string; title: string; note: string; html: string; criteria: string[] }
export interface Plan {
writtenAt: string;
approvedAt?: string;
criteria: Criterion[];
/** HTML fragments. Empty means none. */
questions: string[];
/** HTML. */
approach: string;
decisions: [question: string, choice: string, why: string][];
steps: Step[];
risks: Risk[];
tests: Test[];
mocks: Mock[];
}
export interface Proof {
kind: "image" | "text" | "test";
/** Relative to the ticket directory, for image and text proof. */
file?: string;
/** Embedded content of a text proof, because file:// pages cannot fetch it. */
text?: string;
/** The passing test's name, for test proof. */
test?: string;
proves: string[];
caption: string;
mock?: string;
at: string;
}
export interface Finding {
id: string;
severity: "CONFIRMED" | "PLAUSIBLE";
text: string;
where?: string;
status: "open" | "fixed" | "rejected";
commit?: string;
reason?: string;
at: string;
}
export interface Event { at: string; kind: string; text: string; commit?: string }
export interface Ticket {
schema: 1;
key: string;
title: string;
type: string;
status: string;
priority: string;
estimate?: string;
assignee?: string;
epic: { key: string; title: string } | null;
jira: string;
branch?: string;
classified?: "bounded" | "architectural";
links: { type: string; key: string; title: string }[];
source: { importedAt: string; fields: [string, string][]; sections: Section[] };
attachments: Attachment[];
plan: Plan | null;
proof: Proof[];
review: {
findings: Finding[];
preMr: { verdict: string; recommendation?: string; head?: string; runs: number; at: string } | null;
};
jiraFields: { name: string; at: string }[];
mr: { at: string } | null;
events: Event[];
updatedAt: string;
}
export type StoryStatus = "todo" | "planned" | "building" | "verifying" | "ready" | "done";
export interface Story {
key: string;
title: string;
hours?: number;
risk?: string;
after: string[];
status: StoryStatus;
/** Set once a ticket page exists for the story, so the index can link to it. */
page?: boolean;
progress?: { criteria: [number, number]; steps: [number, number] };
}
export interface Epic {
schema: 1;
key: string;
title: string;
jira: string;
/** Why the build order is what it is, one reason per entry. */
why: string[];
/** In build order. */
stories: Story[];
closed: { key: string; title: string }[];
updatedAt: string;
}
export const now = () => new Date().toISOString();
const GLOBAL = { ticket: "TICKET", epic: "EPIC" } as const;
export function readData(file: string, kind: "ticket"): Ticket;
export function readData(file: string, kind: "epic"): Epic;
export function readData(file: string, kind: keyof typeof GLOBAL): Ticket | Epic {
const raw = readFileSync(file, "utf8");
const prefix = `window.${GLOBAL[kind]} = `;
if (!raw.startsWith(prefix)) throw new Error(`${file} does not start with ${prefix.trim()}`);
return JSON.parse(raw.slice(prefix.length).trim().replace(/;$/, ""));
}
/** Validates, then writes through a temp file and a rename so a page reloading
* the file mid-write never sees half of it. */
export function writeData(file: string, kind: "ticket", data: Ticket): void;
export function writeData(file: string, kind: "epic", data: Epic): void;
export function writeData(file: string, kind: keyof typeof GLOBAL, data: Ticket | Epic) {
const problems = kind === "ticket" ? validateTicket(data as Ticket) : validateEpic(data as Epic);
if (problems.length) throw new Error(`refusing to write ${file}:\n - ${problems.join("\n - ")}`);
data.updatedAt = now();
mkdirSync(dirname(file), { recursive: true });
const tmp = join(dirname(file), `.${Date.now()}.${process.pid}.tmp`);
writeFileSync(tmp, `window.${GLOBAL[kind]} = ${JSON.stringify(data, null, 1)};\n`);
renameSync(tmp, file);
}
const isStr = (x: unknown): x is string => typeof x === "string";
export function validateTicket(t: Ticket): string[] {
const p: string[] = [];
if (t.schema !== 1) p.push("schema must be 1");
for (const k of ["key", "title", "type", "status", "jira"] as const) if (!isStr(t[k]) || !t[k]) p.push(`${k} is required`);
if (!Array.isArray(t.events)) p.push("events must be a list");
if (!t.plan) return p;
const plan = t.plan;
const ids = new Set<string>();
plan.criteria.forEach((c, i) => {
if (!c.id || !c.text) p.push(`criterion ${i + 1} needs an id and text`);
if (ids.has(c.id)) p.push(`criterion id ${c.id} is used twice`);
ids.add(c.id);
for (const n of c.steps) if (!plan.steps.some(s => s.n === n)) p.push(`${c.id} names step ${n}, which does not exist`);
});
plan.steps.forEach((s, i) => {
if (s.n !== i + 1) p.push(`steps must be numbered 1..${plan.steps.length} in order, found ${s.n} at position ${i + 1}`);
if (!["code", "mock", "deliverable"].includes(s.kind)) p.push(`step ${s.n} kind must be code, mock or deliverable`);
if (!["todo", "running", "done"].includes(s.status)) p.push(`step ${s.n} status must be todo, running or done`);
});
const mockIds = new Set(plan.mocks.map(m => m.id));
for (const m of plan.mocks) for (const c of m.criteria) if (!ids.has(c)) p.push(`mock ${m.id} names unknown criterion ${c}`);
for (const r of plan.risks) for (const c of r.criteria ?? []) if (!ids.has(c)) p.push(`risk "${r.risk}" names unknown criterion ${c}`);
for (const x of plan.tests) for (const c of x.criteria ?? []) if (!ids.has(c)) p.push(`test "${x.text}" names unknown criterion ${c}`);
for (const pr of t.proof) {
for (const c of pr.proves) if (!ids.has(c)) p.push(`proof ${pr.file ?? pr.test} names unknown criterion ${c}`);
if (pr.mock && !mockIds.has(pr.mock)) p.push(`proof ${pr.file} names unknown mock ${pr.mock}`);
}
return p;
}
export function validateEpic(e: Epic): string[] {
const p: string[] = [];
if (e.schema !== 1) p.push("schema must be 1");
if (!e.key || !e.title) p.push("key and title are required");
const keys = new Set<string>();
for (const s of e.stories) {
if (keys.has(s.key)) p.push(`${s.key} appears twice in the order`);
keys.add(s.key);
}
for (const s of e.stories) for (const a of s.after) if (!keys.has(a)) p.push(`${s.key} waits on ${a}, which is not in the epic`);
return p;
}
/** Where a ticket stands, derived from its data rather than stored, so the page,
* the epic index and `show` can never disagree. */
export function storyStatus(t: Ticket): StoryStatus {
if (!t.plan) return "todo";
if (t.review.preMr?.verdict === "Ready to Open MR") return "ready";
if (!t.plan.approvedAt) return "planned";
return t.plan.steps.every(s => s.status === "done") ? "verifying" : "building";
}
export function progress(t: Ticket): Story["progress"] {
const pl = t.plan;
if (!pl) return { criteria: [0, 0], steps: [0, 0] };
return {
criteria: [pl.criteria.filter(c => c.status === "done").length, pl.criteria.length],
steps: [pl.steps.filter(s => s.status === "done").length, pl.steps.length],
};
}
+6
View File
@@ -0,0 +1,6 @@
#!/usr/bin/env bash
# Runs the ticket page CLI under bun. See ../SITE.md, or: ticket-page help
set -euo pipefail
here="$(dirname "$(readlink -f "$0")")"
command -v bun >/dev/null 2>&1 || { echo "ticket-page: bun is not on PATH" >&2; exit 127; }
exec bun "$here/ticket-page.ts" "$@"
@@ -0,0 +1,116 @@
// Run with: bun test (from this directory)
import { describe, expect, test } from "bun:test";
import { spawnSync } from "node:child_process";
import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { importIssue, type JiraIssue, parseSrt, sanitize } from "./jira-import";
import { readData, type Ticket, writeData } from "./ticket-data";
const issue = (over: Partial<JiraIssue["fields"]> = {}, rendered: Record<string, string> = {}): JiraIssue => ({
key: "KACP-1",
fields: { summary: "Add canonical IDs", issuetype: { name: "Story" }, status: { name: "To Do" }, priority: { name: "Medium" },
parent: { key: "KACP-9", fields: { summary: "Engagement epic" } }, attachment: [{ id: "42830", filename: "shot.png" }], ...over },
renderedFields: { description: "<p>Do the thing</p>", ...rendered },
names: { description: "Description", customfield_1: "Risk Mitigation", customfield_2: "Some Oddly Named Field", customfield_3: "Developer Review Instructions" },
});
describe("sanitize", () => {
test("drops anything executable and points attachments at the downloaded copy", () => {
const out = sanitize(
`<p onclick="x()">hi</p><script>alert(1)</script><a href="javascript:evil()">x</a>` +
`<img src="https://ksense-tech.atlassian.net/rest/api/3/attachment/content/42830" alt="shot.png">`,
new Map([["42830", "resources/shot.png"]]));
expect(out).not.toMatch(/onclick|<script|javascript:/i);
expect(out).toContain('src="resources/shot.png"');
});
});
describe("importIssue", () => {
test("keeps every rich field, reader order first and unexpected names after", () => {
const t = importIssue(issue({}, { customfield_2: "<p>odd</p>", customfield_1: "<table><tr><td>r</td></tr></table>", customfield_3: "<p>dev</p>" }), "https://j");
expect(t.source.sections.map(s => s.title)).toEqual(["Description", "Developer Review Instructions", "Risk Mitigation", "Some Oddly Named Field"]);
expect(t.epic).toEqual({ key: "KACP-9", title: "Engagement epic" });
});
});
test("parseSrt skips cues with no words", () => {
const segs = parseSrt("1\n00:00:01,500 --> 00:00:03,000\n Hello there\n\n2\n00:00:03,000 --> 00:00:04,000\n I\n\n3\n00:01:02,000 --> 00:01:05,000\nBye");
expect(segs).toEqual([{ t: 1.5, text: "Hello there" }, { t: 62, text: "Bye" }]);
});
test("writeData refuses a plan that names a missing step and leaves the file as it was", () => {
const dir = mkdtempSync(join(tmpdir(), "tp-"));
const file = join(dir, "ticket.data.js");
const base = { schema: 1, key: "KACP-1", title: "t", type: "Story", status: "To Do", priority: "", epic: null, jira: "j", links: [],
source: { importedAt: "", fields: [], sections: [] }, attachments: [], plan: null, proof: [], review: { findings: [], preMr: null },
jiraFields: [], mr: null, events: [], updatedAt: "" } satisfies Ticket;
writeData(file, "ticket", base);
const before = readFileSync(file, "utf8");
const broken: Ticket = { ...base, plan: { writtenAt: "", criteria: [{ id: "AC1", text: "x", steps: [2], status: "todo" }], questions: [], approach: "",
decisions: [], steps: [{ n: 1, text: "s", kind: "code", status: "todo", commits: [] }], risks: [], tests: [], mocks: [] } };
expect(() => writeData(file, "ticket", broken)).toThrow(/AC1 names step 2/);
expect(readFileSync(file, "utf8")).toBe(before);
});
describe("the CLI, end to end", () => {
const docs = mkdtempSync(join(tmpdir(), "tp-docs-"));
const cli = (...args: string[]) => spawnSync("bun", [join(import.meta.dir, "ticket-page.ts"), ...args], { encoding: "utf8", env: { ...process.env, TICKET_DOCS: docs } });
const scratch = mkdtempSync(join(tmpdir(), "tp-in-"));
const issuePath = join(scratch, "issue.json");
writeFileSync(issuePath, JSON.stringify(issue()));
const dir = join(docs, "KACP-9", "KACP-1");
const ticket = () => readData(join(dir, "ticket.data.js"), "ticket");
const epic = () => readData(join(docs, "KACP-9", "epic.data.js"), "epic");
test("init files the ticket under its epic and writes both pages", () => {
expect(cli("init", "KACP-1", "--issue", issuePath).status).toBe(0);
for (const f of ["index.html", "ticket.data.js"]) expect(existsSync(join(dir, f))).toBe(true);
expect(existsSync(join(docs, "KACP-9", "index.html"))).toBe(true);
expect(readFileSync(join(dir, "index.html"), "utf8")).toMatch(/href="..\/..\/_site\/site.css\?v=[0-9a-f]{10}"/);
expect(epic().stories.map(s => [s.key, s.status])).toEqual([["KACP-1", "todo"]]);
});
test("a step is done only once its commit exists, and the epic row follows", () => {
const plan = join(scratch, "plan.json");
writeFileSync(plan, JSON.stringify({ approach: "<p>a</p>", criteria: [{ id: "AC1", text: "c", steps: [1] }], steps: [{ n: 1, text: "s" }, { n: 2, text: "t" }] }));
expect(cli("plan", "KACP-1", "--file", plan).status).toBe(0);
expect(epic().stories[0].status).toBe("planned");
cli("approve", "KACP-1");
const refused = cli("step", "KACP-1", "1", "done");
expect(refused.status).toBe(2);
expect(refused.stderr).toMatch(/--commit/);
expect(cli("step", "KACP-1", "1", "done", "--commit", "abc1234").status).toBe(0);
expect(ticket().plan!.steps[0]).toMatchObject({ status: "done", commits: ["abc1234"] });
expect(epic().stories[0]).toMatchObject({ status: "building", progress: { steps: [1, 2] } });
});
test("text proof is embedded, because a file:// page cannot fetch it", () => {
mkdirSync(join(dir, "proof"), { recursive: true });
writeFileSync(join(dir, "proof", "counts.txt"), "70 of 70 codes stored");
expect(cli("proof", "KACP-1", "--file", "proof/counts.txt", "--proves", "AC1", "--caption", "Counts").status).toBe(0);
expect(ticket().proof[0]).toMatchObject({ kind: "text", text: "70 of 70 codes stored" });
expect(cli("proof", "KACP-1", "--file", "proof/counts.txt", "--proves", "AC7", "--caption", "x").status).toBe(1);
});
test("--amend keeps progress for the steps that remain, a rewrite starts over", () => {
const plan = join(scratch, "plan2.json");
const exported = JSON.parse(cli("plan-json", "KACP-1").stdout);
exported.steps.push({ n: 3, text: "a review fix" });
writeFileSync(plan, JSON.stringify(exported));
expect(cli("plan", "KACP-1", "--file", plan, "--amend").status).toBe(0);
expect(ticket().plan!.steps.map(s => s.status)).toEqual(["done", "todo", "todo"]);
expect(ticket().plan!.approvedAt).toBeDefined();
expect(ticket().proof).toHaveLength(1);
expect(cli("plan", "KACP-1", "--file", plan).status).toBe(0);
expect(ticket().plan!.steps.every(s => s.status === "todo")).toBe(true);
expect(ticket().proof).toHaveLength(0);
});
test("re-importing keeps the plan unless --reset", () => {
cli("init", "KACP-1", "--issue", issuePath);
expect(ticket().plan).not.toBeNull();
cli("init", "KACP-1", "--issue", issuePath, "--reset");
expect(ticket().plan).toBeNull();
});
});
@@ -0,0 +1,436 @@
// 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<string, string[]>();
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 <issue.raw.json>`);
}
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 === "<" ? "&lt;" : "&amp;")));
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<typeof parse>;
const commands: Record<string, { usage: string; run: (a: Args) => void }> = {};
const command = (name: string, usage: string, run: (a: Args) => void) => { commands[name] = { usage, run }; };
command("init", "init <KEY> --issue <issue.raw.json> [--reset] import the ticket, keeping any plan unless --reset", a => {
const [key] = a.pos;
const issuePath = a.one("issue") ?? fail("init needs --issue <issue.raw.json>");
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 <KEY> 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 <KEY> [--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 <KEY> <title> --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);
}
}
@@ -1,422 +0,0 @@
<!doctype html>
<!--
plan.html template for the ticket skill. Copy this file next to plan.md and fill
every slot marked "slot:". Everything outside a slot stays as it is, so every plan
page looks and behaves the same. The page is self-contained: no network, no
external assets, opens from file://.
Slots, in order:
KEY, TITLE, JIRA_URL, BRANCH, KIND the header line
criteria one <li> per acceptance criterion
questions one <li> per open question, or the "None" line
approach the Approach narrative as <p> blocks
steps one <li> per step, with a badge for (mock) / (deliverable)
risks one <li> per risk or edge case
tests one <li> per test plan item
mocks one <template> per artboard, plus its tab button;
delete the whole <section id="mocks"> and its
nav link when the ticket changes nothing a user sees
-->
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<!-- slot: KEY and TITLE -->
<title>KACP-00000 plan</title>
<style>
:root {
--bg: #f7f7f5;
--surface: #ffffff;
--ink: #1c1c1a;
--muted: #6b6b66;
--line: #e2e2dd;
--accent: #2f5fd0;
--accent-ink: #ffffff;
--warn-bg: #fff6e0;
--warn-line: #e6c36b;
--badge-mock: #e8f0ff;
--badge-deliverable: #eaf7ea;
--mono: ui-monospace, "JetBrains Mono", "Cascadia Mono", Menlo, monospace;
--sans: system-ui, -apple-system, "Segoe UI", Roboto, "Inter", sans-serif;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg: #151614;
--surface: #1e1f1c;
--ink: #ecebe6;
--muted: #9b9a93;
--line: #2e2f2b;
--accent: #7c9cf0;
--accent-ink: #0f1420;
--warn-bg: #2b2513;
--warn-line: #7a6427;
--badge-mock: #1f2a44;
--badge-deliverable: #1d2f1f;
}
}
:root[data-theme="dark"] {
--bg: #151614;
--surface: #1e1f1c;
--ink: #ecebe6;
--muted: #9b9a93;
--line: #2e2f2b;
--accent: #7c9cf0;
--accent-ink: #0f1420;
--warn-bg: #2b2513;
--warn-line: #7a6427;
--badge-mock: #1f2a44;
--badge-deliverable: #1d2f1f;
}
* { box-sizing: border-box; }
html { scroll-behavior: smooth; }
body {
margin: 0;
background: var(--bg);
color: var(--ink);
font: 16px/1.55 var(--sans);
}
a { color: var(--accent); }
code { font-family: var(--mono); font-size: 0.92em; }
pre {
font-family: var(--mono);
font-size: 0.88em;
background: var(--surface);
border: 1px solid var(--line);
border-radius: 6px;
padding: 12px 14px;
overflow-x: auto;
}
.layout {
display: grid;
grid-template-columns: 220px minmax(0, 1fr);
gap: 32px;
max-width: 1180px;
margin: 0 auto;
padding: 24px 16px 64px;
}
nav {
position: sticky;
top: 24px;
align-self: start;
font-size: 0.92em;
}
nav ol { list-style: none; margin: 0; padding: 0; }
nav li { margin: 0 0 6px; }
nav a { color: var(--muted); text-decoration: none; }
nav a:hover, nav a.active { color: var(--ink); }
nav .progress {
margin-top: 18px;
padding-top: 14px;
border-top: 1px solid var(--line);
color: var(--muted);
font-size: 0.9em;
}
header.plan { margin-bottom: 28px; }
header.plan h1 { margin: 0 0 6px; font-size: 1.7em; line-height: 1.2; }
header.plan .meta {
display: flex;
flex-wrap: wrap;
gap: 6px 18px;
color: var(--muted);
font-size: 0.92em;
}
header.plan .meta code { color: var(--ink); }
section { margin-bottom: 36px; }
section h2 {
font-size: 1.15em;
margin: 0 0 12px;
padding-bottom: 6px;
border-bottom: 1px solid var(--line);
}
section p { margin: 0 0 12px; max-width: 72ch; }
section ul { padding-left: 22px; margin: 0; }
section li { margin: 0 0 8px; max-width: 72ch; }
.checklist { list-style: none; padding: 0; }
.checklist li { display: flex; gap: 10px; align-items: baseline; }
.checklist input { margin: 0; flex: none; position: relative; top: 2px; }
.checklist li.done label { color: var(--muted); text-decoration: line-through; }
.badge {
display: inline-block;
font-size: 0.75em;
font-weight: 600;
letter-spacing: 0.02em;
text-transform: uppercase;
padding: 1px 7px;
border-radius: 999px;
margin-left: 6px;
vertical-align: middle;
}
.badge.mock { background: var(--badge-mock); }
.badge.deliverable { background: var(--badge-deliverable); }
.callout {
background: var(--warn-bg);
border: 1px solid var(--warn-line);
border-radius: 6px;
padding: 12px 16px;
}
.callout ul { margin: 0; }
.callout.quiet { background: var(--surface); border-color: var(--line); color: var(--muted); }
/* Mocks: one artboard per <template>, shown in an iframe so the mock's CSS stays its own. */
.mock-bar {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
margin-bottom: 12px;
}
.mock-bar .spacer { flex: 1; }
.mock-bar button {
font: inherit;
font-size: 0.9em;
color: var(--ink);
background: var(--surface);
border: 1px solid var(--line);
border-radius: 6px;
padding: 5px 12px;
cursor: pointer;
}
.mock-bar button[aria-pressed="true"] {
background: var(--accent);
color: var(--accent-ink);
border-color: var(--accent);
}
.mock-stage {
background: var(--surface);
border: 1px solid var(--line);
border-radius: 8px;
padding: 16px;
overflow-x: auto;
}
.mock-stage iframe {
display: block;
width: 100%;
max-width: 100%;
height: 480px;
margin: 0 auto;
border: 1px solid var(--line);
border-radius: 6px;
background: #fff;
}
.mock-stage[data-width="tablet"] iframe { width: 820px; }
.mock-stage[data-width="phone"] iframe { width: 390px; }
.mock-note { color: var(--muted); font-size: 0.92em; margin-top: 10px; }
.mock-note:empty { display: none; }
@media (max-width: 820px) {
.layout { grid-template-columns: 1fr; gap: 16px; }
nav { position: static; }
nav ol { display: flex; flex-wrap: wrap; gap: 4px 14px; }
nav .progress { display: none; }
.mock-stage[data-width="tablet"] iframe,
.mock-stage[data-width="phone"] iframe { width: 100%; }
}
</style>
</head>
<body>
<div class="layout">
<nav aria-label="Sections">
<ol>
<li><a href="#criteria">Acceptance criteria</a></li>
<li><a href="#questions">Open questions</a></li>
<li><a href="#approach">Approach</a></li>
<!-- slot: mocks nav link, delete with the section when there are no mocks -->
<li><a href="#mocks">Mocks</a></li>
<li><a href="#steps">Steps</a></li>
<li><a href="#risks">Risks and edge cases</a></li>
<li><a href="#tests">Test plan</a></li>
</ol>
<div class="progress" id="progress"></div>
</nav>
<main>
<header class="plan">
<!-- slot: KEY, TITLE, JIRA_URL, BRANCH, KIND (bounded or architectural) -->
<h1>KACP-00000: Short title of the ticket</h1>
<div class="meta">
<span><a href="https://ksense-tech.atlassian.net/browse/KACP-00000">Open in Jira</a></span>
<span>Branch <code>KACP-00000-Short-Title</code></span>
<span>Classified <code>bounded</code></span>
</div>
</header>
<section id="criteria">
<h2>Acceptance criteria</h2>
<p class="mock-note">Tick a box as you confirm the plan covers it. Ticks stay in this browser only.</p>
<ul class="checklist" data-persist="criteria">
<!-- slot: criteria, one <li> per criterion, restated precisely from the ticket -->
<li><input type="checkbox" id="ac-1"><label for="ac-1">First acceptance criterion</label></li>
</ul>
</section>
<section id="questions">
<h2>Open questions</h2>
<!-- slot: questions. Use class="callout" with one <li> per question that needs an answer
before or during implementation. With none, keep the quiet version below instead. -->
<div class="callout">
<ul>
<li>A question whose answer changes the plan. Name the options and which one the plan assumes.</li>
</ul>
</div>
<!-- <div class="callout quiet">None. Nothing here needs an answer before implementation.</div> -->
</section>
<section id="approach">
<h2>Approach</h2>
<!-- slot: approach, the plan.md narrative as <p> blocks; <code> for file and symbol names -->
<p>What will change, why, which files and modules are involved, and how it fits the patterns already in the codebase.</p>
</section>
<!-- slot: mocks. Keep this section only when a step changes what a user sees.
One <template> per artboard: a <style> block followed by the screen's markup, rendered
in its own iframe so nothing leaks between the mock and this page.
Several artboards when the direction is the user's call, one when it is settled.
The data-note attribute states what the artboard shows that the implementation will not do. -->
<section id="mocks">
<h2>Mocks</h2>
<div class="mock-bar" role="tablist">
<button type="button" data-mock="mock-a" aria-pressed="true">Option A</button>
<button type="button" data-mock="mock-b" aria-pressed="false">Option B</button>
<span class="spacer"></span>
<button type="button" data-width="desktop" aria-pressed="true">Desktop</button>
<button type="button" data-width="tablet" aria-pressed="false">Tablet</button>
<button type="button" data-width="phone" aria-pressed="false">Phone</button>
</div>
<div class="mock-stage" data-width="desktop">
<iframe title="Mock" sandbox="allow-same-origin"></iframe>
<div class="mock-note"></div>
</div>
<template id="mock-a" data-note="Option A keeps the existing layout and adds the new column at the end.">
<style>
body { margin: 0; padding: 24px; font: 14px/1.5 system-ui, sans-serif; color: #1c1c1a; background: #fff; }
</style>
<h1 style="font-size:18px;margin:0 0 12px">Screen name</h1>
<p>Draw the intended screen here with plain HTML and its own style block.</p>
</template>
<template id="mock-b" data-note="Option B moves the action into the row menu.">
<style>
body { margin: 0; padding: 24px; font: 14px/1.5 system-ui, sans-serif; color: #1c1c1a; background: #fff; }
</style>
<h1 style="font-size:18px;margin:0 0 12px">Screen name</h1>
<p>Second option.</p>
</template>
</section>
<section id="steps">
<h2>Steps</h2>
<ul class="checklist" data-persist="steps">
<!-- slot: steps, one <li> per step, roughly one commit each.
Add <span class="badge mock">mock</span> or <span class="badge deliverable">deliverable</span>
after the label text for steps marked that way in plan.md. -->
<li><input type="checkbox" id="step-1"><label for="step-1">First step</label></li>
<li><input type="checkbox" id="step-2"><label for="step-2">A step that builds a mock</label><span class="badge mock">mock</span></li>
</ul>
</section>
<section id="risks">
<h2>Risks and edge cases considered</h2>
<ul>
<!-- slot: risks, one <li> per edge case: the case, then how the plan handles it -->
<li><strong>Edge case.</strong> How the plan handles it.</li>
</ul>
</section>
<section id="tests">
<h2>Test plan</h2>
<ul>
<!-- slot: tests, one <li> per item: which criterion or ticket test case it proves and how -->
<li><strong>Criterion or ticket case.</strong> The suite to run or the test to add, and what it proves.</li>
</ul>
</section>
</main>
</div>
<script>
(() => {
const key = location.pathname;
// Checkbox ticks persist per page in this browser. Storage can be unavailable on file://
// in some browsers, so every access is guarded.
const store = {
get(name) { try { return JSON.parse(localStorage.getItem(key + ':' + name) || '[]'); } catch { return []; } },
set(name, ids) { try { localStorage.setItem(key + ':' + name, JSON.stringify(ids)); } catch {} },
};
const lists = [...document.querySelectorAll('.checklist[data-persist]')];
const progress = document.getElementById('progress');
const paint = () => {
const boxes = lists.flatMap(l => [...l.querySelectorAll('input[type="checkbox"]')]);
boxes.forEach(b => b.closest('li').classList.toggle('done', b.checked));
if (progress) progress.textContent = boxes.filter(b => b.checked).length + ' of ' + boxes.length + ' reviewed';
};
lists.forEach(list => {
const name = list.dataset.persist;
const saved = new Set(store.get(name));
list.querySelectorAll('input[type="checkbox"]').forEach(box => {
box.checked = saved.has(box.id);
box.addEventListener('change', () => {
const ids = [...list.querySelectorAll('input:checked')].map(b => b.id);
store.set(name, ids);
paint();
});
});
});
paint();
// Mock tabs and width toggle. Each <template> is a full HTML document loaded into the iframe.
const mocks = document.getElementById('mocks');
if (mocks) {
const frame = mocks.querySelector('iframe');
const stage = mocks.querySelector('.mock-stage');
const note = mocks.querySelector('.mock-note');
const press = (buttons, active) => buttons.forEach(b => b.setAttribute('aria-pressed', String(b === active)));
const mockButtons = [...mocks.querySelectorAll('button[data-mock]')];
const widthButtons = [...mocks.querySelectorAll('button[data-width]')];
const fit = () => {
try {
const doc = frame.contentDocument;
if (doc && doc.documentElement) frame.style.height = Math.max(320, doc.documentElement.scrollHeight + 2) + 'px';
} catch {}
};
const show = id => {
const tpl = document.getElementById(id);
if (!tpl) return;
frame.srcdoc = tpl.innerHTML;
note.textContent = tpl.dataset.note || '';
press(mockButtons, mockButtons.find(b => b.dataset.mock === id));
};
frame.addEventListener('load', fit);
mockButtons.forEach(b => b.addEventListener('click', () => show(b.dataset.mock)));
widthButtons.forEach(b => b.addEventListener('click', () => {
stage.dataset.width = b.dataset.width;
press(widthButtons, b);
setTimeout(fit, 50);
}));
if (mockButtons[0]) show(mockButtons[0].dataset.mock);
}
// Highlight the section in view in the nav.
const links = [...document.querySelectorAll('nav a[href^="#"]')];
const byId = new Map(links.map(a => [a.getAttribute('href').slice(1), a]));
const observer = new IntersectionObserver(entries => {
entries.forEach(e => {
const a = byId.get(e.target.id);
if (a && e.isIntersecting) { links.forEach(l => l.classList.remove('active')); a.classList.add('active'); }
});
}, { rootMargin: '-10% 0px -70% 0px' });
byId.forEach((_, id) => { const el = document.getElementById(id); if (el) observer.observe(el); });
})();
</script>
</body>
</html>