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,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