Run the ticket skills through T3 Code, with a review council before pre-mr-review
Agents now start pre-mr-review on the work account themselves, through a Klaude delegate, and continue from its verdict. A council of models on my own accounts replaces the single reviewer before it, and adding a seat is one table row. Proof comes from T3's browser, with mp4 recordings, and capture-proof stays as the fallback. Ticket pages carry their own data, so index.html renders alone in T3's file viewer, and worktree threads share the main checkout's docs. A PreToolUse hook sends subagents in T3 threads to delegate_task. Blocker, proof, review and MR summary rules come from ideas in t3dotgg/tstack.
This commit is contained in:
1 parent
968e0a1045
commit
86f6e391b7
14 files changed
+410
-187
No files matched your search
@@ -1,6 +1,6 @@
|
||||
// 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
|
||||
// per epic, each a single `window.X = <strict JSON>;` assignment so an open page can poll
|
||||
// it with a <script> tag, where file:// and T3 Code's sandboxed viewer cannot fetch. 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";
|
||||
@@ -70,7 +70,7 @@ export interface Plan {
|
||||
}
|
||||
|
||||
export interface Proof {
|
||||
kind: "image" | "text" | "test";
|
||||
kind: "image" | "video" | "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. */
|
||||
|
||||
@@ -70,10 +70,32 @@ describe("the CLI, end to end", () => {
|
||||
// Self-contained: T3 Code's file viewer serves nothing from above the page's own folder.
|
||||
const page = readFileSync(join(dir, "index.html"), "utf8");
|
||||
expect(page).toContain(readFileSync(join(import.meta.dir, "../templates/site/site.js"), "utf8"));
|
||||
expect(page).toContain(`"key":"KACP-1"`);
|
||||
expect(page).not.toMatch(/(src|href)="\.\./);
|
||||
expect(epic().stories.map(s => [s.key, s.status])).toEqual([["KACP-1", "todo"]]);
|
||||
});
|
||||
|
||||
test("inline data cannot close the page's script block, and follows every write", () => {
|
||||
cli("event", "KACP-1", "pasted </script><img src=x>");
|
||||
const page = readFileSync(join(dir, "index.html"), "utf8");
|
||||
const inline = page.match(/window\.TICKET = (.*);\n/)![1];
|
||||
expect(inline).not.toContain("</script>");
|
||||
expect(JSON.parse(inline).events.at(-1).text).toBe("pasted </script><img src=x>");
|
||||
});
|
||||
|
||||
test("a linked worktree shares the main checkout's docs", () => {
|
||||
const repo = mkdtempSync(join(tmpdir(), "tp-repo-"));
|
||||
const git = (...a: string[]) => spawnSync("git", a, { cwd: repo, encoding: "utf8" });
|
||||
git("init", "-q");
|
||||
git("-c", "user.name=t", "-c", "user.email=t@t", "commit", "-q", "--allow-empty", "-m", "x");
|
||||
const tree = join(mkdtempSync(join(tmpdir(), "tp-tree-")), "wt");
|
||||
git("worktree", "add", "-q", tree);
|
||||
const { TICKET_DOCS: _, ...env } = process.env;
|
||||
expect(spawnSync("bun", [join(import.meta.dir, "ticket-page.ts"), "init", "KACP-1", "--issue", issuePath], { cwd: tree, encoding: "utf8", env }).status).toBe(0);
|
||||
expect(existsSync(join(repo, ".claude/docs/epics/KACP-9/KACP-1/ticket.data.js"))).toBe(true);
|
||||
expect(existsSync(join(tree, ".claude"))).toBe(false);
|
||||
});
|
||||
|
||||
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" }] }));
|
||||
@@ -103,6 +125,10 @@ describe("the CLI, end to end", () => {
|
||||
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);
|
||||
writeFileSync(join(dir, "proof", "flow.mp4"), "");
|
||||
expect(cli("proof", "KACP-1", "--file", "proof/flow.mp4", "--proves", "AC1", "--caption", "Flow").status).toBe(0);
|
||||
expect(ticket().proof[1]).toMatchObject({ kind: "video", file: "proof/flow.mp4" });
|
||||
expect(cli("unproof", "KACP-1", "proof/flow.mp4").status).toBe(0);
|
||||
});
|
||||
|
||||
test("--amend keeps progress for the steps that remain, a rewrite starts over", () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// 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 { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { htmlToText, importIssue, type JiraIssue, scanResources } from "./jira-import";
|
||||
@@ -34,11 +34,13 @@ const list = (s?: string) => (s ? s.split(",").map(x => x.trim()).filter(Boolean
|
||||
|
||||
// ── Where things live ────────────────────────────────────────────────────────
|
||||
|
||||
/** The main checkout's docs, even from a linked worktree. `.claude/docs` is usually gitignored,
|
||||
* so a T3 Code worktree thread would otherwise start a second, disconnected ticket page. */
|
||||
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");
|
||||
const git = spawnSync("git", ["worktree", "list", "--porcelain"], { encoding: "utf8" });
|
||||
const main = git.status === 0 ? git.stdout.match(/^worktree (.+)$/m)?.[1] : undefined;
|
||||
return join(main ?? fail("not inside a git repository, and TICKET_DOCS is not set"), ".claude/docs/epics");
|
||||
}
|
||||
|
||||
function ticketDir(key: string): string {
|
||||
@@ -49,21 +51,28 @@ function ticketDir(key: string): string {
|
||||
const ticketFile = (dir: string) => join(dir, "ticket.data.js");
|
||||
const epicFile = (epicKey: string) => join(docsRoot(), epicKey, "epic.data.js");
|
||||
|
||||
/** Writes the page shell with the renderer's CSS and JS inlined, so the page carries
|
||||
* everything but its own data file. T3 Code's file viewer serves an HTML file's assets only
|
||||
* from its own folder and below, so a shell that pointed at a shared ../../_site/ renderer
|
||||
* rendered blank there. Run on every write so an old ticket picks up the current renderer. */
|
||||
function refreshSite(pageDir: string, kind: "ticket" | "epic", title: string) {
|
||||
/** Writes the page shell with the renderer and the data inlined, so index.html renders with
|
||||
* nothing else: no server and no other file. T3 Code's file viewer sandboxes the page and
|
||||
* serves assets only from its own folder and below, so a shell that leaned on a shared
|
||||
* ../../_site/ renderer rendered blank there. The sibling data file stays the store, and the
|
||||
* open page polls it to stay live. Run on every write, so the page matches the data and an
|
||||
* old ticket picks up the current renderer. */
|
||||
function refreshSite(pageDir: string, kind: "ticket" | "epic", data: Ticket | Epic) {
|
||||
const fill: Record<string, string> = {
|
||||
CSS: readFileSync(join(SITE_TEMPLATES, "site.css"), "utf8"),
|
||||
JS: readFileSync(join(SITE_TEMPLATES, "site.js"), "utf8"),
|
||||
TITLE: title.replace(/[<&]/g, c => (c === "<" ? "<" : "&")),
|
||||
TITLE: `${data.key}: ${data.title}`.replace(/[<&]/g, c => (c === "<" ? "<" : "&")),
|
||||
// Jira HTML and mock artboards hold `</script>`, which would end the inline block early.
|
||||
DATA: `window.${kind.toUpperCase()} = ${JSON.stringify(data).replace(/</g, "\\u003c")};`,
|
||||
};
|
||||
// A replacer function, because the renderer's own source is full of `$` sequences that
|
||||
// a replacement string would read as patterns.
|
||||
const shell = readFileSync(join(SITE_TEMPLATES, `${kind}.html`), "utf8").replace(/\{\{(CSS|JS|TITLE)\}\}/g, (_, k: string) => fill[k]);
|
||||
const shell = readFileSync(join(SITE_TEMPLATES, `${kind}.html`), "utf8").replace(/\{\{(CSS|JS|TITLE|DATA)\}\}/g, (_, k: string) => fill[k]);
|
||||
const page = join(pageDir, "index.html");
|
||||
if (!existsSync(page) || readFileSync(page, "utf8") !== shell) writeFileSync(page, shell);
|
||||
if (existsSync(page) && readFileSync(page, "utf8") === shell) return;
|
||||
const tmp = `${page}.${process.pid}.tmp`;
|
||||
writeFileSync(tmp, shell);
|
||||
renameSync(tmp, page);
|
||||
}
|
||||
|
||||
// ── Ticket reads and writes ──────────────────────────────────────────────────
|
||||
@@ -75,7 +84,7 @@ function load(key: string) {
|
||||
|
||||
function save(dir: string, t: Ticket) {
|
||||
writeData(ticketFile(dir), "ticket", t);
|
||||
refreshSite(dir, "ticket", `${t.key}: ${t.title}`);
|
||||
refreshSite(dir, "ticket", t);
|
||||
if (t.epic) syncStory(t);
|
||||
}
|
||||
|
||||
@@ -97,7 +106,7 @@ function syncStory(t: Ticket) {
|
||||
s.progress = progress(t);
|
||||
if (s.status !== "done") s.status = storyStatus(t);
|
||||
writeData(file, "epic", e);
|
||||
refreshSite(dirname(file), "epic", `${e.key}: ${e.title}`);
|
||||
refreshSite(dirname(file), "epic", e);
|
||||
}
|
||||
|
||||
// ── Commands ─────────────────────────────────────────────────────────────────
|
||||
@@ -259,9 +268,10 @@ command("proof", "proof <KEY> (--file proof/<name> | --test NAME) --proves AC1,A
|
||||
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);
|
||||
const isVideo = !!file && /\.(mp4|webm|mov)$/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 }),
|
||||
kind: test ? "test" : isText ? "text" : isVideo ? "video" : "image", ...(file ? { file } : { test }),
|
||||
...(isText ? { text: readFileSync(join(dir, file!), "utf8") } : {}),
|
||||
proves, caption, ...(a.one("mock") ? { mock: a.one("mock") } : {}), at: now(),
|
||||
});
|
||||
@@ -341,9 +351,9 @@ command("open", "open <KEY> print the page path, and open it in the browser ou
|
||||
const { dir, t } = load(a.pos[0]);
|
||||
// Rewrites the shells first, so a page last written before the renderer was inlined
|
||||
// opens in T3 too.
|
||||
refreshSite(dir, "ticket", `${t.key}: ${t.title}`);
|
||||
refreshSite(dir, "ticket", t);
|
||||
const epic = t.epic && epicFile(t.epic.key);
|
||||
if (epic && existsSync(epic)) { const e = readData(epic, "epic"); refreshSite(dirname(epic), "epic", `${e.key}: ${e.title}`); }
|
||||
if (epic && existsSync(epic)) { const e = readData(epic, "epic"); refreshSite(dirname(epic), "epic", e); }
|
||||
const page = join(dir, "index.html");
|
||||
// Inside a T3 Code thread the agent links this path and the page opens in T3's file
|
||||
// viewer, beside the conversation. A browser window would be one more thing to juggle.
|
||||
@@ -388,7 +398,7 @@ function withEpic(key: string, fn: (e: Epic) => void) {
|
||||
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}`);
|
||||
refreshSite(dirname(file), "epic", e);
|
||||
}
|
||||
|
||||
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 => {
|
||||
@@ -399,7 +409,7 @@ command("epic", "epic <EPIC> init --title T [--jira URL] | story <KEY> [--title
|
||||
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}`);
|
||||
refreshSite(dirname(file), "epic", e);
|
||||
return console.log(join(dirname(file), "index.html"));
|
||||
}
|
||||
withEpic(key, e => {
|
||||
|
||||
Reference in new issue
Block a user