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..cd6b93a3 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Burnlist requires Node.js 18 or newer. npm install --global burnlist ``` -The global package installs the `burnlist` command and registers the bundled Burnlist skill for Claude Code under `~/.claude/skills` and Codex under `~/.agents/skills`. Streaming Diff hooks are a separate, opt-in per-repository integration; see [Agent integrations](#agent-integrations). +The global package installs the `burnlist` command and registers the bundled Burnlist skill for Claude Code under `~/.claude/skills` and Codex under `~/.agents/skills`. Native observability hooks are a separate, opt-in per-repository integration; see [Agent integrations](#agent-integrations). Ask your agent to create a Burnlist for a goal or continue an existing one. The skill owns that workflow; the CLI provides the dashboard and protocol helpers. @@ -42,6 +42,38 @@ One skill owns Burnlist creation, hardening, execution, and maintenance. The pro The dashboard scans lifecycle folders and refreshes automatically. Its progress views observe Burnlists without changing them. `New Oven` and `Run Burn` write local controller records under `.local/burnlist/` by default; they do not change canonical task state. +## Operational contract + +Start with the thinnest working end-to-end path, prove it, then refine—unless +the repository or user has already chosen another architecture. Burnlist +coordinates task, execution, proof, and observation contracts; it does not +dictate project structure. + +Per-item control recommendations are optional: + +```sh +burnlist recommend # +burnlist recommend # --json +``` + +They suggest the lightest fitting direct/gate/review/branch workflow, a +provider-neutral model class and effort, P0-P4 review handling, and an honest +metric or task-fit Oven when one adds value. The user remains authoritative. + +The Checklist labels items `PENDING`, `ACTIVE`, `WAITING`, `BLOCKED`, or +`COMPLETED` from canonical Burnlist and Run/claim state—never checklist +position. Recent correlated hooks may only refine an active item to +**progressing** and show bounded observational facts. The Loop Progress lens +separates canonical checks/gates/reviews and blockers from observational +agent/model/effort, file activity, timing, usage, and bounded forecasts. +Missing facts stay unavailable. + +Creation, registration, assignment, and Oven commands print the next useful +action and canonical dashboard URL. See the +[operational UX contract](skills/burnlist/references/operational-ux.md) for +coarse-to-fine guidance, P0-P4 policy, provenance, optimization measures, and +the required browser inspection of every task-fit Oven. + ## Ovens An Oven is a declarative recipe for a Burn. Its `instructions.md` defines the outcome, canonical state, required inputs, and evidence rules. Its `.oven` source declares controlled widgets and bindings used to present normalized data. @@ -106,14 +138,26 @@ burnlist uninstall --global --purge ### Hooks: capture Streaming Diff edits -`burnlist hooks install` is separate from skill installation. It is per-repository only (there is no global hooks mode) and merges Burnlist's edit-capture commands without replacing unrelated hooks: +`burnlist hooks install` is separate from skill installation. It is per-repository only (there is no global hooks mode) and merges Burnlist's edit-capture and Loop observation commands without replacing unrelated hooks: | Agent that consumes the hook | Worktree-root config | | --- | --- | | Codex | `/.codex/hooks.json` | | Claude Code | `/.claude/settings.json` | -Codex receives `SessionStart`, `PreToolUse`, and `PostToolUse` hooks; Claude Code also receives `PostToolUseFailure`. Edit events are limited to each agent's edit/write tools and invoke `burnlist streaming-diff hook`. Codex needs CLI version 0.124.0 or newer to run these hooks. The host needs `burnlist` on `PATH`, and the agent remains responsible for any hook trust or consent prompt. +Codex and Claude receive native session, subagent, tool, and stop lifecycle +hooks; Claude also receives `PostToolUseFailure`. Edit events remain limited to +each agent's edit/write tools and invoke `burnlist streaming-diff hook`. +Advisory `burnlist hooks observe` entries publish bounded, correlated Loop +activity through ignored local event state without reporting outcomes or +changing canonical Run state. A native `PreToolUse` for an exact +`burnlist loop next|claim` command prepares a short-lived session/tool/Run +tuple; the matching `PostToolUse` binds it only after that Run's claim is +durable. An unmatched native session stays unbound, and Burnlist never assigns +the only live claim by process-of-elimination. Codex +needs CLI version 0.124.0 or newer to run these hooks. The host needs +`burnlist` on `PATH`, and the agent remains responsible for any hook trust or +consent prompt. ```sh # Hooks only, for both agents by default @@ -144,10 +188,67 @@ Untracked hook configs are added to `.git/info/exclude` by default; tracked conf - `burnlist --close-completed` adds a digest when needed and moves empty in-progress Burnlists to `completed`. - `burnlist --stamp` prints a local ISO timestamp for completion records. - `burnlist install` / `burnlist uninstall` manage the independent agent-skill registrations. -- `burnlist hooks install|uninstall|status` manages the independent per-repository Streaming Diff hooks. +- `burnlist hooks install|uninstall|status` manages the independent per-repository native observability hooks. 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, use `loop next`, invoke a native subagent or +external provider CLI from the host with its small task, and use `loop submit` +for the semantic result. 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, +proof-gated `reconcile` handles a demonstrably lost claim, and `loop prune +--retain ` archives only non-current failed/stopped/exhausted/needs-human +histories. +`loop list` keeps a bounded newest-history public window even when more history +is retained. 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, recent correlated hook activity, and a local forecast +range with confidence and provenance. Forecasts are bounded private history +learned only after accepted semantic completion; missing token facts remain +missing and subscription pricing never becomes an invented cost. 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, +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..dd34550b 100755 --- a/bin/burnlist.mjs +++ b/bin/burnlist.mjs @@ -16,10 +16,14 @@ const knownSubcommands = new Set([ "oven", "new", "show", + "recommend", "ready", "start", "close", "burn", + "loop", + "agent", + "route", "register", "unregister", "roots", @@ -33,6 +37,19 @@ 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 ]", + recommend: "Usage: burnlist recommend # [--json] [--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 +60,12 @@ if (args[0] === "install" || args[0] === "uninstall") { return; } +if (["new", "show", "recommend", "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 +106,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: @@ -101,10 +124,28 @@ Usage: burnlist oven ... burnlist new [--repo ] burnlist show [#] [--repo ] + burnlist recommend # [--json] [--repo ] burnlist ready [--repo ] 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 [--repo ] + burnlist loop submit (--outcome | --result ) [--repo ] + burnlist loop claim [--repo ] (recovery/diagnostics) + burnlist loop report (--outcome | --result ) [--repo ] + burnlist loop abandon --reason [--repo ] + burnlist loop list [--repo ] + burnlist loop prune [--retain ] [--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] @@ -142,8 +183,11 @@ if (args[0] === "oven") { 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])) { +} else if (["new", "show", "recommend", "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/components/ChecklistDashboard/ChecklistDashboard.test.mjs b/dashboard/src/components/ChecklistDashboard/ChecklistDashboard.test.mjs index d67a8279..8f0e9134 100644 --- a/dashboard/src/components/ChecklistDashboard/ChecklistDashboard.test.mjs +++ b/dashboard/src/components/ChecklistDashboard/ChecklistDashboard.test.mjs @@ -96,7 +96,7 @@ test("checklist detail renders the split progress surface and event card list", assert.equal(markup.indexOf("Second event") < markup.indexOf("First event"), true); assert.match(markup, /First proof\./u); assert.match(markup, /Second proof\./u); - assert.doesNotMatch(markup, /class="event-card-field-label">Outcome|>OutcomeOutcome/u); assert.equal((markup.match(/class="event-card-field event-card-field-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); diff --git a/dashboard/src/components/ChecklistDashboard/ChecklistDashboard.tsx b/dashboard/src/components/ChecklistDashboard/ChecklistDashboard.tsx index fe302c59..c53baca1 100644 --- a/dashboard/src/components/ChecklistDashboard/ChecklistDashboard.tsx +++ b/dashboard/src/components/ChecklistDashboard/ChecklistDashboard.tsx @@ -1,21 +1,27 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { ClipboardList, Clock3, Gauge, TimerReset } from "lucide-react"; import type { ChecklistProgressData, HistoryPoint } from "@lib"; -import { checklistEventDetailFields, compactAge, eventRows, formatDuration, progressHistory, timing } from "@lib/checklist-adapter"; +import { checklistEventDetailFields, compactAge, effectiveItemWork, eventRows, formatDuration, progressHistory, timing } from "@lib/checklist-adapter"; 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); - const current = data.active[0]; + const current = data.active.find((item) => effectiveItemWork(data, item).state === "ACTIVE") + ?? data.active.find((item) => effectiveItemWork(data, item).state === "BLOCKED") + ?? data.active.find((item) => effectiveItemWork(data, item).state === "WAITING") + ?? data.active[0]; + const currentState = current ? effectiveItemWork(data, current).state : "PENDING"; const metrics = [ { icon: Clock3, heading: "Elapsed", value: formatDuration(durations.elapsed) }, { icon: Gauge, heading: "Avg pace", value: formatDuration(durations.pace) }, { icon: TimerReset, heading: "Time left", value: formatDuration(durations.timeLeft) }, ]; return - ; @@ -101,10 +107,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..98c6f62c 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 === "implement" && snapshot.attempt === 2 && snapshot.latestResult?.kind === "complete"), + 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 a053beeb..ad34439b 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%
Progress
1211:4211:4411:4611:48Completed 1 itemCompleted 1 item

Events(2)

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

Second proof.

Follow-up

None.

B1First event·50%

First proof.

+
Current
Complete
Progress
2·2(100%)
Elapsed
10m
Avg pace
5m
Time left
0m
Progress
AgeEventResultDeltaDone
10mB2Done+1100%
20mB1Done+150%
Progress
1211: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.

Events(2)

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

Second proof.

Follow-up

None.

B1First event·50%

First proof.

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..df3a5ac6 --- /dev/null +++ b/dashboard/src/components/ChecklistDashboard/checklist-loop-progression.golden.json @@ -0,0 +1,37 @@ +[ + { + "checkpoint": "review/1/reject", + "projectionBytes": 3560, + "projectionSha256": "e269ab436bd602cf2be9ba9ad0ee1e8cab11a48ace11c0a717b534acf7437b28", + "domBytes": 12814, + "domSha256": "8e04609fd5c2f6a2b23ca07c7fd69f41fecae40af986a94cda7b54364d120bb2" + }, + { + "checkpoint": "implement/2/complete", + "projectionBytes": 3829, + "projectionSha256": "a9be55e85fff41141bd03d4a2061f5c10f98313fd037e4cf21b51ad539ac0366", + "domBytes": 13001, + "domSha256": "377e54a5edccdd7be794eb0a4d4f044b8e65d082df82d415623e36f5e71ecd96" + }, + { + "checkpoint": "review/2/approve", + "projectionBytes": 3963, + "projectionSha256": "fa26cf02b2bbf3a47d8246d782b9c7ee4dfd3f7c99692834775d591069d73e2b", + "domBytes": 12993, + "domSha256": "5c6b77a389fbf16b3b15271663b8793f9f4b1a034318dcb10237bfc66ef4d133" + }, + { + "checkpoint": "converged/1/approve", + "projectionBytes": 3989, + "projectionSha256": "99d26c58766df3425692f8a3d23f740544e2cc5218037bf3be450719a1404518", + "domBytes": 12950, + "domSha256": "1b7f95d4a647f14a6fe1e77a4f6d182edd05bc048f0573947d52413ec8587efe" + }, + { + "checkpoint": "completed/1/approve", + "projectionBytes": 3991, + "projectionSha256": "1ade6bffe5db0e839527569b027e9d9575057e69c4f80bae327eb6fbe663a1d5", + "domBytes": 12921, + "domSha256": "5db1e17bbf923c1aeaaaff5c26cacf77bf38e596c78338c25f97c9fab5784df5" + } +] 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..05a3992d --- /dev/null +++ b/dashboard/src/components/ChecklistDashboard/checklist-loop-states.golden.json @@ -0,0 +1,42 @@ +[ + { + "checkpoint": "paused", + "domBytes": 12875, + "domSha256": "3382b6325fe2c8df52d837f52a866872f882df3e288bb9578df230cabdc29b86" + }, + { + "checkpoint": "failed", + "domBytes": 12873, + "domSha256": "6b4d23b366416b52630507e8d7819d0e6476e865dfe0e6744b9b92e53aa94b6b" + }, + { + "checkpoint": "stopped", + "domBytes": 12874, + "domSha256": "6de808d752b54f74060651ad5ebdf49d57039270c68c6806270466ed07289cc6" + }, + { + "checkpoint": "needs-human", + "domBytes": 12878, + "domSha256": "2caef5b73d435ea45c26faa4901c06c872714d70b51303561bd6228ce948d4d7" + }, + { + "checkpoint": "exhausted", + "domBytes": 12883, + "domSha256": "ab0e520fd124feeae7929607f5385c166e2f97712e883e7f48fa4be5291264bf" + }, + { + "checkpoint": "stale", + "domBytes": 12890, + "domSha256": "10679418a8cc7149c02c80ef02c55a7db4e18324f1ef128cc5061cb235069dd6" + }, + { + "checkpoint": "corrupt", + "domBytes": 12892, + "domSha256": "bb08c6d18a033e2f6201385c127a9b3e4620786778ad5150fc21869567c567f4" + }, + { + "checkpoint": "completed", + "domBytes": 12921, + "domSha256": "6d4b98b57e1e3d1aa3365480bbf37f5724355d06ddfa099acdf9e39f4452bf03" + } +] 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..8a5d8934 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,37 @@ 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, /No agent is working on this item yet/u); + assert.match(pageMarkup, /ASSIGNED LOOP/u); + assert.match(pageMarkup, /Direct work/u); + assert.doesNotMatch(pageMarkup, /RIGHT NOW/u); + assert.doesNotMatch(pageMarkup, /More details/u); + assert.doesNotMatch(pageMarkup, /ovens\/ dashboard\/src\/oven\//u); + assert.doesNotMatch(pageMarkup, /HOOKS|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 ( - +