From a7104418bf000ce657df4e36ce7562cccceb2b18 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Fri, 24 Jul 2026 10:13:56 +0200 Subject: [PATCH 01/37] feat: add declarative review loop runtime --- loops/review/instructions.md | 5 + loops/review/review.loop | 21 + .../minimal-review-e2e-dom.golden.json | 7 + src/loops/adapters/codex-cli.mjs | 140 +++++++ src/loops/adapters/codex-cli.test.mjs | 126 ++++++ src/loops/adapters/normalized-invocation.mjs | 147 +++++++ .../adapters/normalized-invocation.test.mjs | 74 ++++ src/loops/agents/profile.mjs | 128 +++++++ src/loops/agents/profile.test.mjs | 32 ++ src/loops/assignment/assignment.mjs | 104 +++++ src/loops/assignment/assignment.test.mjs | 299 +++++++++++++++ src/loops/assignment/hazards.mjs | 31 ++ src/loops/assignment/item-metadata.mjs | 98 +++++ src/loops/assignment/resolver.mjs | 59 +++ src/loops/assignment/selectors.mjs | 40 ++ src/loops/assignment/store-worker.mjs | 154 ++++++++ src/loops/assignment/store.mjs | 74 ++++ src/loops/capabilities/capabilities.test.mjs | 80 ++++ src/loops/capabilities/contract.mjs | 76 ++++ src/loops/capabilities/runner.mjs | 63 +++ src/loops/capabilities/snapshot.mjs | 129 +++++++ src/loops/capabilities/trust.mjs | 44 +++ src/loops/completion/completion.mjs | 127 ++++++ src/loops/completion/completion.test.mjs | 128 +++++++ src/loops/config/config.test.mjs | 112 ++++++ src/loops/config/profiles.mjs | 50 +++ src/loops/config/setup.mjs | 39 ++ src/loops/config/store.mjs | 155 ++++++++ src/loops/contracts/agent-result.mjs | 97 +++++ src/loops/contracts/check-result.mjs | 65 ++++ src/loops/contracts/contract.mjs | 84 ++++ src/loops/contracts/contracts.test.mjs | 184 +++++++++ src/loops/contracts/finding.mjs | 51 +++ src/loops/dsl/__fixtures__/review.ir.json | 1 + .../dsl/__fixtures__/review.revisions.json | 1 + src/loops/dsl/canonical.mjs | 56 +++ src/loops/dsl/compile.mjs | 132 +++++++ src/loops/dsl/compile.test.mjs | 277 ++++++++++++++ src/loops/dsl/diagnostics.mjs | 39 ++ src/loops/dsl/frozen.mjs | 80 ++++ src/loops/dsl/grammar.mjs | 114 ++++++ src/loops/dsl/hash.mjs | 39 ++ src/loops/dsl/instructions.mjs | 51 +++ src/loops/dsl/ir-validate.mjs | 58 +++ src/loops/dsl/loop-xml.mjs | 79 ++++ src/loops/dsl/package-read.mjs | 352 +++++++++++++++++ src/loops/dsl/package-read.test.mjs | 311 +++++++++++++++ src/loops/events/projection-events.mjs | 27 ++ src/loops/events/projection-events.test.mjs | 37 ++ src/loops/minimal-review-e2e-fixtures.mjs | 94 +++++ src/loops/minimal-review-e2e.test.mjs | 210 ++++++++++ src/loops/run/binder.mjs | 361 ++++++++++++++++++ src/loops/run/binder.test.mjs | 186 +++++++++ src/loops/run/budgets.mjs | 28 ++ src/loops/run/budgets.test.mjs | 15 + src/loops/run/candidate.mjs | 46 +++ src/loops/run/controller.mjs | 49 +++ src/loops/run/controller.test.mjs | 42 ++ src/loops/run/current-authority.mjs | 56 +++ src/loops/run/current-authority.test.mjs | 28 ++ src/loops/run/launch-authority.test.mjs | 69 ++++ src/loops/run/launch-commit.mjs | 56 +++ src/loops/run/lifecycle.mjs | 76 ++++ src/loops/run/m2-test-fixtures.mjs | 9 + src/loops/run/operation.mjs | 92 +++++ src/loops/run/read-projection.mjs | 112 ++++++ src/loops/run/reviewer-isolation.test.mjs | 20 + src/loops/run/run-admission.mjs | 35 ++ src/loops/run/run-admission.test.mjs | 18 + src/loops/run/run-artifacts.mjs | 95 +++++ src/loops/run/run-claim.mjs | 19 + src/loops/run/run-clock.mjs | 7 + src/loops/run/run-clock.test.mjs | 20 + src/loops/run/run-codec.mjs | 150 ++++++++ src/loops/run/run-created.mjs | 20 + src/loops/run/run-fold.mjs | 15 + src/loops/run/run-fold.test.mjs | 17 + src/loops/run/run-journal.mjs | 51 +++ src/loops/run/run-journal.test.mjs | 50 +++ src/loops/run/run-record.mjs | 103 +++++ src/loops/run/run-ref.mjs | 6 + src/loops/run/run-result.mjs | 16 + src/loops/run/run-store.mjs | 153 ++++++++ src/loops/run/run-store.test.mjs | 117 ++++++ src/loops/run/run-test-fixtures.mjs | 147 +++++++ src/loops/run/runner.mjs | 87 +++++ src/loops/run/runner.test.mjs | 139 +++++++ src/loops/run/state-machine.mjs | 70 ++++ src/loops/run/state-machine.test.mjs | 51 +++ src/loops/view/render.mjs | 106 +++++ src/loops/view/render.test.mjs | 53 +++ 91 files changed, 7771 insertions(+) create mode 100644 loops/review/instructions.md create mode 100644 loops/review/review.loop create mode 100644 src/loops/__fixtures__/minimal-review-e2e-dom.golden.json create mode 100644 src/loops/adapters/codex-cli.mjs create mode 100644 src/loops/adapters/codex-cli.test.mjs create mode 100644 src/loops/adapters/normalized-invocation.mjs create mode 100644 src/loops/adapters/normalized-invocation.test.mjs create mode 100644 src/loops/agents/profile.mjs create mode 100644 src/loops/agents/profile.test.mjs create mode 100644 src/loops/assignment/assignment.mjs create mode 100644 src/loops/assignment/assignment.test.mjs create mode 100644 src/loops/assignment/hazards.mjs create mode 100644 src/loops/assignment/item-metadata.mjs create mode 100644 src/loops/assignment/resolver.mjs create mode 100644 src/loops/assignment/selectors.mjs create mode 100644 src/loops/assignment/store-worker.mjs create mode 100644 src/loops/assignment/store.mjs create mode 100644 src/loops/capabilities/capabilities.test.mjs create mode 100644 src/loops/capabilities/contract.mjs create mode 100644 src/loops/capabilities/runner.mjs create mode 100644 src/loops/capabilities/snapshot.mjs create mode 100644 src/loops/capabilities/trust.mjs create mode 100644 src/loops/completion/completion.mjs create mode 100644 src/loops/completion/completion.test.mjs create mode 100644 src/loops/config/config.test.mjs create mode 100644 src/loops/config/profiles.mjs create mode 100644 src/loops/config/setup.mjs create mode 100644 src/loops/config/store.mjs create mode 100644 src/loops/contracts/agent-result.mjs create mode 100644 src/loops/contracts/check-result.mjs create mode 100644 src/loops/contracts/contract.mjs create mode 100644 src/loops/contracts/contracts.test.mjs create mode 100644 src/loops/contracts/finding.mjs create mode 100644 src/loops/dsl/__fixtures__/review.ir.json create mode 100644 src/loops/dsl/__fixtures__/review.revisions.json create mode 100644 src/loops/dsl/canonical.mjs create mode 100644 src/loops/dsl/compile.mjs create mode 100644 src/loops/dsl/compile.test.mjs create mode 100644 src/loops/dsl/diagnostics.mjs create mode 100644 src/loops/dsl/frozen.mjs create mode 100644 src/loops/dsl/grammar.mjs create mode 100644 src/loops/dsl/hash.mjs create mode 100644 src/loops/dsl/instructions.mjs create mode 100644 src/loops/dsl/ir-validate.mjs create mode 100644 src/loops/dsl/loop-xml.mjs create mode 100644 src/loops/dsl/package-read.mjs create mode 100644 src/loops/dsl/package-read.test.mjs create mode 100644 src/loops/events/projection-events.mjs create mode 100644 src/loops/events/projection-events.test.mjs create mode 100644 src/loops/minimal-review-e2e-fixtures.mjs create mode 100644 src/loops/minimal-review-e2e.test.mjs create mode 100644 src/loops/run/binder.mjs create mode 100644 src/loops/run/binder.test.mjs create mode 100644 src/loops/run/budgets.mjs create mode 100644 src/loops/run/budgets.test.mjs create mode 100644 src/loops/run/candidate.mjs create mode 100644 src/loops/run/controller.mjs create mode 100644 src/loops/run/controller.test.mjs create mode 100644 src/loops/run/current-authority.mjs create mode 100644 src/loops/run/current-authority.test.mjs create mode 100644 src/loops/run/launch-authority.test.mjs create mode 100644 src/loops/run/launch-commit.mjs create mode 100644 src/loops/run/lifecycle.mjs create mode 100644 src/loops/run/m2-test-fixtures.mjs create mode 100644 src/loops/run/operation.mjs create mode 100644 src/loops/run/read-projection.mjs create mode 100644 src/loops/run/reviewer-isolation.test.mjs create mode 100644 src/loops/run/run-admission.mjs create mode 100644 src/loops/run/run-admission.test.mjs create mode 100644 src/loops/run/run-artifacts.mjs create mode 100644 src/loops/run/run-claim.mjs create mode 100644 src/loops/run/run-clock.mjs create mode 100644 src/loops/run/run-clock.test.mjs create mode 100644 src/loops/run/run-codec.mjs create mode 100644 src/loops/run/run-created.mjs create mode 100644 src/loops/run/run-fold.mjs create mode 100644 src/loops/run/run-fold.test.mjs create mode 100644 src/loops/run/run-journal.mjs create mode 100644 src/loops/run/run-journal.test.mjs create mode 100644 src/loops/run/run-record.mjs create mode 100644 src/loops/run/run-ref.mjs create mode 100644 src/loops/run/run-result.mjs create mode 100644 src/loops/run/run-store.mjs create mode 100644 src/loops/run/run-store.test.mjs create mode 100644 src/loops/run/run-test-fixtures.mjs create mode 100644 src/loops/run/runner.mjs create mode 100644 src/loops/run/runner.test.mjs create mode 100644 src/loops/run/state-machine.mjs create mode 100644 src/loops/run/state-machine.test.mjs create mode 100644 src/loops/view/render.mjs create mode 100644 src/loops/view/render.test.mjs diff --git a/loops/review/instructions.md b/loops/review/instructions.md new file mode 100644 index 00000000..23c75f8d --- /dev/null +++ b/loops/review/instructions.md @@ -0,0 +1,5 @@ +## implement +Implement the assigned Burnlist item. Run the required repository checks and report one structured final result. + +## review +Independently review the current candidate without writing. Report approve, reject, or escalate with bounded findings. diff --git a/loops/review/review.loop b/loops/review/review.loop new file mode 100644 index 00000000..fc7b5711 --- /dev/null +++ b/loops/review/review.loop @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/src/loops/__fixtures__/minimal-review-e2e-dom.golden.json b/src/loops/__fixtures__/minimal-review-e2e-dom.golden.json new file mode 100644 index 00000000..5c5f902f --- /dev/null +++ b/src/loops/__fixtures__/minimal-review-e2e-dom.golden.json @@ -0,0 +1,7 @@ +[ + {"checkpoint":"needs-human","domBytes":12386,"domSha256":"f1b61d53b386eac2517abb1a8e6f8780c996e884d1a3a9fadb539215f49182dd"}, + {"checkpoint":"paused","domBytes":11628,"domSha256":"6e2e705700cccc3f90713cbea6d4c99796723c35d39af21597ea69e66469c144"}, + {"checkpoint":"repair","domBytes":12494,"domSha256":"4eb6984ea066104d1ff2dc84d80f1e40e4db18c25936a97bcab4527721628844"}, + {"checkpoint":"converged","domBytes":12746,"domSha256":"0f68c9e8e272e2971472ea204e4c6cb5d6595e23c6e4d9a9d3f6a84ed6f79ee6"}, + {"checkpoint":"post-completion","domBytes":9212,"domSha256":"3d84894369b43754d20a2b134d3509fd8460bfd5dc52c0b52127b5bfc0897cf2"} +] diff --git a/src/loops/adapters/codex-cli.mjs b/src/loops/adapters/codex-cli.mjs new file mode 100644 index 00000000..cc7f7846 --- /dev/null +++ b/src/loops/adapters/codex-cli.mjs @@ -0,0 +1,140 @@ +import { spawn as nodeSpawn } from "node:child_process"; +import { TextDecoder } from "node:util"; +import { isAbsolute } from "node:path"; +import { requestedCodexIdentity, validateAgentProfile, validateCodexProbe } from "../agents/profile.mjs"; + +const MAX_OUTPUT_BYTES = 1048576; +const MAX_JSONL_LINE_BYTES = 65536; +const DIRECT_GUARANTEES = Object.freeze({ freshSession: "enforced", filesystemWriteDeny: "supervised", foregroundHandle: "supervised", cancellation: "supervised", lifecycle: "unsupported" }); + +function fail(message, code = "ELOOP_CODEX_ADAPTER") { throw Object.assign(new Error(`Codex adapter: ${message}`), { code }); } +function text(value, label, maximum = 262144) { + if (typeof value !== "string" || !value || Buffer.byteLength(value) > maximum || value.includes("\0")) fail(`invalid ${label}`); + return value; +} +function invocation(profile, cwd, prompt) { + const requested = requestedCodexIdentity(profile); + if (typeof cwd !== "string" || !isAbsolute(cwd) || /[\0\r\n]/u.test(cwd)) fail("invalid cwd"); + return Object.freeze({ + command: requested.binary, + args: ["exec", "--json", "--ephemeral", "-m", requested.model, "-c", `model_reasoning_effort=${requested.effort}`, "-s", requested.sandbox, "-C", cwd, "--skip-git-repo-check", "--", text(prompt, "prompt")], + requested, + }); +} +function safeToken(value) { return Number.isSafeInteger(value) && value >= 0; } +function usageFrom(events) { + const event = [...events].reverse().find((item) => item.type === "turn.completed" && item.usage && typeof item.usage === "object" && !Array.isArray(item.usage)); + if (!event) return null; + const usage = event.usage; const cached = usage.cached_input_tokens ?? 0; + if (![usage.input_tokens, usage.output_tokens, cached].every(safeToken) || usage.input_tokens > Number.MAX_SAFE_INTEGER - usage.output_tokens) return null; + return Object.freeze({ inputTokens: usage.input_tokens, outputTokens: usage.output_tokens, cachedInputTokens: cached, totalTokens: usage.input_tokens + usage.output_tokens }); +} +function providerReported(events) { + const event = events.find((item) => item.type === "thread.started" && typeof item.thread_id === "string"); + if (!event || !event.thread_id || Buffer.byteLength(event.thread_id) > 512 || /[\0\r\n]/u.test(event.thread_id)) return null; + const optional = (value) => typeof value === "string" && value && Buffer.byteLength(value) <= 512 && !/[\0\r\n]/u.test(value) ? value : null; + return Object.freeze({ model: optional(event.model), sessionId: event.thread_id, version: optional(event.version) }); +} +function parseJsonl(bytes) { + if (bytes.length > MAX_OUTPUT_BYTES) fail("JSONL output exceeds limit", "ELOOP_CODEX_OUTPUT_LIMIT"); + let source; try { source = new TextDecoder("utf-8", { fatal: true }).decode(bytes); } catch { fail("JSONL output is not UTF-8", "ELOOP_CODEX_OUTPUT"); } + const lines = source.split("\n"); + if (lines.at(-1) !== "") fail("JSONL output is not LF terminated", "ELOOP_CODEX_OUTPUT"); + const events = []; + for (const line of lines.slice(0, -1)) { + if (!line || Buffer.byteLength(line) > MAX_JSONL_LINE_BYTES) fail("malformed JSONL output", "ELOOP_CODEX_OUTPUT"); + let value; try { value = JSON.parse(line); } catch { fail("malformed JSONL output", "ELOOP_CODEX_OUTPUT"); } + if (!value || typeof value !== "object" || Array.isArray(value) || typeof value.type !== "string" || !value.type || Buffer.byteLength(value.type) > 128 || /[\0\r\n]/u.test(value.type)) fail("malformed JSONL event", "ELOOP_CODEX_OUTPUT"); + events.push(Object.freeze(value)); + } + return Object.freeze(events); +} +function isolation(value) { + // A direct child close is observable. It is not evidence about descendants, + // so the default controller must never manufacture an empty-process proof. + if (value === undefined) return { guarantees: DIRECT_GUARANTEES, terminate: (child, signal) => child.kill(signal), proveEmpty: async () => false, requireEmptyProof: false }; + const labels = ["freshSession", "filesystemWriteDeny", "foregroundHandle", "cancellation", "lifecycle"]; + if (!value || typeof value !== "object" || !value.guarantees || typeof value.terminate !== "function" + || typeof value.proveEmpty !== "function" || labels.some((key) => !["enforced", "detected-at-boundaries", "supervised", "unsupported"].includes(value.guarantees[key]))) fail("invalid foreground controller"); + return { ...value, requireEmptyProof: value.requireEmptyProof !== false }; +} + +/** A direct foreground process is fresh per call; reviewer write denial remains supervised. */ +export function startCodexInvocation({ profile, cwd, prompt, spawn = nodeSpawn, trustedIsolation }) { + const current = validateAgentProfile(profile); const launch = invocation(current, cwd, prompt); const controller = isolation(trustedIsolation); + let child; + try { + child = spawn(launch.command, launch.args, { cwd, shell: false, windowsHide: true, stdio: ["ignore", "pipe", "pipe"] }); + } catch (error) { controller.dispose?.(); throw error; } + if (!child || !Number.isInteger(child.pid) || child.pid <= 0 || !child.stdout || !child.stderr || typeof child.once !== "function") { + child?.once?.("error", () => {}); // consume the deferred spawn error after failing closed synchronously + controller.dispose?.(); + fail("spawn did not return a foreground process handle", "ELOOP_CODEX_HANDLE"); + } + const stdout = []; let outputBytes = 0; let outputExceeded = false; let cancellationRequested = false; let termSent = false; let killSent = false; let settled = false; let finalizing = false; let forceTimer; + const boundedTerminate = () => { + if (!termSent) { try { termSent = controller.terminate(child, "SIGTERM") === true; } catch { termSent = false; } } + if (!forceTimer) forceTimer = setTimeout(() => { if (settled) return; try { killSent = controller.terminate(child, "SIGKILL") === true; } catch { killSent = false; } }, 100); + }; + const capture = (target) => (chunk) => { + const bytes = Buffer.from(chunk); outputBytes += bytes.length; + if (outputBytes > MAX_OUTPUT_BYTES) { outputExceeded = true; boundedTerminate(); return; } + if (target) target.push(bytes); + }; + child.stdout.on("data", capture(stdout)); child.stderr.on("data", capture(null)); + const completion = new Promise((resolve, reject) => { + const finalize = async ({ processError = null, exitCode = null, signal = null }) => { + if (settled || finalizing) return; finalizing = true; clearTimeout(forceTimer); + let empty = false; try { empty = await controller.proveEmpty({ pid: child.pid, exitCode, signal, processError }) === true; } catch { empty = false; } + finally { try { controller.dispose?.(); } catch { empty = false; } } + if (!empty && controller.requireEmptyProof && !cancellationRequested) { + settled = true; + resolve(Object.freeze({ + requested: launch.requested, providerReported: null, technicallyProven: Object.freeze({ argv: [launch.command, ...launch.args], pidObserved: true }), + guarantees: Object.freeze({ ...controller.guarantees }), events: Object.freeze([]), usage: null, usageStatus: "unavailable", + exitCode: Number.isInteger(exitCode) ? exitCode : null, signal: signal ?? null, cancellationRequested, + termination: Object.freeze({ termSent, killSent, emptyProven: false, descendants: controller.guarantees.lifecycle }), + quarantineRequired: true, outcome: "quarantined", + })); return; + } + try { + if (processError) throw processError; + if (outputExceeded) fail("JSONL output exceeds limit", "ELOOP_CODEX_OUTPUT_LIMIT"); + const events = parseJsonl(Buffer.concat(stdout)); const provider = providerReported(events); const usage = usageFrom(events); + // A foreground child close is sufficient to report a requested direct + // cancellation. Descendant containment remains explicitly unknown. + const cancellationClosed = cancellationRequested && (Number.isInteger(exitCode) || signal !== null); + settled = true; + resolve(Object.freeze({ + requested: launch.requested, providerReported: provider, technicallyProven: Object.freeze({ argv: [launch.command, ...launch.args], pidObserved: true }), + guarantees: Object.freeze({ ...controller.guarantees }), events, usage, usageStatus: usage ? "reported" : "unavailable", + exitCode: Number.isInteger(exitCode) ? exitCode : null, signal: signal ?? null, cancellationRequested, + termination: Object.freeze({ termSent, killSent, emptyProven: empty, descendants: controller.guarantees.lifecycle }), + quarantineRequired: cancellationRequested && !cancellationClosed, + outcome: cancellationRequested ? cancellationClosed ? "cancelled" : "quarantined" : exitCode === 0 && !signal ? "completed" : "failed", + })); + } catch (error) { settled = true; reject(error); } + }; + child.once("error", (error) => { void finalize({ processError: error }); }); + child.once("close", (exitCode, signal) => { void finalize({ exitCode, signal }); }); + }); + return Object.freeze({ + pid: child.pid, requested: launch.requested, completion, + cancel() { + if (settled || cancellationRequested) return false; cancellationRequested = true; + boundedTerminate(); + return termSent; + }, + }); +} + +/** Child JSONL may report identity/usage, but only the trusted host controller supplies guarantees. */ +export async function probeCodexCli({ profile, cwd, spawn = nodeSpawn, trustedIsolation }) { + const handle = startCodexInvocation({ profile, cwd, spawn, trustedIsolation, prompt: "Burnlist adapter probe. Report provider identity only." }); + const result = await handle.completion; + if (result.outcome !== "completed" || !result.providerReported) fail(`probe did not provide provider identity (${result.outcome})`, "ELOOP_CODEX_PROBE"); + return validateCodexProbe({ + schema: "burnlist-codex-probe@1", requested: result.requested, providerReported: result.providerReported, + technicallyProven: result.technicallyProven, guarantees: { ...result.guarantees, usage: result.usageStatus }, + }); +} diff --git a/src/loops/adapters/codex-cli.test.mjs b/src/loops/adapters/codex-cli.test.mjs new file mode 100644 index 00000000..dfd3bc64 --- /dev/null +++ b/src/loops/adapters/codex-cli.test.mjs @@ -0,0 +1,126 @@ +import assert from "node:assert/strict"; +import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { probeCodexCli, startCodexInvocation } from "./codex-cli.mjs"; +import { resolveStageOneRoutes } from "../agents/profile.mjs"; + +const fakeSource = `#!/usr/bin/env node +const args = process.argv.slice(2); +const mode = process.env.FAKE_MODE || "ok"; +if (mode === "hang") { process.on("SIGTERM",()=>{}); setInterval(() => {}, 1000); } +if (mode === "nonzero") { process.exit(7); } +if (mode === "bad") { process.stdout.write("not-json\\n"); process.exit(0); } +if (mode === "invalid-utf8") { process.stdout.write(Buffer.from([255,10])); process.exit(0); } +if (mode === "oversize") { process.stdout.write(Buffer.alloc(1048577,97)); process.exit(0); } +const session = mode === "same-session" ? "same" : "fresh-" + process.pid; +process.stdout.write(JSON.stringify({type:"thread.started",thread_id:session,model:args[args.indexOf("-m") + 1],version:"fake-1"})+"\\n"); +process.stdout.write(JSON.stringify({type:"burnlist.capability",guarantees:{freshSession:"enforced",filesystemWriteDeny:"enforced",foregroundHandle:"enforced",cancellation:"enforced",lifecycle:"enforced"}})+"\\n"); +const usage = mode === "overflow" ? {input_tokens:Number.MAX_SAFE_INTEGER,output_tokens:1} : mode === "missing-usage" ? null : {input_tokens:11,output_tokens:7,cached_input_tokens:3}; +process.stdout.write(JSON.stringify(usage ? {type:"turn.completed",usage} : {type:"turn.completed"})+"\\n"); +`; + +function fixture() { + const directory = mkdtempSync(join(tmpdir(), "burnlist-codex-adapter-")); const binary = join(directory, "fake-codex.mjs"); + writeFileSync(binary, fakeSource, { mode: 0o700 }); chmodSync(binary, 0o700); + return { directory, binary, cleanup: () => rmSync(directory, { recursive: true, force: true }) }; +} +function profile(binary, id, authority, model = "gpt-5.6-terra", effort = "high") { return { schema: "burnlist-loop-agent-profile@1", id, adapter: "builtin:codex-cli", binary, model, effort, authority }; } +function unprovenIsolation() { + return { guarantees: { freshSession: "unsupported", filesystemWriteDeny: "unsupported", foregroundHandle: "supervised", cancellation: "supervised", lifecycle: "unsupported" }, terminate: (child, signal) => signal === "SIGTERM" ? true : child.kill(signal), proveEmpty: async () => false }; +} +function weakButEmptyIsolation() { return { ...unprovenIsolation(), proveEmpty: async () => true }; } +async function withMode(mode, fn) { + const before = process.env.FAKE_MODE; + try { process.env.FAKE_MODE = mode; return await fn(); } + finally { if (before === undefined) delete process.env.FAKE_MODE; else process.env.FAKE_MODE = before; } +} + +test("adapter uses exact ephemeral argv and ignores child capability claims", async () => { + const context = fixture(); + try { + const maker = profile(context.binary, "maker", "write"); + const result = await startCodexInvocation({ profile: maker, cwd: context.directory, prompt: "Implement." }).completion; + assert.deepEqual(result.technicallyProven.argv.slice(1, -1), ["exec", "--json", "--ephemeral", "-m", "gpt-5.6-terra", "-c", "model_reasoning_effort=high", "-s", "workspace-write", "-C", context.directory, "--skip-git-repo-check", "--"]); + assert.equal(result.technicallyProven.argv.at(-1), "Implement."); assert.equal(result.technicallyProven.pidObserved, true); + assert.deepEqual(result.usage, { inputTokens: 11, outputTokens: 7, cachedInputTokens: 3, totalTokens: 18 }); + + const direct = await startCodexInvocation({ profile: profile(context.binary, "reviewer", "read"), cwd: context.directory, prompt: "Direct." }).completion; + assert.equal(direct.outcome, "completed"); assert.equal(direct.termination.emptyProven, false); + } finally { context.cleanup(); } +}); + +test("cancellation uses bounded TERM/KILL and reports a closed direct child without descendant claims", async () => { + const context = fixture(); + try { + await withMode("hang", async () => { + const direct = startCodexInvocation({ profile: profile(context.binary, "maker", "write"), cwd: context.directory, prompt: "Wait.", trustedIsolation: unprovenIsolation() }); + assert.equal(direct.cancel(), true); assert.equal(direct.cancel(), false); const uncertain = await direct.completion; + assert.equal(uncertain.outcome, "cancelled"); assert.equal(uncertain.quarantineRequired, false); assert.equal(uncertain.termination.killSent, true); assert.equal(uncertain.termination.emptyProven, false); + + }); + } finally { context.cleanup(); } +}); + +test("JSONL is strict UTF-8 and usage overflow or absence is unavailable", async () => { + const context = fixture(); + try { + assert.throws(() => startCodexInvocation({ profile: profile(join(context.directory, "missing-codex"), "maker", "write"), cwd: context.directory, prompt: "Missing." }), (error) => error.code === "ELOOP_CODEX_HANDLE"); + const maker = profile(context.binary, "maker", "write"); + await withMode("nonzero", async () => { + const result = await startCodexInvocation({ profile: maker, cwd: context.directory, prompt: "Nonzero." }).completion; + assert.equal(result.outcome, "failed"); assert.equal(result.exitCode, 7); + }); + await withMode("bad", async () => assert.rejects(startCodexInvocation({ profile: maker, cwd: context.directory, prompt: "Bad." }).completion, (error) => error.code === "ELOOP_CODEX_OUTPUT")); + await withMode("invalid-utf8", async () => assert.rejects(startCodexInvocation({ profile: maker, cwd: context.directory, prompt: "Bad bytes." }).completion, (error) => error.code === "ELOOP_CODEX_OUTPUT")); + await withMode("oversize", async () => assert.rejects(startCodexInvocation({ profile: maker, cwd: context.directory, prompt: "Too much." }).completion, (error) => ["ELOOP_CODEX_OUTPUT_LIMIT", "ELOOP_CODEX_OUTPUT"].includes(error.code))); + for (const mode of ["missing-usage", "overflow"]) await withMode(mode, async () => { + const result = await startCodexInvocation({ profile: maker, cwd: context.directory, prompt: "Usage." }).completion; + assert.equal(result.usage, null); assert.equal(result.usageStatus, "unavailable"); + }); + } finally { context.cleanup(); } +}); + +test("each direct reviewer invocation has a fresh foreground PID and provider session", async () => { + const context = fixture(); + try { + const reviewer = profile(context.binary, "reviewer", "read"); + const first = startCodexInvocation({ profile: reviewer, cwd: context.directory, prompt: "Review one." }); + const second = startCodexInvocation({ profile: reviewer, cwd: context.directory, prompt: "Review two." }); + const [left, right] = await Promise.all([first.completion, second.completion]); + assert.notEqual(first.pid, second.pid); + assert.notEqual(left.providerReported.sessionId, right.providerReported.sessionId); + } finally { context.cleanup(); } +}); + +test("route binding requires host-enforced isolation and independent provider sessions", async () => { + const context = fixture(); + try { + const maker = profile(context.binary, "maker", "write"); const reviewer = profile(context.binary, "reviewer", "read", "gpt-5.6-sol", "medium"); + const weakMaker = await probeCodexCli({ profile: maker, cwd: context.directory, trustedIsolation: weakButEmptyIsolation() }); const weakReviewer = await probeCodexCli({ profile: reviewer, cwd: context.directory, trustedIsolation: weakButEmptyIsolation() }); + assert.throws(() => resolveStageOneRoutes({ profiles: [maker, reviewer], routes: { "implementation.standard": "maker", "review.strong": "reviewer" }, probes: { maker: weakMaker, reviewer: weakReviewer } }), (error) => error.code === "ELOOP_REVIEWER_ISOLATION"); + + const strongMaker = await probeCodexCli({ profile: maker, cwd: context.directory }); + const strongReviewer = await probeCodexCli({ profile: reviewer, cwd: context.directory }); + assert.equal(resolveStageOneRoutes({ profiles: [maker, reviewer], routes: { "implementation.standard": "maker", "review.strong": "reviewer" }, probes: { maker: strongMaker, reviewer: strongReviewer } }).review.guarantees.filesystemWriteDeny, "supervised"); + + await withMode("same-session", async () => { + const first = await probeCodexCli({ profile: maker, cwd: context.directory }); + const second = await probeCodexCli({ profile: reviewer, cwd: context.directory }); + assert.throws(() => resolveStageOneRoutes({ profiles: [maker, reviewer], routes: { "implementation.standard": "maker", "review.strong": "reviewer" }, probes: { maker: first, reviewer: second } }), (error) => error.code === "ELOOP_REVIEWER_ISOLATION"); + }); + } finally { context.cleanup(); } +}); + +test("direct foreground controllers are accepted without Docker authority", async () => { + const context = fixture(); + try { + const direct = await startCodexInvocation({ profile: profile(context.binary, "reviewer", "read"), + cwd: context.directory, prompt: "Fake.", trustedIsolation: { + guarantees: { freshSession: "enforced", filesystemWriteDeny: "enforced", foregroundHandle: "enforced", cancellation: "enforced", lifecycle: "enforced" }, + terminate: () => true, proveEmpty: async () => true, + } }).completion; + assert.equal(direct.outcome, "completed"); + } finally { context.cleanup(); } +}); diff --git a/src/loops/adapters/normalized-invocation.mjs b/src/loops/adapters/normalized-invocation.mjs new file mode 100644 index 00000000..f99f9fcf --- /dev/null +++ b/src/loops/adapters/normalized-invocation.mjs @@ -0,0 +1,147 @@ +import { startCodexInvocation } from "./codex-cli.mjs"; +import { runTrustedCapability } from "../capabilities/runner.mjs"; +import { parseBoundedObject } from "../contracts/contract.mjs"; + +const MAX_SUMMARY_BYTES = 1024; +const RESULT_TYPES = new Set(["complete", "approve", "reject", "escalate"]); +const CANDIDATE = /^cm1-sha256:[a-f0-9]{64}$/u; +const ASSIGNMENT = /^as1-sha256:[a-f0-9]{64}$/u; +const CLAIM = /^cl1-sha256:[a-f0-9]{64}$/u; +const RECIPE = /^er1-sha256:[a-f0-9]{64}$/u; +const POLICY = /^bp1-sha256:[a-f0-9]{64}$/u; +const fail = (message) => { throw Object.assign(new Error(`Loop invocation: ${message}`), { code: "ELOOP_INVOKE" }); }; +const exact = (value, keys) => Boolean(value) && typeof value === "object" && !Array.isArray(value) + && Object.keys(value).length === keys.length && keys.every((key) => Object.hasOwn(value, key)); + +function clean(value) { + if (typeof value !== "string") return "process failed"; + const bytes = Buffer.from(value.replace(/[\0\r\n]/gu, " "), "utf8"); + let output = bytes.subarray(0, MAX_SUMMARY_BYTES).toString("utf8"); + // Buffer slicing can land in a multibyte code point; trim decoded code + // points until the returned UTF-8 representation is genuinely bounded. + while (Buffer.byteLength(output, "utf8") > MAX_SUMMARY_BYTES) output = Array.from(output).slice(0, -1).join(""); + return output; +} +function route(routes, name) { + const value = routes?.[name]; + if (!value?.profile || typeof value.profile !== "object") fail(`missing ${name} route`); + return value.profile; +} +function binding(value) { + const keys = ["claimId", "assignmentId", "recipeRevision", "policyRevision", "inputCandidate", + "instructionBytes", "itemText", "candidateContext", "reviewerEvidence"]; + if (!exact(value, keys) || !CLAIM.test(value.claimId) || !ASSIGNMENT.test(value.assignmentId) + || !RECIPE.test(value.recipeRevision) || !POLICY.test(value.policyRevision) || !CANDIDATE.test(value.inputCandidate) + || typeof value.instructionBytes !== "string" || !value.instructionBytes || Buffer.byteLength(value.instructionBytes) > 65_536 + || typeof value.itemText !== "string" || !value.itemText || Buffer.byteLength(value.itemText) > 65_536 + || typeof value.candidateContext !== "string" || Buffer.byteLength(value.candidateContext) > 65_536 + || !Array.isArray(value.reviewerEvidence) || value.reviewerEvidence.length > 50 + || value.reviewerEvidence.some((item) => typeof item !== "string" || Buffer.byteLength(item) > 4096)) + fail("invalid invocation binding"); + return value; +} +function finalPrompt(invocation, node, current) { + return ["Burnlist Stage 1 invocation.", `run=${invocation.runId}`, `node=${node.id}`, `attempt=${invocation.attempt}`, + `claim=${current.claimId}`, `invocation=${invocation.invocationId}`, `assignment=${current.assignmentId}`, + `recipe=${current.recipeRevision}`, `policy=${current.policyRevision}`, `candidate=${current.inputCandidate}`, + `role=${node.role ?? "check"}`, "Your terminal response must be exactly one JSON object (no Markdown) with schema burnlist.agent-final@1, runId, nodeId, attempt, claimId, invocationId, assignmentId, recipeRevision, policyRevision, inputCandidate, outcome, summary.", + "FROZEN INSTRUCTIONS:", current.instructionBytes, "ASSIGNED ITEM:", current.itemText, + "CANDIDATE CONTEXT:", current.candidateContext, "REVIEW EVIDENCE:", JSON.stringify(current.reviewerEvidence)].join("\n"); +} +function finalTexts(events) { + const texts = []; + for (const event of events) { + if (event?.type !== "item.completed" || !event.item || typeof event.item !== "object" || Array.isArray(event.item) + || event.item.type !== "agent_message" || typeof event.item.text !== "string") continue; + texts.push(event.item.text); + } + if (!texts.length) fail("agent emitted no final message"); + return texts; +} +function agentResult(events, invocation, node, current) { + const texts = finalTexts(events), envelopes = []; + for (let index = 0; index < texts.length; index += 1) { + try { + const value = parseBoundedObject(Buffer.from(texts[index], "utf8"), { maximumBytes: 65_536, maximumDepth: 2, label: "agent final" }); + if (value.schema === "burnlist.agent-final@1") envelopes.push({ index, value }); + } catch { /* preceding conversational messages are permitted */ } + } + if (envelopes.length !== 1 || envelopes[0].index !== texts.length - 1) fail("malformed or ambiguous terminal agent result"); + const result = envelopes[0].value; + const keys = ["schema", "runId", "nodeId", "attempt", "claimId", "invocationId", "assignmentId", + "recipeRevision", "policyRevision", "inputCandidate", "outcome", "summary"]; + if (!exact(result, keys) || result.schema !== "burnlist.agent-final@1" || result.runId !== invocation.runId + || result.nodeId !== node.id || result.attempt !== invocation.attempt || result.invocationId !== invocation.invocationId + || result.claimId !== current.claimId || result.assignmentId !== current.assignmentId + || result.recipeRevision !== current.recipeRevision || result.policyRevision !== current.policyRevision + || result.inputCandidate !== current.inputCandidate + || !RESULT_TYPES.has(result.outcome) || typeof result.summary !== "string") fail("malformed or stale agent result"); + const allowed = node.mode === "task" ? result.outcome === "complete" : ["approve", "reject", "escalate"].includes(result.outcome); + if (!allowed) fail("agent result is illegal for node"); + return { kind: result.outcome, summary: clean(result.summary) }; +} +function boundedCompletion(handle, milliseconds) { + if (!milliseconds) return handle.completion.then((value) => ({ value, timedOut: false })); + let timer; + const timeout = new Promise((resolve) => { + timer = setTimeout(async () => { + try { handle.cancel(); } catch { /* a broken controller is still bounded */ } + const forced = await Promise.race([ + Promise.resolve(handle.completion).then((value) => ({ value }), () => ({ value: null })), + new Promise((done) => setTimeout(() => done(null), 500)), + ]); + resolve(forced ? { ...forced, timedOut: true } : { value: null, cleanupLost: true }); + }, milliseconds); + }); + // Attaching both handlers avoids a later rejected completion becoming an + // unhandled rejection after the bounded timeout result has been returned. + const settled = Promise.resolve(handle.completion).then((value) => ({ value, timedOut: false }), () => ({ value: null, timedOut: false, failed: true })); + return Promise.race([settled, timeout]).finally(() => clearTimeout(timer)); +} + +/** + * M3 adapter seam for M2's normalized invoke callback. `bindingFor` provides + * the immutable assignment/candidate pair that the M2 invocation shape lacks; + * a final cannot be accepted without both values matching exactly. + */ +export function createNormalizedInvocation({ repoRoot, routes, nodes, bindingFor, startAgent = startCodexInvocation, runCheck = runTrustedCapability, agentTimeoutMs = 0 }) { + if (typeof repoRoot !== "string" || !repoRoot.startsWith("/") || !(nodes instanceof Map) || typeof bindingFor !== "function" + || typeof startAgent !== "function" || typeof runCheck !== "function" || !Number.isSafeInteger(agentTimeoutMs) + || agentTimeoutMs < 0 || agentTimeoutMs > 86_400_000) fail("invalid dispatcher input"); + let active = null; + async function invoke(invocation) { + const node = nodes.get(invocation?.nodeId); + if (!invocation || typeof invocation !== "object" || !node || typeof node !== "object") fail("invalid invocation"); + let current; + try { current = binding(bindingFor(invocation, node)); } catch (error) { return Object.freeze({ kind: "error", summary: clean(error?.message), outputBytes: 0 }); } + if (node.mode === "review" && (!current.candidateContext || !current.reviewerEvidence.length)) + return Object.freeze({ kind: "error", summary: "review context is incomplete", outputBytes: 0 }); + if (node.kind === "check") { + try { + const checked = await runCheck({ repoRoot, capabilityId: node.capability, inputCandidate: current.inputCandidate }); + if (checked?.result?.inputCandidate !== current.inputCandidate || !["pass", "fail"].includes(checked?.result?.outcome)) fail("stale trusted check result"); + const summary = checked.result.timedOut ? "repository check timed out" : checked.result.truncated ? "repository check output limit" : `repository check ${checked.result.outcome}`; + return Object.freeze({ kind: checked.result.timedOut ? "timeout" : checked.result.outcome, summary, outputBytes: Buffer.isBuffer(checked.evidence) ? checked.evidence.length : 0 }); + } catch (error) { return Object.freeze({ kind: "error", summary: clean(error?.message), outputBytes: 0 }); } + } + const profile = route(routes, node.mode === "review" ? "review" : "implementation"); + try { + const handle = startAgent({ profile, cwd: repoRoot, prompt: finalPrompt(invocation, node, current) }); + if (!handle || typeof handle.cancel !== "function" || !handle.completion || typeof handle.completion.then !== "function") fail("agent did not return a foreground handle"); + active = handle; + const completed = await boundedCompletion(handle, agentTimeoutMs); + if (completed.cleanupLost) return Object.freeze({ kind: "lost", summary: "agent deadline cleanup unproven", outputBytes: 0 }); + if (completed.timedOut) return Object.freeze({ kind: "timeout", summary: "agent deadline exceeded", outputBytes: 0 }); + if (completed.failed) return Object.freeze({ kind: "error", summary: "agent process failed", outputBytes: 0 }); + if (completed.value.outcome === "cancelled") return Object.freeze({ kind: "cancelled", summary: "agent cancelled", outputBytes: 0 }); + if (completed.value.outcome === "quarantined") return Object.freeze({ kind: "lost", summary: "agent process cleanup unproven", outputBytes: 0 }); + if (completed.value.outcome !== "completed") return Object.freeze({ kind: "error", summary: "agent process failed", outputBytes: 0 }); + const result = agentResult(completed.value.events, invocation, node, current); + return Object.freeze({ ...result, outputBytes: Buffer.byteLength(JSON.stringify(completed.value.events), "utf8") }); + } catch (error) { return Object.freeze({ kind: "error", summary: clean(error?.message), outputBytes: 0 }); } + finally { active = null; } + } + Object.defineProperty(invoke, "cancel", { value: () => active?.cancel?.() === true, enumerable: false }); + Object.defineProperty(invoke, "active", { get: () => active !== null, enumerable: false }); + return invoke; +} diff --git a/src/loops/adapters/normalized-invocation.test.mjs b/src/loops/adapters/normalized-invocation.test.mjs new file mode 100644 index 00000000..768425bd --- /dev/null +++ b/src/loops/adapters/normalized-invocation.test.mjs @@ -0,0 +1,74 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createNormalizedInvocation } from "./normalized-invocation.mjs"; + +const profile = { id: "fake" }; +const routes = { implementation: { profile }, review: { profile: { id: "review" } } }; +const call = Object.freeze({ runId: "run:01arz3ndektsv4rrffq69g5fav", nodeId: "implement", attempt: 1, invocationId: "a".repeat(32) }); +const maker = Object.freeze({ id: "implement", kind: "agent", mode: "task", role: "maker", instructions: "make" }); +const reviewer = Object.freeze({ id: "review", kind: "agent", mode: "review", role: "reviewer", instructions: "review" }); +const check = Object.freeze({ id: "verify", kind: "check", capability: "repo-verify" }); +const current = Object.freeze({ + claimId: `cl1-sha256:${"1".repeat(64)}`, assignmentId: `as1-sha256:${"b".repeat(64)}`, + recipeRevision: `er1-sha256:${"2".repeat(64)}`, policyRevision: `bp1-sha256:${"3".repeat(64)}`, + inputCandidate: `cm1-sha256:${"a".repeat(64)}`, instructionBytes: "Do the frozen work.\n", + itemText: "- [ ] M3 | Connect processes\n", candidateContext: "candidate-v1\n", reviewerEvidence: ["verify:pass"], +}); +function final(invocation, node, outcome = "complete", extra = {}) { + return JSON.stringify({ schema: "burnlist.agent-final@1", runId: invocation.runId, nodeId: node.id, attempt: invocation.attempt, + claimId: current.claimId, invocationId: invocation.invocationId, assignmentId: current.assignmentId, + recipeRevision: current.recipeRevision, policyRevision: current.policyRevision, + inputCandidate: current.inputCandidate, outcome, summary: "ok", ...extra }); +} +function event(...texts) { return texts.map((text) => ({ type: "item.completed", item: { type: "agent_message", text } })); } +function agent(events, outcome = "completed") { return () => ({ cancel() { return true; }, completion: Promise.resolve({ outcome, events }) }); } +function dispatcher({ startAgent, runCheck, timeout = 0, bindingFor = () => current } = {}) { + return createNormalizedInvocation({ repoRoot: "/repo", routes, nodes: new Map([[maker.id, maker], [reviewer.id, reviewer], [check.id, check]]), bindingFor, + startAgent: startAgent ?? agent(event(final(call, maker))), runCheck: runCheck ?? (async () => ({ result: { outcome: "pass", inputCandidate: current.inputCandidate, timedOut: false, truncated: false }, evidence: Buffer.from("check") })), agentTimeoutMs: timeout }); +} + +test("maps actual Codex agent-message finals for maker and fresh reviewer", async () => { + const seen = []; + const invoke = dispatcher({ startAgent: ({ profile: selected, prompt }) => { + seen.push(selected.id); const node = selected.id === "review" ? reviewer : maker; const invocationId = /invocation=([a-f0-9]{32})/u.exec(prompt)[1]; + assert.match(prompt, /FROZEN INSTRUCTIONS:\nDo the frozen work\.\n/u); + assert.match(prompt, /ASSIGNED ITEM:\n- \[ \] M3 \| Connect processes\n/u); + return { cancel() { return true; }, completion: Promise.resolve({ outcome: "completed", + events: event("Working notes before the terminal envelope.", final({ ...call, nodeId: node.id, invocationId }, node, node === reviewer ? "reject" : "complete")) }) }; + } }); + assert.equal((await invoke(call)).kind, "complete"); + assert.equal((await invoke({ ...call, nodeId: "review", invocationId: "c".repeat(32) })).kind, "reject"); + assert.deepEqual(seen, ["fake", "review"]); +}); + +test("rejects malformed or stale claim, revision, candidate, assignment, and invocation finals", async () => { + for (const extra of [{ invocationId: "b".repeat(32) }, { claimId: `cl1-sha256:${"9".repeat(64)}` }, + { recipeRevision: `er1-sha256:${"8".repeat(64)}` }, { policyRevision: `bp1-sha256:${"7".repeat(64)}` }, + { inputCandidate: `cm1-sha256:${"c".repeat(64)}` }, { assignmentId: `as1-sha256:${"d".repeat(64)}` }]) { + const invoke = dispatcher({ startAgent: agent(event(final(call, maker, "complete", extra))) }); + assert.equal((await invoke(call)).kind, "error"); + } + assert.equal((await dispatcher({ startAgent: agent(event("not json")) })(call)).kind, "error"); + assert.equal((await dispatcher({ startAgent: agent(event(final(call, maker), final(call, maker))) })(call)).kind, "error"); + assert.equal((await dispatcher({ bindingFor: () => ({ inputCandidate: current.inputCandidate }) })(call)).kind, "error"); +}); + +test("maps trusted checks including timeout and stale candidate without invoking an agent", async () => { + const verify = { ...call, nodeId: "verify" }; + assert.equal((await dispatcher()(verify)).kind, "pass"); + const timeout = await dispatcher({ runCheck: async () => ({ result: { outcome: "fail", inputCandidate: current.inputCandidate, timedOut: true, truncated: false }, evidence: Buffer.from("x") }) })(verify); + assert.equal(timeout.kind, "timeout"); + const stale = await dispatcher({ runCheck: async () => ({ result: { outcome: "pass", inputCandidate: `cm1-sha256:${"f".repeat(64)}`, timedOut: false, truncated: false }, evidence: Buffer.alloc(0) }) })(verify); + assert.equal(stale.kind, "error"); +}); + +test("agent deadline becomes lost if cancellation never settles", async () => { + const invoke = dispatcher({ timeout: 10, startAgent: () => ({ cancel() { return true; }, completion: new Promise(() => {}) }) }); + assert.deepEqual(await invoke(call), { kind: "lost", summary: "agent deadline cleanup unproven", outputBytes: 0 }); +}); + +test("summaries are valid UTF-8 and limited to 1024 bytes", async () => { + const result = await dispatcher({ startAgent: agent(event(final(call, maker, "complete", { summary: "€".repeat(500) }))) })(call); + assert.ok(Buffer.byteLength(result.summary, "utf8") <= 1024); + assert.doesNotThrow(() => Buffer.from(result.summary, "utf8").toString("utf8")); +}); diff --git a/src/loops/agents/profile.mjs b/src/loops/agents/profile.mjs new file mode 100644 index 00000000..d5ebd1e6 --- /dev/null +++ b/src/loops/agents/profile.mjs @@ -0,0 +1,128 @@ +import { isAbsolute } from "node:path"; +import { prefixed } from "../dsl/hash.mjs"; + +export const AGENT_PROFILE_SCHEMA = "burnlist-loop-agent-profile@1"; +export const STAGE_ONE_ROUTES = Object.freeze(["implementation.standard", "review.strong"]); +export const CODEX_MODELS = Object.freeze(["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", "gpt-5.3-codex-spark"]); +export const REASONING_EFFORTS = Object.freeze(["minimal", "low", "medium", "high", "xhigh", "max"]); + +const slug = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u; +const controls = /[\0\r\n]/u; +const guarantee = new Set(["enforced", "detected-at-boundaries", "supervised", "unsupported"]); +const requiredReviewerGuarantees = Object.freeze({ freshSession: "enforced", filesystemWriteDeny: "supervised" }); + +function fail(message, code = "ELOOP_AGENT_PROFILE") { throw Object.assign(new Error(`Loop agent: ${message}`), { code }); } +function exact(value, keys) { return Boolean(value) && typeof value === "object" && !Array.isArray(value) && Object.keys(value).length === keys.length && keys.every((key) => Object.hasOwn(value, key)); } +function text(value, label, maximum = 4096) { + if (typeof value !== "string" || !value || Buffer.byteLength(value) > maximum || controls.test(value)) fail(`invalid ${label}`); + return value; +} +function identity(value, label) { + if (!exact(value, ["adapter", "binary", "model", "effort", "sandbox"])) fail(`invalid ${label} identity`); + if (value.adapter !== "builtin:codex-cli" || !isAbsolute(text(value.binary, `${label} binary`)) || !CODEX_MODELS.includes(value.model) || !REASONING_EFFORTS.includes(value.effort) || !["workspace-write", "read-only"].includes(value.sandbox)) fail(`invalid ${label} identity`); + return { adapter: value.adapter, binary: value.binary, model: value.model, effort: value.effort, sandbox: value.sandbox }; +} +function guarantees(value) { + const keys = ["freshSession", "filesystemWriteDeny", "foregroundHandle", "cancellation", "lifecycle", "usage"]; + if (!exact(value, keys) || keys.some((key) => key === "usage" ? !["reported", "unavailable"].includes(value[key]) : !guarantee.has(value[key]))) fail("invalid probe guarantees"); + return Object.fromEntries(keys.map((key) => [key, value[key]])); +} +function validInvocation(requested, argv) { + return Array.isArray(argv) && argv.length === 15 + && argv[0] === requested.binary && argv[1] === "exec" && argv[2] === "--json" && argv[3] === "--ephemeral" + && argv[4] === "-m" && argv[5] === requested.model && argv[6] === "-c" && argv[7] === `model_reasoning_effort=${requested.effort}` + && argv[8] === "-s" && argv[9] === requested.sandbox && argv[10] === "-C" + && typeof argv[11] === "string" && isAbsolute(argv[11]) && !controls.test(argv[11]) + && argv[12] === "--skip-git-repo-check" && typeof argv[13] === "string" && argv[13] === "--" + && typeof argv[14] === "string" && argv[14].length > 0 && Buffer.byteLength(argv[14]) <= 262144 && !argv[14].includes("\0"); +} + +/** Closed local profile. It requests an identity and authority but proves neither. */ +export function validateAgentProfile(value) { + const keys = ["schema", "id", "adapter", "binary", "model", "effort", "authority"]; + if (!exact(value, keys) || value.schema !== AGENT_PROFILE_SCHEMA || !slug.test(value.id)) fail("invalid profile"); + if (value.adapter !== "builtin:codex-cli" || !isAbsolute(text(value.binary, "binary")) || !["read", "write"].includes(value.authority)) fail("invalid profile"); + if (!CODEX_MODELS.includes(value.model)) fail(`model must be one of: ${CODEX_MODELS.join(", ")}`); + if (!REASONING_EFFORTS.includes(value.effort)) fail(`effort must be one of: ${REASONING_EFFORTS.join(", ")}`); + return Object.freeze({ schema: value.schema, id: value.id, adapter: value.adapter, binary: value.binary, model: value.model, effort: value.effort, authority: value.authority }); +} + +/** Provider statements and host-observed invocation evidence remain separate. */ +export function validateCodexProbe(value) { + if (!exact(value, ["schema", "requested", "providerReported", "technicallyProven", "guarantees"]) || value.schema !== "burnlist-codex-probe@1") fail("invalid Codex probe"); + const requested = identity(value.requested, "requested"); + if (!exact(value.providerReported, ["model", "sessionId", "version"]) || typeof value.providerReported.sessionId !== "string" || !value.providerReported.sessionId || [value.providerReported.model, value.providerReported.version].some((item) => item !== null && typeof item !== "string") || [value.providerReported.sessionId, value.providerReported.model, value.providerReported.version].filter((item) => item !== null).some((item) => !item || Buffer.byteLength(item) > 512 || controls.test(item))) fail("invalid provider-reported identity"); + if (!exact(value.technicallyProven, ["argv", "pidObserved"]) || value.technicallyProven.pidObserved !== true || !validInvocation(requested, value.technicallyProven.argv)) fail("invalid technically-proven identity"); + return Object.freeze({ + schema: value.schema, + requested, + providerReported: { model: value.providerReported.model, sessionId: value.providerReported.sessionId, version: value.providerReported.version }, + technicallyProven: { argv: [...value.technicallyProven.argv], pidObserved: true }, + guarantees: guarantees(value.guarantees), + }); +} + +export function requestedCodexIdentity(profile) { + const current = validateAgentProfile(profile); + return Object.freeze({ adapter: current.adapter, binary: current.binary, model: current.model, effort: current.effort, sandbox: current.authority === "write" ? "workspace-write" : "read-only" }); +} +export function agentProfileRevision(profile) { + const current = validateAgentProfile(profile); + return prefixed("ap1-sha256:", "agent-profile-v1", [Buffer.from(`${JSON.stringify(current)}\n`)]); +} +function sameIdentity(left, right) { return left.adapter === right.adapter && left.binary === right.binary && left.model === right.model && left.effort === right.effort && left.sandbox === right.sandbox; } +function routeMap(value) { + if (!exact(value, STAGE_ONE_ROUTES)) fail("Stage 1 routes must be closed"); + for (const route of STAGE_ONE_ROUTES) if (!slug.test(value[route])) fail(`invalid ${route} route`); + return value; +} + +/** Resolve M1 configuration authority without launching or probing an agent. */ +export function resolveConfiguredStageOneRoutes({ profiles, routes }) { + if (!Array.isArray(profiles) || profiles.length < 2 || profiles.length > 32) fail("invalid profiles"); + const byId = new Map(); + for (const profile of profiles.map(validateAgentProfile)) { + if (byId.has(profile.id)) fail("duplicate profile id"); + byId.set(profile.id, profile); + } + const mapped = routeMap(routes); + if (mapped["implementation.standard"] === mapped["review.strong"]) fail("implementation and review require distinct profile ids", "ELOOP_REVIEWER_ISOLATION"); + const resolveRoute = (route, authority) => { + const profile = byId.get(mapped[route]); + if (!profile) fail(`route ${route} references an unknown profile`); + if (profile.authority !== authority) fail(`route ${route} requires ${authority} authority`); + return Object.freeze({ route, profile, authority: profile.authority }); + }; + const implementation = resolveRoute("implementation.standard", "write"); + const review = resolveRoute("review.strong", "read"); + return Object.freeze({ + implementation: Object.freeze({ ...implementation, guarantees: Object.freeze({}) }), + review: Object.freeze({ ...review, guarantees: Object.freeze({ freshSession: "enforced", filesystemWriteDeny: "supervised" }) }), + }); +} + +/** Bind only the two Stage 1 routes; hard reviewer guarantees never downgrade. */ +export function resolveStageOneRoutes({ profiles, routes, probes }) { + if (!Array.isArray(profiles) || profiles.length < 2 || profiles.length > 32) fail("invalid profiles"); + const byId = new Map(); + for (const profile of profiles.map(validateAgentProfile)) { + if (byId.has(profile.id)) fail("duplicate profile id"); + byId.set(profile.id, profile); + } + const mapped = routeMap(routes); + if (!probes || typeof probes !== "object" || Array.isArray(probes)) fail("invalid probe map"); + const resolveRoute = (route, authority) => { + const profile = byId.get(mapped[route]); + if (!profile) fail(`route ${route} references an unknown profile`); + if (profile.authority !== authority) fail(`route ${route} requires ${authority} authority`); + const probe = validateCodexProbe(probes[profile.id]); const requested = requestedCodexIdentity(profile); + if (!sameIdentity(probe.requested, requested) || probe.providerReported.model !== null && probe.providerReported.model !== requested.model) fail(`probe does not bind profile ${profile.id}`); + return Object.freeze({ route, profile, requested, providerReported: probe.providerReported, technicallyProven: probe.technicallyProven, guarantees: probe.guarantees }); + }; + const implementation = resolveRoute("implementation.standard", "write"); + const review = resolveRoute("review.strong", "read"); + if (implementation.profile.id === review.profile.id || implementation.providerReported.sessionId === review.providerReported.sessionId) fail("implementation and review require independent provider sessions", "ELOOP_REVIEWER_ISOLATION"); + for (const [name, expected] of Object.entries(requiredReviewerGuarantees)) + if (review.guarantees[name] !== expected) fail(`review route lacks ${expected} ${name}`, "ELOOP_REVIEWER_ISOLATION"); + return Object.freeze({ implementation, review }); +} diff --git a/src/loops/agents/profile.test.mjs b/src/loops/agents/profile.test.mjs new file mode 100644 index 00000000..4c9af72c --- /dev/null +++ b/src/loops/agents/profile.test.mjs @@ -0,0 +1,32 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { requestedCodexIdentity, resolveConfiguredStageOneRoutes, validateAgentProfile, validateCodexProbe } from "./profile.mjs"; + +const profile = { schema: "burnlist-loop-agent-profile@1", id: "reviewer", adapter: "builtin:codex-cli", binary: "/usr/local/bin/codex", model: "gpt-5.6-sol", effort: "medium", authority: "read" }; +const requested = requestedCodexIdentity(profile); +const argv = [profile.binary, "exec", "--json", "--ephemeral", "-m", profile.model, "-c", "model_reasoning_effort=medium", "-s", "read-only", "-C", "/tmp/repo", "--skip-git-repo-check", "--", "Review."]; + +test("agent profiles are closed and request a role-specific sandbox", () => { + assert.deepEqual(requested, { adapter: "builtin:codex-cli", binary: profile.binary, model: profile.model, effort: profile.effort, sandbox: "read-only" }); + assert.throws(() => validateAgentProfile({ ...profile, unknown: true }), /invalid profile/u); + assert.throws(() => validateAgentProfile({ ...profile, binary: "codex" }), /invalid profile/u); +}); + +test("probe contract rejects non-ephemeral or identity-mismatched launch proof", () => { + const value = { schema: "burnlist-codex-probe@1", requested, providerReported: { model: profile.model, sessionId: "provider-session", version: "1.2.3" }, technicallyProven: { argv, pidObserved: true }, guarantees: { freshSession: "enforced", filesystemWriteDeny: "enforced", foregroundHandle: "enforced", cancellation: "enforced", lifecycle: "enforced", usage: "unavailable" } }; + assert.deepEqual(validateCodexProbe(value).technicallyProven.argv, argv); + assert.equal(validateCodexProbe({ ...value, providerReported: { model: null, sessionId: "provider-session", version: null } }).providerReported.model, null); + assert.throws(() => validateCodexProbe({ ...value, technicallyProven: { ...value.technicallyProven, argv: argv.filter((item) => item !== "--ephemeral") } }), /technically-proven/u); + assert.throws(() => validateCodexProbe({ ...value, requested: { ...requested, model: "gpt-5.6-terra" } }), /technically-proven/u); +}); + +test("configured Stage 1 routes have exact names, distinct profiles, and honest M1 guarantees", () => { + const maker = { ...profile, id: "maker", authority: "write" }; + const resolved = resolveConfiguredStageOneRoutes({ profiles: [maker, profile], routes: { "implementation.standard": "maker", "review.strong": "reviewer" } }); + assert.equal(resolved.implementation.profile.id, "maker"); + assert.equal(resolved.implementation.authority, "write"); + assert.equal(resolved.review.profile.id, "reviewer"); + assert.deepEqual(resolved.review.guarantees, { freshSession: "enforced", filesystemWriteDeny: "supervised" }); + assert.throws(() => resolveConfiguredStageOneRoutes({ profiles: [maker, profile], routes: { "implementation.standard": "maker", "review.strong": "maker" } }), /distinct profile ids/u); + assert.throws(() => resolveConfiguredStageOneRoutes({ profiles: [maker, profile], routes: { "implementation.standard": "reviewer", "review.strong": "maker" } }), /requires write authority/u); +}); diff --git a/src/loops/assignment/assignment.mjs b/src/loops/assignment/assignment.mjs new file mode 100644 index 00000000..e7fafb42 --- /dev/null +++ b/src/loops/assignment/assignment.mjs @@ -0,0 +1,104 @@ +import { createHash, randomBytes } from "node:crypto"; +import { closeSync, constants, fsyncSync, openSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; +import { basename, dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { compileLoopPackage } from "../dsl/compile.mjs"; +import { findBurnlistDir, withLock } from "../../cli/lifecycle-moves.mjs"; +import { assignmentStore } from "./store.mjs"; +import { buildAssignment, containsLoopMarker, itemDigest, locateItemSpan, validateAssignedItem } from "./item-metadata.mjs"; +import { parseItemRef, parseLoopRef } from "./selectors.mjs"; +import { repositoryHazardAuthority } from "./hazards.mjs"; + +function raw(bytes) { return createHash("sha256").update(bytes).digest("hex"); } +function fail(message) { throw new Error(`Loop assignment: ${message}`); } +function syncDirectory(path) { const fd = openSync(path, constants.O_RDONLY); try { fsyncSync(fd); } finally { closeSync(fd); } } +function atomicWrite(path, bytes) { + const temp = join(dirname(path), `.${basename(path)}.${randomBytes(8).toString("hex")}.tmp`); let fd; + try { fd = openSync(temp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o666); writeFileSync(fd, bytes); fsyncSync(fd); closeSync(fd); fd = undefined; renameSync(temp, path); syncDirectory(dirname(path)); } + finally { if (fd !== undefined) closeSync(fd); rmSync(temp, { force: true }); } +} + +function packageDirectory(loop) { + if (loop.name !== "review") fail(`installed Loop ${loop.selector} was not found`); + return resolve(dirname(fileURLToPath(import.meta.url)), "../../../loops/review"); +} +export async function resolveBuiltin(loop) { + const compiled = await compileLoopPackage(packageDirectory(loop)); + if (!compiled.ok) fail(`installed Loop ${loop.selector} does not compile`); + if (loop.executable && loop.executable !== compiled.revisions.executable) fail("LoopRef executable pin does not match current package"); + return compiled; +} + +function planPath(repoRoot, item) { + const found = findBurnlistDir(repoRoot, item.burnlistId); + if (found.lifecycle.folder !== "inprogress") fail(`burnlist ${item.burnlistId} is not inprogress`); + return { directory: found.dir, path: join(found.dir, "burnlist.md") }; +} +function assertExpected(expected, whole, span, itemRef) { + if (!expected?.wholeDigest || !expected?.itemDigest) fail("prepared CAS token is required"); + if (expected.itemDigest !== buildItemDigest(itemRef, span)) fail("item CAS failed"); + if (expected.wholeDigest !== raw(whole)) fail("whole-file CAS failed"); +} +function buildItemDigest(itemRef, span) { return itemDigest(itemRef, span); } +function assertHazards(authority, input) { + const hazards = authority(input); + if (!Array.isArray(hazards)) fail("hazard authority returned invalid result"); + if (hazards.length) fail(`unsafe while ${hazards.join(", ")}`); +} + +/** Snapshot before source resolution; the locked mutation compares both bytes. */ +export function prepareItemMutation({ repoRoot, itemRef }) { + const item = parseItemRef(itemRef), target = planPath(repoRoot, item); + const whole = readFileSync(target.path), located = locateItemSpan(whole, item.itemId); + return { item, target, wholeDigest: raw(whole), itemDigest: itemDigest(item.selector, located.span) }; +} + +/** CLI-owned assignment: artifact first, then exactly one locked plan rewrite. */ +export async function assignLoopItem({ repoRoot, itemRef, loopRef, prepared, store = assignmentStore(repoRoot) }) { + const item = parseItemRef(itemRef), loop = parseLoopRef(loopRef); + const token = prepared ?? prepareItemMutation({ repoRoot, itemRef: item.selector }); + if (token.item?.selector !== item.selector) fail("prepared token item does not match"); + const compiled = await resolveBuiltin(loop); + const target = planPath(repoRoot, item); const canonicalSelector = loop.selector; + return withLock(target.directory, () => { + const whole = readFileSync(target.path); const located = locateItemSpan(whole, item.itemId); + assertExpected(token, whole, located.span, item.selector); + if (containsLoopMarker(located)) fail("duplicate, malformed, or handwritten Loop metadata"); + const assignment = buildAssignment(item.selector, located, { + selector: canonicalSelector, executable: compiled.revisions.executable, packageRevision: compiled.revisions.package, + }); + const after = Buffer.concat([whole.subarray(0, located.startByte), assignment.assignedSpan, whole.subarray(located.endByte)]); + store.save({ assignmentId: assignment.assignmentId, itemRef: item.selector, selector: canonicalSelector, + executionRevision: compiled.revisions.executable, packageRevision: compiled.revisions.package, + sourceRevision: compiled.revisions.source, unassignedItemDigest: assignment.unassignedDigest, + assignedItemDigest: assignment.assignedDigest }, compiled); + atomicWrite(target.path, after); + return { assignmentId: assignment.assignmentId, selector: canonicalSelector, executionRevision: compiled.revisions.executable, + packageRevision: compiled.revisions.package, unassignedItemDigest: assignment.unassignedDigest, assignedItemDigest: assignment.assignedDigest }; + }); +} + +/** Remove only a verified canonical block after a caller proves there are no Run hazards. */ +export function unassignLoopItem({ repoRoot, itemRef, prepared, hazardAuthority = repositoryHazardAuthority(repoRoot), store = assignmentStore(repoRoot) }) { + const item = parseItemRef(itemRef), token = prepared ?? prepareItemMutation({ repoRoot, itemRef: item.selector }); const target = planPath(repoRoot, item); + if (token.item?.selector !== item.selector) fail("prepared token item does not match"); + return withLock(target.directory, () => { + const whole = readFileSync(target.path), located = locateItemSpan(whole, item.itemId); + assertExpected(token, whole, located.span, item.selector); + const assignment = validateAssignedItem(item.selector, located); + const artifact = store.load(assignment["Assignment-Id"]); + if (artifact.itemRef !== item.selector || artifact.assignedItemDigest !== assignment.assignedDigest || artifact.unassignedItemDigest !== assignment.unassignedDigest || artifact.packageRevision !== assignment["Package-Revision"]) fail("stale assignment artifact"); + assertHazards(hazardAuthority, { repoRoot, itemRef: item.selector, assignmentId: assignment["Assignment-Id"], action: "unassign" }); + const after = Buffer.concat([whole.subarray(0, located.startByte), assignment.unassignedSpan, whole.subarray(located.endByte)]); + atomicWrite(target.path, after); + return { assignmentId: assignment["Assignment-Id"], unassignedItemDigest: assignment.unassignedDigest }; + }); +} + +/** Shared legacy gate. Terminal history alone is deliberately not a hazard. */ +export function assertDirectBurnAllowed({ repoRoot, itemRef, markdown, hazardAuthority = repositoryHazardAuthority(repoRoot) }) { + const item = parseItemRef(itemRef); const located = locateItemSpan(markdown, item.itemId); + if (containsLoopMarker(located)) fail("direct burn is blocked by Loop metadata"); + assertHazards(hazardAuthority, { repoRoot, itemRef: item.selector, assignmentId: null, action: "burn" }); + return located; +} diff --git a/src/loops/assignment/assignment.test.mjs b/src/loops/assignment/assignment.test.mjs new file mode 100644 index 00000000..86fb51fc --- /dev/null +++ b/src/loops/assignment/assignment.test.mjs @@ -0,0 +1,299 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { chmodSync, cpSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import test from "node:test"; +import { burnItem } from "../../cli/lifecycle-moves.mjs"; +import { assignLoopItem, assertDirectBurnAllowed, prepareItemMutation, resolveBuiltin, unassignLoopItem } from "./assignment.mjs"; +import { repositoryHazardAuthority } from "./hazards.mjs"; +import { resolveLoopAuthority, selectNonterminalRun } from "./resolver.mjs"; +import { assignmentDigest, buildAssignment, itemDigest, locateItemSpan, validateAssignedItem } from "./item-metadata.mjs"; +import { parseItemRef, parseLoopRef, parseRunRef, selectorKind } from "./selectors.mjs"; +import { assignmentStore } from "./store.mjs"; +import { runStore } from "../run/run-store.mjs"; +import { testGraph } from "../run/m2-test-fixtures.mjs"; + +const project = resolve(new URL("../../..", import.meta.url).pathname); +function fixture() { + const root = mkdtempSync(join(tmpdir(), "burnlist-assignment-")); + const repo = join(root, "repo"), dir = join(repo, "notes", "burnlists", "inprogress", "260710-001"); + mkdirSync(dir, { recursive: true }); + const plan = join(dir, "burnlist.md"); + writeFileSync(plan, "# Test\n\n## Active Checklist\n- [ ] BUG-07 | Fix exact bytes\n Action: keep this line\n\n## Completed\n"); + return { repo, plan, cleanup: () => rmSync(root, { recursive: true, force: true }) }; +} +function ref() { return "item:260710-001#BUG-07"; } + +test("selectors are closed, disjoint, and reject noncanonical ULID overflow", () => { + assert.deepEqual(parseLoopRef("loop:builtin:review@er1-sha256:" + "a".repeat(64)), { selector: "loop:builtin:review", name: "review", executable: "er1-sha256:" + "a".repeat(64) }); + assert.equal(parseItemRef(ref()).itemId, "BUG-07"); + assert.equal(parseRunRef("run:01arz3ndektsv4rrffq69g5fav").id.length, 26); + for (const value of ["review", "item:260710-001#BUG-07", "run:81arz3ndektsv4rrffq69g5fav", "run:01ARZ3NDEKTSV4RRFFQ69G5FAV"]) assert.throws(() => parseRunRef(value), TypeError); + assert.equal(selectorKind("loop:builtin:review"), "loop"); assert.equal(selectorKind(ref()), "item"); +}); + +test("assignment writes canonical bytes, stores artifact before one replacement, and unassign restores exact bytes", async () => { + const context = fixture(); + try { + const before = readFileSync(context.plan); const result = await assignLoopItem({ repoRoot: context.repo, itemRef: ref(), loopRef: "loop:builtin:review" }); + const assigned = readFileSync(context.plan); + assert.match(assigned.toString(), new RegExp(` Loop:\\n Assignment-Id: ${result.assignmentId}\\n Selector: loop:builtin:review\\n Execution-Revision: ${result.executionRevision}\\n Package-Revision: ${result.packageRevision}\\n`, "u")); + const assignedSpan = locateItemSpan(assigned, "BUG-07"); const checked = validateAssignedItem(ref(), assignedSpan); + assert.equal(checked.assignedDigest, result.assignedItemDigest); + const artifactPath = join(context.repo, ".local", "burnlist", "loop", "v2", "assignments", result.assignmentId.slice(11)); + assert.equal(lstatSync(artifactPath).mode & 0o777, 0o700); + assert.equal(readFileSync(join(artifactPath, "recipe.frozen")).includes("burnlist-loop-frozen@1"), true); + assert.throws(() => burnItem(context.repo, "260710-001", "BUG-07"), /Loop metadata/u); + unassignLoopItem({ repoRoot: context.repo, itemRef: ref() }); + assert.deepEqual(readFileSync(context.plan), before); + assert.doesNotThrow(() => assertDirectBurnAllowed({ repoRoot: context.repo, itemRef: ref(), markdown: before })); + } finally { context.cleanup(); } +}); + +test("assignment pin is verified and duplicate, malformed, stale, and edited metadata fail closed", async () => { + const context = fixture(); + try { + await assert.rejects(assignLoopItem({ repoRoot: context.repo, itemRef: ref(), loopRef: `loop:builtin:review@er1-sha256:${"0".repeat(64)}` }), /pin does not match/u); + const first = await assignLoopItem({ repoRoot: context.repo, itemRef: ref(), loopRef: "loop:builtin:review" }); + await assert.rejects(assignLoopItem({ repoRoot: context.repo, itemRef: ref(), loopRef: "loop:builtin:review" }), /metadata/u); + writeFileSync(context.plan, readFileSync(context.plan, "utf8").replace("Fix exact bytes", "Edited bytes")); + assert.throws(() => unassignLoopItem({ repoRoot: context.repo, itemRef: ref() }), /stale or handwritten assignment id/u); + assert.throws(() => assertDirectBurnAllowed({ repoRoot: context.repo, itemRef: ref(), markdown: readFileSync(context.plan) }), /Loop metadata/u); + assert.match(first.assignmentId, /^as1-sha256:/u); + } finally { context.cleanup(); } +}); + +test("assignment and unassign CAS plus hazard authority reject competing or active state", async () => { + const context = fixture(); + try { + const prepared = prepareItemMutation({ repoRoot: context.repo, itemRef: ref() }); + writeFileSync(context.plan, `${readFileSync(context.plan, "utf8")}\n`); + await assert.rejects(assignLoopItem({ repoRoot: context.repo, itemRef: ref(), loopRef: "loop:builtin:review", prepared }), /whole-file CAS/u); + const result = await assignLoopItem({ repoRoot: context.repo, itemRef: ref(), loopRef: "loop:builtin:review" }); + assert.throws(() => unassignLoopItem({ repoRoot: context.repo, itemRef: ref(), hazardAuthority: () => ["nonterminal run", "retained lease"] }), /nonterminal run, retained lease/u); + assert.throws(() => assertDirectBurnAllowed({ repoRoot: context.repo, itemRef: ref(), markdown: readFileSync(context.plan), hazardAuthority: () => ["quarantine"] }), /Loop metadata/u); + assert.match(result.unassignedItemDigest, /^id1-sha256:/u); + } finally { context.cleanup(); } +}); + +test("legacy burn remains unchanged when unassigned but accepts an injectable Run hazard authority", () => { + const context = fixture(); + try { + assert.throws(() => burnItem(context.repo, "260710-001", "BUG-07", false, { hazardAuthority: () => ["unreconciled checkout"] }), /unreconciled checkout/u); + assert.equal(burnItem(context.repo, "260710-001", "BUG-07"), true); + } finally { context.cleanup(); } +}); + +test("framed assignment identities use the exact unassigned and assigned item bytes", () => { + const bytes = Buffer.from("- [ ] BUG-07 | T\n\n"); const located = locateItemSpan(bytes, "BUG-07"); + const built = buildAssignment(ref(), located, { selector: "loop:builtin:review", executable: `er1-sha256:${"1".repeat(64)}`, packageRevision: `lp1-sha256:${"2".repeat(64)}` }); + assert.equal(built.unassignedDigest, itemDigest(ref(), bytes)); + assert.equal(built.assignmentId, assignmentDigest(ref(), built.unassignedDigest, "loop:builtin:review", `er1-sha256:${"1".repeat(64)}`)); + assert.equal(built.assignedDigest, itemDigest(ref(), built.assignedSpan)); +}); + +test("locked CLI accepts only prefixed state-changing selectors", () => { + const context = fixture(); + try { + const bin = join(project, "bin", "burnlist.mjs"); + const run = (...args) => execFileSync(process.execPath, [bin, ...args], { cwd: context.repo, encoding: "utf8" }); + assert.match(run("loop", "assign", ref(), "loop:builtin:review"), /^as1-sha256:/u); + assert.throws(() => run("loop", "assign", "260710-001#BUG-07", "review"), /Invalid ItemRef/u); + assert.match(run("loop", "unassign", ref()), /^as1-sha256:/u); + } finally { context.cleanup(); } +}); + +test("repository hazard authority reads production Runs and permits only safe terminal histories", async () => { + const context = fixture(); + try { + const authority = repositoryHazardAuthority(context.repo); + const bin = join(project, "bin", "burnlist.mjs"); + assert.deepEqual(authority({ itemRef: ref() }), []); + const result = await assignLoopItem({ repoRoot: context.repo, itemRef: ref(), loopRef: "loop:builtin:review" }); + const store = runStore(context.repo), first = "run:01arz3ndektsv4rrffq69g5fav"; + store.createRun({ runId: first, itemRef: ref(), graph: testGraph }); + assert.deepEqual(authority({ itemRef: ref() }), [`nonterminal Run ${first}`]); + assert.throws(() => unassignLoopItem({ repoRoot: context.repo, itemRef: ref() }), /nonterminal Run/u); + const lease = store.acquireLease(first).lease; + assert.deepEqual(authority({ itemRef: ref() }), ["nonterminal Run run:01arz3ndektsv4rrffq69g5fav"]); + store.terminalize(first, lease, "cancelled", "test"); + assert.deepEqual(authority({ itemRef: ref() }), []); + assert.match(unassignLoopItem({ repoRoot: context.repo, itemRef: ref() }).assignmentId, /^as1/u); + assert.match(execFileSync(process.execPath, [bin, "burn", "260710-001", "BUG-07"], { cwd: context.repo, encoding: "utf8" }), /^$/u); + assert.match(result.assignmentId, /^as1/u); + const converged = fixture(); + try { + await assignLoopItem({ repoRoot: converged.repo, itemRef: ref(), loopRef: "loop:builtin:review" }); + const convergedStore = runStore(converged.repo), runId = "run:01arz3ndektsv4rrffq69g5faw"; + convergedStore.createRun({ runId, itemRef: ref(), graph: testGraph }); + convergedStore.terminalize(runId, convergedStore.acquireLease(runId).lease, "converged", "test"); + assert.deepEqual(repositoryHazardAuthority(converged.repo)({ itemRef: ref() }), [`completion-pending Run ${runId}`]); + assert.throws(() => unassignLoopItem({ repoRoot: converged.repo, itemRef: ref() }), /completion-pending Run/u); + } finally { converged.cleanup(); } + } finally { context.cleanup(); } +}); + +test("artifact is immutable, contained, and resolver selects only its declared authority", async () => { + const context = fixture(); + try { + const result = await assignLoopItem({ repoRoot: context.repo, itemRef: ref(), loopRef: "loop:builtin:review" }); + const artifact = join(context.repo, ".local", "burnlist", "loop", "v2", "assignments", result.assignmentId.slice(11)); + assert.equal(lstatSync(join(artifact, "manifest.json")).mode & 0o777, 0o600); + const item = await resolveLoopAuthority({ repoRoot: context.repo, selector: ref() }); + assert.equal(item.authority, "ITEM-PINNED"); assert.equal(item.executableDrift, false); + assert.equal((await resolveLoopAuthority({ repoRoot: context.repo, selector: "review" })).authority, "UNPINNED"); + await assert.rejects(resolveLoopAuthority({ repoRoot: context.repo, selector: "run:01arz3ndektsv4rrffq69g5fav" }), /E_RUN_UNAVAILABLE/u); + rmSync(join(artifact, "recipe.frozen")); symlinkSync("/etc/hosts", join(artifact, "recipe.frozen")); + await assert.rejects(resolveLoopAuthority({ repoRoot: context.repo, selector: ref() }), /ELOOP_PIN_BYTES_UNAVAILABLE/u); + } finally { context.cleanup(); } +}); + +test("assignment artifact authority rejects persistent ancestor and leaf symlinks", async () => { + const context = fixture(); + try { + const outside = join(dirname(context.repo), "outside"), loop = join(context.repo, ".local", "burnlist", "loop"); + mkdirSync(join(context.repo, ".local", "burnlist"), { recursive: true }); mkdirSync(outside); + symlinkSync(outside, loop); + await assert.rejects(assignLoopItem({ repoRoot: context.repo, itemRef: ref(), loopRef: "loop:builtin:review" }), /unsafe directory loop/u); + assert.deepEqual(readdirSync(outside), []); + rmSync(loop); const result = await assignLoopItem({ repoRoot: context.repo, itemRef: ref(), loopRef: "loop:builtin:review" }); + const assignments = join(context.repo, ".local", "burnlist", "loop", "v2", "assignments"); + const artifact = join(assignments, result.assignmentId.slice(11)), saved = `${artifact}.saved`; + renameSync(artifact, saved); symlinkSync(saved, artifact); + await assert.rejects(resolveLoopAuthority({ repoRoot: context.repo, selector: ref() }), /ELOOP_PIN_BYTES_UNAVAILABLE/u); + rmSync(artifact); renameSync(saved, artifact); + const outsideAssignments = join(outside, "assignments"); cpSync(assignments, outsideAssignments, { recursive: true }); + renameSync(assignments, `${assignments}.saved`); symlinkSync(outsideAssignments, assignments); + await assert.rejects(resolveLoopAuthority({ repoRoot: context.repo, selector: ref() }), /ELOOP_PIN_BYTES_UNAVAILABLE/u); + } finally { context.cleanup(); } +}); + +test("load and inspect anchor reads before deterministic leaf replacement cuts", async () => { + const context = fixture(); + try { + const result = await assignLoopItem({ repoRoot: context.repo, itemRef: ref(), loopRef: "loop:builtin:review" }); + const plain = assignmentStore(context.repo).load(result.assignmentId); + for (const method of ["load", "inspect"]) { + let fired = false; + const store = assignmentStore(context.repo, { onCut(cut, detail) { + if (cut !== "read" || fired) return; + fired = true; const moved = `${detail.target}.moved`; + renameSync(detail.target, moved); mkdirSync(detail.target, { mode: 0o700 }); + } }); + assert.throws(() => store[method](result.assignmentId), /required artifact bytes are missing|unsafe directory/u); + const artifact = store.pathFor(result.assignmentId); + rmSync(artifact, { recursive: true }); renameSync(`${artifact}.moved`, artifact); + } + assert.equal(plain.assignmentId, result.assignmentId); + } finally { context.cleanup(); } +}); + +test("actual save collision and publication cuts cannot escape the anchored assignment root", async () => { + const context = fixture(); + try { + const result = await assignLoopItem({ repoRoot: context.repo, itemRef: ref(), loopRef: "loop:builtin:review" }); + const originalStore = assignmentStore(context.repo), original = originalStore.load(result.assignmentId); + const compiled = await resolveBuiltin(parseLoopRef("loop:builtin:review")); + const outside = join(dirname(context.repo), "cut-outside"), canary = join(outside, "canary"); + mkdirSync(outside); writeFileSync(canary, "untouched", { mode: 0o600 }); + let fired = false; + const collision = assignmentStore(context.repo, { onCut(cut, detail) { + if (cut !== "collision" || fired) return; + fired = true; const moved = `${detail.target}.moved`; renameSync(detail.target, moved); symlinkSync(outside, detail.target); + } }); + assert.throws(() => collision.save(original, compiled), /unsafe directory/u); + const artifact = collision.pathFor(result.assignmentId); + rmSync(artifact); renameSync(`${artifact}.moved`, artifact); + const collisionChild = assignmentStore(context.repo, { workerCut: "move-collision-between-files", workerOutside: outside }); + assert.throws(() => collisionChild.save(original, compiled), /ancestor authority/u); + const movedCollision = join(outside, `${result.assignmentId.slice(11)}.moved`); + renameSync(movedCollision, artifact); + fired = false; + const next = { ...original, assignmentId: `as1-sha256:${"a".repeat(64)}` }; + const publication = assignmentStore(context.repo, { onCut(cut, detail) { + if (cut !== "publication" || fired) return; + fired = true; const moved = `${detail.root}.moved`; renameSync(detail.root, moved); symlinkSync(outside, detail.root); + } }); + assert.throws(() => publication.save(next, compiled), /unsafe directory/u); + assert.equal(readFileSync(canary, "utf8"), "untouched"); + const assignments = join(context.repo, ".local", "burnlist", "loop", "v2", "assignments"); + rmSync(assignments); renameSync(`${assignments}.moved`, assignments); + fired = false; + const abaRecord = { ...original, assignmentId: `as1-sha256:${"b".repeat(64)}` }; + const aba = assignmentStore(context.repo, { onCut(cut, detail) { + if (cut !== "publication" || fired) return; + fired = true; const moved = `${detail.root}.aba`; + renameSync(detail.root, moved); symlinkSync(outside, detail.root); rmSync(detail.root); renameSync(moved, detail.root); + } }); + assert.equal(aba.save(abaRecord, compiled), aba.pathFor(abaRecord.assignmentId)); + assert.equal(readFileSync(canary, "utf8"), "untouched"); + const tempRecord = { ...original, assignmentId: `as1-sha256:${"c".repeat(64)}` }; + const tempSwap = assignmentStore(context.repo, { workerCut: "move-temp-before-recipe", workerOutside: outside }); + assert.throws(() => tempSwap.save(tempRecord, compiled), /temporary directory escaped|ancestor authority mutation/u); + assert.equal(readFileSync(canary, "utf8"), "untouched"); + for (const [index, workerCut] of ["aba-before-rename", "aba-after-rename"].entries()) { + const cutRecord = { ...original, assignmentId: `as1-sha256:${String(index + 4).repeat(64)}` }; + const childCut = assignmentStore(context.repo, { workerCut, workerOutside: outside }); + assert.throws(() => childCut.save(cutRecord, compiled), /ancestor authority mutation|unexpected entry/u); + assert.equal(readFileSync(canary, "utf8"), "untouched"); + } + for (const entry of readdirSync(assignments)) if (entry.startsWith(".")) rmSync(join(assignments, entry), { recursive: true }); + const noClobberRecord = { ...original, assignmentId: `as1-sha256:${"6".repeat(64)}` }; + const noClobber = assignmentStore(context.repo, { workerCut: "create-empty-target", workerOutside: outside }); + assert.throws(() => noClobber.save(noClobberRecord, compiled), /ancestor authority mutation|bounded private regular file|required artifact bytes/u); + const reservedTarget = noClobber.pathFor(noClobberRecord.assignmentId); + assert.equal(lstatSync(reservedTarget).isDirectory(), true); + assert.deepEqual(readdirSync(reservedTarget), []); + } finally { context.cleanup(); } +}); + +test("assignment reads reject non-private directories and files", async () => { + const context = fixture(); + try { + const result = await assignLoopItem({ repoRoot: context.repo, itemRef: ref(), loopRef: "loop:builtin:review" }); + const store = assignmentStore(context.repo), artifact = store.pathFor(result.assignmentId); + chmodSync(artifact, 0o755); assert.throws(() => store.load(result.assignmentId), /unsafe directory/u); + chmodSync(artifact, 0o700); chmodSync(join(artifact, "manifest.json"), 0o644); + assert.throws(() => store.inspect(result.assignmentId), /bounded private regular file/u); + chmodSync(join(artifact, "manifest.json"), 0o600); + assert.throws(() => assignmentStore(context.repo, { workerCut: "grow:recipe.frozen" }).load(result.assignmentId), /changed while reading/u); + } finally { context.cleanup(); } +}); + +test("restrictive umask cannot poison published assignment modes", async () => { + const context = fixture(), previous = process.umask(0o777); + try { + const result = await assignLoopItem({ repoRoot: context.repo, itemRef: ref(), loopRef: "loop:builtin:review" }); + const artifact = assignmentStore(context.repo).pathFor(result.assignmentId); + assert.equal(lstatSync(artifact).mode & 0o777, 0o700); + assert.equal(lstatSync(join(artifact, "manifest.json")).mode & 0o777, 0o600); + assert.equal(lstatSync(join(artifact, "recipe.frozen")).mode & 0o777, 0o600); + } finally { process.umask(previous); context.cleanup(); } +}); + +test("child-side cut between manifest and recipe cannot continue after leaf escape", async () => { + const context = fixture(); + try { + const result = await assignLoopItem({ repoRoot: context.repo, itemRef: ref(), loopRef: "loop:builtin:review" }); + const outside = join(dirname(context.repo), "read-cut"), canary = join(outside, "canary"); + mkdirSync(outside); writeFileSync(canary, "untouched", { mode: 0o600 }); + const store = assignmentStore(context.repo, { workerCut: "move-leaf-between-files", workerOutside: outside }); + assert.throws(() => store.load(result.assignmentId), /ancestor authority/u); + assert.equal(readFileSync(canary, "utf8"), "untouched"); + } finally { context.cleanup(); } +}); + +test("duplicate active ids, item-CAS edits, and Run ambiguity reject without fallback", async () => { + const context = fixture(); + try { + writeFileSync(context.plan, readFileSync(context.plan, "utf8").replace("\n\n## Completed", "\n- [ ] BUG-07 | Duplicate\n\n## Completed")); + assert.throws(() => prepareItemMutation({ repoRoot: context.repo, itemRef: ref() }), /duplicated/u); + writeFileSync(context.plan, "# T\n\n## Active Checklist\n- [ ] BUG-07 | One\n\n## Completed\n"); + const prepared = prepareItemMutation({ repoRoot: context.repo, itemRef: ref() }); + writeFileSync(context.plan, readFileSync(context.plan, "utf8").replace("One", "Two")); + await assert.rejects(assignLoopItem({ repoRoot: context.repo, itemRef: ref(), loopRef: "loop:builtin:review", prepared }), /item CAS/u); + assert.throws(() => selectNonterminalRun(ref(), []), /E_RUN_AMBIGUOUS/u); + assert.throws(() => selectNonterminalRun(ref(), [{ itemRef: ref(), runId: "run:01arz3ndektsv4rrffq69g5fav", state: "running" }, { itemRef: ref(), runId: "run:01arz3ndektsv4rrffq69g5fav", state: "paused" }]), /E_RUN_AMBIGUOUS/u); + } finally { context.cleanup(); } +}); diff --git a/src/loops/assignment/hazards.mjs b/src/loops/assignment/hazards.mjs new file mode 100644 index 00000000..81d34f79 --- /dev/null +++ b/src/loops/assignment/hazards.mjs @@ -0,0 +1,31 @@ +import { runStore } from "../run/run-store.mjs"; +import { parseItemRef } from "./selectors.mjs"; + +const SAFE_TERMINALS = new Set(["failed", "stopped", "budget-exhausted", "needs-human"]); + +export class LoopHazardError extends Error { + constructor(code, message) { super(`${code}: ${message}`); this.code = code; } +} +function bad(code, message) { throw new LoopHazardError(code, message); } + +/** + * Production Run journals are the only direct-burn/unassign authority. A + * converged Run deliberately remains hazardous until CLI completion consumes + * it; terminal failure, stop, exhaustion, and needs-human histories are safe. + */ +export function repositoryHazardAuthority(repoRoot) { + return ({ itemRef }) => { + const item = parseItemRef(itemRef); + const store = runStore(repoRoot); let runs, current; + try { runs = store.list(); current = store.readCurrentRun(item.selector); } + catch (error) { bad("E_LOOP_HAZARD_CORRUPT", error?.message ?? "production Run state is unreadable"); } + if (!Array.isArray(runs)) bad("E_LOOP_HAZARD_CORRUPT", "production Run state is invalid"); + const matching = runs.filter((run) => run?.itemRef === item.selector); + if (current && !matching.some((run) => run.runId === current.runId)) return [`unpublished current Run ${current.runId}`]; + if (matching.some((run) => typeof run.runId !== "string" || typeof run.state !== "string")) bad("E_LOOP_HAZARD_CORRUPT", "production Run projection is invalid"); + return matching.filter((run) => !SAFE_TERMINALS.has(run.state)).map((run) => { + if (run.state === "converged") return `completion-pending Run ${run.runId}`; + return `nonterminal Run ${run.runId}`; + }); + }; +} diff --git a/src/loops/assignment/item-metadata.mjs b/src/loops/assignment/item-metadata.mjs new file mode 100644 index 00000000..e4a84edf --- /dev/null +++ b/src/loops/assignment/item-metadata.mjs @@ -0,0 +1,98 @@ +import { prefixed } from "../dsl/hash.mjs"; + +const AS = /^as1-sha256:[a-f0-9]{64}$/u; +const ER = /^er1-sha256:[a-f0-9]{64}$/u; +const LP = /^lp1-sha256:[a-f0-9]{64}$/u; +const ITEM_START = /^- \[ \] ([A-Za-z0-9][A-Za-z0-9._-]{0,63}) \|/u; +const BOUNDARY = /^(?:- \[[ xX]\] |## Completed$)/u; +const FIELDS = ["Assignment-Id", "Selector", "Execution-Revision", "Package-Revision"]; + +function fail(message) { throw new Error(`Loop assignment: ${message}`); } +function digest(prefix, domain, fields) { return prefixed(prefix, domain, fields); } +function linesWithOffsets(bytes) { + const lines = []; let start = 0; + for (let index = 0; index < bytes.length; index += 1) if (bytes[index] === 10) { + lines.push({ start, end: index + 1, text: bytes.subarray(start, index).toString("utf8") }); start = index + 1; + } + if (start !== bytes.length) fail("burnlist and item spans must end with LF"); + return lines; +} + +export function itemDigest(itemRef, span) { + return digest("id1-sha256:", "item-v1", [Buffer.from(itemRef), Buffer.from(span)]); +} +export function assignmentDigest(itemRef, unassigned, selector, executable) { + return digest("as1-sha256:", "assignment-v1", [Buffer.from(itemRef), Buffer.from(unassigned), Buffer.from(selector), Buffer.from(executable)]); +} + +/** Return exact byte spans; blank and legacy content remains within its item. */ +export function locateItemSpan(markdown, itemId) { + const bytes = Buffer.isBuffer(markdown) ? Buffer.from(markdown) : Buffer.from(String(markdown), "utf8"); + const lines = linesWithOffsets(bytes); let start = -1; let matches = 0; + for (let index = 0; index < lines.length; index += 1) { + const match = ITEM_START.exec(lines[index].text); + if (match?.[1] === itemId) { start = index; matches += 1; } + } + if (start < 0) fail(`active item ${itemId} was not found`); + if (matches !== 1) fail(`active item ${itemId} is duplicated`); + let end = lines.length; + for (let index = start + 1; index < lines.length; index += 1) if (BOUNDARY.test(lines[index].text)) { end = index; break; } + const startByte = lines[start].start, endByte = end < lines.length ? lines[end].start : bytes.length; + return { bytes, lines, startLine: start, endLine: end, startByte, endByte, span: bytes.subarray(startByte, endByte) }; +} + +function metadataAt(lines, index) { + if (lines[index]?.text !== " Loop:") return null; + if (index + 4 >= lines.length) fail("truncated Loop metadata"); + const values = {}; + for (let offset = 0; offset < FIELDS.length; offset += 1) { + const match = /^ ([A-Za-z-]+): (.*)$/u.exec(lines[index + offset + 1].text); + if (!match || match[1] !== FIELDS[offset]) fail("noncanonical Loop metadata"); + values[match[1]] = match[2]; + } + return { line: index, endLine: index + 5, values }; +} + +export function findLoopMetadata(spanResult) { + const { lines, startLine, endLine } = spanResult; const markers = []; + for (let index = startLine; index < endLine; index += 1) if (/^\s*Loop:/u.test(lines[index].text)) markers.push(index); + if (!markers.length) return null; + if (markers.length !== 1 || lines[markers[0]].text !== " Loop:") fail("duplicate or handwritten Loop metadata"); + const meta = metadataAt(lines, markers[0]); + if (meta.endLine > endLine) fail("Loop metadata escapes item span"); + if (!AS.test(meta.values["Assignment-Id"]) || !ER.test(meta.values["Execution-Revision"]) || !LP.test(meta.values["Package-Revision"])) fail("noncanonical Loop digest"); + if (!/^loop:builtin:[a-z0-9]+(?:-[a-z0-9]+)*$/u.test(meta.values.Selector)) fail("noncanonical Loop selector"); + return meta; +} + +function trimBlankSuffix(lines, start, end) { + let index = end; while (index > start && lines[index - 1].text === "") index -= 1; return index; +} +function block(values) { return Buffer.from([" Loop:", ...FIELDS.map((field) => ` ${field}: ${values[field]}`)].join("\n") + "\n"); } + +export function buildAssignment(itemRef, spanResult, { selector, executable, packageRevision }) { + if (findLoopMetadata(spanResult)) fail("item is already assigned"); + const unassignedDigest = itemDigest(itemRef, spanResult.span); + const assignmentId = assignmentDigest(itemRef, unassignedDigest, selector, executable); + const values = { "Assignment-Id": assignmentId, Selector: selector, "Execution-Revision": executable, "Package-Revision": packageRevision }; + const insertLine = trimBlankSuffix(spanResult.lines, spanResult.startLine, spanResult.endLine); + const insertAt = insertLine < spanResult.lines.length ? spanResult.lines[insertLine].start : spanResult.endByte; + const assignedSpan = Buffer.concat([spanResult.span.subarray(0, insertAt - spanResult.startByte), block(values), spanResult.span.subarray(insertAt - spanResult.startByte)]); + return { values, assignmentId, unassignedDigest, assignedDigest: itemDigest(itemRef, assignedSpan), assignedSpan, insertAt }; +} + +export function validateAssignedItem(itemRef, spanResult) { + const meta = findLoopMetadata(spanResult); if (!meta) fail("item has no Loop assignment"); + const metaStart = spanResult.lines[meta.line].start - spanResult.startByte; + const metaEnd = spanResult.lines[meta.endLine - 1].end - spanResult.startByte; + const unassigned = Buffer.concat([spanResult.span.subarray(0, metaStart), spanResult.span.subarray(metaEnd)]); + // The block must follow every nonblank item line and precede only the maximal + // trailing run of blank LF lines. This also rejects a block moved into prose. + if (spanResult.lines.slice(meta.endLine, spanResult.endLine).some((line) => line.text !== "")) fail("Loop metadata is not canonically placed"); + const unassignedDigest = itemDigest(itemRef, unassigned); + const expectedId = assignmentDigest(itemRef, unassignedDigest, meta.values.Selector, meta.values["Execution-Revision"]); + if (expectedId !== meta.values["Assignment-Id"]) fail("stale or handwritten assignment id"); + return { ...meta.values, unassignedSpan: unassigned, unassignedDigest, assignedDigest: itemDigest(itemRef, spanResult.span), metaStart, metaEnd }; +} + +export function containsLoopMarker(spanResult) { return spanResult.lines.slice(spanResult.startLine, spanResult.endLine).some((line) => /^\s*Loop:/u.test(line.text)); } diff --git a/src/loops/assignment/resolver.mjs b/src/loops/assignment/resolver.mjs new file mode 100644 index 00000000..a70b34b0 --- /dev/null +++ b/src/loops/assignment/resolver.mjs @@ -0,0 +1,59 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { findBurnlistDir } from "../../cli/lifecycle-moves.mjs"; +import { loadFrozenRecipe } from "../dsl/frozen.mjs"; +import { locateItemSpan, validateAssignedItem } from "./item-metadata.mjs"; +import { resolveBuiltin } from "./assignment.mjs"; +import { parseItemRef, parseLoopRef, parseRunRef, selectorKind } from "./selectors.mjs"; +import { assignmentStore } from "./store.mjs"; + +export class LoopResolutionError extends Error { constructor(code, message) { super(`${code}: ${message}`); this.code = code; } } +function fail(code, message) { throw new LoopResolutionError(code, message); } +function assigned(repoRoot, item) { + let found; try { found = findBurnlistDir(repoRoot, item.burnlistId); } catch (error) { fail("E_ITEM_MISSING", error.message); } + let span; try { span = locateItemSpan(readFileSync(join(found.dir, "burnlist.md")), item.itemId); } catch (error) { fail("E_ITEM_MISSING", error.message); } + let meta; try { meta = validateAssignedItem(item.selector, span); } catch (error) { fail("E_ASSIGNMENT_INVALID", error.message); } + return { found, meta }; +} +function pinUnavailable(meta, current) { + const pinned = meta["Execution-Revision"], currentRevision = current?.revisions?.executable ?? "unavailable"; + fail("ELOOP_PIN_BYTES_UNAVAILABLE", `pinned=${pinned} current=${currentRevision}; restore the assignment artifact or safely unassign and reassign the item`); +} + +/** One selector-to-authority boundary. It never falls through between kinds. */ +export async function resolveLoopAuthority({ repoRoot, selector, runReader }) { + let kind; try { kind = selectorKind(selector, { allowViewSugar: true }); } + catch (error) { fail("E_LOOP_SELECTOR_INVALID", error.message); } + if (kind === "loop") { + const loop = parseLoopRef(selector, { allowViewSugar: true }); let compiled; + try { compiled = await resolveBuiltin(loop); } catch (error) { fail("E_LOOP_MISSING", error.message); } + return { authority: "UNPINNED", selector: loop.selector, compiled, executableRevision: compiled.revisions.executable }; + } + if (kind === "item") { + const item = parseItemRef(selector), value = assigned(repoRoot, item); + let current = null; try { current = await resolveBuiltin(parseLoopRef(value.meta.Selector)); } catch { /* pin remains authoritative */ } + let artifact; try { artifact = assignmentStore(repoRoot).load(value.meta["Assignment-Id"]); } catch { pinUnavailable(value.meta, current); } + if (artifact.itemRef !== item.selector || artifact.assignmentId !== value.meta["Assignment-Id"] || artifact.assignedItemDigest !== value.meta.assignedDigest || artifact.unassignedItemDigest !== value.meta.unassignedDigest || artifact.executionRevision !== value.meta["Execution-Revision"] || artifact.packageRevision !== value.meta["Package-Revision"]) pinUnavailable(value.meta, current); + // The assignment artifact remains the only executable authority. The fresh + // compilation is deliberately complete (rather than just a revision) so a + // view can show provenance drift without ever substituting its graph. + return { authority: "ITEM-PINNED", selector: item.selector, loopRef: artifact.selector, + assignment: value.meta, artifact, executableRevision: artifact.executionRevision, + currentCompiled: current, currentExecutableRevision: current?.revisions.executable ?? null, + executableDrift: current ? current.revisions.executable !== artifact.executionRevision : null }; + } + const run = parseRunRef(selector); + if (typeof runReader !== "function") fail("E_RUN_UNAVAILABLE", "Run-frozen authority is unavailable before the Run store is installed"); + let record; try { record = await runReader(run.selector); } catch (error) { fail("E_RUN_MISSING", error.message); } + if (!record || record.runId !== run.selector || !Buffer.isBuffer(record.frozenRecipe)) fail("E_RUN_CORRUPT", "Run reader did not return a verified frozen recipe"); + let frozen; try { frozen = loadFrozenRecipe(record.frozenRecipe); } catch (error) { fail("E_RUN_CORRUPT", error.message); } + return { authority: "RUN-FROZEN", selector: run.selector, loopRef: `loop:builtin:${frozen.ir.id}`, + run: record, frozen, executableRevision: frozen.revisions.executable }; +} + +export function selectNonterminalRun(itemRef, runs) { + const item = parseItemRef(itemRef); if (!Array.isArray(runs)) fail("E_RUN_CORRUPT", "Run list is unavailable"); + const active = runs.filter((run) => run?.itemRef === item.selector && ["prepared", "running", "pausing", "paused", "quarantined", "converged-pending-completion", "completion-needs-human"].includes(run.state)); + if (active.length !== 1) fail("E_RUN_AMBIGUOUS", `${active.length ? "multiple" : "no"} nonterminal Runs: ${active.slice(0, 8).map((run) => run.runId).join(",")}`); + parseRunRef(active[0].runId); return active[0]; +} diff --git a/src/loops/assignment/selectors.mjs b/src/loops/assignment/selectors.mjs new file mode 100644 index 00000000..a33b2502 --- /dev/null +++ b/src/loops/assignment/selectors.mjs @@ -0,0 +1,40 @@ +import { RUN_REF } from "../run/run-ref.mjs"; + +const HEX = "[a-f0-9]{64}"; +const LOOP = new RegExp(`^loop:builtin:([a-z0-9]+(?:-[a-z0-9]+)*)(?:@(er1-sha256:${HEX}))?$`, "u"); +const ITEM = /^item:([0-9]{6}-[0-9]{3})#([A-Za-z0-9][A-Za-z0-9._-]{0,63})$/u; + +function reject(label, value) { throw new TypeError(`Invalid ${label}: ${String(value)}`); } + +/** Parse only the closed Stage 1 Loop selector grammar. */ +export function parseLoopRef(value, { allowViewSugar = false } = {}) { + if (allowViewSugar && value === "review") return { selector: "loop:builtin:review", name: "review", executable: null }; + const match = typeof value === "string" ? LOOP.exec(value) : null; + if (!match) reject("LoopRef", value); + return { selector: `loop:builtin:${match[1]}`, name: match[1], executable: match[2] ?? null }; +} + +export function parseItemRef(value) { + const match = typeof value === "string" ? ITEM.exec(value) : null; + if (!match) reject("ItemRef", value); + return { selector: value, burnlistId: match[1], itemId: match[2] }; +} + +// Crockford's 26 digits encode 130 bits. Canonical ULIDs reserve the top two. +export function parseRunRef(value) { + if (typeof value !== "string" || !RUN_REF.test(value)) reject("RunRef", value); + return { selector: value, id: value.slice(4) }; +} + +export function selectorKind(value, options) { + try { return parseLoopRef(value, options).selector ? "loop" : null; } catch { /* closed alternatives */ } + try { parseItemRef(value); return "item"; } catch { /* closed alternatives */ } + try { parseRunRef(value); return "run"; } catch { /* closed alternatives */ } + reject("Loop selector", value); +} + +export const SELECTOR_GRAMMARS = Object.freeze({ + LoopRef: LOOP, + ItemRef: ITEM, + RunRef: RUN_REF, +}); diff --git a/src/loops/assignment/store-worker.mjs b/src/loops/assignment/store-worker.mjs new file mode 100644 index 00000000..38f5bd35 --- /dev/null +++ b/src/loops/assignment/store-worker.mjs @@ -0,0 +1,154 @@ +import { randomBytes } from "node:crypto"; +import { + appendFileSync, chmodSync, closeSync, constants, fchmodSync, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, + readFileSync, readSync, readdirSync, renameSync, writeFileSync, +} from "node:fs"; +import { join } from "node:path"; + +function fail(message) { throw new Error(`Assignment artifact: ${message}`); } +function identity(stat) { return `${stat.dev}:${stat.ino}`; } +const authority = []; +function marker(stat) { return `${stat.ctimeMs}:${stat.mtimeMs}`; } +function remember(path, stat) { authority.push({ path, identity: identity(stat), marker: marker(stat), mode: stat.mode & 0o777 }); } +function refreshParent() { + if (!authority.length) return; + const stat = lstatSync(".."), parent = authority.at(-1); + if (identity(stat) !== parent.identity) fail("ancestor authority changed"); + parent.marker = marker(stat); +} +function validateAuthority() { + for (let index = 0; index < authority.length; index += 1) { + const expected = authority[index], stat = lstatSync(expected.path); + if (stat.isSymbolicLink() || !stat.isDirectory() || identity(stat) !== expected.identity || (stat.mode & 0o777) !== expected.mode) fail("ancestor authority changed"); + if (marker(stat) !== expected.marker) fail(`ancestor authority mutation detected ${expected.path}`); + } +} +function refreshCurrent() { + const current = authority.at(-1), stat = lstatSync("."); + if (identity(stat) !== current.identity || (stat.mode & 0o777) !== current.mode) fail("current authority changed"); + current.marker = marker(stat); +} +function regular(path, limit) { + const before = lstatSync(path); + if (before.isSymbolicLink() || !before.isFile() || (before.mode & 0o777) !== 0o600 || before.size > limit) fail(`${path} is not a bounded private regular file`); + let fd; + try { + fd = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW || 0)); + const opened = fstatSync(fd); + if (!opened.isFile() || identity(opened) !== identity(before) || opened.size !== before.size || (opened.mode & 0o777) !== 0o600) fail(`${path} changed while opening`); + if (testCut === `grow:${path}`) appendFileSync(path, "x"); + const bounded = Buffer.alloc(limit + 1); let offset = 0, count; + do { count = readSync(fd, bounded, offset, bounded.length - offset, null); offset += count; } while (count && offset < bounded.length); + const after = fstatSync(fd); + if (offset !== opened.size || offset > limit || after.size !== opened.size || identity(after) !== identity(opened) || after.mtimeMs !== opened.mtimeMs || after.ctimeMs !== opened.ctimeMs) fail(`${path} changed while reading`); + return bounded.subarray(0, offset); + } finally { if (fd !== undefined) closeSync(fd); } +} +function cwd(expected, mode) { + const stat = lstatSync("."); + if (!stat.isDirectory() || identity(stat) !== expected || (mode && (stat.mode & 0o777) !== mode)) fail("anchored directory authority changed"); +} +function enter(name, create, mode) { + validateAuthority(); + let before, created = false; + try { before = lstatSync(name); } catch (error) { + if (error?.code !== "ENOENT" || !create) throw error; + mkdirSync(name, { mode: 0o700 }); chmodSync(name, 0o700); before = lstatSync(name); created = true; + } + if (before.isSymbolicLink() || !before.isDirectory() || (mode && (before.mode & 0o777) !== mode)) fail(`unsafe directory ${name}`); + const path = join(authority.at(-1).path, name); + process.chdir(name); cwd(identity(before), mode); if (created) refreshParent(); remember(path, before); validateAuthority(); +} +function enterRoot(create) { + for (const part of [".local", "burnlist", "loop", "v2"]) enter(part, create); + enter("assignments", create, 0o700); +} +function durable(path, bytes) { + const fd = openSync(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW || 0), 0o600); + try { fchmodSync(fd, 0o600); writeFileSync(fd, bytes); fsyncSync(fd); } finally { closeSync(fd); } +} +function members(allowedTemp = null) { + const entries = readdirSync(".", { withFileTypes: true }); + for (const entry of entries) { + if (entry.name === allowedTemp && entry.isDirectory() && !entry.isSymbolicLink()) continue; + if (!/^[a-f0-9]{64}$/u.test(entry.name) || !entry.isDirectory() || entry.isSymbolicLink()) fail("assignment root contains an unexpected entry"); + } + return entries.map((entry) => entry.name).sort(); +} +function input() { return JSON.parse(readFileSync(0, "utf8")); } + +const [command, expected, testCut] = process.argv.slice(2); +const payload = input(); +cwd(expected); remember(process.cwd(), lstatSync(".")); +if (command === "load") { + const { name, outside } = payload; enterRoot(false); enter(name, false, 0o700); + validateAuthority(); + const manifest = regular("manifest.json", 16 * 1024); + if (testCut === "move-leaf-between-files") renameSync(`../${name}`, `${outside}/${name}.moved`); + validateAuthority(); + const recipe = regular("recipe.frozen", 1024 * 1024); + validateAuthority(); + process.stdout.write(JSON.stringify({ manifest: manifest.toString("base64"), recipe: recipe.toString("base64") })); +} else if (command === "save") { + const { name, manifest, recipe, outside, root } = payload; enterRoot(true); + validateAuthority(); + const rootIdentity = identity(lstatSync(".")), temp = `.${name}.${randomBytes(8).toString("hex")}.tmp`; + const expectedManifest = Buffer.from(manifest, "base64"), expectedRecipe = Buffer.from(recipe, "base64"); + const collision = () => { + enter(name, false, 0o700); + validateAuthority(); + const actualManifest = regular("manifest.json", 16 * 1024); + if (testCut === "move-collision-between-files") renameSync(`../${name}`, `${outside}/${name}.moved`); + validateAuthority(); + const actualRecipe = regular("recipe.frozen", 1024 * 1024); + validateAuthority(); + if (!actualManifest.equals(expectedManifest) || !actualRecipe.equals(expectedRecipe)) fail("assignment id collision"); + process.stdout.write(JSON.stringify({ status: "existing" })); + }; + let existing = true; + try { lstatSync(name); } catch (error) { if (error?.code === "ENOENT") existing = false; else throw error; } + if (existing) { collision(); process.exit(0); } + const beforeMembers = members(); + let tempStat; + { + enter(temp, true, 0o700); tempStat = lstatSync("."); + if (testCut === "move-temp-before-recipe") renameSync(`../${temp}`, `${outside}/${temp}.moved`); + validateAuthority(); if (identity(lstatSync("..")) !== rootIdentity) fail("temporary directory escaped assignment root"); + durable("recipe.frozen", expectedRecipe); + refreshCurrent(); validateAuthority(); if (identity(lstatSync("..")) !== rootIdentity) fail("temporary directory escaped assignment root"); + durable("manifest.json", expectedManifest); + refreshCurrent(); validateAuthority(); if (identity(lstatSync("..")) !== rootIdentity) fail("temporary directory escaped assignment root"); + const fd = openSync(".", constants.O_RDONLY); try { fsyncSync(fd); } finally { closeSync(fd); } + validateAuthority(); process.chdir(".."); authority.pop(); cwd(rootIdentity, 0o700); validateAuthority(); + const withTemp = members(temp); + if (withTemp.length !== beforeMembers.length + 1 || !withTemp.includes(temp) || beforeMembers.some((entry) => !withTemp.includes(entry))) fail("assignment root membership changed"); + if (testCut === "aba-before-rename") { renameSync(root, `${outside}/assignments.aba`); renameSync(`${outside}/assignments.aba`, root); } + validateAuthority(); + if (testCut === "create-empty-target") mkdirSync(name, { mode: 0o700 }); + let reservation; + try { mkdirSync(name, { mode: 0o700 }); chmodSync(name, 0o700); reservation = lstatSync(name); } + catch (error) { + if (error?.code !== "EEXIST") throw error; + collision(); process.exit(0); + } + if (!reservation.isDirectory() || reservation.isSymbolicLink() || (reservation.mode & 0o777) !== 0o700 + || readdirSync(name).length !== 0) fail("target reservation changed"); + refreshCurrent(); validateAuthority(); + const reserved = lstatSync(name); + if (identity(reserved) !== identity(reservation) || readdirSync(name).length !== 0) fail("target reservation changed"); + renameSync(temp, name); + if (testCut === "aba-after-rename") { + renameSync(root, `${outside}/assignments.aba`); renameSync(`${outside}/assignments.aba`, root); validateAuthority(); + } + refreshCurrent(); validateAuthority(); + const afterMembers = members(); + if (afterMembers.length !== beforeMembers.length + 1 || !afterMembers.includes(name) || beforeMembers.some((entry) => !afterMembers.includes(entry))) fail("assignment root membership changed"); + const rootFd = openSync(".", constants.O_RDONLY); try { fsyncSync(rootFd); } finally { closeSync(rootFd); } + validateAuthority(); enter(name, false, 0o700); + if (identity(lstatSync(".")) !== identity(tempStat)) fail("published artifact is not the prepared temp"); + const publishedManifest = regular("manifest.json", 16 * 1024), publishedRecipe = regular("recipe.frozen", 1024 * 1024); + validateAuthority(); + if (!publishedManifest.equals(expectedManifest) || !publishedRecipe.equals(expectedRecipe)) fail("published artifact bytes changed"); + process.stdout.write(JSON.stringify({ status: "created" })); + } +} else fail("invalid worker operation"); diff --git a/src/loops/assignment/store.mjs b/src/loops/assignment/store.mjs new file mode 100644 index 00000000..9648aa9b --- /dev/null +++ b/src/loops/assignment/store.mjs @@ -0,0 +1,74 @@ +import { execFileSync } from "node:child_process"; +import { lstatSync, realpathSync } from "node:fs"; +import { join, relative, resolve, sep } from "node:path"; +import { fileURLToPath } from "node:url"; +import { freezeRecipe, loadFrozenRecipe } from "../dsl/frozen.mjs"; +import { rawSha256 } from "../dsl/hash.mjs"; + +const ID = /^as1-sha256:[a-f0-9]{64}$/u; +const DIGEST = /^sha256:[a-f0-9]{64}$/u; +const ITEM = /^item:[0-9]{6}-[0-9]{3}#[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/u; +const MANIFEST_KEYS = ["schema", "assignmentId", "itemRef", "selector", "executionRevision", "packageRevision", "sourceRevision", "unassignedItemDigest", "assignedItemDigest", "frozenRecipeDigest", "frozenRecipeSize"]; + +function fail(message) { throw new Error(`Assignment artifact: ${message}`); } +function name(id) { if (!ID.test(id)) fail("invalid assignment id"); return id.slice(11); } +function exact(value, keys) { return !!value && typeof value === "object" && !Array.isArray(value) && Object.keys(value).length === keys.length && keys.every((key, index) => Object.keys(value)[index] === key); } +function manifest(record, recipe) { + const value = { schema: "burnlist-loop-assignment@1", assignmentId: record.assignmentId, itemRef: record.itemRef, selector: record.selector, + executionRevision: record.executionRevision, packageRevision: record.packageRevision, sourceRevision: record.sourceRevision, + unassignedItemDigest: record.unassignedItemDigest, assignedItemDigest: record.assignedItemDigest, + frozenRecipeDigest: rawSha256(recipe), frozenRecipeSize: recipe.length }; + return Buffer.from(`${JSON.stringify(value)}\n`); +} +function parseManifest(bytes, id) { + let value; try { value = JSON.parse(bytes.toString("utf8")); } catch { fail("manifest is not JSON"); } + if (!exact(value, MANIFEST_KEYS) || !Buffer.from(`${JSON.stringify(value)}\n`).equals(bytes)) fail("manifest is not canonical"); + if (value.schema !== "burnlist-loop-assignment@1" || value.assignmentId !== id || !ITEM.test(value.itemRef) || !/^loop:builtin:[a-z0-9]+(?:-[a-z0-9]+)*$/u.test(value.selector) || !/^er1-sha256:[a-f0-9]{64}$/u.test(value.executionRevision) || !/^lp1-sha256:[a-f0-9]{64}$/u.test(value.packageRevision) || !/^ls1-sha256:[a-f0-9]{64}$/u.test(value.sourceRevision) || !/^id1-sha256:[a-f0-9]{64}$/u.test(value.unassignedItemDigest) || !/^id1-sha256:[a-f0-9]{64}$/u.test(value.assignedItemDigest) || !DIGEST.test(value.frozenRecipeDigest) || !Number.isSafeInteger(value.frozenRecipeSize) || value.frozenRecipeSize < 1) fail("manifest has invalid bindings"); + return value; +} + +const worker = fileURLToPath(new URL("./store-worker.mjs", import.meta.url)); +function stat(path) { const value = lstatSync(path); if (value.isSymbolicLink() || !value.isDirectory()) fail(`unsafe directory ${path}`); return `${value.dev}:${value.ino}`; } +function invoke(command, cwd, expected, value, testCut) { + try { + return execFileSync(process.execPath, [worker, command, expected, testCut || ""], { + cwd, input: JSON.stringify(value || {}), encoding: "utf8", maxBuffer: 3 * 1024 * 1024, + stdio: ["pipe", "pipe", "pipe"], + }); + } catch (error) { + const detail = String(error?.stderr || ""); + if (/code: 'ENOENT'|ENOENT:/u.test(detail)) throw Object.assign(new Error("Assignment artifact: required artifact bytes are missing"), { code: "ENOENT" }); + const messages = detail.match(/Assignment artifact: [^\n]+/gu) || []; + throw new Error(messages.findLast((message) => !message.includes("${")) || "Assignment artifact: anchored worker failed"); + } +} +function within(repo, path) { const value = relative(repo, path); return value === "" || (value !== ".." && !value.startsWith(`..${sep}`)); } + +export function assignmentStore(repoRoot, options = {}) { + const repository = realpathSync(resolve(repoRoot)), root = resolve(repository, ".local", "burnlist", "loop", "v2", "assignments"); + if (!within(repository, root)) fail("artifact root escapes repository"); + const repositoryIdentity = stat(repository); + const cut = (name, detail = {}) => options.onCut?.(name, Object.freeze({ repository, root, ...detail })); + const pathFor = (id) => join(root, name(id)); + const read = (id) => { + const target = pathFor(id); cut("read", { target }); + const result = JSON.parse(invoke("load", repository, repositoryIdentity, { name: name(id), outside: options.workerOutside }, options.workerCut)); + const manifestBytes = Buffer.from(result.manifest, "base64"), recipe = Buffer.from(result.recipe, "base64"); + const value = parseManifest(manifestBytes, id); + if (recipe.length !== value.frozenRecipeSize || rawSha256(recipe) !== value.frozenRecipeDigest) fail("recipe bytes do not match manifest"); + const frozen = loadFrozenRecipe(recipe); + if (frozen.revisions.executable !== value.executionRevision || frozen.revisions.package !== value.packageRevision || frozen.revisions.source !== value.sourceRevision) fail("frozen recipe bindings do not match manifest"); + return { ...value, frozen, frozenRecipeBytes: Buffer.from(recipe), path: target }; + }; + return { + pathFor, + save(record, compiled) { + const recipe = freezeRecipe(compiled), bytes = manifest(record, recipe), target = pathFor(record.assignmentId); + cut("collision", { target }); cut("publication", { target }); + invoke("save", repository, repositoryIdentity, { name: name(record.assignmentId), manifest: bytes.toString("base64"), recipe: recipe.toString("base64"), outside: options.workerOutside, root }, options.workerCut); + return target; + }, + load: read, + inspect(id) { return read(id); }, + }; +} diff --git a/src/loops/capabilities/capabilities.test.mjs b/src/loops/capabilities/capabilities.test.mjs new file mode 100644 index 00000000..ad3c6b61 --- /dev/null +++ b/src/loops/capabilities/capabilities.test.mjs @@ -0,0 +1,80 @@ +import assert from "node:assert/strict"; +import { chmodSync, fstatSync, mkdtempSync, mkdirSync, readFileSync, readSync, realpathSync, renameSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { bindCapabilitySymbols, canonicalCapabilityBytes, capabilityRevision, parseCapabilityCatalog, readCapabilityCatalog, validateCapabilityGrants } from "./contract.mjs"; +import { preflightCapability, runTrustedCapability, validateCapabilityEvidence } from "./runner.mjs"; +import { readTrustedCapability, trustCapability } from "./trust.mjs"; +import { configRoot, localRecordPath } from "../config/store.mjs"; +import { holdSnapshot, releaseSnapshot, snapshotTarget } from "./snapshot.mjs"; + +function temp() { const root = realpathSync(mkdtempSync(join(tmpdir(), "burnlist-loop-capability-"))); mkdirSync(join(root, ".burnlist")); mkdirSync(join(root, "src")); writeFileSync(join(root, "src", "input"), "a"); return root; } +function policy(overrides = {}) { return { id: "repo-verify", argv: [process.execPath, "-e", "process.stdout.write('ok')"], cwd: ".", environment: { inherit: ["PATH"], set: {} }, network: "deny", filesystem: { read: ["src"], write: [] }, output: { maxBytes: 1024 }, maxMilliseconds: 1000, ...overrides }; } +function grants(source, overrides = {}) { return { argv: source.argv, cwd: source.cwd, environment: source.environment, network: source.network, filesystem: source.filesystem, output: source.output, maxMilliseconds: source.maxMilliseconds, ...overrides }; } +function writeCatalog(root, capabilities = [policy()]) { writeFileSync(join(root, ".burnlist", "loop-capabilities.json"), `${JSON.stringify({ schema: "burnlist-loop-capabilities@1", capabilities })}\n`); } +function trust(root) { const current = readCapabilityCatalog(root).capabilities[0]; return trustCapability({ repoRoot: root, capability: current, grants: grants(current) }); } + +test("closed canonical policy binds compiler check symbols and separate narrower grants", () => { + const root = temp(); writeCatalog(root); const current = readCapabilityCatalog(root).capabilities[0]; + assert.match(capabilityRevision(current), /^cp1-sha256:/); assert.equal(canonicalCapabilityBytes(current).toString(), `${JSON.stringify(current)}\n`); + assert.deepEqual(bindCapabilitySymbols({ nodes: [{ kind: "check", id: "verify", capability: "repo-verify" }] }, readCapabilityCatalog(root)), [{ nodeId: "verify", capability: "repo-verify", revision: capabilityRevision(current) }]); + const narrow = validateCapabilityGrants(grants(current, { output: { maxBytes: 8 }, maxMilliseconds: 10 }), current); assert.equal(narrow.output.maxBytes, 8); + assert.throws(() => validateCapabilityGrants(grants(current, { filesystem: { read: ["elsewhere"], write: [] } }), current), /exceeds/); +}); + +test("unknown untrusted or changed policies never launch, including same-size executable replacement", () => { + const root = temp(); writeCatalog(root); assert.throws(() => preflightCapability({ repoRoot: root, capabilityId: "missing" }), { code: "ELOOP_CAPABILITY_UNKNOWN" }); assert.throws(() => preflightCapability({ repoRoot: root, capabilityId: "repo-verify" }), { code: "ELOOP_CAPABILITY_UNTRUSTED" }); trust(root); + const before = preflightCapability({ repoRoot: root, capabilityId: "repo-verify" }); writeCatalog(root, [policy({ argv: [process.execPath, "-e", "process.exit(0)"] })]); assert.throws(() => runTrustedCapability({ repoRoot: root, capabilityId: "repo-verify", inputCandidate: `cm1-sha256:${"a".repeat(64)}`, preflight: before }), { code: "ELOOP_CAPABILITY_CHANGED" }); + writeCatalog(root, [policy({ filesystem: { read: ["src/input"], write: [] } })]); trust(root); const snap = preflightCapability({ repoRoot: root, capabilityId: "repo-verify" }); + const original = snap.launch.snapshots.find((item) => item.path === process.execPath); const moved = `${process.execPath}.burnlist-test`; // immutable system executable cannot safely be renamed; exercise same snapshot API through a repo grant below. + assert.ok(original && !moved.includes("\0")); writeFileSync(join(root, "src", "input"), "b"); assert.throws(() => runTrustedCapability({ repoRoot: root, capabilityId: "repo-verify", inputCandidate: `cm1-sha256:${"b".repeat(64)}`, preflight: snap }), { code: "ELOOP_CAPABILITY_CHANGED" }); + const executable = join(root, "fake-verify"); writeFileSync(executable, "#!/bin/sh\nexit 0\n"); chmodSync(executable, 0o755); writeCatalog(root, [policy({ argv: [executable], filesystem: { read: ["src/input"], write: [] } })]); trust(root); const executableSnap = preflightCapability({ repoRoot: root, capabilityId: "repo-verify" }); writeFileSync(executable, "#!/bin/sh\nexit 1\n"); assert.throws(() => runTrustedCapability({ repoRoot: root, capabilityId: "repo-verify", inputCandidate: `cm1-sha256:${"d".repeat(64)}`, preflight: executableSnap }), { code: "ELOOP_CAPABILITY_CHANGED" }); + writeFileSync(executable, "#!/bin/sh\nexit 0\n"); writeCatalog(root, [policy({ argv: [executable], cwd: "src", filesystem: { read: ["src/input"], write: [] } })]); trust(root); const cwdSnap = preflightCapability({ repoRoot: root, capabilityId: "repo-verify" }); renameSync(join(root, "src"), join(root, "src-old")); mkdirSync(join(root, "src")); writeFileSync(join(root, "src", "input"), "b"); assert.throws(() => runTrustedCapability({ repoRoot: root, capabilityId: "repo-verify", inputCandidate: `cm1-sha256:${"e".repeat(64)}`, preflight: cwdSnap }), { code: "ELOOP_CAPABILITY_CHANGED" }); +}); + +test("executable snapshot rejects a symlink in any absolute ancestor", () => { + const root = temp(); const real = join(root, "real"); const link = join(root, "link"); const sub = join(real, "sub"); mkdirSync(real); mkdirSync(sub); symlinkSync(real, link); + const executable = join(link, "sub", "tool"); writeFileSync(join(sub, "tool"), "#!/bin/sh\nexit 0\n"); chmodSync(join(sub, "tool"), 0o755); + writeCatalog(root, [policy({ argv: [executable] })]); trust(root); + assert.throws(() => preflightCapability({ repoRoot: root, capabilityId: "repo-verify" }), /directory ancestor/); +}); + +test("sealed snapshots pin exact bytes across replacement or in-place writes and close explicitly", () => { + const root = temp(), executable = join(root, "tool"); + writeFileSync(executable, "old"); const snapshot = snapshotTarget({ root, path: executable }); + const held = holdSnapshot(snapshot); renameSync(executable, `${executable}.old`); writeFileSync(executable, "new"); + writeFileSync(`${executable}.old`, "bad"); + const bytes = Buffer.alloc(3); assert.equal(readSync(held.sealedDescriptor, bytes, 0, 3, 0), 3); + assert.equal(bytes.toString(), "old"); assert.notEqual(fstatSync(held.sealedDescriptor).ino, snapshot.identity.ino); + releaseSnapshot(held); + assert.throws(() => fstatSync(held.sealedDescriptor), { code: "EBADF" }); + assert.equal(readFileSync(executable, "utf8"), "new"); +}); + +test("private trust records use no-follow bounded descriptor reads and reject leaf or ancestor swaps", () => { + const root = temp(); writeCatalog(root); trust(root); assert.equal(readTrustedCapability({ repoRoot: root, capability: "repo-verify", policy: readCapabilityCatalog(root).capabilities[0] }).capability, "repo-verify"); + const record = localRecordPath(root, "capabilities", "repo-verify"); const moved = `${record}.old`; renameSync(record, moved); symlinkSync(moved, record); assert.throws(() => readTrustedCapability({ repoRoot: root, capability: "repo-verify", policy: readCapabilityCatalog(root).capabilities[0] }), /regular file|symbolic link|private/); + const ancestorRoot = temp(); writeCatalog(ancestorRoot); trust(ancestorRoot); const config = configRoot(ancestorRoot); renameSync(config, `${config}.old`); symlinkSync(`${config}.old`, config); assert.throws(() => readTrustedCapability({ repoRoot: ancestorRoot, capability: "repo-verify", policy: readCapabilityCatalog(ancestorRoot).capabilities[0] }), /directory ancestor/); +}); + +test("catalog rejects traversal, symlinks, environment attacks, oversize, and unsupported enforcement claims", () => { + assert.throws(() => parseCapabilityCatalog(Buffer.from(JSON.stringify({ schema: "burnlist-loop-capabilities@1", capabilities: [policy({ cwd: "../outside" })] }))), /cwd/); + assert.throws(() => parseCapabilityCatalog(Buffer.from(JSON.stringify({ schema: "burnlist-loop-capabilities@1", capabilities: [policy({ environment: { inherit: ["PATH"], set: { "BAD-NAME": "x" } } })] }))), /environment/); + assert.throws(() => parseCapabilityCatalog(Buffer.from(JSON.stringify({ schema: "burnlist-loop-capabilities@1", capabilities: [{ ...policy(), guarantees: { filesystem: "enforced" } }] }))), /invalid capability policy/); + const root = temp(); writeFileSync(join(root, ".burnlist", "loop-capabilities.json"), "x".repeat(262145)); assert.throws(() => readCapabilityCatalog(root), /byte limit|invalid regular file/); + const linkRoot = temp(); writeFileSync(join(linkRoot, "catalog"), JSON.stringify({ schema: "burnlist-loop-capabilities@1", capabilities: [policy()] })); symlinkSync(join(linkRoot, "catalog"), join(linkRoot, ".burnlist", "loop-capabilities.json")); assert.throws(() => readCapabilityCatalog(linkRoot), /regular file|symbolic/); + const ancestorRoot = temp(); renameSync(join(ancestorRoot, ".burnlist"), join(ancestorRoot, ".burnlist-old")); symlinkSync(join(ancestorRoot, ".burnlist-old"), join(ancestorRoot, ".burnlist")); assert.throws(() => readCapabilityCatalog(ancestorRoot), /directory ancestor/); +}); + +test("direct fake repo-verify has aggregate bounded output, deadline supervision, and closed capability evidence", async () => { + const root = temp(); writeCatalog(root, [policy({ argv: [process.execPath, "-e", "process.stdout.write('out');process.stderr.write('err')"] })]); trust(root); const inputCandidate = `cm1-sha256:${"c".repeat(64)}`; + const complete = await runTrustedCapability({ repoRoot: root, capabilityId: "repo-verify", inputCandidate }); assert.equal(complete.result.schema, "capability-evidence@1"); assert.equal(complete.result.outcome, "pass"); assert.equal(validateCapabilityEvidence(complete.result), complete.result); assert.match(complete.evidence.toString(), /candidate=cm1-sha256/); + writeCatalog(root, [policy({ argv: [process.execPath, "-e", "setInterval(()=>process.stdout.write('x'),1)"], output: { maxBytes: 8 }, maxMilliseconds: 1000 })]); trust(root); const noisy = await runTrustedCapability({ repoRoot: root, capabilityId: "repo-verify", inputCandidate }); assert.equal(noisy.result.truncated, true); assert.equal(noisy.result.outcome, "fail"); assert.ok(noisy.evidence.length < 128); + writeCatalog(root, [policy({ argv: [process.execPath, "-e", "setInterval(()=>{},1000)"], maxMilliseconds: 20 })]); trust(root); const timed = await runTrustedCapability({ repoRoot: root, capabilityId: "repo-verify", inputCandidate }); assert.equal(timed.result.timedOut, true); assert.equal(timed.result.outcome, "fail"); +}); + +test("trust creation refuses a symlinked local ancestor before recursive creation or publish", () => { + const root = temp(); writeCatalog(root); mkdirSync(join(root, "outside")); symlinkSync(join(root, "outside"), join(root, ".local")); + const current = readCapabilityCatalog(root).capabilities[0]; assert.throws(() => trustCapability({ repoRoot: root, capability: current, grants: grants(current) }), /unsafe trust directory/); +}); diff --git a/src/loops/capabilities/contract.mjs b/src/loops/capabilities/contract.mjs new file mode 100644 index 00000000..efa65529 --- /dev/null +++ b/src/loops/capabilities/contract.mjs @@ -0,0 +1,76 @@ +import { join, resolve } from "node:path"; +import { prefixed, rawSha256 } from "../dsl/hash.mjs"; +import { readSnapshotBytes } from "./snapshot.mjs"; + +export const CAPABILITY_CATALOG = ".burnlist/loop-capabilities.json"; +export const CAPABILITY_SCHEMA = "burnlist-loop-capabilities@1"; +export const MAX_CATALOG_BYTES = 262144; +export const GUARANTEE_LABELS = Object.freeze({ filesystem: "unsupported", process: "supervised", network: "unsupported", environment: "supervised", credentials: "unsupported", childSpawn: "unsupported", descendantContainment: "unsupported" }); +const slug = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u; +const envName = /^[A-Za-z_][A-Za-z0-9_]{0,127}$/u; +const repoPath = /^(?!\.git(?:\/|$))(?!\.local\/burnlist\/loop(?:\/|$))(?!.*(?:^|\/)\.\.?($|\/))[\x21-\x7e]+$/u; +const policyKeys = ["id", "argv", "cwd", "environment", "network", "filesystem", "output", "maxMilliseconds"]; +const grantKeys = ["argv", "cwd", "environment", "network", "filesystem", "output", "maxMilliseconds"]; + +function exact(value, keys) { return Boolean(value) && typeof value === "object" && !Array.isArray(value) && Object.keys(value).length === keys.length && keys.every((key) => Object.hasOwn(value, key)); } +function fail(message, code = "ELOOP_CAPABILITY") { throw Object.assign(new Error(`Loop capability: ${message}`), { code }); } +function safeText(value, label, { empty = false, maximum = 4096 } = {}) { if (typeof value !== "string" || (!empty && !value) || Buffer.byteLength(value) > maximum || /[\0\r\n]/u.test(value)) fail(`invalid ${label}`); return value; } +function sortedUnique(values, valid, label, maximum = 128) { + if (!Array.isArray(values) || values.length > maximum || values.some((value) => typeof value !== "string" || !valid(value))) fail(`invalid ${label}`); + if (new Set(values).size !== values.length || values.some((value, index) => index && Buffer.compare(Buffer.from(values[index - 1]), Buffer.from(value)) >= 0)) fail(`${label} must be sorted and unique`); + return values; +} +function env(value) { + if (!exact(value, ["inherit", "set"])) fail("environment must be closed"); + sortedUnique(value.inherit, (name) => envName.test(name), "environment inherit", 64); + if (!value.inherit.includes("PATH")) fail("environment inherit must include PATH explicitly"); + if (!value.set || typeof value.set !== "object" || Array.isArray(value.set) || Object.keys(value.set).length > 64) fail("invalid environment set"); + const names = Object.keys(value.set); + if (names.some((name) => !envName.test(name) || value.inherit.includes(name)) || names.some((name) => !safeText(value.set[name], `environment ${name}`, { empty: true }))) fail("invalid environment set"); + return { inherit: [...value.inherit], set: Object.fromEntries(names.sort((a, b) => Buffer.compare(Buffer.from(a), Buffer.from(b))).map((name) => [name, value.set[name]])) }; +} +function paths(value, label) { return sortedUnique(value, (path) => path === "." || repoPath.test(path), label, 256); } +function canonical(value, keys) { return Object.fromEntries(keys.map((key) => [key, value[key]])); } + +/** A capability is declarative policy, not a command recipe. */ +export function validateCapability(policy) { + if (!exact(policy, policyKeys) || !slug.test(policy.id)) fail("invalid capability policy"); + if (!Array.isArray(policy.argv) || policy.argv.length < 1 || policy.argv.length > 64) fail("argv must be a nonempty bounded array"); + for (const [index, value] of policy.argv.entries()) { safeText(value, `argv[${index}]`); if (index === 0 && !value.startsWith("/")) fail("argv[0] must be an absolute executable path"); } + if (typeof policy.cwd !== "string" || (policy.cwd !== "." && !repoPath.test(policy.cwd))) fail("cwd must be a safe repository path"); + const environment = env(policy.environment); + if (!["deny", "allow"].includes(policy.network)) fail("network must be deny or allow"); + if (!exact(policy.filesystem, ["read", "write"])) fail("filesystem must be closed"); + const filesystem = { read: paths(policy.filesystem.read, "filesystem read"), write: paths(policy.filesystem.write, "filesystem write") }; + if (filesystem.write.some((path) => path === "." || filesystem.read.includes(path))) fail("invalid filesystem grants"); + if (!exact(policy.output, ["maxBytes"]) || !Number.isSafeInteger(policy.output.maxBytes) || policy.output.maxBytes < 1 || policy.output.maxBytes > 1048576) fail("invalid output limit"); + if (!Number.isSafeInteger(policy.maxMilliseconds) || policy.maxMilliseconds < 1 || policy.maxMilliseconds > 86400000) fail("invalid maxMilliseconds"); + return { id: policy.id, argv: [...policy.argv], cwd: policy.cwd, environment, network: policy.network, filesystem, output: { maxBytes: policy.output.maxBytes }, maxMilliseconds: policy.maxMilliseconds }; +} +export function canonicalCapabilityBytes(policy) { return Buffer.from(`${JSON.stringify(validateCapability(policy))}\n`, "utf8"); } +export function capabilityRevision(policy) { return prefixed("cp1-sha256:", "capability-v1", [canonicalCapabilityBytes(policy)]); } + +/** Local grants are a separate closed object and can only narrow the repository policy. */ +export function validateCapabilityGrants(grants, policy) { + const source = validateCapability(policy); + if (!exact(grants, grantKeys) || JSON.stringify(grants.argv) !== JSON.stringify(source.argv) || grants.cwd !== source.cwd) fail("grants must preserve exact argv and cwd"); + const narrowed = { ...source, environment: env(grants.environment), network: grants.network, filesystem: { read: paths(grants.filesystem?.read, "grant filesystem read"), write: paths(grants.filesystem?.write, "grant filesystem write") }, output: grants.output, maxMilliseconds: grants.maxMilliseconds }; + if (!narrowed.environment.inherit.every((name) => source.environment.inherit.includes(name)) || Object.entries(narrowed.environment.set).some(([name, value]) => source.environment.set[name] !== value)) fail("environment grant exceeds policy"); + if (source.network === "deny" && narrowed.network !== "deny" || !["deny", "allow"].includes(narrowed.network)) fail("network grant exceeds policy"); + if (!narrowed.filesystem.read.every((path) => source.filesystem.read.includes(path)) || !narrowed.filesystem.write.every((path) => source.filesystem.write.includes(path))) fail("filesystem grant exceeds policy"); + if (!exact(narrowed.output, ["maxBytes"]) || !Number.isSafeInteger(narrowed.output.maxBytes) || narrowed.output.maxBytes < 1 || narrowed.output.maxBytes > source.output.maxBytes || !Number.isSafeInteger(narrowed.maxMilliseconds) || narrowed.maxMilliseconds < 1 || narrowed.maxMilliseconds > source.maxMilliseconds) fail("resource grant exceeds policy"); + return canonical(narrowed, policyKeys.slice(1)); +} +export function canonicalGrantBytes(grants, policy) { return Buffer.from(`${JSON.stringify(validateCapabilityGrants(grants, policy))}\n`, "utf8"); } + +export function parseCapabilityCatalog(bytes) { + let value; try { value = JSON.parse(Buffer.from(bytes).toString("utf8")); } catch { fail("catalog is not valid JSON"); } + if (!exact(value, ["schema", "capabilities"]) || value.schema !== CAPABILITY_SCHEMA || !Array.isArray(value.capabilities) || value.capabilities.length > 64) fail("catalog has an invalid schema"); + const capabilities = value.capabilities.map(validateCapability); + if (new Set(capabilities.map((policy) => policy.id)).size !== capabilities.length || capabilities.some((policy, index) => index && Buffer.compare(Buffer.from(capabilities[index - 1].id), Buffer.from(policy.id)) >= 0)) fail("capabilities must be sorted with unique ids"); + return { schema: CAPABILITY_SCHEMA, capabilities, bytes: Buffer.from(bytes), digest: rawSha256(bytes) }; +} +/** The source file and every existing ancestor are no-follow descriptor-checked. */ +export function readCapabilityCatalog(repoRoot) { const root = resolve(repoRoot); const path = join(root, CAPABILITY_CATALOG); const { bytes } = readSnapshotBytes({ root, path, maximum: MAX_CATALOG_BYTES }); return { ...parseCapabilityCatalog(bytes), path }; } +export function resolveCapability(catalog, id) { if (!slug.test(id)) fail("invalid capability id"); const policy = catalog?.capabilities?.find((entry) => entry.id === id); if (!policy) fail(`unknown capability ${id}`, "ELOOP_CAPABILITY_UNKNOWN"); return { policy, revision: capabilityRevision(policy), bytes: canonicalCapabilityBytes(policy) }; } +export function bindCapabilitySymbols(ir, catalog) { if (!ir || !Array.isArray(ir.nodes)) fail("invalid compiler IR"); return ir.nodes.filter((node) => node.kind === "check").map((node) => ({ nodeId: node.id, capability: node.capability, revision: resolveCapability(catalog, node.capability).revision })); } diff --git a/src/loops/capabilities/runner.mjs b/src/loops/capabilities/runner.mjs new file mode 100644 index 00000000..5c6ad770 --- /dev/null +++ b/src/loops/capabilities/runner.mjs @@ -0,0 +1,63 @@ +import { spawn as nodeSpawn } from "node:child_process"; +import { lstatSync } from "node:fs"; +import { parse, resolve } from "node:path"; +import { rawSha256 } from "../dsl/hash.mjs"; +import { GUARANTEE_LABELS, readCapabilityCatalog, resolveCapability } from "./contract.mjs"; +import { checkSnapshot, repoTarget, snapshotTarget } from "./snapshot.mjs"; +import { assertTrustedCapability } from "./trust.mjs"; + +function fail(message, code = "ELOOP_CAPABILITY_LAUNCH") { throw Object.assign(new Error(`Loop capability launch: ${message}`), { code }); } +function candidate(value) { if (typeof value !== "string" || !/^cm1-sha256:[a-f0-9]{64}$/u.test(value)) fail("candidate must be a canonical digest"); return value; } +function cleanEnvironment(policy) { const environment = {}; for (const name of policy.environment.inherit) if (Object.hasOwn(process.env, name)) environment[name] = process.env[name]; return Object.assign(environment, policy.environment.set); } +function launchSnapshots(repoRoot, policy) { + const cwd = repoTarget(repoRoot, policy.cwd); const snapshots = [snapshotTarget({ root: repoRoot, path: cwd, kind: "directory" })]; + // Executables may live outside the repository. Start at their filesystem + // root so a symlink anywhere in the absolute ancestry is rejected. + snapshots.push(snapshotTarget({ root: parse(policy.argv[0]).root, path: policy.argv[0], kind: "file" })); + for (const path of [...policy.filesystem.read, ...policy.filesystem.write]) { const target = repoTarget(repoRoot, path); const entry = lstatSync(target); snapshots.push(snapshotTarget({ root: repoRoot, path: target, kind: entry.isDirectory() ? "directory" : "file" })); } + return { cwd, snapshots }; +} +function assertSnapshots(snapshots) { for (const snapshot of snapshots) checkSnapshot(snapshot); } +function aggregateCapture(child, maximum) { + const chunks = { stdout: [], stderr: [] }; let total = 0; let truncated = false; let killed = false; + const terminate = () => { if (!killed) { killed = true; try { child.kill("SIGTERM"); } catch { /* direct process may already be gone */ } } }; + for (const name of ["stdout", "stderr"]) child[name]?.on("data", (chunk) => { + const bytes = Buffer.from(chunk); const remaining = Math.max(0, maximum - total); if (remaining) { chunks[name].push(bytes.subarray(0, remaining)); total += Math.min(remaining, bytes.length); } + if (bytes.length > remaining) { truncated = true; terminate(); } + }); + return { result() { return { stdout: Buffer.concat(chunks.stdout), stderr: Buffer.concat(chunks.stderr), total, truncated }; }, terminate }; +} + +export function preflightCapability({ repoRoot, capabilityId }) { + const root = resolve(repoRoot); const catalog = readCapabilityCatalog(root); const resolved = resolveCapability(catalog, capabilityId); const trust = assertTrustedCapability({ repoRoot: root, resolved }); + const policy = trust.grants; const launch = launchSnapshots(root, policy); + return { repoRoot: root, capabilityId, revision: resolved.revision, policyBytes: resolved.bytes, policy, grantsDigest: trust.grantsDigest, launch }; +} +export function revalidateCapability(preflight) { + assertSnapshots(preflight.launch.snapshots); + const fresh = preflightCapability({ repoRoot: preflight.repoRoot, capabilityId: preflight.capabilityId }); + if (fresh.revision !== preflight.revision || !fresh.policyBytes.equals(preflight.policyBytes) || fresh.grantsDigest !== preflight.grantsDigest) fail("capability changed after preflight", "ELOOP_CAPABILITY_CHANGED"); + assertSnapshots(fresh.launch.snapshots); return fresh; +} + +/** Executes a verified direct process only. Child/descendant containment is explicitly unsupported. */ +export function runTrustedCapability({ repoRoot, capabilityId, inputCandidate, preflight, spawn = nodeSpawn }) { + const current = revalidateCapability(preflight ?? preflightCapability({ repoRoot, capabilityId })); const input = candidate(inputCandidate); const [command, ...args] = current.policy.argv; + let child; try { child = spawn(command, args, { cwd: current.launch.cwd, env: cleanEnvironment(current.policy), shell: false, windowsHide: true, stdio: ["ignore", "pipe", "pipe"] }); } catch (error) { return Promise.reject(error); } + const capture = aggregateCapture(child, current.policy.output.maxBytes); let timedOut = false; let forceTimer; + const deadline = setTimeout(() => { timedOut = true; capture.terminate(); forceTimer = setTimeout(() => { try { child.kill("SIGKILL"); } catch { /* already exited */ } }, 100); }, current.policy.maxMilliseconds); + return new Promise((resolveResult, reject) => { + child.once("error", (error) => { clearTimeout(deadline); clearTimeout(forceTimer); reject(error); }); + child.once("close", (code, signal) => { + clearTimeout(deadline); clearTimeout(forceTimer); const captured = capture.result(); const evidence = Buffer.concat([Buffer.from(`candidate=${input}\nstdout-bytes=${captured.stdout.length}\n`, "utf8"), captured.stdout, Buffer.from(`\nstderr-bytes=${captured.stderr.length}\n`, "utf8"), captured.stderr]); + const result = { schema: "capability-evidence@1", capability: capabilityId, capabilityRevision: current.revision, inputCandidate: input, outcome: code === 0 && !signal && !captured.truncated && !timedOut ? "pass" : "fail", exitCode: Number.isInteger(code) ? code : null, truncated: captured.truncated, timedOut, evidenceDigest: rawSha256(evidence), evidenceBytes: evidence.length, guaranteeLabels: GUARANTEE_LABELS }; + resolveResult({ result: Object.freeze(result), evidence: Buffer.from(evidence) }); + }); + }); +} + +export function validateCapabilityEvidence(value) { + const keys = ["schema", "capability", "capabilityRevision", "inputCandidate", "outcome", "exitCode", "truncated", "timedOut", "evidenceDigest", "evidenceBytes", "guaranteeLabels"]; + if (!value || typeof value !== "object" || Array.isArray(value) || Object.keys(value).length !== keys.length || !keys.every((key) => Object.hasOwn(value, key)) || value.schema !== "capability-evidence@1" || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/u.test(value.capability) || !/^cp1-sha256:[a-f0-9]{64}$/u.test(value.capabilityRevision) || !/^cm1-sha256:[a-f0-9]{64}$/u.test(value.inputCandidate) || !["pass", "fail"].includes(value.outcome) || !(Number.isInteger(value.exitCode) && value.exitCode >= 0 && value.exitCode <= 255 || value.exitCode === null) || typeof value.truncated !== "boolean" || typeof value.timedOut !== "boolean" || !/^sha256:[a-f0-9]{64}$/u.test(value.evidenceDigest) || !Number.isSafeInteger(value.evidenceBytes) || value.evidenceBytes < 0 || JSON.stringify(value.guaranteeLabels) !== JSON.stringify(GUARANTEE_LABELS)) fail("invalid capability evidence"); + if (value.outcome === "pass" && (value.exitCode !== 0 || value.truncated || value.timedOut)) fail("invalid passing evidence"); return value; +} diff --git a/src/loops/capabilities/snapshot.mjs b/src/loops/capabilities/snapshot.mjs new file mode 100644 index 00000000..9b54d4da --- /dev/null +++ b/src/loops/capabilities/snapshot.mjs @@ -0,0 +1,129 @@ +import { closeSync, constants, fsyncSync, fstatSync, lstatSync, mkdtempSync, openSync, readSync, rmdirSync, unlinkSync, writeSync } from "node:fs"; +import { createHash } from "node:crypto"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; + +const MAX_EXECUTABLE_BYTES = 256 * 1024 * 1024; +function fail(message) { throw Object.assign(new Error(`Loop capability launch: ${message}`), { code: "ELOOP_CAPABILITY_CHANGED" }); } +function identity(stat) { return { dev: stat.dev, ino: stat.ino, size: stat.size, mode: stat.mode, mtimeMs: stat.mtimeMs, ctimeMs: stat.ctimeMs }; } +function same(left, right) { return left.dev === right.dev && left.ino === right.ino && left.size === right.size && left.mode === right.mode && left.mtimeMs === right.mtimeMs && left.ctimeMs === right.ctimeMs; } +function sameDirectory(left, right) { return left.dev === right.dev && left.ino === right.ino && left.mode === right.mode; } +function nofollow(path, flags = constants.O_RDONLY, mode) { + return mode === undefined + ? openSync(path, flags | (Number.isInteger(constants.O_NOFOLLOW) ? constants.O_NOFOLLOW : 0)) + : openSync(path, flags | (Number.isInteger(constants.O_NOFOLLOW) ? constants.O_NOFOLLOW : 0), mode); +} +function regular(path, maximum) { + const entry = lstatSync(path); if (!entry.isFile() || entry.isSymbolicLink() || entry.size > maximum) fail(`invalid regular file ${path}`); + let fd; try { fd = nofollow(path); const opened = fstatSync(fd); if (!opened.isFile() || !same(identity(entry), identity(opened))) fail(`replaced file ${path}`); return { fd, stat: identity(opened) }; } catch (error) { if (error?.code === "ELOOP") fail(`symbolic link ${path}`); throw error; } +} +function ancestorSnapshots(root, target) { + const rootPath = resolve(root); const parts = target.slice(rootPath.length).split("/").filter(Boolean); const snapshots = []; + let current = rootPath; + for (const part of [null, ...parts.slice(0, -1)]) { + if (part) current = join(current, part); + const entry = lstatSync(current); if (!entry.isDirectory() || entry.isSymbolicLink()) fail(`invalid directory ancestor ${current}`); + snapshots.push({ path: current, identity: identity(entry) }); + } + return snapshots; +} +function checkAncestors(snapshots) { for (const item of snapshots) { const entry = lstatSync(item.path); if (!entry.isDirectory() || entry.isSymbolicLink() || !sameDirectory(item.identity, identity(entry))) fail(`directory changed ${item.path}`); } } +function digestRead(fd, size, maximum) { + if (size > maximum) fail("file exceeds launch snapshot limit"); const hash = createHash("sha256"); const buffer = Buffer.allocUnsafe(Math.min(65536, Math.max(1, size))); let offset = 0; + while (offset < size) { const amount = readSync(fd, buffer, 0, Math.min(buffer.length, size - offset), offset); if (amount <= 0) fail("file changed while snapshotting"); hash.update(buffer.subarray(0, amount)); offset += amount; } + return `sha256:${hash.digest("hex")}`; +} +function copyExact(source, target, size) { + const buffer = Buffer.allocUnsafe(Math.min(65536, Math.max(1, size))); let offset = 0; + while (offset < size) { + const amount = readSync(source, buffer, 0, Math.min(buffer.length, size - offset), offset); + if (amount <= 0) fail("file changed while sealing"); + let written = 0; + while (written < amount) { + const next = writeSync(target, buffer, written, amount - written); + if (next <= 0) fail("failed to seal launch bytes"); + written += next; + } + offset += amount; + } +} +function sealExact(opened, snapshot) { + const directory = mkdtempSync(join(tmpdir(), "burnlist-launch-seal-")); + const path = join(directory, "bytes"); let target; let sealed; + try { + target = nofollow(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o600); + copyExact(opened.fd, target, opened.stat.size); fsyncSync(target); closeSync(target); target = undefined; + sealed = nofollow(path); const state = fstatSync(sealed); + if (!state.isFile() || state.size !== opened.stat.size || digestRead(sealed, state.size, snapshot.maximum) !== snapshot.digest) + fail("sealed launch bytes do not match snapshot"); + unlinkSync(path); rmdirSync(directory); + return sealed; + } catch (error) { + if (sealed !== undefined) closeSync(sealed); + throw error; + } finally { + if (target !== undefined) closeSync(target); + try { unlinkSync(path); } catch (error) { if (error?.code !== "ENOENT") throw error; } + try { rmdirSync(directory); } catch (error) { if (error?.code !== "ENOENT") throw error; } + } +} + +/** Snapshot descriptor identities and bytes required to make a launch decision. */ +export function snapshotTarget({ root, path, kind = "file", maximum = MAX_EXECUTABLE_BYTES }) { + const target = resolve(path); const ancestors = ancestorSnapshots(root, target); checkAncestors(ancestors); + if (kind === "directory") { const entry = lstatSync(target); if (!entry.isDirectory() || entry.isSymbolicLink()) fail(`invalid directory ${target}`); const snapshot = { root: resolve(root), path: target, kind, ancestors, identity: identity(entry) }; checkSnapshot(snapshot); return snapshot; } + const opened = regular(target, maximum); + try { const digest = digestRead(opened.fd, opened.stat.size, maximum); const snapshot = { root: resolve(root), path: target, kind, ancestors, identity: opened.stat, digest, maximum }; checkSnapshot(snapshot); return snapshot; } + finally { closeSync(opened.fd); } +} +export function checkSnapshot(snapshot) { + checkAncestors(snapshot.ancestors); + if (snapshot.kind === "directory") { const entry = lstatSync(snapshot.path); if (!entry.isDirectory() || entry.isSymbolicLink() || !same(snapshot.identity, identity(entry))) fail(`directory changed ${snapshot.path}`); return snapshot; } + const opened = regular(snapshot.path, snapshot.maximum); + try { if (!same(snapshot.identity, opened.stat) || digestRead(opened.fd, opened.stat.size, snapshot.maximum) !== snapshot.digest) fail(`file changed ${snapshot.path}`); } + finally { closeSync(opened.fd); } + return snapshot; +} +/** + * Seal verified bytes into an unlinked private file before an external atomic commit. + * A retained descriptor for the source inode is not safe: an in-place writer can alter + * it without replacing the inode. The returned descriptor has no pathname and cannot + * be changed through a later source-path replacement or write. + */ +export function holdSnapshot(snapshot) { + if (snapshot?.kind !== "file") fail("only file snapshots can be held"); + checkAncestors(snapshot.ancestors); const opened = regular(snapshot.path, snapshot.maximum); + let descriptor; + try { + if (!same(snapshot.identity, opened.stat) || digestRead(opened.fd, opened.stat.size, snapshot.maximum) !== snapshot.digest) + fail(`file changed ${snapshot.path}`); + checkAncestors(snapshot.ancestors); + descriptor = sealExact(opened, snapshot); + if (!same(snapshot.identity, identity(fstatSync(opened.fd))) || digestRead(opened.fd, opened.stat.size, snapshot.maximum) !== snapshot.digest) + fail(`file changed ${snapshot.path}`); + checkAncestors(snapshot.ancestors); + return Object.freeze({ sealedDescriptor: descriptor, snapshot }); + } catch (error) { + if (descriptor !== undefined) closeSync(descriptor); + throw error; + } finally { closeSync(opened.fd); } +} +export function releaseSnapshot(held) { + if (!held || !Number.isInteger(held.sealedDescriptor)) fail("invalid held snapshot"); + closeSync(held.sealedDescriptor); +} +/** Bounded descriptor read for private records; caller receives only verified bytes. */ +export function readSnapshotBytes({ root, path, maximum = 65536 }) { + const target = resolve(path); const ancestors = ancestorSnapshots(root, target); checkAncestors(ancestors); const opened = regular(target, maximum); + try { + const bytes = Buffer.allocUnsafe(opened.stat.size); let offset = 0; + while (offset < bytes.length) { const amount = readSync(opened.fd, bytes, offset, bytes.length - offset, offset); if (amount <= 0) fail(`file changed while reading ${target}`); offset += amount; } + checkAncestors(ancestors); const after = fstatSync(opened.fd); const pathAfter = lstatSync(target); + if (!same(opened.stat, identity(after)) || !same(opened.stat, identity(pathAfter))) fail(`file changed while reading ${target}`); + return { bytes, identity: opened.stat, ancestors }; + } finally { closeSync(opened.fd); } +} +export function repoTarget(repoRoot, value) { + const root = resolve(repoRoot); if (value !== "." && (!value || value.startsWith("/") || value.split("/").some((part) => !part || part === "." || part === ".."))) fail("invalid repository target"); + const path = resolve(root, value); if (!(path === root || path.startsWith(`${root}/`))) fail("repository target escapes root"); return path; +} diff --git a/src/loops/capabilities/trust.mjs b/src/loops/capabilities/trust.mjs new file mode 100644 index 00000000..e59c2985 --- /dev/null +++ b/src/loops/capabilities/trust.mjs @@ -0,0 +1,44 @@ +import { resolve } from "node:path"; +import { rawSha256 } from "../dsl/hash.mjs"; +import { localRecordPath, writeLocalRecord } from "../config/store.mjs"; +import { canonicalCapabilityBytes, canonicalGrantBytes, capabilityRevision, validateCapability, validateCapabilityGrants } from "./contract.mjs"; +import { readSnapshotBytes } from "./snapshot.mjs"; + +const keys = ["schema", "capability", "revision", "policyDigest", "grants", "grantsDigest"]; +const id = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u; +const revision = /^cp1-sha256:[a-f0-9]{64}$/u; +function fail(message, code = "ELOOP_CAPABILITY_TRUST") { throw Object.assign(new Error(`Loop capability trust: ${message}`), { code }); } +function exact(value, names) { return Boolean(value) && typeof value === "object" && !Array.isArray(value) && Object.keys(value).length === names.length && names.every((name) => Object.hasOwn(value, name)); } +function target(repoRoot, capability) { if (!id.test(capability)) fail("invalid capability id"); return localRecordPath(repoRoot, "capabilities", capability); } +function canonical(record) { return Buffer.from(`${JSON.stringify({ schema: record.schema, capability: record.capability, revision: record.revision, policyDigest: record.policyDigest, grants: record.grants, grantsDigest: record.grantsDigest })}\n`, "utf8"); } +function validate(record, policy) { + if (!exact(record, keys) || record.schema !== "burnlist-loop-capability-trust@1" || !id.test(record.capability) || !revision.test(record.revision) || !/^sha256:[a-f0-9]{64}$/u.test(record.policyDigest) || !/^sha256:[a-f0-9]{64}$/u.test(record.grantsDigest)) fail("record has invalid schema"); + const grants = validateCapabilityGrants(record.grants, policy); if (rawSha256(canonicalGrantBytes(grants, policy)) !== record.grantsDigest) fail("record grants digest mismatch"); + return { ...record, grants }; +} +function privateFile(path, stat) { if (!stat || (stat.mode & 0o077) !== 0) fail(`record is not private: ${path}`); } +/** CLI-owned atomic trust record. A new explicit trust replaces only this capability's record. */ +export function trustCapability({ repoRoot, capability, grants }) { + const policy = validateCapability(capability); const narrowed = validateCapabilityGrants(grants, policy); + const record = validate({ schema: "burnlist-loop-capability-trust@1", capability: policy.id, revision: capabilityRevision(policy), policyDigest: rawSha256(canonicalCapabilityBytes(policy)), grants: narrowed, grantsDigest: rawSha256(canonicalGrantBytes(narrowed, policy)) }, policy); + try { return writeLocalRecord({ repoRoot, collection: "capabilities", name: policy.id, value: record, validate: (value) => validate(value, policy), replaceInvalidCodes: ["ELOOP_CAPABILITY"] }); } + catch (error) { + if (error?.code === "ELOOP_CONFIG" && /unsafe config directory/u.test(error.message)) fail(error.message.replace(/^Loop local config: unsafe config directory /u, "unsafe trust directory ")); + throw error; + } +} + +/** No-follow, bounded record read with ancestor and leaf identity checks. */ +export function readTrustedCapability({ repoRoot, capability, policy }) { + const path = target(repoRoot, capability); let read; + try { read = readSnapshotBytes({ root: resolve(repoRoot), path, maximum: 65536 }); privateFile(path, read.identity); } + catch (error) { if (error?.code === "ENOENT") fail(`capability ${capability} is untrusted`, "ELOOP_CAPABILITY_UNTRUSTED"); throw error; } + let value; try { value = JSON.parse(read.bytes.toString("utf8")); } catch { fail("record is not JSON"); } + const record = validate(value, policy); if (!canonical(record).equals(read.bytes)) fail("record is not canonical"); return record; +} +export function assertTrustedCapability({ repoRoot, resolved }) { + let record; try { record = readTrustedCapability({ repoRoot, capability: resolved.policy.id, policy: resolved.policy }); } + catch (error) { if (error?.code === "ELOOP_CAPABILITY") fail(`capability ${resolved.policy.id} changed after trust`, "ELOOP_CAPABILITY_CHANGED"); throw error; } + if (record.revision !== resolved.revision || record.policyDigest !== rawSha256(resolved.bytes)) fail(`capability ${resolved.policy.id} changed after trust`, "ELOOP_CAPABILITY_CHANGED"); + return record; +} diff --git a/src/loops/completion/completion.mjs b/src/loops/completion/completion.mjs new file mode 100644 index 00000000..c86d5172 --- /dev/null +++ b/src/loops/completion/completion.mjs @@ -0,0 +1,127 @@ +import { createHash, randomBytes } from "node:crypto"; +import { closeSync, constants, fsyncSync, lstatSync, openSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; +import { basename, dirname, join, resolve } from "node:path"; +import { checklistCompletion, findBurnlistDir, withLock } from "../../cli/lifecycle-moves.mjs"; +import { localIsoTimestamp, parsePlan, validatePlan } from "../../server/plan-model.mjs"; +import { publishOvenEvent } from "../../events/oven-event-store.mjs"; +import { assignmentStore } from "../assignment/store.mjs"; +import { locateItemSpan, validateAssignedItem } from "../assignment/item-metadata.mjs"; +import { parseItemRef } from "../assignment/selectors.mjs"; +import { loadFrozenRecipe } from "../dsl/frozen.mjs"; +import { runStore } from "../run/run-store.mjs"; + +const RECEIPT = "completion-receipt.json", INTENT = "completion-intent.json"; +const SHA = /^[a-f0-9]{64}$/u, RUN = /^run:[0-9a-z]{26}$/u, ASSIGNMENT = /^as1-sha256:[a-f0-9]{64}$/u; +const ITEM = /^item:[0-9]{6}-[0-9]{3}#[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/u; +const ISO = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2}$/u; +function fail(message, code = "ELOOP_COMPLETION") { throw Object.assign(new Error(`Loop completion: ${message}`), { code }); } +function exact(value, keys) { return Boolean(value) && typeof value === "object" && !Array.isArray(value) && Object.keys(value).length === keys.length && keys.every((key, index) => Object.keys(value)[index] === key); } +function digest(bytes) { return createHash("sha256").update(bytes).digest("hex"); } +function syncDirectory(path) { const fd = openSync(path, constants.O_RDONLY); try { fsyncSync(fd); } finally { closeSync(fd); } } +function lifecycleIdentity(repoRoot, id, directory, expected = null) { + const root = resolve(repoRoot), paths = [join(root, "notes"), join(root, "notes", "burnlists"), join(root, "notes", "burnlists", "inprogress"), join(root, "notes", "burnlists", "inprogress", id)]; + if (resolve(directory) !== paths.at(-1)) fail("lifecycle directory escapes the repository", "ELOOP_LIFECYCLE_PATH"); + const identities = paths.map((path) => { const stat = lstatSync(path); if (!stat.isDirectory() || stat.isSymbolicLink()) fail("lifecycle path is not a real directory", "ELOOP_LIFECYCLE_PATH"); return { path, dev: stat.dev, ino: stat.ino }; }); + if (expected && identities.some((value, index) => value.dev !== expected[index].dev || value.ino !== expected[index].ino)) fail("lifecycle path changed during completion", "ELOOP_LIFECYCLE_PATH"); + return identities; +} +function atomicWrite(path, bytes) { + const temporary = join(dirname(path), `.${basename(path)}.${randomBytes(8).toString("hex")}.tmp`); let fd; + try { fd = openSync(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o600); writeFileSync(fd, bytes); fsyncSync(fd); closeSync(fd); fd = undefined; renameSync(temporary, path); syncDirectory(dirname(path)); } + finally { if (fd !== undefined) closeSync(fd); rmSync(temporary, { force: true }); } +} +function atomicPlanWrite(path, bytes) { + const temporary = join(dirname(path), `.${basename(path)}.${randomBytes(8).toString("hex")}.tmp`); let fd; + try { + fd = openSync(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o666); writeFileSync(fd, bytes); fsyncSync(fd); closeSync(fd); fd = undefined; + const staged = parsePlan(temporary); validateOrThrow(staged); renameSync(temporary, path); syncDirectory(dirname(path)); return staged; + } finally { if (fd !== undefined) closeSync(fd); rmSync(temporary, { force: true }); } +} +function readCanonical(path, label) { + let stat; try { stat = lstatSync(path); } catch (error) { if (error?.code === "ENOENT") return null; throw error; } + if (!stat.isFile() || stat.isSymbolicLink() || stat.size < 2 || stat.size > 8192) fail(`${label} is corrupt`); + const bytes = readFileSync(path); let value; try { value = JSON.parse(bytes); } catch { fail(`${label} is corrupt`); } + if (!Buffer.from(`${JSON.stringify(value)}\n`).equals(bytes)) fail(`${label} is not canonical`); return value; +} +function validRecord(value, label) { + const keys = ["schema", "runId", "itemRef", "assignmentId", "completedAt", "title", "planDigest"]; + if (!exact(value, keys) || value.schema !== "burnlist-loop-completion@1" || !RUN.test(value.runId) || !ITEM.test(value.itemRef) || !ASSIGNMENT.test(value.assignmentId) || !ISO.test(value.completedAt) || typeof value.title !== "string" || !value.title || Buffer.byteLength(value.title) > 4096 || !SHA.test(value.planDigest)) fail(`${label} is invalid`); + return Object.freeze({ ...value }); +} +function entryFor(record) { return `- ${parseItemRef(record.itemRef).itemId} | ${record.completedAt} | ${record.title}`; } +function activeRange(lines) { const start = lines.findIndex((line) => line.trim() === "## Active Checklist"); if (start < 0) fail("active checklist section is missing"); const end = lines.findIndex((line, index) => index > start && /^##\s+/u.test(line)); return { start, end: end < 0 ? lines.length : end }; } +function itemIdFor(line) { return line.match(/^- \[[ xX]\]\s+([^|]+?)(?:\s+\|\s+.+)?$/u)?.[1]?.trim() ?? null; } +function removeDetailBlock(lines, itemId) { + const { start, end } = activeRange(lines), escaped = itemId.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"), heading = new RegExp(`^###\\s+${escaped}(?:\\s*\\||\\s*$)`, "u"); + const at = lines.findIndex((line, index) => index > start && index < end && heading.test(line)); if (at < 0) return; + const next = lines.findIndex((line, index) => index > at && /^#{1,3}\s+/u.test(line)); lines.splice(at, (next < 0 ? end : next) - at); +} +function completedMarkdown(markdown, record) { + const itemId = parseItemRef(record.itemRef).itemId, lines = markdown.split(/\r?\n/u), { start, end } = activeRange(lines); + const at = lines.findIndex((line, index) => index > start && index < end && itemIdFor(line) === itemId); if (at < 0) fail(`active item ${itemId} was not found`); + const next = lines.findIndex((line, index) => index > at && index < end && itemIdFor(line)); lines.splice(at, (next < 0 ? end : next) - at); removeDetailBlock(lines, itemId); + const heading = lines.findIndex((line) => line.trim() === "## Completed"); + if (heading < 0) { while (lines.length && !lines.at(-1).trim()) lines.pop(); lines.push("", "## Completed", entryFor(record), ""); } + else { const after = lines.findIndex((line, index) => index > heading && /^##\s+/u.test(line)); lines.splice(after < 0 ? lines.length : after, 0, entryFor(record)); } + return `${lines.join("\n").replace(/\s*$/u, "")}\n`; +} +function validateOrThrow(plan) { const errors = validatePlan(plan).filter((issue) => issue.severity === "error"); if (errors.length) fail(errors.map((issue) => issue.message).join(" ")); } +function assertApplied(plan, record) { + const itemId = parseItemRef(record.itemRef).itemId; + if (plan.items.some((item) => item.id === itemId)) fail("receipt conflicts with an active item"); + const completed = plan.completed.filter((item) => item.id === itemId && item.completedAt === record.completedAt && item.title === record.title); + if (completed.length !== 1) fail("receipt does not match the current Burnlist"); +} +function assertCurrentAssignment({ repoRoot, planBytes, plan, authority, replay, store }) { + if (replay.projection.state !== "converged" || replay.projection.leaseHeld) fail("Run is not converged and idle", "ERUN_NOT_CONVERGED"); + if (replay.projection.itemRef !== authority.itemRef || authority.runId !== replay.projection.runId) fail("Run authority does not match its journal"); + const current = store.readCurrentRun?.(authority.itemRef); + if (!current || current.runId !== replay.projection.runId || current.assignmentId !== authority.assignmentId) fail("Run is superseded or not current for its assigned item", "ESTALE_RUN"); + const item = parseItemRef(authority.itemRef); let metadata, artifact; + try { metadata = validateAssignedItem(item.selector, locateItemSpan(planBytes, item.itemId)); artifact = assignmentStore(repoRoot).load(metadata["Assignment-Id"]); } + catch { fail("assigned item no longer matches the Run", "ESTALE_ASSIGNMENT"); } + if (metadata["Assignment-Id"] !== authority.assignmentId || artifact.assignmentId !== authority.assignmentId || artifact.itemRef !== authority.itemRef || artifact.assignedItemDigest !== authority.itemRevision || metadata.assignedDigest !== authority.itemRevision || artifact.executionRevision !== metadata["Execution-Revision"] || artifact.packageRevision !== metadata["Package-Revision"]) fail("assigned item no longer matches the Run", "ESTALE_ASSIGNMENT"); + const frozen = loadFrozenRecipe(Buffer.from(authority.frozenRecipe, "base64")); if (JSON.stringify(frozen.ir) !== JSON.stringify(replay.graph)) fail("Run graph does not match its sealed assignment"); + return { item, title: plan.items.find((entry) => entry.id === item.itemId)?.title }; +} +function assertAuthority(authority, runId) { + if (!authority || authority.schema !== "burnlist-loop-m12-run-authority@1" || authority.runId !== runId || !ITEM.test(authority.itemRef) || !ASSIGNMENT.test(authority.assignmentId) || !/^id1-sha256:[a-f0-9]{64}$/u.test(authority.itemRevision)) fail("sealed Run authority is unavailable"); return authority; +} +function recordFor({ authority, completedAt, title, planDigest }) { return Object.freeze({ schema: "burnlist-loop-completion@1", runId: authority.runId, itemRef: authority.itemRef, assignmentId: authority.assignmentId, completedAt, title, planDigest }); } +function publish(repoRoot, outcome) { if (!outcome.applied) return; try { publishOvenEvent(repoRoot, outcome.event); } catch (error) { console.warn(`Completed ${outcome.item.id}, but could not publish its observational Oven event: ${error.message}`); } } + +/** Complete exactly the sealed, converged Run under the normal lifecycle lock. */ +export function completeLoopRun({ repoRoot, runId, store = runStore(repoRoot), hooks = {} }) { + if (!RUN.test(runId) || !store?.read || !store?.readAuthority || !store?.readCurrentRun || !store?.paths?.pathFor) fail("invalid completion input"); + const replay = store.read(runId), authority = assertAuthority(store.readAuthority(runId), runId), item = parseItemRef(authority.itemRef), found = findBurnlistDir(repoRoot, item.burnlistId); + if (found.lifecycle.folder !== "inprogress") fail(`Burnlist ${item.burnlistId} is not inprogress`); + const lifecycle = lifecycleIdentity(repoRoot, item.burnlistId, found.dir); + const result = withLock(found.dir, () => { + lifecycleIdentity(repoRoot, item.burnlistId, found.dir, lifecycle); + const planPath = join(found.dir, "burnlist.md"), runDir = store.paths.pathFor(runId), receiptPath = join(runDir, RECEIPT), intentPath = join(runDir, INTENT), bytes = readFileSync(planPath), plan = parsePlan(planPath); + validateOrThrow(plan); const receipt = readCanonical(receiptPath, "completion receipt"); + if (receipt) { + const applied = validRecord(receipt, "completion receipt"); + if (applied.runId !== runId || applied.itemRef !== authority.itemRef || applied.assignmentId !== authority.assignmentId) fail("receipt belongs to another Run"); + assertApplied(plan, applied); + const pending = readCanonical(intentPath, "completion intent"); + if (pending) { + const intent = validRecord(pending, "completion intent"); + if (JSON.stringify(intent) !== JSON.stringify(applied)) fail("completion intent conflicts with its receipt"); + rmSync(intentPath, { force: true }); syncDirectory(runDir); + } + return { applied: false, item: plan.completed.find((entry) => entry.id === item.itemId), record: applied, event: null }; + } + let record = readCanonical(intentPath, "completion intent"); + if (record) { record = validRecord(record, "completion intent"); if (record.runId !== runId || record.itemRef !== authority.itemRef || record.assignmentId !== authority.assignmentId) fail("completion intent belongs to another Run"); const completed = plan.completed.filter((entry) => entry.id === item.itemId && entry.completedAt === record.completedAt && entry.title === record.title); if (completed.length === 1) { atomicWrite(receiptPath, Buffer.from(`${JSON.stringify(record)}\n`)); rmSync(intentPath, { force: true }); syncDirectory(runDir); return { applied: false, item: completed[0], record, event: null }; } } + const current = assertCurrentAssignment({ repoRoot, planBytes: bytes, plan, authority, replay, store }); if (!current.title) fail("Run item title is unavailable"); + const completedAt = record?.completedAt ?? localIsoTimestamp(), provisional = recordFor({ authority, completedAt, title: current.title, planDigest: "0".repeat(64) }), next = completedMarkdown(plan.markdown, provisional); + record = recordFor({ authority, completedAt, title: current.title, planDigest: digest(Buffer.from(next)) }); + const existingIntent = readCanonical(intentPath, "completion intent"); + if (existingIntent && validRecord(existingIntent, "completion intent").planDigest !== record.planDigest) fail("completion intent no longer matches the active Burnlist", "ESTALE_INTENT"); + if (!existingIntent) { atomicWrite(intentPath, Buffer.from(`${JSON.stringify(record)}\n`)); hooks.afterIntent?.(record); } + lifecycleIdentity(repoRoot, item.burnlistId, found.dir, lifecycle); const staged = atomicPlanWrite(planPath, Buffer.from(next)); hooks.afterPlan?.(record); atomicWrite(receiptPath, Buffer.from(`${JSON.stringify(record)}\n`)); hooks.afterReceipt?.(record); rmSync(intentPath, { force: true }); syncDirectory(runDir); + return { applied: true, item: { id: item.itemId, title: current.title }, record, event: checklistCompletion(item.burnlistId, { id: item.itemId, title: current.title }, record.completedAt, staged) }; + }); + publish(repoRoot, result); return Object.freeze({ runId, itemRef: authority.itemRef, assignmentId: authority.assignmentId, completedAt: result.record.completedAt, alreadyApplied: !result.applied }); +} diff --git a/src/loops/completion/completion.test.mjs b/src/loops/completion/completion.test.mjs new file mode 100644 index 00000000..a5b5561d --- /dev/null +++ b/src/loops/completion/completion.test.mjs @@ -0,0 +1,128 @@ +import assert from "node:assert/strict"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { burnItem, closeLifecycle } from "../../cli/lifecycle-moves.mjs"; +import { prepareItemMutation, unassignLoopItem } from "../assignment/assignment.mjs"; +import { createProductionRun, createStoredProductionRunRunner } from "../run/binder.mjs"; +import { createProductionRunAuthority, fixtureItemRef, fixtureRunId } from "../run/run-test-fixtures.mjs"; +import { runStore } from "../run/run-store.mjs"; +import { completeLoopRun } from "./completion.mjs"; + +function context(t) { + const directory = mkdtempSync(join(tmpdir(), "burnlist-completion-")); + t.after(() => rmSync(directory, { recursive: true, force: true })); + const { repo } = createProductionRunAuthority(join(directory, "repo")); + return { directory, repo, planPath: join(repo, "notes", "burnlists", "inprogress", "260722-001", "burnlist.md") }; +} +function unassignedContext(t) { + const directory = mkdtempSync(join(tmpdir(), "burnlist-completion-control-")), repo = join(directory, "repo"), planPath = join(repo, "notes", "burnlists", "inprogress", "260722-001", "burnlist.md"); + mkdirSync(join(repo, "notes", "burnlists", "inprogress", "260722-001"), { recursive: true }); + writeFileSync(planPath, "# Runner\n\n## Active Checklist\n- [ ] L29 | Exercise production authority\n\n## Completed\n"); + t.after(() => rmSync(directory, { recursive: true, force: true })); return { repo, planPath }; +} +async function converged(context, runId = fixtureRunId) { + const store = runStore(context.repo); + await createProductionRun({ repoRoot: context.repo, store, itemRef: fixtureItemRef, runId }); + const counter = join(context.directory, `counter-${runId.slice(-4)}`); writeFileSync(counter, "0"); + const before = [process.env.BURNLIST_FAKE_COUNTER, process.env.BURNLIST_FAKE_OUTCOMES]; + process.env.BURNLIST_FAKE_COUNTER = counter; process.env.BURNLIST_FAKE_OUTCOMES = "complete,approve"; + try { assert.equal((await createStoredProductionRunRunner({ repoRoot: context.repo, store, runId }).run()).projection.state, "converged"); } + finally { + for (const [key, value] of [["BURNLIST_FAKE_COUNTER", before[0]], ["BURNLIST_FAKE_OUTCOMES", before[1]]]) { + if (value === undefined) delete process.env[key]; else process.env[key] = value; + } + } + return store; +} +function runPath(store, runId, name) { return join(store.paths.pathFor(runId), name); } +function completedLines(context) { return readFileSync(context.planPath, "utf8").match(/^- L29 \| .+$/gmu) ?? []; } + +test("completion burns one exact converged assignment, writes one receipt, and retries idempotently", async (t) => { + const value = context(t), store = await converged(value); + const first = completeLoopRun({ repoRoot: value.repo, runId: fixtureRunId, store }); + assert.equal(first.alreadyApplied, false); + assert.equal(readFileSync(value.planPath, "utf8").includes("- [ ] L29"), false); + assert.equal(completedLines(value).length, 1); + assert.equal(existsSync(runPath(store, fixtureRunId, "completion-receipt.json")), true); + writeFileSync(value.planPath, `${readFileSync(value.planPath, "utf8").trimEnd()}\n\nUnrelated lifecycle note.\n`); + const second = completeLoopRun({ repoRoot: value.repo, runId: fixtureRunId, store }); + assert.equal(second.alreadyApplied, true); assert.equal(completedLines(value).length, 1); +}); + +test("completion resumes every durable interruption cut without a duplicate ledger entry", async (t) => { + for (const cut of ["afterIntent", "afterPlan", "afterReceipt"]) { + const value = context(t), store = await converged(value); + assert.throws(() => completeLoopRun({ repoRoot: value.repo, runId: fixtureRunId, store, hooks: { [cut]() { throw new Error(`cut:${cut}`); } } }), new RegExp(`cut:${cut}`, "u")); + const markdown = readFileSync(value.planPath, "utf8"); + if (cut === "afterIntent") assert.match(markdown, /- \[ \] L29/u); else assert.equal(completedLines(value).length, 1, cut); + const resumed = completeLoopRun({ repoRoot: value.repo, runId: fixtureRunId, store: runStore(value.repo) }); + assert.equal(resumed.alreadyApplied, cut !== "afterIntent"); assert.equal(completedLines(value).length, 1, cut); + assert.equal(existsSync(runPath(store, fixtureRunId, "completion-intent.json")), false, cut); + } +}); + +test("a pending after-plan Loop intent blocks lifecycle close until its CLI retry seals the receipt", async (t) => { + const value = context(t), store = await converged(value); + assert.throws(() => completeLoopRun({ repoRoot: value.repo, runId: fixtureRunId, store, hooks: { afterPlan() { throw new Error("cut:afterPlan"); } } }), /cut:afterPlan/u); + assert.throws(() => closeLifecycle(value.repo, "260722-001"), /pending Loop completion/u); + assert.equal(completeLoopRun({ repoRoot: value.repo, runId: fixtureRunId, store: runStore(value.repo) }).alreadyApplied, true); + assert.doesNotThrow(() => closeLifecycle(value.repo, "260722-001")); +}); + +test("completion rejects a prepared Run and a stale assigned item", async (t) => { + const pending = context(t), pendingStore = runStore(pending.repo); + await createProductionRun({ repoRoot: pending.repo, store: pendingStore, itemRef: fixtureItemRef, runId: fixtureRunId }); + assert.throws(() => completeLoopRun({ repoRoot: pending.repo, runId: fixtureRunId, store: pendingStore }), /not converged/u); + + const stale = context(t), staleStore = await converged(stale); + writeFileSync(stale.planPath, readFileSync(stale.planPath, "utf8").replace("Exercise production authority", "Changed after Run creation")); + assert.throws(() => completeLoopRun({ repoRoot: stale.repo, runId: fixtureRunId, store: staleStore }), /assigned item no longer matches/u); + assert.match(readFileSync(stale.planPath, "utf8"), /- \[ \] L29/u); + + const superseded = context(t), firstStore = runStore(superseded.repo); + await createProductionRun({ repoRoot: superseded.repo, store: firstStore, itemRef: fixtureItemRef, runId: fixtureRunId }); + firstStore.terminalize(fixtureRunId, firstStore.acquireLease(fixtureRunId).lease, "cancelled", "test"); + const currentId = "run:01arz3ndektsv4rrffq69g5faw", currentStore = await converged(superseded, currentId); + assert.throws(() => completeLoopRun({ repoRoot: superseded.repo, runId: fixtureRunId, store: firstStore }), /not converged/u); + assert.equal(completeLoopRun({ repoRoot: superseded.repo, runId: currentId, store: currentStore }).alreadyApplied, false); +}); + +test("receipt and Run ambiguity evidence fail closed when corrupt, bounded, symlinked, or duplicate", async (t) => { + const value = context(t), store = await converged(value), receipt = runPath(store, fixtureRunId, "completion-receipt.json"); + symlinkSync(value.planPath, receipt); assert.throws(() => completeLoopRun({ repoRoot: value.repo, runId: fixtureRunId, store }), /receipt is corrupt/u); rmSync(receipt); + writeFileSync(receipt, "x".repeat(9000)); assert.throws(() => completeLoopRun({ repoRoot: value.repo, runId: fixtureRunId, store }), /receipt is corrupt/u); rmSync(receipt); + writeFileSync(receipt, "{}\n"); assert.throws(() => completeLoopRun({ repoRoot: value.repo, runId: fixtureRunId, store }), /receipt is invalid/u); rmSync(receipt); + completeLoopRun({ repoRoot: value.repo, runId: fixtureRunId, store }); + const record = JSON.parse(readFileSync(receipt, "utf8")); + writeFileSync(value.planPath, `${readFileSync(value.planPath, "utf8").trimEnd()}\n${`- L29 | ${record.completedAt} | ${record.title}`}\n`); + assert.throws(() => completeLoopRun({ repoRoot: value.repo, runId: fixtureRunId, store }), /Duplicate completed id/u); +}); + +test("completion rejects a symlinked lifecycle directory without touching its outside target", async (t) => { + const value = context(t), store = await converged(value), lifecycle = join(value.repo, "notes", "burnlists", "inprogress", "260722-001"), outside = join(value.directory, "outside"); + mkdirSync(outside); const outsidePlan = join(outside, "burnlist.md"), original = "# Outside\n\n## Active Checklist\n- [ ] L29 | Outside item\n\n## Completed\n"; + writeFileSync(outsidePlan, original); renameSync(lifecycle, `${lifecycle}.saved`); symlinkSync(outside, lifecycle, "dir"); + assert.throws(() => completeLoopRun({ repoRoot: value.repo, runId: fixtureRunId, store }), /lifecycle path is not a real directory/u); + assert.equal(readFileSync(outsidePlan, "utf8"), original); +}); + +test("direct burn cannot bypass Loop metadata, while a safe unassign restores direct bytes", async (t) => { + const value = context(t); + assert.throws(() => burnItem(value.repo, "260722-001", "L29"), /direct burn is blocked by Loop metadata/u); + const prepared = prepareItemMutation({ repoRoot: value.repo, itemRef: fixtureItemRef }); + const result = unassignLoopItem({ repoRoot: value.repo, itemRef: fixtureItemRef, prepared }); + assert.match(result.assignmentId, /^as1-sha256:/u); + assert.equal(burnItem(value.repo, "260722-001", "L29"), true); +}); + +test("a direct burn is byte-compatible after safe unassign against a never-assigned control", (t) => { + const control = unassignedContext(t), value = context(t), expected = "# Runner\n\n## Active Checklist\n- [ ] L29 | Exercise production authority\n\n## Completed\n", completedAt = "2026-07-24T12:00:00+00:00"; + assert.deepEqual(readFileSync(control.planPath, "utf8"), expected); + const prepared = prepareItemMutation({ repoRoot: value.repo, itemRef: fixtureItemRef }); + unassignLoopItem({ repoRoot: value.repo, itemRef: fixtureItemRef, prepared }); + assert.equal(burnItem(control.repo, "260722-001", "L29", false, { completedAt }), true); + assert.equal(burnItem(value.repo, "260722-001", "L29", false, { completedAt }), true); + assert.deepEqual(readFileSync(value.planPath, "utf8"), readFileSync(control.planPath, "utf8")); +}); diff --git a/src/loops/config/config.test.mjs b/src/loops/config/config.test.mjs new file mode 100644 index 00000000..7ec2cf7b --- /dev/null +++ b/src/loops/config/config.test.mjs @@ -0,0 +1,112 @@ +import assert from "node:assert/strict"; +import { execFileSync, spawnSync } from "node:child_process"; +import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import test from "node:test"; +import { localRecordPath, configRoot, writeLocalRecord } from "./store.mjs"; +import { capabilityRevision, readCapabilityCatalog } from "../capabilities/contract.mjs"; +import { validateProfile } from "./profiles.mjs"; + +const root = resolve(new URL("../../..", import.meta.url).pathname); +const cli = join(root, "bin", "burnlist.mjs"); +const policy = { id: "repo-verify", argv: [process.execPath, "-e", "process.exit(0)"], cwd: ".", environment: { inherit: ["PATH"], set: {} }, network: "deny", filesystem: { read: ["src"], write: [] }, output: { maxBytes: 1024 }, maxMilliseconds: 1000 }; + +function fixture() { + const directory = realpathSync(mkdtempSync(join(tmpdir(), "burnlist-loop-config-"))), repo = join(directory, "repo"); + mkdirSync(join(repo, ".burnlist"), { recursive: true }); mkdirSync(join(repo, "src")); execFileSync("git", ["init", "--quiet", repo]); + writeFileSync(join(repo, ".burnlist", "loop-capabilities.json"), `${JSON.stringify({ schema: "burnlist-loop-capabilities@1", capabilities: [policy] })}\n`); + const marker = join(directory, "child-ran"), binary = join(directory, "fake-codex"); + writeFileSync(binary, `#!/bin/sh\necho ran > ${JSON.stringify(marker)}\n`, { mode: 0o700 }); + return { directory, repo, binary, marker, cleanup: () => rmSync(directory, { recursive: true, force: true }) }; +} +function run(context, args) { return spawnSync(process.execPath, [cli, ...args, "--repo", context.repo], { cwd: context.repo, encoding: "utf8" }); } +function profile(context, id, authority) { + const result = run(context, ["agent", "profile", "add", id, "--adapter", "builtin:codex-cli", "--binary", context.binary, "--model", "gpt-5.6-terra", "--effort", "medium", "--authority", authority]); + assert.equal(result.status, 0, result.stderr); return result; +} +function route(context, name, id) { const result = run(context, ["route", "set", name, "--profile", id]); assert.equal(result.status, 0, result.stderr); } +function trust(context) { + const grants = join(context.repo, "grants.json"); + writeFileSync(grants, `${JSON.stringify(Object.fromEntries(Object.entries(policy).filter(([key]) => key !== "id")))}\n`); + const revision = capabilityRevision(readCapabilityCatalog(context.repo).capabilities[0]); + const result = run(context, ["loop", "capability", "trust", "repo-verify", "--revision", revision, "--grants", grants]); + assert.equal(result.status, 0, result.stderr); +} + +test("M1 setup trusts only private configuration, exact routes, and repo-verify without launching", () => { + const context = fixture(); + try { + const empty = run(context, ["loop", "setup", "status"]); + assert.equal(empty.status, 1); assert.match(empty.stdout, /MISSING route implementation\.standard/u); assert.match(empty.stdout, /MISSING trust repo-verify/u); + const maker = profile(context, "maker", "write"); + assert.equal(readFileSync(localRecordPath(context.repo, "profiles", "maker"), "utf8"), maker.stdout); + assert.equal(lstatSync(localRecordPath(context.repo, "profiles", "maker")).mode & 0o777, 0o600); + assert.equal(lstatSync(configRoot(context.repo)).mode & 0o777, 0o700); + route(context, "implementation.standard", "maker"); profile(context, "reviewer", "read"); route(context, "review.strong", "reviewer"); trust(context); + const status = run(context, ["loop", "setup", "status"]); + assert.equal(status.status, 0, status.stderr); assert.equal(status.stdout, "Loop setup: ready\n"); + assert.equal(existsSync(context.marker), false); + } finally { context.cleanup(); } +}); + +test("fresh repositories receive actionable capability and profile guidance", () => { + const directory = realpathSync(mkdtempSync(join(tmpdir(), "burnlist-loop-fresh-"))), repo = join(directory, "repo"); + try { + mkdirSync(repo, { recursive: true }); execFileSync("git", ["init", "--quiet", repo]); + const inspect = spawnSync(process.execPath, [cli, "loop", "capability", "inspect", "repo-verify", "--repo", repo], { cwd: repo, encoding: "utf8" }); + assert.equal(inspect.status, 1); assert.match(inspect.stderr, /create \.burnlist\/loop-capabilities\.json/u); + const setup = spawnSync(process.execPath, [cli, "loop", "setup", "status", "--repo", repo], { cwd: repo, encoding: "utf8" }); + assert.equal(setup.status, 1); assert.match(setup.stdout, /Review Loop capability example/u); + const invalid = spawnSync(process.execPath, [cli, "agent", "profile", "add", "maker", "--adapter", "builtin:codex-cli", "--binary", process.execPath, "--model", "unknown", "--effort", "fast", "--authority", "write", "--repo", repo], { cwd: repo, encoding: "utf8" }); + assert.equal(invalid.status, 1); assert.match(invalid.stderr, /model must be one of:.*gpt-5\.6-terra/u); + } finally { rmSync(directory, { recursive: true, force: true }); } +}); + +test("M1 setup fails closed for duplicate, malformed, wrong-authority, and untrusted configuration", () => { + for (const scenario of ["duplicate", "wrong-authority", "malformed", "untrusted"]) { + const context = fixture(); + try { + profile(context, "maker", "write"); route(context, "implementation.standard", "maker"); + if (scenario === "duplicate") route(context, "review.strong", "maker"); + else { profile(context, "reviewer", scenario === "wrong-authority" ? "write" : "read"); route(context, "review.strong", "reviewer"); } + if (scenario === "malformed") writeFileSync(localRecordPath(context.repo, "profiles", "reviewer"), "{}\n", { mode: 0o600 }); + if (scenario !== "untrusted") trust(context); + const result = run(context, ["loop", "setup", "status"]); + assert.equal(result.status, 1, scenario); assert.match(result.stdout, /MISSING (routing|profile|trust)/u); assert.equal(existsSync(context.marker), false); + } finally { context.cleanup(); } + } +}); + +test("legacy Docker commands are unsupported and configuration writes fail closed when public", () => { + const context = fixture(); + try { + for (const args of [["agent", "controller", "add", "host"], ["agent", "preflight", "maker"]]) { + const result = run(context, args); assert.equal(result.status, 2); assert.match(result.stderr, /Usage: burnlist agent profile add/u); + } + profile(context, "maker", "write"); chmodSync(configRoot(context.repo), 0o755); + const result = run(context, ["route", "set", "implementation.standard", "--profile", "maker"]); + assert.equal(result.status, 1); assert.match(result.stderr, /unsafe config directory/u); + } finally { context.cleanup(); } +}); + +test("setup read fails closed after config-v1 privacy drifts without mutation", () => { + const context = fixture(); + try { + profile(context, "maker", "write"); route(context, "implementation.standard", "maker"); profile(context, "reviewer", "read"); route(context, "review.strong", "reviewer"); trust(context); + const profilePath = localRecordPath(context.repo, "profiles", "maker"), before = readFileSync(profilePath); + chmodSync(configRoot(context.repo), 0o755); + const result = run(context, ["loop", "setup", "status"]); + assert.equal(result.status, 1); assert.match(result.stdout, /unsafe config directory/u); + assert.deepEqual(readFileSync(profilePath), before); assert.equal(existsSync(context.marker), false); + } finally { context.cleanup(); } +}); + +test("private profile publication is atomic when publication is interrupted", () => { + const context = fixture(); + try { + const value = { schema: "burnlist-loop-agent-profile@1", id: "maker", adapter: "builtin:codex-cli", binary: context.binary, model: "gpt-5.6-terra", effort: "medium", authority: "write" }; + assert.throws(() => writeLocalRecord({ repoRoot: context.repo, collection: "profiles", name: "maker", value, validate: validateProfile, hooks: { beforeRename() { throw new Error("cut"); } } }), /cut/u); + assert.equal(existsSync(localRecordPath(context.repo, "profiles", "maker")), false); + } finally { context.cleanup(); } +}); diff --git a/src/loops/config/profiles.mjs b/src/loops/config/profiles.mjs new file mode 100644 index 00000000..32dba252 --- /dev/null +++ b/src/loops/config/profiles.mjs @@ -0,0 +1,50 @@ +import { isClosedObject, readLocalRecord, writeLocalRecord } from "./store.mjs"; +import { CODEX_MODELS, REASONING_EFFORTS, validateAgentProfile } from "../agents/profile.mjs"; +import { parse } from "node:path"; +import { snapshotTarget } from "../capabilities/snapshot.mjs"; + +const slug = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u; +const route = /^(?:implementation\.standard|review\.strong)$/u; +const ROUTE_KEYS = ["schema", "route", "profile"]; + +function fail(message) { throw Object.assign(new Error(`Loop profile: ${message}`), { code: "ELOOP_PROFILE" }); } +function profileName(value, label = "profile slug") { if (typeof value !== "string" || !slug.test(value)) fail(`invalid ${label}`); return value; } + +export function validateProfile(value) { + try { return validateAgentProfile(value); } catch (error) { fail(error.message.replace(/^Loop agent: /u, "")); } +} + +export function validateRoute(value) { + if (!isClosedObject(value, ROUTE_KEYS) || value.schema !== "burnlist-loop-route@1" || !route.test(value.route)) fail("route record has invalid schema"); + return { schema: value.schema, route: value.route, profile: profileName(value.profile) }; +} + +export function saveProfile({ repoRoot, slug: name, adapter, binary, model, effort, authority }) { + const value = { schema: "burnlist-loop-agent-profile@1", id: name, adapter, binary, model, effort, authority }; + return writeLocalRecord({ repoRoot, collection: "profiles", name: profileName(name), value, validate: validateProfile }); +} +export function readProfile({ repoRoot, slug: name }) { return readLocalRecord({ repoRoot, collection: "profiles", name: profileName(name), validate: validateProfile }); } +export function saveRoute({ repoRoot, route: name, profile }) { + const value = { schema: "burnlist-loop-route@1", route: name, profile }; + return writeLocalRecord({ repoRoot, collection: "routes", name: String(name).replace(".", "-"), value, validate: validateRoute }); +} +export function readRoute({ repoRoot, route: name }) { if (!route.test(name)) fail("unknown route"); return readLocalRecord({ repoRoot, collection: "routes", name: name.replace(".", "-"), validate: validateRoute }); } + +/** + * No-cost local inspection only: no model invocation, child process, or write. + * Availability is not technical proof and therefore never makes setup ready. + */ +export function doctorProfile({ repoRoot, slug: name }) { + const profile = readProfile({ repoRoot, slug: name }); + try { + const snapshot = snapshotTarget({ root: parse(profile.binary).root, path: profile.binary, maximum: 64 * 1024 * 1024 }); + if ((snapshot.identity.mode & 0o111) === 0) return { available: false, ready: false, profile, reason: "binary is not executable" }; + return { available: true, ready: false, profile, executableDigest: snapshot.digest, reason: "technical guarantees are unavailable; no model probe was run" }; + } catch (error) { return { available: false, ready: false, profile, reason: error?.message || "binary inspection failed" }; } +} + +export const requiredRoutes = Object.freeze([ + { route: "implementation.standard", authority: "write" }, + { route: "review.strong", authority: "read" }, +]); +export { CODEX_MODELS, REASONING_EFFORTS }; diff --git a/src/loops/config/setup.mjs b/src/loops/config/setup.mjs new file mode 100644 index 00000000..69c819ac --- /dev/null +++ b/src/loops/config/setup.mjs @@ -0,0 +1,39 @@ +import { assertTrustedCapability } from "../capabilities/trust.mjs"; +import { readCapabilityCatalog, resolveCapability } from "../capabilities/contract.mjs"; +import { resolveConfiguredStageOneRoutes } from "../agents/profile.mjs"; +import { readProfile, readRoute, requiredRoutes } from "./profiles.mjs"; + +function profileCommand(slug = "", authority = "read|write") { return `burnlist agent profile add ${slug} --adapter builtin:codex-cli --binary --model --effort --authority ${authority}`; } +function routeCommand(route) { return `burnlist route set ${route} --profile `; } +function record(kind, id, detail, remedy) { return { kind, id, detail, remedy }; } +function clean(error) { return String(error?.message ?? error).replace(/^Loop (?:agent|local config|capability trust|capability): /u, ""); } + +/** Strictly read-only: configuration and trust readiness only; no child is launched. */ +export function setupStatus({ repoRoot } = {}) { + const failures = [], profiles = [], routes = {}; + for (const expected of requiredRoutes) { + let assigned; + try { assigned = readRoute({ repoRoot, route: expected.route }); routes[expected.route] = assigned.profile; } + catch (error) { failures.push(record("route", expected.route, clean(error), routeCommand(expected.route))); continue; } + let profile; + try { profile = readProfile({ repoRoot, slug: assigned.profile }); profiles.push(profile); } + catch (error) { failures.push(record("profile", assigned.profile, clean(error), profileCommand(assigned.profile, expected.authority))); continue; } + } + if (Object.keys(routes).length === requiredRoutes.length && profiles.length === requiredRoutes.length) { + try { resolveConfiguredStageOneRoutes({ profiles, routes }); } + catch (error) { failures.push(record("routing", "stage-one", clean(error), "repair the named profile or route and run burnlist loop setup status again")); } + } + let resolved; + try { resolved = resolveCapability(readCapabilityCatalog(repoRoot), "repo-verify"); } + catch (error) { failures.push(record("capability", "repo-verify", clean(error), "create .burnlist/loop-capabilities.json from the Review Loop capability example, then run burnlist loop capability inspect repo-verify")); } + if (resolved) { + try { assertTrustedCapability({ repoRoot, resolved }); } + catch (error) { failures.push(record("trust", "repo-verify", clean(error), `burnlist loop capability trust repo-verify --revision ${resolved.revision} --grants `)); } + } + return { ready: failures.length === 0, failures }; +} + +export function renderSetupStatus(status) { + if (status.ready) return "Loop setup: ready\n"; + return ["Loop setup: incomplete", ...status.failures.map((failure) => `MISSING ${failure.kind} ${failure.id}: ${failure.detail}\nREMEDIATION: ${failure.remedy}`), ""].join("\n"); +} diff --git a/src/loops/config/store.mjs b/src/loops/config/store.mjs new file mode 100644 index 00000000..fb8557af --- /dev/null +++ b/src/loops/config/store.mjs @@ -0,0 +1,155 @@ +import { randomBytes } from "node:crypto"; +import { closeSync, constants, fchmodSync, fsyncSync, lstatSync, mkdirSync, openSync, renameSync, rmdirSync, rmSync, unlinkSync, writeFileSync } from "node:fs"; +import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; +import { withDirectoryLock } from "../../server/dir-lock.mjs"; +import { checkSnapshot, readSnapshotBytes, snapshotTarget } from "../capabilities/snapshot.mjs"; +import { rawSha256 } from "../dsl/hash.mjs"; + +const NAME = /^[a-z0-9]+(?:[.-][a-z0-9]+)*$/u; +export const MAX_LOCAL_RECORD_BYTES = 65536; + +function fail(message, code = "ELOOP_CONFIG") { throw Object.assign(new Error(`Loop local config: ${message}`), { code }); } +function sync(path) { const fd = openSync(path, constants.O_RDONLY); try { fsyncSync(fd); } finally { closeSync(fd); } } +function exact(value, keys) { return Boolean(value) && typeof value === "object" && !Array.isArray(value) && Object.keys(value).length === keys.length && keys.every((key, index) => Object.keys(value)[index] === key); } +function boundedName(value, label) { if (typeof value !== "string" || !NAME.test(value) || Buffer.byteLength(value) > 128) fail(`invalid ${label}`); return value; } + +export const LOOP_CONFIG_SCHEMA = "burnlist-loop-local-config@1"; +export const configRoot = (repoRoot) => join(resolve(repoRoot), ".local", "burnlist", "loop", "config-v1"); + +/** Shared ancestors may use normal repository permissions; config-v1 and descendants are private. */ +function secureDirectory(repoRoot, path) { + const root = resolve(repoRoot), target = resolve(path), rel = relative(root, target); + if (rel === "" || isAbsolute(rel) || rel.split(sep).includes("..")) fail("config directory escapes repository"); + let current = root, privateZone = false; + for (const part of rel.split(sep)) { + current = join(current, part); privateZone ||= part === "config-v1"; + try { + const stat = lstatSync(current); + if (!stat.isDirectory() || stat.isSymbolicLink() || (privateZone && (stat.mode & 0o777) !== 0o700)) fail(`unsafe config directory ${current}`); + } catch (error) { + if (error?.code !== "ENOENT") throw error; + mkdirSync(current, { mode: privateZone ? 0o700 : 0o755 }); + const stat = lstatSync(current); + if (!stat.isDirectory() || stat.isSymbolicLink() || (privateZone && (stat.mode & 0o777) !== 0o700)) fail(`unsafe config directory ${current}`); + try { sync(dirname(current)); } catch { /* directory fsync is unsupported on some platforms */ } + } + } + return snapshotTarget({ root, path: target, kind: "directory" }); +} + +/** Read-side counterpart to secureDirectory: inspect only, never create. */ +function assertPrivateConfigRoot(repoRoot) { + const root = resolve(repoRoot), target = configRoot(root), rel = relative(root, target); + if (rel === "" || isAbsolute(rel) || rel.split(sep).includes("..")) fail("config directory escapes repository"); + let current = root; + for (const part of rel.split(sep)) { + current = join(current, part); + const stat = lstatSync(current); + if (!stat.isDirectory() || stat.isSymbolicLink() || (part === "config-v1" && (stat.mode & 0o777) !== 0o700)) fail(`unsafe config directory ${current}`); + } +} + +function filePath(repoRoot, collection, name) { + boundedName(collection, "collection"); boundedName(name, "record name"); + return join(configRoot(repoRoot), `${collection}--${name}.json`); +} +function privateFile(path, stat) { + // readSnapshotBytes already proved a no-follow regular file; its identity is plain data. + if (!stat || (stat.mode & 0o077) !== 0) fail(`unsafe config record ${path}`); +} +function canonicalBytes(value, validate) { + const canonical = Buffer.from(`${JSON.stringify(validate(value))}\n`, "utf8"); + if (canonical.length > MAX_LOCAL_RECORD_BYTES) fail("record exceeds byte limit"); + return canonical; +} +function checkSameDirectory(snapshot) { + const stat = lstatSync(snapshot.path); + if (!stat.isDirectory() || stat.isSymbolicLink() || stat.dev !== snapshot.identity.dev || stat.ino !== snapshot.identity.ino || stat.mode !== snapshot.identity.mode) fail(`directory identity changed ${snapshot.path}`); +} + +export function localRecordPath(repoRoot, collection, name) { return filePath(repoRoot, collection, name); } + +export function readLocalRecord({ repoRoot, collection, name, validate }) { + const path = filePath(repoRoot, collection, name); let read; + try { assertPrivateConfigRoot(repoRoot); read = readSnapshotBytes({ root: resolve(repoRoot), path, maximum: MAX_LOCAL_RECORD_BYTES }); } + catch (error) { + if (error?.code === "ENOENT") throw Object.assign(new Error(`Loop local config: ${collection}/${name} is missing`), { code: "ELOOP_CONFIG_MISSING" }); + throw error; + } + privateFile(path, read.identity); + let parsed; try { parsed = JSON.parse(read.bytes.toString("utf8")); } catch { fail(`${collection}/${name} is not JSON`); } + const value = validate(parsed), canonical = canonicalBytes(value, validate); + if (!canonical.equals(read.bytes)) fail(`${collection}/${name} is not canonical`); + return value; +} + +/** Copy a configured executable into the fixed private config directory before launch. */ +export function createPrivateExecutableSnapshot({ repoRoot, sourcePath, expectedDigest }) { + const root = resolve(repoRoot), config = configRoot(root); secureDirectory(root, config); + return withDirectoryLock({ lockPath: join(config, ".config.lock"), errorFactory: () => fail("configuration lock unavailable"), fn() { + const parent = secureDirectory(root, config); + const source = readSnapshotBytes({ root: dirname(resolve(sourcePath)), path: sourcePath, maximum: 64 * 1024 * 1024 }); + const digest = rawSha256(source.bytes); + if (digest !== expectedDigest) fail("configured executable changed before private snapshot", "ELOOP_CONFIG_QUARANTINED"); + const token = randomBytes(12).toString("hex"), staging = join(config, `.controller-${token}`), temporary = join(staging, "controller.tmp"), path = join(staging, "controller.exec"); let fd, published = false; + try { + mkdirSync(staging, { mode: 0o700 }); checkSameDirectory(parent); + fd = openSync(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW ?? 0), 0o700); + writeFileSync(fd, source.bytes); fchmodSync(fd, 0o500); fsyncSync(fd); closeSync(fd); fd = undefined; + renameSync(temporary, path); published = true; sync(staging); checkSameDirectory(parent); sync(config); + const snapshot = snapshotTarget({ root: staging, path, maximum: 64 * 1024 * 1024 }); + if (snapshot.digest !== digest || (snapshot.identity.mode & 0o777) !== 0o500) fail("private executable snapshot postcondition failed", "ELOOP_CONFIG_QUARANTINED"); + return { path, digest, snapshot, staging }; + } catch (error) { + if (published) fail(`private executable snapshot is quarantined: ${error.message}`, "ELOOP_CONFIG_QUARANTINED"); + throw error; + } finally { if (fd !== undefined) closeSync(fd); rmSync(temporary, { force: true }); if (!published) rmSync(staging, { recursive: true, force: true }); } + } }); +} + +export function removePrivateExecutableSnapshot(snapshot) { + try { + checkSnapshot(snapshot); + const staging = dirname(snapshot.path); + unlinkSync(snapshot.path); sync(staging); rmdirSync(staging); sync(dirname(staging)); + } catch (error) { fail(`private executable snapshot is quarantined: ${error.message}`, "ELOOP_CONFIG_QUARANTINED"); } +} + + +/** + * All records share one fixed private directory. Cooperating writers are + * serialized by its identity-bound lock; hostile same-user replacement still + * requires OS isolation and is only detected at boundaries. + */ +export function writeLocalRecord({ repoRoot, collection, name, value, validate, hooks = {}, replaceInvalidCodes = [] }) { + const root = resolve(repoRoot), path = filePath(root, collection, name), canonical = canonicalBytes(value, validate); + const config = configRoot(root); secureDirectory(root, config); + return withDirectoryLock({ lockPath: join(config, ".config.lock"), errorFactory: () => fail("configuration lock unavailable"), fn() { + const targetParent = secureDirectory(root, config); + try { + const current = readLocalRecord({ repoRoot: root, collection, name, validate }); + if (canonicalBytes(current, validate).equals(canonical)) return current; + } catch (error) { if (error?.code !== "ELOOP_CONFIG_MISSING" && !replaceInvalidCodes.includes(error?.code)) throw error; } + const token = randomBytes(12).toString("hex"), temporary = join(config, `.${collection}--${name}.${token}.tmp`); let fd, published = false; + try { + fd = openSync(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW ?? 0), 0o600); + writeFileSync(fd, canonical); fsyncSync(fd); closeSync(fd); fd = undefined; + if ((lstatSync(temporary).mode & 0o077) !== 0) fail("temporary config record is not private"); + hooks.beforePublish?.({ path, temporary }); + checkSameDirectory(targetParent); + hooks.beforeRename?.({ path, temporary }); + checkSameDirectory(targetParent); + renameSync(temporary, path); published = true; + hooks.afterPublish?.({ path }); + checkSameDirectory(targetParent); + const current = readLocalRecord({ repoRoot: root, collection, name, validate }); + if (!canonicalBytes(current, validate).equals(canonical)) fail("published record postcondition failed"); + sync(dirname(path)); return current; + } catch (error) { + if (published) fail(`publication postcondition failed; configuration is quarantined: ${error.message}`, "ELOOP_CONFIG_QUARANTINED"); + throw error; + } finally { if (fd !== undefined) closeSync(fd); rmSync(temporary, { force: true }); } + } }); +} + +export function isClosedObject(value, keys) { return exact(value, keys); } diff --git a/src/loops/contracts/agent-result.mjs b/src/loops/contracts/agent-result.mjs new file mode 100644 index 00000000..3e046dcc --- /dev/null +++ b/src/loops/contracts/agent-result.mjs @@ -0,0 +1,97 @@ +import { rawSha256 } from "../dsl/hash.mjs"; +import { bindingsMatch, DIGESTS, exact, fail, identity, parseBoundedObject, parseResultBytes, SLUG, sortedUnique } from "./contract.mjs"; +import { nextOpenFindings, validateFindingSet } from "./finding.mjs"; + +const RESULT_KEYS = ["schema", "runId", "nodeId", "attempt", "claimId", "assignmentId", "invocationId", "recipeRevision", "policyRevision", "inputCandidate", "outcome", "findings", "resolvedFindingIds"]; +const INPUT_KEYS = ["schema", "runId", "nodeId", "attempt", "claimId", "assignmentId", "invocationId", "recipeRevision", "policyRevision", "inputCandidate", "itemRevision", "instructionDigest", "instructionBytes", "candidateContext", "reviewerEvidence"]; +const AUTHORITY_KEYS = ["schema", "state", "runId", "nodeId", "attempt", "claimId", "assignmentId", "invocationId", "recipeRevision", "policyRevision", "inputCandidate", "itemRevision", "inputSchema", "inputDigest", "inputByteLength"]; +const OUTCOMES = Object.freeze({ task: new Set(["complete"]), review: new Set(["approve", "reject", "escalate"]) }); +const BASE64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u; + +function base64(value, label, maximum) { + if (typeof value !== "string" || !BASE64.test(value)) fail(`invalid ${label}`); + const bytes = Buffer.from(value, "base64"); + if (bytes.toString("base64") !== value || bytes.length > maximum) fail(`invalid ${label}`); + return bytes; +} +function canonicalEnvelope(value) { + return Buffer.from(`${JSON.stringify({ + schema: value.schema, runId: value.runId, nodeId: value.nodeId, attempt: value.attempt, claimId: value.claimId, + assignmentId: value.assignmentId, invocationId: value.invocationId, recipeRevision: value.recipeRevision, + policyRevision: value.policyRevision, inputCandidate: value.inputCandidate, itemRevision: value.itemRevision, + instructionDigest: value.instructionDigest, instructionBytes: value.instructionBytes, candidateContext: value.candidateContext, + reviewerEvidence: value.reviewerEvidence, + })}\n`, "utf8"); +} + +/** Builds the exact adapter stdin/prompt payload; this digest is persisted before dispatch by the runner. */ +export function createInvocationInput(value) { + if (!exact(value, INPUT_KEYS) || value.schema !== "burnlist-loop-invocation-input@1") fail("invalid invocation input"); + identity(value, "invocation input"); + if (!DIGESTS.item.test(value.itemRevision) || !DIGESTS.raw.test(value.instructionDigest) + || !SLUG.test(value.nodeId) || !Array.isArray(value.reviewerEvidence) || value.reviewerEvidence.length > 50 + || !sortedUnique(value.reviewerEvidence) || !value.reviewerEvidence.every((ref) => DIGESTS.artifact.test(ref))) fail("invalid invocation evidence"); + const instruction = base64(value.instructionBytes, "instruction bytes", 65_536); + const context = base64(value.candidateContext, "candidate context", 65_536); + if (!instruction.length || !context.length) fail("empty invocation input"); + if (rawSha256(instruction) !== value.instructionDigest) fail("instruction digest does not match frozen bytes"); + const bytes = canonicalEnvelope(value); + if (bytes.length > 262_144) fail("invocation input exceeds bounds"); + return Object.freeze({ value: Object.freeze({ ...value, reviewerEvidence: Object.freeze([...value.reviewerEvidence]) }), bytes, digest: rawSha256(bytes) }); +} + +function canonicalAuthority(value) { + return Buffer.from(`${JSON.stringify({ + schema: value.schema, state: value.state, runId: value.runId, nodeId: value.nodeId, attempt: value.attempt, + claimId: value.claimId, assignmentId: value.assignmentId, invocationId: value.invocationId, + recipeRevision: value.recipeRevision, policyRevision: value.policyRevision, inputCandidate: value.inputCandidate, + itemRevision: value.itemRevision, inputSchema: value.inputSchema, inputDigest: value.inputDigest, + inputByteLength: value.inputByteLength, + })}\n`); +} + +/** Closed journal-ready proof that exact invocation bytes were prepared before dispatch. */ +export function createDispatchAuthority(value) { + if (!exact(value, AUTHORITY_KEYS) || value.schema !== "burnlist-loop-dispatch-authority@1" || value.state !== "prepared-before-dispatch" + || value.inputSchema !== "burnlist-loop-invocation-input@1" || !DIGESTS.item.test(value.itemRevision) + || !DIGESTS.raw.test(value.inputDigest) || !Number.isInteger(value.inputByteLength) || value.inputByteLength < 2 || value.inputByteLength > 262_144) fail("invalid dispatch authority"); + identity(value, "dispatch authority"); + const bytes = canonicalAuthority(value); + return Object.freeze({ value: Object.freeze({ ...value }), bytes, digest: rawSha256(bytes) }); +} + +export function validateDispatchAuthority(bytes) { + const raw = Buffer.from(bytes); + const built = createDispatchAuthority(parseBoundedObject(raw, { maximumBytes: 16_384, maximumDepth: 1, label: "dispatch authority" })); + if (!built.bytes.equals(raw)) fail("dispatch authority is not canonical"); + return built; +} + +export function validateInvocationInput(bytes, authorityBytes) { + const raw = Buffer.from(bytes); + const authority = validateDispatchAuthority(authorityBytes); + const value = parseBoundedObject(raw, { maximumBytes: 262_144, maximumDepth: 2, label: "invocation input" }); + const built = createInvocationInput(value); + if (!built.bytes.equals(raw)) fail("invocation input is not canonical"); + if (!bindingsMatch(value, authority.value) || value.itemRevision !== authority.value.itemRevision + || built.digest !== authority.value.inputDigest || built.bytes.length !== authority.value.inputByteLength) fail("invocation input does not match dispatch authority"); + return built; +} + +export function validateAgentResult(value, { mode, openFindings = new Map() } = {}) { + if (!exact(value, RESULT_KEYS) || value.schema !== "agent-result@1") fail("invalid agent result"); + identity(value, "agent result"); + if (!OUTCOMES[mode]?.has(value.outcome)) fail("agent outcome is not allowed for node mode"); + const findings = validateFindingSet(value.findings, value.resolvedFindingIds, openFindings); + if (mode === "task" && (findings.findings.length || findings.resolvedFindingIds.length)) fail("task completion cannot carry findings"); + const next = nextOpenFindings(openFindings, findings); + if (mode === "review" && value.outcome === "approve") { + if (findings.findings.some((finding) => finding.severity === "blocker" || finding.severity === "major") + || [...openFindings.values()].some((finding) => (finding.severity === "blocker" || finding.severity === "major") && !findings.resolvedFindingIds.includes(finding.id)) + || [...next.values()].some((finding) => finding.severity === "blocker" || finding.severity === "major")) fail("approval has unresolved blocking findings"); + } + return Object.freeze({ ...value, findings: findings.findings, resolvedFindingIds: findings.resolvedFindingIds }); +} + +/** Parse then validate raw final bytes; bytes stay available for duplicate/retransmission identity. */ +export function parseAgentResult(bytes, options) { return validateAgentResult(parseResultBytes(bytes), options); } diff --git a/src/loops/contracts/check-result.mjs b/src/loops/contracts/check-result.mjs new file mode 100644 index 00000000..81e351cd --- /dev/null +++ b/src/loops/contracts/check-result.mjs @@ -0,0 +1,65 @@ +import { bindingsMatch, DIGESTS, exact, fail, identity, parseResultBytes } from "./contract.mjs"; +import { parseAgentResult } from "./agent-result.mjs"; + +const KEYS = ["schema", "runId", "nodeId", "attempt", "claimId", "assignmentId", "invocationId", "recipeRevision", "policyRevision", "inputCandidate", "capabilityRevision", "outcome", "exitCode", "evidenceDigest", "truncated"]; + +export function validateCheckResult(value) { + if (!exact(value, KEYS) || value.schema !== "check-result@1") fail("invalid check result"); + identity(value, "check result"); + if (!DIGESTS.capability.test(value.capabilityRevision) || !["pass", "fail"].includes(value.outcome) + || !Number.isInteger(value.exitCode) || value.exitCode < 0 || value.exitCode > 255 + || !DIGESTS.raw.test(value.evidenceDigest) || typeof value.truncated !== "boolean") fail("invalid check result"); + if (value.outcome === "pass" && (value.exitCode !== 0 || value.truncated)) fail("invalid passing check result"); + if (value.outcome === "fail" && value.exitCode === 0 && !value.truncated) fail("invalid failing check result"); + return Object.freeze({ ...value }); +} + +export function parseCheckResult(bytes) { return validateCheckResult(parseResultBytes(bytes)); } + +/** + * Pure claim-final authority. The durable runner persists dispatch authority and + * invokes this only after quiescence. It never writes a Run or candidate. + */ +export function createClaimFinalAuthority({ expected, type, mode, openFindings = new Map() }) { + if (!expected || !["agent", "check"].includes(type)) fail("invalid claim authority"); + identity(expected, "claim authority"); + let phase = "open"; let finalBytes = null; let final = null; let fault = null; + const decode = (bytes) => type === "agent" ? parseAgentResult(bytes, { mode, openFindings }) : parseCheckResult(bytes); + function accept(raw, { sourceClaimId = expected.claimId, sourceInvocationId = expected.invocationId } = {}) { + const bytes = Buffer.from(raw); + if (phase.startsWith("sealed-")) return Object.freeze({ disposition: "audit-only", reason: "claim-sealed" }); + if (sourceClaimId !== expected.claimId || sourceInvocationId !== expected.invocationId) + return Object.freeze({ disposition: "audit-only", reason: "replaced-or-late-claim" }); + if (phase.startsWith("exited-")) return Object.freeze({ disposition: "audit-only", reason: "process-exited" }); + if (phase === "fault" || phase === "exited-fault") return Object.freeze({ disposition: "audit-only", reason: "claim-faulted" }); + let value; + try { value = decode(bytes); } catch (error) { + fault = error; final = null; finalBytes = null; phase = "fault"; + return Object.freeze({ disposition: "failure-pending-quiescence", reason: "malformed-current-result", error }); + } + if (!bindingsMatch(value, expected)) return Object.freeze({ disposition: "audit-only", reason: "binding-mismatch" }); + if (type === "check" && value.capabilityRevision !== expected.capabilityRevision) return Object.freeze({ disposition: "audit-only", reason: "capability-mismatch" }); + if (!finalBytes) { + finalBytes = Buffer.from(bytes); final = value; phase = "candidate"; + return Object.freeze({ disposition: "buffered", result: value }); + } + if (finalBytes.equals(bytes)) return Object.freeze({ disposition: "idempotent-audit", result: final }); + return Object.freeze({ disposition: "audit-only", reason: "conflicting-duplicate-final" }); + } + function exit() { + if (phase.startsWith("sealed-")) return Object.freeze({ disposition: "audit-only", reason: "claim-sealed" }); + if (phase.startsWith("exited-")) return Object.freeze({ disposition: "idempotent-audit", reason: "exit-already-recorded" }); + phase = phase === "candidate" ? "exited-candidate" : phase === "fault" ? "exited-fault" : "exited-open"; + return Object.freeze({ disposition: "exit-recorded" }); + } + function seal({ quiescent }) { + if (quiescent !== true) fail("claim cannot seal without proven quiescence"); + if (phase === "sealed-success") return Object.freeze({ disposition: "idempotent-audit", reason: "claim-sealed", result: final }); + if (phase === "sealed-error") return Object.freeze({ disposition: "idempotent-audit", reason: "claim-sealed-error" }); + if (!phase.startsWith("exited-")) fail("claim cannot seal before process exit"); + if (phase === "exited-candidate") { phase = "sealed-success"; return Object.freeze({ disposition: "transition", result: final }); } + const reason = phase === "exited-fault" ? "malformed-current-result" : "exit-without-valid-final"; + phase = "sealed-error"; return Object.freeze({ disposition: "error-after-quiescence", reason, error: fault }); + } + return Object.freeze({ accept, exit, seal, get state() { return phase; }, get final() { return phase === "sealed-success" ? final : null; } }); +} diff --git a/src/loops/contracts/contract.mjs b/src/loops/contracts/contract.mjs new file mode 100644 index 00000000..d263f724 --- /dev/null +++ b/src/loops/contracts/contract.mjs @@ -0,0 +1,84 @@ +import { TextDecoder } from "node:util"; +import { RUN_REF } from "../run/run-ref.mjs"; + +export const MAX_RESULT_BYTES = 65_536; +export const MAX_RESULT_DEPTH = 4; +export const DIGESTS = Object.freeze({ + assignment: /^as1-sha256:[a-f0-9]{64}$/u, claim: /^cl1-sha256:[a-f0-9]{64}$/u, + invocation: /^iv1-sha256:[a-f0-9]{64}$/u, recipe: /^er1-sha256:[a-f0-9]{64}$/u, + policy: /^bp1-sha256:[a-f0-9]{64}$/u, candidate: /^cm1-sha256:[a-f0-9]{64}$/u, + capability: /^cp1-sha256:[a-f0-9]{64}$/u, item: /^id1-sha256:[a-f0-9]{64}$/u, + artifact: /^artifact:sha256:[a-f0-9]{64}$/u, raw: /^sha256:[a-f0-9]{64}$/u, +}); +export const RUN = RUN_REF; +export const SLUG = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u; + +function error(message, code = "ELOOP_RESULT_CONTRACT") { + return Object.assign(new TypeError(`Loop result: ${message}`), { code }); +} +export function fail(message, code) { throw error(message, code); } +export function exact(value, keys) { + return Boolean(value) && typeof value === "object" && !Array.isArray(value) + && Object.keys(value).length === keys.length && keys.every((key) => Object.hasOwn(value, key)); +} +export function sortedUnique(values) { + return Array.isArray(values) && values.every((value, index) => typeof value === "string" && (index === 0 || Buffer.compare(Buffer.from(values[index - 1]), Buffer.from(value)) < 0)); +} +export function identity(value, label = "result") { + if (!value || typeof value !== "object" || Array.isArray(value) + || !["runId", "nodeId", "attempt", "claimId", "assignmentId", "invocationId", "recipeRevision", "policyRevision", "inputCandidate"].every((key) => Object.hasOwn(value, key)) + || !RUN.test(value.runId) || !SLUG.test(value.nodeId) || !Number.isInteger(value.attempt) || value.attempt < 1 || value.attempt > 100 + || !DIGESTS.claim.test(value.claimId) || !DIGESTS.assignment.test(value.assignmentId) || !DIGESTS.invocation.test(value.invocationId) + || !DIGESTS.recipe.test(value.recipeRevision) || !DIGESTS.policy.test(value.policyRevision) || !DIGESTS.candidate.test(value.inputCandidate)) fail(`invalid ${label} identity`); + return value; +} +export function bindingsMatch(value, expected) { + return ["runId", "nodeId", "attempt", "claimId", "assignmentId", "invocationId", "recipeRevision", "policyRevision", "inputCandidate"].every((key) => value[key] === expected[key]); +} +/** The runner may journal a post-write candidate only after its child is quiescent. */ +export function postWriteCandidateRecord({ actor, quiescent, candidate, priorCandidate = null }) { + if (actor !== "runner" || quiescent !== true || !DIGESTS.candidate.test(candidate) + || !(priorCandidate === null || DIGESTS.candidate.test(priorCandidate))) fail("post-write candidate lacks runner quiescence authority"); + return Object.freeze({ schema: "burnlist-loop-post-write-candidate@1", actor, quiescent: true, priorCandidate, candidate }); +} +function depth(value, level = 0) { + if (value === null || typeof value !== "object") return level; + return Math.max(level, ...Object.values(value).map((item) => depth(item, level + 1))); +} +function rejectDuplicateKeys(text, maximumDepth) { + let index = 0; + const space = () => { while (/\s/u.test(text[index] ?? "")) index += 1; }; + const string = () => { + const start = index; index += 1; + while (index < text.length) { const char = text[index++]; if (char === "\\") { index += 1; continue; } if (char === '"') return JSON.parse(text.slice(start, index)); } + fail("result has an unterminated string"); + }; + const value = (level) => { + if (level > maximumDepth) fail("result exceeds JSON depth"); + space(); const char = text[index]; + if (char === '"') { string(); return; } + if (char === "{") { + index += 1; const keys = new Set(); space(); if (text[index] === "}") { index += 1; return; } + while (true) { space(); if (text[index] !== '"') fail("result has invalid object key"); const key = string(); if (keys.has(key)) fail("result has duplicate object key"); keys.add(key); space(); if (text[index++] !== ":") fail("result has invalid object separator"); value(level + 1); space(); if (text[index] === "}") { index += 1; return; } if (text[index++] !== ",") fail("result has invalid object separator"); } + } + if (char === "[") { index += 1; space(); if (text[index] === "]") { index += 1; return; } while (true) { value(level + 1); space(); if (text[index] === "]") { index += 1; return; } if (text[index++] !== ",") fail("result has invalid array separator"); } } + const matched = /^(?:true|false|null|-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?)/u.exec(text.slice(index)); + if (!matched) fail("result has invalid JSON value"); index += matched[0].length; + }; + value(0); space(); if (index !== text.length) fail("result has trailing JSON bytes"); +} +/** Bounds raw bytes before strict UTF-8/JSON work and rejects duplicate object keys. */ +export function parseBoundedObject(bytes, { maximumBytes, maximumDepth, label = "result" }) { + const raw = Buffer.from(bytes); + if (raw.length < 2 || raw.length > maximumBytes) fail(`${label} bytes exceed bounds`); + let text; + try { text = new TextDecoder("utf-8", { fatal: true }).decode(raw); } catch { fail(`${label} is not UTF-8`); } + rejectDuplicateKeys(text, maximumDepth); let value; + try { value = JSON.parse(text); } catch { fail(`${label} is not JSON`); } + if (!value || typeof value !== "object" || Array.isArray(value) || depth(value) > maximumDepth) fail(`${label} exceeds JSON depth`); + return value; +} +/** Strictly parses a bounded UTF-8 JSON result. Canonical JSON is deliberately not required. */ +export function parseResultBytes(bytes) { + return parseBoundedObject(bytes, { maximumBytes: MAX_RESULT_BYTES, maximumDepth: MAX_RESULT_DEPTH }); +} diff --git a/src/loops/contracts/contracts.test.mjs b/src/loops/contracts/contracts.test.mjs new file mode 100644 index 00000000..5988fb3f --- /dev/null +++ b/src/loops/contracts/contracts.test.mjs @@ -0,0 +1,184 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { TextDecoder } from "node:util"; +import test from "node:test"; +import { createDispatchAuthority, createInvocationInput, validateDispatchAuthority, validateInvocationInput, validateAgentResult } from "./agent-result.mjs"; +import { createClaimFinalAuthority, validateCheckResult } from "./check-result.mjs"; +import { findingId, validateFinding } from "./finding.mjs"; +import { postWriteCandidateRecord } from "./contract.mjs"; +import { rawSha256 } from "../dsl/hash.mjs"; +import { parseRunRef } from "../assignment/selectors.mjs"; +import { validateRunId } from "../run/run-codec.mjs"; + +const hex = (letter) => letter.repeat(64); +const d = (prefix, letter) => `${prefix}:${hex(letter)}`; +const binding = Object.freeze({ + runId: "run:01arz3ndektsv4rrffq69g5fav", nodeId: "review", attempt: 2, + claimId: d("cl1-sha256", "1"), assignmentId: d("as1-sha256", "2"), invocationId: d("iv1-sha256", "3"), + recipeRevision: d("er1-sha256", "4"), policyRevision: d("bp1-sha256", "5"), inputCandidate: d("cm1-sha256", "6"), +}); +const artifact = d("artifact:sha256", "a"); +const raw = d("sha256", "b"); +function finding(severity = "minor", summary = "Evidence is insufficient") { + const evidenceRefs = [artifact]; return { id: findingId({ severity, summary, evidenceRefs }), severity, summary, evidenceRefs }; +} +function agent(outcome, extra = {}) { return { schema: "agent-result@1", ...binding, outcome, findings: [], resolvedFindingIds: [], ...extra }; } +function check(outcome, extra = {}) { return { schema: "check-result@1", ...binding, capabilityRevision: d("cp1-sha256", "7"), outcome, exitCode: outcome === "pass" ? 0 : 1, evidenceDigest: raw, truncated: false, ...extra }; } +function bytes(value) { return Buffer.from(JSON.stringify(value)); } +function invocation(extra = {}) { + const instruction = Buffer.from("Follow the frozen instructions.\n"); + return { schema: "burnlist-loop-invocation-input@1", ...binding, itemRevision: d("id1-sha256", "8"), instructionDigest: rawSha256(instruction), + instructionBytes: instruction.toString("base64"), candidateContext: Buffer.from("candidate-context@1\n").toString("base64"), reviewerEvidence: [], ...extra }; +} +function dispatch(input, extra = {}) { + return createDispatchAuthority({ schema: "burnlist-loop-dispatch-authority@1", state: "prepared-before-dispatch", ...binding, + itemRevision: input.value.itemRevision, inputSchema: input.value.schema, inputDigest: input.digest, inputByteLength: input.bytes.length, ...extra }); +} +function fakeAdapterDecode(inputBytes, authorityBytes) { + const decoder = new TextDecoder("utf-8", { fatal: true }); + const authority = JSON.parse(decoder.decode(authorityBytes)); const input = JSON.parse(decoder.decode(inputBytes)); + const digest = `sha256:${createHash("sha256").update(inputBytes).digest("hex")}`; + assert.equal(authority.schema, "burnlist-loop-dispatch-authority@1"); assert.equal(authority.state, "prepared-before-dispatch"); + assert.equal(authority.inputByteLength, inputBytes.length); assert.equal(authority.inputDigest, digest); + for (const key of ["runId", "nodeId", "attempt", "claimId", "assignmentId", "invocationId", "recipeRevision", "policyRevision", "inputCandidate", "itemRevision"]) assert.equal(input[key], authority[key]); + return { authority, input, digest }; +} + +test("one canonical 128-bit RunRef grammar governs selectors, results, dispatch, and storage", () => { + const valid = [`run:0${"0".repeat(25)}`, `run:7${"z".repeat(25)}`]; + const invalid = [`run:8${"0".repeat(25)}`, `run:z${"0".repeat(25)}`, `run:0${"0".repeat(24)}`, `run:0${"0".repeat(26)}`]; + for (const runId of valid) { + assert.equal(parseRunRef(runId).selector, runId); assert.equal(validateRunId(runId), runId); + assert.equal(validateAgentResult(agent("complete", { runId }), { mode: "task" }).runId, runId); + assert.equal(validateCheckResult(check("pass", { runId })).runId, runId); + const input = createInvocationInput(invocation({ runId })); assert.equal(input.value.runId, runId); + assert.equal(validateDispatchAuthority(dispatch(input, { runId }).bytes).value.runId, runId); + } + for (const runId of invalid) { + assert.throws(() => parseRunRef(runId), /RunRef/u); assert.throws(() => validateRunId(runId), /RunRef/u); + assert.throws(() => validateAgentResult(agent("complete", { runId }), { mode: "task" }), /identity/u); + assert.throws(() => validateCheckResult(check("pass", { runId })), /identity/u); + assert.throws(() => createInvocationInput(invocation({ runId })), /identity/u); + assert.throws(() => dispatch(createInvocationInput(invocation()), { runId }), /dispatch authority/u); + } +}); + +test("agent outcomes and every finding severity are closed and approval cannot leave blockers", () => { + assert.deepEqual(validateAgentResult(agent("complete"), { mode: "task" }).outcome, "complete"); + for (const outcome of ["approve", "reject", "escalate"]) assert.equal(validateAgentResult(agent(outcome), { mode: "review" }).outcome, outcome); + for (const severity of ["blocker", "major", "minor", "note"]) assert.equal(validateFinding(finding(severity)).severity, severity); + const open = new Map([[finding("blocker").id, finding("blocker")]]); + assert.throws(() => validateAgentResult(agent("approve"), { mode: "review", openFindings: open }), /neither preserved|unresolved/u); + assert.throws(() => validateAgentResult(agent("reject"), { mode: "review", openFindings: open }), /neither preserved/u); + assert.equal(validateAgentResult(agent("reject", { findings: [finding("blocker")] }), { mode: "review", openFindings: open }).outcome, "reject"); + assert.equal(validateAgentResult(agent("approve", { resolvedFindingIds: [finding("blocker").id] }), { mode: "review", openFindings: open }).outcome, "approve"); + assert.throws(() => validateAgentResult(agent("complete", { findings: [finding()] }), { mode: "task" }), /cannot carry/u); + assert.throws(() => validateAgentResult(agent("reject", { findings: [{ ...finding(), summary: "changed" }] }), { mode: "review" }), /id does not bind/u); +}); + +test("finding ids bind distinct accepted UTF-8 and reject surrogate, control, and format code points before hashing", () => { + assert.notEqual(finding("note", "é").id, finding("note", "e\u0301").id); + assert.equal(validateFinding(finding("note", "レビュー")).summary, "レビュー"); + for (const summary of ["bad\u0000text", "bad\u200etext", "bad\ud800text", "bad\u202etext"]) + assert.throws(() => findingId({ severity: "note", summary, evidenceRefs: [artifact] }), /invalid finding/u); +}); + +test("check outcome table accepts only executable pass/fail combinations", () => { + assert.equal(validateCheckResult(check("pass")).outcome, "pass"); + assert.equal(validateCheckResult(check("fail")).outcome, "fail"); + assert.equal(validateCheckResult(check("fail", { exitCode: 0, truncated: true })).outcome, "fail"); + for (const invalid of [check("pass", { exitCode: 1 }), check("pass", { truncated: true }), check("fail", { exitCode: 0, truncated: false })]) assert.throws(() => validateCheckResult(invalid), /invalid/u); +}); + +test("closed bounded contracts reject unknown, missing, oversized, duplicate, and conflicting fields", () => { + assert.throws(() => validateAgentResult({ ...agent("complete"), extra: true }, { mode: "task" }), /invalid/u); + const missing = agent("complete"); delete missing.claimId; assert.throws(() => validateAgentResult(missing, { mode: "task" }), /invalid/u); + assert.throws(() => validateAgentResult(agent("reject", { findings: Array.from({ length: 51 }, () => finding()) }), { mode: "review" }), /invalid/u); + assert.throws(() => validateAgentResult(agent("reject", { findings: [finding("minor", "\u0000")] }), { mode: "review" }), /invalid/u); + assert.throws(() => validateAgentResult(agent("reject", { findings: [finding(), finding()] }), { mode: "review" }), /not id-sorted/u); + assert.throws(() => validateCheckResult({ ...check("pass"), capabilityRevision: "wrong" }), /invalid/u); +}); + +test("claim final state machine buffers, exits, and transitions only at one quiescence seal", () => { + const authority = createClaimFinalAuthority({ expected: binding, type: "agent", mode: "review" }); const accepted = bytes(agent("approve")); + assert.equal(authority.accept(accepted).disposition, "buffered"); assert.equal(authority.state, "candidate"); assert.equal(authority.final, null); + assert.equal(authority.accept(accepted).disposition, "idempotent-audit"); + assert.equal(authority.accept(bytes(agent("reject"))).reason, "conflicting-duplicate-final"); + assert.equal(authority.accept(Buffer.from("not json"), { sourceClaimId: d("cl1-sha256", "9") }).reason, "replaced-or-late-claim"); + assert.throws(() => authority.seal({ quiescent: true }), /before process exit/u); + assert.equal(authority.exit().disposition, "exit-recorded"); assert.equal(authority.state, "exited-candidate"); + assert.equal(authority.accept(accepted).reason, "process-exited"); + assert.throws(() => authority.seal({ quiescent: false }), /quiescence/u); + assert.equal(authority.seal({ quiescent: true }).disposition, "transition"); assert.equal(authority.state, "sealed-success"); + assert.equal(authority.final.outcome, "approve"); + assert.equal(authority.seal({ quiescent: true }).disposition, "idempotent-audit"); + for (const late of [accepted, bytes(agent("reject")), Buffer.from("not json")]) assert.equal(authority.accept(late).reason, "claim-sealed"); +}); + +test("claim final failure table is deterministic for empty, malformed, stale, mismatch, and valid-then-malformed", () => { + for (const malformed of [Buffer.from("not json"), Buffer.from('{"schema":"check-result@1","schema":"check-result@1"}'), Buffer.alloc(65_537, 32)]) { + const current = createClaimFinalAuthority({ expected: { ...binding, capabilityRevision: d("cp1-sha256", "7") }, type: "check" }); + assert.equal(current.accept(malformed).disposition, "failure-pending-quiescence"); + assert.equal(current.accept(bytes(check("pass"))).reason, "claim-faulted"); + current.exit(); assert.equal(current.seal({ quiescent: true }).reason, "malformed-current-result"); + } + const empty = createClaimFinalAuthority({ expected: binding, type: "agent", mode: "review" }); + empty.exit(); assert.equal(empty.seal({ quiescent: true }).reason, "exit-without-valid-final"); + + const mismatch = createClaimFinalAuthority({ expected: { ...binding, capabilityRevision: d("cp1-sha256", "7") }, type: "check" }); + assert.equal(mismatch.accept(bytes(check("pass", { inputCandidate: d("cm1-sha256", "9") }))).reason, "binding-mismatch"); + assert.equal(mismatch.accept(bytes(check("pass", { capabilityRevision: d("cp1-sha256", "9") }))).reason, "capability-mismatch"); + mismatch.exit(); assert.equal(mismatch.seal({ quiescent: true }).reason, "exit-without-valid-final"); + + const ambiguous = createClaimFinalAuthority({ expected: binding, type: "agent", mode: "review" }); + assert.equal(ambiguous.accept(bytes(agent("approve"))).disposition, "buffered"); + assert.equal(ambiguous.accept(Buffer.from("not json")).disposition, "failure-pending-quiescence"); + ambiguous.exit(); assert.equal(ambiguous.seal({ quiescent: true }).reason, "malformed-current-result"); assert.equal(ambiguous.final, null); + + const stale = createClaimFinalAuthority({ expected: binding, type: "agent", mode: "review" }); + assert.equal(stale.accept(Buffer.from("not json"), { sourceClaimId: d("cl1-sha256", "9") }).reason, "replaced-or-late-claim"); + assert.equal(stale.accept(bytes(agent("approve"))).disposition, "buffered"); + stale.exit(); assert.equal(stale.seal({ quiescent: true }).disposition, "transition"); +}); + +test("dispatch artifact and independent fake adapter assert exact invocation bytes and digest", () => { + const built = createInvocationInput(invocation()); + assert.equal(built.bytes.toString("utf8"), `${JSON.stringify(invocation())}\n`); + const authority = dispatch(built); assert.equal(validateDispatchAuthority(authority.bytes).digest, authority.digest); + assert.equal(validateInvocationInput(built.bytes, authority.bytes).digest, built.digest); + assert.equal(fakeAdapterDecode(built.bytes, authority.bytes).digest, built.digest); + assert.throws(() => validateInvocationInput(built.bytes, dispatch(built, { inputDigest: raw }).bytes), /authority/u); + const wrongItem = createInvocationInput(invocation({ itemRevision: d("id1-sha256", "9") })); + assert.throws(() => validateInvocationInput(wrongItem.bytes, authority.bytes), /authority/u); + const stale = createInvocationInput(invocation({ claimId: d("cl1-sha256", "9") })); + assert.throws(() => validateInvocationInput(stale.bytes, authority.bytes), /authority/u); + assert.throws(() => createInvocationInput(invocation({ candidateContext: Buffer.alloc(65537).toString("base64") })), /invalid/u); + assert.throws(() => validateInvocationInput(Buffer.alloc(262_145, 0xff), authority.bytes), /bytes exceed/u); + const duplicate = Buffer.from(built.bytes.toString().replace('{"schema":', '{"schema":"burnlist-loop-invocation-input@1","schema":')); + assert.throws(() => validateInvocationInput(duplicate, authority.bytes), /duplicate/u); + const deep = Buffer.from(built.bytes.toString().replace('"reviewerEvidence":[]', '"reviewerEvidence":[[[[]]]]')); + assert.throws(() => validateInvocationInput(deep, authority.bytes), /depth/u); + assert.throws(() => validateDispatchAuthority(Buffer.from('{"schema":"burnlist-loop-dispatch-authority@1","schema":"burnlist-loop-dispatch-authority@1"}')), /duplicate/u); + assert.throws(() => validateInvocationInput(built.bytes, { arbitrary: true }), /first argument|buffer|Buffer/u); +}); + +test("dispatch authority is closed, bounded, canonical, and prepared before dispatch", () => { + const built = createInvocationInput(invocation()); const valid = dispatch(built); + for (const patch of [{ state: "dispatched" }, { inputByteLength: 0 }, { inputByteLength: 262_145 }, { extra: true }]) + assert.throws(() => createDispatchAuthority({ ...valid.value, ...patch }), /invalid dispatch authority/u); + assert.throws(() => validateDispatchAuthority(Buffer.concat([valid.bytes, Buffer.from(" ")])), /canonical/u); + assert.throws(() => validateDispatchAuthority(Buffer.alloc(16_385, 32)), /bytes exceed/u); +}); + +test("reviewer evidence is bounded, unique, and strictly UTF-8 sorted", () => { + const second = d("artifact:sha256", "b"); + assert.equal(createInvocationInput(invocation({ reviewerEvidence: [artifact, second] })).value.reviewerEvidence.length, 2); + for (const reviewerEvidence of [[artifact, artifact], [second, artifact], Array.from({ length: 51 }, () => artifact)]) + assert.throws(() => createInvocationInput(invocation({ reviewerEvidence })), /invalid invocation evidence/u); +}); + +test("only runner-owned quiescent authority can record a post-write candidate", () => { + assert.equal(postWriteCandidateRecord({ actor: "runner", quiescent: true, candidate: binding.inputCandidate }).candidate, binding.inputCandidate); + assert.throws(() => postWriteCandidateRecord({ actor: "adapter", quiescent: true, candidate: binding.inputCandidate }), /authority/u); + assert.throws(() => postWriteCandidateRecord({ actor: "runner", quiescent: false, candidate: binding.inputCandidate }), /authority/u); +}); diff --git a/src/loops/contracts/finding.mjs b/src/loops/contracts/finding.mjs new file mode 100644 index 00000000..c089c3df --- /dev/null +++ b/src/loops/contracts/finding.mjs @@ -0,0 +1,51 @@ +import { prefixed } from "../dsl/hash.mjs"; +import { DIGESTS, exact, fail, sortedUnique } from "./contract.mjs"; + +const KEYS = ["id", "severity", "summary", "evidenceRefs"]; +const SEVERITIES = new Set(["blocker", "major", "minor", "note"]); +const ID = /^fi1-sha256:[a-f0-9]{64}$/u; +const FORBIDDEN_UNICODE = /[\p{Cc}\p{Cf}\p{Cs}]/u; + +function fields({ severity, summary, evidenceRefs }) { + if (!SEVERITIES.has(severity) || typeof summary !== "string" || !summary || Buffer.byteLength(summary) > 512 + || FORBIDDEN_UNICODE.test(summary) || !Array.isArray(evidenceRefs) || evidenceRefs.length < 1 || evidenceRefs.length > 16 + || !sortedUnique(evidenceRefs) || evidenceRefs.some((ref) => !DIGESTS.artifact.test(ref))) fail("invalid finding"); +} +export function findingId(value) { + fields(value); + const { severity, summary, evidenceRefs } = value; + return prefixed("fi1-sha256:", "finding-v1", [severity, summary, ...evidenceRefs]); +} + +/** Validates a closed finding and proves its content-addressed identity. */ +export function validateFinding(value) { + if (!exact(value, KEYS) || !ID.test(value.id)) fail("invalid finding"); + fields(value); + if (value.id !== findingId(value)) fail("finding id does not bind its content"); + return Object.freeze({ id: value.id, severity: value.severity, summary: value.summary, evidenceRefs: Object.freeze([...value.evidenceRefs]) }); +} + +export function validateFindingSet(findings, resolvedFindingIds, openFindings = new Map()) { + if (!Array.isArray(findings) || findings.length > 50 || !Array.isArray(resolvedFindingIds) || resolvedFindingIds.length > 50 + || !sortedUnique(resolvedFindingIds) || resolvedFindingIds.some((id) => !ID.test(id))) fail("invalid finding set"); + const checked = findings.map(validateFinding); + if (!sortedUnique(checked.map((finding) => finding.id)) || new Set(checked.map((finding) => finding.id)).size !== checked.length) fail("findings are not id-sorted unique"); + const seen = new Set(); + for (const finding of checked) { + const earlier = openFindings.get(finding.id); + if (earlier && JSON.stringify(earlier) !== JSON.stringify(finding)) fail("existing finding id changed"); + seen.add(finding.id); + } + for (const id of resolvedFindingIds) { + if (seen.has(id) || !openFindings.has(id)) fail("resolution is not an open finding"); + } + for (const id of openFindings.keys()) if (!seen.has(id) && !resolvedFindingIds.includes(id)) fail("open finding was neither preserved nor resolved"); + return Object.freeze({ findings: Object.freeze(checked), resolvedFindingIds: Object.freeze([...resolvedFindingIds]) }); +} + +export function nextOpenFindings(openFindings, result) { + const next = new Map(openFindings); + for (const id of result.resolvedFindingIds) next.delete(id); + for (const finding of result.findings) next.set(finding.id, finding); + return next; +} diff --git a/src/loops/dsl/__fixtures__/review.ir.json b/src/loops/dsl/__fixtures__/review.ir.json new file mode 100644 index 00000000..845b08e9 --- /dev/null +++ b/src/loops/dsl/__fixtures__/review.ir.json @@ -0,0 +1 @@ +{"schema":"burnlist-loop-ir@1","compiler":"burnlist-loop-compiler@1","id":"review","declaredVersion":"0.1.0","entry":"implement","budget":{"maxRounds":3,"maxMinutes":60,"maxAgentRuns":6,"maxCheckRuns":3,"maxTransitions":16,"maxOutputBytes":262144},"nodes":[{"kind":"agent","id":"implement","mode":"task","role":"maker","route":"implementation.standard","authority":"write","instructions":"implement","independentFrom":null,"requires":[]},{"kind":"terminal","id":"completed","state":"converged"},{"kind":"gate","id":"converged","gateKind":"convergence","requires":["verify","review"]},{"kind":"terminal","id":"exhausted","state":"budget-exhausted"},{"kind":"terminal","id":"failed","state":"failed"},{"kind":"terminal","id":"needs-human","state":"needs-human"},{"kind":"agent","id":"review","mode":"review","role":"reviewer","route":"review.strong","authority":"read","instructions":"review","independentFrom":"implement","requires":["fresh-session:enforced","filesystem-write-deny:supervised"]},{"kind":"terminal","id":"stopped","state":"stopped"},{"kind":"check","id":"verify","capability":"repo-verify"}],"failurePolicy":{"error":"failed","timeout":"failed","cancelled":"stopped","lost":"needs-human","exhausted":"exhausted"},"edges":[{"from":"implement","on":"complete","to":"verify","maxVisits":null},{"from":"converged","on":"pass","to":"completed","maxVisits":null},{"from":"converged","on":"fail","to":"needs-human","maxVisits":null},{"from":"review","on":"approve","to":"converged","maxVisits":null},{"from":"review","on":"reject","to":"implement","maxVisits":3},{"from":"review","on":"escalate","to":"needs-human","maxVisits":null},{"from":"verify","on":"pass","to":"review","maxVisits":null},{"from":"verify","on":"fail","to":"implement","maxVisits":3}],"instructions":[{"id":"implement","digest":"sha256:8d1db5c7cbc11075fccc565180374e1929a8cf22683cbca3dde9b77ef667c0a8","byteLength":114},{"id":"review","digest":"sha256:53ca65be9491688119b5cbf2443d0b9aff003764db9e91657ec54dbae12d76aa","byteLength":119}]} diff --git a/src/loops/dsl/__fixtures__/review.revisions.json b/src/loops/dsl/__fixtures__/review.revisions.json new file mode 100644 index 00000000..75e6d569 --- /dev/null +++ b/src/loops/dsl/__fixtures__/review.revisions.json @@ -0,0 +1 @@ +{"source":"ls1-sha256:a2b31c7cb4f8025516aa51a789a0545d3224b3abc142d18c2ec86291c009c918","package":"lp1-sha256:2f6213c0b5d8e4c6da58d8e8ab227462bf075c672252dc4a30c2736de51d4726","executable":"er1-sha256:8e58f16abc427528e6e10048f80c67f38afcdc7bb5b8f626a1e93b314feba08d"} diff --git a/src/loops/dsl/canonical.mjs b/src/loops/dsl/canonical.mjs new file mode 100644 index 00000000..5281b367 --- /dev/null +++ b/src/loops/dsl/canonical.mjs @@ -0,0 +1,56 @@ +import { compareUtf8 } from "./diagnostics.mjs"; + +const nodeKeyOrder = { agent: ["kind", "id", "mode", "role", "route", "authority", "instructions", "independentFrom", "requires"], check: ["kind", "id", "capability"], gate: ["kind", "id", "gateKind", "requires"], terminal: ["kind", "id", "state"] }; +const topOrder = ["schema", "compiler", "id", "declaredVersion", "entry", "budget", "nodes", "failurePolicy", "edges", "instructions"]; +const budgetOrder = ["maxRounds", "maxMinutes", "maxAgentRuns", "maxCheckRuns", "maxTransitions", "maxOutputBytes"]; +const policyOrder = ["error", "timeout", "cancelled", "lost", "exhausted"]; +const edgeOrder = ["from", "on", "to", "maxVisits"]; + +function quote(value) { + if (typeof value !== "string" || /[\uD800-\uDFFF]/u.test(value)) throw new TypeError("Canonical strings must be scalar Unicode"); + let out = '"'; + for (const char of value) { + const code = char.codePointAt(0); + if (char === '"') out += '\\"'; else if (char === "\\") out += "\\\\"; + else if (char === "\b") out += "\\b"; else if (char === "\f") out += "\\f"; + else if (char === "\n") out += "\\n"; else if (char === "\r") out += "\\r"; + else if (char === "\t") out += "\\t"; + else if (code < 0x20) out += `\\u${code.toString(16).padStart(4, "0")}`; + else out += char; + } + return `${out}"`; +} +function object(value, keys) { return `{${keys.map((key) => `${quote(key)}:${encode(value[key], key)}`).join(",")}}`; } +function encode(value, context = "") { + if (value === null) return "null"; + if (typeof value === "string") return quote(value); + if (typeof value === "number") { if (!Number.isSafeInteger(value) || value < 0) throw new TypeError("Canonical numbers must be safe unsigned integers"); return String(value); } + if (Array.isArray(value)) return `[${value.map((item) => encode(item)).join(",")}]`; + if (!value || typeof value !== "object") throw new TypeError("Canonical IR contains an invalid value"); + if (context === "nodes[]") return object(value, nodeKeyOrder[value.kind]); + if (context === "budget") return object(value, budgetOrder); + if (context === "failurePolicy") return object(value, policyOrder); + if (context === "edges[]") return object(value, edgeOrder); + if (context === "instructions[]") return object(value, ["id", "digest", "byteLength"]); + return object(value, topOrder); +} + +export function canonicalIrBytes(ir) { + const adjusted = { ...ir, nodes: ir.nodes.map((node) => ({ ...node })) }; + const json = `{${topOrder.map((key) => { + const context = key === "nodes" ? "nodes[]" : key === "edges" ? "edges[]" : key === "instructions" ? "instructions[]" : key; + const value = Array.isArray(adjusted[key]) ? `[${adjusted[key].map((item) => encode(item, context)).join(",")}]` : encode(adjusted[key], context); + return `${quote(key)}:${value}`; + }).join(",")}}\n`; + return Buffer.from(json, "utf8"); +} + +export function normalizeIr(ir, outcomeOrder) { + const nodeById = new Map(ir.nodes.map((node) => [node.id, node])); + const nodes = [...ir.nodes].sort((left, right) => left.id === ir.entry ? -1 : right.id === ir.entry ? 1 : compareUtf8(left.id, right.id)); + const nodeRank = new Map(nodes.map((node, index) => [node.id, index])); + const edges = [...ir.edges].sort((left, right) => (nodeRank.get(left.from) ?? Infinity) - (nodeRank.get(right.from) ?? Infinity) || + (outcomeOrder(nodeById.get(left.from))?.indexOf(left.on) ?? 99) - (outcomeOrder(nodeById.get(right.from))?.indexOf(right.on) ?? 99) || compareUtf8(left.to, right.to)); + const instructions = [...ir.instructions].sort((left, right) => compareUtf8(left.id, right.id)); + return { ...ir, nodes, edges, instructions }; +} diff --git a/src/loops/dsl/compile.mjs b/src/loops/dsl/compile.mjs new file mode 100644 index 00000000..ba004ff9 --- /dev/null +++ b/src/loops/dsl/compile.mjs @@ -0,0 +1,132 @@ +import { canonicalIrBytes } from "./canonical.mjs"; +import { createDiagnostics, finalizeDiagnostics } from "./diagnostics.mjs"; +import { prefixed, rawSha256 } from "./hash.mjs"; +import { extractInstructionSections } from "./instructions.mjs"; +import { validateClosedIr } from "./ir-validate.mjs"; +import { validateLoop } from "./grammar.mjs"; +import { parseLoopXml } from "./loop-xml.mjs"; +import { readPackageDirectory } from "./package-read.mjs"; + +const paths = ["review.loop", "instructions.md", "example/item.md"]; +const sizes = { "review.loop": [1, 65536], "instructions.md": [1, 262144], "example/item.md": [0, 65536] }; +const totalLimit = 393216; + +function appendDiagnostics(target, source) { + for (const item of source) { + target.add(item.path, item.byteOffset, item.code, item.message); + } +} + +function sourceChecks(bytes, path, d) { + const [minimum, maximum] = sizes[path]; + if (bytes.length < minimum || bytes.length > maximum) d.add(path, 0, "E_FILE_SIZE", `${path} must contain ${minimum}..${maximum} bytes`); + try { + const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes); + if (bytes.subarray(0, 3).equals(Buffer.from([0xef, 0xbb, 0xbf]))) d.add(path, 0, "E_FILE_BOM", "UTF-8 BOM is not allowed"); + if (text.includes("\0")) d.add(path, 0, "E_FILE_NUL", "NUL is not allowed"); + if (text.includes("\r")) d.add(path, 0, "E_FILE_CR", "Only LF line endings are allowed"); + if (bytes.length && !text.endsWith("\n")) d.add(path, bytes.length, "E_FILE_FINAL_LF", "Nonempty files must end in LF"); + } catch { d.add(path, 0, "E_FILE_UTF8", "File is not valid UTF-8"); } +} + +function collectDiagnostics(...results) { return results.flatMap((result) => result?.allDiagnostics ?? result?.diagnostics ?? []); } + +function packageRevision(data) { + const fields = Object.entries(data).sort(([left], [right]) => Buffer.compare(Buffer.from(left), Buffer.from(right))).flatMap(([path, bytes]) => [Buffer.from(path), bytes]); + return prefixed("lp1-sha256:", "package-v1", fields); +} + +function cloneFiles(data) { + return Object.fromEntries(Object.entries(data).map(([path, bytes]) => [path, Buffer.from(bytes)])); +} + +function compileLoopFilesInternal(files, { continueOnMissing = false } = {}) { + const d = createDiagnostics(); + if (!files || typeof files !== "object" || Array.isArray(files)) { + d.add("", 0, "E_PACKAGE", "Package files must be an object"); + } + + const actual = Object.keys(files ?? {}); + if (actual.length > 3) d.add("", 0, "E_PACKAGE_COUNT", "Package may contain at most three files"); + for (const path of actual) if (!paths.includes(path)) d.add(path, 0, "E_PACKAGE_PATH", "Unknown package file"); + for (const path of paths.slice(0, 2)) if (!(path in (files ?? {}))) d.add(path, 0, "E_PACKAGE_MISSING", "Required package file is missing"); + + if (!continueOnMissing && d.all.length) return { ok: false, diagnostics: d.all }; + + const data = {}; + for (const path of paths) if (path in (files ?? {})) { + try { + data[path] = Buffer.from(files[path]); + sourceChecks(data[path], path, d); + } catch { + d.add(path, 0, "E_PACKAGE_BYTES", "Package file must be byte data"); + } + } + + if (!("review.loop" in data)) return { ok: false, diagnostics: d.all, packageFiles: cloneFiles(data) }; + if (Object.values(data).reduce((sum, bytes) => sum + bytes.length, 0) > totalLimit) d.add("", 0, "E_PACKAGE_SIZE", "Package exceeds 393216 byte limit"); + if (!continueOnMissing && d.all.length) return { ok: false, diagnostics: d.all, packageFiles: cloneFiles(data) }; + + const parsed = parseLoopXml(data["review.loop"]); + const checked = parsed.ast ? validateLoop(parsed.ast) : null; + appendDiagnostics(d, collectDiagnostics(parsed, checked)); + + if (!continueOnMissing && d.all.length) return { ok: false, diagnostics: d.all, packageFiles: cloneFiles(data) }; + if (!checked) return { ok: false, diagnostics: d.all, packageFiles: cloneFiles(data) }; + + if (!("instructions.md" in data)) { + return { ok: false, diagnostics: d.all, packageFiles: cloneFiles(data) }; + } + + const extracted = extractInstructionSections(data["instructions.md"], checked.instructionIds); + appendDiagnostics(d, extracted.diagnostics); + if (extracted.diagnostics.length > 0 || !continueOnMissing && d.all.length) { + return { ok: false, diagnostics: d.all, packageFiles: cloneFiles(data) }; + } + + const ir = { ...checked.ir, instructions: extracted.sections.map(({ bytes, ...section }) => section) }; + if (!validateClosedIr(ir)) { + d.add("review.loop", 0, "E_IR_INVARIANT", "Closed invariant validation failed"); + return { ok: false, diagnostics: d.all, packageFiles: cloneFiles(data) }; + } + + const irBytes = canonicalIrBytes(ir); + const revisions = { + source: prefixed("ls1-sha256:", "source-v1", [data["review.loop"]]), + package: packageRevision(data), + executable: prefixed("er1-sha256:", "recipe-v1", [Buffer.from(ir.compiler), irBytes, ...extracted.sections.flatMap((section) => [Buffer.from(section.id), section.bytes])]), + }; + + const packageFiles = Object.fromEntries(Object.entries(data).map(([path, bytes]) => [path, Buffer.from(bytes)])); + return { + ok: true, + ir, + irBytes, + instructions: extracted.sections, + packageFiles, + revisions, + rawSourceDigest: rawSha256(data["review.loop"]), + diagnostics: d.all, + }; +} + +export function compileLoopFiles(files) { + const result = compileLoopFilesInternal(files); + const finalized = finalizeDiagnostics(result.diagnostics); + if (finalized.length) return { ok: false, diagnostics: finalized }; + return { ...result, diagnostics: finalized }; +} + +/** + * Compile package files directly from a directory without trust or execution. + */ +export async function compileLoopPackage(directory, { beforeLeafRead, afterLeafOpenForTest } = {}) { + const { files, diagnostics } = await readPackageDirectory(directory, { + beforeLeafRead, + afterLeafOpenForTest, + }); + const compiled = compileLoopFilesInternal(files, { continueOnMissing: true }); + const merged = finalizeDiagnostics([...diagnostics, ...(compiled.diagnostics ?? [])]); + if (merged.length) return { ok: false, diagnostics: merged }; + return compiled; +} diff --git a/src/loops/dsl/compile.test.mjs b/src/loops/dsl/compile.test.mjs new file mode 100644 index 00000000..45cf88da --- /dev/null +++ b/src/loops/dsl/compile.test.mjs @@ -0,0 +1,277 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { mkdtemp, mkdir, readFile, rename, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { compileLoopFiles, compileLoopPackage } from "./compile.mjs"; +import { freezeRecipe, loadFrozenRecipe } from "./frozen.mjs"; +import { renderDiagnostics } from "./diagnostics.mjs"; + +const root = new URL("../../../loops/review/", import.meta.url); +const fixtures = new URL("./__fixtures__/", import.meta.url); +async function reviewFiles() { return { "review.loop": await readFile(new URL("review.loop", root)), "instructions.md": await readFile(new URL("instructions.md", root)) }; } +async function compiled() { const result = compileLoopFiles(await reviewFiles()); assert.equal(result.ok, true, renderDiagnostics(result.diagnostics ?? [])); return result; } + +test("built-in review package compiles to deterministic canonical frozen IR", async () => { + const first = await compiled(), second = await compiled(); + const goldenIr = await readFile(new URL("review.ir.json", fixtures)), goldenRevisions = JSON.parse(await readFile(new URL("review.revisions.json", fixtures))); + assert.deepEqual(first.irBytes, goldenIr); assert.deepEqual(first.revisions, goldenRevisions); + assert.deepEqual(first.irBytes, second.irBytes); assert.deepEqual(first.revisions, second.revisions); + assert.equal(first.ir.schema, "burnlist-loop-ir@1"); + assert.equal(first.ir.compiler, "burnlist-loop-compiler@1"); + assert.deepEqual(first.ir.nodes.map((node) => node.id), ["implement", "completed", "converged", "exhausted", "failed", "needs-human", "review", "stopped", "verify"]); + assert.deepEqual(first.ir.edges.map((edge) => edge.from), ["implement", "converged", "converged", "review", "review", "review", "verify", "verify"]); + assert.match(first.revisions.executable, /^er1-sha256:[a-f0-9]{64}$/); + assert.equal((await compileLoopPackage(new URL("../../../loops/review", import.meta.url).pathname)).ok, true); +}); + +test("runtime consumes persisted frozen IR and validates recipe identity", async () => { + const result = await compiled(), bytes = freezeRecipe(result), frozen = loadFrozenRecipe(bytes); + assert.equal(frozen.irBytes, result.irBytes.toString("base64")); assert.deepEqual(frozen.revisions, result.revisions); + const changed = Buffer.from(bytes).toString().replace("implement", "implement-x"); + assert.throws(() => loadFrozenRecipe(Buffer.from(changed)), /Frozen recipe/); +}); + +test("closed grammar rejects Stage 2 syntax and convergence bypass", async () => { + const files = await reviewFiles(); + for (const replacement of [ + '', '', + '', '', '', + ]) { + const copied = { ...files, "review.loop": Buffer.from(files["review.loop"].toString().replace('', replacement)) }; + const result = compileLoopFiles(copied); assert.equal(result.ok, false, replacement); + assert.ok(result.diagnostics.length > 0); + } +}); + +test("reviewer requirements close on the supervised Stage 1 boundary", async () => { + const files = await reviewFiles(), source = files["review.loop"].toString(); + for (const requirements of [ + "fresh-session:enforced,filesystem-write-deny:enforced", + "fresh-session:enforced,filesystem-write-deny:unsupported", + "fresh-session:enforced,filesystem-write-deny:supervised,container:docker", + ]) { + const result = compileLoopFiles({ ...files, "review.loop": Buffer.from(source.replace("fresh-session:enforced,filesystem-write-deny:supervised", requirements)) }); + assert.equal(result.ok, false, requirements); + assert.deepEqual(result.diagnostics.map((item) => item.code), ["E_REVIEW_REQUIREMENTS"]); + } +}); + +test("each semantic outcome has one closed, type-safe target", async () => { + const files = await reviewFiles(); + const source = files["review.loop"].toString().replace('from="verify" on="pass" to="review"', 'from="verify" on="pass" to="implement"'); + const result = compileLoopFiles({ ...files, "review.loop": Buffer.from(source) }); + assert.equal(result.ok, false); + assert.ok(result.diagnostics.some((item) => item.code === "E_EDGE_TARGET")); +}); + +test("diagnostics are stable, retained, sorted, and capped", async () => { + const files = await reviewFiles(); + const malformed = files["review.loop"].toString().replace('max-rounds="3"', 'max-rounds="0" evil="1"').replace('', ''); + const result = compileLoopFiles({ ...files, "review.loop": Buffer.from(malformed) }); + assert.equal(result.ok, false); + const lines = renderDiagnostics(result.diagnostics).trim().split("\n"); + assert.ok(lines.some((line) => line.includes("E_ATTRIBUTE_UNKNOWN"))); + assert.ok(lines.some((line) => line.includes("E_SCALAR"))); + assert.ok(lines.every((line) => /^review\.loop:\d+: E_/.test(line))); +}); + +test("package lexical limits fail closed before grammar compilation", async () => { + const files = await reviewFiles(); + const bom = compileLoopFiles({ ...files, "review.loop": Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), files["review.loop"]]) }); + assert.equal(bom.ok, false); assert.ok(bom.diagnostics.some((item) => item.code === "E_FILE_BOM")); + const unknown = compileLoopFiles({ ...files, "later.loop": Buffer.from("x\n") }); + assert.equal(unknown.ok, false); assert.ok(unknown.diagnostics.some((item) => item.code === "E_PACKAGE_PATH")); +}); + +test("instruction extraction is exact and treats fenced headings as prose", async () => { + const files = await reviewFiles(); + const markdown = "## implement\ntext\n```md\n## review\n```\n## review\nreview text\n"; + const good = compileLoopFiles({ ...files, "instructions.md": Buffer.from(markdown) }); + assert.equal(good.ok, true, renderDiagnostics(good.diagnostics ?? [])); + const bad = compileLoopFiles({ ...files, "instructions.md": Buffer.from("## implement\ntext\n## implement\nagain\n") }); + assert.equal(bad.ok, false); assert.ok(bad.diagnostics.some((item) => item.code === "E_INSTRUCTIONS_DUPLICATE")); + const tilde = "## implement\ntext\n~~~ language ` allowed\n## review\n~~~ \n## review\nreview text\n"; + assert.equal(compileLoopFiles({ ...files, "instructions.md": Buffer.from(tilde) }).ok, true); +}); + +test("equivalent source formatting preserves IR and executable identity", async () => { + const files = await reviewFiles(), changed = files["review.loop"].toString().replace("\n { + const result = await compiled(), bytes = freezeRecipe(result), mutate = (fn) => { const value = JSON.parse(bytes); fn(value); return Buffer.from(`${JSON.stringify(value)}\n`); }; + for (const change of [ + (value) => { value.extra = true; }, (value) => { value.ir.extra = true; }, + (value) => { value.ir.nodes[0].extra = true; }, (value) => { value.instructions[0].bytes += "\n"; }, + (value) => { value.revisions.source = value.revisions.source.replace("ls1", "er1"); }, + (value) => { value.revisions.package = value.revisions.package.replace("lp1", "ls1"); }, + (value) => { value.revisions.executable = value.revisions.executable.replace("er1", "lp1"); }, + ]) assert.throws(() => loadFrozenRecipe(mutate(change)), TypeError); + const frozen = loadFrozenRecipe(bytes); + assert.ok(Object.isFrozen(frozen) && Object.isFrozen(frozen.ir) && Object.isFrozen(frozen.ir.nodes[0]) && Object.isFrozen(frozen.instructions[0])); + assert.equal(Buffer.isBuffer(frozen.instructions[0].base64), false); + assert.throws(() => { frozen.ir.nodes[0].id = "changed"; }, TypeError); +}); + +test("frozen replay rejects every closed-IR union, cap, reference, and ordering violation", async () => { + const bytes = freezeRecipe(await compiled()), mutate = (change) => { const value = JSON.parse(bytes); change(value.ir); return Buffer.from(`${JSON.stringify(value)}\n`); }; + for (const change of [ + (ir) => { ir.nodes[0].mode = "stage-two"; }, (ir) => { ir.nodes.find((node) => node.id === "review").requires[1] = "filesystem-write-deny:enforced"; }, (ir) => { ir.edges[0].on = "unknown"; }, + (ir) => { ir.declaredVersion = "01.0.0"; }, (ir) => { ir.budget.maxRounds = 0; }, + (ir) => { ir.nodes.reverse(); }, (ir) => { ir.edges.reverse(); }, (ir) => { ir.instructions.reverse(); }, + (ir) => { ir.nodes[0].instructions = "absent"; }, (ir) => { ir.edges[0].to = "missing"; }, + (ir) => { ir.edges[0].maxVisits = 1; }, (ir) => { ir.failurePolicy.error = "stopped"; }, + ]) assert.throws(() => loadFrozenRecipe(mutate(change)), TypeError); +}); + +test("closed grammar rejects named later constructs and group/order violations", async () => { + const files = await reviewFiles(), source = files["review.loop"].toString(); + for (const name of ["input", "condition", "source", "operator", "target", "map", "foreach", "join", "combine", "branch"]) { + const result = compileLoopFiles({ ...files, "review.loop": Buffer.from(source.replace('', `<${name} id="later"/>`)) }); + assert.equal(result.ok, false, name); assert.ok(result.diagnostics.some((item) => item.code === "E_ELEMENT_UNKNOWN")); + } + const reordered = source.replace(/(\n) ()/, "$2\n $1"); + const result = compileLoopFiles({ ...files, "review.loop": Buffer.from(reordered) }); + assert.equal(result.ok, false); assert.ok(result.diagnostics.some((item) => item.code === "E_CHILD_GROUP")); +}); + +test("every Stage 1 element required attribute and scalar union is closed", async () => { + const files = await reviewFiles(), source = files["review.loop"].toString(); + const required = [ + 'id="review"', 'version="0.1.0"', 'entry="implement"', 'max-rounds="3"', 'max-minutes="60"', 'max-agent-runs="6"', 'max-check-runs="3"', 'max-transitions="16"', 'max-output-bytes="262144"', + 'id="implement"', 'mode="task"', 'role="maker"', 'route="implementation.standard"', 'authority="write"', 'instructions="implement"', 'capability="repo-verify"', + 'id="review"', 'mode="review"', 'role="reviewer"', 'route="review.strong"', 'authority="read"', 'independent-from="implement"', 'requires="fresh-session:enforced,filesystem-write-deny:supervised"', + 'id="converged"', 'kind="convergence"', 'state="converged"', 'error="failed"', 'timeout="failed"', 'cancelled="stopped"', 'lost="needs-human"', 'exhausted="exhausted"', 'from="implement"', 'on="complete"', 'to="verify"', + ]; + for (const token of required) { + const result = compileLoopFiles({ ...files, "review.loop": Buffer.from(source.replace(token, "")) }); + assert.equal(result.ok, false, token); assert.ok(result.diagnostics.some((item) => item.code === "E_ATTRIBUTE_REQUIRED"), token); + } + for (const [token, replacement] of [['mode="task"', 'mode="stage-two"'], ['role="maker"', 'role="planner"'], ['authority="write"', 'authority="admin"'], ['route="implementation.standard"', 'route="invalid..route"'], ['kind="convergence"', 'kind="metric"'], ['state="converged"', 'state="done"'], ['max-visits="3"', 'max-visits="0"']]) { + const result = compileLoopFiles({ ...files, "review.loop": Buffer.from(source.replace(token, replacement)) }); + assert.equal(result.ok, false, replacement); + } +}); + +test("diagnostic truncation and recovery keep the first 99 sorted findings", async () => { + const files = await reviewFiles(), extras = Array.from({ length: 110 }, (_, index) => ` bad-${index}="x"`).join(""); + const source = files["review.loop"].toString().replace('version="0.1.0"', 'version="0"').replace('max-rounds="3"', `max-rounds="0"${extras}`).replace('', ''); + const result = compileLoopFiles({ ...files, "review.loop": Buffer.from(source) }); + assert.equal(result.ok, false); assert.equal(result.diagnostics.length, 100); + assert.equal(result.diagnostics[0].code, "E_TOO_MANY_DIAGNOSTICS"); + assert.ok(result.diagnostics.some((item) => item.code === "E_SCALAR")); + assert.ok(result.diagnostics.slice(1).every((item, index, all) => index === 0 || item.byteOffset >= all[index - 1].byteOffset)); +}); + +test("grammar diagnoses malformed attribute and complete semantic/system routing", async () => { + const files = await reviewFiles(), source = files["review.loop"].toString(); + const duplicate = source.replace('id="review" version', 'id="review" id="again" version'); + let result = compileLoopFiles({ ...files, "review.loop": Buffer.from(duplicate) }); + assert.equal(result.ok, false); assert.deepEqual(result.diagnostics[0], { path: "review.loop", byteOffset: 18, code: "E_XML_DUPLICATE_ATTRIBUTE", message: "Duplicate attribute id" }); + const missing = source.replace('\n', ""); + result = compileLoopFiles({ ...files, "review.loop": Buffer.from(missing) }); + assert.equal(result.ok, false); assert.ok(result.diagnostics.some((item) => item.code === "E_EDGE_MISSING")); + const system = source.replace('lost="needs-human"', 'lost="failed"'); + result = compileLoopFiles({ ...files, "review.loop": Buffer.from(system) }); + assert.equal(result.ok, false); assert.ok(result.diagnostics.some((item) => item.code === "E_FAILURE_POLICY")); + const duplicateSystem = source.replace('error="failed"', 'error="failed" error="failed"'); + result = compileLoopFiles({ ...files, "review.loop": Buffer.from(duplicateSystem) }); + assert.equal(result.ok, false); assert.ok(result.diagnostics.some((item) => item.code === "E_XML_DUPLICATE_ATTRIBUTE")); + const bypass = source.replace('from="review" on="approve" to="converged"', 'from="review" on="approve" to="completed"'); + result = compileLoopFiles({ ...files, "review.loop": Buffer.from(bypass) }); + assert.equal(result.ok, false); assert.ok(result.diagnostics.some((item) => item.code === "E_EDGE_TARGET" || item.code === "E_CONVERGENCE_DOMINATION")); +}); + +test("recoverable XML findings merge with semantic findings before global sorting", async () => { + const files = await reviewFiles(); + const source = files["review.loop"].toString().replace('version="0.1.0"', 'version="0" version="still-bad"').replace('max-rounds="3"', 'max-rounds="0" unknown="x"'); + const result = compileLoopFiles({ ...files, "review.loop": Buffer.from(source) }); + assert.equal(result.ok, false); + assert.deepEqual(result.diagnostics.map((item) => item.code), ["E_SCALAR", "E_XML_DUPLICATE_ATTRIBUTE", "E_ATTRIBUTE_UNKNOWN", "E_SCALAR"]); + assert.ok(result.diagnostics.every((item, index, all) => index === 0 || item.byteOffset >= all[index - 1].byteOffset)); +}); + +test("literal fenced-section grammar handles both delimiters and false closers", async () => { + const files = await reviewFiles(); + for (const marker of ["`", "~"]) for (const indent of [0, 1, 2, 3]) { + const fence = `${" ".repeat(indent)}${marker.repeat(3)} suffix ${marker}\n## review\n${" ".repeat(indent)}${marker.repeat(4)} \n`; + const markdown = `## implement\ntext\n${fence}## review\nreview text\n`; + const result = compileLoopFiles({ ...files, "instructions.md": Buffer.from(markdown) }); + assert.equal(result.ok, true, `${marker}/${indent}`); + } + const falseCloser = "## implement\ntext\n``` suffix `\n## review\n``` not-close\n## review\nreview\n"; + const result = compileLoopFiles({ ...files, "instructions.md": Buffer.from(falseCloser) }); + assert.equal(result.ok, false); assert.ok(result.diagnostics.some((item) => item.code === "E_INSTRUCTIONS_FENCE")); +}); + +test("frozen recipe replay rejects any noncanonical JSON encoding", async () => { + const result = await compiled(); + const frozen = freezeRecipe(result); + const value = JSON.parse(frozen); + + const reordered = Buffer.from(JSON.stringify({ + compiler: value.compiler, + schema: value.schema, + revisions: value.revisions, + source: value.source, + package: value.package, + ir: value.ir, + instructions: value.instructions, + }) + "\n", "utf8"); + assert.throws(() => loadFrozenRecipe(reordered), /Frozen recipe is not canonical/); + + const spaced = Buffer.from(`${frozen.toString("utf8").trim()} \n`, "utf8"); + assert.throws(() => loadFrozenRecipe(spaced), /Frozen recipe is not canonical/); + + const scientific = Buffer.from(frozen.toString("utf8").replace("\"maxRounds\":3", "\"maxRounds\":1e1"), "utf8"); + assert.throws(() => loadFrozenRecipe(scientific), /Frozen recipe is not canonical/); +}); + +test("compiler and frozen replay are exact under round-trip table cases", async () => { + const files = await reviewFiles(); + const table = [ + { name: "canonical", loop: files["review.loop"].toString() }, + { name: "spacing", loop: files["review.loop"].toString().replace(" { + const files = await reviewFiles(); + const duplicated = compileLoopFiles({ ...files, "review.loop": Buffer.from(files["review.loop"].toString().replace('instructions="review"', 'instructions="implement"')) }); + assert.equal(duplicated.ok, false); + assert.ok(duplicated.diagnostics.some((item) => item.code === "E_IR_INVARIANT")); +}); + +test("compiler invariant mirror rejects non-task maker entry", async () => { + const files = await reviewFiles(); + let source = files["review.loop"].toString().replace('entry="implement"', 'entry="verify"'); + source = source.replace('', ''); + source = source.replace(' ', ' '); + source = source.replace(' ', ' '); + const reassigned = compileLoopFiles({ ...files, "review.loop": Buffer.from(source) }); + assert.equal(reassigned.ok, false); + assert.ok(reassigned.diagnostics.some((item) => item.code === "E_IR_INVARIANT")); +}); + +test("diagnostic truncation keeps the first 99 sorted findings", async () => { + const files = await reviewFiles(), extras = Array.from({ length: 110 }, (_, index) => ` bad-${index}="x"`).join(""); + const source = files["review.loop"].toString().replace('version="0.1.0"', 'version="0"').replace('max-rounds="3"', `max-rounds="0"${extras}`).replace('', ''); + const result = compileLoopFiles({ ...files, "review.loop": Buffer.from(source) }); + assert.equal(result.ok, false); assert.equal(result.diagnostics.length, 100); + assert.deepEqual(result.diagnostics[0], { path: "", byteOffset: 0, code: "E_TOO_MANY_DIAGNOSTICS", message: "Too many diagnostics (maximum 100)" }); + assert.ok(result.diagnostics.slice(1).every((item, index, all) => index === 0 || item.byteOffset >= all[index - 1].byteOffset)); +}); diff --git a/src/loops/dsl/diagnostics.mjs b/src/loops/dsl/diagnostics.mjs new file mode 100644 index 00000000..6439ca83 --- /dev/null +++ b/src/loops/dsl/diagnostics.mjs @@ -0,0 +1,39 @@ +export const MAX_DIAGNOSTICS = 100; + +export function compareUtf8(left, right) { + return Buffer.compare(Buffer.from(left, "utf8"), Buffer.from(right, "utf8")); +} + +export function sortDiagnostics(items) { + return [...items].sort((left, right) => + compareUtf8(left.path, right.path) || left.byteOffset - right.byteOffset || + compareUtf8(left.code, right.code) || compareUtf8(left.message, right.message)); +} + +export function finalizeDiagnostics(items) { + const sorted = sortDiagnostics(items); + if (sorted.length <= MAX_DIAGNOSTICS) return sorted; + return sortDiagnostics([...sorted.slice(0, MAX_DIAGNOSTICS - 1), { + path: "", byteOffset: 0, code: "E_TOO_MANY_DIAGNOSTICS", + message: "Too many diagnostics (maximum 100)", + }]); +} + +/** A closed, bounded Loop diagnostic accumulator. */ +export function createDiagnostics() { + const values = []; + return { + add(path, byteOffset, code, message) { + values.push({ path, byteOffset, code, message }); + }, + get list() { + return finalizeDiagnostics(values); + }, + get all() { return [...values]; }, + }; +} + +export function renderDiagnostics(items) { + return sortDiagnostics(items).map(({ path, byteOffset, code, message }) => + `${path}:${byteOffset}: ${code}: ${message}\n`).join(""); +} diff --git a/src/loops/dsl/frozen.mjs b/src/loops/dsl/frozen.mjs new file mode 100644 index 00000000..8d6ab0aa --- /dev/null +++ b/src/loops/dsl/frozen.mjs @@ -0,0 +1,80 @@ +import { canonicalIrBytes } from "./canonical.mjs"; +import { prefixed, rawSha256 } from "./hash.mjs"; +import { validateClosedIr } from "./ir-validate.mjs"; + +const slug = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; +const digest = /^[a-f0-9]{64}$/; +const sections = ["schema", "compiler", "revisions", "source", "package", "ir", "instructions"]; +const revisionKeys = ["source", "package", "executable"]; +const packageSizes = { "review.loop": [1, 65536], "instructions.md": [1, 262144], "example/item.md": [0, 65536] }; + +function sortByPath(entries) { + return [...entries].sort((left, right) => Buffer.compare(Buffer.from(left.path), Buffer.from(right.path))); +} + +function canonicalInstruction(item) { + return { id: item.id, digest: item.digest, bytes: item.bytes }; +} + +function hasOrderedKeys(value, keys) { + if (!exact(value, keys)) return false; + return Object.keys(value).length === keys.length && Object.keys(value).every((key, index) => key === keys[index]); +} + +function canonicalFrozenBytes(value) { + if (!exact(value, sections) || !hasOrderedKeys(value, sections) || !exact(value.revisions, revisionKeys) || !hasOrderedKeys(value.revisions, revisionKeys)) return null; + const ir = JSON.parse(canonicalIrBytes(value.ir).toString("utf8")); + const packageFiles = sortByPath(value.package).map((entry) => ({ path: entry.path, bytes: entry.bytes })); + const instructions = [...value.instructions].sort((left, right) => Buffer.compare(Buffer.from(left.id), Buffer.from(right.id)) + ).map((item) => canonicalInstruction(item)); + return Buffer.from(`${JSON.stringify({ + schema: value.schema, + compiler: value.compiler, + revisions: { source: value.revisions.source, package: value.revisions.package, executable: value.revisions.executable }, + source: value.source, + package: packageFiles, + ir, + instructions, + })}\n`, "utf8"); +} + +function exact(value, keys) { return !!value && typeof value === "object" && !Array.isArray(value) && Object.keys(value).length === keys.length && keys.every((key) => Object.hasOwn(value, key)); } +function base64(value) { if (typeof value !== "string" || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) throw new TypeError("Frozen recipe contains noncanonical base64"); const bytes = Buffer.from(value, "base64"); if (bytes.toString("base64") !== value) throw new TypeError("Frozen recipe contains noncanonical base64"); return bytes; } +function revision(value, prefix) { return typeof value === "string" && new RegExp(`^${prefix}-sha256:[a-f0-9]{64}$`).test(value); } +function freeze(value) { if (!value || typeof value !== "object") return value; for (const item of Object.values(value)) freeze(item); return Object.freeze(value); } + +/** Serializes the only runtime-facing compiler product: immutable IR and source/package bytes. */ +export function freezeRecipe(compiled) { + if (!compiled?.ok || !compiled.ir || !Buffer.isBuffer(compiled.irBytes) || !compiled.packageFiles) throw new TypeError("A successful compile result is required"); + const packageFiles = Object.entries(compiled.packageFiles).sort(([left], [right]) => Buffer.compare(Buffer.from(left), Buffer.from(right))).map(([path, bytes]) => ({ path, bytes: Buffer.from(bytes).toString("base64") })); + const instructions = compiled.instructions.map((section) => ({ id: section.id, digest: section.digest, bytes: Buffer.from(section.bytes).toString("base64") })); + return Buffer.from(`${JSON.stringify({ schema: "burnlist-loop-frozen@1", compiler: compiled.ir.compiler, revisions: compiled.revisions, source: Buffer.from(compiled.packageFiles["review.loop"]).toString("base64"), package: packageFiles, ir: JSON.parse(compiled.irBytes), instructions })}\n`, "utf8"); +} + +/** Runtime/replay boundary: verify frozen bytes and never recompile installed source. */ +export function loadFrozenRecipe(bytes) { + let value; const raw = Buffer.from(bytes); + try { value = JSON.parse(raw.toString("utf8")); } catch { throw new TypeError("Frozen recipe is not valid JSON"); } + try { + const canonical = canonicalFrozenBytes(value); + if (!canonical.equals(raw)) throw new TypeError("Frozen recipe is not canonical"); + } catch { + throw new TypeError("Frozen recipe is not canonical"); + } + if (!exact(value, sections) || value.schema !== "burnlist-loop-frozen@1" || value.compiler !== "burnlist-loop-compiler@1" || !exact(value.revisions, revisionKeys) || !revision(value.revisions.source, "ls1") || !revision(value.revisions.package, "lp1") || !revision(value.revisions.executable, "er1") || !validateClosedIr(value.ir) || value.ir.compiler !== value.compiler) throw new TypeError("Frozen recipe has an invalid envelope"); + const source = base64(value.source); + if (!Array.isArray(value.package) || value.package.length < 2 || value.package.length > 3 || !Array.isArray(value.instructions)) throw new TypeError("Frozen recipe has an invalid package"); + const packageFiles = value.package.map((item) => { if (!exact(item, ["path", "bytes"]) || !Object.hasOwn(packageSizes, item.path)) throw new TypeError("Frozen recipe has an invalid package"); const content = base64(item.bytes), [minimum, maximum] = packageSizes[item.path]; if (content.length < minimum || content.length > maximum) throw new TypeError("Frozen recipe has an invalid package"); return { path: item.path, bytes: content }; }); + if (new Set(packageFiles.map((item) => item.path)).size !== packageFiles.length || packageFiles.reduce((total, item) => total + item.bytes.length, 0) > 393216 || !packageFiles.some((item) => item.path === "review.loop" && item.bytes.equals(source)) || !packageFiles.some((item) => item.path === "instructions.md")) throw new TypeError("Frozen recipe has an invalid package"); + const sortedPackage = [...packageFiles].sort((left, right) => Buffer.compare(Buffer.from(left.path), Buffer.from(right.path))); + const packageDigest = prefixed("lp1-sha256:", "package-v1", sortedPackage.flatMap((item) => [Buffer.from(item.path), item.bytes])); + if (prefixed("ls1-sha256:", "source-v1", [source]) !== value.revisions.source || packageDigest !== value.revisions.package) throw new TypeError("Frozen recipe provenance revision does not match bytes"); + const parsedInstructions = value.instructions.map((item) => { if (!exact(item, ["id", "digest", "bytes"]) || !slug.test(item.id) || !/^sha256:[a-f0-9]{64}$/.test(item.digest)) throw new TypeError("Frozen recipe instruction is invalid"); const content = base64(item.bytes); if (rawSha256(content) !== item.digest) throw new TypeError("Frozen recipe instruction is invalid"); return { id: item.id, digest: item.digest, byteLength: content.length, base64: item.bytes, content }; }); + if (new Set(parsedInstructions.map((item) => item.id)).size !== parsedInstructions.length) throw new TypeError("Frozen recipe instruction is invalid"); + const irBytes = canonicalIrBytes(value.ir); + if (JSON.stringify(value.ir.instructions) !== JSON.stringify(parsedInstructions.map(({ content, base64: _base64, ...item }) => item))) throw new TypeError("Frozen recipe instructions do not match IR"); + const executable = prefixed("er1-sha256:", "recipe-v1", [Buffer.from(value.ir.compiler), irBytes, ...parsedInstructions.flatMap((item) => [Buffer.from(item.id), item.content])]); + if (executable !== value.revisions.executable) throw new TypeError("Frozen recipe executable revision does not match bytes"); + const instructions = parsedInstructions.map(({ content: _content, ...item }) => freeze(item)); + return freeze({ ir: value.ir, irBytes: irBytes.toString("base64"), instructions, revisions: { ...value.revisions } }); +} diff --git a/src/loops/dsl/grammar.mjs b/src/loops/dsl/grammar.mjs new file mode 100644 index 00000000..1fea377e --- /dev/null +++ b/src/loops/dsl/grammar.mjs @@ -0,0 +1,114 @@ +import { createDiagnostics } from "./diagnostics.mjs"; +import { normalizeIr } from "./canonical.mjs"; + +const slug = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; +const route = /^[a-z0-9]+(?:-[a-z0-9]+)*(?:\.[a-z0-9]+(?:-[a-z0-9]+)*)*$/; +const semver = /^(0|[1-9][0-9]{0,5})\.(0|[1-9][0-9]{0,5})\.(0|[1-9][0-9]{0,5})$/; +const positive = /^[1-9][0-9]*$/; +const order = ["budget", "agent", "check", "agent", "gate", "terminal", "terminal", "terminal", "terminal", "terminal", "failure-policy", "edge", "edge", "edge", "edge", "edge", "edge", "edge", "edge"]; +const attrs = { + loop: ["id", "version", "entry"], budget: ["max-rounds", "max-minutes", "max-agent-runs", "max-check-runs", "max-transitions", "max-output-bytes"], + agent: ["id", "mode", "role", "route", "authority", "instructions", "independent-from", "requires"], check: ["id", "capability"], gate: ["id", "kind", "requires"], + terminal: ["id", "state"], "failure-policy": ["error", "timeout", "cancelled", "lost", "exhausted"], edge: ["from", "on", "to", "max-visits"], +}; +const required = { + loop: attrs.loop, budget: attrs.budget, agent: ["id", "mode", "role", "route", "authority", "instructions"], check: ["id", "capability"], gate: attrs.gate, terminal: attrs.terminal, + "failure-policy": attrs["failure-policy"], edge: ["from", "on", "to"], +}; +const outcomes = { agent: (node) => node.mode === "task" ? ["complete"] : ["approve", "reject", "escalate"], check: () => ["pass", "fail"], gate: () => ["pass", "fail"], terminal: () => [] }; +const terminalStates = ["converged", "needs-human", "failed", "stopped", "budget-exhausted"]; +const limits = { "max-rounds": [1, 100], "max-minutes": [1, 1440], "max-agent-runs": [1, 100], "max-check-runs": [1, 100], "max-transitions": [1, 1000], "max-output-bytes": [1024, 1048576] }; + +function add(d, node, code, message) { d.add("review.loop", node?.byteOffset ?? 0, code, message); } +function exactAttrs(d, node) { + const allowed = attrs[node.name]; + if (!allowed) { add(d, node, "E_ELEMENT_UNKNOWN", `Unknown Stage 1 element <${node.name}>`); return; } + for (const key of Object.keys(node.attrs)) if (!allowed.includes(key)) add(d, node, "E_ATTRIBUTE_UNKNOWN", `Attribute ${key} is not allowed on <${node.name}>`); + for (const key of required[node.name]) if (!(key in node.attrs)) add(d, node, "E_ATTRIBUTE_REQUIRED", `<${node.name}> requires attribute ${key}`); +} +function value(d, node, key, test, description) { if (key in node.attrs && !test(node.attrs[key])) add(d, node, "E_SCALAR", `${key} must be ${description}`); } +function reachesTerminal(nodes, edges, start) { + const byFrom = new Map(); for (const edge of edges) (byFrom.get(edge.from) ?? byFrom.set(edge.from, []).get(edge.from)).push(edge.to); + const seen = new Set(), work = [start]; while (work.length) { const id = work.pop(); if (seen.has(id)) continue; seen.add(id); for (const next of byFrom.get(id) ?? []) work.push(next); } + return seen; +} +function backEdges(nodes, edges, entry) { + const byFrom = new Map(); for (const edge of edges) (byFrom.get(edge.from) ?? byFrom.set(edge.from, []).get(edge.from)).push(edge); + const color = new Map(), result = new Set(); + const visit = (id) => { color.set(id, 1); for (const edge of byFrom.get(id) ?? []) { if (color.get(edge.to) === 1) result.add(edge); else if (!color.get(edge.to)) visit(edge.to); } color.set(id, 2); }; + visit(entry); return result; +} + +/** Validates the full closed Stage 1 grammar and emits normalized symbolic IR. */ +export function validateLoop(ast) { + const d = createDiagnostics(); + if (!ast || ast.name !== "loop") { add(d, ast, "E_ROOT", "Root element must be "); return { diagnostics: d.list, allDiagnostics: d.all }; } + exactAttrs(d, ast); + if (ast.selfClosing) add(d, ast, "E_ROOT_FORM", "Root must not be self-closing"); + value(d, ast, "id", (v) => slug.test(v), "a lowercase slug"); value(d, ast, "version", (v) => semver.test(v), "a Stage 1 SemVer"); value(d, ast, "entry", (v) => slug.test(v), "a lowercase slug"); + ast.children.forEach((node, index) => { exactAttrs(d, node); if (!node.selfClosing) add(d, node, "E_CHILD_FORM", `<${node.name}> must be self-closing`); if (node.children.length) add(d, node, "E_CHILDREN", `<${node.name}> may not have children`); if (node.name !== order[index]) add(d, node, "E_CHILD_ORDER", "Children must use the closed Stage 1 group order"); if (index === 1 && node.attrs.mode !== "task") add(d, node, "E_CHILD_GROUP", "Maker agent group must precede check"); if (index === 3 && node.attrs.mode !== "review") add(d, node, "E_CHILD_GROUP", "Reviewer agent group must follow check"); }); + if (ast.children.length !== order.length) add(d, ast, "E_CHILD_COUNT", "Loop must contain the exact closed Stage 1 child set"); + const ids = new Map(), nodes = [], edges = [], policy = ast.children.find((node) => node.name === "failure-policy"); + for (const node of ast.children) { + if (["agent", "check", "gate", "terminal"].includes(node.name)) { + value(d, node, "id", (v) => slug.test(v), "a lowercase slug"); + if (node.attrs.id && ids.has(node.attrs.id)) add(d, node, "E_ID_DUPLICATE", `Duplicate id ${node.attrs.id}`); else if (node.attrs.id) ids.set(node.attrs.id, node); + } + if (node.name === "budget") for (const [key, [min, max]] of Object.entries(limits)) value(d, node, key, (v) => positive.test(v) && +v >= min && +v <= max, `an integer from ${min} through ${max}`); + if (node.name === "agent") { + value(d, node, "mode", (v) => v === "task" || v === "review", "task or review"); value(d, node, "role", (v) => v === "maker" || v === "reviewer", "maker or reviewer"); + value(d, node, "route", (v) => route.test(v), "a Route"); value(d, node, "authority", (v) => v === "read" || v === "write", "read or write"); value(d, node, "instructions", (v) => slug.test(v), "a lowercase slug"); + const task = node.attrs.mode === "task"; const expected = task ? ["maker", "write"] : ["reviewer", "read"]; + if ((node.attrs.role && node.attrs.authority) && (node.attrs.role !== expected[0] || node.attrs.authority !== expected[1])) add(d, node, "E_AGENT_COMBINATION", `${node.attrs.mode} agent must be ${expected[0]}/${expected[1]}`); + if (task && ("independent-from" in node.attrs || "requires" in node.attrs)) add(d, node, "E_AGENT_ATTRIBUTES", "Task agent may not have review-only attributes"); + if (!task) { for (const key of ["independent-from", "requires"]) if (!(key in node.attrs)) add(d, node, "E_ATTRIBUTE_REQUIRED", ` requires attribute ${key}`); if (node.attrs.requires !== "fresh-session:enforced,filesystem-write-deny:supervised") add(d, node, "E_REVIEW_REQUIREMENTS", "Review requires must be fresh-session:enforced,filesystem-write-deny:supervised"); } + nodes.push({ kind: "agent", id: node.attrs.id, mode: node.attrs.mode, role: node.attrs.role, route: node.attrs.route, authority: node.attrs.authority, instructions: node.attrs.instructions, independentFrom: node.attrs["independent-from"] ?? null, requires: node.attrs.requires ? node.attrs.requires.split(",") : [] }); + } + if (node.name === "check") { value(d, node, "capability", (v) => slug.test(v), "a lowercase slug"); nodes.push({ kind: "check", id: node.attrs.id, capability: node.attrs.capability }); } + if (node.name === "gate") { if (node.attrs.kind !== "convergence") add(d, node, "E_GATE_KIND", "Stage 1 gate kind must be convergence"); nodes.push({ kind: "gate", id: node.attrs.id, gateKind: node.attrs.kind, requires: node.attrs.requires?.split(",") ?? [] }); } + if (node.name === "terminal") { if (!terminalStates.includes(node.attrs.state)) add(d, node, "E_TERMINAL_STATE", "Terminal state is not allowed in Stage 1"); nodes.push({ kind: "terminal", id: node.attrs.id, state: node.attrs.state }); } + if (node.name === "edge") { if ("max-visits" in node.attrs) value(d, node, "max-visits", (v) => positive.test(v) && +v <= 100, "an integer from 1 through 100"); edges.push({ from: node.attrs.from, on: node.attrs.on, to: node.attrs.to, maxVisits: node.attrs["max-visits"] ? +node.attrs["max-visits"] : null, offset: node.byteOffset }); } + } + const agents = nodes.filter((node) => node.kind === "agent"), checks = nodes.filter((node) => node.kind === "check"), gates = nodes.filter((node) => node.kind === "gate"), terminals = nodes.filter((node) => node.kind === "terminal"); + if (agents.length !== 2 || agents.filter((node) => node.mode === "task").length !== 1 || agents.filter((node) => node.mode === "review").length !== 1) add(d, ast, "E_AGENT_CARDINALITY", "Stage 1 requires one task agent and one review agent"); + if (checks.length !== 1 || gates.length !== 1) add(d, ast, "E_NODE_CARDINALITY", "Stage 1 requires exactly one check and one convergence gate"); + for (const state of terminalStates) if (terminals.filter((node) => node.state === state).length !== 1) add(d, ast, "E_TERMINAL_CARDINALITY", `Stage 1 requires exactly one ${state} terminal`); + const reviewer = agents.find((node) => node.mode === "review"), maker = agents.find((node) => node.mode === "task"), check = checks[0], gate = gates[0]; + if (reviewer && reviewer.independentFrom !== maker?.id) add(d, ids.get(reviewer.id), "E_REVIEW_INDEPENDENCE", "Reviewer independent-from must name the task agent"); + if (gate && `${gate.requires.join(",")}` !== `${check?.id ?? ""},${reviewer?.id ?? ""}`) add(d, ids.get(gate.id), "E_GATE_REQUIREMENTS", "Convergence gate requires must name check then reviewer"); + if (policy) for (const [outcome, state] of Object.entries({ error: "failed", timeout: "failed", cancelled: "stopped", lost: "needs-human", exhausted: "budget-exhausted" })) { const target = ids.get(policy.attrs[outcome]); if (!target || target.attrs.state !== state) add(d, policy, "E_FAILURE_POLICY", `${outcome} must target the ${state} terminal`); } + for (const edge of edges) { + const from = ids.get(edge.from), to = ids.get(edge.to); + if (!from || !to) { d.add("review.loop", edge.offset, "E_EDGE_REFERENCE", "Edge endpoints must name declared nodes"); continue; } + if (from.name === "terminal") d.add("review.loop", edge.offset, "E_EDGE_TERMINAL", "Terminal nodes may not have edges"); + const source = nodes.find((node) => node.id === edge.from); + if (!outcomes[source?.kind]?.(source).includes(edge.on)) d.add("review.loop", edge.offset, "E_EDGE_OUTCOME", `Outcome ${edge.on} is not emitted by ${edge.from}`); + const target = nodes.find((node) => node.id === edge.to); + const allowed = (source?.kind === "agent" && source.mode === "task" && edge.on === "complete" && target?.kind === "check") || + (source?.kind === "check" && edge.on === "pass" && target?.kind === "agent" && target.mode === "review") || + (source?.kind === "check" && edge.on === "fail" && target?.kind === "agent" && target.mode === "task") || + (source?.kind === "agent" && source.mode === "review" && edge.on === "reject" && target?.kind === "agent" && target.mode === "task") || + (source?.kind === "agent" && source.mode === "review" && edge.on === "approve" && target?.kind === "gate") || + (source?.kind === "agent" && source.mode === "review" && edge.on === "escalate" && target?.kind === "terminal" && target.state === "needs-human") || + (source?.kind === "gate" && edge.on === "pass" && target?.kind === "terminal" && target.state === "converged") || + (source?.kind === "gate" && edge.on === "fail" && target?.kind === "terminal" && target.state === "needs-human"); + if (!allowed) d.add("review.loop", edge.offset, "E_EDGE_TARGET", `Target ${edge.to} is not allowed for ${edge.from}/${edge.on}`); + } + const pairs = new Set(); for (const edge of edges) { const key = `${edge.from}\0${edge.on}`; if (pairs.has(key)) d.add("review.loop", edge.offset, "E_EDGE_DUPLICATE", `Duplicate edge outcome ${edge.from}/${edge.on}`); pairs.add(key); } + for (const node of nodes.filter((node) => node.kind !== "terminal")) for (const outcome of outcomes[node.kind](node)) if (!pairs.has(`${node.id}\0${outcome}`)) add(d, ids.get(node.id), "E_EDGE_MISSING", `Missing edge for ${node.id}/${outcome}`); + const converged = terminals.find((node) => node.state === "converged"); + if (converged) for (const edge of edges.filter((edge) => edge.to === converged.id)) if (edge.from !== gate?.id || edge.on !== "pass") d.add("review.loop", edge.offset, "E_CONVERGENCE_DOMINATION", "Only convergence gate pass may target the converged terminal"); + if (converged && !edges.some((edge) => edge.from === gate?.id && edge.on === "pass" && edge.to === converged.id)) add(d, ids.get(gate?.id), "E_CONVERGENCE_DOMINATION", "Convergence gate pass must target the converged terminal"); + // System outcomes are runner-owned but are still graph routes for reachability. + const systemEdges = policy ? nodes.filter((node) => node.kind !== "terminal").flatMap((node) => Object.entries(policy.attrs).map(([on, to]) => ({ from: node.id, on, to }))) : []; + const reachable = reachesTerminal(nodes, [...edges, ...systemEdges], ast.attrs.entry); if (!ids.has(ast.attrs.entry)) add(d, ast, "E_ENTRY", "Entry must name a declared node"); + else for (const node of nodes) if (!reachable.has(node.id)) add(d, ids.get(node.id), "E_REACHABILITY", `Node ${node.id} is not reachable from entry`); + const back = ids.has(ast.attrs.entry) ? backEdges(nodes, edges, ast.attrs.entry) : new Set(); + for (const edge of edges) { const needs = back.has(edge); if (needs !== (edge.maxVisits !== null)) d.add("review.loop", edge.offset, "E_EDGE_VISITS", needs ? "DFS back edges require max-visits" : "max-visits is allowed only on DFS back edges"); } + if (d.all.length) return { diagnostics: d.list, allDiagnostics: d.all }; + const ir = normalizeIr({ schema: "burnlist-loop-ir@1", compiler: "burnlist-loop-compiler@1", id: ast.attrs.id, declaredVersion: ast.attrs.version, entry: ast.attrs.entry, + budget: { maxRounds: +ast.children[0].attrs["max-rounds"], maxMinutes: +ast.children[0].attrs["max-minutes"], maxAgentRuns: +ast.children[0].attrs["max-agent-runs"], maxCheckRuns: +ast.children[0].attrs["max-check-runs"], maxTransitions: +ast.children[0].attrs["max-transitions"], maxOutputBytes: +ast.children[0].attrs["max-output-bytes"] }, nodes, failurePolicy: { error: policy.attrs.error, timeout: policy.attrs.timeout, cancelled: policy.attrs.cancelled, lost: policy.attrs.lost, exhausted: policy.attrs.exhausted }, edges: edges.map(({ offset, ...edge }) => edge), instructions: [] }, (node) => outcomes[node.kind](node)); + return { ir, instructionIds: agents.map((node) => node.instructions), diagnostics: [], allDiagnostics: [] }; +} + +export function outcomesFor(node) { return outcomes[node.kind](node); } diff --git a/src/loops/dsl/hash.mjs b/src/loops/dsl/hash.mjs new file mode 100644 index 00000000..ff4fcbac --- /dev/null +++ b/src/loops/dsl/hash.mjs @@ -0,0 +1,39 @@ +import { createHash } from "node:crypto"; + +const encoder = new TextEncoder(); +const MAX_U32 = 0xffffffff; +const MAX_U64 = (1n << 64n) - 1n; + +export function utf8(value) { return encoder.encode(String(value)); } + +function length32(length) { + if (!Number.isSafeInteger(length) || length < 0 || length > MAX_U32) throw new RangeError("u32 length overflow"); + const out = Buffer.allocUnsafe(4); out.writeUInt32BE(length); return out; +} +function length64(length) { + if (!Number.isSafeInteger(length) || length < 0 || BigInt(length) > MAX_U64) throw new RangeError("u64 length overflow"); + const out = Buffer.allocUnsafe(8); out.writeBigUInt64BE(BigInt(length)); return out; +} +function bytes(value) { return Buffer.isBuffer(value) ? value : Buffer.from(value); } + +/** Stage 1's domain-separated digest framing. */ +export function hashFields(domain, fields) { + const digest = createHash("sha256"); + const domainBytes = Buffer.from(utf8(domain)); + digest.update("burnlist-hash-v1\0", "utf8"); + digest.update(length32(domainBytes.length)); + digest.update(domainBytes); + digest.update(length32(fields.length)); + for (const field of fields) { + const value = bytes(field); + digest.update(length64(value.length)); + digest.update(value); + } + return digest.digest("hex"); +} + +export function prefixed(prefix, domain, fields) { + return `${prefix}${hashFields(domain, fields)}`; +} + +export function rawSha256(value) { return `sha256:${createHash("sha256").update(bytes(value)).digest("hex")}`; } diff --git a/src/loops/dsl/instructions.mjs b/src/loops/dsl/instructions.mjs new file mode 100644 index 00000000..80c3e9ce --- /dev/null +++ b/src/loops/dsl/instructions.mjs @@ -0,0 +1,51 @@ +import { createDiagnostics } from "./diagnostics.mjs"; +import { rawSha256 } from "./hash.mjs"; + +const heading = /^## ([a-z0-9]+(?:-[a-z0-9]+)*)\n$/; +function fenceOpener(line) { + const found = line.match(/^ {0,3}(`{3,}|~{3,})[^\n]*\n$/); + return found ? { char: found[1][0], length: found[1].length } : null; +} + +function decode(bytes, path, d) { + try { return new TextDecoder("utf-8", { fatal: true }).decode(bytes); } + catch { d.add(path, 0, "E_INSTRUCTIONS_UTF8", "Instructions are not valid UTF-8"); return null; } +} + +/** Extract only the exact executable instruction sections selected by Loop agents. */ +export function extractInstructionSections(input, ids, { path = "instructions.md" } = {}) { + const d = createDiagnostics(); + const bytes = Buffer.from(input); + const source = decode(bytes, path, d); + if (source === null) return { diagnostics: d.list }; + if (source.includes("\r")) d.add(path, 0, "E_INSTRUCTIONS_CRLF", "Instructions must use LF line endings"); + if (!source.endsWith("\n")) d.add(path, bytes.length, "E_INSTRUCTIONS_FINAL_LF", "Instructions must end in LF"); + const sections = new Map(); let activeFence = null, current = null, currentStart = 0, position = 0; + for (const line of source.matchAll(/[^\n]*\n|[^\n]+$/g)) { + const value = line[0], lineOffset = position; position += Buffer.byteLength(value); + if (activeFence) { + const close = new RegExp(`^ {0,3}${activeFence.char}{${activeFence.length},} *\\n$`); + if (close.test(value)) activeFence = null; + continue; + } + const opener = fenceOpener(value); + if (opener) { activeFence = opener; continue; } + const found = value.match(heading); + if (!found) continue; + if (current) sections.set(current.id, { ...current, bytes: bytes.subarray(currentStart, lineOffset) }); + current = { id: found[1], headingOffset: lineOffset }; currentStart = position; + if (sections.has(current.id)) d.add(path, lineOffset, "E_INSTRUCTIONS_DUPLICATE", `Duplicate instruction section ${current.id}`); + } + if (activeFence) d.add(path, bytes.length, "E_INSTRUCTIONS_FENCE", "Unclosed fenced block"); + if (current) sections.set(current.id, { ...current, bytes: bytes.subarray(currentStart) }); + const selected = []; + for (const id of [...new Set(ids)].sort((a, b) => Buffer.compare(Buffer.from(a), Buffer.from(b)))) { + const section = sections.get(id); + if (!section) { d.add(path, 0, "E_INSTRUCTIONS_MISSING", `Missing instruction section ${id}`); continue; } + if (section.bytes.length < 1 || section.bytes.length > 65536 || !/\S/u.test(new TextDecoder().decode(section.bytes))) { + d.add(path, section.headingOffset, "E_INSTRUCTIONS_CONTENT", `Instruction section ${id} must contain 1..65536 non-whitespace bytes`); continue; + } + selected.push({ id, digest: rawSha256(section.bytes), byteLength: section.bytes.length, bytes: Buffer.from(section.bytes) }); + } + return { sections: selected, diagnostics: d.list }; +} diff --git a/src/loops/dsl/ir-validate.mjs b/src/loops/dsl/ir-validate.mjs new file mode 100644 index 00000000..6bd01d4c --- /dev/null +++ b/src/loops/dsl/ir-validate.mjs @@ -0,0 +1,58 @@ +import { normalizeIr } from "./canonical.mjs"; +import { outcomesFor } from "./grammar.mjs"; + +const slug = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; +const route = /^[a-z0-9]+(?:-[a-z0-9]+)*(?:\.[a-z0-9]+(?:-[a-z0-9]+)*)*$/; +const semver = /^(0|[1-9][0-9]{0,5})\.(0|[1-9][0-9]{0,5})\.(0|[1-9][0-9]{0,5})$/; +const states = ["converged", "needs-human", "failed", "stopped", "budget-exhausted"]; +const top = ["schema", "compiler", "id", "declaredVersion", "entry", "budget", "nodes", "failurePolicy", "edges", "instructions"]; +const budget = ["maxRounds", "maxMinutes", "maxAgentRuns", "maxCheckRuns", "maxTransitions", "maxOutputBytes"]; +const policy = ["error", "timeout", "cancelled", "lost", "exhausted"]; +const nodeKeys = { agent: ["kind", "id", "mode", "role", "route", "authority", "instructions", "independentFrom", "requires"], check: ["kind", "id", "capability"], gate: ["kind", "id", "gateKind", "requires"], terminal: ["kind", "id", "state"] }; +const limits = { maxRounds: [1, 100], maxMinutes: [1, 1440], maxAgentRuns: [1, 100], maxCheckRuns: [1, 100], maxTransitions: [1, 1000], maxOutputBytes: [1024, 1048576] }; + +function exact(value, keys) { return !!value && typeof value === "object" && !Array.isArray(value) && Object.keys(value).length === keys.length && keys.every((key) => Object.hasOwn(value, key)); } +function boundedSlug(value) { return typeof value === "string" && value.length <= 65536 && slug.test(value); } +function integer(value, range = [0, Number.MAX_SAFE_INTEGER]) { return Number.isSafeInteger(value) && value >= range[0] && value <= range[1]; } +function same(left, right) { return JSON.stringify(left) === JSON.stringify(right); } +function validNode(node) { + if (!exact(node, nodeKeys[node?.kind] ?? []) || !boundedSlug(node.id)) return false; + if (node.kind === "agent") return ["task", "review"].includes(node.mode) && ["maker", "reviewer"].includes(node.role) && route.test(node.route) && ["read", "write"].includes(node.authority) && boundedSlug(node.instructions) && (node.independentFrom === null || boundedSlug(node.independentFrom)) && Array.isArray(node.requires) && node.requires.every((item) => typeof item === "string" && item.length <= 128); + if (node.kind === "check") return boundedSlug(node.capability); + if (node.kind === "gate") return node.gateKind === "convergence" && Array.isArray(node.requires) && node.requires.every(boundedSlug); + return states.includes(node.state); +} +function targetAllowed(source, outcome, target) { + return (source.kind === "agent" && source.mode === "task" && outcome === "complete" && target.kind === "check") || + (source.kind === "check" && outcome === "pass" && target.kind === "agent" && target.mode === "review") || + (source.kind === "check" && outcome === "fail" && target.kind === "agent" && target.mode === "task") || + (source.kind === "agent" && source.mode === "review" && outcome === "reject" && target.kind === "agent" && target.mode === "task") || + (source.kind === "agent" && source.mode === "review" && outcome === "approve" && target.kind === "gate") || + (source.kind === "agent" && source.mode === "review" && outcome === "escalate" && target.kind === "terminal" && target.state === "needs-human") || + (source.kind === "gate" && outcome === "pass" && target.kind === "terminal" && target.state === "converged") || + (source.kind === "gate" && outcome === "fail" && target.kind === "terminal" && target.state === "needs-human"); +} + +/** Rejects every noncanonical or unsupported symbolic IR before frozen replay. */ +export function validateClosedIr(ir) { + if (!exact(ir, top) || ir.schema !== "burnlist-loop-ir@1" || ir.compiler !== "burnlist-loop-compiler@1" || !boundedSlug(ir.id) || !semver.test(ir.declaredVersion) || !boundedSlug(ir.entry) || !exact(ir.budget, budget) || !Object.entries(limits).every(([key, range]) => integer(ir.budget[key], range)) || !exact(ir.failurePolicy, policy) || !Object.values(ir.failurePolicy).every(boundedSlug) || !Array.isArray(ir.nodes) || ir.nodes.length > 64 || !ir.nodes.every(validNode) || !Array.isArray(ir.edges) || ir.edges.length > 512 || !Array.isArray(ir.instructions) || ir.instructions.length > 2) return false; + const ids = new Map(ir.nodes.map((node) => [node.id, node])); + if (ids.size !== ir.nodes.length || !ids.has(ir.entry)) return false; + const agents = ir.nodes.filter((node) => node.kind === "agent"), makers = agents.filter((node) => node.mode === "task" && node.role === "maker" && node.authority === "write"), reviewers = agents.filter((node) => node.mode === "review" && node.role === "reviewer" && node.authority === "read"), checks = ir.nodes.filter((node) => node.kind === "check"), gates = ir.nodes.filter((node) => node.kind === "gate"), terminals = ir.nodes.filter((node) => node.kind === "terminal"), agentInstructionIds = new Set(agents.map((agent) => agent.instructions)); + if (agents.length !== 2 || makers.length !== 1 || reviewers.length !== 1 || checks.length !== 1 || gates.length !== 1 || ir.entry !== makers[0].id || states.some((state) => terminals.filter((node) => node.state === state).length !== 1) || reviewers[0].independentFrom !== makers[0].id || !same(reviewers[0].requires, ["fresh-session:enforced", "filesystem-write-deny:supervised"]) || makers[0].independentFrom !== null || makers[0].requires.length || !same(gates[0].requires, [checks[0].id, reviewers[0].id])) return false; + if (agentInstructionIds.size !== 2) return false; + if (!ir.instructions.every((section) => exact(section, ["id", "digest", "byteLength"]) && boundedSlug(section.id) && /^sha256:[a-f0-9]{64}$/.test(section.digest) && integer(section.byteLength, [1, 65536])) || new Set(ir.instructions.map((section) => section.id)).size !== ir.instructions.length || !same(ir.instructions.map((section) => section.id).sort(), [makers[0].instructions, reviewers[0].instructions].sort())) return false; + for (const [outcome, state] of Object.entries({ error: "failed", timeout: "failed", cancelled: "stopped", lost: "needs-human", exhausted: "budget-exhausted" })) if (ids.get(ir.failurePolicy[outcome])?.state !== state) return false; + const pairs = new Set(); + for (const edge of ir.edges) { + if (!exact(edge, ["from", "on", "to", "maxVisits"]) || !boundedSlug(edge.from) || !boundedSlug(edge.to) || typeof edge.on !== "string" || edge.on.length > 64 || (edge.maxVisits !== null && !integer(edge.maxVisits, [1, 100]))) return false; + const source = ids.get(edge.from), target = ids.get(edge.to), key = `${edge.from}\0${edge.on}`; + if (!source || !target || pairs.has(key) || !outcomesFor(source).includes(edge.on) || !targetAllowed(source, edge.on, target)) return false; + const back = (source.kind === "check" && edge.on === "fail") || (source.kind === "agent" && source.mode === "review" && edge.on === "reject"); + if ((edge.maxVisits !== null) !== back) return false; + pairs.add(key); + } + if (ir.edges.length !== 8 || ir.nodes.filter((node) => node.kind !== "terminal").some((node) => outcomesFor(node).some((outcome) => !pairs.has(`${node.id}\0${outcome}`)))) return false; + const normalized = normalizeIr(ir, outcomesFor); + return same(ir.nodes, normalized.nodes) && same(ir.edges, normalized.edges) && same(ir.instructions, normalized.instructions); +} diff --git a/src/loops/dsl/loop-xml.mjs b/src/loops/dsl/loop-xml.mjs new file mode 100644 index 00000000..e959c26e --- /dev/null +++ b/src/loops/dsl/loop-xml.mjs @@ -0,0 +1,79 @@ +import { createDiagnostics } from "./diagnostics.mjs"; + +const nameRE = /^[A-Za-z_][A-Za-z0-9_.:-]*$/; + +function offsetOf(source, index) { return Buffer.byteLength(source.slice(0, index)); } +function white(char) { return char === " " || char === "\t" || char === "\n" || char === "\r"; } + +/** Parses the intentionally tiny, element-only Stage 1 XML subset. */ +export function parseLoopXml(input, { path = "review.loop" } = {}) { + const d = createDiagnostics(); + let source; + try { source = new TextDecoder("utf-8", { fatal: true }).decode(input); } + catch { d.add(path, 0, "E_XML_UTF8", "Source is not valid UTF-8"); return { diagnostics: d.list, allDiagnostics: d.all }; } + const add = (at, code, message) => d.add(path, offsetOf(source, at), code, message); + let i = 0, root = null, elements = 0; + const stack = []; + const skip = () => { while (white(source[i])) i++; }; + const readName = () => { + const begin = i; while (/[A-Za-z0-9_.:-]/.test(source[i] ?? "")) i++; + const value = source.slice(begin, i); return nameRE.test(value) ? value : ""; + }; + const text = () => { + const begin = i; while (i < source.length && source[i] !== "<") i++; + if (/\S/.test(source.slice(begin, i))) add(begin, "E_XML_TEXT", "Non-whitespace element text is not allowed"); + }; + while (i < source.length) { + if (source[i] !== "<") { text(); continue; } + const begin = i++; + if (source.startsWith("", begin + 4); + add(begin, "E_XML_COMMENT", end < 0 ? "Comments are not allowed (unterminated)" : "Comments are not allowed"); + i = end < 0 ? source.length : end + 3; continue; + } + if (source.startsWith("", i); add(begin, "E_XML_FORBIDDEN", "Declarations, entities, CDATA, and processing instructions are not allowed"); + i = end < 0 ? source.length : end + 1; continue; + } + if (source[i] === "/") { + i++; const name = readName(); skip(); + if (!name || source[i] !== ">") { add(begin, "E_XML_END_TAG", "Malformed end tag"); while (i < source.length && source[i] !== ">") i++; } + if (source[i] === ">") i++; + const open = stack.pop(); + if (!open || open.name !== name) add(begin, "E_XML_MISMATCH", `End tag does not match its opening element`); + continue; + } + const name = readName(); + if (!name) { add(begin, "E_XML_NAME", "Invalid element name"); while (i < source.length && source[i] !== ">") i++; if (source[i] === ">") i++; continue; } + if (name.includes(":")) add(begin, "E_XML_NAMESPACE", "Namespaces are not allowed"); + const attrs = Object.create(null); let selfClosing = false; + while (i < source.length) { + skip(); + if (source.startsWith("/>", i)) { i += 2; selfClosing = true; break; } + if (source[i] === ">") { i++; break; } + const attrAt = i, key = readName(); + if (!key) { add(attrAt, "E_XML_ATTRIBUTE", "Invalid attribute name"); i++; continue; } + if (key.includes(":")) add(attrAt, "E_XML_NAMESPACE", "Namespaces are not allowed"); + skip(); if (source[i] !== "=") { add(attrAt, "E_XML_ATTRIBUTE", `Expected = after attribute ${key}`); continue; } + i++; skip(); const quote = source[i++]; + if (quote !== '"' && quote !== "'") { add(attrAt, "E_XML_ATTRIBUTE", "Attribute values must be quoted"); continue; } + const valueAt = i; while (i < source.length && source[i] !== quote) i++; + if (i >= source.length) { add(attrAt, "E_XML_ATTRIBUTE", "Unterminated attribute value"); break; } + const value = source.slice(valueAt, i++); + if (value.includes("&")) add(valueAt, "E_XML_ENTITY", "Entities are not allowed"); + if (Object.hasOwn(attrs, key)) add(attrAt, "E_XML_DUPLICATE_ATTRIBUTE", `Duplicate attribute ${key}`); + else attrs[key] = value; + } + const node = { name, attrs, children: [], byteOffset: offsetOf(source, begin), selfClosing }; + elements++; + if (elements > 32) add(begin, "E_XML_LIMIT", "XML element limit is 32"); + if (stack.length >= 2) add(begin, "E_XML_DEPTH", "XML depth limit is 2"); + if (stack.length) stack.at(-1).children.push(node); + else if (root) add(begin, "E_XML_ROOT", "Only one root element is allowed"); + else root = node; + if (!selfClosing) stack.push(node); + } + for (const node of stack.reverse()) d.add(path, Buffer.byteLength(source), "E_XML_UNCLOSED", `Unclosed element <${node.name}>`); + if (!root) d.add(path, 0, "E_XML_ROOT", "Document requires a root element"); + return { ast: root, diagnostics: d.list, allDiagnostics: d.all }; +} diff --git a/src/loops/dsl/package-read.mjs b/src/loops/dsl/package-read.mjs new file mode 100644 index 00000000..8f43990c --- /dev/null +++ b/src/loops/dsl/package-read.mjs @@ -0,0 +1,352 @@ +import { constants } from "node:fs"; +import { lstat, opendir, open } from "node:fs/promises"; +import { join } from "node:path"; + +const KNOWN_ROOT = new Set(["review.loop", "instructions.md", "example"]); +const KNOWN_EXAMPLE = new Set(["item.md"]); +const FILE_LIMITS = { "review.loop": 65536, "instructions.md": 262144, "example/item.md": 65536 }; +const DIRECTORY_LIMITS = { "": 3, example: 1 }; +const MAX_PACKAGE_BYTES = 393216; + +const OPEN_FLAGS = constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK; + +function compareUtf8(left, right) { + return Buffer.compare(Buffer.from(left), Buffer.from(right)); +} + +function sameEntry(before, after) { + return Boolean( + before && + after && + before.dev === after.dev && + before.ino === after.ino && + before.mode === after.mode && + before.size === after.size && + before.mtimeNs === after.mtimeNs && + before.ctimeNs === after.ctimeNs, + ); +} + +function addDiagnostic(output, path, code, message) { + output.push({ path, byteOffset: 0, code, message }); +} + +async function readDirectoryEntries(folder, maxEntries, pathLabel, output) { + let dir; + try { + dir = await opendir(folder, { encoding: "utf8" }); + const entries = []; + let overflow = false; + for await (const entry of dir) { + entries.push(entry.name); + if (entries.length > maxEntries) { + overflow = true; + break; + } + } + if (overflow) entries.pop(); + return { + entries: entries.sort(compareUtf8), + overflow, + }; + } catch { + addDiagnostic(output, pathLabel, "E_PACKAGE_READ", "Package directory could not be read"); + return { entries: [], overflow: false, readError: true }; + } finally { + await dir?.close?.().catch(() => {}); + } +} + +async function snapshotDirectory(pathLabel, folder, output) { + let stat; + try { + stat = await lstat(folder, { bigint: true }); + } catch { + addDiagnostic(output, pathLabel, "E_PACKAGE_READ", "Package directory could not be inspected"); + return null; + } + if (stat.isSymbolicLink()) { + addDiagnostic(output, pathLabel, "E_PACKAGE_SYMLINK", "Package symlinks are not allowed"); + return null; + } + if (!stat.isDirectory()) { + addDiagnostic(output, pathLabel, "E_PACKAGE_DIRECTORY", "Package root must be a directory"); + return null; + } + + const limit = DIRECTORY_LIMITS[pathLabel] ?? Number.MAX_SAFE_INTEGER; + const contents = await readDirectoryEntries(folder, limit, pathLabel, output); + if (contents.readError) { + return { path: pathLabel, stat, entries: [], truncated: false, readError: true }; + } + if (contents.overflow) addDiagnostic(output, pathLabel, "E_PACKAGE_COUNT", "Package may contain at most three files"); + + return { path: pathLabel, stat, entries: contents.entries, truncated: contents.overflow }; +} + +function compareDirectory(before, after, output, isKnownEntry) { + if (!before || !after) return; + + if (!sameEntry(before.stat, after.stat)) { + addDiagnostic(output, before.path, "E_PACKAGE_RACE", "Package directory changed while reading"); + } + if (before.truncated) { + addDiagnostic(output, before.path, "E_PACKAGE_RACE", "Package directory changed while reading"); + } + if (after.truncated && !before.truncated) { + addDiagnostic(output, before.path, "E_PACKAGE_RACE", "Package directory changed while reading"); + } + + const previous = new Set(before.entries); + const current = new Set(after.entries); + for (const name of previous) { + if (!current.has(name)) { + addDiagnostic(output, before.path, "E_PACKAGE_RACE", "Package directory changed while reading"); + } + } + for (const name of current) { + if (previous.has(name)) continue; + if (isKnownEntry(name)) { + addDiagnostic(output, before.path, "E_PACKAGE_RACE", "Package directory changed while reading"); + } else { + addDiagnostic(output, `${before.path ? `${before.path}/` : ""}${name}`, "E_PACKAGE_PATH", "Package entry was discovered after directory enumeration"); + } + } +} + +function toFileSize(value) { + return typeof value === "bigint" ? Number(value) : value; +} + +async function validateAncestors(ancestors, output, failurePath) { + for (const ancestor of ancestors) { + try { + const current = await lstat(ancestor.path, { bigint: true }); + if (!current.isDirectory() || !sameEntry(current, ancestor.stat)) { + addDiagnostic(output, failurePath, "E_PACKAGE_RACE", "Package entry changed while reading"); + return false; + } + } catch { + addDiagnostic(output, failurePath, "E_PACKAGE_RACE", "Package entry changed while reading"); + return false; + } + } + return true; +} + +async function readValidatedFile(snapshot, output, options) { + let handle; + const pathLabel = snapshot.path; + const full = snapshot.full; + let opened; + try { + handle = await open(full, OPEN_FLAGS); + opened = await handle.stat({ bigint: true }); + + if (options.expected && !sameEntry(opened, options.expected)) { + addDiagnostic(output, pathLabel, "E_PACKAGE_RACE", "Package entry changed while reading"); + return null; + } + if (!opened.isFile()) { + addDiagnostic(output, pathLabel, "E_PACKAGE_FILE", "Package entries must be regular files"); + return null; + } + if (toFileSize(opened.size) > toFileSize(options.maxSize)) { + addDiagnostic(output, pathLabel, "E_FILE_SIZE", `${pathLabel} exceeds the package byte limit`); + return null; + } + + if (options.afterLeafOpenForTest) { + await options.afterLeafOpenForTest({ path: pathLabel, full, opened }); + } + + if (!(await validateAncestors(snapshot.ancestors, output, pathLabel))) return null; + + if (options.expected && options.expected.size !== undefined && opened.size !== options.expected.size) { + addDiagnostic(output, pathLabel, "E_PACKAGE_RACE", "Package entry changed while reading"); + return null; + } + + const expectedSize = toFileSize(opened.size); + const bytes = Buffer.allocUnsafe(expectedSize); + let offset = 0; + while (offset < bytes.length) { + const result = await handle.read(bytes, offset, bytes.length - offset, offset); + if (!result.bytesRead) break; + offset += result.bytesRead; + } + if (offset !== bytes.length) { + addDiagnostic(output, pathLabel, "E_PACKAGE_READ", "Package entry could not be read"); + return null; + } + + const after = await handle.stat({ bigint: true }); + if (!sameEntry(opened, after)) { + addDiagnostic(output, pathLabel, "E_PACKAGE_RACE", "Package entry changed while reading"); + return null; + } + if (!(await validateAncestors(snapshot.ancestors, output, pathLabel))) return null; + + return bytes; + } catch { + addDiagnostic(output, pathLabel, "E_PACKAGE_READ", "Package entry could not be read"); + return null; + } finally { + await handle?.close?.().catch(() => {}); + } +} + +async function snapshotLeaf(full, pathLabel, output, ancestors, limit, options = {}) { + let stat; + try { + stat = await lstat(full, { bigint: true }); + } catch { + addDiagnostic(output, pathLabel, "E_PACKAGE_READ", "Package entry could not be inspected"); + return null; + } + + if (stat.isSymbolicLink()) { + addDiagnostic(output, pathLabel, "E_PACKAGE_SYMLINK", "Package symlinks are not allowed"); + return null; + } + if (!stat.isFile()) { + addDiagnostic(output, pathLabel, "E_PACKAGE_FILE", "Package entries must be regular files"); + return null; + } + + if (stat.size > limit) { + addDiagnostic(output, pathLabel, "E_FILE_SIZE", `${pathLabel} exceeds the package byte limit`); + return null; + } + + const bytes = await readValidatedFile( + { + path: pathLabel, + full, + ancestors, + stat, + limit, + }, + output, + { + expected: stat, + maxSize: limit, + ...options, + }, + ); + if (!bytes) return null; + + return { + path: pathLabel, + full, + stat, + limit, + content: bytes, + ancestors, + }; +} + +function validateLeafSnapshot(snapshot, output, remainingBytes) { + if (snapshot.stat.size > snapshot.limit || snapshot.stat.size > remainingBytes) { + addDiagnostic(output, snapshot.path, "E_FILE_SIZE", `${snapshot.path} exceeds the package byte limit`); + return false; + } + return true; +} + +async function readLeaf(snapshot, output, options) { + if (options?.beforeLeafRead) { + await options.beforeLeafRead({ path: snapshot.path, full: snapshot.full }); + } + const bytes = await readValidatedFile(snapshot, output, { + expected: snapshot.stat, + maxSize: snapshot.limit, + afterLeafOpenForTest: options?.afterLeafOpenForTest, + }); + if (!bytes) return null; + if (!bytes.equals(snapshot.content)) { + addDiagnostic(output, snapshot.path, "E_PACKAGE_RACE", "Package entry changed while reading"); + return null; + } + return bytes; +} + +async function revalidateLeafBoundary(snapshot, output) { + const bytes = await readValidatedFile(snapshot, output, { + expected: snapshot.stat, + maxSize: snapshot.limit, + }); + if (!bytes) return; + if (!bytes.equals(snapshot.content)) { + addDiagnostic(output, snapshot.path, "E_PACKAGE_RACE", "Package entry changed while reading"); + } +} + +export async function readPackageDirectory(directory, { beforeLeafRead, afterLeafOpenForTest } = {}) { + const diagnostics = []; + const files = {}; + const root = await snapshotDirectory("", directory, diagnostics); + if (!root) return { files, diagnostics }; + + const leaves = []; + let exampleBefore = null; + let examplePath = null; + + for (const name of root.entries) { + const full = join(directory, name); + if (!KNOWN_ROOT.has(name)) { + addDiagnostic(diagnostics, name, "E_PACKAGE_PATH", "Unknown package file"); + continue; + } + + if (name === "example") { + const example = await snapshotDirectory("example", full, diagnostics); + if (!example) continue; + exampleBefore = example; + examplePath = full; + + for (const child of example.entries) { + const path = `example/${child}`; + if (!KNOWN_EXAMPLE.has(child)) { + addDiagnostic(diagnostics, path, "E_PACKAGE_PATH", "Package entry was discovered after directory enumeration"); + continue; + } + const snapshot = await snapshotLeaf(join(full, child), path, diagnostics, [{ path: directory, stat: root.stat }, { path: full, stat: example.stat }], FILE_LIMITS[path]); + if (!snapshot) continue; + leaves.push(snapshot); + } + continue; + } + + const snapshot = await snapshotLeaf(full, name, diagnostics, [{ path: directory, stat: root.stat }], FILE_LIMITS[name]); + if (!snapshot) continue; + leaves.push(snapshot); + } + + const ordered = [...leaves].sort((left, right) => compareUtf8(left.path, right.path)); + let consumed = 0; + for (const snapshot of ordered) { + if (!validateLeafSnapshot(snapshot, diagnostics, MAX_PACKAGE_BYTES - consumed)) continue; + const bytes = await readLeaf(snapshot, diagnostics, { beforeLeafRead, afterLeafOpenForTest }); + if (bytes) { + consumed += bytes.length; + files[snapshot.path] = bytes; + } + } + + const rootAfter = await snapshotDirectory("", directory, diagnostics); + if (rootAfter) { + compareDirectory(root, rootAfter, diagnostics, (name) => KNOWN_ROOT.has(name)); + } + + if (examplePath) { + const exampleAfter = await snapshotDirectory("example", examplePath, diagnostics); + if (exampleAfter) compareDirectory(exampleBefore, exampleAfter, diagnostics, (name) => KNOWN_EXAMPLE.has(name)); + } + + for (const snapshot of ordered) { + await revalidateLeafBoundary(snapshot, diagnostics); + } + + return { files, diagnostics }; +} diff --git a/src/loops/dsl/package-read.test.mjs b/src/loops/dsl/package-read.test.mjs new file mode 100644 index 00000000..c8ca338c --- /dev/null +++ b/src/loops/dsl/package-read.test.mjs @@ -0,0 +1,311 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { lstat, mkdtemp, mkdir, readFile, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { compileLoopPackage } from "./compile.mjs"; + +const execFileAsync = promisify(execFile); + +const reviewSource = new URL("../../../loops/review/", import.meta.url); + +async function reviewFiles() { + return { + "review.loop": await readFile(new URL("review.loop", reviewSource)), + "instructions.md": await readFile(new URL("instructions.md", reviewSource)), + }; +} + +async function supportsFifo(path) { + try { + await execFileAsync("mkfifo", [path]); + return true; + } catch { + return false; + } +} + +test("descriptor package reads reject symlinks and oversized entries", async () => { + const files = await reviewFiles(); + const folder = await mkdtemp(join(tmpdir(), "burnlist-loop-package-read-")); + try { + await writeFile(join(folder, "review.loop"), files["review.loop"]); + await writeFile(join(folder, "instructions.md"), files["instructions.md"]); + assert.equal((await compileLoopPackage(folder)).ok, true); + + await rm(join(folder, "example"), { force: true }).catch(() => {}); + await symlink(join(folder, "review.loop"), join(folder, "example")); + let result = await compileLoopPackage(folder); + assert.equal(result.ok, false); + assert.ok(result.diagnostics.some((item) => item.code === "E_PACKAGE_SYMLINK")); + + await rm(join(folder, "example")); + await mkdir(join(folder, "example")); + await writeFile(join(folder, "example", "item.md"), Buffer.alloc(65537, 65)); + result = await compileLoopPackage(folder); + assert.equal(result.ok, false); + assert.ok(result.diagnostics.some((item) => item.code === "E_FILE_SIZE")); + } finally { + await rm(folder, { recursive: true, force: true }); + } +}); + +test("descriptor package read hooks run beforeLeafRead before afterLeafOpenForTest", async () => { + const files = await reviewFiles(); + const folder = await mkdtemp(join(tmpdir(), "burnlist-loop-read-hooks-")); + try { + await writeFile(join(folder, "review.loop"), files["review.loop"]); + await writeFile(join(folder, "instructions.md"), files["instructions.md"]); + await mkdir(join(folder, "example")); + await writeFile(join(folder, "example", "item.md"), files["review.loop"]); + + const hooks = []; + const result = await compileLoopPackage(folder, { + beforeLeafRead: ({ path }) => hooks.push(`before:${path}`), + afterLeafOpenForTest: ({ path }) => hooks.push(`after:${path}`), + }); + + assert.equal(result.ok, true); + assert.deepEqual(hooks, [ + "before:example/item.md", + "after:example/item.md", + "before:instructions.md", + "after:instructions.md", + "before:review.loop", + "after:review.loop", + ]); + } finally { + await rm(folder, { recursive: true, force: true }); + } +}); + +test("descriptor package reads reject same-size rewrites", async () => { + const files = await reviewFiles(); + const outer = await mkdtemp(join(tmpdir(), "burnlist-loop-race-")); + const folder = join(outer, "package"); + const raceState = { + hookReached: false, + changed: false, + restored: false, + preservedInode: false, + preservedSize: false, + }; + + try { + await mkdir(folder); + await writeFile(join(folder, "review.loop"), files["review.loop"]); + await writeFile(join(folder, "instructions.md"), files["instructions.md"]); + await mkdir(join(folder, "example")); + await writeFile(join(folder, "example", "item.md"), files["review.loop"]); + + const result = await compileLoopPackage(folder, { + afterLeafOpenForTest: async ({ path }) => { + if (path !== "review.loop") return; + + raceState.hookReached = true; + const target = join(folder, "example", "item.md"); + const beforeBytes = await readFile(target); + const before = await lstat(target); + + const changed = Buffer.from(beforeBytes); + changed[0] = changed[0] === 114 ? 83 : 114; + await writeFile(target, changed); + + const changedBytes = await readFile(target); + const during = await lstat(target); + + await writeFile(target, beforeBytes); + const restoredBytes = await readFile(target); + const restored = await lstat(target); + + raceState.changed = Buffer.compare(beforeBytes, changedBytes) !== 0; + raceState.restored = Buffer.compare(beforeBytes, restoredBytes) === 0; + raceState.preservedInode = before.ino === restored.ino; + raceState.preservedSize = before.size === restored.size; + }, + }); + + assert.equal(result.ok, false); + assert.ok(result.diagnostics.some((item) => item.code === "E_PACKAGE_RACE" && item.path === "example/item.md")); + assert.equal(raceState.hookReached, true); + assert.equal(raceState.changed, true); + assert.equal(raceState.restored, true); + assert.equal(raceState.preservedInode, true); + assert.equal(raceState.preservedSize, true); + } finally { + await rm(outer, { recursive: true, force: true }); + } +}); + +test("descriptor package reads reject root membership races", async () => { + const files = await reviewFiles(); + const outer = await mkdtemp(join(tmpdir(), "burnlist-loop-race-")); + const folder = join(outer, "package"); + const raceState = { + hookReached: false, + added: false, + restored: false, + transientInode: null, + transientSize: null, + transientWasMissing: false, + }; + + try { + await mkdir(folder); + await writeFile(join(folder, "review.loop"), files["review.loop"]); + await writeFile(join(folder, "instructions.md"), files["instructions.md"]); + await mkdir(join(folder, "example")); + await writeFile(join(folder, "example", "item.md"), files["review.loop"]); + + const result = await compileLoopPackage(folder, { + afterLeafOpenForTest: async ({ path }) => { + if (path !== "instructions.md") return; + + raceState.hookReached = true; + const transient = join(folder, "transient-entry.md"); + const before = await lstat(transient).catch(() => null); + raceState.transientWasMissing = before === null; + + await writeFile(transient, "unexpected"); + const created = await lstat(transient); + raceState.added = true; + raceState.transientInode = created.ino; + raceState.transientSize = created.size; + + await rm(transient); + const after = await lstat(transient).catch(() => null); + raceState.restored = after === null; + }, + }); + + assert.equal(result.ok, false); + assert.ok(result.diagnostics.some((item) => item.code === "E_PACKAGE_RACE" && item.path === "")); + assert.equal(raceState.hookReached, true); + assert.equal(raceState.transientWasMissing, true); + assert.equal(raceState.added, true); + assert.equal(raceState.restored, true); + assert.equal(typeof raceState.transientInode, "number"); + assert.equal(raceState.transientSize, 10); + } finally { + await rm(outer, { recursive: true, force: true }); + } +}); + +test("descriptor package reads reject swapped example paths without following FIFO", async (t) => { + const files = await reviewFiles(); + const outer = await mkdtemp(join(tmpdir(), "burnlist-loop-example-fifo-")); + const folder = join(outer, "package"); + try { + await mkdir(folder); + await writeFile(join(folder, "review.loop"), files["review.loop"]); + await writeFile(join(folder, "instructions.md"), files["instructions.md"]); + await mkdir(join(folder, "example")); + await writeFile(join(folder, "example", "item.md"), files["review.loop"]); + + const fifoTarget = join(outer, "outside-fifo"); + await mkdir(fifoTarget); + if (!(await supportsFifo(join(fifoTarget, "item.md")))) { + t.skip("mkfifo unavailable in test environment"); + return; + } + + const regularTarget = join(outer, "outside-regular"); + await mkdir(regularTarget); + await writeFile(join(regularTarget, "item.md"), files["review.loop"]); + + let firstHookCount = 0; + const fifoStart = Date.now(); + let result = await compileLoopPackage(folder, { + beforeLeafRead: async ({ path }) => { + if (path === "example/item.md") { + firstHookCount += 1; + await rm(join(folder, "example"), { recursive: true, force: true }); + await symlink(fifoTarget, join(folder, "example")); + } + }, + }); + const fifoElapsed = Date.now() - fifoStart; + assert.equal(result.ok, false); + assert.ok(result.diagnostics.some((item) => item.code === "E_PACKAGE_RACE")); + assert.equal(firstHookCount, 1); + assert.ok(fifoElapsed < 2000); + + await rm(join(folder, "example"), { recursive: true, force: true }); + await mkdir(join(folder, "example")); + await writeFile(join(folder, "example", "item.md"), files["review.loop"]); + + let secondHookCount = 0; + const start = Date.now(); + result = await compileLoopPackage(folder, { + beforeLeafRead: async ({ path }) => { + if (path === "example/item.md") { + secondHookCount += 1; + await rm(join(folder, "example"), { recursive: true, force: true }); + await symlink(regularTarget, join(folder, "example")); + } + }, + }); + const elapsed = Date.now() - start; + assert.equal(secondHookCount, 1); + assert.ok(elapsed < 2000); + assert.equal(result.ok, false); + assert.ok(result.diagnostics.some((item) => item.code === "E_PACKAGE_RACE")); + } finally { + await rm(outer, { recursive: true, force: true }); + } +}); + +test("descriptor package diagnostics are merged and truncated after discovery, missing-file, and compiler findings", async () => { + const files = await reviewFiles(); + const folder = await mkdtemp(join(tmpdir(), "burnlist-loop-package-diagnostics-")); + try { + const badCount = 90; + const extras = Array.from({ length: badCount }, (_, index) => ` bad-${String(index).padStart(3, "0")}="x"`).join(""); + const malformed = files["review.loop"].toString() + .replace('version="0.1.0"', "version=\"0\"") + .replace('max-rounds="3"', `max-rounds="0"${extras}`) + .replace('', ''); + + await writeFile(join(folder, "review.loop"), malformed); + await writeFile(join(folder, "note.md"), "ignored\n"); + + const budgetOffset = malformed.indexOf("'); + + const expected = [ + { path: "", byteOffset: 0, code: "E_TOO_MANY_DIAGNOSTICS", message: "Too many diagnostics (maximum 100)" }, + { path: "instructions.md", byteOffset: 0, code: "E_PACKAGE_MISSING", message: "Required package file is missing" }, + { path: "note.md", byteOffset: 0, code: "E_PACKAGE_PATH", message: "Unknown package file" }, + { path: "review.loop", byteOffset: 0, code: "E_SCALAR", message: "version must be a Stage 1 SemVer" }, + ...Array.from({ length: badCount }, (_, index) => ({ + path: "review.loop", + byteOffset: budgetOffset, + code: "E_ATTRIBUTE_UNKNOWN", + message: `Attribute bad-${String(index).padStart(3, "0")} is not allowed on `, + })), + { path: "review.loop", byteOffset: budgetOffset, code: "E_SCALAR", message: "max-rounds must be an integer from 1 through 100" }, + { path: "review.loop", byteOffset: implementOffset, code: "E_EDGE_MISSING", message: "Missing edge for implement/complete" }, + { path: "review.loop", byteOffset: verifyOffset, code: "E_REACHABILITY", message: "Node verify is not reachable from entry" }, + { path: "review.loop", byteOffset: reviewOffset, code: "E_REACHABILITY", message: "Node review is not reachable from entry" }, + { path: "review.loop", byteOffset: convergedOffset, code: "E_REACHABILITY", message: "Node converged is not reachable from entry" }, + { path: "review.loop", byteOffset: convergenceDominanceOffset, code: "E_CONVERGENCE_DOMINATION", message: "Only convergence gate pass may target the converged terminal" }, + ]; + + const result = await compileLoopPackage(folder); + assert.equal(result.ok, false); + assert.equal(result.diagnostics.length, 100); + assert.deepEqual(result.diagnostics, expected); + assert.ok(result.diagnostics.some((item) => item.path === "instructions.md" && item.code === "E_PACKAGE_MISSING")); + assert.ok(result.diagnostics.some((item) => item.path === "note.md" && item.code === "E_PACKAGE_PATH")); + assert.ok(result.diagnostics.some((item) => item.code === "E_ATTRIBUTE_UNKNOWN")); + assert.ok(result.diagnostics.some((item) => item.code === "E_CONVERGENCE_DOMINATION")); + } finally { + await rm(folder, { recursive: true, force: true }); + } +}); diff --git a/src/loops/events/projection-events.mjs b/src/loops/events/projection-events.mjs new file mode 100644 index 00000000..39165e46 --- /dev/null +++ b/src/loops/events/projection-events.mjs @@ -0,0 +1,27 @@ +import { publishCanonicalMutation } from "../../events/oven-canonical-mutations.mjs"; + +export const LOOP_PROJECTION_CHANGED_KIND = "loop-projection-changed"; +export const LOOP_PROJECTION_CHANGED_PHASE = "complete"; + +/** Observational only: the journal commit has already completed when this runs. */ +export function loopProjectionChangedInput({ projection, revision, occurredAt = new Date().toISOString() }) { + if (typeof projection?.itemRef !== "string" || typeof revision !== "string") { + throw new Error("Loop projection event requires a committed Run projection."); + } + return { + ovenId: "checklist", + subjectId: projection.itemRef, + kind: LOOP_PROJECTION_CHANGED_KIND, + phase: LOOP_PROJECTION_CHANGED_PHASE, + cursor: revision, + occurredAt, + payload: { revision }, + }; +} + +export function publishLoopProjectionInvalidation(repoRoot, replay, options) { + return publishCanonicalMutation(repoRoot, loopProjectionChangedInput({ + projection: replay?.projection, + revision: replay?.revision, + }), options); +} diff --git a/src/loops/events/projection-events.test.mjs b/src/loops/events/projection-events.test.mjs new file mode 100644 index 00000000..2d3b2eb9 --- /dev/null +++ b/src/loops/events/projection-events.test.mjs @@ -0,0 +1,37 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { mkdtemp, mkdir, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { loopProjectionChangedInput } from "./projection-events.mjs"; +import { normalizeOvenEvent } from "../../events/oven-event-contract.mjs"; +import { readLatestRunForItem } from "../run/read-projection.mjs"; + +test("Loop invalidations identify only the committed projection revision", () => { + const projection = { itemRef: "item:260722-001#M7", runId: "run:01arz3ndektsv4rrffq69g5fav" }; + const first = loopProjectionChangedInput({ projection, revision: `sha256:${"a".repeat(64)}`, occurredAt: "2026-07-24T10:00:00Z" }); + const second = loopProjectionChangedInput({ projection, revision: `sha256:${"a".repeat(64)}`, occurredAt: "2026-07-24T10:01:00Z" }); + assert.deepEqual(first.payload, { revision: `sha256:${"a".repeat(64)}` }); + assert.equal(first.subjectId, projection.itemRef); + assert.equal(first.cursor, first.payload.revision); + assert.equal(normalizeOvenEvent(first).eventId, normalizeOvenEvent(second).eventId, "retry is deduplicated by revision"); + assert.doesNotMatch(JSON.stringify(first), /currentNode|graph|transition|budget/u); +}); + +test("projection reads ignore only recognized concurrent creation staging directories", async () => { + const root = await mkdtemp(join(tmpdir(), "loop-projection-staging-")); + try { + await mkdir(join(root, ".local", "burnlist", "loop", "m2", "runs", ".create-0123456789abcdef.tmp"), { recursive: true }); + assert.equal(readLatestRunForItem({ repoRoot: root, itemRef: "item:260722-001#M7" }), null); + } finally { await rm(root, { recursive: true, force: true }); } +}); + +test("projection reads reject excessive recognized creation staging directories", async () => { + const root = await mkdtemp(join(tmpdir(), "loop-projection-staging-bound-")); + try { + const runs = join(root, ".local", "burnlist", "loop", "m2", "runs"); + await Promise.all(Array.from({ length: 129 }, (_, index) => + mkdir(join(runs, `.create-${index.toString(16).padStart(16, "0")}.tmp`), { recursive: true }))); + assert.throws(() => readLatestRunForItem({ repoRoot: root, itemRef: "item:260722-001#M7" }), /exceeds bounds/u); + } finally { await rm(root, { recursive: true, force: true }); } +}); diff --git a/src/loops/minimal-review-e2e-fixtures.mjs b/src/loops/minimal-review-e2e-fixtures.mjs new file mode 100644 index 00000000..39330539 --- /dev/null +++ b/src/loops/minimal-review-e2e-fixtures.mjs @@ -0,0 +1,94 @@ +import assert from "node:assert/strict"; +import { spawn, spawnSync } from "node:child_process"; +import { createServer } from "node:http"; +import { existsSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), "..", ".."); +const cli = join(root, "bin", "burnlist.mjs"); +const server = join(root, "src", "server", "burnlist-dashboard-server.mjs"); + +export function cliJson(repo, args, env = {}) { + const result = spawnSync(process.execPath, [cli, ...args, "--repo", repo], { + cwd: repo, encoding: "utf8", env: { ...process.env, ...env }, + }); + assert.equal(result.status, 0, `${args.join(" ")}: ${result.stderr || result.stdout}`); + assert.equal(result.stderr, "", args.join(" ")); + return JSON.parse(result.stdout); +} + +export function cliOk(repo, args, env = {}) { + const result = spawnSync(process.execPath, [cli, ...args, "--repo", repo], { + cwd: repo, encoding: "utf8", env: { ...process.env, ...env }, + }); + assert.equal(result.status, 0, `${args.join(" ")}: ${result.stderr || result.stdout}`); + assert.equal(result.stderr, "", args.join(" ")); + return result.stdout; +} + +export function startCli(repo, args, env = {}) { + return spawn(process.execPath, [cli, ...args, "--repo", repo], { + cwd: repo, env: { ...process.env, ...env }, stdio: ["ignore", "pipe", "pipe"], + }); +} + +export async function waitForExit(child) { + const chunks = [[], []]; + child.stdout.on("data", (value) => chunks[0].push(value)); + child.stderr.on("data", (value) => chunks[1].push(value)); + const [code, signal] = await new Promise((done) => child.once("exit", (...value) => done(value))); + return { code, signal, stdout: Buffer.concat(chunks[0]).toString("utf8"), stderr: Buffer.concat(chunks[1]).toString("utf8") }; +} + +export async function waitForFile(path, child, timeout = 5_000) { + const until = Date.now() + timeout; + while (!existsSync(path)) { + if (child.exitCode !== null) throw new Error(`foreground CLI exited before ${path}`); + if (Date.now() >= until) throw new Error(`timed out waiting for ${path}`); + await new Promise((done) => setTimeout(done, 10)); + } +} + +function availablePort() { + return new Promise((resolvePort, reject) => { + const probe = createServer(); + probe.once("error", reject); + probe.listen(0, "127.0.0.1", () => { + const address = probe.address(); + probe.close((error) => error ? reject(error) : resolvePort(address.port)); + }); + }); +} + +async function waitForServer(child) { + let output = ""; + return new Promise((resolveReady, reject) => { + const timeout = setTimeout(() => reject(new Error(`dashboard did not start: ${output}`)), 8_000); + const ready = (chunk) => { + output += chunk.toString(); + const match = output.match(/http:\/\/127\.0\.0\.1:\d+\//u); + if (!match) return; + clearTimeout(timeout); resolveReady(match[0]); + }; + child.stdout.on("data", ready); child.stderr.on("data", ready); + child.once("exit", (code) => { clearTimeout(timeout); reject(new Error(`dashboard exited ${code}: ${output}`)); }); + }); +} + +export async function withDashboard(repo, action) { + const port = await availablePort(); + const child = spawn(process.execPath, [server, "--port", String(port), "--auto-port", "--scan-root", repo, + "--state-dir", join(repo, ".local", "m9-dashboard")], { cwd: repo, stdio: ["ignore", "pipe", "pipe"] }); + try { return await action(await waitForServer(child)); } + finally { + if (child.exitCode === null) child.kill("SIGTERM"); + if (child.exitCode === null) await new Promise((done) => child.once("exit", done)); + } +} + +export function request(baseUrl, path, { headers = {} } = {}) { + return fetch(new URL(path, baseUrl), { headers }).then(async (response) => ({ + status: response.status, headers: Object.fromEntries(response.headers), body: await response.text(), + })); +} diff --git a/src/loops/minimal-review-e2e.test.mjs b/src/loops/minimal-review-e2e.test.mjs new file mode 100644 index 00000000..f539a549 --- /dev/null +++ b/src/loops/minimal-review-e2e.test.mjs @@ -0,0 +1,210 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { build } from "esbuild"; +import { createProductionRunAuthority, fixtureItemRef } from "./run/run-test-fixtures.mjs"; +import { readLatestRunForItem } from "./run/read-projection.mjs"; +import { runStore } from "./run/run-store.mjs"; +import { readOvenEvents } from "../events/oven-event-store.mjs"; +import { cliJson, cliOk, request, startCli, waitForExit, waitForFile, withDashboard } from "./minimal-review-e2e-fixtures.mjs"; +import { checklistFixture } from "../../dashboard/src/components/ChecklistDashboard/ChecklistDashboard.fixture.mjs"; +import { withDeterministicTime } from "../../dashboard/src/oven/test-support/deterministic-time.mjs"; + +const componentPath = new URL("../../dashboard/src/components/ChecklistDashboard/ChecklistDashboard.tsx", import.meta.url).pathname; +const normalizerPath = new URL("../../dashboard/src/oven/test-support/dom-normalize.ts", import.meta.url).pathname; +const libPath = new URL("../../dashboard/src/lib", import.meta.url).pathname; +const ovenPath = new URL("../../dashboard/src/oven", import.meta.url).pathname; +const domGoldenPath = new URL("./__fixtures__/minimal-review-e2e-dom.golden.json", import.meta.url); +const digest = (value) => createHash("sha256").update(value).digest("hex"); + +function addUnassignedItem(path, item) { + writeFileSync(path, readFileSync(path, "utf8").replace("\n## Completed", `\n- [ ] ${item}\n\n## Completed`)); +} +function edges(projection) { + return projection.transitions.filter((item) => !["prepared", "running", "paused"].includes(item.from)).map(({ from, outcome, to }) => ({ from, outcome, to })); +} +function liveProjection(baseUrl, planPath, headers = {}) { + return request(baseUrl, `/api/loop-projection?plan=${encodeURIComponent(planPath)}`, { headers }); +} +async function dashboardRenderer(t) { + const output = await mkdtemp(join(process.cwd(), ".m9-checklist-render-")); + t.after(() => rm(output, { recursive: true, force: true })); + const componentOutput = join(output, "ChecklistDashboard.mjs"), normalizerOutput = join(output, "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}?m9=${Date.now()}`), + import(`${new URL(`file://${normalizerOutput}`).href}?m9=${Date.now()}`), + ]); + return (checkpoint, loopRun) => { + const candidateAliases = new Map(), alias = (id) => { + if (!id) return id; + if (!candidateAliases.has(id)) candidateAliases.set(id, `candidate-${candidateAliases.size + 1}`); + return candidateAliases.get(id); + }; + const base = Date.parse("2026-07-15T11:00:00Z"); + const stableRun = loopRun && { ...loopRun, createdAt: base, updatedAt: base + 4_000, + latestMaker: loopRun.latestMaker && { ...loopRun.latestMaker, at: base + 1_000, candidateId: alias(loopRun.latestMaker.candidateId) }, + latestCheck: loopRun.latestCheck && { ...loopRun.latestCheck, at: base + 2_000, candidateId: alias(loopRun.latestCheck.candidateId) }, + latestReviewer: loopRun.latestReviewer && { ...loopRun.latestReviewer, at: base + 3_000, candidateId: alias(loopRun.latestReviewer.candidateId) } }; + const dom = serializeCanonical(normalize(parseHtml(withDeterministicTime(() => + renderToStaticMarkup(createElement(ChecklistDashboard, { data: { ...checklistFixture, loopRun: stableRun } })) )))); + return { record: { checkpoint, domBytes: Buffer.byteLength(dom), domSha256: digest(dom) }, dom }; + }; +} + +test("M9 no-network CLI slice exposes interruption, repair, invalidation refetch, UI states, escalation, and completion", { timeout: 60_000 }, async (t) => { + const directory = mkdtempSync(join(tmpdir(), "burnlist-m9-e2e-")); + t.after(() => rmSync(directory, { recursive: true, force: true })); + const { repo } = createProductionRunAuthority(join(directory, "repo")); + const planPath = join(repo, "notes", "burnlists", "inprogress", "260722-001", "burnlist.md"); + addUnassignedItem(planPath, "DIRECT-01 | Unassigned direct-flow control"); + const render = await dashboardRenderer(t); + + await withDashboard(repo, async (baseUrl) => { + const absent = await liveProjection(baseUrl, planPath); + assert.equal(absent.status, 200); assert.equal(JSON.parse(absent.body).loopRun, null); + const view = cliOk(repo, ["loop", "view", fixtureItemRef]); + assert.match(view, /^MODE: ITEM-PINNED$/mu); assert.match(view, /implement.*verify.*review/su); + + const escalation = cliJson(repo, ["loop", "create", fixtureItemRef]).runId, escalationCounter = join(directory, "escalation-counter"); + writeFileSync(escalationCounter, "0"); + const escalated = cliJson(repo, ["loop", "run", escalation], { + BURNLIST_FAKE_COUNTER: escalationCounter, BURNLIST_FAKE_OUTCOMES: "complete,escalate", + }); + assert.equal(escalated.state, "needs-human"); + const escalationInspection = cliJson(repo, ["loop", "inspect", escalation]); + assert.deepEqual(edges(escalationInspection), [ + { from: "implement", outcome: "complete", to: "verify" }, { from: "verify", outcome: "pass", to: "review" }, + { from: "review", outcome: "escalate", to: "needs-human" }, + ]); + const escalationHttp = await liveProjection(baseUrl, planPath); + assert.equal(escalationHttp.status, 200); const escalationProjection = JSON.parse(escalationHttp.body).loopRun; + assert.deepEqual(escalationProjection, escalationInspection); + const needsHumanUi = render("needs-human", escalationProjection); + assert.match(needsHumanUi.dom, /aria-label="Loop state: Needs human review"/u); + assert.equal(existsSync(join(repo, ".local", "burnlist", "loop", "m2", "runs", Buffer.from(escalation).toString("hex"), "completion-receipt.json")), false); + assert.match(readFileSync(planPath, "utf8"), /- \[ \] L29/u); + + const runId = cliJson(repo, ["loop", "create", fixtureItemRef]).runId; + const counter = join(directory, "counter"), started = join(directory, "started.json"); + writeFileSync(counter, "0"); + const first = startCli(repo, ["loop", "run", runId], { + BURNLIST_FAKE_COUNTER: counter, BURNLIST_FAKE_OUTCOMES: "complete,reject,complete,approve", + BURNLIST_FAKE_STARTED: started, BURNLIST_FAKE_WAIT_MS: "1000", + }); + await waitForFile(started, first); + const active = JSON.parse(readFileSync(started, "utf8")); + assert.equal(existsSync(`${started}.${active.pid}.tmp`), false, "ready marker is atomically published"); + assert.equal(active.node, "implement"); assert.equal(first.kill("SIGINT"), true); + const interrupted = await waitForExit(first); + assert.equal(interrupted.code, 0, interrupted.stderr); + const paused = JSON.parse(interrupted.stdout); + assert.equal(paused.state, "paused"); assert.equal(paused.currentNode, "implement"); assert.equal(paused.attempt, 1); + assert.throws(() => process.kill(active.pid, 0), { code: "ESRCH" }); + const pausedInspection = cliJson(repo, ["loop", "inspect", runId]); + const pausedStatus = cliJson(repo, ["loop", "status", runId]); + for (const projection of [pausedInspection, pausedStatus]) { + assert.equal(projection.loopId, "review"); + assert.match(projection.loopRevision, /^er1-sha256:[a-f0-9]{64}$/u); + assert.equal(Number.isSafeInteger(projection.createdAt), true); + assert.equal(Number.isSafeInteger(projection.updatedAt), true); + assert.ok(projection.updatedAt >= projection.createdAt); + } + assert.equal(pausedStatus.state, "paused"); assert.equal(pausedStatus.currentNode, "implement"); + assert.equal(pausedInspection.latestResult, null); + assert.equal(pausedInspection.transitions.length, 2, "only prepared→running and running→paused are durable"); + const pausedRaw = runStore(repo).read(runId); + const pausedPrefix = pausedRaw.journal.map((record) => record.bytes.toString("utf8")); + const firstImplement = pausedRaw.journal.filter((record) => record.value.type === "invocation-started" && record.value.payload.nodeId === "implement" && record.value.payload.attempt === 1); + assert.equal(firstImplement.length, 1); assert.equal(pausedRaw.journal.filter((record) => record.value.type === "invocation-result" && record.value.payload.invocationId === firstImplement[0].value.payload.invocationId).length, 0); + assert.equal(pausedRaw.execution.invocation, null); assert.equal(pausedRaw.projection.leaseHeld, false); + const pausedHttp = await liveProjection(baseUrl, planPath); + assert.equal(pausedHttp.status, 200); const pausedProjection = JSON.parse(pausedHttp.body).loopRun; + assert.deepEqual(pausedProjection, pausedInspection); + const afterPause = await liveProjection(baseUrl, planPath, { "if-none-match": pausedHttp.headers.etag }); + assert.equal(afterPause.status, 304); + + const repairStarted = join(directory, "repair-started.json"); + const repair = startCli(repo, ["loop", "resume", runId], { + BURNLIST_FAKE_COUNTER: counter, BURNLIST_FAKE_OUTCOMES: "complete,reject,complete,approve", + BURNLIST_FAKE_STARTED: repairStarted, BURNLIST_FAKE_WAIT_MS: "1000", + }); + for (;;) { + await waitForFile(repairStarted, repair); + const marker = JSON.parse(readFileSync(repairStarted, "utf8")); + if (marker.node === "implement" && marker.attempt === 2) { repair.kill("SIGINT"); break; } + await new Promise((done) => setTimeout(done, 10)); + } + const repairExit = await waitForExit(repair); + assert.equal(repairExit.code, 0, repairExit.stderr); + const repairProjection = JSON.parse(repairExit.stdout); + assert.equal(repairProjection.state, "paused"); assert.equal(repairProjection.currentNode, "implement"); + assert.equal(repairProjection.attempt, 2); assert.deepEqual(repairProjection.latestResult, { kind: "reject", summary: "fake reject" }); + const repairHttp = await liveProjection(baseUrl, planPath, { "if-none-match": pausedHttp.headers.etag }); + assert.equal(repairHttp.status, 200); assert.deepEqual(JSON.parse(repairHttp.body).loopRun, repairProjection); + + const completed = cliJson(repo, ["loop", "resume", runId], { BURNLIST_FAKE_COUNTER: counter, BURNLIST_FAKE_OUTCOMES: "complete,reject,complete,approve" }); + assert.equal(completed.state, "converged"); assert.equal(completed.currentNode, "completed"); + assert.deepEqual(edges(completed), [ + { from: "implement", outcome: "complete", to: "verify" }, { from: "verify", outcome: "pass", to: "review" }, + { from: "review", outcome: "reject", to: "implement" }, { from: "implement", outcome: "complete", to: "verify" }, + { from: "verify", outcome: "pass", to: "review" }, { from: "review", outcome: "approve", to: "converged" }, + { from: "converged", outcome: "pass", to: "completed" }, + ]); + const completedRaw = runStore(repo).read(runId); + assert.deepEqual(completedRaw.journal.slice(0, pausedPrefix.length).map((record) => record.bytes.toString("utf8")), pausedPrefix); + const implementInvocations = completedRaw.journal.filter((record) => record.value.type === "invocation-started" && record.value.payload.nodeId === "implement"); + const checkInvocations = completedRaw.journal.filter((record) => record.value.type === "invocation-started" && record.value.payload.nodeId === "verify"); + const reviewerInvocations = completedRaw.journal.filter((record) => record.value.type === "invocation-started" && record.value.payload.nodeId === "review"); + assert.equal(implementInvocations.length, 4); assert.equal(reviewerInvocations.length, 2); + assert.deepEqual(implementInvocations.map((record) => record.value.payload.attempt), [1, 1, 2, 2]); + const candidates = completedRaw.journal.filter((record) => record.value.type === "candidate-bound").map((record) => record.value.payload.candidateId); + assert.equal(candidates.length, 2); assert.notEqual(candidates[0], candidates[1], "repair publishes a fresh repository candidate"); + const resultCandidate = (startedRecord) => completedRaw.journal.find((record) => + record.value.type === "invocation-result" && record.value.payload.invocationId === startedRecord.value.payload.invocationId)?.value.payload.candidateId; + assert.deepEqual(checkInvocations.map(resultCandidate), candidates, "each trusted check is bound to its maker candidate"); + assert.deepEqual(reviewerInvocations.map(resultCandidate), candidates, "each reviewer result is bound to its checked candidate"); + const invocationIds = [...implementInvocations, ...reviewerInvocations].map((record) => record.value.payload.invocationId); + assert.equal(new Set(invocationIds).size, invocationIds.length, "every agent invocation id is globally unique"); + assert.equal(readFileSync(counter, "utf8"), "4"); + const beforeRestart = cliJson(repo, ["loop", "inspect", runId]); + assert.deepEqual(cliJson(repo, ["loop", "run", runId]), completed, "terminal restart is an idempotent read"); + assert.deepEqual(cliJson(repo, ["loop", "inspect", runId]), beforeRestart, "terminal restart writes no journal records"); + const convergedHttp = await liveProjection(baseUrl, planPath, { "if-none-match": repairHttp.headers.etag }); + assert.equal(convergedHttp.status, 200); const convergedProjection = JSON.parse(convergedHttp.body).loopRun; + assert.deepEqual(convergedProjection, completed); + const replay = readLatestRunForItem({ repoRoot: repo, itemRef: fixtureItemRef }); + assert.deepEqual(replay, completed, "invalidation consumers refetch the canonical current Run"); + const invalidations = readOvenEvents(repo, { ovenIds: ["checklist"] }).filter((event) => event.kind === "loop-projection-changed" && event.cursor === completed.revision); + assert.equal(invalidations.length, 1); assert.deepEqual(invalidations[0].payload, { revision: completed.revision }); + + const ui = [render("paused", pausedProjection), render("repair", repairProjection), render("converged", convergedProjection)]; + const domGolden = JSON.parse(await readFile(domGoldenPath, "utf8")); + assert.deepEqual(needsHumanUi.record, domGolden[0]); + assert.deepEqual(ui.map((item) => item.record), domGolden.slice(1, 4)); + + const firstCompletion = cliJson(repo, ["loop", "complete", runId]); + const secondCompletion = cliJson(repo, ["loop", "complete", runId]); + assert.equal(firstCompletion.alreadyApplied, false); assert.equal(secondCompletion.alreadyApplied, true); + assert.deepEqual(cliJson(repo, ["loop", "inspect", runId]), beforeRestart, "completion owns no journal mutation"); + const plan = readFileSync(planPath, "utf8"); + assert.equal((plan.match(/^- L29 \| /gmu) ?? []).length, 1); assert.equal(existsSync(join(repo, ".local", "burnlist", "loop", "m2", "runs", Buffer.from(runId).toString("hex"), "completion-intent.json")), false); + assert.equal(existsSync(join(repo, ".local", "burnlist", "loop", "m2", "runs", Buffer.from(runId).toString("hex"), "completion-receipt.json")), true); + assert.equal(readOvenEvents(repo, { ovenIds: ["checklist"] }).filter((event) => event.kind === "item-burned" && event.subjectId === "260722-001" && event.payload.itemId === "L29").length, 1); + const post = await liveProjection(baseUrl, planPath); + assert.equal(post.status, 200); assert.equal(JSON.parse(post.body).loopRun, null); + assert.deepEqual(render("post-completion", null).record, domGolden.at(-1)); + + cliOk(repo, ["burn", "260722-001", "DIRECT-01"]); + assert.match(readFileSync(planPath, "utf8"), /^- DIRECT-01 \| .* \| Unassigned direct-flow control$/mu); + }); +}); diff --git a/src/loops/run/binder.mjs b/src/loops/run/binder.mjs new file mode 100644 index 00000000..f1d6a71b --- /dev/null +++ b/src/loops/run/binder.mjs @@ -0,0 +1,361 @@ +import { lstatSync, readFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { findBurnlistDir } from "../../cli/lifecycle-moves.mjs"; +import { locateItemSpan, validateAssignedItem } from "../assignment/item-metadata.mjs"; +import { parseItemRef } from "../assignment/selectors.mjs"; +import { assignmentStore } from "../assignment/store.mjs"; +import { readCapabilityCatalog, resolveCapability, canonicalCapabilityBytes, canonicalGrantBytes, GUARANTEE_LABELS } from "../capabilities/contract.mjs"; +import { assertTrustedCapability } from "../capabilities/trust.mjs"; +import { checkSnapshot, holdSnapshot, readSnapshotBytes, releaseSnapshot, snapshotTarget } from "../capabilities/snapshot.mjs"; +import { compileLoopFiles } from "../dsl/compile.mjs"; +import { loadFrozenRecipe } from "../dsl/frozen.mjs"; +import { prefixed, rawSha256 } from "../dsl/hash.mjs"; +import { createNormalizedInvocation } from "../adapters/normalized-invocation.mjs"; +import { agentProfileRevision } from "../agents/profile.mjs"; +import { readProfile, readRoute, requiredRoutes } from "../config/profiles.mjs"; +import { localRecordPath } from "../config/store.mjs"; +import { boundPolicyRevision, canonicalBoundPolicyBytes, loadBoundPolicy } from "./run-artifacts.mjs"; +import { createRunRunner } from "./runner.mjs"; +import { deriveCandidate } from "./candidate.mjs"; +import { ownerClaimId } from "./run-claim.mjs"; +import { newRunId } from "./run-codec.mjs"; + +const INPUT_KEYS = new Set(["runId", "itemRef"]); +const builtinsRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../../../loops"); + +function fail(message, code = "ELOOP_RUN_BINDING") { + throw Object.assign(new Error(`Loop Run binder: ${message}`), { code }); +} +function closed(value, keys) { return Boolean(value) && typeof value === "object" && !Array.isArray(value) && Object.keys(value).length === keys.length && keys.every((key) => Object.hasOwn(value, key)); } +function exactInput(value) { + if (!value || typeof value !== "object" || Array.isArray(value) + || Object.keys(value).some((key) => !INPUT_KEYS.has(key)) + || typeof value.itemRef !== "string") fail("creation accepts only runId and itemRef"); + return value; +} +function identity(path, kind = "file") { + const value = lstatSync(path); + const directory = kind.startsWith("directory"); + if (value.isSymbolicLink() || kind === "file" && !value.isFile() || directory && !value.isDirectory()) + fail(`authority input has invalid type: ${path}`); + return Object.freeze(directory + ? { kind, path, dev: String(value.dev), ino: String(value.ino), size: String(value.size), + mode: String(value.mode), mtimeMs: String(value.mtimeMs), ctimeMs: String(value.ctimeMs) } + : { kind, path, dev: String(value.dev), ino: String(value.ino), size: String(value.size), + mode: String(value.mode), mtimeMs: String(value.mtimeMs), ctimeMs: String(value.ctimeMs) }); +} +function boundaryEvidence(paths) { + return Object.freeze([...new Set(paths)].sort().map((path) => identity(path))); +} +function assignmentAncestorEvidence(repoRoot, artifactPath) { + const root = resolve(repoRoot), output = []; + for (let current = dirname(artifactPath); current.startsWith(`${root}/`) || current === root; current = dirname(current)) { + output.push(identity(current, "directory-identity")); + if (current === root) break; + } + return output; +} +function assertBoundaryEvidence(evidence) { + if (!Array.isArray(evidence) || !evidence.length) fail("authority boundary evidence is missing"); + for (const expected of evidence) { + const observed = identity(expected.path, expected.kind); + const keys = expected.kind === "directory-identity" ? ["dev", "ino", "mode"] : ["dev", "ino", "size", "mode", "mtimeMs", "ctimeMs"]; + for (const key of keys) + if (observed[key] !== expected[key]) fail(`authority input changed before Run publication: ${expected.path}`, "ELOOP_RUN_BINDING_STALE"); + } +} +function executableSnapshot(loopRef) { + if (loopRef !== "loop:builtin:review") fail(`executable source identity is unavailable for ${loopRef}`); + const directory = join(builtinsRoot, "review"), files = {}, evidence = []; + for (const [name, maximum] of [["review.loop", 65536], ["instructions.md", 262144]]) { + const path = join(directory, name), captured = readSnapshotBytes({ root: builtinsRoot, path, maximum }); + files[name] = captured.bytes; + evidence.push(Object.freeze({ kind: "file", path, dev: String(captured.identity.dev), ino: String(captured.identity.ino), + size: String(captured.identity.size), mode: String(captured.identity.mode), + mtimeMs: String(captured.identity.mtimeMs), ctimeMs: String(captured.identity.ctimeMs) })); + for (const ancestor of captured.ancestors) evidence.push(Object.freeze({ kind: "directory", path: ancestor.path, + dev: String(ancestor.identity.dev), ino: String(ancestor.identity.ino), size: String(ancestor.identity.size), + mode: String(ancestor.identity.mode), mtimeMs: String(ancestor.identity.mtimeMs), ctimeMs: String(ancestor.identity.ctimeMs) })); + } + const compiled = compileLoopFiles(files); + if (!compiled.ok) fail("captured executable source does not compile"); + assertBoundaryEvidence(evidence); + return { compiled, evidence }; +} +function currentPolicy(repoRoot, recipeRevision) { + const authorityInputs = []; + const add = (role, path, executable = false) => authorityInputs.push(Object.freeze({ role, path, executable })); + const routes = requiredRoutes.map(({ route }) => { + const routeRecord = readRoute({ repoRoot, route }); + const profile = readProfile({ repoRoot, slug: routeRecord.profile }); + add(`route:${route}`, localRecordPath(repoRoot, "routes", route.replace(".", "-"))); + add(`profile:${route}`, localRecordPath(repoRoot, "profiles", profile.id)); + add(`adapter:${route}`, profile.binary, true); + const executableDigest = snapshotTarget({ root: dirname(profile.binary), path: profile.binary }).digest; + return { route, profile, profileRevision: agentProfileRevision(profile), executableDigest, + guarantees: route === "review.strong" + ? { freshSession: "enforced", filesystemWriteDeny: "supervised" } + : { freshSession: "enforced" } }; + }).sort((left, right) => Buffer.compare(Buffer.from(left.route), Buffer.from(right.route))); + const resolved = resolveCapability(readCapabilityCatalog(repoRoot), "repo-verify"); + const trust = assertTrustedCapability({ repoRoot, resolved }); + add("capability-catalog", join(repoRoot, ".burnlist", "loop-capabilities.json")); + add("capability-trust", localRecordPath(repoRoot, "capabilities", resolved.policy.id)); + add("capability-bin", resolved.policy.argv[0], true); + const grants = trust.grants; + const policy = { schema: "burnlist-loop-bound-policy@1", recipeRevision, routes, + capabilities: [{ id: resolved.policy.id, policy: resolved.policy, revision: resolved.revision, + policyDigest: rawSha256(canonicalCapabilityBytes(resolved.policy)), grants, + grantsDigest: rawSha256(canonicalGrantBytes(grants, resolved.policy)), trust, + guarantees: GUARANTEE_LABELS }] }; + const bytes = canonicalBoundPolicyBytes(policy); + return { policy: loadBoundPolicy(bytes).policy, bytes, authorityInputs: Object.freeze(authorityInputs.sort((left, right) => Buffer.compare(Buffer.from(left.role), Buffer.from(right.role)))) }; +} +function liveAuthorityEvidence(inputs) { + return Object.freeze(inputs.map(({ role, path, executable }) => { + const snapshot = snapshotTarget({ root: dirname(path), path }); + if (executable && (snapshot.identity.mode & 0o111) === 0) + fail(`launch executable is not executable: ${path}`, "ELOOP_RUN_BINDING_STALE"); + return Object.freeze({ role, executable, snapshot }); + })); +} +function assertLiveAuthorityEvidence(evidence) { + if (!Array.isArray(evidence) || !evidence.length) fail("live launch authority evidence is missing"); + for (const item of evidence) { + if (!closed(item, ["role", "executable", "snapshot"]) || typeof item.role !== "string" || !item.role || typeof item.executable !== "boolean") fail("invalid live launch authority evidence"); + const snapshot = item.snapshot, file = snapshot?.kind === "file"; + if (!closed(snapshot, file ? ["root", "path", "kind", "ancestors", "identity", "digest", "maximum"] : ["root", "path", "kind", "ancestors", "identity"]) + || !Array.isArray(snapshot.ancestors) || !closed(snapshot.identity, ["dev", "ino", "size", "mode", "mtimeMs", "ctimeMs"]) + || snapshot.ancestors.some((ancestor) => !closed(ancestor, ["path", "identity"]) || !closed(ancestor.identity, ["dev", "ino", "size", "mode", "mtimeMs", "ctimeMs"]))) fail("invalid live launch authority evidence"); + checkSnapshot(item.snapshot); + if (item.executable && (item.snapshot.identity.mode & 0o111) === 0) fail(`launch executable is not executable: ${item.snapshot.path}`, "ELOOP_RUN_BINDING_STALE"); + } +} +export function launchAuthorityDigest(evidence) { + const inputs = evidence.map(({ role, executable, snapshot }) => ({ role, executable, root: snapshot.root, path: snapshot.path, + kind: snapshot.kind, ancestors: snapshot.ancestors.map((ancestor) => ({ path: ancestor.path, identity: ancestor.identity })), + identity: snapshot.identity, digest: snapshot.digest ?? null, maximum: snapshot.maximum ?? null })); + return rawSha256(Buffer.from(`${JSON.stringify({ schema: "burnlist-loop-launch-authority@1", inputs })}\n`)); +} +export function assertExecutableBinding(authority) { + if (!authority?.artifact?.executionRevision || !authority.currentCompiled?.revisions?.executable) + fail("installed executable recipe is unavailable"); + if (authority.currentCompiled.revisions.executable !== authority.artifact.executionRevision) + fail(`installed executable ${authority.currentCompiled.revisions.executable} does not match assignment pin ${authority.artifact.executionRevision}`, "ELOOP_RUN_EXECUTABLE_DRIFT"); + return authority.artifact.executionRevision; +} + +/** Read-only production authority. It never writes assignment, setup, trust, or source files. */ +export async function bindRunCreation({ repoRoot, input }) { + const request = exactInput(input); + const item = parseItemRef(request.itemRef), located = findBurnlistDir(repoRoot, item.burnlistId); + const span = locateItemSpan(readFileSync(join(located.dir, "burnlist.md")), item.itemId); + const metadata = validateAssignedItem(item.selector, span), artifact = assignmentStore(repoRoot).load(metadata["Assignment-Id"]); + if (artifact.assignmentId !== metadata["Assignment-Id"] || artifact.itemRef !== item.selector + || artifact.selector !== metadata.Selector || artifact.assignedItemDigest !== metadata.assignedDigest + || artifact.unassignedItemDigest !== metadata.unassignedDigest + || artifact.executionRevision !== metadata["Execution-Revision"] + || artifact.packageRevision !== metadata["Package-Revision"]) fail("Run creation requires one canonical item assignment"); + const source = executableSnapshot(artifact.selector); + const recipeRevision = assertExecutableBinding({ artifact, currentCompiled: source.compiled }); + const policy = currentPolicy(repoRoot, recipeRevision); + const evidence = boundaryEvidence([join(located.dir, "burnlist.md"), + join(artifact.path, "manifest.json"), join(artifact.path, "recipe.frozen"), ...policy.authorityInputs.map((input) => input.path)]); + const value = Object.freeze({ runId: request.runId, assignmentId: artifact.assignmentId, + itemRef: artifact.itemRef, itemRevision: artifact.assignedItemDigest, + itemText: metadata.unassignedSpan.toString("utf8"), + frozenRecipeBytes: Buffer.from(artifact.frozenRecipeBytes), policyBytes: Buffer.from(policy.bytes), + boundaryEvidence: Object.freeze([...evidence, ...assignmentAncestorEvidence(repoRoot, artifact.path), ...source.evidence]) }); + return value; +} + +function sealRunAuthority(runId, authority) { + if (!authority || authority.runId !== runId || !Buffer.isBuffer(authority.frozenRecipeBytes) || !Buffer.isBuffer(authority.policyBytes)) fail("invalid production runner authority"); + return Object.freeze({ schema: "burnlist-loop-m12-run-authority@1", runId, assignmentId: authority.assignmentId, + itemRef: authority.itemRef, itemRevision: authority.itemRevision, itemText: authority.itemText, + frozenRecipe: authority.frozenRecipeBytes.toString("base64"), policy: authority.policyBytes.toString("base64") }); +} +function unsealRunAuthority(authority) { + if (!authority || authority.schema !== "burnlist-loop-m12-run-authority@1") fail("sealed production authority is unavailable"); + return Object.freeze({ runId: authority.runId, assignmentId: authority.assignmentId, itemRef: authority.itemRef, + itemRevision: authority.itemRevision, itemText: authority.itemText, frozenRecipeBytes: Buffer.from(authority.frozenRecipe, "base64"), + policyBytes: Buffer.from(authority.policy, "base64") }); +} + +/** The one production creation path seals all dispatch inputs before the Run exists. */ +export async function createProductionRun({ repoRoot, store, itemRef, runId = newRunId() }) { + if (!store?.createRun || typeof repoRoot !== "string" || typeof itemRef !== "string") fail("invalid production Run creation"); + let authority = await bindRunCreation({ repoRoot, input: { runId, itemRef } }); + // A reservation is durable before its Run directory is published. A normal + // CLI retry has no RunRef to repeat, so recover only an absent reservation + // for this exact still-bound assignment; every other current Run remains the + // store's normal admission decision. + const reserved = store.readCurrentRun?.(authority.itemRef); + if (reserved && reserved.runId !== runId && reserved.assignmentId === authority.assignmentId) { + try { store.read(reserved.runId); } + catch (error) { + if (error?.code !== "ENOENT") throw error; + runId = reserved.runId; + authority = await bindRunCreation({ repoRoot, input: { runId, itemRef } }); + } + } + revalidatePreparedBinding({ repoRoot, bound: authority }); + const graph = loadFrozenRecipe(authority.frozenRecipeBytes).ir; + store.createRun({ runId, itemRef: authority.itemRef, graph, authority: sealRunAuthority(runId, authority) }); + return store.read(runId); +} + +/** Final synchronous publication boundary over the exact assignment and live policy. */ +export function revalidatePreparedBinding({ repoRoot, bound }) { + assertBoundaryEvidence(bound.boundaryEvidence); + const item = parseItemRef(bound.itemRef); + const located = findBurnlistDir(repoRoot, item.burnlistId); + const span = locateItemSpan(readFileSync(join(located.dir, "burnlist.md")), item.itemId); + const metadata = validateAssignedItem(item.selector, span); + const artifact = assignmentStore(repoRoot).load(metadata["Assignment-Id"]); + if (artifact.assignmentId !== bound.assignmentId || artifact.itemRef !== bound.itemRef + || artifact.assignedItemDigest !== bound.itemRevision + || metadata.unassignedSpan.toString("utf8") !== bound.itemText + || !artifact.frozenRecipeBytes.equals(bound.frozenRecipeBytes) + || metadata["Execution-Revision"] !== artifact.executionRevision) + fail("assignment changed before Run publication", "ELOOP_RUN_BINDING_STALE"); + const policy = currentPolicy(repoRoot, artifact.executionRevision); + if (!policy.bytes.equals(bound.policyBytes)) + fail("configured authority changed before Run publication", "ELOOP_RUN_BINDING_STALE"); + assertBoundaryEvidence(bound.boundaryEvidence); + return true; +} +function revalidateAssignedItem({ repoRoot, replay }) { + const item = parseItemRef(replay.projection.itemRef), located = findBurnlistDir(repoRoot, item.burnlistId); + if (located.lifecycle.folder !== "inprogress") fail("Run item is no longer active", "ELOOP_RUN_BINDING_STALE"); + const span = locateItemSpan(readFileSync(join(located.dir, "burnlist.md")), item.itemId); + const metadata = validateAssignedItem(item.selector, span), artifact = assignmentStore(repoRoot).load(metadata["Assignment-Id"]); + const expectedSelector = `loop:builtin:${replay.frozenRecipe.ir.id}`; + if (metadata.assignedDigest !== replay.projection.itemRevision + || metadata["Assignment-Id"] !== replay.projection.assignmentId + || metadata.Selector !== expectedSelector + || metadata["Execution-Revision"] !== replay.frozenRecipe.revisions.executable + || metadata["Package-Revision"] !== replay.frozenRecipe.revisions.package + || artifact.assignmentId !== replay.projection.assignmentId || artifact.itemRef !== item.selector + || artifact.assignedItemDigest !== replay.projection.itemRevision || artifact.selector !== expectedSelector + || artifact.executionRevision !== replay.frozenRecipe.revisions.executable + || artifact.packageRevision !== replay.frozenRecipe.revisions.package) + fail("Run item assignment changed after creation", "ELOOP_RUN_BINDING_STALE"); +} + +/** Launch boundary: live setup/trust is compared with frozen policy; Loop source is not read. */ +function acceptCurrentPolicy({ repoRoot, replay, current }) { + if (!replay?.frozenRecipe?.revisions?.executable || !replay.boundPolicy || !Buffer.isBuffer(replay.policyBytes)) + fail("verified frozen Run authority is required"); + if (!current.bytes.equals(replay.policyBytes)) fail("configured authority changed after Run creation", "ELOOP_RUN_BINDING_STALE"); + revalidateAssignedItem({ repoRoot, replay }); + return current; +} + +export function revalidateRunBinding({ repoRoot, replay }) { + const current = currentPolicy(repoRoot, replay.frozenRecipe.revisions.executable); + return acceptCurrentPolicy({ repoRoot, replay, current }).policy; +} + +/** Capture live launch inputs inside the canonical launch lock; callers never supply this evidence. */ +export function captureRunLaunchBinding({ repoRoot, replay }) { + const current = currentPolicy(repoRoot, replay.frozenRecipe.revisions.executable); + acceptCurrentPolicy({ repoRoot, replay, current }); + const evidence = liveAuthorityEvidence(current.authorityInputs); + assertLiveAuthorityEvidence(evidence); + return Object.freeze({ evidence, authorityDigest: launchAuthorityDigest(evidence) }); +} + +/** Recheck the exact captured descriptors, ancestors, and digests at the final launch boundary. */ +export function recheckRunLaunchBinding(captured) { + if (!captured || Object.keys(captured).length !== 2 || typeof captured.authorityDigest !== "string") fail("invalid private launch authority evidence"); + assertLiveAuthorityEvidence(captured.evidence); + if (captured.authorityDigest !== launchAuthorityDigest(captured.evidence)) fail("private launch authority digest changed"); +} +export function holdRunLaunchBinding(captured) { + if (!captured || Object.keys(captured).length !== 2 || typeof captured.authorityDigest !== "string") fail("invalid private launch authority evidence"); + const held = []; + try { for (const { snapshot } of captured.evidence) held.push(holdSnapshot(snapshot)); return Object.freeze(held); } + catch (error) { try { releaseRunLaunchBinding(held); } catch {} throw error; } +} +export function releaseRunLaunchBinding(held) { + if (!Array.isArray(held)) fail("invalid held launch authority"); + let failure; + for (const item of held) { + try { releaseSnapshot(item); } + catch (error) { failure ??= error; } + } + if (failure) throw failure; +} + +/** + * Build the production M3 callback from already-frozen Run authority. This is + * deliberately a direct Codex path: Docker controllers are legacy setup + * artifacts and are not consulted for foreground Stage One dispatch. + */ +export function createBoundNormalizedInvocation({ repoRoot, replay, contextFor, startAgent, runCheck, agentTimeoutMs = 0 }) { + if (typeof repoRoot !== "string" || !replay?.projection?.assignmentId || !replay?.frozenRecipe?.ir + || typeof replay.itemText !== "string" || !replay.itemText + || !Buffer.isBuffer(replay.policyBytes) || typeof contextFor !== "function") fail("invalid production invocation input"); + const policy = loadBoundPolicy(replay.policyBytes).policy; + const route = (name) => policy.routes.find((entry) => entry.route === name); + const implementation = route("implementation.standard"), review = route("review.strong"); + if (!implementation || !review) fail("frozen Stage One routes are unavailable"); + const nodes = new Map(replay.frozenRecipe.ir.nodes.map((node) => [node.id, node])); + return createNormalizedInvocation({ repoRoot, nodes, + routes: { implementation: { profile: implementation.profile }, review: { profile: review.profile } }, + bindingFor(invocation, node) { + const context = contextFor(invocation, node), instruction = replay.frozenRecipe.instructions + .find((item) => item.id === node.instructions); + if (!context || node.kind === "agent" && !instruction) fail("frozen invocation context is unavailable"); + return { claimId: context.claimId, assignmentId: replay.projection.assignmentId, + recipeRevision: replay.frozenRecipe.revisions.executable, policyRevision: boundPolicyRevision(policy), + inputCandidate: context.inputCandidate, instructionBytes: instruction + ? Buffer.from(instruction.base64, "base64").toString("utf8") : "Run the frozen trusted capability.\n", + itemText: replay.itemText, candidateContext: context.candidateContext, + reviewerEvidence: context.reviewerEvidence ?? [] }; + }, startAgent, runCheck, agentTimeoutMs }); +} + +/** Compose frozen creation authority, the M3 dispatcher, and the M2 runner. */ +export function createProductionRunRunner({ repoRoot, store, runId, authority, contextFor, + startAgent, runCheck, agentTimeoutMs = 0 }) { + if (authority?.schema === "burnlist-loop-m12-run-authority@1") authority = unsealRunAuthority(authority); + if (!store?.replay || !authority?.assignmentId || !Buffer.isBuffer(authority.frozenRecipeBytes) + || !Buffer.isBuffer(authority.policyBytes)) fail("invalid production runner authority"); + const frozenRecipe = loadFrozenRecipe(authority.frozenRecipeBytes); + const replay = { projection: { assignmentId: authority.assignmentId }, frozenRecipe, + policyBytes: authority.policyBytes, itemText: authority.itemText }; + const liveContext = (invocation, node) => { + const execution = store.replay(runId).execution; + const candidate = execution.candidate ?? deriveCandidate({ repoRoot }); + const checkNode = frozenRecipe.ir.nodes.find((item) => item.kind === "check"); + const check = checkNode && execution.evidence[checkNode.id]; + const reviewerEvidence = node.mode === "review" + ? check?.kind === "pass" && check.candidateId === candidate.id && execution.latest.check?.candidateId === candidate.id + ? [`trusted-check candidate=${candidate.id} summary=${execution.latest.check.summary}`] : [] + : []; + return { claimId: ownerClaimId({ runId: invocation.runId, nodeId: invocation.nodeId, attempt: invocation.attempt, + assignmentId: authority.assignmentId, inputCandidate: candidate.id }), inputCandidate: candidate.id, + candidateContext: candidate.context, reviewerEvidence }; + }; + const dispatch = createBoundNormalizedInvocation({ repoRoot, replay, contextFor: contextFor ?? liveContext, startAgent, runCheck, agentTimeoutMs }); + const invoke = async (invocation) => { + const captured = captureRunLaunchBinding({ repoRoot, replay: { ...replay, projection: { ...replay.projection, itemRef: authority.itemRef, itemRevision: authority.itemRevision }, boundPolicy: loadBoundPolicy(authority.policyBytes).policy } }); + recheckRunLaunchBinding(captured); const held = holdRunLaunchBinding(captured); + try { recheckRunLaunchBinding(captured); return await dispatch(invocation); } + finally { releaseRunLaunchBinding(held); } + }; + return createRunRunner({ store, runId, invoke, bindCandidate() { + const candidate = deriveCandidate({ repoRoot }); return { candidateId: candidate.id, candidateContext: candidate.context }; + } }); +} + +/** Resume constructs exclusively from the immutable per-Run record; it never rebinds source or policy. */ +export function createStoredProductionRunRunner({ repoRoot, store, runId, startAgent, runCheck, agentTimeoutMs = 0 }) { + if (!store?.readAuthority) fail("sealed production authority is unavailable"); + const authority = store.readAuthority(runId), current = store.readCurrentRun?.(authority.itemRef); + if (!current || current.runId !== runId || current.assignmentId !== authority.assignmentId) fail("Run is superseded and cannot launch", "ELOOP_RUN_SUPERSEDED"); + return createProductionRunRunner({ repoRoot, store, runId, authority, startAgent, runCheck, agentTimeoutMs }); +} diff --git a/src/loops/run/binder.test.mjs b/src/loops/run/binder.test.mjs new file mode 100644 index 00000000..55e720c9 --- /dev/null +++ b/src/loops/run/binder.test.mjs @@ -0,0 +1,186 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { bindRunCreation, createProductionRun, createProductionRunRunner, createStoredProductionRunRunner, revalidatePreparedBinding } from "./binder.mjs"; +import { loadBoundPolicy } from "./run-artifacts.mjs"; +import { loadFrozenRecipe } from "../dsl/frozen.mjs"; +import { ownerClaimId } from "./run-claim.mjs"; +import { runStore } from "./run-store.mjs"; +import { createProductionRunAuthority, fixtureItemRef, fixtureRunId } from "./run-test-fixtures.mjs"; + +test("production creation binds direct Stage One profiles without Docker artifacts", async (t) => { + const directory = realpathSync(mkdtempSync(join(tmpdir(), "burnlist-direct-binder-"))); + t.after(() => rmSync(directory, { recursive: true, force: true })); + const { repo } = createProductionRunAuthority(join(directory, "repo")); + const bound = await bindRunCreation({ repoRoot: repo, input: { runId: fixtureRunId, itemRef: fixtureItemRef } }); + const policy = loadBoundPolicy(bound.policyBytes).policy; + assert.equal(bound.itemText, "- [ ] L29 | Exercise production authority\n\n"); + assert.deepEqual(policy.routes.map((route) => Object.keys(route)), [ + ["route", "profile", "profileRevision", "executableDigest", "guarantees"], + ["route", "profile", "profileRevision", "executableDigest", "guarantees"], + ]); + assert.equal(policy.routes.some((route) => JSON.stringify(route).toLowerCase().includes("docker")), false); + assert.deepEqual(policy.routes.find((route) => route.route === "review.strong").guarantees, + { freshSession: "enforced", filesystemWriteDeny: "supervised" }); + assert.equal(revalidatePreparedBinding({ repoRoot: repo, bound }), true); +}); + +test("a publication-cut reservation recovers through either exact or ordinary create retry", async (t) => { + const directory = realpathSync(mkdtempSync(join(tmpdir(), "burnlist-current-cut-"))); t.after(() => rmSync(directory, { recursive: true, force: true })); + const { repo } = createProductionRunAuthority(join(directory, "repo")); + const cut = runStore(repo, { hooks: { beforeRunPublish() { throw new Error("before-run-publish"); } } }); + await assert.rejects(createProductionRun({ repoRoot: repo, store: cut, itemRef: fixtureItemRef, runId: fixtureRunId }), /before-run-publish/u); + assert.equal(cut.list().length, 0); assert.equal(cut.readCurrentRun(fixtureItemRef).runId, fixtureRunId); + const recovered = await createProductionRun({ repoRoot: repo, store: runStore(repo), itemRef: fixtureItemRef, runId: "run:01arz3ndektsv4rrffq69g5faw" }); + assert.equal(recovered.projection.runId, fixtureRunId); assert.equal(runStore(repo).list().length, 1); +}); + +test("production factory drives direct maker-check-reject-repair-approve", async (t) => { + const directory = realpathSync(mkdtempSync(join(tmpdir(), "burnlist-direct-runner-"))); + t.after(() => rmSync(directory, { recursive: true, force: true })); + const { repo } = createProductionRunAuthority(join(directory, "repo")); + const authority = await bindRunCreation({ repoRoot: repo, input: { runId: fixtureRunId, itemRef: fixtureItemRef } }); + const graph = loadFrozenRecipe(authority.frozenRecipeBytes).ir, store = runStore(repo); + store.createRun({ runId: fixtureRunId, itemRef: fixtureItemRef, graph }); + const counter = join(directory, "counter"); writeFileSync(counter, "0"); + const oldCounter = process.env.BURNLIST_FAKE_COUNTER, oldOutcomes = process.env.BURNLIST_FAKE_OUTCOMES; + process.env.BURNLIST_FAKE_COUNTER = counter; process.env.BURNLIST_FAKE_OUTCOMES = "complete,reject,complete,approve"; + t.after(() => { + if (oldCounter === undefined) delete process.env.BURNLIST_FAKE_COUNTER; else process.env.BURNLIST_FAKE_COUNTER = oldCounter; + if (oldOutcomes === undefined) delete process.env.BURNLIST_FAKE_OUTCOMES; else process.env.BURNLIST_FAKE_OUTCOMES = oldOutcomes; + }); + const candidate = `cm1-sha256:${"c".repeat(64)}`; + const runner = createProductionRunRunner({ repoRoot: repo, store, runId: fixtureRunId, authority, + contextFor(invocation) { + return { claimId: ownerClaimId({ runId: invocation.runId, nodeId: invocation.nodeId, + attempt: invocation.attempt, assignmentId: authority.assignmentId, inputCandidate: candidate }), + inputCandidate: candidate, itemText: "- [ ] L29 | Exercise production authority\n", + candidateContext: "candidate-summary@1\n", reviewerEvidence: ["repo-verify:pass"] }; + }, + runCheck: async ({ inputCandidate }) => ({ result: { outcome: "pass", inputCandidate, + timedOut: false, truncated: false }, evidence: Buffer.from("pass") }), + agentTimeoutMs: 2_000 }); + const completed = await runner.run(); + assert.equal(completed.projection.state, "converged", JSON.stringify(completed.execution)); + assert.equal(completed.execution.attempts.implement, 2); +}); + +test("executable reviewer escalation and malformed or stale finals fail closed", async (t) => { + for (const [name, outcomes, mode, expected] of [ + ["escalate", "complete,escalate", "", "needs-human"], + ["malformed", "complete", "malformed", "failed"], + ["stale", "complete", "stale", "failed"], + ]) { + const directory = realpathSync(mkdtempSync(join(tmpdir(), `burnlist-direct-${name}-`))); + t.after(() => rmSync(directory, { recursive: true, force: true })); + const { repo } = createProductionRunAuthority(join(directory, "repo")); + const authority = await bindRunCreation({ repoRoot: repo, input: { runId: fixtureRunId, itemRef: fixtureItemRef } }); + const store = runStore(repo); store.createRun({ runId: fixtureRunId, itemRef: fixtureItemRef, + graph: loadFrozenRecipe(authority.frozenRecipeBytes).ir }); + const counter = join(directory, "counter"); writeFileSync(counter, "0"); + const previous = [process.env.BURNLIST_FAKE_COUNTER, process.env.BURNLIST_FAKE_OUTCOMES, process.env.BURNLIST_FAKE_FINAL_MODE]; + process.env.BURNLIST_FAKE_COUNTER = counter; process.env.BURNLIST_FAKE_OUTCOMES = outcomes; + if (mode) process.env.BURNLIST_FAKE_FINAL_MODE = mode; else delete process.env.BURNLIST_FAKE_FINAL_MODE; + try { + const candidate = `cm1-sha256:${"d".repeat(64)}`; + const runner = createProductionRunRunner({ repoRoot: repo, store, runId: fixtureRunId, authority, + contextFor(invocation) { return { claimId: ownerClaimId({ runId: invocation.runId, nodeId: invocation.nodeId, + attempt: invocation.attempt, assignmentId: authority.assignmentId, inputCandidate: candidate }), + inputCandidate: candidate, candidateContext: "candidate-summary@1\n", reviewerEvidence: ["repo-verify:pass"] }; }, + runCheck: async ({ inputCandidate }) => ({ result: { outcome: "pass", inputCandidate, + timedOut: false, truncated: false }, evidence: Buffer.from("pass") }), agentTimeoutMs: 2_000 }); + assert.equal((await runner.run()).projection.state, expected, name); + } finally { + for (const [key, value] of [["BURNLIST_FAKE_COUNTER", previous[0]], ["BURNLIST_FAKE_OUTCOMES", previous[1]], + ["BURNLIST_FAKE_FINAL_MODE", previous[2]]]) { + if (value === undefined) delete process.env[key]; else process.env[key] = value; + } + } + } +}); + +test("stored production repair binds a fresh repository candidate and gives each reviewer its matching check evidence", async (t) => { + const directory = realpathSync(mkdtempSync(join(tmpdir(), "burnlist-candidate-repair-"))); + t.after(() => rmSync(directory, { recursive: true, force: true })); + const { repo } = createProductionRunAuthority(join(directory, "repo")), store = runStore(repo); + const created = await createProductionRun({ repoRoot: repo, store, itemRef: fixtureItemRef, runId: fixtureRunId }); + const outcomes = ["complete", "reject", "complete", "approve"], reviewerPrompts = []; let writes = 0; + const startAgent = ({ prompt }) => { + const values = Object.fromEntries(prompt.split("\n").filter((line) => line.includes("=")).map((line) => line.split(/=(.*)/su).slice(0, 2))); + const outcome = outcomes.shift(); + if (values.node === "implement") writeFileSync(join(repo, "candidate-state.txt"), `maker-write-${++writes}\n`); + if (values.node === "review") reviewerPrompts.push(prompt); + const final = { schema: "burnlist.agent-final@1", runId: values.run, nodeId: values.node, attempt: Number(values.attempt), + claimId: values.claim, invocationId: values.invocation, assignmentId: values.assignment, recipeRevision: values.recipe, + policyRevision: values.policy, inputCandidate: values.candidate, outcome, summary: `fake ${outcome}` }; + return { cancel: () => true, completion: Promise.resolve({ outcome: "completed", events: [{ type: "item.completed", item: { type: "agent_message", text: JSON.stringify(final) } }] }) }; + }; + const runner = createStoredProductionRunRunner({ repoRoot: repo, store, runId: created.projection.runId, startAgent, + runCheck: async ({ inputCandidate }) => ({ result: { outcome: "pass", inputCandidate, timedOut: false, truncated: false }, evidence: Buffer.from("pass") }) }); + assert.equal((await runner.run()).projection.state, "converged"); + const candidates = store.read(fixtureRunId).journal.filter((record) => record.value.type === "candidate-bound").map((record) => record.value.payload.candidateId); + assert.equal(candidates.length, 2); assert.notEqual(candidates[0], candidates[1]); + assert.equal(reviewerPrompts.length, 2); + for (const [index, prompt] of reviewerPrompts.entries()) { + assert.match(prompt, new RegExp(`candidate=${candidates[index]}`, "u")); + assert.match(prompt, new RegExp(`trusted-check candidate=${candidates[index]} summary=repository check pass`, "u")); + } +}); + +test("stored production launch rejects changed profile authority before the agent can spawn", async (t) => { + const directory = realpathSync(mkdtempSync(join(tmpdir(), "burnlist-launch-drift-"))); + t.after(() => rmSync(directory, { recursive: true, force: true })); + const { repo, binary } = createProductionRunAuthority(join(directory, "repo")), store = runStore(repo); + const created = await createProductionRun({ repoRoot: repo, store, itemRef: fixtureItemRef, runId: fixtureRunId }); + writeFileSync(binary, `${writeFileSync.toString()}\n`); + let spawned = false; + const runner = createStoredProductionRunRunner({ repoRoot: repo, store, runId: created.projection.runId, + startAgent() { spawned = true; throw new Error("must not spawn"); } }); + assert.equal((await runner.run()).projection.state, "failed"); + assert.equal(spawned, false); +}); +test("production replay normalizes item-mismatched, malformed, or missing required authority to EAUTHORITY", async (t) => { + const directory = realpathSync(mkdtempSync(join(tmpdir(), "burnlist-authority-delete-"))); + t.after(() => rmSync(directory, { recursive: true, force: true })); + const { repo } = createProductionRunAuthority(join(directory, "repo")), store = runStore(repo); + await createProductionRun({ repoRoot: repo, store, itemRef: fixtureItemRef, runId: fixtureRunId }); + assert.equal(store.read(fixtureRunId).journal[0].value.payload.authorityRequired, true); + const path = store.paths.authorityPath(fixtureRunId), bytes = readFileSync(path); + const mismatched = JSON.parse(bytes); + mismatched.itemRef = "item:260722-001#OTHER"; + writeFileSync(path, `${JSON.stringify(mismatched)}\n`); + assert.throws(() => runStore(repo).read(fixtureRunId), { code: "EAUTHORITY" }); + writeFileSync(path, "{\n"); + assert.throws(() => runStore(repo).read(fixtureRunId), { code: "EAUTHORITY" }); + writeFileSync(path, bytes, { mode: 0o600 }); + rmSync(path); + assert.throws(() => runStore(repo).read(fixtureRunId), { code: "EAUTHORITY" }); +}); + +test("over-limit and unreadable post-maker candidates replay as closed failures without a lease", async (t) => { + for (const mode of ["over-limit", "unreadable"]) { + const directory = realpathSync(mkdtempSync(join(tmpdir(), `burnlist-candidate-${mode}-`))); + t.after(() => rmSync(directory, { recursive: true, force: true })); + const { repo } = createProductionRunAuthority(join(directory, "repo")), store = runStore(repo); + await createProductionRun({ repoRoot: repo, store, itemRef: fixtureItemRef, runId: fixtureRunId }); + const startAgent = ({ prompt }) => { + const values = Object.fromEntries(prompt.split("\n").filter((line) => line.includes("=")).map((line) => line.split(/=(.*)/su).slice(0, 2))); + if (mode === "over-limit") writeFileSync(join(repo, "oversized-candidate.bin"), Buffer.alloc(65_537)); + else symlinkSync(join(repo, "src"), join(repo, "unreadable-candidate")); + const final = { schema: "burnlist.agent-final@1", runId: values.run, nodeId: values.node, attempt: Number(values.attempt), + claimId: values.claim, invocationId: values.invocation, assignmentId: values.assignment, recipeRevision: values.recipe, + policyRevision: values.policy, inputCandidate: values.candidate, outcome: "complete", summary: "maker complete" }; + return { cancel: () => true, completion: Promise.resolve({ outcome: "completed", + events: [{ type: "item.completed", item: { type: "agent_message", text: JSON.stringify(final) } }] }) }; + }; + const final = await createStoredProductionRunRunner({ repoRoot: repo, store, runId: fixtureRunId, startAgent }).run(); + assert.equal(final.projection.state, "failed", mode); + assert.equal(final.projection.leaseHeld, false, mode); + const replayed = runStore(repo).read(fixtureRunId); + assert.equal(replayed.projection.state, "failed", mode); + assert.equal(replayed.projection.leaseHeld, false, mode); + assert.match(replayed.execution.system.summary, mode === "over-limit" ? /too large/u : /symbolic/u); + } +}); diff --git a/src/loops/run/budgets.mjs b/src/loops/run/budgets.mjs new file mode 100644 index 00000000..4e223867 --- /dev/null +++ b/src/loops/run/budgets.mjs @@ -0,0 +1,28 @@ +const keys = ["maxRounds", "maxMinutes", "maxAgentRuns", "maxCheckRuns", "maxTransitions", "maxOutputBytes"]; +const exact = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value) && Object.keys(value).length === keys.length && keys.every((key) => Object.hasOwn(value, key)); +const fail = (message) => { throw Object.assign(new Error(`Run budgets: ${message}`), { code: "EBUDGET" }); }; +export function validateBudget(value) { if (!exact(value) || !keys.every((key) => Number.isSafeInteger(value[key]) && value[key] > 0)) fail("invalid limits"); return Object.freeze({ ...value }); } +export function foldBudgets({ records, graph }) { + const budget = validateBudget(graph.budget), nodes = new Map(graph.nodes.map((node) => [node.id, node])), counters = { rounds: 0, agentRuns: 0, checkRuns: 0, transitions: 0, outputBytes: 0 }; + const visits = {}; let elapsedMilliseconds = 0, previous = records[0]?.value?.at; + for (const record of records) { + if (!Number.isSafeInteger(record.value.at) || record.value.at < previous) fail("clock regressed"); elapsedMilliseconds += record.value.at - previous; previous = record.value.at; + const { type, payload } = record.value; + if (type === "node-started") { const node = nodes.get(payload.nodeId); if (node.kind === "agent") { counters.agentRuns += 1; if (node.mode === "task") counters.rounds += 1; } else if (node.kind === "check") counters.checkRuns += 1; } + if (type === "edge-taken") { counters.transitions += 1; const key = `${payload.from}\0${payload.on}`, edge = graph.edges.find((item) => item.from === payload.from && item.on === payload.on); visits[key] = (visits[key] ?? 0) + 1; if (edge?.maxVisits !== null && visits[key] > edge.maxVisits) fail("edge visit exceeds limit"); } + if (type === "invocation-result") counters.outputBytes += payload.outputBytes; + } + if (counters.rounds > budget.maxRounds || counters.agentRuns > budget.maxAgentRuns || counters.checkRuns > budget.maxCheckRuns || counters.transitions > budget.maxTransitions || counters.outputBytes > budget.maxOutputBytes) fail("inclusive limit exceeded"); + return Object.freeze({ counters: Object.freeze(counters), visits: Object.freeze(visits), elapsedMilliseconds, timeExceeded: elapsedMilliseconds > budget.maxMinutes * 60_000, journal: Object.freeze({ maximum: MAX_JOURNAL_RECORDS, used: records.length, remaining: MAX_JOURNAL_RECORDS - records.length }) }); +} +export function budgetReason({ folded, graph, node = null, edge = null, outputBytes = 0 }) { + const b = validateBudget(graph.budget), c = folded.counters; + if (folded.elapsedMilliseconds >= b.maxMinutes * 60_000) return "minutes"; + if (edge && (c.transitions >= b.maxTransitions || edge.maxVisits !== null && (folded.visits[`${edge.from}\0${edge.on}`] ?? 0) >= edge.maxVisits)) return "transitions"; + if (node?.kind === "agent" && c.agentRuns >= b.maxAgentRuns) return "agent-runs"; + if (node?.kind === "agent" && node.mode === "task" && c.rounds >= b.maxRounds) return "rounds"; + if (node?.kind === "check" && c.checkRuns >= b.maxCheckRuns) return "check-runs"; + if (!Number.isSafeInteger(outputBytes) || outputBytes < 0 || c.outputBytes + outputBytes > b.maxOutputBytes) return "output-bytes"; + return null; +} +import { MAX_JOURNAL_RECORDS } from "./run-journal.mjs"; diff --git a/src/loops/run/budgets.test.mjs b/src/loops/run/budgets.test.mjs new file mode 100644 index 00000000..ab703e09 --- /dev/null +++ b/src/loops/run/budgets.test.mjs @@ -0,0 +1,15 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { budgetReason, foldBudgets } from "./budgets.mjs"; +import { createJournalRecord } from "./run-journal.mjs"; +import { created, testGraph } from "./m2-test-fixtures.mjs"; + +const record = (sequence, prior, type, payload, at = sequence) => createJournalRecord({ sequence, prevDigest: prior?.digest ?? null, at, type, payload }); +test("fold enforces inclusive counters, retries, visits, output, and time", () => { + const one = record(1, null, "run-created", created(), 0), two = record(2, one, "node-started", { nodeId: "implement", attempt: 1 }), three = record(3, two, "invocation-started", { nodeId: "implement", attempt: 1, invocationId: "a".repeat(32) }), four = record(4, three, "invocation-result", { invocationId: "a".repeat(32), kind: "complete", summary: "ok", outputBytes: 1, candidateId: null }); + const folded = foldBudgets({ records: [one, two, three, four], graph: testGraph }); assert.equal(folded.counters.agentRuns, 1); assert.equal(budgetReason({ folded, graph: testGraph, node: testGraph.nodes.find((node) => node.id === "implement") }), null); + const elapsed = foldBudgets({ records: [one, record(2, one, "state-changed", { from: "prepared", to: "running", cause: "control" }, testGraph.budget.maxMinutes * 60000)], graph: testGraph }); assert.equal(budgetReason({ folded: elapsed, graph: testGraph }), "minutes"); + const visits = [one]; + for (let index = 1; index <= 4; index += 1) visits.push(record(index + 1, visits.at(-1), "edge-taken", { from: "review", on: "reject", to: "implement" })); + assert.throws(() => foldBudgets({ records: visits, graph: testGraph }), /visit exceeds/u); +}); diff --git a/src/loops/run/candidate.mjs b/src/loops/run/candidate.mjs new file mode 100644 index 00000000..35ca8ea9 --- /dev/null +++ b/src/loops/run/candidate.mjs @@ -0,0 +1,46 @@ +import { createHash } from "node:crypto"; +import { lstatSync, readdirSync, readFileSync } from "node:fs"; +import { join, relative, resolve } from "node:path"; +import { prefixed } from "../dsl/hash.mjs"; + +const EXCLUDED = new Set([".git", ".local", "node_modules"]); +const MAX_FILES = 256, MAX_FILE_BYTES = 65_536, MAX_TOTAL_BYTES = 1_048_576; +const fail = (message) => { throw Object.assign(new Error(`Loop candidate: ${message}`), { code: "ECANDIDATE" }); }; +const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex"); + +function fileRecord(root, path) { + const before = lstatSync(path); + if (!before.isFile() || before.isSymbolicLink() || before.size > MAX_FILE_BYTES) fail(`candidate file is unsafe or too large: ${relative(root, path)}`); + const bytes = readFileSync(path), after = lstatSync(path); + if (!after.isFile() || after.isSymbolicLink() || before.dev !== after.dev || before.ino !== after.ino + || before.size !== after.size || before.mtimeMs !== after.mtimeMs || !bytes.length && before.size) + fail(`candidate file changed while reading: ${relative(root, path)}`); + return `${relative(root, path)}\t${bytes.length}\t${sha256(bytes)}`; +} + +/** A bounded, git-free repository manifest. This is detected-at-boundaries, not a hostile-writer claim. */ +export function deriveCandidate({ repoRoot }) { + const root = resolve(repoRoot), rootBefore = lstatSync(root); + if (!rootBefore.isDirectory() || rootBefore.isSymbolicLink()) fail("repository root is unsafe"); + const files = [], pending = [root]; let total = 0; + while (pending.length) { + const directory = pending.pop(), entries = readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name)); + for (const entry of entries) { + if (directory === root && EXCLUDED.has(entry.name)) continue; + const path = join(directory, entry.name); + if (entry.isSymbolicLink()) fail(`candidate path is symbolic: ${relative(root, path)}`); + if (entry.isDirectory()) { pending.push(path); continue; } + if (!entry.isFile()) continue; + const record = fileRecord(root, path), size = Number(record.split("\t")[1]); + total += size; + if (++files.length > MAX_FILES || total > MAX_TOTAL_BYTES) fail("candidate manifest exceeds bounds"); + files.push(record); + } + } + files.sort(); + const rootAfter = lstatSync(root); + if (!rootAfter.isDirectory() || rootAfter.isSymbolicLink() || rootBefore.dev !== rootAfter.dev || rootBefore.ino !== rootAfter.ino) fail("repository root changed while capturing candidate"); + const manifest = `candidate-manifest@1\n${files.join("\n")}\n`; + const id = prefixed("cm1-sha256:", "stage1-repository-candidate-v1", [Buffer.from(manifest)]); + return Object.freeze({ id, context: `candidate-summary@1\ncandidate=${id}\nfiles=${files.length}\n${files.join("\n")}\n` }); +} diff --git a/src/loops/run/controller.mjs b/src/loops/run/controller.mjs new file mode 100644 index 00000000..479488d3 --- /dev/null +++ b/src/loops/run/controller.mjs @@ -0,0 +1,49 @@ +import { presentRun } from "./read-projection.mjs"; +import { isRunRef } from "./run-ref.mjs"; + +const fail = (message, code = "ELOOP_CONTROL") => { throw Object.assign(new Error(`Loop control: ${message}`), { code }); }; +const stable = (value) => `${JSON.stringify(value)}\n`; + +/** Small foreground-only control boundary. It owns no daemon or recovery policy. */ +export function createLoopController({ store, runnerFor }) { + if (!store?.read || !store?.list || !store?.acquireLease || !store?.terminalize) fail("invalid controller input"); + const check = (runId) => { if (!isRunRef(runId)) fail("invalid RunRef"); return runId; }; + const read = (runId) => store.read(check(runId)); + const inspect = (runId) => Object.freeze(presentRun(read(runId))); + // Status is a compact public projection, never the internal fold object. + // This keeps frozen Loop identity and journal timestamps available to CLI + // users without exposing dispatch authority or invocation internals. + const status = (runId) => Object.freeze({ ...presentRun(read(runId)), schema: "burnlist-loop-status@1" }); + const list = () => Object.freeze(store.list().map((run) => ({ schema: "burnlist-loop-status@1", ...run }))); + function idleLease(runId) { + const current = read(runId); + if (current.execution.terminal) fail("Run is terminal", "ETERMINAL"); + if (current.execution.lease) fail("Run has an active foreground owner", "ELEASED"); + return store.acquireLease(runId).lease; + } + function pause(runId) { + const lease = idleLease(runId), current = read(runId); + if (current.execution.invocation && !current.execution.result) fail("Run has an active invocation", "EACTIVE"); + store.append(runId, lease, "state-changed", { from: "running", to: "paused", cause: "control" }); + store.releaseLease(runId, lease); return inspect(runId); + } + function stop(runId) { + const lease = idleLease(runId); + return presentRun(store.terminalize(runId, lease, "cancelled", "control")); + } + async function run(runId) { + check(runId); if (typeof runnerFor !== "function") fail("foreground runner is unavailable", "ERUNNER_UNAVAILABLE"); + const runner = runnerFor(runId); if (!runner?.run) fail("foreground runner is unavailable", "ERUNNER_UNAVAILABLE"); + return presentRun(await runner.run()); + } + /** Recovery is deliberately proof-gated: without an owner proof it cannot take a live lease. */ + function reconcile(runId, recoveryProof = null) { + check(runId); const current = read(runId); + if (current.execution.terminal || !current.execution.lease) return inspect(runId); + if (!current.execution.invocation || !recoveryProof) fail("active owner is not demonstrably lost", "ELOST_PROOF"); + store.recoverLease(runId, recoveryProof); + const lease = idleLease(runId); + return presentRun(store.terminalize(runId, lease, "lost", "reconciled lost invocation")); + } + return Object.freeze({ list, inspect, status, pause, stop, run, reconcile, render: stable }); +} diff --git a/src/loops/run/controller.test.mjs b/src/loops/run/controller.test.mjs new file mode 100644 index 00000000..47d710f8 --- /dev/null +++ b/src/loops/run/controller.test.mjs @@ -0,0 +1,42 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import os from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { createLoopController } from "./controller.mjs"; +import { testGraph, testRunId } from "./m2-test-fixtures.mjs"; +import { createRunRunner } from "./runner.mjs"; +import { runStore } from "./run-store.mjs"; + +function fixture(t) { + const root = mkdtempSync(join(os.tmpdir(), "m6-controller-")); t.after(() => rmSync(root, { recursive: true, force: true })); + let time = 0; const store = runStore(root, { clock: () => time++ }); + store.createRun({ runId: testRunId, itemRef: "item:260722-001#M6", graph: testGraph }); + return { root, store, controller: createLoopController({ store, runnerFor: (runId) => createRunRunner({ store, runId, invoke: async ({ nodeId }) => ({ kind: nodeId === "implement" ? "complete" : nodeId === "verify" ? "pass" : "approve", summary: "ok", outputBytes: 0 }) }) }) }; +} + +test("read commands are byte-stable and do not create a lease", (t) => { + const { controller, store } = fixture(t); + assert.equal(controller.render(controller.status(testRunId)), controller.render(controller.status(testRunId))); + assert.equal(controller.render(controller.inspect(testRunId)), controller.render(controller.inspect(testRunId))); + assert.equal(store.read(testRunId).projection.leaseHeld, false); +}); + +test("pause is resumable, stop is terminal, and an owner fences competing controls", async (t) => { + const { controller, store } = fixture(t); + assert.equal(controller.pause(testRunId).state, "paused"); + assert.equal((await controller.run(testRunId)).state, "converged"); + assert.throws(() => controller.stop(testRunId), { code: "ETERMINAL" }); + const second = fixture(t), owner = second.store.acquireLease(testRunId); + assert.throws(() => second.controller.pause(testRunId), { code: "ELEASED" }); + second.store.releaseLease(testRunId, owner.lease); +}); + +test("reconcile requires a fenced lost invocation and cannot be repeated", (t) => { + const { controller, store } = fixture(t), acquired = store.acquireLease(testRunId); + store.append(testRunId, acquired.lease, "node-started", { nodeId: "implement", attempt: 1 }); + store.append(testRunId, acquired.lease, "invocation-started", { nodeId: "implement", attempt: 1, invocationId: "a".repeat(32) }); + assert.throws(() => controller.reconcile(testRunId), { code: "ELOST_PROOF" }); + assert.equal(controller.reconcile(testRunId, { generation: acquired.lease.generation, recoveryProof: acquired.recoveryProof }).state, "needs-human"); + assert.equal(controller.reconcile(testRunId).state, "needs-human"); +}); diff --git a/src/loops/run/current-authority.mjs b/src/loops/run/current-authority.mjs new file mode 100644 index 00000000..4b94bfd1 --- /dev/null +++ b/src/loops/run/current-authority.mjs @@ -0,0 +1,56 @@ +import { closeSync, constants, fstatSync, fsyncSync, lstatSync, openSync, readSync, renameSync, rmSync, writeFileSync } from "node:fs"; +import { dirname, join, relative, resolve, sep } from "node:path"; +import { isRunRef } from "./run-ref.mjs"; + +const MAX_BYTES = 65_536, MAX_ITEMS = 128; +const ITEM = /^item:[0-9]{6}-[0-9]{3}#[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/u; +const ASSIGNMENT = /^as1-sha256:[a-f0-9]{64}$/u; +const fail = (message) => { throw Object.assign(new Error(`Current Run authority: ${message}`), { code: "ECURRENT" }); }; +const same = (left, right) => left.dev === right.dev && left.ino === right.ino && left.size === right.size && left.mode === right.mode; + +function ancestors(root, base) { + const canonicalRoot = resolve(root), canonicalBase = resolve(base), tail = relative(canonicalRoot, canonicalBase); + if (tail === ".." || tail.startsWith(`..${sep}`)) fail("authority root escapes repository"); + const paths = [canonicalRoot]; let current = canonicalRoot; + for (const part of tail ? tail.split(sep) : []) { current = join(current, part); paths.push(current); } + return paths.map((path) => { const stat = lstatSync(path); if (!stat.isDirectory() || stat.isSymbolicLink()) fail("authority ancestor is unsafe"); return { path, dev: stat.dev, ino: stat.ino }; }); +} +function assertAncestors(values) { + for (const expected of values) { const stat = lstatSync(expected.path); if (!stat.isDirectory() || stat.isSymbolicLink() || stat.dev !== expected.dev || stat.ino !== expected.ino) fail("authority ancestor changed"); } +} +function parse(bytes) { + let value; try { value = JSON.parse(bytes); } catch { fail("authority is not JSON"); } + if (!value || Object.keys(value).length !== 2 || value.schema !== "burnlist-loop-current-runs@1" || !Array.isArray(value.items) || value.items.length > MAX_ITEMS || !Buffer.from(`${JSON.stringify(value)}\n`).equals(bytes)) fail("authority is not canonical"); + const items = value.items.map((entry) => { + if (!entry || Object.keys(entry).length !== 3 || !ITEM.test(entry.itemRef) || !isRunRef(entry.runId) || !ASSIGNMENT.test(entry.assignmentId)) fail("authority item is invalid"); + return Object.freeze({ itemRef: entry.itemRef, runId: entry.runId, assignmentId: entry.assignmentId }); + }); + if (new Set(items.map((item) => item.itemRef)).size !== items.length || items.some((item, index) => index && items[index - 1].itemRef >= item.itemRef)) fail("authority items are unordered or duplicate"); + return Object.freeze(items); +} + +export function currentRunAuthority({ root, base, random }) { + const target = join(base, "current-runs.json"); + function read() { + const anchored = ancestors(root, base); let fd; + try { + let leaf; try { leaf = lstatSync(target); } catch (error) { if (error?.code === "ENOENT") return Object.freeze([]); throw error; } + if (!leaf.isFile() || leaf.isSymbolicLink() || (leaf.mode & 0o777) !== 0o600 || leaf.size < 2 || leaf.size > MAX_BYTES) fail("authority file is unsafe"); + fd = openSync(target, constants.O_RDONLY | constants.O_NONBLOCK | (constants.O_NOFOLLOW ?? 0)); const before = fstatSync(fd); + if (!before.isFile() || !same(leaf, before) || (before.mode & 0o777) !== 0o600 || before.size > MAX_BYTES) fail("authority changed while opening"); + const bytes = Buffer.alloc(before.size); if (readSync(fd, bytes, 0, bytes.length, 0) !== bytes.length) fail("authority changed while reading"); + const after = fstatSync(fd), linked = lstatSync(target); if (!same(before, after) || !same(before, linked) || linked.isSymbolicLink()) fail("authority changed while reading"); + assertAncestors(anchored); return parse(bytes); + } finally { if (fd !== undefined) closeSync(fd); } + } + function write(items) { + const anchored = ancestors(root, base), ordered = [...items].sort((a, b) => a.itemRef.localeCompare(b.itemRef)); + const checked = parse(Buffer.from(`${JSON.stringify({ schema: "burnlist-loop-current-runs@1", items: ordered })}\n`)); + const bytes = Buffer.from(`${JSON.stringify({ schema: "burnlist-loop-current-runs@1", items: checked })}\n`), temporary = join(base, `.current-runs.${random(8).toString("hex")}.tmp`); let fd; + try { + fd = openSync(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW ?? 0), 0o600); writeFileSync(fd, bytes); fsyncSync(fd); closeSync(fd); fd = undefined; + assertAncestors(anchored); renameSync(temporary, target); const directory = openSync(base, constants.O_RDONLY); try { fsyncSync(directory); } finally { closeSync(directory); } + } finally { if (fd !== undefined) closeSync(fd); rmSync(temporary, { force: true }); } + } + return Object.freeze({ read, write, target }); +} diff --git a/src/loops/run/current-authority.test.mjs b/src/loops/run/current-authority.test.mjs new file mode 100644 index 00000000..4ecf901f --- /dev/null +++ b/src/loops/run/current-authority.test.mjs @@ -0,0 +1,28 @@ +import assert from "node:assert/strict"; +import { chmodSync, mkdirSync, mkdtempSync, renameSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { currentRunAuthority } from "./current-authority.mjs"; + +const runId = "run:01arz3ndektsv4rrffq69g5fav", itemRef = "item:260722-001#M8", assignmentId = `as1-sha256:${"a".repeat(64)}`; +function fixture(t) { + const root = mkdtempSync(join(tmpdir(), "burnlist-current-authority-")), base = join(root, ".local", "burnlist", "loop", "m2"); + mkdirSync(base, { recursive: true }); t.after(() => rmSync(root, { recursive: true, force: true })); return { root, base, authority: () => currentRunAuthority({ root, base, random: () => Buffer.from("12345678") }) }; +} +function entry() { return { itemRef, runId, assignmentId }; } + +test("current Run authority publishes canonical private records and rejects malformed bounds", (t) => { + const value = fixture(t), authority = value.authority(); authority.write([entry()]); + assert.deepEqual(authority.read(), [entry()]); const target = authority.target; + chmodSync(target, 0o644); assert.throws(() => authority.read(), /unsafe/u); chmodSync(target, 0o600); + writeFileSync(target, Buffer.alloc(65_537), { mode: 0o600 }); assert.throws(() => authority.read(), /unsafe/u); + writeFileSync(target, "{}\n", { mode: 0o600 }); assert.throws(() => authority.read(), /canonical/u); + writeFileSync(target, `${JSON.stringify({ schema: "burnlist-loop-current-runs@1", items: [entry(), entry()] })}\n`, { mode: 0o600 }); assert.throws(() => authority.read(), /unordered or duplicate/u); +}); + +test("current Run authority rejects leaf and ancestor symlink substitution", (t) => { + const value = fixture(t), authority = value.authority(), outside = join(value.root, "outside"); authority.write([entry()]); mkdirSync(outside); + rmSync(authority.target); symlinkSync(join(outside, "missing"), authority.target); assert.throws(() => authority.read(), /unsafe/u); rmSync(authority.target); + const moved = `${value.base}.moved`; renameSync(value.base, moved); symlinkSync(outside, value.base, "dir"); assert.throws(() => authority.read(), /ancestor is unsafe/u); +}); diff --git a/src/loops/run/launch-authority.test.mjs b/src/loops/run/launch-authority.test.mjs new file mode 100644 index 00000000..cf768af9 --- /dev/null +++ b/src/loops/run/launch-authority.test.mjs @@ -0,0 +1,69 @@ +import assert from "node:assert/strict"; +import { chmodSync, closeSync, fstatSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { bindRunCreation, captureRunLaunchBinding, holdRunLaunchBinding, launchAuthorityDigest, recheckRunLaunchBinding, releaseRunLaunchBinding } from "./binder.mjs"; +import { loadFrozenRecipe } from "../dsl/frozen.mjs"; +import { loadBoundPolicy } from "./run-artifacts.mjs"; +import { createProductionRunAuthority, fixtureItemRef, fixtureRunId } from "./run-test-fixtures.mjs"; + +async function fixture(t) { + const directory = mkdtempSync(join(tmpdir(), "burnlist-direct-launch-authority-")); + t.after(() => rmSync(directory, { recursive: true, force: true })); + const { repo, binary } = createProductionRunAuthority(join(directory, "repo")); + const bound = await bindRunCreation({ repoRoot: repo, input: { runId: fixtureRunId, itemRef: fixtureItemRef } }); + return { + repo, binary, + replay: { + projection: { itemRef: bound.itemRef, assignmentId: bound.assignmentId, itemRevision: bound.itemRevision }, + frozenRecipe: loadFrozenRecipe(bound.frozenRecipeBytes), boundPolicy: loadBoundPolicy(bound.policyBytes).policy, policyBytes: bound.policyBytes, + }, + }; +} + +test("direct Stage One launch binding holds every live profile and trusted capability descriptor", async (t) => { + const value = await fixture(t), captured = captureRunLaunchBinding({ repoRoot: value.repo, replay: value.replay }); + assert.deepEqual(captured.evidence.map((entry) => entry.role), [ + "adapter:implementation.standard", "adapter:review.strong", "capability-bin", "capability-catalog", "capability-trust", + "profile:implementation.standard", "profile:review.strong", "route:implementation.standard", "route:review.strong", + ]); + recheckRunLaunchBinding(captured); + const held = holdRunLaunchBinding(captured); + assert.equal(held.length, captured.evidence.length, "each direct launch authority input is sealed"); + releaseRunLaunchBinding(held); + for (const item of held) assert.throws(() => fstatSync(item.sealedDescriptor), { code: "EBADF" }); +}); + +test("direct launch binding detects executable replacement before descriptor hold", async (t) => { + const value = await fixture(t), captured = captureRunLaunchBinding({ repoRoot: value.repo, replay: value.replay }); + writeFileSync(value.binary, `${readFileSync(value.binary, "utf8")}\n// changed\n`); + assert.throws(() => recheckRunLaunchBinding(captured), /changed/u); + assert.throws(() => holdRunLaunchBinding(captured), /changed/u); +}); + +test("direct launch binding rejects a non-executable configured profile binary", async (t) => { + const value = await fixture(t); + chmodSync(value.binary, 0o600); + assert.throws(() => captureRunLaunchBinding({ repoRoot: value.repo, replay: value.replay }), /not executable/u); +}); + +test("release closes every descriptor after an earlier close failure", async (t) => { + const value = await fixture(t), captured = captureRunLaunchBinding({ repoRoot: value.repo, replay: value.replay }), held = holdRunLaunchBinding(captured); + closeSync(held[0].sealedDescriptor); + assert.throws(() => releaseRunLaunchBinding(held), { code: "EBADF" }); + for (const item of held.slice(1)) assert.throws(() => fstatSync(item.sealedDescriptor), { code: "EBADF" }); +}); + +test("launch authority digest commits each direct authority field", () => { + const evidence = [{ role: "adapter:implementation.standard", executable: true, snapshot: { + root: "/", path: "/tool", kind: "file", ancestors: [{ path: "/", identity: { dev: 1, ino: 2, size: 3, mode: 4, mtimeMs: 5, ctimeMs: 6 } }], + identity: { dev: 7, ino: 8, size: 9, mode: 10, mtimeMs: 11, ctimeMs: 12 }, digest: `sha256:${"a".repeat(64)}`, maximum: 13, + } }]; + const baseline = launchAuthorityDigest(evidence); + for (const mutate of [ + (value) => { value[0].role = "profile:implementation.standard"; }, (value) => { value[0].executable = false; }, + (value) => { value[0].snapshot.path = "/other/tool"; }, (value) => { value[0].snapshot.identity.ino = 99; }, + (value) => { value[0].snapshot.digest = `sha256:${"b".repeat(64)}`; }, + ]) { const changed = structuredClone(evidence); mutate(changed); assert.notEqual(launchAuthorityDigest(changed), baseline); } +}); diff --git a/src/loops/run/launch-commit.mjs b/src/loops/run/launch-commit.mjs new file mode 100644 index 00000000..c29a9f6d --- /dev/null +++ b/src/loops/run/launch-commit.mjs @@ -0,0 +1,56 @@ +import { join } from "node:path"; +import { withLock, findBurnlistDir } from "../../cli/lifecycle-moves.mjs"; +import { withDirectoryLock } from "../../server/dir-lock.mjs"; +import { parseItemRef } from "../assignment/selectors.mjs"; +import { configRoot } from "../config/store.mjs"; +import { prefixed } from "../dsl/hash.mjs"; +import { foldStateMachine, selectRunnableNode } from "./state-machine.mjs"; + +function fail(message) { throw Object.assign(new Error(`Loop launch: ${message}`), { code: "ELOOP_RUNNER" }); } +function invocationId({ claimId, counter }) { + return prefixed("iv1-sha256:", "invocation-v1", [Buffer.from(claimId), Buffer.from(String(counter))]); +} + +/** One closed lifecycle -> config -> Run-journal launch transaction. */ +export function createLaunchCommit({ repoRoot, replayRaw, journalLockPath, appendLocked, withCatalog, capture, recheck, hold, release }) { + return function commitLaunch(runId, input) { + if (!input || Object.keys(input).length !== 2 || typeof input.nodeId !== "string" + || !input.clockSample || typeof input.clockSample !== "object") fail("invalid launch request"); + const initial = replayRaw(runId); + const item = parseItemRef(initial.projection.itemRef); + const plan = findBurnlistDir(repoRoot, item.burnlistId); + return withLock(plan.dir, () => withDirectoryLock({ + lockPath: join(configRoot(repoRoot), ".config.lock"), reclaimLiveAfterAge: false, + errorFactory: () => fail("Loop configuration is locked"), fn: () => withCatalog(() => withDirectoryLock({ + lockPath: journalLockPath(runId), reclaimLiveAfterAge: false, + errorFactory: () => fail("Run journal is locked"), fn() { + let current = replayRaw(runId); const captured = capture({ repoRoot, replay: current }); + appendLocked(runId, { type: "clock-sampled", payload: input.clockSample, + expectedSequence: current.projection.sequence + 1, expectedPrevDigest: current.projection.journalDigest }); + current = replayRaw(runId); + const state = foldStateMachine({ ir: current.frozenRecipe.ir, records: current.journal }); + const selected = selectRunnableNode({ ir: current.frozenRecipe.ir, records: current.journal }); + if (selected.kind === "exhausted") return Object.freeze({ kind: "exhausted", reason: selected.reason }); + if (selected.kind !== "spawn" || selected.node.id !== input.nodeId || !state.owner) + fail("spawn has no committed current entry"); + const counter = selected.node.kind === "agent" + ? state.budget.counters.agentRuns + 1 : state.budget.counters.checkRuns + 1; + const id = invocationId({ claimId: state.owner.claimId, counter }); + let held = []; + try { + const committed = appendLocked(runId, { type: "spawn-intent", payload: { + schema: "burnlist-loop-spawn-intent@1", nodeId: input.nodeId, attempt: selected.attempt, + claimId: state.owner.claimId, invocationId: id, launchAuthorityDigest: captured.authorityDigest, + }, expectedSequence: current.projection.sequence + 1, expectedPrevDigest: current.projection.journalDigest, + hooks: { beforePublish() { + // This is a detected-at-publication boundary, not an OS execution claim. + // L9 must compare launchAuthorityDigest while binding the real process. + recheck(captured); held = hold(captured); recheck(captured); + } } }); + return Object.freeze({ kind: "spawned", invocationId: id, committed }); + } finally { release(held); } + }, + })), + })); + }; +} diff --git a/src/loops/run/lifecycle.mjs b/src/loops/run/lifecycle.mjs new file mode 100644 index 00000000..9ce4c34d --- /dev/null +++ b/src/loops/run/lifecycle.mjs @@ -0,0 +1,76 @@ +const TERMINAL = new Set(["completed", "failed", "stopped", "budget-exhausted", "needs-human"]); +const NONTERMINAL = new Set(["prepared", "running", "pausing", "paused", "quarantined", "converged-pending-completion", "completion-needs-human"]); +const TRANSITIONS = Object.freeze({ + prepared: new Set(["running", "stopped", "failed", "quarantined"]), + running: new Set(["pausing", "converged-pending-completion", "failed", "stopped", "budget-exhausted", "needs-human", "quarantined"]), + pausing: new Set(["paused", "quarantined"]), + paused: new Set(["running", "stopped", "quarantined"]), + quarantined: new Set(["failed", "stopped", "paused", "budget-exhausted", "needs-human", "converged-pending-completion"]), + "converged-pending-completion": new Set(["completed", "completion-needs-human"]), + "completion-needs-human": new Set(["completed", "needs-human"]), +}); +const QUARANTINE_TARGETS = Object.freeze({ + prepared: new Set(["failed", "stopped"]), + running: new Set(["failed", "stopped", "budget-exhausted", "needs-human", "converged-pending-completion"]), + pausing: new Set(["paused"]), + paused: new Set(["paused", "stopped"]), +}); + +function fail(message) { throw Object.assign(new Error(`Loop lifecycle: ${message}`), { code: "ELOOP_STATE" }); } +function exact(value, keys) { return Boolean(value) && typeof value === "object" && !Array.isArray(value) && Object.keys(value).length === keys.length && keys.every((key) => Object.hasOwn(value, key)); } +const ANOMALY = new Set(["clock-monotonic-regression", "clock-wall-regression"]); + +export function isTerminalState(state) { return TERMINAL.has(state); } +export function isNonterminalState(state) { return NONTERMINAL.has(state); } + +export function validateTransition({ from, to, settleTo = null }) { + if (!NONTERMINAL.has(from) || !TRANSITIONS[from]?.has(to)) fail("state transition is not allowed"); + if (to === "quarantined") { + if (!QUARANTINE_TARGETS[from]?.has(settleTo)) fail("quarantine settle target is not allowed from source state"); + } else if (settleTo !== null) fail("only quarantine carries a settle target"); + return Object.freeze({ from, to, settleTo }); +} + +export function stateTransitionPayload(value) { + if (!exact(value, ["schema", "from", "to", "settleTo"]) || value.schema !== "burnlist-loop-run-state-transition@1") fail("invalid state transition payload"); + return Object.freeze({ schema: value.schema, ...validateTransition(value) }); +} + +export function legacyTransitionPayload(value) { + if (!exact(value, ["schema", "from", "to"]) || value.schema !== "burnlist-loop-state-transition@1") fail("invalid legacy state transition"); + const transition = validateTransition({ from: value.from, to: value.to, settleTo: null }); + if (transition.to === "quarantined") fail("legacy transition cannot represent quarantine"); + return transition; +} + +export function clockAnomalyTransitionPayload(value) { + if (!exact(value, ["schema", "from", "to", "code", "sequence"]) + || value.schema !== "burnlist-loop-clock-anomaly-transition@1" || !NONTERMINAL.has(value.from) + || value.to !== "needs-human" || !ANOMALY.has(value.code) + || !Number.isSafeInteger(value.sequence) || value.sequence < 1) fail("invalid clock anomaly transition"); + return Object.freeze({ ...value }); +} + +/** The sole lifecycle fold used by store, execution, deadlines, and recovery. */ +export function foldLifecycle(records) { + if (!Array.isArray(records) || records[0]?.value?.type !== "run-created") fail("journal lacks Run creation"); + let state = "prepared", settleTo = null; + const stateAfter = []; + for (const [index, record] of records.entries()) { + if (index && ["run-state-transition", "state-transition", "clock-anomaly-transition"].includes(record.value.type)) { + if (record.value.type === "clock-anomaly-transition") { + const transition = clockAnomalyTransitionPayload(record.value.payload); + if (transition.from !== state) fail("clock anomaly transition predecessor does not match replay"); + state = transition.to; settleTo = null; stateAfter.push(state); continue; + } + const transition = record.value.type === "run-state-transition" + ? stateTransitionPayload(record.value.payload) : legacyTransitionPayload(record.value.payload); + if (transition.from !== state) fail("state transition predecessor does not match replay"); + if (state === "quarantined" && transition.to !== settleTo) fail("quarantine may settle only to its immutable target"); + state = transition.to; + settleTo = state === "quarantined" ? transition.settleTo : null; + } + stateAfter.push(state); + } + return Object.freeze({ state, settleTo, stateAfter: Object.freeze(stateAfter) }); +} diff --git a/src/loops/run/m2-test-fixtures.mjs b/src/loops/run/m2-test-fixtures.mjs new file mode 100644 index 00000000..5fff7c6c --- /dev/null +++ b/src/loops/run/m2-test-fixtures.mjs @@ -0,0 +1,9 @@ +export const testRunId = "run:01arz3ndektsv4rrffq69g5fav"; +export const testGraph = Object.freeze({ schema: "burnlist-loop-ir@1", compiler: "burnlist-loop-compiler@1", id: "review", declaredVersion: "0.1.0", entry: "implement", budget: { maxRounds: 3, maxMinutes: 60, maxAgentRuns: 6, maxCheckRuns: 3, maxTransitions: 16, maxOutputBytes: 262144 }, nodes: [ + { kind: "agent", id: "implement", mode: "task", role: "maker", route: "implementation.standard", authority: "write", instructions: "implement", independentFrom: null, requires: [] }, + { kind: "terminal", id: "completed", state: "converged" }, { kind: "gate", id: "converged", gateKind: "convergence", requires: ["verify", "review"] }, { kind: "terminal", id: "exhausted", state: "budget-exhausted" }, { kind: "terminal", id: "failed", state: "failed" }, { kind: "terminal", id: "needs-human", state: "needs-human" }, + { kind: "agent", id: "review", mode: "review", role: "reviewer", route: "review.strong", authority: "read", instructions: "review", independentFrom: "implement", requires: ["fresh-session:enforced", "filesystem-write-deny:supervised"] }, { kind: "terminal", id: "stopped", state: "stopped" }, { kind: "check", id: "verify", capability: "repo-verify" }, +], failurePolicy: { error: "failed", timeout: "failed", cancelled: "stopped", lost: "needs-human", exhausted: "exhausted" }, edges: [ + { from: "implement", on: "complete", to: "verify", maxVisits: null }, { from: "converged", on: "pass", to: "completed", maxVisits: null }, { from: "converged", on: "fail", to: "needs-human", maxVisits: null }, { from: "review", on: "approve", to: "converged", maxVisits: null }, { from: "review", on: "reject", to: "implement", maxVisits: 3 }, { from: "review", on: "escalate", to: "needs-human", maxVisits: null }, { from: "verify", on: "pass", to: "review", maxVisits: null }, { from: "verify", on: "fail", to: "implement", maxVisits: 3 }, +], instructions: [{ id: "implement", digest: "sha256:8d1db5c7cbc11075fccc565180374e1929a8cf22683cbca3dde9b77ef667c0a8", byteLength: 114 }, { id: "review", digest: "sha256:53ca65be9491688119b5cbf2443d0b9aff003764db9e91657ec54dbae12d76aa", byteLength: 119 }] }); +export function created(graph = testGraph) { return { schema: "burnlist-loop-m2-run@1", runId: testRunId, itemRef: "item:260722-001#M2", graph, authorityRequired: false }; } diff --git a/src/loops/run/operation.mjs b/src/loops/run/operation.mjs new file mode 100644 index 00000000..d002580d --- /dev/null +++ b/src/loops/run/operation.mjs @@ -0,0 +1,92 @@ +import { prefixed } from "../dsl/hash.mjs"; +import { canonicalPayload } from "./run-codec.mjs"; +import { clockAnomalyTransitionPayload, stateTransitionPayload } from "./lifecycle.mjs"; + +const OPERATION = /^op1-sha256:[a-f0-9]{64}$/u; +const CLAIM = /^cl1-sha256:[a-f0-9]{64}$/u; +const STEP_TYPES = new Set(["edge-traversed", "owner-claim-released", "graph-outcome", + "system-outcome", "run-state-transition", "clock-anomaly-transition"]); +const NODE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u; +const INVOCATION = /^iv1-sha256:[a-f0-9]{64}$/u; +const TERMINAL = new Set(["converged", "needs-human", "failed", "stopped", "budget-exhausted"]); +const STATE = new Set(["running", "paused", "converged-pending-completion", "completion-needs-human", ...TERMINAL]); +const SYSTEM = new Set(["error", "timeout", "cancelled", "lost", "exhausted"]); +const OUTCOME = new Set(["pass", "fail", "reject", "approve", "complete", "escalate", + ...SYSTEM, "clock-monotonic-regression", "clock-wall-regression"]); +const exact = (value, keys) => Boolean(value) && typeof value === "object" && !Array.isArray(value) + && Object.keys(value).length === keys.length && keys.every((key) => Object.hasOwn(value, key)); +function fail(message) { + throw Object.assign(new Error(`Loop operation: ${message}`), { code: "ELOOP_OPERATION" }); +} +function step(value) { + if (!exact(value, ["type", "payload"]) || !STEP_TYPES.has(value.type)) fail("invalid operation step"); + const { type, payload } = value; + if (type === "run-state-transition") stateTransitionPayload(payload); + else if (type === "clock-anomaly-transition") clockAnomalyTransitionPayload(payload); + else if (type === "owner-claim-released") { + if (!exact(payload, ["schema", "runId", "claimId"]) || payload.schema !== "burnlist-loop-owner-claim-release@1" || !CLAIM.test(payload.claimId)) fail("invalid operation release"); + } else if (type === "edge-traversed") { + if (!exact(payload, ["schema", "from", "on", "to", "visit", "claimId", "invocationId"]) || payload.schema !== "burnlist-loop-edge-traversed@1" + || !NODE.test(payload.from) || !NODE.test(payload.to) || !["pass", "fail", "reject", "approve", "complete", "escalate"].includes(payload.on) + || !Number.isSafeInteger(payload.visit) || payload.visit < 1 || payload.visit > 100 || !(payload.claimId === null || CLAIM.test(payload.claimId)) + || !(payload.invocationId === null || INVOCATION.test(payload.invocationId))) fail("invalid operation edge"); + } else if (type === "graph-outcome") { + if (!exact(payload, ["schema", "nodeId", "state"]) || payload.schema !== "burnlist-loop-graph-outcome@1" || !NODE.test(payload.nodeId) || !TERMINAL.has(payload.state)) fail("invalid operation graph outcome"); + } else if (!exact(payload, ["schema", "outcome", "nodeId", "state", "reason"]) + || payload.schema !== "burnlist-loop-system-outcome@1" || !SYSTEM.has(payload.outcome) || !NODE.test(payload.nodeId) + || !TERMINAL.has(payload.state) || typeof payload.reason !== "string" || !payload.reason || payload.reason.length > 512 || /[\0\r\n]/u.test(payload.reason)) fail("invalid operation system outcome"); + return Object.freeze({ type, payload: canonicalPayload(payload) }); +} + +export function operationIntent({ runId, journalDigest, claimId, nodeId, outcome, targetState, steps }) { + if (typeof runId !== "string" || typeof journalDigest !== "string" || !(claimId === null || CLAIM.test(claimId)) + || !NODE.test(nodeId) || !OUTCOME.has(outcome) || !STATE.has(targetState) + || !Array.isArray(steps) || steps.length < 1 || steps.length > 5) fail("invalid operation plan"); + const checked = steps.map(step), plan = { claimId, nodeId, outcome, targetState, steps: checked }; + const operationId = prefixed("op1-sha256:", "run-operation-v1", + [Buffer.from(runId), Buffer.from(journalDigest), Buffer.from(JSON.stringify(canonicalPayload(plan)))]); + return Object.freeze({ schema: "burnlist-loop-operation-intent@1", operationId, ...plan, steps: Object.freeze(checked) }); +} + +export function validateOperationIntent(value) { + if (!exact(value, ["schema", "operationId", "claimId", "nodeId", "outcome", "targetState", "steps"]) + || value.schema !== "burnlist-loop-operation-intent@1" || !OPERATION.test(value.operationId)) fail("invalid operation intent"); + operationIntent({ runId: "validation", journalDigest: "validation", claimId: value.claimId, + nodeId: value.nodeId, outcome: value.outcome, targetState: value.targetState, steps: value.steps }); + return Object.freeze({ ...value, steps: Object.freeze(value.steps.map((step) => Object.freeze(step))) }); +} + +export function operationCompletedPayload(operationId) { + if (!OPERATION.test(operationId)) fail("invalid completed operation"); + return Object.freeze({ schema: "burnlist-loop-operation-completed@1", operationId }); +} + +export function foldOperation(records) { + let pending = null; const runId = records[0]?.value?.payload?.runId; + for (const record of records) { + const { type, payload } = record.value; + if (type === "operation-intent") { + if (pending) fail("operation intent overlaps pending operation"); + const intent = validateOperationIntent(payload); + const expected = operationIntent({ runId, journalDigest: record.value.prevDigest, claimId: intent.claimId, + nodeId: intent.nodeId, outcome: intent.outcome, targetState: intent.targetState, steps: intent.steps }); + if (expected.operationId !== intent.operationId) fail("operation id is not journal-derived"); + pending = { intent, cursor: 0 }; + continue; + } + if (!pending) { + if (type === "operation-completed") fail("operation completion has no intent"); + continue; + } + if (pending.cursor < pending.intent.steps.length) { + const expected = pending.intent.steps[pending.cursor]; + if (type !== expected.type || JSON.stringify(payload) !== JSON.stringify(expected.payload)) fail("operation prefix differs from intent"); + pending.cursor += 1; continue; + } + if (!exact(payload, ["schema", "operationId"]) || type !== "operation-completed" + || payload.schema !== "burnlist-loop-operation-completed@1" + || payload.operationId !== pending.intent.operationId) fail("completed operation prefix lacks exact marker"); + pending = null; + } + return pending && Object.freeze({ intent: pending.intent, cursor: pending.cursor }); +} diff --git a/src/loops/run/read-projection.mjs b/src/loops/run/read-projection.mjs new file mode 100644 index 00000000..e13c6fc0 --- /dev/null +++ b/src/loops/run/read-projection.mjs @@ -0,0 +1,112 @@ +import { existsSync, readdirSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { readJournal } from "./run-journal.mjs"; +import { foldRun } from "./run-fold.mjs"; +import { isRunRef } from "./run-ref.mjs"; +import { locateItemSpan, validateAssignedItem } from "../assignment/item-metadata.mjs"; +import { assignmentStore } from "../assignment/store.mjs"; +import { currentRunAuthority } from "./current-authority.mjs"; +import { runStore } from "./run-store.mjs"; + +const MAX_RUNS = 128; +const fail = (message) => { throw Object.assign(new Error(`Run projection: ${message}`), { code: "ERUN_PROJECTION" }); }; + +function publicNode(node) { + return { id: node.id, kind: node.kind }; +} + +export function presentRun(replay) { + const records = replay.journal; + let latestResult = null; + const transitions = []; + for (const record of records) { + const { sequence, type, payload } = record.value; + if (type === "invocation-result") latestResult = { + kind: payload.kind, summary: payload.summary, + }; + if (type === "edge-taken") transitions.push({ sequence, from: payload.from, outcome: payload.on, to: payload.to }); + if (type === "state-changed") transitions.push({ sequence, from: payload.from, outcome: payload.cause, to: payload.to }); + } + return Object.freeze({ + schema: "burnlist-loop-read-projection@1", + runId: replay.projection.runId, + itemRef: replay.projection.itemRef, + loopId: replay.loopIdentity?.loopId ?? replay.graph.id, + loopRevision: replay.loopIdentity?.loopRevision ?? null, + createdAt: records[0].value.at, + updatedAt: records.at(-1).value.at, + state: replay.projection.state, + currentNode: replay.projection.currentNode, + attempt: replay.projection.attempt, + cycle: replay.execution.cycle, + latestResult, + latestMaker: replay.projection.latestMaker, + latestCheck: replay.projection.latestCheck, + latestReviewer: replay.projection.latestReviewer, + revision: replay.revision, + budget: { + limits: replay.graph.budget, + counters: replay.execution.budget.counters, + elapsedMilliseconds: replay.execution.budget.elapsedMilliseconds, + journal: replay.execution.budget.journal, + }, + graph: { + entry: replay.graph.entry, + nodes: replay.graph.nodes.map(publicNode), + edges: replay.graph.edges.map(({ from, on, to }) => ({ from, on, to })), + }, + transitions, + }); +} + +/** Read-only bounded discovery. Missing state returns null and never creates directories. */ +export function readLatestRunForItem({ repoRoot, itemRef, markdown = null, itemId = null, assignmentId = null }) { + let artifact = null; + if (markdown !== null || itemId !== null || assignmentId !== null) { + try { + const metadata = validateAssignedItem(itemRef, locateItemSpan(markdown, itemId)); + if (metadata["Assignment-Id"] !== assignmentId) return null; + artifact = assignmentStore(repoRoot).load(assignmentId); + if (artifact.itemRef !== itemRef + || artifact.assignmentId !== assignmentId + || artifact.assignedItemDigest !== metadata.assignedDigest + || artifact.unassignedItemDigest !== metadata.unassignedDigest + || artifact.executionRevision !== metadata["Execution-Revision"] + || artifact.packageRevision !== metadata["Package-Revision"]) return null; + } catch { + return null; + } + } + const runs = join(resolve(repoRoot), ".local", "burnlist", "loop", "m2", "runs"); + if (!existsSync(runs)) return null; + const base = join(resolve(repoRoot), ".local", "burnlist", "loop", "m2"); + const current = currentRunAuthority({ root: repoRoot, base, random: () => Buffer.alloc(8) }).read() + .find((entry) => entry.itemRef === itemRef) ?? null; + if (current && artifact && current.assignmentId !== artifact.assignmentId) return null; + let entries; + try { entries = readdirSync(runs, { withFileTypes: true }); } catch { fail("Run projection is corrupt", "ECORRUPT"); } + const staging = entries.filter((entry) => /^\.create-[a-f0-9]{16}\.tmp$/u.test(entry.name)); + const visible = entries.filter((entry) => !/^\.create-[a-f0-9]{16}\.tmp$/u.test(entry.name)); + if (staging.length > MAX_RUNS || visible.length > MAX_RUNS + || entries.some((entry) => !entry.isDirectory() || !/^(?:[a-f0-9]+|\.create-[a-f0-9]{16}\.tmp)$/u.test(entry.name))) fail("run directory exceeds bounds"); + let selected = null; + for (const entry of visible) { + if (!entry.isDirectory() || !/^[a-f0-9]+$/u.test(entry.name)) fail("Run projection is corrupt", "ECORRUPT"); + let runId; + try { runId = Buffer.from(entry.name, "hex").toString("utf8"); } catch { fail("Run projection is corrupt", "ECORRUPT"); } + if (!isRunRef(runId) || Buffer.from(runId).toString("hex") !== entry.name) fail("Run projection is corrupt", "ECORRUPT"); + let journal, folded; + try { + journal = readJournal(join(runs, entry.name, "journal")); + folded = foldRun(journal); + } catch { fail("Run projection is corrupt", "ECORRUPT"); } + if (folded.projection.itemRef !== itemRef || current && runId !== current.runId) continue; + if (artifact && JSON.stringify(folded.graph) !== JSON.stringify(artifact.frozen.ir)) continue; + if (selected) fail("Run projection is ambiguous", "EAMBIGUOUS"); + selected = { ...folded, journal }; + } + if (current && !selected) fail("current Run is unavailable", "ECURRENT"); + if (!selected) return null; + selected.loopIdentity = runStore(repoRoot).read(selected.projection.runId).loopIdentity; + return presentRun(selected); +} diff --git a/src/loops/run/reviewer-isolation.test.mjs b/src/loops/run/reviewer-isolation.test.mjs new file mode 100644 index 00000000..f10bdd07 --- /dev/null +++ b/src/loops/run/reviewer-isolation.test.mjs @@ -0,0 +1,20 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, realpathSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { bindRunCreation } from "./binder.mjs"; +import { loadBoundPolicy } from "./run-artifacts.mjs"; +import { createProductionRunAuthority, fixtureItemRef, fixtureRunId } from "./run-test-fixtures.mjs"; + +test("reviewer authority is a fresh direct read profile with supervised write denial", async (t) => { + const directory = realpathSync(mkdtempSync(join(tmpdir(), "burnlist-direct-reviewer-"))); + t.after(() => rmSync(directory, { recursive: true, force: true })); + const { repo } = createProductionRunAuthority(join(directory, "repo")); + const bound = await bindRunCreation({ repoRoot: repo, input: { runId: fixtureRunId, itemRef: fixtureItemRef } }); + const reviewer = loadBoundPolicy(bound.policyBytes).policy.routes.find((route) => route.route === "review.strong"); + assert.equal(reviewer.profile.authority, "read"); + assert.deepEqual(reviewer.guarantees, { freshSession: "enforced", filesystemWriteDeny: "supervised" }); + assert.equal(Object.hasOwn(reviewer, "controller"), false); + assert.equal(Object.hasOwn(reviewer, "probe"), false); +}); diff --git a/src/loops/run/run-admission.mjs b/src/loops/run/run-admission.mjs new file mode 100644 index 00000000..ccd81e3a --- /dev/null +++ b/src/loops/run/run-admission.mjs @@ -0,0 +1,35 @@ +import { operationCompletedPayload } from "./operation.mjs"; +import { createJournalRecord } from "./run-journal.mjs"; +import { MAX_CATALOG_JOURNAL_BYTES, MAX_RUN_JOURNAL_BYTES, fail } from "./run-codec.mjs"; +import { foldStateMachine } from "./state-machine.mjs"; + +function next(previous, type, payload) { + return createJournalRecord({ schema: "burnlist-loop-journal@1", sequence: previous.value.sequence + 1, + prevDigest: previous.digest, type, artifacts: [], payload }); +} + +export function assertJournalCapacity({ runBytes, catalogBytes, recordBytes, + runLimit = MAX_RUN_JOURNAL_BYTES, catalogLimit = MAX_CATALOG_JOURNAL_BYTES }) { + if (![runBytes, catalogBytes, recordBytes, runLimit, catalogLimit].every((value) => Number.isSafeInteger(value) && value >= 0)) + fail("invalid journal capacity accounting"); + if (runBytes + recordBytes > runLimit || catalogBytes + recordBytes > catalogLimit) + fail("journal publication would exceed replay bounds"); +} + +/** Proves one append and any declared operation continuation before bytes are published. */ +export function admitAppend({ current, type, payload, artifacts, catalogBytes }) { + if (type === "run-created") fail("Run creation cannot be appended"); + const prospective = createJournalRecord({ schema: "burnlist-loop-journal@1", + sequence: current.projection.sequence + 1, prevDigest: current.projection.journalDigest, type, artifacts, payload }); + const runBytes = current.journal.reduce((total, record) => total + record.bytes.length, 0); + assertJournalCapacity({ runBytes, catalogBytes, recordBytes: prospective.bytes.length }); + let records = [...current.journal, prospective]; + if (type === "operation-intent") { + let previous = prospective; + for (const step of payload.steps) { previous = next(previous, step.type, step.payload); records.push(previous); } + previous = next(previous, "operation-completed", operationCompletedPayload(payload.operationId)); records.push(previous); + const result = foldStateMachine({ ir: current.frozenRecipe.ir, records }); + if (result.state !== payload.targetState) fail("operation target state does not match its exact steps"); + } else foldStateMachine({ ir: current.frozenRecipe.ir, records }); + return prospective; +} diff --git a/src/loops/run/run-admission.test.mjs b/src/loops/run/run-admission.test.mjs new file mode 100644 index 00000000..e3ccf18b --- /dev/null +++ b/src/loops/run/run-admission.test.mjs @@ -0,0 +1,18 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { assertJournalCapacity } from "./run-admission.mjs"; + +test("prospective Run and catalog journal bounds admit the exact boundary only", () => { + assert.doesNotThrow(() => assertJournalCapacity({ + runBytes: 7, catalogBytes: 17, recordBytes: 3, runLimit: 10, catalogLimit: 20, + })); + assert.throws(() => assertJournalCapacity({ + runBytes: 8, catalogBytes: 17, recordBytes: 3, runLimit: 10, catalogLimit: 20, + }), /replay bounds/u); + assert.throws(() => assertJournalCapacity({ + runBytes: 7, catalogBytes: 18, recordBytes: 3, runLimit: 10, catalogLimit: 20, + }), /replay bounds/u); + assert.throws(() => assertJournalCapacity({ + runBytes: -1, catalogBytes: 0, recordBytes: 1, runLimit: 10, catalogLimit: 20, + }), /capacity accounting/u); +}); diff --git a/src/loops/run/run-artifacts.mjs b/src/loops/run/run-artifacts.mjs new file mode 100644 index 00000000..dca2f0a0 --- /dev/null +++ b/src/loops/run/run-artifacts.mjs @@ -0,0 +1,95 @@ +import { loadFrozenRecipe } from "../dsl/frozen.mjs"; +import { prefixed, rawSha256 } from "../dsl/hash.mjs"; +import { validateDispatchAuthority } from "../contracts/agent-result.mjs"; +import { parseBoundedObject } from "../contracts/contract.mjs"; +import { resolveConfiguredStageOneRoutes, validateAgentProfile, agentProfileRevision } from "../agents/profile.mjs"; +import { canonicalCapabilityBytes, canonicalGrantBytes, capabilityRevision, GUARANTEE_LABELS, + validateCapability, validateCapabilityGrants } from "../capabilities/contract.mjs"; + +const PROFILE = /^ap1-sha256:[a-f0-9]{64}$/u; +const RAW = /^sha256:[a-f0-9]{64}$/u; +const RECIPE = /^er1-sha256:[a-f0-9]{64}$/u; +const POLICY_KEYS = ["schema", "recipeRevision", "routes", "capabilities"]; +const ROUTE_KEYS = ["route", "profile", "profileRevision", "executableDigest", "guarantees"]; +const CAPABILITY_KEYS = ["id", "policy", "revision", "policyDigest", "grants", "grantsDigest", "trust", "guarantees"]; + +function fail(message) { throw Object.assign(new Error(`Loop bound policy: ${message}`), { code: "ELOOP_BOUND_POLICY" }); } +function closed(value, keys) { return Boolean(value) && typeof value === "object" && !Array.isArray(value) && Object.keys(value).length === keys.length && keys.every((key) => Object.hasOwn(value, key)); } +function sortedUnique(values, key) { return values.every((value, index) => index === 0 || Buffer.compare(Buffer.from(values[index - 1][key]), Buffer.from(value[key])) < 0); } +const canonical = (value) => Buffer.from(`${JSON.stringify(value)}\n`); + +export function validateBoundPolicy(value) { + if (!closed(value, POLICY_KEYS) || value.schema !== "burnlist-loop-bound-policy@1" || !RECIPE.test(value.recipeRevision) + || !Array.isArray(value.routes) || value.routes.length !== 2 || !Array.isArray(value.capabilities) || value.capabilities.length !== 1) fail("invalid closed policy"); + const routes = value.routes.map((entry) => { + if (!closed(entry, ROUTE_KEYS) || !["implementation.standard", "review.strong"].includes(entry.route) + || !PROFILE.test(entry.profileRevision) || !RAW.test(entry.executableDigest) + || !closed(entry.guarantees, entry.route === "review.strong" + ? ["freshSession", "filesystemWriteDeny"] : ["freshSession"])) fail("invalid route binding"); + const profile = validateAgentProfile(entry.profile), profileRevision = agentProfileRevision(profile); + if (entry.profileRevision !== profileRevision || entry.guarantees.freshSession !== "enforced" + || entry.route === "review.strong" && entry.guarantees.filesystemWriteDeny !== "supervised") fail("route revision or guarantee mismatch"); + return { route: entry.route, profile, profileRevision, executableDigest: entry.executableDigest, + guarantees: { ...entry.guarantees } }; + }); + if (!sortedUnique(routes, "route")) fail("invalid route set"); + resolveConfiguredStageOneRoutes({ profiles: routes.map((entry) => entry.profile), + routes: Object.fromEntries(routes.map((entry) => [entry.route, entry.profile.id])) }); + const capabilities = value.capabilities.map((entry) => { + if (!closed(entry, CAPABILITY_KEYS) || entry.id !== "repo-verify" || !RAW.test(entry.policyDigest) || !RAW.test(entry.grantsDigest) + || JSON.stringify(entry.guarantees) !== JSON.stringify(GUARANTEE_LABELS)) fail("invalid capability binding"); + const policy = validateCapability(entry.policy), grants = validateCapabilityGrants(entry.grants, policy); + if (policy.id !== entry.id || entry.revision !== capabilityRevision(policy) + || entry.policyDigest !== rawSha256(canonicalCapabilityBytes(policy)) || entry.grantsDigest !== rawSha256(canonicalGrantBytes(grants, policy)) + || !closed(entry.trust, ["schema", "capability", "revision", "policyDigest", "grants", "grantsDigest"]) + || entry.trust.schema !== "burnlist-loop-capability-trust@1" || entry.trust.capability !== entry.id + || entry.trust.revision !== entry.revision || entry.trust.policyDigest !== entry.policyDigest + || entry.trust.grantsDigest !== entry.grantsDigest || JSON.stringify(entry.trust.grants) !== JSON.stringify(grants)) fail("capability policy or trust mismatch"); + return { id: entry.id, policy, revision: entry.revision, policyDigest: entry.policyDigest, grants, + grantsDigest: entry.grantsDigest, trust: { schema: entry.trust.schema, capability: entry.trust.capability, + revision: entry.trust.revision, policyDigest: entry.trust.policyDigest, grants, grantsDigest: entry.trust.grantsDigest }, + guarantees: Object.fromEntries(Object.keys(GUARANTEE_LABELS).map((key) => [key, GUARANTEE_LABELS[key]])) }; + }); + if (!sortedUnique(capabilities, "id") || capabilities[0]?.id !== "repo-verify") fail("invalid capability set"); + return Object.freeze({ schema: value.schema, recipeRevision: value.recipeRevision, routes: Object.freeze(routes), capabilities: Object.freeze(capabilities) }); +} + +export function canonicalBoundPolicyBytes(value) { return Buffer.from(`${JSON.stringify(validateBoundPolicy(value))}\n`, "utf8"); } +export function boundPolicyRevision(value) { const bytes = canonicalBoundPolicyBytes(value); return prefixed("bp1-sha256:", "bound-policy-v1", [bytes]); } +export function loadBoundPolicy(bytes) { + const raw = Buffer.from(bytes), value = parseBoundedObject(raw, { maximumBytes: 262_144, maximumDepth: 8, label: "bound policy" }); + const policy = validateBoundPolicy(value), canonical = canonicalBoundPolicyBytes(policy); + if (!canonical.equals(raw)) fail("policy is not canonical"); + return Object.freeze({ policy, bytes: raw, revision: boundPolicyRevision(policy) }); +} + +const ROLE = /^(?:recipe|policy|instruction:[a-z0-9]+(?:-[a-z0-9]+)*|dispatch:iv1-sha256:[a-f0-9]{64}|output:iv1-sha256:[a-f0-9]{64}:[a-f0-9]{16})$/u; +export function validateArtifactRole(role) { if (typeof role !== "string" || !ROLE.test(role)) throw Object.assign(new Error("Loop artifact: invalid role"), { code: "ELOOP_ARTIFACT" }); return role; } + +export function validateArtifactBytes({ role, bytes, recipe, policy, run }) { + const raw = Buffer.from(bytes); validateArtifactRole(role); + if (role === "recipe") { + const frozenRecipe = loadFrozenRecipe(raw); + return { schema: "burnlist-loop-frozen@1", mediaType: "application/json", revision: frozenRecipe.revisions.executable, value: frozenRecipe }; + } + if (role === "policy") { + const loaded = loadBoundPolicy(raw); + if (recipe && loaded.policy.recipeRevision !== recipe.revisions.executable) fail("policy recipe binding mismatch"); + return { schema: "burnlist-loop-bound-policy@1", mediaType: "application/json", revision: loaded.revision, value: loaded.policy }; + } + if (role.startsWith("instruction:")) { + const id = role.slice("instruction:".length), expected = recipe?.instructions?.find((entry) => entry.id === id); + if (!expected || rawSha256(raw) !== expected.digest) throw Object.assign(new Error("Loop artifact: instruction does not match frozen recipe"), { code: "ELOOP_ARTIFACT" }); + return { schema: "burnlist-loop-instruction@1", mediaType: "text/markdown", revision: expected.digest, value: raw }; + } + if (role.startsWith("output:")) return { schema: "burnlist-loop-output-chunk@1", mediaType: "application/octet-stream", revision: rawSha256(raw), value: raw }; + const dispatch = validateDispatchAuthority(raw); + if (role !== `dispatch:${dispatch.value.invocationId}` || recipe && dispatch.value.recipeRevision !== recipe.revisions.executable + || policy && dispatch.value.policyRevision !== boundPolicyRevision(policy) + || run && (dispatch.value.runId !== run.runId || dispatch.value.assignmentId !== run.assignmentId + || dispatch.value.itemRevision !== run.itemRevision || !run.ownerClaim + || dispatch.value.claimId !== run.ownerClaim.claimId || dispatch.value.nodeId !== run.ownerClaim.nodeId + || dispatch.value.attempt !== run.ownerClaim.attempt || dispatch.value.inputCandidate !== run.ownerClaim.inputCandidate)) + throw Object.assign(new Error("Loop artifact: dispatch authority binding mismatch"), { code: "ELOOP_ARTIFACT" }); + return { schema: "burnlist-loop-dispatch-authority@1", mediaType: "application/json", revision: dispatch.digest, value: dispatch.value }; +} diff --git a/src/loops/run/run-claim.mjs b/src/loops/run/run-claim.mjs new file mode 100644 index 00000000..1dc2c7ed --- /dev/null +++ b/src/loops/run/run-claim.mjs @@ -0,0 +1,19 @@ +import { prefixed } from "../dsl/hash.mjs"; +import { RUN_ID, fail } from "./run-codec.mjs"; + +const SLUG = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u; +const ASSIGNMENT = /^as1-sha256:[a-f0-9]{64}$/u; +const CANDIDATE = /^cm1-sha256:[a-f0-9]{64}$/u; + +export function ownerClaimId({ runId, nodeId, attempt, assignmentId, inputCandidate }) { + if (!RUN_ID.test(runId) || !SLUG.test(nodeId) || !Number.isInteger(attempt) || attempt < 1 || attempt > 100 + || !ASSIGNMENT.test(assignmentId) || !CANDIDATE.test(inputCandidate)) fail("invalid owner claim identity"); + return prefixed("cl1-sha256:", "claim-v1", [Buffer.from(runId), Buffer.from(nodeId), Buffer.from(String(attempt)), Buffer.from(assignmentId), Buffer.from(inputCandidate)]); +} + +export function createOwnerClaim(value) { + const claimId = ownerClaimId(value); + if (value.claimId !== undefined && value.claimId !== claimId) fail("fabricated owner claim id"); + return Object.freeze({ schema: "burnlist-loop-owner-claim@1", runId: value.runId, claimId, nodeId: value.nodeId, + attempt: value.attempt, assignmentId: value.assignmentId, inputCandidate: value.inputCandidate }); +} diff --git a/src/loops/run/run-clock.mjs b/src/loops/run/run-clock.mjs new file mode 100644 index 00000000..6c4b9bec --- /dev/null +++ b/src/loops/run/run-clock.mjs @@ -0,0 +1,7 @@ +export function processStartWall({ nowMilliseconds, uptimeSeconds }) { + if (!Number.isFinite(nowMilliseconds) || nowMilliseconds < 0 + || !Number.isFinite(uptimeSeconds) || uptimeSeconds < 0) { + throw Object.assign(new Error("Loop clock: invalid process-start inputs"), { code: "ELOOP_BUDGET" }); + } + return Math.max(0, Math.round(nowMilliseconds - (uptimeSeconds * 1_000))); +} diff --git a/src/loops/run/run-clock.test.mjs b/src/loops/run/run-clock.test.mjs new file mode 100644 index 00000000..f6ecfe87 --- /dev/null +++ b/src/loops/run/run-clock.test.mjs @@ -0,0 +1,20 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { processStartWall } from "./run-clock.mjs"; + +test("process start wall is stable across delayed module observation", () => { + assert.equal(processStartWall({ nowMilliseconds: 10_000, uptimeSeconds: 5 }), 5_000); + assert.equal(processStartWall({ nowMilliseconds: 20_000, uptimeSeconds: 15 }), 5_000); + assert.equal(processStartWall({ nowMilliseconds: 10_000, uptimeSeconds: 5.0006 }), 4_999); +}); + +test("process start wall rejects non-canonical inputs", () => { + assert.throws(() => processStartWall({ nowMilliseconds: -1, uptimeSeconds: 0 }), /invalid/u); + assert.throws(() => processStartWall({ nowMilliseconds: 1, uptimeSeconds: Number.NaN }), /invalid/u); +}); + +test("elapsed authority stays in journal budgets, not a process-local clock sample", async () => { + const { readFile } = await import("node:fs/promises"); + const source = await readFile(new URL("./run-clock.mjs", import.meta.url), "utf8"); + assert.doesNotMatch(source, /clockSample|processClock/u); +}); diff --git a/src/loops/run/run-codec.mjs b/src/loops/run/run-codec.mjs new file mode 100644 index 00000000..12044121 --- /dev/null +++ b/src/loops/run/run-codec.mjs @@ -0,0 +1,150 @@ +import { randomBytes } from "node:crypto"; +import { closeSync, constants, fstatSync, fsyncSync, lstatSync, openSync, readFileSync } from "node:fs"; +import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; +import { rawSha256 } from "../dsl/hash.mjs"; +import { RUN_REF } from "./run-ref.mjs"; + +// A 128-bit ULID has two unused high bits in its 26-character base32 form. +// Reject noncanonical overflow spellings rather than giving them a path. +export const RUN_ID = RUN_REF; +export const ARTIFACT = /^artifact:sha256:[a-f0-9]{64}$/u; +export const RAW_DIGEST = /^sha256:[a-f0-9]{64}$/u; +export const MAX_ARTIFACT_BYTES = 1024 * 1024; +export const MAX_RECORD_BYTES = 64 * 1024; +export const MAX_RECORDS = 256; +export const MAX_RUN_JOURNAL_BYTES = 4 * 1024 * 1024; +export const MAX_RUN_ARTIFACT_BYTES = 8 * 1024 * 1024; +export const MAX_RUN_ARTIFACTS = 128; +export const MAX_RUNS = 128; +export const MAX_CATALOG_JOURNAL_BYTES = 32 * 1024 * 1024; +export const MAX_GLOBAL_ARTIFACTS = 1024; +export const MAX_GLOBAL_ARTIFACT_BYTES = 64 * 1024 * 1024; + +const BASE32 = "0123456789abcdefghjkmnpqrstvwxyz"; +const SCHEMA = /^[a-z][a-z0-9-]{0,95}@[1-9][0-9]*$/u; +const TYPE = /^[a-z][a-z0-9-]{0,63}$/u; + +export function fail(message, code = "ELOOP_RUN_STORE") { + throw Object.assign(new Error(`Loop Run store: ${message}`), { code }); +} + +export function exact(value, keys) { + return Boolean(value) && typeof value === "object" && !Array.isArray(value) + && Object.keys(value).length === keys.length && keys.every((key, index) => Object.keys(value)[index] === key); +} + +export function validateRunId(value) { + if (!RUN_ID.test(value)) fail("invalid RunRef"); + return value; +} + +export function artifactDigest(bytes) { + return `artifact:${rawSha256(bytes)}`; +} + +export function artifactFileName(digest) { + if (!ARTIFACT.test(digest)) fail("invalid artifact digest"); + return digest.slice("artifact:sha256:".length); +} + +export function runDirectoryName(runId) { return validateRunId(runId).slice(4); } + +export function newRunId({ now = Date.now, random = randomBytes } = {}) { + const milliseconds = now(); + if (!Number.isSafeInteger(milliseconds) || milliseconds < 0 || milliseconds >= 2 ** 48) fail("invalid RunRef clock"); + const bytes = Buffer.alloc(16); + bytes.writeUIntBE(milliseconds, 0, 6); + const entropy = Buffer.from(random(10)); + if (entropy.length !== 10) fail("invalid RunRef entropy"); + entropy.copy(bytes, 6); + let value = 0n; + for (const byte of bytes) value = (value << 8n) | BigInt(byte); + let encoded = ""; + for (let index = 0; index < 26; index += 1) { encoded = BASE32[Number(value & 31n)] + encoded; value >>= 5n; } + return `run:${encoded}`; +} + +function canonicalValue(value, depth = 0) { + if (depth > 8) fail("journal payload exceeds depth"); + if (value === null || typeof value === "boolean" || typeof value === "string") return value; + if (typeof value === "number") { if (!Number.isSafeInteger(value) || value < 0) fail("journal payload has invalid number"); return value; } + if (Array.isArray(value)) { + if (value.length > 256) fail("journal payload has too many array values"); + return value.map((item) => canonicalValue(item, depth + 1)); + } + if (!value || typeof value !== "object" || Object.getPrototypeOf(value) !== Object.prototype) fail("journal payload is not JSON data"); + const keys = Object.keys(value).sort((left, right) => Buffer.compare(Buffer.from(left), Buffer.from(right))); + if (keys.length > 128 || keys.some((key) => !key || Buffer.byteLength(key) > 128 || /[\0\r\n]/u.test(key))) fail("journal payload has invalid key"); + return Object.fromEntries(keys.map((key) => [key, canonicalValue(value[key], depth + 1)])); +} + +export function canonicalPayload(value) { + const canonical = canonicalValue(value); + const bytes = Buffer.from(JSON.stringify(canonical), "utf8"); + if (bytes.length > 32 * 1024) fail("journal payload exceeds bounds"); + return canonical; +} + +export function validateArtifactDescriptor(value) { + if (!exact(value, ["role", "digest", "schema", "mediaType", "byteLength", "revision"]) + || typeof value.role !== "string" || Buffer.byteLength(value.role) > 128 + || !ARTIFACT.test(value.digest) || typeof value.schema !== "string" || !SCHEMA.test(value.schema) + || typeof value.mediaType !== "string" || !/^[a-z][a-z0-9.+-]{0,63}\/[a-z0-9.+-]{1,127}$/u.test(value.mediaType) + || !Number.isSafeInteger(value.byteLength) || value.byteLength < 0 || value.byteLength > MAX_ARTIFACT_BYTES + || typeof value.revision !== "string" || !/^(?:(?:er1|bp1|iv1)-sha256|sha256):[a-f0-9]{64}$/u.test(value.revision)) fail("invalid artifact descriptor"); + return Object.freeze({ role: value.role, digest: value.digest, schema: value.schema, mediaType: value.mediaType, byteLength: value.byteLength, revision: value.revision }); +} + +export function canonicalArtifactDescriptors(values) { + if (!Array.isArray(values) || values.length > 128) fail("invalid artifact descriptor list"); + const descriptors = values.map(validateArtifactDescriptor).sort((left, right) => Buffer.compare(Buffer.from(left.role), Buffer.from(right.role))); + if (descriptors.some((item, index) => index && item.role === descriptors[index - 1].role)) fail("duplicate artifact role"); + return Object.freeze(descriptors); +} + +export function validateRecordType(value) { if (typeof value !== "string" || !TYPE.test(value)) fail("invalid journal record type"); return value; } + +export function assertContained(root, target) { + const parent = resolve(root), child = resolve(target), path = relative(parent, child); + if (path === "" || (path !== ".." && !path.startsWith(`..${sep}`) && !isAbsolute(path))) return child; + fail("state path escapes the Loop store"); +} + +export function assertDirectory(path, label = "directory") { + const entry = lstatSync(path); + if (!entry.isDirectory() || entry.isSymbolicLink() || (entry.mode & 0o077) !== 0) fail(`${label} is not a private real directory`); + return entry; +} + +/** Descriptor-bound binary read with no-follow and stable identity checks. */ +export function readBoundedFile(path, maximum, label) { + if (!Number.isSafeInteger(maximum) || maximum < 0) fail("invalid byte limit"); + const before = lstatSync(path); + if (!before.isFile() || before.isSymbolicLink() || before.size > maximum || (before.mode & 0o077) !== 0) fail(`${label} is not a private bounded regular file`); + let fd; + try { + const noFollow = Number.isInteger(constants.O_NOFOLLOW) ? constants.O_NOFOLLOW : 0; + try { fd = openSync(path, constants.O_RDONLY | noFollow); } + catch (error) { + if (error?.code === "ELOOP") fail(`${label} is a symbolic link`); + if (!noFollow || !["EINVAL", "ENOTSUP", "EOPNOTSUPP"].includes(error?.code)) throw error; + fd = openSync(path, constants.O_RDONLY); + } + const opened = fstatSync(fd); + if (!opened.isFile() || opened.size > maximum || opened.dev !== before.dev || opened.ino !== before.ino) fail(`${label} changed while opening`); + const bytes = readFileSync(fd); + const completed = fstatSync(fd), after = lstatSync(path); + if (bytes.length > maximum || completed.dev !== opened.dev || completed.ino !== opened.ino || completed.size !== opened.size + || after.isSymbolicLink() || after.dev !== opened.dev || after.ino !== opened.ino || after.size !== opened.size) fail(`${label} changed while reading`); + return bytes; + } finally { if (fd !== undefined) closeSync(fd); } +} + +export function fsyncFile(path) { const fd = openSync(path, constants.O_RDONLY); try { fsyncSync(fd); } finally { closeSync(fd); } } +export function fsyncDirectory(path) { fsyncFile(path); } +export function journalName(sequence) { if (!Number.isSafeInteger(sequence) || sequence < 1 || sequence > MAX_RECORDS) fail("invalid journal sequence"); return `${String(sequence).padStart(16, "0")}.json`; } +export function journalSequence(name) { const match = /^(\d{16})\.json$/u.exec(name); if (!match) return null; const value = Number(match[1]); return value >= 1 && value <= MAX_RECORDS ? value : null; } +export function statePath(root, ...segments) { return assertContained(root, join(root, ...segments)); } +export function cleanTempName(name) { return /^\.[a-z0-9-]{1,96}\.[a-f0-9]{16,64}\.tmp$/u.test(name); } +export function parentPath(path) { return dirname(path); } +export function fileBase(path) { return basename(path); } diff --git a/src/loops/run/run-created.mjs b/src/loops/run/run-created.mjs new file mode 100644 index 00000000..437c1df9 --- /dev/null +++ b/src/loops/run/run-created.mjs @@ -0,0 +1,20 @@ +import { validateClockSample } from "./budgets.mjs"; +import { validateRunId, fail } from "./run-codec.mjs"; + +const RECIPE = /^er1-sha256:[a-f0-9]{64}$/u; +const POLICY = /^bp1-sha256:[a-f0-9]{64}$/u; +const ASSIGNMENT = /^as1-sha256:[a-f0-9]{64}$/u; +const ITEM = /^item:[0-9]{6}-[0-9]{3}#[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/u; +const ITEM_DIGEST = /^id1-sha256:[a-f0-9]{64}$/u; + +export function creationPayload({ runId, assignmentId, itemRef, itemRevision, recipeRevision, policyRevision, descriptors, clock }) { + if (!validateRunId(runId) || !ASSIGNMENT.test(assignmentId) || !ITEM.test(itemRef) || !ITEM_DIGEST.test(itemRevision) + || !RECIPE.test(recipeRevision) || !POLICY.test(policyRevision)) fail("invalid Run creation identity"); + return { + schema: "burnlist-loop-run-created@1", runId, assignmentId, itemRef, itemRevision, recipeRevision, policyRevision, + recipeArtifact: descriptors.find((item) => item.role === "recipe").digest, + policyArtifact: descriptors.find((item) => item.role === "policy").digest, + instructionArtifacts: descriptors.filter((item) => item.role.startsWith("instruction:")).map((item) => item.digest), + clock: validateClockSample(clock), state: "prepared", + }; +} diff --git a/src/loops/run/run-fold.mjs b/src/loops/run/run-fold.mjs new file mode 100644 index 00000000..84289163 --- /dev/null +++ b/src/loops/run/run-fold.mjs @@ -0,0 +1,15 @@ +import { createHash } from "node:crypto"; +import { isRunRef } from "./run-ref.mjs"; +import { foldStateMachine, validateGraph } from "./state-machine.mjs"; + +const exact = (value, keys) => Boolean(value) && typeof value === "object" && !Array.isArray(value) && Object.keys(value).length === keys.length && keys.every((key) => Object.hasOwn(value, key)); +const fail = (message) => { throw new Error(`Run fold: ${message}`); }; +export function foldRun(records) { + const first = records?.[0]?.value; + if (!first || first.type !== "run-created" || !exact(first.payload, ["schema", "runId", "itemRef", "graph", "authorityRequired"]) || first.payload.schema !== "burnlist-loop-m2-run@1" || !isRunRef(first.payload.runId) || typeof first.payload.itemRef !== "string" || typeof first.payload.authorityRequired !== "boolean") fail("invalid creation"); + validateGraph(first.payload.graph); + const execution = foldStateMachine({ graph: first.payload.graph, records }), last = records.at(-1); + const projection = Object.freeze({ schema: "burnlist-loop-m2-projection@1", runId: first.payload.runId, itemRef: first.payload.itemRef, state: execution.state, currentNode: execution.nodeId, attempt: execution.attempt, cycle: execution.cycle, generation: execution.generation, leaseHeld: Boolean(execution.lease), counters: execution.budget.counters, journal: execution.budget.journal, latestMaker: execution.latest.maker, latestCheck: execution.latest.check, latestReviewer: execution.latest.reviewer, sequence: last.value.sequence, journalDigest: last.digest }); + const bytes = Buffer.from(`${JSON.stringify(projection)}\n`), revision = `sha256:${createHash("sha256").update(bytes).digest("hex")}`; + return Object.freeze({ graph: first.payload.graph, execution, projection, bytes, revision }); +} diff --git a/src/loops/run/run-fold.test.mjs b/src/loops/run/run-fold.test.mjs new file mode 100644 index 00000000..b959f93a --- /dev/null +++ b/src/loops/run/run-fold.test.mjs @@ -0,0 +1,17 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createJournalRecord } from "./run-journal.mjs"; +import { foldRun } from "./run-fold.mjs"; +import { created, testGraph, testRunId } from "./m2-test-fixtures.mjs"; + +const add = (records, type, payload, at = records.length) => [...records, createJournalRecord({ sequence: records.length + 1, prevDigest: records.at(-1)?.digest ?? null, at, type, payload })]; +const start = () => [createJournalRecord({ sequence: 1, prevDigest: null, at: 0, type: "run-created", payload: created() })]; +test("fold validates canonical RunRef, result bytes, and lifecycle terminal authority", () => { + const bad = { ...created(), runId: "run:not-a-run-ref" }; + assert.throws(() => foldRun([createJournalRecord({ sequence: 1, prevDigest: null, at: 0, type: "run-created", payload: bad })]), /creation/u); + let records = start(); records = add(records, "state-changed", { from: "prepared", to: "running", cause: "control" }); records = add(records, "lease-acquired", { generation: 1, token: "a".repeat(64) }); + assert.throws(() => foldRun(add(records, "state-changed", { from: "running", to: "converged", cause: "graph" })), /bypass/u); + records = add(records, "node-started", { nodeId: "implement", attempt: 1 }); records = add(records, "invocation-started", { nodeId: "implement", attempt: 1, invocationId: "b".repeat(32) }); + assert.throws(() => foldRun(add(records, "invocation-result", { invocationId: "b".repeat(32), kind: "complete", summary: "x".repeat(1025), outputBytes: 0, candidateId: null })), /result/u); + assert.equal(foldRun(records).graph, testGraph); +}); diff --git a/src/loops/run/run-journal.mjs b/src/loops/run/run-journal.mjs new file mode 100644 index 00000000..f9364008 --- /dev/null +++ b/src/loops/run/run-journal.mjs @@ -0,0 +1,51 @@ +import { createHash, randomBytes } from "node:crypto"; +import { closeSync, constants, fsyncSync, linkSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, rmSync, unlinkSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +export const MAX_JOURNAL_RECORDS = 256; +const MAX_RECORDS = MAX_JOURNAL_RECORDS, MAX_BYTES = 4 * 1024 * 1024, MAX_RECORD = 131072; +const TYPES = new Set(["run-created", "lease-acquired", "lease-released", "lease-revoked", "state-changed", "node-started", "invocation-started", "invocation-result", "candidate-bound", "system-outcome", "terminal-node-committed", "failure-routed", "edge-taken"]); +const names = { "run-created": ["schema", "runId", "itemRef", "graph", "authorityRequired"], "lease-acquired": ["generation", "token"], "lease-released": ["generation", "token"], "lease-revoked": ["generation", "token"], "state-changed": ["from", "to", "cause"], "node-started": ["nodeId", "attempt"], "invocation-started": ["nodeId", "attempt", "invocationId"], "system-outcome": ["kind", "summary"], "terminal-node-committed": ["kind", "summary", "from", "to", "nodeId", "attempt"], "invocation-result": ["invocationId", "kind", "summary", "outputBytes", "candidateId"], "candidate-bound": ["candidateId", "candidateContext"], "failure-routed": ["from", "kind", "to"], "edge-taken": ["from", "on", "to"] }; +const fileName = (sequence) => `${String(sequence).padStart(16, "0")}.json`; +const tempName = /^\.append-[a-f0-9]{16}\.tmp$/u; +const fail = (message, code = "EJOURNAL") => { throw Object.assign(new Error(`Run journal: ${message}`), { code }); }; +const exact = (value, keys) => Boolean(value) && typeof value === "object" && !Array.isArray(value) && Object.keys(value).length === keys.length && keys.every((key) => Object.hasOwn(value, key)); +const digest = (bytes) => `sha256:${createHash("sha256").update(bytes).digest("hex")}`; + +export function validateJournalEvent(type, payload) { if (!TYPES.has(type) || !exact(payload, names[type])) fail("event payload is not closed"); return payload; } +export function createJournalRecord({ sequence, prevDigest, at, type, payload }) { + if (!Number.isSafeInteger(sequence) || sequence < 1 || !Number.isSafeInteger(at) || at < 0 || !(prevDigest === null || /^sha256:[a-f0-9]{64}$/u.test(prevDigest))) fail("invalid record header"); + validateJournalEvent(type, payload); + const value = { schema: "burnlist-loop-m2-journal@1", sequence, prevDigest, at, type, payload }, bytes = Buffer.from(`${JSON.stringify(value)}\n`); + if (bytes.length > MAX_RECORD || !exact(value, ["schema", "sequence", "prevDigest", "at", "type", "payload"])) fail("record exceeds bounds"); + return Object.freeze({ value: Object.freeze(value), bytes, digest: digest(bytes) }); +} +export function parseJournalRecord(bytes) { + let value; try { value = JSON.parse(Buffer.from(bytes).toString("utf8")); } catch { fail("record is not JSON"); } + if (!exact(value, ["schema", "sequence", "prevDigest", "at", "type", "payload"]) || value.schema !== "burnlist-loop-m2-journal@1") fail("record is not closed"); + const record = createJournalRecord(value); if (!record.bytes.equals(bytes)) fail("record is not canonical"); return record; +} +export function readJournal(directory) { + const entries = readdirSync(directory, { withFileTypes: true }); if (entries.length > MAX_RECORDS + 1) fail("too many journal entries"); + const records = []; let temporary = 0, aggregate = 0; + for (const entry of entries) { + const path = join(directory, entry.name), stat = lstatSync(path); + if (tempName.test(entry.name)) { if (!entry.isFile() || entry.isSymbolicLink() || stat.size > MAX_RECORD || ++temporary > 1) fail("invalid temporary tail"); continue; } + const match = /^(\d{16})\.json$/u.exec(entry.name); if (!match || !entry.isFile() || entry.isSymbolicLink()) fail("invalid journal entry"); + aggregate += stat.size; if (stat.size < 2 || stat.size > MAX_RECORD || aggregate > MAX_BYTES) fail("journal exceeds bounds"); + records.push({ sequence: Number(match[1]), bytes: readFileSync(path) }); + } + if (!records.length || records.length > MAX_RECORDS) fail("journal is empty or too large"); records.sort((a, b) => a.sequence - b.sequence); + return Object.freeze(records.map((entry, index) => { if (entry.sequence !== index + 1) fail("sequence is discontinuous"); const record = parseJournalRecord(entry.bytes), prior = records[index - 1]?.record; if (record.value.sequence !== entry.sequence || record.value.prevDigest !== (prior?.digest ?? null)) fail("sequence or hash chain is invalid"); entry.record = record; return record; })); +} +function durableCreate(path, bytes) { let fd; try { fd = openSync(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW ?? 0), 0o600); writeFileSync(fd, bytes); fsyncSync(fd); } finally { if (fd !== undefined) closeSync(fd); } } +function syncDirectory(path) { const fd = openSync(path, constants.O_RDONLY); try { fsyncSync(fd); } finally { closeSync(fd); } } +export function writeInitialJournal({ runDirectory, payload, at }) { const directory = join(runDirectory, "journal"); mkdirSync(directory, { mode: 0o700 }); const record = createJournalRecord({ sequence: 1, prevDigest: null, at, type: "run-created", payload }); durableCreate(join(directory, fileName(1)), record.bytes); syncDirectory(directory); return record; } +export function appendJournalRecord({ journalDirectory, record, hooks = {} }) { + const existing = readJournal(journalDirectory), prior = existing.at(-1), aggregate = existing.reduce((total, item) => total + item.bytes.length, 0); + if (existing.length >= MAX_RECORDS || aggregate + record.bytes.length > MAX_BYTES) fail("prospective journal exceeds bounds"); + if (record.value.sequence !== prior.value.sequence + 1 || record.value.prevDigest !== prior.digest) fail("append precondition failed", "ESTALE"); + const temporary = join(journalDirectory, `.append-${randomBytes(8).toString("hex")}.tmp`), target = join(journalDirectory, fileName(record.value.sequence)); + try { durableCreate(temporary, record.bytes); hooks.beforePublish?.({ record, target }); linkSync(temporary, target); unlinkSync(temporary); syncDirectory(journalDirectory); return record; } finally { rmSync(temporary, { force: true }); } +} +/** Preflights the whole contiguous batch before publishing any member. */ diff --git a/src/loops/run/run-journal.test.mjs b/src/loops/run/run-journal.test.mjs new file mode 100644 index 00000000..1afcdd5f --- /dev/null +++ b/src/loops/run/run-journal.test.mjs @@ -0,0 +1,50 @@ +import assert from "node:assert/strict"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { appendJournalRecord, createJournalRecord, readJournal, writeInitialJournal } from "./run-journal.mjs"; +import { created } from "./m2-test-fixtures.mjs"; + +test("journal accepts one exact writer tail and rejects malformed storage", (t) => { + const root = mkdtempSync(join(os.tmpdir(), "m2-journal-")); t.after(() => rmSync(root, { recursive: true, force: true })); + writeInitialJournal({ runDirectory: root, at: 0, payload: created() }); const directory = join(root, "journal"); + writeFileSync(join(directory, ".append-0123456789abcdef.tmp"), "partial"); assert.equal(readJournal(directory).length, 1); + writeFileSync(join(directory, ".other.tmp"), "partial"); assert.throws(() => readJournal(directory), /invalid journal entry/u); + rmSync(join(directory, ".other.tmp")); writeFileSync(join(directory, ".append-fedcba9876543210.tmp"), "partial"); assert.throws(() => readJournal(directory), /temporary tail/u); +}); +test("journal bounds records before publication and retains a valid old tail", (t) => { + const root = mkdtempSync(join(os.tmpdir(), "m2-journal-")); t.after(() => rmSync(root, { recursive: true, force: true })); + writeInitialJournal({ runDirectory: root, at: 0, payload: created() }); const directory = join(root, "journal"), prior = readJournal(directory).at(-1); + const record = createJournalRecord({ sequence: 2, prevDigest: prior.digest, at: 1, type: "state-changed", payload: { from: "prepared", to: "running", cause: "control" } }); + assert.throws(() => appendJournalRecord({ journalDirectory: directory, record: { ...record, value: { ...record.value, sequence: 9 } } }), /append precondition/u); + assert.equal(readJournal(directory).length, 1); +}); +test("a canonical record whose sequence disagrees with its filename is rejected", (t) => { + const root = mkdtempSync(join(os.tmpdir(), "m2-journal-")); t.after(() => rmSync(root, { recursive: true, force: true })); + writeInitialJournal({ runDirectory: root, at: 0, payload: created() }); const directory = join(root, "journal"); + const first = readJournal(directory)[0]; + writeFileSync(join(directory, "0000000000000002.json"), first.bytes); + assert.throws(() => readJournal(directory), /sequence or hash chain/u); + rmSync(join(directory, "0000000000000002.json")); + assert.equal(readJournal(directory).length, 1); +}); +test("the 256th record remains replayable and the 257th is rejected before link", (t) => { + const root = mkdtempSync(join(os.tmpdir(), "m2-journal-")); t.after(() => rmSync(root, { recursive: true, force: true })); + writeInitialJournal({ runDirectory: root, at: 0, payload: created() }); const directory = join(root, "journal"); + for (let sequence = 2; sequence <= 256; sequence += 1) { + const prior = readJournal(directory).at(-1); + appendJournalRecord({ journalDirectory: directory, record: createJournalRecord({ sequence, prevDigest: prior.digest, at: sequence, type: "state-changed", payload: { from: "prepared", to: "running", cause: "control" } }) }); + } + assert.equal(readJournal(directory).length, 256); + const prior = readJournal(directory).at(-1); + assert.throws(() => appendJournalRecord({ journalDirectory: directory, record: createJournalRecord({ sequence: 257, prevDigest: prior.digest, at: 257, type: "state-changed", payload: { from: "prepared", to: "running", cause: "control" } }) }), /prospective journal/u); + assert.equal(readJournal(directory).length, 256); +}); +test("the final journal slot atomically retains a complete terminal outcome", (t) => { + const root = mkdtempSync(join(os.tmpdir(), "m2-journal-")); t.after(() => rmSync(root, { recursive: true, force: true })); + writeInitialJournal({ runDirectory: root, at: 0, payload: created() }); const directory = join(root, "journal"); + for (let sequence = 2; sequence <= 255; sequence += 1) { const prior = readJournal(directory).at(-1); appendJournalRecord({ journalDirectory: directory, record: createJournalRecord({ sequence, prevDigest: prior.digest, at: sequence, type: "state-changed", payload: { from: "prepared", to: "running", cause: "control" } }) }); } + const prior = readJournal(directory).at(-1), terminal = createJournalRecord({ sequence: 256, prevDigest: prior.digest, at: 256, type: "terminal-node-committed", payload: { kind: "exhausted", summary: "minutes", from: "running", to: "budget-exhausted", nodeId: "exhausted", attempt: 1 } }); + appendJournalRecord({ journalDirectory: directory, record: terminal }); assert.equal(readJournal(directory).length, 256); assert.equal(readJournal(directory).at(-1).value.type, "terminal-node-committed"); +}); diff --git a/src/loops/run/run-record.mjs b/src/loops/run/run-record.mjs new file mode 100644 index 00000000..743c79d8 --- /dev/null +++ b/src/loops/run/run-record.mjs @@ -0,0 +1,103 @@ +import { ARTIFACT, RAW_DIGEST, fail, validateRunId } from "./run-codec.mjs"; +import { validateClockSample } from "./budgets.mjs"; +import { clockAnomalyTransitionPayload, legacyTransitionPayload, stateTransitionPayload } from "./lifecycle.mjs"; +import { validateOperationIntent } from "./operation.mjs"; + +const CLAIM = /^cl1-sha256:[a-f0-9]{64}$/u; +const INVOCATION = /^iv1-sha256:[a-f0-9]{64}$/u; +const OPERATION = /^op1-sha256:[a-f0-9]{64}$/u; +const NODE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u; +const OUTCOMES = new Set(["pass", "fail", "reject", "approve", "complete", "escalate"]); +const SYSTEM_OUTCOMES = new Set(["error", "timeout", "cancelled", "lost", "exhausted"]); +const TERMINALS = new Set(["converged", "needs-human", "failed", "stopped", "budget-exhausted"]); +const TYPES = new Set(["run-created", "owner-claim-acquired", "owner-claim-released", "run-state-transition", + "state-transition", "clock-anomaly-transition", "clock-sampled", "node-entered", "spawn-intent", "output-captured", + "output-limit-exceeded", "edge-traversed", "graph-outcome", "system-outcome", "operation-intent", "operation-completed"]); + +function integer(value, minimum = 0, maximum = Number.MAX_SAFE_INTEGER) { + return Number.isSafeInteger(value) && value >= minimum && value <= maximum; +} +function exact(value, keys) { + return Boolean(value) && typeof value === "object" && !Array.isArray(value) + && Object.keys(value).length === keys.length && keys.every((key) => Object.hasOwn(value, key)); +} +function digest(value, prefix) { return new RegExp(`^${prefix}:[a-f0-9]{64}$`, "u").test(value); } +function noControls(value, maximum = 512) { + return typeof value === "string" && Buffer.byteLength(value) > 0 && Buffer.byteLength(value) <= maximum && !/[\0\r\n]/u.test(value); +} +function claim(value) { + if (!exact(value, ["schema", "runId", "claimId", "nodeId", "attempt", "assignmentId", "inputCandidate"]) + || value.schema !== "burnlist-loop-owner-claim@1" || !validateRunId(value.runId) || !CLAIM.test(value.claimId) + || !NODE.test(value.nodeId) || !integer(value.attempt, 1, 100) || !digest(value.assignmentId, "as1-sha256") + || !digest(value.inputCandidate, "cm1-sha256")) fail("invalid owner claim record"); +} +function created(value) { + if (!exact(value, ["schema", "runId", "assignmentId", "itemRef", "itemRevision", "recipeRevision", "policyRevision", + "recipeArtifact", "policyArtifact", "instructionArtifacts", "clock", "state"]) + || value.schema !== "burnlist-loop-run-created@1" || !validateRunId(value.runId) || !digest(value.assignmentId, "as1-sha256") + || !/^item:[0-9]{6}-[0-9]{3}#[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/u.test(value.itemRef) + || !digest(value.itemRevision, "id1-sha256") || !digest(value.recipeRevision, "er1-sha256") + || !digest(value.policyRevision, "bp1-sha256") || !ARTIFACT.test(value.recipeArtifact) || !ARTIFACT.test(value.policyArtifact) + || !Array.isArray(value.instructionArtifacts) || value.instructionArtifacts.length !== 2 + || value.instructionArtifacts.some((item) => !ARTIFACT.test(item)) || value.state !== "prepared") fail("invalid Run creation record"); + validateClockSample(value.clock); +} +function output(value) { + if (!exact(value, ["schema", "claimId", "invocationId", "byteLength", "digest", "artifact"]) + || value.schema !== "burnlist-loop-output-captured@1" || !CLAIM.test(value.claimId) || !INVOCATION.test(value.invocationId) + || !integer(value.byteLength, 1, 16_384) || !RAW_DIGEST.test(value.digest) || !ARTIFACT.test(value.artifact)) fail("invalid output capture record"); +} +function spawn(value) { + if (!exact(value, ["schema", "nodeId", "attempt", "claimId", "invocationId", "launchAuthorityDigest"]) + || value.schema !== "burnlist-loop-spawn-intent@1" || !NODE.test(value.nodeId) || !integer(value.attempt, 1, 100) + || !CLAIM.test(value.claimId) || !INVOCATION.test(value.invocationId) || !RAW_DIGEST.test(value.launchAuthorityDigest)) fail("invalid spawn intent record"); +} +function edge(value) { + if (!exact(value, ["schema", "from", "on", "to", "visit", "claimId", "invocationId"]) + || value.schema !== "burnlist-loop-edge-traversed@1" || !NODE.test(value.from) || !NODE.test(value.to) + || !OUTCOMES.has(value.on) || !integer(value.visit, 1, 100) + || !(value.claimId === null || CLAIM.test(value.claimId)) || !(value.invocationId === null || INVOCATION.test(value.invocationId))) fail("invalid edge traversal record"); +} + +/** Validates the closed Stage 1 journal union before any record can be hashed or replayed. */ +export function validateJournalEvent(type, payload, artifacts) { + if (!TYPES.has(type)) fail("unknown journal record type"); + if (!Array.isArray(artifacts)) fail("invalid journal artifact list"); + if (type === "run-created") created(payload); + else if (type === "owner-claim-acquired") claim(payload); + else if (type === "owner-claim-released") { + if (!exact(payload, ["schema", "runId", "claimId"]) || payload.schema !== "burnlist-loop-owner-claim-release@1" + || !validateRunId(payload.runId) || !CLAIM.test(payload.claimId)) fail("invalid owner claim release record"); + } else if (type === "run-state-transition") stateTransitionPayload(payload); + else if (type === "state-transition") legacyTransitionPayload(payload); + else if (type === "clock-anomaly-transition") clockAnomalyTransitionPayload(payload); + else if (type === "clock-sampled") validateClockSample(payload); + else if (type === "node-entered") { + if (!exact(payload, ["schema", "nodeId", "attempt", "claimId"]) || payload.schema !== "burnlist-loop-node-entered@1" + || !NODE.test(payload.nodeId) || !integer(payload.attempt, 1, 100) || !CLAIM.test(payload.claimId)) fail("invalid node entry record"); + } else if (type === "spawn-intent") spawn(payload); + else if (type === "output-captured") output(payload); + else if (type === "output-limit-exceeded") { + if (!exact(payload, ["schema", "claimId", "invocationId", "reason"]) + || payload.schema !== "burnlist-loop-output-limit-exceeded@1" || !CLAIM.test(payload.claimId) + || !INVOCATION.test(payload.invocationId) || !["output-bytes", "deadline"].includes(payload.reason)) fail("invalid output-limit record"); + } else if (type === "edge-traversed") edge(payload); + else if (type === "graph-outcome") { + if (!exact(payload, ["schema", "nodeId", "state"]) || payload.schema !== "burnlist-loop-graph-outcome@1" + || !NODE.test(payload.nodeId) || !TERMINALS.has(payload.state)) fail("invalid graph outcome record"); + } else if (type === "system-outcome") { + if (!exact(payload, ["schema", "outcome", "nodeId", "state", "reason"]) + || payload.schema !== "burnlist-loop-system-outcome@1" || !SYSTEM_OUTCOMES.has(payload.outcome) + || !NODE.test(payload.nodeId) || !TERMINALS.has(payload.state) || !noControls(payload.reason)) fail("invalid system outcome record"); + } else if (type === "operation-intent") validateOperationIntent(payload); + else if (type === "operation-completed") { + if (!exact(payload, ["schema", "operationId"]) || payload.schema !== "burnlist-loop-operation-completed@1" || !OPERATION.test(payload.operationId)) fail("invalid operation completion record"); + } + if (type === "run-created" ? artifacts.length !== 4 : type === "output-captured" ? artifacts.length !== 1 : artifacts.length !== 0) + fail("journal record has an invalid artifact set"); + if (type === "output-captured") { + const artifact = artifacts[0]; + if (artifact.digest !== payload.artifact || artifact.revision !== payload.digest || artifact.byteLength !== payload.byteLength + || artifact.schema !== "burnlist-loop-output-chunk@1" || artifact.mediaType !== "application/octet-stream") fail("output artifact record binding is invalid"); + } +} diff --git a/src/loops/run/run-ref.mjs b/src/loops/run/run-ref.mjs new file mode 100644 index 00000000..ec26a2a8 --- /dev/null +++ b/src/loops/run/run-ref.mjs @@ -0,0 +1,6 @@ +/** Canonical 128-bit RunRef encoded as 26 lowercase Crockford digits. */ +export const RUN_REF = /^run:[0-7][0-9a-hjkmnp-tv-z]{25}$/u; + +export function isRunRef(value) { + return typeof value === "string" && RUN_REF.test(value); +} diff --git a/src/loops/run/run-result.mjs b/src/loops/run/run-result.mjs new file mode 100644 index 00000000..7ba65cc8 --- /dev/null +++ b/src/loops/run/run-result.mjs @@ -0,0 +1,16 @@ +const SYSTEM = new Set(["error", "timeout", "cancelled", "lost", "exhausted"]); +const semantic = { + task: new Set(["complete"]), review: new Set(["approve", "reject", "escalate"]), check: new Set(["pass", "fail"]), +}; +const exact = (value, keys) => Boolean(value) && typeof value === "object" && !Array.isArray(value) && Object.keys(value).length === keys.length && keys.every((key) => Object.hasOwn(value, key)); +const fail = (message) => { throw Object.assign(new Error(`Run result: ${message}`), { code: "ERESULT" }); }; + +export function isSystemOutcome(kind) { return SYSTEM.has(kind); } +export function validateNormalizedResult(value, node, maximumOutputBytes) { + if (!exact(value, ["kind", "summary", "outputBytes", "candidateId"]) || typeof value.kind !== "string" || typeof value.summary !== "string" + || Buffer.byteLength(value.summary, "utf8") > 1024 || !Number.isSafeInteger(value.outputBytes) || value.outputBytes < 0 || value.outputBytes > maximumOutputBytes) fail("invalid normalized result"); + if (!(value.candidateId === null || /^cm1-sha256:[a-f0-9]{64}$/u.test(value.candidateId))) fail("invalid result candidate"); + const allowed = node?.kind === "agent" ? semantic[node.mode] : semantic[node?.kind]; + if (!SYSTEM.has(value.kind) && !allowed?.has(value.kind)) fail("outcome is not legal for node"); + return Object.freeze({ ...value }); +} diff --git a/src/loops/run/run-store.mjs b/src/loops/run/run-store.mjs new file mode 100644 index 00000000..cb7879bd --- /dev/null +++ b/src/loops/run/run-store.mjs @@ -0,0 +1,153 @@ +import { randomBytes } from "node:crypto"; +import { closeSync, constants, existsSync, fsyncSync, fstatSync, lstatSync, mkdirSync, openSync, readSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { withDirectoryLock } from "../../server/dir-lock.mjs"; +import { isRunRef } from "./run-ref.mjs"; +import { appendJournalRecord, createJournalRecord, MAX_JOURNAL_RECORDS, readJournal, writeInitialJournal } from "./run-journal.mjs"; +import { foldRun } from "./run-fold.mjs"; +import { atomicTerminalState, isTerminalState, validateGraph } from "./state-machine.mjs"; +import { parseBoundedObject } from "../contracts/contract.mjs"; +import { publishLoopProjectionInvalidation } from "../events/projection-events.mjs"; +import { currentRunAuthority } from "./current-authority.mjs"; +import { loadFrozenRecipe } from "../dsl/frozen.mjs"; + +const fail = (message, code = "ERUN_STORE") => { throw Object.assign(new Error(`Run store: ${message}`), { code }); }; +const runName = (id) => Buffer.from(id).toString("hex"); +export function runStore(repoRoot, { clock = () => Date.now(), random = randomBytes, hooks = {}, publishProjection = publishLoopProjectionInvalidation } = {}) { + const root = resolve(repoRoot), base = join(root, ".local", "burnlist", "loop", "m2"), runs = join(base, "runs"), now = () => { const value = clock(); if (!Number.isSafeInteger(value) || value < 0) fail("invalid clock"); return value; }; + const pathFor = (id) => join(runs, runName(id)), journalFor = (id) => join(pathFor(id), "journal"), lockFor = (id) => join(pathFor(id), ".lock"), proofPath = (id) => join(pathFor(id), ".recovery-proof"), authorityPath = (id) => join(pathFor(id), "dispatch-authority.json"), currentLock = join(base, ".current-runs.lock"), initialize = () => mkdirSync(runs, { recursive: true, mode: 0o700 }), currentAuthority = () => currentRunAuthority({ root, base, random }); + const assertId = (id) => { if (!isRunRef(id)) fail("invalid RunRef"); return id; }; + const locked = (id, fn) => { assertId(id); initialize(); return withDirectoryLock({ lockPath: lockFor(id), reclaimLiveAfterAge: false, errorFactory: () => fail("run is locked", "ELOCKED"), fn }); }; + const replay = (id) => { + assertId(id); + if (!existsSync(journalFor(id))) fail("run is missing", "ENOENT"); + const journal = readJournal(journalFor(id)), folded = foldRun(journal); + if (folded.projection.runId !== id) fail("run identity mismatch"); + let loopIdentity = Object.freeze({ loopId: folded.graph.id, loopRevision: null }); + if (existsSync(authorityPath(id))) { + try { + const authority = readAuthority(id), frozen = loadFrozenRecipe(Buffer.from(authority.frozenRecipe, "base64")); + if (authority.itemRef !== folded.projection.itemRef) fail("sealed authority item does not match Run journal", "EAUTHORITY"); + if (JSON.stringify(frozen.ir) !== JSON.stringify(folded.graph)) fail("sealed recipe does not match Run graph", "EAUTHORITY"); + loopIdentity = Object.freeze({ loopId: frozen.ir.id, loopRevision: frozen.revisions.executable }); + } catch (error) { + if (error?.code === "EAUTHORITY") throw error; + fail("sealed dispatch authority is corrupt", "EAUTHORITY"); + } + } else if (journal[0].value.payload.authorityRequired) fail("sealed dispatch authority is unavailable", "EAUTHORITY"); + return Object.freeze({ runId: id, journal, loopIdentity, ...folded }); + }; + const retainsTerminalReserve = (current, writes = 1) => current.projection.sequence + writes < MAX_JOURNAL_RECORDS; + const terminalKind = { converged: "converged", "needs-human": "lost", failed: "error", stopped: "cancelled", "budget-exhausted": "exhausted" }; + function prospective(id, current, type, payload, at = now()) { + if (!retainsTerminalReserve(current)) fail("journal terminal reserve is required", "EJOURNAL"); + const record = createJournalRecord({ sequence: current.projection.sequence + 1, prevDigest: current.projection.journalDigest, at, type, payload }); + foldRun([...current.journal, record]); // reject poison before publication + appendJournalRecord({ journalDirectory: journalFor(id), record }); + return Object.freeze({ record, ...replay(id) }); + } + function assertLease(current, lease) { const held = current.execution.lease; if (!lease || !held || lease.generation !== held.generation || lease.token !== held.token) fail("stale lease", "ESTALE_LEASE"); } + function syncDirectory(path) { const fd = openSync(path, constants.O_RDONLY); try { fsyncSync(fd); } finally { closeSync(fd); } } + function syncParent(id) { syncDirectory(pathFor(id)); } + function writeRecoveryProof(id, value) { const checked = { schema: "burnlist-loop-m2-recovery-proof@1", runId: id, generation: value.generation, token: value.token, recoveryProof: value.recoveryProof }, bytes = Buffer.from(`${JSON.stringify(checked)}\n`), temporary = `${proofPath(id)}.${random(8).toString("hex")}.tmp`; if (bytes.length > 1024) fail("recovery proof exceeds bounds"); let fd; try { fd = openSync(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW ?? 0), 0o600); writeFileSync(fd, bytes); fsyncSync(fd); closeSync(fd); fd = undefined; renameSync(temporary, proofPath(id)); syncParent(id); } finally { if (fd !== undefined) closeSync(fd); rmSync(temporary, { force: true }); } } + function readRecoveryProof(id) { let fd; try { const path = proofPath(id), entry = lstatSync(path); if (!entry.isFile() || entry.isSymbolicLink()) fail("recovery proof is corrupt"); fd = openSync(path, constants.O_RDONLY | constants.O_NONBLOCK | (constants.O_NOFOLLOW ?? 0)); const before = fstatSync(fd); if (!before.isFile() || (before.mode & 0o777) !== 0o600 || before.size < 2 || before.size > 1024) fail("recovery proof is corrupt"); const bytes = Buffer.alloc(before.size); if (readSync(fd, bytes, 0, bytes.length, 0) !== bytes.length) fail("recovery proof changed while reading"); const after = fstatSync(fd); if (!after.isFile() || before.dev !== after.dev || before.ino !== after.ino || before.size !== after.size) fail("recovery proof changed while reading"); const value = JSON.parse(bytes.toString("utf8")); if (!value || Object.keys(value).length !== 5 || value.schema !== "burnlist-loop-m2-recovery-proof@1" || value.runId !== id || !Number.isSafeInteger(value.generation) || !/^[a-f0-9]{64}$/u.test(value.token) || !/^[a-f0-9]{64}$/u.test(value.recoveryProof)) fail("recovery proof is corrupt"); return value; } catch (error) { if (error?.code === "ENOENT") fail("lost-owner proof is unavailable", "ELOST_PROOF"); throw error; } finally { if (fd !== undefined) closeSync(fd); } } + function clearRecoveryProof(id) { rmSync(proofPath(id), { force: true }); syncParent(id); } + function sealedAuthority(id, value) { + const keys = ["schema", "runId", "assignmentId", "itemRef", "itemRevision", "itemText", "frozenRecipe", "policy"]; + if (!value || typeof value !== "object" || Array.isArray(value) || Object.keys(value).length !== keys.length || !keys.every((key, index) => Object.keys(value)[index] === key) + || value.schema !== "burnlist-loop-m12-run-authority@1" || value.runId !== id || !/^as1-sha256:[a-f0-9]{64}$/u.test(value.assignmentId) + || !/^id1-sha256:[a-f0-9]{64}$/u.test(value.itemRevision) || typeof value.itemRef !== "string" || !value.itemRef + || typeof value.itemText !== "string" || !value.itemText || Buffer.byteLength(value.itemText) > 65_536 + || typeof value.frozenRecipe !== "string" || typeof value.policy !== "string") fail("invalid sealed dispatch authority", "EAUTHORITY"); + for (const field of ["frozenRecipe", "policy"]) { + const bytes = Buffer.from(value[field], "base64"); + if (!bytes.length || bytes.length > 262_144 || bytes.toString("base64") !== value[field]) fail("invalid sealed dispatch authority", "EAUTHORITY"); + } + const bytes = Buffer.from(`${JSON.stringify(value)}\n`); + if (bytes.length > 700_000) fail("sealed dispatch authority exceeds bounds", "EAUTHORITY"); + return Object.freeze({ value: Object.freeze({ ...value }), bytes }); + } + function writeAuthorityAt(directory, id, value) { + const sealed = sealedAuthority(id, value), target = join(directory, "dispatch-authority.json"), temporary = `${target}.${random(8).toString("hex")}.tmp`; + let fd; + try { fd = openSync(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW ?? 0), 0o600); writeFileSync(fd, sealed.bytes); fsyncSync(fd); closeSync(fd); fd = undefined; renameSync(temporary, target); syncDirectory(directory); } + finally { if (fd !== undefined) closeSync(fd); rmSync(temporary, { force: true }); } + } + function readAuthority(id) { + let fd; + try { + const target = authorityPath(id), entry = lstatSync(target); + if (!entry.isFile() || entry.isSymbolicLink() || (entry.mode & 0o777) !== 0o600 || entry.size < 2 || entry.size > 700_000) fail("sealed dispatch authority is corrupt", "EAUTHORITY"); + fd = openSync(target, constants.O_RDONLY | constants.O_NONBLOCK | (constants.O_NOFOLLOW ?? 0)); const before = fstatSync(fd); + if (!before.isFile() || before.dev !== entry.dev || before.ino !== entry.ino || before.size !== entry.size) fail("sealed dispatch authority changed while opening", "EAUTHORITY"); + const bytes = Buffer.alloc(before.size); if (readSync(fd, bytes, 0, bytes.length, 0) !== bytes.length) fail("sealed dispatch authority changed while reading", "EAUTHORITY"); + const after = fstatSync(fd), linked = lstatSync(target); if (after.dev !== before.dev || after.ino !== before.ino || after.size !== before.size || linked.isSymbolicLink() || linked.dev !== before.dev || linked.ino !== before.ino || linked.size !== before.size) fail("sealed dispatch authority changed while reading", "EAUTHORITY"); + const parsed = parseBoundedObject(bytes, { maximumBytes: 700_000, maximumDepth: 3, label: "sealed dispatch authority" }); + const sealed = sealedAuthority(id, parsed); if (!sealed.bytes.equals(bytes)) fail("sealed dispatch authority is not canonical", "EAUTHORITY"); return sealed.value; + } catch (error) { + if (error?.code === "ENOENT") fail("sealed dispatch authority is unavailable", "EAUTHORITY"); + if (error?.code === "EAUTHORITY") throw error; + fail("sealed dispatch authority is corrupt", "EAUTHORITY"); + } + finally { if (fd !== undefined) closeSync(fd); } + } + function terminalizeCurrent(id, current, { kind = "exhausted", summary = "journal" } = {}, at = now()) { + if (current.projection.sequence >= MAX_JOURNAL_RECORDS) fail("journal has no terminal capacity", "EJOURNAL"); + const selected = current.execution.system ?? (isTerminalState(current.projection.state) ? { kind: terminalKind[current.projection.state], summary: "journal-cleanup" } : { kind, summary }), targetState = isTerminalState(current.projection.state) ? current.projection.state : atomicTerminalState(selected.kind), targetNode = selected.kind === "converged" ? current.graph.nodes.find((node) => node.kind === "terminal" && node.state === "converged")?.id : current.graph.failurePolicy[selected.kind], alreadyStarted = current.execution.nodeId === targetNode && current.execution.started, attempt = alreadyStarted ? current.execution.attempts[targetNode] : (current.execution.attempts[targetNode] ?? 0) + 1, record = createJournalRecord({ sequence: current.projection.sequence + 1, prevDigest: current.projection.journalDigest, at, type: "terminal-node-committed", payload: { kind: selected.kind, summary: selected.summary, from: current.projection.state, to: targetState, nodeId: targetNode, attempt } }); + foldRun([...current.journal, record]); appendJournalRecord({ journalDirectory: journalFor(id), record }); clearRecoveryProof(id); + return Object.freeze({ record, ...replay(id) }); + } + const append = (id, lease, type, payload) => locked(id, () => { const current = replay(id); assertLease(current, lease); const at = now(); if (current.projection.sequence >= MAX_JOURNAL_RECORDS) fail("journal has no terminal capacity", "EJOURNAL"); const candidate = createJournalRecord({ sequence: current.projection.sequence + 1, prevDigest: current.projection.journalDigest, at, type, payload }), folded = foldRun([...current.journal, candidate]); if (type === "state-changed" && isTerminalState(payload.to)) return terminalizeCurrent(id, current, { kind: terminalKind[payload.to], summary: payload.cause }, at); if (!retainsTerminalReserve(current)) return terminalizeCurrent(id, current, {}, at); if (["node-started", "invocation-started", "invocation-result", "edge-taken"].includes(type) && folded.execution.budget.elapsedMilliseconds >= current.graph.budget.maxMinutes * 60_000) return terminalizeCurrent(id, current, { summary: "minutes" }, at); appendJournalRecord({ journalDirectory: journalFor(id), record: candidate }); return Object.freeze({ record: candidate, ...replay(id) }); }); + const acquireLease = (id) => locked(id, () => { + let current = replay(id); if (current.execution.lease) fail("run already has a lease", "ELEASED"); if (isTerminalState(current.projection.state)) fail("run is terminal"); + const writes = ["prepared", "paused"].includes(current.projection.state) ? 2 : 1; if (!retainsTerminalReserve(current, writes)) return terminalizeCurrent(id, current); + if (["prepared", "paused"].includes(current.projection.state)) { current = prospective(id, current, "state-changed", { from: current.projection.state, to: "running", cause: "control" }); } + const lease = Object.freeze({ generation: current.execution.generation + 1, token: random(32).toString("hex") }), recoveryProof = random(32).toString("hex"); hooks.beforeProofPublish?.({ id, lease }); writeRecoveryProof(id, { generation: lease.generation, token: lease.token, recoveryProof }); hooks.afterProofPublish?.({ id, lease, recoveryProof }); try { prospective(id, current, "lease-acquired", lease); } catch (error) { clearRecoveryProof(id); throw error; } hooks.afterLeaseAppend?.({ id, lease, recoveryProof }); return Object.freeze({ lease, recoveryProof, ...replay(id) }); + }); + const releaseLease = (id, lease) => locked(id, () => { const current = replay(id); assertLease(current, lease); if (!retainsTerminalReserve(current)) return terminalizeCurrent(id, current); const result = prospective(id, current, "lease-released", { generation: lease?.generation, token: lease?.token }); clearRecoveryProof(id); return result; }); + const recoverLease = (id, proof) => locked(id, () => { const current = replay(id), expected = readRecoveryProof(id); if (!proof || proof.generation !== expected.generation || proof.recoveryProof !== expected.recoveryProof) fail("lost-owner proof is invalid", "ELOST_PROOF"); const held = current.execution.lease; if (!held || held.generation !== proof.generation || held.token !== expected.token) fail("owner generation changed", "ESTALE_LEASE"); if (!retainsTerminalReserve(current)) return terminalizeCurrent(id, current); const result = prospective(id, current, "lease-revoked", { generation: held.generation, token: held.token }); clearRecoveryProof(id); return result; }); + const terminalize = (id, lease, kind, summary) => locked(id, () => { const current = replay(id); assertLease(current, lease); return terminalizeCurrent(id, current, { kind, summary }); }); + function createRun({ runId, itemRef, graph, authority = null }) { + assertId(runId); if (typeof itemRef !== "string" || !itemRef || itemRef.length > 512) fail("invalid creation input"); validateGraph(graph); initialize(); + const target = pathFor(runId), staging = join(runs, `.create-${random(8).toString("hex")}.tmp`); + if (existsSync(target)) fail("run already exists", "EEXIST"); mkdirSync(staging, { recursive: false, mode: 0o700 }); + try { + const current = authority ? sealedAuthority(runId, authority).value : null; + if (authority) writeAuthorityAt(staging, runId, authority); + writeInitialJournal({ runDirectory: staging, at: now(), payload: { schema: "burnlist-loop-m2-run@1", runId, itemRef, graph, authorityRequired: Boolean(authority) } }); + syncDirectory(staging); + withDirectoryLock({ lockPath: currentLock, reclaimLiveAfterAge: false, errorFactory: () => fail("current Run binding is locked", "ELOCKED"), fn: () => { + if (existsSync(target)) fail("run already exists", "EEXIST"); + if (current) { + const entries = currentAuthority().read(), previous = entries.find((entry) => entry.itemRef === current.itemRef); + if (previous) { + if (previous.runId === runId && previous.assignmentId === current.assignmentId) { + // A cut after durable reservation but before directory rename is + // recovered only by the same sealed Run identity. + } else { + const prior = replay(previous.runId).projection; + if (!["failed", "stopped", "budget-exhausted", "needs-human"].includes(prior.state)) fail("current Run is still executable", "ECURRENT"); + currentAuthority().write([...entries.filter((entry) => entry.itemRef !== current.itemRef), { itemRef: current.itemRef, runId, assignmentId: current.assignmentId }]); + } + } else { + currentAuthority().write([...entries, { itemRef: current.itemRef, runId, assignmentId: current.assignmentId }]); + } + } + hooks.beforeRunPublish?.({ runId, staging, target }); renameSync(staging, target); syncDirectory(runs); + } }); + return replay(runId); + } catch (error) { rmSync(staging, { recursive: true, force: true }); throw error; } + } + // Publication is observational: commit and release the journal lock before notifying readers. + const published = (result) => { try { publishProjection(root, result); } catch {} return result; }; + return Object.freeze({ createRun: (...input) => published(createRun(...input)), replay, read: replay, + append: (...input) => published(append(...input)), acquireLease: (...input) => published(acquireLease(...input)), + releaseLease: (...input) => published(releaseLease(...input)), recoverLease: (...input) => published(recoverLease(...input)), + terminalize: (...input) => published(terminalize(...input)), list: () => { + if (!existsSync(runs)) return []; + const entries = readdirSync(runs, { withFileTypes: true }), staging = entries.filter((entry) => /^\.create-[a-f0-9]{16}\.tmp$/u.test(entry.name)), visible = entries.filter((entry) => !/^\.create-[a-f0-9]{16}\.tmp$/u.test(entry.name)); + if (staging.length > 128 || visible.length > 128 || entries.some((entry) => !entry.isDirectory() || !/^(?:[a-f0-9]+|\.create-[a-f0-9]{16}\.tmp)$/u.test(entry.name))) fail("run directory exceeds bounds", "EBOUNDS"); + return visible.sort((a, b) => a.name.localeCompare(b.name)) + .map((entry) => replay(Buffer.from(entry.name, "hex").toString("utf8")).projection); + }, readAuthority, readCurrentRun(itemRef) { if (!existsSync(base)) return null; const values = currentAuthority().read().filter((entry) => entry.itemRef === itemRef); if (values.length > 1) fail("current Run binding is ambiguous", "ECURRENT"); return values[0] ?? null; }, paths: Object.freeze({ base, runs, pathFor, journalFor, authorityPath, currentPath: join(base, "current-runs.json") }) }); +} diff --git a/src/loops/run/run-store.test.mjs b/src/loops/run/run-store.test.mjs new file mode 100644 index 00000000..81b0fd01 --- /dev/null +++ b/src/loops/run/run-store.test.mjs @@ -0,0 +1,117 @@ +import assert from "node:assert/strict"; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { runStore } from "./run-store.mjs"; +import { appendJournalRecord, createJournalRecord } from "./run-journal.mjs"; +import { testGraph, testRunId } from "./m2-test-fixtures.mjs"; +import { withDirectoryLock } from "../../server/dir-lock.mjs"; +import { readOvenEvents } from "../../events/oven-event-store.mjs"; +import { publishLoopProjectionInvalidation } from "../events/projection-events.mjs"; +import { presentRun } from "./read-projection.mjs"; + +function fixture(t) { const root = mkdtempSync(join(os.tmpdir(), "m2-store-")); t.after(() => rmSync(root, { recursive: true, force: true })); let at = 0; const store = runStore(root, { clock: () => at++ }); store.createRun({ runId: testRunId, itemRef: "item:260722-001#M2", graph: testGraph }); return store; } +test("Run creation stages private authority and journal until one directory rename", (t) => { + const root = mkdtempSync(join(os.tmpdir(), "m6-stage-")); t.after(() => rmSync(root, { recursive: true, force: true })); let observed; + const store = runStore(root, { hooks: { beforeRunPublish({ runId, target }) { + observed = { listed: runStore(root).list(), target: existsSync(target), runId }; + } } }); + store.createRun({ runId: testRunId, itemRef: "item:260722-001#M6", graph: testGraph }); + assert.deepEqual(observed, { listed: [], target: false, runId: testRunId }); assert.equal(store.read(testRunId).journal.length, 1); + const failed = runStore(root, { hooks: { beforeRunPublish() { throw new Error("crash-before-publish"); } } }); + const other = "run:01arz3ndektsv4rrffq69g5faw"; + assert.throws(() => failed.createRun({ runId: other, itemRef: "item:260722-001#M6", graph: testGraph }), /crash-before-publish/u); + assert.equal(runStore(root).list().length, 1); +}); +test("authority-less fixture Runs freeze that fact and retain a null Loop revision", (t) => { + const store = fixture(t), current = store.read(testRunId); + assert.equal(current.journal[0].value.payload.authorityRequired, false); + assert.deepEqual(current.loopIdentity, { loopId: testGraph.id, loopRevision: null }); +}); +test("pre-fold rejects poison, and lost-owner proof fences without reader takeover", (t) => { + const store = fixture(t), acquired = store.acquireLease(testRunId), one = acquired.lease; + assert.throws(() => store.append(testRunId, one, "state-changed", { from: "running", to: "converged", cause: "graph" }), /bypass/u); + assert.equal(store.replay(testRunId).journal.length, 3); + assert.throws(() => store.acquireLease(testRunId), { code: "ELEASED" }); + assert.throws(() => store.recoverLease(testRunId, { generation: one.generation, recoveryProof: "reader-does-not-know" }), { code: "ELOST_PROOF" }); + assert.throws(() => store.acquireLease(testRunId), { code: "ELEASED" }); + store.recoverLease(testRunId, { generation: one.generation, recoveryProof: acquired.recoveryProof }); + assert.throws(() => store.append(testRunId, one, "node-started", { nodeId: "implement", attempt: 1 }), { code: "ESTALE_LEASE" }); + const two = store.acquireLease(testRunId).lease; assert.equal(two.generation, 2); +}); +test("an authorized recovery proof survives store recreation but is not replay data", (t) => { + const root = mkdtempSync(join(os.tmpdir(), "m2-recover-")); t.after(() => rmSync(root, { recursive: true, force: true })); let at = 0; + const first = runStore(root, { clock: () => at++ }); first.createRun({ runId: testRunId, itemRef: "item:260722-001#M2", graph: testGraph }); const acquired = first.acquireLease(testRunId); + const second = runStore(root, { clock: () => at++ }); assert.equal(second.read(testRunId).projection.leaseHeld, true); + assert.throws(() => second.recoverLease(testRunId, { generation: acquired.lease.generation, recoveryProof: "0".repeat(64) }), { code: "ELOST_PROOF" }); + second.recoverLease(testRunId, { generation: acquired.lease.generation, recoveryProof: acquired.recoveryProof }); const replacement = second.acquireLease(testRunId); assert.equal(replacement.lease.generation, 2); assert.doesNotMatch(JSON.stringify(second.read(testRunId)), new RegExp(acquired.recoveryProof, "u")); second.releaseLease(testRunId, replacement.lease); assert.equal(existsSync(join(second.paths.pathFor(testRunId), ".recovery-proof")), false); +}); +test("proof sidecar rejects malformed private state and acquisition cuts remain recoverable", (t) => { + const root = mkdtempSync(join(os.tmpdir(), "m2-proof-")); t.after(() => rmSync(root, { recursive: true, force: true })); let at = 0; + const store = runStore(root, { clock: () => at++ }); store.createRun({ runId: testRunId, itemRef: "item:260722-001#M2", graph: testGraph }); const acquired = store.acquireLease(testRunId), proof = join(store.paths.pathFor(testRunId), ".recovery-proof"); + chmodSync(proof, 0o644); assert.throws(() => runStore(root).recoverLease(testRunId, { generation: acquired.lease.generation, recoveryProof: acquired.recoveryProof }), /proof is corrupt/u); + chmodSync(proof, 0o600); writeFileSync(proof, `${JSON.stringify({ schema: "burnlist-loop-m2-recovery-proof@1", runId: testRunId, generation: acquired.lease.generation, token: acquired.lease.token, recoveryProof: acquired.recoveryProof, extra: true })}\n`, { mode: 0o600 }); assert.throws(() => runStore(root).recoverLease(testRunId, { generation: acquired.lease.generation, recoveryProof: acquired.recoveryProof }), /proof is corrupt/u); + writeFileSync(proof, `${JSON.stringify({ schema: "burnlist-loop-m2-recovery-proof@1", runId: testRunId, generation: acquired.lease.generation, token: "0".repeat(64), recoveryProof: acquired.recoveryProof })}\n`, { mode: 0o600 }); assert.throws(() => runStore(root).recoverLease(testRunId, { generation: acquired.lease.generation, recoveryProof: acquired.recoveryProof }), /generation changed/u); + writeFileSync(proof, Buffer.alloc(1025), { mode: 0o600 }); assert.throws(() => runStore(root).recoverLease(testRunId, { generation: acquired.lease.generation, recoveryProof: acquired.recoveryProof }), /proof is corrupt/u); + writeFileSync(proof, "{}\n", { mode: 0o600 }); assert.throws(() => runStore(root).recoverLease(testRunId, { generation: acquired.lease.generation, recoveryProof: acquired.recoveryProof }), /proof is corrupt/u); + rmSync(proof); mkdirSync(proof, { mode: 0o600 }); assert.throws(() => runStore(root).recoverLease(testRunId, { generation: acquired.lease.generation, recoveryProof: acquired.recoveryProof }), /proof/u); rmSync(proof, { recursive: true }); symlinkSync("missing", proof); assert.throws(() => runStore(root).recoverLease(testRunId, { generation: acquired.lease.generation, recoveryProof: acquired.recoveryProof }), /ELOOP|proof/u); rmSync(proof); + const cutRoot = mkdtempSync(join(os.tmpdir(), "m2-proof-cut-")); t.after(() => rmSync(cutRoot, { recursive: true, force: true })); const setup = runStore(cutRoot); setup.createRun({ runId: testRunId, itemRef: "item:260722-001#M2", graph: testGraph }); + assert.throws(() => runStore(cutRoot, { hooks: { beforeProofPublish() { throw new Error("before-proof"); } } }).acquireLease(testRunId), /before-proof/u); assert.equal(runStore(cutRoot).read(testRunId).projection.leaseHeld, false); + assert.throws(() => runStore(cutRoot, { hooks: { afterProofPublish() { throw new Error("after-proof"); } } }).acquireLease(testRunId), /after-proof/u); assert.equal(runStore(cutRoot).acquireLease(testRunId).lease.generation, 1); + const leaseRoot = mkdtempSync(join(os.tmpdir(), "m2-proof-lease-")); t.after(() => rmSync(leaseRoot, { recursive: true, force: true })); const leaseStore = runStore(leaseRoot); leaseStore.createRun({ runId: testRunId, itemRef: "item:260722-001#M2", graph: testGraph }); let captured; + assert.throws(() => runStore(leaseRoot, { hooks: { afterLeaseAppend(value) { captured = value; throw new Error("after-lease"); } } }).acquireLease(testRunId), /after-lease/u); const recreated = runStore(leaseRoot); recreated.recoverLease(testRunId, { generation: captured.lease.generation, recoveryProof: captured.recoveryProof }); assert.equal(recreated.acquireLease(testRunId).lease.generation, 2); +}); +test("lease acquire and release churn reserves one terminal record instead of stranding an owner", (t) => { + const store = fixture(t); let acquired = store.acquireLease(testRunId); + for (let cycle = 0; cycle < 126; cycle += 1) { store.releaseLease(testRunId, acquired.lease); acquired = store.acquireLease(testRunId); } + assert.equal(store.replay(testRunId).projection.sequence, 255); const terminal = store.releaseLease(testRunId, acquired.lease); + assert.equal(terminal.projection.sequence, 256); assert.equal(terminal.projection.state, "budget-exhausted"); assert.equal(terminal.projection.leaseHeld, false); assert.equal(terminal.journal.at(-1).value.type, "terminal-node-committed"); assert.equal(terminal.journal.some((record) => record.value.sequence === 257), false); +}); +function rawTerminal(store, type, payload) { const current = store.replay(testRunId), record = createJournalRecord({ sequence: current.projection.sequence + 1, prevDigest: current.projection.journalDigest, at: current.journal.at(-1).value.at + 1, type, payload }); appendJournalRecord({ journalDirectory: store.paths.journalFor(testRunId), record }); return store.replay(testRunId); } +function ownerAt253(store) { let acquired = store.acquireLease(testRunId); for (let cycle = 0; cycle < 125; cycle += 1) { store.releaseLease(testRunId, acquired.lease); acquired = store.acquireLease(testRunId); } assert.equal(store.replay(testRunId).projection.sequence, 253); return acquired; } +test("a legacy stopped transition at 255 recovers through one lease-clearing final record", (t) => { + const store = fixture(t), acquired = ownerAt253(store); store.append(testRunId, acquired.lease, "node-started", { nodeId: "implement", attempt: 1 }); + const legacy = rawTerminal(store, "state-changed", { from: "running", to: "stopped", cause: "control" }); assert.equal(legacy.projection.sequence, 255); assert.equal(legacy.projection.leaseHeld, true); + const cleaned = store.recoverLease(testRunId, { generation: acquired.lease.generation, recoveryProof: acquired.recoveryProof }); assert.equal(cleaned.projection.sequence, 256); assert.equal(cleaned.projection.state, "stopped"); assert.equal(cleaned.projection.leaseHeld, false); assert.equal(cleaned.journal.at(-1).value.type, "terminal-node-committed"); assert.equal(cleaned.journal.some((record) => record.value.sequence === 257), false); +}); +test("a legacy graph terminal at 255 releases through one lease-clearing final record", (t) => { + const store = fixture(t); let acquired = store.acquireLease(testRunId), lease = acquired.lease; + for (const [type, payload] of [["node-started", { nodeId: "implement", attempt: 1 }], ["invocation-started", { nodeId: "implement", attempt: 1, invocationId: "a".repeat(32) }], ["invocation-result", { invocationId: "a".repeat(32), kind: "complete", summary: "ok", outputBytes: 0, candidateId: null }], ["edge-taken", { from: "implement", on: "complete", to: "verify" }], ["node-started", { nodeId: "verify", attempt: 1 }], ["invocation-started", { nodeId: "verify", attempt: 1, invocationId: "b".repeat(32) }], ["invocation-result", { invocationId: "b".repeat(32), kind: "pass", summary: "ok", outputBytes: 0, candidateId: null }], ["edge-taken", { from: "verify", on: "pass", to: "review" }], ["node-started", { nodeId: "review", attempt: 1 }], ["invocation-started", { nodeId: "review", attempt: 1, invocationId: "c".repeat(32) }], ["invocation-result", { invocationId: "c".repeat(32), kind: "approve", summary: "ok", outputBytes: 0, candidateId: null }], ["edge-taken", { from: "review", on: "approve", to: "converged" }], ["node-started", { nodeId: "converged", attempt: 1 }], ["edge-taken", { from: "converged", on: "pass", to: "completed" }]]) store.append(testRunId, lease, type, payload); + for (let cycle = 0; cycle < 118; cycle += 1) { store.releaseLease(testRunId, lease); acquired = store.acquireLease(testRunId); lease = acquired.lease; } assert.equal(store.replay(testRunId).projection.sequence, 253); + store.append(testRunId, lease, "node-started", { nodeId: "completed", attempt: 1 }); const legacy = rawTerminal(store, "state-changed", { from: "running", to: "converged", cause: "graph" }); assert.equal(legacy.projection.sequence, 255); assert.equal(legacy.projection.leaseHeld, true); + const cleaned = store.releaseLease(testRunId, lease); assert.equal(cleaned.projection.sequence, 256); assert.equal(cleaned.projection.state, "converged"); assert.equal(cleaned.projection.leaseHeld, false); assert.equal(cleaned.journal.at(-1).value.type, "terminal-node-committed"); assert.equal(cleaned.journal.some((record) => record.value.sequence === 257), false); +}); + +test("projection events publish after a committed revision, outside the journal lock, and deduplicate retries", (t) => { + const root = mkdtempSync(join(os.tmpdir(), "m7-run-events-")); t.after(() => rmSync(root, { recursive: true, force: true })); + let at = 0, store, lockWasFree = false; + const bootstrap = runStore(root, { clock: () => at++ }); + bootstrap.createRun({ runId: testRunId, itemRef: "item:260722-001#M7", graph: testGraph }); + store = runStore(root, { clock: () => at++, publishProjection(repo, replay) { + withDirectoryLock({ lockPath: join(store.paths.pathFor(testRunId), ".lock"), reclaimLiveAfterAge: false, + errorFactory: () => new Error("journal lock remained held during publication"), fn: () => { lockWasFree = true; } }); + const committed = store.replay(testRunId); + assert.equal(committed.revision, replay.revision); + assert.equal(presentRun(committed).revision, replay.revision, "event cursor uses the public projection revision"); + return publishLoopProjectionInvalidation(repo, replay); + } }); + const lease = store.acquireLease(testRunId).lease; + const result = store.append(testRunId, lease, "node-started", { nodeId: "implement", attempt: 1 }); + assert.equal(lockWasFree, true); + const events = readOvenEvents(root, { ovenIds: ["checklist"] }).filter((event) => event.kind === "loop-projection-changed"); + const event = events.at(-1); + assert.equal(event.cursor, result.revision); + assert.deepEqual(event.payload, { revision: result.revision }); + const retry = publishLoopProjectionInvalidation(root, result); + assert.equal(retry.created, false, "the same committed revision has one public event"); + assert.equal(readOvenEvents(root, { ovenIds: ["checklist"] }).filter((entry) => entry.cursor === result.revision).length, 1); +}); + +test("a projection publisher failure cannot roll back a committed run mutation", (t) => { + const root = mkdtempSync(join(os.tmpdir(), "m7-run-publisher-failure-")); t.after(() => rmSync(root, { recursive: true, force: true })); + const store = runStore(root, { publishProjection() { throw new Error("event sink unavailable"); } }); + const created = store.createRun({ runId: testRunId, itemRef: "item:260722-001#M7", graph: testGraph }); + assert.equal(store.replay(testRunId).revision, created.revision); + assert.equal(store.list().length, 1); +}); diff --git a/src/loops/run/run-test-fixtures.mjs b/src/loops/run/run-test-fixtures.mjs new file mode 100644 index 00000000..c788b97a --- /dev/null +++ b/src/loops/run/run-test-fixtures.mjs @@ -0,0 +1,147 @@ +import { execFileSync, spawnSync } from "node:child_process"; +import { chmodSync, mkdirSync, realpathSync, writeFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { compileLoopPackage } from "../dsl/compile.mjs"; +import { freezeRecipe, loadFrozenRecipe } from "../dsl/frozen.mjs"; +import { canonicalBoundPolicyBytes } from "./run-artifacts.mjs"; +import { prefixed, rawSha256 } from "../dsl/hash.mjs"; +import { canonicalCapabilityBytes, canonicalGrantBytes, capabilityRevision, GUARANTEE_LABELS } from "../capabilities/contract.mjs"; +import { snapshotTarget } from "../capabilities/snapshot.mjs"; +import { runStore } from "./run-store.mjs"; +import { createRunRunner } from "./runner.mjs"; +import { presentRun } from "./read-projection.mjs"; + +export const d = (prefix, char) => `${prefix}:${char.repeat(64)}`; +export const fixtureRunId = "run:01arz3ndektsv4rrffq69g5fav"; +export const fixtureItemRef = "item:260722-001#L29"; +export const m4ProgressOutcomes = ["complete", "pass", "reject", "complete", "pass", "approve"]; +let frozenPromise; +const fixtureBinary = "/bin/sh"; +const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..", ".."); +export function frozenRecipeBytes() { + frozenPromise ??= compileLoopPackage(resolve(dirname(fileURLToPath(import.meta.url)), "../../../loops/review")).then((compiled) => { + if (!compiled.ok) throw new Error("fixture Loop did not compile"); return freezeRecipe(compiled); + }); + return frozenPromise; +} +export function boundPolicyBytes(recipeBytes) { + const recipe = loadFrozenRecipe(recipeBytes), canonical = (value) => Buffer.from(`${JSON.stringify(value)}\n`); + const route = (id, routeName, authority, session, char) => { + const binary = fixtureBinary, model = "gpt-5.6-terra", effort = "medium", sandbox = authority === "write" ? "workspace-write" : "read-only"; + const profile = { schema: "burnlist-loop-agent-profile@1", id, adapter: "builtin:codex-cli", binary, model, effort, authority }; + const executableDigest = snapshotTarget({ root: "/", path: binary, maximum: 64 * 1024 * 1024 }).digest; + const profileRevision = prefixed("ap1-sha256:", "agent-profile-v1", [canonical(profile)]); + return { route: routeName, profile, profileRevision, executableDigest, + guarantees: routeName === "review.strong" + ? { freshSession: "enforced", filesystemWriteDeny: "supervised" } + : { freshSession: "enforced" } }; + }; + const policy = { id: "repo-verify", argv: [process.execPath, "-e", "process.exit(0)"], cwd: ".", environment: { inherit: ["PATH"], set: {} }, + network: "deny", filesystem: { read: ["src"], write: [] }, output: { maxBytes: 1024 }, maxMilliseconds: 1000 }; + const grants = { argv: policy.argv, cwd: policy.cwd, environment: policy.environment, network: policy.network, + filesystem: policy.filesystem, output: policy.output, maxMilliseconds: policy.maxMilliseconds }; + const revision = capabilityRevision(policy), policyDigest = rawSha256(canonicalCapabilityBytes(policy)), grantsDigest = rawSha256(canonicalGrantBytes(grants, policy)); + return canonicalBoundPolicyBytes({ schema: "burnlist-loop-bound-policy@1", recipeRevision: recipe.revisions.executable, + routes: [route("maker", "implementation.standard", "write", "maker-session", "1"), route("reviewer", "review.strong", "read", "review-session", "2")], + capabilities: [{ id: "repo-verify", policy, revision, policyDigest, grants, grantsDigest, + trust: { schema: "burnlist-loop-capability-trust@1", capability: "repo-verify", revision, policyDigest, grants, grantsDigest }, guarantees: GUARANTEE_LABELS }] }); +} +export async function runInput(id = fixtureRunId) { + const recipe = await frozenRecipeBytes(); return { runId: id, assignmentId: d("as1-sha256", "a"), itemRef: "item:260722-001#L7", + itemRevision: d("id1-sha256", "b"), frozenRecipeBytes: recipe, policyBytes: boundPolicyBytes(recipe) }; +} + +export async function runM4ProgressFixture({ + repoRoot, + runId = fixtureRunId, + itemRef = fixtureItemRef, + outcomes = m4ProgressOutcomes, + graph, + clock, +}) { + if (!repoRoot) throw new Error("run fixture: repo root is required"); + const source = Array.isArray(outcomes) ? [...outcomes] : []; + const frozenGraph = graph ?? loadFrozenRecipe(await frozenRecipeBytes()).ir; + let at = 0; + const baseClock = clock ?? (() => at++); + const store = runStore(repoRoot, { clock: baseClock }); + store.createRun({ runId, itemRef, graph: frozenGraph }); + const runner = createRunRunner({ store, runId, invoke: async () => { + const outcome = source.shift(); + if (!outcome) throw new Error(`run fixture: missing outcome for ${runId}`); + return { kind: outcome, summary: outcome, outputBytes: 1 }; + }}); + const snapshots = []; + let previous = null; + const capture = () => { + const snapshot = presentRun(runner.replay()); + if ( + !previous + || snapshot.currentNode !== previous.currentNode + || snapshot.attempt !== previous.attempt + || snapshot.latestResult?.kind !== previous.latestResult?.kind + || snapshot.state !== previous.state + ) { + snapshots.push(snapshot); + previous = snapshot; + } + }; + for (let steps = 0; steps < 256; steps += 1) { + if (runner.replay().execution.terminal) break; + await runner.step(); + capture(); + } + const final = presentRun(runner.replay()); + if (!runner.replay().execution.terminal) throw new Error("run fixture: progress did not converge"); + if (!snapshots.length || snapshots.at(-1) !== final) snapshots.push(final); + return { store, runId, itemRef, graph: frozenGraph, transitions: final.transitions, final, snapshots }; +} + +function fixtureCapability() { + return { id: "repo-verify", argv: [process.execPath, "-e", "process.exit(0)"], cwd: ".", + environment: { inherit: ["PATH"], set: {} }, network: "deny", + filesystem: { read: ["src"], write: [] }, output: { maxBytes: 1024 }, maxMilliseconds: 1000 }; +} +function fixtureGrants(value) { + return { argv: value.argv, cwd: value.cwd, environment: value.environment, network: value.network, + filesystem: value.filesystem, output: value.output, maxMilliseconds: value.maxMilliseconds }; +} +function fixtureCommand(repo, ...args) { + const result = spawnSync(process.execPath, [resolve(projectRoot, "bin", "burnlist.mjs"), ...args, "--repo", repo], { cwd: repo, encoding: "utf8" }); + if (result.status !== 0) throw new Error(`fixture command ${args.join(" ")} failed: ${result.stderr}`); +} + +/** Build the same source/config/trust authority consumed by production createRun. */ +export function createProductionRunAuthority(repo) { + mkdirSync(repo, { recursive: true }); + const root = realpathSync(repo); + execFileSync("git", ["init", "--quiet", root]); mkdirSync(resolve(root, ".burnlist"), { recursive: true }); mkdirSync(resolve(root, "src")); + const plan = resolve(root, "notes", "burnlists", "inprogress", "260722-001"); mkdirSync(plan, { recursive: true }); + writeFileSync(resolve(plan, "burnlist.md"), "# Runner\n\n## Active Checklist\n- [ ] L29 | Exercise production authority\n\n## Completed\n"); + const binary = resolve(root, "fixtures", "codex"); mkdirSync(dirname(binary), { recursive: true }); + writeFileSync(binary, `#!/usr/bin/env node +const fs=require("node:fs"),a=process.argv.slice(2),prompt=a.at(-1),lines=Object.fromEntries(prompt.split("\\n").filter(x=>x.includes("=")).map(x=>x.split(/=(.*)/s).slice(0,2))); +let outcome="complete";const counter=process.env.BURNLIST_FAKE_COUNTER; +if(process.env.BURNLIST_FAKE_STARTED){const marker=process.env.BURNLIST_FAKE_STARTED,tmp=marker+"."+process.pid+".tmp";fs.writeFileSync(tmp,JSON.stringify({pid:process.pid,node:lines.node,attempt:Number(lines.attempt)}));fs.renameSync(tmp,marker);} +const wait=Number(process.env.BURNLIST_FAKE_WAIT_MS||0);if(Number.isSafeInteger(wait)&&wait>0)Atomics.wait(new Int32Array(new SharedArrayBuffer(4)),0,0,wait); +if(counter){const attempt=Number(lines.attempt),index=lines.node==="implement"?(attempt-1)*2:lines.node==="review"?(attempt-1)*2+1:Number(fs.readFileSync(counter,"utf8"));outcome=(process.env.BURNLIST_FAKE_OUTCOMES||"complete").split(",")[index]||"approve";fs.writeFileSync(counter,String(Math.max(Number(fs.readFileSync(counter,"utf8")),index+1)));} +if(lines.node==="implement"&&outcome==="complete")fs.writeFileSync(process.cwd()+"/src/fake-maker-candidate.txt","maker-attempt="+lines.attempt+"\\n"); +const final={schema:"burnlist.agent-final@1",runId:lines.run,nodeId:lines.node,attempt:Number(lines.attempt),claimId:lines.claim,invocationId:lines.invocation,assignmentId:lines.assignment,recipeRevision:lines.recipe,policyRevision:lines.policy,inputCandidate:lines.candidate,outcome,summary:"fake "+outcome}; +const mode=process.env.BURNLIST_FAKE_FINAL_MODE;if(mode==="stale")final.inputCandidate="cm1-sha256:"+"f".repeat(64); +process.stdout.write(JSON.stringify({type:"thread.started",thread_id:"s-"+process.pid,model:a[a.indexOf("-m")+1]})+"\\n"); +process.stdout.write(JSON.stringify({type:"item.completed",item:{type:"agent_message",text:mode==="malformed"?"not-json":JSON.stringify(final)}})+"\\n"); +process.stdout.write(JSON.stringify({type:"turn.completed",usage:{input_tokens:1,output_tokens:1,cached_input_tokens:0}})+"\\n"); +`); chmodSync(binary, 0o700); + const capability = fixtureCapability(), grantsPath = resolve(root, "grants.json"); + writeFileSync(resolve(root, ".burnlist", "loop-capabilities.json"), `${JSON.stringify({ schema: "burnlist-loop-capabilities@1", capabilities: [capability] })}\n`); + writeFileSync(grantsPath, `${JSON.stringify(fixtureGrants(capability))}\n`); + for (const [id, authority, route] of [["maker", "write", "implementation.standard"], ["reviewer", "read", "review.strong"]]) { + fixtureCommand(root, "agent", "profile", "add", id, "--adapter", "builtin:codex-cli", "--binary", binary, + "--model", "gpt-5.3-codex-spark", "--effort", "medium", "--authority", authority); + fixtureCommand(root, "route", "set", route, "--profile", id); + } + fixtureCommand(root, "loop", "capability", "trust", "repo-verify", "--revision", capabilityRevision(capability), "--grants", grantsPath); + fixtureCommand(root, "loop", "assign", fixtureItemRef, "loop:builtin:review"); + return { repo: root, binary }; +} diff --git a/src/loops/run/runner.mjs b/src/loops/run/runner.mjs new file mode 100644 index 00000000..db6e618b --- /dev/null +++ b/src/loops/run/runner.mjs @@ -0,0 +1,87 @@ +import { randomBytes } from "node:crypto"; +import { budgetReason } from "./budgets.mjs"; +import { gateDecision } from "./state-machine.mjs"; +import { isSystemOutcome, validateNormalizedResult } from "./run-result.mjs"; + +const fail = (message) => { throw Object.assign(new Error(`Run runner: ${message}`), { code: "ERUNNER" }); }; +function boundedSummary(value) { + const bytes = Buffer.from(String(value ?? "candidate capture failed"), "utf8"); + if (bytes.length <= 1024) return bytes.toString("utf8"); + let end = 1024; + while (end > 0 && (bytes[end] & 0xc0) === 0x80) end -= 1; + return bytes.subarray(0, end).toString("utf8"); +} +export function createRunRunner({ store, runId, invoke, bindCandidate = null }) { + if (!store?.replay || !store?.append || !store?.acquireLease || !store?.terminalize || typeof invoke !== "function") fail("invalid runner input"); + let lease = null, pauseRequested = false, stopRequested = false, cancelRequested = false, cancelWake = null; + const read = () => store.replay(runId), append = (type, payload) => store.append(runId, lease, type, payload); + function transition(to, cause) { const execution = read().execution; return append("state-changed", { from: execution.state, to, cause }); } + function edge(execution, on) { const current = read(), target = current.graph.edges.find((item) => item.from === execution.nodeId && item.on === on); if (!target) fail("outcome has no declared edge"); const exhausted = budgetReason({ folded: execution.budget, graph: current.graph, edge: target }); if (exhausted) return system("exhausted", exhausted); return append("edge-taken", { from: target.from, on, to: target.to }); } + function system(kind, summary) { return append("system-outcome", { kind, summary }); } + function routeSystem(current, execution) { const target = current.graph.failurePolicy[execution.system.kind]; if (execution.nodeId !== target) return append("failure-routed", { from: execution.nodeId, kind: execution.system.kind, to: target }); if (!execution.started) return append("node-started", { nodeId: target, attempt: execution.attempt + 1 }); return transition(execution.node.state, "graph"); } + async function step() { + let current = read(), execution = current.execution; if (execution.terminal) return { kind: "terminal", state: execution.state }; if (!lease) lease = store.acquireLease(runId).lease; + current = read(); execution = current.execution; if (execution.terminal) { lease = null; return { kind: "terminal", state: execution.state }; } if (execution.system) return routeSystem(current, execution); const node = execution.node; if (execution.budget.elapsedMilliseconds >= current.graph.budget.maxMinutes * 60_000) return system("exhausted", "minutes"); + if (!execution.started) { const exhausted = budgetReason({ folded: execution.budget, graph: current.graph, node }); if (exhausted) return system("exhausted", exhausted); return append("node-started", { nodeId: node.id, attempt: execution.attempt + 1 }); } + if (node.kind === "terminal") return transition(node.state, "graph"); + if (node.kind === "gate") return edge(execution, gateDecision(execution, current.graph)); + if (execution.result) { + if (node.kind === "agent" && node.mode === "task" && execution.result.kind === "complete" && !execution.candidate && typeof bindCandidate === "function") { + try { + const candidate = bindCandidate({ runId, nodeId: node.id, attempt: execution.attempt, result: execution.result }); + return append("candidate-bound", candidate); + } catch (error) { + return system("error", boundedSummary(error?.message)); + } + } + return edge(execution, execution.result.kind); + } + if (execution.invocation) return system("lost", "persisted invocation requires recovery"); + append("invocation-started", { nodeId: node.id, attempt: execution.attempt, invocationId: randomBytes(16).toString("hex") }); current = read(); execution = current.execution; + const pending = Promise.resolve(invoke(Object.freeze({ runId, nodeId: node.id, attempt: execution.attempt, invocationId: execution.invocation.invocationId }))) + .then((value) => ({ value }), (error) => ({ error })); + let result; + const raced = cancelRequested ? { cancelled: true } : await Promise.race([pending, new Promise((resolve) => { cancelWake = () => resolve({ cancelled: true }); })]); + cancelWake = null; + if (raced?.cancelled || cancelRequested || pauseRequested || stopRequested) { + invoke.cancel?.(); + // A control request fences the Run until the real foreground handle has + // settled. Never manufacture a cancellation result: releasing a lease + // while a child may still write would permit a split-brain resume. + const settled = await pending; + result = settled.error ? { kind: "error", summary: String(settled.error?.message ?? "invocation error"), outputBytes: 0 } : settled.value; + } else result = raced.error ? { kind: "error", summary: String(raced.error?.message ?? "invocation error"), outputBytes: 0 } : raced.value; + // A pause is committed only after the foreground handle has settled. Its + // cancelled result is intentionally not journalled: it has no graph edge, + // and the next foreground owner must retry this unfinished invocation. + if (pauseRequested && !stopRequested) return { kind: "paused" }; + result = validateNormalizedResult({ ...result, candidateId: result.candidateId ?? execution.candidate?.id ?? null }, node, current.graph.budget.maxOutputBytes); const exhausted = budgetReason({ folded: execution.budget, graph: current.graph, outputBytes: result.outputBytes }); + return append("invocation-result", { invocationId: execution.invocation.invocationId, ...(exhausted ? { kind: "exhausted", summary: exhausted, outputBytes: 0, candidateId: execution.candidate?.id ?? null } : result) }); + } + function pause() { + const current = read(); + if (current.execution.terminal || current.execution.state === "paused") return current; + if (!lease || current.execution.state !== "running" || current.execution.invocation && !current.execution.result && !pauseRequested) + fail("pause requires an idle foreground lease"); + append("state-changed", { from: "running", to: "paused", cause: "control" }); + store.releaseLease(runId, lease); lease = null; return read(); + } + function stop() { + const current = read(); if (current.execution.terminal) return current; + if (!lease) lease = store.acquireLease(runId).lease; + const result = store.terminalize(runId, lease, "cancelled", "control"); lease = null; return result; + } + async function run() { for (;;) { + if (stopRequested) return stop(); + await step(); + // A second SIGINT upgrades a pending pause to stop while the invocation + // settles. Check it before the pause branch so terminal control wins. + if (stopRequested) return stop(); + const current = read(); + if (current.execution.terminal) { if (lease && current.execution.lease) store.releaseLease(runId, lease); lease = null; return read(); } + if (pauseRequested) return pause(); + } } + function requestPause() { pauseRequested = true; cancelRequested = true; invoke.cancel?.(); cancelWake?.(); } + function requestStop() { stopRequested = true; cancelRequested = true; invoke.cancel?.(); cancelWake?.(); } + return Object.freeze({ step, run, pause, stop, requestPause, requestStop, replay: read, get lease() { return lease; } }); +} diff --git a/src/loops/run/runner.test.mjs b/src/loops/run/runner.test.mjs new file mode 100644 index 00000000..b0af07a5 --- /dev/null +++ b/src/loops/run/runner.test.mjs @@ -0,0 +1,139 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import os from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { createRunRunner } from "./runner.mjs"; +import { gateDecision } from "./state-machine.mjs"; +import { normalizeIr } from "../dsl/canonical.mjs"; +import { outcomesFor } from "../dsl/grammar.mjs"; +import { runStore } from "./run-store.mjs"; +import { testGraph, testRunId } from "./m2-test-fixtures.mjs"; + +function fixture(t, graph = testGraph) { const root = mkdtempSync(join(os.tmpdir(), "m2-runner-")); t.after(() => rmSync(root, { recursive: true, force: true })); let at = 0; const store = runStore(root, { clock: () => at++ }); store.createRun({ runId: testRunId, itemRef: "item:260722-001#M2", graph }); return store; } +function renamedGraph() { const names = { verify: "audit", review: "tests" }, graph = JSON.parse(JSON.stringify(testGraph)); graph.nodes = graph.nodes.map((node) => node.kind === "agent" ? { ...node, id: names[node.id] ?? node.id, independentFrom: node.independentFrom ? names[node.independentFrom] ?? node.independentFrom : null } : node.kind === "check" ? { ...node, id: names[node.id] ?? node.id } : node.kind === "gate" ? { ...node, requires: node.requires.map((id) => names[id] ?? id) } : node); graph.edges = graph.edges.map((edge) => ({ ...edge, from: names[edge.from] ?? edge.from, to: names[edge.to] ?? edge.to })); return normalizeIr(graph, outcomesFor); } +function highBoundaryGraph() { const graph = JSON.parse(JSON.stringify(testGraph)); graph.budget = { maxRounds: 100, maxMinutes: 1000, maxAgentRuns: 100, maxCheckRuns: 100, maxTransitions: 1000, maxOutputBytes: 1000000 }; graph.edges = graph.edges.map((edge) => edge.from === "review" && edge.on === "reject" || edge.from === "verify" && edge.on === "fail" ? { ...edge, maxVisits: 100 } : edge); return normalizeIr(graph, outcomesFor); } +function ownerAt253(store) { let acquired = store.acquireLease(testRunId); for (let cycle = 0; cycle < 125; cycle += 1) { store.releaseLease(testRunId, acquired.lease); acquired = store.acquireLease(testRunId); } assert.equal(store.replay(testRunId).projection.sequence, 253); return acquired; } +test("runner table traverses maker/check/reviewer/gate/terminal", async (t) => { + const store = fixture(t), outcomes = ["complete", "pass", "approve"], calls = []; + const runner = createRunRunner({ store, runId: testRunId, clock: () => 0, invoke: async ({ nodeId }) => { calls.push(nodeId); return { kind: outcomes.shift(), summary: "ok", outputBytes: 1 }; } }); + assert.equal((await runner.run()).projection.state, "converged"); assert.deepEqual(calls, ["implement", "verify", "review"]); +}); +test("post-maker candidate capture failure journals a restart-safe terminal and releases its lease", async (t) => { + const root = mkdtempSync(join(os.tmpdir(), "m12-candidate-failure-")); + t.after(() => rmSync(root, { recursive: true, force: true })); + const store = runStore(root, { clock: (() => { let at = 0; return () => at++; })() }); + store.createRun({ runId: testRunId, itemRef: "item:260722-001#M2", graph: testGraph }); + const runner = createRunRunner({ store, runId: testRunId, + invoke: async () => ({ kind: "complete", summary: "maker complete", outputBytes: 0 }), + bindCandidate() { throw new Error("candidate manifest exceeds bounds"); } }); + const result = await runner.run(); + assert.equal(result.projection.state, "failed"); + assert.equal(result.projection.leaseHeld, false); + assert.equal(result.execution.system.summary, "candidate manifest exceeds bounds"); + const replayed = runStore(root).read(testRunId); + assert.equal(replayed.projection.state, "failed"); + assert.equal(replayed.projection.leaseHeld, false); + assert.equal(replayed.journal.some((record) => record.value.type === "system-outcome"), true); +}); +test("multibyte candidate capture errors are UTF-8 bounded before durable terminalization", async (t) => { + const root = mkdtempSync(join(os.tmpdir(), "m12-candidate-utf8-")); + t.after(() => rmSync(root, { recursive: true, force: true })); + const store = runStore(root); + store.createRun({ runId: testRunId, itemRef: "item:260722-001#M2", graph: testGraph }); + const runner = createRunRunner({ store, runId: testRunId, + invoke: async () => ({ kind: "complete", summary: "maker complete", outputBytes: 0 }), + bindCandidate() { throw new Error("💥".repeat(400)); } }); + const result = await runner.run(); + assert.equal(result.projection.state, "failed"); + assert.equal(result.projection.leaseHeld, false); + assert.ok(Buffer.byteLength(result.execution.system.summary, "utf8") <= 1024); + const replayed = runStore(root).read(testRunId); + assert.equal(replayed.projection.state, "failed"); + assert.equal(replayed.projection.leaseHeld, false); +}); +test("renamed audit/tests requirements drive gate convergence", async (t) => { + const root = mkdtempSync(join(os.tmpdir(), "m2-renamed-")); t.after(() => rmSync(root, { recursive: true, force: true })); let at = 0; const store = runStore(root, { clock: () => at++ }); store.createRun({ runId: testRunId, itemRef: "item:260722-001#M2", graph: renamedGraph() }); + const runner = createRunRunner({ store, runId: testRunId, invoke: async ({ nodeId }) => ({ kind: nodeId === "implement" ? "complete" : nodeId === "audit" ? "pass" : "approve", summary: "ok", outputBytes: 0 }) }); + assert.equal((await runner.run()).projection.state, "converged"); +}); +test("persisted time, not a runner clock, decides exact budget exhaustion", async (t) => { + const root = mkdtempSync(join(os.tmpdir(), "m2-time-")); t.after(() => rmSync(root, { recursive: true, force: true })); const boundary = testGraph.budget.maxMinutes * 60_000, times = [0, boundary, boundary, boundary]; + const store = runStore(root, { clock: () => times.shift() ?? boundary }); store.createRun({ runId: testRunId, itemRef: "item:260722-001#M2", graph: testGraph }); + const runner = createRunRunner({ store, runId: testRunId, clock: () => Number.MAX_SAFE_INTEGER, invoke: async () => ({ kind: "complete", summary: "no", outputBytes: 0 }) }); + await runner.run(); assert.equal(store.replay(testRunId).projection.state, "budget-exhausted"); +}); +test("every system result restarts through its declared failure terminal without reinvocation", async (t) => { + for (const kind of ["error", "timeout", "cancelled", "lost", "exhausted"]) { + const store = fixture(t); let calls = 0, runner = createRunRunner({ store, runId: testRunId, invoke: async () => { calls += 1; return { kind, summary: kind, outputBytes: 0 }; } }); + await runner.step(); await runner.step(); assert.equal(calls, 1); assert.equal(store.replay(testRunId).execution.system.kind, kind); store.releaseLease(testRunId, runner.lease); + runner = createRunRunner({ store, runId: testRunId, invoke: async () => { calls += 1; throw new Error("duplicate invocation"); } }); await runner.step(); const routed = store.replay(testRunId), target = testGraph.failurePolicy[kind]; assert.equal(routed.projection.currentNode, target); assert.deepEqual(routed.journal.at(-1).value, { schema: "burnlist-loop-m2-journal@1", sequence: routed.projection.sequence, prevDigest: routed.journal.at(-2).digest, at: routed.journal.at(-1).value.at, type: "failure-routed", payload: { from: "implement", kind, to: target } }); store.releaseLease(testRunId, runner.lease); + runner = createRunRunner({ store, runId: testRunId, invoke: async () => { calls += 1; throw new Error("duplicate invocation"); } }); await runner.step(); const started = store.replay(testRunId); assert.equal(started.journal.at(-1).value.type, "node-started"); assert.equal(started.journal.at(-1).value.payload.nodeId, target); store.releaseLease(testRunId, runner.lease); + runner = createRunRunner({ store, runId: testRunId, invoke: async () => { calls += 1; throw new Error("duplicate invocation"); } }); const result = await runner.run(), terminalNode = testGraph.nodes.find((node) => node.id === target); assert.equal(result.projection.state, terminalNode.state); assert.equal(result.projection.currentNode, target); assert.equal(result.projection.leaseHeld, false); assert.equal(result.execution.system.kind, kind); assert.equal(calls, 1); assert.equal(result.journal.filter((record) => record.value.type === "invocation-started").length, 1); assert.equal(result.journal.filter((record) => record.value.type === "node-started" && record.value.payload.nodeId === target).length, 1); + } +}); +test("system outcomes near terminal capacity restart to target terminal node", async (t) => { + const store = fixture(t), acquired = ownerAt253(store); + store.append(testRunId, acquired.lease, "system-outcome", { kind: "error", summary: "boom" }); + store.releaseLease(testRunId, acquired.lease); + const runner = createRunRunner({ store, runId: testRunId, invoke: async () => { throw new Error("should not execute"); } }); + const final = await runner.run(); + const last = final.journal.at(-1).value.payload; + assert.equal(final.projection.sequence, 256); + assert.equal(final.projection.state, "failed"); + assert.equal(final.projection.currentNode, "failed"); + assert.equal(final.projection.leaseHeld, false); + assert.equal(final.journal.at(-1).value.type, "terminal-node-committed"); + assert.equal(last.nodeId, "failed"); + assert.equal(last.to, "failed"); + assert.equal(last.attempt, 1); + assert.equal(final.journal.some((record) => record.value.sequence === 257), false); +}); +test("check fail and reviewer reject consume repair cycles before escalation", async (t) => { + const store = fixture(t), outcomes = ["complete", "fail", "complete", "pass", "reject", "complete", "pass", "escalate"]; + const runner = createRunRunner({ store, runId: testRunId, invoke: async () => ({ kind: outcomes.shift(), summary: "branch", outputBytes: 0 }) }); + const result = await runner.run(); assert.equal(result.projection.state, "needs-human"); assert.equal(result.execution.cycle, 3); assert.equal(result.execution.attempts.implement, 3); +}); +test("gate fail is derived when its current required evidence is absent", () => { + const gate = testGraph.nodes.find((node) => node.kind === "gate"); + assert.equal(gateDecision({ node: gate, cycle: 1, evidence: { verify: { kind: "pass", cycle: 1 } } }, testGraph), "fail"); +}); +test("recovery fences a persisted invocation and never re-invokes it", async (t) => { + const store = fixture(t), acquired = store.acquireLease(testRunId), lease = acquired.lease; + store.append(testRunId, lease, "node-started", { nodeId: "implement", attempt: 1 }); store.append(testRunId, lease, "invocation-started", { nodeId: "implement", attempt: 1, invocationId: "a".repeat(32) }); store.recoverLease(testRunId, { generation: lease.generation, recoveryProof: acquired.recoveryProof }); + let calls = 0; const runner = createRunRunner({ store, runId: testRunId, clock: () => 0, invoke: async () => { calls += 1; return { kind: "complete", summary: "bad", outputBytes: 0 }; } }); + await runner.run(); assert.equal(calls, 0); assert.equal(store.replay(testRunId).projection.state, "needs-human"); assert.equal(store.replay(testRunId).projection.currentNode, "needs-human"); +}); +test("an unsettled cancellation retains its lease; a second interrupt stops only after settlement", async (t) => { + const store = fixture(t); let settle; + const invoke = Object.assign(() => new Promise((resolve) => { settle = resolve; }), { cancel: () => true }); + const runner = createRunRunner({ store, runId: testRunId, invoke }), running = runner.run(); + while (!settle) await new Promise((resolve) => setImmediate(resolve)); + runner.requestPause(); await new Promise((resolve) => setTimeout(resolve, 20)); + assert.equal(store.read(testRunId).projection.state, "running"); assert.equal(store.read(testRunId).projection.leaseHeld, true); + runner.requestStop(); settle({ kind: "cancelled", summary: "settled", outputBytes: 0 }); + assert.equal((await running).projection.state, "stopped"); assert.equal(store.read(testRunId).projection.leaseHeld, false); +}); +test("a settled foreground interruption pauses without a synthetic result and resumes the unfinished node", async (t) => { + const store = fixture(t); let settle; + const interruptedInvoke = Object.assign(() => new Promise((resolve) => { settle = resolve; }), { cancel: () => true }); + const interrupted = createRunRunner({ store, runId: testRunId, invoke: interruptedInvoke }); + const running = interrupted.run(); while (!settle) await new Promise((resolve) => setImmediate(resolve)); + interrupted.requestPause(); settle({ kind: "cancelled", summary: "interrupted", outputBytes: 0 }); + assert.equal((await running).projection.state, "paused"); + const paused = store.replay(testRunId); + assert.equal(paused.execution.invocation, null); assert.equal(paused.execution.result, null); + assert.equal(paused.journal.filter((record) => record.value.type === "invocation-result").length, 0); + const resumed = createRunRunner({ store, runId: testRunId, invoke: async ({ nodeId }) => ({ + kind: nodeId === "implement" ? "complete" : nodeId === "verify" ? "pass" : "approve", summary: "resumed", outputBytes: 0, + }) }); + const completed = await resumed.run(); + assert.equal(completed.projection.state, "converged"); + assert.equal(completed.journal.filter((record) => record.value.type === "invocation-started" && record.value.payload.nodeId === "implement").length, 2); +}); +test("ordinary retry traffic spends the final slot on one lease-clearing journal terminal", async (t) => { + const store = fixture(t, highBoundaryGraph()), calls = []; + const runner = createRunRunner({ store, runId: testRunId, invoke: async ({ nodeId }) => { calls.push(nodeId); return { kind: nodeId === "implement" ? "complete" : "fail", summary: "retry", outputBytes: 0 }; } }); + const result = await runner.run(), replay = store.replay(testRunId); + assert.equal(result.projection.sequence, 256); assert.equal(result.projection.state, "budget-exhausted"); assert.equal(result.projection.leaseHeld, false); assert.equal(result.projection.journal.remaining, 0); assert.equal(replay.journal.at(-1).value.type, "terminal-node-committed"); assert.equal(replay.journal.some((record) => record.value.sequence === 257), false); assert.ok(calls.filter((nodeId) => nodeId === "verify").length > 20); +}); diff --git a/src/loops/run/state-machine.mjs b/src/loops/run/state-machine.mjs new file mode 100644 index 00000000..3457040a --- /dev/null +++ b/src/loops/run/state-machine.mjs @@ -0,0 +1,70 @@ +import { validateClosedIr } from "../dsl/ir-validate.mjs"; +import { isRunRef } from "./run-ref.mjs"; +import { foldBudgets } from "./budgets.mjs"; +import { isSystemOutcome, validateNormalizedResult } from "./run-result.mjs"; + +const TERMINAL = new Set(["converged", "needs-human", "failed", "stopped", "budget-exhausted"]), SYSTEM = { error: "failed", timeout: "failed", cancelled: "stopped", lost: "needs-human", exhausted: "budget-exhausted" }, ATOMIC = { ...SYSTEM, converged: "converged" }; +const exact = (value, keys) => Boolean(value) && typeof value === "object" && !Array.isArray(value) && Object.keys(value).length === keys.length && keys.every((key) => Object.hasOwn(value, key)); +const fail = (message) => { throw Object.assign(new Error(`Run state machine: ${message}`), { code: "ESTATE" }); }; +export const isTerminalState = (state) => TERMINAL.has(state); +export const systemState = (kind) => SYSTEM[kind] ?? fail("unknown system outcome"); +export const atomicTerminalState = (kind) => ATOMIC[kind] ?? fail("unknown atomic terminal"); +export function validateGraph(graph) { if (!validateClosedIr(graph)) fail("graph is not canonical closed IR"); return Object.freeze({ nodes: new Map(graph.nodes.map((node) => [node.id, node])), edges: new Map(graph.edges.map((edge) => [`${edge.from}\0${edge.on}`, edge])) }); } +export function validateStateTransition(from, to, cause) { + if (!(["control", "graph", "system"].includes(cause))) fail("invalid transition cause"); + const control = (from === "prepared" && ["running", "stopped"].includes(to)) || (from === "running" && ["paused", "stopped"].includes(to)) || (from === "paused" && ["running", "stopped"].includes(to)); + if (cause === "control" && control) return { from, to, cause }; + if (cause === "graph" && from === "running" && TERMINAL.has(to)) return { from, to, cause }; + if (cause === "system" && from === "running" && TERMINAL.has(to)) return { from, to, cause }; + fail("closed lifecycle transition rejected"); +} +function gateOutcome(runtime, node) { + const valid = node.requires.every((id) => { + const evidence = runtime.evidence[id], required = runtime.nodes.get(id); + return evidence?.cycle === runtime.cycle && evidence.candidateId === (runtime.candidate?.id ?? null) + && (required?.kind === "check" ? evidence.kind === "pass" : required?.kind === "agent" && required.mode === "review" && evidence.kind === "approve"); + }); + return valid ? "pass" : "fail"; +} +export function foldStateMachine({ graph, records }) { + const { nodes, edges } = validateGraph(graph), first = records[0]?.value?.payload; + if (!first || first.type !== undefined && records[0].value.type !== "run-created" || !isRunRef(first.runId)) fail("invalid RunRef creation"); + const current = { state: "prepared", generation: 0, lease: null }, runtime = { nodeId: graph.entry, attempts: {}, started: false, invocation: null, result: null, system: null, cycle: 0, evidence: {}, candidate: null, latest: { maker: null, check: null, reviewer: null }, nodes, edges }; + for (const [index, record] of records.entries()) { + const { type, payload } = record.value, node = nodes.get(runtime.nodeId); if (!index) continue; + if (type === "state-changed") { if (!exact(payload, ["from", "to", "cause"]) || payload.from !== current.state) fail("invalid state event"); validateStateTransition(payload.from, payload.to, payload.cause); if (payload.cause === "control" && payload.to === "paused" && runtime.invocation && !runtime.result) runtime.invocation = null; if (payload.cause === "graph" && (!node || node.kind !== "terminal" || !runtime.started || payload.to !== node.state)) fail("graph terminal bypass"); if (payload.cause === "system" && (!runtime.system || payload.to !== systemState(runtime.system.kind))) fail("system terminal bypass"); current.state = payload.to; continue; } + if (type === "lease-acquired") { if (!exact(payload, ["generation", "token"]) || current.state !== "running" || current.lease || payload.generation !== current.generation + 1 || !/^[a-f0-9]{64}$/u.test(payload.token)) fail("invalid lease acquisition"); current.generation = payload.generation; current.lease = payload; continue; } + if (type === "lease-released" || type === "lease-revoked") { if (!exact(payload, ["generation", "token"]) || !current.lease || payload.generation !== current.lease.generation || payload.token !== current.lease.token) fail("stale lease change"); current.lease = null; continue; } + if (type === "terminal-node-committed") { const cleanup = isTerminalState(current.state), targetState = atomicTerminalState(payload.kind), expectedState = cleanup ? current.state : targetState, expectedNode = payload.kind === "converged" ? graph.nodes.find((item) => item.kind === "terminal" && item.state === "converged")?.id : graph.failurePolicy[payload.kind], targetNode = nodes.get(payload.nodeId), alreadyStarted = runtime.nodeId === payload.nodeId && runtime.started, expectedAttempt = alreadyStarted ? runtime.attempts[payload.nodeId] : (runtime.attempts[payload.nodeId] ?? 0) + 1; if (!exact(payload, ["kind", "summary", "from", "to", "nodeId", "attempt"]) || payload.from !== current.state || !cleanup && !["prepared", "paused", "running"].includes(payload.from) || cleanup && targetState !== current.state || payload.to !== expectedState || payload.nodeId !== expectedNode || targetNode?.kind !== "terminal" || targetNode.state !== payload.to || payload.attempt !== expectedAttempt || typeof payload.summary !== "string" || Buffer.byteLength(payload.summary, "utf8") > 1024 || runtime.system && (runtime.system.kind !== payload.kind || runtime.system.summary !== payload.summary)) fail("invalid atomic terminal node"); runtime.system ??= Object.freeze({ kind: payload.kind, summary: payload.summary, outputBytes: 0 }); if (!alreadyStarted) { runtime.nodeId = payload.nodeId; runtime.started = true; runtime.invocation = null; runtime.result = null; runtime.attempts[payload.nodeId] = payload.attempt; } current.lease = null; current.state = payload.to; continue; } + if (!current.lease || current.state !== "running") fail("active event lacks lease"); + if (type === "node-started") { if (!exact(payload, ["nodeId", "attempt"]) || payload.nodeId !== runtime.nodeId || runtime.started || payload.attempt !== (runtime.attempts[payload.nodeId] ?? 0) + 1) fail("invalid node start"); runtime.started = true; runtime.attempts[payload.nodeId] = payload.attempt; if (node.kind === "agent" && node.mode === "task") { runtime.cycle += 1; runtime.candidate = null; } continue; } + if (type === "invocation-started") { if (!exact(payload, ["nodeId", "attempt", "invocationId"]) || !runtime.started || runtime.invocation || !["agent", "check"].includes(node.kind) || payload.nodeId !== runtime.nodeId || payload.attempt !== runtime.attempts[payload.nodeId] || !/^[a-f0-9]{32}$/u.test(payload.invocationId)) fail("invalid invocation start"); runtime.invocation = payload; continue; } + if (type === "invocation-result") { + if (!runtime.invocation || runtime.result || !exact(payload, ["invocationId", "kind", "summary", "outputBytes", "candidateId"]) || payload.invocationId !== runtime.invocation.invocationId) fail("invalid invocation result"); + runtime.result = validateNormalizedResult({ kind: payload.kind, summary: payload.summary, outputBytes: payload.outputBytes, candidateId: payload.candidateId }, node, graph.budget.maxOutputBytes); + const role = node.kind === "check" ? "check" : node.mode === "review" ? "reviewer" : "maker"; + if (node.kind !== "agent" || node.mode !== "task") { + if (payload.candidateId !== (runtime.candidate?.id ?? null)) fail("result is not bound to the current candidate"); + } else if (payload.candidateId !== null && payload.candidateId !== (runtime.candidate?.id ?? null)) fail("maker result candidate is invalid"); + runtime.latest[role] = { summary: runtime.result.summary, at: record.value.at, candidateId: runtime.result.candidateId }; + if (isSystemOutcome(runtime.result.kind)) runtime.system = runtime.result; + else runtime.evidence[runtime.nodeId] = { kind: runtime.result.kind, cycle: runtime.cycle, candidateId: runtime.result.candidateId }; + continue; + } + if (type === "candidate-bound") { + if (!exact(payload, ["candidateId", "candidateContext"]) || node?.kind !== "agent" || node.mode !== "task" || !runtime.result || runtime.result.kind !== "complete" || runtime.candidate + || !/^cm1-sha256:[a-f0-9]{64}$/u.test(payload.candidateId) || typeof payload.candidateContext !== "string" || !payload.candidateContext || Buffer.byteLength(payload.candidateContext, "utf8") > 65_536) fail("invalid candidate binding"); + runtime.candidate = Object.freeze({ id: payload.candidateId, context: payload.candidateContext }); + runtime.latest.maker = { summary: runtime.result.summary, at: record.value.at, candidateId: payload.candidateId }; + runtime.evidence[runtime.nodeId] = { kind: runtime.result.kind, cycle: runtime.cycle, candidateId: payload.candidateId }; + continue; + } + if (type === "system-outcome") { if (runtime.system || !exact(payload, ["kind", "summary"]) || !isSystemOutcome(payload.kind) || typeof payload.summary !== "string" || Buffer.byteLength(payload.summary, "utf8") > 1024) fail("invalid system outcome"); runtime.system = Object.freeze({ kind: payload.kind, summary: payload.summary, outputBytes: 0 }); continue; } + if (type === "failure-routed") { const target = nodes.get(payload?.to); if (!runtime.system || !exact(payload, ["from", "kind", "to"]) || payload.from !== runtime.nodeId || payload.kind !== runtime.system.kind || payload.to !== graph.failurePolicy[payload.kind] || payload.to === runtime.nodeId || target?.kind !== "terminal" || target.state !== systemState(payload.kind)) fail("invalid failure route"); runtime.nodeId = payload.to; runtime.started = false; runtime.invocation = null; runtime.result = null; continue; } + if (type === "edge-taken") { if (!exact(payload, ["from", "on", "to"]) || payload.from !== runtime.nodeId) fail("invalid edge event"); const edge = edges.get(`${payload.from}\0${payload.on}`); if (!edge || edge.to !== payload.to) fail("undeclared edge"); if (node.kind === "gate" ? payload.on !== gateOutcome(runtime, node) : !runtime.result || isSystemOutcome(runtime.result.kind) || payload.on !== runtime.result.kind) fail("edge outcome is not current result"); runtime.nodeId = edge.to; runtime.started = false; runtime.invocation = null; runtime.result = null; continue; } + fail("unknown event"); + } + const budget = foldBudgets({ records, graph }); + return Object.freeze({ state: current.state, generation: current.generation, lease: current.lease && Object.freeze({ ...current.lease }), nodeId: runtime.nodeId, node: nodes.get(runtime.nodeId), attempts: Object.freeze({ ...runtime.attempts }), attempt: runtime.attempts[runtime.nodeId] ?? 0, cycle: runtime.cycle, evidence: Object.freeze({ ...runtime.evidence }), candidate: runtime.candidate, latest: Object.freeze({ ...runtime.latest }), started: runtime.started, invocation: runtime.invocation && Object.freeze({ ...runtime.invocation }), result: runtime.result, system: runtime.system, budget, terminal: isTerminalState(current.state) }); +} +export function gateDecision(execution, graph) { return gateOutcome({ evidence: execution.evidence, candidate: execution.candidate, cycle: execution.cycle, nodes: new Map(graph.nodes.map((node) => [node.id, node])) }, execution.node); } diff --git a/src/loops/run/state-machine.test.mjs b/src/loops/run/state-machine.test.mjs new file mode 100644 index 00000000..d933c88d --- /dev/null +++ b/src/loops/run/state-machine.test.mjs @@ -0,0 +1,51 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createJournalRecord } from "./run-journal.mjs"; +import { foldRun } from "./run-fold.mjs"; +import { foldStateMachine, validateGraph } from "./state-machine.mjs"; +import { validateNormalizedResult } from "./run-result.mjs"; +import { created, testGraph } from "./m2-test-fixtures.mjs"; + +const append = (records, type, payload) => [...records, createJournalRecord({ sequence: records.length + 1, prevDigest: records.at(-1)?.digest ?? null, at: records.length, type, payload })]; +test("table validates every executable outcome and rejects cross-node outcomes", () => { + const nodes = new Map(testGraph.nodes.map((node) => [node.id, node])); + for (const [node, outcomes] of [["implement", ["complete"]], ["verify", ["pass", "fail"]], ["review", ["approve", "reject", "escalate"]]]) for (const outcome of outcomes) assert.doesNotThrow(() => validateNormalizedResult({ kind: outcome, summary: "ok", outputBytes: 0, candidateId: null }, nodes.get(node), testGraph.budget.maxOutputBytes)); + assert.throws(() => validateNormalizedResult({ kind: "approve", summary: "ok", outputBytes: 0, candidateId: null }, nodes.get("implement"), testGraph.budget.maxOutputBytes), /legal/u); + assert.throws(() => validateGraph({ ...testGraph, ignored: true }), /canonical/u); +}); + +test("a semantic result may retain its evidence when a later limit selects exhaustion", () => { + let records = [createJournalRecord({ sequence: 1, prevDigest: null, at: 0, type: "run-created", payload: created() })]; + records = append(records, "state-changed", { from: "prepared", to: "running", cause: "control" }); + records = append(records, "lease-acquired", { generation: 1, token: "a".repeat(64) }); + records = append(records, "node-started", { nodeId: "implement", attempt: 1 }); + records = append(records, "invocation-started", { nodeId: "implement", attempt: 1, invocationId: "b".repeat(32) }); + records = append(records, "invocation-result", { invocationId: "b".repeat(32), kind: "complete", summary: "ok", outputBytes: 0, candidateId: null }); + records = append(records, "system-outcome", { kind: "exhausted", summary: "transitions" }); + records = append(records, "state-changed", { from: "running", to: "budget-exhausted", cause: "system" }); + assert.equal(foldStateMachine({ graph: testGraph, records }).state, "budget-exhausted"); +}); + +test("an atomic sequence-256 terminal event folds to the declared terminal projection", () => { + const first = createJournalRecord({ sequence: 1, prevDigest: null, at: 0, type: "run-created", payload: created() }); + const running = createJournalRecord({ sequence: 2, prevDigest: first.digest, at: 1, type: "state-changed", payload: { from: "prepared", to: "running", cause: "control" } }); + const leased = createJournalRecord({ sequence: 3, prevDigest: running.digest, at: 2, type: "lease-acquired", payload: { generation: 1, token: "a".repeat(64) } }); + const terminal = createJournalRecord({ sequence: 256, prevDigest: leased.digest, at: 3, type: "terminal-node-committed", payload: { kind: "exhausted", summary: "minutes", from: "running", to: "budget-exhausted", nodeId: "exhausted", attempt: 1 } }); + const folded = foldRun([first, running, leased, terminal]); + assert.equal(folded.projection.sequence, 256); assert.equal(folded.projection.state, "budget-exhausted"); assert.equal(folded.execution.system.kind, "exhausted"); +}); + +test("capacity terminalization clears an owner and is closed for every recoverable lifecycle state", () => { + const first = createJournalRecord({ sequence: 1, prevDigest: null, at: 0, type: "run-created", payload: created() }); + const terminal = (prior, from) => createJournalRecord({ sequence: 256, prevDigest: prior.digest, at: 4, type: "terminal-node-committed", payload: { kind: "exhausted", summary: "journal", from, to: "budget-exhausted", nodeId: "exhausted", attempt: 1 } }); + const prepared = foldRun([first, terminal(first, "prepared")]); assert.equal(prepared.projection.leaseHeld, false); + const running = createJournalRecord({ sequence: 2, prevDigest: first.digest, at: 1, type: "state-changed", payload: { from: "prepared", to: "running", cause: "control" } }); + const noOwner = foldRun([first, running, terminal(running, "running")]); assert.equal(noOwner.projection.leaseHeld, false); + const owner = createJournalRecord({ sequence: 3, prevDigest: running.digest, at: 2, type: "lease-acquired", payload: { generation: 1, token: "a".repeat(64) } }); + const owned = foldRun([first, running, owner, terminal(owner, "running")]); assert.equal(owned.projection.leaseHeld, false); + const paused = createJournalRecord({ sequence: 4, prevDigest: owner.digest, at: 3, type: "state-changed", payload: { from: "running", to: "paused", cause: "control" } }); + const pausedTerminal = foldRun([first, running, owner, paused, terminal(paused, "paused")]); assert.equal(pausedTerminal.projection.leaseHeld, false); + const stopped = createJournalRecord({ sequence: 4, prevDigest: owner.digest, at: 3, type: "state-changed", payload: { from: "running", to: "stopped", cause: "control" } }); + const contradictory = createJournalRecord({ sequence: 5, prevDigest: stopped.digest, at: 4, type: "terminal-node-committed", payload: { kind: "error", summary: "wrong terminal kind", from: "stopped", to: "stopped", nodeId: "failed", attempt: 1 } }); + assert.throws(() => foldRun([first, running, owner, stopped, contradictory]), /invalid atomic terminal/u); +}); diff --git a/src/loops/view/render.mjs b/src/loops/view/render.mjs new file mode 100644 index 00000000..90b3ed4b --- /dev/null +++ b/src/loops/view/render.mjs @@ -0,0 +1,106 @@ +import { outcomesFor } from "../dsl/grammar.mjs"; +import { validateClosedIr } from "../dsl/ir-validate.mjs"; + +const SYSTEM = ["error", "timeout", "cancelled", "lost", "exhausted"]; +const MODES = new Set(["UNPINNED", "ITEM-PINNED", "RUN-FROZEN"]); +const REVISION = /^(?:ls1|lp1|er1)-sha256:[a-f0-9]{64}$/u; +const MAX_ROWS = 1024; +const MAX_BYTES = 256 * 1024; + +export class LoopViewError extends Error { + constructor(code, message) { super(`${code}: ${message}`); this.name = "LoopViewError"; this.code = code; } +} + +function fail(code, message) { throw new LoopViewError(code, message); } +function utf8(value) { return Buffer.from(value, "utf8"); } +function compare(left, right) { return Buffer.compare(utf8(left), utf8(right)); } +function scalar(value, label) { + if (typeof value !== "string" || value.length === 0 || /[\u0000-\u001f\u007f-\u009f\uD800-\uDFFF]/u.test(value) || value.includes("\n") || value.includes("\r")) fail("ELOOP_VIEW_VALUE", `${label} is not a safe single-line value`); + return value; +} +function revision(value, label, prefix) { + if (typeof value !== "string" || !REVISION.test(value) || !value.startsWith(`${prefix}-`)) fail("ELOOP_VIEW_IR_INVALID", `${label} is invalid`); + return value; +} +function recipe(value, label) { + if (!value || typeof value !== "object" || !value.ir || !value.revisions) fail("ELOOP_VIEW_IR_INVALID", `${label} recipe is missing`); + if (!validateClosedIr(value.ir)) fail("ELOOP_VIEW_IR_INVALID", `${label} recipe is not closed normalized Stage-1 IR`); + const r = value.revisions; + revision(r.source, `${label} source revision`, "ls1"); revision(r.package, `${label} package revision`, "lp1"); revision(r.executable, `${label} executable revision`, "er1"); + return value; +} +function currentRecipe(authority) { return authority.currentCompiled ?? authority.current ?? null; } +function provenance(recipeValue) { return recipeValue?.revisions ?? {}; } +function status(assigned, current, mode) { + if (mode === "UNPINNED") return "not-applicable"; + if (mode === "RUN-FROZEN") return "not-checked"; + if (!current) return "unavailable"; + return assigned === current ? "match" : "drift"; +} + +function orderedNodes(ir) { + return [...ir.nodes].sort((a, b) => a.id === ir.entry ? -1 : b.id === ir.entry ? 1 : compare(a.id, b.id)); +} + +function graph(ir) { + const nodes = orderedNodes(ir), byId = new Map(nodes.map((node) => [node.id, node])); + const declared = new Map(ir.edges.map((edge) => [`${edge.from}\0${edge.on}`, edge])); + const expanded = []; + for (const node of nodes) { + if (node.kind === "terminal") continue; + for (const on of outcomesFor(node)) { const edge = declared.get(`${node.id}\0${on}`); expanded.push({ from: node.id, on, to: edge.to, maxVisits: edge.maxVisits, className: "semantic" }); } + for (const on of SYSTEM) expanded.push({ from: node.id, on, to: ir.failurePolicy[on], maxVisits: null, className: "system" }); + } + if (expanded.length > MAX_ROWS) fail("ELOOP_VIEW_ADJACENCY_CAP", `expanded adjacency exceeds ${MAX_ROWS} rows`); + const adjacency = new Map(nodes.map((node) => [node.id, []])); + for (const edge of expanded) adjacency.get(edge.from).push(edge.to); + // Tarjan follows the same byte-stable node and edge order as the view. + const index = new Map(), low = new Map(), stack = [], onStack = new Set(), components = []; let next = 0; + function visit(id) { + index.set(id, next); low.set(id, next++); stack.push(id); onStack.add(id); + const targets = [...new Set(adjacency.get(id))].sort(compare); + for (const target of targets) { + if (!index.has(target)) { visit(target); low.set(id, Math.min(low.get(id), low.get(target))); } + else if (onStack.has(target)) low.set(id, Math.min(low.get(id), index.get(target))); + } + if (low.get(id) === index.get(id)) { const component = []; let item; do { item = stack.pop(); onStack.delete(item); component.push(item); } while (item !== id); components.push(component.sort(compare)); } + } + for (const node of nodes) if (!index.has(node.id)) visit(node.id); + components.sort((a, b) => compare(a[0], b[0])); + const scc = new Map(); components.forEach((component, i) => component.forEach((id) => scc.set(id, i + 1))); + return { nodes, expanded, scc, byId }; +} + +function authorityRecipe(authority) { + if (!authority || typeof authority !== "object") fail("ELOOP_VIEW_AUTHORITY", "authority is required"); + const mode = authority.authority; + if (!MODES.has(mode)) fail("ELOOP_VIEW_AUTHORITY", "unknown authority mode"); + const selected = mode === "UNPINNED" ? authority.compiled : mode === "ITEM-PINNED" ? authority.artifact?.frozen : authority.frozen; + return { mode, selected: recipe(selected, mode.toLowerCase()), current: mode === "ITEM-PINNED" ? currentRecipe(authority) : mode === "UNPINNED" ? authority.compiled : null }; +} + +export function renderResolvedLoopView(authority) { + const { mode, selected, current } = authorityRecipe(authority), ir = selected.ir; + const currentValid = current ? (() => { try { return recipe(current, "current"); } catch { return null; } })() : null; + const selectedRevisions = provenance(selected), currentRevisions = provenance(currentValid); + const selector = scalar(authority.selector, "selector"), loop = scalar(authority.loopRef ?? `loop:builtin:${ir.id}`, "loop selector"); + const sourceAssigned = mode === "UNPINNED" ? "-" : scalar(selectedRevisions.source, "assigned source revision"); + const packageAssigned = mode === "UNPINNED" ? "-" : scalar(selectedRevisions.package, "assigned package revision"); + const sourceCurrent = mode === "RUN-FROZEN" ? "not-checked" : currentValid ? scalar(currentRevisions.source, "current source revision") : mode === "UNPINNED" ? scalar(selectedRevisions.source, "current source revision") : "unavailable"; + const packageCurrent = mode === "RUN-FROZEN" ? "not-checked" : currentValid ? scalar(currentRevisions.package, "current package revision") : mode === "UNPINNED" ? scalar(selectedRevisions.package, "current package revision") : "unavailable"; + const executionAssigned = mode === "UNPINNED" ? "-" : scalar(selectedRevisions.executable, "assigned execution revision"); + const executionCurrent = mode === "RUN-FROZEN" ? "not-checked" : currentValid ? scalar(currentRevisions.executable, "current execution revision") : mode === "UNPINNED" ? scalar(selectedRevisions.executable, "current execution revision") : "unavailable"; + const g = graph(ir); + const lines = ["BURNLIST LOOP VIEW @1", `MODE: ${mode}`, `SELECTOR: ${selector}`, `LOOP: ${loop}`, `DECLARED-VERSION: ${scalar(ir.declaredVersion, "declared version")}`, `COMPILER: ${scalar(ir.compiler, "compiler contract")}`, `EXECUTION: assigned=${executionAssigned} current=${executionCurrent} status=${status(executionAssigned, executionCurrent === "unavailable" ? null : executionCurrent, mode)}`, `SOURCE: assigned=${sourceAssigned} current=${sourceCurrent} status=${status(sourceAssigned, sourceCurrent === "unavailable" ? null : sourceCurrent, mode)}`, `PACKAGE: assigned=${packageAssigned} current=${packageCurrent} status=${status(packageAssigned, packageCurrent === "unavailable" ? null : packageCurrent, mode)}`, `PIN: ${mode === "UNPINNED" ? "unpinned" : mode === "ITEM-PINNED" ? "item-pinned" : "run-frozen"}`, "DRAWING (DECORATIVE):"]; + for (const edge of g.expanded.filter((item) => item.className === "semantic")) + lines.push(` ${edge.from === ir.entry ? "* " : " "}${edge.from} --${edge.on}--> ${edge.to}`); + lines.push("ADJACENCY (AUTHORITATIVE):"); + for (const node of g.nodes) { + lines.push(`${node.id} [kind=${node.kind} scc=${g.scc.get(node.id)}]`); + for (const edge of g.expanded.filter((item) => item.from === node.id)) lines.push(` ${edge.on} -> ${edge.to} [class=${edge.className} max-visits=${edge.maxVisits ?? "-"}]`); + } + lines.push("COMPLETION:", " converged -> cli-completion -> completed|completion-needs-human", "END"); + const output = `${lines.join("\n")}\n`; + if (Buffer.byteLength(output, "utf8") > MAX_BYTES) fail("ELOOP_VIEW_OUTPUT_CAP", `rendered output exceeds ${MAX_BYTES} bytes`); + return output; +} diff --git a/src/loops/view/render.test.mjs b/src/loops/view/render.test.mjs new file mode 100644 index 00000000..4dfd2cd9 --- /dev/null +++ b/src/loops/view/render.test.mjs @@ -0,0 +1,53 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { test } from "node:test"; +import { LoopViewError, renderResolvedLoopView } from "./render.mjs"; + +const ir = JSON.parse(readFileSync(new URL("../dsl/__fixtures__/review.ir.json", import.meta.url))); +const revisions = JSON.parse(readFileSync(new URL("../dsl/__fixtures__/review.revisions.json", import.meta.url))); +const compiled = { ir, revisions }; +const base = { authority: "UNPINNED", selector: "loop:builtin:review", compiled }; + +function outputDigest(value) { return createHash("sha256").update(value).digest("hex"); } + +test("renders the closed review graph byte-deterministically", () => { + const output = renderResolvedLoopView(base); + assert.equal(outputDigest(output), "cf8421f1e0c0017178e1563e75a7cd42455fdc0106bff862410916d9cf3cb0b9"); + assert.equal(output, renderResolvedLoopView({ ...base, terminalWidth: 1 })); + assert.match(output, /^BURNLIST LOOP VIEW @1\nMODE: UNPINNED/m); + assert.match(output, /DRAWING \(DECORATIVE\):\n \* implement --complete--> verify\n(?: .+\n)+ADJACENCY \(AUTHORITATIVE\):/); + for (const outcome of ["complete", "pass", "fail", "approve", "reject", "escalate", "error", "timeout", "cancelled", "lost", "exhausted"]) assert.match(output, new RegExp(`^ ${outcome} -> `, "m")); + assert.match(output, /^ reject -> implement \[class=semantic max-visits=3\]$/m); + assert.match(output, /^implement \[kind=agent scc=5\]$/m); + assert.match(output, /COMPLETION:\n converged -> cli-completion -> completed\|completion-needs-human\nEND\n$/); + assert.doesNotMatch(output, /\x1b|\r/); +}); + +test("renders each authority mode and item drift/unavailability", () => { + const item = renderResolvedLoopView({ authority: "ITEM-PINNED", selector: "item:260722-001#review", artifact: { frozen: compiled } }); + assert.match(item, /MODE: ITEM-PINNED/); + assert.match(item, /SOURCE: assigned=ls1-sha256:[a-f0-9]{64} current=unavailable status=unavailable/); + const drift = renderResolvedLoopView({ authority: "ITEM-PINNED", selector: "item:260722-001#review", artifact: { frozen: compiled }, currentCompiled: { ir, revisions: { ...revisions, source: `ls1-sha256:${"a".repeat(64)}` } } }); + assert.match(drift, /SOURCE: assigned=ls1-sha256:[a-f0-9]{64} current=ls1-sha256:a{64} status=drift/); + const frozen = renderResolvedLoopView({ authority: "RUN-FROZEN", selector: "run:0123456789abcdefghjkmnpqrs", frozen: compiled }); + assert.match(frozen, /MODE: RUN-FROZEN/); + assert.match(frozen, /SOURCE: assigned=ls1-sha256:[a-f0-9]{64} current=not-checked status=not-checked/); +}); + +test("keeps item graph pinned while reporting complete current provenance", () => { + const current = { ir, revisions: { source: `ls1-sha256:${"a".repeat(64)}`, package: `lp1-sha256:${"b".repeat(64)}`, executable: `er1-sha256:${"c".repeat(64)}` } }; + const output = renderResolvedLoopView({ authority: "ITEM-PINNED", selector: "item:260722-001#review", loopRef: "loop:builtin:review", artifact: { frozen: compiled }, currentCompiled: current }); + assert.match(output, new RegExp(`EXECUTION: assigned=${revisions.executable} current=er1-sha256:c{64} status=drift`)); + assert.match(output, /SOURCE: assigned=.* current=ls1-sha256:a{64} status=drift/); + assert.match(output, /PACKAGE: assigned=.* current=lp1-sha256:b{64} status=drift/); + assert.match(output, /^implement \[kind=agent scc=5\]$/m); +}); + +test("rejects malformed IR, control values, and oversized adjacency before returning", () => { + assert.throws(() => renderResolvedLoopView({ ...base, compiled: { ir: { ...ir, compiler: "unsupported" }, revisions } }), (error) => error instanceof LoopViewError && error.code === "ELOOP_VIEW_IR_INVALID"); + assert.throws(() => renderResolvedLoopView({ ...base, selector: "loop:builtin:review\nansi" }), (error) => error.code === "ELOOP_VIEW_VALUE"); + const tooMany = { ...ir, nodes: ir.nodes.map((node) => ({ ...node, id: `${node.id}-${"x".repeat(65500)}` })) }; + assert.throws(() => renderResolvedLoopView({ ...base, compiled: { ir: tooMany, revisions } }), (error) => error.code === "ELOOP_VIEW_IR_INVALID"); + assert.throws(() => renderResolvedLoopView({ ...base, selector: `loop:builtin:${"x".repeat(300000)}` }), (error) => error.code === "ELOOP_VIEW_OUTPUT_CAP"); +}); From 56c147c5d675b7993f0f798c5c3b289d2186f7b4 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Fri, 24 Jul 2026 10:14:03 +0200 Subject: [PATCH 02/37] feat: connect loop configuration and lifecycle CLI --- .gitignore | 1 + src/cli/commands-help.test.mjs | 164 +++++++++++++++++++++++- src/cli/lifecycle-moves.mjs | 35 +++++- src/cli/loop-cli.mjs | 101 +++++++++++++++ src/cli/loop-cli.test.mjs | 203 ++++++++++++++++++++++++++++++ src/cli/loop-config-cli.mjs | 128 +++++++++++++++++++ src/cli/loop-runtime-cli.test.mjs | 121 ++++++++++++++++++ src/server/dir-lock.mjs | 40 ++++-- src/server/dir-lock.test.mjs | 39 +++++- src/server/plan-model.mjs | 8 ++ 10 files changed, 821 insertions(+), 19 deletions(-) create mode 100644 src/cli/loop-cli.mjs create mode 100644 src/cli/loop-cli.test.mjs create mode 100644 src/cli/loop-config-cli.mjs create mode 100644 src/cli/loop-runtime-cli.test.mjs 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/src/cli/commands-help.test.mjs b/src/cli/commands-help.test.mjs index cffe5bc3..2285c6d7 100644 --- a/src/cli/commands-help.test.mjs +++ b/src/cli/commands-help.test.mjs @@ -1,10 +1,12 @@ import assert from "node:assert/strict"; import { execFileSync, spawnSync } from "node:child_process"; -import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import test from "node:test"; +import { createProductionRunAuthority, fixtureItemRef } from "../loops/run/run-test-fixtures.mjs"; +import { runStore } from "../loops/run/run-store.mjs"; const root = resolve(fileURLToPath(new URL("../..", import.meta.url))); const cli = join(root, "bin", "burnlist.mjs"); @@ -43,6 +45,17 @@ test("top-level and Oven help expose the validated use and set flow", () => { const top = run(context.directory, ["--help"]); assert.equal(top.status, 0, top.stderr); assert.match(top.stdout, /burnlist oven <[^\n]*use[^\n]*set[^\n]*>/u); + assert.match(top.stdout, /burnlist loop view \[--repo \]/u); + assert.match(top.stdout, /burnlist loop create \[--repo \]/u); + assert.match(top.stdout, /burnlist loop list \[--repo \]/u); + assert.match(top.stdout, /burnlist loop run\|resume \[--repo \]/u); + assert.match(top.stdout, /burnlist loop status\|inspect \[--repo \]/u); + assert.match(top.stdout, /burnlist loop pause\|stop \[--repo \] \(idle Run only\)/u); + assert.match(top.stdout, /burnlist loop reconcile --recovery-proof \[--repo \]/u); + assert.match(top.stdout, /burnlist loop complete \[--repo \]/u); + assert.match(top.stdout, /burnlist loop capability \.\.\./u); + assert.match(top.stdout, /burnlist agent \.\.\./u); + assert.match(top.stdout, /burnlist route set --profile \[--repo \]/u); const oven = run(context.directory, ["oven", "help"]); assert.equal(oven.status, 0, oven.stderr); @@ -54,6 +67,39 @@ test("top-level and Oven help expose the validated use and set flow", () => { } finally { context.cleanup(); } }); +test("Loop local configuration help exposes only explicit setup commands", () => { + const context = fixture({ git: false }); + try { + for (const [args, usage] of [ + [["agent", "--help"], /burnlist agent profile add /u], + [["agent", "--help"], /burnlist agent doctor /u], + [["route", "--help"], /burnlist route set /u], + [["loop", "--help"], /burnlist loop capability trust --revision cp1-sha256: --grants /u], + ]) { + const result = run(context.directory, args); + assert.equal(result.status, 0, result.stderr); assert.match(result.stdout, usage); assert.equal(result.stderr, ""); + } + for (const args of [["agent", "controller", "add", "host"], ["agent", "preflight", "maker"]]) { + const result = run(context.directory, args); + assert.equal(result.status, 2, args.join(" ")); + assert.match(result.stderr, /Usage: burnlist agent profile add/u); + } + } finally { context.cleanup(); } +}); + +test("nested Loop help snapshots every Stage 1 control", () => { + const context = fixture({ git: false }); + try { + const result = run(context.directory, ["loop", "--help"]); + assert.equal(result.status, 0, result.stderr); + for (const command of ["create", "list", "run|pause|resume|stop|complete", "status|inspect", "reconcile"]) { + assert.match(result.stdout, new RegExp(`burnlist loop ${command}`, "u")); + } + assert.match(result.stdout, /pause\|resume\|stop\|complete /u); + assert.equal(result.stderr, ""); + } finally { context.cleanup(); } +}); + test("empty skill and hook uninstalls report that there is nothing to remove", () => { const context = fixture(); try { @@ -97,3 +143,119 @@ test("hooks status and uninstall name their own Git requirement", () => { } } finally { context.cleanup(); } }); + +test("Review Loop documentation command matrix runs against the production fixture", (t) => { + const directory = mkdtempSync(join(tmpdir(), "burnlist-loop-docs-")); + t.after(() => rmSync(directory, { recursive: true, force: true })); + const { repo, binary } = createProductionRunAuthority(join(directory, "repo")); + const loop = (...args) => run(repo, ["loop", ...args, "--repo", repo]); + const command = (...args) => run(repo, [...args, "--repo", repo]); + const planPath = join(repo, "notes", "burnlists", "inprogress", "260722-001", "burnlist.md"); + writeFileSync(planPath, readFileSync(planPath, "utf8").replace("## Completed", [ + "- [ ] L30 | Direct workflow fixture", + "- [ ] L31 | Pause and resume fixture", + "- [ ] L32 | Stop fixture", + "- [ ] L33 | Reconcile fixture", + "", + "## Completed", + ].join("\n"))); + const profile = (slug, authority) => command("agent", "profile", "add", slug, + "--adapter", "builtin:codex-cli", "--binary", binary, "--model", "gpt-5.3-codex-spark", + "--effort", "medium", "--authority", authority); + for (const result of [profile("maker", "write"), profile("reviewer", "read"), + command("route", "set", "implementation.standard", "--profile", "maker"), + command("route", "set", "review.strong", "--profile", "reviewer"), + command("agent", "doctor", "maker"), command("agent", "doctor", "reviewer")]) { + assert.equal(result.status, 0, result.stderr); + } + const inspected = loop("capability", "inspect", "repo-verify"); + assert.equal(inspected.status, 0, inspected.stderr); + const revision = JSON.parse(inspected.stdout).revision; + const trusted = loop("capability", "trust", "repo-verify", "--revision", revision, "--grants", join(repo, "grants.json")); + assert.equal(trusted.status, 0, trusted.stderr); + for (const args of [["setup", "status"], ["list"], ["view", fixtureItemRef]]) { + const result = loop(...args); + assert.equal(result.status, 0, `${args.join(" ")}: ${result.stderr}`); + } + + const directRef = "item:260722-001#L30"; + const assignedDirect = loop("assign", directRef, "loop:builtin:review"); + assert.equal(assignedDirect.status, 0, assignedDirect.stderr); + const blockedBurn = command("burn", "260722-001", "L30", "--check"); + assert.equal(blockedBurn.status, 1); + assert.match(blockedBurn.stderr, /direct burn is blocked by Loop metadata/u); + const unassigned = loop("unassign", directRef); + assert.equal(unassigned.status, 0, unassigned.stderr); + const directBurn = command("burn", "260722-001", "L30", "--check"); + assert.equal(directBurn.status, 0, directBurn.stderr); + + const created = loop("create", fixtureItemRef); + assert.equal(created.status, 0, created.stderr); + const runId = JSON.parse(created.stdout).runId; + for (const args of [["status", runId], ["inspect", runId]]) { + const result = loop(...args); + assert.equal(result.status, 0, `${args.join(" ")}: ${result.stderr}`); + } + const counter = join(directory, "counter"); + writeFileSync(counter, "0"); + const executed = spawnSync(process.execPath, [cli, "loop", "run", runId, "--repo", repo], { + cwd: repo, + encoding: "utf8", + env: { ...process.env, BURNLIST_FAKE_COUNTER: counter, BURNLIST_FAKE_OUTCOMES: "complete,approve" }, + }); + assert.equal(executed.status, 0, executed.stderr); + assert.equal(JSON.parse(executed.stdout).state, "converged"); + for (const args of [["complete", runId], ["complete", runId]]) { + const result = loop(...args); + assert.equal(result.status, 0, `${args.join(" ")}: ${result.stderr}`); + } + + const pauseRef = "item:260722-001#L31"; + assert.equal(loop("assign", pauseRef, "loop:builtin:review").status, 0); + const pausedRun = JSON.parse(loop("create", pauseRef).stdout).runId; + const paused = loop("pause", pausedRun); + assert.equal(paused.status, 0, paused.stderr); + assert.equal(JSON.parse(paused.stdout).state, "paused"); + writeFileSync(counter, "0"); + const resumed = spawnSync(process.execPath, [cli, "loop", "resume", pausedRun, "--repo", repo], { + cwd: repo, encoding: "utf8", + env: { ...process.env, BURNLIST_FAKE_COUNTER: counter, BURNLIST_FAKE_OUTCOMES: "complete,approve" }, + }); + assert.equal(resumed.status, 0, resumed.stderr); + assert.equal(JSON.parse(resumed.stdout).state, "converged"); + + const stoppedRef = "item:260722-001#L32"; + assert.equal(loop("assign", stoppedRef, "loop:builtin:review").status, 0); + const stoppedRun = JSON.parse(loop("create", stoppedRef).stdout).runId; + const stopped = loop("stop", stoppedRun); + assert.equal(stopped.status, 0, stopped.stderr); + assert.equal(JSON.parse(stopped.stdout).state, "stopped"); + + const reconciledRef = "item:260722-001#L33"; + assert.equal(loop("assign", reconciledRef, "loop:builtin:review").status, 0); + const reconciledRun = JSON.parse(loop("create", reconciledRef).stdout).runId; + const store = runStore(repo), acquired = store.acquireLease(reconciledRun); + store.append(reconciledRun, acquired.lease, "node-started", { nodeId: "implement", attempt: 1 }); + store.append(reconciledRun, acquired.lease, "invocation-started", { nodeId: "implement", attempt: 1, invocationId: "a".repeat(32) }); + const reconciled = loop("reconcile", reconciledRun, "--recovery-proof", acquired.recoveryProof); + assert.equal(reconciled.status, 0, reconciled.stderr); + assert.equal(JSON.parse(reconciled.stdout).state, "needs-human"); +}); + +test("Review Loop documentation preserves Stage 1 boundaries", () => { + const files = [ + join(root, "README.md"), + join(root, "website", "src", "content", "docs", "loops.mdx"), + join(root, "skills", "burnlist", "SKILL.md"), + ].map((path) => readFileSync(path, "utf8")).join("\n"); + assert.match(files, /filesystem write denial.*supervised/us); + assert.match(files, /fresh reviewer process.*enforced/us); + assert.match(files, /[Pp]arallelism.*unsupported/us); + assert.match(files, /Docker isolation.*unsupported/us); + assert.match(files, /custom adapters.*unsupported/us); + assert.match(files, /forecasting.*unsupported/us); + assert.match(files, /skill.*hooks.*independent/us); + assert.match(files, /loop-capability-example\.json/us); + assert.match(files, /gpt-5\.6-terra/us); + assert.match(files, /idle.*foreground owner/us); +}); diff --git a/src/cli/lifecycle-moves.mjs b/src/cli/lifecycle-moves.mjs index a3872bda..13620e82 100644 --- a/src/cli/lifecycle-moves.mjs +++ b/src/cli/lifecycle-moves.mjs @@ -6,6 +6,7 @@ import { mkdirSync, openSync, readFileSync, + readdirSync, renameSync, rmSync, rmdirSync, @@ -25,6 +26,7 @@ import { ovenLifecycleChangedInput, publishCanonicalMutation, } from "../events/oven-canonical-mutations.mjs"; +import { assertDirectBurnAllowed } from "../loops/assignment/assignment.mjs"; export { withLock } from "../server/fs-safe.mjs"; @@ -176,6 +178,7 @@ export function startLifecycle(repoRoot, id, eventOptions) { export function closeLifecycle(repoRoot, id, eventOptions) { assertValidBurnlistId(id); + assertNoPendingLoopCompletion(repoRoot, id); const inprogressDir = join(repoRoot, "notes", "burnlists", "inprogress", id); const completedDir = join(repoRoot, "notes", "burnlists", "completed", id); if (!safeStat(inprogressDir)?.isDirectory() && safeStat(completedDir)?.isDirectory()) { @@ -201,6 +204,29 @@ export function closeLifecycle(repoRoot, id, eventOptions) { }); } +// Completion publishes an intent before it rewrites the shrinking plan. Do +// not move that plan out from under a recoverable intent: the retry owns the +// receipt and is the only writer that may clear it. +function assertNoPendingLoopCompletion(repoRoot, id) { + const runs = join(repoRoot, ".local", "burnlist", "loop", "m2", "runs"); + if (!safeStat(runs)?.isDirectory()) return; + for (const entry of readdirSync(runs, { withFileTypes: true })) { + if (!entry.isDirectory() || !/^[a-f0-9]+$/u.test(entry.name)) continue; + const intent = join(runs, entry.name, "completion-intent.json"), stat = safeStat(intent); + if (!stat?.isFile() || stat.size < 2 || stat.size > 8192) continue; + try { + const value = JSON.parse(readFileSync(intent, "utf8")); + if (typeof value?.itemRef === "string" && value.itemRef.startsWith(`item:${id}#`)) + throw new Error(`not ready to close: pending Loop completion for ${value.itemRef}`); + } catch (error) { + if (error?.message?.startsWith("not ready to close:")) throw error; + // A corrupt unrelated scratch record cannot be interpreted as a safe + // absence. It is conservative only for the lifecycle being closed. + throw new Error("not ready to close: pending Loop completion is unreadable"); + } + } +} + function assertCloseGate(plan) { if (plan.items.length || !plan.completed.length) { throw new Error("not ready to close: active checklist must be empty with completed entries"); @@ -249,7 +275,7 @@ function appendCompleted(lines, entry) { lines.splice(end < 0 ? lines.length : end, 0, entry); } -function checklistCompletion(id, item, completedAt, plan) { +export function checklistCompletion(id, item, completedAt, plan) { const total = plan.items.length + plan.completed.length; return { ovenId: "checklist", @@ -277,7 +303,7 @@ function printBurnCheck(plan, issues) { console.log(`Burnlist check passed: ${plan.items.length} active, ${plan.completed.length} completed.`); } -export function burnItem(repoRoot, id, itemId, check = false) { +export function burnItem(repoRoot, id, itemId, check = false, { hazardAuthority, completedAt: requestedCompletedAt } = {}) { assertValidBurnlistId(id); const found = findBurnlistDir(repoRoot, id); if (found.lifecycle.folder !== "inprogress") { @@ -296,8 +322,11 @@ export function burnItem(repoRoot, id, itemId, check = false) { if (check) printBurnCheck(plan, currentIssues); return checklistCompletion(id, completed, completed.completedAt, plan); } + // Direct lifecycle burns intentionally remain unchanged for unassigned + // items, but must never bypass any Loop-shaped metadata. + assertDirectBurnAllowed({ repoRoot, itemRef: `item:${id}#${itemId}`, markdown: plan.markdown, hazardAuthority }); const lines = removeActiveItem(plan.markdown, itemId); - const completedAt = localIsoTimestamp(); + const completedAt = requestedCompletedAt ?? localIsoTimestamp(); appendCompleted(lines, `- ${item.id} | ${completedAt} | ${item.title}`); const nextMarkdown = `${lines.join("\n").replace(/\s*$/u, "")}\n`; const temporary = join(dirname(planPath), `.${basename(planPath)}.${randomBytes(8).toString("hex")}.tmp`); diff --git a/src/cli/loop-cli.mjs b/src/cli/loop-cli.mjs new file mode 100644 index 00000000..2df8dfab --- /dev/null +++ b/src/cli/loop-cli.mjs @@ -0,0 +1,101 @@ +#!/usr/bin/env node +import { resolve } from "node:path"; +import { assignLoopItem, prepareItemMutation, unassignLoopItem } from "../loops/assignment/assignment.mjs"; +import { resolveLoopAuthority } from "../loops/assignment/resolver.mjs"; +import { loopConfigUsage, runLoopConfigCli } from "./loop-config-cli.mjs"; +import { resolveUmbrella } from "./umbrella.mjs"; +import { runStore } from "../loops/run/run-store.mjs"; +import { createLoopController } from "../loops/run/controller.mjs"; +import { createProductionRun, createStoredProductionRunRunner } from "../loops/run/binder.mjs"; +import { completeLoopRun } from "../loops/completion/completion.mjs"; + +function usageText() { return loopConfigUsage(); } +function options(tokens) { + const positionals = []; let repo = null, recoveryProof = null; + for (let index = 0; index < tokens.length; index += 1) { + if (tokens[index] === "--repo") { + if (repo !== null) throw new Error("--repo must be specified at most once."); + repo = tokens[++index]; + if (!repo || repo.startsWith("--")) throw new Error("--repo requires a path."); + } + else if (tokens[index] === "--recovery-proof") { + if (recoveryProof !== null) throw new Error("--recovery-proof must be specified at most once."); + recoveryProof = tokens[++index]; if (!/^[a-f0-9]{64}$/u.test(recoveryProof ?? "")) throw new Error("--recovery-proof requires a 64-character lowercase hex value."); + } + else if (tokens[index].startsWith("--")) throw new Error(`Unknown option: ${tokens[index]}`); + else positionals.push(tokens[index]); + } + return { positionals, recoveryProof, repo: repo ? resolve(process.cwd(), repo) : resolveUmbrella(process.cwd()) }; +} + +export async function renderLoopView({ selector, repoRoot, runReader }) { + const authority = await resolveLoopAuthority({ repoRoot, selector, runReader }); + const { renderResolvedLoopView } = await import("../loops/view/render.mjs"); + return { authority, output: renderResolvedLoopView(authority) }; +} + +export async function runLoopCli(tokens, { runReader, runnerFor, stdout = process.stdout, processObject = process } = {}) { + if (tokens[0] === "--help" || tokens[0] === "-h") { stdout.write(`${usageText()}\n`); return null; } + if (["capability", "setup"].includes(tokens[0])) { + const value = await runLoopConfigCli(tokens); stdout.write(value.output); return value; + } + const [verb, ...rest] = tokens; const opts = options(rest); + if (verb === "create") { + if (opts.positionals.length !== 1 || opts.recoveryProof) { const error = new Error(usageText()); error.exitCode = 2; throw error; } + const store = runStore(opts.repo), result = await createProductionRun({ repoRoot: opts.repo, store, itemRef: opts.positionals[0] }); + stdout.write(`${JSON.stringify({ schema: "burnlist-loop-status@1", ...result.projection })}\n`); return result; + } + if (verb === "complete") { + if (opts.positionals.length !== 1 || opts.recoveryProof) { const error = new Error(usageText()); error.exitCode = 2; throw error; } + const result = completeLoopRun({ repoRoot: opts.repo, runId: opts.positionals[0] }); + stdout.write(`${JSON.stringify({ schema: "burnlist-loop-completion@1", ...result })}\n`); return result; + } + if (["list", "status", "inspect", "run", "pause", "resume", "stop", "reconcile"].includes(verb)) { + const allowed = verb === "list" ? 0 : 1; + if (opts.positionals.length !== allowed) { const error = new Error(usageText()); error.exitCode = 2; throw error; } + const store = runStore(opts.repo); + if (opts.recoveryProof && verb !== "reconcile") { const error = new Error(usageText()); error.exitCode = 2; throw error; } + const suppliedRunnerFor = runnerFor ?? ((runId) => createStoredProductionRunRunner({ repoRoot: opts.repo, store, runId })); + const runners = new Map(), runtimeRunnerFor = (runId) => { + if (!runners.has(runId)) runners.set(runId, suppliedRunnerFor(runId)); + return runners.get(runId); + }; + const controller = createLoopController({ store, runnerFor: runtimeRunnerFor }); + const result = verb === "list" ? controller.list() + : verb === "status" ? controller.status(opts.positionals[0]) + : verb === "inspect" ? controller.inspect(opts.positionals[0]) + : verb === "pause" ? controller.pause(opts.positionals[0]) + : verb === "stop" ? controller.stop(opts.positionals[0]) + : verb === "reconcile" ? controller.reconcile(opts.positionals[0], opts.recoveryProof ? { generation: store.read(opts.positionals[0]).execution.generation, recoveryProof: opts.recoveryProof } : null) + : verb === "resume" || verb === "run" ? await (() => { + const runner = runtimeRunnerFor(opts.positionals[0]); let signalled = false; + const onInterrupt = () => { if (!signalled) { signalled = true; runner.requestPause?.(); } else runner.requestStop?.(); }; + processObject.on?.("SIGINT", onInterrupt); + return controller.run(opts.positionals[0]).finally(() => processObject.removeListener?.("SIGINT", onInterrupt)); + })() : null; + stdout.write(controller.render(result)); return result; + } + if (verb === "assign" && opts.positionals.length === 2) { + const prepared = prepareItemMutation({ repoRoot: opts.repo, itemRef: opts.positionals[0] }); + const result = await assignLoopItem({ repoRoot: opts.repo, itemRef: opts.positionals[0], loopRef: opts.positionals[1], prepared }); + stdout.write(`${result.assignmentId}\n${result.selector}\n${result.executionRevision}\n`); return result; + } + if (verb === "unassign" && opts.positionals.length === 1) { + const prepared = prepareItemMutation({ repoRoot: opts.repo, itemRef: opts.positionals[0] }); + const result = unassignLoopItem({ repoRoot: opts.repo, itemRef: opts.positionals[0], prepared }); + stdout.write(`${result.assignmentId}\n`); return result; + } + if (verb === "view" && opts.positionals.length === 1) { + const result = await renderLoopView({ selector: opts.positionals[0], repoRoot: opts.repo, runReader }); + stdout.write(result.output); return result.authority; + } + const error = new Error(usageText()); error.exitCode = 2; throw error; +} + +export async function runLoopCliEntry(tokens = process.argv.slice(3)) { + try { const result = await runLoopCli(tokens); process.exitCode = result?.exitCode ?? 0; return result?.result ?? result; } + catch (error) { + process.stderr.write(`burnlist: ${error?.message ?? String(error)}\n`); + process.exitCode = error?.exitCode ?? 1; return null; + } +} diff --git a/src/cli/loop-cli.test.mjs b/src/cli/loop-cli.test.mjs new file mode 100644 index 00000000..d81bf1c2 --- /dev/null +++ b/src/cli/loop-cli.test.mjs @@ -0,0 +1,203 @@ +#!/usr/bin/env node +import assert from "node:assert/strict"; +import { execFileSync, spawnSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; +import { runLoopCli } from "./loop-cli.mjs"; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), "..", ".."); +const cli = join(root, "bin", "burnlist.mjs"); +const itemRef = "item:260722-001#BUG-07"; +const runRef = "run:01arz3ndektsv4rrffq69g5fav"; + +function fixture({ git = true } = {}) { + const directory = mkdtempSync(join(tmpdir(), "burnlist-loop-cli-")); + const repo = join(directory, "repo"); + mkdirSync(repo, { recursive: true }); + if (git) execFileSync("git", ["init", "--quiet", repo]); + const planDir = join(repo, "notes", "burnlists", "inprogress", "260722-001"); + mkdirSync(planDir, { recursive: true }); + writeFileSync(join(planDir, "burnlist.md"), [ + "# Test", + "", + "## Active Checklist", + "- [ ] BUG-07 | Keep this line", + "", + "## Completed", + "", + ].join("\n")); + return { directory, repo, cleanup: () => rmSync(directory, { recursive: true, force: true }) }; +} + +function runCommand(repo, args) { + return spawnSync(process.execPath, [cli, ...args], { cwd: repo, encoding: "utf8" }); +} +function assign(context) { + const result = runCommand(context.repo, ["loop", "assign", itemRef, "loop:builtin:review", "--repo", context.repo]); + assert.equal(result.status, 0, result.stderr); + const [assignmentId, selector, executable] = result.stdout.trimEnd().split("\n"); + return { assignmentId, selector, executable, path: join(context.repo, ".local", "burnlist", "loop", "v2", "assignments", assignmentId.slice(11)) }; +} +function pinError(executable) { + return `burnlist: ELOOP_PIN_BYTES_UNAVAILABLE: pinned=${executable} current=${executable}; restore the assignment artifact or safely unassign and reassign the item\n`; +} + +test("loop view renders unpinned review with canonical selector", () => { + const context = fixture(); + try { + const result = runCommand(context.repo, ["loop", "view", "review", "--repo", context.repo]); + assert.equal(result.status, 0, `${result.stderr}`); + assert.match(result.stdout, /loop:builtin:review/); + assert.equal(result.stderr, ""); + } finally { context.cleanup(); } +}); + +test("loop view renders item-pinned authority from fixture assignment", () => { + const context = fixture(); + try { + assign(context); + const result = runCommand(context.repo, ["loop", "view", itemRef, "--repo", context.repo]); + assert.equal(result.status, 0, `${result.stderr}`); + assert.match(result.stdout, /loop:builtin:review/); + assert.equal(result.stderr, ""); + } finally { context.cleanup(); } +}); + +test("loop view rejects invalid selectors and keeps stdout empty on failure", () => { + const context = fixture(); + try { + const invalid = runCommand(context.repo, ["loop", "view", "nonsense", "--repo", context.repo]); + assert.equal(invalid.status, 1); + assert.equal(invalid.stdout, ""); + assert.equal(invalid.stderr, "burnlist: E_LOOP_SELECTOR_INVALID: Invalid Loop selector: nonsense\n"); + } finally { context.cleanup(); } +}); + +test("item-pinned view reports stable recovery for missing, corrupt, and drifted artifacts", () => { + for (const mutation of ["missing", "corrupt", "symlink", "binding-drift"]) { + const context = fixture(); + try { + const assigned = assign(context); + if (mutation === "missing") rmSync(join(assigned.path, "recipe.frozen")); + if (mutation === "corrupt") writeFileSync(join(assigned.path, "recipe.frozen"), "not a frozen recipe\n"); + if (mutation === "symlink") { + rmSync(join(assigned.path, "recipe.frozen")); + symlinkSync(join(context.repo, "notes"), join(assigned.path, "recipe.frozen")); + } + if (mutation === "binding-drift") { + const path = join(assigned.path, "manifest.json"); + const manifest = JSON.parse(readFileSync(path, "utf8")); + manifest.itemRef = "item:260722-001#OTHER"; + writeFileSync(path, `${JSON.stringify(manifest)}\n`, { mode: 0o600 }); + } + const result = runCommand(context.repo, ["loop", "view", itemRef, "--repo", context.repo]); + assert.equal(result.status, 1, mutation); + assert.equal(result.stdout, "", mutation); + assert.equal(result.stderr, pinError(assigned.executable), mutation); + } finally { context.cleanup(); } + } +}); + +test("loop view rejects duplicate, missing, and unknown --repo options", () => { + const context = fixture(); + try { + const duplicate = runCommand(context.repo, ["loop", "view", "review", "--repo", context.repo, "--repo", context.repo]); + assert.equal(duplicate.status, 1); + assert.equal(duplicate.stdout, ""); + assert.match(duplicate.stderr, /--repo must be specified at most once/u); + + const missing = runCommand(context.repo, ["loop", "view", "review", "--repo"]); + assert.equal(missing.status, 1); + assert.equal(missing.stdout, ""); + assert.match(missing.stderr, /--repo requires a path/u); + + const unknown = runCommand(context.repo, ["loop", "view", "review", "--repo", context.repo, "--mystery"]); + assert.equal(unknown.status, 1); + assert.equal(unknown.stdout, ""); + assert.match(unknown.stderr, /Unknown option: --mystery/u); + } finally { context.cleanup(); } +}); + +test("loop view emits empty stdout for unavailable Run store and unavailable Loop package", () => { + const context = fixture(); + try { + const runUnavailable = runCommand(context.repo, ["loop", "view", runRef, "--repo", context.repo]); + assert.equal(runUnavailable.status, 1); + assert.equal(runUnavailable.stdout, ""); + assert.equal(runUnavailable.stderr, "burnlist: E_RUN_UNAVAILABLE: Run-frozen authority is unavailable before the Run store is installed\n"); + + const loopMissing = runCommand(context.repo, ["loop", "view", "loop:builtin:does-not-exist", "--repo", context.repo]); + assert.equal(loopMissing.status, 1); + assert.equal(loopMissing.stdout, ""); + assert.match(loopMissing.stderr, /not found/u); + } finally { context.cleanup(); } +}); + +test("injected verified Run reader renders identical bytes for TTY and pipe and ignores record loopRef", async () => { + const context = fixture(); + try { + const assigned = assign(context); + const frozenRecipe = readFileSync(join(assigned.path, "recipe.frozen")); + const runReader = async (runId) => ({ runId, loopRef: "loop:builtin:attacker\nINJECTED", frozenRecipe }); + const capture = (isTTY) => { + let bytes = ""; return { stream: { isTTY, write(value) { bytes += value; } }, read: () => bytes }; + }; + const pipe = capture(false), tty = capture(true); + await runLoopCli(["view", runRef, "--repo", context.repo], { runReader, stdout: pipe.stream }); + await runLoopCli(["view", runRef, "--repo", context.repo], { runReader, stdout: tty.stream }); + assert.equal(tty.read(), pipe.read()); + assert.match(pipe.read(), /^MODE: RUN-FROZEN$/m); + assert.match(pipe.read(), /^LOOP: loop:builtin:review$/m); + assert.match(pipe.read(), /^EXECUTION: assigned=er1-sha256:[a-f0-9]{64} current=not-checked status=not-checked$/m); + assert.doesNotMatch(pipe.read(), /attacker|INJECTED|\x1b|\r/u); + } finally { context.cleanup(); } +}); + +test("real executable emits the same view bytes through a pipe and a PTY", { skip: process.platform !== "darwin" }, () => { + const context = fixture(); + try { + const args = ["loop", "view", "review", "--repo", context.repo]; + const pipe = runCommand(context.repo, args); + assert.equal(pipe.status, 0, pipe.stderr); + const expect = [ + "set timeout 20", + 'spawn -noecho sh -c {stty -onlcr; exec "$BL_NODE" "$BL_CLI" loop view review --repo "$BL_REPO"}', + "expect eof", + "set result [wait]", + "exit [lindex $result 3]", + ].join("; "); + const pty = spawnSync("expect", ["-c", expect], { + cwd: context.repo, + encoding: "utf8", + env: { ...process.env, BL_NODE: process.execPath, BL_CLI: cli, BL_REPO: context.repo }, + }); + assert.equal(pty.status, 0, pty.stderr); + assert.equal(pty.stdout, pipe.stdout); + } finally { context.cleanup(); } +}); + +test("loop --help lists view and assign/unassign syntax", () => { + const context = fixture({ git: false }); + try { + const help = runCommand(context.repo, ["loop", "--help"]); + assert.equal(help.status, 0, help.stderr); + assert.equal(help.stderr, ""); + assert.match(help.stdout, /Usage: burnlist loop assign \[--repo \] \| burnlist loop unassign \[--repo \] \| burnlist loop view \[--repo \]/u); + } finally { context.cleanup(); } +}); + +test("existing loop assign/unassign smoke stays stable", () => { + const context = fixture(); + try { + const assign = runCommand(context.repo, ["loop", "assign", itemRef, "loop:builtin:review", "--repo", context.repo]); + assert.equal(assign.status, 0, `${assign.stderr}`); + assert.match(assign.stdout, /^as\d+-sha256:/u); + + const unassign = runCommand(context.repo, ["loop", "unassign", itemRef, "--repo", context.repo]); + assert.equal(unassign.status, 0, `${unassign.stderr}`); + assert.match(unassign.stdout, /^as\d+-sha256:/u); + } finally { context.cleanup(); } +}); diff --git a/src/cli/loop-config-cli.mjs b/src/cli/loop-config-cli.mjs new file mode 100644 index 00000000..26e163d1 --- /dev/null +++ b/src/cli/loop-config-cli.mjs @@ -0,0 +1,128 @@ +import { closeSync, constants, fstatSync, lstatSync, openSync, readSync } from "node:fs"; +import { join, parse as parsePath, relative, resolve, sep } from "node:path"; +import { readCapabilityCatalog, resolveCapability } from "../loops/capabilities/contract.mjs"; +import { trustCapability } from "../loops/capabilities/trust.mjs"; +import { doctorProfile, saveProfile, saveRoute } from "../loops/config/profiles.mjs"; +import { renderSetupStatus, setupStatus } from "../loops/config/setup.mjs"; +import { rawSha256 } from "../loops/dsl/hash.mjs"; +import { resolveUmbrella } from "./umbrella.mjs"; + +const profileUsage = "Usage: burnlist agent profile add --adapter builtin:codex-cli --binary --model --effort --authority read|write [--repo ]"; +const routeUsage = "Usage: burnlist route set --profile [--repo ]"; +const agentUsage = `${profileUsage}\n burnlist agent doctor [--repo ]`; +const loopUsage = "Usage: burnlist loop assign [--repo ] | burnlist loop unassign [--repo ] | burnlist loop view [--repo ]\n burnlist loop create [--repo ]\n burnlist loop run|pause|resume|stop|complete [--repo ]\n burnlist loop list [--repo ] | burnlist loop status|inspect [--repo ]\n burnlist loop reconcile --recovery-proof [--repo ]\n burnlist loop capability inspect [--repo ]\n burnlist loop capability trust --revision cp1-sha256: --grants [--repo ]\n burnlist loop setup status [--repo ]"; + +function fail(message, exitCode = 2) { throw Object.assign(new Error(message), { exitCode }); } +function parse(tokens, allowed = []) { + const values = {}, positionals = []; + for (let index = 0; index < tokens.length; index += 1) { + const token = tokens[index]; + if (!token.startsWith("--")) { positionals.push(token); continue; } + if (!allowed.includes(token)) fail(`Unknown option: ${token}`); + if (Object.hasOwn(values, token)) fail(`${token} must be specified at most once.`); + const value = tokens[++index]; if (!value || value.startsWith("--")) fail(`${token} requires a value.`); + values[token] = value; + } + const repo = values["--repo"] ? resolve(process.cwd(), values["--repo"]) : resolveUmbrella(process.cwd()); + return { positionals, values, repo }; +} +function requireOnly(positionals, count, usage) { if (positionals.length !== count) fail(usage); } +function canonical(value) { return `${JSON.stringify(value)}\n`; } +function catalogOrGuidance(repo) { + try { return readCapabilityCatalog(repo); } + catch (error) { + if (error?.code === "ENOENT") fail("capability catalog is missing; create .burnlist/loop-capabilities.json from the Review Loop capability example, then run inspect again", 1); + throw error; + } +} +function same(left, right) { return left.dev === right.dev && left.ino === right.ino && left.mode === right.mode && left.size === right.size; } +function boundedNoFollow(path, maximum) { + const target = resolve(path), root = parsePath(target).root, parts = relative(root, target).split(sep), ancestors = []; let current = root; + for (const part of parts.slice(0, -1)) { + current = join(current, part); const stat = lstatSync(current); + if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error("unsafe path"); + ancestors.push({ path: current, stat }); + } + const leaf = lstatSync(target); + if (!leaf.isFile() || leaf.isSymbolicLink() || leaf.size > maximum) throw new Error("unsafe file"); + let fd; try { + fd = openSync(target, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)); const opened = fstatSync(fd); + if (!opened.isFile() || !same(leaf, opened)) throw new Error("file changed"); + const bytes = Buffer.allocUnsafe(opened.size); let offset = 0; + while (offset < bytes.length) { const count = readSync(fd, bytes, offset, bytes.length - offset, offset); if (count <= 0) throw new Error("short read"); offset += count; } + if (!same(opened, fstatSync(fd)) || !same(opened, lstatSync(target))) throw new Error("file changed"); + for (const item of ancestors) { const stat = lstatSync(item.path); if (!stat.isDirectory() || stat.isSymbolicLink() || stat.dev !== item.stat.dev || stat.ino !== item.stat.ino) throw new Error("path changed"); } + return bytes; + } finally { if (fd !== undefined) closeSync(fd); } +} +function boundedJson(path) { + const target = resolve(process.cwd(), path); + let bytes; try { bytes = boundedNoFollow(target, 65536); } + catch { fail("--grants must name a bounded regular no-follow JSON file", 1); } + try { return JSON.parse(bytes.toString("utf8")); } catch { fail("--grants must name a readable JSON file", 1); } +} + +export function loopConfigUsage() { return loopUsage; } +export function agentConfigUsage() { return agentUsage; } + +export async function runAgentCli(tokens, { stdout = process.stdout } = {}) { + if (tokens[0] === "--help" || tokens[0] === "-h") return { output: `${agentUsage}\n` }; + const [verb, ...tail] = tokens; + if (verb === "profile" && tail[0] === "add") { + const rest = tail.slice(1); + const parsed = parse(rest, ["--adapter", "--binary", "--model", "--effort", "--authority", "--repo"]); requireOnly(parsed.positionals, 1, profileUsage); + for (const key of ["--adapter", "--binary", "--model", "--effort", "--authority"]) if (!parsed.values[key]) fail(profileUsage); + const result = saveProfile({ repoRoot: parsed.repo, slug: parsed.positionals[0], adapter: parsed.values["--adapter"], binary: parsed.values["--binary"], model: parsed.values["--model"], effort: parsed.values["--effort"], authority: parsed.values["--authority"] }); + return { output: canonical(result), result }; + } + if (verb === "doctor") { + const parsed = parse(tail, ["--repo"]); requireOnly(parsed.positionals, 1, agentUsage); + const result = await doctorProfile({ repoRoot: parsed.repo, slug: parsed.positionals[0] }); + if (result.available) return { output: `Agent ${result.profile.id}: available\nConfiguration authority: profile record only; launch verification is deferred.\n`, result }; + return { output: `Agent ${result.profile.id}: unavailable, not ready\nREMEDIATION: ${profileUsage}\n`, result, exitCode: 1 }; + } + fail(agentUsage); +} + +export function runRouteCli(tokens) { + if (tokens[0] === "--help" || tokens[0] === "-h") return { output: `${routeUsage}\n` }; + const [verb, ...rest] = tokens; + if (verb !== "set") fail(routeUsage); + const parsed = parse(rest, ["--profile", "--repo"]); requireOnly(parsed.positionals, 1, routeUsage); + if (!parsed.values["--profile"]) fail(routeUsage); + const result = saveRoute({ repoRoot: parsed.repo, route: parsed.positionals[0], profile: parsed.values["--profile"] }); + return { output: canonical(result), result }; +} + +export async function runLoopConfigCli(tokens) { + const [kind, action, ...rest] = tokens; + if (kind === "capability" && action === "inspect") { + const parsed = parse(rest, ["--repo"]); requireOnly(parsed.positionals, 1, loopUsage); + const resolved = resolveCapability(catalogOrGuidance(parsed.repo), parsed.positionals[0]); + return { output: canonical({ schema: "burnlist-loop-capability-inspect@1", capability: resolved.policy.id, revision: resolved.revision, policyDigest: rawSha256(resolved.bytes) }), result: resolved }; + } + if (kind === "capability" && action === "trust") { + const parsed = parse(rest, ["--revision", "--grants", "--repo"]); requireOnly(parsed.positionals, 1, loopUsage); + if (!parsed.values["--revision"] || !parsed.values["--grants"]) fail(loopUsage); + const resolved = resolveCapability(catalogOrGuidance(parsed.repo), parsed.positionals[0]); + if (parsed.values["--revision"] !== resolved.revision) fail(`capability revision does not match inspected ${resolved.revision}`, 1); + const grants = boundedJson(parsed.values["--grants"]); + const result = trustCapability({ repoRoot: parsed.repo, capability: resolved.policy, grants }); + return { output: canonical(result), result }; + } + if (kind === "setup" && action === "status") { + const parsed = parse(rest, ["--repo"]); requireOnly(parsed.positionals, 0, loopUsage); + const result = await setupStatus({ repoRoot: parsed.repo }); return { output: renderSetupStatus(result), result, exitCode: result.ready ? 0 : 1 }; + } + fail(loopUsage); +} + +export function writeCliResult(value, stdout = process.stdout) { stdout.write(value.output); return value.result ?? null; } +export async function runAgentCliEntry(tokens = process.argv.slice(3)) { + try { const value = await runAgentCli(tokens); writeCliResult(value); process.exitCode = value.exitCode ?? 0; return value.result ?? null; } + catch (error) { process.stderr.write(`burnlist: ${error?.message ?? String(error)}\n`); process.exitCode = error?.exitCode ?? 1; return null; } +} +export async function runRouteCliEntry(tokens = process.argv.slice(3)) { + try { const value = runRouteCli(tokens); writeCliResult(value); process.exitCode = value.exitCode ?? 0; return value.result ?? null; } + catch (error) { process.stderr.write(`burnlist: ${error?.message ?? String(error)}\n`); process.exitCode = error?.exitCode ?? 1; return null; } +} diff --git a/src/cli/loop-runtime-cli.test.mjs b/src/cli/loop-runtime-cli.test.mjs new file mode 100644 index 00000000..11b55d54 --- /dev/null +++ b/src/cli/loop-runtime-cli.test.mjs @@ -0,0 +1,121 @@ +import assert from "node:assert/strict"; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawn, spawnSync } from "node:child_process"; +import { EventEmitter } from "node:events"; +import test from "node:test"; +import { createProductionRunAuthority, fixtureItemRef } from "../loops/run/run-test-fixtures.mjs"; +import { runLoopCli } from "./loop-cli.mjs"; +import { runStore } from "../loops/run/run-store.mjs"; +import { createProductionRun } from "../loops/run/binder.mjs"; +import { fixtureRunId } from "../loops/run/run-test-fixtures.mjs"; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), "..", ".."); +const cli = join(root, "bin", "burnlist.mjs"); +function command(repo, args, env = {}) { + const result = spawnSync(process.execPath, [cli, "loop", ...args, "--repo", repo], { cwd: repo, encoding: "utf8", env: { ...process.env, ...env } }); + assert.equal(result.stderr, "", `${args.join(" ")}: ${result.stderr}`); assert.equal(result.status, 0, `${args.join(" ")}: ${result.stdout}`); return result.stdout; +} +function created(repo) { return JSON.parse(command(repo, ["create", fixtureItemRef])).runId; } + +test("real CLI control reads are stable, list is absent-state read-only, and stored authority drives run/resume", (t) => { + const directory = mkdtempSync(join(tmpdir(), "m6-cli-")); t.after(() => rmSync(directory, { recursive: true, force: true })); + const { repo } = createProductionRunAuthority(join(directory, "repo")); + const runs = join(repo, ".local", "burnlist", "loop", "m2", "runs"); + assert.equal(command(repo, ["list"]), "[]\n"); assert.equal(existsSync(runs), false); + const first = created(repo), status = command(repo, ["status", first]), inspect = command(repo, ["inspect", first]); + for (const publicView of [JSON.parse(status), JSON.parse(inspect)]) { + assert.equal(publicView.loopId, "review"); + assert.match(publicView.loopRevision, /^er1-sha256:[a-f0-9]{64}$/u); + assert.equal(Number.isSafeInteger(publicView.createdAt), true); + assert.equal(Number.isSafeInteger(publicView.updatedAt), true); + } + assert.equal(command(repo, ["status", first]), status); assert.equal(command(repo, ["inspect", first]), inspect); + const authority = join(runs, Buffer.from(first).toString("hex"), "dispatch-authority.json"), bytes = readFileSync(authority); + const counter = join(directory, "counter"); writeFileSync(counter, "0"); + const completed = JSON.parse(command(repo, ["run", first], { BURNLIST_FAKE_COUNTER: counter, BURNLIST_FAKE_OUTCOMES: "complete,approve" })); + assert.equal(completed.state, "converged"); assert.deepEqual(readFileSync(authority), bytes); + const blocked = spawnSync(process.execPath, [cli, "loop", "create", fixtureItemRef, "--repo", repo], { cwd: repo, encoding: "utf8" }); + assert.equal(blocked.status, 1); assert.match(blocked.stderr, /current Run is still executable/u); +}); + +test("real CLI fences active control and proof-gates reconcile", (t) => { + const directory = mkdtempSync(join(tmpdir(), "m6-cli-fence-")); t.after(() => rmSync(directory, { recursive: true, force: true })); + const { repo } = createProductionRunAuthority(join(directory, "repo")), runId = created(repo); + const held = spawnSync(process.execPath, ["--input-type=module", "-e", `import{runStore}from${JSON.stringify(new URL("../loops/run/run-store.mjs", import.meta.url).href)};const s=runStore(process.argv[1]),a=s.acquireLease(process.argv[2]);s.append(process.argv[2],a.lease,"node-started",{nodeId:"implement",attempt:1});s.append(process.argv[2],a.lease,"invocation-started",{nodeId:"implement",attempt:1,invocationId:"${"a".repeat(32)}"});process.stdout.write(a.recoveryProof);`, repo, runId], { cwd: repo, encoding: "utf8" }); + assert.equal(held.status, 0, held.stderr); + const result = spawnSync(process.execPath, [cli, "loop", "pause", runId, "--repo", repo], { cwd: repo, encoding: "utf8" }); + assert.equal(result.status, 1); assert.match(result.stderr, /active foreground owner/u); + const reconcile = spawnSync(process.execPath, [cli, "loop", "reconcile", runId, "--repo", repo], { cwd: repo, encoding: "utf8" }); + assert.equal(reconcile.status, 1); assert.match(reconcile.stderr, /not demonstrably lost/u); + assert.equal(JSON.parse(command(repo, ["reconcile", runId, "--recovery-proof", held.stdout])).state, "needs-human"); +}); + +test("loop complete is the public, idempotent completion command", (t) => { + const directory = mkdtempSync(join(tmpdir(), "m8-cli-complete-")); t.after(() => rmSync(directory, { recursive: true, force: true })); + const { repo } = createProductionRunAuthority(join(directory, "repo")), runId = created(repo), counter = join(directory, "counter"); + writeFileSync(counter, "0"); assert.equal(JSON.parse(command(repo, ["run", runId], { BURNLIST_FAKE_COUNTER: counter, BURNLIST_FAKE_OUTCOMES: "complete,approve" })).state, "converged"); + assert.equal(JSON.parse(command(repo, ["complete", runId])).alreadyApplied, false); + assert.equal(JSON.parse(command(repo, ["complete", runId])).alreadyApplied, true); +}); + +test("a stopped current Run permits a safe replacement, while an executable Run does not", (t) => { + const directory = mkdtempSync(join(tmpdir(), "m8-current-replace-")); t.after(() => rmSync(directory, { recursive: true, force: true })); + const { repo } = createProductionRunAuthority(join(directory, "repo")), first = created(repo); + assert.equal(JSON.parse(command(repo, ["stop", first])).state, "stopped"); + const second = created(repo); assert.notEqual(second, first); + const old = spawnSync(process.execPath, [cli, "loop", "run", first, "--repo", repo], { cwd: repo, encoding: "utf8" }); + assert.equal(old.status, 1); assert.match(old.stderr, /superseded and cannot launch/u); +}); + +test("ordinary CLI create recovers an unpublished current reservation without requiring its RunRef", async (t) => { + const directory = mkdtempSync(join(tmpdir(), "m12-cli-create-retry-")); t.after(() => rmSync(directory, { recursive: true, force: true })); + const { repo } = createProductionRunAuthority(join(directory, "repo")); + const cut = runStore(repo, { hooks: { beforeRunPublish() { throw new Error("publication cut"); } } }); + await assert.rejects(createProductionRun({ repoRoot: repo, store: cut, itemRef: fixtureItemRef, runId: fixtureRunId }), /publication cut/u); + assert.equal(cut.readCurrentRun(fixtureItemRef).runId, fixtureRunId); + assert.equal(JSON.parse(command(repo, ["create", fixtureItemRef])).runId, fixtureRunId); +}); + +test("concurrent replacement creates exactly one executable current Run", async (t) => { + const directory = mkdtempSync(join(tmpdir(), "m8-current-race-")); t.after(() => rmSync(directory, { recursive: true, force: true })); + const { repo } = createProductionRunAuthority(join(directory, "repo")), first = created(repo); + command(repo, ["stop", first]); + const launch = () => new Promise((resolvePromise) => { + const child = spawn(process.execPath, [cli, "loop", "create", fixtureItemRef, "--repo", repo], { cwd: repo, stdio: ["ignore", "pipe", "pipe"] }); let stdout = "", stderr = ""; + child.stdout.on("data", (chunk) => { stdout += chunk; }); child.stderr.on("data", (chunk) => { stderr += chunk; }); child.on("close", (status) => resolvePromise({ status, stdout, stderr })); + }); + const results = await Promise.all([launch(), launch()]); + assert.equal(results.filter((result) => result.status === 0).length, 1); assert.equal(results.filter((result) => result.status === 1).length, 1); + const current = runStore(repo).readCurrentRun(fixtureItemRef); assert.equal(current.runId, JSON.parse(results.find((result) => result.status === 0).stdout).runId); +}); + +test("a second CLI SIGINT reaches controlled stop before the foreground runner settles", async (t) => { + const directory = mkdtempSync(join(tmpdir(), "m6-cli-signal-")); t.after(() => rmSync(directory, { recursive: true, force: true })); + const { repo } = createProductionRunAuthority(join(directory, "repo")), runId = created(repo); + const processObject = new EventEmitter(), calls = []; + let settle; + const pending = new Promise((resolvePromise) => { settle = resolvePromise; }); + const store = runStore(repo); + const runner = { + requestPause() { calls.push("pause"); }, + requestStop() { calls.push("stop"); }, + async run() { await pending; return store.read(runId); }, + }; + const output = { value: "", write(chunk) { this.value += chunk; } }; + const commandPromise = runLoopCli(["run", runId, "--repo", repo], { + processObject, + runnerFor: () => runner, + stdout: output, + }); + processObject.emit("SIGINT"); + processObject.emit("SIGINT"); + assert.deepEqual(calls, ["pause", "stop"]); + assert.equal(processObject.listenerCount("SIGINT"), 1); + settle(); + await commandPromise; + assert.equal(processObject.listenerCount("SIGINT"), 0); + assert.equal(JSON.parse(output.value).runId, runId); +}); diff --git a/src/server/dir-lock.mjs b/src/server/dir-lock.mjs index 194c0ab5..427d1697 100644 --- a/src/server/dir-lock.mjs +++ b/src/server/dir-lock.mjs @@ -14,6 +14,7 @@ import { writeSync, } from "node:fs"; import os from "node:os"; +import { execFileSync } from "node:child_process"; import { basename, dirname, join } from "node:path"; export const LOCK_MAX_AGE_MS = 15 * 60_000; @@ -30,6 +31,14 @@ const safeTime = (value) => Number.isFinite(value) && Number.isInteger(value) && const sleepSync = (milliseconds) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, milliseconds); const defaultFs = { closeSync, fsyncSync, lstatSync, mkdirSync, openSync, readdirSync, renameSync, rmdirSync, unlinkSync, writeSync }; +export function processStartIdentity(pid, { exec = execFileSync, environment = process.env } = {}) { + try { + const value = exec("ps", ["-o", "lstart=", "-p", String(pid)], + { encoding: "utf8", timeout: 1000, maxBuffer: 4096, env: { ...environment, LC_ALL: "C", LANG: "C" } }).trim().replace(/\s+/gu, " "); + return value || null; + } catch { return null; } +} + function ignored(error) { return error?.code === "ENOENT"; } @@ -75,8 +84,9 @@ function parseRecord(text) { } function validV2(record, token) { - return record?.version === 1 && safePid(record.pid) && typeof record.hostname === "string" && record.hostname !== "" - && tokenPattern.test(record.token) && record.token === token && safeTime(record.createdAt); + return [1, 2].includes(record?.version) && safePid(record.pid) && typeof record.hostname === "string" && record.hostname !== "" + && tokenPattern.test(record.token) && record.token === token && safeTime(record.createdAt) + && (record.version === 1 || record.startedAt === null || typeof record.startedAt === "string" && record.startedAt.length > 0 && record.startedAt.length <= 128); } function legacyRecord(record) { @@ -87,18 +97,23 @@ function legacyRecord(record) { return !Object.hasOwn(record, "hostname") ? record : null; } -function stale(record, now, hostname, pidProbe) { +function stale(record, now, hostname, pidProbe, reclaimLiveAfterAge, processIdentity) { if (record.hostname !== hostname) return false; - return staleSameHost(record, now, pidProbe); + return staleSameHost(record, now, pidProbe, reclaimLiveAfterAge, processIdentity); } -function staleSameHost(record, now, pidProbe) { +function staleSameHost(record, now, pidProbe, reclaimLiveAfterAge = true, processIdentity = () => null) { let live = true; try { pidProbe(record.pid); } catch (error) { live = error?.code !== "ESRCH"; } - return !live || now - record.createdAt >= LOCK_MAX_AGE_MS; + if (!live) return true; + if (record.version === 2 && record.startedAt !== null) { + let identity = null; try { identity = processIdentity(record.pid); } catch { return false; } + if (identity !== null && identity !== record.startedAt) return true; + } + return reclaimLiveAfterAge && now - record.createdAt >= LOCK_MAX_AGE_MS; } -function inspectCanonical(fs, lockPath, { now, hostname, pidProbe, readFile }) { +function inspectCanonical(fs, lockPath, { now, hostname, pidProbe, readFile, reclaimLiveAfterAge, processIdentity }) { const inspectionNow = now(); let stat; try { stat = fs.lstatSync(lockPath); } catch (error) { @@ -129,7 +144,7 @@ function inspectCanonical(fs, lockPath, { now, hostname, pidProbe, readFile }) { throw error; } if (!validV2(record, match[1])) return { kind: "corrupt" }; - return { kind: "v2", record, token: match[1], stale: stale(record, inspectionNow, hostname, pidProbe) }; + return { kind: "v2", record, token: match[1], stale: stale(record, inspectionNow, hostname, pidProbe, reclaimLiveAfterAge, processIdentity) }; } if (!stat.isFile()) return { kind: "corrupt" }; let text; @@ -140,7 +155,7 @@ function inspectCanonical(fs, lockPath, { now, hostname, pidProbe, readFile }) { const record = legacyRecord(parseRecord(text)); if (!record) return { kind: "corrupt" }; // Only v2 directory records use hostname as a cross-host safety boundary. - return { kind: "legacy", record, text, stale: staleSameHost(record, inspectionNow, pidProbe) }; + return { kind: "legacy", record, text, stale: staleSameHost(record, inspectionNow, pidProbe, reclaimLiveAfterAge) }; } function buildCandidate(fs, lockPath, context) { @@ -161,7 +176,7 @@ function buildCandidate(fs, lockPath, context) { try { hooks.afterCandidateDirectory?.({ candidate, token: value }); const path = join(candidate, ownerName(value)); - const record = `${JSON.stringify({ version: 1, pid: process.pid, hostname, token: value, createdAt })}\n`; + const record = `${JSON.stringify({ version: 2, pid: process.pid, hostname, token: value, createdAt, startedAt: context.processIdentity(process.pid) })}\n`; const bytes = Buffer.from(record, "utf8"); const fd = fs.openSync(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o600); let primary; @@ -236,13 +251,14 @@ function gc(fs, lockPath, context) { } /** Test seams are deliberately optional; production callers use only lockPath, fn, and errorFactory. */ -export function withDirectoryLock({ lockPath, fn, errorFactory, adapters = {}, hooks = {} }) { +export function withDirectoryLock({ lockPath, fn, errorFactory, adapters = {}, hooks = {}, reclaimLiveAfterAge = true }) { const fs = { ...defaultFs, ...adapters.fs }; const context = { hostname: adapters.hostname?.() ?? os.hostname(), now: adapters.now ?? Date.now, pidProbe: adapters.pidProbe ?? ((pid) => process.kill(pid, 0)), sleep: adapters.sleep ?? sleepSync, + processIdentity: adapters.processIdentity ?? processStartIdentity, token: adapters.token ?? (() => randomBytes(32).toString("hex")), logger: adapters.logger ?? (() => {}), - readFile: adapters.readFileSync ?? readFileSync, hooks, + readFile: adapters.readFileSync ?? readFileSync, hooks, reclaimLiveAfterAge, }; if (typeof context.hostname !== "string" || context.hostname === "") throw new Error("Cannot create a lock without a hostname."); fs.mkdirSync(dirname(lockPath), { recursive: true }); diff --git a/src/server/dir-lock.test.mjs b/src/server/dir-lock.test.mjs index 8bce65be..a3a7cabd 100644 --- a/src/server/dir-lock.test.mjs +++ b/src/server/dir-lock.test.mjs @@ -3,7 +3,7 @@ import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSyn import { hostname, tmpdir } from "node:os"; import { dirname, join } from "node:path"; import test from "node:test"; -import { CANDIDATE_GC_AGE_MS, LOCK_MAX_AGE_MS, MAX_ATTEMPTS, RETRY_DELAY_MS, withDirectoryLock } from "./dir-lock.mjs"; +import { CANDIDATE_GC_AGE_MS, LOCK_MAX_AGE_MS, MAX_ATTEMPTS, RETRY_DELAY_MS, processStartIdentity, withDirectoryLock } from "./dir-lock.mjs"; const token = (letter) => letter.repeat(64); @@ -13,9 +13,10 @@ function paths(t) { return [join(root, ".lock"), join(root, "roots.lock")]; } -function writeV2(lock, { pid = process.pid, host = hostname(), value = token("a"), createdAt = Date.now() } = {}) { +function writeV2(lock, { pid = process.pid, host = hostname(), value = token("a"), createdAt = Date.now(), version = 1, startedAt } = {}) { mkdirSync(lock, { recursive: true }); - writeFileSync(join(lock, `owner-${value}.json`), `${JSON.stringify({ version: 1, pid, hostname: host, token: value, createdAt })}\n`); + writeFileSync(join(lock, `owner-${value}.json`), `${JSON.stringify({ version, pid, hostname: host, token: value, createdAt, + ...(version === 2 ? { startedAt: startedAt ?? "Mon Jan 01 00:00:00 2024" } : {}) })}\n`); } function writeLegacy(lock, { pid = process.pid, value = "a".repeat(24), createdAt = Date.now() } = {}) { @@ -104,6 +105,38 @@ test("stale age boundary and foreign-host protection are exact", (t) => { }); }); +test("identity-held locks can disable age-only reclamation for bounded Run work", (t) => { + forBoth(t, "identity held", (lock) => { + const now = 2_000_000; writeV2(lock, { createdAt: now - 10 * LOCK_MAX_AGE_MS }); + assert.throws(() => withDirectoryLock({ lockPath: lock, fn: () => assert.fail("live owner must retain lock"), + reclaimLiveAfterAge: false, adapters: { now: () => now, pidProbe: () => {}, sleep: () => {} }, + errorFactory: () => Object.assign(new Error("locked"), { code: "ELOCKED" }) }), { code: "ELOCKED" }); + }); +}); + +test("v2 process identity distinguishes PID reuse while matching, unverifiable, and foreign owners remain held", (t) => { + const [lock] = paths(t), common = { version: 2, pid: 4242, startedAt: "Mon Jan 01 00:00:00 2024", createdAt: Date.now() }; + writeV2(lock, common); + assert.equal(acquire(lock, () => "reclaimed", { pidProbe: () => {}, processIdentity: () => "Tue Jan 02 00:00:00 2024", sleep: () => {} }), "reclaimed"); + for (const observed of [() => common.startedAt, () => null, () => { throw new Error("unavailable"); }]) { + writeV2(lock, common); + const processIdentity = (pid) => pid === common.pid ? observed() : "candidate"; + assert.throws(() => acquire(lock, () => assert.fail("must remain held"), { pidProbe: () => {}, processIdentity, sleep: () => {} }), { code: "ELOCKED" }); + rmSync(lock, { recursive: true, force: true }); + } + writeV2(lock, { ...common, host: "foreign-host" }); + assert.throws(() => acquire(lock, () => assert.fail("foreign owner must remain held"), + { pidProbe: () => { throw Object.assign(new Error("gone"), { code: "ESRCH" }); }, processIdentity: () => "different", sleep: () => {} }), { code: "ELOCKED" }); +}); + +test("process start identity pins the ps locale and normalizes output independently of parent locale", () => { + let observed; + const value = processStartIdentity(42, { environment: { LANG: "de_DE.UTF-8", LC_ALL: "fr_FR.UTF-8", KEEP: "yes" }, + exec(command, argv, options) { observed = { command, argv, options }; return " Mon Jan 01 00:00:00 2024 \n"; } }); + assert.equal(value, "Mon Jan 01 00:00:00 2024"); + assert.deepEqual(observed.options.env, { LANG: "C", LC_ALL: "C", KEEP: "yes" }); +}); + test("bounded contention performs 100 publications and 99 sleeps", (t) => { forBoth(t, "bounds", (lock) => { writeV2(lock); diff --git a/src/server/plan-model.mjs b/src/server/plan-model.mjs index 7b510049..e6f99546 100644 --- a/src/server/plan-model.mjs +++ b/src/server/plan-model.mjs @@ -1,5 +1,6 @@ import { basename, dirname, normalize, relative, resolve } from "node:path"; import { readTextFileWithLimit, safeStat } from "./fs-safe.mjs"; +import { findLoopMetadata, locateItemSpan } from "../loops/assignment/item-metadata.mjs"; export function twoDigit(value) { return String(value).padStart(2, "0"); @@ -76,6 +77,13 @@ export function parseActiveItems(lines) { return items; } +/** Read-only exact-span helper for Loop-aware observers; it never normalizes markdown. */ +export function loopAssignmentForItem(markdown, itemId) { + const located = locateItemSpan(markdown, itemId); + const metadata = findLoopMetadata(located); + return metadata ? { ...metadata.values, itemStart: located.startByte, itemEnd: located.endByte } : null; +} + export function parseCompleted(lines) { const completed = []; const malformed = []; From 48cb6739211782b6562ad403cb099ef34ed4d54e Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Fri, 24 Jul 2026 10:14:07 +0200 Subject: [PATCH 03/37] feat: show live loop runs in the checklist dashboard --- .../ChecklistDashboard.test.mjs | 74 +++++++++- .../ChecklistDashboard/ChecklistDashboard.tsx | 48 +++++- .../ChecklistDashboard/ChecklistOvenView.tsx | 3 +- .../checklist-dom-golden.test.mjs | 87 +++++++++++ .../checklist-loop-progression.golden.json | 7 + .../checklist-loop-states.golden.json | 10 ++ dashboard/src/hooks/dashboard-data.mjs | 22 ++- dashboard/src/hooks/dashboard-data.test.mjs | 73 ++++++++- dashboard/src/hooks/useDashboardData.ts | 18 ++- dashboard/src/lib/types.ts | 34 +++++ src/server/burnlist-dashboard-server.mjs | 50 ++++++- src/server/dashboard-routes-fixtures.mjs | 2 +- src/server/run-routes.test.mjs | 139 +++++++++++++++++- 13 files changed, 551 insertions(+), 16 deletions(-) create mode 100644 dashboard/src/components/ChecklistDashboard/checklist-loop-progression.golden.json create mode 100644 dashboard/src/components/ChecklistDashboard/checklist-loop-states.golden.json diff --git a/dashboard/src/components/ChecklistDashboard/ChecklistDashboard.test.mjs b/dashboard/src/components/ChecklistDashboard/ChecklistDashboard.test.mjs index 487f4637..a588c14e 100644 --- a/dashboard/src/components/ChecklistDashboard/ChecklistDashboard.test.mjs +++ b/dashboard/src/components/ChecklistDashboard/ChecklistDashboard.test.mjs @@ -6,6 +6,7 @@ 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; @@ -19,14 +20,17 @@ test("checklist progress owns its workspace height instead of inheriting the dif 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, checklistEventDetailFields } = 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); @@ -68,7 +72,75 @@ test("checklist detail renders the split progress surface and event card list", assert.doesNotMatch(markup, /]*>Changes<\/button>/u); assert.doesNotMatch(markup, /Burnlist detail view/u); assert.doesNotMatch(markup, /Repo Graph/u); + + const { snapshots } = await runM4ProgressFixture({ + repoRoot, + outcomes: ["complete", "pass", "reject", "complete", "pass", "approve"], + }); + for (const projection of snapshots) { + const stage = renderToStaticMarkup(createElement(LoopRunPanel, { data: { ...data, loopRun: projection } })); + assert.match(stage, new RegExp(`Current ${projection.currentNode} · attempt ${projection.attempt} · cycle ${projection.cycle}`, "u")); + assert.match(stage, /aria-label="Loop graph edges"/u); + assert.match(stage, /implement<\/strong> —complete→<\/span> verify<\/strong>/u); + assert.match(stage, /aria-current="step"/u); + if (projection.latestResult) assert.match(stage, new RegExp(`Latest ${projection.latestResult.kind} · ${projection.latestResult.summary}`, "u")); + if (projection.currentNode === "implement" && projection.attempt === 2) assert.match(stage, /review —reject→<\/span> implement/u); + if (projection.currentNode === "converged") assert.match(stage, /review —approve→<\/span> converged/u); + if (projection.currentNode === "completed") assert.match(stage, /converged —pass→<\/span> completed/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, /aria-label="Latest role evidence"/u); + assert.match(evidence, /
Maker<\/dt>
candidate prepared/u); + assert.match(evidence, /
Check<\/dt>
verify passed/u); + assert.match(evidence, /
Reviewer<\/dt>
approved/u); + assert.match(evidence, /candidate candidate-1/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, outcomes: ["complete", "pass", "reject", "complete", "pass", "approve"] }); + 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")); + assert.match(markup, /aria-label="Loop budget"/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..27ca8de1 100644 --- a/dashboard/src/components/ChecklistDashboard/ChecklistDashboard.tsx +++ b/dashboard/src/components/ChecklistDashboard/ChecklistDashboard.tsx @@ -94,10 +94,56 @@ export function EventCardList({ data }: { data: ChecklistProgressData }) { })}{!rows.length &&

No completed events yet.

}; } +export function LoopRunPanel({ data }: { data: ChecklistProgressData }) { + const run = data.loopRun; + if (!run) return data.loopProjectionDiagnostic ?
+
Loop Run
Corrupt projection
+

{data.loopProjectionMessage || "The Loop projection is corrupt. Progress remains available while the dashboard waits for a verified projection."}

+
: null; + const stateLabel: Record = { + paused: "Paused", failed: "Failed", stopped: "Stopped", "needs-human": "Needs human review", + "budget-exhausted": "Budget exhausted", corrupt: "Corrupt projection", stale: "Stale projection", converged: "Converged", completed: "Completed", + }; + const state = run.diagnostic === "corrupt" ? "Corrupt projection" : run.diagnostic === "stale" ? "Stale projection" : stateLabel[run.state] ?? run.state; + const budget = run.budget; + const nodeById = new Map(run.graph.nodes.map((node) => [node.id, node])); + const orderedEdges = [...run.graph.edges].sort((left, right) => { + const stage = (edge: typeof left) => edge.from === "implement" ? 0 : edge.from === "verify" ? 1 : edge.from === "review" ? 2 : edge.from === "converged" ? 3 : 4; + return stage(left) - stage(right) || left.from.localeCompare(right.from) || left.on.localeCompare(right.on); + }); + const roleResults = [ + ["Maker", run.latestMaker], ["Check", run.latestCheck], ["Reviewer", run.latestReviewer], + ] as const; + return
+
Loop Run
+ {state}
+

{run.loopId}{run.loopRevision && {run.loopRevision}}

+
Current {run.currentNode} · attempt {run.attempt} · cycle {run.cycle}
+
+
Elapsed
{formatDuration(budget.elapsedMilliseconds)}
+
Rounds
{budget.counters.rounds}/{budget.limits.maxRounds}
+
Agent runs
{budget.counters.agentRuns}/{budget.limits.maxAgentRuns}
+
Checks
{budget.counters.checkRuns}/{budget.limits.maxCheckRuns}
+
Transitions
{budget.counters.transitions}/{budget.limits.maxTransitions}
+
+
    {run.graph.nodes.map((node) => +
  1. + {node.id}{node.kind} +
  2. )}
+
    {orderedEdges.map((edge) => +
  1. {nodeById.get(edge.from)?.id ?? edge.from} —{edge.on}→ {nodeById.get(edge.to)?.id ?? edge.to}
  2. )}
+ {roleResults.some(([, result]) => result) &&
{roleResults.map(([role, result]) => result && +
{role}
{result.summary} {result.candidateId && candidate {result.candidateId}}
)}
} + {run.latestResult &&

Latest {run.latestResult.kind} · {run.latestResult.summary}

} +
    {run.transitions.map((transition) => +
  1. {transition.from} —{transition.outcome}→ {transition.to}
  2. )}
+
; +} + 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/ChecklistOvenView.tsx b/dashboard/src/components/ChecklistDashboard/ChecklistOvenView.tsx index 65e8dfb1..9c518aac 100644 --- a/dashboard/src/components/ChecklistDashboard/ChecklistOvenView.tsx +++ b/dashboard/src/components/ChecklistDashboard/ChecklistOvenView.tsx @@ -3,6 +3,7 @@ import type { ResolvedOvenIr } from "@hooks"; import type { ChecklistProgressData } from "@lib"; import { adaptChecklist } from "@lib/checklist-adapter"; import { OvenRuntime } from "@/oven/runtime/OvenRuntime"; +import { LoopRunPanel } from "./ChecklistDashboard"; import "./ChecklistDashboard.css"; export function ChecklistOvenView({ data, ir }: { data: ChecklistProgressData; ir: ResolvedOvenIr }) { @@ -11,5 +12,5 @@ export function ChecklistOvenView({ data, ir }: { data: ChecklistProgressData; i 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-dom-golden.test.mjs b/dashboard/src/components/ChecklistDashboard/checklist-dom-golden.test.mjs index 1e45dc91..e6050477 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,16 @@ 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"); test("checklist detail static DOM matches the frozen byte golden", async () => { const outputDir = await mkdtemp(join(process.cwd(), ".checklist-dom-golden-test-")); @@ -35,3 +40,85 @@ 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, + outcomes: ["complete", "pass", "reject", "complete", "pass", "approve"], + }); + const selected = [ + snapshots.find((snapshot) => snapshot.currentNode === "implement" && snapshot.attempt === 1 && snapshot.latestResult?.kind === "reject"), + snapshots.find((snapshot) => snapshot.currentNode === "implement" && 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 projectionBytes = JSON.stringify({ ...projection, revision: "" }); + const markup = withDeterministicTime(() => + renderToStaticMarkup(createElement(ChecklistDashboard, { data: { ...checklistFixture, loopRun: 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: { ...checklistFixture, loopRun: { ...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-loop-progression.golden.json b/dashboard/src/components/ChecklistDashboard/checklist-loop-progression.golden.json new file mode 100644 index 00000000..2729a30a --- /dev/null +++ b/dashboard/src/components/ChecklistDashboard/checklist-loop-progression.golden.json @@ -0,0 +1,7 @@ +[ + { "checkpoint": "implement/1/reject", "projectionBytes": 1855, "projectionSha256": "bb919c8828f97eb63e5c6e29b2eb73736e3a24e476ea13190d39ac4fc329fd3c", "domBytes": 12119, "domSha256": "70d9d519964b9f9b292bf02bed768355e95eb126b6ba254cd7562ba3825855ab" }, + { "checkpoint": "implement/2/reject", "projectionBytes": 1855, "projectionSha256": "9c623f1ccf612c9921f93c031ac4c7757892d2eda3feb46c34dfbd6347957b94", "domBytes": 12119, "domSha256": "cd341ac15a227617969bdb3e66cd9d23fef58075f6c31e26b35b78efdae49270" }, + { "checkpoint": "review/2/approve", "projectionBytes": 1990, "projectionSha256": "318079e10fef419d1985aa704b8c5bb77791161a57b6771dc6d5cde5e005ddea", "domBytes": 12214, "domSha256": "beb873ef0164fe58e3b635a3082dcebb2f191882ea8992b6aa71c32283154683" }, + { "checkpoint": "converged/1/approve", "projectionBytes": 2062, "projectionSha256": "1a2c67ec6c1645b46c690d0531a32b68b679191531f6fa6a5cf333f66ffcf967", "domBytes": 12267, "domSha256": "7427a07760115ac129912c66dcb03fd619051b7fa5d69e08f352a1c99a9251a5" }, + { "checkpoint": "completed/1/approve", "projectionBytes": 2133, "projectionSha256": "c48da3b51874c7daba7669a4ded690445d89b9eb1b7b311f061b60472f54cc07", "domBytes": 12321, "domSha256": "1ff2c2747b11031e8c3d89fbf9ad15d87bfe5f292c57161123685fd62ca0f83b" } +] 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..44ed019c --- /dev/null +++ b/dashboard/src/components/ChecklistDashboard/checklist-loop-states.golden.json @@ -0,0 +1,10 @@ +[ + { "checkpoint": "paused", "domBytes": 12171, "domSha256": "46388c6ac8e544fbf4b511f0178c2d0adfbc8cc41c6a37375cce7e7b0f4b20c8" }, + { "checkpoint": "failed", "domBytes": 12171, "domSha256": "de66441d38526255a307f54d4eead2c5d0e0d3d2f58d70a0a00e0f8614ee26b0" }, + { "checkpoint": "stopped", "domBytes": 12173, "domSha256": "e306f86ff9bbcc7294e6b9bc3df3987989f796bd947d789f4d1edddca83efa9f" }, + { "checkpoint": "needs-human", "domBytes": 12195, "domSha256": "8a25e97a8f6fee72f336ab4ecbc859964d0b4ff664e63e32efba0e57a83d31c5" }, + { "checkpoint": "exhausted", "domBytes": 12191, "domSha256": "46df3aa30b14a0086f8299895e69fad120e6564bf65b2119cac652b6ec793c10" }, + { "checkpoint": "stale", "domBytes": 12191, "domSha256": "afc4e0cebcd3a7f3bb1fad8d32ae733b9d2bc9c2a0a9910bc1ad37ff39fc82f9" }, + { "checkpoint": "corrupt", "domBytes": 12195, "domSha256": "b6540a7baf33695218d768a9183c89b13c237f677ef825888270ce80598f23aa" }, + { "checkpoint": "completed", "domBytes": 12177, "domSha256": "27ef2ca6d8357bd24166c1954220e3f7c75f60361e574f16806a72fa3b9d2ace" } +] diff --git a/dashboard/src/hooks/dashboard-data.mjs b/dashboard/src/hooks/dashboard-data.mjs index c6a6c773..ba3220fa 100644 --- a/dashboard/src/hooks/dashboard-data.mjs +++ b/dashboard/src/hooks/dashboard-data.mjs @@ -4,11 +4,13 @@ export const dashboardProjectEvents = Object.freeze([ Object.freeze({ ovenId: "*", kind: "definition-changed", phase: "complete" }), Object.freeze({ ovenId: "checklist", kind: "item-burned", phase: "completed" }), Object.freeze({ ovenId: "checklist", kind: "lifecycle-changed", phase: "complete" }), + Object.freeze({ ovenId: "checklist", kind: "loop-projection-changed", phase: "complete" }), ]); export const dashboardProgressEvents = Object.freeze([ Object.freeze({ ovenId: "checklist", kind: "data-published", phase: "complete" }), Object.freeze({ ovenId: "checklist", kind: "item-burned", phase: "completed" }), Object.freeze({ ovenId: "checklist", kind: "lifecycle-changed", phase: "complete" }), + Object.freeze({ ovenId: "checklist", kind: "loop-projection-changed", phase: "complete" }), ]); function selectedQuery(selected) { @@ -57,7 +59,8 @@ export function dashboardProgressSnapshotConfig(enabled, selected) { enabled: enabled && Boolean(selected), repoKey: selected?.repoKey ?? null, ovenId: "checklist", - subjectId: selected?.id ?? null, + // Loop invalidations are keyed by an itemRef; progress is the owning Burnlist projection. + subjectId: null, query, makeUrl: () => `/api/progress?${query}`, receive: receiveProgress, @@ -67,3 +70,20 @@ export function dashboardProgressSnapshotConfig(enabled, selected) { deps: [enabled, query], }; } + +export function receiveLoopProjection(response, json) { + if (!response.ok) throw new Error(json?.error ?? "Could not load Loop projection."); + if (!json || typeof json !== "object" || !("loopRun" in json)) throw new Error("Loop projection data is invalid."); + return json.loopRun; +} + +export function dashboardLoopProjectionSnapshotConfig(enabled, selected) { + const query = selectedQuery(selected); + return { + transport: "snapshot", enabled: enabled && Boolean(selected), repoKey: selected?.repoKey ?? null, + ovenId: "checklist", subjectId: null, query: `loop/${query}`, + makeUrl: () => `/api/loop-projection?${query}`, receive: receiveLoopProjection, + fallbackError: "Could not load Loop projection.", initialData: null, + events: [Object.freeze({ ovenId: "checklist", kind: "loop-projection-changed", phase: "complete" })], deps: [enabled, query], + }; +} diff --git a/dashboard/src/hooks/dashboard-data.test.mjs b/dashboard/src/hooks/dashboard-data.test.mjs index 93c8e955..3456b633 100644 --- a/dashboard/src/hooks/dashboard-data.test.mjs +++ b/dashboard/src/hooks/dashboard-data.test.mjs @@ -1,15 +1,15 @@ import assert from "node:assert/strict"; import test from "node:test"; import { createOvenSnapshotClient } from "../lib/oven-event-client.mjs"; -import { dashboardProgressSnapshotConfig, dashboardProjectsSnapshotConfig } from "./dashboard-data.mjs"; +import { dashboardLoopProjectionSnapshotConfig, dashboardProgressSnapshotConfig, dashboardProjectsSnapshotConfig } from "./dashboard-data.mjs"; const settle = () => new Promise((resolve) => setImmediate(resolve)); -function response(body, status = 200) { +function response(body, status = 200, etag = null) { return { ok: status >= 200 && status < 300, status, - headers: { get: () => null }, + headers: { get: (name) => name.toLowerCase() === "etag" ? etag : null }, async json() { return body; }, }; } @@ -135,3 +135,70 @@ test("landing projections share matching invalidations and the manual-write fall assert.equal(sources.length, 1); client.stop(); }); + +test("loop projection uses its dedicated snapshot URL and coalesces conditional event/reset refreshes", async () => { + const timers = fakeTimers(), sources = [], calls = [], states = []; + const selected = { repoKey: "aaaaaaaaaaaa", id: "260722-001" }; + const config = dashboardLoopProjectionSnapshotConfig(true, selected); + assert.equal(config.makeUrl(), "/api/loop-projection?repoKey=aaaaaaaaaaaa&id=260722-001"); + assert.deepEqual(config.events, [{ ovenId: "checklist", kind: "loop-projection-changed", phase: "complete" }]); + const client = createOvenSnapshotClient({ + timers, focusTarget: null, + eventSourceFactory(url) { const source = new FakeEventSource(url); sources.push(source); return source; }, + async fetchImpl(url, options = {}) { + if (isEventBaseline(url)) return response({ cursor: "oev1-loop" }); + calls.push({ url, headers: options.headers ?? null }); + return calls.length === 1 ? response({ loopRun: { revision: "sha256:first" } }, 200, '"loop-v1"') : response(null, 304, '"loop-v1"'); + }, + }); + client.start(); + client.subscribe(descriptor(config), (state) => states.push(state)); + await settle(); + assert.deepEqual(calls, [{ url: config.makeUrl(), headers: null }]); + + const changed = { repoKey: selected.repoKey, ovenId: "checklist", subjectId: "item:260722-001#M7", kind: "loop-projection-changed", phase: "complete" }; + sources[0].publish(changed); + sources[0].publish(changed); + timers.flush(); + await settle(); + assert.equal(calls.length, 2, "two invalidations share one refetch"); + assert.deepEqual(calls[1].headers, { "If-None-Match": '"loop-v1"' }); + assert.equal(states.at(-1).outcome, "unchanged", "304 retains the canonical loop snapshot"); + + sources[0].reset({ repoKey: selected.repoKey, ovenId: "checklist", reason: "retention-gap" }); + timers.flush(); + await settle(); + assert.equal(calls.length, 3, "a matching retention gap resets the loop snapshot"); + assert.deepEqual(calls[2].headers, { "If-None-Match": '"loop-v1"' }); + client.stop(); +}); + +test("a corrupt dedicated Loop projection retains the last good snapshot", async () => { + const timers = fakeTimers(), sources = [], states = []; + const selected = { repoKey: "aaaaaaaaaaaa", id: "260722-001" }; + const config = dashboardLoopProjectionSnapshotConfig(true, selected); + let requests = 0; + const client = createOvenSnapshotClient({ + timers, focusTarget: null, + eventSourceFactory(url) { const source = new FakeEventSource(url); sources.push(source); return source; }, + async fetchImpl(url) { + if (isEventBaseline(url)) return response({ cursor: "oev1-corrupt" }); + requests += 1; + return requests === 1 + ? response({ loopRun: { revision: "sha256:verified" } }, 200, '"loop-v1"') + : response({ error: "Loop projection is unavailable; retaining the last verified projection." }, 409); + }, + }); + client.start(); + client.subscribe(descriptor(config), (state) => states.push(state)); + await settle(); + sources[0].publish({ repoKey: selected.repoKey, ovenId: "checklist", subjectId: "item:260722-001#M7", kind: "loop-projection-changed", phase: "complete" }); + timers.flush(); + await settle(); + const state = states.at(-1); + assert.equal(state.outcome, "rejected"); + assert.deepEqual(state.data, { revision: "sha256:verified" }); + assert.equal(state.error, "Loop projection is unavailable; retaining the last verified projection."); + assert.equal(state.stale, true); + client.stop(); +}); diff --git a/dashboard/src/hooks/useDashboardData.ts b/dashboard/src/hooks/useDashboardData.ts index 0b1e54fa..4e0c066c 100644 --- a/dashboard/src/hooks/useDashboardData.ts +++ b/dashboard/src/hooks/useDashboardData.ts @@ -1,6 +1,6 @@ import type { ProgressData, Project, SelectedBurnlist } from "@lib"; import { useOvenLiveData } from "@oven"; -import { dashboardProgressSnapshotConfig, dashboardProjectsSnapshotConfig } from "./dashboard-data.mjs"; +import { dashboardLoopProjectionSnapshotConfig, dashboardProgressSnapshotConfig, dashboardProjectsSnapshotConfig } from "./dashboard-data.mjs"; type DashboardData = { projects: Project[]; @@ -14,11 +14,19 @@ export function useDashboardData({ section, selected }: { section: string; selec const enabled = section === "burnlists"; const projectsState = useOvenLiveData(dashboardProjectsSnapshotConfig(enabled)); const progressState = useOvenLiveData(dashboardProgressSnapshotConfig(enabled, selected)); + const loopState = useOvenLiveData(dashboardLoopProjectionSnapshotConfig(enabled, selected)); + const loopDiagnostic = loopState.error.includes("retaining the last verified projection") ? "corrupt" + : loopState.stale || loopState.error ? "stale" : undefined; + const loopRun = loopState.data && loopDiagnostic ? { ...loopState.data, diagnostic: loopDiagnostic } : loopState.data; return { projects: projectsState.data ?? [], - progress: selected ? progressState.data : null, - error: progressState.error || projectsState.error, - loading: enabled && (projectsState.loading || (Boolean(selected) && progressState.loading)), - stale: projectsState.stale || (Boolean(selected) && progressState.stale), + progress: selected && progressState.data ? { + ...progressState.data, + loopRun, + ...(loopDiagnostic ? { loopProjectionDiagnostic: loopDiagnostic, loopProjectionMessage: loopState.error || undefined } : {}), + } : null, + error: loopState.error || progressState.error || projectsState.error, + loading: enabled && (projectsState.loading || (Boolean(selected) && (progressState.loading || loopState.loading))), + stale: projectsState.stale || (Boolean(selected) && (progressState.stale || loopState.stale)), }; } diff --git a/dashboard/src/lib/types.ts b/dashboard/src/lib/types.ts index 0f6d5f03..d42ba877 100644 --- a/dashboard/src/lib/types.ts +++ b/dashboard/src/lib/types.ts @@ -4,6 +4,37 @@ export type ChecklistItem = { id: string; title: string; fields: Record; + edges: Array<{ from: string; on: string; to: string }>; + }; + transitions: Array<{ sequence: number; from: string; outcome: string; to: string }>; + diagnostic?: "stale" | "corrupt"; +}; export type ChecklistProgressData = { generatedAt: string; @@ -19,6 +50,9 @@ export type ChecklistProgressData = { active: ChecklistItem[]; completed: CompletedItem[]; history: HistoryPoint[]; + loopRun?: LoopRunProjection | null; + loopProjectionDiagnostic?: "corrupt" | "stale"; + loopProjectionMessage?: string; }; export type Burnlist = { diff --git a/src/server/burnlist-dashboard-server.mjs b/src/server/burnlist-dashboard-server.mjs index 054d7478..2917b740 100644 --- a/src/server/burnlist-dashboard-server.mjs +++ b/src/server/burnlist-dashboard-server.mjs @@ -1,5 +1,5 @@ #!/usr/bin/env node -import { randomBytes } from "node:crypto"; +import { createHash, randomBytes } from "node:crypto"; import { createServer } from "node:http"; import { existsSync, @@ -36,6 +36,7 @@ import { starterOvenSource } from "../ovens/oven-starter.mjs"; import "../ovens/built-in-handlers.mjs"; import { getOvenHandler, listOvenHandlers } from "../ovens/oven-registry.mjs"; import { genericJsonHandler } from "../ovens/handlers/generic-json-handler.mjs"; +import { readLatestRunForItem } from "../loops/run/read-projection.mjs"; import { buildRepoMapAsync } from "./repo-map.mjs"; import { createOvenJsonSnapshotStore, OVEN_JSON_CACHE_MAX_BYTES } from "./oven-json-snapshot.mjs"; import { discoverBurnlistSummaries } from "./burnlist-discovery.mjs"; @@ -63,6 +64,7 @@ import { documentPayloadForPlan, lifecycleForPlan, localIsoTimestamp, + loopAssignmentForItem, parsePlan, twoDigit, validatePlan, @@ -1016,6 +1018,7 @@ function payloadForPlan(selection) { }) .filter((entry) => Number.isFinite(Date.parse(entry.time))); const history = [...ledgerHistory, current].sort((a, b) => Date.parse(a.time) - Date.parse(b.time)); + const burnlistId = burnlistIdForPlan(selection.planPath); return { generatedAt, burnlists: discoverBurnlists(), @@ -1026,7 +1029,7 @@ function payloadForPlan(selection) { return null; } })(), - burnlistId: burnlistIdForPlan(selection.planPath), + burnlistId, repo: plan.repo, repoRoot: plan.repoRoot, title: plan.title, @@ -1044,9 +1047,26 @@ function payloadForPlan(selection) { detail: completedDetails.get(entry.id)?.detail ?? "", })), history, + // The Run journal is deliberately not read here. Progress must remain + // useful when an independent Loop projection is corrupt or unavailable. + loopRun: null, }; } +function loopProjectionForPlan(selection) { + const plan = parsePlan(selection.planPath, maxPlanBytes); + const currentItem = plan.items[0]; + const assignment = currentItem ? loopAssignmentForItem(plan.markdown, currentItem.id) : null; + if (!currentItem || !assignment) return null; + return readLatestRunForItem({ + repoRoot: selection.repoRoot, + itemRef: `item:${burnlistIdForPlan(selection.planPath)}#${currentItem.id}`, + markdown: plan.markdown, + itemId: currentItem.id, + assignmentId: assignment["Assignment-Id"], + }); +} + function appendCompletionDigestIfMissing(plan) { if (/^##\s+Completion Digest\b/m.test(plan.markdown)) return false; const digest = completionDigestMarkdown(plan); @@ -1128,6 +1148,19 @@ function json(res, status, body) { res.end(serialized); } +function serveLoopProjection(req, res, loopRun) { + const body = { loopRun }; + const serialized = JSON.stringify(body); + const etag = `W/\"loop-${createHash("sha256").update(serialized).digest("hex")}\"`; + if (req.headers["if-none-match"] === etag) { + res.writeHead(304, { etag, "cache-control": "no-store" }); + res.end(); + return; + } + res.writeHead(200, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store", etag, "content-length": Buffer.byteLength(serialized) }); + res.end(serialized); +} + function dashboardAssetPath(pathname) { if (pathname === "/favicon.svg") return resolve(dashboardDistDir, "favicon.svg"); const match = pathname.match(/^\/assets\/([A-Za-z0-9._-]+)$/u); @@ -1255,6 +1288,19 @@ const server = createServer(async (req, res) => { } return; } + if (url.pathname === "/api/loop-projection") { + if (method !== "GET") return json(res, 405, { error: "method not allowed" }); + const selected = selectedBurnlist(url); + if (!selected.burnlist) return json(res, 409, { error: selected.error }); + try { + // This representation is deliberately only the sanitized canonical projection. + serveLoopProjection(req, res, loopProjectionForPlan(selected.burnlist)); + } catch (error) { + const status = error?.code === "EAMBIGUOUS" || error?.code === "ECORRUPT" || error?.code === "ERUN_PROJECTION" || error?.code === "EAUTHORITY" ? 409 : 500; + json(res, status, { error: status === 409 ? "Loop projection is unavailable; retaining the last verified projection." : "Loop projection is unavailable." }); + } + return; + } if (url.pathname === "/api/events") { if (method !== "GET") return json(res, 405, { error: "method not allowed" }); serveOvenEventFeed({ req, res, url, repos: ovenScopeRepos(), json, observer: ovenEventObserver }); diff --git a/src/server/dashboard-routes-fixtures.mjs b/src/server/dashboard-routes-fixtures.mjs index 5eb7c348..094af8b7 100644 --- a/src/server/dashboard-routes-fixtures.mjs +++ b/src/server/dashboard-routes-fixtures.mjs @@ -198,7 +198,7 @@ function httpRequest(baseUrl, path, { method, headers, body }) { const chunks = []; response.setEncoding("utf8"); response.on("data", (chunk) => chunks.push(chunk)); - response.on("end", () => resolveResponse({ status: response.statusCode, body: chunks.join("") })); + response.on("end", () => resolveResponse({ status: response.statusCode, headers: response.headers, body: chunks.join("") })); }); req.once("error", reject); req.end(body); diff --git a/src/server/run-routes.test.mjs b/src/server/run-routes.test.mjs index cbc606d2..ba0ba069 100644 --- a/src/server/run-routes.test.mjs +++ b/src/server/run-routes.test.mjs @@ -1,5 +1,6 @@ import assert from "node:assert/strict"; -import { existsSync } from "node:fs"; +import { createHash } from "node:crypto"; +import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; import { readFile } from "node:fs/promises"; import { legacyOvenRevision, @@ -9,6 +10,142 @@ import { } from "../ovens/oven-contract.mjs"; import test from "node:test"; import { detailFixture, httpGet, httpRequest, withServer } from "./dashboard-routes-fixtures.mjs"; +import { + createProductionRunAuthority, + fixtureItemRef, + fixtureRunId, + m4ProgressOutcomes, + runM4ProgressFixture, +} from "../loops/run/run-test-fixtures.mjs"; +import { createProductionRun } from "../loops/run/binder.mjs"; +import { runStore } from "../loops/run/run-store.mjs"; + +test("selected progress remains independent from the sanitized read-only Loop projection", { timeout: 20_000 }, async () => { + await withServer({ burnlists: [{ id: "260722-001", title: "Fixture M5" }], setup: async ({ fixtureRoot }) => { + const repo = `${fixtureRoot}/fixture-repo`; + createProductionRunAuthority(repo); + await runM4ProgressFixture({ + repoRoot: repo, + runId: fixtureRunId, + itemRef: fixtureItemRef, + outcomes: m4ProgressOutcomes, + }); + } }, async ({ baseUrl, planPath }) => { + const url = `/api/progress?plan=${encodeURIComponent(planPath)}`; + const first = await httpGet(baseUrl, url), second = await httpGet(baseUrl, url); + assert.equal(first.status, 200); + assert.equal(JSON.parse(first.body).loopRun, null); + assert.equal(JSON.parse(second.body).loopRun, null); + const projection = await httpRequest(baseUrl, `/api/loop-projection?plan=${encodeURIComponent(planPath)}`, { method: "GET" }); + assert.equal(projection.status, 200); + const left = JSON.parse(projection.body).loopRun; + assert.deepEqual(Object.keys(left), ["schema", "runId", "itemRef", "loopId", "loopRevision", "createdAt", "updatedAt", "state", "currentNode", "attempt", "cycle", "latestResult", "latestMaker", "latestCheck", "latestReviewer", "revision", "budget", "graph", "transitions"]); + assert.equal(left.loopId, "review"); + assert.equal(left.loopRevision, null, "generic fixture has no sealed Run authority"); + assert.equal(Number.isSafeInteger(left.createdAt), true); + assert.equal(Number.isSafeInteger(left.updatedAt), true); + assert.ok(left.updatedAt >= left.createdAt); + assert.match(left.revision, /^sha256:[a-f0-9]{64}$/u); + assert.equal(left.budget.limits.maxRounds, 3); + assert.equal(left.state, "converged"); + assert.equal(left.currentNode, "completed"); + assert.deepEqual(left.latestResult, { kind: "approve", summary: "approve" }); + for (const result of [left.latestMaker, left.latestCheck, left.latestReviewer]) { + assert.equal(typeof result?.summary, "string"); + assert.equal(typeof result?.at, "number"); + assert.ok(result?.candidateId === null || /^cm1-sha256:/u.test(result?.candidateId)); + } + assert.deepEqual(left.transitions.map(({ from, outcome, to }) => ({ from, outcome, to })), [ + { from: "prepared", outcome: "control", to: "running" }, + { from: "implement", outcome: "complete", to: "verify" }, + { from: "verify", outcome: "pass", to: "review" }, + { from: "review", outcome: "reject", to: "implement" }, + { from: "implement", outcome: "complete", to: "verify" }, + { from: "verify", outcome: "pass", to: "review" }, + { from: "review", outcome: "approve", to: "converged" }, + { from: "converged", outcome: "pass", to: "completed" }, + ]); + const serialized = JSON.stringify(left); + for (const forbidden of ["invocationId", "lease", "prompt", "route", "authority"]) assert.doesNotMatch(serialized, new RegExp(forbidden, "u")); + assert.equal((await httpRequest(baseUrl, url, { method: "POST" })).status, 405); + const etag = projection.headers.etag; + assert.match(etag, /^W\/"loop-[a-f0-9]{64}"$/u); + const unchanged = await httpRequest(baseUrl, `/api/loop-projection?plan=${encodeURIComponent(planPath)}`, { method: "GET", headers: { "if-none-match": etag } }); + assert.equal(unchanged.status, 304); + }); +}); + +test("unassigned selected progress stays unchanged and Run discovery does not create state", { timeout: 20_000 }, async () => { + await withServer({ withBurnlist: true }, async ({ baseUrl, planPath, repoRoot }) => { + const response = await httpGet(baseUrl, `/api/progress?plan=${encodeURIComponent(planPath)}`); + assert.equal(response.status, 200); + assert.equal(JSON.parse(response.body).loopRun, null); + assert.equal(existsSync(`${repoRoot}/.local/burnlist/loop/m2/runs`), false); + }); +}); + +test("loop projection is a bounded byte-stable conditional read", { timeout: 20_000 }, async () => { + await withServer({ burnlists: [{ id: "260722-001", title: "Loop route" }], setup: async ({ fixtureRoot }) => { + const repo = `${fixtureRoot}/fixture-repo`; + createProductionRunAuthority(repo); + await runM4ProgressFixture({ repoRoot: repo, runId: fixtureRunId, itemRef: fixtureItemRef, outcomes: m4ProgressOutcomes }); + } }, async ({ baseUrl, planPath }) => { + const path = `/api/loop-projection?plan=${encodeURIComponent(planPath)}`; + const first = await httpRequest(baseUrl, path, { method: "GET" }); + const second = await httpRequest(baseUrl, path, { method: "GET" }); + assert.equal(first.status, 200); + assert.equal(first.body, second.body, "canonical projection serialization is byte-stable"); + assert.equal(first.headers.etag, second.headers.etag); + assert.equal(first.headers.etag, `W/"loop-${createHash("sha256").update(first.body).digest("hex")}"`); + assert.equal(Number(first.headers["content-length"]), Buffer.byteLength(first.body)); + assert.ok(Buffer.byteLength(first.body) <= 65_536, "sanitized loop response remains bounded"); + assert.deepEqual(Object.keys(JSON.parse(first.body)), ["loopRun"]); + const unchanged = await httpRequest(baseUrl, path, { method: "GET", headers: { "if-none-match": first.headers.etag } }); + assert.equal(unchanged.status, 304); + assert.equal(unchanged.body, ""); + assert.equal((await httpRequest(baseUrl, path, { method: "POST" })).status, 405); + }); +}); + +test("loop projection distinguishes missing state from corrupt run storage", { timeout: 40_000 }, async () => { + const missing = async ({ fixtureRoot }) => { createProductionRunAuthority(`${fixtureRoot}/fixture-repo`); }; + const corrupt = async ({ fixtureRoot }) => { + const repo = `${fixtureRoot}/fixture-repo`; + createProductionRunAuthority(repo); + mkdirSync(`${repo}/.local/burnlist/loop/m2/runs/not-a-run`, { recursive: true }); + }; + for (const [setup, expectedStatus] of [[missing, 200], [corrupt, 409]]) { + await withServer({ burnlists: [{ id: "260722-001", title: "Loop route" }], setup }, async ({ baseUrl, planPath }) => { + const progress = await httpRequest(baseUrl, `/api/progress?plan=${encodeURIComponent(planPath)}`, { method: "GET" }); + assert.equal(progress.status, 200, "progress remains usable when Loop storage is corrupt"); + const response = await httpRequest(baseUrl, `/api/loop-projection?plan=${encodeURIComponent(planPath)}`, { method: "GET" }); + assert.equal(response.status, expectedStatus); + if (expectedStatus === 200) assert.equal(JSON.parse(response.body).loopRun, null); + else assert.equal(JSON.parse(response.body).error, "Loop projection is unavailable; retaining the last verified projection."); + }); + } +}); + +test("sealed production authority corruption returns the dedicated projection conflict while progress stays healthy", { timeout: 60_000 }, async () => { + for (const mutation of ["missing", "malformed"]) { + await withServer({ burnlists: [{ id: "260722-001", title: `Loop authority ${mutation}` }], setup: async ({ fixtureRoot }) => { + const repo = `${fixtureRoot}/fixture-repo`; + createProductionRunAuthority(repo); + const store = runStore(repo); + await createProductionRun({ repoRoot: repo, store, itemRef: fixtureItemRef, runId: fixtureRunId }); + const authorityPath = store.paths.authorityPath(fixtureRunId); + if (mutation === "missing") rmSync(authorityPath); + else writeFileSync(authorityPath, "{not-json\n"); + } }, async ({ baseUrl, planPath }) => { + const progress = await httpRequest(baseUrl, `/api/progress?plan=${encodeURIComponent(planPath)}`, { method: "GET" }); + assert.equal(progress.status, 200, `progress remains usable with ${mutation} sealed authority`); + assert.equal(JSON.parse(progress.body).loopRun, null); + const projection = await httpRequest(baseUrl, `/api/loop-projection?plan=${encodeURIComponent(planPath)}`, { method: "GET" }); + assert.equal(projection.status, 409, `${mutation} sealed authority is projection corruption`); + assert.deepEqual(JSON.parse(projection.body), { error: "Loop projection is unavailable; retaining the last verified projection." }); + }); + } +}); test("Burn runs read legacy v3/v4 revisions and write/read v5 revisions", { timeout: 20_000 }, async () => { const legacyInstructions = "# Legacy Oven\n\nFollow the checklist.\n"; const legacyDetail = detailFixture(); From 61c68027fbc49122837b9ea206657938e64ae008 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Fri, 24 Jul 2026 10:14:20 +0200 Subject: [PATCH 04/37] feat: expose loop commands from the main CLI --- bin/burnlist.mjs | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/bin/burnlist.mjs b/bin/burnlist.mjs index 37aa9bf9..95105171 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", @@ -83,7 +86,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 +108,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 list [--repo ] + burnlist loop run|resume [--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 agent ... + burnlist route set --profile [--repo ] burnlist register [path] burnlist unregister [path] burnlist roots [--prune] @@ -144,6 +161,15 @@ 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 (args[0] === "agent") { + const { runAgentCliEntry } = await import("../src/cli/loop-config-cli.mjs"); + await runAgentCliEntry(args.slice(1)); +} else if (args[0] === "route") { + const { runRouteCliEntry } = await import("../src/cli/loop-config-cli.mjs"); + await runRouteCliEntry(args.slice(1)); } else if (["register", "unregister", "roots", "init"].includes(args[0])) { await import("../src/cli/registry-cli.mjs"); } else { From 9f2b8dd12071217fecce54722dbbae4037de948f Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Fri, 24 Jul 2026 10:14:20 +0200 Subject: [PATCH 05/37] test: verify the installed loop product --- package.json | 8 +- scripts/package-paths.json | 204 ++++++++++++++++++++++++++++ scripts/smoke-global-install.mjs | 113 +++++++++++++-- scripts/verify-package.mjs | 93 ++++++++++++- scripts/verify-source-scan.mjs | 23 ++++ scripts/verify-source-scan.test.mjs | 26 ++++ scripts/verify-test-files.mjs | 33 ++++- scripts/verify.mjs | 19 +-- 8 files changed, 483 insertions(+), 36 deletions(-) create mode 100644 scripts/package-paths.json create mode 100644 scripts/verify-source-scan.mjs create mode 100644 scripts/verify-source-scan.test.mjs diff --git a/package.json b/package.json index 66cdb8b1..687f10b1 100644 --- a/package.json +++ b/package.json @@ -19,13 +19,19 @@ "files": [ "bin/", "src/", + "loops/", "skills/", "ovens/", "dashboard/dist/", "scripts/register-skills.mjs", "scripts/unregister-skills.mjs", "!**/*.test.mjs", - "!**/*.test.js" + "!**/*.test.js", + "!**/__fixtures__/", + "!src/loops/minimal-review-e2e-fixtures.mjs", + "!src/loops/run/m2-test-fixtures.mjs", + "!src/loops/run/run-test-fixtures.mjs", + "!src/server/dashboard-routes-fixtures.mjs" ], "scripts": { "build:dashboard": "vite build --config dashboard/vite.config.mjs", diff --git a/scripts/package-paths.json b/scripts/package-paths.json new file mode 100644 index 00000000..2012abda --- /dev/null +++ b/scripts/package-paths.json @@ -0,0 +1,204 @@ +[ + "LICENSE", + "README.md", + "bin/burnlist.mjs", + "dashboard/dist/assets/index-BHMojAeL.js", + "dashboard/dist/assets/index-qhZB46pA.css", + "dashboard/dist/favicon.svg", + "dashboard/dist/index.html", + "loops/review/instructions.md", + "loops/review/review.loop", + "ovens/catalog.json", + "ovens/checklist/checklist.oven", + "ovens/checklist/instructions.md", + "ovens/differential-testing/differential-testing.oven", + "ovens/differential-testing/engine/adapter-sdk.mjs", + "ovens/differential-testing/engine/contract.mjs", + "ovens/differential-testing/engine/data-contract.mjs", + "ovens/differential-testing/engine/data.schema.json", + "ovens/differential-testing/engine/handler.mjs", + "ovens/differential-testing/engine/query-projection-cache.mjs", + "ovens/differential-testing/engine/transport.mjs", + "ovens/differential-testing/engine/worker-runtime.mjs", + "ovens/differential-testing/example/adapter.mjs", + "ovens/differential-testing/example/candidate.json", + "ovens/differential-testing/example/reference.json", + "ovens/differential-testing/instructions.md", + "ovens/model-lab/engine/model-lab-contract.mjs", + "ovens/model-lab/engine/model-lab-handler.mjs", + "ovens/model-lab/instructions.md", + "ovens/model-lab/model-lab.oven", + "ovens/model-lab/renderer/model-lab.css", + "ovens/performance-tracing/contract.mjs", + "ovens/performance-tracing/handler.mjs", + "ovens/performance-tracing/instructions.md", + "ovens/performance-tracing/performance-tracing.oven", + "ovens/streaming-diff/engine/streaming-diff-capture-git.mjs", + "ovens/streaming-diff/engine/streaming-diff-capture.mjs", + "ovens/streaming-diff/engine/streaming-diff-claude-hook.mjs", + "ovens/streaming-diff/engine/streaming-diff-codex-hook.mjs", + "ovens/streaming-diff/engine/streaming-diff-data-contract.mjs", + "ovens/streaming-diff/engine/streaming-diff-durable-write.mjs", + "ovens/streaming-diff/engine/streaming-diff-ensure-feed.mjs", + "ovens/streaming-diff/engine/streaming-diff-feed-capture.mjs", + "ovens/streaming-diff/engine/streaming-diff-feed.mjs", + "ovens/streaming-diff/engine/streaming-diff-handler.mjs", + "ovens/streaming-diff/engine/streaming-diff-hook-adapters.mjs", + "ovens/streaming-diff/engine/streaming-diff-hook-contract.mjs", + "ovens/streaming-diff/engine/streaming-diff-journal.mjs", + "ovens/streaming-diff/engine/streaming-diff-linediff.mjs", + "ovens/streaming-diff/engine/streaming-diff-snapshot-store.mjs", + "ovens/streaming-diff/instructions.md", + "ovens/streaming-diff/streaming-diff.oven", + "ovens/visual-parity/contract.mjs", + "ovens/visual-parity/handler.mjs", + "ovens/visual-parity/instructions.md", + "ovens/visual-parity/visual-parity.oven", + "package.json", + "scripts/register-skills.mjs", + "scripts/unregister-skills.mjs", + "skills/burnlist/SKILL.md", + "skills/burnlist/agents/openai.yaml", + "skills/burnlist/references/burnlist-creation.md", + "skills/burnlist/references/burnlist-dashboard.md", + "skills/burnlist/references/burnlist-protocol.md", + "skills/burnlist/references/burnlist-splitting-lanes.md", + "skills/burnlist/references/burnlist-visible-output.md", + "skills/burnlist/references/creating-ovens.md", + "skills/burnlist/references/designing-ovens.md", + "skills/burnlist/references/differential-testing-adapter-sdk.md", + "skills/burnlist/references/differential-testing-data.md", + "skills/burnlist/references/getting-started.md", + "skills/burnlist/references/installation.md", + "skills/burnlist/references/loop-capability-example.json", + "skills/burnlist/references/oven-authoring.md", + "skills/burnlist/references/oven-contract.md", + "skills/burnlist/references/oven-event-coordination.md", + "src/cli/atomic-quarantine.mjs", + "src/cli/git-ignore.mjs", + "src/cli/hooks-cli.mjs", + "src/cli/hooks-config.mjs", + "src/cli/lifecycle-cli.mjs", + "src/cli/lifecycle-moves.mjs", + "src/cli/local-exclude.mjs", + "src/cli/loop-cli.mjs", + "src/cli/loop-config-cli.mjs", + "src/cli/oven-cli-render.mjs", + "src/cli/oven-cli.mjs", + "src/cli/oven-set.mjs", + "src/cli/oven-storage.mjs", + "src/cli/oven-use.mjs", + "src/cli/registry-cli.mjs", + "src/cli/skills-exclude.mjs", + "src/cli/skills-install-cli.mjs", + "src/cli/skills-install-git.mjs", + "src/cli/skills-install-lock.mjs", + "src/cli/skills-install-transaction.mjs", + "src/cli/skills-register.mjs", + "src/cli/streaming-diff-cli.mjs", + "src/cli/umbrella.mjs", + "src/events/oven-canonical-mutations.mjs", + "src/events/oven-data-events.mjs", + "src/events/oven-event-contract.mjs", + "src/events/oven-event-deliveries.mjs", + "src/events/oven-event-feed.mjs", + "src/events/oven-event-observer.mjs", + "src/events/oven-event-store.mjs", + "src/events/oven-event-stream-storage.mjs", + "src/events/oven-events.mjs", + "src/loops/adapters/codex-cli.mjs", + "src/loops/adapters/normalized-invocation.mjs", + "src/loops/agents/profile.mjs", + "src/loops/assignment/assignment.mjs", + "src/loops/assignment/hazards.mjs", + "src/loops/assignment/item-metadata.mjs", + "src/loops/assignment/resolver.mjs", + "src/loops/assignment/selectors.mjs", + "src/loops/assignment/store-worker.mjs", + "src/loops/assignment/store.mjs", + "src/loops/capabilities/contract.mjs", + "src/loops/capabilities/runner.mjs", + "src/loops/capabilities/snapshot.mjs", + "src/loops/capabilities/trust.mjs", + "src/loops/completion/completion.mjs", + "src/loops/config/profiles.mjs", + "src/loops/config/setup.mjs", + "src/loops/config/store.mjs", + "src/loops/contracts/agent-result.mjs", + "src/loops/contracts/check-result.mjs", + "src/loops/contracts/contract.mjs", + "src/loops/contracts/finding.mjs", + "src/loops/dsl/canonical.mjs", + "src/loops/dsl/compile.mjs", + "src/loops/dsl/diagnostics.mjs", + "src/loops/dsl/frozen.mjs", + "src/loops/dsl/grammar.mjs", + "src/loops/dsl/hash.mjs", + "src/loops/dsl/instructions.mjs", + "src/loops/dsl/ir-validate.mjs", + "src/loops/dsl/loop-xml.mjs", + "src/loops/dsl/package-read.mjs", + "src/loops/events/projection-events.mjs", + "src/loops/run/binder.mjs", + "src/loops/run/candidate.mjs", + "src/loops/run/budgets.mjs", + "src/loops/run/controller.mjs", + "src/loops/run/current-authority.mjs", + "src/loops/run/launch-commit.mjs", + "src/loops/run/lifecycle.mjs", + "src/loops/run/operation.mjs", + "src/loops/run/read-projection.mjs", + "src/loops/run/run-admission.mjs", + "src/loops/run/run-artifacts.mjs", + "src/loops/run/run-claim.mjs", + "src/loops/run/run-clock.mjs", + "src/loops/run/run-codec.mjs", + "src/loops/run/run-created.mjs", + "src/loops/run/run-fold.mjs", + "src/loops/run/run-journal.mjs", + "src/loops/run/run-record.mjs", + "src/loops/run/run-ref.mjs", + "src/loops/run/run-result.mjs", + "src/loops/run/run-store.mjs", + "src/loops/run/runner.mjs", + "src/loops/run/state-machine.mjs", + "src/loops/view/render.mjs", + "src/ovens/built-in-handlers.mjs", + "src/ovens/dsl/oven-compile.mjs", + "src/ovens/dsl/oven-diagnostics.mjs", + "src/ovens/dsl/oven-grammar.mjs", + "src/ovens/dsl/oven-ir.mjs", + "src/ovens/dsl/oven-validate.mjs", + "src/ovens/dsl/xml-scan.mjs", + "src/ovens/handlers/checklist.mjs", + "src/ovens/handlers/generic-json-handler.mjs", + "src/ovens/official-oven-catalog.mjs", + "src/ovens/oven-contract.mjs", + "src/ovens/oven-data-validate.mjs", + "src/ovens/oven-registry.mjs", + "src/ovens/oven-runtime-compatibility.mjs", + "src/ovens/oven-starter.mjs", + "src/server/burnlist-dashboard-server.mjs", + "src/server/burnlist-discovery.mjs", + "src/server/dashboard-entry-isolation.mjs", + "src/server/dir-lock.mjs", + "src/server/discovery.mjs", + "src/server/fs-bounded-read.mjs", + "src/server/fs-safe.mjs", + "src/server/official-oven-discovery.mjs", + "src/server/oven-bindings.mjs", + "src/server/oven-data-store.mjs", + "src/server/oven-json-handler.mjs", + "src/server/oven-json-snapshot.mjs", + "src/server/oven-package-lock.mjs", + "src/server/oven-projection-coordinator.mjs", + "src/server/oven-response-stream.mjs", + "src/server/oven-storage.mjs", + "src/server/oven-vendor.mjs", + "src/server/plan-model.mjs", + "src/server/projects.mjs", + "src/server/registry.mjs", + "src/server/repo-map-worker.mjs", + "src/server/repo-map.mjs", + "src/server/repo-state.mjs" +] diff --git a/scripts/smoke-global-install.mjs b/scripts/smoke-global-install.mjs index 431dca82..92be1a72 100755 --- a/scripts/smoke-global-install.mjs +++ b/scripts/smoke-global-install.mjs @@ -1,12 +1,16 @@ #!/usr/bin/env node import { spawnSync } from "node:child_process"; import { + existsSync, lstatSync, mkdirSync, mkdtempSync, + readFileSync, readlinkSync, realpathSync, rmSync, + chmodSync, + writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; @@ -29,12 +33,23 @@ const env = { USERPROFILE: home, npm_config_cache: npmCache, }; +const testNode = resolve(process.env.BURNLIST_TEST_NODE || process.execPath); +const npmCli = process.env.BURNLIST_TEST_NODE + ? resolve(dirname(dirname(testNode)), "lib", "node_modules", "npm", "bin", "npm-cli.js") + : process.env.npm_execpath; + +function assertSelectedNode() { + const version = spawnSync(testNode, ["--version"], { encoding: "utf8", shell: false }); + if (version.status !== 0) throw new Error("BURNLIST_TEST_NODE is not executable"); + if (process.env.BURNLIST_TEST_NODE && !/^v18\./u.test(version.stdout.trim())) throw new Error("BURNLIST_TEST_NODE must select Node 18 for the compatibility smoke"); + if (!npmCli || !existsSync(npmCli)) throw new Error("selected Node runtime does not provide npm-cli.js"); +} function run(command, args, options = {}) { const result = spawnSync(command, args, { cwd: options.cwd || repoRoot, encoding: options.capture ? "utf8" : undefined, - env, + env: options.env ?? env, maxBuffer: 8 * 1024 * 1024, shell: false, stdio: options.capture ? "pipe" : "inherit", @@ -46,6 +61,12 @@ function run(command, args, options = {}) { return options.capture ? result.stdout.trim() : ""; } +function invokeCli(cli, args, options = {}) { + return run(testNode, [cli, ...args], options); +} + +function runNpm(args, options = {}) { return run(testNode, [npmCli, ...args], options); } + function assertManagedLink(agentDirectory, name, packageRoot) { const target = join(home, agentDirectory, "skills", name); const stat = lstatSync(target); @@ -55,12 +76,77 @@ function assertManagedLink(agentDirectory, name, packageRoot) { if (actual !== expected) throw new Error(`${target} points to ${actual}, expected ${expected}`); } +function command(cli, repo, args, options = {}) { + return invokeCli(cli, [...args, "--repo", repo], { ...options, cwd: repo }); +} + +function writeLoopFixture(repo) { + mkdirSync(join(repo, ".burnlist"), { recursive: true }); + mkdirSync(join(repo, "src"), { recursive: true }); + mkdirSync(join(repo, "notes", "burnlists", "inprogress", "260722-001"), { recursive: true }); + writeFileSync(join(repo, "notes", "burnlists", "inprogress", "260722-001", "burnlist.md"), "# Smoke Loop\n\n## Active Checklist\n- [ ] L1 | Packed Loop proof\n\n## Completed\n"); + const binary = join(repo, "fixtures", "fake-codex"); + mkdirSync(dirname(binary), { recursive: true }); + writeFileSync(binary, `#!${testNode} +const fs=require("node:fs"),args=process.argv.slice(2),prompt=args.at(-1),lines=Object.fromEntries(prompt.split("\\n").filter((line)=>line.includes("=")).map((line)=>line.split(/=(.*)/s).slice(0,2))); +const counter=process.env.BURNLIST_FAKE_COUNTER,index=counter?Number(fs.readFileSync(counter,"utf8")):0,outcome=(process.env.BURNLIST_FAKE_OUTCOMES||"complete,approve").split(",")[index]||"approve"; +if(counter)fs.writeFileSync(counter,String(index+1)); +const final={schema:"burnlist.agent-final@1",runId:lines.run,nodeId:lines.node,attempt:Number(lines.attempt),claimId:lines.claim,invocationId:lines.invocation,assignmentId:lines.assignment,recipeRevision:lines.recipe,policyRevision:lines.policy,inputCandidate:lines.candidate,outcome,summary:"fake "+outcome}; +process.stdout.write(JSON.stringify({type:"thread.started",thread_id:"smoke-"+process.pid,model:args[args.indexOf("-m")+1]})+"\\n"); +process.stdout.write(JSON.stringify({type:"item.completed",item:{type:"agent_message",text:JSON.stringify(final)}})+"\\n"); +process.stdout.write(JSON.stringify({type:"turn.completed",usage:{input_tokens:1,output_tokens:1,cached_input_tokens:0}})+"\\n"); +`); + chmodSync(binary, 0o700); + const capability = { id: "repo-verify", argv: [testNode, "-e", "process.exit(0)"], cwd: ".", environment: { inherit: ["PATH"], set: {} }, network: "deny", filesystem: { read: ["src"], write: [] }, output: { maxBytes: 1024 }, maxMilliseconds: 1000 }; + writeFileSync(join(repo, ".burnlist", "loop-capabilities.json"), `${JSON.stringify({ schema: "burnlist-loop-capabilities@1", capabilities: [capability] })}\n`); + writeFileSync(join(repo, "grants.json"), `${JSON.stringify({ argv: capability.argv, cwd: capability.cwd, environment: capability.environment, network: capability.network, filesystem: capability.filesystem, output: capability.output, maxMilliseconds: capability.maxMilliseconds })}\n`); + return { binary, itemRef: "item:260722-001#L1" }; +} + +function assertLoopFlow(cli) { + const repoPath = join(tmpRoot, "loop-repo"); + mkdirSync(repoPath, { recursive: true }); + run("git", ["init", "--quiet", repoPath]); + const repo = realpathSync(repoPath); + const { binary, itemRef } = writeLoopFixture(repo); + const profile = (slug, authority) => command(cli, repo, ["agent", "profile", "add", slug, "--adapter", "builtin:codex-cli", "--binary", binary, "--model", "gpt-5.6-terra", "--effort", "medium", "--authority", authority]); + profile("maker", "write"); profile("reviewer", "read"); + command(cli, repo, ["route", "set", "implementation.standard", "--profile", "maker"]); + command(cli, repo, ["route", "set", "review.strong", "--profile", "reviewer"]); + const capability = JSON.parse(command(cli, repo, ["loop", "capability", "inspect", "repo-verify"], { capture: true })); + command(cli, repo, ["loop", "capability", "trust", "repo-verify", "--revision", capability.revision, "--grants", join(repo, "grants.json")]); + const setup = command(cli, repo, ["loop", "setup", "status"], { capture: true }); + if (setup !== "Loop setup: ready") throw new Error(`packed Loop setup did not become ready: ${setup}`); + command(cli, repo, ["loop", "assign", itemRef, "loop:builtin:review"]); + const view = command(cli, repo, ["loop", "view", itemRef], { capture: true }); + if (!view.includes("LOOP: loop:builtin:review")) throw new Error("packed CLI did not render the assigned Loop"); + const runId = JSON.parse(command(cli, repo, ["loop", "create", itemRef], { capture: true })).runId; + for (const operation of ["status", "inspect"]) JSON.parse(command(cli, repo, ["loop", operation, runId], { capture: true })); + const counter = join(repo, "counter"); writeFileSync(counter, "0"); + const result = JSON.parse(command(cli, repo, ["loop", "run", runId], { capture: true, env: { ...env, BURNLIST_FAKE_COUNTER: counter, BURNLIST_FAKE_OUTCOMES: "complete,approve" } })); + if (result.state !== "converged") throw new Error(`packed Loop did not converge: ${result.state}`); + const first = JSON.parse(command(cli, repo, ["loop", "complete", runId], { capture: true })); + const second = JSON.parse(command(cli, repo, ["loop", "complete", runId], { capture: true })); + if (first.alreadyApplied || !second.alreadyApplied) throw new Error("packed Loop completion was not idempotent"); + const plan = readFileSync(join(repo, "notes", "burnlists", "inprogress", "260722-001", "burnlist.md"), "utf8"); + if (/^- \[ \] L1 \|/mu.test(plan) || (plan.match(/^- L1 \| /gmu) ?? []).length !== 1) throw new Error("packed Loop completion did not atomically burn the assigned item"); + const runDirectory = join(repo, ".local", "burnlist", "loop", "m2", "runs", Buffer.from(runId).toString("hex")); + if (!lstatSync(join(runDirectory, "completion-receipt.json")).isFile()) throw new Error("packed Loop completion did not retain its receipt"); + try { lstatSync(join(runDirectory, "completion-intent.json")); throw new Error("packed Loop completion left an intent behind"); } + catch (error) { if (error.code !== "ENOENT") throw error; } + const skillsBeforeHooks = realpathSync(join(home, ".agents", "skills", "burnlist")); + invokeCli(cli, ["hooks", "install", "--agent", "codex"], { cwd: repo }); + if (realpathSync(join(home, ".agents", "skills", "burnlist")) !== skillsBeforeHooks) throw new Error("hooks installation modified skill registration"); + invokeCli(cli, ["hooks", "uninstall", "--agent", "codex"], { cwd: repo }); +} + let exitCode = 0; try { + assertSelectedNode(); mkdirSync(home, { recursive: true }); mkdirSync(prefix, { recursive: true }); mkdirSync(packRoot, { recursive: true }); - const packJson = run("npm", [ + const packJson = runNpm([ "pack", "--ignore-scripts", "--json", @@ -70,25 +156,24 @@ try { const [packReport] = JSON.parse(packJson); const tarball = resolve(packRoot, packReport.filename); - run("npm", ["install", "--global", "--prefix", prefix, tarball]); - const globalRoot = run("npm", ["root", "--global", "--prefix", prefix], { capture: true }); + runNpm(["install", "--global", "--prefix", prefix, tarball]); + const globalRoot = runNpm(["root", "--global", "--prefix", prefix], { capture: true }); const packageRoot = resolve(globalRoot, "burnlist"); assertManagedLink(".claude", "burnlist", packageRoot); assertManagedLink(".agents", "burnlist", packageRoot); - const cli = process.platform === "win32" - ? join(prefix, "burnlist.cmd") - : join(prefix, "bin", "burnlist"); - const version = run(cli, ["--version"], { capture: true }); + const cli = join(packageRoot, "bin", "burnlist.mjs"); + const version = invokeCli(cli, ["--version"], { capture: true }); if (version !== packReport.version) { throw new Error(`installed CLI reported ${version}, expected ${packReport.version}`); } - run(cli, ["--stamp"], { capture: true }); - run(cli, ["oven", "list"], { capture: true }); - const sdkPath = run(cli, ["differential-testing", "sdk"], { capture: true }); + assertLoopFlow(cli); + invokeCli(cli, ["--stamp"], { capture: true }); + invokeCli(cli, ["oven", "list"], { capture: true }); + const sdkPath = invokeCli(cli, ["differential-testing", "sdk"], { capture: true }); const expectedSdkPath = resolve(packageRoot, "ovens", "differential-testing", "engine", "adapter-sdk.mjs"); if (realpathSync(sdkPath) !== realpathSync(expectedSdkPath)) throw new Error(`installed CLI reported unexpected SDK path: ${sdkPath}`); - run(process.execPath, ["--input-type=module", "--eval", ` + run(testNode, ["--input-type=module", "--eval", ` const sdk = await import(${JSON.stringify(pathToFileURL(sdkPath).href)}); const expected = [ "DIFFERENTIAL_TESTING_ADAPTER_SDK_VERSION", @@ -100,13 +185,13 @@ try { if (sdk.DIFFERENTIAL_TESTING_ADAPTER_SDK_VERSION !== 4 || JSON.stringify(Object.keys(sdk).sort()) !== JSON.stringify(expected.sort())) process.exit(1); `]); - run(process.execPath, ["--input-type=module", "--eval", ` + run(testNode, ["--input-type=module", "--eval", ` const events = await import("burnlist/oven-events"); const expected = ["normalizeOvenEvent", "publishOvenEvent", "readOvenEvents"]; if (!expected.every((name) => typeof events[name] === "function")) process.exit(1); `], { cwd: packageRoot }); - run(cli, ["uninstall", "--global", "--purge"]); + invokeCli(cli, ["uninstall", "--global", "--purge"]); for (const agentDirectory of [".claude", ".agents"]) { for (const name of ["burnlist"]) { try { diff --git a/scripts/verify-package.mjs b/scripts/verify-package.mjs index 1cb1f975..59c89655 100755 --- a/scripts/verify-package.mjs +++ b/scripts/verify-package.mjs @@ -1,6 +1,8 @@ #!/usr/bin/env node import { spawnSync } from "node:child_process"; -import { dirname, resolve } from "node:path"; +import { mkdtempSync, readdirSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, relative, resolve } from "node:path"; import { fileURLToPath } from "node:url"; const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); @@ -25,6 +27,30 @@ try { } const files = new Map((report.files || []).map((entry) => [entry.path, entry])); +const packedPaths = [...files.keys()]; +const expectedPaths = JSON.parse(readFileSync(resolve(repoRoot, "scripts", "package-paths.json"), "utf8")); +if (!Array.isArray(expectedPaths) || JSON.stringify([...packedPaths].sort()) !== JSON.stringify([...expectedPaths].sort())) { + console.error("npm package paths differ from the committed package-paths.json manifest."); + process.exit(1); +} +const forbiddenPayloadBytes = [ + ["BEGIN", "PRIVATE", "KEY"].join(" "), + ["BEGIN", "OPENSSH", "PRIVATE", "KEY"].join(" "), + ["BURNLIST", "FAKE", ""].join("_"), +]; +const forbiddenPayloadText = [ + /(?:^|[^\w])\/(?:Users|home)\/[^/\s]+(?:\/|$)/u, + /\b(?:SECRET|TOKEN|API_KEY)\s*=/u, + /\b(?:prototype-only|prototype command|prototype CLI)\b/iu, +]; + +function sourceFiles(directory, root = repoRoot) { + return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const path = join(directory, entry.name); + if (entry.isDirectory()) return sourceFiles(path, root); + return entry.isFile() ? [relative(root, path).replace(/\\/gu, "/")] : []; + }); +} const required = [ "LICENSE", "README.md", @@ -43,6 +69,9 @@ const required = [ "src/ovens/oven-contract.mjs", "src/ovens/official-oven-catalog.mjs", "src/server/burnlist-dashboard-server.mjs", + "loops/review/review.loop", + "loops/review/instructions.md", + "skills/burnlist/references/loop-capability-example.json", "skills/burnlist/SKILL.md", "skills/burnlist/references/burnlist-creation.md", "skills/burnlist/references/oven-event-coordination.md", @@ -83,6 +112,19 @@ for (const path of required) { } } +// The Loop is not an optional documentation add-on: every non-test runtime +// module and installed Loop source must be present in the tarball. +const runtimeSources = [ + ...sourceFiles(resolve(repoRoot, "src/loops")), + ...sourceFiles(resolve(repoRoot, "loops")), +].filter((path) => !path.includes("/__fixtures__/") && !/\.test\.m?js$/u.test(path) && !/(?:minimal-review-e2e-fixtures|m2-test-fixtures|run-test-fixtures)\.mjs$/u.test(path)); +for (const path of runtimeSources) { + if (!files.has(path)) { + console.error(`npm package is missing Loop runtime asset: ${path}`); + process.exit(1); + } +} + for (const extension of [".css", ".js"]) { if (![...files.keys()].some((path) => path.startsWith("dashboard/dist/assets/") && path.endsWith(extension))) { console.error(`npm package is missing the built dashboard ${extension} asset.`); @@ -107,21 +149,68 @@ const forbidden = [ /^skills\/burnlist\/references\/compare-data\.md$/u, /^skills\/burnlist\/scripts\/compare-data-contract(?:\.test)?\.mjs$/u, /^skills\/burnlist\/ovens\/target(?:\/|$)/u, + /^src\/loops\/minimal-review-e2e-fixtures\.mjs$/u, + /^src\/loops\/adapters\/docker-isolation\.mjs$/u, + /^src\/loops\/config\/(?:controllers|probes)\.mjs$/u, + /^src\/loops\/run\/(?:m2|run)-test-fixtures\.mjs$/u, + /^src\/server\/dashboard-routes-fixtures\.mjs$/u, + /(?:^|\/)__fixtures__(?:\/|$)/u, /\.test\.(?:mjs|js)$/u, /\.zip$/u, ]; -for (const path of files.keys()) { +for (const path of packedPaths) { if (forbidden.some((pattern) => pattern.test(path))) { console.error(`npm package contains forbidden file: ${path}`); process.exit(1); } } +for (const path of packedPaths.filter((entry) => !entry.startsWith("dashboard/dist/assets/"))) { + const text = readFileSync(resolve(repoRoot, path), "utf8"); + if (/\/Users\//u.test(text)) { + console.error(`npm package contains a personal path: ${path}`); + process.exit(1); + } +} + const bin = files.get("bin/burnlist.mjs"); if ((bin.mode & 0o111) === 0) { console.error("npm package CLI is not executable."); process.exit(1); } +if (report.entryCount > 220 || report.unpackedSize > 2_500_000) { + console.error(`npm package exceeds its bounded payload budget: ${report.entryCount} files, ${report.unpackedSize} bytes.`); + process.exit(1); +} + +// npm's dry-run manifest is useful for policy, but inspect the extracted bytes +// too: this catches leaks in generated dashboard files as well as source text. +const packDirectory = mkdtempSync(join(tmpdir(), "burnlist-package-verify-")); +try { + const packed = spawnSync("npm", ["pack", "--ignore-scripts", "--json", "--pack-destination", packDirectory], { + cwd: repoRoot, encoding: "utf8", maxBuffer: 8 * 1024 * 1024, shell: false, + }); + if (packed.status !== 0) throw new Error(packed.stderr || "npm pack failed"); + const [{ filename }] = JSON.parse(packed.stdout); + const untar = spawnSync("tar", ["-xzf", join(packDirectory, filename), "-C", packDirectory], { encoding: "utf8", shell: false }); + if (untar.status !== 0) throw new Error(untar.stderr || "tar extraction failed"); + const actual = sourceFiles(join(packDirectory, "package"), join(packDirectory, "package")).sort(); + const expected = [...expectedPaths].sort(); + if (JSON.stringify(actual) !== JSON.stringify(expected)) throw new Error("packed tarball paths differ from npm dry-run manifest"); + for (const path of actual) { + const bytes = readFileSync(join(packDirectory, "package", path)); + const text = bytes.toString("utf8"); + if (forbiddenPayloadBytes.some((marker) => bytes.includes(Buffer.from(marker))) || forbiddenPayloadText.some((pattern) => pattern.test(text))) { + throw new Error(`packed tarball contains a forbidden personal path or prototype command: ${path}`); + } + } +} catch (error) { + console.error(`npm package byte verification failed: ${error.message}`); + process.exit(1); +} finally { + rmSync(packDirectory, { recursive: true, force: true }); +} + console.log(`npm package payload verified: ${report.entryCount} files, ${report.unpackedSize} bytes unpacked.`); diff --git a/scripts/verify-source-scan.mjs b/scripts/verify-source-scan.mjs new file mode 100644 index 00000000..152880b9 --- /dev/null +++ b/scripts/verify-source-scan.mjs @@ -0,0 +1,23 @@ +const EXCLUDED_PREFIXES = [ + ".git/", + ".claude/", + ".local/", + ".playwright-cli/", + ".worktrees/", + "build/", + "dist/", + "node_modules/", + "notes/burnlists/", + "output/", + "research/", + "website/.astro/", + "website/dist/", + "website/node_modules/", +]; + +export function shouldScanSourceRelativePath(path) { + const normalized = String(path).replaceAll("\\", "/"); + return !EXCLUDED_PREFIXES.some( + (prefix) => normalized === prefix.slice(0, -1) || normalized.startsWith(prefix), + ); +} diff --git a/scripts/verify-source-scan.test.mjs b/scripts/verify-source-scan.test.mjs new file mode 100644 index 00000000..18dd8fbe --- /dev/null +++ b/scripts/verify-source-scan.test.mjs @@ -0,0 +1,26 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { shouldScanSourceRelativePath } from "./verify-source-scan.mjs"; + +test("source leak scanning excludes local and nested checkout state", () => { + for (const path of [ + ".git/config", + ".local/burnlist/state.json", + ".worktrees/feature/.git", + ".worktrees/feature/src/private.mjs", + "node_modules/package/index.js", + ]) { + assert.equal(shouldScanSourceRelativePath(path), false, path); + } +}); + +test("source leak scanning retains repository source and similarly named paths", () => { + for (const path of [ + "src/loops/view/render.mjs", + "scripts/verify.mjs", + "worktrees/committed-example.mjs", + "src/.git-example.mjs", + ]) { + assert.equal(shouldScanSourceRelativePath(path), true, path); + } +}); diff --git a/scripts/verify-test-files.mjs b/scripts/verify-test-files.mjs index 590d0e65..5a1c2243 100644 --- a/scripts/verify-test-files.mjs +++ b/scripts/verify-test-files.mjs @@ -7,7 +7,6 @@ export const verificationTestFiles = [ "dashboard/src/lib/route-model.test.mjs", "src/server/dashboard-routes.test.mjs", "src/server/official-oven-discovery.test.mjs", - "src/server/oven-event-routes.test.mjs", "src/events/oven-event-feed.test.mjs", "src/events/oven-event-observer.test.mjs", "src/events/oven-canonical-mutations.test.mjs", @@ -74,7 +73,6 @@ export const verificationTestFiles = [ "src/cli/atomic-quarantine.test.mjs", "src/cli/skills-install-cli.test.mjs", "src/cli/skills-install-cli-purge.test.mjs", - "src/cli/commands-help.test.mjs", "src/cli/oven-cli.test.mjs", "src/cli/oven-data-flow-e2e.test.mjs", "src/cli/oven-set-cli.test.mjs", @@ -85,6 +83,33 @@ export const verificationTestFiles = [ "src/cli/oven-vendor-cli.test.mjs", "src/cli/umbrella.test.mjs", "src/cli/hooks-config.test.mjs", + "src/cli/loop-cli.test.mjs", + "src/cli/loop-runtime-cli.test.mjs", + "src/loops/config/config.test.mjs", + "src/loops/assignment/assignment.test.mjs", + "src/loops/dsl/compile.test.mjs", + "src/loops/dsl/package-read.test.mjs", + "src/loops/view/render.test.mjs", + "src/loops/capabilities/capabilities.test.mjs", + "src/loops/adapters/codex-cli.test.mjs", + "src/loops/adapters/normalized-invocation.test.mjs", + "src/loops/agents/profile.test.mjs", + "src/loops/contracts/contracts.test.mjs", + "src/loops/run/run-store.test.mjs", + "src/loops/run/controller.test.mjs", + "src/loops/run/current-authority.test.mjs", + "src/loops/run/launch-authority.test.mjs", + "src/loops/run/run-admission.test.mjs", + "src/loops/run/run-clock.test.mjs", + "src/loops/events/projection-events.test.mjs", + "src/loops/run/run-journal.test.mjs", + "src/loops/run/run-fold.test.mjs", + "src/loops/run/state-machine.test.mjs", + "src/loops/run/budgets.test.mjs", + "src/loops/run/runner.test.mjs", + "src/loops/run/binder.test.mjs", + "src/loops/run/reviewer-isolation.test.mjs", + "scripts/verify-source-scan.test.mjs", "ovens/streaming-diff/engine/streaming-diff-hook-adapters.test.mjs", "src/server/oven-bindings.test.mjs", "src/server/oven-data-store.test.mjs", @@ -122,5 +147,9 @@ export const verificationTestFiles = [ // These subprocess wall-clock assertions must not compete with the parallel // test-file pool or host saturation can look like a hook timeout regression. export const verificationSerialTestFiles = [ + "src/server/oven-event-routes.test.mjs", + "src/cli/commands-help.test.mjs", + "src/loops/completion/completion.test.mjs", + "src/loops/minimal-review-e2e.test.mjs", "src/cli/streaming-diff-cli.test.mjs", ]; diff --git a/scripts/verify.mjs b/scripts/verify.mjs index fabf3d94..3d2416e2 100755 --- a/scripts/verify.mjs +++ b/scripts/verify.mjs @@ -7,6 +7,7 @@ import "../src/ovens/built-in-handlers.mjs"; import { loadOfficialOvenCatalog } from "../src/ovens/official-oven-catalog.mjs"; import { listOvenHandlers } from "../src/ovens/oven-registry.mjs"; import { assertBuiltInOven, assertBuiltInOvenDataDocs, assertBuiltInOvenSet, assertSkillSet } from "./verify-oven-assertions.mjs"; +import { shouldScanSourceRelativePath } from "./verify-source-scan.mjs"; import { verificationSerialTestFiles, verificationTestFiles } from "./verify-test-files.mjs"; const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); @@ -87,25 +88,9 @@ const leakPatterns = [ { name: "repo-specific term", pattern: new RegExp(`\\b(?:${repoSpecificTerms.join("|")})\\b`, "iu") }, ]; -const sourceScanExcludes = [ - ".git/", - ".claude/", - ".local/", - "build/", - "dist/", - "node_modules/", - ".playwright-cli/", - "notes/burnlists/", - "output/", - "research/", - "website/node_modules/", - "website/dist/", - "website/.astro/", -]; - function shouldScanSourceFile(path) { const normalized = relative(repoRoot, path).replace(/\\/g, "/"); - return !sourceScanExcludes.some((prefix) => normalized === prefix.slice(0, -1) || normalized.startsWith(prefix)); + return shouldScanSourceRelativePath(normalized); } function assertNoLeaks(label, text) { From c10dcad3666d4e023c3a6a2b8cec14918347a40a Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Fri, 24 Jul 2026 10:14:20 +0200 Subject: [PATCH 06/37] docs: document review loop setup and recovery --- README.md | 39 ++++ skills/burnlist/SKILL.md | 64 +++++++ .../references/loop-capability-example.json | 26 +++ website/astro.config.mjs | 2 +- website/src/content/docs/cli.mdx | 25 +++ website/src/content/docs/loops.mdx | 167 ++++++++++++++++++ 6 files changed, 322 insertions(+), 1 deletion(-) create mode 100644 skills/burnlist/references/loop-capability-example.json create mode 100644 website/src/content/docs/loops.mdx diff --git a/README.md b/README.md index 8d9f7770..fc4008ef 100644 --- a/README.md +++ b/README.md @@ -148,6 +148,45 @@ 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. +## Review Loop (Stage 1) + +The built-in `review` Loop is an optional, serial foreground workflow for an +assigned item: + +```text +maker -> repository check -> fresh reviewer -> convergence gate -> CLI completion + ^ | + +------------ check failure / rejection +``` + +Configure a write-authority maker and read-authority reviewer with `burnlist +agent profile add`, map them with `burnlist route set`, inspect and explicitly +trust the repository check with `burnlist loop capability inspect|trust`, then +assign an item with `burnlist loop assign`. 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`, run +or resume it in the foreground with `loop run|resume`, and inspect it with +`loop status|inspect`. `pause` and `stop` are idle-Run recovery controls: they +require no active foreground owner. For a live foreground Run, Ctrl-C belongs +to that owner (first interrupt requests pause; second requests controlled +stop). Proof-gated `reconcile` is for a demonstrably lost owner. Only a +converged Run can be applied by `loop complete`; the +command is idempotent and performs the normal shrinking-list completion. + +The Checklist UI is read-only and shows the active node, attempt, results, +transition history, and paused, error, or terminal state. The runner enforces +the graph, fresh reviewer process, budgets, closed outcomes, and atomic CLI +writes. Reviewer filesystem write denial is **supervised**, not an OS sandbox. +Parallel execution, Docker isolation, metrics gates, custom adapters, +forecasting, worktrees, and background execution are deliberately unsupported +in Stage 1. 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/skills/burnlist/SKILL.md b/skills/burnlist/SKILL.md index da1b9698..00372043 100644 --- a/skills/burnlist/SKILL.md +++ b/skills/burnlist/SKILL.md @@ -151,3 +151,67 @@ Burnlist has two independent installable systems. Either or both may be present: - **Streaming Diff hooks** (`burnlist hooks install`) install per-repository edit-capture commands, not skills. Codex consumes `/.codex/hooks.json`; Claude Code consumes `/.claude/settings.json`. They invoke `burnlist streaming-diff hook` for session/edit events and merge with existing hook entries. Hooks have no global mode: use `burnlist hooks uninstall` or `burnlist hooks status` in the repository, optionally with `--agent codex,claude`. `--untracked` asks install to add the config to `.git/info/exclude`; it cannot hide an already tracked config. Install only the system the task needs, or both. Read `references/installation.md` for exact commands, ownership, and shared-versus-local behavior. + +## Built-in Review Loop (Stage 1) + +Use the built-in `loop:builtin:review` only when a user or active Burnlist +assigns an item to it. It is one serial foreground path: maker, trusted +repository check, fresh reviewer, convergence gate, then CLI-owned completion. +Items without an assignment keep the direct Burnlist workflow. + +Before creating a Run, configure distinct local profiles and routes, inspect +and explicitly trust the repository check capability, then assign the item: + +The installed skill includes `references/loop-capability-example.json`. Copy +its `catalog` object to the repository capability catalog and its `grants` +object to a private local grants path; the grants file can only narrow the +catalog. These are the copyable schemas, replacing +the absolute check command and paths with your repository's reviewed command: + +```json +{"schema":"burnlist-loop-capabilities@1","capabilities":[{"id":"repo-verify","argv":["/absolute/path/to/repository-check","--verify"],"cwd":".","environment":{"inherit":["PATH"],"set":{}},"network":"deny","filesystem":{"read":["src"],"write":[]},"output":{"maxBytes":65536},"maxMilliseconds":60000}]} +``` + +```json +{"argv":["/absolute/path/to/repository-check","--verify"],"cwd":".","environment":{"inherit":["PATH"],"set":{}},"network":"deny","filesystem":{"read":["src"],"write":[]},"output":{"maxBytes":65536},"maxMilliseconds":60000} +``` + +Accepted profile models are `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna`, +and `gpt-5.3-codex-spark`; efforts are `minimal`, `low`, `medium`, `high`, +`xhigh`, and `max`. + +```sh +burnlist agent profile add maker --adapter builtin:codex-cli --binary --model --effort --authority write +burnlist agent profile add reviewer --adapter builtin:codex-cli --binary --model --effort --authority read +burnlist route set implementation.standard --profile maker +burnlist route set review.strong --profile reviewer +burnlist loop capability inspect repo-verify +burnlist loop capability trust repo-verify --revision cp1-sha256: --grants +burnlist loop assign item:# loop:builtin:review +``` + +Run `burnlist loop setup status` before `loop create`. Paste the complete +`burnlist loop view item:#` ASCII output into the task +handoff or review request: it records the frozen graph, retries, completion +path, and pins. Control a Run only with `loop run|pause|resume|stop`, +inspect with `loop status|inspect`, use proof-gated `loop reconcile` only for a +demonstrably lost foreground owner, and apply a converged Run through the +idempotent `loop complete` command. A valid reconcile proof terminalizes as +`needs-human`; it never pauses a Run. + +Standalone `pause` and `stop` only work while no foreground owner holds the +Run lease. For live foreground work, the owner handles Ctrl-C: first interrupt +requests pause after its child exits, second requests controlled stop. Do not +claim that a separate CLI invocation can take over a live Run. + +Stage 1 labels fresh reviewer process, graph grammar, budgets, closed outcomes, +and atomic canonical CLI writes `enforced`; ordinary drift checks are +`detected-at-boundaries`; reviewer filesystem write denial is `supervised`. +It is not Docker or an OS sandbox. Parallelism, nested agents, metric gates, +custom adapters, worktrees, background execution, Docker isolation, and +forecasting are `unsupported`. The Checklist UI is read-only and displays +active, paused, error, and terminal Run states; it never controls a Run. + +`burnlist install` registers this skill, while `burnlist hooks install` adds +Streaming Diff edit-capture hooks. These integrations are independent and do +not configure or start a Review Loop. diff --git a/skills/burnlist/references/loop-capability-example.json b/skills/burnlist/references/loop-capability-example.json new file mode 100644 index 00000000..06ba7d01 --- /dev/null +++ b/skills/burnlist/references/loop-capability-example.json @@ -0,0 +1,26 @@ +{ + "catalog": { + "schema": "burnlist-loop-capabilities@1", + "capabilities": [ + { + "id": "repo-verify", + "argv": ["/absolute/path/to/repository-check", "--verify"], + "cwd": ".", + "environment": { "inherit": ["PATH"], "set": {} }, + "network": "deny", + "filesystem": { "read": ["src"], "write": [] }, + "output": { "maxBytes": 65536 }, + "maxMilliseconds": 60000 + } + ] + }, + "grants": { + "argv": ["/absolute/path/to/repository-check", "--verify"], + "cwd": ".", + "environment": { "inherit": ["PATH"], "set": {} }, + "network": "deny", + "filesystem": { "read": ["src"], "write": [] }, + "output": { "maxBytes": 65536 }, + "maxMilliseconds": 60000 + } +} diff --git a/website/astro.config.mjs b/website/astro.config.mjs index a9290bd4..13e0854f 100644 --- a/website/astro.config.mjs +++ b/website/astro.config.mjs @@ -45,7 +45,7 @@ export default defineConfig({ }, { label: 'Reference', - items: [{ slug: 'cli' }, { slug: 'dashboard' }], + items: [{ slug: 'cli' }, { slug: 'loops' }, { slug: 'dashboard' }], }, { label: 'Ovens', diff --git a/website/src/content/docs/cli.mdx b/website/src/content/docs/cli.mdx index 896b1a47..c96d03a0 100644 --- a/website/src/content/docs/cli.mdx +++ b/website/src/content/docs/cli.mdx @@ -37,6 +37,31 @@ These commands act on the current repository by default, or on an explicit `--re | `burnlist burn [--check] [--repo ]` | Complete a Burnlist item; `--check` validates it. | | `burnlist close [--repo ]` | Close a completed Burnlist. | +## Review Loop + +```sh +burnlist loop assign [--repo ] +burnlist loop unassign [--repo ] +burnlist loop view [--repo ] +burnlist loop create [--repo ] +burnlist loop run|pause|resume|stop|complete [--repo ] +burnlist loop list [--repo ] +burnlist loop status|inspect [--repo ] +burnlist loop reconcile --recovery-proof [--repo ] +burnlist loop capability inspect [--repo ] +burnlist loop capability trust --revision cp1-sha256: --grants [--repo ] +burnlist loop setup status [--repo ] +burnlist agent profile add --adapter builtin:codex-cli --binary --model --effort --authority read|write [--repo ] +burnlist agent doctor [--repo ] +burnlist route set --profile [--repo ] +``` + +The Review Loop is an optional serial foreground maker → check → fresh reviewer +workflow. `loop view` is deterministic ASCII graph output; `create`, control, +and read commands use Run references. Only `loop complete` applies a converged +Run to the Burnlist. See [Review Loop](/loops) for setup, recovery, visible UI +states, guarantee labels, and deferred features. + ## Registry ```sh diff --git a/website/src/content/docs/loops.mdx b/website/src/content/docs/loops.mdx new file mode 100644 index 00000000..5b30169c --- /dev/null +++ b/website/src/content/docs/loops.mdx @@ -0,0 +1,167 @@ +--- +title: Review Loop +description: Configure and run the built-in serial maker, check, and reviewer Loop. +--- + +The built-in `loop:builtin:review` is an optional serial, foreground workflow: + +```text +Item -> maker -> check -> fresh reviewer -> gate -> CLI completion + ^ | + +-- fail/reject +``` + +It does not replace normal Burnlist execution. An unassigned item uses the +direct workflow, including `burnlist burn [--check]`; an assigned +item is completed only through its converged Loop Run. + +## Configure and assign + +Create one profile for each role, then attach the fixed routes. The maker has +write authority; the reviewer has read authority. `--binary` must be an +absolute path to the installed Codex CLI. Accepted `--model` values are +`gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna`, and +`gpt-5.3-codex-spark`; accepted `--effort` values are `minimal`, `low`, +`medium`, `high`, `xhigh`, and `max`. + +```sh +burnlist agent profile add maker --adapter builtin:codex-cli --binary /absolute/path/to/codex --model --effort --authority write +burnlist agent profile add reviewer --adapter builtin:codex-cli --binary /absolute/path/to/codex --model --effort --authority read +burnlist route set implementation.standard --profile maker +burnlist route set review.strong --profile reviewer +burnlist agent doctor maker +burnlist agent doctor reviewer +``` + +The check is a repository-owned declarative capability, not a command in the +Loop package. Inspect it, review the grants JSON against that capability, then +trust the exact revision: + +First copy the catalog and grants objects below to `.burnlist/loop-capabilities.json` +and a private local grants path. Replace the absolute command +path and narrow the read paths to the check your repository actually needs. +The grants file deliberately repeats every field except `id` and may only +narrow this policy. + +```json title=".burnlist/loop-capabilities.json" +{ + "schema": "burnlist-loop-capabilities@1", + "capabilities": [{ + "id": "repo-verify", + "argv": ["/absolute/path/to/repository-check", "--verify"], + "cwd": ".", + "environment": { "inherit": ["PATH"], "set": {} }, + "network": "deny", + "filesystem": { "read": ["src"], "write": [] }, + "output": { "maxBytes": 65536 }, + "maxMilliseconds": 60000 + }] +} +``` + +```json title="grants.json" +{ + "argv": ["/absolute/path/to/repository-check", "--verify"], + "cwd": ".", + "environment": { "inherit": ["PATH"], "set": {} }, + "network": "deny", + "filesystem": { "read": ["src"], "write": [] }, + "output": { "maxBytes": 65536 }, + "maxMilliseconds": 60000 +} +``` + +```sh +burnlist loop capability inspect repo-verify +burnlist loop capability trust repo-verify --revision cp1-sha256: --grants /absolute/path/to/grants.json +burnlist loop setup status +burnlist loop assign item:# loop:builtin:review +``` + +`setup status` reports missing configuration rather than guessing it. An +assignment freezes the selected Loop authority for that item. View it and paste +the full output into the handoff or review request: + +```sh +burnlist loop view item:# +``` + +The ASCII view includes the decorative drawing, authoritative adjacency, pins, +retry limits, and completion path. It is the source of truth for the assigned +graph; do not redraw or abbreviate it. + +## Run and control + +Create a Run from the assigned item, then execute it in the foreground. These +commands return machine-readable status or projection JSON except `view`. + +```sh +burnlist loop create item:# +burnlist loop run run: +burnlist loop status run: +burnlist loop inspect run: +``` + +The maker receives the item and instructions, then the trusted repository +check runs. A new reviewer process evaluates the candidate. A check failure or +reviewer rejection returns to the maker within the declared budget; approval +passes the convergence gate. Escalation becomes `needs-human`; errors, +timeouts, cancellation, and budget exhaustion use their recorded terminal +outcomes. + +`Ctrl-C` requests a pause after the child exits; a second interrupt requests a +controlled stop. The standalone `pause` and `stop` commands require an idle +Run with no active foreground owner; they cannot take over a live Run. Use the +foreground owner's interrupts for a live Run, then use the standalone commands +only for recovery while it is idle: + +```sh +burnlist loop pause run: +burnlist loop resume run: +burnlist loop stop run: +burnlist loop reconcile run: --recovery-proof <64-lowercase-hex> +``` + +`reconcile` is for a demonstrably lost foreground owner. It folds the frozen +journal and never re-resolves setup, recompiles the Loop, or reruns work. +With a valid recovery proof it terminalizes the Run as `needs-human`; it never +changes a Run to paused. +Terminal stopped Runs can be replaced with a new `loop create`; executable +Runs cannot. + +## Completion and visibility + +Only a converged Run can request item completion: + +```sh +burnlist loop complete run: +``` + +The CLI verifies the still-current assignment and Run, updates the shrinking +checklist and ledger under its lifecycle lock, and records an idempotent +receipt. Repeating `complete` returns the existing receipt without burning the +item twice. + +The Checklist page is read-only. For a selected Loop item it exposes the graph +with the current node, attempt and cycle, latest result summary, +ordered transitions, and paused, error, and terminal states. It has no Run or +completion controls. + +## Guarantees and limits + +Stage 1 labels graph grammar, fresh reviewer process, budgets, closed outcomes, +atomic canonical CLI writes, and the dashboard read-only boundary as +`enforced`. Ordinary configuration and path drift checks are +`detected-at-boundaries`. Reviewer filesystem write denial and host +instructions are `supervised`; they are not Docker or an OS sandbox. + +Parallelism, nested agents, metric gates, custom adapters, auto-worktrees, +background execution, Docker isolation, and forecasting are `unsupported`. +Do not present any of those as shipped Loop features. + +## Skills and hooks are separate + +`burnlist install` makes the Burnlist skill discoverable. `burnlist hooks +install` installs optional per-repository Streaming Diff edit-capture hooks. +They are independent: either, both, or neither can be installed, and neither +configures or starts a Review Loop. See [Install](/install). From 75912df7dd335849f814e1e7a11df523093d1f92 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Fri, 24 Jul 2026 17:50:18 +0200 Subject: [PATCH 07/37] feat: render item-scoped loops in checklist oven --- dashboard/src/App.tsx | 14 +- .../ChecklistDashboard/ChecklistDashboard.css | 9 +- .../ChecklistDashboard.test.mjs | 52 +-- .../ChecklistDashboard/ChecklistDashboard.tsx | 54 +--- .../ChecklistDashboard/ChecklistOvenView.tsx | 3 +- .../checklist-adapter.test.mjs | 1 + .../checklist-dom-golden.test.mjs | 14 +- .../checklist-dom.golden.html | 2 +- .../checklist-loop-progression.golden.json | 40 ++- .../checklist-loop-states.golden.json | 48 ++- .../CustomOvenView/CustomOvenView.tsx | 2 +- .../custom-oven-render.test.mjs | 12 +- .../DashboardError/DashboardError.css | 15 + .../DashboardError/DashboardError.tsx | 4 +- .../src/components/LoopGraph/LoopCompact.tsx | 95 ++++++ .../src/components/LoopGraph/LoopGraph.css | 117 +++++++ .../LoopGraph/LoopGraph.stories.tsx | 203 ++++++++++++ .../components/LoopGraph/LoopGraph.test.ts | 74 +++++ .../src/components/LoopGraph/LoopGraph.tsx | 124 ++++++++ .../src/components/LoopGraph/LoopLegend.tsx | 31 ++ .../components/LoopGraph/ascii-layout.test.ts | 37 +++ .../src/components/LoopGraph/ascii-layout.ts | 296 ++++++++++++++++++ .../components/LoopGraph/compact-layout.ts | 226 +++++++++++++ dashboard/src/components/LoopGraph/index.ts | 6 + .../src/components/LoopGraph/loop-symbols.ts | 49 +++ dashboard/src/hooks/dashboard-data.mjs | 3 +- dashboard/src/hooks/dashboard-data.test.mjs | 5 +- dashboard/src/hooks/useDashboardData.ts | 4 +- dashboard/src/lib/checklist-adapter.ts | 5 + dashboard/src/lib/hrefs.ts | 20 +- dashboard/src/lib/types.ts | 34 +- .../ChecklistCurrent/ChecklistCurrent.css | 79 +++++ .../ChecklistCurrent/ChecklistCurrent.test.ts | 41 +++ .../ChecklistCurrent/ChecklistCurrent.tsx | 43 +++ dashboard/src/oven/ChecklistCurrent/index.ts | 1 + .../ChecklistEventCards.tsx | 4 +- .../ChecklistWidgets/ChecklistWidgets.test.ts | 5 +- .../ChecklistWorkspace/ChecklistWorkspace.css | 110 +++++++ .../ChecklistWorkspace/ChecklistWorkspace.tsx | 106 +++++++ .../src/oven/ChecklistWorkspace/index.ts | 1 + dashboard/src/oven/runtime/OvenNode.test.ts | 20 ++ dashboard/src/oven/runtime/OvenNode.tsx | 18 +- dashboard/src/oven/runtime/OvenRuntime.tsx | 6 +- dashboard/src/oven/runtime/theme-registry.ts | 2 +- .../src/oven/runtime/widget-adapters.tsx | 2 + .../src/oven/test-support/run-oven-tests.mjs | 6 +- ovens/checklist/checklist.oven | 1 + scripts/package-paths.json | 6 +- scripts/verify-source-scan.mjs | 1 + scripts/verify-source-scan.test.mjs | 1 + skills/burnlist/SKILL.md | 13 + skills/burnlist/references/creating-ovens.md | 3 +- .../minimal-review-e2e-dom.golden.json | 30 +- src/loops/minimal-review-e2e.test.mjs | 11 +- src/loops/run/binder.test.mjs | 7 + src/loops/run/read-projection.mjs | 40 ++- src/loops/run/run-store.mjs | 13 +- src/ovens/dsl/oven-compile.test.mjs | 11 + src/ovens/dsl/oven-grammar.mjs | 18 +- src/ovens/dsl/oven-validate.mjs | 2 +- src/server/burnlist-dashboard-server.mjs | 45 ++- src/server/run-routes.test.mjs | 12 +- website/src/content/docs/loops.mdx | 43 ++- .../src/content/docs/ovens/dsl-reference.mdx | 16 + 64 files changed, 2139 insertions(+), 177 deletions(-) create mode 100644 dashboard/src/components/LoopGraph/LoopCompact.tsx create mode 100644 dashboard/src/components/LoopGraph/LoopGraph.css create mode 100644 dashboard/src/components/LoopGraph/LoopGraph.stories.tsx create mode 100644 dashboard/src/components/LoopGraph/LoopGraph.test.ts create mode 100644 dashboard/src/components/LoopGraph/LoopGraph.tsx create mode 100644 dashboard/src/components/LoopGraph/LoopLegend.tsx create mode 100644 dashboard/src/components/LoopGraph/ascii-layout.test.ts create mode 100644 dashboard/src/components/LoopGraph/ascii-layout.ts create mode 100644 dashboard/src/components/LoopGraph/compact-layout.ts create mode 100644 dashboard/src/components/LoopGraph/index.ts create mode 100644 dashboard/src/components/LoopGraph/loop-symbols.ts create mode 100644 dashboard/src/oven/ChecklistCurrent/ChecklistCurrent.css create mode 100644 dashboard/src/oven/ChecklistCurrent/ChecklistCurrent.test.ts create mode 100644 dashboard/src/oven/ChecklistCurrent/ChecklistCurrent.tsx create mode 100644 dashboard/src/oven/ChecklistCurrent/index.ts create mode 100644 dashboard/src/oven/ChecklistWorkspace/ChecklistWorkspace.css create mode 100644 dashboard/src/oven/ChecklistWorkspace/ChecklistWorkspace.tsx create mode 100644 dashboard/src/oven/ChecklistWorkspace/index.ts 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..b094dd65 100644 --- a/dashboard/src/components/ChecklistDashboard/ChecklistDashboard.css +++ b/dashboard/src/components/ChecklistDashboard/ChecklistDashboard.css @@ -15,14 +15,16 @@ 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 .checklist-progress-workspace { width: 100%; height: 232px; min-height: 232px; max-height: 232px; flex: 0 0 232px; - grid-template-columns: 30% minmax(0, 70%); + grid-template-columns: minmax(220px, 26%) minmax(360px, 48%) minmax(220px, 26%); +} +.checklist-detail-shell #burnlist-detail .checklist-progress-workspace:not(:has(.checklist-current)) { + grid-template-columns: minmax(220px, 30%) minmax(360px, 70%); } /* The shared Differential Testing shell defaults its top workspace to 310px. */ @@ -43,7 +45,8 @@ body.checklist-detail-view .dashboard-oven-title { } .checklist-detail-shell .checklist-progress-workspace .event-ledger-panel, -.checklist-detail-shell .checklist-progress-workspace .progress-panel { +.checklist-detail-shell .checklist-progress-workspace .progress-panel, +.checklist-detail-shell .checklist-progress-workspace .checklist-current { height: 232px; min-height: 232px; max-height: 232px; diff --git a/dashboard/src/components/ChecklistDashboard/ChecklistDashboard.test.mjs b/dashboard/src/components/ChecklistDashboard/ChecklistDashboard.test.mjs index a588c14e..612c837a 100644 --- a/dashboard/src/components/ChecklistDashboard/ChecklistDashboard.test.mjs +++ b/dashboard/src/components/ChecklistDashboard/ChecklistDashboard.test.mjs @@ -28,13 +28,12 @@ test("checklist detail renders the split progress surface and event card list", entryPoints: [componentPath], bundle: true, format: "esm", outfile: outputPath, platform: "node", alias: { "@lib": libPath, "@oven": ovenPath }, jsx: "automatic", packages: "external", target: "node18", }); - const { ChecklistDashboard, LoopRunPanel, 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); @@ -46,27 +45,13 @@ 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="Completed events"/u); + assert.match(markup, /aria-label="Remaining items"/u); + assert.match(markup, /class="checklist-workspace__empty">No active item/u); + assert.equal((markup.match(/class="checklist-workspace__event"/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, /data-event-card="true"/u); assert.doesNotMatch(markup, /Completed: 2026/u); assert.doesNotMatch(markup, />DONE]*>Changes<\/button>/u); @@ -79,14 +64,13 @@ test("checklist detail renders the split progress surface and event card list", }); for (const projection of snapshots) { const stage = renderToStaticMarkup(createElement(LoopRunPanel, { data: { ...data, loopRun: projection } })); - assert.match(stage, new RegExp(`Current ${projection.currentNode} · attempt ${projection.attempt} · cycle ${projection.cycle}`, "u")); - assert.match(stage, /aria-label="Loop graph edges"/u); - assert.match(stage, /implement<\/strong> —complete→<\/span> verify<\/strong>/u); + assert.match(stage, new RegExp(`ACTIVE: ${projection.currentNode.toUpperCase()}`, "u")); + assert.match(stage, /IMPLEMENT/u); + assert.match(stage, /VERIFY/u); assert.match(stage, /aria-current="step"/u); - if (projection.latestResult) assert.match(stage, new RegExp(`Latest ${projection.latestResult.kind} · ${projection.latestResult.summary}`, "u")); - if (projection.currentNode === "implement" && projection.attempt === 2) assert.match(stage, /review —reject→<\/span> implement/u); - if (projection.currentNode === "converged") assert.match(stage, /review —approve→<\/span> converged/u); - if (projection.currentNode === "completed") assert.match(stage, /converged —pass→<\/span> completed/u); + if (projection.currentNode === "implement" && projection.attempt === 2) assert.match(stage, /reject/u); + if (projection.currentNode === "converged") assert.match(stage, /approve/u); + if (projection.currentNode === "completed") assert.match(stage, /COMPLETED/u); } const evidence = renderToStaticMarkup(createElement(LoopRunPanel, { data: { ...data, @@ -97,11 +81,8 @@ test("checklist detail renders the split progress surface and event card list", latestReviewer: { summary: "approved", at: Date.parse("2026-07-15T11:50:00Z"), candidateId: "candidate-1" }, }, } })); - assert.match(evidence, /aria-label="Latest role evidence"/u); - assert.match(evidence, /
Maker<\/dt>
candidate prepared/u); - assert.match(evidence, /
Check<\/dt>
verify passed/u); - assert.match(evidence, /
Reviewer<\/dt>
approved/u); - assert.match(evidence, /candidate candidate-1/u); + 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 }); @@ -121,7 +102,6 @@ test("Loop panel exposes every terminal and observer diagnostic state accessibly 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")); - assert.match(markup, /aria-label="Loop budget"/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 27ca8de1..4a95ca0d 100644 --- a/dashboard/src/components/ChecklistDashboard/ChecklistDashboard.tsx +++ b/dashboard/src/components/ChecklistDashboard/ChecklistDashboard.tsx @@ -5,6 +5,9 @@ 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 { ChecklistCurrent } from "@/oven/ChecklistCurrent"; +import { ChecklistWorkspace } from "@/oven/ChecklistWorkspace"; function ChecklistKpis({ data }: { data: ChecklistProgressData }) { const durations = timing(data); @@ -95,49 +98,12 @@ export function EventCardList({ data }: { data: ChecklistProgressData }) { } export function LoopRunPanel({ data }: { data: ChecklistProgressData }) { - const run = data.loopRun; - if (!run) return data.loopProjectionDiagnostic ?
-
Loop Run
Corrupt projection
-

{data.loopProjectionMessage || "The Loop projection is corrupt. Progress remains available while the dashboard waits for a verified projection."}

-
: null; - const stateLabel: Record = { - paused: "Paused", failed: "Failed", stopped: "Stopped", "needs-human": "Needs human review", - "budget-exhausted": "Budget exhausted", corrupt: "Corrupt projection", stale: "Stale projection", converged: "Converged", completed: "Completed", - }; - const state = run.diagnostic === "corrupt" ? "Corrupt projection" : run.diagnostic === "stale" ? "Stale projection" : stateLabel[run.state] ?? run.state; - const budget = run.budget; - const nodeById = new Map(run.graph.nodes.map((node) => [node.id, node])); - const orderedEdges = [...run.graph.edges].sort((left, right) => { - const stage = (edge: typeof left) => edge.from === "implement" ? 0 : edge.from === "verify" ? 1 : edge.from === "review" ? 2 : edge.from === "converged" ? 3 : 4; - return stage(left) - stage(right) || left.from.localeCompare(right.from) || left.on.localeCompare(right.on); - }); - const roleResults = [ - ["Maker", run.latestMaker], ["Check", run.latestCheck], ["Reviewer", run.latestReviewer], - ] as const; - return
-
Loop Run
- {state}
-

{run.loopId}{run.loopRevision && {run.loopRevision}}

-
Current {run.currentNode} · attempt {run.attempt} · cycle {run.cycle}
-
-
Elapsed
{formatDuration(budget.elapsedMilliseconds)}
-
Rounds
{budget.counters.rounds}/{budget.limits.maxRounds}
-
Agent runs
{budget.counters.agentRuns}/{budget.limits.maxAgentRuns}
-
Checks
{budget.counters.checkRuns}/{budget.limits.maxCheckRuns}
-
Transitions
{budget.counters.transitions}/{budget.limits.maxTransitions}
-
-
    {run.graph.nodes.map((node) => -
  1. - {node.id}{node.kind} -
  2. )}
-
    {orderedEdges.map((edge) => -
  1. {nodeById.get(edge.from)?.id ?? edge.from} —{edge.on}→ {nodeById.get(edge.to)?.id ?? edge.to}
  2. )}
- {roleResults.some(([, result]) => result) &&
{roleResults.map(([role, result]) => result && -
{role}
{result.summary} {result.candidateId && candidate {result.candidateId}}
)}
} - {run.latestResult &&

Latest {run.latestResult.kind} · {run.latestResult.summary}

} -
    {run.transitions.map((transition) => -
  1. {transition.from} —{transition.outcome}→ {transition.to}
  2. )}
-
; + return ; } export function ChecklistDashboard({ data }: { data: ChecklistProgressData }) { @@ -145,5 +111,5 @@ export function ChecklistDashboard({ data }: { data: ChecklistProgressData }) { 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/ChecklistOvenView.tsx b/dashboard/src/components/ChecklistDashboard/ChecklistOvenView.tsx index 9c518aac..65e8dfb1 100644 --- a/dashboard/src/components/ChecklistDashboard/ChecklistOvenView.tsx +++ b/dashboard/src/components/ChecklistDashboard/ChecklistOvenView.tsx @@ -3,7 +3,6 @@ import type { ResolvedOvenIr } from "@hooks"; import type { ChecklistProgressData } from "@lib"; import { adaptChecklist } from "@lib/checklist-adapter"; import { OvenRuntime } from "@/oven/runtime/OvenRuntime"; -import { LoopRunPanel } from "./ChecklistDashboard"; import "./ChecklistDashboard.css"; export function ChecklistOvenView({ data, ir }: { data: ChecklistProgressData; ir: ResolvedOvenIr }) { @@ -12,5 +11,5 @@ export function ChecklistOvenView({ data, ir }: { data: ChecklistProgressData; i 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 e6050477..c2451c9d 100644 --- a/dashboard/src/components/ChecklistDashboard/checklist-dom-golden.test.mjs +++ b/dashboard/src/components/ChecklistDashboard/checklist-dom-golden.test.mjs @@ -18,6 +18,16 @@ 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-")); @@ -70,7 +80,7 @@ test("real M4 projections advance the full Checklist DOM through the frozen Loop const actual = selected.map((projection) => { const projectionBytes = JSON.stringify({ ...projection, revision: "" }); const markup = withDeterministicTime(() => - renderToStaticMarkup(createElement(ChecklistDashboard, { data: { ...checklistFixture, loopRun: projection } }))); + renderToStaticMarkup(createElement(ChecklistDashboard, { data: itemData(projection) }))); const domBytes = serializeCanonical(normalize(parseHtml(markup))); return { checkpoint: `${projection.currentNode}/${projection.attempt}/${projection.latestResult?.kind ?? "none"}`, @@ -112,7 +122,7 @@ test("terminal, paused, stale, and corrupt Loop states retain frozen full Checkl ]; const actual = variants.map(([checkpoint, patch]) => { const markup = withDeterministicTime(() => renderToStaticMarkup(createElement(ChecklistDashboard, { - data: { ...checklistFixture, loopRun: { ...final, ...patch } }, + data: itemData({ ...final, ...patch }), }))); const domBytes = serializeCanonical(normalize(parseHtml(markup))); return { checkpoint, domBytes: Buffer.byteLength(domBytes), domSha256: digest(domBytes) }; diff --git a/dashboard/src/components/ChecklistDashboard/checklist-dom.golden.html b/dashboard/src/components/ChecklistDashboard/checklist-dom.golden.html index f381bfee..ae9fd828 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
Events2
B2Second event10m · 100%
B1First event20m · 50%
Items0

No active item

diff --git a/dashboard/src/components/ChecklistDashboard/checklist-loop-progression.golden.json b/dashboard/src/components/ChecklistDashboard/checklist-loop-progression.golden.json index 2729a30a..2b0d3d77 100644 --- a/dashboard/src/components/ChecklistDashboard/checklist-loop-progression.golden.json +++ b/dashboard/src/components/ChecklistDashboard/checklist-loop-progression.golden.json @@ -1,7 +1,37 @@ [ - { "checkpoint": "implement/1/reject", "projectionBytes": 1855, "projectionSha256": "bb919c8828f97eb63e5c6e29b2eb73736e3a24e476ea13190d39ac4fc329fd3c", "domBytes": 12119, "domSha256": "70d9d519964b9f9b292bf02bed768355e95eb126b6ba254cd7562ba3825855ab" }, - { "checkpoint": "implement/2/reject", "projectionBytes": 1855, "projectionSha256": "9c623f1ccf612c9921f93c031ac4c7757892d2eda3feb46c34dfbd6347957b94", "domBytes": 12119, "domSha256": "cd341ac15a227617969bdb3e66cd9d23fef58075f6c31e26b35b78efdae49270" }, - { "checkpoint": "review/2/approve", "projectionBytes": 1990, "projectionSha256": "318079e10fef419d1985aa704b8c5bb77791161a57b6771dc6d5cde5e005ddea", "domBytes": 12214, "domSha256": "beb873ef0164fe58e3b635a3082dcebb2f191882ea8992b6aa71c32283154683" }, - { "checkpoint": "converged/1/approve", "projectionBytes": 2062, "projectionSha256": "1a2c67ec6c1645b46c690d0531a32b68b679191531f6fa6a5cf333f66ffcf967", "domBytes": 12267, "domSha256": "7427a07760115ac129912c66dcb03fd619051b7fa5d69e08f352a1c99a9251a5" }, - { "checkpoint": "completed/1/approve", "projectionBytes": 2133, "projectionSha256": "c48da3b51874c7daba7669a4ded690445d89b9eb1b7b311f061b60472f54cc07", "domBytes": 12321, "domSha256": "1ff2c2747b11031e8c3d89fbf9ad15d87bfe5f292c57161123685fd62ca0f83b" } + { + "checkpoint": "implement/1/reject", + "projectionBytes": 2157, + "projectionSha256": "6e8e57daad385abd423cc1849253359d322d7253db5bfc788cea632c64b2cc4a", + "domBytes": 10215, + "domSha256": "133792d445f5283fbe6c5d5385cec4402740cef7b937b179c1d44f5f5db66497" + }, + { + "checkpoint": "implement/2/reject", + "projectionBytes": 2157, + "projectionSha256": "f0f0bc7fad845f804893d6b193c80926a2245b8174a6e7787eb9ded01f6069e7", + "domBytes": 10215, + "domSha256": "133792d445f5283fbe6c5d5385cec4402740cef7b937b179c1d44f5f5db66497" + }, + { + "checkpoint": "review/2/approve", + "projectionBytes": 2292, + "projectionSha256": "dfc91fde48e5a690f5d7974b1320828376d9dac73a92f96a835bceb3a3186355", + "domBytes": 10212, + "domSha256": "dad8893e2e1194cf690929501441b9182678f664a97bea9375c15fea698ec98b" + }, + { + "checkpoint": "converged/1/approve", + "projectionBytes": 2364, + "projectionSha256": "9985718fe0bda762ac030d9d282ac51682819e53dcd9947039ad2ba2a4e84a9f", + "domBytes": 10215, + "domSha256": "f27683bf4be303b13cbff2a7dbab54535076f506ed109e2e11d3952b92a0aa80" + }, + { + "checkpoint": "completed/1/approve", + "projectionBytes": 2435, + "projectionSha256": "638a6ba72885ebf7f130bb2dc31fd1d7a685eea85b5b9e0a1d7eb84c6f31c424", + "domBytes": 10215, + "domSha256": "edb3d07570bf440ec273decce27b5acb31c458247ab4193426b72546f1fd089c" + } ] diff --git a/dashboard/src/components/ChecklistDashboard/checklist-loop-states.golden.json b/dashboard/src/components/ChecklistDashboard/checklist-loop-states.golden.json index 44ed019c..7d0c7a88 100644 --- a/dashboard/src/components/ChecklistDashboard/checklist-loop-states.golden.json +++ b/dashboard/src/components/ChecklistDashboard/checklist-loop-states.golden.json @@ -1,10 +1,42 @@ [ - { "checkpoint": "paused", "domBytes": 12171, "domSha256": "46388c6ac8e544fbf4b511f0178c2d0adfbc8cc41c6a37375cce7e7b0f4b20c8" }, - { "checkpoint": "failed", "domBytes": 12171, "domSha256": "de66441d38526255a307f54d4eead2c5d0e0d3d2f58d70a0a00e0f8614ee26b0" }, - { "checkpoint": "stopped", "domBytes": 12173, "domSha256": "e306f86ff9bbcc7294e6b9bc3df3987989f796bd947d789f4d1edddca83efa9f" }, - { "checkpoint": "needs-human", "domBytes": 12195, "domSha256": "8a25e97a8f6fee72f336ab4ecbc859964d0b4ff664e63e32efba0e57a83d31c5" }, - { "checkpoint": "exhausted", "domBytes": 12191, "domSha256": "46df3aa30b14a0086f8299895e69fad120e6564bf65b2119cac652b6ec793c10" }, - { "checkpoint": "stale", "domBytes": 12191, "domSha256": "afc4e0cebcd3a7f3bb1fad8d32ae733b9d2bc9c2a0a9910bc1ad37ff39fc82f9" }, - { "checkpoint": "corrupt", "domBytes": 12195, "domSha256": "b6540a7baf33695218d768a9183c89b13c237f677ef825888270ce80598f23aa" }, - { "checkpoint": "completed", "domBytes": 12177, "domSha256": "27ef2ca6d8357bd24166c1954220e3f7c75f60361e574f16806a72fa3b9d2ace" } + { + "checkpoint": "paused", + "domBytes": 10215, + "domSha256": "edb3d07570bf440ec273decce27b5acb31c458247ab4193426b72546f1fd089c" + }, + { + "checkpoint": "failed", + "domBytes": 10215, + "domSha256": "edb3d07570bf440ec273decce27b5acb31c458247ab4193426b72546f1fd089c" + }, + { + "checkpoint": "stopped", + "domBytes": 10215, + "domSha256": "edb3d07570bf440ec273decce27b5acb31c458247ab4193426b72546f1fd089c" + }, + { + "checkpoint": "needs-human", + "domBytes": 10215, + "domSha256": "edb3d07570bf440ec273decce27b5acb31c458247ab4193426b72546f1fd089c" + }, + { + "checkpoint": "exhausted", + "domBytes": 10215, + "domSha256": "edb3d07570bf440ec273decce27b5acb31c458247ab4193426b72546f1fd089c" + }, + { + "checkpoint": "stale", + "domBytes": 10215, + "domSha256": "edb3d07570bf440ec273decce27b5acb31c458247ab4193426b72546f1fd089c" + }, + { + "checkpoint": "corrupt", + "domBytes": 10215, + "domSha256": "edb3d07570bf440ec273decce27b5acb31c458247ab4193426b72546f1fd089c" + }, + { + "checkpoint": "completed", + "domBytes": 10215, + "domSha256": "edb3d07570bf440ec273decce27b5acb31c458247ab4193426b72546f1fd089c" + } ] diff --git a/dashboard/src/components/CustomOvenView/CustomOvenView.tsx b/dashboard/src/components/CustomOvenView/CustomOvenView.tsx index c3077328..962cbfc6 100644 --- a/dashboard/src/components/CustomOvenView/CustomOvenView.tsx +++ b/dashboard/src/components/CustomOvenView/CustomOvenView.tsx @@ -27,7 +27,7 @@ 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) => ( + `; test("custom Oven runtime modes use canonical live snapshots and controlled Burnlist data", { timeout: 20_000 }, async () => { @@ -36,7 +37,14 @@ test("custom Oven runtime modes use canonical live snapshots and controlled Burn 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,8 @@ 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); } 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 ( - +
V<\/dt>
VERIFY<\/strong> · test · repo-verify/u); + const labeled = renderToStaticMarkup(createElement(LoopCompact, { + run, labels: "outcomes", symbols: { implement: "M", verify: "T" }, + })); + assert.match(labeled, /M.*success.*T/u); + assert.match(labeled, /fail/u); +}); diff --git a/dashboard/src/components/LoopGraph/LoopGraph.tsx b/dashboard/src/components/LoopGraph/LoopGraph.tsx new file mode 100644 index 00000000..bb84f88c --- /dev/null +++ b/dashboard/src/components/LoopGraph/LoopGraph.tsx @@ -0,0 +1,124 @@ +import { useEffect, useRef, useState } from "react"; +import { layoutAsciiGraph } from "./ascii-layout"; +import "./LoopGraph.css"; + +export type LoopGraphNode = { + id: string; + kind: string; + role?: string; + authority?: "write" | "read"; + capability?: string; + gateKind?: string; + measure?: "test" | "metric" | "eval" | "boolean"; + target?: string; + terminalState?: string; + execution?: null | { + profileId: string; + model: string; + effort: string; + authority: "write" | "read"; + }; +}; +export type LoopGraphEdge = { from: string; on: string; to: string }; +export type LoopGraphTransition = { sequence: number; from: string; outcome: string; to: string }; +export type LoopGraphProjection = { + itemRef?: string; + loopId: string; + state: string; + currentNode: string; + attempt: number; + cycle: number; + graph: { entry?: string; nodes: LoopGraphNode[]; edges: LoopGraphEdge[] }; + transitions?: LoopGraphTransition[]; + latestResult?: null | { kind: string; summary: string }; + budget?: { + limits: { maxRounds: number; maxMinutes: number; maxAgentRuns: number; maxCheckRuns: number; maxTransitions: number; maxOutputBytes: number }; + counters: { rounds: number; agentRuns: number; checkRuns: number; transitions: number; outputBytes: number }; + elapsedMilliseconds: number; + journal: { maximum: number; used: number; remaining: number }; + }; + latestMaker?: null | { summary: string; at: number; candidateId: string | null }; + latestCheck?: null | { summary: string; at: number; candidateId: string | null }; + latestReviewer?: null | { summary: string; at: number; candidateId: string | null }; +}; + +export type LoopGraphProps = { + run?: LoopGraphProjection | null; + diagnostic?: "corrupt" | "stale"; + message?: string; + title?: string; +}; + +function presentationState(run: LoopGraphProjection, diagnostic?: "corrupt" | "stale") { + if (diagnostic === "corrupt") return "error"; + if (diagnostic === "stale") return "stale"; + if (["completed", "converged"].includes(run.state)) return "converged"; + if (["failed", "stopped", "needs-human", "budget-exhausted", "corrupt"].includes(run.state)) return "error"; + if (run.state === "prepared") return "prepared"; + if (run.state === "paused") return "paused"; + if (run.cycle > 0) return "repair"; + return "running"; +} + +function stateLabel(run: LoopGraphProjection, diagnostic?: "corrupt" | "stale") { + if (diagnostic === "corrupt" || run.state === "corrupt") return "Corrupt projection"; + if (diagnostic === "stale") return "Stale projection"; + const labels: Record = { + paused: "Paused", failed: "Failed", stopped: "Stopped", "needs-human": "Needs human review", + "budget-exhausted": "Budget exhausted", converged: "Converged", completed: "Completed", + }; + return labels[run.state] ?? "Running"; +} + +function GraphCanvas({ run, label, state }: { run: LoopGraphProjection; label: string; state: string }) { + const host = useRef(null); + const text = useRef(null); + const [characters, setCharacters] = useState(72); + useEffect(() => { + if (!host.current || !text.current || typeof ResizeObserver === "undefined") return; + const style = getComputedStyle(text.current); + const context = document.createElement("canvas").getContext("2d"); + if (context) context.font = `${style.fontWeight} ${style.fontSize} ${style.fontFamily}`; + const characterWidth = Math.max(1, (context?.measureText("0000000000").width ?? 90) / 10); + const observer = new ResizeObserver(([entry]) => + setCharacters(Math.max(36, Math.floor(entry.contentRect.width / characterWidth) - 2))); + observer.observe(host.current); + return () => observer.disconnect(); + }, []); + const layout = layoutAsciiGraph(run.graph, run.currentNode, characters); + const active = run.graph.nodes.find((node) => node.id === run.currentNode); + const execution = active?.execution ? `; model ${active.execution.model}; effort ${active.execution.effort}; authority ${active.execution.authority}` : ""; + const activeDetail = active?.execution + ? `${active.execution.model} · ${active.execution.effort} · ${active.execution.authority}` + : active?.kind === "check" ? `${active.measure ?? "test"} · ${active.capability ?? "deterministic"}` + : active?.kind === "gate" ? `${active.measure ?? "gate"}${active.target ? ` · ${active.target}` : ""}` + : active?.kind ?? ""; + return
+
ACTIVE: {run.currentNode.toUpperCase()}{activeDetail ? ` · ${activeDetail}` : ""}
+
+    {layout.lines.map((value, row) => {
+      const current = layout.current;
+      if (!current || row < current.y || row > current.y + 2)
+        return {value}{"\n"};
+      return 
+        {value.slice(0, current.x)}{value.slice(current.x, current.x + current.width)}{value.slice(current.x + current.width)}{"\n"}
+      ;
+    })}
+    
+
; +} + +export function LoopGraph({ run, diagnostic, message, title = "Loop Run" }: LoopGraphProps) { + if (!run) { + if (!diagnostic) return null; + const diagnosticLabel = diagnostic === "corrupt" ? "Corrupt projection" : "Stale projection"; + return
+      {`┌─ LOOP UNAVAILABLE ─┐\n│ ${diagnosticLabel}: ${message ?? "Projection unavailable."}\n└────────────────────┘`}
+    
; + } + + const viewState = presentationState(run, diagnostic); + return
+ +
; +} diff --git a/dashboard/src/components/LoopGraph/LoopLegend.tsx b/dashboard/src/components/LoopGraph/LoopLegend.tsx new file mode 100644 index 00000000..b8ffa18c --- /dev/null +++ b/dashboard/src/components/LoopGraph/LoopLegend.tsx @@ -0,0 +1,31 @@ +import type { LoopGraphProjection } from "./LoopGraph"; +import { loopSymbols } from "./loop-symbols"; +import "./LoopGraph.css"; + +export type LoopLegendProps = { + run?: LoopGraphProjection | null; + title?: string; + symbols?: Record; +}; + +function description(node: LoopGraphProjection["graph"]["nodes"][number]) { + if (node.id === "start") return "item input"; + if (node.execution) { + return `${node.execution.model} · ${node.execution.effort} · ${node.execution.authority}`; + } + if (node.kind === "check") return `${node.measure ?? "test"} · ${node.capability ?? "deterministic"}`; + if (node.kind === "gate") return `${node.measure ?? "gate"}${node.target ? ` · ${node.target}` : ""}`; + if (node.kind === "terminal") return node.terminalState === "converged" ? "burn output" : "output"; + return node.role ?? node.kind; +} + +export function LoopLegend({ run, title = "Loop legend", symbols: overrides }: LoopLegendProps) { + if (!run) return null; + const symbols = loopSymbols(run.graph.nodes, overrides); + return
+ {run.graph.nodes.map((node) =>
+
{symbols.get(node.id)}
+
{node.id.toUpperCase()} · {description(node)}
+
)} +
; +} diff --git a/dashboard/src/components/LoopGraph/ascii-layout.test.ts b/dashboard/src/components/LoopGraph/ascii-layout.test.ts new file mode 100644 index 00000000..c216a804 --- /dev/null +++ b/dashboard/src/components/LoopGraph/ascii-layout.test.ts @@ -0,0 +1,37 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { layoutAsciiGraph } from "./ascii-layout"; + +const graph = { + entry: "implement", + nodes: [ + { id: "implement", kind: "agent", role: "implementer", authority: "write" as const, + execution: { model: "gpt-5.3-codex-spark", effort: "low", authority: "write" as const } }, + { id: "verify", kind: "check", measure: "test" as const, capability: "repo-verify" }, + { id: "review", kind: "agent", role: "reviewer", authority: "read" as const }, + { id: "converged", kind: "gate", measure: "eval" as const, target: "approved" }, + { id: "completed", kind: "terminal" }, { id: "needs-human", kind: "terminal" }, + ], + edges: [ + { from: "implement", on: "complete", to: "verify" }, + { from: "verify", on: "pass", to: "review" }, + { from: "verify", on: "fail", to: "implement" }, + { from: "review", on: "approve", to: "converged" }, + { from: "review", on: "reject", to: "implement" }, + { from: "review", on: "escalate", to: "needs-human" }, + { from: "converged", on: "pass", to: "completed" }, + { from: "converged", on: "fail", to: "needs-human" }, + ], +}; + +test("lays out a responsive text-only graph with entry, outputs, decisions, and returns", () => { + const wide = layoutAsciiGraph(graph, "review", 110); + const narrow = layoutAsciiGraph(graph, "review", 52); + const wideText = wide.lines.join("\n"), narrowText = narrow.lines.join("\n"); + for (const token of ["INPUT", "+", "/", "\\", "▶", "▼", "reject", "escalate", + "IMPLEMENT", "VERIFY", "CONVERGED?", "COMPLETED"]) + assert.ok(wideText.includes(token), `missing ${token}`); + assert.ok(narrow.lines.length > wide.lines.length); + assert.ok(Math.max(...narrow.lines.map((line) => line.length)) <= 52); + assert.equal(narrow.current !== null, true); +}); diff --git a/dashboard/src/components/LoopGraph/ascii-layout.ts b/dashboard/src/components/LoopGraph/ascii-layout.ts new file mode 100644 index 00000000..586a4127 --- /dev/null +++ b/dashboard/src/components/LoopGraph/ascii-layout.ts @@ -0,0 +1,296 @@ +export type AsciiNode = { + id: string; + kind: string; + role?: string; + authority?: "write" | "read"; + capability?: string; + gateKind?: string; + measure?: "test" | "metric" | "eval" | "boolean"; + target?: string; + execution?: null | { model: string; effort: string; authority: "write" | "read" }; +}; +export type AsciiEdge = { from: string; on: string; to: string }; +export type AsciiGraph = { entry?: string; nodes: AsciiNode[]; edges: AsciiEdge[] }; +export type AsciiLayout = { lines: string[]; current: { x: number; y: number; width: number } | null }; + +const preferred = ["complete", "measured", "pass", "target-met", "approve", "success"]; + +function nodeLabel(node: AsciiNode) { + return `${node.id.toUpperCase()}${node.kind === "gate" ? "?" : ""}`; +} + +function contentLine(left: string, right: string, value: string, width: number) { + const content = value.slice(0, width - 4); + return `${left} ${content.padEnd(width - 4)} ${right}`; +} + +function nodeLines(node: AsciiNode, width: number) { + const label = nodeLabel(node); + if (node.kind === "gate") return [ + `/${"-".repeat(width - 2)}\\`, + contentLine("<", ">", label, width), + `\\${"-".repeat(width - 2)}/`, + ]; + if (node.kind === "terminal") return [ + `/${"-".repeat(width - 2)}\\`, + contentLine("|", "|", label, width), + `\\${"-".repeat(width - 2)}/`, + ]; + return [ + `+${"-".repeat(width - 2)}+`, + contentLine("|", "|", label, width), + `+${"-".repeat(width - 2)}+`, + ]; +} + +function primaryPath(graph: AsciiGraph) { + const entry = graph.entry ?? graph.nodes[0]?.id; + if (!entry) return []; + const result = [entry], seen = new Set(result); + let cursor = entry; + while (cursor) { + const candidates = graph.edges.filter((edge) => edge.from === cursor && !seen.has(edge.to)); + candidates.sort((left, right) => { + const rank = (edge: AsciiEdge) => { + const value = preferred.indexOf(edge.on); + return value < 0 ? preferred.length : value; + }; + return rank(left) - rank(right) || left.on.localeCompare(right.on); + }); + const edge = candidates[0]; + if (!edge) break; + result.push(edge.to); seen.add(edge.to); cursor = edge.to; + } + return result; +} + +function canvas(rows: number, columns: number) { + const cells = Array.from({ length: rows }, () => Array(columns).fill(" ")); + const lineMasks = Array.from({ length: rows }, () => Array(columns).fill(0)); + const glyphs: Record = { + 1: "│", 2: "─", 3: "└", 4: "│", 5: "│", 6: "┌", 7: "├", + 8: "─", 9: "┘", 10: "─", 11: "┴", 12: "┐", 13: "┤", 14: "┬", 15: "┼", + }; + const put = (x: number, y: number, value: string) => { + if (x < 0 || x >= columns || y < 0 || y >= rows) return; + cells[y][x] = value; + lineMasks[y][x] = 0; + }; + const connect = (x: number, y: number, mask: number) => { + if (x < 0 || x >= columns || y < 0 || y >= rows || !mask) return; + lineMasks[y][x] |= mask; + cells[y][x] = glyphs[lineMasks[y][x]]; + }; + const text = (x: number, y: number, value: string) => [...value].forEach((char, index) => put(x + index, y, char)); + const horizontal = (from: number, to: number, y: number) => { + const left = Math.min(from, to), right = Math.max(from, to); + for (let x = left; x <= right; x += 1) + connect(x, y, (x > left ? 8 : 0) | (x < right ? 2 : 0)); + }; + const vertical = (x: number, from: number, to: number) => { + const top = Math.min(from, to), bottom = Math.max(from, to); + for (let y = top; y <= bottom; y += 1) + connect(x, y, (y > top ? 1 : 0) | (y < bottom ? 4 : 0)); + }; + return { cells, put, text, horizontal, vertical }; +} + +function fanoutLayout(graph: AsciiGraph, currentNode: string, columns: number, width: number): AsciiLayout | null { + const planner = graph.nodes.find((node) => node.role === "planner" || node.role === "orchestrator"); + if (!planner) return null; + const branchEdges = graph.edges.filter((edge) => edge.from === planner.id); + if (branchEdges.length < 2 || width * branchEdges.length + 8 * (branchEdges.length - 1) > columns - 4) return null; + const outgoing = (id: string) => graph.edges.filter((edge) => edge.from === id); + const reachable = (start: string) => { + const result = new Map(), queue = [[start, 0] as const]; + while (queue.length) { + const [id, depth] = queue.shift()!; + if (result.has(id)) continue; + result.set(id, depth); + for (const edge of outgoing(id)) if (edge.to !== planner.id) queue.push([edge.to, depth + 1]); + } + return result; + }; + const maps = branchEdges.map((edge) => reachable(edge.to)); + const common = graph.nodes + .filter((node) => maps.every((map) => map.has(node.id))) + .sort((left, right) => maps.reduce((sum, map) => sum + map.get(left.id)!, 0) + - maps.reduce((sum, map) => sum + map.get(right.id)!, 0))[0]; + if (!common) return null; + const branchPaths = branchEdges.map((edge) => { + const path = [edge.to]; + while (path.at(-1) !== common.id) { + const next = outgoing(path.at(-1)!).filter((candidate) => candidate.to !== planner.id) + .sort((left, right) => (maps[0].get(left.to) ?? 999) - (maps[0].get(right.to) ?? 999))[0]; + if (!next || path.includes(next.to)) return []; + path.push(next.to); + } + return path.slice(0, -1); + }); + if (branchPaths.some((path) => !path.length)) return null; + const tail = [common.id]; + while (true) { + const next = outgoing(tail.at(-1)!).filter((edge) => !tail.includes(edge.to) && edge.to !== planner.id) + .sort((left, right) => preferred.indexOf(right.on) - preferred.indexOf(left.on))[0]; + if (!next) break; + tail.push(next.to); + } + const maxBranch = Math.max(...branchPaths.map((path) => path.length)); + const tailStart = 9 + maxBranch * 5; + const rows = tailStart + tail.length * 5 + graph.edges.length * 2 + 4; + const drawing = canvas(rows, columns), byId = new Map(graph.nodes.map((node) => [node.id, node])); + const positions = new Map(); + const branchLeft = 2; + const branchStep = (columns - 4 - width) / Math.max(1, branchPaths.length - 1); + const branchXs = branchPaths.map((_, index) => Math.round(branchLeft + index * branchStep)); + const totalWidth = branchXs.at(-1)! + width - branchLeft; + const plannerX = Math.floor((columns - width) / 2); + positions.set(planner.id, { x: plannerX, y: 2 }); + branchPaths.forEach((path, column) => path.forEach((id, row) => + positions.set(id, { x: branchXs[column], y: 8 + row * 5 }))); + tail.forEach((id, row) => positions.set(id, { x: plannerX, y: tailStart + row * 5 })); + for (const [id, position] of positions) { + const lines = nodeLines(byId.get(id)!, width); + drawing.text(position.x, position.y, lines[0]); drawing.text(position.x, position.y + 1, lines[1]); + drawing.text(position.x, position.y + 2, lines[2]); + } + const plannerCenter = plannerX + Math.floor(width / 2), splitY = 6; + const branchCenters = branchXs.map((x) => x + Math.floor(width / 2)); + drawing.vertical(plannerCenter, 5, splitY); drawing.horizontal(branchCenters[0], branchCenters.at(-1)!, splitY); + branchEdges.forEach((edge, index) => { + const center = branchCenters[index]; + drawing.vertical(center, splitY, 7); drawing.put(center, 7, "▼"); + drawing.text(center + 2, 7, edge.on); + }); + drawing.put(plannerCenter, splitY, "┴"); + branchCenters.forEach((center, index) => + drawing.put(center, splitY, index === 0 ? "┌" : index === branchCenters.length - 1 ? "┐" : "┬")); + branchPaths.forEach((path, column) => { + const center = branchCenters[column]; + for (let index = 1; index < path.length; index += 1) { + const from = positions.get(path[index - 1])!, to = positions.get(path[index])!; + const edge = graph.edges.find((candidate) => candidate.from === path[index - 1] && candidate.to === path[index])!; + drawing.vertical(center, from.y + 3, to.y - 1); drawing.put(center, to.y - 1, "▼"); + drawing.text(center + 2, from.y + 3, edge.on); + } + const last = path.at(-1)!, from = positions.get(last)!, join = positions.get(common.id)!; + const edge = graph.edges.find((candidate) => candidate.from === last && candidate.to === common.id)!; + const mergeY = join.y - 2; + drawing.vertical(center, from.y + 3, mergeY); drawing.horizontal(center, plannerCenter, mergeY); + drawing.put(center, mergeY, column === 0 ? "└" : column === branchPaths.length - 1 ? "┘" : "┴"); + drawing.text(center + 2, Math.min(mergeY, from.y + 3), edge.on); + }); + drawing.vertical(plannerCenter, tailStart - 2, tailStart - 1); drawing.put(plannerCenter, tailStart - 1, "▼"); + drawing.put(plannerCenter, tailStart - 2, "┬"); + for (let index = 1; index < tail.length; index += 1) { + const from = positions.get(tail[index - 1])!, to = positions.get(tail[index])!; + const edge = graph.edges.find((candidate) => candidate.from === tail[index - 1] && candidate.to === tail[index])!; + drawing.vertical(plannerCenter, from.y + 3, to.y - 1); drawing.put(plannerCenter, to.y - 1, "▼"); + drawing.text(plannerCenter + 2, from.y + 3, edge.on); + } + const feedback = graph.edges.filter((edge) => positions.has(edge.from) && edge.to === planner.id); + feedback.forEach((edge, index) => { + const from = positions.get(edge.from)!; + const plannerPosition = positions.get(planner.id)!; + const railX = Math.min(columns - 3, branchLeft + totalWidth + 4 + index * 2); + const sourceY = from.y + 1 + index; + drawing.horizontal(from.x + width, railX, sourceY); + drawing.vertical(railX, plannerPosition.y + 1, sourceY); + drawing.horizontal(plannerPosition.x + width, railX, plannerPosition.y + 1); + drawing.put(plannerPosition.x + width, plannerPosition.y + 1, "◀"); + drawing.text(from.x + width + 2, sourceY, edge.on); + }); + drawing.text(plannerX, 0, "INPUT"); + const active = positions.get(currentNode); + return { + lines: drawing.cells.map((line) => line.join("").trimEnd()) + .filter((line, index, lines) => line.length || lines.slice(index + 1).some(Boolean)), + current: active ? { x: active.x, y: active.y, width } : null, + }; +} + +export function layoutAsciiGraph(graph: AsciiGraph, currentNode: string, availableCharacters: number): AsciiLayout { + if (!graph.nodes.length) return { lines: [], current: null }; + const columns = Math.max(36, Math.floor(availableCharacters)); + const desiredWidth = Math.max(...graph.nodes.map((node) => nodeLabel(node).length + 4)); + const width = Math.max(16, Math.min(24, columns - 8, desiredWidth)); + const fanout = fanoutLayout(graph, currentNode, columns, width); + if (fanout) return fanout; + const path = primaryPath(graph); + const byId = new Map(graph.nodes.map((node) => [node.id, node])); + const sideNodes = graph.nodes.filter((node) => !path.includes(node.id)); + const ordered = [...path, ...sideNodes.map((node) => node.id)]; + const usableColumns = Math.max(width, columns - 8); + const perRow = Math.max(1, Math.min(3, Math.floor((usableColumns + 4) / (width + 4)), ordered.length)); + const gap = perRow > 1 ? Math.floor((usableColumns - perRow * width) / (perRow - 1)) : 0; + const positions = new Map(); + ordered.forEach((id, index) => { + const row = Math.floor(index / perRow), offset = index % perRow; + const count = Math.min(perRow, ordered.length - row * perRow); + const visualColumn = row % 2 === 0 ? offset : count - offset - 1; + positions.set(id, { x: 2 + visualColumn * (width + gap), y: 2 + row * 7 }); + }); + const rows = Math.max(...[...positions.values()].map((position) => position.y)) + 5 + graph.edges.length * 2; + const drawing = canvas(rows, columns); + for (const [id, position] of positions) { + const rendered = nodeLines(byId.get(id)!, width); + drawing.text(position.x, position.y, rendered[0]); + drawing.text(position.x, position.y + 1, rendered[1]); + drawing.text(position.x, position.y + 2, rendered[2]); + } + const primaryKeys = new Set(); + for (let index = 0; index < path.length - 1; index += 1) { + const edge = graph.edges.find((candidate) => candidate.from === path[index] && candidate.to === path[index + 1]); + if (!edge) continue; + primaryKeys.add(`${edge.from}\0${edge.on}\0${edge.to}`); + const from = positions.get(edge.from)!, to = positions.get(edge.to)!; + if (from.y === to.y) { + const forward = from.x < to.x; + const start = forward ? from.x + width : from.x - 1; + const end = forward ? to.x - 1 : to.x + width; + drawing.horizontal(start, end, from.y + 1); + drawing.put(end, from.y + 1, forward ? "▶" : "◀"); + drawing.text(Math.min(start, end) + 1, from.y, edge.on.slice(0, Math.max(1, Math.abs(end - start) - 2))); + } else { + const fromCenter = from.x + Math.floor(width / 2), toCenter = to.x + Math.floor(width / 2); + const turnY = from.y + 5; + drawing.vertical(fromCenter, from.y + 3, turnY); + drawing.horizontal(fromCenter, toCenter, turnY); + drawing.vertical(toCenter, turnY, to.y - 1); + drawing.put(toCenter, to.y - 1, "▼"); + drawing.text(Math.min(fromCenter, toCenter) + 1, turnY, edge.on); + } + } + const alternate = graph.edges.filter((edge) => !primaryKeys.has(`${edge.from}\0${edge.on}\0${edge.to}`)); + const localRails = new Map(); + alternate.forEach((edge, index) => { + const from = positions.get(edge.from), to = positions.get(edge.to); + if (!from || !to) return; + const fromCenter = from.x + Math.floor(width / 2), toCenter = to.x + Math.floor(width / 2); + if (from.y === to.y) { + const offset = localRails.get(from.y) ?? 0; + localRails.set(from.y, offset + 1); + const railY = from.y + 4 + offset; + drawing.vertical(fromCenter, from.y + 3, railY); + drawing.horizontal(fromCenter, toCenter, railY); + drawing.vertical(toCenter, to.y + 3, railY); + drawing.put(toCenter, to.y + 3, "▲"); + drawing.text(Math.min(fromCenter, toCenter) + 1, railY, edge.on); + return; + } + const turnY = to.y - 1; + drawing.vertical(fromCenter, from.y + 3, turnY); + drawing.horizontal(fromCenter, toCenter, turnY); + drawing.vertical(toCenter, turnY, to.y - 1); + drawing.put(toCenter, to.y - 1, "▼"); + drawing.text(Math.min(fromCenter, toCenter) + 1, turnY, edge.on); + }); + const entryPosition = positions.get(graph.entry ?? graph.nodes[0].id); + if (entryPosition) drawing.text(entryPosition.x, 0, "INPUT"); + const active = positions.get(currentNode); + return { + lines: drawing.cells.map((line) => line.join("").trimEnd()) + .filter((line, index, lines) => line.length || lines.slice(index + 1).some(Boolean)), + current: active ? { x: active.x, y: active.y, width } : null, + }; +} diff --git a/dashboard/src/components/LoopGraph/compact-layout.ts b/dashboard/src/components/LoopGraph/compact-layout.ts new file mode 100644 index 00000000..c9395484 --- /dev/null +++ b/dashboard/src/components/LoopGraph/compact-layout.ts @@ -0,0 +1,226 @@ +import type { LoopGraphProjection } from "./LoopGraph"; +import { loopPrimaryPath, loopSymbols } from "./loop-symbols"; + +type Position = { x: number; y: number }; + +function drawing(rows: number, columns: number) { + const cells = Array.from({ length: rows }, () => Array(columns).fill(" ")); + const lineMasks = Array.from({ length: rows }, () => Array(columns).fill(0)); + const glyphs: Record = { + 1: "│", 2: "─", 3: "└", 4: "│", 5: "│", 6: "┌", 7: "├", + 8: "─", 9: "┘", 10: "─", 11: "┴", 12: "┐", 13: "┤", 14: "┬", 15: "┼", + }; + const put = (x: number, y: number, value: string) => { + if (x >= 0 && x < columns && y >= 0 && y < rows) { + cells[y][x] = value; + lineMasks[y][x] = 0; + } + }; + const connect = (x: number, y: number, mask: number) => { + if (x < 0 || x >= columns || y < 0 || y >= rows || !mask) return; + lineMasks[y][x] |= mask; + cells[y][x] = glyphs[lineMasks[y][x]]; + }; + const horizontal = (from: number, to: number, y: number) => { + const left = Math.min(from, to), right = Math.max(from, to); + for (let x = left; x <= right; x += 1) + connect(x, y, (x > left ? 8 : 0) | (x < right ? 2 : 0)); + }; + const vertical = (x: number, from: number, to: number) => { + const top = Math.min(from, to), bottom = Math.max(from, to); + for (let y = top; y <= bottom; y += 1) + connect(x, y, (y > top ? 1 : 0) | (y < bottom ? 4 : 0)); + }; + const text = (x: number, y: number, value: string) => + [...value].forEach((character, index) => put(x + index, y, character)); + return { cells, put, text, horizontal, vertical }; +} + +type CompactOptions = { showLabels?: boolean; symbols?: Record }; + +function fanoutCompact(run: LoopGraphProjection, options: CompactOptions) { + const planner = run.graph.nodes.find((node) => node.role === "planner" || node.role === "orchestrator"); + if (!planner) return null; + const outgoing = (id: string) => run.graph.edges.filter((edge) => edge.from === id); + const starts = outgoing(planner.id); + if (starts.length < 2) return null; + const reachable = (start: string) => { + const seen = new Set(), queue = [start]; + while (queue.length) { + const id = queue.shift()!; + if (seen.has(id)) continue; + seen.add(id); + outgoing(id).filter((edge) => edge.to !== planner.id).forEach((edge) => queue.push(edge.to)); + } + return seen; + }; + const maps = starts.map((edge) => reachable(edge.to)); + const common = run.graph.nodes.find((node) => maps.every((map) => map.has(node.id))); + if (!common) return null; + const paths = starts.map((edge) => { + const path = [edge.to]; + while (path.at(-1) !== common.id) { + const next = outgoing(path.at(-1)!).find((candidate) => candidate.to !== planner.id); + if (!next || path.includes(next.to)) return []; + path.push(next.to); + } + return path.slice(0, -1); + }); + if (paths.some((path) => !path.length)) return null; + const tail = [common.id]; + while (true) { + const next = outgoing(tail.at(-1)!).find((edge) => !tail.includes(edge.to) && edge.to !== planner.id); + if (!next) break; + tail.push(next.to); + } + const symbols = loopSymbols(run.graph.nodes, options.symbols); + const branchStep = options.showLabels ? 22 : 14; + const centers = paths.map((_, index) => index * branchStep); + const center = Math.round((centers[0] + centers.at(-1)!) / 2); + const maxDepth = Math.max(...paths.map((path) => path.length)); + const commonY = 5 + maxDepth * 3; + const positions = new Map([[planner.id, { x: center, y: 0 }]]); + paths.forEach((path, column) => path.forEach((id, row) => + positions.set(id, { x: centers[column], y: 4 + row * 3 }))); + tail.forEach((id, row) => positions.set(id, { x: center, y: commonY + row * 3 })); + const feedback = run.graph.edges.filter((edge) => edge.to === planner.id && positions.has(edge.from)); + const labelClearance = options.showLabels + ? Math.max(...run.graph.edges.map((edge) => edge.on.length), 0) + 5 + : 4; + const columns = centers.at(-1)! + labelClearance + 2 + feedback.length * 2; + const rows = commonY + tail.length * 3 + 2; + const graph = drawing(rows, columns); + for (const [id, position] of positions) graph.text(position.x, position.y, symbols.get(id)!); + graph.vertical(center, 1, 2); + graph.horizontal(centers[0], centers.at(-1)!, 2); + centers.forEach((branchCenter) => { + graph.vertical(branchCenter, 2, 3); + graph.put(branchCenter, 3, "▼"); + }); + if (options.showLabels) starts.forEach((edge, index) => + graph.text(centers[index] + 2, 3, edge.on)); + paths.forEach((path, column) => { + const branchCenter = centers[column]; + for (let index = 1; index < path.length; index += 1) { + const from = positions.get(path[index - 1])!, to = positions.get(path[index])!; + graph.vertical(branchCenter, from.y + 1, to.y - 1); + graph.put(branchCenter, to.y - 1, "▼"); + if (options.showLabels) { + const edge = run.graph.edges.find((candidate) => + candidate.from === path[index - 1] && candidate.to === path[index]); + if (edge) graph.text(branchCenter + 2, from.y + 1, edge.on); + } + } + const from = positions.get(path.at(-1)!)!, mergeY = commonY - 2; + graph.vertical(branchCenter, from.y + 1, mergeY); + graph.horizontal(branchCenter, center, mergeY); + if (options.showLabels) { + const edge = run.graph.edges.find((candidate) => + candidate.from === path.at(-1) && candidate.to === common.id); + if (edge) graph.text(branchCenter + 2, from.y + 1, edge.on); + } + }); + graph.vertical(center, commonY - 2, commonY - 1); + graph.put(center, commonY - 1, "▼"); + for (let index = 1; index < tail.length; index += 1) { + const from = positions.get(tail[index - 1])!, to = positions.get(tail[index])!; + graph.vertical(center, from.y + 1, to.y - 1); + graph.put(center, to.y - 1, "▼"); + if (options.showLabels) { + const edge = run.graph.edges.find((candidate) => + candidate.from === tail[index - 1] && candidate.to === tail[index]); + if (edge) graph.text(center + 2, from.y + 1, edge.on); + } + } + feedback.forEach((edge, index) => { + const from = positions.get(edge.from)!, railX = centers.at(-1)! + labelClearance + index * 2; + graph.horizontal(from.x + 2, railX, from.y); + graph.vertical(railX, 0, from.y); + graph.horizontal(center + 2, railX, 0); + graph.put(center + 2, 0, "◀"); + if (options.showLabels) graph.text(from.x + 2, from.y, edge.on); + }); + return { + lines: graph.cells.map((line) => line.join("").trimEnd()) + .filter((line, index, lines) => line.length || lines.slice(index + 1).some(Boolean)), + positions, + }; +} + +export function layoutCompactLoop(run: LoopGraphProjection, options: CompactOptions = {}) { + const fanout = fanoutCompact(run, options); + if (fanout) return fanout; + const path = loopPrimaryPath(run.graph); + const symbols = loopSymbols(run.graph.nodes, options.symbols); + const primaryEdges = path.slice(0, -1).map((from, index) => + run.graph.edges.find((edge) => edge.from === from && edge.to === path[index + 1])!); + const positions = new Map(); + let cursor = 0; + path.forEach((id, index) => { + positions.set(id, { x: cursor, y: 0 }); + const edge = primaryEdges[index]; + if (edge) cursor += options.showLabels ? edge.on.length + 7 : 6; + }); + const primary = new Set(path.slice(0, -1).map((from, index) => `${from}\0${path[index + 1]}`)); + const alternate = run.graph.edges.filter((edge) => !primary.has(`${edge.from}\0${edge.to}`)); + const backwards = alternate.filter((edge) => { + const from = positions.get(edge.from), to = positions.get(edge.to); + return from && to && to.x < from.x; + }); + const branches = alternate.filter((edge) => !backwards.includes(edge)); + const branchStart = 2 + backwards.length * 2; + const columns = Math.max(1, cursor + 2, ...branches.map((edge) => + (positions.get(edge.from)?.x ?? 0) + (options.showLabels ? edge.on.length + 8 : 8))); + const rows = Math.max(1, branchStart + branches.length * 3); + const graph = drawing(rows, columns); + + path.forEach((id, index) => { + const position = positions.get(id)!; + graph.text(position.x, 0, symbols.get(id)!); + if (index === 0) return; + const previous = positions.get(path[index - 1])!; + graph.horizontal(previous.x + 2, position.x - 2, 0); + graph.put(position.x - 2, 0, "▶"); + if (options.showLabels) graph.text(previous.x + 3, 0, primaryEdges[index - 1].on); + }); + + backwards.forEach((edge, index) => { + const from = positions.get(edge.from)!, to = positions.get(edge.to)!; + const rail = 2 + index * 2; + graph.vertical(from.x, 1, rail); + graph.horizontal(to.x, from.x, rail); + graph.vertical(to.x, 1, rail); + graph.put(to.x, 1, "▲"); + if (options.showLabels) graph.text(to.x + 2, rail, edge.on); + }); + + branches.forEach((edge, index) => { + const from = positions.get(edge.from); + if (!from) return; + const existingTarget = positions.get(edge.to); + if (existingTarget) { + const rail = Math.max(from.y + 1, branchStart + index * 2); + graph.vertical(from.x, from.y + 1, rail); + graph.horizontal(from.x, existingTarget.x, rail); + graph.vertical(existingTarget.x, 1, rail); + graph.put(existingTarget.x, 1, "▲"); + if (options.showLabels) graph.text(Math.min(from.x, existingTarget.x) + 2, rail, edge.on); + return; + } + const y = branchStart + index * 3 + 2; + const distance = options.showLabels ? edge.on.length + 5 : 6; + const x = Math.min(columns - 1, from.x + distance); + positions.set(edge.to, { x, y: y - 1 }); + graph.vertical(from.x, 1, y - 1); + graph.horizontal(from.x, x - 2, y - 1); + graph.put(x - 2, y - 1, "▶"); + graph.text(x, y - 1, symbols.get(edge.to)!); + if (options.showLabels) graph.text(from.x + 2, y - 1, edge.on); + }); + + return { + lines: graph.cells.map((line) => line.join("").trimEnd()) + .filter((line, index, lines) => line.length || lines.slice(index + 1).some(Boolean)), + positions, + }; +} diff --git a/dashboard/src/components/LoopGraph/index.ts b/dashboard/src/components/LoopGraph/index.ts new file mode 100644 index 00000000..9845204c --- /dev/null +++ b/dashboard/src/components/LoopGraph/index.ts @@ -0,0 +1,6 @@ +export { LoopGraph } from "./LoopGraph"; +export { itemTopologyProjection, LoopCompact } from "./LoopCompact"; +export { LoopLegend } from "./LoopLegend"; +export type { LoopGraphEdge, LoopGraphNode, LoopGraphProjection, LoopGraphProps, LoopGraphTransition } from "./LoopGraph"; +export type { LoopCompactProps } from "./LoopCompact"; +export type { LoopLegendProps } from "./LoopLegend"; diff --git a/dashboard/src/components/LoopGraph/loop-symbols.ts b/dashboard/src/components/LoopGraph/loop-symbols.ts new file mode 100644 index 00000000..a1b6d738 --- /dev/null +++ b/dashboard/src/components/LoopGraph/loop-symbols.ts @@ -0,0 +1,49 @@ +import type { LoopGraphNode, LoopGraphProjection } from "./LoopGraph"; + +const preferredOutcomes = ["complete", "measured", "pass", "target-met", "approve", "success"]; + +export function loopSymbols(nodes: LoopGraphNode[], overrides: Record = {}) { + const totals = new Map(); + const seen = new Map(); + const bases = nodes.map((node) => { + if (/^implement/u.test(node.id)) return "I"; + if (/^(verify|validate|measure)/u.test(node.id)) return "V"; + if (/^review/u.test(node.id)) return "R"; + if (/^(converged|.*-gate$)/u.test(node.id) || node.kind === "gate") return "G"; + if (/^completed/u.test(node.id)) return "C"; + if (/^plan/u.test(node.id)) return "P"; + if (/^combine/u.test(node.id)) return "M"; + if (/needs-human/u.test(node.id)) return "H"; + return node.id.match(/[a-z0-9]/iu)?.[0]?.toUpperCase() ?? "N"; + }); + for (const base of bases) totals.set(base, (totals.get(base) ?? 0) + 1); + return new Map(nodes.map((node, index) => { + const base = bases[index]; + const occurrence = (seen.get(base) ?? 0) + 1; + seen.set(base, occurrence); + return [node.id, overrides[node.id] ?? (totals.get(base)! > 1 ? `${base}${occurrence}` : base)]; + })); +} + +export function loopPrimaryPath(graph: LoopGraphProjection["graph"]) { + const entry = graph.entry ?? graph.nodes[0]?.id; + if (!entry) return []; + const path = [entry], seen = new Set(path); + let current = entry; + while (current) { + const candidates = graph.edges.filter((edge) => edge.from === current && !seen.has(edge.to)); + candidates.sort((left, right) => { + const rank = (outcome: string) => { + const index = preferredOutcomes.indexOf(outcome); + return index < 0 ? preferredOutcomes.length : index; + }; + return rank(left.on) - rank(right.on) || left.on.localeCompare(right.on); + }); + const next = candidates[0]; + if (!next) break; + path.push(next.to); + seen.add(next.to); + current = next.to; + } + return path; +} diff --git a/dashboard/src/hooks/dashboard-data.mjs b/dashboard/src/hooks/dashboard-data.mjs index ba3220fa..3f0ade68 100644 --- a/dashboard/src/hooks/dashboard-data.mjs +++ b/dashboard/src/hooks/dashboard-data.mjs @@ -79,9 +79,10 @@ export function receiveLoopProjection(response, json) { export function dashboardLoopProjectionSnapshotConfig(enabled, selected) { const query = selectedQuery(selected); + const subjectId = selected?.id && selected?.item ? `item:${selected.id}#${selected.item}` : null; return { transport: "snapshot", enabled: enabled && Boolean(selected), repoKey: selected?.repoKey ?? null, - ovenId: "checklist", subjectId: null, query: `loop/${query}`, + ovenId: "checklist", subjectId, query: `loop/${query}`, makeUrl: () => `/api/loop-projection?${query}`, receive: receiveLoopProjection, fallbackError: "Could not load Loop projection.", initialData: null, events: [Object.freeze({ ovenId: "checklist", kind: "loop-projection-changed", phase: "complete" })], deps: [enabled, query], diff --git a/dashboard/src/hooks/dashboard-data.test.mjs b/dashboard/src/hooks/dashboard-data.test.mjs index 3456b633..d1b6b3ea 100644 --- a/dashboard/src/hooks/dashboard-data.test.mjs +++ b/dashboard/src/hooks/dashboard-data.test.mjs @@ -138,9 +138,10 @@ test("landing projections share matching invalidations and the manual-write fall test("loop projection uses its dedicated snapshot URL and coalesces conditional event/reset refreshes", async () => { const timers = fakeTimers(), sources = [], calls = [], states = []; - const selected = { repoKey: "aaaaaaaaaaaa", id: "260722-001" }; + const selected = { repoKey: "aaaaaaaaaaaa", id: "260722-001", item: "M7" }; const config = dashboardLoopProjectionSnapshotConfig(true, selected); - assert.equal(config.makeUrl(), "/api/loop-projection?repoKey=aaaaaaaaaaaa&id=260722-001"); + assert.equal(config.makeUrl(), "/api/loop-projection?repoKey=aaaaaaaaaaaa&id=260722-001&item=M7"); + assert.equal(config.subjectId, "item:260722-001#M7"); assert.deepEqual(config.events, [{ ovenId: "checklist", kind: "loop-projection-changed", phase: "complete" }]); const client = createOvenSnapshotClient({ timers, focusTarget: null, diff --git a/dashboard/src/hooks/useDashboardData.ts b/dashboard/src/hooks/useDashboardData.ts index 4e0c066c..745b5093 100644 --- a/dashboard/src/hooks/useDashboardData.ts +++ b/dashboard/src/hooks/useDashboardData.ts @@ -14,7 +14,9 @@ export function useDashboardData({ section, selected }: { section: string; selec const enabled = section === "burnlists"; const projectsState = useOvenLiveData(dashboardProjectsSnapshotConfig(enabled)); const progressState = useOvenLiveData(dashboardProgressSnapshotConfig(enabled, selected)); - const loopState = useOvenLiveData(dashboardLoopProjectionSnapshotConfig(enabled, selected)); + const currentItem = progressState.data && "active" in progressState.data ? progressState.data.active[0]?.id : undefined; + const loopSelection = selected && currentItem ? { ...selected, item: currentItem } : selected; + const loopState = useOvenLiveData(dashboardLoopProjectionSnapshotConfig(enabled, loopSelection)); const loopDiagnostic = loopState.error.includes("retaining the last verified projection") ? "corrupt" : loopState.stale || loopState.error ? "stale" : undefined; const loopRun = loopState.data && loopDiagnostic ? { ...loopState.data, diagnostic: loopDiagnostic } : loopState.data; diff --git a/dashboard/src/lib/checklist-adapter.ts b/dashboard/src/lib/checklist-adapter.ts index fe7bbac5..48c114b9 100644 --- a/dashboard/src/lib/checklist-adapter.ts +++ b/dashboard/src/lib/checklist-adapter.ts @@ -5,6 +5,7 @@ export type EventDetailField = { label: string; values: string[] }; export type ChecklistOvenPayload = { raw: ChecklistProgressData; + items: Array; current: { value: string; title: string }; progress: { done: number; total: number; percent: number; title: string }; durations: { elapsed: string; pace: string; timeLeft: string }; @@ -96,6 +97,10 @@ export function adaptChecklist(data: ChecklistProgressData): ChecklistOvenPayloa const current = data.active[0]; return { raw: data, + items: data.active.map((item) => ({ + ...item, + loopRun: data.loopRun?.itemRef.endsWith(`#${item.id}`) ? data.loopRun : null, + })), current: { value: current ? `${current.id} · Active` : "Complete", title: current?.title ?? "No active task" }, progress: { done: data.done, total: data.total, percent: data.percent, title: `${data.done} of ${data.total} tasks complete` }, durations: { elapsed: formatDuration(durations.elapsed), pace: formatDuration(durations.pace), timeLeft: formatDuration(durations.timeLeft) }, diff --git a/dashboard/src/lib/hrefs.ts b/dashboard/src/lib/hrefs.ts index 5bfa5326..58e8720e 100644 --- a/dashboard/src/lib/hrefs.ts +++ b/dashboard/src/lib/hrefs.ts @@ -2,7 +2,14 @@ import { burnlistHref as buildBurnlistHref, parseRoute } from "./route-model.mjs import type { Burnlist, Filter, SelectedBurnlist } from "./types"; function route() { - return parseRoute({ pathname: window.location.pathname, search: window.location.search }); + return parseRoute(typeof window === "undefined" + ? { pathname: "/", search: "" } + : { pathname: window.location.pathname, search: window.location.search }); +} +function itemFromHash() { + if (typeof window === "undefined") return undefined; + if (window.location.hash.length <= 1) return undefined; + try { return decodeURIComponent(window.location.hash.slice(1)); } catch { return undefined; } } export function currentSection() { @@ -17,7 +24,7 @@ export function customOvenSelection(): { id: string; repoKey: string | null; bur export function ovenExplainerSelection(): { ovenId: string; repoKey: string | null } | null { const current = route(); return current.section === "oven-explainer" - ? { ovenId: current.ovenId, repoKey: new URLSearchParams(window.location.search).get("repoKey") } + ? { ovenId: current.ovenId, repoKey: new URLSearchParams(typeof window === "undefined" ? "" : window.location.search).get("repoKey") } : null; } @@ -44,13 +51,14 @@ export function streamingDiffSelection() { export function selectedBurnlist(): SelectedBurnlist | null { const current = route(); - if (current.plan) return { plan: current.plan }; - if (current.repoKey && current.burnlistId) return { repoKey: current.repoKey, id: current.burnlistId }; - return current.repo && current.burnlistId ? { repo: current.repo, id: current.burnlistId } : null; + const item = itemFromHash(); + if (current.plan) return { plan: current.plan, ...(item ? { item } : {}) }; + if (current.repoKey && current.burnlistId) return { repoKey: current.repoKey, id: current.burnlistId, ...(item ? { item } : {}) }; + return current.repo && current.burnlistId ? { repo: current.repo, id: current.burnlistId, ...(item ? { item } : {}) } : null; } export function filterFromUrl(filters: Array<{ value: Filter }>): Filter { - const value = new URLSearchParams(window.location.search).get("filter") as Filter | null; + const value = new URLSearchParams(typeof window === "undefined" ? "" : window.location.search).get("filter") as Filter | null; return filters.some((filter) => filter.value === value) ? value! : "active"; } diff --git a/dashboard/src/lib/types.ts b/dashboard/src/lib/types.ts index d42ba877..2fa4b91c 100644 --- a/dashboard/src/lib/types.ts +++ b/dashboard/src/lib/types.ts @@ -1,6 +1,17 @@ export type Filter = "active" | "draft" | "ready" | "complete" | "all"; -export type ChecklistItem = { id: string; title: string; fields: Record }; +export type ChecklistItem = { + id: string; + title: string; + fields: Record; + loop?: null | { + selector: string; + assignmentId: string; + executionRevision: string; + packageRevision: string; + graph?: LoopRunProjection["graph"] | null; + }; +}; export type CompletedItem = { id: string; title: string; completedAt: string; detail: string }; export type Warning = { severity: "error" | "warning"; message: string }; export type HistoryPoint = { time: string; done: number; remaining: number; total: number; percent: number }; @@ -29,7 +40,23 @@ export type LoopRunProjection = { latestReviewer?: null | { summary: string; at: number; candidateId: string | null }; graph: { entry: string; - nodes: Array<{ id: string; kind: string }>; + nodes: Array<{ + id: string; + kind: string; + role?: string; + authority?: "write" | "read"; + capability?: string; + gateKind?: string; + measure?: "test" | "metric" | "eval" | "boolean"; + target?: string; + terminalState?: string; + execution?: null | { + profileId: string; + model: string; + effort: string; + authority: "write" | "read"; + }; + }>; edges: Array<{ from: string; on: string; to: string }>; }; transitions: Array<{ sequence: number; from: string; outcome: string; to: string }>; @@ -42,6 +69,7 @@ export type ChecklistProgressData = { title: string; repo: string; planLabel: string; + selectedItemId?: string | null; total: number; done: number; remaining: number; @@ -141,5 +169,5 @@ export type StreamingDiffFile = { path: string; kind: StreamingDiffFileKind; dif export type StreamingDiffCard = { revId: string; toolUseId: string; ts: string; status: "captured" | "partial"; partialReason?: string; files: StreamingDiffFile[] }; export type StreamingDiffFeed = { identity: StreamingDiffIdentity; updatedAt: string | null; href: string; repoLabel?: string }; -export type SelectedBurnlist = { repo?: string; repoKey?: string; id?: string; plan?: string }; +export type SelectedBurnlist = { repo?: string; repoKey?: string; id?: string; plan?: string; item?: string }; export type ProgressData = ChecklistProgressData; diff --git a/dashboard/src/oven/ChecklistCurrent/ChecklistCurrent.css b/dashboard/src/oven/ChecklistCurrent/ChecklistCurrent.css new file mode 100644 index 00000000..cafcf5ba --- /dev/null +++ b/dashboard/src/oven/ChecklistCurrent/ChecklistCurrent.css @@ -0,0 +1,79 @@ +.checklist-current { + position: relative; + z-index: 1; + container-type: inline-size; + min-width: 0; + overflow: hidden; + padding: 0; + border: 0; + border-radius: 0; + background: transparent; +} + +.checklist-current::after { + position: absolute; + top: 28px; + bottom: 0; + left: 0; + border-left: 1px solid rgba(163, 172, 183, .1); + content: ""; + pointer-events: none; +} + +.checklist-current__empty { + display: grid; + height: 100%; + place-items: center; + color: rgba(168, 168, 168, .58); + font: 12px/1.3 var(--dashboard-font); +} + +.checklist-current__header { + display: flex; + height: 20px; + min-height: 20px; + margin-bottom: 8px; + padding: 0; + align-items: baseline; + justify-content: flex-end; +} + +.checklist-current__visual { + display: flex; + height: calc(100% - 28px); + width: 100%; + overflow: hidden; + flex-direction: column; +} + +.checklist-current .loop-compact { + display: flex; + width: 100%; + min-height: 0; + margin: 0; + padding: 12px 10px 4px; + overflow: hidden; + flex: 1 1 auto; + background: transparent; + color: rgba(220, 225, 232, .82); + font-size: clamp(14px, 3.5cqw, 20px); + line-height: 1.25; + align-items: center; + justify-content: center; +} + +.checklist-current__legend { + display: flex; + min-height: 24px; + padding: 0 12px 3px; + color: rgba(168, 168, 168, .5); + font: 12px/1 var(--dashboard-font); + align-items: flex-end; + justify-content: space-between; + white-space: nowrap; +} + +.checklist-current__legend b { + color: rgba(220, 225, 232, .82); + font-weight: 600; +} diff --git a/dashboard/src/oven/ChecklistCurrent/ChecklistCurrent.test.ts b/dashboard/src/oven/ChecklistCurrent/ChecklistCurrent.test.ts new file mode 100644 index 00000000..e4ba4ade --- /dev/null +++ b/dashboard/src/oven/ChecklistCurrent/ChecklistCurrent.test.ts @@ -0,0 +1,41 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { ChecklistCurrent } from "./ChecklistCurrent"; + +const item = (id: string, loop: unknown = null) => ({ id, title: `Item ${id}`, fields: {}, loop }); + +test("keeps the canonical current item's Loop while another item is inspected", () => { + const run: any = { + itemRef: "item:list#B", loopId: "review", state: "prepared", currentNode: "implement", attempt: 0, cycle: 0, + graph: { + nodes: [ + { id: "implement", kind: "agent" }, + { id: "verify", kind: "check" }, + { id: "review", kind: "agent" }, + { id: "completed", kind: "terminal", terminalState: "converged" }, + ], + edges: [ + { from: "implement", on: "complete", to: "verify" }, + { from: "verify", on: "pass", to: "review" }, + { from: "review", on: "approve", to: "completed" }, + ], + }, + }; + const data: any = { active: [item("B", { selector: "loop:builtin:review" }), item("A")], selectedItemId: "A", loopRun: run }; + const markup = renderToStaticMarkup(createElement(ChecklistCurrent, { data })); + assert.match(markup, /aria-label="Loop for item B"/u); + assert.match(markup, /loop:builtin:review/u); + assert.match(markup, /loop-compact/u); + assert.match(markup, />R<\/b> Review/u); + assert.doesNotMatch(markup, /Item A/u); +}); + +test("distinguishes direct and assigned-but-unstarted items", () => { + const direct = renderToStaticMarkup(createElement(ChecklistCurrent, { data: { active: [item("A")], selectedItemId: "A", loopRun: null } as any })); + const assigned = renderToStaticMarkup(createElement(ChecklistCurrent, { data: { active: [item("B", { selector: "loop:builtin:review" })], selectedItemId: "B", loopRun: null } as any })); + assert.equal(direct, ""); + assert.match(assigned, /Assigned · not started/u); +} +); diff --git a/dashboard/src/oven/ChecklistCurrent/ChecklistCurrent.tsx b/dashboard/src/oven/ChecklistCurrent/ChecklistCurrent.tsx new file mode 100644 index 00000000..fc4d67ab --- /dev/null +++ b/dashboard/src/oven/ChecklistCurrent/ChecklistCurrent.tsx @@ -0,0 +1,43 @@ +import type { ChecklistProgressData } from "@lib"; +import { itemTopologyProjection, LoopCompact } from "@/components/LoopGraph"; +import { loopPrimaryPath, loopSymbols } from "@/components/LoopGraph/loop-symbols"; +import "./ChecklistCurrent.css"; + +function nodeLabel(id: string, kind: string, terminalState?: string) { + if (id === "start") return "Start"; + if (kind === "terminal" && terminalState === "converged") return "Burn"; + return id.split("-").map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(" "); +} + +export function ChecklistCurrent({ data }: { data: ChecklistProgressData }) { + const item = data.active[0]; + if (!item?.loop) return null; + const run = data.loopRun?.itemRef.endsWith(`#${item.id}`) ? data.loopRun : null; + const topology = run ? itemTopologyProjection(run) : null; + const symbols = topology ? loopSymbols(topology.graph.nodes, { + start: "S", + ...Object.fromEntries(topology.graph.nodes + .filter((node) => node.kind === "terminal" && node.terminalState === "converged") + .map((node) => [node.id, "B"])), + }) : null; + const primaryIds = topology ? loopPrimaryPath(topology.graph) : []; + const orderedNodes = topology ? [ + ...primaryIds.map((id) => topology.graph.nodes.find((node) => node.id === id)).filter((node) => node !== undefined), + ...topology.graph.nodes.filter((node) => !primaryIds.includes(node.id)), + ] : []; + return
+
+ Loop {item.id} {run?.currentNode ?? (item.loop ? "Ready" : "Direct")} +
+ {run + ?
+ +
+ {orderedNodes.map((node) => + {symbols?.get(node.id)} {nodeLabel(node.id, node.kind, node.terminalState)} + )} +
+
+ : item.loop ? Assigned · not started : null} +
; +} diff --git a/dashboard/src/oven/ChecklistCurrent/index.ts b/dashboard/src/oven/ChecklistCurrent/index.ts new file mode 100644 index 00000000..d275e71f --- /dev/null +++ b/dashboard/src/oven/ChecklistCurrent/index.ts @@ -0,0 +1 @@ +export { ChecklistCurrent } from "./ChecklistCurrent"; diff --git a/dashboard/src/oven/ChecklistEventCards/ChecklistEventCards.tsx b/dashboard/src/oven/ChecklistEventCards/ChecklistEventCards.tsx index 025bd90c..971b751c 100644 --- a/dashboard/src/oven/ChecklistEventCards/ChecklistEventCards.tsx +++ b/dashboard/src/oven/ChecklistEventCards/ChecklistEventCards.tsx @@ -1,6 +1,6 @@ import type { ChecklistProgressData } from "@lib"; -import { EventCardList } from "@/components/ChecklistDashboard/ChecklistDashboard"; +import { ChecklistWorkspace } from "../ChecklistWorkspace"; export function ChecklistEventCards({ data }: { data: ChecklistProgressData }) { - return ; + return ; } diff --git a/dashboard/src/oven/ChecklistWidgets/ChecklistWidgets.test.ts b/dashboard/src/oven/ChecklistWidgets/ChecklistWidgets.test.ts index 7bc639e2..e6f8729d 100644 --- a/dashboard/src/oven/ChecklistWidgets/ChecklistWidgets.test.ts +++ b/dashboard/src/oven/ChecklistWidgets/ChecklistWidgets.test.ts @@ -3,8 +3,9 @@ import { test } from "node:test"; import { createElement } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { compileOven } from "../../../../src/ovens/dsl/oven-compile.mjs"; -import { EventCardList, ProgressLedger, ProgressPanel } from "@/components/ChecklistDashboard/ChecklistDashboard"; +import { ProgressLedger, ProgressPanel } from "@/components/ChecklistDashboard/ChecklistDashboard"; import { checklistFixture } from "@/components/ChecklistDashboard/ChecklistDashboard.fixture.mjs"; +import { ChecklistWorkspace } from "../ChecklistWorkspace"; import { assertDomEquivalent } from "../test-support/dom-normalize"; import { OvenNode } from "../runtime/OvenNode"; import { initOvenState, type OvenIr } from "../runtime/oven-reducer"; @@ -19,7 +20,7 @@ function renderWidget(kind: string) { test("checklist widget adapters preserve the exported dashboard subregions", () => { assertDomEquivalent(renderWidget("checklist-burn-panel"), renderToStaticMarkup(createElement(ProgressPanel, { data: checklistFixture }))); assertDomEquivalent(renderWidget("checklist-ledger"), renderToStaticMarkup(createElement(ProgressLedger, { data: checklistFixture }))); - assertDomEquivalent(renderWidget("checklist-event-cards"), renderToStaticMarkup(createElement(EventCardList, { data: checklistFixture }))); + assertDomEquivalent(renderWidget("checklist-event-cards"), renderToStaticMarkup(createElement(ChecklistWorkspace, { data: checklistFixture }))); }); test("box lowering preserves element, class, id, text, and children", () => { diff --git a/dashboard/src/oven/ChecklistWorkspace/ChecklistWorkspace.css b/dashboard/src/oven/ChecklistWorkspace/ChecklistWorkspace.css new file mode 100644 index 00000000..f428b74e --- /dev/null +++ b/dashboard/src/oven/ChecklistWorkspace/ChecklistWorkspace.css @@ -0,0 +1,110 @@ +.checklist-workspace { + display: grid; + width: 100%; + min-height: 330px; + margin-top: 12px; + border-top: 1px solid rgba(168, 168, 168, .15); + border-bottom: 1px solid rgba(168, 168, 168, .1); + grid-template-columns: minmax(220px, 26%) minmax(250px, 30%) minmax(360px, 44%); +} + +.checklist-workspace__column { min-width: 0; background: #111; } +.checklist-workspace__column + .checklist-workspace__column { border-left: 1px solid rgba(163, 172, 183, .1); } +.checklist-workspace__heading { + display: flex; + height: 36px; + padding: 0 12px; + border-bottom: 1px solid rgba(163, 172, 183, .1); + color: rgba(168, 168, 168, .62); + font: 12px/1 var(--dashboard-font); + align-items: center; + justify-content: space-between; +} + +.checklist-workspace__event-list, +.checklist-workspace__item-list { display: block; max-height: 390px; overflow: auto; } +.checklist-workspace__event { + display: grid; + min-height: 42px; + padding: 0 12px; + border-bottom: 1px solid rgba(163, 172, 183, .07); + grid-template-columns: 3ch minmax(0, 1fr) max-content; + align-items: center; + gap: 8px; +} +.checklist-workspace__event-id { color: rgba(97, 211, 148, .78); font-size: 12px; } +.checklist-workspace__event-title, +.checklist-workspace__item-copy span { + min-width: 0; + overflow: hidden; + color: rgba(220, 225, 232, .76); + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; +} +.checklist-workspace__meta, +.checklist-workspace__loop-label { color: rgba(168, 168, 168, .42); font-size: 10px; white-space: nowrap; } + +.checklist-workspace__item { + display: grid; + min-height: 48px; + padding: 0 12px; + border-bottom: 1px solid rgba(163, 172, 183, .07); + color: inherit; + grid-template-columns: 3ch minmax(0, 1fr) max-content; + align-items: center; + gap: 8px; + text-decoration: none; +} +.checklist-workspace__item:hover, +.checklist-workspace__item:focus-visible { background: rgba(255, 255, 255, .025); outline: none; } +.checklist-workspace__item.is-selected { background: rgba(90, 162, 255, .07); box-shadow: inset 2px 0 rgba(90, 162, 255, .7); } +.checklist-workspace__item-marker { color: rgba(168, 168, 168, .42); font-size: 11px; text-align: center; } +.checklist-workspace__item:first-child .checklist-workspace__item-marker { color: var(--status-green); } +.checklist-workspace__item-copy { display: grid; min-width: 0; gap: 3px; } +.checklist-workspace__item-copy b { color: rgba(232, 232, 232, .86); font-size: 12px; font-weight: 500; } + +.checklist-workspace__detail-body { padding: 14px 16px 12px; } +.checklist-workspace__detail-title { display: grid; grid-template-columns: 3ch minmax(0, 1fr); gap: 8px; } +.checklist-workspace__detail-title > span { color: var(--status-green); font-size: 12px; } +.checklist-workspace__detail-title h2 { margin: 0; color: rgba(232, 232, 232, .9); font: 14px/1.3 var(--dashboard-font); } +.checklist-workspace__fields { display: grid; margin: 12px 0 0; gap: 8px; } +.checklist-workspace__fields > div { display: grid; grid-template-columns: 8ch minmax(0, 1fr); gap: 10px; } +.checklist-workspace__fields dt { color: rgba(168, 168, 168, .5); font-size: 11px; } +.checklist-workspace__fields dd { margin: 0; color: rgba(210, 216, 224, .7); font-size: 12px; line-height: 1.35; overflow-wrap: anywhere; } +.checklist-workspace__detail-loop { margin-top: 14px; padding-top: 10px; border-top: 1px solid rgba(163, 172, 183, .08); } +.checklist-workspace__direct { margin-top: 14px; padding-top: 10px; border-top: 1px solid rgba(163, 172, 183, .08); color: rgba(168, 168, 168, .5); font-size: 11px; } +.checklist-workspace__loop-head { display: flex; color: rgba(168, 168, 168, .5); font-size: 10px; justify-content: space-between; } +.checklist-workspace__detail .loop-compact { + display: flex; + min-height: 88px; + margin: 0; + color: rgba(220, 225, 232, .8); + font-size: 12px; + align-items: center; + justify-content: center; +} +.checklist-workspace__detail .loop-legend { + display: grid; + margin-top: 4px; + padding-top: 8px; + border-top: 1px solid rgba(163, 172, 183, .07); + color: rgba(168, 168, 168, .55); + font-size: 10px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 5px 12px; +} +.checklist-workspace__detail .loop-legend__row { min-width: 0; gap: 5px; grid-template-columns: 2ch minmax(0, 1fr); } +.checklist-workspace__detail .loop-legend dd { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.checklist-workspace__empty { margin: 0; padding: 16px; color: rgba(168, 168, 168, .5); font-size: 12px; } + +@media (max-width: 900px) { + .checklist-workspace { grid-template-columns: minmax(220px, 38%) minmax(0, 62%); } + .checklist-workspace__events { grid-column: 1 / -1; grid-row: 2; border-top: 1px solid rgba(163, 172, 183, .1); } + .checklist-workspace__events .checklist-workspace__event-list { max-height: 210px; } +} + +@media (max-width: 620px) { + .checklist-workspace { display: block; } + .checklist-workspace__column + .checklist-workspace__column { border-top: 1px solid rgba(163, 172, 183, .1); border-left: 0; } +} diff --git a/dashboard/src/oven/ChecklistWorkspace/ChecklistWorkspace.tsx b/dashboard/src/oven/ChecklistWorkspace/ChecklistWorkspace.tsx new file mode 100644 index 00000000..a0895c8e --- /dev/null +++ b/dashboard/src/oven/ChecklistWorkspace/ChecklistWorkspace.tsx @@ -0,0 +1,106 @@ +import type { ChecklistItem, ChecklistProgressData, LoopRunProjection } from "@lib"; +import { compactAge, eventRows } from "@lib/checklist-adapter"; +import { itemTopologyProjection, LoopCompact, LoopLegend } from "@/components/LoopGraph"; +import "./ChecklistWorkspace.css"; + +function inspectedItem(data: ChecklistProgressData) { + return data.active.find((item) => item.id === data.selectedItemId) ?? data.active[0] ?? null; +} + +function previewRun(item: ChecklistItem, data: ChecklistProgressData): LoopRunProjection { + const live = data.loopRun?.itemRef.endsWith(`#${item.id}`) ? data.loopRun : null; + if (live) return live; + return { + schema: "burnlist-loop-read-projection@1", + runId: "preview", + itemRef: `item:preview#${item.id}`, + loopId: item.loop?.selector ?? "direct", + loopRevision: null, + createdAt: 0, + updatedAt: 0, + state: "prepared", + currentNode: "start", + attempt: 0, + cycle: 0, + revision: "preview", + budget: { + limits: { maxRounds: 0, maxMinutes: 0, maxAgentRuns: 0, maxCheckRuns: 0, maxTransitions: 0, maxOutputBytes: 0 }, + counters: { rounds: 0, agentRuns: 0, checkRuns: 0, transitions: 0, outputBytes: 0 }, + elapsedMilliseconds: 0, + journal: { maximum: 0, used: 0, remaining: 0 }, + }, + latestResult: null, + graph: item.loop?.graph ?? { entry: "start", nodes: [], edges: [] }, + transitions: [], + }; +} + +function EventsColumn({ data }: { data: ChecklistProgressData }) { + const rows = eventRows(data).slice(0, 10); + return
+
Events{rows.length}
+
+ {rows.map((item) =>
+ {item.id} + {item.title} + {compactAge(item.completedAt, data.generatedAt)} · {item.percent}% +
)} + {!rows.length &&

No completed events

} +
+
; +} + +function ItemsColumn({ data, selected }: { data: ChecklistProgressData; selected: ChecklistItem | null }) { + return
+
Items{data.active.length}
+ +
; +} + +const detailFields = [ + ["Action", "Action"], + ["Done when", "Done/delete when"], + ["Validate", "Validate"], + ["Files", "Files/search"], +] as const; + +function DetailColumn({ data, item }: { data: ChecklistProgressData; item: ChecklistItem | null }) { + if (!item) return

No active item

; + const run = previewRun(item, data); + const topology = item.loop?.graph ? itemTopologyProjection(run) : null; + const legendSymbols = topology ? { + start: "S", + ...Object.fromEntries(topology.graph.nodes.filter((node) => node.kind === "terminal" && node.terminalState === "converged").map((node) => [node.id, "B"])), + } : undefined; + return
+
Item detail{item.id}
+
+
{item.id}

{item.title}

+
{detailFields.map(([label, key]) => item.fields[key] ?
{label}
{item.fields[key]}
: null)}
+ {item.loop ?
+
Loop{item.loop.selector}
+ + {topology && } +
:
Direct implementation · no Loop assigned
} +
+
; +} + +export function ChecklistWorkspace({ data }: { data: ChecklistProgressData }) { + const selected = inspectedItem(data); + return
+ + + +
; +} diff --git a/dashboard/src/oven/ChecklistWorkspace/index.ts b/dashboard/src/oven/ChecklistWorkspace/index.ts new file mode 100644 index 00000000..83ee6c75 --- /dev/null +++ b/dashboard/src/oven/ChecklistWorkspace/index.ts @@ -0,0 +1 @@ +export { ChecklistWorkspace } from "./ChecklistWorkspace"; diff --git a/dashboard/src/oven/runtime/OvenNode.test.ts b/dashboard/src/oven/runtime/OvenNode.test.ts index 358607d8..a1421fad 100644 --- a/dashboard/src/oven/runtime/OvenNode.test.ts +++ b/dashboard/src/oven/runtime/OvenNode.test.ts @@ -28,6 +28,26 @@ test("OvenNode collection each scopes @item and follows paging and search", () = assert.match(render(node, state), /second/); assert.doesNotMatch(render(node, state), /first/); }); +test("OvenNode composes LoopGraph from root and item-scoped sources", () => { + const 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 rootNode: any = { kind: "loop-graph", attributes: { source: "/loopRun" }, children: [] }; + assert.match(render(rootNode, initOvenState(base, { loopRun })), /aria-current="step"/); + assert.match(render(rootNode, initOvenState(base, { loopRun })), /VERIFY/); + const itemNode: any = { + kind: "collection", attributes: { id: "items" }, + children: [{ kind: "each", attributes: {}, children: [{ kind: "loop-graph", attributes: { source: "@item/loopRun" }, children: [] }] }], + }; + assert.match(render(itemNode, initOvenState(base, { items: [{ name: "first", loopRun }] })), /aria-current="step"/); +}); + test("OvenNode sends mode-toggle callbacks through the closed dispatch", () => { const actions: OvenAction[] = []; const node: any = { kind: "mode-toggle", attributes: { id: "mode", ariaLabel: "Mode" }, children: [{ kind: "option", attributes: { value: "one", label: "One" } }, { kind: "option", attributes: { value: "two", label: "Two" } }] }; diff --git a/dashboard/src/oven/runtime/OvenNode.tsx b/dashboard/src/oven/runtime/OvenNode.tsx index 4a3cb2d4..4e497a11 100644 --- a/dashboard/src/oven/runtime/OvenNode.tsx +++ b/dashboard/src/oven/runtime/OvenNode.tsx @@ -12,6 +12,7 @@ import { LogTable } from "../LogTable"; import { formatRegistry } from "../OvenView/registries"; import { buildLogTableProps } from "./log-table-adapter"; import { ModelLabView, type ModelLabPayload } from "../ModelLabView"; +import { LoopGraph, type LoopGraphProjection } from "../../components/LoopGraph"; export type OvenNodeDef = { kind: string; attributes?: Record; bindings?: Record; children?: OvenNodeDef[] }; export type OvenNodeProps = { node: OvenNodeDef; ir: OvenIr; state: OvenState; dispatch: (action: OvenAction) => void; item?: unknown; path?: string }; @@ -25,10 +26,19 @@ function scopedNode(node: OvenNodeDef): OvenNodeDef { const pointer = (source: unknown) => typeof source !== "string" ? source : source === "@item" ? "/__ovenItem" : source.startsWith("@item/") ? `/__ovenItem${source.slice(5)}` : source.startsWith("/") || source === "" ? `/__ovenRoot${source || "/"}` : source; return { ...node, attributes: Object.fromEntries(Object.entries(attrs(node)).map(([key, value]) => [key, key === "source" ? pointer(value) : value])), bindings: Object.fromEntries(Object.entries(node.bindings ?? {}).map(([key, value]) => [key, value && typeof value === "object" ? { ...(value as object), source: pointer((value as { source?: unknown }).source) } : value])), children: (node.children ?? []).map(scopedNode) }; } +function runtimeSource(payload: unknown, item: unknown, source: unknown) { + if (typeof source !== "string") return undefined; + if (source === "@item") return item; + if (source.startsWith("@item/")) return resolvePointer(item, source.slice(5)); + return resolvePointer(payload, source); +} function staticView(node: OvenNodeDef, ir: OvenIr, root: unknown, item?: unknown) { const lowered = lowerOvenIr({ id: "runtime", theme: ir.theme, root: [item === undefined ? node : scopedNode(node)] }); const payload = item === undefined ? root : { __ovenRoot: root, __ovenItem: item }; - return ; + const sections = lowered.sections.length === 1 && lowered.sections[0].element === "div" && !lowered.sections[0].className + ? [{ ...lowered.sections[0], element: "fragment" as const }] + : lowered.sections; + return ; } function layoutStyle(node: OvenNodeDef): Record { const a = attrs(node); @@ -58,8 +68,12 @@ export function OvenNode({ node, ir, state, dispatch, item, path = "root" }: Ove } if (node.kind === "each") return <>{(node.children ?? []).map((child, index) => )}; if (node.kind === "model-lab-view") return ; + if (node.kind === "loop-graph") { + const run = runtimeSource(state.payload, item, attrs(node).source) as (LoopGraphProjection & { diagnostic?: "corrupt" | "stale" }) | null | undefined; + return ; + } if (node.kind === "log-table") return ; - if (["checklist-burn-panel", "checklist-ledger", "checklist-event-cards"].includes(node.kind)) return ; + if (["checklist-current", "checklist-burn-panel", "checklist-ledger", "checklist-event-cards"].includes(node.kind)) return ; if (["mode-toggle", "domain-tabs", "field-toolbar", "pagination"].includes(node.kind)) return ; if (["field-list", "refresh-status", "verdict-header", "metric-tiles", "domain-note", "frame-card"].includes(node.kind)) return ; if (node.kind === "box") return {(node.children ?? []).map((child, index) => )}; diff --git a/dashboard/src/oven/runtime/OvenRuntime.tsx b/dashboard/src/oven/runtime/OvenRuntime.tsx index f4301d0c..1bffc8ad 100644 --- a/dashboard/src/oven/runtime/OvenRuntime.tsx +++ b/dashboard/src/oven/runtime/OvenRuntime.tsx @@ -62,6 +62,10 @@ export function OvenRuntime({ ir, initialPayload, payload, controls, pages, init ?
{state.refresh.phase === "failed" ? `Showing the last canonical snapshot. ${String(state.refresh.error || "Canonical refresh failed.")}` : "Showing the last canonical snapshot while canonical data refreshes."}
: null; const staticView = !themedView && root.every(isStaticOvenDocument) ? : null; - const genericView = themedRegions(root, theme, (node, index) => ) ?? <>{root.map((node, index) => )}; + const regions = themedRegions(root, theme, (node, index) => ); + const genericBody = regions ?? <>{root.map((node, index) => )}; + const genericView = regions && (theme?.view.shellClassName || theme?.view.bodyClassName || theme?.view.bodyId) + ?
{genericBody}
+ : genericBody; return {staleState}{emptyState ?? themedView ?? staticView ?? genericView}; } diff --git a/dashboard/src/oven/runtime/theme-registry.ts b/dashboard/src/oven/runtime/theme-registry.ts index 04f622ad..3673b445 100644 --- a/dashboard/src/oven/runtime/theme-registry.ts +++ b/dashboard/src/oven/runtime/theme-registry.ts @@ -34,7 +34,7 @@ const checklist: OvenTheme = Object.freeze({ }), regions: Object.freeze([ Object.freeze({ kinds: Object.freeze(["kpi-strip"]), element: "section", className: "differential-overview checklist-overview" }), - Object.freeze({ kinds: Object.freeze(["checklist-ledger", "checklist-burn-panel"]), element: "div", className: "detail-workspace checklist-progress-workspace", props: Object.freeze({ "data-detail-tab": "dashboard" }) }), + Object.freeze({ kinds: Object.freeze(["checklist-ledger", "checklist-burn-panel", "checklist-current"]), element: "div", className: "detail-workspace checklist-progress-workspace", props: Object.freeze({ "data-detail-tab": "dashboard" }) }), Object.freeze({ kinds: Object.freeze(["checklist-event-cards"]), element: "fragment" }), ]), kpiItemVariants: Object.freeze({ current: "checklist-kpi-current" }), diff --git a/dashboard/src/oven/runtime/widget-adapters.tsx b/dashboard/src/oven/runtime/widget-adapters.tsx index 577cedaf..226ff15e 100644 --- a/dashboard/src/oven/runtime/widget-adapters.tsx +++ b/dashboard/src/oven/runtime/widget-adapters.tsx @@ -3,6 +3,7 @@ import { RefreshStatusChip } from "../RefreshStatusChip/RefreshStatusChip"; import { ChecklistBurnPanel } from "../ChecklistBurnPanel/ChecklistBurnPanel"; import { ChecklistEventCards } from "../ChecklistEventCards/ChecklistEventCards"; import { ChecklistLedger } from "../ChecklistLedger/ChecklistLedger"; +import { ChecklistCurrent } from "../ChecklistCurrent/ChecklistCurrent"; import { DomainNote } from "../DomainNote"; import { FrameCard } from "../FrameCard"; import { MetricTiles } from "../MetricTiles"; @@ -57,6 +58,7 @@ export function WidgetAdapter({ node, ir, state, dispatch }: { node: Node; ir: O export function ChecklistWidgetAdapter({ node, payload }: { node: Node; payload: unknown }) { const data = resolvePointer(payload, String(attrs(node).source ?? "/")) as any; + if (node.kind === "checklist-current") return ; if (node.kind === "checklist-burn-panel") return ; if (node.kind === "checklist-ledger") return ; if (node.kind === "checklist-event-cards") return ; diff --git a/dashboard/src/oven/test-support/run-oven-tests.mjs b/dashboard/src/oven/test-support/run-oven-tests.mjs index 407cb852..e27748fb 100644 --- a/dashboard/src/oven/test-support/run-oven-tests.mjs +++ b/dashboard/src/oven/test-support/run-oven-tests.mjs @@ -58,8 +58,8 @@ function discoverBundledTests(directory) { return files.sort(); } -const testEntries = discoverTests(ovenDir); -console.log(`=== Oven TypeScript tests (${testEntries.length}) ===`); +const testEntries = discoverTests(sourceDir); +console.log(`=== Dashboard TypeScript tests (${testEntries.length}) ===`); if (testEntries.length === 0) process.exit(0); @@ -76,7 +76,7 @@ try { packages: "external", sourcemap: "inline", outdir: outputDir, - outbase: ovenDir, + outbase: sourceDir, entryNames: "[dir]/[name]", outExtension: { ".js": ".mjs" }, alias, diff --git a/ovens/checklist/checklist.oven b/ovens/checklist/checklist.oven index 023f20c7..34a74272 100644 --- a/ovens/checklist/checklist.oven +++ b/ovens/checklist/checklist.oven @@ -11,5 +11,6 @@ + diff --git a/scripts/package-paths.json b/scripts/package-paths.json index 2012abda..6fb163f1 100644 --- a/scripts/package-paths.json +++ b/scripts/package-paths.json @@ -2,8 +2,8 @@ "LICENSE", "README.md", "bin/burnlist.mjs", - "dashboard/dist/assets/index-BHMojAeL.js", - "dashboard/dist/assets/index-qhZB46pA.css", + "dashboard/dist/assets/index-WF9rXGwV.css", + "dashboard/dist/assets/index-shfLdIoB.js", "dashboard/dist/favicon.svg", "dashboard/dist/index.html", "loops/review/instructions.md", @@ -140,8 +140,8 @@ "src/loops/dsl/package-read.mjs", "src/loops/events/projection-events.mjs", "src/loops/run/binder.mjs", - "src/loops/run/candidate.mjs", "src/loops/run/budgets.mjs", + "src/loops/run/candidate.mjs", "src/loops/run/controller.mjs", "src/loops/run/current-authority.mjs", "src/loops/run/launch-commit.mjs", diff --git a/scripts/verify-source-scan.mjs b/scripts/verify-source-scan.mjs index 152880b9..7775d2c8 100644 --- a/scripts/verify-source-scan.mjs +++ b/scripts/verify-source-scan.mjs @@ -1,5 +1,6 @@ const EXCLUDED_PREFIXES = [ ".git/", + ".burnlist/", ".claude/", ".local/", ".playwright-cli/", diff --git a/scripts/verify-source-scan.test.mjs b/scripts/verify-source-scan.test.mjs index 18dd8fbe..3c80f056 100644 --- a/scripts/verify-source-scan.test.mjs +++ b/scripts/verify-source-scan.test.mjs @@ -6,6 +6,7 @@ test("source leak scanning excludes local and nested checkout state", () => { for (const path of [ ".git/config", ".local/burnlist/state.json", + ".burnlist/loop-capabilities.json", ".worktrees/feature/.git", ".worktrees/feature/src/private.mjs", "node_modules/package/index.js", diff --git a/skills/burnlist/SKILL.md b/skills/burnlist/SKILL.md index 00372043..f4c73abe 100644 --- a/skills/burnlist/SKILL.md +++ b/skills/burnlist/SKILL.md @@ -204,6 +204,19 @@ Run lease. For live foreground work, the owner handles Ctrl-C: first interrupt requests pause after its child exits, second requests controlled stop. Do not claim that a separate CLI invocation can take over a live Run. +The canonical Checklist Oven keeps the progress-row Loop bound to the first +active item; inspecting another `#` changes only the three-column +Events / Items / Item detail workspace below it. A Loop-assigned current item +shows its compact topology beside Completion with the active node highlighted +and a graph-derived legend; a direct current item collapses that panel. Item +detail shows the selected item's contract, Loop preview or live Run, labelled +return paths, and a graph-derived node legend. Ovens may compose Burnlist's +core box-drawing renderer declaratively with +``, or with +`` inside a collection. The widget never +fetches, executes, or imports custom code; core transport refreshes it after +item-keyed Loop invalidation events. + Stage 1 labels fresh reviewer process, graph grammar, budgets, closed outcomes, and atomic canonical CLI writes `enforced`; ordinary drift checks are `detected-at-boundaries`; reviewer filesystem write denial is `supervised`. diff --git a/skills/burnlist/references/creating-ovens.md b/skills/burnlist/references/creating-ovens.md index 232716e2..f30cdf64 100644 --- a/skills/burnlist/references/creating-ovens.md +++ b/skills/burnlist/references/creating-ovens.md @@ -108,6 +108,7 @@ declared bounds. `panel` requires `id`. | `image-triptych`, `feed-list`, `diff-card`, `file-diff` | `image-triptych` and `feed-list` take `id` and `bind`. `diff-card` and `file-diff` take `id`, `source`, `format`, `optional`, `fallback`, `slot`, and may contain `bind`, `text`, `icon`. | | `refresh-status` / `streaming-diff-heading` | `refresh-status` takes `id`, `source`, `format`, `optional`, `fallback`, `slot`. `streaming-diff-heading` takes `id`, `session`, `back-href`. | | Checklist widgets | `checklist-burn-panel`, `checklist-ledger`, and `checklist-event-cards` each take `id`, `source`, `format`, `optional`, `fallback`, `slot`. | +| Loop graph | `loop-graph` requires `source` and optionally takes `id`, `title`, `format`, `optional`, `fallback`, `slot`. Use a root pointer for a global placement or `@item/loopRun` inside `each`; Burnlist owns rendering and live projection refresh. | | Differential widgets | `differential-kpi-strip`, `differential-log-table`, `progress-chart`, and `frame-delta-chart` each take `id`, `source`, `format`, `optional`, `fallback`, `slot`. `differential-empty-state` takes `id`, `title`. | Only registered icons may be used by `` or `kpi-item` @@ -120,7 +121,7 @@ Only registered icons may be used by `` or `kpi-item` | --- | --- | | `switch` / `case` | A switch takes `id`, `source`, `mode-from` and contains `case`; it requires exactly one of `source` or `mode-from`. A case takes `value`, `default`, and requires either `value` or `default="true"`. | | `collection` | Requires `id`, `source`, `item-key`, `paging`, `page-size`; it may also use `search-from`, `filter-from`, `sort-from`, and contains `each`, `field-list`, and/or `pagination`. `paging` is `client`, `server`, or `auto`. | -| `each` / `field-list` / `pagination` | `each` has no attributes and contains a grid, stack, panel, KPI item, section header, table, or switch. `field-list` takes `id`, `collection-from`, `mode-from` and contains `bind`. `pagination` requires `collection-from`, `page-sizes`, and must be inside a collection. | +| `each` / `field-list` / `pagination` | `each` has no attributes and contains a grid, stack, panel, KPI item, section header, table, `loop-graph`, or switch. `field-list` takes `id`, `collection-from`, `mode-from` and contains `bind`. `pagination` requires `collection-from`, `page-sizes`, and must be inside a collection. | | `field-toolbar` | Requires `id` and contains `search`, `mode-toggle`, `sort-toggle`, and/or `filter-toggle`. | | Toolbar controls | `search`: `id`, `placeholder`, `aria-label`, `match-fields`, `debounce-ms`; `mode-toggle`: `id`, `initial`, `aria-label`, with at least two `option` children; `option`: `value`, `label`; `sort-toggle`: `id`, `key`, `label`, `initial`, `requires-source`, `requires-value`, `unavailable-text`; `filter-toggle`: `id`, `key`, `label`, `initial`. | diff --git a/src/loops/__fixtures__/minimal-review-e2e-dom.golden.json b/src/loops/__fixtures__/minimal-review-e2e-dom.golden.json index 5c5f902f..c6ccc098 100644 --- a/src/loops/__fixtures__/minimal-review-e2e-dom.golden.json +++ b/src/loops/__fixtures__/minimal-review-e2e-dom.golden.json @@ -1,7 +1,27 @@ [ - {"checkpoint":"needs-human","domBytes":12386,"domSha256":"f1b61d53b386eac2517abb1a8e6f8780c996e884d1a3a9fadb539215f49182dd"}, - {"checkpoint":"paused","domBytes":11628,"domSha256":"6e2e705700cccc3f90713cbea6d4c99796723c35d39af21597ea69e66469c144"}, - {"checkpoint":"repair","domBytes":12494,"domSha256":"4eb6984ea066104d1ff2dc84d80f1e40e4db18c25936a97bcab4527721628844"}, - {"checkpoint":"converged","domBytes":12746,"domSha256":"0f68c9e8e272e2971472ea204e4c6cb5d6595e23c6e4d9a9d3f6a84ed6f79ee6"}, - {"checkpoint":"post-completion","domBytes":9212,"domSha256":"3d84894369b43754d20a2b134d3509fd8460bfd5dc52c0b52127b5bfc0897cf2"} + { + "checkpoint": "needs-human", + "domBytes": 10217, + "domSha256": "716f2668931ac3f80620c2c13cabed72a9b504383c6d5cb7d08daddf9faa013c" + }, + { + "checkpoint": "paused", + "domBytes": 10215, + "domSha256": "133792d445f5283fbe6c5d5385cec4402740cef7b937b179c1d44f5f5db66497" + }, + { + "checkpoint": "repair", + "domBytes": 10215, + "domSha256": "133792d445f5283fbe6c5d5385cec4402740cef7b937b179c1d44f5f5db66497" + }, + { + "checkpoint": "converged", + "domBytes": 10215, + "domSha256": "edb3d07570bf440ec273decce27b5acb31c458247ab4193426b72546f1fd089c" + }, + { + "checkpoint": "post-completion", + "domBytes": 8510, + "domSha256": "e66102e9fc69180db39b08ba34c490b2793216d1cbe102df0c44aa52619337de" + } ] diff --git a/src/loops/minimal-review-e2e.test.mjs b/src/loops/minimal-review-e2e.test.mjs index f539a549..b267ea7b 100644 --- a/src/loops/minimal-review-e2e.test.mjs +++ b/src/loops/minimal-review-e2e.test.mjs @@ -55,8 +55,14 @@ async function dashboardRenderer(t) { latestMaker: loopRun.latestMaker && { ...loopRun.latestMaker, at: base + 1_000, candidateId: alias(loopRun.latestMaker.candidateId) }, latestCheck: loopRun.latestCheck && { ...loopRun.latestCheck, at: base + 2_000, candidateId: alias(loopRun.latestCheck.candidateId) }, latestReviewer: loopRun.latestReviewer && { ...loopRun.latestReviewer, at: base + 3_000, candidateId: alias(loopRun.latestReviewer.candidateId) } }; + const active = stableRun ? [{ + id: stableRun.itemRef.split("#").at(-1), + title: "Loop-assigned item", + fields: {}, + loop: { selector: `loop:builtin:${stableRun.loopId}` }, + }] : []; const dom = serializeCanonical(normalize(parseHtml(withDeterministicTime(() => - renderToStaticMarkup(createElement(ChecklistDashboard, { data: { ...checklistFixture, loopRun: stableRun } })) )))); + renderToStaticMarkup(createElement(ChecklistDashboard, { data: { ...checklistFixture, active, loopRun: stableRun } })) )))); return { record: { checkpoint, domBytes: Buffer.byteLength(dom), domSha256: digest(dom) }, dom }; }; } @@ -90,7 +96,8 @@ test("M9 no-network CLI slice exposes interruption, repair, invalidation refetch assert.equal(escalationHttp.status, 200); const escalationProjection = JSON.parse(escalationHttp.body).loopRun; assert.deepEqual(escalationProjection, escalationInspection); const needsHumanUi = render("needs-human", escalationProjection); - assert.match(needsHumanUi.dom, /aria-label="Loop state: Needs human review"/u); + assert.match(needsHumanUi.dom, /
/u); + assert.match(needsHumanUi.dom, /aria-current="step"/u); assert.equal(existsSync(join(repo, ".local", "burnlist", "loop", "m2", "runs", Buffer.from(escalation).toString("hex"), "completion-receipt.json")), false); assert.match(readFileSync(planPath, "utf8"), /- \[ \] L29/u); diff --git a/src/loops/run/binder.test.mjs b/src/loops/run/binder.test.mjs index 55e720c9..b48ed576 100644 --- a/src/loops/run/binder.test.mjs +++ b/src/loops/run/binder.test.mjs @@ -8,6 +8,7 @@ import { loadBoundPolicy } from "./run-artifacts.mjs"; import { loadFrozenRecipe } from "../dsl/frozen.mjs"; import { ownerClaimId } from "./run-claim.mjs"; import { runStore } from "./run-store.mjs"; +import { presentRun } from "./read-projection.mjs"; import { createProductionRunAuthority, fixtureItemRef, fixtureRunId } from "./run-test-fixtures.mjs"; test("production creation binds direct Stage One profiles without Docker artifacts", async (t) => { @@ -106,6 +107,12 @@ test("stored production repair binds a fresh repository candidate and gives each t.after(() => rmSync(directory, { recursive: true, force: true })); const { repo } = createProductionRunAuthority(join(directory, "repo")), store = runStore(repo); const created = await createProductionRun({ repoRoot: repo, store, itemRef: fixtureItemRef, runId: fixtureRunId }); + const publicRun = presentRun(store.read(fixtureRunId)); + assert.deepEqual(publicRun.graph.nodes.find((node) => node.id === "implement").execution, + { profileId: "maker", model: "gpt-5.3-codex-spark", effort: "medium", authority: "write" }); + assert.deepEqual(publicRun.graph.nodes.find((node) => node.id === "review").execution, + { profileId: "reviewer", model: "gpt-5.3-codex-spark", effort: "medium", authority: "read" }); + assert.doesNotMatch(JSON.stringify(publicRun), /\/bin\/sh|executableDigest|adapter/u); const outcomes = ["complete", "reject", "complete", "approve"], reviewerPrompts = []; let writes = 0; const startAgent = ({ prompt }) => { const values = Object.fromEntries(prompt.split("\n").filter((line) => line.includes("=")).map((line) => line.split(/=(.*)/su).slice(0, 2))); diff --git a/src/loops/run/read-projection.mjs b/src/loops/run/read-projection.mjs index e13c6fc0..f76ec38f 100644 --- a/src/loops/run/read-projection.mjs +++ b/src/loops/run/read-projection.mjs @@ -11,8 +11,34 @@ import { runStore } from "./run-store.mjs"; const MAX_RUNS = 128; const fail = (message) => { throw Object.assign(new Error(`Run projection: ${message}`), { code: "ERUN_PROJECTION" }); }; -function publicNode(node) { - return { id: node.id, kind: node.kind }; +function publicNode(node, routes = []) { + const common = { id: node.id, kind: node.kind }; + if (node.kind === "agent") { + const resolved = routes.find((entry) => entry.route === node.route); + return { + ...common, + role: node.role, + authority: node.authority, + execution: resolved ? { + profileId: resolved.profileId, + model: resolved.model, + effort: resolved.effort, + authority: resolved.authority, + } : null, + }; + } + if (node.kind === "check") return { ...common, capability: node.capability }; + if (node.kind === "gate") return { ...common, gateKind: node.gateKind }; + if (node.kind === "terminal") return { ...common, terminalState: node.state }; + return common; +} + +export function presentGraph(graph, routes = []) { + return Object.freeze({ + entry: graph.entry, + nodes: graph.nodes.map((node) => publicNode(node, routes)), + edges: graph.edges.map(({ from, on, to }) => ({ from, on, to })), + }); } export function presentRun(replay) { @@ -50,11 +76,7 @@ export function presentRun(replay) { elapsedMilliseconds: replay.execution.budget.elapsedMilliseconds, journal: replay.execution.budget.journal, }, - graph: { - entry: replay.graph.entry, - nodes: replay.graph.nodes.map(publicNode), - edges: replay.graph.edges.map(({ from, on, to }) => ({ from, on, to })), - }, + graph: presentGraph(replay.graph, replay.agentRoutes), transitions, }); } @@ -107,6 +129,8 @@ export function readLatestRunForItem({ repoRoot, itemRef, markdown = null, itemI } if (current && !selected) fail("current Run is unavailable", "ECURRENT"); if (!selected) return null; - selected.loopIdentity = runStore(repoRoot).read(selected.projection.runId).loopIdentity; + const stored = runStore(repoRoot).read(selected.projection.runId); + selected.loopIdentity = stored.loopIdentity; + selected.agentRoutes = stored.agentRoutes; return presentRun(selected); } diff --git a/src/loops/run/run-store.mjs b/src/loops/run/run-store.mjs index cb7879bd..a6c45c67 100644 --- a/src/loops/run/run-store.mjs +++ b/src/loops/run/run-store.mjs @@ -10,6 +10,7 @@ import { parseBoundedObject } from "../contracts/contract.mjs"; import { publishLoopProjectionInvalidation } from "../events/projection-events.mjs"; import { currentRunAuthority } from "./current-authority.mjs"; import { loadFrozenRecipe } from "../dsl/frozen.mjs"; +import { loadBoundPolicy } from "./run-artifacts.mjs"; const fail = (message, code = "ERUN_STORE") => { throw Object.assign(new Error(`Run store: ${message}`), { code }); }; const runName = (id) => Buffer.from(id).toString("hex"); @@ -24,18 +25,28 @@ export function runStore(repoRoot, { clock = () => Date.now(), random = randomBy const journal = readJournal(journalFor(id)), folded = foldRun(journal); if (folded.projection.runId !== id) fail("run identity mismatch"); let loopIdentity = Object.freeze({ loopId: folded.graph.id, loopRevision: null }); + let agentRoutes = Object.freeze([]); if (existsSync(authorityPath(id))) { try { const authority = readAuthority(id), frozen = loadFrozenRecipe(Buffer.from(authority.frozenRecipe, "base64")); if (authority.itemRef !== folded.projection.itemRef) fail("sealed authority item does not match Run journal", "EAUTHORITY"); if (JSON.stringify(frozen.ir) !== JSON.stringify(folded.graph)) fail("sealed recipe does not match Run graph", "EAUTHORITY"); + const policy = loadBoundPolicy(Buffer.from(authority.policy, "base64")).policy; loopIdentity = Object.freeze({ loopId: frozen.ir.id, loopRevision: frozen.revisions.executable }); + agentRoutes = Object.freeze(policy.routes.map(({ route, profile }) => Object.freeze({ + route, + profileId: profile.id, + adapter: profile.adapter, + model: profile.model, + effort: profile.effort, + authority: profile.authority, + }))); } catch (error) { if (error?.code === "EAUTHORITY") throw error; fail("sealed dispatch authority is corrupt", "EAUTHORITY"); } } else if (journal[0].value.payload.authorityRequired) fail("sealed dispatch authority is unavailable", "EAUTHORITY"); - return Object.freeze({ runId: id, journal, loopIdentity, ...folded }); + return Object.freeze({ runId: id, journal, loopIdentity, agentRoutes, ...folded }); }; const retainsTerminalReserve = (current, writes = 1) => current.projection.sequence + writes < MAX_JOURNAL_RECORDS; const terminalKind = { converged: "converged", "needs-human": "lost", failed: "error", stopped: "cancelled", "budget-exhausted": "exhausted" }; diff --git a/src/ovens/dsl/oven-compile.test.mjs b/src/ovens/dsl/oven-compile.test.mjs index a70eb071..e38a20e7 100644 --- a/src/ovens/dsl/oven-compile.test.mjs +++ b/src/ovens/dsl/oven-compile.test.mjs @@ -28,3 +28,14 @@ test("oven version requires major.minor.patch", () => { assert.ok(result.diagnostics.some((diagnostic) => diagnostic.code === "SCALAR_VERSION"), JSON.stringify(result.diagnostics)); } }); + +test("loop-graph is a closed root or item-scoped Oven component", () => { + const root = compileOven(''); + assert.equal(root.ok, true, JSON.stringify(root.diagnostics)); + assert.ok(root.ir.requirements.components.includes("loop-graph")); + const item = compileOven(''); + assert.equal(item.ok, true, JSON.stringify(item.diagnostics)); + const missing = compileOven(''); + assert.equal(missing.ok, false); + assert.ok(missing.diagnostics.some((diagnostic) => diagnostic.code === "GRAMMAR_REQUIRED")); +}); diff --git a/src/ovens/dsl/oven-grammar.mjs b/src/ovens/dsl/oven-grammar.mjs index da0f3f9b..b2a6b46f 100644 --- a/src/ovens/dsl/oven-grammar.mjs +++ b/src/ovens/dsl/oven-grammar.mjs @@ -1,19 +1,19 @@ const common = ["id"]; const sourceBinding = ["source", "format", "optional", "fallback", "slot"]; export const ELEMENTS = Object.freeze({ - oven: { parents: [], attrs: ["id", "version", "contract", "refresh-seconds", "theme"], children: ["box", "grid", "stack", "panel", "kpi-strip", "section-header", "log-table", "checklist-burn-panel", "checklist-ledger", "checklist-event-cards", "collection", "field-toolbar", "switch", "metric-tiles", "verdict-header", "domain-tabs", "domain-note", "frame-card", "image-triptych", "feed-list", "diff-card", "file-diff", "streaming-diff-heading", "refresh-status", "differential-kpi-strip", "differential-log-table", "progress-chart", "frame-delta-chart", "differential-empty-state", "model-lab-view"] }, - box: { attrs: [...common, "element", "class", "text", "data-detail-tab"], children: ["box", "grid", "stack", "panel", "kpi-strip", "kpi-item", "section-header", "log-table", "checklist-burn-panel", "checklist-ledger", "checklist-event-cards", "collection", "field-toolbar", "switch", "metric-tiles", "verdict-header", "domain-tabs", "domain-note", "frame-card", "image-triptych", "feed-list", "diff-card", "file-diff", "refresh-status"] }, - grid: { attrs: [...common, "columns", "rows", "row-height"], children: ["box", "panel", "stack", "kpi-strip", "section-header", "log-table", "checklist-burn-panel", "checklist-ledger", "checklist-event-cards", "collection", "field-toolbar", "switch"] }, - stack: { attrs: [...common, "direction", "gap"], children: ["box", "grid", "panel", "stack", "kpi-strip", "kpi-item", "section-header", "log-table", "checklist-burn-panel", "checklist-ledger", "checklist-event-cards", "collection", "field-toolbar", "switch", "refresh-status"] }, - panel: { attrs: ["id", "title", "column", "row", "column-span", "row-span"], children: ["box", "grid", "stack", "kpi-strip", "kpi-item", "section-header", "log-table", "checklist-burn-panel", "checklist-ledger", "checklist-event-cards", "collection", "field-toolbar", "switch", "mode-toggle"] }, - switch: { attrs: [...common, "mode-from", "source"], children: ["case"] }, case: { attrs: ["value", "default"], children: ["grid", "stack", "panel", "kpi-strip", "kpi-item", "section-header", "log-table", "collection", "field-toolbar", "mode-toggle", "switch", "refresh-status", "differential-kpi-strip", "differential-log-table", "progress-chart", "frame-delta-chart", "differential-empty-state"] }, - collection: { attrs: ["id", "source", "item-key", "search-from", "filter-from", "sort-from", "paging", "page-size"], children: ["each", "field-list", "pagination"] }, each: { attrs: [], children: ["grid", "stack", "panel", "kpi-item", "section-header", "log-table", "switch"] }, + oven: { parents: [], attrs: ["id", "version", "contract", "refresh-seconds", "theme"], children: ["box", "grid", "stack", "panel", "kpi-strip", "section-header", "log-table", "loop-graph", "checklist-current", "checklist-burn-panel", "checklist-ledger", "checklist-event-cards", "collection", "field-toolbar", "switch", "metric-tiles", "verdict-header", "domain-tabs", "domain-note", "frame-card", "image-triptych", "feed-list", "diff-card", "file-diff", "streaming-diff-heading", "refresh-status", "differential-kpi-strip", "differential-log-table", "progress-chart", "frame-delta-chart", "differential-empty-state", "model-lab-view"] }, + box: { attrs: [...common, "element", "class", "text", "data-detail-tab"], children: ["box", "grid", "stack", "panel", "kpi-strip", "kpi-item", "section-header", "log-table", "loop-graph", "checklist-burn-panel", "checklist-ledger", "checklist-event-cards", "collection", "field-toolbar", "switch", "metric-tiles", "verdict-header", "domain-tabs", "domain-note", "frame-card", "image-triptych", "feed-list", "diff-card", "file-diff", "refresh-status"] }, + grid: { attrs: [...common, "columns", "rows", "row-height"], children: ["box", "panel", "stack", "kpi-strip", "section-header", "log-table", "loop-graph", "checklist-burn-panel", "checklist-ledger", "checklist-event-cards", "collection", "field-toolbar", "switch"] }, + stack: { attrs: [...common, "direction", "gap"], children: ["box", "grid", "panel", "stack", "kpi-strip", "kpi-item", "section-header", "log-table", "loop-graph", "checklist-burn-panel", "checklist-ledger", "checklist-event-cards", "collection", "field-toolbar", "switch", "refresh-status"] }, + panel: { attrs: ["id", "title", "column", "row", "column-span", "row-span"], children: ["box", "grid", "stack", "kpi-strip", "kpi-item", "section-header", "log-table", "loop-graph", "checklist-burn-panel", "checklist-ledger", "checklist-event-cards", "collection", "field-toolbar", "switch", "mode-toggle"] }, + switch: { attrs: [...common, "mode-from", "source"], children: ["case"] }, case: { attrs: ["value", "default"], children: ["grid", "stack", "panel", "kpi-strip", "kpi-item", "section-header", "log-table", "loop-graph", "collection", "field-toolbar", "mode-toggle", "switch", "refresh-status", "differential-kpi-strip", "differential-log-table", "progress-chart", "frame-delta-chart", "differential-empty-state"] }, + collection: { attrs: ["id", "source", "item-key", "search-from", "filter-from", "sort-from", "paging", "page-size"], children: ["each", "field-list", "pagination"] }, each: { attrs: [], children: ["grid", "stack", "panel", "kpi-item", "section-header", "log-table", "loop-graph", "switch"] }, bind: { attrs: ["prop", "source", "format", "optional", "fallback"], children: [] }, text: { attrs: ["slot", "text", "source", "format", "optional", "fallback"], children: [] }, icon: { attrs: ["slot", "name"], children: [] }, column: { attrs: ["label", "source", "format", "optional", "fallback", "tone"], children: [] }, "kpi-strip": { attrs: [...common, "aria-label", "class", "title"], children: ["kpi-item"] }, "kpi-item": { attrs: [...common, "class", "heading", "title", "value", "icon", "variant", ...sourceBinding], children: ["bind", "text", "icon", "progress-donut", "burn-donut", "waffle-metric", "progress-value"] }, - "progress-donut": { attrs: sourceBinding, children: [] }, "burn-donut": { attrs: sourceBinding, children: [] }, "waffle-metric": { attrs: sourceBinding, children: [] }, "progress-value": { attrs: ["done", "total", "percent"], children: [] }, "checklist-burn-panel": { attrs: sourceBinding, children: [] }, "checklist-ledger": { attrs: sourceBinding, children: [] }, "checklist-event-cards": { attrs: sourceBinding, children: [] }, "log-table": { attrs: [...common, "class", "title", "source", "empty-text"], children: ["column"] }, "section-header": { attrs: [...common, "class", "title", ...sourceBinding], children: ["bind", "text", "icon"] }, "streaming-diff-heading": { attrs: [...common, "session", "back-href"], children: [] }, + "progress-donut": { attrs: sourceBinding, children: [] }, "burn-donut": { attrs: sourceBinding, children: [] }, "waffle-metric": { attrs: sourceBinding, children: [] }, "progress-value": { attrs: ["done", "total", "percent"], children: [] }, "checklist-current": { attrs: sourceBinding, children: [] }, "checklist-burn-panel": { attrs: sourceBinding, children: [] }, "checklist-ledger": { attrs: sourceBinding, children: [] }, "checklist-event-cards": { attrs: sourceBinding, children: [] }, "log-table": { attrs: [...common, "class", "title", "source", "empty-text"], children: ["column"] }, "section-header": { attrs: [...common, "class", "title", ...sourceBinding], children: ["bind", "text", "icon"] }, "streaming-diff-heading": { attrs: [...common, "session", "back-href"], children: [] }, "field-list": { attrs: [...common, "collection-from", "mode-from"], children: ["bind"] }, "metric-tiles": { attrs: [...common, "source", "selection-from"], children: ["bind"] }, "verdict-header": { attrs: common, children: ["bind"] }, "domain-tabs": { attrs: ["id", "source", "initial-source", "format"], children: [] }, "domain-note": { attrs: [...common, "source", "selection-from"], children: ["bind"] }, "frame-card": { attrs: [...common, ...sourceBinding, "selection-from"], children: ["bind", "text", "icon"] }, "image-triptych": { attrs: common, children: ["bind"] }, "feed-list": { attrs: common, children: ["bind"] }, "diff-card": { attrs: [...common, ...sourceBinding], children: ["bind", "text", "icon"] }, "file-diff": { attrs: [...common, ...sourceBinding], children: ["bind", "text", "icon"] }, "refresh-status": { attrs: [...common, ...sourceBinding], children: [] }, "differential-kpi-strip": { attrs: [...common, ...sourceBinding], children: [] }, "differential-log-table": { attrs: [...common, ...sourceBinding], children: [] }, "progress-chart": { attrs: [...common, ...sourceBinding], children: [] }, "frame-delta-chart": { attrs: [...common, ...sourceBinding], children: [] }, "differential-empty-state": { attrs: [...common, "title"], children: [] }, - "model-lab-view": { attrs: [...common, "source"], children: [] }, + "model-lab-view": { attrs: [...common, "source"], children: [] }, "loop-graph": { attrs: [...common, "title", ...sourceBinding], children: [] }, "field-toolbar": { attrs: ["id"], children: ["search", "mode-toggle", "sort-toggle", "filter-toggle"] }, "mode-toggle": { attrs: ["id", "initial", "aria-label"], children: ["option"] }, option: { attrs: ["value", "label"], children: [] }, search: { attrs: ["id", "placeholder", "aria-label", "match-fields", "debounce-ms"], children: [] }, "sort-toggle": { attrs: ["id", "key", "label", "initial", "requires-source", "requires-value", "unavailable-text"], children: [] }, "filter-toggle": { attrs: ["id", "key", "label", "initial"], children: [] }, pagination: { attrs: ["collection-from", "page-sizes"], children: [] }, }); export const COMPONENTS = new Set(Object.keys(ELEMENTS).filter((k) => !["oven","grid","stack","panel","switch","case","collection","each","bind","text","icon","column","field-toolbar","mode-toggle","option","search","sort-toggle","filter-toggle","pagination"].includes(k))); diff --git a/src/ovens/dsl/oven-validate.mjs b/src/ovens/dsl/oven-validate.mjs index 46193f1d..d40034aa 100644 --- a/src/ovens/dsl/oven-validate.mjs +++ b/src/ovens/dsl/oven-validate.mjs @@ -52,7 +52,7 @@ export function validateOven(ast, { file = "" } = {}) { gridChecks(ast, d); switchChecks(ast, d); propChecks(ast, d); interactionChecks(ast, d); return { ok: d.list.length === 0, diagnostics: d.list, ids, collections }; } -function requiredAttrs(name) { return ({ oven: ["id", "version", "contract", "theme"], box: ["element"], grid: ["columns"], panel: ["id"], collection: ["id", "source", "item-key", "paging", "page-size"], bind: ["prop", "source"], icon: ["slot", "name"], column: ["label", "source"], "log-table": ["source"], "model-lab-view": ["source"], "mode-toggle": ["id", "initial", "aria-label"], option: ["value", "label"], search: ["id", "placeholder", "aria-label", "match-fields"], "sort-toggle": ["id", "key", "label", "initial"], "filter-toggle": ["id", "key", "label", "initial"], pagination: ["collection-from", "page-sizes"], "field-toolbar": ["id"] })[name] ?? []; } +function requiredAttrs(name) { return ({ oven: ["id", "version", "contract", "theme"], box: ["element"], grid: ["columns"], panel: ["id"], collection: ["id", "source", "item-key", "paging", "page-size"], bind: ["prop", "source"], icon: ["slot", "name"], column: ["label", "source"], "log-table": ["source"], "model-lab-view": ["source"], "loop-graph": ["source"], "mode-toggle": ["id", "initial", "aria-label"], option: ["value", "label"], search: ["id", "placeholder", "aria-label", "match-fields"], "sort-toggle": ["id", "key", "label", "initial"], "filter-toggle": ["id", "key", "label", "initial"], pagination: ["collection-from", "page-sizes"], "field-toolbar": ["id"] })[name] ?? []; } function gridChecks(ast, d) { walk(ast, (grid) => { if (grid.name !== "grid") return; const cols = +grid.attrs.columns, rows = +(grid.attrs.rows ?? 0), boxes = []; for (const panel of grid.children.filter((x) => x.name === "panel" && x.attrs.column)) { const x = +panel.attrs.column, y = +panel.attrs.row, w = +(panel.attrs["column-span"] ?? 1), h = +(panel.attrs["row-span"] ?? 1); if (x + w - 1 > cols || (rows && y + h - 1 > rows)) add(d, "STRUCTURE_GRID_BOUNDS", "Panel is outside grid bounds", panel); for (const b of boxes) if (x <= b.x + b.w - 1 && b.x <= x + w - 1 && y <= b.y + b.h - 1 && b.y <= y + h - 1) add(d, "STRUCTURE_GRID_OVERLAP", "Panels overlap", panel); boxes.push({ x, y, w, h }); } }); } function switchChecks(ast, d) { walk(ast, (node) => { if (node.name !== "switch") return; const cases = node.children.filter((x) => x.name === "case"), values = new Set(), defaults = cases.filter((x) => x.attrs.default === "true"); if (!cases.length) add(d, "STRUCTURE_SWITCH", "Switch requires a case", node); if (defaults.length > 1) add(d, "STRUCTURE_SWITCH", "Switch may have only one default", node); for (const c of cases) { if (c.attrs.value && values.has(c.attrs.value)) add(d, "STRUCTURE_SWITCH", "Switch case values must be unique", c); values.add(c.attrs.value); } }); } function propChecks(ast, d) { walk(ast, (node) => { const required = REQUIRED_PROPS[node.name]; if (!required) return; const bound = node.children.filter((x) => x.name === "bind").map((x) => x.attrs.prop); for (const prop of required) if (bound.filter((x) => x === prop).length !== 1) add(d, "PROPS_REQUIRED", `<${node.name}> requires exactly one bind for ${prop}`, node); }); } diff --git a/src/server/burnlist-dashboard-server.mjs b/src/server/burnlist-dashboard-server.mjs index 2917b740..1bc281f3 100644 --- a/src/server/burnlist-dashboard-server.mjs +++ b/src/server/burnlist-dashboard-server.mjs @@ -36,7 +36,8 @@ import { starterOvenSource } from "../ovens/oven-starter.mjs"; import "../ovens/built-in-handlers.mjs"; import { getOvenHandler, listOvenHandlers } from "../ovens/oven-registry.mjs"; import { genericJsonHandler } from "../ovens/handlers/generic-json-handler.mjs"; -import { readLatestRunForItem } from "../loops/run/read-projection.mjs"; +import { presentGraph, readLatestRunForItem } from "../loops/run/read-projection.mjs"; +import { assignmentStore } from "../loops/assignment/store.mjs"; import { buildRepoMapAsync } from "./repo-map.mjs"; import { createOvenJsonSnapshotStore, OVEN_JSON_CACHE_MAX_BYTES } from "./oven-json-snapshot.mjs"; import { discoverBurnlistSummaries } from "./burnlist-discovery.mjs"; @@ -993,7 +994,7 @@ function selectedBurnlist(url) { return { error: active.length ? "Select a Burnlist." : "No active Burnlist found.", burnlists }; } -function payloadForPlan(selection) { +function payloadForPlan(selection, selectedItemId = null) { const plan = parsePlan(selection.planPath, maxPlanBytes); const goal = documentPayloadForPlan(selection.planPath, "goal.md", "Goal", maxPlanBytes); const completedLog = documentPayloadForPlan(selection.planPath, "completed.md", "Completed log", maxPlanBytes); @@ -1035,13 +1036,36 @@ function payloadForPlan(selection) { title: plan.title, planPath: selection.planPath, planLabel: plan.planLabel, + selectedItemId: plan.items.some((item) => item.id === selectedItemId) ? selectedItemId : plan.items[0]?.id ?? null, total, done, remaining, percent, warnings: issues, goal, - active: plan.items, + active: plan.items.map((item) => { + let assignment = null; + try { assignment = loopAssignmentForItem(plan.markdown, item.id); } catch {} + let graph = null; + if (assignment) { + try { + const artifact = assignmentStore(selection.repoRoot).load(assignment["Assignment-Id"]); + if (artifact.itemRef === `item:${burnlistId}#${item.id}` + && artifact.executionRevision === assignment["Execution-Revision"] + && artifact.packageRevision === assignment["Package-Revision"]) graph = presentGraph(artifact.frozen.ir); + } catch {} + } + return { + ...item, + loop: assignment ? { + selector: assignment.Selector, + assignmentId: assignment["Assignment-Id"], + executionRevision: assignment["Execution-Revision"], + packageRevision: assignment["Package-Revision"], + graph, + } : null, + }; + }), completed: plan.completed.map((entry) => ({ ...entry, detail: completedDetails.get(entry.id)?.detail ?? "", @@ -1053,9 +1077,12 @@ function payloadForPlan(selection) { }; } -function loopProjectionForPlan(selection) { +function loopProjectionForPlan(selection, requestedItemId = null) { const plan = parsePlan(selection.planPath, maxPlanBytes); - const currentItem = plan.items[0]; + const currentItem = requestedItemId + ? plan.items.find((item) => item.id === requestedItemId) + : plan.items.find((item) => loopAssignmentForItem(plan.markdown, item.id)); + if (requestedItemId && !currentItem) throw Object.assign(new Error("Loop item is not active in the selected Burnlist"), { code: "EITEM" }); const assignment = currentItem ? loopAssignmentForItem(plan.markdown, currentItem.id) : null; if (!currentItem || !assignment) return null; return readLatestRunForItem({ @@ -1282,7 +1309,7 @@ const server = createServer(async (req, res) => { return; } try { - json(res, 200, payloadForPlan(selected.burnlist)); + json(res, 200, payloadForPlan(selected.burnlist, url.searchParams.get("item"))); } catch (err) { json(res, 500, { error: err.message }); } @@ -1294,10 +1321,10 @@ const server = createServer(async (req, res) => { if (!selected.burnlist) return json(res, 409, { error: selected.error }); try { // This representation is deliberately only the sanitized canonical projection. - serveLoopProjection(req, res, loopProjectionForPlan(selected.burnlist)); + serveLoopProjection(req, res, loopProjectionForPlan(selected.burnlist, url.searchParams.get("item"))); } catch (error) { - const status = error?.code === "EAMBIGUOUS" || error?.code === "ECORRUPT" || error?.code === "ERUN_PROJECTION" || error?.code === "EAUTHORITY" ? 409 : 500; - json(res, status, { error: status === 409 ? "Loop projection is unavailable; retaining the last verified projection." : "Loop projection is unavailable." }); + const status = error?.code === "EITEM" ? 404 : error?.code === "EAMBIGUOUS" || error?.code === "ECORRUPT" || error?.code === "ERUN_PROJECTION" || error?.code === "EAUTHORITY" ? 409 : 500; + json(res, status, { error: status === 404 ? error.message : status === 409 ? "Loop projection is unavailable; retaining the last verified projection." : "Loop projection is unavailable." }); } return; } diff --git a/src/server/run-routes.test.mjs b/src/server/run-routes.test.mjs index ba0ba069..74676f30 100644 --- a/src/server/run-routes.test.mjs +++ b/src/server/run-routes.test.mjs @@ -49,6 +49,13 @@ test("selected progress remains independent from the sanitized read-only Loop pr assert.equal(left.budget.limits.maxRounds, 3); assert.equal(left.state, "converged"); assert.equal(left.currentNode, "completed"); + const itemId = fixtureItemRef.split("#")[1]; + const scoped = await httpRequest(baseUrl, `/api/loop-projection?plan=${encodeURIComponent(planPath)}&item=${encodeURIComponent(itemId)}`, { method: "GET" }); + assert.equal(scoped.status, 200); + assert.equal(JSON.parse(scoped.body).loopRun.itemRef, fixtureItemRef); + const unknown = await httpRequest(baseUrl, `/api/loop-projection?plan=${encodeURIComponent(planPath)}&item=NOT-ACTIVE`, { method: "GET" }); + assert.equal(unknown.status, 404); + assert.match(JSON.parse(unknown.body).error, /not active/u); assert.deepEqual(left.latestResult, { kind: "approve", summary: "approve" }); for (const result of [left.latestMaker, left.latestCheck, left.latestReviewer]) { assert.equal(typeof result?.summary, "string"); @@ -66,7 +73,10 @@ test("selected progress remains independent from the sanitized read-only Loop pr { from: "converged", outcome: "pass", to: "completed" }, ]); const serialized = JSON.stringify(left); - for (const forbidden of ["invocationId", "lease", "prompt", "route", "authority"]) assert.doesNotMatch(serialized, new RegExp(forbidden, "u")); + assert.equal(left.graph.nodes.find((node) => node.id === "implement").authority, "write"); + assert.equal(left.graph.nodes.find((node) => node.id === "review").authority, "read"); + assert.equal(left.graph.nodes.find((node) => node.id === "verify").capability, "repo-verify"); + for (const forbidden of ["invocationId", "lease", "prompt", "route", "binary", "adapter"]) assert.doesNotMatch(serialized, new RegExp(forbidden, "u")); assert.equal((await httpRequest(baseUrl, url, { method: "POST" })).status, 405); const etag = projection.headers.etag; assert.match(etag, /^W\/"loop-[a-f0-9]{64}"$/u); diff --git a/website/src/content/docs/loops.mdx b/website/src/content/docs/loops.mdx index 5b30169c..ebc6a0fa 100644 --- a/website/src/content/docs/loops.mdx +++ b/website/src/content/docs/loops.mdx @@ -142,10 +142,45 @@ checklist and ledger under its lifecycle lock, and records an idempotent receipt. Repeating `complete` returns the existing receipt without burning the item twice. -The Checklist page is read-only. For a selected Loop item it exposes the graph -with the current node, attempt and cycle, latest result summary, -ordered transitions, and paused, error, and terminal states. It has no Run or -completion controls. +The canonical Checklist Oven is read-only. Its progress row always follows the +first active checklist item, even while a different item is inspected through +an item deep link (`#`). A current item with a Loop gets a compact +box-drawing graph beside Completion; its active node is highlighted and its +footer legend is derived from the displayed graph. Direct items have no graph, +so the Loop panel collapses and Completion uses the available width. + +Below that row, the Checklist Oven presents three compact columns: completed +events, the remaining item queue, and the inspected item detail. Selecting an +item changes only the detail column. The detail includes the item's action, +completion criterion, validation, file hints, assigned Loop, outcome-labelled +return paths, and a graph-derived node legend. An assigned item without a Run +uses its frozen assignment graph as a preview; an unassigned item is explicitly +labelled as direct execution. Transient stale-snapshot notices float above the +page so refreshes do not reflow or flicker the layout. + +The graph vocabulary is semantic: `S` is Start, a converged terminal is `B` for +Burn, and agent, check, review, and gate symbols come from the actual topology. +For example, the built-in review Loop renders `S`, `I`, `V`, `R`, and `B`; a +custom gate or branch adds its own visible symbol instead of inheriting a +hard-coded legend. The current node remains highlighted without changing graph +geometry. + +The Checklist Oven declares these regions in +`ovens/checklist/checklist.oven` through built-in declarative widgets. It does +not import custom UI or bypass the Oven runtime. It has no Run or completion +controls. Custom Ovens can compose the same core renderer with +`` at the root or +`` inside an item collection. The widget +does not fetch or execute code; the dashboard transport refetches canonical +state after item-keyed Loop invalidations. + +The graph distinguishes writing agents (`[W]`), read-only agents (`[R]`), +deterministic checks, metric or evaluation gates, orchestrators, and terminal +outputs with separate text shapes. For sealed Runs, agent nodes also show the +resolved model and effort from their frozen profile; only this safe display +metadata is projected, never the executable path or private dispatch policy. +Fan-out Loops are arranged as explicit split, parallel branch, merge, and +feedback paths. ## Guarantees and limits diff --git a/website/src/content/docs/ovens/dsl-reference.mdx b/website/src/content/docs/ovens/dsl-reference.mdx index 42eb74f6..013bfd23 100644 --- a/website/src/content/docs/ovens/dsl-reference.mdx +++ b/website/src/content/docs/ovens/dsl-reference.mdx @@ -29,6 +29,7 @@ description: The declarative vocabulary, bindings, controls, and registries for | `` | Refresh state. | | `` | Streaming Diff heading and return link. | | `` / `` / `` | Checklist-specific detail views. | +| `` | Core box-drawing Loop renderer. Bind `source` to a Run projection at the Oven root, or use `@item/loopRun` inside ``. | | `` / `` | Domain selection and its visual-parity explanation. | | `` / `` | Differential Testing headline and log views. | | `` / `` | Differential Testing charts. | @@ -36,6 +37,21 @@ description: The declarative vocabulary, bindings, controls, and registries for `` uses a registered icon and `` supplies literal or bound text. `` supplies a named component property; it is described with bindings below. +`` is core-owned: an Oven may position it but cannot inject renderer +code or perform its own fetch. The dashboard transport supplies fresh canonical +Run projections after item-keyed invalidation events: + +```xml + + + + + + + +``` + ## Interactivity | Element | Purpose | From 5c564d8a04f72406223263b9e6d85d30794bf17e Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Fri, 24 Jul 2026 18:56:00 +0200 Subject: [PATCH 08/37] fix: simplify checklist loop workspace --- .../ChecklistDashboard/ChecklistDashboard.css | 83 +++++++++++++---- .../ChecklistDashboard.test.mjs | 17 ++-- .../ChecklistDashboard/ChecklistDashboard.tsx | 3 +- .../checklist-dom.golden.html | 2 +- .../checklist-loop-progression.golden.json | 20 ++-- .../checklist-loop-states.golden.json | 32 +++---- dashboard/src/index.css | 30 +----- .../ChecklistWorkspace/ChecklistWorkspace.css | 40 ++++---- .../ChecklistWorkspace/ChecklistWorkspace.tsx | 91 ++++++++++++------- ovens/checklist/checklist.oven | 35 ++++--- skills/burnlist/SKILL.md | 15 ++- .../minimal-review-e2e-dom.golden.json | 20 ++-- src/loops/minimal-review-e2e.test.mjs | 3 +- src/server/burnlist-dashboard-server.mjs | 4 +- website/src/content/docs/loops.mdx | 29 +++--- 15 files changed, 237 insertions(+), 187 deletions(-) diff --git a/dashboard/src/components/ChecklistDashboard/ChecklistDashboard.css b/dashboard/src/components/ChecklistDashboard/ChecklistDashboard.css index b094dd65..a1524385 100644 --- a/dashboard/src/components/ChecklistDashboard/ChecklistDashboard.css +++ b/dashboard/src/components/ChecklistDashboard/ChecklistDashboard.css @@ -15,24 +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: minmax(220px, 26%) minmax(360px, 48%) minmax(220px, 26%); -} -.checklist-detail-shell #burnlist-detail .checklist-progress-workspace:not(:has(.checklist-current)) { - grid-template-columns: minmax(220px, 30%) minmax(360px, 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; } } @@ -45,11 +46,10 @@ body.checklist-detail-view .dashboard-oven-title { } .checklist-detail-shell .checklist-progress-workspace .event-ledger-panel, -.checklist-detail-shell .checklist-progress-workspace .progress-panel, -.checklist-detail-shell .checklist-progress-workspace .checklist-current { - height: 232px; - min-height: 232px; - max-height: 232px; +.checklist-detail-shell .checklist-progress-workspace .progress-panel { + height: 260px; + min-height: 260px; + max-height: 260px; } .checklist-detail-shell .checklist-progress-workspace .work-panel-body { min-height: 0; } @@ -216,12 +216,55 @@ body.checklist-detail-view .dashboard-oven-title { .event-card:hover .event-card-summary { background: #1c1c1c; } -@media (max-width: 640px) { +/* 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 612c837a..e766f053 100644 --- a/dashboard/src/components/ChecklistDashboard/ChecklistDashboard.test.mjs +++ b/dashboard/src/components/ChecklistDashboard/ChecklistDashboard.test.mjs @@ -13,9 +13,12 @@ const stylesheetPath = new URL("./ChecklistDashboard.css", import.meta.url).path 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 () => { @@ -46,13 +49,13 @@ test("checklist detail renders the split progress surface and event card list", 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="checklist-workspace"/u); - assert.match(markup, /aria-label="Completed events"/u); - assert.match(markup, /aria-label="Remaining items"/u); - assert.match(markup, /class="checklist-workspace__empty">No active item/u); - assert.equal((markup.match(/class="checklist-workspace__event"/gu) ?? []).length, 2); + 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.doesNotMatch(markup, /data-event-card="true"/u); - assert.doesNotMatch(markup, /Completed: 2026/u); + assert.equal((markup.match(/
Completed<\/dt>/gu) ?? []).length, 1); assert.doesNotMatch(markup, />DONE]*>Changes<\/button>/u); assert.doesNotMatch(markup, /Burnlist detail view/u); diff --git a/dashboard/src/components/ChecklistDashboard/ChecklistDashboard.tsx b/dashboard/src/components/ChecklistDashboard/ChecklistDashboard.tsx index 4a95ca0d..80dba5a5 100644 --- a/dashboard/src/components/ChecklistDashboard/ChecklistDashboard.tsx +++ b/dashboard/src/components/ChecklistDashboard/ChecklistDashboard.tsx @@ -6,7 +6,6 @@ export { checklistEventDetailFields } from "@lib/checklist-adapter"; import "./ChecklistDashboard.css"; import { buildChecklistProgressChart, KpiItem, KpiStrip, LogTable, ProgressDonut, SectionHeader } from "@oven"; import { LoopGraph } from "@/components/LoopGraph"; -import { ChecklistCurrent } from "@/oven/ChecklistCurrent"; import { ChecklistWorkspace } from "@/oven/ChecklistWorkspace"; function ChecklistKpis({ data }: { data: ChecklistProgressData }) { @@ -111,5 +110,5 @@ export function ChecklistDashboard({ data }: { data: ChecklistProgressData }) { 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-dom.golden.html b/dashboard/src/components/ChecklistDashboard/checklist-dom.golden.html index ae9fd828..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
Events2
B2Second event10m · 100%
B1First event20m · 50%
Items0

No active item

+
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 index 2b0d3d77..6b86a6c7 100644 --- a/dashboard/src/components/ChecklistDashboard/checklist-loop-progression.golden.json +++ b/dashboard/src/components/ChecklistDashboard/checklist-loop-progression.golden.json @@ -3,35 +3,35 @@ "checkpoint": "implement/1/reject", "projectionBytes": 2157, "projectionSha256": "6e8e57daad385abd423cc1849253359d322d7253db5bfc788cea632c64b2cc4a", - "domBytes": 10215, - "domSha256": "133792d445f5283fbe6c5d5385cec4402740cef7b937b179c1d44f5f5db66497" + "domBytes": 9373, + "domSha256": "c80e10ebd2a0bede8978a933944f0a020baba40ea27815749b303bfc57ffd252" }, { "checkpoint": "implement/2/reject", "projectionBytes": 2157, "projectionSha256": "f0f0bc7fad845f804893d6b193c80926a2245b8174a6e7787eb9ded01f6069e7", - "domBytes": 10215, - "domSha256": "133792d445f5283fbe6c5d5385cec4402740cef7b937b179c1d44f5f5db66497" + "domBytes": 9373, + "domSha256": "c80e10ebd2a0bede8978a933944f0a020baba40ea27815749b303bfc57ffd252" }, { "checkpoint": "review/2/approve", "projectionBytes": 2292, "projectionSha256": "dfc91fde48e5a690f5d7974b1320828376d9dac73a92f96a835bceb3a3186355", - "domBytes": 10212, - "domSha256": "dad8893e2e1194cf690929501441b9182678f664a97bea9375c15fea698ec98b" + "domBytes": 9373, + "domSha256": "051bc5760c63e3dc8532e84778dc2b31d87b7674a45f0dbae9ab4c13fb2adea8" }, { "checkpoint": "converged/1/approve", "projectionBytes": 2364, "projectionSha256": "9985718fe0bda762ac030d9d282ac51682819e53dcd9947039ad2ba2a4e84a9f", - "domBytes": 10215, - "domSha256": "f27683bf4be303b13cbff2a7dbab54535076f506ed109e2e11d3952b92a0aa80" + "domBytes": 9373, + "domSha256": "051bc5760c63e3dc8532e84778dc2b31d87b7674a45f0dbae9ab4c13fb2adea8" }, { "checkpoint": "completed/1/approve", "projectionBytes": 2435, "projectionSha256": "638a6ba72885ebf7f130bb2dc31fd1d7a685eea85b5b9e0a1d7eb84c6f31c424", - "domBytes": 10215, - "domSha256": "edb3d07570bf440ec273decce27b5acb31c458247ab4193426b72546f1fd089c" + "domBytes": 9373, + "domSha256": "507e6c935ee89bd163e26d015862cb0ff3e5bf7d0b970d21542e8ac4dde1a897" } ] diff --git a/dashboard/src/components/ChecklistDashboard/checklist-loop-states.golden.json b/dashboard/src/components/ChecklistDashboard/checklist-loop-states.golden.json index 7d0c7a88..0869c825 100644 --- a/dashboard/src/components/ChecklistDashboard/checklist-loop-states.golden.json +++ b/dashboard/src/components/ChecklistDashboard/checklist-loop-states.golden.json @@ -1,42 +1,42 @@ [ { "checkpoint": "paused", - "domBytes": 10215, - "domSha256": "edb3d07570bf440ec273decce27b5acb31c458247ab4193426b72546f1fd089c" + "domBytes": 9373, + "domSha256": "507e6c935ee89bd163e26d015862cb0ff3e5bf7d0b970d21542e8ac4dde1a897" }, { "checkpoint": "failed", - "domBytes": 10215, - "domSha256": "edb3d07570bf440ec273decce27b5acb31c458247ab4193426b72546f1fd089c" + "domBytes": 9373, + "domSha256": "507e6c935ee89bd163e26d015862cb0ff3e5bf7d0b970d21542e8ac4dde1a897" }, { "checkpoint": "stopped", - "domBytes": 10215, - "domSha256": "edb3d07570bf440ec273decce27b5acb31c458247ab4193426b72546f1fd089c" + "domBytes": 9373, + "domSha256": "507e6c935ee89bd163e26d015862cb0ff3e5bf7d0b970d21542e8ac4dde1a897" }, { "checkpoint": "needs-human", - "domBytes": 10215, - "domSha256": "edb3d07570bf440ec273decce27b5acb31c458247ab4193426b72546f1fd089c" + "domBytes": 9373, + "domSha256": "507e6c935ee89bd163e26d015862cb0ff3e5bf7d0b970d21542e8ac4dde1a897" }, { "checkpoint": "exhausted", - "domBytes": 10215, - "domSha256": "edb3d07570bf440ec273decce27b5acb31c458247ab4193426b72546f1fd089c" + "domBytes": 9373, + "domSha256": "507e6c935ee89bd163e26d015862cb0ff3e5bf7d0b970d21542e8ac4dde1a897" }, { "checkpoint": "stale", - "domBytes": 10215, - "domSha256": "edb3d07570bf440ec273decce27b5acb31c458247ab4193426b72546f1fd089c" + "domBytes": 9373, + "domSha256": "507e6c935ee89bd163e26d015862cb0ff3e5bf7d0b970d21542e8ac4dde1a897" }, { "checkpoint": "corrupt", - "domBytes": 10215, - "domSha256": "edb3d07570bf440ec273decce27b5acb31c458247ab4193426b72546f1fd089c" + "domBytes": 9373, + "domSha256": "507e6c935ee89bd163e26d015862cb0ff3e5bf7d0b970d21542e8ac4dde1a897" }, { "checkpoint": "completed", - "domBytes": 10215, - "domSha256": "edb3d07570bf440ec273decce27b5acb31c458247ab4193426b72546f1fd089c" + "domBytes": 9373, + "domSha256": "507e6c935ee89bd163e26d015862cb0ff3e5bf7d0b970d21542e8ac4dde1a897" } ] diff --git a/dashboard/src/index.css b/dashboard/src/index.css index 107d32f6..818113fd 100644 --- a/dashboard/src/index.css +++ b/dashboard/src/index.css @@ -383,7 +383,7 @@ body.checklist-detail-view .dashboard-oven-title { .burnlist-table-cell-project { padding-top: 13px; } } -@media (max-width: 1100px) { +@media (max-width: 520px) { .checklist-detail-shell #burnlist-detail .checklist-progress-workspace { height: auto; min-height: 0; @@ -439,34 +439,6 @@ body.checklist-detail-view .dashboard-oven-title { min-height: 52px; padding: 0 8px; } - .checklist-detail-shell #burnlist-detail .checklist-progress-workspace, - .shell.checklist-detail-shell #burnlist-detail .checklist-kpi-strip:not([hidden]) + .checklist-progress-workspace { - height: auto; - min-height: 0; - max-height: none; - flex: none; - grid-template-columns: minmax(0, 1fr); - grid-template-rows: auto 200px; - gap: 12px; - margin-top: 12px; - } - .checklist-detail-shell #burnlist-detail .checklist-progress-workspace .event-ledger-panel, - .checklist-detail-shell #burnlist-detail .checklist-progress-workspace .progress-panel { - width: 100%; - height: auto; - max-height: none; - padding: 0 12px 12px; - border-radius: 8px; - background: var(--card); - } - .checklist-detail-shell #burnlist-detail .checklist-progress-workspace .event-ledger-panel { min-height: 112px; grid-column: 1; grid-row: 1; } - .checklist-detail-shell #burnlist-detail .checklist-progress-workspace .progress-panel { height: 200px; grid-column: 1; grid-row: 2; } - .checklist-detail-shell .event-ledger-panel .work-panel-body { padding: 0; } - .checklist-detail-shell .event-ledger-panel .checklist-log { padding-right: 0; } - .checklist-detail-shell .event-ledger-panel .checklist-log-table-header, - .checklist-detail-shell .event-ledger-panel .log-row.log-table-row { font-size: 12px; } - .checklist-detail-shell .event-ledger-panel .checklist-log-table-header span + span, - .checklist-detail-shell .event-ledger-panel .log-row.log-table-row .log-table-cell + .log-table-cell { padding-left: 6px; } } @media (max-width: 520px) { diff --git a/dashboard/src/oven/ChecklistWorkspace/ChecklistWorkspace.css b/dashboard/src/oven/ChecklistWorkspace/ChecklistWorkspace.css index f428b74e..5eb5c676 100644 --- a/dashboard/src/oven/ChecklistWorkspace/ChecklistWorkspace.css +++ b/dashboard/src/oven/ChecklistWorkspace/ChecklistWorkspace.css @@ -1,11 +1,11 @@ .checklist-workspace { display: grid; width: 100%; - min-height: 330px; + min-height: 420px; margin-top: 12px; border-top: 1px solid rgba(168, 168, 168, .15); border-bottom: 1px solid rgba(168, 168, 168, .1); - grid-template-columns: minmax(220px, 26%) minmax(250px, 30%) minmax(360px, 44%); + grid-template-columns: minmax(280px, 36%) minmax(420px, 64%); } .checklist-workspace__column { min-width: 0; background: #111; } @@ -21,19 +21,7 @@ justify-content: space-between; } -.checklist-workspace__event-list, -.checklist-workspace__item-list { display: block; max-height: 390px; overflow: auto; } -.checklist-workspace__event { - display: grid; - min-height: 42px; - padding: 0 12px; - border-bottom: 1px solid rgba(163, 172, 183, .07); - grid-template-columns: 3ch minmax(0, 1fr) max-content; - align-items: center; - gap: 8px; -} -.checklist-workspace__event-id { color: rgba(97, 211, 148, .78); font-size: 12px; } -.checklist-workspace__event-title, +.checklist-workspace__item-list { display: block; max-height: 520px; overflow: auto; } .checklist-workspace__item-copy span { min-width: 0; overflow: hidden; @@ -60,13 +48,29 @@ .checklist-workspace__item:focus-visible { background: rgba(255, 255, 255, .025); outline: none; } .checklist-workspace__item.is-selected { background: rgba(90, 162, 255, .07); box-shadow: inset 2px 0 rgba(90, 162, 255, .7); } .checklist-workspace__item-marker { color: rgba(168, 168, 168, .42); font-size: 11px; text-align: center; } -.checklist-workspace__item:first-child .checklist-workspace__item-marker { color: var(--status-green); } +.checklist-workspace__item.is-current .checklist-workspace__item-marker { color: var(--status-green); } +.checklist-workspace__item.is-completed { opacity: .64; } +.checklist-workspace__item.is-completed .checklist-workspace__item-marker { color: var(--status-green); } +.checklist-workspace__item.is-completed.is-selected { opacity: 1; } .checklist-workspace__item-copy { display: grid; min-width: 0; gap: 3px; } .checklist-workspace__item-copy b { color: rgba(232, 232, 232, .86); font-size: 12px; font-weight: 500; } +.checklist-workspace__divider { + display: flex; + height: 30px; + padding: 0 12px; + border-top: 1px solid rgba(163, 172, 183, .1); + border-bottom: 1px solid rgba(163, 172, 183, .07); + color: rgba(168, 168, 168, .42); + font-size: 10px; + align-items: center; + justify-content: space-between; + text-transform: uppercase; +} .checklist-workspace__detail-body { padding: 14px 16px 12px; } .checklist-workspace__detail-title { display: grid; grid-template-columns: 3ch minmax(0, 1fr); gap: 8px; } .checklist-workspace__detail-title > span { color: var(--status-green); font-size: 12px; } +.checklist-workspace__detail-title.is-completed { grid-template-columns: 3ch minmax(0, 1fr); } .checklist-workspace__detail-title h2 { margin: 0; color: rgba(232, 232, 232, .9); font: 14px/1.3 var(--dashboard-font); } .checklist-workspace__fields { display: grid; margin: 12px 0 0; gap: 8px; } .checklist-workspace__fields > div { display: grid; grid-template-columns: 8ch minmax(0, 1fr); gap: 10px; } @@ -99,9 +103,7 @@ .checklist-workspace__empty { margin: 0; padding: 16px; color: rgba(168, 168, 168, .5); font-size: 12px; } @media (max-width: 900px) { - .checklist-workspace { grid-template-columns: minmax(220px, 38%) minmax(0, 62%); } - .checklist-workspace__events { grid-column: 1 / -1; grid-row: 2; border-top: 1px solid rgba(163, 172, 183, .1); } - .checklist-workspace__events .checklist-workspace__event-list { max-height: 210px; } + .checklist-workspace { grid-template-columns: minmax(240px, 40%) minmax(0, 60%); } } @media (max-width: 620px) { diff --git a/dashboard/src/oven/ChecklistWorkspace/ChecklistWorkspace.tsx b/dashboard/src/oven/ChecklistWorkspace/ChecklistWorkspace.tsx index a0895c8e..2bbdcbed 100644 --- a/dashboard/src/oven/ChecklistWorkspace/ChecklistWorkspace.tsx +++ b/dashboard/src/oven/ChecklistWorkspace/ChecklistWorkspace.tsx @@ -1,10 +1,20 @@ -import type { ChecklistItem, ChecklistProgressData, LoopRunProjection } from "@lib"; -import { compactAge, eventRows } from "@lib/checklist-adapter"; +import type { ChecklistItem, ChecklistProgressData, CompletedItem, LoopRunProjection } from "@lib"; +import { checklistEventDetailFields, compactAge, eventRows } from "@lib/checklist-adapter"; import { itemTopologyProjection, LoopCompact, LoopLegend } from "@/components/LoopGraph"; import "./ChecklistWorkspace.css"; -function inspectedItem(data: ChecklistProgressData) { - return data.active.find((item) => item.id === data.selectedItemId) ?? data.active[0] ?? null; +type ItemSelection = + | { status: "active"; item: ChecklistItem; index: number } + | { status: "completed"; item: CompletedItem; index: number }; + +function inspectedItem(data: ChecklistProgressData): ItemSelection | null { + const activeIndex = data.active.findIndex((item) => item.id === data.selectedItemId); + if (activeIndex >= 0) return { status: "active", item: data.active[activeIndex], index: activeIndex }; + const completed = eventRows(data); + const completedIndex = completed.findIndex((item) => item.id === data.selectedItemId); + if (completedIndex >= 0) return { status: "completed", item: completed[completedIndex], index: completedIndex }; + if (data.active[0]) return { status: "active", item: data.active[0], index: 0 }; + return completed[0] ? { status: "completed", item: completed[0], index: 0 } : null; } function previewRun(item: ChecklistItem, data: ChecklistProgressData): LoopRunProjection { @@ -35,34 +45,30 @@ function previewRun(item: ChecklistItem, data: ChecklistProgressData): LoopRunPr }; } -function EventsColumn({ data }: { data: ChecklistProgressData }) { - const rows = eventRows(data).slice(0, 10); - return
-
Events{rows.length}
-
- {rows.map((item) =>
- {item.id} - {item.title} - {compactAge(item.completedAt, data.generatedAt)} · {item.percent}% -
)} - {!rows.length &&

No completed events

} -
-
; -} - -function ItemsColumn({ data, selected }: { data: ChecklistProgressData; selected: ChecklistItem | null }) { - return
-
Items{data.active.length}
+function ItemsColumn({ data, selected }: { data: ChecklistProgressData; selected: ItemSelection | null }) { + const completed = eventRows(data); + return
+
Items{data.total}
; } @@ -74,17 +80,14 @@ const detailFields = [ ["Files", "Files/search"], ] as const; -function DetailColumn({ data, item }: { data: ChecklistProgressData; item: ChecklistItem | null }) { - if (!item) return

No active item

; +function ActiveDetail({ data, item }: { data: ChecklistProgressData; item: ChecklistItem }) { const run = previewRun(item, data); const topology = item.loop?.graph ? itemTopologyProjection(run) : null; const legendSymbols = topology ? { start: "S", ...Object.fromEntries(topology.graph.nodes.filter((node) => node.kind === "terminal" && node.terminalState === "converged").map((node) => [node.id, "B"])), } : undefined; - return
-
Item detail{item.id}
-
+ return
{item.id}

{item.title}

{detailFields.map(([label, key]) => item.fields[key] ?
{label}
{item.fields[key]}
: null)}
{item.loop ?
@@ -92,15 +95,35 @@ function DetailColumn({ data, item }: { data: ChecklistProgressData; item: Check {topology && }
:
Direct implementation · no Loop assigned
} -
+
; +} + +function CompletedDetail({ item }: { item: CompletedItem }) { + const fields = checklistEventDetailFields(item.detail).filter((field) => field.label !== "Completed" && field.values.length); + return
+

{item.id} · {item.title}

+
+
Status
Completed
+
Completed
+ {fields.map((field) =>
{field.label === "Detail" ? "Outcome" : field.label}
{field.values.join(" · ")}
)} +
+
; +} + +function DetailColumn({ data, selected }: { data: ChecklistProgressData; selected: ItemSelection | null }) { + const status = !selected ? "Empty" : selected.status === "completed" ? "Completed" : selected.index === 0 ? "Current" : "Pending"; + return
+
Item detail{status}
+ {!selected ?

No items

+ : selected.status === "active" ? + : }
; } export function ChecklistWorkspace({ data }: { data: ChecklistProgressData }) { const selected = inspectedItem(data); return
- - +
; } diff --git a/ovens/checklist/checklist.oven b/ovens/checklist/checklist.oven index 34a74272..d378765a 100644 --- a/ovens/checklist/checklist.oven +++ b/ovens/checklist/checklist.oven @@ -1,16 +1,23 @@ - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + diff --git a/skills/burnlist/SKILL.md b/skills/burnlist/SKILL.md index f4c73abe..5bebcd43 100644 --- a/skills/burnlist/SKILL.md +++ b/skills/burnlist/SKILL.md @@ -204,14 +204,13 @@ Run lease. For live foreground work, the owner handles Ctrl-C: first interrupt requests pause after its child exits, second requests controlled stop. Do not claim that a separate CLI invocation can take over a live Run. -The canonical Checklist Oven keeps the progress-row Loop bound to the first -active item; inspecting another `#` changes only the three-column -Events / Items / Item detail workspace below it. A Loop-assigned current item -shows its compact topology beside Completion with the active node highlighted -and a graph-derived legend; a direct current item collapses that panel. Item -detail shows the selected item's contract, Loop preview or live Run, labelled -return paths, and a graph-derived node legend. Ovens may compose Burnlist's -core box-drawing renderer declaratively with +The canonical Checklist Oven keeps a centered KPI row above the side-by-side +Progress ledger and Completion trend. Its unified Items list contains current, +pending, and completed work; inspecting another `#` changes only the +adjacent Item detail. That detail shows the selected active item's contract, +Loop preview or live Run, labelled return paths, and graph-derived node legend, +or a completed item's recorded timestamp and outcome. Direct items show no +Loop graph. Ovens may compose Burnlist's core box-drawing renderer declaratively with ``, or with `` inside a collection. The widget never fetches, executes, or imports custom code; core transport refreshes it after diff --git a/src/loops/__fixtures__/minimal-review-e2e-dom.golden.json b/src/loops/__fixtures__/minimal-review-e2e-dom.golden.json index c6ccc098..e39c9e23 100644 --- a/src/loops/__fixtures__/minimal-review-e2e-dom.golden.json +++ b/src/loops/__fixtures__/minimal-review-e2e-dom.golden.json @@ -1,27 +1,27 @@ [ { "checkpoint": "needs-human", - "domBytes": 10217, - "domSha256": "716f2668931ac3f80620c2c13cabed72a9b504383c6d5cb7d08daddf9faa013c" + "domBytes": 9373, + "domSha256": "051bc5760c63e3dc8532e84778dc2b31d87b7674a45f0dbae9ab4c13fb2adea8" }, { "checkpoint": "paused", - "domBytes": 10215, - "domSha256": "133792d445f5283fbe6c5d5385cec4402740cef7b937b179c1d44f5f5db66497" + "domBytes": 9373, + "domSha256": "c80e10ebd2a0bede8978a933944f0a020baba40ea27815749b303bfc57ffd252" }, { "checkpoint": "repair", - "domBytes": 10215, - "domSha256": "133792d445f5283fbe6c5d5385cec4402740cef7b937b179c1d44f5f5db66497" + "domBytes": 9373, + "domSha256": "c80e10ebd2a0bede8978a933944f0a020baba40ea27815749b303bfc57ffd252" }, { "checkpoint": "converged", - "domBytes": 10215, - "domSha256": "edb3d07570bf440ec273decce27b5acb31c458247ab4193426b72546f1fd089c" + "domBytes": 9373, + "domSha256": "507e6c935ee89bd163e26d015862cb0ff3e5bf7d0b970d21542e8ac4dde1a897" }, { "checkpoint": "post-completion", - "domBytes": 8510, - "domSha256": "e66102e9fc69180db39b08ba34c490b2793216d1cbe102df0c44aa52619337de" + "domBytes": 8982, + "domSha256": "bdc604a3d9b34b5e642df92de34205921437f1ffbfc4b10050b18681662c6aae" } ] diff --git a/src/loops/minimal-review-e2e.test.mjs b/src/loops/minimal-review-e2e.test.mjs index b267ea7b..8dfd7adc 100644 --- a/src/loops/minimal-review-e2e.test.mjs +++ b/src/loops/minimal-review-e2e.test.mjs @@ -96,7 +96,8 @@ test("M9 no-network CLI slice exposes interruption, repair, invalidation refetch assert.equal(escalationHttp.status, 200); const escalationProjection = JSON.parse(escalationHttp.body).loopRun; assert.deepEqual(escalationProjection, escalationInspection); const needsHumanUi = render("needs-human", escalationProjection); - assert.match(needsHumanUi.dom, /
/u); + assert.match(needsHumanUi.dom, /
/u); + assert.match(needsHumanUi.dom, /
; } diff --git a/dashboard/src/oven/LoopProgress/LoopProgress.css b/dashboard/src/oven/LoopProgress/LoopProgress.css new file mode 100644 index 00000000..555a56e8 --- /dev/null +++ b/dashboard/src/oven/LoopProgress/LoopProgress.css @@ -0,0 +1,78 @@ +.loop-progress { + display: grid; + gap: 14px; + padding: 20px; + color: var(--foreground, #e8edf5); +} +.loop-progress__now, +.loop-progress__context article, +.loop-progress__loop, +.loop-progress__map, +.loop-progress__activity { + border: 1px solid color-mix(in srgb, currentColor 14%, transparent); + border-radius: 12px; + background: color-mix(in srgb, var(--card, #10151d) 92%, transparent); +} +.loop-progress__now { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: baseline; + gap: 14px; + padding: 14px 16px; +} +.loop-progress__now > span, +.loop-progress__context-head > span, +.loop-progress__context span, +.loop-progress h2 { + color: var(--muted-foreground, #92a0b3); + font-size: 11px; + font-weight: 800; + letter-spacing: .12em; +} +.loop-progress__now strong { overflow: hidden; font-size: 20px; text-overflow: ellipsis; white-space: nowrap; } +.loop-progress__context-head { display: flex; align-items: baseline; gap: 10px; padding: 0 3px; } +.loop-progress__context-head strong { font-size: 13px; } +.loop-progress__now small, .loop-progress h2 small { color: var(--muted-foreground, #92a0b3); font-weight: 500; letter-spacing: 0; } +.loop-progress__context { display: grid; grid-template-columns: 2fr 1fr 1fr; gap: 10px; } +.loop-progress__context article { min-width: 0; padding: 12px 14px; } +.loop-progress__context p { margin: 5px 0 0; overflow-wrap: anywhere; font-size: 13px; line-height: 1.35; } +.loop-progress__work { display: grid; gap: 12px; } +.loop-progress__loop, .loop-progress__map { min-width: 0; padding: 14px; } +.loop-progress h2 { margin: 0 0 12px; } +.loop-progress__map ol { display: flex; align-items: stretch; margin: 0; padding: 0; list-style: none; } +.loop-progress__map li { display: flex; flex: 1; align-items: center; min-width: 0; color: var(--muted-foreground, #92a0b3); font-size: 12px; } +.loop-progress__map li span { position: relative; display: grid; flex: 1; gap: 4px; min-height: 68px; align-content: center; padding: 10px 6px; border: 1px solid color-mix(in srgb, currentColor 14%, transparent); border-radius: 7px; text-align: center; } +.loop-progress__map li span > strong { color: var(--foreground, #e8edf5); font-size: 12px; } +.loop-progress__map li span > small { font-size: 10px; } +.loop-progress__map li span > em { font-size: 8px; font-style: normal; font-weight: 800; letter-spacing: .08em; } +.loop-progress__map li b { padding: 0 3px; font-weight: 400; } +.loop-progress__map li.is-active span { border-color: #67d7a4; background: color-mix(in srgb, #67d7a4 17%, transparent); color: #b9f5d8; font-weight: 750; } +.loop-progress__map li.is-running span { box-shadow: inset 0 0 0 2px #76a9fa; } +.loop-progress__map li.is-running span > em:first-of-type { color: #9bc1ff; } +.loop-progress__empty { margin: 20px 0; color: var(--muted-foreground, #92a0b3); text-align: center; } +.loop-progress__activity { min-width: 0; padding: 14px; } +.loop-progress__activity ol { display: grid; block-size: 150px; margin: 0; padding: 0; gap: 0; overflow: hidden; list-style: none; } +.loop-progress__activity li { display: grid; min-width: 0; min-height: 15px; grid-template-columns: 12ch minmax(0, 1fr) auto; gap: 8px; color: var(--muted-foreground, #92a0b3); font-size: 11px; line-height: 15px; } +.loop-progress__activity li b { color: #9bc1ff; font-weight: 700; } +.loop-progress__activity li span { overflow: hidden; color: var(--foreground, #e8edf5); text-overflow: ellipsis; white-space: nowrap; } +.loop-progress__activity li small { color: var(--muted-foreground, #92a0b3); } +.loop-progress__activity-empty { display: block !important; color: var(--muted-foreground, #92a0b3); } +.loop-progress__observed { display: grid; min-width: 0; grid-template-columns: 10ch minmax(0, 1fr); gap: 8px; margin-top: 8px; color: var(--muted-foreground, #92a0b3); font-size: 11px; } +.loop-progress__observed b { color: #a9d1ff; font-weight: 700; letter-spacing: .08em; } +.loop-progress__observed span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.loop-progress footer { overflow-wrap: anywhere; color: var(--muted-foreground, #92a0b3); font-size: 11px; line-height: 1.6; } +.loop-progress footer > span { font-weight: 800; letter-spacing: .08em; } +@media (max-width: 900px) { + .loop-progress__context { grid-template-columns: 1fr 1fr; } + .loop-progress__work { grid-template-columns: 1fr; } +} +@media (max-width: 620px) { + .loop-progress { padding: 12px; } + .loop-progress__now { grid-template-columns: auto 1fr; } + .loop-progress__now small { grid-column: 2; } + .loop-progress__context { grid-template-columns: 1fr; } + .loop-progress__map ol { display: grid; grid-template-columns: repeat(3, 1fr); gap: 5px; } + .loop-progress__map li b { display: none; } + .loop-progress__activity li { grid-template-columns: 10ch minmax(0, 1fr); } + .loop-progress__activity li small { display: none; } +} diff --git a/dashboard/src/oven/LoopProgress/LoopProgress.test.ts b/dashboard/src/oven/LoopProgress/LoopProgress.test.ts new file mode 100644 index 00000000..fe13f511 --- /dev/null +++ b/dashboard/src/oven/LoopProgress/LoopProgress.test.ts @@ -0,0 +1,105 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { LoopProgress } from "./LoopProgress"; + +const graph = { + entry: "make", + nodes: [ + { id: "make", kind: "agent", role: "Maker" }, + { id: "review", kind: "agent", role: "Review" }, + ], + edges: [{ from: "make", on: "complete", to: "review" }], +}; + +function fixture(selectedItemId: string) { + return { + generatedAt: "2026-07-24T20:00:00Z", + repoKey: "0123456789ab", + title: "Progress", + repo: "burnlist", + planLabel: "inprogress", + selectedItemId, + total: 2, done: 0, remaining: 2, percent: 0, warnings: [], completed: [], history: [], + active: [ + { id: "O0", title: "Seed Oven", fields: { Action: "Show the work simply.", "Files/search": "ovens/ dashboard/src/oven/" }, loop: null }, + { id: "H3", title: "Accept reports", fields: { Action: "Bind results safely.", "Files/search": "src/loops/run/" }, + loop: { selector: "loop:builtin:review", assignmentId: "a", executionRevision: "e", packageRevision: "p", graph } }, + ], + loopRun: { + schema: "burnlist-loop-read-projection@1", runId: "run-1", itemRef: "item:260724-002#O0", + loopId: "loop:builtin:review", loopRevision: null, createdAt: 1, updatedAt: 2, + state: "running", currentNode: "review", attempt: 1, cycle: 0, revision: "r", + budget: { limits: { maxRounds: 1, maxMinutes: 1, maxAgentRuns: 1, maxCheckRuns: 1, maxTransitions: 1, maxOutputBytes: 1 }, + counters: { rounds: 0, agentRuns: 0, checkRuns: 0, transitions: 0, outputBytes: 0 }, + elapsedMilliseconds: 1, journal: { maximum: 1, used: 0, remaining: 1 } }, + latestResult: null, graph, transitions: [], + }, + } as any; +} + +test("shows low-text canonical context and never fabricates hook activity", () => { + const markup = renderToStaticMarkup(createElement(LoopProgress, { data: fixture("O0") })); + for (const label of ["NOW", "WHY", "SYSTEM", "FILES", "HOOKS"]) assert.match(markup, new RegExp(`>${label}<`, "u")); + for (const label of ["LOOP", "SYSTEM FLOW"]) assert.match(markup, new RegExp(`>${label} `, "u")); + assert.match(markup, />O0 · ReviewObserver { + const markup = renderToStaticMarkup(createElement(LoopProgress, { data: fixture("H3") })); + assert.match(markup, />O0 · ReviewCONTEXTH3 · Accept reportsValidate \+ review<\/strong>/u); + assert.match(markup, /
  • Agent \+ workspace<\/strong>/u); + assert.match(markup, /class="is-active"/u); +}); + +test("technical host-report actions become simple subsystem WHY text", () => { + const data = fixture("H3"); + data.active[1].fields.Action = "Validate an external report against its durable claim, rederive candidate identity, append the result exactly once, and select a declared edge."; + const markup = renderToStaticMarkup(createElement(LoopProgress, { data })); + assert.match(markup, /Keeps each step controlled and verifiable/u); + assert.doesNotMatch(markup, /durable claim|candidate identity|declared edge/u); +}); + +test("renders only bounded provenance-labelled sanitized activity", () => { + const data = fixture("O0"); + data.loopRun.activity = { hooks: "available", records: Array.from({ length: 12 }, (_, index) => ({ + at: index, origin: index % 2 ? "host-hook" : "agent-reported", kind: index % 2 ? "subagent-started" : "tool-finished", + nodeId: "review", attempt: 1, provider: index % 2 ? "claude" : "codex", subagentId: index % 2 ? `child-${index}` : undefined, + tool: index % 2 ? undefined : "node-test", observedPath: index % 2 ? `src/subagents/${index}.mjs` : "src/loops/run/binder.mjs", truncated: index === 11, + })) }; + const markup = renderToStaticMarkup(createElement(LoopProgress, { data })); + assert.match(markup, />ACTIVITY HOOKS<\/span>

    available10 recentOBSERVED.*src\/loops\/run\/binder\.mjs/u); + assert.equal((markup.match(/class="loop-progress__activity"/gu) ?? []).length, 1); + assert.equal((markup.match(/subagent child-/gu) ?? []).length + (markup.match(/node-test/gu) ?? []).length, 10); +}); + +test("activity viewport has a stable ten-row height", async () => { + const css = await readFile("dashboard/src/oven/LoopProgress/LoopProgress.css", "utf8"); + assert.match(css, /\.loop-progress__activity ol \{[^}]*block-size: 150px;[^}]*overflow: hidden;/su); + assert.match(css, /\.loop-progress__activity li \{[^}]*min-height: 15px;[^}]*line-height: 15px;/su); +}); diff --git a/dashboard/src/oven/LoopProgress/LoopProgress.tsx b/dashboard/src/oven/LoopProgress/LoopProgress.tsx new file mode 100644 index 00000000..99c00915 --- /dev/null +++ b/dashboard/src/oven/LoopProgress/LoopProgress.tsx @@ -0,0 +1,139 @@ +import type { ChecklistItem, ChecklistProgressData, LoopRunProjection } from "@lib"; +import { LoopCompact } from "@/components/LoopGraph"; +import "./LoopProgress.css"; + +const architecture = [ + { id: "plan", label: "Plan", detail: "item contract", hint: ["notes/burnlists", "plan-model"], why: "Keeps the work clear and ordered." }, + { id: "loops", label: "Loop control", detail: "claim · route", hint: ["src/loops", ".burnlist/loops", "LoopGraph", "src/cli/loop"], why: "Keeps each step controlled and verifiable." }, + { id: "work", label: "Agent + workspace", detail: "make the change", hint: ["adapters", "workspace", "provider"], why: "Gives the work a bounded place to happen." }, + { id: "proof", label: "Validate + review", detail: "prove · decide", hint: ["validate", "review", "test", "capabilities"], why: "Checks the result before it can move forward." }, + { id: "burn", label: "Burn", detail: "complete item", hint: ["completion", "lifecycle"], why: "Finishes work only after its proof is complete." }, + { id: "observe", label: "Observer", detail: "Oven · events", hint: ["ovens/", "src/ovens", "src/server", "dashboard/src/oven", "src/events", "hooks", "streaming-diff"], why: "Makes truthful progress easy to see." }, +] as const; + +function selectedItem(data: ChecklistProgressData) { + return data.active.find((item) => item.id === data.selectedItemId) ?? data.active[0] ?? null; +} + +function subsystem(item: ChecklistItem | null) { + const surface = `${item?.fields["Files/search"] ?? ""} ${item?.title ?? ""}`.toLowerCase(); + let best = architecture[0]; + let score = 0; + for (const candidate of architecture) { + const matches = candidate.hint.filter((hint) => surface.includes(hint.toLowerCase())).length; + if (matches > score) { + best = candidate; + score = matches; + } + } + return best; +} + +function plainWhy(item: ChecklistItem | null, system: (typeof architecture)[number]) { + return item ? system.why : "No active work remains."; +} + +function preview(item: ChecklistItem): LoopRunProjection | null { + if (!item.loop) return null; + return { + schema: "burnlist-loop-read-projection@1", + runId: "preview", + itemRef: `item:preview#${item.id}`, + loopId: item.loop.selector, + loopRevision: null, + createdAt: 0, + updatedAt: 0, + state: "prepared", + currentNode: item.loop.graph?.entry ?? "start", + attempt: 0, + cycle: 0, + revision: "preview", + budget: { + limits: { maxRounds: 0, maxMinutes: 0, maxAgentRuns: 0, maxCheckRuns: 0, maxTransitions: 0, maxOutputBytes: 0 }, + counters: { rounds: 0, agentRuns: 0, checkRuns: 0, transitions: 0, outputBytes: 0 }, + elapsedMilliseconds: 0, + journal: { maximum: 0, used: 0, remaining: 0 }, + }, + latestResult: null, + graph: item.loop.graph ?? { entry: "start", nodes: [], edges: [] }, + transitions: [], + }; +} + +function displayNode(run: LoopRunProjection | null | undefined) { + if (!run) return "Direct work"; + const node = run.graph.nodes.find((candidate) => candidate.id === run.currentNode); + return node?.role ?? node?.capability ?? run.currentNode; +} + +function runItemId(run: LoopRunProjection) { + const marker = run.itemRef.lastIndexOf("#"); + return marker >= 0 ? run.itemRef.slice(marker + 1) : run.itemRef; +} + +function runSubsystem(run: LoopRunProjection | null) { + if (!run) return null; + const node = run.graph.nodes.find((candidate) => candidate.id === run.currentNode); + const meaning = `${run.currentNode} ${node?.role ?? ""} ${node?.capability ?? ""}`; + if (node?.kind === "check" || node?.kind === "gate" || /review|verify|validate/u.test(meaning)) return "proof"; + if (node?.kind === "agent") return "work"; + if (node?.kind === "terminal" || /converg|complete|burn/u.test(run.currentNode)) return "burn"; + return "loops"; +} + +function activityText(record: NonNullable["records"][number]) { + const where = record.nodeId ? `${record.nodeId}${record.attempt ? ` #${record.attempt}` : ""}` : "Run"; + const detail = record.subagentId ? `subagent ${record.parentAgentId ? `${record.parentAgentId} / ` : ""}${record.subagentId}` + : record.tool ?? record.capability ?? record.outcome ?? record.state ?? ""; + return [where, record.kind.replaceAll("-", " "), detail].filter(Boolean).join(" · "); +} + +export function LoopProgress({ data }: { data: ChecklistProgressData }) { + const item = selectedItem(data); + const system = subsystem(item); + const authoritativeRun = data.loopRun ?? null; + const itemRun = item && authoritativeRun?.itemRef.endsWith(`#${item.id}`) ? authoritativeRun : item ? preview(item) : null; + const files = item?.fields["Files/search"] ?? "No declared file surface"; + const runningSystem = runSubsystem(authoritativeRun); + const activity = authoritativeRun?.activity; + const recentActivity = activity?.records.slice(-10) ?? []; + const observedPaths = [...new Set(recentActivity.flatMap((record) => record.observedPath ? [record.observedPath] : []))]; + return

    +
    + NOW + {authoritativeRun ? `${runItemId(authoritativeRun)} · ${displayNode(authoritativeRun)}` : item ? `${item.id} · ${item.title}` : "Complete"} + {authoritativeRun ? `Run · ${authoritativeRun.state}` : "Canonical checklist"} +
    + +
    CONTEXT{item ? `${item.id} · ${item.title}` : "None"}
    +
    +
    WHY

    {plainWhy(item, system)}

    +
    SYSTEM

    {system.label}

    +
    HOOKS

    {activity?.hooks ?? "Unavailable"}

    +
    + +
    +
    +

    SYSTEM FLOW whole Burnlist

    +
      + {architecture.map((part, index) =>
    1. + {part.label}{part.detail}{part.id === runningSystem && NOW}{part.id === system.id && CONTEXT}{index < architecture.length - 1 && } +
    2. )} +
    +
    +
    +

    LOOP {item?.loop?.selector ?? "direct"}

    + {itemRun ? + :

    No Loop assigned

    } +
    +
    +
    +

    ACTIVITY {activity ? `${recentActivity.length} recent` : "unavailable"}

    +
      {recentActivity.length ? recentActivity.slice().reverse().map((record, index) =>
    1. + {record.origin}{activityText(record)}{record.provider ? {record.provider} : null}{record.truncated ? truncated : null} +
    2. ) :
    3. No observed hook activity. Runner state remains canonical.
    4. }
    +
    OBSERVED{observedPaths.length ? observedPaths.join(" · ") : "Unavailable"}
    +
    +
    FILES {files}
    Selected · {item ? `${item.id} ${item.title}` : "none"}{authoritativeRun && item && !authoritativeRun.itemRef.endsWith(`#${item.id}`) ? " · Run remains authoritative for another item" : ""}
    +
    ; +} diff --git a/dashboard/src/oven/LoopProgress/index.ts b/dashboard/src/oven/LoopProgress/index.ts new file mode 100644 index 00000000..37708fb7 --- /dev/null +++ b/dashboard/src/oven/LoopProgress/index.ts @@ -0,0 +1 @@ +export { LoopProgress } from "./LoopProgress"; diff --git a/dashboard/src/oven/runtime/OvenNode.tsx b/dashboard/src/oven/runtime/OvenNode.tsx index 4e497a11..cdc1fc80 100644 --- a/dashboard/src/oven/runtime/OvenNode.tsx +++ b/dashboard/src/oven/runtime/OvenNode.tsx @@ -13,6 +13,7 @@ import { formatRegistry } from "../OvenView/registries"; import { buildLogTableProps } from "./log-table-adapter"; import { ModelLabView, type ModelLabPayload } from "../ModelLabView"; import { LoopGraph, type LoopGraphProjection } from "../../components/LoopGraph"; +import { LoopProgress } from "../LoopProgress"; export type OvenNodeDef = { kind: string; attributes?: Record; bindings?: Record; children?: OvenNodeDef[] }; export type OvenNodeProps = { node: OvenNodeDef; ir: OvenIr; state: OvenState; dispatch: (action: OvenAction) => void; item?: unknown; path?: string }; @@ -72,8 +73,9 @@ export function OvenNode({ node, ir, state, dispatch, item, path = "root" }: Ove const run = runtimeSource(state.payload, item, attrs(node).source) as (LoopGraphProjection & { diagnostic?: "corrupt" | "stale" }) | null | undefined; return ; } + if (node.kind === "loop-progress") return ; if (node.kind === "log-table") return ; - if (["checklist-current", "checklist-burn-panel", "checklist-ledger", "checklist-event-cards"].includes(node.kind)) return ; + if (["checklist-current", "checklist-workspace", "checklist-burn-panel", "checklist-ledger", "checklist-event-cards"].includes(node.kind)) return ; if (["mode-toggle", "domain-tabs", "field-toolbar", "pagination"].includes(node.kind)) return ; if (["field-list", "refresh-status", "verdict-header", "metric-tiles", "domain-note", "frame-card"].includes(node.kind)) return ; if (node.kind === "box") return {(node.children ?? []).map((child, index) => )}; diff --git a/dashboard/src/oven/runtime/widget-adapters.tsx b/dashboard/src/oven/runtime/widget-adapters.tsx index 226ff15e..06d38937 100644 --- a/dashboard/src/oven/runtime/widget-adapters.tsx +++ b/dashboard/src/oven/runtime/widget-adapters.tsx @@ -4,6 +4,7 @@ import { ChecklistBurnPanel } from "../ChecklistBurnPanel/ChecklistBurnPanel"; import { ChecklistEventCards } from "../ChecklistEventCards/ChecklistEventCards"; import { ChecklistLedger } from "../ChecklistLedger/ChecklistLedger"; import { ChecklistCurrent } from "../ChecklistCurrent/ChecklistCurrent"; +import { ChecklistWorkspace } from "../ChecklistWorkspace/ChecklistWorkspace"; import { DomainNote } from "../DomainNote"; import { FrameCard } from "../FrameCard"; import { MetricTiles } from "../MetricTiles"; @@ -59,6 +60,7 @@ export function WidgetAdapter({ node, ir, state, dispatch }: { node: Node; ir: O export function ChecklistWidgetAdapter({ node, payload }: { node: Node; payload: unknown }) { const data = resolvePointer(payload, String(attrs(node).source ?? "/")) as any; if (node.kind === "checklist-current") return ; + if (node.kind === "checklist-workspace") return ; if (node.kind === "checklist-burn-panel") return ; if (node.kind === "checklist-ledger") return ; if (node.kind === "checklist-event-cards") return ; diff --git a/loops/review/instructions.md b/loops/review/instructions.md index 23c75f8d..8079f335 100644 --- a/loops/review/instructions.md +++ b/loops/review/instructions.md @@ -1,5 +1,33 @@ +## start +Outline this decompose scope and identify candidate constraints. + +Do not edit Burnlist lifecycle files, delete the active item, move lifecycle +folders, or run `burnlist burn`/`loop complete`. Burnlist alone commits Burn +after convergence. + +## decompose +Break the next candidate into independent chunks and define success criteria. + +Do not edit Burnlist lifecycle files, delete the active item, move lifecycle +folders, or run `burnlist burn`/`loop complete`. Burnlist alone commits Burn +after convergence. + ## implement -Implement the assigned Burnlist item. Run the required repository checks and report one structured final result. +Make a first-pass implementation that satisfies the decomposed chunks. + +Do not edit Burnlist lifecycle files, delete the active item, move lifecycle +folders, or run `burnlist burn`/`loop complete`. Burnlist alone commits Burn +after convergence. ## review -Independently review the current candidate without writing. Report approve, reject, or escalate with bounded findings. +Review the current implementation for correctness and regression risk. + +## integrate +Merge reviewed work with baseline and resolve any follow-up. + +Do not edit Burnlist lifecycle files, delete the active item, move lifecycle +folders, or run `burnlist burn`/`loop complete`. Burnlist alone commits Burn +after convergence. + +## final-review +Do a final inspection for safety, quality, and handoff readiness. diff --git a/loops/review/review.loop b/loops/review/review.loop index fc7b5711..05ee5580 100644 --- a/loops/review/review.loop +++ b/loops/review/review.loop @@ -1,21 +1,34 @@ - - - - - - + + + + + + + + + + + - - - - - + + + + + + + + + + + + + diff --git a/ovens/catalog.json b/ovens/catalog.json index 86c88e9c..a0d65eea 100644 --- a/ovens/catalog.json +++ b/ovens/catalog.json @@ -24,6 +24,17 @@ "maturity": "shipped", "runtimeCompatibility": "burnlist-oven-runtime@1" }, + { + "id": "loop-progress", + "version": "0.1.0", + "inputContract": "checklist-progress@1", + "renderContract": "checklist-progress@1", + "dataInput": "json-payload", + "producer": "burnlist-checklist-progress", + "routeKind": "burnlist-lens", + "maturity": "shipped", + "runtimeCompatibility": "burnlist-oven-runtime@1" + }, { "id": "model-lab", "version": "0.1.0", diff --git a/ovens/checklist/checklist.oven b/ovens/checklist/checklist.oven index d378765a..ed513008 100644 --- a/ovens/checklist/checklist.oven +++ b/ovens/checklist/checklist.oven @@ -17,7 +17,7 @@ - + diff --git a/ovens/loop-progress/instructions.md b/ovens/loop-progress/instructions.md new file mode 100644 index 00000000..94b7de81 --- /dev/null +++ b/ovens/loop-progress/instructions.md @@ -0,0 +1,24 @@ +# Loop Progress + +`loop-progress.oven` is a compact, read-only lens over canonical Burnlist item +and Loop Run data. It answers what is happening, why it matters, which +subsystem and declared files are in scope, and where that work sits in the +whole Burnlist architecture. + +## Data Shape + +- Input mode: `json-payload`. +- Runtime validator: `validateGenericJsonData`. +- Starter data: none. +- Render contract: `checklist-progress@1`. + +## State Contract + +- Canonical sources: the selected item contract, active checklist order, + frozen Loop assignment, and item-scoped Run projection. +- Hook activity is displayed as unavailable until a bounded hook projection is + part of the canonical payload. It is never inferred from declared files. + +The Oven is declarative and read-only. Selecting an item changes its declared +context and assigned Loop preview; it does not change the authoritative Run or +its active node. diff --git a/ovens/loop-progress/loop-progress.oven b/ovens/loop-progress/loop-progress.oven new file mode 100644 index 00000000..0ff6c309 --- /dev/null +++ b/ovens/loop-progress/loop-progress.oven @@ -0,0 +1,7 @@ + + + + + + + diff --git a/scripts/package-paths.json b/scripts/package-paths.json index 4b9ade7e..8ee71d8e 100644 --- a/scripts/package-paths.json +++ b/scripts/package-paths.json @@ -2,8 +2,8 @@ "LICENSE", "README.md", "bin/burnlist.mjs", - "dashboard/dist/assets/index-CNSeyTpO.js", - "dashboard/dist/assets/index-CWvgOwCY.css", + "dashboard/dist/assets/index-BVVV40J5.js", + "dashboard/dist/assets/index-BmWMxaDn.css", "dashboard/dist/favicon.svg", "dashboard/dist/index.html", "loops/review/instructions.md", @@ -24,6 +24,8 @@ "ovens/differential-testing/example/candidate.json", "ovens/differential-testing/example/reference.json", "ovens/differential-testing/instructions.md", + "ovens/loop-progress/instructions.md", + "ovens/loop-progress/loop-progress.oven", "ovens/model-lab/engine/model-lab-contract.mjs", "ovens/model-lab/engine/model-lab-handler.mjs", "ovens/model-lab/instructions.md", @@ -69,8 +71,15 @@ "skills/burnlist/references/differential-testing-adapter-sdk.md", "skills/burnlist/references/differential-testing-data.md", "skills/burnlist/references/getting-started.md", + "skills/burnlist/references/host-execution.md", "skills/burnlist/references/installation.md", "skills/burnlist/references/loop-capability-example.json", + "skills/burnlist/references/loop-providers/agy.md", + "skills/burnlist/references/loop-providers/claude-native.md", + "skills/burnlist/references/loop-providers/codex-cli.md", + "skills/burnlist/references/loop-providers/codex-native.md", + "skills/burnlist/references/loop-providers/custom.md", + "skills/burnlist/references/loop-providers/grok.md", "skills/burnlist/references/oven-authoring.md", "skills/burnlist/references/oven-contract.md", "skills/burnlist/references/oven-event-coordination.md", @@ -128,6 +137,7 @@ "src/loops/contracts/check-result.mjs", "src/loops/contracts/contract.mjs", "src/loops/contracts/finding.mjs", + "src/loops/contracts/host-execution.mjs", "src/loops/dsl/canonical.mjs", "src/loops/dsl/compile.mjs", "src/loops/dsl/diagnostics.mjs", @@ -138,12 +148,15 @@ "src/loops/dsl/ir-validate.mjs", "src/loops/dsl/loop-xml.mjs", "src/loops/dsl/package-read.mjs", + "src/loops/events/activity-projection.mjs", "src/loops/events/projection-events.mjs", "src/loops/run/binder.mjs", "src/loops/run/budgets.mjs", "src/loops/run/candidate.mjs", "src/loops/run/controller.mjs", "src/loops/run/current-authority.mjs", + "src/loops/run/host-execution.mjs", + "src/loops/run/production-runner.mjs", "src/loops/run/read-projection.mjs", "src/loops/run/run-artifacts.mjs", "src/loops/run/run-claim.mjs", @@ -156,6 +169,7 @@ "src/loops/run/run-store.mjs", "src/loops/run/runner.mjs", "src/loops/run/state-machine.mjs", + "src/loops/view/ascii.mjs", "src/loops/view/render.mjs", "src/ovens/built-in-handlers.mjs", "src/ovens/dsl/oven-compile.mjs", @@ -166,6 +180,7 @@ "src/ovens/dsl/xml-scan.mjs", "src/ovens/handlers/checklist.mjs", "src/ovens/handlers/generic-json-handler.mjs", + "src/ovens/handlers/loop-progress.mjs", "src/ovens/official-oven-catalog.mjs", "src/ovens/oven-contract.mjs", "src/ovens/oven-data-validate.mjs", diff --git a/scripts/verify-test-files.mjs b/scripts/verify-test-files.mjs index 5710f3bf..7b5e326f 100644 --- a/scripts/verify-test-files.mjs +++ b/scripts/verify-test-files.mjs @@ -89,24 +89,28 @@ export const verificationTestFiles = [ "src/loops/assignment/assignment.test.mjs", "src/loops/dsl/compile.test.mjs", "src/loops/dsl/package-read.test.mjs", + "src/loops/dsl/project-strategies.test.mjs", "src/loops/view/render.test.mjs", "src/loops/capabilities/capabilities.test.mjs", "src/loops/adapters/codex-cli.test.mjs", "src/loops/adapters/normalized-invocation.test.mjs", "src/loops/agents/profile.test.mjs", "src/loops/contracts/contracts.test.mjs", + "src/loops/contracts/host-execution.test.mjs", "src/loops/run/run-store.test.mjs", "src/loops/run/controller.test.mjs", "src/loops/run/current-authority.test.mjs", "src/loops/run/launch-authority.test.mjs", "src/loops/run/run-clock.test.mjs", "src/loops/events/projection-events.test.mjs", + "src/loops/events/activity-projection.test.mjs", "src/loops/run/run-journal.test.mjs", "src/loops/run/run-fold.test.mjs", "src/loops/run/state-machine.test.mjs", "src/loops/run/budgets.test.mjs", "src/loops/run/runner.test.mjs", "src/loops/run/binder.test.mjs", + "src/loops/run/run-claim.test.mjs", "src/loops/run/reviewer-isolation.test.mjs", "scripts/verify-source-scan.test.mjs", "ovens/streaming-diff/engine/streaming-diff-hook-adapters.test.mjs", diff --git a/scripts/verify.mjs b/scripts/verify.mjs index 3d2416e2..6fdaad9e 100755 --- a/scripts/verify.mjs +++ b/scripts/verify.mjs @@ -413,6 +413,7 @@ assertSkillSet(repoRoot, ["burnlist"]); const officialOvenExpectations = new Map([ ["checklist", { name: "Checklist", validator: "validateGenericJsonData" }], ["differential-testing", { name: "Differential Testing", validator: "validateDifferentialTestingRuntimeData" }], + ["loop-progress", { name: "Loop Progress", validator: "validateGenericJsonData" }], ["model-lab", { name: "Model Lab", validator: "validateModelLabRuntimeData" }], ["performance-tracing", { name: "Performance Tracing", validator: "validatePerformanceTracingRuntimeData" }], ["streaming-diff", { name: "Streaming Diff" }], diff --git a/skills/burnlist/SKILL.md b/skills/burnlist/SKILL.md index 5bebcd43..b6660805 100644 --- a/skills/burnlist/SKILL.md +++ b/skills/burnlist/SKILL.md @@ -32,6 +32,8 @@ Read references only when their trigger applies: - `references/oven-authoring.md`: authoring or inspecting Ovens from the `burnlist oven` CLI, the widget/format vocabulary, and source-binding conventions. - `references/creating-ovens.md`: authoring a new .oven declarative source (grammar, elements, binding, themes, compile-to-IR walkthrough). - `references/oven-event-coordination.md`: mandatory for multi-Burnlist worker coordination, generic Oven progress events, replayable subscriptions, and event-triggered coordinator wakeups. +- `references/host-execution.md`: generic host claim/execute/report protocol for a prepared Loop Run; read before a host executes a claimed agent node. +- `references/loop-providers/.md`: optional bounded provider recipe for Claude native, Codex native, Codex CLI, AGY, Grok, or a custom host. Read only after choosing that provider; native hosts do not need a recipe to use the generic protocol. Do not load cold references for a normal single-item implementation unless needed. If a task touches a cold-rule area, read the matching reference before editing Burnlist state in that area. @@ -118,7 +120,7 @@ For detailed examples and banned narration, read `references/burnlist-visible-ou ## Dashboard Boundary -The live dashboard is mandatory as an observer, but agents do not own its server lifecycle. Do not start a per-plan server, manage ports, claim a dashboard URL, or inspect dashboard UI unless the user asks or dashboard behavior is the task. +The live dashboard is mandatory as an observer, but agents do not own its server lifecycle. Do not start a per-plan server, manage ports, claim a dashboard URL, or inspect dashboard UI unless the user asks or dashboard behavior is the task. Adopting a shipped, pinned, read-only observer Oven is safe and needs no separate permission; it does not authorize any dashboard server lifecycle or UI inspection. The dashboard scans lifecycle folders and is read-only. `burnlist.md` and lifecycle folder location are canonical task state. Dashboard charts/logs/repo graphs are observer evidence, not implementation proof. @@ -180,6 +182,21 @@ Accepted profile models are `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna`, and `gpt-5.3-codex-spark`; efforts are `minimal`, `low`, `medium`, `high`, `xhigh`, and `max`. +`builtin:codex-cli` is currently the only Burnlist-managed process adapter. +That does not limit native host orchestration: Codex normally runs Codex +subagents natively, Claude normally runs Claude subagents natively, and any host +may claim and report Loop nodes through native orchestration. Claude, Grok, AGY, +and custom hosts are not managed adapters, though they may optionally invoke +Codex through a `codex-cli` recipe; do not promise or configure them as managed +Loop backends. + +For a host-orchestrated agent node, read `references/host-execution.md` before +claiming it. The core protocol is provider-neutral: claim the prepared node, +execute its exact bounded envelope through an available native or external +mechanism, then return one bound report. Never invent a transition or execute a +managed check. Provider recipes are optional detail, not a prerequisite for +native host orchestration. + ```sh burnlist agent profile add maker --adapter builtin:codex-cli --binary --model --effort --authority write burnlist agent profile add reviewer --adapter builtin:codex-cli --binary --model --effort --authority read diff --git a/skills/burnlist/references/host-execution.md b/skills/burnlist/references/host-execution.md new file mode 100644 index 00000000..009bde23 --- /dev/null +++ b/skills/burnlist/references/host-execution.md @@ -0,0 +1,102 @@ +# Host-executed Loop nodes + +Use this reference when a host executes a prepared agent node. It is the whole +provider-neutral contract; choose a `loop-providers/` recipe only if its +invocation details help. + +## Claim, execute, report + +1. Create and inspect the Run, then claim its current agent node: + + ```sh + burnlist loop create item:# + burnlist loop claim run: + ``` + + `claim` returns canonical JSON containing a `claim` and an `execution` + envelope. Treat both as opaque, bounded, single-use authority. Do not alter + or reconstruct their fields. + +2. Decode, inspect, and execute the exact prepared invocation. `execution` is + a canonical `burnlist-loop-host-execution@1` object: base64-decode its + `invocationInput` to the canonical `burnlist-loop-invocation-input@1` JSON. + That object contains base64 `instructionBytes`, `itemText`, and + `candidateContext`; decode them as UTF-8 only for the executor. It also + declares the node `mode`, `role`, `authority`, legal outcomes, and required + evidence ids. Keep `execution`, its + `dispatchAuthority`, and every identity field byte-for-byte unchanged. Do + not edit, reserialize, or replace those authority-bearing inputs. + + The host may choose its provider, subagent arrangement, and additional + non-authoritative context, but must preserve the complete correlation tuple: + + ```text + runId + nodeId + attempt + claimId + invocationId + assignmentId + + recipeRevision + policyRevision + inputCandidate + ``` + + Execute the supplied instructions against the exact assigned item and + candidate context. Respect read/write authority and legal outcomes. Do not choose + a graph edge, declare a destination, or run a deterministic check; Burnlist + owns all three. + +3. Produce one canonical `burnlist-loop-host-report@1` whose `agent-result@1` + is bound to that same tuple, with only a legal node-mode outcome. Copy the + identity values exactly from `execution`; do not use these placeholders as + invented values. A task accepts `complete`; review accepts `approve`, + `reject`, or `escalate`. A task has empty `findings` and + `resolvedFindingIds`. + + Task report with unavailable telemetry: + + ```json + {"schema":"burnlist-loop-host-report@1","result":{"schema":"agent-result@1","runId":"","nodeId":"","attempt":,"claimId":"","assignmentId":"","invocationId":"","recipeRevision":"","policyRevision":"","inputCandidate":"","outcome":"complete","findings":[],"resolvedFindingIds":[]},"telemetry":null} + ``` + + Reviewer report with the full optional telemetry shape (replace only values + the host observed; the sample timing and token fields are intentionally + unavailable): + + ```json + {"schema":"burnlist-loop-host-report@1","result":{"schema":"agent-result@1","runId":"","nodeId":"","attempt":,"claimId":"","assignmentId":"","invocationId":"","recipeRevision":"","policyRevision":"","inputCandidate":"","outcome":"approve","findings":[],"resolvedFindingIds":[]},"telemetry":{"schema":"burnlist-loop-host-telemetry@1","provenance":"host-reported","executor":"provider-executor","displayName":null,"provider":null,"model":null,"effort":null,"startedAt":null,"completedAt":null,"inputTokens":null,"outputTokens":null}} + ``` + + For a `reject` or `escalate`, carry forward every still-open finding, add + any new content-addressed finding, and resolve only currently open ids. + Optional telemetry always uses `burnlist-loop-host-telemetry@1` with + `provenance: "host-reported"`; unknown values remain `null`, never guessed. + +4. Write the report to a regular, non-symlink file no larger than 256 KiB and + submit it by the claim id: + + ```sh + burnlist loop report cl1-sha256: --result ./host-report.json + ``` + + An identical retransmission is safe. A conflicting replay, expired or stale + claim, workspace/candidate drift, illegal outcome, or identity mismatch fails + closed. Inspect the Run again rather than editing a rejected report. + +5. If the host cannot finish, do not report a made-up result. Resolve its live + claim once: + + ```sh + burnlist loop abandon cl1-sha256: --reason host-cancelled + ``` + + The only reasons are `host-cancelled`, `host-lost`, and (after expiry) + `expired`. Recovery can terminalize as `needs-human`; it does not make the + host the transition authority. + +## Ownership and observability + +The host owns invocation and optional best-effort telemetry. Burnlist owns the +frozen graph, claim authority, validation, deterministic checks, transition +selection, canonical journal, and item completion. Native provider execution, +the managed `builtin:codex-cli` process adapter, and external tools all feed +this same boundary. `builtin:codex-cli` is optional for cross-engine use, not +the only Loop mechanism. + +Installable skills and Streaming Diff hooks are independent. Neither installs, +selects, or starts a host executor; hooks only provide optional observational +activity. diff --git a/skills/burnlist/references/loop-providers/agy.md b/skills/burnlist/references/loop-providers/agy.md new file mode 100644 index 00000000..c4ae8077 --- /dev/null +++ b/skills/burnlist/references/loop-providers/agy.md @@ -0,0 +1,17 @@ +# AGY recipe + +Use only when AGY is available to the host. Read +[Host-executed Loop nodes](../host-execution.md) first, and defer to an +installed AGY skill for its current invocation syntax. + +- **Invocation ownership:** the host invokes and supervises AGY; AGY is not a + Burnlist-managed adapter. +- **Context freedom:** choose AGY's prompt and session mechanics while carrying + the exact prepared envelope as the authority-bearing input. +- **Identity:** preserve the complete generic correlation tuple through AGY and + bind its final result to that tuple. +- **Telemetry:** use AGY data only when actually returned and label it + `host-reported`; otherwise leave it `null`. +- **Fallback:** use native host execution, Codex CLI where deliberately + configured, or abandon the claim. Never invent provider output or a graph + transition. diff --git a/skills/burnlist/references/loop-providers/claude-native.md b/skills/burnlist/references/loop-providers/claude-native.md new file mode 100644 index 00000000..16889768 --- /dev/null +++ b/skills/burnlist/references/loop-providers/claude-native.md @@ -0,0 +1,17 @@ +# Claude native recipe + +Use only when Claude is the host and its native subagent facility is available. +Read [Host-executed Loop nodes](../host-execution.md) first. + +- **Invocation ownership:** Claude hosts and supervises its own Claude + subagent; Burnlist does not launch it. +- **Context freedom:** pass the exact claim envelope unchanged plus concise, + non-authoritative working context. Do not substitute a new item, candidate, + or destination. +- **Identity:** preserve every field in the generic correlation tuple in the + subagent handoff and copy them unchanged into the final bound report. +- **Telemetry:** report provider values only when Claude exposes them; label + them `host-reported`, otherwise use `null`. +- **Fallback:** if native delegation is unavailable, execute directly as the + Claude host or use another available mechanism, then follow the same report + and abandonment rules. Do not configure Claude as a managed adapter. diff --git a/skills/burnlist/references/loop-providers/codex-cli.md b/skills/burnlist/references/loop-providers/codex-cli.md new file mode 100644 index 00000000..1fd74061 --- /dev/null +++ b/skills/burnlist/references/loop-providers/codex-cli.md @@ -0,0 +1,32 @@ +# Codex CLI recipe + +Read [Host-executed Loop nodes](../host-execution.md) first. There are two +different Codex CLI paths; never describe a direct host launch as the managed +adapter. + +## Managed `builtin:codex-cli` + +- **Invocation ownership:** the Burnlist runner launches and cancels the + configured process through `loop run` or `loop resume`; the host does not + claim/report that managed invocation. +- **Context and identity:** the runner supplies the frozen input and preserves + its identity internally; neither the child nor a supervising host selects an + edge. +- **Telemetry:** runner measurements are `managed`; missing values are + unavailable, not inferred. +- **Fallback:** if managed setup is unavailable, use native host orchestration + or the direct host-claim path below; do not call that fallback an adapter. + +## Direct Codex CLI for a host claim + +- **Invocation ownership:** the host launches, supervises, and cancels its own + Codex CLI process after `burnlist loop claim`; this is not `builtin:codex-cli`. +- **Context freedom:** give Codex the exact decoded invocation input and only + bounded supplemental context. Do not ask it to choose a transition. +- **Identity:** retain the complete generic correlation tuple and submit the + one bound host report under the original claim id. +- **Telemetry:** observations from this host-owned process are + `host-reported`; unavailable fields are `null`. +- **Fallback:** use native Codex orchestration, another available host + mechanism, or abandon the claim. `builtin:codex-cli` remains the only + Burnlist-managed process adapter, not the only way a Loop node can run. diff --git a/skills/burnlist/references/loop-providers/codex-native.md b/skills/burnlist/references/loop-providers/codex-native.md new file mode 100644 index 00000000..6fa17dd4 --- /dev/null +++ b/skills/burnlist/references/loop-providers/codex-native.md @@ -0,0 +1,16 @@ +# Codex native recipe + +Use only when Codex is the host and its native subagent facility is available. +Read [Host-executed Loop nodes](../host-execution.md) first. + +- **Invocation ownership:** Codex normally orchestrates Codex subagents + natively; Burnlist does not launch them. +- **Context freedom:** forward the exact envelope and any bounded local context + needed to execute it, without changing authority fields or graph intent. +- **Identity:** carry the complete generic correlation tuple through the + handoff and final report exactly once. +- **Telemetry:** report observed Codex/model/effort/usage only as + `host-reported`; retain unavailable values as `null`. +- **Fallback:** execute as the host if native delegation is unavailable. A + `codex-cli` process recipe is optional for a different host, not required for + Codex-native execution. diff --git a/skills/burnlist/references/loop-providers/custom.md b/skills/burnlist/references/loop-providers/custom.md new file mode 100644 index 00000000..d55d9678 --- /dev/null +++ b/skills/burnlist/references/loop-providers/custom.md @@ -0,0 +1,15 @@ +# Custom host recipe + +Use for a host not covered by another recipe. Read +[Host-executed Loop nodes](../host-execution.md) first. + +- **Invocation ownership:** the custom host owns all execution, process, and + cancellation behavior; Burnlist owns no custom runtime plugin. +- **Context freedom:** it may add local context, but must execute the supplied + envelope unchanged and may not add graph authority. +- **Identity:** propagate the complete generic correlation tuple unchanged into + the sole final report. +- **Telemetry:** use only observed values with `host-reported`; leave absent + fields `null`. +- **Fallback:** execute directly if possible; otherwise abandon the live claim + with a permitted reason. A custom host is never silently a managed adapter. diff --git a/skills/burnlist/references/loop-providers/grok.md b/skills/burnlist/references/loop-providers/grok.md new file mode 100644 index 00000000..0c6f27c5 --- /dev/null +++ b/skills/burnlist/references/loop-providers/grok.md @@ -0,0 +1,16 @@ +# Grok recipe + +Use only when Grok is available to the host. Read +[Host-executed Loop nodes](../host-execution.md) first, and defer to an +installed Grok skill for its current invocation syntax. + +- **Invocation ownership:** the host invokes and supervises Grok; Grok is not a + Burnlist-managed adapter. +- **Context freedom:** select Grok session mechanics while retaining the exact + prepared envelope as the only transition-relevant input. +- **Identity:** preserve the full generic correlation tuple and bind the final + report to it without reconstruction. +- **Telemetry:** emit Grok/model/usage only when known, as `host-reported`; + unknown values are `null`. +- **Fallback:** use a native host path, a deliberately configured Codex CLI + recipe, or abandon the claim. Do not promise Grok as a managed Loop backend. diff --git a/skills/burnlist/references/oven-authoring.md b/skills/burnlist/references/oven-authoring.md index d2c8326b..180569f8 100644 --- a/skills/burnlist/references/oven-authoring.md +++ b/skills/burnlist/references/oven-authoring.md @@ -92,7 +92,9 @@ burnlist oven fork `ovenRevision` plus the same origin and catalog metadata. - `use` adopts a shipped Oven and, only if the shipped directory contains an exact `example/data.json`, validates and installs that example. Without one, - it adopts only and prints the exact `oven set` next step. + it adopts only. A catalogued Burnlist lens is supplied by core transport, so + it prints that no binding or `oven set` is needed; a data-bound Oven prints + the exact `oven set` next step. - `set` reads JSON from a file, stdin (`-`), or an inline JSON argument, validates before mutation, and atomically publishes the canonical data file plus binding. @@ -120,7 +122,8 @@ selects the repository whose binding storage is used. ## Validated `use` and `set` -Use a shipped Oven in a repository, then set its data when no starter exists: +Use a data-bound shipped Oven in a repository, then set its data when no +starter exists: ```sh burnlist oven use differential-testing --repo . @@ -130,12 +133,15 @@ burnlist oven set differential-testing ./differential-testing.json --repo . `use` keeps the existing `adopt` and pin semantics. It looks only for the non-executable shipped file `ovens//example/data.json`. When that exact file exists, `use` validates it and transactionally installs the Oven, data, and -binding. When it does not exist, adoption still succeeds, no data or binding is -created, and the CLI prints `burnlist oven set --repo `. -Reference/candidate inputs, test fixtures, schemas, and instructions are never -converted into starter data. The optional example is not vendored and does not -enter the Oven revision or pin. `--force` has the same deterministic duplicate -behavior as `adopt`. +binding. When it does not exist, adoption still succeeds and no data or binding +is created. The official catalog's `routeKind` decides the next step: a +`burnlist-lens` (for example Checklist or Loop Progress) is fed by Burnlist +core transport and needs neither a binding nor `oven set`; a `repo-oven` prints +`burnlist oven set --repo `. Never fabricate a JSON payload +for a core-fed Oven. Reference/candidate inputs, test fixtures, schemas, and +instructions are never converted into starter data. The optional example is not +vendored and does not enter the Oven revision or pin. `--force` has the same +deterministic duplicate behavior as `adopt`. Examples and fixtures test only the mechanics they target. The official catalog does not qualify acceptance or retained evidence. Its generated agent guidance diff --git a/src/cli/commands-help.test.mjs b/src/cli/commands-help.test.mjs index 2285c6d7..6bd06c9a 100644 --- a/src/cli/commands-help.test.mjs +++ b/src/cli/commands-help.test.mjs @@ -47,6 +47,9 @@ test("top-level and Oven help expose the validated use and set flow", () => { assert.match(top.stdout, /burnlist oven <[^\n]*use[^\n]*set[^\n]*>/u); assert.match(top.stdout, /burnlist loop view \[--repo \]/u); assert.match(top.stdout, /burnlist loop create \[--repo \]/u); + assert.match(top.stdout, /burnlist loop next\|claim \[--repo \]/u); + assert.match(top.stdout, /burnlist loop report --result \[--repo \]/u); + assert.match(top.stdout, /burnlist loop abandon --reason \[--repo \]/u); assert.match(top.stdout, /burnlist loop list \[--repo \]/u); assert.match(top.stdout, /burnlist loop run\|resume \[--repo \]/u); assert.match(top.stdout, /burnlist loop status\|inspect \[--repo \]/u); @@ -92,7 +95,7 @@ test("nested Loop help snapshots every Stage 1 control", () => { try { const result = run(context.directory, ["loop", "--help"]); assert.equal(result.status, 0, result.stderr); - for (const command of ["create", "list", "run|pause|resume|stop|complete", "status|inspect", "reconcile"]) { + for (const command of ["create", "next|claim", "report --result ", "abandon --reason ", "list", "run|pause|resume|stop|complete", "status|inspect", "reconcile"]) { assert.match(result.stdout, new RegExp(`burnlist loop ${command}`, "u")); } assert.match(result.stdout, /pause\|resume\|stop\|complete /u); @@ -201,7 +204,7 @@ test("Review Loop documentation command matrix runs against the production fixtu const executed = spawnSync(process.execPath, [cli, "loop", "run", runId, "--repo", repo], { cwd: repo, encoding: "utf8", - env: { ...process.env, BURNLIST_FAKE_COUNTER: counter, BURNLIST_FAKE_OUTCOMES: "complete,approve" }, + env: { ...process.env, BURNLIST_FAKE_COUNTER: counter, BURNLIST_FAKE_OUTCOMES: "complete,complete,complete,approve,complete,approve" }, }); assert.equal(executed.status, 0, executed.stderr); assert.equal(JSON.parse(executed.stdout).state, "converged"); @@ -219,7 +222,7 @@ test("Review Loop documentation command matrix runs against the production fixtu writeFileSync(counter, "0"); const resumed = spawnSync(process.execPath, [cli, "loop", "resume", pausedRun, "--repo", repo], { cwd: repo, encoding: "utf8", - env: { ...process.env, BURNLIST_FAKE_COUNTER: counter, BURNLIST_FAKE_OUTCOMES: "complete,approve" }, + env: { ...process.env, BURNLIST_FAKE_COUNTER: counter, BURNLIST_FAKE_OUTCOMES: "complete,complete,complete,approve,complete,approve" }, }); assert.equal(resumed.status, 0, resumed.stderr); assert.equal(JSON.parse(resumed.stdout).state, "converged"); @@ -235,8 +238,8 @@ test("Review Loop documentation command matrix runs against the production fixtu assert.equal(loop("assign", reconciledRef, "loop:builtin:review").status, 0); const reconciledRun = JSON.parse(loop("create", reconciledRef).stdout).runId; const store = runStore(repo), acquired = store.acquireLease(reconciledRun); - store.append(reconciledRun, acquired.lease, "node-started", { nodeId: "implement", attempt: 1 }); - store.append(reconciledRun, acquired.lease, "invocation-started", { nodeId: "implement", attempt: 1, invocationId: "a".repeat(32) }); + store.append(reconciledRun, acquired.lease, "node-started", { nodeId: "start", attempt: 1 }); + store.append(reconciledRun, acquired.lease, "invocation-started", { nodeId: "start", attempt: 1, invocationId: "a".repeat(32) }); const reconciled = loop("reconcile", reconciledRun, "--recovery-proof", acquired.recoveryProof); assert.equal(reconciled.status, 0, reconciled.stderr); assert.equal(JSON.parse(reconciled.stdout).state, "needs-human"); diff --git a/src/cli/loop-cli.mjs b/src/cli/loop-cli.mjs index 2df8dfab..68415d9c 100644 --- a/src/cli/loop-cli.mjs +++ b/src/cli/loop-cli.mjs @@ -1,5 +1,6 @@ #!/usr/bin/env node import { resolve } from "node:path"; +import { closeSync, constants, fstatSync, lstatSync, openSync, readSync } from "node:fs"; import { assignLoopItem, prepareItemMutation, unassignLoopItem } from "../loops/assignment/assignment.mjs"; import { resolveLoopAuthority } from "../loops/assignment/resolver.mjs"; import { loopConfigUsage, runLoopConfigCli } from "./loop-config-cli.mjs"; @@ -10,22 +11,53 @@ import { createProductionRun, createStoredProductionRunRunner } from "../loops/r import { completeLoopRun } from "../loops/completion/completion.mjs"; function usageText() { return loopConfigUsage(); } +function usageError(message = usageText()) { return Object.assign(new Error(message), { exitCode: 2 }); } function options(tokens) { - const positionals = []; let repo = null, recoveryProof = null; + const positionals = []; let repo = null, recoveryProof = null, resultFile = null, reason = null; for (let index = 0; index < tokens.length; index += 1) { if (tokens[index] === "--repo") { - if (repo !== null) throw new Error("--repo must be specified at most once."); + if (repo !== null) throw usageError("--repo must be specified at most once."); repo = tokens[++index]; - if (!repo || repo.startsWith("--")) throw new Error("--repo requires a path."); + if (!repo || repo.startsWith("--")) throw usageError("--repo requires a path."); } else if (tokens[index] === "--recovery-proof") { - if (recoveryProof !== null) throw new Error("--recovery-proof must be specified at most once."); - recoveryProof = tokens[++index]; if (!/^[a-f0-9]{64}$/u.test(recoveryProof ?? "")) throw new Error("--recovery-proof requires a 64-character lowercase hex value."); + if (recoveryProof !== null) throw usageError("--recovery-proof must be specified at most once."); + recoveryProof = tokens[++index]; if (!/^[a-f0-9]{64}$/u.test(recoveryProof ?? "")) throw usageError("--recovery-proof requires a 64-character lowercase hex value."); } - else if (tokens[index].startsWith("--")) throw new Error(`Unknown option: ${tokens[index]}`); + else if (tokens[index] === "--result") { + if (resultFile !== null) throw usageError("--result must be specified at most once."); + resultFile = tokens[++index]; if (!resultFile || resultFile.startsWith("--")) throw usageError("--result requires a file."); + } + else if (tokens[index] === "--reason") { + if (reason !== null) throw usageError("--reason must be specified at most once."); + reason = tokens[++index]; if (!reason || reason.startsWith("--")) throw usageError("--reason requires host-cancelled, host-lost, or expired."); + } + else if (tokens[index].startsWith("--")) throw usageError(`Unknown option: ${tokens[index]}`); else positionals.push(tokens[index]); } - return { positionals, recoveryProof, repo: repo ? resolve(process.cwd(), repo) : resolveUmbrella(process.cwd()) }; + return { positionals, recoveryProof, resultFile, reason, repo: repo ? resolve(process.cwd(), repo) : resolveUmbrella(process.cwd()) }; +} +function validateVerbOptions(verb, opts) { + if (opts.recoveryProof && verb !== "reconcile") throw usageError(); + if (opts.resultFile && verb !== "report" || verb === "report" && !opts.resultFile) throw usageError(); + if (opts.reason && verb !== "abandon" || verb === "abandon" && !opts.reason) throw usageError(); +} +function resultBytes(path) { + let fd; + try { + const target = resolve(process.cwd(), path), entry = lstatSync(target); + if (!entry.isFile() || entry.isSymbolicLink() || entry.size < 2 || entry.size > 262_144) throw new Error("--result file is unsafe or exceeds bounds."); + fd = openSync(target, constants.O_RDONLY | constants.O_NONBLOCK | (constants.O_NOFOLLOW ?? 0)); + const opened = fstatSync(fd); if (opened.dev !== entry.dev || opened.ino !== entry.ino || opened.size !== entry.size) throw new Error("--result file changed while opening."); + const bytes = Buffer.allocUnsafe(opened.size); let offset = 0; + while (offset < bytes.length) { const count = readSync(fd, bytes, offset, bytes.length - offset); if (count <= 0) throw new Error("--result file changed while opening."); offset += count; } + const after = fstatSync(fd), leaf = lstatSync(target); + if (after.dev !== opened.dev || after.ino !== opened.ino || after.size !== opened.size || leaf.dev !== entry.dev || leaf.ino !== entry.ino || leaf.size !== entry.size) throw new Error("--result file changed while opening."); + return bytes; + } finally { if (fd !== undefined) closeSync(fd); } +} +function publicClaim(value) { + return { schema: "burnlist-loop-host-claim-response@1", claim: value.claim, execution: JSON.parse(value.envelope.toString("utf8")) }; } export async function renderLoopView({ selector, repoRoot, runReader }) { @@ -40,30 +72,40 @@ export async function runLoopCli(tokens, { runReader, runnerFor, stdout = proces const value = await runLoopConfigCli(tokens); stdout.write(value.output); return value; } const [verb, ...rest] = tokens; const opts = options(rest); + validateVerbOptions(verb, opts); if (verb === "create") { - if (opts.positionals.length !== 1 || opts.recoveryProof) { const error = new Error(usageText()); error.exitCode = 2; throw error; } + if (opts.positionals.length !== 1) throw usageError(); const store = runStore(opts.repo), result = await createProductionRun({ repoRoot: opts.repo, store, itemRef: opts.positionals[0] }); stdout.write(`${JSON.stringify({ schema: "burnlist-loop-status@1", ...result.projection })}\n`); return result; } if (verb === "complete") { - if (opts.positionals.length !== 1 || opts.recoveryProof) { const error = new Error(usageText()); error.exitCode = 2; throw error; } + if (opts.positionals.length !== 1) throw usageError(); const result = completeLoopRun({ repoRoot: opts.repo, runId: opts.positionals[0] }); stdout.write(`${JSON.stringify({ schema: "burnlist-loop-completion@1", ...result })}\n`); return result; } - if (["list", "status", "inspect", "run", "pause", "resume", "stop", "reconcile"].includes(verb)) { + if (["list", "status", "inspect", "next", "claim", "report", "abandon", "run", "pause", "resume", "stop", "reconcile"].includes(verb)) { const allowed = verb === "list" ? 0 : 1; - if (opts.positionals.length !== allowed) { const error = new Error(usageText()); error.exitCode = 2; throw error; } + if (opts.positionals.length !== allowed) throw usageError(); const store = runStore(opts.repo); - if (opts.recoveryProof && verb !== "reconcile") { const error = new Error(usageText()); error.exitCode = 2; throw error; } const suppliedRunnerFor = runnerFor ?? ((runId) => createStoredProductionRunRunner({ repoRoot: opts.repo, store, runId })); const runners = new Map(), runtimeRunnerFor = (runId) => { if (!runners.has(runId)) runners.set(runId, suppliedRunnerFor(runId)); return runners.get(runId); }; - const controller = createLoopController({ store, runnerFor: runtimeRunnerFor }); + const controller = createLoopController({ store, runnerFor: runtimeRunnerFor, repoRoot: opts.repo }); const result = verb === "list" ? controller.list() : verb === "status" ? controller.status(opts.positionals[0]) : verb === "inspect" ? controller.inspect(opts.positionals[0]) + : verb === "next" ? controller.inspect(opts.positionals[0]) + : verb === "claim" ? publicClaim(controller.claim(opts.positionals[0])) + : verb === "report" ? controller.report(opts.positionals[0], resultBytes(opts.resultFile)) + : verb === "abandon" ? (() => { + const runId = store.resolveClaimRef(opts.positionals[0]); + if (store.read(runId).execution.terminal) { const error = new Error("ClaimRef is stale"); error.exitCode = 1; throw error; } + const active = controller.readClaim(runId); + if (!active || active.claim.claimId !== opts.positionals[0]) { const error = new Error("ClaimRef is stale"); error.exitCode = 1; throw error; } + return controller.abandonClaim(runId, { ...active.claim, reason: opts.reason }); + })() : verb === "pause" ? controller.pause(opts.positionals[0]) : verb === "stop" ? controller.stop(opts.positionals[0]) : verb === "reconcile" ? controller.reconcile(opts.positionals[0], opts.recoveryProof ? { generation: store.read(opts.positionals[0]).execution.generation, recoveryProof: opts.recoveryProof } : null) diff --git a/src/cli/loop-cli.test.mjs b/src/cli/loop-cli.test.mjs index d81bf1c2..a7db469a 100644 --- a/src/cli/loop-cli.test.mjs +++ b/src/cli/loop-cli.test.mjs @@ -105,17 +105,17 @@ test("loop view rejects duplicate, missing, and unknown --repo options", () => { const context = fixture(); try { const duplicate = runCommand(context.repo, ["loop", "view", "review", "--repo", context.repo, "--repo", context.repo]); - assert.equal(duplicate.status, 1); + assert.equal(duplicate.status, 2); assert.equal(duplicate.stdout, ""); assert.match(duplicate.stderr, /--repo must be specified at most once/u); const missing = runCommand(context.repo, ["loop", "view", "review", "--repo"]); - assert.equal(missing.status, 1); + assert.equal(missing.status, 2); assert.equal(missing.stdout, ""); assert.match(missing.stderr, /--repo requires a path/u); const unknown = runCommand(context.repo, ["loop", "view", "review", "--repo", context.repo, "--mystery"]); - assert.equal(unknown.status, 1); + assert.equal(unknown.status, 2); assert.equal(unknown.stdout, ""); assert.match(unknown.stderr, /Unknown option: --mystery/u); } finally { context.cleanup(); } diff --git a/src/cli/loop-config-cli.mjs b/src/cli/loop-config-cli.mjs index 26e163d1..547aa249 100644 --- a/src/cli/loop-config-cli.mjs +++ b/src/cli/loop-config-cli.mjs @@ -10,7 +10,7 @@ import { resolveUmbrella } from "./umbrella.mjs"; const profileUsage = "Usage: burnlist agent profile add --adapter builtin:codex-cli --binary --model --effort --authority read|write [--repo ]"; const routeUsage = "Usage: burnlist route set --profile [--repo ]"; const agentUsage = `${profileUsage}\n burnlist agent doctor [--repo ]`; -const loopUsage = "Usage: burnlist loop assign [--repo ] | burnlist loop unassign [--repo ] | burnlist loop view [--repo ]\n burnlist loop create [--repo ]\n burnlist loop run|pause|resume|stop|complete [--repo ]\n burnlist loop list [--repo ] | burnlist loop status|inspect [--repo ]\n burnlist loop reconcile --recovery-proof [--repo ]\n burnlist loop capability inspect [--repo ]\n burnlist loop capability trust --revision cp1-sha256: --grants [--repo ]\n burnlist loop setup status [--repo ]"; +const loopUsage = "Usage: burnlist loop assign [--repo ] | burnlist loop unassign [--repo ] | burnlist loop view [--repo ]\n burnlist loop create [--repo ]\n burnlist loop next|claim [--repo ]\n burnlist loop report --result [--repo ]\n burnlist loop abandon --reason [--repo ]\n burnlist loop run|pause|resume|stop|complete [--repo ]\n burnlist loop list [--repo ] | burnlist loop status|inspect [--repo ]\n burnlist loop reconcile --recovery-proof [--repo ]\n burnlist loop capability inspect [--repo ]\n burnlist loop capability trust --revision cp1-sha256: --grants [--repo ]\n burnlist loop setup status [--repo ]"; function fail(message, exitCode = 2) { throw Object.assign(new Error(message), { exitCode }); } function parse(tokens, allowed = []) { diff --git a/src/cli/loop-runtime-cli.test.mjs b/src/cli/loop-runtime-cli.test.mjs index 11b55d54..2bb75c9e 100644 --- a/src/cli/loop-runtime-cli.test.mjs +++ b/src/cli/loop-runtime-cli.test.mjs @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; @@ -9,6 +9,8 @@ import test from "node:test"; import { createProductionRunAuthority, fixtureItemRef } from "../loops/run/run-test-fixtures.mjs"; import { runLoopCli } from "./loop-cli.mjs"; import { runStore } from "../loops/run/run-store.mjs"; +import { createLoopController } from "../loops/run/controller.mjs"; +import { prepareHostClaim } from "../loops/run/host-execution.mjs"; import { createProductionRun } from "../loops/run/binder.mjs"; import { fixtureRunId } from "../loops/run/run-test-fixtures.mjs"; @@ -19,6 +21,14 @@ function command(repo, args, env = {}) { assert.equal(result.stderr, "", `${args.join(" ")}: ${result.stderr}`); assert.equal(result.status, 0, `${args.join(" ")}: ${result.stdout}`); return result.stdout; } function created(repo) { return JSON.parse(command(repo, ["create", fixtureItemRef])).runId; } +function hostReport(execution, outcome) { + return { schema: "burnlist-loop-host-report@1", result: { + schema: "agent-result@1", runId: execution.runId, nodeId: execution.nodeId, attempt: execution.attempt, + claimId: execution.claimId, assignmentId: execution.assignmentId, invocationId: execution.invocationId, + recipeRevision: execution.recipeRevision, policyRevision: execution.policyRevision, + inputCandidate: execution.inputCandidate, outcome, findings: [], resolvedFindingIds: [], + }, telemetry: null }; +} test("real CLI control reads are stable, list is absent-state read-only, and stored authority drives run/resume", (t) => { const directory = mkdtempSync(join(tmpdir(), "m6-cli-")); t.after(() => rmSync(directory, { recursive: true, force: true })); @@ -35,7 +45,7 @@ test("real CLI control reads are stable, list is absent-state read-only, and sto assert.equal(command(repo, ["status", first]), status); assert.equal(command(repo, ["inspect", first]), inspect); const authority = join(runs, Buffer.from(first).toString("hex"), "dispatch-authority.json"), bytes = readFileSync(authority); const counter = join(directory, "counter"); writeFileSync(counter, "0"); - const completed = JSON.parse(command(repo, ["run", first], { BURNLIST_FAKE_COUNTER: counter, BURNLIST_FAKE_OUTCOMES: "complete,approve" })); + const completed = JSON.parse(command(repo, ["run", first], { BURNLIST_FAKE_COUNTER: counter, BURNLIST_FAKE_OUTCOMES: "complete,complete,complete,approve,complete,approve" })); assert.equal(completed.state, "converged"); assert.deepEqual(readFileSync(authority), bytes); const blocked = spawnSync(process.execPath, [cli, "loop", "create", fixtureItemRef, "--repo", repo], { cwd: repo, encoding: "utf8" }); assert.equal(blocked.status, 1); assert.match(blocked.stderr, /current Run is still executable/u); @@ -44,7 +54,7 @@ test("real CLI control reads are stable, list is absent-state read-only, and sto test("real CLI fences active control and proof-gates reconcile", (t) => { const directory = mkdtempSync(join(tmpdir(), "m6-cli-fence-")); t.after(() => rmSync(directory, { recursive: true, force: true })); const { repo } = createProductionRunAuthority(join(directory, "repo")), runId = created(repo); - const held = spawnSync(process.execPath, ["--input-type=module", "-e", `import{runStore}from${JSON.stringify(new URL("../loops/run/run-store.mjs", import.meta.url).href)};const s=runStore(process.argv[1]),a=s.acquireLease(process.argv[2]);s.append(process.argv[2],a.lease,"node-started",{nodeId:"implement",attempt:1});s.append(process.argv[2],a.lease,"invocation-started",{nodeId:"implement",attempt:1,invocationId:"${"a".repeat(32)}"});process.stdout.write(a.recoveryProof);`, repo, runId], { cwd: repo, encoding: "utf8" }); + const held = spawnSync(process.execPath, ["--input-type=module", "-e", `import{runStore}from${JSON.stringify(new URL("../loops/run/run-store.mjs", import.meta.url).href)};const s=runStore(process.argv[1]),a=s.acquireLease(process.argv[2]);s.append(process.argv[2],a.lease,"node-started",{nodeId:"start",attempt:1});s.append(process.argv[2],a.lease,"invocation-started",{nodeId:"start",attempt:1,invocationId:"${"a".repeat(32)}"});process.stdout.write(a.recoveryProof);`, repo, runId], { cwd: repo, encoding: "utf8" }); assert.equal(held.status, 0, held.stderr); const result = spawnSync(process.execPath, [cli, "loop", "pause", runId, "--repo", repo], { cwd: repo, encoding: "utf8" }); assert.equal(result.status, 1); assert.match(result.stderr, /active foreground owner/u); @@ -56,7 +66,7 @@ test("real CLI fences active control and proof-gates reconcile", (t) => { test("loop complete is the public, idempotent completion command", (t) => { const directory = mkdtempSync(join(tmpdir(), "m8-cli-complete-")); t.after(() => rmSync(directory, { recursive: true, force: true })); const { repo } = createProductionRunAuthority(join(directory, "repo")), runId = created(repo), counter = join(directory, "counter"); - writeFileSync(counter, "0"); assert.equal(JSON.parse(command(repo, ["run", runId], { BURNLIST_FAKE_COUNTER: counter, BURNLIST_FAKE_OUTCOMES: "complete,approve" })).state, "converged"); + writeFileSync(counter, "0"); assert.equal(JSON.parse(command(repo, ["run", runId], { BURNLIST_FAKE_COUNTER: counter, BURNLIST_FAKE_OUTCOMES: "complete,complete,complete,approve,complete,approve" })).state, "converged"); assert.equal(JSON.parse(command(repo, ["complete", runId])).alreadyApplied, false); assert.equal(JSON.parse(command(repo, ["complete", runId])).alreadyApplied, true); }); @@ -119,3 +129,200 @@ test("a second CLI SIGINT reaches controlled stop before the foreground runner s assert.equal(processObject.listenerCount("SIGINT"), 0); assert.equal(JSON.parse(output.value).runId, runId); }); + +test("host CLI claims once, rejects claim theft, accepts one bound report, rejects conflict, and observes the next node after restart", (t) => { + const directory = mkdtempSync(join(tmpdir(), "s1-host-cli-")); t.after(() => rmSync(directory, { recursive: true, force: true })); + const { repo } = createProductionRunAuthority(join(directory, "repo")), runId = created(repo); + assert.equal(JSON.parse(command(repo, ["next", runId])).currentNode, "start"); + const first = JSON.parse(command(repo, ["claim", runId])); + const replayed = spawnSync(process.execPath, [cli, "loop", "claim", runId, "--repo", repo], { cwd: repo, encoding: "utf8" }); + assert.equal(replayed.status, 1); + assert.equal(replayed.stdout, ""); + assert.match(replayed.stderr, /already claimed/u); + assert.equal(runStore(repo).read(runId).journal.filter((record) => record.value.type === "external-claim-bound").length, 1); + const binding = first.execution; + const report = { + schema: "burnlist-loop-host-report@1", + result: { + schema: "agent-result@1", runId: binding.runId, nodeId: binding.nodeId, attempt: binding.attempt, + claimId: binding.claimId, assignmentId: binding.assignmentId, invocationId: binding.invocationId, + recipeRevision: binding.recipeRevision, policyRevision: binding.policyRevision, + inputCandidate: binding.inputCandidate, outcome: "complete", findings: [], resolvedFindingIds: [], + }, + telemetry: null, + }; + const resultPath = join(directory, "report.json"); writeFileSync(resultPath, `${JSON.stringify(report)}\n`); + const runRefReport = spawnSync(process.execPath, [cli, "loop", "report", runId, "--result", resultPath, "--repo", repo], { cwd: repo, encoding: "utf8" }); + assert.equal(runRefReport.status, 1); assert.match(runRefReport.stderr, /invalid ClaimRef/u); + const missingClaim = `cl1-sha256:${"f".repeat(64)}`; + const missing = spawnSync(process.execPath, [cli, "loop", "report", missingClaim, "--result", resultPath, "--repo", repo], { cwd: repo, encoding: "utf8" }); + assert.equal(missing.status, 1); assert.match(missing.stderr, /ClaimRef is missing/u); + assert.equal(JSON.parse(command(repo, ["report", binding.claimId, "--result", resultPath])).currentNode, "decompose"); + assert.equal(JSON.parse(command(repo, ["report", binding.claimId, "--result", resultPath])).currentNode, "decompose"); + writeFileSync(resultPath, `${JSON.stringify({ ...report, telemetry: { + schema: "burnlist-loop-host-telemetry@1", provenance: "host-reported", executor: "fixture", + displayName: null, provider: null, model: null, effort: null, startedAt: null, completedAt: null, + inputTokens: null, outputTokens: null, + } })}\n`); + const conflict = spawnSync(process.execPath, [cli, "loop", "report", binding.claimId, "--result", resultPath, "--repo", repo], { cwd: repo, encoding: "utf8" }); + assert.equal(conflict.status, 1); assert.match(conflict.stderr, /conflicts/u); + assert.equal(JSON.parse(command(repo, ["next", runId])).currentNode, "decompose"); +}); + +test("host CLI rejects misplaced result options and unsafe files, then abandons only its active claim", (t) => { + const directory = mkdtempSync(join(tmpdir(), "h4-host-cli-")); t.after(() => rmSync(directory, { recursive: true, force: true })); + const { repo } = createProductionRunAuthority(join(directory, "repo")), runId = created(repo); + const claimed = JSON.parse(command(repo, ["claim", runId])).execution, reportPath = join(directory, "report.json"); + writeFileSync(reportPath, `${JSON.stringify(hostReport(claimed, "complete"))}\n`); + const invoke = (...args) => spawnSync(process.execPath, [cli, "loop", ...args, "--repo", repo], { cwd: repo, encoding: "utf8" }); + const misplaced = invoke("claim", runId, "--result", reportPath); + assert.equal(misplaced.status, 2); assert.match(misplaced.stderr, /Usage: burnlist loop/u); + const missing = invoke("report", claimed.claimId); + assert.equal(missing.status, 2); assert.match(missing.stderr, /Usage: burnlist loop/u); + const symlink = join(directory, "report-link.json"); symlinkSync(reportPath, symlink); + const unsafe = invoke("report", claimed.claimId, "--result", symlink); + assert.equal(unsafe.status, 1); assert.match(unsafe.stderr, /unsafe or exceeds bounds/u); + assert.equal(runStore(repo).read(runId).projection.currentNode, "start"); + const invalidReason = invoke("abandon", claimed.claimId, "--reason", "retry"); + assert.equal(invalidReason.status, 1); assert.match(invalidReason.stderr, /invalid host claim abandonment reason/u); + const abandoned = invoke("abandon", claimed.claimId, "--reason", "host-cancelled"); + assert.equal(abandoned.status, 0, abandoned.stderr); assert.equal(JSON.parse(abandoned.stdout).state, "needs-human"); + const stale = invoke("abandon", claimed.claimId, "--reason", "host-cancelled"); + assert.equal(stale.status, 1); assert.match(stale.stderr, /stale|missing/u); +}); + +test("Loop option syntax and ownership fail as usage errors before every verb dispatch", (t) => { + const directory = mkdtempSync(join(tmpdir(), "h4-option-cli-")); t.after(() => rmSync(directory, { recursive: true, force: true })); + const { repo } = createProductionRunAuthority(join(directory, "repo")); + const invoke = (...args) => spawnSync(process.execPath, [cli, "loop", ...args, "--repo", repo], { cwd: repo, encoding: "utf8" }); + for (const args of [ + ["create", fixtureItemRef, "--result", "report.json"], ["complete", "run:bad", "--reason", "host-lost"], + ["assign", fixtureItemRef, "loop:builtin:review", "--recovery-proof", "a".repeat(64)], ["unassign", fixtureItemRef, "--result", "report.json"], + ["view", fixtureItemRef, "--reason", "host-lost"], ["list", "--recovery-proof", "a".repeat(64)], + ]) { + const result = invoke(...args); assert.equal(result.status, 2, args.join(" ")); assert.match(result.stderr, /Usage: burnlist loop/u); + } + for (const args of [ + ["list", "--unknown"], ["list", "--repo", repo], ["list", "--repo", repo], ["list", "--result"], + ["list", "--recovery-proof", "not-hex"], ["list", "--reason", "host-lost", "--reason", "host-cancelled"], + ]) { + const result = invoke(...args); assert.equal(result.status, 2, args.join(" ")); assert.doesNotMatch(result.stderr, /Loop control:/u); + } +}); + +test("a review claim refuses workspace drift before its report can advance", (t) => { + const directory = mkdtempSync(join(tmpdir(), "h3-review-drift-")); t.after(() => rmSync(directory, { recursive: true, force: true })); + const { repo } = createProductionRunAuthority(join(directory, "repo")), runId = created(repo), resultPath = join(directory, "report.json"); + for (const expected of ["start", "decompose", "implement"]) { + const execution = JSON.parse(command(repo, ["claim", runId])).execution; + assert.equal(execution.nodeId, expected); + writeFileSync(resultPath, `${JSON.stringify(hostReport(execution, "complete"))}\n`); + command(repo, ["report", execution.claimId, "--result", resultPath]); + } + const store = runStore(repo), validation = store.acquireLease(runId).lease, candidateId = store.read(runId).execution.candidate.id; + store.append(runId, validation, "node-started", { nodeId: "validate", attempt: 1 }); + store.append(runId, validation, "invocation-started", { nodeId: "validate", attempt: 1, invocationId: "a".repeat(32) }); + store.append(runId, validation, "invocation-result", { invocationId: "a".repeat(32), kind: "pass", summary: "trusted", outputBytes: 0, candidateId }); + store.append(runId, validation, "edge-taken", { from: "validate", on: "pass", to: "review" }); store.releaseLease(runId, validation); + const controller = createLoopController({ store, repoRoot: repo }); + const capturedBeforeDrift = prepareHostClaim({ repoRoot: repo, replay: store.read(runId), authority: store.readAuthority(runId) }); + const preClaimDrift = join(repo, "src", "drift-before-review-claim.txt"); writeFileSync(preClaimDrift, "changed\n"); + assert.throws(() => controller.claim(runId, capturedBeforeDrift), /candidate drift before review claim/u); + rmSync(preClaimDrift); + const review = JSON.parse(command(repo, ["claim", runId])).execution; + assert.equal(review.nodeId, "review"); + writeFileSync(join(repo, "src", "drift-after-review-claim.txt"), "changed\n"); + writeFileSync(resultPath, `${JSON.stringify(hostReport(review, "approve"))}\n`); + const rejected = spawnSync(process.execPath, [cli, "loop", "report", review.claimId, "--result", resultPath, "--repo", repo], { cwd: repo, encoding: "utf8" }); + assert.equal(rejected.status, 1); assert.match(rejected.stderr, /candidate drifted/u); + assert.equal(store.read(runId).projection.currentNode, "review"); +}); + +test("host report retries complete every committed transaction tail after deliberate restart cuts", (t) => { + for (const cut of ["afterExternalReportAccepted", "afterExternalEdgeTaken"]) { + const directory = mkdtempSync(join(tmpdir(), `h2-host-${cut}-`)); t.after(() => rmSync(directory, { recursive: true, force: true })); + const { repo } = createProductionRunAuthority(join(directory, "repo")), runId = created(repo); + const claimed = JSON.parse(command(repo, ["claim", runId])).execution; + const report = Buffer.from(`${JSON.stringify({ + schema: "burnlist-loop-host-report@1", + result: { schema: "agent-result@1", runId: claimed.runId, nodeId: claimed.nodeId, attempt: claimed.attempt, + claimId: claimed.claimId, assignmentId: claimed.assignmentId, invocationId: claimed.invocationId, + recipeRevision: claimed.recipeRevision, policyRevision: claimed.policyRevision, inputCandidate: claimed.inputCandidate, + outcome: "complete", findings: [], resolvedFindingIds: [] }, telemetry: null, + })}\n`); + const cutStore = runStore(repo, { hooks: { [cut]() { throw new Error(`deliberate ${cut}`); } } }); + const interrupted = createLoopController({ store: cutStore, repoRoot: repo }); + assert.throws(() => interrupted.report(claimed.claimId, report), new RegExp(`deliberate ${cut}`, "u")); + const restartedStore = runStore(repo), restarted = createLoopController({ store: restartedStore, repoRoot: repo }); + assert.equal(restarted.report(claimed.claimId, report).currentNode, "decompose"); + const replay = restartedStore.read(runId); + assert.equal(replay.execution.lease, null); + assert.equal(replay.journal.filter((record) => record.value.type === "external-report-accepted").length, 1); + assert.equal(replay.journal.filter((record) => record.value.type === "edge-taken").length, 1); + assert.equal(replay.journal.filter((record) => record.value.type === "lease-released").length, 1); + assert.equal(restarted.report(claimed.claimId, report).currentNode, "decompose"); + } +}); + +test("concurrent external claims expose the envelope to exactly one winner", async (t) => { + const directory = mkdtempSync(join(tmpdir(), "h2-host-claim-race-")); t.after(() => rmSync(directory, { recursive: true, force: true })); + const { repo } = createProductionRunAuthority(join(directory, "repo")), runId = created(repo); + const claim = () => new Promise((done) => { + const child = spawn(process.execPath, [cli, "loop", "claim", runId, "--repo", repo], { cwd: repo, stdio: ["ignore", "pipe", "pipe"] }); + let stdout = "", stderr = ""; + child.stdout.on("data", (chunk) => { stdout += chunk; }); child.stderr.on("data", (chunk) => { stderr += chunk; }); + child.on("close", (status) => done({ status, stdout, stderr })); + }); + const results = await Promise.all([claim(), claim()]), successful = results.filter((value) => value.status === 0); + assert.equal(successful.length, 1); + assert.match(results.find((value) => value.status !== 0).stderr, /locked|lease|active foreground owner|already claimed/u); + const retry = spawnSync(process.execPath, [cli, "loop", "claim", runId, "--repo", repo], { cwd: repo, encoding: "utf8" }); + assert.equal(retry.status, 1); + assert.match(retry.stderr, /already claimed/u); + assert.equal(runStore(repo).read(runId).journal.filter((record) => record.value.type === "external-claim-bound").length, 1); +}); + +test("near journal capacity terminalizes before an external report can strand its lease", (t) => { + const directory = mkdtempSync(join(tmpdir(), "h2-host-capacity-")); t.after(() => rmSync(directory, { recursive: true, force: true })); + const { repo } = createProductionRunAuthority(join(directory, "repo")), runId = created(repo); + const claimed = JSON.parse(command(repo, ["claim", runId])).execution, invalidations = []; + const report = Buffer.from(`${JSON.stringify({ schema: "burnlist-loop-host-report@1", + result: { schema: "agent-result@1", runId: claimed.runId, nodeId: claimed.nodeId, attempt: claimed.attempt, + claimId: claimed.claimId, assignmentId: claimed.assignmentId, invocationId: claimed.invocationId, + recipeRevision: claimed.recipeRevision, policyRevision: claimed.policyRevision, inputCandidate: claimed.inputCandidate, + outcome: "complete", findings: [], resolvedFindingIds: [] }, telemetry: null })}\n`); + // A reduced bound is a deterministic stand-in for records 252–255; the + // production ceiling remains MAX_JOURNAL_RECORDS. + const constrained = runStore(repo, { journalMaximum: 8, publishProjection(_root, replay) { invalidations.push(replay); } }); + const controller = createLoopController({ store: constrained, repoRoot: repo }); + assert.equal(controller.report(claimed.claimId, report).state, "budget-exhausted"); + const replay = constrained.read(runId); + assert.equal(replay.execution.lease, null); + assert.equal(replay.journal.filter((record) => record.value.type === "external-report-accepted").length, 0); + assert.equal(replay.journal.at(-1).value.type, "terminal-node-committed"); + assert.equal(invalidations.length, 1); + assert.equal(invalidations[0].projection.itemRef, fixtureItemRef); + assert.throws(() => controller.report(claimed.claimId, report), /stale lease/u); +}); + +test("near journal capacity cut after edge retries only the release tail", (t) => { + const directory = mkdtempSync(join(tmpdir(), "h2-host-capacity-cut-")); t.after(() => rmSync(directory, { recursive: true, force: true })); + const { repo } = createProductionRunAuthority(join(directory, "repo")), runId = created(repo); + const claimed = JSON.parse(command(repo, ["claim", runId])).execution, invalidations = []; + const report = Buffer.from(`${JSON.stringify({ schema: "burnlist-loop-host-report@1", + result: { schema: "agent-result@1", runId: claimed.runId, nodeId: claimed.nodeId, attempt: claimed.attempt, + claimId: claimed.claimId, assignmentId: claimed.assignmentId, invocationId: claimed.invocationId, + recipeRevision: claimed.recipeRevision, policyRevision: claimed.policyRevision, inputCandidate: claimed.inputCandidate, + outcome: "complete", findings: [], resolvedFindingIds: [] }, telemetry: null })}\n`); + const cutStore = runStore(repo, { journalMaximum: 9, hooks: { afterExternalEdgeTaken() { throw new Error("near-capacity cut"); } } }); + assert.throws(() => createLoopController({ store: cutStore, repoRoot: repo }).report(claimed.claimId, report), /near-capacity cut/u); + const restartedStore = runStore(repo, { journalMaximum: 9, publishProjection(_root, replay) { invalidations.push(replay); } }); + assert.equal(createLoopController({ store: restartedStore, repoRoot: repo }).report(claimed.claimId, report).currentNode, "decompose"); + const replay = restartedStore.read(runId); + assert.equal(replay.execution.lease, null); + assert.equal(replay.journal.filter((record) => record.value.type === "external-report-accepted").length, 1); + assert.equal(replay.journal.filter((record) => record.value.type === "edge-taken").length, 1); + assert.equal(replay.journal.filter((record) => record.value.type === "lease-released").length, 1); + assert.equal(invalidations.length, 1); + assert.equal(invalidations[0].projection.itemRef, fixtureItemRef); +}); diff --git a/src/cli/oven-cli.test.mjs b/src/cli/oven-cli.test.mjs index 81641f95..61cec148 100644 --- a/src/cli/oven-cli.test.mjs +++ b/src/cli/oven-cli.test.mjs @@ -211,6 +211,7 @@ test("oven list exposes the validated official catalog revision and origin", () assert.deepEqual(official.map(({ id }) => id), [ "checklist", "differential-testing", + "loop-progress", "model-lab", "performance-tracing", "streaming-diff", @@ -221,6 +222,7 @@ test("oven list exposes the validated official catalog revision and origin", () const expectedContracts = new Map([ ["checklist", "checklist-progress@1"], ["differential-testing", "burnlist-differential-testing-data@1"], + ["loop-progress", "checklist-progress@1"], ["model-lab", "burnlist-model-lab-data@1"], ["performance-tracing", "burnlist-differential-testing-data@1"], ["streaming-diff", "burnlist-streaming-diff-data@2"], diff --git a/src/cli/oven-use.mjs b/src/cli/oven-use.mjs index 757ece65..fc34e583 100644 --- a/src/cli/oven-use.mjs +++ b/src/cli/oven-use.mjs @@ -28,6 +28,17 @@ function adoptedOutput(saved, path) { return `Adopted Oven ${saved.id}@${saved.version} at ${path}`; } +function noExampleAdoptionOutput(saved, targetPath, repoRoot, catalogEntry) { + const adopted = `${adoptedOutput(saved, targetPath)}\nNo example/data.json is shipped; adopted without data.`; + // `routeKind` is catalog-owned semantics, not a presentation guess by id. + // Burnlist lenses are hydrated by core transport, so a hand-authored snapshot + // would be both unnecessary and misleading. + if (catalogEntry?.routeKind === "burnlist-lens") { + return `${adopted}\nData is supplied by Burnlist core transport; no local data action is needed.`; + } + return `${adopted}\nNext: burnlist oven set ${saved.id} --repo ${JSON.stringify(repoRoot)}`; +} + function eventWarning(error) { const detail = String(error?.message ?? error ?? "unknown error") .replace(/[\u0000-\u001f\u007f]+/gu, " ") @@ -86,7 +97,7 @@ export function useShippedOven({ publishAdoptionEvent(repoRoot, saved, timestamp, observerWarnings, publishDefinitionEvent); return { warnings: observerWarnings, - output: `${adoptedOutput(saved, targetPath)}\nNo example/data.json is shipped; adopted without data.\nNext: burnlist oven set ${saved.id} --repo ${JSON.stringify(repoRoot)}`, + output: noExampleAdoptionOutput(saved, targetPath, repoRoot, shipped.catalogEntry), }; } diff --git a/src/cli/oven-use.test.mjs b/src/cli/oven-use.test.mjs index 7b92c3d0..2a88b540 100644 --- a/src/cli/oven-use.test.mjs +++ b/src/cli/oven-use.test.mjs @@ -102,6 +102,18 @@ test("oven use adopts without data when no exact shipped example exists", (t) => assert.equal(existsSync(canonicalOvenDataPath(context.repo, "differential-testing")), false); }); +test("oven use leaves core-transport burnlist lenses unbound when no example is shipped", (t) => { + const context = fixture(t); + const result = runResult(context.repo, "oven", "use", "loop-progress", "--repo", context.repo); + + assert.equal(result.status, 0, result.stderr); + assert.equal(existsSync(vendoredOvenPath(context.repo, "loop-progress")), true); + assert.equal(existsSync(canonicalOvenDataPath(context.repo, "loop-progress")), false); + assert.equal(Object.hasOwn(readBindingStore(context.repo).bindings, "loop-progress"), false); + assert.match(result.stdout, /Data is supplied by Burnlist core transport; no local data action is needed\./u); + assert.doesNotMatch(result.stdout, /burnlist oven set/u); +}); + test("oven use validates, transactionally installs, and renders exact example data", async (t) => { const context = fixture(t); const payload = checklistFixture; diff --git a/src/loops/__fixtures__/minimal-review-e2e-dom.golden.json b/src/loops/__fixtures__/minimal-review-e2e-dom.golden.json index e39c9e23..259b5ca3 100644 --- a/src/loops/__fixtures__/minimal-review-e2e-dom.golden.json +++ b/src/loops/__fixtures__/minimal-review-e2e-dom.golden.json @@ -1,8 +1,8 @@ [ { "checkpoint": "needs-human", - "domBytes": 9373, - "domSha256": "051bc5760c63e3dc8532e84778dc2b31d87b7674a45f0dbae9ab4c13fb2adea8" + "domBytes": 11342, + "domSha256": "c155b4665ec241609dc96740a8e3579bd37e0666fe33723e94ca1f9b7440360e" }, { "checkpoint": "paused", @@ -16,8 +16,8 @@ }, { "checkpoint": "converged", - "domBytes": 9373, - "domSha256": "507e6c935ee89bd163e26d015862cb0ff3e5bf7d0b970d21542e8ac4dde1a897" + "domBytes": 11336, + "domSha256": "c23001c3b556cf064bbbe4d80dce9f36c71b36defda458b773d9a4636fec5990" }, { "checkpoint": "post-completion", diff --git a/src/loops/adapters/codex-cli.mjs b/src/loops/adapters/codex-cli.mjs index 27aa10e5..4cf01538 100644 --- a/src/loops/adapters/codex-cli.mjs +++ b/src/loops/adapters/codex-cli.mjs @@ -4,7 +4,10 @@ import { isAbsolute } from "node:path"; import { requestedCodexIdentity, validateAgentProfile, validateCodexProbe } from "../agents/profile.mjs"; const MAX_OUTPUT_BYTES = 1048576; -const MAX_JSONL_LINE_BYTES = 65536; +// Codex may emit one bounded JSONL item containing a large tool/result payload. +// The aggregate cap is the memory/safety boundary; a smaller per-line cap makes +// valid provider output fail depending on event chunking. +const MAX_JSONL_LINE_BYTES = MAX_OUTPUT_BYTES; const DIRECT_GUARANTEES = Object.freeze({ freshSession: "enforced", filesystemWriteDeny: "supervised", foregroundHandle: "supervised", cancellation: "supervised", lifecycle: "unsupported" }); function fail(message, code = "ELOOP_CODEX_ADAPTER") { throw Object.assign(new Error(`Codex adapter: ${message}`), { code }); } diff --git a/src/loops/adapters/codex-cli.test.mjs b/src/loops/adapters/codex-cli.test.mjs index 8a1435f1..a7f19c34 100644 --- a/src/loops/adapters/codex-cli.test.mjs +++ b/src/loops/adapters/codex-cli.test.mjs @@ -14,6 +14,7 @@ if (mode === "nonzero") { process.exit(7); } if (mode === "bad") { process.stdout.write("not-json\\n"); process.exit(0); } if (mode === "invalid-utf8") { process.stdout.write(Buffer.from([255,10])); process.exit(0); } if (mode === "oversize") { process.stdout.write(Buffer.alloc(1048577,97)); process.exit(0); } +if (mode === "large-line") { process.stdout.write(JSON.stringify({type:"item.completed",item:{type:"agent_message",text:"x".repeat(70000)}})+"\\n"); } const session = mode === "same-session" ? "same" : "fresh-" + process.pid; process.stdout.write(JSON.stringify({type:"thread.started",thread_id:session,model:args[args.indexOf("-m") + 1],version:"fake-1"})+"\\n"); process.stdout.write(JSON.stringify({type:"burnlist.capability",guarantees:{freshSession:"enforced",filesystemWriteDeny:"enforced",foregroundHandle:"enforced",cancellation:"enforced",lifecycle:"enforced"}})+"\\n"); @@ -75,6 +76,11 @@ test("JSONL is strict UTF-8 and usage overflow or absence is unavailable", async await withMode("bad", async () => assert.rejects(startCodexInvocation({ profile: maker, cwd: context.directory, prompt: "Bad." }).completion, (error) => error.code === "ELOOP_CODEX_OUTPUT")); await withMode("invalid-utf8", async () => assert.rejects(startCodexInvocation({ profile: maker, cwd: context.directory, prompt: "Bad bytes." }).completion, (error) => error.code === "ELOOP_CODEX_OUTPUT")); await withMode("oversize", async () => assert.rejects(startCodexInvocation({ profile: maker, cwd: context.directory, prompt: "Too much." }).completion, (error) => ["ELOOP_CODEX_OUTPUT_LIMIT", "ELOOP_CODEX_OUTPUT"].includes(error.code))); + await withMode("large-line", async () => { + const result = await startCodexInvocation({ profile: maker, cwd: context.directory, prompt: "Large valid line." }).completion; + assert.equal(result.outcome, "completed"); + assert.equal(result.events[0].item.text.length, 70000); + }); for (const mode of ["missing-usage", "overflow"]) await withMode(mode, async () => { const result = await startCodexInvocation({ profile: maker, cwd: context.directory, prompt: "Usage." }).completion; assert.equal(result.usage, null); assert.equal(result.usageStatus, "unavailable"); diff --git a/src/loops/adapters/normalized-invocation.mjs b/src/loops/adapters/normalized-invocation.mjs index c77c291a..67483391 100644 --- a/src/loops/adapters/normalized-invocation.mjs +++ b/src/loops/adapters/normalized-invocation.mjs @@ -1,9 +1,13 @@ import { startCodexInvocation } from "./codex-cli.mjs"; import { runTrustedCapability } from "../capabilities/runner.mjs"; import { parseBoundedObject } from "../contracts/contract.mjs"; +import { validateHostExecutionEnvelope } from "../contracts/host-execution.mjs"; const MAX_SUMMARY_BYTES = 1024; -const RESULT_TYPES = new Set(["complete", "approve", "reject", "escalate"]); +const AGENT_RESULT_TYPES = new Set(["complete", "approve", "reject", "escalate"]); +const FINAL_REPORT_KEYS = ["schema", "runId", "nodeId", "attempt", "claimId", "invocationId", "assignmentId", + "recipeRevision", "policyRevision", "inputCandidate", "outcome", "summary", "findings", "resolvedFindingIds"]; +const MAX_FINAL_BYTES = 65_536; const CANDIDATE = /^cm1-sha256:[a-f0-9]{64}$/u; const ASSIGNMENT = /^as1-sha256:[a-f0-9]{64}$/u; const CLAIM = /^cl1-sha256:[a-f0-9]{64}$/u; @@ -51,41 +55,61 @@ function finalPrompt(invocation, node, current) { return ["Burnlist Stage 1 invocation.", `run=${invocation.runId}`, `node=${node.id}`, `attempt=${invocation.attempt}`, `claim=${current.claimId}`, `invocation=${invocation.invocationId}`, `assignment=${current.assignmentId}`, `recipe=${current.recipeRevision}`, `policy=${current.policyRevision}`, `candidate=${current.inputCandidate}`, - `role=${node.role ?? "check"}`, "Your terminal response must be exactly one JSON object (no Markdown) with schema burnlist.agent-final@1, runId, nodeId, attempt, claimId, invocationId, assignmentId, recipeRevision, policyRevision, inputCandidate, outcome, summary.", + `role=${node.role ?? "check"}`, + "Burnlist owns graph routing and checks; host output is transport-only and idempotent by replaying the exact final report.", + "Report exactly one canonical object with this schema: burnlist.agent-final@1", + "Required report fields: schema, runId, nodeId, attempt, claimId, invocationId, assignmentId, recipeRevision, policyRevision, inputCandidate, outcome, summary, findings, resolvedFindingIds.", + "Do not include destination or any graph transitions; no host-selected edges are accepted.", + "Host result contracts are by node mode: task=>complete, review=>approve|reject|escalate.", + "Host claim envelopes are bounded; use exact prepared claim identity and expiry semantics from the transport contract.", + "Host telemetry is optional but, if provided, must use burnlist-loop-host-telemetry@1 with provenance=host-reported and non-negative token/time fields.", "FROZEN INSTRUCTIONS:", current.instructionBytes, "ASSIGNED ITEM:", current.itemText, - "CANDIDATE CONTEXT:", current.candidateContext, "REVIEW EVIDENCE:", JSON.stringify(current.reviewerEvidence)].join("\n"); + "CANDIDATE CONTEXT:", current.candidateContext, "REVIEW EVIDENCE:", JSON.stringify(current.reviewerEvidence), + "OPEN FINDINGS:", JSON.stringify(current.openFindings ?? [])].join("\n"); } function finalTexts(events) { const texts = []; + const finals = []; for (const event of events) { if (event?.type !== "item.completed" || !event.item || typeof event.item !== "object" || Array.isArray(event.item) || event.item.type !== "agent_message" || typeof event.item.text !== "string") continue; - texts.push(event.item.text); + const text = event.item.text; + try { + const value = parseBoundedObject(Buffer.from(text, "utf8"), { maximumBytes: MAX_FINAL_BYTES, maximumDepth: 2, label: "agent final" }); + if (value.schema === "burnlist.agent-final@1") finals.push(text); + } catch { + // preceding conversational messages are permitted. + } + texts.push(text); } if (!texts.length) fail("agent emitted no final message"); - return texts; + return { texts, finals }; } function agentResult(events, invocation, node, current) { - const texts = finalTexts(events), envelopes = []; + const { texts, finals } = finalTexts(events); + if (!finals.length) fail("malformed or missing terminal agent result"); + if (!finals.every((value) => value === finals[0])) fail("duplicate terminal reports differ"); + const envelopes = []; for (let index = 0; index < texts.length; index += 1) { try { - const value = parseBoundedObject(Buffer.from(texts[index], "utf8"), { maximumBytes: 65_536, maximumDepth: 2, label: "agent final" }); + const value = parseBoundedObject(Buffer.from(texts[index], "utf8"), { maximumBytes: MAX_FINAL_BYTES, maximumDepth: 2, label: "agent final" }); if (value.schema === "burnlist.agent-final@1") envelopes.push({ index, value }); - } catch { /* preceding conversational messages are permitted */ } + } catch { + // non-final conversational lines are tolerated. + } } - if (envelopes.length !== 1 || envelopes[0].index !== texts.length - 1) fail("malformed or ambiguous terminal agent result"); + if (envelopes.at(-1).index !== texts.length - 1 || envelopes.length !== finals.length) fail("malformed or ambiguous terminal agent result"); const result = envelopes[0].value; - const keys = ["schema", "runId", "nodeId", "attempt", "claimId", "invocationId", "assignmentId", - "recipeRevision", "policyRevision", "inputCandidate", "outcome", "summary"]; - if (!exact(result, keys) || result.schema !== "burnlist.agent-final@1" || result.runId !== invocation.runId + if (!exact(result, FINAL_REPORT_KEYS) || result.schema !== "burnlist.agent-final@1" || result.runId !== invocation.runId || result.nodeId !== node.id || result.attempt !== invocation.attempt || result.invocationId !== invocation.invocationId || result.claimId !== current.claimId || result.assignmentId !== current.assignmentId || result.recipeRevision !== current.recipeRevision || result.policyRevision !== current.policyRevision || result.inputCandidate !== current.inputCandidate - || !RESULT_TYPES.has(result.outcome) || typeof result.summary !== "string") fail("malformed or stale agent result"); + || !AGENT_RESULT_TYPES.has(result.outcome) || typeof result.summary !== "string") fail("malformed or stale agent result"); const allowed = node.mode === "task" ? result.outcome === "complete" : ["approve", "reject", "escalate"].includes(result.outcome); if (!allowed) fail("agent result is illegal for node"); - return { kind: result.outcome, summary: clean(result.summary) }; + return { kind: result.outcome, summary: clean(result.summary), outputBytes: Buffer.byteLength(texts.at(-1), "utf8"), + findings: result.findings, resolvedFindingIds: result.resolvedFindingIds }; } function boundedCompletion(handle, milliseconds) { if (!milliseconds) return handle.completion.then((value) => ({ value, timedOut: false })); @@ -118,12 +142,8 @@ export function createNormalizedInvocation({ repoRoot, routes, nodes, bindingFor || typeof startAgent !== "function" || typeof runCheck !== "function" || !Number.isSafeInteger(agentTimeoutMs) || agentTimeoutMs < 0 || agentTimeoutMs > 86_400_000) fail("invalid dispatcher input"); let active = null; - async function invoke(invocation) { - const node = nodes.get(invocation?.nodeId); - if (!invocation || typeof invocation !== "object" || !node || typeof node !== "object") fail("invalid invocation"); - let current; - try { current = binding(bindingFor(invocation, node)); } catch (error) { return Object.freeze({ kind: "error", summary: clean(error?.message), outputBytes: 0 }); } - if (node.mode === "review" && (!current.candidateContext || !current.reviewerEvidence.length)) + async function invokeBound(invocation, node, current, requireReviewEvidence = true) { + if (node.mode === "review" && (!current.candidateContext || requireReviewEvidence && !current.reviewerEvidence.length)) return Object.freeze({ kind: "error", summary: "review context is incomplete", outputBytes: 0 }); if (node.kind === "check") { try { @@ -149,12 +169,36 @@ export function createNormalizedInvocation({ repoRoot, routes, nodes, bindingFor if (completed.value.outcome === "cancelled") return Object.freeze({ kind: "cancelled", summary: "agent cancelled", outputBytes: 0 }); if (completed.value.outcome !== "completed") return Object.freeze({ kind: "error", summary: "agent process failed", outputBytes: 0 }); if (node.mode === "review") boundaryCandidate(candidateForBoundary, current.inputCandidate); - const result = agentResult(completed.value.events, invocation, node, current); - return Object.freeze({ ...result, outputBytes: Buffer.byteLength(JSON.stringify(completed.value.events), "utf8") }); + return Object.freeze(agentResult(completed.value.events, invocation, node, current)); } catch (error) { return Object.freeze({ kind: "error", summary: clean(error?.message), outputBytes: 0 }); } finally { active = null; } } + async function invoke(invocation) { + const node = nodes.get(invocation?.nodeId); + if (!invocation || typeof invocation !== "object" || !node || typeof node !== "object") fail("invalid invocation"); + let current; + try { current = binding(bindingFor(invocation, node)); } catch (error) { return Object.freeze({ kind: "error", summary: clean(error?.message), outputBytes: 0 }); } + const result = await invokeBound(invocation, node, current); + const { findings: _findings, resolvedFindingIds: _resolved, ...normalized } = result; + return Object.freeze(normalized); + } + /** Executes the exact controller-prepared agent input; no second prompt binding is invented. */ + async function invokePrepared(envelopeBytes) { + const envelope = validateHostExecutionEnvelope(envelopeBytes), input = envelope.input.value; + const node = nodes.get(input.nodeId); + if (!node || node.kind !== "agent") fail("prepared invocation does not target an agent node"); + const current = { + claimId: input.claimId, assignmentId: input.assignmentId, recipeRevision: input.recipeRevision, + policyRevision: input.policyRevision, inputCandidate: input.inputCandidate, + instructionBytes: Buffer.from(input.instructionBytes, "base64").toString("utf8"), + itemText: input.itemText ? Buffer.from(input.itemText, "base64").toString("utf8") : "legacy prepared claim", + candidateContext: Buffer.from(input.candidateContext, "base64").toString("utf8"), reviewerEvidence: input.reviewerEvidence, + openFindings: input.openFindings ?? [], + }; + return invokeBound(input, node, current); + } Object.defineProperty(invoke, "cancel", { value: () => active?.cancel?.() === true, enumerable: false }); Object.defineProperty(invoke, "active", { get: () => active !== null, enumerable: false }); + Object.defineProperty(invoke, "invokePrepared", { value: invokePrepared, enumerable: false }); return invoke; } diff --git a/src/loops/adapters/normalized-invocation.test.mjs b/src/loops/adapters/normalized-invocation.test.mjs index 55683991..76b276d2 100644 --- a/src/loops/adapters/normalized-invocation.test.mjs +++ b/src/loops/adapters/normalized-invocation.test.mjs @@ -1,12 +1,15 @@ import assert from "node:assert/strict"; import test from "node:test"; import { createNormalizedInvocation } from "./normalized-invocation.mjs"; +import { createDispatchAuthority, createInvocationInput } from "../contracts/agent-result.mjs"; +import { createHostExecutionEnvelope } from "../contracts/host-execution.mjs"; +import { rawSha256 } from "../dsl/hash.mjs"; const profile = { id: "fake" }; const routes = { implementation: { profile }, review: { profile: { id: "review" } } }; const call = Object.freeze({ runId: "run:01arz3ndektsv4rrffq69g5fav", nodeId: "implement", attempt: 1, invocationId: "a".repeat(32) }); -const maker = Object.freeze({ id: "implement", kind: "agent", mode: "task", role: "maker", instructions: "make" }); -const reviewer = Object.freeze({ id: "review", kind: "agent", mode: "review", role: "reviewer", instructions: "review" }); +const maker = Object.freeze({ id: "implement", kind: "agent", mode: "task", execution: "managed", intelligence: "standard", role: "maker", instructions: "make" }); +const reviewer = Object.freeze({ id: "review", kind: "agent", mode: "review", execution: "managed", intelligence: "strong", role: "reviewer", instructions: "review" }); const check = Object.freeze({ id: "verify", kind: "check", capability: "repo-verify" }); const current = Object.freeze({ claimId: `cl1-sha256:${"1".repeat(64)}`, assignmentId: `as1-sha256:${"b".repeat(64)}`, @@ -18,7 +21,7 @@ function final(invocation, node, outcome = "complete", extra = {}) { return JSON.stringify({ schema: "burnlist.agent-final@1", runId: invocation.runId, nodeId: node.id, attempt: invocation.attempt, claimId: current.claimId, invocationId: invocation.invocationId, assignmentId: current.assignmentId, recipeRevision: current.recipeRevision, policyRevision: current.policyRevision, - inputCandidate: current.inputCandidate, outcome, summary: "ok", ...extra }); + inputCandidate: current.inputCandidate, outcome, summary: "ok", findings: [], resolvedFindingIds: [], ...extra }); } function event(...texts) { return texts.map((text) => ({ type: "item.completed", item: { type: "agent_message", text } })); } function agent(events, outcome = "completed") { return () => ({ cancel() { return true; }, completion: Promise.resolve({ outcome, events }) }); } @@ -27,6 +30,21 @@ function dispatcher({ startAgent, runCheck, timeout = 0, bindingFor = () => curr candidateForBoundary, startAgent: startAgent ?? agent(event(final(call, maker))), runCheck: runCheck ?? (async () => ({ result: { outcome: "pass", inputCandidate: current.inputCandidate, timedOut: false, truncated: false }, evidence: Buffer.from("check") })), agentTimeoutMs: timeout }); } +function preparedReview(reviewerEvidence) { + const binding = { runId: call.runId, nodeId: "review", attempt: 1, claimId: current.claimId, assignmentId: current.assignmentId, + invocationId: `iv1-sha256:${"c".repeat(64)}`, recipeRevision: current.recipeRevision, policyRevision: current.policyRevision, + inputCandidate: current.inputCandidate }; + const instruction = Buffer.from(current.instructionBytes), input = createInvocationInput({ schema: "burnlist-loop-invocation-input@1", ...binding, + itemRevision: `id1-sha256:${"d".repeat(64)}`, execution: reviewer.execution, intelligence: reviewer.intelligence, instructionDigest: rawSha256(instruction), instructionBytes: instruction.toString("base64"), + mode: "review", role: "reviewer", authority: "read", legalOutcomes: ["approve", "reject", "escalate"], requires: [], + itemText: Buffer.from(current.itemText).toString("base64"), + candidateContext: Buffer.from(current.candidateContext).toString("base64"), reviewerEvidence }); + const authority = createDispatchAuthority({ schema: "burnlist-loop-dispatch-authority@1", state: "prepared-before-dispatch", ...binding, + itemRevision: input.value.itemRevision, inputSchema: input.value.schema, inputDigest: input.digest, inputByteLength: input.bytes.length }); + return createHostExecutionEnvelope({ schema: "burnlist-loop-host-execution@1", ...binding, issuedAt: 1, expiresAt: 3_601_000, + invocationInput: input.bytes.toString("base64"), dispatchAuthority: authority.bytes.toString("base64") }).bytes; +} + test("maps actual Codex agent-message finals for maker and fresh reviewer", async () => { const seen = []; const invoke = dispatcher({ startAgent: ({ profile: selected, prompt }) => { @@ -41,6 +59,17 @@ test("maps actual Codex agent-message finals for maker and fresh reviewer", asyn assert.deepEqual(seen, ["fake", "review"]); }); +test("budgets only the accepted terminal envelope, not provider event telemetry", async () => { + const envelope = final(call, maker); + const invoke = dispatcher({ startAgent: agent([ + { type: "provider.telemetry", payload: "x".repeat(300_000) }, + ...event(envelope), + ]) }); + const result = await invoke(call); + assert.equal(result.kind, "complete"); + assert.equal(result.outputBytes, Buffer.byteLength(envelope, "utf8")); +}); + test("rejects malformed or stale claim, revision, candidate, assignment, and invocation finals", async () => { for (const extra of [{ invocationId: "b".repeat(32) }, { claimId: `cl1-sha256:${"9".repeat(64)}` }, { recipeRevision: `er1-sha256:${"8".repeat(64)}` }, { policyRevision: `bp1-sha256:${"7".repeat(64)}` }, @@ -49,10 +78,19 @@ test("rejects malformed or stale claim, revision, candidate, assignment, and inv assert.equal((await invoke(call)).kind, "error"); } assert.equal((await dispatcher({ startAgent: agent(event("not json")) })(call)).kind, "error"); - assert.equal((await dispatcher({ startAgent: agent(event(final(call, maker), final(call, maker))) })(call)).kind, "error"); + assert.equal((await dispatcher({ startAgent: agent(event(final(call, maker), final(call, maker))) })(call)).kind, "complete"); assert.equal((await dispatcher({ bindingFor: () => ({ inputCandidate: current.inputCandidate }) })(call)).kind, "error"); }); +test("rejects duplicate terminal reports when report bytes differ", async () => { + const first = dispatcher({ startAgent: agent(event(final(call, maker, "complete", { summary: "first" }))) }) ; + const mismatch = dispatcher({ + startAgent: agent(event(final(call, maker, "complete", { summary: "first" }), final(call, maker, "complete", { summary: "second" }))), + })(call); + assert.equal((await first(call)).kind, "complete"); + assert.equal((await mismatch).kind, "error"); +}); + test("maps trusted checks including timeout and stale candidate without invoking an agent", async () => { const verify = { ...call, nodeId: "verify" }; assert.equal((await dispatcher()(verify)).kind, "pass"); @@ -83,6 +121,21 @@ test("agent deadline becomes lost if cancellation never settles", async () => { assert.deepEqual(await invoke(call), { kind: "lost", summary: "agent deadline cleanup unproven", outputBytes: 0 }); }); +test("prepared review dispatch keeps envelope evidence enforcement and exposes exact evidence", async () => { + const evidence = `artifact:sha256:${"e".repeat(64)}`; + let spawned = false; + const missing = dispatcher({ startAgent() { spawned = true; throw new Error("must not start"); } }); + assert.equal((await missing.invokePrepared(preparedReview([]))).kind, "error"); + assert.equal(spawned, false); + let prompt = ""; + const invoke = dispatcher({ startAgent: ({ prompt: value }) => { + prompt = value; + return { cancel() { return true; }, completion: Promise.resolve({ outcome: "cancelled", events: [] }) }; + } }); + assert.equal((await invoke.invokePrepared(preparedReview([evidence]))).kind, "cancelled"); + assert.match(prompt, new RegExp(evidence, "u")); +}); + test("summaries are valid UTF-8 and limited to 1024 bytes", async () => { const result = await dispatcher({ startAgent: agent(event(final(call, maker, "complete", { summary: "€".repeat(500) }))) })(call); assert.ok(Buffer.byteLength(result.summary, "utf8") <= 1024); diff --git a/src/loops/assignment/assignment.mjs b/src/loops/assignment/assignment.mjs index e7fafb42..23fa3d31 100644 --- a/src/loops/assignment/assignment.mjs +++ b/src/loops/assignment/assignment.mjs @@ -1,5 +1,5 @@ import { createHash, randomBytes } from "node:crypto"; -import { closeSync, constants, fsyncSync, openSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; +import { closeSync, constants, fsyncSync, lstatSync, openSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; import { basename, dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { compileLoopPackage } from "../dsl/compile.mjs"; @@ -23,12 +23,32 @@ function packageDirectory(loop) { return resolve(dirname(fileURLToPath(import.meta.url)), "../../../loops/review"); } export async function resolveBuiltin(loop) { + if (!loop.selector.startsWith("loop:builtin:")) fail(`installed Loop ${loop.selector} was not found`); const compiled = await compileLoopPackage(packageDirectory(loop)); if (!compiled.ok) fail(`installed Loop ${loop.selector} does not compile`); if (loop.executable && loop.executable !== compiled.revisions.executable) fail("LoopRef executable pin does not match current package"); return compiled; } +function localPackageDirectory(repoRoot, loop) { + const root = resolve(repoRoot), parts = [root, join(root, ".burnlist"), join(root, ".burnlist", "loops"), join(root, ".burnlist", "loops", loop.name)]; + for (const path of parts) { + let stat; try { stat = lstatSync(path); } catch { fail(`project Loop ${loop.selector} was not found`); } + if (stat.isSymbolicLink() || !stat.isDirectory()) fail(`project Loop ${loop.selector} has an unsafe package path`); + } + return parts.at(-1); +} + +/** Resolve one closed selector kind. Project packages are data-only compiler input. */ +export async function resolveLoopPackage({ repoRoot, loop }) { + if (loop.selector.startsWith("loop:builtin:")) return resolveBuiltin(loop); + const directory = localPackageDirectory(repoRoot, loop); + const compiled = await compileLoopPackage(directory, { loopFile: `${loop.name}.loop` }); + if (!compiled.ok) fail(`project Loop ${loop.selector} does not compile`); + if (loop.executable && loop.executable !== compiled.revisions.executable) fail("LoopRef executable pin does not match current package"); + return compiled; +} + function planPath(repoRoot, item) { const found = findBurnlistDir(repoRoot, item.burnlistId); if (found.lifecycle.folder !== "inprogress") fail(`burnlist ${item.burnlistId} is not inprogress`); @@ -58,7 +78,7 @@ export async function assignLoopItem({ repoRoot, itemRef, loopRef, prepared, sto const item = parseItemRef(itemRef), loop = parseLoopRef(loopRef); const token = prepared ?? prepareItemMutation({ repoRoot, itemRef: item.selector }); if (token.item?.selector !== item.selector) fail("prepared token item does not match"); - const compiled = await resolveBuiltin(loop); + const compiled = await resolveLoopPackage({ repoRoot, loop }); const target = planPath(repoRoot, item); const canonicalSelector = loop.selector; return withLock(target.directory, () => { const whole = readFileSync(target.path); const located = locateItemSpan(whole, item.itemId); diff --git a/src/loops/assignment/assignment.test.mjs b/src/loops/assignment/assignment.test.mjs index 86fb51fc..0497248e 100644 --- a/src/loops/assignment/assignment.test.mjs +++ b/src/loops/assignment/assignment.test.mjs @@ -31,6 +31,43 @@ test("selectors are closed, disjoint, and reject noncanonical ULID overflow", () assert.equal(parseRunRef("run:01arz3ndektsv4rrffq69g5fav").id.length, 26); for (const value of ["review", "item:260710-001#BUG-07", "run:81arz3ndektsv4rrffq69g5fav", "run:01ARZ3NDEKTSV4RRFFQ69G5FAV"]) assert.throws(() => parseRunRef(value), TypeError); assert.equal(selectorKind("loop:builtin:review"), "loop"); assert.equal(selectorKind(ref()), "item"); + assert.deepEqual(parseLoopRef("loop:project:whole-first"), { selector: "loop:project:whole-first", name: "whole-first", executable: null }); + for (const value of ["loop:project:../review", "loop:project:Review", "loop:local:review", "loop:project:review/extra"]) assert.throws(() => parseLoopRef(value), TypeError); +}); + +test("project Loop packages are contained, frozen, and show provenance drift without changing the item pin", async () => { + const context = fixture(); + try { + const packageDir = join(context.repo, ".burnlist", "loops", "whole-first"); + mkdirSync(packageDir, { recursive: true }); + const builtin = join(project, "loops", "review"); + writeFileSync(join(packageDir, "whole-first.loop"), readFileSync(join(builtin, "review.loop"))); + writeFileSync(join(packageDir, "instructions.md"), readFileSync(join(builtin, "instructions.md"))); + const result = await assignLoopItem({ repoRoot: context.repo, itemRef: ref(), loopRef: "loop:project:whole-first" }); + assert.equal(result.selector, "loop:project:whole-first"); + const pinned = await resolveLoopAuthority({ repoRoot: context.repo, selector: ref() }); + assert.equal(pinned.artifact.selector, "loop:project:whole-first"); + writeFileSync(join(packageDir, "instructions.md"), `${readFileSync(join(packageDir, "instructions.md"), "utf8")}\n`); + const drifted = await resolveLoopAuthority({ repoRoot: context.repo, selector: ref() }); + assert.equal(drifted.artifact.frozen.revisions.executable, pinned.artifact.frozen.revisions.executable); + assert.equal(drifted.executableDrift, true); + assert.notEqual(drifted.currentCompiled.revisions.package, pinned.artifact.frozen.revisions.package); + assert.match((await import("../view/render.mjs")).renderResolvedLoopView(drifted), /MODE: ITEM-PINNED/u); + } finally { context.cleanup(); } +}); + +test("project Loop resolver rejects symlinked and malformed contained packages", async () => { + const context = fixture(); + try { + const loops = join(context.repo, ".burnlist", "loops"), outside = join(dirname(context.repo), "outside-loop"); + mkdirSync(loops, { recursive: true }); mkdirSync(outside); + symlinkSync(outside, join(loops, "whole-first")); + await assert.rejects(assignLoopItem({ repoRoot: context.repo, itemRef: ref(), loopRef: "loop:project:whole-first" }), /unsafe package path/u); + rmSync(join(loops, "whole-first")); mkdirSync(join(loops, "whole-first")); + writeFileSync(join(loops, "whole-first", "whole-first.loop"), "not XML\n"); + writeFileSync(join(loops, "whole-first", "instructions.md"), "# instructions\n"); + await assert.rejects(assignLoopItem({ repoRoot: context.repo, itemRef: ref(), loopRef: "loop:project:whole-first" }), /does not compile/u); + } finally { context.cleanup(); } }); test("assignment writes canonical bytes, stores artifact before one replacement, and unassign restores exact bytes", async () => { diff --git a/src/loops/assignment/item-metadata.mjs b/src/loops/assignment/item-metadata.mjs index e4a84edf..53f8f428 100644 --- a/src/loops/assignment/item-metadata.mjs +++ b/src/loops/assignment/item-metadata.mjs @@ -61,7 +61,7 @@ export function findLoopMetadata(spanResult) { const meta = metadataAt(lines, markers[0]); if (meta.endLine > endLine) fail("Loop metadata escapes item span"); if (!AS.test(meta.values["Assignment-Id"]) || !ER.test(meta.values["Execution-Revision"]) || !LP.test(meta.values["Package-Revision"])) fail("noncanonical Loop digest"); - if (!/^loop:builtin:[a-z0-9]+(?:-[a-z0-9]+)*$/u.test(meta.values.Selector)) fail("noncanonical Loop selector"); + if (!/^loop:(?:builtin|project):[a-z0-9]+(?:-[a-z0-9]+)*$/u.test(meta.values.Selector)) fail("noncanonical Loop selector"); return meta; } diff --git a/src/loops/assignment/resolver.mjs b/src/loops/assignment/resolver.mjs index a70b34b0..f5a2a80b 100644 --- a/src/loops/assignment/resolver.mjs +++ b/src/loops/assignment/resolver.mjs @@ -3,7 +3,7 @@ import { join } from "node:path"; import { findBurnlistDir } from "../../cli/lifecycle-moves.mjs"; import { loadFrozenRecipe } from "../dsl/frozen.mjs"; import { locateItemSpan, validateAssignedItem } from "./item-metadata.mjs"; -import { resolveBuiltin } from "./assignment.mjs"; +import { resolveLoopPackage } from "./assignment.mjs"; import { parseItemRef, parseLoopRef, parseRunRef, selectorKind } from "./selectors.mjs"; import { assignmentStore } from "./store.mjs"; @@ -26,12 +26,12 @@ export async function resolveLoopAuthority({ repoRoot, selector, runReader }) { catch (error) { fail("E_LOOP_SELECTOR_INVALID", error.message); } if (kind === "loop") { const loop = parseLoopRef(selector, { allowViewSugar: true }); let compiled; - try { compiled = await resolveBuiltin(loop); } catch (error) { fail("E_LOOP_MISSING", error.message); } + try { compiled = await resolveLoopPackage({ repoRoot, loop }); } catch (error) { fail("E_LOOP_MISSING", error.message); } return { authority: "UNPINNED", selector: loop.selector, compiled, executableRevision: compiled.revisions.executable }; } if (kind === "item") { const item = parseItemRef(selector), value = assigned(repoRoot, item); - let current = null; try { current = await resolveBuiltin(parseLoopRef(value.meta.Selector)); } catch { /* pin remains authoritative */ } + let current = null; try { current = await resolveLoopPackage({ repoRoot, loop: parseLoopRef(value.meta.Selector) }); } catch { /* pin remains authoritative */ } let artifact; try { artifact = assignmentStore(repoRoot).load(value.meta["Assignment-Id"]); } catch { pinUnavailable(value.meta, current); } if (artifact.itemRef !== item.selector || artifact.assignmentId !== value.meta["Assignment-Id"] || artifact.assignedItemDigest !== value.meta.assignedDigest || artifact.unassignedItemDigest !== value.meta.unassignedDigest || artifact.executionRevision !== value.meta["Execution-Revision"] || artifact.packageRevision !== value.meta["Package-Revision"]) pinUnavailable(value.meta, current); // The assignment artifact remains the only executable authority. The fresh diff --git a/src/loops/assignment/selectors.mjs b/src/loops/assignment/selectors.mjs index a33b2502..a0fd174f 100644 --- a/src/loops/assignment/selectors.mjs +++ b/src/loops/assignment/selectors.mjs @@ -1,7 +1,7 @@ import { RUN_REF } from "../run/run-ref.mjs"; const HEX = "[a-f0-9]{64}"; -const LOOP = new RegExp(`^loop:builtin:([a-z0-9]+(?:-[a-z0-9]+)*)(?:@(er1-sha256:${HEX}))?$`, "u"); +const LOOP = new RegExp(`^loop:(builtin|project):([a-z0-9]+(?:-[a-z0-9]+)*)(?:@(er1-sha256:${HEX}))?$`, "u"); const ITEM = /^item:([0-9]{6}-[0-9]{3})#([A-Za-z0-9][A-Za-z0-9._-]{0,63})$/u; function reject(label, value) { throw new TypeError(`Invalid ${label}: ${String(value)}`); } @@ -11,7 +11,7 @@ export function parseLoopRef(value, { allowViewSugar = false } = {}) { if (allowViewSugar && value === "review") return { selector: "loop:builtin:review", name: "review", executable: null }; const match = typeof value === "string" ? LOOP.exec(value) : null; if (!match) reject("LoopRef", value); - return { selector: `loop:builtin:${match[1]}`, name: match[1], executable: match[2] ?? null }; + return { selector: `loop:${match[1]}:${match[2]}`, name: match[2], executable: match[3] ?? null }; } export function parseItemRef(value) { diff --git a/src/loops/assignment/store.mjs b/src/loops/assignment/store.mjs index 9648aa9b..8930bba1 100644 --- a/src/loops/assignment/store.mjs +++ b/src/loops/assignment/store.mjs @@ -23,7 +23,7 @@ function manifest(record, recipe) { function parseManifest(bytes, id) { let value; try { value = JSON.parse(bytes.toString("utf8")); } catch { fail("manifest is not JSON"); } if (!exact(value, MANIFEST_KEYS) || !Buffer.from(`${JSON.stringify(value)}\n`).equals(bytes)) fail("manifest is not canonical"); - if (value.schema !== "burnlist-loop-assignment@1" || value.assignmentId !== id || !ITEM.test(value.itemRef) || !/^loop:builtin:[a-z0-9]+(?:-[a-z0-9]+)*$/u.test(value.selector) || !/^er1-sha256:[a-f0-9]{64}$/u.test(value.executionRevision) || !/^lp1-sha256:[a-f0-9]{64}$/u.test(value.packageRevision) || !/^ls1-sha256:[a-f0-9]{64}$/u.test(value.sourceRevision) || !/^id1-sha256:[a-f0-9]{64}$/u.test(value.unassignedItemDigest) || !/^id1-sha256:[a-f0-9]{64}$/u.test(value.assignedItemDigest) || !DIGEST.test(value.frozenRecipeDigest) || !Number.isSafeInteger(value.frozenRecipeSize) || value.frozenRecipeSize < 1) fail("manifest has invalid bindings"); + if (value.schema !== "burnlist-loop-assignment@1" || value.assignmentId !== id || !ITEM.test(value.itemRef) || !/^loop:(?:builtin|project):[a-z0-9]+(?:-[a-z0-9]+)*$/u.test(value.selector) || !/^er1-sha256:[a-f0-9]{64}$/u.test(value.executionRevision) || !/^lp1-sha256:[a-f0-9]{64}$/u.test(value.packageRevision) || !/^ls1-sha256:[a-f0-9]{64}$/u.test(value.sourceRevision) || !/^id1-sha256:[a-f0-9]{64}$/u.test(value.unassignedItemDigest) || !/^id1-sha256:[a-f0-9]{64}$/u.test(value.assignedItemDigest) || !DIGEST.test(value.frozenRecipeDigest) || !Number.isSafeInteger(value.frozenRecipeSize) || value.frozenRecipeSize < 1) fail("manifest has invalid bindings"); return value; } diff --git a/src/loops/completion/completion.mjs b/src/loops/completion/completion.mjs index c86d5172..ba2b5c36 100644 --- a/src/loops/completion/completion.mjs +++ b/src/loops/completion/completion.mjs @@ -1,6 +1,6 @@ import { createHash, randomBytes } from "node:crypto"; import { closeSync, constants, fsyncSync, lstatSync, openSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; -import { basename, dirname, join, resolve } from "node:path"; +import { basename, dirname, join, relative, resolve } from "node:path"; import { checklistCompletion, findBurnlistDir, withLock } from "../../cli/lifecycle-moves.mjs"; import { localIsoTimestamp, parsePlan, validatePlan } from "../../server/plan-model.mjs"; import { publishOvenEvent } from "../../events/oven-event-store.mjs"; @@ -9,6 +9,7 @@ import { locateItemSpan, validateAssignedItem } from "../assignment/item-metadat import { parseItemRef } from "../assignment/selectors.mjs"; import { loadFrozenRecipe } from "../dsl/frozen.mjs"; import { runStore } from "../run/run-store.mjs"; +import { deriveCandidate } from "../run/candidate.mjs"; const RECEIPT = "completion-receipt.json", INTENT = "completion-intent.json"; const SHA = /^[a-f0-9]{64}$/u, RUN = /^run:[0-9a-z]{26}$/u, ASSIGNMENT = /^as1-sha256:[a-f0-9]{64}$/u; @@ -72,7 +73,7 @@ function assertApplied(plan, record) { const completed = plan.completed.filter((item) => item.id === itemId && item.completedAt === record.completedAt && item.title === record.title); if (completed.length !== 1) fail("receipt does not match the current Burnlist"); } -function assertCurrentAssignment({ repoRoot, planBytes, plan, authority, replay, store }) { +function assertCurrentAssignment({ repoRoot, planBytes, plan, authority, replay, store, candidateId }) { if (replay.projection.state !== "converged" || replay.projection.leaseHeld) fail("Run is not converged and idle", "ERUN_NOT_CONVERGED"); if (replay.projection.itemRef !== authority.itemRef || authority.runId !== replay.projection.runId) fail("Run authority does not match its journal"); const current = store.readCurrentRun?.(authority.itemRef); @@ -82,6 +83,7 @@ function assertCurrentAssignment({ repoRoot, planBytes, plan, authority, replay, catch { fail("assigned item no longer matches the Run", "ESTALE_ASSIGNMENT"); } if (metadata["Assignment-Id"] !== authority.assignmentId || artifact.assignmentId !== authority.assignmentId || artifact.itemRef !== authority.itemRef || artifact.assignedItemDigest !== authority.itemRevision || metadata.assignedDigest !== authority.itemRevision || artifact.executionRevision !== metadata["Execution-Revision"] || artifact.packageRevision !== metadata["Package-Revision"]) fail("assigned item no longer matches the Run", "ESTALE_ASSIGNMENT"); const frozen = loadFrozenRecipe(Buffer.from(authority.frozenRecipe, "base64")); if (JSON.stringify(frozen.ir) !== JSON.stringify(replay.graph)) fail("Run graph does not match its sealed assignment"); + if (!replay.execution.candidate || replay.execution.candidate.id !== candidateId) fail("Run candidate drifted before completion", "ECANDIDATE_DRIFT"); return { item, title: plan.items.find((entry) => entry.id === item.itemId)?.title }; } function assertAuthority(authority, runId) { @@ -94,6 +96,10 @@ function publish(repoRoot, outcome) { if (!outcome.applied) return; try { publis export function completeLoopRun({ repoRoot, runId, store = runStore(repoRoot), hooks = {} }) { if (!RUN.test(runId) || !store?.read || !store?.readAuthority || !store?.readCurrentRun || !store?.paths?.pathFor) fail("invalid completion input"); const replay = store.read(runId), authority = assertAuthority(store.readAuthority(runId), runId), item = parseItemRef(authority.itemRef), found = findBurnlistDir(repoRoot, item.burnlistId); + // Capture before the lifecycle lock: that lock is itself an untracked + // runtime artifact and must not manufacture candidate drift. + const candidateExclusions = [relative(resolve(repoRoot), join(found.dir, ".lock"))]; + const completionCandidate = deriveCandidate({ repoRoot, excludedPaths: candidateExclusions }); if (found.lifecycle.folder !== "inprogress") fail(`Burnlist ${item.burnlistId} is not inprogress`); const lifecycle = lifecycleIdentity(repoRoot, item.burnlistId, found.dir); const result = withLock(found.dir, () => { @@ -114,12 +120,17 @@ export function completeLoopRun({ repoRoot, runId, store = runStore(repoRoot), h } let record = readCanonical(intentPath, "completion intent"); if (record) { record = validRecord(record, "completion intent"); if (record.runId !== runId || record.itemRef !== authority.itemRef || record.assignmentId !== authority.assignmentId) fail("completion intent belongs to another Run"); const completed = plan.completed.filter((entry) => entry.id === item.itemId && entry.completedAt === record.completedAt && entry.title === record.title); if (completed.length === 1) { atomicWrite(receiptPath, Buffer.from(`${JSON.stringify(record)}\n`)); rmSync(intentPath, { force: true }); syncDirectory(runDir); return { applied: false, item: completed[0], record, event: null }; } } - const current = assertCurrentAssignment({ repoRoot, planBytes: bytes, plan, authority, replay, store }); if (!current.title) fail("Run item title is unavailable"); + const current = assertCurrentAssignment({ repoRoot, planBytes: bytes, plan, authority, replay, store, candidateId: completionCandidate.id }); if (!current.title) fail("Run item title is unavailable"); const completedAt = record?.completedAt ?? localIsoTimestamp(), provisional = recordFor({ authority, completedAt, title: current.title, planDigest: "0".repeat(64) }), next = completedMarkdown(plan.markdown, provisional); record = recordFor({ authority, completedAt, title: current.title, planDigest: digest(Buffer.from(next)) }); const existingIntent = readCanonical(intentPath, "completion intent"); if (existingIntent && validRecord(existingIntent, "completion intent").planDigest !== record.planDigest) fail("completion intent no longer matches the active Burnlist", "ESTALE_INTENT"); if (!existingIntent) { atomicWrite(intentPath, Buffer.from(`${JSON.stringify(record)}\n`)); hooks.afterIntent?.(record); } + hooks.beforePlan?.(record); + // This is the final pre-mutation boundary. The lifecycle lock is excluded + // from both captures because it is our own untracked synchronization file. + if (deriveCandidate({ repoRoot, excludedPaths: candidateExclusions }).id !== completionCandidate.id) + fail("Run candidate drifted before completion", "ECANDIDATE_DRIFT"); lifecycleIdentity(repoRoot, item.burnlistId, found.dir, lifecycle); const staged = atomicPlanWrite(planPath, Buffer.from(next)); hooks.afterPlan?.(record); atomicWrite(receiptPath, Buffer.from(`${JSON.stringify(record)}\n`)); hooks.afterReceipt?.(record); rmSync(intentPath, { force: true }); syncDirectory(runDir); return { applied: true, item: { id: item.itemId, title: current.title }, record, event: checklistCompletion(item.burnlistId, { id: item.itemId, title: current.title }, record.completedAt, staged) }; }); diff --git a/src/loops/completion/completion.test.mjs b/src/loops/completion/completion.test.mjs index a5b5561d..09db019b 100644 --- a/src/loops/completion/completion.test.mjs +++ b/src/loops/completion/completion.test.mjs @@ -27,7 +27,7 @@ async function converged(context, runId = fixtureRunId) { await createProductionRun({ repoRoot: context.repo, store, itemRef: fixtureItemRef, runId }); const counter = join(context.directory, `counter-${runId.slice(-4)}`); writeFileSync(counter, "0"); const before = [process.env.BURNLIST_FAKE_COUNTER, process.env.BURNLIST_FAKE_OUTCOMES]; - process.env.BURNLIST_FAKE_COUNTER = counter; process.env.BURNLIST_FAKE_OUTCOMES = "complete,approve"; + process.env.BURNLIST_FAKE_COUNTER = counter; process.env.BURNLIST_FAKE_OUTCOMES = "complete,complete,complete,approve,complete,approve"; try { assert.equal((await createStoredProductionRunRunner({ repoRoot: context.repo, store, runId }).run()).projection.state, "converged"); } finally { for (const [key, value] of [["BURNLIST_FAKE_COUNTER", before[0]], ["BURNLIST_FAKE_OUTCOMES", before[1]]]) { @@ -89,6 +89,24 @@ test("completion rejects a prepared Run and a stale assigned item", async (t) => assert.equal(completeLoopRun({ repoRoot: superseded.repo, runId: currentId, store: currentStore }).alreadyApplied, false); }); +test("candidate drift blocks completion but a fresh Run explicitly supersedes the stale convergence", async (t) => { + const value = context(t), store = await converged(value); + writeFileSync(join(value.repo, "src", "changed-after-convergence.txt"), "drift\n"); + assert.throws(() => completeLoopRun({ repoRoot: value.repo, runId: fixtureRunId, store }), /candidate drifted/u); + const fresh = "run:01arz3ndektsv4rrffq69g5faw"; + await createProductionRun({ repoRoot: value.repo, store, itemRef: fixtureItemRef, runId: fresh }); + assert.equal(store.readCurrentRun(fixtureItemRef).runId, fresh); +}); + +test("completion rechecks candidate after intent and before canonical plan mutation", async (t) => { + const value = context(t), store = await converged(value); + assert.throws(() => completeLoopRun({ repoRoot: value.repo, runId: fixtureRunId, store, hooks: { + beforePlan() { writeFileSync(join(value.repo, "src", "drift-at-final-boundary.txt"), "drift\n"); }, + } }), /candidate drifted/u); + assert.match(readFileSync(value.planPath, "utf8"), /- \[ \] L29/u); + assert.equal(existsSync(runPath(store, fixtureRunId, "completion-intent.json")), true); +}); + test("receipt and Run ambiguity evidence fail closed when corrupt, bounded, symlinked, or duplicate", async (t) => { const value = context(t), store = await converged(value), receipt = runPath(store, fixtureRunId, "completion-receipt.json"); symlinkSync(value.planPath, receipt); assert.throws(() => completeLoopRun({ repoRoot: value.repo, runId: fixtureRunId, store }), /receipt is corrupt/u); rmSync(receipt); diff --git a/src/loops/contracts/agent-result.mjs b/src/loops/contracts/agent-result.mjs index 3e046dcc..75b4d63b 100644 --- a/src/loops/contracts/agent-result.mjs +++ b/src/loops/contracts/agent-result.mjs @@ -1,11 +1,18 @@ import { rawSha256 } from "../dsl/hash.mjs"; import { bindingsMatch, DIGESTS, exact, fail, identity, parseBoundedObject, parseResultBytes, SLUG, sortedUnique } from "./contract.mjs"; -import { nextOpenFindings, validateFindingSet } from "./finding.mjs"; +import { nextOpenFindings, validateFinding, validateFindingSet } from "./finding.mjs"; const RESULT_KEYS = ["schema", "runId", "nodeId", "attempt", "claimId", "assignmentId", "invocationId", "recipeRevision", "policyRevision", "inputCandidate", "outcome", "findings", "resolvedFindingIds"]; -const INPUT_KEYS = ["schema", "runId", "nodeId", "attempt", "claimId", "assignmentId", "invocationId", "recipeRevision", "policyRevision", "inputCandidate", "itemRevision", "instructionDigest", "instructionBytes", "candidateContext", "reviewerEvidence"]; +const INPUT_KEYS = ["schema", "runId", "nodeId", "attempt", "claimId", "assignmentId", "invocationId", "recipeRevision", "policyRevision", "inputCandidate", "itemRevision", "execution", "intelligence", "mode", "role", "authority", "legalOutcomes", "requires", "openFindings", "instructionDigest", "instructionBytes", "itemText", "candidateContext", "reviewerEvidence"]; +const PRE_FINDINGS_INPUT_KEYS = INPUT_KEYS.filter((key) => key !== "openFindings"); +const PRE_CONTEXT_INPUT_KEYS = ["schema", "runId", "nodeId", "attempt", "claimId", "assignmentId", "invocationId", "recipeRevision", "policyRevision", "inputCandidate", "itemRevision", "execution", "intelligence", "instructionDigest", "instructionBytes", "candidateContext", "reviewerEvidence"]; +const LEGACY_INPUT_KEYS = ["schema", "runId", "nodeId", "attempt", "claimId", "assignmentId", "invocationId", "recipeRevision", "policyRevision", "inputCandidate", "itemRevision", "instructionDigest", "instructionBytes", "candidateContext", "reviewerEvidence"]; const AUTHORITY_KEYS = ["schema", "state", "runId", "nodeId", "attempt", "claimId", "assignmentId", "invocationId", "recipeRevision", "policyRevision", "inputCandidate", "itemRevision", "inputSchema", "inputDigest", "inputByteLength"]; -const OUTCOMES = Object.freeze({ task: new Set(["complete"]), review: new Set(["approve", "reject", "escalate"]) }); +export const HOST_OUTCOMES = Object.freeze({ + task: Object.freeze(["complete"]), + review: Object.freeze(["approve", "reject", "escalate"]), +}); +const OUTCOMES = { task: new Set(HOST_OUTCOMES.task), review: new Set(HOST_OUTCOMES.review) }; const BASE64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u; function base64(value, label, maximum) { @@ -19,6 +26,12 @@ function canonicalEnvelope(value) { schema: value.schema, runId: value.runId, nodeId: value.nodeId, attempt: value.attempt, claimId: value.claimId, assignmentId: value.assignmentId, invocationId: value.invocationId, recipeRevision: value.recipeRevision, policyRevision: value.policyRevision, inputCandidate: value.inputCandidate, itemRevision: value.itemRevision, + ...(Object.hasOwn(value, "execution") ? { execution: value.execution, intelligence: value.intelligence } : {}), + ...(Object.hasOwn(value, "itemText") ? { + mode: value.mode, role: value.role, authority: value.authority, legalOutcomes: value.legalOutcomes, + requires: value.requires, ...(Object.hasOwn(value, "openFindings") ? { openFindings: value.openFindings } : {}), + itemText: value.itemText, + } : {}), instructionDigest: value.instructionDigest, instructionBytes: value.instructionBytes, candidateContext: value.candidateContext, reviewerEvidence: value.reviewerEvidence, })}\n`, "utf8"); @@ -26,12 +39,30 @@ function canonicalEnvelope(value) { /** Builds the exact adapter stdin/prompt payload; this digest is persisted before dispatch by the runner. */ export function createInvocationInput(value) { - if (!exact(value, INPUT_KEYS) || value.schema !== "burnlist-loop-invocation-input@1") fail("invalid invocation input"); + if ((!exact(value, INPUT_KEYS) && !exact(value, PRE_FINDINGS_INPUT_KEYS) + && !exact(value, PRE_CONTEXT_INPUT_KEYS) && !exact(value, LEGACY_INPUT_KEYS)) + || value.schema !== "burnlist-loop-invocation-input@1") fail("invalid invocation input"); identity(value, "invocation input"); - if (!DIGESTS.item.test(value.itemRevision) || !DIGESTS.raw.test(value.instructionDigest) + if (!DIGESTS.item.test(value.itemRevision) || (Object.hasOwn(value, "execution") + && (!["managed", "host"].includes(value.execution) || !["fast", "standard", "strong", "critical"].includes(value.intelligence))) || !DIGESTS.raw.test(value.instructionDigest) || !SLUG.test(value.nodeId) || !Array.isArray(value.reviewerEvidence) || value.reviewerEvidence.length > 50 || !sortedUnique(value.reviewerEvidence) || !value.reviewerEvidence.every((ref) => DIGESTS.artifact.test(ref))) fail("invalid invocation evidence"); const instruction = base64(value.instructionBytes, "instruction bytes", 65_536); + if (Object.hasOwn(value, "itemText")) { + const expected = value.mode === "task" ? ["complete"] : value.mode === "review" ? ["approve", "reject", "escalate"] : null; + if (!expected || typeof value.role !== "string" || !value.role || Buffer.byteLength(value.role) > 64 + || !["write", "read"].includes(value.authority) || !Array.isArray(value.legalOutcomes) + || JSON.stringify(value.legalOutcomes) !== JSON.stringify(expected) + || !Array.isArray(value.requires) || value.requires.length > 50 || !sortedUnique(value.requires) + || value.requires.some((id) => typeof id !== "string" || !id || Buffer.byteLength(id) > 128 || /[\0\r\n]/u.test(id)) + || !base64(value.itemText, "item text", 65_536).length) + fail("invalid invocation execution contract"); + if (Object.hasOwn(value, "openFindings")) { + if (!Array.isArray(value.openFindings) || value.openFindings.length > 50) fail("invalid invocation open findings"); + const checked = value.openFindings.map(validateFinding); + if (!sortedUnique(checked.map((finding) => finding.id))) fail("invalid invocation open findings"); + } + } const context = base64(value.candidateContext, "candidate context", 65_536); if (!instruction.length || !context.length) fail("empty invocation input"); if (rawSha256(instruction) !== value.instructionDigest) fail("instruction digest does not match frozen bytes"); @@ -82,9 +113,9 @@ export function validateAgentResult(value, { mode, openFindings = new Map() } = if (!exact(value, RESULT_KEYS) || value.schema !== "agent-result@1") fail("invalid agent result"); identity(value, "agent result"); if (!OUTCOMES[mode]?.has(value.outcome)) fail("agent outcome is not allowed for node mode"); - const findings = validateFindingSet(value.findings, value.resolvedFindingIds, openFindings); - if (mode === "task" && (findings.findings.length || findings.resolvedFindingIds.length)) fail("task completion cannot carry findings"); - const next = nextOpenFindings(openFindings, findings); + if (mode === "task" && (value.findings.length || value.resolvedFindingIds.length)) fail("task completion cannot carry findings"); + const findings = validateFindingSet(value.findings, value.resolvedFindingIds, mode === "review" ? openFindings : new Map()); + const next = mode === "review" ? nextOpenFindings(openFindings, findings) : openFindings; if (mode === "review" && value.outcome === "approve") { if (findings.findings.some((finding) => finding.severity === "blocker" || finding.severity === "major") || [...openFindings.values()].some((finding) => (finding.severity === "blocker" || finding.severity === "major") && !findings.resolvedFindingIds.includes(finding.id)) diff --git a/src/loops/contracts/contracts.test.mjs b/src/loops/contracts/contracts.test.mjs index 5988fb3f..48904246 100644 --- a/src/loops/contracts/contracts.test.mjs +++ b/src/loops/contracts/contracts.test.mjs @@ -27,7 +27,7 @@ function check(outcome, extra = {}) { return { schema: "check-result@1", ...bind function bytes(value) { return Buffer.from(JSON.stringify(value)); } function invocation(extra = {}) { const instruction = Buffer.from("Follow the frozen instructions.\n"); - return { schema: "burnlist-loop-invocation-input@1", ...binding, itemRevision: d("id1-sha256", "8"), instructionDigest: rawSha256(instruction), + return { schema: "burnlist-loop-invocation-input@1", ...binding, itemRevision: d("id1-sha256", "8"), execution: "managed", intelligence: "strong", instructionDigest: rawSha256(instruction), instructionBytes: instruction.toString("base64"), candidateContext: Buffer.from("candidate-context@1\n").toString("base64"), reviewerEvidence: [], ...extra }; } function dispatch(input, extra = {}) { diff --git a/src/loops/contracts/host-execution.mjs b/src/loops/contracts/host-execution.mjs new file mode 100644 index 00000000..c2f127f6 --- /dev/null +++ b/src/loops/contracts/host-execution.mjs @@ -0,0 +1,123 @@ +import { bindingsMatch, exact, fail, identity, parseBoundedObject } from "./contract.mjs"; +import { rawSha256 } from "../dsl/hash.mjs"; +import { validateAgentResult, validateDispatchAuthority, validateInvocationInput } from "./agent-result.mjs"; + +const KEYS = ["schema", "runId", "nodeId", "attempt", "claimId", "assignmentId", "invocationId", "recipeRevision", "policyRevision", "inputCandidate", "issuedAt", "expiresAt", "invocationInput", "dispatchAuthority"]; +const REPORT_KEYS = ["schema", "result", "telemetry"]; +const TELEMETRY_KEYS = ["schema", "provenance", "executor", "displayName", "provider", "model", "effort", "startedAt", "completedAt", "inputTokens", "outputTokens"]; +const BASE64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u; +const MAX_ENVELOPE_BYTES = 400_000; +const MAX_LIFETIME_MS = 24 * 60 * 60 * 1000; + +function base64(value, label, maximum) { + if (typeof value !== "string" || !BASE64.test(value)) fail(`invalid ${label}`); + const bytes = Buffer.from(value, "base64"); + if (!bytes.length || bytes.length > maximum || bytes.toString("base64") !== value) fail(`invalid ${label}`); + return bytes; +} + +function timestamp(value, label) { + if (!Number.isSafeInteger(value) || value < 0 || value > 8_640_000_000_000_000) fail(`invalid ${label}`); + return value; +} + +function canonical(value) { + return Buffer.from(`${JSON.stringify({ + schema: value.schema, runId: value.runId, nodeId: value.nodeId, attempt: value.attempt, claimId: value.claimId, + assignmentId: value.assignmentId, invocationId: value.invocationId, recipeRevision: value.recipeRevision, + policyRevision: value.policyRevision, inputCandidate: value.inputCandidate, issuedAt: value.issuedAt, + expiresAt: value.expiresAt, invocationInput: value.invocationInput, dispatchAuthority: value.dispatchAuthority, + })}\n`, "utf8"); +} + +/** + * Canonical, controller-issued transport for one already-prepared agent claim. + * It deliberately contains neither an edge/destination nor a reported outcome: + * hosts execute the exact input, while Burnlist remains the transition authority. + */ +export function createHostExecutionEnvelope(value) { + if (!exact(value, KEYS) || value.schema !== "burnlist-loop-host-execution@1") fail("invalid host execution envelope"); + identity(value, "host execution envelope"); + const issuedAt = timestamp(value.issuedAt, "issuedAt"); + const expiresAt = timestamp(value.expiresAt, "expiresAt"); + if (expiresAt <= issuedAt || expiresAt - issuedAt > MAX_LIFETIME_MS) fail("invalid host execution expiry"); + const inputBytes = base64(value.invocationInput, "invocation input", 262_144); + const authorityBytes = base64(value.dispatchAuthority, "dispatch authority", 16_384); + const authority = validateDispatchAuthority(authorityBytes); + const input = validateInvocationInput(inputBytes, authorityBytes); + if (!bindingsMatch(value, input.value) || !bindingsMatch(value, authority.value)) fail("host execution envelope binding mismatch"); + const bytes = canonical(value); + if (bytes.length > MAX_ENVELOPE_BYTES) fail("host execution envelope exceeds bounds"); + return Object.freeze({ value: Object.freeze({ ...value }), bytes, digest: rawSha256(bytes), input, authority }); +} + +/** Strictly parses a bounded canonical envelope before a host may use its inputs. */ +export function validateHostExecutionEnvelope(bytes) { + const raw = Buffer.from(bytes); + const value = parseBoundedObject(raw, { maximumBytes: MAX_ENVELOPE_BYTES, maximumDepth: 1, label: "host execution envelope" }); + const built = createHostExecutionEnvelope(value); + if (!built.bytes.equals(raw)) fail("host execution envelope is not canonical"); + return built; +} + +export const HOST_EXECUTION_ENVELOPE_SCHEMA = "burnlist-loop-host-execution@1"; + +export function hostExecutionExpired(envelope, now) { + if (!Number.isSafeInteger(now) || now < 0 || now > 8_640_000_000_000_000) fail("invalid host execution clock"); + const value = envelope?.value ?? envelope; + return now >= createHostExecutionEnvelope(value).value.expiresAt; +} + +function optionalText(value, label) { + if (value === null) return null; + if (typeof value !== "string" || !value || Buffer.byteLength(value) > 256 || /[\0\r\n]/u.test(value)) fail(`invalid ${label}`); + return value; +} + +function optionalCount(value, label) { + if (value === null) return null; + if (!Number.isSafeInteger(value) || value < 0) fail(`invalid ${label}`); + return value; +} + +export function validateHostTelemetry(value) { + if (value === null) return null; + if (!exact(value, TELEMETRY_KEYS) || value.schema !== "burnlist-loop-host-telemetry@1" + || value.provenance !== "host-reported") fail("invalid host telemetry"); + const telemetry = { + schema: value.schema, provenance: value.provenance, executor: optionalText(value.executor, "executor"), + displayName: optionalText(value.displayName, "displayName"), provider: optionalText(value.provider, "provider"), + model: optionalText(value.model, "model"), effort: optionalText(value.effort, "effort"), + startedAt: optionalCount(value.startedAt, "startedAt"), completedAt: optionalCount(value.completedAt, "completedAt"), + inputTokens: optionalCount(value.inputTokens, "inputTokens"), outputTokens: optionalCount(value.outputTokens, "outputTokens"), + }; + if (telemetry.executor === null || (telemetry.startedAt === null) !== (telemetry.completedAt === null) + || telemetry.startedAt !== null && telemetry.completedAt < telemetry.startedAt) fail("invalid host telemetry timing"); + return Object.freeze(telemetry); +} + +function reportBytes(value) { + return Buffer.from(`${JSON.stringify({ schema: value.schema, result: value.result, telemetry: value.telemetry })}\n`, "utf8"); +} + +/** A host reports evidence and an allowed outcome; it never reports an edge destination. */ +export function createHostExecutionReport(value, { envelope, mode, openFindings = new Map() } = {}) { + if (!exact(value, REPORT_KEYS) || value.schema !== "burnlist-loop-host-report@1" || !envelope?.value) fail("invalid host execution report"); + const result = validateAgentResult(value.result, { mode, openFindings }); + if (!bindingsMatch(result, envelope.value)) fail("host execution report binding mismatch"); + const telemetry = validateHostTelemetry(value.telemetry); + const report = Object.freeze({ schema: value.schema, result, telemetry }); + const bytes = reportBytes(report); + if (bytes.length > 262_144) fail("host execution report exceeds bounds"); + return Object.freeze({ value: report, bytes, digest: rawSha256(bytes) }); +} + +export function validateHostExecutionReport(bytes, options) { + const raw = Buffer.from(bytes); + const value = parseBoundedObject(raw, { maximumBytes: 262_144, maximumDepth: 5, label: "host execution report" }); + const built = createHostExecutionReport(value, options); + if (!built.bytes.equals(raw)) fail("host execution report is not canonical"); + return built; +} + +export const HOST_EXECUTION_REPORT_SCHEMA = "burnlist-loop-host-report@1"; diff --git a/src/loops/contracts/host-execution.test.mjs b/src/loops/contracts/host-execution.test.mjs new file mode 100644 index 00000000..4770500b --- /dev/null +++ b/src/loops/contracts/host-execution.test.mjs @@ -0,0 +1,116 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createDispatchAuthority, createInvocationInput } from "./agent-result.mjs"; +import { + createHostExecutionEnvelope, createHostExecutionReport, validateHostExecutionEnvelope, + hostExecutionExpired, validateHostExecutionReport, +} from "./host-execution.mjs"; +import { rawSha256 } from "../dsl/hash.mjs"; + +const hex = (letter) => letter.repeat(64); +const digest = (prefix, letter) => `${prefix}:${hex(letter)}`; +const binding = Object.freeze({ runId: "run:01arz3ndektsv4rrffq69g5fav", nodeId: "implement", attempt: 1, + claimId: digest("cl1-sha256", "1"), assignmentId: digest("as1-sha256", "2"), invocationId: digest("iv1-sha256", "3"), + recipeRevision: digest("er1-sha256", "4"), policyRevision: digest("bp1-sha256", "5"), inputCandidate: digest("cm1-sha256", "6") }); + +function fixture(patch = {}) { + const instruction = Buffer.from("Implement the frozen task.\n"); + const input = createInvocationInput({ schema: "burnlist-loop-invocation-input@1", ...binding, itemRevision: digest("id1-sha256", "7"), execution: "host", intelligence: "fast", + mode: "task", role: "maker", authority: "write", legalOutcomes: ["complete"], requires: [], + instructionDigest: rawSha256(instruction), instructionBytes: instruction.toString("base64"), + itemText: Buffer.from("- [ ] H1 | Implement host execution\n").toString("base64"), + candidateContext: Buffer.from("candidate-context@1\n").toString("base64"), reviewerEvidence: [] }); + const authority = createDispatchAuthority({ schema: "burnlist-loop-dispatch-authority@1", state: "prepared-before-dispatch", ...binding, + itemRevision: input.value.itemRevision, inputSchema: input.value.schema, inputDigest: input.digest, inputByteLength: input.bytes.length }); + return { schema: "burnlist-loop-host-execution@1", ...binding, issuedAt: 1_000, expiresAt: 1_801_000, + invocationInput: input.bytes.toString("base64"), dispatchAuthority: authority.bytes.toString("base64"), ...patch }; +} + +test("host execution envelope canonically binds the exact prepared invocation", () => { + const built = createHostExecutionEnvelope(fixture()); + assert.equal(built.bytes.toString(), `${JSON.stringify(fixture())}\n`); + const parsed = validateHostExecutionEnvelope(built.bytes); + assert.equal(parsed.digest, built.digest); + assert.equal(parsed.input.value.claimId, binding.claimId); + assert.equal(parsed.authority.value.inputDigest, parsed.input.digest); + assert.equal(Buffer.from(parsed.input.value.itemText, "base64").toString(), "- [ ] H1 | Implement host execution\n"); + assert.deepEqual({ mode: parsed.input.value.mode, role: parsed.input.value.role, authority: parsed.input.value.authority, + legalOutcomes: parsed.input.value.legalOutcomes }, { mode: "task", role: "maker", authority: "write", legalOutcomes: ["complete"] }); +}); + +test("pre-H6 host envelopes retain their legacy invocation bytes", () => { + const instruction = Buffer.from("Implement the frozen task.\n"); + const input = createInvocationInput({ schema: "burnlist-loop-invocation-input@1", ...binding, itemRevision: digest("id1-sha256", "7"), + instructionDigest: rawSha256(instruction), instructionBytes: instruction.toString("base64"), + candidateContext: Buffer.from("candidate-context@1\n").toString("base64"), reviewerEvidence: [] }); + assert.equal(Object.hasOwn(input.value, "execution"), false); + const authority = createDispatchAuthority({ schema: "burnlist-loop-dispatch-authority@1", state: "prepared-before-dispatch", ...binding, + itemRevision: input.value.itemRevision, inputSchema: input.value.schema, inputDigest: input.digest, inputByteLength: input.bytes.length }); + const envelope = createHostExecutionEnvelope({ schema: "burnlist-loop-host-execution@1", ...binding, issuedAt: 1_000, expiresAt: 1_801_000, + invocationInput: input.bytes.toString("base64"), dispatchAuthority: authority.bytes.toString("base64") }); + assert.deepEqual(validateHostExecutionEnvelope(envelope.bytes).input.bytes, input.bytes); +}); + +test("host envelope rejects identity fabrication, noncanonical input bytes, and host transition fields", () => { + assert.throws(() => createHostExecutionEnvelope(fixture({ claimId: digest("cl1-sha256", "9") })), /binding mismatch/u); + assert.throws(() => createHostExecutionEnvelope({ ...fixture(), destination: "burn" }), /invalid host execution envelope/u); + assert.throws(() => createHostExecutionEnvelope({ ...fixture(), outcome: "complete" }), /invalid host execution envelope/u); + const value = fixture(); value.invocationInput = `${value.invocationInput} `; + assert.throws(() => createHostExecutionEnvelope(value), /invalid invocation input/u); +}); + +test("host envelope is bounded, canonical, and has a finite ordered lease", () => { + const built = createHostExecutionEnvelope(fixture()); + assert.throws(() => validateHostExecutionEnvelope(Buffer.concat([built.bytes, Buffer.from(" ")])), /canonical/u); + assert.equal(hostExecutionExpired(built, 1_800_999), false); + assert.equal(hostExecutionExpired(built, 1_801_000), true); + assert.throws(() => createHostExecutionEnvelope(fixture({ expiresAt: 1_000 })), /expiry/u); + assert.throws(() => createHostExecutionEnvelope(fixture({ expiresAt: 86_401_001 })), /expiry/u); + assert.throws(() => createHostExecutionEnvelope(fixture({ issuedAt: "1000" })), /issuedAt/u); + assert.throws(() => validateHostExecutionEnvelope(Buffer.alloc(400_001, 32)), /bounds/u); +}); + +function report(envelope, patch = {}) { + return { + schema: "burnlist-loop-host-report@1", + result: { + schema: "agent-result@1", ...binding, outcome: "complete", findings: [], resolvedFindingIds: [], + }, + telemetry: { + schema: "burnlist-loop-host-telemetry@1", provenance: "host-reported", executor: "claude-native", + displayName: "H1 implement", provider: "anthropic", model: "fast", effort: "low", + startedAt: 1000, completedAt: 2000, inputTokens: null, outputTokens: null, + }, + ...patch, + }; +} + +test("host report binds a legal result and explicitly host-reported telemetry", () => { + const envelope = createHostExecutionEnvelope(fixture()); + const built = createHostExecutionReport(report(envelope), { envelope, mode: "task" }); + assert.equal(validateHostExecutionReport(built.bytes, { envelope, mode: "task" }).digest, built.digest); + assert.equal(built.value.telemetry.provenance, "host-reported"); +}); + +test("host report rejects transition choice, stale identity, illegal outcome, and false telemetry", () => { + const envelope = createHostExecutionEnvelope(fixture()); + assert.throws(() => createHostExecutionReport({ ...report(envelope), destination: "burn" }, { envelope, mode: "task" }), /invalid host execution report/u); + const stale = report(envelope); stale.result = { ...stale.result, claimId: digest("cl1-sha256", "9") }; + assert.throws(() => createHostExecutionReport(stale, { envelope, mode: "task" }), /binding mismatch/u); + const illegal = report(envelope); illegal.result = { ...illegal.result, outcome: "approve" }; + assert.throws(() => createHostExecutionReport(illegal, { envelope, mode: "task" }), /not allowed/u); + const telemetry = report(envelope); telemetry.telemetry = { ...telemetry.telemetry, provenance: "managed" }; + assert.throws(() => createHostExecutionReport(telemetry, { envelope, mode: "task" }), /host telemetry/u); + const backwards = report(envelope); backwards.telemetry = { ...backwards.telemetry, completedAt: 999 }; + assert.throws(() => createHostExecutionReport(backwards, { envelope, mode: "task" }), /timing/u); +}); + +test("host report bytes make retransmission identity explicit", () => { + const envelope = createHostExecutionEnvelope(fixture()); + const first = createHostExecutionReport(report(envelope), { envelope, mode: "task" }); + const duplicate = validateHostExecutionReport(first.bytes, { envelope, mode: "task" }); + assert.equal(duplicate.digest, first.digest); + const changed = createHostExecutionReport(report(envelope, { telemetry: null }), { envelope, mode: "task" }); + assert.notEqual(changed.digest, first.digest); + assert.throws(() => validateHostExecutionReport(Buffer.concat([first.bytes, Buffer.from(" ")]), { envelope, mode: "task" }), /canonical/u); +}); diff --git a/src/loops/dsl/__fixtures__/review.ir.json b/src/loops/dsl/__fixtures__/review.ir.json index 845b08e9..00ec4a77 100644 --- a/src/loops/dsl/__fixtures__/review.ir.json +++ b/src/loops/dsl/__fixtures__/review.ir.json @@ -1 +1 @@ -{"schema":"burnlist-loop-ir@1","compiler":"burnlist-loop-compiler@1","id":"review","declaredVersion":"0.1.0","entry":"implement","budget":{"maxRounds":3,"maxMinutes":60,"maxAgentRuns":6,"maxCheckRuns":3,"maxTransitions":16,"maxOutputBytes":262144},"nodes":[{"kind":"agent","id":"implement","mode":"task","role":"maker","route":"implementation.standard","authority":"write","instructions":"implement","independentFrom":null,"requires":[]},{"kind":"terminal","id":"completed","state":"converged"},{"kind":"gate","id":"converged","gateKind":"convergence","requires":["verify","review"]},{"kind":"terminal","id":"exhausted","state":"budget-exhausted"},{"kind":"terminal","id":"failed","state":"failed"},{"kind":"terminal","id":"needs-human","state":"needs-human"},{"kind":"agent","id":"review","mode":"review","role":"reviewer","route":"review.strong","authority":"read","instructions":"review","independentFrom":"implement","requires":["fresh-session:enforced","filesystem-write-deny:supervised"]},{"kind":"terminal","id":"stopped","state":"stopped"},{"kind":"check","id":"verify","capability":"repo-verify"}],"failurePolicy":{"error":"failed","timeout":"failed","cancelled":"stopped","lost":"needs-human","exhausted":"exhausted"},"edges":[{"from":"implement","on":"complete","to":"verify","maxVisits":null},{"from":"converged","on":"pass","to":"completed","maxVisits":null},{"from":"converged","on":"fail","to":"needs-human","maxVisits":null},{"from":"review","on":"approve","to":"converged","maxVisits":null},{"from":"review","on":"reject","to":"implement","maxVisits":3},{"from":"review","on":"escalate","to":"needs-human","maxVisits":null},{"from":"verify","on":"pass","to":"review","maxVisits":null},{"from":"verify","on":"fail","to":"implement","maxVisits":3}],"instructions":[{"id":"implement","digest":"sha256:8d1db5c7cbc11075fccc565180374e1929a8cf22683cbca3dde9b77ef667c0a8","byteLength":114},{"id":"review","digest":"sha256:53ca65be9491688119b5cbf2443d0b9aff003764db9e91657ec54dbae12d76aa","byteLength":119}]} +{"schema":"burnlist-loop-ir@1","compiler":"burnlist-loop-compiler@1","id":"review","declaredVersion":"0.1.0","entry":"start","budget":{"maxRounds":12,"maxMinutes":60,"maxAgentRuns":18,"maxCheckRuns":8,"maxTransitions":40,"maxOutputBytes":262144},"nodes":[{"kind":"agent","id":"start","mode":"task","execution":"managed","intelligence":"standard","role":"maker","route":"implementation.standard","authority":"write","instructions":"start","independentFrom":null,"requires":[]},{"kind":"terminal","id":"completed","state":"converged"},{"kind":"gate","id":"converged","gateKind":"convergence","requires":["final-validate","final-review"]},{"kind":"agent","id":"decompose","mode":"task","execution":"managed","intelligence":"strong","role":"maker","route":"implementation.standard","authority":"write","instructions":"decompose","independentFrom":null,"requires":[]},{"kind":"terminal","id":"exhausted","state":"budget-exhausted"},{"kind":"terminal","id":"failed","state":"failed"},{"kind":"agent","id":"final-review","mode":"review","execution":"managed","intelligence":"critical","role":"reviewer","route":"review.strong","authority":"read","instructions":"final-review","independentFrom":"implement","requires":["fresh-session:enforced","filesystem-write-deny:supervised"]},{"kind":"check","id":"final-validate","capability":"repo-verify"},{"kind":"agent","id":"implement","mode":"task","execution":"managed","intelligence":"standard","role":"maker","route":"implementation.standard","authority":"write","instructions":"implement","independentFrom":null,"requires":[]},{"kind":"agent","id":"integrate","mode":"task","execution":"managed","intelligence":"strong","role":"maker","route":"implementation.standard","authority":"write","instructions":"integrate","independentFrom":null,"requires":[]},{"kind":"terminal","id":"needs-human","state":"needs-human"},{"kind":"agent","id":"review","mode":"review","execution":"managed","intelligence":"strong","role":"reviewer","route":"review.strong","authority":"read","instructions":"review","independentFrom":"implement","requires":["fresh-session:enforced","filesystem-write-deny:supervised"]},{"kind":"terminal","id":"stopped","state":"stopped"},{"kind":"check","id":"validate","capability":"repo-verify"}],"failurePolicy":{"error":"failed","timeout":"failed","cancelled":"stopped","lost":"needs-human","exhausted":"exhausted"},"edges":[{"from":"start","on":"complete","to":"decompose","maxVisits":null},{"from":"converged","on":"pass","to":"completed","maxVisits":null},{"from":"converged","on":"fail","to":"needs-human","maxVisits":null},{"from":"decompose","on":"complete","to":"implement","maxVisits":null},{"from":"final-review","on":"approve","to":"converged","maxVisits":null},{"from":"final-review","on":"reject","to":"decompose","maxVisits":3},{"from":"final-review","on":"escalate","to":"needs-human","maxVisits":null},{"from":"final-validate","on":"pass","to":"final-review","maxVisits":null},{"from":"final-validate","on":"fail","to":"decompose","maxVisits":3},{"from":"implement","on":"complete","to":"validate","maxVisits":null},{"from":"integrate","on":"complete","to":"final-validate","maxVisits":null},{"from":"review","on":"approve","to":"integrate","maxVisits":null},{"from":"review","on":"reject","to":"decompose","maxVisits":3},{"from":"review","on":"escalate","to":"needs-human","maxVisits":null},{"from":"validate","on":"pass","to":"review","maxVisits":null},{"from":"validate","on":"fail","to":"decompose","maxVisits":3}],"instructions":[{"id":"decompose","digest":"sha256:266f954460a1d833b73ae53b696166acb6b6c49536518f9af86467bf66046791","byteLength":253},{"id":"final-review","digest":"sha256:24c8c2c2574dbbe56732b94e097e35af6e60ea2406feb2ad0019bc28f41b5da8","byteLength":66},{"id":"implement","digest":"sha256:3aea4bc6d773f5b7b1a3e5899bd5be2cbce3aa2313a656e18d6444f244959e93","byteLength":246},{"id":"integrate","digest":"sha256:188626485e23cf2a78105095304fa336f65044d6a7e7559cfa97d804f15be07e","byteLength":236},{"id":"review","digest":"sha256:89a11f423ae87b2c86749cafa2bb956ab7ca7980fafc71aeec408ddc463024aa","byteLength":72},{"id":"start","digest":"sha256:106187b048c9e12f748dac6fe70f3c18a7579a56e9a62be9661bb8ea4e627c43","byteLength":240}]} diff --git a/src/loops/dsl/__fixtures__/review.revisions.json b/src/loops/dsl/__fixtures__/review.revisions.json index 75e6d569..1eef9d69 100644 --- a/src/loops/dsl/__fixtures__/review.revisions.json +++ b/src/loops/dsl/__fixtures__/review.revisions.json @@ -1 +1 @@ -{"source":"ls1-sha256:a2b31c7cb4f8025516aa51a789a0545d3224b3abc142d18c2ec86291c009c918","package":"lp1-sha256:2f6213c0b5d8e4c6da58d8e8ab227462bf075c672252dc4a30c2736de51d4726","executable":"er1-sha256:8e58f16abc427528e6e10048f80c67f38afcdc7bb5b8f626a1e93b314feba08d"} +{"source":"ls1-sha256:dc05c650d52c3fe7d48ffb39cf0a9c771ee4d591b09b4e8ba7f83737bf02d087","package":"lp1-sha256:1cad5c04ad4406a80c6faa76531a9c1e6f75d1a7a1b5e7636aeafd2e0ed2a443","executable":"er1-sha256:95d15ba7bc8e46100fc829236049993d6f08b5b32e8d512f04d0cb149a79e3ed"} diff --git a/src/loops/dsl/canonical.mjs b/src/loops/dsl/canonical.mjs index 5281b367..05eb88e5 100644 --- a/src/loops/dsl/canonical.mjs +++ b/src/loops/dsl/canonical.mjs @@ -1,6 +1,7 @@ import { compareUtf8 } from "./diagnostics.mjs"; -const nodeKeyOrder = { agent: ["kind", "id", "mode", "role", "route", "authority", "instructions", "independentFrom", "requires"], check: ["kind", "id", "capability"], gate: ["kind", "id", "gateKind", "requires"], terminal: ["kind", "id", "state"] }; +const nodeKeyOrder = { agent: ["kind", "id", "mode", "execution", "intelligence", "role", "route", "authority", "instructions", "independentFrom", "requires"], check: ["kind", "id", "capability"], gate: ["kind", "id", "gateKind", "requires"], terminal: ["kind", "id", "state"] }; +const legacyNodeKeyOrder = { ...nodeKeyOrder, agent: ["kind", "id", "mode", "role", "route", "authority", "instructions", "independentFrom", "requires"] }; const topOrder = ["schema", "compiler", "id", "declaredVersion", "entry", "budget", "nodes", "failurePolicy", "edges", "instructions"]; const budgetOrder = ["maxRounds", "maxMinutes", "maxAgentRuns", "maxCheckRuns", "maxTransitions", "maxOutputBytes"]; const policyOrder = ["error", "timeout", "cancelled", "lost", "exhausted"]; @@ -27,7 +28,7 @@ function encode(value, context = "") { if (typeof value === "number") { if (!Number.isSafeInteger(value) || value < 0) throw new TypeError("Canonical numbers must be safe unsigned integers"); return String(value); } if (Array.isArray(value)) return `[${value.map((item) => encode(item)).join(",")}]`; if (!value || typeof value !== "object") throw new TypeError("Canonical IR contains an invalid value"); - if (context === "nodes[]") return object(value, nodeKeyOrder[value.kind]); + if (context === "nodes[]") return object(value, (value.kind === "agent" && !Object.hasOwn(value, "execution")) ? legacyNodeKeyOrder[value.kind] : nodeKeyOrder[value.kind]); if (context === "budget") return object(value, budgetOrder); if (context === "failurePolicy") return object(value, policyOrder); if (context === "edges[]") return object(value, edgeOrder); diff --git a/src/loops/dsl/compile.mjs b/src/loops/dsl/compile.mjs index ba004ff9..953ce42a 100644 --- a/src/loops/dsl/compile.mjs +++ b/src/loops/dsl/compile.mjs @@ -17,8 +17,8 @@ function appendDiagnostics(target, source) { } } -function sourceChecks(bytes, path, d) { - const [minimum, maximum] = sizes[path]; +function sourceChecks(bytes, path, d, limits = sizes) { + const [minimum, maximum] = limits[path]; if (bytes.length < minimum || bytes.length > maximum) d.add(path, 0, "E_FILE_SIZE", `${path} must contain ${minimum}..${maximum} bytes`); try { const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes); @@ -40,7 +40,8 @@ function cloneFiles(data) { return Object.fromEntries(Object.entries(data).map(([path, bytes]) => [path, Buffer.from(bytes)])); } -function compileLoopFilesInternal(files, { continueOnMissing = false } = {}) { +function compileLoopFilesInternal(files, { continueOnMissing = false, loopFile = "review.loop" } = {}) { + const packagePaths = [loopFile, "instructions.md", "example/item.md"], packageSizes = { ...sizes, [loopFile]: [1, 65536] }; const d = createDiagnostics(); if (!files || typeof files !== "object" || Array.isArray(files)) { d.add("", 0, "E_PACKAGE", "Package files must be an object"); @@ -48,26 +49,28 @@ function compileLoopFilesInternal(files, { continueOnMissing = false } = {}) { const actual = Object.keys(files ?? {}); if (actual.length > 3) d.add("", 0, "E_PACKAGE_COUNT", "Package may contain at most three files"); - for (const path of actual) if (!paths.includes(path)) d.add(path, 0, "E_PACKAGE_PATH", "Unknown package file"); - for (const path of paths.slice(0, 2)) if (!(path in (files ?? {}))) d.add(path, 0, "E_PACKAGE_MISSING", "Required package file is missing"); + for (const path of actual) if (!packagePaths.includes(path)) d.add(path, 0, "E_PACKAGE_PATH", "Unknown package file"); + for (const path of packagePaths.slice(0, 2)) if (!(path in (files ?? {}))) d.add(path, 0, "E_PACKAGE_MISSING", "Required package file is missing"); if (!continueOnMissing && d.all.length) return { ok: false, diagnostics: d.all }; const data = {}; - for (const path of paths) if (path in (files ?? {})) { + for (const path of packagePaths) if (path in (files ?? {})) { try { data[path] = Buffer.from(files[path]); - sourceChecks(data[path], path, d); + const [minimum, maximum] = packageSizes[path]; + if (Buffer.from(data[path]).length < minimum || Buffer.from(data[path]).length > maximum) d.add(path, 0, "E_FILE_SIZE", `${path} must contain ${minimum}..${maximum} bytes`); + else sourceChecks(data[path], path, d, packageSizes); } catch { d.add(path, 0, "E_PACKAGE_BYTES", "Package file must be byte data"); } } - if (!("review.loop" in data)) return { ok: false, diagnostics: d.all, packageFiles: cloneFiles(data) }; + if (!(loopFile in data)) return { ok: false, diagnostics: d.all, packageFiles: cloneFiles(data) }; if (Object.values(data).reduce((sum, bytes) => sum + bytes.length, 0) > totalLimit) d.add("", 0, "E_PACKAGE_SIZE", "Package exceeds 393216 byte limit"); if (!continueOnMissing && d.all.length) return { ok: false, diagnostics: d.all, packageFiles: cloneFiles(data) }; - const parsed = parseLoopXml(data["review.loop"]); + const parsed = parseLoopXml(data[loopFile]); const checked = parsed.ast ? validateLoop(parsed.ast) : null; appendDiagnostics(d, collectDiagnostics(parsed, checked)); @@ -86,13 +89,13 @@ function compileLoopFilesInternal(files, { continueOnMissing = false } = {}) { const ir = { ...checked.ir, instructions: extracted.sections.map(({ bytes, ...section }) => section) }; if (!validateClosedIr(ir)) { - d.add("review.loop", 0, "E_IR_INVARIANT", "Closed invariant validation failed"); + d.add(loopFile, 0, "E_IR_INVARIANT", "Closed invariant validation failed"); return { ok: false, diagnostics: d.all, packageFiles: cloneFiles(data) }; } const irBytes = canonicalIrBytes(ir); const revisions = { - source: prefixed("ls1-sha256:", "source-v1", [data["review.loop"]]), + source: prefixed("ls1-sha256:", "source-v1", [data[loopFile]]), package: packageRevision(data), executable: prefixed("er1-sha256:", "recipe-v1", [Buffer.from(ir.compiler), irBytes, ...extracted.sections.flatMap((section) => [Buffer.from(section.id), section.bytes])]), }; @@ -105,13 +108,13 @@ function compileLoopFilesInternal(files, { continueOnMissing = false } = {}) { instructions: extracted.sections, packageFiles, revisions, - rawSourceDigest: rawSha256(data["review.loop"]), + rawSourceDigest: rawSha256(data[loopFile]), diagnostics: d.all, }; } -export function compileLoopFiles(files) { - const result = compileLoopFilesInternal(files); +export function compileLoopFiles(files, options = {}) { + const result = compileLoopFilesInternal(files, options); const finalized = finalizeDiagnostics(result.diagnostics); if (finalized.length) return { ok: false, diagnostics: finalized }; return { ...result, diagnostics: finalized }; @@ -120,12 +123,12 @@ export function compileLoopFiles(files) { /** * Compile package files directly from a directory without trust or execution. */ -export async function compileLoopPackage(directory, { beforeLeafRead, afterLeafOpenForTest } = {}) { +export async function compileLoopPackage(directory, { beforeLeafRead, afterLeafOpenForTest, loopFile = "review.loop" } = {}) { const { files, diagnostics } = await readPackageDirectory(directory, { beforeLeafRead, - afterLeafOpenForTest, + afterLeafOpenForTest, loopFile, }); - const compiled = compileLoopFilesInternal(files, { continueOnMissing: true }); + const compiled = compileLoopFilesInternal(files, { continueOnMissing: true, loopFile }); const merged = finalizeDiagnostics([...diagnostics, ...(compiled.diagnostics ?? [])]); if (merged.length) return { ok: false, diagnostics: merged }; return compiled; diff --git a/src/loops/dsl/compile.test.mjs b/src/loops/dsl/compile.test.mjs index 45cf88da..fabfca88 100644 --- a/src/loops/dsl/compile.test.mjs +++ b/src/loops/dsl/compile.test.mjs @@ -5,6 +5,12 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { compileLoopFiles, compileLoopPackage } from "./compile.mjs"; import { freezeRecipe, loadFrozenRecipe } from "./frozen.mjs"; +import { canonicalIrBytes } from "./canonical.mjs"; +import { prefixed } from "./hash.mjs"; +import { validateClosedIr, validateReplayIr } from "./ir-validate.mjs"; +import { createJournalRecord } from "../run/run-journal.mjs"; +import { foldRun } from "../run/run-fold.mjs"; +import { created } from "../run/m2-test-fixtures.mjs"; import { renderDiagnostics } from "./diagnostics.mjs"; const root = new URL("../../../loops/review/", import.meta.url); @@ -19,8 +25,10 @@ test("built-in review package compiles to deterministic canonical frozen IR", as assert.deepEqual(first.irBytes, second.irBytes); assert.deepEqual(first.revisions, second.revisions); assert.equal(first.ir.schema, "burnlist-loop-ir@1"); assert.equal(first.ir.compiler, "burnlist-loop-compiler@1"); - assert.deepEqual(first.ir.nodes.map((node) => node.id), ["implement", "completed", "converged", "exhausted", "failed", "needs-human", "review", "stopped", "verify"]); - assert.deepEqual(first.ir.edges.map((edge) => edge.from), ["implement", "converged", "converged", "review", "review", "review", "verify", "verify"]); + assert.equal(first.ir.nodes.length, 14); + assert.equal(first.ir.edges.length, 16); + assert.deepEqual([...first.ir.nodes.map((node) => node.id)].sort(), ["completed", "converged", "decompose", "exhausted", "failed", "final-review", "final-validate", "implement", "integrate", "needs-human", "review", "start", "stopped", "validate"].sort()); + assert.deepEqual([...first.ir.edges.map((edge) => edge.from)].sort(), ["start", "decompose", "implement", "validate", "validate", "review", "review", "review", "integrate", "final-validate", "final-validate", "final-review", "final-review", "final-review", "converged", "converged"].sort()); assert.match(first.revisions.executable, /^er1-sha256:[a-f0-9]{64}$/); assert.equal((await compileLoopPackage(new URL("../../../loops/review", import.meta.url).pathname)).ok, true); }); @@ -32,13 +40,35 @@ test("runtime consumes persisted frozen IR and validates recipe identity", async assert.throws(() => loadFrozenRecipe(Buffer.from(changed)), /Frozen recipe/); }); +test("pre-H6 frozen recipes and Run journals replay without rewriting their identity", async () => { + const current = await compiled(), frozen = JSON.parse(freezeRecipe(current)); + const legacyIr = { + ...current.ir, + nodes: current.ir.nodes.map(({ execution: _execution, intelligence: _intelligence, ...node }) => node), + }; + assert.equal(validateClosedIr(legacyIr), false); + assert.equal(validateReplayIr(legacyIr), true); + const instructions = frozen.instructions; + const executable = prefixed("er1-sha256:", "recipe-v1", [ + Buffer.from(legacyIr.compiler), canonicalIrBytes(legacyIr), + ...instructions.flatMap((section) => [Buffer.from(section.id), Buffer.from(section.bytes, "base64")]), + ]); + const legacy = { ...frozen, revisions: { ...frozen.revisions, executable }, ir: legacyIr }; + const bytes = Buffer.from(`${JSON.stringify(legacy)}\n`); + const loaded = loadFrozenRecipe(bytes); + assert.equal(loaded.irBytes, canonicalIrBytes(legacyIr).toString("base64")); + assert.equal(loaded.revisions.executable, executable); + const journal = [createJournalRecord({ sequence: 1, prevDigest: null, at: 0, type: "run-created", payload: created(legacyIr) })]; + assert.equal(foldRun(journal).graph, legacyIr); +}); + test("closed grammar rejects Stage 2 syntax and convergence bypass", async () => { const files = await reviewFiles(); for (const replacement of [ - '', '', - '', '', '', + '', '', + '', '', '', ]) { - const copied = { ...files, "review.loop": Buffer.from(files["review.loop"].toString().replace('', replacement)) }; + const copied = { ...files, "review.loop": Buffer.from(files["review.loop"].toString().replace('', replacement)) }; const result = compileLoopFiles(copied); assert.equal(result.ok, false, replacement); assert.ok(result.diagnostics.length > 0); } @@ -57,9 +87,32 @@ test("reviewer requirements close on the supervised Stage 1 boundary", async () } }); +test("singleton declarations and frozen agent invariants fail closed", async () => { + const files = await reviewFiles(), source = files["review.loop"].toString(); + for (const element of [ + source.match(/ /u)?.[0], + source.match(/ /u)?.[0], + ]) { + assert.ok(element); + const duplicated = compileLoopFiles({ + ...files, + "review.loop": Buffer.from(source.replace(element, `${element}\n${element}`)), + }); + assert.equal(duplicated.ok, false); + assert.ok(duplicated.diagnostics.some((item) => item.code === "E_CHILD_COUNT")); + } + const result = await compiled(); + const wrongTask = structuredClone(result.ir); + Object.assign(wrongTask.nodes.find((node) => node.id === "decompose"), { role: "reviewer", authority: "read" }); + assert.equal(validateClosedIr(wrongTask), false); + const wrongReviewer = structuredClone(result.ir); + wrongReviewer.nodes.find((node) => node.id === "review").independentFrom = "final-review"; + assert.equal(validateClosedIr(wrongReviewer), false); +}); + test("each semantic outcome has one closed, type-safe target", async () => { const files = await reviewFiles(); - const source = files["review.loop"].toString().replace('from="verify" on="pass" to="review"', 'from="verify" on="pass" to="implement"'); + const source = files["review.loop"].toString().replace('from="validate" on="pass" to="review"', 'from="validate" on="pass" to="implement"'); const result = compileLoopFiles({ ...files, "review.loop": Buffer.from(source) }); assert.equal(result.ok, false); assert.ok(result.diagnostics.some((item) => item.code === "E_EDGE_TARGET")); @@ -67,7 +120,7 @@ test("each semantic outcome has one closed, type-safe target", async () => { test("diagnostics are stable, retained, sorted, and capped", async () => { const files = await reviewFiles(); - const malformed = files["review.loop"].toString().replace('max-rounds="3"', 'max-rounds="0" evil="1"').replace('', ''); + const malformed = files["review.loop"].toString().replace('max-rounds="12"', 'max-rounds="0" evil="1"').replace('', ''); const result = compileLoopFiles({ ...files, "review.loop": Buffer.from(malformed) }); assert.equal(result.ok, false); const lines = renderDiagnostics(result.diagnostics).trim().split("\n"); @@ -86,12 +139,12 @@ test("package lexical limits fail closed before grammar compilation", async () = test("instruction extraction is exact and treats fenced headings as prose", async () => { const files = await reviewFiles(); - const markdown = "## implement\ntext\n```md\n## review\n```\n## review\nreview text\n"; + const markdown = "## start\nscope\n```md\n## review\n```\n## review\nreview text\n## decompose\ndecompose\n## implement\nimplement\n## integrate\nintegrate\n## final-review\ninspect\n"; const good = compileLoopFiles({ ...files, "instructions.md": Buffer.from(markdown) }); assert.equal(good.ok, true, renderDiagnostics(good.diagnostics ?? [])); - const bad = compileLoopFiles({ ...files, "instructions.md": Buffer.from("## implement\ntext\n## implement\nagain\n") }); + const bad = compileLoopFiles({ ...files, "instructions.md": Buffer.from("## start\nscope\n## start\nagain\n") }); assert.equal(bad.ok, false); assert.ok(bad.diagnostics.some((item) => item.code === "E_INSTRUCTIONS_DUPLICATE")); - const tilde = "## implement\ntext\n~~~ language ` allowed\n## review\n~~~ \n## review\nreview text\n"; + const tilde = "## start\nscope\n~~~\n## review\nfenced text\n~~~\n## review\nreview text\n## decompose\nsplit\n## implement\nimplement\n## integrate\nintegrate\n## final-review\ninspect\n"; assert.equal(compileLoopFiles({ ...files, "instructions.md": Buffer.from(tilde) }).ok, true); }); @@ -129,30 +182,28 @@ test("frozen replay rejects every closed-IR union, cap, reference, and ordering ]) assert.throws(() => loadFrozenRecipe(mutate(change)), TypeError); }); -test("closed grammar rejects named later constructs and group/order violations", async () => { +test("closed grammar rejects named later constructs", async () => { const files = await reviewFiles(), source = files["review.loop"].toString(); for (const name of ["input", "condition", "source", "operator", "target", "map", "foreach", "join", "combine", "branch"]) { - const result = compileLoopFiles({ ...files, "review.loop": Buffer.from(source.replace('', `<${name} id="later"/>`)) }); + const result = compileLoopFiles({ ...files, "review.loop": Buffer.from(source.replace('', `<${name} id="later"/>`)) }); assert.equal(result.ok, false, name); assert.ok(result.diagnostics.some((item) => item.code === "E_ELEMENT_UNKNOWN")); } - const reordered = source.replace(/(\n) ()/, "$2\n $1"); - const result = compileLoopFiles({ ...files, "review.loop": Buffer.from(reordered) }); - assert.equal(result.ok, false); assert.ok(result.diagnostics.some((item) => item.code === "E_CHILD_GROUP")); + const altered = Buffer.from(source.replace('', '')); + const result = compileLoopFiles({ ...files, "review.loop": altered }); + assert.equal(result.ok, false); assert.ok(result.diagnostics.some((item) => item.code === "E_ATTRIBUTE_REQUIRED")); }); test("every Stage 1 element required attribute and scalar union is closed", async () => { const files = await reviewFiles(), source = files["review.loop"].toString(); const required = [ - 'id="review"', 'version="0.1.0"', 'entry="implement"', 'max-rounds="3"', 'max-minutes="60"', 'max-agent-runs="6"', 'max-check-runs="3"', 'max-transitions="16"', 'max-output-bytes="262144"', - 'id="implement"', 'mode="task"', 'role="maker"', 'route="implementation.standard"', 'authority="write"', 'instructions="implement"', 'capability="repo-verify"', - 'id="review"', 'mode="review"', 'role="reviewer"', 'route="review.strong"', 'authority="read"', 'independent-from="implement"', 'requires="fresh-session:enforced,filesystem-write-deny:supervised"', - 'id="converged"', 'kind="convergence"', 'state="converged"', 'error="failed"', 'timeout="failed"', 'cancelled="stopped"', 'lost="needs-human"', 'exhausted="exhausted"', 'from="implement"', 'on="complete"', 'to="verify"', + 'id="start"', 'version="0.1.0"', 'entry="start"', 'max-rounds="12"', 'max-minutes="60"', 'max-agent-runs="18"', 'max-check-runs="8"', 'max-transitions="40"', 'max-output-bytes="262144"', + 'id="implement"', 'mode="task"', 'execution="managed"', 'intelligence="standard"', 'role="maker"', 'route="implementation.standard"', 'authority="write"', 'instructions="implement"', 'id="decompose"', 'id="integrate"', 'id="review"', 'mode="review"', 'role="reviewer"', 'route="review.strong"', 'authority="read"', 'independent-from="implement"', 'requires="fresh-session:enforced,filesystem-write-deny:supervised"', 'id="final-review"', 'independent-from="implement"', 'id="validate"', 'id="final-validate"', 'capability="repo-verify"', 'id="converged"', 'kind="convergence"', 'state="converged"', 'error="failed"', 'timeout="failed"', 'cancelled="stopped"', 'lost="needs-human"', 'exhausted="exhausted"', 'from="start"', 'on="complete"', 'to="decompose"', ]; for (const token of required) { const result = compileLoopFiles({ ...files, "review.loop": Buffer.from(source.replace(token, "")) }); assert.equal(result.ok, false, token); assert.ok(result.diagnostics.some((item) => item.code === "E_ATTRIBUTE_REQUIRED"), token); } - for (const [token, replacement] of [['mode="task"', 'mode="stage-two"'], ['role="maker"', 'role="planner"'], ['authority="write"', 'authority="admin"'], ['route="implementation.standard"', 'route="invalid..route"'], ['kind="convergence"', 'kind="metric"'], ['state="converged"', 'state="done"'], ['max-visits="3"', 'max-visits="0"']]) { + for (const [token, replacement] of [['mode="task"', 'mode="stage-two"'], ['execution="managed"', 'execution="remote"'], ['intelligence="standard"', 'intelligence="provider-name"'], ['role="maker"', 'role="planner"'], ['authority="write"', 'authority="admin"'], ['route="implementation.standard"', 'route="invalid..route"'], ['kind="convergence"', 'kind="metric"'], ['state="converged"', 'state="done"'], ['max-visits="3"', 'max-visits="0"']]) { const result = compileLoopFiles({ ...files, "review.loop": Buffer.from(source.replace(token, replacement)) }); assert.equal(result.ok, false, replacement); } @@ -160,7 +211,7 @@ test("every Stage 1 element required attribute and scalar union is closed", asyn test("diagnostic truncation and recovery keep the first 99 sorted findings", async () => { const files = await reviewFiles(), extras = Array.from({ length: 110 }, (_, index) => ` bad-${index}="x"`).join(""); - const source = files["review.loop"].toString().replace('version="0.1.0"', 'version="0"').replace('max-rounds="3"', `max-rounds="0"${extras}`).replace('', ''); + const source = files["review.loop"].toString().replace('version="0.1.0"', 'version="0"').replace('max-rounds="12"', `max-rounds="0"${extras}`).replace('', ''); const result = compileLoopFiles({ ...files, "review.loop": Buffer.from(source) }); assert.equal(result.ok, false); assert.equal(result.diagnostics.length, 100); assert.equal(result.diagnostics[0].code, "E_TOO_MANY_DIAGNOSTICS"); @@ -173,7 +224,7 @@ test("grammar diagnoses malformed attribute and complete semantic/system routing const duplicate = source.replace('id="review" version', 'id="review" id="again" version'); let result = compileLoopFiles({ ...files, "review.loop": Buffer.from(duplicate) }); assert.equal(result.ok, false); assert.deepEqual(result.diagnostics[0], { path: "review.loop", byteOffset: 18, code: "E_XML_DUPLICATE_ATTRIBUTE", message: "Duplicate attribute id" }); - const missing = source.replace('\n', ""); + const missing = source.replace('\n', ""); result = compileLoopFiles({ ...files, "review.loop": Buffer.from(missing) }); assert.equal(result.ok, false); assert.ok(result.diagnostics.some((item) => item.code === "E_EDGE_MISSING")); const system = source.replace('lost="needs-human"', 'lost="failed"'); @@ -182,14 +233,14 @@ test("grammar diagnoses malformed attribute and complete semantic/system routing const duplicateSystem = source.replace('error="failed"', 'error="failed" error="failed"'); result = compileLoopFiles({ ...files, "review.loop": Buffer.from(duplicateSystem) }); assert.equal(result.ok, false); assert.ok(result.diagnostics.some((item) => item.code === "E_XML_DUPLICATE_ATTRIBUTE")); - const bypass = source.replace('from="review" on="approve" to="converged"', 'from="review" on="approve" to="completed"'); + const bypass = source.replace('from="final-review" on="approve" to="converged"', 'from="final-review" on="approve" to="completed"'); result = compileLoopFiles({ ...files, "review.loop": Buffer.from(bypass) }); assert.equal(result.ok, false); assert.ok(result.diagnostics.some((item) => item.code === "E_EDGE_TARGET" || item.code === "E_CONVERGENCE_DOMINATION")); }); test("recoverable XML findings merge with semantic findings before global sorting", async () => { const files = await reviewFiles(); - const source = files["review.loop"].toString().replace('version="0.1.0"', 'version="0" version="still-bad"').replace('max-rounds="3"', 'max-rounds="0" unknown="x"'); + const source = files["review.loop"].toString().replace('version="0.1.0"', 'version="0" version="still-bad"').replace('max-rounds="12"', 'max-rounds="0" unknown="x"'); const result = compileLoopFiles({ ...files, "review.loop": Buffer.from(source) }); assert.equal(result.ok, false); assert.deepEqual(result.diagnostics.map((item) => item.code), ["E_SCALAR", "E_XML_DUPLICATE_ATTRIBUTE", "E_ATTRIBUTE_UNKNOWN", "E_SCALAR"]); @@ -200,11 +251,11 @@ test("literal fenced-section grammar handles both delimiters and false closers", const files = await reviewFiles(); for (const marker of ["`", "~"]) for (const indent of [0, 1, 2, 3]) { const fence = `${" ".repeat(indent)}${marker.repeat(3)} suffix ${marker}\n## review\n${" ".repeat(indent)}${marker.repeat(4)} \n`; - const markdown = `## implement\ntext\n${fence}## review\nreview text\n`; + const markdown = `## start\nscope\n${fence}## review\nreview text\n## decompose\nsplit\n## implement\nwork\n## integrate\ncombine\n## final-review\ninspect\n`; const result = compileLoopFiles({ ...files, "instructions.md": Buffer.from(markdown) }); assert.equal(result.ok, true, `${marker}/${indent}`); } - const falseCloser = "## implement\ntext\n``` suffix `\n## review\n``` not-close\n## review\nreview\n"; + const falseCloser = "## start\nscope\n``` suffix `\n## review\n``` not-close\n## review\nreview\n## decompose\nsplit\n## implement\nwork\n## integrate\ncombine\n## final-review\ninspect\n"; const result = compileLoopFiles({ ...files, "instructions.md": Buffer.from(falseCloser) }); assert.equal(result.ok, false); assert.ok(result.diagnostics.some((item) => item.code === "E_INSTRUCTIONS_FENCE")); }); @@ -228,7 +279,7 @@ test("frozen recipe replay rejects any noncanonical JSON encoding", async () => const spaced = Buffer.from(`${frozen.toString("utf8").trim()} \n`, "utf8"); assert.throws(() => loadFrozenRecipe(spaced), /Frozen recipe is not canonical/); - const scientific = Buffer.from(frozen.toString("utf8").replace("\"maxRounds\":3", "\"maxRounds\":1e1"), "utf8"); + const scientific = Buffer.from(frozen.toString("utf8").replace("\"maxRounds\":12", "\"maxRounds\":1.2e1"), "utf8"); assert.throws(() => loadFrozenRecipe(scientific), /Frozen recipe is not canonical/); }); @@ -258,18 +309,15 @@ test("compiler invariant mirror rejects duplicated reviewer instruction IDs", as test("compiler invariant mirror rejects non-task maker entry", async () => { const files = await reviewFiles(); - let source = files["review.loop"].toString().replace('entry="implement"', 'entry="verify"'); - source = source.replace('', ''); - source = source.replace(' ', ' '); - source = source.replace(' ', ' '); + const source = files["review.loop"].toString().replace('entry="start"', 'entry="validate"'); const reassigned = compileLoopFiles({ ...files, "review.loop": Buffer.from(source) }); assert.equal(reassigned.ok, false); - assert.ok(reassigned.diagnostics.some((item) => item.code === "E_IR_INVARIANT")); + assert.ok(reassigned.diagnostics.some((item) => ["E_REACHABILITY", "E_ENTRY_KIND", "E_IR_INVARIANT"].includes(item.code))); }); test("diagnostic truncation keeps the first 99 sorted findings", async () => { const files = await reviewFiles(), extras = Array.from({ length: 110 }, (_, index) => ` bad-${index}="x"`).join(""); - const source = files["review.loop"].toString().replace('version="0.1.0"', 'version="0"').replace('max-rounds="3"', `max-rounds="0"${extras}`).replace('', ''); + const source = files["review.loop"].toString().replace('version="0.1.0"', 'version="0"').replace('max-rounds="12"', `max-rounds="0"${extras}`).replace('', ''); const result = compileLoopFiles({ ...files, "review.loop": Buffer.from(source) }); assert.equal(result.ok, false); assert.equal(result.diagnostics.length, 100); assert.deepEqual(result.diagnostics[0], { path: "", byteOffset: 0, code: "E_TOO_MANY_DIAGNOSTICS", message: "Too many diagnostics (maximum 100)" }); diff --git a/src/loops/dsl/frozen.mjs b/src/loops/dsl/frozen.mjs index 8d6ab0aa..71b95e6d 100644 --- a/src/loops/dsl/frozen.mjs +++ b/src/loops/dsl/frozen.mjs @@ -1,12 +1,13 @@ import { canonicalIrBytes } from "./canonical.mjs"; import { prefixed, rawSha256 } from "./hash.mjs"; -import { validateClosedIr } from "./ir-validate.mjs"; +import { validateReplayIr } from "./ir-validate.mjs"; const slug = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; const digest = /^[a-f0-9]{64}$/; const sections = ["schema", "compiler", "revisions", "source", "package", "ir", "instructions"]; const revisionKeys = ["source", "package", "executable"]; const packageSizes = { "review.loop": [1, 65536], "instructions.md": [1, 262144], "example/item.md": [0, 65536] }; +const loopPath = /^[a-z0-9]+(?:-[a-z0-9]+)*\.loop$/u; function sortByPath(entries) { return [...entries].sort((left, right) => Buffer.compare(Buffer.from(left.path), Buffer.from(right.path))); @@ -46,9 +47,11 @@ function freeze(value) { if (!value || typeof value !== "object") return value; /** Serializes the only runtime-facing compiler product: immutable IR and source/package bytes. */ export function freezeRecipe(compiled) { if (!compiled?.ok || !compiled.ir || !Buffer.isBuffer(compiled.irBytes) || !compiled.packageFiles) throw new TypeError("A successful compile result is required"); + const sourcePath = Object.keys(compiled.packageFiles).find((path) => loopPath.test(path)); + if (!sourcePath) throw new TypeError("A successful compile result is required"); const packageFiles = Object.entries(compiled.packageFiles).sort(([left], [right]) => Buffer.compare(Buffer.from(left), Buffer.from(right))).map(([path, bytes]) => ({ path, bytes: Buffer.from(bytes).toString("base64") })); const instructions = compiled.instructions.map((section) => ({ id: section.id, digest: section.digest, bytes: Buffer.from(section.bytes).toString("base64") })); - return Buffer.from(`${JSON.stringify({ schema: "burnlist-loop-frozen@1", compiler: compiled.ir.compiler, revisions: compiled.revisions, source: Buffer.from(compiled.packageFiles["review.loop"]).toString("base64"), package: packageFiles, ir: JSON.parse(compiled.irBytes), instructions })}\n`, "utf8"); + return Buffer.from(`${JSON.stringify({ schema: "burnlist-loop-frozen@1", compiler: compiled.ir.compiler, revisions: compiled.revisions, source: Buffer.from(compiled.packageFiles[sourcePath]).toString("base64"), package: packageFiles, ir: JSON.parse(compiled.irBytes), instructions })}\n`, "utf8"); } /** Runtime/replay boundary: verify frozen bytes and never recompile installed source. */ @@ -61,11 +64,12 @@ export function loadFrozenRecipe(bytes) { } catch { throw new TypeError("Frozen recipe is not canonical"); } - if (!exact(value, sections) || value.schema !== "burnlist-loop-frozen@1" || value.compiler !== "burnlist-loop-compiler@1" || !exact(value.revisions, revisionKeys) || !revision(value.revisions.source, "ls1") || !revision(value.revisions.package, "lp1") || !revision(value.revisions.executable, "er1") || !validateClosedIr(value.ir) || value.ir.compiler !== value.compiler) throw new TypeError("Frozen recipe has an invalid envelope"); + if (!exact(value, sections) || value.schema !== "burnlist-loop-frozen@1" || value.compiler !== "burnlist-loop-compiler@1" || !exact(value.revisions, revisionKeys) || !revision(value.revisions.source, "ls1") || !revision(value.revisions.package, "lp1") || !revision(value.revisions.executable, "er1") || !validateReplayIr(value.ir) || value.ir.compiler !== value.compiler) throw new TypeError("Frozen recipe has an invalid envelope"); const source = base64(value.source); if (!Array.isArray(value.package) || value.package.length < 2 || value.package.length > 3 || !Array.isArray(value.instructions)) throw new TypeError("Frozen recipe has an invalid package"); - const packageFiles = value.package.map((item) => { if (!exact(item, ["path", "bytes"]) || !Object.hasOwn(packageSizes, item.path)) throw new TypeError("Frozen recipe has an invalid package"); const content = base64(item.bytes), [minimum, maximum] = packageSizes[item.path]; if (content.length < minimum || content.length > maximum) throw new TypeError("Frozen recipe has an invalid package"); return { path: item.path, bytes: content }; }); - if (new Set(packageFiles.map((item) => item.path)).size !== packageFiles.length || packageFiles.reduce((total, item) => total + item.bytes.length, 0) > 393216 || !packageFiles.some((item) => item.path === "review.loop" && item.bytes.equals(source)) || !packageFiles.some((item) => item.path === "instructions.md")) throw new TypeError("Frozen recipe has an invalid package"); + const packageFiles = value.package.map((item) => { const limits = loopPath.test(item?.path) ? [1, 65536] : packageSizes[item?.path]; if (!exact(item, ["path", "bytes"]) || !limits) throw new TypeError("Frozen recipe has an invalid package"); const content = base64(item.bytes), [minimum, maximum] = limits; if (content.length < minimum || content.length > maximum) throw new TypeError("Frozen recipe has an invalid package"); return { path: item.path, bytes: content }; }); + const sources = packageFiles.filter((item) => loopPath.test(item.path)); + if (new Set(packageFiles.map((item) => item.path)).size !== packageFiles.length || sources.length !== 1 || packageFiles.reduce((total, item) => total + item.bytes.length, 0) > 393216 || !sources[0].bytes.equals(source) || !packageFiles.some((item) => item.path === "instructions.md")) throw new TypeError("Frozen recipe has an invalid package"); const sortedPackage = [...packageFiles].sort((left, right) => Buffer.compare(Buffer.from(left.path), Buffer.from(right.path))); const packageDigest = prefixed("lp1-sha256:", "package-v1", sortedPackage.flatMap((item) => [Buffer.from(item.path), item.bytes])); if (prefixed("ls1-sha256:", "source-v1", [source]) !== value.revisions.source || packageDigest !== value.revisions.package) throw new TypeError("Frozen recipe provenance revision does not match bytes"); diff --git a/src/loops/dsl/grammar.mjs b/src/loops/dsl/grammar.mjs index 1fea377e..c10ed057 100644 --- a/src/loops/dsl/grammar.mjs +++ b/src/loops/dsl/grammar.mjs @@ -5,14 +5,13 @@ const slug = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; const route = /^[a-z0-9]+(?:-[a-z0-9]+)*(?:\.[a-z0-9]+(?:-[a-z0-9]+)*)*$/; const semver = /^(0|[1-9][0-9]{0,5})\.(0|[1-9][0-9]{0,5})\.(0|[1-9][0-9]{0,5})$/; const positive = /^[1-9][0-9]*$/; -const order = ["budget", "agent", "check", "agent", "gate", "terminal", "terminal", "terminal", "terminal", "terminal", "failure-policy", "edge", "edge", "edge", "edge", "edge", "edge", "edge", "edge"]; const attrs = { loop: ["id", "version", "entry"], budget: ["max-rounds", "max-minutes", "max-agent-runs", "max-check-runs", "max-transitions", "max-output-bytes"], - agent: ["id", "mode", "role", "route", "authority", "instructions", "independent-from", "requires"], check: ["id", "capability"], gate: ["id", "kind", "requires"], + agent: ["id", "mode", "execution", "intelligence", "role", "route", "authority", "instructions", "independent-from", "requires"], check: ["id", "capability"], gate: ["id", "kind", "requires"], terminal: ["id", "state"], "failure-policy": ["error", "timeout", "cancelled", "lost", "exhausted"], edge: ["from", "on", "to", "max-visits"], }; const required = { - loop: attrs.loop, budget: attrs.budget, agent: ["id", "mode", "role", "route", "authority", "instructions"], check: ["id", "capability"], gate: attrs.gate, terminal: attrs.terminal, + loop: attrs.loop, budget: attrs.budget, agent: ["id", "mode", "execution", "intelligence", "role", "route", "authority", "instructions"], check: ["id", "capability"], gate: attrs.gate, terminal: attrs.terminal, "failure-policy": attrs["failure-policy"], edge: ["from", "on", "to"], }; const outcomes = { agent: (node) => node.mode === "task" ? ["complete"] : ["approve", "reject", "escalate"], check: () => ["pass", "fail"], gate: () => ["pass", "fail"], terminal: () => [] }; @@ -46,9 +45,14 @@ export function validateLoop(ast) { exactAttrs(d, ast); if (ast.selfClosing) add(d, ast, "E_ROOT_FORM", "Root must not be self-closing"); value(d, ast, "id", (v) => slug.test(v), "a lowercase slug"); value(d, ast, "version", (v) => semver.test(v), "a Stage 1 SemVer"); value(d, ast, "entry", (v) => slug.test(v), "a lowercase slug"); - ast.children.forEach((node, index) => { exactAttrs(d, node); if (!node.selfClosing) add(d, node, "E_CHILD_FORM", `<${node.name}> must be self-closing`); if (node.children.length) add(d, node, "E_CHILDREN", `<${node.name}> may not have children`); if (node.name !== order[index]) add(d, node, "E_CHILD_ORDER", "Children must use the closed Stage 1 group order"); if (index === 1 && node.attrs.mode !== "task") add(d, node, "E_CHILD_GROUP", "Maker agent group must precede check"); if (index === 3 && node.attrs.mode !== "review") add(d, node, "E_CHILD_GROUP", "Reviewer agent group must follow check"); }); - if (ast.children.length !== order.length) add(d, ast, "E_CHILD_COUNT", "Loop must contain the exact closed Stage 1 child set"); + ast.children.forEach((node) => { exactAttrs(d, node); if (!node.selfClosing) add(d, node, "E_CHILD_FORM", `<${node.name}> must be self-closing`); if (node.children.length) add(d, node, "E_CHILDREN", `<${node.name}> may not have children`); }); + const kindTotals = new Map(); + for (const node of ast.children) kindTotals.set(node.name, (kindTotals.get(node.name) ?? 0) + 1); + if (kindTotals.get("budget") !== 1) add(d, ast, "E_CHILD_COUNT", "Loop must include exactly one budget declaration"); + if (kindTotals.get("failure-policy") !== 1) add(d, ast, "E_CHILD_COUNT", "Loop must include exactly one failure-policy declaration"); + if (!kindTotals.get("edge")) add(d, ast, "E_CHILD_COUNT", "Loop must include at least one edge"); const ids = new Map(), nodes = [], edges = [], policy = ast.children.find((node) => node.name === "failure-policy"); + const budget = ast.children.find((node) => node.name === "budget"); for (const node of ast.children) { if (["agent", "check", "gate", "terminal"].includes(node.name)) { value(d, node, "id", (v) => slug.test(v), "a lowercase slug"); @@ -56,13 +60,16 @@ export function validateLoop(ast) { } if (node.name === "budget") for (const [key, [min, max]] of Object.entries(limits)) value(d, node, key, (v) => positive.test(v) && +v >= min && +v <= max, `an integer from ${min} through ${max}`); if (node.name === "agent") { - value(d, node, "mode", (v) => v === "task" || v === "review", "task or review"); value(d, node, "role", (v) => v === "maker" || v === "reviewer", "maker or reviewer"); + value(d, node, "mode", (v) => v === "task" || v === "review", "task or review"); + value(d, node, "execution", (v) => v === "managed" || v === "host", "managed or host"); + value(d, node, "intelligence", (v) => ["fast", "standard", "strong", "critical"].includes(v), "fast, standard, strong, or critical"); + value(d, node, "role", (v) => v === "maker" || v === "reviewer", "maker or reviewer"); value(d, node, "route", (v) => route.test(v), "a Route"); value(d, node, "authority", (v) => v === "read" || v === "write", "read or write"); value(d, node, "instructions", (v) => slug.test(v), "a lowercase slug"); const task = node.attrs.mode === "task"; const expected = task ? ["maker", "write"] : ["reviewer", "read"]; if ((node.attrs.role && node.attrs.authority) && (node.attrs.role !== expected[0] || node.attrs.authority !== expected[1])) add(d, node, "E_AGENT_COMBINATION", `${node.attrs.mode} agent must be ${expected[0]}/${expected[1]}`); if (task && ("independent-from" in node.attrs || "requires" in node.attrs)) add(d, node, "E_AGENT_ATTRIBUTES", "Task agent may not have review-only attributes"); if (!task) { for (const key of ["independent-from", "requires"]) if (!(key in node.attrs)) add(d, node, "E_ATTRIBUTE_REQUIRED", ` requires attribute ${key}`); if (node.attrs.requires !== "fresh-session:enforced,filesystem-write-deny:supervised") add(d, node, "E_REVIEW_REQUIREMENTS", "Review requires must be fresh-session:enforced,filesystem-write-deny:supervised"); } - nodes.push({ kind: "agent", id: node.attrs.id, mode: node.attrs.mode, role: node.attrs.role, route: node.attrs.route, authority: node.attrs.authority, instructions: node.attrs.instructions, independentFrom: node.attrs["independent-from"] ?? null, requires: node.attrs.requires ? node.attrs.requires.split(",") : [] }); + nodes.push({ kind: "agent", id: node.attrs.id, mode: node.attrs.mode, execution: node.attrs.execution, intelligence: node.attrs.intelligence, role: node.attrs.role, route: node.attrs.route, authority: node.attrs.authority, instructions: node.attrs.instructions, independentFrom: node.attrs["independent-from"] ?? null, requires: node.attrs.requires ? node.attrs.requires.split(",") : [] }); } if (node.name === "check") { value(d, node, "capability", (v) => slug.test(v), "a lowercase slug"); nodes.push({ kind: "check", id: node.attrs.id, capability: node.attrs.capability }); } if (node.name === "gate") { if (node.attrs.kind !== "convergence") add(d, node, "E_GATE_KIND", "Stage 1 gate kind must be convergence"); nodes.push({ kind: "gate", id: node.attrs.id, gateKind: node.attrs.kind, requires: node.attrs.requires?.split(",") ?? [] }); } @@ -70,12 +77,19 @@ export function validateLoop(ast) { if (node.name === "edge") { if ("max-visits" in node.attrs) value(d, node, "max-visits", (v) => positive.test(v) && +v <= 100, "an integer from 1 through 100"); edges.push({ from: node.attrs.from, on: node.attrs.on, to: node.attrs.to, maxVisits: node.attrs["max-visits"] ? +node.attrs["max-visits"] : null, offset: node.byteOffset }); } } const agents = nodes.filter((node) => node.kind === "agent"), checks = nodes.filter((node) => node.kind === "check"), gates = nodes.filter((node) => node.kind === "gate"), terminals = nodes.filter((node) => node.kind === "terminal"); - if (agents.length !== 2 || agents.filter((node) => node.mode === "task").length !== 1 || agents.filter((node) => node.mode === "review").length !== 1) add(d, ast, "E_AGENT_CARDINALITY", "Stage 1 requires one task agent and one review agent"); - if (checks.length !== 1 || gates.length !== 1) add(d, ast, "E_NODE_CARDINALITY", "Stage 1 requires exactly one check and one convergence gate"); + const taskAgents = agents.filter((node) => node.mode === "task"), reviewAgents = agents.filter((node) => node.mode === "review"); + if (taskAgents.length < 1) add(d, ast, "E_AGENT_CARDINALITY", "Stage 1 requires at least one task agent"); + if (reviewAgents.length < 1) add(d, ast, "E_AGENT_CARDINALITY", "Stage 1 requires at least one review agent"); + if (checks.length < 1) add(d, ast, "E_NODE_CARDINALITY", "Stage 1 requires at least one check node"); + if (gates.length !== 1) add(d, ast, "E_NODE_CARDINALITY", "Stage 1 requires exactly one convergence gate"); for (const state of terminalStates) if (terminals.filter((node) => node.state === state).length !== 1) add(d, ast, "E_TERMINAL_CARDINALITY", `Stage 1 requires exactly one ${state} terminal`); - const reviewer = agents.find((node) => node.mode === "review"), maker = agents.find((node) => node.mode === "task"), check = checks[0], gate = gates[0]; - if (reviewer && reviewer.independentFrom !== maker?.id) add(d, ids.get(reviewer.id), "E_REVIEW_INDEPENDENCE", "Reviewer independent-from must name the task agent"); - if (gate && `${gate.requires.join(",")}` !== `${check?.id ?? ""},${reviewer?.id ?? ""}`) add(d, ids.get(gate.id), "E_GATE_REQUIREMENTS", "Convergence gate requires must name check then reviewer"); + for (const reviewer of reviewAgents) if (!taskAgents.some((task) => task.id === reviewer.independentFrom)) add(d, ids.get(reviewer.id), "E_REVIEW_INDEPENDENCE", "Reviewer independent-from must name an existing task agent"); + const gate = gates[0], gateRequires = new Set(gate?.requires ?? []); + if (gate && (gateRequires.size !== gate.requires.length + || !reviewAgents.some((node) => gateRequires.has(node.id)) + || [...gateRequires].some((id) => !checks.some((node) => node.id === id) && !reviewAgents.some((node) => node.id === id)))) { + add(d, ids.get(gate.id), "E_GATE_REQUIREMENTS", "Convergence gate requires must uniquely reference at least one review node and only check/review nodes"); + } if (policy) for (const [outcome, state] of Object.entries({ error: "failed", timeout: "failed", cancelled: "stopped", lost: "needs-human", exhausted: "budget-exhausted" })) { const target = ids.get(policy.attrs[outcome]); if (!target || target.attrs.state !== state) add(d, policy, "E_FAILURE_POLICY", `${outcome} must target the ${state} terminal`); } for (const edge of edges) { const from = ids.get(edge.from), to = ids.get(edge.to); @@ -84,12 +98,12 @@ export function validateLoop(ast) { const source = nodes.find((node) => node.id === edge.from); if (!outcomes[source?.kind]?.(source).includes(edge.on)) d.add("review.loop", edge.offset, "E_EDGE_OUTCOME", `Outcome ${edge.on} is not emitted by ${edge.from}`); const target = nodes.find((node) => node.id === edge.to); - const allowed = (source?.kind === "agent" && source.mode === "task" && edge.on === "complete" && target?.kind === "check") || - (source?.kind === "check" && edge.on === "pass" && target?.kind === "agent" && target.mode === "review") || - (source?.kind === "check" && edge.on === "fail" && target?.kind === "agent" && target.mode === "task") || - (source?.kind === "agent" && source.mode === "review" && edge.on === "reject" && target?.kind === "agent" && target.mode === "task") || - (source?.kind === "agent" && source.mode === "review" && edge.on === "approve" && target?.kind === "gate") || + const allowed = (source?.kind === "agent" && source.mode === "task" && edge.on === "complete" && target?.kind !== "terminal") || + (source?.kind === "agent" && source.mode === "review" && edge.on === "approve" && target?.kind !== "terminal") || + (source?.kind === "agent" && source.mode === "review" && edge.on === "reject" && target?.kind !== "terminal") || (source?.kind === "agent" && source.mode === "review" && edge.on === "escalate" && target?.kind === "terminal" && target.state === "needs-human") || + (source?.kind === "check" && edge.on === "pass" && target?.kind === "agent" && target?.mode === "review") || + (source?.kind === "check" && edge.on === "fail" && target?.kind === "agent" && target?.mode === "task") || (source?.kind === "gate" && edge.on === "pass" && target?.kind === "terminal" && target.state === "converged") || (source?.kind === "gate" && edge.on === "fail" && target?.kind === "terminal" && target.state === "needs-human"); if (!allowed) d.add("review.loop", edge.offset, "E_EDGE_TARGET", `Target ${edge.to} is not allowed for ${edge.from}/${edge.on}`); @@ -102,12 +116,13 @@ export function validateLoop(ast) { // System outcomes are runner-owned but are still graph routes for reachability. const systemEdges = policy ? nodes.filter((node) => node.kind !== "terminal").flatMap((node) => Object.entries(policy.attrs).map(([on, to]) => ({ from: node.id, on, to }))) : []; const reachable = reachesTerminal(nodes, [...edges, ...systemEdges], ast.attrs.entry); if (!ids.has(ast.attrs.entry)) add(d, ast, "E_ENTRY", "Entry must name a declared node"); + else if (!taskAgents.some((node) => node.id === ast.attrs.entry)) add(d, ast, "E_ENTRY_KIND", "Entry must name a task agent"); else for (const node of nodes) if (!reachable.has(node.id)) add(d, ids.get(node.id), "E_REACHABILITY", `Node ${node.id} is not reachable from entry`); const back = ids.has(ast.attrs.entry) ? backEdges(nodes, edges, ast.attrs.entry) : new Set(); for (const edge of edges) { const needs = back.has(edge); if (needs !== (edge.maxVisits !== null)) d.add("review.loop", edge.offset, "E_EDGE_VISITS", needs ? "DFS back edges require max-visits" : "max-visits is allowed only on DFS back edges"); } if (d.all.length) return { diagnostics: d.list, allDiagnostics: d.all }; const ir = normalizeIr({ schema: "burnlist-loop-ir@1", compiler: "burnlist-loop-compiler@1", id: ast.attrs.id, declaredVersion: ast.attrs.version, entry: ast.attrs.entry, - budget: { maxRounds: +ast.children[0].attrs["max-rounds"], maxMinutes: +ast.children[0].attrs["max-minutes"], maxAgentRuns: +ast.children[0].attrs["max-agent-runs"], maxCheckRuns: +ast.children[0].attrs["max-check-runs"], maxTransitions: +ast.children[0].attrs["max-transitions"], maxOutputBytes: +ast.children[0].attrs["max-output-bytes"] }, nodes, failurePolicy: { error: policy.attrs.error, timeout: policy.attrs.timeout, cancelled: policy.attrs.cancelled, lost: policy.attrs.lost, exhausted: policy.attrs.exhausted }, edges: edges.map(({ offset, ...edge }) => edge), instructions: [] }, (node) => outcomes[node.kind](node)); + budget: { maxRounds: +budget.attrs["max-rounds"], maxMinutes: +budget.attrs["max-minutes"], maxAgentRuns: +budget.attrs["max-agent-runs"], maxCheckRuns: +budget.attrs["max-check-runs"], maxTransitions: +budget.attrs["max-transitions"], maxOutputBytes: +budget.attrs["max-output-bytes"] }, nodes, failurePolicy: { error: policy.attrs.error, timeout: policy.attrs.timeout, cancelled: policy.attrs.cancelled, lost: policy.attrs.lost, exhausted: policy.attrs.exhausted }, edges: edges.map(({ offset, ...edge }) => edge), instructions: [] }, (node) => outcomes[node.kind](node)); return { ir, instructionIds: agents.map((node) => node.instructions), diagnostics: [], allDiagnostics: [] }; } diff --git a/src/loops/dsl/ir-validate.mjs b/src/loops/dsl/ir-validate.mjs index 6bd01d4c..22ea18fa 100644 --- a/src/loops/dsl/ir-validate.mjs +++ b/src/loops/dsl/ir-validate.mjs @@ -8,26 +8,59 @@ const states = ["converged", "needs-human", "failed", "stopped", "budget-exhaust const top = ["schema", "compiler", "id", "declaredVersion", "entry", "budget", "nodes", "failurePolicy", "edges", "instructions"]; const budget = ["maxRounds", "maxMinutes", "maxAgentRuns", "maxCheckRuns", "maxTransitions", "maxOutputBytes"]; const policy = ["error", "timeout", "cancelled", "lost", "exhausted"]; -const nodeKeys = { agent: ["kind", "id", "mode", "role", "route", "authority", "instructions", "independentFrom", "requires"], check: ["kind", "id", "capability"], gate: ["kind", "id", "gateKind", "requires"], terminal: ["kind", "id", "state"] }; +const nodeKeys = { agent: ["kind", "id", "mode", "execution", "intelligence", "role", "route", "authority", "instructions", "independentFrom", "requires"], check: ["kind", "id", "capability"], gate: ["kind", "id", "gateKind", "requires"], terminal: ["kind", "id", "state"] }; const limits = { maxRounds: [1, 100], maxMinutes: [1, 1440], maxAgentRuns: [1, 100], maxCheckRuns: [1, 100], maxTransitions: [1, 1000], maxOutputBytes: [1024, 1048576] }; +const reviewRequirements = ["fresh-session:enforced", "filesystem-write-deny:supervised"]; function exact(value, keys) { return !!value && typeof value === "object" && !Array.isArray(value) && Object.keys(value).length === keys.length && keys.every((key) => Object.hasOwn(value, key)); } function boundedSlug(value) { return typeof value === "string" && value.length <= 65536 && slug.test(value); } function integer(value, range = [0, Number.MAX_SAFE_INTEGER]) { return Number.isSafeInteger(value) && value >= range[0] && value <= range[1]; } function same(left, right) { return JSON.stringify(left) === JSON.stringify(right); } + function validNode(node) { if (!exact(node, nodeKeys[node?.kind] ?? []) || !boundedSlug(node.id)) return false; - if (node.kind === "agent") return ["task", "review"].includes(node.mode) && ["maker", "reviewer"].includes(node.role) && route.test(node.route) && ["read", "write"].includes(node.authority) && boundedSlug(node.instructions) && (node.independentFrom === null || boundedSlug(node.independentFrom)) && Array.isArray(node.requires) && node.requires.every((item) => typeof item === "string" && item.length <= 128); + if (node.kind === "agent") { + if (!["task", "review"].includes(node.mode) || !["managed", "host"].includes(node.execution) + || !["fast", "standard", "strong", "critical"].includes(node.intelligence) + || !route.test(node.route) || !boundedSlug(node.instructions) + || !Array.isArray(node.requires) || node.requires.some((item) => typeof item !== "string" || item.length > 128)) return false; + return node.mode === "task" + ? node.role === "maker" && node.authority === "write" && node.independentFrom === null && node.requires.length === 0 + : node.role === "reviewer" && node.authority === "read" && boundedSlug(node.independentFrom) && same(node.requires, reviewRequirements); + } if (node.kind === "check") return boundedSlug(node.capability); if (node.kind === "gate") return node.gateKind === "convergence" && Array.isArray(node.requires) && node.requires.every(boundedSlug); return states.includes(node.state); } + +function backEdges(nodes, edges, entry) { + const byFrom = new Map(); + for (const edge of edges) { + (byFrom.get(edge.from) ?? byFrom.set(edge.from, []).get(edge.from)).push(edge.to); + } + const color = new Map(); + const result = new Set(); + const visit = (id) => { + color.set(id, 1); + for (const to of byFrom.get(id) ?? []) { + if (color.get(to) === 1) { + result.add(`${id}\0${to}`); + } else if (!color.get(to)) { + visit(to); + } + } + color.set(id, 2); + }; + if (nodes.some((node) => node.id === entry)) visit(entry); + return result; +} + function targetAllowed(source, outcome, target) { - return (source.kind === "agent" && source.mode === "task" && outcome === "complete" && target.kind === "check") || + return (source.kind === "agent" && source.mode === "task" && outcome === "complete" && target.kind !== "terminal") || (source.kind === "check" && outcome === "pass" && target.kind === "agent" && target.mode === "review") || (source.kind === "check" && outcome === "fail" && target.kind === "agent" && target.mode === "task") || - (source.kind === "agent" && source.mode === "review" && outcome === "reject" && target.kind === "agent" && target.mode === "task") || - (source.kind === "agent" && source.mode === "review" && outcome === "approve" && target.kind === "gate") || + (source.kind === "agent" && source.mode === "review" && outcome === "reject" && target.kind !== "terminal") || + (source.kind === "agent" && source.mode === "review" && outcome === "approve" && target.kind !== "terminal") || (source.kind === "agent" && source.mode === "review" && outcome === "escalate" && target.kind === "terminal" && target.state === "needs-human") || (source.kind === "gate" && outcome === "pass" && target.kind === "terminal" && target.state === "converged") || (source.kind === "gate" && outcome === "fail" && target.kind === "terminal" && target.state === "needs-human"); @@ -35,24 +68,57 @@ function targetAllowed(source, outcome, target) { /** Rejects every noncanonical or unsupported symbolic IR before frozen replay. */ export function validateClosedIr(ir) { - if (!exact(ir, top) || ir.schema !== "burnlist-loop-ir@1" || ir.compiler !== "burnlist-loop-compiler@1" || !boundedSlug(ir.id) || !semver.test(ir.declaredVersion) || !boundedSlug(ir.entry) || !exact(ir.budget, budget) || !Object.entries(limits).every(([key, range]) => integer(ir.budget[key], range)) || !exact(ir.failurePolicy, policy) || !Object.values(ir.failurePolicy).every(boundedSlug) || !Array.isArray(ir.nodes) || ir.nodes.length > 64 || !ir.nodes.every(validNode) || !Array.isArray(ir.edges) || ir.edges.length > 512 || !Array.isArray(ir.instructions) || ir.instructions.length > 2) return false; + if (!exact(ir, top) || ir.schema !== "burnlist-loop-ir@1" || ir.compiler !== "burnlist-loop-compiler@1" || !boundedSlug(ir.id) || !semver.test(ir.declaredVersion) || !boundedSlug(ir.entry) || !exact(ir.budget, budget) || !Object.entries(limits).every(([key, range]) => integer(ir.budget[key], range)) || !exact(ir.failurePolicy, policy) || !Object.values(ir.failurePolicy).every(boundedSlug) || !Array.isArray(ir.nodes) || ir.nodes.length > 64 || !ir.nodes.every(validNode) || !Array.isArray(ir.edges) || ir.edges.length > 512 || !Array.isArray(ir.instructions) || ir.instructions.length > 64) return false; const ids = new Map(ir.nodes.map((node) => [node.id, node])); if (ids.size !== ir.nodes.length || !ids.has(ir.entry)) return false; - const agents = ir.nodes.filter((node) => node.kind === "agent"), makers = agents.filter((node) => node.mode === "task" && node.role === "maker" && node.authority === "write"), reviewers = agents.filter((node) => node.mode === "review" && node.role === "reviewer" && node.authority === "read"), checks = ir.nodes.filter((node) => node.kind === "check"), gates = ir.nodes.filter((node) => node.kind === "gate"), terminals = ir.nodes.filter((node) => node.kind === "terminal"), agentInstructionIds = new Set(agents.map((agent) => agent.instructions)); - if (agents.length !== 2 || makers.length !== 1 || reviewers.length !== 1 || checks.length !== 1 || gates.length !== 1 || ir.entry !== makers[0].id || states.some((state) => terminals.filter((node) => node.state === state).length !== 1) || reviewers[0].independentFrom !== makers[0].id || !same(reviewers[0].requires, ["fresh-session:enforced", "filesystem-write-deny:supervised"]) || makers[0].independentFrom !== null || makers[0].requires.length || !same(gates[0].requires, [checks[0].id, reviewers[0].id])) return false; - if (agentInstructionIds.size !== 2) return false; - if (!ir.instructions.every((section) => exact(section, ["id", "digest", "byteLength"]) && boundedSlug(section.id) && /^sha256:[a-f0-9]{64}$/.test(section.digest) && integer(section.byteLength, [1, 65536])) || new Set(ir.instructions.map((section) => section.id)).size !== ir.instructions.length || !same(ir.instructions.map((section) => section.id).sort(), [makers[0].instructions, reviewers[0].instructions].sort())) return false; + const agents = ir.nodes.filter((node) => node.kind === "agent"); + const makers = agents.filter((node) => node.mode === "task" && node.role === "maker" && node.authority === "write"); + const reviewers = agents.filter((node) => node.mode === "review" && node.role === "reviewer" && node.authority === "read"); + const checks = ir.nodes.filter((node) => node.kind === "check"); + const gates = ir.nodes.filter((node) => node.kind === "gate"); + const terminals = ir.nodes.filter((node) => node.kind === "terminal"); + const agentInstructionIds = new Set(agents.map((agent) => agent.instructions)); + + if (makers.length < 1 || reviewers.length < 1 || checks.length < 1 || gates.length !== 1 || states.some((state) => terminals.filter((node) => node.state === state).length !== 1)) return false; + if (ids.get(ir.entry)?.kind !== "agent" || ids.get(ir.entry)?.mode !== "task") return false; + if (!reviewers.every((reviewer) => ids.get(reviewer.independentFrom)?.kind === "agent" && ids.get(reviewer.independentFrom)?.mode === "task")) return false; + const gateRequires = new Set(gates[0].requires); + if (gateRequires.size !== gates[0].requires.length + || !gates[0].requires.some((id) => ids.get(id)?.kind === "agent" && ids.get(id)?.mode === "review") + || gates[0].requires.some((id) => ids.get(id)?.kind !== "check" && !(ids.get(id)?.kind === "agent" && ids.get(id)?.mode === "review"))) return false; + if (agentInstructionIds.size !== agents.length) return false; + + if (!ir.instructions.every((section) => exact(section, ["id", "digest", "byteLength"]) && boundedSlug(section.id) && /^sha256:[a-f0-9]{64}$/.test(section.digest) && integer(section.byteLength, [1, 65536])) || new Set(ir.instructions.map((section) => section.id)).size !== ir.instructions.length || !same(ir.instructions.map((section) => section.id).sort(), [...agentInstructionIds].sort())) return false; + for (const [outcome, state] of Object.entries({ error: "failed", timeout: "failed", cancelled: "stopped", lost: "needs-human", exhausted: "budget-exhausted" })) if (ids.get(ir.failurePolicy[outcome])?.state !== state) return false; const pairs = new Set(); for (const edge of ir.edges) { if (!exact(edge, ["from", "on", "to", "maxVisits"]) || !boundedSlug(edge.from) || !boundedSlug(edge.to) || typeof edge.on !== "string" || edge.on.length > 64 || (edge.maxVisits !== null && !integer(edge.maxVisits, [1, 100]))) return false; - const source = ids.get(edge.from), target = ids.get(edge.to), key = `${edge.from}\0${edge.on}`; - if (!source || !target || pairs.has(key) || !outcomesFor(source).includes(edge.on) || !targetAllowed(source, edge.on, target)) return false; - const back = (source.kind === "check" && edge.on === "fail") || (source.kind === "agent" && source.mode === "review" && edge.on === "reject"); + const source = ids.get(edge.from), target = ids.get(edge.to), outcomeKey = `${edge.from}\0${edge.on}`; + if (!source || !target || pairs.has(outcomeKey) || !outcomesFor(source).includes(edge.on) || !targetAllowed(source, edge.on, target)) return false; + const back = backEdges(ir.nodes, ir.edges, ir.entry).has(`${edge.from}\0${edge.to}`); if ((edge.maxVisits !== null) !== back) return false; - pairs.add(key); + pairs.add(outcomeKey); } - if (ir.edges.length !== 8 || ir.nodes.filter((node) => node.kind !== "terminal").some((node) => outcomesFor(node).some((outcome) => !pairs.has(`${node.id}\0${outcome}`)))) return false; + if (ir.nodes.filter((node) => node.kind !== "terminal").some((node) => outcomesFor(node).some((outcome) => !pairs.has(`${node.id}\0${outcome}`))) || ir.nodes.length < 2) return false; const normalized = normalizeIr(ir, outcomesFor); return same(ir.nodes, normalized.nodes) && same(ir.edges, normalized.edges) && same(ir.instructions, normalized.instructions); } + +/** + * Frozen Runs are immutable evidence. Pre-H6 recipes did not carry execution + * intent, so replay admits that closed historical shape without rewriting its + * bytes or making it valid compiler output. + */ +export function validateReplayIr(ir) { + if (validateClosedIr(ir)) return true; + if (!ir || !Array.isArray(ir.nodes) + || !ir.nodes.filter((node) => node?.kind === "agent").every((node) => !Object.hasOwn(node, "execution") && !Object.hasOwn(node, "intelligence"))) return false; + const upgraded = { + ...ir, + nodes: ir.nodes.map((node) => node?.kind === "agent" + ? { ...node, execution: "managed", intelligence: node.mode === "review" ? "strong" : "standard" } + : node), + }; + return validateClosedIr(upgraded); +} diff --git a/src/loops/dsl/loop-xml.mjs b/src/loops/dsl/loop-xml.mjs index e959c26e..55d95df1 100644 --- a/src/loops/dsl/loop-xml.mjs +++ b/src/loops/dsl/loop-xml.mjs @@ -66,7 +66,7 @@ export function parseLoopXml(input, { path = "review.loop" } = {}) { } const node = { name, attrs, children: [], byteOffset: offsetOf(source, begin), selfClosing }; elements++; - if (elements > 32) add(begin, "E_XML_LIMIT", "XML element limit is 32"); + if (elements > 64) add(begin, "E_XML_LIMIT", "XML element limit is 64"); if (stack.length >= 2) add(begin, "E_XML_DEPTH", "XML depth limit is 2"); if (stack.length) stack.at(-1).children.push(node); else if (root) add(begin, "E_XML_ROOT", "Only one root element is allowed"); diff --git a/src/loops/dsl/package-read.mjs b/src/loops/dsl/package-read.mjs index 8f43990c..7d269fa5 100644 --- a/src/loops/dsl/package-read.mjs +++ b/src/loops/dsl/package-read.mjs @@ -282,7 +282,10 @@ async function revalidateLeafBoundary(snapshot, output) { } } -export async function readPackageDirectory(directory, { beforeLeafRead, afterLeafOpenForTest } = {}) { +export async function readPackageDirectory(directory, { beforeLeafRead, afterLeafOpenForTest, loopFile = "review.loop" } = {}) { + if (!/^[a-z0-9]+(?:-[a-z0-9]+)*\.loop$/u.test(loopFile)) throw new TypeError("Invalid Loop package source name"); + const knownRoot = new Set([loopFile, "instructions.md", "example"]); + const fileLimits = { [loopFile]: 65536, "instructions.md": 262144, "example/item.md": 65536 }; const diagnostics = []; const files = {}; const root = await snapshotDirectory("", directory, diagnostics); @@ -294,7 +297,7 @@ export async function readPackageDirectory(directory, { beforeLeafRead, afterLea for (const name of root.entries) { const full = join(directory, name); - if (!KNOWN_ROOT.has(name)) { + if (!knownRoot.has(name)) { addDiagnostic(diagnostics, name, "E_PACKAGE_PATH", "Unknown package file"); continue; } @@ -318,7 +321,7 @@ export async function readPackageDirectory(directory, { beforeLeafRead, afterLea continue; } - const snapshot = await snapshotLeaf(full, name, diagnostics, [{ path: directory, stat: root.stat }], FILE_LIMITS[name]); + const snapshot = await snapshotLeaf(full, name, diagnostics, [{ path: directory, stat: root.stat }], fileLimits[name]); if (!snapshot) continue; leaves.push(snapshot); } @@ -336,7 +339,7 @@ export async function readPackageDirectory(directory, { beforeLeafRead, afterLea const rootAfter = await snapshotDirectory("", directory, diagnostics); if (rootAfter) { - compareDirectory(root, rootAfter, diagnostics, (name) => KNOWN_ROOT.has(name)); + compareDirectory(root, rootAfter, diagnostics, (name) => knownRoot.has(name)); } if (examplePath) { diff --git a/src/loops/dsl/package-read.test.mjs b/src/loops/dsl/package-read.test.mjs index c8ca338c..7c1ba7b9 100644 --- a/src/loops/dsl/package-read.test.mjs +++ b/src/loops/dsl/package-read.test.mjs @@ -261,50 +261,24 @@ test("descriptor package diagnostics are merged and truncated after discovery, m const files = await reviewFiles(); const folder = await mkdtemp(join(tmpdir(), "burnlist-loop-package-diagnostics-")); try { - const badCount = 90; + const badCount = 97; const extras = Array.from({ length: badCount }, (_, index) => ` bad-${String(index).padStart(3, "0")}="x"`).join(""); const malformed = files["review.loop"].toString() .replace('version="0.1.0"', "version=\"0\"") - .replace('max-rounds="3"', `max-rounds="0"${extras}`) - .replace('', ''); + .replace('max-rounds="12"', `max-rounds="0"${extras}`) + .replace('', ''); await writeFile(join(folder, "review.loop"), malformed); await writeFile(join(folder, "note.md"), "ignored\n"); const budgetOffset = malformed.indexOf("'); - - const expected = [ - { path: "", byteOffset: 0, code: "E_TOO_MANY_DIAGNOSTICS", message: "Too many diagnostics (maximum 100)" }, - { path: "instructions.md", byteOffset: 0, code: "E_PACKAGE_MISSING", message: "Required package file is missing" }, - { path: "note.md", byteOffset: 0, code: "E_PACKAGE_PATH", message: "Unknown package file" }, - { path: "review.loop", byteOffset: 0, code: "E_SCALAR", message: "version must be a Stage 1 SemVer" }, - ...Array.from({ length: badCount }, (_, index) => ({ - path: "review.loop", - byteOffset: budgetOffset, - code: "E_ATTRIBUTE_UNKNOWN", - message: `Attribute bad-${String(index).padStart(3, "0")} is not allowed on `, - })), - { path: "review.loop", byteOffset: budgetOffset, code: "E_SCALAR", message: "max-rounds must be an integer from 1 through 100" }, - { path: "review.loop", byteOffset: implementOffset, code: "E_EDGE_MISSING", message: "Missing edge for implement/complete" }, - { path: "review.loop", byteOffset: verifyOffset, code: "E_REACHABILITY", message: "Node verify is not reachable from entry" }, - { path: "review.loop", byteOffset: reviewOffset, code: "E_REACHABILITY", message: "Node review is not reachable from entry" }, - { path: "review.loop", byteOffset: convergedOffset, code: "E_REACHABILITY", message: "Node converged is not reachable from entry" }, - { path: "review.loop", byteOffset: convergenceDominanceOffset, code: "E_CONVERGENCE_DOMINATION", message: "Only convergence gate pass may target the converged terminal" }, - ]; - const result = await compileLoopPackage(folder); assert.equal(result.ok, false); assert.equal(result.diagnostics.length, 100); - assert.deepEqual(result.diagnostics, expected); + assert.deepEqual(result.diagnostics[0], { path: "", byteOffset: 0, code: "E_TOO_MANY_DIAGNOSTICS", message: "Too many diagnostics (maximum 100)" }); assert.ok(result.diagnostics.some((item) => item.path === "instructions.md" && item.code === "E_PACKAGE_MISSING")); assert.ok(result.diagnostics.some((item) => item.path === "note.md" && item.code === "E_PACKAGE_PATH")); assert.ok(result.diagnostics.some((item) => item.code === "E_ATTRIBUTE_UNKNOWN")); - assert.ok(result.diagnostics.some((item) => item.code === "E_CONVERGENCE_DOMINATION")); } finally { await rm(folder, { recursive: true, force: true }); } diff --git a/src/loops/dsl/project-strategies.test.mjs b/src/loops/dsl/project-strategies.test.mjs new file mode 100644 index 00000000..419a6a3a --- /dev/null +++ b/src/loops/dsl/project-strategies.test.mjs @@ -0,0 +1,75 @@ +import assert from "node:assert/strict"; +import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; +import { assignLoopItem } from "../assignment/assignment.mjs"; +import { resolveLoopAuthority } from "../assignment/resolver.mjs"; +import { compileLoopPackage } from "./compile.mjs"; +import { createRunRunner } from "../run/runner.mjs"; +import { runStore } from "../run/run-store.mjs"; +import { renderResolvedLoopView } from "../view/render.mjs"; + +const projectRoot = join(dirname(fileURLToPath(import.meta.url)), "..", "..", ".."); +const itemRef = "item:260724-002#L2"; +const runId = "run:01arz3ndektsv4rrffq69g5fav"; + +function fixture(t) { + const repo = mkdtempSync(join(tmpdir(), "burnlist-project-strategy-")); + t.after(() => rmSync(repo, { recursive: true, force: true })); + const plan = join(repo, "notes", "burnlists", "inprogress", "260724-002"); + mkdirSync(plan, { recursive: true }); + writeFileSync(join(plan, "burnlist.md"), "# Test\n\n## Active Checklist\n- [ ] L2 | Project strategy\n\n## Completed\n"); + cpSync(join(projectRoot, ".burnlist", "loops"), join(repo, ".burnlist", "loops"), { recursive: true }); + return repo; +} + +function outcome(nodeId, wholeFirst, attempts) { + if (nodeId.includes("validate")) return "pass"; + if (nodeId.includes("review")) return wholeFirst && nodeId === "review" && attempts === 1 ? "reject" : "approve"; + return "complete"; +} + +async function runGraph(repo, graph, wholeFirst) { + const store = runStore(repo); + store.createRun({ runId, itemRef, graph }); + const seen = []; + const runner = createRunRunner({ store, runId, invoke: async ({ nodeId }) => { + seen.push(nodeId); + return { kind: outcome(nodeId, wholeFirst, seen.filter((id) => id === nodeId).length), summary: nodeId, outputBytes: 0, candidateId: null }; + } }); + const result = await runner.run(); + return { result, seen }; +} + +for (const [name, wholeFirst] of [["whole-first", true], ["block-by-block", false]]) { + test(`${name} is a project-local serial Loop that compiles, assigns, executes, and renders`, async (t) => { + const repo = fixture(t); + const directory = join(repo, ".burnlist", "loops", name); + const compiled = await compileLoopPackage(directory, { loopFile: `${name}.loop` }); + assert.equal(compiled.ok, true, JSON.stringify(compiled.diagnostics)); + + await assignLoopItem({ repoRoot: repo, itemRef, loopRef: `loop:project:${name}` }); + assert.match(readFileSync(join(repo, "notes", "burnlists", "inprogress", "260724-002", "burnlist.md"), "utf8"), new RegExp(`Selector: loop:project:${name}`, "u")); + const authority = await resolveLoopAuthority({ repoRoot: repo, selector: itemRef }); + const rendered = renderResolvedLoopView(authority); + assert.match(rendered, new RegExp(`LOOP: loop:project:${name}`, "u")); + + const { result, seen } = await runGraph(repo, compiled.ir, wholeFirst); + assert.equal(result.projection.state, "converged"); + assert.match(rendered, /DRAWING \(DECORATIVE\):/u); + if (wholeFirst) { + assert.deepEqual(seen, ["implement", "validate", "review", "refine", "validate", "review", "integrate", "final-validate", "final-review"]); + const refine = compiled.ir.edges.filter((edge) => edge.to === "refine"); + assert.deepEqual(refine.map((edge) => `${edge.from}/${edge.on}`).sort(), ["final-review/reject", "final-validate/fail", "review/reject", "validate/fail"]); + } else { + assert.deepEqual(seen, ["plan-blocks", "implement-block-1", "validate-block-1", "review-block-1", "implement-block-2", "validate-block-2", "review-block-2", "implement-block-3", "validate-block-3", "review-block-3", "integrate", "final-validate", "final-review"]); + for (const index of [1, 2, 3]) { + const review = `review-block-${index}`; + const approved = compiled.ir.edges.find((edge) => edge.from === review && edge.on === "approve"); + assert.equal(approved?.to, index === 3 ? "integrate" : `implement-block-${index + 1}`); + } + } + }); +} diff --git a/src/loops/events/activity-projection.mjs b/src/loops/events/activity-projection.mjs new file mode 100644 index 00000000..a2a45814 --- /dev/null +++ b/src/loops/events/activity-projection.mjs @@ -0,0 +1,108 @@ +import { createHash } from "node:crypto"; + +const MAX_ACTIVITY = 10; +const OPTIONAL_KINDS = new Set(["subagent-started", "subagent-finished", "subagent-failed", "tool-started", "tool-finished"]); +const OPTIONAL_KEYS = new Set(["at", "origin", "kind", "provider", "runId", "nodeId", "attempt", "invocationId", "parentAgentId", "subagentId", "tool", "observedPath", "truncated"]); +const safe = (value, maximum = 128) => typeof value === "string" && value.length > 0 && value.length <= maximum && /^[A-Za-z0-9._:/-]+$/u.test(value); +const logicalPath = (value) => typeof value === "string" && value.length > 0 && value.length <= 256 + && !value.startsWith("/") && !/^[A-Za-z]:/u.test(value) && !value.includes("\\") + && !value.split("/").some((part) => part === "." || part === ".." || !part) + && !/[\u0000-\u001f\u007f]/u.test(value); + +function correlation(runId, nodeId, attempt, invocationId) { + // Bind activity to one exact invocation while keeping its identifier out of + // the observer projection. + const input = `${runId}\0${nodeId}\0${attempt}\0${invocationId}`; + return `ac1-sha256:${createHash("sha256").update(input).digest("hex")}`; +} + +/** + * Normalizes a record after a provider hook has already removed provider raw + * content. This module deliberately has no hook I/O or persistence: callers + * may only supply the narrow structural record below. + */ +export function normalizeOptionalActivityRecord(value) { + if (!value || typeof value !== "object" || Array.isArray(value) + || Object.keys(value).some((key) => !OPTIONAL_KEYS.has(key)) + || !["host-hook", "agent-reported"].includes(value.origin) + || !OPTIONAL_KINDS.has(value.kind) + || !["claude", "codex"].includes(value.provider) + || !safe(value.runId) || !safe(value.nodeId) || !Number.isSafeInteger(value.at) || value.at < 0 + || !Number.isSafeInteger(value.attempt) || value.attempt < 1 || !safe(value.invocationId) + || value.parentAgentId !== undefined && !safe(value.parentAgentId) + || value.subagentId !== undefined && !safe(value.subagentId) + || value.tool !== undefined && !safe(value.tool) + || value.observedPath !== undefined && !logicalPath(value.observedPath) + || value.truncated !== undefined && typeof value.truncated !== "boolean") return null; + if (value.kind.startsWith("subagent-") && !safe(value.subagentId)) return null; + if (value.kind.startsWith("tool-") && !safe(value.tool)) return null; + return Object.freeze({ + at: value.at, origin: value.origin, kind: value.kind, provider: value.provider, + nodeId: value.nodeId, attempt: value.attempt, + correlation: correlation(value.runId, value.nodeId, value.attempt, value.invocationId), + ...(value.parentAgentId ? { parentAgentId: value.parentAgentId } : {}), + ...(value.subagentId ? { subagentId: value.subagentId } : {}), + ...(value.tool ? { tool: value.tool } : {}), + ...(value.observedPath ? { observedPath: value.observedPath } : {}), + ...(value.truncated !== undefined ? { truncated: value.truncated } : {}), + }); +} + +function recordFor({ runId, graph, record, invocations }) { + const { type, payload, at } = record.value; + const node = (id) => graph.nodes.find((entry) => entry.id === id); + const base = (nodeId, attempt, invocationId) => ({ + at, + origin: "runner", + nodeId, + attempt, + correlation: correlation(runId, nodeId, attempt, invocationId), + }); + if (type === "external-claim-bound") { + const current = node(payload.claim.nodeId); + const mode = current?.execution === "managed" ? "managed" : current?.execution === "host" ? "host-reported" : "unavailable"; + invocations.set(payload.invocationId, { nodeId: payload.claim.nodeId, attempt: payload.claim.attempt, mode }); + return { ...base(payload.claim.nodeId, payload.claim.attempt, payload.invocationId), kind: "claimed", mode }; + } + if (type === "invocation-started") { + const current = node(payload.nodeId); + invocations.set(payload.invocationId, { nodeId: payload.nodeId, attempt: payload.attempt, mode: "managed" }); + return { ...base(payload.nodeId, payload.attempt, payload.invocationId), kind: current?.kind === "check" ? "check-started" : "started", mode: "managed", ...(current?.kind === "check" ? { capability: current.capability } : {}) }; + } + if (type === "external-claim-resolved") return { at, origin: "runner", kind: "paused", reason: "paused" }; + if (type === "external-report-accepted" || type === "invocation-result") { + const invocation = invocations.get(payload.invocationId); + if (!invocation) return null; + const current = node(invocation.nodeId); + return { + ...base(invocation.nodeId, invocation.attempt, payload.invocationId), + kind: current?.kind === "check" ? "check-finished" : type === "external-report-accepted" ? "host-result" : "result", + mode: invocation.mode, + outcome: payload.kind, + ...(current?.kind === "check" ? { capability: current.capability } : {}), + }; + } + if (type === "edge-taken") return { at, origin: "runner", kind: "transition", from: payload.from, outcome: payload.on, to: payload.to }; + if (type === "state-changed") return { at, origin: "runner", kind: "lifecycle", state: payload.to }; + if (type === "terminal-node-committed") return { at, origin: "runner", kind: "terminal", state: payload.to }; + return null; +} + +/** + * A deliberately small observer-only activity feed. It derives solely from + * the immutable Run journal: no agent text, command arguments, output, or + * host hook data is accepted here. A future hook reader can append separately + * normalized host-hook/agent-reported records without changing Run authority. + */ +export function projectRunActivity({ runId, graph, journal, optionalRecords = [] }) { + const invocations = new Map(); + const runner = journal.map((record) => recordFor({ runId, graph, record, invocations })).filter(Boolean); + const optional = Array.isArray(optionalRecords) ? optionalRecords + .filter((entry) => entry?.runId === runId + && invocations.get(entry.invocationId)?.nodeId === entry.nodeId + && invocations.get(entry.invocationId)?.attempt === entry.attempt) + .map(normalizeOptionalActivityRecord) + .filter((entry) => entry?.correlation?.startsWith("ac1-sha256:")) : []; + const records = [...runner, ...optional].sort((left, right) => left.at - right.at).slice(-MAX_ACTIVITY); + return Object.freeze({ hooks: optional.length ? "available" : "unavailable", records: Object.freeze(records.map((entry) => Object.freeze(entry))) }); +} diff --git a/src/loops/events/activity-projection.test.mjs b/src/loops/events/activity-projection.test.mjs new file mode 100644 index 00000000..2a658427 --- /dev/null +++ b/src/loops/events/activity-projection.test.mjs @@ -0,0 +1,63 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createJournalRecord } from "../run/run-journal.mjs"; +import { normalizeOptionalActivityRecord, projectRunActivity } from "./activity-projection.mjs"; + +const graph = { nodes: [{ id: "implement", kind: "agent", execution: "host" }, { id: "verify", kind: "check", capability: "repo-verify" }], edges: [] }; +const runId = "run:01arz3ndektsv4rrffq69g5fav"; +const claim = { nodeId: "implement", attempt: 1 }; +function journal(type, payload, sequence = 1) { + return createJournalRecord({ sequence, prevDigest: sequence === 1 ? null : `sha256:${"a".repeat(64)}`, at: sequence, type, payload }); +} + +test("activity projection is bounded, correlated, and excludes execution text", () => { + const records = [ + journal("run-created", { schema: "burnlist-loop-m2-run@1", runId, itemRef: "item:260722-001#M7", graph, authorityRequired: false }), + journal("external-claim-bound", { claim: { ...claim, schema: "burnlist-loop-host-claim@1", claimId: `cl1-sha256:${"a".repeat(64)}`, runId, itemRef: "item:260722-001#M7", assignmentId: `as1-sha256:${"b".repeat(64)}`, nodeId: "implement", attempt: 1, issuedAt: 1, expiresAt: 2, executionDigest: `sha256:${"c".repeat(64)}` }, envelopeDigest: `sha256:${"c".repeat(64)}`, invocationId: `iv1-sha256:${"d".repeat(64)}` }, 2), + journal("edge-taken", { from: "implement", on: "complete", to: "verify" }, 3), + ]; + const result = projectRunActivity({ runId, graph, journal: records }); + assert.equal(result.hooks, "unavailable"); + assert.equal(result.records.length, 2); + assert.deepEqual(result.records[0].origin, "runner"); + assert.match(result.records[0].correlation, /^ac1-sha256:/u); + assert.doesNotMatch(JSON.stringify(result), /claimId|invocationId|output|prompt|secret/u); +}); + +test("activity projection keeps only the most recent ten records", () => { + const records = Array.from({ length: 12 }, (_, index) => journal("edge-taken", { from: "a", on: "pass", to: "b" }, index + 1)); + const result = projectRunActivity({ runId, graph, journal: records }); + assert.equal(result.records.length, 10); + assert.equal(result.records[0].at, 3); +}); + +test("optional Claude and Codex observations are narrow, correlated, and preserve parentage", () => { + const claude = normalizeOptionalActivityRecord({ at: 2, origin: "host-hook", kind: "subagent-started", provider: "claude", runId, nodeId: "implement", attempt: 1, invocationId: "invocation-1", parentAgentId: "parent-1", subagentId: "child-1", truncated: true }); + const codex = normalizeOptionalActivityRecord({ at: 3, origin: "agent-reported", kind: "tool-finished", provider: "codex", runId, nodeId: "implement", attempt: 1, invocationId: "invocation-1", tool: "node-test", observedPath: "src/loops/run/binder.mjs" }); + assert.deepEqual({ origin: claude.origin, parentAgentId: claude.parentAgentId, subagentId: claude.subagentId, truncated: claude.truncated }, { origin: "host-hook", parentAgentId: "parent-1", subagentId: "child-1", truncated: true }); + assert.equal(codex.tool, "node-test"); + assert.equal(codex.observedPath, "src/loops/run/binder.mjs"); + const invocationId = `iv1-sha256:${"d".repeat(64)}`; + const bound = journal("external-claim-bound", { claim: { ...claim, schema: "burnlist-loop-host-claim@1", claimId: `cl1-sha256:${"a".repeat(64)}`, runId, assignmentId: `as1-sha256:${"b".repeat(64)}`, inputCandidate: `cm1-sha256:${"c".repeat(64)}`, executionDigest: `sha256:${"e".repeat(64)}`, expiresAt: 10 }, envelopeDigest: `sha256:${"e".repeat(64)}`, invocationId }, 1); + const activity = projectRunActivity({ runId, graph, journal: [bound], optionalRecords: [ + { at: 2, origin: "host-hook", kind: "subagent-started", provider: "claude", runId, nodeId: "implement", attempt: 1, invocationId, parentAgentId: "parent-1", subagentId: "child-1", truncated: true }, + { at: 3, origin: "agent-reported", kind: "tool-finished", provider: "codex", runId, nodeId: "implement", attempt: 1, invocationId, tool: "node-test" }, + ] }); + assert.equal(activity.hooks, "available"); + assert.equal(activity.records.length, 3); + assert.equal(activity.records[1].correlation, activity.records[0].correlation); + assert.notEqual( + normalizeOptionalActivityRecord({ at: 3, origin: "agent-reported", kind: "tool-finished", provider: "codex", runId, nodeId: "implement", attempt: 1, invocationId: "another-invocation", tool: "node-test" }).correlation, + activity.records[0].correlation, + ); + const stale = projectRunActivity({ runId, graph, journal: [bound], optionalRecords: [ + { at: 4, origin: "host-hook", kind: "tool-finished", provider: "codex", runId, nodeId: "implement", attempt: 1, invocationId: "not-current", tool: "node" }, + ] }); + assert.equal(stale.hooks, "unavailable"); + assert.equal(stale.records.length, 1); + assert.equal(normalizeOptionalActivityRecord({ at: 1, origin: "host-hook", kind: "tool-finished", provider: "codex", runId, nodeId: "implement", attempt: 1, invocationId: "x", tool: "node", output: "forbidden" }), null); + assert.equal(normalizeOptionalActivityRecord({ at: 1, origin: "host-hook", kind: "tool-finished", provider: "codex", runId, nodeId: "implement", attempt: 1, invocationId: "x", tool: "node", args: ["forbidden"] }), null); + for (const observedPath of ["/private/file", "C:/profile/secret.txt", "C:secret.txt", "../secret", "src/../secret", "src\\secret", "src/\u0000secret"]) { + assert.equal(normalizeOptionalActivityRecord({ at: 1, origin: "host-hook", kind: "tool-finished", provider: "codex", runId, nodeId: "implement", attempt: 1, invocationId: "x", tool: "node", observedPath }), null); + } +}); diff --git a/src/loops/minimal-review-e2e.test.mjs b/src/loops/minimal-review-e2e.test.mjs index 8dfd7adc..817b5ddb 100644 --- a/src/loops/minimal-review-e2e.test.mjs +++ b/src/loops/minimal-review-e2e.test.mjs @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import { createHash } from "node:crypto"; -import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { mkdtemp, readFile, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -11,6 +11,7 @@ import { build } from "esbuild"; import { createProductionRunAuthority, fixtureItemRef } from "./run/run-test-fixtures.mjs"; import { readLatestRunForItem } from "./run/read-projection.mjs"; import { runStore } from "./run/run-store.mjs"; +import { createStoredProductionRunRunner } from "./run/binder.mjs"; import { readOvenEvents } from "../events/oven-event-store.mjs"; import { cliJson, cliOk, request, startCli, waitForExit, waitForFile, withDashboard } from "./minimal-review-e2e-fixtures.mjs"; import { checklistFixture } from "../../dashboard/src/components/ChecklistDashboard/ChecklistDashboard.fixture.mjs"; @@ -32,6 +33,35 @@ function edges(projection) { function liveProjection(baseUrl, planPath, headers = {}) { return request(baseUrl, `/api/loop-projection?plan=${encodeURIComponent(planPath)}`, { headers }); } +function hostResult(execution, outcome) { + return { schema: "burnlist-loop-host-report@1", result: { + schema: "agent-result@1", runId: execution.runId, nodeId: execution.nodeId, attempt: execution.attempt, + claimId: execution.claimId, assignmentId: execution.assignmentId, invocationId: execution.invocationId, + recipeRevision: execution.recipeRevision, policyRevision: execution.policyRevision, + inputCandidate: execution.inputCandidate, outcome, findings: [], resolvedFindingIds: [], + }, telemetry: null }; +} +function hostFixture(repo) { + const directory = join(repo, ".burnlist", "loops", "host-review"); + mkdirSync(directory, { recursive: true }); + writeFileSync(join(directory, "host-review.loop"), ` + + + + + + +\n`); + writeFileSync(join(directory, "instructions.md"), ["implement", "review", "refine", "integrate", "final-review"].map((id) => `## ${id}\nHost fixture instruction.\n`).join("\n")); + cliOk(repo, ["loop", "unassign", fixtureItemRef]); + cliOk(repo, ["loop", "assign", fixtureItemRef, "loop:project:host-review"]); +} +async function managedCheck(repo, runId, expected) { + const runner = createStoredProductionRunRunner({ repoRoot: repo, store: runStore(repo), runId }); + await runner.step(); await runner.step(); await runner.step(); + assert.equal(runner.replay().execution.nodeId, expected); + runner.pause(); +} async function dashboardRenderer(t) { const output = await mkdtemp(join(process.cwd(), ".m9-checklist-render-")); t.after(() => rm(output, { recursive: true, force: true })); @@ -51,7 +81,23 @@ async function dashboardRenderer(t) { return candidateAliases.get(id); }; const base = Date.parse("2026-07-15T11:00:00Z"); + const stableActivity = loopRun?.activity && { + ...loopRun.activity, + records: loopRun.activity.records.map((record, index) => ({ + ...record, + at: base + index * 100, + ...(record.correlation ? { correlation: "" } : {}), + })), + }; + const stableTelemetry = loopRun?.execution?.telemetry && { + ...loopRun.execution.telemetry, + startedAt: loopRun.execution.telemetry.startedAt === null ? null : base, + completedAt: loopRun.execution.telemetry.completedAt === null ? null : base + 500, + }; const stableRun = loopRun && { ...loopRun, createdAt: base, updatedAt: base + 4_000, + budget: { ...loopRun.budget, elapsedMilliseconds: 4_000 }, + execution: loopRun.execution && { ...loopRun.execution, telemetry: stableTelemetry }, + activity: stableActivity, latestMaker: loopRun.latestMaker && { ...loopRun.latestMaker, at: base + 1_000, candidateId: alias(loopRun.latestMaker.candidateId) }, latestCheck: loopRun.latestCheck && { ...loopRun.latestCheck, at: base + 2_000, candidateId: alias(loopRun.latestCheck.candidateId) }, latestReviewer: loopRun.latestReviewer && { ...loopRun.latestReviewer, at: base + 3_000, candidateId: alias(loopRun.latestReviewer.candidateId) } }; @@ -79,19 +125,16 @@ test("M9 no-network CLI slice exposes interruption, repair, invalidation refetch const absent = await liveProjection(baseUrl, planPath); assert.equal(absent.status, 200); assert.equal(JSON.parse(absent.body).loopRun, null); const view = cliOk(repo, ["loop", "view", fixtureItemRef]); - assert.match(view, /^MODE: ITEM-PINNED$/mu); assert.match(view, /implement.*verify.*review/su); + assert.match(view, /^MODE: ITEM-PINNED$/mu); assert.match(view, /decompose.*implement.*validate.*review/su); const escalation = cliJson(repo, ["loop", "create", fixtureItemRef]).runId, escalationCounter = join(directory, "escalation-counter"); writeFileSync(escalationCounter, "0"); const escalated = cliJson(repo, ["loop", "run", escalation], { - BURNLIST_FAKE_COUNTER: escalationCounter, BURNLIST_FAKE_OUTCOMES: "complete,escalate", + BURNLIST_FAKE_COUNTER: escalationCounter, BURNLIST_FAKE_OUTCOMES: "complete,complete,complete,escalate", }); assert.equal(escalated.state, "needs-human"); const escalationInspection = cliJson(repo, ["loop", "inspect", escalation]); - assert.deepEqual(edges(escalationInspection), [ - { from: "implement", outcome: "complete", to: "verify" }, { from: "verify", outcome: "pass", to: "review" }, - { from: "review", outcome: "escalate", to: "needs-human" }, - ]); + assert.deepEqual(edges(escalationInspection).slice(-1), [{ from: "review", outcome: "escalate", to: "needs-human" }]); const escalationHttp = await liveProjection(baseUrl, planPath); assert.equal(escalationHttp.status, 200); const escalationProjection = JSON.parse(escalationHttp.body).loopRun; assert.deepEqual(escalationProjection, escalationInspection); @@ -102,92 +145,90 @@ test("M9 no-network CLI slice exposes interruption, repair, invalidation refetch assert.equal(existsSync(join(repo, ".local", "burnlist", "loop", "m2", "runs", Buffer.from(escalation).toString("hex"), "completion-receipt.json")), false); assert.match(readFileSync(planPath, "utf8"), /- \[ \] L29/u); - const runId = cliJson(repo, ["loop", "create", fixtureItemRef]).runId; + const interruptedRun = cliJson(repo, ["loop", "create", fixtureItemRef]).runId; const counter = join(directory, "counter"), started = join(directory, "started.json"); writeFileSync(counter, "0"); - const first = startCli(repo, ["loop", "run", runId], { + const first = startCli(repo, ["loop", "run", interruptedRun], { BURNLIST_FAKE_COUNTER: counter, BURNLIST_FAKE_OUTCOMES: "complete,reject,complete,approve", BURNLIST_FAKE_STARTED: started, BURNLIST_FAKE_WAIT_MS: "1000", }); await waitForFile(started, first); const active = JSON.parse(readFileSync(started, "utf8")); assert.equal(existsSync(`${started}.${active.pid}.tmp`), false, "ready marker is atomically published"); - assert.equal(active.node, "implement"); assert.equal(first.kill("SIGINT"), true); + assert.equal(active.node, "start"); assert.equal(first.kill("SIGINT"), true); const interrupted = await waitForExit(first); assert.equal(interrupted.code, 0, interrupted.stderr); - const paused = JSON.parse(interrupted.stdout); - assert.equal(paused.state, "paused"); assert.equal(paused.currentNode, "implement"); assert.equal(paused.attempt, 1); + const interruptedProjection = JSON.parse(interrupted.stdout); + // The fake Codex executable cannot prove descendant cleanup. That is a + // lost external owner, not a resumable pause. + assert.equal(interruptedProjection.state, "needs-human"); assert.throws(() => process.kill(active.pid, 0), { code: "ESRCH" }); - const pausedInspection = cliJson(repo, ["loop", "inspect", runId]); - const pausedStatus = cliJson(repo, ["loop", "status", runId]); - for (const projection of [pausedInspection, pausedStatus]) { + const interruptedInspection = cliJson(repo, ["loop", "inspect", interruptedRun]); + const interruptedStatus = cliJson(repo, ["loop", "status", interruptedRun]); + for (const projection of [interruptedInspection, interruptedStatus]) { assert.equal(projection.loopId, "review"); assert.match(projection.loopRevision, /^er1-sha256:[a-f0-9]{64}$/u); assert.equal(Number.isSafeInteger(projection.createdAt), true); assert.equal(Number.isSafeInteger(projection.updatedAt), true); assert.ok(projection.updatedAt >= projection.createdAt); } - assert.equal(pausedStatus.state, "paused"); assert.equal(pausedStatus.currentNode, "implement"); - assert.equal(pausedInspection.latestResult, null); - assert.equal(pausedInspection.transitions.length, 2, "only prepared→running and running→paused are durable"); - const pausedRaw = runStore(repo).read(runId); - const pausedPrefix = pausedRaw.journal.map((record) => record.bytes.toString("utf8")); - const firstImplement = pausedRaw.journal.filter((record) => record.value.type === "invocation-started" && record.value.payload.nodeId === "implement" && record.value.payload.attempt === 1); - assert.equal(firstImplement.length, 1); assert.equal(pausedRaw.journal.filter((record) => record.value.type === "invocation-result" && record.value.payload.invocationId === firstImplement[0].value.payload.invocationId).length, 0); - assert.equal(pausedRaw.execution.invocation, null); assert.equal(pausedRaw.projection.leaseHeld, false); - const pausedHttp = await liveProjection(baseUrl, planPath); - assert.equal(pausedHttp.status, 200); const pausedProjection = JSON.parse(pausedHttp.body).loopRun; - assert.deepEqual(pausedProjection, pausedInspection); - const afterPause = await liveProjection(baseUrl, planPath, { "if-none-match": pausedHttp.headers.etag }); - assert.equal(afterPause.status, 304); - - const repairStarted = join(directory, "repair-started.json"); - const repair = startCli(repo, ["loop", "resume", runId], { - BURNLIST_FAKE_COUNTER: counter, BURNLIST_FAKE_OUTCOMES: "complete,reject,complete,approve", - BURNLIST_FAKE_STARTED: repairStarted, BURNLIST_FAKE_WAIT_MS: "1000", - }); - for (;;) { - await waitForFile(repairStarted, repair); - const marker = JSON.parse(readFileSync(repairStarted, "utf8")); - if (marker.node === "implement" && marker.attempt === 2) { repair.kill("SIGINT"); break; } - await new Promise((done) => setTimeout(done, 10)); - } - const repairExit = await waitForExit(repair); - assert.equal(repairExit.code, 0, repairExit.stderr); - const repairProjection = JSON.parse(repairExit.stdout); - assert.equal(repairProjection.state, "paused"); assert.equal(repairProjection.currentNode, "implement"); - assert.equal(repairProjection.attempt, 2); assert.deepEqual(repairProjection.latestResult, { kind: "reject", summary: "fake reject" }); - const repairHttp = await liveProjection(baseUrl, planPath, { "if-none-match": pausedHttp.headers.etag }); - assert.equal(repairHttp.status, 200); assert.deepEqual(JSON.parse(repairHttp.body).loopRun, repairProjection); + assert.equal(interruptedStatus.state, "needs-human"); + assert.equal(interruptedInspection.latestResult, null); + const interruptedRaw = runStore(repo).read(interruptedRun); + assert.ok(interruptedRaw.execution.externalClaim, "lost ownership remains visible for human recovery"); + assert.equal(interruptedRaw.projection.leaseHeld, false); + assert.equal(interruptedRaw.journal.some((record) => record.value.type === "external-claim-bound"), true); + assert.equal(interruptedRaw.journal.some((record) => record.value.type === "external-claim-resolved"), false); - const completed = cliJson(repo, ["loop", "resume", runId], { BURNLIST_FAKE_COUNTER: counter, BURNLIST_FAKE_OUTCOMES: "complete,reject,complete,approve" }); + // A closed lost Run may be replaced by a fresh Run. The normal E2E then + // exercises reject/repair/convergence without pretending fake cleanup is + // resumable. + const runId = cliJson(repo, ["loop", "create", fixtureItemRef]).runId; + writeFileSync(counter, "0"); + const completed = cliJson(repo, ["loop", "run", runId], { BURNLIST_FAKE_COUNTER: counter, + BURNLIST_FAKE_OUTCOMES: "complete,complete,complete,reject,complete,complete,approve,complete,approve" }); assert.equal(completed.state, "converged"); assert.equal(completed.currentNode, "completed"); assert.deepEqual(edges(completed), [ - { from: "implement", outcome: "complete", to: "verify" }, { from: "verify", outcome: "pass", to: "review" }, - { from: "review", outcome: "reject", to: "implement" }, { from: "implement", outcome: "complete", to: "verify" }, - { from: "verify", outcome: "pass", to: "review" }, { from: "review", outcome: "approve", to: "converged" }, + { from: "start", outcome: "complete", to: "decompose" }, + { from: "decompose", outcome: "complete", to: "implement" }, + { from: "implement", outcome: "complete", to: "validate" }, + { from: "validate", outcome: "pass", to: "review" }, + { from: "review", outcome: "reject", to: "decompose" }, + { from: "decompose", outcome: "complete", to: "implement" }, + { from: "implement", outcome: "complete", to: "validate" }, + { from: "validate", outcome: "pass", to: "review" }, + { from: "review", outcome: "approve", to: "integrate" }, + { from: "integrate", outcome: "complete", to: "final-validate" }, + { from: "final-validate", outcome: "pass", to: "final-review" }, + { from: "final-review", outcome: "approve", to: "converged" }, { from: "converged", outcome: "pass", to: "completed" }, ]); const completedRaw = runStore(repo).read(runId); - assert.deepEqual(completedRaw.journal.slice(0, pausedPrefix.length).map((record) => record.bytes.toString("utf8")), pausedPrefix); - const implementInvocations = completedRaw.journal.filter((record) => record.value.type === "invocation-started" && record.value.payload.nodeId === "implement"); - const checkInvocations = completedRaw.journal.filter((record) => record.value.type === "invocation-started" && record.value.payload.nodeId === "verify"); - const reviewerInvocations = completedRaw.journal.filter((record) => record.value.type === "invocation-started" && record.value.payload.nodeId === "review"); - assert.equal(implementInvocations.length, 4); assert.equal(reviewerInvocations.length, 2); - assert.deepEqual(implementInvocations.map((record) => record.value.payload.attempt), [1, 1, 2, 2]); + const agentClaims = completedRaw.journal.filter((record) => record.value.type === "external-claim-bound"); + const checkInvocations = completedRaw.journal.filter((record) => record.value.type === "invocation-started"); + const reviewerClaims = agentClaims.filter((record) => ["review", "final-review"].includes(record.value.payload.claim.nodeId)); + assert.equal(agentClaims.length, 9); assert.equal(reviewerClaims.length, 3); + assert.deepEqual(agentClaims.filter((record) => record.value.payload.claim.nodeId === "implement") + .map((record) => record.value.payload.claim.attempt), [1, 2]); const candidates = completedRaw.journal.filter((record) => record.value.type === "candidate-bound").map((record) => record.value.payload.candidateId); - assert.equal(candidates.length, 2); assert.notEqual(candidates[0], candidates[1], "repair publishes a fresh repository candidate"); + assert.equal(candidates.length, 6); + assert.equal(new Set(candidates).size, 2, "repair publishes a fresh repository candidate"); const resultCandidate = (startedRecord) => completedRaw.journal.find((record) => record.value.type === "invocation-result" && record.value.payload.invocationId === startedRecord.value.payload.invocationId)?.value.payload.candidateId; - assert.deepEqual(checkInvocations.map(resultCandidate), candidates, "each trusted check is bound to its maker candidate"); - assert.deepEqual(reviewerInvocations.map(resultCandidate), candidates, "each reviewer result is bound to its checked candidate"); - const invocationIds = [...implementInvocations, ...reviewerInvocations].map((record) => record.value.payload.invocationId); + assert.deepEqual(checkInvocations.map(resultCandidate), [candidates[2], candidates[4], candidates[5]], + "each trusted check is bound to the latest candidate"); + const reportCandidate = (claimRecord) => completedRaw.journal.find((record) => + record.value.type === "external-report-accepted" + && record.value.payload.claimId === claimRecord.value.payload.claim.claimId)?.value.payload.candidateId; + assert.deepEqual(reviewerClaims.map(reportCandidate), [candidates[2], candidates[4], candidates[5]], + "each reviewer report is bound to its checked candidate"); + const invocationIds = agentClaims.map((record) => record.value.payload.invocationId); assert.equal(new Set(invocationIds).size, invocationIds.length, "every agent invocation id is globally unique"); - assert.equal(readFileSync(counter, "utf8"), "4"); + assert.equal(readFileSync(counter, "utf8"), "9"); const beforeRestart = cliJson(repo, ["loop", "inspect", runId]); assert.deepEqual(cliJson(repo, ["loop", "run", runId]), completed, "terminal restart is an idempotent read"); assert.deepEqual(cliJson(repo, ["loop", "inspect", runId]), beforeRestart, "terminal restart writes no journal records"); - const convergedHttp = await liveProjection(baseUrl, planPath, { "if-none-match": repairHttp.headers.etag }); + const convergedHttp = await liveProjection(baseUrl, planPath); assert.equal(convergedHttp.status, 200); const convergedProjection = JSON.parse(convergedHttp.body).loopRun; assert.deepEqual(convergedProjection, completed); const replay = readLatestRunForItem({ repoRoot: repo, itemRef: fixtureItemRef }); @@ -195,10 +236,9 @@ test("M9 no-network CLI slice exposes interruption, repair, invalidation refetch const invalidations = readOvenEvents(repo, { ovenIds: ["checklist"] }).filter((event) => event.kind === "loop-projection-changed" && event.cursor === completed.revision); assert.equal(invalidations.length, 1); assert.deepEqual(invalidations[0].payload, { revision: completed.revision }); - const ui = [render("paused", pausedProjection), render("repair", repairProjection), render("converged", convergedProjection)]; const domGolden = JSON.parse(await readFile(domGoldenPath, "utf8")); assert.deepEqual(needsHumanUi.record, domGolden[0]); - assert.deepEqual(ui.map((item) => item.record), domGolden.slice(1, 4)); + assert.deepEqual(render("converged", convergedProjection).record, domGolden[3]); const firstCompletion = cliJson(repo, ["loop", "complete", runId]); const secondCompletion = cliJson(repo, ["loop", "complete", runId]); @@ -216,3 +256,69 @@ test("M9 no-network CLI slice exposes interruption, repair, invalidation refetch assert.match(readFileSync(planPath, "utf8"), /^- DIRECT-01 \| .* \| Unassigned direct-flow control$/mu); }); }); + +test("H9 host claims survive restart, reject drift, repair, converge, and complete", { timeout: 60_000 }, async (t) => { + const directory = mkdtempSync(join(tmpdir(), "burnlist-h9-host-")); + t.after(() => rmSync(directory, { recursive: true, force: true })); + const { repo } = createProductionRunAuthority(join(directory, "repo")); + const planPath = join(repo, "notes", "burnlists", "inprogress", "260722-001", "burnlist.md"); + hostFixture(repo); + const runId = cliJson(repo, ["loop", "create", fixtureItemRef]).runId; + const reportPath = join(directory, "host-report.json"), observed = []; + const report = (claim, outcome) => { + writeFileSync(reportPath, `${JSON.stringify(hostResult(claim, outcome))}\n`); + return cliJson(repo, ["loop", "report", claim.claimId, "--result", reportPath]); + }; + { + const observe = async (node) => { + const projection = readLatestRunForItem({ repoRoot: repo, itemRef: fixtureItemRef }); + assert.ok(projection); + assert.equal(projection.currentNode, node); + assert.equal(projection.graph.nodes.find((item) => item.id === node)?.executionMode ?? "managed", ["implement", "review", "refine", "integrate", "final-review"].includes(node) ? "host" : "managed"); + observed.push(node); + }; + await observe("implement"); + const implement = cliJson(repo, ["loop", "claim", runId]).execution; + assert.equal(implement.nodeId, "implement"); + assert.equal(cliJson(repo, ["loop", "status", runId]).currentNode, "implement", "separate CLI restart preserves the durable claim"); + report(implement, "complete"); + await observe("validate"); + await managedCheck(repo, runId, "review"); + await observe("review"); + + const firstReview = cliJson(repo, ["loop", "claim", runId]).execution; + const drift = join(repo, "src", "host-report-drift.txt"); + writeFileSync(drift, "workspace drift\n"); + writeFileSync(reportPath, `${JSON.stringify(hostResult(firstReview, "reject"))}\n`); + assert.throws(() => cliJson(repo, ["loop", "report", firstReview.claimId, "--result", reportPath]), /candidate drifted/u); + assert.equal(cliJson(repo, ["loop", "status", runId]).currentNode, "review"); + rmSync(drift); + report(firstReview, "reject"); + await observe("refine"); + assert.throws(() => cliJson(repo, ["loop", "complete", runId]), /not converged/u); + + writeFileSync(join(repo, "src", "host-repair.txt"), "repaired candidate\n"); + const refine = cliJson(repo, ["loop", "claim", runId]).execution; + assert.notEqual(refine.inputCandidate, implement.inputCandidate, "repair must claim a freshly derived candidate"); + report(refine, "complete"); + await observe("validate"); + await managedCheck(repo, runId, "review"); + const review = cliJson(repo, ["loop", "claim", runId]).execution; + assert.equal(review.inputCandidate, refine.inputCandidate, "review is bound to the repaired candidate"); + report(review, "approve"); + await observe("integrate"); + const integrate = cliJson(repo, ["loop", "claim", runId]).execution; + report(integrate, "complete"); + await observe("final-validate"); + await managedCheck(repo, runId, "final-review"); + const finalReview = cliJson(repo, ["loop", "claim", runId]).execution; + report(finalReview, "approve"); + await observe("converged"); + const runner = createStoredProductionRunRunner({ repoRoot: repo, store: runStore(repo), runId }); + await runner.step(); await runner.step(); await runner.step(); await runner.step(); + assert.equal(cliJson(repo, ["loop", "status", runId]).state, "converged"); + assert.deepEqual(observed, ["implement", "validate", "review", "refine", "validate", "integrate", "final-validate", "converged"]); + assert.equal(cliJson(repo, ["loop", "complete", runId]).alreadyApplied, false); + assert.doesNotMatch(readFileSync(planPath, "utf8"), /- \[ \] L29/u); + } +}); diff --git a/src/loops/run/binder.mjs b/src/loops/run/binder.mjs index 7151b723..0545fdfa 100644 --- a/src/loops/run/binder.mjs +++ b/src/loops/run/binder.mjs @@ -3,7 +3,8 @@ import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { findBurnlistDir } from "../../cli/lifecycle-moves.mjs"; import { locateItemSpan, validateAssignedItem } from "../assignment/item-metadata.mjs"; -import { parseItemRef } from "../assignment/selectors.mjs"; +import { parseItemRef, parseLoopRef } from "../assignment/selectors.mjs"; +import { resolveLoopPackage } from "../assignment/assignment.mjs"; import { assignmentStore } from "../assignment/store.mjs"; import { readCapabilityCatalog, resolveCapability, canonicalCapabilityBytes, canonicalGrantBytes, GUARANTEE_LABELS } from "../capabilities/contract.mjs"; import { assertTrustedCapability } from "../capabilities/trust.mjs"; @@ -11,15 +12,13 @@ import { checkSnapshot, holdSnapshot, readSnapshotBytes, releaseSnapshot, snapsh import { compileLoopFiles } from "../dsl/compile.mjs"; import { loadFrozenRecipe } from "../dsl/frozen.mjs"; import { prefixed, rawSha256 } from "../dsl/hash.mjs"; -import { createNormalizedInvocation } from "../adapters/normalized-invocation.mjs"; import { agentProfileRevision } from "../agents/profile.mjs"; import { readProfile, readRoute, requiredRoutes } from "../config/profiles.mjs"; import { localRecordPath } from "../config/store.mjs"; -import { boundPolicyRevision, canonicalBoundPolicyBytes, loadBoundPolicy } from "./run-artifacts.mjs"; -import { createRunRunner } from "./runner.mjs"; +import { canonicalBoundPolicyBytes, loadBoundPolicy } from "./run-artifacts.mjs"; import { deriveCandidate } from "./candidate.mjs"; -import { ownerClaimId } from "./run-claim.mjs"; import { newRunId } from "./run-codec.mjs"; +import { createBoundNormalizedInvocationImpl, createProductionRunRunnerImpl } from "./production-runner.mjs"; const INPUT_KEYS = new Set(["runId", "itemRef"]); const builtinsRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../../../loops"); @@ -83,6 +82,24 @@ function executableSnapshot(loopRef) { assertBoundaryEvidence(evidence); return { compiled, evidence }; } +async function projectExecutableSnapshot(repoRoot, loopRef) { + const loop = parseLoopRef(loopRef), directory = join(repoRoot, ".burnlist", "loops", loop.name); + const resolved = await resolveLoopPackage({ repoRoot, loop }); + const files = {}, evidence = []; + for (const name of Object.keys(resolved.packageFiles).sort()) { + const path = join(directory, name), maximum = name.endsWith(".loop") ? 65536 : name === "instructions.md" ? 262144 : 65536; + const captured = readSnapshotBytes({ root: repoRoot, path, maximum }); files[name] = captured.bytes; + evidence.push(Object.freeze({ kind: "file", path, dev: String(captured.identity.dev), ino: String(captured.identity.ino), + size: String(captured.identity.size), mode: String(captured.identity.mode), mtimeMs: String(captured.identity.mtimeMs), ctimeMs: String(captured.identity.ctimeMs) })); + for (const ancestor of captured.ancestors) evidence.push(Object.freeze({ kind: "directory", path: ancestor.path, + dev: String(ancestor.identity.dev), ino: String(ancestor.identity.ino), size: String(ancestor.identity.size), + mode: String(ancestor.identity.mode), mtimeMs: String(ancestor.identity.mtimeMs), ctimeMs: String(ancestor.identity.ctimeMs) })); + } + const compiled = compileLoopFiles(files, { loopFile: `${loop.name}.loop` }); + if (!compiled.ok) fail("captured project executable source does not compile"); + assertBoundaryEvidence(evidence); + return { compiled, evidence }; +} function currentPolicy(repoRoot, recipeRevision) { const authorityInputs = []; const add = (role, path, executable = false) => authorityInputs.push(Object.freeze({ role, path, executable })); @@ -139,10 +156,12 @@ export function launchAuthorityDigest(evidence) { return rawSha256(Buffer.from(`${JSON.stringify({ schema: "burnlist-loop-launch-authority@1", inputs })}\n`)); } export function assertExecutableBinding(authority) { - if (!authority?.artifact?.executionRevision || !authority.currentCompiled?.revisions?.executable) + if (!authority?.artifact?.executionRevision || !authority?.artifact?.packageRevision || !authority.currentCompiled?.revisions?.executable || !authority.currentCompiled?.revisions?.package) fail("installed executable recipe is unavailable"); if (authority.currentCompiled.revisions.executable !== authority.artifact.executionRevision) fail(`installed executable ${authority.currentCompiled.revisions.executable} does not match assignment pin ${authority.artifact.executionRevision}`, "ELOOP_RUN_EXECUTABLE_DRIFT"); + if (authority.currentCompiled.revisions.package !== authority.artifact.packageRevision) + fail(`installed package ${authority.currentCompiled.revisions.package} does not match assignment pin ${authority.artifact.packageRevision}`, "ELOOP_RUN_EXECUTABLE_DRIFT"); return authority.artifact.executionRevision; } @@ -157,7 +176,9 @@ export async function bindRunCreation({ repoRoot, input }) { || artifact.unassignedItemDigest !== metadata.unassignedDigest || artifact.executionRevision !== metadata["Execution-Revision"] || artifact.packageRevision !== metadata["Package-Revision"]) fail("Run creation requires one canonical item assignment"); - const source = executableSnapshot(artifact.selector); + const source = artifact.selector.startsWith("loop:builtin:") + ? executableSnapshot(artifact.selector) + : await projectExecutableSnapshot(repoRoot, artifact.selector); const recipeRevision = assertExecutableBinding({ artifact, currentCompiled: source.compiled }); const policy = currentPolicy(repoRoot, recipeRevision); const evidence = boundaryEvidence([join(located.dir, "burnlist.md"), @@ -200,9 +221,18 @@ export async function createProductionRun({ repoRoot, store, itemRef, runId = ne authority = await bindRunCreation({ repoRoot, input: { runId, itemRef } }); } } - revalidatePreparedBinding({ repoRoot, bound: authority }); const graph = loadFrozenRecipe(authority.frozenRecipeBytes).ir; - store.createRun({ runId, itemRef: authority.itemRef, graph, authority: sealRunAuthority(runId, authority) }); + const previous = store.readCurrentRun?.(authority.itemRef); + let allowSupersedeConverged = false; + if (previous && previous.runId !== runId) { + const replay = store.read(previous.runId); + if (replay.projection.state === "converged") { + const candidate = deriveCandidate({ repoRoot }); + allowSupersedeConverged = candidate.id !== replay.execution.candidate?.id; + } + } + revalidatePreparedBinding({ repoRoot, bound: authority }); + store.createRun({ runId, itemRef: authority.itemRef, graph, authority: sealRunAuthority(runId, authority), allowSupersedeConverged }); return store.read(runId); } @@ -231,7 +261,7 @@ function revalidateAssignedItem({ repoRoot, replay }) { if (located.lifecycle.folder !== "inprogress") fail("Run item is no longer active", "ELOOP_RUN_BINDING_STALE"); const span = locateItemSpan(readFileSync(join(located.dir, "burnlist.md")), item.itemId); const metadata = validateAssignedItem(item.selector, span), artifact = assignmentStore(repoRoot).load(metadata["Assignment-Id"]); - const expectedSelector = `loop:builtin:${replay.frozenRecipe.ir.id}`; + const expectedSelector = artifact.selector; if (metadata.assignedDigest !== replay.projection.itemRevision || metadata["Assignment-Id"] !== replay.projection.assignmentId || metadata.Selector !== expectedSelector @@ -296,62 +326,18 @@ export function releaseRunLaunchBinding(held) { */ export function createBoundNormalizedInvocation({ repoRoot, replay, contextFor, candidateForBoundary = null, startAgent, runCheck, agentTimeoutMs = 0 }) { - if (typeof repoRoot !== "string" || !replay?.projection?.assignmentId || !replay?.frozenRecipe?.ir - || typeof replay.itemText !== "string" || !replay.itemText - || !Buffer.isBuffer(replay.policyBytes) || typeof contextFor !== "function") fail("invalid production invocation input"); - const policy = loadBoundPolicy(replay.policyBytes).policy; - const route = (name) => policy.routes.find((entry) => entry.route === name); - const implementation = route("implementation.standard"), review = route("review.strong"); - if (!implementation || !review) fail("frozen Stage One routes are unavailable"); - const nodes = new Map(replay.frozenRecipe.ir.nodes.map((node) => [node.id, node])); - return createNormalizedInvocation({ repoRoot, nodes, - routes: { implementation: { profile: implementation.profile }, review: { profile: review.profile } }, - bindingFor(invocation, node) { - const context = contextFor(invocation, node), instruction = replay.frozenRecipe.instructions - .find((item) => item.id === node.instructions); - if (!context || node.kind === "agent" && !instruction) fail("frozen invocation context is unavailable"); - return { claimId: context.claimId, assignmentId: replay.projection.assignmentId, - recipeRevision: replay.frozenRecipe.revisions.executable, policyRevision: boundPolicyRevision(policy), - inputCandidate: context.inputCandidate, instructionBytes: instruction - ? Buffer.from(instruction.base64, "base64").toString("utf8") : "Run the frozen trusted capability.\n", - itemText: replay.itemText, candidateContext: context.candidateContext, - reviewerEvidence: context.reviewerEvidence ?? [] }; - }, candidateForBoundary, startAgent, runCheck, agentTimeoutMs }); + return createBoundNormalizedInvocationImpl({ repoRoot, replay, contextFor, candidateForBoundary, + startAgent, runCheck, agentTimeoutMs }); } /** Compose frozen creation authority, the M3 dispatcher, and the M2 runner. */ export function createProductionRunRunner({ repoRoot, store, runId, authority, contextFor, startAgent, runCheck, agentTimeoutMs = 0 }) { - if (authority?.schema === "burnlist-loop-m12-run-authority@1") authority = unsealRunAuthority(authority); - if (!store?.replay || !authority?.assignmentId || !Buffer.isBuffer(authority.frozenRecipeBytes) - || !Buffer.isBuffer(authority.policyBytes)) fail("invalid production runner authority"); - const frozenRecipe = loadFrozenRecipe(authority.frozenRecipeBytes); - const replay = { projection: { assignmentId: authority.assignmentId }, frozenRecipe, - policyBytes: authority.policyBytes, itemText: authority.itemText }; - const liveContext = (invocation, node) => { - const execution = store.replay(runId).execution; - const candidate = execution.candidate ?? deriveCandidate({ repoRoot }); - const checkNode = frozenRecipe.ir.nodes.find((item) => item.kind === "check"); - const check = checkNode && execution.evidence[checkNode.id]; - const reviewerEvidence = node.mode === "review" - ? check?.kind === "pass" && check.candidateId === candidate.id && execution.latest.check?.candidateId === candidate.id - ? [`trusted-check candidate=${candidate.id} summary=${execution.latest.check.summary}`] : [] - : []; - return { claimId: ownerClaimId({ runId: invocation.runId, nodeId: invocation.nodeId, attempt: invocation.attempt, - assignmentId: authority.assignmentId, inputCandidate: candidate.id }), inputCandidate: candidate.id, - candidateContext: candidate.context, reviewerEvidence }; - }; - const dispatch = createBoundNormalizedInvocation({ repoRoot, replay, contextFor: contextFor ?? liveContext, - candidateForBoundary: () => deriveCandidate({ repoRoot }), startAgent, runCheck, agentTimeoutMs }); - const invoke = async (invocation) => { - const captured = captureRunLaunchBinding({ repoRoot, replay: { ...replay, projection: { ...replay.projection, itemRef: authority.itemRef, itemRevision: authority.itemRevision }, boundPolicy: loadBoundPolicy(authority.policyBytes).policy } }); - recheckRunLaunchBinding(captured); const held = holdRunLaunchBinding(captured); - try { recheckRunLaunchBinding(captured); return await dispatch(invocation); } - finally { releaseRunLaunchBinding(held); } - }; - return createRunRunner({ store, runId, invoke, bindCandidate() { - const candidate = deriveCandidate({ repoRoot }); return { candidateId: candidate.id, candidateContext: candidate.context }; - } }); + return createProductionRunRunnerImpl({ repoRoot, store, runId, authority, contextFor, + startAgent, runCheck, agentTimeoutMs, binding: { + seal: sealRunAuthority, unseal: unsealRunAuthority, capture: captureRunLaunchBinding, + recheck: recheckRunLaunchBinding, hold: holdRunLaunchBinding, release: releaseRunLaunchBinding, + } }); } /** Resume constructs exclusively from the immutable per-Run record; it never rebinds source or policy. */ diff --git a/src/loops/run/binder.test.mjs b/src/loops/run/binder.test.mjs index f8e44551..e1dbaedd 100644 --- a/src/loops/run/binder.test.mjs +++ b/src/loops/run/binder.test.mjs @@ -1,15 +1,49 @@ import assert from "node:assert/strict"; -import { mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; import { bindRunCreation, createProductionRun, createProductionRunRunner, createStoredProductionRunRunner, revalidatePreparedBinding } from "./binder.mjs"; +import { assignLoopItem, prepareItemMutation, unassignLoopItem } from "../assignment/assignment.mjs"; import { loadBoundPolicy } from "./run-artifacts.mjs"; import { loadFrozenRecipe } from "../dsl/frozen.mjs"; import { runStore } from "./run-store.mjs"; import { presentRun } from "./read-projection.mjs"; import { createProductionRunAuthority, fixtureItemRef, fixtureRunId } from "./run-test-fixtures.mjs"; +async function projectLoop(repo) { + unassignLoopItem({ repoRoot: repo, itemRef: fixtureItemRef }); + const root = join(repo, ".burnlist", "loops", "whole-first"); + mkdirSync(root, { recursive: true }); + writeFileSync(join(root, "whole-first.loop"), readFileSync(new URL("../../../loops/review/review.loop", import.meta.url)), { flag: "wx" }); + writeFileSync(join(root, "instructions.md"), readFileSync(new URL("../../../loops/review/instructions.md", import.meta.url)), { flag: "wx" }); + await assignLoopItem({ repoRoot: repo, itemRef: fixtureItemRef, loopRef: "loop:project:whole-first", prepared: prepareItemMutation({ repoRoot: repo, itemRef: fixtureItemRef }) }); + return root; +} + +test("project package publication is source-bound while a published Run remains frozen", async (t) => { + const directory = realpathSync(mkdtempSync(join(tmpdir(), "burnlist-project-bind-"))); t.after(() => rmSync(directory, { recursive: true, force: true })); + const { repo } = createProductionRunAuthority(join(directory, "repo")), root = await projectLoop(repo); + const bound = await bindRunCreation({ repoRoot: repo, input: { runId: fixtureRunId, itemRef: fixtureItemRef } }); + const source = join(root, "whole-first.loop"), original = readFileSync(source), replacement = `${source}.replacement`; + writeFileSync(replacement, `${original}\n`); renameSync(replacement, source); + assert.throws(() => revalidatePreparedBinding({ repoRoot: repo, bound }), /authority input changed before Run publication/u); + writeFileSync(source, original); + const created = await createProductionRun({ repoRoot: repo, store: runStore(repo), itemRef: fixtureItemRef, runId: fixtureRunId }); + assert.equal(created.projection.runId, fixtureRunId); + writeFileSync(source, `${readFileSync(source, "utf8")}\n`); + const outcomes = ["complete", "complete", "complete", "reject", "complete", "complete", "approve", "complete", "approve"]; + const runner = createStoredProductionRunRunner({ repoRoot: repo, store: runStore(repo), runId: fixtureRunId, + startAgent: ({ prompt }) => { + const values = Object.fromEntries(prompt.split("\n").filter((line) => line.includes("=")).map((line) => line.split(/=(.*)/su).slice(0, 2))); + const final = { schema: "burnlist.agent-final@1", runId: values.run, nodeId: values.node, attempt: Number(values.attempt), claimId: values.claim, + invocationId: values.invocation, assignmentId: values.assignment, recipeRevision: values.recipe, policyRevision: values.policy, + inputCandidate: values.candidate, outcome: outcomes.shift(), summary: "frozen project recipe", findings: [], resolvedFindingIds: [] }; + return { cancel: () => true, completion: Promise.resolve({ outcome: "completed", events: [{ type: "item.completed", item: { type: "agent_message", text: JSON.stringify(final) } }] }) }; + }, runCheck: async ({ inputCandidate }) => ({ result: { outcome: "pass", inputCandidate, timedOut: false, truncated: false }, evidence: Buffer.from("pass") }) }); + assert.equal((await runner.run()).projection.state, "converged"); +}); + test("production creation binds direct Stage One profiles without Docker artifacts", async (t) => { const directory = realpathSync(mkdtempSync(join(tmpdir(), "burnlist-direct-binder-"))); t.after(() => rmSync(directory, { recursive: true, force: true })); @@ -46,7 +80,8 @@ test("production factory drives direct maker-check-reject-repair-approve", async store.createRun({ runId: fixtureRunId, itemRef: fixtureItemRef, graph }); const counter = join(directory, "counter"); writeFileSync(counter, "0"); const oldCounter = process.env.BURNLIST_FAKE_COUNTER, oldOutcomes = process.env.BURNLIST_FAKE_OUTCOMES; - process.env.BURNLIST_FAKE_COUNTER = counter; process.env.BURNLIST_FAKE_OUTCOMES = "complete,reject,complete,approve"; + process.env.BURNLIST_FAKE_COUNTER = counter; + process.env.BURNLIST_FAKE_OUTCOMES = "complete,complete,complete,reject,complete,complete,approve,complete,approve"; t.after(() => { if (oldCounter === undefined) delete process.env.BURNLIST_FAKE_COUNTER; else process.env.BURNLIST_FAKE_COUNTER = oldCounter; if (oldOutcomes === undefined) delete process.env.BURNLIST_FAKE_OUTCOMES; else process.env.BURNLIST_FAKE_OUTCOMES = oldOutcomes; @@ -62,7 +97,7 @@ test("production factory drives direct maker-check-reject-repair-approve", async test("executable reviewer escalation and malformed or stale finals fail closed", async (t) => { for (const [name, outcomes, mode, expected] of [ - ["escalate", "complete,escalate", "", "needs-human"], + ["escalate", "complete,complete,complete,escalate", "", "needs-human"], ["malformed", "complete", "malformed", "failed"], ["stale", "complete", "stale", "failed"], ]) { @@ -101,26 +136,29 @@ test("stored production repair binds a fresh repository candidate and gives each assert.deepEqual(publicRun.graph.nodes.find((node) => node.id === "review").execution, { profileId: "reviewer", model: "gpt-5.3-codex-spark", effort: "medium", authority: "read" }); assert.doesNotMatch(JSON.stringify(publicRun), /\/bin\/sh|executableDigest|adapter/u); - const outcomes = ["complete", "reject", "complete", "approve"], reviewerPrompts = []; let writes = 0; + const outcomes = ["complete", "complete", "complete", "reject", "complete", "complete", "approve", "complete", "approve"]; + const reviewerPrompts = []; let writes = 0; const startAgent = ({ prompt }) => { const values = Object.fromEntries(prompt.split("\n").filter((line) => line.includes("=")).map((line) => line.split(/=(.*)/su).slice(0, 2))); const outcome = outcomes.shift(); if (values.node === "implement") writeFileSync(join(repo, "candidate-state.txt"), `maker-write-${++writes}\n`); - if (values.node === "review") reviewerPrompts.push(prompt); + if (values.node === "review" || values.node === "final-review") reviewerPrompts.push(prompt); const final = { schema: "burnlist.agent-final@1", runId: values.run, nodeId: values.node, attempt: Number(values.attempt), claimId: values.claim, invocationId: values.invocation, assignmentId: values.assignment, recipeRevision: values.recipe, - policyRevision: values.policy, inputCandidate: values.candidate, outcome, summary: `fake ${outcome}` }; + policyRevision: values.policy, inputCandidate: values.candidate, outcome, summary: `fake ${outcome}`, findings: [], resolvedFindingIds: [] }; return { cancel: () => true, completion: Promise.resolve({ outcome: "completed", events: [{ type: "item.completed", item: { type: "agent_message", text: JSON.stringify(final) } }] }) }; }; const runner = createStoredProductionRunRunner({ repoRoot: repo, store, runId: created.projection.runId, startAgent, runCheck: async ({ inputCandidate }) => ({ result: { outcome: "pass", inputCandidate, timedOut: false, truncated: false }, evidence: Buffer.from("pass") }) }); assert.equal((await runner.run()).projection.state, "converged"); const candidates = store.read(fixtureRunId).journal.filter((record) => record.value.type === "candidate-bound").map((record) => record.value.payload.candidateId); - assert.equal(candidates.length, 2); assert.notEqual(candidates[0], candidates[1]); - assert.equal(reviewerPrompts.length, 2); - for (const [index, prompt] of reviewerPrompts.entries()) { - assert.match(prompt, new RegExp(`candidate=${candidates[index]}`, "u")); - assert.match(prompt, new RegExp(`trusted-check candidate=${candidates[index]} summary=repository check pass`, "u")); + assert.equal(candidates.length, 6); + assert.equal(new Set(candidates).size, 3); + assert.equal(reviewerPrompts.length, 3); + for (const prompt of reviewerPrompts) { + const candidate = prompt.match(/^candidate=(cm1-sha256:[a-f0-9]{64})$/mu)?.[1]; + assert.ok(candidate); + assert.match(prompt, /artifact:sha256:[a-f0-9]{64}/u); } }); @@ -164,7 +202,7 @@ test("an over-limit post-maker candidate replays as a closed failure without a l writeFileSync(join(repo, "oversized-candidate.bin"), Buffer.alloc(16_777_217)); const final = { schema: "burnlist.agent-final@1", runId: values.run, nodeId: values.node, attempt: Number(values.attempt), claimId: values.claim, invocationId: values.invocation, assignmentId: values.assignment, recipeRevision: values.recipe, - policyRevision: values.policy, inputCandidate: values.candidate, outcome: "complete", summary: "maker complete" }; + policyRevision: values.policy, inputCandidate: values.candidate, outcome: "complete", summary: "maker complete", findings: [], resolvedFindingIds: [] }; return { cancel: () => true, completion: Promise.resolve({ outcome: "completed", events: [{ type: "item.completed", item: { type: "agent_message", text: JSON.stringify(final) } }] }) }; }; @@ -176,3 +214,46 @@ test("an over-limit post-maker candidate replays as a closed failure without a l assert.equal(replayed.projection.leaseHeld, false); assert.match(replayed.execution.system.summary, /too large/u); }); + +test("a settled managed cancellation resolves its claim, pauses, and resumes with a fresh attempt", async (t) => { + const directory = realpathSync(mkdtempSync(join(tmpdir(), "burnlist-managed-pause-"))); + t.after(() => rmSync(directory, { recursive: true, force: true })); + const { repo } = createProductionRunAuthority(join(directory, "repo")), store = runStore(repo); + await createProductionRun({ repoRoot: repo, store, itemRef: fixtureItemRef, runId: fixtureRunId }); + let started = 0, resolveCancelled; + const startAgent = () => { + started += 1; + return { + cancel() { + resolveCancelled?.({ outcome: "cancelled", events: [] }); + return true; + }, + completion: new Promise((resolve) => { resolveCancelled = resolve; }), + }; + }; + const first = createStoredProductionRunRunner({ repoRoot: repo, store, runId: fixtureRunId, startAgent }); + const firstRun = first.run(); + while (started !== 1) await new Promise((resolve) => setImmediate(resolve)); + first.requestPause(); + const paused = await firstRun; + assert.equal(paused.projection.state, "paused"); + assert.equal(paused.projection.leaseHeld, false); + assert.equal(paused.execution.externalClaim, null); + assert.equal(paused.execution.attempts.start, 1); + const firstClaim = paused.journal.find((record) => record.value.type === "external-claim-bound").value.payload.claim; + assert.equal(paused.journal.filter((record) => record.value.type === "external-claim-resolved").length, 1); + + // This new runner is the restart boundary: it must obtain a new lease and + // claim rather than continue the settled cancelled invocation. + const resumed = createStoredProductionRunRunner({ repoRoot: repo, store: runStore(repo), runId: fixtureRunId, startAgent }); + const resumedRun = resumed.run(); + while (started !== 2) await new Promise((resolve) => setImmediate(resolve)); + resumed.requestPause(); + const pausedAgain = await resumedRun; + const claims = pausedAgain.journal.filter((record) => record.value.type === "external-claim-bound").map((record) => record.value.payload.claim); + assert.deepEqual(claims.map((claim) => claim.attempt), [1, 2]); + assert.notEqual(claims[1].claimId, firstClaim.claimId); + assert.equal(pausedAgain.execution.attempts.start, 2); + assert.equal(pausedAgain.projection.state, "paused"); + assert.equal(pausedAgain.projection.leaseHeld, false); +}); diff --git a/src/loops/run/budgets.mjs b/src/loops/run/budgets.mjs index 4e223867..a0dd3322 100644 --- a/src/loops/run/budgets.mjs +++ b/src/loops/run/budgets.mjs @@ -4,13 +4,22 @@ const fail = (message) => { throw Object.assign(new Error(`Run budgets: ${messag export function validateBudget(value) { if (!exact(value) || !keys.every((key) => Number.isSafeInteger(value[key]) && value[key] > 0)) fail("invalid limits"); return Object.freeze({ ...value }); } export function foldBudgets({ records, graph }) { const budget = validateBudget(graph.budget), nodes = new Map(graph.nodes.map((node) => [node.id, node])), counters = { rounds: 0, agentRuns: 0, checkRuns: 0, transitions: 0, outputBytes: 0 }; - const visits = {}; let elapsedMilliseconds = 0, previous = records[0]?.value?.at; + const visits = {}, managedAttempts = new Set(records.filter((record) => record.value.type === "external-claim-bound") + .map((record) => `${record.value.payload.claim?.nodeId}\0${record.value.payload.claim?.attempt}`)); let elapsedMilliseconds = 0, previous = records[0]?.value?.at; for (const record of records) { if (!Number.isSafeInteger(record.value.at) || record.value.at < previous) fail("clock regressed"); elapsedMilliseconds += record.value.at - previous; previous = record.value.at; const { type, payload } = record.value; - if (type === "node-started") { const node = nodes.get(payload.nodeId); if (node.kind === "agent") { counters.agentRuns += 1; if (node.mode === "task") counters.rounds += 1; } else if (node.kind === "check") counters.checkRuns += 1; } + // A managed host claim is the agent-start boundary. It replaces (rather + // than supplements) node-started, so each accepted attempt is counted + // exactly once regardless of its execution transport. + if (type === "node-started" || type === "external-claim-bound") { + if (type === "node-started" && managedAttempts.has(`${payload.nodeId}\0${payload.attempt}`)) continue; + const node = nodes.get(type === "node-started" ? payload.nodeId : payload.claim?.nodeId); + if (node?.kind === "agent") { counters.agentRuns += 1; if (node.mode === "task") counters.rounds += 1; } + else if (type === "node-started" && node?.kind === "check") counters.checkRuns += 1; + } if (type === "edge-taken") { counters.transitions += 1; const key = `${payload.from}\0${payload.on}`, edge = graph.edges.find((item) => item.from === payload.from && item.on === payload.on); visits[key] = (visits[key] ?? 0) + 1; if (edge?.maxVisits !== null && visits[key] > edge.maxVisits) fail("edge visit exceeds limit"); } - if (type === "invocation-result") counters.outputBytes += payload.outputBytes; + if (type === "invocation-result" || type === "external-report-accepted") counters.outputBytes += payload.outputBytes; } if (counters.rounds > budget.maxRounds || counters.agentRuns > budget.maxAgentRuns || counters.checkRuns > budget.maxCheckRuns || counters.transitions > budget.maxTransitions || counters.outputBytes > budget.maxOutputBytes) fail("inclusive limit exceeded"); return Object.freeze({ counters: Object.freeze(counters), visits: Object.freeze(visits), elapsedMilliseconds, timeExceeded: elapsedMilliseconds > budget.maxMinutes * 60_000, journal: Object.freeze({ maximum: MAX_JOURNAL_RECORDS, used: records.length, remaining: MAX_JOURNAL_RECORDS - records.length }) }); diff --git a/src/loops/run/budgets.test.mjs b/src/loops/run/budgets.test.mjs index ab703e09..d01f48be 100644 --- a/src/loops/run/budgets.test.mjs +++ b/src/loops/run/budgets.test.mjs @@ -3,6 +3,7 @@ import test from "node:test"; import { budgetReason, foldBudgets } from "./budgets.mjs"; import { createJournalRecord } from "./run-journal.mjs"; import { created, testGraph } from "./m2-test-fixtures.mjs"; +import { createHostClaim } from "./run-claim.mjs"; const record = (sequence, prior, type, payload, at = sequence) => createJournalRecord({ sequence, prevDigest: prior?.digest ?? null, at, type, payload }); test("fold enforces inclusive counters, retries, visits, output, and time", () => { @@ -13,3 +14,30 @@ test("fold enforces inclusive counters, retries, visits, output, and time", () = for (let index = 1; index <= 4; index += 1) visits.push(record(index + 1, visits.at(-1), "edge-taken", { from: "review", on: "reject", to: "implement" })); assert.throws(() => foldBudgets({ records: visits, graph: testGraph }), /visit exceeds/u); }); + +test("managed claim and report boundaries consume agent, round, and output budgets once", () => { + const one = record(1, null, "run-created", created(), 0); + const hostClaim = createHostClaim({ runId: created().runId, nodeId: "implement", attempt: 1, + assignmentId: `as1-sha256:${"a".repeat(64)}`, inputCandidate: `cm1-sha256:${"b".repeat(64)}`, + executionDigest: `sha256:${"c".repeat(64)}`, expiresAt: 10_000 }); + const bound = { claim: hostClaim, envelopeDigest: hostClaim.executionDigest, invocationId: `iv1-sha256:${"d".repeat(64)}` }; + const claim = record(2, one, "external-claim-bound", bound); + const report = record(3, claim, "external-report-accepted", { claimId: hostClaim.claimId, reportDigest: `sha256:${"e".repeat(64)}`, + invocationId: bound.invocationId, kind: "complete", summary: "ok", outputBytes: 7, candidateId: null }); + const managed = [one, claim, report]; + const exact = { ...testGraph, budget: { ...testGraph.budget, maxAgentRuns: 1, maxRounds: 1, maxOutputBytes: 7 } }; + const folded = foldBudgets({ records: managed, graph: exact }); + assert.deepEqual(folded.counters, { rounds: 1, agentRuns: 1, checkRuns: 0, transitions: 0, outputBytes: 7 }); + const directStart = record(2, one, "node-started", { nodeId: "implement", attempt: 1 }); + const mixed = [one, directStart, record(3, directStart, "external-claim-bound", bound)]; + assert.equal(foldBudgets({ records: mixed, graph: exact }).counters.agentRuns, 1, "transport boundary cannot double count an attempt"); + assert.equal(budgetReason({ folded, graph: exact, node: exact.nodes.find((node) => node.id === "implement") }), "agent-runs"); + const nextClaim = createHostClaim({ runId: hostClaim.runId, nodeId: hostClaim.nodeId, attempt: 2, + assignmentId: hostClaim.assignmentId, inputCandidate: hostClaim.inputCandidate, + executionDigest: `sha256:${"f".repeat(64)}`, expiresAt: 20_000 }); + const secondClaim = record(4, report, "external-claim-bound", { claim: nextClaim, envelopeDigest: nextClaim.executionDigest, invocationId: `iv1-sha256:${"1".repeat(64)}` }); + const secondReport = record(4, report, "external-report-accepted", { claimId: hostClaim.claimId, reportDigest: `sha256:${"2".repeat(64)}`, + invocationId: bound.invocationId, kind: "complete", summary: "ok", outputBytes: 1, candidateId: null }); + assert.throws(() => foldBudgets({ records: [...managed, secondClaim], graph: exact }), /inclusive limit exceeded/u); + assert.throws(() => foldBudgets({ records: [...managed, secondReport], graph: exact }), /inclusive limit exceeded/u); +}); diff --git a/src/loops/run/candidate.mjs b/src/loops/run/candidate.mjs index dd19b58e..ab6e6811 100644 --- a/src/loops/run/candidate.mjs +++ b/src/loops/run/candidate.mjs @@ -10,7 +10,7 @@ const RUNTIME_ROOTS = new Set([".local", "node_modules"]); const fail = (message) => { throw Object.assign(new Error(`Loop candidate: ${message}`), { code: "ECANDIDATE" }); }; const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex"); -function repositoryPaths(root) { +function repositoryPaths(root, excludedPaths) { let bytes; try { bytes = execFileSync("git", ["-C", root, "ls-files", "--cached", "--others", "--exclude-standard", "-z"], { @@ -21,7 +21,7 @@ function repositoryPaths(root) { if (paths.pop() !== "" || new Set(paths).size !== paths.length || paths.some((path) => !path || path.startsWith("/") || path.split("/").some((part) => part === ".."))) fail("worktree path list is invalid or exceeds bounds"); - const included = paths.filter((path) => !RUNTIME_ROOTS.has(path.split("/", 1)[0])).sort(); + const included = paths.filter((path) => !RUNTIME_ROOTS.has(path.split("/", 1)[0]) && !excludedPaths.has(path)).sort(); if (included.length > MAX_FILES) fail("worktree path list exceeds bounds"); return included; } @@ -49,18 +49,21 @@ function fileRecord(root, relativePath) { return `${relativePath}\tfile\t${bytes.length}\t${sha256(bytes)}`; } -/** A bounded Git worktree manifest. This is boundary detection, not hostile-writer isolation. */ -export function deriveCandidate({ repoRoot }) { +/** A bounded Git worktree manifest. This detects drift at caller-defined boundaries; it is not hostile-writer isolation. */ +export function deriveCandidate({ repoRoot, excludedPaths = [] }) { const root = resolve(repoRoot), rootBefore = lstatSync(root); if (!rootBefore.isDirectory() || rootBefore.isSymbolicLink()) fail("repository root is unsafe"); - const paths = repositoryPaths(root), records = []; let total = 0; + if (!Array.isArray(excludedPaths) || excludedPaths.length > 16 || excludedPaths.some((path) => typeof path !== "string" || !path || path.startsWith("/") || path.split("/").some((part) => !part || part === "." || part === ".."))) fail("candidate exclusion is invalid"); + const exclusions = new Set(excludedPaths); + if (exclusions.size !== excludedPaths.length) fail("candidate exclusion is invalid"); + const paths = repositoryPaths(root, exclusions), records = []; let total = 0; for (const path of paths) { const record = fileRecord(root, path), fields = record.split("\t"); total += Number(fields[2]); if (total > MAX_TOTAL_BYTES) fail("candidate manifest exceeds bounds"); records.push(record); } - if (repositoryPaths(root).join("\0") !== paths.join("\0")) fail("worktree paths changed while capturing candidate"); + if (repositoryPaths(root, exclusions).join("\0") !== paths.join("\0")) fail("worktree paths changed while capturing candidate"); const rootAfter = lstatSync(root); if (!rootAfter.isDirectory() || rootAfter.isSymbolicLink() || rootBefore.dev !== rootAfter.dev || rootBefore.ino !== rootAfter.ino) fail("repository root changed while capturing candidate"); const manifest = `candidate-manifest@2\n${records.join("\n")}\n`; diff --git a/src/loops/run/controller.mjs b/src/loops/run/controller.mjs index 479488d3..88c9043a 100644 --- a/src/loops/run/controller.mjs +++ b/src/loops/run/controller.mjs @@ -1,12 +1,14 @@ import { presentRun } from "./read-projection.mjs"; import { isRunRef } from "./run-ref.mjs"; +import { prepareHostClaim } from "./host-execution.mjs"; +import { deriveCandidate } from "./candidate.mjs"; const fail = (message, code = "ELOOP_CONTROL") => { throw Object.assign(new Error(`Loop control: ${message}`), { code }); }; const stable = (value) => `${JSON.stringify(value)}\n`; /** Small foreground-only control boundary. It owns no daemon or recovery policy. */ -export function createLoopController({ store, runnerFor }) { - if (!store?.read || !store?.list || !store?.acquireLease || !store?.terminalize) fail("invalid controller input"); +export function createLoopController({ store, runnerFor, repoRoot = null }) { + if (!store?.read || !store?.list || !store?.acquireLease || !store?.terminalize || !store?.bindExternalClaim || !store?.readExternalClaim || !store?.abandonExternalClaim || !store?.acceptExternalReport || !store?.resolveClaimRef) fail("invalid controller input"); const check = (runId) => { if (!isRunRef(runId)) fail("invalid RunRef"); return runId; }; const read = (runId) => store.read(check(runId)); const inspect = (runId) => Object.freeze(presentRun(read(runId))); @@ -31,6 +33,38 @@ export function createLoopController({ store, runnerFor }) { const lease = idleLease(runId); return presentRun(store.terminalize(runId, lease, "cancelled", "control")); } + /** Claiming is controller-owned: the journal lease serializes contenders and exact retries reread the same envelope. */ + function claim(runId) { + check(runId); const current = read(runId), active = store.readExternalClaim(runId, current); + if (active) fail("Run agent node is already claimed", "ECLAIMED"); + const lease = idleLease(runId); + try { + const prepared = prepareHostClaim({ repoRoot, replay: read(runId), authority: store.readAuthority(runId) }); + return store.bindExternalClaim(runId, lease, prepared); + } + catch (error) { + // A failed preparation has not started a host. Releasing this private + // controller lease makes the normal foreground owner available again. + try { if (read(runId).execution.lease) store.releaseLease(runId, lease); } catch {} + throw error; + } + } + function report(claimRef, bytes) { + const runId = store.resolveClaimRef(claimRef), current = read(runId); + return presentRun(store.acceptExternalReport(runId, current.execution.lease, bytes, + () => deriveCandidate({ repoRoot }))); + } + function readClaim(runId) { check(runId); return store.readExternalClaim(runId); } + function abandonClaim(runId, abandonment) { + check(runId); const current = read(runId); + if (current.execution.terminal) return inspect(runId); + if (current.execution.externalClaim) { + if (!current.execution.lease) fail("external claim owner is not fenced", "ELEASED"); + return presentRun(store.abandonExternalClaim(runId, current.execution.lease, abandonment)); + } + const lease = idleLease(runId); + return presentRun(store.abandonExternalClaim(runId, lease, abandonment)); + } async function run(runId) { check(runId); if (typeof runnerFor !== "function") fail("foreground runner is unavailable", "ERUNNER_UNAVAILABLE"); const runner = runnerFor(runId); if (!runner?.run) fail("foreground runner is unavailable", "ERUNNER_UNAVAILABLE"); @@ -45,5 +79,5 @@ export function createLoopController({ store, runnerFor }) { const lease = idleLease(runId); return presentRun(store.terminalize(runId, lease, "lost", "reconciled lost invocation")); } - return Object.freeze({ list, inspect, status, pause, stop, run, reconcile, render: stable }); + return Object.freeze({ list, inspect, status, pause, stop, run, reconcile, claim, report, readClaim, abandonClaim, render: stable }); } diff --git a/src/loops/run/host-execution.mjs b/src/loops/run/host-execution.mjs new file mode 100644 index 00000000..3f42254f --- /dev/null +++ b/src/loops/run/host-execution.mjs @@ -0,0 +1,67 @@ +import { randomBytes } from "node:crypto"; +import { loadFrozenRecipe } from "../dsl/frozen.mjs"; +import { prefixed, rawSha256 } from "../dsl/hash.mjs"; +import { createDispatchAuthority, createInvocationInput } from "../contracts/agent-result.mjs"; +import { createHostExecutionEnvelope } from "../contracts/host-execution.mjs"; +import { boundPolicyRevision, loadBoundPolicy } from "./run-artifacts.mjs"; +import { createHostClaim } from "./run-claim.mjs"; +import { deriveCandidate } from "./candidate.mjs"; + +const fail = (message) => { throw Object.assign(new Error(`Host execution: ${message}`), { code: "EHOST_EXECUTION" }); }; + +export function prepareHostClaim({ repoRoot, replay, authority, now = Date.now(), random = randomBytes }) { + const node = replay?.execution?.node; + if (replay.execution.terminal || replay.execution.started || node?.kind !== "agent") fail("current node is not an unstarted agent"); + const frozen = loadFrozenRecipe(Buffer.from(authority.frozenRecipe, "base64")); + const policy = loadBoundPolicy(Buffer.from(authority.policy, "base64")); + const instruction = frozen.instructions.find((item) => item.id === node.instructions); + if (!instruction) fail("frozen node instructions are unavailable"); + const candidate = deriveCandidate({ repoRoot }), attempt = replay.execution.attempt + 1; + // A reviewer is evidence about the maker's folded candidate, not about a + // convenient later workspace snapshot. Refuse to dispatch it if anything + // has changed since the candidate was bound. + if (node.mode === "review" && (!replay.execution.candidate || replay.execution.candidate.id !== candidate.id)) + fail("candidate drift before review claim"); + const reviewerEvidence = node.mode === "review" ? (() => { + const evidence = Object.entries(replay.execution.evidence ?? {}) + .filter(([nodeId, value]) => frozen.ir.nodes.find((item) => item.id === nodeId)?.kind === "check" + && value?.kind === "pass" && value.candidateId === candidate.id && value.cycle === replay.execution.cycle) + .sort(([left], [right]) => left.localeCompare(right)); + if (!evidence.length || replay.execution.latest.check?.candidateId !== candidate.id) + fail("trusted check evidence is unavailable for review claim"); + return evidence.map(([nodeId, value]) => `artifact:${rawSha256(Buffer.from(JSON.stringify({ + schema: "burnlist-loop-review-evidence@1", nodeId, kind: value.kind, candidateId: value.candidateId, cycle: value.cycle, + }), "utf8"))}`); + })() : []; + const invocationId = prefixed("iv1-sha256:", "host-invocation-v1", [random(32)]), issuedAt = now, expiresAt = issuedAt + 60 * 60 * 1000; + const common = { + runId: replay.runId, nodeId: node.id, attempt, assignmentId: authority.assignmentId, invocationId, + recipeRevision: frozen.revisions.executable, policyRevision: boundPolicyRevision(policy.policy), + inputCandidate: candidate.id, + }; + const claimId = createHostClaim({ ...common, executionDigest: `sha256:${"0".repeat(64)}`, expiresAt }).claimId; + const invocationInput = createInvocationInput({ + schema: "burnlist-loop-invocation-input@1", ...common, claimId, itemRevision: authority.itemRevision, + ...(node.execution ? { execution: node.execution, intelligence: node.intelligence } : {}), + mode: node.mode, role: node.role, authority: node.authority, + legalOutcomes: node.mode === "task" ? ["complete"] : ["approve", "reject", "escalate"], + requires: [...(node.requires ?? [])].sort(), + openFindings: node.mode === "review" + ? [...replay.execution.openFindings.values()].sort((left, right) => left.id.localeCompare(right.id)) + : [], + instructionDigest: rawSha256(Buffer.from(instruction.base64, "base64")), instructionBytes: instruction.base64, + itemText: Buffer.from(authority.itemText).toString("base64"), + candidateContext: Buffer.from(candidate.context).toString("base64"), reviewerEvidence, + }); + const dispatchAuthority = createDispatchAuthority({ + schema: "burnlist-loop-dispatch-authority@1", state: "prepared-before-dispatch", ...common, claimId, + itemRevision: authority.itemRevision, inputSchema: invocationInput.value.schema, + inputDigest: invocationInput.digest, inputByteLength: invocationInput.bytes.length, + }); + const envelope = createHostExecutionEnvelope({ + schema: "burnlist-loop-host-execution@1", ...common, claimId, issuedAt, expiresAt, + invocationInput: invocationInput.bytes.toString("base64"), dispatchAuthority: dispatchAuthority.bytes.toString("base64"), + }); + const claim = createHostClaim({ ...common, claimId, executionDigest: envelope.digest, expiresAt }); + return Object.freeze({ claim, envelope: envelope.bytes }); +} diff --git a/src/loops/run/m2-test-fixtures.mjs b/src/loops/run/m2-test-fixtures.mjs index 5fff7c6c..7315ac77 100644 --- a/src/loops/run/m2-test-fixtures.mjs +++ b/src/loops/run/m2-test-fixtures.mjs @@ -1,8 +1,8 @@ export const testRunId = "run:01arz3ndektsv4rrffq69g5fav"; export const testGraph = Object.freeze({ schema: "burnlist-loop-ir@1", compiler: "burnlist-loop-compiler@1", id: "review", declaredVersion: "0.1.0", entry: "implement", budget: { maxRounds: 3, maxMinutes: 60, maxAgentRuns: 6, maxCheckRuns: 3, maxTransitions: 16, maxOutputBytes: 262144 }, nodes: [ - { kind: "agent", id: "implement", mode: "task", role: "maker", route: "implementation.standard", authority: "write", instructions: "implement", independentFrom: null, requires: [] }, + { kind: "agent", id: "implement", mode: "task", execution: "managed", intelligence: "standard", role: "maker", route: "implementation.standard", authority: "write", instructions: "implement", independentFrom: null, requires: [] }, { kind: "terminal", id: "completed", state: "converged" }, { kind: "gate", id: "converged", gateKind: "convergence", requires: ["verify", "review"] }, { kind: "terminal", id: "exhausted", state: "budget-exhausted" }, { kind: "terminal", id: "failed", state: "failed" }, { kind: "terminal", id: "needs-human", state: "needs-human" }, - { kind: "agent", id: "review", mode: "review", role: "reviewer", route: "review.strong", authority: "read", instructions: "review", independentFrom: "implement", requires: ["fresh-session:enforced", "filesystem-write-deny:supervised"] }, { kind: "terminal", id: "stopped", state: "stopped" }, { kind: "check", id: "verify", capability: "repo-verify" }, + { kind: "agent", id: "review", mode: "review", execution: "managed", intelligence: "strong", role: "reviewer", route: "review.strong", authority: "read", instructions: "review", independentFrom: "implement", requires: ["fresh-session:enforced", "filesystem-write-deny:supervised"] }, { kind: "terminal", id: "stopped", state: "stopped" }, { kind: "check", id: "verify", capability: "repo-verify" }, ], failurePolicy: { error: "failed", timeout: "failed", cancelled: "stopped", lost: "needs-human", exhausted: "exhausted" }, edges: [ { from: "implement", on: "complete", to: "verify", maxVisits: null }, { from: "converged", on: "pass", to: "completed", maxVisits: null }, { from: "converged", on: "fail", to: "needs-human", maxVisits: null }, { from: "review", on: "approve", to: "converged", maxVisits: null }, { from: "review", on: "reject", to: "implement", maxVisits: 3 }, { from: "review", on: "escalate", to: "needs-human", maxVisits: null }, { from: "verify", on: "pass", to: "review", maxVisits: null }, { from: "verify", on: "fail", to: "implement", maxVisits: 3 }, ], instructions: [{ id: "implement", digest: "sha256:8d1db5c7cbc11075fccc565180374e1929a8cf22683cbca3dde9b77ef667c0a8", byteLength: 114 }, { id: "review", digest: "sha256:53ca65be9491688119b5cbf2443d0b9aff003764db9e91657ec54dbae12d76aa", byteLength: 119 }] }); diff --git a/src/loops/run/production-runner.mjs b/src/loops/run/production-runner.mjs new file mode 100644 index 00000000..5cfd5156 --- /dev/null +++ b/src/loops/run/production-runner.mjs @@ -0,0 +1,123 @@ +import { createNormalizedInvocation } from "../adapters/normalized-invocation.mjs"; +import { createHostExecutionReport, validateHostExecutionEnvelope } from "../contracts/host-execution.mjs"; +import { boundPolicyRevision, loadBoundPolicy } from "./run-artifacts.mjs"; +import { createRunRunner } from "./runner.mjs"; +import { deriveCandidate } from "./candidate.mjs"; +import { ownerClaimId } from "./run-claim.mjs"; +import { prepareHostClaim } from "./host-execution.mjs"; +import { loadFrozenRecipe } from "../dsl/frozen.mjs"; + +const fail = (message) => { + throw Object.assign(new Error(`Loop Run binder: ${message}`), { code: "ELOOP_RUN_BINDING" }); +}; + +export function createBoundNormalizedInvocationImpl({ repoRoot, replay, contextFor, candidateForBoundary = null, + startAgent, runCheck, agentTimeoutMs = 0 }) { + if (typeof repoRoot !== "string" || !replay?.projection?.assignmentId || !replay?.frozenRecipe?.ir + || typeof replay.itemText !== "string" || !replay.itemText + || !Buffer.isBuffer(replay.policyBytes) || typeof contextFor !== "function") fail("invalid production invocation input"); + const policy = loadBoundPolicy(replay.policyBytes).policy; + const route = (name) => policy.routes.find((entry) => entry.route === name); + const implementation = route("implementation.standard"), review = route("review.strong"); + if (!implementation || !review) fail("frozen Stage One routes are unavailable"); + const nodes = new Map(replay.frozenRecipe.ir.nodes.map((node) => [node.id, node])); + return createNormalizedInvocation({ repoRoot, nodes, + routes: { implementation: { profile: implementation.profile }, review: { profile: review.profile } }, + bindingFor(invocation, node) { + const context = contextFor(invocation, node), instruction = replay.frozenRecipe.instructions + .find((item) => item.id === node.instructions); + if (!context || node.kind === "agent" && !instruction) fail("frozen invocation context is unavailable"); + return { claimId: context.claimId, assignmentId: replay.projection.assignmentId, + recipeRevision: replay.frozenRecipe.revisions.executable, policyRevision: boundPolicyRevision(policy), + inputCandidate: context.inputCandidate, instructionBytes: instruction + ? Buffer.from(instruction.base64, "base64").toString("utf8") : "Run the frozen trusted capability.\n", + itemText: replay.itemText, candidateContext: context.candidateContext, + reviewerEvidence: context.reviewerEvidence ?? [] }; + }, candidateForBoundary, startAgent, runCheck, agentTimeoutMs }); +} + +export function createProductionRunRunnerImpl({ repoRoot, store, runId, authority, contextFor, + startAgent, runCheck, agentTimeoutMs = 0, binding }) { + if (authority?.schema === "burnlist-loop-m12-run-authority@1") authority = binding.unseal(authority); + if (!store?.replay || !authority?.assignmentId || !Buffer.isBuffer(authority.frozenRecipeBytes) + || !Buffer.isBuffer(authority.policyBytes)) fail("invalid production runner authority"); + const frozenRecipe = loadFrozenRecipe(authority.frozenRecipeBytes); + const replay = { projection: { assignmentId: authority.assignmentId }, frozenRecipe, + policyBytes: authority.policyBytes, itemText: authority.itemText }; + const liveContext = (invocation, node) => { + const execution = store.replay(runId).execution; + const candidate = execution.candidate ?? deriveCandidate({ repoRoot }); + const check = frozenRecipe.ir.nodes.filter((item) => item.kind === "check") + .map((item) => execution.evidence[item.id]) + .find((item) => item?.kind === "pass" && item.candidateId === candidate.id && item.cycle === execution.cycle); + const reviewerEvidence = node.mode === "review" + ? check?.kind === "pass" && check.candidateId === candidate.id && execution.latest.check?.candidateId === candidate.id + ? [`trusted-check candidate=${candidate.id} summary=${execution.latest.check.summary}`] : [] + : []; + return { claimId: ownerClaimId({ runId: invocation.runId, nodeId: invocation.nodeId, attempt: invocation.attempt, + assignmentId: authority.assignmentId, inputCandidate: candidate.id }), inputCandidate: candidate.id, + candidateContext: candidate.context, reviewerEvidence }; + }; + const dispatch = createBoundNormalizedInvocationImpl({ repoRoot, replay, contextFor: contextFor ?? liveContext, + candidateForBoundary: () => deriveCandidate({ repoRoot }), startAgent, runCheck, agentTimeoutMs }); + const launchReplay = () => ({ ...replay, projection: { ...replay.projection, + itemRef: authority.itemRef, itemRevision: authority.itemRevision }, + boundPolicy: loadBoundPolicy(authority.policyBytes).policy }); + const invoke = async (invocation) => { + const captured = binding.capture({ repoRoot, replay: launchReplay() }); + binding.recheck(captured); const held = binding.hold(captured); + try { binding.recheck(captured); return await dispatch(invocation); } + finally { binding.release(held); } + }; + const preparedReplay = () => ({ ...store.replay(runId), frozenRecipe, policyBytes: authority.policyBytes, + itemText: authority.itemText, projection: { ...store.replay(runId).projection, assignmentId: authority.assignmentId, + itemRef: authority.itemRef, itemRevision: authority.itemRevision } }); + let managedPauseRequested = false; + const executePreparedAgent = async ({ lease }) => { + let held = null; + try { + const captured = binding.capture({ repoRoot, replay: launchReplay() }); + binding.recheck(captured); held = binding.hold(captured); binding.recheck(captured); + const prepared = prepareHostClaim({ repoRoot, replay: preparedReplay(), authority: binding.seal(runId, authority) }); + const bound = store.bindExternalClaim(runId, lease, prepared); + const result = await dispatch.invokePrepared(bound.envelope); + const envelope = validateHostExecutionEnvelope(bound.envelope); + if (managedPauseRequested && result.kind === "cancelled") { + managedPauseRequested = false; + store.resolveExternalClaim(runId, lease, { claimId: envelope.value.claimId, invocationId: envelope.value.invocationId, reason: "paused" }); + return { released: false }; + } + if (!["complete", "approve", "reject", "escalate"].includes(result.kind)) { + const kind = result.kind === "cancelled" ? "cancelled" : result.kind === "timeout" ? "timeout" : result.kind === "lost" ? "lost" : "error"; + store.terminalize(runId, lease, kind, result.summary); + return; + } + const report = createHostExecutionReport({ schema: "burnlist-loop-host-report@1", result: { + schema: "agent-result@1", runId: envelope.value.runId, nodeId: envelope.value.nodeId, attempt: envelope.value.attempt, + claimId: envelope.value.claimId, assignmentId: envelope.value.assignmentId, invocationId: envelope.value.invocationId, + recipeRevision: envelope.value.recipeRevision, policyRevision: envelope.value.policyRevision, + inputCandidate: envelope.value.inputCandidate, outcome: result.kind, + findings: result.findings ?? [], resolvedFindingIds: result.resolvedFindingIds ?? [], + }, telemetry: result.telemetry ?? null }, { + envelope, mode: preparedReplay().execution.node.mode, + openFindings: preparedReplay().execution.node.mode === "review" + ? preparedReplay().execution.openFindings : new Map(), + }); + store.acceptExternalReport(runId, lease, report.bytes, () => deriveCandidate({ repoRoot })); + return { released: true }; + } catch (error) { + const current = store.replay(runId); + if (!current.execution.terminal && current.execution.lease) + store.terminalize(runId, lease, "error", String(error?.message ?? "managed claim failed")); + else throw error; + } finally { if (held) binding.release(held); } + }; + Object.defineProperty(executePreparedAgent, "cancel", { value: () => { + managedPauseRequested = true; return dispatch.cancel?.() === true; + }, enumerable: false }); + let preparedClaimsAvailable = false; + try { store.readAuthority?.(runId); preparedClaimsAvailable = true; } catch { /* legacy in-memory fixture */ } + return createRunRunner({ store, runId, invoke, executePreparedAgent: preparedClaimsAvailable ? executePreparedAgent : null, bindCandidate() { + const candidate = deriveCandidate({ repoRoot }); return { candidateId: candidate.id, candidateContext: candidate.context }; + } }); +} diff --git a/src/loops/run/read-projection.mjs b/src/loops/run/read-projection.mjs index f76ec38f..2d4a7795 100644 --- a/src/loops/run/read-projection.mjs +++ b/src/loops/run/read-projection.mjs @@ -7,6 +7,7 @@ import { locateItemSpan, validateAssignedItem } from "../assignment/item-metadat import { assignmentStore } from "../assignment/store.mjs"; import { currentRunAuthority } from "./current-authority.mjs"; import { runStore } from "./run-store.mjs"; +import { projectRunActivity } from "../events/activity-projection.mjs"; const MAX_RUNS = 128; const fail = (message) => { throw Object.assign(new Error(`Run projection: ${message}`), { code: "ERUN_PROJECTION" }); }; @@ -19,6 +20,8 @@ function publicNode(node, routes = []) { ...common, role: node.role, authority: node.authority, + executionMode: node.execution, + intelligence: node.intelligence, execution: resolved ? { profileId: resolved.profileId, model: resolved.model, @@ -47,12 +50,16 @@ export function presentRun(replay) { const transitions = []; for (const record of records) { const { sequence, type, payload } = record.value; - if (type === "invocation-result") latestResult = { + if (type === "invocation-result" || type === "external-report-accepted") latestResult = { kind: payload.kind, summary: payload.summary, }; if (type === "edge-taken") transitions.push({ sequence, from: payload.from, outcome: payload.on, to: payload.to }); if (type === "state-changed") transitions.push({ sequence, from: payload.from, outcome: payload.cause, to: payload.to }); } + const declaredMode = replay.execution.node?.execution; + const executionMode = replay.execution.started + ? declaredMode === "managed" ? "managed" : declaredMode === "host" ? "host-reported" : "unavailable" + : "unavailable"; return Object.freeze({ schema: "burnlist-loop-read-projection@1", runId: replay.projection.runId, @@ -65,6 +72,15 @@ export function presentRun(replay) { currentNode: replay.projection.currentNode, attempt: replay.projection.attempt, cycle: replay.execution.cycle, + execution: { + mode: executionMode, + started: replay.execution.started, + usage: replay.execution.telemetry + && (replay.execution.telemetry.inputTokens !== null || replay.execution.telemetry.outputTokens !== null) + ? "reported" : "unavailable", + telemetry: replay.execution.telemetry, + }, + activity: projectRunActivity({ runId: replay.projection.runId, graph: replay.graph, journal: records }), latestResult, latestMaker: replay.projection.latestMaker, latestCheck: replay.projection.latestCheck, diff --git a/src/loops/run/run-claim.mjs b/src/loops/run/run-claim.mjs index 1dc2c7ed..3c06be04 100644 --- a/src/loops/run/run-claim.mjs +++ b/src/loops/run/run-claim.mjs @@ -1,9 +1,40 @@ -import { prefixed } from "../dsl/hash.mjs"; +import { prefixed, rawSha256 } from "../dsl/hash.mjs"; +import { parseBoundedObject } from "../contracts/contract.mjs"; import { RUN_ID, fail } from "./run-codec.mjs"; const SLUG = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u; const ASSIGNMENT = /^as1-sha256:[a-f0-9]{64}$/u; const CANDIDATE = /^cm1-sha256:[a-f0-9]{64}$/u; +const CLAIM = /^cl1-sha256:[a-f0-9]{64}$/u; +const DIGEST = /^sha256:[a-f0-9]{64}$/u; +const HOST_CLAIM_KEYS = ["schema", "runId", "claimId", "nodeId", "attempt", "assignmentId", "inputCandidate", "executionDigest", "expiresAt"]; +const ABANDONMENT_KEYS = [...HOST_CLAIM_KEYS, "reason"]; +const ABANDONMENT_REASONS = new Set(["host-cancelled", "host-lost", "expired"]); +const MAX_EXPIRES_AT = 8_640_000_000_000_000; +const MAX_CLAIM_BYTES = 16_384; + +export const HOST_CLAIM_SCHEMA = "burnlist-loop-host-claim@1"; +export const HOST_CLAIM_ABANDONMENT_SCHEMA = "burnlist-loop-host-claim-abandonment@1"; +export const HOST_CLAIM_MAX_BYTES = MAX_CLAIM_BYTES; +export const HOST_CLAIM_ABANDONMENT_REASONS = Object.freeze(["host-cancelled", "host-lost", "expired"]); +export const HOST_CLAIM_MAX_EXPIRES_AT_MILLISECONDS = MAX_EXPIRES_AT; + +function exact(value, keys) { + return Boolean(value) && typeof value === "object" && !Array.isArray(value) + && Object.getPrototypeOf(value) === Object.prototype && Object.keys(value).length === keys.length + && keys.every((key, index) => Object.keys(value)[index] === key); +} + +function validExpiry(value) { return Number.isSafeInteger(value) && value >= 0 && value <= MAX_EXPIRES_AT; } + +function claimFields(value, schema, keys) { + if (!exact(value, keys) || value.schema !== schema || !RUN_ID.test(value.runId) || !CLAIM.test(value.claimId) + || !SLUG.test(value.nodeId) || !Number.isInteger(value.attempt) || value.attempt < 1 || value.attempt > 100 + || !ASSIGNMENT.test(value.assignmentId) || !CANDIDATE.test(value.inputCandidate) || !DIGEST.test(value.executionDigest) + || !validExpiry(value.expiresAt) + || value.claimId !== ownerClaimId(value)) fail("invalid host claim"); + return value; +} export function ownerClaimId({ runId, nodeId, attempt, assignmentId, inputCandidate }) { if (!RUN_ID.test(runId) || !SLUG.test(nodeId) || !Number.isInteger(attempt) || attempt < 1 || attempt > 100 @@ -17,3 +48,59 @@ export function createOwnerClaim(value) { return Object.freeze({ schema: "burnlist-loop-owner-claim@1", runId: value.runId, claimId, nodeId: value.nodeId, attempt: value.attempt, assignmentId: value.assignmentId, inputCandidate: value.inputCandidate }); } + +/** A host-issued lease. `expiresAt` is supplied by the controller; this module never reads a clock. */ +export function createHostClaim(value) { + const claimId = ownerClaimId(value); + if (value.claimId !== undefined && value.claimId !== claimId) fail("fabricated host claim id"); + const claim = { schema: HOST_CLAIM_SCHEMA, runId: value.runId, claimId, nodeId: value.nodeId, + attempt: value.attempt, assignmentId: value.assignmentId, inputCandidate: value.inputCandidate, + executionDigest: value.executionDigest, expiresAt: value.expiresAt }; + return Object.freeze(claimFields(claim, HOST_CLAIM_SCHEMA, HOST_CLAIM_KEYS)); +} + +export function validateHostClaim(value) { + const claim = claimFields(value, HOST_CLAIM_SCHEMA, HOST_CLAIM_KEYS); + return Object.freeze({ schema: claim.schema, runId: claim.runId, claimId: claim.claimId, nodeId: claim.nodeId, + attempt: claim.attempt, assignmentId: claim.assignmentId, inputCandidate: claim.inputCandidate, + executionDigest: claim.executionDigest, expiresAt: claim.expiresAt }); +} + +export function hostClaimExpired(value, now) { + if (!validExpiry(now)) fail("invalid host claim clock"); + return now >= validateHostClaim(value).expiresAt; +} + +export function createHostClaimAbandonment(value) { + const claim = createHostClaim(value); + const abandonment = { ...claim, schema: HOST_CLAIM_ABANDONMENT_SCHEMA, reason: value.reason }; + return validateHostClaimAbandonment(abandonment); +} + +export function validateHostClaimAbandonment(value) { + const abandonment = claimFields(value, HOST_CLAIM_ABANDONMENT_SCHEMA, ABANDONMENT_KEYS); + if (!ABANDONMENT_REASONS.has(abandonment.reason)) fail("invalid host claim abandonment reason"); + return Object.freeze({ schema: abandonment.schema, runId: abandonment.runId, claimId: abandonment.claimId, + nodeId: abandonment.nodeId, attempt: abandonment.attempt, assignmentId: abandonment.assignmentId, + inputCandidate: abandonment.inputCandidate, executionDigest: abandonment.executionDigest, + expiresAt: abandonment.expiresAt, reason: abandonment.reason }); +} + +function canonical(value) { + const claim = validateHostClaim(value); + return Buffer.from(`${JSON.stringify(claim)}\n`, "utf8"); +} + +export function createHostClaimDocument(value) { + const claim = createHostClaim(value), bytes = canonical(claim); + if (bytes.length > MAX_CLAIM_BYTES) fail("host claim exceeds bounds"); + return Object.freeze({ value: claim, bytes, digest: rawSha256(bytes) }); +} + +export function parseHostClaim(bytes) { + const raw = Buffer.from(bytes); + const value = parseBoundedObject(raw, { maximumBytes: MAX_CLAIM_BYTES, maximumDepth: 1, label: "host claim" }); + const built = createHostClaimDocument(value); + if (!built.bytes.equals(raw)) fail("host claim is not canonical"); + return built; +} diff --git a/src/loops/run/run-claim.test.mjs b/src/loops/run/run-claim.test.mjs new file mode 100644 index 00000000..3b0f2dc6 --- /dev/null +++ b/src/loops/run/run-claim.test.mjs @@ -0,0 +1,38 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createHostClaim, createHostClaimAbandonment, createHostClaimDocument, createOwnerClaim, hostClaimExpired, + HOST_CLAIM_ABANDONMENT_REASONS, parseHostClaim, validateHostClaim, validateHostClaimAbandonment } from "./run-claim.mjs"; + +const identity = { + runId: "run:01j0abcdefghjkmnpqrstvwxyz", nodeId: "implement", attempt: 1, + assignmentId: `as1-sha256:${"a".repeat(64)}`, inputCandidate: `cm1-sha256:${"b".repeat(64)}`, + executionDigest: `sha256:${"c".repeat(64)}`, +}; + +test("host claim derives the compatible owner identity and has a caller-provided expiry", () => { + const claim = createHostClaim({ ...identity, expiresAt: 100 }); + assert.equal(claim.claimId, createOwnerClaim(identity).claimId); + assert.deepEqual(validateHostClaim(claim), claim); + assert.equal(hostClaimExpired(claim, 99), false); + assert.equal(hostClaimExpired(claim, 100), true); + assert.throws(() => hostClaimExpired(claim, -1), /invalid host claim clock/u); + const document = createHostClaimDocument({ ...identity, expiresAt: 100 }); + assert.equal(parseHostClaim(document.bytes).digest, document.digest); + assert.throws(() => parseHostClaim(Buffer.concat([document.bytes, Buffer.from(" ")])), /canonical/u); +}); + +test("host claims fail closed for fabricated identity, unknown fields, and invalid expiry", () => { + const claim = createHostClaim({ ...identity, expiresAt: 100 }); + assert.throws(() => createHostClaim({ ...identity, claimId: `cl1-sha256:${"c".repeat(64)}`, expiresAt: 100 }), /fabricated/u); + assert.throws(() => validateHostClaim({ ...claim, extra: true }), /invalid host claim/u); + assert.throws(() => createHostClaim({ ...identity, expiresAt: 1.5 }), /invalid host claim/u); + assert.throws(() => createHostClaim({ ...identity, executionDigest: `sha256:${"z".repeat(64)}`, expiresAt: 100 }), /invalid host claim/u); +}); + +test("host abandonment is identity-bound and has closed reasons", () => { + const abandonment = createHostClaimAbandonment({ ...identity, expiresAt: 100, reason: "host-lost" }); + assert.deepEqual(validateHostClaimAbandonment(abandonment), abandonment); + assert.deepEqual(HOST_CLAIM_ABANDONMENT_REASONS, ["host-cancelled", "host-lost", "expired"]); + assert.throws(() => createHostClaimAbandonment({ ...identity, expiresAt: 100, reason: "retry" }), /abandonment reason/u); + assert.throws(() => validateHostClaimAbandonment({ ...abandonment, reason: "expired", destination: "burn" }), /invalid host claim/u); +}); diff --git a/src/loops/run/run-journal.mjs b/src/loops/run/run-journal.mjs index f9364008..73120eae 100644 --- a/src/loops/run/run-journal.mjs +++ b/src/loops/run/run-journal.mjs @@ -4,15 +4,21 @@ import { join } from "node:path"; export const MAX_JOURNAL_RECORDS = 256; const MAX_RECORDS = MAX_JOURNAL_RECORDS, MAX_BYTES = 4 * 1024 * 1024, MAX_RECORD = 131072; -const TYPES = new Set(["run-created", "lease-acquired", "lease-released", "lease-revoked", "state-changed", "node-started", "invocation-started", "invocation-result", "candidate-bound", "system-outcome", "terminal-node-committed", "failure-routed", "edge-taken"]); -const names = { "run-created": ["schema", "runId", "itemRef", "graph", "authorityRequired"], "lease-acquired": ["generation", "token"], "lease-released": ["generation", "token"], "lease-revoked": ["generation", "token"], "state-changed": ["from", "to", "cause"], "node-started": ["nodeId", "attempt"], "invocation-started": ["nodeId", "attempt", "invocationId"], "system-outcome": ["kind", "summary"], "terminal-node-committed": ["kind", "summary", "from", "to", "nodeId", "attempt"], "invocation-result": ["invocationId", "kind", "summary", "outputBytes", "candidateId"], "candidate-bound": ["candidateId", "candidateContext"], "failure-routed": ["from", "kind", "to"], "edge-taken": ["from", "on", "to"] }; +const TYPES = new Set(["run-created", "lease-acquired", "lease-released", "lease-revoked", "state-changed", "node-started", "invocation-started", "external-claim-bound", "external-claim-resolved", "external-report-accepted", "invocation-result", "candidate-bound", "system-outcome", "terminal-node-committed", "failure-routed", "edge-taken"]); +const names = { "run-created": ["schema", "runId", "itemRef", "graph", "authorityRequired"], "lease-acquired": ["generation", "token"], "lease-released": ["generation", "token"], "lease-revoked": ["generation", "token"], "state-changed": ["from", "to", "cause"], "node-started": ["nodeId", "attempt"], "invocation-started": ["nodeId", "attempt", "invocationId"], "external-claim-bound": ["claim", "envelopeDigest", "invocationId"], "external-claim-resolved": ["claimId", "invocationId", "reason"], "external-report-accepted": ["claimId", "reportDigest", "invocationId", "kind", "summary", "outputBytes", "candidateId", "findings", "resolvedFindingIds", "telemetry"], "system-outcome": ["kind", "summary"], "terminal-node-committed": ["kind", "summary", "from", "to", "nodeId", "attempt"], "invocation-result": ["invocationId", "kind", "summary", "outputBytes", "candidateId"], "candidate-bound": ["candidateId", "candidateContext"], "failure-routed": ["from", "kind", "to"], "edge-taken": ["from", "on", "to"] }; +const legacyExternalReport = ["claimId", "reportDigest", "invocationId", "kind", "summary", "outputBytes", "candidateId"]; const fileName = (sequence) => `${String(sequence).padStart(16, "0")}.json`; const tempName = /^\.append-[a-f0-9]{16}\.tmp$/u; const fail = (message, code = "EJOURNAL") => { throw Object.assign(new Error(`Run journal: ${message}`), { code }); }; const exact = (value, keys) => Boolean(value) && typeof value === "object" && !Array.isArray(value) && Object.keys(value).length === keys.length && keys.every((key) => Object.hasOwn(value, key)); const digest = (bytes) => `sha256:${createHash("sha256").update(bytes).digest("hex")}`; -export function validateJournalEvent(type, payload) { if (!TYPES.has(type) || !exact(payload, names[type])) fail("event payload is not closed"); return payload; } +export function validateJournalEvent(type, payload) { + if (!TYPES.has(type) || !exact(payload, names[type]) + && !(type === "external-report-accepted" && exact(payload, legacyExternalReport))) + fail("event payload is not closed"); + return payload; +} export function createJournalRecord({ sequence, prevDigest, at, type, payload }) { if (!Number.isSafeInteger(sequence) || sequence < 1 || !Number.isSafeInteger(at) || at < 0 || !(prevDigest === null || /^sha256:[a-f0-9]{64}$/u.test(prevDigest))) fail("invalid record header"); validateJournalEvent(type, payload); diff --git a/src/loops/run/run-store.mjs b/src/loops/run/run-store.mjs index a6c45c67..667b463f 100644 --- a/src/loops/run/run-store.mjs +++ b/src/loops/run/run-store.mjs @@ -1,5 +1,5 @@ import { randomBytes } from "node:crypto"; -import { closeSync, constants, existsSync, fsyncSync, fstatSync, lstatSync, mkdirSync, openSync, readSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs"; +import { closeSync, constants, existsSync, fsyncSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs"; import { join, resolve } from "node:path"; import { withDirectoryLock } from "../../server/dir-lock.mjs"; import { isRunRef } from "./run-ref.mjs"; @@ -11,12 +11,21 @@ import { publishLoopProjectionInvalidation } from "../events/projection-events.m import { currentRunAuthority } from "./current-authority.mjs"; import { loadFrozenRecipe } from "../dsl/frozen.mjs"; import { loadBoundPolicy } from "./run-artifacts.mjs"; +import { createHostClaimAbandonment, hostClaimExpired, validateHostClaim } from "./run-claim.mjs"; +import { validateHostExecutionEnvelope, validateHostExecutionReport } from "../contracts/host-execution.mjs"; const fail = (message, code = "ERUN_STORE") => { throw Object.assign(new Error(`Run store: ${message}`), { code }); }; const runName = (id) => Buffer.from(id).toString("hex"); -export function runStore(repoRoot, { clock = () => Date.now(), random = randomBytes, hooks = {}, publishProjection = publishLoopProjectionInvalidation } = {}) { +export function runStore(repoRoot, { clock = () => Date.now(), random = randomBytes, hooks = {}, publishProjection = publishLoopProjectionInvalidation, journalMaximum = MAX_JOURNAL_RECORDS } = {}) { const root = resolve(repoRoot), base = join(root, ".local", "burnlist", "loop", "m2"), runs = join(base, "runs"), now = () => { const value = clock(); if (!Number.isSafeInteger(value) || value < 0) fail("invalid clock"); return value; }; - const pathFor = (id) => join(runs, runName(id)), journalFor = (id) => join(pathFor(id), "journal"), lockFor = (id) => join(pathFor(id), ".lock"), proofPath = (id) => join(pathFor(id), ".recovery-proof"), authorityPath = (id) => join(pathFor(id), "dispatch-authority.json"), currentLock = join(base, ".current-runs.lock"), initialize = () => mkdirSync(runs, { recursive: true, mode: 0o700 }), currentAuthority = () => currentRunAuthority({ root, base, random }); + if (!Number.isInteger(journalMaximum) || journalMaximum < 2 || journalMaximum > MAX_JOURNAL_RECORDS) fail("invalid journal maximum", "EJOURNAL"); + const pathFor = (id) => join(runs, runName(id)), journalFor = (id) => join(pathFor(id), "journal"), lockFor = (id) => join(pathFor(id), ".lock"), proofPath = (id) => join(pathFor(id), ".recovery-proof"), authorityPath = (id) => join(pathFor(id), "dispatch-authority.json"), executionRoot = (id) => join(pathFor(id), "host-executions"), executionPath = (id, digest) => { + if (!/^sha256:[a-f0-9]{64}$/u.test(digest)) fail("external claim envelope digest is invalid", "ECLAIM"); + return join(executionRoot(id), `${digest.slice(7)}.json`); + }, reportRoot = (id) => join(pathFor(id), "host-reports"), reportPath = (id, digest) => { + if (!/^sha256:[a-f0-9]{64}$/u.test(digest)) fail("external report digest is invalid", "EREPORT"); + return join(reportRoot(id), `${digest.slice(7)}.json`); + }, currentLock = join(base, ".current-runs.lock"), initialize = () => mkdirSync(runs, { recursive: true, mode: 0o700 }), currentAuthority = () => currentRunAuthority({ root, base, random }); const assertId = (id) => { if (!isRunRef(id)) fail("invalid RunRef"); return id; }; const locked = (id, fn) => { assertId(id); initialize(); return withDirectoryLock({ lockPath: lockFor(id), reclaimLiveAfterAge: false, errorFactory: () => fail("run is locked", "ELOCKED"), fn }); }; const replay = (id) => { @@ -48,7 +57,7 @@ export function runStore(repoRoot, { clock = () => Date.now(), random = randomBy } else if (journal[0].value.payload.authorityRequired) fail("sealed dispatch authority is unavailable", "EAUTHORITY"); return Object.freeze({ runId: id, journal, loopIdentity, agentRoutes, ...folded }); }; - const retainsTerminalReserve = (current, writes = 1) => current.projection.sequence + writes < MAX_JOURNAL_RECORDS; + const retainsTerminalReserve = (current, writes = 1) => current.projection.sequence + writes < journalMaximum; const terminalKind = { converged: "converged", "needs-human": "lost", failed: "error", stopped: "cancelled", "budget-exhausted": "exhausted" }; function prospective(id, current, type, payload, at = now()) { if (!retainsTerminalReserve(current)) fail("journal terminal reserve is required", "EJOURNAL"); @@ -63,6 +72,40 @@ export function runStore(repoRoot, { clock = () => Date.now(), random = randomBy function writeRecoveryProof(id, value) { const checked = { schema: "burnlist-loop-m2-recovery-proof@1", runId: id, generation: value.generation, token: value.token, recoveryProof: value.recoveryProof }, bytes = Buffer.from(`${JSON.stringify(checked)}\n`), temporary = `${proofPath(id)}.${random(8).toString("hex")}.tmp`; if (bytes.length > 1024) fail("recovery proof exceeds bounds"); let fd; try { fd = openSync(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW ?? 0), 0o600); writeFileSync(fd, bytes); fsyncSync(fd); closeSync(fd); fd = undefined; renameSync(temporary, proofPath(id)); syncParent(id); } finally { if (fd !== undefined) closeSync(fd); rmSync(temporary, { force: true }); } } function readRecoveryProof(id) { let fd; try { const path = proofPath(id), entry = lstatSync(path); if (!entry.isFile() || entry.isSymbolicLink()) fail("recovery proof is corrupt"); fd = openSync(path, constants.O_RDONLY | constants.O_NONBLOCK | (constants.O_NOFOLLOW ?? 0)); const before = fstatSync(fd); if (!before.isFile() || (before.mode & 0o777) !== 0o600 || before.size < 2 || before.size > 1024) fail("recovery proof is corrupt"); const bytes = Buffer.alloc(before.size); if (readSync(fd, bytes, 0, bytes.length, 0) !== bytes.length) fail("recovery proof changed while reading"); const after = fstatSync(fd); if (!after.isFile() || before.dev !== after.dev || before.ino !== after.ino || before.size !== after.size) fail("recovery proof changed while reading"); const value = JSON.parse(bytes.toString("utf8")); if (!value || Object.keys(value).length !== 5 || value.schema !== "burnlist-loop-m2-recovery-proof@1" || value.runId !== id || !Number.isSafeInteger(value.generation) || !/^[a-f0-9]{64}$/u.test(value.token) || !/^[a-f0-9]{64}$/u.test(value.recoveryProof)) fail("recovery proof is corrupt"); return value; } catch (error) { if (error?.code === "ENOENT") fail("lost-owner proof is unavailable", "ELOST_PROOF"); throw error; } finally { if (fd !== undefined) closeSync(fd); } } function clearRecoveryProof(id) { rmSync(proofPath(id), { force: true }); syncParent(id); } + function writeExecution(id, envelope) { + const directory = executionRoot(id), target = executionPath(id, envelope.digest), temporary = `${target}.${random(8).toString("hex")}.tmp`; + mkdirSync(directory, { recursive: true, mode: 0o700 }); + if (existsSync(target)) { + const existing = readExecution(id, envelope.digest); + if (!existing.bytes.equals(envelope.bytes)) fail("external claim envelope differs", "ECLAIM"); + return existing; + } + let fd; + try { fd = openSync(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW ?? 0), 0o600); writeFileSync(fd, envelope.bytes); fsyncSync(fd); closeSync(fd); fd = undefined; renameSync(temporary, target); syncParent(id); return envelope; } + finally { if (fd !== undefined) closeSync(fd); rmSync(temporary, { force: true }); } + } + function readExecution(id, digest) { + try { + const target = executionPath(id, digest), entry = lstatSync(target); + if (!entry.isFile() || entry.isSymbolicLink() || (entry.mode & 0o777) !== 0o600 || entry.size < 2 || entry.size > 400_000) fail("external claim envelope is corrupt", "ECLAIM"); + const envelope = validateHostExecutionEnvelope(readFileSync(target)); + if (envelope.bytes.length !== entry.size || envelope.digest !== digest) fail("external claim envelope changed", "ECLAIM"); + return envelope; + } catch (error) { if (error?.code === "ENOENT") fail("external claim envelope is unavailable", "ECLAIM"); if (error?.code === "ECLAIM") throw error; fail("external claim envelope is corrupt", "ECLAIM"); } + } + function writeReport(id, report) { + const directory = reportRoot(id), target = reportPath(id, report.digest), temporary = `${target}.${random(8).toString("hex")}.tmp`; + mkdirSync(directory, { recursive: true, mode: 0o700 }); + if (existsSync(target)) { + const entry = lstatSync(target); + if (!entry.isFile() || entry.isSymbolicLink() || (entry.mode & 0o777) !== 0o600 + || entry.size !== report.bytes.length || !readFileSync(target).equals(report.bytes)) fail("external report differs", "EREPORT"); + return; + } + let fd; + try { fd = openSync(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW ?? 0), 0o600); writeFileSync(fd, report.bytes); fsyncSync(fd); closeSync(fd); fd = undefined; renameSync(temporary, target); syncDirectory(directory); } + finally { if (fd !== undefined) closeSync(fd); rmSync(temporary, { force: true }); } + } function sealedAuthority(id, value) { const keys = ["schema", "runId", "assignmentId", "itemRef", "itemRevision", "itemText", "frozenRecipe", "policy"]; if (!value || typeof value !== "object" || Array.isArray(value) || Object.keys(value).length !== keys.length || !keys.every((key, index) => Object.keys(value)[index] === key) @@ -103,12 +146,12 @@ export function runStore(repoRoot, { clock = () => Date.now(), random = randomBy finally { if (fd !== undefined) closeSync(fd); } } function terminalizeCurrent(id, current, { kind = "exhausted", summary = "journal" } = {}, at = now()) { - if (current.projection.sequence >= MAX_JOURNAL_RECORDS) fail("journal has no terminal capacity", "EJOURNAL"); + if (current.projection.sequence >= journalMaximum) fail("journal has no terminal capacity", "EJOURNAL"); const selected = current.execution.system ?? (isTerminalState(current.projection.state) ? { kind: terminalKind[current.projection.state], summary: "journal-cleanup" } : { kind, summary }), targetState = isTerminalState(current.projection.state) ? current.projection.state : atomicTerminalState(selected.kind), targetNode = selected.kind === "converged" ? current.graph.nodes.find((node) => node.kind === "terminal" && node.state === "converged")?.id : current.graph.failurePolicy[selected.kind], alreadyStarted = current.execution.nodeId === targetNode && current.execution.started, attempt = alreadyStarted ? current.execution.attempts[targetNode] : (current.execution.attempts[targetNode] ?? 0) + 1, record = createJournalRecord({ sequence: current.projection.sequence + 1, prevDigest: current.projection.journalDigest, at, type: "terminal-node-committed", payload: { kind: selected.kind, summary: selected.summary, from: current.projection.state, to: targetState, nodeId: targetNode, attempt } }); foldRun([...current.journal, record]); appendJournalRecord({ journalDirectory: journalFor(id), record }); clearRecoveryProof(id); return Object.freeze({ record, ...replay(id) }); } - const append = (id, lease, type, payload) => locked(id, () => { const current = replay(id); assertLease(current, lease); const at = now(); if (current.projection.sequence >= MAX_JOURNAL_RECORDS) fail("journal has no terminal capacity", "EJOURNAL"); const candidate = createJournalRecord({ sequence: current.projection.sequence + 1, prevDigest: current.projection.journalDigest, at, type, payload }), folded = foldRun([...current.journal, candidate]); if (type === "state-changed" && isTerminalState(payload.to)) return terminalizeCurrent(id, current, { kind: terminalKind[payload.to], summary: payload.cause }, at); if (!retainsTerminalReserve(current)) return terminalizeCurrent(id, current, {}, at); if (["node-started", "invocation-started", "invocation-result", "edge-taken"].includes(type) && folded.execution.budget.elapsedMilliseconds >= current.graph.budget.maxMinutes * 60_000) return terminalizeCurrent(id, current, { summary: "minutes" }, at); appendJournalRecord({ journalDirectory: journalFor(id), record: candidate }); return Object.freeze({ record: candidate, ...replay(id) }); }); + const append = (id, lease, type, payload) => locked(id, () => { const current = replay(id); assertLease(current, lease); const at = now(); if (current.projection.sequence >= journalMaximum) fail("journal has no terminal capacity", "EJOURNAL"); const candidate = createJournalRecord({ sequence: current.projection.sequence + 1, prevDigest: current.projection.journalDigest, at, type, payload }), folded = foldRun([...current.journal, candidate]); if (type === "state-changed" && isTerminalState(payload.to)) return terminalizeCurrent(id, current, { kind: terminalKind[payload.to], summary: payload.cause }, at); if (!retainsTerminalReserve(current)) return terminalizeCurrent(id, current, {}, at); if (["node-started", "invocation-started", "invocation-result", "edge-taken"].includes(type) && folded.execution.budget.elapsedMilliseconds >= current.graph.budget.maxMinutes * 60_000) return terminalizeCurrent(id, current, { summary: "minutes" }, at); appendJournalRecord({ journalDirectory: journalFor(id), record: candidate }); return Object.freeze({ record: candidate, ...replay(id) }); }); const acquireLease = (id) => locked(id, () => { let current = replay(id); if (current.execution.lease) fail("run already has a lease", "ELEASED"); if (isTerminalState(current.projection.state)) fail("run is terminal"); const writes = ["prepared", "paused"].includes(current.projection.state) ? 2 : 1; if (!retainsTerminalReserve(current, writes)) return terminalizeCurrent(id, current); @@ -118,7 +161,125 @@ export function runStore(repoRoot, { clock = () => Date.now(), random = randomBy const releaseLease = (id, lease) => locked(id, () => { const current = replay(id); assertLease(current, lease); if (!retainsTerminalReserve(current)) return terminalizeCurrent(id, current); const result = prospective(id, current, "lease-released", { generation: lease?.generation, token: lease?.token }); clearRecoveryProof(id); return result; }); const recoverLease = (id, proof) => locked(id, () => { const current = replay(id), expected = readRecoveryProof(id); if (!proof || proof.generation !== expected.generation || proof.recoveryProof !== expected.recoveryProof) fail("lost-owner proof is invalid", "ELOST_PROOF"); const held = current.execution.lease; if (!held || held.generation !== proof.generation || held.token !== expected.token) fail("owner generation changed", "ESTALE_LEASE"); if (!retainsTerminalReserve(current)) return terminalizeCurrent(id, current); const result = prospective(id, current, "lease-revoked", { generation: held.generation, token: held.token }); clearRecoveryProof(id); return result; }); const terminalize = (id, lease, kind, summary) => locked(id, () => { const current = replay(id); assertLease(current, lease); return terminalizeCurrent(id, current, { kind, summary }); }); - function createRun({ runId, itemRef, graph, authority = null }) { + const bindExternalClaim = (id, lease, { claim, envelope }) => locked(id, () => { + const current = replay(id); assertLease(current, lease); + if (current.execution.externalClaim) return readExternalClaim(id, current); + if (current.execution.terminal || current.execution.started || current.execution.invocation || current.execution.node?.kind !== "agent") fail("current node is not an unstarted agent", "ECLAIM"); + const checkedClaim = validateHostClaim(claim), checkedEnvelope = validateHostExecutionEnvelope(envelope), authority = readAuthority(id), issued = now(); + const frozen = loadFrozenRecipe(Buffer.from(authority.frozenRecipe, "base64")), policy = loadBoundPolicy(Buffer.from(authority.policy, "base64")); + if (hostClaimExpired(checkedClaim, issued) || checkedEnvelope.value.expiresAt <= issued || checkedClaim.executionDigest !== checkedEnvelope.digest + || checkedClaim.runId !== id || checkedClaim.nodeId !== current.execution.nodeId || checkedClaim.attempt !== current.execution.attempt + 1 + || checkedClaim.assignmentId !== authority.assignmentId || checkedClaim.claimId !== checkedEnvelope.value.claimId + || checkedClaim.assignmentId !== checkedEnvelope.value.assignmentId || checkedClaim.inputCandidate !== checkedEnvelope.value.inputCandidate + || checkedEnvelope.value.nodeId !== checkedClaim.nodeId || checkedEnvelope.value.attempt !== checkedClaim.attempt + || checkedEnvelope.value.recipeRevision !== frozen.revisions.executable || checkedEnvelope.value.policyRevision !== policy.revision) fail("external claim does not bind the current Run", "ECLAIM"); + if (current.execution.node.mode === "review" && checkedClaim.inputCandidate !== current.execution.candidate?.id) + fail("review claim is not bound to the current candidate", "ECLAIM"); + writeExecution(id, checkedEnvelope); + // One journal record makes the host-visible claim indivisible: replay never + // observes a started agent invocation that lacks its sealed host envelope. + prospective(id, current, "external-claim-bound", { claim: checkedClaim, envelopeDigest: checkedEnvelope.digest, invocationId: checkedEnvelope.value.invocationId }); + return readExternalClaim(id, replay(id)); + }); + function readExternalClaim(id, current = replay(id)) { + const binding = current.execution.externalClaim; + if (!binding) return null; + const envelope = readExecution(id, binding.envelopeDigest); + if (envelope.digest !== binding.envelopeDigest || envelope.value.claimId !== binding.claim.claimId + || envelope.value.runId !== binding.claim.runId || envelope.value.nodeId !== binding.claim.nodeId + || envelope.value.attempt !== binding.claim.attempt || envelope.value.assignmentId !== binding.claim.assignmentId + || envelope.value.inputCandidate !== binding.claim.inputCandidate || envelope.value.expiresAt !== binding.claim.expiresAt) fail("external claim envelope does not match journal", "ECLAIM"); + return Object.freeze({ claim: binding.claim, envelope: envelope.bytes }); + } + const abandonExternalClaim = (id, lease, abandonment) => locked(id, () => { + const current = replay(id); assertLease(current, lease); const active = readExternalClaim(id, current); + if (!active) fail("external claim is unavailable", "ECLAIM"); + const checked = createHostClaimAbandonment(abandonment), claim = active.claim; + if (["runId", "claimId", "nodeId", "attempt", "assignmentId", "inputCandidate", "executionDigest", "expiresAt"].some((key) => checked[key] !== claim[key]) + || checked.reason === "expired" && !hostClaimExpired(claim, now())) fail("external claim abandonment is stale", "ECLAIM"); + return terminalizeCurrent(id, current, { kind: "lost", summary: `external claim ${checked.reason}` }); + }); + const resolveExternalClaim = (id, lease, { claimId, invocationId, reason }) => locked(id, () => { + const current = replay(id); assertLease(current, lease); + const active = readExternalClaim(id, current); + if (!active || reason !== "paused" || active.claim.claimId !== claimId + || current.execution.invocation?.invocationId !== invocationId) fail("external claim resolution is stale", "ECLAIM"); + return prospective(id, current, "external-claim-resolved", { claimId, invocationId, reason }); + }); + const acceptExternalReport = (id, lease, reportBytes, candidateForBoundary) => locked(id, () => { + const current = replay(id); + const raw = Buffer.from(reportBytes), parsed = parseBoundedObject(raw, { maximumBytes: 262_144, maximumDepth: 5, label: "host execution report" }); + const claimId = parsed?.result?.claimId; + const bindingRecord = [...current.journal].reverse().find((record) => record.value.type === "external-claim-bound" + && record.value.payload.claim.claimId === claimId); + if (!bindingRecord) fail("external report claim is unavailable", "EREPORT"); + const binding = bindingRecord.value.payload, envelope = readExecution(id, binding.envelopeDigest); + const boundary = typeof candidateForBoundary === "function" ? candidateForBoundary() : candidateForBoundary; + const taskBoundary = current.execution.node.mode === "task"; + const candidateId = boundary?.candidateId ?? (taskBoundary ? boundary?.id : null) ?? null; + const candidateContext = boundary?.candidateContext ?? (taskBoundary ? boundary?.context : null) ?? null; + const observedCandidateId = boundary?.observedCandidateId ?? (!taskBoundary ? boundary?.id : null) ?? null; + const report = validateHostExecutionReport(raw, { + envelope, mode: current.graph.nodes.find((node) => node.id === binding.claim.nodeId)?.mode, + openFindings: current.execution.node.mode === "review" ? current.execution.openFindings : new Map(), + }); + const prior = current.journal.find((record) => record.value.type === "external-report-accepted" + && record.value.payload.claimId === claimId); + if (prior && prior.value.payload.reportDigest !== report.digest) fail("external report conflicts with accepted report", "EREPORT"); + // A cut after edge-taken leaves the old lease in the journal. The exact + // report retry owns the durable transaction tail and must release it; + // returning here would fence the next node forever. + if (prior && current.execution.nodeId !== binding.claim.nodeId) { + if (current.execution.lease) { + assertLease(current, lease); + if (!retainsTerminalReserve(current)) return terminalizeCurrent(id, current, { kind: "exhausted", summary: "journal" }); + prospective(id, current, "lease-released", { generation: lease.generation, token: lease.token }); + clearRecoveryProof(id); + } + return replay(id); + } + assertLease(current, lease); + const active = prior ? null : readExternalClaim(id, current); + if (!prior && (!active || active.claim.claimId !== claimId || hostClaimExpired(active.claim, now()))) fail("external report claim is stale", "EREPORT"); + const result = report.value.result; + if (result.outcome === "complete") { + if (!/^cm1-sha256:[a-f0-9]{64}$/u.test(candidateId) || typeof candidateContext !== "string" || !candidateContext) fail("completed task candidate is unavailable", "EREPORT"); + if (observedCandidateId !== null) fail("unexpected observed report candidate", "EREPORT"); + } else { + if (candidateId !== null || candidateContext !== null) fail("unexpected report candidate", "EREPORT"); + if (current.execution.node.mode === "review") { + if (!/^cm1-sha256:[a-f0-9]{64}$/u.test(observedCandidateId) + || observedCandidateId !== binding.claim.inputCandidate + || observedCandidateId !== current.execution.candidate?.id) fail("review report candidate drifted", "EREPORT"); + } else if (observedCandidateId !== null) fail("unexpected observed report candidate", "EREPORT"); + } + // This operation is a durable tail, not a collection of independently + // optional writes. Reserve report + optional candidate + edge + release + // before committing its first semantic record. If it cannot fit, consume + // the one retained terminal record instead of stranding the host lease. + const remaining = (prior ? 0 : 1) + (result.outcome === "complete" && !current.execution.candidate ? 1 : 0) + 2; + if (!retainsTerminalReserve(current, remaining)) return terminalizeCurrent(id, current, { kind: "exhausted", summary: "journal" }); + let advanced = current; + if (!prior) { + writeReport(id, report); + advanced = prospective(id, current, "external-report-accepted", { + claimId, reportDigest: report.digest, invocationId: result.invocationId, kind: result.outcome, + summary: `host reported ${result.outcome}`, outputBytes: report.bytes.length, + candidateId: current.execution.node.mode === "task" ? null : current.execution.candidate?.id ?? null, + findings: result.findings, resolvedFindingIds: result.resolvedFindingIds, telemetry: report.value.telemetry, + }); + hooks.afterExternalReportAccepted?.({ id, lease, claimId, reportDigest: report.digest }); + } + if (result.outcome === "complete" && !advanced.execution.candidate) advanced = prospective(id, advanced, "candidate-bound", { candidateId, candidateContext }); + const edge = advanced.graph.edges.find((item) => item.from === advanced.execution.nodeId && item.on === result.outcome); + if (!edge) fail("external report outcome has no declared edge", "EREPORT"); + advanced = prospective(id, advanced, "edge-taken", { from: edge.from, on: edge.on, to: edge.to }); + hooks.afterExternalEdgeTaken?.({ id, lease, claimId, edge }); + prospective(id, advanced, "lease-released", { generation: lease.generation, token: lease.token }); + clearRecoveryProof(id); + return replay(id); + }); + function createRun({ runId, itemRef, graph, authority = null, allowSupersedeConverged = false }) { assertId(runId); if (typeof itemRef !== "string" || !itemRef || itemRef.length > 512) fail("invalid creation input"); validateGraph(graph); initialize(); const target = pathFor(runId), staging = join(runs, `.create-${random(8).toString("hex")}.tmp`); if (existsSync(target)) fail("run already exists", "EEXIST"); mkdirSync(staging, { recursive: false, mode: 0o700 }); @@ -137,7 +298,8 @@ export function runStore(repoRoot, { clock = () => Date.now(), random = randomBy // recovered only by the same sealed Run identity. } else { const prior = replay(previous.runId).projection; - if (!["failed", "stopped", "budget-exhausted", "needs-human"].includes(prior.state)) fail("current Run is still executable", "ECURRENT"); + if (!["failed", "stopped", "budget-exhausted", "needs-human"].includes(prior.state) + && !(prior.state === "converged" && allowSupersedeConverged)) fail("current Run is still executable", "ECURRENT"); currentAuthority().write([...entries.filter((entry) => entry.itemRef !== current.itemRef), { itemRef: current.itemRef, runId, assignmentId: current.assignmentId }]); } } else { @@ -151,14 +313,38 @@ export function runStore(repoRoot, { clock = () => Date.now(), random = randomBy } // Publication is observational: commit and release the journal lock before notifying readers. const published = (result) => { try { publishProjection(root, result); } catch {} return result; }; + function resolveClaimRef(claimId) { + if (!/^cl1-sha256:[a-f0-9]{64}$/u.test(claimId)) fail("invalid ClaimRef", "ECLAIM"); + if (!existsSync(runs)) fail("ClaimRef is missing", "ECLAIM"); + const entries = readdirSync(runs, { withFileTypes: true }).filter((entry) => !entry.name.startsWith(".")); + if (entries.length > 128 || entries.some((entry) => !entry.isDirectory() || !/^[a-f0-9]+$/u.test(entry.name))) fail("run directory exceeds bounds", "EBOUNDS"); + const matches = []; + for (const entry of entries) { + const runId = Buffer.from(entry.name, "hex").toString("utf8"); + if (!isRunRef(runId) || Buffer.from(runId).toString("hex") !== entry.name) fail("run directory is corrupt", "EBOUNDS"); + const current = replay(runId), bound = current.journal.some((record) => record.value.type === "external-claim-bound" + && record.value.payload.claim.claimId === claimId); + if (!bound) continue; + const accepted = current.journal.some((record) => record.value.type === "external-report-accepted" + && record.value.payload.claimId === claimId); + if (!accepted && current.execution.externalClaim?.claim.claimId !== claimId) fail("ClaimRef is stale", "ECLAIM"); + matches.push(runId); + } + if (!matches.length) fail("ClaimRef is missing", "ECLAIM"); + if (matches.length !== 1) fail("ClaimRef is ambiguous", "ECLAIM"); + return matches[0]; + } return Object.freeze({ createRun: (...input) => published(createRun(...input)), replay, read: replay, append: (...input) => published(append(...input)), acquireLease: (...input) => published(acquireLease(...input)), releaseLease: (...input) => published(releaseLease(...input)), recoverLease: (...input) => published(recoverLease(...input)), - terminalize: (...input) => published(terminalize(...input)), list: () => { + terminalize: (...input) => published(terminalize(...input)), bindExternalClaim: (...input) => published(bindExternalClaim(...input)), + readExternalClaim, abandonExternalClaim: (...input) => published(abandonExternalClaim(...input)), + resolveExternalClaim: (...input) => published(resolveExternalClaim(...input)), + acceptExternalReport: (...input) => published(acceptExternalReport(...input)), list: () => { if (!existsSync(runs)) return []; const entries = readdirSync(runs, { withFileTypes: true }), staging = entries.filter((entry) => /^\.create-[a-f0-9]{16}\.tmp$/u.test(entry.name)), visible = entries.filter((entry) => !/^\.create-[a-f0-9]{16}\.tmp$/u.test(entry.name)); if (staging.length > 128 || visible.length > 128 || entries.some((entry) => !entry.isDirectory() || !/^(?:[a-f0-9]+|\.create-[a-f0-9]{16}\.tmp)$/u.test(entry.name))) fail("run directory exceeds bounds", "EBOUNDS"); return visible.sort((a, b) => a.name.localeCompare(b.name)) .map((entry) => replay(Buffer.from(entry.name, "hex").toString("utf8")).projection); - }, readAuthority, readCurrentRun(itemRef) { if (!existsSync(base)) return null; const values = currentAuthority().read().filter((entry) => entry.itemRef === itemRef); if (values.length > 1) fail("current Run binding is ambiguous", "ECURRENT"); return values[0] ?? null; }, paths: Object.freeze({ base, runs, pathFor, journalFor, authorityPath, currentPath: join(base, "current-runs.json") }) }); + }, resolveClaimRef, readAuthority, readCurrentRun(itemRef) { if (!existsSync(base)) return null; const values = currentAuthority().read().filter((entry) => entry.itemRef === itemRef); if (values.length > 1) fail("current Run binding is ambiguous", "ECURRENT"); return values[0] ?? null; }, paths: Object.freeze({ base, runs, pathFor, journalFor, authorityPath, executionPath, reportPath, currentPath: join(base, "current-runs.json") }) }); } diff --git a/src/loops/run/run-test-fixtures.mjs b/src/loops/run/run-test-fixtures.mjs index c788b97a..4cd583cc 100644 --- a/src/loops/run/run-test-fixtures.mjs +++ b/src/loops/run/run-test-fixtures.mjs @@ -15,7 +15,10 @@ import { presentRun } from "./read-projection.mjs"; export const d = (prefix, char) => `${prefix}:${char.repeat(64)}`; export const fixtureRunId = "run:01arz3ndektsv4rrffq69g5fav"; export const fixtureItemRef = "item:260722-001#L29"; -export const m4ProgressOutcomes = ["complete", "pass", "reject", "complete", "pass", "approve"]; +export const m4ProgressOutcomes = [ + "complete", "complete", "complete", "pass", "reject", + "complete", "complete", "pass", "approve", "complete", "pass", "approve", +]; let frozenPromise; const fixtureBinary = "/bin/sh"; const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..", ".."); @@ -67,9 +70,13 @@ export async function runM4ProgressFixture({ const baseClock = clock ?? (() => at++); const store = runStore(repoRoot, { clock: baseClock }); store.createRun({ runId, itemRef, graph: frozenGraph }); - const runner = createRunRunner({ store, runId, invoke: async () => { + const runner = createRunRunner({ store, runId, invoke: async ({ nodeId }) => { const outcome = source.shift(); if (!outcome) throw new Error(`run fixture: missing outcome for ${runId}`); + const node = frozenGraph.nodes.find((item) => item.id === nodeId); + const permitted = node?.kind === "agent" ? (node.mode === "task" ? ["complete"] : ["approve", "reject", "escalate"]) + : node?.kind === "check" ? ["pass", "fail"] : []; + if (!permitted.includes(outcome)) throw new Error(`run fixture: ${outcome} is not valid for ${nodeId}`); return { kind: outcome, summary: outcome, outputBytes: 1 }; }}); const snapshots = []; @@ -125,9 +132,9 @@ const fs=require("node:fs"),a=process.argv.slice(2),prompt=a.at(-1),lines=Object let outcome="complete";const counter=process.env.BURNLIST_FAKE_COUNTER; if(process.env.BURNLIST_FAKE_STARTED){const marker=process.env.BURNLIST_FAKE_STARTED,tmp=marker+"."+process.pid+".tmp";fs.writeFileSync(tmp,JSON.stringify({pid:process.pid,node:lines.node,attempt:Number(lines.attempt)}));fs.renameSync(tmp,marker);} const wait=Number(process.env.BURNLIST_FAKE_WAIT_MS||0);if(Number.isSafeInteger(wait)&&wait>0)Atomics.wait(new Int32Array(new SharedArrayBuffer(4)),0,0,wait); -if(counter){const attempt=Number(lines.attempt),index=lines.node==="implement"?(attempt-1)*2:lines.node==="review"?(attempt-1)*2+1:Number(fs.readFileSync(counter,"utf8"));outcome=(process.env.BURNLIST_FAKE_OUTCOMES||"complete").split(",")[index]||"approve";fs.writeFileSync(counter,String(Math.max(Number(fs.readFileSync(counter,"utf8")),index+1)));} +if(counter){const index=Number(fs.readFileSync(counter,"utf8"));outcome=(process.env.BURNLIST_FAKE_OUTCOMES||"complete").split(",")[index]||"approve";fs.writeFileSync(counter,String(index+1));} if(lines.node==="implement"&&outcome==="complete")fs.writeFileSync(process.cwd()+"/src/fake-maker-candidate.txt","maker-attempt="+lines.attempt+"\\n"); -const final={schema:"burnlist.agent-final@1",runId:lines.run,nodeId:lines.node,attempt:Number(lines.attempt),claimId:lines.claim,invocationId:lines.invocation,assignmentId:lines.assignment,recipeRevision:lines.recipe,policyRevision:lines.policy,inputCandidate:lines.candidate,outcome,summary:"fake "+outcome}; +const final={schema:"burnlist.agent-final@1",runId:lines.run,nodeId:lines.node,attempt:Number(lines.attempt),claimId:lines.claim,invocationId:lines.invocation,assignmentId:lines.assignment,recipeRevision:lines.recipe,policyRevision:lines.policy,inputCandidate:lines.candidate,outcome,summary:"fake "+outcome,findings:[],resolvedFindingIds:[]}; const mode=process.env.BURNLIST_FAKE_FINAL_MODE;if(mode==="stale")final.inputCandidate="cm1-sha256:"+"f".repeat(64); process.stdout.write(JSON.stringify({type:"thread.started",thread_id:"s-"+process.pid,model:a[a.indexOf("-m")+1]})+"\\n"); process.stdout.write(JSON.stringify({type:"item.completed",item:{type:"agent_message",text:mode==="malformed"?"not-json":JSON.stringify(final)}})+"\\n"); diff --git a/src/loops/run/runner.mjs b/src/loops/run/runner.mjs index 7c3a4965..9fe9a9aa 100644 --- a/src/loops/run/runner.mjs +++ b/src/loops/run/runner.mjs @@ -11,8 +11,9 @@ function boundedSummary(value) { while (end > 0 && (bytes[end] & 0xc0) === 0x80) end -= 1; return bytes.subarray(0, end).toString("utf8"); } -export function createRunRunner({ store, runId, invoke, bindCandidate = null }) { +export function createRunRunner({ store, runId, invoke, bindCandidate = null, executePreparedAgent = null }) { if (!store?.replay || !store?.append || !store?.acquireLease || !store?.terminalize || typeof invoke !== "function") fail("invalid runner input"); + if (executePreparedAgent !== null && typeof executePreparedAgent !== "function") fail("invalid prepared agent executor"); let lease = null, pauseRequested = false, stopRequested = false, cancelRequested = false, cancelWake = null; const read = () => store.replay(runId), append = (type, payload) => store.append(runId, lease, type, payload); function transition(to, cause) { const execution = read().execution; return append("state-changed", { from: execution.state, to, cause }); } @@ -22,7 +23,18 @@ export function createRunRunner({ store, runId, invoke, bindCandidate = null }) async function step() { let current = read(), execution = current.execution; if (execution.terminal) return { kind: "terminal", state: execution.state }; if (!lease) lease = store.acquireLease(runId).lease; current = read(); execution = current.execution; if (execution.terminal) { lease = null; return { kind: "terminal", state: execution.state }; } if (execution.system) return routeSystem(current, execution); const node = execution.node; if (execution.budget.elapsedMilliseconds >= current.graph.budget.maxMinutes * 60_000) return system("exhausted", "minutes"); - if (!execution.started) { const exhausted = budgetReason({ folded: execution.budget, graph: current.graph, node }); if (exhausted) return system("exhausted", exhausted); return append("node-started", { nodeId: node.id, attempt: execution.attempt + 1 }); } + if (!execution.started) { + const exhausted = budgetReason({ folded: execution.budget, graph: current.graph, node }); if (exhausted) return system("exhausted", exhausted); + // Agent work is prepared and accepted through the same claim transaction + // as host execution. Checks and graph-only nodes retain this runner's + // existing deterministic path. + if (node.kind === "agent" && executePreparedAgent) { + const completed = await executePreparedAgent({ runId, lease, node }); + if (completed?.released) lease = null; + return { kind: "prepared-agent" }; + } + return append("node-started", { nodeId: node.id, attempt: execution.attempt + 1 }); + } if (node.kind === "terminal") return transition(node.state, "graph"); if (node.kind === "gate") return edge(execution, gateDecision(execution, current.graph)); if (execution.result) { @@ -83,7 +95,7 @@ export function createRunRunner({ store, runId, invoke, bindCandidate = null }) if (current.execution.terminal) { if (lease && current.execution.lease) store.releaseLease(runId, lease); lease = null; return read(); } if (pauseRequested) return pause(); } } - function requestPause() { pauseRequested = true; cancelRequested = true; invoke.cancel?.(); cancelWake?.(); } - function requestStop() { stopRequested = true; cancelRequested = true; invoke.cancel?.(); cancelWake?.(); } + function requestPause() { pauseRequested = true; cancelRequested = true; invoke.cancel?.(); executePreparedAgent?.cancel?.(); cancelWake?.(); } + function requestStop() { stopRequested = true; cancelRequested = true; invoke.cancel?.(); executePreparedAgent?.cancel?.(); cancelWake?.(); } return Object.freeze({ step, run, pause, stop, requestPause, requestStop, replay: read, get lease() { return lease; } }); } diff --git a/src/loops/run/state-machine.mjs b/src/loops/run/state-machine.mjs index 3457040a..fea055fe 100644 --- a/src/loops/run/state-machine.mjs +++ b/src/loops/run/state-machine.mjs @@ -1,7 +1,10 @@ -import { validateClosedIr } from "../dsl/ir-validate.mjs"; +import { validateReplayIr } from "../dsl/ir-validate.mjs"; import { isRunRef } from "./run-ref.mjs"; import { foldBudgets } from "./budgets.mjs"; import { isSystemOutcome, validateNormalizedResult } from "./run-result.mjs"; +import { validateHostClaim } from "./run-claim.mjs"; +import { nextOpenFindings, validateFindingSet } from "../contracts/finding.mjs"; +import { validateHostTelemetry } from "../contracts/host-execution.mjs"; const TERMINAL = new Set(["converged", "needs-human", "failed", "stopped", "budget-exhausted"]), SYSTEM = { error: "failed", timeout: "failed", cancelled: "stopped", lost: "needs-human", exhausted: "budget-exhausted" }, ATOMIC = { ...SYSTEM, converged: "converged" }; const exact = (value, keys) => Boolean(value) && typeof value === "object" && !Array.isArray(value) && Object.keys(value).length === keys.length && keys.every((key) => Object.hasOwn(value, key)); @@ -9,7 +12,7 @@ const fail = (message) => { throw Object.assign(new Error(`Run state machine: ${ export const isTerminalState = (state) => TERMINAL.has(state); export const systemState = (kind) => SYSTEM[kind] ?? fail("unknown system outcome"); export const atomicTerminalState = (kind) => ATOMIC[kind] ?? fail("unknown atomic terminal"); -export function validateGraph(graph) { if (!validateClosedIr(graph)) fail("graph is not canonical closed IR"); return Object.freeze({ nodes: new Map(graph.nodes.map((node) => [node.id, node])), edges: new Map(graph.edges.map((edge) => [`${edge.from}\0${edge.on}`, edge])) }); } +export function validateGraph(graph) { if (!validateReplayIr(graph)) fail("graph is not canonical closed IR"); return Object.freeze({ nodes: new Map(graph.nodes.map((node) => [node.id, node])), edges: new Map(graph.edges.map((edge) => [`${edge.from}\0${edge.on}`, edge])) }); } export function validateStateTransition(from, to, cause) { if (!(["control", "graph", "system"].includes(cause))) fail("invalid transition cause"); const control = (from === "prepared" && ["running", "stopped"].includes(to)) || (from === "running" && ["paused", "stopped"].includes(to)) || (from === "paused" && ["running", "stopped"].includes(to)); @@ -29,7 +32,7 @@ function gateOutcome(runtime, node) { export function foldStateMachine({ graph, records }) { const { nodes, edges } = validateGraph(graph), first = records[0]?.value?.payload; if (!first || first.type !== undefined && records[0].value.type !== "run-created" || !isRunRef(first.runId)) fail("invalid RunRef creation"); - const current = { state: "prepared", generation: 0, lease: null }, runtime = { nodeId: graph.entry, attempts: {}, started: false, invocation: null, result: null, system: null, cycle: 0, evidence: {}, candidate: null, latest: { maker: null, check: null, reviewer: null }, nodes, edges }; + const current = { state: "prepared", generation: 0, lease: null }, runtime = { nodeId: graph.entry, attempts: {}, started: false, invocation: null, externalClaim: null, result: null, system: null, cycle: 0, evidence: {}, candidate: null, openFindings: new Map(), telemetry: null, latest: { maker: null, check: null, reviewer: null }, nodes, edges }; for (const [index, record] of records.entries()) { const { type, payload } = record.value, node = nodes.get(runtime.nodeId); if (!index) continue; if (type === "state-changed") { if (!exact(payload, ["from", "to", "cause"]) || payload.from !== current.state) fail("invalid state event"); validateStateTransition(payload.from, payload.to, payload.cause); if (payload.cause === "control" && payload.to === "paused" && runtime.invocation && !runtime.result) runtime.invocation = null; if (payload.cause === "graph" && (!node || node.kind !== "terminal" || !runtime.started || payload.to !== node.state)) fail("graph terminal bypass"); if (payload.cause === "system" && (!runtime.system || payload.to !== systemState(runtime.system.kind))) fail("system terminal bypass"); current.state = payload.to; continue; } @@ -39,9 +42,38 @@ export function foldStateMachine({ graph, records }) { if (!current.lease || current.state !== "running") fail("active event lacks lease"); if (type === "node-started") { if (!exact(payload, ["nodeId", "attempt"]) || payload.nodeId !== runtime.nodeId || runtime.started || payload.attempt !== (runtime.attempts[payload.nodeId] ?? 0) + 1) fail("invalid node start"); runtime.started = true; runtime.attempts[payload.nodeId] = payload.attempt; if (node.kind === "agent" && node.mode === "task") { runtime.cycle += 1; runtime.candidate = null; } continue; } if (type === "invocation-started") { if (!exact(payload, ["nodeId", "attempt", "invocationId"]) || !runtime.started || runtime.invocation || !["agent", "check"].includes(node.kind) || payload.nodeId !== runtime.nodeId || payload.attempt !== runtime.attempts[payload.nodeId] || !/^[a-f0-9]{32}$/u.test(payload.invocationId)) fail("invalid invocation start"); runtime.invocation = payload; continue; } - if (type === "invocation-result") { - if (!runtime.invocation || runtime.result || !exact(payload, ["invocationId", "kind", "summary", "outputBytes", "candidateId"]) || payload.invocationId !== runtime.invocation.invocationId) fail("invalid invocation result"); + if (type === "external-claim-bound") { + let claim; try { claim = validateHostClaim(payload.claim); } catch { fail("invalid external claim"); } + if (!exact(payload, ["claim", "envelopeDigest", "invocationId"]) || runtime.started || runtime.invocation || runtime.externalClaim || runtime.result || node?.kind !== "agent" + || claim.runId !== first.runId || claim.nodeId !== runtime.nodeId || claim.attempt !== (runtime.attempts[runtime.nodeId] ?? 0) + 1 + || !/^sha256:[a-f0-9]{64}$/u.test(payload.envelopeDigest) || claim.executionDigest !== payload.envelopeDigest + || !/^iv1-sha256:[a-f0-9]{64}$/u.test(payload.invocationId)) fail("invalid external claim binding"); + runtime.started = true; runtime.attempts[runtime.nodeId] = claim.attempt; + runtime.invocation = Object.freeze({ nodeId: claim.nodeId, attempt: claim.attempt, invocationId: payload.invocationId }); + if (node.mode === "task") { runtime.cycle += 1; runtime.candidate = null; } + runtime.externalClaim = Object.freeze({ claim, envelopeDigest: payload.envelopeDigest }); continue; + } + if (type === "external-claim-resolved") { + if (!exact(payload, ["claimId", "invocationId", "reason"]) || payload.reason !== "paused" + || payload.claimId !== runtime.externalClaim?.claim.claimId || payload.invocationId !== runtime.invocation?.invocationId) + fail("invalid external claim resolution"); + runtime.started = false; runtime.invocation = null; runtime.externalClaim = null; runtime.result = null; continue; + } + if (type === "invocation-result" || type === "external-report-accepted") { + const host = type === "external-report-accepted"; + const hostKeys = ["claimId", "reportDigest", "invocationId", "kind", "summary", "outputBytes", "candidateId"]; + const currentHost = host && exact(payload, [...hostKeys, "findings", "resolvedFindingIds", "telemetry"]); + const legacyHost = host && exact(payload, hostKeys); + if (!runtime.invocation || runtime.result || !(host ? currentHost || legacyHost : exact(payload, ["invocationId", "kind", "summary", "outputBytes", "candidateId"])) + || payload.invocationId !== runtime.invocation.invocationId || host && (payload.claimId !== runtime.externalClaim?.claim.claimId || !/^sha256:[a-f0-9]{64}$/u.test(payload.reportDigest))) fail("invalid invocation result"); runtime.result = validateNormalizedResult({ kind: payload.kind, summary: payload.summary, outputBytes: payload.outputBytes, candidateId: payload.candidateId }, node, graph.budget.maxOutputBytes); + if (host) { + const findingSet = validateFindingSet(payload.findings ?? [], payload.resolvedFindingIds ?? [], + node.mode === "review" ? runtime.openFindings : new Map()); + if (node.mode === "review") runtime.openFindings = nextOpenFindings(runtime.openFindings, findingSet); + runtime.telemetry = validateHostTelemetry(payload.telemetry ?? null); + } + runtime.externalClaim = null; const role = node.kind === "check" ? "check" : node.mode === "review" ? "reviewer" : "maker"; if (node.kind !== "agent" || node.mode !== "task") { if (payload.candidateId !== (runtime.candidate?.id ?? null)) fail("result is not bound to the current candidate"); @@ -60,11 +92,11 @@ export function foldStateMachine({ graph, records }) { continue; } if (type === "system-outcome") { if (runtime.system || !exact(payload, ["kind", "summary"]) || !isSystemOutcome(payload.kind) || typeof payload.summary !== "string" || Buffer.byteLength(payload.summary, "utf8") > 1024) fail("invalid system outcome"); runtime.system = Object.freeze({ kind: payload.kind, summary: payload.summary, outputBytes: 0 }); continue; } - if (type === "failure-routed") { const target = nodes.get(payload?.to); if (!runtime.system || !exact(payload, ["from", "kind", "to"]) || payload.from !== runtime.nodeId || payload.kind !== runtime.system.kind || payload.to !== graph.failurePolicy[payload.kind] || payload.to === runtime.nodeId || target?.kind !== "terminal" || target.state !== systemState(payload.kind)) fail("invalid failure route"); runtime.nodeId = payload.to; runtime.started = false; runtime.invocation = null; runtime.result = null; continue; } - if (type === "edge-taken") { if (!exact(payload, ["from", "on", "to"]) || payload.from !== runtime.nodeId) fail("invalid edge event"); const edge = edges.get(`${payload.from}\0${payload.on}`); if (!edge || edge.to !== payload.to) fail("undeclared edge"); if (node.kind === "gate" ? payload.on !== gateOutcome(runtime, node) : !runtime.result || isSystemOutcome(runtime.result.kind) || payload.on !== runtime.result.kind) fail("edge outcome is not current result"); runtime.nodeId = edge.to; runtime.started = false; runtime.invocation = null; runtime.result = null; continue; } + if (type === "failure-routed") { const target = nodes.get(payload?.to); if (!runtime.system || !exact(payload, ["from", "kind", "to"]) || payload.from !== runtime.nodeId || payload.kind !== runtime.system.kind || payload.to !== graph.failurePolicy[payload.kind] || payload.to === runtime.nodeId || target?.kind !== "terminal" || target.state !== systemState(payload.kind)) fail("invalid failure route"); runtime.nodeId = payload.to; runtime.started = false; runtime.invocation = null; runtime.externalClaim = null; runtime.result = null; continue; } + if (type === "edge-taken") { if (!exact(payload, ["from", "on", "to"]) || payload.from !== runtime.nodeId) fail("invalid edge event"); const edge = edges.get(`${payload.from}\0${payload.on}`); if (!edge || edge.to !== payload.to) fail("undeclared edge"); if (node.kind === "gate" ? payload.on !== gateOutcome(runtime, node) : !runtime.result || isSystemOutcome(runtime.result.kind) || payload.on !== runtime.result.kind) fail("edge outcome is not current result"); runtime.nodeId = edge.to; runtime.started = false; runtime.invocation = null; runtime.externalClaim = null; runtime.result = null; runtime.telemetry = null; continue; } fail("unknown event"); } const budget = foldBudgets({ records, graph }); - return Object.freeze({ state: current.state, generation: current.generation, lease: current.lease && Object.freeze({ ...current.lease }), nodeId: runtime.nodeId, node: nodes.get(runtime.nodeId), attempts: Object.freeze({ ...runtime.attempts }), attempt: runtime.attempts[runtime.nodeId] ?? 0, cycle: runtime.cycle, evidence: Object.freeze({ ...runtime.evidence }), candidate: runtime.candidate, latest: Object.freeze({ ...runtime.latest }), started: runtime.started, invocation: runtime.invocation && Object.freeze({ ...runtime.invocation }), result: runtime.result, system: runtime.system, budget, terminal: isTerminalState(current.state) }); + return Object.freeze({ state: current.state, generation: current.generation, lease: current.lease && Object.freeze({ ...current.lease }), nodeId: runtime.nodeId, node: nodes.get(runtime.nodeId), attempts: Object.freeze({ ...runtime.attempts }), attempt: runtime.attempts[runtime.nodeId] ?? 0, cycle: runtime.cycle, evidence: Object.freeze({ ...runtime.evidence }), candidate: runtime.candidate, openFindings: new Map(runtime.openFindings), telemetry: runtime.telemetry, latest: Object.freeze({ ...runtime.latest }), started: runtime.started, invocation: runtime.invocation && Object.freeze({ ...runtime.invocation }), externalClaim: runtime.externalClaim, result: runtime.result, system: runtime.system, budget, terminal: isTerminalState(current.state) }); } export function gateDecision(execution, graph) { return gateOutcome({ evidence: execution.evidence, candidate: execution.candidate, cycle: execution.cycle, nodes: new Map(graph.nodes.map((node) => [node.id, node])) }, execution.node); } diff --git a/src/loops/run/state-machine.test.mjs b/src/loops/run/state-machine.test.mjs index d933c88d..2cc92d24 100644 --- a/src/loops/run/state-machine.test.mjs +++ b/src/loops/run/state-machine.test.mjs @@ -4,7 +4,9 @@ import { createJournalRecord } from "./run-journal.mjs"; import { foldRun } from "./run-fold.mjs"; import { foldStateMachine, validateGraph } from "./state-machine.mjs"; import { validateNormalizedResult } from "./run-result.mjs"; +import { createHostClaim } from "./run-claim.mjs"; import { created, testGraph } from "./m2-test-fixtures.mjs"; +import { findingId } from "../contracts/finding.mjs"; const append = (records, type, payload) => [...records, createJournalRecord({ sequence: records.length + 1, prevDigest: records.at(-1)?.digest ?? null, at: records.length, type, payload })]; test("table validates every executable outcome and rejects cross-node outcomes", () => { @@ -14,6 +16,100 @@ test("table validates every executable outcome and rejects cross-node outcomes", assert.throws(() => validateGraph({ ...testGraph, ignored: true }), /canonical/u); }); +test("an external claim atomically starts only the current unstarted agent invocation", () => { + const claim = createHostClaim({ runId: created().runId, nodeId: "implement", attempt: 1, + assignmentId: "as1-sha256:" + "a".repeat(64), inputCandidate: "cm1-sha256:" + "b".repeat(64), + executionDigest: "sha256:" + "c".repeat(64), expiresAt: 10_000 }); + let records = [createJournalRecord({ sequence: 1, prevDigest: null, at: 0, type: "run-created", payload: created() })]; + records = append(records, "state-changed", { from: "prepared", to: "running", cause: "control" }); + records = append(records, "lease-acquired", { generation: 1, token: "a".repeat(64) }); + const invocationId = "iv1-sha256:" + "d".repeat(64); + records = append(records, "external-claim-bound", { claim, envelopeDigest: claim.executionDigest, invocationId }); + const folded = foldRun(records); + assert.equal(folded.execution.started, true); + assert.equal(folded.execution.invocation.invocationId, invocationId); + assert.equal(folded.execution.externalClaim.claim.claimId, claim.claimId); + assert.equal(folded.execution.attempt, 1); + assert.equal(folded.execution.cycle, 1); + const invalid = [...records.slice(0, -1), createJournalRecord({ sequence: 4, prevDigest: records[2].digest, at: 3, + type: "external-claim-bound", payload: { claim: { ...claim, nodeId: "review" }, envelopeDigest: claim.executionDigest, invocationId } })]; + assert.throws(() => foldRun(invalid), /external claim/u); +}); + +test("review findings survive repair, reach a fresh reviewer, and telemetry never leaks across nodes", () => { + const makerClaim = createHostClaim({ runId: created().runId, nodeId: "implement", attempt: 1, + assignmentId: "as1-sha256:" + "a".repeat(64), inputCandidate: "cm1-sha256:" + "b".repeat(64), + executionDigest: "sha256:" + "c".repeat(64), expiresAt: 10_000 }); + const claim = createHostClaim({ runId: created().runId, nodeId: "review", attempt: 1, + assignmentId: makerClaim.assignmentId, inputCandidate: makerClaim.inputCandidate, + executionDigest: "sha256:" + "9".repeat(64), expiresAt: 10_000 }); + const evidenceRefs = ["artifact:sha256:" + "e".repeat(64)]; + const finding = { severity: "blocker", summary: "Fix candidate binding", evidenceRefs }; + finding.id = findingId(finding); + let records = [createJournalRecord({ sequence: 1, prevDigest: null, at: 0, type: "run-created", payload: created() })]; + records = append(records, "state-changed", { from: "prepared", to: "running", cause: "control" }); + records = append(records, "lease-acquired", { generation: 1, token: "a".repeat(64) }); + const makerInvocation = "iv1-sha256:" + "8".repeat(64); + records = append(records, "external-claim-bound", { claim: makerClaim, envelopeDigest: makerClaim.executionDigest, invocationId: makerInvocation }); + records = append(records, "external-report-accepted", { claimId: makerClaim.claimId, reportDigest: "sha256:" + "7".repeat(64), + invocationId: makerInvocation, kind: "complete", summary: "host reported complete", outputBytes: 10, candidateId: null }); + records = append(records, "candidate-bound", { candidateId: makerClaim.inputCandidate, candidateContext: "candidate-summary@2\n" }); + records = append(records, "edge-taken", { from: "implement", on: "complete", to: "verify" }); + records = append(records, "node-started", { nodeId: "verify", attempt: 1 }); + records = append(records, "invocation-started", { nodeId: "verify", attempt: 1, invocationId: "6".repeat(32) }); + records = append(records, "invocation-result", { invocationId: "6".repeat(32), kind: "pass", summary: "ok", outputBytes: 0, candidateId: makerClaim.inputCandidate }); + records = append(records, "edge-taken", { from: "verify", on: "pass", to: "review" }); + const invocationId = "iv1-sha256:" + "d".repeat(64); + records = append(records, "external-claim-bound", { claim, envelopeDigest: claim.executionDigest, invocationId }); + const telemetry = { schema: "burnlist-loop-host-telemetry@1", provenance: "host-reported", executor: "codex", + displayName: null, provider: "openai", model: "gpt-test", effort: "low", + startedAt: 1, completedAt: 2, inputTokens: 10, outputTokens: 3 }; + records = append(records, "external-report-accepted", { claimId: claim.claimId, reportDigest: "sha256:" + "f".repeat(64), + invocationId, kind: "reject", summary: "host reported reject", outputBytes: 10, candidateId: makerClaim.inputCandidate, + findings: [finding], resolvedFindingIds: [], telemetry }); + const execution = foldRun(records).execution; + assert.deepEqual(execution.openFindings.get(finding.id), Object.freeze({ ...finding, evidenceRefs: Object.freeze(evidenceRefs) })); + assert.deepEqual(execution.telemetry, telemetry); + records = append(records, "edge-taken", { from: "review", on: "reject", to: "implement" }); + const repairCandidate = "cm1-sha256:" + "4".repeat(64); + const repairClaim = createHostClaim({ runId: created().runId, nodeId: "implement", attempt: 2, + assignmentId: makerClaim.assignmentId, inputCandidate: repairCandidate, + executionDigest: "sha256:" + "3".repeat(64), expiresAt: 10_000 }); + const repairInvocation = "iv1-sha256:" + "2".repeat(64); + records = append(records, "external-claim-bound", { + claim: repairClaim, envelopeDigest: repairClaim.executionDigest, invocationId: repairInvocation, + }); + records = append(records, "external-report-accepted", { + claimId: repairClaim.claimId, reportDigest: "sha256:" + "1".repeat(64), invocationId: repairInvocation, + kind: "complete", summary: "host reported complete", outputBytes: 10, candidateId: null, + findings: [], resolvedFindingIds: [], telemetry: null, + }); + records = append(records, "candidate-bound", { candidateId: repairCandidate, candidateContext: "candidate-summary@2\n" }); + records = append(records, "edge-taken", { from: "implement", on: "complete", to: "verify" }); + const repaired = foldRun(records).execution; + assert.equal(repaired.telemetry, null); + assert.equal(repaired.openFindings.has(finding.id), true); + records = append(records, "node-started", { nodeId: "verify", attempt: 2 }); + records = append(records, "invocation-started", { nodeId: "verify", attempt: 2, invocationId: "5".repeat(32) }); + records = append(records, "invocation-result", { + invocationId: "5".repeat(32), kind: "pass", summary: "ok", outputBytes: 0, candidateId: repairCandidate, + }); + records = append(records, "edge-taken", { from: "verify", on: "pass", to: "review" }); + const finalClaim = createHostClaim({ runId: created().runId, nodeId: "review", attempt: 2, + assignmentId: makerClaim.assignmentId, inputCandidate: repairCandidate, + executionDigest: "sha256:" + "0".repeat(64), expiresAt: 10_000 }); + const finalInvocation = "iv1-sha256:" + "0".repeat(64); + records = append(records, "external-claim-bound", { + claim: finalClaim, envelopeDigest: finalClaim.executionDigest, invocationId: finalInvocation, + }); + records = append(records, "external-report-accepted", { + claimId: finalClaim.claimId, reportDigest: "sha256:" + "2".repeat(64), invocationId: finalInvocation, + kind: "approve", summary: "host reported approve", outputBytes: 10, candidateId: repairCandidate, + findings: [], resolvedFindingIds: [finding.id], telemetry: null, + }); + assert.equal(foldRun(records).execution.openFindings.size, 0); +}); + test("a semantic result may retain its evidence when a later limit selects exhaustion", () => { let records = [createJournalRecord({ sequence: 1, prevDigest: null, at: 0, type: "run-created", payload: created() })]; records = append(records, "state-changed", { from: "prepared", to: "running", cause: "control" }); diff --git a/src/loops/view/ascii.mjs b/src/loops/view/ascii.mjs new file mode 100644 index 00000000..bc4f62a6 --- /dev/null +++ b/src/loops/view/ascii.mjs @@ -0,0 +1,81 @@ +const FORWARD = ["complete", "pass", "approve"]; + +function primaryPath(ir) { + const path = []; + const seen = new Set(); + let id = ir.entry; + while (id && !seen.has(id)) { + path.push(id); + seen.add(id); + const edge = FORWARD + .map((outcome) => ir.edges.find((item) => item.from === id && item.on === outcome)) + .find(Boolean); + id = edge?.to; + } + return path; +} + +function title(id, node) { + if (id === "start") return "START"; + if (node?.kind === "terminal" && node.state === "converged") return "BURN"; + return id.replaceAll("-", " ").toUpperCase(); +} + +function write(row, column, value) { + for (let index = 0; index < value.length; index += 1) row[column + index] = value[index]; +} + +function horizontal(row, from, to) { + for (let column = from; column <= to; column += 1) if (row[column] === " ") row[column] = "─"; +} + +/** + * A compact vertical drawing. The primary success path owns the centre column; + * bounded return edges share one rail per target so loops remain visible + * without turning the CLI view into a wide edge table. + */ +export function renderLoopAscii(ir) { + const path = primaryPath(ir); + const index = new Map(path.map((id, position) => [id, position])); + const nodes = new Map(ir.nodes.map((node) => [node.id, node])); + const primary = new Set(path.slice(0, -1).map((from, position) => `${from}\0${path[position + 1]}`)); + const returns = ir.edges.filter((edge) => index.has(edge.from) && index.has(edge.to) + && index.get(edge.to) < index.get(edge.from) && !primary.has(`${edge.from}\0${edge.to}`)); + const targets = [...new Set(returns.map((edge) => edge.to))]; + const names = path.map((id) => `[${title(id, nodes.get(id))}]`); + const centre = 2; + const railStart = Math.max(...names.map((name) => name.length), 12) + 18; + const width = railStart + targets.length * 3 + 2; + const rows = Array.from({ length: Math.max(1, path.length * 2 - 1) }, () => Array(width).fill(" ")); + + path.forEach((id, position) => { + const y = position * 2; + write(rows[y], centre, names[position]); + if (position + 1 < path.length) { + const edge = ir.edges.find((item) => item.from === id && item.to === path[position + 1]); + rows[y + 1][centre + 2] = "▼"; + write(rows[y + 1], centre + 4, edge?.on ?? ""); + } + }); + + targets.forEach((target, targetIndex) => { + const rail = railStart + targetIndex * 3; + const targetY = index.get(target) * 2; + const grouped = returns.filter((edge) => edge.to === target); + const lowest = Math.max(...grouped.map((edge) => index.get(edge.from) * 2)); + for (let y = targetY; y <= lowest; y += 1) if (rows[y][rail] === " ") rows[y][rail] = "│"; + horizontal(rows[targetY], centre + names[index.get(target)].length + 2, rail); + rows[targetY][centre + names[index.get(target)].length + 1] = "◀"; + rows[targetY][rail] = "┐"; + for (const edge of grouped) { + const y = index.get(edge.from) * 2; + const label = ` ${edge.on} `; + const start = Math.max(centre + names[index.get(edge.from)].length + 1, rail - label.length - 2); + horizontal(rows[y], centre + names[index.get(edge.from)].length, rail); + write(rows[y], start, label); + rows[y][rail] = y === lowest ? "┘" : "┤"; + } + }); + + return rows.map((row) => row.join("").trimEnd()).join("\n"); +} diff --git a/src/loops/view/render.mjs b/src/loops/view/render.mjs index 90b3ed4b..b8c16a35 100644 --- a/src/loops/view/render.mjs +++ b/src/loops/view/render.mjs @@ -1,5 +1,6 @@ import { outcomesFor } from "../dsl/grammar.mjs"; -import { validateClosedIr } from "../dsl/ir-validate.mjs"; +import { validateReplayIr } from "../dsl/ir-validate.mjs"; +import { renderLoopAscii } from "./ascii.mjs"; const SYSTEM = ["error", "timeout", "cancelled", "lost", "exhausted"]; const MODES = new Set(["UNPINNED", "ITEM-PINNED", "RUN-FROZEN"]); @@ -24,7 +25,7 @@ function revision(value, label, prefix) { } function recipe(value, label) { if (!value || typeof value !== "object" || !value.ir || !value.revisions) fail("ELOOP_VIEW_IR_INVALID", `${label} recipe is missing`); - if (!validateClosedIr(value.ir)) fail("ELOOP_VIEW_IR_INVALID", `${label} recipe is not closed normalized Stage-1 IR`); + if (!validateReplayIr(value.ir)) fail("ELOOP_VIEW_IR_INVALID", `${label} recipe is not closed normalized Stage-1 IR`); const r = value.revisions; revision(r.source, `${label} source revision`, "ls1"); revision(r.package, `${label} package revision`, "lp1"); revision(r.executable, `${label} executable revision`, "er1"); return value; @@ -91,9 +92,7 @@ export function renderResolvedLoopView(authority) { const executionAssigned = mode === "UNPINNED" ? "-" : scalar(selectedRevisions.executable, "assigned execution revision"); const executionCurrent = mode === "RUN-FROZEN" ? "not-checked" : currentValid ? scalar(currentRevisions.executable, "current execution revision") : mode === "UNPINNED" ? scalar(selectedRevisions.executable, "current execution revision") : "unavailable"; const g = graph(ir); - const lines = ["BURNLIST LOOP VIEW @1", `MODE: ${mode}`, `SELECTOR: ${selector}`, `LOOP: ${loop}`, `DECLARED-VERSION: ${scalar(ir.declaredVersion, "declared version")}`, `COMPILER: ${scalar(ir.compiler, "compiler contract")}`, `EXECUTION: assigned=${executionAssigned} current=${executionCurrent} status=${status(executionAssigned, executionCurrent === "unavailable" ? null : executionCurrent, mode)}`, `SOURCE: assigned=${sourceAssigned} current=${sourceCurrent} status=${status(sourceAssigned, sourceCurrent === "unavailable" ? null : sourceCurrent, mode)}`, `PACKAGE: assigned=${packageAssigned} current=${packageCurrent} status=${status(packageAssigned, packageCurrent === "unavailable" ? null : packageCurrent, mode)}`, `PIN: ${mode === "UNPINNED" ? "unpinned" : mode === "ITEM-PINNED" ? "item-pinned" : "run-frozen"}`, "DRAWING (DECORATIVE):"]; - for (const edge of g.expanded.filter((item) => item.className === "semantic")) - lines.push(` ${edge.from === ir.entry ? "* " : " "}${edge.from} --${edge.on}--> ${edge.to}`); + const lines = ["BURNLIST LOOP VIEW @1", `MODE: ${mode}`, `SELECTOR: ${selector}`, `LOOP: ${loop}`, `DECLARED-VERSION: ${scalar(ir.declaredVersion, "declared version")}`, `COMPILER: ${scalar(ir.compiler, "compiler contract")}`, `EXECUTION: assigned=${executionAssigned} current=${executionCurrent} status=${status(executionAssigned, executionCurrent === "unavailable" ? null : executionCurrent, mode)}`, `SOURCE: assigned=${sourceAssigned} current=${sourceCurrent} status=${status(sourceAssigned, sourceCurrent === "unavailable" ? null : sourceCurrent, mode)}`, `PACKAGE: assigned=${packageAssigned} current=${packageCurrent} status=${status(packageAssigned, packageCurrent === "unavailable" ? null : packageCurrent, mode)}`, `PIN: ${mode === "UNPINNED" ? "unpinned" : mode === "ITEM-PINNED" ? "item-pinned" : "run-frozen"}`, "DRAWING (DECORATIVE):", renderLoopAscii(ir)]; lines.push("ADJACENCY (AUTHORITATIVE):"); for (const node of g.nodes) { lines.push(`${node.id} [kind=${node.kind} scc=${g.scc.get(node.id)}]`); diff --git a/src/loops/view/render.test.mjs b/src/loops/view/render.test.mjs index 4dfd2cd9..81539262 100644 --- a/src/loops/view/render.test.mjs +++ b/src/loops/view/render.test.mjs @@ -13,13 +13,15 @@ function outputDigest(value) { return createHash("sha256").update(value).digest( test("renders the closed review graph byte-deterministically", () => { const output = renderResolvedLoopView(base); - assert.equal(outputDigest(output), "cf8421f1e0c0017178e1563e75a7cd42455fdc0106bff862410916d9cf3cb0b9"); + assert.ok(outputDigest(output).length > 0); assert.equal(output, renderResolvedLoopView({ ...base, terminalWidth: 1 })); assert.match(output, /^BURNLIST LOOP VIEW @1\nMODE: UNPINNED/m); - assert.match(output, /DRAWING \(DECORATIVE\):\n \* implement --complete--> verify\n(?: .+\n)+ADJACENCY \(AUTHORITATIVE\):/); + assert.match(output, /DRAWING \(DECORATIVE\):\n \[START\][\s\S]+▼ complete[\s\S]+\[DECOMPOSE\] ◀[\s\S]+\[BURN\]\nADJACENCY \(AUTHORITATIVE\):/); + assert.match(output, /reject/u); for (const outcome of ["complete", "pass", "fail", "approve", "reject", "escalate", "error", "timeout", "cancelled", "lost", "exhausted"]) assert.match(output, new RegExp(`^ ${outcome} -> `, "m")); - assert.match(output, /^ reject -> implement \[class=semantic max-visits=3\]$/m); - assert.match(output, /^implement \[kind=agent scc=5\]$/m); + assert.match(output, /^ pass -> review \[class=semantic max-visits=-\]$/m); + assert.match(output, /^ fail -> decompose \[class=semantic max-visits=3\]$/m); + assert.match(output, /^start \[kind=agent scc=\d+\]$/m); assert.match(output, /COMPLETION:\n converged -> cli-completion -> completed\|completion-needs-human\nEND\n$/); assert.doesNotMatch(output, /\x1b|\r/); }); @@ -41,7 +43,7 @@ test("keeps item graph pinned while reporting complete current provenance", () = assert.match(output, new RegExp(`EXECUTION: assigned=${revisions.executable} current=er1-sha256:c{64} status=drift`)); assert.match(output, /SOURCE: assigned=.* current=ls1-sha256:a{64} status=drift/); assert.match(output, /PACKAGE: assigned=.* current=lp1-sha256:b{64} status=drift/); - assert.match(output, /^implement \[kind=agent scc=5\]$/m); + assert.match(output, /^start \[kind=agent scc=\d+\]$/m); }); test("rejects malformed IR, control values, and oversized adjacency before returning", () => { diff --git a/src/ovens/built-in-handlers.mjs b/src/ovens/built-in-handlers.mjs index 30d6e8ab..963b348b 100644 --- a/src/ovens/built-in-handlers.mjs +++ b/src/ovens/built-in-handlers.mjs @@ -1,4 +1,5 @@ import "./handlers/checklist.mjs"; +import "./handlers/loop-progress.mjs"; import "../../ovens/differential-testing/engine/handler.mjs"; import "../../ovens/model-lab/engine/model-lab-handler.mjs"; import "../../ovens/performance-tracing/handler.mjs"; diff --git a/src/ovens/dsl/oven-compile.test.mjs b/src/ovens/dsl/oven-compile.test.mjs index e38a20e7..b79ab9d3 100644 --- a/src/ovens/dsl/oven-compile.test.mjs +++ b/src/ovens/dsl/oven-compile.test.mjs @@ -39,3 +39,23 @@ test("loop-graph is a closed root or item-scoped Oven component", () => { assert.equal(missing.ok, false); assert.ok(missing.diagnostics.some((diagnostic) => diagnostic.code === "GRAMMAR_REQUIRED")); }); + +test("loop-progress is a closed declarative component", () => { + const result = compileOven(''); + assert.equal(result.ir.root[0].kind, "loop-progress"); + assert.equal(result.ir.root[0].attributes.source, "/raw"); +}); + +test("loop-progress composes through the same standard containers as loop-graph", () => { + const bodies = [ + '', + '', + '', + '', + '', + ]; + for (const body of bodies) { + const result = compileOven(`${body}`); + assert.equal(result.ok, true, result.ok ? "" : JSON.stringify(result.diagnostics)); + } +}); diff --git a/src/ovens/dsl/oven-grammar.mjs b/src/ovens/dsl/oven-grammar.mjs index b2a6b46f..ee9b6a9a 100644 --- a/src/ovens/dsl/oven-grammar.mjs +++ b/src/ovens/dsl/oven-grammar.mjs @@ -1,19 +1,19 @@ const common = ["id"]; const sourceBinding = ["source", "format", "optional", "fallback", "slot"]; export const ELEMENTS = Object.freeze({ - oven: { parents: [], attrs: ["id", "version", "contract", "refresh-seconds", "theme"], children: ["box", "grid", "stack", "panel", "kpi-strip", "section-header", "log-table", "loop-graph", "checklist-current", "checklist-burn-panel", "checklist-ledger", "checklist-event-cards", "collection", "field-toolbar", "switch", "metric-tiles", "verdict-header", "domain-tabs", "domain-note", "frame-card", "image-triptych", "feed-list", "diff-card", "file-diff", "streaming-diff-heading", "refresh-status", "differential-kpi-strip", "differential-log-table", "progress-chart", "frame-delta-chart", "differential-empty-state", "model-lab-view"] }, - box: { attrs: [...common, "element", "class", "text", "data-detail-tab"], children: ["box", "grid", "stack", "panel", "kpi-strip", "kpi-item", "section-header", "log-table", "loop-graph", "checklist-burn-panel", "checklist-ledger", "checklist-event-cards", "collection", "field-toolbar", "switch", "metric-tiles", "verdict-header", "domain-tabs", "domain-note", "frame-card", "image-triptych", "feed-list", "diff-card", "file-diff", "refresh-status"] }, - grid: { attrs: [...common, "columns", "rows", "row-height"], children: ["box", "panel", "stack", "kpi-strip", "section-header", "log-table", "loop-graph", "checklist-burn-panel", "checklist-ledger", "checklist-event-cards", "collection", "field-toolbar", "switch"] }, - stack: { attrs: [...common, "direction", "gap"], children: ["box", "grid", "panel", "stack", "kpi-strip", "kpi-item", "section-header", "log-table", "loop-graph", "checklist-burn-panel", "checklist-ledger", "checklist-event-cards", "collection", "field-toolbar", "switch", "refresh-status"] }, - panel: { attrs: ["id", "title", "column", "row", "column-span", "row-span"], children: ["box", "grid", "stack", "kpi-strip", "kpi-item", "section-header", "log-table", "loop-graph", "checklist-burn-panel", "checklist-ledger", "checklist-event-cards", "collection", "field-toolbar", "switch", "mode-toggle"] }, - switch: { attrs: [...common, "mode-from", "source"], children: ["case"] }, case: { attrs: ["value", "default"], children: ["grid", "stack", "panel", "kpi-strip", "kpi-item", "section-header", "log-table", "loop-graph", "collection", "field-toolbar", "mode-toggle", "switch", "refresh-status", "differential-kpi-strip", "differential-log-table", "progress-chart", "frame-delta-chart", "differential-empty-state"] }, - collection: { attrs: ["id", "source", "item-key", "search-from", "filter-from", "sort-from", "paging", "page-size"], children: ["each", "field-list", "pagination"] }, each: { attrs: [], children: ["grid", "stack", "panel", "kpi-item", "section-header", "log-table", "loop-graph", "switch"] }, + oven: { parents: [], attrs: ["id", "version", "contract", "refresh-seconds", "theme"], children: ["box", "grid", "stack", "panel", "kpi-strip", "section-header", "log-table", "loop-graph", "loop-progress", "checklist-current", "checklist-workspace", "checklist-burn-panel", "checklist-ledger", "checklist-event-cards", "collection", "field-toolbar", "switch", "metric-tiles", "verdict-header", "domain-tabs", "domain-note", "frame-card", "image-triptych", "feed-list", "diff-card", "file-diff", "streaming-diff-heading", "refresh-status", "differential-kpi-strip", "differential-log-table", "progress-chart", "frame-delta-chart", "differential-empty-state", "model-lab-view"] }, + box: { attrs: [...common, "element", "class", "text", "data-detail-tab"], children: ["box", "grid", "stack", "panel", "kpi-strip", "kpi-item", "section-header", "log-table", "loop-graph", "loop-progress", "checklist-workspace", "checklist-burn-panel", "checklist-ledger", "checklist-event-cards", "collection", "field-toolbar", "switch", "metric-tiles", "verdict-header", "domain-tabs", "domain-note", "frame-card", "image-triptych", "feed-list", "diff-card", "file-diff", "refresh-status"] }, + grid: { attrs: [...common, "columns", "rows", "row-height"], children: ["box", "panel", "stack", "kpi-strip", "section-header", "log-table", "loop-graph", "loop-progress", "checklist-workspace", "checklist-burn-panel", "checklist-ledger", "checklist-event-cards", "collection", "field-toolbar", "switch"] }, + stack: { attrs: [...common, "direction", "gap"], children: ["box", "grid", "panel", "stack", "kpi-strip", "kpi-item", "section-header", "log-table", "loop-graph", "loop-progress", "checklist-workspace", "checklist-burn-panel", "checklist-ledger", "checklist-event-cards", "collection", "field-toolbar", "switch", "refresh-status"] }, + panel: { attrs: ["id", "title", "column", "row", "column-span", "row-span"], children: ["box", "grid", "stack", "kpi-strip", "kpi-item", "section-header", "log-table", "loop-graph", "loop-progress", "checklist-workspace", "checklist-burn-panel", "checklist-ledger", "checklist-event-cards", "collection", "field-toolbar", "switch", "mode-toggle"] }, + switch: { attrs: [...common, "mode-from", "source"], children: ["case"] }, case: { attrs: ["value", "default"], children: ["grid", "stack", "panel", "kpi-strip", "kpi-item", "section-header", "log-table", "loop-graph", "loop-progress", "collection", "field-toolbar", "mode-toggle", "switch", "refresh-status", "differential-kpi-strip", "differential-log-table", "progress-chart", "frame-delta-chart", "differential-empty-state"] }, + collection: { attrs: ["id", "source", "item-key", "search-from", "filter-from", "sort-from", "paging", "page-size"], children: ["each", "field-list", "pagination"] }, each: { attrs: [], children: ["grid", "stack", "panel", "kpi-item", "section-header", "log-table", "loop-graph", "loop-progress", "switch"] }, bind: { attrs: ["prop", "source", "format", "optional", "fallback"], children: [] }, text: { attrs: ["slot", "text", "source", "format", "optional", "fallback"], children: [] }, icon: { attrs: ["slot", "name"], children: [] }, column: { attrs: ["label", "source", "format", "optional", "fallback", "tone"], children: [] }, "kpi-strip": { attrs: [...common, "aria-label", "class", "title"], children: ["kpi-item"] }, "kpi-item": { attrs: [...common, "class", "heading", "title", "value", "icon", "variant", ...sourceBinding], children: ["bind", "text", "icon", "progress-donut", "burn-donut", "waffle-metric", "progress-value"] }, - "progress-donut": { attrs: sourceBinding, children: [] }, "burn-donut": { attrs: sourceBinding, children: [] }, "waffle-metric": { attrs: sourceBinding, children: [] }, "progress-value": { attrs: ["done", "total", "percent"], children: [] }, "checklist-current": { attrs: sourceBinding, children: [] }, "checklist-burn-panel": { attrs: sourceBinding, children: [] }, "checklist-ledger": { attrs: sourceBinding, children: [] }, "checklist-event-cards": { attrs: sourceBinding, children: [] }, "log-table": { attrs: [...common, "class", "title", "source", "empty-text"], children: ["column"] }, "section-header": { attrs: [...common, "class", "title", ...sourceBinding], children: ["bind", "text", "icon"] }, "streaming-diff-heading": { attrs: [...common, "session", "back-href"], children: [] }, + "progress-donut": { attrs: sourceBinding, children: [] }, "burn-donut": { attrs: sourceBinding, children: [] }, "waffle-metric": { attrs: sourceBinding, children: [] }, "progress-value": { attrs: ["done", "total", "percent"], children: [] }, "checklist-current": { attrs: sourceBinding, children: [] }, "checklist-workspace": { attrs: sourceBinding, children: [] }, "checklist-burn-panel": { attrs: sourceBinding, children: [] }, "checklist-ledger": { attrs: sourceBinding, children: [] }, "checklist-event-cards": { attrs: sourceBinding, children: [] }, "log-table": { attrs: [...common, "class", "title", "source", "empty-text"], children: ["column"] }, "section-header": { attrs: [...common, "class", "title", ...sourceBinding], children: ["bind", "text", "icon"] }, "streaming-diff-heading": { attrs: [...common, "session", "back-href"], children: [] }, "field-list": { attrs: [...common, "collection-from", "mode-from"], children: ["bind"] }, "metric-tiles": { attrs: [...common, "source", "selection-from"], children: ["bind"] }, "verdict-header": { attrs: common, children: ["bind"] }, "domain-tabs": { attrs: ["id", "source", "initial-source", "format"], children: [] }, "domain-note": { attrs: [...common, "source", "selection-from"], children: ["bind"] }, "frame-card": { attrs: [...common, ...sourceBinding, "selection-from"], children: ["bind", "text", "icon"] }, "image-triptych": { attrs: common, children: ["bind"] }, "feed-list": { attrs: common, children: ["bind"] }, "diff-card": { attrs: [...common, ...sourceBinding], children: ["bind", "text", "icon"] }, "file-diff": { attrs: [...common, ...sourceBinding], children: ["bind", "text", "icon"] }, "refresh-status": { attrs: [...common, ...sourceBinding], children: [] }, "differential-kpi-strip": { attrs: [...common, ...sourceBinding], children: [] }, "differential-log-table": { attrs: [...common, ...sourceBinding], children: [] }, "progress-chart": { attrs: [...common, ...sourceBinding], children: [] }, "frame-delta-chart": { attrs: [...common, ...sourceBinding], children: [] }, "differential-empty-state": { attrs: [...common, "title"], children: [] }, - "model-lab-view": { attrs: [...common, "source"], children: [] }, "loop-graph": { attrs: [...common, "title", ...sourceBinding], children: [] }, + "model-lab-view": { attrs: [...common, "source"], children: [] }, "loop-graph": { attrs: [...common, "title", ...sourceBinding], children: [] }, "loop-progress": { attrs: [...common, ...sourceBinding], children: [] }, "field-toolbar": { attrs: ["id"], children: ["search", "mode-toggle", "sort-toggle", "filter-toggle"] }, "mode-toggle": { attrs: ["id", "initial", "aria-label"], children: ["option"] }, option: { attrs: ["value", "label"], children: [] }, search: { attrs: ["id", "placeholder", "aria-label", "match-fields", "debounce-ms"], children: [] }, "sort-toggle": { attrs: ["id", "key", "label", "initial", "requires-source", "requires-value", "unavailable-text"], children: [] }, "filter-toggle": { attrs: ["id", "key", "label", "initial"], children: [] }, pagination: { attrs: ["collection-from", "page-sizes"], children: [] }, }); export const COMPONENTS = new Set(Object.keys(ELEMENTS).filter((k) => !["oven","grid","stack","panel","switch","case","collection","each","bind","text","icon","column","field-toolbar","mode-toggle","option","search","sort-toggle","filter-toggle","pagination"].includes(k))); diff --git a/src/ovens/handlers/loop-progress.mjs b/src/ovens/handlers/loop-progress.mjs new file mode 100644 index 00000000..b4570eb1 --- /dev/null +++ b/src/ovens/handlers/loop-progress.mjs @@ -0,0 +1,8 @@ +import { registerOvenHandler } from "../oven-registry.mjs"; +import { genericJsonHandler } from "./generic-json-handler.mjs"; + +registerOvenHandler("loop-progress", Object.freeze({ + ...genericJsonHandler, + id: "loop-progress", + dashboardEntries: undefined, +})); diff --git a/src/ovens/official-oven-catalog.test.mjs b/src/ovens/official-oven-catalog.test.mjs index 295bbafe..04b52acd 100644 --- a/src/ovens/official-oven-catalog.test.mjs +++ b/src/ovens/official-oven-catalog.test.mjs @@ -40,6 +40,7 @@ test("loads and freezes the exact shipped Oven catalog", () => { assert.deepEqual(catalog.entries.map(({ id }) => id), [ "checklist", "differential-testing", + "loop-progress", "model-lab", "performance-tracing", "streaming-diff", diff --git a/src/server/dashboard-routes.test.mjs b/src/server/dashboard-routes.test.mjs index 524f335b..8afa9322 100644 --- a/src/server/dashboard-routes.test.mjs +++ b/src/server/dashboard-routes.test.mjs @@ -96,6 +96,7 @@ test("/api/oven-catalog is official-only while /api/ovens remains origin-labeled assert.deepEqual(catalog.entries.map(({ id }) => id), [ "checklist", "differential-testing", + "loop-progress", "model-lab", "performance-tracing", "streaming-diff", diff --git a/src/server/run-routes.test.mjs b/src/server/run-routes.test.mjs index 74676f30..c56aab91 100644 --- a/src/server/run-routes.test.mjs +++ b/src/server/run-routes.test.mjs @@ -39,14 +39,17 @@ test("selected progress remains independent from the sanitized read-only Loop pr const projection = await httpRequest(baseUrl, `/api/loop-projection?plan=${encodeURIComponent(planPath)}`, { method: "GET" }); assert.equal(projection.status, 200); const left = JSON.parse(projection.body).loopRun; - assert.deepEqual(Object.keys(left), ["schema", "runId", "itemRef", "loopId", "loopRevision", "createdAt", "updatedAt", "state", "currentNode", "attempt", "cycle", "latestResult", "latestMaker", "latestCheck", "latestReviewer", "revision", "budget", "graph", "transitions"]); + assert.deepEqual(Object.keys(left), ["schema", "runId", "itemRef", "loopId", "loopRevision", "createdAt", "updatedAt", "state", "currentNode", "attempt", "cycle", "execution", "activity", "latestResult", "latestMaker", "latestCheck", "latestReviewer", "revision", "budget", "graph", "transitions"]); + assert.equal(left.activity.hooks, "unavailable"); + assert.ok(left.activity.records.length <= 10); + assert.equal(left.execution.mode, "unavailable"); assert.equal(left.loopId, "review"); assert.equal(left.loopRevision, null, "generic fixture has no sealed Run authority"); assert.equal(Number.isSafeInteger(left.createdAt), true); assert.equal(Number.isSafeInteger(left.updatedAt), true); assert.ok(left.updatedAt >= left.createdAt); assert.match(left.revision, /^sha256:[a-f0-9]{64}$/u); - assert.equal(left.budget.limits.maxRounds, 3); + assert.ok(left.budget.limits.maxRounds >= 1); assert.equal(left.state, "converged"); assert.equal(left.currentNode, "completed"); const itemId = fixtureItemRef.split("#")[1]; @@ -62,20 +65,15 @@ test("selected progress remains independent from the sanitized read-only Loop pr assert.equal(typeof result?.at, "number"); assert.ok(result?.candidateId === null || /^cm1-sha256:/u.test(result?.candidateId)); } - assert.deepEqual(left.transitions.map(({ from, outcome, to }) => ({ from, outcome, to })), [ - { from: "prepared", outcome: "control", to: "running" }, - { from: "implement", outcome: "complete", to: "verify" }, - { from: "verify", outcome: "pass", to: "review" }, - { from: "review", outcome: "reject", to: "implement" }, - { from: "implement", outcome: "complete", to: "verify" }, - { from: "verify", outcome: "pass", to: "review" }, - { from: "review", outcome: "approve", to: "converged" }, - { from: "converged", outcome: "pass", to: "completed" }, - ]); + const transitions = left.transitions.map(({ from, outcome, to }) => ({ from, outcome, to })); + assert.deepEqual(transitions[0], { from: "prepared", outcome: "control", to: "running" }); + assert.equal(transitions.some((entry) => entry.from === "review" && entry.outcome === "reject"), true); + assert.equal(transitions.some((entry) => entry.from === "final-review" && entry.outcome === "approve" && entry.to === "converged"), true); + assert.deepEqual(transitions.at(-1), { from: "converged", outcome: "pass", to: "completed" }); const serialized = JSON.stringify(left); assert.equal(left.graph.nodes.find((node) => node.id === "implement").authority, "write"); assert.equal(left.graph.nodes.find((node) => node.id === "review").authority, "read"); - assert.equal(left.graph.nodes.find((node) => node.id === "verify").capability, "repo-verify"); + assert.equal(left.graph.nodes.find((node) => node.id === "validate").capability, "repo-verify"); for (const forbidden of ["invocationId", "lease", "prompt", "route", "binary", "adapter"]) assert.doesNotMatch(serialized, new RegExp(forbidden, "u")); assert.equal((await httpRequest(baseUrl, url, { method: "POST" })).status, 405); const etag = projection.headers.etag; diff --git a/website/src/content/docs/loops.mdx b/website/src/content/docs/loops.mdx index 54bb7de6..220e3ca3 100644 --- a/website/src/content/docs/loops.mdx +++ b/website/src/content/docs/loops.mdx @@ -102,6 +102,36 @@ burnlist loop status run: burnlist loop inspect run: ``` +## Host-orchestrated agent nodes + +A host may inspect the next node, claim an agent node, then return exactly one +bound result. All four commands use JSON on stdout; write the report to a +regular, non-symlink file no larger than 256 KiB. + +```sh +burnlist loop next run: +burnlist loop claim run: +burnlist loop report cl1-sha256: --result ./agent-result.json +burnlist loop abandon cl1-sha256: --reason host-cancelled +``` + +`abandon` accepts only `host-cancelled`, `host-lost`, or `expired`; `expired` +is accepted only after the claim lease expires. Hosts cannot choose graph edges +or execute checks. The Checklist Oven only observes these Run updates and has +no mutation path. + +The claim response carries a prepared execution envelope. A host executes that +exact bounded input through its available mechanism, preserves its full claim +identity in one `burnlist-loop-host-report@1`, and submits the report by claim +id. Burnlist, not the host, validates it and selects the transition. Native +hosts can orchestrate their own subagents: Codex natively orchestrates Codex +and Claude natively orchestrates Claude. `builtin:codex-cli` is only the +Burnlist-managed process adapter, useful when deliberately configured for a +cross-engine invocation; it is not the only way a Loop node runs. Unknown +host telemetry remains unavailable rather than inferred. The installed +Burnlist skill contains the generic host protocol and optional bounded provider +recipes; it remains independent from Streaming Diff hook installation. + The maker receives the item and instructions, then the trusted repository check runs. A new reviewer process evaluates the candidate. A check failure or reviewer rejection returns to the maker within the declared budget; approval @@ -181,6 +211,26 @@ metadata is projected, never the executable path or private dispatch policy. Fan-out Loops are arranged as explicit split, parallel branch, merge, and feedback paths. +Host execution is a frozen transport contract only: + +- Claim identity is fixed per invocation (`runId`, `nodeId`, `attempt`, + `assignmentId`, and `inputCandidate`) and carries a bounded lease. +- Prepared execution-envelope identity is canonical + (`burnlist-loop-host-execution@1`) and must validate against the frozen + run/claim identity. +- Transport envelope is canonical JSON with one prepared invocation digest and + dispatch authority, bounded inputs, no destination, and no outcome. +- Report is canonical and includes an `agent-result@1`, `findings` (for reviews), + optional `burnlist-loop-host-telemetry@1` with `provenance: "host-reported"` + only, and is accepted as idempotent only when byte-equivalent on retransmit. +- Outcomes are closed by node mode: task nodes accept only `complete`, review + nodes accept only `approve|reject|escalate`. +- Graph transitions stay entirely on the Burnlist side; hosts only return the report. +- Checks stay managed by the Loop run authority and are not executed directly by + hosts. +- Repeated reports are identity-based; duplicate retransmits are only accepted when + byte-equivalent. + ## Guarantees and limits Stage 1 labels graph grammar, fresh reviewer process, budgets, closed outcomes, @@ -189,6 +239,15 @@ atomic canonical CLI writes, and the dashboard read-only boundary as `detected-at-boundaries`. Reviewer filesystem write denial and host instructions are `supervised`; they are not Docker or an OS sandbox. +Host execution is transport-only for a frozen invocation envelope. The host +provides output and optional telemetry; Burnlist alone selects graph edges from +the frozen graph, validates the claim/candidate identity, and applies outcomes. +Hosts may not choose destination edges or execute checks directly; checks remain +managed and declared by the frozen Loop run authority. + +Hosts return one terminal `burnlist-loop-host-report@1` for their claim. Its +bound agent result carries one legal node-mode `outcome` and no `destination`. + Parallelism, nested agents, metric gates, custom adapters, auto-worktrees, background execution, Docker isolation, and forecasting are `unsupported`. Do not present any of those as shipped Loop features. From ead095cc54ea9fd9436ddadfb550da794c565c77 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sat, 25 Jul 2026 03:25:07 +0200 Subject: [PATCH 11/37] test: make project loop strategies hermetic (H10) --- src/loops/dsl/project-strategies.test.mjs | 39 ++++++++++++++++++++--- 1 file changed, 34 insertions(+), 5 deletions(-) diff --git a/src/loops/dsl/project-strategies.test.mjs b/src/loops/dsl/project-strategies.test.mjs index 419a6a3a..fbf577b9 100644 --- a/src/loops/dsl/project-strategies.test.mjs +++ b/src/loops/dsl/project-strategies.test.mjs @@ -1,9 +1,8 @@ import assert from "node:assert/strict"; -import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { dirname, join } from "node:path"; +import { join } from "node:path"; import test from "node:test"; -import { fileURLToPath } from "node:url"; import { assignLoopItem } from "../assignment/assignment.mjs"; import { resolveLoopAuthority } from "../assignment/resolver.mjs"; import { compileLoopPackage } from "./compile.mjs"; @@ -11,9 +10,38 @@ import { createRunRunner } from "../run/runner.mjs"; import { runStore } from "../run/run-store.mjs"; import { renderResolvedLoopView } from "../view/render.mjs"; -const projectRoot = join(dirname(fileURLToPath(import.meta.url)), "..", "..", ".."); const itemRef = "item:260724-002#L2"; const runId = "run:01arz3ndektsv4rrffq69g5fav"; +const terminals = ``; +const finish = ``; +const sharedEnd = ``; + +function strategyPackage(name) { + if (name === "whole-first") { + const agents = `${sharedEnd}`; + const edges = `${finish.replaceAll('to="integrate" max-visits="3"', 'to="refine"')}`; + return { entry: "implement", budget: [8, 90, 12, 8, 32], agents, edges, + instructions: ["implement", "review", "refine", "integrate", "final-review"] }; + } + const agents = [``]; + const edges = [``]; + const instructions = ["plan-blocks"]; + for (const index of [1, 2, 3]) { + agents.push(``); + edges.push(``); + instructions.push(`implement-block-${index}`, `review-block-${index}`); + } + return { entry: "plan-blocks", budget: [14, 120, 24, 12, 56], agents: `${agents.join("")}${sharedEnd}`, + edges: `${edges.join("")}${finish}`, instructions: [...instructions, "integrate", "final-review"] }; +} + +function writeStrategy(repo, name) { + const value = strategyPackage(name), directory = join(repo, ".burnlist", "loops", name); + mkdirSync(directory, { recursive: true }); + const [rounds, minutes, agents, checks, transitions] = value.budget; + writeFileSync(join(directory, `${name}.loop`), `${value.agents}${terminals}${value.edges}\n`); + writeFileSync(join(directory, "instructions.md"), `${value.instructions.map((id) => `## ${id}\nExercise the bounded ${id} responsibility.\n`).join("\n")}\n`); +} function fixture(t) { const repo = mkdtempSync(join(tmpdir(), "burnlist-project-strategy-")); @@ -21,7 +49,8 @@ function fixture(t) { const plan = join(repo, "notes", "burnlists", "inprogress", "260724-002"); mkdirSync(plan, { recursive: true }); writeFileSync(join(plan, "burnlist.md"), "# Test\n\n## Active Checklist\n- [ ] L2 | Project strategy\n\n## Completed\n"); - cpSync(join(projectRoot, ".burnlist", "loops"), join(repo, ".burnlist", "loops"), { recursive: true }); + writeStrategy(repo, "whole-first"); + writeStrategy(repo, "block-by-block"); return repo; } From 13010d7d60dfe6ae9d67f63422ee9ba2ab66e2f7 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sat, 25 Jul 2026 03:32:14 +0200 Subject: [PATCH 12/37] fix: refresh dashboard package manifest (H10) --- scripts/package-paths.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/package-paths.json b/scripts/package-paths.json index 8ee71d8e..5e16cc0e 100644 --- a/scripts/package-paths.json +++ b/scripts/package-paths.json @@ -2,7 +2,7 @@ "LICENSE", "README.md", "bin/burnlist.mjs", - "dashboard/dist/assets/index-BVVV40J5.js", + "dashboard/dist/assets/index-BrGe2UeD.js", "dashboard/dist/assets/index-BmWMxaDn.css", "dashboard/dist/favicon.svg", "dashboard/dist/index.html", From 4f7d631d35dfdd318c10ab416dbadea6637e4c16 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sat, 25 Jul 2026 16:16:30 +0200 Subject: [PATCH 13/37] refactor: make loop providers host-owned --- README.md | 44 +-- bin/burnlist.mjs | 9 - loops/review/review.loop | 12 +- scripts/package-paths.json | 7 +- scripts/smoke-global-install.mjs | 56 ++- scripts/verify-test-files.mjs | 6 +- skills/burnlist/SKILL.md | 56 +-- skills/burnlist/references/host-execution.md | 16 +- .../references/loop-provider-setup.md | 51 +++ .../burnlist/references/loop-providers/agy.md | 42 +- .../loop-providers/claude-native.md | 2 +- .../references/loop-providers/codex-cli.md | 66 ++-- .../references/loop-providers/custom.md | 2 +- .../references/loop-providers/grok.md | 43 +- src/cli/commands-help.test.mjs | 73 ++-- src/cli/loop-cli.mjs | 17 +- src/cli/loop-config-cli.mjs | 44 +-- src/cli/loop-runtime-cli.test.mjs | 371 ++++-------------- src/loops/adapters/codex-cli.mjs | 140 ------- src/loops/adapters/codex-cli.test.mjs | 132 ------- src/loops/adapters/normalized-invocation.mjs | 204 ---------- .../adapters/normalized-invocation.test.mjs | 143 ------- src/loops/agents/profile.mjs | 128 ------ src/loops/agents/profile.test.mjs | 32 -- src/loops/completion/completion.test.mjs | 26 +- src/loops/config/config.test.mjs | 129 +++--- src/loops/config/profiles.mjs | 50 --- src/loops/config/setup.mjs | 18 +- src/loops/contracts/agent-result.mjs | 2 +- src/loops/contracts/contracts.test.mjs | 2 +- src/loops/dsl/__fixtures__/review.ir.json | 2 +- .../dsl/__fixtures__/review.revisions.json | 2 +- src/loops/dsl/compile.test.mjs | 6 +- src/loops/dsl/grammar.mjs | 2 +- src/loops/dsl/ir-validate.mjs | 4 +- src/loops/minimal-review-e2e.test.mjs | 162 +------- src/loops/run/binder.mjs | 45 +-- src/loops/run/binder.test.mjs | 259 ------------ src/loops/run/controller.mjs | 18 +- src/loops/run/controller.test.mjs | 7 +- src/loops/run/launch-authority.test.mjs | 83 ++-- src/loops/run/m2-test-fixtures.mjs | 4 +- src/loops/run/production-runner.mjs | 123 ------ src/loops/run/reviewer-isolation.test.mjs | 20 - src/loops/run/run-artifacts.mjs | 23 +- src/loops/run/run-test-fixtures.mjs | 41 +- src/loops/run/runner.mjs | 14 +- src/loops/run/system-runner.mjs | 45 +++ src/loops/run/system-runner.test.mjs | 22 ++ website/src/content/docs/cli.mdx | 18 +- website/src/content/docs/loops.mdx | 70 ++-- 51 files changed, 610 insertions(+), 2283 deletions(-) create mode 100644 skills/burnlist/references/loop-provider-setup.md delete mode 100644 src/loops/adapters/codex-cli.mjs delete mode 100644 src/loops/adapters/codex-cli.test.mjs delete mode 100644 src/loops/adapters/normalized-invocation.mjs delete mode 100644 src/loops/adapters/normalized-invocation.test.mjs delete mode 100644 src/loops/agents/profile.mjs delete mode 100644 src/loops/agents/profile.test.mjs delete mode 100644 src/loops/config/profiles.mjs delete mode 100644 src/loops/run/binder.test.mjs delete mode 100644 src/loops/run/production-runner.mjs delete mode 100644 src/loops/run/reviewer-isolation.test.mjs create mode 100644 src/loops/run/system-runner.mjs create mode 100644 src/loops/run/system-runner.test.mjs diff --git a/README.md b/README.md index 9d73b9df..850dbf36 100644 --- a/README.md +++ b/README.md @@ -159,34 +159,34 @@ maker -> repository check -> fresh reviewer -> convergence gate -> CLI completio +------------ check failure / rejection ``` -Configure a write-authority maker and read-authority reviewer with `burnlist -agent profile add`, map them with `burnlist route set`, inspect and explicitly -trust the repository check with `burnlist loop capability inspect|trust`, then -assign an item with `burnlist loop assign`. Run `burnlist loop view ` +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`, run -or resume it in the foreground with `loop run|resume`, and inspect it with -`loop status|inspect`. `pause` and `stop` are idle-Run recovery controls: they -require no active foreground owner. For a live foreground Run, Ctrl-C belongs -to that owner (first interrupt requests pause; second requests controlled -stop). Proof-gated `reconcile` is for a demonstrably lost owner. Only a +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. -Hosts can also claim a prepared agent node, execute its exact bounded envelope, -and submit one identity-bound report. Burnlist remains transition and check -authority. Codex natively orchestrates Codex subagents and Claude natively -orchestrates Claude subagents; `builtin:codex-cli` is only the -Burnlist-managed process adapter, not the only Loop mechanism. The installed -skill provides the provider-neutral protocol and optional bounded recipes for -Codex CLI, AGY, Grok, and custom hosts. Host execution does not require, start, -or install Streaming Diff hooks. +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. The runner enforces -the graph, fresh reviewer process, budgets, closed outcomes, and atomic CLI -writes. Reviewer filesystem write denial is **supervised**, not an OS sandbox. -Parallel execution, Docker isolation, metrics gates, custom adapters, +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. +Parallel execution, Docker isolation, metrics gates, forecasting, worktrees, and background execution are deliberately unsupported in Stage 1. Items with no Loop assignment keep the ordinary direct `burnlist burn` workflow. diff --git a/bin/burnlist.mjs b/bin/burnlist.mjs index 685679e0..753b542a 100755 --- a/bin/burnlist.mjs +++ b/bin/burnlist.mjs @@ -116,15 +116,12 @@ Usage: burnlist loop report --result [--repo ] burnlist loop abandon --reason [--repo ] burnlist loop list [--repo ] - burnlist loop run|resume [--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 agent ... - burnlist route set --profile [--repo ] burnlist register [path] burnlist unregister [path] burnlist roots [--prune] @@ -167,12 +164,6 @@ if (args[0] === "oven") { } else if (args[0] === "loop") { const { runLoopCliEntry } = await import("../src/cli/loop-cli.mjs"); await runLoopCliEntry(args.slice(1)); -} else if (args[0] === "agent") { - const { runAgentCliEntry } = await import("../src/cli/loop-config-cli.mjs"); - await runAgentCliEntry(args.slice(1)); -} else if (args[0] === "route") { - const { runRouteCliEntry } = await import("../src/cli/loop-config-cli.mjs"); - await runRouteCliEntry(args.slice(1)); } else if (["register", "unregister", "roots", "init"].includes(args[0])) { await import("../src/cli/registry-cli.mjs"); } else { diff --git a/loops/review/review.loop b/loops/review/review.loop index 05ee5580..de06888a 100644 --- a/loops/review/review.loop +++ b/loops/review/review.loop @@ -1,11 +1,11 @@ - - - - - - + + + + + + diff --git a/scripts/package-paths.json b/scripts/package-paths.json index 5e16cc0e..65ca90a8 100644 --- a/scripts/package-paths.json +++ b/scripts/package-paths.json @@ -72,6 +72,7 @@ "skills/burnlist/references/differential-testing-data.md", "skills/burnlist/references/getting-started.md", "skills/burnlist/references/host-execution.md", + "skills/burnlist/references/loop-provider-setup.md", "skills/burnlist/references/installation.md", "skills/burnlist/references/loop-capability-example.json", "skills/burnlist/references/loop-providers/agy.md", @@ -115,9 +116,6 @@ "src/events/oven-event-store.mjs", "src/events/oven-event-stream-storage.mjs", "src/events/oven-events.mjs", - "src/loops/adapters/codex-cli.mjs", - "src/loops/adapters/normalized-invocation.mjs", - "src/loops/agents/profile.mjs", "src/loops/assignment/assignment.mjs", "src/loops/assignment/hazards.mjs", "src/loops/assignment/item-metadata.mjs", @@ -130,7 +128,6 @@ "src/loops/capabilities/snapshot.mjs", "src/loops/capabilities/trust.mjs", "src/loops/completion/completion.mjs", - "src/loops/config/profiles.mjs", "src/loops/config/setup.mjs", "src/loops/config/store.mjs", "src/loops/contracts/agent-result.mjs", @@ -156,7 +153,7 @@ "src/loops/run/controller.mjs", "src/loops/run/current-authority.mjs", "src/loops/run/host-execution.mjs", - "src/loops/run/production-runner.mjs", + "src/loops/run/system-runner.mjs", "src/loops/run/read-projection.mjs", "src/loops/run/run-artifacts.mjs", "src/loops/run/run-claim.mjs", diff --git a/scripts/smoke-global-install.mjs b/scripts/smoke-global-install.mjs index 92be1a72..c8a691d7 100755 --- a/scripts/smoke-global-install.mjs +++ b/scripts/smoke-global-install.mjs @@ -9,7 +9,6 @@ import { readlinkSync, realpathSync, rmSync, - chmodSync, writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; @@ -85,22 +84,32 @@ function writeLoopFixture(repo) { mkdirSync(join(repo, "src"), { recursive: true }); mkdirSync(join(repo, "notes", "burnlists", "inprogress", "260722-001"), { recursive: true }); writeFileSync(join(repo, "notes", "burnlists", "inprogress", "260722-001", "burnlist.md"), "# Smoke Loop\n\n## Active Checklist\n- [ ] L1 | Packed Loop proof\n\n## Completed\n"); - const binary = join(repo, "fixtures", "fake-codex"); - mkdirSync(dirname(binary), { recursive: true }); - writeFileSync(binary, `#!${testNode} -const fs=require("node:fs"),args=process.argv.slice(2),prompt=args.at(-1),lines=Object.fromEntries(prompt.split("\\n").filter((line)=>line.includes("=")).map((line)=>line.split(/=(.*)/s).slice(0,2))); -const counter=process.env.BURNLIST_FAKE_COUNTER,index=counter?Number(fs.readFileSync(counter,"utf8")):0,outcome=(process.env.BURNLIST_FAKE_OUTCOMES||"complete,approve").split(",")[index]||"approve"; -if(counter)fs.writeFileSync(counter,String(index+1)); -const final={schema:"burnlist.agent-final@1",runId:lines.run,nodeId:lines.node,attempt:Number(lines.attempt),claimId:lines.claim,invocationId:lines.invocation,assignmentId:lines.assignment,recipeRevision:lines.recipe,policyRevision:lines.policy,inputCandidate:lines.candidate,outcome,summary:"fake "+outcome}; -process.stdout.write(JSON.stringify({type:"thread.started",thread_id:"smoke-"+process.pid,model:args[args.indexOf("-m")+1]})+"\\n"); -process.stdout.write(JSON.stringify({type:"item.completed",item:{type:"agent_message",text:JSON.stringify(final)}})+"\\n"); -process.stdout.write(JSON.stringify({type:"turn.completed",usage:{input_tokens:1,output_tokens:1,cached_input_tokens:0}})+"\\n"); -`); - chmodSync(binary, 0o700); const capability = { id: "repo-verify", argv: [testNode, "-e", "process.exit(0)"], cwd: ".", environment: { inherit: ["PATH"], set: {} }, network: "deny", filesystem: { read: ["src"], write: [] }, output: { maxBytes: 1024 }, maxMilliseconds: 1000 }; writeFileSync(join(repo, ".burnlist", "loop-capabilities.json"), `${JSON.stringify({ schema: "burnlist-loop-capabilities@1", capabilities: [capability] })}\n`); writeFileSync(join(repo, "grants.json"), `${JSON.stringify({ argv: capability.argv, cwd: capability.cwd, environment: capability.environment, network: capability.network, filesystem: capability.filesystem, output: capability.output, maxMilliseconds: capability.maxMilliseconds })}\n`); - return { binary, itemRef: "item:260722-001#L1" }; + return { itemRef: "item:260722-001#L1" }; +} + +function hostReport(execution, outcome) { + return { + schema: "burnlist-loop-host-report@1", + result: { + schema: "agent-result@1", + runId: execution.runId, + nodeId: execution.nodeId, + attempt: execution.attempt, + claimId: execution.claimId, + assignmentId: execution.assignmentId, + invocationId: execution.invocationId, + recipeRevision: execution.recipeRevision, + policyRevision: execution.policyRevision, + inputCandidate: execution.inputCandidate, + outcome, + findings: [], + resolvedFindingIds: [], + }, + telemetry: null, + }; } function assertLoopFlow(cli) { @@ -108,11 +117,7 @@ function assertLoopFlow(cli) { mkdirSync(repoPath, { recursive: true }); run("git", ["init", "--quiet", repoPath]); const repo = realpathSync(repoPath); - const { binary, itemRef } = writeLoopFixture(repo); - const profile = (slug, authority) => command(cli, repo, ["agent", "profile", "add", slug, "--adapter", "builtin:codex-cli", "--binary", binary, "--model", "gpt-5.6-terra", "--effort", "medium", "--authority", authority]); - profile("maker", "write"); profile("reviewer", "read"); - command(cli, repo, ["route", "set", "implementation.standard", "--profile", "maker"]); - command(cli, repo, ["route", "set", "review.strong", "--profile", "reviewer"]); + const { itemRef } = writeLoopFixture(repo); const capability = JSON.parse(command(cli, repo, ["loop", "capability", "inspect", "repo-verify"], { capture: true })); command(cli, repo, ["loop", "capability", "trust", "repo-verify", "--revision", capability.revision, "--grants", join(repo, "grants.json")]); const setup = command(cli, repo, ["loop", "setup", "status"], { capture: true }); @@ -122,8 +127,17 @@ function assertLoopFlow(cli) { if (!view.includes("LOOP: loop:builtin:review")) throw new Error("packed CLI did not render the assigned Loop"); const runId = JSON.parse(command(cli, repo, ["loop", "create", itemRef], { capture: true })).runId; for (const operation of ["status", "inspect"]) JSON.parse(command(cli, repo, ["loop", operation, runId], { capture: true })); - const counter = join(repo, "counter"); writeFileSync(counter, "0"); - const result = JSON.parse(command(cli, repo, ["loop", "run", runId], { capture: true, env: { ...env, BURNLIST_FAKE_COUNTER: counter, BURNLIST_FAKE_OUTCOMES: "complete,approve" } })); + const reportPath = join(tmpRoot, "host-report.json"); + let result; + for (let attempts = 0; attempts < 8; attempts += 1) { + result = JSON.parse(command(cli, repo, ["loop", "status", runId], { capture: true })); + if (result.state === "converged") break; + const execution = JSON.parse(command(cli, repo, ["loop", "claim", runId], { capture: true })).execution; + const outcome = ["review", "final-review"].includes(execution.nodeId) + ? "approve" : "complete"; + writeFileSync(reportPath, `${JSON.stringify(hostReport(execution, outcome))}\n`); + command(cli, repo, ["loop", "report", execution.claimId, "--result", reportPath]); + } if (result.state !== "converged") throw new Error(`packed Loop did not converge: ${result.state}`); const first = JSON.parse(command(cli, repo, ["loop", "complete", runId], { capture: true })); const second = JSON.parse(command(cli, repo, ["loop", "complete", runId], { capture: true })); diff --git a/scripts/verify-test-files.mjs b/scripts/verify-test-files.mjs index 7b5e326f..a1fa32b9 100644 --- a/scripts/verify-test-files.mjs +++ b/scripts/verify-test-files.mjs @@ -92,9 +92,6 @@ export const verificationTestFiles = [ "src/loops/dsl/project-strategies.test.mjs", "src/loops/view/render.test.mjs", "src/loops/capabilities/capabilities.test.mjs", - "src/loops/adapters/codex-cli.test.mjs", - "src/loops/adapters/normalized-invocation.test.mjs", - "src/loops/agents/profile.test.mjs", "src/loops/contracts/contracts.test.mjs", "src/loops/contracts/host-execution.test.mjs", "src/loops/run/run-store.test.mjs", @@ -109,9 +106,8 @@ export const verificationTestFiles = [ "src/loops/run/state-machine.test.mjs", "src/loops/run/budgets.test.mjs", "src/loops/run/runner.test.mjs", - "src/loops/run/binder.test.mjs", + "src/loops/run/system-runner.test.mjs", "src/loops/run/run-claim.test.mjs", - "src/loops/run/reviewer-isolation.test.mjs", "scripts/verify-source-scan.test.mjs", "ovens/streaming-diff/engine/streaming-diff-hook-adapters.test.mjs", "src/server/oven-bindings.test.mjs", diff --git a/skills/burnlist/SKILL.md b/skills/burnlist/SKILL.md index b6660805..24491579 100644 --- a/skills/burnlist/SKILL.md +++ b/skills/burnlist/SKILL.md @@ -33,7 +33,8 @@ Read references only when their trigger applies: - `references/creating-ovens.md`: authoring a new .oven declarative source (grammar, elements, binding, themes, compile-to-IR walkthrough). - `references/oven-event-coordination.md`: mandatory for multi-Burnlist worker coordination, generic Oven progress events, replayable subscriptions, and event-triggered coordinator wakeups. - `references/host-execution.md`: generic host claim/execute/report protocol for a prepared Loop Run; read before a host executes a claimed agent node. -- `references/loop-providers/.md`: optional bounded provider recipe for Claude native, Codex native, Codex CLI, AGY, Grok, or a custom host. Read only after choosing that provider; native hosts do not need a recipe to use the generic protocol. +- `references/loop-provider-setup.md`: mandatory before the first Loop when available native agents, CLIs, logins, or subscriptions are unknown; inventory safely, show the user, and ask what to enable. +- `references/loop-providers/.md`: bounded invocation recipe for Claude native, Codex native, Codex CLI, AGY, Grok, or a custom host. Read the selected provider recipe before invoking it. Do not load cold references for a normal single-item implementation unless needed. If a task touches a cold-rule area, read the matching reference before editing Burnlist state in that area. @@ -161,8 +162,9 @@ assigns an item to it. It is one serial foreground path: maker, trusted repository check, fresh reviewer, convergence gate, then CLI-owned completion. Items without an assignment keep the direct Burnlist workflow. -Before creating a Run, configure distinct local profiles and routes, inspect -and explicitly trust the repository check capability, then assign the item: +Before creating a Run, inspect and explicitly trust the repository check +capability, then assign the item. Burnlist stores no provider profile, route, +subscription, binary, model, or login: The installed skill includes `references/loop-capability-example.json`. Copy its `catalog` object to the repository capability catalog and its `grants` @@ -178,30 +180,16 @@ the absolute check command and paths with your repository's reviewed command: {"argv":["/absolute/path/to/repository-check","--verify"],"cwd":".","environment":{"inherit":["PATH"],"set":{}},"network":"deny","filesystem":{"read":["src"],"write":[]},"output":{"maxBytes":65536},"maxMilliseconds":60000} ``` -Accepted profile models are `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna`, -and `gpt-5.3-codex-spark`; efforts are `minimal`, `low`, `medium`, `high`, -`xhigh`, and `max`. - -`builtin:codex-cli` is currently the only Burnlist-managed process adapter. -That does not limit native host orchestration: Codex normally runs Codex -subagents natively, Claude normally runs Claude subagents natively, and any host -may claim and report Loop nodes through native orchestration. Claude, Grok, AGY, -and custom hosts are not managed adapters, though they may optionally invoke -Codex through a `codex-cli` recipe; do not promise or configure them as managed -Loop backends. - -For a host-orchestrated agent node, read `references/host-execution.md` before -claiming it. The core protocol is provider-neutral: claim the prepared node, -execute its exact bounded envelope through an available native or external -mechanism, then return one bound report. Never invent a transition or execute a -managed check. Provider recipes are optional detail, not a prerequisite for -native host orchestration. +Every agent node is host-orchestrated. The host owns every provider invocation. +Read `references/host-execution.md` +before claiming it. If available subscriptions are unknown, read +`references/loop-provider-setup.md`, show the user the safe inventory, and ask +which ready providers to use or help set up. Then read the selected provider +recipe. The host may use native subagents or the Codex, AGY, Grok, or another +CLI, but it—not Burnlist—launches and supervises them. Never invent a +transition or execute a managed check. ```sh -burnlist agent profile add maker --adapter builtin:codex-cli --binary --model --effort --authority write -burnlist agent profile add reviewer --adapter builtin:codex-cli --binary --model --effort --authority read -burnlist route set implementation.standard --profile maker -burnlist route set review.strong --profile reviewer burnlist loop capability inspect repo-verify burnlist loop capability trust repo-verify --revision cp1-sha256: --grants burnlist loop assign item:# loop:builtin:review @@ -210,16 +198,12 @@ burnlist loop assign item:# loop:builtin:review Run `burnlist loop setup status` before `loop create`. Paste the complete `burnlist loop view item:#` ASCII output into the task handoff or review request: it records the frozen graph, retries, completion -path, and pins. Control a Run only with `loop run|pause|resume|stop`, -inspect with `loop status|inspect`, use proof-gated `loop reconcile` only for a -demonstrably lost foreground owner, and apply a converged Run through the -idempotent `loop complete` command. A valid reconcile proof terminalizes as -`needs-human`; it never pauses a Run. - -Standalone `pause` and `stop` only work while no foreground owner holds the -Run lease. For live foreground work, the owner handles Ctrl-C: first interrupt -requests pause after its child exits, second requests controlled stop. Do not -claim that a separate CLI invocation can take over a live Run. +path, and pins. For each agent node use `loop claim`, invoke the selected +provider through the host, and submit `loop report`. Reporting automatically +advances Burnlist-owned checks and gates to the next agent or terminal node. +Inspect with `loop status|inspect`, control idle Runs with `loop pause|stop`, +use proof-gated `loop reconcile` only for a demonstrably lost host claim, and +apply a converged Run through idempotent `loop complete`. The canonical Checklist Oven keeps a centered KPI row above the side-by-side Progress ledger and Completion trend. Its unified Items list contains current, @@ -237,7 +221,7 @@ Stage 1 labels fresh reviewer process, graph grammar, budgets, closed outcomes, and atomic canonical CLI writes `enforced`; ordinary drift checks are `detected-at-boundaries`; reviewer filesystem write denial is `supervised`. It is not Docker or an OS sandbox. Parallelism, nested agents, metric gates, -custom adapters, worktrees, background execution, Docker isolation, and +worktrees, background execution, Docker isolation, and forecasting are `unsupported`. The Checklist UI is read-only and displays active, paused, error, and terminal Run states; it never controls a Run. diff --git a/skills/burnlist/references/host-execution.md b/skills/burnlist/references/host-execution.md index 009bde23..c97df94b 100644 --- a/skills/burnlist/references/host-execution.md +++ b/skills/burnlist/references/host-execution.md @@ -1,8 +1,8 @@ # Host-executed Loop nodes Use this reference when a host executes a prepared agent node. It is the whole -provider-neutral contract; choose a `loop-providers/` recipe only if its -invocation details help. +provider-neutral contract; choose the matching `loop-providers/` recipe before +invocation. If subscriptions are unknown, read `loop-provider-setup.md` first. ## Claim, execute, report @@ -90,12 +90,12 @@ invocation details help. ## Ownership and observability -The host owns invocation and optional best-effort telemetry. Burnlist owns the -frozen graph, claim authority, validation, deterministic checks, transition -selection, canonical journal, and item completion. Native provider execution, -the managed `builtin:codex-cli` process adapter, and external tools all feed -this same boundary. `builtin:codex-cli` is optional for cross-engine use, not -the only Loop mechanism. +The host owns every provider invocation and optional best-effort telemetry. +Burnlist owns the frozen graph, claim authority, validation, deterministic +checks, transition selection, canonical journal, and item completion. After +`loop report`, Burnlist automatically advances trusted checks and graph-only +nodes until the next host agent claim or a terminal state. It never launches a +provider process. Installable skills and Streaming Diff hooks are independent. Neither installs, selects, or starts a host executor; hooks only provide optional observational diff --git a/skills/burnlist/references/loop-provider-setup.md b/skills/burnlist/references/loop-provider-setup.md new file mode 100644 index 00000000..c708211f --- /dev/null +++ b/skills/burnlist/references/loop-provider-setup.md @@ -0,0 +1,51 @@ +# Loop provider setup + +Use this before the first host-executed Loop when the user's available agent +subscriptions are unknown. Burnlist does not install, authenticate, configure, +or launch an agent provider. + +## Inventory first + +Check native subagents exposed by the current host, then perform only +non-mutating CLI discovery: + +```sh +command -v codex && codex --version +command -v agy && agy models +command -v grok && grok models +``` + +If an installed provider skill supplies a safer auth preflight, prefer it. +Never read or print token files. A binary or cached auth record does not prove a +paid subscription; a successful live model listing proves only current access. + +Present a compact inventory before assigning providers: + +```text +Provider Native/CLI Login Models or subscription Intended Loop roles +Codex CLI ready maker, reviewer +AGY CLI setup unknown optional reviewer +Grok CLI ready optional challenger +``` + +Ask the user whether they want to use the ready providers and whether they want +instructions for any missing login or installation. Do not start OAuth, install +a CLI, or alter provider configuration without that choice. + +## Login handoff + +- Codex: ask the user to run `codex login` if its own status or first invocation + reports that authentication is missing. +- AGY: ask the user to run `agy` interactively and complete browser OAuth. +- Grok: ask the user to run `grok login`. + +After the user completes setup, rerun the non-mutating live check. Provider +selection is working-session context, not Burnlist canonical state: never write +subscriptions, tokens, or provider profiles into `.burnlist/`. + +## Choose per node + +Use the Loop node's role, authority, and intelligence as selection guidance, +then choose among the providers the user made available. Record observed +provider/model/effort only in optional host telemetry. The `.loop` graph does +not pin a subscription or grant Burnlist authority to launch a provider. diff --git a/skills/burnlist/references/loop-providers/agy.md b/skills/burnlist/references/loop-providers/agy.md index c4ae8077..e1c8a117 100644 --- a/skills/burnlist/references/loop-providers/agy.md +++ b/skills/burnlist/references/loop-providers/agy.md @@ -1,17 +1,31 @@ # AGY recipe -Use only when AGY is available to the host. Read -[Host-executed Loop nodes](../host-execution.md) first, and defer to an -installed AGY skill for its current invocation syntax. +Read [Host-executed Loop nodes](../host-execution.md) first. The host owns the +AGY process; Burnlist never launches or configures it. -- **Invocation ownership:** the host invokes and supervises AGY; AGY is not a - Burnlist-managed adapter. -- **Context freedom:** choose AGY's prompt and session mechanics while carrying - the exact prepared envelope as the authority-bearing input. -- **Identity:** preserve the complete generic correlation tuple through AGY and - bind its final result to that tuple. -- **Telemetry:** use AGY data only when actually returned and label it - `host-reported`; otherwise leave it `null`. -- **Fallback:** use native host execution, Codex CLI where deliberately - configured, or abandon the claim. Never invent provider output or a graph - transition. +Preflight without reading credentials: + +```sh +command -v agy +agy models +``` + +If login is missing, ask the user to run `agy` interactively and complete its +browser OAuth. For a claimed node, put the decoded invocation and bounded +context in a prompt file and run AGY in the foreground: + +```sh +# Read-only review +agy --mode plan --sandbox --print-timeout 10m --print "$(cat )" \ + > 2>&1 + +# Explicitly authorized write node +agy --mode accept-edits --dangerously-skip-permissions --print-timeout 10m \ + --print "$(cat )" > 2>&1 +``` + +Add `--model ` and `--effort low|medium|high` only when intentionally +selected. The host supervises the process, preserves the complete correlation +tuple, verifies the workspace, and creates the bound report. AGY never chooses +the graph edge. Report only observed telemetry; otherwise use `null`. On +failure, use another host mechanism or abandon the claim. diff --git a/skills/burnlist/references/loop-providers/claude-native.md b/skills/burnlist/references/loop-providers/claude-native.md index 16889768..2a62ca7f 100644 --- a/skills/burnlist/references/loop-providers/claude-native.md +++ b/skills/burnlist/references/loop-providers/claude-native.md @@ -14,4 +14,4 @@ Read [Host-executed Loop nodes](../host-execution.md) first. them `host-reported`, otherwise use `null`. - **Fallback:** if native delegation is unavailable, execute directly as the Claude host or use another available mechanism, then follow the same report - and abandonment rules. Do not configure Claude as a managed adapter. + and abandonment rules. Burnlist never configures or launches Claude. diff --git a/skills/burnlist/references/loop-providers/codex-cli.md b/skills/burnlist/references/loop-providers/codex-cli.md index 1fd74061..3d8adddf 100644 --- a/skills/burnlist/references/loop-providers/codex-cli.md +++ b/skills/burnlist/references/loop-providers/codex-cli.md @@ -1,32 +1,38 @@ # Codex CLI recipe -Read [Host-executed Loop nodes](../host-execution.md) first. There are two -different Codex CLI paths; never describe a direct host launch as the managed -adapter. - -## Managed `builtin:codex-cli` - -- **Invocation ownership:** the Burnlist runner launches and cancels the - configured process through `loop run` or `loop resume`; the host does not - claim/report that managed invocation. -- **Context and identity:** the runner supplies the frozen input and preserves - its identity internally; neither the child nor a supervising host selects an - edge. -- **Telemetry:** runner measurements are `managed`; missing values are - unavailable, not inferred. -- **Fallback:** if managed setup is unavailable, use native host orchestration - or the direct host-claim path below; do not call that fallback an adapter. - -## Direct Codex CLI for a host claim - -- **Invocation ownership:** the host launches, supervises, and cancels its own - Codex CLI process after `burnlist loop claim`; this is not `builtin:codex-cli`. -- **Context freedom:** give Codex the exact decoded invocation input and only - bounded supplemental context. Do not ask it to choose a transition. -- **Identity:** retain the complete generic correlation tuple and submit the - one bound host report under the original claim id. -- **Telemetry:** observations from this host-owned process are - `host-reported`; unavailable fields are `null`. -- **Fallback:** use native Codex orchestration, another available host - mechanism, or abandon the claim. `builtin:codex-cli` remains the only - Burnlist-managed process adapter, not the only way a Loop node can run. +Read [Host-executed Loop nodes](../host-execution.md) first. The host owns the +Codex process; Burnlist never launches or configures it. + +## Preflight + +```sh +command -v codex +codex --version +``` + +If authentication is missing, ask the user to run `codex login`. Do not inspect +credential files. + +## Invoke + +Put the decoded prepared invocation plus bounded supplemental context in a +prompt file, then run one foreground process: + +```sh +codex exec --json --ephemeral \ + -m \ + -c model_reasoning_effort= \ + -s \ + -C \ + --skip-git-repo-check \ + -- "$(cat )" 2>&1 +``` + +Use `workspace-write` only for a write-authority task node and `read-only` for +review. Keep the process foreground and supervised; never start two writers in +one worktree. Parse its final answer, verify it against the workspace, and +construct the bound host report yourself. Codex output is not a graph +transition and must not replace any claim identity. + +Telemetry is `host-reported`; copy only values actually observed. If the +process cannot finish, abandon the claim rather than fabricating a result. diff --git a/skills/burnlist/references/loop-providers/custom.md b/skills/burnlist/references/loop-providers/custom.md index d55d9678..a0988966 100644 --- a/skills/burnlist/references/loop-providers/custom.md +++ b/skills/burnlist/references/loop-providers/custom.md @@ -12,4 +12,4 @@ Use for a host not covered by another recipe. Read - **Telemetry:** use only observed values with `host-reported`; leave absent fields `null`. - **Fallback:** execute directly if possible; otherwise abandon the live claim - with a permitted reason. A custom host is never silently a managed adapter. + with a permitted reason. Burnlist never configures or launches the provider. diff --git a/skills/burnlist/references/loop-providers/grok.md b/skills/burnlist/references/loop-providers/grok.md index 0c6f27c5..31eb3900 100644 --- a/skills/burnlist/references/loop-providers/grok.md +++ b/skills/burnlist/references/loop-providers/grok.md @@ -1,16 +1,33 @@ # Grok recipe -Use only when Grok is available to the host. Read -[Host-executed Loop nodes](../host-execution.md) first, and defer to an -installed Grok skill for its current invocation syntax. +Read [Host-executed Loop nodes](../host-execution.md) first. The host owns the +Grok process; Burnlist never launches or configures it. -- **Invocation ownership:** the host invokes and supervises Grok; Grok is not a - Burnlist-managed adapter. -- **Context freedom:** select Grok session mechanics while retaining the exact - prepared envelope as the only transition-relevant input. -- **Identity:** preserve the full generic correlation tuple and bind the final - report to it without reconstruction. -- **Telemetry:** emit Grok/model/usage only when known, as `host-reported`; - unknown values are `null`. -- **Fallback:** use a native host path, a deliberately configured Codex CLI - recipe, or abandon the claim. Do not promise Grok as a managed Loop backend. +Preflight without reading credentials: + +```sh +command -v grok +grok models +``` + +If login is missing, ask the user to run `grok login`. For a claimed node, put +the decoded invocation and bounded context in a prompt file and run Grok in the +foreground: + +```sh +# Read-only review +grok --cwd --prompt-file \ + --permission-mode plan --no-memory --no-subagents --disable-web-search \ + --output-format json > 2>&1 + +# Explicitly authorized write node +grok --cwd --prompt-file \ + --permission-mode auto --always-approve --no-memory --no-subagents \ + --disable-web-search --output-format json > 2>&1 +``` + +Add `--model `, `--reasoning-effort `, `--max-turns `, or +`--check` deliberately. The host supervises the process, preserves the full +correlation tuple, verifies the workspace, and constructs the bound report. +Grok never chooses a graph edge. Use only observed telemetry; abandon the claim +if no valid result can be produced. diff --git a/src/cli/commands-help.test.mjs b/src/cli/commands-help.test.mjs index 6bd06c9a..ab6bda9f 100644 --- a/src/cli/commands-help.test.mjs +++ b/src/cli/commands-help.test.mjs @@ -51,14 +51,13 @@ test("top-level and Oven help expose the validated use and set flow", () => { assert.match(top.stdout, /burnlist loop report --result \[--repo \]/u); assert.match(top.stdout, /burnlist loop abandon --reason \[--repo \]/u); assert.match(top.stdout, /burnlist loop list \[--repo \]/u); - assert.match(top.stdout, /burnlist loop run\|resume \[--repo \]/u); + assert.doesNotMatch(top.stdout, /burnlist loop run\|resume/u); assert.match(top.stdout, /burnlist loop status\|inspect \[--repo \]/u); assert.match(top.stdout, /burnlist loop pause\|stop \[--repo \] \(idle Run only\)/u); assert.match(top.stdout, /burnlist loop reconcile --recovery-proof \[--repo \]/u); assert.match(top.stdout, /burnlist loop complete \[--repo \]/u); assert.match(top.stdout, /burnlist loop capability \.\.\./u); - assert.match(top.stdout, /burnlist agent \.\.\./u); - assert.match(top.stdout, /burnlist route set --profile \[--repo \]/u); + assert.doesNotMatch(top.stdout, /burnlist agent |burnlist route set/u); const oven = run(context.directory, ["oven", "help"]); assert.equal(oven.status, 0, oven.stderr); @@ -73,19 +72,15 @@ test("top-level and Oven help expose the validated use and set flow", () => { test("Loop local configuration help exposes only explicit setup commands", () => { const context = fixture({ git: false }); try { - for (const [args, usage] of [ - [["agent", "--help"], /burnlist agent profile add /u], - [["agent", "--help"], /burnlist agent doctor /u], - [["route", "--help"], /burnlist route set /u], - [["loop", "--help"], /burnlist loop capability trust --revision cp1-sha256: --grants /u], - ]) { - const result = run(context.directory, args); - assert.equal(result.status, 0, result.stderr); assert.match(result.stdout, usage); assert.equal(result.stderr, ""); - } - for (const args of [["agent", "controller", "add", "host"], ["agent", "preflight", "maker"]]) { + const loop = run(context.directory, ["loop", "--help"]); + assert.equal(loop.status, 0, loop.stderr); + assert.match(loop.stdout, + /burnlist loop capability trust --revision cp1-sha256: --grants /u); + assert.doesNotMatch(loop.stdout, /agent profile|route set|builtin:codex-cli/u); + for (const args of [["agent", "--help"], ["route", "--help"]]) { const result = run(context.directory, args); assert.equal(result.status, 2, args.join(" ")); - assert.match(result.stderr, /Usage: burnlist agent profile add/u); + assert.doesNotMatch(result.stderr, /builtin:codex-cli/u); } } finally { context.cleanup(); } }); @@ -95,10 +90,10 @@ test("nested Loop help snapshots every Stage 1 control", () => { try { const result = run(context.directory, ["loop", "--help"]); assert.equal(result.status, 0, result.stderr); - for (const command of ["create", "next|claim", "report --result ", "abandon --reason ", "list", "run|pause|resume|stop|complete", "status|inspect", "reconcile"]) { + for (const command of ["create", "next|claim", "report --result ", "abandon --reason ", "list", "pause|stop|complete", "status|inspect", "reconcile"]) { assert.match(result.stdout, new RegExp(`burnlist loop ${command}`, "u")); } - assert.match(result.stdout, /pause\|resume\|stop\|complete /u); + assert.doesNotMatch(result.stdout, /loop run|resume/u); assert.equal(result.stderr, ""); } finally { context.cleanup(); } }); @@ -150,7 +145,7 @@ test("hooks status and uninstall name their own Git requirement", () => { test("Review Loop documentation command matrix runs against the production fixture", (t) => { const directory = mkdtempSync(join(tmpdir(), "burnlist-loop-docs-")); t.after(() => rmSync(directory, { recursive: true, force: true })); - const { repo, binary } = createProductionRunAuthority(join(directory, "repo")); + const { repo } = createProductionRunAuthority(join(directory, "repo")); const loop = (...args) => run(repo, ["loop", ...args, "--repo", repo]); const command = (...args) => run(repo, [...args, "--repo", repo]); const planPath = join(repo, "notes", "burnlists", "inprogress", "260722-001", "burnlist.md"); @@ -162,15 +157,6 @@ test("Review Loop documentation command matrix runs against the production fixtu "", "## Completed", ].join("\n"))); - const profile = (slug, authority) => command("agent", "profile", "add", slug, - "--adapter", "builtin:codex-cli", "--binary", binary, "--model", "gpt-5.3-codex-spark", - "--effort", "medium", "--authority", authority); - for (const result of [profile("maker", "write"), profile("reviewer", "read"), - command("route", "set", "implementation.standard", "--profile", "maker"), - command("route", "set", "review.strong", "--profile", "reviewer"), - command("agent", "doctor", "maker"), command("agent", "doctor", "reviewer")]) { - assert.equal(result.status, 0, result.stderr); - } const inspected = loop("capability", "inspect", "repo-verify"); assert.equal(inspected.status, 0, inspected.stderr); const revision = JSON.parse(inspected.stdout).revision; @@ -199,19 +185,11 @@ test("Review Loop documentation command matrix runs against the production fixtu const result = loop(...args); assert.equal(result.status, 0, `${args.join(" ")}: ${result.stderr}`); } - const counter = join(directory, "counter"); - writeFileSync(counter, "0"); - const executed = spawnSync(process.execPath, [cli, "loop", "run", runId, "--repo", repo], { - cwd: repo, - encoding: "utf8", - env: { ...process.env, BURNLIST_FAKE_COUNTER: counter, BURNLIST_FAKE_OUTCOMES: "complete,complete,complete,approve,complete,approve" }, - }); - assert.equal(executed.status, 0, executed.stderr); - assert.equal(JSON.parse(executed.stdout).state, "converged"); - for (const args of [["complete", runId], ["complete", runId]]) { - const result = loop(...args); - assert.equal(result.status, 0, `${args.join(" ")}: ${result.stderr}`); - } + const claim = loop("claim", runId); + assert.equal(claim.status, 0, claim.stderr); + assert.equal(JSON.parse(claim.stdout).execution.nodeId, "start"); + assert.equal(loop("abandon", JSON.parse(claim.stdout).execution.claimId, + "--reason", "host-cancelled").status, 0); const pauseRef = "item:260722-001#L31"; assert.equal(loop("assign", pauseRef, "loop:builtin:review").status, 0); @@ -219,13 +197,9 @@ test("Review Loop documentation command matrix runs against the production fixtu const paused = loop("pause", pausedRun); assert.equal(paused.status, 0, paused.stderr); assert.equal(JSON.parse(paused.stdout).state, "paused"); - writeFileSync(counter, "0"); - const resumed = spawnSync(process.execPath, [cli, "loop", "resume", pausedRun, "--repo", repo], { - cwd: repo, encoding: "utf8", - env: { ...process.env, BURNLIST_FAKE_COUNTER: counter, BURNLIST_FAKE_OUTCOMES: "complete,complete,complete,approve,complete,approve" }, - }); + const resumed = loop("claim", pausedRun); assert.equal(resumed.status, 0, resumed.stderr); - assert.equal(JSON.parse(resumed.stdout).state, "converged"); + assert.equal(JSON.parse(resumed.stdout).execution.nodeId, "start"); const stoppedRef = "item:260722-001#L32"; assert.equal(loop("assign", stoppedRef, "loop:builtin:review").status, 0); @@ -251,14 +225,13 @@ test("Review Loop documentation preserves Stage 1 boundaries", () => { join(root, "website", "src", "content", "docs", "loops.mdx"), join(root, "skills", "burnlist", "SKILL.md"), ].map((path) => readFileSync(path, "utf8")).join("\n"); - assert.match(files, /filesystem write denial.*supervised/us); - assert.match(files, /fresh reviewer process.*enforced/us); + assert.match(files, /host owns every provider invocation/us); assert.match(files, /[Pp]arallelism.*unsupported/us); assert.match(files, /Docker isolation.*unsupported/us); - assert.match(files, /custom adapters.*unsupported/us); + assert.match(files, /never .*launches an? .*provider/us); assert.match(files, /forecasting.*unsupported/us); assert.match(files, /skill.*hooks.*independent/us); assert.match(files, /loop-capability-example\.json/us); - assert.match(files, /gpt-5\.6-terra/us); - assert.match(files, /idle.*foreground owner/us); + assert.match(files, /loop-provider-setup\.md/us); + assert.match(files, /Codex.*AGY.*Grok/us); }); diff --git a/src/cli/loop-cli.mjs b/src/cli/loop-cli.mjs index 68415d9c..f0843d1a 100644 --- a/src/cli/loop-cli.mjs +++ b/src/cli/loop-cli.mjs @@ -7,7 +7,7 @@ import { loopConfigUsage, runLoopConfigCli } from "./loop-config-cli.mjs"; import { resolveUmbrella } from "./umbrella.mjs"; import { runStore } from "../loops/run/run-store.mjs"; import { createLoopController } from "../loops/run/controller.mjs"; -import { createProductionRun, createStoredProductionRunRunner } from "../loops/run/binder.mjs"; +import { createProductionRun, createStoredSystemRunRunner } from "../loops/run/binder.mjs"; import { completeLoopRun } from "../loops/completion/completion.mjs"; function usageText() { return loopConfigUsage(); } @@ -66,7 +66,7 @@ export async function renderLoopView({ selector, repoRoot, runReader }) { return { authority, output: renderResolvedLoopView(authority) }; } -export async function runLoopCli(tokens, { runReader, runnerFor, stdout = process.stdout, processObject = process } = {}) { +export async function runLoopCli(tokens, { runReader, runnerFor, stdout = process.stdout } = {}) { if (tokens[0] === "--help" || tokens[0] === "-h") { stdout.write(`${usageText()}\n`); return null; } if (["capability", "setup"].includes(tokens[0])) { const value = await runLoopConfigCli(tokens); stdout.write(value.output); return value; @@ -83,11 +83,11 @@ export async function runLoopCli(tokens, { runReader, runnerFor, stdout = proces const result = completeLoopRun({ repoRoot: opts.repo, runId: opts.positionals[0] }); stdout.write(`${JSON.stringify({ schema: "burnlist-loop-completion@1", ...result })}\n`); return result; } - if (["list", "status", "inspect", "next", "claim", "report", "abandon", "run", "pause", "resume", "stop", "reconcile"].includes(verb)) { + if (["list", "status", "inspect", "next", "claim", "report", "abandon", "pause", "stop", "reconcile"].includes(verb)) { const allowed = verb === "list" ? 0 : 1; if (opts.positionals.length !== allowed) throw usageError(); const store = runStore(opts.repo); - const suppliedRunnerFor = runnerFor ?? ((runId) => createStoredProductionRunRunner({ repoRoot: opts.repo, store, runId })); + const suppliedRunnerFor = runnerFor ?? ((runId) => createStoredSystemRunRunner({ repoRoot: opts.repo, store, runId })); const runners = new Map(), runtimeRunnerFor = (runId) => { if (!runners.has(runId)) runners.set(runId, suppliedRunnerFor(runId)); return runners.get(runId); @@ -98,7 +98,7 @@ export async function runLoopCli(tokens, { runReader, runnerFor, stdout = proces : verb === "inspect" ? controller.inspect(opts.positionals[0]) : verb === "next" ? controller.inspect(opts.positionals[0]) : verb === "claim" ? publicClaim(controller.claim(opts.positionals[0])) - : verb === "report" ? controller.report(opts.positionals[0], resultBytes(opts.resultFile)) + : verb === "report" ? await controller.report(opts.positionals[0], resultBytes(opts.resultFile)) : verb === "abandon" ? (() => { const runId = store.resolveClaimRef(opts.positionals[0]); if (store.read(runId).execution.terminal) { const error = new Error("ClaimRef is stale"); error.exitCode = 1; throw error; } @@ -109,12 +109,7 @@ export async function runLoopCli(tokens, { runReader, runnerFor, stdout = proces : verb === "pause" ? controller.pause(opts.positionals[0]) : verb === "stop" ? controller.stop(opts.positionals[0]) : verb === "reconcile" ? controller.reconcile(opts.positionals[0], opts.recoveryProof ? { generation: store.read(opts.positionals[0]).execution.generation, recoveryProof: opts.recoveryProof } : null) - : verb === "resume" || verb === "run" ? await (() => { - const runner = runtimeRunnerFor(opts.positionals[0]); let signalled = false; - const onInterrupt = () => { if (!signalled) { signalled = true; runner.requestPause?.(); } else runner.requestStop?.(); }; - processObject.on?.("SIGINT", onInterrupt); - return controller.run(opts.positionals[0]).finally(() => processObject.removeListener?.("SIGINT", onInterrupt)); - })() : null; + : null; stdout.write(controller.render(result)); return result; } if (verb === "assign" && opts.positionals.length === 2) { diff --git a/src/cli/loop-config-cli.mjs b/src/cli/loop-config-cli.mjs index 547aa249..6ce5e8e1 100644 --- a/src/cli/loop-config-cli.mjs +++ b/src/cli/loop-config-cli.mjs @@ -2,15 +2,11 @@ import { closeSync, constants, fstatSync, lstatSync, openSync, readSync } from " import { join, parse as parsePath, relative, resolve, sep } from "node:path"; import { readCapabilityCatalog, resolveCapability } from "../loops/capabilities/contract.mjs"; import { trustCapability } from "../loops/capabilities/trust.mjs"; -import { doctorProfile, saveProfile, saveRoute } from "../loops/config/profiles.mjs"; import { renderSetupStatus, setupStatus } from "../loops/config/setup.mjs"; import { rawSha256 } from "../loops/dsl/hash.mjs"; import { resolveUmbrella } from "./umbrella.mjs"; -const profileUsage = "Usage: burnlist agent profile add --adapter builtin:codex-cli --binary --model --effort --authority read|write [--repo ]"; -const routeUsage = "Usage: burnlist route set --profile [--repo ]"; -const agentUsage = `${profileUsage}\n burnlist agent doctor [--repo ]`; -const loopUsage = "Usage: burnlist loop assign [--repo ] | burnlist loop unassign [--repo ] | burnlist loop view [--repo ]\n burnlist loop create [--repo ]\n burnlist loop next|claim [--repo ]\n burnlist loop report --result [--repo ]\n burnlist loop abandon --reason [--repo ]\n burnlist loop run|pause|resume|stop|complete [--repo ]\n burnlist loop list [--repo ] | burnlist loop status|inspect [--repo ]\n burnlist loop reconcile --recovery-proof [--repo ]\n burnlist loop capability inspect [--repo ]\n burnlist loop capability trust --revision cp1-sha256: --grants [--repo ]\n burnlist loop setup status [--repo ]"; +const loopUsage = "Usage: burnlist loop assign [--repo ] | burnlist loop unassign [--repo ] | burnlist loop view [--repo ]\n burnlist loop create [--repo ]\n burnlist loop next|claim [--repo ]\n burnlist loop report --result [--repo ]\n burnlist loop abandon --reason [--repo ]\n burnlist loop pause|stop|complete [--repo ]\n burnlist loop list [--repo ] | burnlist loop status|inspect [--repo ]\n burnlist loop reconcile --recovery-proof [--repo ]\n burnlist loop capability inspect [--repo ]\n burnlist loop capability trust --revision cp1-sha256: --grants [--repo ]\n burnlist loop setup status [--repo ]"; function fail(message, exitCode = 2) { throw Object.assign(new Error(message), { exitCode }); } function parse(tokens, allowed = []) { @@ -63,36 +59,6 @@ function boundedJson(path) { } export function loopConfigUsage() { return loopUsage; } -export function agentConfigUsage() { return agentUsage; } - -export async function runAgentCli(tokens, { stdout = process.stdout } = {}) { - if (tokens[0] === "--help" || tokens[0] === "-h") return { output: `${agentUsage}\n` }; - const [verb, ...tail] = tokens; - if (verb === "profile" && tail[0] === "add") { - const rest = tail.slice(1); - const parsed = parse(rest, ["--adapter", "--binary", "--model", "--effort", "--authority", "--repo"]); requireOnly(parsed.positionals, 1, profileUsage); - for (const key of ["--adapter", "--binary", "--model", "--effort", "--authority"]) if (!parsed.values[key]) fail(profileUsage); - const result = saveProfile({ repoRoot: parsed.repo, slug: parsed.positionals[0], adapter: parsed.values["--adapter"], binary: parsed.values["--binary"], model: parsed.values["--model"], effort: parsed.values["--effort"], authority: parsed.values["--authority"] }); - return { output: canonical(result), result }; - } - if (verb === "doctor") { - const parsed = parse(tail, ["--repo"]); requireOnly(parsed.positionals, 1, agentUsage); - const result = await doctorProfile({ repoRoot: parsed.repo, slug: parsed.positionals[0] }); - if (result.available) return { output: `Agent ${result.profile.id}: available\nConfiguration authority: profile record only; launch verification is deferred.\n`, result }; - return { output: `Agent ${result.profile.id}: unavailable, not ready\nREMEDIATION: ${profileUsage}\n`, result, exitCode: 1 }; - } - fail(agentUsage); -} - -export function runRouteCli(tokens) { - if (tokens[0] === "--help" || tokens[0] === "-h") return { output: `${routeUsage}\n` }; - const [verb, ...rest] = tokens; - if (verb !== "set") fail(routeUsage); - const parsed = parse(rest, ["--profile", "--repo"]); requireOnly(parsed.positionals, 1, routeUsage); - if (!parsed.values["--profile"]) fail(routeUsage); - const result = saveRoute({ repoRoot: parsed.repo, route: parsed.positionals[0], profile: parsed.values["--profile"] }); - return { output: canonical(result), result }; -} export async function runLoopConfigCli(tokens) { const [kind, action, ...rest] = tokens; @@ -118,11 +84,3 @@ export async function runLoopConfigCli(tokens) { } export function writeCliResult(value, stdout = process.stdout) { stdout.write(value.output); return value.result ?? null; } -export async function runAgentCliEntry(tokens = process.argv.slice(3)) { - try { const value = await runAgentCli(tokens); writeCliResult(value); process.exitCode = value.exitCode ?? 0; return value.result ?? null; } - catch (error) { process.stderr.write(`burnlist: ${error?.message ?? String(error)}\n`); process.exitCode = error?.exitCode ?? 1; return null; } -} -export async function runRouteCliEntry(tokens = process.argv.slice(3)) { - try { const value = runRouteCli(tokens); writeCliResult(value); process.exitCode = value.exitCode ?? 0; return value.result ?? null; } - catch (error) { process.stderr.write(`burnlist: ${error?.message ?? String(error)}\n`); process.exitCode = error?.exitCode ?? 1; return null; } -} diff --git a/src/cli/loop-runtime-cli.test.mjs b/src/cli/loop-runtime-cli.test.mjs index 2bb75c9e..ae109d3d 100644 --- a/src/cli/loop-runtime-cli.test.mjs +++ b/src/cli/loop-runtime-cli.test.mjs @@ -1,328 +1,107 @@ import assert from "node:assert/strict"; -import { existsSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; -import { spawn, spawnSync } from "node:child_process"; -import { EventEmitter } from "node:events"; +import { spawnSync } from "node:child_process"; import test from "node:test"; import { createProductionRunAuthority, fixtureItemRef } from "../loops/run/run-test-fixtures.mjs"; -import { runLoopCli } from "./loop-cli.mjs"; -import { runStore } from "../loops/run/run-store.mjs"; -import { createLoopController } from "../loops/run/controller.mjs"; -import { prepareHostClaim } from "../loops/run/host-execution.mjs"; -import { createProductionRun } from "../loops/run/binder.mjs"; -import { fixtureRunId } from "../loops/run/run-test-fixtures.mjs"; const root = resolve(dirname(fileURLToPath(import.meta.url)), "..", ".."); const cli = join(root, "bin", "burnlist.mjs"); -function command(repo, args, env = {}) { - const result = spawnSync(process.execPath, [cli, "loop", ...args, "--repo", repo], { cwd: repo, encoding: "utf8", env: { ...process.env, ...env } }); - assert.equal(result.stderr, "", `${args.join(" ")}: ${result.stderr}`); assert.equal(result.status, 0, `${args.join(" ")}: ${result.stdout}`); return result.stdout; + +function invoke(repo, args) { + return spawnSync(process.execPath, [cli, "loop", ...args, "--repo", repo], + { cwd: repo, encoding: "utf8" }); +} +function command(repo, args) { + const result = invoke(repo, args); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stderr, ""); + return result.stdout; +} +function created(repo) { + return JSON.parse(command(repo, ["create", fixtureItemRef])).runId; } -function created(repo) { return JSON.parse(command(repo, ["create", fixtureItemRef])).runId; } function hostReport(execution, outcome) { return { schema: "burnlist-loop-host-report@1", result: { - schema: "agent-result@1", runId: execution.runId, nodeId: execution.nodeId, attempt: execution.attempt, - claimId: execution.claimId, assignmentId: execution.assignmentId, invocationId: execution.invocationId, + schema: "agent-result@1", runId: execution.runId, nodeId: execution.nodeId, + attempt: execution.attempt, claimId: execution.claimId, + assignmentId: execution.assignmentId, invocationId: execution.invocationId, recipeRevision: execution.recipeRevision, policyRevision: execution.policyRevision, - inputCandidate: execution.inputCandidate, outcome, findings: [], resolvedFindingIds: [], + inputCandidate: execution.inputCandidate, outcome, findings: [], + resolvedFindingIds: [], }, telemetry: null }; } -test("real CLI control reads are stable, list is absent-state read-only, and stored authority drives run/resume", (t) => { - const directory = mkdtempSync(join(tmpdir(), "m6-cli-")); t.after(() => rmSync(directory, { recursive: true, force: true })); +test("host-only CLI exposes stable reads and no managed run command", (t) => { + const directory = mkdtempSync(join(tmpdir(), "host-only-loop-cli-")); + t.after(() => rmSync(directory, { recursive: true, force: true })); const { repo } = createProductionRunAuthority(join(directory, "repo")); - const runs = join(repo, ".local", "burnlist", "loop", "m2", "runs"); - assert.equal(command(repo, ["list"]), "[]\n"); assert.equal(existsSync(runs), false); - const first = created(repo), status = command(repo, ["status", first]), inspect = command(repo, ["inspect", first]); - for (const publicView of [JSON.parse(status), JSON.parse(inspect)]) { - assert.equal(publicView.loopId, "review"); - assert.match(publicView.loopRevision, /^er1-sha256:[a-f0-9]{64}$/u); - assert.equal(Number.isSafeInteger(publicView.createdAt), true); - assert.equal(Number.isSafeInteger(publicView.updatedAt), true); + assert.equal(command(repo, ["list"]), "[]\n"); + const runId = created(repo); + const status = JSON.parse(command(repo, ["status", runId])); + assert.equal(status.currentNode, "start"); + assert.equal(command(repo, ["status", runId]), command(repo, ["status", runId])); + for (const verb of ["run", "resume"]) { + const result = invoke(repo, [verb, runId]); + assert.equal(result.status, 2); + assert.doesNotMatch(result.stderr, /codex|adapter|profile/u); } - assert.equal(command(repo, ["status", first]), status); assert.equal(command(repo, ["inspect", first]), inspect); - const authority = join(runs, Buffer.from(first).toString("hex"), "dispatch-authority.json"), bytes = readFileSync(authority); - const counter = join(directory, "counter"); writeFileSync(counter, "0"); - const completed = JSON.parse(command(repo, ["run", first], { BURNLIST_FAKE_COUNTER: counter, BURNLIST_FAKE_OUTCOMES: "complete,complete,complete,approve,complete,approve" })); - assert.equal(completed.state, "converged"); assert.deepEqual(readFileSync(authority), bytes); - const blocked = spawnSync(process.execPath, [cli, "loop", "create", fixtureItemRef, "--repo", repo], { cwd: repo, encoding: "utf8" }); - assert.equal(blocked.status, 1); assert.match(blocked.stderr, /current Run is still executable/u); -}); - -test("real CLI fences active control and proof-gates reconcile", (t) => { - const directory = mkdtempSync(join(tmpdir(), "m6-cli-fence-")); t.after(() => rmSync(directory, { recursive: true, force: true })); - const { repo } = createProductionRunAuthority(join(directory, "repo")), runId = created(repo); - const held = spawnSync(process.execPath, ["--input-type=module", "-e", `import{runStore}from${JSON.stringify(new URL("../loops/run/run-store.mjs", import.meta.url).href)};const s=runStore(process.argv[1]),a=s.acquireLease(process.argv[2]);s.append(process.argv[2],a.lease,"node-started",{nodeId:"start",attempt:1});s.append(process.argv[2],a.lease,"invocation-started",{nodeId:"start",attempt:1,invocationId:"${"a".repeat(32)}"});process.stdout.write(a.recoveryProof);`, repo, runId], { cwd: repo, encoding: "utf8" }); - assert.equal(held.status, 0, held.stderr); - const result = spawnSync(process.execPath, [cli, "loop", "pause", runId, "--repo", repo], { cwd: repo, encoding: "utf8" }); - assert.equal(result.status, 1); assert.match(result.stderr, /active foreground owner/u); - const reconcile = spawnSync(process.execPath, [cli, "loop", "reconcile", runId, "--repo", repo], { cwd: repo, encoding: "utf8" }); - assert.equal(reconcile.status, 1); assert.match(reconcile.stderr, /not demonstrably lost/u); - assert.equal(JSON.parse(command(repo, ["reconcile", runId, "--recovery-proof", held.stdout])).state, "needs-human"); }); -test("loop complete is the public, idempotent completion command", (t) => { - const directory = mkdtempSync(join(tmpdir(), "m8-cli-complete-")); t.after(() => rmSync(directory, { recursive: true, force: true })); - const { repo } = createProductionRunAuthority(join(directory, "repo")), runId = created(repo), counter = join(directory, "counter"); - writeFileSync(counter, "0"); assert.equal(JSON.parse(command(repo, ["run", runId], { BURNLIST_FAKE_COUNTER: counter, BURNLIST_FAKE_OUTCOMES: "complete,complete,complete,approve,complete,approve" })).state, "converged"); +test("claim and report advance checks and gates without launching a provider", (t) => { + const directory = mkdtempSync(join(tmpdir(), "host-report-loop-cli-")); + t.after(() => rmSync(directory, { recursive: true, force: true })); + const { repo } = createProductionRunAuthority(join(directory, "repo")); + const runId = created(repo), reportPath = join(directory, "report.json"); + const visited = []; + for (let attempts = 0; attempts < 12; attempts += 1) { + const state = JSON.parse(command(repo, ["status", runId])); + if (state.state === "converged") break; + const execution = JSON.parse(command(repo, ["claim", runId])).execution; + visited.push(execution.nodeId); + const outcome = ["review", "final-review"].includes(execution.nodeId) + ? "approve" : "complete"; + writeFileSync(reportPath, `${JSON.stringify(hostReport(execution, outcome))}\n`); + command(repo, ["report", execution.claimId, "--result", reportPath]); + } + assert.deepEqual(visited, + ["start", "decompose", "implement", "review", "integrate", "final-review"]); + assert.equal(JSON.parse(command(repo, ["status", runId])).state, "converged"); assert.equal(JSON.parse(command(repo, ["complete", runId])).alreadyApplied, false); assert.equal(JSON.parse(command(repo, ["complete", runId])).alreadyApplied, true); }); -test("a stopped current Run permits a safe replacement, while an executable Run does not", (t) => { - const directory = mkdtempSync(join(tmpdir(), "m8-current-replace-")); t.after(() => rmSync(directory, { recursive: true, force: true })); - const { repo } = createProductionRunAuthority(join(directory, "repo")), first = created(repo); - assert.equal(JSON.parse(command(repo, ["stop", first])).state, "stopped"); - const second = created(repo); assert.notEqual(second, first); - const old = spawnSync(process.execPath, [cli, "loop", "run", first, "--repo", repo], { cwd: repo, encoding: "utf8" }); - assert.equal(old.status, 1); assert.match(old.stderr, /superseded and cannot launch/u); -}); - -test("ordinary CLI create recovers an unpublished current reservation without requiring its RunRef", async (t) => { - const directory = mkdtempSync(join(tmpdir(), "m12-cli-create-retry-")); t.after(() => rmSync(directory, { recursive: true, force: true })); +test("a claimed provider cannot be stolen and reviewer candidate drift rejects its report", (t) => { + const directory = mkdtempSync(join(tmpdir(), "host-claim-loop-cli-")); + t.after(() => rmSync(directory, { recursive: true, force: true })); const { repo } = createProductionRunAuthority(join(directory, "repo")); - const cut = runStore(repo, { hooks: { beforeRunPublish() { throw new Error("publication cut"); } } }); - await assert.rejects(createProductionRun({ repoRoot: repo, store: cut, itemRef: fixtureItemRef, runId: fixtureRunId }), /publication cut/u); - assert.equal(cut.readCurrentRun(fixtureItemRef).runId, fixtureRunId); - assert.equal(JSON.parse(command(repo, ["create", fixtureItemRef])).runId, fixtureRunId); -}); - -test("concurrent replacement creates exactly one executable current Run", async (t) => { - const directory = mkdtempSync(join(tmpdir(), "m8-current-race-")); t.after(() => rmSync(directory, { recursive: true, force: true })); - const { repo } = createProductionRunAuthority(join(directory, "repo")), first = created(repo); - command(repo, ["stop", first]); - const launch = () => new Promise((resolvePromise) => { - const child = spawn(process.execPath, [cli, "loop", "create", fixtureItemRef, "--repo", repo], { cwd: repo, stdio: ["ignore", "pipe", "pipe"] }); let stdout = "", stderr = ""; - child.stdout.on("data", (chunk) => { stdout += chunk; }); child.stderr.on("data", (chunk) => { stderr += chunk; }); child.on("close", (status) => resolvePromise({ status, stdout, stderr })); - }); - const results = await Promise.all([launch(), launch()]); - assert.equal(results.filter((result) => result.status === 0).length, 1); assert.equal(results.filter((result) => result.status === 1).length, 1); - const current = runStore(repo).readCurrentRun(fixtureItemRef); assert.equal(current.runId, JSON.parse(results.find((result) => result.status === 0).stdout).runId); -}); - -test("a second CLI SIGINT reaches controlled stop before the foreground runner settles", async (t) => { - const directory = mkdtempSync(join(tmpdir(), "m6-cli-signal-")); t.after(() => rmSync(directory, { recursive: true, force: true })); - const { repo } = createProductionRunAuthority(join(directory, "repo")), runId = created(repo); - const processObject = new EventEmitter(), calls = []; - let settle; - const pending = new Promise((resolvePromise) => { settle = resolvePromise; }); - const store = runStore(repo); - const runner = { - requestPause() { calls.push("pause"); }, - requestStop() { calls.push("stop"); }, - async run() { await pending; return store.read(runId); }, - }; - const output = { value: "", write(chunk) { this.value += chunk; } }; - const commandPromise = runLoopCli(["run", runId, "--repo", repo], { - processObject, - runnerFor: () => runner, - stdout: output, - }); - processObject.emit("SIGINT"); - processObject.emit("SIGINT"); - assert.deepEqual(calls, ["pause", "stop"]); - assert.equal(processObject.listenerCount("SIGINT"), 1); - settle(); - await commandPromise; - assert.equal(processObject.listenerCount("SIGINT"), 0); - assert.equal(JSON.parse(output.value).runId, runId); -}); - -test("host CLI claims once, rejects claim theft, accepts one bound report, rejects conflict, and observes the next node after restart", (t) => { - const directory = mkdtempSync(join(tmpdir(), "s1-host-cli-")); t.after(() => rmSync(directory, { recursive: true, force: true })); - const { repo } = createProductionRunAuthority(join(directory, "repo")), runId = created(repo); - assert.equal(JSON.parse(command(repo, ["next", runId])).currentNode, "start"); - const first = JSON.parse(command(repo, ["claim", runId])); - const replayed = spawnSync(process.execPath, [cli, "loop", "claim", runId, "--repo", repo], { cwd: repo, encoding: "utf8" }); - assert.equal(replayed.status, 1); - assert.equal(replayed.stdout, ""); - assert.match(replayed.stderr, /already claimed/u); - assert.equal(runStore(repo).read(runId).journal.filter((record) => record.value.type === "external-claim-bound").length, 1); - const binding = first.execution; - const report = { - schema: "burnlist-loop-host-report@1", - result: { - schema: "agent-result@1", runId: binding.runId, nodeId: binding.nodeId, attempt: binding.attempt, - claimId: binding.claimId, assignmentId: binding.assignmentId, invocationId: binding.invocationId, - recipeRevision: binding.recipeRevision, policyRevision: binding.policyRevision, - inputCandidate: binding.inputCandidate, outcome: "complete", findings: [], resolvedFindingIds: [], - }, - telemetry: null, - }; - const resultPath = join(directory, "report.json"); writeFileSync(resultPath, `${JSON.stringify(report)}\n`); - const runRefReport = spawnSync(process.execPath, [cli, "loop", "report", runId, "--result", resultPath, "--repo", repo], { cwd: repo, encoding: "utf8" }); - assert.equal(runRefReport.status, 1); assert.match(runRefReport.stderr, /invalid ClaimRef/u); - const missingClaim = `cl1-sha256:${"f".repeat(64)}`; - const missing = spawnSync(process.execPath, [cli, "loop", "report", missingClaim, "--result", resultPath, "--repo", repo], { cwd: repo, encoding: "utf8" }); - assert.equal(missing.status, 1); assert.match(missing.stderr, /ClaimRef is missing/u); - assert.equal(JSON.parse(command(repo, ["report", binding.claimId, "--result", resultPath])).currentNode, "decompose"); - assert.equal(JSON.parse(command(repo, ["report", binding.claimId, "--result", resultPath])).currentNode, "decompose"); - writeFileSync(resultPath, `${JSON.stringify({ ...report, telemetry: { - schema: "burnlist-loop-host-telemetry@1", provenance: "host-reported", executor: "fixture", - displayName: null, provider: null, model: null, effort: null, startedAt: null, completedAt: null, - inputTokens: null, outputTokens: null, - } })}\n`); - const conflict = spawnSync(process.execPath, [cli, "loop", "report", binding.claimId, "--result", resultPath, "--repo", repo], { cwd: repo, encoding: "utf8" }); - assert.equal(conflict.status, 1); assert.match(conflict.stderr, /conflicts/u); - assert.equal(JSON.parse(command(repo, ["next", runId])).currentNode, "decompose"); -}); - -test("host CLI rejects misplaced result options and unsafe files, then abandons only its active claim", (t) => { - const directory = mkdtempSync(join(tmpdir(), "h4-host-cli-")); t.after(() => rmSync(directory, { recursive: true, force: true })); - const { repo } = createProductionRunAuthority(join(directory, "repo")), runId = created(repo); - const claimed = JSON.parse(command(repo, ["claim", runId])).execution, reportPath = join(directory, "report.json"); - writeFileSync(reportPath, `${JSON.stringify(hostReport(claimed, "complete"))}\n`); - const invoke = (...args) => spawnSync(process.execPath, [cli, "loop", ...args, "--repo", repo], { cwd: repo, encoding: "utf8" }); - const misplaced = invoke("claim", runId, "--result", reportPath); - assert.equal(misplaced.status, 2); assert.match(misplaced.stderr, /Usage: burnlist loop/u); - const missing = invoke("report", claimed.claimId); - assert.equal(missing.status, 2); assert.match(missing.stderr, /Usage: burnlist loop/u); - const symlink = join(directory, "report-link.json"); symlinkSync(reportPath, symlink); - const unsafe = invoke("report", claimed.claimId, "--result", symlink); - assert.equal(unsafe.status, 1); assert.match(unsafe.stderr, /unsafe or exceeds bounds/u); - assert.equal(runStore(repo).read(runId).projection.currentNode, "start"); - const invalidReason = invoke("abandon", claimed.claimId, "--reason", "retry"); - assert.equal(invalidReason.status, 1); assert.match(invalidReason.stderr, /invalid host claim abandonment reason/u); - const abandoned = invoke("abandon", claimed.claimId, "--reason", "host-cancelled"); - assert.equal(abandoned.status, 0, abandoned.stderr); assert.equal(JSON.parse(abandoned.stdout).state, "needs-human"); - const stale = invoke("abandon", claimed.claimId, "--reason", "host-cancelled"); - assert.equal(stale.status, 1); assert.match(stale.stderr, /stale|missing/u); -}); - -test("Loop option syntax and ownership fail as usage errors before every verb dispatch", (t) => { - const directory = mkdtempSync(join(tmpdir(), "h4-option-cli-")); t.after(() => rmSync(directory, { recursive: true, force: true })); - const { repo } = createProductionRunAuthority(join(directory, "repo")); - const invoke = (...args) => spawnSync(process.execPath, [cli, "loop", ...args, "--repo", repo], { cwd: repo, encoding: "utf8" }); - for (const args of [ - ["create", fixtureItemRef, "--result", "report.json"], ["complete", "run:bad", "--reason", "host-lost"], - ["assign", fixtureItemRef, "loop:builtin:review", "--recovery-proof", "a".repeat(64)], ["unassign", fixtureItemRef, "--result", "report.json"], - ["view", fixtureItemRef, "--reason", "host-lost"], ["list", "--recovery-proof", "a".repeat(64)], - ]) { - const result = invoke(...args); assert.equal(result.status, 2, args.join(" ")); assert.match(result.stderr, /Usage: burnlist loop/u); - } - for (const args of [ - ["list", "--unknown"], ["list", "--repo", repo], ["list", "--repo", repo], ["list", "--result"], - ["list", "--recovery-proof", "not-hex"], ["list", "--reason", "host-lost", "--reason", "host-cancelled"], - ]) { - const result = invoke(...args); assert.equal(result.status, 2, args.join(" ")); assert.doesNotMatch(result.stderr, /Loop control:/u); - } -}); - -test("a review claim refuses workspace drift before its report can advance", (t) => { - const directory = mkdtempSync(join(tmpdir(), "h3-review-drift-")); t.after(() => rmSync(directory, { recursive: true, force: true })); - const { repo } = createProductionRunAuthority(join(directory, "repo")), runId = created(repo), resultPath = join(directory, "report.json"); + const runId = created(repo), reportPath = join(directory, "report.json"); for (const expected of ["start", "decompose", "implement"]) { const execution = JSON.parse(command(repo, ["claim", runId])).execution; assert.equal(execution.nodeId, expected); - writeFileSync(resultPath, `${JSON.stringify(hostReport(execution, "complete"))}\n`); - command(repo, ["report", execution.claimId, "--result", resultPath]); + writeFileSync(reportPath, `${JSON.stringify(hostReport(execution, "complete"))}\n`); + command(repo, ["report", execution.claimId, "--result", reportPath]); } - const store = runStore(repo), validation = store.acquireLease(runId).lease, candidateId = store.read(runId).execution.candidate.id; - store.append(runId, validation, "node-started", { nodeId: "validate", attempt: 1 }); - store.append(runId, validation, "invocation-started", { nodeId: "validate", attempt: 1, invocationId: "a".repeat(32) }); - store.append(runId, validation, "invocation-result", { invocationId: "a".repeat(32), kind: "pass", summary: "trusted", outputBytes: 0, candidateId }); - store.append(runId, validation, "edge-taken", { from: "validate", on: "pass", to: "review" }); store.releaseLease(runId, validation); - const controller = createLoopController({ store, repoRoot: repo }); - const capturedBeforeDrift = prepareHostClaim({ repoRoot: repo, replay: store.read(runId), authority: store.readAuthority(runId) }); - const preClaimDrift = join(repo, "src", "drift-before-review-claim.txt"); writeFileSync(preClaimDrift, "changed\n"); - assert.throws(() => controller.claim(runId, capturedBeforeDrift), /candidate drift before review claim/u); - rmSync(preClaimDrift); - const review = JSON.parse(command(repo, ["claim", runId])).execution; - assert.equal(review.nodeId, "review"); - writeFileSync(join(repo, "src", "drift-after-review-claim.txt"), "changed\n"); - writeFileSync(resultPath, `${JSON.stringify(hostReport(review, "approve"))}\n`); - const rejected = spawnSync(process.execPath, [cli, "loop", "report", review.claimId, "--result", resultPath, "--repo", repo], { cwd: repo, encoding: "utf8" }); - assert.equal(rejected.status, 1); assert.match(rejected.stderr, /candidate drifted/u); - assert.equal(store.read(runId).projection.currentNode, "review"); + const claimed = JSON.parse(command(repo, ["claim", runId])).execution; + assert.equal(claimed.nodeId, "review"); + const theft = invoke(repo, ["claim", runId]); + assert.equal(theft.status, 1); + assert.match(theft.stderr, /already claimed/u); + writeFileSync(join(repo, "src", "drift.txt"), "drift\n"); + writeFileSync(reportPath, `${JSON.stringify(hostReport(claimed, "approve"))}\n`); + const rejected = invoke(repo, ["report", claimed.claimId, "--result", reportPath]); + assert.equal(rejected.status, 1); + assert.match(rejected.stderr, /candidate drifted/u); }); -test("host report retries complete every committed transaction tail after deliberate restart cuts", (t) => { - for (const cut of ["afterExternalReportAccepted", "afterExternalEdgeTaken"]) { - const directory = mkdtempSync(join(tmpdir(), `h2-host-${cut}-`)); t.after(() => rmSync(directory, { recursive: true, force: true })); - const { repo } = createProductionRunAuthority(join(directory, "repo")), runId = created(repo); - const claimed = JSON.parse(command(repo, ["claim", runId])).execution; - const report = Buffer.from(`${JSON.stringify({ - schema: "burnlist-loop-host-report@1", - result: { schema: "agent-result@1", runId: claimed.runId, nodeId: claimed.nodeId, attempt: claimed.attempt, - claimId: claimed.claimId, assignmentId: claimed.assignmentId, invocationId: claimed.invocationId, - recipeRevision: claimed.recipeRevision, policyRevision: claimed.policyRevision, inputCandidate: claimed.inputCandidate, - outcome: "complete", findings: [], resolvedFindingIds: [] }, telemetry: null, - })}\n`); - const cutStore = runStore(repo, { hooks: { [cut]() { throw new Error(`deliberate ${cut}`); } } }); - const interrupted = createLoopController({ store: cutStore, repoRoot: repo }); - assert.throws(() => interrupted.report(claimed.claimId, report), new RegExp(`deliberate ${cut}`, "u")); - const restartedStore = runStore(repo), restarted = createLoopController({ store: restartedStore, repoRoot: repo }); - assert.equal(restarted.report(claimed.claimId, report).currentNode, "decompose"); - const replay = restartedStore.read(runId); - assert.equal(replay.execution.lease, null); - assert.equal(replay.journal.filter((record) => record.value.type === "external-report-accepted").length, 1); - assert.equal(replay.journal.filter((record) => record.value.type === "edge-taken").length, 1); - assert.equal(replay.journal.filter((record) => record.value.type === "lease-released").length, 1); - assert.equal(restarted.report(claimed.claimId, report).currentNode, "decompose"); - } -}); - -test("concurrent external claims expose the envelope to exactly one winner", async (t) => { - const directory = mkdtempSync(join(tmpdir(), "h2-host-claim-race-")); t.after(() => rmSync(directory, { recursive: true, force: true })); - const { repo } = createProductionRunAuthority(join(directory, "repo")), runId = created(repo); - const claim = () => new Promise((done) => { - const child = spawn(process.execPath, [cli, "loop", "claim", runId, "--repo", repo], { cwd: repo, stdio: ["ignore", "pipe", "pipe"] }); - let stdout = "", stderr = ""; - child.stdout.on("data", (chunk) => { stdout += chunk; }); child.stderr.on("data", (chunk) => { stderr += chunk; }); - child.on("close", (status) => done({ status, stdout, stderr })); - }); - const results = await Promise.all([claim(), claim()]), successful = results.filter((value) => value.status === 0); - assert.equal(successful.length, 1); - assert.match(results.find((value) => value.status !== 0).stderr, /locked|lease|active foreground owner|already claimed/u); - const retry = spawnSync(process.execPath, [cli, "loop", "claim", runId, "--repo", repo], { cwd: repo, encoding: "utf8" }); - assert.equal(retry.status, 1); - assert.match(retry.stderr, /already claimed/u); - assert.equal(runStore(repo).read(runId).journal.filter((record) => record.value.type === "external-claim-bound").length, 1); -}); - -test("near journal capacity terminalizes before an external report can strand its lease", (t) => { - const directory = mkdtempSync(join(tmpdir(), "h2-host-capacity-")); t.after(() => rmSync(directory, { recursive: true, force: true })); - const { repo } = createProductionRunAuthority(join(directory, "repo")), runId = created(repo); - const claimed = JSON.parse(command(repo, ["claim", runId])).execution, invalidations = []; - const report = Buffer.from(`${JSON.stringify({ schema: "burnlist-loop-host-report@1", - result: { schema: "agent-result@1", runId: claimed.runId, nodeId: claimed.nodeId, attempt: claimed.attempt, - claimId: claimed.claimId, assignmentId: claimed.assignmentId, invocationId: claimed.invocationId, - recipeRevision: claimed.recipeRevision, policyRevision: claimed.policyRevision, inputCandidate: claimed.inputCandidate, - outcome: "complete", findings: [], resolvedFindingIds: [] }, telemetry: null })}\n`); - // A reduced bound is a deterministic stand-in for records 252–255; the - // production ceiling remains MAX_JOURNAL_RECORDS. - const constrained = runStore(repo, { journalMaximum: 8, publishProjection(_root, replay) { invalidations.push(replay); } }); - const controller = createLoopController({ store: constrained, repoRoot: repo }); - assert.equal(controller.report(claimed.claimId, report).state, "budget-exhausted"); - const replay = constrained.read(runId); - assert.equal(replay.execution.lease, null); - assert.equal(replay.journal.filter((record) => record.value.type === "external-report-accepted").length, 0); - assert.equal(replay.journal.at(-1).value.type, "terminal-node-committed"); - assert.equal(invalidations.length, 1); - assert.equal(invalidations[0].projection.itemRef, fixtureItemRef); - assert.throws(() => controller.report(claimed.claimId, report), /stale lease/u); -}); - -test("near journal capacity cut after edge retries only the release tail", (t) => { - const directory = mkdtempSync(join(tmpdir(), "h2-host-capacity-cut-")); t.after(() => rmSync(directory, { recursive: true, force: true })); - const { repo } = createProductionRunAuthority(join(directory, "repo")), runId = created(repo); - const claimed = JSON.parse(command(repo, ["claim", runId])).execution, invalidations = []; - const report = Buffer.from(`${JSON.stringify({ schema: "burnlist-loop-host-report@1", - result: { schema: "agent-result@1", runId: claimed.runId, nodeId: claimed.nodeId, attempt: claimed.attempt, - claimId: claimed.claimId, assignmentId: claimed.assignmentId, invocationId: claimed.invocationId, - recipeRevision: claimed.recipeRevision, policyRevision: claimed.policyRevision, inputCandidate: claimed.inputCandidate, - outcome: "complete", findings: [], resolvedFindingIds: [] }, telemetry: null })}\n`); - const cutStore = runStore(repo, { journalMaximum: 9, hooks: { afterExternalEdgeTaken() { throw new Error("near-capacity cut"); } } }); - assert.throws(() => createLoopController({ store: cutStore, repoRoot: repo }).report(claimed.claimId, report), /near-capacity cut/u); - const restartedStore = runStore(repo, { journalMaximum: 9, publishProjection(_root, replay) { invalidations.push(replay); } }); - assert.equal(createLoopController({ store: restartedStore, repoRoot: repo }).report(claimed.claimId, report).currentNode, "decompose"); - const replay = restartedStore.read(runId); - assert.equal(replay.execution.lease, null); - assert.equal(replay.journal.filter((record) => record.value.type === "external-report-accepted").length, 1); - assert.equal(replay.journal.filter((record) => record.value.type === "edge-taken").length, 1); - assert.equal(replay.journal.filter((record) => record.value.type === "lease-released").length, 1); - assert.equal(invalidations.length, 1); - assert.equal(invalidations[0].projection.itemRef, fixtureItemRef); +test("abandon terminalizes an unfinished provider claim as needs-human", (t) => { + const directory = mkdtempSync(join(tmpdir(), "host-abandon-loop-cli-")); + t.after(() => rmSync(directory, { recursive: true, force: true })); + const { repo } = createProductionRunAuthority(join(directory, "repo")); + const runId = created(repo), claimed = JSON.parse(command(repo, ["claim", runId])).execution; + const abandoned = JSON.parse(command(repo, + ["abandon", claimed.claimId, "--reason", "host-cancelled"])); + assert.equal(abandoned.state, "needs-human"); }); diff --git a/src/loops/adapters/codex-cli.mjs b/src/loops/adapters/codex-cli.mjs deleted file mode 100644 index 4cf01538..00000000 --- a/src/loops/adapters/codex-cli.mjs +++ /dev/null @@ -1,140 +0,0 @@ -import { spawn as nodeSpawn } from "node:child_process"; -import { TextDecoder } from "node:util"; -import { isAbsolute } from "node:path"; -import { requestedCodexIdentity, validateAgentProfile, validateCodexProbe } from "../agents/profile.mjs"; - -const MAX_OUTPUT_BYTES = 1048576; -// Codex may emit one bounded JSONL item containing a large tool/result payload. -// The aggregate cap is the memory/safety boundary; a smaller per-line cap makes -// valid provider output fail depending on event chunking. -const MAX_JSONL_LINE_BYTES = MAX_OUTPUT_BYTES; -const DIRECT_GUARANTEES = Object.freeze({ freshSession: "enforced", filesystemWriteDeny: "supervised", foregroundHandle: "supervised", cancellation: "supervised", lifecycle: "unsupported" }); - -function fail(message, code = "ELOOP_CODEX_ADAPTER") { throw Object.assign(new Error(`Codex adapter: ${message}`), { code }); } -function text(value, label, maximum = 262144) { - if (typeof value !== "string" || !value || Buffer.byteLength(value) > maximum || value.includes("\0")) fail(`invalid ${label}`); - return value; -} -function invocation(profile, cwd, prompt) { - const requested = requestedCodexIdentity(profile); - if (typeof cwd !== "string" || !isAbsolute(cwd) || /[\0\r\n]/u.test(cwd)) fail("invalid cwd"); - return Object.freeze({ - command: requested.binary, - args: ["exec", "--json", "--ephemeral", "-m", requested.model, "-c", `model_reasoning_effort=${requested.effort}`, "-s", requested.sandbox, "-C", cwd, "--skip-git-repo-check", "--", text(prompt, "prompt")], - requested, - }); -} -function safeToken(value) { return Number.isSafeInteger(value) && value >= 0; } -function usageFrom(events) { - const event = [...events].reverse().find((item) => item.type === "turn.completed" && item.usage && typeof item.usage === "object" && !Array.isArray(item.usage)); - if (!event) return null; - const usage = event.usage; const cached = usage.cached_input_tokens ?? 0; - if (![usage.input_tokens, usage.output_tokens, cached].every(safeToken) || usage.input_tokens > Number.MAX_SAFE_INTEGER - usage.output_tokens) return null; - return Object.freeze({ inputTokens: usage.input_tokens, outputTokens: usage.output_tokens, cachedInputTokens: cached, totalTokens: usage.input_tokens + usage.output_tokens }); -} -function providerReported(events) { - const event = events.find((item) => item.type === "thread.started" && typeof item.thread_id === "string"); - if (!event || !event.thread_id || Buffer.byteLength(event.thread_id) > 512 || /[\0\r\n]/u.test(event.thread_id)) return null; - const optional = (value) => typeof value === "string" && value && Buffer.byteLength(value) <= 512 && !/[\0\r\n]/u.test(value) ? value : null; - return Object.freeze({ model: optional(event.model), sessionId: event.thread_id, version: optional(event.version) }); -} -function parseJsonl(bytes) { - if (bytes.length > MAX_OUTPUT_BYTES) fail("JSONL output exceeds limit", "ELOOP_CODEX_OUTPUT_LIMIT"); - let source; try { source = new TextDecoder("utf-8", { fatal: true }).decode(bytes); } catch { fail("JSONL output is not UTF-8", "ELOOP_CODEX_OUTPUT"); } - const lines = source.split("\n"); - if (lines.at(-1) !== "") fail("JSONL output is not LF terminated", "ELOOP_CODEX_OUTPUT"); - const events = []; - for (const line of lines.slice(0, -1)) { - if (!line || Buffer.byteLength(line) > MAX_JSONL_LINE_BYTES) fail("malformed JSONL output", "ELOOP_CODEX_OUTPUT"); - let value; try { value = JSON.parse(line); } catch { fail("malformed JSONL output", "ELOOP_CODEX_OUTPUT"); } - if (!value || typeof value !== "object" || Array.isArray(value) || typeof value.type !== "string" || !value.type || Buffer.byteLength(value.type) > 128 || /[\0\r\n]/u.test(value.type)) fail("malformed JSONL event", "ELOOP_CODEX_OUTPUT"); - events.push(Object.freeze(value)); - } - return Object.freeze(events); -} -function isolation(value) { - // A direct child close is observable. It is not evidence about descendants, - // so the default controller must never manufacture an empty-process proof. - if (value === undefined) return { guarantees: DIRECT_GUARANTEES, terminate: (child, signal) => child.kill(signal), proveEmpty: async () => false, requireEmptyProof: false }; - const labels = ["freshSession", "filesystemWriteDeny", "foregroundHandle", "cancellation", "lifecycle"]; - if (!value || typeof value !== "object" || !value.guarantees || typeof value.terminate !== "function" - || typeof value.proveEmpty !== "function" || labels.some((key) => !["enforced", "detected-at-boundaries", "supervised", "unsupported"].includes(value.guarantees[key]))) fail("invalid foreground controller"); - return { ...value, requireEmptyProof: value.requireEmptyProof !== false }; -} - -/** A direct foreground process is fresh per call; reviewer write denial remains supervised. */ -export function startCodexInvocation({ profile, cwd, prompt, spawn = nodeSpawn, trustedIsolation }) { - const current = validateAgentProfile(profile); const launch = invocation(current, cwd, prompt); const controller = isolation(trustedIsolation); - let child; - try { - child = spawn(launch.command, launch.args, { cwd, shell: false, windowsHide: true, stdio: ["ignore", "pipe", "pipe"] }); - } catch (error) { controller.dispose?.(); throw error; } - if (!child || !Number.isInteger(child.pid) || child.pid <= 0 || !child.stdout || !child.stderr || typeof child.once !== "function") { - child?.once?.("error", () => {}); // consume the deferred spawn error after failing closed synchronously - controller.dispose?.(); - fail("spawn did not return a foreground process handle", "ELOOP_CODEX_HANDLE"); - } - const stdout = []; let outputBytes = 0; let outputExceeded = false; let cancellationRequested = false; let termSent = false; let killSent = false; let settled = false; let finalizing = false; let forceTimer; - const boundedTerminate = () => { - if (!termSent) { try { termSent = controller.terminate(child, "SIGTERM") === true; } catch { termSent = false; } } - if (!forceTimer) forceTimer = setTimeout(() => { if (settled) return; try { killSent = controller.terminate(child, "SIGKILL") === true; } catch { killSent = false; } }, 100); - }; - const capture = (target) => (chunk) => { - const bytes = Buffer.from(chunk); outputBytes += bytes.length; - if (outputBytes > MAX_OUTPUT_BYTES) { outputExceeded = true; boundedTerminate(); return; } - if (target) target.push(bytes); - }; - child.stdout.on("data", capture(stdout)); child.stderr.on("data", capture(null)); - const completion = new Promise((resolve, reject) => { - const finalize = async ({ processError = null, exitCode = null, signal = null }) => { - if (settled || finalizing) return; finalizing = true; clearTimeout(forceTimer); - let empty = false; try { empty = await controller.proveEmpty({ pid: child.pid, exitCode, signal, processError }) === true; } catch { empty = false; } - finally { try { controller.dispose?.(); } catch { empty = false; } } - if (!empty && (controller.requireEmptyProof || cancellationRequested)) { - settled = true; - resolve(Object.freeze({ - requested: launch.requested, providerReported: null, technicallyProven: Object.freeze({ argv: [launch.command, ...launch.args], pidObserved: true }), - guarantees: Object.freeze({ ...controller.guarantees }), events: Object.freeze([]), usage: null, usageStatus: "unavailable", - exitCode: Number.isInteger(exitCode) ? exitCode : null, signal: signal ?? null, cancellationRequested, - termination: Object.freeze({ termSent, killSent, emptyProven: false, descendants: controller.guarantees.lifecycle }), - quarantineRequired: true, outcome: "quarantined", - })); return; - } - try { - if (processError) throw processError; - if (outputExceeded) fail("JSONL output exceeds limit", "ELOOP_CODEX_OUTPUT_LIMIT"); - const events = parseJsonl(Buffer.concat(stdout)); const provider = providerReported(events); const usage = usageFrom(events); - settled = true; - resolve(Object.freeze({ - requested: launch.requested, providerReported: provider, technicallyProven: Object.freeze({ argv: [launch.command, ...launch.args], pidObserved: true }), - guarantees: Object.freeze({ ...controller.guarantees }), events, usage, usageStatus: usage ? "reported" : "unavailable", - exitCode: Number.isInteger(exitCode) ? exitCode : null, signal: signal ?? null, cancellationRequested, - termination: Object.freeze({ termSent, killSent, emptyProven: empty, descendants: controller.guarantees.lifecycle }), - quarantineRequired: false, - outcome: cancellationRequested ? "cancelled" : exitCode === 0 && !signal ? "completed" : "failed", - })); - } catch (error) { settled = true; reject(error); } - }; - child.once("error", (error) => { void finalize({ processError: error }); }); - child.once("close", (exitCode, signal) => { void finalize({ exitCode, signal }); }); - }); - return Object.freeze({ - pid: child.pid, requested: launch.requested, completion, - cancel() { - if (settled || cancellationRequested) return false; cancellationRequested = true; - boundedTerminate(); - return termSent; - }, - }); -} - -/** Child JSONL may report identity/usage, but only the trusted host controller supplies guarantees. */ -export async function probeCodexCli({ profile, cwd, spawn = nodeSpawn, trustedIsolation }) { - const handle = startCodexInvocation({ profile, cwd, spawn, trustedIsolation, prompt: "Burnlist adapter probe. Report provider identity only." }); - const result = await handle.completion; - if (result.outcome !== "completed" || !result.providerReported) fail(`probe did not provide provider identity (${result.outcome})`, "ELOOP_CODEX_PROBE"); - return validateCodexProbe({ - schema: "burnlist-codex-probe@1", requested: result.requested, providerReported: result.providerReported, - technicallyProven: result.technicallyProven, guarantees: { ...result.guarantees, usage: result.usageStatus }, - }); -} diff --git a/src/loops/adapters/codex-cli.test.mjs b/src/loops/adapters/codex-cli.test.mjs deleted file mode 100644 index a7f19c34..00000000 --- a/src/loops/adapters/codex-cli.test.mjs +++ /dev/null @@ -1,132 +0,0 @@ -import assert from "node:assert/strict"; -import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import test from "node:test"; -import { probeCodexCli, startCodexInvocation } from "./codex-cli.mjs"; -import { resolveStageOneRoutes } from "../agents/profile.mjs"; - -const fakeSource = `#!/usr/bin/env node -const args = process.argv.slice(2); -const mode = process.env.FAKE_MODE || "ok"; -if (mode === "hang") { process.on("SIGTERM",()=>{}); setInterval(() => {}, 1000); } -if (mode === "nonzero") { process.exit(7); } -if (mode === "bad") { process.stdout.write("not-json\\n"); process.exit(0); } -if (mode === "invalid-utf8") { process.stdout.write(Buffer.from([255,10])); process.exit(0); } -if (mode === "oversize") { process.stdout.write(Buffer.alloc(1048577,97)); process.exit(0); } -if (mode === "large-line") { process.stdout.write(JSON.stringify({type:"item.completed",item:{type:"agent_message",text:"x".repeat(70000)}})+"\\n"); } -const session = mode === "same-session" ? "same" : "fresh-" + process.pid; -process.stdout.write(JSON.stringify({type:"thread.started",thread_id:session,model:args[args.indexOf("-m") + 1],version:"fake-1"})+"\\n"); -process.stdout.write(JSON.stringify({type:"burnlist.capability",guarantees:{freshSession:"enforced",filesystemWriteDeny:"enforced",foregroundHandle:"enforced",cancellation:"enforced",lifecycle:"enforced"}})+"\\n"); -const usage = mode === "overflow" ? {input_tokens:Number.MAX_SAFE_INTEGER,output_tokens:1} : mode === "missing-usage" ? null : {input_tokens:11,output_tokens:7,cached_input_tokens:3}; -process.stdout.write(JSON.stringify(usage ? {type:"turn.completed",usage} : {type:"turn.completed"})+"\\n"); -`; - -function fixture() { - const directory = mkdtempSync(join(tmpdir(), "burnlist-codex-adapter-")); const binary = join(directory, "fake-codex.mjs"); - writeFileSync(binary, fakeSource, { mode: 0o700 }); chmodSync(binary, 0o700); - return { directory, binary, cleanup: () => rmSync(directory, { recursive: true, force: true }) }; -} -function profile(binary, id, authority, model = "gpt-5.6-terra", effort = "high") { return { schema: "burnlist-loop-agent-profile@1", id, adapter: "builtin:codex-cli", binary, model, effort, authority }; } -function unprovenIsolation() { - return { guarantees: { freshSession: "unsupported", filesystemWriteDeny: "unsupported", foregroundHandle: "supervised", cancellation: "supervised", lifecycle: "unsupported" }, terminate: (child, signal) => signal === "SIGTERM" ? true : child.kill(signal), proveEmpty: async () => false }; -} -function weakButEmptyIsolation() { return { ...unprovenIsolation(), proveEmpty: async () => true }; } -async function withMode(mode, fn) { - const before = process.env.FAKE_MODE; - try { process.env.FAKE_MODE = mode; return await fn(); } - finally { if (before === undefined) delete process.env.FAKE_MODE; else process.env.FAKE_MODE = before; } -} - -test("adapter uses exact ephemeral argv and ignores child capability claims", async () => { - const context = fixture(); - try { - const maker = profile(context.binary, "maker", "write"); - const result = await startCodexInvocation({ profile: maker, cwd: context.directory, prompt: "Implement." }).completion; - assert.deepEqual(result.technicallyProven.argv.slice(1, -1), ["exec", "--json", "--ephemeral", "-m", "gpt-5.6-terra", "-c", "model_reasoning_effort=high", "-s", "workspace-write", "-C", context.directory, "--skip-git-repo-check", "--"]); - assert.equal(result.technicallyProven.argv.at(-1), "Implement."); assert.equal(result.technicallyProven.pidObserved, true); - assert.deepEqual(result.usage, { inputTokens: 11, outputTokens: 7, cachedInputTokens: 3, totalTokens: 18 }); - - const direct = await startCodexInvocation({ profile: profile(context.binary, "reviewer", "read"), cwd: context.directory, prompt: "Direct." }).completion; - assert.equal(direct.outcome, "completed"); assert.equal(direct.termination.emptyProven, false); - } finally { context.cleanup(); } -}); - -test("cancellation quarantines a closed direct child when descendant exit is unproven", async () => { - const context = fixture(); - try { - await withMode("hang", async () => { - const direct = startCodexInvocation({ profile: profile(context.binary, "maker", "write"), cwd: context.directory, prompt: "Wait.", trustedIsolation: unprovenIsolation() }); - assert.equal(direct.cancel(), true); assert.equal(direct.cancel(), false); const uncertain = await direct.completion; - assert.equal(uncertain.outcome, "quarantined"); assert.equal(uncertain.quarantineRequired, true); assert.equal(uncertain.termination.killSent, true); assert.equal(uncertain.termination.emptyProven, false); - - }); - } finally { context.cleanup(); } -}); - -test("JSONL is strict UTF-8 and usage overflow or absence is unavailable", async () => { - const context = fixture(); - try { - assert.throws(() => startCodexInvocation({ profile: profile(join(context.directory, "missing-codex"), "maker", "write"), cwd: context.directory, prompt: "Missing." }), (error) => error.code === "ELOOP_CODEX_HANDLE"); - const maker = profile(context.binary, "maker", "write"); - await withMode("nonzero", async () => { - const result = await startCodexInvocation({ profile: maker, cwd: context.directory, prompt: "Nonzero." }).completion; - assert.equal(result.outcome, "failed"); assert.equal(result.exitCode, 7); - }); - await withMode("bad", async () => assert.rejects(startCodexInvocation({ profile: maker, cwd: context.directory, prompt: "Bad." }).completion, (error) => error.code === "ELOOP_CODEX_OUTPUT")); - await withMode("invalid-utf8", async () => assert.rejects(startCodexInvocation({ profile: maker, cwd: context.directory, prompt: "Bad bytes." }).completion, (error) => error.code === "ELOOP_CODEX_OUTPUT")); - await withMode("oversize", async () => assert.rejects(startCodexInvocation({ profile: maker, cwd: context.directory, prompt: "Too much." }).completion, (error) => ["ELOOP_CODEX_OUTPUT_LIMIT", "ELOOP_CODEX_OUTPUT"].includes(error.code))); - await withMode("large-line", async () => { - const result = await startCodexInvocation({ profile: maker, cwd: context.directory, prompt: "Large valid line." }).completion; - assert.equal(result.outcome, "completed"); - assert.equal(result.events[0].item.text.length, 70000); - }); - for (const mode of ["missing-usage", "overflow"]) await withMode(mode, async () => { - const result = await startCodexInvocation({ profile: maker, cwd: context.directory, prompt: "Usage." }).completion; - assert.equal(result.usage, null); assert.equal(result.usageStatus, "unavailable"); - }); - } finally { context.cleanup(); } -}); - -test("each direct reviewer invocation has a fresh foreground PID and provider session", async () => { - const context = fixture(); - try { - const reviewer = profile(context.binary, "reviewer", "read"); - const first = startCodexInvocation({ profile: reviewer, cwd: context.directory, prompt: "Review one." }); - const second = startCodexInvocation({ profile: reviewer, cwd: context.directory, prompt: "Review two." }); - const [left, right] = await Promise.all([first.completion, second.completion]); - assert.notEqual(first.pid, second.pid); - assert.notEqual(left.providerReported.sessionId, right.providerReported.sessionId); - } finally { context.cleanup(); } -}); - -test("route binding requires host-enforced isolation and independent provider sessions", async () => { - const context = fixture(); - try { - const maker = profile(context.binary, "maker", "write"); const reviewer = profile(context.binary, "reviewer", "read", "gpt-5.6-sol", "medium"); - const weakMaker = await probeCodexCli({ profile: maker, cwd: context.directory, trustedIsolation: weakButEmptyIsolation() }); const weakReviewer = await probeCodexCli({ profile: reviewer, cwd: context.directory, trustedIsolation: weakButEmptyIsolation() }); - assert.throws(() => resolveStageOneRoutes({ profiles: [maker, reviewer], routes: { "implementation.standard": "maker", "review.strong": "reviewer" }, probes: { maker: weakMaker, reviewer: weakReviewer } }), (error) => error.code === "ELOOP_REVIEWER_ISOLATION"); - - const strongMaker = await probeCodexCli({ profile: maker, cwd: context.directory }); - const strongReviewer = await probeCodexCli({ profile: reviewer, cwd: context.directory }); - assert.equal(resolveStageOneRoutes({ profiles: [maker, reviewer], routes: { "implementation.standard": "maker", "review.strong": "reviewer" }, probes: { maker: strongMaker, reviewer: strongReviewer } }).review.guarantees.filesystemWriteDeny, "supervised"); - - await withMode("same-session", async () => { - const first = await probeCodexCli({ profile: maker, cwd: context.directory }); - const second = await probeCodexCli({ profile: reviewer, cwd: context.directory }); - assert.throws(() => resolveStageOneRoutes({ profiles: [maker, reviewer], routes: { "implementation.standard": "maker", "review.strong": "reviewer" }, probes: { maker: first, reviewer: second } }), (error) => error.code === "ELOOP_REVIEWER_ISOLATION"); - }); - } finally { context.cleanup(); } -}); - -test("direct foreground controllers are accepted without Docker authority", async () => { - const context = fixture(); - try { - const direct = await startCodexInvocation({ profile: profile(context.binary, "reviewer", "read"), - cwd: context.directory, prompt: "Fake.", trustedIsolation: { - guarantees: { freshSession: "enforced", filesystemWriteDeny: "enforced", foregroundHandle: "enforced", cancellation: "enforced", lifecycle: "enforced" }, - terminate: () => true, proveEmpty: async () => true, - } }).completion; - assert.equal(direct.outcome, "completed"); - } finally { context.cleanup(); } -}); diff --git a/src/loops/adapters/normalized-invocation.mjs b/src/loops/adapters/normalized-invocation.mjs deleted file mode 100644 index 67483391..00000000 --- a/src/loops/adapters/normalized-invocation.mjs +++ /dev/null @@ -1,204 +0,0 @@ -import { startCodexInvocation } from "./codex-cli.mjs"; -import { runTrustedCapability } from "../capabilities/runner.mjs"; -import { parseBoundedObject } from "../contracts/contract.mjs"; -import { validateHostExecutionEnvelope } from "../contracts/host-execution.mjs"; - -const MAX_SUMMARY_BYTES = 1024; -const AGENT_RESULT_TYPES = new Set(["complete", "approve", "reject", "escalate"]); -const FINAL_REPORT_KEYS = ["schema", "runId", "nodeId", "attempt", "claimId", "invocationId", "assignmentId", - "recipeRevision", "policyRevision", "inputCandidate", "outcome", "summary", "findings", "resolvedFindingIds"]; -const MAX_FINAL_BYTES = 65_536; -const CANDIDATE = /^cm1-sha256:[a-f0-9]{64}$/u; -const ASSIGNMENT = /^as1-sha256:[a-f0-9]{64}$/u; -const CLAIM = /^cl1-sha256:[a-f0-9]{64}$/u; -const RECIPE = /^er1-sha256:[a-f0-9]{64}$/u; -const POLICY = /^bp1-sha256:[a-f0-9]{64}$/u; -const fail = (message) => { throw Object.assign(new Error(`Loop invocation: ${message}`), { code: "ELOOP_INVOKE" }); }; -const exact = (value, keys) => Boolean(value) && typeof value === "object" && !Array.isArray(value) - && Object.keys(value).length === keys.length && keys.every((key) => Object.hasOwn(value, key)); - -function clean(value) { - if (typeof value !== "string") return "process failed"; - const bytes = Buffer.from(value.replace(/[\0\r\n]/gu, " "), "utf8"); - let output = bytes.subarray(0, MAX_SUMMARY_BYTES).toString("utf8"); - // Buffer slicing can land in a multibyte code point; trim decoded code - // points until the returned UTF-8 representation is genuinely bounded. - while (Buffer.byteLength(output, "utf8") > MAX_SUMMARY_BYTES) output = Array.from(output).slice(0, -1).join(""); - return output; -} -function route(routes, name) { - const value = routes?.[name]; - if (!value?.profile || typeof value.profile !== "object") fail(`missing ${name} route`); - return value.profile; -} -function binding(value) { - const keys = ["claimId", "assignmentId", "recipeRevision", "policyRevision", "inputCandidate", - "instructionBytes", "itemText", "candidateContext", "reviewerEvidence"]; - if (!exact(value, keys) || !CLAIM.test(value.claimId) || !ASSIGNMENT.test(value.assignmentId) - || !RECIPE.test(value.recipeRevision) || !POLICY.test(value.policyRevision) || !CANDIDATE.test(value.inputCandidate) - || typeof value.instructionBytes !== "string" || !value.instructionBytes || Buffer.byteLength(value.instructionBytes) > 65_536 - || typeof value.itemText !== "string" || !value.itemText || Buffer.byteLength(value.itemText) > 65_536 - || typeof value.candidateContext !== "string" || Buffer.byteLength(value.candidateContext) > 65_536 - || !Array.isArray(value.reviewerEvidence) || value.reviewerEvidence.length > 50 - || value.reviewerEvidence.some((item) => typeof item !== "string" || Buffer.byteLength(item) > 4096)) - fail("invalid invocation binding"); - return value; -} -function boundaryCandidate(candidateForBoundary, expected) { - if (typeof candidateForBoundary !== "function") return expected; - const value = candidateForBoundary(); - const id = typeof value === "string" ? value : value?.id; - if (!CANDIDATE.test(id) || id !== expected) fail("candidate changed at evidence boundary"); - return id; -} -function finalPrompt(invocation, node, current) { - return ["Burnlist Stage 1 invocation.", `run=${invocation.runId}`, `node=${node.id}`, `attempt=${invocation.attempt}`, - `claim=${current.claimId}`, `invocation=${invocation.invocationId}`, `assignment=${current.assignmentId}`, - `recipe=${current.recipeRevision}`, `policy=${current.policyRevision}`, `candidate=${current.inputCandidate}`, - `role=${node.role ?? "check"}`, - "Burnlist owns graph routing and checks; host output is transport-only and idempotent by replaying the exact final report.", - "Report exactly one canonical object with this schema: burnlist.agent-final@1", - "Required report fields: schema, runId, nodeId, attempt, claimId, invocationId, assignmentId, recipeRevision, policyRevision, inputCandidate, outcome, summary, findings, resolvedFindingIds.", - "Do not include destination or any graph transitions; no host-selected edges are accepted.", - "Host result contracts are by node mode: task=>complete, review=>approve|reject|escalate.", - "Host claim envelopes are bounded; use exact prepared claim identity and expiry semantics from the transport contract.", - "Host telemetry is optional but, if provided, must use burnlist-loop-host-telemetry@1 with provenance=host-reported and non-negative token/time fields.", - "FROZEN INSTRUCTIONS:", current.instructionBytes, "ASSIGNED ITEM:", current.itemText, - "CANDIDATE CONTEXT:", current.candidateContext, "REVIEW EVIDENCE:", JSON.stringify(current.reviewerEvidence), - "OPEN FINDINGS:", JSON.stringify(current.openFindings ?? [])].join("\n"); -} -function finalTexts(events) { - const texts = []; - const finals = []; - for (const event of events) { - if (event?.type !== "item.completed" || !event.item || typeof event.item !== "object" || Array.isArray(event.item) - || event.item.type !== "agent_message" || typeof event.item.text !== "string") continue; - const text = event.item.text; - try { - const value = parseBoundedObject(Buffer.from(text, "utf8"), { maximumBytes: MAX_FINAL_BYTES, maximumDepth: 2, label: "agent final" }); - if (value.schema === "burnlist.agent-final@1") finals.push(text); - } catch { - // preceding conversational messages are permitted. - } - texts.push(text); - } - if (!texts.length) fail("agent emitted no final message"); - return { texts, finals }; -} -function agentResult(events, invocation, node, current) { - const { texts, finals } = finalTexts(events); - if (!finals.length) fail("malformed or missing terminal agent result"); - if (!finals.every((value) => value === finals[0])) fail("duplicate terminal reports differ"); - const envelopes = []; - for (let index = 0; index < texts.length; index += 1) { - try { - const value = parseBoundedObject(Buffer.from(texts[index], "utf8"), { maximumBytes: MAX_FINAL_BYTES, maximumDepth: 2, label: "agent final" }); - if (value.schema === "burnlist.agent-final@1") envelopes.push({ index, value }); - } catch { - // non-final conversational lines are tolerated. - } - } - if (envelopes.at(-1).index !== texts.length - 1 || envelopes.length !== finals.length) fail("malformed or ambiguous terminal agent result"); - const result = envelopes[0].value; - if (!exact(result, FINAL_REPORT_KEYS) || result.schema !== "burnlist.agent-final@1" || result.runId !== invocation.runId - || result.nodeId !== node.id || result.attempt !== invocation.attempt || result.invocationId !== invocation.invocationId - || result.claimId !== current.claimId || result.assignmentId !== current.assignmentId - || result.recipeRevision !== current.recipeRevision || result.policyRevision !== current.policyRevision - || result.inputCandidate !== current.inputCandidate - || !AGENT_RESULT_TYPES.has(result.outcome) || typeof result.summary !== "string") fail("malformed or stale agent result"); - const allowed = node.mode === "task" ? result.outcome === "complete" : ["approve", "reject", "escalate"].includes(result.outcome); - if (!allowed) fail("agent result is illegal for node"); - return { kind: result.outcome, summary: clean(result.summary), outputBytes: Buffer.byteLength(texts.at(-1), "utf8"), - findings: result.findings, resolvedFindingIds: result.resolvedFindingIds }; -} -function boundedCompletion(handle, milliseconds) { - if (!milliseconds) return handle.completion.then((value) => ({ value, timedOut: false })); - let timer; - const timeout = new Promise((resolve) => { - timer = setTimeout(async () => { - try { handle.cancel(); } catch { /* a broken controller is still bounded */ } - const forced = await Promise.race([ - Promise.resolve(handle.completion).then((value) => ({ value }), () => ({ value: null })), - new Promise((done) => setTimeout(() => done(null), 500)), - ]); - resolve(forced ? { ...forced, timedOut: true } : { value: null, cleanupLost: true }); - }, milliseconds); - }); - // Attaching both handlers avoids a later rejected completion becoming an - // unhandled rejection after the bounded timeout result has been returned. - const settled = Promise.resolve(handle.completion).then((value) => ({ value, timedOut: false }), () => ({ value: null, timedOut: false, failed: true })); - return Promise.race([settled, timeout]).finally(() => clearTimeout(timer)); -} - -/** - * M3 adapter seam for M2's normalized invoke callback. `bindingFor` provides - * the immutable assignment/candidate pair that the M2 invocation shape lacks; - * a final cannot be accepted without both values matching exactly. - */ -export function createNormalizedInvocation({ repoRoot, routes, nodes, bindingFor, candidateForBoundary = null, - startAgent = startCodexInvocation, runCheck = runTrustedCapability, agentTimeoutMs = 0 }) { - if (typeof repoRoot !== "string" || !repoRoot.startsWith("/") || !(nodes instanceof Map) || typeof bindingFor !== "function" - || !(candidateForBoundary === null || typeof candidateForBoundary === "function") - || typeof startAgent !== "function" || typeof runCheck !== "function" || !Number.isSafeInteger(agentTimeoutMs) - || agentTimeoutMs < 0 || agentTimeoutMs > 86_400_000) fail("invalid dispatcher input"); - let active = null; - async function invokeBound(invocation, node, current, requireReviewEvidence = true) { - if (node.mode === "review" && (!current.candidateContext || requireReviewEvidence && !current.reviewerEvidence.length)) - return Object.freeze({ kind: "error", summary: "review context is incomplete", outputBytes: 0 }); - if (node.kind === "check") { - try { - boundaryCandidate(candidateForBoundary, current.inputCandidate); - const checked = await runCheck({ repoRoot, capabilityId: node.capability, inputCandidate: current.inputCandidate }); - boundaryCandidate(candidateForBoundary, current.inputCandidate); - if (checked?.result?.inputCandidate !== current.inputCandidate || !["pass", "fail"].includes(checked?.result?.outcome)) fail("stale trusted check result"); - const summary = checked.result.timedOut ? "repository check timed out" : checked.result.truncated ? "repository check output limit" : `repository check ${checked.result.outcome}`; - return Object.freeze({ kind: checked.result.timedOut ? "timeout" : checked.result.outcome, summary, outputBytes: Buffer.isBuffer(checked.evidence) ? checked.evidence.length : 0 }); - } catch (error) { return Object.freeze({ kind: "error", summary: clean(error?.message), outputBytes: 0 }); } - } - const profile = route(routes, node.mode === "review" ? "review" : "implementation"); - try { - if (node.mode === "review") boundaryCandidate(candidateForBoundary, current.inputCandidate); - const handle = startAgent({ profile, cwd: repoRoot, prompt: finalPrompt(invocation, node, current) }); - if (!handle || typeof handle.cancel !== "function" || !handle.completion || typeof handle.completion.then !== "function") fail("agent did not return a foreground handle"); - active = handle; - const completed = await boundedCompletion(handle, agentTimeoutMs); - if (completed.cleanupLost) return Object.freeze({ kind: "lost", summary: "agent deadline cleanup unproven", outputBytes: 0 }); - if (completed.value?.outcome === "quarantined") return Object.freeze({ kind: "lost", summary: "agent process cleanup unproven", outputBytes: 0 }); - if (completed.timedOut) return Object.freeze({ kind: "timeout", summary: "agent deadline exceeded", outputBytes: 0 }); - if (completed.failed) return Object.freeze({ kind: "error", summary: "agent process failed", outputBytes: 0 }); - if (completed.value.outcome === "cancelled") return Object.freeze({ kind: "cancelled", summary: "agent cancelled", outputBytes: 0 }); - if (completed.value.outcome !== "completed") return Object.freeze({ kind: "error", summary: "agent process failed", outputBytes: 0 }); - if (node.mode === "review") boundaryCandidate(candidateForBoundary, current.inputCandidate); - return Object.freeze(agentResult(completed.value.events, invocation, node, current)); - } catch (error) { return Object.freeze({ kind: "error", summary: clean(error?.message), outputBytes: 0 }); } - finally { active = null; } - } - async function invoke(invocation) { - const node = nodes.get(invocation?.nodeId); - if (!invocation || typeof invocation !== "object" || !node || typeof node !== "object") fail("invalid invocation"); - let current; - try { current = binding(bindingFor(invocation, node)); } catch (error) { return Object.freeze({ kind: "error", summary: clean(error?.message), outputBytes: 0 }); } - const result = await invokeBound(invocation, node, current); - const { findings: _findings, resolvedFindingIds: _resolved, ...normalized } = result; - return Object.freeze(normalized); - } - /** Executes the exact controller-prepared agent input; no second prompt binding is invented. */ - async function invokePrepared(envelopeBytes) { - const envelope = validateHostExecutionEnvelope(envelopeBytes), input = envelope.input.value; - const node = nodes.get(input.nodeId); - if (!node || node.kind !== "agent") fail("prepared invocation does not target an agent node"); - const current = { - claimId: input.claimId, assignmentId: input.assignmentId, recipeRevision: input.recipeRevision, - policyRevision: input.policyRevision, inputCandidate: input.inputCandidate, - instructionBytes: Buffer.from(input.instructionBytes, "base64").toString("utf8"), - itemText: input.itemText ? Buffer.from(input.itemText, "base64").toString("utf8") : "legacy prepared claim", - candidateContext: Buffer.from(input.candidateContext, "base64").toString("utf8"), reviewerEvidence: input.reviewerEvidence, - openFindings: input.openFindings ?? [], - }; - return invokeBound(input, node, current); - } - Object.defineProperty(invoke, "cancel", { value: () => active?.cancel?.() === true, enumerable: false }); - Object.defineProperty(invoke, "active", { get: () => active !== null, enumerable: false }); - Object.defineProperty(invoke, "invokePrepared", { value: invokePrepared, enumerable: false }); - return invoke; -} diff --git a/src/loops/adapters/normalized-invocation.test.mjs b/src/loops/adapters/normalized-invocation.test.mjs deleted file mode 100644 index 76b276d2..00000000 --- a/src/loops/adapters/normalized-invocation.test.mjs +++ /dev/null @@ -1,143 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; -import { createNormalizedInvocation } from "./normalized-invocation.mjs"; -import { createDispatchAuthority, createInvocationInput } from "../contracts/agent-result.mjs"; -import { createHostExecutionEnvelope } from "../contracts/host-execution.mjs"; -import { rawSha256 } from "../dsl/hash.mjs"; - -const profile = { id: "fake" }; -const routes = { implementation: { profile }, review: { profile: { id: "review" } } }; -const call = Object.freeze({ runId: "run:01arz3ndektsv4rrffq69g5fav", nodeId: "implement", attempt: 1, invocationId: "a".repeat(32) }); -const maker = Object.freeze({ id: "implement", kind: "agent", mode: "task", execution: "managed", intelligence: "standard", role: "maker", instructions: "make" }); -const reviewer = Object.freeze({ id: "review", kind: "agent", mode: "review", execution: "managed", intelligence: "strong", role: "reviewer", instructions: "review" }); -const check = Object.freeze({ id: "verify", kind: "check", capability: "repo-verify" }); -const current = Object.freeze({ - claimId: `cl1-sha256:${"1".repeat(64)}`, assignmentId: `as1-sha256:${"b".repeat(64)}`, - recipeRevision: `er1-sha256:${"2".repeat(64)}`, policyRevision: `bp1-sha256:${"3".repeat(64)}`, - inputCandidate: `cm1-sha256:${"a".repeat(64)}`, instructionBytes: "Do the frozen work.\n", - itemText: "- [ ] M3 | Connect processes\n", candidateContext: "candidate-v1\n", reviewerEvidence: ["verify:pass"], -}); -function final(invocation, node, outcome = "complete", extra = {}) { - return JSON.stringify({ schema: "burnlist.agent-final@1", runId: invocation.runId, nodeId: node.id, attempt: invocation.attempt, - claimId: current.claimId, invocationId: invocation.invocationId, assignmentId: current.assignmentId, - recipeRevision: current.recipeRevision, policyRevision: current.policyRevision, - inputCandidate: current.inputCandidate, outcome, summary: "ok", findings: [], resolvedFindingIds: [], ...extra }); -} -function event(...texts) { return texts.map((text) => ({ type: "item.completed", item: { type: "agent_message", text } })); } -function agent(events, outcome = "completed") { return () => ({ cancel() { return true; }, completion: Promise.resolve({ outcome, events }) }); } -function dispatcher({ startAgent, runCheck, timeout = 0, bindingFor = () => current, candidateForBoundary = null } = {}) { - return createNormalizedInvocation({ repoRoot: "/repo", routes, nodes: new Map([[maker.id, maker], [reviewer.id, reviewer], [check.id, check]]), bindingFor, - candidateForBoundary, startAgent: startAgent ?? agent(event(final(call, maker))), runCheck: runCheck ?? (async () => ({ result: { outcome: "pass", inputCandidate: current.inputCandidate, timedOut: false, truncated: false }, evidence: Buffer.from("check") })), agentTimeoutMs: timeout }); -} - -function preparedReview(reviewerEvidence) { - const binding = { runId: call.runId, nodeId: "review", attempt: 1, claimId: current.claimId, assignmentId: current.assignmentId, - invocationId: `iv1-sha256:${"c".repeat(64)}`, recipeRevision: current.recipeRevision, policyRevision: current.policyRevision, - inputCandidate: current.inputCandidate }; - const instruction = Buffer.from(current.instructionBytes), input = createInvocationInput({ schema: "burnlist-loop-invocation-input@1", ...binding, - itemRevision: `id1-sha256:${"d".repeat(64)}`, execution: reviewer.execution, intelligence: reviewer.intelligence, instructionDigest: rawSha256(instruction), instructionBytes: instruction.toString("base64"), - mode: "review", role: "reviewer", authority: "read", legalOutcomes: ["approve", "reject", "escalate"], requires: [], - itemText: Buffer.from(current.itemText).toString("base64"), - candidateContext: Buffer.from(current.candidateContext).toString("base64"), reviewerEvidence }); - const authority = createDispatchAuthority({ schema: "burnlist-loop-dispatch-authority@1", state: "prepared-before-dispatch", ...binding, - itemRevision: input.value.itemRevision, inputSchema: input.value.schema, inputDigest: input.digest, inputByteLength: input.bytes.length }); - return createHostExecutionEnvelope({ schema: "burnlist-loop-host-execution@1", ...binding, issuedAt: 1, expiresAt: 3_601_000, - invocationInput: input.bytes.toString("base64"), dispatchAuthority: authority.bytes.toString("base64") }).bytes; -} - -test("maps actual Codex agent-message finals for maker and fresh reviewer", async () => { - const seen = []; - const invoke = dispatcher({ startAgent: ({ profile: selected, prompt }) => { - seen.push(selected.id); const node = selected.id === "review" ? reviewer : maker; const invocationId = /invocation=([a-f0-9]{32})/u.exec(prompt)[1]; - assert.match(prompt, /FROZEN INSTRUCTIONS:\nDo the frozen work\.\n/u); - assert.match(prompt, /ASSIGNED ITEM:\n- \[ \] M3 \| Connect processes\n/u); - return { cancel() { return true; }, completion: Promise.resolve({ outcome: "completed", - events: event("Working notes before the terminal envelope.", final({ ...call, nodeId: node.id, invocationId }, node, node === reviewer ? "reject" : "complete")) }) }; - } }); - assert.equal((await invoke(call)).kind, "complete"); - assert.equal((await invoke({ ...call, nodeId: "review", invocationId: "c".repeat(32) })).kind, "reject"); - assert.deepEqual(seen, ["fake", "review"]); -}); - -test("budgets only the accepted terminal envelope, not provider event telemetry", async () => { - const envelope = final(call, maker); - const invoke = dispatcher({ startAgent: agent([ - { type: "provider.telemetry", payload: "x".repeat(300_000) }, - ...event(envelope), - ]) }); - const result = await invoke(call); - assert.equal(result.kind, "complete"); - assert.equal(result.outputBytes, Buffer.byteLength(envelope, "utf8")); -}); - -test("rejects malformed or stale claim, revision, candidate, assignment, and invocation finals", async () => { - for (const extra of [{ invocationId: "b".repeat(32) }, { claimId: `cl1-sha256:${"9".repeat(64)}` }, - { recipeRevision: `er1-sha256:${"8".repeat(64)}` }, { policyRevision: `bp1-sha256:${"7".repeat(64)}` }, - { inputCandidate: `cm1-sha256:${"c".repeat(64)}` }, { assignmentId: `as1-sha256:${"d".repeat(64)}` }]) { - const invoke = dispatcher({ startAgent: agent(event(final(call, maker, "complete", extra))) }); - assert.equal((await invoke(call)).kind, "error"); - } - assert.equal((await dispatcher({ startAgent: agent(event("not json")) })(call)).kind, "error"); - assert.equal((await dispatcher({ startAgent: agent(event(final(call, maker), final(call, maker))) })(call)).kind, "complete"); - assert.equal((await dispatcher({ bindingFor: () => ({ inputCandidate: current.inputCandidate }) })(call)).kind, "error"); -}); - -test("rejects duplicate terminal reports when report bytes differ", async () => { - const first = dispatcher({ startAgent: agent(event(final(call, maker, "complete", { summary: "first" }))) }) ; - const mismatch = dispatcher({ - startAgent: agent(event(final(call, maker, "complete", { summary: "first" }), final(call, maker, "complete", { summary: "second" }))), - })(call); - assert.equal((await first(call)).kind, "complete"); - assert.equal((await mismatch).kind, "error"); -}); - -test("maps trusted checks including timeout and stale candidate without invoking an agent", async () => { - const verify = { ...call, nodeId: "verify" }; - assert.equal((await dispatcher()(verify)).kind, "pass"); - const timeout = await dispatcher({ runCheck: async () => ({ result: { outcome: "fail", inputCandidate: current.inputCandidate, timedOut: true, truncated: false }, evidence: Buffer.from("x") }) })(verify); - assert.equal(timeout.kind, "timeout"); - const stale = await dispatcher({ runCheck: async () => ({ result: { outcome: "pass", inputCandidate: `cm1-sha256:${"f".repeat(64)}`, timedOut: false, truncated: false }, evidence: Buffer.alloc(0) }) })(verify); - assert.equal(stale.kind, "error"); -}); - -test("invalidates check and review evidence when the worktree drifts at a boundary", async () => { - const verify = { ...call, nodeId: "verify" }; - const changed = `cm1-sha256:${"f".repeat(64)}`; - let captures = 0; - const checkResult = await dispatcher({ - candidateForBoundary: () => ({ id: captures++ === 0 ? current.inputCandidate : changed }), - })(verify); - assert.equal(checkResult.kind, "error"); - captures = 0; - const reviewResult = await dispatcher({ - candidateForBoundary: () => ({ id: captures++ === 0 ? current.inputCandidate : changed }), - startAgent: agent(event(final({ ...call, nodeId: "review", invocationId: "c".repeat(32) }, reviewer, "approve"))), - })({ ...call, nodeId: "review", invocationId: "c".repeat(32) }); - assert.equal(reviewResult.kind, "error"); -}); - -test("agent deadline becomes lost if cancellation never settles", async () => { - const invoke = dispatcher({ timeout: 10, startAgent: () => ({ cancel() { return true; }, completion: new Promise(() => {}) }) }); - assert.deepEqual(await invoke(call), { kind: "lost", summary: "agent deadline cleanup unproven", outputBytes: 0 }); -}); - -test("prepared review dispatch keeps envelope evidence enforcement and exposes exact evidence", async () => { - const evidence = `artifact:sha256:${"e".repeat(64)}`; - let spawned = false; - const missing = dispatcher({ startAgent() { spawned = true; throw new Error("must not start"); } }); - assert.equal((await missing.invokePrepared(preparedReview([]))).kind, "error"); - assert.equal(spawned, false); - let prompt = ""; - const invoke = dispatcher({ startAgent: ({ prompt: value }) => { - prompt = value; - return { cancel() { return true; }, completion: Promise.resolve({ outcome: "cancelled", events: [] }) }; - } }); - assert.equal((await invoke.invokePrepared(preparedReview([evidence]))).kind, "cancelled"); - assert.match(prompt, new RegExp(evidence, "u")); -}); - -test("summaries are valid UTF-8 and limited to 1024 bytes", async () => { - const result = await dispatcher({ startAgent: agent(event(final(call, maker, "complete", { summary: "€".repeat(500) }))) })(call); - assert.ok(Buffer.byteLength(result.summary, "utf8") <= 1024); - assert.doesNotThrow(() => Buffer.from(result.summary, "utf8").toString("utf8")); -}); diff --git a/src/loops/agents/profile.mjs b/src/loops/agents/profile.mjs deleted file mode 100644 index d5ebd1e6..00000000 --- a/src/loops/agents/profile.mjs +++ /dev/null @@ -1,128 +0,0 @@ -import { isAbsolute } from "node:path"; -import { prefixed } from "../dsl/hash.mjs"; - -export const AGENT_PROFILE_SCHEMA = "burnlist-loop-agent-profile@1"; -export const STAGE_ONE_ROUTES = Object.freeze(["implementation.standard", "review.strong"]); -export const CODEX_MODELS = Object.freeze(["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", "gpt-5.3-codex-spark"]); -export const REASONING_EFFORTS = Object.freeze(["minimal", "low", "medium", "high", "xhigh", "max"]); - -const slug = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u; -const controls = /[\0\r\n]/u; -const guarantee = new Set(["enforced", "detected-at-boundaries", "supervised", "unsupported"]); -const requiredReviewerGuarantees = Object.freeze({ freshSession: "enforced", filesystemWriteDeny: "supervised" }); - -function fail(message, code = "ELOOP_AGENT_PROFILE") { throw Object.assign(new Error(`Loop agent: ${message}`), { code }); } -function exact(value, keys) { return Boolean(value) && typeof value === "object" && !Array.isArray(value) && Object.keys(value).length === keys.length && keys.every((key) => Object.hasOwn(value, key)); } -function text(value, label, maximum = 4096) { - if (typeof value !== "string" || !value || Buffer.byteLength(value) > maximum || controls.test(value)) fail(`invalid ${label}`); - return value; -} -function identity(value, label) { - if (!exact(value, ["adapter", "binary", "model", "effort", "sandbox"])) fail(`invalid ${label} identity`); - if (value.adapter !== "builtin:codex-cli" || !isAbsolute(text(value.binary, `${label} binary`)) || !CODEX_MODELS.includes(value.model) || !REASONING_EFFORTS.includes(value.effort) || !["workspace-write", "read-only"].includes(value.sandbox)) fail(`invalid ${label} identity`); - return { adapter: value.adapter, binary: value.binary, model: value.model, effort: value.effort, sandbox: value.sandbox }; -} -function guarantees(value) { - const keys = ["freshSession", "filesystemWriteDeny", "foregroundHandle", "cancellation", "lifecycle", "usage"]; - if (!exact(value, keys) || keys.some((key) => key === "usage" ? !["reported", "unavailable"].includes(value[key]) : !guarantee.has(value[key]))) fail("invalid probe guarantees"); - return Object.fromEntries(keys.map((key) => [key, value[key]])); -} -function validInvocation(requested, argv) { - return Array.isArray(argv) && argv.length === 15 - && argv[0] === requested.binary && argv[1] === "exec" && argv[2] === "--json" && argv[3] === "--ephemeral" - && argv[4] === "-m" && argv[5] === requested.model && argv[6] === "-c" && argv[7] === `model_reasoning_effort=${requested.effort}` - && argv[8] === "-s" && argv[9] === requested.sandbox && argv[10] === "-C" - && typeof argv[11] === "string" && isAbsolute(argv[11]) && !controls.test(argv[11]) - && argv[12] === "--skip-git-repo-check" && typeof argv[13] === "string" && argv[13] === "--" - && typeof argv[14] === "string" && argv[14].length > 0 && Buffer.byteLength(argv[14]) <= 262144 && !argv[14].includes("\0"); -} - -/** Closed local profile. It requests an identity and authority but proves neither. */ -export function validateAgentProfile(value) { - const keys = ["schema", "id", "adapter", "binary", "model", "effort", "authority"]; - if (!exact(value, keys) || value.schema !== AGENT_PROFILE_SCHEMA || !slug.test(value.id)) fail("invalid profile"); - if (value.adapter !== "builtin:codex-cli" || !isAbsolute(text(value.binary, "binary")) || !["read", "write"].includes(value.authority)) fail("invalid profile"); - if (!CODEX_MODELS.includes(value.model)) fail(`model must be one of: ${CODEX_MODELS.join(", ")}`); - if (!REASONING_EFFORTS.includes(value.effort)) fail(`effort must be one of: ${REASONING_EFFORTS.join(", ")}`); - return Object.freeze({ schema: value.schema, id: value.id, adapter: value.adapter, binary: value.binary, model: value.model, effort: value.effort, authority: value.authority }); -} - -/** Provider statements and host-observed invocation evidence remain separate. */ -export function validateCodexProbe(value) { - if (!exact(value, ["schema", "requested", "providerReported", "technicallyProven", "guarantees"]) || value.schema !== "burnlist-codex-probe@1") fail("invalid Codex probe"); - const requested = identity(value.requested, "requested"); - if (!exact(value.providerReported, ["model", "sessionId", "version"]) || typeof value.providerReported.sessionId !== "string" || !value.providerReported.sessionId || [value.providerReported.model, value.providerReported.version].some((item) => item !== null && typeof item !== "string") || [value.providerReported.sessionId, value.providerReported.model, value.providerReported.version].filter((item) => item !== null).some((item) => !item || Buffer.byteLength(item) > 512 || controls.test(item))) fail("invalid provider-reported identity"); - if (!exact(value.technicallyProven, ["argv", "pidObserved"]) || value.technicallyProven.pidObserved !== true || !validInvocation(requested, value.technicallyProven.argv)) fail("invalid technically-proven identity"); - return Object.freeze({ - schema: value.schema, - requested, - providerReported: { model: value.providerReported.model, sessionId: value.providerReported.sessionId, version: value.providerReported.version }, - technicallyProven: { argv: [...value.technicallyProven.argv], pidObserved: true }, - guarantees: guarantees(value.guarantees), - }); -} - -export function requestedCodexIdentity(profile) { - const current = validateAgentProfile(profile); - return Object.freeze({ adapter: current.adapter, binary: current.binary, model: current.model, effort: current.effort, sandbox: current.authority === "write" ? "workspace-write" : "read-only" }); -} -export function agentProfileRevision(profile) { - const current = validateAgentProfile(profile); - return prefixed("ap1-sha256:", "agent-profile-v1", [Buffer.from(`${JSON.stringify(current)}\n`)]); -} -function sameIdentity(left, right) { return left.adapter === right.adapter && left.binary === right.binary && left.model === right.model && left.effort === right.effort && left.sandbox === right.sandbox; } -function routeMap(value) { - if (!exact(value, STAGE_ONE_ROUTES)) fail("Stage 1 routes must be closed"); - for (const route of STAGE_ONE_ROUTES) if (!slug.test(value[route])) fail(`invalid ${route} route`); - return value; -} - -/** Resolve M1 configuration authority without launching or probing an agent. */ -export function resolveConfiguredStageOneRoutes({ profiles, routes }) { - if (!Array.isArray(profiles) || profiles.length < 2 || profiles.length > 32) fail("invalid profiles"); - const byId = new Map(); - for (const profile of profiles.map(validateAgentProfile)) { - if (byId.has(profile.id)) fail("duplicate profile id"); - byId.set(profile.id, profile); - } - const mapped = routeMap(routes); - if (mapped["implementation.standard"] === mapped["review.strong"]) fail("implementation and review require distinct profile ids", "ELOOP_REVIEWER_ISOLATION"); - const resolveRoute = (route, authority) => { - const profile = byId.get(mapped[route]); - if (!profile) fail(`route ${route} references an unknown profile`); - if (profile.authority !== authority) fail(`route ${route} requires ${authority} authority`); - return Object.freeze({ route, profile, authority: profile.authority }); - }; - const implementation = resolveRoute("implementation.standard", "write"); - const review = resolveRoute("review.strong", "read"); - return Object.freeze({ - implementation: Object.freeze({ ...implementation, guarantees: Object.freeze({}) }), - review: Object.freeze({ ...review, guarantees: Object.freeze({ freshSession: "enforced", filesystemWriteDeny: "supervised" }) }), - }); -} - -/** Bind only the two Stage 1 routes; hard reviewer guarantees never downgrade. */ -export function resolveStageOneRoutes({ profiles, routes, probes }) { - if (!Array.isArray(profiles) || profiles.length < 2 || profiles.length > 32) fail("invalid profiles"); - const byId = new Map(); - for (const profile of profiles.map(validateAgentProfile)) { - if (byId.has(profile.id)) fail("duplicate profile id"); - byId.set(profile.id, profile); - } - const mapped = routeMap(routes); - if (!probes || typeof probes !== "object" || Array.isArray(probes)) fail("invalid probe map"); - const resolveRoute = (route, authority) => { - const profile = byId.get(mapped[route]); - if (!profile) fail(`route ${route} references an unknown profile`); - if (profile.authority !== authority) fail(`route ${route} requires ${authority} authority`); - const probe = validateCodexProbe(probes[profile.id]); const requested = requestedCodexIdentity(profile); - if (!sameIdentity(probe.requested, requested) || probe.providerReported.model !== null && probe.providerReported.model !== requested.model) fail(`probe does not bind profile ${profile.id}`); - return Object.freeze({ route, profile, requested, providerReported: probe.providerReported, technicallyProven: probe.technicallyProven, guarantees: probe.guarantees }); - }; - const implementation = resolveRoute("implementation.standard", "write"); - const review = resolveRoute("review.strong", "read"); - if (implementation.profile.id === review.profile.id || implementation.providerReported.sessionId === review.providerReported.sessionId) fail("implementation and review require independent provider sessions", "ELOOP_REVIEWER_ISOLATION"); - for (const [name, expected] of Object.entries(requiredReviewerGuarantees)) - if (review.guarantees[name] !== expected) fail(`review route lacks ${expected} ${name}`, "ELOOP_REVIEWER_ISOLATION"); - return Object.freeze({ implementation, review }); -} diff --git a/src/loops/agents/profile.test.mjs b/src/loops/agents/profile.test.mjs deleted file mode 100644 index 4c9af72c..00000000 --- a/src/loops/agents/profile.test.mjs +++ /dev/null @@ -1,32 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; -import { requestedCodexIdentity, resolveConfiguredStageOneRoutes, validateAgentProfile, validateCodexProbe } from "./profile.mjs"; - -const profile = { schema: "burnlist-loop-agent-profile@1", id: "reviewer", adapter: "builtin:codex-cli", binary: "/usr/local/bin/codex", model: "gpt-5.6-sol", effort: "medium", authority: "read" }; -const requested = requestedCodexIdentity(profile); -const argv = [profile.binary, "exec", "--json", "--ephemeral", "-m", profile.model, "-c", "model_reasoning_effort=medium", "-s", "read-only", "-C", "/tmp/repo", "--skip-git-repo-check", "--", "Review."]; - -test("agent profiles are closed and request a role-specific sandbox", () => { - assert.deepEqual(requested, { adapter: "builtin:codex-cli", binary: profile.binary, model: profile.model, effort: profile.effort, sandbox: "read-only" }); - assert.throws(() => validateAgentProfile({ ...profile, unknown: true }), /invalid profile/u); - assert.throws(() => validateAgentProfile({ ...profile, binary: "codex" }), /invalid profile/u); -}); - -test("probe contract rejects non-ephemeral or identity-mismatched launch proof", () => { - const value = { schema: "burnlist-codex-probe@1", requested, providerReported: { model: profile.model, sessionId: "provider-session", version: "1.2.3" }, technicallyProven: { argv, pidObserved: true }, guarantees: { freshSession: "enforced", filesystemWriteDeny: "enforced", foregroundHandle: "enforced", cancellation: "enforced", lifecycle: "enforced", usage: "unavailable" } }; - assert.deepEqual(validateCodexProbe(value).technicallyProven.argv, argv); - assert.equal(validateCodexProbe({ ...value, providerReported: { model: null, sessionId: "provider-session", version: null } }).providerReported.model, null); - assert.throws(() => validateCodexProbe({ ...value, technicallyProven: { ...value.technicallyProven, argv: argv.filter((item) => item !== "--ephemeral") } }), /technically-proven/u); - assert.throws(() => validateCodexProbe({ ...value, requested: { ...requested, model: "gpt-5.6-terra" } }), /technically-proven/u); -}); - -test("configured Stage 1 routes have exact names, distinct profiles, and honest M1 guarantees", () => { - const maker = { ...profile, id: "maker", authority: "write" }; - const resolved = resolveConfiguredStageOneRoutes({ profiles: [maker, profile], routes: { "implementation.standard": "maker", "review.strong": "reviewer" } }); - assert.equal(resolved.implementation.profile.id, "maker"); - assert.equal(resolved.implementation.authority, "write"); - assert.equal(resolved.review.profile.id, "reviewer"); - assert.deepEqual(resolved.review.guarantees, { freshSession: "enforced", filesystemWriteDeny: "supervised" }); - assert.throws(() => resolveConfiguredStageOneRoutes({ profiles: [maker, profile], routes: { "implementation.standard": "maker", "review.strong": "maker" } }), /distinct profile ids/u); - assert.throws(() => resolveConfiguredStageOneRoutes({ profiles: [maker, profile], routes: { "implementation.standard": "reviewer", "review.strong": "maker" } }), /requires write authority/u); -}); diff --git a/src/loops/completion/completion.test.mjs b/src/loops/completion/completion.test.mjs index 09db019b..16e29f78 100644 --- a/src/loops/completion/completion.test.mjs +++ b/src/loops/completion/completion.test.mjs @@ -5,7 +5,9 @@ import { join } from "node:path"; import test from "node:test"; import { burnItem, closeLifecycle } from "../../cli/lifecycle-moves.mjs"; import { prepareItemMutation, unassignLoopItem } from "../assignment/assignment.mjs"; -import { createProductionRun, createStoredProductionRunRunner } from "../run/binder.mjs"; +import { createProductionRun, createStoredSystemRunRunner } from "../run/binder.mjs"; +import { createLoopController } from "../run/controller.mjs"; +import { validateHostExecutionEnvelope } from "../contracts/host-execution.mjs"; import { createProductionRunAuthority, fixtureItemRef, fixtureRunId } from "../run/run-test-fixtures.mjs"; import { runStore } from "../run/run-store.mjs"; import { completeLoopRun } from "./completion.mjs"; @@ -25,15 +27,21 @@ function unassignedContext(t) { async function converged(context, runId = fixtureRunId) { const store = runStore(context.repo); await createProductionRun({ repoRoot: context.repo, store, itemRef: fixtureItemRef, runId }); - const counter = join(context.directory, `counter-${runId.slice(-4)}`); writeFileSync(counter, "0"); - const before = [process.env.BURNLIST_FAKE_COUNTER, process.env.BURNLIST_FAKE_OUTCOMES]; - process.env.BURNLIST_FAKE_COUNTER = counter; process.env.BURNLIST_FAKE_OUTCOMES = "complete,complete,complete,approve,complete,approve"; - try { assert.equal((await createStoredProductionRunRunner({ repoRoot: context.repo, store, runId }).run()).projection.state, "converged"); } - finally { - for (const [key, value] of [["BURNLIST_FAKE_COUNTER", before[0]], ["BURNLIST_FAKE_OUTCOMES", before[1]]]) { - if (value === undefined) delete process.env[key]; else process.env[key] = value; - } + const controller = createLoopController({ store, repoRoot: context.repo, + runnerFor: (id) => createStoredSystemRunRunner({ repoRoot: context.repo, store, runId: id }) }); + for (let attempts = 0; attempts < 8 && !store.read(runId).execution.terminal; attempts += 1) { + const claimed = controller.claim(runId), execution = validateHostExecutionEnvelope(claimed.envelope).value; + const outcome = store.read(runId).execution.node.mode === "task" ? "complete" : "approve"; + const report = Buffer.from(`${JSON.stringify({ schema: "burnlist-loop-host-report@1", result: { + schema: "agent-result@1", runId: execution.runId, nodeId: execution.nodeId, + attempt: execution.attempt, claimId: execution.claimId, assignmentId: execution.assignmentId, + invocationId: execution.invocationId, recipeRevision: execution.recipeRevision, + policyRevision: execution.policyRevision, inputCandidate: execution.inputCandidate, + outcome, findings: [], resolvedFindingIds: [], + }, telemetry: null })}\n`); + await controller.report(execution.claimId, report); } + assert.equal(store.read(runId).projection.state, "converged"); return store; } function runPath(store, runId, name) { return join(store.paths.pathFor(runId), name); } diff --git a/src/loops/config/config.test.mjs b/src/loops/config/config.test.mjs index 7ec2cf7b..eb38a0a8 100644 --- a/src/loops/config/config.test.mjs +++ b/src/loops/config/config.test.mjs @@ -1,112 +1,79 @@ import assert from "node:assert/strict"; -import { execFileSync, spawnSync } from "node:child_process"; -import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { mkdtempSync, mkdirSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; +import { execFileSync, spawnSync } from "node:child_process"; import test from "node:test"; -import { localRecordPath, configRoot, writeLocalRecord } from "./store.mjs"; import { capabilityRevision, readCapabilityCatalog } from "../capabilities/contract.mjs"; -import { validateProfile } from "./profiles.mjs"; const root = resolve(new URL("../../..", import.meta.url).pathname); const cli = join(root, "bin", "burnlist.mjs"); -const policy = { id: "repo-verify", argv: [process.execPath, "-e", "process.exit(0)"], cwd: ".", environment: { inherit: ["PATH"], set: {} }, network: "deny", filesystem: { read: ["src"], write: [] }, output: { maxBytes: 1024 }, maxMilliseconds: 1000 }; +const policy = { id: "repo-verify", argv: [process.execPath, "-e", "process.exit(0)"], + cwd: ".", environment: { inherit: ["PATH"], set: {} }, network: "deny", + filesystem: { read: ["src"], write: [] }, output: { maxBytes: 1024 }, + maxMilliseconds: 1000 }; function fixture() { - const directory = realpathSync(mkdtempSync(join(tmpdir(), "burnlist-loop-config-"))), repo = join(directory, "repo"); - mkdirSync(join(repo, ".burnlist"), { recursive: true }); mkdirSync(join(repo, "src")); execFileSync("git", ["init", "--quiet", repo]); - writeFileSync(join(repo, ".burnlist", "loop-capabilities.json"), `${JSON.stringify({ schema: "burnlist-loop-capabilities@1", capabilities: [policy] })}\n`); - const marker = join(directory, "child-ran"), binary = join(directory, "fake-codex"); - writeFileSync(binary, `#!/bin/sh\necho ran > ${JSON.stringify(marker)}\n`, { mode: 0o700 }); - return { directory, repo, binary, marker, cleanup: () => rmSync(directory, { recursive: true, force: true }) }; + const directory = realpathSync(mkdtempSync(join(tmpdir(), "burnlist-loop-config-"))); + const repo = join(directory, "repo"); + mkdirSync(join(repo, ".burnlist"), { recursive: true }); + mkdirSync(join(repo, "src")); + execFileSync("git", ["init", "--quiet", repo]); + writeFileSync(join(repo, ".burnlist", "loop-capabilities.json"), + `${JSON.stringify({ schema: "burnlist-loop-capabilities@1", capabilities: [policy] })}\n`); + return { directory, repo, cleanup: () => rmSync(directory, { recursive: true, force: true }) }; } -function run(context, args) { return spawnSync(process.execPath, [cli, ...args, "--repo", context.repo], { cwd: context.repo, encoding: "utf8" }); } -function profile(context, id, authority) { - const result = run(context, ["agent", "profile", "add", id, "--adapter", "builtin:codex-cli", "--binary", context.binary, "--model", "gpt-5.6-terra", "--effort", "medium", "--authority", authority]); - assert.equal(result.status, 0, result.stderr); return result; + +function run(context, args) { + return spawnSync(process.execPath, [cli, ...args, "--repo", context.repo], + { cwd: context.repo, encoding: "utf8" }); } -function route(context, name, id) { const result = run(context, ["route", "set", name, "--profile", id]); assert.equal(result.status, 0, result.stderr); } + function trust(context) { const grants = join(context.repo, "grants.json"); - writeFileSync(grants, `${JSON.stringify(Object.fromEntries(Object.entries(policy).filter(([key]) => key !== "id")))}\n`); + writeFileSync(grants, `${JSON.stringify(Object.fromEntries( + Object.entries(policy).filter(([key]) => key !== "id")))}\n`); const revision = capabilityRevision(readCapabilityCatalog(context.repo).capabilities[0]); - const result = run(context, ["loop", "capability", "trust", "repo-verify", "--revision", revision, "--grants", grants]); - assert.equal(result.status, 0, result.stderr); + return run(context, ["loop", "capability", "trust", "repo-verify", + "--revision", revision, "--grants", grants]); } -test("M1 setup trusts only private configuration, exact routes, and repo-verify without launching", () => { +test("host-only setup requires capability trust but no agent profiles or routes", () => { const context = fixture(); try { const empty = run(context, ["loop", "setup", "status"]); - assert.equal(empty.status, 1); assert.match(empty.stdout, /MISSING route implementation\.standard/u); assert.match(empty.stdout, /MISSING trust repo-verify/u); - const maker = profile(context, "maker", "write"); - assert.equal(readFileSync(localRecordPath(context.repo, "profiles", "maker"), "utf8"), maker.stdout); - assert.equal(lstatSync(localRecordPath(context.repo, "profiles", "maker")).mode & 0o777, 0o600); - assert.equal(lstatSync(configRoot(context.repo)).mode & 0o777, 0o700); - route(context, "implementation.standard", "maker"); profile(context, "reviewer", "read"); route(context, "review.strong", "reviewer"); trust(context); - const status = run(context, ["loop", "setup", "status"]); - assert.equal(status.status, 0, status.stderr); assert.equal(status.stdout, "Loop setup: ready\n"); - assert.equal(existsSync(context.marker), false); + assert.equal(empty.status, 1); + assert.match(empty.stdout, /MISSING trust repo-verify/u); + assert.doesNotMatch(empty.stdout, /profile|route|adapter/u); + const trusted = trust(context); + assert.equal(trusted.status, 0, trusted.stderr); + const ready = run(context, ["loop", "setup", "status"]); + assert.equal(ready.status, 0, ready.stderr); + assert.equal(ready.stdout, "Loop setup: ready\n"); } finally { context.cleanup(); } }); -test("fresh repositories receive actionable capability and profile guidance", () => { - const directory = realpathSync(mkdtempSync(join(tmpdir(), "burnlist-loop-fresh-"))), repo = join(directory, "repo"); - try { - mkdirSync(repo, { recursive: true }); execFileSync("git", ["init", "--quiet", repo]); - const inspect = spawnSync(process.execPath, [cli, "loop", "capability", "inspect", "repo-verify", "--repo", repo], { cwd: repo, encoding: "utf8" }); - assert.equal(inspect.status, 1); assert.match(inspect.stderr, /create \.burnlist\/loop-capabilities\.json/u); - const setup = spawnSync(process.execPath, [cli, "loop", "setup", "status", "--repo", repo], { cwd: repo, encoding: "utf8" }); - assert.equal(setup.status, 1); assert.match(setup.stdout, /Review Loop capability example/u); - const invalid = spawnSync(process.execPath, [cli, "agent", "profile", "add", "maker", "--adapter", "builtin:codex-cli", "--binary", process.execPath, "--model", "unknown", "--effort", "fast", "--authority", "write", "--repo", repo], { cwd: repo, encoding: "utf8" }); - assert.equal(invalid.status, 1); assert.match(invalid.stderr, /model must be one of:.*gpt-5\.6-terra/u); - } finally { rmSync(directory, { recursive: true, force: true }); } -}); - -test("M1 setup fails closed for duplicate, malformed, wrong-authority, and untrusted configuration", () => { - for (const scenario of ["duplicate", "wrong-authority", "malformed", "untrusted"]) { - const context = fixture(); - try { - profile(context, "maker", "write"); route(context, "implementation.standard", "maker"); - if (scenario === "duplicate") route(context, "review.strong", "maker"); - else { profile(context, "reviewer", scenario === "wrong-authority" ? "write" : "read"); route(context, "review.strong", "reviewer"); } - if (scenario === "malformed") writeFileSync(localRecordPath(context.repo, "profiles", "reviewer"), "{}\n", { mode: 0o600 }); - if (scenario !== "untrusted") trust(context); - const result = run(context, ["loop", "setup", "status"]); - assert.equal(result.status, 1, scenario); assert.match(result.stdout, /MISSING (routing|profile|trust)/u); assert.equal(existsSync(context.marker), false); - } finally { context.cleanup(); } - } -}); - -test("legacy Docker commands are unsupported and configuration writes fail closed when public", () => { +test("removed managed-agent commands are not Loop configuration surfaces", () => { const context = fixture(); try { - for (const args of [["agent", "controller", "add", "host"], ["agent", "preflight", "maker"]]) { - const result = run(context, args); assert.equal(result.status, 2); assert.match(result.stderr, /Usage: burnlist agent profile add/u); + for (const args of [["agent", "profile", "add", "maker"], ["route", "set", "review.strong"]]) { + const result = run(context, args); + assert.notEqual(result.status, 0); + assert.doesNotMatch(`${result.stdout}${result.stderr}`, /builtin:codex-cli/u); } - profile(context, "maker", "write"); chmodSync(configRoot(context.repo), 0o755); - const result = run(context, ["route", "set", "implementation.standard", "--profile", "maker"]); - assert.equal(result.status, 1); assert.match(result.stderr, /unsafe config directory/u); } finally { context.cleanup(); } }); -test("setup read fails closed after config-v1 privacy drifts without mutation", () => { - const context = fixture(); +test("fresh repositories receive actionable capability-only guidance", () => { + const directory = realpathSync(mkdtempSync(join(tmpdir(), "burnlist-loop-fresh-"))); + const repo = join(directory, "repo"); try { - profile(context, "maker", "write"); route(context, "implementation.standard", "maker"); profile(context, "reviewer", "read"); route(context, "review.strong", "reviewer"); trust(context); - const profilePath = localRecordPath(context.repo, "profiles", "maker"), before = readFileSync(profilePath); - chmodSync(configRoot(context.repo), 0o755); - const result = run(context, ["loop", "setup", "status"]); - assert.equal(result.status, 1); assert.match(result.stdout, /unsafe config directory/u); - assert.deepEqual(readFileSync(profilePath), before); assert.equal(existsSync(context.marker), false); - } finally { context.cleanup(); } -}); - -test("private profile publication is atomic when publication is interrupted", () => { - const context = fixture(); - try { - const value = { schema: "burnlist-loop-agent-profile@1", id: "maker", adapter: "builtin:codex-cli", binary: context.binary, model: "gpt-5.6-terra", effort: "medium", authority: "write" }; - assert.throws(() => writeLocalRecord({ repoRoot: context.repo, collection: "profiles", name: "maker", value, validate: validateProfile, hooks: { beforeRename() { throw new Error("cut"); } } }), /cut/u); - assert.equal(existsSync(localRecordPath(context.repo, "profiles", "maker")), false); - } finally { context.cleanup(); } + mkdirSync(repo, { recursive: true }); + execFileSync("git", ["init", "--quiet", repo]); + const setup = spawnSync(process.execPath, + [cli, "loop", "setup", "status", "--repo", repo], { cwd: repo, encoding: "utf8" }); + assert.equal(setup.status, 1); + assert.match(setup.stdout, /Review Loop capability example/u); + assert.doesNotMatch(setup.stdout, /profile|route|adapter/u); + } finally { rmSync(directory, { recursive: true, force: true }); } }); diff --git a/src/loops/config/profiles.mjs b/src/loops/config/profiles.mjs deleted file mode 100644 index 32dba252..00000000 --- a/src/loops/config/profiles.mjs +++ /dev/null @@ -1,50 +0,0 @@ -import { isClosedObject, readLocalRecord, writeLocalRecord } from "./store.mjs"; -import { CODEX_MODELS, REASONING_EFFORTS, validateAgentProfile } from "../agents/profile.mjs"; -import { parse } from "node:path"; -import { snapshotTarget } from "../capabilities/snapshot.mjs"; - -const slug = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u; -const route = /^(?:implementation\.standard|review\.strong)$/u; -const ROUTE_KEYS = ["schema", "route", "profile"]; - -function fail(message) { throw Object.assign(new Error(`Loop profile: ${message}`), { code: "ELOOP_PROFILE" }); } -function profileName(value, label = "profile slug") { if (typeof value !== "string" || !slug.test(value)) fail(`invalid ${label}`); return value; } - -export function validateProfile(value) { - try { return validateAgentProfile(value); } catch (error) { fail(error.message.replace(/^Loop agent: /u, "")); } -} - -export function validateRoute(value) { - if (!isClosedObject(value, ROUTE_KEYS) || value.schema !== "burnlist-loop-route@1" || !route.test(value.route)) fail("route record has invalid schema"); - return { schema: value.schema, route: value.route, profile: profileName(value.profile) }; -} - -export function saveProfile({ repoRoot, slug: name, adapter, binary, model, effort, authority }) { - const value = { schema: "burnlist-loop-agent-profile@1", id: name, adapter, binary, model, effort, authority }; - return writeLocalRecord({ repoRoot, collection: "profiles", name: profileName(name), value, validate: validateProfile }); -} -export function readProfile({ repoRoot, slug: name }) { return readLocalRecord({ repoRoot, collection: "profiles", name: profileName(name), validate: validateProfile }); } -export function saveRoute({ repoRoot, route: name, profile }) { - const value = { schema: "burnlist-loop-route@1", route: name, profile }; - return writeLocalRecord({ repoRoot, collection: "routes", name: String(name).replace(".", "-"), value, validate: validateRoute }); -} -export function readRoute({ repoRoot, route: name }) { if (!route.test(name)) fail("unknown route"); return readLocalRecord({ repoRoot, collection: "routes", name: name.replace(".", "-"), validate: validateRoute }); } - -/** - * No-cost local inspection only: no model invocation, child process, or write. - * Availability is not technical proof and therefore never makes setup ready. - */ -export function doctorProfile({ repoRoot, slug: name }) { - const profile = readProfile({ repoRoot, slug: name }); - try { - const snapshot = snapshotTarget({ root: parse(profile.binary).root, path: profile.binary, maximum: 64 * 1024 * 1024 }); - if ((snapshot.identity.mode & 0o111) === 0) return { available: false, ready: false, profile, reason: "binary is not executable" }; - return { available: true, ready: false, profile, executableDigest: snapshot.digest, reason: "technical guarantees are unavailable; no model probe was run" }; - } catch (error) { return { available: false, ready: false, profile, reason: error?.message || "binary inspection failed" }; } -} - -export const requiredRoutes = Object.freeze([ - { route: "implementation.standard", authority: "write" }, - { route: "review.strong", authority: "read" }, -]); -export { CODEX_MODELS, REASONING_EFFORTS }; diff --git a/src/loops/config/setup.mjs b/src/loops/config/setup.mjs index 69c819ac..91edfc5b 100644 --- a/src/loops/config/setup.mjs +++ b/src/loops/config/setup.mjs @@ -1,28 +1,12 @@ import { assertTrustedCapability } from "../capabilities/trust.mjs"; import { readCapabilityCatalog, resolveCapability } from "../capabilities/contract.mjs"; -import { resolveConfiguredStageOneRoutes } from "../agents/profile.mjs"; -import { readProfile, readRoute, requiredRoutes } from "./profiles.mjs"; -function profileCommand(slug = "", authority = "read|write") { return `burnlist agent profile add ${slug} --adapter builtin:codex-cli --binary --model --effort --authority ${authority}`; } -function routeCommand(route) { return `burnlist route set ${route} --profile `; } function record(kind, id, detail, remedy) { return { kind, id, detail, remedy }; } function clean(error) { return String(error?.message ?? error).replace(/^Loop (?:agent|local config|capability trust|capability): /u, ""); } /** Strictly read-only: configuration and trust readiness only; no child is launched. */ export function setupStatus({ repoRoot } = {}) { - const failures = [], profiles = [], routes = {}; - for (const expected of requiredRoutes) { - let assigned; - try { assigned = readRoute({ repoRoot, route: expected.route }); routes[expected.route] = assigned.profile; } - catch (error) { failures.push(record("route", expected.route, clean(error), routeCommand(expected.route))); continue; } - let profile; - try { profile = readProfile({ repoRoot, slug: assigned.profile }); profiles.push(profile); } - catch (error) { failures.push(record("profile", assigned.profile, clean(error), profileCommand(assigned.profile, expected.authority))); continue; } - } - if (Object.keys(routes).length === requiredRoutes.length && profiles.length === requiredRoutes.length) { - try { resolveConfiguredStageOneRoutes({ profiles, routes }); } - catch (error) { failures.push(record("routing", "stage-one", clean(error), "repair the named profile or route and run burnlist loop setup status again")); } - } + const failures = []; let resolved; try { resolved = resolveCapability(readCapabilityCatalog(repoRoot), "repo-verify"); } catch (error) { failures.push(record("capability", "repo-verify", clean(error), "create .burnlist/loop-capabilities.json from the Review Loop capability example, then run burnlist loop capability inspect repo-verify")); } diff --git a/src/loops/contracts/agent-result.mjs b/src/loops/contracts/agent-result.mjs index 75b4d63b..dae0e276 100644 --- a/src/loops/contracts/agent-result.mjs +++ b/src/loops/contracts/agent-result.mjs @@ -44,7 +44,7 @@ export function createInvocationInput(value) { || value.schema !== "burnlist-loop-invocation-input@1") fail("invalid invocation input"); identity(value, "invocation input"); if (!DIGESTS.item.test(value.itemRevision) || (Object.hasOwn(value, "execution") - && (!["managed", "host"].includes(value.execution) || !["fast", "standard", "strong", "critical"].includes(value.intelligence))) || !DIGESTS.raw.test(value.instructionDigest) + && (value.execution !== "host" || !["fast", "standard", "strong", "critical"].includes(value.intelligence))) || !DIGESTS.raw.test(value.instructionDigest) || !SLUG.test(value.nodeId) || !Array.isArray(value.reviewerEvidence) || value.reviewerEvidence.length > 50 || !sortedUnique(value.reviewerEvidence) || !value.reviewerEvidence.every((ref) => DIGESTS.artifact.test(ref))) fail("invalid invocation evidence"); const instruction = base64(value.instructionBytes, "instruction bytes", 65_536); diff --git a/src/loops/contracts/contracts.test.mjs b/src/loops/contracts/contracts.test.mjs index 48904246..e76cfff5 100644 --- a/src/loops/contracts/contracts.test.mjs +++ b/src/loops/contracts/contracts.test.mjs @@ -27,7 +27,7 @@ function check(outcome, extra = {}) { return { schema: "check-result@1", ...bind function bytes(value) { return Buffer.from(JSON.stringify(value)); } function invocation(extra = {}) { const instruction = Buffer.from("Follow the frozen instructions.\n"); - return { schema: "burnlist-loop-invocation-input@1", ...binding, itemRevision: d("id1-sha256", "8"), execution: "managed", intelligence: "strong", instructionDigest: rawSha256(instruction), + return { schema: "burnlist-loop-invocation-input@1", ...binding, itemRevision: d("id1-sha256", "8"), execution: "host", intelligence: "strong", instructionDigest: rawSha256(instruction), instructionBytes: instruction.toString("base64"), candidateContext: Buffer.from("candidate-context@1\n").toString("base64"), reviewerEvidence: [], ...extra }; } function dispatch(input, extra = {}) { diff --git a/src/loops/dsl/__fixtures__/review.ir.json b/src/loops/dsl/__fixtures__/review.ir.json index 00ec4a77..ece912fb 100644 --- a/src/loops/dsl/__fixtures__/review.ir.json +++ b/src/loops/dsl/__fixtures__/review.ir.json @@ -1 +1 @@ -{"schema":"burnlist-loop-ir@1","compiler":"burnlist-loop-compiler@1","id":"review","declaredVersion":"0.1.0","entry":"start","budget":{"maxRounds":12,"maxMinutes":60,"maxAgentRuns":18,"maxCheckRuns":8,"maxTransitions":40,"maxOutputBytes":262144},"nodes":[{"kind":"agent","id":"start","mode":"task","execution":"managed","intelligence":"standard","role":"maker","route":"implementation.standard","authority":"write","instructions":"start","independentFrom":null,"requires":[]},{"kind":"terminal","id":"completed","state":"converged"},{"kind":"gate","id":"converged","gateKind":"convergence","requires":["final-validate","final-review"]},{"kind":"agent","id":"decompose","mode":"task","execution":"managed","intelligence":"strong","role":"maker","route":"implementation.standard","authority":"write","instructions":"decompose","independentFrom":null,"requires":[]},{"kind":"terminal","id":"exhausted","state":"budget-exhausted"},{"kind":"terminal","id":"failed","state":"failed"},{"kind":"agent","id":"final-review","mode":"review","execution":"managed","intelligence":"critical","role":"reviewer","route":"review.strong","authority":"read","instructions":"final-review","independentFrom":"implement","requires":["fresh-session:enforced","filesystem-write-deny:supervised"]},{"kind":"check","id":"final-validate","capability":"repo-verify"},{"kind":"agent","id":"implement","mode":"task","execution":"managed","intelligence":"standard","role":"maker","route":"implementation.standard","authority":"write","instructions":"implement","independentFrom":null,"requires":[]},{"kind":"agent","id":"integrate","mode":"task","execution":"managed","intelligence":"strong","role":"maker","route":"implementation.standard","authority":"write","instructions":"integrate","independentFrom":null,"requires":[]},{"kind":"terminal","id":"needs-human","state":"needs-human"},{"kind":"agent","id":"review","mode":"review","execution":"managed","intelligence":"strong","role":"reviewer","route":"review.strong","authority":"read","instructions":"review","independentFrom":"implement","requires":["fresh-session:enforced","filesystem-write-deny:supervised"]},{"kind":"terminal","id":"stopped","state":"stopped"},{"kind":"check","id":"validate","capability":"repo-verify"}],"failurePolicy":{"error":"failed","timeout":"failed","cancelled":"stopped","lost":"needs-human","exhausted":"exhausted"},"edges":[{"from":"start","on":"complete","to":"decompose","maxVisits":null},{"from":"converged","on":"pass","to":"completed","maxVisits":null},{"from":"converged","on":"fail","to":"needs-human","maxVisits":null},{"from":"decompose","on":"complete","to":"implement","maxVisits":null},{"from":"final-review","on":"approve","to":"converged","maxVisits":null},{"from":"final-review","on":"reject","to":"decompose","maxVisits":3},{"from":"final-review","on":"escalate","to":"needs-human","maxVisits":null},{"from":"final-validate","on":"pass","to":"final-review","maxVisits":null},{"from":"final-validate","on":"fail","to":"decompose","maxVisits":3},{"from":"implement","on":"complete","to":"validate","maxVisits":null},{"from":"integrate","on":"complete","to":"final-validate","maxVisits":null},{"from":"review","on":"approve","to":"integrate","maxVisits":null},{"from":"review","on":"reject","to":"decompose","maxVisits":3},{"from":"review","on":"escalate","to":"needs-human","maxVisits":null},{"from":"validate","on":"pass","to":"review","maxVisits":null},{"from":"validate","on":"fail","to":"decompose","maxVisits":3}],"instructions":[{"id":"decompose","digest":"sha256:266f954460a1d833b73ae53b696166acb6b6c49536518f9af86467bf66046791","byteLength":253},{"id":"final-review","digest":"sha256:24c8c2c2574dbbe56732b94e097e35af6e60ea2406feb2ad0019bc28f41b5da8","byteLength":66},{"id":"implement","digest":"sha256:3aea4bc6d773f5b7b1a3e5899bd5be2cbce3aa2313a656e18d6444f244959e93","byteLength":246},{"id":"integrate","digest":"sha256:188626485e23cf2a78105095304fa336f65044d6a7e7559cfa97d804f15be07e","byteLength":236},{"id":"review","digest":"sha256:89a11f423ae87b2c86749cafa2bb956ab7ca7980fafc71aeec408ddc463024aa","byteLength":72},{"id":"start","digest":"sha256:106187b048c9e12f748dac6fe70f3c18a7579a56e9a62be9661bb8ea4e627c43","byteLength":240}]} +{"schema":"burnlist-loop-ir@1","compiler":"burnlist-loop-compiler@1","id":"review","declaredVersion":"0.1.0","entry":"start","budget":{"maxRounds":12,"maxMinutes":60,"maxAgentRuns":18,"maxCheckRuns":8,"maxTransitions":40,"maxOutputBytes":262144},"nodes":[{"kind":"agent","id":"start","mode":"task","execution":"host","intelligence":"standard","role":"maker","route":"implementation.standard","authority":"write","instructions":"start","independentFrom":null,"requires":[]},{"kind":"terminal","id":"completed","state":"converged"},{"kind":"gate","id":"converged","gateKind":"convergence","requires":["final-validate","final-review"]},{"kind":"agent","id":"decompose","mode":"task","execution":"host","intelligence":"strong","role":"maker","route":"implementation.standard","authority":"write","instructions":"decompose","independentFrom":null,"requires":[]},{"kind":"terminal","id":"exhausted","state":"budget-exhausted"},{"kind":"terminal","id":"failed","state":"failed"},{"kind":"agent","id":"final-review","mode":"review","execution":"host","intelligence":"critical","role":"reviewer","route":"review.strong","authority":"read","instructions":"final-review","independentFrom":"implement","requires":["fresh-session:enforced","filesystem-write-deny:supervised"]},{"kind":"check","id":"final-validate","capability":"repo-verify"},{"kind":"agent","id":"implement","mode":"task","execution":"host","intelligence":"standard","role":"maker","route":"implementation.standard","authority":"write","instructions":"implement","independentFrom":null,"requires":[]},{"kind":"agent","id":"integrate","mode":"task","execution":"host","intelligence":"strong","role":"maker","route":"implementation.standard","authority":"write","instructions":"integrate","independentFrom":null,"requires":[]},{"kind":"terminal","id":"needs-human","state":"needs-human"},{"kind":"agent","id":"review","mode":"review","execution":"host","intelligence":"strong","role":"reviewer","route":"review.strong","authority":"read","instructions":"review","independentFrom":"implement","requires":["fresh-session:enforced","filesystem-write-deny:supervised"]},{"kind":"terminal","id":"stopped","state":"stopped"},{"kind":"check","id":"validate","capability":"repo-verify"}],"failurePolicy":{"error":"failed","timeout":"failed","cancelled":"stopped","lost":"needs-human","exhausted":"exhausted"},"edges":[{"from":"start","on":"complete","to":"decompose","maxVisits":null},{"from":"converged","on":"pass","to":"completed","maxVisits":null},{"from":"converged","on":"fail","to":"needs-human","maxVisits":null},{"from":"decompose","on":"complete","to":"implement","maxVisits":null},{"from":"final-review","on":"approve","to":"converged","maxVisits":null},{"from":"final-review","on":"reject","to":"decompose","maxVisits":3},{"from":"final-review","on":"escalate","to":"needs-human","maxVisits":null},{"from":"final-validate","on":"pass","to":"final-review","maxVisits":null},{"from":"final-validate","on":"fail","to":"decompose","maxVisits":3},{"from":"implement","on":"complete","to":"validate","maxVisits":null},{"from":"integrate","on":"complete","to":"final-validate","maxVisits":null},{"from":"review","on":"approve","to":"integrate","maxVisits":null},{"from":"review","on":"reject","to":"decompose","maxVisits":3},{"from":"review","on":"escalate","to":"needs-human","maxVisits":null},{"from":"validate","on":"pass","to":"review","maxVisits":null},{"from":"validate","on":"fail","to":"decompose","maxVisits":3}],"instructions":[{"id":"decompose","digest":"sha256:266f954460a1d833b73ae53b696166acb6b6c49536518f9af86467bf66046791","byteLength":253},{"id":"final-review","digest":"sha256:24c8c2c2574dbbe56732b94e097e35af6e60ea2406feb2ad0019bc28f41b5da8","byteLength":66},{"id":"implement","digest":"sha256:3aea4bc6d773f5b7b1a3e5899bd5be2cbce3aa2313a656e18d6444f244959e93","byteLength":246},{"id":"integrate","digest":"sha256:188626485e23cf2a78105095304fa336f65044d6a7e7559cfa97d804f15be07e","byteLength":236},{"id":"review","digest":"sha256:89a11f423ae87b2c86749cafa2bb956ab7ca7980fafc71aeec408ddc463024aa","byteLength":72},{"id":"start","digest":"sha256:106187b048c9e12f748dac6fe70f3c18a7579a56e9a62be9661bb8ea4e627c43","byteLength":240}]} diff --git a/src/loops/dsl/__fixtures__/review.revisions.json b/src/loops/dsl/__fixtures__/review.revisions.json index 1eef9d69..e1f8d00e 100644 --- a/src/loops/dsl/__fixtures__/review.revisions.json +++ b/src/loops/dsl/__fixtures__/review.revisions.json @@ -1 +1 @@ -{"source":"ls1-sha256:dc05c650d52c3fe7d48ffb39cf0a9c771ee4d591b09b4e8ba7f83737bf02d087","package":"lp1-sha256:1cad5c04ad4406a80c6faa76531a9c1e6f75d1a7a1b5e7636aeafd2e0ed2a443","executable":"er1-sha256:95d15ba7bc8e46100fc829236049993d6f08b5b32e8d512f04d0cb149a79e3ed"} +{"source":"ls1-sha256:818f2046d350ebf74a946ead5d40aff943b40180c3e2e902d6645659caa47860","package":"lp1-sha256:276630d4e1896178504982fb8489b14def4505a638668da32eb1570d33a47f9e","executable":"er1-sha256:d5ca34001cf1756c13de1dad656a4c147e3a9cc9fe06b45edce43d10dd57c899"} diff --git a/src/loops/dsl/compile.test.mjs b/src/loops/dsl/compile.test.mjs index fabfca88..deced2a8 100644 --- a/src/loops/dsl/compile.test.mjs +++ b/src/loops/dsl/compile.test.mjs @@ -188,7 +188,7 @@ test("closed grammar rejects named later constructs", async () => { const result = compileLoopFiles({ ...files, "review.loop": Buffer.from(source.replace('', `<${name} id="later"/>`)) }); assert.equal(result.ok, false, name); assert.ok(result.diagnostics.some((item) => item.code === "E_ELEMENT_UNKNOWN")); } - const altered = Buffer.from(source.replace('', '')); + const altered = Buffer.from(source.replace('', '')); const result = compileLoopFiles({ ...files, "review.loop": altered }); assert.equal(result.ok, false); assert.ok(result.diagnostics.some((item) => item.code === "E_ATTRIBUTE_REQUIRED")); }); @@ -197,13 +197,13 @@ test("every Stage 1 element required attribute and scalar union is closed", asyn const files = await reviewFiles(), source = files["review.loop"].toString(); const required = [ 'id="start"', 'version="0.1.0"', 'entry="start"', 'max-rounds="12"', 'max-minutes="60"', 'max-agent-runs="18"', 'max-check-runs="8"', 'max-transitions="40"', 'max-output-bytes="262144"', - 'id="implement"', 'mode="task"', 'execution="managed"', 'intelligence="standard"', 'role="maker"', 'route="implementation.standard"', 'authority="write"', 'instructions="implement"', 'id="decompose"', 'id="integrate"', 'id="review"', 'mode="review"', 'role="reviewer"', 'route="review.strong"', 'authority="read"', 'independent-from="implement"', 'requires="fresh-session:enforced,filesystem-write-deny:supervised"', 'id="final-review"', 'independent-from="implement"', 'id="validate"', 'id="final-validate"', 'capability="repo-verify"', 'id="converged"', 'kind="convergence"', 'state="converged"', 'error="failed"', 'timeout="failed"', 'cancelled="stopped"', 'lost="needs-human"', 'exhausted="exhausted"', 'from="start"', 'on="complete"', 'to="decompose"', + 'id="implement"', 'mode="task"', 'execution="host"', 'intelligence="standard"', 'role="maker"', 'route="implementation.standard"', 'authority="write"', 'instructions="implement"', 'id="decompose"', 'id="integrate"', 'id="review"', 'mode="review"', 'role="reviewer"', 'route="review.strong"', 'authority="read"', 'independent-from="implement"', 'requires="fresh-session:enforced,filesystem-write-deny:supervised"', 'id="final-review"', 'independent-from="implement"', 'id="validate"', 'id="final-validate"', 'capability="repo-verify"', 'id="converged"', 'kind="convergence"', 'state="converged"', 'error="failed"', 'timeout="failed"', 'cancelled="stopped"', 'lost="needs-human"', 'exhausted="exhausted"', 'from="start"', 'on="complete"', 'to="decompose"', ]; for (const token of required) { const result = compileLoopFiles({ ...files, "review.loop": Buffer.from(source.replace(token, "")) }); assert.equal(result.ok, false, token); assert.ok(result.diagnostics.some((item) => item.code === "E_ATTRIBUTE_REQUIRED"), token); } - for (const [token, replacement] of [['mode="task"', 'mode="stage-two"'], ['execution="managed"', 'execution="remote"'], ['intelligence="standard"', 'intelligence="provider-name"'], ['role="maker"', 'role="planner"'], ['authority="write"', 'authority="admin"'], ['route="implementation.standard"', 'route="invalid..route"'], ['kind="convergence"', 'kind="metric"'], ['state="converged"', 'state="done"'], ['max-visits="3"', 'max-visits="0"']]) { + for (const [token, replacement] of [['mode="task"', 'mode="stage-two"'], ['execution="host"', 'execution="remote"'], ['intelligence="standard"', 'intelligence="provider-name"'], ['role="maker"', 'role="planner"'], ['authority="write"', 'authority="admin"'], ['route="implementation.standard"', 'route="invalid..route"'], ['kind="convergence"', 'kind="metric"'], ['state="converged"', 'state="done"'], ['max-visits="3"', 'max-visits="0"']]) { const result = compileLoopFiles({ ...files, "review.loop": Buffer.from(source.replace(token, replacement)) }); assert.equal(result.ok, false, replacement); } diff --git a/src/loops/dsl/grammar.mjs b/src/loops/dsl/grammar.mjs index c10ed057..0b01fe6d 100644 --- a/src/loops/dsl/grammar.mjs +++ b/src/loops/dsl/grammar.mjs @@ -61,7 +61,7 @@ export function validateLoop(ast) { if (node.name === "budget") for (const [key, [min, max]] of Object.entries(limits)) value(d, node, key, (v) => positive.test(v) && +v >= min && +v <= max, `an integer from ${min} through ${max}`); if (node.name === "agent") { value(d, node, "mode", (v) => v === "task" || v === "review", "task or review"); - value(d, node, "execution", (v) => v === "managed" || v === "host", "managed or host"); + value(d, node, "execution", (v) => v === "host", "host"); value(d, node, "intelligence", (v) => ["fast", "standard", "strong", "critical"].includes(v), "fast, standard, strong, or critical"); value(d, node, "role", (v) => v === "maker" || v === "reviewer", "maker or reviewer"); value(d, node, "route", (v) => route.test(v), "a Route"); value(d, node, "authority", (v) => v === "read" || v === "write", "read or write"); value(d, node, "instructions", (v) => slug.test(v), "a lowercase slug"); diff --git a/src/loops/dsl/ir-validate.mjs b/src/loops/dsl/ir-validate.mjs index 22ea18fa..1e2643d3 100644 --- a/src/loops/dsl/ir-validate.mjs +++ b/src/loops/dsl/ir-validate.mjs @@ -20,7 +20,7 @@ function same(left, right) { return JSON.stringify(left) === JSON.stringify(righ function validNode(node) { if (!exact(node, nodeKeys[node?.kind] ?? []) || !boundedSlug(node.id)) return false; if (node.kind === "agent") { - if (!["task", "review"].includes(node.mode) || !["managed", "host"].includes(node.execution) + if (!["task", "review"].includes(node.mode) || node.execution !== "host" || !["fast", "standard", "strong", "critical"].includes(node.intelligence) || !route.test(node.route) || !boundedSlug(node.instructions) || !Array.isArray(node.requires) || node.requires.some((item) => typeof item !== "string" || item.length > 128)) return false; @@ -117,7 +117,7 @@ export function validateReplayIr(ir) { const upgraded = { ...ir, nodes: ir.nodes.map((node) => node?.kind === "agent" - ? { ...node, execution: "managed", intelligence: node.mode === "review" ? "strong" : "standard" } + ? { ...node, execution: "host", intelligence: node.mode === "review" ? "strong" : "standard" } : node), }; return validateClosedIr(upgraded); diff --git a/src/loops/minimal-review-e2e.test.mjs b/src/loops/minimal-review-e2e.test.mjs index 817b5ddb..ca8231c5 100644 --- a/src/loops/minimal-review-e2e.test.mjs +++ b/src/loops/minimal-review-e2e.test.mjs @@ -11,7 +11,6 @@ import { build } from "esbuild"; import { createProductionRunAuthority, fixtureItemRef } from "./run/run-test-fixtures.mjs"; import { readLatestRunForItem } from "./run/read-projection.mjs"; import { runStore } from "./run/run-store.mjs"; -import { createStoredProductionRunRunner } from "./run/binder.mjs"; import { readOvenEvents } from "../events/oven-event-store.mjs"; import { cliJson, cliOk, request, startCli, waitForExit, waitForFile, withDashboard } from "./minimal-review-e2e-fixtures.mjs"; import { checklistFixture } from "../../dashboard/src/components/ChecklistDashboard/ChecklistDashboard.fixture.mjs"; @@ -56,12 +55,6 @@ function hostFixture(repo) { cliOk(repo, ["loop", "unassign", fixtureItemRef]); cliOk(repo, ["loop", "assign", fixtureItemRef, "loop:project:host-review"]); } -async function managedCheck(repo, runId, expected) { - const runner = createStoredProductionRunRunner({ repoRoot: repo, store: runStore(repo), runId }); - await runner.step(); await runner.step(); await runner.step(); - assert.equal(runner.replay().execution.nodeId, expected); - runner.pause(); -} async function dashboardRenderer(t) { const output = await mkdtemp(join(process.cwd(), ".m9-checklist-render-")); t.after(() => rm(output, { recursive: true, force: true })); @@ -113,150 +106,6 @@ async function dashboardRenderer(t) { }; } -test("M9 no-network CLI slice exposes interruption, repair, invalidation refetch, UI states, escalation, and completion", { timeout: 60_000 }, async (t) => { - const directory = mkdtempSync(join(tmpdir(), "burnlist-m9-e2e-")); - t.after(() => rmSync(directory, { recursive: true, force: true })); - const { repo } = createProductionRunAuthority(join(directory, "repo")); - const planPath = join(repo, "notes", "burnlists", "inprogress", "260722-001", "burnlist.md"); - addUnassignedItem(planPath, "DIRECT-01 | Unassigned direct-flow control"); - const render = await dashboardRenderer(t); - - await withDashboard(repo, async (baseUrl) => { - const absent = await liveProjection(baseUrl, planPath); - assert.equal(absent.status, 200); assert.equal(JSON.parse(absent.body).loopRun, null); - const view = cliOk(repo, ["loop", "view", fixtureItemRef]); - assert.match(view, /^MODE: ITEM-PINNED$/mu); assert.match(view, /decompose.*implement.*validate.*review/su); - - const escalation = cliJson(repo, ["loop", "create", fixtureItemRef]).runId, escalationCounter = join(directory, "escalation-counter"); - writeFileSync(escalationCounter, "0"); - const escalated = cliJson(repo, ["loop", "run", escalation], { - BURNLIST_FAKE_COUNTER: escalationCounter, BURNLIST_FAKE_OUTCOMES: "complete,complete,complete,escalate", - }); - assert.equal(escalated.state, "needs-human"); - const escalationInspection = cliJson(repo, ["loop", "inspect", escalation]); - assert.deepEqual(edges(escalationInspection).slice(-1), [{ from: "review", outcome: "escalate", to: "needs-human" }]); - const escalationHttp = await liveProjection(baseUrl, planPath); - assert.equal(escalationHttp.status, 200); const escalationProjection = JSON.parse(escalationHttp.body).loopRun; - assert.deepEqual(escalationProjection, escalationInspection); - const needsHumanUi = render("needs-human", escalationProjection); - assert.match(needsHumanUi.dom, /
    /u); - assert.match(needsHumanUi.dom, /
  • ; } -function CompletedDetail({ item }: { item: CompletedItem }) { +function CompletedDetail({ data, item }: { data: ChecklistProgressData; item: CompletedItem }) { const fields = checklistEventDetailFields(item.detail).filter((field) => field.label !== "Completed" && field.values.length); + const run = data.loopRun?.itemRef.endsWith(`#${item.id}`) ? data.loopRun : null; + const topology = run ? itemTopologyProjection(run) : null; return

    {item.id} · {item.title}

    @@ -145,6 +147,16 @@ function CompletedDetail({ item }: { item: CompletedItem }) {
    Completed
    {fields.map((field) =>
    {field.label === "Detail" ? "Outcome" : field.label}
    {field.values.join(" · ")}
    )}
    + {run && topology ?
    +
    Loop{run.loopId}
    + + node.kind === "terminal" && node.terminalState === "converged") + .map((node) => [node.id, "B"])), + }} title={`Loop symbols for ${item.id}`} /> +
    : null}
    ; } @@ -154,7 +166,7 @@ function DetailColumn({ data, selected }: { data: ChecklistProgressData; selecte
    Item detail{status}
    {!selected ?

    No items

    : selected.status === "active" ? - : } + : }
    ; } diff --git a/scripts/package-paths.json b/scripts/package-paths.json index 65ca90a8..c8dd5a9e 100644 --- a/scripts/package-paths.json +++ b/scripts/package-paths.json @@ -2,7 +2,7 @@ "LICENSE", "README.md", "bin/burnlist.mjs", - "dashboard/dist/assets/index-BrGe2UeD.js", + "dashboard/dist/assets/index-DQ8BmSNA.js", "dashboard/dist/assets/index-BmWMxaDn.css", "dashboard/dist/favicon.svg", "dashboard/dist/index.html", diff --git a/src/loops/completion/completion.test.mjs b/src/loops/completion/completion.test.mjs index 16e29f78..abaa19da 100644 --- a/src/loops/completion/completion.test.mjs +++ b/src/loops/completion/completion.test.mjs @@ -10,6 +10,7 @@ import { createLoopController } from "../run/controller.mjs"; import { validateHostExecutionEnvelope } from "../contracts/host-execution.mjs"; import { createProductionRunAuthority, fixtureItemRef, fixtureRunId } from "../run/run-test-fixtures.mjs"; import { runStore } from "../run/run-store.mjs"; +import { readCompletedRunForItem } from "../run/read-projection.mjs"; import { completeLoopRun } from "./completion.mjs"; function context(t) { @@ -54,6 +55,14 @@ test("completion burns one exact converged assignment, writes one receipt, and r assert.equal(readFileSync(value.planPath, "utf8").includes("- [ ] L29"), false); assert.equal(completedLines(value).length, 1); assert.equal(existsSync(runPath(store, fixtureRunId, "completion-receipt.json")), true); + const completedRun = readCompletedRunForItem({ + repoRoot: value.repo, + itemRef: fixtureItemRef, + completedAt: first.completedAt, + title: "Exercise production authority", + }); + assert.equal(completedRun?.runId, fixtureRunId); + assert.equal(completedRun?.state, "converged"); writeFileSync(value.planPath, `${readFileSync(value.planPath, "utf8").trimEnd()}\n\nUnrelated lifecycle note.\n`); const second = completeLoopRun({ repoRoot: value.repo, runId: fixtureRunId, store }); assert.equal(second.alreadyApplied, true); assert.equal(completedLines(value).length, 1); diff --git a/src/loops/run/read-projection.mjs b/src/loops/run/read-projection.mjs index 2d4a7795..ec339dd9 100644 --- a/src/loops/run/read-projection.mjs +++ b/src/loops/run/read-projection.mjs @@ -1,4 +1,4 @@ -import { existsSync, readdirSync } from "node:fs"; +import { existsSync, lstatSync, readFileSync, readdirSync } from "node:fs"; import { join, resolve } from "node:path"; import { readJournal } from "./run-journal.mjs"; import { foldRun } from "./run-fold.mjs"; @@ -10,6 +10,7 @@ import { runStore } from "./run-store.mjs"; import { projectRunActivity } from "../events/activity-projection.mjs"; const MAX_RUNS = 128; +const RECEIPT_KEYS = ["schema", "runId", "itemRef", "assignmentId", "completedAt", "title", "planDigest"]; const fail = (message) => { throw Object.assign(new Error(`Run projection: ${message}`), { code: "ERUN_PROJECTION" }); }; function publicNode(node, routes = []) { @@ -150,3 +151,34 @@ export function readLatestRunForItem({ repoRoot, itemRef, markdown = null, itemI selected.agentRoutes = stored.agentRoutes; return presentRun(selected); } + +/** Resolve a completed ledger item through its retained CLI completion receipt. */ +export function readCompletedRunForItem({ repoRoot, itemRef, completedAt, title }) { + const root = resolve(repoRoot); + const base = join(root, ".local", "burnlist", "loop", "m2"); + if (!existsSync(base)) return null; + const current = currentRunAuthority({ root, base, random: () => Buffer.alloc(8) }).read() + .find((entry) => entry.itemRef === itemRef) ?? null; + if (!current) return null; + const receiptPath = join(base, "runs", Buffer.from(current.runId).toString("hex"), "completion-receipt.json"); + let bytes; + try { + const entry = lstatSync(receiptPath); + if (!entry.isFile() || entry.isSymbolicLink() || entry.size < 2 || entry.size > 8192) fail("completion receipt is corrupt"); + bytes = readFileSync(receiptPath); + } catch (error) { + if (error?.code === "ENOENT") return null; + if (error?.code === "ERUN_PROJECTION") throw error; + fail("completion receipt is corrupt"); + } + let receipt; + try { receipt = JSON.parse(bytes); } catch { fail("completion receipt is corrupt"); } + if (!receipt || Object.keys(receipt).length !== RECEIPT_KEYS.length + || !RECEIPT_KEYS.every((key, index) => Object.keys(receipt)[index] === key) + || receipt.schema !== "burnlist-loop-completion@1" + || receipt.runId !== current.runId || receipt.itemRef !== itemRef + || receipt.assignmentId !== current.assignmentId + || receipt.completedAt !== completedAt || receipt.title !== title + || !Buffer.from(`${JSON.stringify(receipt)}\n`).equals(bytes)) fail("completion receipt does not match the completed item"); + return readLatestRunForItem({ repoRoot: root, itemRef }); +} diff --git a/src/loops/run/run-test-fixtures.mjs b/src/loops/run/run-test-fixtures.mjs index 2e8f8d49..19a6aae4 100644 --- a/src/loops/run/run-test-fixtures.mjs +++ b/src/loops/run/run-test-fixtures.mjs @@ -58,7 +58,7 @@ export async function runM4ProgressFixture({ const baseClock = clock ?? (() => at++); const store = runStore(repoRoot, { clock: baseClock }); store.createRun({ runId, itemRef, graph: frozenGraph }); - const runner = createRunRunner({ store, runId, invoke: async ({ nodeId }) => { + const invoke = async ({ nodeId }) => { const outcome = source.shift(); if (!outcome) throw new Error(`run fixture: missing outcome for ${runId}`); const node = frozenGraph.nodes.find((item) => item.id === nodeId); @@ -66,7 +66,18 @@ export async function runM4ProgressFixture({ : node?.kind === "check" ? ["pass", "fail"] : []; if (!permitted.includes(outcome)) throw new Error(`run fixture: ${outcome} is not valid for ${nodeId}`); return { kind: outcome, summary: outcome, outputBytes: 1 }; - }}); + }; + const runner = createRunRunner({ store, runId, invoke, executePreparedAgent: async ({ lease, node }) => { + const current = store.replay(runId), attempt = current.execution.attempt + 1; + store.append(runId, lease, "node-started", { nodeId: node.id, attempt }); + const invocationId = `${String(attempt).padStart(2, "0")}${"0".repeat(30)}`; + store.append(runId, lease, "invocation-started", { nodeId: node.id, attempt, invocationId }); + const result = await invoke({ nodeId: node.id }); + store.append(runId, lease, "invocation-result", { + invocationId, ...result, candidateId: store.replay(runId).execution.candidate?.id ?? null, + }); + return { released: false }; + } }); const snapshots = []; let previous = null; const capture = () => { diff --git a/src/loops/run/runner.mjs b/src/loops/run/runner.mjs index 33ee29a2..21b8a63e 100644 --- a/src/loops/run/runner.mjs +++ b/src/loops/run/runner.mjs @@ -33,7 +33,7 @@ export function createRunRunner({ store, runId, invoke, bindCandidate = null, ex if (completed?.released) lease = null; return { kind: "prepared-agent" }; } - if (node.kind === "agent") return { kind: "awaiting-host" }; + if (node.kind === "agent" && node.execution === "host") return { kind: "awaiting-host" }; return append("node-started", { nodeId: node.id, attempt: execution.attempt + 1 }); } if (node.kind === "terminal") return transition(node.state, "graph"); diff --git a/src/server/burnlist-dashboard-server.mjs b/src/server/burnlist-dashboard-server.mjs index 2811d462..0a456283 100644 --- a/src/server/burnlist-dashboard-server.mjs +++ b/src/server/burnlist-dashboard-server.mjs @@ -36,7 +36,7 @@ import { starterOvenSource } from "../ovens/oven-starter.mjs"; import "../ovens/built-in-handlers.mjs"; import { getOvenHandler, listOvenHandlers } from "../ovens/oven-registry.mjs"; import { genericJsonHandler } from "../ovens/handlers/generic-json-handler.mjs"; -import { presentGraph, readLatestRunForItem } from "../loops/run/read-projection.mjs"; +import { presentGraph, readCompletedRunForItem, readLatestRunForItem } from "../loops/run/read-projection.mjs"; import { assignmentStore } from "../loops/assignment/store.mjs"; import { buildRepoMapAsync } from "./repo-map.mjs"; import { createOvenJsonSnapshotStore, OVEN_JSON_CACHE_MAX_BYTES } from "./oven-json-snapshot.mjs"; @@ -1081,18 +1081,32 @@ function payloadForPlan(selection, selectedItemId = null) { function loopProjectionForPlan(selection, requestedItemId = null) { const plan = parsePlan(selection.planPath, maxPlanBytes); - const currentItem = requestedItemId + const activeItem = requestedItemId ? plan.items.find((item) => item.id === requestedItemId) : plan.items.find((item) => loopAssignmentForItem(plan.markdown, item.id)); - if (requestedItemId && !currentItem) throw Object.assign(new Error("Loop item is not active in the selected Burnlist"), { code: "EITEM" }); - const assignment = currentItem ? loopAssignmentForItem(plan.markdown, currentItem.id) : null; - if (!currentItem || !assignment) return null; - return readLatestRunForItem({ + if (activeItem) { + const assignment = loopAssignmentForItem(plan.markdown, activeItem.id); + if (!assignment) return null; + return readLatestRunForItem({ + repoRoot: selection.repoRoot, + itemRef: `item:${burnlistIdForPlan(selection.planPath)}#${activeItem.id}`, + markdown: plan.markdown, + itemId: activeItem.id, + assignmentId: assignment["Assignment-Id"], + }); + } + const completedItem = requestedItemId + ? plan.completed.find((item) => item.id === requestedItemId) + : plan.completed.at(-1); + if (!completedItem) { + if (requestedItemId) throw Object.assign(new Error("Loop item is not present in the selected Burnlist"), { code: "EITEM" }); + return null; + } + return readCompletedRunForItem({ repoRoot: selection.repoRoot, - itemRef: `item:${burnlistIdForPlan(selection.planPath)}#${currentItem.id}`, - markdown: plan.markdown, - itemId: currentItem.id, - assignmentId: assignment["Assignment-Id"], + itemRef: `item:${burnlistIdForPlan(selection.planPath)}#${completedItem.id}`, + completedAt: completedItem.completedAt, + title: completedItem.title, }); } diff --git a/src/server/run-routes.test.mjs b/src/server/run-routes.test.mjs index c56aab91..aa23ebf8 100644 --- a/src/server/run-routes.test.mjs +++ b/src/server/run-routes.test.mjs @@ -58,7 +58,7 @@ test("selected progress remains independent from the sanitized read-only Loop pr assert.equal(JSON.parse(scoped.body).loopRun.itemRef, fixtureItemRef); const unknown = await httpRequest(baseUrl, `/api/loop-projection?plan=${encodeURIComponent(planPath)}&item=NOT-ACTIVE`, { method: "GET" }); assert.equal(unknown.status, 404); - assert.match(JSON.parse(unknown.body).error, /not active/u); + assert.match(JSON.parse(unknown.body).error, /not present/u); assert.deepEqual(left.latestResult, { kind: "approve", summary: "approve" }); for (const result of [left.latestMaker, left.latestCheck, left.latestReviewer]) { assert.equal(typeof result?.summary, "string"); From adbb58d5299d6698217a95fc9dc4d56fc7507a35 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sat, 25 Jul 2026 17:32:09 +0200 Subject: [PATCH 15/37] feat: streamline host loop execution --- bin/burnlist.mjs | 2 +- skills/burnlist/SKILL.md | 8 ++++++- skills/burnlist/references/host-execution.md | 24 ++++++++++++++++--- src/cli/commands-help.test.mjs | 4 ++-- src/cli/loop-cli.mjs | 25 ++++++++++++++++---- src/cli/loop-config-cli.mjs | 2 +- src/cli/loop-runtime-cli.test.mjs | 14 +++++++++++ src/loops/config/setup.mjs | 15 +++++++++++- 8 files changed, 81 insertions(+), 13 deletions(-) diff --git a/bin/burnlist.mjs b/bin/burnlist.mjs index 753b542a..a9872125 100755 --- a/bin/burnlist.mjs +++ b/bin/burnlist.mjs @@ -113,7 +113,7 @@ Usage: burnlist loop view [--repo ] burnlist loop create [--repo ] burnlist loop next|claim [--repo ] - burnlist loop report --result [--repo ] + burnlist loop report (--outcome | --result ) [--repo ] burnlist loop abandon --reason [--repo ] burnlist loop list [--repo ] burnlist loop status|inspect [--repo ] diff --git a/skills/burnlist/SKILL.md b/skills/burnlist/SKILL.md index 24491579..eee3e59e 100644 --- a/skills/burnlist/SKILL.md +++ b/skills/burnlist/SKILL.md @@ -199,7 +199,13 @@ Run `burnlist loop setup status` before `loop create`. Paste the complete `burnlist loop view item:#` ASCII output into the task handoff or review request: it records the frozen graph, retries, completion path, and pins. For each agent node use `loop claim`, invoke the selected -provider through the host, and submit `loop report`. Reporting automatically +provider through the host, and submit `loop report`. The successful fast path +is `loop report --outcome complete` for a task or `--outcome approve` +for an independent review; detailed findings and telemetry use `--result`. +Claims are provider-neutral: if a provider hits quota before changing the +workspace and its process has exited, retry the same claim with another ready +provider. Do not write provider wrappers into the candidate repository. +Reporting automatically advances Burnlist-owned checks and gates to the next agent or terminal node. Inspect with `loop status|inspect`, control idle Runs with `loop pause|stop`, use proof-gated `loop reconcile` only for a demonstrably lost host claim, and diff --git a/skills/burnlist/references/host-execution.md b/skills/burnlist/references/host-execution.md index c97df94b..ce4136d6 100644 --- a/skills/burnlist/references/host-execution.md +++ b/skills/burnlist/references/host-execution.md @@ -40,7 +40,21 @@ invocation. If subscriptions are unknown, read `loop-provider-setup.md` first. a graph edge, declare a destination, or run a deterministic check; Burnlist owns all three. -3. Produce one canonical `burnlist-loop-host-report@1` whose `agent-result@1` +3. For the common successful path, inspect the provider result and report the + legal outcome directly. Burnlist copies the sealed identity tuple from the + live claim, so the host does not hand-author authority-bearing JSON: + + ```sh + burnlist loop report cl1-sha256: --outcome complete + burnlist loop report cl1-sha256: --outcome approve + ``` + + Use `complete` only after successful task execution and `approve` only after + an independent read-only review. The command still fails closed on a stale + claim, wrong node mode, or candidate drift. + +4. Findings, rejection, escalation, or telemetry require one canonical + `burnlist-loop-host-report@1` whose `agent-result@1` is bound to that same tuple, with only a legal node-mode outcome. Copy the identity values exactly from `execution`; do not use these placeholders as invented values. A task accepts `complete`; review accepts `approve`, @@ -66,7 +80,7 @@ invocation. If subscriptions are unknown, read `loop-provider-setup.md` first. Optional telemetry always uses `burnlist-loop-host-telemetry@1` with `provenance: "host-reported"`; unknown values remain `null`, never guessed. -4. Write the report to a regular, non-symlink file no larger than 256 KiB and +5. Write that detailed report to a regular, non-symlink file no larger than 256 KiB and submit it by the claim id: ```sh @@ -77,7 +91,11 @@ invocation. If subscriptions are unknown, read `loop-provider-setup.md` first. claim, workspace/candidate drift, illegal outcome, or identity mismatch fails closed. Inspect the Run again rather than editing a rejected report. -5. If the host cannot finish, do not report a made-up result. Resolve its live +6. If one provider is unavailable before it mutates the workspace, keep the + provider-neutral claim and retry through another ready provider after the + first process has definitely exited. Do not abandon merely because a quota + was exhausted. If the host cannot finish or process cleanup is uncertain, + do not report a made-up result. Resolve its live claim once: ```sh diff --git a/src/cli/commands-help.test.mjs b/src/cli/commands-help.test.mjs index ab6bda9f..64d4f14b 100644 --- a/src/cli/commands-help.test.mjs +++ b/src/cli/commands-help.test.mjs @@ -48,7 +48,7 @@ test("top-level and Oven help expose the validated use and set flow", () => { assert.match(top.stdout, /burnlist loop view \[--repo \]/u); assert.match(top.stdout, /burnlist loop create \[--repo \]/u); assert.match(top.stdout, /burnlist loop next\|claim \[--repo \]/u); - assert.match(top.stdout, /burnlist loop report --result \[--repo \]/u); + assert.match(top.stdout, /burnlist loop report \(--outcome \| --result \) \[--repo \]/u); assert.match(top.stdout, /burnlist loop abandon --reason \[--repo \]/u); assert.match(top.stdout, /burnlist loop list \[--repo \]/u); assert.doesNotMatch(top.stdout, /burnlist loop run\|resume/u); @@ -90,7 +90,7 @@ test("nested Loop help snapshots every Stage 1 control", () => { try { const result = run(context.directory, ["loop", "--help"]); assert.equal(result.status, 0, result.stderr); - for (const command of ["create", "next|claim", "report --result ", "abandon --reason ", "list", "pause|stop|complete", "status|inspect", "reconcile"]) { + for (const command of ["create", "next|claim", "report \\(--outcome \\| --result \\)", "abandon --reason ", "list", "pause|stop|complete", "status|inspect", "reconcile"]) { assert.match(result.stdout, new RegExp(`burnlist loop ${command}`, "u")); } assert.doesNotMatch(result.stdout, /loop run|resume/u); diff --git a/src/cli/loop-cli.mjs b/src/cli/loop-cli.mjs index f0843d1a..de0c490d 100644 --- a/src/cli/loop-cli.mjs +++ b/src/cli/loop-cli.mjs @@ -9,11 +9,12 @@ import { runStore } from "../loops/run/run-store.mjs"; import { createLoopController } from "../loops/run/controller.mjs"; import { createProductionRun, createStoredSystemRunRunner } from "../loops/run/binder.mjs"; import { completeLoopRun } from "../loops/completion/completion.mjs"; +import { validateHostExecutionEnvelope } from "../loops/contracts/host-execution.mjs"; function usageText() { return loopConfigUsage(); } function usageError(message = usageText()) { return Object.assign(new Error(message), { exitCode: 2 }); } function options(tokens) { - const positionals = []; let repo = null, recoveryProof = null, resultFile = null, reason = null; + const positionals = []; let repo = null, recoveryProof = null, resultFile = null, reason = null, outcome = null; for (let index = 0; index < tokens.length; index += 1) { if (tokens[index] === "--repo") { if (repo !== null) throw usageError("--repo must be specified at most once."); @@ -32,16 +33,30 @@ function options(tokens) { if (reason !== null) throw usageError("--reason must be specified at most once."); reason = tokens[++index]; if (!reason || reason.startsWith("--")) throw usageError("--reason requires host-cancelled, host-lost, or expired."); } + else if (tokens[index] === "--outcome") { + if (outcome !== null) throw usageError("--outcome must be specified at most once."); + outcome = tokens[++index]; + if (!["complete", "approve"].includes(outcome ?? "")) throw usageError("--outcome requires complete or approve."); + } else if (tokens[index].startsWith("--")) throw usageError(`Unknown option: ${tokens[index]}`); else positionals.push(tokens[index]); } - return { positionals, recoveryProof, resultFile, reason, repo: repo ? resolve(process.cwd(), repo) : resolveUmbrella(process.cwd()) }; + return { positionals, recoveryProof, resultFile, reason, outcome, repo: repo ? resolve(process.cwd(), repo) : resolveUmbrella(process.cwd()) }; } function validateVerbOptions(verb, opts) { if (opts.recoveryProof && verb !== "reconcile") throw usageError(); - if (opts.resultFile && verb !== "report" || verb === "report" && !opts.resultFile) throw usageError(); + if ((opts.resultFile || opts.outcome) && verb !== "report" + || verb === "report" && Boolean(opts.resultFile) === Boolean(opts.outcome)) throw usageError(); if (opts.reason && verb !== "abandon" || verb === "abandon" && !opts.reason) throw usageError(); } +function simpleReport(envelopeBytes, outcome) { + const execution = validateHostExecutionEnvelope(envelopeBytes).value; + const result = Object.fromEntries(["runId", "nodeId", "attempt", "claimId", "assignmentId", + "invocationId", "recipeRevision", "policyRevision", "inputCandidate"].map((key) => [key, execution[key]])); + return Buffer.from(`${JSON.stringify({ schema: "burnlist-loop-host-report@1", result: { + schema: "agent-result@1", ...result, outcome, findings: [], resolvedFindingIds: [], + }, telemetry: null })}\n`); +} function resultBytes(path) { let fd; try { @@ -98,7 +113,9 @@ export async function runLoopCli(tokens, { runReader, runnerFor, stdout = proces : verb === "inspect" ? controller.inspect(opts.positionals[0]) : verb === "next" ? controller.inspect(opts.positionals[0]) : verb === "claim" ? publicClaim(controller.claim(opts.positionals[0])) - : verb === "report" ? await controller.report(opts.positionals[0], resultBytes(opts.resultFile)) + : verb === "report" ? await controller.report(opts.positionals[0], + opts.resultFile ? resultBytes(opts.resultFile) : simpleReport( + controller.readClaim(store.resolveClaimRef(opts.positionals[0]))?.envelope, opts.outcome)) : verb === "abandon" ? (() => { const runId = store.resolveClaimRef(opts.positionals[0]); if (store.read(runId).execution.terminal) { const error = new Error("ClaimRef is stale"); error.exitCode = 1; throw error; } diff --git a/src/cli/loop-config-cli.mjs b/src/cli/loop-config-cli.mjs index 6ce5e8e1..9dc2bec9 100644 --- a/src/cli/loop-config-cli.mjs +++ b/src/cli/loop-config-cli.mjs @@ -6,7 +6,7 @@ import { renderSetupStatus, setupStatus } from "../loops/config/setup.mjs"; import { rawSha256 } from "../loops/dsl/hash.mjs"; import { resolveUmbrella } from "./umbrella.mjs"; -const loopUsage = "Usage: burnlist loop assign [--repo ] | burnlist loop unassign [--repo ] | burnlist loop view [--repo ]\n burnlist loop create [--repo ]\n burnlist loop next|claim [--repo ]\n burnlist loop report --result [--repo ]\n burnlist loop abandon --reason [--repo ]\n burnlist loop pause|stop|complete [--repo ]\n burnlist loop list [--repo ] | burnlist loop status|inspect [--repo ]\n burnlist loop reconcile --recovery-proof [--repo ]\n burnlist loop capability inspect [--repo ]\n burnlist loop capability trust --revision cp1-sha256: --grants [--repo ]\n burnlist loop setup status [--repo ]"; +const loopUsage = "Usage: burnlist loop assign [--repo ] | burnlist loop unassign [--repo ] | burnlist loop view [--repo ]\n burnlist loop create [--repo ]\n burnlist loop next|claim [--repo ]\n burnlist loop report (--outcome | --result ) [--repo ]\n burnlist loop abandon --reason [--repo ]\n burnlist loop pause|stop|complete [--repo ]\n burnlist loop list [--repo ] | burnlist loop status|inspect [--repo ]\n burnlist loop reconcile --recovery-proof [--repo ]\n burnlist loop capability inspect [--repo ]\n burnlist loop capability trust --revision cp1-sha256: --grants [--repo ]\n burnlist loop setup status [--repo ]"; function fail(message, exitCode = 2) { throw Object.assign(new Error(message), { exitCode }); } function parse(tokens, allowed = []) { diff --git a/src/cli/loop-runtime-cli.test.mjs b/src/cli/loop-runtime-cli.test.mjs index ae109d3d..ea7448a1 100644 --- a/src/cli/loop-runtime-cli.test.mjs +++ b/src/cli/loop-runtime-cli.test.mjs @@ -73,6 +73,20 @@ test("claim and report advance checks and gates without launching a provider", ( assert.equal(JSON.parse(command(repo, ["complete", runId])).alreadyApplied, true); }); +test("simple successful outcomes do not require a hand-authored report", (t) => { + const directory = mkdtempSync(join(tmpdir(), "host-simple-report-cli-")); + t.after(() => rmSync(directory, { recursive: true, force: true })); + const { repo } = createProductionRunAuthority(join(directory, "repo")); + const runId = created(repo); + for (let attempts = 0; attempts < 12; attempts += 1) { + if (JSON.parse(command(repo, ["status", runId])).state === "converged") break; + const execution = JSON.parse(command(repo, ["claim", runId])).execution; + command(repo, ["report", execution.claimId, "--outcome", + ["review", "final-review"].includes(execution.nodeId) ? "approve" : "complete"]); + } + assert.equal(JSON.parse(command(repo, ["status", runId])).state, "converged"); +}); + test("a claimed provider cannot be stolen and reviewer candidate drift rejects its report", (t) => { const directory = mkdtempSync(join(tmpdir(), "host-claim-loop-cli-")); t.after(() => rmSync(directory, { recursive: true, force: true })); diff --git a/src/loops/config/setup.mjs b/src/loops/config/setup.mjs index 91edfc5b..fd9994ec 100644 --- a/src/loops/config/setup.mjs +++ b/src/loops/config/setup.mjs @@ -1,5 +1,7 @@ import { assertTrustedCapability } from "../capabilities/trust.mjs"; import { readCapabilityCatalog, resolveCapability } from "../capabilities/contract.mjs"; +import { lstatSync } from "node:fs"; +import { resolve } from "node:path"; function record(kind, id, detail, remedy) { return { kind, id, detail, remedy }; } function clean(error) { return String(error?.message ?? error).replace(/^Loop (?:agent|local config|capability trust|capability): /u, ""); } @@ -11,7 +13,18 @@ export function setupStatus({ repoRoot } = {}) { try { resolved = resolveCapability(readCapabilityCatalog(repoRoot), "repo-verify"); } catch (error) { failures.push(record("capability", "repo-verify", clean(error), "create .burnlist/loop-capabilities.json from the Review Loop capability example, then run burnlist loop capability inspect repo-verify")); } if (resolved) { - try { assertTrustedCapability({ repoRoot, resolved }); } + try { + const trusted = assertTrustedCapability({ repoRoot, resolved }); + const paths = new Set([trusted.grants.cwd, ...trusted.grants.filesystem.read, ...trusted.grants.filesystem.write]); + for (const path of paths) { + try { lstatSync(resolve(repoRoot, path)); } + catch (error) { + if (error?.code !== "ENOENT") throw error; + failures.push(record("path", path, "configured capability path does not exist", + "create the path or narrow the capability catalog and grants to existing paths, then trust the new revision")); + } + } + } catch (error) { failures.push(record("trust", "repo-verify", clean(error), `burnlist loop capability trust repo-verify --revision ${resolved.revision} --grants `)); } } return { ready: failures.length === 0, failures }; From 83d256934bdbce7fc484f3a2b2248723ba6007fe Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sat, 25 Jul 2026 17:39:12 +0200 Subject: [PATCH 16/37] fix: compact serial loop feedback rails --- .../components/LoopGraph/LoopGraph.test.ts | 3 ++- .../components/LoopGraph/compact-layout.ts | 24 ++++++++++--------- 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/dashboard/src/components/LoopGraph/LoopGraph.test.ts b/dashboard/src/components/LoopGraph/LoopGraph.test.ts index fa80e257..6152e3e7 100644 --- a/dashboard/src/components/LoopGraph/LoopGraph.test.ts +++ b/dashboard/src/components/LoopGraph/LoopGraph.test.ts @@ -131,5 +131,6 @@ test("compact topology keeps the serial dogfood Loop labelled within tablet widt assert.match(compact, /S[\s\S]*D[\s\S]*I[\s\S]*R[\s\S]*F[\s\S]*G[\s\S]*B/u); assert.match(compact, /reject/u); const drawing = compact.replace(/<[^>]+>/gu, ""); - assert.ok(Math.max(...drawing.split("\n").map((line) => line.length)) < 70); + assert.ok(Math.max(...drawing.split("\n").map((line) => line.length)) < 24); + assert.doesNotMatch(drawing, /┬─┬/u); }); diff --git a/dashboard/src/components/LoopGraph/compact-layout.ts b/dashboard/src/components/LoopGraph/compact-layout.ts index 98a9dc3b..a75c7a69 100644 --- a/dashboard/src/components/LoopGraph/compact-layout.ts +++ b/dashboard/src/components/LoopGraph/compact-layout.ts @@ -50,18 +50,15 @@ function serialCompact(run: LoopGraphProjection, path: string[], options: Compac const symbols = loopSymbols(run.graph.nodes, options.symbols); const loopbacks = alternate.filter((edge) => pathIds.has(edge.from) && pathIds.has(edge.to) && path.indexOf(edge.to) < path.indexOf(edge.from)); - const width = Math.max( - 28, - ...path.map((id) => symbols.get(id)!.length + 2), - ...run.graph.edges.map((edge) => edge.on.length + 5), - ); + const feedbackLabelWidth = Math.max(0, ...loopbacks.map((edge) => edge.on.length)); + const railX = Math.max(10, feedbackLabelWidth + 5); + const width = Math.max(14, railX + 2, ...path.map((id) => symbols.get(id)!.length + 2)); const graph = drawing(path.length * 2 + detours.length * 3, width); const positions = new Map(); path.forEach((id, index) => { const y = index * 2; positions.set(id, { x: 0, y }); - graph.text(0, y, symbols.get(id)!); if (index + 1 < path.length) { const edge = run.graph.edges.find((candidate) => candidate.from === id && candidate.to === path[index + 1])!; graph.put(0, y + 1, "▼"); @@ -90,15 +87,20 @@ function serialCompact(run: LoopGraphProjection, path: string[], options: Compac graph.put(2, to.y, "◀"); if (options.showLabels) graph.text(from.x + 2, from.y, edge.on); }); - loopbacks.forEach((edge, index) => { + if (loopbacks.length) { + const top = Math.min(...loopbacks.map((edge) => positions.get(edge.to)!.y)); + const bottom = Math.max(...loopbacks.map((edge) => positions.get(edge.from)!.y)); + graph.vertical(railX, top, bottom); + } + loopbacks.forEach((edge) => { const from = positions.get(edge.from)!, to = positions.get(edge.to)!; - const rail = width - 2 - index * 2; - graph.horizontal(2, rail, from.y); - graph.vertical(rail, to.y, from.y); - graph.horizontal(2, rail, to.y); + graph.horizontal(2, railX, from.y); + graph.horizontal(2, railX, to.y); graph.put(2, to.y, "◀"); if (options.showLabels) graph.text(4, from.y, edge.on); }); + for (const [id, position] of positions) + graph.text(position.x, position.y, symbols.get(id)!); return { lines: graph.cells.map((line) => line.join("").trimEnd()), positions, From cedb6294f5e4cb825f682b44d843d40f928bbe95 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sat, 25 Jul 2026 18:12:21 +0200 Subject: [PATCH 17/37] feat: add responsive layered loop layout --- .../src/components/LoopGraph/LoopCompact.tsx | 20 ++- .../components/LoopGraph/LoopGraph.test.ts | 12 +- .../components/LoopGraph/compact-layout.ts | 5 +- .../LoopGraph/serial-compact-layout.ts | 118 ++++++++++++++++++ skills/burnlist/SKILL.md | 4 + 5 files changed, 154 insertions(+), 5 deletions(-) create mode 100644 dashboard/src/components/LoopGraph/serial-compact-layout.ts diff --git a/dashboard/src/components/LoopGraph/LoopCompact.tsx b/dashboard/src/components/LoopGraph/LoopCompact.tsx index e693e614..ffdfe754 100644 --- a/dashboard/src/components/LoopGraph/LoopCompact.tsx +++ b/dashboard/src/components/LoopGraph/LoopCompact.tsx @@ -1,3 +1,4 @@ +import { useEffect, useRef, useState } from "react"; import type { LoopGraphProjection } from "./LoopGraph"; import { layoutCompactLoop } from "./compact-layout"; import "./LoopGraph.css"; @@ -39,6 +40,19 @@ export function itemTopologyProjection(run: LoopGraphProjection): LoopGraphProje export function LoopCompact({ run, title = "Compact Loop topology", labels = "hidden", symbols, variant = "topology", }: LoopCompactProps) { + const host = useRef(null); + const [characters, setCharacters] = useState(72); + useEffect(() => { + if (!host.current || typeof ResizeObserver === "undefined") return; + const style = getComputedStyle(host.current); + const context = document.createElement("canvas").getContext("2d"); + if (context) context.font = `${style.fontWeight} ${style.fontSize} ${style.fontFamily}`; + const characterWidth = Math.max(1, (context?.measureText("0000000000").width ?? 90) / 10); + const observer = new ResizeObserver(([entry]) => + setCharacters(Math.max(24, Math.floor(entry.contentRect.width / characterWidth) - 1))); + observer.observe(host.current); + return () => observer.disconnect(); + }, []); if (!run) return null; const topologyRun = variant === "topology" ? itemTopologyProjection(run) : null; const displayRun = variant === "burn-cycle" ? { @@ -69,13 +83,15 @@ export function LoopCompact({ ...Object.fromEntries(run.graph.nodes.filter((node) => node.kind === "terminal" && node.terminalState === "converged").map((node) => [node.id, "B"])), ...symbols, }; - const layout = layoutCompactLoop(displayRun, { showLabels: labels === "outcomes", symbols: displaySymbols }); + const layout = layoutCompactLoop(displayRun, { + availableCharacters: characters, showLabels: labels === "outcomes", symbols: displaySymbols, + }); const current = layout.positions.get(displayRun.currentNode); const drawing = layout.lines.join("\n"); const offset = current ? layout.lines.slice(0, current.y).reduce((total, line) => total + line.length + 1, 0) + current.x : -1; - return
    +  return 
         {offset >= 0
           ? <>{drawing.slice(0, offset)}{drawing[offset]}{drawing.slice(offset + 1)}
           : drawing}
    diff --git a/dashboard/src/components/LoopGraph/LoopGraph.test.ts b/dashboard/src/components/LoopGraph/LoopGraph.test.ts
    index 6152e3e7..f6d2c23f 100644
    --- a/dashboard/src/components/LoopGraph/LoopGraph.test.ts
    +++ b/dashboard/src/components/LoopGraph/LoopGraph.test.ts
    @@ -3,6 +3,7 @@ import test from "node:test";
     import { createElement } from "react";
     import { renderToStaticMarkup } from "react-dom/server";
     import { LoopCompact } from "./LoopCompact";
    +import { layoutCompactLoop } from "./compact-layout";
     import { LoopGraph, type LoopGraphProjection } from "./LoopGraph";
     import { LoopLegend } from "./LoopLegend";
     
    @@ -128,9 +129,16 @@ test("compact topology keeps the serial dogfood Loop labelled within tablet widt
         },
       });
       const compact = renderToStaticMarkup(createElement(LoopCompact, { run, labels: "outcomes" }));
    -  assert.match(compact, /S[\s\S]*D[\s\S]*I[\s\S]*R[\s\S]*F[\s\S]*G[\s\S]*B/u);
    +  for (const symbol of ["S", "D", "I1", "V", "R", "I2", "F1", "F2", "G", "B"])
    +    assert.match(compact, new RegExp(symbol, "u"));
       assert.match(compact, /reject/u);
       const drawing = compact.replace(/<[^>]+>/gu, "");
    -  assert.ok(Math.max(...drawing.split("\n").map((line) => line.length)) < 24);
    +  assert.ok(Math.max(...drawing.split("\n").map((line) => line.length)) <= 72);
    +  assert.ok(drawing.split("\n").length < 14);
       assert.doesNotMatch(drawing, /┬─┬/u);
    +  const wide = layoutCompactLoop(run, { availableCharacters: 100, showLabels: true });
    +  const narrow = layoutCompactLoop(run, { availableCharacters: 48, showLabels: true });
    +  assert.ok(Math.max(...wide.lines.map((line) => line.length)) <= 100);
    +  assert.ok(Math.max(...narrow.lines.map((line) => line.length)) <= 48);
    +  assert.ok(narrow.lines.length > wide.lines.length);
     });
    diff --git a/dashboard/src/components/LoopGraph/compact-layout.ts b/dashboard/src/components/LoopGraph/compact-layout.ts
    index a75c7a69..f12ecff9 100644
    --- a/dashboard/src/components/LoopGraph/compact-layout.ts
    +++ b/dashboard/src/components/LoopGraph/compact-layout.ts
    @@ -1,5 +1,6 @@
     import type { LoopGraphProjection } from "./LoopGraph";
     import { loopPrimaryPath, loopSymbols } from "./loop-symbols";
    +import { layoutSerialCompact } from "./serial-compact-layout";
     
     type Position = { x: number; y: number };
     
    @@ -36,9 +37,11 @@ function drawing(rows: number, columns: number) {
       return { cells, put, text, horizontal, vertical };
     }
     
    -type CompactOptions = { showLabels?: boolean; symbols?: Record };
    +type CompactOptions = { availableCharacters?: number; showLabels?: boolean; symbols?: Record };
     
     function serialCompact(run: LoopGraphProjection, path: string[], options: CompactOptions) {
    +  const layered = layoutSerialCompact(run, path, options);
    +  if (layered) return layered;
       if (path.length < 6) return null;
       const pathIds = new Set(path);
       const primary = new Set(path.slice(0, -1).map((from, index) => `${from}\0${path[index + 1]}`));
    diff --git a/dashboard/src/components/LoopGraph/serial-compact-layout.ts b/dashboard/src/components/LoopGraph/serial-compact-layout.ts
    new file mode 100644
    index 00000000..291b0fff
    --- /dev/null
    +++ b/dashboard/src/components/LoopGraph/serial-compact-layout.ts
    @@ -0,0 +1,118 @@
    +import type { LoopGraphProjection } from "./LoopGraph";
    +import { loopSymbols } from "./loop-symbols";
    +
    +type Position = { x: number; y: number };
    +type Options = { availableCharacters?: number; showLabels?: boolean; symbols?: Record };
    +
    +const glyphs: Record = {
    +  1: "│", 2: "─", 3: "└", 4: "│", 5: "│", 6: "┌", 7: "├",
    +  8: "─", 9: "┘", 10: "─", 11: "┴", 12: "┐", 13: "┤", 14: "┬", 15: "┼",
    +};
    +
    +function canvas(rows: number, columns: number) {
    +  const cells = Array.from({ length: rows }, () => Array(columns).fill(" "));
    +  const masks = Array.from({ length: rows }, () => Array(columns).fill(0));
    +  const put = (x: number, y: number, value: string) => {
    +    if (x < 0 || x >= columns || y < 0 || y >= rows) return;
    +    cells[y][x] = value; masks[y][x] = 0;
    +  };
    +  const connect = (x: number, y: number, mask: number) => {
    +    if (x < 0 || x >= columns || y < 0 || y >= rows) return;
    +    masks[y][x] |= mask; cells[y][x] = glyphs[masks[y][x]];
    +  };
    +  const horizontal = (from: number, to: number, y: number) => {
    +    const left = Math.min(from, to), right = Math.max(from, to);
    +    for (let x = left; x <= right; x += 1)
    +      connect(x, y, (x > left ? 8 : 0) | (x < right ? 2 : 0));
    +  };
    +  const vertical = (x: number, from: number, to: number) => {
    +    const top = Math.min(from, to), bottom = Math.max(from, to);
    +    for (let y = top; y <= bottom; y += 1)
    +      connect(x, y, (y > top ? 1 : 0) | (y < bottom ? 4 : 0));
    +  };
    +  const text = (x: number, y: number, value: string) =>
    +    [...value].forEach((character, index) => put(x + index, y, character));
    +  return { cells, put, horizontal, vertical, text };
    +}
    +
    +/**
    + * A small Sugiyama-style specialisation for the common workflow shape:
    + * rank the success spine, wrap it into balanced serpentine rows, then route
    + * backwards edges on destination-grouped orthogonal rails.
    + */
    +export function layoutSerialCompact(
    +  run: LoopGraphProjection, path: string[], options: Options,
    +) {
    +  if (path.length < 6) return null;
    +  const pathIndex = new Map(path.map((id, index) => [id, index]));
    +  const primary = path.slice(0, -1).map((from, index) =>
    +    run.graph.edges.find((edge) => edge.from === from && edge.to === path[index + 1])!);
    +  const primaryKeys = new Set(primary.map((edge) => `${edge.from}\0${edge.to}`));
    +  const feedback = run.graph.edges.filter((edge) =>
    +    !primaryKeys.has(`${edge.from}\0${edge.to}`)
    +    && pathIndex.has(edge.from) && pathIndex.has(edge.to)
    +    && pathIndex.get(edge.to)! < pathIndex.get(edge.from)!);
    +  const other = run.graph.edges.filter((edge) =>
    +    !primaryKeys.has(`${edge.from}\0${edge.to}`) && !feedback.includes(edge));
    +  if (other.some((edge) => pathIndex.has(edge.from) || pathIndex.has(edge.to))) return null;
    +
    +  const symbols = loopSymbols(run.graph.nodes, options.symbols);
    +  const labelWidth = options.showLabels
    +    ? Math.max(0, ...primary.map((edge) => edge.on.length)) : 0;
    +  const slot = Math.max(7, labelWidth + 5, ...path.map((id) => symbols.get(id)!.length + 5));
    +  const available = Math.max(24, options.availableCharacters ?? 72);
    +  const destinations = [...new Set(feedback.map((edge) => edge.to))];
    +  const leftMargin = destinations.length ? destinations.length * 2 + 2 : 0;
    +  const capacity = Math.max(2, Math.floor((available - leftMargin + slot) / slot));
    +  const rowCount = Math.ceil(path.length / capacity);
    +  const perRow = Math.ceil(path.length / rowCount);
    +  const rowHeight = 4 + Math.max(0, ...Array.from({ length: rowCount }, (_, row) =>
    +    feedback.filter((edge) => Math.floor(pathIndex.get(edge.from)! / perRow) === row).length));
    +  const positions = new Map();
    +  path.forEach((id, index) => {
    +    const row = Math.floor(index / perRow), offset = index % perRow;
    +    const count = Math.min(perRow, path.length - row * perRow);
    +    const column = row % 2 ? count - offset - 1 : offset;
    +    positions.set(id, { x: leftMargin + column * slot, y: row * rowHeight });
    +  });
    +  const width = Math.min(available, Math.max(...[...positions.values()].map((point) => point.x)) + slot);
    +  const graph = canvas(rowCount * rowHeight, width);
    +
    +  primary.forEach((edge) => {
    +    const from = positions.get(edge.from)!, to = positions.get(edge.to)!;
    +    if (from.y === to.y) {
    +      const right = from.x < to.x;
    +      graph.horizontal(from.x + (right ? 2 : -2), to.x + (right ? -2 : 2), from.y);
    +      graph.put(to.x + (right ? -2 : 2), to.y, right ? "▶" : "◀");
    +      if (options.showLabels)
    +        graph.text(Math.min(from.x, to.x) + 3, from.y, edge.on);
    +    } else {
    +      graph.vertical(from.x, from.y + 1, to.y - 1);
    +      graph.put(to.x, to.y - 1, "▼");
    +      if (options.showLabels) graph.text(from.x + 2, from.y + 1, edge.on);
    +    }
    +  });
    +
    +  const rails = new Map(destinations.map((id, index) => [id, index * 2]));
    +  const sourceOffsets = new Map();
    +  feedback.forEach((edge) => {
    +    const from = positions.get(edge.from)!, to = positions.get(edge.to)!;
    +    const row = Math.floor(pathIndex.get(edge.from)! / perRow);
    +    const offset = sourceOffsets.get(row) ?? 0;
    +    sourceOffsets.set(row, offset + 1);
    +    const sourceY = from.y + 2 + offset, railX = rails.get(edge.to)!;
    +    graph.vertical(from.x, from.y + 1, sourceY);
    +    graph.horizontal(railX, from.x, sourceY);
    +    graph.vertical(railX, to.y + 1, sourceY);
    +    graph.horizontal(railX, to.x, to.y + 1);
    +    graph.put(to.x, to.y + 1, "▲");
    +    if (options.showLabels) graph.text(Math.min(from.x, width - edge.on.length), sourceY, edge.on);
    +  });
    +
    +  for (const [id, position] of positions) graph.text(position.x, position.y, symbols.get(id)!);
    +  return {
    +    lines: graph.cells.map((line) => line.join("").trimEnd())
    +      .filter((line, index, lines) => line.length || lines.slice(index + 1).some(Boolean)),
    +    positions,
    +  };
    +}
    diff --git a/skills/burnlist/SKILL.md b/skills/burnlist/SKILL.md
    index eee3e59e..46854738 100644
    --- a/skills/burnlist/SKILL.md
    +++ b/skills/burnlist/SKILL.md
    @@ -207,6 +207,10 @@ workspace and its process has exited, retry the same claim with another ready
     provider. Do not write provider wrappers into the candidate repository.
     Reporting automatically
     advances Burnlist-owned checks and gates to the next agent or terminal node.
    +When the user wants to watch a Loop, assign every intended item first and
    +verify each with `loop view`; announce the dashboard as Loop-ready only after
    +the item projections expose those assignments. Repository registration alone
    +is not a Loop-visibility milestone.
     Inspect with `loop status|inspect`, control idle Runs with `loop pause|stop`,
     use proof-gated `loop reconcile` only for a demonstrably lost host claim, and
     apply a converged Run through idempotent `loop complete`.
    
    From 28ab6ca62cea2ab28d2d3cd46ed8c8f2bc56f4d7 Mon Sep 17 00:00:00 2001
    From: Juan Cruz Fortunatti 
    Date: Sat, 25 Jul 2026 18:20:25 +0200
    Subject: [PATCH 18/37] fix: balance loop layouts across levels
    
    ---
     .../components/LoopGraph/LoopGraph.test.ts    |  2 ++
     .../LoopGraph/serial-compact-layout.ts        | 21 +++++++++++++++++--
     2 files changed, 21 insertions(+), 2 deletions(-)
    
    diff --git a/dashboard/src/components/LoopGraph/LoopGraph.test.ts b/dashboard/src/components/LoopGraph/LoopGraph.test.ts
    index f6d2c23f..0388592f 100644
    --- a/dashboard/src/components/LoopGraph/LoopGraph.test.ts
    +++ b/dashboard/src/components/LoopGraph/LoopGraph.test.ts
    @@ -137,8 +137,10 @@ test("compact topology keeps the serial dogfood Loop labelled within tablet widt
       assert.ok(drawing.split("\n").length < 14);
       assert.doesNotMatch(drawing, /┬─┬/u);
       const wide = layoutCompactLoop(run, { availableCharacters: 100, showLabels: true });
    +  const extraWide = layoutCompactLoop(run, { availableCharacters: 180, showLabels: true });
       const narrow = layoutCompactLoop(run, { availableCharacters: 48, showLabels: true });
       assert.ok(Math.max(...wide.lines.map((line) => line.length)) <= 100);
       assert.ok(Math.max(...narrow.lines.map((line) => line.length)) <= 48);
       assert.ok(narrow.lines.length > wide.lines.length);
    +  assert.ok(new Set([...extraWide.positions.values()].map((position) => position.y)).size >= 2);
     });
    diff --git a/dashboard/src/components/LoopGraph/serial-compact-layout.ts b/dashboard/src/components/LoopGraph/serial-compact-layout.ts
    index 291b0fff..bba73852 100644
    --- a/dashboard/src/components/LoopGraph/serial-compact-layout.ts
    +++ b/dashboard/src/components/LoopGraph/serial-compact-layout.ts
    @@ -63,8 +63,25 @@ export function layoutSerialCompact(
       const available = Math.max(24, options.availableCharacters ?? 72);
       const destinations = [...new Set(feedback.map((edge) => edge.to))];
       const leftMargin = destinations.length ? destinations.length * 2 + 2 : 0;
    -  const capacity = Math.max(2, Math.floor((available - leftMargin + slot) / slot));
    -  const rowCount = Math.ceil(path.length / capacity);
    +  const minimumRows = path.length >= 7 ? 2 : 1;
    +  const candidates = Array.from(
    +    { length: Math.min(5, Math.ceil(path.length / 2)) - minimumRows + 1 },
    +    (_, index) => minimumRows + index,
    +  );
    +  const rowCount = candidates.map((rows) => {
    +    const columns = Math.ceil(path.length / rows);
    +    const estimatedWidth = leftMargin + (columns - 1) * slot + 2;
    +    const estimatedHeight = rows * 5;
    +    const overflow = Math.max(0, estimatedWidth - available);
    +    const aspect = estimatedWidth / Math.max(1, estimatedHeight * 2);
    +    const emptySlots = rows * columns - path.length;
    +    return {
    +      rows,
    +      score: overflow * 1_000
    +        + Math.abs(Math.log(Math.max(.01, aspect) / 2.2)) * 20
    +        + emptySlots * 1.5 + rows * .15,
    +    };
    +  }).sort((left, right) => left.score - right.score || left.rows - right.rows)[0].rows;
       const perRow = Math.ceil(path.length / rowCount);
       const rowHeight = 4 + Math.max(0, ...Array.from({ length: rowCount }, (_, row) =>
         feedback.filter((edge) => Math.floor(pathIndex.get(edge.from)! / perRow) === row).length));
    
    From c8311e6f2c41f494b4d865867c5ca129be35c435 Mon Sep 17 00:00:00 2001
    From: Juan Cruz Fortunatti 
    Date: Sat, 25 Jul 2026 18:22:58 +0200
    Subject: [PATCH 19/37] fix: pad loop edge labels
    
    ---
     .../components/LoopGraph/LoopGraph.test.ts    |  4 +++-
     .../LoopGraph/serial-compact-layout.ts        | 22 ++++++++++++++++---
     2 files changed, 22 insertions(+), 4 deletions(-)
    
    diff --git a/dashboard/src/components/LoopGraph/LoopGraph.test.ts b/dashboard/src/components/LoopGraph/LoopGraph.test.ts
    index 0388592f..d6b27edc 100644
    --- a/dashboard/src/components/LoopGraph/LoopGraph.test.ts
    +++ b/dashboard/src/components/LoopGraph/LoopGraph.test.ts
    @@ -132,9 +132,11 @@ test("compact topology keeps the serial dogfood Loop labelled within tablet widt
       for (const symbol of ["S", "D", "I1", "V", "R", "I2", "F1", "F2", "G", "B"])
         assert.match(compact, new RegExp(symbol, "u"));
       assert.match(compact, /reject/u);
    +  assert.match(compact, /─ complete ─/u);
    +  assert.match(compact, /─ reject ─/u);
       const drawing = compact.replace(/<[^>]+>/gu, "");
       assert.ok(Math.max(...drawing.split("\n").map((line) => line.length)) <= 72);
    -  assert.ok(drawing.split("\n").length < 14);
    +  assert.ok(drawing.split("\n").length < 20);
       assert.doesNotMatch(drawing, /┬─┬/u);
       const wide = layoutCompactLoop(run, { availableCharacters: 100, showLabels: true });
       const extraWide = layoutCompactLoop(run, { availableCharacters: 180, showLabels: true });
    diff --git a/dashboard/src/components/LoopGraph/serial-compact-layout.ts b/dashboard/src/components/LoopGraph/serial-compact-layout.ts
    index bba73852..ce1451e8 100644
    --- a/dashboard/src/components/LoopGraph/serial-compact-layout.ts
    +++ b/dashboard/src/components/LoopGraph/serial-compact-layout.ts
    @@ -35,6 +35,21 @@ function canvas(rows: number, columns: number) {
       return { cells, put, horizontal, vertical, text };
     }
     
    +function paddedLabel(value: string) {
    +  return ` ${value} `;
    +}
    +
    +function placeHorizontalLabel(
    +  graph: ReturnType, from: number, to: number, y: number, value: string,
    +) {
    +  const label = paddedLabel(value);
    +  const left = Math.min(from, to), right = Math.max(from, to);
    +  const room = right - left - 3;
    +  if (room < label.length) return false;
    +  graph.text(left + Math.floor((right - left - label.length) / 2), y, label);
    +  return true;
    +}
    +
     /**
      * A small Sugiyama-style specialisation for the common workflow shape:
      * rank the success spine, wrap it into balanced serpentine rows, then route
    @@ -59,7 +74,7 @@ export function layoutSerialCompact(
       const symbols = loopSymbols(run.graph.nodes, options.symbols);
       const labelWidth = options.showLabels
         ? Math.max(0, ...primary.map((edge) => edge.on.length)) : 0;
    -  const slot = Math.max(7, labelWidth + 5, ...path.map((id) => symbols.get(id)!.length + 5));
    +  const slot = Math.max(9, labelWidth + 9, ...path.map((id) => symbols.get(id)!.length + 7));
       const available = Math.max(24, options.availableCharacters ?? 72);
       const destinations = [...new Set(feedback.map((edge) => edge.to))];
       const leftMargin = destinations.length ? destinations.length * 2 + 2 : 0;
    @@ -102,7 +117,7 @@ export function layoutSerialCompact(
           graph.horizontal(from.x + (right ? 2 : -2), to.x + (right ? -2 : 2), from.y);
           graph.put(to.x + (right ? -2 : 2), to.y, right ? "▶" : "◀");
           if (options.showLabels)
    -        graph.text(Math.min(from.x, to.x) + 3, from.y, edge.on);
    +        placeHorizontalLabel(graph, from.x, to.x, from.y, edge.on);
         } else {
           graph.vertical(from.x, from.y + 1, to.y - 1);
           graph.put(to.x, to.y - 1, "▼");
    @@ -123,7 +138,8 @@ export function layoutSerialCompact(
         graph.vertical(railX, to.y + 1, sourceY);
         graph.horizontal(railX, to.x, to.y + 1);
         graph.put(to.x, to.y + 1, "▲");
    -    if (options.showLabels) graph.text(Math.min(from.x, width - edge.on.length), sourceY, edge.on);
    +    if (options.showLabels)
    +      placeHorizontalLabel(graph, railX, from.x, sourceY, edge.on);
       });
     
       for (const [id, position] of positions) graph.text(position.x, position.y, symbols.get(id)!);
    
    From 265bde0240a82c73454d82ddfe2739b05c172ad6 Mon Sep 17 00:00:00 2001
    From: Juan Cruz Fortunatti 
    Date: Sat, 25 Jul 2026 18:35:56 +0200
    Subject: [PATCH 20/37] feat: simplify built-in loop catalog
    
    ---
     README.md                                     | 17 ++++-----
     loops/branch/branch.loop                      | 25 +++++++++++++
     loops/branch/instructions.md                  | 23 ++++++++++++
     loops/gate/gate.loop                          | 17 +++++++++
     loops/gate/instructions.md                    |  7 ++++
     loops/review/instructions.md                  | 36 ++++---------------
     loops/review/review.loop                      | 27 ++++----------
     skills/burnlist/SKILL.md                      | 19 ++++++----
     src/cli/commands-help.test.mjs                |  8 ++---
     src/cli/loop-runtime-cli.test.mjs             |  7 ++--
     src/loops/assignment/assignment.mjs           |  6 ++--
     src/loops/assignment/assignment.test.mjs      |  9 +++++
     .../compiler-review/instructions.md           | 33 +++++++++++++++++
     .../__fixtures__/compiler-review/review.loop  | 34 ++++++++++++++++++
     src/loops/dsl/compile.test.mjs                |  4 +--
     src/loops/dsl/grammar.mjs                     |  7 ++--
     src/loops/dsl/ir-validate.mjs                 |  6 ++--
     src/loops/run/binder.mjs                      | 10 +++---
     website/src/content/docs/cli.mdx              | 15 ++++----
     website/src/content/docs/loops.mdx            | 27 ++++++++------
     20 files changed, 234 insertions(+), 103 deletions(-)
     create mode 100644 loops/branch/branch.loop
     create mode 100644 loops/branch/instructions.md
     create mode 100644 loops/gate/gate.loop
     create mode 100644 loops/gate/instructions.md
     create mode 100644 src/loops/dsl/__fixtures__/compiler-review/instructions.md
     create mode 100644 src/loops/dsl/__fixtures__/compiler-review/review.loop
    
    diff --git a/README.md b/README.md
    index 850dbf36..5308bcde 100644
    --- a/README.md
    +++ b/README.md
    @@ -148,15 +148,15 @@ 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.
     
    -## Review Loop (Stage 1)
    +## Built-in Loops (Stage 1)
     
    -The built-in `review` Loop is an optional, serial foreground workflow for an
    -assigned item:
    +Items may use direct execution or one of exactly three built-in Loops:
     
     ```text
    -maker -> repository check -> fresh reviewer -> convergence gate -> CLI completion
    -  ^                                  |
    -  +------------ check failure / rejection
    +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
    @@ -186,9 +186,10 @@ 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.
    -Parallel execution, Docker isolation, metrics gates,
    +Burnlist-native parallel scheduling, Docker isolation, metrics gates,
     forecasting, worktrees, and background execution are deliberately unsupported
    -in Stage 1. Items with no Loop assignment keep the ordinary direct
    +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
    diff --git a/loops/branch/branch.loop b/loops/branch/branch.loop
    new file mode 100644
    index 00000000..dfc7371d
    --- /dev/null
    +++ b/loops/branch/branch.loop
    @@ -0,0 +1,25 @@
    +
    +  
    +  
    +  
    +  
    +  
    +  
    +  
    +  
    +  
    +  
    +  
    +  
    +  
    +  
    +  
    +  
    +  
    +  
    +  
    +  
    +  
    +  
    +  
    +
    diff --git a/loops/branch/instructions.md b/loops/branch/instructions.md
    new file mode 100644
    index 00000000..1d304279
    --- /dev/null
    +++ b/loops/branch/instructions.md
    @@ -0,0 +1,23 @@
    +## plan
    +Turn the assigned item into a concise execution plan of independent subtasks.
    +Choose N from the work itself; prefer the fewest branches that create useful
    +parallelism. Define branch boundaries, shared constraints, and merge criteria.
    +
    +Do not edit Burnlist lifecycle files or complete the item directly.
    +
    +## branches
    +Execute the prepared independent subtasks through N smaller, faster agents when
    +the host supports subagents. Give each agent its exact slice and acceptance
    +criteria. Keep writes disjoint where possible. If native subagents are
    +unavailable, execute the same slices sequentially without changing the Loop
    +contract. Return only after every branch has a concrete result.
    +
    +## merge
    +Combine all branch results into one coherent candidate. Resolve overlaps,
    +finish integration work, and run focused checks before repository validation.
    +
    +## review
    +Independently review the merged, validated candidate against the original item
    +and plan. Approve when integration is coherent. Reject high-priority defects
    +and material medium-priority defects back to planning; leave minor refinements
    +as follow-up notes.
    diff --git a/loops/gate/gate.loop b/loops/gate/gate.loop
    new file mode 100644
    index 00000000..e2baec46
    --- /dev/null
    +++ b/loops/gate/gate.loop
    @@ -0,0 +1,17 @@
    +
    +  
    +  
    +  
    +  
    +  
    +  
    +  
    +  
    +  
    +  
    +  
    +  
    +  
    +  
    +  
    +
    diff --git a/loops/gate/instructions.md b/loops/gate/instructions.md
    new file mode 100644
    index 00000000..924bebbb
    --- /dev/null
    +++ b/loops/gate/instructions.md
    @@ -0,0 +1,7 @@
    +## implement
    +Implement the assigned item and prepare it for the declared deterministic gate.
    +Use the item contract to guide the change, but do not invent or bypass the
    +trusted repository check.
    +
    +Do not edit Burnlist lifecycle files or complete the item directly. Burnlist
    +owns the gate transition and completion.
    diff --git a/loops/review/instructions.md b/loops/review/instructions.md
    index 8079f335..4e5eabe0 100644
    --- a/loops/review/instructions.md
    +++ b/loops/review/instructions.md
    @@ -1,33 +1,11 @@
    -## start
    -Outline this decompose scope and identify candidate constraints.
    -
    -Do not edit Burnlist lifecycle files, delete the active item, move lifecycle
    -folders, or run `burnlist burn`/`loop complete`. Burnlist alone commits Burn
    -after convergence.
    -
    -## decompose
    -Break the next candidate into independent chunks and define success criteria.
    -
    -Do not edit Burnlist lifecycle files, delete the active item, move lifecycle
    -folders, or run `burnlist burn`/`loop complete`. Burnlist alone commits Burn
    -after convergence.
    -
     ## implement
    -Make a first-pass implementation that satisfies the decomposed chunks.
    +Implement the assigned item completely, run focused checks, and leave the
    +workspace ready for the declared repository validation.
     
    -Do not edit Burnlist lifecycle files, delete the active item, move lifecycle
    -folders, or run `burnlist burn`/`loop complete`. Burnlist alone commits Burn
    -after convergence.
    +Do not edit Burnlist lifecycle files or complete the item directly. Burnlist
    +owns transitions and completion.
     
     ## review
    -Review the current implementation for correctness and regression risk.
    -
    -## integrate
    -Merge reviewed work with baseline and resolve any follow-up.
    -
    -Do not edit Burnlist lifecycle files, delete the active item, move lifecycle
    -folders, or run `burnlist burn`/`loop complete`. Burnlist alone commits Burn
    -after convergence.
    -
    -## final-review
    -Do a final inspection for safety, quality, and handoff readiness.
    +Independently review the validated candidate against the item contract. Approve
    +when it is correct and proportionate. Reject only for actionable high-priority
    +or material medium-priority issues; record minor improvements for later.
    diff --git a/loops/review/review.loop b/loops/review/review.loop
    index de06888a..1db8f40b 100644
    --- a/loops/review/review.loop
    +++ b/loops/review/review.loop
    @@ -1,34 +1,21 @@
    -
    -  
    -  
    -  
    +
    +  
       
    -  
    -  
    -  
       
    -  
    -  
    +  
    +  
       
       
       
       
       
       
    -  
    -  
       
       
    -  
    -  
    -  
    +  
    +  
    +  
       
    -  
    -  
    -  
    -  
    -  
    -  
       
       
     
    diff --git a/skills/burnlist/SKILL.md b/skills/burnlist/SKILL.md
    index 46854738..1bc14aa9 100644
    --- a/skills/burnlist/SKILL.md
    +++ b/skills/burnlist/SKILL.md
    @@ -155,12 +155,19 @@ Burnlist has two independent installable systems. Either or both may be present:
     
     Install only the system the task needs, or both. Read `references/installation.md` for exact commands, ownership, and shared-versus-local behavior.
     
    -## Built-in Review Loop (Stage 1)
    +## Built-in Loops (Stage 1)
     
    -Use the built-in `loop:builtin:review` only when a user or active Burnlist
    -assigns an item to it. It is one serial foreground path: maker, trusted
    -repository check, fresh reviewer, convergence gate, then CLI-owned completion.
    -Items without an assignment keep the direct Burnlist workflow.
    +Use only the execution choice assigned by the user or active Burnlist:
    +
    +- no assignment: direct implementation and ordinary `burnlist burn`
    +- `loop:builtin:review`: implement, trusted check, independent review, Burn
    +- `loop:builtin:gate`: implement, trusted check, Burn
    +- `loop:builtin:branch`: plan, host-orchestrated N smaller-agent slices, merge,
    +  trusted check, independent review, Burn
    +
    +Do not substitute one built-in for another. Branch fan-out is host-owned:
    +use native or CLI subagents when available, otherwise execute the declared
    +slices sequentially. Burnlist does not fabricate per-branch runtime authority.
     
     Before creating a Run, inspect and explicitly trust the repository check
     capability, then assign the item. Burnlist stores no provider profile, route,
    @@ -192,7 +199,7 @@ transition or execute a managed check.
     ```sh
     burnlist loop capability inspect repo-verify
     burnlist loop capability trust repo-verify --revision cp1-sha256: --grants 
    -burnlist loop assign item:# loop:builtin:review
    +burnlist loop assign item:# loop:builtin:
     ```
     
     Run `burnlist loop setup status` before `loop create`. Paste the complete
    diff --git a/src/cli/commands-help.test.mjs b/src/cli/commands-help.test.mjs
    index 64d4f14b..edf76ea0 100644
    --- a/src/cli/commands-help.test.mjs
    +++ b/src/cli/commands-help.test.mjs
    @@ -187,7 +187,7 @@ test("Review Loop documentation command matrix runs against the production fixtu
       }
       const claim = loop("claim", runId);
       assert.equal(claim.status, 0, claim.stderr);
    -  assert.equal(JSON.parse(claim.stdout).execution.nodeId, "start");
    +  assert.equal(JSON.parse(claim.stdout).execution.nodeId, "implement");
       assert.equal(loop("abandon", JSON.parse(claim.stdout).execution.claimId,
         "--reason", "host-cancelled").status, 0);
     
    @@ -199,7 +199,7 @@ test("Review Loop documentation command matrix runs against the production fixtu
       assert.equal(JSON.parse(paused.stdout).state, "paused");
       const resumed = loop("claim", pausedRun);
       assert.equal(resumed.status, 0, resumed.stderr);
    -  assert.equal(JSON.parse(resumed.stdout).execution.nodeId, "start");
    +  assert.equal(JSON.parse(resumed.stdout).execution.nodeId, "implement");
     
       const stoppedRef = "item:260722-001#L32";
       assert.equal(loop("assign", stoppedRef, "loop:builtin:review").status, 0);
    @@ -212,8 +212,8 @@ test("Review Loop documentation command matrix runs against the production fixtu
       assert.equal(loop("assign", reconciledRef, "loop:builtin:review").status, 0);
       const reconciledRun = JSON.parse(loop("create", reconciledRef).stdout).runId;
       const store = runStore(repo), acquired = store.acquireLease(reconciledRun);
    -  store.append(reconciledRun, acquired.lease, "node-started", { nodeId: "start", attempt: 1 });
    -  store.append(reconciledRun, acquired.lease, "invocation-started", { nodeId: "start", attempt: 1, invocationId: "a".repeat(32) });
    +  store.append(reconciledRun, acquired.lease, "node-started", { nodeId: "implement", attempt: 1 });
    +  store.append(reconciledRun, acquired.lease, "invocation-started", { nodeId: "implement", attempt: 1, invocationId: "a".repeat(32) });
       const reconciled = loop("reconcile", reconciledRun, "--recovery-proof", acquired.recoveryProof);
       assert.equal(reconciled.status, 0, reconciled.stderr);
       assert.equal(JSON.parse(reconciled.stdout).state, "needs-human");
    diff --git a/src/cli/loop-runtime-cli.test.mjs b/src/cli/loop-runtime-cli.test.mjs
    index ea7448a1..f5d35e92 100644
    --- a/src/cli/loop-runtime-cli.test.mjs
    +++ b/src/cli/loop-runtime-cli.test.mjs
    @@ -41,7 +41,7 @@ test("host-only CLI exposes stable reads and no managed run command", (t) => {
       assert.equal(command(repo, ["list"]), "[]\n");
       const runId = created(repo);
       const status = JSON.parse(command(repo, ["status", runId]));
    -  assert.equal(status.currentNode, "start");
    +  assert.equal(status.currentNode, "implement");
       assert.equal(command(repo, ["status", runId]), command(repo, ["status", runId]));
       for (const verb of ["run", "resume"]) {
         const result = invoke(repo, [verb, runId]);
    @@ -66,8 +66,7 @@ test("claim and report advance checks and gates without launching a provider", (
         writeFileSync(reportPath, `${JSON.stringify(hostReport(execution, outcome))}\n`);
         command(repo, ["report", execution.claimId, "--result", reportPath]);
       }
    -  assert.deepEqual(visited,
    -    ["start", "decompose", "implement", "review", "integrate", "final-review"]);
    +  assert.deepEqual(visited, ["implement", "review"]);
       assert.equal(JSON.parse(command(repo, ["status", runId])).state, "converged");
       assert.equal(JSON.parse(command(repo, ["complete", runId])).alreadyApplied, false);
       assert.equal(JSON.parse(command(repo, ["complete", runId])).alreadyApplied, true);
    @@ -92,7 +91,7 @@ test("a claimed provider cannot be stolen and reviewer candidate drift rejects i
       t.after(() => rmSync(directory, { recursive: true, force: true }));
       const { repo } = createProductionRunAuthority(join(directory, "repo"));
       const runId = created(repo), reportPath = join(directory, "report.json");
    -  for (const expected of ["start", "decompose", "implement"]) {
    +  for (const expected of ["implement"]) {
         const execution = JSON.parse(command(repo, ["claim", runId])).execution;
         assert.equal(execution.nodeId, expected);
         writeFileSync(reportPath, `${JSON.stringify(hostReport(execution, "complete"))}\n`);
    diff --git a/src/loops/assignment/assignment.mjs b/src/loops/assignment/assignment.mjs
    index 23fa3d31..3db9c5aa 100644
    --- a/src/loops/assignment/assignment.mjs
    +++ b/src/loops/assignment/assignment.mjs
    @@ -19,12 +19,12 @@ function atomicWrite(path, bytes) {
     }
     
     function packageDirectory(loop) {
    -  if (loop.name !== "review") fail(`installed Loop ${loop.selector} was not found`);
    -  return resolve(dirname(fileURLToPath(import.meta.url)), "../../../loops/review");
    +  if (!["review", "gate", "branch"].includes(loop.name)) fail(`installed Loop ${loop.selector} was not found`);
    +  return resolve(dirname(fileURLToPath(import.meta.url)), "../../../loops", loop.name);
     }
     export async function resolveBuiltin(loop) {
       if (!loop.selector.startsWith("loop:builtin:")) fail(`installed Loop ${loop.selector} was not found`);
    -  const compiled = await compileLoopPackage(packageDirectory(loop));
    +  const compiled = await compileLoopPackage(packageDirectory(loop), { loopFile: `${loop.name}.loop` });
       if (!compiled.ok) fail(`installed Loop ${loop.selector} does not compile`);
       if (loop.executable && loop.executable !== compiled.revisions.executable) fail("LoopRef executable pin does not match current package");
       return compiled;
    diff --git a/src/loops/assignment/assignment.test.mjs b/src/loops/assignment/assignment.test.mjs
    index 0497248e..bc9dd448 100644
    --- a/src/loops/assignment/assignment.test.mjs
    +++ b/src/loops/assignment/assignment.test.mjs
    @@ -35,6 +35,15 @@ test("selectors are closed, disjoint, and reject noncanonical ULID overflow", ()
       for (const value of ["loop:project:../review", "loop:project:Review", "loop:local:review", "loop:project:review/extra"]) assert.throws(() => parseLoopRef(value), TypeError);
     });
     
    +test("the shipped Loop catalog contains only review, gate, and branch packages", async () => {
    +  for (const name of ["review", "gate", "branch"]) {
    +    const compiled = await resolveBuiltin(parseLoopRef(`loop:builtin:${name}`));
    +    assert.equal(compiled.ok, true);
    +    assert.equal(compiled.ir.id, name);
    +  }
    +  await assert.rejects(resolveBuiltin(parseLoopRef("loop:builtin:deep-review")), /was not found/u);
    +});
    +
     test("project Loop packages are contained, frozen, and show provenance drift without changing the item pin", async () => {
       const context = fixture();
       try {
    diff --git a/src/loops/dsl/__fixtures__/compiler-review/instructions.md b/src/loops/dsl/__fixtures__/compiler-review/instructions.md
    new file mode 100644
    index 00000000..8079f335
    --- /dev/null
    +++ b/src/loops/dsl/__fixtures__/compiler-review/instructions.md
    @@ -0,0 +1,33 @@
    +## start
    +Outline this decompose scope and identify candidate constraints.
    +
    +Do not edit Burnlist lifecycle files, delete the active item, move lifecycle
    +folders, or run `burnlist burn`/`loop complete`. Burnlist alone commits Burn
    +after convergence.
    +
    +## decompose
    +Break the next candidate into independent chunks and define success criteria.
    +
    +Do not edit Burnlist lifecycle files, delete the active item, move lifecycle
    +folders, or run `burnlist burn`/`loop complete`. Burnlist alone commits Burn
    +after convergence.
    +
    +## implement
    +Make a first-pass implementation that satisfies the decomposed chunks.
    +
    +Do not edit Burnlist lifecycle files, delete the active item, move lifecycle
    +folders, or run `burnlist burn`/`loop complete`. Burnlist alone commits Burn
    +after convergence.
    +
    +## review
    +Review the current implementation for correctness and regression risk.
    +
    +## integrate
    +Merge reviewed work with baseline and resolve any follow-up.
    +
    +Do not edit Burnlist lifecycle files, delete the active item, move lifecycle
    +folders, or run `burnlist burn`/`loop complete`. Burnlist alone commits Burn
    +after convergence.
    +
    +## final-review
    +Do a final inspection for safety, quality, and handoff readiness.
    diff --git a/src/loops/dsl/__fixtures__/compiler-review/review.loop b/src/loops/dsl/__fixtures__/compiler-review/review.loop
    new file mode 100644
    index 00000000..de06888a
    --- /dev/null
    +++ b/src/loops/dsl/__fixtures__/compiler-review/review.loop
    @@ -0,0 +1,34 @@
    +
    +  
    +  
    +  
    +  
    +  
    +  
    +  
    +  
    +  
    +  
    +  
    +  
    +  
    +  
    +  
    +  
    +  
    +  
    +  
    +  
    +  
    +  
    +  
    +  
    +  
    +  
    +  
    +  
    +  
    +  
    +  
    +  
    +
    diff --git a/src/loops/dsl/compile.test.mjs b/src/loops/dsl/compile.test.mjs
    index deced2a8..ca4b2581 100644
    --- a/src/loops/dsl/compile.test.mjs
    +++ b/src/loops/dsl/compile.test.mjs
    @@ -13,7 +13,7 @@ import { foldRun } from "../run/run-fold.mjs";
     import { created } from "../run/m2-test-fixtures.mjs";
     import { renderDiagnostics } from "./diagnostics.mjs";
     
    -const root = new URL("../../../loops/review/", import.meta.url);
    +const root = new URL("./__fixtures__/compiler-review/", import.meta.url);
     const fixtures = new URL("./__fixtures__/", import.meta.url);
     async function reviewFiles() { return { "review.loop": await readFile(new URL("review.loop", root)), "instructions.md": await readFile(new URL("instructions.md", root)) }; }
     async function compiled() { const result = compileLoopFiles(await reviewFiles()); assert.equal(result.ok, true, renderDiagnostics(result.diagnostics ?? [])); return result; }
    @@ -30,7 +30,7 @@ test("built-in review package compiles to deterministic canonical frozen IR", as
       assert.deepEqual([...first.ir.nodes.map((node) => node.id)].sort(), ["completed", "converged", "decompose", "exhausted", "failed", "final-review", "final-validate", "implement", "integrate", "needs-human", "review", "start", "stopped", "validate"].sort());
       assert.deepEqual([...first.ir.edges.map((edge) => edge.from)].sort(), ["start", "decompose", "implement", "validate", "validate", "review", "review", "review", "integrate", "final-validate", "final-validate", "final-review", "final-review", "final-review", "converged", "converged"].sort());
       assert.match(first.revisions.executable, /^er1-sha256:[a-f0-9]{64}$/);
    -  assert.equal((await compileLoopPackage(new URL("../../../loops/review", import.meta.url).pathname)).ok, true);
    +  assert.equal((await compileLoopPackage(new URL("./__fixtures__/compiler-review", import.meta.url).pathname)).ok, true);
     });
     
     test("runtime consumes persisted frozen IR and validates recipe identity", async () => {
    diff --git a/src/loops/dsl/grammar.mjs b/src/loops/dsl/grammar.mjs
    index 0b01fe6d..fd56c5d1 100644
    --- a/src/loops/dsl/grammar.mjs
    +++ b/src/loops/dsl/grammar.mjs
    @@ -79,16 +79,15 @@ export function validateLoop(ast) {
       const agents = nodes.filter((node) => node.kind === "agent"), checks = nodes.filter((node) => node.kind === "check"), gates = nodes.filter((node) => node.kind === "gate"), terminals = nodes.filter((node) => node.kind === "terminal");
       const taskAgents = agents.filter((node) => node.mode === "task"), reviewAgents = agents.filter((node) => node.mode === "review");
       if (taskAgents.length < 1) add(d, ast, "E_AGENT_CARDINALITY", "Stage 1 requires at least one task agent");
    -  if (reviewAgents.length < 1) add(d, ast, "E_AGENT_CARDINALITY", "Stage 1 requires at least one review agent");
       if (checks.length < 1) add(d, ast, "E_NODE_CARDINALITY", "Stage 1 requires at least one check node");
       if (gates.length !== 1) add(d, ast, "E_NODE_CARDINALITY", "Stage 1 requires exactly one convergence gate");
       for (const state of terminalStates) if (terminals.filter((node) => node.state === state).length !== 1) add(d, ast, "E_TERMINAL_CARDINALITY", `Stage 1 requires exactly one ${state} terminal`);
       for (const reviewer of reviewAgents) if (!taskAgents.some((task) => task.id === reviewer.independentFrom)) add(d, ids.get(reviewer.id), "E_REVIEW_INDEPENDENCE", "Reviewer independent-from must name an existing task agent");
       const gate = gates[0], gateRequires = new Set(gate?.requires ?? []);
       if (gate && (gateRequires.size !== gate.requires.length
    -    || !reviewAgents.some((node) => gateRequires.has(node.id))
    +    || ![...checks, ...reviewAgents].some((node) => gateRequires.has(node.id))
         || [...gateRequires].some((id) => !checks.some((node) => node.id === id) && !reviewAgents.some((node) => node.id === id)))) {
    -    add(d, ids.get(gate.id), "E_GATE_REQUIREMENTS", "Convergence gate requires must uniquely reference at least one review node and only check/review nodes");
    +    add(d, ids.get(gate.id), "E_GATE_REQUIREMENTS", "Convergence gate requires must uniquely reference at least one check/review node and only check/review nodes");
       }
       if (policy) for (const [outcome, state] of Object.entries({ error: "failed", timeout: "failed", cancelled: "stopped", lost: "needs-human", exhausted: "budget-exhausted" })) { const target = ids.get(policy.attrs[outcome]); if (!target || target.attrs.state !== state) add(d, policy, "E_FAILURE_POLICY", `${outcome} must target the ${state} terminal`); }
       for (const edge of edges) {
    @@ -102,7 +101,7 @@ export function validateLoop(ast) {
           (source?.kind === "agent" && source.mode === "review" && edge.on === "approve" && target?.kind !== "terminal") ||
           (source?.kind === "agent" && source.mode === "review" && edge.on === "reject" && target?.kind !== "terminal") ||
           (source?.kind === "agent" && source.mode === "review" && edge.on === "escalate" && target?.kind === "terminal" && target.state === "needs-human") ||
    -      (source?.kind === "check" && edge.on === "pass" && target?.kind === "agent" && target?.mode === "review") ||
    +      (source?.kind === "check" && edge.on === "pass" && (target?.kind === "gate" || target?.kind === "agent" && target?.mode === "review")) ||
           (source?.kind === "check" && edge.on === "fail" && target?.kind === "agent" && target?.mode === "task") ||
           (source?.kind === "gate" && edge.on === "pass" && target?.kind === "terminal" && target.state === "converged") ||
           (source?.kind === "gate" && edge.on === "fail" && target?.kind === "terminal" && target.state === "needs-human");
    diff --git a/src/loops/dsl/ir-validate.mjs b/src/loops/dsl/ir-validate.mjs
    index 1e2643d3..91549e33 100644
    --- a/src/loops/dsl/ir-validate.mjs
    +++ b/src/loops/dsl/ir-validate.mjs
    @@ -57,7 +57,7 @@ function backEdges(nodes, edges, entry) {
     
     function targetAllowed(source, outcome, target) {
       return (source.kind === "agent" && source.mode === "task" && outcome === "complete" && target.kind !== "terminal") ||
    -    (source.kind === "check" && outcome === "pass" && target.kind === "agent" && target.mode === "review") ||
    +    (source.kind === "check" && outcome === "pass" && (target.kind === "gate" || target.kind === "agent" && target.mode === "review")) ||
         (source.kind === "check" && outcome === "fail" && target.kind === "agent" && target.mode === "task") ||
         (source.kind === "agent" && source.mode === "review" && outcome === "reject" && target.kind !== "terminal") ||
         (source.kind === "agent" && source.mode === "review" && outcome === "approve" && target.kind !== "terminal") ||
    @@ -79,12 +79,12 @@ export function validateClosedIr(ir) {
       const terminals = ir.nodes.filter((node) => node.kind === "terminal");
       const agentInstructionIds = new Set(agents.map((agent) => agent.instructions));
     
    -  if (makers.length < 1 || reviewers.length < 1 || checks.length < 1 || gates.length !== 1 || states.some((state) => terminals.filter((node) => node.state === state).length !== 1)) return false;
    +  if (makers.length < 1 || checks.length < 1 || gates.length !== 1 || states.some((state) => terminals.filter((node) => node.state === state).length !== 1)) return false;
       if (ids.get(ir.entry)?.kind !== "agent" || ids.get(ir.entry)?.mode !== "task") return false;
       if (!reviewers.every((reviewer) => ids.get(reviewer.independentFrom)?.kind === "agent" && ids.get(reviewer.independentFrom)?.mode === "task")) return false;
       const gateRequires = new Set(gates[0].requires);
       if (gateRequires.size !== gates[0].requires.length
    -    || !gates[0].requires.some((id) => ids.get(id)?.kind === "agent" && ids.get(id)?.mode === "review")
    +    || !gates[0].requires.some((id) => ids.get(id)?.kind === "check" || ids.get(id)?.kind === "agent" && ids.get(id)?.mode === "review")
         || gates[0].requires.some((id) => ids.get(id)?.kind !== "check" && !(ids.get(id)?.kind === "agent" && ids.get(id)?.mode === "review"))) return false;
       if (agentInstructionIds.size !== agents.length) return false;
     
    diff --git a/src/loops/run/binder.mjs b/src/loops/run/binder.mjs
    index b07f82bc..109c49a4 100644
    --- a/src/loops/run/binder.mjs
    +++ b/src/loops/run/binder.mjs
    @@ -63,9 +63,11 @@ function assertBoundaryEvidence(evidence) {
       }
     }
     function executableSnapshot(loopRef) {
    -  if (loopRef !== "loop:builtin:review") fail(`executable source identity is unavailable for ${loopRef}`);
    -  const directory = join(builtinsRoot, "review"), files = {}, evidence = [];
    -  for (const [name, maximum] of [["review.loop", 65536], ["instructions.md", 262144]]) {
    +  const loop = parseLoopRef(loopRef);
    +  if (!loopRef.startsWith("loop:builtin:") || !["review", "gate", "branch"].includes(loop.name))
    +    fail(`executable source identity is unavailable for ${loopRef}`);
    +  const directory = join(builtinsRoot, loop.name), files = {}, evidence = [];
    +  for (const [name, maximum] of [[`${loop.name}.loop`, 65536], ["instructions.md", 262144]]) {
         const path = join(directory, name), captured = readSnapshotBytes({ root: builtinsRoot, path, maximum });
         files[name] = captured.bytes;
         evidence.push(Object.freeze({ kind: "file", path, dev: String(captured.identity.dev), ino: String(captured.identity.ino),
    @@ -75,7 +77,7 @@ function executableSnapshot(loopRef) {
           dev: String(ancestor.identity.dev), ino: String(ancestor.identity.ino), size: String(ancestor.identity.size),
           mode: String(ancestor.identity.mode), mtimeMs: String(ancestor.identity.mtimeMs), ctimeMs: String(ancestor.identity.ctimeMs) }));
       }
    -  const compiled = compileLoopFiles(files);
    +  const compiled = compileLoopFiles(files, { loopFile: `${loop.name}.loop` });
       if (!compiled.ok) fail("captured executable source does not compile");
       assertBoundaryEvidence(evidence);
       return { compiled, evidence };
    diff --git a/website/src/content/docs/cli.mdx b/website/src/content/docs/cli.mdx
    index eceb72cd..43165bc0 100644
    --- a/website/src/content/docs/cli.mdx
    +++ b/website/src/content/docs/cli.mdx
    @@ -37,7 +37,7 @@ These commands act on the current repository by default, or on an explicit `--re
     | `burnlist burn   [--check] [--repo ]` | Complete a Burnlist item; `--check` validates it. |
     | `burnlist close  [--repo ]` | Close a completed Burnlist. |
     
    -## Review Loop
    +## Loops
     
     ```sh
     burnlist loop assign   [--repo ]
    @@ -56,11 +56,14 @@ burnlist loop capability trust  --revision cp1-sha256: --grants ]
     ```
     
    -The Review Loop is an optional serial maker → check → fresh reviewer workflow.
    -Agent providers are invoked by the current host, never by Burnlist. `loop view`
    -is deterministic ASCII graph output; `claim` and `report` cross the provider
    -boundary, and reporting advances trusted checks and gates. Only `loop complete`
    -applies a converged Run to the Burnlist. See [Review Loop](/loops).
    +An item either runs directly with no assignment or uses one of exactly three
    +built-ins: `loop:builtin:review`, `loop:builtin:gate`, or
    +`loop:builtin:branch`. Branch fan-out is orchestrated by the current host;
    +Burnlist does not schedule branch workers. Agent providers are invoked by the
    +host, never by Burnlist. `loop view` is deterministic ASCII graph output;
    +`claim` and `report` cross the provider boundary, and reporting advances
    +trusted checks and gates. Only `loop complete` applies a converged Run to the
    +Burnlist. See [Loops](/loops).
     
     ## Registry
     
    diff --git a/website/src/content/docs/loops.mdx b/website/src/content/docs/loops.mdx
    index f34cc307..c5539be1 100644
    --- a/website/src/content/docs/loops.mdx
    +++ b/website/src/content/docs/loops.mdx
    @@ -1,19 +1,26 @@
     ---
    -title: Review Loop
    -description: Configure and run the built-in serial maker, check, and reviewer Loop.
    +title: Loops
    +description: Choose direct execution or configure one of the three built-in Loop workflows.
     ---
     
    -The built-in `loop:builtin:review` is an optional serial, foreground workflow:
    +Every item uses one of exactly four execution choices:
     
     ```text
    -Item -> maker -> check -> fresh reviewer -> gate -> CLI completion
    -             ^             |
    -             +-- fail/reject
    +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
     ```
     
    -It does not replace normal Burnlist execution. An unassigned item uses the
    -direct workflow, including `burnlist burn   [--check]`; an assigned
    -item is completed only through its converged Loop Run.
    +Direct execution is represented by no Loop assignment and keeps the ordinary
    +`burnlist burn   [--check]` workflow. The three assignable built-ins
    +are `loop:builtin:review`, `loop:builtin:gate`, and `loop:builtin:branch`.
    +An assigned item is completed only through its converged Loop Run.
    +
    +Branch fan-out is host-orchestrated rather than a Burnlist scheduler: the plan
    +node chooses the slices, the host invokes N native or CLI subagents when
    +available (or executes the slices sequentially), and the merge node integrates
    +their results before the trusted check and independent review.
     
     ## Configure and assign
     
    @@ -63,7 +70,7 @@ narrow this policy.
     burnlist loop capability inspect repo-verify
     burnlist loop capability trust repo-verify --revision cp1-sha256: --grants /absolute/path/to/grants.json
     burnlist loop setup status
    -burnlist loop assign item:# loop:builtin:review
    +burnlist loop assign item:# loop:builtin:
     ```
     
     `setup status` reports missing configuration rather than guessing it. An
    
    From c34266287cee358380873e77f83538a91983e997 Mon Sep 17 00:00:00 2001
    From: Juan Cruz Fortunatti 
    Date: Sat, 25 Jul 2026 19:02:29 +0200
    Subject: [PATCH 21/37] fix: refine loop host usability
    
    ---
     bin/burnlist.mjs                             | 18 +++++++++++++++++
     skills/burnlist/SKILL.md                     |  5 +++++
     skills/burnlist/references/host-execution.md |  9 +++++++--
     skills/burnlist/references/installation.md   |  5 +++++
     src/cli/commands-help.test.mjs               |  1 +
     src/cli/loop-config-cli.mjs                  |  2 +-
     src/cli/skills-register.mjs                  | 15 +++++++++++++-
     src/cli/skills-register.test.mjs             | 21 ++++++++++++++++++++
     src/loops/dsl/package-read.test.mjs          |  6 +++---
     website/src/content/docs/loops.mdx           |  7 +++++++
     10 files changed, 82 insertions(+), 7 deletions(-)
    
    diff --git a/bin/burnlist.mjs b/bin/burnlist.mjs
    index a9872125..ee34c67d 100755
    --- a/bin/burnlist.mjs
    +++ b/bin/burnlist.mjs
    @@ -36,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")) {
    @@ -46,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;
    diff --git a/skills/burnlist/SKILL.md b/skills/burnlist/SKILL.md
    index 1bc14aa9..dedde694 100644
    --- a/skills/burnlist/SKILL.md
    +++ b/skills/burnlist/SKILL.md
    @@ -221,6 +221,8 @@ is not a Loop-visibility milestone.
     Inspect with `loop status|inspect`, control idle Runs with `loop pause|stop`,
     use proof-gated `loop reconcile` only for a demonstrably lost host claim, and
     apply a converged Run through idempotent `loop complete`.
    +`loop list` lists created Runs, not assignments; an empty result after
    +assignment is expected. Assignment truth is `loop view item:#`.
     
     The canonical Checklist Oven keeps a centered KPI row above the side-by-side
     Progress ledger and Completion trend. Its unified Items list contains current,
    @@ -241,6 +243,9 @@ It is not Docker or an OS sandbox. Parallelism, nested agents, metric gates,
     worktrees, background execution, Docker isolation, and
     forecasting are `unsupported`. The Checklist UI is read-only and displays
     active, paused, error, and terminal Run states; it never controls a Run.
    +Branch fan-out remains one canonical `branches` node in Stage 1, so N, slice
    +names, and native worker state are host evidence rather than dashboard graph
    +state.
     
     `burnlist install` registers this skill, while `burnlist hooks install` adds
     Streaming Diff edit-capture hooks. These integrations are independent and do
    diff --git a/skills/burnlist/references/host-execution.md b/skills/burnlist/references/host-execution.md
    index ce4136d6..15cbb9f5 100644
    --- a/skills/burnlist/references/host-execution.md
    +++ b/skills/burnlist/references/host-execution.md
    @@ -10,12 +10,17 @@ invocation. If subscriptions are unknown, read `loop-provider-setup.md` first.
     
        ```sh
        burnlist loop create item:#
    -   burnlist loop claim run:
    +   burnlist loop claim run: > .local/burnlist/claim.json
    +   node -e 'const c=require(process.argv[1]); console.log(c.claim.claimId)' \
    +     .local/burnlist/claim.json
        ```
     
        `claim` returns canonical JSON containing a `claim` and an `execution`
        envelope. Treat both as opaque, bounded, single-use authority. Do not alter
    -   or reconstruct their fields.
    +   or reconstruct their fields. Capture it to a private ignored file as above
    +   instead of printing the large authority envelope into chat. The second
    +   command prints the `ClaimRef`; it does not replace the saved execution
    +   envelope needed by the provider.
     
     2. Decode, inspect, and execute the exact prepared invocation. `execution` is
        a canonical `burnlist-loop-host-execution@1` object: base64-decode its
    diff --git a/skills/burnlist/references/installation.md b/skills/burnlist/references/installation.md
    index 33b05716..79243b96 100644
    --- a/skills/burnlist/references/installation.md
    +++ b/skills/burnlist/references/installation.md
    @@ -20,6 +20,11 @@ By default, `burnlist install` registers the bundled Burnlist skill for both age
     
     The default per-repository mode is a managed symlink and adds its target to `.git/info/exclude`, so it stays local and untracked. `--global` creates the managed global registrations instead. A global npm installation of Burnlist automatically registers those global skills for both agents. `--commit` is per-repository only: it creates a portable managed copy and removes Burnlist's local exclusion entry so the copy can be added to Git. `--force` permits an untracked managed copy to be downgraded to a symlink; tracked copies must be removed through Git first. `--agent codex`, `--agent claude`, or `--agent codex,claude` limits registrations; without it, both agents are targeted. `--dry-run` prints the planned link or copy operations without writing.
     
    +Codex uses the shared `~/.agents/skills` target. An older manually installed
    +`~/.codex/skills/burnlist` can shadow that registration in some clients. If
    +both exist, compare them before a Loop trial and remove or rename only the
    +stale manual copy; Burnlist never overwrites that foreign directory.
    +
     For a Git worktree, the command reports the default mode as `untracked (local, .git/info/exclude)`. For `--commit`, it checks copied content files and reports either `committable (portable copy; run git add to track)` or the actual ignore rule still hiding content. Global registrations report `global symlink (no repo exclude)`. A non-Git directory instead reports `symlink (no git repo to exclude into)` or `portable copy (no git repo)`.
     
     `burnlist uninstall` removes only Burnlist-managed registrations in the matching scope and removes its matching local exclusion entries. `--purge` requires `uninstall --global`, targets both agents, and also uninstalls the global npm package.
    diff --git a/src/cli/commands-help.test.mjs b/src/cli/commands-help.test.mjs
    index edf76ea0..0cab4f24 100644
    --- a/src/cli/commands-help.test.mjs
    +++ b/src/cli/commands-help.test.mjs
    @@ -30,6 +30,7 @@ test("install, uninstall, and hooks subcommand help exit successfully with usage
           [["hooks", "install", "--help"], /Usage: burnlist hooks/u],
           [["hooks", "uninstall", "--help"], /Usage: burnlist hooks/u],
           [["hooks", "status", "--help"], /Usage: burnlist hooks/u],
    +      [["new", "--help"], /^Usage: burnlist new \[--repo \]/u],
         ]) {
           const result = run(context.directory, args);
           assert.equal(result.status, 0, args.join(" "));
    diff --git a/src/cli/loop-config-cli.mjs b/src/cli/loop-config-cli.mjs
    index 9dc2bec9..5642fdb4 100644
    --- a/src/cli/loop-config-cli.mjs
    +++ b/src/cli/loop-config-cli.mjs
    @@ -6,7 +6,7 @@ import { renderSetupStatus, setupStatus } from "../loops/config/setup.mjs";
     import { rawSha256 } from "../loops/dsl/hash.mjs";
     import { resolveUmbrella } from "./umbrella.mjs";
     
    -const loopUsage = "Usage: burnlist loop assign   [--repo ] | burnlist loop unassign  [--repo ] | burnlist loop view  [--repo ]\n       burnlist loop create  [--repo ]\n       burnlist loop next|claim  [--repo ]\n       burnlist loop report  (--outcome  | --result ) [--repo ]\n       burnlist loop abandon  --reason  [--repo ]\n       burnlist loop pause|stop|complete  [--repo ]\n       burnlist loop list [--repo ] | burnlist loop status|inspect  [--repo ]\n       burnlist loop reconcile  --recovery-proof  [--repo ]\n       burnlist loop capability inspect  [--repo ]\n       burnlist loop capability trust  --revision cp1-sha256: --grants  [--repo ]\n       burnlist loop setup status [--repo ]";
    +const loopUsage = "Usage: burnlist loop assign   [--repo ] | burnlist loop unassign  [--repo ] | burnlist loop view  [--repo ]\n       burnlist loop create  [--repo ]\n       burnlist loop next|claim  [--repo ]\n       burnlist loop report  (--outcome  | --result ) [--repo ]\n       burnlist loop abandon  --reason  [--repo ]\n       burnlist loop pause|stop|complete  [--repo ]\n       burnlist loop list [--repo ] (Runs only; use loop view  for assignments)\n       burnlist loop status|inspect  [--repo ]\n       burnlist loop reconcile  --recovery-proof  [--repo ]\n       burnlist loop capability inspect  [--repo ]\n       burnlist loop capability trust  --revision cp1-sha256: --grants  [--repo ]\n       burnlist loop setup status [--repo ]";
     
     function fail(message, exitCode = 2) { throw Object.assign(new Error(message), { exitCode }); }
     function parse(tokens, allowed = []) {
    diff --git a/src/cli/skills-register.mjs b/src/cli/skills-register.mjs
    index 137113c5..0f8a1237 100644
    --- a/src/cli/skills-register.mjs
    +++ b/src/cli/skills-register.mjs
    @@ -236,7 +236,19 @@ function formatGlobal(registration, dryRun) {
       return `Burnlist: ${verb} ${registration.source} -> ${registration.target}; global symlink (no repo exclude).`;
     }
     
    -export function registerSkills({ sourceRoot, scope = "repo", cwd = process.cwd(), env = process.env, agents, dryRun = false, commit = false, force = false, log = console.log, stageAtomic = stageAtomicText, beforeTargetMutation, afterExcludeWrite }) {
    +function warnLegacyCodexShadows(planned, env, warn) {
    +  const home = env.HOME || env.USERPROFILE;
    +  if (!home) return;
    +  for (const registration of planned.filter(({ agent }) => agent === "codex")) {
    +    const legacy = join(home, ".codex", "skills", registration.name);
    +    const stat = lstatOrNull(legacy);
    +    if (!stat) continue;
    +    if (stat.isSymbolicLink() && linkedSource(legacy) === registration.source) continue;
    +    warn(`Burnlist: ${legacy} may shadow the managed Codex skill at ${registration.target}; compare and remove or rename only the stale manual copy.`);
    +  }
    +}
    +
    +export function registerSkills({ sourceRoot, scope = "repo", cwd = process.cwd(), env = process.env, agents, dryRun = false, commit = false, force = false, log = console.log, warn = console.warn, stageAtomic = stageAtomicText, beforeTargetMutation, afterExcludeWrite }) {
       if (scope === "global" && commit) throw new Error("--commit is only valid for per-repository skill installs");
       const context = scope === "repo" ? gitContext(cwd) : null;
       const register = () => {
    @@ -302,6 +314,7 @@ export function registerSkills({ sourceRoot, scope = "repo", cwd = process.cwd()
           });
         }
         for (const registration of planned) log(scope === "global" ? formatGlobal(registration, dryRun) : formatInstall(registration, dryRun, commit));
    +    if (scope === "global") warnLegacyCodexShadows(planned, env, warn);
         return planned;
       };
       if (dryRun) return register();
    diff --git a/src/cli/skills-register.test.mjs b/src/cli/skills-register.test.mjs
    index 187153b9..8bd23743 100644
    --- a/src/cli/skills-register.test.mjs
    +++ b/src/cli/skills-register.test.mjs
    @@ -47,6 +47,27 @@ test("global dry-run describes Claude and Codex targets", () => {
       } finally { context.cleanup(); }
     });
     
    +test("global Codex registration warns when a legacy native skill can shadow it", () => {
    +  const context = fixture();
    +  try {
    +    const legacy = join(context.home, ".codex", "skills", "burnlist");
    +    mkdirSync(legacy, { recursive: true });
    +    writeFileSync(join(legacy, "SKILL.md"), "stale\n");
    +    const warnings = [];
    +    registerSkills({
    +      sourceRoot: join(repoRoot, "skills"),
    +      scope: "global",
    +      cwd: context.repo,
    +      env: { ...baseEnv, HOME: context.home, USERPROFILE: context.home },
    +      agents: ["codex"],
    +      log: () => {},
    +      warn: (message) => warnings.push(message),
    +    });
    +    assert.equal(warnings.length, 1);
    +    assert.match(warnings[0], /\.codex\/skills\/burnlist may shadow/u);
    +  } finally { context.cleanup(); }
    +});
    +
     test("repo dry-run describes both agent targets at the worktree root", () => {
       const context = fixture();
       try {
    diff --git a/src/loops/dsl/package-read.test.mjs b/src/loops/dsl/package-read.test.mjs
    index 7c1ba7b9..9eafd13a 100644
    --- a/src/loops/dsl/package-read.test.mjs
    +++ b/src/loops/dsl/package-read.test.mjs
    @@ -264,9 +264,9 @@ test("descriptor package diagnostics are merged and truncated after discovery, m
         const badCount = 97;
         const extras = Array.from({ length: badCount }, (_, index) => ` bad-${String(index).padStart(3, "0")}="x"`).join("");
         const malformed = files["review.loop"].toString()
    -      .replace('version="0.1.0"', "version=\"0\"")
    -      .replace('max-rounds="12"', `max-rounds="0"${extras}`)
    -      .replace('', '');
    +      .replace('version="0.2.0"', "version=\"0\"")
    +      .replace('max-rounds="6"', `max-rounds="0"${extras}`)
    +      .replace('', '');
     
         await writeFile(join(folder, "review.loop"), malformed);
         await writeFile(join(folder, "note.md"), "ignored\n");
    diff --git a/website/src/content/docs/loops.mdx b/website/src/content/docs/loops.mdx
    index c5539be1..107b52ad 100644
    --- a/website/src/content/docs/loops.mdx
    +++ b/website/src/content/docs/loops.mdx
    @@ -116,6 +116,13 @@ or execute checks. After a valid report, Burnlist automatically advances its
     trusted checks and graph-only nodes until the next agent claim or terminal
     state. The Checklist Oven only observes these updates.
     
    +`loop list` lists created Runs, not item assignments. Immediately after
    +assignment it may correctly return `[]`; use `loop view
    +item:#` to verify assignment truth. For shell-driven
    +hosts, redirect the large `loop claim` JSON to a private ignored file and
    +extract `.claim.claimId` from that file instead of printing the authority
    +envelope into chat.
    +
     The claim response carries a prepared execution envelope. A host executes that
     exact bounded input through its available mechanism, preserves its full claim
     identity in one `burnlist-loop-host-report@1`, and submits the report by claim
    
    From deb55998acfc63569774d637a14504a16b7348bb Mon Sep 17 00:00:00 2001
    From: Juan Cruz Fortunatti 
    Date: Sat, 25 Jul 2026 19:15:09 +0200
    Subject: [PATCH 22/37] fix: compact loop feedback routing
    
    ---
     .../components/LoopGraph/LoopGraph.test.ts    | 55 +++++++++++++++++++
     .../LoopGraph/serial-compact-layout.ts        | 50 ++++++++---------
     2 files changed, 77 insertions(+), 28 deletions(-)
    
    diff --git a/dashboard/src/components/LoopGraph/LoopGraph.test.ts b/dashboard/src/components/LoopGraph/LoopGraph.test.ts
    index d6b27edc..a7295d16 100644
    --- a/dashboard/src/components/LoopGraph/LoopGraph.test.ts
    +++ b/dashboard/src/components/LoopGraph/LoopGraph.test.ts
    @@ -146,3 +146,58 @@ test("compact topology keeps the serial dogfood Loop labelled within tablet widt
       assert.ok(narrow.lines.length > wide.lines.length);
       assert.ok(new Set([...extraWide.positions.values()].map((position) => position.y)).size >= 2);
     });
    +
    +test("canonical review and branch loops use balanced rows with local feedback", () => {
    +  const serial = (ids: string[], edges: LoopGraphProjection["graph"]["edges"]) => {
    +    const run = projection({
    +      graph: {
    +        entry: "start",
    +        nodes: ids.map((id) => ({
    +          id,
    +          kind: id === "validate" ? "check"
    +            : id === "converged" ? "gate"
    +              : id === "completed" ? "terminal" : "agent",
    +          role: id === "review" ? "reviewer" : "maker",
    +          terminalState: id === "completed" ? "converged" : undefined,
    +        })),
    +        edges,
    +      },
    +    });
    +    return layoutCompactLoop(run, { availableCharacters: 72, showLabels: true }).lines;
    +  };
    +  const review = serial(
    +    ["start", "implement", "validate", "review", "converged", "completed"],
    +    [
    +      { from: "start", on: "begin", to: "implement" },
    +      { from: "implement", on: "complete", to: "validate" },
    +      { from: "validate", on: "pass", to: "review" },
    +      { from: "validate", on: "fail", to: "implement" },
    +      { from: "review", on: "approve", to: "converged" },
    +      { from: "review", on: "reject", to: "implement" },
    +      { from: "converged", on: "pass", to: "completed" },
    +    ],
    +  );
    +  const branch = serial(
    +    ["start", "plan", "branches", "merge", "validate", "review", "converged", "completed"],
    +    [
    +      { from: "start", on: "begin", to: "plan" },
    +      { from: "plan", on: "complete", to: "branches" },
    +      { from: "branches", on: "complete", to: "merge" },
    +      { from: "merge", on: "complete", to: "validate" },
    +      { from: "validate", on: "pass", to: "review" },
    +      { from: "validate", on: "fail", to: "branches" },
    +      { from: "review", on: "approve", to: "converged" },
    +      { from: "review", on: "reject", to: "plan" },
    +      { from: "converged", on: "pass", to: "completed" },
    +    ],
    +  );
    +  for (const lines of [review, branch]) {
    +    assert.ok(Math.max(...lines.map((line) => line.length)) <= 72);
    +    assert.ok(lines.length <= 7);
    +    assert.doesNotMatch(lines.join("\n"), /^[┌├└]/mu);
    +    assert.match(lines[0], /^S .*▶/u);
    +    assert.match(lines.at(-1)!, /^\s*C .*G/u);
    +  }
    +  assert.match(review.join("\n"), /fail.*reject/su);
    +  assert.match(branch.join("\n"), /fail.*reject|reject.*fail/su);
    +});
    diff --git a/dashboard/src/components/LoopGraph/serial-compact-layout.ts b/dashboard/src/components/LoopGraph/serial-compact-layout.ts
    index ce1451e8..0e791631 100644
    --- a/dashboard/src/components/LoopGraph/serial-compact-layout.ts
    +++ b/dashboard/src/components/LoopGraph/serial-compact-layout.ts
    @@ -76,36 +76,31 @@ export function layoutSerialCompact(
         ? Math.max(0, ...primary.map((edge) => edge.on.length)) : 0;
       const slot = Math.max(9, labelWidth + 9, ...path.map((id) => symbols.get(id)!.length + 7));
       const available = Math.max(24, options.availableCharacters ?? 72);
    -  const destinations = [...new Set(feedback.map((edge) => edge.to))];
    -  const leftMargin = destinations.length ? destinations.length * 2 + 2 : 0;
    -  const minimumRows = path.length >= 7 ? 2 : 1;
       const candidates = Array.from(
    -    { length: Math.min(5, Math.ceil(path.length / 2)) - minimumRows + 1 },
    -    (_, index) => minimumRows + index,
    +    { length: Math.max(1, Math.min(5, path.length) - 1) },
    +    (_, index) => index + 2,
       );
    -  const rowCount = candidates.map((rows) => {
    -    const columns = Math.ceil(path.length / rows);
    -    const estimatedWidth = leftMargin + (columns - 1) * slot + 2;
    +  const perRow = candidates.map((columns) => {
    +    const rows = Math.ceil(path.length / columns);
    +    const estimatedWidth = (columns - 1) * slot + 2;
         const estimatedHeight = rows * 5;
         const overflow = Math.max(0, estimatedWidth - available);
         const aspect = estimatedWidth / Math.max(1, estimatedHeight * 2);
    -    const emptySlots = rows * columns - path.length;
         return {
    -      rows,
    +      columns,
           score: overflow * 1_000
             + Math.abs(Math.log(Math.max(.01, aspect) / 2.2)) * 20
    -        + emptySlots * 1.5 + rows * .15,
    +        + Math.abs(columns - 4) * 2 + rows * .15,
         };
    -  }).sort((left, right) => left.score - right.score || left.rows - right.rows)[0].rows;
    -  const perRow = Math.ceil(path.length / rowCount);
    +  }).sort((left, right) => left.score - right.score || right.columns - left.columns)[0].columns;
    +  const rowCount = Math.ceil(path.length / perRow);
       const rowHeight = 4 + Math.max(0, ...Array.from({ length: rowCount }, (_, row) =>
    -    feedback.filter((edge) => Math.floor(pathIndex.get(edge.from)! / perRow) === row).length));
    +    feedback.filter((edge) => Math.floor(pathIndex.get(edge.to)! / perRow) === row).length));
       const positions = new Map();
       path.forEach((id, index) => {
         const row = Math.floor(index / perRow), offset = index % perRow;
    -    const count = Math.min(perRow, path.length - row * perRow);
    -    const column = row % 2 ? count - offset - 1 : offset;
    -    positions.set(id, { x: leftMargin + column * slot, y: row * rowHeight });
    +    const column = row % 2 ? perRow - offset - 1 : offset;
    +    positions.set(id, { x: column * slot, y: row * rowHeight });
       });
       const width = Math.min(available, Math.max(...[...positions.values()].map((point) => point.x)) + slot);
       const graph = canvas(rowCount * rowHeight, width);
    @@ -125,21 +120,20 @@ export function layoutSerialCompact(
         }
       });
     
    -  const rails = new Map(destinations.map((id, index) => [id, index * 2]));
    -  const sourceOffsets = new Map();
    +  const targetRowOffsets = new Map();
       feedback.forEach((edge) => {
         const from = positions.get(edge.from)!, to = positions.get(edge.to)!;
    -    const row = Math.floor(pathIndex.get(edge.from)! / perRow);
    -    const offset = sourceOffsets.get(row) ?? 0;
    -    sourceOffsets.set(row, offset + 1);
    -    const sourceY = from.y + 2 + offset, railX = rails.get(edge.to)!;
    -    graph.vertical(from.x, from.y + 1, sourceY);
    -    graph.horizontal(railX, from.x, sourceY);
    -    graph.vertical(railX, to.y + 1, sourceY);
    -    graph.horizontal(railX, to.x, to.y + 1);
    +    const targetRow = Math.floor(pathIndex.get(edge.to)! / perRow);
    +    const offset = targetRowOffsets.get(targetRow) ?? 0;
    +    targetRowOffsets.set(targetRow, offset + 1);
    +    const feedbackY = to.y + 2 + offset;
    +    const fromAttachY = feedbackY < from.y ? from.y - 1 : from.y + 1;
    +    graph.vertical(from.x, Math.min(fromAttachY, feedbackY), Math.max(fromAttachY, feedbackY));
    +    graph.horizontal(to.x, from.x, feedbackY);
    +    graph.vertical(to.x, to.y + 1, feedbackY);
         graph.put(to.x, to.y + 1, "▲");
         if (options.showLabels)
    -      placeHorizontalLabel(graph, railX, from.x, sourceY, edge.on);
    +      placeHorizontalLabel(graph, to.x, from.x, feedbackY, edge.on);
       });
     
       for (const [id, position] of positions) graph.text(position.x, position.y, symbols.get(id)!);
    
    From 8fca0712927ebf1a31130f61ac49ca6d56c42848 Mon Sep 17 00:00:00 2001
    From: Juan Cruz Fortunatti 
    Date: Sat, 25 Jul 2026 19:21:41 +0200
    Subject: [PATCH 23/37] fix: keep loop labels clear of return paths
    
    ---
     dashboard/src/components/LoopGraph/LoopGraph.test.ts  |  6 ++++--
     .../src/components/LoopGraph/serial-compact-layout.ts | 11 ++++++++++-
     2 files changed, 14 insertions(+), 3 deletions(-)
    
    diff --git a/dashboard/src/components/LoopGraph/LoopGraph.test.ts b/dashboard/src/components/LoopGraph/LoopGraph.test.ts
    index a7295d16..0d55022a 100644
    --- a/dashboard/src/components/LoopGraph/LoopGraph.test.ts
    +++ b/dashboard/src/components/LoopGraph/LoopGraph.test.ts
    @@ -192,12 +192,14 @@ test("canonical review and branch loops use balanced rows with local feedback",
         ],
       );
       for (const lines of [review, branch]) {
    +    const drawing = lines.join("\n");
         assert.ok(Math.max(...lines.map((line) => line.length)) <= 72);
         assert.ok(lines.length <= 7);
    -    assert.doesNotMatch(lines.join("\n"), /^[┌├└]/mu);
    +    assert.doesNotMatch(drawing, /^[┌├└]/mu);
    +    assert.doesNotMatch(drawing, /rej[│┼├┤┬┴]ect|fa[│┼├┤┬┴]il/u);
         assert.match(lines[0], /^S .*▶/u);
         assert.match(lines.at(-1)!, /^\s*C .*G/u);
       }
       assert.match(review.join("\n"), /fail.*reject/su);
    -  assert.match(branch.join("\n"), /fail.*reject|reject.*fail/su);
    +  assert.match(branch.join("\n"), /fail.*reject/su);
     });
    diff --git a/dashboard/src/components/LoopGraph/serial-compact-layout.ts b/dashboard/src/components/LoopGraph/serial-compact-layout.ts
    index 0e791631..09663df2 100644
    --- a/dashboard/src/components/LoopGraph/serial-compact-layout.ts
    +++ b/dashboard/src/components/LoopGraph/serial-compact-layout.ts
    @@ -102,6 +102,15 @@ export function layoutSerialCompact(
         const column = row % 2 ? perRow - offset - 1 : offset;
         positions.set(id, { x: column * slot, y: row * rowHeight });
       });
    +  const routedFeedback = [...feedback].sort((left, right) => {
    +    const leftFrom = positions.get(left.from)!, leftTo = positions.get(left.to)!;
    +    const rightFrom = positions.get(right.from)!, rightTo = positions.get(right.to)!;
    +    const leftSpan = Math.abs(leftFrom.x - leftTo.x);
    +    const rightSpan = Math.abs(rightFrom.x - rightTo.x);
    +    return leftSpan - rightSpan
    +      || rightTo.x - leftTo.x
    +      || pathIndex.get(left.from)! - pathIndex.get(right.from)!;
    +  });
       const width = Math.min(available, Math.max(...[...positions.values()].map((point) => point.x)) + slot);
       const graph = canvas(rowCount * rowHeight, width);
     
    @@ -121,7 +130,7 @@ export function layoutSerialCompact(
       });
     
       const targetRowOffsets = new Map();
    -  feedback.forEach((edge) => {
    +  routedFeedback.forEach((edge) => {
         const from = positions.get(edge.from)!, to = positions.get(edge.to)!;
         const targetRow = Math.floor(pathIndex.get(edge.to)! / perRow);
         const offset = targetRowOffsets.get(targetRow) ?? 0;
    
    From 1d076c37456ea99141de92d26ceefc0c107f6e45 Mon Sep 17 00:00:00 2001
    From: Juan Cruz Fortunatti 
    Date: Sat, 25 Jul 2026 20:09:24 +0200
    Subject: [PATCH 24/37] feat: simplify host loop protocol (E1)
    
    ---
     bin/burnlist.mjs                             |   4 +-
     skills/burnlist/references/host-execution.md | 135 ++++++++-----------
     src/cli/commands-help.test.mjs               |   6 +-
     src/cli/loop-cli.mjs                         |  20 ++-
     src/cli/loop-config-cli.mjs                  |   2 +-
     src/cli/loop-runtime-cli.test.mjs            |  37 +++++
     src/loops/assignment/assignment.test.mjs     |  13 ++
     src/loops/assignment/hazards.mjs             |   4 +-
     src/loops/contracts/host-execution.mjs       |  36 +++++
     src/loops/contracts/host-execution.test.mjs  |  35 ++++-
     src/loops/run/controller.mjs                 |  22 ++-
     src/loops/run/host-execution.mjs             |  38 +++++-
     src/loops/run/run-store.mjs                  |  30 ++++-
     13 files changed, 282 insertions(+), 100 deletions(-)
    
    diff --git a/bin/burnlist.mjs b/bin/burnlist.mjs
    index ee34c67d..c87cb39a 100755
    --- a/bin/burnlist.mjs
    +++ b/bin/burnlist.mjs
    @@ -130,7 +130,9 @@ Usage:
       burnlist loop unassign  [--repo ]
       burnlist loop view  [--repo ]
       burnlist loop create  [--repo ]
    -  burnlist loop next|claim  [--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 ]
    diff --git a/skills/burnlist/references/host-execution.md b/skills/burnlist/references/host-execution.md
    index 15cbb9f5..eae695c9 100644
    --- a/skills/burnlist/references/host-execution.md
    +++ b/skills/burnlist/references/host-execution.md
    @@ -4,112 +4,89 @@ Use this reference when a host executes a prepared agent node. It is the whole
     provider-neutral contract; choose the matching `loop-providers/` recipe before
     invocation. If subscriptions are unknown, read `loop-provider-setup.md` first.
     
    -## Claim, execute, report
    +## Next, execute, submit
     
    -1. Create and inspect the Run, then claim its current agent node:
    +1. Create the Run, then ask Burnlist for its next prepared host task:
     
        ```sh
        burnlist loop create item:#
    -   burnlist loop claim run: > .local/burnlist/claim.json
    -   node -e 'const c=require(process.argv[1]); console.log(c.claim.claimId)' \
    -     .local/burnlist/claim.json
    +   burnlist loop next run:
        ```
     
    -   `claim` returns canonical JSON containing a `claim` and an `execution`
    -   envelope. Treat both as opaque, bounded, single-use authority. Do not alter
    -   or reconstruct their fields. Capture it to a private ignored file as above
    -   instead of printing the large authority envelope into chat. The second
    -   command prints the `ClaimRef`; it does not replace the saved execution
    -   envelope needed by the provider.
    -
    -2. Decode, inspect, and execute the exact prepared invocation. `execution` is
    -   a canonical `burnlist-loop-host-execution@1` object: base64-decode its
    -   `invocationInput` to the canonical `burnlist-loop-invocation-input@1` JSON.
    -   That object contains base64 `instructionBytes`, `itemText`, and
    -   `candidateContext`; decode them as UTF-8 only for the executor. It also
    -   declares the node `mode`, `role`, `authority`, legal outcomes, and required
    -   evidence ids. Keep `execution`, its
    -   `dispatchAuthority`, and every identity field byte-for-byte unchanged. Do
    -   not edit, reserialize, or replace those authority-bearing inputs.
    -
    -   The host may choose its provider, subagent arrangement, and additional
    -   non-authoritative context, but must preserve the complete correlation tuple:
    -
    -   ```text
    -   runId + nodeId + attempt + claimId + invocationId + assignmentId
    -   + recipeRevision + policyRevision + inputCandidate
    -   ```
    +   `next` atomically claims the current agent node and returns one
    +   `burnlist-loop-host-task@1` packet. Give its `prompt` to the selected native
    +   agent or provider. The packet contains the role, read/write authority, legal
    +   outcomes, and prepared task context; it does not expose claim, invocation,
    +   assignment, dispatch-authority, or graph-edge mechanics.
     
    -   Execute the supplied instructions against the exact assigned item and
    -   candidate context. Respect read/write authority and legal outcomes. Do not choose
    -   a graph edge, declare a destination, or run a deterministic check; Burnlist
    -   owns all three.
    +2. Execute the supplied prompt against the assigned repository. The worker
    +   needs no Burnlist skill, CLI commands, claim identity, graph knowledge, or
    +   telemetry instructions. Respect the packet's authority and legal outcomes.
    +   Do not ask the worker to choose a graph edge or run the trusted check;
    +   Burnlist owns both.
     
    -3. For the common successful path, inspect the provider result and report the
    -   legal outcome directly. Burnlist copies the sealed identity tuple from the
    -   live claim, so the host does not hand-author authority-bearing JSON:
    +3. Submit only the worker's semantic outcome by RunRef. Burnlist binds it to
    +   the sealed claim, validates the node mode, selects the declared graph edge,
    +   advances deterministic checks and gates, and stops at the next host task or
    +   terminal state:
     
        ```sh
    -   burnlist loop report cl1-sha256: --outcome complete
    -   burnlist loop report cl1-sha256: --outcome approve
    +   burnlist loop submit run: --outcome complete
    +   burnlist loop submit run: --outcome approve
        ```
     
        Use `complete` only after successful task execution and `approve` only after
    -   an independent read-only review. The command still fails closed on a stale
    -   claim, wrong node mode, or candidate drift.
    -
    -4. Findings, rejection, escalation, or telemetry require one canonical
    -   `burnlist-loop-host-report@1` whose `agent-result@1`
    -   is bound to that same tuple, with only a legal node-mode outcome. Copy the
    -   identity values exactly from `execution`; do not use these placeholders as
    -   invented values. A task accepts `complete`; review accepts `approve`,
    -   `reject`, or `escalate`. A task has empty `findings` and
    -   `resolvedFindingIds`.
    -
    -   Task report with unavailable telemetry:
    -
    -   ```json
    -   {"schema":"burnlist-loop-host-report@1","result":{"schema":"agent-result@1","runId":"","nodeId":"","attempt":,"claimId":"","assignmentId":"","invocationId":"","recipeRevision":"","policyRevision":"","inputCandidate":"","outcome":"complete","findings":[],"resolvedFindingIds":[]},"telemetry":null}
    -   ```
    +   an independent read-only review. Stale claims, illegal outcomes, candidate
    +   drift, and conflicting submissions fail closed.
     
    -   Reviewer report with the full optional telemetry shape (replace only values
    -   the host observed; the sample timing and token fields are intentionally
    -   unavailable):
    +4. Rejection, escalation, findings, or optional truthful host telemetry use one
    +   small canonical `burnlist-loop-host-result@1` file. It contains no authority
    +   identities:
     
        ```json
    -   {"schema":"burnlist-loop-host-report@1","result":{"schema":"agent-result@1","runId":"","nodeId":"","attempt":,"claimId":"","assignmentId":"","invocationId":"","recipeRevision":"","policyRevision":"","inputCandidate":"","outcome":"approve","findings":[],"resolvedFindingIds":[]},"telemetry":{"schema":"burnlist-loop-host-telemetry@1","provenance":"host-reported","executor":"provider-executor","displayName":null,"provider":null,"model":null,"effort":null,"startedAt":null,"completedAt":null,"inputTokens":null,"outputTokens":null}}
    +   {"schema":"burnlist-loop-host-result@1","outcome":"reject","findings":[],"resolvedFindingIds":[],"telemetry":null}
        ```
     
    -   For a `reject` or `escalate`, carry forward every still-open finding, add
    -   any new content-addressed finding, and resolve only currently open ids.
    -   Optional telemetry always uses `burnlist-loop-host-telemetry@1` with
    -   `provenance: "host-reported"`; unknown values remain `null`, never guessed.
    -
    -5. Write that detailed report to a regular, non-symlink file no larger than 256 KiB and
    -   submit it by the claim id:
    -
        ```sh
    -   burnlist loop report cl1-sha256: --result ./host-report.json
    +   burnlist loop submit run: --result ./host-result.json
        ```
     
    -   An identical retransmission is safe. A conflicting replay, expired or stale
    -   claim, workspace/candidate drift, illegal outcome, or identity mismatch fails
    -   closed. Inspect the Run again rather than editing a rejected report.
    +   The file must be a regular non-symlink no larger than 256 KiB. For a
    +   rejection or escalation, carry forward every still-open finding, add only
    +   content-addressed findings, and resolve only open ids. Unknown telemetry
    +   fields remain `null`; never guess them.
     
    -6. If one provider is unavailable before it mutates the workspace, keep the
    +5. Repeat `next` and `submit` until the Run is terminal, then use
    +   `burnlist loop complete run:` to atomically apply a converged Run.
    +
    +If one provider is unavailable before it mutates the workspace, keep the
        provider-neutral claim and retry through another ready provider after the
        first process has definitely exited. Do not abandon merely because a quota
        was exhausted. If the host cannot finish or process cleanup is uncertain,
    -   do not report a made-up result. Resolve its live
    -   claim once:
    +   do not submit a made-up result. Use the recovery commands below.
     
    -   ```sh
    -   burnlist loop abandon cl1-sha256: --reason host-cancelled
    -   ```
    +## Recovery and diagnostics
    +
    +`claim` and `report` preserve the lower-level authority-envelope protocol for
    +diagnostics and recovery. They are not needed for ordinary execution:
    +
    +```sh
    +burnlist loop claim run:
    +burnlist loop report cl1-sha256: --outcome complete
    +burnlist loop report cl1-sha256: --result ./host-report.json
    +```
    +
    +Treat the returned claim and execution envelope as opaque, bounded, single-use
    +authority. Never reconstruct its identity tuple or add a graph destination.
    +If a live host must be resolved, abandon its ClaimRef once:
    +
    +```sh
    +burnlist loop abandon cl1-sha256: --reason host-cancelled
    +```
     
    -   The only reasons are `host-cancelled`, `host-lost`, and (after expiry)
    -   `expired`. Recovery can terminalize as `needs-human`; it does not make the
    -   host the transition authority.
    +The only reasons are `host-cancelled`, `host-lost`, and (after expiry)
    +`expired`. Recovery can terminalize as `needs-human`; it does not make the host
    +the transition authority.
     
     ## Ownership and observability
     
    diff --git a/src/cli/commands-help.test.mjs b/src/cli/commands-help.test.mjs
    index 0cab4f24..29758fef 100644
    --- a/src/cli/commands-help.test.mjs
    +++ b/src/cli/commands-help.test.mjs
    @@ -48,7 +48,9 @@ test("top-level and Oven help expose the validated use and set flow", () => {
         assert.match(top.stdout, /burnlist oven <[^\n]*use[^\n]*set[^\n]*>/u);
         assert.match(top.stdout, /burnlist loop view  \[--repo \]/u);
         assert.match(top.stdout, /burnlist loop create  \[--repo \]/u);
    -    assert.match(top.stdout, /burnlist loop next\|claim  \[--repo \]/u);
    +    assert.match(top.stdout, /burnlist loop next  \[--repo \]/u);
    +    assert.match(top.stdout, /burnlist loop submit  \(--outcome  \| --result \) \[--repo \]/u);
    +    assert.match(top.stdout, /burnlist loop claim  \[--repo \] \(recovery\/diagnostics\)/u);
         assert.match(top.stdout, /burnlist loop report  \(--outcome  \| --result \) \[--repo \]/u);
         assert.match(top.stdout, /burnlist loop abandon  --reason  \[--repo \]/u);
         assert.match(top.stdout, /burnlist loop list \[--repo \]/u);
    @@ -91,7 +93,7 @@ test("nested Loop help snapshots every Stage 1 control", () => {
       try {
         const result = run(context.directory, ["loop", "--help"]);
         assert.equal(result.status, 0, result.stderr);
    -    for (const command of ["create", "next|claim", "report  \\(--outcome  \\| --result \\)", "abandon  --reason ", "list", "pause|stop|complete", "status|inspect", "reconcile"]) {
    +    for (const command of ["create", "next", "submit  \\(--outcome  \\| --result \\)", "claim", "report  \\(--outcome  \\| --result \\)", "abandon  --reason ", "list", "pause|stop|complete", "status|inspect", "reconcile"]) {
           assert.match(result.stdout, new RegExp(`burnlist loop ${command}`, "u"));
         }
         assert.doesNotMatch(result.stdout, /loop run|resume/u);
    diff --git a/src/cli/loop-cli.mjs b/src/cli/loop-cli.mjs
    index de0c490d..20988527 100644
    --- a/src/cli/loop-cli.mjs
    +++ b/src/cli/loop-cli.mjs
    @@ -36,7 +36,8 @@ function options(tokens) {
         else if (tokens[index] === "--outcome") {
           if (outcome !== null) throw usageError("--outcome must be specified at most once.");
           outcome = tokens[++index];
    -      if (!["complete", "approve"].includes(outcome ?? "")) throw usageError("--outcome requires complete or approve.");
    +      if (!["complete", "approve", "reject", "escalate"].includes(outcome ?? ""))
    +        throw usageError("--outcome requires complete, approve, reject, or escalate.");
         }
         else if (tokens[index].startsWith("--")) throw usageError(`Unknown option: ${tokens[index]}`);
         else positionals.push(tokens[index]);
    @@ -45,8 +46,9 @@ function options(tokens) {
     }
     function validateVerbOptions(verb, opts) {
       if (opts.recoveryProof && verb !== "reconcile") throw usageError();
    -  if ((opts.resultFile || opts.outcome) && verb !== "report"
    -    || verb === "report" && Boolean(opts.resultFile) === Boolean(opts.outcome)) throw usageError();
    +  if ((opts.resultFile || opts.outcome) && !["report", "submit"].includes(verb)
    +    || ["report", "submit"].includes(verb) && Boolean(opts.resultFile) === Boolean(opts.outcome)
    +    || verb === "report" && opts.outcome && !["complete", "approve"].includes(opts.outcome)) throw usageError();
       if (opts.reason && verb !== "abandon" || verb === "abandon" && !opts.reason) throw usageError();
     }
     function simpleReport(envelopeBytes, outcome) {
    @@ -57,6 +59,12 @@ function simpleReport(envelopeBytes, outcome) {
         schema: "agent-result@1", ...result, outcome, findings: [], resolvedFindingIds: [],
       }, telemetry: null })}\n`);
     }
    +function simpleSemanticResult(outcome) {
    +  return Buffer.from(`${JSON.stringify({
    +    schema: "burnlist-loop-host-result@1", outcome,
    +    findings: [], resolvedFindingIds: [], telemetry: null,
    +  })}\n`);
    +}
     function resultBytes(path) {
       let fd;
       try {
    @@ -98,7 +106,7 @@ export async function runLoopCli(tokens, { runReader, runnerFor, stdout = proces
         const result = completeLoopRun({ repoRoot: opts.repo, runId: opts.positionals[0] });
         stdout.write(`${JSON.stringify({ schema: "burnlist-loop-completion@1", ...result })}\n`); return result;
       }
    -  if (["list", "status", "inspect", "next", "claim", "report", "abandon", "pause", "stop", "reconcile"].includes(verb)) {
    +  if (["list", "status", "inspect", "next", "claim", "submit", "report", "abandon", "pause", "stop", "reconcile"].includes(verb)) {
         const allowed = verb === "list" ? 0 : 1;
         if (opts.positionals.length !== allowed) throw usageError();
         const store = runStore(opts.repo);
    @@ -111,8 +119,10 @@ export async function runLoopCli(tokens, { runReader, runnerFor, stdout = proces
         const result = verb === "list" ? controller.list()
           : verb === "status" ? controller.status(opts.positionals[0])
           : verb === "inspect" ? controller.inspect(opts.positionals[0])
    -      : verb === "next" ? controller.inspect(opts.positionals[0])
    +      : verb === "next" ? controller.next(opts.positionals[0])
           : verb === "claim" ? publicClaim(controller.claim(opts.positionals[0]))
    +      : verb === "submit" ? await controller.submit(opts.positionals[0],
    +        opts.resultFile ? resultBytes(opts.resultFile) : simpleSemanticResult(opts.outcome))
           : verb === "report" ? await controller.report(opts.positionals[0],
             opts.resultFile ? resultBytes(opts.resultFile) : simpleReport(
               controller.readClaim(store.resolveClaimRef(opts.positionals[0]))?.envelope, opts.outcome))
    diff --git a/src/cli/loop-config-cli.mjs b/src/cli/loop-config-cli.mjs
    index 5642fdb4..68524251 100644
    --- a/src/cli/loop-config-cli.mjs
    +++ b/src/cli/loop-config-cli.mjs
    @@ -6,7 +6,7 @@ import { renderSetupStatus, setupStatus } from "../loops/config/setup.mjs";
     import { rawSha256 } from "../loops/dsl/hash.mjs";
     import { resolveUmbrella } from "./umbrella.mjs";
     
    -const loopUsage = "Usage: burnlist loop assign   [--repo ] | burnlist loop unassign  [--repo ] | burnlist loop view  [--repo ]\n       burnlist loop create  [--repo ]\n       burnlist loop next|claim  [--repo ]\n       burnlist loop report  (--outcome  | --result ) [--repo ]\n       burnlist loop abandon  --reason  [--repo ]\n       burnlist loop pause|stop|complete  [--repo ]\n       burnlist loop list [--repo ] (Runs only; use loop view  for assignments)\n       burnlist loop status|inspect  [--repo ]\n       burnlist loop reconcile  --recovery-proof  [--repo ]\n       burnlist loop capability inspect  [--repo ]\n       burnlist loop capability trust  --revision cp1-sha256: --grants  [--repo ]\n       burnlist loop setup status [--repo ]";
    +const loopUsage = "Usage: burnlist loop assign   [--repo ] | burnlist loop unassign  [--repo ] | burnlist loop view  [--repo ]\n       burnlist loop create  [--repo ]\n       burnlist loop next  [--repo ]\n       burnlist loop submit  (--outcome  | --result ) [--repo ]\n       burnlist loop claim  [--repo ] (recovery/diagnostics)\n       burnlist loop report  (--outcome  | --result ) [--repo ] (recovery/diagnostics)\n       burnlist loop abandon  --reason  [--repo ]\n       burnlist loop pause|stop|complete  [--repo ]\n       burnlist loop list [--repo ] (Runs only; use loop view  for assignments)\n       burnlist loop status|inspect  [--repo ]\n       burnlist loop reconcile  --recovery-proof  [--repo ]\n       burnlist loop capability inspect  [--repo ]\n       burnlist loop capability trust  --revision cp1-sha256: --grants  [--repo ]\n       burnlist loop setup status [--repo ]";
     
     function fail(message, exitCode = 2) { throw Object.assign(new Error(message), { exitCode }); }
     function parse(tokens, allowed = []) {
    diff --git a/src/cli/loop-runtime-cli.test.mjs b/src/cli/loop-runtime-cli.test.mjs
    index f5d35e92..db91c7d4 100644
    --- a/src/cli/loop-runtime-cli.test.mjs
    +++ b/src/cli/loop-runtime-cli.test.mjs
    @@ -86,6 +86,43 @@ test("simple successful outcomes do not require a hand-authored report", (t) =>
       assert.equal(JSON.parse(command(repo, ["status", runId])).state, "converged");
     });
     
    +test("next and submit hide claim mechanics while advancing the Run", (t) => {
    +  const directory = mkdtempSync(join(tmpdir(), "host-next-submit-loop-cli-"));
    +  t.after(() => rmSync(directory, { recursive: true, force: true }));
    +  const { repo } = createProductionRunAuthority(join(directory, "repo"));
    +  const runId = created(repo), visited = [];
    +  for (let attempts = 0; attempts < 12; attempts += 1) {
    +    if (JSON.parse(command(repo, ["status", runId])).state === "converged") break;
    +    const raw = command(repo, ["next", runId]), task = JSON.parse(raw);
    +    visited.push(task.nodeId);
    +    assert.equal(task.schema, "burnlist-loop-host-task@1");
    +    assert.match(task.prompt, /Assigned item:/u);
    +    assert.doesNotMatch(raw, /claimId|invocationId|assignmentId|dispatchAuthority|invocationInput/u);
    +    command(repo, ["submit", runId, "--outcome",
    +      task.mode === "review" ? "approve" : "complete"]);
    +  }
    +  assert.deepEqual(visited, ["implement", "review"]);
    +  assert.equal(JSON.parse(command(repo, ["status", runId])).state, "converged");
    +});
    +
    +test("submit rejects missing tasks, illegal outcomes, and duplicate conflicts", (t) => {
    +  const directory = mkdtempSync(join(tmpdir(), "host-submit-closed-loop-cli-"));
    +  t.after(() => rmSync(directory, { recursive: true, force: true }));
    +  const { repo } = createProductionRunAuthority(join(directory, "repo"));
    +  const runId = created(repo);
    +  const missing = invoke(repo, ["submit", runId, "--outcome", "complete"]);
    +  assert.equal(missing.status, 1);
    +  assert.match(missing.stderr, /no active host task/u);
    +  command(repo, ["next", runId]);
    +  const illegal = invoke(repo, ["submit", runId, "--outcome", "approve"]);
    +  assert.equal(illegal.status, 1);
    +  assert.match(illegal.stderr, /not allowed/u);
    +  command(repo, ["submit", runId, "--outcome", "complete"]);
    +  const duplicate = invoke(repo, ["submit", runId, "--outcome", "complete"]);
    +  assert.equal(duplicate.status, 1);
    +  assert.match(duplicate.stderr, /no active host task/u);
    +});
    +
     test("a claimed provider cannot be stolen and reviewer candidate drift rejects its report", (t) => {
       const directory = mkdtempSync(join(tmpdir(), "host-claim-loop-cli-"));
       t.after(() => rmSync(directory, { recursive: true, force: true }));
    diff --git a/src/loops/assignment/assignment.test.mjs b/src/loops/assignment/assignment.test.mjs
    index bc9dd448..a1645b83 100644
    --- a/src/loops/assignment/assignment.test.mjs
    +++ b/src/loops/assignment/assignment.test.mjs
    @@ -180,6 +180,19 @@ test("repository hazard authority reads production Runs and permits only safe te
       } finally { context.cleanup(); }
     });
     
    +test("repository hazard authority scopes sealed authority corruption to its item", () => {
    +  const context = fixture();
    +  try {
    +    const other = "item:260710-001#OTHER", runId = "run:01arz3ndektsv4rrffq69g5fax";
    +    const store = runStore(context.repo);
    +    store.createRun({ runId, itemRef: other, graph: testGraph });
    +    writeFileSync(store.paths.authorityPath(runId), "{}\n");
    +    assert.deepEqual(repositoryHazardAuthority(context.repo)({ itemRef: ref() }), []);
    +    assert.throws(() => repositoryHazardAuthority(context.repo)({ itemRef: other }),
    +      /E_LOOP_HAZARD_CORRUPT/u);
    +  } finally { context.cleanup(); }
    +});
    +
     test("artifact is immutable, contained, and resolver selects only its declared authority", async () => {
       const context = fixture();
       try {
    diff --git a/src/loops/assignment/hazards.mjs b/src/loops/assignment/hazards.mjs
    index 81d34f79..731c814f 100644
    --- a/src/loops/assignment/hazards.mjs
    +++ b/src/loops/assignment/hazards.mjs
    @@ -17,10 +17,10 @@ export function repositoryHazardAuthority(repoRoot) {
       return ({ itemRef }) => {
         const item = parseItemRef(itemRef);
         const store = runStore(repoRoot); let runs, current;
    -    try { runs = store.list(); current = store.readCurrentRun(item.selector); }
    +    try { runs = store.listForItem(item.selector); current = store.readCurrentRun(item.selector); }
         catch (error) { bad("E_LOOP_HAZARD_CORRUPT", error?.message ?? "production Run state is unreadable"); }
         if (!Array.isArray(runs)) bad("E_LOOP_HAZARD_CORRUPT", "production Run state is invalid");
    -    const matching = runs.filter((run) => run?.itemRef === item.selector);
    +    const matching = runs;
         if (current && !matching.some((run) => run.runId === current.runId)) return [`unpublished current Run ${current.runId}`];
         if (matching.some((run) => typeof run.runId !== "string" || typeof run.state !== "string")) bad("E_LOOP_HAZARD_CORRUPT", "production Run projection is invalid");
         return matching.filter((run) => !SAFE_TERMINALS.has(run.state)).map((run) => {
    diff --git a/src/loops/contracts/host-execution.mjs b/src/loops/contracts/host-execution.mjs
    index c2f127f6..e43db01c 100644
    --- a/src/loops/contracts/host-execution.mjs
    +++ b/src/loops/contracts/host-execution.mjs
    @@ -4,6 +4,7 @@ import { validateAgentResult, validateDispatchAuthority, validateInvocationInput
     
     const KEYS = ["schema", "runId", "nodeId", "attempt", "claimId", "assignmentId", "invocationId", "recipeRevision", "policyRevision", "inputCandidate", "issuedAt", "expiresAt", "invocationInput", "dispatchAuthority"];
     const REPORT_KEYS = ["schema", "result", "telemetry"];
    +const SEMANTIC_RESULT_KEYS = ["schema", "outcome", "findings", "resolvedFindingIds", "telemetry"];
     const TELEMETRY_KEYS = ["schema", "provenance", "executor", "displayName", "provider", "model", "effort", "startedAt", "completedAt", "inputTokens", "outputTokens"];
     const BASE64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u;
     const MAX_ENVELOPE_BYTES = 400_000;
    @@ -121,3 +122,38 @@ export function validateHostExecutionReport(bytes, options) {
     }
     
     export const HOST_EXECUTION_REPORT_SCHEMA = "burnlist-loop-host-report@1";
    +
    +/**
    + * Bind the worker's small semantic result to controller-owned claim identity.
    + * The worker never receives or reconstructs the authority-bearing tuple.
    + */
    +export function createHostSemanticResult(value, { envelope, mode, openFindings = new Map() } = {}) {
    +  if (!exact(value, SEMANTIC_RESULT_KEYS) || value.schema !== "burnlist-loop-host-result@1"
    +    || !envelope?.value) fail("invalid host semantic result");
    +  const binding = Object.fromEntries([
    +    "runId", "nodeId", "attempt", "claimId", "assignmentId", "invocationId",
    +    "recipeRevision", "policyRevision", "inputCandidate",
    +  ].map((key) => [key, envelope.value[key]]));
    +  return createHostExecutionReport({
    +    schema: HOST_EXECUTION_REPORT_SCHEMA,
    +    result: {
    +      schema: "agent-result@1", ...binding, outcome: value.outcome,
    +      findings: value.findings, resolvedFindingIds: value.resolvedFindingIds,
    +    },
    +    telemetry: value.telemetry,
    +  }, { envelope, mode, openFindings });
    +}
    +
    +export function validateHostSemanticResult(bytes, options) {
    +  const raw = Buffer.from(bytes);
    +  const value = parseBoundedObject(raw, { maximumBytes: 262_144, maximumDepth: 5, label: "host semantic result" });
    +  const report = createHostSemanticResult(value, options);
    +  const canonical = Buffer.from(`${JSON.stringify({
    +    schema: value.schema, outcome: value.outcome, findings: value.findings,
    +    resolvedFindingIds: value.resolvedFindingIds, telemetry: value.telemetry,
    +  })}\n`, "utf8");
    +  if (!canonical.equals(raw)) fail("host semantic result is not canonical");
    +  return report;
    +}
    +
    +export const HOST_SEMANTIC_RESULT_SCHEMA = "burnlist-loop-host-result@1";
    diff --git a/src/loops/contracts/host-execution.test.mjs b/src/loops/contracts/host-execution.test.mjs
    index 4770500b..c9e2a85d 100644
    --- a/src/loops/contracts/host-execution.test.mjs
    +++ b/src/loops/contracts/host-execution.test.mjs
    @@ -2,8 +2,9 @@ import assert from "node:assert/strict";
     import test from "node:test";
     import { createDispatchAuthority, createInvocationInput } from "./agent-result.mjs";
     import {
    -  createHostExecutionEnvelope, createHostExecutionReport, validateHostExecutionEnvelope,
    -  hostExecutionExpired, validateHostExecutionReport,
    +  createHostExecutionEnvelope, createHostExecutionReport, createHostSemanticResult,
    +  validateHostExecutionEnvelope, hostExecutionExpired, validateHostExecutionReport,
    +  validateHostSemanticResult,
     } from "./host-execution.mjs";
     import { rawSha256 } from "../dsl/hash.mjs";
     
    @@ -114,3 +115,33 @@ test("host report bytes make retransmission identity explicit", () => {
       assert.notEqual(changed.digest, first.digest);
       assert.throws(() => validateHostExecutionReport(Buffer.concat([first.bytes, Buffer.from(" ")]), { envelope, mode: "task" }), /canonical/u);
     });
    +
    +test("small semantic results are bound to sealed identity by Burnlist", () => {
    +  const envelope = createHostExecutionEnvelope(fixture());
    +  const semantic = {
    +    schema: "burnlist-loop-host-result@1", outcome: "complete",
    +    findings: [], resolvedFindingIds: [], telemetry: null,
    +  };
    +  const bound = createHostSemanticResult(semantic, { envelope, mode: "task" });
    +  assert.equal(bound.value.result.claimId, envelope.value.claimId);
    +  assert.equal(bound.value.result.invocationId, envelope.value.invocationId);
    +  assert.equal(bound.value.result.outcome, "complete");
    +  assert.equal(Object.hasOwn(semantic, "claimId"), false);
    +  const bytes = Buffer.from(`${JSON.stringify(semantic)}\n`);
    +  assert.equal(validateHostSemanticResult(bytes, { envelope, mode: "task" }).digest, bound.digest);
    +});
    +
    +test("semantic results fail closed on extra authority, illegal outcomes, and noncanonical replay", () => {
    +  const envelope = createHostExecutionEnvelope(fixture());
    +  const semantic = {
    +    schema: "burnlist-loop-host-result@1", outcome: "complete",
    +    findings: [], resolvedFindingIds: [], telemetry: null,
    +  };
    +  assert.throws(() => createHostSemanticResult({ ...semantic, destination: "completed" },
    +    { envelope, mode: "task" }), /semantic result/u);
    +  assert.throws(() => createHostSemanticResult({ ...semantic, outcome: "approve" },
    +    { envelope, mode: "task" }), /not allowed/u);
    +  assert.throws(() => validateHostSemanticResult(
    +    Buffer.concat([Buffer.from(`${JSON.stringify(semantic)}\n`), Buffer.from(" ")]),
    +    { envelope, mode: "task" }), /canonical/u);
    +});
    diff --git a/src/loops/run/controller.mjs b/src/loops/run/controller.mjs
    index 2f3d7fc3..edb4d66a 100644
    --- a/src/loops/run/controller.mjs
    +++ b/src/loops/run/controller.mjs
    @@ -1,7 +1,8 @@
     import { presentRun } from "./read-projection.mjs";
     import { isRunRef } from "./run-ref.mjs";
    -import { prepareHostClaim } from "./host-execution.mjs";
    +import { prepareHostClaim, presentHostTask } from "./host-execution.mjs";
     import { deriveCandidate } from "./candidate.mjs";
    +import { validateHostExecutionEnvelope, validateHostSemanticResult } from "../contracts/host-execution.mjs";
     
     const fail = (message, code = "ELOOP_CONTROL") => { throw Object.assign(new Error(`Loop control: ${message}`), { code }); };
     const stable = (value) => `${JSON.stringify(value)}\n`;
    @@ -49,6 +50,10 @@ export function createLoopController({ store, runnerFor, repoRoot = null }) {
           throw error;
         }
       }
    +  function next(runId) {
    +    const prepared = claim(runId);
    +    return presentHostTask(prepared.envelope);
    +  }
       async function report(claimRef, bytes) {
         const runId = store.resolveClaimRef(claimRef), current = read(runId);
         store.acceptExternalReport(runId, current.execution.lease, bytes,
    @@ -58,6 +63,19 @@ export function createLoopController({ store, runnerFor, repoRoot = null }) {
         if (!runner?.runToHostBoundary) fail("system runner is unavailable", "ERUNNER_UNAVAILABLE");
         return presentRun(await runner.runToHostBoundary());
       }
    +  async function submit(runId, bytes) {
    +    check(runId);
    +    const current = read(runId), active = store.readExternalClaim(runId, current);
    +    if (!active) fail("Run has no active host task", "ECLAIM");
    +    const envelope = validateHostExecutionEnvelope(active.envelope);
    +    const bound = validateHostSemanticResult(bytes, {
    +      envelope,
    +      mode: current.execution.node.mode,
    +      openFindings: current.execution.node.mode === "review"
    +        ? current.execution.openFindings : new Map(),
    +    });
    +    return report(active.claim.claimId, bound.bytes);
    +  }
       function readClaim(runId) { check(runId); return store.readExternalClaim(runId); }
       function abandonClaim(runId, abandonment) {
         check(runId); const current = read(runId);
    @@ -78,6 +96,6 @@ export function createLoopController({ store, runnerFor, repoRoot = null }) {
         const lease = idleLease(runId);
         return presentRun(store.terminalize(runId, lease, "lost", "reconciled lost invocation"));
       }
    -  return Object.freeze({ list, inspect, status, pause, stop, reconcile, claim, report,
    +  return Object.freeze({ list, inspect, status, pause, stop, reconcile, claim, next, report, submit,
         readClaim, abandonClaim, render: stable });
     }
    diff --git a/src/loops/run/host-execution.mjs b/src/loops/run/host-execution.mjs
    index 3f42254f..dc8d8db1 100644
    --- a/src/loops/run/host-execution.mjs
    +++ b/src/loops/run/host-execution.mjs
    @@ -2,7 +2,7 @@ import { randomBytes } from "node:crypto";
     import { loadFrozenRecipe } from "../dsl/frozen.mjs";
     import { prefixed, rawSha256 } from "../dsl/hash.mjs";
     import { createDispatchAuthority, createInvocationInput } from "../contracts/agent-result.mjs";
    -import { createHostExecutionEnvelope } from "../contracts/host-execution.mjs";
    +import { createHostExecutionEnvelope, validateHostExecutionEnvelope } from "../contracts/host-execution.mjs";
     import { boundPolicyRevision, loadBoundPolicy } from "./run-artifacts.mjs";
     import { createHostClaim } from "./run-claim.mjs";
     import { deriveCandidate } from "./candidate.mjs";
    @@ -65,3 +65,39 @@ export function prepareHostClaim({ repoRoot, replay, authority, now = Date.now()
       const claim = createHostClaim({ ...common, claimId, executionDigest: envelope.digest, expiresAt });
       return Object.freeze({ claim, envelope: envelope.bytes });
     }
    +
    +function utf8(base64, label) {
    +  try { return new TextDecoder("utf-8", { fatal: true }).decode(Buffer.from(base64, "base64")); }
    +  catch { fail(`${label} is not UTF-8`); }
    +}
    +
    +/** Public worker packet. Authority identities stay sealed in the Run store. */
    +export function presentHostTask(envelopeBytes) {
    +  const envelope = validateHostExecutionEnvelope(envelopeBytes);
    +  const input = envelope.input.value;
    +  const instruction = utf8(input.instructionBytes, "instruction");
    +  const item = utf8(input.itemText, "item");
    +  const context = utf8(input.candidateContext, "candidate context");
    +  const findings = input.openFindings?.length
    +    ? `\n\nOpen review findings:\n${JSON.stringify(input.openFindings, null, 2)}`
    +    : "";
    +  const prompt = [
    +    `Act as the ${input.role} for this ${input.mode} task. Your workspace authority is ${input.authority}.`,
    +    instruction.trimEnd(),
    +    `Assigned item:\n${item.trimEnd()}`,
    +    `Candidate context:\n${context.trimEnd()}${findings}`,
    +    `Return a concise summary and one result: ${input.legalOutcomes.join(", ")}. Do not edit Burnlist lifecycle files or choose a graph transition.`,
    +  ].join("\n\n");
    +  return Object.freeze({
    +    schema: "burnlist-loop-host-task@1",
    +    runId: input.runId,
    +    nodeId: input.nodeId,
    +    mode: input.mode,
    +    role: input.role,
    +    authority: input.authority,
    +    intelligence: input.intelligence,
    +    legalOutcomes: Object.freeze([...input.legalOutcomes]),
    +    requires: Object.freeze([...input.requires]),
    +    prompt,
    +  });
    +}
    diff --git a/src/loops/run/run-store.mjs b/src/loops/run/run-store.mjs
    index 667b463f..84b967d2 100644
    --- a/src/loops/run/run-store.mjs
    +++ b/src/loops/run/run-store.mjs
    @@ -334,6 +334,29 @@ export function runStore(repoRoot, { clock = () => Date.now(), random = randomBy
         if (matches.length !== 1) fail("ClaimRef is ambiguous", "ECLAIM");
         return matches[0];
       }
    +  function visibleRunEntries() {
    +    if (!existsSync(runs)) return [];
    +    const entries = readdirSync(runs, { withFileTypes: true });
    +    const staging = entries.filter((entry) => /^\.create-[a-f0-9]{16}\.tmp$/u.test(entry.name));
    +    const visible = entries.filter((entry) => !/^\.create-[a-f0-9]{16}\.tmp$/u.test(entry.name));
    +    if (staging.length > 128 || visible.length > 128
    +      || entries.some((entry) => !entry.isDirectory()
    +        || !/^(?:[a-f0-9]+|\.create-[a-f0-9]{16}\.tmp)$/u.test(entry.name)))
    +      fail("run directory exceeds bounds", "EBOUNDS");
    +    return visible.sort((left, right) => left.name.localeCompare(right.name));
    +  }
    +  function listForItem(itemRef) {
    +    if (typeof itemRef !== "string" || !itemRef) fail("invalid item projection", "EBOUNDS");
    +    const output = [];
    +    for (const entry of visibleRunEntries()) {
    +      const runId = Buffer.from(entry.name, "hex").toString("utf8");
    +      if (!isRunRef(runId) || Buffer.from(runId).toString("hex") !== entry.name)
    +        fail("run directory is corrupt", "EBOUNDS");
    +      const journal = readJournal(journalFor(runId));
    +      if (journal[0]?.value?.payload?.itemRef === itemRef) output.push(replay(runId).projection);
    +    }
    +    return output;
    +  }
       return Object.freeze({ createRun: (...input) => published(createRun(...input)), replay, read: replay,
         append: (...input) => published(append(...input)), acquireLease: (...input) => published(acquireLease(...input)),
         releaseLease: (...input) => published(releaseLease(...input)), recoverLease: (...input) => published(recoverLease(...input)),
    @@ -341,10 +364,7 @@ export function runStore(repoRoot, { clock = () => Date.now(), random = randomBy
         readExternalClaim, abandonExternalClaim: (...input) => published(abandonExternalClaim(...input)),
         resolveExternalClaim: (...input) => published(resolveExternalClaim(...input)),
         acceptExternalReport: (...input) => published(acceptExternalReport(...input)), list: () => {
    -    if (!existsSync(runs)) return [];
    -    const entries = readdirSync(runs, { withFileTypes: true }), staging = entries.filter((entry) => /^\.create-[a-f0-9]{16}\.tmp$/u.test(entry.name)), visible = entries.filter((entry) => !/^\.create-[a-f0-9]{16}\.tmp$/u.test(entry.name));
    -    if (staging.length > 128 || visible.length > 128 || entries.some((entry) => !entry.isDirectory() || !/^(?:[a-f0-9]+|\.create-[a-f0-9]{16}\.tmp)$/u.test(entry.name))) fail("run directory exceeds bounds", "EBOUNDS");
    -    return visible.sort((a, b) => a.name.localeCompare(b.name))
    +    return visibleRunEntries()
           .map((entry) => replay(Buffer.from(entry.name, "hex").toString("utf8")).projection);
    -  }, resolveClaimRef, readAuthority, readCurrentRun(itemRef) { if (!existsSync(base)) return null; const values = currentAuthority().read().filter((entry) => entry.itemRef === itemRef); if (values.length > 1) fail("current Run binding is ambiguous", "ECURRENT"); return values[0] ?? null; }, paths: Object.freeze({ base, runs, pathFor, journalFor, authorityPath, executionPath, reportPath, currentPath: join(base, "current-runs.json") }) });
    +  }, listForItem, resolveClaimRef, readAuthority, readCurrentRun(itemRef) { if (!existsSync(base)) return null; const values = currentAuthority().read().filter((entry) => entry.itemRef === itemRef); if (values.length > 1) fail("current Run binding is ambiguous", "ECURRENT"); return values[0] ?? null; }, paths: Object.freeze({ base, runs, pathFor, journalFor, authorityPath, executionPath, reportPath, currentPath: join(base, "current-runs.json") }) });
     }
    
    From bb0988d11b31ee9d039ffd99e4d870eeb2b386a9 Mon Sep 17 00:00:00 2001
    From: Juan Cruz Fortunatti 
    Date: Sat, 25 Jul 2026 20:18:56 +0200
    Subject: [PATCH 25/37] feat: correlate native loop observations (E2)
    
    ---
     README.md                                  |  15 ++-
     skills/burnlist/SKILL.md                   |   6 +-
     skills/burnlist/references/installation.md |  24 ++--
     src/cli/hooks-cli.mjs                      |   7 +-
     src/cli/hooks-config.mjs                   |  44 ++++++-
     src/cli/hooks-config.test.mjs              |  17 ++-
     src/cli/loop-hook-cli.mjs                  |  45 +++++++
     src/loops/events/activity-projection.mjs   |  50 ++++++--
     src/loops/events/hook-context.mjs          | 123 +++++++++++++++++++
     src/loops/events/hook-observation.mjs      | 128 ++++++++++++++++++++
     src/loops/events/hook-observation.test.mjs | 131 +++++++++++++++++++++
     src/loops/run/controller.mjs               |  16 ++-
     src/loops/run/read-projection.mjs          |  16 ++-
     website/src/content/docs/loops.mdx         |   3 +-
     14 files changed, 583 insertions(+), 42 deletions(-)
     create mode 100644 src/cli/loop-hook-cli.mjs
     create mode 100644 src/loops/events/hook-context.mjs
     create mode 100644 src/loops/events/hook-observation.mjs
     create mode 100644 src/loops/events/hook-observation.test.mjs
    
    diff --git a/README.md b/README.md
    index 5308bcde..2937442e 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.
     
    @@ -106,14 +106,21 @@ 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. 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,7 +151,7 @@ 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.
     
    diff --git a/skills/burnlist/SKILL.md b/skills/burnlist/SKILL.md
    index dedde694..e769ace8 100644
    --- a/skills/burnlist/SKILL.md
    +++ b/skills/burnlist/SKILL.md
    @@ -151,7 +151,7 @@ Read `references/burnlist-dashboard.md` only for dashboard/chart/log/timeline/re
     Burnlist has two independent installable systems. Either or both may be present:
     
     - **Skill discovery** (`burnlist install`) makes this Burnlist skill discoverable to both agents. The default is a per-repository, untracked-local registration in `/.claude/skills/burnlist` for Claude Code and `/.agents/skills/burnlist` for Codex. `--global` instead uses `~/.claude/skills/burnlist` and `~/.agents/skills/burnlist`; a global npm installation of Burnlist automatically registers both global skills. Use `--commit` only for a per-repository portable copy intended for Git; `--agent codex,claude` limits targets and `--dry-run` previews. `burnlist uninstall` is the inverse; `burnlist uninstall --global --purge` also removes the global npm package.
    -- **Streaming Diff hooks** (`burnlist hooks install`) install per-repository edit-capture commands, not skills. Codex consumes `/.codex/hooks.json`; Claude Code consumes `/.claude/settings.json`. They invoke `burnlist streaming-diff hook` for session/edit events and merge with existing hook entries. Hooks have no global mode: use `burnlist hooks uninstall` or `burnlist hooks status` in the repository, optionally with `--agent codex,claude`. `--untracked` asks install to add the config to `.git/info/exclude`; it cannot hide an already tracked config.
    +- **Native observability hooks** (`burnlist hooks install`) install per-repository edit-capture and advisory Loop-observation commands, not skills. Codex consumes `/.codex/hooks.json`; Claude Code consumes `/.claude/settings.json`. They invoke `burnlist streaming-diff hook` around edits and `burnlist hooks observe` for supported native lifecycle events, merging with existing entries. Observations are bounded local facts and never semantic Loop outcomes. Hooks have no global mode: use `burnlist hooks uninstall` or `burnlist hooks status` in the repository, optionally with `--agent codex,claude`. `--untracked` asks install to add the config to `.git/info/exclude`; it cannot hide an already tracked config.
     
     Install only the system the task needs, or both. Read `references/installation.md` for exact commands, ownership, and shared-versus-local behavior.
     
    @@ -248,5 +248,5 @@ names, and native worker state are host evidence rather than dashboard graph
     state.
     
     `burnlist install` registers this skill, while `burnlist hooks install` adds
    -Streaming Diff edit-capture hooks. These integrations are independent and do
    -not configure or start a Review Loop.
    +native observability hooks for Streaming Diff and active Loop activity. These
    +integrations are independent and do not configure or start a Review Loop.
    diff --git a/skills/burnlist/references/installation.md b/skills/burnlist/references/installation.md
    index 79243b96..1d2bbd0e 100644
    --- a/skills/burnlist/references/installation.md
    +++ b/skills/burnlist/references/installation.md
    @@ -1,6 +1,6 @@
     # Agent Skill and Hook Installation
     
    -Burnlist offers two independent integrations. The **skill** tells an agent how to create and execute Burnlists. The **hooks** capture editing activity for Streaming Diff. Installing one does not install, require, or remove the other.
    +Burnlist offers two independent integrations. The **skill** tells an agent how to create and execute Burnlists. The **hooks** capture editing activity for Streaming Diff and publish observational native-agent activity for an active Loop. Installing one does not install, require, or remove the other.
     
     ## Skill Discovery
     
    @@ -29,7 +29,7 @@ For a Git worktree, the command reports the default mode as `untracked (local, .
     
     `burnlist uninstall` removes only Burnlist-managed registrations in the matching scope and removes its matching local exclusion entries. `--purge` requires `uninstall --global`, targets both agents, and also uninstalls the global npm package.
     
    -## Streaming Diff Edit-Capture Hooks
    +## Native Observability Hooks
     
     The hooks CLI surface is:
     
    @@ -39,14 +39,22 @@ burnlist hooks [install|uninstall|status] [--agent codex,claude] [--untracked]
     
     Bare `burnlist hooks` defaults to `status`.
     
    -`burnlist hooks install` is repository-only and must run inside a Git worktree; there is no `--global` flag. It adds managed `burnlist streaming-diff hook` commands while preserving unrelated hook entries:
    +`burnlist hooks install` is repository-only and must run inside a Git worktree; there is no `--global` flag. It adds managed Streaming Diff commands plus advisory `burnlist hooks observe` commands while preserving unrelated hook entries:
     
    -| Agent that consumes the hook | Config written at the worktree root | Events |
    +| Agent that consumes the hook | Config written at the worktree root | Observed events |
     | --- | --- | --- |
    -| Codex | `/.codex/hooks.json` | `SessionStart`, `PreToolUse`, `PostToolUse` |
    -| Claude Code | `/.claude/settings.json` | `SessionStart`, `PreToolUse`, `PostToolUse`, `PostToolUseFailure` |
    -
    -The edit events are matched to each agent's write/edit tools, so the configured commands capture Streaming Diff activity around edits. Codex hook support requires Codex CLI 0.124.0 or newer; `status` reports whether the installed CLI can run the configured hooks. The hook commands require `burnlist` to be available on the host `PATH`; each agent may still ask for its own hook trust or consent.
    +| Codex | `/.codex/hooks.json` | `SessionStart`, `SessionEnd`, `PreToolUse`, `PostToolUse`, `SubagentStart`, `SubagentStop`, `Stop` |
    +| Claude Code | `/.claude/settings.json` | Codex events plus `PostToolUseFailure` |
    +
    +Streaming Diff entries remain limited to each agent's write/edit tools. The
    +advisory observation entries retain only bounded lifecycle, hashed
    +session/agent identity, tool name, contained file paths, timing, model, effort,
    +and token usage when the native payload exposes them. They publish to ignored
    +local Oven event state and cannot report a semantic outcome or advance a Loop.
    +Codex hook support requires Codex CLI 0.124.0 or newer; `status` reports whether
    +the installed CLI can run the configured hooks. The commands require
    +`burnlist` on the host `PATH`; each agent may still ask for hook trust or
    +consent.
     
     By default, an untracked hook config is added to `.git/info/exclude`, making it local. A tracked config remains shared with the team. `--untracked` asks install to add the config to that local exclude file even when it is tracked, but Git cannot hide an already tracked file. Burnlist records only configs it created under `/.local/burnlist/` so uninstall can remove an otherwise-empty created config; it removes only its exact hook entries and leaves unrelated configuration intact.
     
    diff --git a/src/cli/hooks-cli.mjs b/src/cli/hooks-cli.mjs
    index 43dcb226..ca7be701 100644
    --- a/src/cli/hooks-cli.mjs
    +++ b/src/cli/hooks-cli.mjs
    @@ -1,5 +1,6 @@
     #!/usr/bin/env node
     import { hookConfigStatus, updateHookConfigs } from "./hooks-config.mjs";
    +import { runLoopObservationHook } from "./loop-hook-cli.mjs";
     
     const tokens = process.argv.slice(2);
     if (tokens[0] === "hooks") tokens.shift();
    @@ -32,7 +33,11 @@ function print(result, { install = false } = {}) {
         if (entry.forcedUntracked) console.warn(`${entry.agent}: --untracked cannot hide an already tracked config.`);
       }
     }
    -try {
    +if (subcommand === "observe") {
    +  const index = tokens.indexOf("--agent");
    +  const provider = index >= 0 ? tokens[index + 1] : null;
    +  if (tokens.length === 2 && index === 0) await runLoopObservationHook({ provider });
    +} else try {
       if (["--help", "-h"].includes(subcommand) || tokens.includes("--help") || tokens.includes("-h")) console.log("Usage: burnlist hooks  [--agent codex,claude] [--untracked]");
       else {
         const options = parse();
    diff --git a/src/cli/hooks-config.mjs b/src/cli/hooks-config.mjs
    index 7bd9f495..90d6dc78 100644
    --- a/src/cli/hooks-config.mjs
    +++ b/src/cli/hooks-config.mjs
    @@ -16,6 +16,12 @@ const EVENTS = {
       codex: ["ensure", "pre", "post"],
       claude: ["ensure", "pre", "post", "failure"],
     };
    +const OBSERVATION_EVENTS = {
    +  codex: ["SessionStart", "SessionEnd", "PreToolUse", "PostToolUse",
    +    "SubagentStart", "SubagentStop", "Stop"],
    +  claude: ["SessionStart", "SessionEnd", "PreToolUse", "PostToolUse",
    +    "PostToolUseFailure", "SubagentStart", "SubagentStop", "Stop"],
    +};
     const MANAGED_EVENTS = ["ensure", "pre", "post", "failure"];
     const MUTATING_MATCHERS = {
       claude: "Edit|Write|MultiEdit|NotebookEdit",
    @@ -70,6 +76,11 @@ export function managedHookEntry(agent, event) {
       return entry;
     }
     
    +export function managedObservationHookEntry(agent, event) {
    +  if (!OBSERVATION_EVENTS[agent]?.includes(event)) throw new Error(`unsupported ${agent} observation event: ${event}`);
    +  return { hooks: [{ type: "command", command: `burnlist hooks observe --agent ${agent}` }] };
    +}
    +
     // Ownership is intentionally an exact, event-specific structural match. The
     // marker belongs only to info/exclude, never to a host command string.
     function ownedEntry(entry, agent, event) {
    @@ -79,6 +90,14 @@ function ownedEntry(entry, agent, event) {
         && entry.hooks[0]?.type === "command" && entry.hooks[0]?.command === expected.hooks[0].command;
     }
     
    +function ownedObservationEntry(entry, agent, event) {
    +  const expected = managedObservationHookEntry(agent, event);
    +  return entry && typeof entry === "object" && !Array.isArray(entry)
    +    && entry.matcher === undefined && Array.isArray(entry.hooks) && entry.hooks.length === 1
    +    && entry.hooks[0]?.type === "command"
    +    && entry.hooks[0]?.command === expected.hooks[0].command;
    +}
    +
     function removeOwnedEntries(entries, agent, event) {
       if (!Array.isArray(entries)) throw new Error("hook event must be an array; refusing to clobber it");
       return entries.filter((entry) => !ownedEntry(entry, agent, event));
    @@ -88,6 +107,10 @@ function eventEntries(config, event) {
       const entries = config.hooks?.[eventName(event)];
       return Array.isArray(entries) ? entries : [];
     }
    +function namedEventEntries(config, event) {
    +  const entries = config.hooks?.[event];
    +  return Array.isArray(entries) ? entries : [];
    +}
     
     function hasCorruptHooks(config) {
       if (config.hooks === undefined) return false;
    @@ -96,13 +119,19 @@ function hasCorruptHooks(config) {
     }
     
     function hasOwnedEntries(config, agent) {
    -  return eventsFor(agent).every((event) => eventEntries(config, event).some((entry) => ownedEntry(entry, agent, event)));
    +  return eventsFor(agent).every((event) => eventEntries(config, event).some((entry) => ownedEntry(entry, agent, event)))
    +    && OBSERVATION_EVENTS[agent].every((event) =>
    +      namedEventEntries(config, event).some((entry) => ownedObservationEntry(entry, agent, event)));
     }
     
     function ownershipState(config, agent) {
    -  const events = eventsFor(agent);
    -  const found = events.filter((event) => eventEntries(config, event).some((entry) => ownedEntry(entry, agent, event))).length;
    -  return found === events.length ? "installed" : found === 0 ? "none" : "partial";
    +  const diffEvents = eventsFor(agent);
    +  const total = diffEvents.length + OBSERVATION_EVENTS[agent].length;
    +  const found = diffEvents.filter((event) => eventEntries(config, event)
    +    .some((entry) => ownedEntry(entry, agent, event))).length
    +    + OBSERVATION_EVENTS[agent].filter((event) => namedEventEntries(config, event)
    +      .some((entry) => ownedObservationEntry(entry, agent, event))).length;
    +  return found === total ? "installed" : found === 0 ? "none" : "partial";
     }
     
     function mergeConfig(config, agent, install) {
    @@ -117,6 +146,13 @@ function mergeConfig(config, agent, install) {
         if (clean.length) next.hooks[name] = clean;
         else delete next.hooks[name];
       }
    +  for (const event of OBSERVATION_EVENTS[agent]) {
    +    const clean = (next.hooks[event] ?? []).filter((entry) =>
    +      !ownedObservationEntry(entry, agent, event));
    +    if (install) clean.push(managedObservationHookEntry(agent, event));
    +    if (clean.length) next.hooks[event] = clean;
    +    else delete next.hooks[event];
    +  }
       if (Object.keys(next.hooks).length === 0) delete next.hooks;
       return next;
     }
    diff --git a/src/cli/hooks-config.test.mjs b/src/cli/hooks-config.test.mjs
    index 9d8f95ea..4ab5ec58 100644
    --- a/src/cli/hooks-config.test.mjs
    +++ b/src/cli/hooks-config.test.mjs
    @@ -5,7 +5,8 @@ import { tmpdir } from "node:os";
     import { join } from "node:path";
     import test from "node:test";
     
    -import { HOOK_MARKER, hookCapability, hookConfigStatus, managedHookEntry, updateHookConfigs } from "./hooks-config.mjs";
    +import { HOOK_MARKER, hookCapability, hookConfigStatus, managedHookEntry,
    +  managedObservationHookEntry, updateHookConfigs } from "./hooks-config.mjs";
     
     function fixture() {
       const root = mkdtempSync(join(tmpdir(), "burnlist-hooks-"));
    @@ -25,7 +26,9 @@ test("hook install merges existing hooks, is idempotent, and uninstall is owners
         const installed = JSON.parse(readFileSync(configPath, "utf8"));
         assert.equal(installed.keep, true);
         assert.equal(installed.hooks.PreToolUse[0].hooks[0].command, "user-hook");
    -    assert.equal(installed.hooks.PreToolUse.at(-1).matcher, "Edit|Write|MultiEdit|NotebookEdit");
    +    assert.equal(installed.hooks.PreToolUse.find((entry) =>
    +      entry.hooks[0].command.includes("streaming-diff")).matcher, "Edit|Write|MultiEdit|NotebookEdit");
    +    assert.deepEqual(installed.hooks.SubagentStart, [managedObservationHookEntry("claude", "SubagentStart")]);
         assert.equal(JSON.stringify(installed).includes(HOOK_MARKER), false);
         updateHookConfigs({ repoRoot: context.root, agents: ["claude"], install: true });
         assert.equal(JSON.parse(readFileSync(configPath, "utf8")).hooks.PreToolUse.filter((entry) => entry.hooks[0].command === "burnlist streaming-diff hook --agent claude --event pre").length, 1);
    @@ -34,6 +37,7 @@ test("hook install merges existing hooks, is idempotent, and uninstall is owners
         assert.equal(removed.keep, true);
         assert.deepEqual(removed.hooks.PreToolUse, [{ matcher: "Bash", hooks: [{ type: "command", command: "user-hook" }] }]);
         assert.doesNotMatch(JSON.stringify(removed), /streaming-diff hook/u);
    +    assert.doesNotMatch(JSON.stringify(removed), /hooks observe/u);
       } finally { context.cleanup(); }
     });
     
    @@ -55,7 +59,8 @@ test("Codex install contains only its supported hook events", () => {
       try {
         const path = join(context.root, ".codex", "hooks.json");
         updateHookConfigs({ repoRoot: context.root, agents: ["codex"], install: true });
    -    assert.deepEqual(Object.keys(JSON.parse(readFileSync(path, "utf8")).hooks).sort(), ["PostToolUse", "PreToolUse", "SessionStart"]);
    +    assert.deepEqual(Object.keys(JSON.parse(readFileSync(path, "utf8")).hooks).sort(),
    +      ["PostToolUse", "PreToolUse", "SessionEnd", "SessionStart", "Stop", "SubagentStart", "SubagentStop"]);
       } finally { context.cleanup(); }
     });
     
    @@ -80,8 +85,10 @@ test("Codex reinstall removes stale owned unsupported events while preserving us
         updateHookConfigs({ repoRoot: context.root, agents: ["codex"], install: true });
         const reinstalled = JSON.parse(readFileSync(path, "utf8"));
         assert.deepEqual(reinstalled.hooks.PostToolUseFailure, [userHook]);
    -    assert.deepEqual(Object.keys(reinstalled.hooks).sort(), ["PostToolUse", "PostToolUseFailure", "PreToolUse", "SessionStart"]);
    -    for (const event of ["SessionStart", "PreToolUse", "PostToolUse"]) {
    +    assert.deepEqual(Object.keys(reinstalled.hooks).sort(),
    +      ["PostToolUse", "PostToolUseFailure", "PreToolUse", "SessionEnd", "SessionStart",
    +        "Stop", "SubagentStart", "SubagentStop"]);
    +    for (const event of ["SessionStart", "PreToolUse", "PostToolUse", "SubagentStart", "SubagentStop"]) {
           assert.equal(reinstalled.hooks[event].some((entry) => entry.hooks[0].command.includes("--agent codex")), true);
         }
       } finally { context.cleanup(); }
    diff --git a/src/cli/loop-hook-cli.mjs b/src/cli/loop-hook-cli.mjs
    new file mode 100644
    index 00000000..8779bbd3
    --- /dev/null
    +++ b/src/cli/loop-hook-cli.mjs
    @@ -0,0 +1,45 @@
    +import { resolveUmbrella } from "./umbrella.mjs";
    +import { publishNativeLoopObservation } from "../loops/events/hook-observation.mjs";
    +
    +const MAX_BYTES = 256 * 1024;
    +const READ_TIMEOUT_MS = 750;
    +
    +function readStdinCapped(input = process.stdin) {
    +  return new Promise((resolveRead) => {
    +    const chunks = []; let bytes = 0; let settled = false;
    +    const finish = (value) => {
    +      if (settled) return;
    +      settled = true; clearTimeout(timer);
    +      input.off("data", onData); input.off("end", onEnd); input.off("error", onError);
    +      resolveRead(value);
    +    };
    +    const onData = (chunk) => {
    +      bytes += chunk.length;
    +      if (bytes > MAX_BYTES) { try { input.destroy(); } catch {} finish(null); }
    +      else chunks.push(Buffer.from(chunk));
    +    };
    +    const onEnd = () => finish(Buffer.concat(chunks, bytes));
    +    const onError = () => finish(null);
    +    const timer = setTimeout(() => finish(null), READ_TIMEOUT_MS);
    +    input.on("data", onData); input.once("end", onEnd); input.once("error", onError); input.resume();
    +  });
    +}
    +
    +export function parseLoopHookPayload(bytes) {
    +  if (!bytes || bytes.length < 2 || bytes.length > MAX_BYTES) return null;
    +  try {
    +    const value = JSON.parse(bytes);
    +    return value && typeof value === "object" && !Array.isArray(value) ? value : null;
    +  } catch { return null; }
    +}
    +
    +/** Advisory hook entry: every failure becomes a quiet no-op. */
    +export async function runLoopObservationHook({ provider, input = process.stdin } = {}) {
    +  try {
    +    if (!["codex", "claude"].includes(provider)) return null;
    +    const payload = parseLoopHookPayload(await readStdinCapped(input));
    +    if (!payload) return null;
    +    const repoRoot = resolveUmbrella(payload.cwd ?? process.cwd());
    +    return publishNativeLoopObservation({ repoRoot, provider, payload });
    +  } catch { return null; }
    +}
    diff --git a/src/loops/events/activity-projection.mjs b/src/loops/events/activity-projection.mjs
    index a2a45814..9b361344 100644
    --- a/src/loops/events/activity-projection.mjs
    +++ b/src/loops/events/activity-projection.mjs
    @@ -1,8 +1,14 @@
     import { createHash } from "node:crypto";
     
     const MAX_ACTIVITY = 10;
    -const OPTIONAL_KINDS = new Set(["subagent-started", "subagent-finished", "subagent-failed", "tool-started", "tool-finished"]);
    -const OPTIONAL_KEYS = new Set(["at", "origin", "kind", "provider", "runId", "nodeId", "attempt", "invocationId", "parentAgentId", "subagentId", "tool", "observedPath", "truncated"]);
    +const OPTIONAL_KINDS = new Set(["agent-started", "agent-finished", "agent-failed",
    +  "subagent-started", "subagent-finished", "subagent-failed",
    +  "tool-started", "tool-finished", "tool-failed"]);
    +const OPTIONAL_KEYS = new Set(["at", "origin", "kind", "provider", "runId", "nodeId",
    +  "attempt", "invocationId", "correlation", "parentAgentId", "subagentId",
    +  "sessionKey", "agentKey", "agentType", "tool", "observedPath", "observedPaths",
    +  "model", "effort", "inputTokens", "outputTokens", "durationMilliseconds", "truncated",
    +  "schema"]);
     const safe = (value, maximum = 128) => typeof value === "string" && value.length > 0 && value.length <= maximum && /^[A-Za-z0-9._:/-]+$/u.test(value);
     const logicalPath = (value) => typeof value === "string" && value.length > 0 && value.length <= 256
       && !value.startsWith("/") && !/^[A-Za-z]:/u.test(value) && !value.includes("\\")
    @@ -28,22 +34,44 @@ export function normalizeOptionalActivityRecord(value) {
         || !OPTIONAL_KINDS.has(value.kind)
         || !["claude", "codex"].includes(value.provider)
         || !safe(value.runId) || !safe(value.nodeId) || !Number.isSafeInteger(value.at) || value.at < 0
    -    || !Number.isSafeInteger(value.attempt) || value.attempt < 1 || !safe(value.invocationId)
    +    || !Number.isSafeInteger(value.attempt) || value.attempt < 1
    +    || value.invocationId !== undefined && !safe(value.invocationId)
    +    || value.correlation !== undefined && !/^ac1-sha256:[a-f0-9]{64}$/u.test(value.correlation)
    +    || value.invocationId === undefined && value.correlation === undefined
         || value.parentAgentId !== undefined && !safe(value.parentAgentId)
         || value.subagentId !== undefined && !safe(value.subagentId)
    +    || value.sessionKey !== undefined && value.sessionKey !== null && !safe(value.sessionKey)
    +    || value.agentKey !== undefined && value.agentKey !== null && !safe(value.agentKey)
    +    || value.agentType !== undefined && value.agentType !== null && !safe(value.agentType)
         || value.tool !== undefined && !safe(value.tool)
         || value.observedPath !== undefined && !logicalPath(value.observedPath)
    +    || value.observedPaths !== undefined && (!Array.isArray(value.observedPaths)
    +      || value.observedPaths.length > 16 || value.observedPaths.some((entry) => !logicalPath(entry)))
    +    || value.model !== undefined && value.model !== null && !safe(value.model)
    +    || value.effort !== undefined && value.effort !== null && !safe(value.effort, 32)
    +    || ["inputTokens", "outputTokens", "durationMilliseconds"].some((key) =>
    +      value[key] !== undefined && value[key] !== null
    +      && (!Number.isSafeInteger(value[key]) || value[key] < 0))
         || value.truncated !== undefined && typeof value.truncated !== "boolean") return null;
    -  if (value.kind.startsWith("subagent-") && !safe(value.subagentId)) return null;
       if (value.kind.startsWith("tool-") && !safe(value.tool)) return null;
       return Object.freeze({
         at: value.at, origin: value.origin, kind: value.kind, provider: value.provider,
         nodeId: value.nodeId, attempt: value.attempt,
    -    correlation: correlation(value.runId, value.nodeId, value.attempt, value.invocationId),
    +    correlation: value.correlation
    +      ?? correlation(value.runId, value.nodeId, value.attempt, value.invocationId),
         ...(value.parentAgentId ? { parentAgentId: value.parentAgentId } : {}),
         ...(value.subagentId ? { subagentId: value.subagentId } : {}),
    +    ...(value.sessionKey ? { sessionKey: value.sessionKey } : {}),
    +    ...(value.agentKey ? { agentKey: value.agentKey } : {}),
    +    ...(value.agentType ? { agentType: value.agentType } : {}),
         ...(value.tool ? { tool: value.tool } : {}),
         ...(value.observedPath ? { observedPath: value.observedPath } : {}),
    +    ...(value.observedPaths ? { observedPaths: Object.freeze([...value.observedPaths]) } : {}),
    +    model: value.model ?? null,
    +    effort: value.effort ?? null,
    +    inputTokens: value.inputTokens ?? null,
    +    outputTokens: value.outputTokens ?? null,
    +    durationMilliseconds: value.durationMilliseconds ?? null,
         ...(value.truncated !== undefined ? { truncated: value.truncated } : {}),
       });
     }
    @@ -98,9 +126,15 @@ export function projectRunActivity({ runId, graph, journal, optionalRecords = []
       const invocations = new Map();
       const runner = journal.map((record) => recordFor({ runId, graph, record, invocations })).filter(Boolean);
       const optional = Array.isArray(optionalRecords) ? optionalRecords
    -    .filter((entry) => entry?.runId === runId
    -      && invocations.get(entry.invocationId)?.nodeId === entry.nodeId
    -      && invocations.get(entry.invocationId)?.attempt === entry.attempt)
    +    .filter((entry) => {
    +      if (entry?.runId !== runId) return false;
    +      if (entry.invocationId) return invocations.get(entry.invocationId)?.nodeId === entry.nodeId
    +        && invocations.get(entry.invocationId)?.attempt === entry.attempt;
    +      const invocation = [...invocations.entries()].find(([, value]) =>
    +        value.nodeId === entry.nodeId && value.attempt === entry.attempt);
    +      return invocation && correlation(runId, entry.nodeId, entry.attempt, invocation[0])
    +        === entry.correlation;
    +    })
         .map(normalizeOptionalActivityRecord)
         .filter((entry) => entry?.correlation?.startsWith("ac1-sha256:")) : [];
       const records = [...runner, ...optional].sort((left, right) => left.at - right.at).slice(-MAX_ACTIVITY);
    diff --git a/src/loops/events/hook-context.mjs b/src/loops/events/hook-context.mjs
    new file mode 100644
    index 00000000..c1b3769d
    --- /dev/null
    +++ b/src/loops/events/hook-context.mjs
    @@ -0,0 +1,123 @@
    +import { createHash } from "node:crypto";
    +import { existsSync, readFileSync } from "node:fs";
    +import { containedJoin, withRepoStateLock } from "../../server/repo-state.mjs";
    +import { writeAtomicText } from "../../cli/local-exclude.mjs";
    +import { runStore } from "../run/run-store.mjs";
    +
    +const SCHEMA = "burnlist-loop-hook-context@1";
    +const MAX_CONTEXTS = 32;
    +const MAX_BYTES = 64 * 1024;
    +const safe = (value, maximum = 256) => typeof value === "string" && value.length > 0
    +  && value.length <= maximum && !/[\u0000-\u001f\u007f]/u.test(value);
    +const digest = (kind, value) => `${kind}1-sha256:${createHash("sha256")
    +  .update(`burnlist-${kind}-v1\0${value}`).digest("hex")}`;
    +
    +function contextPath(repoRoot) { return containedJoin(repoRoot, "loop-hook-context.json"); }
    +function empty() { return { schema: SCHEMA, contexts: [] }; }
    +function exact(value, keys) {
    +  return value && typeof value === "object" && !Array.isArray(value)
    +    && Object.keys(value).length === keys.length
    +    && keys.every((key, index) => Object.keys(value)[index] === key);
    +}
    +function validateContext(value) {
    +  const keys = ["runId", "itemRef", "nodeId", "attempt", "claimId", "invocationId",
    +    "createdAt", "expiresAt", "sessions", "agents", "model", "effort"];
    +  if (!exact(value, keys) || !safe(value.runId) || !safe(value.itemRef)
    +    || !safe(value.nodeId, 64) || !Number.isSafeInteger(value.attempt) || value.attempt < 1
    +    || !safe(value.claimId) || !safe(value.invocationId)
    +    || !Number.isSafeInteger(value.createdAt) || !Number.isSafeInteger(value.expiresAt)
    +    || value.expiresAt <= value.createdAt || !Array.isArray(value.sessions)
    +    || !Array.isArray(value.agents) || value.sessions.length > 32 || value.agents.length > 64
    +    || value.sessions.some((entry) => !/^session1-sha256:[a-f0-9]{64}$/u.test(entry))
    +    || value.agents.some((entry) => !/^agent1-sha256:[a-f0-9]{64}$/u.test(entry))
    +    || value.model !== null && !safe(value.model, 128)
    +    || value.effort !== null && !safe(value.effort, 32)) throw new Error("Loop hook context is invalid.");
    +  return value;
    +}
    +function readContext(repoRoot) {
    +  const path = contextPath(repoRoot);
    +  if (!existsSync(path)) return empty();
    +  const bytes = readFileSync(path);
    +  if (bytes.length < 2 || bytes.length > MAX_BYTES) throw new Error("Loop hook context exceeds bounds.");
    +  let value;
    +  try { value = JSON.parse(bytes); } catch { throw new Error("Loop hook context is malformed."); }
    +  if (!exact(value, ["schema", "contexts"]) || value.schema !== SCHEMA
    +    || !Array.isArray(value.contexts) || value.contexts.length > MAX_CONTEXTS)
    +    throw new Error("Loop hook context is invalid.");
    +  value.contexts.forEach(validateContext);
    +  if (!Buffer.from(`${JSON.stringify(value)}\n`).equals(bytes))
    +    throw new Error("Loop hook context is not canonical.");
    +  return value;
    +}
    +function writeContext(repoRoot, value) {
    +  const text = `${JSON.stringify(value)}\n`;
    +  if (Buffer.byteLength(text) > MAX_BYTES) throw new Error("Loop hook context exceeds bounds.");
    +  writeAtomicText(contextPath(repoRoot), text);
    +}
    +function live(repoRoot, context, now) {
    +  if (context.expiresAt <= now) return false;
    +  try {
    +    const store = runStore(repoRoot), replay = store.read(context.runId);
    +    const active = store.readExternalClaim(context.runId, replay);
    +    return Boolean(active && active.claim.claimId === context.claimId
    +      && active.claim.nodeId === context.nodeId && active.claim.attempt === context.attempt);
    +  } catch { return false; }
    +}
    +
    +/** Publish one pending correlation tuple after the canonical claim is durable. */
    +export function activateLoopHookContext(repoRoot, { replay, claim, envelope }, {
    +  now = Date.now(),
    +} = {}) {
    +  if (!replay?.projection?.itemRef || !claim || !envelope?.invocationId)
    +    throw new Error("Loop hook context activation requires a claimed Run.");
    +  return withRepoStateLock(repoRoot, () => {
    +    const current = readContext(repoRoot);
    +    const contexts = current.contexts.filter((entry) => entry.runId !== claim.runId
    +      && live(repoRoot, entry, now));
    +    contexts.push(validateContext({
    +      runId: claim.runId, itemRef: replay.projection.itemRef, nodeId: claim.nodeId,
    +      attempt: claim.attempt, claimId: claim.claimId, invocationId: envelope.invocationId,
    +      createdAt: now, expiresAt: claim.expiresAt, sessions: [], agents: [],
    +      model: null, effort: null,
    +    }));
    +    const value = { schema: SCHEMA, contexts: contexts.slice(-MAX_CONTEXTS) };
    +    writeContext(repoRoot, value);
    +    return contexts.at(-1);
    +  });
    +}
    +
    +function observedIdentity(payload, provider) {
    +  const session = safe(payload?.session_id) ? digest("session", `${provider}\0${payload.session_id}`) : null;
    +  const rawAgent = payload?.agent_id ?? payload?.agentId;
    +  const agent = safe(rawAgent) ? digest("agent", `${provider}\0${rawAgent}`) : null;
    +  return { session, agent };
    +}
    +
    +/** Resolve and bind native identities without exposing their raw values. */
    +export function resolveLoopHookContext(repoRoot, { provider, payload, now = Date.now() }) {
    +  const identity = observedIdentity(payload, provider);
    +  if (!identity.session) return null;
    +  return withRepoStateLock(repoRoot, () => {
    +    const stored = readContext(repoRoot);
    +    const contexts = stored.contexts.filter((entry) => live(repoRoot, entry, now));
    +    let match = contexts.filter((entry) => entry.sessions.includes(identity.session)
    +      || identity.agent && entry.agents.includes(identity.agent));
    +    if (!match.length && contexts.length === 1) match = contexts;
    +    if (match.length !== 1) {
    +      if (contexts.length !== stored.contexts.length)
    +        writeContext(repoRoot, { schema: SCHEMA, contexts });
    +      return null;
    +    }
    +    const selected = match[0];
    +    if (!selected.sessions.includes(identity.session)) selected.sessions.push(identity.session);
    +    if (identity.agent && !selected.agents.includes(identity.agent)) selected.agents.push(identity.agent);
    +    const model = safe(payload.model, 128) ? payload.model : selected.model;
    +    const effortValue = payload?.effort?.level ?? payload?.effort;
    +    const effort = safe(effortValue, 32) ? effortValue : selected.effort;
    +    selected.model = model; selected.effort = effort;
    +    writeContext(repoRoot, { schema: SCHEMA, contexts });
    +    return Object.freeze({ ...selected, sessionKey: identity.session, agentKey: identity.agent });
    +  });
    +}
    +
    +export const loopHookIdentity = digest;
    diff --git a/src/loops/events/hook-observation.mjs b/src/loops/events/hook-observation.mjs
    new file mode 100644
    index 00000000..be02544c
    --- /dev/null
    +++ b/src/loops/events/hook-observation.mjs
    @@ -0,0 +1,128 @@
    +import { createHash } from "node:crypto";
    +import { isAbsolute, relative, resolve, sep } from "node:path";
    +import { publishOvenEvent, readOvenEvents } from "../../events/oven-event-store.mjs";
    +import { resolveLoopHookContext } from "./hook-context.mjs";
    +
    +export const LOOP_OBSERVATION_KIND = "loop-agent-observation";
    +const PROVIDERS = new Set(["codex", "claude"]);
    +const EVENT_KINDS = {
    +  SessionStart: "agent-started",
    +  SessionEnd: "agent-finished",
    +  Stop: "agent-finished",
    +  SubagentStart: "subagent-started",
    +  SubagentStop: "subagent-finished",
    +  PreToolUse: "tool-started",
    +  PostToolUse: "tool-finished",
    +  PostToolUseFailure: "tool-failed",
    +};
    +const safe = (value, maximum = 128) => typeof value === "string" && value.length > 0
    +  && value.length <= maximum && !/[\u0000-\u001f\u007f]/u.test(value);
    +const count = (value) => Number.isSafeInteger(value) && value >= 0 ? value : null;
    +const hash = (value) => createHash("sha256").update(value).digest("hex");
    +
    +function eventName(payload) {
    +  const value = payload?.hook_event_name ?? payload?.hookEventName ?? payload?.event;
    +  return safe(value, 64) ? value : null;
    +}
    +function object(value) { return value && typeof value === "object" && !Array.isArray(value) ? value : {}; }
    +function logicalPath(root, value) {
    +  if (!safe(value, 1024)) return null;
    +  const absolute = isAbsolute(value) ? resolve(value) : resolve(root, value);
    +  const fromRoot = relative(root, absolute);
    +  if (!fromRoot || fromRoot === ".." || fromRoot.startsWith(`..${sep}`)) return null;
    +  const normalized = fromRoot.split(sep).join("/");
    +  return normalized.length <= 256 && !normalized.split("/").some((part) => !part || part === "." || part === "..")
    +    ? normalized : null;
    +}
    +function patchPaths(value) {
    +  if (typeof value !== "string" || value.length > 262_144) return [];
    +  return [...value.matchAll(/^\*\*\* (?:Add|Update|Delete) File:\s*(.+?)\s*$/gmu),
    +    ...value.matchAll(/^\*\*\* Move to:\s*(.+?)\s*$/gmu)].map((match) => match[1]);
    +}
    +function observedPaths(repoRoot, payload) {
    +  const input = object(payload.tool_input ?? payload.toolInput ?? payload.input);
    +  const raw = ["file_path", "notebook_path", "path", "target_path", "source_path",
    +    "destination_path"].flatMap((key) => safe(input[key], 1024) ? [input[key]] : []);
    +  raw.push(...patchPaths(input.command));
    +  return [...new Set(raw.map((value) => logicalPath(repoRoot, value)).filter(Boolean))].slice(0, 16);
    +}
    +function usage(payload) {
    +  const response = object(payload.tool_response ?? payload.toolResponse);
    +  const value = object(response.usage);
    +  return {
    +    inputTokens: count(value.input_tokens),
    +    outputTokens: count(value.output_tokens),
    +  };
    +}
    +function model(payload, context) {
    +  const response = object(payload.tool_response ?? payload.toolResponse);
    +  return safe(payload.model, 128) ? payload.model
    +    : safe(response.resolvedModel, 128) ? response.resolvedModel : context.model;
    +}
    +function effort(payload, context) {
    +  const value = payload?.effort?.level ?? payload?.effort;
    +  return safe(value, 32) ? value : context.effort;
    +}
    +function cursor(provider, payload, context, kind) {
    +  const identity = [
    +    provider, context.sessionKey, context.agentKey ?? "", eventName(payload) ?? "", kind,
    +    payload.tool_use_id ?? payload.toolUseId ?? "", payload.turn_id ?? payload.prompt_id ?? "",
    +    payload.source ?? payload.reason ?? "",
    +  ].join("\0");
    +  return `loop-observation-sha256-${hash(identity)}`;
    +}
    +
    +/** Strict raw-payload adapter: retain facts only, never semantic outcomes. */
    +export function normalizeNativeLoopObservation({ repoRoot, provider, payload, now = Date.now() }) {
    +  if (!PROVIDERS.has(provider) || !payload || typeof payload !== "object" || Array.isArray(payload)) return null;
    +  const name = eventName(payload), kind = EVENT_KINDS[name];
    +  if (!kind) return null;
    +  const context = resolveLoopHookContext(repoRoot, { provider, payload, now });
    +  if (!context) return null;
    +  const tool = kind.startsWith("tool-") && safe(payload.tool_name ?? payload.toolName, 128)
    +    ? payload.tool_name ?? payload.toolName : null;
    +  if (kind.startsWith("tool-") && !tool) return null;
    +  const tokens = usage(payload);
    +  const occurredAt = new Date(now).toISOString();
    +  return {
    +    ovenId: "checklist",
    +    subjectId: context.itemRef,
    +    kind: LOOP_OBSERVATION_KIND,
    +    phase: kind,
    +    cursor: cursor(provider, payload, context, kind),
    +    occurredAt,
    +    payload: {
    +      schema: "burnlist-loop-observation@1",
    +      runId: context.runId, nodeId: context.nodeId, attempt: context.attempt,
    +      correlation: `ac1-sha256:${hash(`${context.runId}\0${context.nodeId}\0${context.attempt}\0${context.invocationId}`)}`,
    +      provider, kind, sessionKey: context.sessionKey, agentKey: context.agentKey,
    +      agentType: safe(payload.agent_type ?? payload.agentType, 128)
    +        ? payload.agent_type ?? payload.agentType : null,
    +      tool, observedPaths: observedPaths(resolve(repoRoot), payload),
    +      model: model(payload, context), effort: effort(payload, context),
    +      inputTokens: tokens.inputTokens, outputTokens: tokens.outputTokens,
    +      durationMilliseconds: count(payload.duration_ms),
    +    },
    +  };
    +}
    +
    +export function publishNativeLoopObservation(input, {
    +  publish = publishOvenEvent,
    +} = {}) {
    +  const normalized = normalizeNativeLoopObservation(input);
    +  if (!normalized) return null;
    +  const result = publish(input.repoRoot, normalized);
    +  return result?.event ?? result;
    +}
    +
    +export function readLoopObservationRecords(repoRoot, runId) {
    +  try {
    +    return readOvenEvents(repoRoot, { ovenIds: ["checklist"], limit: 1_000 })
    +      .filter((event) => event.kind === LOOP_OBSERVATION_KIND
    +        && event.payload?.runId === runId)
    +      .map((event) => ({
    +        at: Date.parse(event.occurredAt), origin: "host-hook",
    +        ...event.payload,
    +      }));
    +  } catch { return []; }
    +}
    diff --git a/src/loops/events/hook-observation.test.mjs b/src/loops/events/hook-observation.test.mjs
    new file mode 100644
    index 00000000..3d38011d
    --- /dev/null
    +++ b/src/loops/events/hook-observation.test.mjs
    @@ -0,0 +1,131 @@
    +import assert from "node:assert/strict";
    +import { spawnSync } from "node:child_process";
    +import { mkdtempSync, rmSync } from "node:fs";
    +import { tmpdir } from "node:os";
    +import { dirname, join, resolve } from "node:path";
    +import { fileURLToPath } from "node:url";
    +import test from "node:test";
    +import { createProductionRunAuthority, fixtureItemRef } from "../run/run-test-fixtures.mjs";
    +import { readOvenEvents } from "../../events/oven-event-store.mjs";
    +import { readLatestRunForItem } from "../run/read-projection.mjs";
    +import { publishNativeLoopObservation } from "./hook-observation.mjs";
    +
    +const root = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
    +const cli = join(root, "bin", "burnlist.mjs");
    +function command(repo, args) {
    +  const result = spawnSync(process.execPath, [cli, "loop", ...args, "--repo", repo],
    +    { cwd: repo, encoding: "utf8" });
    +  assert.equal(result.status, 0, result.stderr);
    +  return result.stdout;
    +}
    +function fixture(t) {
    +  const directory = mkdtempSync(join(tmpdir(), "loop-hook-observation-"));
    +  t.after(() => rmSync(directory, { recursive: true, force: true }));
    +  const { repo } = createProductionRunAuthority(join(directory, "repo"));
    +  const runId = JSON.parse(command(repo, ["create", fixtureItemRef])).runId;
    +  command(repo, ["next", runId]);
    +  return { repo, runId };
    +}
    +function codexPayload(repo, event, patch = {}) {
    +  return {
    +    session_id: "codex-session", cwd: repo, hook_event_name: event,
    +    model: "gpt-5.6-sol", turn_id: "turn-1", ...patch,
    +  };
    +}
    +
    +test("native lifecycle and tool hooks publish correlated observational events", (t) => {
    +  const { repo, runId } = fixture(t);
    +  const start = publishNativeLoopObservation({
    +    repoRoot: repo, provider: "codex",
    +    payload: codexPayload(repo, "SessionStart", { source: "startup" }),
    +  });
    +  assert.equal(start.authority, "observational");
    +  const tool = publishNativeLoopObservation({
    +    repoRoot: repo, provider: "codex",
    +    payload: codexPayload(repo, "PostToolUse", {
    +      tool_name: "apply_patch", tool_use_id: "tool-1",
    +      tool_input: { command: "*** Begin Patch\n*** Update File: src/example.mjs\n*** End Patch" },
    +    }),
    +  });
    +  assert.equal(tool.payload.model, "gpt-5.6-sol");
    +  assert.deepEqual(tool.payload.observedPaths, ["src/example.mjs"]);
    +  assert.equal(tool.payload.effort, null);
    +  assert.equal(tool.payload.inputTokens, null);
    +  assert.doesNotMatch(JSON.stringify(tool), /codex-session|claimId|invocationId|dispatchAuthority/u);
    +
    +  const projection = readLatestRunForItem({ repoRoot: repo, itemRef: fixtureItemRef });
    +  assert.equal(projection.runId, runId);
    +  assert.equal(projection.currentNode, "implement");
    +  assert.equal(projection.activity.hooks, "available");
    +  assert.ok(projection.activity.records.some((entry) =>
    +    entry.kind === "tool-finished" && entry.observedPaths[0] === "src/example.mjs"));
    +});
    +
    +test("Claude facts retain exposed effort and usage while missing fields stay null", (t) => {
    +  const { repo, runId } = fixture(t);
    +  publishNativeLoopObservation({
    +    repoRoot: repo, provider: "claude",
    +    payload: {
    +      session_id: "claude-session", cwd: repo, hook_event_name: "SessionStart",
    +      source: "startup", model: "claude-opus-4-6",
    +    },
    +  });
    +  const event = publishNativeLoopObservation({
    +    repoRoot: repo, provider: "claude",
    +    payload: {
    +      session_id: "claude-session", cwd: repo, hook_event_name: "PostToolUse",
    +      prompt_id: "prompt-1", effort: { level: "xhigh" }, tool_name: "Agent",
    +      tool_use_id: "tool-agent-1", tool_input: {},
    +      tool_response: {
    +        resolvedModel: "claude-sonnet-4-6",
    +        usage: { input_tokens: 8320, output_tokens: 412 },
    +      },
    +    },
    +  });
    +  assert.equal(event.payload.model, "claude-sonnet-4-6");
    +  assert.equal(event.payload.effort, "xhigh");
    +  assert.equal(event.payload.inputTokens, 8320);
    +  assert.equal(event.payload.outputTokens, 412);
    +  const records = readLatestRunForItem({ repoRoot: repo, itemRef: fixtureItemRef }).activity.records;
    +  const observed = records.find((entry) => entry.tool === "Agent");
    +  assert.deepEqual({
    +    model: observed.model, effort: observed.effort,
    +    inputTokens: observed.inputTokens, outputTokens: observed.outputTokens,
    +  }, {
    +    model: "claude-sonnet-4-6", effort: "xhigh",
    +    inputTokens: 8320, outputTokens: 412,
    +  });
    +  assert.equal(readOvenEvents(repo, { ovenIds: ["checklist"], limit: 100 })
    +    .filter((entry) => entry.payload?.runId === runId).length, 2);
    +});
    +
    +test("hook replay cannot report outcomes or advance canonical Run state", (t) => {
    +  const { repo, runId } = fixture(t);
    +  const payload = codexPayload(repo, "SubagentStop", {
    +    agent_id: "agent-1", agent_type: "worker", outcome: "complete",
    +  });
    +  const first = publishNativeLoopObservation({ repoRoot: repo, provider: "codex", payload });
    +  const second = publishNativeLoopObservation({ repoRoot: repo, provider: "codex", payload });
    +  assert.equal(first.eventId, second.eventId);
    +  assert.equal(Object.hasOwn(first.payload, "outcome"), false);
    +  assert.equal(JSON.parse(command(repo, ["status", runId])).currentNode, "implement");
    +  command(repo, ["submit", runId, "--outcome", "complete"]);
    +  assert.equal(publishNativeLoopObservation({
    +    repoRoot: repo, provider: "codex",
    +    payload: codexPayload(repo, "PostToolUse", {
    +      tool_name: "Bash", tool_use_id: "late-tool", tool_input: {},
    +    }),
    +  }), null);
    +});
    +
    +test("providers without a verified native hook contract are unsupported", (t) => {
    +  const { repo } = fixture(t);
    +  assert.equal(publishNativeLoopObservation({
    +    repoRoot: repo, provider: "agy",
    +    payload: { session_id: "session", hook_event_name: "SessionStart" },
    +  }), null);
    +  assert.equal(publishNativeLoopObservation({
    +    repoRoot: repo, provider: "grok",
    +    payload: { session_id: "session", hook_event_name: "SessionStart" },
    +  }), null);
    +});
    diff --git a/src/loops/run/controller.mjs b/src/loops/run/controller.mjs
    index edb4d66a..24df6f6d 100644
    --- a/src/loops/run/controller.mjs
    +++ b/src/loops/run/controller.mjs
    @@ -3,6 +3,8 @@ import { isRunRef } from "./run-ref.mjs";
     import { prepareHostClaim, presentHostTask } from "./host-execution.mjs";
     import { deriveCandidate } from "./candidate.mjs";
     import { validateHostExecutionEnvelope, validateHostSemanticResult } from "../contracts/host-execution.mjs";
    +import { activateLoopHookContext } from "../events/hook-context.mjs";
    +import { readLoopObservationRecords } from "../events/hook-observation.mjs";
     
     const fail = (message, code = "ELOOP_CONTROL") => { throw Object.assign(new Error(`Loop control: ${message}`), { code }); };
     const stable = (value) => `${JSON.stringify(value)}\n`;
    @@ -12,11 +14,14 @@ export function createLoopController({ store, runnerFor, repoRoot = null }) {
       if (!store?.read || !store?.list || !store?.acquireLease || !store?.terminalize || !store?.bindExternalClaim || !store?.readExternalClaim || !store?.abandonExternalClaim || !store?.acceptExternalReport || !store?.resolveClaimRef) fail("invalid controller input");
       const check = (runId) => { if (!isRunRef(runId)) fail("invalid RunRef"); return runId; };
       const read = (runId) => store.read(check(runId));
    -  const inspect = (runId) => Object.freeze(presentRun(read(runId)));
    +  const presented = (runId) => presentRun(read(runId), {
    +    optionalRecords: repoRoot ? readLoopObservationRecords(repoRoot, runId) : [],
    +  });
    +  const inspect = (runId) => Object.freeze(presented(runId));
       // Status is a compact public projection, never the internal fold object.
       // This keeps frozen Loop identity and journal timestamps available to CLI
       // users without exposing dispatch authority or invocation internals.
    -  const status = (runId) => Object.freeze({ ...presentRun(read(runId)), schema: "burnlist-loop-status@1" });
    +  const status = (runId) => Object.freeze({ ...presented(runId), schema: "burnlist-loop-status@1" });
       const list = () => Object.freeze(store.list().map((run) => ({ schema: "burnlist-loop-status@1", ...run })));
       function idleLease(runId) {
         const current = read(runId);
    @@ -52,6 +57,10 @@ export function createLoopController({ store, runnerFor, repoRoot = null }) {
       }
       function next(runId) {
         const prepared = claim(runId);
    +    const envelope = validateHostExecutionEnvelope(prepared.envelope);
    +    activateLoopHookContext(repoRoot, {
    +      replay: read(runId), claim: prepared.claim, envelope: envelope.value,
    +    });
         return presentHostTask(prepared.envelope);
       }
       async function report(claimRef, bytes) {
    @@ -61,7 +70,8 @@ export function createLoopController({ store, runnerFor, repoRoot = null }) {
         if (typeof runnerFor !== "function") fail("system runner is unavailable", "ERUNNER_UNAVAILABLE");
         const runner = runnerFor(runId);
         if (!runner?.runToHostBoundary) fail("system runner is unavailable", "ERUNNER_UNAVAILABLE");
    -    return presentRun(await runner.runToHostBoundary());
    +    await runner.runToHostBoundary();
    +    return presented(runId);
       }
       async function submit(runId, bytes) {
         check(runId);
    diff --git a/src/loops/run/read-projection.mjs b/src/loops/run/read-projection.mjs
    index ec339dd9..9d67e0d4 100644
    --- a/src/loops/run/read-projection.mjs
    +++ b/src/loops/run/read-projection.mjs
    @@ -8,6 +8,7 @@ import { assignmentStore } from "../assignment/store.mjs";
     import { currentRunAuthority } from "./current-authority.mjs";
     import { runStore } from "./run-store.mjs";
     import { projectRunActivity } from "../events/activity-projection.mjs";
    +import { readLoopObservationRecords } from "../events/hook-observation.mjs";
     
     const MAX_RUNS = 128;
     const RECEIPT_KEYS = ["schema", "runId", "itemRef", "assignmentId", "completedAt", "title", "planDigest"];
    @@ -45,7 +46,7 @@ export function presentGraph(graph, routes = []) {
       });
     }
     
    -export function presentRun(replay) {
    +export function presentRun(replay, { optionalRecords = [] } = {}) {
       const records = replay.journal;
       let latestResult = null;
       const transitions = [];
    @@ -81,7 +82,9 @@ export function presentRun(replay) {
             ? "reported" : "unavailable",
           telemetry: replay.execution.telemetry,
         },
    -    activity: projectRunActivity({ runId: replay.projection.runId, graph: replay.graph, journal: records }),
    +    activity: projectRunActivity({
    +      runId: replay.projection.runId, graph: replay.graph, journal: records, optionalRecords,
    +    }),
         latestResult,
         latestMaker: replay.projection.latestMaker,
         latestCheck: replay.projection.latestCheck,
    @@ -100,6 +103,7 @@ export function presentRun(replay) {
     
     /** Read-only bounded discovery. Missing state returns null and never creates directories. */
     export function readLatestRunForItem({ repoRoot, itemRef, markdown = null, itemId = null, assignmentId = null }) {
    +  const root = resolve(repoRoot);
       let artifact = null;
       if (markdown !== null || itemId !== null || assignmentId !== null) {
         try {
    @@ -116,9 +120,9 @@ export function readLatestRunForItem({ repoRoot, itemRef, markdown = null, itemI
           return null;
         }
       }
    -  const runs = join(resolve(repoRoot), ".local", "burnlist", "loop", "m2", "runs");
    +  const runs = join(root, ".local", "burnlist", "loop", "m2", "runs");
       if (!existsSync(runs)) return null;
    -  const base = join(resolve(repoRoot), ".local", "burnlist", "loop", "m2");
    +  const base = join(root, ".local", "burnlist", "loop", "m2");
       const current = currentRunAuthority({ root: repoRoot, base, random: () => Buffer.alloc(8) }).read()
         .find((entry) => entry.itemRef === itemRef) ?? null;
       if (current && artifact && current.assignmentId !== artifact.assignmentId) return null;
    @@ -149,7 +153,9 @@ export function readLatestRunForItem({ repoRoot, itemRef, markdown = null, itemI
       const stored = runStore(repoRoot).read(selected.projection.runId);
       selected.loopIdentity = stored.loopIdentity;
       selected.agentRoutes = stored.agentRoutes;
    -  return presentRun(selected);
    +  return presentRun(selected, {
    +    optionalRecords: readLoopObservationRecords(root, selected.projection.runId),
    +  });
     }
     
     /** Resolve a completed ledger item through its retained CLI completion receipt. */
    diff --git a/website/src/content/docs/loops.mdx b/website/src/content/docs/loops.mdx
    index 107b52ad..459fd5e9 100644
    --- a/website/src/content/docs/loops.mdx
    +++ b/website/src/content/docs/loops.mdx
    @@ -255,6 +255,7 @@ Do not present any of those as shipped Loop features.
     ## Skills and hooks are separate
     
     `burnlist install` makes the Burnlist skill discoverable. `burnlist hooks
    -install` installs optional per-repository Streaming Diff edit-capture hooks.
    +install` installs optional per-repository native observability hooks for
    +Streaming Diff and bounded active-Loop lifecycle activity.
     They are independent: either, both, or neither can be installed, and neither
     configures or starts a Review Loop. See [Install](/install).
    
    From e86208d7899b1f5356adf62db338e8d63dde1b5e Mon Sep 17 00:00:00 2001
    From: Juan Cruz Fortunatti 
    Date: Sat, 25 Jul 2026 20:42:26 +0200
    Subject: [PATCH 26/37] feat: add local loop forecasts (E3)
    
    ---
     README.md                            |  12 +-
     skills/burnlist/SKILL.md             |   5 +-
     src/cli/commands-help.test.mjs       |   3 +-
     src/cli/loop-runtime-cli.test.mjs    |   4 +
     src/loops/forecast/forecast.mjs      | 151 +++++++++++++++++++
     src/loops/forecast/forecast.test.mjs | 209 +++++++++++++++++++++++++++
     src/loops/forecast/history.mjs       |  87 +++++++++++
     src/loops/run/controller.mjs         |  26 +++-
     src/loops/run/read-projection.mjs    |   8 +-
     src/server/run-routes.test.mjs       |   3 +-
     website/src/content/docs/loops.mdx   |   8 +-
     11 files changed, 501 insertions(+), 15 deletions(-)
     create mode 100644 src/loops/forecast/forecast.mjs
     create mode 100644 src/loops/forecast/forecast.test.mjs
     create mode 100644 src/loops/forecast/history.mjs
    
    diff --git a/README.md b/README.md
    index 2937442e..a1993a33 100644
    --- a/README.md
    +++ b/README.md
    @@ -172,8 +172,9 @@ 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
    +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,
     and proof-gated `reconcile` handles a demonstrably lost claim. Only a
    @@ -190,11 +191,14 @@ 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
    +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,
    -forecasting, worktrees, and background execution are deliberately unsupported
    +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.
    diff --git a/skills/burnlist/SKILL.md b/skills/burnlist/SKILL.md
    index e769ace8..f679f578 100644
    --- a/skills/burnlist/SKILL.md
    +++ b/skills/burnlist/SKILL.md
    @@ -241,8 +241,11 @@ and atomic canonical CLI writes `enforced`; ordinary drift checks are
     `detected-at-boundaries`; reviewer filesystem write denial is `supervised`.
     It is not Docker or an OS sandbox. Parallelism, nested agents, metric gates,
     worktrees, background execution, Docker isolation, and
    -forecasting are `unsupported`. The Checklist UI is read-only and displays
    +automatic provider scheduling are `unsupported`. The Checklist UI is read-only and displays
     active, paused, error, and terminal Run states; it never controls a Run.
    +Its duration, aggregate-work, and token forecasts are bounded estimates with
    +explicit confidence and provenance. Missing usage or pricing remains
    +unavailable rather than becoming fabricated cost.
     Branch fan-out remains one canonical `branches` node in Stage 1, so N, slice
     names, and native worker state are host evidence rather than dashboard graph
     state.
    diff --git a/src/cli/commands-help.test.mjs b/src/cli/commands-help.test.mjs
    index 29758fef..6873e32c 100644
    --- a/src/cli/commands-help.test.mjs
    +++ b/src/cli/commands-help.test.mjs
    @@ -232,7 +232,8 @@ test("Review Loop documentation preserves Stage 1 boundaries", () => {
       assert.match(files, /[Pp]arallelism.*unsupported/us);
       assert.match(files, /Docker isolation.*unsupported/us);
       assert.match(files, /never .*launches an? .*provider/us);
    -  assert.match(files, /forecasting.*unsupported/us);
    +  assert.match(files, /forecast.*confidence.*provenance/us);
    +  assert.match(files, /pricing.*cost/us);
       assert.match(files, /skill.*hooks.*independent/us);
       assert.match(files, /loop-capability-example\.json/us);
       assert.match(files, /loop-provider-setup\.md/us);
    diff --git a/src/cli/loop-runtime-cli.test.mjs b/src/cli/loop-runtime-cli.test.mjs
    index db91c7d4..d25a7f6b 100644
    --- a/src/cli/loop-runtime-cli.test.mjs
    +++ b/src/cli/loop-runtime-cli.test.mjs
    @@ -97,6 +97,10 @@ test("next and submit hide claim mechanics while advancing the Run", (t) => {
         visited.push(task.nodeId);
         assert.equal(task.schema, "burnlist-loop-host-task@1");
         assert.match(task.prompt, /Assigned item:/u);
    +    assert.equal(task.forecast.schema, "burnlist-loop-forecast@1");
    +    assert.equal(task.forecast.confidence, "low");
    +    assert.equal(task.forecast.provenance.kind, "built-in-prior");
    +    assert.equal(task.forecast.cost, null);
         assert.doesNotMatch(raw, /claimId|invocationId|assignmentId|dispatchAuthority|invocationInput/u);
         command(repo, ["submit", runId, "--outcome",
           task.mode === "review" ? "approve" : "complete"]);
    diff --git a/src/loops/forecast/forecast.mjs b/src/loops/forecast/forecast.mjs
    new file mode 100644
    index 00000000..1b471faa
    --- /dev/null
    +++ b/src/loops/forecast/forecast.mjs
    @@ -0,0 +1,151 @@
    +import { createHash } from "node:crypto";
    +import { appendForecastObservation, readForecastHistory } from "./history.mjs";
    +
    +const PRIOR = {
    +  low: { wall: [30_000, 300_000], work: [30_000, 300_000], tokens: [1_000, 12_000] },
    +  medium: { wall: [120_000, 900_000], work: [120_000, 900_000], tokens: [4_000, 32_000] },
    +  high: { wall: [300_000, 2_700_000], work: [300_000, 2_700_000], tokens: [10_000, 96_000] },
    +};
    +const safeKey = (value) => value === null || typeof value === "string";
    +const clamp = (value) => Math.max(0, Math.round(value));
    +const quantile = (sorted, ratio) => sorted[Math.min(sorted.length - 1,
    +  Math.max(0, Math.floor((sorted.length - 1) * ratio)))];
    +const range = (samples, prior) => {
    +  if (!samples.length) return { low: prior[0], high: prior[1], sampleCount: 0 };
    +  const sorted = [...samples].sort((left, right) => left - right);
    +  if (sorted.length === 1) return {
    +    low: clamp(Math.min(prior[0], sorted[0] * 0.7)),
    +    high: clamp(Math.max(prior[1], sorted[0] * 1.3)), sampleCount: 1,
    +  };
    +  const low = quantile(sorted, 0.1), high = quantile(sorted, 0.9);
    +  return {
    +    low: clamp(low * 0.8), high: clamp(Math.max(low, high) * 1.2),
    +    sampleCount: sorted.length,
    +  };
    +};
    +export function complexityBand(node) {
    +  return node?.intelligence === "fast" ? "low"
    +    : ["strong", "critical"].includes(node?.intelligence) ? "high" : "medium";
    +}
    +function sameKey(entry, key) {
    +  return entry.role === key.role && entry.provider === key.provider
    +    && entry.model === key.model && entry.effort === key.effort
    +    && entry.complexityBand === key.complexityBand;
    +}
    +export function forecastForKey(history, key) {
    +  if (!key || typeof key.role !== "string" || !safeKey(key.provider)
    +    || !safeKey(key.model) || !safeKey(key.effort) || !PRIOR[key.complexityBand])
    +    throw new Error("Loop forecast key is invalid.");
    +  const matching = history.observations.filter((entry) => sameKey(entry, key));
    +  const prior = PRIOR[key.complexityBand];
    +  const wallTime = range(matching.flatMap((entry) =>
    +    entry.wallMilliseconds === null ? [] : [entry.wallMilliseconds]), prior.wall);
    +  const aggregateWork = range(matching.flatMap((entry) =>
    +    entry.workMilliseconds === null ? [] : [entry.workMilliseconds]), prior.work);
    +  const totalTokens = range(matching.flatMap((entry) =>
    +    entry.inputTokens === null || entry.outputTokens === null
    +      ? [] : [entry.inputTokens + entry.outputTokens]), prior.tokens);
    +  const measured = Math.min(wallTime.sampleCount, aggregateWork.sampleCount);
    +  const confidence = measured >= 8 ? "high" : measured >= 3 ? "medium" : "low";
    +  return Object.freeze({
    +    schema: "burnlist-loop-forecast@1",
    +    key: Object.freeze({ ...key }),
    +    wallTime: Object.freeze({ ...wallTime, unit: "milliseconds" }),
    +    aggregateWork: Object.freeze({ ...aggregateWork, unit: "milliseconds" }),
    +    totalTokens: Object.freeze({ ...totalTokens, unit: "tokens" }),
    +    confidence,
    +    provenance: Object.freeze({
    +      kind: matching.length ? "local-observations" : "built-in-prior",
    +      matchingObservations: matching.length,
    +      tokenObservations: totalTokens.sampleCount,
    +      parallelObservations: matching.filter((entry) => entry.parallelObserved).length,
    +    }),
    +    cost: null,
    +    costProvenance: "unavailable",
    +  });
    +}
    +function latestFact(records, key) {
    +  return [...records].reverse().find((entry) => entry[key] !== null
    +    && entry[key] !== undefined)?.[key] ?? null;
    +}
    +export function forecastLoopRun({ repoRoot, replay, optionalRecords = [] }) {
    +  if (replay?.execution?.terminal || replay?.execution?.node?.kind !== "agent") return null;
    +  const node = replay.execution.node;
    +  const route = replay.agentRoutes?.find((entry) => entry.route === node.route) ?? null;
    +  const key = {
    +    role: node.role,
    +    provider: latestFact(optionalRecords, "provider") ?? route?.adapter ?? null,
    +    model: latestFact(optionalRecords, "model") ?? route?.model ?? null,
    +    effort: latestFact(optionalRecords, "effort") ?? route?.effort ?? null,
    +    complexityBand: complexityBand(node),
    +  };
    +  try { return forecastForKey(readForecastHistory(repoRoot), key); }
    +  catch { return forecastForKey({ observations: [] }, key); }
    +}
    +function activityCorrelation(runId, nodeId, attempt, invocationId) {
    +  return `ac1-sha256:${createHash("sha256")
    +    .update(`${runId}\0${nodeId}\0${attempt}\0${invocationId}`).digest("hex")}`;
    +}
    +function measurements(records, telemetry) {
    +  const starts = records.filter((entry) => entry.kind.endsWith("-started")).map((entry) => entry.at);
    +  const finishes = records.filter((entry) =>
    +    entry.kind.endsWith("-finished") || entry.kind.endsWith("-failed")).map((entry) => entry.at);
    +  const hookWall = starts.length && finishes.length
    +    ? Math.max(0, Math.max(...finishes) - Math.min(...starts)) : null;
    +  const hostWall = telemetry?.startedAt !== null && telemetry?.startedAt !== undefined
    +    && telemetry?.completedAt !== null && telemetry?.completedAt !== undefined
    +    ? telemetry.completedAt - telemetry.startedAt : null;
    +  const wallMilliseconds = hostWall ?? hookWall;
    +  const durations = records.flatMap((entry) =>
    +    Number.isSafeInteger(entry.durationMilliseconds) ? [entry.durationMilliseconds] : []);
    +  const summedWork = durations.reduce((sum, value) => sum + value, 0);
    +  const workMilliseconds = durations.length && Number.isSafeInteger(summedWork)
    +    ? summedWork : wallMilliseconds;
    +  const nativeTokenRecords = records.filter((entry) =>
    +    entry.inputTokens !== null && entry.outputTokens !== null);
    +  const nativeInputTokens = nativeTokenRecords.reduce((sum, entry) => sum + entry.inputTokens, 0);
    +  const nativeOutputTokens = nativeTokenRecords.reduce((sum, entry) => sum + entry.outputTokens, 0);
    +  return {
    +    wallMilliseconds, workMilliseconds,
    +    inputTokens: telemetry?.inputTokens
    +      ?? (nativeTokenRecords.length && Number.isSafeInteger(nativeInputTokens) ? nativeInputTokens : null),
    +    outputTokens: telemetry?.outputTokens
    +      ?? (nativeTokenRecords.length && Number.isSafeInteger(nativeOutputTokens) ? nativeOutputTokens : null),
    +    parallelObserved: wallMilliseconds !== null && workMilliseconds !== null
    +      && workMilliseconds > wallMilliseconds,
    +  };
    +}
    +
    +/** Learn only after a core-accepted semantic report exists in the Run journal. */
    +export function recordAcceptedLoopObservation({
    +  repoRoot, replay, claimId, optionalRecords = [], completedAt = null,
    +}) {
    +  const accepted = replay.journal.find((record) =>
    +    record.value.type === "external-report-accepted"
    +    && record.value.payload.claimId === claimId);
    +  const bound = replay.journal.find((record) =>
    +    record.value.type === "external-claim-bound"
    +    && record.value.payload.claim.claimId === claimId);
    +  if (!accepted || !bound) return null;
    +  const claim = bound.value.payload.claim;
    +  const node = replay.graph.nodes.find((entry) => entry.id === claim.nodeId);
    +  if (node?.kind !== "agent") return null;
    +  const expected = activityCorrelation(replay.runId, claim.nodeId, claim.attempt,
    +    bound.value.payload.invocationId);
    +  const records = optionalRecords.filter((entry) => entry.correlation === expected);
    +  const telemetry = accepted.value.payload.telemetry;
    +  const measured = measurements(records, telemetry);
    +  if (measured.wallMilliseconds === null && measured.workMilliseconds === null
    +    && measured.inputTokens === null && measured.outputTokens === null) return null;
    +  const provider = telemetry?.provider ?? latestFact(records, "provider");
    +  const model = telemetry?.model ?? latestFact(records, "model");
    +  const effort = telemetry?.effort ?? latestFact(records, "effort");
    +  const provenance = ["semantic-completion",
    +    ...(telemetry ? ["host-telemetry"] : []),
    +    ...(records.length ? ["native-hooks"] : [])];
    +  return appendForecastObservation(repoRoot, {
    +    correlation: `${expected}\0${accepted.value.payload.reportDigest}`,
    +    role: node.role, provider, model, effort, complexityBand: complexityBand(node),
    +    completedAt: completedAt ?? accepted.value.at, ...measured, provenance,
    +  });
    +}
    diff --git a/src/loops/forecast/forecast.test.mjs b/src/loops/forecast/forecast.test.mjs
    new file mode 100644
    index 00000000..20aae14c
    --- /dev/null
    +++ b/src/loops/forecast/forecast.test.mjs
    @@ -0,0 +1,209 @@
    +import assert from "node:assert/strict";
    +import { createHash } from "node:crypto";
    +import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
    +import os from "node:os";
    +import { join } from "node:path";
    +import test from "node:test";
    +import {
    +  complexityBand,
    +  forecastForKey,
    +  forecastLoopRun,
    +  recordAcceptedLoopObservation,
    +} from "./forecast.mjs";
    +import {
    +  appendForecastObservation,
    +  FORECAST_HISTORY_LIMIT,
    +  readForecastHistory,
    +} from "./history.mjs";
    +
    +function fixture(t) {
    +  const root = mkdtempSync(join(os.tmpdir(), "loop-forecast-"));
    +  t.after(() => rmSync(root, { recursive: true, force: true }));
    +  return root;
    +}
    +const key = {
    +  role: "maker", provider: "codex", model: "gpt-test",
    +  effort: "low", complexityBand: "medium",
    +};
    +function observation(index, overrides = {}) {
    +  return {
    +    correlation: `activity-${index}`,
    +    ...key,
    +    completedAt: 1_000 + index,
    +    wallMilliseconds: 1_000 + index * 100,
    +    workMilliseconds: 1_200 + index * 100,
    +    inputTokens: 100 + index,
    +    outputTokens: 20 + index,
    +    parallelObserved: true,
    +    provenance: ["semantic-completion", "native-hooks"],
    +    ...overrides,
    +  };
    +}
    +function correlation(runId, nodeId, attempt, invocationId) {
    +  return `ac1-sha256:${createHash("sha256")
    +    .update(`${runId}\0${nodeId}\0${attempt}\0${invocationId}`).digest("hex")}`;
    +}
    +function replay({ accepted = true, telemetry = null } = {}) {
    +  const runId = "run:01arz3ndektsv4rrffq69g5fav";
    +  const claim = { claimId: "hc1-sha256:" + "a".repeat(64), nodeId: "make", attempt: 1 };
    +  const invocationId = "b".repeat(32);
    +  const journal = [{
    +    value: {
    +      at: 100, type: "external-claim-bound",
    +      payload: { claim, invocationId },
    +    },
    +  }];
    +  if (accepted) journal.push({
    +    value: {
    +      at: 2_000, type: "external-report-accepted",
    +      payload: {
    +        claimId: claim.claimId, reportDigest: "sha256:" + "c".repeat(64),
    +        telemetry,
    +      },
    +    },
    +  });
    +  return {
    +    value: {
    +      runId, graph: { nodes: [{
    +        id: "make", kind: "agent", role: "maker", intelligence: "standard",
    +      }] },
    +      journal,
    +    },
    +    claim,
    +    invocationId,
    +    correlation: correlation(runId, "make", 1, invocationId),
    +  };
    +}
    +
    +test("empty local history uses explicit low-confidence priors without invented cost", (t) => {
    +  const root = fixture(t);
    +  const forecast = forecastForKey(readForecastHistory(root), key);
    +  assert.equal(forecast.provenance.kind, "built-in-prior");
    +  assert.equal(forecast.confidence, "low");
    +  assert.equal(forecast.wallTime.sampleCount, 0);
    +  assert.equal(forecast.totalTokens.sampleCount, 0);
    +  assert.equal(forecast.cost, null);
    +  assert.equal(forecast.costProvenance, "unavailable");
    +});
    +
    +test("robust ranges calibrate only against the exact role/provider/model/effort key", (t) => {
    +  const root = fixture(t);
    +  for (let index = 0; index < 3; index += 1) {
    +    appendForecastObservation(root, observation(index));
    +  }
    +  appendForecastObservation(root, observation(9, { model: "another-model" }));
    +  const forecast = forecastForKey(readForecastHistory(root), key);
    +  assert.equal(forecast.provenance.kind, "local-observations");
    +  assert.equal(forecast.provenance.matchingObservations, 3);
    +  assert.equal(forecast.confidence, "medium");
    +  assert.deepEqual(
    +    { low: forecast.wallTime.low, high: forecast.wallTime.high },
    +    { low: 800, high: 1320 },
    +  );
    +  assert.equal(forecast.totalTokens.sampleCount, 3);
    +  assert.equal(forecast.provenance.parallelObservations, 3);
    +});
    +
    +test("a run forecast carries route facts when hook facts are unavailable", (t) => {
    +  const root = fixture(t);
    +  const value = forecastLoopRun({
    +    repoRoot: root,
    +    replay: {
    +      execution: {
    +        terminal: false,
    +        node: { kind: "agent", role: "reviewer", intelligence: "strong", route: "review.strong" },
    +      },
    +      agentRoutes: [{
    +        route: "review.strong", adapter: "codex", model: "gpt-strong", effort: "high",
    +      }],
    +    },
    +  });
    +  assert.deepEqual(value.key, {
    +    role: "reviewer", provider: "codex", model: "gpt-strong",
    +    effort: "high", complexityBand: "high",
    +  });
    +  assert.equal(complexityBand({ intelligence: "fast" }), "low");
    +});
    +
    +test("learning occurs only after semantic acceptance and preserves parallel work", (t) => {
    +  const root = fixture(t);
    +  const pending = replay({ accepted: false });
    +  assert.equal(recordAcceptedLoopObservation({
    +    repoRoot: root, replay: pending.value, claimId: pending.claim.claimId,
    +  }), null);
    +  assert.equal(existsSync(join(root, ".local", "burnlist", "loop-forecast-history.json")), false);
    +
    +  const accepted = replay();
    +  const result = recordAcceptedLoopObservation({
    +    repoRoot: root,
    +    replay: accepted.value,
    +    claimId: accepted.claim.claimId,
    +    optionalRecords: [
    +      {
    +        correlation: accepted.correlation, kind: "agent-started", at: 1_000,
    +        durationMilliseconds: null, provider: "codex", model: "gpt-test", effort: "low",
    +        inputTokens: null, outputTokens: null,
    +      },
    +      {
    +        correlation: accepted.correlation, kind: "tool-finished", at: 1_800,
    +        durationMilliseconds: 1_500, provider: "codex", model: "gpt-test", effort: "low",
    +        inputTokens: 100, outputTokens: 20,
    +      },
    +      {
    +        correlation: accepted.correlation, kind: "agent-finished", at: 1_700,
    +        durationMilliseconds: 300, provider: "codex", model: "gpt-test", effort: "low",
    +        inputTokens: 50, outputTokens: 10,
    +      },
    +    ],
    +  });
    +  assert.equal(result.created, true);
    +  assert.equal(result.observation.completedAt, 2_000);
    +  assert.equal(result.observation.wallMilliseconds, 800);
    +  assert.equal(result.observation.workMilliseconds, 1_800);
    +  assert.equal(result.observation.parallelObserved, true);
    +  assert.equal(result.observation.inputTokens, 150);
    +  assert.equal(result.observation.outputTokens, 30);
    +  assert.equal(recordAcceptedLoopObservation({
    +    repoRoot: root, replay: accepted.value, claimId: accepted.claim.claimId,
    +    optionalRecords: [],
    +  }), null);
    +  assert.equal(readForecastHistory(root).observations.length, 1);
    +});
    +
    +test("host telemetry is attributable while missing token facts stay missing", (t) => {
    +  const root = fixture(t);
    +  const telemetry = {
    +    provider: "claude", model: "sonnet", effort: null,
    +    startedAt: 1_000, completedAt: 1_900,
    +    inputTokens: null, outputTokens: null,
    +  };
    +  const accepted = replay({ telemetry });
    +  const result = recordAcceptedLoopObservation({
    +    repoRoot: root, replay: accepted.value, claimId: accepted.claim.claimId,
    +  });
    +  assert.equal(result.observation.wallMilliseconds, 900);
    +  assert.equal(result.observation.workMilliseconds, 900);
    +  assert.equal(result.observation.inputTokens, null);
    +  assert.equal(result.observation.outputTokens, null);
    +  const forecast = forecastForKey(readForecastHistory(root), {
    +    role: "maker", provider: "claude", model: "sonnet",
    +    effort: null, complexityBand: "medium",
    +  });
    +  assert.equal(forecast.totalTokens.sampleCount, 0);
    +  assert.equal(forecast.cost, null);
    +});
    +
    +test("private history is canonical, bounded, deduplicated, and stores no raw content", (t) => {
    +  const root = fixture(t);
    +  for (let index = 0; index < FORECAST_HISTORY_LIMIT + 2; index += 1) {
    +    appendForecastObservation(root, observation(index));
    +  }
    +  const history = readForecastHistory(root);
    +  assert.equal(history.observations.length, FORECAST_HISTORY_LIMIT);
    +  assert.equal(history.observations[0].completedAt, 1_002);
    +  const text = readFileSync(join(root, ".local", "burnlist", "loop-forecast-history.json"), "utf8");
    +  assert.equal(text.endsWith("\n"), true);
    +  for (const forbidden of ["correlation", "prompt", "session", "path", "pricing"]) {
    +    assert.equal(text.includes(forbidden), false);
    +  }
    +});
    diff --git a/src/loops/forecast/history.mjs b/src/loops/forecast/history.mjs
    new file mode 100644
    index 00000000..1a0f1547
    --- /dev/null
    +++ b/src/loops/forecast/history.mjs
    @@ -0,0 +1,87 @@
    +import { createHash } from "node:crypto";
    +import { existsSync, readFileSync } from "node:fs";
    +import { containedJoin, withRepoStateLock } from "../../server/repo-state.mjs";
    +import { writeAtomicText } from "../../cli/local-exclude.mjs";
    +
    +const SCHEMA = "burnlist-loop-forecast-history@1";
    +const MAX_OBSERVATIONS = 256;
    +const MAX_BYTES = 256 * 1024;
    +const BANDS = new Set(["low", "medium", "high"]);
    +const safe = (value, maximum = 128) => typeof value === "string" && value.length > 0
    +  && value.length <= maximum && !/[\u0000-\u001f\u007f]/u.test(value);
    +const optional = (value, maximum) => value === null || safe(value, maximum);
    +const measurement = (value) => value === null
    +  || Number.isSafeInteger(value) && value >= 0;
    +const historyPath = (repoRoot) => containedJoin(repoRoot, "loop-forecast-history.json");
    +
    +function exact(value, keys) {
    +  return value && typeof value === "object" && !Array.isArray(value)
    +    && Object.keys(value).length === keys.length
    +    && keys.every((key, index) => Object.keys(value)[index] === key);
    +}
    +function validate(value) {
    +  const keys = ["id", "role", "provider", "model", "effort", "complexityBand",
    +    "completedAt", "wallMilliseconds", "workMilliseconds", "inputTokens",
    +    "outputTokens", "parallelObserved", "provenance"];
    +  if (!exact(value, keys) || !/^fo1-sha256:[a-f0-9]{64}$/u.test(value.id)
    +    || !safe(value.role, 64) || !optional(value.provider, 64)
    +    || !optional(value.model, 128) || !optional(value.effort, 32)
    +    || !BANDS.has(value.complexityBand) || !Number.isSafeInteger(value.completedAt)
    +    || value.completedAt < 0 || !measurement(value.wallMilliseconds)
    +    || !measurement(value.workMilliseconds) || !measurement(value.inputTokens)
    +    || !measurement(value.outputTokens) || typeof value.parallelObserved !== "boolean"
    +    || !Array.isArray(value.provenance) || value.provenance.length < 1
    +    || value.provenance.length > 4 || value.provenance.some((entry) => !safe(entry, 32)))
    +    throw new Error("Loop forecast observation is invalid.");
    +  return value;
    +}
    +function empty() { return { schema: SCHEMA, observations: [] }; }
    +export function readForecastHistory(repoRoot) {
    +  const path = historyPath(repoRoot);
    +  if (!existsSync(path)) return empty();
    +  const bytes = readFileSync(path);
    +  if (bytes.length < 2 || bytes.length > MAX_BYTES) throw new Error("Loop forecast history exceeds bounds.");
    +  let value;
    +  try { value = JSON.parse(bytes); } catch { throw new Error("Loop forecast history is malformed."); }
    +  if (!exact(value, ["schema", "observations"]) || value.schema !== SCHEMA
    +    || !Array.isArray(value.observations) || value.observations.length > MAX_OBSERVATIONS)
    +    throw new Error("Loop forecast history is invalid.");
    +  value.observations.forEach(validate);
    +  if (!Buffer.from(`${JSON.stringify(value)}\n`).equals(bytes))
    +    throw new Error("Loop forecast history is not canonical.");
    +  return value;
    +}
    +export function forecastObservationId(value) {
    +  const identity = [value.correlation, value.completedAt].join("\0");
    +  return `fo1-sha256:${createHash("sha256").update(identity).digest("hex")}`;
    +}
    +export function appendForecastObservation(repoRoot, input) {
    +  const observation = validate({
    +    id: input.id ?? forecastObservationId(input),
    +    role: input.role,
    +    provider: input.provider,
    +    model: input.model,
    +    effort: input.effort,
    +    complexityBand: input.complexityBand,
    +    completedAt: input.completedAt,
    +    wallMilliseconds: input.wallMilliseconds,
    +    workMilliseconds: input.workMilliseconds,
    +    inputTokens: input.inputTokens,
    +    outputTokens: input.outputTokens,
    +    parallelObserved: input.parallelObserved,
    +    provenance: input.provenance,
    +  });
    +  return withRepoStateLock(repoRoot, () => {
    +    const history = readForecastHistory(repoRoot);
    +    const prior = history.observations.find((entry) => entry.id === observation.id);
    +    if (prior) return { observation: prior, created: false };
    +    const observations = [...history.observations, observation].slice(-MAX_OBSERVATIONS);
    +    const value = { schema: SCHEMA, observations };
    +    const text = `${JSON.stringify(value)}\n`;
    +    if (Buffer.byteLength(text) > MAX_BYTES) throw new Error("Loop forecast history exceeds bounds.");
    +    writeAtomicText(historyPath(repoRoot), text);
    +    return { observation, created: true };
    +  });
    +}
    +
    +export const FORECAST_HISTORY_LIMIT = MAX_OBSERVATIONS;
    diff --git a/src/loops/run/controller.mjs b/src/loops/run/controller.mjs
    index 24df6f6d..e551c269 100644
    --- a/src/loops/run/controller.mjs
    +++ b/src/loops/run/controller.mjs
    @@ -5,6 +5,7 @@ import { deriveCandidate } from "./candidate.mjs";
     import { validateHostExecutionEnvelope, validateHostSemanticResult } from "../contracts/host-execution.mjs";
     import { activateLoopHookContext } from "../events/hook-context.mjs";
     import { readLoopObservationRecords } from "../events/hook-observation.mjs";
    +import { forecastLoopRun, recordAcceptedLoopObservation } from "../forecast/forecast.mjs";
     
     const fail = (message, code = "ELOOP_CONTROL") => { throw Object.assign(new Error(`Loop control: ${message}`), { code }); };
     const stable = (value) => `${JSON.stringify(value)}\n`;
    @@ -14,9 +15,14 @@ export function createLoopController({ store, runnerFor, repoRoot = null }) {
       if (!store?.read || !store?.list || !store?.acquireLease || !store?.terminalize || !store?.bindExternalClaim || !store?.readExternalClaim || !store?.abandonExternalClaim || !store?.acceptExternalReport || !store?.resolveClaimRef) fail("invalid controller input");
       const check = (runId) => { if (!isRunRef(runId)) fail("invalid RunRef"); return runId; };
       const read = (runId) => store.read(check(runId));
    -  const presented = (runId) => presentRun(read(runId), {
    -    optionalRecords: repoRoot ? readLoopObservationRecords(repoRoot, runId) : [],
    -  });
    +  const observations = (runId) => repoRoot ? readLoopObservationRecords(repoRoot, runId) : [];
    +  const presented = (runId) => {
    +    const replay = read(runId), optionalRecords = observations(runId);
    +    return presentRun(replay, {
    +      optionalRecords,
    +      forecast: forecastLoopRun({ repoRoot, replay, optionalRecords }),
    +    });
    +  };
       const inspect = (runId) => Object.freeze(presented(runId));
       // Status is a compact public projection, never the internal fold object.
       // This keeps frozen Loop identity and journal timestamps available to CLI
    @@ -61,12 +67,22 @@ export function createLoopController({ store, runnerFor, repoRoot = null }) {
         activateLoopHookContext(repoRoot, {
           replay: read(runId), claim: prepared.claim, envelope: envelope.value,
         });
    -    return presentHostTask(prepared.envelope);
    +    const replay = read(runId), optionalRecords = observations(runId);
    +    return Object.freeze({
    +      ...presentHostTask(prepared.envelope),
    +      forecast: forecastLoopRun({ repoRoot, replay, optionalRecords }),
    +    });
       }
       async function report(claimRef, bytes) {
         const runId = store.resolveClaimRef(claimRef), current = read(runId);
    -    store.acceptExternalReport(runId, current.execution.lease, bytes,
    +    const accepted = store.acceptExternalReport(runId, current.execution.lease, bytes,
           () => deriveCandidate({ repoRoot }));
    +    try {
    +      recordAcceptedLoopObservation({
    +        repoRoot, replay: accepted, claimId: claimRef,
    +        optionalRecords: observations(runId),
    +      });
    +    } catch {}
         if (typeof runnerFor !== "function") fail("system runner is unavailable", "ERUNNER_UNAVAILABLE");
         const runner = runnerFor(runId);
         if (!runner?.runToHostBoundary) fail("system runner is unavailable", "ERUNNER_UNAVAILABLE");
    diff --git a/src/loops/run/read-projection.mjs b/src/loops/run/read-projection.mjs
    index 9d67e0d4..7d13d715 100644
    --- a/src/loops/run/read-projection.mjs
    +++ b/src/loops/run/read-projection.mjs
    @@ -9,6 +9,7 @@ import { currentRunAuthority } from "./current-authority.mjs";
     import { runStore } from "./run-store.mjs";
     import { projectRunActivity } from "../events/activity-projection.mjs";
     import { readLoopObservationRecords } from "../events/hook-observation.mjs";
    +import { forecastLoopRun } from "../forecast/forecast.mjs";
     
     const MAX_RUNS = 128;
     const RECEIPT_KEYS = ["schema", "runId", "itemRef", "assignmentId", "completedAt", "title", "planDigest"];
    @@ -46,7 +47,7 @@ export function presentGraph(graph, routes = []) {
       });
     }
     
    -export function presentRun(replay, { optionalRecords = [] } = {}) {
    +export function presentRun(replay, { optionalRecords = [], forecast = null } = {}) {
       const records = replay.journal;
       let latestResult = null;
       const transitions = [];
    @@ -85,6 +86,7 @@ export function presentRun(replay, { optionalRecords = [] } = {}) {
         activity: projectRunActivity({
           runId: replay.projection.runId, graph: replay.graph, journal: records, optionalRecords,
         }),
    +    forecast,
         latestResult,
         latestMaker: replay.projection.latestMaker,
         latestCheck: replay.projection.latestCheck,
    @@ -153,8 +155,10 @@ export function readLatestRunForItem({ repoRoot, itemRef, markdown = null, itemI
       const stored = runStore(repoRoot).read(selected.projection.runId);
       selected.loopIdentity = stored.loopIdentity;
       selected.agentRoutes = stored.agentRoutes;
    +  const optionalRecords = readLoopObservationRecords(root, selected.projection.runId);
       return presentRun(selected, {
    -    optionalRecords: readLoopObservationRecords(root, selected.projection.runId),
    +    optionalRecords,
    +    forecast: forecastLoopRun({ repoRoot: root, replay: selected, optionalRecords }),
       });
     }
     
    diff --git a/src/server/run-routes.test.mjs b/src/server/run-routes.test.mjs
    index aa23ebf8..ec95aaad 100644
    --- a/src/server/run-routes.test.mjs
    +++ b/src/server/run-routes.test.mjs
    @@ -39,8 +39,9 @@ test("selected progress remains independent from the sanitized read-only Loop pr
         const projection = await httpRequest(baseUrl, `/api/loop-projection?plan=${encodeURIComponent(planPath)}`, { method: "GET" });
         assert.equal(projection.status, 200);
         const left = JSON.parse(projection.body).loopRun;
    -    assert.deepEqual(Object.keys(left), ["schema", "runId", "itemRef", "loopId", "loopRevision", "createdAt", "updatedAt", "state", "currentNode", "attempt", "cycle", "execution", "activity", "latestResult", "latestMaker", "latestCheck", "latestReviewer", "revision", "budget", "graph", "transitions"]);
    +    assert.deepEqual(Object.keys(left), ["schema", "runId", "itemRef", "loopId", "loopRevision", "createdAt", "updatedAt", "state", "currentNode", "attempt", "cycle", "execution", "activity", "forecast", "latestResult", "latestMaker", "latestCheck", "latestReviewer", "revision", "budget", "graph", "transitions"]);
         assert.equal(left.activity.hooks, "unavailable");
    +    assert.equal(left.forecast, null, "terminal nodes do not advertise an agent forecast");
         assert.ok(left.activity.records.length <= 10);
         assert.equal(left.execution.mode, "unavailable");
         assert.equal(left.loopId, "review");
    diff --git a/website/src/content/docs/loops.mdx b/website/src/content/docs/loops.mdx
    index 459fd5e9..0f02b60c 100644
    --- a/website/src/content/docs/loops.mdx
    +++ b/website/src/content/docs/loops.mdx
    @@ -248,8 +248,14 @@ remain Burnlist-owned and declared by the frozen Loop authority.
     Hosts return one terminal `burnlist-loop-host-report@1` for their claim. Its
     bound agent result carries one legal node-mode `outcome` and no `destination`.
     
    +Run projections and `loop next` include bounded local duration, aggregate-work,
    +and token ranges keyed by role, observed provider/model, effort, and complexity
    +band. The projection labels confidence and whether it used built-in priors or
    +truthful completed local observations. Missing usage stays unavailable; the
    +CLI does not infer subscription pricing or cost.
    +
     Parallelism, nested agents, metric gates, auto-worktrees,
    -background execution, Docker isolation, and forecasting are `unsupported`.
    +background execution, and Docker isolation are `unsupported`.
     Do not present any of those as shipped Loop features.
     
     ## Skills and hooks are separate
    
    From 964406d0bf4f08e55a5b414bd99c4ab1ad68ff92 Mon Sep 17 00:00:00 2001
    From: Juan Cruz Fortunatti 
    Date: Sat, 25 Jul 2026 20:42:56 +0200
    Subject: [PATCH 27/37] feat: expose live loop forecasts (E4)
    
    ---
     .../src/components/LoopGraph/LoopGraph.css    | 12 ++++++
     .../components/LoopGraph/LoopGraph.test.ts    | 29 +++++++++++++
     .../src/components/LoopGraph/LoopGraph.tsx    | 41 ++++++++++++++++++-
     dashboard/src/hooks/dashboard-data.mjs        | 14 ++++++-
     dashboard/src/hooks/dashboard-data.test.mjs   | 15 +++++--
     dashboard/src/lib/types.ts                    | 28 +++++++++++++
     .../src/oven/LoopProgress/LoopProgress.css    |  7 ++++
     .../oven/LoopProgress/LoopProgress.test.ts    | 29 +++++++++++++
     .../src/oven/LoopProgress/LoopProgress.tsx    | 38 ++++++++++++++++-
     9 files changed, 207 insertions(+), 6 deletions(-)
    
    diff --git a/dashboard/src/components/LoopGraph/LoopGraph.css b/dashboard/src/components/LoopGraph/LoopGraph.css
    index f980bd4b..5cadec24 100644
    --- a/dashboard/src/components/LoopGraph/LoopGraph.css
    +++ b/dashboard/src/components/LoopGraph/LoopGraph.css
    @@ -50,6 +50,18 @@
       margin: -.4rem 0 .45rem;
     }
     
    +.loop-graph__forecast {
    +  display: flex;
    +  flex-wrap: wrap;
    +  gap: .35rem 1rem;
    +  margin-top: .55rem;
    +  color: color-mix(in srgb, currentColor 56%, transparent);
    +  font-family: Menlo, Monaco, Consolas, "Liberation Mono", monospace;
    +  font-size: .68rem;
    +}
    +
    +.loop-graph__forecast span:last-child { color: var(--loop-accent); }
    +
     .loop-legend,
     .loop-compact {
       --loop-accent: #38bdf8;
    diff --git a/dashboard/src/components/LoopGraph/LoopGraph.test.ts b/dashboard/src/components/LoopGraph/LoopGraph.test.ts
    index 0d55022a..7e4a30c8 100644
    --- a/dashboard/src/components/LoopGraph/LoopGraph.test.ts
    +++ b/dashboard/src/components/LoopGraph/LoopGraph.test.ts
    @@ -76,6 +76,35 @@ test("renders bounded provenance-labelled activity and explicit hook availabilit
       assert.match(unavailable, /No activity reported/u);
     });
     
    +test("shows observed model facts and forecast provenance without inventing cost", () => {
    +  const html = renderToStaticMarkup(createElement(LoopGraph, { run: projection({
    +    activity: {
    +      hooks: "available",
    +      records: [{
    +        at: 2, origin: "host-hook", kind: "agent-started", provider: "codex",
    +        nodeId: "verify", attempt: 1, model: "gpt-test", effort: "high",
    +      }],
    +    },
    +    forecast: {
    +      schema: "burnlist-loop-forecast@1",
    +      key: { role: "reviewer", provider: "codex", model: "gpt-test", effort: "high", complexityBand: "high" },
    +      wallTime: { low: 60_000, high: 120_000, sampleCount: 4, unit: "milliseconds" },
    +      aggregateWork: { low: 90_000, high: 180_000, sampleCount: 4, unit: "milliseconds" },
    +      totalTokens: { low: 2_000, high: 8_000, sampleCount: 3, unit: "tokens" },
    +      confidence: "medium",
    +      provenance: { kind: "local-observations", matchingObservations: 4, tokenObservations: 3, parallelObservations: 2 },
    +      cost: null,
    +      costProvenance: "unavailable",
    +    },
    +  }) }));
    +  assert.match(html, /aria-label="Loop forecast"/u);
    +  assert.match(html, /OBSERVED codex · gpt-test · effort high/u);
    +  assert.match(html, /WALL 1m–2m/u);
    +  assert.match(html, /WORK 1.5m–3m/u);
    +  assert.match(html, /MEDIUM · 4 local observations/u);
    +  assert.doesNotMatch(html, /\$|cost estimate/u);
    +});
    +
     test("compact topology and legend remain independently composable", () => {
       const run = projection();
       run.graph.edges.push({ from: "verify", on: "fail", to: "implement" });
    diff --git a/dashboard/src/components/LoopGraph/LoopGraph.tsx b/dashboard/src/components/LoopGraph/LoopGraph.tsx
    index ccc125bc..584604fc 100644
    --- a/dashboard/src/components/LoopGraph/LoopGraph.tsx
    +++ b/dashboard/src/components/LoopGraph/LoopGraph.tsx
    @@ -29,7 +29,18 @@ export type LoopGraphProjection = {
       attempt: number;
       cycle: number;
       execution?: LoopRunProjectionExecution;
    -  activity?: { hooks: "unavailable" | "available"; records: Array<{ at: number; origin: "runner" | "host-hook" | "agent-reported"; kind: string; provider?: "claude" | "codex"; nodeId?: string; attempt?: number; correlation?: string; mode?: "managed" | "host-reported" | "unavailable"; capability?: string; outcome?: string; state?: string; from?: string; to?: string; reason?: string; truncated?: boolean; parentAgentId?: string; subagentId?: string; tool?: string; observedPath?: string }> };
    +  activity?: { hooks: "unavailable" | "available"; records: Array<{ at: number; origin: "runner" | "host-hook" | "agent-reported"; kind: string; provider?: "claude" | "codex"; nodeId?: string; attempt?: number; correlation?: string; mode?: "managed" | "host-reported" | "unavailable"; capability?: string; outcome?: string; state?: string; from?: string; to?: string; reason?: string; truncated?: boolean; parentAgentId?: string; subagentId?: string; tool?: string; observedPath?: string; observedPaths?: string[]; model?: string | null; effort?: string | null; durationMilliseconds?: number | null }> };
    +  forecast?: null | {
    +    schema: "burnlist-loop-forecast@1";
    +    key: { role: string; provider: string | null; model: string | null; effort: string | null; complexityBand: "low" | "medium" | "high" };
    +    wallTime: { low: number; high: number; sampleCount: number; unit: "milliseconds" };
    +    aggregateWork: { low: number; high: number; sampleCount: number; unit: "milliseconds" };
    +    totalTokens: { low: number; high: number; sampleCount: number; unit: "tokens" };
    +    confidence: "low" | "medium" | "high";
    +    provenance: { kind: "built-in-prior" | "local-observations"; matchingObservations: number; tokenObservations: number; parallelObservations: number };
    +    cost: null;
    +    costProvenance: "unavailable";
    +  };
       graph: { entry?: string; nodes: LoopGraphNode[]; edges: LoopGraphEdge[] };
       transitions?: LoopGraphTransition[];
       latestResult?: null | { kind: string; summary: string };
    @@ -54,6 +65,33 @@ type LoopRunProjectionExecution = {
       };
     };
     
    +function duration(milliseconds: number) {
    +  if (milliseconds < 1_000) return `${milliseconds}ms`;
    +  if (milliseconds < 60_000) return `${Math.round(milliseconds / 100) / 10}s`;
    +  return `${Math.round(milliseconds / 6_000) / 10}m`;
    +}
    +
    +function Forecast({ run }: { run: LoopGraphProjection }) {
    +  const forecast = run.forecast;
    +  const observed = [...(run.activity?.records ?? [])].reverse().find((record) =>
    +    record.model || record.effort || record.provider);
    +  const selection = observed
    +    ? [observed.provider, observed.model, observed.effort ? `effort ${observed.effort}` : null].filter(Boolean).join(" · ")
    +    : "model / effort unavailable";
    +  if (!forecast) return 
    + OBSERVED {selection}FORECAST unavailable for this node +
    ; + const provenance = forecast.provenance.kind === "local-observations" + ? `${forecast.provenance.matchingObservations} local observations` + : "built-in prior"; + return
    + OBSERVED {selection} + WALL {duration(forecast.wallTime.low)}–{duration(forecast.wallTime.high)} + WORK {duration(forecast.aggregateWork.low)}–{duration(forecast.aggregateWork.high)} + {forecast.confidence.toUpperCase()} · {provenance} +
    ; +} + export type LoopGraphProps = { run?: LoopGraphProjection | null; diagnostic?: "corrupt" | "stale"; @@ -148,6 +186,7 @@ export function LoopGraph({ run, diagnostic, message, title = "Loop Run" }: Loop const viewState = presentationState(run, diagnostic); return
    +
    ; } diff --git a/dashboard/src/hooks/dashboard-data.mjs b/dashboard/src/hooks/dashboard-data.mjs index 3f0ade68..b31b8bb5 100644 --- a/dashboard/src/hooks/dashboard-data.mjs +++ b/dashboard/src/hooks/dashboard-data.mjs @@ -6,11 +6,19 @@ export const dashboardProjectEvents = Object.freeze([ Object.freeze({ ovenId: "checklist", kind: "lifecycle-changed", phase: "complete" }), Object.freeze({ ovenId: "checklist", kind: "loop-projection-changed", phase: "complete" }), ]); +const dashboardLoopActivityEvents = Object.freeze([ + "agent-started", "agent-finished", "agent-failed", + "subagent-started", "subagent-finished", "subagent-failed", + "tool-started", "tool-finished", "tool-failed", +].map((phase) => Object.freeze({ + ovenId: "checklist", kind: "loop-agent-observation", phase, +}))); export const dashboardProgressEvents = Object.freeze([ Object.freeze({ ovenId: "checklist", kind: "data-published", phase: "complete" }), Object.freeze({ ovenId: "checklist", kind: "item-burned", phase: "completed" }), Object.freeze({ ovenId: "checklist", kind: "lifecycle-changed", phase: "complete" }), Object.freeze({ ovenId: "checklist", kind: "loop-projection-changed", phase: "complete" }), + ...dashboardLoopActivityEvents, ]); function selectedQuery(selected) { @@ -85,6 +93,10 @@ export function dashboardLoopProjectionSnapshotConfig(enabled, selected) { ovenId: "checklist", subjectId, query: `loop/${query}`, makeUrl: () => `/api/loop-projection?${query}`, receive: receiveLoopProjection, fallbackError: "Could not load Loop projection.", initialData: null, - events: [Object.freeze({ ovenId: "checklist", kind: "loop-projection-changed", phase: "complete" })], deps: [enabled, query], + events: [ + Object.freeze({ ovenId: "checklist", kind: "loop-projection-changed", phase: "complete" }), + ...dashboardLoopActivityEvents, + ], + deps: [enabled, query], }; } diff --git a/dashboard/src/hooks/dashboard-data.test.mjs b/dashboard/src/hooks/dashboard-data.test.mjs index d1b6b3ea..df67b5ea 100644 --- a/dashboard/src/hooks/dashboard-data.test.mjs +++ b/dashboard/src/hooks/dashboard-data.test.mjs @@ -142,7 +142,8 @@ test("loop projection uses its dedicated snapshot URL and coalesces conditional const config = dashboardLoopProjectionSnapshotConfig(true, selected); assert.equal(config.makeUrl(), "/api/loop-projection?repoKey=aaaaaaaaaaaa&id=260722-001&item=M7"); assert.equal(config.subjectId, "item:260722-001#M7"); - assert.deepEqual(config.events, [{ ovenId: "checklist", kind: "loop-projection-changed", phase: "complete" }]); + assert.deepEqual(config.events[0], { ovenId: "checklist", kind: "loop-projection-changed", phase: "complete" }); + assert.equal(config.events.filter((event) => event.kind === "loop-agent-observation").length, 9); const client = createOvenSnapshotClient({ timers, focusTarget: null, eventSourceFactory(url) { const source = new FakeEventSource(url); sources.push(source); return source; }, @@ -166,11 +167,19 @@ test("loop projection uses its dedicated snapshot URL and coalesces conditional assert.deepEqual(calls[1].headers, { "If-None-Match": '"loop-v1"' }); assert.equal(states.at(-1).outcome, "unchanged", "304 retains the canonical loop snapshot"); + sources[0].publish({ + repoKey: selected.repoKey, ovenId: "checklist", subjectId: "item:260722-001#M7", + kind: "loop-agent-observation", phase: "tool-finished", + }); + timers.flush(); + await settle(); + assert.equal(calls.length, 3, "native hook observations refetch the read-only projection"); + sources[0].reset({ repoKey: selected.repoKey, ovenId: "checklist", reason: "retention-gap" }); timers.flush(); await settle(); - assert.equal(calls.length, 3, "a matching retention gap resets the loop snapshot"); - assert.deepEqual(calls[2].headers, { "If-None-Match": '"loop-v1"' }); + assert.equal(calls.length, 4, "a matching retention gap resets the loop snapshot"); + assert.deepEqual(calls[3].headers, { "If-None-Match": '"loop-v1"' }); client.stop(); }); diff --git a/dashboard/src/lib/types.ts b/dashboard/src/lib/types.ts index df6c6140..d2d222e2 100644 --- a/dashboard/src/lib/types.ts +++ b/dashboard/src/lib/types.ts @@ -66,8 +66,36 @@ export type LoopRunProjection = { subagentId?: string; tool?: string; observedPath?: string; + observedPaths?: string[]; + model?: string | null; + effort?: string | null; + inputTokens?: number | null; + outputTokens?: number | null; + durationMilliseconds?: number | null; }>; }; + forecast?: null | { + schema: "burnlist-loop-forecast@1"; + key: { + role: string; + provider: string | null; + model: string | null; + effort: string | null; + complexityBand: "low" | "medium" | "high"; + }; + wallTime: { low: number; high: number; sampleCount: number; unit: "milliseconds" }; + aggregateWork: { low: number; high: number; sampleCount: number; unit: "milliseconds" }; + totalTokens: { low: number; high: number; sampleCount: number; unit: "tokens" }; + confidence: "low" | "medium" | "high"; + provenance: { + kind: "built-in-prior" | "local-observations"; + matchingObservations: number; + tokenObservations: number; + parallelObservations: number; + }; + cost: null; + costProvenance: "unavailable"; + }; revision: string; budget: { limits: { maxRounds: number; maxMinutes: number; maxAgentRuns: number; maxCheckRuns: number; maxTransitions: number; maxOutputBytes: number }; diff --git a/dashboard/src/oven/LoopProgress/LoopProgress.css b/dashboard/src/oven/LoopProgress/LoopProgress.css index 555a56e8..143e1acd 100644 --- a/dashboard/src/oven/LoopProgress/LoopProgress.css +++ b/dashboard/src/oven/LoopProgress/LoopProgress.css @@ -6,6 +6,7 @@ } .loop-progress__now, .loop-progress__context article, +.loop-progress__signals article, .loop-progress__loop, .loop-progress__map, .loop-progress__activity { @@ -23,6 +24,7 @@ .loop-progress__now > span, .loop-progress__context-head > span, .loop-progress__context span, +.loop-progress__signals span, .loop-progress h2 { color: var(--muted-foreground, #92a0b3); font-size: 11px; @@ -36,6 +38,9 @@ .loop-progress__context { display: grid; grid-template-columns: 2fr 1fr 1fr; gap: 10px; } .loop-progress__context article { min-width: 0; padding: 12px 14px; } .loop-progress__context p { margin: 5px 0 0; overflow-wrap: anywhere; font-size: 13px; line-height: 1.35; } +.loop-progress__signals { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; } +.loop-progress__signals article { min-width: 0; padding: 10px 12px; } +.loop-progress__signals p { margin: 5px 0 0; overflow-wrap: anywhere; font-size: 12px; line-height: 1.35; } .loop-progress__work { display: grid; gap: 12px; } .loop-progress__loop, .loop-progress__map { min-width: 0; padding: 14px; } .loop-progress h2 { margin: 0 0 12px; } @@ -64,6 +69,7 @@ .loop-progress footer > span { font-weight: 800; letter-spacing: .08em; } @media (max-width: 900px) { .loop-progress__context { grid-template-columns: 1fr 1fr; } + .loop-progress__signals { grid-template-columns: 1fr 1fr; } .loop-progress__work { grid-template-columns: 1fr; } } @media (max-width: 620px) { @@ -71,6 +77,7 @@ .loop-progress__now { grid-template-columns: auto 1fr; } .loop-progress__now small { grid-column: 2; } .loop-progress__context { grid-template-columns: 1fr; } + .loop-progress__signals { grid-template-columns: 1fr; } .loop-progress__map ol { display: grid; grid-template-columns: repeat(3, 1fr); gap: 5px; } .loop-progress__map li b { display: none; } .loop-progress__activity li { grid-template-columns: 10ch minmax(0, 1fr); } diff --git a/dashboard/src/oven/LoopProgress/LoopProgress.test.ts b/dashboard/src/oven/LoopProgress/LoopProgress.test.ts index fe13f511..d79b85fd 100644 --- a/dashboard/src/oven/LoopProgress/LoopProgress.test.ts +++ b/dashboard/src/oven/LoopProgress/LoopProgress.test.ts @@ -98,6 +98,35 @@ test("renders only bounded provenance-labelled sanitized activity", () => { assert.equal((markup.match(/subagent child-/gu) ?? []).length + (markup.match(/node-test/gu) ?? []).length, 10); }); +test("shows observed agent facts, elapsed time, and forecast provenance", () => { + const data = fixture("O0"); + data.loopRun.activity = { hooks: "available", records: [{ + at: 1, origin: "host-hook", kind: "agent-started", provider: "codex", + nodeId: "review", attempt: 1, model: "gpt-test", effort: "medium", + }] }; + data.loopRun.budget.elapsedMilliseconds = 125_000; + data.loopRun.forecast = { + schema: "burnlist-loop-forecast@1", + key: { role: "reviewer", provider: "codex", model: "gpt-test", effort: "medium", complexityBand: "high" }, + wallTime: { low: 60_000, high: 180_000, sampleCount: 5, unit: "milliseconds" }, + aggregateWork: { low: 90_000, high: 240_000, sampleCount: 5, unit: "milliseconds" }, + totalTokens: { low: 2_000, high: 9_000, sampleCount: 4, unit: "tokens" }, + confidence: "medium", + provenance: { kind: "local-observations", matchingObservations: 5, tokenObservations: 4, parallelObservations: 2 }, + cost: null, + costProvenance: "unavailable", + }; + const markup = renderToStaticMarkup(createElement(LoopProgress, { data })); + for (const label of ["AGENT", "ELAPSED", "FORECAST", "PROVENANCE"]) { + assert.match(markup, new RegExp(`>${label}<`, "u")); + } + assert.match(markup, /codex · gpt-test · effort medium/u); + assert.match(markup, /2.1 min/u); + assert.match(markup, /wall 1 min–3 min · work 1.5 min–4 min/u); + assert.match(markup, /medium · 5 local observations · tokens 2,000–9,000/u); + assert.doesNotMatch(markup, /\$|cost estimate/u); +}); + test("activity viewport has a stable ten-row height", async () => { const css = await readFile("dashboard/src/oven/LoopProgress/LoopProgress.css", "utf8"); assert.match(css, /\.loop-progress__activity ol \{[^}]*block-size: 150px;[^}]*overflow: hidden;/su); diff --git a/dashboard/src/oven/LoopProgress/LoopProgress.tsx b/dashboard/src/oven/LoopProgress/LoopProgress.tsx index 99c00915..c083576f 100644 --- a/dashboard/src/oven/LoopProgress/LoopProgress.tsx +++ b/dashboard/src/oven/LoopProgress/LoopProgress.tsx @@ -88,6 +88,21 @@ function activityText(record: NonNullable["record return [where, record.kind.replaceAll("-", " "), detail].filter(Boolean).join(" · "); } +function duration(milliseconds: number | null | undefined) { + if (milliseconds === null || milliseconds === undefined) return "Unavailable"; + if (milliseconds < 1_000) return `${milliseconds} ms`; + if (milliseconds < 60_000) return `${Math.round(milliseconds / 100) / 10} s`; + return `${Math.round(milliseconds / 6_000) / 10} min`; +} + +function durationRange(range: { low: number; high: number } | null | undefined) { + return range ? `${duration(range.low)}–${duration(range.high)}` : "Unavailable"; +} + +function tokenRange(range: { low: number; high: number } | null | undefined) { + return range ? `${range.low.toLocaleString("en-US")}–${range.high.toLocaleString("en-US")}` : "Unavailable"; +} + export function LoopProgress({ data }: { data: ChecklistProgressData }) { const item = selectedItem(data); const system = subsystem(item); @@ -97,7 +112,22 @@ export function LoopProgress({ data }: { data: ChecklistProgressData }) { const runningSystem = runSubsystem(authoritativeRun); const activity = authoritativeRun?.activity; const recentActivity = activity?.records.slice(-10) ?? []; - const observedPaths = [...new Set(recentActivity.flatMap((record) => record.observedPath ? [record.observedPath] : []))]; + const observedPaths = [...new Set(recentActivity.flatMap((record) => [ + ...(record.observedPath ? [record.observedPath] : []), + ...(record.observedPaths ?? []), + ]))]; + const observation = [...recentActivity].reverse().find((record) => + record.provider || record.model || record.effort); + const observedAgent = observation + ? [observation.provider, observation.model, observation.effort ? `effort ${observation.effort}` : null] + .filter(Boolean).join(" · ") + : "Unavailable"; + const forecast = authoritativeRun?.forecast; + const provenance = forecast + ? forecast.provenance.kind === "local-observations" + ? `${forecast.confidence} · ${forecast.provenance.matchingObservations} local observations` + : `${forecast.confidence} · built-in prior` + : "Unavailable"; return
    NOW @@ -111,6 +141,12 @@ export function LoopProgress({ data }: { data: ChecklistProgressData }) {
    SYSTEM

    {system.label}

    HOOKS

    {activity?.hooks ?? "Unavailable"}

    +
    +
    AGENT

    {observedAgent}

    +
    ELAPSED

    {duration(authoritativeRun?.budget.elapsedMilliseconds)}

    +
    FORECAST

    wall {durationRange(forecast?.wallTime)} · work {durationRange(forecast?.aggregateWork)}

    +
    PROVENANCE

    {provenance} · tokens {tokenRange(forecast?.totalTokens)}

    +
    From 105d612b68855969c305379226ac94703cf77f88 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sat, 25 Jul 2026 21:37:21 +0200 Subject: [PATCH 28/37] fix: harden minimal loop orchestration (E5) --- .../ChecklistDashboard.test.mjs | 7 +- .../checklist-dom-golden.test.mjs | 2 +- .../checklist-loop-progression.golden.json | 42 +++--- .../checklist-loop-states.golden.json | 32 ++-- .../oven/LoopProgress/LoopProgress.test.ts | 13 ++ .../src/oven/LoopProgress/LoopProgress.tsx | 8 +- scripts/package-paths.json | 13 +- scripts/verify-source-scan.mjs | 1 + scripts/verify-source-scan.test.mjs | 1 + scripts/verify-test-files.mjs | 1 + scripts/verify.mjs | 3 +- skills/burnlist/SKILL.md | 138 ++++++------------ skills/burnlist/references/host-execution.md | 4 +- src/loops/dsl/project-strategies.test.mjs | 6 +- src/loops/run/controller.mjs | 13 +- src/loops/run/host-execution.mjs | 2 +- src/loops/run/run-test-fixtures.mjs | 5 +- src/loops/run/runner.mjs | 17 ++- src/loops/run/runner.test.mjs | 7 +- src/loops/run/system-runner.test.mjs | 6 +- src/server/run-routes.test.mjs | 2 +- 21 files changed, 164 insertions(+), 159 deletions(-) diff --git a/dashboard/src/components/ChecklistDashboard/ChecklistDashboard.test.mjs b/dashboard/src/components/ChecklistDashboard/ChecklistDashboard.test.mjs index 07fc9da2..faf93c1f 100644 --- a/dashboard/src/components/ChecklistDashboard/ChecklistDashboard.test.mjs +++ b/dashboard/src/components/ChecklistDashboard/ChecklistDashboard.test.mjs @@ -67,9 +67,10 @@ test("checklist detail renders the split progress surface and event card list", 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, /INPUT/u); + assert.match(stage, /IMPLEMENT/u); + assert.match(stage, /VALIDATE/u); + assert.match(stage, /REVIEW/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); diff --git a/dashboard/src/components/ChecklistDashboard/checklist-dom-golden.test.mjs b/dashboard/src/components/ChecklistDashboard/checklist-dom-golden.test.mjs index 27ddbc2f..98c6f62c 100644 --- a/dashboard/src/components/ChecklistDashboard/checklist-dom-golden.test.mjs +++ b/dashboard/src/components/ChecklistDashboard/checklist-dom-golden.test.mjs @@ -70,7 +70,7 @@ test("real M4 projections advance the full Checklist DOM through the frozen Loop }); 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 === "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), diff --git a/dashboard/src/components/ChecklistDashboard/checklist-loop-progression.golden.json b/dashboard/src/components/ChecklistDashboard/checklist-loop-progression.golden.json index 600667cc..57f67d4c 100644 --- a/dashboard/src/components/ChecklistDashboard/checklist-loop-progression.golden.json +++ b/dashboard/src/components/ChecklistDashboard/checklist-loop-progression.golden.json @@ -1,37 +1,37 @@ [ { "checkpoint": "review/1/reject", - "projectionBytes": 4926, - "projectionSha256": "b3f77d4de6155c04bf6383fb4236ff6ff59554948fc6c8d4f7268ce863373ca6", - "domBytes": 11411, - "domSha256": "4a9c82ddb85f33914f70d05fb9791bcf07cad1fa987f27c55edd3d1fc24405b3" + "projectionBytes": 3538, + "projectionSha256": "b36c496aad25169a88a0c84dd9ed75a7cfd6aec5d0093515f67606baf7bf8e9d", + "domBytes": 11233, + "domSha256": "672d6a50706cf2b2c5a481fa84da147ef36247d283d7e0a3c2f8c587599fc278" }, { - "checkpoint": "decompose/2/reject", - "projectionBytes": 4942, - "projectionSha256": "ae98eb5c311d9e76eb3b4aa654a0a870444f44e1d381e75a5b92cf86bd71dd0d", - "domBytes": 11371, - "domSha256": "3cc0268cf7e1f9a709c85aba98308f9dd4488e9e4921462dd799a70bd02e21d2" + "checkpoint": "implement/2/complete", + "projectionBytes": 3807, + "projectionSha256": "7be746531fcad9c4af563facce1ad6731a4dce27b9770d518abd5489e9eaa401", + "domBytes": 11420, + "domSha256": "2b1859f6eca476950866f07ce8921b0f60abb3e5fa8be7135970bffe073a1d04" }, { "checkpoint": "review/2/approve", - "projectionBytes": 5209, - "projectionSha256": "c82308d4a5c20257f6de806fbc46a8857d95ec5719a13717d5f6124461c9fabd", - "domBytes": 11413, - "domSha256": "427a07db4545b487cae3f9cb79cae75fb0148d866372880b65e62c1e046094cf" + "projectionBytes": 3941, + "projectionSha256": "52f34c71dc728e640d0750c4f2ecb51524ca53accb2efa478781a30e73c13833", + "domBytes": 11412, + "domSha256": "442d5b3285a9289e4f0db15931a2455b1855934050446838df82501edbca7a2d" }, { "checkpoint": "converged/1/approve", - "projectionBytes": 5506, - "projectionSha256": "4446c290d2959a6d28c6f56eeee834cafb8459e61b0af18b83a32119d5eed8cb", - "domBytes": 11390, - "domSha256": "a189ee185d39cc7ea1dcac894c352c90e99e7108fd924a6538320da0b435814e" + "projectionBytes": 3961, + "projectionSha256": "4b85c361bff58ca9d57f89b91ae4fab8d589ae2742b4647f962f1df1d2a60809", + "domBytes": 11365, + "domSha256": "bc0912d4e9b5cf7f243ad1f4c5dac5d91f400b6082e0f470b2a242f205aab725" }, { "checkpoint": "completed/1/approve", - "projectionBytes": 5507, - "projectionSha256": "d83e0cd1d8a9243647e1e04e36dd6d09627ef6a270803739005d0e898ec1474e", - "domBytes": 11343, - "domSha256": "34c70f62d314366aeb3e34d0ed9013bc5e938b4702719695b6df97e5c914c891" + "projectionBytes": 3963, + "projectionSha256": "8cd03ed5d8249b91c0f348c5b722752db7b1a54534898a29516a9337bc2043b0", + "domBytes": 11319, + "domSha256": "c0849c59f8d32fc6ffddc1775ee614e0c7fcfd9a83a2e4b779305f75f8ec1043" } ] diff --git a/dashboard/src/components/ChecklistDashboard/checklist-loop-states.golden.json b/dashboard/src/components/ChecklistDashboard/checklist-loop-states.golden.json index 9d4730a6..4c1fbe65 100644 --- a/dashboard/src/components/ChecklistDashboard/checklist-loop-states.golden.json +++ b/dashboard/src/components/ChecklistDashboard/checklist-loop-states.golden.json @@ -1,42 +1,42 @@ [ { "checkpoint": "paused", - "domBytes": 10876, - "domSha256": "78e067dd99894335edaaa2ab79aa9a77bcd9cd91430fc80054e205cd219dc150" + "domBytes": 11319, + "domSha256": "3c80dbafd88e55b307255f003a64ef442d5e53c79e0650bcd378b2c3424b255b" }, { "checkpoint": "failed", - "domBytes": 10876, - "domSha256": "78e067dd99894335edaaa2ab79aa9a77bcd9cd91430fc80054e205cd219dc150" + "domBytes": 11319, + "domSha256": "3c80dbafd88e55b307255f003a64ef442d5e53c79e0650bcd378b2c3424b255b" }, { "checkpoint": "stopped", - "domBytes": 10876, - "domSha256": "78e067dd99894335edaaa2ab79aa9a77bcd9cd91430fc80054e205cd219dc150" + "domBytes": 11319, + "domSha256": "3c80dbafd88e55b307255f003a64ef442d5e53c79e0650bcd378b2c3424b255b" }, { "checkpoint": "needs-human", - "domBytes": 10876, - "domSha256": "78e067dd99894335edaaa2ab79aa9a77bcd9cd91430fc80054e205cd219dc150" + "domBytes": 11319, + "domSha256": "3c80dbafd88e55b307255f003a64ef442d5e53c79e0650bcd378b2c3424b255b" }, { "checkpoint": "exhausted", - "domBytes": 10876, - "domSha256": "78e067dd99894335edaaa2ab79aa9a77bcd9cd91430fc80054e205cd219dc150" + "domBytes": 11319, + "domSha256": "3c80dbafd88e55b307255f003a64ef442d5e53c79e0650bcd378b2c3424b255b" }, { "checkpoint": "stale", - "domBytes": 10876, - "domSha256": "78e067dd99894335edaaa2ab79aa9a77bcd9cd91430fc80054e205cd219dc150" + "domBytes": 11319, + "domSha256": "3c80dbafd88e55b307255f003a64ef442d5e53c79e0650bcd378b2c3424b255b" }, { "checkpoint": "corrupt", - "domBytes": 10876, - "domSha256": "78e067dd99894335edaaa2ab79aa9a77bcd9cd91430fc80054e205cd219dc150" + "domBytes": 11319, + "domSha256": "3c80dbafd88e55b307255f003a64ef442d5e53c79e0650bcd378b2c3424b255b" }, { "checkpoint": "completed", - "domBytes": 10876, - "domSha256": "78e067dd99894335edaaa2ab79aa9a77bcd9cd91430fc80054e205cd219dc150" + "domBytes": 11319, + "domSha256": "3c80dbafd88e55b307255f003a64ef442d5e53c79e0650bcd378b2c3424b255b" } ] diff --git a/dashboard/src/oven/LoopProgress/LoopProgress.test.ts b/dashboard/src/oven/LoopProgress/LoopProgress.test.ts index d79b85fd..5dcbac12 100644 --- a/dashboard/src/oven/LoopProgress/LoopProgress.test.ts +++ b/dashboard/src/oven/LoopProgress/LoopProgress.test.ts @@ -127,6 +127,19 @@ test("shows observed agent facts, elapsed time, and forecast provenance", () => assert.doesNotMatch(markup, /\$|cost estimate/u); }); +test("live hook time extends elapsed display without mutating Run budget state", () => { + const data = fixture("O0"); + data.loopRun.createdAt = 1_000; + data.loopRun.budget.elapsedMilliseconds = 2_000; + data.loopRun.activity = { hooks: "available", records: [{ + at: 91_000, origin: "host-hook", kind: "tool-finished", provider: "codex", + nodeId: "review", attempt: 1, + }] }; + const markup = renderToStaticMarkup(createElement(LoopProgress, { data })); + assert.match(markup, />ELAPSED<\/span>

    1.5 min<\/p>/u); + assert.equal(data.loopRun.budget.elapsedMilliseconds, 2_000); +}); + test("activity viewport has a stable ten-row height", async () => { const css = await readFile("dashboard/src/oven/LoopProgress/LoopProgress.css", "utf8"); assert.match(css, /\.loop-progress__activity ol \{[^}]*block-size: 150px;[^}]*overflow: hidden;/su); diff --git a/dashboard/src/oven/LoopProgress/LoopProgress.tsx b/dashboard/src/oven/LoopProgress/LoopProgress.tsx index c083576f..3730f0ce 100644 --- a/dashboard/src/oven/LoopProgress/LoopProgress.tsx +++ b/dashboard/src/oven/LoopProgress/LoopProgress.tsx @@ -128,6 +128,12 @@ export function LoopProgress({ data }: { data: ChecklistProgressData }) { ? `${forecast.confidence} · ${forecast.provenance.matchingObservations} local observations` : `${forecast.confidence} · built-in prior` : "Unavailable"; + const observedElapsed = authoritativeRun + ? Math.max( + authoritativeRun.budget.elapsedMilliseconds, + ...recentActivity.map((record) => Math.max(0, record.at - authoritativeRun.createdAt)), + ) + : null; return

    NOW @@ -143,7 +149,7 @@ export function LoopProgress({ data }: { data: ChecklistProgressData }) {
    AGENT

    {observedAgent}

    -
    ELAPSED

    {duration(authoritativeRun?.budget.elapsedMilliseconds)}

    +
    ELAPSED

    {duration(observedElapsed)}

    FORECAST

    wall {durationRange(forecast?.wallTime)} · work {durationRange(forecast?.aggregateWork)}

    PROVENANCE

    {provenance} · tokens {tokenRange(forecast?.totalTokens)}

    diff --git a/scripts/package-paths.json b/scripts/package-paths.json index c8dd5a9e..1ea58137 100644 --- a/scripts/package-paths.json +++ b/scripts/package-paths.json @@ -2,10 +2,14 @@ "LICENSE", "README.md", "bin/burnlist.mjs", - "dashboard/dist/assets/index-DQ8BmSNA.js", - "dashboard/dist/assets/index-BmWMxaDn.css", + "dashboard/dist/assets/index-BMNqJ4RF.css", + "dashboard/dist/assets/index-ixN_C5MH.js", "dashboard/dist/favicon.svg", "dashboard/dist/index.html", + "loops/branch/branch.loop", + "loops/branch/instructions.md", + "loops/gate/gate.loop", + "loops/gate/instructions.md", "loops/review/instructions.md", "loops/review/review.loop", "ovens/catalog.json", @@ -93,6 +97,7 @@ "src/cli/local-exclude.mjs", "src/cli/loop-cli.mjs", "src/cli/loop-config-cli.mjs", + "src/cli/loop-hook-cli.mjs", "src/cli/oven-cli-render.mjs", "src/cli/oven-cli.mjs", "src/cli/oven-set.mjs", @@ -146,7 +151,11 @@ "src/loops/dsl/loop-xml.mjs", "src/loops/dsl/package-read.mjs", "src/loops/events/activity-projection.mjs", + "src/loops/events/hook-context.mjs", + "src/loops/events/hook-observation.mjs", "src/loops/events/projection-events.mjs", + "src/loops/forecast/forecast.mjs", + "src/loops/forecast/history.mjs", "src/loops/run/binder.mjs", "src/loops/run/budgets.mjs", "src/loops/run/candidate.mjs", diff --git a/scripts/verify-source-scan.mjs b/scripts/verify-source-scan.mjs index 7775d2c8..953daca4 100644 --- a/scripts/verify-source-scan.mjs +++ b/scripts/verify-source-scan.mjs @@ -6,6 +6,7 @@ const EXCLUDED_PREFIXES = [ ".playwright-cli/", ".worktrees/", "build/", + "burnlist-tests/", "dist/", "node_modules/", "notes/burnlists/", diff --git a/scripts/verify-source-scan.test.mjs b/scripts/verify-source-scan.test.mjs index 3c80f056..03144a20 100644 --- a/scripts/verify-source-scan.test.mjs +++ b/scripts/verify-source-scan.test.mjs @@ -9,6 +9,7 @@ test("source leak scanning excludes local and nested checkout state", () => { ".burnlist/loop-capabilities.json", ".worktrees/feature/.git", ".worktrees/feature/src/private.mjs", + "burnlist-tests/test-001/.burnlist/loop-capabilities.json", "node_modules/package/index.js", ]) { assert.equal(shouldScanSourceRelativePath(path), false, path); diff --git a/scripts/verify-test-files.mjs b/scripts/verify-test-files.mjs index a1fa32b9..85dd719a 100644 --- a/scripts/verify-test-files.mjs +++ b/scripts/verify-test-files.mjs @@ -101,6 +101,7 @@ export const verificationTestFiles = [ "src/loops/run/run-clock.test.mjs", "src/loops/events/projection-events.test.mjs", "src/loops/events/activity-projection.test.mjs", + "src/loops/forecast/forecast.test.mjs", "src/loops/run/run-journal.test.mjs", "src/loops/run/run-fold.test.mjs", "src/loops/run/state-machine.test.mjs", diff --git a/scripts/verify.mjs b/scripts/verify.mjs index 6fdaad9e..5a3637cb 100755 --- a/scripts/verify.mjs +++ b/scripts/verify.mjs @@ -73,7 +73,8 @@ function withoutCanonicalTemplateVocabulary(text) { .replaceAll(/driving parity/giu, "") .replaceAll(/visual-parity/giu, "") .replaceAll(/visual parity/giu, "") - .replaceAll(/parity progress/giu, ""); + .replaceAll(/parity progress/giu, "") + .replaceAll(/renderer into parity/giu, ""); } const leakPatterns = [ diff --git a/skills/burnlist/SKILL.md b/skills/burnlist/SKILL.md index f679f578..d3644e1c 100644 --- a/skills/burnlist/SKILL.md +++ b/skills/burnlist/SKILL.md @@ -32,7 +32,8 @@ Read references only when their trigger applies: - `references/oven-authoring.md`: authoring or inspecting Ovens from the `burnlist oven` CLI, the widget/format vocabulary, and source-binding conventions. - `references/creating-ovens.md`: authoring a new .oven declarative source (grammar, elements, binding, themes, compile-to-IR walkthrough). - `references/oven-event-coordination.md`: mandatory for multi-Burnlist worker coordination, generic Oven progress events, replayable subscriptions, and event-triggered coordinator wakeups. -- `references/host-execution.md`: generic host claim/execute/report protocol for a prepared Loop Run; read before a host executes a claimed agent node. +- `references/host-execution.md`: generic host next/execute/submit protocol for a prepared Loop Run; read before a host executes an agent node. +- `references/loop-capability-example.json`: starting catalog only when a repository has no trusted check capability yet. - `references/loop-provider-setup.md`: mandatory before the first Loop when available native agents, CLIs, logins, or subscriptions are unknown; inventory safely, show the user, and ask what to enable. - `references/loop-providers/.md`: bounded invocation recipe for Claude native, Codex native, Codex CLI, AGY, Grok, or a custom host. Read the selected provider recipe before invoking it. @@ -157,99 +158,42 @@ Install only the system the task needs, or both. Read `references/installation.m ## Built-in Loops (Stage 1) -Use only the execution choice assigned by the user or active Burnlist: - -- no assignment: direct implementation and ordinary `burnlist burn` -- `loop:builtin:review`: implement, trusted check, independent review, Burn -- `loop:builtin:gate`: implement, trusted check, Burn -- `loop:builtin:branch`: plan, host-orchestrated N smaller-agent slices, merge, - trusted check, independent review, Burn - -Do not substitute one built-in for another. Branch fan-out is host-owned: -use native or CLI subagents when available, otherwise execute the declared -slices sequentially. Burnlist does not fabricate per-branch runtime authority. - -Before creating a Run, inspect and explicitly trust the repository check -capability, then assign the item. Burnlist stores no provider profile, route, -subscription, binary, model, or login: - -The installed skill includes `references/loop-capability-example.json`. Copy -its `catalog` object to the repository capability catalog and its `grants` -object to a private local grants path; the grants file can only narrow the -catalog. These are the copyable schemas, replacing -the absolute check command and paths with your repository's reviewed command: - -```json -{"schema":"burnlist-loop-capabilities@1","capabilities":[{"id":"repo-verify","argv":["/absolute/path/to/repository-check","--verify"],"cwd":".","environment":{"inherit":["PATH"],"set":{}},"network":"deny","filesystem":{"read":["src"],"write":[]},"output":{"maxBytes":65536},"maxMilliseconds":60000}]} -``` - -```json -{"argv":["/absolute/path/to/repository-check","--verify"],"cwd":".","environment":{"inherit":["PATH"],"set":{}},"network":"deny","filesystem":{"read":["src"],"write":[]},"output":{"maxBytes":65536},"maxMilliseconds":60000} -``` - -Every agent node is host-orchestrated. The host owns every provider invocation. -Read `references/host-execution.md` -before claiming it. If available subscriptions are unknown, read -`references/loop-provider-setup.md`, show the user the safe inventory, and ask -which ready providers to use or help set up. Then read the selected provider -recipe. The host may use native subagents or the Codex, AGY, Grok, or another -CLI, but it—not Burnlist—launches and supervises them. Never invent a -transition or execute a managed check. - -```sh -burnlist loop capability inspect repo-verify -burnlist loop capability trust repo-verify --revision cp1-sha256: --grants -burnlist loop assign item:# loop:builtin: -``` - -Run `burnlist loop setup status` before `loop create`. Paste the complete -`burnlist loop view item:#` ASCII output into the task -handoff or review request: it records the frozen graph, retries, completion -path, and pins. For each agent node use `loop claim`, invoke the selected -provider through the host, and submit `loop report`. The successful fast path -is `loop report --outcome complete` for a task or `--outcome approve` -for an independent review; detailed findings and telemetry use `--result`. -Claims are provider-neutral: if a provider hits quota before changing the -workspace and its process has exited, retry the same claim with another ready -provider. Do not write provider wrappers into the candidate repository. -Reporting automatically -advances Burnlist-owned checks and gates to the next agent or terminal node. -When the user wants to watch a Loop, assign every intended item first and -verify each with `loop view`; announce the dashboard as Loop-ready only after -the item projections expose those assignments. Repository registration alone -is not a Loop-visibility milestone. -Inspect with `loop status|inspect`, control idle Runs with `loop pause|stop`, -use proof-gated `loop reconcile` only for a demonstrably lost host claim, and -apply a converged Run through idempotent `loop complete`. -`loop list` lists created Runs, not assignments; an empty result after -assignment is expected. Assignment truth is `loop view item:#`. - -The canonical Checklist Oven keeps a centered KPI row above the side-by-side -Progress ledger and Completion trend. Its unified Items list contains current, -pending, and completed work; inspecting another `#` changes only the -adjacent Item detail. That detail shows the selected active item's contract, -Loop preview or live Run, labelled return paths, and graph-derived node legend, -or a completed item's recorded timestamp and outcome. Direct items show no -Loop graph. Ovens may compose Burnlist's core box-drawing renderer declaratively with -``, or with -`` inside a collection. The widget never -fetches, executes, or imports custom code; core transport refreshes it after -item-keyed Loop invalidation events. - -Stage 1 labels fresh reviewer process, graph grammar, budgets, closed outcomes, -and atomic canonical CLI writes `enforced`; ordinary drift checks are -`detected-at-boundaries`; reviewer filesystem write denial is `supervised`. -It is not Docker or an OS sandbox. Parallelism, nested agents, metric gates, -worktrees, background execution, Docker isolation, and -automatic provider scheduling are `unsupported`. The Checklist UI is read-only and displays -active, paused, error, and terminal Run states; it never controls a Run. -Its duration, aggregate-work, and token forecasts are bounded estimates with -explicit confidence and provenance. Missing usage or pricing remains -unavailable rather than becoming fabricated cost. -Branch fan-out remains one canonical `branches` node in Stage 1, so N, slice -names, and native worker state are host evidence rather than dashboard graph -state. - -`burnlist install` registers this skill, while `burnlist hooks install` adds -native observability hooks for Streaming Diff and active Loop activity. These -integrations are independent and do not configure or start a Review Loop. +Honor the choice already assigned by the user or active item: + +- no assignment: implement directly and use ordinary `burnlist burn` +- `loop:builtin:gate`: maker, trusted repository check, Burn +- `loop:builtin:review`: maker, trusted check, independent reviewer, Burn +- `loop:builtin:branch`: plan, host-selected slices, merge, trusted check, + independent reviewer, Burn + +Do not substitute a different Loop because it is more convenient. Read +`references/host-execution.md` for the core CLI protocol; it is intentionally +not duplicated here. + +The host—not Burnlist—chooses and supervises each native agent or provider CLI. +If provider availability is unknown, read `references/loop-provider-setup.md`, +show the safe inventory, and ask what the user wants enabled. Then read only +the selected provider recipe. Give workers the prepared task, not this skill: +workers need no Burnlist commands, claim identities, graph knowledge, or +lifecycle authority. + +Make semantic decisions only from evidence: + +- submit `complete` after the maker has implemented and checked its work +- submit `approve` only from a fresh independent read-only review with no open blocker +- submit `reject` with specific findings that the next maker can resolve +- submit `escalate` when evidence cannot support approval or bounded repair + +Never invent an outcome, choose a graph edge, run the trusted check on the +worker's behalf, or treat hooks/forecasts as proof. Burnlist owns those checks, +transitions, budgets, and completion. + +For Branch, choose genuinely separable slices and use independent workers when +available; otherwise execute the same slices sequentially. Keep integration +with one owner. Burnlist records the canonical branch node, while slice names +and native worker state remain host evidence. + +If a provider fails before changing the workspace and its process has +definitely exited, retry the same provider-neutral task with another ready +provider. If cleanup or candidate state is uncertain, stop and use the +recovery guidance instead of submitting a made-up result. diff --git a/skills/burnlist/references/host-execution.md b/skills/burnlist/references/host-execution.md index eae695c9..516f5511 100644 --- a/skills/burnlist/references/host-execution.md +++ b/skills/burnlist/references/host-execution.md @@ -18,6 +18,8 @@ invocation. If subscriptions are unknown, read `loop-provider-setup.md` first. agent or provider. The packet contains the role, read/write authority, legal outcomes, and prepared task context; it does not expose claim, invocation, assignment, dispatch-authority, or graph-edge mechanics. + Its optional forecast is a bounded planning range with explicit confidence + and provenance. It is not proof, a deadline, or a billing estimate. 2. Execute the supplied prompt against the assigned repository. The worker needs no Burnlist skill, CLI commands, claim identity, graph knowledge, or @@ -93,7 +95,7 @@ the transition authority. The host owns every provider invocation and optional best-effort telemetry. Burnlist owns the frozen graph, claim authority, validation, deterministic checks, transition selection, canonical journal, and item completion. After -`loop report`, Burnlist automatically advances trusted checks and graph-only +`loop submit` (or recovery `loop report`), Burnlist automatically advances trusted checks and graph-only nodes until the next host agent claim or a terminal state. It never launches a provider process. diff --git a/src/loops/dsl/project-strategies.test.mjs b/src/loops/dsl/project-strategies.test.mjs index fbf577b9..7a27d1f9 100644 --- a/src/loops/dsl/project-strategies.test.mjs +++ b/src/loops/dsl/project-strategies.test.mjs @@ -6,7 +6,7 @@ import test from "node:test"; import { assignLoopItem } from "../assignment/assignment.mjs"; import { resolveLoopAuthority } from "../assignment/resolver.mjs"; import { compileLoopPackage } from "./compile.mjs"; -import { createRunRunner } from "../run/runner.mjs"; +import { createRunRunner as createCoreRunRunner } from "../run/runner.mjs"; import { runStore } from "../run/run-store.mjs"; import { renderResolvedLoopView } from "../view/render.mjs"; @@ -15,6 +15,10 @@ const runId = "run:01arz3ndektsv4rrffq69g5fav"; const terminals = ``; const finish = ``; const sharedEnd = ``; +const createRunRunner = (options) => createCoreRunRunner({ + ...options, + allowTestAgentExecution: true, +}); function strategyPackage(name) { if (name === "whole-first") { diff --git a/src/loops/run/controller.mjs b/src/loops/run/controller.mjs index e551c269..6665f361 100644 --- a/src/loops/run/controller.mjs +++ b/src/loops/run/controller.mjs @@ -52,7 +52,14 @@ export function createLoopController({ store, runnerFor, repoRoot = null }) { const lease = idleLease(runId); try { const prepared = prepareHostClaim({ repoRoot, replay: read(runId), authority: store.readAuthority(runId) }); - return store.bindExternalClaim(runId, lease, prepared); + const bound = store.bindExternalClaim(runId, lease, prepared); + try { + const envelope = validateHostExecutionEnvelope(bound.envelope); + activateLoopHookContext(repoRoot, { + replay: read(runId), claim: bound.claim, envelope: envelope.value, + }); + } catch {} + return bound; } catch (error) { // A failed preparation has not started a host. Releasing this private @@ -63,10 +70,6 @@ export function createLoopController({ store, runnerFor, repoRoot = null }) { } function next(runId) { const prepared = claim(runId); - const envelope = validateHostExecutionEnvelope(prepared.envelope); - activateLoopHookContext(repoRoot, { - replay: read(runId), claim: prepared.claim, envelope: envelope.value, - }); const replay = read(runId), optionalRecords = observations(runId); return Object.freeze({ ...presentHostTask(prepared.envelope), diff --git a/src/loops/run/host-execution.mjs b/src/loops/run/host-execution.mjs index dc8d8db1..f6a3980b 100644 --- a/src/loops/run/host-execution.mjs +++ b/src/loops/run/host-execution.mjs @@ -82,7 +82,7 @@ export function presentHostTask(envelopeBytes) { ? `\n\nOpen review findings:\n${JSON.stringify(input.openFindings, null, 2)}` : ""; const prompt = [ - `Act as the ${input.role} for this ${input.mode} task. Your workspace authority is ${input.authority}.`, + `Act as the ${input.role} for this ${input.mode === "task" ? "implementation" : input.mode} task. Your workspace authority is ${input.authority}.`, instruction.trimEnd(), `Assigned item:\n${item.trimEnd()}`, `Candidate context:\n${context.trimEnd()}${findings}`, diff --git a/src/loops/run/run-test-fixtures.mjs b/src/loops/run/run-test-fixtures.mjs index 19a6aae4..64c1dfc3 100644 --- a/src/loops/run/run-test-fixtures.mjs +++ b/src/loops/run/run-test-fixtures.mjs @@ -15,8 +15,7 @@ export const d = (prefix, char) => `${prefix}:${char.repeat(64)}`; export const fixtureRunId = "run:01arz3ndektsv4rrffq69g5fav"; export const fixtureItemRef = "item:260722-001#L29"; export const m4ProgressOutcomes = [ - "complete", "complete", "complete", "pass", "reject", - "complete", "complete", "pass", "approve", "complete", "pass", "approve", + "complete", "pass", "reject", "complete", "pass", "approve", ]; let frozenPromise; const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..", ".."); @@ -67,7 +66,7 @@ export async function runM4ProgressFixture({ if (!permitted.includes(outcome)) throw new Error(`run fixture: ${outcome} is not valid for ${nodeId}`); return { kind: outcome, summary: outcome, outputBytes: 1 }; }; - const runner = createRunRunner({ store, runId, invoke, executePreparedAgent: async ({ lease, node }) => { + const runner = createRunRunner({ store, runId, invoke, allowTestAgentExecution: true, executePreparedAgent: async ({ lease, node }) => { const current = store.replay(runId), attempt = current.execution.attempt + 1; store.append(runId, lease, "node-started", { nodeId: node.id, attempt }); const invocationId = `${String(attempt).padStart(2, "0")}${"0".repeat(30)}`; diff --git a/src/loops/run/runner.mjs b/src/loops/run/runner.mjs index 21b8a63e..1424a2ad 100644 --- a/src/loops/run/runner.mjs +++ b/src/loops/run/runner.mjs @@ -11,9 +11,13 @@ function boundedSummary(value) { while (end > 0 && (bytes[end] & 0xc0) === 0x80) end -= 1; return bytes.subarray(0, end).toString("utf8"); } -export function createRunRunner({ store, runId, invoke, bindCandidate = null, executePreparedAgent = null }) { +export function createRunRunner({ + store, runId, invoke, bindCandidate = null, executePreparedAgent = null, + allowTestAgentExecution = false, +}) { if (!store?.replay || !store?.append || !store?.acquireLease || !store?.terminalize || typeof invoke !== "function") fail("invalid runner input"); if (executePreparedAgent !== null && typeof executePreparedAgent !== "function") fail("invalid prepared agent executor"); + if (typeof allowTestAgentExecution !== "boolean") fail("invalid test agent execution flag"); let lease = null, pauseRequested = false, stopRequested = false, cancelRequested = false, cancelWake = null; const read = () => store.replay(runId), append = (type, payload) => store.append(runId, lease, type, payload); function transition(to, cause) { const execution = read().execution; return append("state-changed", { from: execution.state, to, cause }); } @@ -33,7 +37,8 @@ export function createRunRunner({ store, runId, invoke, bindCandidate = null, ex if (completed?.released) lease = null; return { kind: "prepared-agent" }; } - if (node.kind === "agent" && node.execution === "host") return { kind: "awaiting-host" }; + if (node.kind === "agent" && node.execution === "host" && !allowTestAgentExecution) + return { kind: "awaiting-host" }; return append("node-started", { nodeId: node.id, attempt: execution.attempt + 1 }); } if (node.kind === "terminal") return transition(node.state, "graph"); @@ -88,7 +93,13 @@ export function createRunRunner({ store, runId, invoke, bindCandidate = null, ex } async function run() { for (;;) { if (stopRequested) return stop(); - await step(); + const result = await step(); + if (result?.kind === "awaiting-host") { + const current = read(); + if (lease && current.execution.lease) store.releaseLease(runId, lease); + lease = null; + return read(); + } // A second SIGINT upgrades a pending pause to stop while the invocation // settles. Check it before the pause branch so terminal control wins. if (stopRequested) return stop(); diff --git a/src/loops/run/runner.test.mjs b/src/loops/run/runner.test.mjs index 3bfbe8ed..7b68cff4 100644 --- a/src/loops/run/runner.test.mjs +++ b/src/loops/run/runner.test.mjs @@ -3,13 +3,18 @@ import { mkdtempSync, rmSync } from "node:fs"; import os from "node:os"; import { join } from "node:path"; import test from "node:test"; -import { createRunRunner } from "./runner.mjs"; +import { createRunRunner as createCoreRunRunner } from "./runner.mjs"; import { gateDecision } from "./state-machine.mjs"; import { normalizeIr } from "../dsl/canonical.mjs"; import { outcomesFor } from "../dsl/grammar.mjs"; import { runStore } from "./run-store.mjs"; import { testGraph, testRunId } from "./m2-test-fixtures.mjs"; +const createRunRunner = (options) => createCoreRunRunner({ + ...options, + allowTestAgentExecution: true, +}); + function fixture(t, graph = testGraph) { const root = mkdtempSync(join(os.tmpdir(), "m2-runner-")); t.after(() => rmSync(root, { recursive: true, force: true })); let at = 0; const store = runStore(root, { clock: () => at++ }); store.createRun({ runId: testRunId, itemRef: "item:260722-001#M2", graph }); return store; } function renamedGraph() { const names = { verify: "audit", review: "tests" }, graph = JSON.parse(JSON.stringify(testGraph)); graph.nodes = graph.nodes.map((node) => node.kind === "agent" ? { ...node, id: names[node.id] ?? node.id, independentFrom: node.independentFrom ? names[node.independentFrom] ?? node.independentFrom : null } : node.kind === "check" ? { ...node, id: names[node.id] ?? node.id } : node.kind === "gate" ? { ...node, requires: node.requires.map((id) => names[id] ?? id) } : node); graph.edges = graph.edges.map((edge) => ({ ...edge, from: names[edge.from] ?? edge.from, to: names[edge.to] ?? edge.to })); return normalizeIr(graph, outcomesFor); } function highBoundaryGraph() { const graph = JSON.parse(JSON.stringify(testGraph)); graph.budget = { maxRounds: 100, maxMinutes: 1000, maxAgentRuns: 100, maxCheckRuns: 100, maxTransitions: 1000, maxOutputBytes: 1000000 }; graph.edges = graph.edges.map((edge) => edge.from === "review" && edge.on === "reject" || edge.from === "verify" && edge.on === "fail" ? { ...edge, maxVisits: 100 } : edge); return normalizeIr(graph, outcomesFor); } diff --git a/src/loops/run/system-runner.test.mjs b/src/loops/run/system-runner.test.mjs index 8316772a..52deb748 100644 --- a/src/loops/run/system-runner.test.mjs +++ b/src/loops/run/system-runner.test.mjs @@ -16,7 +16,11 @@ test("system runner stops at host agents and never invokes a provider", async (t const runner = createStoredSystemRunRunner({ repoRoot: repo, store, runId: fixtureRunId, runCheck: async () => { throw new Error("check must not run before a host result"); } }); const result = await runner.runToHostBoundary(); - assert.equal(result.execution.nodeId, "start"); + assert.equal(result.execution.nodeId, result.graph.entry); + assert.equal(result.execution.node.kind, "agent"); assert.equal(result.execution.started, false); assert.equal(result.execution.invocation, null); + const foreground = await runner.run(); + assert.equal(foreground.execution.nodeId, result.graph.entry); + assert.equal(foreground.execution.lease, null, "ordinary run also returns at the host boundary"); }); diff --git a/src/server/run-routes.test.mjs b/src/server/run-routes.test.mjs index ec95aaad..8f89d2f3 100644 --- a/src/server/run-routes.test.mjs +++ b/src/server/run-routes.test.mjs @@ -69,7 +69,7 @@ test("selected progress remains independent from the sanitized read-only Loop pr const transitions = left.transitions.map(({ from, outcome, to }) => ({ from, outcome, to })); assert.deepEqual(transitions[0], { from: "prepared", outcome: "control", to: "running" }); assert.equal(transitions.some((entry) => entry.from === "review" && entry.outcome === "reject"), true); - assert.equal(transitions.some((entry) => entry.from === "final-review" && entry.outcome === "approve" && entry.to === "converged"), true); + assert.equal(transitions.some((entry) => entry.from === "review" && entry.outcome === "approve" && entry.to === "converged"), true); assert.deepEqual(transitions.at(-1), { from: "converged", outcome: "pass", to: "completed" }); const serialized = JSON.stringify(left); assert.equal(left.graph.nodes.find((node) => node.id === "implement").authority, "write"); From 0bc50cd1775f195ad69952fe0500f1020f8a0da9 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sat, 25 Jul 2026 23:12:04 +0200 Subject: [PATCH 29/37] feat: optimize Burnlist operational usability (E6) --- README.md | 32 ++++ bin/burnlist.mjs | 7 +- .../ChecklistDashboard/ChecklistDashboard.tsx | 10 +- .../checklist-loop-progression.golden.json | 40 ++--- .../checklist-loop-states.golden.json | 32 ++-- dashboard/src/lib/checklist-adapter.ts | 107 ++++++++++++- dashboard/src/lib/types.ts | 27 +++- .../ChecklistWidgets/ChecklistWidgets.test.ts | 5 +- .../ChecklistWorkspace/ChecklistWorkspace.css | 20 +++ .../ChecklistWorkspace/ChecklistWorkspace.tsx | 25 ++- .../src/oven/LoopProgress/LoopProgress.css | 11 ++ .../oven/LoopProgress/LoopProgress.test.ts | 21 ++- .../src/oven/LoopProgress/LoopProgress.tsx | 74 ++++++++- loops/branch/instructions.md | 7 +- loops/review/instructions.md | 6 +- ovens/checklist/instructions.md | 17 ++- ovens/loop-progress/instructions.md | 22 ++- scripts/package-paths.json | 8 +- scripts/verify-package.mjs | 2 +- scripts/verify-test-files.mjs | 3 + skills/burnlist/SKILL.md | 1 + skills/burnlist/references/operational-ux.md | 143 ++++++++++++++++++ src/cli/actionable-output.mjs | 69 +++++++++ src/cli/actionable-output.test.mjs | 38 +++++ src/cli/lifecycle-cli.mjs | 86 ++++++++--- src/cli/lifecycle-cli.test.mjs | 25 +++ src/cli/loop-cli.mjs | 19 ++- src/cli/loop-cli.test.mjs | 2 + src/cli/oven-cli.mjs | 29 ++++ src/cli/registry-cli.mjs | 8 + src/cli/registry-cli.test.mjs | 7 +- src/loops/run/item-work-state.mjs | 108 +++++++++++++ src/loops/run/item-work-state.test.mjs | 53 +++++++ src/loops/run/read-projection.mjs | 4 + src/operations/recommendation.mjs | 122 +++++++++++++++ src/operations/recommendation.test.mjs | 61 ++++++++ src/server/burnlist-dashboard-server.mjs | 25 +++ src/server/run-routes.test.mjs | 13 +- 38 files changed, 1186 insertions(+), 103 deletions(-) create mode 100644 skills/burnlist/references/operational-ux.md create mode 100644 src/cli/actionable-output.mjs create mode 100644 src/cli/actionable-output.test.mjs create mode 100644 src/loops/run/item-work-state.mjs create mode 100644 src/loops/run/item-work-state.test.mjs create mode 100644 src/operations/recommendation.mjs create mode 100644 src/operations/recommendation.test.mjs diff --git a/README.md b/README.md index a1993a33..3fe3e271 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/bin/burnlist.mjs b/bin/burnlist.mjs index c87cb39a..65760122 100755 --- a/bin/burnlist.mjs +++ b/bin/burnlist.mjs @@ -16,6 +16,7 @@ const knownSubcommands = new Set([ "oven", "new", "show", + "recommend", "ready", "start", "close", @@ -40,6 +41,7 @@ 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 ]", @@ -58,7 +60,7 @@ if (args[0] === "install" || args[0] === "uninstall") { return; } -if (["new", "show", "ready", "start", "close", "burn"].includes(args[0]) +if (["new", "show", "recommend", "ready", "start", "close", "burn"].includes(args[0]) && (args.includes("--help") || args.includes("-h"))) { printLifecycleUsage(args[0]); return; @@ -122,6 +124,7 @@ Usage: burnlist oven ... burnlist new [--repo ] burnlist show [#] [--repo ] + burnlist recommend # [--json] [--repo ] burnlist ready [--repo ] burnlist start [--repo ] burnlist close [--repo ] @@ -179,7 +182,7 @@ 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"); diff --git a/dashboard/src/components/ChecklistDashboard/ChecklistDashboard.tsx b/dashboard/src/components/ChecklistDashboard/ChecklistDashboard.tsx index 80dba5a5..0e0050d2 100644 --- a/dashboard/src/components/ChecklistDashboard/ChecklistDashboard.tsx +++ b/dashboard/src/components/ChecklistDashboard/ChecklistDashboard.tsx @@ -1,7 +1,7 @@ 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"; @@ -10,14 +10,18 @@ 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 - ; diff --git a/dashboard/src/components/ChecklistDashboard/checklist-loop-progression.golden.json b/dashboard/src/components/ChecklistDashboard/checklist-loop-progression.golden.json index 57f67d4c..7217bafb 100644 --- a/dashboard/src/components/ChecklistDashboard/checklist-loop-progression.golden.json +++ b/dashboard/src/components/ChecklistDashboard/checklist-loop-progression.golden.json @@ -1,37 +1,37 @@ [ { "checkpoint": "review/1/reject", - "projectionBytes": 3538, - "projectionSha256": "b36c496aad25169a88a0c84dd9ed75a7cfd6aec5d0093515f67606baf7bf8e9d", - "domBytes": 11233, - "domSha256": "672d6a50706cf2b2c5a481fa84da147ef36247d283d7e0a3c2f8c587599fc278" + "projectionBytes": 3560, + "projectionSha256": "e269ab436bd602cf2be9ba9ad0ee1e8cab11a48ace11c0a717b534acf7437b28", + "domBytes": 11478, + "domSha256": "aff2df5ab5aac77cbc85487043c75e523fc14e75f78406599b97619e0934ec96" }, { "checkpoint": "implement/2/complete", - "projectionBytes": 3807, - "projectionSha256": "7be746531fcad9c4af563facce1ad6731a4dce27b9770d518abd5489e9eaa401", - "domBytes": 11420, - "domSha256": "2b1859f6eca476950866f07ce8921b0f60abb3e5fa8be7135970bffe073a1d04" + "projectionBytes": 3829, + "projectionSha256": "a9be55e85fff41141bd03d4a2061f5c10f98313fd037e4cf21b51ad539ac0366", + "domBytes": 11665, + "domSha256": "29c8a1f06c9635ffb51362597345b987b2cad503c08e2702d61d92863d8f551c" }, { "checkpoint": "review/2/approve", - "projectionBytes": 3941, - "projectionSha256": "52f34c71dc728e640d0750c4f2ecb51524ca53accb2efa478781a30e73c13833", - "domBytes": 11412, - "domSha256": "442d5b3285a9289e4f0db15931a2455b1855934050446838df82501edbca7a2d" + "projectionBytes": 3963, + "projectionSha256": "fa26cf02b2bbf3a47d8246d782b9c7ee4dfd3f7c99692834775d591069d73e2b", + "domBytes": 11657, + "domSha256": "b6e1372afb1c371f7ddf3e7e7e7dd397a91d33701b210c55b5adb55f79d6bfe9" }, { "checkpoint": "converged/1/approve", - "projectionBytes": 3961, - "projectionSha256": "4b85c361bff58ca9d57f89b91ae4fab8d589ae2742b4647f962f1df1d2a60809", - "domBytes": 11365, - "domSha256": "bc0912d4e9b5cf7f243ad1f4c5dac5d91f400b6082e0f470b2a242f205aab725" + "projectionBytes": 3989, + "projectionSha256": "99d26c58766df3425692f8a3d23f740544e2cc5218037bf3be450719a1404518", + "domBytes": 11614, + "domSha256": "5ad3e03714f3acea0b468e295d5cf4b16d3e4ac45da5917fb253e049f8bacaa4" }, { "checkpoint": "completed/1/approve", - "projectionBytes": 3963, - "projectionSha256": "8cd03ed5d8249b91c0f348c5b722752db7b1a54534898a29516a9337bc2043b0", - "domBytes": 11319, - "domSha256": "c0849c59f8d32fc6ffddc1775ee614e0c7fcfd9a83a2e4b779305f75f8ec1043" + "projectionBytes": 3991, + "projectionSha256": "1ade6bffe5db0e839527569b027e9d9575057e69c4f80bae327eb6fbe663a1d5", + "domBytes": 11585, + "domSha256": "7a3d5472661fcf7def0d64c3f4bb7c1bcd5de0b8ec7251dfbc4c8e6217e00139" } ] diff --git a/dashboard/src/components/ChecklistDashboard/checklist-loop-states.golden.json b/dashboard/src/components/ChecklistDashboard/checklist-loop-states.golden.json index 4c1fbe65..f738554f 100644 --- a/dashboard/src/components/ChecklistDashboard/checklist-loop-states.golden.json +++ b/dashboard/src/components/ChecklistDashboard/checklist-loop-states.golden.json @@ -1,42 +1,42 @@ [ { "checkpoint": "paused", - "domBytes": 11319, - "domSha256": "3c80dbafd88e55b307255f003a64ef442d5e53c79e0650bcd378b2c3424b255b" + "domBytes": 11539, + "domSha256": "5502efe29562ac55d549bd22d512e5e56c312043dc9a47f15b008fd719678229" }, { "checkpoint": "failed", - "domBytes": 11319, - "domSha256": "3c80dbafd88e55b307255f003a64ef442d5e53c79e0650bcd378b2c3424b255b" + "domBytes": 11537, + "domSha256": "7aba7741fe80cb71352348a4573c95dae072cffb9480d095d5e1ad48d9c8f1ef" }, { "checkpoint": "stopped", - "domBytes": 11319, - "domSha256": "3c80dbafd88e55b307255f003a64ef442d5e53c79e0650bcd378b2c3424b255b" + "domBytes": 11538, + "domSha256": "e2c7aa9b6c919a3a29244f794167542ee61cb38b6424852d0eabb39a78e4d374" }, { "checkpoint": "needs-human", - "domBytes": 11319, - "domSha256": "3c80dbafd88e55b307255f003a64ef442d5e53c79e0650bcd378b2c3424b255b" + "domBytes": 11542, + "domSha256": "13af55dbf157da9638de11bb7a0aeebd38d71fa97036f38bb9b43409e086b9fa" }, { "checkpoint": "exhausted", - "domBytes": 11319, - "domSha256": "3c80dbafd88e55b307255f003a64ef442d5e53c79e0650bcd378b2c3424b255b" + "domBytes": 11547, + "domSha256": "7054d53708e82fd50d69719336f937f1a5de36103452aae9b9335aabb16e60be" }, { "checkpoint": "stale", - "domBytes": 11319, - "domSha256": "3c80dbafd88e55b307255f003a64ef442d5e53c79e0650bcd378b2c3424b255b" + "domBytes": 11554, + "domSha256": "7c4b0ff382daaf28f0853c4ebe11f7b52be665f081b14d098b8ca5cb629b8c65" }, { "checkpoint": "corrupt", - "domBytes": 11319, - "domSha256": "3c80dbafd88e55b307255f003a64ef442d5e53c79e0650bcd378b2c3424b255b" + "domBytes": 11556, + "domSha256": "226ac90b4ef4f3b8c9c1db6e19605bcd24c8c768f650e0930b789ebd7b7b8255" }, { "checkpoint": "completed", - "domBytes": 11319, - "domSha256": "3c80dbafd88e55b307255f003a64ef442d5e53c79e0650bcd378b2c3424b255b" + "domBytes": 11585, + "domSha256": "c980b879a49b77827f22699a6c69c8e230c97af49c5cded2ed633e276c592fec" } ] diff --git a/dashboard/src/lib/checklist-adapter.ts b/dashboard/src/lib/checklist-adapter.ts index 48c114b9..69b2d5a2 100644 --- a/dashboard/src/lib/checklist-adapter.ts +++ b/dashboard/src/lib/checklist-adapter.ts @@ -1,4 +1,10 @@ -import type { ChecklistProgressData, CompletedItem, HistoryPoint } from "./types"; +import type { + ChecklistItem, + ChecklistItemWork, + ChecklistProgressData, + CompletedItem, + HistoryPoint, +} from "./types"; export type EventRow = CompletedItem & { ordinal: number; percent: number }; export type EventDetailField = { label: string; values: string[] }; @@ -15,6 +21,97 @@ export type ChecklistOvenPayload = { }; const EVENT_DETAIL_LABELS = new Set(["Completed", "Changed", "Proof", "Outcome", "Follow-up"]); +const BLOCKED_RUN_STATES = new Set(["needs-human", "failed", "stopped", "budget-exhausted"]); +const RECENT_ACTIVITY_MS = 120_000; + +function pendingWork(): ChecklistItemWork { + return { + state: "PENDING", + reason: "No canonical Run or claim exists; checklist position does not imply execution.", + run: null, + progressing: false, + observation: { + source: "bounded-correlated-hooks", + authority: "observational", + availability: "unavailable", + lastAt: null, + lastKind: null, + ageMilliseconds: null, + recent: false, + codeChanges: [], + }, + provenance: { state: "canonical-burnlist-and-run-absence", activity: "unavailable" }, + }; +} + +export function effectiveItemWork(data: ChecklistProgressData, item: ChecklistItem): ChecklistItemWork { + if (item.work) return item.work; + const run = data.loopRun?.itemRef.endsWith(`#${item.id}`) ? data.loopRun : null; + if (!run) return pendingWork(); + if (data.loopProjectionDiagnostic || run.diagnostic) return { + ...pendingWork(), + state: "BLOCKED", + reason: "Canonical Run projection is unavailable.", + provenance: { + state: "canonical-run-projection", + activity: "bounded-correlated-hooks-observational-only", + }, + }; + const now = Date.parse(data.generatedAt); + const records = run.activity?.records.filter((record) => + record.origin === "host-hook" || record.origin === "agent-reported") ?? []; + const last = records.at(-1) ?? null; + const ageMilliseconds = last && Number.isSafeInteger(last.at) && Number.isFinite(now) + ? Math.max(0, now - last.at) + : null; + const observation = { + source: "bounded-correlated-hooks" as const, + authority: "observational" as const, + availability: run.activity?.hooks ?? "unavailable", + lastAt: last?.at ?? null, + lastKind: last?.kind ?? null, + ageMilliseconds, + recent: ageMilliseconds !== null && ageMilliseconds <= RECENT_ACTIVITY_MS, + codeChanges: [...new Set(records.flatMap((record) => [ + ...(record.observedPath ? [record.observedPath] : []), + ...(record.observedPaths ?? []), + ]))].slice(-16), + }; + const node = run.graph.nodes.find((entry) => entry.id === run.currentNode); + const projected = BLOCKED_RUN_STATES.has(run.state) + ? { state: "BLOCKED" as const, reason: `Canonical Run is ${run.state}.` } + : run.state === "converged" + ? { state: "WAITING" as const, reason: "Canonical Run converged and is waiting for atomic Burnlist completion." } + : run.state === "prepared" + ? { state: "WAITING" as const, reason: "Canonical Run is prepared and waiting for the next host task." } + : run.state === "paused" + ? { state: "WAITING" as const, reason: "Canonical Run is paused." } + : run.state === "running" && node?.kind === "agent" && run.hostTask !== "claimed" + ? { state: "WAITING" as const, reason: "Canonical agent node is waiting for a host claim." } + : run.state === "running" + ? { + state: "ACTIVE" as const, + reason: run.hostTask === "claimed" + ? "Canonical Run has a live host claim." + : "Canonical Run is executing a deterministic node.", + } + : { state: "BLOCKED" as const, reason: `Canonical Run state ${run.state || "unknown"} is unsupported.` }; + return { + ...projected, + run: { + runId: run.runId, + state: run.state, + node: run.currentNode, + claim: run.hostTask ?? "unavailable", + }, + progressing: projected.state === "ACTIVE" && observation.recent, + observation, + provenance: { + state: "canonical-run-and-claim", + activity: "bounded-correlated-hooks-observational-only", + }, + }; +} export function formatDuration(milliseconds: number) { if (!Number.isFinite(milliseconds) || milliseconds < 0) return "--"; @@ -94,14 +191,18 @@ export function progressHistory(data: ChecklistProgressData): HistoryPoint[] { export function adaptChecklist(data: ChecklistProgressData): ChecklistOvenPayload { const durations = timing(data); const rows = eventRows(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"; return { raw: data, items: data.active.map((item) => ({ ...item, loopRun: data.loopRun?.itemRef.endsWith(`#${item.id}`) ? data.loopRun : null, })), - current: { value: current ? `${current.id} · Active` : "Complete", title: current?.title ?? "No active task" }, + current: { value: current ? `${current.id} · ${currentState}` : "Complete", title: current?.title ?? "No active task" }, progress: { done: data.done, total: data.total, percent: data.percent, title: `${data.done} of ${data.total} tasks complete` }, durations: { elapsed: formatDuration(durations.elapsed), pace: formatDuration(durations.pace), timeLeft: formatDuration(durations.timeLeft) }, ledger: rows.slice(0, 8).map((item) => ({ key: `${item.id}/${item.completedAt}`, age: compactAge(item.completedAt, data.generatedAt), event: item.id, result: "Done", delta: "+1", donePercent: item.percent })), diff --git a/dashboard/src/lib/types.ts b/dashboard/src/lib/types.ts index d2d222e2..f1468413 100644 --- a/dashboard/src/lib/types.ts +++ b/dashboard/src/lib/types.ts @@ -1,9 +1,33 @@ export type Filter = "active" | "draft" | "ready" | "complete" | "all"; +export type ChecklistItemWork = { + state: "PENDING" | "ACTIVE" | "WAITING" | "BLOCKED" | "COMPLETED"; + reason: string; + run: null | { + runId: string; + state: string; + node: string; + claim: "claimed" | "awaiting-claim" | "resolved" | "not-applicable" | "unavailable"; + }; + progressing: boolean; + observation: { + source: "bounded-correlated-hooks"; + authority: "observational"; + availability: "unavailable" | "available"; + lastAt: number | null; + lastKind: string | null; + ageMilliseconds: number | null; + recent: boolean; + codeChanges: string[]; + }; + provenance: { state: string; activity: string }; +}; + export type ChecklistItem = { id: string; title: string; fields: Record; + work?: ChecklistItemWork; loop?: null | { selector: string; assignmentId: string; @@ -12,7 +36,7 @@ export type ChecklistItem = { graph?: LoopRunProjection["graph"] | null; }; }; -export type CompletedItem = { id: string; title: string; completedAt: string; detail: string }; +export type CompletedItem = { id: string; title: string; completedAt: string; detail: string; work?: ChecklistItemWork }; export type Warning = { severity: "error" | "warning"; message: string }; export type HistoryPoint = { time: string; done: number; remaining: number; total: number; percent: number }; export type LoopRunProjection = { @@ -27,6 +51,7 @@ export type LoopRunProjection = { currentNode: string; attempt: number; cycle: number; + hostTask?: "claimed" | "awaiting-claim" | "resolved" | "not-applicable"; execution?: { mode: "managed" | "host-reported" | "unavailable"; started: boolean; diff --git a/dashboard/src/oven/ChecklistWidgets/ChecklistWidgets.test.ts b/dashboard/src/oven/ChecklistWidgets/ChecklistWidgets.test.ts index 6969952e..6d09bf34 100644 --- a/dashboard/src/oven/ChecklistWidgets/ChecklistWidgets.test.ts +++ b/dashboard/src/oven/ChecklistWidgets/ChecklistWidgets.test.ts @@ -54,6 +54,7 @@ test("workspace exposes live execution identity, evidence, and observed paths", currentNode: "implement", attempt: 2, cycle: 1, + hostTask: "claimed", revision: "run-revision:test", execution: { mode: "host-reported", started: true, usage: "unavailable" }, budget: { @@ -68,10 +69,12 @@ test("workspace exposes live execution identity, evidence, and observed paths", transitions: [], activity: { hooks: "available", - records: [{ at: 1, origin: "host-hook", provider: "codex", mode: "host-reported", kind: "tool", nodeId: "implement", attempt: 2, observedPath: "src/example.mjs", truncated: true }], + records: [{ at: Date.parse(data.generatedAt) - 1_000, origin: "host-hook", provider: "codex", mode: "host-reported", kind: "tool", nodeId: "implement", attempt: 2, observedPath: "src/example.mjs", truncated: true }], }, }; const html = renderToStaticMarkup(createElement(ChecklistWorkspace, { data })); + assert.match(html, /ACTIVE · progressing/); + assert.match(html, /Canonical Run has a live host claim/); assert.match(html, /Current Loop execution/); assert.match(html, /host-reported/); assert.match(html, /1m 5s/); diff --git a/dashboard/src/oven/ChecklistWorkspace/ChecklistWorkspace.css b/dashboard/src/oven/ChecklistWorkspace/ChecklistWorkspace.css index 0d9efcc6..ec49a7b1 100644 --- a/dashboard/src/oven/ChecklistWorkspace/ChecklistWorkspace.css +++ b/dashboard/src/oven/ChecklistWorkspace/ChecklistWorkspace.css @@ -49,6 +49,11 @@ .checklist-workspace__item.is-selected { background: rgba(90, 162, 255, .07); box-shadow: inset 2px 0 rgba(90, 162, 255, .7); } .checklist-workspace__item-marker { color: rgba(168, 168, 168, .42); font-size: 11px; text-align: center; } .checklist-workspace__item.is-current .checklist-workspace__item-marker { color: var(--status-green); } +.checklist-workspace__item.is-waiting .checklist-workspace__item-marker { color: #e2b96f; } +.checklist-workspace__item.is-blocked .checklist-workspace__item-marker { color: #ff8178; font-weight: 800; } +.checklist-workspace__item.is-active .checklist-workspace__loop-label { color: #79d7a9; } +.checklist-workspace__item.is-waiting .checklist-workspace__loop-label { color: #e2b96f; } +.checklist-workspace__item.is-blocked .checklist-workspace__loop-label { color: #ff8178; } .checklist-workspace__item.is-completed { opacity: .64; } .checklist-workspace__item.is-completed .checklist-workspace__item-marker { color: var(--status-green); } .checklist-workspace__item.is-completed.is-selected { opacity: 1; } @@ -72,6 +77,21 @@ .checklist-workspace__detail-title > span { color: var(--status-green); font-size: 12px; } .checklist-workspace__detail-title.is-completed { grid-template-columns: 3ch minmax(0, 1fr); } .checklist-workspace__detail-title h2 { margin: 0; color: rgba(232, 232, 232, .9); font: 14px/1.3 var(--dashboard-font); } +.checklist-workspace__work-state { + display: grid; + margin-top: 10px; + padding: 9px 10px; + border: 1px solid rgba(163, 172, 183, .1); + border-radius: 6px; + background: rgba(255, 255, 255, .015); + gap: 4px; +} +.checklist-workspace__work-state strong { color: rgba(210, 216, 224, .82); font-size: 10px; letter-spacing: .08em; } +.checklist-workspace__work-state p { margin: 0; color: rgba(210, 216, 224, .68); font-size: 11px; line-height: 1.35; } +.checklist-workspace__work-state small { color: rgba(168, 168, 168, .46); font-size: 9px; overflow-wrap: anywhere; } +.checklist-workspace__work-state.is-active { border-color: rgba(103, 215, 164, .25); } +.checklist-workspace__work-state.is-waiting { border-color: rgba(226, 185, 111, .24); } +.checklist-workspace__work-state.is-blocked { border-color: rgba(255, 129, 120, .28); } .checklist-workspace__fields { display: grid; margin: 12px 0 0; gap: 8px; } .checklist-workspace__fields > div { display: grid; grid-template-columns: 8ch minmax(0, 1fr); gap: 10px; } .checklist-workspace__fields dt { color: rgba(168, 168, 168, .5); font-size: 11px; } diff --git a/dashboard/src/oven/ChecklistWorkspace/ChecklistWorkspace.tsx b/dashboard/src/oven/ChecklistWorkspace/ChecklistWorkspace.tsx index d6fcc172..c7130432 100644 --- a/dashboard/src/oven/ChecklistWorkspace/ChecklistWorkspace.tsx +++ b/dashboard/src/oven/ChecklistWorkspace/ChecklistWorkspace.tsx @@ -1,5 +1,5 @@ import type { ChecklistItem, ChecklistProgressData, CompletedItem, LoopRunProjection } from "@lib"; -import { checklistEventDetailFields, compactAge, eventRows } from "@lib/checklist-adapter"; +import { checklistEventDetailFields, compactAge, effectiveItemWork, eventRows } from "@lib/checklist-adapter"; import { itemTopologyProjection, LoopCompact, LoopLegend } from "@/components/LoopGraph"; import "./ChecklistWorkspace.css"; @@ -50,13 +50,17 @@ function ItemsColumn({ data, selected }: { data: ChecklistProgressData; selected return
    Items{data.total}
    FILES {files}
    Selected · {item ? `${item.id} ${item.title}` : "none"}{authoritativeRun && item && !authoritativeRun.itemRef.endsWith(`#${item.id}`) ? " · Run remains authoritative for another item" : ""}
    ; diff --git a/loops/branch/instructions.md b/loops/branch/instructions.md index 1d304279..65db27ef 100644 --- a/loops/branch/instructions.md +++ b/loops/branch/instructions.md @@ -18,6 +18,7 @@ finish integration work, and run focused checks before repository validation. ## review Independently review the merged, validated candidate against the original item -and plan. Approve when integration is coherent. Reject high-priority defects -and material medium-priority defects back to planning; leave minor refinements -as follow-up notes. +and plan. Approve when integration is coherent. Escalate P0 safety, security, +data-loss, or authority blockers; reject P1 defects and P2 defects that +materially affect acceptance back to planning. Keep P3 refinements as +non-blocking follow-ups and omit noisy P4 preferences. diff --git a/loops/review/instructions.md b/loops/review/instructions.md index 4e5eabe0..9b52e900 100644 --- a/loops/review/instructions.md +++ b/loops/review/instructions.md @@ -7,5 +7,7 @@ owns transitions and completion. ## review Independently review the validated candidate against the item contract. Approve -when it is correct and proportionate. Reject only for actionable high-priority -or material medium-priority issues; record minor improvements for later. +when it is correct and proportionate. Treat P0 safety, security, data-loss, or +authority findings as blockers that require escalation; reject P1 high-impact +defects and P2 defects that materially affect acceptance. P3 improvements are +non-blocking follow-ups. P4 notes are optional and should not add noise. diff --git a/ovens/checklist/instructions.md b/ovens/checklist/instructions.md index a9b5e82e..100da382 100644 --- a/ovens/checklist/instructions.md +++ b/ovens/checklist/instructions.md @@ -25,4 +25,19 @@ The engine receives this payload from the dashboard on each data refresh. The ov ## Active Checklist -The canonical `burnlist.md` Active Checklist remains the ordered source of pending work. Its completed ledger supplies the burned items shown by this read-only view; the dashboard never replaces or silently mutates either source of truth. +### Work State + +The canonical `burnlist.md` Active Checklist remains the ordered source of +queued work, but position never implies execution: + +- `PENDING`: no canonical Run or claim. +- `ACTIVE`: a canonical Run executes a deterministic node or has a live claim. +- `WAITING`: a Run awaits a host task, resume, or atomic completion. +- `BLOCKED`: canonical Run state requires intervention or cannot be projected. +- `COMPLETED`: the completed ledger records the item. + +Recent bounded, invocation-correlated hooks may label an active item +`progressing`; they remain observational and cannot create state, satisfy a +gate, or complete an item. The item detail names both provenance classes. The +completed ledger supplies the burned items shown by this read-only view; the +dashboard never replaces or silently mutates either source of truth. diff --git a/ovens/loop-progress/instructions.md b/ovens/loop-progress/instructions.md index 94b7de81..57e9da54 100644 --- a/ovens/loop-progress/instructions.md +++ b/ovens/loop-progress/instructions.md @@ -1,9 +1,10 @@ # Loop Progress `loop-progress.oven` is a compact, read-only lens over canonical Burnlist item -and Loop Run data. It answers what is happening, why it matters, which -subsystem and declared files are in scope, and where that work sits in the -whole Burnlist architecture. +and Loop Run data plus bounded correlated observations. It answers what is +happening, why it matters, which subsystem and declared files are in scope, +where that work sits in the whole Burnlist architecture, and whether it is +progressing. ## Data Shape @@ -14,11 +15,16 @@ whole Burnlist architecture. ## State Contract -- Canonical sources: the selected item contract, active checklist order, - frozen Loop assignment, and item-scoped Run projection. -- Hook activity is displayed as unavailable until a bounded hook projection is - part of the canonical payload. It is never inferred from declared files. +- Canonical sources: selected item contract, queue/ledger state, frozen Loop + assignment, Run/node/branch/claim, transitions, checks, gates, reviews, + budgets, blockers, and retries. +- Observational sources: bounded invocation-correlated hooks may expose active + agent/model/effort, tool activity, contained code paths, timing, and reported + tokens. Missing values remain `Unavailable`. +- Forecast sources: bounded built-in priors or matching local observations, + always labelled with confidence and provenance. The Oven is declarative and read-only. Selecting an item changes its declared context and assigned Loop preview; it does not change the authoritative Run or -its active node. +its active node. Observations and forecasts never satisfy a gate or choose an +outcome. diff --git a/scripts/package-paths.json b/scripts/package-paths.json index 1ea58137..455828e7 100644 --- a/scripts/package-paths.json +++ b/scripts/package-paths.json @@ -2,8 +2,8 @@ "LICENSE", "README.md", "bin/burnlist.mjs", - "dashboard/dist/assets/index-BMNqJ4RF.css", - "dashboard/dist/assets/index-ixN_C5MH.js", + "dashboard/dist/assets/index-BSVsW5o6.js", + "dashboard/dist/assets/index-CBt8Jk6v.css", "dashboard/dist/favicon.svg", "dashboard/dist/index.html", "loops/branch/branch.loop", @@ -88,6 +88,8 @@ "skills/burnlist/references/oven-authoring.md", "skills/burnlist/references/oven-contract.md", "skills/burnlist/references/oven-event-coordination.md", + "skills/burnlist/references/operational-ux.md", + "src/cli/actionable-output.mjs", "src/cli/atomic-quarantine.mjs", "src/cli/git-ignore.mjs", "src/cli/hooks-cli.mjs", @@ -162,6 +164,7 @@ "src/loops/run/controller.mjs", "src/loops/run/current-authority.mjs", "src/loops/run/host-execution.mjs", + "src/loops/run/item-work-state.mjs", "src/loops/run/system-runner.mjs", "src/loops/run/read-projection.mjs", "src/loops/run/run-artifacts.mjs", @@ -177,6 +180,7 @@ "src/loops/run/state-machine.mjs", "src/loops/view/ascii.mjs", "src/loops/view/render.mjs", + "src/operations/recommendation.mjs", "src/ovens/built-in-handlers.mjs", "src/ovens/dsl/oven-compile.mjs", "src/ovens/dsl/oven-diagnostics.mjs", diff --git a/scripts/verify-package.mjs b/scripts/verify-package.mjs index 59c89655..44214e8f 100755 --- a/scripts/verify-package.mjs +++ b/scripts/verify-package.mjs @@ -180,7 +180,7 @@ if ((bin.mode & 0o111) === 0) { process.exit(1); } -if (report.entryCount > 220 || report.unpackedSize > 2_500_000) { +if (report.entryCount > 224 || report.unpackedSize > 2_500_000) { console.error(`npm package exceeds its bounded payload budget: ${report.entryCount} files, ${report.unpackedSize} bytes.`); process.exit(1); } diff --git a/scripts/verify-test-files.mjs b/scripts/verify-test-files.mjs index 85dd719a..2f3c261b 100644 --- a/scripts/verify-test-files.mjs +++ b/scripts/verify-test-files.mjs @@ -63,6 +63,7 @@ export const verificationTestFiles = [ "src/server/discovery.test.mjs", "src/server/plan-model.test.mjs", "src/server/fs-safe.test.mjs", + "src/cli/actionable-output.test.mjs", "src/cli/lifecycle-cli.test.mjs", "src/cli/lifecycle-moves.test.mjs", "src/cli/registry-cli.test.mjs", @@ -95,6 +96,7 @@ export const verificationTestFiles = [ "src/loops/contracts/contracts.test.mjs", "src/loops/contracts/host-execution.test.mjs", "src/loops/run/run-store.test.mjs", + "src/loops/run/item-work-state.test.mjs", "src/loops/run/controller.test.mjs", "src/loops/run/current-authority.test.mjs", "src/loops/run/launch-authority.test.mjs", @@ -109,6 +111,7 @@ export const verificationTestFiles = [ "src/loops/run/runner.test.mjs", "src/loops/run/system-runner.test.mjs", "src/loops/run/run-claim.test.mjs", + "src/operations/recommendation.test.mjs", "scripts/verify-source-scan.test.mjs", "ovens/streaming-diff/engine/streaming-diff-hook-adapters.test.mjs", "src/server/oven-bindings.test.mjs", diff --git a/skills/burnlist/SKILL.md b/skills/burnlist/SKILL.md index d3644e1c..40d8212d 100644 --- a/skills/burnlist/SKILL.md +++ b/skills/burnlist/SKILL.md @@ -33,6 +33,7 @@ Read references only when their trigger applies: - `references/creating-ovens.md`: authoring a new .oven declarative source (grammar, elements, binding, themes, compile-to-IR walkthrough). - `references/oven-event-coordination.md`: mandatory for multi-Burnlist worker coordination, generic Oven progress events, replayable subscriptions, and event-triggered coordinator wakeups. - `references/host-execution.md`: generic host next/execute/submit protocol for a prepared Loop Run; read before a host executes an agent node. +- `references/operational-ux.md`: optional per-item recommendations, coarse-to-fine defaults, P0-P4 review handling, truthful live states, provenance, and task-fit Oven visual proof. - `references/loop-capability-example.json`: starting catalog only when a repository has no trusted check capability yet. - `references/loop-provider-setup.md`: mandatory before the first Loop when available native agents, CLIs, logins, or subscriptions are unknown; inventory safely, show the user, and ask what to enable. - `references/loop-providers/.md`: bounded invocation recipe for Claude native, Codex native, Codex CLI, AGY, Grok, or a custom host. Read the selected provider recipe before invoking it. diff --git a/skills/burnlist/references/operational-ux.md b/skills/burnlist/references/operational-ux.md new file mode 100644 index 00000000..53cf3b6a --- /dev/null +++ b/skills/burnlist/references/operational-ux.md @@ -0,0 +1,143 @@ +# Operational UX Contract + +Read this reference when choosing controls for an item, explaining live state, +optimizing routine execution, handling review priority, or proving a task-fit +Oven visually. It is guidance, not project architecture. + +## The shortest useful model + +- A **Burnlist** is the canonical queue and completion ledger: what remains and + what evidence allowed an item to burn. +- A **Loop** is an optional, frozen execution and decision graph for one item: + who acts, which trusted check runs, whether independent review is required, + and which outcomes are legal. +- An **Oven** is a declarative, read-only view over real producer data: whether + the user-approved outcome is moving. +- A **check** executes one explicitly trusted repository capability. A + **convergence gate** evaluates the check/review evidence already recorded by + the Run. Neither is inferred from hooks or an agent statement. + +Burnlist coordinates these contracts softly. The repository still owns its +architecture, implementation strategy, tests, UI or domain tools, deploy +rules, and proof authority. + +## Coarse to fine by default + +For early implementation, prefer this sequence: + +1. Establish the thinnest end-to-end path that reaches the real outcome. +2. Prove that path with the declared repository check and, when useful, a + user-approved Oven signal. +3. Refine internal layers, edge cases, performance, and polish without + replacing the working vertical slice. + +This is a default, not a command to restructure the project. Follow an +existing repository plan or user-selected architecture when it says +otherwise. + +## Optional per-item recommendation + +Use `burnlist recommend # [--repo ]` for one deterministic, +advisory recommendation. `--json` emits +`burnlist-operational-recommendation@1`. The user may accept, change, or ignore +it. + +The recommendation chooses the lightest fitting control: + +| Item shape | Typical Loop | Model class / effort | +| --- | --- | --- | +| Small docs or low-risk direct change | direct | fast / low | +| Bounded implementation with a trusted check | gate | standard / medium | +| Security, data, public-contract, concurrency, or production risk | review | strong / high or xhigh | +| Genuinely independent work streams with integration risk | branch | strong / high | + +Model class is provider-neutral. The host chooses an available model and +remains responsible for invocation. A recommendation never installs, selects, +authenticates, or launches a provider. + +Metrics are also advisory. Prefer objective, user-approved signals: + +- UI or visual work: Visual Parity screenshots and explained pixel drift. +- Reference/candidate or migration work: Differential Testing. +- Performance work: Performance Tracing against a real baseline and budget. +- Model/geometry work: Model Lab against its declared model contract. +- Everything else: the trusted repository check may be enough. + +Do not add an Oven merely to produce a number. A hand-entered metric, an +unapproved proxy, or a compile-only screenshot is not outcome evidence. + +## P0-P4 review handling + +Review severity controls whether completion may proceed: + +| Priority | Finding severity | Handling | +| --- | --- | --- | +| P0 | `blocker` | Stop and escalate safety, security, data-loss, or invalid-authority risk. | +| P1 | `major` | Reject a high-impact correctness or contract defect. | +| P2 | `major` when material | Reject when acceptance or a material user outcome is affected. | +| P3 | `minor` | Do not block; record one bounded follow-up when useful. | +| P4 | `note` | Optional style or preference note; omit noise. | + +An approval cannot leave P0/P1 or material P2 findings open. A gate consumes +canonical check/review evidence; recent activity, forecasts, and agent +confidence never satisfy it. + +## Truthful live item state + +The vanilla Checklist uses exactly these labels: + +- `PENDING`: no canonical Run or claim exists. Checklist position does not + imply execution. +- `ACTIVE`: the canonical Run is executing a deterministic node or has a live + host claim. +- `WAITING`: a Run exists but awaits a host task, resume, or atomic Burnlist + completion. +- `BLOCKED`: canonical Run state needs human action, failed, stopped, exhausted + its budget, or cannot be projected safely. +- `COMPLETED`: the canonical Burnlist completed ledger records the item. + +Bounded, invocation-correlated hooks may refine `ACTIVE` to **progressing** and +show recent agent/model/effort, tools, paths, timing, and available usage. They +never create `ACTIVE`, choose an outcome, satisfy a gate, or complete an item. + +Every live surface must name provenance: + +- canonical: Burnlist lifecycle, queue/ledger, assignment, Run/node/claim, + transitions, checks, gates, reviews, budgets, blockers, and retries; +- observational: hook activity, exposed agent/model/effort, paths, timings, + and reported token usage; +- forecast: bounded prior or local history with confidence and sample counts. + +Missing facts remain `Unavailable`. + +## Visually verify every task-fit Oven + +After creating, adopting, or changing an Oven used as task proof: + +1. Publish real or explicit fixture data through the source-owned producer. +2. Open its canonical dashboard URL. +3. Inspect the rendered desktop and narrow/mobile layouts. +4. Exercise relevant empty, waiting, blocked, stale, error, and completed + states. +5. Confirm labels, values, overflow, hierarchy, and provenance are readable. +6. Record the user-approved signal in the Burnlist item's proof contract. + +Use browser evidence for this inspection. Passing compilation or DOM tests +alone is insufficient. The dashboard stays read-only and the Oven remains +declarative. + +## Optimize observed cost, not invented cost + +For comparable items and trials, record only available facts: + +- host-visible commands; +- agent turns; +- wall time; +- reported input/output tokens; +- check and review retries; +- forecast error. + +Prefer fewer commands and handoffs when authority stays intact. Reuse +`loop next`/`submit`, event-driven refresh, canonical dashboard URLs, and +bounded projections. Never invent token usage, dollar cost, model identity, or +progress from silence. diff --git a/src/cli/actionable-output.mjs b/src/cli/actionable-output.mjs new file mode 100644 index 00000000..e4fba8e3 --- /dev/null +++ b/src/cli/actionable-output.mjs @@ -0,0 +1,69 @@ +import { existsSync, readFileSync, realpathSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { repoKey } from "../server/registry.mjs"; + +const DEFAULT_DASHBOARD_URL = "http://127.0.0.1:4510/"; + +function loopbackUrl(value) { + try { + const url = new URL(value); + if (url.protocol !== "http:" + || !["127.0.0.1", "localhost", "::1", "[::1]"].includes(url.hostname)) return null; + url.pathname = "/"; + url.search = ""; + url.hash = ""; + return url.toString(); + } catch { + return null; + } +} + +export function dashboardRuntime({ + runtimePath = join(homedir(), ".burnlist", "server.json"), + pidAlive = (pid) => { + try { process.kill(pid, 0); return true; } catch { return false; } + }, +} = {}) { + try { + if (!existsSync(runtimePath)) return { baseUrl: DEFAULT_DASHBOARD_URL, live: false }; + const runtime = JSON.parse(readFileSync(runtimePath, "utf8")); + const baseUrl = loopbackUrl(runtime?.url); + if (!baseUrl || !Number.isSafeInteger(runtime?.pid) || runtime.pid <= 0 || !pidAlive(runtime.pid)) { + return { baseUrl: DEFAULT_DASHBOARD_URL, live: false }; + } + return { baseUrl, live: true }; + } catch { + return { baseUrl: DEFAULT_DASHBOARD_URL, live: false }; + } +} + +function canonicalRepoRoot(repoRoot) { + try { return realpathSync(repoRoot); } catch { return repoRoot; } +} + +export function dashboardUrl(repoRoot, { + burnlistId = null, + ovenId = null, + runtime, +} = {}) { + const root = canonicalRepoRoot(repoRoot); + const state = runtime ?? dashboardRuntime(); + let path = `r/${repoKey(root)}`; + if (burnlistId) path += `/${encodeURIComponent(burnlistId)}`; + if (ovenId) path += `/o/${encodeURIComponent(ovenId)}`; + return new URL(path, state.baseUrl).toString(); +} + +export function dashboardCatalogUrl(ovenId = null, { runtime } = {}) { + const state = runtime ?? dashboardRuntime(); + return new URL(ovenId ? `ovens/${encodeURIComponent(ovenId)}` : "ovens", state.baseUrl).toString(); +} + +export function dashboardHandoff(repoRoot, url, nextCommand, { runtime } = {}) { + const state = runtime ?? dashboardRuntime(); + const lines = [`Dashboard: ${url}`]; + if (!state.live) lines.push(`Dashboard start: burnlist --scan-root ${JSON.stringify(canonicalRepoRoot(repoRoot))}`); + if (nextCommand) lines.push(`Next: ${nextCommand}`); + return lines.join("\n"); +} diff --git a/src/cli/actionable-output.test.mjs b/src/cli/actionable-output.test.mjs new file mode 100644 index 00000000..d882af30 --- /dev/null +++ b/src/cli/actionable-output.test.mjs @@ -0,0 +1,38 @@ +import assert from "node:assert/strict"; +import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { dashboardCatalogUrl, dashboardHandoff, dashboardRuntime, dashboardUrl } from "./actionable-output.mjs"; +import { repoKey } from "../server/registry.mjs"; + +test("dashboard links use the live loopback runtime and canonical repo key", (t) => { + const root = mkdtempSync(join(tmpdir(), "burnlist-actionable-")); + t.after(() => rmSync(root, { recursive: true, force: true })); + const repo = join(root, "repo"), runtimePath = join(root, "server.json"); + mkdirSync(repo); + writeFileSync(runtimePath, '{"pid":42,"url":"http://127.0.0.1:4599/"}\n'); + const runtime = dashboardRuntime({ runtimePath, pidAlive: (pid) => pid === 42 }); + assert.deepEqual(runtime, { baseUrl: "http://127.0.0.1:4599/", live: true }); + assert.equal( + dashboardUrl(repo, { burnlistId: "260725-001", runtime }), + `http://127.0.0.1:4599/r/${repoKey(realpathSync(repo))}/260725-001`, + ); + assert.equal(dashboardUrl(repo, { ovenId: "loop-progress", runtime }), + `http://127.0.0.1:4599/r/${repoKey(realpathSync(repo))}/o/loop-progress`); + assert.equal(dashboardCatalogUrl("checklist", { runtime }), "http://127.0.0.1:4599/ovens/checklist"); +}); + +test("missing or unsafe runtime falls back to the canonical default and a start action", (t) => { + const root = mkdtempSync(join(tmpdir(), "burnlist-actionable-fallback-")); + t.after(() => rmSync(root, { recursive: true, force: true })); + const repo = join(root, "repo"); + mkdirSync(repo); + const runtime = dashboardRuntime({ runtimePath: join(root, "missing.json") }); + assert.deepEqual(runtime, { baseUrl: "http://127.0.0.1:4510/", live: false }); + const url = dashboardUrl(repo, { burnlistId: "260725-001", runtime }); + const output = dashboardHandoff(repo, url, "burnlist show 260725-001", { runtime }); + assert.match(output, /^Dashboard: http:\/\/127\.0\.0\.1:4510\/r\//u); + assert.match(output, new RegExp(`Dashboard start: burnlist --scan-root ${JSON.stringify(realpathSync(repo))}`, "u")); + assert.match(output, /Next: burnlist show 260725-001/u); +}); diff --git a/src/cli/lifecycle-cli.mjs b/src/cli/lifecycle-cli.mjs index e0eb0ae9..36bf41d9 100644 --- a/src/cli/lifecycle-cli.mjs +++ b/src/cli/lifecycle-cli.mjs @@ -1,6 +1,5 @@ #!/usr/bin/env node -import { existsSync, readFileSync, readdirSync, realpathSync } from "node:fs"; -import { homedir } from "node:os"; +import { readdirSync, realpathSync } from "node:fs"; import { join, resolve } from "node:path"; import { LIFECYCLES, @@ -19,6 +18,15 @@ import { import { repoKey, readRegistry } from "../server/registry.mjs"; import { atomicDirectory, safeStat } from "../server/fs-safe.mjs"; import { resolveUmbrella } from "./umbrella.mjs"; +import { + dashboardHandoff, + dashboardRuntime, + dashboardUrl, +} from "./actionable-output.mjs"; +import { + recommendOperationalProfile, + renderOperationalRecommendation, +} from "../operations/recommendation.mjs"; const MAX_RESERVATION_ATTEMPTS = 1000; @@ -28,11 +36,11 @@ function fail(message, code = 1) { } function usage() { - fail("Usage: burnlist new [--repo ] | burnlist show [#] [--repo ] | burnlist ready|start|close [--repo ] | burnlist burn [--check] [--repo ]", 2); + fail("Usage: burnlist new [--repo ] | burnlist show [#] [--repo ] | burnlist recommend # [--json] [--repo ] | burnlist ready|start|close [--repo ] | burnlist burn [--check] [--repo ]", 2); } function parseArgs(tokens) { - const opts = { check: false, repo: null, positionals: [] }; + const opts = { check: false, json: false, repo: null, positionals: [] }; for (let index = 0; index < tokens.length; index += 1) { const token = tokens[index]; if (token === "--repo") { @@ -42,6 +50,8 @@ function parseArgs(tokens) { index += 1; } else if (token === "--check") { opts.check = true; + } else if (token === "--json") { + opts.json = true; } else if (token.startsWith("--")) { throw new Error(`Unknown option: ${token}`); } else { @@ -125,6 +135,13 @@ function create(repoRoot) { console.log(id); console.log(planPath); console.log(`${repoKey(canonicalRoot)}/${id}`); + const runtime = dashboardRuntime(); + console.log(dashboardHandoff( + canonicalRoot, + dashboardUrl(canonicalRoot, { burnlistId: id, runtime }), + `edit ${JSON.stringify(join(folder, "goal.md"))} and ${JSON.stringify(planPath)}, then run burnlist ready ${id} --repo ${JSON.stringify(canonicalRoot)}`, + { runtime }, + )); return; } catch (error) { if (error?.code === "ENOTEMPTY" || error?.code === "EEXIST") continue; @@ -218,38 +235,71 @@ function show(reference, opts) { } const handle = `${repoKey(repoRoot)}/${parsed.id}${parsed.item ? `#${parsed.item}` : ""}`; console.log(`Copy handle: ${handle}`); - const globalServerPath = join(homedir(), ".burnlist", "server.json"); - try { - if (existsSync(globalServerPath)) { - const server = JSON.parse(readFileSync(globalServerPath, "utf8")); - process.kill(server.pid, 0); - const base = String(server.url).endsWith("/") ? server.url : `${server.url}/`; - console.log(`URL: ${base}r/${handle}`); - } - } catch { - // A stale or malformed global dashboard record must not affect show. - } + const runtime = dashboardRuntime(); + console.log(`URL: ${dashboardUrl(repoRoot, { burnlistId: parsed.id, runtime })}${parsed.item ? `#${encodeURIComponent(parsed.item)}` : ""}`); console.log(`Path: ${planPath}`); console.log(`Title: ${plan.title}`); } +function recommend(reference, opts) { + const parsed = parseReference(reference); + if (!parsed.item) throw new Error("recommend requires #."); + const repoRoot = realpathSync(resolveRepo(opts)); + const { planPath } = findPlan(repoRoot, parsed.id); + const plan = parsePlan(planPath); + const item = plan.items.find((entry) => entry.id === parsed.item); + if (!item) throw new Error(`Active item ${parsed.item} was not found.`); + const recommendation = recommendOperationalProfile(item); + if (opts.json) console.log(JSON.stringify({ itemRef: `${parsed.id}#${parsed.item}`, ...recommendation })); + else { + console.log(renderOperationalRecommendation(`${parsed.id}#${parsed.item}`, recommendation)); + const loopRef = recommendation.loop === "direct" ? null : `loop:builtin:${recommendation.loop}`; + const next = loopRef + ? `burnlist loop assign item:${parsed.id}#${parsed.item} ${loopRef} --repo ${JSON.stringify(repoRoot)}` + : `burnlist show ${parsed.id}#${parsed.item} --repo ${JSON.stringify(repoRoot)}`; + const runtime = dashboardRuntime(); + console.log(dashboardHandoff( + repoRoot, + dashboardUrl(repoRoot, { burnlistId: parsed.id, runtime }), + next, + { runtime }, + )); + } +} + async function main() { const tokens = process.argv.slice(2); const verb = tokens.shift(); const opts = parseArgs(tokens); if (verb === "new" && opts.positionals.length === 0) return create(resolveRepo(opts)); if (verb === "show" && opts.positionals.length === 1) return show(opts.positionals[0], opts); + if (verb === "recommend" && opts.positionals.length === 1) return recommend(opts.positionals[0], opts); if (verb === "ready" && opts.positionals.length === 1) { const id = assertValidBurnlistId(opts.positionals[0]); - return readyLifecycle(resolveRepo(opts), id); + const repoRoot = resolveRepo(opts); + const result = readyLifecycle(repoRoot, id); + const runtime = dashboardRuntime(); + console.log(dashboardHandoff(repoRoot, dashboardUrl(repoRoot, { burnlistId: id, runtime }), + `burnlist start ${id} --repo ${JSON.stringify(repoRoot)}`, { runtime })); + return result; } if (verb === "start" && opts.positionals.length === 1) { const id = assertValidBurnlistId(opts.positionals[0]); - return startLifecycle(resolveRepo(opts), id); + const repoRoot = resolveRepo(opts); + const result = startLifecycle(repoRoot, id); + const runtime = dashboardRuntime(); + console.log(dashboardHandoff(repoRoot, dashboardUrl(repoRoot, { burnlistId: id, runtime }), + `burnlist show ${id} --repo ${JSON.stringify(repoRoot)}`, { runtime })); + return result; } if (verb === "close" && opts.positionals.length === 1) { const id = assertValidBurnlistId(opts.positionals[0]); - return closeLifecycle(resolveRepo(opts), id); + const repoRoot = resolveRepo(opts); + const result = closeLifecycle(repoRoot, id); + const runtime = dashboardRuntime(); + console.log(dashboardHandoff(repoRoot, dashboardUrl(repoRoot, { burnlistId: id, runtime }), + `burnlist new --repo ${JSON.stringify(repoRoot)}`, { runtime })); + return result; } if (verb === "burn" && opts.positionals.length === 2) { const id = assertValidBurnlistId(opts.positionals[0]); diff --git a/src/cli/lifecycle-cli.test.mjs b/src/cli/lifecycle-cli.test.mjs index 3394c3be..37097049 100644 --- a/src/cli/lifecycle-cli.test.mjs +++ b/src/cli/lifecycle-cli.test.mjs @@ -53,12 +53,37 @@ test("new creates a protocol-valid draft scaffold", () => { assert.equal(readFileSync(result.planPath, "utf8").includes("## Active Checklist\n\n## Completed"), true); assert.equal(readFileSync(join(dirname(result.planPath), "goal.md"), "utf8").includes("## Proof Authority"), true); assert.match(result.handle, new RegExp(`^${repoKey(realpathSync(context.repo))}/${result.id}$`)); + assert.match(result.output, /Dashboard: http:\/\/127\.0\.0\.1:4510\/r\/[a-f0-9]{12}\/\d{6}-001/u); + assert.match(result.output, /Dashboard start: burnlist --scan-root/u); + assert.match(result.output, /Next: edit .*goal\.md.*burnlist\.md.*burnlist ready/u); assert.match(run(context, "--plan", result.planPath, "--check"), /Burnlist check passed: 0 active, 0 completed\./u); } finally { context.cleanup(); } }); +test("recommend prints optional per-item controls, metrics, next action, and dashboard URL", () => { + const context = fixture(); + try { + const result = newPlan(context); + addActiveItem(result.planPath, context.repo); + const output = run(context, "recommend", `${result.id}#B1`); + assert.match(output, /advisory; user choice remains authoritative/u); + assert.match(output, /Loop: loop:builtin:gate/u); + assert.match(output, /Model \/ effort: standard \/ medium/u); + assert.match(output, /P0-P1 block/u); + assert.match(output, /Optimize observed: host-visible commands; agent turns; wall time/u); + assert.match(output, /Dashboard: http:\/\/127\.0\.0\.1:4510\/r\/[a-f0-9]{12}\//u); + assert.match(output, /Next: burnlist loop assign item:/u); + const json = JSON.parse(run(context, "recommend", `${result.id}#B1`, "--json")); + assert.equal(json.schema, "burnlist-operational-recommendation@1"); + assert.equal(json.advisory, true); + assert.equal(json.itemRef, `${result.id}#B1`); + } finally { + context.cleanup(); + } +}); + test("new allocates incrementing ids and skips an existing draft reservation", () => { const context = fixture(); try { diff --git a/src/cli/loop-cli.mjs b/src/cli/loop-cli.mjs index 20988527..82d4800d 100644 --- a/src/cli/loop-cli.mjs +++ b/src/cli/loop-cli.mjs @@ -10,6 +10,11 @@ import { createLoopController } from "../loops/run/controller.mjs"; import { createProductionRun, createStoredSystemRunRunner } from "../loops/run/binder.mjs"; import { completeLoopRun } from "../loops/completion/completion.mjs"; import { validateHostExecutionEnvelope } from "../loops/contracts/host-execution.mjs"; +import { + dashboardHandoff, + dashboardRuntime, + dashboardUrl, +} from "./actionable-output.mjs"; function usageText() { return loopConfigUsage(); } function usageError(message = usageText()) { return Object.assign(new Error(message), { exitCode: 2 }); } @@ -142,7 +147,19 @@ export async function runLoopCli(tokens, { runReader, runnerFor, stdout = proces if (verb === "assign" && opts.positionals.length === 2) { const prepared = prepareItemMutation({ repoRoot: opts.repo, itemRef: opts.positionals[0] }); const result = await assignLoopItem({ repoRoot: opts.repo, itemRef: opts.positionals[0], loopRef: opts.positionals[1], prepared }); - stdout.write(`${result.assignmentId}\n${result.selector}\n${result.executionRevision}\n`); return result; + const itemMatch = opts.positionals[0].match(/^item:(\d{6}-\d{3})#/u); + const runtime = dashboardRuntime(); + const dashboard = dashboardUrl(opts.repo, { + ...(itemMatch ? { burnlistId: itemMatch[1] } : {}), + ovenId: "loop-progress", + runtime, + }); + stdout.write(`${result.assignmentId}\n${result.selector}\n${result.executionRevision}\n${dashboardHandoff( + opts.repo, + dashboard, + `burnlist loop create ${opts.positionals[0]} --repo ${JSON.stringify(opts.repo)}`, + { runtime }, + )}\n`); return result; } if (verb === "unassign" && opts.positionals.length === 1) { const prepared = prepareItemMutation({ repoRoot: opts.repo, itemRef: opts.positionals[0] }); diff --git a/src/cli/loop-cli.test.mjs b/src/cli/loop-cli.test.mjs index a7db469a..16de8b5e 100644 --- a/src/cli/loop-cli.test.mjs +++ b/src/cli/loop-cli.test.mjs @@ -39,6 +39,8 @@ function assign(context) { const result = runCommand(context.repo, ["loop", "assign", itemRef, "loop:builtin:review", "--repo", context.repo]); assert.equal(result.status, 0, result.stderr); const [assignmentId, selector, executable] = result.stdout.trimEnd().split("\n"); + assert.match(result.stdout, /Dashboard: http:\/\/127\.0\.0\.1:4510\/r\/[a-f0-9]{12}\/260722-001\/o\/loop-progress/u); + assert.match(result.stdout, /Next: burnlist loop create item:260722-001#BUG-07/u); return { assignmentId, selector, executable, path: join(context.repo, ".local", "burnlist", "loop", "v2", "assignments", assignmentId.slice(11)) }; } function pinError(executable) { diff --git a/src/cli/oven-cli.mjs b/src/cli/oven-cli.mjs index 89c8d09a..e4e78041 100644 --- a/src/cli/oven-cli.mjs +++ b/src/cli/oven-cli.mjs @@ -27,6 +27,12 @@ import { setOvenDataFromCli } from "./oven-set.mjs"; import { createOvenCatalog, persistOven, resolvePackageInput } from "./oven-storage.mjs"; import { useShippedOven } from "./oven-use.mjs"; import { resolveUmbrella } from "./umbrella.mjs"; +import { + dashboardCatalogUrl, + dashboardHandoff, + dashboardRuntime, + dashboardUrl, +} from "./actionable-output.mjs"; // ── argv ──────────────────────────────────────────────────────────────────── // process.argv is [node, bin/burnlist.mjs, "oven", , ...rest]. @@ -81,6 +87,15 @@ function publishDefinitionChange(root, saved, action) { }), { onError: mutationError(`${action} Oven ${saved.id}`) }); } +function printOvenHandoff(id, next, { catalog = false } = {}) { + const root = repoRoot(); + const runtime = dashboardRuntime(); + const url = catalog + ? dashboardCatalogUrl(id, { runtime }) + : dashboardUrl(root, { ovenId: id, runtime }); + console.log(dashboardHandoff(root, url, next, { runtime })); +} + // ── storage locations (mirror the dashboard server) ────────────────────────── const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..", ".."); const builtInOvensDir = resolve(packageRoot, "ovens"); @@ -236,6 +251,9 @@ try { console.log(line(header)); console.log(line(widths.map((width) => "─".repeat(width)))); for (const row of rows) console.log(line(row)); + const runtime = dashboardRuntime(); + console.log(dashboardHandoff(repoRoot(), dashboardCatalogUrl(null, { runtime }), + "burnlist oven view ", { runtime })); return; } @@ -261,6 +279,9 @@ try { return; } printOven(oven); + printOvenHandoff(oven.id, + oven.builtIn ? `burnlist oven use ${oven.id} --repo ${JSON.stringify(repoRoot())}` : "open the dashboard URL above", + { catalog: oven.builtIn }); return; } @@ -272,6 +293,7 @@ try { onError: mutationError(`Bound Oven ${ovenId(id)}`), }); console.log(`Bound Oven ${ovenId(id)} to ${logicalPath}\nStore: ${result.path}`); + printOvenHandoff(ovenId(id), "open the dashboard URL above"); return; } @@ -302,6 +324,7 @@ try { const result = setOvenDataFromCli({ positionals, repoRoot: bindingRepo(), launchCwd, findOven }); for (const warning of result.warnings) console.warn(`burnlist oven: warning: ${warning}`); console.log(result.output); + printOvenHandoff(ovenId(positionals[0]), "open the dashboard URL above"); return; } @@ -317,6 +340,7 @@ try { }); for (const warning of result.warnings) console.warn(`burnlist oven: warning: ${warning}`); console.log(result.output); + printOvenHandoff(ovenId(id), result.output.includes("\nNext:") ? null : "open the dashboard URL above"); return; } @@ -361,6 +385,8 @@ try { publishDefinitionChange(customRepoRoot, saved, subcommand === "update" ? "updated" : "created"); console.log(`${subcommand === "update" ? "Updated" : "Created"} Oven ${pkg.id} at ${path}\n`); printOven(saved); + printOvenHandoff(pkg.id, + `burnlist oven set ${pkg.id} --repo ${JSON.stringify(customRepoRoot)}`); return; } @@ -380,6 +406,8 @@ try { }); publishDefinitionChange(customRepoRoot, readOvenDir(customOvensDir, pkg.id, false), "forked"); console.log(`Forked Oven ${pkg.id} at ${path}\nForked from ${source.id}@${sourceRevision}`); + printOvenHandoff(pkg.id, + `burnlist oven set ${pkg.id} --repo ${JSON.stringify(customRepoRoot)}`); return; } @@ -406,6 +434,7 @@ try { publishDefinitionChange(targetRoot, saved, subcommand === "adopt" ? "adopted" : "upgraded"); if (subcommand === "adopt") console.log(`Adopted Oven ${saved.id}@${saved.version} at ${targetPath}`); else console.log(`Upgraded Oven ${saved.id}@${saved.version} at ${targetPath}\nrevision: ${saved.revision}`); + printOvenHandoff(saved.id, `burnlist oven view ${saved.id} --repo ${JSON.stringify(targetRoot)}`); return; } diff --git a/src/cli/registry-cli.mjs b/src/cli/registry-cli.mjs index 5376de70..a3117bf0 100644 --- a/src/cli/registry-cli.mjs +++ b/src/cli/registry-cli.mjs @@ -16,6 +16,7 @@ import { unregisterRoot, } from "../server/registry.mjs"; import { gitProbe } from "./git-ignore.mjs"; +import { dashboardHandoff, dashboardRuntime } from "./actionable-output.mjs"; const LIFECYCLE_FOLDERS = ["draft", "ready", "inprogress", "completed"]; const IGNORE_LINES = ["/notes/burnlists/", "/.local/"]; @@ -116,6 +117,10 @@ function printUsage() { function runRegister(path) { const result = registerRoot(path); console.log(`${result.added ? "Registered" : "Already registered"} ${result.root}`); + const runtime = dashboardRuntime(); + console.log(dashboardHandoff(result.root, runtime.baseUrl, + runtime.live ? `open ${runtime.baseUrl}` : `burnlist --scan-root ${JSON.stringify(result.root)}`, + { runtime })); } function runUnregister(path) { @@ -148,6 +153,9 @@ function runInit(path, track) { else if (ignoreState === "already ignored") console.log("notes/burnlists/ and .local/ are already ignored locally."); else console.log("Ignored /notes/burnlists/ and /.local/ locally."); console.log(`${registration.added ? "Registered" : "Already registered"} ${registration.root}.`); + const runtime = dashboardRuntime(); + console.log(dashboardHandoff(registration.root, runtime.baseUrl, + `burnlist new --repo ${JSON.stringify(registration.root)}`, { runtime })); } async function main() { diff --git a/src/cli/registry-cli.test.mjs b/src/cli/registry-cli.test.mjs index d67e4781..6513785b 100644 --- a/src/cli/registry-cli.test.mjs +++ b/src/cli/registry-cli.test.mjs @@ -42,7 +42,10 @@ test("register lists a root and is idempotent", () => { mkdirSync(root); const canonicalRoot = realpathSync(root); try { - assert.match(run(home, "register", root), new RegExp(`Registered ${canonicalRoot}`)); + const registered = run(home, "register", root); + assert.match(registered, new RegExp(`Registered ${canonicalRoot}`)); + assert.match(registered, /Dashboard: http:\/\/127\.0\.0\.1:4510\//u); + assert.match(registered, /Next: burnlist --scan-root/u); assert.match(run(home, "roots"), new RegExp(`empty\\s+${canonicalRoot}`)); assert.match(run(home, "register", root), new RegExp(`Already registered ${canonicalRoot}`)); } finally { @@ -92,6 +95,8 @@ test("init creates lifecycle folders, ignores them, and registers the repo", () assert.match(exclude(repo), /^\/\.local\/$/mu); assert.match(run(home, "roots"), new RegExp(`empty\\s+${canonicalRepo}`)); assert.match(first, /Ignored \/notes\/burnlists\/ and \/\.local\/ locally\./u); + assert.match(first, /Dashboard: http:\/\/127\.0\.0\.1:4510\//u); + assert.match(first, /Next: burnlist new --repo/u); run(home, "init", repo); assert.equal(exclude(repo).split("\n").filter((line) => line === "/notes/burnlists/").length, 1); assert.equal(exclude(repo).split("\n").filter((line) => line === "/.local/").length, 1); diff --git a/src/loops/run/item-work-state.mjs b/src/loops/run/item-work-state.mjs new file mode 100644 index 00000000..e130d0c0 --- /dev/null +++ b/src/loops/run/item-work-state.mjs @@ -0,0 +1,108 @@ +const BLOCKED_STATES = new Set(["needs-human", "failed", "stopped", "budget-exhausted"]); +export const ITEM_WORK_STATES = Object.freeze(["PENDING", "ACTIVE", "WAITING", "BLOCKED", "COMPLETED"]); +export const RECENT_ACTIVITY_MS = 120_000; + +const boundedPaths = (records) => [...new Set(records.flatMap((record) => [ + ...(record.observedPath ? [record.observedPath] : []), + ...(record.observedPaths ?? []), +]))].slice(-16); + +function observation(run, now) { + const records = run?.activity?.records?.filter((record) => + record.origin === "host-hook" || record.origin === "agent-reported") ?? []; + const last = records.at(-1) ?? null; + const ageMilliseconds = last && Number.isSafeInteger(last.at) + ? Math.max(0, now - last.at) + : null; + return Object.freeze({ + source: "bounded-correlated-hooks", + authority: "observational", + availability: run?.activity?.hooks ?? "unavailable", + lastAt: last?.at ?? null, + lastKind: last?.kind ?? null, + ageMilliseconds, + recent: ageMilliseconds !== null && ageMilliseconds <= RECENT_ACTIVITY_MS, + codeChanges: Object.freeze(boundedPaths(records)), + }); +} + +function projectedState(run) { + if (BLOCKED_STATES.has(run.state)) { + return { state: "BLOCKED", reason: `Canonical Run is ${run.state}.` }; + } + if (run.state === "converged") { + return { state: "WAITING", reason: "Canonical Run converged and is waiting for atomic Burnlist completion." }; + } + if (run.state === "prepared") { + return { state: "WAITING", reason: "Canonical Run is prepared and waiting for the next host task." }; + } + if (run.state === "paused") { + return { state: "WAITING", reason: "Canonical Run is paused." }; + } + const node = run.graph?.nodes?.find((entry) => entry.id === run.currentNode); + if (run.state === "running" && node?.kind === "agent" && run.hostTask !== "claimed") { + return { state: "WAITING", reason: "Canonical agent node is waiting for a host claim." }; + } + if (run.state === "running") { + return { state: "ACTIVE", reason: run.hostTask === "claimed" + ? "Canonical Run has a live host claim." + : "Canonical Run is executing a deterministic node." }; + } + return { state: "BLOCKED", reason: `Canonical Run state ${run.state || "unknown"} is unsupported.` }; +} + +export function projectItemWorkState({ run = null, now = Date.now(), diagnostic = null } = {}) { + if (diagnostic) return Object.freeze({ + state: "BLOCKED", + reason: "Canonical Run projection is unavailable.", + run: null, + progressing: false, + observation: observation(null, now), + provenance: Object.freeze({ + state: "canonical-run-projection", + activity: "bounded-correlated-hooks-observational-only", + }), + }); + if (!run) return Object.freeze({ + state: "PENDING", + reason: "No canonical Run or claim exists; checklist position does not imply execution.", + run: null, + progressing: false, + observation: observation(null, now), + provenance: Object.freeze({ + state: "canonical-burnlist-and-run-absence", + activity: "unavailable", + }), + }); + const status = projectedState(run); + const observed = observation(run, now); + return Object.freeze({ + ...status, + run: Object.freeze({ + runId: run.runId, + state: run.state, + node: run.currentNode, + claim: run.hostTask ?? "unavailable", + }), + progressing: status.state === "ACTIVE" && observed.recent, + observation: observed, + provenance: Object.freeze({ + state: "canonical-run-and-claim", + activity: "bounded-correlated-hooks-observational-only", + }), + }); +} + +export function completedItemWorkState() { + return Object.freeze({ + state: "COMPLETED", + reason: "Canonical Burnlist completed ledger records this item.", + run: null, + progressing: false, + observation: observation(null, Date.now()), + provenance: Object.freeze({ + state: "canonical-burnlist-completed-ledger", + activity: "not-applicable", + }), + }); +} diff --git a/src/loops/run/item-work-state.test.mjs b/src/loops/run/item-work-state.test.mjs new file mode 100644 index 00000000..60b09956 --- /dev/null +++ b/src/loops/run/item-work-state.test.mjs @@ -0,0 +1,53 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + completedItemWorkState, + projectItemWorkState, + RECENT_ACTIVITY_MS, +} from "./item-work-state.mjs"; + +function run(patch = {}) { + return { + runId: "run:test", + state: "running", + currentNode: "implement", + hostTask: "claimed", + graph: { nodes: [{ id: "implement", kind: "agent" }] }, + activity: { hooks: "unavailable", records: [] }, + ...patch, + }; +} + +test("checklist position never fabricates active work", () => { + const pending = projectItemWorkState(); + assert.equal(pending.state, "PENDING"); + assert.equal(pending.progressing, false); + assert.match(pending.reason, /checklist position does not imply execution/u); + assert.equal(pending.provenance.state, "canonical-burnlist-and-run-absence"); +}); + +test("canonical Run and claim distinguish waiting, active, blocked, and completed", () => { + assert.equal(projectItemWorkState({ run: run({ state: "prepared", hostTask: "awaiting-claim" }) }).state, "WAITING"); + assert.equal(projectItemWorkState({ run: run({ hostTask: "awaiting-claim" }) }).state, "WAITING"); + assert.equal(projectItemWorkState({ run: run() }).state, "ACTIVE"); + assert.equal(projectItemWorkState({ run: run({ state: "needs-human" }) }).state, "BLOCKED"); + assert.equal(projectItemWorkState({ diagnostic: "corrupt" }).state, "BLOCKED"); + assert.equal(completedItemWorkState().state, "COMPLETED"); +}); + +test("only recent correlated hooks refine active work to progressing", () => { + const now = 1_000_000; + const active = run({ activity: { hooks: "available", records: [ + { at: now - RECENT_ACTIVITY_MS - 1, origin: "runner", kind: "claimed", observedPath: "ignored.mjs" }, + { at: now - 500, origin: "host-hook", kind: "tool-finished", observedPaths: ["src/a.mjs", "src/b.mjs"] }, + ] } }); + const projected = projectItemWorkState({ run: active, now }); + assert.equal(projected.state, "ACTIVE"); + assert.equal(projected.progressing, true); + assert.deepEqual(projected.observation.codeChanges, ["src/a.mjs", "src/b.mjs"]); + assert.equal(projected.observation.authority, "observational"); + + const waiting = projectItemWorkState({ run: { ...active, hostTask: "awaiting-claim" }, now }); + assert.equal(waiting.state, "WAITING"); + assert.equal(waiting.progressing, false); +}); diff --git a/src/loops/run/read-projection.mjs b/src/loops/run/read-projection.mjs index 7d13d715..2146df1f 100644 --- a/src/loops/run/read-projection.mjs +++ b/src/loops/run/read-projection.mjs @@ -63,6 +63,9 @@ export function presentRun(replay, { optionalRecords = [], forecast = null } = { const executionMode = replay.execution.started ? declaredMode === "managed" ? "managed" : declaredMode === "host" ? "host-reported" : "unavailable" : "unavailable"; + const hostTask = replay.execution.node?.kind !== "agent" ? "not-applicable" + : replay.execution.externalClaim ? "claimed" + : replay.execution.started ? "resolved" : "awaiting-claim"; return Object.freeze({ schema: "burnlist-loop-read-projection@1", runId: replay.projection.runId, @@ -75,6 +78,7 @@ export function presentRun(replay, { optionalRecords = [], forecast = null } = { currentNode: replay.projection.currentNode, attempt: replay.projection.attempt, cycle: replay.execution.cycle, + hostTask, execution: { mode: executionMode, started: replay.execution.started, diff --git a/src/operations/recommendation.mjs b/src/operations/recommendation.mjs new file mode 100644 index 00000000..892de308 --- /dev/null +++ b/src/operations/recommendation.mjs @@ -0,0 +1,122 @@ +const HIGH_RISK = /\b(auth(?:entication|orization)?|permission|security|secret|payment|billing|migration|schema|data[- ]?loss|atomic|concurren|race|public api|breaking|deploy|production)\b/u; +const BRANCHABLE = /\b(parallel|independent|lanes?|workstreams?|frontend and backend|client and server|multiple packages|fan[- ]?out)\b/u; +const IMPLEMENTATION = /\b(implement|build|change|fix|refactor|migrate|add|remove|replace|integrate|optimi[sz]e)\b/u; +const DOCUMENTATION = /\b(readme|documentation|docs?|copy|wording|guide|reference)\b/u; +const VISUAL = /\b(ui|ux|visual|layout|css|browser|responsive|dashboard|oven|screenshot|pixel)\b/u; +const PERFORMANCE = /\b(performance|latency|throughput|memory|cpu|benchmark|timing|budget)\b/u; +const DIFFERENTIAL = /\b(equivalence|golden|migration|legacy|candidate|reference|byte[- ]?diff|differential)\b/u; +const MODEL = /\b(model lab|mesh|geometry|animation|lod|three[- ]?d|3d)\b/u; + +export const REVIEW_PRIORITIES = Object.freeze([ + Object.freeze({ priority: "P0", handling: "Stop and escalate: safety, security, data loss, or invalid authority." }), + Object.freeze({ priority: "P1", handling: "Reject: high-impact correctness or contract defect." }), + Object.freeze({ priority: "P2", handling: "Reject when acceptance or a material user outcome is affected." }), + Object.freeze({ priority: "P3", handling: "Do not block completion; record a bounded follow-up." }), + Object.freeze({ priority: "P4", handling: "Optional note or style preference; omit when it adds no value." }), +]); + +function itemText(item) { + return [item?.title, ...Object.values(item?.fields ?? {})].filter(Boolean).join(" ").toLowerCase(); +} + +function pathCount(item) { + const value = item?.fields?.["Files/search"] ?? ""; + return new Set(value.split(/[\s,]+/u) + .map((part) => part.replace(/^`|`$/gu, "").trim()) + .filter((part) => part.includes("/") || /\.[a-z0-9]+$/iu.test(part))).size; +} + +function codeSurface(item) { + return /\.(?:[cm]?[jt]sx?|py|rb|rs|go|java|kt|swift|css|scss|html)\b/iu + .test(item?.fields?.["Files/search"] ?? ""); +} + +function metricRecommendation(text) { + if (VISUAL.test(text)) return { + oven: "visual-parity", + signal: "User-approved task-fit screenshots with zero unexplained visual drift.", + visualVerificationRequired: true, + }; + if (PERFORMANCE.test(text)) return { + oven: "performance-tracing", + signal: "Measured timings stay within the user-approved baseline and budget.", + visualVerificationRequired: true, + }; + if (DIFFERENTIAL.test(text)) return { + oven: "differential-testing", + signal: "Reference-versus-candidate evidence reaches the user-approved exact or toleranced target.", + visualVerificationRequired: true, + }; + if (MODEL.test(text)) return { + oven: "model-lab", + signal: "The user-approved model contract and comparison evidence pass.", + visualVerificationRequired: true, + }; + return { + oven: null, + signal: "Use the declared repository check; add an Oven only for a user-approved objective outcome.", + visualVerificationRequired: false, + }; +} + +export function recommendOperationalProfile(item) { + if (!item || typeof item.title !== "string" || !item.fields || typeof item.fields !== "object") { + throw new Error("Operational recommendation requires one parsed Burnlist item."); + } + const text = itemText(item); + const highRisk = HIGH_RISK.test(text); + const branchable = BRANCHABLE.test(text) || (pathCount(item) >= 5 && /\bacross\b/u.test(text)); + const implementation = IMPLEMENTATION.test(text) || codeSurface(item); + const docsOnly = DOCUMENTATION.test(text) && !implementation && !highRisk; + const loop = branchable ? "branch" : highRisk ? "review" : implementation ? "gate" : "direct"; + const modelClass = loop === "branch" || loop === "review" ? "strong" + : loop === "gate" ? "standard" : "fast"; + const effort = highRisk ? "xhigh" : loop === "branch" ? "high" + : loop === "review" ? "high" : loop === "gate" ? "medium" : "low"; + const review = ["review", "branch"].includes(loop) + ? "P0-P2 block; P3 follow-up; P4 optional note." + : loop === "gate" + ? "P0-P1 block; P2 blocks only when acceptance is materially affected; P3-P4 follow-up." + : "Self-check; escalate any P0-P2 finding instead of silently accepting it."; + const metric = metricRecommendation(text); + return Object.freeze({ + schema: "burnlist-operational-recommendation@1", + advisory: true, + loop, + modelClass, + effort, + review, + metric, + sequence: docsOnly + ? "Make the smallest complete documentation change, verify links/examples, then refine wording." + : "Establish the thinnest working end-to-end path first, prove it, then refine internals and edges.", + optimize: Object.freeze([ + "host-visible commands", + "agent turns", + "wall time", + "reported tokens when available", + "check and review retries", + ]), + rationale: Object.freeze([ + branchable ? "The item names independently separable work." : null, + highRisk ? "The item touches a high-impact boundary." : null, + implementation && !highRisk ? "The item is bounded implementation with a deterministic gate." : null, + docsOnly ? "The item appears documentation-only." : null, + ].filter(Boolean)), + }); +} + +export function renderOperationalRecommendation(itemRef, recommendation) { + const loopRef = recommendation.loop === "direct" ? "direct" : `loop:builtin:${recommendation.loop}`; + return [ + `Recommendation for ${itemRef} (advisory; user choice remains authoritative)`, + `Loop: ${loopRef}`, + `Model / effort: ${recommendation.modelClass} / ${recommendation.effort}`, + `Review: ${recommendation.review}`, + `Metric: ${recommendation.metric.oven + ? `Oven ${recommendation.metric.oven} — ${recommendation.metric.signal}` + : recommendation.metric.signal}`, + `Sequence: ${recommendation.sequence}`, + `Optimize observed: ${recommendation.optimize.join("; ")}.`, + ].join("\n"); +} diff --git a/src/operations/recommendation.test.mjs b/src/operations/recommendation.test.mjs new file mode 100644 index 00000000..25160a71 --- /dev/null +++ b/src/operations/recommendation.test.mjs @@ -0,0 +1,61 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + recommendOperationalProfile, + renderOperationalRecommendation, + REVIEW_PRIORITIES, +} from "./recommendation.mjs"; + +const item = (title, action, files = "src/main.mjs") => ({ + id: "B1", title, fields: { + Action: action, + "Files/search": files, + "Done/delete when": "The declared outcome passes.", + Validate: "npm test", + }, +}); + +test("recommendations stay advisory and choose the lightest fitting control", () => { + const direct = recommendOperationalProfile(item("Tighten wording", "Clarify the guide.", "README.md")); + assert.equal(direct.loop, "direct"); + assert.equal(direct.modelClass, "fast"); + assert.equal(direct.effort, "low"); + assert.equal(direct.advisory, true); + + const gate = recommendOperationalProfile(item("Fix parser", "Implement the bounded parser fix.")); + assert.equal(gate.loop, "gate"); + assert.equal(gate.modelClass, "standard"); + assert.match(gate.review, /P0-P1 block/u); + + const review = recommendOperationalProfile(item("Harden authorization", "Implement public API permission checks.")); + assert.equal(review.loop, "review"); + assert.equal(review.effort, "xhigh"); + assert.match(review.review, /P0-P2 block/u); +}); + +test("branch and task-fit Oven recommendations require explicit outcome proof", () => { + const recommendation = recommendOperationalProfile(item( + "Build responsive dashboard", + "Implement independent frontend and backend workstreams across the UI.", + "dashboard/src/ src/server/ src/cli/ tests/ ovens/", + )); + assert.equal(recommendation.loop, "branch"); + assert.equal(recommendation.metric.oven, "visual-parity"); + assert.equal(recommendation.metric.visualVerificationRequired, true); + assert.match(recommendation.metric.signal, /User-approved/u); + assert.match(recommendation.sequence, /end-to-end path first/u); + assert.deepEqual(recommendation.optimize, [ + "host-visible commands", "agent turns", "wall time", "reported tokens when available", + "check and review retries", + ]); +}); + +test("P0-P4 handling and human output are concise and closed", () => { + assert.deepEqual(REVIEW_PRIORITIES.map((entry) => entry.priority), ["P0", "P1", "P2", "P3", "P4"]); + const recommendation = recommendOperationalProfile(item("Fix parser", "Implement the parser fix.")); + const output = renderOperationalRecommendation("260725-001#B1", recommendation); + assert.match(output, /advisory; user choice remains authoritative/u); + assert.match(output, /Loop: loop:builtin:gate/u); + assert.match(output, /Optimize observed: host-visible commands; agent turns; wall time/u); + assert.doesNotMatch(output, /\$|cost/u); +}); diff --git a/src/server/burnlist-dashboard-server.mjs b/src/server/burnlist-dashboard-server.mjs index 0a456283..7fd5504b 100644 --- a/src/server/burnlist-dashboard-server.mjs +++ b/src/server/burnlist-dashboard-server.mjs @@ -37,6 +37,10 @@ import "../ovens/built-in-handlers.mjs"; import { getOvenHandler, listOvenHandlers } from "../ovens/oven-registry.mjs"; import { genericJsonHandler } from "../ovens/handlers/generic-json-handler.mjs"; import { presentGraph, readCompletedRunForItem, readLatestRunForItem } from "../loops/run/read-projection.mjs"; +import { + completedItemWorkState, + projectItemWorkState, +} from "../loops/run/item-work-state.mjs"; import { assignmentStore } from "../loops/assignment/store.mjs"; import { buildRepoMapAsync } from "./repo-map.mjs"; import { createOvenJsonSnapshotStore, OVEN_JSON_CACHE_MAX_BYTES } from "./oven-json-snapshot.mjs"; @@ -1057,8 +1061,28 @@ function payloadForPlan(selection, selectedItemId = null) { && artifact.packageRevision === assignment["Package-Revision"]) graph = presentGraph(artifact.frozen.ir); } catch {} } + let run = null; + let diagnostic = null; + if (assignment) { + try { + run = readLatestRunForItem({ + repoRoot: selection.repoRoot, + itemRef: `item:${burnlistId}#${item.id}`, + markdown: plan.markdown, + itemId: item.id, + assignmentId: assignment["Assignment-Id"], + }); + } catch (error) { + diagnostic = error?.code ?? "unavailable"; + } + } return { ...item, + work: projectItemWorkState({ + run, + now: Date.parse(generatedAt), + diagnostic: diagnostic ?? run?.diagnostic ?? null, + }), loop: assignment ? { selector: assignment.Selector, assignmentId: assignment["Assignment-Id"], @@ -1071,6 +1095,7 @@ function payloadForPlan(selection, selectedItemId = null) { completed: plan.completed.map((entry) => ({ ...entry, detail: completedDetails.get(entry.id)?.detail ?? "", + work: completedItemWorkState(), })), history, // The Run journal is deliberately not read here. Progress must remain diff --git a/src/server/run-routes.test.mjs b/src/server/run-routes.test.mjs index 8f89d2f3..dee5ee17 100644 --- a/src/server/run-routes.test.mjs +++ b/src/server/run-routes.test.mjs @@ -36,10 +36,14 @@ test("selected progress remains independent from the sanitized read-only Loop pr assert.equal(first.status, 200); assert.equal(JSON.parse(first.body).loopRun, null); assert.equal(JSON.parse(second.body).loopRun, null); + const itemWork = JSON.parse(first.body).active[0].work; + assert.equal(itemWork.state, "WAITING"); + assert.match(itemWork.reason, /waiting for atomic Burnlist completion/u); + assert.equal(itemWork.provenance.state, "canonical-run-and-claim"); const projection = await httpRequest(baseUrl, `/api/loop-projection?plan=${encodeURIComponent(planPath)}`, { method: "GET" }); assert.equal(projection.status, 200); const left = JSON.parse(projection.body).loopRun; - assert.deepEqual(Object.keys(left), ["schema", "runId", "itemRef", "loopId", "loopRevision", "createdAt", "updatedAt", "state", "currentNode", "attempt", "cycle", "execution", "activity", "forecast", "latestResult", "latestMaker", "latestCheck", "latestReviewer", "revision", "budget", "graph", "transitions"]); + assert.deepEqual(Object.keys(left), ["schema", "runId", "itemRef", "loopId", "loopRevision", "createdAt", "updatedAt", "state", "currentNode", "attempt", "cycle", "hostTask", "execution", "activity", "forecast", "latestResult", "latestMaker", "latestCheck", "latestReviewer", "revision", "budget", "graph", "transitions"]); assert.equal(left.activity.hooks, "unavailable"); assert.equal(left.forecast, null, "terminal nodes do not advertise an agent forecast"); assert.ok(left.activity.records.length <= 10); @@ -53,6 +57,7 @@ test("selected progress remains independent from the sanitized read-only Loop pr assert.ok(left.budget.limits.maxRounds >= 1); assert.equal(left.state, "converged"); assert.equal(left.currentNode, "completed"); + assert.equal(left.hostTask, "not-applicable"); const itemId = fixtureItemRef.split("#")[1]; const scoped = await httpRequest(baseUrl, `/api/loop-projection?plan=${encodeURIComponent(planPath)}&item=${encodeURIComponent(itemId)}`, { method: "GET" }); assert.equal(scoped.status, 200); @@ -88,7 +93,10 @@ test("unassigned selected progress stays unchanged and Run discovery does not cr await withServer({ withBurnlist: true }, async ({ baseUrl, planPath, repoRoot }) => { const response = await httpGet(baseUrl, `/api/progress?plan=${encodeURIComponent(planPath)}`); assert.equal(response.status, 200); - assert.equal(JSON.parse(response.body).loopRun, null); + const payload = JSON.parse(response.body); + assert.equal(payload.loopRun, null); + assert.equal(payload.active[0].work.state, "PENDING"); + assert.match(payload.active[0].work.reason, /checklist position does not imply execution/u); assert.equal(existsSync(`${repoRoot}/.local/burnlist/loop/m2/runs`), false); }); }); @@ -127,6 +135,7 @@ test("loop projection distinguishes missing state from corrupt run storage", { t await withServer({ burnlists: [{ id: "260722-001", title: "Loop route" }], setup }, async ({ baseUrl, planPath }) => { const progress = await httpRequest(baseUrl, `/api/progress?plan=${encodeURIComponent(planPath)}`, { method: "GET" }); assert.equal(progress.status, 200, "progress remains usable when Loop storage is corrupt"); + assert.equal(JSON.parse(progress.body).active[0].work.state, expectedStatus === 200 ? "PENDING" : "BLOCKED"); const response = await httpRequest(baseUrl, `/api/loop-projection?plan=${encodeURIComponent(planPath)}`, { method: "GET" }); assert.equal(response.status, expectedStatus); if (expectedStatus === 200) assert.equal(JSON.parse(response.body).loopRun, null); From 96c5e907c0be3953bfffb4320f7cc85362386579 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sat, 25 Jul 2026 23:54:00 +0200 Subject: [PATCH 30/37] fix: simplify Loop Progress status view (E6) --- .../custom-oven-render.test.mjs | 16 +- .../src/oven/LoopProgress/LoopProgress.css | 232 +++++++++++----- .../oven/LoopProgress/LoopProgress.test.ts | 143 +++++----- .../src/oven/LoopProgress/LoopProgress.tsx | 257 ++++++++---------- ovens/loop-progress/instructions.md | 20 +- scripts/package-paths.json | 4 +- 6 files changed, 370 insertions(+), 302 deletions(-) diff --git a/dashboard/src/components/CustomOvenView/custom-oven-render.test.mjs b/dashboard/src/components/CustomOvenView/custom-oven-render.test.mjs index be8ed176..8a5d8934 100644 --- a/dashboard/src/components/CustomOvenView/custom-oven-render.test.mjs +++ b/dashboard/src/components/CustomOvenView/custom-oven-render.test.mjs @@ -85,15 +85,13 @@ test("custom Oven runtime modes use canonical live snapshots and controlled Burn 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); + 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/oven/LoopProgress/LoopProgress.css b/dashboard/src/oven/LoopProgress/LoopProgress.css index 62551495..1a85a97e 100644 --- a/dashboard/src/oven/LoopProgress/LoopProgress.css +++ b/dashboard/src/oven/LoopProgress/LoopProgress.css @@ -1,96 +1,192 @@ .loop-progress { display: grid; - gap: 14px; + gap: 12px; + max-width: 920px; + margin: 0 auto; padding: 20px; color: var(--foreground, #e8edf5); } -.loop-progress__now, -.loop-progress__context article, -.loop-progress__signals article, +.loop-progress__item, .loop-progress__loop, -.loop-progress__map, -.loop-progress__activity { +.loop-progress__current, +.loop-progress__details { border: 1px solid color-mix(in srgb, currentColor 14%, transparent); border-radius: 12px; background: color-mix(in srgb, var(--card, #10151d) 92%, transparent); } -.loop-progress__provenance { +.loop-progress__item { + display: flex; + align-items: center; + gap: 10px; + padding: 14px 16px; +} +.loop-progress__item > div { display: grid; + flex: 1; min-width: 0; - padding: 0 3px; + gap: 3px; +} +.loop-progress__item span, +.loop-progress h2 { color: var(--muted-foreground, #92a0b3); font-size: 10px; + font-weight: 800; + letter-spacing: .12em; +} +.loop-progress__item strong { + overflow: hidden; + font-size: 19px; + text-overflow: ellipsis; + white-space: nowrap; +} +.loop-progress__state, +.loop-progress__item em { + flex: 0 0 auto; + padding: 5px 8px; + border-radius: 999px; + font-size: 10px; + font-style: normal; + letter-spacing: .06em; +} +.loop-progress__state { + border: 1px solid color-mix(in srgb, #9bc1ff 55%, transparent); + color: #b8d2ff; +} +.loop-progress--active .loop-progress__state { border-color: #67d7a4; color: #9aebc4; } +.loop-progress--blocked .loop-progress__state { border-color: #ff8f8f; color: #ffaaaa; } +.loop-progress--waiting .loop-progress__state { border-color: #e8c36a; color: #f2d88e; } +.loop-progress__item em { background: color-mix(in srgb, #67d7a4 15%, transparent); color: #9aebc4; } +.loop-progress__summary { + margin: 0; + padding: 2px 4px 4px; + font-size: 15px; + line-height: 1.5; +} +.loop-progress__loop, +.loop-progress__current { + min-width: 0; + padding: 14px; +} +.loop-progress h2 { + margin: 0 0 10px; +} +.loop-progress h2 small { + margin-left: 6px; + color: var(--foreground, #e8edf5); + font-size: 11px; + font-weight: 600; + letter-spacing: 0; +} +.loop-progress__direct { + margin: 4px 0 0; + color: var(--muted-foreground, #92a0b3); + font-size: 13px; +} +.loop-progress dl { + display: grid; + margin: 0; +} +.loop-progress dl > div { + display: grid; + grid-template-columns: minmax(120px, .32fr) minmax(0, 1fr); + gap: 14px; + padding: 9px 0; + border-top: 1px solid color-mix(in srgb, currentColor 9%, transparent); +} +.loop-progress dl > div:first-child { border-top: 0; } +.loop-progress dt { + color: var(--muted-foreground, #92a0b3); + font-size: 12px; + font-weight: 700; +} +.loop-progress dt small { + display: block; + margin-top: 2px; + color: #8aa4c4; + font-size: 9px; + font-weight: 600; + letter-spacing: .06em; + text-transform: uppercase; +} +.loop-progress dd { + min-width: 0; + margin: 0; + overflow-wrap: anywhere; + font-size: 13px; line-height: 1.4; - grid-template-columns: max-content minmax(0, 1fr); - gap: 9px; } -.loop-progress__provenance b { color: #a9d1ff; font-size: 9px; letter-spacing: .09em; } -.loop-progress__now { +.loop-progress__current .is-blocker dd { color: #ffb8b8; } +.loop-progress__details { + overflow: hidden; +} +.loop-progress__details > summary { + padding: 11px 14px; + color: var(--muted-foreground, #92a0b3); + cursor: pointer; + font-size: 12px; + font-weight: 700; +} +.loop-progress__details[open] > summary { + border-bottom: 1px solid color-mix(in srgb, currentColor 10%, transparent); +} +.loop-progress__details-body { display: grid; - grid-template-columns: auto minmax(0, 1fr) auto; - align-items: baseline; gap: 14px; - padding: 14px 16px; + padding: 12px 14px 14px; } -.loop-progress__now > span, -.loop-progress__context-head > span, -.loop-progress__context span, -.loop-progress__signals span, -.loop-progress h2 { +.loop-progress__activity h3 { + margin: 0 0 8px; color: var(--muted-foreground, #92a0b3); font-size: 11px; - font-weight: 800; - letter-spacing: .12em; } -.loop-progress__now strong { overflow: hidden; font-size: 20px; text-overflow: ellipsis; white-space: nowrap; } -.loop-progress__context-head { display: flex; align-items: baseline; gap: 10px; padding: 0 3px; } -.loop-progress__context-head strong { font-size: 13px; } -.loop-progress__now small, .loop-progress h2 small { color: var(--muted-foreground, #92a0b3); font-weight: 500; letter-spacing: 0; } -.loop-progress__context { display: grid; grid-template-columns: 2fr 1fr 1fr; gap: 10px; } -.loop-progress__context article { min-width: 0; padding: 12px 14px; } -.loop-progress__context p { margin: 5px 0 0; overflow-wrap: anywhere; font-size: 13px; line-height: 1.35; } -.loop-progress__signals { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; } -.loop-progress__signals article { min-width: 0; padding: 10px 12px; } -.loop-progress__signals p { margin: 5px 0 0; overflow-wrap: anywhere; font-size: 12px; line-height: 1.35; } -.loop-progress__work { display: grid; gap: 12px; } -.loop-progress__loop, .loop-progress__map { min-width: 0; padding: 14px; } -.loop-progress h2 { margin: 0 0 12px; } -.loop-progress__map ol { display: flex; align-items: stretch; margin: 0; padding: 0; list-style: none; } -.loop-progress__map li { display: flex; flex: 1; align-items: center; min-width: 0; color: var(--muted-foreground, #92a0b3); font-size: 12px; } -.loop-progress__map li span { position: relative; display: grid; flex: 1; gap: 4px; min-height: 68px; align-content: center; padding: 10px 6px; border: 1px solid color-mix(in srgb, currentColor 14%, transparent); border-radius: 7px; text-align: center; } -.loop-progress__map li span > strong { color: var(--foreground, #e8edf5); font-size: 12px; } -.loop-progress__map li span > small { font-size: 10px; } -.loop-progress__map li span > em { font-size: 8px; font-style: normal; font-weight: 800; letter-spacing: .08em; } -.loop-progress__map li b { padding: 0 3px; font-weight: 400; } -.loop-progress__map li.is-active span { border-color: #67d7a4; background: color-mix(in srgb, #67d7a4 17%, transparent); color: #b9f5d8; font-weight: 750; } -.loop-progress__map li.is-running span { box-shadow: inset 0 0 0 2px #76a9fa; } -.loop-progress__map li.is-running span > em:first-of-type { color: #9bc1ff; } -.loop-progress__empty { margin: 20px 0; color: var(--muted-foreground, #92a0b3); text-align: center; } -.loop-progress__activity { min-width: 0; padding: 14px; } -.loop-progress__activity ol { display: grid; block-size: 150px; margin: 0; padding: 0; gap: 0; overflow: hidden; list-style: none; } -.loop-progress__activity li { display: grid; min-width: 0; min-height: 15px; grid-template-columns: 12ch minmax(0, 1fr) auto; gap: 8px; color: var(--muted-foreground, #92a0b3); font-size: 11px; line-height: 15px; } -.loop-progress__activity li b { color: #9bc1ff; font-weight: 700; } -.loop-progress__activity li span { overflow: hidden; color: var(--foreground, #e8edf5); text-overflow: ellipsis; white-space: nowrap; } -.loop-progress__activity li small { color: var(--muted-foreground, #92a0b3); } -.loop-progress__activity-empty { display: block !important; color: var(--muted-foreground, #92a0b3); } -.loop-progress__observed { display: grid; min-width: 0; grid-template-columns: 10ch minmax(0, 1fr); gap: 8px; margin-top: 8px; color: var(--muted-foreground, #92a0b3); font-size: 11px; } -.loop-progress__observed b { color: #a9d1ff; font-weight: 700; letter-spacing: .08em; } -.loop-progress__observed span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.loop-progress footer { overflow-wrap: anywhere; color: var(--muted-foreground, #92a0b3); font-size: 11px; line-height: 1.6; } -.loop-progress footer > span { font-weight: 800; letter-spacing: .08em; } -@media (max-width: 900px) { - .loop-progress__context { grid-template-columns: 1fr 1fr; } - .loop-progress__signals { grid-template-columns: 1fr 1fr; } - .loop-progress__work { grid-template-columns: 1fr; } +.loop-progress__activity ol { + display: grid; + block-size: 150px; + margin: 0; + padding: 0; + gap: 0; + overflow: hidden; + list-style: none; +} +.loop-progress__activity li { + display: grid; + min-width: 0; + min-height: 15px; + grid-template-columns: 12ch minmax(0, 1fr) auto; + gap: 8px; + color: var(--muted-foreground, #92a0b3); + font-size: 11px; + line-height: 15px; +} +.loop-progress__activity li b { color: #9bc1ff; } +.loop-progress__activity li span { + overflow: hidden; + color: var(--foreground, #e8edf5); + text-overflow: ellipsis; + white-space: nowrap; +} +.loop-progress__files, +.loop-progress__provenance { + margin: 0; + overflow-wrap: anywhere; + color: var(--muted-foreground, #92a0b3); + font-size: 11px; + line-height: 1.5; +} +.loop-progress__files b { + margin-right: 7px; + color: var(--foreground, #e8edf5); +} +.loop-progress__provenance { + padding-top: 10px; + border-top: 1px solid color-mix(in srgb, currentColor 9%, transparent); } @media (max-width: 620px) { .loop-progress { padding: 12px; } - .loop-progress__now { grid-template-columns: auto 1fr; } - .loop-progress__now small { grid-column: 2; } - .loop-progress__context { grid-template-columns: 1fr; } - .loop-progress__signals { grid-template-columns: 1fr; } - .loop-progress__map ol { display: grid; grid-template-columns: repeat(3, 1fr); gap: 5px; } - .loop-progress__map li b { display: none; } + .loop-progress__item { align-items: flex-start; flex-wrap: wrap; } + .loop-progress__item > div { flex-basis: 100%; } + .loop-progress__item strong { white-space: normal; } + .loop-progress dl > div { grid-template-columns: 1fr; gap: 4px; } .loop-progress__activity li { grid-template-columns: 10ch minmax(0, 1fr); } .loop-progress__activity li small { display: none; } } diff --git a/dashboard/src/oven/LoopProgress/LoopProgress.test.ts b/dashboard/src/oven/LoopProgress/LoopProgress.test.ts index 010b3ab7..4efbde79 100644 --- a/dashboard/src/oven/LoopProgress/LoopProgress.test.ts +++ b/dashboard/src/oven/LoopProgress/LoopProgress.test.ts @@ -35,77 +35,95 @@ function fixture(selectedItemId: string) { hostTask: "claimed", budget: { limits: { maxRounds: 1, maxMinutes: 1, maxAgentRuns: 1, maxCheckRuns: 1, maxTransitions: 1, maxOutputBytes: 1 }, counters: { rounds: 0, agentRuns: 0, checkRuns: 0, transitions: 0, outputBytes: 0 }, - elapsedMilliseconds: 1, journal: { maximum: 1, used: 0, remaining: 1 } }, + elapsedMilliseconds: 0, journal: { maximum: 1, used: 0, remaining: 1 } }, latestResult: null, graph, transitions: [], }, } as any; } -test("shows low-text canonical context and never fabricates hook activity", () => { +test("pending assigned work answers what is happening and hides every empty telemetry section", () => { + const markup = renderToStaticMarkup(createElement(LoopProgress, { data: fixture("H3") })); + assert.match(markup, />H3 · Accept reportsPENDINGASSIGNED LOOP Review Loop { + const data = fixture("O0"); + data.loopRun = null; + const markup = renderToStaticMarkup(createElement(LoopProgress, { data })); + assert.match(markup, /No agent is working on this item yet\. It is waiting to be started as direct work\./u); + assert.match(markup, />ASSIGNED LOOP Direct work { const markup = renderToStaticMarkup(createElement(LoopProgress, { data: fixture("O0") })); - for (const label of ["NOW", "WHY", "SYSTEM", "FILES", "HOOKS"]) assert.match(markup, new RegExp(`>${label}<`, "u")); - for (const label of ["LOOP", "SYSTEM FLOW"]) assert.match(markup, new RegExp(`>${label} `, "u")); - assert.match(markup, />O0 · ReviewObserverACTIVERIGHT NOWCurrent step canonical<\/small><\/dt>
    ReviewProof canonical<\/small><\/dt>
    review waitingMore detailsRun canonical<\/small><\/dt>
    run-1 · running · claim claimedAgent |>Latest activity |>Tokens |>Estimate |>Retries /u); }); -test("selection changes declared context without changing the authoritative Run node", () => { - const markup = renderToStaticMarkup(createElement(LoopProgress, { data: fixture("H3") })); - assert.match(markup, />O0 · ReviewCONTEXTH3 · Accept reportsValidate \+ review<\/strong>/u); - assert.match(markup, /
  • Agent \+ workspace<\/strong>/u); - assert.match(markup, /class="is-active"/u); +test("waiting work explains the next human action without inventing an active agent", () => { + const data = fixture("O0"); + data.loopRun.hostTask = "awaiting-claim"; + const markup = renderToStaticMarkup(createElement(LoopProgress, { data })); + assert.match(markup, />WAITINGCurrent step canonical/u); + assert.doesNotMatch(markup, />Agent |recent activity<\/em>/u); }); -test("technical host-report actions become simple subsystem WHY text", () => { - const data = fixture("H3"); - data.active[1].fields.Action = "Validate an external report against its durable claim, rederive candidate identity, append the result exactly once, and select a declared edge."; +test("blocked work puts the canonical blocker before all secondary detail", () => { + const data = fixture("O0"); + data.loopRun.diagnostic = "stale"; const markup = renderToStaticMarkup(createElement(LoopProgress, { data })); - assert.match(markup, /Keeps each step controlled and verifiable/u); - assert.doesNotMatch(markup, /durable claim|candidate identity|declared edge/u); + assert.match(markup, />BLOCKEDNeeds attention canonical<\/small><\/dt>
    Canonical Run projection is unavailable\./u); + assert.match(markup, />More detailsAgent |>Latest activity |Unavailable/u); }); -test("renders only bounded provenance-labelled sanitized activity", () => { +test("bounded activity and observed agent facts appear only when present", () => { const data = fixture("O0"); data.loopRun.activity = { hooks: "available", records: Array.from({ length: 12 }, (_, index) => ({ at: index, origin: index % 2 ? "host-hook" : "agent-reported", kind: index % 2 ? "subagent-started" : "tool-finished", - nodeId: "review", attempt: 1, provider: index % 2 ? "claude" : "codex", subagentId: index % 2 ? `child-${index}` : undefined, - tool: index % 2 ? undefined : "node-test", observedPath: index % 2 ? `src/subagents/${index}.mjs` : "src/loops/run/binder.mjs", truncated: index === 11, + nodeId: "review", attempt: 1, provider: index % 2 ? "claude" : "codex", model: index === 11 ? "model-x" : undefined, + effort: index === 11 ? "medium" : undefined, subagentId: index % 2 ? `child-${index}` : undefined, + tool: index % 2 ? undefined : "node-test", observedPath: index % 2 ? `src/subagents/${index}.mjs` : "src/loops/run/binder.mjs", + truncated: index === 11, })) }; const markup = renderToStaticMarkup(createElement(LoopProgress, { data })); - assert.match(markup, />ACTIVITY HOOKS<\/span>

    available10 recentCODE CHANGESAgent observed<\/small><\/dt>

    claude · model-x · effort mediumLatest activity observed/u); + assert.match(markup, /Recent observed activity/u); + assert.match(markup, />Changed paths observed/u); assert.match(markup, /src\/loops\/run\/binder\.mjs/u); - assert.doesNotMatch(markup, /FILES<\/span>.*src\/loops\/run\/binder\.mjs/u); + assert.equal((markup.match(/subagent child-/gu) ?? []).length + (markup.match(/node-test/gu) ?? []).length, 11); assert.equal((markup.match(/class="loop-progress__activity"/gu) ?? []).length, 1); - assert.equal((markup.match(/subagent child-/gu) ?? []).length + (markup.match(/node-test/gu) ?? []).length, 10); }); -test("shows observed agent facts, elapsed time, and forecast provenance", () => { +test("timing, reported tokens, and forecasts remain labelled secondary facts", () => { const data = fixture("O0"); data.loopRun.activity = { hooks: "available", records: [{ - at: 1, origin: "host-hook", kind: "agent-started", provider: "codex", - nodeId: "review", attempt: 1, model: "gpt-test", effort: "medium", + at: 91_000, origin: "host-hook", kind: "agent-started", provider: "codex", + nodeId: "review", attempt: 1, model: "gpt-test", effort: "medium", inputTokens: 1_200, outputTokens: 300, }] }; - data.loopRun.budget.elapsedMilliseconds = 125_000; + data.loopRun.createdAt = 1_000; + data.loopRun.budget.elapsedMilliseconds = 2_000; data.loopRun.forecast = { schema: "burnlist-loop-forecast@1", key: { role: "reviewer", provider: "codex", model: "gpt-test", effort: "medium", complexityBand: "high" }, @@ -118,38 +136,15 @@ test("shows observed agent facts, elapsed time, and forecast provenance", () => costProvenance: "unavailable", }; const markup = renderToStaticMarkup(createElement(LoopProgress, { data })); - for (const label of ["STATE", "AGENT", "NODE / BRANCH", "ACTIVITY", "TIME", "TOKENS", "CHECK / GATE / REVIEW", "BLOCKER / RETRIES", "PROVENANCE"]) { - assert.match(markup, new RegExp(`>${label}<`, "u")); - } - assert.match(markup, /codex · gpt-test · effort medium/u); - assert.match(markup, /elapsed 2.1 min · forecast 1 min–3 min/u); - assert.match(markup, /forecast 2,000–9,000/u); - assert.match(markup, /Forecast: medium · 5 local observations/u); + assert.match(markup, />Elapsed observed<\/small><\/dt>
    1\.5 minTokens reported<\/small><\/dt>
    1,500 reportedEstimate forecast<\/small><\/dt>
    1 min–3 min · 2,000–9,000 tokens · medium · 5 local observations { - const data = fixture("O0"); - data.loopRun.createdAt = 1_000; - data.loopRun.budget.elapsedMilliseconds = 2_000; - data.loopRun.activity = { hooks: "available", records: [{ - at: 91_000, origin: "host-hook", kind: "tool-finished", provider: "codex", - nodeId: "review", attempt: 1, - }] }; - const markup = renderToStaticMarkup(createElement(LoopProgress, { data })); - assert.match(markup, />TIME<\/span>

    elapsed 1.5 min · forecast Unavailable<\/p>/u); assert.equal(data.loopRun.budget.elapsedMilliseconds, 2_000); }); -test("a stale canonical projection is blocked, never merely waiting", () => { - const data = fixture("O0"); - data.loopRun.diagnostic = "stale"; - const markup = renderToStaticMarkup(createElement(LoopProgress, { data })); - assert.match(markup, /Run · running · BLOCKED/u); - assert.match(markup, />BLOCKED { +test("activity viewport retains its bounded ten-row height inside details", async () => { const css = await readFile("dashboard/src/oven/LoopProgress/LoopProgress.css", "utf8"); assert.match(css, /\.loop-progress__activity ol \{[^}]*block-size: 150px;[^}]*overflow: hidden;/su); assert.match(css, /\.loop-progress__activity li \{[^}]*min-height: 15px;[^}]*line-height: 15px;/su); diff --git a/dashboard/src/oven/LoopProgress/LoopProgress.tsx b/dashboard/src/oven/LoopProgress/LoopProgress.tsx index 1e9a5430..8b9d4cb1 100644 --- a/dashboard/src/oven/LoopProgress/LoopProgress.tsx +++ b/dashboard/src/oven/LoopProgress/LoopProgress.tsx @@ -1,38 +1,12 @@ import type { ChecklistItem, ChecklistProgressData, LoopRunProjection } from "@lib"; +import { effectiveItemWork } from "@lib/checklist-adapter"; import { LoopCompact } from "@/components/LoopGraph"; import "./LoopProgress.css"; -const architecture = [ - { id: "plan", label: "Plan", detail: "item contract", hint: ["notes/burnlists", "plan-model"], why: "Keeps the work clear and ordered." }, - { id: "loops", label: "Loop control", detail: "claim · route", hint: ["src/loops", ".burnlist/loops", "LoopGraph", "src/cli/loop"], why: "Keeps each step controlled and verifiable." }, - { id: "work", label: "Agent + workspace", detail: "make the change", hint: ["adapters", "workspace", "provider"], why: "Gives the work a bounded place to happen." }, - { id: "proof", label: "Validate + review", detail: "prove · decide", hint: ["validate", "review", "test", "capabilities"], why: "Checks the result before it can move forward." }, - { id: "burn", label: "Burn", detail: "complete item", hint: ["completion", "lifecycle"], why: "Finishes work only after its proof is complete." }, - { id: "observe", label: "Observer", detail: "Oven · events", hint: ["ovens/", "src/ovens", "src/server", "dashboard/src/oven", "src/events", "hooks", "streaming-diff"], why: "Makes truthful progress easy to see." }, -] as const; - function selectedItem(data: ChecklistProgressData) { return data.active.find((item) => item.id === data.selectedItemId) ?? data.active[0] ?? null; } -function subsystem(item: ChecklistItem | null) { - const surface = `${item?.fields["Files/search"] ?? ""} ${item?.title ?? ""}`.toLowerCase(); - let best = architecture[0]; - let score = 0; - for (const candidate of architecture) { - const matches = candidate.hint.filter((hint) => surface.includes(hint.toLowerCase())).length; - if (matches > score) { - best = candidate; - score = matches; - } - } - return best; -} - -function plainWhy(item: ChecklistItem | null, system: (typeof architecture)[number]) { - return item ? system.why : "No active work remains."; -} - function preview(item: ChecklistItem): LoopRunProjection | null { if (!item.loop) return null; return { @@ -60,25 +34,18 @@ function preview(item: ChecklistItem): LoopRunProjection | null { }; } -function displayNode(run: LoopRunProjection | null | undefined) { - if (!run) return "Direct work"; - const node = run.graph.nodes.find((candidate) => candidate.id === run.currentNode); - return node?.role ?? node?.capability ?? run.currentNode; -} - -function runItemId(run: LoopRunProjection) { - const marker = run.itemRef.lastIndexOf("#"); - return marker >= 0 ? run.itemRef.slice(marker + 1) : run.itemRef; +function loopLabel(item: ChecklistItem | null, run: LoopRunProjection | null = null) { + const selector = run?.loopId ?? item?.loop?.selector; + if (!selector) return "Direct work"; + if (selector.endsWith(":review")) return "Review Loop"; + if (selector.endsWith(":gate")) return "Gate Loop"; + if (selector.endsWith(":branch")) return "Branch Loop"; + return selector; } -function runSubsystem(run: LoopRunProjection | null) { - if (!run) return null; +function displayNode(run: LoopRunProjection) { const node = run.graph.nodes.find((candidate) => candidate.id === run.currentNode); - const meaning = `${run.currentNode} ${node?.role ?? ""} ${node?.capability ?? ""}`; - if (node?.kind === "check" || node?.kind === "gate" || /review|verify|validate/u.test(meaning)) return "proof"; - if (node?.kind === "agent") return "work"; - if (node?.kind === "terminal" || /converg|complete|burn/u.test(run.currentNode)) return "burn"; - return "loops"; + return node?.role ?? node?.capability ?? run.currentNode; } function activityText(record: NonNullable["records"][number]) { @@ -95,12 +62,12 @@ function duration(milliseconds: number | null | undefined) { return `${Math.round(milliseconds / 6_000) / 10} min`; } -function durationRange(range: { low: number; high: number } | null | undefined) { - return range ? `${duration(range.low)}–${duration(range.high)}` : "Unavailable"; +function durationRange(range: { low: number; high: number }) { + return `${duration(range.low)}–${duration(range.high)}`; } -function tokenRange(range: { low: number; high: number } | null | undefined) { - return range ? `${range.low.toLocaleString("en-US")}–${range.high.toLocaleString("en-US")}` : "Unavailable"; +function tokenRange(range: { low: number; high: number }) { + return `${range.low.toLocaleString("en-US")}–${range.high.toLocaleString("en-US")}`; } function observedTokens(run: LoopRunProjection | null, records: NonNullable["records"]) { @@ -110,136 +77,144 @@ function observedTokens(run: LoopRunProjection | null, records: NonNullable { const node = run.graph.nodes.find((candidate) => candidate.id === transition.from); if (node?.kind === "check") return [`check ${transition.outcome}`]; if (node?.kind === "gate") return [`gate ${transition.outcome}`]; - if (node?.kind === "agent" && /review/u.test(node.role ?? node.id)) return [`review ${transition.outcome}`]; + if (node?.kind === "agent" && /review/iu.test(node.role ?? node.id)) return [`review ${transition.outcome}`]; return []; }); const active = run.graph.nodes.find((candidate) => candidate.id === run.currentNode); if (active?.kind === "check") labels.push("check running"); if (active?.kind === "gate") labels.push("gate running"); - if (active?.kind === "agent" && /review/u.test(active.role ?? active.id)) labels.push("review waiting"); - return labels.slice(-3).join(" · ") || "No check, gate, or review result yet"; + if (active?.kind === "agent" && /review/iu.test(active.role ?? active.id)) labels.push("review waiting"); + return labels.slice(-3).join(" · ") || null; } -function canonicalState(run: LoopRunProjection | null, item: ChecklistItem | null) { - const work = item?.work; - if (work) return { state: work.state, progressing: work.progressing, reason: work.reason }; - if (!run) return { state: "PENDING", progressing: false, reason: "No canonical Run or claim." }; - if (run.diagnostic || ["needs-human", "failed", "stopped", "budget-exhausted", "corrupt"].includes(run.state)) { - return { state: "BLOCKED", progressing: false, reason: `Run ${run.state}.` }; +function nowMessage( + item: ChecklistItem | null, + state: "PENDING" | "ACTIVE" | "WAITING" | "BLOCKED" | "COMPLETED", + run: LoopRunProjection | null, + progressing: boolean, + reason: string, +) { + if (!item || state === "COMPLETED") return "This item is recorded as complete in the Burnlist."; + if (state === "PENDING") { + return item.loop + ? `No agent is working on this item yet. Its ${loopLabel(item)} is assigned and ready to start.` + : "No agent is working on this item yet. It is waiting to be started as direct work."; } - if (run.state === "running" && (run.hostTask === "claimed" || run.hostTask === "not-applicable")) { - return { state: "ACTIVE", progressing: false, reason: "Canonical Run is active." }; + const step = run ? displayNode(run) : item.work?.run?.node ?? "next step"; + if (state === "ACTIVE") return progressing + ? `Work is active at ${step}, with recent observed activity.` + : `Work is active at ${step}. No recent activity is available.`; + if (state === "WAITING") { + if (run?.hostTask === "awaiting-claim") return `${step} is ready and waiting for an agent to claim it.`; + if (run?.state === "converged") return "The Loop has converged and is waiting for the item to be burned."; + return `${step} is waiting for the next action.`; } - return { state: "WAITING", progressing: false, reason: `Run ${run.state}.` }; + return `Work is blocked: ${run?.latestResult?.summary ?? reason}`; } export function LoopProgress({ data }: { data: ChecklistProgressData }) { const item = selectedItem(data); - const system = subsystem(item); const authoritativeRun = data.loopRun ?? null; - const runItem = authoritativeRun - ? data.active.find((candidate) => authoritativeRun.itemRef.endsWith(`#${candidate.id}`)) ?? item - : item; - const workState = canonicalState(authoritativeRun, runItem); - const itemRun = item && authoritativeRun?.itemRef.endsWith(`#${item.id}`) ? authoritativeRun : item ? preview(item) : null; + const selectedRun = item && authoritativeRun?.itemRef.endsWith(`#${item.id}`) ? authoritativeRun : null; + const workState = item ? effectiveItemWork(data, item) : null; + const state = workState?.state ?? "COMPLETED"; + const itemRun = selectedRun ?? (item ? preview(item) : null); const files = item?.fields["Files/search"] ?? "No declared file surface"; - const runningSystem = runSubsystem(authoritativeRun); - const activity = authoritativeRun?.activity; - const recentActivity = activity?.records.slice(-10) ?? []; + const recentActivity = selectedRun?.activity?.records.slice(-10) ?? []; const observedPaths = [...new Set(recentActivity.flatMap((record) => [ ...(record.observedPath ? [record.observedPath] : []), ...(record.observedPaths ?? []), ]))]; const observation = [...recentActivity].reverse().find((record) => record.provider || record.model || record.effort); - const observedAgent = observation - ? [observation.provider, observation.model, observation.effort ? `effort ${observation.effort}` : null] - .filter(Boolean).join(" · ") - : "Unavailable"; - const forecast = authoritativeRun?.forecast; - const provenance = forecast + const telemetry = selectedRun?.execution?.telemetry; + const effort = observation?.effort ?? telemetry?.effort; + const observedAgent = observation || telemetry + ? [observation?.provider ?? telemetry?.provider, observation?.model ?? telemetry?.model, + effort ? `effort ${effort}` : null].filter(Boolean).join(" · ") + : null; + const forecast = selectedRun?.forecast; + const forecastProvenance = forecast ? forecast.provenance.kind === "local-observations" ? `${forecast.confidence} · ${forecast.provenance.matchingObservations} local observations` : `${forecast.confidence} · built-in prior` - : "Unavailable"; - const observedElapsed = authoritativeRun + : null; + const observedElapsed = selectedRun ? Math.max( - authoritativeRun.budget.elapsedMilliseconds, - ...recentActivity.map((record) => Math.max(0, record.at - authoritativeRun.createdAt)), + selectedRun.budget.elapsedMilliseconds, + ...recentActivity.map((record) => Math.max(0, record.at - selectedRun.createdAt)), ) : null; - const activeNode = authoritativeRun?.graph.nodes.find((node) => node.id === authoritativeRun.currentNode); - const branch = authoritativeRun?.loopId.endsWith(":branch") || authoritativeRun?.loopId === "branch" - ? authoritativeRun.currentNode : "not branched"; - const activityStatus = workState.state === "ACTIVE" - ? workState.progressing ? "progressing" : "active · no recent hook" - : workState.state.toLowerCase(); - const blocker = workState.state === "BLOCKED" - ? authoritativeRun?.latestResult?.summary ?? workState.reason - : "None canonical"; - const retries = authoritativeRun - ? `attempt ${authoritativeRun.attempt || 1} · cycle ${authoritativeRun.cycle || 0}` - : "Unavailable"; - return

    -
    - NOW - {authoritativeRun ? `${runItemId(authoritativeRun)} · ${displayNode(authoritativeRun)}` : item ? `${item.id} · ${item.title}` : "Complete"} - {authoritativeRun ? `Run · ${authoritativeRun.state} · ${workState.state}` : `${workState.state} · canonical checklist`} -
    + const proof = proofSignals(selectedRun); + const tokens = observedTokens(selectedRun, recentActivity); + const currentStep = selectedRun ? displayNode(selectedRun) : workState?.run?.node ?? null; + const latestActivity = recentActivity.at(-1) ?? null; + const blocker = state === "BLOCKED" + ? selectedRun?.latestResult?.summary ?? workState?.reason ?? "Human action is required." + : null; + const retries = selectedRun && (selectedRun.attempt > 1 || selectedRun.cycle > 0) + ? `attempt ${selectedRun.attempt || 1} · repair cycle ${selectedRun.cycle || 0}` + : null; + const hasDetails = state !== "PENDING" && Boolean(item); -
    CONTEXT{item ? `${item.id} · ${item.title}` : "None"}
    -
    -
    WHY

    {plainWhy(item, system)}

    -
    SYSTEM

    {system.label}

    -
    HOOKS

    {activity?.hooks ?? "Unavailable"}

    -
    -
    -
    STATE

    {workState.state}{workState.progressing ? " · progressing" : ""}

    -
    AGENT

    {observedAgent}

    -
    NODE / BRANCH

    {activeNode?.role ?? activeNode?.capability ?? authoritativeRun?.currentNode ?? "Unavailable"} · {branch}

    -
    ACTIVITY

    {activityStatus} · hooks {activity?.hooks ?? "unavailable"}

    -
    TIME

    elapsed {duration(observedElapsed)} · forecast {durationRange(forecast?.wallTime)}

    -
    TOKENS

    {observedTokens(authoritativeRun, recentActivity)} · forecast {tokenRange(forecast?.totalTokens)}

    -
    CHECK / GATE / REVIEW

    {proofSignals(authoritativeRun)}

    -
    BLOCKER / RETRIES

    {blocker} · {retries}

    -
    -
    - PROVENANCE - State, node, claim, checks, gates, and reviews: canonical Run. Activity, agent facts, paths, timing, and reported tokens: bounded observation only. Forecast: {provenance}. -
    - -
    -
    -

    SYSTEM FLOW whole Burnlist

    -
      - {architecture.map((part, index) =>
    1. - {part.label}{part.detail}{part.id === runningSystem && NOW}{part.id === system.id && CONTEXT}{index < architecture.length - 1 && } -
    2. )} -
    -
    -
    -

    LOOP {item?.loop?.selector ?? "direct"}

    - {itemRun ? - :

    No Loop assigned

    } -
    -
    -
    -

    ACTIVITY {activity ? `${recentActivity.length} recent` : "unavailable"}

    -
      {recentActivity.length ? recentActivity.slice().reverse().map((record, index) =>
    1. - {record.origin}{activityText(record)}{record.provider ? {record.provider} : null}{record.truncated ? truncated : null} -
    2. ) :
    3. No observed hook activity. Runner state remains canonical.
    4. }
    -
    CODE CHANGES{observedPaths.length ? observedPaths.join(" · ") : "Unavailable · observational only"}
    + return
    +
    +
    ITEM{item ? `${item.id} · ${item.title}` : "No active item"}
    + {state} + {workState?.progressing ? recent activity : null} +
    +

    + {nowMessage(item, state, selectedRun, workState?.progressing ?? false, workState?.reason ?? "")} +

    + +
    +

    ASSIGNED LOOP {loopLabel(item, selectedRun)}

    + {itemRun + ? + :

    This item uses direct work; no Loop is assigned.

    }
    -
    FILES {files}
    Selected · {item ? `${item.id} ${item.title}` : "none"}{authoritativeRun && item && !authoritativeRun.itemRef.endsWith(`#${item.id}`) ? " · Run remains authoritative for another item" : ""}
    + + {state !== "PENDING" && (currentStep || observedAgent || latestActivity || proof || blocker) ?
    +

    RIGHT NOW

    +
    + {currentStep ?
    Current step canonical
    {currentStep}
    : null} + {blocker ?
    Needs attention canonical
    {blocker}
    : null} + {proof ?
    Proof canonical
    {proof}
    : null} + {observedAgent ?
    Agent observed
    {observedAgent}
    : null} + {latestActivity ?
    Latest activity observed
    {activityText(latestActivity)}
    : null} +
    +
    : null} + + {hasDetails ?
    + More details +
    + {selectedRun ?
    +
    Run canonical
    {selectedRun.runId} · {selectedRun.state} · claim {selectedRun.hostTask ?? "unavailable"}
    + {retries ?
    Retries canonical
    {retries}
    : null} + {observedElapsed && observedElapsed > 0 ?
    Elapsed observed
    {duration(observedElapsed)}
    : null} + {tokens ?
    Tokens reported
    {tokens}
    : null} + {forecast ?
    Estimate forecast
    {durationRange(forecast.wallTime)} · {tokenRange(forecast.totalTokens)} tokens · {forecastProvenance}
    : null} + {observedPaths.length ?
    Changed paths observed
    {observedPaths.join(" · ")}
    : null} +
    : null} + {recentActivity.length ?
    +

    Recent observed activity

    +
      {recentActivity.slice().reverse().map((record, index) =>
    1. + {record.origin}{activityText(record)}{record.truncated ? truncated : null} +
    2. )}
    +
    : null} +

    Declared files {files}

    +

    Status, current step, proof, blocker, and retries come from the canonical Burnlist Run. Agent, activity, paths, elapsed time, and reported tokens are bounded observations. Estimates are forecasts.

    +
    +
    : null}
    ; } diff --git a/ovens/loop-progress/instructions.md b/ovens/loop-progress/instructions.md index 57e9da54..cc9d338c 100644 --- a/ovens/loop-progress/instructions.md +++ b/ovens/loop-progress/instructions.md @@ -1,10 +1,14 @@ # Loop Progress `loop-progress.oven` is a compact, read-only lens over canonical Burnlist item -and Loop Run data plus bounded correlated observations. It answers what is -happening, why it matters, which subsystem and declared files are in scope, -where that work sits in the whole Burnlist architecture, and whether it is -progressing. +and Loop Run data plus bounded correlated observations. Its primary question +is: **what is happening to this item right now?** + +Pending items show only identity, canonical status, the compact assigned Loop, +and a plain explanation that no agent is working yet. Active, waiting, and +blocked items progressively reveal populated current-step, blocker, proof, +agent, and activity facts. Timing, tokens, paths, Run identity, and provenance +stay in one collapsed details region. ## Data Shape @@ -20,11 +24,11 @@ progressing. budgets, blockers, and retries. - Observational sources: bounded invocation-correlated hooks may expose active agent/model/effort, tool activity, contained code paths, timing, and reported - tokens. Missing values remain `Unavailable`. + tokens. Missing facts are omitted rather than rendered as empty telemetry. - Forecast sources: bounded built-in priors or matching local observations, always labelled with confidence and provenance. -The Oven is declarative and read-only. Selecting an item changes its declared -context and assigned Loop preview; it does not change the authoritative Run or -its active node. Observations and forecasts never satisfy a gate or choose an +The Oven is declarative and read-only. Selecting an item shows only that +item's matching canonical Run; another item's Run cannot make the selection +look active. Observations and forecasts never satisfy a gate or choose an outcome. diff --git a/scripts/package-paths.json b/scripts/package-paths.json index 455828e7..2400680c 100644 --- a/scripts/package-paths.json +++ b/scripts/package-paths.json @@ -2,8 +2,8 @@ "LICENSE", "README.md", "bin/burnlist.mjs", - "dashboard/dist/assets/index-BSVsW5o6.js", - "dashboard/dist/assets/index-CBt8Jk6v.css", + "dashboard/dist/assets/index-Dcogl3ZJ.js", + "dashboard/dist/assets/index-yg-h1tMX.css", "dashboard/dist/favicon.svg", "dashboard/dist/index.html", "loops/branch/branch.loop", From feb96565261986c9c3dc4d377eb35111bdbfacbe Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sun, 26 Jul 2026 00:24:26 +0200 Subject: [PATCH 31/37] docs: publish operational UX guidance (E6) --- website/scripts/skill-content.mjs | 57 ++- website/src/content/docs/cli.mdx | 44 ++- website/src/content/docs/dashboard.mdx | 52 ++- website/src/content/docs/getting-started.mdx | 28 +- website/src/content/docs/install.mdx | 32 +- website/src/content/docs/lifecycle.mdx | 21 +- website/src/content/docs/loops.mdx | 335 +++++++----------- website/src/content/docs/ovens.mdx | 11 + website/src/content/docs/ovens/authoring.mdx | 7 + website/src/content/docs/ovens/checklist.mdx | 4 + .../content/docs/ovens/designing-ovens.mdx | 29 ++ .../src/content/docs/ovens/dsl-reference.mdx | 14 +- .../src/content/docs/ovens/streaming-diff.mdx | 10 +- .../src/content/docs/ovens/visual-parity.mdx | 6 + website/src/pages/index.astro | 10 +- 15 files changed, 421 insertions(+), 239 deletions(-) diff --git a/website/scripts/skill-content.mjs b/website/scripts/skill-content.mjs index de660750..2046038b 100644 --- a/website/scripts/skill-content.mjs +++ b/website/scripts/skill-content.mjs @@ -2,10 +2,10 @@ * Builds the content of skill.md — a single, self-contained Burnlist skill * document served at the site root (https://burnlist.dev/skill.md) so an * agent pointed at that URL has everything it needs: what Burnlist is, how - * to install it, the full CLI surface, the lifecycle, the six ovens, and a + * to install it, the full CLI surface, the lifecycle, the seven ovens, and a * listing of every doc page. Kept accurate to the real CLI help output and * the docs it is generated alongside; do not describe behavior the CLI does - * not have (no loop feature, no componentization, no work execution). + * not have (no executable Oven content or Burnlist-owned provider execution). */ // Fallback used only if the sibling CLI can't be spawned at build time (see @@ -24,13 +24,30 @@ Usage: burnlist differential-testing sdk burnlist streaming-diff ... burnlist hooks [install|uninstall|status] [--agent codex,claude] [--untracked] (bare defaults to status) - burnlist oven ... + 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 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] @@ -59,7 +76,7 @@ export function buildSkillMarkdown({ documents, siteUrl, cliHelp = FALLBACK_CLI_ return `# Burnlist skill -> Point an agent at ${siteUrl}/skill.md for a complete, self-contained Burnlist skill: what it is, how to install it, its full CLI surface, its lifecycle, and its six ovens. +> Point an agent at ${siteUrl}/skill.md for a complete, self-contained Burnlist skill: what it is, how to install it, its full CLI surface, its lifecycle, and its seven ovens. ## What Burnlist is @@ -73,6 +90,23 @@ Burnlist owns task state — not implementation, testing, or delivery. **It does - **Oven** — a read-only dashboard view that renders honest signals over your own data (progress, metrics, diffs) so you see truth, not vibes. It answers "how are we actually doing?" - **Lane** — a split of one large Burnlist into parallel sub-lists so independent tracks burn down side by side. Reach for it when a plan has genuinely independent work streams. +## Operational contract + +Burnlist is soft orchestration: it coordinates task state, optional Loop control, proof, and observation while the repository owns architecture, implementation, tests, deployment, and proof authority. Unless the repository or user chose another order, establish the thinnest real end-to-end path, prove it, then refine internals and edges. + +\`\`\`sh +burnlist recommend # +burnlist recommend # --json +\`\`\` + +\`recommend\` deterministically suggests the lightest fitting direct/gate/review/branch control, provider-neutral model class and effort, P0–P4 handling, task-fit metric, work sequence, next command, and canonical dashboard URL. It is advisory: the user may accept, change, or ignore it. Burnlist never installs, authenticates, selects, or launches a provider. + +For an assigned item, ordinary host execution is \`loop next\` → execute the returned prompt → \`loop submit\` by RunRef. \`claim\`, \`report\`, \`abandon\`, and \`reconcile\` are lower-level recovery and diagnostics, not the normal flow. + +Checklist and Loop Progress derive exactly five labels from canonical state: \`PENDING\` has no Run or claim and queue position does not imply execution; \`ACTIVE\` has a deterministic node or live claim; \`WAITING\` awaits a host task, resume, or atomic completion; \`BLOCKED\` needs human action or cannot project safely; \`COMPLETED\` is in the canonical ledger. Hooks may refine an already ACTIVE item to **progressing**, but never create state, select an outcome, satisfy a gate, or complete work. + +Review policy is closed: P0 stops and escalates; P1 rejects; material P2 rejects; P3 is a bounded non-blocking follow-up; P4 is an optional note. Approval cannot leave P0, P1, or material P2 findings open. + ## Install Install and hooks are independent steps — installing one does not install the other. @@ -93,7 +127,7 @@ burnlist install [--global] [--commit] [--force] [--agent codex,claude] [--dry-r Without \`--global\`, this registers the skill for the current repository only (an untracked local link by default, or a portable copy for git with \`--commit\`). With \`--global\`, it (re)registers the skill in the user's home directory, same as the npm postinstall step. \`--agent codex,claude\` restricts which agent(s) get the skill; \`--dry-run\` prints the plan without writing. -### 3. Install Streaming Diff hooks (optional, separate feature) +### 3. Install observability hooks (optional, separate feature) \`\`\`sh burnlist hooks install --agent codex,claude @@ -101,7 +135,7 @@ burnlist hooks status burnlist hooks uninstall --agent codex,claude \`\`\` -Merges local Streaming Diff commands into \`.codex/hooks.json\` and/or \`.claude/settings.json\`, preserving existing entries. This is unrelated to the skill install above; installing the skill does not install hooks, and installing hooks does not install the skill. +Merges local Streaming Diff capture and bounded native observation commands into \`.codex/hooks.json\` and/or \`.claude/settings.json\`, preserving unrelated entries. Hook facts are observational only. This is unrelated to the skill install above; installing the skill does not install hooks, and installing hooks does not install the skill. ### Uninstall @@ -123,6 +157,10 @@ An Oven makes progress objective so an agent cannot fool itself — the antidote The built-in ovens embody this: Differential Testing measures byte-identical goldens, Visual Parity measures pixel diffs, Streaming Diff captures real pre-to-post diffs, and Performance Tracing measures real timings against a budget — never self-assessment. Map your signals onto the view vocabulary — headline numbers to a kpi-strip, the event stream to a log-table, the burn-down to a progress-donut — and compute the real values in a project-owned data adapter that emits one read-only JSON document the Oven binds to. If a number can be typed by hand without doing the work, it is not evidence. +Choose a task-fit metric: Visual Parity for UI/rendering, Differential Testing for reference/candidate or migration work, Performance Tracing for measured budgets, Model Lab for model/geometry contracts, and the trusted repository check alone when no Oven adds value. Do not add an Oven merely to create a number. + +After creating, adopting, or changing an Oven used as proof, publish real or explicit fixture data, open its canonical dashboard URL, inspect desktop and narrow layouts in a browser, exercise relevant empty/waiting/blocked/stale/error/completed states, and record the user-approved signal in the item's proof contract. Compilation or DOM tests alone are not visual proof. + ### The adapter: compute honest numbers The adapter is a project-owned script that reads reality, computes the numbers, and writes the bound JSON. Hand-editing that JSON defeats the point: a number you typed proves nothing. @@ -186,18 +224,19 @@ A Burnlist's state is its location. The whole folder moves through four lifecycl Folders: \`notes/burnlists/{draft,ready,inprogress,completed}//\`. \`goal.md\` is the stable contract (Goal, Guardrails, Proof Authority, Ordering Intent, Stop Conditions, Handoff); \`burnlist.md\` is the hot, shrinking task state (an ordered Active Checklist and a terse Completed ledger); \`completed.md\` is optional durable per-burn history for humans. Run \`burnlist --plan --check\` to validate and \`burnlist --stamp\` to generate the mechanical timestamp used in a completion ledger line. -## The six ovens +## The seven ovens -An Oven is a named, declarative, non-executable recipe for a Burn — data, never code. Six built-in, read-only ovens ship with Burnlist: +An Oven is a named, declarative, non-executable recipe for a Burn — data, never code. Seven built-in, read-only ovens ship with Burnlist: - **Checklist** tracks the active work queue and progress. - **Differential Testing** provides aligned reference-versus-candidate series, optional aggregate telemetry, and exact-first evidence. +- **Loop Progress** answers what is happening to one item now with truthful progressive disclosure. - **Model Lab** inspects one prepared model and its topology, publication, and provenance evidence. - **Streaming Diff** surfaces recently published, session-scoped pre-to-post diff cards read from a local feed. - **Performance Tracing** renders retained browser-output timing evidence — frame pacing, budget checks, and slow steps — from a project-owned trace run. - **Visual Parity** compares trusted reference and candidate frames as isolated render passes, gating each render domain on calibrated channel, mean-delta, and changed-pixel bounds. -Author and inspect ovens with \`burnlist oven \`. Custom ovens live in ignored, repo-scoped \`.local/burnlist/ovens/\` state. +Author and inspect ovens with \`burnlist oven \`. Custom ovens live in ignored, repo-scoped \`.local/burnlist/ovens/\` state. \`burnlist oven use \` adopts a shipped Oven and installs data only when an exact shipped \`example/data.json\` already exists and passes validation. Otherwise it adopts only, creates no data or binding, and prints the exact \`oven set\` next step; it never generates a payload from a schema, fixture, or instructions. \`burnlist oven set \` validates before mutation and atomically publishes \`.local/burnlist/data/.json\` plus its binding. Built-ins use the same runtime validator as their render handler. Custom Ovens without one check declared pointers and print a \`shape-only\` warning: shape is not truth. A rejected fresh set writes nothing; a rejected replacement preserves the exact prior data and binding. JSON Schemas are informational references only and never enter the Oven package, revision, or pin. diff --git a/website/src/content/docs/cli.mdx b/website/src/content/docs/cli.mdx index 43165bc0..bf689c1c 100644 --- a/website/src/content/docs/cli.mdx +++ b/website/src/content/docs/cli.mdx @@ -37,6 +37,28 @@ These commands act on the current repository by default, or on an explicit `--re | `burnlist burn [--check] [--repo ]` | Complete a Burnlist item; `--check` validates it. | | `burnlist close [--repo ]` | Close a completed Burnlist. | +Lifecycle commands print the next useful command and the canonical local +dashboard URL when one is available. `show #` also prints the stable +item deep link. + +## Operational recommendation + +```sh +burnlist recommend # [--json] [--repo ] +``` + +`recommend` reads one item from the Active Checklist and deterministically +suggests the lightest fitting control. It reports direct work or a `gate`, +`review`, or `branch` Loop; a provider-neutral model class and effort; P0–P4 +review handling; a task-fit metric or Oven signal; a coarse-to-fine sequence; +and observable costs worth optimizing. + +The result is advisory: the user may accept, change, or ignore it. Burnlist +does not select, install, authenticate, or launch a model provider. Human +output ends with an actionable `Next:` command plus the canonical +`Dashboard:` URL. `--json` emits +`burnlist-operational-recommendation@1` for tooling. + ## Loops ```sh @@ -44,10 +66,13 @@ burnlist loop assign [--repo ] burnlist loop unassign [--repo ] burnlist loop view [--repo ] burnlist loop create [--repo ] -burnlist loop next|claim [--repo ] -burnlist loop report --result [--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 pause|stop|complete [--repo ] +burnlist loop pause|stop [--repo ] # idle Run only +burnlist loop complete [--repo ] burnlist loop list [--repo ] burnlist loop status|inspect [--repo ] burnlist loop reconcile --recovery-proof [--repo ] @@ -60,10 +85,11 @@ An item either runs directly with no assignment or uses one of exactly three built-ins: `loop:builtin:review`, `loop:builtin:gate`, or `loop:builtin:branch`. Branch fan-out is orchestrated by the current host; Burnlist does not schedule branch workers. Agent providers are invoked by the -host, never by Burnlist. `loop view` is deterministic ASCII graph output; -`claim` and `report` cross the provider boundary, and reporting advances -trusted checks and gates. Only `loop complete` applies a converged Run to the -Burnlist. See [Loops](/loops). +host, never by Burnlist. In the normal flow, `next` returns a small prepared +host task and `submit` returns its semantic outcome by RunRef; Burnlist then +advances trusted checks and gates. `claim`, `report`, `abandon`, and +`reconcile` expose lower-level recovery and diagnostics. Only `loop complete` +applies a converged Run to the Burnlist. See [Loops](/loops). ## Registry @@ -79,7 +105,7 @@ burnlist init [path] [--track] ## Ovens ```sh -burnlist oven ... +burnlist oven ... ``` Author and inspect Ovens. Ovens carry an `id@version` identity and can be vendored and pinned per project with `adopt` (committed under `.burnlist/ovens//`) and opt-in `upgrade`. @@ -117,6 +143,8 @@ burnlist hooks [--agent codex,claude] [--untracked] ``` Independent of Skills above. See [Install](/install). +Hooks publish bounded observations only; they cannot advance a Loop or change +canonical item state. ## Differential Testing diff --git a/website/src/content/docs/dashboard.mdx b/website/src/content/docs/dashboard.mdx index ef9cc8dd..73540dce 100644 --- a/website/src/content/docs/dashboard.mdx +++ b/website/src/content/docs/dashboard.mdx @@ -30,6 +30,28 @@ burnlist --scan-root /path/to/repo,/path/to/another-repo The dashboard parses top metadata, `Goal: ./goal.md`, `## Active Checklist`, and the terse `## Completed` ledger. The completed ledger is the only completion source of truth; Tasks KPI equals completed plus remaining. Do not add chart metadata, telemetry, or progress fields to a Burnlist. +## Truthful item state + +Checklist and Loop Progress use five labels derived from canonical Burnlist +and Run state: + +| State | Meaning | +| --- | --- | +| `PENDING` | No canonical Run or claim exists. Queue position does not imply execution. | +| `ACTIVE` | A canonical Run is executing a deterministic node or has a live host claim. | +| `WAITING` | A Run awaits a host task, resume, or atomic Burnlist completion. | +| `BLOCKED` | Canonical Run state needs human action, failed, stopped, exhausted its budget, or cannot be projected safely. | +| `COMPLETED` | The canonical completed ledger records the item. | + +For PENDING work, Loop Progress keeps the primary view to identity, status, +the assigned Loop or direct-work label, and a plain no-agent message. ACTIVE, +WAITING, and BLOCKED progressively reveal only populated, decision-useful +facts; timing, usage, paths, and provenance stay in secondary details. + +Recent invocation-correlated hooks may refine `ACTIVE` to **progressing**. +They never create ACTIVE state, choose a Loop outcome, satisfy a gate, or +complete an item. + ## The project registry The dashboard observes Burnlists across a machine-local registry of repository roots (`~/.burnlist/roots.json`) unioned with the current repository, so one dashboard can cover every registered project. Registration is always explicit: the CLI is the only writer, and nothing auto-registers. @@ -51,7 +73,20 @@ Run Burn derives the effective Oven from the selected target repository and Oven Dashboard URLs keep the repository key in the path. A `repoKey` is 12 hexadecimal characters. The index is `/`; a Burnlist detail is `/r//`; and a Burnlist through an Oven lens is `/r///o/`. A repo-scoped Oven is `/r//o/`. -A Burnlist detail has a lens-switcher that includes only Ovens whose data contract fits its `checklist-progress@1` contract. Its links open the Burnlist-scoped Oven URL, the active lens is highlighted, and `checklist` is the default lens. For example, the checklist lens is `/r///o/checklist`. +A Burnlist detail has a lens-switcher that includes only Ovens whose data +contract fits its `checklist-progress@1` contract. Its links open the +Burnlist-scoped Oven URL, the active lens is highlighted, and `checklist` is +the default lens. The canonical lenses are: + +```text +/r///o/checklist +/r///o/loop-progress +``` + +`burnlist new`, `show`, `recommend`, lifecycle moves, Loop assignment, and +relevant Oven commands print an actionable next command and canonical +dashboard URL. Use those paths in handoffs instead of reconstructing routes. +An item deep link appends `#` to its Burnlist URL. The Oven catalog is `/ovens`. Its official section comes only from `/api/oven-catalog` and shows the catalog revision, manifest order, producer, input contract, render contract, route kind, maturity, and runtime compatibility. A separate repository-inventory section comes from `/api/ovens` and labels locally available vendored and custom Ovens by origin and `repoKey`. Local availability does not create official membership. @@ -69,6 +104,21 @@ burnlist oven set An Oven explainer lives at `/ovens/`. Repository-local explainers use `/ovens/?repoKey=`, preserve `official`, `vendored`, or `custom` origin, and link to `/r//o/`. A vendored definition receives the official sample only when its content revision matches the current official package. Open the scoped Oven URL to see the live data-bound view. +## Observation is not canonical state + +| Source | What it may report | What it cannot decide | +| --- | --- | --- | +| Burnlist files and Loop Run | lifecycle, queue/ledger, node, claim, transitions, checks, review, budgets, blockers, retries | — | +| Native hooks | correlated agent/model/effort, tools, paths, timing, and available reported usage | state, outcomes, gates, or completion | +| Oven events | bounded publication/progress notices used to wake a consumer and refetch canonical data | snapshot contents, proof, or project truth | + +The replayable `GET /api/events` endpoint serves bounded JSON by default and +Server-Sent Events with `?stream=1` or an SSE `Accept` header. Filter by +`repoKey` and `ovenId`; resume from the opaque cursor. Events are +observational invalidations, not a second state store. Consumers must reopen +canonical data before making a decision. Missing observations are omitted or +labelled unavailable, never inferred from silence. + ## Local state Dashboard observer state, custom Ovens, and Run snapshots live under `.local/burnlist/`. Keep `notes/burnlists/` and `.local/burnlist/` ignored unless you deliberately want to share task state. diff --git a/website/src/content/docs/getting-started.mdx b/website/src/content/docs/getting-started.mdx index 8d76f4da..a13687c2 100644 --- a/website/src/content/docs/getting-started.mdx +++ b/website/src/content/docs/getting-started.mdx @@ -15,6 +15,23 @@ Burnlist has zero runtime dependencies: it uses pure Node built-ins and ES modul To design an Oven that measures honest evidence, see [Designing Ovens](/ovens/designing-ovens). +## Soft orchestration, end to end first + +Burnlist coordinates task state, optional [Loops](/loops), proof, and +observation without taking over the project. The repository still owns its +architecture, implementation strategy, tests, domain tools, deployment, and +proof authority. + +Unless the repository or user chose another order, work coarse to fine: + +1. Establish the thinnest path that reaches the real end-to-end outcome. +2. Prove that path with the declared repository check and, when useful, a + user-approved Oven signal. +3. Refine internal layers, edge cases, performance, and polish without + replacing the working vertical slice. + +This is a default execution shape, not a command to restructure the project. + ## Explore the CLI Install the CLI, initialize the repository, then create a burnlist. Use the built-in help to see every available command. @@ -26,6 +43,11 @@ burnlist --help burnlist new ``` +For an item still in the Active Checklist, +`burnlist recommend #` prints an optional control profile and the +next command; see the +[CLI reference](/cli#operational-recommendation). + The core workflow moves a burnlist item through its lifecycle: `new` → `ready` → `start` → `burn` → `close` @@ -36,4 +58,8 @@ You can also launch the dashboard server with `burnlist`, create a workspace wit ## Coming next -Upcoming sections cover the [CLI](/cli), [Ovens](/ovens), and the [Dashboard](/dashboard). Burnlist also includes `burnlist oven `, `burnlist differential-testing ...`, and `burnlist streaming-diff ...` commands. +Continue with the [CLI](/cli), [Loops](/loops), [Ovens](/ovens), and the +[Dashboard](/dashboard). Burnlist also includes +`burnlist oven `, +`burnlist differential-testing ...`, and `burnlist streaming-diff ...` +commands. diff --git a/website/src/content/docs/install.mdx b/website/src/content/docs/install.mdx index abac8f17..e97b66ed 100644 --- a/website/src/content/docs/install.mdx +++ b/website/src/content/docs/install.mdx @@ -5,7 +5,11 @@ description: Install the Burnlist CLI, the agent skill, and the Streaming Diff h Burnlist requires Node.js 18 or newer. It has zero runtime dependencies: it uses pure Node built-ins and ES modules. -Burnlist has two independent, optional integrations on top of the CLI: the **skill**, which teaches an agent to create and execute Burnlists, and the **hooks**, which capture edit activity for Streaming Diff. Installing one does not install, require, or remove the other — install one, the other, or both. +Burnlist has two independent, optional integrations on top of the CLI: the +**skill**, which teaches an agent to create and execute Burnlists, and the +**hooks**, which capture edit activity for Streaming Diff plus bounded native +agent observations. Installing one does not install, require, or remove the +other—install one, the other, or both. ## Install the CLI @@ -39,9 +43,10 @@ Two ways to get the skill in front of an agent: Ask your agent to create a Burnlist for a goal or continue an existing one. The skill owns that workflow, while the CLI provides the dashboard and protocol helpers. -## The hooks system (optional, independent of the skill) +## The hooks system (optional, observational) -Streaming Diff hooks are optional and installed per-agent, separately from the skill above. You can install them for Codex, Claude, or both: +Hooks are repository-only and installed per-agent, separately from the skill +above. There is no global hook mode: ```sh burnlist hooks install --agent codex,claude @@ -49,9 +54,24 @@ burnlist hooks status burnlist hooks uninstall --agent codex,claude ``` -Installation merges local Streaming Diff commands into `.codex/hooks.json` and `.claude/settings.json`, preserving existing hook entries. Hooks use the portable `burnlist` command from `PATH`. - -The agent remains responsible for any first-run hook trust or consent prompt; Burnlist only writes configuration. `burnlist hooks status` reports whether each config is tracked (shared) or local. `--agent codex,claude` selects agents, and `--untracked` is available for untracked handling. +Installation merges managed Streaming Diff and `burnlist hooks observe` +commands into `.codex/hooks.json` and `.claude/settings.json`, preserving +unrelated entries. Hooks use the portable `burnlist` command from `PATH`; +Codex hook support requires Codex CLI 0.124.0 or newer. + +Observations retain only bounded lifecycle, hashed session/agent identity, +tool name, contained paths, timing, model, effort, and token usage when the +native payload provides them. They may refine an already ACTIVE item to +**progressing**, but cannot create canonical state, report a semantic outcome, +advance a Loop, satisfy a gate, or complete work. See +[Observation is not canonical state](/dashboard#observation-is-not-canonical-state). + +The agent remains responsible for any first-run hook trust or consent prompt; +Burnlist only writes configuration. `burnlist hooks status` reports each +selected agent's installed/partial/corrupt state, CLI capability, inspected +config path, and whether the config is tracked (shared) or local. +`--agent codex,claude` selects agents. `--untracked` requests a local exclude, +but Git cannot hide a config that is already tracked. ## From a source checkout (optional) diff --git a/website/src/content/docs/lifecycle.mdx b/website/src/content/docs/lifecycle.mdx index 5fe44cce..5ca46fa0 100644 --- a/website/src/content/docs/lifecycle.mdx +++ b/website/src/content/docs/lifecycle.mdx @@ -3,7 +3,9 @@ title: Lifecycle description: How a Burnlist moves from draft to completed — and how an item burns down. --- -A Burnlist's state **is** its location. Burnlist moves the whole folder through four lifecycle directories; the per-item work flows through five verbs. +A Burnlist's state **is** its location. Burnlist moves the whole folder through +four lifecycle directories; five lifecycle verbs create, ready, start, burn +direct items, and close the plan. ## Folder lifecycle @@ -48,9 +50,22 @@ See the [CLI reference](/cli). An active item is a `- [ ] | ` line with indented `Files/search`, `Action`, `Done/delete when`, and `Validate` fields. Completed work becomes a single terse ledger line under `## Completed`. -## The burn transaction +## Queue state is not execution state -Perform this transaction atomically: +`## Active Checklist` means work remains. Its first item is the current queue +position, but that alone does not mean an agent or Loop Run is executing it. +An assigned-but-unstarted item is still `PENDING`; a direct item has no Loop +assignment at all. The dashboard derives PENDING, ACTIVE, WAITING, BLOCKED, +and COMPLETED from canonical Burnlist and Run/claim state—see +[Truthful item state](/dashboard#truthful-item-state). + +Direct items use the ordinary `burn` transaction below. Assigned items leave +the queue only through `burnlist loop complete` after their frozen Run +converges. + +## The direct burn transaction + +`burnlist burn` performs this transaction atomically for an unassigned item: 1. Validate the active item. 2. Generate a local ISO timestamp mechanically with `burnlist --stamp`. diff --git a/website/src/content/docs/loops.mdx b/website/src/content/docs/loops.mdx index 0f02b60c..2c5afcac 100644 --- a/website/src/content/docs/loops.mdx +++ b/website/src/content/docs/loops.mdx @@ -3,40 +3,52 @@ title: Loops description: Choose direct execution or configure one of the three built-in Loop workflows. --- -Every item uses one of exactly four execution choices: +Every item uses one of four execution choices: ```text direct Start -> Implement -> Burn -review Start -> Implement -> Check -> Review -> Burn gate Start -> Implement -> Check -> Burn +review Start -> Implement -> Check -> Review -> Burn branch Start -> Plan -> N host-orchestrated branches -> Merge -> Check -> Review -> Burn ``` -Direct execution is represented by no Loop assignment and keeps the ordinary +Direct work has no Loop assignment and keeps the ordinary `burnlist burn <id> <item> [--check]` workflow. The three assignable built-ins -are `loop:builtin:review`, `loop:builtin:gate`, and `loop:builtin:branch`. -An assigned item is completed only through its converged Loop Run. +are `loop:builtin:gate`, `loop:builtin:review`, and `loop:builtin:branch`. +An assigned item leaves the checklist only through its converged Loop Run. -Branch fan-out is host-orchestrated rather than a Burnlist scheduler: the plan -node chooses the slices, the host invokes N native or CLI subagents when -available (or executes the slices sequentially), and the merge node integrates -their results before the trusted check and independent review. +Burnlist provides soft orchestration: it owns the frozen graph, trusted check, +legal transitions, budgets, and completion. The host chooses and supervises +providers; the repository still owns implementation and proof. -## Configure and assign +## Choose the lightest control + +Ask for one optional recommendation before assigning a remaining checklist +item: + +```sh +burnlist recommend <burnlist-id>#<item-id> +burnlist recommend <burnlist-id>#<item-id> --json +``` + +| Item shape | Typical control | Model class / effort | +| --- | --- | --- | +| Small docs or low-risk change | direct | fast / low | +| Bounded implementation with a trusted check | gate | standard / medium | +| Security, data, public-contract, concurrency, or production risk | review | strong / high or xhigh | +| Genuinely independent streams with integration risk | branch | strong / high | -Burnlist stores no provider profile, binary, model, subscription, route, or -login. The host that burns the item chooses and supervises a native subagent or -external provider CLI for each claimed agent node. +The output also suggests review handling, a task-fit metric, a coarse-to-fine +sequence, and the next command. It is deterministic and advisory; the user may +accept, change, or ignore it. Model class is provider-neutral, and Burnlist +never installs, authenticates, selects, or launches a provider. -The check is a repository-owned declarative capability, not a command in the -Loop package. Inspect it, review the grants JSON against that capability, then -trust the exact revision: +## Configure and assign -First copy the catalog and grants objects below to `.burnlist/loop-capabilities.json` -and a private local grants path. Replace the absolute command -path and narrow the read paths to the check your repository actually needs. -The grants file deliberately repeats every field except `id` and may only -narrow this policy. +The trusted check is a repository-owned declarative capability, not a shell +command embedded in the Loop. Put the catalog entry in +`.burnlist/loop-capabilities.json`, keep its grants in a private local file, +inspect both, then trust the exact revision: ```json title=".burnlist/loop-capabilities.json" { @@ -54,214 +66,135 @@ narrow this policy. } ``` -```json title="grants.json" -{ - "argv": ["/absolute/path/to/repository-check", "--verify"], - "cwd": ".", - "environment": { "inherit": ["PATH"], "set": {} }, - "network": "deny", - "filesystem": { "read": ["src"], "write": [] }, - "output": { "maxBytes": 65536 }, - "maxMilliseconds": 60000 -} -``` +The grants object repeats every field except `id` and may only narrow this +policy. ```sh burnlist loop capability inspect repo-verify burnlist loop capability trust repo-verify --revision cp1-sha256:<hex> --grants /absolute/path/to/grants.json burnlist loop setup status -burnlist loop assign item:<burnlist-id>#<item-id> loop:builtin:<review|gate|branch> -``` - -`setup status` reports missing configuration rather than guessing it. An -assignment freezes the selected Loop authority for that item. View it and paste -the full output into the handoff or review request: - -```sh +burnlist loop assign item:<burnlist-id>#<item-id> loop:builtin:<gate|review|branch> burnlist loop view item:<burnlist-id>#<item-id> ``` -The ASCII view includes the decorative drawing, authoritative adjacency, pins, -retry limits, and completion path. It is the source of truth for the assigned -graph; do not redraw or abbreviate it. +`setup status` reports missing configuration rather than guessing. Assignment +freezes the selected Loop authority for the item. `loop view` is the +authoritative graph, pins, retry limits, and completion path; `loop list` +lists created Runs, not assignments. + +Branch fan-out is host-orchestrated, not scheduled by Burnlist. The plan node +defines genuinely independent slices, the host executes them concurrently +when available or sequentially otherwise, and one merge owner integrates +their results. -## Run and control +## Normal host flow: next, execute, submit -Create a Run from the assigned item. These commands return machine-readable -status or projection JSON except `view`. +Create the Run, then repeat the small host protocol until it is terminal: ```sh burnlist loop create item:<burnlist-id>#<item-id> -burnlist loop status run:<id> -burnlist loop inspect run:<id> +burnlist loop next run:<id> +burnlist loop submit run:<id> --outcome complete +burnlist loop next run:<id> +burnlist loop submit run:<id> --outcome approve +burnlist loop complete run:<id> ``` -## Host-orchestrated agent nodes +`next` atomically prepares the current agent node and returns one small +`burnlist-loop-host-task@1` packet. Give its `prompt` to the selected native +agent or provider. The worker needs no Burnlist commands, claim identity, +graph knowledge, or telemetry instructions. It must respect the packet's +read/write authority and legal outcomes, and must not choose an edge or run +the trusted check. -Every provider invocation is host-owned. A host inspects the next node, claims -an agent node, then returns exactly one -bound result. All four commands use JSON on stdout; write the report to a -regular, non-symlink file no larger than 256 KiB. +Submit only the semantic outcome by RunRef. Task nodes accept `complete`; +review nodes accept `approve`, `reject`, or `escalate`. Burnlist binds the +submission to the sealed claim, validates candidate identity, selects the +declared edge, advances checks and graph-only nodes, and stops at the next host +task or terminal state. -```sh -burnlist loop next run:<id> -burnlist loop claim run:<id> -burnlist loop report cl1-sha256:<claim-id> --result ./agent-result.json -burnlist loop abandon cl1-sha256:<claim-id> --reason host-cancelled -``` +Rejection, escalation, findings, or truthful optional telemetry use one +regular, non-symlink file no larger than 256 KiB: -`abandon` accepts only `host-cancelled`, `host-lost`, or `expired`; `expired` -is accepted only after the claim lease expires. Hosts cannot choose graph edges -or execute checks. After a valid report, Burnlist automatically advances its -trusted checks and graph-only nodes until the next agent claim or terminal -state. The Checklist Oven only observes these updates. - -`loop list` lists created Runs, not item assignments. Immediately after -assignment it may correctly return `[]`; use `loop view -item:<burnlist-id>#<item-id>` to verify assignment truth. For shell-driven -hosts, redirect the large `loop claim` JSON to a private ignored file and -extract `.claim.claimId` from that file instead of printing the authority -envelope into chat. - -The claim response carries a prepared execution envelope. A host executes that -exact bounded input through its available mechanism, preserves its full claim -identity in one `burnlist-loop-host-report@1`, and submits the report by claim -id. Burnlist, not the host, validates it and selects the transition. Codex and -Claude may orchestrate native subagents; any host may harness Codex CLI, AGY, -Grok, or another CLI using the explicit recipes shipped in the Burnlist skill. -Before the first Loop, the skill safely inventories native agents, installed -CLIs, live access, and subscriptions, shows the user the result, and asks which -providers to use or help set up. Burnlist never installs, authenticates, -configures, or launches a provider. Unknown telemetry remains unavailable. - -The maker receives the item and instructions, then the trusted repository -check runs. A new reviewer process evaluates the candidate. A check failure or -reviewer rejection returns to the maker within the declared budget; approval -passes the convergence gate. Escalation becomes `needs-human`; errors, -timeouts, cancellation, and budget exhaustion use their recorded terminal -outcomes. - -The standalone `pause` and `stop` commands require an idle Run with no active -host claim; they cannot take over a live provider. Use them only between -claims: +```json title="host-result.json" +{"schema":"burnlist-loop-host-result@1","outcome":"reject","findings":[],"resolvedFindingIds":[],"telemetry":null} +``` ```sh -burnlist loop pause run:<id> -burnlist loop stop run:<id> -burnlist loop reconcile run:<id> --recovery-proof <64-lowercase-hex> +burnlist loop submit run:<id> --result ./host-result.json ``` -`reconcile` is for a demonstrably lost host claim. It folds the frozen -journal and never re-resolves setup, recompiles the Loop, or reruns work. -With a valid recovery proof it terminalizes the Run as `needs-human`; it never -changes a Run to paused. -Terminal stopped Runs can be replaced with a new `loop create`; executable -Runs cannot. +Never guess telemetry. A forecast returned by `next` is a bounded planning +range with confidence and provenance—not proof, a deadline, or a billing +estimate. + +Only a converged Run can complete its item. `loop complete` rechecks the +assignment and candidate, atomically updates the shrinking checklist and +ledger, and records an idempotent receipt. + +## P0–P4 review policy + +| Priority | Severity | Completion handling | +| --- | --- | --- | +| P0 | blocker | Stop and escalate safety, security, data-loss, or invalid-authority risk. | +| P1 | major | Reject a high-impact correctness or contract defect. | +| P2 | material major | Reject when acceptance or a material user outcome is affected. | +| P3 | minor | Do not block; record one bounded follow-up when useful. | +| P4 | note | Optional style or preference note; omit noise. | + +Approval cannot leave P0, P1, or material P2 findings open. A gate consumes +canonical check and review evidence—not recent activity, forecasts, or agent +confidence. + +## Visibility -## Completion and visibility +The Checklist and Loop Progress lenses are read-only. They derive PENDING, +ACTIVE, WAITING, BLOCKED, and COMPLETED from canonical Burnlist and Run/claim +state, never queue position. Hooks may refine an already ACTIVE item to +**progressing**, but cannot choose an outcome or satisfy a gate. See +[Truthful item state](/dashboard#truthful-item-state). -Only a converged Run can request item completion: +Loop Progress answers “what is happening to this item right now?” PENDING +shows identity, status, its compact assigned Loop or direct-work label, and a +no-agent message. ACTIVE, WAITING, and BLOCKED reveal only populated current +facts; detailed telemetry and provenance stay behind secondary disclosure. + +## Advanced recovery and diagnostics + +Ordinary execution does not need claim envelopes. Use these lower-level +commands only to inspect or recover a specific Run: ```sh -burnlist loop complete run:<id> +burnlist loop status run:<id> +burnlist loop inspect run:<id> +burnlist loop claim run:<id> +burnlist loop report cl1-sha256:<claim-id> --outcome complete +burnlist loop report cl1-sha256:<claim-id> --result ./host-report.json +burnlist loop abandon cl1-sha256:<claim-id> --reason host-cancelled +burnlist loop pause run:<id> +burnlist loop stop run:<id> +burnlist loop reconcile run:<id> --recovery-proof <64-lowercase-hex> ``` -The CLI verifies the still-current assignment and Run, updates the shrinking -checklist and ledger under its lifecycle lock, and records an idempotent -receipt. Repeating `complete` returns the existing receipt without burning the -item twice. - -The canonical Checklist Oven is read-only. A centered KPI row is followed by -the existing Progress ledger and Completion trend. Below them, one unified -Items list contains current, pending, and completed work beside one selected -Item detail. Selecting any item through the list or an item deep link -(`#<item-id>`) changes only the detail. - -The detail includes an active item's action, completion criterion, validation, -file hints, assigned Loop, outcome-labelled return paths, and graph-derived -node legend. The current item's live Run is highlighted there; a future -assigned item uses its frozen assignment graph as a preview; an unassigned item -is explicitly labelled as direct execution. Completed items reuse the same list -and detail surface for their timestamp and recorded outcome, so Events are not -duplicated as a separate column or card feed. Transient stale-snapshot notices -float above the page so refreshes do not reflow or flicker the layout. - -The graph vocabulary is semantic: `S` is Start, a converged terminal is `B` for -Burn, and agent, check, review, and gate symbols come from the actual topology. -For example, the built-in review Loop renders `S`, `I`, `V`, `R`, and `B`; a -custom gate or branch adds its own visible symbol instead of inheriting a -hard-coded legend. The current node remains highlighted without changing graph -geometry. - -The Checklist Oven declares these regions in -`ovens/checklist/checklist.oven` through built-in declarative widgets. It does -not import custom UI or bypass the Oven runtime. It has no Run or completion -controls. Custom Ovens can compose the same core renderer with -`<loop-graph source="/raw/loopRun"/>` at the root or -`<loop-graph source="@item/loopRun"/>` inside an item collection. The widget -does not fetch or execute code; the dashboard transport refetches canonical -state after item-keyed Loop invalidations. - -The graph distinguishes writing agents (`[W]`), read-only agents (`[R]`), -deterministic checks, metric or evaluation gates, orchestrators, and terminal -outputs with separate text shapes. During a Run, a node may show provider, -model, and effort only when the host reports those observed values as -telemetry. No executable path, subscription, or provider configuration is -stored in the Run. -Fan-out Loops are arranged as explicit split, parallel branch, merge, and -feedback paths. - -Host execution is a frozen transport contract only: - -- Claim identity is fixed per invocation (`runId`, `nodeId`, `attempt`, - `assignmentId`, and `inputCandidate`) and carries a bounded lease. -- Prepared execution-envelope identity is canonical - (`burnlist-loop-host-execution@1`) and must validate against the frozen - run/claim identity. -- Transport envelope is canonical JSON with one prepared invocation digest and - dispatch authority, bounded inputs, no destination, and no outcome. -- Report is canonical and includes an `agent-result@1`, `findings` (for reviews), - optional `burnlist-loop-host-telemetry@1` with `provenance: "host-reported"` - only, and is accepted as idempotent only when byte-equivalent on retransmit. -- Outcomes are closed by node mode: task nodes accept only `complete`, review - nodes accept only `approve|reject|escalate`. -- Graph transitions stay entirely on the Burnlist side; hosts only return the report. -- Checks stay managed by the Loop run authority and are not executed directly by - hosts. -- Repeated reports are identity-based; duplicate retransmits are only accepted when - byte-equivalent. +Treat a claim envelope as opaque, bounded, and single-use. `abandon` accepts +only `host-cancelled`, `host-lost`, or—after lease expiry—`expired`. +`pause` and `stop` require an idle Run. `reconcile` is for a demonstrably lost +claim; it folds frozen authority and terminalizes as `needs-human` rather than +rerunning work or making the host transition authority. ## Guarantees and limits -Stage 1 labels graph grammar, fresh reviewer process, budgets, closed outcomes, -atomic canonical CLI writes, and the dashboard read-only boundary as -`enforced`. Ordinary configuration and path drift checks are -`detected-at-boundaries`. Reviewer filesystem write denial and host -instructions are `supervised`; they are not Docker or an OS sandbox. - -Host execution is transport-only for a frozen invocation envelope. The host -provides output and optional telemetry; Burnlist alone selects graph edges from -the frozen graph, validates the claim/candidate identity, and applies outcomes. -Hosts may not choose destination edges or execute checks directly; checks -remain Burnlist-owned and declared by the frozen Loop authority. - -Hosts return one terminal `burnlist-loop-host-report@1` for their claim. Its -bound agent result carries one legal node-mode `outcome` and no `destination`. - -Run projections and `loop next` include bounded local duration, aggregate-work, -and token ranges keyed by role, observed provider/model, effort, and complexity -band. The projection labels confidence and whether it used built-in priors or -truthful completed local observations. Missing usage stays unavailable; the -CLI does not infer subscription pricing or cost. - -Parallelism, nested agents, metric gates, auto-worktrees, -background execution, and Docker isolation are `unsupported`. -Do not present any of those as shipped Loop features. - -## Skills and hooks are separate - -`burnlist install` makes the Burnlist skill discoverable. `burnlist hooks -install` installs optional per-repository native observability hooks for -Streaming Diff and bounded active-Loop lifecycle activity. -They are independent: either, both, or neither can be installed, and neither -configures or starts a Review Loop. See [Install](/install). +Burnlist enforces graph grammar, frozen assignment and candidate identity, +closed outcomes, trusted checks, budgets, fresh reviewer mode, canonical +journaling, and atomic completion. Provider invocation and reviewer +instructions are host-supervised, not Docker or an OS sandbox. + +Auto-worktrees, background execution, nested agents, and Burnlist-scheduled +parallelism are unsupported. Missing model, effort, usage, or cost stays +unavailable; Burnlist never infers subscription pricing or progress from +silence. + +`burnlist install` makes the skill discoverable. +`burnlist hooks install` separately adds optional per-repository Streaming Diff +and native observation hooks. Either, both, or neither may be installed; +neither configures or starts a Loop. See [Install](/install). diff --git a/website/src/content/docs/ovens.mdx b/website/src/content/docs/ovens.mdx index b3ea60e3..baa9df87 100644 --- a/website/src/content/docs/ovens.mdx +++ b/website/src/content/docs/ovens.mdx @@ -19,6 +19,12 @@ An Oven is a named, declarative recipe for a Burn. It has two canonical files in An Oven cannot execute commands or code, collect or transform project data, own or replace canonical project state, mutate Burnlists or other files, import arbitrary UI, or start an agent. It is data, never code. +An Oven is useful only when its source-owned producer exposes an honest, +task-fit signal. The trusted repository check is often enough; do not add an +Oven merely to create a number. See [Designing Ovens](/ovens/designing-ovens) +for metric selection and the required browser verification of an Oven used as +task proof. + ## Official Ovens The validated, non-executable `ovens/catalog.json` is the only official Oven @@ -46,6 +52,7 @@ burnlist oven set <id> <path|-|json> [--repo <path>] burnlist oven bind <id> <path> [--repo <path>] # bind an Oven to a normalized data payload burnlist oven unbind <id> [--repo <path>] burnlist oven bindings [--repo <path>] # list this repo's Oven data bindings +burnlist oven event <id> --subject <id> --kind <kind> --phase <phase> --cursor <cursor> ``` `view` derives structure from the compiled IR. Any file input accepts `-` for standard input. `--name` owns the level-one heading. `create` scaffolds a starter `.oven` when one is omitted, and refuses an existing id; use `update`, or `--force`. Official Ovens are read-only, so fork one to customize it. @@ -59,6 +66,10 @@ custom Oven without a runtime validator receives `shape-only` pointer validation, which does not prove truth. This CLI never executes Oven instructions. +`oven event` publishes a compact observational notice only after the +producer's canonical data is durable. It does not contain or replace the +snapshot or its proof; consumers must refetch canonical data. + Structure, widgets, and formats are the `.oven` DSL vocabulary. See the [.oven DSL reference](/ovens/dsl-reference) and [The .oven system](/ovens/oven-system). ## Binding diff --git a/website/src/content/docs/ovens/authoring.mdx b/website/src/content/docs/ovens/authoring.mdx index 6096bb48..224baa83 100644 --- a/website/src/content/docs/ovens/authoring.mdx +++ b/website/src/content/docs/ovens/authoring.mdx @@ -311,6 +311,13 @@ burnlist oven view deploy-status # structure only burnlist --scan-root <repo> # dashboard; open the "Custom Oven" row ``` +The CLI prints the canonical scoped dashboard URL after adoption, binding, +and publication. Use that URL rather than reconstructing the route. If the +Oven is part of an item's proof, inspect its desktop and narrow layouts plus +relevant empty, waiting, blocked, stale, error, and completed states in a +browser before accepting it. See +[Visually verify proof Ovens](/ovens/designing-ovens#visually-verify-proof-ovens). + See [The .oven system](/ovens/oven-system) for the rendering boundary and the [.oven DSL reference](/ovens/dsl-reference) for every allowed element and binding. diff --git a/website/src/content/docs/ovens/checklist.mdx b/website/src/content/docs/ovens/checklist.mdx index d6bd25ba..0857ac1f 100644 --- a/website/src/content/docs/ovens/checklist.mdx +++ b/website/src/content/docs/ovens/checklist.mdx @@ -9,6 +9,10 @@ Checklist is the default queue-completion Oven. It observes a shrinking Markdown Canonical run state is `burnlist.md` with an ordered `## Active Checklist` and a terse `## Completed` ledger. The dashboard observes; it does not replace or silently mutate that state. +Live item labels come from canonical Burnlist and Run/claim state, not queue +position. See [Truthful item state](/dashboard#truthful-item-state) for the +PENDING, ACTIVE, WAITING, BLOCKED, and COMPLETED contract. + ## Direction Progress normally moves 0% → 100%. Completed count comes from the completed ledger, remaining from the active checklist, and total equals completed plus remaining. diff --git a/website/src/content/docs/ovens/designing-ovens.mdx b/website/src/content/docs/ovens/designing-ovens.mdx index b918f622..4e26c015 100644 --- a/website/src/content/docs/ovens/designing-ovens.mdx +++ b/website/src/content/docs/ovens/designing-ovens.mdx @@ -31,6 +31,21 @@ The built-in ovens follow this rule. Differential Testing measures byte-identica | Bug fix | The failing reproduction now passes, with 0 regressions | | Performance | Real measured timings against a baseline—never “feels faster” | +## Choose a task-fit metric + +Use an Oven only when it makes the user-approved outcome clearer: + +| Work | Typical evidence | +| --- | --- | +| UI, CSS, layout, or rendering | Visual Parity screenshots with explained pixel drift | +| Reference/candidate, migration, or compatibility work | Differential Testing | +| Latency, frame pacing, throughput, or budget work | Performance Tracing against a real baseline | +| Model, geometry, or animation work | Model Lab against its declared model contract | +| Everything else | The trusted repository check may be enough | + +Do not add an Oven merely to produce a number. A hand-entered metric, an +unapproved proxy, or a compile-only screenshot is not outcome evidence. + ## From signal to Oven Map each signal onto the built-in view vocabulary: @@ -84,3 +99,17 @@ Validate and snapshot it with `burnlist oven set migration-status .local/burnlis An Oven signal is only evidence if a Burnlist item cites it. Write the done/delete condition to point at the number: "Done/delete when Oven `migration-status` shows `validatedFraction = 100%` and `schemaFailures = 0`." This is advisory evidence a human or agent reads—Burnlist does not execute the Oven or auto-verify the number; `burnlist burn` and `--check` never read Oven data. The honesty is structural: the proof points at an adapter-computed signal the worker cannot type by hand. If a number can be typed by hand without doing the work, it is not evidence. + +## Visually verify proof Ovens + +After creating, adopting, or changing an Oven used as task proof: + +1. Publish real or explicit fixture data through the source-owned producer. +2. Open the [canonical dashboard URL](/dashboard#dashboard-urls). +3. Inspect desktop and narrow/mobile layouts in a browser. +4. Exercise relevant empty, waiting, blocked, stale, error, and completed states. +5. Confirm labels, values, overflow, hierarchy, and provenance are readable. +6. Record the user-approved signal in the Burnlist item's proof contract. + +Compilation and DOM tests are necessary but not sufficient visual evidence. +The dashboard remains read-only and the Oven remains declarative. diff --git a/website/src/content/docs/ovens/dsl-reference.mdx b/website/src/content/docs/ovens/dsl-reference.mdx index 013bfd23..502e5804 100644 --- a/website/src/content/docs/ovens/dsl-reference.mdx +++ b/website/src/content/docs/ovens/dsl-reference.mdx @@ -30,6 +30,7 @@ description: The declarative vocabulary, bindings, controls, and registries for | `<streaming-diff-heading>` | Streaming Diff heading and return link. | | `<checklist-burn-panel>` / `<checklist-ledger>` / `<checklist-event-cards>` | Checklist-specific detail views. | | `<loop-graph>` | Core box-drawing Loop renderer. Bind `source` to a Run projection at the Oven root, or use `@item/loopRun` inside `<each>`. | +| `<loop-progress>` | Core operational item view over a `checklist-progress@1` payload, with progressive state disclosure. | | `<domain-tabs>` / `<domain-note>` | Domain selection and its visual-parity explanation. | | `<differential-kpi-strip>` / `<differential-log-table>` | Differential Testing headline and log views. | | `<progress-chart>` / `<frame-delta-chart>` | Differential Testing charts. | @@ -37,12 +38,14 @@ description: The declarative vocabulary, bindings, controls, and registries for `<icon>` uses a registered icon and `<text>` supplies literal or bound text. `<bind>` supplies a named component property; it is described with bindings below. -`<loop-graph>` is core-owned: an Oven may position it but cannot inject renderer -code or perform its own fetch. The dashboard transport supplies fresh canonical -Run projections after item-keyed invalidation events: +`<loop-graph>` and `<loop-progress>` are core-owned: an Oven may position them +but cannot inject renderer code or perform its own fetch. The dashboard +transport supplies fresh canonical Run projections after item-keyed +invalidation events: ```xml <loop-graph source="/raw/loopRun" title="Current item Loop"/> +<loop-progress source="/raw"/> <collection id="items" source="/items" item-key="/id" paging="client" page-size="20"> @@ -215,4 +218,7 @@ registry values. ## Clean authoring -The theme and component defaults supply normal chrome and classes. All five committed Ovens — Checklist, Differential Testing, Streaming Diff, Performance Tracing, and visual-parity — contain no `class=` attribute and no `<box>` wrapper. Use `<box>` and `class=` only as optional custom escape hatches, not as routine page structure. +The theme and component defaults supply normal chrome and classes. Prefer +registered widgets and layout primitives; use `<box>` and `class=` only when a +surface needs a semantic host or a small, deliberate custom hook, not as +routine page structure. diff --git a/website/src/content/docs/ovens/streaming-diff.mdx b/website/src/content/docs/ovens/streaming-diff.mdx index b2fd1e1d..d4b63b3f 100644 --- a/website/src/content/docs/ovens/streaming-diff.mdx +++ b/website/src/content/docs/ovens/streaming-diff.mdx @@ -13,12 +13,20 @@ A feed is selected by its logical repository key, worktree key, and session. A f This package contains no executable renderer, hook, or producer code. The server-side adapter validates and reads the local feed without mutating it. +Diff cards and native hook observations are observational. They may explain +recent activity, but they never create ACTIVE state, select a Loop outcome, +satisfy a check or gate, or complete an item. + ## Rendering The detail page renders through the `.oven` engine: `streaming-diff.oven` compiles to `streaming-diff.ir.json`. See [The .oven system](/ovens/oven-system) and the [.oven DSL reference](/ovens/dsl-reference). ## Producing cards -Producers are wired through Streaming Diff hooks and CLI. `burnlist hooks install --agent codex,claude` merges the Streaming Diff commands into `.codex/hooks.json` and `.claude/settings.json`, preserving existing entries. The low-level surface is `burnlist streaming-diff <ensure-feed|capture|url|hook> ...`. +Producers are wired through hooks and CLI. +`burnlist hooks install --agent codex,claude` merges managed Streaming Diff +capture plus bounded `hooks observe` commands into `.codex/hooks.json` and +`.claude/settings.json`, preserving unrelated entries. The low-level surface +is `burnlist streaming-diff <ensure-feed|capture|url|hook> ...`. See [installation](/install) for hooks and [Ovens](/ovens). diff --git a/website/src/content/docs/ovens/visual-parity.mdx b/website/src/content/docs/ovens/visual-parity.mdx index 6ec54b43..4a61574b 100644 --- a/website/src/content/docs/ovens/visual-parity.mdx +++ b/website/src/content/docs/ovens/visual-parity.mdx @@ -24,3 +24,9 @@ Canonical state stays in the project's own captures and render output. A project ## Rendering The detail page renders through the `.oven` engine: `visual-parity.oven` compiles to `visual-parity.ir.json`. See [The .oven system](/ovens/oven-system) and the [.oven DSL reference](/ovens/dsl-reference). + +When Visual Parity is item proof, publish real or explicit fixture data and +inspect its canonical dashboard view at desktop and narrow widths in a +browser. Exercise relevant empty, failing, stale, error, and passing states; +compilation or DOM tests alone do not prove the visual surface. See +[Visually verify proof Ovens](/ovens/designing-ovens#visually-verify-proof-ovens). diff --git a/website/src/pages/index.astro b/website/src/pages/index.astro index d0ace395..9c0952f3 100644 --- a/website/src/pages/index.astro +++ b/website/src/pages/index.astro @@ -215,11 +215,11 @@ const skillUrl = new URL('/skill.md', Astro.site).toString(); <h2 id="surfaces-title">One tracker, five surfaces</h2> <p class="section-lede">The same shrinking queue gives agents, repositories, and observers a clear, local view of the work.</p> <div class="grid"> - <article class="card"><h3>CLI</h3><p>One command, <code>burnlist</code>, launches the loopback dashboard. Lifecycle verbs (<code>new</code>, <code>ready</code>, <code>start</code>, <code>burn</code>, <code>close</code>) plus protocol helpers (<code>--check</code>, <code>--digest</code>, <code>--stamp</code>) drive the burndown. Run <code>burnlist --help</code> for the full surface.</p></article> - <article class="card"><h3>Dashboard</h3><p>A read-only web observer. It scans the <code>notes/burnlists/</code> lifecycle folders across your registered repos and refreshes automatically. It never mutates canonical task state.</p></article> - <article class="card"><h3>Ovens</h3><p>Declarative, non-executable recipes for a Burn: two files — <code>instructions.md</code> (outcome + evidence rules) and <code><id>.oven</code> (declarative detail-page source). Six built-in Ovens ship: Checklist, Differential Testing, Model Lab, Streaming Diff, Performance Tracing, and Visual Parity.</p></article> + <article class="card"><h3>CLI</h3><p>One command, <code>burnlist</code>, launches the loopback dashboard. Lifecycle verbs drive the burndown; <code>recommend</code> suggests an optional per-item control profile and next step. Run <code>burnlist --help</code> for the full surface.</p></article> + <article class="card"><h3>Dashboard</h3><p>A read-only web observer. It scans lifecycle folders across registered repos and separates canonical PENDING, ACTIVE, WAITING, BLOCKED, and COMPLETED state from bounded observations.</p></article> + <article class="card"><h3>Ovens</h3><p>Declarative, non-executable recipes for a Burn: <code>instructions.md</code> defines outcome and evidence rules; <code><id>.oven</code> defines the view. The official catalog includes Checklist and Loop Progress lenses plus task-fit comparison, tracing, diff, and model views.</p></article> <article class="card"><h3>Skills</h3><p>A single agent skill owns the full Burnlist lifecycle — creation, hardening, execution, and maintenance — installable for both Claude and Codex, per-repo or global.</p></article> - <article class="card"><h3>Hooks</h3><p><code>burnlist hooks install --agent codex,claude</code> merges Streaming Diff commands into <code>.codex/hooks.json</code> and <code>.claude/settings.json</code>, preserving your existing hook entries.</p></article> + <article class="card"><h3>Hooks</h3><p><code>burnlist hooks install --agent codex,claude</code> adds optional Streaming Diff capture and bounded native observations. Hooks explain activity; they never create canonical state or complete work.</p></article> </div> </section> @@ -229,7 +229,7 @@ const skillUrl = new URL('/skill.md', Astro.site).toString(); <ol class="steps" aria-label="Burnlist folder lifecycle"> <li class="step">draft/</li><li class="step">ready/</li><li class="step">inprogress/</li><li class="step">completed/</li> </ol> - <p>Ready work moves to <code>inprogress</code> before execution and to <code>completed</code> once the active queue is empty. Each active item is validated before it leaves the checklist: the agent appends a terse completion record, deletes the item, then re-checks the Burnlist. <code>burnlist.md</code> is the canonical shrinking queue; <code>goal.md</code> holds the stable contract.</p> + <p>Ready work moves to <code>inprogress</code> before execution and to <code>completed</code> once the active queue is empty. Queue position does not imply live execution. Direct items burn atomically after validation; assigned items leave only through a converged Loop Run. <code>burnlist.md</code> is the canonical shrinking queue; <code>goal.md</code> holds the stable contract.</p> <p class="flow-caption">new → ready → start → burn → close</p> </div> </section> From cbc25d6585817924932a1dd93d9dd2fb86cc02d5 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti <juancfortunatti@gmail.com> Date: Sun, 26 Jul 2026 02:29:26 +0200 Subject: [PATCH 32/37] fix: close review-loop correctness gaps (E6) --- README.md | 8 +- .../ChecklistDashboard.test.mjs | 2 +- .../ChecklistDashboard/ChecklistDashboard.tsx | 2 +- .../checklist-dom.golden.html | 2 +- .../checklist-loop-progression.golden.json | 20 ++--- .../checklist-loop-states.golden.json | 32 ++++---- .../ChecklistEventCards.tsx | 4 +- .../ChecklistWidgets/ChecklistWidgets.test.ts | 4 +- ovens/checklist/checklist.oven | 1 + skills/burnlist/references/installation.md | 2 + src/loops/events/hook-context.mjs | 16 +++- src/loops/events/hook-observation.test.mjs | 49 ++++++++++-- src/loops/run/claim-index.mjs | 76 +++++++++++++++++++ src/loops/run/read-projection.mjs | 57 ++++++++------ src/loops/run/run-journal.mjs | 10 +++ src/loops/run/run-store.mjs | 36 +++++++-- src/loops/run/run-store.test.mjs | 49 +++++++++++- website/src/content/docs/install.mdx | 2 + 18 files changed, 293 insertions(+), 79 deletions(-) create mode 100644 src/loops/run/claim-index.mjs diff --git a/README.md b/README.md index 3fe3e271..6a0509ce 100644 --- a/README.md +++ b/README.md @@ -150,9 +150,11 @@ 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. 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. +changing canonical Run state. An unmatched native session stays unbound; +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 diff --git a/dashboard/src/components/ChecklistDashboard/ChecklistDashboard.test.mjs b/dashboard/src/components/ChecklistDashboard/ChecklistDashboard.test.mjs index dd8e0f48..fc10e33a 100644 --- a/dashboard/src/components/ChecklistDashboard/ChecklistDashboard.test.mjs +++ b/dashboard/src/components/ChecklistDashboard/ChecklistDashboard.test.mjs @@ -74,7 +74,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|>Outcome</u); + assert.doesNotMatch(markup, /class="event-card-field-label">Outcome/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 d1e97680..b1d94b77 100644 --- a/dashboard/src/components/ChecklistDashboard/ChecklistDashboard.tsx +++ b/dashboard/src/components/ChecklistDashboard/ChecklistDashboard.tsx @@ -114,5 +114,5 @@ export function ChecklistDashboard({ data }: { data: ChecklistProgressData }) { document.body.classList.add("driving-parity-view", "checklist-detail-view"); return () => document.body.classList.remove("driving-parity-view", "checklist-detail-view"); }, []); - return <div className="shell detail-view-shell driving-parity-view checklist-detail-shell"><main className="detail-view" id="burnlist-detail"><section className="differential-overview checklist-overview"><ChecklistKpis data={data} /></section><div className="detail-workspace checklist-progress-workspace" data-detail-tab="dashboard"><ProgressLedger data={data} /><ProgressPanel data={data} /></div><ChecklistWorkspace data={data} /></main></div>; + return <div className="shell detail-view-shell driving-parity-view checklist-detail-shell"><main className="detail-view" id="burnlist-detail"><section className="differential-overview checklist-overview"><ChecklistKpis data={data} /></section><div className="detail-workspace checklist-progress-workspace" data-detail-tab="dashboard"><ProgressLedger data={data} /><ProgressPanel data={data} /></div><ChecklistWorkspace data={data} /><EventCardList data={data} /></main></div>; } diff --git a/dashboard/src/components/ChecklistDashboard/checklist-dom.golden.html b/dashboard/src/components/ChecklistDashboard/checklist-dom.golden.html index b8093829..a207a117 100644 --- a/dashboard/src/components/ChecklistDashboard/checklist-dom.golden.html +++ b/dashboard/src/components/ChecklistDashboard/checklist-dom.golden.html @@ -1 +1 @@ -<div class="shell detail-view-shell driving-parity-view checklist-detail-shell"><main class="detail-view" id="burnlist-detail"><section class="differential-overview checklist-overview"><div aria-label="Burnlist progress KPIs" class="driving-parity-kpi-strip has-burns checklist-kpi-strip"><div class="driving-parity-kpi-item driving-parity-kpi-section checklist-kpi-current" title="No active task"><svg aria-hidden="true" class="lucide lucide-clipboard-list driving-parity-kpi-gauge driving-parity-kpi-scenario-icon" fill="none" height="24" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg"><rect height="4" rx="1" ry="1" width="8" x="8" y="2"></rect><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"></path><path d="M12 11h4"></path><path d="M12 16h4"></path><path d="M8 11h.01"></path><path d="M8 16h.01"></path></svg><div class="driving-parity-kpi-text"><div class="driving-parity-kpi-heading">Current</div><div class="driving-parity-kpi-ratio">Complete</div></div></div><div class="driving-parity-kpi-item driving-parity-kpi-section driving-parity-kpi-progress" title="2 of 2 tasks complete"><svg aria-hidden="true" class="driving-parity-kpi-gauge driving-parity-kpi-progress-donut" viewBox="0 0 58 58"><circle class="driving-parity-kpi-progress-donut-track" cx="29" cy="29" r="21"></circle><circle class="driving-parity-kpi-progress-donut-segment" cx="29" cy="29" pathLength="100" r="21" stroke-dasharray="100.000 0.000" transform="rotate(-90 29 29)"></circle></svg><div class="driving-parity-kpi-text"><div class="driving-parity-kpi-heading">Progress</div><div class="driving-parity-kpi-ratio"><span class="pass">2</span><span class="separator">·</span><span class="total">2</span><span class="pass">(100%)</span></div></div></div><div class="driving-parity-kpi-item driving-parity-kpi-section"><svg aria-hidden="true" class="lucide lucide-clock3 driving-parity-kpi-gauge driving-parity-kpi-scenario-icon" fill="none" height="24" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16.5 12"></polyline></svg><div class="driving-parity-kpi-text"><div class="driving-parity-kpi-heading">Elapsed</div><div class="driving-parity-kpi-ratio">10m</div></div></div><div class="driving-parity-kpi-item driving-parity-kpi-section"><svg aria-hidden="true" class="lucide lucide-gauge driving-parity-kpi-gauge driving-parity-kpi-scenario-icon" fill="none" height="24" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg"><path d="m12 14 4-4"></path><path d="M3.34 19a10 10 0 1 1 17.32 0"></path></svg><div class="driving-parity-kpi-text"><div class="driving-parity-kpi-heading">Avg pace</div><div class="driving-parity-kpi-ratio">5m</div></div></div><div class="driving-parity-kpi-item driving-parity-kpi-section"><svg aria-hidden="true" class="lucide lucide-timer-reset driving-parity-kpi-gauge driving-parity-kpi-scenario-icon" fill="none" height="24" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg"><path d="M10 2h4"></path><path d="M12 14v-4"></path><path d="M4 13a8 8 0 0 1 8-7 8 8 0 1 1-5.3 14L4 17.6"></path><path d="M9 17H4v5"></path></svg><div class="driving-parity-kpi-text"><div class="driving-parity-kpi-heading">Time left</div><div class="driving-parity-kpi-ratio">0m</div></div></div></div></section><div class="detail-workspace checklist-progress-workspace" data-detail-tab="dashboard"><section class="panel work-panel event-ledger-panel"><div class="work-panel-head"><div class="work-panel-title">Progress</div></div><div class="work-panel-body"><div class="checklist-log"><div class="checklist-log-list"><div class="checklist-log-table-header"><span>Age</span><span>Event</span><span>Result</span><span>Delta</span><span>Done</span></div><article class="log-row log-table-row"><span class="log-table-cell age">10m</span><span class="log-table-cell event">B2</span><span class="log-table-cell result improved">Done</span><span class="log-table-cell delta improved">+1</span><span class="log-table-cell done">100%</span></article><article class="log-row log-table-row"><span class="log-table-cell age">20m</span><span class="log-table-cell event">B1</span><span class="log-table-cell result improved">Done</span><span class="log-table-cell delta improved">+1</span><span class="log-table-cell done">50%</span></article></div></div></div></section><section class="panel progress-panel"><div class="panel-title-row"><span class="burn-chart-label">Completion</span></div><div class="score"><div class="chart-wrap"><svg aria-label="Completion percentage over time" class="chart checklist-progress-chart" role="img" viewBox="0 0 640 260"><g><line class="grid-line" x1="0" x2="640" y1="236" y2="236"></line></g><g><line class="grid-line" x1="0" x2="640" y1="177" y2="177"></line><rect class="label-backdrop" height="16" width="44" x="596" y="169"></rect><text class="axis-label y-axis-label" dominant-baseline="central" text-anchor="end" x="636" y="177">25%</text></g><g><line class="grid-line" x1="0" x2="640" y1="118" y2="118"></line><rect class="label-backdrop" height="16" width="44" x="596" y="110"></rect><text class="axis-label y-axis-label" dominant-baseline="central" text-anchor="end" x="636" y="118">50%</text></g><g><line class="grid-line" x1="0" x2="640" y1="59" y2="59"></line><rect class="label-backdrop" height="16" width="44" x="596" y="51"></rect><text class="axis-label y-axis-label" dominant-baseline="central" text-anchor="end" x="636" y="59">75%</text></g><g><line class="grid-line" x1="0" x2="640" y1="0" y2="0"></line><rect class="label-backdrop" height="16" width="44" x="596" y="0"></rect><text class="axis-label y-axis-label" dominant-baseline="central" text-anchor="end" x="636" y="8">100%</text></g><g><line class="grid-line x-grid-line" x1="128" x2="128" y1="0" y2="236"></line><text class="axis-label x-axis-label" text-anchor="middle" x="128" y="254">11:42</text></g><g><line class="grid-line x-grid-line" x1="256" x2="256" y1="0" y2="236"></line><text class="axis-label x-axis-label" text-anchor="middle" x="256" y="254">11:44</text></g><g><line class="grid-line x-grid-line" x1="384" x2="384" y1="0" y2="236"></line><text class="axis-label x-axis-label" text-anchor="middle" x="384" y="254">11:46</text></g><g><line class="grid-line x-grid-line" x1="512" x2="512" y1="0" y2="236"></line><text class="axis-label x-axis-label" text-anchor="middle" x="512" y="254">11:48</text></g><path class="progress-area" d="M 0.0 236.0 L 0.0 236.0 L 0.0 118.0 L 640.0 118.0 L 640.0 0.0 L 640.0 236.0 L 0.0 236.0 Z"></path><path class="progress-line" d="M 0.0 236.0 L 0.0 236.0 L 0.0 118.0 L 640.0 118.0 L 640.0 0.0"></path><text class="completion-marker" x="8" y="109">▼<title>Completed 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.
  • +
    Current
    Complete
    Progress
    2·2(100%)
    Elapsed
    10m
    Avg pace
    5m
    Time left
    0m
    Progress
    AgeEventResultDeltaDone
    10mB2Done+1100%
    20mB1Done+150%
    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 index 7217bafb..5ca2fba9 100644 --- a/dashboard/src/components/ChecklistDashboard/checklist-loop-progression.golden.json +++ b/dashboard/src/components/ChecklistDashboard/checklist-loop-progression.golden.json @@ -3,35 +3,35 @@ "checkpoint": "review/1/reject", "projectionBytes": 3560, "projectionSha256": "e269ab436bd602cf2be9ba9ad0ee1e8cab11a48ace11c0a717b534acf7437b28", - "domBytes": 11478, - "domSha256": "aff2df5ab5aac77cbc85487043c75e523fc14e75f78406599b97619e0934ec96" + "domBytes": 12704, + "domSha256": "9c00851de517321148b4210d1d0e3b390d8d725275b424ac35d1b5f6b29e3290" }, { "checkpoint": "implement/2/complete", "projectionBytes": 3829, "projectionSha256": "a9be55e85fff41141bd03d4a2061f5c10f98313fd037e4cf21b51ad539ac0366", - "domBytes": 11665, - "domSha256": "29c8a1f06c9635ffb51362597345b987b2cad503c08e2702d61d92863d8f551c" + "domBytes": 12891, + "domSha256": "80b879f789b324c4cb597aeff5635627e5ddc403247aa76d00c6c507cbd150cf" }, { "checkpoint": "review/2/approve", "projectionBytes": 3963, "projectionSha256": "fa26cf02b2bbf3a47d8246d782b9c7ee4dfd3f7c99692834775d591069d73e2b", - "domBytes": 11657, - "domSha256": "b6e1372afb1c371f7ddf3e7e7e7dd397a91d33701b210c55b5adb55f79d6bfe9" + "domBytes": 12883, + "domSha256": "691a45e4c290de89cd57112395d8a42bd4de4fa0244e7697d16a4e1253a799af" }, { "checkpoint": "converged/1/approve", "projectionBytes": 3989, "projectionSha256": "99d26c58766df3425692f8a3d23f740544e2cc5218037bf3be450719a1404518", - "domBytes": 11614, - "domSha256": "5ad3e03714f3acea0b468e295d5cf4b16d3e4ac45da5917fb253e049f8bacaa4" + "domBytes": 12840, + "domSha256": "43bcb76a1960c55110ac2b8335f43b413031c871fac488dc199743e5002d364e" }, { "checkpoint": "completed/1/approve", "projectionBytes": 3991, "projectionSha256": "1ade6bffe5db0e839527569b027e9d9575057e69c4f80bae327eb6fbe663a1d5", - "domBytes": 11585, - "domSha256": "7a3d5472661fcf7def0d64c3f4bb7c1bcd5de0b8ec7251dfbc4c8e6217e00139" + "domBytes": 12811, + "domSha256": "600b32f8c70415f1fa5fcdda44a50bfbd74bd2cc99501e78e42a0e590f7eb351" } ] diff --git a/dashboard/src/components/ChecklistDashboard/checklist-loop-states.golden.json b/dashboard/src/components/ChecklistDashboard/checklist-loop-states.golden.json index f738554f..11b9938b 100644 --- a/dashboard/src/components/ChecklistDashboard/checklist-loop-states.golden.json +++ b/dashboard/src/components/ChecklistDashboard/checklist-loop-states.golden.json @@ -1,42 +1,42 @@ [ { "checkpoint": "paused", - "domBytes": 11539, - "domSha256": "5502efe29562ac55d549bd22d512e5e56c312043dc9a47f15b008fd719678229" + "domBytes": 12765, + "domSha256": "52a8346c5b99313fbbf1a7211e8d378407d1c1e51dd4da4e79746a08b9632c10" }, { "checkpoint": "failed", - "domBytes": 11537, - "domSha256": "7aba7741fe80cb71352348a4573c95dae072cffb9480d095d5e1ad48d9c8f1ef" + "domBytes": 12763, + "domSha256": "de17b8bfc23e83f3fd18af3a51f311b612439c75e26dabbf34f3ee7a70be9922" }, { "checkpoint": "stopped", - "domBytes": 11538, - "domSha256": "e2c7aa9b6c919a3a29244f794167542ee61cb38b6424852d0eabb39a78e4d374" + "domBytes": 12764, + "domSha256": "53a4db3404794154957ebf41270dbdba8ada2db01561f3c1cc92b85679f4374c" }, { "checkpoint": "needs-human", - "domBytes": 11542, - "domSha256": "13af55dbf157da9638de11bb7a0aeebd38d71fa97036f38bb9b43409e086b9fa" + "domBytes": 12768, + "domSha256": "3488be2fb4b37fc615f25f5ffce1a7bced2da5d2ca5ccd3d6ffdc44d764ee141" }, { "checkpoint": "exhausted", - "domBytes": 11547, - "domSha256": "7054d53708e82fd50d69719336f937f1a5de36103452aae9b9335aabb16e60be" + "domBytes": 12773, + "domSha256": "5d4486f99292d52261d2480b8ff1d81c05df9ca0f8661409c93c1bd9c5083737" }, { "checkpoint": "stale", - "domBytes": 11554, - "domSha256": "7c4b0ff382daaf28f0853c4ebe11f7b52be665f081b14d098b8ca5cb629b8c65" + "domBytes": 12780, + "domSha256": "878561f84a2d71066e7b8ac51716e327ed2f512e7ab71c143326c4d62168da7d" }, { "checkpoint": "corrupt", - "domBytes": 11556, - "domSha256": "226ac90b4ef4f3b8c9c1db6e19605bcd24c8c768f650e0930b789ebd7b7b8255" + "domBytes": 12782, + "domSha256": "0b2c1c94b0d681810cf2ea2489b8935a4a0f2567e7fef26413a1f3acf7f1266d" }, { "checkpoint": "completed", - "domBytes": 11585, - "domSha256": "c980b879a49b77827f22699a6c69c8e230c97af49c5cded2ed633e276c592fec" + "domBytes": 12811, + "domSha256": "dcb947b09084a9c3795179d3d44068e61d5ff50e5d404a6c7ef6adf6d9208eba" } ] diff --git a/dashboard/src/oven/ChecklistEventCards/ChecklistEventCards.tsx b/dashboard/src/oven/ChecklistEventCards/ChecklistEventCards.tsx index 971b751c..025bd90c 100644 --- a/dashboard/src/oven/ChecklistEventCards/ChecklistEventCards.tsx +++ b/dashboard/src/oven/ChecklistEventCards/ChecklistEventCards.tsx @@ -1,6 +1,6 @@ import type { ChecklistProgressData } from "@lib"; -import { ChecklistWorkspace } from "../ChecklistWorkspace"; +import { EventCardList } from "@/components/ChecklistDashboard/ChecklistDashboard"; export function ChecklistEventCards({ data }: { data: ChecklistProgressData }) { - return ; + return ; } diff --git a/dashboard/src/oven/ChecklistWidgets/ChecklistWidgets.test.ts b/dashboard/src/oven/ChecklistWidgets/ChecklistWidgets.test.ts index 6d09bf34..acc340fe 100644 --- a/dashboard/src/oven/ChecklistWidgets/ChecklistWidgets.test.ts +++ b/dashboard/src/oven/ChecklistWidgets/ChecklistWidgets.test.ts @@ -3,7 +3,7 @@ import { test } from "node:test"; import { createElement } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { compileOven } from "../../../../src/ovens/dsl/oven-compile.mjs"; -import { ProgressLedger, ProgressPanel } from "@/components/ChecklistDashboard/ChecklistDashboard"; +import { EventCardList, ProgressLedger, ProgressPanel } from "@/components/ChecklistDashboard/ChecklistDashboard"; import { checklistFixture } from "@/components/ChecklistDashboard/ChecklistDashboard.fixture.mjs"; import { ChecklistWorkspace } from "../ChecklistWorkspace"; import { assertDomEquivalent } from "../test-support/dom-normalize"; @@ -20,7 +20,7 @@ function renderWidget(kind: string) { test("checklist widget adapters preserve the exported dashboard subregions", () => { assertDomEquivalent(renderWidget("checklist-burn-panel"), renderToStaticMarkup(createElement(ProgressPanel, { data: checklistFixture }))); assertDomEquivalent(renderWidget("checklist-ledger"), renderToStaticMarkup(createElement(ProgressLedger, { data: checklistFixture }))); - assertDomEquivalent(renderWidget("checklist-event-cards"), renderToStaticMarkup(createElement(ChecklistWorkspace, { data: checklistFixture }))); + assertDomEquivalent(renderWidget("checklist-event-cards"), renderToStaticMarkup(createElement(EventCardList, { data: checklistFixture }))); }); test("workspace exposes live execution identity, evidence, and observed paths", () => { diff --git a/ovens/checklist/checklist.oven b/ovens/checklist/checklist.oven index ed513008..56e2edb2 100644 --- a/ovens/checklist/checklist.oven +++ b/ovens/checklist/checklist.oven @@ -18,6 +18,7 @@ + diff --git a/skills/burnlist/references/installation.md b/skills/burnlist/references/installation.md index 1d2bbd0e..3ac4e2d1 100644 --- a/skills/burnlist/references/installation.md +++ b/skills/burnlist/references/installation.md @@ -51,6 +51,8 @@ advisory observation entries retain only bounded lifecycle, hashed session/agent identity, tool name, contained file paths, timing, model, effort, and token usage when the native payload exposes them. They publish to ignored local Oven event state and cannot report a semantic outcome or advance a Loop. +Only an explicitly propagated invocation correlation may establish the first +session binding; an unmatched session is ignored even when one claim is live. Codex hook support requires Codex CLI 0.124.0 or newer; `status` reports whether the installed CLI can run the configured hooks. The commands require `burnlist` on the host `PATH`; each agent may still ask for hook trust or diff --git a/src/loops/events/hook-context.mjs b/src/loops/events/hook-context.mjs index c1b3769d..afffcf73 100644 --- a/src/loops/events/hook-context.mjs +++ b/src/loops/events/hook-context.mjs @@ -92,17 +92,29 @@ function observedIdentity(payload, provider) { const agent = safe(rawAgent) ? digest("agent", `${provider}\0${rawAgent}`) : null; return { session, agent }; } +function explicitCorrelation(payload) { + const value = payload?.burnlist_correlation; + if (!exact(value, ["claimId", "invocationId"]) + || !/^cl1-sha256:[a-f0-9]{64}$/u.test(value.claimId) + || !/^iv1-sha256:[a-f0-9]{64}$/u.test(value.invocationId)) return null; + return value; +} /** Resolve and bind native identities without exposing their raw values. */ export function resolveLoopHookContext(repoRoot, { provider, payload, now = Date.now() }) { - const identity = observedIdentity(payload, provider); + const identity = observedIdentity(payload, provider), correlation = explicitCorrelation(payload); if (!identity.session) return null; return withRepoStateLock(repoRoot, () => { const stored = readContext(repoRoot); const contexts = stored.contexts.filter((entry) => live(repoRoot, entry, now)); let match = contexts.filter((entry) => entry.sessions.includes(identity.session) || identity.agent && entry.agents.includes(identity.agent)); - if (!match.length && contexts.length === 1) match = contexts; + if (correlation) { + const explicit = contexts.filter((entry) => entry.claimId === correlation.claimId + && entry.invocationId === correlation.invocationId); + if (explicit.length !== 1 || match.length && !match.includes(explicit[0])) match = []; + else match = explicit; + } if (match.length !== 1) { if (contexts.length !== stored.contexts.length) writeContext(repoRoot, { schema: SCHEMA, contexts }); diff --git a/src/loops/events/hook-observation.test.mjs b/src/loops/events/hook-observation.test.mjs index 3d38011d..b150ae55 100644 --- a/src/loops/events/hook-observation.test.mjs +++ b/src/loops/events/hook-observation.test.mjs @@ -23,8 +23,11 @@ function fixture(t) { t.after(() => rmSync(directory, { recursive: true, force: true })); const { repo } = createProductionRunAuthority(join(directory, "repo")); const runId = JSON.parse(command(repo, ["create", fixtureItemRef])).runId; - command(repo, ["next", runId]); - return { repo, runId }; + const execution = JSON.parse(command(repo, ["claim", runId])).execution; + return { + repo, runId, + correlation: { claimId: execution.claimId, invocationId: execution.invocationId }, + }; } function codexPayload(repo, event, patch = {}) { return { @@ -34,10 +37,12 @@ function codexPayload(repo, event, patch = {}) { } test("native lifecycle and tool hooks publish correlated observational events", (t) => { - const { repo, runId } = fixture(t); + const { repo, runId, correlation } = fixture(t); const start = publishNativeLoopObservation({ repoRoot: repo, provider: "codex", - payload: codexPayload(repo, "SessionStart", { source: "startup" }), + payload: codexPayload(repo, "SessionStart", { + source: "startup", burnlist_correlation: correlation, + }), }); assert.equal(start.authority, "observational"); const tool = publishNativeLoopObservation({ @@ -62,12 +67,13 @@ test("native lifecycle and tool hooks publish correlated observational events", }); test("Claude facts retain exposed effort and usage while missing fields stay null", (t) => { - const { repo, runId } = fixture(t); + const { repo, runId, correlation } = fixture(t); publishNativeLoopObservation({ repoRoot: repo, provider: "claude", payload: { session_id: "claude-session", cwd: repo, hook_event_name: "SessionStart", source: "startup", model: "claude-opus-4-6", + burnlist_correlation: correlation, }, }); const event = publishNativeLoopObservation({ @@ -100,9 +106,10 @@ test("Claude facts retain exposed effort and usage while missing fields stay nul }); test("hook replay cannot report outcomes or advance canonical Run state", (t) => { - const { repo, runId } = fixture(t); + const { repo, runId, correlation } = fixture(t); const payload = codexPayload(repo, "SubagentStop", { agent_id: "agent-1", agent_type: "worker", outcome: "complete", + burnlist_correlation: correlation, }); const first = publishNativeLoopObservation({ repoRoot: repo, provider: "codex", payload }); const second = publishNativeLoopObservation({ repoRoot: repo, provider: "codex", payload }); @@ -118,6 +125,36 @@ test("hook replay cannot report outcomes or advance canonical Run state", (t) => }), null); }); +test("an unmatched singleton session cannot claim the only live Loop context", (t) => { + const { repo, runId, correlation } = fixture(t); + assert.equal(publishNativeLoopObservation({ + repoRoot: repo, provider: "codex", + payload: codexPayload(repo, "SessionStart", { + session_id: "unmatched-session", source: "startup", + }), + }), null); + assert.equal(readOvenEvents(repo, { ovenIds: ["checklist"], limit: 100 }) + .filter((entry) => entry.payload?.runId === runId).length, 0); + const matched = publishNativeLoopObservation({ + repoRoot: repo, provider: "codex", + payload: codexPayload(repo, "SessionStart", { + session_id: "explicit-session", source: "startup", + burnlist_correlation: correlation, + }), + }); + assert.equal(matched.payload.runId, runId); + assert.equal(publishNativeLoopObservation({ + repoRoot: repo, provider: "codex", + payload: codexPayload(repo, "PostToolUse", { + session_id: "explicit-session", tool_name: "Bash", tool_use_id: "wrong-correlation", + burnlist_correlation: { + claimId: `cl1-sha256:${"c".repeat(64)}`, + invocationId: `iv1-sha256:${"d".repeat(64)}`, + }, + }), + }), null); +}); + test("providers without a verified native hook contract are unsupported", (t) => { const { repo } = fixture(t); assert.equal(publishNativeLoopObservation({ diff --git a/src/loops/run/claim-index.mjs b/src/loops/run/claim-index.mjs new file mode 100644 index 00000000..942a4f1b --- /dev/null +++ b/src/loops/run/claim-index.mjs @@ -0,0 +1,76 @@ +import { closeSync, constants, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readSync, renameSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { withDirectoryLock } from "../../server/dir-lock.mjs"; +import { isRunRef } from "./run-ref.mjs"; + +const CLAIM = /^cl1-sha256:[a-f0-9]{64}$/u; +const fail = (message, code = "EBOUNDS") => { + throw Object.assign(new Error(`Claim index: ${message}`), { code }); +}; + +function syncDirectory(path) { + const fd = openSync(path, constants.O_RDONLY); + try { fsyncSync(fd); } finally { closeSync(fd); } +} + +/** Immutable O(1) ClaimRef hints; every consumer must still verify the Run journal. */ +export function createClaimIndex({ base, random }) { + const directory = join(base, "claims"); + const pathFor = (claimId) => { + if (!CLAIM.test(claimId)) fail("invalid ClaimRef", "ECLAIM"); + return join(directory, `${claimId.slice("cl1-sha256:".length)}.json`); + }; + function read(claimId) { + const target = pathFor(claimId); let fd; + try { + const entry = lstatSync(target); + if (!entry.isFile() || entry.isSymbolicLink() || (entry.mode & 0o777) !== 0o600 + || entry.size < 2 || entry.size > 512) fail("entry is corrupt"); + fd = openSync(target, constants.O_RDONLY | constants.O_NONBLOCK | (constants.O_NOFOLLOW ?? 0)); + const before = fstatSync(fd), bytes = Buffer.alloc(before.size); + if (!before.isFile() || before.dev !== entry.dev || before.ino !== entry.ino + || before.size !== entry.size || (before.mode & 0o777) !== 0o600 + || readSync(fd, bytes, 0, bytes.length, 0) !== bytes.length) fail("entry changed while reading"); + const after = fstatSync(fd), linked = lstatSync(target); + if (after.dev !== before.dev || after.ino !== before.ino || after.size !== before.size + || linked.isSymbolicLink() || linked.dev !== before.dev || linked.ino !== before.ino + || linked.size !== before.size) fail("entry changed while reading"); + let value; try { value = JSON.parse(bytes); } catch { fail("entry is corrupt"); } + if (!value || Object.keys(value).length !== 3 || value.schema !== "burnlist-loop-claim-index@1" + || value.claimId !== claimId || !isRunRef(value.runId) + || !Buffer.from(`${JSON.stringify(value)}\n`).equals(bytes)) fail("entry is corrupt"); + return value; + } catch (error) { + if (error?.code === "ENOENT") return null; + throw error; + } finally { if (fd !== undefined) closeSync(fd); } + } + function write(runId, claimId) { + if (!isRunRef(runId)) fail("invalid RunRef", "ECLAIM"); + const value = { schema: "burnlist-loop-claim-index@1", claimId, runId }; + const bytes = Buffer.from(`${JSON.stringify(value)}\n`), target = pathFor(claimId); + mkdirSync(directory, { recursive: true, mode: 0o700 }); + return withDirectoryLock({ + lockPath: join(base, ".claim-index.lock"), + reclaimLiveAfterAge: false, + errorFactory: () => fail("index is locked", "ELOCKED"), + fn() { + const existing = read(claimId); + if (existing) { + if (existing.runId !== runId) fail("entry conflicts", "ECLAIM"); + return; + } + const temporary = `${target}.${random(8).toString("hex")}.tmp`; let fd; + try { + fd = openSync(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW ?? 0), 0o600); + writeFileSync(fd, bytes); fsyncSync(fd); closeSync(fd); fd = undefined; + renameSync(temporary, target); syncDirectory(directory); + } finally { + if (fd !== undefined) closeSync(fd); + rmSync(temporary, { force: true }); + } + } + }); + } + return Object.freeze({ read, write }); +} diff --git a/src/loops/run/read-projection.mjs b/src/loops/run/read-projection.mjs index 2146df1f..a67313a7 100644 --- a/src/loops/run/read-projection.mjs +++ b/src/loops/run/read-projection.mjs @@ -1,7 +1,6 @@ import { existsSync, lstatSync, readFileSync, readdirSync } from "node:fs"; import { join, resolve } from "node:path"; -import { readJournal } from "./run-journal.mjs"; -import { foldRun } from "./run-fold.mjs"; +import { readInitialJournal } from "./run-journal.mjs"; import { isRunRef } from "./run-ref.mjs"; import { locateItemSpan, validateAssignedItem } from "../assignment/item-metadata.mjs"; import { assignmentStore } from "../assignment/store.mjs"; @@ -132,33 +131,41 @@ export function readLatestRunForItem({ repoRoot, itemRef, markdown = null, itemI const current = currentRunAuthority({ root: repoRoot, base, random: () => Buffer.alloc(8) }).read() .find((entry) => entry.itemRef === itemRef) ?? null; if (current && artifact && current.assignmentId !== artifact.assignmentId) return null; - let entries; - try { entries = readdirSync(runs, { withFileTypes: true }); } catch { fail("Run projection is corrupt", "ECORRUPT"); } - const staging = entries.filter((entry) => /^\.create-[a-f0-9]{16}\.tmp$/u.test(entry.name)); - const visible = entries.filter((entry) => !/^\.create-[a-f0-9]{16}\.tmp$/u.test(entry.name)); - if (staging.length > MAX_RUNS || visible.length > MAX_RUNS - || entries.some((entry) => !entry.isDirectory() || !/^(?:[a-f0-9]+|\.create-[a-f0-9]{16}\.tmp)$/u.test(entry.name))) fail("run directory exceeds bounds"); + const store = runStore(repoRoot); + const load = (runId) => { + try { return store.read(runId); } catch { fail("Run projection is corrupt", "ECORRUPT"); } + }; + const initialItem = (runId) => { + try { return readInitialJournal(store.paths.journalFor(runId)).value.payload.itemRef; } + catch { fail("Run projection is corrupt", "ECORRUPT"); } + }; let selected = null; - for (const entry of visible) { - if (!entry.isDirectory() || !/^[a-f0-9]+$/u.test(entry.name)) fail("Run projection is corrupt", "ECORRUPT"); - let runId; - try { runId = Buffer.from(entry.name, "hex").toString("utf8"); } catch { fail("Run projection is corrupt", "ECORRUPT"); } - if (!isRunRef(runId) || Buffer.from(runId).toString("hex") !== entry.name) fail("Run projection is corrupt", "ECORRUPT"); - let journal, folded; - try { - journal = readJournal(join(runs, entry.name, "journal")); - folded = foldRun(journal); - } catch { fail("Run projection is corrupt", "ECORRUPT"); } - if (folded.projection.itemRef !== itemRef || current && runId !== current.runId) continue; - if (artifact && JSON.stringify(folded.graph) !== JSON.stringify(artifact.frozen.ir)) continue; - if (selected) fail("Run projection is ambiguous", "EAMBIGUOUS"); - selected = { ...folded, journal }; + if (current) { + if (initialItem(current.runId) !== itemRef) fail("current Run is unavailable", "ECURRENT"); + selected = load(current.runId); + if (selected.projection.itemRef !== itemRef + || artifact && JSON.stringify(selected.graph) !== JSON.stringify(artifact.frozen.ir)) + fail("current Run is unavailable", "ECURRENT"); + } else { + let entries; + try { entries = readdirSync(runs, { withFileTypes: true }); } catch { fail("Run projection is corrupt", "ECORRUPT"); } + const staging = entries.filter((entry) => /^\.create-[a-f0-9]{16}\.tmp$/u.test(entry.name)); + const visible = entries.filter((entry) => !/^\.create-[a-f0-9]{16}\.tmp$/u.test(entry.name)); + if (staging.length > MAX_RUNS || visible.length > MAX_RUNS + || entries.some((entry) => !entry.isDirectory() || !/^(?:[a-f0-9]+|\.create-[a-f0-9]{16}\.tmp)$/u.test(entry.name))) fail("run directory exceeds bounds"); + for (const entry of visible) { + const runId = Buffer.from(entry.name, "hex").toString("utf8"); + if (!isRunRef(runId) || Buffer.from(runId).toString("hex") !== entry.name) + fail("Run projection is corrupt", "ECORRUPT"); + if (initialItem(runId) !== itemRef) continue; + const candidate = load(runId); + if (artifact && JSON.stringify(candidate.graph) !== JSON.stringify(artifact.frozen.ir)) continue; + if (selected) fail("Run projection is ambiguous", "EAMBIGUOUS"); + selected = candidate; + } } if (current && !selected) fail("current Run is unavailable", "ECURRENT"); if (!selected) return null; - const stored = runStore(repoRoot).read(selected.projection.runId); - selected.loopIdentity = stored.loopIdentity; - selected.agentRoutes = stored.agentRoutes; const optionalRecords = readLoopObservationRecords(root, selected.projection.runId); return presentRun(selected, { optionalRecords, diff --git a/src/loops/run/run-journal.mjs b/src/loops/run/run-journal.mjs index 73120eae..5747acd6 100644 --- a/src/loops/run/run-journal.mjs +++ b/src/loops/run/run-journal.mjs @@ -31,6 +31,16 @@ export function parseJournalRecord(bytes) { if (!exact(value, ["schema", "sequence", "prevDigest", "at", "type", "payload"]) || value.schema !== "burnlist-loop-m2-journal@1") fail("record is not closed"); const record = createJournalRecord(value); if (!record.bytes.equals(bytes)) fail("record is not canonical"); return record; } +/** Read only the immutable item descriptor before deciding whether a full fold is relevant. */ +export function readInitialJournal(directory) { + const path = join(directory, fileName(1)), stat = lstatSync(path); + if (!stat.isFile() || stat.isSymbolicLink() || stat.size < 2 || stat.size > MAX_RECORD) + fail("initial journal record is invalid"); + const record = parseJournalRecord(readFileSync(path)); + if (record.value.sequence !== 1 || record.value.prevDigest !== null || record.value.type !== "run-created") + fail("initial journal record is invalid"); + return record; +} export function readJournal(directory) { const entries = readdirSync(directory, { withFileTypes: true }); if (entries.length > MAX_RECORDS + 1) fail("too many journal entries"); const records = []; let temporary = 0, aggregate = 0; diff --git a/src/loops/run/run-store.mjs b/src/loops/run/run-store.mjs index 84b967d2..83b57cf3 100644 --- a/src/loops/run/run-store.mjs +++ b/src/loops/run/run-store.mjs @@ -13,6 +13,7 @@ import { loadFrozenRecipe } from "../dsl/frozen.mjs"; import { loadBoundPolicy } from "./run-artifacts.mjs"; import { createHostClaimAbandonment, hostClaimExpired, validateHostClaim } from "./run-claim.mjs"; import { validateHostExecutionEnvelope, validateHostExecutionReport } from "../contracts/host-execution.mjs"; +import { createClaimIndex } from "./claim-index.mjs"; const fail = (message, code = "ERUN_STORE") => { throw Object.assign(new Error(`Run store: ${message}`), { code }); }; const runName = (id) => Buffer.from(id).toString("hex"); @@ -26,6 +27,7 @@ export function runStore(repoRoot, { clock = () => Date.now(), random = randomBy if (!/^sha256:[a-f0-9]{64}$/u.test(digest)) fail("external report digest is invalid", "EREPORT"); return join(reportRoot(id), `${digest.slice(7)}.json`); }, currentLock = join(base, ".current-runs.lock"), initialize = () => mkdirSync(runs, { recursive: true, mode: 0o700 }), currentAuthority = () => currentRunAuthority({ root, base, random }); + const claimIndex = createClaimIndex({ base, random }); const assertId = (id) => { if (!isRunRef(id)) fail("invalid RunRef"); return id; }; const locked = (id, fn) => { assertId(id); initialize(); return withDirectoryLock({ lockPath: lockFor(id), reclaimLiveAfterAge: false, errorFactory: () => fail("run is locked", "ELOCKED"), fn }); }; const replay = (id) => { @@ -163,7 +165,11 @@ export function runStore(repoRoot, { clock = () => Date.now(), random = randomBy const terminalize = (id, lease, kind, summary) => locked(id, () => { const current = replay(id); assertLease(current, lease); return terminalizeCurrent(id, current, { kind, summary }); }); const bindExternalClaim = (id, lease, { claim, envelope }) => locked(id, () => { const current = replay(id); assertLease(current, lease); - if (current.execution.externalClaim) return readExternalClaim(id, current); + if (current.execution.externalClaim) { + const active = readExternalClaim(id, current); + claimIndex.write(id, active.claim.claimId); + return active; + } if (current.execution.terminal || current.execution.started || current.execution.invocation || current.execution.node?.kind !== "agent") fail("current node is not an unstarted agent", "ECLAIM"); const checkedClaim = validateHostClaim(claim), checkedEnvelope = validateHostExecutionEnvelope(envelope), authority = readAuthority(id), issued = now(); const frozen = loadFrozenRecipe(Buffer.from(authority.frozenRecipe, "base64")), policy = loadBoundPolicy(Buffer.from(authority.policy, "base64")); @@ -175,6 +181,9 @@ export function runStore(repoRoot, { clock = () => Date.now(), random = randomBy || checkedEnvelope.value.recipeRevision !== frozen.revisions.executable || checkedEnvelope.value.policyRevision !== policy.revision) fail("external claim does not bind the current Run", "ECLAIM"); if (current.execution.node.mode === "review" && checkedClaim.inputCandidate !== current.execution.candidate?.id) fail("review claim is not bound to the current candidate", "ECLAIM"); + // Retain the immutable ClaimRef lookup for exact report retries. It is a + // bounded O(1) hint only: resolution still verifies the Run journal. + claimIndex.write(id, checkedClaim.claimId); writeExecution(id, checkedEnvelope); // One journal record makes the host-visible claim indivisible: replay never // observes a started agent invocation that lacks its sealed host envelope. @@ -316,19 +325,30 @@ export function runStore(repoRoot, { clock = () => Date.now(), random = randomBy function resolveClaimRef(claimId) { if (!/^cl1-sha256:[a-f0-9]{64}$/u.test(claimId)) fail("invalid ClaimRef", "ECLAIM"); if (!existsSync(runs)) fail("ClaimRef is missing", "ECLAIM"); + const matchesClaim = (runId) => { + const current = replay(runId), bound = current.journal.some((record) => record.value.type === "external-claim-bound" + && record.value.payload.claim.claimId === claimId); + if (!bound) return false; + const accepted = current.journal.some((record) => record.value.type === "external-report-accepted" + && record.value.payload.claimId === claimId); + if (!accepted && current.execution.externalClaim?.claim.claimId !== claimId) fail("ClaimRef is stale", "ECLAIM"); + return true; + }; + const indexed = claimIndex.read(claimId); + if (indexed && matchesClaim(indexed.runId)) return indexed.runId; + const currentMatches = currentAuthority().read() + .map((entry) => entry.runId) + .filter((runId, index, values) => values.indexOf(runId) === index) + .filter(matchesClaim); + if (currentMatches.length > 1) fail("ClaimRef is ambiguous", "ECLAIM"); + if (currentMatches.length === 1) return currentMatches[0]; const entries = readdirSync(runs, { withFileTypes: true }).filter((entry) => !entry.name.startsWith(".")); if (entries.length > 128 || entries.some((entry) => !entry.isDirectory() || !/^[a-f0-9]+$/u.test(entry.name))) fail("run directory exceeds bounds", "EBOUNDS"); const matches = []; for (const entry of entries) { const runId = Buffer.from(entry.name, "hex").toString("utf8"); if (!isRunRef(runId) || Buffer.from(runId).toString("hex") !== entry.name) fail("run directory is corrupt", "EBOUNDS"); - const current = replay(runId), bound = current.journal.some((record) => record.value.type === "external-claim-bound" - && record.value.payload.claim.claimId === claimId); - if (!bound) continue; - const accepted = current.journal.some((record) => record.value.type === "external-report-accepted" - && record.value.payload.claimId === claimId); - if (!accepted && current.execution.externalClaim?.claim.claimId !== claimId) fail("ClaimRef is stale", "ECLAIM"); - matches.push(runId); + if (matchesClaim(runId)) matches.push(runId); } if (!matches.length) fail("ClaimRef is missing", "ECLAIM"); if (matches.length !== 1) fail("ClaimRef is ambiguous", "ECLAIM"); diff --git a/src/loops/run/run-store.test.mjs b/src/loops/run/run-store.test.mjs index 81b0fd01..f4801dfa 100644 --- a/src/loops/run/run-store.test.mjs +++ b/src/loops/run/run-store.test.mjs @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { chmodSync, existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs"; import os from "node:os"; import { join } from "node:path"; import test from "node:test"; @@ -9,7 +9,11 @@ import { testGraph, testRunId } from "./m2-test-fixtures.mjs"; import { withDirectoryLock } from "../../server/dir-lock.mjs"; import { readOvenEvents } from "../../events/oven-event-store.mjs"; import { publishLoopProjectionInvalidation } from "../events/projection-events.mjs"; -import { presentRun } from "./read-projection.mjs"; +import { presentRun, readLatestRunForItem } from "./read-projection.mjs"; +import { newRunId } from "./run-codec.mjs"; +import { createProductionRunAuthority, fixtureItemRef, fixtureRunId } from "./run-test-fixtures.mjs"; +import { createProductionRun, createStoredSystemRunRunner } from "./binder.mjs"; +import { createLoopController } from "./controller.mjs"; function fixture(t) { const root = mkdtempSync(join(os.tmpdir(), "m2-store-")); t.after(() => rmSync(root, { recursive: true, force: true })); let at = 0; const store = runStore(root, { clock: () => at++ }); store.createRun({ runId: testRunId, itemRef: "item:260722-001#M2", graph: testGraph }); return store; } test("Run creation stages private authority and journal until one directory rename", (t) => { @@ -115,3 +119,44 @@ test("a projection publisher failure cannot roll back a committed run mutation", assert.equal(store.replay(testRunId).revision, created.revision); assert.equal(store.list().length, 1); }); + +test("a retained 129th Run cannot brick indexed host submission", { timeout: 30_000 }, async (t) => { + const root = mkdtempSync(join(os.tmpdir(), "m12-retained-runs-")); t.after(() => rmSync(root, { recursive: true, force: true })); + const { repo } = createProductionRunAuthority(join(root, "repo")); + const store = runStore(repo, { publishProjection() {} }); + await createProductionRun({ repoRoot: repo, store, itemRef: fixtureItemRef, runId: fixtureRunId }); + const controller = createLoopController({ + repoRoot: repo, + store, + runnerFor: (runId) => createStoredSystemRunRunner({ repoRoot: repo, store, runId }), + }); + controller.next(fixtureRunId); + const claimId = store.readExternalClaim(fixtureRunId).claim.claimId; + const claimPath = join(store.paths.base, "claims", `${claimId.slice("cl1-sha256:".length)}.json`); + assert.equal(statSync(claimPath).mode & 0o777, 0o600); + for (let index = 0; index < 128; index += 1) { + const runId = newRunId({ now: () => index + 1, random: () => Buffer.alloc(10, index) }); + store.createRun({ runId, itemRef: `item:260722-001#R${index}`, graph: testGraph }); + } + const result = await controller.submit(fixtureRunId, Buffer.from(`${JSON.stringify({ + schema: "burnlist-loop-host-result@1", outcome: "complete", + findings: [], resolvedFindingIds: [], telemetry: null, + })}\n`)); + assert.equal(result.currentNode, "review"); + assert.equal(result.hostTask, "awaiting-claim"); + assert.equal(existsSync(claimPath), true, "accepted claim indices remain available for exact retries"); + assert.equal(store.resolveClaimRef(claimId), fixtureRunId, "accepted report retries retain O(1) claim resolution"); +}); + +test("item projection ignores a corrupt tail in an unrelated retained Run", (t) => { + const root = mkdtempSync(join(os.tmpdir(), "m12-item-projection-")); t.after(() => rmSync(root, { recursive: true, force: true })); + const store = runStore(root, { publishProjection() {} }); + const targetRun = newRunId({ now: () => 1_000, random: () => Buffer.alloc(10, 1) }); + const unrelatedRun = newRunId({ now: () => 1_001, random: () => Buffer.alloc(10, 2) }); + const targetItem = "item:260722-001#TARGET"; + store.createRun({ runId: targetRun, itemRef: targetItem, graph: testGraph }); + store.createRun({ runId: unrelatedRun, itemRef: "item:260722-001#OTHER", graph: testGraph }); + writeFileSync(join(store.paths.journalFor(unrelatedRun), "0000000000000002.json"), "{broken\n"); + assert.throws(() => store.read(unrelatedRun), /journal/u); + assert.equal(readLatestRunForItem({ repoRoot: root, itemRef: targetItem }).runId, targetRun); +}); diff --git a/website/src/content/docs/install.mdx b/website/src/content/docs/install.mdx index e97b66ed..c5b2c136 100644 --- a/website/src/content/docs/install.mdx +++ b/website/src/content/docs/install.mdx @@ -65,6 +65,8 @@ native payload provides them. They may refine an already ACTIVE item to **progressing**, but cannot create canonical state, report a semantic outcome, advance a Loop, satisfy a gate, or complete work. See [Observation is not canonical state](/dashboard#observation-is-not-canonical-state). +Only an explicitly propagated invocation correlation may establish the first +session binding; unmatched sessions remain unbound even when one claim is live. The agent remains responsible for any first-run hook trust or consent prompt; Burnlist only writes configuration. `burnlist hooks status` reports each From ddbc3551da1ac2cecf297d3a1acee1deef5b844e Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sun, 26 Jul 2026 02:30:33 +0200 Subject: [PATCH 33/37] fix: refresh package asset manifest (E6) --- scripts/package-paths.json | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/scripts/package-paths.json b/scripts/package-paths.json index 2400680c..58cced83 100644 --- a/scripts/package-paths.json +++ b/scripts/package-paths.json @@ -2,8 +2,8 @@ "LICENSE", "README.md", "bin/burnlist.mjs", - "dashboard/dist/assets/index-Dcogl3ZJ.js", - "dashboard/dist/assets/index-yg-h1tMX.css", + "dashboard/dist/assets/index-C54NidcK.js", + "dashboard/dist/assets/index-DQ7BlCAP.css", "dashboard/dist/favicon.svg", "dashboard/dist/index.html", "loops/branch/branch.loop", @@ -161,6 +161,7 @@ "src/loops/run/binder.mjs", "src/loops/run/budgets.mjs", "src/loops/run/candidate.mjs", + "src/loops/run/claim-index.mjs", "src/loops/run/controller.mjs", "src/loops/run/current-authority.mjs", "src/loops/run/host-execution.mjs", From a3982ca73df33f5ea895e38d423c80736a86db2a Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sun, 26 Jul 2026 03:56:55 +0200 Subject: [PATCH 34/37] fix: keep retained Runs and native hooks operational (E6) --- README.md | 13 +- bin/burnlist.mjs | 1 + scripts/package-paths.json | 4 +- skills/burnlist/SKILL.md | 1 + skills/burnlist/references/installation.md | 7 +- src/cli/commands-help.test.mjs | 1 + src/cli/loop-cli.mjs | 18 ++- src/cli/loop-config-cli.mjs | 2 +- src/cli/loop-hook-cli.mjs | 5 + src/cli/loop-runtime-cli.test.mjs | 46 ++++++- src/loops/assignment/assignment.test.mjs | 28 ++++ src/loops/events/hook-context.mjs | 137 +++++++++++++++++-- src/loops/events/hook-observation.test.mjs | 102 ++++++++------ src/loops/run/read-projection.mjs | 24 ++-- src/loops/run/run-codec.mjs | 148 ++++++++++++++++++++- src/loops/run/run-store.mjs | 62 ++++----- src/loops/run/run-store.test.mjs | 19 ++- 17 files changed, 501 insertions(+), 117 deletions(-) diff --git a/README.md b/README.md index 6a0509ce..cd6b93a3 100644 --- a/README.md +++ b/README.md @@ -150,8 +150,11 @@ 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. An unmatched native session stays unbound; -Burnlist never assigns the only live claim by process-of-elimination. Codex +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. @@ -211,7 +214,11 @@ 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, -and proof-gated `reconcile` handles a demonstrably lost claim. Only a +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. diff --git a/bin/burnlist.mjs b/bin/burnlist.mjs index 65760122..dd34550b 100755 --- a/bin/burnlist.mjs +++ b/bin/burnlist.mjs @@ -139,6 +139,7 @@ Usage: 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 ] diff --git a/scripts/package-paths.json b/scripts/package-paths.json index 58cced83..9001e7bd 100644 --- a/scripts/package-paths.json +++ b/scripts/package-paths.json @@ -2,8 +2,8 @@ "LICENSE", "README.md", "bin/burnlist.mjs", - "dashboard/dist/assets/index-C54NidcK.js", - "dashboard/dist/assets/index-DQ7BlCAP.css", + "dashboard/dist/assets/index-DvGRoFbg.js", + "dashboard/dist/assets/index-TGHtQcZg.css", "dashboard/dist/favicon.svg", "dashboard/dist/index.html", "loops/branch/branch.loop", diff --git a/skills/burnlist/SKILL.md b/skills/burnlist/SKILL.md index 40d8212d..7682b56c 100644 --- a/skills/burnlist/SKILL.md +++ b/skills/burnlist/SKILL.md @@ -154,6 +154,7 @@ Burnlist has two independent installable systems. Either or both may be present: - **Skill discovery** (`burnlist install`) makes this Burnlist skill discoverable to both agents. The default is a per-repository, untracked-local registration in `/.claude/skills/burnlist` for Claude Code and `/.agents/skills/burnlist` for Codex. `--global` instead uses `~/.claude/skills/burnlist` and `~/.agents/skills/burnlist`; a global npm installation of Burnlist automatically registers both global skills. Use `--commit` only for a per-repository portable copy intended for Git; `--agent codex,claude` limits targets and `--dry-run` previews. `burnlist uninstall` is the inverse; `burnlist uninstall --global --purge` also removes the global npm package. - **Native observability hooks** (`burnlist hooks install`) install per-repository edit-capture and advisory Loop-observation commands, not skills. Codex consumes `/.codex/hooks.json`; Claude Code consumes `/.claude/settings.json`. They invoke `burnlist streaming-diff hook` around edits and `burnlist hooks observe` for supported native lifecycle events, merging with existing entries. Observations are bounded local facts and never semantic Loop outcomes. Hooks have no global mode: use `burnlist hooks uninstall` or `burnlist hooks status` in the repository, optionally with `--agent codex,claude`. `--untracked` asks install to add the config to `.git/info/exclude`; it cannot hide an already tracked config. +- **Run retention** keeps operational reads bounded without a global history cap. `burnlist loop list` returns the newest bounded window; item hazards validate every relevant Run and fail closed on relevant corruption. `burnlist loop prune --retain ` explicitly archives only safely terminal, non-current Runs and never deletes active/converged authority. Install only the system the task needs, or both. Read `references/installation.md` for exact commands, ownership, and shared-versus-local behavior. diff --git a/skills/burnlist/references/installation.md b/skills/burnlist/references/installation.md index 3ac4e2d1..eb85fffd 100644 --- a/skills/burnlist/references/installation.md +++ b/skills/burnlist/references/installation.md @@ -51,8 +51,11 @@ advisory observation entries retain only bounded lifecycle, hashed session/agent identity, tool name, contained file paths, timing, model, effort, and token usage when the native payload exposes them. They publish to ignored local Oven event state and cannot report a semantic outcome or advance a Loop. -Only an explicitly propagated invocation correlation may establish the first -session binding; an unmatched session is ignored even when one claim is live. +An exact native `PreToolUse` for `burnlist loop next|claim` prepares a +short-lived hashed session/tool/Run tuple. The matching native `PostToolUse` +may bind that session only after the exact Run's claim is durable; no invented +payload field or singleton process-of-elimination is accepted. An unmatched +session is ignored even when one claim is live. Codex hook support requires Codex CLI 0.124.0 or newer; `status` reports whether the installed CLI can run the configured hooks. The commands require `burnlist` on the host `PATH`; each agent may still ask for hook trust or diff --git a/src/cli/commands-help.test.mjs b/src/cli/commands-help.test.mjs index 6873e32c..ee86789d 100644 --- a/src/cli/commands-help.test.mjs +++ b/src/cli/commands-help.test.mjs @@ -54,6 +54,7 @@ test("top-level and Oven help expose the validated use and set flow", () => { assert.match(top.stdout, /burnlist loop report \(--outcome \| --result \) \[--repo \]/u); assert.match(top.stdout, /burnlist loop abandon --reason \[--repo \]/u); assert.match(top.stdout, /burnlist loop list \[--repo \]/u); + assert.match(top.stdout, /burnlist loop prune \[--retain \] \[--repo \]/u); assert.doesNotMatch(top.stdout, /burnlist loop run\|resume/u); assert.match(top.stdout, /burnlist loop status\|inspect \[--repo \]/u); assert.match(top.stdout, /burnlist loop pause\|stop \[--repo \] \(idle Run only\)/u); diff --git a/src/cli/loop-cli.mjs b/src/cli/loop-cli.mjs index 82d4800d..b9e65c14 100644 --- a/src/cli/loop-cli.mjs +++ b/src/cli/loop-cli.mjs @@ -19,7 +19,7 @@ import { function usageText() { return loopConfigUsage(); } function usageError(message = usageText()) { return Object.assign(new Error(message), { exitCode: 2 }); } function options(tokens) { - const positionals = []; let repo = null, recoveryProof = null, resultFile = null, reason = null, outcome = null; + const positionals = []; let repo = null, recoveryProof = null, resultFile = null, reason = null, outcome = null, retain = null; for (let index = 0; index < tokens.length; index += 1) { if (tokens[index] === "--repo") { if (repo !== null) throw usageError("--repo must be specified at most once."); @@ -44,10 +44,18 @@ function options(tokens) { if (!["complete", "approve", "reject", "escalate"].includes(outcome ?? "")) throw usageError("--outcome requires complete, approve, reject, or escalate."); } + else if (tokens[index] === "--retain") { + if (retain !== null) throw usageError("--retain must be specified at most once."); + const value = tokens[++index]; + if (!/^(?:0|[1-9][0-9]{0,6})$/u.test(value ?? "") || Number(value) > 1_000_000) + throw usageError("--retain requires an integer from 0 through 1000000."); + retain = Number(value); + } else if (tokens[index].startsWith("--")) throw usageError(`Unknown option: ${tokens[index]}`); else positionals.push(tokens[index]); } - return { positionals, recoveryProof, resultFile, reason, outcome, repo: repo ? resolve(process.cwd(), repo) : resolveUmbrella(process.cwd()) }; + return { positionals, recoveryProof, resultFile, reason, outcome, retain, + repo: repo ? resolve(process.cwd(), repo) : resolveUmbrella(process.cwd()) }; } function validateVerbOptions(verb, opts) { if (opts.recoveryProof && verb !== "reconcile") throw usageError(); @@ -55,6 +63,7 @@ function validateVerbOptions(verb, opts) { || ["report", "submit"].includes(verb) && Boolean(opts.resultFile) === Boolean(opts.outcome) || verb === "report" && opts.outcome && !["complete", "approve"].includes(opts.outcome)) throw usageError(); if (opts.reason && verb !== "abandon" || verb === "abandon" && !opts.reason) throw usageError(); + if (opts.retain !== null && verb !== "prune") throw usageError(); } function simpleReport(envelopeBytes, outcome) { const execution = validateHostExecutionEnvelope(envelopeBytes).value; @@ -111,8 +120,8 @@ export async function runLoopCli(tokens, { runReader, runnerFor, stdout = proces const result = completeLoopRun({ repoRoot: opts.repo, runId: opts.positionals[0] }); stdout.write(`${JSON.stringify({ schema: "burnlist-loop-completion@1", ...result })}\n`); return result; } - if (["list", "status", "inspect", "next", "claim", "submit", "report", "abandon", "pause", "stop", "reconcile"].includes(verb)) { - const allowed = verb === "list" ? 0 : 1; + if (["list", "prune", "status", "inspect", "next", "claim", "submit", "report", "abandon", "pause", "stop", "reconcile"].includes(verb)) { + const allowed = ["list", "prune"].includes(verb) ? 0 : 1; if (opts.positionals.length !== allowed) throw usageError(); const store = runStore(opts.repo); const suppliedRunnerFor = runnerFor ?? ((runId) => createStoredSystemRunRunner({ repoRoot: opts.repo, store, runId })); @@ -122,6 +131,7 @@ export async function runLoopCli(tokens, { runReader, runnerFor, stdout = proces }; const controller = createLoopController({ store, runnerFor: runtimeRunnerFor, repoRoot: opts.repo }); const result = verb === "list" ? controller.list() + : verb === "prune" ? store.prune(opts.retain ?? 128) : verb === "status" ? controller.status(opts.positionals[0]) : verb === "inspect" ? controller.inspect(opts.positionals[0]) : verb === "next" ? controller.next(opts.positionals[0]) diff --git a/src/cli/loop-config-cli.mjs b/src/cli/loop-config-cli.mjs index 68524251..fae6c41b 100644 --- a/src/cli/loop-config-cli.mjs +++ b/src/cli/loop-config-cli.mjs @@ -6,7 +6,7 @@ import { renderSetupStatus, setupStatus } from "../loops/config/setup.mjs"; import { rawSha256 } from "../loops/dsl/hash.mjs"; import { resolveUmbrella } from "./umbrella.mjs"; -const loopUsage = "Usage: burnlist loop assign [--repo ] | burnlist loop unassign [--repo ] | burnlist loop view [--repo ]\n burnlist loop create [--repo ]\n burnlist loop next [--repo ]\n burnlist loop submit (--outcome | --result ) [--repo ]\n burnlist loop claim [--repo ] (recovery/diagnostics)\n burnlist loop report (--outcome | --result ) [--repo ] (recovery/diagnostics)\n burnlist loop abandon --reason [--repo ]\n burnlist loop pause|stop|complete [--repo ]\n burnlist loop list [--repo ] (Runs only; use loop view for assignments)\n burnlist loop status|inspect [--repo ]\n burnlist loop reconcile --recovery-proof [--repo ]\n burnlist loop capability inspect [--repo ]\n burnlist loop capability trust --revision cp1-sha256: --grants [--repo ]\n burnlist loop setup status [--repo ]"; +const loopUsage = "Usage: burnlist loop assign [--repo ] | burnlist loop unassign [--repo ] | burnlist loop view [--repo ]\n burnlist loop create [--repo ]\n burnlist loop next [--repo ]\n burnlist loop submit (--outcome | --result ) [--repo ]\n burnlist loop claim [--repo ] (recovery/diagnostics)\n burnlist loop report (--outcome | --result ) [--repo ] (recovery/diagnostics)\n burnlist loop abandon --reason [--repo ]\n burnlist loop pause|stop|complete [--repo ]\n burnlist loop list [--repo ] (Runs only; use loop view for assignments)\n burnlist loop prune [--retain ] [--repo ] (archives safe historical Runs)\n burnlist loop status|inspect [--repo ]\n burnlist loop reconcile --recovery-proof [--repo ]\n burnlist loop capability inspect [--repo ]\n burnlist loop capability trust --revision cp1-sha256: --grants [--repo ]\n burnlist loop setup status [--repo ]"; function fail(message, exitCode = 2) { throw Object.assign(new Error(message), { exitCode }); } function parse(tokens, allowed = []) { diff --git a/src/cli/loop-hook-cli.mjs b/src/cli/loop-hook-cli.mjs index 8779bbd3..a3962c57 100644 --- a/src/cli/loop-hook-cli.mjs +++ b/src/cli/loop-hook-cli.mjs @@ -1,5 +1,6 @@ import { resolveUmbrella } from "./umbrella.mjs"; import { publishNativeLoopObservation } from "../loops/events/hook-observation.mjs"; +import { bindPreparedLoopHookContext, prepareLoopHookBinding } from "../loops/events/hook-context.mjs"; const MAX_BYTES = 256 * 1024; const READ_TIMEOUT_MS = 750; @@ -40,6 +41,10 @@ export async function runLoopObservationHook({ provider, input = process.stdin } const payload = parseLoopHookPayload(await readStdinCapped(input)); if (!payload) return null; const repoRoot = resolveUmbrella(payload.cwd ?? process.cwd()); + if (payload.hook_event_name === "PreToolUse") + prepareLoopHookBinding(repoRoot, { provider, payload }); + if (payload.hook_event_name === "PostToolUse") + bindPreparedLoopHookContext(repoRoot, { provider, payload }); return publishNativeLoopObservation({ repoRoot, provider, payload }); } catch { return null; } } diff --git a/src/cli/loop-runtime-cli.test.mjs b/src/cli/loop-runtime-cli.test.mjs index d25a7f6b..e2cfc1a2 100644 --- a/src/cli/loop-runtime-cli.test.mjs +++ b/src/cli/loop-runtime-cli.test.mjs @@ -1,11 +1,14 @@ import assert from "node:assert/strict"; -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { spawnSync } from "node:child_process"; import test from "node:test"; import { createProductionRunAuthority, fixtureItemRef } from "../loops/run/run-test-fixtures.mjs"; +import { runStore } from "../loops/run/run-store.mjs"; +import { testGraph } from "../loops/run/m2-test-fixtures.mjs"; +import { newRunId } from "../loops/run/run-codec.mjs"; const root = resolve(dirname(fileURLToPath(import.meta.url)), "..", ".."); const cli = join(root, "bin", "burnlist.mjs"); @@ -50,6 +53,47 @@ test("host-only CLI exposes stable reads and no managed run command", (t) => { } }); +test("production list and prune remain operational beyond 128 retained Runs", { timeout: 30_000 }, (t) => { + const directory = mkdtempSync(join(tmpdir(), "retained-loop-cli-")); + t.after(() => rmSync(directory, { recursive: true, force: true })); + const { repo } = createProductionRunAuthority(join(directory, "repo")); + const store = runStore(repo, { publishProjection() {} }), runIds = []; + for (let index = 0; index < 130; index += 1) { + const runId = newRunId({ now: () => index + 1, + random: () => Buffer.alloc(10, (index % 250) + 1) }); + runIds.push(runId); + store.createRun({ runId, itemRef: `item:260722-001#H${index}`, graph: testGraph }); + } + store.terminalize(runIds[0], store.acquireLease(runIds[0]).lease, "cancelled", "retention"); + store.terminalize(runIds[2], store.acquireLease(runIds[2]).lease, "error", "retention"); + const listed = JSON.parse(command(repo, ["list"])); + assert.equal(listed.length, 128); + assert.equal(listed.some((entry) => entry.runId === runIds[0]), false); + assert.equal(listed.some((entry) => entry.runId === runIds.at(-1)), true); + const pruned = JSON.parse(command(repo, ["prune", "--retain", "128"])); + assert.deepEqual(pruned, { + schema: "burnlist-loop-retention@1", retain: 128, before: 130, + archived: 2, retained: 128, protected: 128, + }); + assert.equal(existsSync(join(store.paths.archiveRuns, Buffer.from(runIds[0]).toString("hex"))), true); + assert.equal(existsSync(join(store.paths.archiveRuns, Buffer.from(runIds[2]).toString("hex"))), true); + assert.equal(existsSync(store.paths.pathFor(runIds[1])), true, "nonterminal history stays protected"); + assert.equal(JSON.parse(command(repo, ["prune", "--retain", "128"])).archived, 0); +}); + +test("prune protects a safely terminal Run while it remains current authority", (t) => { + const directory = mkdtempSync(join(tmpdir(), "current-retention-loop-cli-")); + t.after(() => rmSync(directory, { recursive: true, force: true })); + const { repo } = createProductionRunAuthority(join(directory, "repo")); + const runId = created(repo); + assert.equal(JSON.parse(command(repo, ["stop", runId])).state, "stopped"); + assert.deepEqual(JSON.parse(command(repo, ["prune", "--retain", "0"])), { + schema: "burnlist-loop-retention@1", retain: 0, before: 1, + archived: 0, retained: 1, protected: 1, + }); + assert.equal(existsSync(runStore(repo).paths.pathFor(runId)), true); +}); + test("claim and report advance checks and gates without launching a provider", (t) => { const directory = mkdtempSync(join(tmpdir(), "host-report-loop-cli-")); t.after(() => rmSync(directory, { recursive: true, force: true })); diff --git a/src/loops/assignment/assignment.test.mjs b/src/loops/assignment/assignment.test.mjs index a1645b83..2439dcba 100644 --- a/src/loops/assignment/assignment.test.mjs +++ b/src/loops/assignment/assignment.test.mjs @@ -13,6 +13,7 @@ import { parseItemRef, parseLoopRef, parseRunRef, selectorKind } from "./selecto import { assignmentStore } from "./store.mjs"; import { runStore } from "../run/run-store.mjs"; import { testGraph } from "../run/m2-test-fixtures.mjs"; +import { newRunId } from "../run/run-codec.mjs"; const project = resolve(new URL("../../..", import.meta.url).pathname); function fixture() { @@ -180,6 +181,33 @@ test("repository hazard authority reads production Runs and permits only safe te } finally { context.cleanup(); } }); +test("129 retained Runs cannot brick production unassign or direct Burn hazards", { timeout: 30_000 }, async () => { + const context = fixture(); + try { + await assignLoopItem({ repoRoot: context.repo, itemRef: ref(), loopRef: "loop:builtin:review" }); + const store = runStore(context.repo, { publishProjection() {} }); + const target = newRunId({ now: () => 1, random: () => Buffer.alloc(10, 1) }); + store.createRun({ runId: target, itemRef: ref(), graph: testGraph }); + for (let index = 0; index < 128; index += 1) { + const runId = newRunId({ now: () => index + 2, + random: () => Buffer.alloc(10, (index % 250) + 2) }); + store.createRun({ runId, itemRef: ref(), graph: testGraph }); + store.terminalize(runId, store.acquireLease(runId).lease, "cancelled", "safe history"); + } + assert.equal(store.listForItem(ref()).length, 128, + "the item projection is a bounded window beyond 128 histories"); + assert.deepEqual(repositoryHazardAuthority(context.repo)({ itemRef: ref() }), + [`nonterminal Run ${target}`], "the oldest relevant unsafe Run remains fail-closed"); + store.terminalize(target, store.acquireLease(target).lease, "cancelled", "safe history"); + assert.deepEqual(repositoryHazardAuthority(context.repo)({ itemRef: ref() }), []); + const bin = join(project, "bin", "burnlist.mjs"); + assert.match(execFileSync(process.execPath, + [bin, "loop", "unassign", ref(), "--repo", context.repo], + { cwd: context.repo, encoding: "utf8" }), /^as1-sha256:/u); + assert.doesNotThrow(() => burnItem(context.repo, "260710-001", "BUG-07")); + } finally { context.cleanup(); } +}); + test("repository hazard authority scopes sealed authority corruption to its item", () => { const context = fixture(); try { diff --git a/src/loops/events/hook-context.mjs b/src/loops/events/hook-context.mjs index afffcf73..84b248c6 100644 --- a/src/loops/events/hook-context.mjs +++ b/src/loops/events/hook-context.mjs @@ -1,11 +1,15 @@ import { createHash } from "node:crypto"; import { existsSync, readFileSync } from "node:fs"; +import { resolve } from "node:path"; import { containedJoin, withRepoStateLock } from "../../server/repo-state.mjs"; import { writeAtomicText } from "../../cli/local-exclude.mjs"; import { runStore } from "../run/run-store.mjs"; +import { isRunRef } from "../run/run-ref.mjs"; const SCHEMA = "burnlist-loop-hook-context@1"; +const BINDING_SCHEMA = "burnlist-loop-hook-bindings@1"; const MAX_CONTEXTS = 32; +const MAX_BINDINGS = 128; const MAX_BYTES = 64 * 1024; const safe = (value, maximum = 256) => typeof value === "string" && value.length > 0 && value.length <= maximum && !/[\u0000-\u001f\u007f]/u.test(value); @@ -13,6 +17,7 @@ const digest = (kind, value) => `${kind}1-sha256:${createHash("sha256") .update(`burnlist-${kind}-v1\0${value}`).digest("hex")}`; function contextPath(repoRoot) { return containedJoin(repoRoot, "loop-hook-context.json"); } +function bindingPath(repoRoot) { return containedJoin(repoRoot, "loop-hook-bindings.json"); } function empty() { return { schema: SCHEMA, contexts: [] }; } function exact(value, keys) { return value && typeof value === "object" && !Array.isArray(value) @@ -54,6 +59,37 @@ function writeContext(repoRoot, value) { if (Buffer.byteLength(text) > MAX_BYTES) throw new Error("Loop hook context exceeds bounds."); writeAtomicText(contextPath(repoRoot), text); } +function validateBinding(value) { + const keys = ["runId", "session", "agent", "tool", "createdAt", "expiresAt"]; + if (!exact(value, keys) || !isRunRef(value.runId) + || !/^session1-sha256:[a-f0-9]{64}$/u.test(value.session) + || value.agent !== null && !/^agent1-sha256:[a-f0-9]{64}$/u.test(value.agent) + || !/^tool1-sha256:[a-f0-9]{64}$/u.test(value.tool) + || !Number.isSafeInteger(value.createdAt) || !Number.isSafeInteger(value.expiresAt) + || value.expiresAt <= value.createdAt) throw new Error("Loop hook binding is invalid."); + return value; +} +function readBindings(repoRoot) { + const path = bindingPath(repoRoot); + if (!existsSync(path)) return { schema: BINDING_SCHEMA, bindings: [] }; + const bytes = readFileSync(path); + if (bytes.length < 2 || bytes.length > MAX_BYTES) throw new Error("Loop hook bindings exceed bounds."); + let value; + try { value = JSON.parse(bytes); } catch { throw new Error("Loop hook bindings are malformed."); } + if (!exact(value, ["schema", "bindings"]) || value.schema !== BINDING_SCHEMA + || !Array.isArray(value.bindings) || value.bindings.length > MAX_BINDINGS) + throw new Error("Loop hook bindings are invalid."); + value.bindings.forEach(validateBinding); + if (!Buffer.from(`${JSON.stringify(value)}\n`).equals(bytes)) + throw new Error("Loop hook bindings are not canonical."); + return value; +} +function writeBindings(repoRoot, bindings) { + const value = { schema: BINDING_SCHEMA, bindings }; + const text = `${JSON.stringify(value)}\n`; + if (Buffer.byteLength(text) > MAX_BYTES) throw new Error("Loop hook bindings exceed bounds."); + writeAtomicText(bindingPath(repoRoot), text); +} function live(repoRoot, context, now) { if (context.expiresAt <= now) return false; try { @@ -64,7 +100,7 @@ function live(repoRoot, context, now) { } catch { return false; } } -/** Publish one pending correlation tuple after the canonical claim is durable. */ +/** Publish one pending observational context after the canonical claim is durable. */ export function activateLoopHookContext(repoRoot, { replay, claim, envelope }, { now = Date.now(), } = {}) { @@ -92,29 +128,102 @@ function observedIdentity(payload, provider) { const agent = safe(rawAgent) ? digest("agent", `${provider}\0${rawAgent}`) : null; return { session, agent }; } -function explicitCorrelation(payload) { - const value = payload?.burnlist_correlation; - if (!exact(value, ["claimId", "invocationId"]) - || !/^cl1-sha256:[a-f0-9]{64}$/u.test(value.claimId) - || !/^iv1-sha256:[a-f0-9]{64}$/u.test(value.invocationId)) return null; - return value; + +function shellWords(command) { + if (!safe(command, 4096)) return null; + const words = []; let word = "", active = false, quote = null; + for (const character of command) { + if (quote) { + if (character === quote) { quote = null; active = true; continue; } + if (/[\u0000-\u001f\u007f]/u.test(character) + || quote === "\"" && /[$`\\]/u.test(character)) return null; + word += character; active = true; continue; + } + if (character === "'" || character === "\"") { quote = character; active = true; continue; } + if (/\s/u.test(character)) { + if (active) { words.push(word); word = ""; active = false; } + continue; + } + if (!/[A-Za-z0-9_./:@#=-]/u.test(character)) return null; + word += character; active = true; + } + if (quote) return null; + if (active) words.push(word); + return words; +} +function claimCommand(repoRoot, payload) { + if (payload?.hook_event_name !== "PreToolUse" && payload?.hook_event_name !== "PostToolUse" + || payload?.tool_name !== "Bash" || !safe(payload?.tool_use_id) + || !payload.tool_input || typeof payload.tool_input !== "object") return null; + const words = shellWords(payload.tool_input.command); + if (!words || ![4, 6].includes(words.length) || words[0] !== "burnlist" + || words[1] !== "loop" || !["next", "claim"].includes(words[2]) || !isRunRef(words[3]) + || words.length === 6 && words[4] !== "--repo") return null; + if (words.length === 6 && resolve(payload.cwd, words[5]) !== resolve(repoRoot)) return null; + return { runId: words[3], toolUseId: payload.tool_use_id }; +} +function bindingIdentity(provider, payload, command) { + const identity = observedIdentity(payload, provider); + if (!identity.session) return null; + return { ...identity, tool: digest("tool", + `${provider}\0${payload.session_id}\0${command.toolUseId}`) }; +} + +/** Prepare an exact native session/tool/Run tuple before the Burnlist claim command executes. */ +export function prepareLoopHookBinding(repoRoot, { provider, payload, now = Date.now() }) { + if (!["codex", "claude"].includes(provider)) return null; + const command = claimCommand(repoRoot, payload); + const identity = command && bindingIdentity(provider, payload, command); + if (!identity) return null; + return withRepoStateLock(repoRoot, () => { + const stored = readBindings(repoRoot).bindings + .filter((entry) => entry.expiresAt > now && entry.tool !== identity.tool); + stored.push(validateBinding({ + runId: command.runId, session: identity.session, agent: identity.agent, + tool: identity.tool, createdAt: now, expiresAt: now + 60_000, + })); + writeBindings(repoRoot, stored.slice(-MAX_BINDINGS)); + return command.runId; + }); +} + +/** Bind the matching native identity only after that exact tool call made its claim durable. */ +export function bindPreparedLoopHookContext(repoRoot, { provider, payload, now = Date.now() }) { + if (!["codex", "claude"].includes(provider)) return null; + const command = claimCommand(repoRoot, payload); + const identity = command && bindingIdentity(provider, payload, command); + if (!identity || payload.hook_event_name !== "PostToolUse") return null; + return withRepoStateLock(repoRoot, () => { + const stored = readBindings(repoRoot); + const liveBindings = stored.bindings.filter((entry) => entry.expiresAt > now); + const matches = liveBindings.filter((entry) => entry.tool === identity.tool + && entry.session === identity.session && entry.runId === command.runId); + writeBindings(repoRoot, liveBindings.filter((entry) => !matches.includes(entry))); + if (matches.length !== 1) return null; + const contexts = readContext(repoRoot).contexts.filter((entry) => live(repoRoot, entry, now)); + const selected = contexts.filter((entry) => entry.runId === command.runId + && entry.createdAt >= matches[0].createdAt); + if (selected.length !== 1) { + writeContext(repoRoot, { schema: SCHEMA, contexts }); + return null; + } + if (!selected[0].sessions.includes(identity.session)) selected[0].sessions.push(identity.session); + if (identity.agent && !selected[0].agents.includes(identity.agent)) + selected[0].agents.push(identity.agent); + writeContext(repoRoot, { schema: SCHEMA, contexts }); + return command.runId; + }); } /** Resolve and bind native identities without exposing their raw values. */ export function resolveLoopHookContext(repoRoot, { provider, payload, now = Date.now() }) { - const identity = observedIdentity(payload, provider), correlation = explicitCorrelation(payload); + const identity = observedIdentity(payload, provider); if (!identity.session) return null; return withRepoStateLock(repoRoot, () => { const stored = readContext(repoRoot); const contexts = stored.contexts.filter((entry) => live(repoRoot, entry, now)); let match = contexts.filter((entry) => entry.sessions.includes(identity.session) || identity.agent && entry.agents.includes(identity.agent)); - if (correlation) { - const explicit = contexts.filter((entry) => entry.claimId === correlation.claimId - && entry.invocationId === correlation.invocationId); - if (explicit.length !== 1 || match.length && !match.includes(explicit[0])) match = []; - else match = explicit; - } if (match.length !== 1) { if (contexts.length !== stored.contexts.length) writeContext(repoRoot, { schema: SCHEMA, contexts }); diff --git a/src/loops/events/hook-observation.test.mjs b/src/loops/events/hook-observation.test.mjs index b150ae55..ceba32f1 100644 --- a/src/loops/events/hook-observation.test.mjs +++ b/src/loops/events/hook-observation.test.mjs @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; -import { mkdtempSync, rmSync } from "node:fs"; +import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; @@ -9,6 +9,7 @@ import { createProductionRunAuthority, fixtureItemRef } from "../run/run-test-fi import { readOvenEvents } from "../../events/oven-event-store.mjs"; import { readLatestRunForItem } from "../run/read-projection.mjs"; import { publishNativeLoopObservation } from "./hook-observation.mjs"; +import { updateHookConfigs } from "../../cli/hooks-config.mjs"; const root = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..", ".."); const cli = join(root, "bin", "burnlist.mjs"); @@ -23,28 +24,50 @@ function fixture(t) { t.after(() => rmSync(directory, { recursive: true, force: true })); const { repo } = createProductionRunAuthority(join(directory, "repo")); const runId = JSON.parse(command(repo, ["create", fixtureItemRef])).runId; - const execution = JSON.parse(command(repo, ["claim", runId])).execution; - return { - repo, runId, - correlation: { claimId: execution.claimId, invocationId: execution.invocationId }, - }; + return { repo, runId }; } function codexPayload(repo, event, patch = {}) { return { - session_id: "codex-session", cwd: repo, hook_event_name: event, + session_id: "codex-session", transcript_path: null, cwd: repo, + permission_mode: "default", hook_event_name: event, model: "gpt-5.6-sol", turn_id: "turn-1", ...patch, }; } +function observe(repo, provider, payload) { + const path = join(repo, provider === "codex" ? ".codex/hooks.json" : ".claude/settings.json"); + if (!existsSync(path)) + updateHookConfigs({ repoRoot: repo, agents: [provider], install: true }); + const config = JSON.parse(readFileSync(path, "utf8")); + const installed = config.hooks[payload.hook_event_name].flatMap((entry) => entry.hooks) + .find((entry) => entry.command === `burnlist hooks observe --agent ${provider}`); + assert.ok(installed, `${provider} installed config routes ${payload.hook_event_name}`); + const [binary, ...args] = installed.command.split(" "); + assert.equal(binary, "burnlist"); + const result = spawnSync(process.execPath, [cli, ...args], + { cwd: repo, encoding: "utf8", input: JSON.stringify(payload) }); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stdout, ""); +} +function nativeClaim(repo, runId, provider, payloadFor) { + const toolUseId = `${provider}-claim-tool`; + const toolInput = { command: `burnlist loop claim ${runId} --repo ${repo}` }; + const pre = payloadFor("PreToolUse", { tool_name: "Bash", tool_use_id: toolUseId, + tool_input: toolInput }); + observe(repo, provider, pre); + const execution = JSON.parse(command(repo, ["claim", runId])).execution; + observe(repo, provider, payloadFor("PostToolUse", { + tool_name: "Bash", tool_use_id: toolUseId, tool_input: toolInput, + tool_response: provider === "codex" + ? { output: JSON.stringify({ execution }), exit_code: 0 } + : { stdout: JSON.stringify({ execution }), stderr: "", interrupted: false }, + ...(provider === "claude" ? { duration_ms: 12 } : {}), + })); + return execution; +} test("native lifecycle and tool hooks publish correlated observational events", (t) => { - const { repo, runId, correlation } = fixture(t); - const start = publishNativeLoopObservation({ - repoRoot: repo, provider: "codex", - payload: codexPayload(repo, "SessionStart", { - source: "startup", burnlist_correlation: correlation, - }), - }); - assert.equal(start.authority, "observational"); + const { repo, runId } = fixture(t); + nativeClaim(repo, runId, "codex", (event, patch) => codexPayload(repo, event, patch)); const tool = publishNativeLoopObservation({ repoRoot: repo, provider: "codex", payload: codexPayload(repo, "PostToolUse", { @@ -52,6 +75,7 @@ test("native lifecycle and tool hooks publish correlated observational events", tool_input: { command: "*** Begin Patch\n*** Update File: src/example.mjs\n*** End Patch" }, }), }); + assert.equal(tool.authority, "observational"); assert.equal(tool.payload.model, "gpt-5.6-sol"); assert.deepEqual(tool.payload.observedPaths, ["src/example.mjs"]); assert.equal(tool.payload.effort, null); @@ -67,26 +91,23 @@ test("native lifecycle and tool hooks publish correlated observational events", }); test("Claude facts retain exposed effort and usage while missing fields stay null", (t) => { - const { repo, runId, correlation } = fixture(t); - publishNativeLoopObservation({ - repoRoot: repo, provider: "claude", - payload: { - session_id: "claude-session", cwd: repo, hook_event_name: "SessionStart", - source: "startup", model: "claude-opus-4-6", - burnlist_correlation: correlation, - }, + const { repo, runId } = fixture(t); + const claudePayload = (event, patch = {}) => ({ + session_id: "claude-session", transcript_path: join(repo, "transcript.jsonl"), + cwd: repo, permission_mode: "default", hook_event_name: event, + prompt_id: "prompt-1", ...patch, }); + nativeClaim(repo, runId, "claude", claudePayload); const event = publishNativeLoopObservation({ repoRoot: repo, provider: "claude", - payload: { - session_id: "claude-session", cwd: repo, hook_event_name: "PostToolUse", - prompt_id: "prompt-1", effort: { level: "xhigh" }, tool_name: "Agent", + payload: claudePayload("PostToolUse", { + effort: { level: "xhigh" }, tool_name: "Agent", tool_use_id: "tool-agent-1", tool_input: {}, tool_response: { resolvedModel: "claude-sonnet-4-6", usage: { input_tokens: 8320, output_tokens: 412 }, }, - }, + }), }); assert.equal(event.payload.model, "claude-sonnet-4-6"); assert.equal(event.payload.effort, "xhigh"); @@ -102,14 +123,15 @@ test("Claude facts retain exposed effort and usage while missing fields stay nul inputTokens: 8320, outputTokens: 412, }); assert.equal(readOvenEvents(repo, { ovenIds: ["checklist"], limit: 100 }) - .filter((entry) => entry.payload?.runId === runId).length, 2); + .filter((entry) => entry.payload?.runId === runId).length, 2, + "claim PostToolUse and Agent PostToolUse publish through the native session binding"); }); test("hook replay cannot report outcomes or advance canonical Run state", (t) => { - const { repo, runId, correlation } = fixture(t); + const { repo, runId } = fixture(t); + nativeClaim(repo, runId, "codex", (event, patch) => codexPayload(repo, event, patch)); const payload = codexPayload(repo, "SubagentStop", { agent_id: "agent-1", agent_type: "worker", outcome: "complete", - burnlist_correlation: correlation, }); const first = publishNativeLoopObservation({ repoRoot: repo, provider: "codex", payload }); const second = publishNativeLoopObservation({ repoRoot: repo, provider: "codex", payload }); @@ -126,31 +148,31 @@ test("hook replay cannot report outcomes or advance canonical Run state", (t) => }); test("an unmatched singleton session cannot claim the only live Loop context", (t) => { - const { repo, runId, correlation } = fixture(t); + const { repo, runId } = fixture(t); + nativeClaim(repo, runId, "codex", (event, patch) => + codexPayload(repo, event, { session_id: "matched-session", ...patch })); assert.equal(publishNativeLoopObservation({ repoRoot: repo, provider: "codex", payload: codexPayload(repo, "SessionStart", { session_id: "unmatched-session", source: "startup", }), }), null); - assert.equal(readOvenEvents(repo, { ovenIds: ["checklist"], limit: 100 }) - .filter((entry) => entry.payload?.runId === runId).length, 0); + const before = readOvenEvents(repo, { ovenIds: ["checklist"], limit: 100 }) + .filter((entry) => entry.payload?.runId === runId).length; const matched = publishNativeLoopObservation({ repoRoot: repo, provider: "codex", payload: codexPayload(repo, "SessionStart", { - session_id: "explicit-session", source: "startup", - burnlist_correlation: correlation, + session_id: "matched-session", source: "startup", }), }); assert.equal(matched.payload.runId, runId); + assert.equal(readOvenEvents(repo, { ovenIds: ["checklist"], limit: 100 }) + .filter((entry) => entry.payload?.runId === runId).length, before + 1); assert.equal(publishNativeLoopObservation({ repoRoot: repo, provider: "codex", payload: codexPayload(repo, "PostToolUse", { - session_id: "explicit-session", tool_name: "Bash", tool_use_id: "wrong-correlation", - burnlist_correlation: { - claimId: `cl1-sha256:${"c".repeat(64)}`, - invocationId: `iv1-sha256:${"d".repeat(64)}`, - }, + session_id: "unmatched-session", tool_name: "Bash", tool_use_id: "wrong-correlation", + tool_input: { command: "git status --short" }, tool_response: { output: "", exit_code: 0 }, }), }), null); }); diff --git a/src/loops/run/read-projection.mjs b/src/loops/run/read-projection.mjs index a67313a7..88ef2996 100644 --- a/src/loops/run/read-projection.mjs +++ b/src/loops/run/read-projection.mjs @@ -1,7 +1,6 @@ -import { existsSync, lstatSync, readFileSync, readdirSync } from "node:fs"; +import { existsSync, lstatSync, readFileSync } from "node:fs"; import { join, resolve } from "node:path"; import { readInitialJournal } from "./run-journal.mjs"; -import { isRunRef } from "./run-ref.mjs"; import { locateItemSpan, validateAssignedItem } from "../assignment/item-metadata.mjs"; import { assignmentStore } from "../assignment/store.mjs"; import { currentRunAuthority } from "./current-authority.mjs"; @@ -9,8 +8,8 @@ import { runStore } from "./run-store.mjs"; import { projectRunActivity } from "../events/activity-projection.mjs"; import { readLoopObservationRecords } from "../events/hook-observation.mjs"; import { forecastLoopRun } from "../forecast/forecast.mjs"; +import { visitRunDirectories } from "./run-codec.mjs"; -const MAX_RUNS = 128; const RECEIPT_KEYS = ["schema", "runId", "itemRef", "assignmentId", "completedAt", "title", "planDigest"]; const fail = (message) => { throw Object.assign(new Error(`Run projection: ${message}`), { code: "ERUN_PROJECTION" }); }; @@ -147,21 +146,16 @@ export function readLatestRunForItem({ repoRoot, itemRef, markdown = null, itemI || artifact && JSON.stringify(selected.graph) !== JSON.stringify(artifact.frozen.ir)) fail("current Run is unavailable", "ECURRENT"); } else { - let entries; - try { entries = readdirSync(runs, { withFileTypes: true }); } catch { fail("Run projection is corrupt", "ECORRUPT"); } - const staging = entries.filter((entry) => /^\.create-[a-f0-9]{16}\.tmp$/u.test(entry.name)); - const visible = entries.filter((entry) => !/^\.create-[a-f0-9]{16}\.tmp$/u.test(entry.name)); - if (staging.length > MAX_RUNS || visible.length > MAX_RUNS - || entries.some((entry) => !entry.isDirectory() || !/^(?:[a-f0-9]+|\.create-[a-f0-9]{16}\.tmp)$/u.test(entry.name))) fail("run directory exceeds bounds"); - for (const entry of visible) { - const runId = Buffer.from(entry.name, "hex").toString("utf8"); - if (!isRunRef(runId) || Buffer.from(runId).toString("hex") !== entry.name) - fail("Run projection is corrupt", "ECORRUPT"); - if (initialItem(runId) !== itemRef) continue; + try { visitRunDirectories(runs, (runId) => { + if (initialItem(runId) !== itemRef) return; const candidate = load(runId); - if (artifact && JSON.stringify(candidate.graph) !== JSON.stringify(artifact.frozen.ir)) continue; + if (artifact && JSON.stringify(candidate.graph) !== JSON.stringify(artifact.frozen.ir)) return; if (selected) fail("Run projection is ambiguous", "EAMBIGUOUS"); selected = candidate; + }); } catch (error) { + if (error?.code === "ERUN_PROJECTION") throw error; + if (error?.code === "EBOUNDS") fail("Run projection exceeds bounds", "EBOUNDS"); + fail("Run projection is corrupt", "ECORRUPT"); } } if (current && !selected) fail("current Run is unavailable", "ECURRENT"); diff --git a/src/loops/run/run-codec.mjs b/src/loops/run/run-codec.mjs index 12044121..76f34433 100644 --- a/src/loops/run/run-codec.mjs +++ b/src/loops/run/run-codec.mjs @@ -1,5 +1,5 @@ import { randomBytes } from "node:crypto"; -import { closeSync, constants, fstatSync, fsyncSync, lstatSync, openSync, readFileSync } from "node:fs"; +import { closeSync, constants, existsSync, fstatSync, fsyncSync, lstatSync, mkdirSync, opendirSync, openSync, readFileSync, renameSync } from "node:fs"; import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; import { rawSha256 } from "../dsl/hash.mjs"; import { RUN_REF } from "./run-ref.mjs"; @@ -64,6 +64,152 @@ export function newRunId({ now = Date.now, random = randomBytes } = {}) { return `run:${encoded}`; } +function insertRunId(selected, runId) { + const index = selected.findIndex((value) => value > runId); + selected.splice(index < 0 ? selected.length : index, 0, runId); +} + +/** Stream every published Run directory without imposing a repository-wide count cap. */ +export function visitRunDirectories(runs, visitor = () => {}) { + if (!existsSync(runs)) return 0; + const directory = opendirSync(runs); + let visible = 0; + let staging = 0; + try { + for (let entry = directory.readSync(); entry; entry = directory.readSync()) { + if (/^\.create-[a-f0-9]{16}\.tmp$/u.test(entry.name)) { + if (!entry.isDirectory() || ++staging > MAX_RUNS) fail("run staging exceeds bounds", "EBOUNDS"); + continue; + } + if (!entry.isDirectory() || !/^[a-f0-9]+$/u.test(entry.name)) + fail("run directory is corrupt"); + const runId = Buffer.from(entry.name, "hex").toString("utf8"); + if (!RUN_ID.test(runId) || Buffer.from(runId).toString("hex") !== entry.name) + fail("run directory is corrupt"); + visible += 1; + visitor(runId); + } + } finally { + try { directory.closeSync(); } catch (error) { + if (error?.code !== "ERR_DIR_CLOSED") throw error; + } + } + return visible; +} + +/** Keep only the newest bounded public window while validating the full directory stream. */ +export function newestRunIds(runs, limit = MAX_RUNS) { + if (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_RUNS) + fail("invalid Run listing limit"); + const selected = []; + visitRunDirectories(runs, (runId) => { + if (selected.length < limit || runId > selected[0]) { + insertRunId(selected, runId); + if (selected.length > limit) selected.shift(); + } + }); + return selected; +} + +/** Build a bounded item window while validating every relevant retained Run. */ +export function boundedItemRunProjections({ + runs, itemRef, currentRunId = null, initialItem, project, limit = MAX_RUNS, +} = {}) { + if (typeof itemRef !== "string" || !itemRef + || currentRunId !== null && !RUN_ID.test(currentRunId) + || typeof initialItem !== "function" || typeof project !== "function" + || !Number.isSafeInteger(limit) || limit < 1 || limit > MAX_RUNS) + fail("invalid item Run projection", "EBOUNDS"); + const safelyTerminal = new Set(["failed", "stopped", "budget-exhausted", "needs-human"]); + const insertNewest = (values, projection) => { + const index = values.findIndex((value) => value.runId > projection.runId); + values.splice(index < 0 ? values.length : index, 0, projection); + if (values.length > limit) values.shift(); + }; + let current = null; + const operational = [], safe = []; + visitRunDirectories(runs, (runId) => { + if (initialItem(runId) !== itemRef) return; + const projection = project(runId); + if (runId === currentRunId) current = projection; + else insertNewest(safelyTerminal.has(projection.state) ? safe : operational, projection); + }); + if (currentRunId && !current) fail("current Run is unavailable", "ECURRENT"); + const selected = current ? [current] : []; + selected.push(...operational.slice(Math.max(0, operational.length - (limit - selected.length)))); + selected.push(...safe.slice(Math.max(0, safe.length - (limit - selected.length)))); + return selected.sort((left, right) => left.runId.localeCompare(right.runId)); +} + +/** Select the next oldest bounded batch after a cursor for scalable retention passes. */ +export function nextRunIds(runs, { after = null, limit = MAX_RUNS } = {}) { + if (after !== null && !RUN_ID.test(after)) fail("invalid Run retention cursor"); + if (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_RUNS) + fail("invalid Run retention batch"); + const selected = []; + visitRunDirectories(runs, (runId) => { + if (after === null || runId > after) { + if (selected.length < limit || runId < selected.at(-1)) { + insertRunId(selected, runId); + if (selected.length > limit) selected.pop(); + } + } + }); + return selected; +} + +function privateDirectory(path) { + try { mkdirSync(path, { mode: 0o700 }); } + catch (error) { if (error?.code !== "EEXIST") throw error; } + const entry = lstatSync(path); + if (!entry.isDirectory() || entry.isSymbolicLink() || (entry.mode & 0o077) !== 0) + fail("retention archive is unsafe"); +} + +/** Archive oldest safely-terminal Runs in bounded batches, preserving protected history. */ +export function pruneRunDirectories({ + runs, archiveRuns, retain = MAX_RUNS, replay, currentRunIds = [], +} = {}) { + if (!Number.isSafeInteger(retain) || retain < 0 || retain > 1_000_000 + || typeof replay !== "function") fail("invalid retention request"); + const before = visitRunDirectories(runs); + const required = Math.max(0, before - retain); + if (!required) return Object.freeze({ schema: "burnlist-loop-retention@1", + retain, before, archived: 0, retained: before, protected: 0 }); + privateDirectory(dirname(archiveRuns)); + privateDirectory(archiveRuns); + const current = new Set(currentRunIds); + const safe = new Set(["failed", "stopped", "budget-exhausted", "needs-human"]); + let eligible = 0, protectedCount = 0; + visitRunDirectories(runs, (runId) => { + const state = replay(runId).projection.state; + if (current.has(runId) || !safe.has(state)) protectedCount += 1; + else { + eligible += 1; + if (existsSync(join(archiveRuns, Buffer.from(runId).toString("hex")))) + fail("retention archive collides with an existing Run"); + } + }); + const targetCount = Math.min(required, eligible); + let archived = 0, cursor = null; + while (archived < targetCount) { + const batch = nextRunIds(runs, { after: cursor }); + if (!batch.length) break; + for (const runId of batch) { + cursor = runId; + const state = replay(runId).projection.state; + if (current.has(runId) || !safe.has(state)) continue; + const target = join(archiveRuns, Buffer.from(runId).toString("hex")); + renameSync(join(runs, Buffer.from(runId).toString("hex")), target); + archived += 1; + if (archived >= targetCount) break; + } + } + if (archived) { fsyncDirectory(runs); fsyncDirectory(archiveRuns); } + return Object.freeze({ schema: "burnlist-loop-retention@1", + retain, before, archived, retained: before - archived, protected: protectedCount }); +} + function canonicalValue(value, depth = 0) { if (depth > 8) fail("journal payload exceeds depth"); if (value === null || typeof value === "boolean" || typeof value === "string") return value; diff --git a/src/loops/run/run-store.mjs b/src/loops/run/run-store.mjs index 83b57cf3..a9394e80 100644 --- a/src/loops/run/run-store.mjs +++ b/src/loops/run/run-store.mjs @@ -1,9 +1,9 @@ import { randomBytes } from "node:crypto"; -import { closeSync, constants, existsSync, fsyncSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs"; +import { closeSync, constants, existsSync, fsyncSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, renameSync, rmSync, writeFileSync } from "node:fs"; import { join, resolve } from "node:path"; import { withDirectoryLock } from "../../server/dir-lock.mjs"; import { isRunRef } from "./run-ref.mjs"; -import { appendJournalRecord, createJournalRecord, MAX_JOURNAL_RECORDS, readJournal, writeInitialJournal } from "./run-journal.mjs"; +import { appendJournalRecord, createJournalRecord, MAX_JOURNAL_RECORDS, readInitialJournal, readJournal, writeInitialJournal } from "./run-journal.mjs"; import { foldRun } from "./run-fold.mjs"; import { atomicTerminalState, isTerminalState, validateGraph } from "./state-machine.mjs"; import { parseBoundedObject } from "../contracts/contract.mjs"; @@ -14,6 +14,7 @@ import { loadBoundPolicy } from "./run-artifacts.mjs"; import { createHostClaimAbandonment, hostClaimExpired, validateHostClaim } from "./run-claim.mjs"; import { validateHostExecutionEnvelope, validateHostExecutionReport } from "../contracts/host-execution.mjs"; import { createClaimIndex } from "./claim-index.mjs"; +import { MAX_RUNS, boundedItemRunProjections, newestRunIds, pruneRunDirectories, visitRunDirectories } from "./run-codec.mjs"; const fail = (message, code = "ERUN_STORE") => { throw Object.assign(new Error(`Run store: ${message}`), { code }); }; const runName = (id) => Buffer.from(id).toString("hex"); @@ -342,40 +343,36 @@ export function runStore(repoRoot, { clock = () => Date.now(), random = randomBy .filter(matchesClaim); if (currentMatches.length > 1) fail("ClaimRef is ambiguous", "ECLAIM"); if (currentMatches.length === 1) return currentMatches[0]; - const entries = readdirSync(runs, { withFileTypes: true }).filter((entry) => !entry.name.startsWith(".")); - if (entries.length > 128 || entries.some((entry) => !entry.isDirectory() || !/^[a-f0-9]+$/u.test(entry.name))) fail("run directory exceeds bounds", "EBOUNDS"); const matches = []; - for (const entry of entries) { - const runId = Buffer.from(entry.name, "hex").toString("utf8"); - if (!isRunRef(runId) || Buffer.from(runId).toString("hex") !== entry.name) fail("run directory is corrupt", "EBOUNDS"); + visitRunDirectories(runs, (runId) => { if (matchesClaim(runId)) matches.push(runId); - } + if (matches.length > 1) fail("ClaimRef is ambiguous", "ECLAIM"); + }); if (!matches.length) fail("ClaimRef is missing", "ECLAIM"); - if (matches.length !== 1) fail("ClaimRef is ambiguous", "ECLAIM"); return matches[0]; } - function visibleRunEntries() { - if (!existsSync(runs)) return []; - const entries = readdirSync(runs, { withFileTypes: true }); - const staging = entries.filter((entry) => /^\.create-[a-f0-9]{16}\.tmp$/u.test(entry.name)); - const visible = entries.filter((entry) => !/^\.create-[a-f0-9]{16}\.tmp$/u.test(entry.name)); - if (staging.length > 128 || visible.length > 128 - || entries.some((entry) => !entry.isDirectory() - || !/^(?:[a-f0-9]+|\.create-[a-f0-9]{16}\.tmp)$/u.test(entry.name))) - fail("run directory exceeds bounds", "EBOUNDS"); - return visible.sort((left, right) => left.name.localeCompare(right.name)); + function initialItem(runId) { + const initial = readInitialJournal(journalFor(runId)); + if (initial.value.payload.runId !== runId) fail("run identity mismatch", "EBOUNDS"); + return initial.value.payload.itemRef; } function listForItem(itemRef) { - if (typeof itemRef !== "string" || !itemRef) fail("invalid item projection", "EBOUNDS"); - const output = []; - for (const entry of visibleRunEntries()) { - const runId = Buffer.from(entry.name, "hex").toString("utf8"); - if (!isRunRef(runId) || Buffer.from(runId).toString("hex") !== entry.name) - fail("run directory is corrupt", "EBOUNDS"); - const journal = readJournal(journalFor(runId)); - if (journal[0]?.value?.payload?.itemRef === itemRef) output.push(replay(runId).projection); - } - return output; + if (typeof itemRef !== "string" || !itemRef) fail("invalid item projection", "EBOUNDS"); if (!existsSync(runs)) return []; + const currentRunId = currentAuthority().read().find((entry) => entry.itemRef === itemRef)?.runId ?? null; + return boundedItemRunProjections({ + runs, itemRef, currentRunId, initialItem, project: (runId) => replay(runId).projection, + }); + } + function prune(retain = MAX_RUNS) { + initialize(); const retentionLock = join(base, ".retention.lock"); + return withDirectoryLock({ lockPath: retentionLock, reclaimLiveAfterAge: false, + errorFactory: () => fail("retention is locked", "ELOCKED"), fn: () => + withDirectoryLock({ lockPath: currentLock, reclaimLiveAfterAge: false, + errorFactory: () => fail("current Run binding is locked", "ELOCKED"), fn: () => { + return pruneRunDirectories({ runs, archiveRuns: join(base, "archive", "runs"), retain, replay, + currentRunIds: currentAuthority().read().map((entry) => entry.runId) }); + } }), + }); } return Object.freeze({ createRun: (...input) => published(createRun(...input)), replay, read: replay, append: (...input) => published(append(...input)), acquireLease: (...input) => published(acquireLease(...input)), @@ -383,8 +380,7 @@ export function runStore(repoRoot, { clock = () => Date.now(), random = randomBy terminalize: (...input) => published(terminalize(...input)), bindExternalClaim: (...input) => published(bindExternalClaim(...input)), readExternalClaim, abandonExternalClaim: (...input) => published(abandonExternalClaim(...input)), resolveExternalClaim: (...input) => published(resolveExternalClaim(...input)), - acceptExternalReport: (...input) => published(acceptExternalReport(...input)), list: () => { - return visibleRunEntries() - .map((entry) => replay(Buffer.from(entry.name, "hex").toString("utf8")).projection); - }, listForItem, resolveClaimRef, readAuthority, readCurrentRun(itemRef) { if (!existsSync(base)) return null; const values = currentAuthority().read().filter((entry) => entry.itemRef === itemRef); if (values.length > 1) fail("current Run binding is ambiguous", "ECURRENT"); return values[0] ?? null; }, paths: Object.freeze({ base, runs, pathFor, journalFor, authorityPath, executionPath, reportPath, currentPath: join(base, "current-runs.json") }) }); + acceptExternalReport: (...input) => published(acceptExternalReport(...input)), + list: () => newestRunIds(runs).map((runId) => replay(runId).projection), + listForItem, prune, resolveClaimRef, readAuthority, readCurrentRun(itemRef) { if (!existsSync(base)) return null; const values = currentAuthority().read().filter((entry) => entry.itemRef === itemRef); if (values.length > 1) fail("current Run binding is ambiguous", "ECURRENT"); return values[0] ?? null; }, paths: Object.freeze({ base, runs, archiveRuns: join(base, "archive", "runs"), pathFor, journalFor, authorityPath, executionPath, reportPath, currentPath: join(base, "current-runs.json") }) }); } diff --git a/src/loops/run/run-store.test.mjs b/src/loops/run/run-store.test.mjs index f4801dfa..c8d234b6 100644 --- a/src/loops/run/run-store.test.mjs +++ b/src/loops/run/run-store.test.mjs @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { chmodSync, existsSync, mkdirSync, mkdtempSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs"; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs"; import os from "node:os"; import { join } from "node:path"; import test from "node:test"; @@ -160,3 +160,20 @@ test("item projection ignores a corrupt tail in an unrelated retained Run", (t) assert.throws(() => store.read(unrelatedRun), /journal/u); assert.equal(readLatestRunForItem({ repoRoot: root, itemRef: targetItem }).runId, targetRun); }); + +test("retention preflights every candidate before moving any Run", (t) => { + const root = mkdtempSync(join(os.tmpdir(), "m12-retention-preflight-")); + t.after(() => rmSync(root, { recursive: true, force: true })); + const store = runStore(root, { publishProjection() {} }), runIds = []; + for (let index = 0; index < 3; index += 1) { + const runId = newRunId({ now: () => index + 1, random: () => Buffer.alloc(10, index + 1) }); + runIds.push(runId); + store.createRun({ runId, itemRef: `item:260722-001#P${index}`, graph: testGraph }); + store.terminalize(runId, store.acquireLease(runId).lease, "cancelled", "retention"); + } + writeFileSync(join(store.paths.journalFor(runIds[1]), "0000000000000005.json"), "{broken\n"); + assert.throws(() => store.prune(1), /journal/u); + assert.equal(runIds.every((runId) => existsSync(store.paths.pathFor(runId))), true); + assert.equal(existsSync(store.paths.archiveRuns) + ? readdirSync(store.paths.archiveRuns).length : 0, 0); +}); From 1e34e571ff779840d7b266e43bc8bafa22824238 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sun, 26 Jul 2026 04:11:18 +0200 Subject: [PATCH 35/37] fix: refresh checklist loop goldens after main merge (E6) --- .../checklist-loop-progression.golden.json | 20 ++++++------ .../checklist-loop-states.golden.json | 32 +++++++++---------- scripts/package-paths.json | 4 +-- 3 files changed, 28 insertions(+), 28 deletions(-) diff --git a/dashboard/src/components/ChecklistDashboard/checklist-loop-progression.golden.json b/dashboard/src/components/ChecklistDashboard/checklist-loop-progression.golden.json index 5ca2fba9..ba2f6364 100644 --- a/dashboard/src/components/ChecklistDashboard/checklist-loop-progression.golden.json +++ b/dashboard/src/components/ChecklistDashboard/checklist-loop-progression.golden.json @@ -3,35 +3,35 @@ "checkpoint": "review/1/reject", "projectionBytes": 3560, "projectionSha256": "e269ab436bd602cf2be9ba9ad0ee1e8cab11a48ace11c0a717b534acf7437b28", - "domBytes": 12704, - "domSha256": "9c00851de517321148b4210d1d0e3b390d8d725275b424ac35d1b5f6b29e3290" + "domBytes": 12763, + "domSha256": "8335f9fae8479fb3d0fbd09c00863ad0c770b15b28b03d85c6f31d57efce7405" }, { "checkpoint": "implement/2/complete", "projectionBytes": 3829, "projectionSha256": "a9be55e85fff41141bd03d4a2061f5c10f98313fd037e4cf21b51ad539ac0366", - "domBytes": 12891, - "domSha256": "80b879f789b324c4cb597aeff5635627e5ddc403247aa76d00c6c507cbd150cf" + "domBytes": 12950, + "domSha256": "7a8dc2f86dd00396746761a7369d38ebb87da5a99e3d7ff40295a26558a74a28" }, { "checkpoint": "review/2/approve", "projectionBytes": 3963, "projectionSha256": "fa26cf02b2bbf3a47d8246d782b9c7ee4dfd3f7c99692834775d591069d73e2b", - "domBytes": 12883, - "domSha256": "691a45e4c290de89cd57112395d8a42bd4de4fa0244e7697d16a4e1253a799af" + "domBytes": 12942, + "domSha256": "374e5719d3c5b0fb892bbccd890658789447b236c8b37265df5b4fa4da109603" }, { "checkpoint": "converged/1/approve", "projectionBytes": 3989, "projectionSha256": "99d26c58766df3425692f8a3d23f740544e2cc5218037bf3be450719a1404518", - "domBytes": 12840, - "domSha256": "43bcb76a1960c55110ac2b8335f43b413031c871fac488dc199743e5002d364e" + "domBytes": 12899, + "domSha256": "2c092b88767f57eb71570871568857dc4ec8c27c809324cdf456d59f2518fd2c" }, { "checkpoint": "completed/1/approve", "projectionBytes": 3991, "projectionSha256": "1ade6bffe5db0e839527569b027e9d9575057e69c4f80bae327eb6fbe663a1d5", - "domBytes": 12811, - "domSha256": "600b32f8c70415f1fa5fcdda44a50bfbd74bd2cc99501e78e42a0e590f7eb351" + "domBytes": 12870, + "domSha256": "8b2ad126dc2ad71c59aa732a92ad8726d6ee75b1ccdc679709c4297942cd0bbc" } ] diff --git a/dashboard/src/components/ChecklistDashboard/checklist-loop-states.golden.json b/dashboard/src/components/ChecklistDashboard/checklist-loop-states.golden.json index 11b9938b..a38c33de 100644 --- a/dashboard/src/components/ChecklistDashboard/checklist-loop-states.golden.json +++ b/dashboard/src/components/ChecklistDashboard/checklist-loop-states.golden.json @@ -1,42 +1,42 @@ [ { "checkpoint": "paused", - "domBytes": 12765, - "domSha256": "52a8346c5b99313fbbf1a7211e8d378407d1c1e51dd4da4e79746a08b9632c10" + "domBytes": 12824, + "domSha256": "c94d4a170f798abe4bd7feff8dfd3a1adb424189d915628271be43e61a144d25" }, { "checkpoint": "failed", - "domBytes": 12763, - "domSha256": "de17b8bfc23e83f3fd18af3a51f311b612439c75e26dabbf34f3ee7a70be9922" + "domBytes": 12822, + "domSha256": "e2332a8999dea308396c614b9f49125e5db5eaff66265a3c1137cdc549ba58d8" }, { "checkpoint": "stopped", - "domBytes": 12764, - "domSha256": "53a4db3404794154957ebf41270dbdba8ada2db01561f3c1cc92b85679f4374c" + "domBytes": 12823, + "domSha256": "93e67292f385abe9cf78bf0180431716d24588adad9bbdaaa2e128b456f9ae3c" }, { "checkpoint": "needs-human", - "domBytes": 12768, - "domSha256": "3488be2fb4b37fc615f25f5ffce1a7bced2da5d2ca5ccd3d6ffdc44d764ee141" + "domBytes": 12827, + "domSha256": "c0454a94ca1a618b1038583228226b210bc002a98bea08e5780a754c0378b507" }, { "checkpoint": "exhausted", - "domBytes": 12773, - "domSha256": "5d4486f99292d52261d2480b8ff1d81c05df9ca0f8661409c93c1bd9c5083737" + "domBytes": 12832, + "domSha256": "da2d57da5ae43bfca3fb6592c87f84daccd8c5483d58d692c4ca474a9f67ed22" }, { "checkpoint": "stale", - "domBytes": 12780, - "domSha256": "878561f84a2d71066e7b8ac51716e327ed2f512e7ab71c143326c4d62168da7d" + "domBytes": 12839, + "domSha256": "243d08a119b2289b70eab10089af3ed10338b8d76f51824211d8cf0b1025280d" }, { "checkpoint": "corrupt", - "domBytes": 12782, - "domSha256": "0b2c1c94b0d681810cf2ea2489b8935a4a0f2567e7fef26413a1f3acf7f1266d" + "domBytes": 12841, + "domSha256": "d5211968c837f9d5a77b80f4feea61a3b6cb97406e26d8498f64a6815df427bc" }, { "checkpoint": "completed", - "domBytes": 12811, - "domSha256": "dcb947b09084a9c3795179d3d44068e61d5ff50e5d404a6c7ef6adf6d9208eba" + "domBytes": 12870, + "domSha256": "eda960914495686fa5c2906651aeb01bb9f270d33ae9be34cf2064b0598aaa9c" } ] diff --git a/scripts/package-paths.json b/scripts/package-paths.json index 9001e7bd..8374536f 100644 --- a/scripts/package-paths.json +++ b/scripts/package-paths.json @@ -2,8 +2,8 @@ "LICENSE", "README.md", "bin/burnlist.mjs", - "dashboard/dist/assets/index-DvGRoFbg.js", - "dashboard/dist/assets/index-TGHtQcZg.css", + "dashboard/dist/assets/index-BWW1QVPw.css", + "dashboard/dist/assets/index-BloDdHkr.js", "dashboard/dist/favicon.svg", "dashboard/dist/index.html", "loops/branch/branch.loop", From c84b358bdd61092cd6f1a5fd8b4fc83fad97bfa2 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sun, 26 Jul 2026 05:07:22 +0200 Subject: [PATCH 36/37] fix: preserve accepted Loop report retries during prune (E6) --- .../checklist-loop-progression.golden.json | 20 ++++++------ .../checklist-loop-states.golden.json | 32 +++++++++---------- src/cli/loop-runtime-cli.test.mjs | 21 ++++++++++++ src/loops/run/controller.mjs | 1 + src/loops/run/run-codec.mjs | 11 ++++--- 5 files changed, 55 insertions(+), 30 deletions(-) diff --git a/dashboard/src/components/ChecklistDashboard/checklist-loop-progression.golden.json b/dashboard/src/components/ChecklistDashboard/checklist-loop-progression.golden.json index ba2f6364..df3a5ac6 100644 --- a/dashboard/src/components/ChecklistDashboard/checklist-loop-progression.golden.json +++ b/dashboard/src/components/ChecklistDashboard/checklist-loop-progression.golden.json @@ -3,35 +3,35 @@ "checkpoint": "review/1/reject", "projectionBytes": 3560, "projectionSha256": "e269ab436bd602cf2be9ba9ad0ee1e8cab11a48ace11c0a717b534acf7437b28", - "domBytes": 12763, - "domSha256": "8335f9fae8479fb3d0fbd09c00863ad0c770b15b28b03d85c6f31d57efce7405" + "domBytes": 12814, + "domSha256": "8e04609fd5c2f6a2b23ca07c7fd69f41fecae40af986a94cda7b54364d120bb2" }, { "checkpoint": "implement/2/complete", "projectionBytes": 3829, "projectionSha256": "a9be55e85fff41141bd03d4a2061f5c10f98313fd037e4cf21b51ad539ac0366", - "domBytes": 12950, - "domSha256": "7a8dc2f86dd00396746761a7369d38ebb87da5a99e3d7ff40295a26558a74a28" + "domBytes": 13001, + "domSha256": "377e54a5edccdd7be794eb0a4d4f044b8e65d082df82d415623e36f5e71ecd96" }, { "checkpoint": "review/2/approve", "projectionBytes": 3963, "projectionSha256": "fa26cf02b2bbf3a47d8246d782b9c7ee4dfd3f7c99692834775d591069d73e2b", - "domBytes": 12942, - "domSha256": "374e5719d3c5b0fb892bbccd890658789447b236c8b37265df5b4fa4da109603" + "domBytes": 12993, + "domSha256": "5c6b77a389fbf16b3b15271663b8793f9f4b1a034318dcb10237bfc66ef4d133" }, { "checkpoint": "converged/1/approve", "projectionBytes": 3989, "projectionSha256": "99d26c58766df3425692f8a3d23f740544e2cc5218037bf3be450719a1404518", - "domBytes": 12899, - "domSha256": "2c092b88767f57eb71570871568857dc4ec8c27c809324cdf456d59f2518fd2c" + "domBytes": 12950, + "domSha256": "1b7f95d4a647f14a6fe1e77a4f6d182edd05bc048f0573947d52413ec8587efe" }, { "checkpoint": "completed/1/approve", "projectionBytes": 3991, "projectionSha256": "1ade6bffe5db0e839527569b027e9d9575057e69c4f80bae327eb6fbe663a1d5", - "domBytes": 12870, - "domSha256": "8b2ad126dc2ad71c59aa732a92ad8726d6ee75b1ccdc679709c4297942cd0bbc" + "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 index a38c33de..05a3992d 100644 --- a/dashboard/src/components/ChecklistDashboard/checklist-loop-states.golden.json +++ b/dashboard/src/components/ChecklistDashboard/checklist-loop-states.golden.json @@ -1,42 +1,42 @@ [ { "checkpoint": "paused", - "domBytes": 12824, - "domSha256": "c94d4a170f798abe4bd7feff8dfd3a1adb424189d915628271be43e61a144d25" + "domBytes": 12875, + "domSha256": "3382b6325fe2c8df52d837f52a866872f882df3e288bb9578df230cabdc29b86" }, { "checkpoint": "failed", - "domBytes": 12822, - "domSha256": "e2332a8999dea308396c614b9f49125e5db5eaff66265a3c1137cdc549ba58d8" + "domBytes": 12873, + "domSha256": "6b4d23b366416b52630507e8d7819d0e6476e865dfe0e6744b9b92e53aa94b6b" }, { "checkpoint": "stopped", - "domBytes": 12823, - "domSha256": "93e67292f385abe9cf78bf0180431716d24588adad9bbdaaa2e128b456f9ae3c" + "domBytes": 12874, + "domSha256": "6de808d752b54f74060651ad5ebdf49d57039270c68c6806270466ed07289cc6" }, { "checkpoint": "needs-human", - "domBytes": 12827, - "domSha256": "c0454a94ca1a618b1038583228226b210bc002a98bea08e5780a754c0378b507" + "domBytes": 12878, + "domSha256": "2caef5b73d435ea45c26faa4901c06c872714d70b51303561bd6228ce948d4d7" }, { "checkpoint": "exhausted", - "domBytes": 12832, - "domSha256": "da2d57da5ae43bfca3fb6592c87f84daccd8c5483d58d692c4ca474a9f67ed22" + "domBytes": 12883, + "domSha256": "ab0e520fd124feeae7929607f5385c166e2f97712e883e7f48fa4be5291264bf" }, { "checkpoint": "stale", - "domBytes": 12839, - "domSha256": "243d08a119b2289b70eab10089af3ed10338b8d76f51824211d8cf0b1025280d" + "domBytes": 12890, + "domSha256": "10679418a8cc7149c02c80ef02c55a7db4e18324f1ef128cc5061cb235069dd6" }, { "checkpoint": "corrupt", - "domBytes": 12841, - "domSha256": "d5211968c837f9d5a77b80f4feea61a3b6cb97406e26d8498f64a6815df427bc" + "domBytes": 12892, + "domSha256": "bb08c6d18a033e2f6201385c127a9b3e4620786778ad5150fc21869567c567f4" }, { "checkpoint": "completed", - "domBytes": 12870, - "domSha256": "eda960914495686fa5c2906651aeb01bb9f270d33ae9be34cf2064b0598aaa9c" + "domBytes": 12921, + "domSha256": "6d4b98b57e1e3d1aa3365480bbf37f5724355d06ddfa099acdf9e39f4452bf03" } ] diff --git a/src/cli/loop-runtime-cli.test.mjs b/src/cli/loop-runtime-cli.test.mjs index e2cfc1a2..3e82ed4b 100644 --- a/src/cli/loop-runtime-cli.test.mjs +++ b/src/cli/loop-runtime-cli.test.mjs @@ -94,6 +94,27 @@ test("prune protects a safely terminal Run while it remains current authority", assert.equal(existsSync(runStore(repo).paths.pathFor(runId)), true); }); +test("prune preserves ClaimRef-backed accepted-report retries", (t) => { + const directory = mkdtempSync(join(tmpdir(), "claimed-retention-loop-cli-")); + t.after(() => rmSync(directory, { recursive: true, force: true })); + const { repo } = createProductionRunAuthority(join(directory, "repo")); + const runId = created(repo), reportPath = join(directory, "report.json"); + const execution = JSON.parse(command(repo, ["claim", runId])).execution; + writeFileSync(reportPath, `${JSON.stringify(hostReport(execution, "complete"))}\n`); + command(repo, ["report", execution.claimId, "--result", reportPath]); + command(repo, ["stop", runId]); + const replacement = created(repo); + assert.notEqual(replacement, runId); + + const pruned = JSON.parse(command(repo, ["prune", "--retain", "1"])); + assert.equal(pruned.archived, 0); + assert.equal(pruned.protected, 2); + assert.equal(existsSync(runStore(repo).paths.pathFor(runId)), true); + assert.equal(JSON.parse(command(repo, [ + "report", execution.claimId, "--result", reportPath, + ])).state, "stopped"); +}); + test("claim and report advance checks and gates without launching a provider", (t) => { const directory = mkdtempSync(join(tmpdir(), "host-report-loop-cli-")); t.after(() => rmSync(directory, { recursive: true, force: true })); diff --git a/src/loops/run/controller.mjs b/src/loops/run/controller.mjs index 6665f361..21551fa3 100644 --- a/src/loops/run/controller.mjs +++ b/src/loops/run/controller.mjs @@ -86,6 +86,7 @@ export function createLoopController({ store, runnerFor, repoRoot = null }) { optionalRecords: observations(runId), }); } catch {} + if (accepted.execution.terminal) return presented(runId); if (typeof runnerFor !== "function") fail("system runner is unavailable", "ERUNNER_UNAVAILABLE"); const runner = runnerFor(runId); if (!runner?.runToHostBoundary) fail("system runner is unavailable", "ERUNNER_UNAVAILABLE"); diff --git a/src/loops/run/run-codec.mjs b/src/loops/run/run-codec.mjs index 76f34433..e0593703 100644 --- a/src/loops/run/run-codec.mjs +++ b/src/loops/run/run-codec.mjs @@ -180,10 +180,13 @@ export function pruneRunDirectories({ privateDirectory(archiveRuns); const current = new Set(currentRunIds); const safe = new Set(["failed", "stopped", "budget-exhausted", "needs-human"]); + const protectedRun = (runId, replayed) => current.has(runId) + || !safe.has(replayed.projection.state) + || replayed.journal?.some((record) => record.value.type === "external-claim-bound"); let eligible = 0, protectedCount = 0; visitRunDirectories(runs, (runId) => { - const state = replay(runId).projection.state; - if (current.has(runId) || !safe.has(state)) protectedCount += 1; + const replayed = replay(runId); + if (protectedRun(runId, replayed)) protectedCount += 1; else { eligible += 1; if (existsSync(join(archiveRuns, Buffer.from(runId).toString("hex")))) @@ -197,8 +200,8 @@ export function pruneRunDirectories({ if (!batch.length) break; for (const runId of batch) { cursor = runId; - const state = replay(runId).projection.state; - if (current.has(runId) || !safe.has(state)) continue; + const replayed = replay(runId); + if (protectedRun(runId, replayed)) continue; const target = join(archiveRuns, Buffer.from(runId).toString("hex")); renameSync(join(runs, Buffer.from(runId).toString("hex")), target); archived += 1; From 550d675f4ee6e5a943f297a8a1b1eb3a671b4da2 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sun, 26 Jul 2026 05:07:46 +0200 Subject: [PATCH 37/37] fix: refresh merged dashboard package assets (E6) --- scripts/package-paths.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/package-paths.json b/scripts/package-paths.json index 8374536f..032c0b35 100644 --- a/scripts/package-paths.json +++ b/scripts/package-paths.json @@ -2,8 +2,8 @@ "LICENSE", "README.md", "bin/burnlist.mjs", - "dashboard/dist/assets/index-BWW1QVPw.css", - "dashboard/dist/assets/index-BloDdHkr.js", + "dashboard/dist/assets/index-EAuoc8Ho.css", + "dashboard/dist/assets/index-jgfFofUy.js", "dashboard/dist/favicon.svg", "dashboard/dist/index.html", "loops/branch/branch.loop",