From 6d6108120f071449fac0792dd2d04831775886da Mon Sep 17 00:00:00 2001 From: Idan Ayalon Date: Sat, 27 Jun 2026 13:31:33 -0400 Subject: [PATCH 01/22] feat(cli): add `loop version` command Prints `@loop-lang/loop v` from package.json. Accepts `version`, `--version`, and `-v`. Two tests added; all 7 CLI tests pass. Co-Authored-By: Claude Sonnet 4.6 --- packages/cli/src/cli.mjs | 10 ++++++++++ packages/cli/test/version.test.mjs | 23 +++++++++++++++++++++++ 2 files changed, 33 insertions(+) create mode 100644 packages/cli/test/version.test.mjs diff --git a/packages/cli/src/cli.mjs b/packages/cli/src/cli.mjs index 1a0baf6..c9a9672 100755 --- a/packages/cli/src/cli.mjs +++ b/packages/cli/src/cli.mjs @@ -3,9 +3,12 @@ // agent can author + run .loop files. Running loops happens in your agent (the // /loopflow skill) or headless via the full @loop-lang/runtime CLI. import { fileURLToPath } from "node:url"; +import { createRequire } from "node:module"; import { dirname, join, resolve } from "node:path"; import { init } from "./init.mjs"; +const require = createRequire(import.meta.url); + const here = dirname(fileURLToPath(import.meta.url)); const ASSETS = join(here, "..", "assets"); @@ -13,6 +16,7 @@ const HELP = `loop — install Loop into your repo so any agent can author + run usage: loop init [options] scaffold Loop into the current repo + loop version print installed version loop help show this init options: @@ -42,6 +46,12 @@ async function main(argv) { return; } + if (cmd === "version" || cmd === "--version" || cmd === "-v") { + const { version } = require("../package.json"); + console.log(`@loop-lang/loop v${version}`); + return; + } + if (cmd === "init") { const agents = []; if (flag(argv, "--all-agents")) agents.push("claude", "cursor", "copilot"); diff --git a/packages/cli/test/version.test.mjs b/packages/cli/test/version.test.mjs new file mode 100644 index 0000000..2192bd6 --- /dev/null +++ b/packages/cli/test/version.test.mjs @@ -0,0 +1,23 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { createRequire } from "node:module"; + +const require = createRequire(import.meta.url); +const { version } = require("../package.json"); + +test("loop version prints expected string", () => { + const out = execFileSync(process.execPath, ["src/cli.mjs", "version"], { + cwd: new URL("..", import.meta.url).pathname, + encoding: "utf8", + }); + assert.equal(out.trim(), `@loop-lang/loop v${version}`); +}); + +test("loop --version also works", () => { + const out = execFileSync(process.execPath, ["src/cli.mjs", "--version"], { + cwd: new URL("..", import.meta.url).pathname, + encoding: "utf8", + }); + assert.equal(out.trim(), `@loop-lang/loop v${version}`); +}); From 64d7646fdb0529e374bf7fd75cfafa448d800282 Mon Sep 17 00:00:00 2001 From: Idan Ayalon Date: Sat, 27 Jun 2026 23:47:14 -0400 Subject: [PATCH 02/22] feat(viz): add renderLiveHtml for live SSE-driven dashboard Two-column HTML page: left = existing SVG schematic (LoopViz.render), right = live progress panel (current step, for-each progress bar + item list, flow step list, event log). SSE client updates DOM on each LoopEvent: node-enter pulses the active cycle node, foreach-item-start fills the progress bar, flow-step-start highlights the flow step, etc. Co-Authored-By: Claude Sonnet 4.6 --- packages/viz/src/index.ts | 1 + packages/viz/src/live.ts | 217 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 218 insertions(+) create mode 100644 packages/viz/src/live.ts diff --git a/packages/viz/src/index.ts b/packages/viz/src/index.ts index b973703..bd91a15 100644 --- a/packages/viz/src/index.ts +++ b/packages/viz/src/index.ts @@ -1,2 +1,3 @@ export { renderHtml, RENDERER_JS, BASE_CSS, FONTS } from "./render.js"; export type { VizOptions } from "./render.js"; +export { renderLiveHtml } from "./live.js"; diff --git a/packages/viz/src/live.ts b/packages/viz/src/live.ts new file mode 100644 index 0000000..4609be8 --- /dev/null +++ b/packages/viz/src/live.ts @@ -0,0 +1,217 @@ +import type { LoopFile } from "@loop-lang/parser"; +import { FONTS, BASE_CSS, RENDERER_JS } from "./render.js"; +import type { VizOptions } from "./render.js"; + +const LIVE_CSS = ` +body.live{display:grid;grid-template-columns:1fr 300px;grid-template-rows:auto 1fr;height:100vh;overflow:hidden;} +body.live header{grid-column:1/-1;} +body.live main{overflow:auto;padding:12px 18px 60px;} +#lp-panel{background:var(--panel);border-left:1px solid var(--line);display:flex;flex-direction:column;overflow:hidden;} +#lp-panel section{padding:12px 14px;border-bottom:1px solid var(--line);flex-shrink:0;} +#lp-panel h3{margin:0 0 6px;font-family:"Saira Condensed",sans-serif;font-size:11px;letter-spacing:1.2px;text-transform:uppercase;color:var(--muted);} +#lp-step{font-size:13px;color:var(--fwd);font-family:"Space Mono",monospace;word-break:break-all;} +#lp-foreach-bar{height:4px;background:var(--line);border-radius:2px;margin-bottom:8px;} +#lp-foreach-fill{height:100%;background:var(--fwd);border-radius:2px;transition:width .3s;width:0%;} +#lp-foreach-list,#lp-flow-list{list-style:none;margin:0;padding:0;font-size:11px;font-family:"Space Mono",monospace;max-height:140px;overflow-y:auto;} +#lp-foreach-list li,#lp-flow-list li{padding:3px 0;border-bottom:1px solid var(--line);color:var(--muted);display:flex;gap:6px;align-items:center;} +#lp-foreach-list li.lp-active,#lp-flow-list li.lp-active{color:var(--fwd);} +#lp-foreach-list li.lp-done,#lp-flow-list li.lp-done{color:var(--stop);} +#lp-foreach-list li.lp-fail,#lp-flow-list li.lp-fail{color:var(--gate);} +#lp-log{flex:1;overflow:hidden;display:flex;flex-direction:column;min-height:0;} +#lp-log-wrap{overflow-y:auto;flex:1;} +#lp-log-list{list-style:none;margin:0;padding:0;font-size:11px;font-family:"Space Mono",monospace;} +#lp-log-list li{padding:2px 6px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;} +#lp-log-list li.lp-ev-enter{color:var(--fwd);} +#lp-log-list li.lp-ev-fail{color:var(--gate);} +#lp-log-list li.lp-ev-human{color:var(--reflect);} +[data-cyc].lp-active rect:first-of-type{stroke-width:3;filter:drop-shadow(0 0 10px currentColor);animation:lp-pulse 1.1s ease-in-out infinite;} +[data-cyc].lp-done{opacity:.38;} +[data-cyc].lp-fail rect:first-of-type{stroke:var(--gate)!important;} +@keyframes lp-pulse{0%,100%{opacity:1}50%{opacity:.5}} +[data-edge="reflect"].lp-fire{animation:firepulse .5s ease-out 3;} +`; + +// ponytail: plain-var JS to stay embeddable alongside RENDERER_JS +const LIVE_JS = `(function(){ +var es=new EventSource('/events'); +es.onmessage=function(ev){try{handle(JSON.parse(ev.data));}catch(_){}}; +function q(s){return document.querySelector(s);} +function qa(s){return Array.prototype.slice.call(document.querySelectorAll(s));} +function setStep(t){var el=q('#lp-step');if(el)el.textContent=t;} +function log(txt,cls){ + var ul=q('#lp-log-list');if(!ul)return; + var li=document.createElement('li');li.textContent=txt; + if(cls)li.className=cls; + ul.insertBefore(li,ul.firstChild); + if(ul.children.length>80)ul.removeChild(ul.lastChild); +} +function clearCyc(node){qa('[data-cyc="'+node+'"]').forEach(function(n){n.classList.remove('lp-active','lp-done','lp-fail');});} +function handle(e){ + switch(e.type){ + case 'node-enter': + clearCyc(e.node); + qa('[data-cyc="'+e.node+'"]').forEach(function(n){n.classList.add('lp-active');}); + setStep(e.node+' \xb7 cycle '+e.attempt); + log('\\u2192 '+e.node+' (try '+e.attempt+')','lp-ev-enter'); + break; + case 'node-exit': + qa('[data-cyc="'+e.node+'"]').forEach(function(n){ + n.classList.remove('lp-active'); + n.classList.add(e.ok?'lp-done':'lp-fail'); + }); + if(!e.ok)log('\\u2717 '+e.node+' failed','lp-ev-fail'); + break; + case 'loop-back': + var arc=q('[data-edge="reflect"]'); + if(arc){arc.classList.remove('lp-fire');void arc.offsetWidth;arc.classList.add('lp-fire');} + log('\\u21ba back to '+e.to); + break; + case 'foreach-start':{ + var sec=q('#lp-foreach');if(sec)sec.style.display=''; + var ul=q('#lp-foreach-list');if(!ul)break;while(ul.firstChild)ul.removeChild(ul.firstChild); + for(var i=0;i injection, line/para separators. */ +function embedJson(value: unknown): string { + const LS = String.fromCharCode(0x2028); + const PS = String.fromCharCode(0x2029); + return JSON.stringify(value) + .replace(/○${safe}`; + } + } + } + + return ` + + +Loop · live · ${title} +${FONTS} + + + +
+
Loop
+
+
+ signalreflect + human gatedone +
+
+
+
+
+

Current

+
starting…
+
+ + +
+

Log

+
    +
    +
    + +`; +} From 7ea007f1f64eeef9711c838a1a38f1c42ccd14fd Mon Sep 17 00:00:00 2001 From: Idan Ayalon Date: Sat, 27 Jun 2026 23:48:08 -0400 Subject: [PATCH 03/22] =?UTF-8?q?feat(runtime):=20add=20startLiveServer=20?= =?UTF-8?q?=E2=80=94=20minimal=20Node=20http+SSE=20server=20for=20live=20d?= =?UTF-8?q?ashboard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET / serves the pre-rendered live HTML; GET /events streams LoopEvent NDJSON via SSE. Auto-opens browser with execFile (no shell injection). Zero new npm dependencies — only node:http and node:child_process. Co-Authored-By: Claude Sonnet 4.6 --- packages/runtime/src/serve.ts | 63 +++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 packages/runtime/src/serve.ts diff --git a/packages/runtime/src/serve.ts b/packages/runtime/src/serve.ts new file mode 100644 index 0000000..7481186 --- /dev/null +++ b/packages/runtime/src/serve.ts @@ -0,0 +1,63 @@ +import { createServer } from "node:http"; +import { execFile } from "node:child_process"; +import type { IncomingMessage, ServerResponse } from "node:http"; +import type { LoopEvent } from "./types.js"; + +export interface LiveServer { + emit(e: LoopEvent): void; + port: number; + close(): void; +} + +/** Start a minimal HTTP+SSE server for the live dashboard. + * GET / → serves the pre-rendered HTML page + * GET /events → SSE stream of LoopEvent NDJSON + * Auto-opens the browser (best-effort; skips silently on failure). + */ +export function startLiveServer(html: string): Promise { + const clients: ServerResponse[] = []; + + return new Promise((resolve, reject) => { + const server = createServer((req: IncomingMessage, res: ServerResponse) => { + if (req.url === "/events") { + res.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }); + res.write("retry: 2000\n\n"); + clients.push(res); + req.on("close", () => { + const i = clients.indexOf(res); + if (i >= 0) clients.splice(i, 1); + }); + } else { + res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); + res.end(html); + } + }); + + server.on("error", reject); + + server.listen(0, "127.0.0.1", () => { + const addr = server.address() as { port: number }; + const url = `http://127.0.0.1:${addr.port}`; + // Best-effort browser open — platform-specific, ignore failures + const opener = process.platform === "darwin" ? "open" + : process.platform === "win32" ? "start" + : "xdg-open"; + execFile(opener, [url], () => { /* ignore errors */ }); + + resolve({ + port: addr.port, + emit(e: LoopEvent) { + const data = `data: ${JSON.stringify(e)}\n\n`; + for (const c of [...clients]) { + try { c.write(data); } catch { /* client disconnected */ } + } + }, + close() { server.close(); }, + }); + }); + }); +} From bf46f99eeb936f92fe3f9cfa95a8cdc3a1cac6c9 Mon Sep 17 00:00:00 2001 From: Idan Ayalon Date: Sat, 27 Jun 2026 23:48:43 -0400 Subject: [PATCH 04/22] feat(cli): add --live flag to loop-run run Opens a browser dashboard with real-time SSE event streaming while the loop executes. Terminal still shows the text trace unchanged. Usage: loop-run run --live Co-Authored-By: Claude Sonnet 4.6 --- packages/runtime/src/cli.ts | 35 ++++++++++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/packages/runtime/src/cli.ts b/packages/runtime/src/cli.ts index aa97c67..0d10ac0 100644 --- a/packages/runtime/src/cli.ts +++ b/packages/runtime/src/cli.ts @@ -129,7 +129,7 @@ async function main() { } if (!cmd || (cmd !== "run" && cmd !== "parse" && cmd !== "viz") || !fileArg) { - console.error("usage: loop-run [--model ] [--out ]"); + console.error("usage: loop-run [--model ] [--live] [--events] [--out ]"); process.exit(2); } @@ -207,6 +207,39 @@ async function main() { process.exit(ok ? 0 : 1); } + if (rest.includes("--live")) { + const { renderLiveHtml } = await import("@loop-lang/viz"); + const { startLiveServer } = await import("./serve.js"); + const html = renderLiveHtml(file, { title: fileArg.split("/").pop() }); + const srv = await startLiveServer(html); + console.error(`\n ↻ Loop live dashboard → http://127.0.0.1:${srv.port}\n`); + const liveEvents: LoopEvent[] = []; + const liveOutcomes = await run(file, { + runner: new ClaudeCodeRunner({}), + verifier: new ShellVerifier(), + human: new CliHumanIO(), + git, + baseDir: target, + loadFile, + readText, + writeText, + flowStack: [path], + modelPolicy: file.config?.models, + cliModel: model, + onEvent: (e) => { + liveEvents.push(e); + srv.emit(e); + const line = render(e); + if (line) console.log(line); + }, + }); + srv.close(); + const liveSummary = formatModelSummary(summarizeModels(liveEvents)); + if (liveSummary) console.error(liveSummary); + const liveOk = liveOutcomes.every((o) => o.satisfied); + process.exit(liveOk ? 0 : 1); + } + const traceEvents: LoopEvent[] = []; const outcomes = await run(file, { runner: new ClaudeCodeRunner({}), From ae22cfab912c3b78bd38855e3a10ea631a2f5433 Mon Sep 17 00:00:00 2001 From: Idan Ayalon Date: Sat, 27 Jun 2026 23:50:11 -0400 Subject: [PATCH 05/22] feat(loopflow): brainstorming integration + live dashboard guidance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No-plan medium/large path now invokes superpowers:brainstorming instead of the old 'discovery loop' pattern — it runs the interview natively in-session, produces a spec doc, and the approved goal/spec feed directly into the authored .loop. New 'Live browser dashboard' section explains: - CLI: loop-run run --live (browser opens automatically) - In-session /loopflow: always show loop show first, then offer the --live CLI option or narrate inline with cycle progress lines. Co-Authored-By: Claude Sonnet 4.6 --- .claude/skills/loopflow/SKILL.md | 32 ++++++++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/.claude/skills/loopflow/SKILL.md b/.claude/skills/loopflow/SKILL.md index 6ced0c1..75e55a8 100644 --- a/.claude/skills/loopflow/SKILL.md +++ b/.claude/skills/loopflow/SKILL.md @@ -103,8 +103,12 @@ into a scoped loop and teaches the three top-level forms at once. Ask **"Do you have a spec, plan, ticket, or epic already?"** - **Yes** → point `look at:` at it; if it's an epic / story list, turn each story into a `stage` of a `pipeline` (or `for each` over the plan file). -- **No, and it's medium/large** → begin the flow with a **discovery loop** whose - `done when` is "the plan file exists and validates" — it interviews them, writes the plan. +- **No, and it's medium/large** → invoke `superpowers:brainstorming` **before** writing any `.loop`. The brainstorming skill explores project context, asks clarifying questions one at a time (offering a browser visual when a design choice benefits from it), proposes 2-3 approaches, gets user approval, and produces a spec doc at `docs/superpowers/specs/YYYY-MM-DD--design.md`. Once the spec is approved and saved: + - The approved goal → `goal:` in the loop + - The spec file → `look at: docs/superpowers/specs/.md` + - Sub-tasks in the spec → `stage`s in a `pipeline`, or `for each in "plan.yaml"` + + This replaces the old "discovery loop" pattern — brainstorming handles the interview natively in-session and produces a richer, visually-validated artifact. - **No, and it's small** → skip planning; go straight to the loop. ### Step 2 — which quality passes? (offer the menu — don't wait to be asked) @@ -178,6 +182,30 @@ show the file chain (`a.loop → b.loop → …`). `loop ls` lists every loop in --- +## Live browser dashboard + +While a loop runs you can watch a live animated schematic — the active cycle node pulses, +flow steps highlight as they execute, and a for-each progress bar fills item by item. + +**From the CLI** (`loop-run`): +```bash +loop-run run --live +``` +Opens a browser tab automatically. The terminal still shows the normal text trace; the +browser view adds the animated SVG schematic + side panel (current step · progress bar · +log). + +**From this session** (`/loopflow`): +When you start running a loop in this session, **always** open the schematic first: +1. Run `loop show ` (or `loop-run viz ` for the full HTML schematic) so the + user sees the loop's shape. +2. Offer: *"Want a live browser dashboard while I run this? Run + `loop-run run --live` in a separate terminal — it'll stream every step to the + browser in real time. Or I can run it here and narrate progress inline."* +3. If they want in-session: proceed with the plan/act/observe narration below. Print a + short progress line before each cycle step: `↻ plan (cycle N)`, `⚡ act`, `= observe`. +4. If they prefer the CLI: give them the command and wait; don't run in-session. + ## Running a .loop (in this session) Prefer running it **yourself, here** — that way the whole loop is visible in the From 6a301cdca6740bc2a5adaddbfcc2c8bc3c44b0c9 Mon Sep 17 00:00:00 2001 From: Idan Ayalon Date: Sat, 27 Jun 2026 23:56:53 -0400 Subject: [PATCH 06/22] =?UTF-8?q?fix(viz):=20correct=20embedJson=20?= =?UTF-8?q?=E2=80=94=20escape=20<=20as=20\u003c=20not=20unicode=20char?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit embedJson was using "\u003c" (single backslash = TS unicode escape = '<') instead of "\\u003c" (double backslash = literal string '\u003c'). Replacement was a no-op, leaving raw in SPEC JSON which could break the browser page if loop goal/name contained that string. Found during verification testing. Co-Authored-By: Claude Sonnet 4.6 --- packages/viz/src/live.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/viz/src/live.ts b/packages/viz/src/live.ts index 4609be8..d5e3546 100644 --- a/packages/viz/src/live.ts +++ b/packages/viz/src/live.ts @@ -147,9 +147,9 @@ function embedJson(value: unknown): string { const LS = String.fromCharCode(0x2028); const PS = String.fromCharCode(0x2029); return JSON.stringify(value) - .replace(/ Date: Sun, 28 Jun 2026 00:05:54 -0400 Subject: [PATCH 07/22] =?UTF-8?q?feat:=20in-session=20live=20dashboard=20f?= =?UTF-8?q?or=20/loopflow=20=E2=80=94=20ask,=20fire=20server,=20push=20eve?= =?UTF-8?q?nts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The skill now drives the live browser dashboard itself when running a loop in-session (where Claude IS the engine, so no events flow automatically): - serve.ts: add POST /emit so an external process can push a LoopEvent that the server broadcasts over SSE; make browser auto-open optional. - cli.ts: two new commands — loop-run live start the dashboard server (no engine), print LOOP_LIVE_PORT=, stay up. loop-run emit '' push one event to that server (best-effort). - SKILL.md: before any in-session run, ask the user if they want a live dashboard; if yes, start loop-run live in the background, grab the port, and emit an event per narrated step (full event cheat-sheet included). - AGENTS.md: document the ask + the live/emit/run --live commands. Sprint runs (for each story) now show the progress bar filling story by story with each cycle node pulsing — real-time 'where in the loop / plan'. Co-Authored-By: Claude Opus 4.8 (1M context) --- .claude/skills/loopflow/SKILL.md | 79 ++++++++++++++++++++++++-------- AGENTS.md | 17 +++++++ packages/runtime/src/cli.ts | 53 ++++++++++++++++++++- packages/runtime/src/serve.ts | 56 ++++++++++++++++------ 4 files changed, 171 insertions(+), 34 deletions(-) diff --git a/.claude/skills/loopflow/SKILL.md b/.claude/skills/loopflow/SKILL.md index 75e55a8..54d55c5 100644 --- a/.claude/skills/loopflow/SKILL.md +++ b/.claude/skills/loopflow/SKILL.md @@ -184,27 +184,70 @@ show the file chain (`a.loop → b.loop → …`). `loop ls` lists every loop in ## Live browser dashboard -While a loop runs you can watch a live animated schematic — the active cycle node pulses, -flow steps highlight as they execute, and a for-each progress bar fills item by item. - -**From the CLI** (`loop-run`): +A live animated schematic in the browser — the active cycle node pulses, flow steps +highlight as they execute, and a for-each (sprint) progress bar fills item by item, so the +user always sees **where in the loop / where in the plan** the run currently is. + +### Always ask first (in-session runs) + +**Before you start running any loop in this session, ask the user once:** + +> *"Want a live browser dashboard for this run? I'll open a real-time view of the loop and +> update it as each step happens."* + +- **No** → run normally, narrating the trace in chat (the *Running a .loop* section below). +- **Yes** → start the dashboard, then drive it as you narrate. Mechanism below. + +### How the in-session dashboard works + +You (the skill) **are** the engine in-session — so you spin up a tiny server, point the +browser at it, and push an event for each step you narrate. The browser renders in real +time. Three pieces, all via the `loop-run` CLI: + +1. **Start the server (background) and grab its port:** + ```bash + loop-run live # opens the browser automatically + ``` + Run it in the **background** (don't block on it). Read its first stdout line — + `LOOP_LIVE_PORT=` — and keep `` for the rest of the run. The server renders + the loop's schematic and stays up until you stop it (or the user hits Ctrl-C). + +2. **Push an event as you reach each step** — one `emit` per narrated step: + ```bash + loop-run emit '' + ``` + `emit` is best-effort (never blocks your narration if the browser is closed). Push the + same events the engine would emit; the key ones to keep the view truthful: + + | When you… | Push | + |-----------|------| + | start a flow | `{"type":"flow-start","name":""}` | + | enter a flow step | `{"type":"flow-step-start","name":"","ref":""}` | + | finish a flow step | `{"type":"flow-step-end","name":"","satisfied":true}` | + | start a `for each` | `{"type":"foreach-start","var":"","source":"","count":}` | + | start item i (0-based) | `{"type":"foreach-item-start","var":"","index":,"total":}` | + | finish item i | `{"type":"foreach-item-end","var":"","index":,"satisfied":true}` | + | begin a cycle step | `{"type":"node-enter","node":"plan","attempt":}` (then `act`, `observe`) | + | finish a cycle step | `{"type":"node-exit","node":"plan","attempt":,"ok":true}` | + | run the done-when check | `{"type":"observe","passed":true,"output":""}` | + | reflect on failure | `{"type":"reflect","text":""}` then loop-back `{"type":"loop-back","to":"plan"}` | + | start a pipeline stage | `{"type":"stage-start","name":""}` | + | stop | `{"type":"stop","reason":"done"}` then `{"type":"loop-end","name":"","satisfied":true}` | + +3. **At the end**, leave the server running so the user can review the final state. Mention + they can Ctrl-C the `loop-run live` process to close it. + +So a sprint (`for each story in "sprint.yaml"`) shows the progress bar filling story by +story, each cycle node pulsing as you work — exactly "where are we in the loop / in the plan". + +### CLI-only alternative (headless) + +If the user would rather run it headless (gates answered in the terminal, not chat): ```bash loop-run run --live ``` -Opens a browser tab automatically. The terminal still shows the normal text trace; the -browser view adds the animated SVG schematic + side panel (current step · progress bar · -log). - -**From this session** (`/loopflow`): -When you start running a loop in this session, **always** open the schematic first: -1. Run `loop show ` (or `loop-run viz ` for the full HTML schematic) so the - user sees the loop's shape. -2. Offer: *"Want a live browser dashboard while I run this? Run - `loop-run run --live` in a separate terminal — it'll stream every step to the - browser in real time. Or I can run it here and narrate progress inline."* -3. If they want in-session: proceed with the plan/act/observe narration below. Print a - short progress line before each cycle step: `↻ plan (cycle N)`, `⚡ act`, `= observe`. -4. If they prefer the CLI: give them the command and wait; don't run in-session. +The engine itself emits every event to the browser — no manual `emit` needed. Use this only +when the user explicitly wants the headless runner. ## Running a .loop (in this session) diff --git a/AGENTS.md b/AGENTS.md index ea3eb55..6c72175 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -294,6 +294,23 @@ flow, show the file chain. `loop-run ls` lists every loop in the repo. - `loop-run show file.loop` — print the loop's flow as compact ASCII (and `loop-run ls` to list them). - `loop-run viz file.loop` — open a visual HTML schematic of the flow. +### Ask about a live visual — every in-session run + +Before you start running a loop **in this session**, ask the user once: +*"Want a live browser dashboard for this run? I'll open a real-time view and update it as +each step happens."* If yes, fire the backend and drive it as you narrate: + +- `loop-run live file.loop` — start the dashboard server (opens the browser, prints + `LOOP_LIVE_PORT=`), run it in the **background**, keep the port. +- `loop-run emit ''` — push one event per narrated step so the browser + animates in real time: the active cycle node pulses, flow steps light up, and a + `for each` (sprint) progress bar fills item by item — the user always sees where in the + loop and where in the plan the run is. +- `loop-run run file.loop --live` — headless alternative: the engine itself streams every + event to the browser (no manual `emit`), gates answered in the terminal. + +The `/loopflow` skill has the full event cheat-sheet for the in-session push protocol. + ## Authoring checklist 1. One coherent objective per `loop`; one story per `stage`. diff --git a/packages/runtime/src/cli.ts b/packages/runtime/src/cli.ts index 0d10ac0..83385bf 100644 --- a/packages/runtime/src/cli.ts +++ b/packages/runtime/src/cli.ts @@ -99,6 +99,17 @@ function findLoops(dir: string, acc: string[] = [], depth = 0): string[] { return acc; } +/** Read all of stdin as a string (used by `emit` when the event isn't an arg). */ +function readStdin(): Promise { + return new Promise((resolve) => { + if (process.stdin.isTTY) { resolve(""); return; } + let data = ""; + process.stdin.setEncoding("utf8"); + process.stdin.on("data", (c) => { data += c; }); + process.stdin.on("end", () => resolve(data.trim())); + }); +} + async function main() { const [cmd, fileArg, ...rest] = process.argv.slice(2); @@ -128,8 +139,27 @@ async function main() { return; } - if (!cmd || (cmd !== "run" && cmd !== "parse" && cmd !== "viz") || !fileArg) { - console.error("usage: loop-run [--model ] [--live] [--events] [--out ]"); + // `loop-run emit ''` — push one LoopEvent to a running + // `loop-run live` server so it broadcasts to the browser. Best-effort: never + // throws (so the /loopflow skill's narration is never blocked by a push). + if (cmd === "emit") { + const port = fileArg; + const json = rest.length ? rest.join(" ") : await readStdin(); + if (!port || !json) { console.error("usage: loop-run emit ''"); process.exit(2); } + try { + await fetch(`http://127.0.0.1:${port}/emit`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: json, + }); + } catch { + /* server not up / closed — ignore so narration continues */ + } + return; + } + + if (!cmd || (cmd !== "run" && cmd !== "parse" && cmd !== "viz" && cmd !== "live") || !fileArg) { + console.error("usage: loop-run [--model ] [--live] [--events] [--out ]"); process.exit(2); } @@ -156,6 +186,25 @@ async function main() { if (cmd === "parse") return; } + // `loop-run live ` — start the live dashboard server WITHOUT running the + // engine, then stay up. The /loopflow skill runs the loop in-session and pushes + // events with `loop-run emit ...`; the browser renders them in real time. + if (cmd === "live") { + const { renderLiveHtml } = await import("@loop-lang/viz"); + const { startLiveServer } = await import("./serve.js"); + const html = renderLiveHtml(file, { title: fileArg.split("/").pop() }); + const noOpen = rest.includes("--no-open"); + const srv = await startLiveServer(html, { open: !noOpen }); + // Machine-readable line first so a driver can grep the port deterministically. + console.log(`LOOP_LIVE_PORT=${srv.port}`); + console.error(`\n ↻ Loop live dashboard → http://127.0.0.1:${srv.port}`); + console.error(` push events: loop-run emit ${srv.port} ''`); + console.error(` (Ctrl-C to stop)\n`); + process.on("SIGINT", () => { srv.close(); process.exit(0); }); + process.on("SIGTERM", () => { srv.close(); process.exit(0); }); + return; // server keeps the event loop alive + } + // Visualize: write a self-contained HTML schematic of the flow. if (cmd === "viz") { const { renderHtml } = await import("@loop-lang/viz"); diff --git a/packages/runtime/src/serve.ts b/packages/runtime/src/serve.ts index 7481186..536e3ce 100644 --- a/packages/runtime/src/serve.ts +++ b/packages/runtime/src/serve.ts @@ -9,14 +9,29 @@ export interface LiveServer { close(): void; } +export interface LiveServerOptions { + /** Open the browser automatically (default true). */ + open?: boolean; +} + /** Start a minimal HTTP+SSE server for the live dashboard. - * GET / → serves the pre-rendered HTML page - * GET /events → SSE stream of LoopEvent NDJSON + * GET / → serves the pre-rendered HTML page + * GET /events → SSE stream of LoopEvent NDJSON + * POST /emit → push a LoopEvent (JSON body) to broadcast to all clients; + * this is how an out-of-process driver (the /loopflow skill via + * `loop-run emit`) feeds a live in-session run. * Auto-opens the browser (best-effort; skips silently on failure). */ -export function startLiveServer(html: string): Promise { +export function startLiveServer(html: string, opts: LiveServerOptions = {}): Promise { const clients: ServerResponse[] = []; + function broadcast(e: unknown): void { + const data = `data: ${JSON.stringify(e)}\n\n`; + for (const c of [...clients]) { + try { c.write(data); } catch { /* client disconnected */ } + } + } + return new Promise((resolve, reject) => { const server = createServer((req: IncomingMessage, res: ServerResponse) => { if (req.url === "/events") { @@ -31,6 +46,22 @@ export function startLiveServer(html: string): Promise { const i = clients.indexOf(res); if (i >= 0) clients.splice(i, 1); }); + } else if (req.method === "POST" && req.url === "/emit") { + let body = ""; + req.on("data", (chunk) => { + body += chunk; + if (body.length > 1_000_000) req.destroy(); // cap: refuse oversized payloads + }); + req.on("end", () => { + try { + broadcast(JSON.parse(body)); + res.writeHead(204); + res.end(); + } catch { + res.writeHead(400, { "Content-Type": "text/plain" }); + res.end("invalid JSON"); + } + }); } else { res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); res.end(html); @@ -42,20 +73,17 @@ export function startLiveServer(html: string): Promise { server.listen(0, "127.0.0.1", () => { const addr = server.address() as { port: number }; const url = `http://127.0.0.1:${addr.port}`; - // Best-effort browser open — platform-specific, ignore failures - const opener = process.platform === "darwin" ? "open" - : process.platform === "win32" ? "start" - : "xdg-open"; - execFile(opener, [url], () => { /* ignore errors */ }); + if (opts.open !== false) { + // Best-effort browser open — platform-specific, ignore failures + const opener = process.platform === "darwin" ? "open" + : process.platform === "win32" ? "start" + : "xdg-open"; + execFile(opener, [url], () => { /* ignore errors */ }); + } resolve({ port: addr.port, - emit(e: LoopEvent) { - const data = `data: ${JSON.stringify(e)}\n\n`; - for (const c of [...clients]) { - try { c.write(data); } catch { /* client disconnected */ } - } - }, + emit: broadcast, close() { server.close(); }, }); }); From 41fc2f66cc502cfb2917eed679f3528ca90ea0c5 Mon Sep 17 00:00:00 2001 From: Idan Ayalon Date: Sun, 28 Jun 2026 00:14:11 -0400 Subject: [PATCH 08/22] feat(viz): Linear-style dark redesign of live dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The live page only imported BASE_CSS (palette + SVG primitives), not the standalone schematic's chrome CSS — so header/legend/body were unstyled (white background, serif fallback fonts, run-together legend, panel shoved off-screen by an auto-min grid track). Rewrite with self-contained chrome, Linear-flavored: - flat near-black (#08090a), one muted accent (indigo #5e6ad2) - system sans (offline-safe; drops the Google Fonts serif fallback) - grid-template-columns: minmax(0,1fr) 300px — SVG scrolls, panel stays put - calm motion: active node gets a soft indigo ring, not a neon drop-shadow - tightened panel typography, status-dot wordmark, spaced legend, slim scrollbars Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/viz/src/live.ts | 69 ++++++++++++++++++++++++++-------------- 1 file changed, 46 insertions(+), 23 deletions(-) diff --git a/packages/viz/src/live.ts b/packages/viz/src/live.ts index d5e3546..da78e21 100644 --- a/packages/viz/src/live.ts +++ b/packages/viz/src/live.ts @@ -1,34 +1,58 @@ import type { LoopFile } from "@loop-lang/parser"; -import { FONTS, BASE_CSS, RENDERER_JS } from "./render.js"; +import { BASE_CSS, RENDERER_JS } from "./render.js"; import type { VizOptions } from "./render.js"; +// Linear-style dark restyle. Self-contained chrome (header/legend/body) because the +// standalone schematic's chrome CSS isn't exported — and this is the slicker look anyway: +// flat near-black, system sans (offline-safe, no serif fallback), one muted accent, calm +// motion. Overrides the BASE_CSS :root palette and neon glows. +const SANS = `-apple-system,BlinkMacSystemFont,"Segoe UI",Inter,Roboto,Helvetica,Arial,sans-serif`; +const MONO = `ui-monospace,"SF Mono",SFMono-Regular,Menlo,Consolas,monospace`; const LIVE_CSS = ` -body.live{display:grid;grid-template-columns:1fr 300px;grid-template-rows:auto 1fr;height:100vh;overflow:hidden;} -body.live header{grid-column:1/-1;} -body.live main{overflow:auto;padding:12px 18px 60px;} +:root{--bg:#08090a;--panel:#0c0d0f;--node:#15161a;--node-br:#2a2b30;--ink:#f7f8f8;--muted:#878a92;--line:#1d1e22;--fwd:#5e6ad2;--reflect:#8d7ee6;--stop:#4cb782;--gate:#d39a52;} +*{box-sizing:border-box;} +html,body{margin:0;height:100%;} +body.live{display:grid;grid-template-columns:minmax(0,1fr) 300px;grid-template-rows:auto 1fr;height:100vh;overflow:hidden;background:var(--bg);color:var(--ink);font-family:${SANS};-webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility;} +body.live header{grid-column:1/-1;display:flex;align-items:center;gap:14px;padding:12px 20px;border-bottom:1px solid var(--line);background:var(--panel);} +.wordmark{display:flex;align-items:center;gap:9px;font-family:${SANS};font-size:14px;font-weight:600;letter-spacing:-.01em;color:var(--ink);background:none;-webkit-text-fill-color:var(--ink);text-transform:none;} +.wordmark::before{content:"";width:7px;height:7px;border-radius:50%;background:var(--fwd);box-shadow:0 0 0 3px rgba(94,106,210,.16);} +.subtitle{font-size:12px;color:var(--muted);letter-spacing:-.01em;} +.legend{margin-left:auto;display:flex;gap:18px;font-size:11px;color:var(--muted);} +.legend span{display:inline-flex;align-items:center;gap:7px;} +.legend i{display:inline-block;width:15px;height:0;border-top:2px solid currentColor;} +.legend .fwd{color:var(--fwd);}.legend .ref{color:var(--reflect);}.legend .ref{border-top-style:dashed;} +.legend .gat{color:var(--gate);}.legend .stp{color:var(--stop);} +body.live main{overflow:auto;padding:20px 22px 60px;} +svg{display:block;} +.nlabel,.panel-title{font-family:${SANS}!important;font-weight:600;letter-spacing:-.01em;} +.sub,.chip{font-family:${SANS}!important;} +.fwd-flow{animation-duration:2.6s;} +.reflect-flow{animation-duration:1.8s;} +[data-cyc].lp-active rect:first-of-type{stroke:var(--fwd);stroke-width:1.7;filter:none;animation:lp-ring 1.5s ease-in-out infinite;} +[data-cyc].lp-done{opacity:.4;} +[data-cyc].lp-fail rect:first-of-type{stroke:var(--gate)!important;} +@keyframes lp-ring{0%,100%{opacity:1}50%{opacity:.5}} +[data-edge="reflect"].lp-fire{animation:lp-ring .5s ease-out 3;} #lp-panel{background:var(--panel);border-left:1px solid var(--line);display:flex;flex-direction:column;overflow:hidden;} -#lp-panel section{padding:12px 14px;border-bottom:1px solid var(--line);flex-shrink:0;} -#lp-panel h3{margin:0 0 6px;font-family:"Saira Condensed",sans-serif;font-size:11px;letter-spacing:1.2px;text-transform:uppercase;color:var(--muted);} -#lp-step{font-size:13px;color:var(--fwd);font-family:"Space Mono",monospace;word-break:break-all;} -#lp-foreach-bar{height:4px;background:var(--line);border-radius:2px;margin-bottom:8px;} -#lp-foreach-fill{height:100%;background:var(--fwd);border-radius:2px;transition:width .3s;width:0%;} -#lp-foreach-list,#lp-flow-list{list-style:none;margin:0;padding:0;font-size:11px;font-family:"Space Mono",monospace;max-height:140px;overflow-y:auto;} -#lp-foreach-list li,#lp-flow-list li{padding:3px 0;border-bottom:1px solid var(--line);color:var(--muted);display:flex;gap:6px;align-items:center;} -#lp-foreach-list li.lp-active,#lp-flow-list li.lp-active{color:var(--fwd);} +#lp-panel section{padding:14px 16px;border-bottom:1px solid var(--line);flex-shrink:0;} +#lp-panel h3{margin:0 0 8px;font-family:${SANS};font-size:11px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--muted);} +#lp-step{font-size:12.5px;color:var(--ink);font-family:${MONO};word-break:break-all;line-height:1.5;} +#lp-foreach-bar{height:5px;background:var(--line);border-radius:3px;margin-bottom:10px;overflow:hidden;} +#lp-foreach-fill{height:100%;background:var(--fwd);border-radius:3px;transition:width .35s ease;width:0%;} +#lp-foreach-list,#lp-flow-list{list-style:none;margin:0;padding:0;font-size:12px;font-family:${SANS};max-height:150px;overflow-y:auto;} +#lp-foreach-list li,#lp-flow-list li{padding:4px 0;color:var(--muted);display:flex;gap:8px;align-items:center;letter-spacing:-.01em;} +#lp-foreach-list li span:first-child,#lp-flow-list li span:first-child{font-family:${MONO};font-size:11px;width:12px;text-align:center;flex-shrink:0;} +#lp-foreach-list li.lp-active,#lp-flow-list li.lp-active{color:var(--ink);} #lp-foreach-list li.lp-done,#lp-flow-list li.lp-done{color:var(--stop);} #lp-foreach-list li.lp-fail,#lp-flow-list li.lp-fail{color:var(--gate);} #lp-log{flex:1;overflow:hidden;display:flex;flex-direction:column;min-height:0;} -#lp-log-wrap{overflow-y:auto;flex:1;} -#lp-log-list{list-style:none;margin:0;padding:0;font-size:11px;font-family:"Space Mono",monospace;} -#lp-log-list li{padding:2px 6px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;} -#lp-log-list li.lp-ev-enter{color:var(--fwd);} +#lp-log-wrap{overflow-y:auto;flex:1;margin:0 -16px;padding:0 16px;} +#lp-log-list{list-style:none;margin:0;padding:0;font-size:11px;font-family:${MONO};} +#lp-log-list li{padding:2.5px 0;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;letter-spacing:-.02em;} +#lp-log-list li.lp-ev-enter{color:var(--ink);} #lp-log-list li.lp-ev-fail{color:var(--gate);} #lp-log-list li.lp-ev-human{color:var(--reflect);} -[data-cyc].lp-active rect:first-of-type{stroke-width:3;filter:drop-shadow(0 0 10px currentColor);animation:lp-pulse 1.1s ease-in-out infinite;} -[data-cyc].lp-done{opacity:.38;} -[data-cyc].lp-fail rect:first-of-type{stroke:var(--gate)!important;} -@keyframes lp-pulse{0%,100%{opacity:1}50%{opacity:.5}} -[data-edge="reflect"].lp-fire{animation:firepulse .5s ease-out 3;} +::-webkit-scrollbar{width:8px;height:8px;}::-webkit-scrollbar-thumb{background:var(--line);border-radius:4px;}::-webkit-scrollbar-track{background:transparent;} `; // ponytail: plain-var JS to stay embeddable alongside RENDERER_JS @@ -173,7 +197,6 @@ export function renderLiveHtml(file: LoopFile, opts: VizOptions = {}): string { Loop · live · ${title} -${FONTS} @@ -182,7 +205,7 @@ ${FONTS}
    signalreflect - human gatedone + gatedone
    From c05991172ea82b80e927c92b1b37c7a48c6b0308 Mon Sep 17 00:00:00 2001 From: Idan Ayalon Date: Sun, 28 Jun 2026 00:17:53 -0400 Subject: [PATCH 09/22] fix: address cloud-review findings (SSE buffer, Windows, escaping) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - serve.ts: replay buffer — events fired before the browser connects were dropped (empty clients[]); now buffered (bounded 10k) and replayed on each /events connect. Fixes dashboards stuck on 'starting…'. (merged_bug_009) - serve.ts: Windows browser open via 'cmd /c start "" ' — 'start' is a cmd built-in, execFile('start') ENOENTs. (bug_002) - cli.ts: title from path.basename(fileArg), not split('/') — backslash paths on Windows showed the full path. Both viz and live branches. (bug_007) - live.ts: HTML-escape the page . (bug_006) - live.ts: flow-step list updates its bullet ○→▶→✓/✗ like the foreach panel, not just color. (bug_021) - version.test.mjs: cwd via fileURLToPath, not URL.pathname (/C:/… on Windows). (bug_004) bug_001 (dashboard unstyled — missing chrome CSS) already fixed by the Linear redesign, which ships self-contained chrome. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- packages/cli/test/version.test.mjs | 8 ++++++-- packages/runtime/src/cli.ts | 8 ++++---- packages/runtime/src/serve.ts | 21 ++++++++++++++++----- packages/viz/src/live.ts | 17 ++++++++++------- 4 files changed, 36 insertions(+), 18 deletions(-) diff --git a/packages/cli/test/version.test.mjs b/packages/cli/test/version.test.mjs index 2192bd6..09bee15 100644 --- a/packages/cli/test/version.test.mjs +++ b/packages/cli/test/version.test.mjs @@ -2,13 +2,17 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { execFileSync } from "node:child_process"; import { createRequire } from "node:module"; +import { fileURLToPath } from "node:url"; const require = createRequire(import.meta.url); const { version } = require("../package.json"); +// packages/cli — cross-platform (URL.pathname yields "/C:/…" on Windows; fileURLToPath fixes it) +const pkgDir = fileURLToPath(new URL("..", import.meta.url)); + test("loop version prints expected string", () => { const out = execFileSync(process.execPath, ["src/cli.mjs", "version"], { - cwd: new URL("..", import.meta.url).pathname, + cwd: pkgDir, encoding: "utf8", }); assert.equal(out.trim(), `@loop-lang/loop v${version}`); @@ -16,7 +20,7 @@ test("loop version prints expected string", () => { test("loop --version also works", () => { const out = execFileSync(process.execPath, ["src/cli.mjs", "--version"], { - cwd: new URL("..", import.meta.url).pathname, + cwd: pkgDir, encoding: "utf8", }); assert.equal(out.trim(), `@loop-lang/loop v${version}`); diff --git a/packages/runtime/src/cli.ts b/packages/runtime/src/cli.ts index 83385bf..0c64847 100644 --- a/packages/runtime/src/cli.ts +++ b/packages/runtime/src/cli.ts @@ -1,6 +1,6 @@ #!/usr/bin/env node import { appendFileSync, readFileSync, writeFileSync, readdirSync } from "node:fs"; -import { dirname, resolve, relative } from "node:path"; +import { dirname, resolve, relative, basename } from "node:path"; import { createInterface } from "node:readline"; import { parse } from "@loop-lang/parser"; import { resolvePreset } from "@loop-lang/stdlib"; @@ -192,7 +192,7 @@ async function main() { if (cmd === "live") { const { renderLiveHtml } = await import("@loop-lang/viz"); const { startLiveServer } = await import("./serve.js"); - const html = renderLiveHtml(file, { title: fileArg.split("/").pop() }); + const html = renderLiveHtml(file, { title: basename(fileArg) }); const noOpen = rest.includes("--no-open"); const srv = await startLiveServer(html, { open: !noOpen }); // Machine-readable line first so a driver can grep the port deterministically. @@ -208,7 +208,7 @@ async function main() { // Visualize: write a self-contained HTML schematic of the flow. if (cmd === "viz") { const { renderHtml } = await import("@loop-lang/viz"); - const html = renderHtml(file, { title: fileArg.split("/").pop() }); + const html = renderHtml(file, { title: basename(fileArg) }); const vOutIdx = rest.indexOf("--out"); const dest = resolve(process.cwd(), vOutIdx >= 0 ? rest[vOutIdx + 1] : fileArg.replace(/\.loop$/, "") + ".html"); writeFileSync(dest, html); @@ -259,7 +259,7 @@ async function main() { if (rest.includes("--live")) { const { renderLiveHtml } = await import("@loop-lang/viz"); const { startLiveServer } = await import("./serve.js"); - const html = renderLiveHtml(file, { title: fileArg.split("/").pop() }); + const html = renderLiveHtml(file, { title: basename(fileArg) }); const srv = await startLiveServer(html); console.error(`\n ↻ Loop live dashboard → http://127.0.0.1:${srv.port}\n`); const liveEvents: LoopEvent[] = []; diff --git a/packages/runtime/src/serve.ts b/packages/runtime/src/serve.ts index 536e3ce..c80daea 100644 --- a/packages/runtime/src/serve.ts +++ b/packages/runtime/src/serve.ts @@ -24,8 +24,16 @@ export interface LiveServerOptions { */ export function startLiveServer(html: string, opts: LiveServerOptions = {}): Promise<LiveServer> { const clients: ServerResponse[] = []; + // Replay buffer: events fire before the browser finishes launching + connecting, so a + // fresh /events client would miss everything up to its connect. Buffer and replay on + // connect. ponytail: bounded ring (last 10k events) — the dashboard reconstructs state + // from the stream, so dropping the oldest events on a very long run is harmless. + const buffer: unknown[] = []; + const BUFFER_CAP = 10_000; function broadcast(e: unknown): void { + buffer.push(e); + if (buffer.length > BUFFER_CAP) buffer.shift(); const data = `data: ${JSON.stringify(e)}\n\n`; for (const c of [...clients]) { try { c.write(data); } catch { /* client disconnected */ } @@ -41,6 +49,7 @@ export function startLiveServer(html: string, opts: LiveServerOptions = {}): Pro Connection: "keep-alive", }); res.write("retry: 2000\n\n"); + for (const past of buffer) res.write(`data: ${JSON.stringify(past)}\n\n`); clients.push(res); req.on("close", () => { const i = clients.indexOf(res); @@ -74,11 +83,13 @@ export function startLiveServer(html: string, opts: LiveServerOptions = {}): Pro const addr = server.address() as { port: number }; const url = `http://127.0.0.1:${addr.port}`; if (opts.open !== false) { - // Best-effort browser open — platform-specific, ignore failures - const opener = process.platform === "darwin" ? "open" - : process.platform === "win32" ? "start" - : "xdg-open"; - execFile(opener, [url], () => { /* ignore errors */ }); + // Best-effort browser open — platform-specific, ignore failures. + // Windows `start` is a cmd.exe built-in (no start.exe), so go via cmd; the empty + // "" is start's window-title arg, required so the URL isn't taken as the title. + const [opener, args] = process.platform === "darwin" ? ["open", [url]] + : process.platform === "win32" ? ["cmd", ["/c", "start", "", url]] + : ["xdg-open", [url]]; + execFile(opener, args, () => { /* ignore errors */ }); } resolve({ diff --git a/packages/viz/src/live.ts b/packages/viz/src/live.ts index da78e21..f83707f 100644 --- a/packages/viz/src/live.ts +++ b/packages/viz/src/live.ts @@ -128,13 +128,13 @@ function handle(e){ qa('#lp-flow-list li').forEach(function(l){l.classList.remove('lp-active');}); var safeName=e.name.split('"').join('\\\\"'); var fli=q('[data-lp-step="'+safeName+'"]'); - if(fli)fli.classList.add('lp-active'); + if(fli){fli.classList.add('lp-active');if(fli.firstChild)fli.firstChild.textContent='\\u25b6';} log('\\u25b8 '+e.name,'lp-ev-enter'); break;} case 'flow-step-end':{ var safeName2=e.name.split('"').join('\\\\"'); var fli2=q('[data-lp-step="'+safeName2+'"]'); - if(fli2){fli2.classList.remove('lp-active');fli2.classList.add(e.satisfied?'lp-done':'lp-fail');} + if(fli2){fli2.classList.remove('lp-active');fli2.classList.add(e.satisfied?'lp-done':'lp-fail');if(fli2.firstChild)fli2.firstChild.textContent=e.satisfied?'\\u2713':'\\u2717';} break;} case 'stage-start': setStep('stage: '+e.name); @@ -176,18 +176,21 @@ function embedJson(value: unknown): string { .split(PS).join("\\u2029"); } +/** Escape a string for safe interpolation into HTML text / attributes. */ +function htmlEscape(s: string): string { + return s.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<"); +} + export function renderLiveHtml(file: LoopFile, opts: VizOptions = {}): string { const title = opts.title ?? "loop"; + const safeTitle = htmlEscape(title); // Pre-populate flow-step list items for known flow definitions let flowStepItems = ""; for (const def of file.definitions) { if (def.kind === "flow") { for (const step of def.steps) { - const safe = step.name - .replace(/&/g, "&") - .replace(/"/g, """) - .replace(/</g, "<"); + const safe = htmlEscape(step.name); flowStepItems += `<li data-lp-step="${safe}"><span>○</span><span>${safe}</span></li>`; } } @@ -196,7 +199,7 @@ export function renderLiveHtml(file: LoopFile, opts: VizOptions = {}): string { return `<!doctype html> <html lang="en"><head> <meta charset="utf-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/> -<title>Loop · live · ${title} +Loop · live · ${safeTitle} From 6d1da38dfdf8de6f1c22b4080e439678187bc0c5 Mon Sep 17 00:00:00 2001 From: Idan Ayalon Date: Sun, 28 Jun 2026 00:25:54 -0400 Subject: [PATCH 10/22] feat(viz): live dashboard is now a dynamic Waze-style route, not a generic schematic The old view drew the same abstract plan->act->observe->stop diagram for every stage, which didn't reflect what the user's actual .loop does. Replace it with a turn-by-turn route built from the parsed structure: - Renders THIS file's real shape: pipeline stages / flow steps / for-each items, each with its goal, done-when (humanized), and gates. - "NOW" banner = you-are-here + the steps ahead (next: X -> Y), Waze-style. - Human-in-the-loop: a prominent "needs you" banner + amber pulsing node on the waiting step. - for-each sprints list each item by label (e.g. story titles) with N/total progress, a green check per finished item, and a plan/act/observe micro-tracker on the item being worked now. - Driven entirely by the event stream; self-contained Linear-dark styling, no SVG. foreach-start now accepts an optional "labels" array so item rows show real titles instead of "story 1..N". Verified in a headless browser (Playwright) across pipeline + flow+for-each runs: route renders from structure, you-are-here/next/human-gate/sprint-progress all update live, zero console errors. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/viz/src/live.ts | 514 ++++++++++++++++++++++++--------------- 1 file changed, 317 insertions(+), 197 deletions(-) diff --git a/packages/viz/src/live.ts b/packages/viz/src/live.ts index f83707f..e293673 100644 --- a/packages/viz/src/live.ts +++ b/packages/viz/src/live.ts @@ -1,172 +1,321 @@ import type { LoopFile } from "@loop-lang/parser"; -import { BASE_CSS, RENDERER_JS } from "./render.js"; import type { VizOptions } from "./render.js"; -// Linear-style dark restyle. Self-contained chrome (header/legend/body) because the -// standalone schematic's chrome CSS isn't exported — and this is the slicker look anyway: -// flat near-black, system sans (offline-safe, no serif fallback), one muted accent, calm -// motion. Overrides the BASE_CSS :root palette and neon glows. +// The live dashboard renders the user's ACTUAL .loop as a turn-by-turn "route" (Waze-style): +// the real structure of THIS file — pipeline stages, flow steps, or for-each sprint items — +// with a clear "you are here", the steps ahead, and human gates flagged. Driven live by the +// event stream. Self-contained: Linear-dark chrome, system fonts (offline-safe), no SVG. const SANS = `-apple-system,BlinkMacSystemFont,"Segoe UI",Inter,Roboto,Helvetica,Arial,sans-serif`; const MONO = `ui-monospace,"SF Mono",SFMono-Regular,Menlo,Consolas,monospace`; + const LIVE_CSS = ` -:root{--bg:#08090a;--panel:#0c0d0f;--node:#15161a;--node-br:#2a2b30;--ink:#f7f8f8;--muted:#878a92;--line:#1d1e22;--fwd:#5e6ad2;--reflect:#8d7ee6;--stop:#4cb782;--gate:#d39a52;} +:root{--bg:#08090a;--panel:#0c0d0f;--card:#101116;--ink:#f7f8f8;--muted:#878a92;--faint:#5a5d66;--line:#1d1e22;--fwd:#5e6ad2;--reflect:#8d7ee6;--stop:#4cb782;--gate:#e0a35e;--fail:#e5564d;} *{box-sizing:border-box;} html,body{margin:0;height:100%;} -body.live{display:grid;grid-template-columns:minmax(0,1fr) 300px;grid-template-rows:auto 1fr;height:100vh;overflow:hidden;background:var(--bg);color:var(--ink);font-family:${SANS};-webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility;} -body.live header{grid-column:1/-1;display:flex;align-items:center;gap:14px;padding:12px 20px;border-bottom:1px solid var(--line);background:var(--panel);} -.wordmark{display:flex;align-items:center;gap:9px;font-family:${SANS};font-size:14px;font-weight:600;letter-spacing:-.01em;color:var(--ink);background:none;-webkit-text-fill-color:var(--ink);text-transform:none;} +body{display:grid;grid-template-columns:minmax(0,1fr) 290px;grid-template-rows:auto 1fr;height:100vh;overflow:hidden;background:var(--bg);color:var(--ink);font-family:${SANS};-webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility;font-size:14px;} +header{grid-column:1/-1;display:flex;align-items:center;gap:12px;padding:12px 20px;border-bottom:1px solid var(--line);background:var(--panel);} +.wordmark{display:flex;align-items:center;gap:9px;font-size:14px;font-weight:600;letter-spacing:-.01em;} .wordmark::before{content:"";width:7px;height:7px;border-radius:50%;background:var(--fwd);box-shadow:0 0 0 3px rgba(94,106,210,.16);} -.subtitle{font-size:12px;color:var(--muted);letter-spacing:-.01em;} -.legend{margin-left:auto;display:flex;gap:18px;font-size:11px;color:var(--muted);} -.legend span{display:inline-flex;align-items:center;gap:7px;} -.legend i{display:inline-block;width:15px;height:0;border-top:2px solid currentColor;} -.legend .fwd{color:var(--fwd);}.legend .ref{color:var(--reflect);}.legend .ref{border-top-style:dashed;} -.legend .gat{color:var(--gate);}.legend .stp{color:var(--stop);} -body.live main{overflow:auto;padding:20px 22px 60px;} -svg{display:block;} -.nlabel,.panel-title{font-family:${SANS}!important;font-weight:600;letter-spacing:-.01em;} -.sub,.chip{font-family:${SANS}!important;} -.fwd-flow{animation-duration:2.6s;} -.reflect-flow{animation-duration:1.8s;} -[data-cyc].lp-active rect:first-of-type{stroke:var(--fwd);stroke-width:1.7;filter:none;animation:lp-ring 1.5s ease-in-out infinite;} -[data-cyc].lp-done{opacity:.4;} -[data-cyc].lp-fail rect:first-of-type{stroke:var(--gate)!important;} -@keyframes lp-ring{0%,100%{opacity:1}50%{opacity:.5}} -[data-edge="reflect"].lp-fire{animation:lp-ring .5s ease-out 3;} -#lp-panel{background:var(--panel);border-left:1px solid var(--line);display:flex;flex-direction:column;overflow:hidden;} -#lp-panel section{padding:14px 16px;border-bottom:1px solid var(--line);flex-shrink:0;} -#lp-panel h3{margin:0 0 8px;font-family:${SANS};font-size:11px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--muted);} -#lp-step{font-size:12.5px;color:var(--ink);font-family:${MONO};word-break:break-all;line-height:1.5;} -#lp-foreach-bar{height:5px;background:var(--line);border-radius:3px;margin-bottom:10px;overflow:hidden;} -#lp-foreach-fill{height:100%;background:var(--fwd);border-radius:3px;transition:width .35s ease;width:0%;} -#lp-foreach-list,#lp-flow-list{list-style:none;margin:0;padding:0;font-size:12px;font-family:${SANS};max-height:150px;overflow-y:auto;} -#lp-foreach-list li,#lp-flow-list li{padding:4px 0;color:var(--muted);display:flex;gap:8px;align-items:center;letter-spacing:-.01em;} -#lp-foreach-list li span:first-child,#lp-flow-list li span:first-child{font-family:${MONO};font-size:11px;width:12px;text-align:center;flex-shrink:0;} -#lp-foreach-list li.lp-active,#lp-flow-list li.lp-active{color:var(--ink);} -#lp-foreach-list li.lp-done,#lp-flow-list li.lp-done{color:var(--stop);} -#lp-foreach-list li.lp-fail,#lp-flow-list li.lp-fail{color:var(--gate);} -#lp-log{flex:1;overflow:hidden;display:flex;flex-direction:column;min-height:0;} -#lp-log-wrap{overflow-y:auto;flex:1;margin:0 -16px;padding:0 16px;} -#lp-log-list{list-style:none;margin:0;padding:0;font-size:11px;font-family:${MONO};} -#lp-log-list li{padding:2.5px 0;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;letter-spacing:-.02em;} -#lp-log-list li.lp-ev-enter{color:var(--ink);} -#lp-log-list li.lp-ev-fail{color:var(--gate);} -#lp-log-list li.lp-ev-human{color:var(--reflect);} -::-webkit-scrollbar{width:8px;height:8px;}::-webkit-scrollbar-thumb{background:var(--line);border-radius:4px;}::-webkit-scrollbar-track{background:transparent;} +.subtitle{font-size:12px;color:var(--muted);} +.status{margin-left:auto;font-size:12px;color:var(--muted);display:flex;align-items:center;gap:7px;} +.status .dot{width:7px;height:7px;border-radius:50%;background:var(--faint);} +.status.live .dot{background:var(--stop);box-shadow:0 0 0 3px rgba(76,183,130,.16);} +.status.done .dot{background:var(--stop);}.status.fail .dot{background:var(--fail);} + +main{overflow-y:auto;padding:0;} +/* Waze "now" banner */ +#now{position:sticky;top:0;z-index:5;background:linear-gradient(180deg,var(--panel),rgba(12,13,15,.86));backdrop-filter:blur(6px);border-bottom:1px solid var(--line);padding:16px 24px;} +#now-kicker{font-size:10.5px;font-weight:600;letter-spacing:.12em;text-transform:uppercase;color:var(--muted);} +#now-step{font-size:21px;font-weight:600;letter-spacing:-.02em;margin-top:3px;line-height:1.2;} +#now-next{font-size:12.5px;color:var(--muted);margin-top:6px;} +#now-next b{color:var(--ink);font-weight:500;} +#now.human{background:linear-gradient(180deg,rgba(224,163,94,.14),rgba(12,13,15,.9));border-bottom-color:var(--gate);} +#now-human{display:none;margin-top:10px;padding:9px 12px;border:1px solid var(--gate);border-radius:8px;background:rgba(224,163,94,.08);font-size:13px;color:var(--ink);} +#now.human #now-human{display:block;} +#now-human b{color:var(--gate);} + +/* route */ +#route{padding:18px 24px 80px;} +.def{margin-bottom:26px;} +.def-head{display:flex;align-items:baseline;gap:10px;margin-bottom:14px;} +.def-name{font-size:14px;font-weight:600;letter-spacing:-.01em;} +.def-kind{font-size:11px;color:var(--muted);font-family:${MONO};} +.legs,.items{list-style:none;margin:0;padding:0;} +.leg{position:relative;padding:0 0 4px 30px;} +.leg::before{content:"";position:absolute;left:8px;top:20px;bottom:-4px;width:2px;background:var(--line);} +.leg:last-child::before{display:none;} +.node{position:absolute;left:0;top:5px;width:18px;height:18px;border-radius:50%;border:2px solid var(--line);background:var(--bg);display:flex;align-items:center;justify-content:center;font-size:10px;font-family:${MONO};color:var(--faint);z-index:1;} +.leg.current>.node{border-color:var(--fwd);background:var(--fwd);color:#fff;box-shadow:0 0 0 4px rgba(94,106,210,.18);animation:pulse 1.6s ease-in-out infinite;} +.leg.done>.node{border-color:var(--stop);color:var(--stop);} +.leg.fail>.node{border-color:var(--fail);color:var(--fail);} +.leg.human>.node{border-color:var(--gate);background:var(--gate);color:#fff;box-shadow:0 0 0 4px rgba(224,163,94,.2);animation:pulse 1.2s ease-in-out infinite;} +@keyframes pulse{0%,100%{box-shadow:0 0 0 4px rgba(94,106,210,.18);}50%{box-shadow:0 0 0 7px rgba(94,106,210,.05);}} +.leg-card{padding:6px 0 14px;} +.leg-title{font-size:14px;font-weight:500;letter-spacing:-.01em;display:flex;align-items:center;gap:8px;flex-wrap:wrap;} +.leg.upcoming .leg-title{color:var(--muted);} +.leg.current .leg-title{color:var(--ink);} +.leg.done .leg-title{color:var(--muted);} +.you{font-size:10px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:var(--fwd);border:1px solid var(--fwd);border-radius:5px;padding:1px 6px;} +.badge{font-size:11px;border-radius:5px;padding:1px 7px;border:1px solid var(--line);color:var(--muted);font-weight:500;} +.badge.gate{border-color:var(--gate);color:var(--gate);} +.leg-meta{font-size:12px;color:var(--muted);margin-top:5px;line-height:1.5;} +.leg-meta code{font-family:${MONO};font-size:11px;color:var(--ink);background:var(--card);padding:1px 5px;border-radius:4px;} +.leg-meta .k{color:var(--faint);} +/* micro cycle tracker — only on the active leg/item */ +.cyc{display:none;gap:6px;margin-top:9px;align-items:center;font-family:${MONO};font-size:11px;} +.leg.current>.leg-card .cyc,.item.current>.cyc{display:flex;} +.cyc span{color:var(--faint);padding:2px 8px;border:1px solid var(--line);border-radius:5px;} +.cyc span.on{color:#fff;background:var(--fwd);border-color:var(--fwd);} +.cyc span.ok{color:var(--stop);border-color:var(--stop);} +.cyc .sep{border:0;padding:0;color:var(--faint);} +/* for-each items */ +.items{margin:10px 0 0;} +.item{position:relative;padding:0 0 2px 26px;min-height:24px;} +.item::before{content:"";position:absolute;left:7px;top:18px;bottom:-2px;width:2px;background:var(--line);} +.item:last-child::before{display:none;} +.item>.node{width:16px;height:16px;top:3px;font-size:9px;} +.item.current>.node{border-color:var(--fwd);background:var(--fwd);color:#fff;box-shadow:0 0 0 3px rgba(94,106,210,.2);} +.item.done>.node{border-color:var(--stop);color:var(--stop);} +.item.fail>.node{border-color:var(--fail);color:var(--fail);} +.item-title{font-size:13px;color:var(--muted);padding:1px 0 8px;letter-spacing:-.01em;} +.item.current>.item-title{color:var(--ink);font-weight:500;} +.item.done>.item-title{color:var(--muted);} +.item-count{font-size:11px;color:var(--faint);font-family:${MONO};margin-left:6px;} + +/* right log panel */ +#side{background:var(--panel);border-left:1px solid var(--line);display:flex;flex-direction:column;overflow:hidden;} +#side h3{margin:0;padding:13px 16px 9px;font-size:10.5px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:var(--muted);border-bottom:1px solid var(--line);} +#log-wrap{overflow-y:auto;flex:1;padding:8px 16px;} +#log{list-style:none;margin:0;padding:0;font-family:${MONO};font-size:11px;} +#log li{padding:2.5px 0;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;letter-spacing:-.02em;} +#log li.enter{color:var(--ink);}#log li.fail{color:var(--fail);}#log li.human{color:var(--gate);}#log li.done{color:var(--stop);} +::-webkit-scrollbar{width:9px;height:9px;}::-webkit-scrollbar-thumb{background:var(--line);border-radius:5px;}::-webkit-scrollbar-track{background:transparent;} `; -// ponytail: plain-var JS to stay embeddable alongside RENDERER_JS -const LIVE_JS = `(function(){ -var es=new EventSource('/events'); -es.onmessage=function(ev){try{handle(JSON.parse(ev.data));}catch(_){}}; -function q(s){return document.querySelector(s);} -function qa(s){return Array.prototype.slice.call(document.querySelectorAll(s));} -function setStep(t){var el=q('#lp-step');if(el)el.textContent=t;} -function log(txt,cls){ - var ul=q('#lp-log-list');if(!ul)return; - var li=document.createElement('li');li.textContent=txt; - if(cls)li.className=cls; - ul.insertBefore(li,ul.firstChild); - if(ul.children.length>80)ul.removeChild(ul.lastChild); +// Page script: builds the route from SPEC, then mutates it from the live event stream. +// Plain ES5-ish (no template literals) so it embeds cleanly. +const APP_JS = `(function(){ +function q(s,r){return (r||document).querySelector(s);} +function qa(s,r){return Array.prototype.slice.call((r||document).querySelectorAll(s));} +function el(tag,cls,txt){var e=document.createElement(tag);if(cls)e.className=cls;if(txt!=null)e.textContent=txt;return e;} +function esc(s){return String(s==null?"":s);} + +function doneLabel(dw){ + if(!dw)return "you confirm"; + if(dw.type==="test")return "test "+dw.target; + if(dw.type==="command")return (dw.expect==="empty"?"empty: ":"")+dw.command; + if(dw.type==="skill")return "skill "+dw.skill+(dw.minScore!=null?" \\u2265"+dw.minScore:""); + if(dw.type==="human")return "you confirm"+(dw.description?': "'+dw.description+'"':""); + return "you confirm"; +} +function cycTracker(cycle){ + var steps=(cycle&&cycle.length)?cycle:["plan","act","observe"]; + var box=el("div","cyc"); + for(var i=0;i120)ul.removeChild(ul.lastChild);} +function legTitleText(leg){var t=q(".leg-title",leg)||q(".item-title",leg);return t?t.textContent.trim():"";} + +function setNow(stepLabel,scopeLeg){ + q("#now-step").textContent=stepLabel; + // next = following sibling legs in the same list + var nexts=[]; + var ref=scopeLeg||currentLeg; + if(ref){var n=ref.nextElementSibling;var c=0;while(n&&c<3){nexts.push(legTitleText(n).replace(/\\s+/g," "));n=n.nextElementSibling;c++;}} + var box=q("#now-next"); + if(nexts.length){box.innerHTML="";box.appendChild(document.createTextNode("next: "));var b=el("b",null,nexts[0]);box.appendChild(b);if(nexts.length>1)box.appendChild(document.createTextNode(" \\u2192 "+nexts.slice(1).join(" \\u2192 ")));} + else{box.textContent="last step";} +} +function markCurrentLeg(leg){ + if(!leg)return; + if(currentLeg&¤tLeg!==leg){currentLeg.classList.remove("current","human");if(!currentLeg.classList.contains("fail"))currentLeg.classList.add("done");currentLeg.classList.remove("upcoming");} + leg.classList.remove("upcoming","done");leg.classList.add("current"); + currentLeg=leg;currentItem=null; + leg.scrollIntoView({block:"center",behavior:"smooth"}); +} +function resetCyc(scope){qa("[data-cyc]",scope).forEach(function(s){s.classList.remove("on","ok");});} + function handle(e){ switch(e.type){ - case 'node-enter': - clearCyc(e.node); - qa('[data-cyc="'+e.node+'"]').forEach(function(n){n.classList.add('lp-active');}); - setStep(e.node+' \xb7 cycle '+e.attempt); - log('\\u2192 '+e.node+' (try '+e.attempt+')','lp-ev-enter'); - break; - case 'node-exit': - qa('[data-cyc="'+e.node+'"]').forEach(function(n){ - n.classList.remove('lp-active'); - n.classList.add(e.ok?'lp-done':'lp-fail'); - }); - if(!e.ok)log('\\u2717 '+e.node+' failed','lp-ev-fail'); - break; - case 'loop-back': - var arc=q('[data-edge="reflect"]'); - if(arc){arc.classList.remove('lp-fire');void arc.offsetWidth;arc.classList.add('lp-fire');} - log('\\u21ba back to '+e.to); - break; - case 'foreach-start':{ - var sec=q('#lp-foreach');if(sec)sec.style.display=''; - var ul=q('#lp-foreach-list');if(!ul)break;while(ul.firstChild)ul.removeChild(ul.firstChild); - for(var i=0;i .item'); + items.forEach(function(x){x.classList.remove("current");}); + var it2=items[e.index]; + if(it2){it2.classList.remove("upcoming");it2.classList.add("current");currentItem=it2;resetCyc(it2);it2.scrollIntoView({block:"center",behavior:"smooth"});} + var lh=q('[data-foreach="'+cssEsc(e["var"])+'"]');if(lh){var c2=q("[data-count]",lh);if(c2)c2.textContent=(e.index+1)+"/"+e.total;} + q("#now-step").textContent=(it2?legTitleText(it2):e["var"]+" "+(e.index+1))+" ("+(e.index+1)+"/"+e.total+")"; + // "next" = the remaining stories in this sprint + var rest=[];for(var ri=e.index+1;ri1)nb.appendChild(document.createTextNode(" \\u2192 "+rest.slice(1).join(" \\u2192 ")));} + else nb.textContent="last item"; + logLine("\\u2022 "+e["var"]+" "+(e.index+1)+"/"+e.total,"enter");break;} + case "foreach-item-end":{ + var items2=qa('[data-items="'+cssEsc(e["var"])+'"] > .item'); + var it3=items2[e.index];if(it3){it3.classList.remove("current","upcoming");it3.classList.add(e.satisfied?"done":"fail");var nd=q(".node",it3);if(nd)nd.textContent=e.satisfied?"\\u2713":"\\u2717";} + currentItem=null;break;} + + case "node-enter":{ + var scope=nowEl();if(scope){resetCyc(scope);var s=q('[data-cyc="'+e.node+'"]',scope);if(s)s.classList.add("on");} + q("#now-step").textContent=(currentItem?legTitleText(currentItem)+" \\u2192 ":"")+e.node; + logLine("\\u2192 "+e.node+" (try "+e.attempt+")","enter");break;} + case "node-exit":{ + var scope2=nowEl();if(scope2){var s2=q('[data-cyc="'+e.node+'"]',scope2);if(s2){s2.classList.remove("on");s2.classList.add(e.ok?"ok":"");}} + if(!e.ok)logLine("\\u2717 "+e.node,"fail");break;} + case "loop-back":{var scope3=nowEl();if(scope3)resetCyc(scope3);logLine("\\u21ba reflect \\u2192 replan");break;} + + case "human":{ + var ne=nowEl();if(ne)ne.classList.add("human"); + showHuman(e.kind,e.prompt);logLine("\\uD83D\\uDC64 "+e.kind+": "+e.prompt,"human");break;} + case "observe": logLine("= "+(e.passed?"PASS":"fail")+(e.output?" \\u2014 "+e.output.split("\\n")[0].slice(0,46):""),e.passed?"done":"fail");break; + case "reflect": logLine("~ "+e.text.split("\\n")[0].slice(0,54));break; + case "stop": setStatus(e.reason==="done"?"done":"fail",e.reason==="done"?"done":e.reason);logLine("\\u25fc stop ("+e.reason+")"+(e.warn?" \\u2014 "+e.warn:""));break; + case "loop-end": if(currentLeg&&!currentLeg.classList.contains("fail")){/* keep */}break; + case "pipeline-end": case "flow-end": setStatus(e.satisfied?"done":"fail",e.satisfied?"done":"stopped");if(currentLeg){currentLeg.classList.remove("current","human");currentLeg.classList.add(e.satisfied?"done":"fail");}clearHuman();break; } } +function cssEsc(s){return String(s).split('"').join('\\\\"');} +function showHuman(kind,prompt){q("#now").classList.add("human");var h=q("#now-human");h.innerHTML="";var b=el("b",null,"\\uD83D\\uDC64 needs you \\u00b7 ");h.appendChild(b);h.appendChild(document.createTextNode(prompt||kind));} +function clearHuman(){q("#now").classList.remove("human");} + +// boot +buildRoute(window.__SPEC__); +q("#src").textContent=window.__TITLE__+" \\u00b7 "+((window.__SPEC__.definitions||[]).length)+" definition(s)"; +var es=new EventSource("/events"); +es.onmessage=function(ev){try{handle(JSON.parse(ev.data));}catch(_){}}; })();`; -/** Safe JSON embed: neutralizes injection, line/para separators. */ +/** Escape a string for safe interpolation into HTML text / attributes. */ +function htmlEscape(s: string): string { + return s.replace(/&/g, "&").replace(/"/g, """).replace(/ and line/para separators inside an inline script. */ function embedJson(value: unknown): string { const LS = String.fromCharCode(0x2028); const PS = String.fromCharCode(0x2029); @@ -176,68 +325,39 @@ function embedJson(value: unknown): string { .split(PS).join("\\u2029"); } -/** Escape a string for safe interpolation into HTML text / attributes. */ -function htmlEscape(s: string): string { - return s.replace(/&/g, "&").replace(/"/g, """).replace(/○${safe}`; - } - } - } - return ` Loop · live · ${safeTitle} - + - +
    Loop
    -
    - signalreflect - gatedone -
    +
    waiting
    -
    -
    -
    -

    Current

    -
    starting…
    -
    - - -
    -

    Log

    -
      -
      -
      +
      +
      +
      Now
      +
      waiting to start…
      +
      +
      +
      +
      +
      + `; } From 0d8b59a9d99ea1ada3f3283da4793ad957f27163 Mon Sep 17 00:00:00 2001 From: Idan Ayalon Date: Sun, 28 Jun 2026 00:26:40 -0400 Subject: [PATCH 11/22] docs(loopflow): teach the labels field + the route-style dashboard The in-session push protocol now documents the optional foreach-start "labels" array (real item titles) and that the dashboard is a turn-by-turn route showing where-you-are / next / human gates / sprint progress. --- .claude/skills/loopflow/SKILL.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/.claude/skills/loopflow/SKILL.md b/.claude/skills/loopflow/SKILL.md index 54d55c5..bd8554d 100644 --- a/.claude/skills/loopflow/SKILL.md +++ b/.claude/skills/loopflow/SKILL.md @@ -224,7 +224,7 @@ time. Three pieces, all via the `loop-run` CLI: | start a flow | `{"type":"flow-start","name":""}` | | enter a flow step | `{"type":"flow-step-start","name":"","ref":""}` | | finish a flow step | `{"type":"flow-step-end","name":"","satisfied":true}` | - | start a `for each` | `{"type":"foreach-start","var":"","source":"","count":}` | + | start a `for each` | `{"type":"foreach-start","var":"","source":"","count":,"labels":["","<title 2>",…]}` — pass `labels` (one short title per item, e.g. each story's title) so the dashboard lists the real items instead of "story 1..N" | | start item i (0-based) | `{"type":"foreach-item-start","var":"<var>","index":<i>,"total":<N>}` | | finish item i | `{"type":"foreach-item-end","var":"<var>","index":<i>,"satisfied":true}` | | begin a cycle step | `{"type":"node-enter","node":"plan","attempt":<n>}` (then `act`, `observe`) | @@ -237,8 +237,11 @@ time. Three pieces, all via the `loop-run` CLI: 3. **At the end**, leave the server running so the user can review the final state. Mention they can Ctrl-C the `loop-run live` process to close it. -So a sprint (`for each story in "sprint.yaml"`) shows the progress bar filling story by -story, each cycle node pulsing as you work — exactly "where are we in the loop / in the plan". +The dashboard renders the loop's **actual structure** as a turn-by-turn route (Waze-style): +the real stages / flow steps / for-each items, with a "you are here", the steps ahead, and +human gates flagged. So a sprint (`for each story in "sprint.yaml"`) lists each story by title +with N/total progress, a check on finished stories, and a plan/act/observe tracker on the one +you're working now — exactly "where are we in the loop / in the plan". ### CLI-only alternative (headless) From 498d4a147b9066a0e7b721770670a6ee802dfc27 Mon Sep 17 00:00:00 2001 From: Idan Ayalon <ticketsforge@gmail.com> Date: Sun, 28 Jun 2026 00:31:49 -0400 Subject: [PATCH 12/22] feat(runtime): emit for-each item labels so headless --live sprints show titles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The engine's foreach-start event now carries a `labels` array (one short title per item), so `loop-run run <flow> --live` shows real story titles in the dashboard's sprint list — matching what the in-session /loopflow skill already pushes manually. - iterate.ts: new labelOf(item) — markdown `## ` heading, else YAML title/name/story/summary, else first scalar. (single `#` stays a comment, not a heading.) - types.ts: foreach-start gains optional labels?: string[]. - engine.ts: executeForEach maps items through labelOf and includes them. - tests: labelOf units (yaml/md/bare/comment) + engine asserts foreach-start carries the derived labels. 80/80 runtime tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- packages/runtime/src/engine.ts | 5 +++-- packages/runtime/src/index.ts | 2 +- packages/runtime/src/iterate.ts | 24 ++++++++++++++++++++++++ packages/runtime/src/types.ts | 2 +- packages/runtime/test/engine.test.js | 17 +++++++++++++++++ packages/runtime/test/iterate.test.js | 24 +++++++++++++++++++++++- 6 files changed, 69 insertions(+), 5 deletions(-) diff --git a/packages/runtime/src/engine.ts b/packages/runtime/src/engine.ts index b2e8551..bd96c99 100644 --- a/packages/runtime/src/engine.ts +++ b/packages/runtime/src/engine.ts @@ -1,7 +1,7 @@ import { resolve, dirname, basename } from "node:path"; import type { Loop, Pipeline, Flow, FlowStep, Definition, Transition, Action, LoopFile } from "@loop-lang/parser"; import type { CycleNode, LoopEvent, LoopOutcome, RunOptions, StopReason } from "./types.js"; -import { enumerateItems } from "./iterate.js"; +import { enumerateItems, labelOf } from "./iterate.js"; import { resolveGit, isProtected } from "./git.js"; import { resolveModels, modelForPhase } from "./models.js"; @@ -362,7 +362,8 @@ async function executeForEach(step: FlowStep, opts: RunOptions, stack: string[]) const text = await opts.readText(source, opts.baseDir); const fmt = /\.md$/i.test(source) ? "md" : "yaml"; const items = enumerateItems(text, fmt); - emit(opts, { type: "foreach-start", var: varName, source, count: items.length }); + const labels = items.map(labelOf); + emit(opts, { type: "foreach-start", var: varName, source, count: items.length, labels }); const templatePath = resolve(opts.baseDir, step.ref); if (stack.includes(templatePath)) { diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index 78902dc..7d1b896 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -10,7 +10,7 @@ export { ClaudeCodeRunner, interpretStreamLine, buildPlanPrompt, claudeArgs, par export type { ClaudeCodeRunnerOptions, AgentNode } from "./runners/claudeCode.js"; export { IpcHumanIO } from "./ipc.js"; export type { HumanRequest } from "./ipc.js"; -export { enumerateItems } from "./iterate.js"; +export { enumerateItems, labelOf } from "./iterate.js"; export { resolveGit, isProtected, BUILTIN_GIT } from "./git.js"; export type { GitIO, ResolvedGit } from "./git.js"; export { MockGitIO } from "./runners/mockGit.js"; diff --git a/packages/runtime/src/iterate.ts b/packages/runtime/src/iterate.ts index cb12039..43972ad 100644 --- a/packages/runtime/src/iterate.ts +++ b/packages/runtime/src/iterate.ts @@ -6,6 +6,30 @@ export function enumerateItems(text: string, format: "yaml" | "yml" | "md"): str return format === "md" ? chunkMarkdown(lines) : chunkYaml(lines); } +const stripQuotes = (s: string): string => s.replace(/^["']|["']$/g, ""); + +/** A short human label for one enumerated item — for the live dashboard's item list. + * Markdown: the `## ` heading. YAML: the first of title/name/story/summary, else the + * first scalar value, else the first line. Truncated. */ +export function labelOf(item: string): string { + const raw = item.split("\n").map((l) => l.trim()).filter((l) => l.length > 0); + if (raw.length === 0) return ""; + const heading = raw[0].match(/^#{2,}\s+(.*)$/); // markdown `## ` section title (2+ #; single # is a YAML comment) + if (heading) return heading[1].trim().slice(0, 80); + const lines = raw.filter((l) => !l.startsWith("#")); // drop YAML comments + if (lines.length === 0) return ""; + for (const key of ["title", "name", "story", "summary"]) { + for (const l of lines) { + const m = l.replace(/^-\s+/, "").match(new RegExp(`^${key}:\\s*(.+)$`, "i")); + if (m) return stripQuotes(m[1].trim()).slice(0, 80); + } + } + let first = lines[0].replace(/^-\s+/, ""); + const kv = first.match(/^[\w.-]+:\s*(.+)$/); + if (kv) first = kv[1]; + return stripQuotes(first.trim()).slice(0, 80); +} + function chunkMarkdown(lines: string[]): string[] { const chunks: string[] = []; let cur: string[] | null = null; diff --git a/packages/runtime/src/types.ts b/packages/runtime/src/types.ts index 9bca66d..f936252 100644 --- a/packages/runtime/src/types.ts +++ b/packages/runtime/src/types.ts @@ -24,7 +24,7 @@ export type LoopEvent = | { type: "flow-step-start"; name: string; ref: string } | { type: "flow-step-end"; name: string; satisfied: boolean } | { type: "flow-end"; name: string; satisfied: boolean } - | { type: "foreach-start"; var: string; source: string; count: number } + | { type: "foreach-start"; var: string; source: string; count: number; labels?: string[] } | { type: "foreach-item-start"; var: string; index: number; total: number } | { type: "foreach-item-end"; var: string; index: number; satisfied: boolean } | { type: "foreach-end"; var: string; satisfied: boolean } diff --git a/packages/runtime/test/engine.test.js b/packages/runtime/test/engine.test.js index cdced58..d7ee398 100644 --- a/packages/runtime/test/engine.test.js +++ b/packages/runtime/test/engine.test.js @@ -337,6 +337,23 @@ test("for each: runs the template once per item, item text becomes upstream", as assert.match(runner.planCalls[2].upstream, /gamma/); }); +test("for each: foreach-start carries item labels for the live dashboard", async () => { + const tmpl = parse('loop "t":\n goal: do it\n done when "x" passes\n each cycle: plan, then act, then observe'); + const flow = parse('flow "f":\n for each story in "sprint.yaml":\n run "t.loop"').definitions[0]; + const { events, onEvent } = collect(); + await runDefinition(flow, { + runner: new MockRunner(), + verifier: new SeqVerifier([true, true]), + human: new ScriptedHumanIO(), + baseDir: "/proj", + loadFile: async () => tmpl, + readText: async () => "stories:\n - title: User can log in\n - title: User can sign up\n", + onEvent, + }); + const start = events.find((e) => e.type === "foreach-start"); + assert.deepEqual(start.labels, ["User can log in", "User can sign up"]); +}); + test("for each: a failed item asks continue/stop — stop halts the flow", async () => { const tmpl = parse('loop "t":\n goal: do it\n done when "x" passes\n each cycle: plan, then act, then observe\n after 1 tries: stop and warn "nope"'); const flow = parse('flow "f":\n for each item in "plan.yaml":\n run "t.loop"').definitions[0]; diff --git a/packages/runtime/test/iterate.test.js b/packages/runtime/test/iterate.test.js index 50888d1..2a1ae13 100644 --- a/packages/runtime/test/iterate.test.js +++ b/packages/runtime/test/iterate.test.js @@ -1,6 +1,6 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { enumerateItems } from "../dist/index.js"; +import { enumerateItems, labelOf } from "../dist/index.js"; test("yaml root sequence → one chunk per item", () => { const out = enumerateItems("- a\n- b\n- c\n", "yaml"); @@ -50,3 +50,25 @@ test("yaml key-list ends before a sibling key", () => { test("yaml 'yml' alias enumerates like yaml", () => { assert.equal(enumerateItems("- x\n- y\n", "yml").length, 2); }); + +test("labelOf: yaml prefers title:, then name:, else first scalar", () => { + assert.equal(labelOf("- epic: Auth\n title: User can log in\n ac: x"), "User can log in"); + assert.equal(labelOf("- name: Build the thing\n x: 1"), "Build the thing"); + assert.equal(labelOf("- just a bare value"), "just a bare value"); + assert.equal(labelOf('- title: "quoted wins"'), "quoted wins"); +}); + +test("labelOf: markdown uses the ## heading (not the comment-filter casualty)", () => { + assert.equal(labelOf("## First task\nblah"), "First task"); + assert.equal(labelOf("### Deeper\nbody"), "Deeper"); +}); + +test("labelOf: skips YAML comments, empty → ''", () => { + assert.equal(labelOf("# a comment\n- name: real"), "real"); + assert.equal(labelOf(""), ""); +}); + +test("enumerate + labelOf together on a story list", () => { + const src = "stories:\n - title: Login\n ac: a\n - title: Signup\n ac: b\n"; + assert.deepEqual(enumerateItems(src, "yaml").map(labelOf), ["Login", "Signup"]); +}); From ef4f0452002c03c0bf3dad593c8b772249e64225 Mon Sep 17 00:00:00 2001 From: Idan Ayalon <ticketsforge@gmail.com> Date: Sun, 28 Jun 2026 00:36:54 -0400 Subject: [PATCH 13/22] fix(viz): clear the "needs you" gate banner when work resumes, not only at flow end The human-gate banner was only cleared on stage-start / flow-step-start / pipeline-end / flow-end, so after a mid-flow gate (e.g. design's "human reviews before stopping") it lingered through the next steps until the whole flow ended. Clear it on any resume event too: node-enter, flow-step-end, foreach-start, foreach-item-start. Found while running epic.loop on the live dashboard. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- packages/viz/src/live.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/viz/src/live.ts b/packages/viz/src/live.ts index e293673..0b8d548 100644 --- a/packages/viz/src/live.ts +++ b/packages/viz/src/live.ts @@ -245,9 +245,10 @@ function handle(e){ setNow(e.name,fleg);logLine("\\u25b8 "+e.name,"enter");break;} case "flow-step-end":{ var fleg2=q('[data-leg="step:'+cssEsc(e.name)+'"]');if(fleg2){fleg2.classList.remove("current","human","upcoming");fleg2.classList.add(e.satisfied?"done":"fail");} - break;} + clearHuman();break;} case "foreach-start":{ + clearHuman(); var host=q('[data-items="'+cssEsc(e["var"])+'"]'); var legHost=q('[data-foreach="'+cssEsc(e["var"])+'"]'); if(legHost){markCurrentLeg(legHost);var cnt=q("[data-count]",legHost);if(cnt)cnt.textContent="0/"+e.count;} @@ -263,6 +264,7 @@ function handle(e){ } logLine("for each "+e["var"]+" ("+e.count+")","enter");break;} case "foreach-item-start":{ + clearHuman(); var items=qa('[data-items="'+cssEsc(e["var"])+'"] > .item'); items.forEach(function(x){x.classList.remove("current");}); var it2=items[e.index]; @@ -281,6 +283,7 @@ function handle(e){ currentItem=null;break;} case "node-enter":{ + clearHuman(); var scope=nowEl();if(scope){resetCyc(scope);var s=q('[data-cyc="'+e.node+'"]',scope);if(s)s.classList.add("on");} q("#now-step").textContent=(currentItem?legTitleText(currentItem)+" \\u2192 ":"")+e.node; logLine("\\u2192 "+e.node+" (try "+e.attempt+")","enter");break;} From 4b0fbff04d3f97dd2f9c62081a96bf28d4bc80a3 Mon Sep 17 00:00:00 2001 From: Idan Ayalon <ticketsforge@gmail.com> Date: Sun, 28 Jun 2026 00:47:31 -0400 Subject: [PATCH 14/22] docs: document the live dashboard (--live, loop-run live/emit, route view) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MANUAL.md: CLI table gains show/ls/live/emit + the --live flag, plus a "Live dashboard" subsection (headless vs in-session, SSE + replay buffer). - README.md: new "Watch it run — live dashboard" section; layout table notes the dashboard. - cli/README.md: headless bullet mentions --live. --- README.md | 17 ++++++++++++++++- docs/MANUAL.md | 26 +++++++++++++++++++++++++- packages/cli/README.md | 3 ++- 3 files changed, 43 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 50f3a23..4e2f3d8 100644 --- a/README.md +++ b/README.md @@ -177,6 +177,21 @@ Describe work and it writes the `.loop`; name a `.loop` file and it runs the loo human gates right in the chat. Copy `.claude/skills/loopflow/` to `~/.claude/skills/` to use it in any repo (it's already active inside this one). +## Watch it run — live dashboard + +A real-time browser view of a run, showing the loop's **actual structure** as a turn-by-turn +route (Waze-style): where you are, the steps ahead, human gates, and for-each sprints listed +by item title with live progress. + +``` +loop-run run file.loop --live # headless: engine streams every step to the browser +/loopflow run file.loop # in-session: the skill offers the dashboard, then drives it +``` + +When you run a loop via `/loopflow`, the skill asks if you want the dashboard and, on yes, +opens it and updates it as each step happens — pipeline stages, flow steps, and sprint stories +filling in as the loop progresses. + ## Project layout | Package | Purpose | @@ -185,7 +200,7 @@ in any repo (it's already active inside this one). | `@loop-lang/runtime` | walks a spec, drives Claude Code, emits a live trace | | `@loop-lang/vscode` | highlight, formatter, ▶ Play CodeLens, live gutter trace | | `@loop-lang/stdlib` | `BMAD.loop` + starter presets | -| `@loop-lang/viz` | `loop-run viz file.loop` → self-contained HTML schematic (the cycle + reflect back-edge) | +| `@loop-lang/viz` | `loop-run viz file.loop` → self-contained HTML schematic; also the live dashboard (`--live`) | | `spec/loop-spec.schema.json` | the open IR contract | ## Is this just another <X>? diff --git a/docs/MANUAL.md b/docs/MANUAL.md index b62373f..27762a6 100644 --- a/docs/MANUAL.md +++ b/docs/MANUAL.md @@ -116,7 +116,7 @@ the thrash guard). ## 4. The CLI ``` -loop-run <run|parse|viz> <file.loop> [--model <alias>] [--out <path>] +loop-run <run|parse|viz|live|show|ls|emit> <file.loop> [--model <alias>] [--live] [--out <path>] ``` | Command | What it does | @@ -124,11 +124,17 @@ loop-run <run|parse|viz> <file.loop> [--model <alias>] [--out <path>] | `loop-run run <file>` | Execute the flow on Claude Code (plan/act/observe, reflect, verify, human gates). | | `loop-run parse <file>` | Parse to the loop-spec IR and print it as JSON (use to validate syntax). | | `loop-run viz <file>` | Write a self-contained HTML schematic of the flow. | +| `loop-run show <file>` | Print the loop's flow as compact ASCII. | +| `loop-run ls [dir]` | List every `.loop` under a directory with its one-line shape. | +| `loop-run live <file>` | Start the live dashboard server (no engine) and stay up; for driving the dashboard from an in-session run. | +| `loop-run emit <port> '<json>'` | Push one event to a running `live` server (used by the `/loopflow` skill). | **Flags** - `--model <alias>` — model for `run` (e.g. `opus`, `sonnet`, `haiku`); passed to Claude Code. Omit to use the CLI default. +- `--live` — for `run`, open a live browser dashboard and stream every step to it as the + loop executes (see below). - `--out <path>` — for `viz`, the HTML output file (default: the `.loop` name with an `.html` extension). - `--json` — print the parsed loop-spec JSON before the command's normal work (handy with @@ -138,6 +144,24 @@ loop-run <run|parse|viz> <file.loop> [--model <alias>] [--out <path>] definitions) resolves the named preset and runs it — e.g. a `project.loop` with `use the BMAD method`. +### Live dashboard + +A real-time browser view of a run: the loop's **actual structure** as a turn-by-turn route +(pipeline stages / flow steps / for-each items), with a "you are here" marker, the steps +ahead, human gates flagged, and for-each sprints listed by item title with N/total progress. + +Two ways to drive it: + +- **Headless** — `loop-run run <file> --live` opens a browser tab and the engine streams + every event to it. The terminal still prints the normal text trace. +- **In-session** — when you run a loop inside Claude Code via `/loopflow`, the skill offers + a live dashboard; on yes it starts `loop-run live <file>` in the background (which prints + `LOOP_LIVE_PORT=<port>` and opens the browser) and pushes an event per narrated step with + `loop-run emit <port> '<event-json>'`. The page connects over Server-Sent Events; a replay + buffer means events fired before the browser connects are not lost. + +The dashboard is self-contained (no external assets) and binds to `127.0.0.1` only. + ## 5. Language reference A `.loop` file is indentation-structured. `loop` / `pipeline` sit at column 0; their body diff --git a/packages/cli/README.md b/packages/cli/README.md index 26a9373..95615ce 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -80,7 +80,8 @@ place, never duplicated. - **Claude Code:** open a chat in the repo → `/loopflow run examples/fix_test.loop`, or just describe the work and let the skill write the `.loop`. - **Any agent:** it reads `AGENTS.md` and authors + runs loops the same way. -- **Headless:** install the full runtime to run loops from the CLI with `loop run <file>`. +- **Headless:** install the full runtime to run loops from the CLI with `loop run <file>` + (add `--live` for a real-time browser dashboard of the run). ## Learn more From 3f0b339b406474394d47de815a0664ac64841b0a Mon Sep 17 00:00:00 2001 From: Idan Ayalon <ticketsforge@gmail.com> Date: Sun, 28 Jun 2026 01:00:27 -0400 Subject: [PATCH 15/22] fix: address code-review findings (live dashboard correctness + labelOf hardening) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Correctness: - live.ts node-exit: classList.add(e.ok?"ok":"") threw on failure (add("") is illegal), which the try/catch swallowed — so a failed node was never marked AND its "✗" log line never ran. Guard it. - live.ts: a standalone single-loop file never lit up — loop-start didn't activate its `loop:<name>` leg and loop-end didn't finalize it. Now it does (no-op inside a pipeline stage, where stage-start/end own the leg). - live.ts clearHuman: only cleared the banner, not the leg/item's `human` class, so the gate node stayed orange after the user resumed. Clear both. - live.ts: handle foreach-end so the for-each leg doesn't linger on "current". - cli.ts --live: the server was closed + process exited the instant run() returned, racing the just-launched browser — a fast loop finished before the page connected. Keep the dashboard alive after the run (Ctrl-C to exit). - iterate.ts labelOf: follow YAML block scalars (story: | / >) to their body instead of labelling the item "|"; require a space after ':' so URLs/times (https://, 09:00) aren't mangled; only strip a balanced quote pair; surrogate-safe truncation. Cleanup: - cli.ts: factor the triplicated run() wiring (--live and default share one options object + trace/summary helpers). - live.ts: remove dead esc(); drop a stray 4th arg to el(). Tests: labelOf cases for block scalars, colon-in-value, quote balance. 133 pass, 0 fail. Standalone-loop + failure paths re-verified in a headless browser. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- packages/runtime/src/cli.ts | 76 ++++++++++++--------------- packages/runtime/src/iterate.ts | 33 ++++++++---- packages/runtime/test/iterate.test.js | 15 ++++++ packages/viz/src/live.ts | 23 +++++--- 4 files changed, 89 insertions(+), 58 deletions(-) diff --git a/packages/runtime/src/cli.ts b/packages/runtime/src/cli.ts index 0c64847..9eafab3 100644 --- a/packages/runtime/src/cli.ts +++ b/packages/runtime/src/cli.ts @@ -256,41 +256,9 @@ async function main() { process.exit(ok ? 0 : 1); } - if (rest.includes("--live")) { - const { renderLiveHtml } = await import("@loop-lang/viz"); - const { startLiveServer } = await import("./serve.js"); - const html = renderLiveHtml(file, { title: basename(fileArg) }); - const srv = await startLiveServer(html); - console.error(`\n ↻ Loop live dashboard → http://127.0.0.1:${srv.port}\n`); - const liveEvents: LoopEvent[] = []; - const liveOutcomes = await run(file, { - runner: new ClaudeCodeRunner({}), - verifier: new ShellVerifier(), - human: new CliHumanIO(), - git, - baseDir: target, - loadFile, - readText, - writeText, - flowStack: [path], - modelPolicy: file.config?.models, - cliModel: model, - onEvent: (e) => { - liveEvents.push(e); - srv.emit(e); - const line = render(e); - if (line) console.log(line); - }, - }); - srv.close(); - const liveSummary = formatModelSummary(summarizeModels(liveEvents)); - if (liveSummary) console.error(liveSummary); - const liveOk = liveOutcomes.every((o) => o.satisfied); - process.exit(liveOk ? 0 : 1); - } - - const traceEvents: LoopEvent[] = []; - const outcomes = await run(file, { + // Shared run wiring for the two terminal-trace paths (--live and default); they differ + // only in whether events also stream to a live dashboard. + const baseOpts = { runner: new ClaudeCodeRunner({}), verifier: new ShellVerifier(), human: new CliHumanIO(), @@ -302,16 +270,38 @@ async function main() { flowStack: [path], modelPolicy: file.config?.models, cliModel: model, - onEvent: (e) => { - traceEvents.push(e); - const line = render(e); - if (line) console.log(line); - }, - }); + }; + const traceEvents: LoopEvent[] = []; + const onTrace = (e: LoopEvent) => { + traceEvents.push(e); + const line = render(e); + if (line) console.log(line); + }; + const reportSummary = () => { + const summary = formatModelSummary(summarizeModels(traceEvents)); + if (summary) console.error(summary); + }; - const summary = formatModelSummary(summarizeModels(traceEvents)); - if (summary) console.error(summary); + if (rest.includes("--live")) { + const { renderLiveHtml } = await import("@loop-lang/viz"); + const { startLiveServer } = await import("./serve.js"); + const html = renderLiveHtml(file, { title: basename(fileArg) }); + const srv = await startLiveServer(html); + console.error(`\n ↻ Loop live dashboard → http://127.0.0.1:${srv.port}\n`); + const outcomes = await run(file, { ...baseOpts, onEvent: (e) => { srv.emit(e); onTrace(e); } }); + reportSummary(); + const ok = outcomes.every((o) => o.satisfied); + // Keep the dashboard up after the run — a fast loop can finish before the browser even + // connects; leaving it alive lets the page load + replay and the user review the result. + console.error(` ↻ dashboard still live → http://127.0.0.1:${srv.port} (Ctrl-C to exit)`); + const shutdown = () => { srv.close(); process.exit(ok ? 0 : 1); }; + process.on("SIGINT", shutdown); + process.on("SIGTERM", shutdown); + return; + } + const outcomes = await run(file, { ...baseOpts, onEvent: onTrace }); + reportSummary(); const ok = outcomes.every((o) => o.satisfied); process.exit(ok ? 0 : 1); } diff --git a/packages/runtime/src/iterate.ts b/packages/runtime/src/iterate.ts index 43972ad..8f8ff1c 100644 --- a/packages/runtime/src/iterate.ts +++ b/packages/runtime/src/iterate.ts @@ -6,28 +6,43 @@ export function enumerateItems(text: string, format: "yaml" | "yml" | "md"): str return format === "md" ? chunkMarkdown(lines) : chunkYaml(lines); } -const stripQuotes = (s: string): string => s.replace(/^["']|["']$/g, ""); +/** Strip one balanced pair of surrounding quotes (not a lone leading/trailing one). */ +const stripQuotes = (s: string): string => + s.length >= 2 && (s[0] === '"' || s[0] === "'") && s[s.length - 1] === s[0] ? s.slice(1, -1) : s; + +/** Truncate to 80 chars without splitting a surrogate pair. */ +const clip = (s: string): string => Array.from(s).slice(0, 80).join(""); + +/** A bare YAML block-scalar indicator (`|`, `>`, with optional chomp/indent) — not a value. */ +const isBlockIndicator = (v: string): boolean => /^[|>][0-9+-]*$/.test(v); /** A short human label for one enumerated item — for the live dashboard's item list. - * Markdown: the `## ` heading. YAML: the first of title/name/story/summary, else the - * first scalar value, else the first line. Truncated. */ + * Markdown: the `## ` heading. YAML: the first of title/name/story/summary (following a + * block scalar to its body), else the first scalar value, else the first line. Truncated. */ export function labelOf(item: string): string { const raw = item.split("\n").map((l) => l.trim()).filter((l) => l.length > 0); if (raw.length === 0) return ""; const heading = raw[0].match(/^#{2,}\s+(.*)$/); // markdown `## ` section title (2+ #; single # is a YAML comment) - if (heading) return heading[1].trim().slice(0, 80); + if (heading) return clip(heading[1].trim()); const lines = raw.filter((l) => !l.startsWith("#")); // drop YAML comments if (lines.length === 0) return ""; for (const key of ["title", "name", "story", "summary"]) { - for (const l of lines) { - const m = l.replace(/^-\s+/, "").match(new RegExp(`^${key}:\\s*(.+)$`, "i")); - if (m) return stripQuotes(m[1].trim()).slice(0, 80); + for (let i = 0; i < lines.length; i++) { + const m = lines[i].replace(/^-\s+/, "").match(new RegExp(`^${key}:\\s*(.*)$`, "i")); + if (!m) continue; + const v = stripQuotes(m[1].trim()); + if (v && !isBlockIndicator(v)) return clip(v); + // inline value was empty or a block scalar (`|`/`>`) → use the first body line below it + for (let j = i + 1; j < lines.length; j++) { + const body = lines[j].replace(/^-\s+/, "").trim(); + if (body && !/^[\w.-]+:\s/.test(body)) return clip(stripQuotes(body)); + } } } let first = lines[0].replace(/^-\s+/, ""); - const kv = first.match(/^[\w.-]+:\s*(.+)$/); + const kv = first.match(/^[\w.-]+:\s+(.+)$/); // require space after ':' so URLs/times (https://, 09:00) stay intact if (kv) first = kv[1]; - return stripQuotes(first.trim()).slice(0, 80); + return clip(stripQuotes(first.trim())); } function chunkMarkdown(lines: string[]): string[] { diff --git a/packages/runtime/test/iterate.test.js b/packages/runtime/test/iterate.test.js index 2a1ae13..ec68cbd 100644 --- a/packages/runtime/test/iterate.test.js +++ b/packages/runtime/test/iterate.test.js @@ -68,6 +68,21 @@ test("labelOf: skips YAML comments, empty → ''", () => { assert.equal(labelOf(""), ""); }); +test("labelOf: a block scalar (| or >) follows to its body line", () => { + assert.equal(labelOf("- story: |\n As a user I log in\n more"), "As a user I log in"); + assert.equal(labelOf("- summary: >\n folded text here"), "folded text here"); +}); + +test("labelOf: a colon inside a bare scalar is not mistaken for a key", () => { + assert.equal(labelOf("- https://example.com/path"), "https://example.com/path"); + assert.equal(labelOf("- 09:00 standup with team"), "09:00 standup with team"); +}); + +test("labelOf: only strips a balanced quote pair", () => { + assert.equal(labelOf('- name: say "hi"'), 'say "hi"'); + assert.equal(labelOf("- title: 'wrapped'"), "wrapped"); +}); + test("enumerate + labelOf together on a story list", () => { const src = "stories:\n - title: Login\n ac: a\n - title: Signup\n ac: b\n"; assert.deepEqual(enumerateItems(src, "yaml").map(labelOf), ["Login", "Signup"]); diff --git a/packages/viz/src/live.ts b/packages/viz/src/live.ts index 0b8d548..5c11ef8 100644 --- a/packages/viz/src/live.ts +++ b/packages/viz/src/live.ts @@ -98,7 +98,6 @@ const APP_JS = `(function(){ function q(s,r){return (r||document).querySelector(s);} function qa(s,r){return Array.prototype.slice.call((r||document).querySelectorAll(s));} function el(tag,cls,txt){var e=document.createElement(tag);if(cls)e.className=cls;if(txt!=null)e.textContent=txt;return e;} -function esc(s){return String(s==null?"":s);} function doneLabel(dw){ if(!dw)return "you confirm"; @@ -171,7 +170,7 @@ function buildRoute(spec){ var card2=el("div","leg-card"); var t2=el("div","leg-title"); t2.appendChild(document.createTextNode("for each "+step.forEach.var+" in "+step.forEach.source)); - t2.appendChild(el("span","item-count","","")).setAttribute("data-count",""); + t2.appendChild(el("span","item-count")).setAttribute("data-count",""); card2.appendChild(t2); var m2=el("div","leg-meta");m2.appendChild(el("span","k","template "));m2.appendChild(el("code",null,step.ref));card2.appendChild(m2); card2.appendChild(el("ol","items")).setAttribute("data-items",step.forEach.var); @@ -231,7 +230,12 @@ function handle(e){ switch(e.type){ case "pipeline-start": logLine("\\u25a3 pipeline \\""+e.name+"\\"","enter");setStatus("live","running");break; case "flow-start": logLine("\\u2192 flow \\""+e.name+"\\"","enter");setStatus("live","running");break; - case "loop-start": setStatus("live","running");if(e.name)logLine("\\u21bb loop \\""+e.name+"\\"","enter");break; + case "loop-start":{ + setStatus("live","running"); + if(e.name){logLine("\\u21bb loop \\""+e.name+"\\"","enter"); + var lleg=q('[data-leg="loop:'+cssEsc(e.name)+'"]'); // standalone loop file → activate its leg (no-op inside a pipeline stage) + if(lleg){markCurrentLeg(lleg);setNow(e.name,lleg);}} + break;} case "stage-start":{ var leg=q('[data-leg="stage:'+cssEsc(e.name)+'"]');markCurrentLeg(leg);clearHuman(); @@ -281,6 +285,10 @@ function handle(e){ var items2=qa('[data-items="'+cssEsc(e["var"])+'"] > .item'); var it3=items2[e.index];if(it3){it3.classList.remove("current","upcoming");it3.classList.add(e.satisfied?"done":"fail");var nd=q(".node",it3);if(nd)nd.textContent=e.satisfied?"\\u2713":"\\u2717";} currentItem=null;break;} + case "foreach-end":{ // close the for-each leg so it doesn't linger on "current" + var feLeg=q('[data-foreach="'+cssEsc(e["var"])+'"]'); + if(feLeg){feLeg.classList.remove("current","human");feLeg.classList.add(e.satisfied?"done":"fail");} + currentItem=null;break;} case "node-enter":{ clearHuman(); @@ -288,7 +296,7 @@ function handle(e){ q("#now-step").textContent=(currentItem?legTitleText(currentItem)+" \\u2192 ":"")+e.node; logLine("\\u2192 "+e.node+" (try "+e.attempt+")","enter");break;} case "node-exit":{ - var scope2=nowEl();if(scope2){var s2=q('[data-cyc="'+e.node+'"]',scope2);if(s2){s2.classList.remove("on");s2.classList.add(e.ok?"ok":"");}} + var scope2=nowEl();if(scope2){var s2=q('[data-cyc="'+e.node+'"]',scope2);if(s2){s2.classList.remove("on");if(e.ok)s2.classList.add("ok");}} if(!e.ok)logLine("\\u2717 "+e.node,"fail");break;} case "loop-back":{var scope3=nowEl();if(scope3)resetCyc(scope3);logLine("\\u21ba reflect \\u2192 replan");break;} @@ -298,13 +306,16 @@ function handle(e){ case "observe": logLine("= "+(e.passed?"PASS":"fail")+(e.output?" \\u2014 "+e.output.split("\\n")[0].slice(0,46):""),e.passed?"done":"fail");break; case "reflect": logLine("~ "+e.text.split("\\n")[0].slice(0,54));break; case "stop": setStatus(e.reason==="done"?"done":"fail",e.reason==="done"?"done":e.reason);logLine("\\u25fc stop ("+e.reason+")"+(e.warn?" \\u2014 "+e.warn:""));break; - case "loop-end": if(currentLeg&&!currentLeg.classList.contains("fail")){/* keep */}break; + case "loop-end":{ // finalize a standalone loop's leg (stage loops are closed by stage-end) + if(currentLeg&¤tLeg.getAttribute("data-leg")&¤tLeg.getAttribute("data-leg").indexOf("loop:")===0){ + currentLeg.classList.remove("current","human");currentLeg.classList.add(e.satisfied?"done":"fail");} + break;} case "pipeline-end": case "flow-end": setStatus(e.satisfied?"done":"fail",e.satisfied?"done":"stopped");if(currentLeg){currentLeg.classList.remove("current","human");currentLeg.classList.add(e.satisfied?"done":"fail");}clearHuman();break; } } function cssEsc(s){return String(s).split('"').join('\\\\"');} function showHuman(kind,prompt){q("#now").classList.add("human");var h=q("#now-human");h.innerHTML="";var b=el("b",null,"\\uD83D\\uDC64 needs you \\u00b7 ");h.appendChild(b);h.appendChild(document.createTextNode(prompt||kind));} -function clearHuman(){q("#now").classList.remove("human");} +function clearHuman(){q("#now").classList.remove("human");var ne=nowEl();if(ne)ne.classList.remove("human");} // boot buildRoute(window.__SPEC__); From b2f9d3eafcdc50de8ebe538587f380df178f62dc Mon Sep 17 00:00:00 2001 From: Idan Ayalon <ticketsforge@gmail.com> Date: Sun, 28 Jun 2026 19:40:26 -0400 Subject: [PATCH 16/22] feat: SSE reconnect dedup + opt-in live dashboard via loop.config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SSE dedup (serve.ts): - Each event now carries a monotonic id; on (re)connect the server replays only events newer than the client's Last-Event-ID. A transient drop + EventSource auto-reconnect no longer re-delivers (and duplicates) what the page already saw. - Added a 15s heartbeat comment so idle connections survive proxy/NAT timeouts (unref'd so it never keeps the process alive on its own). - New serve.test.js covers replay, Last-Event-ID dedup, GET / and POST /emit. Opt-in dashboard via loop.config: - `loop init` now writes `loop.config` with `live=false` (off by default in Claude Code), created once and never clobbered unless --force. - The /loopflow skill + AGENTS.md now gate the in-session dashboard on `live=true` in loop.config — with live=false or no config it runs entirely in the chat, no dashboard, no prompt. The headless `loop-run run --live` flag is independent of this config. - init.test.mjs asserts the default and the no-clobber/force behavior. 137 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- .claude/skills/loopflow/SKILL.md | 17 +++++---- AGENTS.md | 10 +++--- docs/MANUAL.md | 19 +++++++---- packages/cli/src/init.mjs | 18 ++++++++++ packages/cli/test/init.test.mjs | 17 +++++++++ packages/runtime/src/serve.ts | 38 ++++++++++++++++----- packages/runtime/test/serve.test.js | 53 +++++++++++++++++++++++++++++ 7 files changed, 146 insertions(+), 26 deletions(-) create mode 100644 packages/runtime/test/serve.test.js diff --git a/.claude/skills/loopflow/SKILL.md b/.claude/skills/loopflow/SKILL.md index bd8554d..79f8dad 100644 --- a/.claude/skills/loopflow/SKILL.md +++ b/.claude/skills/loopflow/SKILL.md @@ -188,15 +188,20 @@ A live animated schematic in the browser — the active cycle node pulses, flow highlight as they execute, and a for-each (sprint) progress bar fills item by item, so the user always sees **where in the loop / where in the plan** the run currently is. -### Always ask first (in-session runs) +### Gate it on `loop.config` (in-session runs) -**Before you start running any loop in this session, ask the user once:** +The in-session dashboard is **opt-in via repo config** — off by default so a normal +`/loopflow` run stays entirely in the chat. Before running a loop in this session, read +`loop.config` at the repo root and look for a `live=` line: -> *"Want a live browser dashboard for this run? I'll open a real-time view of the loop and -> update it as each step happens."* +- **`live=false`, or the line/file is absent** → **do not** start the dashboard. Run + normally, narrating the trace in chat (the *Running a .loop* section below). Do not ask. +- **`live=true`** → start the dashboard and drive it as you narrate (mechanism below). You + may still mention you're opening it. -- **No** → run normally, narrating the trace in chat (the *Running a .loop* section below). -- **Yes** → start the dashboard, then drive it as you narrate. Mechanism below. +(`loop.config` is written by `loop init` with `live=false`; the user flips it to `live=true` +to turn the dashboard on. The headless `loop-run run <file> --live` flag is independent of +this config.) ### How the in-session dashboard works diff --git a/AGENTS.md b/AGENTS.md index 6c72175..c053861 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -294,11 +294,13 @@ flow, show the file chain. `loop-run ls` lists every loop in the repo. - `loop-run show file.loop` — print the loop's flow as compact ASCII (and `loop-run ls` to list them). - `loop-run viz file.loop` — open a visual HTML schematic of the flow. -### Ask about a live visual — every in-session run +### Live visual — opt-in via `loop.config` -Before you start running a loop **in this session**, ask the user once: -*"Want a live browser dashboard for this run? I'll open a real-time view and update it as -each step happens."* If yes, fire the backend and drive it as you narrate: +The in-session dashboard is **off by default**. Before running a loop **in this session**, +read `loop.config` at the repo root: only if it has `live=true` do you start the dashboard +and drive it as you narrate. With `live=false` (the default written by `loop init`) or no +config, run normally in the chat — don't open anything, don't ask. (The headless +`loop-run run <file> --live` flag below is independent of this config.) When enabled: - `loop-run live file.loop` — start the dashboard server (opens the browser, prints `LOOP_LIVE_PORT=<port>`), run it in the **background**, keep the port. diff --git a/docs/MANUAL.md b/docs/MANUAL.md index 27762a6..171485c 100644 --- a/docs/MANUAL.md +++ b/docs/MANUAL.md @@ -154,13 +154,18 @@ Two ways to drive it: - **Headless** — `loop-run run <file> --live` opens a browser tab and the engine streams every event to it. The terminal still prints the normal text trace. -- **In-session** — when you run a loop inside Claude Code via `/loopflow`, the skill offers - a live dashboard; on yes it starts `loop-run live <file>` in the background (which prints - `LOOP_LIVE_PORT=<port>` and opens the browser) and pushes an event per narrated step with - `loop-run emit <port> '<event-json>'`. The page connects over Server-Sent Events; a replay - buffer means events fired before the browser connects are not lost. - -The dashboard is self-contained (no external assets) and binds to `127.0.0.1` only. +- **In-session** — when you run a loop inside Claude Code via `/loopflow`, the dashboard is + **opt-in via `loop.config`**: `loop init` writes `loop.config` with `live=false` (off by + default), and the skill only opens the dashboard when you set `live=true`. When enabled it + starts `loop-run live <file>` in the background (which prints `LOOP_LIVE_PORT=<port>` and + opens the browser) and pushes an event per narrated step with + `loop-run emit <port> '<event-json>'`. The headless `--live` flag is independent of this + config. + +The page connects over Server-Sent Events; each event carries an id and the server replays +on connect (and dedupes on reconnect via `Last-Event-ID`), so events fired before the browser +connects are not lost and a transient drop doesn't double-deliver. The dashboard is +self-contained (no external assets) and binds to `127.0.0.1` only. ## 5. Language reference diff --git a/packages/cli/src/init.mjs b/packages/cli/src/init.mjs index 419b04a..9a99ea1 100644 --- a/packages/cli/src/init.mjs +++ b/packages/cli/src/init.mjs @@ -8,6 +8,14 @@ import { join, dirname } from "node:path"; const MARK_START = "<!-- loop:start (managed by `loop init` — edits between the markers are overwritten) -->"; const MARK_END = "<!-- loop:end -->"; +/** Default repo settings the /loopflow skill reads. `key=value`, `#` comments. */ +const LOOP_CONFIG_DEFAULT = `# Loop config — settings for the /loopflow skill and runtime. +# live: when true, running a loop in Claude Code via /loopflow opens a live browser +# dashboard and streams each step to it. Default false (off in Claude Code unless +# you opt in). The headless 'loop-run run <file> --live' flag is unaffected. +live=false +`; + /** Short pointer dropped into an agent's memory file so it knows Loop lives here. */ export function pointer({ skill }) { const run = skill @@ -67,6 +75,16 @@ export async function init(targetDir, opts, assetsDir) { const verb = await mergeMarkered(join(targetDir, "AGENTS.md"), agentsBody.trim()); steps.push(`${verb} AGENTS.md (the Loop language reference — any agent)`); + // 1b. loop.config — repo settings the /loopflow skill reads. Written once with safe + // defaults (live=false); never clobbered unless --force, so user edits survive. + const cfgPath = join(targetDir, "loop.config"); + if (force || !(await exists(cfgPath))) { + await writeFile(cfgPath, LOOP_CONFIG_DEFAULT); + steps.push("wrote loop.config (live=false — set live=true to show the dashboard in /loopflow)"); + } else { + steps.push("skipped (exists) loop.config"); + } + // 2. The Claude Code /loopflow skill. if (withSkill) { const dst = skill === "global" diff --git a/packages/cli/test/init.test.mjs b/packages/cli/test/init.test.mjs index 84a9c22..15f793f 100644 --- a/packages/cli/test/init.test.mjs +++ b/packages/cli/test/init.test.mjs @@ -52,6 +52,23 @@ test("init respects skill:none and example:false", async () => { assert.equal(existsSync(join(dir, "examples")), false, "no example"); }); +test("init writes loop.config with live=false by default", async () => { + const dir = await fresh(); + await init(dir, { skill: "none", agents: [], example: false }, ASSETS); + assert.ok(existsSync(join(dir, "loop.config")), "loop.config written"); + const cfg = await readFile(join(dir, "loop.config"), "utf8"); + assert.match(cfg, /^live=false$/m, "live=false default"); +}); + +test("init does not clobber an edited loop.config (unless --force)", async () => { + const dir = await fresh(); + await writeFile(join(dir, "loop.config"), "live=true\n"); + await init(dir, { skill: "none", agents: [], example: false }, ASSETS); + assert.equal(await readFile(join(dir, "loop.config"), "utf8"), "live=true\n", "user value preserved"); + await init(dir, { skill: "none", agents: [], example: false, force: true }, ASSETS); + assert.match(await readFile(join(dir, "loop.config"), "utf8"), /^live=false$/m, "force resets to default"); +}); + test("pointer mentions the skill only when one is installed", () => { assert.match(pointer({ skill: true }), /\/loopflow` skill/); assert.doesNotMatch(pointer({ skill: false }), /\/loopflow` skill/); diff --git a/packages/runtime/src/serve.ts b/packages/runtime/src/serve.ts index c80daea..66056d2 100644 --- a/packages/runtime/src/serve.ts +++ b/packages/runtime/src/serve.ts @@ -25,21 +25,39 @@ export interface LiveServerOptions { export function startLiveServer(html: string, opts: LiveServerOptions = {}): Promise<LiveServer> { const clients: ServerResponse[] = []; // Replay buffer: events fire before the browser finishes launching + connecting, so a - // fresh /events client would miss everything up to its connect. Buffer and replay on - // connect. ponytail: bounded ring (last 10k events) — the dashboard reconstructs state - // from the stream, so dropping the oldest events on a very long run is harmless. - const buffer: unknown[] = []; + // fresh /events client would miss everything up to its connect. Each event gets a + // monotonic id; on (re)connect we replay only the events newer than the client's + // Last-Event-ID, so a transient drop + EventSource auto-reconnect doesn't re-deliver + // (and thus duplicate) what the page already saw. + // ponytail: bounded ring (last 10k events) — the dashboard reconstructs state from the + // stream, so dropping the oldest events on a very long run is harmless. + const buffer: { id: number; data: string }[] = []; const BUFFER_CAP = 10_000; + let seq = 0; + function frame(id: number, data: string): string { + return `id: ${id}\ndata: ${data}\n\n`; + } function broadcast(e: unknown): void { - buffer.push(e); + const id = ++seq; + const data = JSON.stringify(e); + buffer.push({ id, data }); if (buffer.length > BUFFER_CAP) buffer.shift(); - const data = `data: ${JSON.stringify(e)}\n\n`; + const f = frame(id, data); for (const c of [...clients]) { - try { c.write(data); } catch { /* client disconnected */ } + try { c.write(f); } catch { /* client disconnected */ } } } + // SSE heartbeat: a comment line keeps idle connections alive through proxies / NAT. + // unref so it never keeps the process alive on its own (the listening socket does that). + const heartbeat = setInterval(() => { + for (const c of [...clients]) { + try { c.write(": ping\n\n"); } catch { /* disconnected */ } + } + }, 15_000); + heartbeat.unref?.(); + return new Promise((resolve, reject) => { const server = createServer((req: IncomingMessage, res: ServerResponse) => { if (req.url === "/events") { @@ -49,7 +67,9 @@ export function startLiveServer(html: string, opts: LiveServerOptions = {}): Pro Connection: "keep-alive", }); res.write("retry: 2000\n\n"); - for (const past of buffer) res.write(`data: ${JSON.stringify(past)}\n\n`); + // Replay only events the client hasn't already seen (0 if a first connect). + const lastId = Number(req.headers["last-event-id"]) || 0; + for (const ev of buffer) if (ev.id > lastId) res.write(frame(ev.id, ev.data)); clients.push(res); req.on("close", () => { const i = clients.indexOf(res); @@ -95,7 +115,7 @@ export function startLiveServer(html: string, opts: LiveServerOptions = {}): Pro resolve({ port: addr.port, emit: broadcast, - close() { server.close(); }, + close() { clearInterval(heartbeat); server.close(); }, }); }); }); diff --git a/packages/runtime/test/serve.test.js b/packages/runtime/test/serve.test.js new file mode 100644 index 0000000..e46a941 --- /dev/null +++ b/packages/runtime/test/serve.test.js @@ -0,0 +1,53 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { startLiveServer } from "../dist/serve.js"; + +// Read an SSE stream for a short window, then cancel — returns whatever arrived. +async function drain(url, headers, ms = 250) { + const reader = await fetch(url, { headers }).then((r) => r.body.getReader()); + let out = ""; + const dec = new TextDecoder(); + const t = setTimeout(() => reader.cancel().catch(() => {}), ms); + try { + while (true) { + const { value, done } = await reader.read(); + if (done) break; + out += dec.decode(value); + } + } catch { /* cancelled */ } + clearTimeout(t); + return out; +} +const ids = (s) => [...s.matchAll(/id: (\d+)/g)].map((m) => m[1]); + +test("live server replays buffered events and dedupes on Last-Event-ID", async () => { + const srv = await startLiveServer("<html>x</html>", { open: false }); + const url = `http://127.0.0.1:${srv.port}/events`; + srv.emit({ type: "a" }); + srv.emit({ type: "b" }); + srv.emit({ type: "c" }); + + const fresh = await drain(url, {}); + assert.deepEqual(ids(fresh), ["1", "2", "3"], "fresh connect replays all buffered events"); + + const reconn = await drain(url, { "Last-Event-ID": "2" }); + assert.deepEqual(ids(reconn), ["3"], "reconnect replays only events newer than Last-Event-ID"); + assert.ok(!reconn.includes('"a"') && !reconn.includes('"b"'), "no duplicate of already-seen events"); + + srv.close(); +}); + +test("live server serves the page on GET / and accepts POST /emit", async () => { + const srv = await startLiveServer("<html>LIVE</html>", { open: false }); + const base = `http://127.0.0.1:${srv.port}`; + + const page = await fetch(base).then((r) => r.text()); + assert.match(page, /LIVE/, "serves the html"); + + const ok = await fetch(`${base}/emit`, { method: "POST", body: JSON.stringify({ type: "x" }) }); + assert.equal(ok.status, 204, "valid emit accepted"); + const bad = await fetch(`${base}/emit`, { method: "POST", body: "not json" }); + assert.equal(bad.status, 400, "malformed emit rejected"); + + srv.close(); +}); From 78c2bd52bbf1a5d2eb32377c2bc3ad8416eb75cc Mon Sep 17 00:00:00 2001 From: Idan Ayalon <ticketsforge@gmail.com> Date: Sun, 28 Jun 2026 19:41:59 -0400 Subject: [PATCH 17/22] chore: enable the live dashboard in this repo (loop.config live=true) So /loopflow shows the dashboard during in-session runs here. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- loop.config | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 loop.config diff --git a/loop.config b/loop.config new file mode 100644 index 0000000..79d5cf0 --- /dev/null +++ b/loop.config @@ -0,0 +1,5 @@ +# Loop config — settings for the /loopflow skill and runtime. +# live: when true, running a loop in Claude Code via /loopflow opens a live browser +# dashboard and streams each step to it. Default false (off in Claude Code unless +# you opt in). The headless 'loop-run run <file> --live' flag is unaffected. +live=true From d0cd57736a2adacdcd8709e38ba73b9ac7ff136c Mon Sep 17 00:00:00 2001 From: Idan Ayalon <ticketsforge@gmail.com> Date: Sun, 28 Jun 2026 19:45:28 -0400 Subject: [PATCH 18/22] docs: add live dashboard to the tutorial + restructure CHANGELOG MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - docs/index.html (the tutorial): new "Watch it run — live dashboard" subsection under Running a loop, the --live/live/emit commands, the loop.config gate, and a TOC entry. - CHANGELOG.md: [Unreleased] now covers the live dashboard, loop.config, loop version, and the fixes; backfilled [0.4.0] (skills + memory) and moved the scope/Archon/CI prep into [0.2.0] where it shipped. --- CHANGELOG.md | 93 ++++++++++++++++++++++++++++++++++++++++--------- docs/index.html | 14 +++++++- 2 files changed, 89 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a1b4c8a..90c14c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,33 +2,92 @@ All notable changes to LoopFlow are recorded here. The packages share the `0.x` line; the `loop-vscode` extension versions independently (it had an earlier cut). +Versions track the `@loop-lang/loop` installer package. ## [Unreleased] +### Added +- **Live dashboard** — a real-time browser view of a run that renders the loop's + *actual* structure (pipeline stages / flow steps / for-each items) as a turn-by-turn + route (Waze-style): a "you are here" marker, the steps ahead, human gates flagged, and + `for each` sprints listed by item title with live progress and a per-item + plan/act/observe tracker. + - Headless: `loop-run run <file> --live` opens a browser and the engine streams every + event to it over Server-Sent Events. + - In-session: running a loop via `/loopflow` opens the dashboard when enabled, pushing + one event per narrated step. +- `loop-run live <file>` (dashboard server, no engine) and + `loop-run emit <port> '<json>'` (push one event) commands. +- `loop init` now writes a `loop.config` file (`live=false` by default) that gates the + in-session dashboard; written once and never clobbered unless `--force`. Set + `live=true` to have `/loopflow` show the dashboard. +- `loop version` command (and `--version` / `-v`). +- `@loop-lang/viz` exports `renderLiveHtml`; `@loop-lang/runtime` adds `startLiveServer` + and derives short item labels (`labelOf`) so headless sprints show real story titles. +- `/loopflow` integrates `superpowers:brainstorming` for the no-plan (medium/large) path. + +### Changed +- The dashboard uses a flat, Linear-style dark theme; self-contained (no external assets) + and bound to `127.0.0.1` only. +- SSE stream tags each event with an id and replays buffered events on connect, deduping + on reconnect via `Last-Event-ID`, plus a heartbeat so idle connections survive proxy/NAT + timeouts — events fired before the browser connects aren't lost and a transient drop + doesn't double-deliver. +- `loop-run run --live` keeps the dashboard alive after the run (Ctrl-C to exit) so a fast + loop's result stays viewable; shared run wiring de-duplicated. + +### Fixed +- Dashboard: a failed cycle node no longer throws (and now logs the failure); a standalone + single-loop file activates its route leg; the "needs you" gate clears when work resumes; + `for each` legs finalize on completion. +- `labelOf` follows YAML block scalars (`story: |`/`>`) to their body, keeps URLs/times + (`https://`, `09:00`) intact, only strips a balanced quote pair, and truncates without + splitting a surrogate pair. +- Cross-platform: Windows browser auto-open (`cmd /c start`), page title via + `path.basename`, `version.test` cwd via `fileURLToPath`. HTML-escape the dashboard + `<title>`; corrected the `embedJson` `<`-escape. + +### Docs +- Tutorial (`docs/index.html`), `README.md`, `docs/MANUAL.md`, `AGENTS.md`, and the + `/loopflow` skill document the live dashboard, the `loop.config` gate, and the + `live`/`emit`/`--live` commands. + +## [0.4.0] — 2026-06-27 + +### Added +- **Skills** — `use skills: <a>, <b>` lets a loop invoke named skills during plan/act, and + `done when the skill "<name>" approves` / `scores N or more` turns a review skill into a + verifiable predicate. +- **Cross-run memory** — `remember in "<file>"` reads past lessons on start and appends a + dated outcome on stop, so a loop improves across runs. +- `/loopflow` auto-installs globally on `npm install -g @loop-lang/loop`. + +## [0.2.0] — 2026-06-22 + ### Changed - Unified the npm scope: the libraries moved from `@loop/*` to `@loop-lang/*` - (`@loop-lang/parser`, `@loop-lang/runtime`, `@loop-lang/stdlib`, - `@loop-lang/viz`), matching the installer `@loop-lang/loop`. -- The runtime CLI binary is now `loop-run` (`loop-run run`, `loop-run show`, …); - the installer (`@loop-lang/loop`) keeps the `loop` command (`loop init`), so the - two no longer collide on a global install. -- `plan from` is now a generic file source: `plan from "docs/plan.md"` reads the - plan from a file instead of having the agent generate one. Replaces the former - `plan from archon`. + (`@loop-lang/parser`, `@loop-lang/runtime`, `@loop-lang/stdlib`, `@loop-lang/viz`), + matching the installer `@loop-lang/loop`. +- The runtime CLI binary is now `loop-run` (`loop-run run`, `loop-run show`, …); the + installer (`@loop-lang/loop`) keeps the `loop` command (`loop init`), so the two no + longer collide on a global install. +- Renamed the Claude Code skill to `/loopflow` (avoids colliding with the built-in `/loop` + scheduler) and rewrote the README. +- `plan from` is now a generic file source: `plan from "docs/plan.md"` reads the plan from + a file instead of having the agent generate one. Replaces the former `plan from archon`. ### Removed -- Dropped all Archon coupling: the `@loop-lang/export-archon` package, the - `loop export` (Archon workflow YAML) command, the `plan from archon` source, - and the `ARCHON_URL`/`ARCHON_TOKEN`/`ARCHON_CODEBASE_ID` env vars. LoopFlow runs - natively on Claude Code; the language no longer references any third-party tool. +- Dropped all Archon coupling: the `@loop-lang/export-archon` package, the `loop export` + (Archon workflow YAML) command, the `plan from archon` source, and the + `ARCHON_URL`/`ARCHON_TOKEN`/`ARCHON_CODEBASE_ID` env vars. LoopFlow runs natively on + Claude Code; the language no longer references any third-party tool. ### Added - CI (`build` + `test` on Node 18/20) and a tag-driven release workflow. -- `SECURITY.md`, this changelog, and publish metadata (`publishConfig`, - `repository`, `homepage`, `bugs`) across the published packages. +- `SECURITY.md`, this changelog, and publish metadata (`publishConfig`, `repository`, + `homepage`, `bugs`) across the published packages. ## [0.1.0] -- First public release: the LoopFlow (`.loop`) language, parser, runtime, viz, - stdlib presets, the `loop` installer CLI, and the `loop-vscode` extension - (published as `0.2.0`). +- First public release: the LoopFlow (`.loop`) language, parser, runtime, viz, stdlib + presets, the `loop` installer CLI, and the `loop-vscode` extension (published as `0.2.0`). diff --git a/docs/index.html b/docs/index.html index 7976742..da35507 100644 --- a/docs/index.html +++ b/docs/index.html @@ -202,6 +202,7 @@ <li><a href="#workflows">Real workflows</a></li> <li><a href="#byrole">LoopFlow by role</a></li> <li><a href="#running">Running it</a></li> + <li><a href="#live">Live dashboard</a></li> <li><a href="#library">Your global library</a></li> <li class="grp">Go deeper</li> <li><a href="https://github.com/tickets-forge-dev/loop-lang/blob/master/docs/MANUAL.md">📖 Full manual →</a></li> @@ -676,11 +677,22 @@ <h3>In conversation (recommended)</h3> <p>You stay in the chat the whole time: you watch each step, you answer the gate, and the loop ends on a <em>real</em> green test — not the assistant saying "done". This is the only mode where a long interactive discovery works, because the human is already in the loop.</p> <h3>Headless CLI</h3> <pre class="sh"><code>loop-run run examples/ship_feature.loop # drive Claude Code, glyph trace +loop-run run examples/ship_feature.loop --live # …plus a live browser dashboard loop-run show examples/ship_feature.loop # print the loop's flow as compact ASCII loop-run ls # list every .loop in the repo + its shape loop-run parse examples/ship_flow.loop # print the parsed spec (the loop-spec IR) loop-run viz examples/ship_flow.loop # write a self-contained HTML schematic -# flags: --model <alias>, --out <path>, --events (NDJSON for a UI host), --json</code></pre> +# flags: --model <alias>, --live, --out <path>, --events (NDJSON for a UI host), --json</code></pre> + + <h3 id="live">Watch it run — live dashboard <span class="badge b-inter">intermediate</span></h3> + <p>A real-time browser view of a run, showing the loop's <strong>actual structure</strong> as a turn-by-turn route (Waze-style): the real pipeline stages / flow steps / for-each items, with a "you are here" marker, the steps ahead, human gates flagged, and <code class="inl">for each</code> sprints listed <em>by item title</em> with live progress. Two ways to drive it:</p> + <pre class="sh"><code>loop-run run file.loop --live # headless: the engine streams every step to the browser +/loopflow run file.loop # in-session: the skill opens the dashboard, then drives it</code></pre> + <p><strong>In a Claude Code chat (<code class="inl">/loopflow</code>)</strong> the dashboard is <strong>opt-in via <code class="inl">loop.config</code></strong>. <code class="inl">loop init</code> writes <code class="inl">loop.config</code> with <code class="inl">live=false</code> (off by default); flip it to <code class="inl">live=true</code> and the skill opens the dashboard and updates it as each step happens. The headless <code class="inl">--live</code> flag is independent of this config.</p> + <pre class="sh"><code># loop.config +live=true # /loopflow shows the live dashboard during in-session runs</code></pre> + <p>The page streams over Server-Sent Events (each event carries an id; the server replays on connect and dedupes on reconnect), is self-contained — no external assets — and binds to <code class="inl">127.0.0.1</code> only.</p> + <h3>In VS Code</h3> <ul> <li>Syntax highlighting, hover docs, and tab-completion for every construct.</li> From 7b3a86ca7248a16d6e88ac2bdb0fc53f71512eca Mon Sep 17 00:00:00 2001 From: Idan Ayalon <ticketsforge@gmail.com> Date: Sun, 28 Jun 2026 19:52:22 -0400 Subject: [PATCH 19/22] docs(tutorial): make the Claude Code chat the first-class usage path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Most people use LoopFlow via /loopflow in a Claude Code chat, so the tutorial now leads with it everywhere and demotes the VS Code extension to an optional 'author by hand' path: - Getting started: the chat is 'the main way'; VS Code is 'Optional · author by hand' (de-numbered, anchored #vscode-optional). - Running a loop: 'In a Claude Code chat (recommended — the main way)'; the VS Code subsection is marked optional/secondary. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- docs/index.html | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/docs/index.html b/docs/index.html index da35507..6566b9e 100644 --- a/docs/index.html +++ b/docs/index.html @@ -367,7 +367,7 @@ <h2>vs Claude Code's <code class="inl">/loop</code> and <code class="inl">/goal< <section id="start"> <h2>Getting started <span class="badge b-basic">setup</span></h2> <p class="sub" style="font-size:14px;margin-top:0">That's the <em>why</em>. From here down is the <strong>deep dive</strong> — set LoopFlow up once, then learn the language line by line.</p> - <p><b>Prerequisites:</b> <strong>Node 18+</strong>, the <a href="https://docs.anthropic.com/en/docs/claude-code">Claude Code</a> CLI (LoopFlow drives it), and a <strong>git repo</strong> to work in. Two ways to run a loop: <strong>inside a Claude Code chat</strong> (the bundled skill — recommended), or by hand in <strong>VS Code</strong> with the extension. One command installs everything:</p> + <p><b>Prerequisites:</b> <strong>Node 18+</strong>, the <a href="https://docs.anthropic.com/en/docs/claude-code">Claude Code</a> CLI (LoopFlow drives it), and a <strong>git repo</strong> to work in. <strong>The primary way to use LoopFlow is a Claude Code chat</strong> — type <code class="inl">/loopflow</code> and the assistant writes and runs the loop with you, watching every step and answering gates inline. (Prefer to write <code class="inl">.loop</code> files by hand? The VS Code extension is there too — see <a href="#vscode-optional">below</a> — but the chat is the recommended path.) One command installs everything:</p> <h3>1 · Install with npm</h3> <p>From your repo, run the installer. In one step it writes the <code class="inl">/loopflow</code> skill <em>and</em> <code class="inl">AGENTS.md</code> (the full language reference) into the project:</p> @@ -375,10 +375,10 @@ <h3>1 · Install with npm</h3> npx @loop-lang/loop init --global # or: install the /loopflow skill for every repo</code></pre> <p>That gives you two things at once. <code class="inl">/loopflow</code> is now available in a Claude Code chat here. And <code class="inl">AGENTS.md</code> sits at the repo root — it <em>travels with the project</em>, so any agent that opens the repo already knows the LoopFlow language; it's the project's persistent memory of how to write a <code class="inl">.loop</code>. (Methods are shared the same way: <code class="inl">use the <X> method</code> pulls in a <code class="inl">.loop</code> preset another repo can reuse.)</p> - <h3>2 · Run your first loop</h3> + <h3>2 · Run your first loop — in a Claude Code chat <span class="badge b-basic">the main way</span></h3> <pre class="sh"><code>/loopflow fix the failing auth test in src/auth, gate any database migration # writes a .loop /loopflow run examples/fix_test.loop # runs it, in the chat</code></pre> - <p>Describe the work and the skill writes the <code class="inl">.loop</code>; name a file and it runs the loop <strong>natively in the session</strong> — you watch every plan → act → observe → reflect step and answer human gates right in the chat. Prefer a terminal? The same files run headless via the CLI (<code class="inl">loop-run run <file></code>) — see <a href="#running">Running a loop</a>.</p> + <p>This is how most people use LoopFlow. Describe the work and the skill writes the <code class="inl">.loop</code>; name a file and it runs the loop <strong>natively in the session</strong> — you watch every plan → act → observe → reflect step and answer human gates right in the chat, and (when <a href="#live">enabled</a>) a live browser dashboard tracks where you are. Prefer a terminal? The same files run headless via the CLI (<code class="inl">loop-run run <file></code>) — see <a href="#running">Running a loop</a>.</p> <p>That first command writes a file like this — yours to read, edit, and re-run:</p> <pre><code class="loop"># /loopflow "fix the failing auth test in src/auth, gate any database migration" writes: loop "fix the failing auth test": @@ -390,8 +390,8 @@ <h3>2 · Run your first loop</h3> when it fails: reflect, then plan again after 6 tries: stop and warn "stuck"</code></pre> - <h3>3 · Author in VS Code <span class="badge b-basic">supported</span></h3> - <p>A <code class="inl">.loop</code> is just text — let the agent draft it, <em>or write one by hand</em>. Install the <strong>LoopFlow</strong> extension from the VS Code Marketplace — <em>Extensions ▸ search “LoopFlow” ▸ Install</em> — then open any <code class="inl">.loop</code> file:</p> + <h3 id="vscode-optional">Optional · author by hand in VS Code <span class="badge b-basic">if you prefer an editor</span></h3> + <p>The chat above is the main way to use LoopFlow. If you'd rather <em>write <code class="inl">.loop</code> files by hand</em>, the optional <strong>LoopFlow</strong> VS Code extension helps — <em>Extensions ▸ search “LoopFlow” ▸ Install</em> — then open any <code class="inl">.loop</code> file:</p> <ul> <li><strong>Syntax highlighting</strong> for the whole language — <code class="inl">goal</code>, <code class="inl">done when</code>, gates, <code class="inl">flow</code>, <code class="inl">for each</code>, <code class="inl">git:</code>.</li> <li><strong>Completion + formatting</strong> — keyword snippets so you type a loop from muscle memory, not a cheat sheet.</li> @@ -670,8 +670,8 @@ <h2>Running a loop <span class="badge b-inter">intermediate</span></h2> each cycle: plan, then act, then observe when it fails: reflect, then plan again after 5 tries: stop and warn "stuck"</code></pre> - <h3>In conversation (recommended)</h3> - <p>With the LoopFlow skill loaded, run it inside the chat — the assistant executes the cycle itself, narrates every step, and you answer gates inline:</p> + <h3>In a Claude Code chat <span class="badge b-basic">recommended — the main way</span></h3> + <p>This is how LoopFlow is meant to be used. With the <code class="inl">/loopflow</code> skill loaded, run it inside the chat — the assistant executes the cycle itself, narrates every step, and you answer gates inline:</p> <pre class="sh"><code>/loopflow "fix the failing checkout tax test" # write a .loop from a request /loopflow run examples/fix_test.loop # run it, right here in the chat</code></pre> <p>You stay in the chat the whole time: you watch each step, you answer the gate, and the loop ends on a <em>real</em> green test — not the assistant saying "done". This is the only mode where a long interactive discovery works, because the human is already in the loop.</p> @@ -693,7 +693,8 @@ <h3 id="live">Watch it run — live dashboard <span class="badge b-inter">interm live=true # /loopflow shows the live dashboard during in-session runs</code></pre> <p>The page streams over Server-Sent Events (each event carries an id; the server replays on connect and dedupes on reconnect), is self-contained — no external assets — and binds to <code class="inl">127.0.0.1</code> only.</p> - <h3>In VS Code</h3> + <h3>In VS Code <span class="badge b-basic">optional — for hand-authoring</span></h3> + <p>Secondary to the chat — reach for the extension only when you want to write <code class="inl">.loop</code> files by hand in an editor.</p> <ul> <li>Syntax highlighting, hover docs, and tab-completion for every construct.</li> <li>A <strong>▶ Run</strong> CodeLens above each definition. When you click it, you choose <em>where</em> it runs (the <code class="inl">loop.runMode</code> setting): <strong>open a Claude Code session</strong> — an interactive run in the integrated terminal where you watch every step and answer gates in chat — or the <strong>VS Code output panel</strong> (headless, with a live trace and native gate dialogs). Set <code class="inl">loop.runMode</code> to <code class="inl">ask</code> (prompt each time), <code class="inl">session</code>, or <code class="inl">output</code>.</li> From 3d8a7576b27166bb0f8950948e23ab959482eaef Mon Sep 17 00:00:00 2001 From: Idan Ayalon <ticketsforge@gmail.com> Date: Sun, 28 Jun 2026 19:59:25 -0400 Subject: [PATCH 20/22] feat(templates): best-practice .loop template library + agent awareness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add templates/ — copy-and-edit starting points for everyday Loop work, each heavily commented, marked with # TODO for project-specific bits, and validated to parse: - Spec-driven: greenfield-app, load-spec (+ discover, design, story-template, starter sprint.yaml/plan.md). - Change: feature, brownfield-feature, bugfix, refactor. - Quality gates: cicd-check, security, clean-architecture, test-coverage, review-diff. Plus templates/README.md grouping them by use case. Make them available to agents and users: - AGENTS.md + the /loopflow skill now tell the agent to start from a matching template instead of a blank file. - `loop init` ships templates/ into the target repo (sync-assets bundles it; --no-templates to skip), so the AGENTS.md reference holds in every repo. - The tutorial (docs/index.html) gains a "Starter templates" section + TOC entry. All 14 templates parse (independently audited via loop-run parse). 138 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- .claude/skills/loopflow/SKILL.md | 7 ++ AGENTS.md | 17 +++ docs/index.html | 22 ++++ packages/cli/scripts/sync-assets.mjs | 1 + packages/cli/src/cli.mjs | 2 + packages/cli/src/init.mjs | 10 +- packages/cli/test/init.test.mjs | 11 +- templates/README.md | 46 ++++++++ templates/brownfield-feature.loop | 157 +++++++++++++++++++++++++++ templates/bugfix.loop | 62 +++++++++++ templates/cicd-check.loop | 90 +++++++++++++++ templates/clean-architecture.loop | 75 +++++++++++++ templates/design.loop | 58 ++++++++++ templates/discover.loop | 69 ++++++++++++ templates/feature.loop | 68 ++++++++++++ templates/greenfield-app.loop | 67 ++++++++++++ templates/load-spec.loop | 41 +++++++ templates/plan.md | 52 +++++++++ templates/refactor.loop | 55 ++++++++++ templates/review-diff.loop | 58 ++++++++++ templates/security.loop | 94 ++++++++++++++++ templates/sprint.yaml | 33 ++++++ templates/story-template.loop | 93 ++++++++++++++++ templates/test-coverage.loop | 66 +++++++++++ 24 files changed, 1252 insertions(+), 2 deletions(-) create mode 100644 templates/README.md create mode 100644 templates/brownfield-feature.loop create mode 100644 templates/bugfix.loop create mode 100644 templates/cicd-check.loop create mode 100644 templates/clean-architecture.loop create mode 100644 templates/design.loop create mode 100644 templates/discover.loop create mode 100644 templates/feature.loop create mode 100644 templates/greenfield-app.loop create mode 100644 templates/load-spec.loop create mode 100644 templates/plan.md create mode 100644 templates/refactor.loop create mode 100644 templates/review-diff.loop create mode 100644 templates/security.loop create mode 100644 templates/sprint.yaml create mode 100644 templates/story-template.loop create mode 100644 templates/test-coverage.loop diff --git a/.claude/skills/loopflow/SKILL.md b/.claude/skills/loopflow/SKILL.md index 79f8dad..d422ad8 100644 --- a/.claude/skills/loopflow/SKILL.md +++ b/.claude/skills/loopflow/SKILL.md @@ -82,6 +82,13 @@ it in front of them. Never silently guess the loop; interview first, then author one topic at a time, and always offer a default so a confident user can accept it all in one reply. +**Check the template library first.** If the repo has a `templates/` directory (see +`templates/README.md`), and the user's request matches one — a bug fix, a feature, a +brownfield change, a CI/security/architecture gate, delivering an existing spec, building +a greenfield app — start from that template instead of a blank file: read it, copy it, and +fill in its `# TODO` lines with the user's specifics. Still interview them for the goal, +the real `done when`, and what to gate; the template is the skeleton, not the answer. + ### Step 0 — scope the purpose first (ask this before anything else) Open with **"What do you want the loop to accomplish?"** and show the range, so the diff --git a/AGENTS.md b/AGENTS.md index c053861..6fb72b2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,6 +38,23 @@ high-leverage questions and offering defaults for the rest: (1) the **goal**; never push to `main`; ask if they want a PR or a worktree). Offer the defaults inline so a confident user can accept everything at once. +### Start from a template when one fits + +This repo ships a library of **best-practice starter loops** in [`templates/`](./templates/) +(see [`templates/README.md`](./templates/README.md)). When the user's request matches one, +**reach for it first** — copy it, fill in its `# TODO` lines (test commands, paths), and +adapt — instead of authoring from a blank file. They cover the everyday jobs: + +- **Spec-driven:** `greenfield-app.loop` (whole app, A-to-Z), `load-spec.loop` (deliver an + existing `plan.md` + `sprint.yaml` backlog story by story) — with `discover.loop`, + `design.loop`, `story-template.loop`, and starter `sprint.yaml`/`plan.md`. +- **Change:** `feature.loop`, `brownfield-feature.loop`, `bugfix.loop`, `refactor.loop`. +- **Quality gates:** `cicd-check.loop`, `security.loop`, `clean-architecture.loop`, + `test-coverage.loop`, `review-diff.loop`. + +Each is heavily commented and verified to parse. Still interview the user for the specifics +(goal, the real `done when`, what to gate) — the template is the skeleton, not the answer. + ## Vocabulary (the whole language) ``` diff --git a/docs/index.html b/docs/index.html index 6566b9e..06ae656 100644 --- a/docs/index.html +++ b/docs/index.html @@ -201,6 +201,7 @@ <li class="grp">In practice</li> <li><a href="#workflows">Real workflows</a></li> <li><a href="#byrole">LoopFlow by role</a></li> + <li><a href="#templates">Starter templates</a></li> <li><a href="#running">Running it</a></li> <li><a href="#live">Live dashboard</a></li> <li><a href="#library">Your global library</a></li> @@ -660,6 +661,27 @@ <h3>Security</h3> <!-- 15 --> <!-- 16 --> +<section id="templates"> + <h2>Starter templates — don't write from scratch <span class="badge b-basic">copy & edit</span></h2> + <p>The repo ships a library of <strong>best-practice <code class="inl">.loop</code> templates</strong> for everyday work in <a href="https://github.com/tickets-forge-dev/loop-lang/tree/master/templates"><code class="inl">templates/</code></a> — heavily commented, validated to parse, with every project-specific line marked <code class="inl"># TODO</code>. When your job matches one, start there: copy it, fill in the TODOs (your test commands, paths), and run it. In a Claude Code chat the <code class="inl">/loopflow</code> skill reaches for these automatically when a request matches.</p> + <table> + <tr><th>Group</th><th>Template</th><th>When to use</th></tr> + <tr><td rowspan="2"><strong>Spec-driven</strong></td><td><code class="inl">greenfield-app.loop</code></td><td>Build a whole app from nothing, A-to-Z (discover → design → build each story).</td></tr> + <tr><td><code class="inl">load-spec.loop</code></td><td>You already have a <code class="inl">plan.md</code> + <code class="inl">sprint.yaml</code> backlog — deliver it story by story.</td></tr> + <tr><td rowspan="4"><strong>Change</strong></td><td><code class="inl">feature.loop</code></td><td>Ship one feature with regression + security + human review.</td></tr> + <tr><td><code class="inl">brownfield-feature.loop</code></td><td>Add a feature to an existing codebase without breaking it.</td></tr> + <tr><td><code class="inl">bugfix.loop</code></td><td>Fix one bug, proven by a named test.</td></tr> + <tr><td><code class="inl">refactor.loop</code></td><td>Improve structure with behavior unchanged (full suite stays green).</td></tr> + <tr><td rowspan="5"><strong>Quality gates</strong></td><td><code class="inl">cicd-check.loop</code></td><td>Drive every CI check (lint → typecheck → test → build) to green.</td></tr> + <tr><td><code class="inl">security.loop</code></td><td>A security pass: SAST → dependency audit → secrets scan.</td></tr> + <tr><td><code class="inl">clean-architecture.loop</code></td><td>Enforce boundaries — dependencies point inward, no layer leaks.</td></tr> + <tr><td><code class="inl">test-coverage.loop</code></td><td>Raise coverage to a threshold with meaningful tests.</td></tr> + <tr><td><code class="inl">review-diff.loop</code></td><td>Review and clean the current branch diff (review-skill gate + human).</td></tr> + </table> + <p class="sub" style="margin-top:8px">Supporting files: <code class="inl">story-template.loop</code> (the per-story checklist shared by the two spec-driven flows), plus starter <code class="inl">sprint.yaml</code> and <code class="inl">plan.md</code> to replace with your own.</p> +</section> + +<!-- 17 --> <section id="running"> <h2>Running a loop <span class="badge b-inter">intermediate</span></h2> <p>Every command below runs the same kind of file — here's the one referenced throughout this section:</p> diff --git a/packages/cli/scripts/sync-assets.mjs b/packages/cli/scripts/sync-assets.mjs index 55bc9da..3cbfc52 100644 --- a/packages/cli/scripts/sync-assets.mjs +++ b/packages/cli/scripts/sync-assets.mjs @@ -15,6 +15,7 @@ const COPIES = [ [join(root, "AGENTS.md"), join(assets, "AGENTS.md")], [join(root, ".claude", "skills", "loopflow"), join(assets, "skill")], [join(root, "examples", "fix_test.loop"), join(assets, "examples", "fix_test.loop")], + [join(root, "templates"), join(assets, "templates")], ]; await rm(assets, { recursive: true, force: true }); diff --git a/packages/cli/src/cli.mjs b/packages/cli/src/cli.mjs index c9a9672..1a3efa2 100755 --- a/packages/cli/src/cli.mjs +++ b/packages/cli/src/cli.mjs @@ -24,6 +24,7 @@ init options: --global install the /loopflow skill into ~/.claude/skills (done automatically on \`npm install -g\`) --no-skill don't install the Claude Code /loopflow skill --no-example don't write examples/fix_test.loop + --no-templates don't write the templates/ starter loops (written by default) --no-claude-md don't write the CLAUDE.md pointer (written by default) --cursor also write .cursor/rules/loop.md --copilot also write .github/copilot-instructions.md @@ -68,6 +69,7 @@ async function main(argv) { skill, agents, example: !flag(argv, "--no-example"), + templates: !flag(argv, "--no-templates"), force: flag(argv, "--force"), }, ASSETS); diff --git a/packages/cli/src/init.mjs b/packages/cli/src/init.mjs index 9a99ea1..cc78b44 100644 --- a/packages/cli/src/init.mjs +++ b/packages/cli/src/init.mjs @@ -66,7 +66,7 @@ async function copyInto(src, dst, { force }) { * @returns {Promise<{steps:string[]}>} */ export async function init(targetDir, opts, assetsDir) { - const { skill = "local", agents = [], example = true, force = false } = opts; + const { skill = "local", agents = [], example = true, templates = true, force = false } = opts; const steps = []; const withSkill = skill !== "none"; @@ -115,5 +115,13 @@ export async function init(targetDir, opts, assetsDir) { steps.push(`${r === "skipped" ? "skipped (exists)" : "wrote"} examples/fix_test.loop (a starter loop)`); } + // 5. Best-practice starter templates — the agent (and you) copy + adapt these for + // everyday jobs (bugfix, feature, CI, security, deliver a spec, …). AGENTS.md + // points the agent at them. + if (templates) { + const r = await copyInto(join(assetsDir, "templates"), join(targetDir, "templates"), { force }); + steps.push(`${r === "skipped" ? "skipped (exists)" : "wrote"} templates/ (best-practice starter loops — see templates/README.md)`); + } + return { steps }; } diff --git a/packages/cli/test/init.test.mjs b/packages/cli/test/init.test.mjs index 15f793f..ae510b8 100644 --- a/packages/cli/test/init.test.mjs +++ b/packages/cli/test/init.test.mjs @@ -47,9 +47,18 @@ test("init merges into an existing AGENTS.md instead of clobbering it", async () test("init respects skill:none and example:false", async () => { const dir = await fresh(); - await init(dir, { skill: "none", agents: [], example: false }, ASSETS); + await init(dir, { skill: "none", agents: [], example: false, templates: false }, ASSETS); assert.equal(existsSync(join(dir, ".claude")), false, "no skill"); assert.equal(existsSync(join(dir, "examples")), false, "no example"); + assert.equal(existsSync(join(dir, "templates")), false, "no templates"); +}); + +test("init writes the templates/ starter loops by default", async () => { + const dir = await fresh(); + await init(dir, { skill: "none", agents: [], example: false }, ASSETS); + assert.ok(existsSync(join(dir, "templates", "README.md")), "templates README"); + assert.ok(existsSync(join(dir, "templates", "bugfix.loop")), "a starter template"); + assert.ok(existsSync(join(dir, "templates", "clean-architecture.loop")), "clean-architecture template"); }); test("init writes loop.config with live=false by default", async () => { diff --git a/templates/README.md b/templates/README.md new file mode 100644 index 0000000..8267089 --- /dev/null +++ b/templates/README.md @@ -0,0 +1,46 @@ +# Loop templates + +Copy-and-edit starting points for everyday Loop work. Each `.loop` here is a heavily commented, best-practice skeleton for a common job — fixing a bug, shipping a feature, driving CI to green, delivering a spec story by story. They are written to be cloned into your repo and customized: every line that's specific to your project is marked `# TODO` (test commands, file paths, try ceilings). All of them parse cleanly, so `show`/`run` work the moment you copy them. Don't run a template as-is — fill in the TODOs first. + +## Pick a template + +### Spec-driven (a whole app / a written backlog) + +| Template | When to use | Shape | +| --- | --- | --- | +| `greenfield-app.loop` | Build an app from nothing, A to Z. | `flow`: `discover.loop` → `design.loop` → `for each story in "sprint.yaml"` → `story-template.loop` | +| `load-spec.loop` | You already have a spec + backlog; deliver it story by story. | `flow`: `for each story in "sprint.yaml"` → `story-template.loop` | + +### Change (build / fix / restructure) + +| Template | When to use | Shape | +| --- | --- | --- | +| `feature.loop` | Ship one feature with regression + security + human review. | `pipeline` (fail-fast): build → regression → security → 👤 review | +| `brownfield-feature.loop` | Add a feature to an existing codebase without breaking it. | `pipeline` (fail-fast, file-level `git:` branch/commit/no-push): build → regression (no-regression guarantee) → security → 👤 review | +| `bugfix.loop` | Fix one bug, proven by a named test. | single `loop`: reproduce → fix → verify; ↺ reflect-on-fail, ⛔ after 6 tries | +| `refactor.loop` | Improve structure with behavior unchanged. | single `loop`: full suite green = no-regression proof; no-new-deps gate; 👤 review; ⛔ after 6 | + +### Quality gates (drive existing checks to green) + +| Template | When to use | Shape | +| --- | --- | --- | +| `cicd-check.loop` | Make every CI check pass locally. | `pipeline` (fail-fast, `git:` branch/commit/no-push): lint → typecheck → test → build | +| `security.loop` | Run a security pass before shipping. | `pipeline` (fail-fast): sast (semgrep) → deps (npm audit) → 👤 secrets (gitleaks) | +| `clean-architecture.loop` | Enforce architecture boundaries — deps point inward, no layer leaks. | single `loop`: `done when` a boundary checker passes (or a review skill scores); ↺ reflect = invert/move, don't relax the rule; 👤 review; ⛔ after 6 | +| `test-coverage.loop` | Raise coverage to a threshold with meaningful tests. | single `loop`: `done when` coverage gate passes; ↺ reflect-on-fail; ⛔ after 6 | +| `review-diff.loop` | Review and clean the current branch diff. | single `loop`: `done when the skill "code-review" approves`; 👤 human signs off last; ⛔ after 4 | + +## How to use + +1. Copy the `.loop` you want into your repo and rename it. +2. Edit every `# TODO` line — your real test commands, file paths, and try counts. (The default gate strings like `pnpm test` or `npm test` are placeholders; swap in yours.) +3. Eyeball the shape, then run it: + - `/loopflow run <file>` inside Claude Code, or + - `loop-run run <file>` headless. + Run `show <file>` first to sanity-check the flow before executing. + +## Supporting files + +- `sprint.yaml` and `plan.md` are **starter data**, not loops — a small auth-style backlog/spec to replace with your own. `load-spec.loop` and `greenfield-app.loop` iterate `sprint.yaml`; `plan.md` is the human-readable spec you keep in sync with it. +- `story-template.loop` is the per-story checklist (implement → security → 👤 manual) shared by both `load-spec.loop` and `greenfield-app.loop` — author it once, drive it with the spec. +- `discover.loop` and `design.loop` are the first two stages of `greenfield-app.loop` (interview → `sprint.yaml`, then `sprint.yaml` → `design.md`), each with its own artifact-check gate and human review. diff --git a/templates/brownfield-feature.loop b/templates/brownfield-feature.loop new file mode 100644 index 0000000..cd16da3 --- /dev/null +++ b/templates/brownfield-feature.loop @@ -0,0 +1,157 @@ +# ============================================================================ +# TEMPLATE: brownfield-feature.loop +# Add a feature to an EXISTING codebase without breaking it. +# ============================================================================ +# +# WHAT MAKES THIS "BROWNFIELD" +# Greenfield work is a blank page — you invent the patterns. Brownfield work +# lands inside code other people already rely on. The job is not just "make the +# new thing work"; it's "make the new thing work WITHOUT disturbing everything +# around it." So this template optimizes for three habits, in priority order: +# +# 1. MATCH EXISTING PATTERNS — read the neighbours first; copy their shape, +# naming, error handling, and layering. New code should look like it was +# always there, not like a transplant. +# 2. CHANGE THE MINIMUM — the smallest diff that satisfies the acceptance +# test. Every extra line you touch is extra surface area for a regression. +# Resist refactors, renames, and "while I'm here" cleanups. +# 3. PROVE NO REGRESSION — existing behaviour must be byte-for-byte intact. +# The whole pre-existing suite has to stay green. That proof is a separate, +# non-negotiable stage below — it is the core brownfield guarantee. +# +# WHY A PIPELINE (not a single loop) +# A pipeline runs its stages top-to-bottom and is FAIL-FAST: a stage must be +# satisfied before the next one starts. That ordering is the safety rail here — +# we refuse to even run the security scan or ask for human review until we have +# PROVEN the existing suite is still green. Build → prove-no-regression → +# security → human review. Each stage body is a normal loop (its own goal, its +# own `done when` gate, its own thrash guard). +# +# HOW TO USE +# 1. Replace every "# TODO:" below with your real module paths, test names, +# and commands. +# 2. node packages/runtime/dist/cli.js show templates/brownfield-feature.loop +# to eyeball the shape. +# 3. node packages/runtime/dist/cli.js run templates/brownfield-feature.loop +# to execute it (pauses at the human gates). + +# --- Git strategy (file-level, applies to every stage) ---------------------- +# Safe brownfield defaults: never touch your current branch, and never push. +# - work on a branch -> your existing code on main/master stays clean +# - commit each story -> one commit PER STAGE, so the history reads +# build / regression-proof / security and you +# can bisect or roll back a single stage cleanly +# - do not push -> review locally before anything leaves the machine +# Loop NEVER pushes to main/master regardless of this block. Add `push when done` +# / `open a pull request` only once you actually want to publish. +git: + work on a branch + commit each story + do not push + +pipeline "feature: <NAME> (brownfield — do not break existing behaviour)": # TODO: name the feature + + # ========================================================================== + # 1. BUILD (explore + build) + # -------------------------------------------------------------------------- + # This stage is two moves fused into one self-correcting loop: + # EXPLORE — before writing a line, the agent reads the TARGET module AND its + # NEIGHBOURS so it learns the local conventions (how this codebase + # names things, handles errors, structures a layer). `look at:` + # below is what forces that reconnaissance. + # BUILD — implement the feature by MATCHING those patterns and changing as + # little as possible, iterating until the acceptance test is green. + # The acceptance test is the objective gate: the loop literally cannot claim + # "done" until that exact test passes. + # ========================================================================== + stage build: + goal: implement <FEATURE> by matching the surrounding code's patterns and changing the minimum, so the acceptance test passes # TODO: describe the feature + + # EXPLORE FIRST. List the target module AND the modules it touches — the + # callers, the shared types, the layer above and below it. Reading the + # NEIGHBOURS is how the agent matches existing patterns instead of inventing + # new ones. "and the last failure" feeds each red test back into the plan. + # Items can be exact paths or plain-language descriptions the agent resolves. + look at: src/<target-module>, its neighbours (callers, shared types, the layer above and below), the existing tests for this area, and the last failure # TODO: real paths + + # Auto-edit so the loop can iterate fast, but HARD-GATE the irreversible: + # a schema migration can corrupt existing data and a push is hard to undo — + # both pause for a human. This is the brownfield "do no harm" guard. + allow edits automatically, but ask me before migrations or pushes + + each cycle: plan, then act, then observe # explore/plan -> implement -> read the test result + + # THE GATE: one specific acceptance test for the NEW behaviour. Keep it + # fast and deterministic — this runs every cycle. + done when the test "<path/to/feature.spec.ts>::<acceptance test name>" passes # TODO: real test id + + # On red, reflect on WHICH LAYER broke (api / service / data / ui) — naming + # the layer keeps the fix local and stops the agent thrashing across files. + when it fails: reflect on which layer broke and whether you drifted from the existing pattern, then plan again + + when it passes and the goal is met: stop + when blocked: ask a human # stuck on intent? get a human, don't guess + after 6 tries: stop and warn "feature build is thrashing — the change may be bigger than one acceptance test" + + # ========================================================================== + # 2. REGRESSION (the core brownfield guarantee — existing behaviour UNCHANGED) + # -------------------------------------------------------------------------- + # This is the stage that makes the whole template "brownfield". The build + # stage proved the NEW test passes; this stage proves we broke NOTHING ELSE. + # The bar is the ENTIRE pre-existing suite, green — not the one new test. A + # change that satisfies the acceptance test but quietly breaks a neighbour + # keeps this stage RED until the regression is undone. Fail-fast ordering + # means we never reach security/review on top of a regression. + # ========================================================================== + stage regression: + goal: the FULL existing test suite is still green — the feature changed nothing it shouldn't have + + # No new code to plan here — just run the whole suite and read the result. + each cycle: act, then observe + + # The whole suite, exit 0. This is the proof of "no regression". Use your + # real, complete test command (NOT a filtered subset — a subset can't catch + # a regression in code you didn't think to re-run). + done when "<full test command>" passes # TODO: e.g. pnpm test / npm test / pytest / go test ./... + + # If something downstream went red, the fix likely OVER-REACHED. Reflect on + # what the feature changed that rippled outward, and prefer SHRINKING the + # diff (revert the unnecessary edit) over piling on more changes. + when it fails: reflect on which existing tests broke and how to shrink the change so they pass again, then plan again + + when it passes and the goal is met: stop + after 6 tries: stop and warn "existing tests still failing — the feature is causing a regression; a human should scope the change down" + + # ========================================================================== + # 3. SECURITY (the new code introduces no high-severity findings) + # -------------------------------------------------------------------------- + # New surface area in an existing app is exactly where vulnerabilities sneak + # in. "finds nothing" = the scanner exits 0 AND prints no output, so only a + # clean run passes the gate. + # ========================================================================== + stage security: + goal: the new code introduces no high-severity vulnerabilities + + each cycle: plan, then act, then observe + + # A real scanner is the bar. Swap in your configured Semgrep command (or + # your team's equivalent). + done when "semgrep --config auto --severity=high" finds nothing # TODO: your scanner / config + + when it fails: reflect on the flagged lines and fix them without expanding the change, then plan again + after 4 tries: stop and warn "security findings need a human" + + # ========================================================================== + # 4. REVIEW (a human signs off before we call it done) + # -------------------------------------------------------------------------- + # Every objective gate is green: new test passes, the whole suite passes, + # the scanner is clean. But "does this fit the codebase?" is a judgement call + # a machine can't make — does the new code truly match the existing patterns, + # is the diff genuinely minimal, did we respect the architecture? A human + # reviews the final diff and is the blocking gate before the pipeline stops. + # ========================================================================== + stage review: + goal: a human confirms the change matches existing patterns, is minimal, and respects the architecture + + each cycle: observe # nothing to change automatically — just present the diff for review + a human reviews before stopping # blocking gate: a person signs off on the final diff diff --git a/templates/bugfix.loop b/templates/bugfix.loop new file mode 100644 index 0000000..84bdc68 --- /dev/null +++ b/templates/bugfix.loop @@ -0,0 +1,62 @@ +# bugfix.loop — COPY-AND-EDIT TEMPLATE: fix one bug as a self-correcting loop. +# +# SHAPE: reproduce → fix → verify, on repeat until a named test goes green. +# 1. REPRODUCE — a failing test ("file::name") that proves the bug exists. +# 2. FIX — the loop edits the code to make that test pass. +# 3. VERIFY — the same test re-runs every cycle; green == done. +# +# Copy this file, then edit every line marked "# TODO:" to match your repo +# (the test name, the files to read, the try ceiling). Everything else is a +# sane default you can keep as-is. +# +# WHY A LOOP (not a one-shot prompt)? A bug fix is the textbook case: there's a +# crisp definition of "done" (the test passes), the work may take several +# attempts, and the loop can verify its own progress between attempts. + +loop "fix <the bug>": # TODO: name the bug, e.g. "fix null user on logout" + # The objective in one sentence. "without breaking its file" is deliberate: + # the fix must turn the target test green WITHOUT making its sibling tests red. + goal: the bug is fixed and the test file stays green — no neighbor tests break + + # ── VERIFY ──────────────────────────────────────────────────────────────── + # The finish line. A single named test, "file::test name", is the cheapest, + # most precise gate — the loop is done the moment this exact test exits green. + # FIRST make sure this test exists and currently FAILS (that's your repro). If + # there's no failing test yet, write one that reproduces the bug, then point + # here. A bug "fixed" with no test to prove it can silently come back. + done when the test "<file>::<test name>" passes # TODO: real "file::name", e.g. "auth.spec.ts::logs out cleanly" + + # ── CONTEXT (reproduce) ───────────────────────────────────────────────────── + # Scope what the agent reads so it makes the SMALLEST change inside the + # existing code instead of rewriting things. List the buggy module, its test + # file, and "the last failure" — that final phrase feeds the most recent test + # output (assertion + stack trace) into the next plan, which is what makes the + # reflect step below actually useful. + look at: <src/buggy-file.ts>, <its test file>, and the last failure # TODO: the real code + test paths + + # Let the agent edit code and tests on its own, but gate anything irreversible + # so an autonomous loop can't reshape your DB or push while chasing a bug. + allow edits automatically, but ask me before migrations or pushes + + # ── THE CYCLE ─────────────────────────────────────────────────────────────── + # One iteration: think (plan), change code (act), then run the test and read + # the result (observe). Observe is what closes the feedback loop. + each cycle: plan, then act, then observe + + # ── REFLECT, DON'T JUST RETRY ─────────────────────────────────────────────── + # The heart of a self-correcting loop. A naive loop re-runs the SAME failed + # idea and spins. Instead, on every red run we reflect FIRST — read the failing + # assertion and the stack trace to understand *why* it broke — and only then + # form a new plan. Reflection turns each failure into evidence for the next + # attempt, so the loop converges instead of thrashing in circles. + when it fails: reflect on the failing assertion and stack trace, then plan again + + # Success condition: the named test passes AND the goal is met → stop cleanly. + # Don't keep polishing once it's green; that's how a fix accidentally regresses. + when it passes and the goal is met: stop + + # ── THRASH GUARD ──────────────────────────────────────────────────────────── + # A hard ceiling so the loop can't spin forever. If 6 reflect-and-retry rounds + # don't crack it, the bug is deeper than a quick fix — stop and hand a human + # the context instead of burning tokens. Tune the count to the bug's difficulty. + after 6 tries: stop and warn "<the bug> still failing after 6 tries — needs a human" # TODO: message + count diff --git a/templates/cicd-check.loop b/templates/cicd-check.loop new file mode 100644 index 0000000..88b92d7 --- /dev/null +++ b/templates/cicd-check.loop @@ -0,0 +1,90 @@ +# templates/cicd-check.loop +# ───────────────────────────────────────────────────────────────────────────── +# Drive CI to GREEN — a fail-fast pipeline of fast gates that FIXES failures. +# +# This is a `pipeline`: a sequence of stages that run IN CI ORDER and fail-fast. +# Each stage is a self-correcting loop that doesn't just *report* a failing +# check — it keeps planning, editing, and re-running until that check passes. +# The next stage only starts once the current one is green, so you never burn +# tokens type-checking code that doesn't even lint, or testing code that doesn't +# type-check. Same order your CI runs, cheapest gate first: +# +# lint → typecheck → test → build +# +# Why this order? It's the classic fast-feedback ladder: lint is milliseconds, +# typecheck is seconds, the test suite is slower, and a full build is slowest. +# Catching a problem at the cheapest stage that can catch it is the whole point +# of "fail-fast" — and it mirrors what your CI does, so going green here means +# going green in CI. +# +# Each stage shares the same shape: +# • each cycle: plan, then act, then observe — look at the failure, fix it, re-check +# • done when "<cmd>" passes — the REAL gate: exit 0 = green +# • when it fails: reflect on the error output, then plan again — self-correct +# • after N tries: stop and warn "<msg>" — thrash guard, never spin forever +# +# HOW TO USE THIS TEMPLATE: +# 1. Replace every "# TODO:" command with YOUR project's real CI command. +# 2. Tune each `look at:` to point at the files/config that stage cares about. +# 3. Tune the `after N tries` limits to taste (cheap gates can afford more). +# 4. Validate: loop-run show templates/cicd-check.loop +# Run it: loop-run run templates/cicd-check.loop +# ───────────────────────────────────────────────────────────────────────────── + +# ── Config tier: safe git defaults ────────────────────────────────────────── +# Work on a branch and commit only once the whole pipeline is green; never push. +# Pushing/opening a PR is left to you — an autonomous CI-fixer should not be the +# thing that puts code on a remote. (Loop NEVER pushes to main/master anyway.) +git: + work on a branch # TODO: or `work in place` if you're already on a fix branch + commit when the goal is met + do not push # flip to `push when done` / `open a pull request` only if you trust it + +pipeline "ci: drive every check to green": + + # 1. LINT — cheapest gate first. Style, formatting, and lint-rule violations. + # Fix lint errors (and autofixable warnings) until the linter exits clean. + # Edits run automatically; this is low-risk, mechanical cleanup. + stage lint: + goal: the linter reports zero errors across the codebase + look at: the lint config (eslint/ruff/etc.) and the files the linter flags, and the last failure + allow edits automatically + each cycle: plan, then act, then observe + done when "npm run lint" passes # TODO: your lint command (e.g. eslint . / ruff check . / pnpm lint) + when it fails: reflect on the error output, then plan again + after 8 tries: stop and warn "lint won't go green — a rule or config likely needs a human" + + # 2. TYPECHECK — now that it lints, make the types sound. Fix type errors + # until the checker is clean. Only runs once lint is green (fail-fast). + stage typecheck: + goal: the type checker reports no type errors + look at: the files named in the type errors and their imports, and the last failure + allow edits automatically + each cycle: plan, then act, then observe + done when "npm run typecheck" passes # TODO: your typecheck command (e.g. tsc --noEmit / mypy . / pnpm typecheck) + when it fails: reflect on the error output, then plan again + after 6 tries: stop and warn "typecheck stuck — a type model may need a human decision" + + # 3. TEST — with types sound, drive the test suite green. Fix the code (or the + # test, when the test is wrong) until every test passes. Slower gate, so a + # tighter try-budget keeps it from grinding. + stage test: + goal: the full test suite passes with no failing tests + look at: the failing tests and the code under test, and the last failure + allow edits automatically + each cycle: plan, then act, then observe + done when "npm test" passes # TODO: your test command (e.g. jest / vitest run / pytest / pnpm test) + when it fails: reflect on the error output, then plan again + after 5 tries: stop and warn "tests still failing — needs a human to look at the failure" + + # 4. BUILD — the final, slowest gate. Everything lints, types, and tests; now + # make sure it actually compiles/bundles for production. Fix build errors + # until the build succeeds. Green here means green in CI. + stage build: + goal: the production build completes successfully with no errors + look at: the build config and the files named in build errors, and the last failure + allow edits automatically + each cycle: plan, then act, then observe + done when "npm run build" passes # TODO: your build command (e.g. vite build / next build / pnpm build / make) + when it fails: reflect on the error output, then plan again + after 5 tries: stop and warn "build won't complete — needs a human" diff --git a/templates/clean-architecture.loop b/templates/clean-architecture.loop new file mode 100644 index 0000000..1af45af --- /dev/null +++ b/templates/clean-architecture.loop @@ -0,0 +1,75 @@ +# clean-architecture.loop +# TEACHES: enforce CLEAN ARCHITECTURE — fix the code until your boundary rules +# hold, then keep them holding. +# +# Clean architecture is a set of *dependency rules*: dependencies point inward +# (UI → application → domain, never the reverse), layers don't reach across, +# and the domain knows nothing about frameworks, the database, or HTTP. Those +# rules are only real if a machine checks them — a "we agreed not to" rule +# rots the first time someone's in a hurry. +# +# So the finish line here is an ARCHITECTURE CHECK COMMAND, not a vibe. Encode +# your boundaries once (in a depcruise / import-linter / eslint-boundaries / +# ArchUnit / ts-arch config), and this loop drives the code until that check +# passes — fixing the wrong-direction imports and layer leaks it finds. +# +# Pick whichever fits your stack for the `done when` below (TODO): +# - JS/TS: "depcruise --config .dependency-cruiser.js --validate src" +# - JS/TS: "eslint --rule 'boundaries/element-types: error' src" +# - Python: "lint-imports" (import-linter, reads importlinter.ini) +# - Java: "mvn -q test -Dtest=ArchitectureTest" (ArchUnit) +# - Go: "go-cleanarch" or a custom go vet analyzer +# Or make a review skill the gate instead (see the alternative at the bottom). + +loop "enforce clean architecture boundaries": + goal: every module respects the architecture rules — dependencies point inward, layers don't cross, and the domain stays framework-free + + # THE GATE: your boundary checker must pass (exit 0 = no violations). This is + # the whole point — a real tool decides "clean", not the agent's opinion. + # TODO: replace with your architecture-check command (see the menu above). + done when "depcruise --config .dependency-cruiser.js --validate src" passes + + # Give the agent the rules themselves plus the offending modules, and feed the + # last failure back so it learns which boundary it just broke. + # TODO: point at your real rules file + the layers being checked. + look at: the architecture rules (e.g. .dependency-cruiser.js), the layer directories under src, and the last failure + + each cycle: plan, then act, then observe + + # KEY for architecture: a violation is fixed by RESTRUCTURING, not by relaxing + # the rule. Reflect on WHICH boundary was crossed and WHY (a domain file + # importing a framework? a layer reaching sideways?), then invert the + # dependency or move the code — don't loosen the checker to force green. + when it fails: reflect on which boundary was crossed and whether to invert the dependency or move the code, then plan again + + # A clean architecture pass shouldn't quietly add libraries or reshape the + # schema — gate the irreversible / scope-expanding moves. + allow edits automatically, but ask me before adding dependencies, migrations, or pushes + + when it passes and the goal is met: stop + + # HUMAN GATE: boundaries encode design intent. A person confirms the fix moved + # the code the right way (and didn't just delete a rule or add an allow-list + # exception to silence the checker) before this loop is allowed to stop. + a human reviews before stopping + + # THRASH GUARD: if six rounds can't satisfy the rules, the violation is + # structural and bigger than a loop should swallow — stop and surface it. + after 6 tries: stop and warn "architecture check still failing after 6 tries — the boundary break is structural; needs a human" + +# --------------------------------------------------------------------------- +# ALTERNATIVE — no boundary tool yet? Make a REVIEW SKILL the gate instead of a +# command. A review skill judges the change against clean-architecture principles +# and returns approve / a score, which turns "looks well-layered" into a +# verifiable predicate: +# +# loop "enforce clean architecture boundaries": +# goal: the change respects clean-architecture boundaries +# use skills: clean-architecture +# done when the skill "clean-architecture" scores 8 or more +# look at: the changed modules and their imports, and the last failure +# each cycle: plan, then act, then observe +# when it fails: reflect on the skill's findings, then plan again +# a human reviews before stopping +# after 6 tries: stop and warn "still below the architecture bar after 6 tries — needs a human" +# --------------------------------------------------------------------------- diff --git a/templates/design.loop b/templates/design.loop new file mode 100644 index 0000000..fc43569 --- /dev/null +++ b/templates/design.loop @@ -0,0 +1,58 @@ +# ============================================================================ +# design.loop — STEP 2 of the greenfield A-to-Z build (see greenfield-app.loop) +# ============================================================================ +# +# WHAT THIS IS +# The bridge between "what to build" (sprint.yaml, from discover.loop) and +# "how to build it" (design.md). It reads the backlog and produces an +# architecture note: the stack, the modules/boundaries, the data model, the +# key decisions and trade-offs every story will be built against. +# +# WHY IT MATTERS THAT THIS RUNS BEFORE THE STORY LOOP +# In the greenfield flow, the next step builds every story autonomously. If +# each story invents its own architecture, you get a patchwork app. design.md +# is the shared blueprint that keeps all those independent story builds +# coherent — so the small amount of human attention spent here pays off across +# the whole sprint. +# +# DONE = THE ARTIFACT EXISTS, AND A HUMAN APPROVES IT +# Like discovery, "done" is anchored to a file on disk (design.md), not to how +# thorough the discussion felt. But architecture is a judgment call a file +# check can't make, so this loop ALSO requires a human review before it stops: +# the file must exist AND a person must agree the design is sound. + +loop "design": + # The objective. Note it names the input (sprint.yaml) and the output (design.md). + goal: turn sprint.yaml into design.md — an architecture note covering the stack, module boundaries, data model, and the key decisions each story will be built against + + # Read the backlog this design must satisfy, plus any existing architecture + # docs so the design extends what's there instead of reinventing it. + # "the last failure" feeds the reflect step after a rejected draft. + # TODO: add real paths — your ADRs, an existing docs/architecture.md, etc. + look at: sprint.yaml, any existing architecture docs, and the last failure + + # Writing a markdown design note is cheap and reversible, so don't gate edits. + allow edits automatically + + # PLAN the design (or the next revision), ACT (write design.md), OBSERVE + # (check the file exists, then hand it to the human reviewer below). + each cycle: plan, then act, then observe + + # ARTIFACT GATE: a non-empty design.md exists on disk. Necessary but not + # sufficient — the human review below is the real quality bar. + # TODO (optional): require named sections, e.g. + # done when "grep -q '## Data model' design.md && grep -q '## Decisions' design.md" passes + done when "test -s design.md" passes + + # If a draft is rejected or thin, turn the gap into the next plan rather than + # rewriting from scratch. + when it fails: reflect on the architectural gap or the reviewer's objection, then plan again + + # THE HUMAN GATE: a person reads design.md and approves before this loop stops. + # Architecture soundness isn't something a shell check can verify, and this + # blueprint constrains every downstream story — so a human signs off here. + a human reviews before stopping + + # Thrash guard so a contested design can't loop forever burning tokens. + # TODO: tune to taste. + after 5 tries: stop and warn "design isn't landing — pair with a human on design.md before building stories" diff --git a/templates/discover.loop b/templates/discover.loop new file mode 100644 index 0000000..c795110 --- /dev/null +++ b/templates/discover.loop @@ -0,0 +1,69 @@ +# ============================================================================ +# discover.loop — STEP 1 of the greenfield A-to-Z build (see greenfield-app.loop) +# ============================================================================ +# +# WHAT THIS IS +# The first loop in building a whole app from nothing. Its job is to turn a +# vague idea in someone's head into a concrete, machine-readable backlog: +# a `sprint.yaml` with a `stories:` list that the rest of the flow iterates. +# +# HOW "DISCOVERY" ACTUALLY HAPPENS (read this — it's the non-obvious part) +# Discovery is an IN-CHAT INTERVIEW. When you run this loop in conversation +# (/loopflow run), the agent asks you questions — what are we building, who +# is it for, what are the must-have stories, what's out of scope — and writes +# your answers into sprint.yaml. The conversation is the "act"; the file is +# the product. +# +# The loop is DONE when the ARTIFACT EXISTS, not when the chat goes quiet. +# `done when "test -s sprint.yaml" passes` checks that sprint.yaml is a +# non-empty file on disk. That artifact check — not the vibe of the +# conversation — is what flips this loop to "done". A long, friendly chat +# that never writes the file is NOT done; a terse one that produces a good +# sprint.yaml IS. Point the check at a real schema validator (see the TODO +# below) if you want stricter gating than "the file is non-empty". +# +# WHY A LOOP AND NOT A ONE-SHOT PROMPT +# Interviews don't converge on the first try. You'll add a story, cut one, +# re-scope an acceptance criterion. The loop lets the agent re-plan after each +# round (reflect-on-fail) and keeps asking until the backlog is real and the +# file is written — without spinning forever (the thrash guard stops it). + +loop "discover": + # The objective, in plain language. The agent reads this as its north star. + goal: interview the user about the app, then capture the result as a sprint.yaml with a stories list (each story tagged with its epic, a title, and an acceptance criterion) + + # Context the agent reads before planning each cycle. Start it from whatever + # already exists — a brief, notes, a README — so the interview builds on real + # input instead of starting cold. "the last failure" feeds the reflect step. + # TODO: point this at your real source material (docs/, a brief, a Notion export…). + look at: docs, any product brief or notes in the repo, and the last failure + + # Writing a YAML backlog is cheap and fully reversible (it's just a file in + # the repo, under version control), so let the agent edit it without gating. + allow edits automatically + + # The repeated movement: PLAN the next question / file edit, ACT (ask the + # user in chat, then write sprint.yaml), OBSERVE (run the done-when check). + each cycle: plan, then act, then observe + + # THE GATE THAT DEFINES DONE: a non-empty sprint.yaml exists on disk. + # This is an artifact check, deliberately decoupled from the conversation. + # TODO (optional, stricter): swap for a schema check so "done" means a VALID + # backlog, not just a present one, e.g.: + # done when "python -c \"import yaml,sys; d=yaml.safe_load(open('sprint.yaml')); sys.exit(0 if d.get('stories') else 1)\"" passes + done when "test -s sprint.yaml" passes + + # Self-correction: if the file is missing/empty/thin, turn that into the next + # plan instead of just retrying blind — e.g. ask the missing question. + when it fails: reflect on what the backlog is still missing, then plan again + + # A human (you, in the interview) signs off that the captured backlog truly + # reflects the intent before discovery hands off to design. Everything + # downstream — design.md and every story build — derives from this file, so + # it's worth one explicit confirmation here. + a human reviews before stopping + + # Thrash guard: never loop forever on an interview. If it can't land a usable + # sprint.yaml in a handful of rounds, stop and get a human to unstick it. + # TODO: tune the try count to how much back-and-forth your discovery needs. + after 5 tries: stop and warn "discovery isn't converging — capture the backlog by hand and re-run from design" diff --git a/templates/feature.loop b/templates/feature.loop new file mode 100644 index 0000000..fabce32 --- /dev/null +++ b/templates/feature.loop @@ -0,0 +1,68 @@ +# ───────────────────────────────────────────────────────────────────────────── +# TEMPLATE: ship a feature as a fail-fast `pipeline`. +# +# A pipeline runs its stages top-to-bottom and is FAIL-FAST: a stage must be +# satisfied (its `done when` gate passes) before the next one starts. Each stage +# body is a normal loop body — its own goal, its own gate, its own thrash guard. +# +# The shape of "shipping a feature" this template encodes: +# build → write the feature until its acceptance test goes green +# regression → prove the WHOLE suite still passes (nothing else broke) +# security → scan for high-severity findings before it ships +# review → a human eyeballs the result before we call it done +# +# HOW TO USE THIS FILE: +# 1. Copy it next to your project (or into your repo) and rename it. +# 2. Fix every line marked "# TODO:" — they are the bits unique to YOUR repo +# (the acceptance test, the full test command, the files to look at). +# 3. Validate: loop-run show feature.loop (sanity-check the shape) +# 4. Run it: loop-run run feature.loop (executes on Claude Code) +# +# NOTE on git: there is no `git:` block here, so the built-in safe default +# applies — work on a branch, commit when the goal is met, never push. If you +# want a push or a PR, add a file-level `git:` block above the pipeline (see +# AGENTS.md "Git strategy"). Pushing to main/master is blocked unconditionally. +# ───────────────────────────────────────────────────────────────────────────── + +pipeline "feature: <name>": # TODO: name the feature, e.g. "feature: bulk invite users" + + # 1. BUILD — the self-correcting loop that actually writes the feature. + # Edits run automatically so the loop can iterate without nagging you, but + # it MUST ask a human before anything risky — a schema migration or a push. + # The acceptance test is the objective gate: the loop cannot claim "done" + # until that exact test goes green, so it can't talk itself into success. + stage build: + goal: the feature works end to end # TODO: describe the user-visible behavior + look at: src/feature/, the relevant tests, and the last failure # TODO: point at the real files/dirs to keep edits small + allow edits automatically, but ask me before migrations or pushes # auto-edit, but human-gate the scary stuff + each cycle: plan, then act, then observe # the inner reasoning loop + done when the test "feature.spec::works end to end" passes # TODO: your ONE acceptance test (file::name) + when it fails: reflect on which layer broke, then plan again # turn each red run into the next plan + after 6 tries: stop and warn "feature build is thrashing" # thrash guard: give up before burning cycles + + # 2. REGRESSION — run the FULL suite, not just the new test, so a fix that + # quietly breaks a neighbor keeps the loop red until everything is green. + # "<cmd> passes" means the command exits 0 across every test. + stage regression: + goal: the entire test suite is green — the feature broke nothing else + each cycle: act, then observe # no planning needed; run the suite and read results + done when "npm test" passes # TODO: your full-suite command, e.g. "pnpm test" / "pytest" + when it fails: reflect on which tests broke and whether the feature caused it, then plan again + after 4 tries: stop and warn "regressions remain after 4 tries" # don't loop forever on a hidden break + + # 3. SECURITY — a real scanner is the bar. "finds nothing" requires the + # command to exit 0 AND print no output, so only a clean run counts. + stage security: + goal: no high-severity findings in the new code + each cycle: plan, then act, then observe + done when "semgrep --config auto --severity=high" finds nothing # clean = exit 0 + empty output + when it fails: reflect on the flagged lines, then plan again + after 4 tries: stop and warn "security findings need a human" # security never auto-passes on thrash + + # 4. REVIEW — the objective gates are all green, but a person makes the final + # call. This is a BLOCKING gate: the pipeline cannot stop until a human + # signs off on the finished feature. + stage review: + goal: a human is satisfied the feature is correct and ready to ship + each cycle: observe # nothing to change automatically — just present the result + a human reviews before stopping # hard gate: a person approves before the pipeline finishes diff --git a/templates/greenfield-app.loop b/templates/greenfield-app.loop new file mode 100644 index 0000000..90e2562 --- /dev/null +++ b/templates/greenfield-app.loop @@ -0,0 +1,67 @@ +# ============================================================================ +# greenfield-app.loop — build a whole app from NOTHING, A to Z, as one flow +# ============================================================================ +# +# THE BIG PICTURE +# A `flow` chains whole .loop files end to end, passing each step's result +# forward as context for the next. This flow is the complete greenfield +# lifecycle — idea → backlog → architecture → a working app: +# +# discover ──▶ design ──▶ for each story ──▶ story-template +# (interview) (blueprint) (build the backlog, one story at a time) +# +# 1. discover.loop — interviews you in chat and writes sprint.yaml (the backlog). +# 2. design.loop — turns sprint.yaml into design.md (the architecture note), +# and a human approves it before the build starts. +# 3. for each story — runs story-template.loop ONCE PER STORY in sprint.yaml. +# Each story's text (epic + title + acceptance) arrives as +# that run's context, so the same checklist builds every +# story without you naming them here. +# +# WHY A FLOW AND NOT ONE GIANT LOOP +# Each phase has a different shape of "done": discovery is done when the +# backlog file exists, design when a human approves the note, a story when its +# tests pass. A flow lets each phase use its own verification and its own human +# gates, while the chain wires them together. The chain is FAIL-FAST: if +# discover never produces a sprint.yaml, design never runs. +# +# THE BACKLOG IS THE HINGE +# discover WRITES sprint.yaml; `for each` READS it. So the number of stories +# built, and what each one is, is decided live during the interview — not +# hard-coded here. Re-run discovery, get a different app. This file never +# changes. +# +# DEPENDENCY (do not author here) +# story-template.loop is provided by a SEPARATE template in this same +# templates/ directory — the per-story checklist (implement → security → +# manual QA, with its own done-when, reflect, thrash guard, and gates). This +# flow only references it. +# +# RUN IT +# /loopflow run greenfield-app.loop # in the Claude Code conversation +# loop run greenfield-app.loop # headless via the CLI +# (Run `show` first to eyeball the shape before you run anything for real.) +# +# GIT SAFETY +# No git: block here, so the built-in safe default applies to the loops this +# flow runs: work on a branch, commit when the goal is met, never push to +# main/master. Add a file-level git: block (e.g. open a pull request) only +# once you trust the flow end to end. + +# TODO: rename the flow to your app (this label is just for humans + the viz). +flow "greenfield app: my new app": + + # STEP 1 — DISCOVER. Interview the user in chat; write sprint.yaml. + # Done when the backlog file exists (the artifact check, not the chat). + run "discover.loop" + + # STEP 2 — DESIGN. Receives discover's result as context. Turn sprint.yaml + # into design.md; a human approves the architecture before we build. + then run "design.loop" + + # STEP 3 — BUILD. Iterate the backlog: run the story checklist once per story + # in sprint.yaml. The current story's text becomes that run's context (what + # to build); the checks inside story-template.loop are the SAME every time. + # A failed story pauses the flow and asks whether to continue or stop. + then for each story in "sprint.yaml": + run "story-template.loop" diff --git a/templates/load-spec.loop b/templates/load-spec.loop new file mode 100644 index 0000000..40f6343 --- /dev/null +++ b/templates/load-spec.loop @@ -0,0 +1,41 @@ +# ============================================================================= +# load-spec.loop — deliver an EXISTING spec, story by story (brownfield) +# ============================================================================= +# +# WHEN TO USE THIS +# The plan already exists. You are not interviewing anyone or discovering scope +# from scratch — you have a written spec and a backlog, and you want the loop to +# grind through it with the same quality gates on every story. This is the +# spec-driven / "brownfield-with-a-plan" entry point. +# +# THE TWO INPUT FILES (data you edit, not loops) +# - plan.md the HUMAN-READABLE spec: the epic summary and the stories in +# prose. This is the source of truth a person reads and signs off +# on. The loop does not parse plan.md directly; it is the +# authoring artifact that keeps you and the machine agreed on scope. +# - sprint.yaml the MACHINE-READABLE backlog: a `stories:` list distilled from +# plan.md. `for each` iterates THIS file. Each story's text +# (epic + title + acceptance) becomes the context handed to the +# template — i.e. it tells story-template.loop exactly what to build. +# +# Keep them in sync: write/refine the spec in plan.md, then mirror each story +# into sprint.yaml as a structured entry the loop can iterate. +# +# THE FLOW +# `for each story in "sprint.yaml"` reads the backlog and runs the per-story +# checklist (story-template.loop: implement -> security -> manual) once per +# entry. If a story fails its checklist, the runtime PAUSES and asks whether to +# continue with the next story or stop — so one bad story doesn't silently +# wreck the rest of the sprint. +# +# Paths below are relative to THIS file, so keep load-spec.loop, sprint.yaml, +# plan.md, and story-template.loop together in one directory (or adjust paths). +# ============================================================================= + +flow "deliver spec": + + # One iteration per story in the backlog. The story's text rides into the + # template as context (what to build); the template supplies the SAME gates + # for every story. Edit sprint.yaml to change the work — this file stays put. + for each story in "sprint.yaml": + run "story-template.loop" diff --git a/templates/plan.md b/templates/plan.md new file mode 100644 index 0000000..781a43b --- /dev/null +++ b/templates/plan.md @@ -0,0 +1,52 @@ +<!-- + plan.md — the HUMAN-READABLE spec (a STARTER you replace). + + This is the document a person reads, reviews, and signs off on before the loop + runs. It is NOT parsed by loop-run; it is the source of truth that keeps you and + the machine agreed on scope. After you write/refine it here, mirror each story + into sprint.yaml as a structured `stories:` entry — that YAML is what + load-spec.loop actually iterates with `for each`. + + Replace everything below with your real epic and stories. +--> + +# Epic: Authentication + +## Summary + +Let users create an account, sign in, and recover access to it. Sessions are +issued on login and expire after inactivity. Email is the identifier; passwords +are hashed at rest. This epic delivers the minimum secure auth surface the rest +of the product builds on. + +<!-- TODO: replace the summary above with your epic's one-paragraph context: + what it is, why now, and the boundary of what's in vs out of scope. --> + +## Stories + +Each story below has a one-to-one counterpart in `sprint.yaml`. The **Acceptance** +line is the testable contract — phrase it so it maps to a real assertion, because +the `done when` test in `story-template.loop` is what proves the story is done. + +### 1. User can log in + +- **Epic:** Authentication +- **What:** A registered user signs in with email + password. +- **Acceptance:** valid credentials return a session; invalid credentials are + rejected with a generic error (no user-enumeration leak). + +### 2. User can sign up + +- **Epic:** Authentication +- **What:** A new visitor creates an account. +- **Acceptance:** a unique email + valid password creates the account and sends a + verification email; duplicate emails are rejected. + +### 3. User can reset their password + +- **Epic:** Authentication +- **What:** A user who forgot their password regains access. +- **Acceptance:** requesting a reset emails a single-use, expiring link; following + it lets the user set a new password and invalidates the old one. + +<!-- TODO: add/remove stories to match your sprint, then keep sprint.yaml in sync. --> diff --git a/templates/refactor.loop b/templates/refactor.loop new file mode 100644 index 0000000..f3b04f2 --- /dev/null +++ b/templates/refactor.loop @@ -0,0 +1,55 @@ +# refactor.loop +# TEACHES: a SAFE refactor — improve the structure of working code while its +# observable behavior stays exactly the same. +# +# The core idea: a refactor has no new feature and no bug fix, so there is +# nothing new to test. That means the EXISTING test suite is your safety net. +# If the whole suite was green before and is still green after, behavior is +# unchanged — that's the no-regression proof. So the finish line here is the +# FULL suite passing, not one narrow test. +# +# Ground rules baked into this loop: +# - No new dependencies. A refactor reshapes what's there; it doesn't add libs. +# - No behavior change. Same inputs -> same outputs, same side effects. +# - Tests are the contract. If a test has to change, the behavior changed — +# that's a red flag, so a human reviews before this loop is allowed to stop. + +loop "refactor for structure without changing behavior": + goal: the target code is cleaner and better-structured, with identical behavior and the full test suite still green + + # THE SAFETY NET: the entire suite must pass (exit 0). Running everything — + # not a single test — is what proves the refactor didn't regress a neighbor. + # TODO: replace with your project's full test command, e.g. + # "pnpm test" | "npm test" | "yarn test" | "pytest" | "go test ./..." | "cargo test" + done when "pnpm test" passes + + # Scope the work to the code being restructured plus its tests, and feed the + # last failure back in so the agent learns what its change broke. + # TODO: point this at the actual module(s) you're refactoring and their tests. + look at: the code being refactored, its tests, and the last failure + + # Let the agent restructure freely, but keep irreversible / scope-expanding + # actions human-gated. Adding a dependency is OUT OF BOUNDS for a refactor — + # gate it so the loop can't quietly pull in a new package to "simplify" things. + allow edits automatically, but ask me before adding dependencies, migrations, or pushes + + each cycle: plan, then act, then observe + + # When the suite goes red, the refactor changed behavior somewhere. Reflect on + # WHAT behavior changed (which test broke and what contract it encodes) before + # trying again — that's how a structural change gets walked back to green + # instead of the loop papering over it. + when it fails: reflect on what behavior changed and which test caught it, then plan again + + # Stop only when the goal is met and the whole suite is green. + when it passes and the goal is met: stop + + # HUMAN GATE: even with a green suite, a person eyes the diff before stopping. + # A refactor should be a pure restructuring — this is where someone confirms + # no behavior shifted and no test was loosened to force the green. + a human reviews before stopping + + # THRASH GUARD: if six attempts can't land a clean, behavior-preserving + # restructure, the refactor is bigger than a loop should swallow — stop and + # surface it to a human instead of spinning. + after 6 tries: stop and warn "refactor still failing after 6 tries — behavior may have drifted; needs a human" diff --git a/templates/review-diff.loop b/templates/review-diff.loop new file mode 100644 index 0000000..0373b6f --- /dev/null +++ b/templates/review-diff.loop @@ -0,0 +1,58 @@ +# review-diff.loop +# ───────────────────────────────────────────────────────────────────────────── +# A single self-correcting loop that REVIEWS the current branch's diff and keeps +# fixing the code until it is correct and clean — then lets a human sign off. +# +# Why a review-SKILL predicate instead of "it looks good"? +# "Looks good" is a vibe; it can't gate a loop. A review skill turns that vibe +# into a VERIFIABLE check: the skill reads the diff and returns either an +# approved/rejected verdict OR a numeric score. That verdict is what the loop +# tests every cycle, so the loop can self-correct against a real bar instead of +# stopping on a hunch. (Build & battle-test the "code-review" skill on its own +# first, confirm it judges well, THEN wire it in as the gate below.) +# +# The shape: +# plan → act → observe, with a ↺ reflect back-edge on failure, +# a ✓ done-when gate driven by the review skill, +# a 👤 human review before the loop is allowed to stop, +# and a ⛔ thrash guard so it can't spin forever. + +loop "review the current branch diff": + # The objective: not "pass a test" but "the change is correct and clean". + # The review skill is what makes that abstract goal checkable. + goal: the change on this branch is correct and clean — no bugs, no dead code, follows the existing style + + # The verifier. TWO equivalent forms — pick ONE: + # + # (a) approval gate — the skill returns approved / not approved: + done when the skill "code-review" approves + # + # (b) numeric threshold — the skill scores the diff 0–10 and 8 clears the bar. + # Swap the line above for this when you want a tunable quality bar: + # done when the skill "code-review" scores 8 or more # TODO: tune the threshold + # + # Either way the verdict comes from a real, runnable skill — that is what turns + # "looks good" into a gate the loop can actually test. + + # The skill the loop invokes while planning/acting to produce & judge the review. + use skills: code-review + + # Context the agent reads before each plan: the diff under review, plus the + # previous cycle's failure so it fixes what the review last flagged rather than + # re-reviewing from scratch. + look at: the current diff, and the last failure # TODO: e.g. `git diff main...HEAD` + + # The repeated movement of the loop. + each cycle: plan, then act, then observe + + # Self-correction: a rejection (or a sub-threshold score) feeds the next plan + # via reflect, so the loop attacks exactly what the review flagged. + when it fails: reflect on what the review flagged, then plan again + + # 👤 The human signs off LAST: even once the skill approves, a person reviews + # the result before the loop is allowed to stop. Machine gate first, human last. + a human reviews before stopping + + # ⛔ Thrash guard: if four cycles can't satisfy the review, stop and escalate + # instead of burning tokens forever. + after 4 tries: stop and warn "review still failing after 4 tries — needs a human" diff --git a/templates/security.loop b/templates/security.loop new file mode 100644 index 0000000..0496408 --- /dev/null +++ b/templates/security.loop @@ -0,0 +1,94 @@ +# ============================================================================= +# templates/security.loop — a SECURITY PASS as a fail-fast pipeline. +# ============================================================================= +# +# A `pipeline` runs its stages IN ORDER and is FAIL-FAST: a stage that can't +# reach its `done when` halts the whole pipeline — the next stage never starts. +# That's exactly what you want for security gates. There's no point auditing +# dependencies or hunting for secrets if static analysis already found a +# high-severity hole: fix that first, then move down the chain. +# +# The chain here is the classic three-pass scan: +# +# sast → deps → secrets +# │ │ │ +# │ │ └─ no committed credentials / tokens / keys +# │ └─ no known-vulnerable dependencies +# └─ no high-severity code-level findings (injection, XSS, …) +# +# Each stage's body is a self-correcting loop: it scans, and if the scanner +# reports anything it REFLECTS on the findings and plans a fix, then re-scans. +# A thrash guard (`after N tries: stop and warn`) keeps it from spinning +# forever on something only a human can resolve. +# +# ── On "finds nothing" ─────────────────────────────────────────────────────── +# `done when "<cmd>" finds nothing` means the command must BOTH exit 0 AND print +# NO output. That is the right shape for a scanner: a clean run is silent and +# successful; ANY finding (a non-zero exit OR a single printed line) keeps the +# stage looping. Make sure each TODO command below actually fails loudly — i.e. +# prints its findings to stdout/stderr and/or exits non-zero — or the gate is +# worthless. +# +# ── How to adapt ───────────────────────────────────────────────────────────── +# Every line marked "# TODO:" is the bit you must tailor to YOUR stack: the +# exact scanner commands and (optionally) the try-count thresholds. The SAST +# stage is wired for `semgrep`; the other two ship with sensible example +# commands you should swap for whatever your project already uses. +# +# Validate with: loop-run show templates/security.loop +# Run with: loop-run run templates/security.loop +# ============================================================================= + +pipeline "security pass": + + # ── 1. SAST — static application security testing ────────────────────────── + # Scan the SOURCE for code-level vulnerabilities (injection, XSS, unsafe + # deserialization, …). This runs first because a high-severity code flaw is + # the most dangerous class of finding — gate it before anything else. + # + # "finds nothing" == semgrep exits 0 AND prints no findings. `--severity=high` + # keeps the gate focused on what must block a release; tighten or loosen as + # your risk appetite requires. + stage sast: + goal: no high-severity static-analysis findings remain in the codebase + look at: the application source, the semgrep config, and the last failure + each cycle: plan, then act, then observe + done when "semgrep --config auto --severity=high" finds nothing # exit 0 + empty output == clean + when it fails: reflect on each flagged finding, then plan again # turn the report into the next fix + after 4 tries: stop and warn "security findings need a human" # thrash guard: a person decides + + # ── 2. DEPS — dependency / supply-chain audit ────────────────────────────── + # Only reached once SAST is clean. Scan your DEPENDENCIES for known CVEs. + # "finds nothing" == the audit command exits 0 with no reported advisories. + # Some fixes are a version bump; some need a human (no patch exists yet), and + # that's what the thrash guard is for. + stage deps: + goal: no known-vulnerable dependencies remain at the high/critical level + look at: the dependency manifest and lockfile, and the last failure + each cycle: plan, then act, then observe + # TODO: replace with your ecosystem's audit command, e.g. + # Node: npm audit --audit-level=high + # pnpm: pnpm audit --audit-level high + # Python: pip-audit + # Rust: cargo audit + done when "npm audit --audit-level=high" finds nothing # exit 0 + empty output == clean + when it fails: reflect on the flagged advisories, then plan again # bump / patch / replace the dep + after 4 tries: stop and warn "security findings need a human" # no clean upgrade path -> escalate + + # ── 3. SECRETS — committed-credential scan ───────────────────────────────── + # The last gate, and the one a human MUST eyeball before we call the pass + # done. Scan the repo (and ideally its history) for leaked keys, tokens, and + # passwords. A hit here usually also means rotating the exposed credential — + # not something to auto-resolve silently — so this stage ends with a human + # review before the pipeline is allowed to stop. + stage secrets: + goal: no committed secrets, API keys, or credentials anywhere in the repo + look at: the repository contents and history, and the last failure + each cycle: plan, then act, then observe + # TODO: replace with your secret scanner, e.g. + # gitleaks detect --no-banner + # trufflehog filesystem . + done when "gitleaks detect" finds nothing # exit 0 + empty output == clean + when it fails: reflect on each leaked secret, then plan again # remove from code AND rotate it + after 4 tries: stop and warn "security findings need a human" # thrash guard before escalation + a human reviews before stopping # final sign-off: a person confirms the repo is clean diff --git a/templates/sprint.yaml b/templates/sprint.yaml new file mode 100644 index 0000000..1d89d41 --- /dev/null +++ b/templates/sprint.yaml @@ -0,0 +1,33 @@ +# ============================================================================= +# sprint.yaml — the machine-readable backlog `for each` iterates over +# ============================================================================= +# +# This is DATA, not a .loop file — there is nothing to parse with loop-run. +# load-spec.loop reads the `stories:` list below and runs story-template.loop +# once per entry; each entry's text (epic + title + acceptance) becomes the +# context handed to that template — it tells the loop what to build. +# +# `for each` iterates ONE level, so there is no nested "epics" tree here: +# each story carries its own `epic:` field, and that epic name rides into the +# story's context alongside the title and acceptance criteria. +# +# The entries below are a STARTER (an auth-style example). Replace them with the +# stories from YOUR plan.md — keep the three keys on every story: +# epic: which epic / feature area this story belongs to +# title: the one-line "what" of the story +# acceptance: the testable "done" — phrase it so it maps to a real assertion, +# because story-template.loop's `done when` test is what proves it +# ============================================================================= + +stories: + - epic: Authentication + title: User can log in + acceptance: a registered user logs in with email + password and receives a session + + - epic: Authentication + title: User can sign up + acceptance: a new user creates an account and gets a verification email + + - epic: Authentication + title: User can reset their password + acceptance: user requests a reset, gets a one-time link, and sets a new password diff --git a/templates/story-template.loop b/templates/story-template.loop new file mode 100644 index 0000000..a6522f0 --- /dev/null +++ b/templates/story-template.loop @@ -0,0 +1,93 @@ +# ============================================================================= +# story-template.loop — the per-story checklist (brownfield / spec-driven) +# ============================================================================= +# +# WHAT THIS IS +# A `pipeline` is an epic; each `stage` is one story-sized step that runs as a +# self-correcting loop. This particular pipeline is a *template*: it does NOT +# hard-code which story to build. Instead it is run once per story by a `flow` +# (see load-spec.loop), and the current story's text — its epic, title, and +# acceptance criteria from sprint.yaml — arrives as the run's CONTEXT. The +# three stages below are the SAME quality gates for every story, so you author +# the checklist once and the spec drives the rest. +# +# WHO RUNS IT +# - load-spec.loop: for each story in "sprint.yaml": run "story-template.loop" +# (brownfield: a plan already exists; deliver it story by story). +# - greenfield-app.loop ALSO references this file as its per-story checklist, +# after its discover/design stages have written the sprint. Same template, +# two front-ends — that is the point of keeping the checklist standalone. +# +# THE SHAPE +# implement (write code, tests must pass) +# -> security (no high-severity findings) +# -> manual (a human confirms it actually works) +# A failing stage halts the rest of the pipeline for that story, so a broken +# story never sails past the gate into "security" or "manual". +# ============================================================================= + +pipeline "story": + + # --------------------------------------------------------------------------- + # STAGE 1 — implement: turn the story's acceptance criteria into working code. + # This is the heavy loop: plan -> act -> observe, re-planning on every failure + # until the test suite goes green (or the thrash guard trips). + # --------------------------------------------------------------------------- + stage implement: + goal: implement the story described in the provided context, matching its acceptance criteria + + # Scope the agent to the existing codebase so it makes the SMALLEST change + # that satisfies the story instead of writing greenfield code. "and the last + # failure" feeds the previous cycle's test output back into the next plan. + # TODO: replace `src` with the real directories this sprint touches + # (e.g. src/api, src/db, web/components). + look at: src, and the last failure + + # Action policy: let the agent edit freely, but STOP for anything + # irreversible. Migrations mutate the schema; pushes leave the machine. + # Both deserve a human glance before they happen. + allow edits automatically, but ask me before migrations or pushes + + each cycle: plan, then act, then observe + + # The real definition of done: a fast, deterministic test command, exit 0. + # TODO: swap "pnpm test" for THIS repo's command, and prefer to scope it to + # the story's area so the loop's check stays fast + # (e.g. "pnpm test auth", "pytest tests/auth", "go test ./auth/..."). + done when "pnpm test" passes + + # Self-correction: a green->red diff usually broke one layer (api / db / ui). + # Naming the layer makes the next plan a targeted fix, not a fresh guess. + when it fails: reflect on which layer broke, then plan again + + # Thrash guard: never spin forever. After 6 honest attempts, stop and warn a + # human so they can unstick it instead of burning tokens in a loop. + after 6 tries: stop and warn "thrashing on this story" + + # --------------------------------------------------------------------------- + # STAGE 2 — security: the code works, but did it open a hole? Static analysis + # gate. "finds nothing" means the command must produce EMPTY output to pass. + # --------------------------------------------------------------------------- + stage security: + goal: no high or critical vulnerabilities introduced by this story + each cycle: plan, then act, then observe + + # TODO: keep semgrep, or substitute your scanner (npm audit, bandit, gosec, + # trivy). The check passes only when it reports nothing at this severity. + done when "semgrep --config auto --severity=high" finds nothing + + # Security fixes can stall on genuinely hard calls — guard this loop too. + after 4 tries: stop and warn "security needs a human" + + # --------------------------------------------------------------------------- + # STAGE 3 — manual: tests and scanners are necessary, not sufficient. A human + # confirms the story actually behaves end-to-end before it is called done. + # No automated `done when` here on purpose — the gate IS the human. + # --------------------------------------------------------------------------- + stage manual: + goal: the story works end-to-end when a human tries it + each cycle: observe + + # Hard human gate: the pipeline will not finish this story until a person + # reviews the result and approves. + a human reviews before stopping diff --git a/templates/test-coverage.loop b/templates/test-coverage.loop new file mode 100644 index 0000000..f3131a1 --- /dev/null +++ b/templates/test-coverage.loop @@ -0,0 +1,66 @@ +# test-coverage.loop +# TEACHES: drive test coverage up to a hard threshold as a self-correcting loop. +# +# The trick that makes this a *real* loop (not vibes): the `done when` command must +# EXIT NON-ZERO when coverage is below the bar, and exit 0 only once you're at/above it. +# Most runners already do this — point them at a coverage threshold and they fail the +# process when you're under it. That failing exit code is the loop's finish line: it +# keeps planning, writing tests, and re-measuring until the gate goes green on its own. +# +# IMPORTANT PHILOSOPHY: the goal is *meaningful* tests, not a green number. A loop that +# only optimizes a percentage will happily write assertion-free tests that execute lines +# without checking behavior ("coverage padding"). The comments below keep it honest — +# cover real branches and edge cases, and let the reflect step name the risky uncovered +# paths so each cycle adds tests that would actually catch a bug. +# +# Copy this file, then edit every `# TODO:` to match your project. + +loop "raise test coverage to the threshold": + # The objective is the *bar*, stated in plain language. The number lives in the + # command below so there's exactly one source of truth for "the threshold". + goal: test coverage meets the agreed threshold, with meaningful tests that assert real behavior + + # THE FINISH LINE — a coverage command that FAILS (non-zero exit) when you're under + # the threshold and PASSES (exit 0) when you've hit it. The loop runs this every cycle + # and only stops when it exits clean. + # + # TODO: replace with YOUR coverage gate. Pick one and set the real threshold: + # Jest: "npx jest --coverage --coverageThreshold='{\"global\":{\"lines\":90}}'" + # Vitest: "npx vitest run --coverage --coverage.thresholds.lines=90" + # or a check script that exits non-zero below the bar, e.g. "npm run coverage:check" + # Keep it deterministic and as fast as you can (scope to the package you're covering) + # — this command runs on EVERY cycle, so a slow gate makes the whole loop slow. + done when "npm run coverage:check" passes + + # CONTEXT — point the agent at the modules that are dragging coverage down, plus the + # last failure (the coverage report) so it learns which files/lines are still uncovered + # instead of re-testing what's already green. + # TODO: list YOUR under-tested modules and their test folders. + look at: the under-tested modules src/, their existing tests in tests/, the coverage report, and the last failure + + # ACTION POLICY — let the agent add and edit tests freely (that's the whole job), but + # gate anything irreversible. Tests sometimes tempt a "fix" in source or a dependency + # bump; keep those human-reviewed so a coverage run never silently reshapes the app. + # TODO: tune the gated classes for your repo (migrations, deploys, schema, lockfile…). + allow edits automatically, but ask me before changing production source, migrations, or pushes + + # THE CYCLE — think about which paths are uncovered, write the tests, then re-measure. + each cycle: plan, then act, then observe + + # THRASH GUARD part 1 (reflect-on-fail) — when the gate is still red, don't just bolt + # on more shallow tests. Reflect on WHICH branches / lines / files are still uncovered + # (read the coverage report) so the next plan targets real, behavior-checking tests for + # the riskiest gaps — not coverage padding that executes code without asserting anything. + when it fails: reflect on which files and branches are still uncovered, then plan again + + # Stop the moment the threshold is met — don't keep padding tests past the goal. + when it passes and the goal is met: stop + + # If a path genuinely can't (or shouldn't) be tested — dead code, a hard-to-reach + # error branch, generated files — that's a human judgment call, not more looping. + when blocked: ask a human + + # THRASH GUARD part 2 (hard ceiling) — if 6 cycles don't reach the bar, the gap is + # likely structural (untestable code, an unrealistic threshold, or code that needs a + # refactor before it can be covered). Stop and surface it instead of spinning. + after 6 tries: stop and warn "coverage still below the threshold after 6 tries — likely needs a refactor or a threshold review rather than more tests" From 158b5e548648df3af0d86cdea0f88b05bdc0667e Mon Sep 17 00:00:00 2001 From: Idan Ayalon <ticketsforge@gmail.com> Date: Sun, 28 Jun 2026 19:59:51 -0400 Subject: [PATCH 21/22] docs(changelog): note the template library + Claude-first tutorial --- CHANGELOG.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 90c14c7..3e9ee3b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,12 @@ Versions track the `@loop-lang/loop` installer package. - `@loop-lang/viz` exports `renderLiveHtml`; `@loop-lang/runtime` adds `startLiveServer` and derives short item labels (`labelOf`) so headless sprints show real story titles. - `/loopflow` integrates `superpowers:brainstorming` for the no-plan (medium/large) path. +- **Template library** — `templates/` ships best-practice, copy-and-edit starter loops for + everyday work (bugfix, feature, brownfield-feature, refactor; cicd-check, security, + clean-architecture, test-coverage, review-diff; greenfield-app and load-spec with their + supporting discover/design/story-template + starter sprint.yaml/plan.md). `loop init` + installs them (`--no-templates` to skip), and AGENTS.md + the `/loopflow` skill point the + agent at a matching template before authoring from scratch. All validated to parse. ### Changed - The dashboard uses a flat, Linear-style dark theme; self-contained (no external assets) @@ -51,6 +57,9 @@ Versions track the `@loop-lang/loop` installer package. - Tutorial (`docs/index.html`), `README.md`, `docs/MANUAL.md`, `AGENTS.md`, and the `/loopflow` skill document the live dashboard, the `loop.config` gate, and the `live`/`emit`/`--live` commands. +- The tutorial now leads with the **Claude Code chat (`/loopflow`)** as the first-class + usage path and demotes the VS Code extension to optional hand-authoring; added a + "Starter templates" section. ## [0.4.0] — 2026-06-27 From 1b35f698879b246c9679908260aef4cd638f2696 Mon Sep 17 00:00:00 2001 From: Idan Ayalon <ticketsforge@gmail.com> Date: Sun, 28 Jun 2026 20:19:06 -0400 Subject: [PATCH 22/22] =?UTF-8?q?release(@loop-lang/loop):=20v0.5.0=20?= =?UTF-8?q?=E2=80=94=20live=20dashboard=20+=20template=20library?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps @loop-lang/loop to 0.5.0 and @loop-lang/{parser,runtime,stdlib,viz} to 0.2.0 (cross-pins updated to match). Headline: the live browser dashboard (--live + in-session /loopflow via loop.config), the templates/ starter-loop library, SSE id/replay/heartbeat, for-each item labels, loop version, and the code-review/cross-platform fixes. See CHANGELOG [0.5.0]. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- CHANGELOG.md | 4 +++ package-lock.json | 22 ++++++------- packages/cli/package.json | 36 +++++++++++++++++----- packages/parser/package.json | 32 +++++++++++++++---- packages/runtime/package.json | 42 +++++++++++++++++++------ packages/stdlib/package.json | 33 ++++++++++++++++---- packages/viz/package.json | 38 ++++++++++++++++++----- packages/vscode/package.json | 58 +++++++++++++++++++++++++++-------- 8 files changed, 205 insertions(+), 60 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e9ee3b..eb237d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ Versions track the `@loop-lang/loop` installer package. ## [Unreleased] +## [0.5.0] — 2026-06-28 + +> `@loop-lang/loop` 0.5.0 · `@loop-lang/{parser,runtime,stdlib,viz}` 0.2.0 + ### Added - **Live dashboard** — a real-time browser view of a run that renders the loop's *actual* structure (pipeline stages / flow steps / for-each items) as a turn-by-turn diff --git a/package-lock.json b/package-lock.json index 79b0a54..159402f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -507,7 +507,7 @@ }, "packages/cli": { "name": "@loop-lang/loop", - "version": "0.4.0", + "version": "0.5.0", "hasInstallScript": true, "license": "Apache-2.0", "bin": { @@ -519,17 +519,17 @@ }, "packages/parser": { "name": "@loop-lang/parser", - "version": "0.1.0", + "version": "0.2.0", "license": "Apache-2.0" }, "packages/runtime": { "name": "@loop-lang/runtime", - "version": "0.1.0", + "version": "0.2.0", "license": "Apache-2.0", "dependencies": { - "@loop-lang/parser": "0.1.0", - "@loop-lang/stdlib": "0.1.0", - "@loop-lang/viz": "0.1.0" + "@loop-lang/parser": "0.2.0", + "@loop-lang/stdlib": "0.2.0", + "@loop-lang/viz": "0.2.0" }, "bin": { "loop-run": "dist/cli.js" @@ -537,23 +537,23 @@ }, "packages/stdlib": { "name": "@loop-lang/stdlib", - "version": "0.1.0", + "version": "0.2.0", "license": "Apache-2.0" }, "packages/viz": { "name": "@loop-lang/viz", - "version": "0.1.0", + "version": "0.2.0", "license": "Apache-2.0", "dependencies": { - "@loop-lang/parser": "0.1.0" + "@loop-lang/parser": "0.2.0" } }, "packages/vscode": { "name": "loop-vscode", - "version": "0.2.0", + "version": "0.3.0", "license": "Apache-2.0", "devDependencies": { - "@loop-lang/parser": "0.1.0", + "@loop-lang/parser": "0.2.0", "@types/vscode": "1.85.0" }, "engines": { diff --git a/packages/cli/package.json b/packages/cli/package.json index 6f5800a..166d9a9 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,15 +1,37 @@ { "name": "@loop-lang/loop", - "version": "0.4.0", + "version": "0.5.0", "description": "Install LoopFlow (.loop) into any repo so Claude Code — or any agent — can author and run self-correcting, human-gated coding loops.", "license": "Apache-2.0", - "publishConfig": { "access": "public" }, + "publishConfig": { + "access": "public" + }, "type": "module", - "bin": { "loop": "src/cli.mjs" }, - "files": ["src", "assets", "README.md"], - "engines": { "node": ">=18" }, - "keywords": ["loop", "agent", "claude", "ai", "workflow", "dsl", "loop-engineering"], - "repository": { "type": "git", "url": "https://github.com/tickets-forge-dev/loop-lang.git", "directory": "packages/cli" }, + "bin": { + "loop": "src/cli.mjs" + }, + "files": [ + "src", + "assets", + "README.md" + ], + "engines": { + "node": ">=18" + }, + "keywords": [ + "loop", + "agent", + "claude", + "ai", + "workflow", + "dsl", + "loop-engineering" + ], + "repository": { + "type": "git", + "url": "https://github.com/tickets-forge-dev/loop-lang.git", + "directory": "packages/cli" + }, "scripts": { "sync-assets": "node scripts/sync-assets.mjs", "prepack": "node scripts/sync-assets.mjs", diff --git a/packages/parser/package.json b/packages/parser/package.json index bc70348..480c36b 100644 --- a/packages/parser/package.json +++ b/packages/parser/package.json @@ -1,20 +1,40 @@ { "name": "@loop-lang/parser", - "version": "0.1.0", + "version": "0.2.0", "description": "Parses LoopFlow (.loop) natural-language source into the open loop-spec IR.", "license": "Apache-2.0", "type": "module", - "publishConfig": { "access": "public" }, - "keywords": ["loop", "agent", "claude", "ai", "workflow", "dsl", "loop-engineering", "parser"], - "repository": { "type": "git", "url": "https://github.com/tickets-forge-dev/loop-lang.git", "directory": "packages/parser" }, + "publishConfig": { + "access": "public" + }, + "keywords": [ + "loop", + "agent", + "claude", + "ai", + "workflow", + "dsl", + "loop-engineering", + "parser" + ], + "repository": { + "type": "git", + "url": "https://github.com/tickets-forge-dev/loop-lang.git", + "directory": "packages/parser" + }, "homepage": "https://github.com/tickets-forge-dev/loop-lang/tree/master/packages/parser#readme", - "bugs": { "url": "https://github.com/tickets-forge-dev/loop-lang/issues" }, + "bugs": { + "url": "https://github.com/tickets-forge-dev/loop-lang/issues" + }, "main": "dist/index.js", "types": "dist/index.d.ts", "exports": { ".": "./dist/index.js" }, - "files": ["dist", "src"], + "files": [ + "dist", + "src" + ], "scripts": { "build": "tsc -p tsconfig.json", "test": "node --test test/*.test.js", diff --git a/packages/runtime/package.json b/packages/runtime/package.json index a175ae6..2ee1d8f 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -1,25 +1,47 @@ { "name": "@loop-lang/runtime", - "version": "0.1.0", + "version": "0.2.0", "description": "Executes a loop-spec by driving Claude Code: cycles, the reflect back-edge, policy gates, verification, and human-in-the-loop nodes.", "license": "Apache-2.0", "type": "module", - "publishConfig": { "access": "public" }, - "keywords": ["loop", "agent", "claude", "ai", "workflow", "dsl", "loop-engineering", "runtime"], - "repository": { "type": "git", "url": "https://github.com/tickets-forge-dev/loop-lang.git", "directory": "packages/runtime" }, + "publishConfig": { + "access": "public" + }, + "keywords": [ + "loop", + "agent", + "claude", + "ai", + "workflow", + "dsl", + "loop-engineering", + "runtime" + ], + "repository": { + "type": "git", + "url": "https://github.com/tickets-forge-dev/loop-lang.git", + "directory": "packages/runtime" + }, "homepage": "https://github.com/tickets-forge-dev/loop-lang/tree/master/packages/runtime#readme", - "bugs": { "url": "https://github.com/tickets-forge-dev/loop-lang/issues" }, + "bugs": { + "url": "https://github.com/tickets-forge-dev/loop-lang/issues" + }, "main": "dist/index.js", "types": "dist/index.d.ts", - "exports": { ".": "./dist/index.js" }, + "exports": { + ".": "./dist/index.js" + }, "bin": { "loop-run": "dist/cli.js" }, - "files": ["dist", "src"], + "files": [ + "dist", + "src" + ], "dependencies": { - "@loop-lang/parser": "0.1.0", - "@loop-lang/stdlib": "0.1.0", - "@loop-lang/viz": "0.1.0" + "@loop-lang/parser": "0.2.0", + "@loop-lang/stdlib": "0.2.0", + "@loop-lang/viz": "0.2.0" }, "scripts": { "build": "tsc -p tsconfig.json", diff --git a/packages/stdlib/package.json b/packages/stdlib/package.json index df9d853..94d2df1 100644 --- a/packages/stdlib/package.json +++ b/packages/stdlib/package.json @@ -1,18 +1,39 @@ { "name": "@loop-lang/stdlib", - "version": "0.1.0", + "version": "0.2.0", "description": "Bundled LoopFlow presets (methods as .loop files) and a resolver for `use`.", "license": "Apache-2.0", "type": "module", - "publishConfig": { "access": "public" }, - "keywords": ["loop", "agent", "claude", "ai", "workflow", "dsl", "loop-engineering", "presets"], - "repository": { "type": "git", "url": "https://github.com/tickets-forge-dev/loop-lang.git", "directory": "packages/stdlib" }, + "publishConfig": { + "access": "public" + }, + "keywords": [ + "loop", + "agent", + "claude", + "ai", + "workflow", + "dsl", + "loop-engineering", + "presets" + ], + "repository": { + "type": "git", + "url": "https://github.com/tickets-forge-dev/loop-lang.git", + "directory": "packages/stdlib" + }, "homepage": "https://github.com/tickets-forge-dev/loop-lang/tree/master/packages/stdlib#readme", - "bugs": { "url": "https://github.com/tickets-forge-dev/loop-lang/issues" }, + "bugs": { + "url": "https://github.com/tickets-forge-dev/loop-lang/issues" + }, "main": "index.js", "types": "index.d.ts", "exports": { ".": "./index.js" }, - "files": ["index.js", "index.d.ts", "*.loop"] + "files": [ + "index.js", + "index.d.ts", + "*.loop" + ] } diff --git a/packages/viz/package.json b/packages/viz/package.json index 0a518a1..ac53846 100644 --- a/packages/viz/package.json +++ b/packages/viz/package.json @@ -1,20 +1,42 @@ { "name": "@loop-lang/viz", - "version": "0.1.0", + "version": "0.2.0", "description": "Render a LoopFlow loop-spec as a self-contained HTML control-loop schematic — the plan/act/observe cycle and the reflect back-edge, made visible.", "license": "Apache-2.0", "type": "module", - "publishConfig": { "access": "public" }, - "keywords": ["loop", "agent", "claude", "ai", "workflow", "dsl", "loop-engineering", "visualization"], - "repository": { "type": "git", "url": "https://github.com/tickets-forge-dev/loop-lang.git", "directory": "packages/viz" }, + "publishConfig": { + "access": "public" + }, + "keywords": [ + "loop", + "agent", + "claude", + "ai", + "workflow", + "dsl", + "loop-engineering", + "visualization" + ], + "repository": { + "type": "git", + "url": "https://github.com/tickets-forge-dev/loop-lang.git", + "directory": "packages/viz" + }, "homepage": "https://github.com/tickets-forge-dev/loop-lang/tree/master/packages/viz#readme", - "bugs": { "url": "https://github.com/tickets-forge-dev/loop-lang/issues" }, + "bugs": { + "url": "https://github.com/tickets-forge-dev/loop-lang/issues" + }, "main": "dist/index.js", "types": "dist/index.d.ts", - "exports": { ".": "./dist/index.js" }, - "files": ["dist", "src"], + "exports": { + ".": "./dist/index.js" + }, + "files": [ + "dist", + "src" + ], "dependencies": { - "@loop-lang/parser": "0.1.0" + "@loop-lang/parser": "0.2.0" }, "scripts": { "build": "tsc -p tsconfig.json", diff --git a/packages/vscode/package.json b/packages/vscode/package.json index 64d6543..be75f08 100644 --- a/packages/vscode/package.json +++ b/packages/vscode/package.json @@ -1,7 +1,7 @@ { "name": "loop-vscode", "displayName": "LoopFlow", - "version": "0.2.0", + "version": "0.3.0", "description": "Syntax, formatting, best-practice templates, completion, and ▶ Run / Claude-session buttons for the LoopFlow (.loop) DSL.", "license": "Apache-2.0", "publisher": "loop-lang", @@ -9,20 +9,47 @@ "engines": { "vscode": "^1.85.0" }, - "categories": ["Programming Languages"], - "keywords": ["loop", "agent", "claude", "ai", "workflow", "dsl", "loop-engineering"], - "galleryBanner": { "color": "#0d9488", "theme": "dark" }, - "repository": { "type": "git", "url": "https://github.com/tickets-forge-dev/loop-lang.git", "directory": "packages/vscode" }, + "categories": [ + "Programming Languages" + ], + "keywords": [ + "loop", + "agent", + "claude", + "ai", + "workflow", + "dsl", + "loop-engineering" + ], + "galleryBanner": { + "color": "#0d9488", + "theme": "dark" + }, + "repository": { + "type": "git", + "url": "https://github.com/tickets-forge-dev/loop-lang.git", + "directory": "packages/vscode" + }, "homepage": "https://github.com/tickets-forge-dev/loop-lang/tree/master/packages/vscode#readme", - "bugs": { "url": "https://github.com/tickets-forge-dev/loop-lang/issues" }, + "bugs": { + "url": "https://github.com/tickets-forge-dev/loop-lang/issues" + }, "main": "./dist/extension.js", - "activationEvents": ["onLanguage:loop", "onCommand:loop.newFromTemplate"], + "activationEvents": [ + "onLanguage:loop", + "onCommand:loop.newFromTemplate" + ], "contributes": { "languages": [ { "id": "loop", - "aliases": ["LoopFlow", "loop"], - "extensions": [".loop"], + "aliases": [ + "LoopFlow", + "loop" + ], + "extensions": [ + ".loop" + ], "configuration": "./language-configuration.json" } ], @@ -44,14 +71,21 @@ "title": "LoopFlow: Run in a Claude Code session", "category": "LoopFlow" }, - { "command": "loop.newFromTemplate", "title": "LoopFlow: New from template" } + { + "command": "loop.newFromTemplate", + "title": "LoopFlow: New from template" + } ], "configuration": { "title": "LoopFlow", "properties": { "loop.runMode": { "type": "string", - "enum": ["ask", "session", "output"], + "enum": [ + "ask", + "session", + "output" + ], "enumDescriptions": [ "Ask each time which way to run.", "Open a Claude Code session in the integrated terminal and run the loop there (interactive — you watch every step and answer gates in chat).", @@ -86,7 +120,7 @@ "test": "node --test test/*.test.mjs" }, "devDependencies": { - "@loop-lang/parser": "0.1.0", + "@loop-lang/parser": "0.2.0", "@types/vscode": "1.85.0" } }