diff --git a/.gitignore b/.gitignore index 494128ad..4035ca1b 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ dashboard/dist/ node_modules/ .oven-test-*/ .playwright-cli/ +.worktrees/ output/ build/ *.zip diff --git a/README.md b/README.md index 8d9f7770..5308bcde 100644 --- a/README.md +++ b/README.md @@ -148,6 +148,55 @@ Untracked hook configs are added to `.git/info/exclude` by default; tracked conf Use `burnlist --help` for dashboard ports, scan roots, local state paths, and Oven data bindings. +## Built-in Loops (Stage 1) + +Items may use direct execution or one of exactly three built-in Loops: + +```text +direct Start -> Implement -> Burn +review Start -> Implement -> Check -> Review -> Burn +gate Start -> Implement -> Check -> Burn +branch Start -> Plan -> N host-orchestrated branches -> Merge -> Check -> Review -> Burn +``` + +Inspect and explicitly trust the repository check with `burnlist loop +capability inspect|trust`, then assign an item with `burnlist loop assign`. +Burnlist stores no agent profile, provider route, subscription, or login. Run +`burnlist loop view ` +and paste its complete ASCII output into the work handoff; it is the frozen +graph, pin, and completion-path record for that item. Create a Run with `loop +create`; for each agent node, claim it, invoke a native subagent or external +provider CLI from the host, and submit a bound report. Burnlist automatically +advances its trusted checks and gates to the next agent or terminal node. +Inspect with `loop status|inspect`; `pause` and `stop` are idle-Run controls, +and proof-gated `reconcile` handles a demonstrably lost claim. Only a +converged Run can be applied by `loop complete`; the +command is idempotent and performs the normal shrinking-list completion. + +The host owns every provider invocation. Codex and Claude can use native +subagents; any host can deliberately harness Codex CLI, AGY, Grok, or another +CLI through the installed skill's explicit recipes. Before the first Loop, the +skill inventories available native agents, CLIs, live access, and subscriptions +without reading credentials, shows the result, and asks which providers the +user wants to use or set up. Burnlist never installs, authenticates, configures, +or launches an agent provider. Host execution remains independent from +Streaming Diff hooks. + +The Checklist UI is read-only and shows the active node, attempt, results, +transition history, and paused, error, or terminal state. Burnlist enforces +the graph, claim identity, trusted checks, budgets, closed outcomes, and atomic +CLI writes. Provider permissions remain host-supervised, not an OS sandbox. +Burnlist-native parallel scheduling, Docker isolation, metrics gates, +forecasting, worktrees, and background execution are deliberately unsupported +in Stage 1. The Branch host uses native or CLI subagents when available and +falls back to the same slices sequentially. Items with no Loop assignment keep the ordinary direct +`burnlist burn` workflow. + +See the [Loop CLI reference](website/src/content/docs/loops.mdx) for the exact +setup and recovery commands. Installing the Burnlist skill (`burnlist install`) +does not install Streaming Diff hooks (`burnlist hooks install`), and neither +is required to use the Loop. + ## Build and Verify From a source checkout: diff --git a/bin/burnlist.mjs b/bin/burnlist.mjs index 37aa9bf9..ee34c67d 100755 --- a/bin/burnlist.mjs +++ b/bin/burnlist.mjs @@ -20,6 +20,9 @@ const knownSubcommands = new Set([ "start", "close", "burn", + "loop", + "agent", + "route", "register", "unregister", "roots", @@ -33,6 +36,18 @@ function printSkillUsage(command) { console.log(`${usage}\n\nInstall and remove Burnlist-managed agent skills for Codex and Claude.`); } +function printLifecycleUsage(command) { + const usages = { + new: "Usage: burnlist new [--repo ]", + show: "Usage: burnlist show [#] [--repo ]", + ready: "Usage: burnlist ready [--repo ]", + start: "Usage: burnlist start [--repo ]", + close: "Usage: burnlist close [--repo ]", + burn: "Usage: burnlist burn [--check] [--repo ]", + }; + console.log(usages[command]); +} + async function main() { if (args[0] === "install" || args[0] === "uninstall") { if (args.includes("--help") || args.includes("-h")) { @@ -43,6 +58,12 @@ if (args[0] === "install" || args[0] === "uninstall") { return; } +if (["new", "show", "ready", "start", "close", "burn"].includes(args[0]) + && (args.includes("--help") || args.includes("-h"))) { + printLifecycleUsage(args[0]); + return; +} + if (args[0] === "differential-testing" && args[1] === "schema") { console.log(resolve(packageRoot, "ovens", "differential-testing", "engine", "data.schema.json")); return; @@ -83,7 +104,7 @@ if (args[0] && !args[0].startsWith("--") && !["-h", "-v"].includes(args[0]) && ! process.exit(2); } -if (!["oven", "hooks"].includes(args[0]) && (args.includes("--help") || args.includes("-h"))) { +if (!["oven", "hooks", "loop", "agent", "route"].includes(args[0]) && (args.includes("--help") || args.includes("-h"))) { console.log(`Burnlist Usage: @@ -105,6 +126,20 @@ Usage: burnlist start [--repo ] burnlist close [--repo ] burnlist burn [--check] [--repo ] + burnlist loop assign [--repo ] + burnlist loop unassign [--repo ] + burnlist loop view [--repo ] + burnlist loop create [--repo ] + burnlist loop next|claim [--repo ] + burnlist loop report (--outcome | --result ) [--repo ] + burnlist loop abandon --reason [--repo ] + burnlist loop list [--repo ] + burnlist loop status|inspect [--repo ] + burnlist loop pause|stop [--repo ] (idle Run only) + burnlist loop reconcile --recovery-proof [--repo ] + burnlist loop complete [--repo ] + burnlist loop capability ... + burnlist loop setup status [--repo ] burnlist register [path] burnlist unregister [path] burnlist roots [--prune] @@ -144,6 +179,9 @@ if (args[0] === "oven") { 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 (args[0] === "loop") { + const { runLoopCliEntry } = await import("../src/cli/loop-cli.mjs"); + await runLoopCliEntry(args.slice(1)); } else if (["register", "unregister", "roots", "init"].includes(args[0])) { await import("../src/cli/registry-cli.mjs"); } else { diff --git a/dashboard/src/App.tsx b/dashboard/src/App.tsx index d5509e5e..24d66978 100644 --- a/dashboard/src/App.tsx +++ b/dashboard/src/App.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { ListChecks } from "lucide-react"; import { AppHeader, BurnlistTable, ChecklistOvenView, CustomOvenView, DashboardError, DifferentialTestingOvenPage, EmptyState, FILTERS, Filters, LensSwitcher, ModelLabPage, NewOvenPage, OvenCatalog, OvenDefinition, OvenExplainer, PerformanceTracingOvenPage, ProjectGroup, RunBurnPage, StreamingDiff, VisualParityPage } from "@components"; import { useDashboardData } from "@hooks"; @@ -8,7 +8,13 @@ import { Button } from "@layout"; export function App() { const section = currentSection(); - const selected = useMemo(selectedBurnlist, [window.location.pathname, window.location.search]); + const [itemHash, setItemHash] = useState(() => window.location.hash); + useEffect(() => { + const changed = () => setItemHash(window.location.hash); + window.addEventListener("hashchange", changed); + return () => window.removeEventListener("hashchange", changed); + }, []); + const selected = useMemo(selectedBurnlist, [window.location.pathname, window.location.search, itemHash]); const repoKey = ovenRepoKey(); const [filter, setFilter] = useState(() => filterFromUrl(FILTERS)); const dashboardSection = ["landing", "burnlist", "streaming-diff"].includes(section) || (section === "custom-oven" && selected) ? "burnlists" : section; @@ -34,7 +40,7 @@ export function App() {
{section === "differential-testing" ? {(ir) => } : section === "model-lab" ? {(ir) => } : section === "performance-tracing" ? {(ir) => } : section === "streaming-diff" ? {(ir) => } : section === "visual-parity" ? {(ir) => } : section === "custom-oven" ? : section === "new-oven" ? : section === "run-burn" ? : section === "ovens-catalog" ? : section === "oven-explainer" ? : selected ? ( loading && !progress ? : progress ? ( - <>{(error || stale) && }{(ir) => } + <>{(error || stale) && }{(ir) => } ) : error ? : ) : (
@@ -48,7 +54,7 @@ export function App() { - {(error || stale) && } + {(error || stale) && } {projects.length && visibleBurnlistCount ? (
{projects.map((project) => )}
) : projects.length ? : error ? null : } diff --git a/dashboard/src/components/ChecklistDashboard/ChecklistDashboard.css b/dashboard/src/components/ChecklistDashboard/ChecklistDashboard.css index f0e6fa2b..181057bd 100644 --- a/dashboard/src/components/ChecklistDashboard/ChecklistDashboard.css +++ b/dashboard/src/components/ChecklistDashboard/ChecklistDashboard.css @@ -15,22 +15,25 @@ body.checklist-detail-view .dashboard-oven-title { .checklist-detail-shell .checklist-kpi-strip .driving-parity-kpi-ratio { font-variant-numeric: tabular-nums; } .checklist-detail-shell .checklist-kpi-strip .driving-parity-kpi-progress-donut-segment { transition: stroke-dasharray 160ms ease; } - +.checklist-detail-shell #burnlist-detail { + width: min(100%, 1440px); + margin-inline: auto; +} .checklist-detail-shell #burnlist-detail .checklist-progress-workspace { width: 100%; - height: 232px; - min-height: 232px; - max-height: 232px; - flex: 0 0 232px; - grid-template-columns: 30% minmax(0, 70%); + height: 260px; + min-height: 260px; + max-height: 260px; + flex: 0 0 260px; + grid-template-columns: minmax(320px, 38%) minmax(0, 62%); } /* The shared Differential Testing shell defaults its top workspace to 310px. */ @media (min-width: 1101px) { body.checklist-detail-view .shell.checklist-detail-shell #burnlist-detail .checklist-overview:not([hidden]) + .checklist-progress-workspace { - height: 232px; - min-height: 232px; - max-height: 232px; + height: 260px; + min-height: 260px; + max-height: 260px; } } @@ -44,9 +47,9 @@ body.checklist-detail-view .dashboard-oven-title { .checklist-detail-shell .checklist-progress-workspace .event-ledger-panel, .checklist-detail-shell .checklist-progress-workspace .progress-panel { - height: 232px; - min-height: 232px; - max-height: 232px; + height: 260px; + min-height: 260px; + max-height: 260px; } .checklist-detail-shell .checklist-progress-workspace .work-panel-body { min-height: 0; } @@ -213,12 +216,71 @@ body.checklist-detail-view .dashboard-oven-title { .event-card:hover .event-card-summary { background: #1c1c1c; } -@media (max-width: 640px) { +@media (min-width: 761px) and (max-width: 1000px) { + .shell.checklist-detail-shell #burnlist-detail .checklist-kpi-strip, + .shell.checklist-detail-shell #burnlist-detail .checklist-kpi-strip.has-burns { + grid-template-columns: repeat(5, minmax(0, 1fr)); + column-gap: 0; + } + .shell.checklist-detail-shell #burnlist-detail .checklist-kpi-strip .driving-parity-kpi-section { + padding-inline: 6px; + grid-template-columns: 28px minmax(0, 1fr); + column-gap: 6px; + } + .checklist-detail-shell .checklist-kpi-strip .driving-parity-kpi-heading { font-size: 11px; } + .checklist-detail-shell .checklist-kpi-strip .driving-parity-kpi-ratio { font-size: 11px; } + .checklist-detail-shell .checklist-kpi-strip .driving-parity-kpi-scenario-icon { width: 28px; height: 28px; } +} + +/* Keep Progress and Completion side by side through tablet widths. */ +@media (min-width: 521px) and (max-width: 640px) { + body.checklist-detail-view .shell.checklist-detail-shell #burnlist-detail .checklist-overview:not([hidden]) + .checklist-progress-workspace { + height: 260px; + min-height: 260px; + max-height: 260px; + grid-template-columns: minmax(240px, 38%) minmax(0, 62%); + grid-template-rows: minmax(0, 1fr); + } + .checklist-detail-shell #burnlist-detail .checklist-progress-workspace .event-ledger-panel { + height: 260px; + min-height: 260px; + max-height: 260px; + grid-column: 1; + grid-row: 1; + } + .checklist-detail-shell #burnlist-detail .checklist-progress-workspace .progress-panel { + height: 260px; + min-height: 260px; + max-height: 260px; + grid-column: 2; + grid-row: 1; + } +} + +@media (max-width: 520px) { body.checklist-detail-view .dashboard-oven-title { max-width: calc(100% - 190px); font-size: 14px; } .dashboard-detail-time { display: none; } - .checklist-detail-shell #burnlist-detail .checklist-progress-workspace { height: auto; min-height: 612px; max-height: none; flex: none; grid-template-rows: 320px 282px; } - .checklist-detail-shell .checklist-progress-workspace .event-ledger-panel, - .checklist-detail-shell .checklist-progress-workspace .progress-panel { height: auto; min-height: 0; max-height: none; } + body.checklist-detail-view .shell.checklist-detail-shell #burnlist-detail .checklist-overview:not([hidden]) + .checklist-progress-workspace { + height: 532px; + min-height: 532px; + max-height: 532px; + flex: none; + grid-template-columns: minmax(0, 1fr); + grid-template-rows: 260px 260px; + gap: 12px; + } + .checklist-detail-shell #burnlist-detail .checklist-progress-workspace::before { display: none; } + .checklist-detail-shell #burnlist-detail .checklist-progress-workspace .event-ledger-panel, + .checklist-detail-shell #burnlist-detail .checklist-progress-workspace .progress-panel { + width: 100%; + height: 260px; + min-height: 260px; + max-height: 260px; + border-radius: 8px; + background: var(--card); + } + .checklist-detail-shell #burnlist-detail .checklist-progress-workspace .event-ledger-panel { grid-column: 1; grid-row: 1; } + .checklist-detail-shell #burnlist-detail .checklist-progress-workspace .progress-panel { grid-column: 1; grid-row: 2; } .event-card-summary { grid-template-columns: max-content minmax(0, 1fr) max-content; padding: 0 10px; gap: 8px; } .event-card-description { padding: 0 10px 8px; } .event-card-field { grid-template-columns: 1fr; gap: 3px; } diff --git a/dashboard/src/components/ChecklistDashboard/ChecklistDashboard.test.mjs b/dashboard/src/components/ChecklistDashboard/ChecklistDashboard.test.mjs index 487f4637..07fc9da2 100644 --- a/dashboard/src/components/ChecklistDashboard/ChecklistDashboard.test.mjs +++ b/dashboard/src/components/ChecklistDashboard/ChecklistDashboard.test.mjs @@ -6,31 +6,37 @@ import { createElement } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { build } from "esbuild"; import { checklistFixture as data } from "./ChecklistDashboard.fixture.mjs"; +import { runM4ProgressFixture } from "../../../../src/loops/run/run-test-fixtures.mjs"; const componentPath = new URL("./ChecklistDashboard.tsx", import.meta.url).pathname; const stylesheetPath = new URL("./ChecklistDashboard.css", import.meta.url).pathname; const libPath = new URL("../../lib", import.meta.url).pathname; const ovenPath = new URL("../../oven", import.meta.url).pathname; -test("checklist progress owns its workspace height instead of inheriting the differential default", async () => { +test("checklist keeps a centered horizontal KPI strip above matched Progress and Completion panels", async () => { const stylesheet = await readFile(stylesheetPath, "utf8"); - assert.match(stylesheet, /body\.checklist-detail-view \.shell\.checklist-detail-shell #burnlist-detail \.checklist-overview:not\(\[hidden\]\) \+ \.checklist-progress-workspace \{\s+height: 232px;\s+min-height: 232px;\s+max-height: 232px;/u); + assert.match(stylesheet, /\.checklist-detail-shell #burnlist-detail \{\s+width: min\(100%, 1440px\);\s+margin-inline: auto;/u); + assert.match(stylesheet, /\.checklist-detail-shell #burnlist-detail \.checklist-progress-workspace \{[^}]+grid-template-columns: minmax\(320px, 38%\) minmax\(0, 62%\);/su); + assert.match(stylesheet, /@media \(min-width: 521px\) and \(max-width: 640px\) \{[\s\S]+grid-template-columns: minmax\(240px, 38%\) minmax\(0, 62%\);[\s\S]+\.event-ledger-panel \{[\s\S]+grid-column: 1;[\s\S]+grid-row: 1;[\s\S]+\.progress-panel \{[\s\S]+grid-column: 2;[\s\S]+grid-row: 1;/u); + assert.match(stylesheet, /@media \(max-width: 520px\) \{[\s\S]+\.event-ledger-panel,[\s\S]+\.progress-panel \{\s+width: 100%;/u); }); test("checklist detail renders the split progress surface and event card list", async () => { const outputDir = await mkdtemp(join(process.cwd(), ".checklist-dashboard-test-")); + let repoRoot; try { + repoRoot = await mkdtemp(join(process.cwd(), ".m5-loop-ui-")); const outputPath = join(outputDir, "ChecklistDashboard.mjs"); await build({ entryPoints: [componentPath], bundle: true, format: "esm", outfile: outputPath, platform: "node", alias: { "@lib": libPath, "@oven": ovenPath }, jsx: "automatic", packages: "external", target: "node18", }); - const { ChecklistDashboard, checklistEventDetailFields } = await import(`${new URL(`file://${outputPath}`).href}?test=${Date.now()}`); + const { ChecklistDashboard, LoopRunPanel } = await import(`${new URL(`file://${outputPath}`).href}?test=${Date.now()}`); const markup = renderToStaticMarkup(createElement(ChecklistDashboard, { data })); + assert.equal(markup, renderToStaticMarkup(createElement(ChecklistDashboard, { data: { ...data, loopRun: null } }))); assert.match(markup, /aria-label="Burnlist progress KPIs"/u); - assert.match(markup, /class="driving-parity-kpi-item driving-parity-kpi-section checklist-kpi-current"/u); - assert.match(markup, /
Current<\/div>
Complete<\/div>/u); + assert.doesNotMatch(markup, /class="panel checklist-current"/u); assert.match(markup, /class="driving-parity-kpi-item driving-parity-kpi-section driving-parity-kpi-progress"/u); assert.match(markup, /
2<\/span>·<\/span>2<\/span> \(100%\)<\/span><\/div>/u); assert.match(markup, /
Elapsed<\/div>/u); @@ -42,33 +48,89 @@ test("checklist detail renders the split progress surface and event card list", assert.match(markup, /Completion<\/span>/u); assert.doesNotMatch(markup, /aria-label="Burnlist progress chart view"/u); assert.match(markup, /Age<\/span>Event<\/span>Result<\/span>Delta<\/span>Done<\/span>/u); - assert.match(markup, /class="event-card-list"/u); - assert.equal((markup.match(/data-event-card="true"/gu) ?? []).length, 2); + assert.match(markup, /class="checklist-workspace"/u); + assert.match(markup, /aria-label="All items"/u); + assert.match(markup, /aria-label="Item B2 detail"/u); + assert.match(markup, /Item detail<\/span>Completed<\/span>/u); + assert.equal((markup.match(/class="checklist-workspace__item is-completed/gu) ?? []).length, 2); assert.equal(markup.indexOf("Second event") < markup.indexOf("First event"), true); - assert.match(markup, /First proof\./u); - assert.match(markup, /Second proof\./u); - assert.equal((markup.match(/class="event-card-field-label">Outcome/gu) ?? []).length, 2); - assert.equal((markup.match(/class="event-card-summary"/gu) ?? []).length, 2); - assert.equal((markup.match(/class="event-card-description"/gu) ?? []).length, 2); - assert.match(markup, /
Changed<\/span>1<\/span><\/summary>/u); - assert.match(markup, /
Proof<\/span>1<\/span><\/summary>/u); - assert.match(markup, /src\/second\.mjs/u); - assert.match(markup, /node --test second\.test\.mjs/u); - assert.match(markup, /class="event-card-field-label">Follow-up/u); - assert.doesNotMatch(markup, /event-card-cell|event-card-content|event-card-expand/u); - assert.deepEqual(checklistEventDetailFields(data.completed[1].detail), [ - { label: "Completed", values: ["2026-07-15T11:50:00Z"] }, - { label: "Changed", values: ["src/second.mjs"] }, - { label: "Proof", values: ["node --test second.test.mjs"] }, - { label: "Outcome", values: ["Second proof."] }, - { label: "Follow-up", values: ["None."] }, - ]); - assert.doesNotMatch(markup, /Completed: 2026/u); + assert.doesNotMatch(markup, /data-event-card="true"/u); + assert.equal((markup.match(/
Completed<\/dt>/gu) ?? []).length, 1); assert.doesNotMatch(markup, />DONE]*>Changes<\/button>/u); assert.doesNotMatch(markup, /Burnlist detail view/u); assert.doesNotMatch(markup, /Repo Graph/u); + + const { snapshots } = await runM4ProgressFixture({ + repoRoot, + }); + for (const projection of snapshots) { + const stage = renderToStaticMarkup(createElement(LoopRunPanel, { data: { ...data, loopRun: projection } })); + assert.match(stage, new RegExp(`ACTIVE: ${projection.currentNode.toUpperCase()}`, "u")); + assert.match(stage, /START/u); + assert.match(stage, /DECOMPO/u); + assert.match(stage, /FINAL-R/u); + assert.match(stage, /aria-current="step"/u); + if (projection.currentNode === "converged") assert.match(stage, /approve/u); + if (projection.currentNode === "completed") assert.match(stage, /COMPLETED/u); + } + const completedLoopMarkup = renderToStaticMarkup(createElement(ChecklistDashboard, { data: { + ...data, + selectedItemId: "B2", + loopRun: { ...snapshots.at(-1), itemRef: "item:260722-001#B2" }, + } })); + assert.match(completedLoopMarkup, /Completed Loop for B2/u); + assert.match(completedLoopMarkup, /Loop symbols for B2/u); + assert.match(completedLoopMarkup, /IMPLEMENT/u); + const evidence = renderToStaticMarkup(createElement(LoopRunPanel, { data: { + ...data, + loopRun: { + ...snapshots.at(-1), + latestMaker: { summary: "candidate prepared", at: Date.parse("2026-07-15T11:40:00Z"), candidateId: "candidate-1" }, + latestCheck: { summary: "verify passed", at: Date.parse("2026-07-15T11:45:00Z"), candidateId: "candidate-1" }, + latestReviewer: { summary: "approved", at: Date.parse("2026-07-15T11:50:00Z"), candidateId: "candidate-1" }, + }, + } })); + assert.match(evidence, /ACTIVE: COMPLETED/u); + assert.match(evidence, /aria-label="Loop state: Converged"/u); } finally { await rm(outputDir, { force: true, recursive: true }); + if (repoRoot) await rm(repoRoot, { force: true, recursive: true }); } }); + +test("Loop panel exposes every terminal and observer diagnostic state accessibly", async () => { + const outputDir = await mkdtemp(join(process.cwd(), ".m7-loop-state-ui-")); + const repoRoot = await mkdtemp(join(process.cwd(), ".m7-loop-state-run-")); + try { + const outputPath = join(outputDir, "ChecklistDashboard.mjs"); + await build({ entryPoints: [componentPath], bundle: true, format: "esm", outfile: outputPath, platform: "node", alias: { "@lib": libPath, "@oven": ovenPath }, jsx: "automatic", packages: "external", target: "node18" }); + const { LoopRunPanel } = await import(`${new URL(`file://${outputPath}`).href}?states=${Date.now()}`); + const { final } = await runM4ProgressFixture({ repoRoot }); + const labels = { paused: "Paused", failed: "Failed", stopped: "Stopped", "needs-human": "Needs human review", "budget-exhausted": "Budget exhausted", converged: "Converged", completed: "Completed", corrupt: "Corrupt projection", stale: "Stale projection" }; + for (const [state, label] of Object.entries(labels)) { + const projection = { ...final, state }; + const markup = renderToStaticMarkup(createElement(LoopRunPanel, { data: { ...data, loopRun: state === "stale" ? { ...projection, diagnostic: "stale" } : projection } })); + assert.match(markup, new RegExp(`aria-label="Loop state: ${label}"`, "u")); + } + } finally { + await rm(outputDir, { force: true, recursive: true }); + await rm(repoRoot, { force: true, recursive: true }); + } +}); + +test("Loop panel exposes a real unreachable-projection diagnostic without fabricating a Run", async () => { + const outputDir = await mkdtemp(join(process.cwd(), ".m12-loop-diagnostic-")); + try { + const outputPath = join(outputDir, "ChecklistDashboard.mjs"); + await build({ entryPoints: [componentPath], bundle: true, format: "esm", outfile: outputPath, platform: "node", alias: { "@lib": libPath, "@oven": ovenPath }, jsx: "automatic", packages: "external", target: "node18" }); + const { LoopRunPanel } = await import(`${new URL(`file://${outputPath}`).href}?diagnostic=${Date.now()}`); + const markup = renderToStaticMarkup(createElement(LoopRunPanel, { data: { + ...data, loopProjectionDiagnostic: "corrupt", loopProjectionMessage: "Loop projection is unavailable; retaining the last verified projection.", loopRun: null, + } })); + assert.match(markup, /aria-label="Loop run diagnostic"/u); + assert.match(markup, /role="alert"/u); + assert.match(markup, /Corrupt projection/u); + assert.doesNotMatch(markup, /Current<\/strong>/u); + } finally { await rm(outputDir, { force: true, recursive: true }); } +}); diff --git a/dashboard/src/components/ChecklistDashboard/ChecklistDashboard.tsx b/dashboard/src/components/ChecklistDashboard/ChecklistDashboard.tsx index afd971a9..80dba5a5 100644 --- a/dashboard/src/components/ChecklistDashboard/ChecklistDashboard.tsx +++ b/dashboard/src/components/ChecklistDashboard/ChecklistDashboard.tsx @@ -5,6 +5,8 @@ import { checklistEventDetailFields, compactAge, eventRows, formatDuration, prog export { checklistEventDetailFields } from "@lib/checklist-adapter"; import "./ChecklistDashboard.css"; import { buildChecklistProgressChart, KpiItem, KpiStrip, LogTable, ProgressDonut, SectionHeader } from "@oven"; +import { LoopGraph } from "@/components/LoopGraph"; +import { ChecklistWorkspace } from "@/oven/ChecklistWorkspace"; function ChecklistKpis({ data }: { data: ChecklistProgressData }) { const durations = timing(data); @@ -94,10 +96,19 @@ export function EventCardList({ data }: { data: ChecklistProgressData }) { })}{!rows.length &&

No completed events yet.

}
; } +export function LoopRunPanel({ data }: { data: ChecklistProgressData }) { + return ; +} + export function ChecklistDashboard({ data }: { data: ChecklistProgressData }) { useEffect(() => { document.body.classList.add("driving-parity-view", "checklist-detail-view"); return () => document.body.classList.remove("driving-parity-view", "checklist-detail-view"); }, []); - return
; + return
; } diff --git a/dashboard/src/components/ChecklistDashboard/checklist-adapter.test.mjs b/dashboard/src/components/ChecklistDashboard/checklist-adapter.test.mjs index ce523387..e7be74c6 100644 --- a/dashboard/src/components/ChecklistDashboard/checklist-adapter.test.mjs +++ b/dashboard/src/components/ChecklistDashboard/checklist-adapter.test.mjs @@ -15,6 +15,7 @@ test("adaptChecklist precomputes the checklist oven payload", async () => { const { adaptChecklist } = await import(`${new URL(`file://${outputPath}`).href}?test=${Date.now()}`); assert.deepEqual(adaptChecklist(checklistFixture), { raw: checklistFixture, + items: [], current: { value: "Complete", title: "No active task" }, progress: { done: 2, total: 2, percent: 100, title: "2 of 2 tasks complete" }, durations: { elapsed: "10m", pace: "5m", timeLeft: "0m" }, diff --git a/dashboard/src/components/ChecklistDashboard/checklist-dom-golden.test.mjs b/dashboard/src/components/ChecklistDashboard/checklist-dom-golden.test.mjs index 1e45dc91..27ddbc2f 100644 --- a/dashboard/src/components/ChecklistDashboard/checklist-dom-golden.test.mjs +++ b/dashboard/src/components/ChecklistDashboard/checklist-dom-golden.test.mjs @@ -1,4 +1,5 @@ import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; import { mkdtemp, readFile, rm } from "node:fs/promises"; import { join } from "node:path"; import test from "node:test"; @@ -7,12 +8,26 @@ import { renderToStaticMarkup } from "react-dom/server"; import { build } from "esbuild"; import { checklistFixture } from "./ChecklistDashboard.fixture.mjs"; import { withDeterministicTime } from "../../oven/test-support/deterministic-time.mjs"; +import { runM4ProgressFixture } from "../../../../src/loops/run/run-test-fixtures.mjs"; const componentPath = new URL("./ChecklistDashboard.tsx", import.meta.url).pathname; const normalizerPath = new URL("../../oven/test-support/dom-normalize.ts", import.meta.url).pathname; const libPath = new URL("../../lib", import.meta.url).pathname; const ovenPath = new URL("../../oven", import.meta.url).pathname; const goldenPath = new URL("./checklist-dom.golden.html", import.meta.url); +const loopGoldenPath = new URL("./checklist-loop-progression.golden.json", import.meta.url); +const loopStateGoldenPath = new URL("./checklist-loop-states.golden.json", import.meta.url); +const digest = (value) => createHash("sha256").update(value).digest("hex"); +const itemData = (projection) => ({ + ...checklistFixture, + active: [{ + id: projection.itemRef.split("#").at(-1), + title: "Loop-assigned item", + fields: {}, + loop: { selector: `loop:builtin:${projection.loopId}` }, + }], + loopRun: projection, +}); test("checklist detail static DOM matches the frozen byte golden", async () => { const outputDir = await mkdtemp(join(process.cwd(), ".checklist-dom-golden-test-")); @@ -35,3 +50,95 @@ test("checklist detail static DOM matches the frozen byte golden", async () => { await rm(outputDir, { force: true, recursive: true }); } }); + +test("real M4 projections advance the full Checklist DOM through the frozen Loop progression", async () => { + const outputDir = await mkdtemp(join(process.cwd(), ".checklist-loop-golden-test-")); + const repoRoot = await mkdtemp(join(process.cwd(), ".checklist-loop-run-")); + try { + const componentOutput = join(outputDir, "ChecklistDashboard.mjs"); + const normalizerOutput = join(outputDir, "dom-normalize.mjs"); + await Promise.all([ + build({ entryPoints: [componentPath], bundle: true, format: "esm", outfile: componentOutput, platform: "node", alias: { "@lib": libPath, "@oven": ovenPath }, jsx: "automatic", packages: "external", target: "node18" }), + build({ entryPoints: [normalizerPath], bundle: true, format: "esm", outfile: normalizerOutput, platform: "node", target: "node18" }), + ]); + const [{ ChecklistDashboard }, { normalize, parseHtml, serializeCanonical }] = await Promise.all([ + import(`${new URL(`file://${componentOutput}`).href}?test=${Date.now()}`), + import(`${new URL(`file://${normalizerOutput}`).href}?test=${Date.now()}`), + ]); + const { snapshots } = await runM4ProgressFixture({ + repoRoot, + }); + const selected = [ + snapshots.find((snapshot) => snapshot.currentNode === "review" && snapshot.attempt === 1 && snapshot.latestResult?.kind === "reject"), + snapshots.find((snapshot) => snapshot.currentNode === "decompose" && snapshot.attempt === 2 && snapshot.latestResult?.kind === "reject"), + snapshots.find((snapshot) => snapshot.currentNode === "review" && snapshot.attempt === 2 && snapshot.latestResult?.kind === "approve"), + snapshots.find((snapshot) => snapshot.currentNode === "converged" && snapshot.attempt === 1), + snapshots.filter((snapshot) => snapshot.currentNode === "completed").at(-1), + ]; + assert.equal(selected.every(Boolean), true); + const actual = selected.map((projection) => { + const stableActivity = projection.activity ? { + ...projection.activity, + records: projection.activity.records.map((record) => ({ + ...record, + ...(record.correlation ? { correlation: "" } : {}), + })), + } : undefined; + const projectionBytes = JSON.stringify({ + ...projection, + revision: "", + ...(stableActivity ? { activity: stableActivity } : {}), + }); + const markup = withDeterministicTime(() => + renderToStaticMarkup(createElement(ChecklistDashboard, { data: itemData(projection) }))); + const domBytes = serializeCanonical(normalize(parseHtml(markup))); + return { + checkpoint: `${projection.currentNode}/${projection.attempt}/${projection.latestResult?.kind ?? "none"}`, + projectionBytes: Buffer.byteLength(projectionBytes), + projectionSha256: digest(projectionBytes), + domBytes: Buffer.byteLength(domBytes), + domSha256: digest(domBytes), + }; + }); + const expected = JSON.parse(await readFile(loopGoldenPath, "utf8")); + assert.deepEqual(actual, expected); + } finally { + await Promise.all([ + rm(outputDir, { force: true, recursive: true }), + rm(repoRoot, { force: true, recursive: true }), + ]); + } +}); + +test("terminal, paused, stale, and corrupt Loop states retain frozen full Checklist DOMs", async () => { + const outputDir = await mkdtemp(join(process.cwd(), ".checklist-loop-state-golden-test-")); + const repoRoot = await mkdtemp(join(process.cwd(), ".checklist-loop-state-run-")); + try { + const componentOutput = join(outputDir, "ChecklistDashboard.mjs"); + const normalizerOutput = join(outputDir, "dom-normalize.mjs"); + await Promise.all([ + build({ entryPoints: [componentPath], bundle: true, format: "esm", outfile: componentOutput, platform: "node", alias: { "@lib": libPath, "@oven": ovenPath }, jsx: "automatic", packages: "external", target: "node18" }), + build({ entryPoints: [normalizerPath], bundle: true, format: "esm", outfile: normalizerOutput, platform: "node", target: "node18" }), + ]); + const [{ ChecklistDashboard }, { normalize, parseHtml, serializeCanonical }] = await Promise.all([ + import(`${new URL(`file://${componentOutput}`).href}?test=${Date.now()}`), + import(`${new URL(`file://${normalizerOutput}`).href}?test=${Date.now()}`), + ]); + const { final } = await runM4ProgressFixture({ repoRoot, outcomes: ["complete", "pass", "approve"] }); + const variants = [ + ["paused", { state: "paused" }], ["failed", { state: "failed" }], ["stopped", { state: "stopped" }], + ["needs-human", { state: "needs-human" }], ["exhausted", { state: "budget-exhausted" }], + ["stale", { diagnostic: "stale" }], ["corrupt", { state: "corrupt" }], ["completed", { state: "converged" }], + ]; + const actual = variants.map(([checkpoint, patch]) => { + const markup = withDeterministicTime(() => renderToStaticMarkup(createElement(ChecklistDashboard, { + data: itemData({ ...final, ...patch }), + }))); + const domBytes = serializeCanonical(normalize(parseHtml(markup))); + return { checkpoint, domBytes: Buffer.byteLength(domBytes), domSha256: digest(domBytes) }; + }); + assert.deepEqual(actual, JSON.parse(await readFile(loopStateGoldenPath, "utf8"))); + } finally { + await Promise.all([rm(outputDir, { force: true, recursive: true }), rm(repoRoot, { force: true, recursive: true })]); + } +}); diff --git a/dashboard/src/components/ChecklistDashboard/checklist-dom.golden.html b/dashboard/src/components/ChecklistDashboard/checklist-dom.golden.html index f381bfee..b8093829 100644 --- a/dashboard/src/components/ChecklistDashboard/checklist-dom.golden.html +++ b/dashboard/src/components/ChecklistDashboard/checklist-dom.golden.html @@ -1 +1 @@ -
Current
Complete
Progress
2·2(100%)
Elapsed
10m
Avg pace
5m
Time left
0m
Progress
AgeEventResultDeltaDone
10mB2Done+1100%
20mB1Done+150%
Completion
25%50%75%100%11:4211:4411:4611:48Completed 1 itemCompleted 1 item

Events(2)

B2Second event·100%
Changed1
  • src/second.mjs
Proof1
  • node --test second.test.mjs
Outcome

Second proof.

Follow-up

None.

B1First event·50%
Outcome

First proof.

+
Current
Complete
Progress
2·2(100%)
Elapsed
10m
Avg pace
5m
Time left
0m
Progress
AgeEventResultDeltaDone
10mB2Done+1100%
20mB1Done+150%
Completion
25%50%75%100%11:4211:4411:4611:48Completed 1 itemCompleted 1 item
Items2
Item detailCompleted

B2 · Second event

Status
Completed
Completed
Changed
src/second.mjs
Proof
node --test second.test.mjs
Outcome
Second proof.
Follow-up
None.
diff --git a/dashboard/src/components/ChecklistDashboard/checklist-loop-progression.golden.json b/dashboard/src/components/ChecklistDashboard/checklist-loop-progression.golden.json new file mode 100644 index 00000000..600667cc --- /dev/null +++ b/dashboard/src/components/ChecklistDashboard/checklist-loop-progression.golden.json @@ -0,0 +1,37 @@ +[ + { + "checkpoint": "review/1/reject", + "projectionBytes": 4926, + "projectionSha256": "b3f77d4de6155c04bf6383fb4236ff6ff59554948fc6c8d4f7268ce863373ca6", + "domBytes": 11411, + "domSha256": "4a9c82ddb85f33914f70d05fb9791bcf07cad1fa987f27c55edd3d1fc24405b3" + }, + { + "checkpoint": "decompose/2/reject", + "projectionBytes": 4942, + "projectionSha256": "ae98eb5c311d9e76eb3b4aa654a0a870444f44e1d381e75a5b92cf86bd71dd0d", + "domBytes": 11371, + "domSha256": "3cc0268cf7e1f9a709c85aba98308f9dd4488e9e4921462dd799a70bd02e21d2" + }, + { + "checkpoint": "review/2/approve", + "projectionBytes": 5209, + "projectionSha256": "c82308d4a5c20257f6de806fbc46a8857d95ec5719a13717d5f6124461c9fabd", + "domBytes": 11413, + "domSha256": "427a07db4545b487cae3f9cb79cae75fb0148d866372880b65e62c1e046094cf" + }, + { + "checkpoint": "converged/1/approve", + "projectionBytes": 5506, + "projectionSha256": "4446c290d2959a6d28c6f56eeee834cafb8459e61b0af18b83a32119d5eed8cb", + "domBytes": 11390, + "domSha256": "a189ee185d39cc7ea1dcac894c352c90e99e7108fd924a6538320da0b435814e" + }, + { + "checkpoint": "completed/1/approve", + "projectionBytes": 5507, + "projectionSha256": "d83e0cd1d8a9243647e1e04e36dd6d09627ef6a270803739005d0e898ec1474e", + "domBytes": 11343, + "domSha256": "34c70f62d314366aeb3e34d0ed9013bc5e938b4702719695b6df97e5c914c891" + } +] diff --git a/dashboard/src/components/ChecklistDashboard/checklist-loop-states.golden.json b/dashboard/src/components/ChecklistDashboard/checklist-loop-states.golden.json new file mode 100644 index 00000000..9d4730a6 --- /dev/null +++ b/dashboard/src/components/ChecklistDashboard/checklist-loop-states.golden.json @@ -0,0 +1,42 @@ +[ + { + "checkpoint": "paused", + "domBytes": 10876, + "domSha256": "78e067dd99894335edaaa2ab79aa9a77bcd9cd91430fc80054e205cd219dc150" + }, + { + "checkpoint": "failed", + "domBytes": 10876, + "domSha256": "78e067dd99894335edaaa2ab79aa9a77bcd9cd91430fc80054e205cd219dc150" + }, + { + "checkpoint": "stopped", + "domBytes": 10876, + "domSha256": "78e067dd99894335edaaa2ab79aa9a77bcd9cd91430fc80054e205cd219dc150" + }, + { + "checkpoint": "needs-human", + "domBytes": 10876, + "domSha256": "78e067dd99894335edaaa2ab79aa9a77bcd9cd91430fc80054e205cd219dc150" + }, + { + "checkpoint": "exhausted", + "domBytes": 10876, + "domSha256": "78e067dd99894335edaaa2ab79aa9a77bcd9cd91430fc80054e205cd219dc150" + }, + { + "checkpoint": "stale", + "domBytes": 10876, + "domSha256": "78e067dd99894335edaaa2ab79aa9a77bcd9cd91430fc80054e205cd219dc150" + }, + { + "checkpoint": "corrupt", + "domBytes": 10876, + "domSha256": "78e067dd99894335edaaa2ab79aa9a77bcd9cd91430fc80054e205cd219dc150" + }, + { + "checkpoint": "completed", + "domBytes": 10876, + "domSha256": "78e067dd99894335edaaa2ab79aa9a77bcd9cd91430fc80054e205cd219dc150" + } +] diff --git a/dashboard/src/components/CustomOvenView/CustomOvenView.tsx b/dashboard/src/components/CustomOvenView/CustomOvenView.tsx index c3077328..c5266a9e 100644 --- a/dashboard/src/components/CustomOvenView/CustomOvenView.tsx +++ b/dashboard/src/components/CustomOvenView/CustomOvenView.tsx @@ -14,6 +14,10 @@ function unwrapPayload(raw: unknown) { return raw && typeof raw === "object" && "payload" in raw ? (raw as { payload: unknown }).payload : raw; } +export function burnlistOvenPayload(progress: ProgressData) { + return adaptChecklist(progress); +} + export function CustomOvenRuntime({ burnlistId, ir, payload }: { burnlistId?: string; ir: OvenIr; payload?: unknown }) { if (burnlistId) { return <>; @@ -27,12 +31,12 @@ export function CustomOvenView({ error, loading, progress, stale }: { error: str if (selection.burnlistId && loading && !progress) return ; if (selection.burnlistId && !progress) return error ? : ; return <> - {selection.burnlistId && (error || stale) && } + {selection.burnlistId && (error || stale) && } {(ir) => ( )} ; diff --git a/dashboard/src/components/CustomOvenView/custom-oven-render.test.mjs b/dashboard/src/components/CustomOvenView/custom-oven-render.test.mjs index 76053293..be8ed176 100644 --- a/dashboard/src/components/CustomOvenView/custom-oven-render.test.mjs +++ b/dashboard/src/components/CustomOvenView/custom-oven-render.test.mjs @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { mkdtemp, rm } from "node:fs/promises"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; import { join } from "node:path"; import test from "node:test"; import { renderToStaticMarkup } from "react-dom/server"; @@ -14,6 +14,7 @@ const ovenSource = ` + `; test("custom Oven runtime modes use canonical live snapshots and controlled Burnlist data", { timeout: 20_000 }, async () => { @@ -31,12 +32,19 @@ test("custom Oven runtime modes use canonical live snapshots and controlled Burn packages: "external", target: "node18", }); - const { CustomOvenRuntime } = await import(`${new URL(`file://${runtimeOutput}`).href}?test=${Date.now()}`); + const { burnlistOvenPayload, CustomOvenRuntime } = await import(`${new URL(`file://${runtimeOutput}`).href}?test=${Date.now()}`); const compiled = compileOven(ovenSource, { file: "widget-oven.oven" }); assert.equal(compiled.ok, true, compiled.ok ? "" : JSON.stringify(compiled.diagnostics)); if (!compiled.ok) return; - const payload = { widget: { name: "Sprockets", count: 42 } }; + const payload = { + widget: { name: "Sprockets", count: 42 }, + loopRun: { + loopId: "review", state: "running", currentNode: "verify", attempt: 1, cycle: 0, + graph: { entry: "implement", nodes: [{ id: "implement", kind: "agent" }, { id: "verify", kind: "check" }], edges: [{ from: "implement", on: "complete", to: "verify" }] }, + transitions: [{ sequence: 1, from: "implement", outcome: "complete", to: "verify" }], + }, + }; const ir = { ...compiled.ir, refreshSeconds: 7 }; const standalone = CustomOvenRuntime({ ir }); assert.equal(standalone.props.ir, ir); @@ -53,6 +61,39 @@ test("custom Oven runtime modes use canonical live snapshots and controlled Burn assert.equal("initialPayload" in controlled.props, false); assert.equal(controlled.props.ir.refreshSeconds, undefined); assert.equal(controlled.props.adapt, undefined); + assert.match(renderToStaticMarkup(burnlist), /aria-current="step"/u); + assert.match(renderToStaticMarkup(burnlist), /VERIFY/u); + + const loopProgressSource = await readFile(new URL("../../../../ovens/loop-progress/loop-progress.oven", import.meta.url), "utf8"); + const loopProgress = compileOven(loopProgressSource, { file: "loop-progress.oven" }); + assert.equal(loopProgress.ok, true, loopProgress.ok ? "" : JSON.stringify(loopProgress.diagnostics)); + if (!loopProgress.ok) return; + const progress = { + generatedAt: "2026-07-24T20:00:00Z", repoKey: "028543435173", title: "Host Loop", + repo: "burnlist", planLabel: "inprogress", selectedItemId: "O0", + total: 1, done: 0, remaining: 1, percent: 0, warnings: [], completed: [], history: [], + active: [{ id: "O0", title: "Seed the Loop Progress Oven", fields: { + Action: "Show the current work simply.", + "Files/search": "ovens/ dashboard/src/oven/", + }, loop: null }], + loopRun: null, + }; + const canonicalPayload = burnlistOvenPayload(progress); + const page = CustomOvenRuntime({ burnlistId: "260724-002", ir: loopProgress.ir, payload: canonicalPayload }); + const pageRuntime = page.props.children[1]; + assert.equal(pageRuntime.props.payload.raw, progress); + assert.equal(pageRuntime.props.ir.refreshSeconds, undefined); + const pageMarkup = renderToStaticMarkup(page); + assert.match(pageMarkup, /Seed the Loop Progress Oven/u); + assert.match(pageMarkup, /Makes truthful progress easy to see/u); + assert.match(pageMarkup, /SYSTEM FLOW/u); + assert.match(pageMarkup, /Plan/u); + assert.match(pageMarkup, /Loop control/u); + assert.match(pageMarkup, /Agent \+ workspace/u); + assert.match(pageMarkup, /Validate \+ review/u); + assert.match(pageMarkup, /ovens\/ dashboard\/src\/oven\//u); + assert.match(pageMarkup, /HOOKS/u); + assert.match(pageMarkup, /Unavailable/u); } finally { await rm(outputDir, { force: true, recursive: true }); } diff --git a/dashboard/src/components/DashboardError/DashboardError.css b/dashboard/src/components/DashboardError/DashboardError.css index 5b0d2618..b6fb980a 100644 --- a/dashboard/src/components/DashboardError/DashboardError.css +++ b/dashboard/src/components/DashboardError/DashboardError.css @@ -2,3 +2,18 @@ .dashboard-error-content { display: flex; gap: 12px; padding: 0 20px; } .dashboard-error-icon { width: 20px; height: 20px; flex: 0 0 auto; } .dashboard-error-message { margin: 0; font-size: 14px; } +.dashboard-error--floating { + position: fixed; + z-index: 60; + top: 58px; + right: 16px; + width: min(480px, calc(100vw - 32px)); + padding: 10px 0; + border-color: rgba(239, 68, 68, .28); + background: rgba(36, 18, 18, .96); + box-shadow: 0 8px 28px rgba(0, 0, 0, .38); + pointer-events: none; +} +.dashboard-error--floating .dashboard-error-content { align-items: center; padding: 0 12px; } +.dashboard-error--floating .dashboard-error-icon { width: 16px; height: 16px; } +.dashboard-error--floating .dashboard-error-message { font-size: 12px; line-height: 1.35; } diff --git a/dashboard/src/components/DashboardError/DashboardError.tsx b/dashboard/src/components/DashboardError/DashboardError.tsx index d89cf121..1d6a3dcd 100644 --- a/dashboard/src/components/DashboardError/DashboardError.tsx +++ b/dashboard/src/components/DashboardError/DashboardError.tsx @@ -2,9 +2,9 @@ import { AlertTriangle } from "lucide-react"; import { Card, CardContent } from "@layout"; import "./DashboardError.css"; -export function DashboardError({ message }: { message: string }) { +export function DashboardError({ message, floating = false }: { message: string; floating?: boolean }) { return ( - +