Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,12 @@ Projects that need worker orchestration can import `createDifferentialTestingWor

See the [Differential Testing data contract](skills/burnlist/references/differential-testing-data.md) and [adapter SDK reference](skills/burnlist/references/differential-testing-adapter-sdk.md) for scenario bundles, exact sessions, telemetry, and worker interfaces.

## Streaming Diff hooks

`burnlist hooks install --agent codex,claude` merges local Streaming Diff commands into `.codex/hooks.json` and `.claude/settings.json`; it preserves existing hook entries. The agent remains responsible for any first-run hook trust/consent prompt—Burnlist only writes configuration and never bypasses that review. `burnlist hooks status` reports whether each config is tracked (and therefore shared) or local; an already tracked config cannot be hidden with `.git/info/exclude`.

Hooks use the portable `burnlist` command from `PATH`; the host resolves the platform-specific launcher.

## Command Line

- `burnlist --plan <burnlist.md> --check` validates the active queue and completed ledger.
Expand Down
25 changes: 19 additions & 6 deletions bin/burnlist.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const knownSubcommands = new Set([
"uninstall",
"differential-testing",
"streaming-diff",
"hooks",
"oven",
"new",
"show",
Expand Down Expand Up @@ -42,6 +44,7 @@ function runNodeScript(path, scriptArgs) {
});
}

async function main() {
if (args[0] === "uninstall") {
let prefix;
try {
Expand All @@ -64,17 +67,18 @@ if (args[0] === "uninstall") {
console.error("Burnlist: npm uninstall failed; restoring agent skill registrations.");
runNodeScript(resolve(packageRoot, "scripts", "register-skills.mjs"), ["--force-global"]);
}
process.exit(removal.status || 0);
process.exitCode = removal.status || 0;
return;
}

if (args[0] === "differential-testing" && args[1] === "schema") {
console.log(resolve(packageRoot, "ovens", "differential-testing", "schema", "differential-testing-data.schema.json"));
process.exit(0);
return;
}

if (args[0] === "differential-testing" && args[1] === "sdk") {
console.log(resolve(packageRoot, "ovens", "differential-testing", "engine", "differential-testing-adapter-sdk.mjs"));
process.exit(0);
return;
}

if (args[0] === "differential-testing" && ["validate", "validate-bundle"].includes(args[1])) {
Expand All @@ -95,7 +99,7 @@ if (args[0] === "differential-testing" && ["validate", "validate-bundle"].includ
const sampleCount = document.fields.reduce((total, field) => total + field.sampleCount, 0);
console.log(`Valid Differential Testing data: ${document.fields.length} fields, ${sampleCount} samples, ${document.summary.frames.uniqueTicks} aligned ticks.`);
}
process.exit(0);
return;
} catch (error) {
console.error(error.message);
process.exit(1);
Expand All @@ -120,6 +124,8 @@ Usage:
burnlist differential-testing validate-bundle <bundle/current.json>
burnlist differential-testing schema
burnlist differential-testing sdk
burnlist streaming-diff <ensure-feed|capture|url|hook> ...
burnlist hooks <install|uninstall|status> [--agent codex,claude] [--untracked]
burnlist oven <list|view|bind|unbind|bindings|create|update> ...
burnlist new [--repo <path>]
burnlist show <id>[#<item>] [--repo <path>]
Expand All @@ -142,21 +148,28 @@ Options:
--oven-data <id=path> Bind one Oven to a read-only normalized JSON payload.
--version, -v Print the installed Burnlist version.
--help, -h Show this help.`);
process.exit(0);
return;
}

if (args[0] !== "oven" && (args.includes("--version") || args.includes("-v"))) {
const packageJson = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
console.log(packageJson.version);
process.exit(0);
return;
}

if (args[0] === "oven") {
await import("../src/cli/oven-cli.mjs");
} else if (args[0] === "streaming-diff") {
await import("../src/cli/streaming-diff-cli.mjs");
} else if (args[0] === "hooks") {
await import("../src/cli/hooks-cli.mjs");
} else if (["new", "show", "ready", "start", "close", "burn"].includes(args[0])) {
await import("../src/cli/lifecycle-cli.mjs");
} else if (["register", "unregister", "roots", "init"].includes(args[0])) {
await import("../src/cli/registry-cli.mjs");
} else {
await import("../src/server/burnlist-dashboard-server.mjs");
}
}

await main();
9 changes: 5 additions & 4 deletions dashboard/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useMemo, useState } from "react";
import { Clock3, ListChecks } from "lucide-react";
import { AppHeader, ChecklistDashboard, DashboardError, DifferentialTestingPage, EmptyState, FILTERS, Filters, NewOvenPage, ProjectGroup, RunBurnPage } from "@components";
import { AppHeader, ChecklistDashboard, DashboardError, DifferentialTestingPage, EmptyState, FILTERS, Filters, NewOvenPage, ProjectGroup, RunBurnPage, StreamingDiff } from "@components";
import { useDashboardData } from "@hooks";
import { currentSection, filterFromUrl, listHref, selectedBurnlist } from "@lib";
import type { Filter } from "@lib";
Expand All @@ -9,7 +9,8 @@ export function App() {
const section = currentSection();
const selected = useMemo(selectedBurnlist, [window.location.pathname, window.location.search]);
const [filter, setFilter] = useState(() => filterFromUrl(FILTERS));
const { projects, progress, error, loading } = useDashboardData({ section, selected });
const dashboardSection = section === "streaming-diff" ? "burnlists" : section;
const { projects, progress, error, loading } = useDashboardData({ section: dashboardSection, selected });

const updateFilter = (nextFilter: Filter) => {
const url = new URL(window.location.href);
Expand All @@ -23,8 +24,8 @@ export function App() {
return (
<div className="dashboard-app">
<AppHeader section={section} />
<main className="dashboard-main" data-layout={section === "differential-testing" || selected ? "full" : "index"} data-section={section}>
{section === "differential-testing" ? <DifferentialTestingPage /> : section === "new-oven" ? <NewOvenPage /> : section === "run-burn" ? <RunBurnPage /> : selected ? (
<main className="dashboard-main" data-layout={section === "differential-testing" || section === "streaming-diff" || selected ? "full" : "index"} data-section={section}>
{section === "differential-testing" ? <DifferentialTestingPage /> : section === "streaming-diff" ? <StreamingDiff projects={projects} projectsLoading={loading} /> : section === "new-oven" ? <NewOvenPage /> : section === "run-burn" ? <RunBurnPage /> : selected ? (
error ? <DashboardError message={error} /> : loading && !progress ? <EmptyState title="Loading progress" detail="Reading the selected Burnlist." /> : progress ? (
<ChecklistDashboard backHref={listHref(filter)} data={progress} />
) : <EmptyState title="Choose a Burnlist" detail="Select an item from the list to inspect its progress." icon={ListChecks} />
Expand Down
3 changes: 2 additions & 1 deletion dashboard/src/components/AppHeader/AppHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,9 @@ export function AppHeader({ section }: { section: string }) {
<span className="dashboard-brand-name">Burnlist</span>
</a>
{section === "differential-testing" && <div className="dashboard-oven-title">Differential Testing</div>}
{section === "streaming-diff" && <div className="dashboard-oven-title">Streaming Diff</div>}
<nav aria-label="Primary navigation" className="dashboard-primary-nav">
{section !== "differential-testing" && HEADER_LINKS.map((link, index) => (
{section !== "differential-testing" && section !== "streaming-diff" && HEADER_LINKS.map((link, index) => (
<span className="dashboard-primary-nav-item" key={link.href}>
{index > 0 && <span aria-hidden="true" className="dashboard-primary-nav-separator">·</span>}
<a aria-label={link.label} aria-current={section === link.section ? "page" : undefined} className="dashboard-primary-nav-link" href={link.href} title={link.label}>
Expand Down
94 changes: 94 additions & 0 deletions dashboard/src/components/StreamingDiff/StreamingDiff.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { useEffect, useMemo } from "react";
import { Badge, Card, CardContent, CardDescription, CardHeader, CardTitle } from "@layout";
import { useStreamingDiffCards, useStreamingDiffFeeds } from "@hooks";
import { ovenRepoKey, streamingDiffAutoOpenHref, streamingDiffFeedKey, streamingDiffRepositories, streamingDiffSelection } from "@lib";
import { fileKindChip, isTextFileKind } from "@lib";
import type { Project, StreamingDiffCard, StreamingDiffFeed, StreamingDiffFile } from "@lib";
import "../../../../ovens/streaming-diff/renderer/streaming-diff.css";

function timestamp(value: string | null) {
const parsed = value ? Date.parse(value) : Number.NaN;
return Number.isFinite(parsed) ? new Date(parsed).toLocaleString() : value ?? "Unknown activity time";
}

function FeedList({ feeds, error, loading, showRepository }: { feeds: StreamingDiffFeed[]; error: string; loading: boolean; showRepository: boolean }) {
return (
<section className="streaming-diff-view">
<header className="streaming-diff-heading">
<h1>Streaming Diff</h1>
<p>Recent feeds are ordered by published activity time, not process liveness.</p>
</header>
{error ? <p className="streaming-diff-message is-error">{error}</p> : loading ? <p className="streaming-diff-message">Loading recent feeds.</p> : !feeds.length ? <p className="streaming-diff-message">No recent feeds.</p> : (
<div className="streaming-diff-feed-list">
{feeds.map((feed) => (
<a className="streaming-diff-feed" href={feed.href} key={streamingDiffFeedKey(feed)}>
<span className="streaming-diff-feed-session">{feed.identity.session}</span>
{showRepository && <span className="streaming-diff-feed-worktree">repository {feed.repoLabel}</span>}
<span className="streaming-diff-feed-worktree">worktree {feed.identity.worktreeKey}</span>
<time className="streaming-diff-feed-time" dateTime={feed.updatedAt ?? undefined}>{timestamp(feed.updatedAt)}</time>
</a>
))}
</div>
)}
</section>
);
}

function FileDiff({ file }: { file: StreamingDiffFile }) {
const chip = fileKindChip(file.kind, file.meta);
if (chip) {
return <section className="streaming-diff-file">
<div className="streaming-diff-file-head"><code>{file.path}</code><Badge variant="outline">{chip}</Badge></div>
{(file.meta?.reason || file.meta?.bytes !== undefined) && <p className="streaming-diff-file-meta">{file.meta?.reason}{file.meta?.reason && file.meta?.bytes !== undefined ? " · " : ""}{file.meta?.bytes !== undefined ? `${file.meta.bytes} bytes` : ""}</p>}
</section>;
}
return <section className="streaming-diff-file">
<div className="streaming-diff-file-head"><code>{file.path}</code><Badge variant="secondary">{file.kind}</Badge></div>
{isTextFileKind(file.kind) && file.diff !== undefined ? <pre className="streaming-diff-unified">{file.diff}</pre> : <p className="streaming-diff-file-meta">Diff content is unavailable.</p>}
</section>;
}

function DiffCard({ card }: { card: StreamingDiffCard }) {
const partial = card.status === "partial";
return <Card className="streaming-diff-card">
<CardHeader className="streaming-diff-card-header">
<div>
<CardTitle>{card.toolUseId}</CardTitle>
<CardDescription><time dateTime={card.ts}>{timestamp(card.ts)}</time> · {card.revId}</CardDescription>
</div>
<Badge variant={partial ? "destructive" : "default"}>{card.status}</Badge>
</CardHeader>
<CardContent className="streaming-diff-card-content">
{partial && <p className="streaming-diff-partial">{card.partialReason ?? "This revision is partial."}</p>}
{card.files.length ? card.files.map((file) => <FileDiff file={file} key={file.path} />) : <p className="streaming-diff-file-meta">No file content was captured.</p>}
</CardContent>
</Card>;
}

function SelectedFeed({ cards, error, session }: { cards: StreamingDiffCard[]; error: string; session: string }) {
return <section className="streaming-diff-view">
<header className="streaming-diff-heading">
<a className="streaming-diff-back" href={`/ovens/streaming-diff/view?repoKey=${encodeURIComponent(ovenRepoKey() ?? "")}`}>Recent feeds</a>
<h1>Streaming Diff</h1>
<p>Session {session}</p>
</header>
{error && <p className="streaming-diff-message is-error">{error}</p>}
<div className="streaming-diff-cards">{cards.map((card) => <DiffCard card={card} key={card.revId} />)}</div>
{!cards.length && <p className="streaming-diff-message">Waiting for diff cards.</p>}
</section>;
}

export function StreamingDiff({ projects, projectsLoading }: { projects: Project[]; projectsLoading: boolean }) {
const selection = streamingDiffSelection();
const repoKey = ovenRepoKey();
const repositories = useMemo(() => repoKey ? [{ repoKey, label: repoKey }] : streamingDiffRepositories(projects), [projects, repoKey]);
const feeds = useStreamingDiffFeeds(repositories, projectsLoading, Boolean(selection));
const cards = useStreamingDiffCards(selection);
const autoOpenHref = streamingDiffAutoOpenHref(feeds.feeds);

useEffect(() => {
if (autoOpenHref) window.location.replace(autoOpenHref);
}, [autoOpenHref]);

return selection ? <SelectedFeed cards={cards.cards} error={cards.error} session={selection.session} /> : <FeedList {...feeds} showRepository={!repoKey && repositories.length > 1} />;
}
1 change: 1 addition & 0 deletions dashboard/src/components/StreamingDiff/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { StreamingDiff } from "./StreamingDiff";
1 change: 1 addition & 0 deletions dashboard/src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ export { DifferentialTestingPage } from "./DifferentialTesting";
export { EmptyState } from "./EmptyState";
export { FILTERS, Filters } from "./Filters";
export { ProjectGroup } from "./ProjectGroup";
export { StreamingDiff } from "./StreamingDiff";
1 change: 1 addition & 0 deletions dashboard/src/hooks/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export { useDashboardData } from "./useDashboardData";
export { useStreamingDiffCards, useStreamingDiffFeeds } from "./useStreamingDiff";
78 changes: 78 additions & 0 deletions dashboard/src/hooks/useStreamingDiff.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { useEffect, useState } from "react";
import { applyStreamingDiffUpdate, mapStreamingDiffLandingFeeds, parseStreamingDiffCard } from "@lib";
import type { StreamingDiffCard, StreamingDiffFeed } from "@lib";

type FeedState = { feeds: StreamingDiffFeed[]; error: string; loading: boolean };
type CardState = { cards: StreamingDiffCard[]; error: string };
type Selection = { repoKey: string; worktreeKey: string; session: string } | null;
type Repository = { repoKey: string; label: string };

export function useStreamingDiffFeeds(repositories: Repository[], discoveryLoading: boolean, selected: boolean): FeedState {
const [state, setState] = useState<FeedState>({ feeds: [], error: "", loading: !selected });

useEffect(() => {
if (selected) return;
if (discoveryLoading) {
setState({ feeds: [], error: "", loading: true });
return;
}
if (!repositories.length) {
setState({ feeds: [], error: "", loading: false });
return;
}
let cancelled = false;
setState({ feeds: [], error: "", loading: true });
void Promise.allSettled(repositories.map(async (repository) => {
const params = new URLSearchParams({ list: "", repoKey: repository.repoKey });
const response = await fetch(`/api/oven-data/streaming-diff?${params}`, { cache: "no-store" });
const payload = await response.json();
if (!response.ok) throw new Error(payload.error ?? "Could not load recent feeds.");
return { repository, payload };
})).then((results) => {
const successful = results.filter((result): result is PromiseFulfilledResult<{ repository: Repository; payload: unknown }> => result.status === "fulfilled").map((result) => result.value);
if (!successful.length) {
const failure = results.find((result): result is PromiseRejectedResult => result.status === "rejected");
throw failure?.reason ?? new Error("Could not load recent feeds.");
}
if (!cancelled) setState({ feeds: mapStreamingDiffLandingFeeds(successful), error: "", loading: false });
}).catch((cause) => {
if (!cancelled) setState({ feeds: [], error: cause instanceof Error ? cause.message : "Could not load recent feeds.", loading: false });
});
return () => { cancelled = true; };
}, [discoveryLoading, repositories, selected]);

return state;
}

export function useStreamingDiffCards(selection: Selection): CardState {
const [state, setState] = useState<CardState>({ cards: [], error: "" });

useEffect(() => {
if (!selection) {
setState({ cards: [], error: "" });
return;
}
const params = new URLSearchParams(selection);
const stream = new EventSource(`/api/oven-data/streaming-diff?${params}`);
setState({ cards: [], error: "" });
const reset = () => setState((current) => ({ ...current, cards: applyStreamingDiffUpdate(current.cards, { type: "reset" }) }));
stream.addEventListener("reset", reset);
stream.onopen = () => setState((current) => ({ ...current, error: "" }));
stream.onmessage = (event) => {
try {
const card = parseStreamingDiffCard(JSON.parse(event.data));
if (!card) throw new Error("invalid card");
setState((current) => ({ ...current, cards: applyStreamingDiffUpdate(current.cards, { type: "card", card }) }));
} catch {
setState((current) => ({ ...current, error: "Received an invalid Streaming Diff card." }));
}
};
stream.onerror = () => setState((current) => ({ ...current, error: "The stream disconnected; reconnecting." }));
return () => {
stream.removeEventListener("reset", reset);
stream.close();
};
}, [selection?.repoKey, selection?.session, selection?.worktreeKey]);

return state;
}
12 changes: 11 additions & 1 deletion dashboard/src/lib/hrefs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,26 @@ import type { Burnlist, Filter, SelectedBurnlist } from "./types";
export function currentSection() {
if (window.location.pathname === "/ovens/new") return "new-oven";
if (window.location.pathname === "/ovens/differential-testing/view") return "differential-testing";
if (window.location.pathname === "/ovens/streaming-diff/view") return "streaming-diff";
if (window.location.pathname === "/runs/new") return "run-burn";
return "burnlists";
}

export function ovenRepoKey() {
return currentSection() === "differential-testing"
return ["differential-testing", "streaming-diff"].includes(currentSection())
? new URLSearchParams(window.location.search).get("repoKey")
: null;
}

export function streamingDiffSelection() {
if (currentSection() !== "streaming-diff") return null;
const params = new URLSearchParams(window.location.search);
const repoKey = params.get("repoKey");
const worktreeKey = params.get("worktreeKey");
const session = params.get("session");
return repoKey && worktreeKey && session ? { repoKey, worktreeKey, session } : null;
}

export function selectedBurnlist(): SelectedBurnlist | null {
if (currentSection() !== "burnlists") return null;
const plan = new URLSearchParams(window.location.search).get("plan");
Expand Down
5 changes: 3 additions & 2 deletions dashboard/src/lib/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export { formatTime } from "./format";
export { burnlistHref, currentSection, filterFromUrl, listHref, ovenRepoKey, selectedBurnlist } from "./hrefs";
export type { Burnlist, ChecklistProgressData, ChecklistItem, CompletedItem, Filter, HistoryPoint, ProgressData, Project, SelectedBurnlist, Warning } from "./types";
export { burnlistHref, currentSection, filterFromUrl, listHref, ovenRepoKey, selectedBurnlist, streamingDiffSelection } from "./hrefs";
export { applyStreamingDiffUpdate, fileKindChip, groupStreamingDiffCard, isTextFileKind, mapStreamingDiffFeeds, mapStreamingDiffLandingFeeds, parseStreamingDiffCard, streamingDiffAutoOpenHref, streamingDiffFeedHref, streamingDiffFeedKey, streamingDiffRepositories } from "./streaming-diff.mjs";
export type { Burnlist, ChecklistProgressData, ChecklistItem, CompletedItem, Filter, HistoryPoint, ProgressData, Project, SelectedBurnlist, StreamingDiffCard, StreamingDiffFeed, StreamingDiffFile, StreamingDiffFileKind, StreamingDiffIdentity, Warning } from "./types";
export { cn, joinClasses } from "./utils";
export type { ClassValue } from "./utils";
Loading
Loading