Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ Thumbs.db
*.tmp
*.log

# Virtualenv
.venv/

# Agent workspace (contains runtime data)
workspace/

Expand Down
8 changes: 8 additions & 0 deletions QUICK_REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,14 @@ cd google-search-agent && python3 create_test_task.py
cd terminal-agent && python3 create_test_task.py
```

### 4. Gauntlet loop TUI
```bash
./scripts/cloud-agent-install.sh
.venv/bin/python -m gauntlet demo
# Cloud Agents: ~/.orc-venv/bin/python -m gauntlet demo
```
Keys: space pause, n step, s stop, q quit. See `gauntlet/README.md`.

## 📋 Task Creation

### Basic Task Format
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,7 @@ The system is designed for easy extension:
- EmailAgent, SlackAgent, DataAnalysisAgent, DocumentAgent

**Advanced Features**:
- Gauntlet loop TUI (`python -m gauntlet demo`) — live candidate vs named bar, blind critic picks
- Web UI for monitoring and control
- Agent performance analytics
- Dynamic agent spawning
Expand Down
33 changes: 33 additions & 0 deletions gauntlet/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# Gauntlet Loop TUI (ORC)

Operator board for a [Gauntlet Loop](https://github.com/robonuggets/gauntlet-loop): named quality bar, independently judged pieces, builder vs **blind** critic, picks not scores. The TUI is the live status page. You are the brake.

## Run the demo

```bash
./scripts/cloud-agent-install.sh
.venv/bin/python -m gauntlet demo
# Cloud Agents keep a durable venv at ~/.orc-venv
```

Keys: `space` pause · `n` step while paused · `s` stop · `q` quit.

Headless grind (no TUI):

```bash
.venv/bin/python -m gauntlet demo --headless-steps 40
```

Paste-ready prompt for another harness:

```bash
.venv/bin/python -m gauntlet prompt --goal "…" --bar "Named fetchable reference"
```

## What the board shows

- **Pieces** with builder/critic/win state
- **Candidate vs BAR** first-viewport sketches from inspectable HTML, not from builder reasoning
- **Verdicts** as `PICK CANDIDATE` / `PICK REFERENCE` / `PICK INVALID`

State is written to `workspace/gauntlet/demo/status.json` (builder notes are not).
13 changes: 13 additions & 0 deletions gauntlet/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
"""ORC Gauntlet loop: builder/critic pairs judged against a real quality bar."""

from .models import Piece, QualityBar, RunState, Verdict
from .loop import GauntletLoop, validate_bar

__all__ = [
"GauntletLoop",
"Piece",
"QualityBar",
"RunState",
"Verdict",
"validate_bar",
]
67 changes: 67 additions & 0 deletions gauntlet/__main__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
from __future__ import annotations

import argparse
import sys

from .demo import BAR, GOAL, demo_state, demo_store, staged_builder
from .loop import GauntletLoop, validate_bar
from .models import QualityBar
from .tui import GauntletTUI


PROMPT_TEMPLATE = """Build {goal}

The bar is {bar}. Get the real thing first and compare against it directly, not against a description of it.

Break this into the smallest pieces that can be improved and judged on their own. For each piece, fan out a builder and a separate critic with fresh context. The critic inspects the actual output, puts it next to the bar blind with the labels stripped, says which one is better, and names the single biggest remaining gap. Then it goes back to the builder.

The critic should be a harsh critic. Praise is not useful. If ours does not win, it keeps going.

Keep looping until the critic picks ours. Run the builders and critics as parallel subagents. Do not stop before that.

Keep a live progress page updating as the work evolves so I can watch it.
"""


def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(prog="gauntlet", description="ORC Gauntlet loop TUI")
sub = parser.add_subparsers(dest="cmd", required=True)

demo = sub.add_parser("demo", help="Run the Northstar fixture against a named on-disk bar")
demo.add_argument("--interval", type=float, default=0.55)
demo.add_argument("--headless-steps", type=int, default=0, help="Run N loop steps without the TUI")

prompt = sub.add_parser("prompt", help="Print a paste-ready gauntlet prompt")
prompt.add_argument("--goal", default=GOAL)
prompt.add_argument("--bar", default=BAR.name)

args = parser.parse_args(argv)

if args.cmd == "prompt":
bar = QualityBar(name=args.bar, source=BAR.source, medium="html")
error = validate_bar(bar)
if error:
print(error, file=sys.stderr)
return 2
sys.stdout.write(PROMPT_TEMPLATE.format(goal=args.goal, bar=args.bar).strip() + "\n")
return 0

store = demo_store()
loop = GauntletLoop(demo_state(), store=store, builder=staged_builder)
if args.headless_steps:
loop.start()
for _ in range(args.headless_steps):
if loop.state.status.value in {"won", "stopped"}:
break
loop.step()
print(store.status_path)
print(loop.state.status.value, "rounds", loop.state.round_no)
return 0

app = GauntletTUI(loop, interval=args.interval)
app.run()
return 0


if __name__ == "__main__":
raise SystemExit(main())
112 changes: 112 additions & 0 deletions gauntlet/bar.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
from __future__ import annotations

import os
import re
from pathlib import Path
from typing import Optional

from .models import QualityBar


VAGUE_BAR_MARKERS = (
"award-winning",
"best in class",
"world-class",
"premium",
"modern",
"beautiful",
"high quality",
"industry standard",
"good design",
)


def validate_bar(bar: QualityBar) -> Optional[str]:
"""Return an error string if the bar is not named, fetchable, and comparable."""
name = (bar.name or "").strip()
source = (bar.source or "").strip()
medium = (bar.medium or "").strip()

if len(name) < 8:
return "Bar must be named: a specific artifact, not a category."
lowered = name.lower()
if any(marker in lowered for marker in VAGUE_BAR_MARKERS) and "http" not in source:
if not Path(source).exists() and not source.startswith(("http://", "https://")):
return "Bar looks vague. Name a real page, post, repo, or file the critic can open."
if not source:
return "Bar must be fetchable: a path or URL the critic can actually open."
if source.startswith(("http://", "https://")):
pass
elif not Path(source).exists():
return f"Bar is not fetchable: {source} does not exist on disk."
if medium not in {"html", "text", "screenshot", "binary"}:
return "Bar must be comparable in a concrete medium (html, text, screenshot, binary)."
return None


def load_text(source: str) -> str:
path = Path(source)
if path.exists():
return path.read_text(encoding="utf-8")
if source.startswith(("http://", "https://")):
raise FileNotFoundError("Network bars must be fetched by the caller and stored on disk.")
raise FileNotFoundError(source)


def evidence_ok(artifact: str, medium: str) -> bool:
if not artifact or not artifact.strip():
return False
if medium == "html":
return "<html" in artifact.lower() and bool(re.search(r"<body[\s>]", artifact, re.I))
return len(artifact.strip()) >= 40


def extract_preview(html: str, width: int = 36) -> str:
"""Turn inspectable HTML into a compact first-viewport sketch for the TUI."""
if not html.strip():
return "(empty)"

def inner(pattern: str, blob: str = html) -> str:
match = re.search(pattern, blob, re.I | re.S)
return match.group(1) if match else ""

def textify(blob: str) -> str:
return re.sub(r"\s+", " ", re.sub(r"<[^>]+>", " ", blob)).strip()

header_html = inner(r"<header[^>]*>(.*?)</header>")
scope = header_html or html
title = textify(inner(r"<h1[^>]*>(.*?)</h1>", scope) or inner(r"<h1[^>]*>(.*?)</h1>"))
lede = textify(inner(r"<p[^>]*class=['\"]lede['\"][^>]*>(.*?)</p>", scope))
if not lede:
for candidate in re.findall(r"<p[^>]*>(.*?)</p>", scope, re.I | re.S):
text = textify(candidate)
if text and text.lower() != "placeholder page":
lede = text
break
cta = textify(
inner(r"<a[^>]*data-cta=['\"]primary['\"][^>]*>(.*?)</a>")
or inner(r"<button[^>]*>(.*?)</button>")
)
inner_width = width - 2
lines = ["┌" + "─" * inner_width + "┐"]

def row(text: str, emphasize: bool = False) -> None:
clipped = text[: inner_width - 2]
pad = max(0, inner_width - 2 - len(clipped))
mark = "▸ " if emphasize else " "
lines.append("│" + mark + clipped + (" " * pad) + "│")

row(title or "(no hero)")
if lede:
row(lede)
if cta:
row("[" + cta + "]", emphasize=True)
lines.append("└" + "─" * inner_width + "┘")
return "\n".join(lines)


def workspace_root() -> Path:
env = os.getenv("WORKSPACE_PATH")
if env:
return Path(env)
return Path(__file__).resolve().parent.parent / "workspace"
90 changes: 90 additions & 0 deletions gauntlet/demo.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
from __future__ import annotations

from pathlib import Path

from .bar import workspace_root
from .models import Piece, QualityBar, RunState
from .store import RunStore

FIXTURE_DIR = Path(__file__).resolve().parent / "fixtures"

GOAL = "Landing page for a running brand: athletic, dark, green, first viewport unmistakable."

BAR = QualityBar(
name="Northstar Run campaign — desktop capture",
source=str(FIXTURE_DIR / "northstar-run-campaign.html"),
medium="html",
notes="Named fixture. Critic opens this file, not a description of it.",
)

_PIECE_SPECS = (
("hero", "Hero", ["data-piece=\"hero\"", "<h1", "full-bleed"]),
("type", "Type", ["Newsreader", "letter-spacing"]),
("colour", "Colour", ["--volt", "--ink", "background:#0b1210"]),
("cta", "CTA", ["data-cta=\"primary\"", "Shop the kit"]),
("motion", "Motion", ["@keyframes stride", "animation:"]),
)

# Staged candidate HTML so the TUI preview actually fills in, one gap at a time.
_STAGE = {
"hero": [
'<header data-piece="hero"><p>Draft header</p></header>',
'<header data-piece="hero"><h1>Go farther.</h1></header>',
'<header data-piece="hero" class="full-bleed"><h1>Go farther.</h1><p class="lede">A course, not a crowd.</p></header>',
],
"type": [
"<style>body{font-family: Newsreader, serif}</style>",
"<style>body{font-family: Newsreader, serif; letter-spacing: 0.04em}</style>",
],
"colour": [
"<style>:root{--ink:#0b1210}</style>",
"<style>:root{--ink:#0b1210;--volt:#b6ff3b}</style>",
"<style>:root{--ink:#0b1210;--volt:#b6ff3b}body{background:#0b1210}</style>",
],
"cta": [
"<nav><a href='#kit'>See kit</a></nav>",
'<nav><a data-cta="primary" href="#kit">Shop the kit</a></nav>',
],
"motion": [
"<style>@keyframes stride { from { opacity: .7 } to { opacity: 1 } }</style>",
"<style>@keyframes stride { from { opacity: .7 } to { opacity: 1 } } header { animation: stride 1.2s ease-out; }</style>",
],
}


def new_pieces() -> list[Piece]:
return [
Piece(piece_id, title, list(signals))
for piece_id, title, signals in _PIECE_SPECS
]


def demo_state() -> RunState:
return RunState(goal=GOAL, bar=BAR, pieces=new_pieces())


def demo_store() -> RunStore:
root = workspace_root() / "gauntlet" / "demo"
return RunStore(root)


def staged_builder(piece: Piece, current_best: str, last_gap: str) -> tuple[str, str]:
"""Private builder. Notes must never be handed to the critic."""
stages = _STAGE[piece.id]
body = current_best if current_best.strip() else _empty_page()
applied = None
for fragment in stages:
if fragment not in body:
body = body.replace("</body>", fragment + "\n</body>")
applied = fragment[:48]
break
note = f"builder closed {piece.id} via {applied or 'noop'} given gap {last_gap!r}"
return body, note


def _empty_page() -> str:
return (
"<!doctype html><html><body>\n"
"<main><p>Placeholder page</p></main>\n"
"</body></html>\n"
)
22 changes: 22 additions & 0 deletions gauntlet/fixtures/northstar-run-campaign.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Northstar Run</title>
<style>
:root { --ink: #0b1210; --volt: #b6ff3b; }
body { background:#0b1210; color: #f4efe4; font-family: Newsreader, serif; letter-spacing: 0.02em; }
.full-bleed { min-height: 100vh; }
@keyframes stride { from { transform: translateY(8px); } to { transform: translateY(0); } }
header { animation: stride 1.2s ease-out; }
</style>
</head>
<body>
<header data-piece="hero" class="full-bleed">
<p>Northstar Run campaign</p>
<h1>Run until the road ends.</h1>
<p class="lede">Night miles. Volt green. No crowd in the first viewport but the course.</p>
<a data-cta="primary" href="#kit">Shop the kit</a>
</header>
</body>
</html>
Loading