diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 30a0689..573ff29 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,6 +12,29 @@ env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" jobs: + lint: + name: Lint (ruff) + types (mypy) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e ".[api,telegram]" + pip install ruff mypy + + - name: Ruff (lint + import order) + run: ruff check clk_harness tests + + - name: Mypy (lenient type check) + run: mypy clk_harness/ + test: runs-on: ubuntu-latest strategy: diff --git a/README.md b/README.md index b48410a..9221977 100644 --- a/README.md +++ b/README.md @@ -16,2360 +16,33 @@ committed automatically. > Contributions, bug reports, and ideas are very welcome — feel free to > open an issue or pull request! -## What's new - -If you've used CLK before, the highlights of this release: - -- **Autonomous missions — one prompt to done.** `clk run` (and the TUI's first - message) now drive the whole lifecycle autonomously: the chief writes a - **charter**, authors a **living plan**, and walks discovery → … → - deployment through chief-evaluated phase gates to a **code-gated** done. - Reliability is enforced, not hoped for: a machine-checkable **done-gate** - (tests/qa/ralph/deliverables, adaptive for test-less projects) makes - `ACTION:done` a *request*; a **no-op guard** re-dispatches stages that changed - no files; evaluation **auto-derives** a real command instead of vacuously - passing; refinement is on for every producing stage by default; agents - **deliberate** (blocking Q&A + self-reflection); and every boundary leaves a - structured **git commit trace** with a per-cycle telemetry line. Use - `clk mission ""` for the explicit form or `clk run --once` for a single - cycle. See [Autonomous missions](#autonomous-missions). -- **Web dashboard (`clk web`).** A beautiful browser UI that mirrors the - TUI: configure every feature and `.env` setting, kick off workflows, - and watch the agents work in real time with live cards, a colour-coded - activity timeline, and animated token/cost meters. See - [Web dashboard](#web-dashboard). -- **Guided mode.** A beginner-friendly step-by-step wizard in the web - console: scan for available LLM providers, pick a model, describe your - idea in plain language, watch a friendly progress view, browse and - download the files, then loop with follow-up requests. First-time - visitors land here automatically; the full console is one click away. -- **Files tab with git history.** Browse the live workspace, toggle to a - commit **History** view (agent badge, relative time, +/− stats, colored - diff per commit), time-travel any single file to a past version, and - see **uncommitted changes** as a pseudo-entry with new/modified/deleted - badges — files changed since the last commit carry an amber dot. -- **Work is never silently lost.** Failed stage validations no longer - hard-reset the workspace by default (`validation.rollback_on_failure: - careful` — only `careful: true` stages roll back, and even then the - discarded work is preserved behind a `refs/clk/rollbacks/` snapshot - ref). Agent `PATH:`s are resolved chroot-style, so absolute paths no - longer cause writes to be silently skipped. -- **A chief that keeps going.** Supervise/review prompts now carry an - explicit low-bar-to-continue / high-bar-to-stop asymmetry, stalled - cycles trigger a one-shot chief **stall rescue** before the loop gives - up (`supervise.stall_rescue`), unmet outputs contracts dispatch a - chief recovery pass, and dynamic agents receive the full ACTION/POST - protocol automatically so first dispatches comply. -- **Robustness loops by default.** Every meaningful dispatch is now - scored after the provider returns; empty / malformed / contract- - violating / low-confidence responses are re-dispatched with a repair - preamble, escalating to a stochastic consensus fan-out on the final - retry. Stages marked `careful: true` fan into N parallel samples - proactively (configurable via `robustness.auto_consensus`). The - critic-judge inner loop (`refine:` stage attribute, or default-on - for careful stages) drives draft → critic → revise until the critic - signs off. Ralph and autoresearch detect plateau / regression and - escalate-then-reframe instead of burning the full iteration budget. - Agents can ask peers directed clarifying questions via - `POST: question TO: URGENCY: blocking` and the harness - routes the answer inline. Everything is gated by - `clk.config.json::robustness.*` (or `CLK_ROBUSTNESS_*` env vars) so - you can throttle cost — see **Robustness loops** below. -- **The setup wizard explains itself.** `kickoff.sh --setup` is now a - series of explain-then-ask blocks (provider, loop settings, tool - detection, telegram, GitHub, git identity) — every question is - preceded by a short block telling you what the value does. Modeled - on `scripts/install_local.sh`'s narration style. -- **Tool auto-install.** Pick a provider whose CLI isn't installed and - the wizard surfaces the canonical install command (`npm install -g - …`, `curl -fsSL https://ollama.ai/install.sh | sh`, etc.) and asks - before running it. The same registry powers `/install` from inside - the TUI. -- **First-use configuration.** After install, every tool goes through - the same four-step shape: auth → upstream route → model → verify. - Pi prompts for its upstream provider (openrouter / anthropic / - openai / google) and the right env-var receives your API key. - Ollama runs `ollama list`, lets you pick a local model or pull a - new one with progress streaming. Re-run any time via `/configure - [tool]`. -- **GitHub integration.** The wizard offers to skip, link an existing - repo, or create a new private one. A hardened `.gitignore` and a - pre-push secret scanner protect against accidental `.env` / - API-key leaks. `CLK_GITHUB_PUSH_ON_COMMIT=true` makes each agent - commit push automatically. -- **Friendlier TUI.** First-run welcome banner, `/help` modal - overlay (F1 or `?`), state-aware hint bar above the input, - in-title USD cost estimate, narrative status snapshots, and - follow-on suggestions after every workflow and loop ("next: - `/loop ralph 5` to refine, `/undo` to revert, or type a follow-up - message"). The user always knows the next move. -- **Recoverability everywhere.** Atomic `.env` and JSON writes with - `.bak` rotation; `kickoff.sh --restore` swaps it back. Per-step - resume in the wizard via `.clk/.setup-progress`. Crashed-session - detection in the TUI surfaces "recovered from a crashed session" - and points at the preserved `conversation.md`. `/undo` reverts the - last clk-authored commit after explicit confirm. -- **`/doctor` and `/diag`.** Health-check every provider and config; - `--fix` prompts before repairing. `/diag` builds a redacted - tarball for bug reports — API keys are replaced with - ``. -- **`/tutorial`.** A 30-second sample idea against the `shell` - provider so first-time users see agents working end-to-end without - spending a cent. -- **Workspace management.** `./kickoff.sh --list`, `--clean 7d`, - `/workspaces` inside the TUI. Old kickoff dirs no longer pile up. -- **Always-confirm policy.** Every install, push, undo, ollama pull, - cost-cap crossing, or `--clean` removal asks `[y/N]` every single - time. There is no "remember my answer" setting — by design. - -See the **Recoverability**, **GitHub integration**, **Diagnostics**, -**Workspaces**, and **Cost guardrails** sections below for the full -walkthroughs. - -## Why CLK - -- **Local-first.** Everything lives under `.clk/` in the project - directory. No global installs, no `sudo`. -- **Provider-agnostic.** Works with Claude Code, OpenAI Codex, Google - Gemini, OpenWebUI (any OpenAI-compatible HTTP server), Pi, local - Ollama, or a built-in dummy "shell" provider for testing. -- **Dynamic team.** A baseline of three agents (`chief`, `qa`, `ralph`) - ships with the harness; the chief invents project-specific specialists - on the fly — including `engineer` when an implementer is needed — writes - their prompts, and authors the workflow YAML that wires them together. -- **Real actions, not just descriptions.** Agents emit `ACTION:` blocks - (write/edit/append/delete/run/done) that the harness applies with - path-safety checks, automatic backups, and per-agent git commits. -- **Self-healing.** When a stage's dependencies fail, the chief is - dispatched in recovery mode (capped) to fix or re-cast rather than - silently skipping. -- **Iterative by design.** Ships with Archon-style YAML workflows and - a Ralph/gnhf-style improvement loop; the same ralph agent also drives - Karpathy-style autoresearch cycles when the state has open questions. -- **Memory through git.** Every successful milestone (and every action - batch) is committed with a structured message so future agent runs - can mine the log for context. A separate `.clk/state/casting.log` - records every roster decision, and `.clk/logs/session.log` mirrors - the TUI status pane. - -## Pick your path - -Skim this matrix to jump straight to the right tutorial. Every path -goes through the same `kickoff.sh --setup` wizard at some point, so once -you've configured CLK in one place you can mix and match the rest. - -| Platform / mode | Tutorial | -|--------------------------------------|-----------------------------------------------------------| -| Local Linux / macOS / WSL (Python) | [Quick start](#quick-start) → [Lower-level CLI](#lower-level-cli) | -| Browser dashboard (point & click) | [Web dashboard](#web-dashboard) | -| Docker container (build locally) | [Docker](#docker) → [First-run setup](#first-run-setup) | -| Pre-built image from GHCR | [Docker → Pull from GHCR](#pull-from-ghcr) | -| Raspberry Pi (`pi` runtime) | [Pi extension](#pi-extension) | -| REST API (drive CLK from code) | [REST API](#rest-api) | -| Chat-control from your phone | [Telegram Bot](#telegram-bot) | - -Every tutorial ends with a **"You should now see…"** verification step. -If something differs, check the **Troubleshooting** notes inline in the -section you followed. - -## Quick start - -The fastest path is the kickoff script, which copies the harness into a -fresh `workspace/kickoff-/` directory, gives it its own git repo, and -launches the TUI dashboard. The source tree is never modified. - -> **Want chat control?** After running `--setup` once, see the -> [Telegram Bot](#telegram-bot) section to drive CLK from your phone with -> live status updates. - -```bash -# Optional: copy .env.example to .env to set defaults non-interactively. -./kickoff.sh "A local-first journaling app that summarizes my week" - -# First time? Run the setup wizard to create your .env: -./kickoff.sh --setup - -# Or omit the prompt and type your idea into the TUI: -./kickoff.sh -``` - -`kickoff.sh` reads all settings from `.env` (and optional CLI overrides) and -requires no interactive prompts during a normal run. If required config is -missing it prints exactly what's needed and offers to run `--setup` for you. - -```bash -# CLI overrides (override any .env value for a single run) -./kickoff.sh --provider claude --max-iterations 10 "My idea" -./kickoff.sh --no-tui "My idea" - -# Re-run setup at any time to update your .env: -./kickoff.sh --setup -``` - -The TUI shows live agent cards (idle / working / done / failed), a -status log that updates in place, and a Claude-Code-style ``>`` input -field. Use it to type follow-ups; each message dispatches another -engineering cycle so the agents react to the new context. - -| TUI command | Effect | -|--------------------------------------|--------| -| free text | first message becomes the idea, then auto-runs casting + `engineering`; later messages append to the conversation and re-cast + re-run | -| `/help` (or F1, or `?` when empty) | open the in-place help overlay with every command listed | -| `/idea ` | replace the captured idea | -| `/cast` | force a fresh chief casting pass against the current state | -| `/roles list` | print the current roster (baseline + dynamic) | -| `/roles add NAME "role description"` | add a dynamic role (the chief usually does this for you) | -| `/roles drop NAME` | remove a dynamic role (baseline cannot be removed) | -| `/run [workflow]` | run a single workflow cycle (default `engineering`) | -| `/loop ralph 5` | start a Ralph refinement loop with 5 iterations | -| `/loop autoresearch 3` | start a Karpathy-style research loop (ralph agent, research mode) | -| `/stop` | request the active loop to stop after the current iteration | -| `/abort` | SIGTERM any running CLI subprocess (use when an agent is genuinely hung; the heartbeat tells you when this is likely) | -| `/provider ` | switch the active provider; verifies it's reachable and warns if not | -| `/install [tool]` | install a missing provider CLI (claude, pi, ollama, …) via the registry in `scripts/install_tool.sh` | -| `/configure [tool]` | (re-)run a tool's first-use config — auth, upstream route, model picking | -| `/github` | inspect the current remote and link instructions for adding one | -| `/undo` | preview the last clk-authored commit; `/undo confirm` reverts it | -| `/doctor [--fix]` | health-check every provider, config, and git state; `--fix` prompts before repairing | -| `/diag` | bundle the logs, last 3 runs, and a redacted `.env` into `clk-diag-.tar.gz` for bug reports | -| `/tutorial` | run a 30-second sample idea on the `shell` provider — costs nothing | -| `/workspaces list\|rename\|switch\|clean` | manage past kickoff dirs under `workspace/` | -| `/status` | print a narrative session snapshot (idea, agents, tokens, files, per-provider cost) | -| `/quit` | exit the TUI | - -PgUp/PgDn scroll the log pane; Backspace edits the input; Enter sends. -The input area wraps when you type past one row and the status log -word-wraps every entry. A one-line hint bar above the input adapts to -state: if no idea is captured yet it says "type your idea, or -`/tutorial`, or `/help`"; if a run failed with a missing CLI it says -"try `/install ` to fix"; if an agent is working it points -at `/abort`. You always know your next move. - -The title bar shows: project, active provider, current phase, total -tokens, **estimated USD cost for the session** (via the per-provider -table in `clk_harness/pricing.py`), files written, and a `↑N` counter -for commits not yet pushed to the GitHub remote (when configured). - -CLI providers (`claude`, `codex`, `gemini`, `pi`) stream their -subprocess stdout/stderr live: every line the CLI prints (auth status, -"Connecting...", retries, etc.) appears in the status pane within -milliseconds, and each agent card has a "live" rotating view showing -PID + bytes received + the most recent line. The heartbeat fires every -~15s while an agent is working and tells you whether the subprocess is -actively streaming or silent — and if it's been silent for more than -two minutes it suggests typing `/abort`. So you can immediately tell -"this is just a slow model call" from "this is genuinely hung." - -### Lower-level CLI - -If you'd rather drive the harness without the TUI: - -```bash -./scripts/install_local.sh # local pip install (optional) -./scripts/clk init -./scripts/clk idea "A local-first journaling app that summarizes my week" -./scripts/clk plan -./scripts/clk run -./scripts/clk loop --max-iterations 10 -./scripts/clk status -./scripts/clk providers -``` - -Set `CLK_NO_TUI=true` in your environment (or `.env`) to make `kickoff.sh` -fall back to this non-interactive pipeline. - -The shell/dummy provider is the default and always works, so you can -exercise the entire harness with no API keys. Switch providers by -editing `.clk/config/providers.json`, via the TUI's `/provider` command, -or: - -```bash -./scripts/clk configure --set default_provider=claude -``` - -## Web dashboard - -Everything the TUI does, in your browser — and then some. The web -dashboard is a React single-page app served by CLK's own FastAPI server. -It lets you **configure every feature and `.env` setting**, **kick off -agent workflows**, and — the star of the show — **watch what the agents -are doing in real time** with live agent cards, a colour-coded activity -timeline, animated token/cost meters, and a prompt/response inspector. - -### Launch it - -```bash -pip install "clk-harness[api]" - -# Build the UI bundle (needs Node/npm) the first time, then serve it. -clk web --build - -# Subsequent runs (bundle already built) just serve it and open a browser: -clk web -``` - -`clk web` runs a uvicorn server (default `http://127.0.0.1:8001`) and -opens your browser. Flags: - -| Flag | Purpose | -|--------------|----------------------------------------------------------------| -| `--build` | Compile the React bundle first (`npm ci && npm run build`). | -| `--no-build` | Never auto-build, even if the bundle is missing. | -| `--host` | Bind host (default `CLK_API_HOST` or `127.0.0.1`). | -| `--port` | Bind port (default `CLK_API_PORT` or `8001`). | -| `--no-open` | Don't open a browser window. | - -> The pre-built Docker image already contains the compiled bundle, so -> inside a container you can run `clk web --no-build` directly. - -To open the dashboard from the Docker image without kicking off a run -(the default `kickoff.sh` entrypoint starts an agent — `clk web` does -not), override the entrypoint and publish the port: - -```bash -docker run --rm -it \ - -p 127.0.0.1:8001:8001 \ - -e CLK_API_HOST=0.0.0.0 \ - -v "$(pwd):/workspaces" \ - --entrypoint clk \ - clk web --no-open --no-build -``` - -Then browse to `http://localhost:8001`. Notes: - -- `--entrypoint clk` replaces `kickoff.sh`, so nothing runs until you - press **Run** in the UI. -- `CLK_API_HOST=0.0.0.0` lets the container's server accept the forwarded - connection (it binds loopback-only *inside* the container by default). - The `127.0.0.1:` prefix on `-p` publishes the port to your host's - loopback only, so the unauthenticated UI isn't reachable from other - machines on your network. Drop the prefix (`-p 8001:8001`) only if you - deliberately want LAN access and have firewalled appropriately. -- `-v "$(pwd):/workspaces"` bind-mounts the current directory as the - workspace root — no named volumes — and `--rm` discards the image's - anonymous volumes on exit. -- `--no-build` serves the bundle already baked into the image (no npm at - runtime); `--no-open` skips the in-container browser launch. - -### Tutorial: from `docker run` to your first shipped feature - -This walks the whole loop — start the server, configure a provider, kick -off a job, and watch (and steer) the agents — entirely in the browser. - -**1. Start the server.** Using the published image (nothing is built or -run until you ask): - -```bash -docker run --rm -it \ - -p 127.0.0.1:8001:8001 \ - -e CLK_API_HOST=0.0.0.0 \ - -e CLK_ENV_FILE=/workspaces/.env \ - -v "$(pwd):/workspaces" \ - --entrypoint clk \ - ghcr.io/billjr99/cognitiveloopkernel:latest web --no-open --no-build -``` - -Open **http://localhost:8001**. The `CLK_ENV_FILE=/workspaces/.env` line -makes your settings persist to `./.env` on the host (see -[Configuring `.env`](#configuring-env-where-settings-live) below). - -**2. Create a workspace.** In the left rail under **Workspaces**, click -the **+** and name your project (e.g. `markdown-cli`). A workspace is one -isolated project directory; the whole UI focuses on one at a time. - -**3. Configure a provider.** Open the **Configure** tab: - - On **Providers**, pick the **active** provider and click **make - active**. **This matters:** the default active provider is `shell` — - a *stub that echoes prompts and never calls an LLM*. If you leave it - on `shell`, runs will complete instantly and "do things" without ever - touching your model (the Health strip flags this). Choose a real - provider (claude / codex / gemini / pi / ollama / openwebui). - - For HTTP providers (**ollama**, **openwebui**), set the `endpoint`, - then click **models** next to the `model` field — CLK probes the - endpoint and offers a **dropdown of installed models** (falling back - to a text box if the endpoint is unreachable, which also tells you - the server isn't reachable from where CLK runs). - - On **.env (global)**, set any keys your provider needs — e.g. - `ANTHROPIC_API_KEY` (secrets show as `••••••••` and are preserved on - save). Click **Save**. - - Auth: set `CLK_AUTH_MODE` to `apikey` to use the keys above, or `cli` - to trust a provider CLI you've already logged in to. - - > **Running CLK in Docker with a local Ollama/OpenWebUI?** A - > `localhost` endpoint points at the *container*, not your host. CLK - > auto-retries `host.docker.internal`, but the host must be reachable — - > run the container with `--add-host=host.docker.internal:host-gateway` - > on Linux (Docker Desktop adds it automatically). The model dropdown is - > the quickest way to confirm the endpoint resolves. - -**4. Kick off a job.** Open the **Run** tab: - - Type your idea / problem statement (e.g. *"Build a Markdown-to-HTML - CLI with a parser, renderer, and golden-file tests"*). - - Choose a mode: **Run workflow** (one development cycle — pick a - workflow like `engineering`), **Loop** (iterative ralph / - autoresearch with an iteration count), **Plan** (discovery + product - passes), or **Set idea** (just capture it). - - Click **Start**. A raw-output panel streams stdout; the structured - view comes alive on the other tabs. - -**5. Watch the team work.** Three tabs give you live visibility: - - **Dashboard** — the "now happening" banner, per-agent cards (status, - tokens, cost, last thought), the colour-coded activity timeline, - token/cost charts, and a files-changed list. - - **Think** — a live, timestamped feed of every **dispatch**, **prompt**, - and **response**. Filter by type and expand any entry to read the full - prompt or the agent's full response inline (or pop the full inspector). - - **Files** — browse everything the agents created. Click a file to view - and **edit** it (Save writes back to the workspace). - -**6. Steer them — follow up in chat.** On the **Files** tab, the bottom -panel is a chat with the agents. Select a file for context, type a -follow-up (e.g. *"add error handling and a test for empty input"*), pick a -workflow, and **Send**. Each message seeds a new workflow run scoped to -your request and streams the agents' work straight back into the thread — -so you can iterate on the generated code conversationally. - -That's the full loop: configure → run → watch → edit/steer → repeat, all -from `http://localhost:8001`. - -### What you can do from the browser - -- **Workspaces** — create, switch between, and delete isolated projects - from the left rail. The whole UI focuses on one active workspace at a - time, just like the TUI. -- **Run** — capture an idea and launch a workflow (`run`), an iterative - `loop` (ralph / autoresearch with an iteration count), `plan`, or just - set the idea. A raw-output tab streams stdout while the structured - view animates on the Dashboard. -- **Dashboard** — a live **"now happening"** banner, per-agent cards - (status, runs, tokens, cost, last "thought", activity meter), a - filterable real-time **activity timeline** (dispatches, prompts, - responses, actions, retries, commits…), **token & cost charts**, and a - files-changed list. Click any timeline event to inspect the full prompt - and response. -- **Think** — a dedicated, live **thinking & dispatching** feed: every - dispatch, prompt, and response as a timestamped row, filterable by type - and expandable to the full text inline (or in the inspector). -- **Files** — browse the files the agents generated, **view and edit** them - in-browser (Save writes back to the workspace), and **chat with the - agents**: each follow-up message seeds a workflow run scoped to your - request (optionally with a selected file as context) and streams the - result back into the thread. A **History** toggle shows the commit - timeline (agent badge parsed from the commit subject, relative time, - +/− line stats); clicking a commit opens its changed-file list and a - colored diff. The file editor's history button **time-travels a single - file** to any past version (read-only, "Back to latest" banner). When - the working tree is dirty, an **Uncommitted changes** entry tops the - history with new/modified/deleted badges and the working-tree diff, - and changed files carry an amber dot in the list. The file list reads - live from disk (2 s refresh), so it always shows the latest state — - committed or not. -- **Guided mode** — a full-screen step-by-step wizard for newcomers: - provider discovery (Ollama/OpenWebUI probed with a docker-host - fallback, CLI providers detected on PATH or unlocked by an API key) → - model pick → plain-language idea → friendly progress view → files → - follow-up loop. First visit with no workspaces lands here; the - sidebar's sparkle button or "Advanced mode" toggles between the wizard - and the full console mid-run without losing the workspace. -- **Configure** — tabbed settings for the global **`.env`** (grouped, - typed widgets; secrets masked with `••••••••` and preserved on save), - per-workspace **harness config** (`clk.config.json`), **providers** - (pick the active one, edit endpoints/keys), and the **agent roster**. - A health strip surfaces `doctor` findings (missing keys, unavailable - providers) at a glance. - -### How the live view works - -Under the hood the dashboard streams the harness's structured event log -(`.clk/logs/activity.jsonl`) over Server-Sent Events -(`GET /api/workspaces/{id}/activity/stream`) and folds it into a snapshot -(`GET /api/workspaces/{id}/snapshot`) that mirrors the TUI's model. The -connection auto-reconnects, so you can leave the tab open across runs. - -### Configuring `.env` (where settings live) - -The **Configure → .env (global)** tab edits a single `.env` file shared by -all workspaces (provider, API keys, git identity, feature flags…). The API -injects it into every agent subprocess, so edits take effect on the **next -run** without restarting the server. The tab header shows exactly which -file it's editing. - -CLK resolves that path as follows: - -1. **`CLK_ENV_FILE`** — if set, this exact path wins (`~` is expanded). -2. Otherwise, `/../.env`. - -In an installed image the fallback resolves next to the installed -package (e.g. `…/site-packages/.env`) — not where you'd want it. **Set -`CLK_ENV_FILE` to a path inside your bind mount** so the file lives on your -host and persists across containers: - -```bash --e CLK_ENV_FILE=/workspaces/.env -v "$(pwd):/workspaces" -``` - -The file is created on first save (you don't need to pre-create it); just -make sure the parent directory exists. - -### Secrets & network safety - -`.env` editing includes API keys. Secret-looking values -(`*_API_KEY`, `*_TOKEN`, …) are **masked** in every response and never -echoed back; saving an unchanged masked field preserves the stored value. -A single-key reveal endpoint exists but is **disabled by default** — set -`CLK_API_ALLOW_REVEAL=1` to enable it. The server binds to loopback -(`127.0.0.1`) by default; only set `CLK_API_HOST=0.0.0.0` on a trusted, -isolated network (there is no built-in auth). - -### Developing the UI - -The source lives in `webui/` (Vite + React + TypeScript). For hot-reload -development against a running server: - -```bash -clk web --no-open # serve the API on :8001 in one terminal -cd webui && npm install && npm run dev # Vite dev server on :5173 (proxies /api) -``` - -`npm run build` emits the bundle to `clk_harness/webui_dist/` (shipped in -the wheel via `package-data`); `npm test` runs the Vitest suite. - -## REST API - -CLK ships a FastAPI-based HTTP server that exposes a subset of CLI -commands programmatically — specifically: `init`, `idea`, `plan`, `run`, -`loop`, and `status` (see `/api/capabilities` for the authoritative list). -Use it to integrate CLK into your own tooling, drive it from a web UI, -or orchestrate it from CI pipelines without spawning a terminal. - -### Install - -```bash -pip install "clk-harness[api]" -``` - -### Start the server - -The REST API starts **automatically in the background** whenever you run -any `clk` sub-command (provided the optional `[api]` extras are installed). -A `[clk] REST API listening on http://…` banner is printed to stderr at -startup. You can also start it standalone: - -```bash -# Using the console-script entry point (recommended) -clk-api - -# Or via the module entry point -python -m clk_harness.api - -# Or via uvicorn directly -uvicorn clk_harness.api:app --host 0.0.0.0 --port 8001 -``` - -The server listens on port `8001` by default. Override with -`CLK_API_PORT=`. - -### Security and network bind address - -> **Warning: the REST API has no authentication and binds to `0.0.0.0` -> (all interfaces) by default.** This default suits sandbox / container -> environments where network isolation is provided by the runtime. -> **Do not expose the API port to an untrusted network without additional -> access controls.** For local development, restrict the server to -> loopback (`127.0.0.1`) using the mechanisms below. - -When the CLI starts, the REST API auto-starts on a background daemon thread -and prints a `[clk]` banner to stderr. Override the bind address or disable -the API entirely: - -| Mechanism | Effect | -|---|---| -| `CLK_API_HOST=127.0.0.1` | Restrict the API to loopback (recommended for local dev) | -| `CLK_API_PORT=` | Change the listen port (default `8001`) | -| `clk --no-api ` | Skip the background API for this invocation | -| `CLK_DISABLE_API=1` | Disable the background API for all CLI invocations | - -If the optional `[api]` extras (`fastapi`, `uvicorn`) are not installed, -the background thread is silently skipped and the CLI works normally. - -### Quick curl example - -```bash -# Health check -curl http://localhost:8001/api/healthz - -# Create a workspace -WS=$(curl -s -X POST http://localhost:8001/api/workspaces \ - -H 'Content-Type: application/json' \ - -d '{"name": "my-project"}' \ - | python3 -c "import sys,json; print(json.load(sys.stdin)['workspace_id'])") - -# Capture an idea -TASK=$(curl -s -X POST http://localhost:8001/api/research \ - -H 'Content-Type: application/json' \ - -d "{\"command\":\"idea\",\"args\":[\"A local-first journaling app\"],\"workspace_id\":\"$WS\"}" \ - | python3 -c "import sys,json; print(json.load(sys.stdin)['task_id'])") - -# Stream live output -curl -sN http://localhost:8001/api/research/$TASK/stream -``` - -See [docs/REST_API.md](docs/REST_API.md) for the full endpoint reference, -SSE event format, and more examples. - -## Docker - -The harness ships with a `Dockerfile`. Kickoff directories are created under -`workspace/` inside the container; mount a volume there to keep them after -the container exits. - -The default mode is the interactive TUI dashboard — run with `-it` so the -container has a terminal. If no `.env` is present it will prompt for provider -and settings before launching. Pass your idea as the first argument to skip -the prompt and go straight to the engineering workflow. - -> **`install_local.sh` is not needed inside Docker.** The `Dockerfile` runs -> `pip install -e .` at image-build time, so all Python dependencies are -> already present. Keep `CLK_RUN_INSTALL=false` (the default) — setting it to -> `true` in a Docker environment would redundantly re-create a `.clk/venv` -> that the container doesn't need. - -All examples below assume the image is tagged `clk` locally — either -build it from source or pull a prebuilt image and re-tag it (see the next -two sections). - -### Build - -```bash -docker build -t clk . -``` - -### Pull from GHCR - -Prebuilt images are published to GitHub Container Registry on every push to -`main` (tagged `latest` and `main`), every semver tag (`vX.Y.Z` → `X.Y.Z`, -`X.Y`), and every commit (`sha-`): - -```bash -docker pull ghcr.io/billjr99/cognitiveloopkernel:latest -docker tag ghcr.io/billjr99/cognitiveloopkernel:latest clk -``` - -The `docker tag` step lets every later command in this README refer to the -image simply as `clk`. If you'd rather not re-tag, substitute -`ghcr.io/billjr99/cognitiveloopkernel:latest` for `clk` in the examples -below. - -### Configuration via .env - -`kickoff.sh` loads `/app/.env` at startup, so any setting that can be -configured via `CLK_*` env vars (provider, API keys, git identity, etc.) -can also live in a single file. There are two ways to provide it: - -**Bind-mount a host file at `/app/.env`** — recommended when you want the -setup wizard's edits to persist back to disk: - -```bash -touch ~/clk.env # create empty file first (Docker quirk) -docker run --rm -it \ - -v ~/clk.env:/app/.env \ - -v clk-workspace:/app/workspace \ - clk "My idea here" -``` - -**Pass it via `--env-file`** — simpler when the file is read-only config: - -```bash -docker run --rm -it \ - --env-file ~/clk.env \ - -v clk-workspace:/app/workspace \ - clk "My idea here" -``` - -The bind-mount approach is required if you want to use `--setup` (the wizard -writes back into `/app/.env`); `--env-file` only injects vars at start. - -### First-run setup - -Run the setup wizard to create your `.env`. The wizard is structured -as a series of **explain-then-ask** blocks — each section tells you -what the value does before asking for it, modeled on the -`scripts/install_local.sh` narration style. Sections (in order): - -1. **Provider** — pick the AI that writes code (`shell`, `claude`, - `codex`, `gemini`, `pi`, `ollama`, `openwebui`). One-liner per - choice. -2. **Loop settings** — max iterations, project name, install flag, - TUI/no-TUI. The **install flag** (`CLK_RUN_INSTALL`) controls - whether `scripts/install_local.sh` runs inside each kickoff - directory to create a local `.clk/venv`. **Leave it `false` - (the default) when running in Docker** — the image already has - all Python dependencies installed at build time, so the local - venv step is unnecessary. -3. **Auth mode** — only for CLI providers; `cli` reuses your local - `claude login` / `codex login` / `gemini login`, `apikey` - prompts for a key directly. -4. **Tool detection + auto-install** — checks whether the chosen - provider's CLI is on PATH; if not, surfaces the canonical install - command and asks before running it. Backed by - `scripts/install_tool.sh`'s registry — same commands the TUI's - `/install` uses. -5. **First-use configure** — auth → upstream route → model → - verify. Pi picks `openrouter` / `anthropic` / `openai` / `google` - and sets the right `{ROUTE}_API_KEY` env var. Ollama runs - `ollama list`, lets you pick a local model or pull a new one - (progress streamed). State recorded in - `.clk/state/configured-tools.json` so the wizard knows not to - re-prompt next time. -6. **Telegram** — same flow as before. Says yes here triggers the - dedicated bot wizard at `scripts/telegram_setup_wizard.sh`. -7. **GitHub** — optional remote (skip / existing / create); writes a - hardened `.gitignore` and a pre-push secret scan hook. See - [GitHub integration](#github-integration). -8. **Git identity** — `CLK_GIT_NAME` / `CLK_GIT_EMAIL` for the - in-container fallback. - -**Atomic writes.** Every answer is persisted to `.env` immediately -via `env_set` (sourced from `scripts/lib_env.sh`). The previous -content rotates to `.env.bak`. If the wizard crashes mid-flow, the -next run looks at `.clk/.setup-progress` and offers to resume from -the last completed step. To undo a bad wizard run entirely, run -`./kickoff.sh --restore`. - -**Always-confirm.** Every install, push, ollama pull, and -destructive step asks `[y/N]` every single time. Pressing Enter -defaults to the safe option. - -```bash -# Create an empty config file on the host (once) -touch ~/clk.env - -# Run the wizard — writes into the bind-mounted file -docker run --rm -it \ - -v ~/clk.env:/app/.env \ - -v clk-workspace:/app/workspace \ - clk --setup -``` - -`--setup` also works locally (outside Docker) and updates `./kickoff.sh`'s -own `.env` in-place. - -### Run (interactive TUI — default) - -**Named volume** — kickoffs persist in a Docker-managed volume across runs: - -```bash -docker volume create clk-workspace - -docker run --rm -it \ - -v clk-workspace:/app/workspace \ - clk "A local-first journaling app that summarizes my week" -``` - -**Host directory** — kickoffs written directly to a directory on your machine: - -```bash -docker run --rm -it \ - -v /path/to/my/projects:/app/workspace \ - clk "A local-first journaling app that summarizes my week" -``` - -**Anonymous volume** — Docker allocates a temporary volume that is -automatically removed when the container exits (`--rm` handles cleanup): - -```bash -docker run --rm -it \ - -v /app/workspace \ - clk "A local-first journaling app that summarizes my week" -``` - -**Ephemeral** — no explicit volume mount; Docker creates an anonymous volume -for `/app/workspace` (declared in the image) and removes it with `--rm`: - -```bash -docker run --rm -it clk "A local-first journaling app that summarizes my week" -``` - -### Provider and authentication - -Pass any `CLK_*` variable or API key with `-e`: - -```bash -docker run --rm -it \ - -v clk-workspace:/app/workspace \ - -e CLK_PROVIDER=claude \ - -e CLK_AUTH_MODE=apikey \ - -e ANTHROPIC_API_KEY=sk-ant-... \ - clk "A local-first journaling app that summarizes my week" -``` - -For the `pi` provider with an OpenRouter key: - -```bash -docker run --rm -it \ - -v clk-workspace:/app/workspace \ - -e CLK_PROVIDER=pi \ - -e CLK_PI_MODEL=openrouter/free \ - -e CLK_PI_KEY_TYPE=openrouter \ - -e CLK_PI_API_KEY=sk-or-... \ - clk "A local-first journaling app that summarizes my week" -``` - -For `ollama` or `openwebui` running on the host, use `host.docker.internal` -as the endpoint (macOS/Windows) or `--network host` (Linux): - -```bash -docker run --rm -it \ - -v clk-workspace:/app/workspace \ - -e CLK_PROVIDER=ollama \ - -e CLK_OLLAMA_ENDPOINT=http://host.docker.internal:11434 \ - clk "My idea" -``` - -### Non-interactive / CI mode - -For scripted or CI use, skip the TUI entirely. The pipeline runs -`init → idea → plan → run → loop` without any curses UI: - -```bash -docker run --rm \ - -v clk-workspace:/app/workspace \ - -e CLK_NO_TUI=true \ - -e CLK_PROVIDER=claude \ - -e CLK_AUTH_MODE=apikey \ - -e ANTHROPIC_API_KEY=sk-ant-... \ - clk "A local-first journaling app that summarizes my week" -``` - -### Run the REST API - -To run the [REST API](#rest-api) server inside the container instead of the -TUI, override the entrypoint command: - -```bash -docker run --rm -p 8001:8001 \ - -v clk-workspaces:/workspaces \ - clk python -m clk_harness.api -``` - -Mount `/workspaces` to persist workspace *directories* across container -restarts. - -> **Note: workspace state is in-memory and is NOT recoverable after restart.** -> Even when the `/workspaces` volume is mounted, the in-memory registry of -> workspace IDs and task history is lost every time the container restarts. -> The files inside `/workspaces` survive on disk, but you must create new -> workspace registrations via `POST /api/workspaces` after each restart — -> previous workspace IDs and task IDs will not be recognised by the new -> container instance. - -Override the workspace root with `CLK_WORKSPACES_DIR`. - -## Telegram Bot - -Two-way chat control for CLK. The bot lets you kick off runs, watch live -status updates, tail the activity log, and cancel tasks from anywhere -Telegram works — no SSH, no port forwarding, no public URL. It connects -via long polling, so it works behind NAT (your home network, a Pi behind -a router, a Docker container). - -### How it works - -`clk-telegram-bot` is a separate process that: - -1. Long-polls Telegram's servers for messages from allowlisted users. -2. Translates commands into calls against the local CLK REST API - (`clk-api`, default `http://127.0.0.1:8001`). -3. Tails `.clk/logs/activity.jsonl` and pushes interesting events (agent - dispatches, action applied, iteration outcomes, errors) to subscribed - chats in real time. - -Access is gated by a numeric-user-ID allowlist. Unknown users get a single -canned reply that prints their own user ID (so the operator can add them) -and are otherwise ignored. - -### One-time setup (any platform) - -Three steps. The wizard automates the last two: - -1. **Create the bot** with [@BotFather](https://t.me/BotFather): - - Open Telegram, message `@BotFather`. - - Send `/newbot`. Pick a display name and a unique username that ends - in `bot` (e.g. `my_clk_bot`). - - BotFather replies with an HTTP API token like - `123456789:AAH...xyz`. Copy it. -2. **Run the wizard**: - ```bash - ./scripts/telegram_setup_wizard.sh - ``` - The wizard: - - Validates the token by calling `getMe` against Telegram. - - Prints "Send any message to your new bot, then press Enter". - - Reads `getUpdates` to capture your numeric user ID automatically - (you can also enter one manually). - - Writes `CLK_TELEGRAM_BOT_TOKEN`, `CLK_TELEGRAM_ALLOWED_USERS`, and - `CLK_TELEGRAM_ENABLED=true` to `.env` (preserving other keys). -3. **Start the bot**: - ```bash - # Make sure the REST API is running first (so the bot has something to drive): - clk-api & - # Then start the bot: - clk-telegram-bot - ``` - -The wizard is idempotent: re-run any time to rotate the token, add more -allowed users, or re-discover your ID after switching accounts. - -**You should now see:** in your Telegram chat with the new bot, sending -`/start` replies with your user ID and the help text. Sending `/status` -lists workspaces. - -### Setup inside Docker - -`kickoff.sh` offers Telegram setup automatically the first time it runs -without a token configured. The image already includes -`python-telegram-bot`, the wizard script, and the `clk-telegram-bot` -entry point. - -```bash -# 1. Create an empty config file on the host (once). -touch ~/clk.env - -# 2. Run kickoff with --setup; answer "y" at the Telegram prompt. -docker run --rm -it \ - -v ~/clk.env:/app/.env \ - -v clk-workspace:/app/workspace \ - clk --setup -``` - -To run only the Telegram wizard (no kickoff prompts): - -```bash -docker run --rm -it \ - -v ~/clk.env:/app/.env \ - --entrypoint scripts/telegram_setup_wizard.sh \ - clk -``` - -Once `~/clk.env` has the Telegram keys, run the bot in its own container -alongside `clk-api`: - -```bash -# REST API server (port 8001 published so the bot container can reach it) -docker run -d --name clk-api \ - -v ~/clk.env:/app/.env \ - -v clk-workspaces:/workspaces \ - -p 127.0.0.1:8001:8001 \ - --entrypoint python clk -m clk_harness.api - -# Telegram bot — talks to clk-api via Docker's bridge network -docker run -d --name clk-telegram-bot \ - --link clk-api \ - -v ~/clk.env:/app/.env \ - -v clk-workspaces:/workspaces \ - -e CLK_API_HOST=clk-api \ - -e CLK_API_PORT=8001 \ - --entrypoint clk-telegram-bot clk -``` - -The bot makes **outbound** HTTPS calls to `api.telegram.org`, so no -inbound port forwarding is needed. The default Docker bridge network is -enough. - -### Setup on Raspberry Pi (systemd) - -Install CLK via the [Pi extension](#pi-extension) or `pip install -'clk-harness[api,telegram]'`, then drop two systemd units: - -```ini -# /etc/systemd/system/clk-api.service -[Unit] -Description=CLK REST API -After=network-online.target - -[Service] -User=pi -WorkingDirectory=/home/pi/clk -EnvironmentFile=/home/pi/clk/.env -ExecStart=/usr/local/bin/clk-api -Restart=on-failure - -[Install] -WantedBy=multi-user.target -``` - -```ini -# /etc/systemd/system/clk-telegram-bot.service -[Unit] -Description=CLK Telegram bot -After=clk-api.service -Requires=clk-api.service - -[Service] -User=pi -WorkingDirectory=/home/pi/clk -EnvironmentFile=/home/pi/clk/.env -ExecStart=/usr/local/bin/clk-telegram-bot -Restart=on-failure - -[Install] -WantedBy=multi-user.target -``` - -Enable both: `sudo systemctl enable --now clk-api clk-telegram-bot`. - -**You should now see:** from your phone, `/status` returns the current -workspace list. Sending `/run improve the README` kicks off a CLK run and -the bot replies with a task ID. - -### Commands - -| Command | Effect | -|---------|--------| -| `/start` | Greet, show your user ID, indicate whether allowlisted | -| `/help` | Show this command list | -| `/status` | List workspaces and last task ID | -| `/run ` | Start a single CLK run with the given objective | -| `/loop [args]` | Start the Ralph / autoresearch loop | -| `/plan ` | Run the planning workflow | -| `/idea ` | Capture an idea | -| `/cancel [task_id]` | Cancel a running task (latest if omitted) | -| `/tail [N]` | Print the last N lines of `activity.jsonl` (default 20) | -| `/subscribe` | Receive live event pushes in this chat | -| `/unsubscribe` | Stop receiving live event pushes | -| `/workspace ` | Set the default workspace for this chat | - -Any plain text (no slash) from an allowlisted user is treated as -`/run ` — so you can just describe what you want. - -### Adding more allowed users - -Either re-run `scripts/telegram_setup_wizard.sh` (it appends new IDs to -the existing list) or edit `CLK_TELEGRAM_ALLOWED_USERS` in `.env` -directly: - -```bash -# .env -CLK_TELEGRAM_ALLOWED_USERS=123456789,987654321,555666777 -``` - -Restart `clk-telegram-bot` to pick up the change. - -### Troubleshooting - -- **Bot doesn't reply.** Send `/start` and check the reply for your user - ID. If you get the "Not allowlisted" message, add the ID to - `CLK_TELEGRAM_ALLOWED_USERS` and restart the bot. -- **`token rejected by Telegram`** (during the wizard). The token is - wrong or was revoked. Get a fresh one from BotFather with `/token`. -- **No live updates** even after `/subscribe`. Confirm that the bot can - read the activity log: `CLK_TELEGRAM_ACTIVITY_LOG` overrides the - default path, or the bot auto-detects - `$CLK_WORKSPACES_DIR//.clk/logs/activity.jsonl`. -- **`clk-telegram-bot --check-config` exits non-zero.** It prints which - variable is missing (`2` = token, `3` = empty allowlist). -- **Kickoff prompts every run.** Set `CLK_TELEGRAM_SKIP=true` in `.env` - to permanently suppress the "Set up Telegram bot now?" prompt. - -## Recoverability - -CLK tries hard to never leave you with a broken setup or a stuck -session. The safety nets: - -| Safety net | When it kicks in | How to use it | -|---|---|---| -| `.env.bak` rotation | Every wizard run rotates the old `.env` to `.env.bak` before writing. | `./kickoff.sh --restore` swaps it back. | -| Atomic `.env` writes | Wizards write to `.env.tmp` and rename — Ctrl-C mid-write leaves either the old or the new file intact, never half. | Automatic; no user action. | -| Atomic JSON config writes | Same pattern for `.clk/config/*.json` and any agent-written JSON, with `.bak` rotation. | Implemented in `clk_harness.config.save_json`. | -| Per-step wizard resume | Wizard tracks last completed step in `.clk/.setup-progress`. If you Ctrl-C, the next run offers to resume. | `./kickoff.sh --setup` prompts "Resume from after step X? [Y/n]". | -| Crashed-session detection | The TUI writes its PID to `.clk/state/.tui-active`. If a previous TUI exited uncleanly, the next launch surfaces "recovered from a crashed session" and points to the preserved `.clk/state/conversation.md`. | Automatic. | -| `/undo` | After every agent commit, `/undo` lets you preview and revert the last commit. Two-step (preview first, then `/undo confirm`) so it's never accidental. | Type `/undo` in the TUI. | -| `/abort` | When an agent subprocess is stuck, SIGTERM it without killing the TUI. The provider returns a timeout error, the cycle reports the failure cleanly. | Type `/abort` in the TUI. | -| `/install` / `/configure` | Recover from "CLI not found" / "auth failed" without leaving the dashboard. | `/install [provider]` then `/configure [provider]`. | -| Pre-push secret scanner | Installed in the kickoff dir's `.git/hooks/pre-push`. Greps for `ANTHROPIC_API_KEY=`, `OPENAI_API_KEY=`, `sk-…`, private-key headers. Bypass with `git push --no-verify` when sure. | Automatic in every kickoff dir. | - -**Confirmation policy.** Every install, push, undo, cost-cap -crossing, ollama pull, and destructive `--clean` action asks `[y/N]` -every single time. There is no "remember my answer" shortcut — by -design. - -## GitHub integration - -`kickoff.sh --setup` offers to wire each kickoff workspace up to a -GitHub remote so every CLK commit is checkpointed off your machine. - -**Three modes:** - -- `skip` — no GitHub, local commits only (default). -- `existing` — paste a `https://github.com/OWNER/REPO` or - `git@github.com:OWNER/REPO.git` URL; the wizard validates it via - `gh repo view` (or `git ls-remote` if `gh` isn't on PATH). -- `create` — provide `owner/repo` (default - `$USER/$CLK_PROJECT_NAME-kickoff`), the wizard runs - `gh repo create … --private` from inside the kickoff dir. Default - visibility is private — making it public requires an explicit - choice. - -**Auth.** Prefer the `gh` CLI if it's on PATH and authenticated. If -not, the wizard offers to install `gh` and drops you into a shell -for `gh auth login` (same pattern as `pi login`). PATs are stashed -in `~/.config/clk/github-token` (chmod 600), never `.env`. - -**Hardened `.gitignore`.** Written before the first push so secrets -can't leak. Blocks `.env`, `.env.bak`, `.env.local`, `*.pem`, -`*.key`, `*_id_rsa*`, `/secrets/`, plus editor / OS junk. - -**Pre-push hook.** `.git/hooks/pre-push` greps the about-to-push -objects for obvious key patterns (Anthropic / OpenAI / OpenRouter / -Gemini / Google keys, generic `sk-…` strings, Slack `xoxb-` tokens, -private key headers). On a hit the push aborts with the offending -lines and the bypass instructions. Bypass once with `git push ---no-verify`. - -**`CLK_GITHUB_PUSH_ON_COMMIT=true`** makes the harness follow every -auto-commit with a `git push origin HEAD`. Failures are non-fatal — -the commit stays local until the network or remote is back. The TUI -title bar shows `↑N` for the count of unpushed commits. - -**Re-link from the TUI.** Type `/github` to see current remotes and -re-link instructions. - -## Diagnostics & Doctor - -Two new commands help when something feels off. - -### `/doctor` (or `clk doctor`) - -Health-check every provider, validate `.env` against known-bad -combos, and check git/GitHub state. - -- Reports each finding as `ok | warn | fail`. -- Exits non-zero on any `fail` so it slots into CI. -- `/doctor --fix` prompts before each automated remedy (running - `/install`, re-running `configure_tool`, writing a missing key). - -Common findings: - -| Finding | Meaning | Fix | -|---|---|---| -| `claude: unavailable` | `claude` CLI not on PATH or API key missing | `/install claude` then `/configure claude` | -| `anthropic_key: fail` | `CLK_AUTH_MODE=apikey` but `ANTHROPIC_API_KEY` is empty | `/configure claude` to set it | -| `git: warn` | no git repo at project root; auto-commit disabled | `git init` | -| `ollama: unavailable` | endpoint not reachable | `/install ollama`, then `ollama serve &` | - -### `/diag` (or `clk diag`) - -Bundles the current state into a `clk-diag-.tar.gz` for sharing -in bug reports. Contents: - -- `.clk/logs/*` (recent only — capped so the bundle stays small) -- `.clk/runs//` -- `.clk/state/*.{md,json}` -- `clk doctor` output -- `pyproject.toml` version, `python --version`, `git --version`, - `uname -a` -- A redacted copy of `.env` — every value under a key containing - `KEY`, `TOKEN`, `SECRET`, or `PASS` is replaced with - `` so the recipient can confirm you *had* a - key without seeing it. - -Always confirms before writing the tarball. - -## Tutorial mode - -First-time users can type `/tutorial` in the TUI to run a -30-second sample idea — `"Add a hello() function to greeter.py"` — -against the `shell` provider. Costs nothing, takes no API keys, -demonstrates the cast → engineer → qa → commit loop end-to-end so -the user knows what a "real" run will look like. - -The tutorial backs up your active provider, runs one engineering -cycle in `.clk/state/.tutorial/`, then restores. A marker at -`.clk/state/.seen-tutorial` suppresses the "type /tutorial" hint -in the welcome banner on subsequent runs. - -## Workspace management - -Each `kickoff.sh` creates `workspace/kickoff-/`. To keep -the directory navigable: - -```bash -./kickoff.sh --list # show every kickoff with its idea -./kickoff.sh --clean 7d # delete kickoff dirs older than 7 days (after y/N) -./kickoff.sh --clean 30m # same, in minutes -./kickoff.sh --restore # roll .env back to .env.bak (undo last wizard run) -``` - -From inside the TUI: - -```text -/workspaces list # numbered list, * marks the current one -/workspaces rename old-name new # rename a kickoff dir -/workspaces switch # prints instructions (/quit, then cd) -/workspaces clean # points at ./kickoff.sh --clean -``` - -The kickoff manifest at `KICKOFF.md` (written by `kickoff.sh` into -each new workspace) records timestamp, source dir, project name, -provider, max iterations, install flag, and idea. - -## Cost guardrails - -Title-bar dollar cost is computed from the per-provider table in -`clk_harness/pricing.py`: - -| Provider | Default $/1k in | Default $/1k out | -|---|---|---| -| claude (sonnet-4-5) | $0.003 | $0.015 | -| claude (haiku-latest)| $0.0008 | $0.004 | -| claude (opus-latest) | $0.015 | $0.075 | -| codex (gpt-4o) | $0.0025 | $0.010 | -| codex (gpt-4o-mini) | $0.00015 | $0.0006 | -| codex (o1) | $0.015 | $0.060 | -| gemini (1.5-pro) | $0.00125 | $0.005 | -| gemini (1.5-flash) | $0.000075| $0.0003 | -| pi | $0.003 | $0.015 (blended default; override per route) | -| ollama / shell | $0.00 | $0.00 | - -**Override per project** by adding to `.clk/config/providers.json`: - -```jsonc -"providers": { - "pi": { - "type": "pi", - "pricing": { "input_per_1k": 0.002, "output_per_1k": 0.008 } - } -} -``` - -Or per model: - -```jsonc -"pricing_by_model": { "openrouter/free": { "input_per_1k": 0.0, "output_per_1k": 0.0 } } -``` - -`/status` prints the per-provider breakdown so you can see which -provider is eating the budget. Updated lazily from the same numbers -the title bar shows. - -### Robustness-loop multipliers - -The robustness loops (see **Robustness loops**) trade tokens for -quality. Use this table to pick a regime: - -| Knob | Worst-case multiplier per affected dispatch | Recommended starting point | -|----------------------------------------|--------------------------------------------------------------------------|----------------------------| -| `robustness.auto_consensus` | `off` → ×1; `on_careful` → ×(N+1) on careful stages only; `always` → ×(N+1) on every dispatch (where N = `consensus.max_samples`, default 6) | `on_careful` (default) | -| `robustness.auto_refine` | `off` → ×1; `careful_only` → ×(1 + 1 worker revision + 1 critic) on careful stages; `all` → that on every producing stage | `all` (default — every producing stage refines) | -| `robustness.max_quality_retries` | At most this many extra dispatches when a response fails the quality check; 0 disables | 4 (default) | -| `robustness.refine_max_rounds` | Cap on critic↔worker round-trips inside a refine loop | 10 (default) | -| `robustness.debate` | `off` → ×1; `careful_only` → an adversarial panel (one critic per lens) on careful stages; `all` → on every producing stage | `careful_only` (default) | -| `robustness.debate_lenses` | Adversarial lenses (one parallel critic each) — more lenses = more critic dispatches per round | `[correctness, security, simplicity]` | -| `robustness.debate_max_rounds` | Cap on debate rounds (panel critique + worker revision) per stage | 2 (default) | -| `robustness.max_qa_depth` | Cap on inter-agent Q&A chain depth (each peer answer can ask one peer) | 3 (default) | -| `robustness.plateau_window` | How many no-improvement Ralph/autoresearch iterations before escalation | 3 (default) | -| `robustness.plateau_action` | `off` disables adaptive loop termination entirely | `escalate_then_reframe` | - -Cost-minimal regime (closest to legacy CLK behavior, no extra tokens): - -```jsonc -"robustness": { - "auto_consensus": "off", - "auto_refine": "off", - "max_quality_retries": 0, - "plateau_action": "off" -} -``` - -Cost-maximal "lean into the loop" regime (every dispatch fans out, -critic gates every careful stage, plateau detection on, Q&A protocol -fully open): - -```jsonc -"robustness": { - "auto_consensus": "always", - "auto_refine": "all", - "max_quality_retries": 3, - "refine_max_rounds": 4, - "plateau_action": "escalate_then_reframe" -} -``` - -## Pi extension - -A native [pi.dev](https://pi.dev) extension that brings the full CLK -orchestration model — dynamic casting, stochastic consensus, Ralph -refinement, and Karpathy-style autoresearch — into Pi behind a single -`/clk` command. No Python harness required at runtime. - -The TypeScript extension now ports the harness's response-quality -scoring and consensus fan-out as **real tools** (`clk_consensus`, -`clk_subagent_quality`, `clk_autoresearch`, `clk_ralph`) rather than -relying on chief compliance — every parallel sample is scored by the -same rules `clk_harness/orchestration/response_quality.py` uses, the -winner is picked in code, and Ralph branches are created by the tool so -the protocol can't be skipped. - -It also ports the **supervise loop** as a run watchdog: every chief -turn that ends without `clk_done` gets re-prompted with the run state, -consecutive no-progress turns trigger a one-shot stall-rescue prompt, -and a cycle cap bounds token spend — so a run keeps iterating without -the user babysitting it. `clk_merge`/`clk_done` accept `validate` shell -commands and **refuse** on a non-zero exit, and `clk_ralph` refuses a -fourth identical attempt after three consecutive reverted iterations -(plateau guard). See [`pi-extension/README.md`](pi-extension/README.md) -for the full tool reference, state layout, error handling, and -customisation notes. - -**Requirements:** Pi on `PATH`; tmux on `PATH`; Git on `PATH`. - -**Install:** - -| Option | Command | When to use | -|--------|---------|-------------| -| Quick test | `pi -e /path/to/CognitiveLoopKernel/pi-extension/src/index.ts` | Try it out; reloads on `/reload` | -| Project-local | `mkdir -p .pi/extensions && ln -s /path/to/CognitiveLoopKernel/pi-extension .pi/extensions/clk` | Version-controlled per project | -| Global | `mkdir -p ~/.pi/agent/extensions && ln -s /path/to/CognitiveLoopKernel/pi-extension ~/.pi/agent/extensions/clk` | Available in every Pi session | - -**Commands:** - -| Command | Effect | -|---------|--------| -| `/clk ` | Capture the idea and hand off to the chief. The watchdog keeps the chief iterating until `clk_done`. | -| `/clk-resume` | Continue an interrupted run (session restart, abort, or watchdog stall-stop) from persisted state with a fresh stall budget. | -| `/clk-abort` | End the active run. State is preserved; `/clk-resume` continues it later. | -| `/clk-help` | List every CLK slash command, every orchestration tool the chief uses, and the active safety nets. | -| `/clk-doctor` | Health-check tmux, git, the workspace `.clk/` layout, the pre-push hook, and (when a remote exists) the count of local commits not yet pushed. | -| `/clk-undo` | Preview the last CLK commit; `/clk-undo confirm` creates a revert commit on top of it. | - -**Orchestration tools the chief uses (you don't call these directly):** - -| Tool | Purpose | -|---|---| -| `clk_cast` | Persist a roster of project-specific specialist roles. | -| `clk_subagent` | Raw single-subagent dispatch via a detached tmux pi session. | -| `clk_subagent_quality` | One subagent + automatic repair-preamble re-rolls on quality failures. | -| `clk_consensus` | Fan out N parallel samples (default 3, max 6), score each, return the winner plus every candidate's score. | -| `clk_autoresearch` | Bounded researcher + critic alternation; each iteration recorded on the progress log. | -| `clk_ralph` | Create a `ralph/` branch and run a consensus fan-out in one call; chief then calls `clk_merge` or `clk_revert`. Refuses a 4th attempt after 3 consecutive reverts (plateau guard) until the chief acknowledges with a different approach. | -| `clk_branch` / `clk_merge` / `clk_revert` / `clk_checkpoint` | Git plumbing for the Ralph iteration cycle. `clk_merge({ validate })` runs the command first and refuses the merge on a non-zero exit. | -| `clk_progress` | Append a one-line entry to `.clk/state/progress.md`. | -| `clk_done` | Mark the run complete and write `.clk/state/done.md`. `clk_done({ validate: [...] })` refuses completion while any command fails. | - -**Optional env vars:** - -| Variable | Effect | -|---|---| -| `CLK_GITHUB_PUSH_ON_COMMIT=true` | After every `clk_checkpoint` and `clk_merge`, run `git push origin HEAD` best-effort and surface an `↑N` ahead counter if the push fails. Same env var as the Python TUI. | -| `CLK_STALL_CAP` | Consecutive no-progress chief turns before the watchdog's one-shot stall-rescue prompt (default 3). | -| `CLK_MAX_AUTO_CONTINUES` | Hard cap on watchdog auto-continuations per run (default 100) — the extension's `supervise.max_cycles`. | - -A typical session: - -```text -> /clk a local-first journaling app that summarizes my week -[CLK run started. The chief is taking over.] -[chief casts engineer, ux_writer, summarizer, qa] -[chief calls clk_consensus({agent:"architect", samples:3, task:"... storage design ..."})] -[harness fans out 3 parallel tmux pi subagents, scores each, returns the winner] -[chief calls clk_autoresearch({question:"sync model: append-only vs CRDT?"})] -[chief calls clk_ralph({iterationName:"iter-1-mvp", agent:"engineer", task:"... build MVP ..."})] -[chief calls bash: pytest -q] -[chief calls clk_merge: "ralph win: MVP capture+persist+summarize"] -[chief calls clk_done: "MVP runs; tests pass; README + deploy plan present"] -``` - -## Layout - -The package itself: - -``` -clk_harness/ - api.py # FastAPI REST API server - _api_launcher.py # background daemon thread launcher (auto-start on CLI) - _api_shim.py # console-script shim for clk-api (guards ImportError) - cli.py # argparse entrypoint - config.py # paths, default configs, JSON load/save - git_ops.py # init, commit, revert, status helpers - providers/ # claude, codex, pi, ollama, shell adapters - orchestration/ # agent runner, workflow runner, ralph loop (refinement + autoresearch) - templates/ # bundled prompts and workflows - utils/ # logging -scripts/ - clk # launcher (prefers .clk/venv/bin/python) - install_local.sh # creates .clk/venv and installs PyYAML - run_loop.sh # convenience wrapper around clk loop - run_all_tests.sh # orchestrator: build + test in ephemeral Docker -tests/ # pytest regression suite (CI-gated) -user_tests/ # pytest end-to-end suite (drives CLI + REST API) -pi-extension/ # standalone Pi extension (TypeScript) - src/ - index.ts # /clk + /clk-resume + /clk-help + /clk-doctor + /clk-undo, - # session lifecycle + watchdog wiring - prompts.ts # the chief's operator's manual - tools.ts # clk_cast / clk_progress / clk_checkpoint / clk_branch / - # clk_merge / clk_revert / clk_consensus / clk_subagent_quality / - # clk_autoresearch / clk_ralph / clk_done - watchdog.ts # supervise loop: continue → stall rescue → stop ladder - validate.ts # shell validation gate for clk_merge / clk_done - subagent.ts # raw clk_subagent — spawnSubagent() exposed for consensus - consensus.ts # dispatchWithQuality + runConsensus (port of agent.py) - quality.ts # scoreResponse + repairHint + progressSignal - # (port of response_quality.py) - git.ts # checkpoint, branch, merge, revert + hasRemote / commitsAhead / - # pushBestEffort (port of git_ops.py auto-push helpers) - state.ts / abort.ts / errors.ts / types.ts - tests/ # node --test suites covering every file in src/ -docs/ - REST_API.md # full REST API reference -``` - -The harness state, written by `clk init` and grown by every command: - -``` -.clk/ - config/ - clk.config.json # project-wide config (incl. casting + recovery caps) - providers.json # provider registry + active provider - agents.json # agent -> prompt + provider mapping (mutable) - workflows/*.yaml # Archon-style workflows (chief authors per project) - prompts/ # editable prompt templates (one per agent; - # dynamic roles get a generated file here) - state/ - idea.json # captured idea - system_brief.md # initial brief - prd.json # product manager output - progress.md # human-readable timeline - decisions.md # decisions log - experiments.jsonl # per-iteration outcomes - agent_memory.jsonl # all agent invocations (incl. token usage) - casting.log # JSONL of every roster decision (add/update/remove) - done.md # written only when completion criteria met - logs/ - activity.jsonl # detailed agent activity log - session.log # mirror of the TUI status pane - -.log # per-command log files - runs/ # per-invocation prompt + response capture - tools/ # locally-cloned external tools (e.g. pi) - venv/ # local python venv - backups/ # safety copies of overwritten files (per run) -``` - -## Providers - -| Provider | Detection | Notes | -|-------------|------------------------------------------| -`shell` | always available | dummy; echoes prompts and writes stub files. Use for tests, CI, dry runs. | -| `claude` | `claude` on PATH | runs `claude --print` non-interactively. Add `"args": ["--print", "--output-format", "json"]` to `providers.json` to get real token counts. | -| `codex` | `codex` on PATH | runs `codex exec`. | -| `gemini` | `gemini` on PATH | runs the Google Gemini CLI; prompt fed on stdin. | -| `pi` | `pi` on PATH or `.clk/tools/pi/bin/pi` | pi.dev terminal harness; supports model selection, OpenRouter, and any API-key provider. See below. | -| `ollama` | TCP reachable at `endpoint` | local-only LLM via HTTP. **Use a ≥14B model** (e.g. `qwen3:14b`) — see [Ollama provider](#ollama-provider) for why. | -| `openwebui` | TCP reachable at `endpoint` | any OpenAI-compatible server. Configure `endpoint`, `api_key`, `model` in `providers.json`; kickoff offers a numbered model picker fetched from `/api/models`. | - -`./scripts/clk providers` prints availability as JSON. Customize per -provider in `.clk/config/providers.json`. - -### Authentication: CLI vs API key - -For the CLI-driven providers (`claude`, `codex`, `gemini`) you can -choose how authentication works at kickoff: - -- `CLK_AUTH_MODE=cli` (default) — spawn the provider's local CLI as a - subprocess and trust whatever auth that CLI already has. If you've - run `claude login` / `codex login` / Gemini sign-in, no API key is - required and kickoff will *not* prompt for one. Persisted to - `providers.json` as `"mode": "cli"`. -- `CLK_AUTH_MODE=apikey` — call the upstream HTTP API directly (no - local CLI is spawned at all). Kickoff prompts for the standard env - var (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GEMINI_API_KEY` / - `GOOGLE_API_KEY`) and stores it in `providers.json` along with - `"mode": "api"`. Each provider has a built-in HTTP client (Anthropic - Messages, OpenAI Chat Completions, Gemini `generateContent`). - -The other providers don't need this knob: `shell` and `ollama` are -local, `openwebui` uses an explicit bearer token, and `pi` has its own -authentication model described below. - -### Ollama provider - -Ollama is local and free — no API key, no rate limits — which makes -it tempting to default to. The catch is that **CLK asks the chief to -emit machine-parseable YAML workflows**, and small open-weight models -(≤8B parameters) are inconsistent at this. Specifically, the chief -will occasionally produce a `PROPOSE_WORKFLOW` block where a list -item contains an unquoted colon (e.g. `[type:finding, -stage:create_file]`), which YAML can't parse. - -What you'll see when this happens: - -``` -[workflow] PROPOSE_WORKFLOW parse failed: mapping values are not -allowed here :: keeping prior workflow -[supervise] cycle N/M no progress (workflow still has zero new stages) -``` - -The harness handles this safely — it detects the bad YAML, refuses -to clobber the existing workflow file, falls back to the bundled -engineering template, and the supervise loop keeps the run alive -until its cap. But the visible symptom is a loop that "spins" without -forward progress, which is frustrating. - -**Recommendation: use `qwen3:14b` or larger as the minimum.** It -follows the structured-output instructions reliably enough that the -chief's proposals parse on the first try. Pull it with: - -```bash -ollama pull qwen3:14b -``` - -Other ≥14B options that work well: `llama3.1:70b`, `qwen2.5-coder:32b`, -`deepseek-r1:14b`. Models ≤8B (`llama3.2`, `gemma2`, `qwen2.5:7b`, -`phi3`) are fine for chat but flaky for workflow generation — they'll -get through some cycles cleanly but fail the YAML contract often -enough that the loop won't make steady progress. - -Memory rule of thumb: a 14B Q4 model needs ~10 GB of RAM/VRAM; 32B -needs ~20 GB; 70B needs ~40 GB. The setup wizard's ollama section -streams `ollama pull` progress so you can see download size before -it lands. - -### Pi provider - -`pi` (from [pi.dev](https://pi.dev)) is an extensible terminal -harness. CLK drives it as a subprocess, piping the prompt on stdin and -capturing stdout as the agent response. - -**Model selection** - -Pass a model to `pi` via `CLK_PI_MODEL`: - -```bash -CLK_PI_MODEL=openrouter/free # free tier via OpenRouter -CLK_PI_MODEL=openrouter/auto # let OpenRouter pick the best available free model -CLK_PI_MODEL=anthropic/claude-3-5-sonnet # specific model via OpenRouter -``` - -Leave `CLK_PI_MODEL` blank to use pi's own active profile or default. -The value is forwarded to pi as `pi --model `. - -**API keys** - -Pi reads provider-specific environment variables — one per backend. -Two settings control this: - -| Setting | Purpose | -|---|---| -| `CLK_PI_KEY_TYPE` | The provider your key belongs to (default: `openrouter`) | -| `CLK_PI_API_KEY` | The actual key value | - -The harness derives the env var name by convention: -`{CLK_PI_KEY_TYPE.upper()}_API_KEY`. So: - -| `CLK_PI_KEY_TYPE` | Env var set for pi | -|---|---| -| `openrouter` | `OPENROUTER_API_KEY` | -| `openai` | `OPENAI_API_KEY` | -| `anthropic` | `ANTHROPIC_API_KEY` | -| `mistral` | `MISTRAL_API_KEY` | -| any future provider | `{NAME}_API_KEY` automatically | - -This means new providers require no code changes — just set -`CLK_PI_KEY_TYPE` to the provider name and `CLK_PI_API_KEY` to your key. - -Leave `CLK_PI_API_KEY` blank if you have already run `pi login` and pi -has its own stored credentials. - -**Interactive pi setup** - -If you need to run `pi login`, configure a profile, or verify your -setup interactively, kickoff offers to open pi's TUI before launching -the harness. You'll be prompted at the end of the pi configuration -questions during both `--setup` and a normal kickoff run (when `pi` is -on PATH). Exit pi normally when done and kickoff will continue. - -This is useful for first-time Docker sessions where pi has no stored -credentials yet: - -```bash -# Run the setup wizard — it will offer to open pi if found on PATH -./kickoff.sh --setup -``` - -Pi's own state (credentials, profiles) is stored in pi's own config -directory (e.g. `~/.pi/`) — no extra Docker volume is required for -CLK's harness state, but if you want pi credentials to persist across -container restarts, mount the pi config directory: - -```bash -docker run --rm -it \ - -v ~/.pi:/root/.pi \ - -v clk-workspace:/app/workspace \ - -e CLK_PROVIDER=pi \ - -e CLK_PI_MODEL=openrouter/free \ - -e CLK_PI_KEY_TYPE=openrouter \ - -e OPENROUTER_API_KEY=sk-or-... \ - clk "My idea" -``` - -Alternatively, pass the API key directly via `CLK_PI_API_KEY` and skip -`pi login` altogether — kickoff will set the right env var for you. - -## Layout - -The kickoff dir lays the agents' work out as a normal project tree -with all harness machinery folded under `.clk/`: - -``` -workspace/kickoff-/ - src/, tests/, README.md ... # the project the agents are building - # (agents write directly to project root) - scripts/clk # convenience launcher shim - KICKOFF.md # provenance manifest - .gitignore # hardened — blocks .env, .env.bak, *.pem, … - .git/hooks/pre-push # secret scanner; aborts on key patterns - .clk/ # ALL harness state — sandboxed off - .setup-progress # per-step resume marker for the wizard - harness/clk_harness/ # harness sources copied from parent - harness/scripts/ # original launcher / installer - harness/pyproject.toml # package metadata for pip install -e - config/ # clk.config.json, providers.json, agents.json - # each written atomically with a .bak rotation - state/ # idea.json, prd.json, decisions.md ... - # plus: - # .seen-welcome first-run banner marker - # .seen-tutorial /tutorial done marker - # .tui-active PID lock (crashed-session detection) - # configured-tools.json which tools have had configure_tool run - # session-cost.json persisted USD totals - prompts/ # per-agent system prompts - blackboard/ # cross-agent shared scratchpad (POST blocks land here) - runs/ # per-dispatch prompt + response logs - backups/ # pre-write copies of mutated files - cache/, logs/, venv/ # local-only artifacts -``` - -The repo root also adds: - -- `scripts/lib_env.sh` — shared atomic-write helpers (`env_set`, - `env_get`, `env_atomic_write`, `env_restore`) sourced by both - wizards. -- `scripts/install_tool.sh` — install + check + configure registry - for every supported tool. Used by `kickoff.sh --setup` and by the - TUI's `/install` / `/configure` commands. -- `clk_harness/pricing.py` — per-provider USD pricing table backing - the title-bar cost estimate. -- `~/.config/clk/github-token` — when present (chmod 600), used in - place of the `gh` CLI for GitHub operations. - -ACTION blocks resolve relative to the project root. The harness rejects -any path that resolves into `.clk/` so agents can't accidentally (or -intentionally) write into harness state. `run` commands cwd into the -project root. To share findings across agents, workers emit POST -blocks; the harness routes those into `.clk/blackboard/` even though -agents cannot write there directly. - -The kickoff `.gitignore` keeps `.clk/` out of git except for the -curated state files (`idea.json`, `system_brief.md`, `prd.json`, -`decisions.md`, `progress.md`, `casting.log`, `done.md`, plus the -blackboard) so `git log` in the kickoff dir tells the project's story -without harness chatter. Deleting `.clk/` resets the harness without -touching the project tree. - -## Autonomous missions - -Give CLK one objective and it drives the **whole lifecycle to a code-gated -"done" on its own** — no babysitting with separate `plan` / `run` / `loop` -commands. This is the plan→execute→evaluate→refine→iterate loop made -*reliable*: the guarantees that used to live only in prompt text are now -enforced in the harness. - -**Autonomy is the default.** `clk run` (and the TUI's first message, and the -web/REST single-prompt flow) drive the full mission. `clk mission ""` -(alias `clk auto`) is the explicit form; `clk run --once` restores the legacy -single-workflow pass. - -```bash -clk mission "a local-first journaling app that summarizes my week" -# charter → plan → discovery → product → engineering(+ralph) → validation -# → deployment → done-gate → done_granted.md -``` - -### Charter first, then a living plan - -1. **Charter** — before any work, the chief authors `.clk/state/charter.md` - (+ `charter.json`): mission statement, scope, explicit non-goals, success - criteria, constraints. The plan and the done-gate are *derived from it*, so - "done" is judged against an up-front commitment instead of drifting. -2. **Living plan** — the chief emits a `PROPOSE_PLAN` block; the harness - persists an ordered phase plan to `.clk/state/mission.json` (human mirror - `MISSION.md`). After each phase a chief **phase-gate** returns - `pass | repeat | revise | done`; `revise` re-plans the remaining phases, so - the chief can reorder, insert, or skip phases as it learns. - -Three nested loops: the per-stage **supervise/ralph** loops (unchanged) inside a -per-phase **repeat** loop inside the **phase-sequencing** loop — bounded by -`mission.max_phases` and `mission.max_total_cycles`. - -### Reliability reinforcements (enforced in code, not prose) - -- **Machine-checkable done-gate.** `ACTION:done` / `done.md` is now a *request*. - The loop only stops when `done_gate.evaluate_done_gate` passes and writes - `.clk/state/done_granted.md`. Default checks: tests green, deliverables on - disk, a `POST: qa` PASS, a ralph refinement pass, plus any file-named charter - success criteria. **Adaptive:** the tests-green check is auto-relaxed when no - real test command can be derived (docs / research / content projects), so the - gate is strict where it's meaningful but never deadlocks. Each `require_*` is - an independent switch; `done_gate.enabled: false` restores legacy behavior. -- **No-op guard.** A producing stage (engineer/ralph, or any stage with an - `outputs` contract) whose response changed **no files** is re-dispatched with - an escalating "emit ACTION blocks now" preamble — descriptions stop counting - as work. Kill: `noop_guard.enabled: false`. -- **Evaluation never silently skips.** A producing stage with no `validation:` - no longer auto-passes — the harness derives a real command from the project - shape (`pytest` / `npm test` / a `compileall` smoke). Kill: - `validation.auto_derive: false`. -- **Ungated refinement.** `robustness.auto_refine` defaults to `all`, so every - producing stage gets a critic pass without the chief having to mark it - `careful`. -- **Deliberation — the team thinks.** Producing dispatches get a - self-reflect preamble and an invitation to ask peers - `POST: question TO: URGENCY: blocking`; a phase gate cannot `pass` - while a blocking question is unanswered. Kill: `deliberation.enabled: false`. -- **Execution trace.** Structured `[clk:charter|plan|phase-start|phase-gate:*|done]` - commits land at every boundary, so the git log *is* the audit trail. Kill: - `mission.commit_trace: false`. -- **Observability.** Each cycle prints a one-line summary and writes a - `loop_cycle_summary` event to `.clk/logs/activity.jsonl`, e.g. - `cycle 3/60 | phase engineering | stages 5 (4 ok) | actions 7 | refine 1r | eval FAIL(2) | done-gate REJECT(no_qa_pass)`. - -### Mission config (`clk.config.json`) - -```jsonc -"mission": { "max_phases": 12, "max_iterations_per_phase": 3, - "max_total_cycles": 60, "phase_gate": true, - "refine_required": true, "charter_first": true, - "commit_trace": true, "telemetry_stdout": true, - "default_phases": ["discovery","product","engineering","validation","deployment"] }, -"done_gate": { "enabled": true, "require_tests_green": true, - "require_deliverables": true, "min_deliverable_files": 1, - "require_qa_pass": true, "require_ralph_pass": true, - "forbid_todo_markers": false }, -"noop_guard": { "enabled": true, "max_redispatch": 2, - "producing_agents": ["engineer","ralph"] }, -"deliberation": { "enabled": true, "encourage_questions": true, - "require_open_questions_resolved": true, - "self_reflect_preamble": true, "min_debate_rounds": 1 } -``` - -Every knob above is also overridable from the environment via the matching -`CLK_MISSION_*`, `CLK_DONE_GATE_*`, `CLK_NOOP_GUARD_*`, and -`CLK_DELIBERATION_*` variables (see `.env.example`; `kickoff.sh` maps them into -`clk.config.json`). CLI overrides: `clk run --max-phases N --max-cycles M`, -`clk run --once`, `clk mission "" --resume` (continue a persisted plan). - -## Chief supervisor loop - -The default `engineering` workflow ends with a `supervise` stage where -the chief evaluates whether the user's prompt has been fully addressed. -The chief either: - -- emits `ACTION: done` with a one-line reason — writes - `.clk/state/done.md` and terminates the loop, or -- emits `PROPOSE_WORKFLOW` with the next iteration's stages — the - workflow runner picks them up and runs another cycle. - -The prompts enforce an explicit asymmetry: a **low bar to continue** -(any single trigger — missing tests, no ralph pass on the latest -output, open TODOs, stale docs, any nameable improvement — starts the -next cycle immediately) and a **high bar to stop** (`ACTION: done` -requires every done-checklist item: deliverables on disk, tests -passing, a QA PASS, a ralph refinement pass, docs updated). So no agent -is ever truly "done" until the chief proves completion. Capped at -`clk.config.json::supervise.max_cycles` (default 100). - -Stall handling: a cycle with no commits, no file writes, and/or an -explicit `PROGRESS: no` self-report counts against -`supervise.max_consecutive_no_progress` (default 8). Hitting the cap -dispatches the chief once in **stall-rescue** mode (restructure the -plan, unblock, or justify done) before the loop gives up — disable via -`supervise.stall_rescue: false`. - -## Dynamic agents (casting) - -The harness ships with three baseline agents that cannot be removed: - -- `chief` — decomposes objectives, casts the team, authors workflow YAML. -- `qa` — default validator. -- `ralph` — drives both the Ralph refinement loop and Karpathy-style - autoresearch cycles; the mode is inferred from the current project state. - -Everything else is dynamic. On the first user message, the chief is -auto-dispatched with the captured idea and casts the project-specific team, -including `engineer` when an implementer is needed (e.g. `data_steward`, -`ml_evaluator`, `api_contract`, `ux_writer`, `security_auditor`). - -The name `engineer` is reserved: the harness actively rejects any attempt -to create `engineering`, `coder`, `developer`, or other aliases, and -reports the denial directly to the chief via its `$casting_feedback` context -so it learns to use `engineer` directly. Each role decision is -applied immediately and persisted to `.clk/config/agents.json` plus -`.clk/state/casting.log` (JSONL, one entry per add/update/remove). - -Type `/cast` in the TUI to force a re-cast at any time, or run -`clk cast` from the CLI. To inspect or edit by hand: -`clk roles list|add --name X --role "..."|remove --name X`. - -Agents communicate via a **blackboard** at `.clk/blackboard/` — short -markdown POST blocks each agent emits at the end of its run, filtered -into peers' prompts based on each stage's `inputs:` selectors. -Directed clarifying questions are a special POST type -(`POST: question TO: URGENCY: blocking`) routed inline by the -harness — see **Robustness loops** for the protocol details and depth -caps. - -## Action protocol - -Agents drive real changes by emitting `ACTION:` blocks the harness -parses and applies — descriptions alone do nothing. Supported kinds: - -- `ACTION: write` / `edit` / `append` / `delete` — file mutations - (paths must resolve inside the project root; originals are backed up - to `.clk/backups//`). Paths are resolved **chroot-style**: a - leading `/` maps to the project root and a fully-qualified workspace - path has the root prefix stripped, so agents that emit absolute paths - don't silently lose their work. Escapes (`../`) and `.clk/` stay - rejected. -- `ACTION: run` — shell command, runs in project root, output captured - to the log; rejects `sudo` and obvious-foot-gun patterns. -- `ACTION: done` — writes `.clk/state/done.md`, signaling the loops to - stop. - -Every agent run that mutates files produces an immediate structured -git commit (`[agent] objective` with files, commands, token totals in -the body). A cap from `clk.config.json::validation.max_files_per_batch` -(default 25) limits damage from a runaway agent. - -## Self-healing on unmet deps - -When a workflow stage's dependencies fail, the harness dispatches the -chief in *recovery mode* with the exact failure reasons (agent error, -validation output) and asks them to either re-cast the workflow, -emit `ACTION` blocks that fix the upstream failure, or `PROPOSE_ROLE` -a specialist that can. Capped at 3 recovery passes per stage -(configurable via `clk.config.json::recovery::max_per_stage`). - -Two more recovery paths run automatically: - -- **Unmet outputs contracts.** When a stage's declared `outputs:` keys - never land in any POST block's `PRODUCES` line, the chief gets a - recovery dispatch to fill the gap (re-dispatch the worker, post a - substitute, or accept it) instead of letting downstream stages consume - missing inputs. Toggle via `recovery.dispatch_on_unmet_outputs`. -- **Failed validations keep the work.** A failed stage validation no - longer hard-resets the workspace by default — the failure is recorded - and later cycles repair forward, so batch-committed files stay on disk - and visible in the Files tab. Policy via - `validation.rollback_on_failure`: `never` | `careful` (default — only - `careful: true` stages roll back) | `always` (legacy). When a rollback - does run, the discarded work is first preserved behind a - `refs/clk/rollbacks/-` ref so it stays recoverable in git. - -This section is about *dependency and stage* failures. *Content* -failures — empty, malformed, or low-confidence agent output that -nonetheless returned `ok=True` — are handled by the response-quality -re-dispatch loop documented in **Robustness loops** above. - -## Workflows - -YAML workflows live in `.clk/config/workflows/`. The default -`engineering.yaml` is intentionally minimal (chief → engineer → qa); -the chief overwrites it on first cast with a project-tailored cycle. -The bundled scaffolds: - -- `discovery.yaml` - validate problem, users, landscape. -- `product.yaml` - PRD + technical architecture. -- `engineering.yaml` - baseline cycle; chief replaces this per project. -- `validation.yaml` - drive toward a green test suite. -- `deployment.yaml` - deployment recipe + checklist. -- `ralph_loop.yaml` - single Ralph iteration (use `clk loop` to repeat). - -Stage schema: - -```yaml -- id: implement - agent: engineer - objective: Implement the smallest vertical slice. - depends_on: [architect] - validation: "pytest -q" - commit: true -``` - -When `validation` is set, the command must exit 0 before the harness -will commit. Failed validations leave the working tree untouched (and -in the Ralph loop, are reverted to the pre-iteration HEAD). - -## Loops - -Ralph runs in two modes (selected automatically based on project state, -or forced via `/loop`): - -- **Refinement mode (`/loop ralph N`, default).** Each iteration: ralph - picks one measurable improvement, the engineer implements it, QA - validates, and the harness commits or reverts. -- **Autoresearch mode (`/loop autoresearch N`).** Each iteration: ralph - surveys state, picks the highest-value open question, designs and runs - a small experiment, and records the learning regardless of pass/fail. - -Both modes respect `max_iterations` and stop early when -`.clk/state/done.md` is created. Both also auto-detect plateau and -regression and adapt — see **Robustness loops** below. - -## Robustness loops - -CLK leans into the loop: every dispatch is wrapped in self-correcting -behavior so the harness does not just accept the first thing a -sub-agent returns. This section is a single index of every loop the -harness runs — old and new — with the config knob that tunes each -one and the activity-log event you can grep for in `.clk/logs/`. - -All knobs live under `clk.config.json::robustness.*` (and the -parallel `CLK_ROBUSTNESS_*` env-var family — see `.env.example`). -Every layer has an off-switch so you can throttle cost. - -### 1. Provider retry (existing) - -Transient provider errors (rate limits, timeouts, "no endpoints -available", HTTP 429) are retried with exponential backoff before the -response surfaces at the workflow layer. - -- Code: `clk_harness/orchestration/agent.py::AgentRunner._should_retry_provider` -- Config: `clk.config.json::provider_retry.{max_retries, backoff_s}` -- Logged events: `provider_attempt`, `provider_retry` -- Kill switch: set `provider_retry.max_retries: 0` - -### 2. Stage retry (existing) - -When a workflow stage fails with a retryable provider error after the -inner provider-retry budget is exhausted, the workflow runner retries -the entire stage with a larger backoff before giving up on the stage. - -- Code: `workflow.py::WorkflowRunner._is_retryable_stage_error` -- Config: `clk.config.json::provider_retry.{stage_max_retries, stage_backoff_s}` -- Logged events: `workflow_stage_retry` -- Kill switch: set `provider_retry.stage_max_retries: 0` - -### 3. Supervise cycles (existing) - -The chief's `supervise` stage decides whether the user's prompt has -been fully addressed; if not, it emits a `PROPOSE_WORKFLOW` and the -whole workflow re-runs. See **Chief supervisor loop** for the full -description. - -- Config: `clk.config.json::supervise.max_cycles` (default 20) -- Kill switch: set `supervise.max_cycles: 1` - -### 4. Recovery on unmet deps (existing) - -When a stage's dependencies fail, the chief is dispatched in recovery -mode to re-cast, remediate, or accept the gap. See **Self-healing on -unmet deps**. This handles *dependency* failures; *content* failures -are handled by Layer 6 below. - -- Config: `clk.config.json::recovery.max_per_stage` (default 3) - -### 5. Review & checkpoint stages (existing) - -Stages marked `phase: review` automatically receive a chief-authored -review prompt containing the upstream stages' POST blocks, and the -chief emits a verdict (continue / redirect / abort). Stages marked -`careful: true` add a post-stage checkpoint and (when configured) -trigger meta-prompt drafting on dispatch. - -Example: - -```yaml -- id: design_spec - agent: architect - careful: true - outputs: [design_brief] - objective: Draft the API contract. -- id: review_design - agent: chief - phase: review - depends_on: [design_spec] -``` - -- Config: `clk.config.json::review.per_stage` (apply to *every* stage) -- Logged events: `workflow_checkpoint`, `consensus_coalesced` - -### 6. Auto-quality re-dispatch (new) - -After every dispatch, the response is scored against -`response_quality`: - -- empty / sub-threshold text -- malformed `ACTION:` or `POST:` blocks -- missing declared `outputs` (the stage's contract keys) -- self-reported low confidence (`CONFIDENCE: <0..1>` parsed from the - response) -- refusal patterns (treated as not-recoverable — surfaces to the - chief instead of retrying blindly) - -Recoverable failures are re-dispatched with a repair preamble that -quotes the specific reasons back to the worker, up to -`robustness.max_quality_retries`. On the final retry, when -`auto_consensus` is not `"off"`, the dispatch escalates to a -stochastic consensus fan-out rather than another single-shot retry. - -- Code: `orchestration/response_quality.py`, `agent.py::_dispatch_with_quality_loop` -- Config: `robustness.{max_quality_retries, min_response_chars}` -- Logged events: `agent_quality_retry`, `agent_quality_final` -- Kill switch: `robustness.max_quality_retries: 0` - -### 7. Stochastic consensus, opt-in + automatic (existing + new) - -Any agent can emit `PROPOSE_CONSENSUS` to fan a question into N -independent samples; the harness runs them in parallel, logs them, -and dispatches the chief to coalesce. New in this release: -`robustness.auto_consensus` makes the fan-out automatic. - -| `auto_consensus` | Behavior | -|--------------------------|--------------------------------------------------------------------------| -| `off` | Only `PROPOSE_CONSENSUS` triggers fan-out (legacy behavior). | -| `on_careful` *(default)* | Stages marked `careful: true` fan out automatically. | -| `always` | Every non-chief dispatch fans out (×N samples — most expensive setting). | - -Cost: a fan-out costs roughly N + 1 dispatches (N samples + 1 chief -coalescing). Caps at `consensus.max_samples` (default 6) and -`consensus.max_parallel` (default 4). - -- Logged events: `consensus_started`, `consensus_sample_dispatch`, - `consensus_samples_completed`, `consensus_coalesced` -- Kill switch: `robustness.auto_consensus: "off"` - -### 8. Inter-agent clarifying Q&A (new) - -Agents emit: - -``` -POST: question -TO: architect -URGENCY: blocking -BODY: -Are user IDs opaque strings or integers? -END_POST -``` - -With `URGENCY: blocking`, the harness dispatches the target peer -immediately to answer; the peer's `POST: answer` lists the question -id in its `CONSUMES`, and the asker sees the answer in the next -blackboard digest. `URGENCY: async` records the question for the -chief to schedule in a later cycle. - -Chain depth is capped at `robustness.max_qa_depth` (default 3) so a -question can't trigger an unbounded chain of clarifications. - -- Code: `agent.py::_route_blocking_questions`, `blackboard.py` -- Config: `robustness.{max_qa_depth, qa_parallel_judges}` -- Logged events: `qa_dispatch`, `qa_chain_capped`, `qa_chain_cycle`, - `qa_target_unknown` -- Kill switch: omit the `TO:` field in your `POST: question` blocks; - no protocol-level off-switch (Q&A is opt-in per post). - -### 9. Critic-judge refinement (new) - -Stages may declare a refinement loop that threads a critic between -worker rounds. The critic scores the worker's output 0..1; if below -the accept threshold, the worker is re-dispatched with the critic's -feedback until accept or `max_rounds` is reached. - -```yaml -- id: design_spec - agent: architect - refine: - critic: critic - max_rounds: 4 - accept_threshold: 0.8 - objective: Draft the spec. -``` - -When the stage has no explicit `refine:` block, `robustness.auto_refine` -decides whether one round runs anyway: - -| `auto_refine` | Behavior | -|----------------------------|---------------------------------------------------------| -| `off` | Only stages with `refine:` use the inner loop. | -| `careful_only` | Stages marked `careful: true` get one critic pass. | -| `all` *(default)* | Every non-chief, non-qa, non-critic stage gets a critic pass — so refinement fires without relying on the chief marking stages careful. | - -The critic's last two lines must be: - -``` -VERDICT: accept # or `revise` -SCORE: <0..1> -``` - -- Code: `workflow.py::WorkflowRunner._refine_loop` -- Config: `robustness.{auto_refine, refine_max_rounds, - refine_accept_threshold}` -- Logged events: `refine_critic_verdict` -- Kill switch: `robustness.auto_refine: "off"` AND remove any - `refine:` blocks from your workflow YAML. - -### 10. Adaptive Ralph & autoresearch (new) - -Both loops record every iteration's outcome to -`.clk/state/experiments.jsonl`. After `robustness.plateau_window` -consecutive iterations without measurable improvement, the loop: - -1. **Escalates** — the next iteration's dispatches carry - `careful=true` in their extra, which (via Layer 7) fans them into - stochastic consensus. -2. **Reframes** — the chief is dispatched with a "plateau dispatch" - prompt asking it to re-cast roles or re-author the workflow with a - qualitatively different approach (new metric, new experiment - family) rather than another marginal tweak. -3. **Terminates gracefully** — if escalation + reframe fail to break - the plateau across two more iterations, `done.md` is written with - reason "plateau" rather than burning the full iteration budget. - -Regression (last iteration failed after at least one earlier success -in the window) triggers an additional critic dispatch on the failing -diff before the next plan, so the next iteration starts from an -informed view of what broke. - -Autoresearch additionally gains an evaluator gate (previously only in -Ralph): if the analyst's writes break the build, the working tree is -reverted rather than committed. - -Both loops also short-circuit when a planner or surveyor returns -empty / unrecoverable output; rather than commit garbage, the -iteration is recorded with `improved=False`. - -- Code: `ralph_loop.py::RalphLoop`, `autoresearch_loop.py::AutoresearchLoop` -- Config: `robustness.{plateau_window, plateau_action}` - (`escalate_then_reframe` | `escalate_only` | `reframe_only` | `off`) -- Logged events: `ralph_plateau_detected`, `ralph_plateau_escalate`, - `ralph_plateau_terminated`, `ralph_regression_detected`, - `ralph_iteration_skipped_low_quality`, - `autoresearch_step_skipped_low_quality`, `autoresearch_revert` -- Kill switch: `robustness.plateau_action: "off"` - -### 11. Adversarial debate panel (new) - -Instead of a single critic, a stage can be refined by a **panel of -adversarial critics** that each take a distinct lens, try to *break* the work, -and engage with each other across rounds before the worker revises. This -catches failure modes a single reviewer misses (a correctness reviewer won't -think like a security reviewer). - -Each round fans out one critic per lens **in parallel** (reusing the -`critic` agent with a lens-specific adversarial prompt); the worker output is -kept only when a **majority of lenses accept** and the mean score clears -`refine_accept_threshold`. Otherwise the combined critiques drive a revision, -and the next round's critics see the prior panel transcript (posted to the -blackboard as `post_type: debate`) so they can reinforce, refute, or concede -each other's points. Bounded by `debate_max_rounds`. - -```yaml -- id: implement - agent: engineer - refine: - mode: debate - critics: [correctness, security, performance] - max_rounds: 2 - objective: Implement the slice. -``` - -When a stage has no explicit `refine: {mode: debate}`, `robustness.debate` -decides whether the panel runs anyway (`off` | `careful_only` *(default)* | -`all`). The debate panel takes precedence over the single-critic loop -(layer 9) when both would apply. Built-in lenses: `correctness`, `security`, -`simplicity`, `performance`, `robustness`, `tests`, `ux` (configure via -`robustness.debate_lenses`). - -- Code: `workflow.py::WorkflowRunner._debate_loop` / `_dispatch_lens_critic` -- Config: `robustness.{debate, debate_lenses, debate_max_rounds}` -- Logged events: `debate_round` -- Kill switch: `robustness.debate: "off"` AND remove any `refine: {mode: debate}` blocks. - -### Putting it together - -A typical "careful" engineering stage now runs: - -1. Stage dispatched with `careful: true`. -2. `auto_consensus=on_careful` → N samples fan out in parallel. -3. Chief coalesces the samples. -4. `auto_refine=all` (default) → critic scores the coalesced output; - the worker is revised until critic accepts or `max_rounds`. -5. Stage validation runs. -6. Checkpoint (if enabled) — chief CONTINUE / REDIRECT / ABORT - verdict. -7. Outputs contract check; warn if any declared key was not posted. - -Tracing this in `.clk/logs/`: - -``` -grep -E '^(consensus_|refine_|workflow_checkpoint|agent_quality_)' \ - .clk/logs/activity.jsonl | jq . -``` - -## Completion criteria - -CLK considers the system "done" when `.clk/state/done.md` exists. By -convention you create it only when: - -- the MVP runs locally, -- the test suite passes, -- the README explains setup, -- a deployment plan exists, -- a deployment checklist exists, -- at least one user-facing interaction path exists. - -## Testing - -CLK ships three test suites and a one-command orchestrator that runs them -all in an ephemeral Docker container. - -| Suite | What it covers | Runner | -|------------------------|---------------------------------------------------------|--------| -| `tests/` | Unit + integration regression tests (CI-gated) | pytest | -| `user_tests/` | End-to-end CLI / REST API / `kickoff.sh` user tests | pytest | -| `pi-extension/tests/` | TypeScript Node tests for the Pi extension | npm | - -### One-command run - -```bash -# Interactive: prompts for LLM provider, API key, base URL, model. -# Builds an ephemeral Docker image, runs every suite inside, then tears -# the container down (success or failure). -./scripts/run_all_tests.sh - -# CI / scripted use — skip the prompts and use the shell provider: -./scripts/run_all_tests.sh --non-interactive - -# Single suite (no Docker, runs directly on the host): -./scripts/run_all_tests.sh --local --suite=user -./scripts/run_all_tests.sh --local --suite=ci -./scripts/run_all_tests.sh --local --suite=pi -``` - -The interactive menu asks four questions: - -1. **LLM provider** (shell / claude / codex / gemini / pi / ollama / openwebui) -2. **Auth mode** (cli vs apikey) for the CLI-driven providers -3. **API key**, base URL, model name — only for the chosen provider -4. **Confirm + go** - -All deterministic tests (CLI plumbing, REST API contract, etc.) run -against the `shell` provider regardless — they need no credentials and -always succeed. The opt-in *real-provider smoke* test -(`test_kickoff_with_user_selected_provider` in `user_tests/`) runs -kickoff.sh end-to-end with whatever provider you selected, and the -`pi-extension` runtime smoke verifies the `pi` CLI is reachable when you -chose `pi` and gave it a model + key. - -### What runs inside the Docker container - -`run_all_tests.sh` (Docker mode): - -1. Builds `clk:tests-` from the project `Dockerfile`. -2. Mounts the repo read-only at `/repo`, copies it into a writable - `/work` inside the container. -3. Runs `pytest tests/` then `pytest user_tests/` then - `npm test` inside `pi-extension/`. -4. **Always tears down** the container on exit (success, failure, or - ^C) and removes the ephemeral image, unless `--keep` is passed. - -Useful flags: - -| Flag | Effect | -|---------------------|--------| -| `--local` | Run on the host directly; no Docker daemon required. | -| `--non-interactive` | Skip all prompts; force `CLK_PROVIDER=shell`. | -| `--suite=all` | Default — run all three test directories. | -| `--suite=ci` | Only `tests/` (regression). | -| `--suite=user` | Only `user_tests/`. | -| `--suite=pi` | Only `pi-extension/tests/`. | -| `--keep` | Don't remove the container or image on exit. | -| `--no-build` | Reuse a pre-built `clk:tests-latest` image. | -| `-k ` | Forward a `-k` filter to pytest. | -| `-- ` | Pass remaining args verbatim to pytest. | - -### Running suites manually - -Each suite is just pytest / npm and can be invoked on its own: +## Quick start ```bash -# Regression suite (existing CI tests) -pip install -e ".[api,dev]" pytest pytest-asyncio httpx -pytest tests/ -v - -# User-perspective end-to-end suite (CLI subprocess + live REST API + -# real kickoff.sh runs). Uses the shell provider — no API keys needed. -pytest user_tests/ -v - -# Pi extension TypeScript suite -cd pi-extension -npm install -npm test # unit + integration tests (96 tests, ~2s) -npm run test:strict # also runs `tsc --noEmit` +pip install -e ".[api]" # from a clone; or use ./kickoff.sh from a bare clone +clk # launches the TUI; type your idea to begin ``` -The `user_tests/` suite verifies, from a real user's vantage point: - -- Every `clk` sub-command (`init`, `idea`, `cast`, `roles`, - `plan`, `run`, `loop`, `status`, `providers`, `configure`) exits - cleanly and writes the documented `.clk/` artefacts. -- All seven shipped providers register and the `shell` provider is - always available. -- The REST API serves health, capabilities, workflows, workspace CRUD, - research task creation, SSE streaming, artifact listing, path - traversal blocking, and cancellation. -- `kickoff.sh` produces a self-contained workspace dir with its own git - repo, and respects `--provider` / `CLK_PROVIDER` overrides. -- Filesystem invariants (commit history, `.clk/runs/shell-stubs/`, - per-command `.clk/logs/-.log`, etc.). +See [docs/QUICKSTART.md](docs/QUICKSTART.md) for the full walk-through +(setup wizard, `.env` defaults, CLI overrides, and the lower-level CLI), +or pick your path below. -The `pi-extension/tests/` suite verifies: +## Documentation -- `classifyError`, `withRetry`, `looksRedacted`, `isMaxTurnsResult`, - and all `recoveryHint` branches. -- `clkChiefPrimer` renders the captured idea + every CLK tool name - (`clk_cast`, `clk_subagent`, `clk_subagent_quality`, `clk_consensus`, - `clk_autoresearch`, `clk_ralph`, `clk_checkpoint`, `clk_done`). -- `scoreResponse` flags every documented failure mode (empty / refusal / - malformed ACTION / malformed POST / missing outputs / low confidence / - needs-review / missing-confidence) and `repairHint` quotes each reason - to the worker. -- `runConsensus` fans out N samples, scores them, picks the winner, caps - to `maxParallel`, and captures spawn errors without throwing. - `dispatchWithQuality` retries with a repair preamble on recoverable - failures and stops on refusal or `maxRetries`. -- `setIdea`, `setRoster`, `appendProgress`, `markDone`, `isDone` - round-trip state through `.clk/state/*.json` and `progress.md`. -- The `git` wrapper does init, checkpoint, branch, merge, revert, - `hasRemote`, `commitsAhead`, and `pushBestEffort` correctly against a - real `git` binary (including the bare-upstream sync, the unreachable- - remote failure path, and the no-remote no-op). -- The extension's `default` export registers every documented tool - (`clk_cast`, `clk_progress`, `clk_checkpoint`, `clk_revert`, - `clk_branch`, `clk_merge`, `clk_done`, `clk_consensus`, - `clk_subagent_quality`, `clk_autoresearch`, `clk_ralph`, - `clk_subagent`) and the `/clk` slash command, and handles an - empty-idea invocation cleanly. -- `firstLineShort` returns single-line, capped output so a multi-line - idea never bleeds line 2 into the Pi status bar. - -## Customization - -- Edit prompts in `.clk/prompts/` to change agent behavior. -- Edit `.clk/config/agents.json` to bind specific agents to specific - providers (e.g. `engineer` -> `claude`, `researcher` -> `ollama`). -- Edit `.clk/config/workflows/*.yaml` to add new stages or new - workflows. Reference any new workflow with `clk run --workflow NAME`. -- `clk configure --set key=value` updates `.clk/config/clk.config.json`. +| I want to… | Read | +| ------------------------------------- | ------------------------------------------------ | +| Get running locally (Python) | [docs/QUICKSTART.md](docs/QUICKSTART.md) | +| Use the browser dashboard | [docs/WEBUI.md](docs/WEBUI.md) | +| Run in Docker / from a bare clone | [docs/KICKOFF.md](docs/KICKOFF.md) | +| Drive CLK from code (REST API) | [docs/REST_API.md](docs/REST_API.md) | +| Chat-control from my phone | [docs/TELEGRAM.md](docs/TELEGRAM.md) | +| Live in the terminal UI | [docs/TUI.md](docs/TUI.md) | +| Pick / configure an LLM provider | [docs/PROVIDERS.md](docs/PROVIDERS.md) | +| Tune cost guardrails & knobs | [docs/CONFIGURATION.md](docs/CONFIGURATION.md) | +| Understand missions & orchestration | [docs/MISSIONS.md](docs/MISSIONS.md) | +| Understand the design & layout | [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) | +| Run the test suites | [docs/TESTING.md](docs/TESTING.md) | +| See what's new in this release | [docs/CHANGELOG.md](docs/CHANGELOG.md) | ## Safety @@ -2381,12 +54,6 @@ The `pi-extension/tests/` suite verifies: - All exceptions are logged with `[location] message` and a full traceback. -## Dry-run mode - -Every loop and workflow command accepts `--dry-run`. Providers honor it -and skip side effects. Use it to preview prompt rendering and stage -ordering without writing files or committing. - ## License MIT. diff --git a/clk_harness/_api_launcher.py b/clk_harness/_api_launcher.py index 07912fc..77a04d8 100644 --- a/clk_harness/_api_launcher.py +++ b/clk_harness/_api_launcher.py @@ -16,7 +16,6 @@ import threading from typing import Optional - logger = logging.getLogger(__name__) @@ -59,6 +58,7 @@ def start_api_in_background( # Lazy import so missing dependencies do not crash the CLI. try: import uvicorn + from clk_harness.api import app, get_bind_host, get_bind_port except ImportError as exc: print( diff --git a/clk_harness/api.py b/clk_harness/api.py index e4c81b6..d7a716c 100644 --- a/clk_harness/api.py +++ b/clk_harness/api.py @@ -231,7 +231,8 @@ async def _run_task(task_id: str) -> None: # actual init marker (clk.config.json), not just that `.clk/` exists -- # endpoints like set_idea create `.clk/` subdirs, which would otherwise # trick us into skipping init and then failing with "not initialized". - from .config import Paths as _Paths, is_initialized as _is_initialized + from .config import Paths as _Paths + from .config import is_initialized as _is_initialized if command != "init" and not _is_initialized(_Paths(root=ws_path)): try: init_proc = await asyncio.create_subprocess_exec( @@ -389,7 +390,10 @@ async def list_workflows() -> Dict[str, Any]: logger.exception("Failed to load workflow templates") raise HTTPException( status_code=500, - detail={"ok": False, "error": {"code": "template_load_failed", "message": "Failed to load workflow templates."}}, + detail={ + "ok": False, + "error": {"code": "template_load_failed", "message": "Failed to load workflow templates."}, + }, ) from exc @@ -479,8 +483,8 @@ async def create_research(body: ResearchRequest) -> Dict[str, Any]: state_dir = _workspace_path(workspace_id) / ".clk" / "state" state_dir.mkdir(parents=True, exist_ok=True) (state_dir / "stop_when.txt").write_text(body.stop_when.strip(), encoding="utf-8") - except Exception: - pass + except Exception as _exc: + logger.warning("could not persist stop_when for %s: %s", workspace_id, _exc) TASKS[task_id] = task _task_handles[task_id] = asyncio.create_task(_run_task(task_id)) return {"ok": True, "task_id": task_id, "workspace_id": workspace_id} @@ -586,8 +590,8 @@ async def cancel_task(task_id: str) -> Dict[str, Any]: state_dir = _workspace_path(ws_id) / ".clk" / "state" state_dir.mkdir(parents=True, exist_ok=True) (state_dir / "cancel_requested.txt").write_text("cancel\n", encoding="utf-8") - except Exception: - pass + except Exception as _exc: + logger.warning("could not write cancel marker for %s: %s", ws_id, _exc) proc = task.get("proc") if proc is not None: try: diff --git a/clk_harness/cli.py b/clk_harness/cli.py index c0d09d4..c8078e9 100644 --- a/clk_harness/cli.py +++ b/clk_harness/cli.py @@ -2,6 +2,7 @@ Sub-commands: init - bootstrap .clk/, configs, prompts, workflows, git repo + kickoff - bootstrap a self-contained kickoff workspace (port of kickoff.sh) idea - capture an idea plan - run the discovery + product workflows run - drive the autonomous mission to a code-gated done (--once for one cycle) @@ -22,11 +23,10 @@ import sys import textwrap import threading -import traceback import venv from datetime import datetime from pathlib import Path -from typing import Dict, List, Optional +from typing import List, Optional try: from . import __version__ @@ -35,24 +35,26 @@ # empty package __init__.py) shouldn't make the whole CLI unimportable. __version__ = "0.0.0+unknown" from .config import ( - DEFAULT_CLK_CONFIG, Paths, is_initialized, load_agents_config, load_clk_config, load_providers_config, project_paths, - save_agents_config, save_json, write_default_configs, ) from .git_ops import ( add_all, - commit as git_commit, has_changes, init_repo, is_repo, ) +from .git_ops import ( + commit as git_commit, +) +from .kickoff import cmd_kickoff +from .log import close_log, get_logger, init_log_file, log, log_exception from .orchestration import ( AgentRunner, AutoresearchLoop, @@ -62,17 +64,16 @@ RoleProposal, WorkflowRunner, casting_objective, - is_baseline, list_roles, load_workflow, register_role, remove_role, render_roster_summary, ) -from .providers import available_providers, load_provider +from .providers import available_providers from .templates import PROMPTS, WORKFLOWS -from .utils.logging_utils import close_log, init_log_file, log, log_exception +logger = get_logger(__name__) # --------------------------------------------------------------------------- # helpers @@ -140,7 +141,7 @@ def _ensure_gitignore(paths: Paths) -> bool: return False # If the whole .clk/ directory is already ignored (e.g. by kickoff), # the detailed block is redundant — skip it. - existing_lines = {l.strip() for l in current.splitlines()} + existing_lines = {ln.strip() for ln in current.splitlines()} if ".clk/" in existing_lines or ".clk" in existing_lines: return False gi.write_text(current.rstrip() + "\n\n" + block, encoding="utf-8") @@ -275,7 +276,7 @@ def cmd_init(args: argparse.Namespace) -> int: ) print("CLK initialized.") - print("\n".join(" " + l for l in summary_lines)) + print("\n".join(" " + ln for ln in summary_lines)) print("\nNext: clk idea \"\"") close_log() return 0 @@ -542,8 +543,8 @@ def cmd_loop(args: argparse.Namespace) -> int: committed = sum(1 for o in outcomes if o.committed) print(f"ralph loop: {improved}/{len(outcomes)} improved, {committed} committed") else: - loop = AutoresearchLoop(paths, runner, evaluator, max_iterations=max_iter) - experiments = loop.run(dry_run=args.dry_run) + aloop = AutoresearchLoop(paths, runner, evaluator, max_iterations=max_iter) + experiments = aloop.run(dry_run=args.dry_run) committed = sum(1 for e in experiments if e.committed) print(f"autoresearch loop: {len(experiments)} experiments, {committed} committed") close_log() @@ -615,7 +616,6 @@ def cmd_tui(args: argparse.Namespace) -> int: def _build_webui(log=sys.stderr) -> bool: """Build the React web UI bundle with npm. Returns True on success.""" - import shutil import subprocess repo_root = Path(__file__).resolve().parent.parent webui_dir = repo_root / "webui" @@ -647,8 +647,9 @@ def cmd_web(args: argparse.Namespace) -> int: """ try: import uvicorn # noqa: F401 - from .api import app, get_bind_host, get_bind_port + from . import static_spa + from .api import app, get_bind_host, get_bind_port except ImportError: print( "Error: web UI dependencies not installed. Run: " @@ -688,8 +689,8 @@ def _open(): _t.sleep(1.0) try: webbrowser.open(url) - except Exception: - pass + except Exception as _exc: + logger.debug("could not open browser for %s: %s", url, _exc) threading.Thread(target=_open, daemon=True).start() import uvicorn as _uvicorn @@ -777,7 +778,12 @@ def cmd_doctor(args: argparse.Namespace) -> int: findings.append(("fail", "anthropic_key", "CLK_AUTH_MODE=apikey but ANTHROPIC_API_KEY is unset")) if active == "codex" and auth_mode == "apikey" and not _os.environ.get("OPENAI_API_KEY"): findings.append(("fail", "openai_key", "CLK_AUTH_MODE=apikey but OPENAI_API_KEY is unset")) - if active == "gemini" and auth_mode == "apikey" and not _os.environ.get("GEMINI_API_KEY") and not _os.environ.get("GOOGLE_API_KEY"): + if ( + active == "gemini" + and auth_mode == "apikey" + and not _os.environ.get("GEMINI_API_KEY") + and not _os.environ.get("GOOGLE_API_KEY") + ): findings.append(("fail", "gemini_key", "CLK_AUTH_MODE=apikey but GEMINI_API_KEY/GOOGLE_API_KEY are unset")) if not is_repo(paths.root): findings.append(("warn", "git", "no git repo at project root; auto-commit disabled")) @@ -853,8 +859,8 @@ def cmd_diag(args: argparse.Namespace) -> int: if redacted and redacted.exists(): try: redacted.unlink() - except Exception: - pass + except Exception as _exc: + logger.debug("diag: could not remove temp %s: %s", redacted, _exc) print(f"clk diag: wrote {out_path}") print("API keys are redacted; share this in your bug report.") return 0 @@ -910,6 +916,42 @@ def build_parser() -> argparse.ArgumentParser: p_init.add_argument("--name", help="Project name (defaults to directory name).") p_init.set_defaults(func=cmd_init) + p_kick = sub.add_parser( + "kickoff", + help="Bootstrap a self-contained kickoff workspace under ./workspace/ " + "(driven by .env and optional --arg overrides).", + description=( + "Bootstrap a self-contained kickoff workspace under ./workspace/. " + "Driven entirely by .env and optional --arg overrides; normal runs " + "ask no questions. If required configuration is missing, kickoff " + "prints exactly what is needed and offers to launch --setup. " + "Configuration precedence (highest-to-lowest): --arg overrides -> " + ".env file / shell env vars -> built-in defaults." + ), + ) + p_kick.add_argument("idea", nargs="?", help="Idea or problem statement.") + p_kick.add_argument("--setup", action="store_true", + help="Interactive wizard to write or update .env.") + p_kick.add_argument("--restore", action="store_true", + help="Restore .env from .env.bak (undo last --setup).") + p_kick.add_argument("--list", action="store_true", dest="list_mode", + help="List past kickoff dirs under workspace/.") + p_kick.add_argument("--clean", metavar="DURATION", + help="Delete kickoff dirs older than DURATION (e.g. 7d, 30m). " + "Always asks y/N before deleting.") + p_kick.add_argument("--provider", help="Override CLK_PROVIDER.") + p_kick.add_argument("--max-iterations", help="Override CLK_MAX_ITERATIONS.") + p_kick.add_argument("--project-name", help="Override CLK_PROJECT_NAME.") + p_kick.add_argument("--no-tui", action="store_const", const="true", + dest="no_tui_override", + help="Set CLK_NO_TUI=true (non-interactive pipeline).") + p_kick.add_argument("--tui", action="store_const", const="false", + dest="no_tui_override", + help="Set CLK_NO_TUI=false (TUI dashboard, the default).") + p_kick.add_argument("--run-install", action="store_true", + help="Set CLK_RUN_INSTALL=true.") + p_kick.set_defaults(func=cmd_kickoff) + p_idea = sub.add_parser("idea", help="Capture an idea.") p_idea.add_argument("statement", help="The idea, problem statement, or vision.") p_idea.add_argument("--title", help="Short title for the idea.") diff --git a/clk_harness/config.py b/clk_harness/config.py index 52967b0..707ac66 100644 --- a/clk_harness/config.py +++ b/clk_harness/config.py @@ -18,7 +18,6 @@ from pathlib import Path from typing import Any, Dict, Optional - CLK_DIR_NAME = ".clk" @@ -376,7 +375,11 @@ def save_json(path: Path, data: Dict[str, Any], *, backup: bool = True) -> None: # analyst.md, ...) still ship to disk as scaffolds so the chief can cast a # role with an empty PROMPT body and the existing file will be picked up. "agents": { - "chief": {"prompt": "chief.md", "provider": None, "role": "decompose objectives, cast the team, author workflows"}, + "chief": { + "prompt": "chief.md", + "provider": None, + "role": "decompose objectives, cast the team, author workflows", + }, "qa": {"prompt": "qa.md", "provider": None, "role": "test and audit changes (baseline validator)"}, "ralph": {"prompt": "ralph.md", "provider": None, "role": "drive iterative refinement and autoresearch loops"}, } diff --git a/clk_harness/git_ops.py b/clk_harness/git_ops.py index fba1c47..1ae4cf1 100644 --- a/clk_harness/git_ops.py +++ b/clk_harness/git_ops.py @@ -9,8 +9,6 @@ import shutil import subprocess -import sys -import traceback from pathlib import Path from typing import Iterable, List, Optional @@ -316,7 +314,7 @@ def changed_files_since(root: Path, sha: str) -> List[str]: ["git", "diff", "--name-only", sha, "HEAD"], cwd=root, check=True, capture_output=True, text=True, ) - return [l for l in r.stdout.splitlines() if l.strip()] + return [ln for ln in r.stdout.splitlines() if ln.strip()] except Exception as exc: log_exception("git_ops.changed_files_since", exc) return [] diff --git a/clk_harness/integrations/telegram/bot.py b/clk_harness/integrations/telegram/bot.py index bea8f1f..b099cc7 100644 --- a/clk_harness/integrations/telegram/bot.py +++ b/clk_harness/integrations/telegram/bot.py @@ -35,12 +35,9 @@ # importing this module for unit tests (which mock out the bot) does not # require the package to be installed. try: # pragma: no cover - from telegram import Update from telegram.ext import ( - Application, ApplicationBuilder, CommandHandler, - ContextTypes, MessageHandler, filters, ) @@ -170,14 +167,14 @@ async def _start_command(update, context, command: str, args_text: str) -> None: if not workspace: try: ws = await state.client.create_workspace() - workspace = ws.get("id") or ws.get("workspace_id") or ws.get("name") - state.chat_workspace[chat_id] = workspace + workspace = ws.get("id") or ws.get("workspace_id") or ws.get("name") # type: ignore[assignment] + state.chat_workspace[chat_id] = workspace # type: ignore[assignment] except Exception as exc: await _reply(update, f"No workspace and could not create one: {exc}", token=state.token) return args = [a for a in args_text.strip().split() if a] if args_text else [] try: - resp = await state.client.start_task(workspace, command, args=args) + resp = await state.client.start_task(workspace, command, args=args) # type: ignore[arg-type] task_id = resp.get("task_id") or resp.get("id") or "?" state.last_task_id = task_id await _reply( @@ -255,7 +252,11 @@ async def cmd_unsubscribe(update, context) -> None: # pragma: no cover async def cmd_workspace(update, context) -> None: # pragma: no cover state = _state(context) if not context.args: - await _reply(update, f"Current workspace: {state.workspace_for(update.effective_chat.id) or '(none)'}", token=state.token) + await _reply( + update, + f"Current workspace: {state.workspace_for(update.effective_chat.id) or '(none)'}", + token=state.token, + ) return state.chat_workspace[update.effective_chat.id] = context.args[0] await _reply(update, f"Workspace set to `{context.args[0]}`.", token=state.token) diff --git a/clk_harness/kickoff.py b/clk_harness/kickoff.py new file mode 100644 index 0000000..804239f --- /dev/null +++ b/clk_harness/kickoff.py @@ -0,0 +1,1209 @@ +"""``clk kickoff`` — bootstrap a self-contained kickoff workspace. + +Python port of the historical ``kickoff.sh`` body. The shell script is +now a thin wrapper that ``exec``s this subcommand; everything it used to +do lives here: + + * load ``.env`` (via :mod:`clk_harness.env_file`) and export it, + * apply ``--arg`` overrides, then built-in defaults, + * validate the resolved configuration (offering ``--setup`` on a TTY), + * create ``workspace/kickoff-/`` with the harness copied under + ``.clk/harness/``, its own git repo, secret-blocking ``.gitignore`` + and pre-push hook, + * apply ``CLK_*`` env-var overrides into ``.clk/config/clk.config.json`` + (:func:`apply_config_env_overrides` — the former embedded heredoc #1), + * activate the chosen provider in ``.clk/config/providers.json`` + (:func:`activate_provider` — the former embedded heredoc #2), + * drive the non-interactive idea → plan → run → loop pipeline, or hand + control to the TUI dashboard, + * host the interactive ``--setup`` wizard with per-step resume via + ``.clk/.setup-progress`` (reusing :mod:`clk_harness.env_file` for + atomic ``.env`` writes). + +Configuration precedence mirrors the shell script exactly: +``--arg overrides → .env file → shell environment vars → defaults`` +(the ``.env`` file is sourced *after* the environment, so its values win — +same as ``set -a; . .env`` did). +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import shutil +import subprocess +import sys +import time +from datetime import datetime +from pathlib import Path +from typing import Callable, Dict, List, Mapping, MutableMapping, Optional, TextIO, Tuple + +from . import env_file + +VALID_PROVIDERS = ("shell", "claude", "codex", "gemini", "pi", "ollama", "openwebui") + +# Built-in defaults, applied after .env and --arg overrides (mirrors +# kickoff.sh's `_apply_defaults`). Empty counts as unset, like `${VAR:-def}`. +_DEFAULTS: Tuple[Tuple[str, str], ...] = ( + ("CLK_PROVIDER", "shell"), + ("CLK_MAX_ITERATIONS", "10"), + ("CLK_PROJECT_NAME", "clk-app"), + ("CLK_RUN_INSTALL", "false"), + ("CLK_NO_TUI", "false"), + ("CLK_AUTH_MODE", "cli"), + ("CLK_OLLAMA_ENDPOINT", "http://localhost:11434"), + ("CLK_OLLAMA_MODEL", "llama3.1"), +) + + +def harness_root() -> Path: + """The directory holding ``clk_harness/``, ``scripts/`` and ``.env``. + + This is kickoff.sh's ``SCRIPT_DIR``: the repo root in a source tree, + or ``/.clk/harness`` in a kickoff layout. + """ + return Path(__file__).resolve().parent.parent + + +def _open_tty() -> Optional[TextIO]: + """Open ``/dev/tty`` read/write, or None when non-interactive.""" + try: + return open("/dev/tty", "r+", buffering=1) + except OSError: + return None + + +# =========================================================================== +# CLK_* env-var overrides -> .clk/config/clk.config.json +# +# Ported verbatim from kickoff.sh's first embedded Python heredoc. The +# harness ships sane defaults in DEFAULT_CLK_CONFIG (see config.py); this +# lets the user override any of them from the environment without +# hand-editing the JSON. Any unset (or empty, or uncastable) variable +# falls through to the harness's default — keys we didn't see are never +# touched, so a partially-set env still gets the rest from the defaults. +# =========================================================================== + + +def _bool(s) -> bool: + return str(s).strip().lower() in {"1", "true", "yes", "y", "on"} + + +def _csv(s) -> List[str]: + return [item.strip() for item in str(s).split(",") if item.strip()] + + +# (env var, config path, cast) — the exact map from the shell heredoc. +CONFIG_ENV_OVERRIDES: Tuple[Tuple[str, Tuple[str, ...], Callable], ...] = ( + # Robustness block + ("CLK_ROBUSTNESS_AUTO_CONSENSUS", ("robustness", "auto_consensus"), str), + ("CLK_ROBUSTNESS_AUTO_REFINE", ("robustness", "auto_refine"), str), + ("CLK_ROBUSTNESS_MAX_QUALITY_RETRIES", ("robustness", "max_quality_retries"), int), + ("CLK_ROBUSTNESS_MIN_RESPONSE_CHARS", ("robustness", "min_response_chars"), int), + ("CLK_ROBUSTNESS_REFINE_MAX_ROUNDS", ("robustness", "refine_max_rounds"), int), + ("CLK_ROBUSTNESS_REFINE_ACCEPT_THRESHOLD", ("robustness", "refine_accept_threshold"), float), + ("CLK_ROBUSTNESS_QA_PARALLEL_JUDGES", ("robustness", "qa_parallel_judges"), int), + ("CLK_ROBUSTNESS_MAX_QA_DEPTH", ("robustness", "max_qa_depth"), int), + ("CLK_ROBUSTNESS_PLATEAU_WINDOW", ("robustness", "plateau_window"), int), + ("CLK_ROBUSTNESS_PLATEAU_ACTION", ("robustness", "plateau_action"), str), + ("CLK_ROBUSTNESS_DEBATE", ("robustness", "debate"), str), + ("CLK_ROBUSTNESS_DEBATE_LENSES", ("robustness", "debate_lenses"), _csv), + ("CLK_ROBUSTNESS_DEBATE_MAX_ROUNDS", ("robustness", "debate_max_rounds"), int), + # Autonomous mission block + ("CLK_MISSION_MAX_PHASES", ("mission", "max_phases"), int), + ("CLK_MISSION_MAX_ITERATIONS_PER_PHASE", ("mission", "max_iterations_per_phase"), int), + ("CLK_MISSION_MAX_TOTAL_CYCLES", ("mission", "max_total_cycles"), int), + ("CLK_MISSION_PHASE_GATE", ("mission", "phase_gate"), _bool), + ("CLK_MISSION_REFINE_REQUIRED", ("mission", "refine_required"), _bool), + ("CLK_MISSION_AUTO_CONSENSUS_ON_STALL", ("mission", "auto_consensus_on_stall"), _bool), + ("CLK_MISSION_CHARTER_FIRST", ("mission", "charter_first"), _bool), + ("CLK_MISSION_COMMIT_TRACE", ("mission", "commit_trace"), _bool), + ("CLK_MISSION_COMMIT_GRANULARITY", ("mission", "commit_granularity"), str), + ("CLK_MISSION_MIN_CYCLES_BEFORE_DONE", ("mission", "min_cycles_before_done"), int), + ("CLK_MISSION_TELEMETRY_STDOUT", ("mission", "telemetry_stdout"), _bool), + ("CLK_MISSION_ON_BUDGET_EXHAUSTED", ("mission", "on_budget_exhausted"), str), + ("CLK_MISSION_DEFAULT_PHASES", ("mission", "default_phases"), _csv), + # Done-gate block + ("CLK_DONE_GATE_ENABLED", ("done_gate", "enabled"), _bool), + ("CLK_DONE_GATE_REQUIRE_TESTS_GREEN", ("done_gate", "require_tests_green"), _bool), + ("CLK_DONE_GATE_REQUIRE_DELIVERABLES", ("done_gate", "require_deliverables"), _bool), + ("CLK_DONE_GATE_MIN_DELIVERABLE_FILES", ("done_gate", "min_deliverable_files"), int), + ("CLK_DONE_GATE_REQUIRE_QA_PASS", ("done_gate", "require_qa_pass"), _bool), + ("CLK_DONE_GATE_REQUIRE_RALPH_PASS", ("done_gate", "require_ralph_pass"), _bool), + ("CLK_DONE_GATE_FORBID_TODO_MARKERS", ("done_gate", "forbid_todo_markers"), _bool), + ("CLK_DONE_GATE_MAX_FINISH_ATTEMPTS", ("done_gate", "max_finish_attempts"), int), + # No-op guard block + ("CLK_NOOP_GUARD_ENABLED", ("noop_guard", "enabled"), _bool), + ("CLK_NOOP_GUARD_MAX_REDISPATCH", ("noop_guard", "max_redispatch"), int), + ("CLK_NOOP_GUARD_PRODUCING_AGENTS", ("noop_guard", "producing_agents"), _csv), + ("CLK_NOOP_GUARD_TREAT_OUTPUTS_STAGE_AS_PRODUCING", ("noop_guard", "treat_outputs_stage_as_producing"), _bool), + # Deliberation block + ("CLK_DELIBERATION_ENABLED", ("deliberation", "enabled"), _bool), + ("CLK_DELIBERATION_ENCOURAGE_QUESTIONS", ("deliberation", "encourage_questions"), _bool), + ("CLK_DELIBERATION_REQUIRE_OPEN_QUESTIONS_RESOLVED", ("deliberation", "require_open_questions_resolved"), _bool), + ("CLK_DELIBERATION_SELF_REFLECT_PREAMBLE", ("deliberation", "self_reflect_preamble"), _bool), + ("CLK_DELIBERATION_MIN_DEBATE_ROUNDS", ("deliberation", "min_debate_rounds"), int), + # Validation auto-derive + ("CLK_VALIDATION_AUTO_DERIVE", ("validation", "auto_derive"), _bool), + # Prior knobs + ("CLK_PROVIDER_TIMEOUT_S", ("provider_timeout_s",), int), + ("CLK_PROVIDER_NO_OUTPUT_TIMEOUT_S", ("provider_no_output_timeout_s",), int), + ("CLK_PROVIDER_RETRY_MAX_RETRIES", ("provider_retry", "max_retries"), int), + ("CLK_PROVIDER_RETRY_BACKOFF_S", ("provider_retry", "backoff_s"), float), + ("CLK_PROVIDER_RETRY_STAGE_MAX_RETRIES", ("provider_retry", "stage_max_retries"), int), + ("CLK_PROVIDER_RETRY_STAGE_BACKOFF_S", ("provider_retry", "stage_backoff_s"), float), + ("CLK_SUPERVISE_MAX_CYCLES", ("supervise", "max_cycles"), int), + ("CLK_CONSENSUS_MAX_SAMPLES", ("consensus", "max_samples"), int), + ("CLK_CONSENSUS_MAX_PARALLEL", ("consensus", "max_parallel"), int), + ("CLK_CASTING_MAX_DYNAMIC_ROLES", ("casting", "max_dynamic_roles"), int), + ("CLK_AUTO_COMMIT", ("auto_commit",), _bool), + ("CLK_VALIDATION_MAX_FILES_PER_BATCH", ("validation", "max_files_per_batch"), int), + ("CLK_VALIDATION_WARN_FILES_PER_BATCH", ("validation", "warn_files_per_batch"), int), + ("CLK_META_PROMPT_DISPATCH", ("meta_prompt", "dispatch"), str), + ("CLK_META_PROMPT_ROLE", ("meta_prompt", "role"), str), + ("CLK_REVIEW_PER_STAGE", ("review", "per_stage"), _bool), + ("CLK_RECOVERY_MAX_PER_STAGE", ("recovery", "max_per_stage"), int), +) + + +def apply_config_env_overrides(config_path: Path, env: Optional[Mapping[str, str]] = None) -> None: + """Apply recognised ``CLK_*`` env vars into ``clk.config.json``. + + Exact port of kickoff.sh's first heredoc: unset/empty values are + skipped, uncastable values are silently ignored, intermediate dicts + are created with ``setdefault``, and the file is rewritten with + ``indent=2, sort_keys=True``. Missing file is a no-op. + """ + environ: Mapping[str, str] = os.environ if env is None else env + p = Path(config_path) + if not p.exists(): + return # nothing to override + cfg = json.loads(p.read_text(encoding="utf-8")) + for env_var, path, cast in CONFIG_ENV_OVERRIDES: + raw = environ.get(env_var) + if raw is None or raw == "": + continue + try: + val = cast(raw) + except (TypeError, ValueError): + continue + cur = cfg + for key in path[:-1]: + cur = cur.setdefault(key, {}) + cur[path[-1]] = val + p.write_text(json.dumps(cfg, indent=2, sort_keys=True) + "\n", encoding="utf-8") + + +# =========================================================================== +# Provider activation -> .clk/config/providers.json +# +# Ported verbatim from kickoff.sh's second embedded Python heredoc, +# including the `VAR="${VAR:-default}"` prefixes the shell put on the +# invocation (an unset OR empty variable collapses to the default). +# =========================================================================== + + +def activate_provider(providers_path: Path, env: Optional[Mapping[str, str]] = None) -> None: + """Set the active provider and wire per-provider settings/keys. + + For CLI-driven providers, mode=cli (default) spawns the CLI + subprocess. mode=api makes the provider call the upstream HTTP API + directly with no subprocess — which is exactly what the user expects + when they choose "apikey" auth: the API key alone, no local CLI + dependency. + """ + environ: Mapping[str, str] = os.environ if env is None else env + + def _dflt(key: str, default: str = "") -> str: + # `${VAR:-default}` semantics from the shell invocation. + return environ.get(key) or default + + p = Path(providers_path) + data = json.loads(p.read_text(encoding="utf-8")) + provider = environ["CLK_PROVIDER"] + data["active"] = provider + provs = data.setdefault("providers", {}) + auth_mode = _dflt("CLK_AUTH_MODE", "cli") + for cli_provider in ("claude", "codex", "gemini"): + provs.setdefault(cli_provider, {"type": cli_provider}) + provs[cli_provider]["mode"] = "api" if auth_mode == "apikey" else "cli" + if provider == "claude" and auth_mode == "apikey": + provs["claude"]["api_key"] = environ.get("ANTHROPIC_API_KEY", "") + if provider == "codex" and auth_mode == "apikey": + provs["codex"]["api_key"] = environ.get("OPENAI_API_KEY", "") + if provider == "gemini" and auth_mode == "apikey": + provs["gemini"]["api_key"] = ( + environ.get("GEMINI_API_KEY", "") + or environ.get("GOOGLE_API_KEY", "") + ) + if provider == "ollama": + provs.setdefault("ollama", {}) + provs["ollama"]["endpoint"] = _dflt("CLK_OLLAMA_ENDPOINT", "http://localhost:11434") + provs["ollama"]["model"] = _dflt("CLK_OLLAMA_MODEL", "llama3.1") + elif provider == "openwebui": + provs.setdefault("openwebui", {"type": "openwebui"}) + provs["openwebui"]["type"] = "openwebui" + provs["openwebui"]["endpoint"] = _dflt("CLK_OPENWEBUI_ENDPOINT") + provs["openwebui"]["api_key"] = _dflt("CLK_OPENWEBUI_API_KEY") + provs["openwebui"]["model"] = _dflt("CLK_OPENWEBUI_MODEL") + elif provider == "pi": + provs.setdefault("pi", {"type": "pi", "command": "pi", "args": []}) + pi_model = _dflt("CLK_PI_MODEL").strip() + pi_key = _dflt("CLK_PI_API_KEY").strip() + pi_key_type = _dflt("CLK_PI_KEY_TYPE").strip().lower() + if pi_model: + provs["pi"]["model"] = pi_model + if pi_key: + provs["pi"]["api_key"] = pi_key + if pi_key_type: + provs["pi"]["key_type"] = pi_key_type + p.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n", encoding="utf-8") + + +# =========================================================================== +# Defaults + validation (ports of _apply_defaults and _clk_missing) +# =========================================================================== + + +def _apply_defaults(cfg: Dict[str, str]) -> None: + for key, default in _DEFAULTS: + if not cfg.get(key): + cfg[key] = default + + +def _missing_config(cfg: Mapping[str, str], environ: Mapping[str, str], idea: str) -> List[str]: + """Validate resolved config. One string per problem; empty when OK.""" + problems: List[str] = [] + provider = cfg["CLK_PROVIDER"] + auth_mode = cfg["CLK_AUTH_MODE"] + if provider == "shell": + pass + elif provider == "claude": + if auth_mode == "apikey" and not environ.get("ANTHROPIC_API_KEY"): + problems.append( + "ANTHROPIC_API_KEY is unset — required when CLK_PROVIDER=claude and " + "CLK_AUTH_MODE=apikey (or set CLK_AUTH_MODE=cli to use 'claude login')" + ) + elif provider == "codex": + if auth_mode == "apikey" and not environ.get("OPENAI_API_KEY"): + problems.append( + "OPENAI_API_KEY is unset — required when CLK_PROVIDER=codex and " + "CLK_AUTH_MODE=apikey (or set CLK_AUTH_MODE=cli)" + ) + elif provider == "gemini": + if auth_mode == "apikey" and not environ.get("GEMINI_API_KEY") and not environ.get("GOOGLE_API_KEY"): + problems.append( + "GEMINI_API_KEY (or GOOGLE_API_KEY) is unset — required when " + "CLK_PROVIDER=gemini and CLK_AUTH_MODE=apikey" + ) + elif provider == "pi": + pass # Nothing strictly required; pi login handles auth + elif provider == "ollama": + pass # Has built-in defaults + elif provider == "openwebui": + if not environ.get("CLK_OPENWEBUI_ENDPOINT"): + problems.append("CLK_OPENWEBUI_ENDPOINT is unset — required for CLK_PROVIDER=openwebui") + if not environ.get("CLK_OPENWEBUI_MODEL"): + problems.append( + "CLK_OPENWEBUI_MODEL is unset — required for CLK_PROVIDER=openwebui " + "(use --setup to pick from a live model list)" + ) + else: + problems.append( + f"CLK_PROVIDER='{provider}' is not recognised (valid: {'|'.join(VALID_PROVIDERS)})" + ) + + if not re.fullmatch(r"[0-9]+", cfg["CLK_MAX_ITERATIONS"]): + problems.append( + f"CLK_MAX_ITERATIONS must be a positive integer (got '{cfg['CLK_MAX_ITERATIONS']}')" + ) + + if cfg.get("CLK_NO_TUI", "false") == "true" and not idea: + problems.append( + "An idea argument is required when CLK_NO_TUI=true — pass it as the first " + "positional argument" + ) + return problems + + +# =========================================================================== +# --restore / --list / --clean early-exit modes +# =========================================================================== + + +def _cmd_restore(script_dir: Path) -> int: + env_path = script_dir / ".env" + bak = script_dir / ".env.bak" + if not bak.is_file(): + print(f"[lib_env] no backup at {bak}", file=sys.stderr) + print("[kickoff] no .env.bak to restore", file=sys.stderr) + return 1 + os.replace(bak, env_path) + print("[kickoff] .env restored from .env.bak") + return 0 + + +def _cmd_list() -> int: + ws_dir = Path.cwd() / "workspace" + if not ws_dir.is_dir(): + print("[kickoff] no workspace/ dir yet — nothing to list.") + return 0 + print(f"{'kickoff dir':<32} {'last activity':<19} idea") + for d in sorted(ws_dir.glob("kickoff-*")): + if not d.is_dir(): + continue + try: + last = datetime.fromtimestamp(d.stat().st_mtime).strftime("%Y-%m-%d %H:%M:%S") + except OSError: + last = "?" + idea = "" + idea_json = d / ".clk" / "state" / "idea.json" + if idea_json.is_file(): + try: + idea = (json.loads(idea_json.read_text(encoding="utf-8")).get("title") or "")[:60] + except Exception: + idea = "" + print(f"{d.name:<32} {last:<19} {idea}") + return 0 + + +def _cmd_clean(older_than: str) -> int: + ws_dir = Path.cwd() / "workspace" + if not ws_dir.is_dir(): + print("[kickoff] no workspace/ dir; nothing to clean.") + return 0 + unit, qty_s = older_than[-1:], older_than[:-1] + if not re.fullmatch(r"[0-9]+", qty_s): + print(f"[kickoff] --clean expects something like 7d or 30m (got {older_than})", file=sys.stderr) + return 2 + qty = int(qty_s) + if unit == "d": + divisor = 86400 + elif unit == "m": + divisor = 60 + else: + print("[kickoff] --clean unit must be d (days) or m (minutes)", file=sys.stderr) + return 2 + now = time.time() + targets = [] + for d in sorted(ws_dir.glob("kickoff-*")): + if not d.is_dir(): + continue + try: + age = now - d.stat().st_mtime + except OSError: + continue + # find's -mtime +N / -mmin +N semantics: strictly more than N whole units. + if int(age // divisor) > qty: + targets.append(d) + if not targets: + print(f"[kickoff] no kickoff dirs older than {older_than}.") + return 0 + print(f"[kickoff] would remove {len(targets)} kickoff dirs older than {older_than}:") + for t in targets: + print(f" - {t}") + tty = _open_tty() + if tty is None: + print("[kickoff] non-interactive; refusing to delete without confirmation.", file=sys.stderr) + print("[kickoff] re-run from a terminal to confirm.", file=sys.stderr) + return 2 + try: + tty.write("Delete these? [y/N]: ") + tty.flush() + ans = (tty.readline() or "").strip().lower() + finally: + tty.close() + if ans in ("y", "yes"): + for t in targets: + shutil.rmtree(t, ignore_errors=True) + print(f"[kickoff] removed {t}") + else: + print("[kickoff] nothing deleted.") + return 0 + + +# =========================================================================== +# Kickoff directory scaffolding (sections 5 & 6 of the shell script) +# =========================================================================== + +_LAUNCHER_SHIM = """\ +#!/usr/bin/env bash +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd)" +PROJECT_ROOT="$(cd -- "$SCRIPT_DIR/../.." >/dev/null 2>&1 && pwd)" +export CLK_PROJECT_ROOT="$PROJECT_ROOT" +exec "$PROJECT_ROOT/.clk/harness/scripts/clk" "$@" +""" + +_KICKOFF_GITIGNORE = """\ +# All harness state lives under .clk/ — ignore it entirely. +.clk/ +# Secrets +/.env +/.env.example +/.env.bak +/.env.partial +.env.local +*.pem +*.key +*_id_rsa* +/secrets/ +/.secrets/ +# Editor / OS junk +__pycache__/ +*.pyc +.DS_Store +.idea/ +.vscode/ +""" + +_PRE_PUSH_HOOK = """\ +#!/usr/bin/env bash +# CLK pre-push secret scan. Bypass with `git push --no-verify` when sure. +set -eo pipefail +while read -r local_ref local_sha remote_ref remote_sha; do + [ "$local_sha" = "0000000000000000000000000000000000000000" ] && continue + range="$local_sha" + if [ "$remote_sha" != "0000000000000000000000000000000000000000" ]; then + range="$remote_sha..$local_sha" + fi + hits=$(git log -p "$range" 2>/dev/null | grep -E \\ + -e 'ANTHROPIC_API_KEY=[A-Za-z0-9_\\-]+' \\ + -e 'OPENAI_API_KEY=[A-Za-z0-9_\\-]+' \\ + -e 'OPENROUTER_API_KEY=[A-Za-z0-9_\\-]+' \\ + -e 'GEMINI_API_KEY=[A-Za-z0-9_\\-]+' \\ + -e 'GOOGLE_API_KEY=[A-Za-z0-9_\\-]+' \\ + -e 'sk-[A-Za-z0-9]{20,}' \\ + -e 'xoxb-[A-Za-z0-9-]{20,}' \\ + -e 'BEGIN (RSA|OPENSSH|EC|DSA|PGP) PRIVATE KEY' \\ + || true) + if [ -n "$hits" ]; then + echo "[pre-push] aborting — possible secret(s) in $range:" >&2 + echo "$hits" | head -n 5 >&2 + echo "" >&2 + echo "To override: git push --no-verify (only when you're sure)." >&2 + exit 1 + fi +done +""" + + +def _git(kdir: Path, *args: str, capture: bool = True) -> subprocess.CompletedProcess: + return subprocess.run( + ["git", *args], cwd=str(kdir), check=False, + capture_output=capture, text=True, + ) + + +def _copy_if_present(src: Path, dst: Path) -> None: + if src.is_dir(): + shutil.copytree(src, dst, ignore=shutil.ignore_patterns("__pycache__")) + elif src.exists(): + shutil.copy2(src, dst) + + +def _create_kickoff_dir(script_dir: Path, cfg: Mapping[str, str], idea: str) -> Optional[Path]: + ts = time.strftime("%Y%m%d-%H%M%S") + workspace_dir = Path.cwd() / "workspace" + kickoff_dir = workspace_dir / f"kickoff-{ts}" + workspace_dir.mkdir(parents=True, exist_ok=True) + if kickoff_dir.exists(): + print(f"[kickoff] {kickoff_dir} already exists; refusing to overwrite", file=sys.stderr) + return None + print(f"[kickoff] creating {kickoff_dir}") + kickoff_dir.mkdir(parents=True) + + # Harness sources, scripts, and packaging metadata all live under + # .clk/harness/ so the project root looks like a normal codebase from + # the agents' point of view. + harness = kickoff_dir / ".clk" / "harness" + harness.mkdir(parents=True) + _copy_if_present(script_dir / "clk_harness", harness / "clk_harness") + _copy_if_present(script_dir / "scripts", harness / "scripts") + _copy_if_present(script_dir / "pyproject.toml", harness / "pyproject.toml") + _copy_if_present(script_dir / "requirements.txt", harness / "requirements.txt") + _copy_if_present(script_dir / "README.md", harness / "README.md") + + # Launcher shim lives under .clk/scripts/ so the project root stays clean. + shim_dir = kickoff_dir / ".clk" / "scripts" + shim_dir.mkdir(parents=True) + shim = shim_dir / "clk" + shim.write_text(_LAUNCHER_SHIM, encoding="utf-8") + shim.chmod(0o755) + + for rel in ("scripts/clk", "scripts/install_local.sh", "scripts/run_loop.sh"): + p = harness / rel + if p.exists(): + p.chmod(p.stat().st_mode | 0o111) + + # Manifest so future-you knows how this dir was launched. + (kickoff_dir / "KICKOFF.md").write_text( + "# CLK kickoff manifest\n" + "\n" + "| Field | Value |\n" + "|------------------|-------|\n" + f"| Timestamp | {ts} |\n" + f"| Source dir | {script_dir} |\n" + f"| Project name | {cfg['CLK_PROJECT_NAME']} |\n" + f"| Provider | {cfg['CLK_PROVIDER']} |\n" + f"| Max iterations | {cfg['CLK_MAX_ITERATIONS']} |\n" + f"| Ran installer | {cfg['CLK_RUN_INSTALL']} |\n" + f"| Idea | {idea} |\n" + "\n" + "This directory is fully self-contained. Delete it to reset.\n", + encoding="utf-8", + ) + return kickoff_dir + + +def _prepare_kickoff_repo(kickoff_dir: Path, environ: Mapping[str, str]) -> None: + """gitignore + own git repo + pre-push secret hook + optional remote.""" + # Anchor the project root here so find_project_root() returns this dir. + (kickoff_dir / ".clk").mkdir(exist_ok=True) + (kickoff_dir / ".gitignore").write_text(_KICKOFF_GITIGNORE, encoding="utf-8") + + if not shutil.which("git"): + return + if not (kickoff_dir / ".git").is_dir(): + _git(kickoff_dir, "init", "-q") + _git(kickoff_dir, "config", "user.name", "CLK Kickoff") + _git(kickoff_dir, "config", "user.email", "kickoff@local.invalid") + + hooks = kickoff_dir / ".git" / "hooks" + hooks.mkdir(parents=True, exist_ok=True) + hook = hooks / "pre-push" + hook.write_text(_PRE_PUSH_HOOK, encoding="utf-8") + hook.chmod(0o755) + + # Connect a GitHub remote if the wizard recorded one. + remote = environ.get("CLK_GITHUB_REMOTE", "") + mode = environ.get("CLK_GITHUB_MODE", "skip") or "skip" + if remote and mode != "skip": + if mode == "existing": + if _git(kickoff_dir, "remote", "get-url", "origin").returncode != 0: + print(f"[kickoff] linking existing GitHub remote: {remote}") + _git(kickoff_dir, "remote", "add", "origin", remote, capture=False) + elif mode == "create": + gh_ok = bool(shutil.which("gh")) and subprocess.run( + ["gh", "auth", "status"], capture_output=True, check=False, + ).returncode == 0 + if gh_ok: + if _git(kickoff_dir, "remote", "get-url", "origin").returncode != 0: + print(f"[kickoff] creating GitHub repo: {remote} (private)") + rc = subprocess.run( + ["gh", "repo", "create", remote, "--private", + "--source=.", "--remote=origin"], + cwd=str(kickoff_dir), check=False, + ).returncode + if rc != 0: + print("[kickoff] gh repo create failed (continuing without remote)") + else: + print("[kickoff] CLK_GITHUB_MODE=create but gh CLI is not authenticated; skipping remote.") + + +# =========================================================================== +# Pipeline / TUI (section 6 of the shell script) — in-process cmd_* calls +# =========================================================================== + + +def _drive_harness(kickoff_dir: Path, cfg: Dict[str, str], idea: str) -> int: + from . import cli # deferred: cli imports this module + + os.chdir(kickoff_dir) + + if cfg["CLK_RUN_INSTALL"].lower() == "true": + print("[kickoff] running .clk/harness/scripts/install_local.sh") + rc = subprocess.run( + ["bash", ".clk/harness/scripts/install_local.sh"], check=False, + ).returncode + if rc != 0: + print("[kickoff] install_local.sh reported a problem (continuing)") + + print("[kickoff] clk init") + rc = cli.cmd_init(argparse.Namespace(name=cfg["CLK_PROJECT_NAME"])) + if rc: + return rc + + print("[kickoff] applying CLK_* env-var overrides to .clk/config/clk.config.json") + apply_config_env_overrides(Path(".clk/config/clk.config.json")) + + print(f"[kickoff] activating provider: {cfg['CLK_PROVIDER']}") + merged = dict(os.environ) + merged.update(cfg) + activate_provider(Path(".clk/config/providers.json"), env=merged) + cli.cmd_configure(argparse.Namespace( + set=[f"default_provider={cfg['CLK_PROVIDER']}"], show=False, + )) + + if cfg["CLK_NO_TUI"] == "true": + # Non-interactive pipeline. Useful for CI / smoke tests and Docker + # without -it. + print("[kickoff] clk idea") + rc = cli.cmd_idea(argparse.Namespace( + statement=idea, title=cfg["CLK_PROJECT_NAME"], tag=None, no_cast=False, + )) + if rc: + return rc + print("[kickoff] clk plan") + if cli.cmd_plan(argparse.Namespace(dry_run=False)): + print("[kickoff] plan reported failures (continuing)") + print("[kickoff] clk run") + if cli.cmd_run(argparse.Namespace( + once=False, workflow=None, resume=False, + max_phases=None, max_cycles=None, dry_run=False, + )): + print("[kickoff] run reported failures (continuing)") + print(f"[kickoff] clk loop --max-iterations {cfg['CLK_MAX_ITERATIONS']}") + rc = cli.cmd_loop(argparse.Namespace( + mode="ralph", max_iterations=int(cfg["CLK_MAX_ITERATIONS"]), dry_run=False, + )) + if rc: + return rc + else: + # Default: hand control to the TUI dashboard. If an idea was given, + # it pre-seeds the idea and starts an engineering cycle. + print("[kickoff] launching TUI (use /quit to exit, /help-style commands listed inside)") + rc = cli.cmd_tui(argparse.Namespace(prompt=idea or None)) + if rc: + return rc + return 0 + + +# =========================================================================== +# Setup wizard (--setup) — port of _clk_setup / _clk_setup_github +# =========================================================================== + + +class _SetupIO: + """Prompt I/O against /dev/tty when available (so the wizard works + inside ``docker run -it`` even when stdin is piped), else stdin/stderr.""" + + def __init__(self) -> None: + tty = _open_tty() + if tty is not None: + self.fin: TextIO = tty + self.fout: TextIO = tty + else: + self.fin = sys.stdin + self.fout = sys.stderr + + def say(self, msg: str = "") -> None: + print(msg, file=self.fout) + + def explain(self, text: str) -> None: + # Explain-then-ask: tell the user what the value does before asking. + self.say("\n" + text) + + def _readline(self) -> str: + line = self.fin.readline() + return line.rstrip("\n") if line else "" + + def read(self, prompt: str, default: str = "") -> str: + if default: + self.fout.write(f"{prompt} [{default}]: ") + else: + self.fout.write(f"{prompt}: ") + self.fout.flush() + return self._readline() or default + + def secret(self, prompt: str) -> str: + self.fout.write(f"{prompt} (leave blank to keep): ") + self.fout.flush() + try: + import termios + fd = self.fin.fileno() + old = termios.tcgetattr(fd) + new = list(old) + new[3] = new[3] & ~termios.ECHO + termios.tcsetattr(fd, termios.TCSADRAIN, new) + try: + value = self._readline() + finally: + termios.tcsetattr(fd, termios.TCSADRAIN, old) + except Exception: + value = self._readline() + self.say("") + return value + + def confirm(self, prompt: str, default: str = "N") -> bool: + hint = "Y/n" if default.upper() in ("Y", "YES") else "y/N" + self.fout.write(f"{prompt} [{hint}]: ") + self.fout.flush() + answer = self._readline() or default + return answer.lower() in ("y", "yes") + + +def _load_env_into(target: MutableMapping[str, str], path: Path) -> None: + """Mimic `set -a; . ; set +a`: every assignment is exported.""" + if not path.is_file(): + return + try: + text = path.read_text(encoding="utf-8") + except OSError: + return + for line in env_file.parse_env(text): + if line.kind == "kv": + target[line.key] = line.value + + +def _env_set(key: str, value: str) -> None: + """Persist one KEY=VALUE to .env atomically and export it.""" + env_file.write_env({key: value}) + os.environ[key] = value + + +def _install_tool_call(script_dir: Path, func: str, *fargs: str) -> int: + """Run one function from scripts/install_tool.sh (single source of + truth for provider CLI install/config) with the wizard's terminal.""" + script = script_dir / "scripts" / "install_tool.sh" + if not script.is_file(): + return 127 + # $0 must differ from the script path, otherwise install_tool.sh's + # `BASH_SOURCE[0] = $0` direct-dispatch guard fires while sourcing. + cmd = ["bash", "-c", 'source "$1" && shift && "$@"', "clk-kickoff", + str(script), func, *fargs] + return subprocess.run(cmd, check=False).returncode + + +def run_setup_wizard(script_dir: Path) -> int: + """Interactive wizard to write or update .env. + + Same design as the shell wizard it replaces: + * Explain-then-ask before every prompt. + * Atomic writes: each answer persists to .env immediately (via + env_file.write_env), so Ctrl-C never leaves a half-written file. + * Per-step resume: .clk/.setup-progress records the last completed + step; the next run offers to skip ahead. + * Always-confirm: installs/pushes prompt y/N every time. + """ + io = _SetupIO() + + env_path = Path(os.environ.get("CLK_ENV_FILE") or (script_dir / ".env")) + os.environ["CLK_ENV_FILE"] = str(env_path) + progress_file = script_dir / ".clk" / ".setup-progress" + progress_file.parent.mkdir(parents=True, exist_ok=True) + + # Seed defaults from .env.example first, then let an existing .env override. + _load_env_into(os.environ, script_dir / ".env.example") + if env_path.is_file() and env_path.stat().st_size > 0: + _load_env_into(os.environ, env_path) + io.say(f"[setup] loaded existing values from {env_path}") + else: + io.say(f"[setup] {env_path} is empty or missing — using .env.example defaults") + + # Per-step resume. + last_step = "" + if progress_file.is_file(): + lines = [ln for ln in progress_file.read_text(encoding="utf-8").splitlines() if ln.strip()] + if lines: + last_step = lines[-1] + skip_until = "" + if last_step: + if io.confirm(f"[setup] Resume from after step '{last_step}'?", "Y"): + skip_until = last_step + else: + progress_file.write_text("", encoding="utf-8") + + io.say("\n=== CLK Setup Wizard ===") + io.say("Press Enter to keep the value shown in [brackets].") + io.say("Every install, push, and destructive action is confirmed y/N first.") + + def mark_step(name: str) -> None: + with progress_file.open("a", encoding="utf-8") as fh: + fh.write(name + "\n") + + def should_run_step(name: str) -> bool: + nonlocal skip_until + if not skip_until: + return True + if name == skip_until: + skip_until = "" # stop skipping after this match + return False + + env = os.environ # exported view, mirrors `set -a` + + # --- provider -------------------------------------------------------- + if should_run_step("provider"): + io.explain( + "=== Provider ===\n" + "The provider is the AI that actually writes your code each cycle.\n" + "\n" + " shell no AI — useful for smoke tests and the /tutorial walkthrough\n" + " claude Anthropic Claude Code CLI (best at writing code, supports tools)\n" + " codex OpenAI Codex CLI\n" + " gemini Google Gemini CLI\n" + " pi Pi terminal harness — routes through OpenRouter/Anthropic/OpenAI/Google\n" + " ollama local LLM via the Ollama daemon (no external API, free)\n" + " openwebui OpenWebUI server (self-hosted, OpenAI-compatible)" + ) + provider = io.read("Provider", env.get("CLK_PROVIDER") or "shell") + _env_set("CLK_PROVIDER", provider) + mark_step("provider") + else: + provider = env.get("CLK_PROVIDER") or "shell" + + # --- max iterations + project name + flags --------------------------- + if should_run_step("loop_settings"): + io.explain( + "=== Loop settings ===\n" + "`max iterations` caps how many refinement cycles the Ralph and\n" + "autoresearch loops can run. `project name` becomes the title of the\n" + "captured idea and (optionally) the GitHub repo name. The `run install`\n" + "flag triggers .clk/harness/scripts/install_local.sh inside each kickoff\n" + "dir so providers like pi can find PyYAML and other deps — leave it\n" + "`false` (the default) when running inside Docker, because the image\n" + "already has all Python dependencies installed at build time.\n" + "`no TUI` switches to a non-interactive pipeline — handy for CI." + ) + proj_name = "" + _env_set("CLK_MAX_ITERATIONS", io.read("Max loop iterations", env.get("CLK_MAX_ITERATIONS") or "10")) + proj_name = io.read("Project name", env.get("CLK_PROJECT_NAME") or "clk-app") + _env_set("CLK_PROJECT_NAME", proj_name) + _env_set("CLK_RUN_INSTALL", io.read( + "Run install_local.sh in each kickoff (true|false)", env.get("CLK_RUN_INSTALL") or "false")) + _env_set("CLK_NO_TUI", io.read( + "Skip TUI / non-interactive (true|false)", env.get("CLK_NO_TUI") or "false")) + mark_step("loop_settings") + else: + proj_name = env.get("CLK_PROJECT_NAME") or "clk-app" + + # --- auth mode (CLI providers) --------------------------------------- + if provider in ("claude", "codex", "gemini"): + if should_run_step("auth_mode"): + io.explain( + f"=== Auth mode ({provider}) ===\n" + f"'cli' — use your existing local CLI login (run `{provider} login` once;\n" + f" best when you already use {provider} day-to-day).\n" + "'apikey' — call the provider's HTTP API directly using an API key.\n" + " No CLI dependency, but you must paste a key below." + ) + _env_set("CLK_AUTH_MODE", io.read("Auth mode", env.get("CLK_AUTH_MODE") or "cli")) + mark_step("auth_mode") + + # --- install + configure the chosen tool ----------------------------- + if should_run_step("tool_setup"): + if provider != "shell": + io.explain( + f"=== Tool detection ({provider}) ===\n" + f"Checking whether `{provider}` is installed and reachable. If it\n" + "isn't, the wizard will suggest an install command and ask before\n" + "running it. After the tool is available, we'll walk through\n" + "first-use config (auth -> route -> model -> verify)." + ) + if _install_tool_call(script_dir, "check_tool", provider) == 0: + io.say(f"[setup] {provider} is available on this machine.") + else: + if _install_tool_call(script_dir, "install_tool", provider, "--prompt") != 0: + io.say(f"[setup] {provider} install was skipped or failed; continuing.") + if _install_tool_call(script_dir, "check_tool", provider) == 0: + already = _install_tool_call(script_dir, "tool_configured", provider) == 0 + if already and not io.confirm(f"Re-run first-use config for {provider}?", "N"): + io.say(f"[setup] {provider} already configured (per .clk/state/configured-tools.json).") + else: + if _install_tool_call(script_dir, "configure_tool", provider) != 0: + io.say(f"[setup] {provider} configure step exited non-zero; continuing.") + else: + io.say(f"[setup] {provider} is still unavailable — provider calls will fail until you install it.") + mark_step("tool_setup") + # Reload .env so values just written by configure_tool become + # visible to the rest of the wizard. + _load_env_into(os.environ, env_path) + + # --- docker host fallback for local LLM endpoints -------------------- + if should_run_step("docker_host_fallback"): + io.explain( + "=== Local LLM endpoint check ===\n" + "If you have ollama or OpenWebUI running on the host but CLK is in a\n" + "container, 'localhost' won't reach them. We'll probe each configured\n" + "endpoint and, when only host.docker.internal works, offer to switch." + ) + _install_tool_call( + script_dir, "_it_offer_docker_host_fallback", "Ollama", "CLK_OLLAMA_ENDPOINT", + env.get("CLK_OLLAMA_ENDPOINT") or "http://localhost:11434") + _install_tool_call( + script_dir, "_it_offer_docker_host_fallback", "OpenWebUI", "CLK_OPENWEBUI_ENDPOINT", + env.get("CLK_OPENWEBUI_ENDPOINT") or "http://localhost:8080") + mark_step("docker_host_fallback") + _load_env_into(os.environ, env_path) + + # --- telegram -------------------------------------------------------- + tg_setup = "N" + if should_run_step("telegram"): + io.explain( + "=== Telegram bot (optional) ===\n" + "If enabled, you can drive CLK from your phone: send the bot an idea,\n" + "get progress updates back, /stop or /abort remotely. The dedicated\n" + "wizard at scripts/telegram_setup_wizard.sh walks through BotFather\n" + "token creation and discovers your numeric user ID so we can allowlist\n" + "only you." + ) + default_tg = "y" if env.get("CLK_TELEGRAM_ENABLED", "false") == "true" else "N" + tg_setup = io.read("Set up Telegram bot now? (y/N)", default_tg) + if tg_setup.lower() == "y": + _env_set("CLK_TELEGRAM_SKIP", "false") + else: + io.say("[setup] Skipping Telegram. CLK_TELEGRAM_SKIP=true will be written to .env.") + _env_set("CLK_TELEGRAM_SKIP", "true") + mark_step("telegram") + + # --- GitHub ---------------------------------------------------------- + if should_run_step("github"): + _setup_github(io, script_dir, proj_name) + mark_step("github") + + # --- git identity ---------------------------------------------------- + if should_run_step("git_identity"): + io.explain( + "=== Git identity (used in kickoff commits) ===\n" + "Each kickoff workspace is its own git repo and CLK auto-commits after\n" + "every successful agent run. The author/committer comes from your\n" + "global git config unless you set CLK_GIT_NAME / CLK_GIT_EMAIL here\n" + "(useful inside containers where the global config doesn't propagate)." + ) + cur_name = subprocess.run( + ["git", "config", "--global", "user.name"], + capture_output=True, text=True, check=False).stdout.strip() + cur_email = subprocess.run( + ["git", "config", "--global", "user.email"], + capture_output=True, text=True, check=False).stdout.strip() + io.say(f" Current global git name: {cur_name or ''}") + io.say(f" Current global git email: {cur_email or ''}") + _env_set("CLK_GIT_NAME", io.read("Git user.name (blank = keep current)", env.get("CLK_GIT_NAME", ""))) + _env_set("CLK_GIT_EMAIL", io.read("Git user.email (blank = keep current)", env.get("CLK_GIT_EMAIL", ""))) + mark_step("git_identity") + + io.say(f"\n[setup] saved {env_path}") + io.say(f"[setup] previous values are in {env_path}.bak") + + if tg_setup.lower() == "y": + io.say("\n[setup] launching Telegram wizard...") + wizard = script_dir / "scripts" / "telegram_setup_wizard.sh" + rc = subprocess.run( + ["bash", str(wizard)], + env={**os.environ, "CLK_ENV_FILE": str(env_path)}, + check=False, + ).returncode if wizard.is_file() else 127 + if rc != 0: + io.say("[setup] telegram wizard exited non-zero; continuing") + + # Wizard finished cleanly — clear progress so a future --setup starts + # at the top instead of asking to resume. + try: + progress_file.unlink() + except OSError: + pass + return 0 + + +def _setup_github(io: _SetupIO, script_dir: Path, proj_name: str) -> None: + """GitHub connection block — port of _clk_setup_github.""" + env = os.environ + io.explain( + "=== GitHub (optional) ===\n" + "Each kickoff workspace is already a local git repo. You can optionally\n" + "connect it to a GitHub remote so:\n" + " - every agent commit is checkpointed off your machine\n" + " - you (or another machine) can resume the work later by cloning\n" + " - friends/teammates can review the run\n" + "\n" + " skip no GitHub — local commits only (default)\n" + " existing connect to a repo you already own (paste URL)\n" + " create create a brand new private repo under your account\n" + "\n" + "The wizard will write a hardened .gitignore (blocking .env, .env.bak,\n" + "SSH keys, etc.) and install a pre-push hook that aborts when an\n" + "obvious API key pattern appears in the diff." + ) + choice = io.read("Connect to GitHub?", env.get("CLK_GITHUB_MODE") or "skip") + if choice in ("skip", ""): + _env_set("CLK_GITHUB_MODE", "skip") + _env_set("CLK_GITHUB_REMOTE", "") + _env_set("CLK_GITHUB_PUSH_ON_COMMIT", "false") + io.say("[setup] GitHub disabled.") + return + if choice == "existing": + url = io.read( + "Existing repo (https://github.com/OWNER/REPO or git@github.com:OWNER/REPO.git)", + env.get("CLK_GITHUB_REMOTE", "")) + if not url: + io.say("[setup] no URL provided; skipping GitHub.") + _env_set("CLK_GITHUB_MODE", "skip") + return + _env_set("CLK_GITHUB_MODE", "existing") + _env_set("CLK_GITHUB_REMOTE", url) + elif choice == "create": + if not shutil.which("gh"): + io.say("[setup] gh CLI is required to create a repo from here.") + if _install_tool_call(script_dir, "install_tool", "gh", "--prompt") != 0: + io.say('[setup] gh unavailable; cannot create. Falling back to "existing" — paste a URL.') + url = io.read("Existing repo URL", "") + if url: + _env_set("CLK_GITHUB_MODE", "existing") + _env_set("CLK_GITHUB_REMOTE", url) + else: + _env_set("CLK_GITHUB_MODE", "skip") + return + if subprocess.run(["gh", "auth", "status"], capture_output=True, check=False).returncode != 0: + io.say("[setup] gh is installed but not authenticated.") + if io.confirm("Run `gh auth login` now?", "Y"): + subprocess.run(["gh", "auth", "login"], check=False) + who = subprocess.run( + ["gh", "api", "user", "--jq", ".login"], + capture_output=True, text=True, check=False) + default_or = who.stdout.strip() if who.returncode == 0 and who.stdout.strip() else ( + env.get("USER") or "user") + owner_repo = io.read("owner/repo to create", f"{default_or}/{proj_name}-kickoff") + _env_set("CLK_GITHUB_MODE", "create") + _env_set("CLK_GITHUB_REMOTE", owner_repo) + io.say(f'[setup] GitHub repo "{owner_repo}" will be created (private) on the first kickoff push.') + else: + io.say(f'[setup] unknown GitHub choice "{choice}"; skipping.') + _env_set("CLK_GITHUB_MODE", "skip") + return + + if io.confirm("Auto-push to GitHub after every CLK commit?", "Y"): + _env_set("CLK_GITHUB_PUSH_ON_COMMIT", "true") + else: + _env_set("CLK_GITHUB_PUSH_ON_COMMIT", "false") + + +# =========================================================================== +# Entry point +# =========================================================================== + + +def cmd_kickoff(args: argparse.Namespace) -> int: + script_dir = harness_root() + + # Early-exit modes (mirror the shell's flag handlers). + if getattr(args, "restore", False): + return _cmd_restore(script_dir) + if getattr(args, "list_mode", False): + return _cmd_list() + if getattr(args, "clean", None): + return _cmd_clean(args.clean) + if getattr(args, "setup", False): + rc = run_setup_wizard(script_dir) + if rc == 0: + print("[setup] run 'clk kickoff' to start a new session", file=sys.stderr) + return rc + + idea = args.idea or "" + + # First-run nudge: if .env is missing and we have a TTY, offer setup + # inline. Declining falls through to defaults. CI / non-interactive + # containers skip silently. + env_path = script_dir / ".env" + if not (env_path.is_file() and env_path.stat().st_size > 0): + tty = _open_tty() + if tty is not None: + try: + if env_path.is_file(): + print(f"[kickoff] {env_path} is empty (placeholder) — first run?", file=sys.stderr) + else: + print(f"[kickoff] No .env found at {env_path} — first run?", file=sys.stderr) + tty.write("[kickoff] Run --setup now to configure? [Y/n]: ") + tty.flush() + ans = (tty.readline() or "").strip().lower() + finally: + tty.close() + if ans in ("", "y", "yes"): + run_setup_wizard(script_dir) + else: + print("[kickoff] Skipping setup; continuing with defaults.", file=sys.stderr) + + # Load .env (export every assigned var so subprocesses inherit it). + if env_path.is_file(): + print(f"[kickoff] loading {env_path}") + _load_env_into(os.environ, env_path) + + # Apply git identity overrides from .env (useful inside Docker). + if os.environ.get("CLK_GIT_NAME"): + subprocess.run(["git", "config", "--global", "user.name", os.environ["CLK_GIT_NAME"]], + capture_output=True, check=False) + if os.environ.get("CLK_GIT_EMAIL"): + subprocess.run(["git", "config", "--global", "user.email", os.environ["CLK_GIT_EMAIL"]], + capture_output=True, check=False) + + def resolve_cfg() -> Dict[str, str]: + """--arg overrides over env/.env, then built-in defaults.""" + cfg = {key: os.environ.get(key, "") for key, _ in _DEFAULTS} + if getattr(args, "provider", None): + cfg["CLK_PROVIDER"] = args.provider + if getattr(args, "max_iterations", None): + cfg["CLK_MAX_ITERATIONS"] = str(args.max_iterations) + if getattr(args, "project_name", None): + cfg["CLK_PROJECT_NAME"] = args.project_name + if getattr(args, "no_tui_override", None): + cfg["CLK_NO_TUI"] = args.no_tui_override + if getattr(args, "run_install", False): + cfg["CLK_RUN_INSTALL"] = "true" + _apply_defaults(cfg) + return cfg + + cfg = resolve_cfg() + + # Validate; if anything is missing, offer --setup then retry or exit. + missing = _missing_config(cfg, os.environ, idea) + if missing: + print("[kickoff] Cannot start — missing or invalid configuration:\n", file=sys.stderr) + for line in missing: + print(f" • {line}", file=sys.stderr) + print("", file=sys.stderr) + + do_setup = False + tty = _open_tty() + if tty is not None: + try: + print("[kickoff] Run 'clk kickoff --setup' to configure, or answer below.", file=sys.stderr) + tty.write("[kickoff] Run --setup now? [y/N]: ") + tty.flush() + do_setup = (tty.readline() or "").strip().lower() == "y" + finally: + tty.close() + else: + print("[kickoff] Re-run with 'clk kickoff --setup' to configure interactively.", file=sys.stderr) + + if not do_setup: + return 2 + run_setup_wizard(script_dir) + # Reload .env and re-apply overrides + defaults. + _load_env_into(os.environ, env_path) + cfg = resolve_cfg() + missing = _missing_config(cfg, os.environ, idea) + if missing: + print("[kickoff] Still missing after setup — cannot continue:\n", file=sys.stderr) + for line in missing: + print(f" • {line}", file=sys.stderr) + return 2 + + # Create the kickoff directory under workspace/ and drive the harness. + kickoff_dir = _create_kickoff_dir(script_dir, cfg, idea) + if kickoff_dir is None: + return 1 + workspace_dir = kickoff_dir.parent + _prepare_kickoff_repo(kickoff_dir, os.environ) + + rc = _drive_harness(kickoff_dir, cfg, idea) + if rc: + return rc + + print() + print("[kickoff] complete") + print(f"[kickoff] kickoff dir: {kickoff_dir}") + print(f'[kickoff] inspect: cd "{kickoff_dir}" && ./.clk/scripts/clk status') + print(f"[kickoff] workspace: {workspace_dir}") + print(f'[kickoff] reset: rm -rf "{kickoff_dir}"') + return 0 + + +__all__ = [ + "CONFIG_ENV_OVERRIDES", + "VALID_PROVIDERS", + "activate_provider", + "apply_config_env_overrides", + "cmd_kickoff", + "harness_root", + "run_setup_wizard", +] diff --git a/clk_harness/log.py b/clk_harness/log.py new file mode 100644 index 0000000..0781247 --- /dev/null +++ b/clk_harness/log.py @@ -0,0 +1,154 @@ +"""Structured logging for CLK, built on stdlib :mod:`logging`. + +This module is the single sink for harness diagnostics. It preserves the +established user-visible line format:: + + 2026-07-05T18:32:29 [INFO] workflow done: engineering + +on **stderr**, and mirrors every record into the per-run log file under +``.clk/logs/`` opened by :func:`init_log_file` (historically managed by +``clk_harness.utils.logging_utils``, which now delegates here). + +Usage for new code:: + + from clk_harness.log import get_logger + + logger = get_logger(__name__) + logger.info("workflow done: %s", name) + +The legacy helpers (:func:`log`, :func:`log_exception`, :func:`init_log_file`, +:func:`close_log`, :func:`current_log_path`) keep their exact signatures and +output so existing callers and the TUI's log-file tailing keep working. + +The default level is INFO; set ``CLK_LOG_LEVEL=DEBUG`` to surface the +diagnostic breadcrumbs attached to swallowed exceptions. +""" + +from __future__ import annotations + +import datetime as _dt +import logging +import os +import sys +import traceback +from pathlib import Path +from typing import Optional + +_ROOT_NAME = "clk" + +# Historic tags: the harness always printed "[WARN]", not "[WARNING]". +_LEVEL_TAGS = {"WARNING": "WARN"} +_LEVEL_NUMBERS = { + "DEBUG": logging.DEBUG, + "INFO": logging.INFO, + "WARN": logging.WARNING, + "WARNING": logging.WARNING, + "ERROR": logging.ERROR, + "CRITICAL": logging.CRITICAL, +} + +_FILE_HANDLER: Optional[logging.FileHandler] = None +_LOG_PATH: Optional[Path] = None + + +class _ClkFormatter(logging.Formatter): + """`` [LEVEL] message`` — the harness's historic line format.""" + + def format(self, record: logging.LogRecord) -> str: + ts = _dt.datetime.fromtimestamp(record.created).isoformat(timespec="seconds") + level = _LEVEL_TAGS.get(record.levelname, record.levelname) + line = f"{ts} [{level}] {record.getMessage()}" + if record.exc_info: + line = f"{line}\n{self.formatException(record.exc_info)}" + return line + + +def _env_level() -> int: + raw = (os.environ.get("CLK_LOG_LEVEL") or "INFO").strip().upper() + return _LEVEL_NUMBERS.get(raw, logging.INFO) + + +def _root() -> logging.Logger: + """Return the configured ``clk`` root logger (configured once).""" + logger = logging.getLogger(_ROOT_NAME) + if not getattr(logger, "_clk_configured", False): + logger.setLevel(_env_level()) + handler = logging.StreamHandler(sys.stderr) + handler.setFormatter(_ClkFormatter()) + logger.addHandler(handler) + # Don't double-print through the stdlib root logger. + logger.propagate = False + logger._clk_configured = True # type: ignore[attr-defined] + return logger + + +def get_logger(name: str = "") -> logging.Logger: + """Return a per-module logger below the ``clk`` root. + + Pass ``__name__``; a leading ``clk_harness`` package prefix is folded + into the ``clk`` root so records inherit its handlers and level. + """ + _root() + if not name: + return logging.getLogger(_ROOT_NAME) + if name == "clk_harness" or name.startswith("clk_harness."): + name = _ROOT_NAME + name[len("clk_harness"):] + elif name != _ROOT_NAME and not name.startswith(_ROOT_NAME + "."): + name = f"{_ROOT_NAME}.{name}" + return logging.getLogger(name) + + +def init_log_file(log_dir: Path, name: str = "clk") -> Path: + """Open a log file in ``log_dir`` and mirror all records into it. + + Subsequent calls open a new file; the previous handler is closed. + """ + global _FILE_HANDLER, _LOG_PATH + try: + log_dir.mkdir(parents=True, exist_ok=True) + path = log_dir / f"{name}-{_dt.datetime.now().strftime('%Y%m%d-%H%M%S')}.log" + root = _root() + if _FILE_HANDLER is not None: + try: + root.removeHandler(_FILE_HANDLER) + _FILE_HANDLER.close() + except Exception as exc: + print(f"[clk.log.init_log_file] failed to close previous log: {exc}", file=sys.stderr) + traceback.print_exc() + handler = logging.FileHandler(path, mode="a", encoding="utf-8", delay=False) + handler.setFormatter(_ClkFormatter()) + root.addHandler(handler) + _FILE_HANDLER = handler + _LOG_PATH = path + return path + except Exception as exc: + print(f"[clk.log.init_log_file] failed: {exc}", file=sys.stderr) + traceback.print_exc() + return log_dir / "clk-fallback.log" + + +def current_log_path() -> Optional[Path]: + return _LOG_PATH + + +def log(msg: str, level: str = "INFO") -> None: + """Legacy one-shot logger: `` [LEVEL] msg`` to stderr + log file.""" + _root().log(_LEVEL_NUMBERS.get(level.upper(), logging.INFO), "%s", msg) + + +def log_exception(where: str, exc: BaseException) -> None: + """Standard exception logger. Call from every ``except`` block.""" + _root().error("[%s] %s: %s", where, exc.__class__.__name__, exc, exc_info=exc) + + +def close_log() -> None: + global _FILE_HANDLER + if _FILE_HANDLER is not None: + try: + _root().removeHandler(_FILE_HANDLER) + _FILE_HANDLER.close() + except Exception as exc: + print(f"[clk.log.close_log] failed: {exc}", file=sys.stderr) + traceback.print_exc() + finally: + _FILE_HANDLER = None diff --git a/clk_harness/orchestration/__init__.py b/clk_harness/orchestration/__init__.py index de6bc02..aed2c14 100644 --- a/clk_harness/orchestration/__init__.py +++ b/clk_harness/orchestration/__init__.py @@ -1,20 +1,12 @@ """Orchestration primitives for CLK.""" from .agent import AgentObserver, AgentRunner -from .workflow import Workflow, WorkflowRunner, is_provider_failure, load_workflow -from .ralph_loop import RalphLoop from .autoresearch_loop import AutoresearchLoop -from .evaluator import Evaluator, EvalResult, derive_validation -from .scheduler import Scheduler -from .telemetry import CycleTelemetry -from .done_gate import DoneGateVerdict, evaluate_done_gate -from .charter import Charter, bootstrap_charter, derive_done_criteria, load_charter -from .mission import MissionPlan, MissionRunner, PhaseSpec, load_plan from .casting import ( BASELINE_AGENTS, + CastingResult, CharterProposal, ConsensusProposal, - CastingResult, PlanProposal, RoleProposal, WorkflowProposal, @@ -23,15 +15,23 @@ is_baseline, list_roles, parse_charter_proposal, + parse_consensus_proposals, parse_plan_proposal, parse_role_proposals, - parse_consensus_proposals, parse_workflow_proposals, register_role, remove_role, render_roster_summary, write_workflow, ) +from .charter import Charter, bootstrap_charter, derive_done_criteria, load_charter +from .done_gate import DoneGateVerdict, evaluate_done_gate +from .evaluator import EvalResult, Evaluator, derive_validation +from .mission import MissionPlan, MissionRunner, PhaseSpec, load_plan +from .ralph_loop import RalphLoop +from .scheduler import Scheduler +from .telemetry import CycleTelemetry +from .workflow import Workflow, WorkflowRunner, is_provider_failure, load_workflow __all__ = [ "AgentObserver", diff --git a/clk_harness/orchestration/actions.py b/clk_harness/orchestration/actions.py index 93d2a91..09cea20 100644 --- a/clk_harness/orchestration/actions.py +++ b/clk_harness/orchestration/actions.py @@ -64,7 +64,6 @@ import re import shutil import subprocess -import sys from dataclasses import dataclass, field from datetime import datetime from pathlib import Path @@ -74,7 +73,6 @@ from ..utils.activity_log import log_event from ..utils.logging_utils import log, log_exception - # --------------------------------------------------------------------------- # Parsing # --------------------------------------------------------------------------- @@ -557,7 +555,10 @@ def _do_run(paths: Paths, action: Action, result: ActionResult, *, timeout_s: in def _do_done(paths: Paths, action: Action, result: ActionResult) -> None: paths.state.mkdir(parents=True, exist_ok=True) - body = f"# Done\n\nReason: {action.reason or '(no reason given)'}\n\nMarked at {datetime.now().isoformat(timespec='seconds')}\n" + body = ( + f"# Done\n\nReason: {action.reason or '(no reason given)'}\n\n" + f"Marked at {datetime.now().isoformat(timespec='seconds')}\n" + ) (paths.state / "done.md").write_text(body, encoding="utf-8") result.done = True result.done_reason = action.reason or "" diff --git a/clk_harness/orchestration/agent.py b/clk_harness/orchestration/agent.py deleted file mode 100644 index de8c31c..0000000 --- a/clk_harness/orchestration/agent.py +++ /dev/null @@ -1,1707 +0,0 @@ -"""Agent runner. - -Loads a prompt template, renders it against the current state, and -invokes the configured provider. The runner is intentionally thin - -heavier orchestration lives in :mod:`workflow` and the loops. -""" - -from __future__ import annotations - -import json -import sys -import threading -import time -import traceback -from concurrent.futures import ThreadPoolExecutor, as_completed -from dataclasses import dataclass, field -from datetime import datetime -from pathlib import Path -from string import Template -from typing import Any, Dict, List, Optional - -from ..config import Paths -from ..git_ops import add_all, commit as git_commit, has_changes, head_sha, is_repo -from ..providers import AgentProvider, AgentRequest, AgentResponse, load_provider -from ..utils.activity_log import log_event -from ..utils.logging_utils import log, log_exception -from . import casting as _casting -from . import actions as _actions -from . import blackboard as _blackboard -from . import response_quality as _response_quality -from . import noop_guard as _noop_guard - - -def _read_recent_casting_rejections(paths: Paths, *, limit: int = 8) -> str: - """Render the most recent role/workflow rejections into a short feedback - block. Lets the chief see "you tried to create X but Y already exists" - without us hardening the prompt further. - """ - log_path = paths.state / "casting.log" - if not log_path.exists(): - return "" - try: - raw_lines = log_path.read_text(encoding="utf-8").strip().splitlines() - except Exception: - return "" - rows: List[Dict[str, Any]] = [] - for line in reversed(raw_lines): - try: - obj = json.loads(line) - except Exception: - continue - if not isinstance(obj, dict): - continue - event = str(obj.get("event") or "") - if event not in ("role_skipped", "workflow_skipped"): - continue - rows.append(obj) - if len(rows) >= limit: - break - if not rows: - return "" - out: List[str] = ["Recent casting rejections (learn from these — reuse the existing role instead):"] - for r in reversed(rows): - kind = "role" if r.get("event") == "role_skipped" else "workflow" - name = r.get("name") or "?" - reason = r.get("reason") or "?" - ts = r.get("timestamp") or "" - out.append(f"- {ts} {kind} `{name}` rejected: {reason}") - return "\n".join(out) - - -@dataclass -class AgentSpec: - name: str - prompt_file: str - provider: Optional[str] = None - role: str = "" - capabilities: List[str] = field(default_factory=list) - - @classmethod - def from_config(cls, name: str, cfg: Dict[str, Any]) -> "AgentSpec": - return cls( - name=name, - prompt_file=cfg.get("prompt") or f"{name}.md", - provider=cfg.get("provider"), - role=cfg.get("role", ""), - capabilities=list(cfg.get("capabilities") or []), - ) - - -@dataclass -class AgentRun: - agent: str - objective: str - response: AgentResponse - started_at: str - finished_at: str - files_written: List[str] = field(default_factory=list) - # Number of file-mutating ACTIONs (write/edit/append/delete) the harness - # actually applied for this run. Used by the no-op guard: a producing - # stage that applied zero mutations is re-dispatched with an escalating - # repair preamble (descriptions alone do nothing). - file_mutations_applied: int = 0 - # True when the runner already created a git commit for this run's - # actions; downstream consumers (WorkflowRunner._commit) skip in - # that case to avoid double-committing the same diff. - committed: bool = False - # Blackboard post ids written by this run via POST blocks. The - # workflow runner uses these to verify ``WorkflowStage.outputs`` - # contracts and to drive review-stage digests. - posts: List[str] = field(default_factory=list) - - -class AgentObserver: - """Optional hook invoked by :class:`AgentRunner` around every agent call. - - Subclasses override the methods they care about. All methods are wrapped - in try/except inside the runner so an observer bug never breaks a run. - """ - - def begin(self, agent: str, objective: str) -> None: # pragma: no cover - pass - - def prompt_sent(self, agent: str, prompt: str) -> None: # pragma: no cover - """Called after the prompt has been rendered, before provider invocation.""" - pass - - def end(self, agent: str, run: "AgentRun") -> None: # pragma: no cover - pass - - def progress(self, agent: str, kind: str, message: str) -> None: # pragma: no cover - """Streaming progress from a CLI provider's subprocess. - - ``kind`` is one of: ``"start"`` (subprocess launched, message - carries pid + cmd), ``"stdout_line"`` / ``"stderr_line"`` (each - line emitted), ``"end"`` (subprocess exited, message carries - rc), ``"timeout"`` (killed). The TUI uses this to show the user - what the underlying CLI is actually doing rather than letting - them stare at a stalled spinner. - """ - pass - - def log(self, line: str) -> None: # pragma: no cover - pass - - def roster_changed(self, name: str, status: str) -> None: # pragma: no cover - """Called when a dynamic role is added / updated / removed. - - ``status`` is one of ``"added"``, ``"updated"``, ``"removed"``, - ``"workflow_written"``, ``"prompt_updated"``. - """ - pass - - -class AgentRunner: - """Render prompts, invoke providers, persist outputs.""" - - def __init__( - self, - paths: Paths, - agents_cfg: Dict[str, Any], - providers_cfg: Dict[str, Any], - clk_cfg: Dict[str, Any], - observer: Optional[AgentObserver] = None, - ) -> None: - self.paths = paths - self.agents_cfg = agents_cfg - self.providers_cfg = providers_cfg - self.clk_cfg = clk_cfg - self.observer = observer - # Serialises agents_cfg mutations from _apply_proposals so parallel - # workflow stages don't race when both emit PROPOSE_ROLE blocks. - # RLock so consensus coalescing (which calls run() recursively) works. - self._proposals_lock = threading.RLock() - # Lock around meta-prompt cache reads/writes so parallel stages - # racing to draft the same dispatch prompt don't corrupt the file. - self._meta_cache_lock = threading.Lock() - - # -- public ------------------------------------------------------------ - - def get_agent(self, name: str) -> AgentSpec: - cfg = (self.agents_cfg.get("agents") or {}).get(name) - if cfg is None: - cfg = {"prompt": f"{name}.md", "provider": None, "role": ""} - return AgentSpec.from_config(name, cfg) - - def get_provider(self, name: Optional[str]) -> AgentProvider: - import os as _os - from ..config import DEFAULT_PROVIDERS - # CLK_PROVIDER (set in the global .env) is an explicit, authoritative - # choice, so it overrides the workspace's saved `active`. Precedence: - # per-agent pin > env CLK_PROVIDER > providers.json active > - # clk.config default_provider > shell. - env_provider = (_os.environ.get("CLK_PROVIDER") or "").strip() - target = ( - name - or env_provider - or self.providers_cfg.get("active") - or self.clk_cfg.get("default_provider") - or "shell" - ) - prov_cfg = (self.providers_cfg.get("providers") or {}).get(target) - if prov_cfg is None: - # No saved block (e.g. an env-selected provider, or a dropped - # block) -> use the built-in default block for known providers so - # we still call the real provider instead of silently echoing. - prov_cfg = (DEFAULT_PROVIDERS.get("providers") or {}).get(target) - if prov_cfg is None: - log(f"unknown provider '{target}', falling back to shell", level="WARN") - target = "shell" - prov_cfg = (self.providers_cfg.get("providers") or {}).get("shell") or {"type": "shell"} - return load_provider(target, prov_cfg) - - def render_prompt(self, agent: AgentSpec, objective: str, extra: Optional[Dict[str, Any]] = None) -> str: - try: - template = self._load_prompt_template(agent.prompt_file) - ctx = self._collect_context(objective, extra or {}) - return self._safe_substitute(template, ctx) - except Exception as exc: - log_exception("orchestration.agent.render_prompt", exc) - return objective - - # Phases whose dispatches must never re-trigger the auto-consensus or - # quality-retry layers. Otherwise consensus coalescing, checkpoint - # verdicts, recovery dispatches, and the critic-judge inner loop - # would all recurse into themselves. - _META_PHASES = frozenset({ - "consensus_sample", - "consensus", - "checkpoint", - "recovery", - "draft_dispatch_prompt", - "draft_role_prompt", - "qa_answer", - "refine_critic", - "refine_worker", - # Mission-level chief dispatches: single-shot planning / gating that - # must not recurse into consensus or the quality-retry loop. - "charter", - "mission_plan", - "phase_gate", - }) - - def run( - self, - agent_name: str, - objective: str, - *, - extra: Optional[Dict[str, Any]] = None, - dry_run: Optional[bool] = None, - ) -> AgentRun: - """Public dispatch entry point. - - Wraps :meth:`_dispatch_once` with two robustness layers: - - * **Proactive auto-consensus** (`robustness.auto_consensus`) — - stages marked ``careful: true`` (or all stages, when set to - ``"always"``) fan into N stochastic samples and a chief - coalescing pass instead of a single dispatch. - * **Quality-driven re-dispatch** — after a normal dispatch, the - response is scored against ``response_quality``; recoverable - failures (empty, malformed, contract-missing, low-confidence) - trigger a re-run with a repair preamble, escalating to - consensus on the final retry. - - Both layers are gated by ``clk.config.json::robustness`` and - bypassed for dispatches whose ``extra.phase`` indicates a - meta-path (consensus coalescing, recovery, checkpoint, etc.) so - the harness never loops on itself. - """ - extra_dict: Dict[str, Any] = dict(extra or {}) - phase = str(extra_dict.get("phase") or "") - in_meta = phase in self._META_PHASES - is_dry = self.clk_cfg.get("dry_run", False) if dry_run is None else dry_run - - if not in_meta and not is_dry and self._should_auto_consensus(agent_name, extra_dict): - return self._dispatch_auto_consensus( - agent_name, - objective, - extra=extra_dict, - dry_run=dry_run, - reason="auto_consensus_proactive", - ) - - if in_meta or is_dry: - return self._dispatch_once(agent_name, objective, extra=extra_dict, dry_run=dry_run) - - return self._dispatch_with_quality_loop( - agent_name, objective, extra=extra_dict, dry_run=dry_run - ) - - def _dispatch_with_quality_loop( - self, - agent_name: str, - objective: str, - *, - extra: Dict[str, Any], - dry_run: Optional[bool], - ) -> AgentRun: - """Quality-validated dispatch wrapper. - - Runs :meth:`_dispatch_once`, scores the response, and re-runs - the worker with a repair preamble when the verdict is - recoverable. Escalates to ``_dispatch_auto_consensus`` on the - final retry when ``auto_consensus`` is not ``"off"``. - """ - cfg = self.clk_cfg.get("robustness") or {} - max_retries = int(cfg.get("max_quality_retries") or 0) - min_chars = int(cfg.get("min_response_chars") or 40) - auto_consensus_mode = str(cfg.get("auto_consensus") or "off").lower() - expected_outputs = list(extra.get("stage_outputs") or []) - tel = extra.get("telemetry") - - # FM1 no-op guard: a producing stage that applies zero file mutations - # is re-dispatched with an escalating repair preamble (descriptions - # alone do nothing). Independent budget from the quality retries. - expect_mutation = _noop_guard.is_mutation_expected( - agent_name, - outputs=expected_outputs, - commit=bool(extra.get("commit")), - cfg=self.clk_cfg, - ) - max_noop = _noop_guard.max_redispatch(self.clk_cfg) - noop_redispatches = 0 - - attempt = 0 - current_objective = objective - last_run: Optional[AgentRun] = None - while True: - attempt += 1 - attempt_extra = dict(extra) - attempt_extra["quality_attempt"] = attempt - run = self._dispatch_once( - agent_name, current_objective, extra=attempt_extra, dry_run=dry_run - ) - last_run = run - if not run.response.ok: - return run - # No-op check: a producing stage that returned substantive prose - # but changed no files. (An empty/near-empty response is left to - # the quality "empty" flag below — that is a different failure.) - substantive = len((run.response.text or "").strip()) >= min_chars - if ( - expect_mutation - and substantive - and run.file_mutations_applied == 0 - and noop_redispatches < max_noop - ): - noop_redispatches += 1 - if tel is not None: - try: - tel.add_noop_redispatch() - except Exception: - pass - log_event( - self.paths, - "agent_noop_redispatch", - agent=agent_name, - attempt=noop_redispatches, - max_attempts=max_noop, - stage_id=extra.get("stage_id"), - workflow=extra.get("workflow"), - ) - self._observer_log( - f"noop :: {agent_name} :: changed no files; " - f"re-dispatch {noop_redispatches}/{max_noop}" - ) - preamble = _noop_guard.repair_preamble( - noop_redispatches, target=str(extra.get("expected_path") or "") - ) - current_objective = preamble + "\n\nOriginal objective:\n" + objective - continue - try: - q = _response_quality.score( - run.response.text, - min_chars=min_chars, - expected_outputs=expected_outputs, - ) - except Exception as exc: - log_exception("orchestration.agent._dispatch_with_quality_loop.score", exc) - return run - if q.ok or not q.recoverable or attempt > max_retries: - if not q.ok: - log_event( - self.paths, - "agent_quality_final", - agent=agent_name, - attempt=attempt, - ok=q.ok, - recoverable=q.recoverable, - flags=list(q.flags), - reasons=list(q.reasons), - score=q.score, - confidence=q.confidence, - needs_review=q.needs_review, - ) - return run - if tel is not None: - try: - tel.add_quality_retry() - except Exception: - pass - log_event( - self.paths, - "agent_quality_retry", - agent=agent_name, - attempt=attempt, - next_attempt=attempt + 1, - max_attempts=max_retries + 1, - flags=list(q.flags), - reasons=list(q.reasons), - score=q.score, - confidence=q.confidence, - needs_review=q.needs_review, - ) - self._observer_log( - f"quality :: {agent_name} :: retry {attempt}/{max_retries} " - f"flags={','.join(q.flags) or '?'} score={q.score:.2f}" - ) - # On the final retry, optionally escalate to a consensus - # fan-out rather than another single-shot retry — that way - # we get sub-sub-agents on actually-shaky outputs even when - # the stage isn't marked careful. - if attempt == max_retries and auto_consensus_mode != "off": - return self._dispatch_auto_consensus( - agent_name, - objective, - extra=extra, - dry_run=dry_run, - reason=f"quality_escalation:{','.join(q.flags)}", - ) - current_objective = q.repair_hint() + "\n\nOriginal objective:\n" + objective - return last_run # unreachable - - def _should_auto_consensus(self, agent_name: str, extra: Dict[str, Any]) -> bool: - """Proactive auto-consensus trigger check.""" - cfg = self.clk_cfg.get("robustness") or {} - mode = str(cfg.get("auto_consensus") or "off").lower() - if mode in ("", "off", "false", "0"): - return False - # Never fan-out the chief on its own meta-paths. - if agent_name == "chief": - return False - if mode == "always": - return True - # on_careful: only when the stage explicitly opted in. - if mode == "on_careful": - return bool(extra.get("careful")) - return False - - def _dispatch_auto_consensus( - self, - agent_name: str, - objective: str, - *, - extra: Dict[str, Any], - dry_run: Optional[bool], - reason: str = "auto_consensus", - ) -> AgentRun: - """Fan-out a single dispatch into N stochastic samples + coalesce. - - Reuses :meth:`_run_consensus_sample` (same code path as - ``PROPOSE_CONSENSUS``) so the sampling, logging, and parallelism - behavior is identical. The chief is invoked to coalesce. - """ - cfg = self.clk_cfg.get("consensus") or {} - sample_count = max(1, min(int(cfg.get("max_samples") or 3), 6)) - max_parallel = max(1, int(cfg.get("max_parallel") or 4)) - tel = extra.get("telemetry") - if tel is not None: - try: - tel.add_consensus_run() - except Exception: - pass - name = f"auto_{agent_name}_{datetime.now().strftime('%H%M%S%f')}" - log_event( - self.paths, - "consensus_started", - agent=agent_name, - name=name, - objective=objective, - agents=[agent_name] * sample_count, - samples=sample_count, - max_parallel=max_parallel, - trigger=reason, - ) - self._observer_log( - f"consensus :: auto/{agent_name} :: starting {sample_count} samples " - f"(reason={reason})" - ) - results: List[Dict[str, Any]] = [] - with ThreadPoolExecutor(max_workers=min(max_parallel, sample_count)) as pool: - futs = { - pool.submit(self._run_consensus_sample, name, idx + 1, agent_name, objective): idx + 1 - for idx in range(sample_count) - } - for fut in as_completed(futs): - idx = futs[fut] - try: - results.append(fut.result()) - except Exception as exc: - log_exception("orchestration.agent._dispatch_auto_consensus.sample", exc) - results.append({ - "sample": idx, "agent": agent_name, "ok": False, - "error": str(exc), "text": "", - }) - results.sort(key=lambda r: int(r.get("sample") or 0)) - log_event( - self.paths, - "consensus_samples_completed", - agent=agent_name, - name=name, - results=results, - trigger=reason, - ) - coalesce_objective = self._consensus_coalesce_objective(name, objective, results) - coalesced = self._dispatch_once( - "chief", - coalesce_objective, - extra={ - "phase": "consensus", - "consensus_name": name, - "consensus_trigger": reason, - "stage_id": extra.get("stage_id"), - "workflow": extra.get("workflow"), - }, - dry_run=dry_run, - ) - log_event( - self.paths, - "consensus_coalesced", - agent="chief", - name=name, - ok=coalesced.response.ok, - response_text=coalesced.response.text or "", - error=coalesced.response.error, - trigger=reason, - ) - # The coalesced output is the only result the workflow ever sees, - # so it must clear the same quality bar as a direct dispatch. One - # repair pass: re-score, and if the verdict is recoverable, ask the - # chief to re-emit with the specific issues quoted back. - if coalesced.response.ok: - try: - cfg_r = self.clk_cfg.get("robustness") or {} - q = _response_quality.score( - coalesced.response.text, - min_chars=int(cfg_r.get("min_response_chars") or 40), - expected_outputs=list(extra.get("stage_outputs") or []), - ) - if not q.ok and q.recoverable: - log_event( - self.paths, - "consensus_coalesce_retry", - agent=agent_name, - name=name, - flags=list(q.flags), - reasons=list(q.reasons), - score=q.score, - trigger=reason, - ) - self._observer_log( - f"consensus :: {name} :: coalesce rejected " - f"(flags={','.join(q.flags)}); re-dispatching chief" - ) - repaired = self._dispatch_once( - "chief", - q.repair_hint() + "\n\nOriginal coalescing task:\n" + coalesce_objective, - extra={ - "phase": "consensus", - "consensus_name": name, - "consensus_trigger": f"{reason}:coalesce_repair", - "stage_id": extra.get("stage_id"), - "workflow": extra.get("workflow"), - }, - dry_run=dry_run, - ) - if repaired.response.ok: - coalesced = repaired - except Exception as exc: - log_exception("orchestration.agent._dispatch_auto_consensus.rescore", exc) - # Re-label so downstream logging shows the auto path, not "chief". - coalesced.agent = agent_name - return coalesced - - def _dispatch_once( - self, - agent_name: str, - objective: str, - *, - extra: Optional[Dict[str, Any]] = None, - dry_run: Optional[bool] = None, - ) -> AgentRun: - """Single provider dispatch with provider-level retry only. - - This was the body of :meth:`run` before the robustness layers - wrapped it. Keep it self-contained so consensus / refine / - recovery paths can call it without re-entering the wrappers. - """ - agent = self.get_agent(agent_name) - provider = self.get_provider(agent.provider) - prompt = self.render_prompt(agent, objective, extra) - is_dry = self.clk_cfg.get("dry_run", False) if dry_run is None else dry_run - - observer = self.observer - paths = self.paths - - def _on_progress(kind: str, message: str) -> None: - # Log the provider subprocess stream verbatim. This log is - # intended for post-run forensics, so detail is more useful - # than compactness here. - try: - extra: Dict[str, Any] = {} - if kind == "command": - try: - parsed = json.loads(message) - if isinstance(parsed, dict): - extra = parsed - except Exception: - extra = {} - log_event( - paths, - ("http_" + kind[5:] if kind.startswith("http_") else "subprocess_" + kind), - agent=agent.name, - message=message, - message_chars=len(message or ""), - **extra, - ) - except Exception: - pass - if observer is None: - return - try: - observer.progress(agent.name, kind, message) - except Exception as exc: - log_exception("orchestration.agent.observer.progress", exc) - - timeout_s = int((self.clk_cfg.get("provider_timeout_s") or 300)) - no_output_timeout_s = int((self.clk_cfg.get("provider_no_output_timeout_s") or 0)) - retry_cfg = self.clk_cfg.get("provider_retry") or {} - max_retries = int(retry_cfg.get("max_retries", self.clk_cfg.get("provider_max_retries", 1)) or 0) - backoff_s = float(retry_cfg.get("backoff_s", self.clk_cfg.get("provider_retry_backoff_s", 5)) or 0) - req = AgentRequest( - agent=agent.name, - prompt=prompt, - workdir=self.paths.root, - dry_run=bool(is_dry), - timeout_s=timeout_s, - no_output_timeout_s=no_output_timeout_s, - on_progress=_on_progress, - capabilities=list(agent.capabilities or []), - ) - started = datetime.now().isoformat(timespec="seconds") - run_id = f"{started.replace(':','-')}-{agent.name}" - run_dir_rel = f".clk/runs/{run_id}" - log_event( - self.paths, - "agent_dispatch", - agent=agent.name, - action="dispatch", - objective=objective, - objective_chars=len(objective or ""), - workflow=(extra or {}).get("workflow"), - stage_id=(extra or {}).get("stage_id"), - iteration=(extra or {}).get("iteration"), - phase=(extra or {}).get("phase"), - provider=provider.describe(), - dry_run=bool(is_dry), - timeout_s=timeout_s, - no_output_timeout_s=no_output_timeout_s, - prompt_file=agent.prompt_file, - role=agent.role, - capabilities=list(agent.capabilities or []), - run_id=run_id, - max_retries=max_retries, - retry_backoff_s=backoff_s, - ) - log_event( - self.paths, - "prompt_sent", - agent=agent.name, - action="prompt_sent", - prompt_chars=len(prompt), - prompt_path=f"{run_dir_rel}/prompt.txt", - prompt=prompt, - run_id=run_id, - ) - if self.observer is not None: - try: - self.observer.begin(agent.name, objective) - except Exception as exc: - log_exception("orchestration.agent.observer.begin", exc) - try: - self.observer.prompt_sent(agent.name, prompt) - except Exception as exc: - log_exception("orchestration.agent.observer.prompt_sent", exc) - resp = AgentResponse(ok=False, error="provider_not_invoked") - attempt = 0 - while True: - attempt += 1 - log_event( - self.paths, - "provider_attempt", - agent=agent.name, - run_id=run_id, - attempt=attempt, - max_attempts=max_retries + 1, - provider=provider.describe(), - ) - try: - resp = provider.invoke(req) - except Exception as exc: - log_exception(f"orchestration.agent.run[{agent_name}]", exc) - resp = AgentResponse(ok=False, error=str(exc)) - if resp.ok or not self._should_retry_provider(resp.error or "") or attempt > max_retries: - break - log_event( - self.paths, - "provider_retry", - agent=agent.name, - run_id=run_id, - attempt=attempt, - next_attempt=attempt + 1, - backoff_s=backoff_s, - error=resp.error, - ) - _on_progress( - "retry", - f"provider error '{resp.error}'; killed stalled process if present; " - f"backing off {backoff_s:.1f}s then reissuing attempt {attempt + 1}/{max_retries + 1}", - ) - if backoff_s > 0: - time.sleep(backoff_s * (2 ** (attempt - 1))) - finished = datetime.now().isoformat(timespec="seconds") - run = AgentRun( - agent=agent.name, - objective=objective, - response=resp, - started_at=started, - finished_at=finished, - files_written=list(resp.files_written or []), - ) - self._record(run, prompt, provider.describe()) - log_event( - self.paths, - "agent_response", - agent=agent.name, - action="response_received", - ok=run.response.ok, - error=run.response.error, - response_chars=len(run.response.text or ""), - response_path=f"{run_dir_rel}/response.txt", - response_text=run.response.text or "", - tokens_total=int((run.response.usage or {}).get("total_tokens") or 0), - tokens_in=int((run.response.usage or {}).get("input_tokens") or 0), - tokens_out=int((run.response.usage or {}).get("output_tokens") or 0), - usage_source=(run.response.usage or {}).get("source"), - files_reported=list(run.files_written or []), - run_id=run_id, - ) - # Persist POST blocks to the blackboard before the rest of the - # apply hooks. Posting is cheap and uncommitted, so it happens - # even for dry-runs to keep the digest accurate during planning. - self._apply_posts(run, extra or {}) - # Apply any PROPOSE_ROLE / PROPOSE_WORKFLOW blocks the agent - # emitted. Mutates ``self.agents_cfg`` in place so the very next - # stage that names a freshly-proposed role can dispatch to it. - self._apply_proposals(run) - self._apply_consensus(run, extra or {}) - # Execute any ACTION blocks the agent emitted. Real file edits - # / shell runs land here regardless of which provider produced - # the response, so even non-tool-using providers can drive real - # changes. We merge the harness-applied files into the run's - # files_written list so the TUI / commit logic see them. - if not is_dry: - self._apply_actions(run, extra or {}) - if self.observer is not None: - try: - self.observer.end(agent.name, run) - except Exception as exc: - log_exception("orchestration.agent.observer.end", exc) - return run - - def _should_retry_provider(self, error: str) -> bool: - msg = (error or "").lower() - retryable = [ - "no output for", - "timeout after", - "operation was aborted", - # OpenRouter can report this routing/policy text transiently - # even when a later identical request succeeds. - "no endpoints available", - "guardrail restrictions", - "data policy", - "connection reset", - "temporarily unavailable", - "try again", - # HTTP 429 rate-limiting and HTTP 404 (OpenRouter: no endpoints temporarily available) - "http 429", - "http 404", - ] - non_retryable = [ - "api key", - "authentication", - "unauthorized", - "forbidden", - "cli not found", - ] - return any(s in msg for s in retryable) and not any(s in msg for s in non_retryable) - - def _observer_log(self, line: str) -> None: - log(line) - if self.observer is not None: - try: - self.observer.log(line) - except Exception as exc: - log_exception("orchestration.agent.observer.log", exc) - - def _apply_consensus(self, run: AgentRun, extra: Dict[str, Any]) -> None: - text = run.response.text or "" - if not text or "PROPOSE_CONSENSUS" not in text: - return - if str(extra.get("phase") or "") == "consensus": - return - proposals = _casting.parse_consensus_proposals(text) - if not proposals: - return - cfg = self.clk_cfg.get("consensus") or {} - max_samples = int(cfg.get("max_samples") or 6) - max_parallel = int(cfg.get("max_parallel") or 4) - for prop in proposals: - agents = [a for a in prop.agents if a in (self.agents_cfg.get("agents") or {})] - if not agents: - agents = [run.agent] - sample_count = min(max_samples, max(1, int(prop.copies or 3))) - assignments = [agents[i % len(agents)] for i in range(sample_count)] - log_event( - self.paths, - "consensus_started", - agent=run.agent, - name=prop.name, - objective=prop.objective, - agents=list(assignments), - samples=sample_count, - max_parallel=max_parallel, - ) - self._observer_log( - f"consensus :: {prop.name} :: starting {sample_count} samples " - f"across {', '.join(sorted(set(assignments)))}" - ) - results: List[Dict[str, Any]] = [] - with ThreadPoolExecutor(max_workers=max(1, min(max_parallel, sample_count))) as pool: - futs = { - pool.submit(self._run_consensus_sample, prop.name, idx + 1, agent_name, prop.objective): ( - idx + 1, - agent_name, - ) - for idx, agent_name in enumerate(assignments) - } - for fut in as_completed(futs): - idx, agent_name = futs[fut] - try: - results.append(fut.result()) - except Exception as exc: - log_exception("orchestration.agent._apply_consensus.sample", exc) - results.append({"sample": idx, "agent": agent_name, "ok": False, "error": str(exc), "text": ""}) - results.sort(key=lambda r: int(r.get("sample") or 0)) - log_event( - self.paths, - "consensus_samples_completed", - agent=run.agent, - name=prop.name, - results=results, - ) - self._observer_log(f"consensus :: {prop.name} :: samples complete; coalescing with chief") - coalesce = self._consensus_coalesce_objective(prop.name, prop.objective, results) - coalesced = self.run( - "chief", - coalesce, - extra={"phase": "consensus", "consensus_name": prop.name}, - ) - log_event( - self.paths, - "consensus_coalesced", - agent="chief", - name=prop.name, - ok=coalesced.response.ok, - response_text=coalesced.response.text or "", - error=coalesced.response.error, - ) - self._observer_log(f"consensus :: {prop.name} :: coalesced by chief") - - def _run_consensus_sample(self, name: str, sample: int, agent_name: str, objective: str) -> Dict[str, Any]: - label = f"{agent_name}#consensus{sample}" - agent = self.get_agent(agent_name) - provider = self.get_provider(agent.provider) - sample_objective = ( - f"Stochastic consensus sample `{name}` #{sample}.\n\n" - "Answer independently. Do not coordinate with other samples.\n\n" - f"Consensus objective:\n{objective}" - ) - prompt = self.render_prompt(agent, sample_objective, {"phase": "consensus_sample", "agent": agent_name}) - started = datetime.now().isoformat(timespec="seconds") - run_id = f"{started.replace(':','-')}-{label}" - timeout_s = int((self.clk_cfg.get("provider_timeout_s") or 300)) - no_output_timeout_s = int((self.clk_cfg.get("provider_no_output_timeout_s") or 0)) - - def _progress(kind: str, message: str) -> None: - log_event( - self.paths, - ("http_" + kind[5:] if kind.startswith("http_") else "subprocess_" + kind), - agent=label, - consensus=name, - sample=sample, - message=message, - message_chars=len(message or ""), - ) - if self.observer is not None: - try: - self.observer.progress(label, kind, message) - except Exception: - pass - - log_event( - self.paths, - "consensus_sample_dispatch", - agent=label, - base_agent=agent_name, - consensus=name, - sample=sample, - objective=objective, - provider=provider.describe(), - run_id=run_id, - ) - self._observer_log( - f"consensus :: {name} :: sample #{sample} dispatching ({agent_name})" - ) - if self.observer is not None: - self.observer.begin(label, sample_objective) - self.observer.prompt_sent(label, prompt) - req = AgentRequest( - agent=label, - prompt=prompt, - workdir=self.paths.root, - dry_run=bool(self.clk_cfg.get("dry_run", False)), - timeout_s=timeout_s, - no_output_timeout_s=no_output_timeout_s, - on_progress=_progress, - ) - try: - resp = provider.invoke(req) - except Exception as exc: - resp = AgentResponse(ok=False, error=str(exc)) - finished = datetime.now().isoformat(timespec="seconds") - arun = AgentRun(agent=label, objective=sample_objective, response=resp, started_at=started, finished_at=finished) - self._record(arun, prompt, provider.describe()) - if self.observer is not None: - self.observer.end(label, arun) - self._observer_log( - f"consensus :: {name} :: sample #{sample} done " - f"({'ok' if resp.ok else 'error: ' + (resp.error or '?')})" - ) - log_event( - self.paths, - "consensus_sample_response", - agent=label, - base_agent=agent_name, - consensus=name, - sample=sample, - ok=resp.ok, - error=resp.error, - response_text=resp.text or "", - ) - return {"sample": sample, "agent": agent_name, "label": label, "ok": resp.ok, "error": resp.error, "text": resp.text or ""} - - def _consensus_coalesce_objective(self, name: str, objective: str, results: List[Dict[str, Any]]) -> str: - parts = [ - f"Coalesce stochastic consensus `{name}` into one coherent response.", - "", - "Original consensus objective:", - objective, - "", - "Samples:", - ] - for r in results: - parts.append(f"\n--- sample {r.get('sample')} agent={r.get('agent')} ok={r.get('ok')} error={r.get('error') or ''} ---") - parts.append((r.get("text") or "").strip() or "(no response)") - parts.append("\nReturn a unified answer with agreements, disagreements, and the recommended decision.") - return "\n".join(parts) - - # -- meta-prompting ---------------------------------------------------- - - def _meta_cache_path(self) -> Path: - return self.paths.cache / "meta_prompts.jsonl" - - def _meta_cache_lookup(self, key: str) -> Optional[str]: - path = self._meta_cache_path() - if not path.exists(): - return None - try: - with self._meta_cache_lock, path.open("r", encoding="utf-8") as fh: - for line in fh: - try: - obj = json.loads(line) - except Exception: - continue - if isinstance(obj, dict) and obj.get("key") == key: - return obj.get("value") or "" - except Exception as exc: - log_exception("orchestration.agent._meta_cache_lookup", exc) - return None - - def _meta_cache_store(self, key: str, value: str, *, kind: str) -> None: - path = self._meta_cache_path() - try: - with self._meta_cache_lock: - path.parent.mkdir(parents=True, exist_ok=True) - with path.open("a", encoding="utf-8") as fh: - fh.write(json.dumps({ - "key": key, - "kind": kind, - "value": value, - "ts": datetime.now().isoformat(timespec="seconds"), - }) + "\n") - except Exception as exc: - log_exception("orchestration.agent._meta_cache_store", exc) - - @staticmethod - def _meta_key(*parts: str) -> str: - import hashlib - h = hashlib.sha256() - for p in parts: - h.update(b"\x00") - h.update((p or "").encode("utf-8")) - return h.hexdigest() - - def meta_draft_dispatch_prompt( - self, - *, - agent_name: str, - base_objective: str, - blackboard_inputs: Optional[List[str]] = None, - stage_outputs: Optional[List[str]] = None, - ) -> Optional[str]: - """Ask the chief to draft a tighter task prompt for ``agent_name``. - - Returns the drafted objective, or ``None`` when meta-prompting - is disabled or the chief produced no usable text. Cached on disk - so repeated dispatches with identical inputs cost nothing. - """ - cfg = self.clk_cfg.get("meta_prompt") or {} - mode = str(cfg.get("dispatch") or "off").lower() - if mode in ("", "off", "false", "0"): - return None - inputs_key = ",".join(sorted(blackboard_inputs or [])) - outputs_key = ",".join(sorted(stage_outputs or [])) - key = self._meta_key("dispatch", agent_name, base_objective, inputs_key, outputs_key) - cached = self._meta_cache_lookup(key) - if cached: - return cached - try: - agent_spec = self.get_agent(agent_name) - role_line = agent_spec.role or "" - prompt_path = self.paths.prompts / agent_spec.prompt_file - system_preview = "" - if prompt_path.exists(): - system_preview = " ".join(prompt_path.read_text(encoding="utf-8").strip().split())[:600] - contract_lines = "" - if stage_outputs: - produces = ", ".join(stage_outputs) - contract_lines = ( - f"\nThis stage declares an outputs contract: {produces}.\n" - "Your drafted prompt MUST explicitly instruct the worker to end\n" - "with a POST block whose PRODUCES line lists exactly these keys\n" - f"(`PRODUCES: {produces}`) — the harness rejects responses that\n" - "miss any key, so spell it out rather than assuming the worker\n" - "infers it.\n" - ) - objective = ( - f"Draft a tighter task prompt for the `{agent_name}` agent for the\n" - f"objective below. Output ONLY the new objective text — no preamble,\n" - f"no commentary. Keep it focused, concrete, and at most 8 sentences.\n" - f"Reference any relevant blackboard posts the worker should consult.\n" - f"{contract_lines}\n" - "Compliance requirements your drafted prompt must convey:\n" - "- Deliverables are FILES written via ACTION blocks; prose alone\n" - " does not count and the work will be considered missing.\n" - "- Say concretely what 'done' looks like (which files exist, what\n" - " they contain, what validation passes).\n\n" - f"Worker role line: {role_line or '(none)'}\n" - f"Worker system prompt preview: {system_preview or '(missing)'}\n\n" - f"Original objective:\n{base_objective}\n" - ) - self._observer_log( - f"meta :: drafting dispatch prompt for {agent_name} via chief" - ) - run = self.run( - "chief", - objective, - extra={ - "phase": "draft_dispatch_prompt", - "target_agent": agent_name, - "blackboard_inputs": list(blackboard_inputs or []), - }, - ) - except Exception as exc: - log_exception("orchestration.agent.meta_draft_dispatch_prompt", exc) - return None - text = (run.response.text or "").strip() - if not text: - return None - self._meta_cache_store(key, text, kind="dispatch") - log_event( - self.paths, - "meta_prompt_drafted", - agent="chief", - target_agent=agent_name, - kind="dispatch", - chars=len(text), - ) - return text - - def meta_draft_role_prompt( - self, - *, - role_name: str, - role_line: str, - hint: str = "", - ) -> Optional[str]: - """Ask the chief to draft a system prompt body for a new role. - - Returns the drafted prompt text or ``None`` when disabled. - Cached on disk by ``(role_name, role_line, hint)``. Callers - typically use this after ``register_role`` produced a scaffold - prompt and the chief should write a real one. - """ - cfg = self.clk_cfg.get("meta_prompt") or {} - mode = str(cfg.get("role") or "off").lower() - if mode in ("", "off", "false", "0"): - return None - key = self._meta_key("role", role_name, role_line, hint) - cached = self._meta_cache_lookup(key) - if cached: - return cached - try: - objective = ( - f"Draft a system prompt for a new agent named `{role_name}`.\n" - f"Role line: {role_line or '(none)'}\n" - + (f"Additional hint: {hint}\n" if hint else "") - + "\nThe prompt body must be self-contained and use these placeholders:\n" - "$$objective $$state_summary $$idea_title $$idea_statement\n" - "$$project_name $$project_root $$iteration\n" - "Output ONLY the prompt body — no PROPOSE_ROLE wrapper, no commentary.\n" - "Keep it under 50 lines. Make the role's distinct ownership explicit\n" - "compared with existing roles.\n\n" - "The harness automatically appends the ACTION/POST protocol blocks\n" - "to every role prompt, so do NOT restate them. Instead, your draft\n" - "MUST include an Output section that tells the agent:\n" - "- deliverables are files written via ACTION blocks (prose alone\n" - " is not a deliverable and the harness treats it as missing work);\n" - "- to end with a POST block summarising the result, with PRODUCES\n" - " listing any contract keys its stage declares;\n" - "- what a complete, verifiable result looks like for this role.\n" - "Before emitting, re-read your draft as if you were a small local\n" - "model: remove ambiguity, prefer imperative checklists over essays." - ) - self._observer_log( - f"meta :: drafting role prompt for {role_name} via chief" - ) - run = self.run( - "chief", - objective, - extra={"phase": "draft_role_prompt", "target_role": role_name}, - ) - except Exception as exc: - log_exception("orchestration.agent.meta_draft_role_prompt", exc) - return None - text = (run.response.text or "").strip() - if not text: - return None - self._meta_cache_store(key, text, kind="role") - log_event( - self.paths, - "meta_prompt_drafted", - agent="chief", - target_role=role_name, - kind="role", - chars=len(text), - ) - return text - - # -- casting ----------------------------------------------------------- - - def _apply_proposals(self, run: AgentRun) -> None: - text = (run.response.text or "") - if not text or ("PROPOSE_ROLE" not in text and "PROPOSE_WORKFLOW" not in text): - return - cap = int(((self.clk_cfg.get("casting") or {}).get("max_dynamic_roles")) - or _casting.DEFAULT_MAX_DYNAMIC_ROLES) - observer = self.observer - - def _on_change(name: str, status: str) -> None: - log(f"casting :: {name} :: {status}") - if observer is not None: - try: - observer.roster_changed(name, status) - except Exception as exc: - log_exception("orchestration.agent.observer.roster_changed", exc) - - with self._proposals_lock: - result = _casting.apply_response_proposals( - self.paths, - text, - agents_cfg=self.agents_cfg, - max_dynamic=cap, - source_agent=run.agent, - on_change=_on_change, - ) - if not result.is_empty(): - log(f"casting from {run.agent}: {result.summary()}") - - def _apply_posts(self, run: AgentRun, extra: Dict[str, Any]) -> None: - """Persist any POST blocks the agent emitted onto the blackboard. - - Stage_id / workflow are taken from ``extra`` so a post made - during a workflow stage records its provenance and the workflow - runner can verify the stage's declared outputs against it. - - When a question post carries a ``target_agent`` and - ``urgency=blocking``, the harness dispatches the named agent to - answer the question synchronously before this run returns. - That makes the asker's worker effectively block on the answer, - which gets posted back to the blackboard with ``post_type: - answer`` and ``consumes: []``. - """ - text = (run.response.text or "") - if not text or "POST:" not in text: - return - try: - posted = _blackboard.apply_post_blocks( - self.paths, - text, - author=run.agent, - stage_id=str(extra.get("stage_id") or ""), - workflow=str(extra.get("workflow") or ""), - ) - except Exception as exc: - log_exception("orchestration.agent._apply_posts", exc) - return - for p in posted: - if p.id and p.id not in run.posts: - run.posts.append(p.id) - # Route blocking questions: dispatch the target agent inline so - # the asker effectively sees the answer in subsequent rounds. - try: - self._route_blocking_questions(run, posted, extra) - except Exception as exc: - log_exception("orchestration.agent._apply_posts.route_qa", exc) - - def _route_blocking_questions( - self, - run: AgentRun, - posted: List["_blackboard.Post"], - extra: Dict[str, Any], - ) -> None: - """Dispatch peer agents to answer ``POST: question`` blocks. - - Skipped entirely when there are no question posts targeted at a - peer, when we're already inside a Q&A chain that has exhausted - its depth budget, or when the dispatcher is itself in a meta- - phase (consensus, recovery, etc.). - """ - questions = [ - p for p in posted - if (p.post_type or "").lower() == "question" - and (p.target_agent or "").strip() - and (p.urgency or "blocking").lower() == "blocking" - ] - if not questions: - return - if str(extra.get("phase") or "") in self._META_PHASES: - return - tel = extra.get("telemetry") - cfg = self.clk_cfg.get("robustness") or {} - max_depth = int(cfg.get("max_qa_depth") or 3) - chain: List[str] = list(extra.get("qa_chain") or []) - if len(chain) >= max_depth: - log_event( - self.paths, - "qa_chain_capped", - agent=run.agent, - depth=len(chain), - max_depth=max_depth, - chain=list(chain), - ) - return - agents_known = set((self.agents_cfg.get("agents") or {}).keys()) - for q in questions: - target = q.target_agent.strip() - if not target or target not in agents_known: - log_event( - self.paths, - "qa_target_unknown", - agent=run.agent, - target=target, - question_id=q.id, - ) - continue - if target == run.agent: - # Self-questions don't need routing. - continue - if target in chain: - log_event( - self.paths, - "qa_chain_cycle", - agent=run.agent, - target=target, - chain=list(chain), - ) - continue - next_chain = chain + [run.agent] - answer_objective = ( - f"Peer question routed by the harness.\n\n" - f"Asker: `{run.agent}` (stage `{extra.get('stage_id') or '?'}`)\n" - f"Question id: `{q.id}`\n\n" - f"Question:\n{q.body}\n\n" - "Answer this directly. Emit a POST: answer block whose\n" - f"CONSUMES list contains `{q.id}`. Keep the body focused " - "on what the asker needs to make progress — do not start " - "a new sub-thread of questions of your own." - ) - log_event( - self.paths, - "qa_dispatch", - agent=run.agent, - target=target, - question_id=q.id, - chain=next_chain, - urgency=q.urgency or "blocking", - ) - self._observer_log( - f"qa :: {run.agent} → {target} :: {q.id[:32]}" - ) - if tel is not None: - try: - tel.add_qa_exchange() - except Exception: - pass - self._dispatch_once( - target, - answer_objective, - extra={ - "phase": "qa_answer", - "qa_chain": next_chain, - "qa_question_id": q.id, - "qa_asker": run.agent, - "stage_id": extra.get("stage_id"), - "workflow": extra.get("workflow"), - }, - dry_run=self.clk_cfg.get("dry_run", False), - ) - - def _apply_actions(self, run: AgentRun, extra: Optional[Dict[str, Any]] = None) -> None: - """Execute ACTION blocks; merge harness-written files back into the run.""" - extra = extra or {} - tel = extra.get("telemetry") - text = (run.response.text or "") - if not text or "ACTION:" not in text and "ACTION :" not in text: - return - result = _actions.apply_actions( - self.paths, - text, - agent_name=run.agent, - clk_cfg=self.clk_cfg, - ) - # Record how many file mutations actually landed (drives the no-op - # guard even when ACTION blocks were present but all skipped). - run.file_mutations_applied = len(result.files_written) + len(result.files_deleted) - if tel is not None: - try: - tel.add_actions(len(result.files_written) + len(result.files_deleted) - + len(result.commands_run)) - tel.add_files(len(result.files_written)) - except Exception: - pass - if result.is_empty(): - return - # Merge into run.files_written so downstream consumers (TUI, - # commit step) reflect what actually happened. - seen = set(run.files_written) - for f in result.files_written: - if f not in seen: - run.files_written.append(f) - seen.add(f) - # Also surface deletes so they get attributed to this run. - for f in result.files_deleted: - run.files_written.append(f"deleted:{f}") - log(f"actions from {run.agent}: {result.summary()}") - # Annotate the response so the TUI can show it in the log pane: - # we tack a short summary onto the text preview path. - if result.commands_run or result.errors: - for cmd, out in zip(result.commands_run, result.command_outputs): - log(f"actions[{run.agent}] $ {cmd}") - if out.strip(): - for line in out.strip().splitlines()[:6]: - log(f"actions[{run.agent}] {line[:200]}") - for err in result.errors: - log(f"actions[{run.agent}] !! {err}", level="WARN") - # Auto-commit any file changes from this batch so the git log - # has a per-agent-run granularity. Only fires when this run - # actually wrote (or deleted) files. - if (result.files_written or result.files_deleted) and self.clk_cfg.get("auto_commit", True): - self._commit_action_batch(run, result, extra) - - def _commit_action_batch( - self, - run: AgentRun, - result: "_actions.ActionResult", - extra: Optional[Dict[str, Any]] = None, - ) -> None: - try: - if not is_repo(self.paths.root): - return - if not has_changes(self.paths.root): - return - if not add_all(self.paths.root): - return - usage = run.response.usage or {} - tok_total = int(usage.get("total_tokens") or 0) - tok_in = int(usage.get("input_tokens") or 0) - tok_out = int(usage.get("output_tokens") or 0) - extra_lines = [] - if result.commands_run: - extra_lines.append("Commands run:") - for c in result.commands_run[:8]: - extra_lines.append(f" $ {c}") - if result.skipped: - extra_lines.append("") - extra_lines.append("Skipped actions:") - for s in result.skipped[:8]: - extra_lines.append(f" - {s}") - extra_lines.append("") - extra_lines.append( - f"Tokens: total={tok_total} in={tok_in} out={tok_out} " - f"src={usage.get('source','?')}" - ) - committed = git_commit( - self.paths.root, - agent=run.agent, - objective=run.objective, - files_changed=run.files_written, - validation=result.summary(), - next_step="continue iteration", - body_extra="\n".join(extra_lines), - ) - if committed: - run.committed = True - tel = (extra or {}).get("telemetry") - if tel is not None: - try: - tel.add_commit() - except Exception: - pass - log( - f"commit: [{run.agent}] {len(result.files_written)} files, " - f"{len(result.files_deleted)} deletes" - ) - log_event( - self.paths, - "git_commit", - agent=run.agent, - objective=run.objective[:200], - sha=head_sha(self.paths.root), - files_written=list(result.files_written), - files_deleted=list(result.files_deleted), - commands_run=list(result.commands_run), - tokens_total=tok_total, - ) - except Exception as exc: - log_exception("orchestration.agent._commit_action_batch", exc) - - # -- internals --------------------------------------------------------- - - def _load_prompt_template(self, prompt_file: str) -> str: - path = self.paths.prompts / prompt_file - if not path.exists(): - return ( - "You are the $agent agent.\n\n" - "Objective:\n$objective\n\n" - "Project: $project_name\n" - "Working directory: $project_root\n" - "Current state summary:\n$state_summary\n" - ) - try: - template = path.read_text(encoding="utf-8") - except Exception as exc: - log_exception("orchestration.agent._load_prompt_template", exc) - return "Objective:\n$objective\n" - # Dispatch-time healing: prompts written before the protocol suffix - # existed (or hand-edited ones) lack the ACTION/POST grammar, which - # makes the agent emit prose instead of parseable blocks. Append it - # in-memory so every dispatch carries the protocol even when the - # file on disk is stale. Prompts that already carry the base footer - # were assembled deliberately from the templates (e.g. critic.md - # carries only the footer because it never emits actions) — leave - # those alone to avoid duplicating shared blocks. - if ( - _casting._PROTOCOL_MARKER not in template - and "Self-assessment footer" not in template - ): - suffix = _casting._harness_protocol_suffix() - if suffix: - template = template.rstrip() + suffix + "\n" - return template - - def _collect_context(self, objective: str, extra: Dict[str, Any]) -> Dict[str, Any]: - idea_path = self.paths.state / "idea.json" - brief_path = self.paths.state / "system_brief.md" - # Agents write PRD.json, PROGRESS.md, and DECISIONS.md to the project - # root (they cannot write to .clk/state/ via ACTIONs). Check those - # paths first; fall back to the legacy .clk/state/ location. - prd_path = ( - self.paths.root / "PRD.json" - if (self.paths.root / "PRD.json").exists() - else self.paths.state / "prd.json" - ) - progress_path = ( - self.paths.root / "PROGRESS.md" - if (self.paths.root / "PROGRESS.md").exists() - else self.paths.state / "progress.md" - ) - decisions_path = ( - self.paths.root / "DECISIONS.md" - if (self.paths.root / "DECISIONS.md").exists() - else self.paths.state / "decisions.md" - ) - - idea = {} - if idea_path.exists(): - try: - idea = json.loads(idea_path.read_text(encoding="utf-8")) - except Exception as exc: - log_exception("orchestration.agent._collect_context.idea", exc) - - state_summary_lines: List[str] = [] - if idea: - state_summary_lines.append(f"idea: {idea.get('title','(untitled)')}") - if idea.get("statement"): - state_summary_lines.append(f"statement: {idea['statement']}") - for label, path in [ - ("brief", brief_path), - ("prd", prd_path), - ("progress", progress_path), - ("decisions", decisions_path), - ]: - if path.exists(): - try: - snippet = path.read_text(encoding="utf-8").strip().splitlines()[:5] - if snippet: - state_summary_lines.append(f"{label}: " + " | ".join(snippet)) - except Exception as exc: - log_exception(f"orchestration.agent._collect_context.{label}", exc) - - roster_lines: List[str] = [] - for n in sorted((self.agents_cfg.get("agents") or {}).keys()): - cfg = (self.agents_cfg.get("agents") or {}).get(n) or {} - marker = "[baseline]" if _casting.is_baseline(n) else "[dynamic]" - role = (cfg.get("role") or "").strip() - prompt_file = cfg.get("prompt") or f"{n}.md" - prompt_preview = "" - try: - prompt_path = self.paths.prompts / prompt_file - if prompt_path.exists(): - prompt_preview = " ".join(prompt_path.read_text(encoding="utf-8").strip().split())[:220] - except Exception as exc: - log_exception(f"orchestration.agent._collect_context.roster_prompt.{n}", exc) - roster_lines.append( - f"- {marker} {n} :: {role} " - f"(prompt={prompt_file}; prompt_preview={prompt_preview or '(missing)'})" - ) - roster_text = "\n".join(roster_lines) or "(no agents registered yet)" - - # Blackboard digest: filter by the stage's declared `inputs` when - # provided, otherwise show the most recent global posts so the - # agent has at least some peer context. Capped to keep prompts - # bounded; tunable via clk.config.json::blackboard. - bb_cfg = (self.clk_cfg.get("blackboard") or {}) - bb_inputs = list(extra.get("blackboard_inputs") or []) - # Allow the chief to widen the digest via stage metadata flag - # ``include_full_blackboard`` (carried through ``extra``). - if extra.get("include_full_blackboard"): - bb_inputs = [] - try: - bb_digest = _blackboard.digest( - self.paths, - selectors=bb_inputs, - max_posts=int(bb_cfg.get("digest_max_posts") or 20), - max_chars_per_post=int(bb_cfg.get("digest_max_chars_per_post") or 800), - ) - except Exception as exc: - log_exception("orchestration.agent._collect_context.blackboard", exc) - bb_digest = "Blackboard digest: (unavailable)" - - # Casting-rejection feedback: surface duplicate-prevention misses - # so the chief learns from them on the next dispatch. - try: - casting_feedback = _read_recent_casting_rejections(self.paths) - except Exception as exc: - log_exception("orchestration.agent._collect_context.casting_feedback", exc) - casting_feedback = "" - - # Cross-iteration scratchpad: inject PROGRESS.md content as "notes" - notes = "" - notes_path = self.paths.root / "PROGRESS.md" - if notes_path.exists(): - try: - raw_notes = notes_path.read_text(encoding="utf-8") - if len(raw_notes) > 3000: - raw_notes = raw_notes[-3000:] - notes = raw_notes - except Exception as exc: - log_exception("orchestration.agent._collect_context.notes", exc) - - # Outputs contract: convert the stage_outputs list into a concrete, - # agent-visible instruction block so workers know BEFORE they write - # their first response which POST PRODUCES keys are required. Without - # this the agent only learns about the contract through a rejection. - stage_outputs_list = list(extra.get("stage_outputs") or []) - if stage_outputs_list: - produces_line = ", ".join(stage_outputs_list) - outputs_contract = ( - f"REQUIRED OUTPUT CONTRACT — you MUST satisfy these keys:\n" - f" {produces_line}\n" - f"Each key must appear in at least one POST block's PRODUCES " - f"line. Exact format:\n" - f" POST: finding\n" - f" PRODUCES: {produces_line}\n" - f" BODY:\n" - f" \n" - f" END_POST\n" - "Omitting this causes the harness to reject your response and " - "re-dispatch you." - ) - else: - outputs_contract = "" - - ctx = { - "agent": extra.get("agent", ""), - "objective": objective, - "project_name": self.clk_cfg.get("project_name") or self.paths.root.name, - "project_root": str(self.paths.root), - "workspace_root": str(self.paths.workspace), - "state_summary": "\n".join(state_summary_lines) or "(no state yet)", - "idea_title": (idea.get("title") if isinstance(idea, dict) else "") or "", - "idea_statement": (idea.get("statement") if isinstance(idea, dict) else "") or "", - "iteration": str(extra.get("iteration", "")), - "cycle_context": str(extra.get("cycle_context") or ""), - "stop_when": str(extra.get("stop_when") or ""), - "current_roster": roster_text, - "blackboard_digest": bb_digest, - "casting_feedback": casting_feedback or "(none)", - "notes": notes, - "outputs_contract": outputs_contract, - } - # ``telemetry`` is a live counter object threaded through ``extra`` for - # the dispatch hooks — it is not a template variable, so keep it out of - # the prompt context (otherwise it would be str()'d into the prompt). - ctx.update({ - k: v for k, v in extra.items() - if k not in ctx and k != "telemetry" - }) - return ctx - - def _safe_substitute(self, template_text: str, ctx: Dict[str, Any]) -> str: - try: - return Template(template_text).safe_substitute({k: str(v) for k, v in ctx.items()}) - except Exception as exc: - log_exception("orchestration.agent._safe_substitute", exc) - return template_text - - def _record(self, run: AgentRun, prompt: str, provider_desc: str) -> None: - try: - self.paths.state.mkdir(parents=True, exist_ok=True) - mem = self.paths.state / "agent_memory.jsonl" - payload = { - "agent": run.agent, - "run_id": f"{run.started_at.replace(':','-')}-{run.agent}", - "objective": run.objective, - "ok": run.response.ok, - "error": run.response.error, - "files_written": run.files_written, - "started_at": run.started_at, - "finished_at": run.finished_at, - "provider": provider_desc, - "usage": dict(run.response.usage or {}), - "text_preview": (run.response.text or "")[:500], - } - with mem.open("a", encoding="utf-8") as fh: - fh.write(json.dumps(payload) + "\n") - - run_dir = self.paths.runs / f"{run.started_at.replace(':','-')}-{run.agent}" - run_dir.mkdir(parents=True, exist_ok=True) - (run_dir / "prompt.txt").write_text(prompt, encoding="utf-8") - (run_dir / "response.txt").write_text(run.response.text or "", encoding="utf-8") - (run_dir / "meta.json").write_text(json.dumps(payload, indent=2), encoding="utf-8") - except Exception as exc: - log_exception("orchestration.agent._record", exc) diff --git a/clk_harness/orchestration/agent/__init__.py b/clk_harness/orchestration/agent/__init__.py new file mode 100644 index 0000000..7b3adbc --- /dev/null +++ b/clk_harness/orchestration/agent/__init__.py @@ -0,0 +1,31 @@ +"""Agent runner. + +Loads a prompt template, renders it against the current state, and +invokes the configured provider. The runner is intentionally thin - +heavier orchestration lives in :mod:`workflow` and the loops. + +Decomposed package; this ``__init__`` preserves the public surface of +the former ``clk_harness/orchestration/agent.py`` module: + +* :mod:`.runner` — ``AgentRunner``: dispatch entry point, robustness + layers (quality loop, auto-consensus), and provider invocation. +* :mod:`.prompts` — prompt assembly (templates, context collection, + meta-prompting cache). +* :mod:`.transcript` — run records (``AgentSpec``, ``AgentRun``, + ``AgentObserver``) and response-transcript processing (POST / + PROPOSE / ACTION blocks, run history). +""" + +from .prompts import PromptsMixin, _read_recent_casting_rejections +from .runner import AgentRunner +from .transcript import AgentObserver, AgentRun, AgentSpec, TranscriptMixin + +__all__ = [ + "AgentObserver", + "AgentRun", + "AgentRunner", + "AgentSpec", + "PromptsMixin", + "TranscriptMixin", + "_read_recent_casting_rejections", +] diff --git a/clk_harness/orchestration/agent/prompts.py b/clk_harness/orchestration/agent/prompts.py new file mode 100644 index 0000000..e0012d2 --- /dev/null +++ b/clk_harness/orchestration/agent/prompts.py @@ -0,0 +1,502 @@ +"""Prompt assembly for the agent runner. + +Template loading, context collection, safe substitution, and the +meta-prompting layer (chief-drafted dispatch / role prompts with an +on-disk cache). +""" + +from __future__ import annotations + +import json +from datetime import datetime +from pathlib import Path +from string import Template +from typing import TYPE_CHECKING, Any, Dict, List, Optional + +from ...log import get_logger, log_exception +from ...utils.activity_log import log_event +from .. import blackboard as _blackboard +from .. import casting as _casting + +if TYPE_CHECKING: + import threading + + from ...config import Paths + from .transcript import AgentRun, AgentSpec + +logger = get_logger(__name__) + + +def _read_recent_casting_rejections(paths: "Paths", *, limit: int = 8) -> str: + """Render the most recent role/workflow rejections into a short feedback + block. Lets the chief see "you tried to create X but Y already exists" + without us hardening the prompt further. + """ + log_path = paths.state / "casting.log" + if not log_path.exists(): + return "" + try: + raw_lines = log_path.read_text(encoding="utf-8").strip().splitlines() + except Exception as _exc: + logger.debug("could not read casting log %s: %s", log_path, _exc) + return "" + rows: List[Dict[str, Any]] = [] + for line in reversed(raw_lines): + try: + obj = json.loads(line) + except Exception as _exc: + logger.debug("skipping unparseable casting-log line: %s", _exc) + continue + if not isinstance(obj, dict): + continue + event = str(obj.get("event") or "") + if event not in ("role_skipped", "workflow_skipped"): + continue + rows.append(obj) + if len(rows) >= limit: + break + if not rows: + return "" + out: List[str] = ["Recent casting rejections (learn from these — reuse the existing role instead):"] + for r in reversed(rows): + kind = "role" if r.get("event") == "role_skipped" else "workflow" + name = r.get("name") or "?" + reason = r.get("reason") or "?" + ts = r.get("timestamp") or "" + out.append(f"- {ts} {kind} `{name}` rejected: {reason}") + return "\n".join(out) + + +class PromptsMixin: + """Prompt assembly + meta-prompting methods mixed into ``AgentRunner``.""" + + paths: "Paths" + agents_cfg: Dict[str, Any] + clk_cfg: Dict[str, Any] + _meta_cache_lock: "threading.Lock" + + if TYPE_CHECKING: + # Provided by AgentRunner (runner.py); declared here so annotated + # mixin methods type-check without a runtime import cycle. + def get_agent(self, name: str) -> "AgentSpec": ... + + def run( + self, + agent_name: str, + objective: str, + *, + extra: Optional[Dict[str, Any]] = None, + dry_run: Optional[bool] = None, + ) -> "AgentRun": ... + + def _observer_log(self, line: str) -> None: ... + + def render_prompt(self, agent: "AgentSpec", objective: str, extra: Optional[Dict[str, Any]] = None) -> str: + try: + template = self._load_prompt_template(agent.prompt_file) + ctx = self._collect_context(objective, extra or {}) + return self._safe_substitute(template, ctx) + except Exception as exc: + log_exception("orchestration.agent.render_prompt", exc) + return objective + + # -- meta-prompting ---------------------------------------------------- + + def _meta_cache_path(self) -> Path: + return self.paths.cache / "meta_prompts.jsonl" + + def _meta_cache_lookup(self, key: str) -> Optional[str]: + path = self._meta_cache_path() + if not path.exists(): + return None + try: + with self._meta_cache_lock, path.open("r", encoding="utf-8") as fh: + for line in fh: + try: + obj = json.loads(line) + except Exception as _exc: + logger.debug("skipping unparseable meta-cache line: %s", _exc) + continue + if isinstance(obj, dict) and obj.get("key") == key: + return obj.get("value") or "" + except Exception as exc: + log_exception("orchestration.agent._meta_cache_lookup", exc) + return None + + def _meta_cache_store(self, key: str, value: str, *, kind: str) -> None: + path = self._meta_cache_path() + try: + with self._meta_cache_lock: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("a", encoding="utf-8") as fh: + fh.write(json.dumps({ + "key": key, + "kind": kind, + "value": value, + "ts": datetime.now().isoformat(timespec="seconds"), + }) + "\n") + except Exception as exc: + log_exception("orchestration.agent._meta_cache_store", exc) + + @staticmethod + def _meta_key(*parts: str) -> str: + import hashlib + h = hashlib.sha256() + for p in parts: + h.update(b"\x00") + h.update((p or "").encode("utf-8")) + return h.hexdigest() + + def meta_draft_dispatch_prompt( + self, + *, + agent_name: str, + base_objective: str, + blackboard_inputs: Optional[List[str]] = None, + stage_outputs: Optional[List[str]] = None, + ) -> Optional[str]: + """Ask the chief to draft a tighter task prompt for ``agent_name``. + + Returns the drafted objective, or ``None`` when meta-prompting + is disabled or the chief produced no usable text. Cached on disk + so repeated dispatches with identical inputs cost nothing. + """ + cfg = self.clk_cfg.get("meta_prompt") or {} + mode = str(cfg.get("dispatch") or "off").lower() + if mode in ("", "off", "false", "0"): + return None + inputs_key = ",".join(sorted(blackboard_inputs or [])) + outputs_key = ",".join(sorted(stage_outputs or [])) + key = self._meta_key("dispatch", agent_name, base_objective, inputs_key, outputs_key) + cached = self._meta_cache_lookup(key) + if cached: + return cached + try: + agent_spec = self.get_agent(agent_name) + role_line = agent_spec.role or "" + prompt_path = self.paths.prompts / agent_spec.prompt_file + system_preview = "" + if prompt_path.exists(): + system_preview = " ".join(prompt_path.read_text(encoding="utf-8").strip().split())[:600] + contract_lines = "" + if stage_outputs: + produces = ", ".join(stage_outputs) + contract_lines = ( + f"\nThis stage declares an outputs contract: {produces}.\n" + "Your drafted prompt MUST explicitly instruct the worker to end\n" + "with a POST block whose PRODUCES line lists exactly these keys\n" + f"(`PRODUCES: {produces}`) — the harness rejects responses that\n" + "miss any key, so spell it out rather than assuming the worker\n" + "infers it.\n" + ) + objective = ( + f"Draft a tighter task prompt for the `{agent_name}` agent for the\n" + f"objective below. Output ONLY the new objective text — no preamble,\n" + f"no commentary. Keep it focused, concrete, and at most 8 sentences.\n" + f"Reference any relevant blackboard posts the worker should consult.\n" + f"{contract_lines}\n" + "Compliance requirements your drafted prompt must convey:\n" + "- Deliverables are FILES written via ACTION blocks; prose alone\n" + " does not count and the work will be considered missing.\n" + "- Say concretely what 'done' looks like (which files exist, what\n" + " they contain, what validation passes).\n\n" + f"Worker role line: {role_line or '(none)'}\n" + f"Worker system prompt preview: {system_preview or '(missing)'}\n\n" + f"Original objective:\n{base_objective}\n" + ) + self._observer_log( + f"meta :: drafting dispatch prompt for {agent_name} via chief" + ) + run = self.run( + "chief", + objective, + extra={ + "phase": "draft_dispatch_prompt", + "target_agent": agent_name, + "blackboard_inputs": list(blackboard_inputs or []), + }, + ) + except Exception as exc: + log_exception("orchestration.agent.meta_draft_dispatch_prompt", exc) + return None + text = (run.response.text or "").strip() + if not text: + return None + self._meta_cache_store(key, text, kind="dispatch") + log_event( + self.paths, + "meta_prompt_drafted", + agent="chief", + target_agent=agent_name, + kind="dispatch", + chars=len(text), + ) + return text + + def meta_draft_role_prompt( + self, + *, + role_name: str, + role_line: str, + hint: str = "", + ) -> Optional[str]: + """Ask the chief to draft a system prompt body for a new role. + + Returns the drafted prompt text or ``None`` when disabled. + Cached on disk by ``(role_name, role_line, hint)``. Callers + typically use this after ``register_role`` produced a scaffold + prompt and the chief should write a real one. + """ + cfg = self.clk_cfg.get("meta_prompt") or {} + mode = str(cfg.get("role") or "off").lower() + if mode in ("", "off", "false", "0"): + return None + key = self._meta_key("role", role_name, role_line, hint) + cached = self._meta_cache_lookup(key) + if cached: + return cached + try: + objective = ( + f"Draft a system prompt for a new agent named `{role_name}`.\n" + f"Role line: {role_line or '(none)'}\n" + + (f"Additional hint: {hint}\n" if hint else "") + + "\nThe prompt body must be self-contained and use these placeholders:\n" + "$$objective $$state_summary $$idea_title $$idea_statement\n" + "$$project_name $$project_root $$iteration\n" + "Output ONLY the prompt body — no PROPOSE_ROLE wrapper, no commentary.\n" + "Keep it under 50 lines. Make the role's distinct ownership explicit\n" + "compared with existing roles.\n\n" + "The harness automatically appends the ACTION/POST protocol blocks\n" + "to every role prompt, so do NOT restate them. Instead, your draft\n" + "MUST include an Output section that tells the agent:\n" + "- deliverables are files written via ACTION blocks (prose alone\n" + " is not a deliverable and the harness treats it as missing work);\n" + "- to end with a POST block summarising the result, with PRODUCES\n" + " listing any contract keys its stage declares;\n" + "- what a complete, verifiable result looks like for this role.\n" + "Before emitting, re-read your draft as if you were a small local\n" + "model: remove ambiguity, prefer imperative checklists over essays." + ) + self._observer_log( + f"meta :: drafting role prompt for {role_name} via chief" + ) + run = self.run( + "chief", + objective, + extra={"phase": "draft_role_prompt", "target_role": role_name}, + ) + except Exception as exc: + log_exception("orchestration.agent.meta_draft_role_prompt", exc) + return None + text = (run.response.text or "").strip() + if not text: + return None + self._meta_cache_store(key, text, kind="role") + log_event( + self.paths, + "meta_prompt_drafted", + agent="chief", + target_role=role_name, + kind="role", + chars=len(text), + ) + return text + + # -- internals --------------------------------------------------------- + + def _load_prompt_template(self, prompt_file: str) -> str: + path = self.paths.prompts / prompt_file + if not path.exists(): + return ( + "You are the $agent agent.\n\n" + "Objective:\n$objective\n\n" + "Project: $project_name\n" + "Working directory: $project_root\n" + "Current state summary:\n$state_summary\n" + ) + try: + template = path.read_text(encoding="utf-8") + except Exception as exc: + log_exception("orchestration.agent._load_prompt_template", exc) + return "Objective:\n$objective\n" + # Dispatch-time healing: prompts written before the protocol suffix + # existed (or hand-edited ones) lack the ACTION/POST grammar, which + # makes the agent emit prose instead of parseable blocks. Append it + # in-memory so every dispatch carries the protocol even when the + # file on disk is stale. Prompts that already carry the base footer + # were assembled deliberately from the templates (e.g. critic.md + # carries only the footer because it never emits actions) — leave + # those alone to avoid duplicating shared blocks. + if ( + _casting._PROTOCOL_MARKER not in template + and "Self-assessment footer" not in template + ): + suffix = _casting._harness_protocol_suffix() + if suffix: + template = template.rstrip() + suffix + "\n" + return template + + def _collect_context(self, objective: str, extra: Dict[str, Any]) -> Dict[str, Any]: + idea_path = self.paths.state / "idea.json" + brief_path = self.paths.state / "system_brief.md" + # Agents write PRD.json, PROGRESS.md, and DECISIONS.md to the project + # root (they cannot write to .clk/state/ via ACTIONs). Check those + # paths first; fall back to the legacy .clk/state/ location. + prd_path = ( + self.paths.root / "PRD.json" + if (self.paths.root / "PRD.json").exists() + else self.paths.state / "prd.json" + ) + progress_path = ( + self.paths.root / "PROGRESS.md" + if (self.paths.root / "PROGRESS.md").exists() + else self.paths.state / "progress.md" + ) + decisions_path = ( + self.paths.root / "DECISIONS.md" + if (self.paths.root / "DECISIONS.md").exists() + else self.paths.state / "decisions.md" + ) + + idea = {} + if idea_path.exists(): + try: + idea = json.loads(idea_path.read_text(encoding="utf-8")) + except Exception as exc: + log_exception("orchestration.agent._collect_context.idea", exc) + + state_summary_lines: List[str] = [] + if idea: + state_summary_lines.append(f"idea: {idea.get('title','(untitled)')}") + if idea.get("statement"): + state_summary_lines.append(f"statement: {idea['statement']}") + for label, path in [ + ("brief", brief_path), + ("prd", prd_path), + ("progress", progress_path), + ("decisions", decisions_path), + ]: + if path.exists(): + try: + snippet = path.read_text(encoding="utf-8").strip().splitlines()[:5] + if snippet: + state_summary_lines.append(f"{label}: " + " | ".join(snippet)) + except Exception as exc: + log_exception(f"orchestration.agent._collect_context.{label}", exc) + + roster_lines: List[str] = [] + for n in sorted((self.agents_cfg.get("agents") or {}).keys()): + cfg = (self.agents_cfg.get("agents") or {}).get(n) or {} + marker = "[baseline]" if _casting.is_baseline(n) else "[dynamic]" + role = (cfg.get("role") or "").strip() + prompt_file = cfg.get("prompt") or f"{n}.md" + prompt_preview = "" + try: + prompt_path = self.paths.prompts / prompt_file + if prompt_path.exists(): + prompt_preview = " ".join(prompt_path.read_text(encoding="utf-8").strip().split())[:220] + except Exception as exc: + log_exception(f"orchestration.agent._collect_context.roster_prompt.{n}", exc) + roster_lines.append( + f"- {marker} {n} :: {role} " + f"(prompt={prompt_file}; prompt_preview={prompt_preview or '(missing)'})" + ) + roster_text = "\n".join(roster_lines) or "(no agents registered yet)" + + # Blackboard digest: filter by the stage's declared `inputs` when + # provided, otherwise show the most recent global posts so the + # agent has at least some peer context. Capped to keep prompts + # bounded; tunable via clk.config.json::blackboard. + bb_cfg = (self.clk_cfg.get("blackboard") or {}) + bb_inputs = list(extra.get("blackboard_inputs") or []) + # Allow the chief to widen the digest via stage metadata flag + # ``include_full_blackboard`` (carried through ``extra``). + if extra.get("include_full_blackboard"): + bb_inputs = [] + try: + bb_digest = _blackboard.digest( + self.paths, + selectors=bb_inputs, + max_posts=int(bb_cfg.get("digest_max_posts") or 20), + max_chars_per_post=int(bb_cfg.get("digest_max_chars_per_post") or 800), + ) + except Exception as exc: + log_exception("orchestration.agent._collect_context.blackboard", exc) + bb_digest = "Blackboard digest: (unavailable)" + + # Casting-rejection feedback: surface duplicate-prevention misses + # so the chief learns from them on the next dispatch. + try: + casting_feedback = _read_recent_casting_rejections(self.paths) + except Exception as exc: + log_exception("orchestration.agent._collect_context.casting_feedback", exc) + casting_feedback = "" + + # Cross-iteration scratchpad: inject PROGRESS.md content as "notes" + notes = "" + notes_path = self.paths.root / "PROGRESS.md" + if notes_path.exists(): + try: + raw_notes = notes_path.read_text(encoding="utf-8") + if len(raw_notes) > 3000: + raw_notes = raw_notes[-3000:] + notes = raw_notes + except Exception as exc: + log_exception("orchestration.agent._collect_context.notes", exc) + + # Outputs contract: convert the stage_outputs list into a concrete, + # agent-visible instruction block so workers know BEFORE they write + # their first response which POST PRODUCES keys are required. Without + # this the agent only learns about the contract through a rejection. + stage_outputs_list = list(extra.get("stage_outputs") or []) + if stage_outputs_list: + produces_line = ", ".join(stage_outputs_list) + outputs_contract = ( + f"REQUIRED OUTPUT CONTRACT — you MUST satisfy these keys:\n" + f" {produces_line}\n" + f"Each key must appear in at least one POST block's PRODUCES " + f"line. Exact format:\n" + f" POST: finding\n" + f" PRODUCES: {produces_line}\n" + f" BODY:\n" + f" \n" + f" END_POST\n" + "Omitting this causes the harness to reject your response and " + "re-dispatch you." + ) + else: + outputs_contract = "" + + ctx = { + "agent": extra.get("agent", ""), + "objective": objective, + "project_name": self.clk_cfg.get("project_name") or self.paths.root.name, + "project_root": str(self.paths.root), + "workspace_root": str(self.paths.workspace), + "state_summary": "\n".join(state_summary_lines) or "(no state yet)", + "idea_title": (idea.get("title") if isinstance(idea, dict) else "") or "", + "idea_statement": (idea.get("statement") if isinstance(idea, dict) else "") or "", + "iteration": str(extra.get("iteration", "")), + "cycle_context": str(extra.get("cycle_context") or ""), + "stop_when": str(extra.get("stop_when") or ""), + "current_roster": roster_text, + "blackboard_digest": bb_digest, + "casting_feedback": casting_feedback or "(none)", + "notes": notes, + "outputs_contract": outputs_contract, + } + # ``telemetry`` is a live counter object threaded through ``extra`` for + # the dispatch hooks — it is not a template variable, so keep it out of + # the prompt context (otherwise it would be str()'d into the prompt). + ctx.update({ + k: v for k, v in extra.items() + if k not in ctx and k != "telemetry" + }) + return ctx + + def _safe_substitute(self, template_text: str, ctx: Dict[str, Any]) -> str: + try: + return Template(template_text).safe_substitute({k: str(v) for k, v in ctx.items()}) + except Exception as exc: + log_exception("orchestration.agent._safe_substitute", exc) + return template_text diff --git a/clk_harness/orchestration/agent/runner.py b/clk_harness/orchestration/agent/runner.py new file mode 100644 index 0000000..c0c7696 --- /dev/null +++ b/clk_harness/orchestration/agent/runner.py @@ -0,0 +1,870 @@ +"""Agent dispatch and provider invocation. + +``AgentRunner.run`` is the public dispatch entry point; the robustness +layers (quality-driven re-dispatch, proactive auto-consensus) and the +consensus sampling machinery live here too. Prompt assembly is mixed in +from :mod:`.prompts`; response-transcript processing from +:mod:`.transcript`. +""" + +from __future__ import annotations + +import json +import threading +import time +from concurrent.futures import ThreadPoolExecutor, as_completed +from datetime import datetime +from typing import Any, Dict, List, Optional + +from ...config import Paths +from ...log import get_logger, log_exception +from ...providers import AgentProvider, AgentRequest, AgentResponse, load_provider +from ...utils.activity_log import log_event +from .. import casting as _casting +from .. import noop_guard as _noop_guard +from .. import response_quality as _response_quality +from .prompts import PromptsMixin +from .transcript import AgentObserver, AgentRun, AgentSpec, TranscriptMixin + +logger = get_logger(__name__) + + +class AgentRunner(PromptsMixin, TranscriptMixin): + """Render prompts, invoke providers, persist outputs.""" + + def __init__( + self, + paths: Paths, + agents_cfg: Dict[str, Any], + providers_cfg: Dict[str, Any], + clk_cfg: Dict[str, Any], + observer: Optional[AgentObserver] = None, + ) -> None: + self.paths = paths + self.agents_cfg = agents_cfg + self.providers_cfg = providers_cfg + self.clk_cfg = clk_cfg + self.observer = observer + # Serialises agents_cfg mutations from _apply_proposals so parallel + # workflow stages don't race when both emit PROPOSE_ROLE blocks. + # RLock so consensus coalescing (which calls run() recursively) works. + self._proposals_lock = threading.RLock() + # Lock around meta-prompt cache reads/writes so parallel stages + # racing to draft the same dispatch prompt don't corrupt the file. + self._meta_cache_lock = threading.Lock() + + # -- public ------------------------------------------------------------ + + def get_agent(self, name: str) -> AgentSpec: + cfg = (self.agents_cfg.get("agents") or {}).get(name) + if cfg is None: + cfg = {"prompt": f"{name}.md", "provider": None, "role": ""} + return AgentSpec.from_config(name, cfg) + + def get_provider(self, name: Optional[str]) -> AgentProvider: + import os as _os + + from ...config import DEFAULT_PROVIDERS + # CLK_PROVIDER (set in the global .env) is an explicit, authoritative + # choice, so it overrides the workspace's saved `active`. Precedence: + # per-agent pin > env CLK_PROVIDER > providers.json active > + # clk.config default_provider > shell. + env_provider = (_os.environ.get("CLK_PROVIDER") or "").strip() + target = ( + name + or env_provider + or self.providers_cfg.get("active") + or self.clk_cfg.get("default_provider") + or "shell" + ) + prov_cfg = (self.providers_cfg.get("providers") or {}).get(target) + if prov_cfg is None: + # No saved block (e.g. an env-selected provider, or a dropped + # block) -> use the built-in default block for known providers so + # we still call the real provider instead of silently echoing. + prov_cfg = (DEFAULT_PROVIDERS.get("providers") or {}).get(target) + if prov_cfg is None: + logger.warning(f"unknown provider '{target}', falling back to shell") + target = "shell" + prov_cfg = (self.providers_cfg.get("providers") or {}).get("shell") or {"type": "shell"} + return load_provider(target, prov_cfg) + + # Phases whose dispatches must never re-trigger the auto-consensus or + # quality-retry layers. Otherwise consensus coalescing, checkpoint + # verdicts, recovery dispatches, and the critic-judge inner loop + # would all recurse into themselves. + _META_PHASES = frozenset({ + "consensus_sample", + "consensus", + "checkpoint", + "recovery", + "draft_dispatch_prompt", + "draft_role_prompt", + "qa_answer", + "refine_critic", + "refine_worker", + # Mission-level chief dispatches: single-shot planning / gating that + # must not recurse into consensus or the quality-retry loop. + "charter", + "mission_plan", + "phase_gate", + }) + + def run( + self, + agent_name: str, + objective: str, + *, + extra: Optional[Dict[str, Any]] = None, + dry_run: Optional[bool] = None, + ) -> AgentRun: + """Public dispatch entry point. + + Wraps :meth:`_dispatch_once` with two robustness layers: + + * **Proactive auto-consensus** (`robustness.auto_consensus`) — + stages marked ``careful: true`` (or all stages, when set to + ``"always"``) fan into N stochastic samples and a chief + coalescing pass instead of a single dispatch. + * **Quality-driven re-dispatch** — after a normal dispatch, the + response is scored against ``response_quality``; recoverable + failures (empty, malformed, contract-missing, low-confidence) + trigger a re-run with a repair preamble, escalating to + consensus on the final retry. + + Both layers are gated by ``clk.config.json::robustness`` and + bypassed for dispatches whose ``extra.phase`` indicates a + meta-path (consensus coalescing, recovery, checkpoint, etc.) so + the harness never loops on itself. + """ + extra_dict: Dict[str, Any] = dict(extra or {}) + phase = str(extra_dict.get("phase") or "") + in_meta = phase in self._META_PHASES + is_dry = self.clk_cfg.get("dry_run", False) if dry_run is None else dry_run + + if not in_meta and not is_dry and self._should_auto_consensus(agent_name, extra_dict): + return self._dispatch_auto_consensus( + agent_name, + objective, + extra=extra_dict, + dry_run=dry_run, + reason="auto_consensus_proactive", + ) + + if in_meta or is_dry: + return self._dispatch_once(agent_name, objective, extra=extra_dict, dry_run=dry_run) + + return self._dispatch_with_quality_loop( + agent_name, objective, extra=extra_dict, dry_run=dry_run + ) + + def _dispatch_with_quality_loop( + self, + agent_name: str, + objective: str, + *, + extra: Dict[str, Any], + dry_run: Optional[bool], + ) -> AgentRun: + """Quality-validated dispatch wrapper. + + Runs :meth:`_dispatch_once`, scores the response, and re-runs + the worker with a repair preamble when the verdict is + recoverable. Escalates to ``_dispatch_auto_consensus`` on the + final retry when ``auto_consensus`` is not ``"off"``. + """ + cfg = self.clk_cfg.get("robustness") or {} + max_retries = int(cfg.get("max_quality_retries") or 0) + min_chars = int(cfg.get("min_response_chars") or 40) + auto_consensus_mode = str(cfg.get("auto_consensus") or "off").lower() + expected_outputs = list(extra.get("stage_outputs") or []) + tel = extra.get("telemetry") + + # FM1 no-op guard: a producing stage that applies zero file mutations + # is re-dispatched with an escalating repair preamble (descriptions + # alone do nothing). Independent budget from the quality retries. + expect_mutation = _noop_guard.is_mutation_expected( + agent_name, + outputs=expected_outputs, + commit=bool(extra.get("commit")), + cfg=self.clk_cfg, + ) + max_noop = _noop_guard.max_redispatch(self.clk_cfg) + noop_redispatches = 0 + + attempt = 0 + current_objective = objective + last_run: Optional[AgentRun] = None + while True: + attempt += 1 + attempt_extra = dict(extra) + attempt_extra["quality_attempt"] = attempt + run = self._dispatch_once( + agent_name, current_objective, extra=attempt_extra, dry_run=dry_run + ) + last_run = run + if not run.response.ok: + return run + # No-op check: a producing stage that returned substantive prose + # but changed no files. (An empty/near-empty response is left to + # the quality "empty" flag below — that is a different failure.) + substantive = len((run.response.text or "").strip()) >= min_chars + if ( + expect_mutation + and substantive + and run.file_mutations_applied == 0 + and noop_redispatches < max_noop + ): + noop_redispatches += 1 + if tel is not None: + try: + tel.add_noop_redispatch() + except Exception as _exc: + logger.debug("telemetry add_noop_redispatch failed: %s", _exc) + log_event( + self.paths, + "agent_noop_redispatch", + agent=agent_name, + attempt=noop_redispatches, + max_attempts=max_noop, + stage_id=extra.get("stage_id"), + workflow=extra.get("workflow"), + ) + self._observer_log( + f"noop :: {agent_name} :: changed no files; " + f"re-dispatch {noop_redispatches}/{max_noop}" + ) + preamble = _noop_guard.repair_preamble( + noop_redispatches, target=str(extra.get("expected_path") or "") + ) + current_objective = preamble + "\n\nOriginal objective:\n" + objective + continue + try: + q = _response_quality.score( + run.response.text, + min_chars=min_chars, + expected_outputs=expected_outputs, + ) + except Exception as exc: + log_exception("orchestration.agent._dispatch_with_quality_loop.score", exc) + return run + if q.ok or not q.recoverable or attempt > max_retries: + if not q.ok: + log_event( + self.paths, + "agent_quality_final", + agent=agent_name, + attempt=attempt, + ok=q.ok, + recoverable=q.recoverable, + flags=list(q.flags), + reasons=list(q.reasons), + score=q.score, + confidence=q.confidence, + needs_review=q.needs_review, + ) + return run + if tel is not None: + try: + tel.add_quality_retry() + except Exception as _exc: + logger.debug("telemetry add_quality_retry failed: %s", _exc) + log_event( + self.paths, + "agent_quality_retry", + agent=agent_name, + attempt=attempt, + next_attempt=attempt + 1, + max_attempts=max_retries + 1, + flags=list(q.flags), + reasons=list(q.reasons), + score=q.score, + confidence=q.confidence, + needs_review=q.needs_review, + ) + self._observer_log( + f"quality :: {agent_name} :: retry {attempt}/{max_retries} " + f"flags={','.join(q.flags) or '?'} score={q.score:.2f}" + ) + # On the final retry, optionally escalate to a consensus + # fan-out rather than another single-shot retry — that way + # we get sub-sub-agents on actually-shaky outputs even when + # the stage isn't marked careful. + if attempt == max_retries and auto_consensus_mode != "off": + return self._dispatch_auto_consensus( + agent_name, + objective, + extra=extra, + dry_run=dry_run, + reason=f"quality_escalation:{','.join(q.flags)}", + ) + current_objective = q.repair_hint() + "\n\nOriginal objective:\n" + objective + return last_run # unreachable + + def _should_auto_consensus(self, agent_name: str, extra: Dict[str, Any]) -> bool: + """Proactive auto-consensus trigger check.""" + cfg = self.clk_cfg.get("robustness") or {} + mode = str(cfg.get("auto_consensus") or "off").lower() + if mode in ("", "off", "false", "0"): + return False + # Never fan-out the chief on its own meta-paths. + if agent_name == "chief": + return False + if mode == "always": + return True + # on_careful: only when the stage explicitly opted in. + if mode == "on_careful": + return bool(extra.get("careful")) + return False + + def _dispatch_auto_consensus( + self, + agent_name: str, + objective: str, + *, + extra: Dict[str, Any], + dry_run: Optional[bool], + reason: str = "auto_consensus", + ) -> AgentRun: + """Fan-out a single dispatch into N stochastic samples + coalesce. + + Reuses :meth:`_run_consensus_sample` (same code path as + ``PROPOSE_CONSENSUS``) so the sampling, logging, and parallelism + behavior is identical. The chief is invoked to coalesce. + """ + cfg = self.clk_cfg.get("consensus") or {} + sample_count = max(1, min(int(cfg.get("max_samples") or 3), 6)) + max_parallel = max(1, int(cfg.get("max_parallel") or 4)) + tel = extra.get("telemetry") + if tel is not None: + try: + tel.add_consensus_run() + except Exception as _exc: + logger.debug("telemetry add_consensus_run failed: %s", _exc) + name = f"auto_{agent_name}_{datetime.now().strftime('%H%M%S%f')}" + log_event( + self.paths, + "consensus_started", + agent=agent_name, + name=name, + objective=objective, + agents=[agent_name] * sample_count, + samples=sample_count, + max_parallel=max_parallel, + trigger=reason, + ) + self._observer_log( + f"consensus :: auto/{agent_name} :: starting {sample_count} samples " + f"(reason={reason})" + ) + results: List[Dict[str, Any]] = [] + with ThreadPoolExecutor(max_workers=min(max_parallel, sample_count)) as pool: + futs = { + pool.submit(self._run_consensus_sample, name, idx + 1, agent_name, objective): idx + 1 + for idx in range(sample_count) + } + for fut in as_completed(futs): + idx = futs[fut] + try: + results.append(fut.result()) + except Exception as exc: + log_exception("orchestration.agent._dispatch_auto_consensus.sample", exc) + results.append({ + "sample": idx, "agent": agent_name, "ok": False, + "error": str(exc), "text": "", + }) + results.sort(key=lambda r: int(r.get("sample") or 0)) + log_event( + self.paths, + "consensus_samples_completed", + agent=agent_name, + name=name, + results=results, + trigger=reason, + ) + coalesce_objective = self._consensus_coalesce_objective(name, objective, results) + coalesced = self._dispatch_once( + "chief", + coalesce_objective, + extra={ + "phase": "consensus", + "consensus_name": name, + "consensus_trigger": reason, + "stage_id": extra.get("stage_id"), + "workflow": extra.get("workflow"), + }, + dry_run=dry_run, + ) + log_event( + self.paths, + "consensus_coalesced", + agent="chief", + name=name, + ok=coalesced.response.ok, + response_text=coalesced.response.text or "", + error=coalesced.response.error, + trigger=reason, + ) + # The coalesced output is the only result the workflow ever sees, + # so it must clear the same quality bar as a direct dispatch. One + # repair pass: re-score, and if the verdict is recoverable, ask the + # chief to re-emit with the specific issues quoted back. + if coalesced.response.ok: + try: + cfg_r = self.clk_cfg.get("robustness") or {} + q = _response_quality.score( + coalesced.response.text, + min_chars=int(cfg_r.get("min_response_chars") or 40), + expected_outputs=list(extra.get("stage_outputs") or []), + ) + if not q.ok and q.recoverable: + log_event( + self.paths, + "consensus_coalesce_retry", + agent=agent_name, + name=name, + flags=list(q.flags), + reasons=list(q.reasons), + score=q.score, + trigger=reason, + ) + self._observer_log( + f"consensus :: {name} :: coalesce rejected " + f"(flags={','.join(q.flags)}); re-dispatching chief" + ) + repaired = self._dispatch_once( + "chief", + q.repair_hint() + "\n\nOriginal coalescing task:\n" + coalesce_objective, + extra={ + "phase": "consensus", + "consensus_name": name, + "consensus_trigger": f"{reason}:coalesce_repair", + "stage_id": extra.get("stage_id"), + "workflow": extra.get("workflow"), + }, + dry_run=dry_run, + ) + if repaired.response.ok: + coalesced = repaired + except Exception as exc: + log_exception("orchestration.agent._dispatch_auto_consensus.rescore", exc) + # Re-label so downstream logging shows the auto path, not "chief". + coalesced.agent = agent_name + return coalesced + + def _dispatch_once( + self, + agent_name: str, + objective: str, + *, + extra: Optional[Dict[str, Any]] = None, + dry_run: Optional[bool] = None, + ) -> AgentRun: + """Single provider dispatch with provider-level retry only. + + This was the body of :meth:`run` before the robustness layers + wrapped it. Keep it self-contained so consensus / refine / + recovery paths can call it without re-entering the wrappers. + """ + agent = self.get_agent(agent_name) + provider = self.get_provider(agent.provider) + prompt = self.render_prompt(agent, objective, extra) + is_dry = self.clk_cfg.get("dry_run", False) if dry_run is None else dry_run + + observer = self.observer + paths = self.paths + + def _on_progress(kind: str, message: str) -> None: + # Log the provider subprocess stream verbatim. This log is + # intended for post-run forensics, so detail is more useful + # than compactness here. + try: + extra: Dict[str, Any] = {} + if kind == "command": + try: + parsed = json.loads(message) + if isinstance(parsed, dict): + extra = parsed + except Exception: + extra = {} + log_event( + paths, + ("http_" + kind[5:] if kind.startswith("http_") else "subprocess_" + kind), + agent=agent.name, + message=message, + message_chars=len(message or ""), + **extra, + ) + except Exception as _exc: + logger.debug("activity log_event failed: %s", _exc) + if observer is None: + return + try: + observer.progress(agent.name, kind, message) + except Exception as exc: + log_exception("orchestration.agent.observer.progress", exc) + + timeout_s = int((self.clk_cfg.get("provider_timeout_s") or 300)) + no_output_timeout_s = int((self.clk_cfg.get("provider_no_output_timeout_s") or 0)) + retry_cfg = self.clk_cfg.get("provider_retry") or {} + max_retries = int(retry_cfg.get("max_retries", self.clk_cfg.get("provider_max_retries", 1)) or 0) + backoff_s = float(retry_cfg.get("backoff_s", self.clk_cfg.get("provider_retry_backoff_s", 5)) or 0) + req = AgentRequest( + agent=agent.name, + prompt=prompt, + workdir=self.paths.root, + dry_run=bool(is_dry), + timeout_s=timeout_s, + no_output_timeout_s=no_output_timeout_s, + on_progress=_on_progress, + capabilities=list(agent.capabilities or []), + ) + started = datetime.now().isoformat(timespec="seconds") + run_id = f"{started.replace(':','-')}-{agent.name}" + run_dir_rel = f".clk/runs/{run_id}" + log_event( + self.paths, + "agent_dispatch", + agent=agent.name, + action="dispatch", + objective=objective, + objective_chars=len(objective or ""), + workflow=(extra or {}).get("workflow"), + stage_id=(extra or {}).get("stage_id"), + iteration=(extra or {}).get("iteration"), + phase=(extra or {}).get("phase"), + provider=provider.describe(), + dry_run=bool(is_dry), + timeout_s=timeout_s, + no_output_timeout_s=no_output_timeout_s, + prompt_file=agent.prompt_file, + role=agent.role, + capabilities=list(agent.capabilities or []), + run_id=run_id, + max_retries=max_retries, + retry_backoff_s=backoff_s, + ) + log_event( + self.paths, + "prompt_sent", + agent=agent.name, + action="prompt_sent", + prompt_chars=len(prompt), + prompt_path=f"{run_dir_rel}/prompt.txt", + prompt=prompt, + run_id=run_id, + ) + if self.observer is not None: + try: + self.observer.begin(agent.name, objective) + except Exception as exc: + log_exception("orchestration.agent.observer.begin", exc) + try: + self.observer.prompt_sent(agent.name, prompt) + except Exception as exc: + log_exception("orchestration.agent.observer.prompt_sent", exc) + resp = AgentResponse(ok=False, error="provider_not_invoked") + attempt = 0 + while True: + attempt += 1 + log_event( + self.paths, + "provider_attempt", + agent=agent.name, + run_id=run_id, + attempt=attempt, + max_attempts=max_retries + 1, + provider=provider.describe(), + ) + try: + resp = provider.invoke(req) + except Exception as exc: + log_exception(f"orchestration.agent.run[{agent_name}]", exc) + resp = AgentResponse(ok=False, error=str(exc)) + if resp.ok or not self._should_retry_provider(resp.error or "") or attempt > max_retries: + break + log_event( + self.paths, + "provider_retry", + agent=agent.name, + run_id=run_id, + attempt=attempt, + next_attempt=attempt + 1, + backoff_s=backoff_s, + error=resp.error, + ) + _on_progress( + "retry", + f"provider error '{resp.error}'; killed stalled process if present; " + f"backing off {backoff_s:.1f}s then reissuing attempt {attempt + 1}/{max_retries + 1}", + ) + if backoff_s > 0: + time.sleep(backoff_s * (2 ** (attempt - 1))) + finished = datetime.now().isoformat(timespec="seconds") + run = AgentRun( + agent=agent.name, + objective=objective, + response=resp, + started_at=started, + finished_at=finished, + files_written=list(resp.files_written or []), + ) + self._record(run, prompt, provider.describe()) + log_event( + self.paths, + "agent_response", + agent=agent.name, + action="response_received", + ok=run.response.ok, + error=run.response.error, + response_chars=len(run.response.text or ""), + response_path=f"{run_dir_rel}/response.txt", + response_text=run.response.text or "", + tokens_total=int((run.response.usage or {}).get("total_tokens") or 0), + tokens_in=int((run.response.usage or {}).get("input_tokens") or 0), + tokens_out=int((run.response.usage or {}).get("output_tokens") or 0), + usage_source=(run.response.usage or {}).get("source"), + files_reported=list(run.files_written or []), + run_id=run_id, + ) + # Persist POST blocks to the blackboard before the rest of the + # apply hooks. Posting is cheap and uncommitted, so it happens + # even for dry-runs to keep the digest accurate during planning. + self._apply_posts(run, extra or {}) + # Apply any PROPOSE_ROLE / PROPOSE_WORKFLOW blocks the agent + # emitted. Mutates ``self.agents_cfg`` in place so the very next + # stage that names a freshly-proposed role can dispatch to it. + self._apply_proposals(run) + self._apply_consensus(run, extra or {}) + # Execute any ACTION blocks the agent emitted. Real file edits + # / shell runs land here regardless of which provider produced + # the response, so even non-tool-using providers can drive real + # changes. We merge the harness-applied files into the run's + # files_written list so the TUI / commit logic see them. + if not is_dry: + self._apply_actions(run, extra or {}) + if self.observer is not None: + try: + self.observer.end(agent.name, run) + except Exception as exc: + log_exception("orchestration.agent.observer.end", exc) + return run + + def _should_retry_provider(self, error: str) -> bool: + msg = (error or "").lower() + retryable = [ + "no output for", + "timeout after", + "operation was aborted", + # OpenRouter can report this routing/policy text transiently + # even when a later identical request succeeds. + "no endpoints available", + "guardrail restrictions", + "data policy", + "connection reset", + "temporarily unavailable", + "try again", + # HTTP 429 rate-limiting and HTTP 404 (OpenRouter: no endpoints temporarily available) + "http 429", + "http 404", + ] + non_retryable = [ + "api key", + "authentication", + "unauthorized", + "forbidden", + "cli not found", + ] + return any(s in msg for s in retryable) and not any(s in msg for s in non_retryable) + + def _observer_log(self, line: str) -> None: + logger.info(line) + if self.observer is not None: + try: + self.observer.log(line) + except Exception as exc: + log_exception("orchestration.agent.observer.log", exc) + + def _apply_consensus(self, run: AgentRun, extra: Dict[str, Any]) -> None: + text = run.response.text or "" + if not text or "PROPOSE_CONSENSUS" not in text: + return + if str(extra.get("phase") or "") == "consensus": + return + proposals = _casting.parse_consensus_proposals(text) + if not proposals: + return + cfg = self.clk_cfg.get("consensus") or {} + max_samples = int(cfg.get("max_samples") or 6) + max_parallel = int(cfg.get("max_parallel") or 4) + for prop in proposals: + agents = [a for a in prop.agents if a in (self.agents_cfg.get("agents") or {})] + if not agents: + agents = [run.agent] + sample_count = min(max_samples, max(1, int(prop.copies or 3))) + assignments = [agents[i % len(agents)] for i in range(sample_count)] + log_event( + self.paths, + "consensus_started", + agent=run.agent, + name=prop.name, + objective=prop.objective, + agents=list(assignments), + samples=sample_count, + max_parallel=max_parallel, + ) + self._observer_log( + f"consensus :: {prop.name} :: starting {sample_count} samples " + f"across {', '.join(sorted(set(assignments)))}" + ) + results: List[Dict[str, Any]] = [] + with ThreadPoolExecutor(max_workers=max(1, min(max_parallel, sample_count))) as pool: + futs = { + pool.submit(self._run_consensus_sample, prop.name, idx + 1, agent_name, prop.objective): ( + idx + 1, + agent_name, + ) + for idx, agent_name in enumerate(assignments) + } + for fut in as_completed(futs): + idx, agent_name = futs[fut] + try: + results.append(fut.result()) + except Exception as exc: + log_exception("orchestration.agent._apply_consensus.sample", exc) + results.append({"sample": idx, "agent": agent_name, "ok": False, "error": str(exc), "text": ""}) + results.sort(key=lambda r: int(r.get("sample") or 0)) + log_event( + self.paths, + "consensus_samples_completed", + agent=run.agent, + name=prop.name, + results=results, + ) + self._observer_log(f"consensus :: {prop.name} :: samples complete; coalescing with chief") + coalesce = self._consensus_coalesce_objective(prop.name, prop.objective, results) + coalesced = self.run( + "chief", + coalesce, + extra={"phase": "consensus", "consensus_name": prop.name}, + ) + log_event( + self.paths, + "consensus_coalesced", + agent="chief", + name=prop.name, + ok=coalesced.response.ok, + response_text=coalesced.response.text or "", + error=coalesced.response.error, + ) + self._observer_log(f"consensus :: {prop.name} :: coalesced by chief") + + def _run_consensus_sample(self, name: str, sample: int, agent_name: str, objective: str) -> Dict[str, Any]: + label = f"{agent_name}#consensus{sample}" + agent = self.get_agent(agent_name) + provider = self.get_provider(agent.provider) + sample_objective = ( + f"Stochastic consensus sample `{name}` #{sample}.\n\n" + "Answer independently. Do not coordinate with other samples.\n\n" + f"Consensus objective:\n{objective}" + ) + prompt = self.render_prompt(agent, sample_objective, {"phase": "consensus_sample", "agent": agent_name}) + started = datetime.now().isoformat(timespec="seconds") + run_id = f"{started.replace(':','-')}-{label}" + timeout_s = int((self.clk_cfg.get("provider_timeout_s") or 300)) + no_output_timeout_s = int((self.clk_cfg.get("provider_no_output_timeout_s") or 0)) + + def _progress(kind: str, message: str) -> None: + log_event( + self.paths, + ("http_" + kind[5:] if kind.startswith("http_") else "subprocess_" + kind), + agent=label, + consensus=name, + sample=sample, + message=message, + message_chars=len(message or ""), + ) + if self.observer is not None: + try: + self.observer.progress(label, kind, message) + except Exception as _exc: + logger.debug("observer progress failed: %s", _exc) + + log_event( + self.paths, + "consensus_sample_dispatch", + agent=label, + base_agent=agent_name, + consensus=name, + sample=sample, + objective=objective, + provider=provider.describe(), + run_id=run_id, + ) + self._observer_log( + f"consensus :: {name} :: sample #{sample} dispatching ({agent_name})" + ) + if self.observer is not None: + self.observer.begin(label, sample_objective) + self.observer.prompt_sent(label, prompt) + req = AgentRequest( + agent=label, + prompt=prompt, + workdir=self.paths.root, + dry_run=bool(self.clk_cfg.get("dry_run", False)), + timeout_s=timeout_s, + no_output_timeout_s=no_output_timeout_s, + on_progress=_progress, + ) + try: + resp = provider.invoke(req) + except Exception as exc: + resp = AgentResponse(ok=False, error=str(exc)) + finished = datetime.now().isoformat(timespec="seconds") + arun = AgentRun( + agent=label, objective=sample_objective, response=resp, started_at=started, finished_at=finished + ) + self._record(arun, prompt, provider.describe()) + if self.observer is not None: + self.observer.end(label, arun) + self._observer_log( + f"consensus :: {name} :: sample #{sample} done " + f"({'ok' if resp.ok else 'error: ' + (resp.error or '?')})" + ) + log_event( + self.paths, + "consensus_sample_response", + agent=label, + base_agent=agent_name, + consensus=name, + sample=sample, + ok=resp.ok, + error=resp.error, + response_text=resp.text or "", + ) + return { + "sample": sample, + "agent": agent_name, + "label": label, + "ok": resp.ok, + "error": resp.error, + "text": resp.text or "", + } + + def _consensus_coalesce_objective(self, name: str, objective: str, results: List[Dict[str, Any]]) -> str: + parts = [ + f"Coalesce stochastic consensus `{name}` into one coherent response.", + "", + "Original consensus objective:", + objective, + "", + "Samples:", + ] + for r in results: + parts.append( + f"\n--- sample {r.get('sample')} agent={r.get('agent')} " + f"ok={r.get('ok')} error={r.get('error') or ''} ---" + ) + parts.append((r.get("text") or "").strip() or "(no response)") + parts.append("\nReturn a unified answer with agreements, disagreements, and the recommended decision.") + return "\n".join(parts) diff --git a/clk_harness/orchestration/agent/transcript.py b/clk_harness/orchestration/agent/transcript.py new file mode 100644 index 0000000..8385d1f --- /dev/null +++ b/clk_harness/orchestration/agent/transcript.py @@ -0,0 +1,460 @@ +"""Agent run records and response transcript processing. + +The dataclasses that describe a dispatch (:class:`AgentSpec`, +:class:`AgentRun`), the observer hook, and the mixin that parses the +provider response transcript — POST blocks, PROPOSE blocks, ACTION +blocks — and persists the run history. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any, Dict, FrozenSet, List, Optional + +from ...git_ops import add_all, has_changes, head_sha, is_repo +from ...git_ops import commit as git_commit +from ...log import get_logger, log_exception +from ...providers import AgentResponse +from ...utils.activity_log import log_event +from .. import actions as _actions +from .. import blackboard as _blackboard +from .. import casting as _casting + +if TYPE_CHECKING: + import threading + + from ...config import Paths + +logger = get_logger(__name__) + + +@dataclass +class AgentSpec: + name: str + prompt_file: str + provider: Optional[str] = None + role: str = "" + capabilities: List[str] = field(default_factory=list) + + @classmethod + def from_config(cls, name: str, cfg: Dict[str, Any]) -> "AgentSpec": + return cls( + name=name, + prompt_file=cfg.get("prompt") or f"{name}.md", + provider=cfg.get("provider"), + role=cfg.get("role", ""), + capabilities=list(cfg.get("capabilities") or []), + ) + + +@dataclass +class AgentRun: + agent: str + objective: str + response: AgentResponse + started_at: str + finished_at: str + files_written: List[str] = field(default_factory=list) + # Number of file-mutating ACTIONs (write/edit/append/delete) the harness + # actually applied for this run. Used by the no-op guard: a producing + # stage that applied zero mutations is re-dispatched with an escalating + # repair preamble (descriptions alone do nothing). + file_mutations_applied: int = 0 + # True when the runner already created a git commit for this run's + # actions; downstream consumers (WorkflowRunner._commit) skip in + # that case to avoid double-committing the same diff. + committed: bool = False + # Blackboard post ids written by this run via POST blocks. The + # workflow runner uses these to verify ``WorkflowStage.outputs`` + # contracts and to drive review-stage digests. + posts: List[str] = field(default_factory=list) + + +class AgentObserver: + """Optional hook invoked by :class:`AgentRunner` around every agent call. + + Subclasses override the methods they care about. All methods are wrapped + in try/except inside the runner so an observer bug never breaks a run. + """ + + def begin(self, agent: str, objective: str) -> None: # pragma: no cover + pass + + def prompt_sent(self, agent: str, prompt: str) -> None: # pragma: no cover + """Called after the prompt has been rendered, before provider invocation.""" + pass + + def end(self, agent: str, run: "AgentRun") -> None: # pragma: no cover + pass + + def progress(self, agent: str, kind: str, message: str) -> None: # pragma: no cover + """Streaming progress from a CLI provider's subprocess. + + ``kind`` is one of: ``"start"`` (subprocess launched, message + carries pid + cmd), ``"stdout_line"`` / ``"stderr_line"`` (each + line emitted), ``"end"`` (subprocess exited, message carries + rc), ``"timeout"`` (killed). The TUI uses this to show the user + what the underlying CLI is actually doing rather than letting + them stare at a stalled spinner. + """ + pass + + def log(self, line: str) -> None: # pragma: no cover + pass + + def roster_changed(self, name: str, status: str) -> None: # pragma: no cover + """Called when a dynamic role is added / updated / removed. + + ``status`` is one of ``"added"``, ``"updated"``, ``"removed"``, + ``"workflow_written"``, ``"prompt_updated"``. + """ + pass + + +class TranscriptMixin: + """Response-transcript processing mixed into ``AgentRunner``. + + Parses POST / PROPOSE_ROLE / PROPOSE_WORKFLOW / ACTION blocks out of + the provider response, applies their effects, and persists the run + history under ``.clk/runs`` and ``agent_memory.jsonl``. + """ + + paths: "Paths" + agents_cfg: Dict[str, Any] + clk_cfg: Dict[str, Any] + observer: Optional[AgentObserver] + _proposals_lock: "threading.RLock" + _META_PHASES: FrozenSet[str] + + if TYPE_CHECKING: + # Provided by AgentRunner (runner.py); declared here so annotated + # mixin methods type-check without a runtime import cycle. + def _dispatch_once( + self, + agent_name: str, + objective: str, + *, + extra: Optional[Dict[str, Any]] = None, + dry_run: Optional[bool] = None, + ) -> "AgentRun": ... + + def _observer_log(self, line: str) -> None: ... + + # -- casting ----------------------------------------------------------- + + def _apply_proposals(self, run: AgentRun) -> None: + text = (run.response.text or "") + if not text or ("PROPOSE_ROLE" not in text and "PROPOSE_WORKFLOW" not in text): + return + cap = int(((self.clk_cfg.get("casting") or {}).get("max_dynamic_roles")) + or _casting.DEFAULT_MAX_DYNAMIC_ROLES) + observer = self.observer + + def _on_change(name: str, status: str) -> None: + logger.info(f"casting :: {name} :: {status}") + if observer is not None: + try: + observer.roster_changed(name, status) + except Exception as exc: + log_exception("orchestration.agent.observer.roster_changed", exc) + + with self._proposals_lock: + result = _casting.apply_response_proposals( + self.paths, + text, + agents_cfg=self.agents_cfg, + max_dynamic=cap, + source_agent=run.agent, + on_change=_on_change, + ) + if not result.is_empty(): + logger.info(f"casting from {run.agent}: {result.summary()}") + + def _apply_posts(self, run: AgentRun, extra: Dict[str, Any]) -> None: + """Persist any POST blocks the agent emitted onto the blackboard. + + Stage_id / workflow are taken from ``extra`` so a post made + during a workflow stage records its provenance and the workflow + runner can verify the stage's declared outputs against it. + + When a question post carries a ``target_agent`` and + ``urgency=blocking``, the harness dispatches the named agent to + answer the question synchronously before this run returns. + That makes the asker's worker effectively block on the answer, + which gets posted back to the blackboard with ``post_type: + answer`` and ``consumes: []``. + """ + text = (run.response.text or "") + if not text or "POST:" not in text: + return + try: + posted = _blackboard.apply_post_blocks( + self.paths, + text, + author=run.agent, + stage_id=str(extra.get("stage_id") or ""), + workflow=str(extra.get("workflow") or ""), + ) + except Exception as exc: + log_exception("orchestration.agent._apply_posts", exc) + return + for p in posted: + if p.id and p.id not in run.posts: + run.posts.append(p.id) + # Route blocking questions: dispatch the target agent inline so + # the asker effectively sees the answer in subsequent rounds. + try: + self._route_blocking_questions(run, posted, extra) + except Exception as exc: + log_exception("orchestration.agent._apply_posts.route_qa", exc) + + def _route_blocking_questions( + self, + run: AgentRun, + posted: List["_blackboard.Post"], + extra: Dict[str, Any], + ) -> None: + """Dispatch peer agents to answer ``POST: question`` blocks. + + Skipped entirely when there are no question posts targeted at a + peer, when we're already inside a Q&A chain that has exhausted + its depth budget, or when the dispatcher is itself in a meta- + phase (consensus, recovery, etc.). + """ + questions = [ + p for p in posted + if (p.post_type or "").lower() == "question" + and (p.target_agent or "").strip() + and (p.urgency or "blocking").lower() == "blocking" + ] + if not questions: + return + if str(extra.get("phase") or "") in self._META_PHASES: + return + tel = extra.get("telemetry") + cfg = self.clk_cfg.get("robustness") or {} + max_depth = int(cfg.get("max_qa_depth") or 3) + chain: List[str] = list(extra.get("qa_chain") or []) + if len(chain) >= max_depth: + log_event( + self.paths, + "qa_chain_capped", + agent=run.agent, + depth=len(chain), + max_depth=max_depth, + chain=list(chain), + ) + return + agents_known = set((self.agents_cfg.get("agents") or {}).keys()) + for q in questions: + target = q.target_agent.strip() + if not target or target not in agents_known: + log_event( + self.paths, + "qa_target_unknown", + agent=run.agent, + target=target, + question_id=q.id, + ) + continue + if target == run.agent: + # Self-questions don't need routing. + continue + if target in chain: + log_event( + self.paths, + "qa_chain_cycle", + agent=run.agent, + target=target, + chain=list(chain), + ) + continue + next_chain = chain + [run.agent] + answer_objective = ( + f"Peer question routed by the harness.\n\n" + f"Asker: `{run.agent}` (stage `{extra.get('stage_id') or '?'}`)\n" + f"Question id: `{q.id}`\n\n" + f"Question:\n{q.body}\n\n" + "Answer this directly. Emit a POST: answer block whose\n" + f"CONSUMES list contains `{q.id}`. Keep the body focused " + "on what the asker needs to make progress — do not start " + "a new sub-thread of questions of your own." + ) + log_event( + self.paths, + "qa_dispatch", + agent=run.agent, + target=target, + question_id=q.id, + chain=next_chain, + urgency=q.urgency or "blocking", + ) + self._observer_log( + f"qa :: {run.agent} → {target} :: {q.id[:32]}" + ) + if tel is not None: + try: + tel.add_qa_exchange() + except Exception as _exc: + logger.debug("telemetry add_qa_exchange failed: %s", _exc) + self._dispatch_once( + target, + answer_objective, + extra={ + "phase": "qa_answer", + "qa_chain": next_chain, + "qa_question_id": q.id, + "qa_asker": run.agent, + "stage_id": extra.get("stage_id"), + "workflow": extra.get("workflow"), + }, + dry_run=self.clk_cfg.get("dry_run", False), + ) + + def _apply_actions(self, run: AgentRun, extra: Optional[Dict[str, Any]] = None) -> None: + """Execute ACTION blocks; merge harness-written files back into the run.""" + extra = extra or {} + tel = extra.get("telemetry") + text = (run.response.text or "") + if not text or "ACTION:" not in text and "ACTION :" not in text: + return + result = _actions.apply_actions( + self.paths, + text, + agent_name=run.agent, + clk_cfg=self.clk_cfg, + ) + # Record how many file mutations actually landed (drives the no-op + # guard even when ACTION blocks were present but all skipped). + run.file_mutations_applied = len(result.files_written) + len(result.files_deleted) + if tel is not None: + try: + tel.add_actions(len(result.files_written) + len(result.files_deleted) + + len(result.commands_run)) + tel.add_files(len(result.files_written)) + except Exception as _exc: + logger.debug("telemetry add_actions/add_files failed: %s", _exc) + if result.is_empty(): + return + # Merge into run.files_written so downstream consumers (TUI, + # commit step) reflect what actually happened. + seen = set(run.files_written) + for f in result.files_written: + if f not in seen: + run.files_written.append(f) + seen.add(f) + # Also surface deletes so they get attributed to this run. + for f in result.files_deleted: + run.files_written.append(f"deleted:{f}") + logger.info(f"actions from {run.agent}: {result.summary()}") + # Annotate the response so the TUI can show it in the log pane: + # we tack a short summary onto the text preview path. + if result.commands_run or result.errors: + for cmd, out in zip(result.commands_run, result.command_outputs): + logger.info(f"actions[{run.agent}] $ {cmd}") + if out.strip(): + for line in out.strip().splitlines()[:6]: + logger.info(f"actions[{run.agent}] {line[:200]}") + for err in result.errors: + logger.warning(f"actions[{run.agent}] !! {err}") + # Auto-commit any file changes from this batch so the git log + # has a per-agent-run granularity. Only fires when this run + # actually wrote (or deleted) files. + if (result.files_written or result.files_deleted) and self.clk_cfg.get("auto_commit", True): + self._commit_action_batch(run, result, extra) + + def _commit_action_batch( + self, + run: AgentRun, + result: "_actions.ActionResult", + extra: Optional[Dict[str, Any]] = None, + ) -> None: + try: + if not is_repo(self.paths.root): + return + if not has_changes(self.paths.root): + return + if not add_all(self.paths.root): + return + usage = run.response.usage or {} + tok_total = int(usage.get("total_tokens") or 0) + tok_in = int(usage.get("input_tokens") or 0) + tok_out = int(usage.get("output_tokens") or 0) + extra_lines = [] + if result.commands_run: + extra_lines.append("Commands run:") + for c in result.commands_run[:8]: + extra_lines.append(f" $ {c}") + if result.skipped: + extra_lines.append("") + extra_lines.append("Skipped actions:") + for s in result.skipped[:8]: + extra_lines.append(f" - {s}") + extra_lines.append("") + extra_lines.append( + f"Tokens: total={tok_total} in={tok_in} out={tok_out} " + f"src={usage.get('source','?')}" + ) + committed = git_commit( + self.paths.root, + agent=run.agent, + objective=run.objective, + files_changed=run.files_written, + validation=result.summary(), + next_step="continue iteration", + body_extra="\n".join(extra_lines), + ) + if committed: + run.committed = True + tel = (extra or {}).get("telemetry") + if tel is not None: + try: + tel.add_commit() + except Exception as _exc: + logger.debug("telemetry add_commit failed: %s", _exc) + logger.info( + f"commit: [{run.agent}] {len(result.files_written)} files, " + f"{len(result.files_deleted)} deletes" + ) + log_event( + self.paths, + "git_commit", + agent=run.agent, + objective=run.objective[:200], + sha=head_sha(self.paths.root), + files_written=list(result.files_written), + files_deleted=list(result.files_deleted), + commands_run=list(result.commands_run), + tokens_total=tok_total, + ) + except Exception as exc: + log_exception("orchestration.agent._commit_action_batch", exc) + + def _record(self, run: AgentRun, prompt: str, provider_desc: str) -> None: + try: + self.paths.state.mkdir(parents=True, exist_ok=True) + mem = self.paths.state / "agent_memory.jsonl" + payload = { + "agent": run.agent, + "run_id": f"{run.started_at.replace(':','-')}-{run.agent}", + "objective": run.objective, + "ok": run.response.ok, + "error": run.response.error, + "files_written": run.files_written, + "started_at": run.started_at, + "finished_at": run.finished_at, + "provider": provider_desc, + "usage": dict(run.response.usage or {}), + "text_preview": (run.response.text or "")[:500], + } + with mem.open("a", encoding="utf-8") as fh: + fh.write(json.dumps(payload) + "\n") + + run_dir = self.paths.runs / f"{run.started_at.replace(':','-')}-{run.agent}" + run_dir.mkdir(parents=True, exist_ok=True) + (run_dir / "prompt.txt").write_text(prompt, encoding="utf-8") + (run_dir / "response.txt").write_text(run.response.text or "", encoding="utf-8") + (run_dir / "meta.json").write_text(json.dumps(payload, indent=2), encoding="utf-8") + except Exception as exc: + log_exception("orchestration.agent._record", exc) diff --git a/clk_harness/orchestration/autoresearch_loop.py b/clk_harness/orchestration/autoresearch_loop.py index a8e2ade..c935c9f 100644 --- a/clk_harness/orchestration/autoresearch_loop.py +++ b/clk_harness/orchestration/autoresearch_loop.py @@ -15,21 +15,21 @@ from __future__ import annotations import json -import sys -import traceback -from dataclasses import dataclass, field +from dataclasses import dataclass from datetime import datetime -from pathlib import Path -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List from ..config import Paths -from ..git_ops import add_all, commit as git_commit, has_changes, head_sha, revert_to +from ..git_ops import add_all, has_changes, head_sha, revert_to +from ..git_ops import commit as git_commit +from ..log import get_logger, log, log_exception from ..utils.activity_log import log_event -from ..utils.logging_utils import log, log_exception from . import response_quality as _response_quality from .agent import AgentRunner from .evaluator import Evaluator +logger = get_logger(__name__) + @dataclass class Experiment: @@ -75,8 +75,8 @@ def _observer_log(self, line: str) -> None: if obs is not None: try: obs.log(line) - except Exception: - pass + except Exception as _exc: + logger.debug("observer log failed: %s", _exc) def _step(self, idx: int, *, dry_run: bool) -> Experiment: started = datetime.now().isoformat(timespec="seconds") @@ -114,7 +114,7 @@ def _step(self, idx: int, *, dry_run: bool) -> Experiment: ) question_lines = (survey.response.text or "").strip().splitlines() question = next( - (l for l in question_lines if l.strip().startswith(("Q:", "Question:", "Hypothesis:"))), + (ln for ln in question_lines if ln.strip().startswith(("Q:", "Question:", "Hypothesis:"))), f"Open question #{idx}", ) diff --git a/clk_harness/orchestration/blackboard.py b/clk_harness/orchestration/blackboard.py index 18e316c..3ccf958 100644 --- a/clk_harness/orchestration/blackboard.py +++ b/clk_harness/orchestration/blackboard.py @@ -54,8 +54,10 @@ from typing import Any, Dict, Iterable, List, Optional, Sequence from ..config import Paths +from ..log import get_logger, log, log_exception from ..utils.activity_log import log_event -from ..utils.logging_utils import log, log_exception + +logger = get_logger(__name__) def blackboard_dir(paths: Paths) -> Path: @@ -200,8 +202,8 @@ def post( produces=list(p.produces), body_chars=len(p.body or ""), ) - except Exception: - pass + except Exception as _exc: + logger.debug("blackboard: activity mirror failed: %s", _exc) return p diff --git a/clk_harness/orchestration/casting/__init__.py b/clk_harness/orchestration/casting/__init__.py new file mode 100644 index 0000000..c8d3c90 --- /dev/null +++ b/clk_harness/orchestration/casting/__init__.py @@ -0,0 +1,90 @@ +"""Dynamic agent casting. + +The chief authors the project's roster of agents and the workflows that +wire them together. Decomposed package; this ``__init__`` preserves the +public surface of the former ``clk_harness/orchestration/casting.py``: + +* :mod:`.director` — dynamic team casting logic: parsing + ``PROPOSE_*`` blocks, applying them, and the casting protocol / + objective handed to the chief. +* :mod:`.roster` — role/agent definitions: baseline agents, + duplicate detection, prompt scaffolding, and registry mutators. +""" + +from .director import ( + CASTING_PROTOCOL, + CharterProposal, + ConsensusProposal, + PlanProposal, + RoleProposal, + WorkflowProposal, + _parse_simple_phase_list, + _split_items, + apply_response_proposals, + casting_objective, + parse_charter_proposal, + parse_consensus_proposals, + parse_plan_proposal, + parse_role_proposals, + parse_workflow_proposals, +) +from .roster import ( + _PROTOCOL_MARKER, + BASELINE_AGENTS, + DEFAULT_MAX_DYNAMIC_ROLES, + DEFAULT_PROMPT_SIM_THRESHOLD, + CastingResult, + _ensure_prompt_file, + _harness_protocol_suffix, + _log_casting_event, + _name_key, + _normalize_name, + _prompt_similarity, + _prompt_tokens, + _similar_existing_name, + _similar_existing_prompt, + is_baseline, + list_roles, + register_role, + remove_role, + render_roster_summary, + write_workflow, +) + +__all__ = [ + "BASELINE_AGENTS", + "CASTING_PROTOCOL", + "CastingResult", + "CharterProposal", + "ConsensusProposal", + "DEFAULT_MAX_DYNAMIC_ROLES", + "DEFAULT_PROMPT_SIM_THRESHOLD", + "PlanProposal", + "RoleProposal", + "WorkflowProposal", + "_PROTOCOL_MARKER", + "_ensure_prompt_file", + "_harness_protocol_suffix", + "_log_casting_event", + "_name_key", + "_normalize_name", + "_parse_simple_phase_list", + "_prompt_similarity", + "_prompt_tokens", + "_similar_existing_name", + "_similar_existing_prompt", + "_split_items", + "apply_response_proposals", + "casting_objective", + "is_baseline", + "list_roles", + "parse_charter_proposal", + "parse_consensus_proposals", + "parse_plan_proposal", + "parse_role_proposals", + "parse_workflow_proposals", + "register_role", + "remove_role", + "render_roster_summary", + "write_workflow", +] diff --git a/clk_harness/orchestration/casting/director.py b/clk_harness/orchestration/casting/director.py new file mode 100644 index 0000000..6d83c7b --- /dev/null +++ b/clk_harness/orchestration/casting/director.py @@ -0,0 +1,603 @@ +"""Casting director: dynamic team casting logic. + +Parses ``PROPOSE_ROLE`` / ``PROPOSE_WORKFLOW`` / ``PROPOSE_CONSENSUS`` / +``PROPOSE_CHARTER`` / ``PROPOSE_PLAN`` blocks out of any agent's +response text, applies them to the roster, and produces the casting +protocol / objective handed to the chief. + +Format the chief (or any agent) emits for a role proposal:: + + PROPOSE_ROLE: data_steward + ROLE: ensure data integrity and schema versioning + PROVIDER: claude + PROMPT: + You are the **Data Steward** agent. + Objective: $objective + State: $state_summary + ... + END_ROLE + +And for a workflow:: + + PROPOSE_WORKFLOW: engineering + DESCRIPTION: Custom development cycle + YAML: + name: engineering + description: Custom development cycle + stages: + - id: decompose + agent: chief + objective: ... + - id: implement + agent: data_steward + objective: ... + depends_on: [decompose] + commit: true + END_WORKFLOW + +Both blocks may appear multiple times in the same response; ``apply`` +returns counts so callers can log / surface the changes. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass, field +from typing import Any, Callable, Dict, List, Optional + +from ...config import Paths +from ...log import get_logger, log_exception +from ...utils.activity_log import log_event +from .roster import ( + DEFAULT_MAX_DYNAMIC_ROLES, + CastingResult, + _normalize_name, + register_role, + write_workflow, +) + +logger = get_logger(__name__) + + +# --------------------------------------------------------------------------- +# Parsing +# --------------------------------------------------------------------------- + + +@dataclass +class RoleProposal: + name: str + role: str = "" + provider: Optional[str] = None + capabilities: List[str] = field(default_factory=list) + prompt: str = "" + + +@dataclass +class WorkflowProposal: + name: str + description: str = "" + yaml_body: str = "" + + +@dataclass +class ConsensusProposal: + name: str + agents: List[str] = field(default_factory=list) + copies: int = 3 + objective: str = "" + + +@dataclass +class CharterProposal: + """A chief-authored mission charter (the up-front commitment). + + The plan and the done-gate are derived from this, so "done" is judged + against what the chief committed to rather than drifting. + """ + mission: str = "" + scope: List[str] = field(default_factory=list) + non_goals: List[str] = field(default_factory=list) + success: List[str] = field(default_factory=list) + constraints: List[str] = field(default_factory=list) + assumptions: List[str] = field(default_factory=list) + + +@dataclass +class PlanProposal: + """An ordered list of lifecycle phases for a mission.""" + phases: List[Dict[str, Any]] = field(default_factory=list) + + +_ROLE_HEAD_RE = re.compile(r"^\s*PROPOSE_ROLE\s*:\s*([A-Za-z][A-Za-z0-9_\-]*)\s*$", re.MULTILINE) +_ROLE_FIELD_RE = re.compile(r"^(ROLE|PROVIDER|CAPABILITIES)\s*:\s*(.*)$", re.IGNORECASE) +_ROLE_PROMPT_RE = re.compile(r"^\s*PROMPT\s*:\s*$", re.IGNORECASE) +_ROLE_END_RE = re.compile(r"^\s*END_ROLE\s*$", re.IGNORECASE) + +_WF_HEAD_RE = re.compile(r"^\s*PROPOSE_WORKFLOW\s*:\s*([A-Za-z][A-Za-z0-9_\-]*)\s*$", re.MULTILINE) +_WF_FIELD_RE = re.compile(r"^(DESCRIPTION)\s*:\s*(.*)$", re.IGNORECASE) +_WF_YAML_RE = re.compile(r"^\s*YAML\s*:\s*$", re.IGNORECASE) +_WF_END_RE = re.compile(r"^\s*END_WORKFLOW\s*$", re.IGNORECASE) + +_CONS_HEAD_RE = re.compile(r"^\s*PROPOSE_CONSENSUS\s*:\s*([A-Za-z][A-Za-z0-9_\-]*)\s*$", re.MULTILINE) +_CONS_FIELD_RE = re.compile(r"^(AGENTS?|COPIES)\s*:\s*(.*)$", re.IGNORECASE) +_CONS_OBJECTIVE_RE = re.compile(r"^\s*OBJECTIVE\s*:\s*$", re.IGNORECASE) +_CONS_END_RE = re.compile(r"^\s*END_CONSENSUS\s*$", re.IGNORECASE) + +_CHARTER_HEAD_RE = re.compile(r"^\s*PROPOSE_CHARTER\s*:?\s*$", re.IGNORECASE) +_CHARTER_END_RE = re.compile(r"^\s*END_CHARTER\s*$", re.IGNORECASE) +_CHARTER_FIELD_RE = re.compile( + r"^(MISSION|SCOPE|NON_GOALS|NONGOALS|SUCCESS|SUCCESS_CRITERIA|CONSTRAINTS|ASSUMPTIONS)\s*:\s*(.*)$", + re.IGNORECASE, +) + +_PLAN_HEAD_RE = re.compile(r"^\s*PROPOSE_PLAN\s*:?\s*$", re.IGNORECASE) +_PLAN_PHASES_RE = re.compile(r"^\s*PHASES\s*:\s*$", re.IGNORECASE) +_PLAN_END_RE = re.compile(r"^\s*END_PLAN\s*$", re.IGNORECASE) + + +def _split_items(value: str) -> List[str]: + """Split a charter field value into items. + + Items may be separated by ``;`` (preferred, since success criteria can + contain commas) or, when no semicolon is present, by commas. A leading + ``[`` / trailing ``]`` is tolerated. + """ + v = (value or "").strip() + if v.startswith("[") and v.endswith("]"): + v = v[1:-1] + if not v: + return [] + sep = ";" if ";" in v else "," + return [item.strip().strip("-").strip() for item in v.split(sep) if item.strip().strip("-").strip()] + + +def parse_charter_proposal(text: str) -> Optional[CharterProposal]: + """Extract a single ``PROPOSE_CHARTER`` block from ``text`` (or None).""" + if not text or "PROPOSE_CHARTER" not in text.upper(): + return None + lines = text.splitlines() + i = 0 + while i < len(lines): + if not _CHARTER_HEAD_RE.match(lines[i]): + i += 1 + continue + prop = CharterProposal() + i += 1 + while i < len(lines): + line = lines[i] + if _CHARTER_END_RE.match(line): + i += 1 + break + fm = _CHARTER_FIELD_RE.match(line) + if fm: + key = fm.group(1).upper() + val = fm.group(2).strip() + if key == "MISSION": + prop.mission = val + elif key == "SCOPE": + prop.scope = _split_items(val) + elif key in ("NON_GOALS", "NONGOALS"): + prop.non_goals = _split_items(val) + elif key in ("SUCCESS", "SUCCESS_CRITERIA"): + prop.success = _split_items(val) + elif key == "CONSTRAINTS": + prop.constraints = _split_items(val) + elif key == "ASSUMPTIONS": + prop.assumptions = _split_items(val) + i += 1 + if prop.mission or prop.success or prop.scope: + return prop + return None + + +def _parse_simple_phase_list(body: str) -> List[Dict[str, Any]]: + """Dependency-free parser for the PHASES YAML list. + + Handles the documented shape: a list of ``- key: value`` blocks where + values are scalars or ``[a, b]`` inline lists. Used as a fallback when + PyYAML is unavailable. + """ + phases: List[Dict[str, Any]] = [] + current: Optional[Dict[str, Any]] = None + for raw in body.splitlines(): + line = raw.rstrip() + if not line.strip(): + continue + stripped = line.lstrip() + is_item = stripped.startswith("- ") + if is_item: + stripped = stripped[2:].lstrip() + current = {} + phases.append(current) + if current is None: + current = {} + phases.append(current) + if ":" not in stripped: + continue + key, _, value = stripped.partition(":") + key = key.strip() + value = value.strip() + if value.startswith("[") and value.endswith("]"): + inner = value[1:-1] + current[key] = [x.strip().strip('"\'') for x in inner.split(",") if x.strip()] + elif value: + current[key] = value.strip('"\'') + else: + current[key] = "" + return [p for p in phases if p.get("id")] + + +def parse_plan_proposal(text: str) -> Optional[PlanProposal]: + """Extract a single ``PROPOSE_PLAN`` block (phases list) from ``text``.""" + if not text or "PROPOSE_PLAN" not in text.upper(): + return None + lines = text.splitlines() + i = 0 + while i < len(lines): + if not _PLAN_HEAD_RE.match(lines[i]): + i += 1 + continue + i += 1 + # skip to PHASES: + body_lines: List[str] = [] + seen_phases = False + while i < len(lines): + line = lines[i] + if _PLAN_END_RE.match(line): + i += 1 + break + if not seen_phases: + if _PLAN_PHASES_RE.match(line): + seen_phases = True + i += 1 + continue + body_lines.append(line) + i += 1 + body = "\n".join(body_lines).strip("\n") + if not body: + continue + phases: List[Dict[str, Any]] = [] + try: + import yaml as _yaml # type: ignore + parsed = _yaml.safe_load(body) + if isinstance(parsed, list): + phases = [p for p in parsed if isinstance(p, dict) and p.get("id")] + except Exception: + phases = [] + if not phases: + phases = _parse_simple_phase_list(body) + if phases: + return PlanProposal(phases=phases) + return None + + +def parse_role_proposals(text: str) -> List[RoleProposal]: + """Extract every PROPOSE_ROLE block from ``text``. + + Returns an empty list (never raises) when nothing matches or input + is malformed; that way callers can apply this to *any* agent + response without guarding it. + """ + if not text: + return [] + out: List[RoleProposal] = [] + lines = text.splitlines() + i = 0 + while i < len(lines): + m = _ROLE_HEAD_RE.match(lines[i]) + if not m: + i += 1 + continue + prop = RoleProposal(name=m.group(1).strip()) + i += 1 + prompt_lines: List[str] = [] + in_prompt = False + while i < len(lines): + line = lines[i] + if _ROLE_END_RE.match(line): + i += 1 + break + if not in_prompt: + fm = _ROLE_FIELD_RE.match(line) + if fm: + key = fm.group(1).upper() + val = fm.group(2).strip() + if key == "ROLE": + prop.role = val + elif key == "PROVIDER": + prop.provider = val or None + elif key == "CAPABILITIES": + prop.capabilities = [ + c.strip() for c in re.split(r"[,\s]+", val) if c.strip() + ] + i += 1 + continue + if _ROLE_PROMPT_RE.match(line): + in_prompt = True + i += 1 + continue + # tolerate blank or stray lines inside the header section + i += 1 + continue + # in_prompt + prompt_lines.append(line) + i += 1 + prop.prompt = "\n".join(prompt_lines).strip("\n") + if prop.name: + out.append(prop) + return out + + +def parse_workflow_proposals(text: str) -> List[WorkflowProposal]: + if not text: + return [] + out: List[WorkflowProposal] = [] + lines = text.splitlines() + i = 0 + while i < len(lines): + m = _WF_HEAD_RE.match(lines[i]) + if not m: + i += 1 + continue + prop = WorkflowProposal(name=m.group(1).strip()) + i += 1 + yaml_lines: List[str] = [] + in_yaml = False + while i < len(lines): + line = lines[i] + if _WF_END_RE.match(line): + i += 1 + break + if not in_yaml: + fm = _WF_FIELD_RE.match(line) + if fm: + if fm.group(1).upper() == "DESCRIPTION": + prop.description = fm.group(2).strip() + i += 1 + continue + if _WF_YAML_RE.match(line): + in_yaml = True + i += 1 + continue + i += 1 + continue + yaml_lines.append(line) + i += 1 + prop.yaml_body = "\n".join(yaml_lines).strip("\n") + if prop.name and prop.yaml_body: + out.append(prop) + return out + + +def parse_consensus_proposals(text: str) -> List[ConsensusProposal]: + if not text: + return [] + out: List[ConsensusProposal] = [] + lines = text.splitlines() + i = 0 + while i < len(lines): + m = _CONS_HEAD_RE.match(lines[i]) + if not m: + i += 1 + continue + prop = ConsensusProposal(name=m.group(1).strip()) + i += 1 + objective_lines: List[str] = [] + in_objective = False + while i < len(lines): + line = lines[i] + if _CONS_END_RE.match(line): + i += 1 + break + if not in_objective: + fm = _CONS_FIELD_RE.match(line) + if fm: + key = fm.group(1).upper() + val = fm.group(2).strip() + if key in ("AGENT", "AGENTS"): + prop.agents = [ + _normalize_name(a) + for a in re.split(r"[, ]+", val) + if _normalize_name(a) + ] + elif key == "COPIES": + try: + prop.copies = max(1, int(val)) + except ValueError: + prop.copies = 3 + i += 1 + continue + if _CONS_OBJECTIVE_RE.match(line): + in_objective = True + i += 1 + continue + i += 1 + continue + objective_lines.append(line) + i += 1 + prop.objective = "\n".join(objective_lines).strip() + if prop.name and prop.objective: + out.append(prop) + return out + + +def apply_response_proposals( + paths: Paths, + response_text: str, + *, + agents_cfg: Optional[Dict[str, Any]] = None, + max_dynamic: int = DEFAULT_MAX_DYNAMIC_ROLES, + source_agent: str = "", + on_change: Optional[Callable[[str, str], None]] = None, +) -> CastingResult: + """Scan an agent's response, apply every proposal it contains. + + Safe to call after every agent run. Returns a result describing what + actually changed; callers can use ``result.is_empty()`` to skip + further work cheaply. + """ + result = CastingResult() + if not response_text: + return result + try: + for prop in parse_role_proposals(response_text): + ok, status = register_role( + paths, + prop, + agents_cfg=agents_cfg, + max_dynamic=max_dynamic, + source_agent=source_agent, + on_change=on_change, + ) + if not ok: + result.roles_skipped.append(f"{prop.name}:{status}") + log_event( + paths, + "role_skipped", + agent=source_agent, + name=prop.name, + role=prop.role, + provider=prop.provider, + prompt_length=len(prop.prompt or ""), + prompt=prop.prompt or "", + reason=status, + ) + elif status == "added": + result.roles_added.append(prop.name) + else: + result.roles_updated.append(prop.name) + for wf in parse_workflow_proposals(response_text): + ok, status = write_workflow(paths, wf, source_agent=source_agent, on_change=on_change) + if ok: + result.workflows_written.append(wf.name) + else: + result.workflows_skipped.append(f"{wf.name}:{status}") + log_event( + paths, + "workflow_skipped", + agent=source_agent, + name=wf.name, + description=wf.description, + yaml_length=len(wf.yaml_body or ""), + yaml=wf.yaml_body or "", + reason=status, + ) + except Exception as exc: + log_exception("orchestration.casting.apply_response_proposals", exc) + result.errors.append(str(exc)) + return result + + +# --------------------------------------------------------------------------- +# Casting prompt helpers +# --------------------------------------------------------------------------- + + +CASTING_PROTOCOL = """\ +ROLE-CASTING PROTOCOL (machine-parsed, follow exactly) + +You may emit any number of these blocks in your response. The harness +parses them and updates `.clk/config/agents.json`, `.clk/prompts/`, and +`.clk/config/workflows/` immediately. + +Add or refresh a role: + + PROPOSE_ROLE: + ROLE: + PROVIDER: + CAPABILITIES: + PROMPT: + + END_ROLE + +Capability hints (CAPABILITIES field) + Omit the field entirely to use provider defaults (tools on, standard thinking). + Combine as needed: "no-tools, thinking-off" + + no-tools Disable all tools. Best for research / doc-writing agents + that only need to produce text — avoids extra model round + trips and cuts latency significantly. + no-builtin-tools Disable built-in tools but keep custom extensions. + thinking-off Skip chain-of-thought. Best for formatting, summarising, + or any task that does not require deep reasoning. + thinking-low Minimal thinking — slightly faster than the default. + thinking-medium Standard thinking level (provider default). + thinking-high Deep reasoning — use for architecture, debugging, or tasks + where correctness matters more than speed. + thinking-xhigh Maximum thinking — slowest, most thorough. + +Author or replace a workflow (the harness will save it as +`.clk/config/workflows/.yaml`): + + PROPOSE_WORKFLOW: + DESCRIPTION: + YAML: + name: + description: + stages: + - id: + agent: + objective: + depends_on: [other_id, ...] # optional + validation: "" # optional, exit 0 = pass + commit: true # optional, default true + END_WORKFLOW + +Rules +- Roster cap is enforced (default 12 dynamic roles + the 3 baseline roles). + Drop or merge before adding past the cap. +- Baseline roles (chief, ralph, qa) cannot be removed but you may refresh + their prompt bodies. +- engineer is the canonical implementation agent. It is NOT a default + baseline — you must create it with PROPOSE_ROLE: engineer when a project + needs an implementer. The name is reserved: NEVER create `engineering`, + `engineers`, `coder`, `developer`, `programmer`, `implementer`, or any + other variant. The harness will explicitly deny such proposals and report + back to you via $casting_feedback. If engineer already exists, use it. +- ralph is the iterative refinement and autoresearch driver. Always + include at least one ralph stage in engineering workflows so the output + gets iteratively improved before delivery. Do NOT create a separate + autoresearch agent — ralph handles both modes. +- qa is the validation agent. Always include at least one qa stage in + every engineering workflow, typically as the final stage before done. +- All other roles (analyst, researcher, architect, etc.) are dynamic — + create them per project as needed. +- If a stage references an agent you have not defined yet, define it in + the same response with a PROPOSE_ROLE block. +- Workflows may use any combination of baseline and dynamic agents. +- Prefer assigning work to an existing agent when its role already fits. + Create or refresh a role when the need is distinct enough that an + existing role would blur ownership or do materially worse work. +- Before emitting ANY PROPOSE_ROLE block, run this mandatory pre-flight: + 1. Read every agent's prompt_preview in the current roster. + 2. Ask: "Does any existing agent's prompt already describe this work?" + If YES → use that agent. Do NOT emit PROPOSE_ROLE. + 3. Ask: "Is this name a synonym, plural, gerund, or department label + of an existing name?" (e.g. `engineering` when `engineer` exists, + `researchers` when `researcher` exists.) If YES → use the existing + name. Do NOT emit PROPOSE_ROLE. + 4. Only emit PROPOSE_ROLE when both checks pass: no functional overlap + AND a genuinely distinctive name. +- Functional overlap is the primary test — name similarity is secondary. + An agent named differently is still a duplicate if its prompt describes + the same work as an existing agent's prompt. The new role's PROMPT must + state what it owns that no current agent's prompt already covers. +- New role prompts and role lines must state the distinct responsibility + the role owns compared with the nearest existing agent. +""" + + +def casting_objective(idea_title: str, idea_statement: str) -> str: + return ( + "You are the casting director for this project. Decide on the team of agents " + "that will best serve the idea below, author each agent's prompt, and emit a " + "workflow YAML that wires them into one engineering cycle.\n\n" + f"Idea: {idea_title}\n{idea_statement}\n\n" + "Use the role-casting protocol to add or refresh agents and to write the " + "`engineering` workflow. Keep the roster small and specific to this project: " + "reuse existing agents when they fit, drop generic roles that won't earn " + "their keep, and invent specialists only when this particular system needs " + "a distinct owner." + ) diff --git a/clk_harness/orchestration/casting.py b/clk_harness/orchestration/casting/roster.py similarity index 53% rename from clk_harness/orchestration/casting.py rename to clk_harness/orchestration/casting/roster.py index 9d67f8f..fc2aebb 100644 --- a/clk_harness/orchestration/casting.py +++ b/clk_harness/orchestration/casting/roster.py @@ -1,65 +1,28 @@ -"""Dynamic agent casting. - -The chief authors the project's roster of agents and the workflows that -wire them together. This module: - - * defines the immutable BASELINE_AGENTS the chief cannot remove - * parses ``PROPOSE_ROLE`` / ``PROPOSE_WORKFLOW`` blocks out of any - agent's response text - * registers / removes roles in ``.clk/config/agents.json`` and - ``.clk/prompts/`` and updates an in-memory ``agents_cfg`` so a - proposal made mid-workflow takes effect on the very next stage - * produces the prompt context ("current roster") and the casting - objective handed to the chief - -Format the chief (or any agent) emits for a role proposal:: - - PROPOSE_ROLE: data_steward - ROLE: ensure data integrity and schema versioning - PROVIDER: claude - PROMPT: - You are the **Data Steward** agent. - Objective: $objective - State: $state_summary - ... - END_ROLE - -And for a workflow:: - - PROPOSE_WORKFLOW: engineering - DESCRIPTION: Custom development cycle - YAML: - name: engineering - description: Custom development cycle - stages: - - id: decompose - agent: chief - objective: ... - - id: implement - agent: data_steward - objective: ... - depends_on: [decompose] - commit: true - END_WORKFLOW - -Both blocks may appear multiple times in the same response; ``apply`` -returns counts so callers can log / surface the changes. +"""Roster registry: role/agent definitions and mutation. + +The immutable BASELINE_AGENTS, the duplicate-detection machinery +(name keys, synonym table, prompt-body similarity), prompt-file +scaffolding, and the registry mutators (``register_role``, +``remove_role``, ``write_workflow``). """ from __future__ import annotations import json import re -import sys from dataclasses import dataclass, field from datetime import datetime from pathlib import Path -from typing import Any, Callable, Dict, List, Optional, Tuple +from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Tuple + +from ...config import Paths, load_agents_config, save_json +from ...log import get_logger, log_exception +from ...utils.activity_log import log_event -from ..config import Paths, load_agents_config, save_json -from ..utils.activity_log import log_event -from ..utils.logging_utils import log, log_exception +if TYPE_CHECKING: + from .director import RoleProposal, WorkflowProposal +logger = get_logger(__name__) # --------------------------------------------------------------------------- # Baseline (always-present) agents @@ -88,369 +51,6 @@ DEFAULT_MAX_DYNAMIC_ROLES = 12 -# --------------------------------------------------------------------------- -# Parsing -# --------------------------------------------------------------------------- - - -@dataclass -class RoleProposal: - name: str - role: str = "" - provider: Optional[str] = None - capabilities: List[str] = field(default_factory=list) - prompt: str = "" - - -@dataclass -class WorkflowProposal: - name: str - description: str = "" - yaml_body: str = "" - - -@dataclass -class ConsensusProposal: - name: str - agents: List[str] = field(default_factory=list) - copies: int = 3 - objective: str = "" - - -@dataclass -class CharterProposal: - """A chief-authored mission charter (the up-front commitment). - - The plan and the done-gate are derived from this, so "done" is judged - against what the chief committed to rather than drifting. - """ - mission: str = "" - scope: List[str] = field(default_factory=list) - non_goals: List[str] = field(default_factory=list) - success: List[str] = field(default_factory=list) - constraints: List[str] = field(default_factory=list) - assumptions: List[str] = field(default_factory=list) - - -@dataclass -class PlanProposal: - """An ordered list of lifecycle phases for a mission.""" - phases: List[Dict[str, Any]] = field(default_factory=list) - - -_ROLE_HEAD_RE = re.compile(r"^\s*PROPOSE_ROLE\s*:\s*([A-Za-z][A-Za-z0-9_\-]*)\s*$", re.MULTILINE) -_ROLE_FIELD_RE = re.compile(r"^(ROLE|PROVIDER|CAPABILITIES)\s*:\s*(.*)$", re.IGNORECASE) -_ROLE_PROMPT_RE = re.compile(r"^\s*PROMPT\s*:\s*$", re.IGNORECASE) -_ROLE_END_RE = re.compile(r"^\s*END_ROLE\s*$", re.IGNORECASE) - -_WF_HEAD_RE = re.compile(r"^\s*PROPOSE_WORKFLOW\s*:\s*([A-Za-z][A-Za-z0-9_\-]*)\s*$", re.MULTILINE) -_WF_FIELD_RE = re.compile(r"^(DESCRIPTION)\s*:\s*(.*)$", re.IGNORECASE) -_WF_YAML_RE = re.compile(r"^\s*YAML\s*:\s*$", re.IGNORECASE) -_WF_END_RE = re.compile(r"^\s*END_WORKFLOW\s*$", re.IGNORECASE) - -_CONS_HEAD_RE = re.compile(r"^\s*PROPOSE_CONSENSUS\s*:\s*([A-Za-z][A-Za-z0-9_\-]*)\s*$", re.MULTILINE) -_CONS_FIELD_RE = re.compile(r"^(AGENTS?|COPIES)\s*:\s*(.*)$", re.IGNORECASE) -_CONS_OBJECTIVE_RE = re.compile(r"^\s*OBJECTIVE\s*:\s*$", re.IGNORECASE) -_CONS_END_RE = re.compile(r"^\s*END_CONSENSUS\s*$", re.IGNORECASE) - -_CHARTER_HEAD_RE = re.compile(r"^\s*PROPOSE_CHARTER\s*:?\s*$", re.IGNORECASE) -_CHARTER_END_RE = re.compile(r"^\s*END_CHARTER\s*$", re.IGNORECASE) -_CHARTER_FIELD_RE = re.compile( - r"^(MISSION|SCOPE|NON_GOALS|NONGOALS|SUCCESS|SUCCESS_CRITERIA|CONSTRAINTS|ASSUMPTIONS)\s*:\s*(.*)$", - re.IGNORECASE, -) - -_PLAN_HEAD_RE = re.compile(r"^\s*PROPOSE_PLAN\s*:?\s*$", re.IGNORECASE) -_PLAN_PHASES_RE = re.compile(r"^\s*PHASES\s*:\s*$", re.IGNORECASE) -_PLAN_END_RE = re.compile(r"^\s*END_PLAN\s*$", re.IGNORECASE) - - -def _split_items(value: str) -> List[str]: - """Split a charter field value into items. - - Items may be separated by ``;`` (preferred, since success criteria can - contain commas) or, when no semicolon is present, by commas. A leading - ``[`` / trailing ``]`` is tolerated. - """ - v = (value or "").strip() - if v.startswith("[") and v.endswith("]"): - v = v[1:-1] - if not v: - return [] - sep = ";" if ";" in v else "," - return [item.strip().strip("-").strip() for item in v.split(sep) if item.strip().strip("-").strip()] - - -def parse_charter_proposal(text: str) -> Optional[CharterProposal]: - """Extract a single ``PROPOSE_CHARTER`` block from ``text`` (or None).""" - if not text or "PROPOSE_CHARTER" not in text.upper(): - return None - lines = text.splitlines() - i = 0 - while i < len(lines): - if not _CHARTER_HEAD_RE.match(lines[i]): - i += 1 - continue - prop = CharterProposal() - i += 1 - while i < len(lines): - line = lines[i] - if _CHARTER_END_RE.match(line): - i += 1 - break - fm = _CHARTER_FIELD_RE.match(line) - if fm: - key = fm.group(1).upper() - val = fm.group(2).strip() - if key == "MISSION": - prop.mission = val - elif key == "SCOPE": - prop.scope = _split_items(val) - elif key in ("NON_GOALS", "NONGOALS"): - prop.non_goals = _split_items(val) - elif key in ("SUCCESS", "SUCCESS_CRITERIA"): - prop.success = _split_items(val) - elif key == "CONSTRAINTS": - prop.constraints = _split_items(val) - elif key == "ASSUMPTIONS": - prop.assumptions = _split_items(val) - i += 1 - if prop.mission or prop.success or prop.scope: - return prop - return None - - -def _parse_simple_phase_list(body: str) -> List[Dict[str, Any]]: - """Dependency-free parser for the PHASES YAML list. - - Handles the documented shape: a list of ``- key: value`` blocks where - values are scalars or ``[a, b]`` inline lists. Used as a fallback when - PyYAML is unavailable. - """ - phases: List[Dict[str, Any]] = [] - current: Optional[Dict[str, Any]] = None - for raw in body.splitlines(): - line = raw.rstrip() - if not line.strip(): - continue - stripped = line.lstrip() - is_item = stripped.startswith("- ") - if is_item: - stripped = stripped[2:].lstrip() - current = {} - phases.append(current) - if current is None: - current = {} - phases.append(current) - if ":" not in stripped: - continue - key, _, value = stripped.partition(":") - key = key.strip() - value = value.strip() - if value.startswith("[") and value.endswith("]"): - inner = value[1:-1] - current[key] = [x.strip().strip('"\'') for x in inner.split(",") if x.strip()] - elif value: - current[key] = value.strip('"\'') - else: - current[key] = "" - return [p for p in phases if p.get("id")] - - -def parse_plan_proposal(text: str) -> Optional[PlanProposal]: - """Extract a single ``PROPOSE_PLAN`` block (phases list) from ``text``.""" - if not text or "PROPOSE_PLAN" not in text.upper(): - return None - lines = text.splitlines() - i = 0 - while i < len(lines): - if not _PLAN_HEAD_RE.match(lines[i]): - i += 1 - continue - i += 1 - # skip to PHASES: - body_lines: List[str] = [] - seen_phases = False - while i < len(lines): - line = lines[i] - if _PLAN_END_RE.match(line): - i += 1 - break - if not seen_phases: - if _PLAN_PHASES_RE.match(line): - seen_phases = True - i += 1 - continue - body_lines.append(line) - i += 1 - body = "\n".join(body_lines).strip("\n") - if not body: - continue - phases: List[Dict[str, Any]] = [] - try: - import yaml as _yaml # type: ignore - parsed = _yaml.safe_load(body) - if isinstance(parsed, list): - phases = [p for p in parsed if isinstance(p, dict) and p.get("id")] - except Exception: - phases = [] - if not phases: - phases = _parse_simple_phase_list(body) - if phases: - return PlanProposal(phases=phases) - return None - - -def parse_role_proposals(text: str) -> List[RoleProposal]: - """Extract every PROPOSE_ROLE block from ``text``. - - Returns an empty list (never raises) when nothing matches or input - is malformed; that way callers can apply this to *any* agent - response without guarding it. - """ - if not text: - return [] - out: List[RoleProposal] = [] - lines = text.splitlines() - i = 0 - while i < len(lines): - m = _ROLE_HEAD_RE.match(lines[i]) - if not m: - i += 1 - continue - prop = RoleProposal(name=m.group(1).strip()) - i += 1 - prompt_lines: List[str] = [] - in_prompt = False - while i < len(lines): - line = lines[i] - if _ROLE_END_RE.match(line): - i += 1 - break - if not in_prompt: - fm = _ROLE_FIELD_RE.match(line) - if fm: - key = fm.group(1).upper() - val = fm.group(2).strip() - if key == "ROLE": - prop.role = val - elif key == "PROVIDER": - prop.provider = val or None - elif key == "CAPABILITIES": - prop.capabilities = [ - c.strip() for c in re.split(r"[,\s]+", val) if c.strip() - ] - i += 1 - continue - if _ROLE_PROMPT_RE.match(line): - in_prompt = True - i += 1 - continue - # tolerate blank or stray lines inside the header section - i += 1 - continue - # in_prompt - prompt_lines.append(line) - i += 1 - prop.prompt = "\n".join(prompt_lines).strip("\n") - if prop.name: - out.append(prop) - return out - - -def parse_workflow_proposals(text: str) -> List[WorkflowProposal]: - if not text: - return [] - out: List[WorkflowProposal] = [] - lines = text.splitlines() - i = 0 - while i < len(lines): - m = _WF_HEAD_RE.match(lines[i]) - if not m: - i += 1 - continue - prop = WorkflowProposal(name=m.group(1).strip()) - i += 1 - yaml_lines: List[str] = [] - in_yaml = False - while i < len(lines): - line = lines[i] - if _WF_END_RE.match(line): - i += 1 - break - if not in_yaml: - fm = _WF_FIELD_RE.match(line) - if fm: - if fm.group(1).upper() == "DESCRIPTION": - prop.description = fm.group(2).strip() - i += 1 - continue - if _WF_YAML_RE.match(line): - in_yaml = True - i += 1 - continue - i += 1 - continue - yaml_lines.append(line) - i += 1 - prop.yaml_body = "\n".join(yaml_lines).strip("\n") - if prop.name and prop.yaml_body: - out.append(prop) - return out - - -def parse_consensus_proposals(text: str) -> List[ConsensusProposal]: - if not text: - return [] - out: List[ConsensusProposal] = [] - lines = text.splitlines() - i = 0 - while i < len(lines): - m = _CONS_HEAD_RE.match(lines[i]) - if not m: - i += 1 - continue - prop = ConsensusProposal(name=m.group(1).strip()) - i += 1 - objective_lines: List[str] = [] - in_objective = False - while i < len(lines): - line = lines[i] - if _CONS_END_RE.match(line): - i += 1 - break - if not in_objective: - fm = _CONS_FIELD_RE.match(line) - if fm: - key = fm.group(1).upper() - val = fm.group(2).strip() - if key in ("AGENT", "AGENTS"): - prop.agents = [ - _normalize_name(a) - for a in re.split(r"[, ]+", val) - if _normalize_name(a) - ] - elif key == "COPIES": - try: - prop.copies = max(1, int(val)) - except ValueError: - prop.copies = 3 - i += 1 - continue - if _CONS_OBJECTIVE_RE.match(line): - in_objective = True - i += 1 - continue - i += 1 - continue - objective_lines.append(line) - i += 1 - prop.objective = "\n".join(objective_lines).strip() - if prop.name and prop.objective: - out.append(prop) - return out - - # --------------------------------------------------------------------------- # Registry mutators # --------------------------------------------------------------------------- @@ -705,7 +305,8 @@ def _similar_existing_prompt( continue try: ex_body = ex_path.read_text(encoding="utf-8") - except Exception: + except Exception as _exc: + logger.debug("casting: could not read exemplar %s: %s", ex_path, _exc) continue # Compare domain content only: the harness appends a shared # protocol suffix to every dynamic prompt, which would inflate @@ -736,10 +337,10 @@ def _harness_protocol_suffix() -> str: agents emit parseable ACTION/POST blocks instead of prose. """ try: - from ..templates.prompts import ( + from ...templates.prompts import ( _ACTION_PROTOCOL_BLOCK, - _BLACKBOARD_PROTOCOL_BLOCK, _BASE_FOOTER, + _BLACKBOARD_PROTOCOL_BLOCK, ) return "\n\n" + _ACTION_PROTOCOL_BLOCK + "\n" + _BLACKBOARD_PROTOCOL_BLOCK + "\n" + _BASE_FOOTER except Exception as exc: @@ -864,7 +465,7 @@ def summary(self) -> str: def register_role( paths: Paths, - proposal: RoleProposal, + proposal: "RoleProposal", *, agents_cfg: Optional[Dict[str, Any]] = None, max_dynamic: int = DEFAULT_MAX_DYNAMIC_ROLES, @@ -899,8 +500,8 @@ def register_role( if on_change is not None: try: on_change(name, "prompt_updated") - except Exception: - pass + except Exception as _exc: + logger.debug("casting: on_change callback failed: %s", _exc) return True, "baseline_prompt_refreshed" return False, "baseline_protected" @@ -960,8 +561,8 @@ def register_role( if on_change is not None: try: on_change(name, status) - except Exception: - pass + except Exception as _exc: + logger.debug("casting: on_change callback failed: %s", _exc) return True, status @@ -986,14 +587,14 @@ def remove_role( if on_change is not None: try: on_change(name, "removed") - except Exception: - pass + except Exception as _exc: + logger.debug("casting: on_change callback failed: %s", _exc) return True, "removed" def write_workflow( paths: Paths, - proposal: WorkflowProposal, + proposal: "WorkflowProposal", *, source_agent: str = "", on_change: Optional[Callable[[str, str], None]] = None, @@ -1054,178 +655,11 @@ def write_workflow( if on_change is not None: try: on_change(name, "workflow_written") - except Exception: - pass + except Exception as _exc: + logger.debug("casting: on_change callback failed: %s", _exc) return True, "written" -def apply_response_proposals( - paths: Paths, - response_text: str, - *, - agents_cfg: Optional[Dict[str, Any]] = None, - max_dynamic: int = DEFAULT_MAX_DYNAMIC_ROLES, - source_agent: str = "", - on_change: Optional[Callable[[str, str], None]] = None, -) -> CastingResult: - """Scan an agent's response, apply every proposal it contains. - - Safe to call after every agent run. Returns a result describing what - actually changed; callers can use ``result.is_empty()`` to skip - further work cheaply. - """ - result = CastingResult() - if not response_text: - return result - try: - for prop in parse_role_proposals(response_text): - ok, status = register_role( - paths, - prop, - agents_cfg=agents_cfg, - max_dynamic=max_dynamic, - source_agent=source_agent, - on_change=on_change, - ) - if not ok: - result.roles_skipped.append(f"{prop.name}:{status}") - log_event( - paths, - "role_skipped", - agent=source_agent, - name=prop.name, - role=prop.role, - provider=prop.provider, - prompt_length=len(prop.prompt or ""), - prompt=prop.prompt or "", - reason=status, - ) - elif status == "added": - result.roles_added.append(prop.name) - else: - result.roles_updated.append(prop.name) - for wf in parse_workflow_proposals(response_text): - ok, status = write_workflow(paths, wf, source_agent=source_agent, on_change=on_change) - if ok: - result.workflows_written.append(wf.name) - else: - result.workflows_skipped.append(f"{wf.name}:{status}") - log_event( - paths, - "workflow_skipped", - agent=source_agent, - name=wf.name, - description=wf.description, - yaml_length=len(wf.yaml_body or ""), - yaml=wf.yaml_body or "", - reason=status, - ) - except Exception as exc: - log_exception("orchestration.casting.apply_response_proposals", exc) - result.errors.append(str(exc)) - return result - - -# --------------------------------------------------------------------------- -# Casting prompt helpers -# --------------------------------------------------------------------------- - - -CASTING_PROTOCOL = """\ -ROLE-CASTING PROTOCOL (machine-parsed, follow exactly) - -You may emit any number of these blocks in your response. The harness -parses them and updates `.clk/config/agents.json`, `.clk/prompts/`, and -`.clk/config/workflows/` immediately. - -Add or refresh a role: - - PROPOSE_ROLE: - ROLE: - PROVIDER: - CAPABILITIES: - PROMPT: - - END_ROLE - -Capability hints (CAPABILITIES field) - Omit the field entirely to use provider defaults (tools on, standard thinking). - Combine as needed: "no-tools, thinking-off" - - no-tools Disable all tools. Best for research / doc-writing agents - that only need to produce text — avoids extra model round - trips and cuts latency significantly. - no-builtin-tools Disable built-in tools but keep custom extensions. - thinking-off Skip chain-of-thought. Best for formatting, summarising, - or any task that does not require deep reasoning. - thinking-low Minimal thinking — slightly faster than the default. - thinking-medium Standard thinking level (provider default). - thinking-high Deep reasoning — use for architecture, debugging, or tasks - where correctness matters more than speed. - thinking-xhigh Maximum thinking — slowest, most thorough. - -Author or replace a workflow (the harness will save it as -`.clk/config/workflows/.yaml`): - - PROPOSE_WORKFLOW: - DESCRIPTION: - YAML: - name: - description: - stages: - - id: - agent: - objective: - depends_on: [other_id, ...] # optional - validation: "" # optional, exit 0 = pass - commit: true # optional, default true - END_WORKFLOW - -Rules -- Roster cap is enforced (default 12 dynamic roles + the 3 baseline roles). - Drop or merge before adding past the cap. -- Baseline roles (chief, ralph, qa) cannot be removed but you may refresh - their prompt bodies. -- engineer is the canonical implementation agent. It is NOT a default - baseline — you must create it with PROPOSE_ROLE: engineer when a project - needs an implementer. The name is reserved: NEVER create `engineering`, - `engineers`, `coder`, `developer`, `programmer`, `implementer`, or any - other variant. The harness will explicitly deny such proposals and report - back to you via $casting_feedback. If engineer already exists, use it. -- ralph is the iterative refinement and autoresearch driver. Always - include at least one ralph stage in engineering workflows so the output - gets iteratively improved before delivery. Do NOT create a separate - autoresearch agent — ralph handles both modes. -- qa is the validation agent. Always include at least one qa stage in - every engineering workflow, typically as the final stage before done. -- All other roles (analyst, researcher, architect, etc.) are dynamic — - create them per project as needed. -- If a stage references an agent you have not defined yet, define it in - the same response with a PROPOSE_ROLE block. -- Workflows may use any combination of baseline and dynamic agents. -- Prefer assigning work to an existing agent when its role already fits. - Create or refresh a role when the need is distinct enough that an - existing role would blur ownership or do materially worse work. -- Before emitting ANY PROPOSE_ROLE block, run this mandatory pre-flight: - 1. Read every agent's prompt_preview in the current roster. - 2. Ask: "Does any existing agent's prompt already describe this work?" - If YES → use that agent. Do NOT emit PROPOSE_ROLE. - 3. Ask: "Is this name a synonym, plural, gerund, or department label - of an existing name?" (e.g. `engineering` when `engineer` exists, - `researchers` when `researcher` exists.) If YES → use the existing - name. Do NOT emit PROPOSE_ROLE. - 4. Only emit PROPOSE_ROLE when both checks pass: no functional overlap - AND a genuinely distinctive name. -- Functional overlap is the primary test — name similarity is secondary. - An agent named differently is still a duplicate if its prompt describes - the same work as an existing agent's prompt. The new role's PROMPT must - state what it owns that no current agent's prompt already covers. -- New role prompts and role lines must state the distinct responsibility - the role owns compared with the nearest existing agent. -""" - - def render_roster_summary(paths: Paths) -> str: """Markdown bullet list of the current roster (for prompt context).""" agents = (load_agents_config(paths).get("agents") or {}) @@ -1249,20 +683,7 @@ def render_roster_summary(paths: Paths) -> str: log_exception(f"orchestration.casting.render_roster_summary.{name}", exc) lines.append( f"- {marker} {name} :: {role} " - f"(provider={prov}; capabilities={caps_str}; prompt={prompt_file}; prompt_preview={prompt_preview or '(missing)'})" + f"(provider={prov}; capabilities={caps_str}; prompt={prompt_file}; " + f"prompt_preview={prompt_preview or '(missing)'})" ) return "\n".join(lines) - - -def casting_objective(idea_title: str, idea_statement: str) -> str: - return ( - "You are the casting director for this project. Decide on the team of agents " - "that will best serve the idea below, author each agent's prompt, and emit a " - "workflow YAML that wires them into one engineering cycle.\n\n" - f"Idea: {idea_title}\n{idea_statement}\n\n" - "Use the role-casting protocol to add or refresh agents and to write the " - "`engineering` workflow. Keep the roster small and specific to this project: " - "reuse existing agents when they fit, drop generic roles that won't earn " - "their keep, and invent specialists only when this particular system needs " - "a distinct owner." - ) diff --git a/clk_harness/orchestration/charter.py b/clk_harness/orchestration/charter.py index c54b7f5..a0391f7 100644 --- a/clk_harness/orchestration/charter.py +++ b/clk_harness/orchestration/charter.py @@ -16,14 +16,13 @@ from datetime import datetime from typing import Any, Dict, List, Optional -from ..config import Paths, save_json, load_json +from ..config import Paths, load_json, save_json from ..git_ops import commit_trace from ..utils.activity_log import log_event -from ..utils.logging_utils import log, log_exception +from ..utils.logging_utils import log_exception from . import blackboard as _blackboard from . import casting as _casting - # Filename tokens that make a success criterion machine-checkable as "exists". _FILE_EXT_RE = re.compile( r"\b([\w./-]+\.(?:py|md|json|txt|ya?ml|js|ts|tsx|html|css|sh|toml|cfg|ini|rs|go|java|rb))\b" diff --git a/clk_harness/orchestration/done_gate.py b/clk_harness/orchestration/done_gate.py index 807fd8a..f3a9774 100644 --- a/clk_harness/orchestration/done_gate.py +++ b/clk_harness/orchestration/done_gate.py @@ -30,9 +30,10 @@ from typing import Any, Dict, List, Optional from ..config import Paths -from ..utils.logging_utils import log_exception +from ..log import get_logger, log_exception from . import blackboard as _blackboard +logger = get_logger(__name__) _EXCLUDE_TOP = {".clk", ".git", "node_modules", "__pycache__", ".pytest_cache", "venv", ".venv"} _STATEISH_ROOT_FILES = {"PROGRESS.md", "DECISIONS.md", "MISSION.md", "CHARTER.md"} @@ -137,7 +138,8 @@ def todo_markers(root: Path, *, max_files: int = 2000) -> List[str]: if fp.suffix.lower() in {".png", ".jpg", ".jpeg", ".gif", ".pdf", ".zip", ".bin"}: continue text = fp.read_text(encoding="utf-8", errors="ignore") - except Exception: + except Exception as _exc: + logger.debug("done-gate: skipping unreadable %s: %s", rel, _exc) continue if _TODO_RE.search(text): hits.append(rel) diff --git a/clk_harness/orchestration/evaluator.py b/clk_harness/orchestration/evaluator.py index 2a216bf..0563483 100644 --- a/clk_harness/orchestration/evaluator.py +++ b/clk_harness/orchestration/evaluator.py @@ -9,13 +9,11 @@ import glob import json import subprocess -import sys -import traceback from dataclasses import dataclass, field from pathlib import Path from typing import List, Optional, Tuple -from ..utils.logging_utils import log, log_exception +from ..utils.logging_utils import log_exception @dataclass diff --git a/clk_harness/orchestration/mission.py b/clk_harness/orchestration/mission.py index 5e97f08..e04c2a0 100644 --- a/clk_harness/orchestration/mission.py +++ b/clk_harness/orchestration/mission.py @@ -26,22 +26,23 @@ from pathlib import Path from typing import Any, Dict, List, Optional -from ..config import Paths, save_json, load_json +from ..config import Paths, load_json, save_json from ..git_ops import commit_trace +from ..log import get_logger, log_exception from ..utils.activity_log import log_event -from ..utils.logging_utils import log, log_exception from . import blackboard as _blackboard -from . import charter as _charter from . import casting as _casting +from . import charter as _charter from . import deliberation as _deliberation from . import done_gate as _done_gate from .agent import AgentRunner +from .autoresearch_loop import AutoresearchLoop from .evaluator import Evaluator +from .ralph_loop import RalphLoop from .telemetry import CycleTelemetry from .workflow import WorkflowRunner, load_workflow -from .ralph_loop import RalphLoop -from .autoresearch_loop import AutoresearchLoop +logger = get_logger(__name__) _GATE_RE = re.compile(r"^\s*GATE\s*:\s*(pass|repeat|revise|done)\b", re.IGNORECASE | re.MULTILINE) _DEFAULT_PHASES = ["discovery", "product", "engineering", "validation", "deployment"] @@ -242,7 +243,7 @@ def run(self, objective: Optional[str] = None, *, resume: bool = False, dry_run: bool = False) -> MissionPlan: objective = objective or self._idea_objective() if not objective: - log("mission: no objective and no captured idea; nothing to do", level="WARN") + logger.warning("mission: no objective and no captured idea; nothing to do") return MissionPlan(status="aborted") charter = None @@ -253,7 +254,7 @@ def run(self, objective: Optional[str] = None, *, resume: bool = False, plan = self._bootstrap_plan(objective, charter, dry_run=dry_run) else: charter = _charter.load_charter(self.paths) - log(f"mission: resuming plan with {len(plan.phases)} phases " + logger.info(f"mission: resuming plan with {len(plan.phases)} phases " f"({sum(1 for p in plan.phases if p.status == 'done')} done)") plan.status = "running" @@ -276,7 +277,7 @@ def run(self, objective: Optional[str] = None, *, resume: bool = False, budget = phase.max_iterations or self.max_iterations_per_phase for _it in range(budget): if total >= self.max_total_cycles: - log(f"mission: total-cycle budget exhausted ({self.max_total_cycles})", level="WARN") + logger.warning(f"mission: total-cycle budget exhausted ({self.max_total_cycles})") plan.status = "stalled" break total += 1 @@ -341,7 +342,7 @@ def run(self, objective: Optional[str] = None, *, resume: bool = False, else: plan.status = "stalled" save_plan(self.paths, plan) - log(f"mission: finished with status={plan.status} after {plan.total_cycles_used} cycles") + logger.info(f"mission: finished with status={plan.status} after {plan.total_cycles_used} cycles") return plan # -- internals --------------------------------------------------------- @@ -416,10 +417,10 @@ def _run_phase(self, phase: PhaseSpec, tel: CycleTelemetry, dry_run: bool) -> Li log_exception("orchestration.mission._run_phase.ralph", exc) return [] if engine == "autoresearch": - loop = AutoresearchLoop(self.paths, self.runner, self.evaluator, - max_iterations=phase.max_iterations or 1) + aloop = AutoresearchLoop(self.paths, self.runner, self.evaluator, + max_iterations=phase.max_iterations or 1) try: - return list(loop.run(dry_run=dry_run)) + return list(aloop.run(dry_run=dry_run)) except Exception as exc: log_exception("orchestration.mission._run_phase.autoresearch", exc) return [] @@ -463,8 +464,8 @@ def _phase_gate(self, plan: MissionPlan, phase: PhaseSpec, results: List[Any], if _deliberation.require_open_questions_resolved(self.runner.clk_cfg): open_q = _deliberation.unresolved_blocking_questions(self.paths) if open_q: - log(f"mission: phase {phase.id} has {len(open_q)} unresolved blocking " - "question(s); repeating", level="INFO") + logger.info(f"mission: phase {phase.id} has {len(open_q)} unresolved blocking " + "question(s); repeating") return "repeat" try: run = self.runner.run( @@ -497,8 +498,8 @@ def _maybe_finish(self, plan: MissionPlan, charter, eval_result) -> "_done_gate. commit_trace(self.paths.root, kind="done", summary="mission done-gate granted", meta={"checked": list(verdict.checked.keys())}) else: - log(f"mission: done requested but gate REJECTED — unmet: " - f"{', '.join(verdict.failures) or '?'}", level="WARN") + logger.warning(f"mission: done requested but gate REJECTED — unmet: " + f"{', '.join(verdict.failures) or '?'}") log_event(self.paths, "done_gate_rejected", failures=list(verdict.failures), checked=verdict.checked, scope="mission") return verdict diff --git a/clk_harness/orchestration/noop_guard.py b/clk_harness/orchestration/noop_guard.py index 61703f6..b62710f 100644 --- a/clk_harness/orchestration/noop_guard.py +++ b/clk_harness/orchestration/noop_guard.py @@ -13,7 +13,6 @@ from typing import Any, Dict, Optional, Sequence - # Roles that are legitimately prose / verdict only. _NON_PRODUCING = {"chief", "qa", "critic"} _DEFAULT_PRODUCING = ("engineer", "ralph") diff --git a/clk_harness/orchestration/ralph_loop.py b/clk_harness/orchestration/ralph_loop.py index a1d06bd..79916cb 100644 --- a/clk_harness/orchestration/ralph_loop.py +++ b/clk_harness/orchestration/ralph_loop.py @@ -20,26 +20,27 @@ from __future__ import annotations import json -import sys -import traceback -from dataclasses import dataclass, field +from dataclasses import dataclass from datetime import datetime -from pathlib import Path from typing import Any, Dict, List, Optional from ..config import Paths from ..git_ops import ( add_all, - commit as git_commit, has_changes, head_sha, revert_to, ) +from ..git_ops import ( + commit as git_commit, +) +from ..log import get_logger, log, log_exception from ..utils.activity_log import log_event -from ..utils.logging_utils import log, log_exception from . import response_quality as _response_quality from .agent import AgentRunner -from .evaluator import Evaluator, EvalResult +from .evaluator import Evaluator + +logger = get_logger(__name__) @dataclass @@ -76,8 +77,8 @@ def _observer_log(self, line: str) -> None: if obs is not None: try: obs.log(line) - except Exception: - pass + except Exception as _exc: + logger.debug("observer log failed: %s", _exc) def run(self, *, dry_run: bool = False) -> List[IterationOutcome]: outcomes: List[IterationOutcome] = [] diff --git a/clk_harness/orchestration/response_quality.py b/clk_harness/orchestration/response_quality.py index 68aa5b6..78c2b91 100644 --- a/clk_harness/orchestration/response_quality.py +++ b/clk_harness/orchestration/response_quality.py @@ -27,11 +27,9 @@ import re from dataclasses import dataclass, field -from typing import Iterable, List, Optional, Sequence +from typing import List, Optional, Sequence from . import blackboard as _blackboard -from . import actions as _actions - # --------------------------------------------------------------------------- # Public dataclass diff --git a/clk_harness/orchestration/scheduler.py b/clk_harness/orchestration/scheduler.py index 7f0b382..993b308 100644 --- a/clk_harness/orchestration/scheduler.py +++ b/clk_harness/orchestration/scheduler.py @@ -7,9 +7,7 @@ from __future__ import annotations -import sys import time -import traceback from dataclasses import dataclass from typing import Callable, Optional diff --git a/clk_harness/orchestration/telemetry.py b/clk_harness/orchestration/telemetry.py index 65e5fd1..9fbd8e4 100644 --- a/clk_harness/orchestration/telemetry.py +++ b/clk_harness/orchestration/telemetry.py @@ -19,8 +19,11 @@ from typing import Any, Dict, List, Optional from ..config import Paths +from ..log import get_logger from ..utils.activity_log import log_event +logger = get_logger(__name__) + @dataclass class CycleTelemetry: @@ -200,8 +203,8 @@ def emit(self, paths: Optional[Paths], *, to_stdout: bool = True) -> str: if paths is not None: try: log_event(paths, "loop_cycle_summary", **self.as_dict()) - except Exception: - pass + except Exception as _exc: + logger.debug("activity log_event failed: %s", _exc) if to_stdout: print(line, flush=True) return line diff --git a/clk_harness/orchestration/workflow.py b/clk_harness/orchestration/workflow.py deleted file mode 100644 index 60c0fec..0000000 --- a/clk_harness/orchestration/workflow.py +++ /dev/null @@ -1,2092 +0,0 @@ -"""Workflow parser and runner (Archon-style YAML). - -A workflow file looks like: - - name: engineering - description: Single development cycle. - stages: - - id: decompose - agent: chief - objective: Decompose the current top-level objective. - - id: research - agent: researcher - objective: Investigate open assumptions. - depends_on: [decompose] - validation: "echo OK" - commit: true -""" - -from __future__ import annotations - -import re -import shlex -import subprocess -import sys -import time -import traceback -from concurrent.futures import ThreadPoolExecutor, as_completed -from dataclasses import dataclass, field -from pathlib import Path -from typing import Any, Dict, List, Optional, Set, Tuple - -from ..config import Paths -from ..git_ops import ( - add_all, - commit as git_commit, - commit_trace, - has_changes, - head_sha, - revert_to, - snapshot_rollback, -) -from ..utils.activity_log import log_event -from ..utils.logging_utils import log, log_exception -from . import blackboard as _blackboard -from . import response_quality as _response_quality -from . import noop_guard as _noop_guard -from . import deliberation as _deliberation -from . import done_gate as _done_gate -from . import evaluator as _evaluator -from . import charter as _charter -from .agent import AgentRunner, AgentRun -from .telemetry import CycleTelemetry - - -_ROUND_STATUS_RE = re.compile(r"^\s*ROUND_STATUS\s*:\s*(continue|done|finished)\s*$", re.IGNORECASE | re.MULTILINE) - - -def _round_status(text: str) -> str: - """Return 'continue' or 'done'. Default 'done' when no marker found.""" - if not text: - return "done" - matches = _ROUND_STATUS_RE.findall(text) - if not matches: - return "done" - last = matches[-1].lower() - return "continue" if last == "continue" else "done" - - -try: - import yaml # type: ignore -except Exception: - # PyYAML is optional. The mini-YAML loader below covers the workflow - # subset CLK uses, so we silently fall back rather than spraying a - # warning across stderr (which would also corrupt the TUI). - yaml = None - - -def is_provider_failure(error: str) -> bool: - """Return True for failures a downstream agent cannot fix.""" - msg = (error or "").lower() - patterns = [ - "no endpoints available", - "guardrail restrictions", - "data policy", - "api key", - "cli not found", - "not found", - "authentication", - "unauthorized", - "forbidden", - "rate limit", - "quota", - "operation was aborted", - "timeout after", - "no output for", - ] - return any(p in msg for p in patterns) - - -def _mini_yaml_loads(text: str) -> Dict[str, Any]: - """Minimal YAML loader for the workflow subset used by CLK. - - Supports: - * top-level scalar keys (``key: value``) - * a single ``stages:`` key whose value is a list of dicts - * each list item begins with ``- key: value`` then ``key: value`` lines - * inline lists like ``[a, b]`` and booleans (``true``/``false``) - * quoted scalar values - - This is *not* a general YAML parser - it handles exactly what the - bundled workflows use. Keeping it local avoids a hard dependency on - PyYAML when ``ensurepip`` is unavailable. - """ - - def parse_scalar(raw: str) -> Any: - s = raw.strip() - if not s: - return "" - if (s.startswith('"') and s.endswith('"')) or (s.startswith("'") and s.endswith("'")): - return s[1:-1] - if s.startswith("[") and s.endswith("]"): - inner = s[1:-1].strip() - if not inner: - return [] - return [parse_scalar(p) for p in _split_csv(inner)] - low = s.lower() - if low == "true": - return True - if low == "false": - return False - if low in ("null", "~"): - return None - try: - if "." in s: - return float(s) - return int(s) - except ValueError: - return s - - def _split_csv(s: str) -> List[str]: - out: List[str] = [] - buf = "" - depth = 0 - in_quote: Optional[str] = None - for ch in s: - if in_quote: - buf += ch - if ch == in_quote: - in_quote = None - continue - if ch in ("'", '"'): - in_quote = ch - buf += ch - continue - if ch == "[": - depth += 1 - buf += ch - continue - if ch == "]": - depth -= 1 - buf += ch - continue - if ch == "," and depth == 0: - out.append(buf.strip()) - buf = "" - continue - buf += ch - if buf.strip(): - out.append(buf.strip()) - return out - - def continuation(start: int, base_indent: int) -> Tuple[str, int]: - parts: List[str] = [] - j = start - while j < len(lines): - line = lines[j] - stripped = line.lstrip() - indent = len(line) - len(stripped) - if indent <= base_indent: - break - if stripped.startswith("- "): - break - if ":" in stripped: - break - parts.append(stripped) - j += 1 - return " ".join(parts).strip(), j - - lines = [l.rstrip() for l in text.splitlines() if l.strip() and not l.lstrip().startswith("#")] - result: Dict[str, Any] = {} - i = 0 - while i < len(lines): - line = lines[i] - if line.startswith(" "): # unexpected at top level, skip - i += 1 - continue - if ":" not in line: - i += 1 - continue - key, _, val = line.partition(":") - key = key.strip() - val = val.strip() - if val: - result[key] = parse_scalar(val) - i += 1 - extra, ni = continuation(i, 0) - if extra and isinstance(result[key], str): - result[key] = f"{result[key]} {extra}".strip() - i = ni - continue - # value on subsequent indented lines - i += 1 - if i < len(lines) and lines[i].lstrip().startswith("- "): - items: List[Any] = [] - cur: Optional[Dict[str, Any]] = None - while i < len(lines) and lines[i].startswith(" "): - sub = lines[i] - stripped = sub.lstrip() - if stripped.startswith("- "): - if cur is not None: - items.append(cur) - cur = {} - rest = stripped[2:] - if ":" in rest: - k2, _, v2 = rest.partition(":") - k2 = k2.strip() - cur[k2] = parse_scalar(v2) - i += 1 - extra, ni = continuation(i, len(sub) - len(stripped)) - if extra and isinstance(cur[k2], str): - cur[k2] = f"{cur[k2]} {extra}".strip() - i = ni - continue - else: - if cur is None: - cur = {} - if ":" in stripped: - k2, _, v2 = stripped.partition(":") - k2 = k2.strip() - cur[k2] = parse_scalar(v2) - i += 1 - extra, ni = continuation(i, len(sub) - len(stripped)) - if extra and isinstance(cur[k2], str): - cur[k2] = f"{cur[k2]} {extra}".strip() - i = ni - continue - i += 1 - if cur is not None: - items.append(cur) - result[key] = items - else: - # nested mapping not used by our workflow format; collect raw - buf: List[str] = [] - while i < len(lines) and lines[i].startswith(" "): - buf.append(lines[i]) - i += 1 - result[key] = "\n".join(buf) - return result - - -@dataclass -class WorkflowStage: - id: str - agent: str - objective: str - depends_on: List[str] = field(default_factory=list) - validation: Optional[str] = None - commit: bool = True - # Blackboard contract: ``inputs`` selectors filter the blackboard - # digest spliced into the worker's prompt; ``outputs`` are contract - # keys the worker promises to satisfy via POST blocks. Missing - # outputs surface as a warning (not a hard failure) post-run so - # downstream stages can still attempt their work. - inputs: List[str] = field(default_factory=list) - outputs: List[str] = field(default_factory=list) - # Phase tag. ``review`` makes this a chief-review stage that auto- - # digests upstream post-output and asks the chief to decide - # CONTINUE / REDIRECT / ABORT. Unknown phases are passed through to - # the worker prompt as-is for downstream behaviors to interpret. - phase: str = "" - # When > 1, the stage runs in turn-based rounds: after each round - # the runner refreshes the worker's prompt with any new blackboard - # posts (including those from sibling parallel workers) and re- - # dispatches. The worker stops the loop early by emitting - # ``ROUND_STATUS: done`` (default), or requests another round with - # ``ROUND_STATUS: continue``. - rounds: int = 1 - # Tag for sensitive stages. When true, the runner runs an extra - # chief checkpoint after the stage completes (CONTINUE / REDIRECT / - # ABORT) AND uses meta-prompt drafting on dispatch when configured. - careful: bool = False - # Critic-judge inner refinement loop. When present, after the - # worker's first response the harness dispatches the named critic - # agent to score the response 0..1; if below - # ``accept_threshold`` the worker is re-dispatched with the critic's - # feedback, up to ``max_rounds`` total worker dispatches. ``None`` - # means "use the default policy from clk.config.json::robustness". - refine: Optional[Dict[str, Any]] = None - metadata: Dict[str, Any] = field(default_factory=dict) - - -@dataclass -class Workflow: - name: str - description: str - stages: List[WorkflowStage] - - -def load_workflow(path: Path) -> Workflow: - text = path.read_text(encoding="utf-8") - if yaml is not None: - try: - data = yaml.safe_load(text) or {} - except Exception as exc: - log_exception("orchestration.workflow.load_workflow.pyyaml", exc) - try: - data = _mini_yaml_loads(text) - except Exception: - data = {} - else: - try: - data = _mini_yaml_loads(text) - except Exception as exc: - log_exception("orchestration.workflow.load_workflow.fallback", exc) - raise - - # If parsing produced nothing usable (e.g. the chief wrote a - # malformed workflow that wedges every subsequent supervise cycle), - # restore the bundled template for this workflow name so the runner - # has stages to execute on the next pass. - if not isinstance(data, dict) or not data.get("stages"): - try: - from ..templates.workflows import WORKFLOWS as _BUNDLED_WORKFLOWS - except Exception: - _BUNDLED_WORKFLOWS = {} - fallback = _BUNDLED_WORKFLOWS.get(path.name) - if fallback: - try: - path.write_text(fallback, encoding="utf-8") - except Exception as exc: - log_exception("orchestration.workflow.load_workflow.restore", exc) - if yaml is not None: - try: - data = yaml.safe_load(fallback) or {} - except Exception: - data = _mini_yaml_loads(fallback) - else: - data = _mini_yaml_loads(fallback) - - stages: List[WorkflowStage] = [] - for raw in data.get("stages") or []: - try: - rounds = int(raw.get("rounds") or 1) - except (TypeError, ValueError): - rounds = 1 - refine_raw = raw.get("refine") - if isinstance(refine_raw, dict): - refine_cfg: Optional[Dict[str, Any]] = dict(refine_raw) - elif refine_raw in (True, "true", "yes", 1): - refine_cfg = {} - else: - refine_cfg = None - stages.append( - WorkflowStage( - id=str(raw.get("id") or raw.get("agent") or "stage"), - agent=str(raw.get("agent") or "engineer"), - objective=str(raw.get("objective") or ""), - depends_on=list(raw.get("depends_on") or []), - validation=raw.get("validation"), - commit=bool(raw.get("commit", True)), - inputs=list(raw.get("inputs") or []), - outputs=list(raw.get("outputs") or []), - phase=str(raw.get("phase") or "").strip().lower(), - rounds=max(1, rounds), - careful=bool(raw.get("careful") or False), - refine=refine_cfg, - metadata=dict(raw.get("metadata") or {}), - ) - ) - return Workflow( - name=str(data.get("name") or path.stem), - description=str(data.get("description") or ""), - stages=stages, - ) - - -@dataclass -class StageResult: - stage: WorkflowStage - run: AgentRun - validated: bool - validation_output: str = "" - committed: bool = False - failure_reason: str = "" # filled when ok=False or validated=False - - -class WorkflowRunner: - def __init__(self, paths: Paths, runner: AgentRunner) -> None: - self.paths = paths - self.runner = runner - # When set by the MissionRunner, the per-cycle telemetry object is - # threaded into each stage's dispatch extra so the dispatch-path hooks - # accumulate into the active cycle. When None, ``run`` creates one per - # supervise cycle so standalone ``clk run`` is observable too. - self.telemetry: Optional[CycleTelemetry] = None - # When True, producing dispatches get the deliberation preamble and - # the done-gate / phase semantics lean toward unattended autonomy. - self.mission_mode: bool = False - # When the MissionRunner drives phases, it owns the outer loop, so it - # sets this to 1 to make each WorkflowRunner.run() a single pass. - self.supervise_cycles_override: Optional[int] = None - - # Default cap on chief recovery dispatches per stage. A stage that - # still has unmet deps after this many recovery passes gets a final - # WARN and is skipped, so we never loop forever on a stuck workflow. - # Overridable via clk.config.json::recovery::max_per_stage. - DEFAULT_MAX_RECOVERY_PER_STAGE = 3 - - @property - def max_recovery_per_stage(self) -> int: - cfg = (self.runner.clk_cfg.get("recovery") or {}) - return int(cfg.get("max_per_stage") or self.DEFAULT_MAX_RECOVERY_PER_STAGE) - - # Per-stage retry cap for provider errors (separate from recovery_count - # which handles unmet deps). Uses exponential backoff starting at - # stage_backoff_s. Overridable via clk.config.json::provider_retry. - DEFAULT_MAX_STAGE_RETRIES = 2 - DEFAULT_STAGE_BACKOFF_S = 30.0 - - @property - def max_stage_retries(self) -> int: - cfg = (self.runner.clk_cfg.get("provider_retry") or {}) - return int(cfg.get("stage_max_retries", self.DEFAULT_MAX_STAGE_RETRIES) or self.DEFAULT_MAX_STAGE_RETRIES) - - @property - def stage_backoff_s(self) -> float: - cfg = (self.runner.clk_cfg.get("provider_retry") or {}) - return float(cfg.get("stage_backoff_s", self.DEFAULT_STAGE_BACKOFF_S) or self.DEFAULT_STAGE_BACKOFF_S) - - DEFAULT_MAX_SUPERVISE_CYCLES = 20 - - @property - def max_supervise_cycles(self) -> int: - if self.supervise_cycles_override is not None: - return int(self.supervise_cycles_override) - cfg = (self.runner.clk_cfg.get("supervise") or {}) - return int(cfg.get("max_cycles") or self.DEFAULT_MAX_SUPERVISE_CYCLES) - - @property - def max_consecutive_no_progress(self) -> int: - cfg = (self.runner.clk_cfg.get("supervise") or {}) - return int(cfg.get("max_consecutive_no_progress") or 8) - - @property - def stall_rescue_enabled(self) -> bool: - """When True, hitting the no-progress cap dispatches the chief once - in *rescue mode* (restructure the plan or declare done) before the - loop gives up. Overridable via clk.config.json::supervise::stall_rescue. - """ - cfg = (self.runner.clk_cfg.get("supervise") or {}) - val = cfg.get("stall_rescue", True) - return str(val).lower() not in ("false", "0", "off", "no") - - def _should_rollback(self, stage: WorkflowStage) -> bool: - """Whether a failed validation hard-resets the stage's work. - - Policy via clk.config.json::validation::rollback_on_failure: - ``never`` keeps the work; ``careful`` (default) rolls back only - stages marked careful=true; ``always`` is the legacy behavior. - """ - cfg = (self.runner.clk_cfg.get("validation") or {}) - policy = str(cfg.get("rollback_on_failure", "careful")).lower() - if policy == "always": - return True - if policy == "never": - return False - return bool(stage.careful) - - # -- done gate (FM2) --------------------------------------------------- - - def _done_gate_enabled(self) -> bool: - cfg = (self.runner.clk_cfg.get("done_gate") or {}) - return bool(cfg.get("enabled", True)) - - def _telemetry_stdout(self) -> bool: - cfg = (self.runner.clk_cfg.get("mission") or {}) - return bool(cfg.get("telemetry_stdout", True)) - - def _evaluate_done_gate(self) -> "_done_gate.DoneGateVerdict": - """Build a real eval result + charter criteria and run the done gate.""" - val_cfg = (self.runner.clk_cfg.get("validation") or {}) - evaluator = _evaluator.Evaluator( - root=self.paths.root, - default_checks=list(self.runner.clk_cfg.get("validation_checks") or []), - auto_derive=bool(val_cfg.get("auto_derive", True)), - derived_command=val_cfg.get("derived_command"), - ) - try: - eval_result = evaluator.run() - except Exception as exc: - log_exception("orchestration.workflow._evaluate_done_gate.eval", exc) - eval_result = None - try: - charter = _charter.load_charter(self.paths) - extra_criteria = _charter.derive_done_criteria(charter) - except Exception: - extra_criteria = [] - return _done_gate.evaluate_done_gate( - self.paths, self.runner.clk_cfg, eval_result, extra_criteria=extra_criteria, - ) - - def _stop_requested(self, workflow: Workflow) -> bool: - """Whether the loop may stop now. - - ``done_granted.md`` (written only by the gate) is the authoritative - stop signal. A bare ``done.md`` is an agent *request*: when the gate - is enabled it is honored only if every completion criterion passes, - otherwise it is downgraded so the loop keeps working. When the gate - is disabled, ``done.md`` stops the loop as it always did. - """ - state = self.paths.state - if (state / "done_granted.md").exists(): - return True - done_md = state / "done.md" - if not done_md.exists(): - return False - if not self._done_gate_enabled(): - return True - verdict = self._evaluate_done_gate() - if self.telemetry is not None: - try: - self.telemetry.record_done_gate(verdict) - except Exception: - pass - if verdict.passed: - self._grant_done(verdict) - return True - # Reject: downgrade the request so a later cycle can re-earn it. - try: - done_md.rename(state / "done_requested.md") - except Exception: - try: - done_md.unlink() - except Exception: - pass - log( - f"workflow {workflow.name}: ACTION:done REJECTED by done-gate — " - f"unmet: {', '.join(verdict.failures) or '?'}", - level="WARN", - ) - log_event( - self.paths, - "done_gate_rejected", - workflow=workflow.name, - failures=list(verdict.failures), - checked=verdict.checked, - ) - return False - - def _grant_done(self, verdict: "_done_gate.DoneGateVerdict") -> None: - try: - (self.paths.state / "done_granted.md").write_text( - "# Mission complete\n\n" + verdict.summary() + "\n", - encoding="utf-8", - ) - except Exception as exc: - log_exception("orchestration.workflow._grant_done", exc) - log_event(self.paths, "done_gate_granted", checked=verdict.checked) - try: - commit_trace( - self.paths.root, - kind="done", - summary="done-gate granted", - meta={"checked": list(verdict.checked.keys())}, - ) - except Exception: - pass - - def run(self, workflow: Workflow, *, dry_run: Optional[bool] = None) -> List[StageResult]: - """Execute the workflow, looping it under chief supervision. - - Three dynamic behaviors: - - 1. After each stage we re-check the workflow file's mtime: if a - PROPOSE_WORKFLOW block rewrote it, new stages are spliced in - for the remainder of this cycle. - - 2. If a stage's deps are unmet (because an earlier stage failed - or was skipped), we dispatch the chief in *recovery mode* to - either re-cast the workflow, run a remediation stage, or - explicitly accept the gap. After recovery we re-check deps - and retry the stage. Capped at ``MAX_RECOVERY_PER_STAGE``. - - 3. When the workflow finishes without ``.clk/state/done.md`` - existing, we loop and run the workflow again (the chief's - supervise stage may have rewritten it via PROPOSE_WORKFLOW). - This way no agent is ever truly "done" until the chief signals - ACTION:done. Capped at ``DEFAULT_MAX_SUPERVISE_CYCLES``. - """ - all_results: List[StageResult] = [] - stopped_for_provider_failure = False - stopped_done = False - no_progress = 0 - rescue_attempted = False - for cycle in range(1, self.max_supervise_cycles + 1): - if self._stop_requested(workflow): - log(f"workflow {workflow.name}: stop granted, ending supervise loop") - stopped_done = True - break - - cancel_file = self.paths.state / "cancel_requested.txt" - if cancel_file.exists(): - try: - cancel_file.unlink() - except Exception: - pass - log(f"workflow {workflow.name}: graceful cancel requested; stopping after cycle {cycle - 1}") - break - - if cycle > 1: - log(f"workflow {workflow.name}: supervise cycle {cycle}/{self.max_supervise_cycles}") - # Per-cycle telemetry: when the MissionRunner owns one it is set on - # self.telemetry already; otherwise create one for this cycle so - # standalone `clk run` is observable too (FM5). - owns_telemetry = self.telemetry is None - if owns_telemetry: - self.telemetry = CycleTelemetry( - n=cycle, max_cycles=self.max_supervise_cycles, workflow=workflow.name, - ) - try: - refreshed = load_workflow(self.paths.workflows / f"{workflow.name}.yaml") - except Exception: - refreshed = workflow - cycle_results = self._run_once(refreshed, dry_run=dry_run, cycle=cycle) - all_results.extend(cycle_results) - - # Check for no-progress. Two signals combine: - # * material progress — a commit or file write happened - # * self-report — agents end responses with PROGRESS: yes/no; - # when every reporting agent says "no", the cycle counts as - # stalled even if files were technically touched (busywork). - material = any( - r.committed or bool(r.run.files_written) - for r in cycle_results - if r.run.response.ok - ) - signals = [ - _response_quality.progress_signal(r.run.response.text) - for r in cycle_results - if r.run.response.ok - ] - explicit = [s for s in signals if s is not None] - self_reported_stall = bool(explicit) and not any(explicit) - progress = material and not self_reported_stall - # Emit the per-cycle telemetry line (FM5). When the MissionRunner - # owns the telemetry object it records eval/done-gate and emits - # itself, so only emit here for standalone supervise cycles. - if owns_telemetry and self.telemetry is not None: - self.telemetry.progress = progress - self.telemetry.emit(self.paths, to_stdout=self._telemetry_stdout()) - self.telemetry = None - if not progress: - no_progress += 1 - why = "agents reported PROGRESS: no" if (material and self_reported_stall) else "no commits or file writes" - log( - f"workflow {workflow.name}: cycle {cycle} made no progress — {why} " - f"({no_progress}/{self.max_consecutive_no_progress})", - level="WARN" if no_progress >= 2 else "INFO", - ) - if no_progress >= self.max_consecutive_no_progress: - if self.stall_rescue_enabled and not rescue_attempted and not dry_run: - rescue_attempted = True - no_progress = 0 - self._dispatch_stall_rescue(workflow, cycle, cycle_results) - if self._stop_requested(workflow): - log(f"workflow {workflow.name}: stop granted during stall rescue") - stopped_done = True - break - continue - log( - f"workflow {workflow.name}: stopping after {no_progress} consecutive " - "no-progress cycles (set supervise.max_consecutive_no_progress to change)", - level="ERROR", - ) - log_event(self.paths, "workflow_stalled", workflow=workflow.name, - no_progress_cycles=no_progress, - rescue_attempted=rescue_attempted) - break - else: - no_progress = 0 - - if any(self._is_provider_failure((r.run.response.error or "")) for r in cycle_results if not r.run.response.ok): - log( - f"workflow {workflow.name}: stopping supervise cycles after provider failure", - level="ERROR", - ) - stopped_for_provider_failure = True - break - if dry_run: - break - if ( - not stopped_for_provider_failure - and not stopped_done - and not (self.paths.state / "done_granted.md").exists() - and self.max_supervise_cycles > 1 - ): - log( - f"workflow {workflow.name}: supervise cycle limit reached " - f"({self.max_supervise_cycles}); type /run to continue or set " - "supervise.max_cycles in clk.config.json", - level="WARN", - ) - return all_results - - def _run_once(self, workflow: Workflow, *, dry_run: Optional[bool] = None, cycle: int = 1) -> List[StageResult]: - """Single pass through the workflow; stages with no inter-dependencies run in parallel.""" - log(f"workflow start: {workflow.name} ({len(workflow.stages)} stages)") - results: List[StageResult] = [] - completed: Dict[str, bool] = {} - result_by_id: Dict[str, StageResult] = {} - wf_path = self.paths.workflows / f"{workflow.name}.yaml" - wf_mtime = wf_path.stat().st_mtime if wf_path.exists() else 0.0 - - stages = list(workflow.stages) - recovery_count: Dict[str, int] = {} - stage_retry_count: Dict[str, int] = {} - dispatched: Set[str] = set() # stage ids sent to runner this pass - - max_cycles = self.max_supervise_cycles - cycle_context = f"Supervise cycle {cycle}/{max_cycles} — {max_cycles - cycle + 1} remaining." - - while True: - # Stages not yet dispatched and not yet in completed - pending = [s for s in stages if s.id not in dispatched and s.id not in completed] - if not pending: - break - - ready = [s for s in pending if not self._unmet_deps(s, completed)] - blocked = [s for s in pending if self._unmet_deps(s, completed)] - - if not ready: - # Every remaining stage has unmet deps — recovery dispatch for the first one - stage = blocked[0] - unmet = self._unmet_deps(stage, completed) - tries = recovery_count.get(stage.id, 0) - if tries >= self.max_recovery_per_stage or dry_run: - self._log_skip(stage, unmet, result_by_id) - completed[stage.id] = False - dispatched.add(stage.id) - continue - recovery_count[stage.id] = tries + 1 - self._dispatch_recovery( - workflow, stage, unmet, result_by_id, - dry_run=dry_run, cycle_context=cycle_context, - ) - stages, wf_mtime = self._refresh_from_dispatched( - workflow.name, wf_path, wf_mtime, stages, - dispatched | set(completed), - ) - continue - - # Mark ready stages as dispatched before running so re-entrant - # refreshes don't double-dispatch them. - for s in ready: - dispatched.add(s.id) - - if len(ready) > 1: - log( - f"workflow {workflow.name}: parallel batch " - f"[{', '.join(s.id for s in ready)}]" - ) - else: - log(f"stage {ready[0].id} -> agent {ready[0].agent}") - - # Run: parallel when multiple independent stages are ready - if len(ready) == 1 or dry_run: - batch = [self._run_stage(ready[0], workflow, cycle_context, dry_run, result_by_id)] - else: - with ThreadPoolExecutor(max_workers=len(ready)) as pool: - fmap = { - pool.submit(self._run_stage, s, workflow, cycle_context, dry_run, result_by_id): s - for s in ready - } - batch = [fut.result() for fut in as_completed(fmap)] - - abort = False - for sr in batch: - ok = sr.run.response.ok - if not ok and self._is_provider_failure(sr.run.response.error or ""): - error_msg = sr.run.response.error or "" - st = stage_retry_count.get(sr.stage.id, 0) + 1 - stage_retry_count[sr.stage.id] = st - if self._is_retryable_stage_error(error_msg) and st <= self.max_stage_retries: - wait = self.stage_backoff_s * (2 ** (st - 1)) - log( - f"workflow {workflow.name}: stage {sr.stage.id} retryable error " - f"(attempt {st}/{self.max_stage_retries}): {error_msg!r}; " - f"backing off {wait:.0f}s", - level="WARN", - ) - log_event( - self.paths, "workflow_stage_retry", - agent=sr.stage.agent, workflow=workflow.name, - stage_id=sr.stage.id, attempt=st, - max_retries=self.max_stage_retries, - backoff_s=wait, error=error_msg, - ) - if self.runner.observer is not None: - try: - self.runner.observer.progress( - sr.stage.agent, "retry", - f"stage {sr.stage.id} backing off {wait:.0f}s " - f"(attempt {st}/{self.max_stage_retries}): {error_msg}", - ) - except Exception: - pass - dispatched.discard(sr.stage.id) - time.sleep(wait) - continue # retry: don't add to results/completed - log( - f"workflow {workflow.name}: aborting after provider failure " - f"in stage {sr.stage.id} (retries exhausted): {error_msg}", - level="ERROR", - ) - log_event( - self.paths, "workflow_aborted", - agent=sr.stage.agent, workflow=workflow.name, - stage_id=sr.stage.id, reason="provider_failure", error=error_msg, - ) - results.append(sr) - result_by_id[sr.stage.id] = sr - completed[sr.stage.id] = False - abort = True - else: - results.append(sr) - result_by_id[sr.stage.id] = sr - completed[sr.stage.id] = ok and sr.validated - - if abort: - break - - stages, wf_mtime = self._refresh_from_dispatched( - workflow.name, wf_path, wf_mtime, stages, - dispatched | set(completed), - ) - - log(f"workflow done: {workflow.name}") - return results - - def _run_stage( - self, - stage: WorkflowStage, - workflow: Workflow, - cycle_context: str, - dry_run: Optional[bool], - result_by_id: Optional[Dict[str, "StageResult"]] = None, - ) -> StageResult: - """Run a single stage and return its result. - - Handles all the new stage semantics on top of the basic single- - dispatch path: inputs (blackboard filtering), outputs (contract - verification), phase=review (chief review prompt synthesis), - rounds>1 (turn-based re-dispatch with refreshed digest), and - careful=True (extra chief checkpoint after the run). - """ - result_by_id = result_by_id or {} - - pre_stage_sha: Optional[str] = head_sha(self.paths.root) if stage.commit and not dry_run else None - - # Build objective: chief-review stages get a synthesized prompt - # that includes the upstream stages' blackboard posts. - if stage.phase == "review" and stage.depends_on: - objective = self._build_review_objective(workflow, stage, result_by_id) - else: - objective = stage.objective - - # Optional meta-prompt drafting for sensitive (careful) stages or - # when meta_prompt.dispatch is "always". The chief is asked to - # tighten the worker's task prompt; result is cached on disk. - if ( - not dry_run - and stage.agent != "chief" - and stage.phase != "review" - and self._meta_dispatch_enabled(stage) - ): - try: - drafted = self.runner.meta_draft_dispatch_prompt( - agent_name=stage.agent, - base_objective=objective, - blackboard_inputs=list(stage.inputs), - stage_outputs=list(stage.outputs), - ) - if drafted: - objective = drafted - except Exception as exc: - log_exception("orchestration.workflow._run_stage.meta_dispatch", exc) - - # Inputs filter the blackboard digest. Review stages auto-include - # all posts from the stages they depend on. - bb_inputs = list(stage.inputs) - if stage.phase == "review" and not bb_inputs: - bb_inputs = [f"stage:{d}" for d in stage.depends_on] - - base_extra: Dict[str, Any] = { - "stage_id": stage.id, - "workflow": workflow.name, - "cycle_context": cycle_context, - "blackboard_inputs": bb_inputs, - "stage_outputs": list(stage.outputs), - # Carried for the no-op guard (commit=producing) and the telemetry - # hooks on the dispatch path. - "commit": bool(stage.commit), - "telemetry": self.telemetry, - } - if stage.phase: - base_extra["phase"] = stage.phase - - # Deliberation: in mission mode, prepend the self-reflect + ask-peers - # preamble to producing dispatches so the team "thinks" before acting. - if ( - not dry_run - and self.mission_mode - and stage.phase != "review" - and _deliberation.enabled(self.runner.clk_cfg) - and _noop_guard.is_mutation_expected( - stage.agent, outputs=stage.outputs, commit=stage.commit, - cfg=self.runner.clk_cfg, - ) - ): - preamble = _deliberation.dispatch_preamble(self.runner.clk_cfg) - if preamble: - objective = preamble + objective - - stop_when_file = self.paths.state / "stop_when.txt" - stop_when = stop_when_file.read_text(encoding="utf-8").strip() if stop_when_file.exists() else "" - if stop_when: - base_extra["stop_when"] = stop_when - - # Turn-based rounds: keep dispatching until the worker emits - # ROUND_STATUS: done (or absent), or the round cap is reached. - rounds_max = max(1, int(stage.rounds or 1)) - run: Optional[AgentRun] = None - for round_idx in range(1, rounds_max + 1): - extra = dict(base_extra) - extra["round"] = round_idx - extra["rounds_total"] = rounds_max - if round_idx == 1: - round_objective = objective - else: - round_objective = ( - f"Round {round_idx}/{rounds_max} of stage `{stage.id}`.\n\n" - "Sibling agents may have posted to the blackboard since your last " - "round; the digest above has the latest. Continue your work, " - "post new findings via POST blocks, and emit `ROUND_STATUS: done` " - "in your final round (or `ROUND_STATUS: continue` to request " - "another round before the cap).\n\n" - f"Original objective:\n{objective}" - ) - run = self.runner.run( - stage.agent, - round_objective, - extra=extra, - dry_run=dry_run, - ) - if rounds_max == 1: - break - status = _round_status(run.response.text or "") - log_event( - self.paths, - "workflow_round_complete", - agent=stage.agent, - workflow=workflow.name, - stage_id=stage.id, - round=round_idx, - rounds_total=rounds_max, - round_status=status, - ok=run.response.ok, - ) - if status == "done" or not run.response.ok: - break - - assert run is not None # the loop runs at least once - - # Critic-judge refinement loop. When the stage opts in - # (explicit ``refine:`` block or careful=true under the default - # auto_refine policy), dispatch a critic agent to score the - # response; if the critic says revise, re-dispatch the worker - # with the critic's feedback until accept or max_rounds. - if not dry_run and run.response.ok and self._debate_enabled(stage): - # Adversarial debate panel takes precedence over the single critic. - try: - run = self._debate_loop(workflow, stage, run, cycle_context, dry_run) - except Exception as exc: - log_exception("orchestration.workflow._run_stage.debate", exc) - elif not dry_run and run.response.ok and self._refine_enabled(stage): - try: - run = self._refine_loop(workflow, stage, run, cycle_context, dry_run) - except Exception as exc: - log_exception("orchestration.workflow._run_stage.refine", exc) - - ok = run.response.ok - if dry_run: - v_ok, v_out = True, "(dry-run: validation skipped)" - else: - v_ok, v_out = self._validate(stage) - - # Outputs contract: warn when the stage's promised POST keys never - # landed, then give the chief one recovery pass to fill the gap - # (re-dispatch the worker, post a substitute, or accept it) so - # downstream stages don't silently consume missing inputs. - unmet_outputs = self._check_outputs_contract(stage) - if unmet_outputs: - log( - f"workflow {workflow.name}: stage {stage.id} did not satisfy " - f"declared outputs: {unmet_outputs}", - level="WARN", - ) - log_event( - self.paths, - "workflow_outputs_unmet", - agent=stage.agent, - workflow=workflow.name, - stage_id=stage.id, - expected=list(stage.outputs), - missing=list(unmet_outputs), - ) - # Only when the stage otherwise succeeded: a failed response or - # failed validation already keeps the stage incomplete (and may - # roll back), so a recovery pass here couldn't unblock anything. - if ( - not dry_run - and ok - and v_ok - and stage.agent != "chief" - and self._outputs_recovery_enabled - ): - try: - self._dispatch_outputs_recovery( - workflow, stage, unmet_outputs, cycle_context, dry_run - ) - # Re-check: the chief may have posted the missing keys - # (or had the worker do it) during the recovery pass. - unmet_outputs = self._check_outputs_contract(stage) - except Exception as exc: - log_exception("orchestration.workflow._run_stage.outputs_recovery", exc) - - committed = False - if run.committed: - committed = True - elif ok and v_ok and stage.commit and not dry_run: - committed = self._commit(workflow, stage, run, v_out) - - if not v_ok and pre_stage_sha and not dry_run: - if self._should_rollback(stage): - log(f"stage {stage.id}: validation failed; rolling back to {pre_stage_sha[:8]}", level="WARN") - log_event(self.paths, "workflow_stage_rollback", - agent=stage.agent, workflow=workflow.name, - stage_id=stage.id, sha=pre_stage_sha) - # Snapshot the about-to-be-discarded work behind a ref so a - # hard reset never makes it unrecoverable (batch commits - # would otherwise dangle and eventually be GC'd). - snapshot_rollback(self.paths.root, stage.id) - # Verify the rollback actually landed: a silently-failed git - # reset would leave broken state on disk while the runner - # believes it recovered. - rolled_back = revert_to(self.paths.root, pre_stage_sha) - post_sha = head_sha(self.paths.root) if rolled_back else None - if rolled_back and post_sha == pre_stage_sha: - committed = False - else: - log( - f"stage {stage.id}: rollback to {pre_stage_sha[:8]} FAILED " - f"(HEAD is {(post_sha or 'unknown')[:8]}); workspace may " - "contain unvalidated changes", - level="ERROR", - ) - log_event(self.paths, "workflow_rollback_failed", - agent=stage.agent, workflow=workflow.name, - stage_id=stage.id, expected_sha=pre_stage_sha, - actual_sha=post_sha or "") - else: - # Default for ordinary stages: keep the work in place. The - # failure is recorded on the StageResult and the supervise / - # qa loop repairs forward — a hard reset here would delete - # batch-committed files from disk (and the Files tab). - log( - f"stage {stage.id}: validation failed; keeping work in place " - "(validation.rollback_on_failure)", - level="WARN", - ) - log_event(self.paths, "workflow_rollback_skipped", - agent=stage.agent, workflow=workflow.name, - stage_id=stage.id, sha=pre_stage_sha) - - failure_reason = "" - if not ok: - failure_reason = (run.response.error or "agent_failed")[:200] - elif not v_ok: - failure_reason = f"validation_failed: {v_out[:200]}" if v_out else "validation_failed" - elif unmet_outputs: - # Soft-fail tag — does not unset stage completion but visible - # in the result for downstream consumers. - failure_reason = f"outputs_unmet: {','.join(unmet_outputs)[:160]}" - - result = StageResult( - stage=stage, - run=run, - validated=v_ok, - validation_output=v_out, - committed=committed, - failure_reason=failure_reason, - ) - if self.telemetry is not None: - try: - self.telemetry.record_stage(ok=bool(ok and v_ok)) - except Exception: - pass - - # Per-stage chief checkpoint for sensitive stages. Cheap, gated, - # and never blocks: it just keeps the chief in the loop without - # waiting for the next supervise cycle. - if ( - ok - and v_ok - and not dry_run - and self._checkpoint_enabled(stage) - and stage.agent != "chief" # avoid recursion on review/checkpoint stages - ): - try: - self._dispatch_checkpoint(workflow, stage, result, cycle_context, dry_run) - except Exception as exc: - log_exception("orchestration.workflow._run_stage.checkpoint", exc) - - return result - - # -- new helpers (blackboard / review / checkpoint) ------------------ - - def _check_outputs_contract(self, stage: WorkflowStage) -> List[str]: - """Return the list of declared output contract keys not yet posted - by ``stage.id``. Empty list when the contract is satisfied or not - declared. - """ - if not stage.outputs: - return [] - try: - posts = _blackboard.list_posts(self.paths) - return _blackboard.find_outputs_satisfied( - posts, stage_id=stage.id, expected=stage.outputs - ) - except Exception as exc: - log_exception("orchestration.workflow._check_outputs_contract", exc) - return [] - - def _build_review_objective( - self, - workflow: Workflow, - stage: WorkflowStage, - result_by_id: Dict[str, "StageResult"], - ) -> str: - """Render the chief-review prompt for ``stage`` using upstream stages' - actual posts so the chief reads the real artifacts, not the - worker's self-report. - """ - try: - all_posts = _blackboard.list_posts(self.paths) - except Exception: - all_posts = [] - upstream_ids = list(stage.depends_on) - sections: List[str] = [ - f"Review dispatch for workflow `{workflow.name}` stage `{stage.id}`.", - "", - "You are reviewing the output of these upstream stages:", - ] - for sid in upstream_ids: - sr = result_by_id.get(sid) - if sr is None: - sections.append(f"- `{sid}`: (no result on record)") - continue - agent = sr.stage.agent - ok = sr.run.response.ok - v_ok = sr.validated - reason = sr.failure_reason or "" - sections.append( - f"- `{sid}` (agent {agent}): ok={ok} validated={v_ok} " - + (f"reason={reason}" if reason else "no failure") - ) - sections.append("") - sections.append("Blackboard posts produced by these stages:") - any_posts = False - for p in all_posts: - if p.stage_id in upstream_ids: - any_posts = True - body = (p.body or "").strip() - if len(body) > 1200: - body = body[:1200].rstrip() + " …" - sections.append( - f"\n--- post id={p.id} author={p.author} type={p.post_type} " - f"stage={p.stage_id} produces={','.join(p.produces) or '-'} ---" - ) - sections.append(body or "(empty body)") - if not any_posts: - sections.append("(no blackboard posts from upstream — workers may have skipped POST.)") - sections.append("") - sections.append( - "Decide one of:\n" - " (a) ACTION:done with REASON — the user's prompt is fully addressed.\n" - " (b) PROPOSE_WORKFLOW with a refined next iteration (always include\n" - " a final supervise stage so the loop continues).\n" - " (c) PROPOSE_CONSENSUS to re-sample a specific decision when the\n" - " upstream results disagree or seem unreliable.\n" - "Also emit a brief POST: review block summarizing what passed, what\n" - "needs more work, and the chosen path." - ) - if stage.objective: - sections.append("") - sections.append("Review-stage author's objective (from the workflow YAML):") - sections.append(stage.objective) - return "\n".join(sections) - - @property - def _checkpoint_default_per_stage(self) -> bool: - cfg = (self.runner.clk_cfg.get("review") or {}) - return bool(cfg.get("per_stage", False)) - - def _checkpoint_enabled(self, stage: WorkflowStage) -> bool: - if stage.careful: - return True - return self._checkpoint_default_per_stage - - def _meta_dispatch_enabled(self, stage: WorkflowStage) -> bool: - cfg = (self.runner.clk_cfg.get("meta_prompt") or {}) - mode = str(cfg.get("dispatch") or "off").lower() - if mode in ("", "off", "false", "0"): - return False - if mode == "always": - return True - # default mode "careful_only" - return bool(stage.careful) - - # -- critic-judge refinement (Layer 3 robustness loop) --------------- - - def _refine_enabled(self, stage: WorkflowStage) -> bool: - """Decide whether the critic-judge refinement loop should run. - - Explicit ``refine:`` on the stage always wins. Otherwise we - fall back to ``robustness.auto_refine`` (off | careful_only | - all). ``chief`` and ``qa`` agents are skipped to avoid the - critic critiquing its own coalescing output or the validator. - """ - if stage.agent in ("chief", "qa", "critic"): - return False - if stage.refine is not None: - return True - cfg = (self.runner.clk_cfg.get("robustness") or {}) - mode = str(cfg.get("auto_refine") or "off").lower() - if mode in ("", "off", "false", "0"): - return False - if mode == "all": - return True - # default mode "careful_only" - return bool(stage.careful) - - def _refine_loop( - self, - workflow: "Workflow", - stage: WorkflowStage, - first_run: AgentRun, - cycle_context: str, - dry_run: Optional[bool], - ) -> AgentRun: - """Run draft → critic → revise until accept or max_rounds. - - Reuses the runner's existing dispatch path for both the critic - and the revised worker. The critic is dispatched in a ``phase: - refine_critic`` extra so the wrapper's auto-consensus and - quality-retry layers don't recurse. - - Returns the final worker run — either the revised one or the - original when the critic accepts immediately. - """ - defaults = (self.runner.clk_cfg.get("robustness") or {}) - cfg = dict(stage.refine or {}) - critic_name = str(cfg.get("critic") or "critic") - try: - max_rounds = int(cfg.get("max_rounds") or defaults.get("refine_max_rounds") or 3) - except (TypeError, ValueError): - max_rounds = 3 - try: - threshold = float(cfg.get("accept_threshold") or defaults.get("refine_accept_threshold") or 0.8) - except (TypeError, ValueError): - threshold = 0.8 - - # If the named critic isn't in the roster, fall back to the - # `critic` baseline; if even that is missing, skip silently. - agents_cfg = (self.runner.agents_cfg.get("agents") or {}) - if critic_name not in agents_cfg: - critic_name = "critic" if "critic" in agents_cfg else "" - if not critic_name: - return first_run - - current_run = first_run - for round_idx in range(1, max_rounds + 1): - if self.telemetry is not None: - try: - self.telemetry.add_refine_round() - except Exception: - pass - verdict, judge_score, feedback = self._dispatch_critic( - workflow, stage, current_run, critic_name, round_idx, max_rounds, dry_run, - ) - log_event( - self.paths, - "refine_critic_verdict", - agent=stage.agent, - critic=critic_name, - workflow=workflow.name, - stage_id=stage.id, - round=round_idx, - max_rounds=max_rounds, - verdict=verdict, - score=judge_score, - accept_threshold=threshold, - ) - self.runner._observer_log( - f"refine :: {stage.id} :: round {round_idx}/{max_rounds} " - f"{critic_name}→ verdict={verdict} score={judge_score:.2f}" - ) - if verdict == "accept" or judge_score >= threshold: - return current_run - if round_idx == max_rounds: - # Out of budget — keep the latest worker output even - # though the critic isn't satisfied. - return current_run - revise_objective = ( - f"Refinement round {round_idx + 1}/{max_rounds} of stage " - f"`{stage.id}`. The critic (`{critic_name}`) scored your " - f"previous response {judge_score:.2f}/1.0 and asked for " - "revisions:\n\n" - f"{feedback}\n\n" - "Revise the response so the critic's points are addressed. " - "Keep what already works; rewrite only what was flagged. " - "Re-emit POST and ACTION blocks the same way you did the " - "first time so the harness can record the updated work.\n\n" - f"Original objective:\n{stage.objective}" - ) - current_run = self.runner.run( - stage.agent, - revise_objective, - extra={ - "phase": "refine_worker", - "stage_id": stage.id, - "workflow": workflow.name, - "cycle_context": cycle_context, - "blackboard_inputs": list(stage.inputs), - "stage_outputs": list(stage.outputs), - "refine_round": round_idx + 1, - "refine_max_rounds": max_rounds, - "telemetry": self.telemetry, - }, - dry_run=dry_run, - ) - if not current_run.response.ok: - return current_run - return current_run - - _REFINE_VERDICT_RE = re.compile( - r"^\s*VERDICT\s*:\s*(accept|revise|reject)\b", re.IGNORECASE | re.MULTILINE, - ) - _REFINE_SCORE_RE = re.compile( - r"^\s*SCORE\s*:\s*([0-9]*\.?[0-9]+)", re.IGNORECASE | re.MULTILINE, - ) - - def _dispatch_critic( - self, - workflow: "Workflow", - stage: WorkflowStage, - worker_run: AgentRun, - critic_name: str, - round_idx: int, - max_rounds: int, - dry_run: Optional[bool], - ) -> Tuple[str, float, str]: - """Run one critic pass; return ``(verdict, score, feedback)``. - - ``verdict`` is normalised to ``"accept"`` or ``"revise"``. - ``score`` is parsed from the critic's ``SCORE: <0..1>`` line and - defaults to 0.0 (i.e. "revise") when missing. - ``feedback`` is the critic's full response text, used verbatim - in the revision objective. - """ - worker_text = (worker_run.response.text or "").strip() - if len(worker_text) > 4000: - worker_text = worker_text[:4000].rstrip() + "\n…(truncated)" - outputs_text = ( - ", ".join(stage.outputs) if stage.outputs else "(no declared outputs)" - ) - critic_objective = ( - f"Refinement-loop critic pass for workflow `{workflow.name}` " - f"stage `{stage.id}` (round {round_idx}/{max_rounds}).\n\n" - f"Worker: `{stage.agent}`\n" - f"Worker's objective:\n{stage.objective}\n\n" - f"Declared output contract keys: {outputs_text}\n\n" - f"Worker's response:\n---\n{worker_text}\n---\n\n" - "Score the response 0..1 against the objective and the " - "declared output contract. List concrete, specific " - "revisions the worker should make. Be brief — three to six " - "bullets is plenty. End your response with exactly two " - "lines:\n" - "VERDICT: accept # or `revise` if any item must change\n" - "SCORE: <0..1>\n" - ) - critic_run = self.runner.run( - critic_name, - critic_objective, - extra={ - "phase": "refine_critic", - "stage_id": stage.id, - "workflow": workflow.name, - "refine_round": round_idx, - }, - dry_run=dry_run, - ) - text = critic_run.response.text or "" - verdict_m = self._REFINE_VERDICT_RE.search(text) - verdict = (verdict_m.group(1).lower() if verdict_m else "revise") - if verdict not in ("accept", "revise"): - verdict = "revise" - score_m = self._REFINE_SCORE_RE.search(text) - try: - score_val = float(score_m.group(1)) if score_m else 0.0 - except (TypeError, ValueError): - score_val = 0.0 - score_val = max(0.0, min(1.0, score_val)) - # When the critic accepted but didn't post a score, treat it as - # a confident pass; when it asked to revise but didn't score, - # treat as a moderate-low score so the loop continues. - if score_m is None: - score_val = 1.0 if verdict == "accept" else 0.4 - return verdict, score_val, text.strip() - - # -- adversarial debate panel (multi-critic refinement) -------------- - - _DEBATE_LENS_GUIDANCE: Dict[str, str] = { - "correctness": "logic errors, wrong outputs, unhandled edge cases, broken contracts or APIs.", - "security": "injection, unsafe input handling, secret/credential leakage, unsafe shell/file operations.", - "simplicity": "needless complexity, duplication, dead code, and simpler equivalent designs.", - "performance": "obvious inefficiency, redundant work, N+1 patterns, unbounded loops or memory.", - "robustness": "failure modes, missing error handling, flaky assumptions, and race conditions.", - "tests": "missing or weak tests, untested branches, and assertions that don't actually verify behavior.", - "ux": "confusing interfaces, poor error messages, and undocumented behavior.", - } - - def _debate_enabled(self, stage: WorkflowStage) -> bool: - """Whether the adversarial debate panel should run for this stage. - - Explicit ``refine: {mode: debate}`` always wins; otherwise the - ``robustness.debate`` policy (off | careful_only | all) decides. - chief / qa / critic stages are skipped. - """ - if stage.agent in ("chief", "qa", "critic"): - return False - if isinstance(stage.refine, dict) and str(stage.refine.get("mode") or "").lower() == "debate": - return True - cfg = (self.runner.clk_cfg.get("robustness") or {}) - mode = str(cfg.get("debate") or "off").lower() - if mode in ("", "off", "false", "0"): - return False - if mode == "all": - return True - return bool(stage.careful) # careful_only - - def _debate_lenses(self, stage: WorkflowStage) -> List[str]: - if isinstance(stage.refine, dict) and stage.refine.get("critics"): - lenses = [str(x).strip().lower() for x in stage.refine["critics"] if str(x).strip()] - else: - cfg = (self.runner.clk_cfg.get("robustness") or {}) - lenses = [str(x).strip().lower() for x in (cfg.get("debate_lenses") or []) if str(x).strip()] - return lenses or ["correctness", "security", "simplicity"] - - def _dispatch_lens_critic( - self, - workflow: "Workflow", - stage: WorkflowStage, - worker_run: AgentRun, - critic_name: str, - lens: str, - round_idx: int, - max_rounds: int, - peer_transcript: str, - dry_run: Optional[bool], - ) -> Tuple[str, str, float, str]: - """One adversarial critic pass for a single lens. - - Returns ``(lens, verdict, score, feedback)``. The critic is told to - try to *break* the work from its lens and, in later rounds, to engage - with peers' critiques (reinforce / refute / concede). - """ - worker_text = (worker_run.response.text or "").strip() - if len(worker_text) > 3500: - worker_text = worker_text[:3500].rstrip() + "\n…(truncated)" - guidance = self._DEBATE_LENS_GUIDANCE.get( - lens, f"weaknesses from the {lens} perspective." - ) - peer_block = "" - if peer_transcript.strip(): - peer_block = ( - "\nYour fellow panelists said (engage with them — reinforce, " - "refute, or concede explicitly):\n" - f"{peer_transcript}\n" - ) - objective = ( - f"ADVERSARIAL DEBATE — you are the **{lens}** critic on a review " - f"panel for stage `{stage.id}` (round {round_idx}/{max_rounds}).\n\n" - f"Your lens: hunt for {guidance}\n" - "Try hard to BREAK this work from your lens. Be specific and " - "concrete; cite the exact place. Default to skepticism — only " - "accept if you genuinely cannot find a real problem.\n\n" - f"Worker `{stage.agent}` objective:\n{stage.objective}\n\n" - f"Worker's response:\n---\n{worker_text}\n---\n" - f"{peer_block}\n" - "Keep it to 2-5 concrete bullets. End with exactly two lines:\n" - "VERDICT: accept # or `revise` if any real issue remains\n" - "SCORE: <0..1>\n" - ) - critic_run = self.runner.run( - critic_name, - objective, - extra={ - "phase": "refine_critic", - "stage_id": stage.id, - "workflow": workflow.name, - "refine_round": round_idx, - "debate_lens": lens, - }, - dry_run=dry_run, - ) - text = critic_run.response.text or "" - verdict_m = self._REFINE_VERDICT_RE.search(text) - verdict = (verdict_m.group(1).lower() if verdict_m else "revise") - if verdict not in ("accept", "revise"): - verdict = "revise" - score_m = self._REFINE_SCORE_RE.search(text) - try: - score_val = float(score_m.group(1)) if score_m else (1.0 if verdict == "accept" else 0.4) - except (TypeError, ValueError): - score_val = 0.4 - score_val = max(0.0, min(1.0, score_val)) - return lens, verdict, score_val, text.strip() - - def _debate_loop( - self, - workflow: "Workflow", - stage: WorkflowStage, - first_run: AgentRun, - cycle_context: str, - dry_run: Optional[bool], - ) -> AgentRun: - """Run an adversarial debate panel: N lens-critics → worker revision. - - Each round fans out one critic per lens in parallel; the worker is - kept only if a majority of lenses accept (or the mean score clears the - threshold). Otherwise the combined critiques drive a revision, and the - next round's critics see the prior panel transcript so they can debate - each other. Bounded by ``debate_max_rounds``. - """ - defaults = (self.runner.clk_cfg.get("robustness") or {}) - cfg = dict(stage.refine or {}) if isinstance(stage.refine, dict) else {} - try: - max_rounds = int(cfg.get("max_rounds") or defaults.get("debate_max_rounds") or 2) - except (TypeError, ValueError): - max_rounds = 2 - try: - threshold = float(cfg.get("accept_threshold") or defaults.get("refine_accept_threshold") or 0.8) - except (TypeError, ValueError): - threshold = 0.8 - - agents_cfg = (self.runner.agents_cfg.get("agents") or {}) - critic_name = "critic" if "critic" in agents_cfg else "" - if not critic_name: - # No critic in the roster — fall back to the single-critic loop - # (which itself no-ops when no critic exists). - return self._refine_loop(workflow, stage, first_run, cycle_context, dry_run) - - lenses = self._debate_lenses(stage) - max_parallel = max(1, int((self.runner.clk_cfg.get("consensus") or {}).get("max_parallel") or 4)) - current_run = first_run - peer_transcript = "" - - for round_idx in range(1, max_rounds + 1): - if self.telemetry is not None: - try: - self.telemetry.add_refine_round() - except Exception: - pass - verdicts: List[Tuple[str, str, float, str]] = [] - with ThreadPoolExecutor(max_workers=min(max_parallel, len(lenses))) as pool: - futs = { - pool.submit( - self._dispatch_lens_critic, workflow, stage, current_run, - critic_name, lens, round_idx, max_rounds, peer_transcript, dry_run, - ): lens - for lens in lenses - } - for fut in as_completed(futs): - try: - verdicts.append(fut.result()) - except Exception as exc: - log_exception("orchestration.workflow._debate_loop.critic", exc) - if not verdicts: - return current_run - revise_votes = sum(1 for (_l, v, _s, _f) in verdicts if v == "revise") - scores = [s for (_l, _v, s, _f) in verdicts] - avg_score = sum(scores) / len(scores) if scores else 0.0 - transcript = "\n".join( - f"[{lens}] verdict={v} score={s:.2f}\n{fb}" for (lens, v, s, fb) in verdicts - ) - peer_transcript = transcript - log_event( - self.paths, "debate_round", - agent=stage.agent, workflow=workflow.name, stage_id=stage.id, - round=round_idx, max_rounds=max_rounds, - lenses=[l for (l, *_r) in verdicts], - revise_votes=revise_votes, avg_score=round(avg_score, 3), - accept_threshold=threshold, - ) - self.runner._observer_log( - f"debate :: {stage.id} :: round {round_idx}/{max_rounds} " - f"{len(lenses)} critics, {revise_votes} revise, avg={avg_score:.2f}" - ) - try: - _blackboard.post( - self.paths, author="critic-panel", body=transcript[:4000], - post_type="debate", stage_id=stage.id, workflow=workflow.name, - slug_hint=f"debate-{stage.id}-r{round_idx}", - ) - except Exception as exc: - log_exception("orchestration.workflow._debate_loop.post", exc) - - # Panel accepts when a majority accept AND the mean clears the bar. - if revise_votes * 2 <= len(verdicts) and avg_score >= threshold: - return current_run - if round_idx == max_rounds: - return current_run - - revise_objective = ( - f"Debate round {round_idx + 1}/{max_rounds} of stage `{stage.id}`. " - f"An adversarial review panel ({', '.join(lenses)}) found issues " - f"(mean score {avg_score:.2f}/1.0). Address every concrete point " - "below; keep what already works. Re-emit POST and ACTION blocks " - "the same way so the harness records the updated work.\n\n" - f"Panel critiques:\n{transcript}\n\n" - f"Original objective:\n{stage.objective}" - ) - current_run = self.runner.run( - stage.agent, - revise_objective, - extra={ - "phase": "refine_worker", - "stage_id": stage.id, - "workflow": workflow.name, - "cycle_context": cycle_context, - "blackboard_inputs": list(stage.inputs), - "stage_outputs": list(stage.outputs), - "refine_round": round_idx + 1, - "refine_max_rounds": max_rounds, - "telemetry": self.telemetry, - }, - dry_run=dry_run, - ) - if not current_run.response.ok: - return current_run - return current_run - - def _dispatch_checkpoint( - self, - workflow: Workflow, - stage: WorkflowStage, - result: "StageResult", - cycle_context: str, - dry_run: Optional[bool], - ) -> None: - """Light-weight chief checkpoint after a sensitive stage. - - Cost-bounded: a small prompt with the stage's posts and a - request for a CONTINUE / REDIRECT / ABORT verdict. The chief - emits ACTION:done if the project is finished, or PROPOSE_WORKFLOW - if the plan should change. Otherwise we just log the verdict and - let the workflow proceed. - """ - try: - posts = _blackboard.list_posts(self.paths) - except Exception: - posts = [] - produced = [p for p in posts if p.stage_id == stage.id] - snapshot = "\n".join( - f"- {p.id} type={p.post_type} produces={','.join(p.produces) or '-'} " - f"body_chars={len(p.body or '')}" - for p in produced[-10:] - ) or "(no posts from this stage)" - objective = ( - f"Chief checkpoint after stage `{stage.id}` (agent {stage.agent}, " - f"workflow `{workflow.name}`).\n\n" - f"Stage objective:\n{stage.objective}\n\n" - f"Posts produced by this stage:\n{snapshot}\n\n" - "Reply with one of:\n" - " CHECKPOINT: continue — let the workflow proceed as planned.\n" - " CHECKPOINT: redirect — emit PROPOSE_WORKFLOW with a revised plan.\n" - " CHECKPOINT: abort — emit ACTION:done if the project is finished.\n" - "Keep the response short — this is a verification, not a redo." - ) - log(f"workflow {workflow.name}: checkpoint after stage {stage.id}") - self.runner.run( - "chief", - objective, - extra={ - "phase": "checkpoint", - "workflow": workflow.name, - "stage_id": stage.id, - "cycle_context": cycle_context, - "blackboard_inputs": [f"stage:{stage.id}"], - }, - dry_run=dry_run, - ) - - # -- helpers --------------------------------------------------------- - - def _unmet_deps(self, stage: WorkflowStage, completed: Dict[str, bool]) -> List[str]: - return [d for d in stage.depends_on if not completed.get(d)] - - def _is_provider_failure(self, error: str) -> bool: - return is_provider_failure(error) - - def _is_retryable_stage_error(self, error: str) -> bool: - """Subset of provider failures worth retrying with backoff at stage level.""" - msg = (error or "").lower() - retryable = [ - "no output for", - "timeout after", - "operation was aborted", - "no endpoints available", - "guardrail restrictions", - "data policy", - "connection reset", - "temporarily unavailable", - "try again", - "rate limit", - "quota", - # HTTP 429 rate-limiting and HTTP 404 (OpenRouter: no endpoints temporarily available) - "http 429", - "http 404", - ] - non_retryable = [ - "api key", - "authentication", - "unauthorized", - "forbidden", - "cli not found", - ] - return any(s in msg for s in retryable) and not any(s in msg for s in non_retryable) - - def _log_skip( - self, - stage: WorkflowStage, - unmet: List[str], - result_by_id: Dict[str, StageResult], - ) -> None: - details: List[str] = [] - for d in unmet: - sr = result_by_id.get(d) - if sr is None: - details.append(f"{d}=never_ran") - elif sr.failure_reason: - details.append(f"{d}={sr.failure_reason}") - else: - details.append(f"{d}=incomplete") - log( - f"stage {stage.id} skipped after recovery limit: " + "; ".join(details), - level="WARN", - ) - - def _dispatch_recovery( - self, - workflow: Workflow, - stage: WorkflowStage, - unmet: List[str], - result_by_id: Dict[str, StageResult], - *, - dry_run: Optional[bool], - cycle_context: str = "", - ) -> None: - details: List[str] = [] - for d in unmet: - sr = result_by_id.get(d) - if sr is None: - details.append(f"- `{d}`: never ran (probably never reached or removed from workflow)") - elif sr.failure_reason: - details.append(f"- `{d}`: {sr.failure_reason}") - else: - details.append(f"- `{d}`: incomplete (no failure recorded)") - objective = ( - f"Recovery dispatch for workflow `{workflow.name}` stage `{stage.id}`.\n\n" - f"This stage depends on: {stage.depends_on}.\n" - f"Unmet dependencies (with reasons):\n" + "\n".join(details) + "\n\n" - "Decide one of:\n" - " (a) Re-cast the workflow with PROPOSE_WORKFLOW so the dependency is\n" - " no longer required, OR\n" - " (b) Emit ACTION blocks that fix the upstream failure (write/edit/run\n" - " to satisfy the failed validation), OR\n" - " (c) dispatch an existing suitable agent, or PROPOSE_ROLE for a\n" - " distinct specialist if no current agent fits (b).\n" - "Do NOT skip silently. The harness will retry this stage after you respond." - ) - log(f"workflow {workflow.name}: dispatching chief recovery for stage {stage.id}") - self.runner.run( - "chief", - objective, - extra={ - "phase": "recovery", - "workflow": workflow.name, - "stage_id": stage.id, - "unmet_deps": ",".join(unmet), - "cycle_context": cycle_context, - }, - dry_run=dry_run, - ) - - @property - def _outputs_recovery_enabled(self) -> bool: - """Gate for the outputs-contract recovery dispatch. Defaults on; - disable via clk.config.json::recovery::dispatch_on_unmet_outputs. - """ - cfg = (self.runner.clk_cfg.get("recovery") or {}) - val = cfg.get("dispatch_on_unmet_outputs", True) - return str(val).lower() not in ("false", "0", "off", "no") - - def _dispatch_outputs_recovery( - self, - workflow: Workflow, - stage: WorkflowStage, - missing: List[str], - cycle_context: str, - dry_run: Optional[bool], - ) -> None: - """Chief recovery pass for an unsatisfied outputs contract. - - Runs once per stage execution (the caller re-checks the contract - afterwards). The chief can re-dispatch the worker via a Q&A-style - instruction, post the missing keys itself if the information is - already on the blackboard, or explicitly accept the gap. - """ - objective = ( - f"Outputs-contract recovery for workflow `{workflow.name}` " - f"stage `{stage.id}` (agent {stage.agent}).\n\n" - f"The stage declared it would produce these blackboard keys but " - f"did not: {', '.join(missing)}.\n" - f"Stage objective was:\n{stage.objective}\n\n" - "Downstream stages consume these keys; missing them causes silent " - "data gaps. Do one of:\n" - " (a) Post the missing keys yourself (POST block with PRODUCES:\n" - " listing them) if the information already exists on the\n" - " blackboard or in the repo, OR\n" - " (b) Emit ACTION blocks that produce the artifact the keys\n" - " describe, then POST with the keys, OR\n" - " (c) Explicitly accept the gap in a POST: review block stating\n" - " why downstream stages can proceed without these keys.\n" - "Do NOT skip silently." - ) - log( - f"workflow {workflow.name}: dispatching chief outputs recovery " - f"for stage {stage.id} (missing: {', '.join(missing)})" - ) - log_event( - self.paths, "workflow_outputs_recovery", - agent=stage.agent, workflow=workflow.name, - stage_id=stage.id, missing=list(missing), - ) - self.runner.run( - "chief", - objective, - extra={ - "phase": "recovery", - "workflow": workflow.name, - "stage_id": stage.id, - "cycle_context": cycle_context, - "blackboard_inputs": [f"stage:{stage.id}"], - }, - dry_run=dry_run, - ) - - def _dispatch_stall_rescue( - self, - workflow: Workflow, - cycle: int, - cycle_results: List[StageResult], - ) -> None: - """One-shot chief dispatch when the supervise loop stalls. - - Instead of silently giving up after N no-progress cycles, give the - chief the stall evidence and a chance to (a) declare the project - done, (b) restructure the plan via PROPOSE_WORKFLOW, or (c) emit - ACTION blocks that unblock the workers directly. Runs at most once - per supervise loop (the caller tracks ``rescue_attempted``). - """ - lines: List[str] = [] - for r in cycle_results[-8:]: - ok = r.run.response.ok - reason = r.failure_reason or ("ok" if ok else "failed") - lines.append(f"- stage `{r.stage.id}` (agent {r.stage.agent}): {reason}") - objective = ( - f"STALL RESCUE for workflow `{workflow.name}` at supervise cycle {cycle}.\n\n" - "The loop has made no measurable progress for several consecutive " - "cycles (no commits, no file writes, or every agent self-reported " - "`PROGRESS: no`). Last cycle's stages:\n" - + "\n".join(lines or ["- (no stage results recorded)"]) - + "\n\nDiagnose WHY the loop is stuck, then do exactly one of:\n" - " (a) ACTION:done with REASON — if the user's objective is actually\n" - " complete and the loop is spinning on nothing.\n" - " (b) PROPOSE_WORKFLOW with a restructured plan that removes the\n" - " blocked stages and takes a genuinely different approach.\n" - " (c) ACTION blocks (write/edit/run) that directly fix the blocker\n" - " the workers keep hitting.\n" - "Do NOT re-propose the same plan that is already stalling. This is " - "the loop's last chance before the harness stops it." - ) - log(f"workflow {workflow.name}: dispatching chief stall rescue (cycle {cycle})", level="WARN") - log_event(self.paths, "workflow_stall_rescue", workflow=workflow.name, cycle=cycle) - try: - self.runner.run( - "chief", - objective, - extra={ - "phase": "recovery", - "workflow": workflow.name, - "stage_id": "stall_rescue", - }, - ) - except Exception as exc: - log_exception("orchestration.workflow._dispatch_stall_rescue", exc) - - def _refresh_from_dispatched( - self, - workflow_name: str, - wf_path: Path, - prev_mtime: float, - stages: List[WorkflowStage], - done_ids: Set[str], - ) -> Tuple[List[WorkflowStage], float]: - """Reload the workflow file if rewritten; splice in any new stages - whose ids haven't been dispatched yet so dynamically-added stages - are picked up without re-running already-dispatched ones. - """ - if not wf_path.exists(): - return stages, prev_mtime - new_mtime = wf_path.stat().st_mtime - if new_mtime <= prev_mtime: - return stages, prev_mtime - try: - refreshed = load_workflow(wf_path) - except Exception as exc: - log_exception("orchestration.workflow._refresh_from_dispatched", exc) - return stages, prev_mtime - kept = [s for s in stages if s.id in done_ids] - new_pending = [s for s in refreshed.stages if s.id not in done_ids] - merged = kept + new_pending - log( - f"workflow {workflow_name}: refreshed; " - f"{len(kept)} done, {len(new_pending)} pending" - ) - return merged, new_mtime - - def _maybe_refresh_workflow( - self, - workflow_name: str, - wf_path: Path, - prev_mtime: float, - stages: List[WorkflowStage], - cursor: int, - ) -> Tuple[List[WorkflowStage], float]: - """If the workflow file was rewritten, replace the un-processed - tail of the queue with the refreshed stage list. - - ``cursor`` is the index the runner is about to process next. - Stages already executed (``stages[:cursor]``) are preserved so - we never re-run them. Stages from the refreshed YAML whose ids - appear in the executed prefix are dropped (the agent should use - a new id like ``foo_retry`` if they want to re-attempt). - """ - if not wf_path.exists(): - return stages, prev_mtime - new_mtime = wf_path.stat().st_mtime - if new_mtime <= prev_mtime: - return stages, prev_mtime - try: - refreshed = load_workflow(wf_path) - except Exception as exc: - log_exception("orchestration.workflow._maybe_refresh_workflow", exc) - return stages, prev_mtime - processed = stages[:cursor] - processed_ids = {s.id for s in processed} - pending = [s for s in refreshed.stages if s.id not in processed_ids] - merged = processed + pending - log( - f"workflow {workflow_name}: refreshed; " - f"{len(processed)} processed, {len(pending)} pending" - ) - return merged, new_mtime - - def _validate(self, stage: WorkflowStage) -> tuple[bool, str]: - cmd = stage.validation - # FM4: a producing stage with no explicit validation no longer - # auto-passes — derive a real command from the project shape. Non- - # producing stages (chief/critic prose) keep the auto-pass. - if not cmd: - val_cfg = (self.runner.clk_cfg.get("validation") or {}) - if val_cfg.get("auto_derive", True) and _noop_guard.is_mutation_expected( - stage.agent, outputs=stage.outputs, commit=stage.commit, - cfg=self.runner.clk_cfg, - ): - if val_cfg.get("derived_command"): - cmd = str(val_cfg.get("derived_command")) - else: - derived, _weak = _evaluator.derive_validation(self.paths.root) - cmd = derived[0] if derived else None - if not cmd: - return True, "" - try: - log_event( - self.paths, - "shell_command_start", - agent=stage.agent, - action="validation", - stage_id=stage.id, - cmd=cmd, - cwd=str(self.paths.root), - timeout_s=120, - ) - r = subprocess.run( - cmd, - shell=True, - cwd=str(self.paths.root), - capture_output=True, - text=True, - timeout=120, - ) - output = (r.stdout or "") + (r.stderr or "") - log_event( - self.paths, - "shell_command_end", - agent=stage.agent, - action="validation", - stage_id=stage.id, - cmd=cmd, - ok=r.returncode == 0, - returncode=r.returncode, - output=output, - output_chars=len(output or ""), - ) - return r.returncode == 0, output.strip() - except Exception as exc: - log_exception("orchestration.workflow._validate", exc) - log_event( - self.paths, - "shell_command_end", - agent=stage.agent, - action="validation", - stage_id=stage.id, - cmd=cmd, - ok=False, - error=str(exc), - ) - return False, str(exc) - - def _commit( - self, - workflow: Workflow, - stage: WorkflowStage, - run: AgentRun, - validation_output: str, - ) -> bool: - if not has_changes(self.paths.root): - return False - if not add_all(self.paths.root): - return False - return git_commit( - self.paths.root, - agent=f"{workflow.name}.{stage.id}", - objective=stage.objective, - files_changed=run.files_written, - validation=stage.validation or "none", - next_step=f"continue workflow {workflow.name}", - body_extra=(validation_output or "")[:500], - ) diff --git a/clk_harness/orchestration/workflow/__init__.py b/clk_harness/orchestration/workflow/__init__.py new file mode 100644 index 0000000..df1842c --- /dev/null +++ b/clk_harness/orchestration/workflow/__init__.py @@ -0,0 +1,53 @@ +"""Workflow parser and runner (Archon-style YAML). + +Decomposed package; this ``__init__`` preserves the public surface of the +former ``clk_harness/orchestration/workflow.py`` module so existing +imports keep working: + +* :mod:`.stages` — data model + YAML parsing (``Workflow``, + ``WorkflowStage``, ``StageResult``, ``load_workflow``). +* :mod:`.engine` — ``WorkflowRunner``: the supervise-cycle driver and + per-stage dispatch/execution. +* :mod:`.review` — chief review / checkpoint / critic-judge refinement / + adversarial debate behaviors. +* :mod:`.recovery` — provider-failure classification, recovery + dispatches, stall rescue, and rollback policy. +* :mod:`.validation` — stage validation, outputs contract, commits, and + the done gate. +""" + +from ..agent import AgentRun, AgentRunner +from ..telemetry import CycleTelemetry +from .engine import WorkflowRunner +from .recovery import RecoveryMixin, is_provider_failure +from .review import ReviewMixin +from .stages import ( + _ROUND_STATUS_RE, + StageResult, + Workflow, + WorkflowStage, + _mini_yaml_loads, + _round_status, + load_workflow, + yaml, +) +from .validation import ValidationMixin + +__all__ = [ + "AgentRun", + "AgentRunner", + "CycleTelemetry", + "RecoveryMixin", + "ReviewMixin", + "StageResult", + "ValidationMixin", + "Workflow", + "WorkflowRunner", + "WorkflowStage", + "_ROUND_STATUS_RE", + "_mini_yaml_loads", + "_round_status", + "is_provider_failure", + "load_workflow", + "yaml", +] diff --git a/clk_harness/orchestration/workflow/engine.py b/clk_harness/orchestration/workflow/engine.py new file mode 100644 index 0000000..ec05f38 --- /dev/null +++ b/clk_harness/orchestration/workflow/engine.py @@ -0,0 +1,702 @@ +"""Workflow runner engine: the supervise-cycle driver and stage dispatch.""" + +from __future__ import annotations + +import time +from concurrent.futures import ThreadPoolExecutor, as_completed +from pathlib import Path +from typing import Any, Dict, List, Optional, Set, Tuple + +from ...config import Paths +from ...git_ops import head_sha, revert_to, snapshot_rollback +from ...log import get_logger, log_exception +from ...utils.activity_log import log_event +from .. import deliberation as _deliberation +from .. import noop_guard as _noop_guard +from .. import response_quality as _response_quality +from ..agent import AgentRun, AgentRunner +from ..telemetry import CycleTelemetry +from .recovery import RecoveryMixin +from .review import ReviewMixin +from .stages import StageResult, Workflow, WorkflowStage, _round_status, load_workflow +from .validation import ValidationMixin + +logger = get_logger(__name__) + + +class WorkflowRunner(ReviewMixin, RecoveryMixin, ValidationMixin): + def __init__(self, paths: Paths, runner: AgentRunner) -> None: + self.paths = paths + self.runner = runner + # When set by the MissionRunner, the per-cycle telemetry object is + # threaded into each stage's dispatch extra so the dispatch-path hooks + # accumulate into the active cycle. When None, ``run`` creates one per + # supervise cycle so standalone ``clk run`` is observable too. + self.telemetry: Optional[CycleTelemetry] = None + # When True, producing dispatches get the deliberation preamble and + # the done-gate / phase semantics lean toward unattended autonomy. + self.mission_mode: bool = False + # When the MissionRunner drives phases, it owns the outer loop, so it + # sets this to 1 to make each WorkflowRunner.run() a single pass. + self.supervise_cycles_override: Optional[int] = None + + # Per-stage retry cap for provider errors (separate from recovery_count + # which handles unmet deps). Uses exponential backoff starting at + # stage_backoff_s. Overridable via clk.config.json::provider_retry. + DEFAULT_MAX_STAGE_RETRIES = 2 + DEFAULT_STAGE_BACKOFF_S = 30.0 + + @property + def max_stage_retries(self) -> int: + cfg = (self.runner.clk_cfg.get("provider_retry") or {}) + return int(cfg.get("stage_max_retries", self.DEFAULT_MAX_STAGE_RETRIES) or self.DEFAULT_MAX_STAGE_RETRIES) + + @property + def stage_backoff_s(self) -> float: + cfg = (self.runner.clk_cfg.get("provider_retry") or {}) + return float(cfg.get("stage_backoff_s", self.DEFAULT_STAGE_BACKOFF_S) or self.DEFAULT_STAGE_BACKOFF_S) + + DEFAULT_MAX_SUPERVISE_CYCLES = 20 + + @property + def max_supervise_cycles(self) -> int: + if self.supervise_cycles_override is not None: + return int(self.supervise_cycles_override) + cfg = (self.runner.clk_cfg.get("supervise") or {}) + return int(cfg.get("max_cycles") or self.DEFAULT_MAX_SUPERVISE_CYCLES) + + @property + def max_consecutive_no_progress(self) -> int: + cfg = (self.runner.clk_cfg.get("supervise") or {}) + return int(cfg.get("max_consecutive_no_progress") or 8) + + def _telemetry_stdout(self) -> bool: + cfg = (self.runner.clk_cfg.get("mission") or {}) + return bool(cfg.get("telemetry_stdout", True)) + + def run(self, workflow: Workflow, *, dry_run: Optional[bool] = None) -> List[StageResult]: + """Execute the workflow, looping it under chief supervision. + + Three dynamic behaviors: + + 1. After each stage we re-check the workflow file's mtime: if a + PROPOSE_WORKFLOW block rewrote it, new stages are spliced in + for the remainder of this cycle. + + 2. If a stage's deps are unmet (because an earlier stage failed + or was skipped), we dispatch the chief in *recovery mode* to + either re-cast the workflow, run a remediation stage, or + explicitly accept the gap. After recovery we re-check deps + and retry the stage. Capped at ``MAX_RECOVERY_PER_STAGE``. + + 3. When the workflow finishes without ``.clk/state/done.md`` + existing, we loop and run the workflow again (the chief's + supervise stage may have rewritten it via PROPOSE_WORKFLOW). + This way no agent is ever truly "done" until the chief signals + ACTION:done. Capped at ``DEFAULT_MAX_SUPERVISE_CYCLES``. + """ + all_results: List[StageResult] = [] + stopped_for_provider_failure = False + stopped_done = False + no_progress = 0 + rescue_attempted = False + for cycle in range(1, self.max_supervise_cycles + 1): + if self._stop_requested(workflow): + logger.info(f"workflow {workflow.name}: stop granted, ending supervise loop") + stopped_done = True + break + + cancel_file = self.paths.state / "cancel_requested.txt" + if cancel_file.exists(): + try: + cancel_file.unlink() + except Exception as _exc: + logger.debug("could not remove cancel marker %s: %s", cancel_file, _exc) + logger.info(f"workflow {workflow.name}: graceful cancel requested; stopping after cycle {cycle - 1}") + break + + if cycle > 1: + logger.info(f"workflow {workflow.name}: supervise cycle {cycle}/{self.max_supervise_cycles}") + # Per-cycle telemetry: when the MissionRunner owns one it is set on + # self.telemetry already; otherwise create one for this cycle so + # standalone `clk run` is observable too (FM5). + owns_telemetry = self.telemetry is None + if owns_telemetry: + self.telemetry = CycleTelemetry( + n=cycle, max_cycles=self.max_supervise_cycles, workflow=workflow.name, + ) + try: + refreshed = load_workflow(self.paths.workflows / f"{workflow.name}.yaml") + except Exception: + refreshed = workflow + cycle_results = self._run_once(refreshed, dry_run=dry_run, cycle=cycle) + all_results.extend(cycle_results) + + # Check for no-progress. Two signals combine: + # * material progress — a commit or file write happened + # * self-report — agents end responses with PROGRESS: yes/no; + # when every reporting agent says "no", the cycle counts as + # stalled even if files were technically touched (busywork). + material = any( + r.committed or bool(r.run.files_written) + for r in cycle_results + if r.run.response.ok + ) + signals = [ + _response_quality.progress_signal(r.run.response.text) + for r in cycle_results + if r.run.response.ok + ] + explicit = [s for s in signals if s is not None] + self_reported_stall = bool(explicit) and not any(explicit) + progress = material and not self_reported_stall + # Emit the per-cycle telemetry line (FM5). When the MissionRunner + # owns the telemetry object it records eval/done-gate and emits + # itself, so only emit here for standalone supervise cycles. + if owns_telemetry and self.telemetry is not None: + self.telemetry.progress = progress + self.telemetry.emit(self.paths, to_stdout=self._telemetry_stdout()) + self.telemetry = None + if not progress: + no_progress += 1 + why = ( + "agents reported PROGRESS: no" + if (material and self_reported_stall) + else "no commits or file writes" + ) + logger.info( + f"workflow {workflow.name}: cycle {cycle} made no progress — {why} " + f"({no_progress}/{self.max_consecutive_no_progress})" if no_progress >= 2 else "INFO", + ) + if no_progress >= self.max_consecutive_no_progress: + if self.stall_rescue_enabled and not rescue_attempted and not dry_run: + rescue_attempted = True + no_progress = 0 + self._dispatch_stall_rescue(workflow, cycle, cycle_results) + if self._stop_requested(workflow): + logger.info(f"workflow {workflow.name}: stop granted during stall rescue") + stopped_done = True + break + continue + logger.error( + f"workflow {workflow.name}: stopping after {no_progress} consecutive " + "no-progress cycles (set supervise.max_consecutive_no_progress to change)", + ) + log_event(self.paths, "workflow_stalled", workflow=workflow.name, + no_progress_cycles=no_progress, + rescue_attempted=rescue_attempted) + break + else: + no_progress = 0 + + if any( + self._is_provider_failure((r.run.response.error or "")) for r in cycle_results if not r.run.response.ok + ): + logger.error( + f"workflow {workflow.name}: stopping supervise cycles after provider failure", + ) + stopped_for_provider_failure = True + break + if dry_run: + break + if ( + not stopped_for_provider_failure + and not stopped_done + and not (self.paths.state / "done_granted.md").exists() + and self.max_supervise_cycles > 1 + ): + logger.warning( + f"workflow {workflow.name}: supervise cycle limit reached " + f"({self.max_supervise_cycles}); type /run to continue or set " + "supervise.max_cycles in clk.config.json", + ) + return all_results + + def _run_once(self, workflow: Workflow, *, dry_run: Optional[bool] = None, cycle: int = 1) -> List[StageResult]: + """Single pass through the workflow; stages with no inter-dependencies run in parallel.""" + logger.info(f"workflow start: {workflow.name} ({len(workflow.stages)} stages)") + results: List[StageResult] = [] + completed: Dict[str, bool] = {} + result_by_id: Dict[str, StageResult] = {} + wf_path = self.paths.workflows / f"{workflow.name}.yaml" + wf_mtime = wf_path.stat().st_mtime if wf_path.exists() else 0.0 + + stages = list(workflow.stages) + recovery_count: Dict[str, int] = {} + stage_retry_count: Dict[str, int] = {} + dispatched: Set[str] = set() # stage ids sent to runner this pass + + max_cycles = self.max_supervise_cycles + cycle_context = f"Supervise cycle {cycle}/{max_cycles} — {max_cycles - cycle + 1} remaining." + + while True: + # Stages not yet dispatched and not yet in completed + pending = [s for s in stages if s.id not in dispatched and s.id not in completed] + if not pending: + break + + ready = [s for s in pending if not self._unmet_deps(s, completed)] + blocked = [s for s in pending if self._unmet_deps(s, completed)] + + if not ready: + # Every remaining stage has unmet deps — recovery dispatch for the first one + stage = blocked[0] + unmet = self._unmet_deps(stage, completed) + tries = recovery_count.get(stage.id, 0) + if tries >= self.max_recovery_per_stage or dry_run: + self._log_skip(stage, unmet, result_by_id) + completed[stage.id] = False + dispatched.add(stage.id) + continue + recovery_count[stage.id] = tries + 1 + self._dispatch_recovery( + workflow, stage, unmet, result_by_id, + dry_run=dry_run, cycle_context=cycle_context, + ) + stages, wf_mtime = self._refresh_from_dispatched( + workflow.name, wf_path, wf_mtime, stages, + dispatched | set(completed), + ) + continue + + # Mark ready stages as dispatched before running so re-entrant + # refreshes don't double-dispatch them. + for s in ready: + dispatched.add(s.id) + + if len(ready) > 1: + logger.info( + f"workflow {workflow.name}: parallel batch " + f"[{', '.join(s.id for s in ready)}]" + ) + else: + logger.info(f"stage {ready[0].id} -> agent {ready[0].agent}") + + # Run: parallel when multiple independent stages are ready + if len(ready) == 1 or dry_run: + batch = [self._run_stage(ready[0], workflow, cycle_context, dry_run, result_by_id)] + else: + with ThreadPoolExecutor(max_workers=len(ready)) as pool: + fmap = { + pool.submit(self._run_stage, s, workflow, cycle_context, dry_run, result_by_id): s + for s in ready + } + batch = [fut.result() for fut in as_completed(fmap)] + + abort = False + for sr in batch: + ok = sr.run.response.ok + if not ok and self._is_provider_failure(sr.run.response.error or ""): + error_msg = sr.run.response.error or "" + st = stage_retry_count.get(sr.stage.id, 0) + 1 + stage_retry_count[sr.stage.id] = st + if self._is_retryable_stage_error(error_msg) and st <= self.max_stage_retries: + wait = self.stage_backoff_s * (2 ** (st - 1)) + logger.warning( + f"workflow {workflow.name}: stage {sr.stage.id} retryable error " + f"(attempt {st}/{self.max_stage_retries}): {error_msg!r}; " + f"backing off {wait:.0f}s", + ) + log_event( + self.paths, "workflow_stage_retry", + agent=sr.stage.agent, workflow=workflow.name, + stage_id=sr.stage.id, attempt=st, + max_retries=self.max_stage_retries, + backoff_s=wait, error=error_msg, + ) + if self.runner.observer is not None: + try: + self.runner.observer.progress( + sr.stage.agent, "retry", + f"stage {sr.stage.id} backing off {wait:.0f}s " + f"(attempt {st}/{self.max_stage_retries}): {error_msg}", + ) + except Exception as _exc: + logger.debug("observer progress failed: %s", _exc) + dispatched.discard(sr.stage.id) + time.sleep(wait) + continue # retry: don't add to results/completed + logger.error( + f"workflow {workflow.name}: aborting after provider failure " + f"in stage {sr.stage.id} (retries exhausted): {error_msg}", + ) + log_event( + self.paths, "workflow_aborted", + agent=sr.stage.agent, workflow=workflow.name, + stage_id=sr.stage.id, reason="provider_failure", error=error_msg, + ) + results.append(sr) + result_by_id[sr.stage.id] = sr + completed[sr.stage.id] = False + abort = True + else: + results.append(sr) + result_by_id[sr.stage.id] = sr + completed[sr.stage.id] = ok and sr.validated + + if abort: + break + + stages, wf_mtime = self._refresh_from_dispatched( + workflow.name, wf_path, wf_mtime, stages, + dispatched | set(completed), + ) + + logger.info(f"workflow done: {workflow.name}") + return results + + def _run_stage( + self, + stage: WorkflowStage, + workflow: Workflow, + cycle_context: str, + dry_run: Optional[bool], + result_by_id: Optional[Dict[str, "StageResult"]] = None, + ) -> StageResult: + """Run a single stage and return its result. + + Handles all the new stage semantics on top of the basic single- + dispatch path: inputs (blackboard filtering), outputs (contract + verification), phase=review (chief review prompt synthesis), + rounds>1 (turn-based re-dispatch with refreshed digest), and + careful=True (extra chief checkpoint after the run). + """ + result_by_id = result_by_id or {} + + pre_stage_sha: Optional[str] = head_sha(self.paths.root) if stage.commit and not dry_run else None + + # Build objective: chief-review stages get a synthesized prompt + # that includes the upstream stages' blackboard posts. + if stage.phase == "review" and stage.depends_on: + objective = self._build_review_objective(workflow, stage, result_by_id) + else: + objective = stage.objective + + # Optional meta-prompt drafting for sensitive (careful) stages or + # when meta_prompt.dispatch is "always". The chief is asked to + # tighten the worker's task prompt; result is cached on disk. + if ( + not dry_run + and stage.agent != "chief" + and stage.phase != "review" + and self._meta_dispatch_enabled(stage) + ): + try: + drafted = self.runner.meta_draft_dispatch_prompt( + agent_name=stage.agent, + base_objective=objective, + blackboard_inputs=list(stage.inputs), + stage_outputs=list(stage.outputs), + ) + if drafted: + objective = drafted + except Exception as exc: + log_exception("orchestration.workflow._run_stage.meta_dispatch", exc) + + # Inputs filter the blackboard digest. Review stages auto-include + # all posts from the stages they depend on. + bb_inputs = list(stage.inputs) + if stage.phase == "review" and not bb_inputs: + bb_inputs = [f"stage:{d}" for d in stage.depends_on] + + base_extra: Dict[str, Any] = { + "stage_id": stage.id, + "workflow": workflow.name, + "cycle_context": cycle_context, + "blackboard_inputs": bb_inputs, + "stage_outputs": list(stage.outputs), + # Carried for the no-op guard (commit=producing) and the telemetry + # hooks on the dispatch path. + "commit": bool(stage.commit), + "telemetry": self.telemetry, + } + if stage.phase: + base_extra["phase"] = stage.phase + + # Deliberation: in mission mode, prepend the self-reflect + ask-peers + # preamble to producing dispatches so the team "thinks" before acting. + if ( + not dry_run + and self.mission_mode + and stage.phase != "review" + and _deliberation.enabled(self.runner.clk_cfg) + and _noop_guard.is_mutation_expected( + stage.agent, outputs=stage.outputs, commit=stage.commit, + cfg=self.runner.clk_cfg, + ) + ): + preamble = _deliberation.dispatch_preamble(self.runner.clk_cfg) + if preamble: + objective = preamble + objective + + stop_when_file = self.paths.state / "stop_when.txt" + stop_when = stop_when_file.read_text(encoding="utf-8").strip() if stop_when_file.exists() else "" + if stop_when: + base_extra["stop_when"] = stop_when + + # Turn-based rounds: keep dispatching until the worker emits + # ROUND_STATUS: done (or absent), or the round cap is reached. + rounds_max = max(1, int(stage.rounds or 1)) + run: Optional[AgentRun] = None + for round_idx in range(1, rounds_max + 1): + extra = dict(base_extra) + extra["round"] = round_idx + extra["rounds_total"] = rounds_max + if round_idx == 1: + round_objective = objective + else: + round_objective = ( + f"Round {round_idx}/{rounds_max} of stage `{stage.id}`.\n\n" + "Sibling agents may have posted to the blackboard since your last " + "round; the digest above has the latest. Continue your work, " + "post new findings via POST blocks, and emit `ROUND_STATUS: done` " + "in your final round (or `ROUND_STATUS: continue` to request " + "another round before the cap).\n\n" + f"Original objective:\n{objective}" + ) + run = self.runner.run( + stage.agent, + round_objective, + extra=extra, + dry_run=dry_run, + ) + if rounds_max == 1: + break + status = _round_status(run.response.text or "") + log_event( + self.paths, + "workflow_round_complete", + agent=stage.agent, + workflow=workflow.name, + stage_id=stage.id, + round=round_idx, + rounds_total=rounds_max, + round_status=status, + ok=run.response.ok, + ) + if status == "done" or not run.response.ok: + break + + assert run is not None # the loop runs at least once + + # Critic-judge refinement loop. When the stage opts in + # (explicit ``refine:`` block or careful=true under the default + # auto_refine policy), dispatch a critic agent to score the + # response; if the critic says revise, re-dispatch the worker + # with the critic's feedback until accept or max_rounds. + if not dry_run and run.response.ok and self._debate_enabled(stage): + # Adversarial debate panel takes precedence over the single critic. + try: + run = self._debate_loop(workflow, stage, run, cycle_context, dry_run) + except Exception as exc: + log_exception("orchestration.workflow._run_stage.debate", exc) + elif not dry_run and run.response.ok and self._refine_enabled(stage): + try: + run = self._refine_loop(workflow, stage, run, cycle_context, dry_run) + except Exception as exc: + log_exception("orchestration.workflow._run_stage.refine", exc) + + ok = run.response.ok + if dry_run: + v_ok, v_out = True, "(dry-run: validation skipped)" + else: + v_ok, v_out = self._validate(stage) + + # Outputs contract: warn when the stage's promised POST keys never + # landed, then give the chief one recovery pass to fill the gap + # (re-dispatch the worker, post a substitute, or accept it) so + # downstream stages don't silently consume missing inputs. + unmet_outputs = self._check_outputs_contract(stage) + if unmet_outputs: + logger.warning( + f"workflow {workflow.name}: stage {stage.id} did not satisfy " + f"declared outputs: {unmet_outputs}", + ) + log_event( + self.paths, + "workflow_outputs_unmet", + agent=stage.agent, + workflow=workflow.name, + stage_id=stage.id, + expected=list(stage.outputs), + missing=list(unmet_outputs), + ) + # Only when the stage otherwise succeeded: a failed response or + # failed validation already keeps the stage incomplete (and may + # roll back), so a recovery pass here couldn't unblock anything. + if ( + not dry_run + and ok + and v_ok + and stage.agent != "chief" + and self._outputs_recovery_enabled + ): + try: + self._dispatch_outputs_recovery( + workflow, stage, unmet_outputs, cycle_context, dry_run + ) + # Re-check: the chief may have posted the missing keys + # (or had the worker do it) during the recovery pass. + unmet_outputs = self._check_outputs_contract(stage) + except Exception as exc: + log_exception("orchestration.workflow._run_stage.outputs_recovery", exc) + + committed = False + if run.committed: + committed = True + elif ok and v_ok and stage.commit and not dry_run: + committed = self._commit(workflow, stage, run, v_out) + + if not v_ok and pre_stage_sha and not dry_run: + if self._should_rollback(stage): + logger.warning(f"stage {stage.id}: validation failed; rolling back to {pre_stage_sha[:8]}") + log_event(self.paths, "workflow_stage_rollback", + agent=stage.agent, workflow=workflow.name, + stage_id=stage.id, sha=pre_stage_sha) + # Snapshot the about-to-be-discarded work behind a ref so a + # hard reset never makes it unrecoverable (batch commits + # would otherwise dangle and eventually be GC'd). + snapshot_rollback(self.paths.root, stage.id) + # Verify the rollback actually landed: a silently-failed git + # reset would leave broken state on disk while the runner + # believes it recovered. + rolled_back = revert_to(self.paths.root, pre_stage_sha) + post_sha = head_sha(self.paths.root) if rolled_back else None + if rolled_back and post_sha == pre_stage_sha: + committed = False + else: + logger.error( + f"stage {stage.id}: rollback to {pre_stage_sha[:8]} FAILED " + f"(HEAD is {(post_sha or 'unknown')[:8]}); workspace may " + "contain unvalidated changes", + ) + log_event(self.paths, "workflow_rollback_failed", + agent=stage.agent, workflow=workflow.name, + stage_id=stage.id, expected_sha=pre_stage_sha, + actual_sha=post_sha or "") + else: + # Default for ordinary stages: keep the work in place. The + # failure is recorded on the StageResult and the supervise / + # qa loop repairs forward — a hard reset here would delete + # batch-committed files from disk (and the Files tab). + logger.warning( + f"stage {stage.id}: validation failed; keeping work in place " + "(validation.rollback_on_failure)", + ) + log_event(self.paths, "workflow_rollback_skipped", + agent=stage.agent, workflow=workflow.name, + stage_id=stage.id, sha=pre_stage_sha) + + failure_reason = "" + if not ok: + failure_reason = (run.response.error or "agent_failed")[:200] + elif not v_ok: + failure_reason = f"validation_failed: {v_out[:200]}" if v_out else "validation_failed" + elif unmet_outputs: + # Soft-fail tag — does not unset stage completion but visible + # in the result for downstream consumers. + failure_reason = f"outputs_unmet: {','.join(unmet_outputs)[:160]}" + + result = StageResult( + stage=stage, + run=run, + validated=v_ok, + validation_output=v_out, + committed=committed, + failure_reason=failure_reason, + ) + if self.telemetry is not None: + try: + self.telemetry.record_stage(ok=bool(ok and v_ok)) + except Exception as _exc: + logger.debug("telemetry record_stage failed: %s", _exc) + + # Per-stage chief checkpoint for sensitive stages. Cheap, gated, + # and never blocks: it just keeps the chief in the loop without + # waiting for the next supervise cycle. + if ( + ok + and v_ok + and not dry_run + and self._checkpoint_enabled(stage) + and stage.agent != "chief" # avoid recursion on review/checkpoint stages + ): + try: + self._dispatch_checkpoint(workflow, stage, result, cycle_context, dry_run) + except Exception as exc: + log_exception("orchestration.workflow._run_stage.checkpoint", exc) + + return result + + # -- helpers --------------------------------------------------------- + + def _unmet_deps(self, stage: WorkflowStage, completed: Dict[str, bool]) -> List[str]: + return [d for d in stage.depends_on if not completed.get(d)] + + def _refresh_from_dispatched( + self, + workflow_name: str, + wf_path: Path, + prev_mtime: float, + stages: List[WorkflowStage], + done_ids: Set[str], + ) -> Tuple[List[WorkflowStage], float]: + """Reload the workflow file if rewritten; splice in any new stages + whose ids haven't been dispatched yet so dynamically-added stages + are picked up without re-running already-dispatched ones. + """ + if not wf_path.exists(): + return stages, prev_mtime + new_mtime = wf_path.stat().st_mtime + if new_mtime <= prev_mtime: + return stages, prev_mtime + try: + refreshed = load_workflow(wf_path) + except Exception as exc: + log_exception("orchestration.workflow._refresh_from_dispatched", exc) + return stages, prev_mtime + kept = [s for s in stages if s.id in done_ids] + new_pending = [s for s in refreshed.stages if s.id not in done_ids] + merged = kept + new_pending + logger.info( + f"workflow {workflow_name}: refreshed; " + f"{len(kept)} done, {len(new_pending)} pending" + ) + return merged, new_mtime + + def _maybe_refresh_workflow( + self, + workflow_name: str, + wf_path: Path, + prev_mtime: float, + stages: List[WorkflowStage], + cursor: int, + ) -> Tuple[List[WorkflowStage], float]: + """If the workflow file was rewritten, replace the un-processed + tail of the queue with the refreshed stage list. + + ``cursor`` is the index the runner is about to process next. + Stages already executed (``stages[:cursor]``) are preserved so + we never re-run them. Stages from the refreshed YAML whose ids + appear in the executed prefix are dropped (the agent should use + a new id like ``foo_retry`` if they want to re-attempt). + """ + if not wf_path.exists(): + return stages, prev_mtime + new_mtime = wf_path.stat().st_mtime + if new_mtime <= prev_mtime: + return stages, prev_mtime + try: + refreshed = load_workflow(wf_path) + except Exception as exc: + log_exception("orchestration.workflow._maybe_refresh_workflow", exc) + return stages, prev_mtime + processed = stages[:cursor] + processed_ids = {s.id for s in processed} + pending = [s for s in refreshed.stages if s.id not in processed_ids] + merged = processed + pending + logger.info( + f"workflow {workflow_name}: refreshed; " + f"{len(processed)} processed, {len(pending)} pending" + ) + return merged, new_mtime diff --git a/clk_harness/orchestration/workflow/recovery.py b/clk_harness/orchestration/workflow/recovery.py new file mode 100644 index 0000000..444f633 --- /dev/null +++ b/clk_harness/orchestration/workflow/recovery.py @@ -0,0 +1,294 @@ +"""Recovery behaviors for the workflow runner. + +Provider-failure classification, chief recovery dispatches for unmet +dependencies and unsatisfied output contracts, the one-shot stall +rescue, and the validation-failure rollback policy. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Dict, List, Optional + +from ...log import get_logger, log_exception +from ...utils.activity_log import log_event + +if TYPE_CHECKING: + from ...config import Paths + from ..agent import AgentRunner + from .stages import StageResult, Workflow, WorkflowStage + +logger = get_logger(__name__) + + +def is_provider_failure(error: str) -> bool: + """Return True for failures a downstream agent cannot fix.""" + msg = (error or "").lower() + patterns = [ + "no endpoints available", + "guardrail restrictions", + "data policy", + "api key", + "cli not found", + "not found", + "authentication", + "unauthorized", + "forbidden", + "rate limit", + "quota", + "operation was aborted", + "timeout after", + "no output for", + ] + return any(p in msg for p in patterns) + + +class RecoveryMixin: + """Recovery / rescue / rollback methods mixed into ``WorkflowRunner``.""" + + paths: "Paths" + runner: "AgentRunner" + + # Default cap on chief recovery dispatches per stage. A stage that + # still has unmet deps after this many recovery passes gets a final + # WARN and is skipped, so we never loop forever on a stuck workflow. + # Overridable via clk.config.json::recovery::max_per_stage. + DEFAULT_MAX_RECOVERY_PER_STAGE = 3 + + @property + def max_recovery_per_stage(self) -> int: + cfg = (self.runner.clk_cfg.get("recovery") or {}) + return int(cfg.get("max_per_stage") or self.DEFAULT_MAX_RECOVERY_PER_STAGE) + + @property + def stall_rescue_enabled(self) -> bool: + """When True, hitting the no-progress cap dispatches the chief once + in *rescue mode* (restructure the plan or declare done) before the + loop gives up. Overridable via clk.config.json::supervise::stall_rescue. + """ + cfg = (self.runner.clk_cfg.get("supervise") or {}) + val = cfg.get("stall_rescue", True) + return str(val).lower() not in ("false", "0", "off", "no") + + def _should_rollback(self, stage: "WorkflowStage") -> bool: + """Whether a failed validation hard-resets the stage's work. + + Policy via clk.config.json::validation::rollback_on_failure: + ``never`` keeps the work; ``careful`` (default) rolls back only + stages marked careful=true; ``always`` is the legacy behavior. + """ + cfg = (self.runner.clk_cfg.get("validation") or {}) + policy = str(cfg.get("rollback_on_failure", "careful")).lower() + if policy == "always": + return True + if policy == "never": + return False + return bool(stage.careful) + + def _is_provider_failure(self, error: str) -> bool: + return is_provider_failure(error) + + def _is_retryable_stage_error(self, error: str) -> bool: + """Subset of provider failures worth retrying with backoff at stage level.""" + msg = (error or "").lower() + retryable = [ + "no output for", + "timeout after", + "operation was aborted", + "no endpoints available", + "guardrail restrictions", + "data policy", + "connection reset", + "temporarily unavailable", + "try again", + "rate limit", + "quota", + # HTTP 429 rate-limiting and HTTP 404 (OpenRouter: no endpoints temporarily available) + "http 429", + "http 404", + ] + non_retryable = [ + "api key", + "authentication", + "unauthorized", + "forbidden", + "cli not found", + ] + return any(s in msg for s in retryable) and not any(s in msg for s in non_retryable) + + def _log_skip( + self, + stage: "WorkflowStage", + unmet: List[str], + result_by_id: Dict[str, "StageResult"], + ) -> None: + details: List[str] = [] + for d in unmet: + sr = result_by_id.get(d) + if sr is None: + details.append(f"{d}=never_ran") + elif sr.failure_reason: + details.append(f"{d}={sr.failure_reason}") + else: + details.append(f"{d}=incomplete") + logger.warning( + f"stage {stage.id} skipped after recovery limit: " + "; ".join(details), + ) + + def _dispatch_recovery( + self, + workflow: "Workflow", + stage: "WorkflowStage", + unmet: List[str], + result_by_id: Dict[str, "StageResult"], + *, + dry_run: Optional[bool], + cycle_context: str = "", + ) -> None: + details: List[str] = [] + for d in unmet: + sr = result_by_id.get(d) + if sr is None: + details.append(f"- `{d}`: never ran (probably never reached or removed from workflow)") + elif sr.failure_reason: + details.append(f"- `{d}`: {sr.failure_reason}") + else: + details.append(f"- `{d}`: incomplete (no failure recorded)") + objective = ( + f"Recovery dispatch for workflow `{workflow.name}` stage `{stage.id}`.\n\n" + f"This stage depends on: {stage.depends_on}.\n" + f"Unmet dependencies (with reasons):\n" + "\n".join(details) + "\n\n" + "Decide one of:\n" + " (a) Re-cast the workflow with PROPOSE_WORKFLOW so the dependency is\n" + " no longer required, OR\n" + " (b) Emit ACTION blocks that fix the upstream failure (write/edit/run\n" + " to satisfy the failed validation), OR\n" + " (c) dispatch an existing suitable agent, or PROPOSE_ROLE for a\n" + " distinct specialist if no current agent fits (b).\n" + "Do NOT skip silently. The harness will retry this stage after you respond." + ) + logger.info(f"workflow {workflow.name}: dispatching chief recovery for stage {stage.id}") + self.runner.run( + "chief", + objective, + extra={ + "phase": "recovery", + "workflow": workflow.name, + "stage_id": stage.id, + "unmet_deps": ",".join(unmet), + "cycle_context": cycle_context, + }, + dry_run=dry_run, + ) + + @property + def _outputs_recovery_enabled(self) -> bool: + """Gate for the outputs-contract recovery dispatch. Defaults on; + disable via clk.config.json::recovery::dispatch_on_unmet_outputs. + """ + cfg = (self.runner.clk_cfg.get("recovery") or {}) + val = cfg.get("dispatch_on_unmet_outputs", True) + return str(val).lower() not in ("false", "0", "off", "no") + + def _dispatch_outputs_recovery( + self, + workflow: "Workflow", + stage: "WorkflowStage", + missing: List[str], + cycle_context: str, + dry_run: Optional[bool], + ) -> None: + """Chief recovery pass for an unsatisfied outputs contract. + + Runs once per stage execution (the caller re-checks the contract + afterwards). The chief can re-dispatch the worker via a Q&A-style + instruction, post the missing keys itself if the information is + already on the blackboard, or explicitly accept the gap. + """ + objective = ( + f"Outputs-contract recovery for workflow `{workflow.name}` " + f"stage `{stage.id}` (agent {stage.agent}).\n\n" + f"The stage declared it would produce these blackboard keys but " + f"did not: {', '.join(missing)}.\n" + f"Stage objective was:\n{stage.objective}\n\n" + "Downstream stages consume these keys; missing them causes silent " + "data gaps. Do one of:\n" + " (a) Post the missing keys yourself (POST block with PRODUCES:\n" + " listing them) if the information already exists on the\n" + " blackboard or in the repo, OR\n" + " (b) Emit ACTION blocks that produce the artifact the keys\n" + " describe, then POST with the keys, OR\n" + " (c) Explicitly accept the gap in a POST: review block stating\n" + " why downstream stages can proceed without these keys.\n" + "Do NOT skip silently." + ) + logger.info( + f"workflow {workflow.name}: dispatching chief outputs recovery " + f"for stage {stage.id} (missing: {', '.join(missing)})" + ) + log_event( + self.paths, "workflow_outputs_recovery", + agent=stage.agent, workflow=workflow.name, + stage_id=stage.id, missing=list(missing), + ) + self.runner.run( + "chief", + objective, + extra={ + "phase": "recovery", + "workflow": workflow.name, + "stage_id": stage.id, + "cycle_context": cycle_context, + "blackboard_inputs": [f"stage:{stage.id}"], + }, + dry_run=dry_run, + ) + + def _dispatch_stall_rescue( + self, + workflow: "Workflow", + cycle: int, + cycle_results: List["StageResult"], + ) -> None: + """One-shot chief dispatch when the supervise loop stalls. + + Instead of silently giving up after N no-progress cycles, give the + chief the stall evidence and a chance to (a) declare the project + done, (b) restructure the plan via PROPOSE_WORKFLOW, or (c) emit + ACTION blocks that unblock the workers directly. Runs at most once + per supervise loop (the caller tracks ``rescue_attempted``). + """ + lines: List[str] = [] + for r in cycle_results[-8:]: + ok = r.run.response.ok + reason = r.failure_reason or ("ok" if ok else "failed") + lines.append(f"- stage `{r.stage.id}` (agent {r.stage.agent}): {reason}") + objective = ( + f"STALL RESCUE for workflow `{workflow.name}` at supervise cycle {cycle}.\n\n" + "The loop has made no measurable progress for several consecutive " + "cycles (no commits, no file writes, or every agent self-reported " + "`PROGRESS: no`). Last cycle's stages:\n" + + "\n".join(lines or ["- (no stage results recorded)"]) + + "\n\nDiagnose WHY the loop is stuck, then do exactly one of:\n" + " (a) ACTION:done with REASON — if the user's objective is actually\n" + " complete and the loop is spinning on nothing.\n" + " (b) PROPOSE_WORKFLOW with a restructured plan that removes the\n" + " blocked stages and takes a genuinely different approach.\n" + " (c) ACTION blocks (write/edit/run) that directly fix the blocker\n" + " the workers keep hitting.\n" + "Do NOT re-propose the same plan that is already stalling. This is " + "the loop's last chance before the harness stops it." + ) + logger.warning(f"workflow {workflow.name}: dispatching chief stall rescue (cycle {cycle})") + log_event(self.paths, "workflow_stall_rescue", workflow=workflow.name, cycle=cycle) + try: + self.runner.run( + "chief", + objective, + extra={ + "phase": "recovery", + "workflow": workflow.name, + "stage_id": "stall_rescue", + }, + ) + except Exception as exc: + log_exception("orchestration.workflow._dispatch_stall_rescue", exc) diff --git a/clk_harness/orchestration/workflow/review.py b/clk_harness/orchestration/workflow/review.py new file mode 100644 index 0000000..651f87f --- /dev/null +++ b/clk_harness/orchestration/workflow/review.py @@ -0,0 +1,602 @@ +"""Review behaviors for the workflow runner. + +Chief-review prompt synthesis, per-stage checkpoints, the critic-judge +refinement loop, and the adversarial debate panel. +""" + +from __future__ import annotations + +import re +from concurrent.futures import ThreadPoolExecutor, as_completed +from typing import TYPE_CHECKING, Dict, List, Optional, Tuple + +from ...log import get_logger, log_exception +from ...utils.activity_log import log_event +from .. import blackboard as _blackboard + +if TYPE_CHECKING: + from ...config import Paths + from ..agent import AgentRun, AgentRunner + from ..telemetry import CycleTelemetry + from .stages import StageResult, Workflow, WorkflowStage + +logger = get_logger(__name__) + + +class ReviewMixin: + """Review / refinement / checkpoint methods mixed into ``WorkflowRunner``.""" + + paths: "Paths" + runner: "AgentRunner" + telemetry: Optional["CycleTelemetry"] + + def _build_review_objective( + self, + workflow: "Workflow", + stage: "WorkflowStage", + result_by_id: Dict[str, "StageResult"], + ) -> str: + """Render the chief-review prompt for ``stage`` using upstream stages' + actual posts so the chief reads the real artifacts, not the + worker's self-report. + """ + try: + all_posts = _blackboard.list_posts(self.paths) + except Exception: + all_posts = [] + upstream_ids = list(stage.depends_on) + sections: List[str] = [ + f"Review dispatch for workflow `{workflow.name}` stage `{stage.id}`.", + "", + "You are reviewing the output of these upstream stages:", + ] + for sid in upstream_ids: + sr = result_by_id.get(sid) + if sr is None: + sections.append(f"- `{sid}`: (no result on record)") + continue + agent = sr.stage.agent + ok = sr.run.response.ok + v_ok = sr.validated + reason = sr.failure_reason or "" + sections.append( + f"- `{sid}` (agent {agent}): ok={ok} validated={v_ok} " + + (f"reason={reason}" if reason else "no failure") + ) + sections.append("") + sections.append("Blackboard posts produced by these stages:") + any_posts = False + for p in all_posts: + if p.stage_id in upstream_ids: + any_posts = True + body = (p.body or "").strip() + if len(body) > 1200: + body = body[:1200].rstrip() + " …" + sections.append( + f"\n--- post id={p.id} author={p.author} type={p.post_type} " + f"stage={p.stage_id} produces={','.join(p.produces) or '-'} ---" + ) + sections.append(body or "(empty body)") + if not any_posts: + sections.append("(no blackboard posts from upstream — workers may have skipped POST.)") + sections.append("") + sections.append( + "Decide one of:\n" + " (a) ACTION:done with REASON — the user's prompt is fully addressed.\n" + " (b) PROPOSE_WORKFLOW with a refined next iteration (always include\n" + " a final supervise stage so the loop continues).\n" + " (c) PROPOSE_CONSENSUS to re-sample a specific decision when the\n" + " upstream results disagree or seem unreliable.\n" + "Also emit a brief POST: review block summarizing what passed, what\n" + "needs more work, and the chosen path." + ) + if stage.objective: + sections.append("") + sections.append("Review-stage author's objective (from the workflow YAML):") + sections.append(stage.objective) + return "\n".join(sections) + + @property + def _checkpoint_default_per_stage(self) -> bool: + cfg = (self.runner.clk_cfg.get("review") or {}) + return bool(cfg.get("per_stage", False)) + + def _checkpoint_enabled(self, stage: "WorkflowStage") -> bool: + if stage.careful: + return True + return self._checkpoint_default_per_stage + + def _meta_dispatch_enabled(self, stage: "WorkflowStage") -> bool: + cfg = (self.runner.clk_cfg.get("meta_prompt") or {}) + mode = str(cfg.get("dispatch") or "off").lower() + if mode in ("", "off", "false", "0"): + return False + if mode == "always": + return True + # default mode "careful_only" + return bool(stage.careful) + + # -- critic-judge refinement (Layer 3 robustness loop) --------------- + + def _refine_enabled(self, stage: "WorkflowStage") -> bool: + """Decide whether the critic-judge refinement loop should run. + + Explicit ``refine:`` on the stage always wins. Otherwise we + fall back to ``robustness.auto_refine`` (off | careful_only | + all). ``chief`` and ``qa`` agents are skipped to avoid the + critic critiquing its own coalescing output or the validator. + """ + if stage.agent in ("chief", "qa", "critic"): + return False + if stage.refine is not None: + return True + cfg = (self.runner.clk_cfg.get("robustness") or {}) + mode = str(cfg.get("auto_refine") or "off").lower() + if mode in ("", "off", "false", "0"): + return False + if mode == "all": + return True + # default mode "careful_only" + return bool(stage.careful) + + def _refine_loop( + self, + workflow: "Workflow", + stage: "WorkflowStage", + first_run: "AgentRun", + cycle_context: str, + dry_run: Optional[bool], + ) -> "AgentRun": + """Run draft → critic → revise until accept or max_rounds. + + Reuses the runner's existing dispatch path for both the critic + and the revised worker. The critic is dispatched in a ``phase: + refine_critic`` extra so the wrapper's auto-consensus and + quality-retry layers don't recurse. + + Returns the final worker run — either the revised one or the + original when the critic accepts immediately. + """ + defaults = (self.runner.clk_cfg.get("robustness") or {}) + cfg = dict(stage.refine or {}) + critic_name = str(cfg.get("critic") or "critic") + try: + max_rounds = int(cfg.get("max_rounds") or defaults.get("refine_max_rounds") or 3) + except (TypeError, ValueError): + max_rounds = 3 + try: + threshold = float(cfg.get("accept_threshold") or defaults.get("refine_accept_threshold") or 0.8) + except (TypeError, ValueError): + threshold = 0.8 + + # If the named critic isn't in the roster, fall back to the + # `critic` baseline; if even that is missing, skip silently. + agents_cfg = (self.runner.agents_cfg.get("agents") or {}) + if critic_name not in agents_cfg: + critic_name = "critic" if "critic" in agents_cfg else "" + if not critic_name: + return first_run + + current_run = first_run + for round_idx in range(1, max_rounds + 1): + if self.telemetry is not None: + try: + self.telemetry.add_refine_round() + except Exception as _exc: + logger.debug("telemetry add_refine_round failed: %s", _exc) + verdict, judge_score, feedback = self._dispatch_critic( + workflow, stage, current_run, critic_name, round_idx, max_rounds, dry_run, + ) + log_event( + self.paths, + "refine_critic_verdict", + agent=stage.agent, + critic=critic_name, + workflow=workflow.name, + stage_id=stage.id, + round=round_idx, + max_rounds=max_rounds, + verdict=verdict, + score=judge_score, + accept_threshold=threshold, + ) + self.runner._observer_log( + f"refine :: {stage.id} :: round {round_idx}/{max_rounds} " + f"{critic_name}→ verdict={verdict} score={judge_score:.2f}" + ) + if verdict == "accept" or judge_score >= threshold: + return current_run + if round_idx == max_rounds: + # Out of budget — keep the latest worker output even + # though the critic isn't satisfied. + return current_run + revise_objective = ( + f"Refinement round {round_idx + 1}/{max_rounds} of stage " + f"`{stage.id}`. The critic (`{critic_name}`) scored your " + f"previous response {judge_score:.2f}/1.0 and asked for " + "revisions:\n\n" + f"{feedback}\n\n" + "Revise the response so the critic's points are addressed. " + "Keep what already works; rewrite only what was flagged. " + "Re-emit POST and ACTION blocks the same way you did the " + "first time so the harness can record the updated work.\n\n" + f"Original objective:\n{stage.objective}" + ) + current_run = self.runner.run( + stage.agent, + revise_objective, + extra={ + "phase": "refine_worker", + "stage_id": stage.id, + "workflow": workflow.name, + "cycle_context": cycle_context, + "blackboard_inputs": list(stage.inputs), + "stage_outputs": list(stage.outputs), + "refine_round": round_idx + 1, + "refine_max_rounds": max_rounds, + "telemetry": self.telemetry, + }, + dry_run=dry_run, + ) + if not current_run.response.ok: + return current_run + return current_run + + _REFINE_VERDICT_RE = re.compile( + r"^\s*VERDICT\s*:\s*(accept|revise|reject)\b", re.IGNORECASE | re.MULTILINE, + ) + _REFINE_SCORE_RE = re.compile( + r"^\s*SCORE\s*:\s*([0-9]*\.?[0-9]+)", re.IGNORECASE | re.MULTILINE, + ) + + def _dispatch_critic( + self, + workflow: "Workflow", + stage: "WorkflowStage", + worker_run: "AgentRun", + critic_name: str, + round_idx: int, + max_rounds: int, + dry_run: Optional[bool], + ) -> Tuple[str, float, str]: + """Run one critic pass; return ``(verdict, score, feedback)``. + + ``verdict`` is normalised to ``"accept"`` or ``"revise"``. + ``score`` is parsed from the critic's ``SCORE: <0..1>`` line and + defaults to 0.0 (i.e. "revise") when missing. + ``feedback`` is the critic's full response text, used verbatim + in the revision objective. + """ + worker_text = (worker_run.response.text or "").strip() + if len(worker_text) > 4000: + worker_text = worker_text[:4000].rstrip() + "\n…(truncated)" + outputs_text = ( + ", ".join(stage.outputs) if stage.outputs else "(no declared outputs)" + ) + critic_objective = ( + f"Refinement-loop critic pass for workflow `{workflow.name}` " + f"stage `{stage.id}` (round {round_idx}/{max_rounds}).\n\n" + f"Worker: `{stage.agent}`\n" + f"Worker's objective:\n{stage.objective}\n\n" + f"Declared output contract keys: {outputs_text}\n\n" + f"Worker's response:\n---\n{worker_text}\n---\n\n" + "Score the response 0..1 against the objective and the " + "declared output contract. List concrete, specific " + "revisions the worker should make. Be brief — three to six " + "bullets is plenty. End your response with exactly two " + "lines:\n" + "VERDICT: accept # or `revise` if any item must change\n" + "SCORE: <0..1>\n" + ) + critic_run = self.runner.run( + critic_name, + critic_objective, + extra={ + "phase": "refine_critic", + "stage_id": stage.id, + "workflow": workflow.name, + "refine_round": round_idx, + }, + dry_run=dry_run, + ) + text = critic_run.response.text or "" + verdict_m = self._REFINE_VERDICT_RE.search(text) + verdict = (verdict_m.group(1).lower() if verdict_m else "revise") + if verdict not in ("accept", "revise"): + verdict = "revise" + score_m = self._REFINE_SCORE_RE.search(text) + try: + score_val = float(score_m.group(1)) if score_m else 0.0 + except (TypeError, ValueError): + score_val = 0.0 + score_val = max(0.0, min(1.0, score_val)) + # When the critic accepted but didn't post a score, treat it as + # a confident pass; when it asked to revise but didn't score, + # treat as a moderate-low score so the loop continues. + if score_m is None: + score_val = 1.0 if verdict == "accept" else 0.4 + return verdict, score_val, text.strip() + + # -- adversarial debate panel (multi-critic refinement) -------------- + + _DEBATE_LENS_GUIDANCE: Dict[str, str] = { + "correctness": "logic errors, wrong outputs, unhandled edge cases, broken contracts or APIs.", + "security": "injection, unsafe input handling, secret/credential leakage, unsafe shell/file operations.", + "simplicity": "needless complexity, duplication, dead code, and simpler equivalent designs.", + "performance": "obvious inefficiency, redundant work, N+1 patterns, unbounded loops or memory.", + "robustness": "failure modes, missing error handling, flaky assumptions, and race conditions.", + "tests": "missing or weak tests, untested branches, and assertions that don't actually verify behavior.", + "ux": "confusing interfaces, poor error messages, and undocumented behavior.", + } + + def _debate_enabled(self, stage: "WorkflowStage") -> bool: + """Whether the adversarial debate panel should run for this stage. + + Explicit ``refine: {mode: debate}`` always wins; otherwise the + ``robustness.debate`` policy (off | careful_only | all) decides. + chief / qa / critic stages are skipped. + """ + if stage.agent in ("chief", "qa", "critic"): + return False + if isinstance(stage.refine, dict) and str(stage.refine.get("mode") or "").lower() == "debate": + return True + cfg = (self.runner.clk_cfg.get("robustness") or {}) + mode = str(cfg.get("debate") or "off").lower() + if mode in ("", "off", "false", "0"): + return False + if mode == "all": + return True + return bool(stage.careful) # careful_only + + def _debate_lenses(self, stage: "WorkflowStage") -> List[str]: + if isinstance(stage.refine, dict) and stage.refine.get("critics"): + lenses = [str(x).strip().lower() for x in stage.refine["critics"] if str(x).strip()] + else: + cfg = (self.runner.clk_cfg.get("robustness") or {}) + lenses = [str(x).strip().lower() for x in (cfg.get("debate_lenses") or []) if str(x).strip()] + return lenses or ["correctness", "security", "simplicity"] + + def _dispatch_lens_critic( + self, + workflow: "Workflow", + stage: "WorkflowStage", + worker_run: "AgentRun", + critic_name: str, + lens: str, + round_idx: int, + max_rounds: int, + peer_transcript: str, + dry_run: Optional[bool], + ) -> Tuple[str, str, float, str]: + """One adversarial critic pass for a single lens. + + Returns ``(lens, verdict, score, feedback)``. The critic is told to + try to *break* the work from its lens and, in later rounds, to engage + with peers' critiques (reinforce / refute / concede). + """ + worker_text = (worker_run.response.text or "").strip() + if len(worker_text) > 3500: + worker_text = worker_text[:3500].rstrip() + "\n…(truncated)" + guidance = self._DEBATE_LENS_GUIDANCE.get( + lens, f"weaknesses from the {lens} perspective." + ) + peer_block = "" + if peer_transcript.strip(): + peer_block = ( + "\nYour fellow panelists said (engage with them — reinforce, " + "refute, or concede explicitly):\n" + f"{peer_transcript}\n" + ) + objective = ( + f"ADVERSARIAL DEBATE — you are the **{lens}** critic on a review " + f"panel for stage `{stage.id}` (round {round_idx}/{max_rounds}).\n\n" + f"Your lens: hunt for {guidance}\n" + "Try hard to BREAK this work from your lens. Be specific and " + "concrete; cite the exact place. Default to skepticism — only " + "accept if you genuinely cannot find a real problem.\n\n" + f"Worker `{stage.agent}` objective:\n{stage.objective}\n\n" + f"Worker's response:\n---\n{worker_text}\n---\n" + f"{peer_block}\n" + "Keep it to 2-5 concrete bullets. End with exactly two lines:\n" + "VERDICT: accept # or `revise` if any real issue remains\n" + "SCORE: <0..1>\n" + ) + critic_run = self.runner.run( + critic_name, + objective, + extra={ + "phase": "refine_critic", + "stage_id": stage.id, + "workflow": workflow.name, + "refine_round": round_idx, + "debate_lens": lens, + }, + dry_run=dry_run, + ) + text = critic_run.response.text or "" + verdict_m = self._REFINE_VERDICT_RE.search(text) + verdict = (verdict_m.group(1).lower() if verdict_m else "revise") + if verdict not in ("accept", "revise"): + verdict = "revise" + score_m = self._REFINE_SCORE_RE.search(text) + try: + score_val = float(score_m.group(1)) if score_m else (1.0 if verdict == "accept" else 0.4) + except (TypeError, ValueError): + score_val = 0.4 + score_val = max(0.0, min(1.0, score_val)) + return lens, verdict, score_val, text.strip() + + def _debate_loop( + self, + workflow: "Workflow", + stage: "WorkflowStage", + first_run: "AgentRun", + cycle_context: str, + dry_run: Optional[bool], + ) -> "AgentRun": + """Run an adversarial debate panel: N lens-critics → worker revision. + + Each round fans out one critic per lens in parallel; the worker is + kept only if a majority of lenses accept (or the mean score clears the + threshold). Otherwise the combined critiques drive a revision, and the + next round's critics see the prior panel transcript so they can debate + each other. Bounded by ``debate_max_rounds``. + """ + defaults = (self.runner.clk_cfg.get("robustness") or {}) + cfg = dict(stage.refine or {}) if isinstance(stage.refine, dict) else {} + try: + max_rounds = int(cfg.get("max_rounds") or defaults.get("debate_max_rounds") or 2) + except (TypeError, ValueError): + max_rounds = 2 + try: + threshold = float(cfg.get("accept_threshold") or defaults.get("refine_accept_threshold") or 0.8) + except (TypeError, ValueError): + threshold = 0.8 + + agents_cfg = (self.runner.agents_cfg.get("agents") or {}) + critic_name = "critic" if "critic" in agents_cfg else "" + if not critic_name: + # No critic in the roster — fall back to the single-critic loop + # (which itself no-ops when no critic exists). + return self._refine_loop(workflow, stage, first_run, cycle_context, dry_run) + + lenses = self._debate_lenses(stage) + max_parallel = max(1, int((self.runner.clk_cfg.get("consensus") or {}).get("max_parallel") or 4)) + current_run = first_run + peer_transcript = "" + + for round_idx in range(1, max_rounds + 1): + if self.telemetry is not None: + try: + self.telemetry.add_refine_round() + except Exception as _exc: + logger.debug("telemetry add_refine_round failed: %s", _exc) + verdicts: List[Tuple[str, str, float, str]] = [] + with ThreadPoolExecutor(max_workers=min(max_parallel, len(lenses))) as pool: + futs = { + pool.submit( + self._dispatch_lens_critic, workflow, stage, current_run, + critic_name, lens, round_idx, max_rounds, peer_transcript, dry_run, + ): lens + for lens in lenses + } + for fut in as_completed(futs): + try: + verdicts.append(fut.result()) + except Exception as exc: + log_exception("orchestration.workflow._debate_loop.critic", exc) + if not verdicts: + return current_run + revise_votes = sum(1 for (_l, v, _s, _f) in verdicts if v == "revise") + scores = [s for (_l, _v, s, _f) in verdicts] + avg_score = sum(scores) / len(scores) if scores else 0.0 + transcript = "\n".join( + f"[{lens}] verdict={v} score={s:.2f}\n{fb}" for (lens, v, s, fb) in verdicts + ) + peer_transcript = transcript + log_event( + self.paths, "debate_round", + agent=stage.agent, workflow=workflow.name, stage_id=stage.id, + round=round_idx, max_rounds=max_rounds, + lenses=[lens for (lens, *_r) in verdicts], + revise_votes=revise_votes, avg_score=round(avg_score, 3), + accept_threshold=threshold, + ) + self.runner._observer_log( + f"debate :: {stage.id} :: round {round_idx}/{max_rounds} " + f"{len(lenses)} critics, {revise_votes} revise, avg={avg_score:.2f}" + ) + try: + _blackboard.post( + self.paths, author="critic-panel", body=transcript[:4000], + post_type="debate", stage_id=stage.id, workflow=workflow.name, + slug_hint=f"debate-{stage.id}-r{round_idx}", + ) + except Exception as exc: + log_exception("orchestration.workflow._debate_loop.post", exc) + + # Panel accepts when a majority accept AND the mean clears the bar. + if revise_votes * 2 <= len(verdicts) and avg_score >= threshold: + return current_run + if round_idx == max_rounds: + return current_run + + revise_objective = ( + f"Debate round {round_idx + 1}/{max_rounds} of stage `{stage.id}`. " + f"An adversarial review panel ({', '.join(lenses)}) found issues " + f"(mean score {avg_score:.2f}/1.0). Address every concrete point " + "below; keep what already works. Re-emit POST and ACTION blocks " + "the same way so the harness records the updated work.\n\n" + f"Panel critiques:\n{transcript}\n\n" + f"Original objective:\n{stage.objective}" + ) + current_run = self.runner.run( + stage.agent, + revise_objective, + extra={ + "phase": "refine_worker", + "stage_id": stage.id, + "workflow": workflow.name, + "cycle_context": cycle_context, + "blackboard_inputs": list(stage.inputs), + "stage_outputs": list(stage.outputs), + "refine_round": round_idx + 1, + "refine_max_rounds": max_rounds, + "telemetry": self.telemetry, + }, + dry_run=dry_run, + ) + if not current_run.response.ok: + return current_run + return current_run + + def _dispatch_checkpoint( + self, + workflow: "Workflow", + stage: "WorkflowStage", + result: "StageResult", + cycle_context: str, + dry_run: Optional[bool], + ) -> None: + """Light-weight chief checkpoint after a sensitive stage. + + Cost-bounded: a small prompt with the stage's posts and a + request for a CONTINUE / REDIRECT / ABORT verdict. The chief + emits ACTION:done if the project is finished, or PROPOSE_WORKFLOW + if the plan should change. Otherwise we just log the verdict and + let the workflow proceed. + """ + try: + posts = _blackboard.list_posts(self.paths) + except Exception: + posts = [] + produced = [p for p in posts if p.stage_id == stage.id] + snapshot = "\n".join( + f"- {p.id} type={p.post_type} produces={','.join(p.produces) or '-'} " + f"body_chars={len(p.body or '')}" + for p in produced[-10:] + ) or "(no posts from this stage)" + objective = ( + f"Chief checkpoint after stage `{stage.id}` (agent {stage.agent}, " + f"workflow `{workflow.name}`).\n\n" + f"Stage objective:\n{stage.objective}\n\n" + f"Posts produced by this stage:\n{snapshot}\n\n" + "Reply with one of:\n" + " CHECKPOINT: continue — let the workflow proceed as planned.\n" + " CHECKPOINT: redirect — emit PROPOSE_WORKFLOW with a revised plan.\n" + " CHECKPOINT: abort — emit ACTION:done if the project is finished.\n" + "Keep the response short — this is a verification, not a redo." + ) + logger.info(f"workflow {workflow.name}: checkpoint after stage {stage.id}") + self.runner.run( + "chief", + objective, + extra={ + "phase": "checkpoint", + "workflow": workflow.name, + "stage_id": stage.id, + "cycle_context": cycle_context, + "blackboard_inputs": [f"stage:{stage.id}"], + }, + dry_run=dry_run, + ) diff --git a/clk_harness/orchestration/workflow/stages.py b/clk_harness/orchestration/workflow/stages.py new file mode 100644 index 0000000..6a1b8c2 --- /dev/null +++ b/clk_harness/orchestration/workflow/stages.py @@ -0,0 +1,349 @@ +"""Workflow data model and parsing (Archon-style YAML). + +A workflow file looks like: + + name: engineering + description: Single development cycle. + stages: + - id: decompose + agent: chief + objective: Decompose the current top-level objective. + - id: research + agent: researcher + objective: Investigate open assumptions. + depends_on: [decompose] + validation: "echo OK" + commit: true +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + +from ...log import get_logger, log_exception +from ..agent import AgentRun + +logger = get_logger(__name__) + +_ROUND_STATUS_RE = re.compile(r"^\s*ROUND_STATUS\s*:\s*(continue|done|finished)\s*$", re.IGNORECASE | re.MULTILINE) + + +def _round_status(text: str) -> str: + """Return 'continue' or 'done'. Default 'done' when no marker found.""" + if not text: + return "done" + matches = _ROUND_STATUS_RE.findall(text) + if not matches: + return "done" + last = matches[-1].lower() + return "continue" if last == "continue" else "done" + + +try: + import yaml # type: ignore +except Exception as _exc: + # PyYAML is optional. The mini-YAML loader below covers the workflow + # subset CLK uses, so we quietly fall back rather than spraying a + # warning across stderr (which would also corrupt the TUI). + logger.debug("PyYAML unavailable; using the built-in mini-YAML loader: %s", _exc) + yaml = None + + +def _mini_yaml_loads(text: str) -> Dict[str, Any]: + """Minimal YAML loader for the workflow subset used by CLK. + + Supports: + * top-level scalar keys (``key: value``) + * a single ``stages:`` key whose value is a list of dicts + * each list item begins with ``- key: value`` then ``key: value`` lines + * inline lists like ``[a, b]`` and booleans (``true``/``false``) + * quoted scalar values + + This is *not* a general YAML parser - it handles exactly what the + bundled workflows use. Keeping it local avoids a hard dependency on + PyYAML when ``ensurepip`` is unavailable. + """ + + def parse_scalar(raw: str) -> Any: + s = raw.strip() + if not s: + return "" + if (s.startswith('"') and s.endswith('"')) or (s.startswith("'") and s.endswith("'")): + return s[1:-1] + if s.startswith("[") and s.endswith("]"): + inner = s[1:-1].strip() + if not inner: + return [] + return [parse_scalar(p) for p in _split_csv(inner)] + low = s.lower() + if low == "true": + return True + if low == "false": + return False + if low in ("null", "~"): + return None + try: + if "." in s: + return float(s) + return int(s) + except ValueError: + return s + + def _split_csv(s: str) -> List[str]: + out: List[str] = [] + buf = "" + depth = 0 + in_quote: Optional[str] = None + for ch in s: + if in_quote: + buf += ch + if ch == in_quote: + in_quote = None + continue + if ch in ("'", '"'): + in_quote = ch + buf += ch + continue + if ch == "[": + depth += 1 + buf += ch + continue + if ch == "]": + depth -= 1 + buf += ch + continue + if ch == "," and depth == 0: + out.append(buf.strip()) + buf = "" + continue + buf += ch + if buf.strip(): + out.append(buf.strip()) + return out + + def continuation(start: int, base_indent: int) -> Tuple[str, int]: + parts: List[str] = [] + j = start + while j < len(lines): + line = lines[j] + stripped = line.lstrip() + indent = len(line) - len(stripped) + if indent <= base_indent: + break + if stripped.startswith("- "): + break + if ":" in stripped: + break + parts.append(stripped) + j += 1 + return " ".join(parts).strip(), j + + lines = [ln.rstrip() for ln in text.splitlines() if ln.strip() and not ln.lstrip().startswith("#")] + result: Dict[str, Any] = {} + i = 0 + while i < len(lines): + line = lines[i] + if line.startswith(" "): # unexpected at top level, skip + i += 1 + continue + if ":" not in line: + i += 1 + continue + key, _, val = line.partition(":") + key = key.strip() + val = val.strip() + if val: + result[key] = parse_scalar(val) + i += 1 + extra, ni = continuation(i, 0) + if extra and isinstance(result[key], str): + result[key] = f"{result[key]} {extra}".strip() + i = ni + continue + # value on subsequent indented lines + i += 1 + if i < len(lines) and lines[i].lstrip().startswith("- "): + items: List[Any] = [] + cur: Optional[Dict[str, Any]] = None + while i < len(lines) and lines[i].startswith(" "): + sub = lines[i] + stripped = sub.lstrip() + if stripped.startswith("- "): + if cur is not None: + items.append(cur) + cur = {} + rest = stripped[2:] + if ":" in rest: + k2, _, v2 = rest.partition(":") + k2 = k2.strip() + cur[k2] = parse_scalar(v2) + i += 1 + extra, ni = continuation(i, len(sub) - len(stripped)) + if extra and isinstance(cur[k2], str): + cur[k2] = f"{cur[k2]} {extra}".strip() + i = ni + continue + else: + if cur is None: + cur = {} + if ":" in stripped: + k2, _, v2 = stripped.partition(":") + k2 = k2.strip() + cur[k2] = parse_scalar(v2) + i += 1 + extra, ni = continuation(i, len(sub) - len(stripped)) + if extra and isinstance(cur[k2], str): + cur[k2] = f"{cur[k2]} {extra}".strip() + i = ni + continue + i += 1 + if cur is not None: + items.append(cur) + result[key] = items + else: + # nested mapping not used by our workflow format; collect raw + buf: List[str] = [] + while i < len(lines) and lines[i].startswith(" "): + buf.append(lines[i]) + i += 1 + result[key] = "\n".join(buf) + return result + + +@dataclass +class WorkflowStage: + id: str + agent: str + objective: str + depends_on: List[str] = field(default_factory=list) + validation: Optional[str] = None + commit: bool = True + # Blackboard contract: ``inputs`` selectors filter the blackboard + # digest spliced into the worker's prompt; ``outputs`` are contract + # keys the worker promises to satisfy via POST blocks. Missing + # outputs surface as a warning (not a hard failure) post-run so + # downstream stages can still attempt their work. + inputs: List[str] = field(default_factory=list) + outputs: List[str] = field(default_factory=list) + # Phase tag. ``review`` makes this a chief-review stage that auto- + # digests upstream post-output and asks the chief to decide + # CONTINUE / REDIRECT / ABORT. Unknown phases are passed through to + # the worker prompt as-is for downstream behaviors to interpret. + phase: str = "" + # When > 1, the stage runs in turn-based rounds: after each round + # the runner refreshes the worker's prompt with any new blackboard + # posts (including those from sibling parallel workers) and re- + # dispatches. The worker stops the loop early by emitting + # ``ROUND_STATUS: done`` (default), or requests another round with + # ``ROUND_STATUS: continue``. + rounds: int = 1 + # Tag for sensitive stages. When true, the runner runs an extra + # chief checkpoint after the stage completes (CONTINUE / REDIRECT / + # ABORT) AND uses meta-prompt drafting on dispatch when configured. + careful: bool = False + # Critic-judge inner refinement loop. When present, after the + # worker's first response the harness dispatches the named critic + # agent to score the response 0..1; if below + # ``accept_threshold`` the worker is re-dispatched with the critic's + # feedback, up to ``max_rounds`` total worker dispatches. ``None`` + # means "use the default policy from clk.config.json::robustness". + refine: Optional[Dict[str, Any]] = None + metadata: Dict[str, Any] = field(default_factory=dict) + + +@dataclass +class Workflow: + name: str + description: str + stages: List[WorkflowStage] + + +def load_workflow(path: Path) -> Workflow: + text = path.read_text(encoding="utf-8") + if yaml is not None: + try: + data = yaml.safe_load(text) or {} + except Exception as exc: + log_exception("orchestration.workflow.load_workflow.pyyaml", exc) + try: + data = _mini_yaml_loads(text) + except Exception: + data = {} + else: + try: + data = _mini_yaml_loads(text) + except Exception as exc: + log_exception("orchestration.workflow.load_workflow.fallback", exc) + raise + + # If parsing produced nothing usable (e.g. the chief wrote a + # malformed workflow that wedges every subsequent supervise cycle), + # restore the bundled template for this workflow name so the runner + # has stages to execute on the next pass. + if not isinstance(data, dict) or not data.get("stages"): + try: + from ...templates.workflows import WORKFLOWS as _BUNDLED_WORKFLOWS + except Exception: + _BUNDLED_WORKFLOWS = {} + fallback = _BUNDLED_WORKFLOWS.get(path.name) + if fallback: + try: + path.write_text(fallback, encoding="utf-8") + except Exception as exc: + log_exception("orchestration.workflow.load_workflow.restore", exc) + if yaml is not None: + try: + data = yaml.safe_load(fallback) or {} + except Exception: + data = _mini_yaml_loads(fallback) + else: + data = _mini_yaml_loads(fallback) + + stages: List[WorkflowStage] = [] + for raw in data.get("stages") or []: + try: + rounds = int(raw.get("rounds") or 1) + except (TypeError, ValueError): + rounds = 1 + refine_raw = raw.get("refine") + if isinstance(refine_raw, dict): + refine_cfg: Optional[Dict[str, Any]] = dict(refine_raw) + elif refine_raw in (True, "true", "yes", 1): + refine_cfg = {} + else: + refine_cfg = None + stages.append( + WorkflowStage( + id=str(raw.get("id") or raw.get("agent") or "stage"), + agent=str(raw.get("agent") or "engineer"), + objective=str(raw.get("objective") or ""), + depends_on=list(raw.get("depends_on") or []), + validation=raw.get("validation"), + commit=bool(raw.get("commit", True)), + inputs=list(raw.get("inputs") or []), + outputs=list(raw.get("outputs") or []), + phase=str(raw.get("phase") or "").strip().lower(), + rounds=max(1, rounds), + careful=bool(raw.get("careful") or False), + refine=refine_cfg, + metadata=dict(raw.get("metadata") or {}), + ) + ) + return Workflow( + name=str(data.get("name") or path.stem), + description=str(data.get("description") or ""), + stages=stages, + ) + + +@dataclass +class StageResult: + stage: WorkflowStage + run: AgentRun + validated: bool + validation_output: str = "" + committed: bool = False + failure_reason: str = "" # filled when ok=False or validated=False diff --git a/clk_harness/orchestration/workflow/validation.py b/clk_harness/orchestration/workflow/validation.py new file mode 100644 index 0000000..43ed7e3 --- /dev/null +++ b/clk_harness/orchestration/workflow/validation.py @@ -0,0 +1,235 @@ +"""Validation and done-gate integration for the workflow runner. + +Stage validation commands, the outputs contract check, the stage +commit, and the done-gate (FM2) stop logic. +""" + +from __future__ import annotations + +import subprocess +from typing import TYPE_CHECKING, List, Optional + +from ...git_ops import add_all, commit_trace, has_changes +from ...git_ops import commit as git_commit +from ...log import get_logger, log_exception +from ...utils.activity_log import log_event +from .. import blackboard as _blackboard +from .. import charter as _charter +from .. import done_gate as _done_gate +from .. import evaluator as _evaluator +from .. import noop_guard as _noop_guard + +if TYPE_CHECKING: + from ...config import Paths + from ..agent import AgentRun, AgentRunner + from ..telemetry import CycleTelemetry + from .stages import Workflow, WorkflowStage + +logger = get_logger(__name__) + + +class ValidationMixin: + """Validation / done-gate / commit methods mixed into ``WorkflowRunner``.""" + + paths: "Paths" + runner: "AgentRunner" + telemetry: Optional["CycleTelemetry"] + + # -- done gate (FM2) --------------------------------------------------- + + def _done_gate_enabled(self) -> bool: + cfg = (self.runner.clk_cfg.get("done_gate") or {}) + return bool(cfg.get("enabled", True)) + + def _evaluate_done_gate(self) -> "_done_gate.DoneGateVerdict": + """Build a real eval result + charter criteria and run the done gate.""" + val_cfg = (self.runner.clk_cfg.get("validation") or {}) + evaluator = _evaluator.Evaluator( + root=self.paths.root, + default_checks=list(self.runner.clk_cfg.get("validation_checks") or []), + auto_derive=bool(val_cfg.get("auto_derive", True)), + derived_command=val_cfg.get("derived_command"), + ) + try: + eval_result = evaluator.run() + except Exception as exc: + log_exception("orchestration.workflow._evaluate_done_gate.eval", exc) + eval_result = None + try: + charter = _charter.load_charter(self.paths) + extra_criteria = _charter.derive_done_criteria(charter) + except Exception: + extra_criteria = [] + return _done_gate.evaluate_done_gate( + self.paths, self.runner.clk_cfg, eval_result, extra_criteria=extra_criteria, + ) + + def _stop_requested(self, workflow: "Workflow") -> bool: + """Whether the loop may stop now. + + ``done_granted.md`` (written only by the gate) is the authoritative + stop signal. A bare ``done.md`` is an agent *request*: when the gate + is enabled it is honored only if every completion criterion passes, + otherwise it is downgraded so the loop keeps working. When the gate + is disabled, ``done.md`` stops the loop as it always did. + """ + state = self.paths.state + if (state / "done_granted.md").exists(): + return True + done_md = state / "done.md" + if not done_md.exists(): + return False + if not self._done_gate_enabled(): + return True + verdict = self._evaluate_done_gate() + if self.telemetry is not None: + try: + self.telemetry.record_done_gate(verdict) + except Exception as _exc: + logger.debug("telemetry record_done_gate failed: %s", _exc) + if verdict.passed: + self._grant_done(verdict) + return True + # Reject: downgrade the request so a later cycle can re-earn it. + try: + done_md.rename(state / "done_requested.md") + except Exception: + try: + done_md.unlink() + except Exception as _exc: + logger.debug("could not clear done request %s: %s", done_md, _exc) + logger.warning( + f"workflow {workflow.name}: ACTION:done REJECTED by done-gate — " + f"unmet: {', '.join(verdict.failures) or '?'}", + ) + log_event( + self.paths, + "done_gate_rejected", + workflow=workflow.name, + failures=list(verdict.failures), + checked=verdict.checked, + ) + return False + + def _grant_done(self, verdict: "_done_gate.DoneGateVerdict") -> None: + try: + (self.paths.state / "done_granted.md").write_text( + "# Mission complete\n\n" + verdict.summary() + "\n", + encoding="utf-8", + ) + except Exception as exc: + log_exception("orchestration.workflow._grant_done", exc) + log_event(self.paths, "done_gate_granted", checked=verdict.checked) + try: + commit_trace( + self.paths.root, + kind="done", + summary="done-gate granted", + meta={"checked": list(verdict.checked.keys())}, + ) + except Exception as _exc: + logger.debug("done-gate trace commit failed: %s", _exc) + + # -- outputs contract / validation / commit -------------------------- + + def _check_outputs_contract(self, stage: "WorkflowStage") -> List[str]: + """Return the list of declared output contract keys not yet posted + by ``stage.id``. Empty list when the contract is satisfied or not + declared. + """ + if not stage.outputs: + return [] + try: + posts = _blackboard.list_posts(self.paths) + return _blackboard.find_outputs_satisfied( + posts, stage_id=stage.id, expected=stage.outputs + ) + except Exception as exc: + log_exception("orchestration.workflow._check_outputs_contract", exc) + return [] + + def _validate(self, stage: "WorkflowStage") -> tuple[bool, str]: + cmd = stage.validation + # FM4: a producing stage with no explicit validation no longer + # auto-passes — derive a real command from the project shape. Non- + # producing stages (chief/critic prose) keep the auto-pass. + if not cmd: + val_cfg = (self.runner.clk_cfg.get("validation") or {}) + if val_cfg.get("auto_derive", True) and _noop_guard.is_mutation_expected( + stage.agent, outputs=stage.outputs, commit=stage.commit, + cfg=self.runner.clk_cfg, + ): + if val_cfg.get("derived_command"): + cmd = str(val_cfg.get("derived_command")) + else: + derived, _weak = _evaluator.derive_validation(self.paths.root) + cmd = derived[0] if derived else None + if not cmd: + return True, "" + try: + log_event( + self.paths, + "shell_command_start", + agent=stage.agent, + action="validation", + stage_id=stage.id, + cmd=cmd, + cwd=str(self.paths.root), + timeout_s=120, + ) + r = subprocess.run( + cmd, + shell=True, + cwd=str(self.paths.root), + capture_output=True, + text=True, + timeout=120, + ) + output = (r.stdout or "") + (r.stderr or "") + log_event( + self.paths, + "shell_command_end", + agent=stage.agent, + action="validation", + stage_id=stage.id, + cmd=cmd, + ok=r.returncode == 0, + returncode=r.returncode, + output=output, + output_chars=len(output or ""), + ) + return r.returncode == 0, output.strip() + except Exception as exc: + log_exception("orchestration.workflow._validate", exc) + log_event( + self.paths, + "shell_command_end", + agent=stage.agent, + action="validation", + stage_id=stage.id, + cmd=cmd, + ok=False, + error=str(exc), + ) + return False, str(exc) + + def _commit( + self, + workflow: "Workflow", + stage: "WorkflowStage", + run: "AgentRun", + validation_output: str, + ) -> bool: + if not has_changes(self.paths.root): + return False + if not add_all(self.paths.root): + return False + return git_commit( + self.paths.root, + agent=f"{workflow.name}.{stage.id}", + objective=stage.objective, + files_changed=run.files_written, + validation=stage.validation or "none", + next_step=f"continue workflow {workflow.name}", + body_extra=(validation_output or "")[:500], + ) diff --git a/clk_harness/pricing.py b/clk_harness/pricing.py index b240e61..43b5cbd 100644 --- a/clk_harness/pricing.py +++ b/clk_harness/pricing.py @@ -18,7 +18,7 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Any, Dict, Optional, Tuple +from typing import Any, Dict, Optional @dataclass(frozen=True) diff --git a/clk_harness/providers/__init__.py b/clk_harness/providers/__init__.py index e74c166..3d34f25 100644 --- a/clk_harness/providers/__init__.py +++ b/clk_harness/providers/__init__.py @@ -6,13 +6,13 @@ """ from .base import AgentProvider, AgentRequest, AgentResponse, ProviderUnavailable -from .shell import ShellProvider from .claude import ClaudeProvider from .codex import CodexProvider from .gemini import GeminiProvider -from .pi import PiProvider from .ollama import OllamaProvider from .openwebui import OpenWebUIProvider +from .pi import PiProvider +from .shell import ShellProvider __all__ = [ "AgentProvider", diff --git a/clk_harness/providers/_endpoint_fallback.py b/clk_harness/providers/_endpoint_fallback.py index 3cd4100..72a2848 100644 --- a/clk_harness/providers/_endpoint_fallback.py +++ b/clk_harness/providers/_endpoint_fallback.py @@ -19,6 +19,9 @@ from typing import Optional from urllib.parse import urlparse, urlunparse +from ..log import get_logger + +logger = get_logger(__name__) _LOCALHOST_HOSTS = {"localhost", "127.0.0.1", "::1"} _DOCKER_HOST = "host.docker.internal" @@ -64,7 +67,8 @@ def docker_host_swap(endpoint: str) -> Optional[str]: """ try: url = urlparse(endpoint) - except Exception: + except Exception as _exc: + logger.debug("could not parse endpoint %r: %s", endpoint, _exc) return None if (url.hostname or "").lower() not in _LOCALHOST_HOSTS: return None diff --git a/clk_harness/providers/base.py b/clk_harness/providers/base.py index b086717..6953b09 100644 --- a/clk_harness/providers/base.py +++ b/clk_harness/providers/base.py @@ -2,17 +2,21 @@ from __future__ import annotations -import subprocess -import threading -import time import json +import os import shlex import shutil -import os +import subprocess +import threading +import time from dataclasses import dataclass, field from pathlib import Path from typing import Any, Callable, Dict, List, Optional, Tuple +from ..log import get_logger + +logger = get_logger(__name__) + class ProviderUnavailable(RuntimeError): """Raised when a provider cannot service a request.""" @@ -193,8 +197,8 @@ def _sample_process() -> str: parts = (r.stdout or "").strip().split() if len(parts) >= 2: return f"cpu={parts[0]} rss_kb={parts[1]}" - except Exception: - pass + except Exception as _exc: + logger.debug("resource sample failed for pid %s: %s", proc.pid, _exc) return "cpu=? rss_kb=?" def _kill(reason: str, rc_value: int) -> int: @@ -206,8 +210,8 @@ def _kill(reason: str, rc_value: int) -> int: progress("killed", f"pid={proc.pid} reason={reason} kill_error={exc}") try: proc.wait(timeout=5) - except Exception: - pass + except Exception as _exc: + logger.debug("wait after kill failed for pid %s: %s", proc.pid, _exc) return rc_value def _feed_stdin() -> None: @@ -215,14 +219,14 @@ def _feed_stdin() -> None: if stdin_text is not None and proc.stdin is not None: proc.stdin.write(stdin_text) proc.stdin.flush() - except Exception: - pass + except Exception as _exc: + logger.debug("stdin feed to pid %s failed: %s", proc.pid, _exc) finally: try: if proc.stdin is not None: proc.stdin.close() - except Exception: - pass + except Exception as _exc: + logger.debug("stdin close for pid %s failed: %s", proc.pid, _exc) def _read_stream(stream, buf: List[str], kind: str) -> None: try: @@ -230,8 +234,8 @@ def _read_stream(stream, buf: List[str], kind: str) -> None: buf.append(line) last_output[0] = time.monotonic() progress(kind, line.rstrip()) - except Exception: - pass + except Exception as _exc: + logger.debug("%s stream reader stopped: %s", kind, _exc) threads = [ threading.Thread(target=_feed_stdin, name=f"clk-stdin-{proc.pid}", daemon=True), diff --git a/clk_harness/providers/claude.py b/clk_harness/providers/claude.py index 3431813..8592289 100644 --- a/clk_harness/providers/claude.py +++ b/clk_harness/providers/claude.py @@ -20,7 +20,6 @@ from .base import AgentProvider, AgentRequest, AgentResponse, estimate_tokens, run_streaming - _DEFAULT_API_ENDPOINT = "https://api.anthropic.com/v1/messages" _DEFAULT_API_VERSION = "2023-06-01" _DEFAULT_API_MODEL = "claude-sonnet-4-5" @@ -149,7 +148,7 @@ def invoke(self, req: AgentRequest) -> AgentResponse: text = stdout or "" envelope: Dict[str, Any] = {} - usage: Dict[str, Any] = {} + usage = {} stripped = text.strip() if stripped.startswith("{"): try: diff --git a/clk_harness/providers/codex.py b/clk_harness/providers/codex.py index 69bebee..91335df 100644 --- a/clk_harness/providers/codex.py +++ b/clk_harness/providers/codex.py @@ -9,10 +9,10 @@ import traceback import urllib.error import urllib.request +from typing import Any, Dict from .base import AgentProvider, AgentRequest, AgentResponse, estimate_tokens, run_streaming - _DEFAULT_API_ENDPOINT = "https://api.openai.com/v1/chat/completions" _DEFAULT_API_MODEL = "gpt-4o-mini" @@ -93,7 +93,7 @@ def _invoke_api(self, req: AgentRequest) -> AgentResponse: progress = req.on_progress or (lambda kind, msg: None) endpoint = self.config.get("endpoint") or _DEFAULT_API_ENDPOINT model = self.config.get("model") or _DEFAULT_API_MODEL - body = { + body: Dict[str, Any] = { "model": model, "messages": [{"role": "user", "content": req.prompt}], } diff --git a/clk_harness/providers/gemini.py b/clk_harness/providers/gemini.py index ecd5c38..6db3890 100644 --- a/clk_harness/providers/gemini.py +++ b/clk_harness/providers/gemini.py @@ -20,7 +20,6 @@ from .base import AgentProvider, AgentRequest, AgentResponse, estimate_tokens, run_streaming - _DEFAULT_API_BASE = "https://generativelanguage.googleapis.com/v1beta" _DEFAULT_API_MODEL = "gemini-2.0-flash" @@ -142,7 +141,7 @@ def _invoke_api(self, req: AgentRequest) -> AgentResponse: base = (self.config.get("endpoint") or _DEFAULT_API_BASE).rstrip("/") model = self.config.get("model") or _DEFAULT_API_MODEL endpoint = f"{base}/models/{urllib.parse.quote(model)}:generateContent?key={urllib.parse.quote(api_key)}" - body = { + body: Dict[str, Any] = { "contents": [{"parts": [{"text": req.prompt}]}], } if req.system: diff --git a/clk_harness/providers/ollama.py b/clk_harness/providers/ollama.py index 91d3c34..2301443 100644 --- a/clk_harness/providers/ollama.py +++ b/clk_harness/providers/ollama.py @@ -14,8 +14,8 @@ import urllib.error import urllib.request -from .base import AgentProvider, AgentRequest, AgentResponse, estimate_tokens from ._endpoint_fallback import maybe_docker_host_fallback, normalize_endpoint, probe_endpoint +from .base import AgentProvider, AgentRequest, AgentResponse, estimate_tokens def list_models(endpoint: str, *, timeout_s: float = 5.0) -> list: diff --git a/clk_harness/providers/openwebui.py b/clk_harness/providers/openwebui.py index 2341358..4fc124b 100644 --- a/clk_harness/providers/openwebui.py +++ b/clk_harness/providers/openwebui.py @@ -30,8 +30,11 @@ import uuid from typing import Any, Dict, List, Tuple +from ..log import get_logger from .base import AgentProvider, AgentRequest, AgentResponse, estimate_tokens +logger = get_logger(__name__) + def _auth_header(api_key: str) -> Dict[str, str]: if not api_key: @@ -157,8 +160,8 @@ def invoke(self, req: AgentRequest) -> AgentResponse: progress("http_retry", f"attempt {attempt}/{attempts} failed; sleeping {delay}s before retry") try: time.sleep(float(delay)) - except Exception: - pass + except Exception as _exc: + logger.debug("retry backoff sleep failed: %s", _exc) continue break return last_err @@ -285,8 +288,8 @@ def _retry_after_seconds(exc: urllib.error.HTTPError) -> float: ra = exc.headers.get("Retry-After") if exc.headers else None if ra: return float(ra) - except Exception: - pass + except Exception as _exc: + logger.debug("could not parse Retry-After header: %s", _exc) return 0.0 @@ -307,6 +310,6 @@ def _read_error_detail(exc: urllib.error.HTTPError) -> Tuple[str, str]: if v: parts.append(f"{h}={v}") headers_str = ";".join(parts) - except Exception: - pass + except Exception as _exc: + logger.debug("could not extract error headers: %s", _exc) return detail, headers_str diff --git a/clk_harness/providers/shell.py b/clk_harness/providers/shell.py index 7637124..0eb06b3 100644 --- a/clk_harness/providers/shell.py +++ b/clk_harness/providers/shell.py @@ -8,11 +8,9 @@ from __future__ import annotations -import json import sys import traceback from pathlib import Path -from typing import Any, Dict from .base import AgentProvider, AgentRequest, AgentResponse, estimate_tokens @@ -31,7 +29,7 @@ def invoke(self, req: AgentRequest) -> AgentResponse: f"[shell-provider] agent={req.agent}", f"[shell-provider] dry_run={req.dry_run}", "[shell-provider] prompt preview:", - *[f" {l}" for l in preview], + *[f" {ln}" for ln in preview], ] if req.dry_run: diff --git a/clk_harness/templates/prompts.py b/clk_harness/templates/prompts.py index 065b23d..bfe165b 100644 --- a/clk_harness/templates/prompts.py +++ b/clk_harness/templates/prompts.py @@ -16,7 +16,6 @@ from typing import Dict - _CONFIDENCE_BLOCK = """ Self-assessment footer (read by the harness's response-quality loop) End your response with exactly two lines: diff --git a/clk_harness/templates/workflows.py b/clk_harness/templates/workflows.py index 32cacf5..c6b303c 100644 --- a/clk_harness/templates/workflows.py +++ b/clk_harness/templates/workflows.py @@ -4,7 +4,6 @@ from typing import Dict - WORKFLOWS: Dict[str, str] = { "discovery.yaml": """name: discovery description: Validate the problem, users, and landscape before building anything. diff --git a/clk_harness/tui.py b/clk_harness/tui.py deleted file mode 100644 index de000f7..0000000 --- a/clk_harness/tui.py +++ /dev/null @@ -1,2813 +0,0 @@ -"""Cognitive Loop Kernel TUI dashboard. - -A curses-based front end inspired by gnhf / Archon. The screen is -re-drawn in place (no scrolling output) and shows: - - * a top status bar (project, provider, current phase, busy indicator) - * a grid of agent cards, one per configured agent. Each card shows - status (idle / working / done / failed), run count, current task, - and the last result preview. - * a status log pane that updates in place, colored by severity. - * an idea / context line, the current conversation tail, and a - Claude-Code-style ``>`` input field at the very bottom. - -The TUI uses no third-party dependencies - just stdlib ``curses``, -``threading``, and ``queue``. - -Conversational dispatch: - * If no idea has been captured yet, the first user message becomes - the idea, then the engineering workflow runs. - * Subsequent free-text messages append to the conversation file - ``.clk/state/conversation.md`` and trigger another engineering - cycle so the agents see the new context on every turn. - * Slash commands give explicit control: - /idea replace the captured idea - /run [workflow] run a single workflow cycle (default: engineering) - /loop ralph|autoresearch [N] start a loop (autoresearch uses ralph in research mode) - /stop request the active loop to stop - /provider switch active provider - /status show a status snapshot in the log - /quit exit the TUI -""" - -from __future__ import annotations - -import curses -import json -import queue -import re -import sys -import textwrap -import threading -import time -import traceback -from collections import deque -from dataclasses import dataclass, field -from datetime import datetime -from pathlib import Path -from typing import Any, Deque, Dict, List, Optional, Tuple - -from .config import ( - Paths, - is_initialized, - load_agents_config, - load_clk_config, - load_providers_config, - project_paths, - save_json, -) -from .git_ops import ( - add_all, - commit as git_commit, - commits_ahead, - has_changes, - has_remote, - is_repo, - push as git_push, -) -from .orchestration import ( - AgentObserver, - AgentRunner, - AutoresearchLoop, - Evaluator, - MissionRunner, - RalphLoop, - RoleProposal, - WorkflowRunner, - casting_objective, - is_baseline, - is_provider_failure, - list_roles, - load_workflow, - register_role, - remove_role, - render_roster_summary, -) -from .pricing import estimate_usd, format_usd -from .utils.logging_utils import log_exception -from .utils.text_extract import classify_error, extract_thought - - -# --------------------------------------------------------------------------- -# Error classifier — now lives in clk_harness.utils.text_extract so the -# headless web/API layer can reuse it without importing curses. It is -# re-exported here (imported above) so existing call sites are unchanged. -# --------------------------------------------------------------------------- - - -# --------------------------------------------------------------------------- -# stderr/stdout capture -# --------------------------------------------------------------------------- - - -class _StreamToLog: - """File-like object that routes writes into the dashboard log pane. - - Replaces ``sys.stderr`` (and optionally ``sys.stdout``) while the TUI - is active so that ``log()`` calls, ``print()`` statements, and - ``traceback.print_exc()`` output never reach the terminal directly - and therefore never corrupt the curses screen. - - Lines are buffered until a ``\\n`` is seen so partial writes from - ``print(..., end="")`` don't produce noisy fragments. Severity is - inferred from the ``[LEVEL]`` tag the harness's own logger emits; - everything else falls back to the default level. - """ - - LEVEL_TAGS = ("[ERROR]", "[WARN]", "[INFO]") - LOG_PREFIX_RE = re.compile( - r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\s+\[(ERROR|WARN|INFO)\]\s*(.*)$" - ) - - def __init__(self, state: "DashboardState", default_level: str = "INFO") -> None: - self.state = state - self.default_level = default_level - self._buf = "" - - def write(self, s: str) -> int: - if not s: - return 0 - self._buf += s - out_len = len(s) - while "\n" in self._buf: - line, _, self._buf = self._buf.partition("\n") - line = line.rstrip("\r") - if not line.strip(): - continue - level = self.default_level - m = self.LOG_PREFIX_RE.match(line) - if m: - level = m.group(1) - line = m.group(2) - else: - for tag in self.LEVEL_TAGS: - if tag in line: - level = tag.strip("[]") - break - try: - self.state.add_log(line[:300], level=level) - except Exception: - # Last resort: never raise from a stream.write. - pass - return out_len - - def flush(self) -> None: - if self._buf.strip(): - try: - self.state.add_log(self._buf[:300], level=self.default_level) - except Exception: - pass - self._buf = "" - - def isatty(self) -> bool: - return False - - def writable(self) -> bool: - return True - - def fileno(self): # raise so subprocess.* doesn't grab us - raise OSError("StreamToLog has no fileno") - - -# --------------------------------------------------------------------------- -# State -# --------------------------------------------------------------------------- - - -# ``_extract_thought`` moved to clk_harness.utils.text_extract.extract_thought -# (re-exported below as the original private name so call sites are unchanged). -_extract_thought = extract_thought - - -class AgentStatus: - IDLE = "idle" - WORKING = "working" - RECOVERING = "recovering" # red: retry backoff in progress after a provider error - DONE = "done" - FAILED = "failed" - PROVIDER_ERROR = "provider" - - -@dataclass -class AgentCard: - name: str - status: str = AgentStatus.IDLE - current_task: str = "" - last_result: str = "" - last_error: str = "" - runs: int = 0 - last_started_mono: float = 0.0 - last_duration_s: float = 0.0 - # Telemetry surfaced by the rotating in-card panes. - prompt_preview: str = "" - response_preview: str = "" - files_written: List[str] = field(default_factory=list) - last_thought: str = "" - provider: str = "" - role: str = "" - is_baseline: bool = False - roster_status: str = "" # latest roster_changed status, e.g. "added" - # Token accounting (cumulative across all runs of this agent). - input_tokens: int = 0 - output_tokens: int = 0 - total_tokens: int = 0 - last_run_tokens: int = 0 # tokens in the most recent run only - last_usage_source: str = "" - # USD cost estimates — populated by DashboardState.end_agent via - # clk_harness.pricing. last_usd is per-run, total_usd is cumulative. - last_usd: float = 0.0 - total_usd: float = 0.0 - total_files: int = 0 # cumulative file write count - # Live subprocess telemetry (set by streaming providers via the - # ``progress`` observer hook). ``live_pid`` is non-zero while the - # underlying CLI subprocess is alive; ``live_last_line`` is the - # most recent stderr/stdout line so the user can see real activity - # in the card and the log pane rather than a stalled spinner. - live_pid: int = 0 - live_last_line: str = "" - live_stdout_chars: int = 0 - live_stderr_chars: int = 0 - live_last_update_mono: float = 0.0 - provider_issue: bool = False - provider_resolution: str = "" - live_cpu_pct: str = "" - live_rss_kb: str = "" - live_idle_s: float = 0.0 - live_elapsed_s: float = 0.0 - - -def _format_tokens(n: int) -> str: - if n >= 1_000_000: - return f"{n/1_000_000:.2f}M" - if n >= 1_000: - return f"{n/1_000:.1f}k" - return str(n) - - -def _word_wrap(text: str, width: int) -> List[str]: - """Wrap ``text`` to ``width`` columns, breaking on word boundaries. - - Falls back to mid-word splits when a single token exceeds width - (e.g. a long URL). Empty input returns ``[""]`` so callers can rely - on at least one row. - """ - if width <= 1: - return [text] - if not text: - return [""] - out: List[str] = [] - for paragraph in text.splitlines() or [text]: - if not paragraph: - out.append("") - continue - words = paragraph.split(" ") - current = "" - for word in words: - if len(word) > width: - # Hard-break the giant token. Flush whatever we have first. - if current: - out.append(current) - current = "" - while len(word) > width: - out.append(word[:width]) - word = word[width:] - current = word - continue - if not current: - current = word - elif len(current) + 1 + len(word) <= width: - current = f"{current} {word}" - else: - out.append(current) - current = word - if current: - out.append(current) - return out or [""] - - -@dataclass -class LogLine: - ts: str - level: str # INFO | WARN | ERROR | USER | SYSTEM - text: str - - -class DashboardState: - """Thread-safe state shared between the UI thread and the worker.""" - - def __init__(self, agent_names: List[str], *, paths: Optional[Paths] = None, - agents_cfg: Optional[Dict[str, Any]] = None) -> None: - self.lock = threading.Lock() - self.paths = paths - self.agents: Dict[str, AgentCard] = {} - for n in agent_names: - cfg = ((agents_cfg or {}).get("agents") or {}).get(n) or {} - self.agents[n] = AgentCard( - name=n, - role=cfg.get("role", ""), - provider=cfg.get("provider") or "", - is_baseline=is_baseline(n), - ) - self.log: Deque[LogLine] = deque(maxlen=400) - self.idea: str = "" - self.project_name: str = "" - self.provider: str = "" - self.phase: str = "idle" - self.busy: bool = False - self.input_buffer: str = "" - self.input_cursor: int = 0 - self.conversation: List[Tuple[str, str]] = [] - self.stop_requested: bool = False - self.iteration_count: int = 0 - # Project-wide token + file totals (sum across all agents). - self.total_input_tokens: int = 0 - self.total_output_tokens: int = 0 - self.total_tokens: int = 0 - self.total_files: int = 0 - # Largest single-run token total observed across any agent. - # The activity meter normalizes against this so a heavy agent - # (engineer with 5k token responses) shows a long bar while a - # light one (chief with 200 tokens) shows a short one. - self.peak_run_tokens: int = 0 - # Session log file (mirror of the in-pane status log so we - # have a persistent trace for later analysis). - self.session_log_fh = None - # Cost guardrails. ``total_usd`` is the rolling estimate based on - # tokens × provider pricing; ``cost_per_provider`` lets /status - # show a breakdown so the user can see which provider is eating - # the cap. Caps are read lazily from clk.config.json. - self.total_usd: float = 0.0 - self.cost_per_provider: Dict[str, float] = {} - # Most-recent classified error — drives the hint bar below the - # input. Cleared on the next successful run. - self.last_error_kind: str = "" - self.last_error_command: str = "" - # Set to True while /tutorial is running so other commands can - # display a "currently running tutorial" banner. - self.in_tutorial: bool = False - # Set true once /help has been opened in this session — used to - # suppress the "press F1 for help" repeat in the hint bar. - self.help_dismissed: bool = False - # Count of local commits ahead of origin (refreshed lazily by - # the worker after each successful agent commit). - self.github_ahead: int = 0 - - # ----- mutators (locked) -------------------------------------------- - - def add_log(self, text: str, level: str = "INFO") -> None: - # Multi-line writes (e.g. tracebacks routed through _StreamToLog - # if buffering races) become one entry per line so the log pane - # wraps each piece independently. Tabs and stray \r chars are - # also normalized so the word-wrapper sees clean spaces. - text = (text or "").replace("\r", "").replace("\t", " ") - if "\n" in text: - for piece in text.split("\n"): - if piece.strip(): - self.add_log(piece, level=level) - return - line = LogLine(ts=datetime.now().strftime("%H:%M:%S"), level=level, text=text) - with self.lock: - self.log.append(line) - fh = self.session_log_fh - if fh is not None: - try: - fh.write(f"{datetime.now().isoformat(timespec='seconds')} [{level}] {text}\n") - fh.flush() - except Exception: - # Never let a log write blow up the TUI. - pass - - def add_file_log(self, text: str, level: str = "INFO") -> None: - """Write directly to the session log file without adding to the TUI pane. - - Used for high-volume telemetry (tick, command) that is useful for - post-run analysis but would flood the visible status log. - """ - with self.lock: - fh = self.session_log_fh - if fh is not None: - try: - fh.write( - f"{datetime.now().isoformat(timespec='seconds')} [{level}] {text}\n" - ) - fh.flush() - except Exception: - pass - - def attach_session_log(self, path: Path) -> None: - """Open ``path`` in append mode and mirror every log line to it. - - Called once during TUI startup. The file persists across the - run so a `.clk/logs/session.log` accumulates the project's full - history of TUI events for later analysis (alongside - casting.log, agent_memory.jsonl, and the git log). - """ - try: - path.parent.mkdir(parents=True, exist_ok=True) - fh = path.open("a", encoding="utf-8") - with self.lock: - self.session_log_fh = fh - fh.write( - f"\n=== session start {datetime.now().isoformat(timespec='seconds')} ===\n" - ) - fh.flush() - except Exception: - pass - - def close_session_log(self) -> None: - with self.lock: - fh = self.session_log_fh - self.session_log_fh = None - if fh is not None: - try: - fh.write( - f"=== session end {datetime.now().isoformat(timespec='seconds')} ===\n" - ) - fh.close() - except Exception: - pass - - def begin_agent(self, name: str, objective: str) -> None: - with self.lock: - card = self.agents.setdefault(name, AgentCard(name=name)) - card.status = AgentStatus.WORKING - card.current_task = objective - card.last_started_mono = time.monotonic() - # Reset transient telemetry from the previous run. - card.prompt_preview = "" - card.response_preview = "" - card.files_written = [] - card.last_thought = "" - card.live_pid = 0 - card.live_last_line = "" - card.live_stdout_chars = 0 - card.live_stderr_chars = 0 - card.live_last_update_mono = time.monotonic() - card.provider_issue = False - card.provider_resolution = "" - card.live_cpu_pct = "" - card.live_rss_kb = "" - card.live_idle_s = 0.0 - card.live_elapsed_s = 0.0 - # Take the first non-empty line of the objective so that multi-line - # objectives (e.g. recovery dispatches that start with a blank line - # after the header) don't produce a stray fragment in the log pane. - _obj_first = next( - (l for l in (objective or "").splitlines() if l.strip()), - (objective or ""), - ) - self.add_log(f"{name} :: start :: {_obj_first[:80]}", level="INFO") - - def report_progress(self, name: str, kind: str, message: str) -> None: - """Capture streaming progress from a provider's subprocess. - - Updates the card's live_* fields and (selectively) emits log - entries so the user can see what the underlying CLI is doing - in real time. We log every stderr line (these are usually - status / auth / error messages from claude/codex/gemini), the - first 5 stdout lines (so very long responses don't flood the - pane), and the start / end / timeout events. - """ - message = (message or "").rstrip() - with self.lock: - card = self.agents.setdefault(name, AgentCard(name=name)) - now = time.monotonic() - card.live_last_update_mono = now - if kind == "start": - # parse "pid=NNNN cmd=..." into the live fields - pid = 0 - for tok in message.split(): - if tok.startswith("pid="): - try: - pid = int(tok.split("=", 1)[1]) - except Exception: - pid = 0 - break - card.live_pid = pid - card.live_last_line = "starting..." - elif kind in ("stdout_line", "stderr_line"): - card.live_last_line = message[:200] - if kind == "stdout_line": - card.live_stdout_chars += len(message) + 1 - else: - card.live_stderr_chars += len(message) + 1 - elif kind == "tick": - # Strip pid= token before storing — live_pid tracks it separately - # and we don't want it duplicated in the card's status line. - stripped = re.sub(r"\bpid=\S+\s*", "", message).strip() - card.live_last_line = stripped[:200] - for tok in message.split(): - if tok.startswith("cpu="): - card.live_cpu_pct = tok.split("=", 1)[1] - elif tok.startswith("rss_kb="): - card.live_rss_kb = tok.split("=", 1)[1] - elif tok.startswith("idle_s="): - try: - card.live_idle_s = float(tok.split("=", 1)[1]) - except Exception: - pass - elif tok.startswith("elapsed_s="): - try: - card.live_elapsed_s = float(tok.split("=", 1)[1]) - except Exception: - pass - elif kind in ("command", "retry", "killed"): - card.live_last_line = message[:200] - elif kind in ("end", "timeout"): - card.live_pid = 0 - card.live_last_line = f"{kind}: {message}"[:200] - # Route events: high-volume telemetry goes to the session file only; - # actionable events go to both the TUI pane and the file. - if kind == "start": - # A new subprocess started — if this agent was in RECOVERING - # (backoff after a provider error), it is now retrying; show yellow. - with self.lock: - card2 = self.agents.get(name) - if card2 and card2.status == AgentStatus.RECOVERING: - card2.status = AgentStatus.WORKING - card2.provider_issue = False - self.add_log(f"{name} :: subprocess {message}", level="SYSTEM") - elif kind == "command": - # Full command metadata is verbose; useful for forensics, not for - # the live status pane. Write to session log file only. - try: - meta = json.loads(message) - args = meta.get("args") or [] - arg_note = "no args" if not args else f"args={args}" - line = ( - f"{name} :: command :: {meta.get('cmd')} " - f"(argv_count={meta.get('argv_count')}; {arg_note}; " - f"stdin={meta.get('stdin')} {meta.get('stdin_chars')} chars; " - f"cwd={meta.get('cwd')})" - ) - except Exception: - line = f"{name} :: command :: {message[:240]}" - self.add_file_log(line, level="SYSTEM") - elif kind == "tick": - # Per-process telemetry ticks are high-frequency; file log only. - self.add_file_log(f"{name} :: telemetry :: {message[:240]}", level="INFO") - elif kind == "retry": - # Provider error during a run — enter red RECOVERING state. - with self.lock: - card2 = self.agents.get(name) - if card2: - card2.status = AgentStatus.RECOVERING - card2.provider_issue = True - self.add_log(f"{name} :: retry :: {message[:240]}", level="WARN") - elif kind == "killed": - self.add_log(f"{name} :: killed :: {message[:240]}", level="WARN") - elif kind.startswith("http_"): - self.add_log(f"{name} :: {kind} :: {message[:240]}", level="SYSTEM") - elif kind == "stderr_line" and message: - self.add_log(f"{name} stderr: {message[:200]}", level="INFO") - elif kind == "stdout_line": - with self.lock: - cnt = self.agents[name].live_stdout_chars - if cnt < 1024: - self.add_log(f"{name} stdout: {message[:200]}", level="INFO") - elif kind == "timeout": - self.add_log(f"{name} :: TIMEOUT :: {message}", level="ERROR") - elif kind == "end": - self.add_log(f"{name} :: subprocess {message}", level="SYSTEM") - - def set_agent_prompt(self, name: str, prompt: str) -> None: - # Keep a short head-of-prompt; the full prompt is on disk under - # .clk/runs/. We only need a glanceable preview here. - snippet = (prompt or "").strip() - if not snippet: - return - # Strip the boilerplate header lines so the preview shows the - # role-specific objective, not the same operating constraints - # for every card. - candidates = [l for l in snippet.splitlines() if l.strip()] - head = " ".join(candidates[:6]) - with self.lock: - card = self.agents.setdefault(name, AgentCard(name=name)) - card.prompt_preview = head[:240] - - def end_agent( - self, - name: str, - ok: bool, - preview: str = "", - error: str = "", - files_written: Optional[List[str]] = None, - usage: Optional[Dict[str, Any]] = None, - ) -> None: - files_written = list(files_written or []) - usage = dict(usage or {}) - provider_issue = (not ok) and is_provider_failure(error) - with self.lock: - card = self.agents.setdefault(name, AgentCard(name=name)) - card.status = AgentStatus.DONE if ok else ( - AgentStatus.PROVIDER_ERROR if provider_issue else AgentStatus.FAILED - ) - card.current_task = "" - card.last_result = (preview or "").strip().replace("\n", " ")[:240] - card.last_error = error[:240] - card.response_preview = (preview or "").strip()[:400] - card.files_written = files_written - card.last_thought = _extract_thought(preview) - card.runs += 1 - card.total_files += len(files_written) - if card.last_started_mono: - card.last_duration_s = time.monotonic() - card.last_started_mono - in_tok = int(usage.get("input_tokens") or 0) - out_tok = int(usage.get("output_tokens") or 0) - tot_tok = int(usage.get("total_tokens") or (in_tok + out_tok)) - card.input_tokens += in_tok - card.output_tokens += out_tok - card.total_tokens += tot_tok - card.last_run_tokens = tot_tok - card.last_usage_source = str(usage.get("source") or card.last_usage_source) - card.provider_issue = provider_issue - if provider_issue: - card.provider_resolution = self._provider_resolution_message(error) - kind, _, cmd = classify_error(error) - self.last_error_kind = kind - self.last_error_command = cmd - elif ok: - # Clear the hint after a successful run so the user - # doesn't keep seeing a stale "install pi" suggestion. - self.last_error_kind = "" - self.last_error_command = "" - self.total_input_tokens += in_tok - self.total_output_tokens += out_tok - # Cost accumulation. We look up pricing per-provider so a - # mixed-provider session (chief on claude, engineer on - # ollama) gets accurate per-provider totals. - try: - prov_name = card.provider or self.provider or "" - prov_overrides = {} - if self.paths is not None: - try: - from .config import load_providers_config as _lpc - prov_cfg = (_lpc(self.paths).get("providers") or {}).get(prov_name) or {} - prov_overrides = { - "pricing": prov_cfg.get("pricing"), - "pricing_by_model": prov_cfg.get("pricing_by_model"), - } - except Exception: - prov_overrides = {} - model = (usage.get("model") or "") or "" - run_usd = estimate_usd(prov_name, model, in_tok, out_tok, prov_overrides) - card.last_usd = run_usd - card.total_usd = (getattr(card, "total_usd", 0.0) or 0.0) + run_usd - self.total_usd += run_usd - self.cost_per_provider[prov_name] = self.cost_per_provider.get(prov_name, 0.0) + run_usd - except Exception as exc: - log_exception("tui.end_agent.cost", exc) - self.total_tokens += tot_tok - self.total_files += len(files_written) - if tot_tok > self.peak_run_tokens: - self.peak_run_tokens = tot_tok - self.add_log( - f"{name} :: {'ok' if ok else 'fail'} :: " - f"tok={_format_tokens(int(usage.get('total_tokens') or 0))} " - f"files={len(files_written)} :: " - f"{(preview or '').strip().splitlines()[0][:60] if preview else ''}", - level="INFO" if ok else "WARN", - ) - if provider_issue: - self.add_log( - f"{name} :: provider issue :: {error[:200]}", - level="ERROR", - ) - self.add_log( - f"{name} :: resolution :: {self._provider_resolution_message(error)}", - level="WARN", - ) - # File-action log lines: one INFO entry per file so the user - # sees creation activity as it happens. - for fpath in files_written: - self.add_log(f"{name} :: wrote {fpath}", level="SYSTEM") - - def _provider_resolution_message(self, error: str) -> str: - msg = (error or "").lower() - if "rate limit" in msg or "quota" in msg: - return "provider rate/quota failure; backing off by aborting this cycle, then retry after quota/reset or switch provider" - if "timeout" in msg or "no output" in msg or "operation was aborted" in msg: - return "provider call stalled/aborted; stalled PID is killed, configured retries are reissued with backoff, then the cycle stops if retries fail" - if "api key" in msg or "authentication" in msg or "unauthorized" in msg or "forbidden" in msg: - return "provider auth/config failure; fix credentials or switch provider before retrying" - if "no endpoints available" in msg or "guardrail restrictions" in msg or "data policy" in msg: - return "provider endpoint/policy routing issue; configured retries are reissued with backoff because this can be transient, then switch provider or adjust provider privacy settings if retries fail" - if "cli not found" in msg or "not found" in msg: - return "provider executable/config missing; install/configure provider or switch provider" - return "provider failure; workflow recovery is aborted until the provider is fixed or changed" - - def upsert_agent(self, name: str, *, role: str = "", baseline: bool = False, status: str = "added") -> None: - with self.lock: - card = self.agents.setdefault(name, AgentCard(name=name)) - if role: - card.role = role - card.is_baseline = baseline - card.roster_status = status - - def drop_agent(self, name: str) -> None: - with self.lock: - self.agents.pop(name, None) - - def set_phase(self, phase: str, busy: Optional[bool] = None) -> None: - with self.lock: - self.phase = phase - if busy is not None: - self.busy = busy - - def set_idea(self, idea: str) -> None: - with self.lock: - self.idea = idea[:500] - - def add_user_message(self, text: str) -> None: - with self.lock: - self.conversation.append(("user", text)) - self.add_log(text, level="USER") - - def add_system_message(self, text: str) -> None: - with self.lock: - self.conversation.append(("system", text)) - self.add_log(text, level="SYSTEM") - - def request_stop(self) -> None: - with self.lock: - self.stop_requested = True - self.add_log("stop requested by user", level="WARN") - - def clear_stop(self) -> None: - with self.lock: - self.stop_requested = False - - def is_stop_requested(self) -> bool: - with self.lock: - return self.stop_requested - - def snapshot(self) -> Dict[str, Any]: - with self.lock: - return { - "agents": {k: vars(v).copy() for k, v in self.agents.items()}, - "log": list(self.log), - "idea": self.idea, - "project_name": self.project_name, - "provider": self.provider, - "phase": self.phase, - "busy": self.busy, - "input_buffer": self.input_buffer, - "input_cursor": self.input_cursor, - "conversation": list(self.conversation), - } - - -# --------------------------------------------------------------------------- -# Observer that updates the dashboard -# --------------------------------------------------------------------------- - - -class DashboardObserver(AgentObserver): - def __init__(self, state: "DashboardState") -> None: - self.state = state - - def begin(self, agent: str, objective: str) -> None: - self.state.begin_agent(agent, objective) - - def prompt_sent(self, agent: str, prompt: str) -> None: - self.state.set_agent_prompt(agent, prompt) - - def end(self, agent: str, run) -> None: # type: ignore[override] - ok = bool(run.response.ok) - preview = run.response.text or "" - err = run.response.error or "" - self.state.end_agent( - agent, - ok=ok, - preview=preview, - error=err, - files_written=list(run.files_written or []), - usage=dict(run.response.usage or {}), - ) - - def progress(self, agent: str, kind: str, message: str) -> None: - self.state.report_progress(agent, kind, message) - - def log(self, line: str) -> None: - self.state.add_log(line) - - def roster_changed(self, name: str, status: str) -> None: - # workflow_written is a workflow event, not a roster change — log it - # separately and don't create/modify an agent card for it. - if status == "workflow_written": - self.state.add_log(f"workflow :: {name} :: written", level="INFO") - return - # Refresh the card from the (just-mutated) agents config so the - # role / baseline / provider fields stay accurate. - try: - agents = (load_agents_config(self.state.paths).get("agents") or {}) if hasattr(self.state, "paths") else {} - cfg = agents.get(name) or {} - except Exception: - cfg = {} - if status == "removed": - self.state.drop_agent(name) - else: - self.state.upsert_agent( - name, - role=cfg.get("role", ""), - baseline=is_baseline(name), - status=status, - ) - self.state.add_log(f"roster :: {name} :: {status}", level="SYSTEM") - - -# --------------------------------------------------------------------------- -# Worker thread -# --------------------------------------------------------------------------- - - -@dataclass -class Job: - kind: str # idea | run | loop | stop | provider | quit | status - payload: Any = None - - -class Worker(threading.Thread): - daemon = True - - def __init__( - self, - paths: Paths, - runner: AgentRunner, - evaluator: Evaluator, - state: DashboardState, - clk_cfg: Dict[str, Any], - providers_cfg: Dict[str, Any], - ) -> None: - super().__init__(name="clk-worker") - self.paths = paths - self.runner = runner - self.evaluator = evaluator - self.state = state - self.clk_cfg = clk_cfg - self.providers_cfg = providers_cfg - self.q: queue.Queue[Job] = queue.Queue() - self._alive = True - - def submit(self, job: Job) -> None: - self.q.put(job) - - def stop(self) -> None: - self._alive = False - self.q.put(Job("quit")) - - # --- main loop ------------------------------------------------------- - - def run(self) -> None: - while self._alive: - try: - job = self.q.get(timeout=0.25) - except queue.Empty: - continue - try: - self._dispatch(job) - except Exception as exc: - log_exception("tui.Worker.run", exc) - self.state.add_log(f"worker error: {exc}", level="ERROR") - self.state.set_phase("idle", busy=False) - - def _dispatch(self, job: Job) -> None: - if job.kind == "quit": - self._alive = False - return - if job.kind == "idea": - self._do_idea(job.payload or "") - elif job.kind == "cast": - self._do_cast() - elif job.kind == "run": - self._do_workflow(job.payload or "engineering") - elif job.kind == "mission": - self._do_mission() - elif job.kind == "loop": - payload = job.payload or {} - self._do_loop( - mode=payload.get("mode", "ralph"), - n=int(payload.get("n", self.clk_cfg.get("max_iterations") or 5)), - ) - elif job.kind == "stop": - self.state.request_stop() - elif job.kind == "provider": - self._do_set_provider(job.payload or "shell") - elif job.kind == "status": - self._emit_status() - elif job.kind == "roles": - self._do_roles(job.payload or {}) - elif job.kind == "abort": - # /abort runs in the curses thread (not the worker) because - # the worker is blocked on the very subprocess we're killing. - # No-op here; see TuiApp._do_abort. - pass - elif job.kind == "install": - self._do_install(job.payload or "") - elif job.kind == "configure": - self._do_configure(job.payload or "") - elif job.kind == "github": - self._do_github() - elif job.kind == "undo": - self._do_undo(bool((job.payload or {}).get("confirm"))) - elif job.kind == "doctor": - self._do_doctor(bool((job.payload or {}).get("fix"))) - elif job.kind == "diag": - self._do_diag() - elif job.kind == "tutorial": - self._do_tutorial() - elif job.kind == "workspaces": - self._do_workspaces(job.payload or {}) - - # --- handlers -------------------------------------------------------- - - def _do_idea(self, idea: str) -> None: - idea = idea.strip() - if not idea: - return - self.state.set_phase("idea", busy=True) - self.state.set_idea(idea) - title = idea.split(".")[0][:80] or idea[:80] - try: - save_json( - self.paths.state / "idea.json", - { - "title": title, - "statement": idea, - "captured_at": datetime.now().isoformat(timespec="seconds"), - "tags": [], - }, - ) - (self.paths.state / "system_brief.md").write_text( - f"# System brief\n\n**Title:** {title}\n\n## Idea\n{idea}\n", - encoding="utf-8", - ) - self.state.add_system_message( - f"got it — idea captured as '{title}'. The chief will cast a " - f"team next; agent cards above will turn yellow as they start." - ) - self._maybe_commit("clk-tui-idea", f"Capture idea: {title}", "idea captured", ".clk/state/idea.json") - except Exception as exc: - log_exception("tui.Worker._do_idea", exc) - self.state.add_log(f"idea save failed: {exc}", level="ERROR") - finally: - self.state.set_phase("idle", busy=False) - - def _do_cast(self) -> None: - idea_path = self.paths.state / "idea.json" - if not idea_path.exists(): - self.state.add_log("cast skipped: no idea captured yet", level="WARN") - return - try: - payload = json.loads(idea_path.read_text(encoding="utf-8")) - title = payload.get("title") or "Untitled idea" - statement = payload.get("statement") or "" - except Exception as exc: - log_exception("tui.Worker._do_cast.read_idea", exc) - return - self.state.set_phase("casting", busy=True) - try: - objective = casting_objective(title, statement) - self.runner.run("chief", objective, extra={"phase": "casting"}) - self.state.add_system_message( - "casting :: " + render_roster_summary(self.paths).replace("\n", " | ")[:240] - ) - except Exception as exc: - log_exception("tui.Worker._do_cast", exc) - self.state.add_log(f"casting failed: {exc}", level="ERROR") - finally: - self.state.set_phase("idle", busy=False) - - def _do_roles(self, payload: Dict[str, Any]) -> None: - action = payload.get("action") or "list" - if action == "list": - summary = render_roster_summary(self.paths) - for line in summary.splitlines(): - self.state.add_log(line, level="SYSTEM") - return - name = payload.get("name") or "" - if action == "add": - prop = RoleProposal(name=name, role=payload.get("role", ""), provider=payload.get("provider")) - ok, status = register_role( - self.paths, - prop, - agents_cfg=self.runner.agents_cfg, - on_change=lambda n, s: self.state.upsert_agent( - n, role=prop.role, baseline=is_baseline(n), status=s - ), - ) - self.state.add_log(f"roles add {name}: {status}", level="SYSTEM" if ok else "WARN") - return - if action == "remove": - ok, status = remove_role( - self.paths, - name, - agents_cfg=self.runner.agents_cfg, - on_change=lambda n, s: self.state.drop_agent(n) if s == "removed" else None, - ) - self.state.add_log(f"roles remove {name}: {status}", level="SYSTEM" if ok else "WARN") - return - self.state.add_log(f"unknown roles action: {action}", level="WARN") - - def _do_workflow(self, name: str) -> None: - wf_path = self.paths.workflows / f"{name}.yaml" - if not wf_path.exists(): - self.state.add_log( - f"workflow '{name}' not found — try /run engineering or check " - f".clk/config/workflows/ for the available list", - level="WARN", - ) - return - self.state.set_phase(f"workflow:{name}", busy=True) - self.state.add_system_message( - f"starting workflow '{name}' — the chief will cast a team and " - f"dispatch agents stage by stage. Watch the cards above for live progress." - ) - any_failure = False - try: - wf = load_workflow(wf_path) - wf_runner = WorkflowRunner(self.paths, self.runner) - wf_runner.run(wf) - except Exception as exc: - any_failure = True - log_exception("tui.Worker._do_workflow", exc) - self.state.add_log(f"workflow '{name}' hit an error: {exc}", level="ERROR") - finally: - self.state.set_phase("idle", busy=False) - # Friendly post-flight summary. Always tell the user what - # they can do next — even on failure — so they're never - # stuck wondering "is something broken? what do I do?". - with self.state.lock: - tot = self.state.total_tokens - usd = self.state.total_usd - files = self.state.total_files - err_kind = self.state.last_error_kind - err_cmd = self.state.last_error_command - if any_failure or err_kind: - self.state.add_system_message( - f"workflow '{name}' finished with issues. session tokens={_format_tokens(tot)} " - f"cost={format_usd(usd)} files={files}" - ) - if err_cmd: - self.state.add_system_message( - f"suggested next step: {err_cmd} (or /provider to switch)" - ) - else: - self.state.add_system_message( - "suggested next step: /status to inspect, /undo to roll back, " - "or type a follow-up message" - ) - else: - self.state.add_system_message( - f"workflow '{name}' complete. session tokens={_format_tokens(tot)} " - f"cost={format_usd(usd)} files={files}" - ) - self.state.add_system_message( - "next steps: type a follow-up message to keep going, " - "/loop ralph 5 to refine, /undo to revert, or /quit to exit." - ) - - def _do_mission(self) -> None: - """Drive the autonomous mission (charter -> plan -> phases -> done).""" - self.state.clear_stop() - self.state.set_phase("mission", busy=True) - self.state.add_system_message( - "starting autonomous mission — the chief writes a charter and plan, " - "then drives the lifecycle to a code-gated done. Watch the cards above; " - "type /stop to end after the current cycle." - ) - any_failure = False - try: - mr = MissionRunner(self.paths, self.runner, self.evaluator) - plan = mr.run() - self.state.add_system_message( - f"mission {plan.status}: " - f"{sum(1 for p in plan.phases if p.status == 'done')}/{len(plan.phases)} " - f"phases done, {plan.total_cycles_used} cycles." - ) - if plan.status != "done" and (plan.done_gate_last or {}).get("failures"): - self.state.add_system_message( - "done-gate unmet: " + ", ".join(plan.done_gate_last["failures"]) - ) - except Exception as exc: - any_failure = True - log_exception("tui.Worker._do_mission", exc) - self.state.add_log(f"mission hit an error: {exc}", level="ERROR") - finally: - self.state.set_phase("idle", busy=False) - if not any_failure: - self.state.add_system_message( - "next steps: type a follow-up to extend the mission, " - "/loop ralph 5 to refine, /undo to revert, or /quit." - ) - - def _do_loop(self, mode: str, n: int) -> None: - self.state.clear_stop() - self.state.set_phase(f"loop:{mode}", busy=True) - self.state.add_system_message( - f"starting {mode} loop for up to {n} iterations. " - f"Type /stop to end after the current cycle, or /abort to kill a stuck call." - ) - interrupted = False - completed = 0 - try: - if mode == "ralph": - # We can't preempt mid-iteration, but we can check between iterations - # by running one iteration at a time. - for i in range(1, n + 1): - if self.state.is_stop_requested(): - interrupted = True - self.state.add_log( - f"loop interrupted after iteration {i - 1} of {n}", - level="WARN", - ) - break - self.state.iteration_count = i - self.state.add_system_message( - f"ralph iteration {i}/{n} — refining the previous output" - ) - sub = RalphLoop(self.paths, self.runner, self.evaluator, max_iterations=1) - sub.run() - completed = i - else: - for i in range(1, n + 1): - if self.state.is_stop_requested(): - interrupted = True - self.state.add_log( - f"loop interrupted after iteration {i - 1} of {n}", - level="WARN", - ) - break - self.state.iteration_count = i - self.state.add_system_message( - f"autoresearch iteration {i}/{n} — exploring open questions" - ) - sub = AutoresearchLoop(self.paths, self.runner, self.evaluator, max_iterations=1) - sub.run() - completed = i - except Exception as exc: - log_exception("tui.Worker._do_loop", exc) - self.state.add_log(f"loop hit an error and stopped: {exc}", level="ERROR") - finally: - self.state.set_phase("idle", busy=False) - verb = "stopped" if interrupted else "complete" - self.state.add_system_message( - f"{mode} loop {verb} after {completed} iteration(s). " - f"Type /status for the breakdown, /loop {mode} {n} to keep going, " - f"or a follow-up message to redirect." - ) - - def _do_set_provider(self, name: str) -> None: - try: - cfg_path = self.paths.config / "providers.json" - data = json.loads(cfg_path.read_text(encoding="utf-8")) - if name not in (data.get("providers") or {}): - self.state.add_log( - f"'{name}' isn't a known provider. valid: " - f"{', '.join(sorted((data.get('providers') or {}).keys()))}", - level="WARN", - ) - return - old_name = self.state.provider or "(unset)" - data["active"] = name - save_json(cfg_path, data) - self.providers_cfg = data - self.runner.providers_cfg = data - with self.state.lock: - self.state.provider = name - # New provider — clear stale error hints so the bar doesn't - # keep suggesting /install . - self.state.last_error_kind = "" - self.state.last_error_command = "" - # Check that the new provider is actually usable so we can - # warn before the user's next call fails. - try: - from .providers import available_providers as _ap - avail = _ap(data) - if avail.get(name): - self.state.add_system_message( - f"provider: {old_name} → {name} (ready)" - ) - else: - self.state.add_system_message( - f"provider: {old_name} → {name} (NOT ready — try /install {name} or /configure {name})" - ) - with self.state.lock: - self.state.last_error_kind = "not_installed" - self.state.last_error_command = f"/install {name}" - except Exception: - self.state.add_system_message(f"provider switched to {name}") - except Exception as exc: - log_exception("tui.Worker._do_set_provider", exc) - self.state.add_log(f"provider switch failed: {exc}", level="ERROR") - - def _emit_status(self) -> None: - snap = self.state.snapshot() - # Header: short narrative the user can read at a glance. - phase = snap.get("phase") or "idle" - busy = snap.get("busy") - provider = snap.get("provider") or "shell" - agents = snap.get("agents") or {} - narrative = ( - f"working on '{phase}'" if busy else f"idle (last phase: '{phase}')" - ) - self.state.add_system_message( - f"--- session snapshot ---" - ) - self.state.add_system_message( - f" status {narrative}" - ) - self.state.add_system_message( - f" provider {provider}" - ) - self.state.add_system_message( - f" agents {len(agents)} ({', '.join(sorted(agents.keys())) or 'none yet'})" - ) - # Cost breakdown — same numbers the title bar shows, but split by - # provider so the user can see where the spend went. - with self.state.lock: - usd = self.state.total_usd - per = dict(self.state.cost_per_provider) - tot = self.state.total_tokens - files = self.state.total_files - idea = self.state.idea[:80] - self.state.add_system_message( - f" tokens {_format_tokens(tot)} files written: {files}" - ) - self.state.add_system_message(f" est. cost {format_usd(usd)}") - if per: - for p, amount in sorted(per.items()): - if amount > 0: - self.state.add_system_message(f" - {p:<10} {format_usd(amount)}") - if idea: - self.state.add_system_message(f" idea {idea}") - self.state.add_system_message( - "------------------------" - ) - - # ----- subprocess helpers used by /install /configure /doctor ---------- - - def _run_capture(self, cmd: List[str], cwd: Optional[Path] = None) -> Tuple[int, str, str]: - """Run a subprocess, stream its output into the log pane, and - return (rc, stdout, stderr). Used by /install, /configure, etc. - These commands are interactive (they prompt y/N, ask for keys) - so we do NOT capture stdin — the subprocess inherits ours and - runs against /dev/tty when sourced functions need it. - """ - import subprocess - try: - proc = subprocess.Popen( - cmd, - cwd=str(cwd) if cwd else None, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - ) - except Exception as exc: - log_exception("tui.Worker._run_capture", exc) - return 1, "", str(exc) - out_lines: List[str] = [] - err_lines: List[str] = [] - # Drain both pipes line-by-line so the log pane sees progress. - import threading as _t - def _pump(stream, sink, level): - try: - for line in stream: - line = line.rstrip() - if line: - sink.append(line) - self.state.add_log(line, level=level) - except Exception: - pass - t1 = _t.Thread(target=_pump, args=(proc.stdout, out_lines, "INFO"), daemon=True) - t2 = _t.Thread(target=_pump, args=(proc.stderr, err_lines, "WARN"), daemon=True) - t1.start(); t2.start() - rc = proc.wait() - t1.join(timeout=1); t2.join(timeout=1) - return rc, "\n".join(out_lines), "\n".join(err_lines) - - def _script(self, name: str) -> Path: - # Locate scripts/ relative to the harness install. - return Path(__file__).resolve().parent.parent / "scripts" / name - - # ----- /install --------------------------------------------------------- - - def _do_install(self, tool: str) -> None: - tool = (tool or "").strip() - if not tool: - self.state.add_log("install: no tool specified", level="WARN") - return - self.state.set_phase(f"install {tool}", busy=True) - try: - script = self._script("install_tool.sh") - if not script.exists(): - self.state.add_log(f"install: {script} not found", level="ERROR") - return - rc, _out, err = self._run_capture(["bash", str(script), "install", tool, "--prompt"]) - if rc == 0: - self.state.add_system_message(f"install {tool}: done") - # Clear the not_installed hint so the bar updates. - with self.state.lock: - if self.state.last_error_kind == "not_installed": - self.state.last_error_kind = "" - self.state.last_error_command = "" - else: - self.state.add_log(f"install {tool}: rc={rc} {err[:200]}", level="ERROR") - finally: - self.state.set_phase("idle", busy=False) - - # ----- /configure ------------------------------------------------------- - - def _do_configure(self, tool: str) -> None: - tool = (tool or "").strip() - if not tool: - self.state.add_log("configure: no tool specified", level="WARN") - return - self.state.set_phase(f"configure {tool}", busy=True) - try: - script = self._script("install_tool.sh") - rc, _out, err = self._run_capture(["bash", str(script), "configure", tool]) - if rc == 0: - self.state.add_system_message(f"configure {tool}: done") - with self.state.lock: - if self.state.last_error_kind == "auth": - self.state.last_error_kind = "" - self.state.last_error_command = "" - else: - self.state.add_log(f"configure {tool}: rc={rc} {err[:200]}", level="ERROR") - finally: - self.state.set_phase("idle", busy=False) - - # ----- /github ---------------------------------------------------------- - - def _do_github(self) -> None: - # GitHub re-link from inside the TUI. We don't re-run the full - # wizard here — we just print the current state and the - # instructions. The wizard prompts via /dev/tty which the curses - # screen has already taken over, so attempting an interactive - # prompt from within the TUI would corrupt the display. - self.state.set_phase("github", busy=True) - try: - root = self.paths.root - rc, out, _ = self._run_capture(["git", "-C", str(root), "remote", "-v"]) - self.state.add_system_message("current git remotes:") - for line in (out or "").splitlines(): - self.state.add_system_message(f" {line}") - self.state.add_system_message( - "to (re-)link a remote, /quit then run: ./kickoff.sh --setup" - ) - self.state.add_system_message( - "the wizard's GitHub block handles create | existing | skip safely from /dev/tty" - ) - finally: - self.state.set_phase("idle", busy=False) - - # ----- /undo ------------------------------------------------------------ - - def _do_undo(self, confirm: bool) -> None: - root = self.paths.root - try: - if has_changes(root): - self.state.add_log( - "undo refused: uncommitted changes in the workspace. " - "Commit or stash first.", - level="WARN", - ) - return - # Show the diff of HEAD before doing anything. - rc, out, err = self._run_capture( - ["git", "-C", str(root), "log", "-1", "--stat"] - ) - if rc != 0: - self.state.add_log(f"undo: cannot read HEAD: {err}", level="ERROR") - return - if not confirm: - self.state.add_system_message("last commit (HEAD):") - for line in (out or "").splitlines()[:40]: - self.state.add_system_message(f" {line}") - self.state.add_system_message( - "type /undo confirm to revert this commit (creates a new revert commit)" - ) - return - rc, _out, err = self._run_capture( - ["git", "-C", str(root), "revert", "--no-edit", "HEAD"] - ) - if rc == 0: - self.state.add_system_message("undo: HEAD reverted with a new commit.") - else: - self.state.add_log(f"undo: revert failed: {err}", level="ERROR") - except Exception as exc: - log_exception("tui.Worker._do_undo", exc) - self.state.add_log(f"undo error: {exc}", level="ERROR") - - # ----- /doctor ---------------------------------------------------------- - - def _do_doctor(self, fix: bool) -> None: - self.state.set_phase("doctor", busy=True) - try: - from .providers import available_providers - from .config import load_clk_config as _lcc, load_providers_config as _lpc - prov_cfg = _lpc(self.paths) - clk_cfg = _lcc(self.paths) - auth_mode = (clk_cfg.get("auth_mode") or "cli").lower() if isinstance(clk_cfg, dict) else "cli" - findings: List[Tuple[str, str, str]] = [] # (level, name, message) - avail = available_providers(prov_cfg) - active = prov_cfg.get("active") or clk_cfg.get("default_provider") or "shell" - for name, ok in avail.items(): - if ok: - findings.append(("ok", name, "available")) - else: - findings.append(("warn" if name != active else "fail", name, "unavailable")) - # Known-bad combos. - import os as _os - if active == "claude" and auth_mode == "apikey" and not _os.environ.get("ANTHROPIC_API_KEY"): - findings.append(("fail", "anthropic_key", "CLK_AUTH_MODE=apikey but ANTHROPIC_API_KEY is unset")) - if active == "codex" and auth_mode == "apikey" and not _os.environ.get("OPENAI_API_KEY"): - findings.append(("fail", "openai_key", "CLK_AUTH_MODE=apikey but OPENAI_API_KEY is unset")) - # Git / GitHub. - if not is_repo(self.paths.root): - findings.append(("warn", "git", "no git repo at project root; auto-commit disabled")) - # Emit. - for level, name, msg in findings: - self.state.add_system_message(f"doctor :: [{level:<4}] {name}: {msg}") - failures = [f for f in findings if f[0] == "fail"] - if not failures: - self.state.add_system_message("doctor: all checks passed.") - return - if not fix: - self.state.add_system_message( - f"doctor: {len(failures)} failure(s). Re-run as /doctor --fix to attempt repairs." - ) - return - for _, name, _ in failures: - if name in ("anthropic_key", "openai_key"): - self.state.add_system_message( - f"doctor --fix: run /configure {active} to set the missing API key" - ) - elif name == active: - self.state.add_system_message(f"doctor --fix: run /install {name}") - except Exception as exc: - log_exception("tui.Worker._do_doctor", exc) - self.state.add_log(f"doctor error: {exc}", level="ERROR") - finally: - self.state.set_phase("idle", busy=False) - - # ----- /diag ------------------------------------------------------------ - - def _do_diag(self) -> None: - import tarfile - import time as _time - ts = _time.strftime("%Y%m%d-%H%M%S") - out_path = self.paths.root / f"clk-diag-{ts}.tar.gz" - self.state.set_phase("diag", busy=True) - try: - # Build a redacted .env first in a tempfile. - env_path = self.paths.root / ".env" - redacted = None - if env_path.exists(): - redacted_lines = [] - for line in env_path.read_text(encoding="utf-8").splitlines(): - if "=" in line and not line.lstrip().startswith("#"): - k, v = line.split("=", 1) - if any(s in k.upper() for s in ("KEY", "TOKEN", "SECRET", "PASS")): - v = f"" - redacted_lines.append(f"{k}={v}") - else: - redacted_lines.append(line) - redacted = self.paths.state / ".env.redacted" - redacted.write_text("\n".join(redacted_lines) + "\n", encoding="utf-8") - - with tarfile.open(out_path, "w:gz") as tf: - # Pick up logs (last ~5MB total), state, last 3 runs. - for sub in ("logs", "state"): - d = self.paths.clk / sub - if d.exists(): - tf.add(d, arcname=f".clk/{sub}") - runs_dir = self.paths.runs - if runs_dir.exists(): - runs = sorted([p for p in runs_dir.glob("*") if p.is_dir()], - reverse=True)[:3] - for r in runs: - tf.add(r, arcname=f".clk/runs/{r.name}") - if redacted and redacted.exists(): - tf.add(redacted, arcname=".env.redacted") - if redacted and redacted.exists(): - redacted.unlink() - self.state.add_system_message(f"diag: wrote {out_path}") - self.state.add_system_message("share this tarball in your bug report (API keys are redacted)") - except Exception as exc: - log_exception("tui.Worker._do_diag", exc) - self.state.add_log(f"diag error: {exc}", level="ERROR") - finally: - self.state.set_phase("idle", busy=False) - - # ----- /tutorial -------------------------------------------------------- - - def _do_tutorial(self) -> None: - # Switch to the shell provider, sandbox state under - # .clk/state/.tutorial/, run one engineering cycle, restore. - original_provider = self.state.provider - try: - with self.state.lock: - self.state.in_tutorial = True - self.state.add_system_message( - "tutorial: switching to the shell provider; nothing will be charged." - ) - self._do_set_provider("shell") - self.state.add_system_message( - "tutorial: idea = 'Add a hello() function to greeter.py'" - ) - self._do_idea("Add a hello() function to greeter.py") - self._do_workflow("engineering") - self.state.add_system_message("tutorial: done. Type /quit, or type an idea to keep going.") - # Mark seen so the welcome banner stops mentioning the tutorial. - try: - if self.paths and self.paths.state: - (self.paths.state / ".seen-tutorial").write_text("seen\n", encoding="utf-8") - except Exception: - pass - except Exception as exc: - log_exception("tui.Worker._do_tutorial", exc) - self.state.add_log(f"tutorial error: {exc}", level="ERROR") - finally: - with self.state.lock: - self.state.in_tutorial = False - # Restore the user's previous provider if it was something other than shell. - if original_provider and original_provider != "shell": - self._do_set_provider(original_provider) - - # ----- /workspaces ------------------------------------------------------ - - def _do_workspaces(self, payload: Dict[str, Any]) -> None: - action = (payload.get("action") or "list").lower() - args = payload.get("args") or [] - # Workspaces live one dir above the kickoff dir (the kickoff was - # created under /workspace/kickoff-). Walk up to find - # the workspace/ parent. - kickoff_dir = self.paths.root - ws_parent = kickoff_dir.parent if kickoff_dir.parent.name == "workspace" else (kickoff_dir / ".." / "..").resolve() / "workspace" - if action == "list": - if not ws_parent.exists(): - self.state.add_system_message("workspaces: no workspace/ dir found") - return - count = 0 - for d in sorted(ws_parent.glob("kickoff-*"), reverse=True): - if not d.is_dir(): - continue - count += 1 - idea = "" - idea_path = d / ".clk" / "state" / "idea.json" - if idea_path.exists(): - try: - idea = (json.loads(idea_path.read_text(encoding="utf-8")).get("title") or "")[:60] - except Exception: - idea = "" - marker = "* " if d.resolve() == kickoff_dir.resolve() else " " - self.state.add_system_message(f"{marker}{d.name} :: {idea}") - if count == 0: - self.state.add_system_message("workspaces: no kickoff dirs yet") - elif action == "rename": - if len(args) < 2: - self.state.add_log("workspaces rename: usage /workspaces rename ", level="WARN") - return - old, new = ws_parent / args[0], ws_parent / args[1] - if not old.exists(): - self.state.add_log(f"workspaces rename: {old} not found", level="WARN") - return - if new.exists(): - self.state.add_log(f"workspaces rename: {new} already exists", level="WARN") - return - old.rename(new) - self.state.add_system_message(f"workspaces: renamed {args[0]} -> {args[1]}") - elif action == "switch": - self.state.add_system_message( - "workspaces switch: /quit this TUI, then cd into the target dir and run ./.clk/scripts/clk tui" - ) - elif action == "clean": - self.state.add_system_message( - "workspaces clean: run `./kickoff.sh --clean 7d` from the repo root — " - "it prompts before deleting." - ) - else: - self.state.add_log(f"workspaces: unknown action {action}", level="WARN") - - def _maybe_commit(self, agent: str, objective: str, validation: str, *files: str) -> None: - try: - if not is_repo(self.paths.root): - return - if not has_changes(self.paths.root): - return - if not add_all(self.paths.root): - return - ok = git_commit( - self.paths.root, - agent=agent, - objective=objective, - files_changed=list(files), - validation=validation, - next_step="continue conversation", - ) - if not ok: - return - # Push to GitHub if the user opted in (CLK_GITHUB_PUSH_ON_COMMIT=true) - # and there's actually a remote. Errors are non-fatal — the - # commit is local-only until the user can push themselves. - import os - push_on_commit = os.environ.get("CLK_GITHUB_PUSH_ON_COMMIT", "false").lower() == "true" - if push_on_commit and has_remote(self.paths.root): - self.state.add_log("pushing commit to origin…", level="SYSTEM") - if git_push(self.paths.root): - self.state.add_log("push succeeded.", level="SYSTEM") - else: - self.state.add_log( - "push failed — commit is still saved locally. /github to re-check the remote.", - level="WARN", - ) - # Refresh the title-bar ahead counter either way so the user - # can see at a glance how many unpushed commits they have. - try: - ahead = commits_ahead(self.paths.root) - with self.state.lock: - self.state.github_ahead = ahead - except Exception: - pass - except Exception as exc: - log_exception("tui.Worker._maybe_commit", exc) - - -# --------------------------------------------------------------------------- -# Curses UI -# --------------------------------------------------------------------------- - - -class TuiApp: - """Curses front end.""" - - COLOR_TITLE = 1 - COLOR_IDLE = 2 - COLOR_WORKING = 3 - COLOR_DONE = 4 - COLOR_FAILED = 5 - COLOR_LOG_INFO = 6 - COLOR_LOG_WARN = 7 - COLOR_LOG_ERROR = 8 - COLOR_LOG_USER = 9 - COLOR_LOG_SYS = 10 - COLOR_FRAME = 11 - COLOR_PROMPT = 12 - - def __init__(self, state: DashboardState, worker: Worker) -> None: - self.state = state - self.worker = worker - self.scroll_offset = 0 # lines to skip from the bottom of the log - self._spinner = "|/-\\" - self._spinner_idx = 0 - self._last_render = 0.0 - # Heartbeat: when an agent has been WORKING for a while we emit - # "still working: Ns" log lines so the user knows the process - # is alive (vs. genuinely stuck). Per-agent so one slow chief - # doesn't flood the pane. - self._heartbeat_last: Dict[str, float] = {} - # Heartbeat cadence. Longer than the original 8s/8s so a normal - # 30-60s model call doesn't drown the log in "still working" lines. - self.HEARTBEAT_FIRST_S = 15.0 # first heartbeat after this much silence - self.HEARTBEAT_REPEAT_S = 15.0 # then every N seconds while still working - # /help modal overlay: toggled by /help, F1, or `?` when the input - # buffer is empty. Esc/q dismisses. While visible, _render draws - # an extra centred panel above everything else. - self._help_visible: bool = False - - # --- entrypoint ------------------------------------------------------ - - def run(self) -> None: - try: - curses.wrapper(self._loop) - except Exception as exc: - log_exception("tui.TuiApp.run", exc) - - # --- main loop ------------------------------------------------------- - - def _loop(self, stdscr: "curses._CursesWindow") -> None: - curses.curs_set(1) - stdscr.nodelay(True) - stdscr.timeout(80) - self._init_colors() - # First-run welcome: a one-time multi-line greeting that explains - # what CLK is, what agents are, and the most useful commands. We - # gate on a marker file under .clk/state so subsequent runs show - # a one-liner instead. - self._emit_welcome() - # Proactive provider health check — surface broken providers - # *before* the user types their first idea so they don't get a - # surprise "cli not found" three seconds in. - self._emit_provider_health() - while True: - try: - self._render(stdscr) - ch = stdscr.getch() - if ch == -1: - self._spinner_idx = (self._spinner_idx + 1) % len(self._spinner) - self._tick_heartbeat() - continue - if not self._handle_key(ch): - break - except KeyboardInterrupt: - break - except Exception as exc: - log_exception("tui.TuiApp._loop", exc) - self.state.add_log(f"render error: {exc}", level="ERROR") - self.worker.stop() - - # --- welcome & health ------------------------------------------------ - - def _emit_welcome(self) -> None: - """Emit the welcome banner. Multi-line on first run, one-liner after.""" - marker = self.state.paths.state / ".seen-welcome" if self.state.paths else None - first_run = True - if marker is not None: - try: - first_run = not marker.exists() - except Exception: - first_run = True - if first_run: - lines = [ - "Welcome to CLK — Cognitive Loop Kernel.", - "Type any idea below and a team of agents (chief, qa, ralph + dynamic roles)", - "will plan, build, and refine it together. Each commit is checkpointed in git.", - "", - "Quick commands:", - " /help see the full command list (or press F1)", - " /tutorial run a 30-second sample idea on the shell provider (free)", - " /provider X switch the active AI (claude, ollama, pi, …)", - " /quit exit (Ctrl-D also works)", - ] - for line in lines: - self.state.add_system_message(line) - if marker is not None: - try: - marker.parent.mkdir(parents=True, exist_ok=True) - marker.write_text("seen\n", encoding="utf-8") - except Exception: - pass - else: - self.state.add_system_message( - "Welcome back. Type an idea, or /help for commands." - ) - - def _emit_provider_health(self) -> None: - """Surface the available/broken status of each configured provider.""" - if not self.state.paths: - return - try: - from .providers import available_providers - from .config import save_providers_config - prov_cfg = load_providers_config(self.state.paths) - # Snapshot endpoints so we can detect auto-failover (localhost -> - # host.docker.internal) inside available_providers and persist - # the swap to providers.json. - before = { - name: (cfg or {}).get("endpoint") - for name, cfg in (prov_cfg.get("providers") or {}).items() - } - avail = available_providers(prov_cfg) - swapped = [] - for name, cfg in (prov_cfg.get("providers") or {}).items(): - new_ep = (cfg or {}).get("endpoint") - if new_ep and before.get(name) and new_ep != before[name]: - swapped.append((name, before[name], new_ep)) - if swapped: - try: - save_providers_config(self.state.paths, prov_cfg) - except Exception as exc: - log_exception("tui.TuiApp._emit_provider_health.save", exc) - for name, old, new in swapped: - self.state.add_log( - f"{name}: {old} unreachable, auto-switched to {new} " - "(host.docker.internal). providers.json updated.", - level="WARN", - ) - active = self.state.provider or prov_cfg.get("active") or "" - self.state.add_log("provider check:", level="SYSTEM") - for name, ok in avail.items(): - tag = "available" if ok else "UNAVAILABLE" - marker = " (active)" if name == active else "" - self.state.add_log(f" {name:<10} {tag}{marker}", level="SYSTEM" if ok else "WARN") - if not avail.get(active, False): - # Pre-load the hint bar so the user sees a fix path - # before typing anything. - with self.state.lock: - self.state.last_error_kind = "not_installed" - self.state.last_error_command = "/install" - except Exception as exc: - log_exception("tui.TuiApp._emit_provider_health", exc) - - # --- colors ---------------------------------------------------------- - - def _init_colors(self) -> None: - try: - curses.start_color() - curses.use_default_colors() - curses.init_pair(self.COLOR_TITLE, curses.COLOR_BLACK, curses.COLOR_CYAN) - curses.init_pair(self.COLOR_IDLE, curses.COLOR_WHITE, -1) - curses.init_pair(self.COLOR_WORKING, curses.COLOR_YELLOW, -1) - curses.init_pair(self.COLOR_DONE, curses.COLOR_GREEN, -1) - curses.init_pair(self.COLOR_FAILED, curses.COLOR_RED, -1) - curses.init_pair(self.COLOR_LOG_INFO, curses.COLOR_WHITE, -1) - curses.init_pair(self.COLOR_LOG_WARN, curses.COLOR_YELLOW, -1) - curses.init_pair(self.COLOR_LOG_ERROR,curses.COLOR_RED, -1) - curses.init_pair(self.COLOR_LOG_USER, curses.COLOR_CYAN, -1) - curses.init_pair(self.COLOR_LOG_SYS, curses.COLOR_MAGENTA,-1) - curses.init_pair(self.COLOR_FRAME, curses.COLOR_BLUE, -1) - curses.init_pair(self.COLOR_PROMPT, curses.COLOR_GREEN, -1) - except Exception as exc: - log_exception("tui.TuiApp._init_colors", exc) - - # --- rendering ------------------------------------------------------- - - INPUT_MAX_ROWS = 5 # cap on how tall the input area can grow - - def _render(self, stdscr: "curses._CursesWindow") -> None: - h, w = stdscr.getmaxyx() - if h < 13 or w < 60: - stdscr.erase() - self._safe_addstr(stdscr, 0, 0, "Terminal too small. Resize to at least 60x13.", w) - stdscr.refresh() - return - stdscr.erase() - self._draw_title(stdscr, w) - grid_bottom = self._draw_agent_grid(stdscr, top=1, height=h, width=w) - idea_bottom = self._draw_idea(stdscr, top=grid_bottom + 1, width=w) - # Compute how many rows the input field needs right now. - with self.state.lock: - buf = self.state.input_buffer - cursor = self.state.input_cursor - input_rows = self._input_row_count(buf, w) - # Layout from the bottom up: - # y=h-input_rows..h-1 input field rows (1..N) - # y=h-input_rows-1 input frame ("---") - # y=h-input_rows-2 hint bar (state-aware) - # y=h-input_rows-3 global token totals - # y=idea_bottom+1..h-input_rows-4 log pane - log_height = max(3, h - idea_bottom - input_rows - 4) - self._draw_log(stdscr, top=idea_bottom + 1, height=log_height, width=w) - self._draw_totals(stdscr, top=h - input_rows - 3, width=w) - self._draw_hint_bar(stdscr, top=h - input_rows - 2, width=w) - self._draw_input(stdscr, top=h - input_rows - 1, width=w, rows=input_rows, cursor=cursor) - if self._help_visible: - self._draw_help_overlay(stdscr, h, w) - stdscr.refresh() - - def _tick_heartbeat(self) -> None: - """Emit a 'still working' note for any agent that's been WORKING - long enough that the user might think the harness is stuck. - - The heartbeat now reports whether the underlying subprocess is - making progress: if stdout/stderr have been streaming (the - ``live_*`` fields are advancing), we say "active"; if they've - been silent for a long time we say "silent" and include the - last line we did see, so the user can tell "this is just slow" - vs "this is genuinely stuck". - """ - now = time.monotonic() - with self.state.lock: - working = [ - ( - name, - card.last_started_mono, - card.live_pid, - card.live_stdout_chars + card.live_stderr_chars, - card.live_last_update_mono, - card.live_last_line, - card.live_cpu_pct, - ) - for name, card in self.state.agents.items() - if card.status == AgentStatus.WORKING - ] - for name, started, pid, total_bytes, last_io, last_line, cpu_str in working: - elapsed = max(0.0, now - started) - last = self._heartbeat_last.get(name, started) - interval = self.HEARTBEAT_FIRST_S if last == started else self.HEARTBEAT_REPEAT_S - if elapsed >= self.HEARTBEAT_FIRST_S and (now - last) >= interval: - self._heartbeat_last[name] = now - silence_s = max(0.0, now - last_io) - if pid: - # Determine whether the process looks alive. - # A live model call typically shows near-zero CPU while - # blocked on a network response but its pid still exists. - # We trust the process is alive unless cpu has been - # reported as exactly zero for a long time. - try: - cpu = float(cpu_str) if cpu_str and cpu_str not in ("", "?") else -1.0 - except Exception: - cpu = -1.0 - # "looks dead" = cpu was reported as exactly 0.0 AND - # we've been waiting a very long time. Near-zero (e.g. - # 0.9%) is normal for a blocked network call. - looks_dead = (cpu == 0.0 and elapsed > 300) - if total_bytes == 0: - if not looks_dead: - # Normal slow model call — no alarm. - flavor = ( - f"awaiting model response " - f"({elapsed:.0f}s, cpu={cpu_str or '?'}%)" - ) - level = "INFO" - else: - flavor = ( - f"no output for {elapsed:.0f}s, cpu=0% " - f"-- process may be dead; /abort to kill and retry" - ) - level = "WARN" - elif silence_s > 5.0: - flavor = ( - f"streaming idle {silence_s:.0f}s " - f"({total_bytes}b received, last='{(last_line or '-')[:60]}')" - ) - level = "INFO" - else: - flavor = f"streaming ({total_bytes}b received)" - level = "INFO" - note = f"pid={pid} :: {flavor}" - else: - note = "no subprocess yet (provider initializing)" - level = "INFO" - self.state.add_log( - f"{name} :: working {elapsed:.0f}s :: {note}", - level=level, - ) - # Forget heartbeats for agents that have finished, so the next - # WORKING period starts fresh. - with self.state.lock: - still_working = {n for n, c in self.state.agents.items() if c.status == AgentStatus.WORKING} - for name in list(self._heartbeat_last.keys()): - if name not in still_working: - self._heartbeat_last.pop(name, None) - - def _input_row_count(self, buf: str, width: int) -> int: - """Number of rows the input field needs to show ``buf`` in full. - - Uses character wrap (not word wrap) at ``width - 1`` so cursor - positioning is straightforward. Capped at INPUT_MAX_ROWS; past - that the buffer scrolls (last N rows visible). - """ - prompt_len = 2 # "> " - total = prompt_len + len(buf) - eff = max(1, width - 1) - rows = max(1, (total + eff - 1) // eff) - return min(self.INPUT_MAX_ROWS, rows) - - def _draw_title(self, stdscr, width: int) -> None: - s = self.state - with s.lock: - project = s.project_name - provider = s.provider - phase = s.phase - busy = s.busy - iteration = s.iteration_count - tot = s.total_tokens - tot_in = s.total_input_tokens - tot_out = s.total_output_tokens - files = s.total_files - usd = s.total_usd - ahead = s.github_ahead - spin = self._spinner[self._spinner_idx] if busy else " " - cost_str = format_usd(usd) - push_str = f" :: ↑{ahead}" if ahead > 0 else "" - title = ( - f" CLK :: {project} :: provider={provider} :: phase={phase} {spin} " - f"iter={iteration} :: tok={_format_tokens(tot)} " - f"(in={_format_tokens(tot_in)}/out={_format_tokens(tot_out)}) :: " - f"{cost_str} :: files={files}{push_str} " - ) - self._fill(stdscr, 0, 0, width, " ", curses.color_pair(self.COLOR_TITLE) | curses.A_BOLD) - self._safe_addstr( - stdscr, 0, 0, title.ljust(width - 1)[: width - 1], width, - curses.color_pair(self.COLOR_TITLE) | curses.A_BOLD, - ) - - def _draw_agent_grid(self, stdscr, *, top: int, height: int, width: int) -> int: - with self.state.lock: - # Sort: baseline first (chief leading), then alphabetical - # dynamics. Keeps the visual anchor stable as roles come and go. - def _sort_key(name: str) -> tuple: - card = self.state.agents[name] - base = 0 if card.is_baseline else 1 - # chief always first among baseline - pri = 0 if name == "chief" else 1 - return (base, pri, name) - - names = sorted(self.state.agents.keys(), key=_sort_key) - cards = [self.state.agents[n] for n in names] - if not cards: - return top - cols = 4 if width >= 84 else (3 if width >= 64 else 2) - rows = (len(cards) + cols - 1) // cols - max_grid_height = max(1, height - 12) - # Prefer 6 (room for two rotating telemetry rows), shrink as - # vertical real estate runs out. - preferred = 6 - card_h = preferred - if rows * card_h > max_grid_height: - card_h = max(3, max_grid_height // rows) - card_w = (width - 1) // cols - for i, card in enumerate(cards): - r = i // cols - c = i % cols - y = top + r * card_h - x = c * card_w - self._draw_card(stdscr, y, x, card_h, card_w, card) - return top + rows * card_h - - # Rotating telemetry views. Each view returns up to two - # ``(label, value)`` pairs which fill the available rows below the - # bar. Cycle period is 2.5s. - _TELEMETRY_PERIOD_S = 2.5 - - def _telemetry_views(self, card: AgentCard) -> List[List[Tuple[str, str]]]: - prov = card.provider or "(default)" - baseline = "baseline" if card.is_baseline else "dynamic" - files_line = ", ".join(card.files_written[:4]) or "-" - if len(card.files_written) > 4: - files_line += f" (+{len(card.files_written)-4})" - # View 1: live work - live = [ - ("task", card.current_task or "-"), - ("rsp", card.provider_resolution or card.last_result or card.last_error or "-"), - ] - # View 2: I/O - io = [ - ("prompt", card.prompt_preview or "-"), - ("files", f"{files_line} (total {card.total_files})"), - ] - # View 3: thinking + meta (incl. tokens) - tok_str = ( - f"tok={_format_tokens(card.total_tokens)}" - f" (in={_format_tokens(card.input_tokens)}" - f"/out={_format_tokens(card.output_tokens)})" - ) - if card.last_usage_source: - tok_str += f"[{card.last_usage_source}]" - meta_line = f"{baseline} | runs={card.runs} | dur={card.last_duration_s:.1f}s | prov={prov}" - thinking = [ - ("think", card.last_thought or card.role or "-"), - ("meta", meta_line), - ] - # View 4: token usage details - tokens = [ - ("tokens", tok_str), - ("source", f"last={card.last_usage_source or '-'}"), - ] - # View 5: live subprocess telemetry. Useful when WORKING - the - # user can see PID + bytes flowing + the most recent stderr/ - # stdout line, so they know the underlying CLI is alive vs. - # genuinely stuck. - live_label = ( - f"pid={card.live_pid}" if card.live_pid - else ("idle" if card.status != AgentStatus.WORKING else "no_pid") - ) - live_meta = ( - f"out={card.live_stdout_chars}b " - f"err={card.live_stderr_chars}b " - f"cpu={card.live_cpu_pct or '?'}% " - f"{live_label}" - ) - subprocess_view = [ - ("live", card.live_last_line or "-"), - ("io", live_meta), - ] - return [live, io, thinking, tokens, subprocess_view] - - def _draw_card(self, stdscr, y: int, x: int, h: int, w: int, card: AgentCard) -> None: - col = { - AgentStatus.IDLE: self.COLOR_IDLE, - AgentStatus.WORKING: self.COLOR_WORKING, # yellow: waiting for response - AgentStatus.RECOVERING: self.COLOR_FAILED, # red: retry backoff in progress - AgentStatus.DONE: self.COLOR_DONE, - AgentStatus.FAILED: self.COLOR_FAILED, - AgentStatus.PROVIDER_ERROR: self.COLOR_FAILED, - }.get(card.status, self.COLOR_IDLE) - attr = curses.color_pair(col) - frame_attr = curses.color_pair(self.COLOR_FRAME) - # Border - try: - self._safe_addstr(stdscr, y, x, "+" + "-" * (w - 2) + "+", w, frame_attr) - for i in range(1, h - 1): - self._safe_addstr(stdscr, y + i, x, "|", w, frame_attr) - self._safe_addstr(stdscr, y + i, x + w - 1, "|", w, frame_attr) - self._safe_addstr(stdscr, y + h - 1, x, "+" + "-" * (w - 2) + "+", w, frame_attr) - except Exception: - pass - inner_w = max(1, w - 4) - # Header line: name (with baseline marker) + status badge - name_label = ("*" if card.is_baseline else " ") + card.name - name_str = name_label[:inner_w] - self._safe_addstr(stdscr, y + 1, x + 2, name_str, inner_w, attr | curses.A_BOLD) - badge = f"[{card.status:<7}] r={card.runs:<3}" - if len(name_str) + 1 + len(badge) <= inner_w: - self._safe_addstr(stdscr, y + 1, x + w - 2 - len(badge), badge, inner_w, attr) - # Activity meter. - # WORKING: a 3-cell ping-pong cursor sweeping the bar; speed - # accelerates as the run goes long, so a stuck call - # looks visibly more frantic than a fresh one. - # IDLE/DONE/FAILED: bar is filled proportional to this agent's - # most recent run's token count divided by the global - # peak run, so visual bar length = relative work. - bar_len = max(0, inner_w - 2) - if bar_len > 0: - with self.state.lock: - peak = self.state.peak_run_tokens - if card.status == AgentStatus.WORKING: - now = time.monotonic() - elapsed = max(0.0, now - card.last_started_mono) - # Fast when tokens are arriving, slow when waiting for the - # model to reply. idle_since measures time since the last - # stdout/stderr line from the subprocess. - idle_since = ( - max(0.0, now - card.live_last_update_mono) - if card.live_last_update_mono > 0 else elapsed - ) - speed = 8.0 if idle_since < 2.0 else 0.8 - cursor_w = 3 - travel = max(1, bar_len - cursor_w) - step = int(elapsed * speed) % (travel * 2) - pos = step if step < travel else (travel * 2 - step) - bar_chars = ["."] * bar_len - for k in range(cursor_w): - if 0 <= pos + k < bar_len: - bar_chars[pos + k] = "=" - bar = "".join(bar_chars) - else: - if peak > 0 and card.last_run_tokens > 0: - ratio = min(1.0, card.last_run_tokens / float(peak)) - else: - ratio = 0.0 - filled = int(round(bar_len * ratio)) - bar = "#" * filled + "." * (bar_len - filled) - self._safe_addstr(stdscr, y + 2, x + 2, bar, inner_w, attr) - - # Card body rows below the bar: - # h=4 -> 1 row (overlaps bottom border, kept for tiny terms) - # h=5 -> 1 row of telemetry - # h>=6 -> 1 always-on "live" row + 1 rotating row - # The always-on live row is critical when the user is staring at - # a long agent call: it keeps the elapsed-time + last subprocess - # line on screen continuously, instead of cycling away every - # 2.5s with the rest of the rotating views. - if h <= 4: - avail_rows = 1 - row_origin = y + 3 - elif h == 5: - avail_rows = 1 - row_origin = y + 3 - else: - avail_rows = 2 - row_origin = y + 3 - - dim = curses.A_DIM | curses.color_pair(self.COLOR_LOG_INFO) - - if card.status == AgentStatus.WORKING and avail_rows >= 2: - # Top row: live elapsed + last subprocess line, always shown - # while WORKING so the user can see motion at a glance. - elapsed = max(0.0, time.monotonic() - card.last_started_mono) - live_head = ( - f"{elapsed:5.1f}s " - + (f"pid={card.live_pid} " if card.live_pid else " ") - + (card.live_last_line or "awaiting...").strip() - ) - self._safe_addstr(stdscr, row_origin, x + 2, live_head[:inner_w], inner_w, attr) - # Bottom row: rotating telemetry as before. - views = self._telemetry_views(card) - tick = int(time.monotonic() / self._TELEMETRY_PERIOD_S) % len(views) - view = views[tick] - if view: - label, value = view[0] - text = f"{label}: {value}" - self._safe_addstr(stdscr, row_origin + 1, x + 2, text[:inner_w], inner_w, dim) - return - - views = self._telemetry_views(card) - tick = int(time.monotonic() / self._TELEMETRY_PERIOD_S) % len(views) - view = views[tick] - view = view[:avail_rows] - for i, (label, value) in enumerate(view): - if i == 0 and avail_rows == 1: - line = (card.current_task or card.last_result or card.last_error or "-") - self._safe_addstr(stdscr, row_origin + i, x + 2, line[:inner_w], inner_w, attr) - continue - text = f"{label}: {value}" - self._safe_addstr(stdscr, row_origin + i, x + 2, text[:inner_w], inner_w, dim) - - def _draw_idea(self, stdscr, *, top: int, width: int) -> int: - with self.state.lock: - idea = self.state.idea - convo_tail = self.state.conversation[-1:] if self.state.conversation else [] - # Wrap both lines so the user sees the full content rather than - # a truncated head. Cap each block at 3 visual rows so the agent - # grid below has room to breathe. - line = "idea: " + (idea or "(no idea yet - type one below)") - idea_rows = _word_wrap(line, max(20, width - 1))[:3] - for i, row in enumerate(idea_rows): - self._safe_addstr(stdscr, top + i, 0, row, width, curses.A_BOLD) - cursor = top + len(idea_rows) - 1 - if convo_tail: - role, text = convo_tail[-1] - tail_rows = _word_wrap(f"last [{role}]: {text}", max(20, width - 1))[:3] - for i, row in enumerate(tail_rows): - self._safe_addstr(stdscr, cursor + 1 + i, 0, row, width) - cursor += len(tail_rows) - return cursor - - def _draw_log(self, stdscr, *, top: int, height: int, width: int) -> int: - height = max(3, height) - # Frame - self._safe_addstr( - stdscr, top, 0, "-" * (width - 1), width, curses.color_pair(self.COLOR_FRAME) - ) - title = " status log (PgUp/PgDn to scroll) " - self._safe_addstr( - stdscr, top, max(0, (width - len(title)) // 2), title, width, - curses.color_pair(self.COLOR_FRAME) | curses.A_BOLD, - ) - body_top = top + 1 - body_height = height - 1 - with self.state.lock: - lines = list(self.state.log) - if not lines: - self._safe_addstr(stdscr, body_top, 1, "(no events yet)", width) - return body_top + body_height - 1 - # Word-wrap each log entry. The first wrapped row carries the - # full ``HH:MM:SS [LEVEL] ...`` prefix; continuation rows are - # indented so the eye can group them with their parent entry. - inner_w = max(10, width - 2) - flat: List[Tuple[int, str]] = [] # (level_attr, text) - for line in lines: - attr = self._log_attr(line.level) - head = f"{line.ts} [{line.level}] {line.text}" - # Derive the continuation indent from the actual prefix length so - # wrapped lines align regardless of level-name width (INFO vs DEBUG). - cont_indent = " " * (len(line.ts) + len(line.level) + 4) # 4 = " [" + "] " - wrapped = _word_wrap(head, inner_w) - for k, row in enumerate(wrapped): - flat.append((attr, row if k == 0 else cont_indent + row.lstrip())) - # Scroll offset is in *visual* rows now, not log entries. - total = len(flat) - offset = max(0, min(self.scroll_offset, max(0, total - body_height))) - end = total - offset - start = max(0, end - body_height) - slice_ = flat[start:end] - for i, (attr, text) in enumerate(slice_): - self._safe_addstr(stdscr, body_top + i, 1, text[:inner_w], width, attr) - return body_top + body_height - 1 - - def _draw_totals(self, stdscr, *, top: int, width: int) -> None: - with self.state.lock: - tot = self.state.total_tokens - tin = self.state.total_input_tokens - tout = self.state.total_output_tokens - files = self.state.total_files - agents = len(self.state.agents) - peak = self.state.peak_run_tokens - line = ( - f" totals :: agents={agents} :: tokens={_format_tokens(tot)} " - f"(in={_format_tokens(tin)} / out={_format_tokens(tout)}) " - f":: peak_run={_format_tokens(peak)} :: files={files} " - ) - attr = curses.color_pair(self.COLOR_LOG_SYS) | curses.A_BOLD - # Pad to width so the line reads as a band rather than a phrase. - self._safe_addstr(stdscr, top, 0, line.ljust(width - 1)[: width - 1], width, attr) - - def _hint_for_state(self) -> str: - """Compute the one-line hint that goes above the input prompt. - - Looks at the current dashboard state (idea captured? agent - working? last error?) and returns a short suggestion. Returns - an empty string when there's nothing useful to say. - """ - s = self.state - with s.lock: - has_idea = bool(s.idea) - busy = s.busy - err_kind = s.last_error_kind - err_cmd = s.last_error_command - in_tut = s.in_tutorial - stop_req = s.stop_requested - active_provider = s.provider or "shell" - if in_tut: - return "tutorial running — agents are operating in a sandbox; press /quit to exit" - if busy: - return "agent is working — /abort to kill the stuck subprocess if needed" - if stop_req: - return "stop requested — loop will end after the current cycle" - if err_kind == "not_installed": - return f"{active_provider} CLI not found — try /install {active_provider} or /provider " - if err_kind == "auth": - return f"{active_provider} auth failed — /configure {active_provider} to set credentials" - if err_kind == "rate_limit": - return "provider rate-limited — wait, or /provider to switch" - if err_kind == "timeout": - return "provider call stalled — /abort to kill it, then retry or switch provider" - if not has_idea: - return "type your idea, or /tutorial for a sample run, or /help" - return "type a message to continue, or /help for commands" - - def _draw_hint_bar(self, stdscr, *, top: int, width: int) -> None: - hint = self._hint_for_state() - if not hint: - return - text = (" " + hint).ljust(width - 1)[: width - 1] - # Subtle styling — log info color, no bold — so the hint never - # competes with the title bar or the cards for attention. - self._safe_addstr(stdscr, top, 0, text, width, curses.color_pair(self.COLOR_LOG_INFO)) - - # ----- /help overlay ---------------------------------------------------- - - _HELP_ROWS: List[Tuple[str, str]] = [ - ("/help, F1, ?", "Toggle this overlay."), - ("Esc, q", "Dismiss the overlay."), - ("", ""), - ("/idea ", "Replace the captured idea."), - ("/cast", "Have the chief re-pick the team."), - ("/roles list|add|drop", "Inspect or edit the agent roster."), - ("/run [workflow]", "Run one workflow cycle (default: engineering)."), - ("/loop ralph|autoresearch [N]", "Refinement loop for N iterations."), - ("/stop", "Stop the active loop after the current cycle."), - ("/abort", "Hard-kill the running provider subprocess."), - ("", ""), - ("/provider ", "Switch the active provider."), - ("/install [tool]", "Install a missing CLI (claude, pi, ollama, …)."), - ("/configure [tool]", "Re-run a tool's first-use config."), - ("/github", "(Re-)connect this workspace to a GitHub remote."), - ("", ""), - ("/doctor [--fix]", "Health-check providers and config."), - ("/diag", "Build a redacted diagnostic bundle."), - ("/tutorial", "Run a free sample idea on the shell provider."), - ("/workspaces list|switch|rename|clean", "Manage past kickoff dirs."), - ("/undo", "Revert the last clk-authored commit."), - ("/status", "Print a snapshot to the log."), - ("/quit, Ctrl-D", "Exit the TUI."), - ] - - def _draw_help_overlay(self, stdscr, h: int, w: int) -> None: - rows = self._HELP_ROWS - # Box dimensions: leave at least 4 cells of margin on each side - # so the underlying log/cards are still partially visible. - box_w = max(50, min(w - 4, 84)) - box_h = min(h - 2, len(rows) + 5) - y0 = max(1, (h - box_h) // 2) - x0 = max(1, (w - box_w) // 2) - attr = curses.color_pair(self.COLOR_LOG_SYS) | curses.A_BOLD - bg = curses.color_pair(self.COLOR_TITLE) - # Top + bottom borders, side walls. - self._safe_addstr(stdscr, y0, x0, "+" + "-" * (box_w - 2) + "+", box_w, attr) - for r in range(1, box_h - 1): - self._safe_addstr(stdscr, y0 + r, x0, "|" + " " * (box_w - 2) + "|", box_w, attr) - self._safe_addstr(stdscr, y0 + box_h - 1, x0, "+" + "-" * (box_w - 2) + "+", box_w, attr) - # Title. - title = " CLK :: help " - self._safe_addstr( - stdscr, y0, x0 + max(1, (box_w - len(title)) // 2), title, box_w, bg | curses.A_BOLD - ) - # Body. - col_w = (box_w - 6) // 3 - for i, (cmd, desc) in enumerate(rows[: box_h - 4]): - line = f" {cmd:<{col_w}} {desc}"[: box_w - 3] - self._safe_addstr(stdscr, y0 + 2 + i, x0 + 1, line, box_w - 2, curses.color_pair(self.COLOR_LOG_INFO)) - # Footer hint. - footer = " Esc / q to close " - self._safe_addstr( - stdscr, y0 + box_h - 1, - x0 + max(1, (box_w - len(footer)) // 2), - footer, box_w, bg | curses.A_BOLD, - ) - - def _draw_input(self, stdscr, *, top: int, width: int, rows: int = 1, cursor: int = 0) -> None: - # Frame line above the input rows. - self._safe_addstr( - stdscr, top, 0, "-" * (width - 1), width, curses.color_pair(self.COLOR_FRAME) - ) - with self.state.lock: - buf = self.state.input_buffer - cursor = max(0, min(cursor, len(buf))) - prompt = "> " - full = prompt + buf - eff = max(1, width - 1) - # Character-wrap so cursor math is exact even when the user - # types continuous strings (URLs, paste). - chunks_all = [full[i:i + eff] for i in range(0, max(eff, len(full)), eff)] or [""] - cursor_abs = len(prompt) + cursor - cursor_chunk = min(len(chunks_all) - 1, cursor_abs // eff) - first_chunk = max(0, min(cursor_chunk, len(chunks_all) - rows)) - # If the buffer needs more rows than we have, show the LAST - # ``rows`` chunks containing the cursor so the cursor stays visible. - chunks = chunks_all[first_chunk:first_chunk + rows] - attr = curses.color_pair(self.COLOR_PROMPT) | curses.A_BOLD - base_y = top + 1 - for i, line in enumerate(chunks): - self._safe_addstr(stdscr, base_y + i, 0, line, width, attr) - # Cursor goes after the last visible character. - try: - rel = cursor_abs - first_chunk * eff - cursor_y = base_y + max(0, min(rows - 1, rel // eff)) - cursor_x = max(0, min(rel % eff, width - 2)) - stdscr.move(cursor_y, cursor_x) - except Exception: - pass - - # --- input handling -------------------------------------------------- - - def _handle_key(self, ch: int) -> bool: - # When the /help overlay is up, Esc/q dismisses it and all - # other keys (including arrows) pass through normally. This - # lets agent state continue updating beneath the overlay. - if self._help_visible: - if ch in (27, ord('q'), ord('Q')): # Esc / q - self._help_visible = False - with self.state.lock: - self.state.help_dismissed = True - return True - # F1 always toggles help — no need to clear the input buffer. - if ch == curses.KEY_F1: - self._help_visible = not self._help_visible - return True - if ch in (4,): # Ctrl-D - self.worker.stop() - return False - if ch == curses.KEY_RESIZE: - return True - if ch == curses.KEY_PPAGE: - self.scroll_offset += 5 - return True - if ch == curses.KEY_NPAGE: - self.scroll_offset = max(0, self.scroll_offset - 5) - return True - if ch == curses.KEY_HOME: - self.scroll_offset = 10**9 - return True - if ch == curses.KEY_END: - self.scroll_offset = 0 - return True - if ch == curses.KEY_LEFT: - with self.state.lock: - self.state.input_cursor = max(0, self.state.input_cursor - 1) - return True - if ch == curses.KEY_RIGHT: - with self.state.lock: - self.state.input_cursor = min(len(self.state.input_buffer), self.state.input_cursor + 1) - return True - if ch in (curses.KEY_SLEFT, 1): # Shift-left where supported, Ctrl-A - with self.state.lock: - self.state.input_cursor = 0 - return True - if ch in (curses.KEY_SRIGHT, 5): # Shift-right where supported, Ctrl-E - with self.state.lock: - self.state.input_cursor = len(self.state.input_buffer) - return True - if ch == curses.KEY_DC: - with self.state.lock: - i = self.state.input_cursor - if i < len(self.state.input_buffer): - self.state.input_buffer = self.state.input_buffer[:i] + self.state.input_buffer[i + 1:] - return True - if ch in (curses.KEY_BACKSPACE, 127, 8): - with self.state.lock: - i = self.state.input_cursor - if i > 0: - self.state.input_buffer = self.state.input_buffer[:i - 1] + self.state.input_buffer[i:] - self.state.input_cursor = i - 1 - return True - if ch in (10, 13, curses.KEY_ENTER): - with self.state.lock: - msg = self.state.input_buffer - self.state.input_buffer = "" - self.state.input_cursor = 0 - if msg.strip(): - return self._dispatch(msg.strip()) - return True - if 32 <= ch < 127: - # When the input buffer is empty, ? opens /help instead of - # being typed — same convention as many CLIs. - if ch == ord('?'): - with self.state.lock: - empty = (self.state.input_buffer == "") - if empty: - self._help_visible = True - return True - with self.state.lock: - if len(self.state.input_buffer) < 1024: - i = max(0, min(self.state.input_cursor, len(self.state.input_buffer))) - self.state.input_buffer = self.state.input_buffer[:i] + chr(ch) + self.state.input_buffer[i:] - self.state.input_cursor = i + 1 - return True - return True - - # --- chat dispatch --------------------------------------------------- - - def _dispatch(self, msg: str) -> bool: - self.state.add_user_message(msg) - if msg.startswith("/"): - parts = msg[1:].split() - if not parts: - return True - cmd = parts[0].lower() - args = parts[1:] - if cmd in ("quit", "exit"): - self.worker.stop() - return False - if cmd == "idea": - if args: - self.worker.submit(Job("idea", " ".join(args))) - return True - if cmd == "run": - self.worker.submit(Job("run", args[0] if args else "engineering")) - return True - if cmd in ("mission", "auto"): - if args and not self.state.idea: - self.worker.submit(Job("idea", " ".join(args))) - self.worker.submit(Job("mission")) - return True - if cmd == "loop": - mode = args[0] if args else "ralph" - n = int(args[1]) if len(args) > 1 and args[1].isdigit() else 5 - self.worker.submit(Job("loop", {"mode": mode, "n": n})) - return True - if cmd == "stop": - self.worker.submit(Job("stop")) - return True - if cmd == "provider": - if args: - self.worker.submit(Job("provider", args[0])) - return True - if cmd == "status": - self.worker.submit(Job("status")) - return True - if cmd == "cast": - self.worker.submit(Job("cast")) - return True - if cmd == "abort": - self._do_abort() - return True - if cmd == "help": - self._help_visible = True - return True - if cmd == "install": - tool = args[0] if args else (self.state.provider or "") - self.worker.submit(Job("install", tool)) - return True - if cmd == "configure": - tool = args[0] if args else (self.state.provider or "") - self.worker.submit(Job("configure", tool)) - return True - if cmd == "github": - self.worker.submit(Job("github")) - return True - if cmd == "undo": - # Two-step confirm: first /undo prints the diff, second - # /undo confirm actually reverts. Matches the - # always-confirm policy. - confirm = bool(args and args[0].lower() == "confirm") - self.worker.submit(Job("undo", {"confirm": confirm})) - return True - if cmd == "doctor": - fix = bool(args and args[0] == "--fix") - self.worker.submit(Job("doctor", {"fix": fix})) - return True - if cmd == "diag": - self.worker.submit(Job("diag")) - return True - if cmd == "tutorial": - self.worker.submit(Job("tutorial")) - return True - if cmd == "workspaces": - action = args[0] if args else "list" - rest = args[1:] - self.worker.submit(Job("workspaces", {"action": action, "args": rest})) - return True - if cmd == "roles": - if not args: - self.worker.submit(Job("roles", {"action": "list"})) - return True - sub = args[0].lower() - if sub == "list": - self.worker.submit(Job("roles", {"action": "list"})) - elif sub in ("add", "drop", "remove"): - name = args[1] if len(args) > 1 else "" - role_text = " ".join(args[2:]).strip().strip('"') if len(args) > 2 else "" - op = "add" if sub == "add" else "remove" - self.worker.submit(Job("roles", {"action": op, "name": name, "role": role_text})) - else: - self.state.add_log(f"unknown roles op: {sub}", level="WARN") - return True - self.state.add_log( - f"'/{cmd}' isn't a command I know. Type /help (or press F1) for the list.", - level="WARN", - ) - return True - # Free-text: first message becomes the idea; subsequent ones are - # appended to the conversation file and trigger another run. - # The engineering workflow's first stage IS chief casting (the - # chief decomposes + casts the team + authors the workflow YAML - # all in one call), so we deliberately do NOT submit a separate - # Job("cast") here. Doing so would invoke chief twice back-to- - # back per user message and was the cause of the "chief stuck - # at 90+ seconds" symptom. /cast remains as an explicit manual - # trigger when you want a re-cast without running engineering. - # Autonomy by default: a free-text message drives the full autonomous - # mission (charter -> plan -> phases -> code-gated done) rather than a - # single workflow pass. Use /run for a one-shot engineering cycle. - if not self.state.idea: - self.worker.submit(Job("idea", msg)) - self.worker.submit(Job("mission")) - else: - self._append_conversation(msg) - self.worker.submit(Job("mission")) - return True - - def _do_abort(self) -> None: - """Send SIGTERM (then SIGKILL) to every WORKING agent's subprocess. - - Runs on the curses thread, NOT the worker thread, because the - worker is the one blocked inside ``provider.invoke()``. Killing - the subprocess unblocks ``proc.wait()`` in run_streaming, which - causes the provider to return an error response, which the - worker treats as a normal failed run. - """ - import os - import signal - with self.state.lock: - targets = [ - (name, card.live_pid) - for name, card in self.state.agents.items() - if card.status == AgentStatus.WORKING and card.live_pid - ] - if not targets: - self.state.add_log( - "nothing to abort — no agent subprocess is running right now.", - level="WARN", - ) - return - self.state.add_system_message( - f"aborting {len(targets)} stuck subprocess(es)…" - ) - for name, pid in targets: - try: - os.kill(pid, signal.SIGTERM) - self.state.add_log(f"sent SIGTERM to {name} (pid {pid}) — the cycle will report a timeout", level="WARN") - except ProcessLookupError: - self.state.add_log(f"abort: {name} pid={pid} already gone", level="INFO") - except Exception as exc: - self.state.add_log(f"abort: {name} pid={pid} failed: {exc}", level="ERROR") - - def _append_conversation(self, msg: str) -> None: - try: - path = self.worker.paths.state / "conversation.md" - ts = datetime.now().isoformat(timespec="seconds") - with path.open("a", encoding="utf-8") as fh: - fh.write(f"\n## {ts} (user)\n{msg}\n") - except Exception as exc: - log_exception("tui.TuiApp._append_conversation", exc) - - # --- helpers --------------------------------------------------------- - - def _safe_addstr(self, stdscr, y: int, x: int, text: str, max_width: int, attr: int = 0) -> None: - if y < 0 or x < 0: - return - try: - h, w = stdscr.getmaxyx() - if y >= h: - return - limit = max(0, min(len(text), w - x - 1)) - if limit <= 0: - return - stdscr.addnstr(y, x, text, limit, attr) - except curses.error: - pass - except Exception as exc: - log_exception("tui.TuiApp._safe_addstr", exc) - - def _fill(self, stdscr, y: int, x: int, width: int, ch: str, attr: int = 0) -> None: - try: - stdscr.addnstr(y, x, ch * (width - x - 1), max(0, width - x - 1), attr) - except curses.error: - pass - - def _log_attr(self, level: str) -> int: - return { - "INFO": curses.color_pair(self.COLOR_LOG_INFO), - "WARN": curses.color_pair(self.COLOR_LOG_WARN) | curses.A_BOLD, - "ERROR": curses.color_pair(self.COLOR_LOG_ERROR) | curses.A_BOLD, - "USER": curses.color_pair(self.COLOR_LOG_USER) | curses.A_BOLD, - "SYSTEM": curses.color_pair(self.COLOR_LOG_SYS), - }.get(level, 0) - - -# --------------------------------------------------------------------------- -# Public entrypoint -# --------------------------------------------------------------------------- - - -def run(initial_prompt: Optional[str] = None) -> int: - """Entry point used by ``clk tui``.""" - paths = project_paths() - if not is_initialized(paths): - print("CLK is not initialized. Run `clk init` first.", file=sys.stderr) - return 2 - - clk_cfg = load_clk_config(paths) - providers_cfg = load_providers_config(paths) - agents_cfg = load_agents_config(paths) - agent_names = list((agents_cfg.get("agents") or {}).keys()) - - # Crashed-session detection: if a previous TUI exited without removing - # its lock file, mention that and offer the user a clean restart hint. - # We don't auto-resume the conversation here — that's the worker's job - # if the user types something — but we do surface the situation so - # they know what happened. - lock_path = paths.state / ".tui-active" - prior_session = None - try: - if lock_path.exists(): - prior_pid = lock_path.read_text(encoding="utf-8").strip() - # Stale lock if the PID no longer exists. - try: - import os as _os - if prior_pid.isdigit(): - _os.kill(int(prior_pid), 0) - # Still running — leave the file alone, don't claim it. - print( - f"[tui] another CLK TUI is already running (pid {prior_pid}); " - f"close it first or rm -f {lock_path}", - file=sys.stderr, - ) - return 2 - except (OSError, ValueError): - prior_session = prior_pid - paths.state.mkdir(parents=True, exist_ok=True) - lock_path.write_text(str(__import__('os').getpid()), encoding="utf-8") - except Exception: - pass - - state = DashboardState(agent_names, paths=paths, agents_cfg=agents_cfg) - state.project_name = clk_cfg.get("project_name") or paths.root.name - state.provider = providers_cfg.get("active") or clk_cfg.get("default_provider") or "shell" - if prior_session: - state.add_log( - f"recovered from a crashed session (prior pid {prior_session}). " - f"Your conversation is preserved under .clk/state/conversation.md.", - level="WARN", - ) - # Mirror every status-pane line to a persistent file so we have a - # full session trace inside the kickoff dir for later analysis. - state.attach_session_log(paths.logs / "session.log") - - observer = DashboardObserver(state) - runner = AgentRunner( - paths=paths, - agents_cfg=agents_cfg, - providers_cfg=providers_cfg, - clk_cfg=clk_cfg, - observer=observer, - ) - checks = clk_cfg.get("validation_checks") or ["test -f .clk/config/clk.config.json"] - evaluator = Evaluator(root=paths.root, default_checks=checks) - worker = Worker(paths, runner, evaluator, state, clk_cfg, providers_cfg) - - # Pre-populate from existing idea if any. - idea_path = paths.state / "idea.json" - if idea_path.exists(): - try: - payload = json.loads(idea_path.read_text(encoding="utf-8")) - state.set_idea(payload.get("statement") or payload.get("title") or "") - except Exception as exc: - log_exception("tui.run.load_idea", exc) - - # Route every stderr/stdout write into the dashboard log pane BEFORE - # the worker starts processing jobs, so even the very first job's - # output (subprocess, log(), traceback.print_exc()) cannot reach the - # real terminal and corrupt the curses display. The original streams - # are restored on exit so post-TUI shell output looks normal again. - old_stderr = sys.stderr - old_stdout = sys.stdout - sys.stderr = _StreamToLog(state, default_level="INFO") - sys.stdout = _StreamToLog(state, default_level="INFO") - worker.start() - - if initial_prompt: - worker.submit(Job("idea", initial_prompt)) - worker.submit(Job("run", "engineering")) - - app = TuiApp(state, worker) - try: - app.run() - finally: - worker.stop() - worker.join(timeout=2.0) - sys.stderr = old_stderr - sys.stdout = old_stdout - state.close_session_log() - # Clean up the lock so the next run doesn't see "crashed session". - try: - if lock_path.exists(): - lock_path.unlink() - except Exception: - pass - return 0 diff --git a/clk_harness/tui/__init__.py b/clk_harness/tui/__init__.py new file mode 100644 index 0000000..03409bc --- /dev/null +++ b/clk_harness/tui/__init__.py @@ -0,0 +1,48 @@ +"""Cognitive Loop Kernel TUI dashboard. + +A curses-based front end inspired by gnhf / Archon. Decomposed package; +this ``__init__`` preserves the public surface of the former +``clk_harness/tui.py`` module: + +* :mod:`.app` — ``TuiApp`` (the curses layout/render/key loop) and the + ``run()`` entry point ``clk tui`` uses. +* :mod:`.dashboard` — ``DashboardState`` (thread-safe shared state), + agent cards, log lines, and the ``DashboardObserver`` adapter. +* :mod:`.commands` — the ``Worker`` thread that executes slash-command + ``Job``s off the UI thread. +* :mod:`.stream` — the stderr/stdout capture that routes stray writes + into the log pane instead of corrupting the curses display. +* :mod:`.theme` — presentation helpers (token formatting, word wrap). +""" + +from ..utils.text_extract import classify_error, extract_thought +from .app import TuiApp, run +from .commands import Job, Worker +from .dashboard import ( + AgentCard, + AgentStatus, + DashboardObserver, + DashboardState, + LogLine, + _extract_thought, +) +from .stream import _StreamToLog +from .theme import _format_tokens, _word_wrap + +__all__ = [ + "AgentCard", + "AgentStatus", + "DashboardObserver", + "DashboardState", + "Job", + "LogLine", + "TuiApp", + "Worker", + "_StreamToLog", + "_extract_thought", + "_format_tokens", + "_word_wrap", + "classify_error", + "extract_thought", + "run", +] diff --git a/clk_harness/tui/app.py b/clk_harness/tui/app.py new file mode 100644 index 0000000..b3c6208 --- /dev/null +++ b/clk_harness/tui/app.py @@ -0,0 +1,1205 @@ +"""Curses application: layout, rendering, key handling, entry point. +""" + +from __future__ import annotations + +import curses +import json +import sys +import time +from datetime import datetime +from typing import Dict, List, Optional, Tuple + +from ..config import ( + is_initialized, + load_agents_config, + load_clk_config, + load_providers_config, + project_paths, +) +from ..log import get_logger, log_exception +from ..orchestration import ( + AgentRunner, + Evaluator, +) +from ..pricing import format_usd +from .commands import Job, Worker +from .dashboard import AgentCard, AgentStatus, DashboardObserver, DashboardState +from .stream import _StreamToLog +from .theme import _format_tokens, _word_wrap + +logger = get_logger(__name__) + + +class TuiApp: + """Curses front end.""" + + COLOR_TITLE = 1 + COLOR_IDLE = 2 + COLOR_WORKING = 3 + COLOR_DONE = 4 + COLOR_FAILED = 5 + COLOR_LOG_INFO = 6 + COLOR_LOG_WARN = 7 + COLOR_LOG_ERROR = 8 + COLOR_LOG_USER = 9 + COLOR_LOG_SYS = 10 + COLOR_FRAME = 11 + COLOR_PROMPT = 12 + + def __init__(self, state: DashboardState, worker: Worker) -> None: + self.state = state + self.worker = worker + self.scroll_offset = 0 # lines to skip from the bottom of the log + self._spinner = "|/-\\" + self._spinner_idx = 0 + self._last_render = 0.0 + # Heartbeat: when an agent has been WORKING for a while we emit + # "still working: Ns" log lines so the user knows the process + # is alive (vs. genuinely stuck). Per-agent so one slow chief + # doesn't flood the pane. + self._heartbeat_last: Dict[str, float] = {} + # Heartbeat cadence. Longer than the original 8s/8s so a normal + # 30-60s model call doesn't drown the log in "still working" lines. + self.HEARTBEAT_FIRST_S = 15.0 # first heartbeat after this much silence + self.HEARTBEAT_REPEAT_S = 15.0 # then every N seconds while still working + # /help modal overlay: toggled by /help, F1, or `?` when the input + # buffer is empty. Esc/q dismisses. While visible, _render draws + # an extra centred panel above everything else. + self._help_visible: bool = False + + # --- entrypoint ------------------------------------------------------ + + def run(self) -> None: + try: + curses.wrapper(self._loop) + except Exception as exc: + log_exception("tui.TuiApp.run", exc) + + # --- main loop ------------------------------------------------------- + + def _loop(self, stdscr: "curses.window") -> None: + curses.curs_set(1) + stdscr.nodelay(True) + stdscr.timeout(80) + self._init_colors() + # First-run welcome: a one-time multi-line greeting that explains + # what CLK is, what agents are, and the most useful commands. We + # gate on a marker file under .clk/state so subsequent runs show + # a one-liner instead. + self._emit_welcome() + # Proactive provider health check — surface broken providers + # *before* the user types their first idea so they don't get a + # surprise "cli not found" three seconds in. + self._emit_provider_health() + while True: + try: + self._render(stdscr) + ch = stdscr.getch() + if ch == -1: + self._spinner_idx = (self._spinner_idx + 1) % len(self._spinner) + self._tick_heartbeat() + continue + if not self._handle_key(ch): + break + except KeyboardInterrupt: + break + except Exception as exc: + log_exception("tui.TuiApp._loop", exc) + self.state.add_log(f"render error: {exc}", level="ERROR") + self.worker.stop() + + # --- welcome & health ------------------------------------------------ + + def _emit_welcome(self) -> None: + """Emit the welcome banner. Multi-line on first run, one-liner after.""" + marker = self.state.paths.state / ".seen-welcome" if self.state.paths else None + first_run = True + if marker is not None: + try: + first_run = not marker.exists() + except Exception: + first_run = True + if first_run: + lines = [ + "Welcome to CLK — Cognitive Loop Kernel.", + "Type any idea below and a team of agents (chief, qa, ralph + dynamic roles)", + "will plan, build, and refine it together. Each commit is checkpointed in git.", + "", + "Quick commands:", + " /help see the full command list (or press F1)", + " /tutorial run a 30-second sample idea on the shell provider (free)", + " /provider X switch the active AI (claude, ollama, pi, …)", + " /quit exit (Ctrl-D also works)", + ] + for line in lines: + self.state.add_system_message(line) + if marker is not None: + try: + marker.parent.mkdir(parents=True, exist_ok=True) + marker.write_text("seen\n", encoding="utf-8") + except Exception as _exc: + logger.debug("could not persist welcome marker: %s", _exc) + else: + self.state.add_system_message( + "Welcome back. Type an idea, or /help for commands." + ) + + def _emit_provider_health(self) -> None: + """Surface the available/broken status of each configured provider.""" + if not self.state.paths: + return + try: + from ..config import save_providers_config + from ..providers import available_providers + prov_cfg = load_providers_config(self.state.paths) + # Snapshot endpoints so we can detect auto-failover (localhost -> + # host.docker.internal) inside available_providers and persist + # the swap to providers.json. + before = { + name: (cfg or {}).get("endpoint") + for name, cfg in (prov_cfg.get("providers") or {}).items() + } + avail = available_providers(prov_cfg) + swapped = [] + for name, cfg in (prov_cfg.get("providers") or {}).items(): + new_ep = (cfg or {}).get("endpoint") + if new_ep and before.get(name) and new_ep != before[name]: + swapped.append((name, before[name], new_ep)) + if swapped: + try: + save_providers_config(self.state.paths, prov_cfg) + except Exception as exc: + log_exception("tui.TuiApp._emit_provider_health.save", exc) + for name, old, new in swapped: + self.state.add_log( + f"{name}: {old} unreachable, auto-switched to {new} " + "(host.docker.internal). providers.json updated.", + level="WARN", + ) + active = self.state.provider or prov_cfg.get("active") or "" + self.state.add_log("provider check:", level="SYSTEM") + for name, ok in avail.items(): + tag = "available" if ok else "UNAVAILABLE" + marker = " (active)" if name == active else "" + self.state.add_log(f" {name:<10} {tag}{marker}", level="SYSTEM" if ok else "WARN") + if not avail.get(active, False): + # Pre-load the hint bar so the user sees a fix path + # before typing anything. + with self.state.lock: + self.state.last_error_kind = "not_installed" + self.state.last_error_command = "/install" + except Exception as exc: + log_exception("tui.TuiApp._emit_provider_health", exc) + + # --- colors ---------------------------------------------------------- + + def _init_colors(self) -> None: + try: + curses.start_color() + curses.use_default_colors() + curses.init_pair(self.COLOR_TITLE, curses.COLOR_BLACK, curses.COLOR_CYAN) + curses.init_pair(self.COLOR_IDLE, curses.COLOR_WHITE, -1) + curses.init_pair(self.COLOR_WORKING, curses.COLOR_YELLOW, -1) + curses.init_pair(self.COLOR_DONE, curses.COLOR_GREEN, -1) + curses.init_pair(self.COLOR_FAILED, curses.COLOR_RED, -1) + curses.init_pair(self.COLOR_LOG_INFO, curses.COLOR_WHITE, -1) + curses.init_pair(self.COLOR_LOG_WARN, curses.COLOR_YELLOW, -1) + curses.init_pair(self.COLOR_LOG_ERROR,curses.COLOR_RED, -1) + curses.init_pair(self.COLOR_LOG_USER, curses.COLOR_CYAN, -1) + curses.init_pair(self.COLOR_LOG_SYS, curses.COLOR_MAGENTA,-1) + curses.init_pair(self.COLOR_FRAME, curses.COLOR_BLUE, -1) + curses.init_pair(self.COLOR_PROMPT, curses.COLOR_GREEN, -1) + except Exception as exc: + log_exception("tui.TuiApp._init_colors", exc) + + # --- rendering ------------------------------------------------------- + + INPUT_MAX_ROWS = 5 # cap on how tall the input area can grow + + def _render(self, stdscr: "curses.window") -> None: + h, w = stdscr.getmaxyx() + if h < 13 or w < 60: + stdscr.erase() + self._safe_addstr(stdscr, 0, 0, "Terminal too small. Resize to at least 60x13.", w) + stdscr.refresh() + return + stdscr.erase() + self._draw_title(stdscr, w) + grid_bottom = self._draw_agent_grid(stdscr, top=1, height=h, width=w) + idea_bottom = self._draw_idea(stdscr, top=grid_bottom + 1, width=w) + # Compute how many rows the input field needs right now. + with self.state.lock: + buf = self.state.input_buffer + cursor = self.state.input_cursor + input_rows = self._input_row_count(buf, w) + # Layout from the bottom up: + # y=h-input_rows..h-1 input field rows (1..N) + # y=h-input_rows-1 input frame ("---") + # y=h-input_rows-2 hint bar (state-aware) + # y=h-input_rows-3 global token totals + # y=idea_bottom+1..h-input_rows-4 log pane + log_height = max(3, h - idea_bottom - input_rows - 4) + self._draw_log(stdscr, top=idea_bottom + 1, height=log_height, width=w) + self._draw_totals(stdscr, top=h - input_rows - 3, width=w) + self._draw_hint_bar(stdscr, top=h - input_rows - 2, width=w) + self._draw_input(stdscr, top=h - input_rows - 1, width=w, rows=input_rows, cursor=cursor) + if self._help_visible: + self._draw_help_overlay(stdscr, h, w) + stdscr.refresh() + + def _tick_heartbeat(self) -> None: + """Emit a 'still working' note for any agent that's been WORKING + long enough that the user might think the harness is stuck. + + The heartbeat now reports whether the underlying subprocess is + making progress: if stdout/stderr have been streaming (the + ``live_*`` fields are advancing), we say "active"; if they've + been silent for a long time we say "silent" and include the + last line we did see, so the user can tell "this is just slow" + vs "this is genuinely stuck". + """ + now = time.monotonic() + with self.state.lock: + working = [ + ( + name, + card.last_started_mono, + card.live_pid, + card.live_stdout_chars + card.live_stderr_chars, + card.live_last_update_mono, + card.live_last_line, + card.live_cpu_pct, + ) + for name, card in self.state.agents.items() + if card.status == AgentStatus.WORKING + ] + for name, started, pid, total_bytes, last_io, last_line, cpu_str in working: + elapsed = max(0.0, now - started) + last = self._heartbeat_last.get(name, started) + interval = self.HEARTBEAT_FIRST_S if last == started else self.HEARTBEAT_REPEAT_S + if elapsed >= self.HEARTBEAT_FIRST_S and (now - last) >= interval: + self._heartbeat_last[name] = now + silence_s = max(0.0, now - last_io) + if pid: + # Determine whether the process looks alive. + # A live model call typically shows near-zero CPU while + # blocked on a network response but its pid still exists. + # We trust the process is alive unless cpu has been + # reported as exactly zero for a long time. + try: + cpu = float(cpu_str) if cpu_str and cpu_str not in ("", "?") else -1.0 + except Exception: + cpu = -1.0 + # "looks dead" = cpu was reported as exactly 0.0 AND + # we've been waiting a very long time. Near-zero (e.g. + # 0.9%) is normal for a blocked network call. + looks_dead = (cpu == 0.0 and elapsed > 300) + if total_bytes == 0: + if not looks_dead: + # Normal slow model call — no alarm. + flavor = ( + f"awaiting model response " + f"({elapsed:.0f}s, cpu={cpu_str or '?'}%)" + ) + level = "INFO" + else: + flavor = ( + f"no output for {elapsed:.0f}s, cpu=0% " + f"-- process may be dead; /abort to kill and retry" + ) + level = "WARN" + elif silence_s > 5.0: + flavor = ( + f"streaming idle {silence_s:.0f}s " + f"({total_bytes}b received, last='{(last_line or '-')[:60]}')" + ) + level = "INFO" + else: + flavor = f"streaming ({total_bytes}b received)" + level = "INFO" + note = f"pid={pid} :: {flavor}" + else: + note = "no subprocess yet (provider initializing)" + level = "INFO" + self.state.add_log( + f"{name} :: working {elapsed:.0f}s :: {note}", + level=level, + ) + # Forget heartbeats for agents that have finished, so the next + # WORKING period starts fresh. + with self.state.lock: + still_working = {n for n, c in self.state.agents.items() if c.status == AgentStatus.WORKING} + for name in list(self._heartbeat_last.keys()): + if name not in still_working: + self._heartbeat_last.pop(name, None) + + def _input_row_count(self, buf: str, width: int) -> int: + """Number of rows the input field needs to show ``buf`` in full. + + Uses character wrap (not word wrap) at ``width - 1`` so cursor + positioning is straightforward. Capped at INPUT_MAX_ROWS; past + that the buffer scrolls (last N rows visible). + """ + prompt_len = 2 # "> " + total = prompt_len + len(buf) + eff = max(1, width - 1) + rows = max(1, (total + eff - 1) // eff) + return min(self.INPUT_MAX_ROWS, rows) + + def _draw_title(self, stdscr, width: int) -> None: + s = self.state + with s.lock: + project = s.project_name + provider = s.provider + phase = s.phase + busy = s.busy + iteration = s.iteration_count + tot = s.total_tokens + tot_in = s.total_input_tokens + tot_out = s.total_output_tokens + files = s.total_files + usd = s.total_usd + ahead = s.github_ahead + spin = self._spinner[self._spinner_idx] if busy else " " + cost_str = format_usd(usd) + push_str = f" :: ↑{ahead}" if ahead > 0 else "" + title = ( + f" CLK :: {project} :: provider={provider} :: phase={phase} {spin} " + f"iter={iteration} :: tok={_format_tokens(tot)} " + f"(in={_format_tokens(tot_in)}/out={_format_tokens(tot_out)}) :: " + f"{cost_str} :: files={files}{push_str} " + ) + self._fill(stdscr, 0, 0, width, " ", curses.color_pair(self.COLOR_TITLE) | curses.A_BOLD) + self._safe_addstr( + stdscr, 0, 0, title.ljust(width - 1)[: width - 1], width, + curses.color_pair(self.COLOR_TITLE) | curses.A_BOLD, + ) + + def _draw_agent_grid(self, stdscr, *, top: int, height: int, width: int) -> int: + with self.state.lock: + # Sort: baseline first (chief leading), then alphabetical + # dynamics. Keeps the visual anchor stable as roles come and go. + def _sort_key(name: str) -> tuple: + card = self.state.agents[name] + base = 0 if card.is_baseline else 1 + # chief always first among baseline + pri = 0 if name == "chief" else 1 + return (base, pri, name) + + names = sorted(self.state.agents.keys(), key=_sort_key) + cards = [self.state.agents[n] for n in names] + if not cards: + return top + cols = 4 if width >= 84 else (3 if width >= 64 else 2) + rows = (len(cards) + cols - 1) // cols + max_grid_height = max(1, height - 12) + # Prefer 6 (room for two rotating telemetry rows), shrink as + # vertical real estate runs out. + preferred = 6 + card_h = preferred + if rows * card_h > max_grid_height: + card_h = max(3, max_grid_height // rows) + card_w = (width - 1) // cols + for i, card in enumerate(cards): + r = i // cols + c = i % cols + y = top + r * card_h + x = c * card_w + self._draw_card(stdscr, y, x, card_h, card_w, card) + return top + rows * card_h + + # Rotating telemetry views. Each view returns up to two + # ``(label, value)`` pairs which fill the available rows below the + # bar. Cycle period is 2.5s. + _TELEMETRY_PERIOD_S = 2.5 + + def _telemetry_views(self, card: AgentCard) -> List[List[Tuple[str, str]]]: + prov = card.provider or "(default)" + baseline = "baseline" if card.is_baseline else "dynamic" + files_line = ", ".join(card.files_written[:4]) or "-" + if len(card.files_written) > 4: + files_line += f" (+{len(card.files_written)-4})" + # View 1: live work + live = [ + ("task", card.current_task or "-"), + ("rsp", card.provider_resolution or card.last_result or card.last_error or "-"), + ] + # View 2: I/O + io = [ + ("prompt", card.prompt_preview or "-"), + ("files", f"{files_line} (total {card.total_files})"), + ] + # View 3: thinking + meta (incl. tokens) + tok_str = ( + f"tok={_format_tokens(card.total_tokens)}" + f" (in={_format_tokens(card.input_tokens)}" + f"/out={_format_tokens(card.output_tokens)})" + ) + if card.last_usage_source: + tok_str += f"[{card.last_usage_source}]" + meta_line = f"{baseline} | runs={card.runs} | dur={card.last_duration_s:.1f}s | prov={prov}" + thinking = [ + ("think", card.last_thought or card.role or "-"), + ("meta", meta_line), + ] + # View 4: token usage details + tokens = [ + ("tokens", tok_str), + ("source", f"last={card.last_usage_source or '-'}"), + ] + # View 5: live subprocess telemetry. Useful when WORKING - the + # user can see PID + bytes flowing + the most recent stderr/ + # stdout line, so they know the underlying CLI is alive vs. + # genuinely stuck. + live_label = ( + f"pid={card.live_pid}" if card.live_pid + else ("idle" if card.status != AgentStatus.WORKING else "no_pid") + ) + live_meta = ( + f"out={card.live_stdout_chars}b " + f"err={card.live_stderr_chars}b " + f"cpu={card.live_cpu_pct or '?'}% " + f"{live_label}" + ) + subprocess_view = [ + ("live", card.live_last_line or "-"), + ("io", live_meta), + ] + return [live, io, thinking, tokens, subprocess_view] + + def _draw_card(self, stdscr, y: int, x: int, h: int, w: int, card: AgentCard) -> None: + col = { + AgentStatus.IDLE: self.COLOR_IDLE, + AgentStatus.WORKING: self.COLOR_WORKING, # yellow: waiting for response + AgentStatus.RECOVERING: self.COLOR_FAILED, # red: retry backoff in progress + AgentStatus.DONE: self.COLOR_DONE, + AgentStatus.FAILED: self.COLOR_FAILED, + AgentStatus.PROVIDER_ERROR: self.COLOR_FAILED, + }.get(card.status, self.COLOR_IDLE) + attr = curses.color_pair(col) + frame_attr = curses.color_pair(self.COLOR_FRAME) + # Border + try: + self._safe_addstr(stdscr, y, x, "+" + "-" * (w - 2) + "+", w, frame_attr) + for i in range(1, h - 1): + self._safe_addstr(stdscr, y + i, x, "|", w, frame_attr) + self._safe_addstr(stdscr, y + i, x + w - 1, "|", w, frame_attr) + self._safe_addstr(stdscr, y + h - 1, x, "+" + "-" * (w - 2) + "+", w, frame_attr) + except Exception: + pass + inner_w = max(1, w - 4) + # Header line: name (with baseline marker) + status badge + name_label = ("*" if card.is_baseline else " ") + card.name + name_str = name_label[:inner_w] + self._safe_addstr(stdscr, y + 1, x + 2, name_str, inner_w, attr | curses.A_BOLD) + badge = f"[{card.status:<7}] r={card.runs:<3}" + if len(name_str) + 1 + len(badge) <= inner_w: + self._safe_addstr(stdscr, y + 1, x + w - 2 - len(badge), badge, inner_w, attr) + # Activity meter. + # WORKING: a 3-cell ping-pong cursor sweeping the bar; speed + # accelerates as the run goes long, so a stuck call + # looks visibly more frantic than a fresh one. + # IDLE/DONE/FAILED: bar is filled proportional to this agent's + # most recent run's token count divided by the global + # peak run, so visual bar length = relative work. + bar_len = max(0, inner_w - 2) + if bar_len > 0: + with self.state.lock: + peak = self.state.peak_run_tokens + if card.status == AgentStatus.WORKING: + now = time.monotonic() + elapsed = max(0.0, now - card.last_started_mono) + # Fast when tokens are arriving, slow when waiting for the + # model to reply. idle_since measures time since the last + # stdout/stderr line from the subprocess. + idle_since = ( + max(0.0, now - card.live_last_update_mono) + if card.live_last_update_mono > 0 else elapsed + ) + speed = 8.0 if idle_since < 2.0 else 0.8 + cursor_w = 3 + travel = max(1, bar_len - cursor_w) + step = int(elapsed * speed) % (travel * 2) + pos = step if step < travel else (travel * 2 - step) + bar_chars = ["."] * bar_len + for k in range(cursor_w): + if 0 <= pos + k < bar_len: + bar_chars[pos + k] = "=" + bar = "".join(bar_chars) + else: + if peak > 0 and card.last_run_tokens > 0: + ratio = min(1.0, card.last_run_tokens / float(peak)) + else: + ratio = 0.0 + filled = int(round(bar_len * ratio)) + bar = "#" * filled + "." * (bar_len - filled) + self._safe_addstr(stdscr, y + 2, x + 2, bar, inner_w, attr) + + # Card body rows below the bar: + # h=4 -> 1 row (overlaps bottom border, kept for tiny terms) + # h=5 -> 1 row of telemetry + # h>=6 -> 1 always-on "live" row + 1 rotating row + # The always-on live row is critical when the user is staring at + # a long agent call: it keeps the elapsed-time + last subprocess + # line on screen continuously, instead of cycling away every + # 2.5s with the rest of the rotating views. + if h <= 4: + avail_rows = 1 + row_origin = y + 3 + elif h == 5: + avail_rows = 1 + row_origin = y + 3 + else: + avail_rows = 2 + row_origin = y + 3 + + dim = curses.A_DIM | curses.color_pair(self.COLOR_LOG_INFO) + + if card.status == AgentStatus.WORKING and avail_rows >= 2: + # Top row: live elapsed + last subprocess line, always shown + # while WORKING so the user can see motion at a glance. + elapsed = max(0.0, time.monotonic() - card.last_started_mono) + live_head = ( + f"{elapsed:5.1f}s " + + (f"pid={card.live_pid} " if card.live_pid else " ") + + (card.live_last_line or "awaiting...").strip() + ) + self._safe_addstr(stdscr, row_origin, x + 2, live_head[:inner_w], inner_w, attr) + # Bottom row: rotating telemetry as before. + views = self._telemetry_views(card) + tick = int(time.monotonic() / self._TELEMETRY_PERIOD_S) % len(views) + view = views[tick] + if view: + label, value = view[0] + text = f"{label}: {value}" + self._safe_addstr(stdscr, row_origin + 1, x + 2, text[:inner_w], inner_w, dim) + return + + views = self._telemetry_views(card) + tick = int(time.monotonic() / self._TELEMETRY_PERIOD_S) % len(views) + view = views[tick] + view = view[:avail_rows] + for i, (label, value) in enumerate(view): + if i == 0 and avail_rows == 1: + line = (card.current_task or card.last_result or card.last_error or "-") + self._safe_addstr(stdscr, row_origin + i, x + 2, line[:inner_w], inner_w, attr) + continue + text = f"{label}: {value}" + self._safe_addstr(stdscr, row_origin + i, x + 2, text[:inner_w], inner_w, dim) + + def _draw_idea(self, stdscr, *, top: int, width: int) -> int: + with self.state.lock: + idea = self.state.idea + convo_tail = self.state.conversation[-1:] if self.state.conversation else [] + # Wrap both lines so the user sees the full content rather than + # a truncated head. Cap each block at 3 visual rows so the agent + # grid below has room to breathe. + line = "idea: " + (idea or "(no idea yet - type one below)") + idea_rows = _word_wrap(line, max(20, width - 1))[:3] + for i, row in enumerate(idea_rows): + self._safe_addstr(stdscr, top + i, 0, row, width, curses.A_BOLD) + cursor = top + len(idea_rows) - 1 + if convo_tail: + role, text = convo_tail[-1] + tail_rows = _word_wrap(f"last [{role}]: {text}", max(20, width - 1))[:3] + for i, row in enumerate(tail_rows): + self._safe_addstr(stdscr, cursor + 1 + i, 0, row, width) + cursor += len(tail_rows) + return cursor + + def _draw_log(self, stdscr, *, top: int, height: int, width: int) -> int: + height = max(3, height) + # Frame + self._safe_addstr( + stdscr, top, 0, "-" * (width - 1), width, curses.color_pair(self.COLOR_FRAME) + ) + title = " status log (PgUp/PgDn to scroll) " + self._safe_addstr( + stdscr, top, max(0, (width - len(title)) // 2), title, width, + curses.color_pair(self.COLOR_FRAME) | curses.A_BOLD, + ) + body_top = top + 1 + body_height = height - 1 + with self.state.lock: + lines = list(self.state.log) + if not lines: + self._safe_addstr(stdscr, body_top, 1, "(no events yet)", width) + return body_top + body_height - 1 + # Word-wrap each log entry. The first wrapped row carries the + # full ``HH:MM:SS [LEVEL] ...`` prefix; continuation rows are + # indented so the eye can group them with their parent entry. + inner_w = max(10, width - 2) + flat: List[Tuple[int, str]] = [] # (level_attr, text) + for line in lines: + attr = self._log_attr(line.level) + head = f"{line.ts} [{line.level}] {line.text}" + # Derive the continuation indent from the actual prefix length so + # wrapped lines align regardless of level-name width (INFO vs DEBUG). + cont_indent = " " * (len(line.ts) + len(line.level) + 4) # 4 = " [" + "] " + wrapped = _word_wrap(head, inner_w) + for k, row in enumerate(wrapped): + flat.append((attr, row if k == 0 else cont_indent + row.lstrip())) + # Scroll offset is in *visual* rows now, not log entries. + total = len(flat) + offset = max(0, min(self.scroll_offset, max(0, total - body_height))) + end = total - offset + start = max(0, end - body_height) + slice_ = flat[start:end] + for i, (attr, text) in enumerate(slice_): + self._safe_addstr(stdscr, body_top + i, 1, text[:inner_w], width, attr) + return body_top + body_height - 1 + + def _draw_totals(self, stdscr, *, top: int, width: int) -> None: + with self.state.lock: + tot = self.state.total_tokens + tin = self.state.total_input_tokens + tout = self.state.total_output_tokens + files = self.state.total_files + agents = len(self.state.agents) + peak = self.state.peak_run_tokens + line = ( + f" totals :: agents={agents} :: tokens={_format_tokens(tot)} " + f"(in={_format_tokens(tin)} / out={_format_tokens(tout)}) " + f":: peak_run={_format_tokens(peak)} :: files={files} " + ) + attr = curses.color_pair(self.COLOR_LOG_SYS) | curses.A_BOLD + # Pad to width so the line reads as a band rather than a phrase. + self._safe_addstr(stdscr, top, 0, line.ljust(width - 1)[: width - 1], width, attr) + + def _hint_for_state(self) -> str: + """Compute the one-line hint that goes above the input prompt. + + Looks at the current dashboard state (idea captured? agent + working? last error?) and returns a short suggestion. Returns + an empty string when there's nothing useful to say. + """ + s = self.state + with s.lock: + has_idea = bool(s.idea) + busy = s.busy + err_kind = s.last_error_kind + in_tut = s.in_tutorial + stop_req = s.stop_requested + active_provider = s.provider or "shell" + if in_tut: + return "tutorial running — agents are operating in a sandbox; press /quit to exit" + if busy: + return "agent is working — /abort to kill the stuck subprocess if needed" + if stop_req: + return "stop requested — loop will end after the current cycle" + if err_kind == "not_installed": + return f"{active_provider} CLI not found — try /install {active_provider} or /provider " + if err_kind == "auth": + return f"{active_provider} auth failed — /configure {active_provider} to set credentials" + if err_kind == "rate_limit": + return "provider rate-limited — wait, or /provider to switch" + if err_kind == "timeout": + return "provider call stalled — /abort to kill it, then retry or switch provider" + if not has_idea: + return "type your idea, or /tutorial for a sample run, or /help" + return "type a message to continue, or /help for commands" + + def _draw_hint_bar(self, stdscr, *, top: int, width: int) -> None: + hint = self._hint_for_state() + if not hint: + return + text = (" " + hint).ljust(width - 1)[: width - 1] + # Subtle styling — log info color, no bold — so the hint never + # competes with the title bar or the cards for attention. + self._safe_addstr(stdscr, top, 0, text, width, curses.color_pair(self.COLOR_LOG_INFO)) + + # ----- /help overlay ---------------------------------------------------- + + _HELP_ROWS: List[Tuple[str, str]] = [ + ("/help, F1, ?", "Toggle this overlay."), + ("Esc, q", "Dismiss the overlay."), + ("", ""), + ("/idea ", "Replace the captured idea."), + ("/cast", "Have the chief re-pick the team."), + ("/roles list|add|drop", "Inspect or edit the agent roster."), + ("/run [workflow]", "Run one workflow cycle (default: engineering)."), + ("/loop ralph|autoresearch [N]", "Refinement loop for N iterations."), + ("/stop", "Stop the active loop after the current cycle."), + ("/abort", "Hard-kill the running provider subprocess."), + ("", ""), + ("/provider ", "Switch the active provider."), + ("/install [tool]", "Install a missing CLI (claude, pi, ollama, …)."), + ("/configure [tool]", "Re-run a tool's first-use config."), + ("/github", "(Re-)connect this workspace to a GitHub remote."), + ("", ""), + ("/doctor [--fix]", "Health-check providers and config."), + ("/diag", "Build a redacted diagnostic bundle."), + ("/tutorial", "Run a free sample idea on the shell provider."), + ("/workspaces list|switch|rename|clean", "Manage past kickoff dirs."), + ("/undo", "Revert the last clk-authored commit."), + ("/status", "Print a snapshot to the log."), + ("/quit, Ctrl-D", "Exit the TUI."), + ] + + def _draw_help_overlay(self, stdscr, h: int, w: int) -> None: + rows = self._HELP_ROWS + # Box dimensions: leave at least 4 cells of margin on each side + # so the underlying log/cards are still partially visible. + box_w = max(50, min(w - 4, 84)) + box_h = min(h - 2, len(rows) + 5) + y0 = max(1, (h - box_h) // 2) + x0 = max(1, (w - box_w) // 2) + attr = curses.color_pair(self.COLOR_LOG_SYS) | curses.A_BOLD + bg = curses.color_pair(self.COLOR_TITLE) + # Top + bottom borders, side walls. + self._safe_addstr(stdscr, y0, x0, "+" + "-" * (box_w - 2) + "+", box_w, attr) + for r in range(1, box_h - 1): + self._safe_addstr(stdscr, y0 + r, x0, "|" + " " * (box_w - 2) + "|", box_w, attr) + self._safe_addstr(stdscr, y0 + box_h - 1, x0, "+" + "-" * (box_w - 2) + "+", box_w, attr) + # Title. + title = " CLK :: help " + self._safe_addstr( + stdscr, y0, x0 + max(1, (box_w - len(title)) // 2), title, box_w, bg | curses.A_BOLD + ) + # Body. + col_w = (box_w - 6) // 3 + for i, (cmd, desc) in enumerate(rows[: box_h - 4]): + line = f" {cmd:<{col_w}} {desc}"[: box_w - 3] + self._safe_addstr(stdscr, y0 + 2 + i, x0 + 1, line, box_w - 2, curses.color_pair(self.COLOR_LOG_INFO)) + # Footer hint. + footer = " Esc / q to close " + self._safe_addstr( + stdscr, y0 + box_h - 1, + x0 + max(1, (box_w - len(footer)) // 2), + footer, box_w, bg | curses.A_BOLD, + ) + + def _draw_input(self, stdscr, *, top: int, width: int, rows: int = 1, cursor: int = 0) -> None: + # Frame line above the input rows. + self._safe_addstr( + stdscr, top, 0, "-" * (width - 1), width, curses.color_pair(self.COLOR_FRAME) + ) + with self.state.lock: + buf = self.state.input_buffer + cursor = max(0, min(cursor, len(buf))) + prompt = "> " + full = prompt + buf + eff = max(1, width - 1) + # Character-wrap so cursor math is exact even when the user + # types continuous strings (URLs, paste). + chunks_all = [full[i:i + eff] for i in range(0, max(eff, len(full)), eff)] or [""] + cursor_abs = len(prompt) + cursor + cursor_chunk = min(len(chunks_all) - 1, cursor_abs // eff) + first_chunk = max(0, min(cursor_chunk, len(chunks_all) - rows)) + # If the buffer needs more rows than we have, show the LAST + # ``rows`` chunks containing the cursor so the cursor stays visible. + chunks = chunks_all[first_chunk:first_chunk + rows] + attr = curses.color_pair(self.COLOR_PROMPT) | curses.A_BOLD + base_y = top + 1 + for i, line in enumerate(chunks): + self._safe_addstr(stdscr, base_y + i, 0, line, width, attr) + # Cursor goes after the last visible character. + try: + rel = cursor_abs - first_chunk * eff + cursor_y = base_y + max(0, min(rows - 1, rel // eff)) + cursor_x = max(0, min(rel % eff, width - 2)) + stdscr.move(cursor_y, cursor_x) + except Exception: + pass + + # --- input handling -------------------------------------------------- + + def _handle_key(self, ch: int) -> bool: + # When the /help overlay is up, Esc/q dismisses it and all + # other keys (including arrows) pass through normally. This + # lets agent state continue updating beneath the overlay. + if self._help_visible: + if ch in (27, ord('q'), ord('Q')): # Esc / q + self._help_visible = False + with self.state.lock: + self.state.help_dismissed = True + return True + # F1 always toggles help — no need to clear the input buffer. + if ch == curses.KEY_F1: + self._help_visible = not self._help_visible + return True + if ch in (4,): # Ctrl-D + self.worker.stop() + return False + if ch == curses.KEY_RESIZE: + return True + if ch == curses.KEY_PPAGE: + self.scroll_offset += 5 + return True + if ch == curses.KEY_NPAGE: + self.scroll_offset = max(0, self.scroll_offset - 5) + return True + if ch == curses.KEY_HOME: + self.scroll_offset = 10**9 + return True + if ch == curses.KEY_END: + self.scroll_offset = 0 + return True + if ch == curses.KEY_LEFT: + with self.state.lock: + self.state.input_cursor = max(0, self.state.input_cursor - 1) + return True + if ch == curses.KEY_RIGHT: + with self.state.lock: + self.state.input_cursor = min(len(self.state.input_buffer), self.state.input_cursor + 1) + return True + if ch in (curses.KEY_SLEFT, 1): # Shift-left where supported, Ctrl-A + with self.state.lock: + self.state.input_cursor = 0 + return True + if ch in (curses.KEY_SRIGHT, 5): # Shift-right where supported, Ctrl-E + with self.state.lock: + self.state.input_cursor = len(self.state.input_buffer) + return True + if ch == curses.KEY_DC: + with self.state.lock: + i = self.state.input_cursor + if i < len(self.state.input_buffer): + self.state.input_buffer = self.state.input_buffer[:i] + self.state.input_buffer[i + 1:] + return True + if ch in (curses.KEY_BACKSPACE, 127, 8): + with self.state.lock: + i = self.state.input_cursor + if i > 0: + self.state.input_buffer = self.state.input_buffer[:i - 1] + self.state.input_buffer[i:] + self.state.input_cursor = i - 1 + return True + if ch in (10, 13, curses.KEY_ENTER): + with self.state.lock: + msg = self.state.input_buffer + self.state.input_buffer = "" + self.state.input_cursor = 0 + if msg.strip(): + return self._dispatch(msg.strip()) + return True + if 32 <= ch < 127: + # When the input buffer is empty, ? opens /help instead of + # being typed — same convention as many CLIs. + if ch == ord('?'): + with self.state.lock: + empty = (self.state.input_buffer == "") + if empty: + self._help_visible = True + return True + with self.state.lock: + if len(self.state.input_buffer) < 1024: + i = max(0, min(self.state.input_cursor, len(self.state.input_buffer))) + self.state.input_buffer = self.state.input_buffer[:i] + chr(ch) + self.state.input_buffer[i:] + self.state.input_cursor = i + 1 + return True + return True + + # --- chat dispatch --------------------------------------------------- + + def _dispatch(self, msg: str) -> bool: + self.state.add_user_message(msg) + if msg.startswith("/"): + parts = msg[1:].split() + if not parts: + return True + cmd = parts[0].lower() + args = parts[1:] + if cmd in ("quit", "exit"): + self.worker.stop() + return False + if cmd == "idea": + if args: + self.worker.submit(Job("idea", " ".join(args))) + return True + if cmd == "run": + self.worker.submit(Job("run", args[0] if args else "engineering")) + return True + if cmd in ("mission", "auto"): + if args and not self.state.idea: + self.worker.submit(Job("idea", " ".join(args))) + self.worker.submit(Job("mission")) + return True + if cmd == "loop": + mode = args[0] if args else "ralph" + n = int(args[1]) if len(args) > 1 and args[1].isdigit() else 5 + self.worker.submit(Job("loop", {"mode": mode, "n": n})) + return True + if cmd == "stop": + self.worker.submit(Job("stop")) + return True + if cmd == "provider": + if args: + self.worker.submit(Job("provider", args[0])) + return True + if cmd == "status": + self.worker.submit(Job("status")) + return True + if cmd == "cast": + self.worker.submit(Job("cast")) + return True + if cmd == "abort": + self._do_abort() + return True + if cmd == "help": + self._help_visible = True + return True + if cmd == "install": + tool = args[0] if args else (self.state.provider or "") + self.worker.submit(Job("install", tool)) + return True + if cmd == "configure": + tool = args[0] if args else (self.state.provider or "") + self.worker.submit(Job("configure", tool)) + return True + if cmd == "github": + self.worker.submit(Job("github")) + return True + if cmd == "undo": + # Two-step confirm: first /undo prints the diff, second + # /undo confirm actually reverts. Matches the + # always-confirm policy. + confirm = bool(args and args[0].lower() == "confirm") + self.worker.submit(Job("undo", {"confirm": confirm})) + return True + if cmd == "doctor": + fix = bool(args and args[0] == "--fix") + self.worker.submit(Job("doctor", {"fix": fix})) + return True + if cmd == "diag": + self.worker.submit(Job("diag")) + return True + if cmd == "tutorial": + self.worker.submit(Job("tutorial")) + return True + if cmd == "workspaces": + action = args[0] if args else "list" + rest = args[1:] + self.worker.submit(Job("workspaces", {"action": action, "args": rest})) + return True + if cmd == "roles": + if not args: + self.worker.submit(Job("roles", {"action": "list"})) + return True + sub = args[0].lower() + if sub == "list": + self.worker.submit(Job("roles", {"action": "list"})) + elif sub in ("add", "drop", "remove"): + name = args[1] if len(args) > 1 else "" + role_text = " ".join(args[2:]).strip().strip('"') if len(args) > 2 else "" + op = "add" if sub == "add" else "remove" + self.worker.submit(Job("roles", {"action": op, "name": name, "role": role_text})) + else: + self.state.add_log(f"unknown roles op: {sub}", level="WARN") + return True + self.state.add_log( + f"'/{cmd}' isn't a command I know. Type /help (or press F1) for the list.", + level="WARN", + ) + return True + # Free-text: first message becomes the idea; subsequent ones are + # appended to the conversation file and trigger another run. + # The engineering workflow's first stage IS chief casting (the + # chief decomposes + casts the team + authors the workflow YAML + # all in one call), so we deliberately do NOT submit a separate + # Job("cast") here. Doing so would invoke chief twice back-to- + # back per user message and was the cause of the "chief stuck + # at 90+ seconds" symptom. /cast remains as an explicit manual + # trigger when you want a re-cast without running engineering. + # Autonomy by default: a free-text message drives the full autonomous + # mission (charter -> plan -> phases -> code-gated done) rather than a + # single workflow pass. Use /run for a one-shot engineering cycle. + if not self.state.idea: + self.worker.submit(Job("idea", msg)) + self.worker.submit(Job("mission")) + else: + self._append_conversation(msg) + self.worker.submit(Job("mission")) + return True + + def _do_abort(self) -> None: + """Send SIGTERM (then SIGKILL) to every WORKING agent's subprocess. + + Runs on the curses thread, NOT the worker thread, because the + worker is the one blocked inside ``provider.invoke()``. Killing + the subprocess unblocks ``proc.wait()`` in run_streaming, which + causes the provider to return an error response, which the + worker treats as a normal failed run. + """ + import os + import signal + with self.state.lock: + targets = [ + (name, card.live_pid) + for name, card in self.state.agents.items() + if card.status == AgentStatus.WORKING and card.live_pid + ] + if not targets: + self.state.add_log( + "nothing to abort — no agent subprocess is running right now.", + level="WARN", + ) + return + self.state.add_system_message( + f"aborting {len(targets)} stuck subprocess(es)…" + ) + for name, pid in targets: + try: + os.kill(pid, signal.SIGTERM) + self.state.add_log( + f"sent SIGTERM to {name} (pid {pid}) — the cycle will report a timeout", level="WARN" + ) + except ProcessLookupError: + self.state.add_log(f"abort: {name} pid={pid} already gone", level="INFO") + except Exception as exc: + self.state.add_log(f"abort: {name} pid={pid} failed: {exc}", level="ERROR") + + def _append_conversation(self, msg: str) -> None: + try: + path = self.worker.paths.state / "conversation.md" + ts = datetime.now().isoformat(timespec="seconds") + with path.open("a", encoding="utf-8") as fh: + fh.write(f"\n## {ts} (user)\n{msg}\n") + except Exception as exc: + log_exception("tui.TuiApp._append_conversation", exc) + + # --- helpers --------------------------------------------------------- + + def _safe_addstr(self, stdscr, y: int, x: int, text: str, max_width: int, attr: int = 0) -> None: + if y < 0 or x < 0: + return + try: + h, w = stdscr.getmaxyx() + if y >= h: + return + limit = max(0, min(len(text), w - x - 1)) + if limit <= 0: + return + stdscr.addnstr(y, x, text, limit, attr) + except curses.error: + pass + except Exception as exc: + log_exception("tui.TuiApp._safe_addstr", exc) + + def _fill(self, stdscr, y: int, x: int, width: int, ch: str, attr: int = 0) -> None: + try: + stdscr.addnstr(y, x, ch * (width - x - 1), max(0, width - x - 1), attr) + except curses.error: + pass + + def _log_attr(self, level: str) -> int: + return { + "INFO": curses.color_pair(self.COLOR_LOG_INFO), + "WARN": curses.color_pair(self.COLOR_LOG_WARN) | curses.A_BOLD, + "ERROR": curses.color_pair(self.COLOR_LOG_ERROR) | curses.A_BOLD, + "USER": curses.color_pair(self.COLOR_LOG_USER) | curses.A_BOLD, + "SYSTEM": curses.color_pair(self.COLOR_LOG_SYS), + }.get(level, 0) + + +# --------------------------------------------------------------------------- +# Public entrypoint +# --------------------------------------------------------------------------- + + +def run(initial_prompt: Optional[str] = None) -> int: + """Entry point used by ``clk tui``.""" + paths = project_paths() + if not is_initialized(paths): + print("CLK is not initialized. Run `clk init` first.", file=sys.stderr) + return 2 + + clk_cfg = load_clk_config(paths) + providers_cfg = load_providers_config(paths) + agents_cfg = load_agents_config(paths) + agent_names = list((agents_cfg.get("agents") or {}).keys()) + + # Crashed-session detection: if a previous TUI exited without removing + # its lock file, mention that and offer the user a clean restart hint. + # We don't auto-resume the conversation here — that's the worker's job + # if the user types something — but we do surface the situation so + # they know what happened. + lock_path = paths.state / ".tui-active" + prior_session = None + try: + if lock_path.exists(): + prior_pid = lock_path.read_text(encoding="utf-8").strip() + # Stale lock if the PID no longer exists. + try: + import os as _os + if prior_pid.isdigit(): + _os.kill(int(prior_pid), 0) + # Still running — leave the file alone, don't claim it. + print( + f"[tui] another CLK TUI is already running (pid {prior_pid}); " + f"close it first or rm -f {lock_path}", + file=sys.stderr, + ) + return 2 + except (OSError, ValueError): + prior_session = prior_pid + paths.state.mkdir(parents=True, exist_ok=True) + lock_path.write_text(str(__import__('os').getpid()), encoding="utf-8") + except Exception as _exc: + logger.debug("could not write session lock: %s", _exc) + + state = DashboardState(agent_names, paths=paths, agents_cfg=agents_cfg) + state.project_name = clk_cfg.get("project_name") or paths.root.name + state.provider = providers_cfg.get("active") or clk_cfg.get("default_provider") or "shell" + if prior_session: + state.add_log( + f"recovered from a crashed session (prior pid {prior_session}). " + f"Your conversation is preserved under .clk/state/conversation.md.", + level="WARN", + ) + # Mirror every status-pane line to a persistent file so we have a + # full session trace inside the kickoff dir for later analysis. + state.attach_session_log(paths.logs / "session.log") + + observer = DashboardObserver(state) + runner = AgentRunner( + paths=paths, + agents_cfg=agents_cfg, + providers_cfg=providers_cfg, + clk_cfg=clk_cfg, + observer=observer, + ) + checks = clk_cfg.get("validation_checks") or ["test -f .clk/config/clk.config.json"] + evaluator = Evaluator(root=paths.root, default_checks=checks) + worker = Worker(paths, runner, evaluator, state, clk_cfg, providers_cfg) + + # Pre-populate from existing idea if any. + idea_path = paths.state / "idea.json" + if idea_path.exists(): + try: + payload = json.loads(idea_path.read_text(encoding="utf-8")) + state.set_idea(payload.get("statement") or payload.get("title") or "") + except Exception as exc: + log_exception("tui.run.load_idea", exc) + + # Route every stderr/stdout write into the dashboard log pane BEFORE + # the worker starts processing jobs, so even the very first job's + # output (subprocess, log(), traceback.print_exc()) cannot reach the + # real terminal and corrupt the curses display. The original streams + # are restored on exit so post-TUI shell output looks normal again. + old_stderr = sys.stderr + old_stdout = sys.stdout + sys.stderr = _StreamToLog(state, default_level="INFO") + sys.stdout = _StreamToLog(state, default_level="INFO") + worker.start() + + if initial_prompt: + worker.submit(Job("idea", initial_prompt)) + worker.submit(Job("run", "engineering")) + + app = TuiApp(state, worker) + try: + app.run() + finally: + worker.stop() + worker.join(timeout=2.0) + sys.stderr = old_stderr + sys.stdout = old_stdout + state.close_session_log() + # Clean up the lock so the next run doesn't see "crashed session". + try: + if lock_path.exists(): + lock_path.unlink() + except Exception as _exc: + logger.debug("could not remove session lock: %s", _exc) + return 0 diff --git a/clk_harness/tui/commands.py b/clk_harness/tui/commands.py new file mode 100644 index 0000000..32c2723 --- /dev/null +++ b/clk_harness/tui/commands.py @@ -0,0 +1,873 @@ +"""Worker thread: executes TUI jobs (slash commands) off the UI thread. +""" + +from __future__ import annotations + +import json +import queue +import threading +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + +from ..config import ( + Paths, + save_json, +) +from ..git_ops import ( + add_all, + commits_ahead, + has_changes, + has_remote, + is_repo, +) +from ..git_ops import ( + commit as git_commit, +) +from ..git_ops import ( + push as git_push, +) +from ..log import get_logger, log_exception +from ..orchestration import ( + AgentRunner, + AutoresearchLoop, + Evaluator, + MissionRunner, + RalphLoop, + RoleProposal, + WorkflowRunner, + casting_objective, + is_baseline, + load_workflow, + register_role, + remove_role, + render_roster_summary, +) +from ..pricing import format_usd +from .dashboard import DashboardState +from .theme import _format_tokens + +logger = get_logger(__name__) + + +@dataclass +class Job: + kind: str # idea | run | loop | stop | provider | quit | status + payload: Any = None + + +class Worker(threading.Thread): + daemon = True + + def __init__( + self, + paths: Paths, + runner: AgentRunner, + evaluator: Evaluator, + state: DashboardState, + clk_cfg: Dict[str, Any], + providers_cfg: Dict[str, Any], + ) -> None: + super().__init__(name="clk-worker") + self.paths = paths + self.runner = runner + self.evaluator = evaluator + self.state = state + self.clk_cfg = clk_cfg + self.providers_cfg = providers_cfg + self.q: queue.Queue[Job] = queue.Queue() + self._alive = True + + def submit(self, job: Job) -> None: + self.q.put(job) + + def stop(self) -> None: + self._alive = False + self.q.put(Job("quit")) + + # --- main loop ------------------------------------------------------- + + def run(self) -> None: + while self._alive: + try: + job = self.q.get(timeout=0.25) + except queue.Empty: + continue + try: + self._dispatch(job) + except Exception as exc: + log_exception("tui.Worker.run", exc) + self.state.add_log(f"worker error: {exc}", level="ERROR") + self.state.set_phase("idle", busy=False) + + def _dispatch(self, job: Job) -> None: + if job.kind == "quit": + self._alive = False + return + if job.kind == "idea": + self._do_idea(job.payload or "") + elif job.kind == "cast": + self._do_cast() + elif job.kind == "run": + self._do_workflow(job.payload or "engineering") + elif job.kind == "mission": + self._do_mission() + elif job.kind == "loop": + payload = job.payload or {} + self._do_loop( + mode=payload.get("mode", "ralph"), + n=int(payload.get("n", self.clk_cfg.get("max_iterations") or 5)), + ) + elif job.kind == "stop": + self.state.request_stop() + elif job.kind == "provider": + self._do_set_provider(job.payload or "shell") + elif job.kind == "status": + self._emit_status() + elif job.kind == "roles": + self._do_roles(job.payload or {}) + elif job.kind == "abort": + # /abort runs in the curses thread (not the worker) because + # the worker is blocked on the very subprocess we're killing. + # No-op here; see TuiApp._do_abort. + pass + elif job.kind == "install": + self._do_install(job.payload or "") + elif job.kind == "configure": + self._do_configure(job.payload or "") + elif job.kind == "github": + self._do_github() + elif job.kind == "undo": + self._do_undo(bool((job.payload or {}).get("confirm"))) + elif job.kind == "doctor": + self._do_doctor(bool((job.payload or {}).get("fix"))) + elif job.kind == "diag": + self._do_diag() + elif job.kind == "tutorial": + self._do_tutorial() + elif job.kind == "workspaces": + self._do_workspaces(job.payload or {}) + + # --- handlers -------------------------------------------------------- + + def _do_idea(self, idea: str) -> None: + idea = idea.strip() + if not idea: + return + self.state.set_phase("idea", busy=True) + self.state.set_idea(idea) + title = idea.split(".")[0][:80] or idea[:80] + try: + save_json( + self.paths.state / "idea.json", + { + "title": title, + "statement": idea, + "captured_at": datetime.now().isoformat(timespec="seconds"), + "tags": [], + }, + ) + (self.paths.state / "system_brief.md").write_text( + f"# System brief\n\n**Title:** {title}\n\n## Idea\n{idea}\n", + encoding="utf-8", + ) + self.state.add_system_message( + f"got it — idea captured as '{title}'. The chief will cast a " + f"team next; agent cards above will turn yellow as they start." + ) + self._maybe_commit("clk-tui-idea", f"Capture idea: {title}", "idea captured", ".clk/state/idea.json") + except Exception as exc: + log_exception("tui.Worker._do_idea", exc) + self.state.add_log(f"idea save failed: {exc}", level="ERROR") + finally: + self.state.set_phase("idle", busy=False) + + def _do_cast(self) -> None: + idea_path = self.paths.state / "idea.json" + if not idea_path.exists(): + self.state.add_log("cast skipped: no idea captured yet", level="WARN") + return + try: + payload = json.loads(idea_path.read_text(encoding="utf-8")) + title = payload.get("title") or "Untitled idea" + statement = payload.get("statement") or "" + except Exception as exc: + log_exception("tui.Worker._do_cast.read_idea", exc) + return + self.state.set_phase("casting", busy=True) + try: + objective = casting_objective(title, statement) + self.runner.run("chief", objective, extra={"phase": "casting"}) + self.state.add_system_message( + "casting :: " + render_roster_summary(self.paths).replace("\n", " | ")[:240] + ) + except Exception as exc: + log_exception("tui.Worker._do_cast", exc) + self.state.add_log(f"casting failed: {exc}", level="ERROR") + finally: + self.state.set_phase("idle", busy=False) + + def _do_roles(self, payload: Dict[str, Any]) -> None: + action = payload.get("action") or "list" + if action == "list": + summary = render_roster_summary(self.paths) + for line in summary.splitlines(): + self.state.add_log(line, level="SYSTEM") + return + name = payload.get("name") or "" + if action == "add": + prop = RoleProposal(name=name, role=payload.get("role", ""), provider=payload.get("provider")) + ok, status = register_role( + self.paths, + prop, + agents_cfg=self.runner.agents_cfg, + on_change=lambda n, s: self.state.upsert_agent( + n, role=prop.role, baseline=is_baseline(n), status=s + ), + ) + self.state.add_log(f"roles add {name}: {status}", level="SYSTEM" if ok else "WARN") + return + if action == "remove": + ok, status = remove_role( + self.paths, + name, + agents_cfg=self.runner.agents_cfg, + on_change=lambda n, s: self.state.drop_agent(n) if s == "removed" else None, + ) + self.state.add_log(f"roles remove {name}: {status}", level="SYSTEM" if ok else "WARN") + return + self.state.add_log(f"unknown roles action: {action}", level="WARN") + + def _do_workflow(self, name: str) -> None: + wf_path = self.paths.workflows / f"{name}.yaml" + if not wf_path.exists(): + self.state.add_log( + f"workflow '{name}' not found — try /run engineering or check " + f".clk/config/workflows/ for the available list", + level="WARN", + ) + return + self.state.set_phase(f"workflow:{name}", busy=True) + self.state.add_system_message( + f"starting workflow '{name}' — the chief will cast a team and " + f"dispatch agents stage by stage. Watch the cards above for live progress." + ) + any_failure = False + try: + wf = load_workflow(wf_path) + wf_runner = WorkflowRunner(self.paths, self.runner) + wf_runner.run(wf) + except Exception as exc: + any_failure = True + log_exception("tui.Worker._do_workflow", exc) + self.state.add_log(f"workflow '{name}' hit an error: {exc}", level="ERROR") + finally: + self.state.set_phase("idle", busy=False) + # Friendly post-flight summary. Always tell the user what + # they can do next — even on failure — so they're never + # stuck wondering "is something broken? what do I do?". + with self.state.lock: + tot = self.state.total_tokens + usd = self.state.total_usd + files = self.state.total_files + err_kind = self.state.last_error_kind + err_cmd = self.state.last_error_command + if any_failure or err_kind: + self.state.add_system_message( + f"workflow '{name}' finished with issues. session tokens={_format_tokens(tot)} " + f"cost={format_usd(usd)} files={files}" + ) + if err_cmd: + self.state.add_system_message( + f"suggested next step: {err_cmd} (or /provider to switch)" + ) + else: + self.state.add_system_message( + "suggested next step: /status to inspect, /undo to roll back, " + "or type a follow-up message" + ) + else: + self.state.add_system_message( + f"workflow '{name}' complete. session tokens={_format_tokens(tot)} " + f"cost={format_usd(usd)} files={files}" + ) + self.state.add_system_message( + "next steps: type a follow-up message to keep going, " + "/loop ralph 5 to refine, /undo to revert, or /quit to exit." + ) + + def _do_mission(self) -> None: + """Drive the autonomous mission (charter -> plan -> phases -> done).""" + self.state.clear_stop() + self.state.set_phase("mission", busy=True) + self.state.add_system_message( + "starting autonomous mission — the chief writes a charter and plan, " + "then drives the lifecycle to a code-gated done. Watch the cards above; " + "type /stop to end after the current cycle." + ) + any_failure = False + try: + mr = MissionRunner(self.paths, self.runner, self.evaluator) + plan = mr.run() + self.state.add_system_message( + f"mission {plan.status}: " + f"{sum(1 for p in plan.phases if p.status == 'done')}/{len(plan.phases)} " + f"phases done, {plan.total_cycles_used} cycles." + ) + if plan.status != "done" and (plan.done_gate_last or {}).get("failures"): + self.state.add_system_message( + "done-gate unmet: " + ", ".join(plan.done_gate_last["failures"]) + ) + except Exception as exc: + any_failure = True + log_exception("tui.Worker._do_mission", exc) + self.state.add_log(f"mission hit an error: {exc}", level="ERROR") + finally: + self.state.set_phase("idle", busy=False) + if not any_failure: + self.state.add_system_message( + "next steps: type a follow-up to extend the mission, " + "/loop ralph 5 to refine, /undo to revert, or /quit." + ) + + def _do_loop(self, mode: str, n: int) -> None: + self.state.clear_stop() + self.state.set_phase(f"loop:{mode}", busy=True) + self.state.add_system_message( + f"starting {mode} loop for up to {n} iterations. " + f"Type /stop to end after the current cycle, or /abort to kill a stuck call." + ) + interrupted = False + completed = 0 + try: + if mode == "ralph": + # We can't preempt mid-iteration, but we can check between iterations + # by running one iteration at a time. + for i in range(1, n + 1): + if self.state.is_stop_requested(): + interrupted = True + self.state.add_log( + f"loop interrupted after iteration {i - 1} of {n}", + level="WARN", + ) + break + self.state.iteration_count = i + self.state.add_system_message( + f"ralph iteration {i}/{n} — refining the previous output" + ) + sub = RalphLoop(self.paths, self.runner, self.evaluator, max_iterations=1) + sub.run() + completed = i + else: + for i in range(1, n + 1): + if self.state.is_stop_requested(): + interrupted = True + self.state.add_log( + f"loop interrupted after iteration {i - 1} of {n}", + level="WARN", + ) + break + self.state.iteration_count = i + self.state.add_system_message( + f"autoresearch iteration {i}/{n} — exploring open questions" + ) + asub = AutoresearchLoop(self.paths, self.runner, self.evaluator, max_iterations=1) + asub.run() + completed = i + except Exception as exc: + log_exception("tui.Worker._do_loop", exc) + self.state.add_log(f"loop hit an error and stopped: {exc}", level="ERROR") + finally: + self.state.set_phase("idle", busy=False) + verb = "stopped" if interrupted else "complete" + self.state.add_system_message( + f"{mode} loop {verb} after {completed} iteration(s). " + f"Type /status for the breakdown, /loop {mode} {n} to keep going, " + f"or a follow-up message to redirect." + ) + + def _do_set_provider(self, name: str) -> None: + try: + cfg_path = self.paths.config / "providers.json" + data = json.loads(cfg_path.read_text(encoding="utf-8")) + if name not in (data.get("providers") or {}): + self.state.add_log( + f"'{name}' isn't a known provider. valid: " + f"{', '.join(sorted((data.get('providers') or {}).keys()))}", + level="WARN", + ) + return + old_name = self.state.provider or "(unset)" + data["active"] = name + save_json(cfg_path, data) + self.providers_cfg = data + self.runner.providers_cfg = data + with self.state.lock: + self.state.provider = name + # New provider — clear stale error hints so the bar doesn't + # keep suggesting /install . + self.state.last_error_kind = "" + self.state.last_error_command = "" + # Check that the new provider is actually usable so we can + # warn before the user's next call fails. + try: + from ..providers import available_providers as _ap + avail = _ap(data) + if avail.get(name): + self.state.add_system_message( + f"provider: {old_name} → {name} (ready)" + ) + else: + self.state.add_system_message( + f"provider: {old_name} → {name} (NOT ready — try /install {name} or /configure {name})" + ) + with self.state.lock: + self.state.last_error_kind = "not_installed" + self.state.last_error_command = f"/install {name}" + except Exception: + self.state.add_system_message(f"provider switched to {name}") + except Exception as exc: + log_exception("tui.Worker._do_set_provider", exc) + self.state.add_log(f"provider switch failed: {exc}", level="ERROR") + + def _emit_status(self) -> None: + snap = self.state.snapshot() + # Header: short narrative the user can read at a glance. + phase = snap.get("phase") or "idle" + busy = snap.get("busy") + provider = snap.get("provider") or "shell" + agents = snap.get("agents") or {} + narrative = ( + f"working on '{phase}'" if busy else f"idle (last phase: '{phase}')" + ) + self.state.add_system_message( + "--- session snapshot ---" + ) + self.state.add_system_message( + f" status {narrative}" + ) + self.state.add_system_message( + f" provider {provider}" + ) + self.state.add_system_message( + f" agents {len(agents)} ({', '.join(sorted(agents.keys())) or 'none yet'})" + ) + # Cost breakdown — same numbers the title bar shows, but split by + # provider so the user can see where the spend went. + with self.state.lock: + usd = self.state.total_usd + per = dict(self.state.cost_per_provider) + tot = self.state.total_tokens + files = self.state.total_files + idea = self.state.idea[:80] + self.state.add_system_message( + f" tokens {_format_tokens(tot)} files written: {files}" + ) + self.state.add_system_message(f" est. cost {format_usd(usd)}") + if per: + for p, amount in sorted(per.items()): + if amount > 0: + self.state.add_system_message(f" - {p:<10} {format_usd(amount)}") + if idea: + self.state.add_system_message(f" idea {idea}") + self.state.add_system_message( + "------------------------" + ) + + # ----- subprocess helpers used by /install /configure /doctor ---------- + + def _run_capture(self, cmd: List[str], cwd: Optional[Path] = None) -> Tuple[int, str, str]: + """Run a subprocess, stream its output into the log pane, and + return (rc, stdout, stderr). Used by /install, /configure, etc. + These commands are interactive (they prompt y/N, ask for keys) + so we do NOT capture stdin — the subprocess inherits ours and + runs against /dev/tty when sourced functions need it. + """ + import subprocess + try: + proc = subprocess.Popen( + cmd, + cwd=str(cwd) if cwd else None, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + except Exception as exc: + log_exception("tui.Worker._run_capture", exc) + return 1, "", str(exc) + out_lines: List[str] = [] + err_lines: List[str] = [] + # Drain both pipes line-by-line so the log pane sees progress. + import threading as _t + def _pump(stream, sink, level): + try: + for line in stream: + line = line.rstrip() + if line: + sink.append(line) + self.state.add_log(line, level=level) + except Exception as _exc: + logger.debug("output pump stopped: %s", _exc) + t1 = _t.Thread(target=_pump, args=(proc.stdout, out_lines, "INFO"), daemon=True) + t2 = _t.Thread(target=_pump, args=(proc.stderr, err_lines, "WARN"), daemon=True) + t1.start() + t2.start() + rc = proc.wait() + t1.join(timeout=1) + t2.join(timeout=1) + return rc, "\n".join(out_lines), "\n".join(err_lines) + + def _script(self, name: str) -> Path: + # Locate scripts/ relative to the harness install. + return Path(__file__).resolve().parent.parent / "scripts" / name + + # ----- /install --------------------------------------------------------- + + def _do_install(self, tool: str) -> None: + tool = (tool or "").strip() + if not tool: + self.state.add_log("install: no tool specified", level="WARN") + return + self.state.set_phase(f"install {tool}", busy=True) + try: + script = self._script("install_tool.sh") + if not script.exists(): + self.state.add_log(f"install: {script} not found", level="ERROR") + return + rc, _out, err = self._run_capture(["bash", str(script), "install", tool, "--prompt"]) + if rc == 0: + self.state.add_system_message(f"install {tool}: done") + # Clear the not_installed hint so the bar updates. + with self.state.lock: + if self.state.last_error_kind == "not_installed": + self.state.last_error_kind = "" + self.state.last_error_command = "" + else: + self.state.add_log(f"install {tool}: rc={rc} {err[:200]}", level="ERROR") + finally: + self.state.set_phase("idle", busy=False) + + # ----- /configure ------------------------------------------------------- + + def _do_configure(self, tool: str) -> None: + tool = (tool or "").strip() + if not tool: + self.state.add_log("configure: no tool specified", level="WARN") + return + self.state.set_phase(f"configure {tool}", busy=True) + try: + script = self._script("install_tool.sh") + rc, _out, err = self._run_capture(["bash", str(script), "configure", tool]) + if rc == 0: + self.state.add_system_message(f"configure {tool}: done") + with self.state.lock: + if self.state.last_error_kind == "auth": + self.state.last_error_kind = "" + self.state.last_error_command = "" + else: + self.state.add_log(f"configure {tool}: rc={rc} {err[:200]}", level="ERROR") + finally: + self.state.set_phase("idle", busy=False) + + # ----- /github ---------------------------------------------------------- + + def _do_github(self) -> None: + # GitHub re-link from inside the TUI. We don't re-run the full + # wizard here — we just print the current state and the + # instructions. The wizard prompts via /dev/tty which the curses + # screen has already taken over, so attempting an interactive + # prompt from within the TUI would corrupt the display. + self.state.set_phase("github", busy=True) + try: + root = self.paths.root + rc, out, _ = self._run_capture(["git", "-C", str(root), "remote", "-v"]) + self.state.add_system_message("current git remotes:") + for line in (out or "").splitlines(): + self.state.add_system_message(f" {line}") + self.state.add_system_message( + "to (re-)link a remote, /quit then run: ./kickoff.sh --setup" + ) + self.state.add_system_message( + "the wizard's GitHub block handles create | existing | skip safely from /dev/tty" + ) + finally: + self.state.set_phase("idle", busy=False) + + # ----- /undo ------------------------------------------------------------ + + def _do_undo(self, confirm: bool) -> None: + root = self.paths.root + try: + if has_changes(root): + self.state.add_log( + "undo refused: uncommitted changes in the workspace. " + "Commit or stash first.", + level="WARN", + ) + return + # Show the diff of HEAD before doing anything. + rc, out, err = self._run_capture( + ["git", "-C", str(root), "log", "-1", "--stat"] + ) + if rc != 0: + self.state.add_log(f"undo: cannot read HEAD: {err}", level="ERROR") + return + if not confirm: + self.state.add_system_message("last commit (HEAD):") + for line in (out or "").splitlines()[:40]: + self.state.add_system_message(f" {line}") + self.state.add_system_message( + "type /undo confirm to revert this commit (creates a new revert commit)" + ) + return + rc, _out, err = self._run_capture( + ["git", "-C", str(root), "revert", "--no-edit", "HEAD"] + ) + if rc == 0: + self.state.add_system_message("undo: HEAD reverted with a new commit.") + else: + self.state.add_log(f"undo: revert failed: {err}", level="ERROR") + except Exception as exc: + log_exception("tui.Worker._do_undo", exc) + self.state.add_log(f"undo error: {exc}", level="ERROR") + + # ----- /doctor ---------------------------------------------------------- + + def _do_doctor(self, fix: bool) -> None: + self.state.set_phase("doctor", busy=True) + try: + from ..config import load_clk_config as _lcc + from ..config import load_providers_config as _lpc + from ..providers import available_providers + prov_cfg = _lpc(self.paths) + clk_cfg = _lcc(self.paths) + auth_mode = (clk_cfg.get("auth_mode") or "cli").lower() if isinstance(clk_cfg, dict) else "cli" + findings: List[Tuple[str, str, str]] = [] # (level, name, message) + avail = available_providers(prov_cfg) + active = prov_cfg.get("active") or clk_cfg.get("default_provider") or "shell" + for name, ok in avail.items(): + if ok: + findings.append(("ok", name, "available")) + else: + findings.append(("warn" if name != active else "fail", name, "unavailable")) + # Known-bad combos. + import os as _os + if active == "claude" and auth_mode == "apikey" and not _os.environ.get("ANTHROPIC_API_KEY"): + findings.append(("fail", "anthropic_key", "CLK_AUTH_MODE=apikey but ANTHROPIC_API_KEY is unset")) + if active == "codex" and auth_mode == "apikey" and not _os.environ.get("OPENAI_API_KEY"): + findings.append(("fail", "openai_key", "CLK_AUTH_MODE=apikey but OPENAI_API_KEY is unset")) + # Git / GitHub. + if not is_repo(self.paths.root): + findings.append(("warn", "git", "no git repo at project root; auto-commit disabled")) + # Emit. + for level, name, msg in findings: + self.state.add_system_message(f"doctor :: [{level:<4}] {name}: {msg}") + failures = [f for f in findings if f[0] == "fail"] + if not failures: + self.state.add_system_message("doctor: all checks passed.") + return + if not fix: + self.state.add_system_message( + f"doctor: {len(failures)} failure(s). Re-run as /doctor --fix to attempt repairs." + ) + return + for _, name, _ in failures: + if name in ("anthropic_key", "openai_key"): + self.state.add_system_message( + f"doctor --fix: run /configure {active} to set the missing API key" + ) + elif name == active: + self.state.add_system_message(f"doctor --fix: run /install {name}") + except Exception as exc: + log_exception("tui.Worker._do_doctor", exc) + self.state.add_log(f"doctor error: {exc}", level="ERROR") + finally: + self.state.set_phase("idle", busy=False) + + # ----- /diag ------------------------------------------------------------ + + def _do_diag(self) -> None: + import tarfile + import time as _time + ts = _time.strftime("%Y%m%d-%H%M%S") + out_path = self.paths.root / f"clk-diag-{ts}.tar.gz" + self.state.set_phase("diag", busy=True) + try: + # Build a redacted .env first in a tempfile. + env_path = self.paths.root / ".env" + redacted = None + if env_path.exists(): + redacted_lines = [] + for line in env_path.read_text(encoding="utf-8").splitlines(): + if "=" in line and not line.lstrip().startswith("#"): + k, v = line.split("=", 1) + if any(s in k.upper() for s in ("KEY", "TOKEN", "SECRET", "PASS")): + v = f"" + redacted_lines.append(f"{k}={v}") + else: + redacted_lines.append(line) + redacted = self.paths.state / ".env.redacted" + redacted.write_text("\n".join(redacted_lines) + "\n", encoding="utf-8") + + with tarfile.open(out_path, "w:gz") as tf: + # Pick up logs (last ~5MB total), state, last 3 runs. + for sub in ("logs", "state"): + d = self.paths.clk / sub + if d.exists(): + tf.add(d, arcname=f".clk/{sub}") + runs_dir = self.paths.runs + if runs_dir.exists(): + runs = sorted([p for p in runs_dir.glob("*") if p.is_dir()], + reverse=True)[:3] + for r in runs: + tf.add(r, arcname=f".clk/runs/{r.name}") + if redacted and redacted.exists(): + tf.add(redacted, arcname=".env.redacted") + if redacted and redacted.exists(): + redacted.unlink() + self.state.add_system_message(f"diag: wrote {out_path}") + self.state.add_system_message("share this tarball in your bug report (API keys are redacted)") + except Exception as exc: + log_exception("tui.Worker._do_diag", exc) + self.state.add_log(f"diag error: {exc}", level="ERROR") + finally: + self.state.set_phase("idle", busy=False) + + # ----- /tutorial -------------------------------------------------------- + + def _do_tutorial(self) -> None: + # Switch to the shell provider, sandbox state under + # .clk/state/.tutorial/, run one engineering cycle, restore. + original_provider = self.state.provider + try: + with self.state.lock: + self.state.in_tutorial = True + self.state.add_system_message( + "tutorial: switching to the shell provider; nothing will be charged." + ) + self._do_set_provider("shell") + self.state.add_system_message( + "tutorial: idea = 'Add a hello() function to greeter.py'" + ) + self._do_idea("Add a hello() function to greeter.py") + self._do_workflow("engineering") + self.state.add_system_message("tutorial: done. Type /quit, or type an idea to keep going.") + # Mark seen so the welcome banner stops mentioning the tutorial. + try: + if self.paths and self.paths.state: + (self.paths.state / ".seen-tutorial").write_text("seen\n", encoding="utf-8") + except Exception as _exc: + logger.debug("could not persist tutorial marker: %s", _exc) + except Exception as exc: + log_exception("tui.Worker._do_tutorial", exc) + self.state.add_log(f"tutorial error: {exc}", level="ERROR") + finally: + with self.state.lock: + self.state.in_tutorial = False + # Restore the user's previous provider if it was something other than shell. + if original_provider and original_provider != "shell": + self._do_set_provider(original_provider) + + # ----- /workspaces ------------------------------------------------------ + + def _do_workspaces(self, payload: Dict[str, Any]) -> None: + action = (payload.get("action") or "list").lower() + args = payload.get("args") or [] + # Workspaces live one dir above the kickoff dir (the kickoff was + # created under /workspace/kickoff-). Walk up to find + # the workspace/ parent. + kickoff_dir = self.paths.root + ws_parent = ( + kickoff_dir.parent + if kickoff_dir.parent.name == "workspace" + else (kickoff_dir / ".." / "..").resolve() / "workspace" + ) + if action == "list": + if not ws_parent.exists(): + self.state.add_system_message("workspaces: no workspace/ dir found") + return + count = 0 + for d in sorted(ws_parent.glob("kickoff-*"), reverse=True): + if not d.is_dir(): + continue + count += 1 + idea = "" + idea_path = d / ".clk" / "state" / "idea.json" + if idea_path.exists(): + try: + idea = (json.loads(idea_path.read_text(encoding="utf-8")).get("title") or "")[:60] + except Exception: + idea = "" + marker = "* " if d.resolve() == kickoff_dir.resolve() else " " + self.state.add_system_message(f"{marker}{d.name} :: {idea}") + if count == 0: + self.state.add_system_message("workspaces: no kickoff dirs yet") + elif action == "rename": + if len(args) < 2: + self.state.add_log("workspaces rename: usage /workspaces rename ", level="WARN") + return + old, new = ws_parent / args[0], ws_parent / args[1] + if not old.exists(): + self.state.add_log(f"workspaces rename: {old} not found", level="WARN") + return + if new.exists(): + self.state.add_log(f"workspaces rename: {new} already exists", level="WARN") + return + old.rename(new) + self.state.add_system_message(f"workspaces: renamed {args[0]} -> {args[1]}") + elif action == "switch": + self.state.add_system_message( + "workspaces switch: /quit this TUI, then cd into the target dir and run ./.clk/scripts/clk tui" + ) + elif action == "clean": + self.state.add_system_message( + "workspaces clean: run `./kickoff.sh --clean 7d` from the repo root — " + "it prompts before deleting." + ) + else: + self.state.add_log(f"workspaces: unknown action {action}", level="WARN") + + def _maybe_commit(self, agent: str, objective: str, validation: str, *files: str) -> None: + try: + if not is_repo(self.paths.root): + return + if not has_changes(self.paths.root): + return + if not add_all(self.paths.root): + return + ok = git_commit( + self.paths.root, + agent=agent, + objective=objective, + files_changed=list(files), + validation=validation, + next_step="continue conversation", + ) + if not ok: + return + # Push to GitHub if the user opted in (CLK_GITHUB_PUSH_ON_COMMIT=true) + # and there's actually a remote. Errors are non-fatal — the + # commit is local-only until the user can push themselves. + import os + push_on_commit = os.environ.get("CLK_GITHUB_PUSH_ON_COMMIT", "false").lower() == "true" + if push_on_commit and has_remote(self.paths.root): + self.state.add_log("pushing commit to origin…", level="SYSTEM") + if git_push(self.paths.root): + self.state.add_log("push succeeded.", level="SYSTEM") + else: + self.state.add_log( + "push failed — commit is still saved locally. /github to re-check the remote.", + level="WARN", + ) + # Refresh the title-bar ahead counter either way so the user + # can see at a glance how many unpushed commits they have. + try: + ahead = commits_ahead(self.paths.root) + with self.state.lock: + self.state.github_ahead = ahead + except Exception as _exc: + logger.debug("could not refresh unpushed-commit counter: %s", _exc) + except Exception as exc: + log_exception("tui.Worker._maybe_commit", exc) + diff --git a/clk_harness/tui/dashboard.py b/clk_harness/tui/dashboard.py new file mode 100644 index 0000000..46a136e --- /dev/null +++ b/clk_harness/tui/dashboard.py @@ -0,0 +1,640 @@ +"""Dashboard model: agent cards, log lines, and shared TUI state. + +``DashboardState`` is the thread-safe state shared between the UI +thread and the worker; ``DashboardObserver`` adapts AgentRunner +callbacks onto it. +""" + +from __future__ import annotations + +import json +import re +import threading +import time +from collections import deque +from dataclasses import dataclass, field +from datetime import datetime +from pathlib import Path +from typing import Any, Deque, Dict, List, Optional, TextIO, Tuple + +from ..config import ( + Paths, + load_agents_config, +) +from ..log import get_logger, log_exception +from ..orchestration import ( + AgentObserver, + is_baseline, + is_provider_failure, +) +from ..pricing import estimate_usd +from ..utils.text_extract import classify_error, extract_thought +from .theme import _format_tokens + +logger = get_logger(__name__) + + +# ``_extract_thought`` moved to clk_harness.utils.text_extract.extract_thought +# (re-exported below as the original private name so call sites are unchanged). +_extract_thought = extract_thought + + +class AgentStatus: + IDLE = "idle" + WORKING = "working" + RECOVERING = "recovering" # red: retry backoff in progress after a provider error + DONE = "done" + FAILED = "failed" + PROVIDER_ERROR = "provider" + + +@dataclass +class AgentCard: + name: str + status: str = AgentStatus.IDLE + current_task: str = "" + last_result: str = "" + last_error: str = "" + runs: int = 0 + last_started_mono: float = 0.0 + last_duration_s: float = 0.0 + # Telemetry surfaced by the rotating in-card panes. + prompt_preview: str = "" + response_preview: str = "" + files_written: List[str] = field(default_factory=list) + last_thought: str = "" + provider: str = "" + role: str = "" + is_baseline: bool = False + roster_status: str = "" # latest roster_changed status, e.g. "added" + # Token accounting (cumulative across all runs of this agent). + input_tokens: int = 0 + output_tokens: int = 0 + total_tokens: int = 0 + last_run_tokens: int = 0 # tokens in the most recent run only + last_usage_source: str = "" + # USD cost estimates — populated by DashboardState.end_agent via + # clk_harness.pricing. last_usd is per-run, total_usd is cumulative. + last_usd: float = 0.0 + total_usd: float = 0.0 + total_files: int = 0 # cumulative file write count + # Live subprocess telemetry (set by streaming providers via the + # ``progress`` observer hook). ``live_pid`` is non-zero while the + # underlying CLI subprocess is alive; ``live_last_line`` is the + # most recent stderr/stdout line so the user can see real activity + # in the card and the log pane rather than a stalled spinner. + live_pid: int = 0 + live_last_line: str = "" + live_stdout_chars: int = 0 + live_stderr_chars: int = 0 + live_last_update_mono: float = 0.0 + provider_issue: bool = False + provider_resolution: str = "" + live_cpu_pct: str = "" + live_rss_kb: str = "" + live_idle_s: float = 0.0 + live_elapsed_s: float = 0.0 + + + +@dataclass +class LogLine: + ts: str + level: str # INFO | WARN | ERROR | USER | SYSTEM + text: str + + +class DashboardState: + """Thread-safe state shared between the UI thread and the worker.""" + + def __init__(self, agent_names: List[str], *, paths: Optional[Paths] = None, + agents_cfg: Optional[Dict[str, Any]] = None) -> None: + self.lock = threading.Lock() + self.paths = paths + self.agents: Dict[str, AgentCard] = {} + for n in agent_names: + cfg = ((agents_cfg or {}).get("agents") or {}).get(n) or {} + self.agents[n] = AgentCard( + name=n, + role=cfg.get("role", ""), + provider=cfg.get("provider") or "", + is_baseline=is_baseline(n), + ) + self.log: Deque[LogLine] = deque(maxlen=400) + self.idea: str = "" + self.project_name: str = "" + self.provider: str = "" + self.phase: str = "idle" + self.busy: bool = False + self.input_buffer: str = "" + self.input_cursor: int = 0 + self.conversation: List[Tuple[str, str]] = [] + self.stop_requested: bool = False + self.iteration_count: int = 0 + # Project-wide token + file totals (sum across all agents). + self.total_input_tokens: int = 0 + self.total_output_tokens: int = 0 + self.total_tokens: int = 0 + self.total_files: int = 0 + # Largest single-run token total observed across any agent. + # The activity meter normalizes against this so a heavy agent + # (engineer with 5k token responses) shows a long bar while a + # light one (chief with 200 tokens) shows a short one. + self.peak_run_tokens: int = 0 + # Session log file (mirror of the in-pane status log so we + # have a persistent trace for later analysis). + self.session_log_fh: Optional[TextIO] = None + # Cost guardrails. ``total_usd`` is the rolling estimate based on + # tokens × provider pricing; ``cost_per_provider`` lets /status + # show a breakdown so the user can see which provider is eating + # the cap. Caps are read lazily from clk.config.json. + self.total_usd: float = 0.0 + self.cost_per_provider: Dict[str, float] = {} + # Most-recent classified error — drives the hint bar below the + # input. Cleared on the next successful run. + self.last_error_kind: str = "" + self.last_error_command: str = "" + # Set to True while /tutorial is running so other commands can + # display a "currently running tutorial" banner. + self.in_tutorial: bool = False + # Set true once /help has been opened in this session — used to + # suppress the "press F1 for help" repeat in the hint bar. + self.help_dismissed: bool = False + # Count of local commits ahead of origin (refreshed lazily by + # the worker after each successful agent commit). + self.github_ahead: int = 0 + + # ----- mutators (locked) -------------------------------------------- + + def add_log(self, text: str, level: str = "INFO") -> None: + # Multi-line writes (e.g. tracebacks routed through _StreamToLog + # if buffering races) become one entry per line so the log pane + # wraps each piece independently. Tabs and stray \r chars are + # also normalized so the word-wrapper sees clean spaces. + text = (text or "").replace("\r", "").replace("\t", " ") + if "\n" in text: + for piece in text.split("\n"): + if piece.strip(): + self.add_log(piece, level=level) + return + line = LogLine(ts=datetime.now().strftime("%H:%M:%S"), level=level, text=text) + with self.lock: + self.log.append(line) + fh = self.session_log_fh + if fh is not None: + try: + fh.write(f"{datetime.now().isoformat(timespec='seconds')} [{level}] {text}\n") + fh.flush() + except Exception: + # Never let a log write blow up the TUI. + pass + + def add_file_log(self, text: str, level: str = "INFO") -> None: + """Write directly to the session log file without adding to the TUI pane. + + Used for high-volume telemetry (tick, command) that is useful for + post-run analysis but would flood the visible status log. + """ + with self.lock: + fh = self.session_log_fh + if fh is not None: + try: + fh.write( + f"{datetime.now().isoformat(timespec='seconds')} [{level}] {text}\n" + ) + fh.flush() + except Exception: + pass + + def attach_session_log(self, path: Path) -> None: + """Open ``path`` in append mode and mirror every log line to it. + + Called once during TUI startup. The file persists across the + run so a `.clk/logs/session.log` accumulates the project's full + history of TUI events for later analysis (alongside + casting.log, agent_memory.jsonl, and the git log). + """ + try: + path.parent.mkdir(parents=True, exist_ok=True) + fh = path.open("a", encoding="utf-8") + with self.lock: + self.session_log_fh = fh + fh.write( + f"\n=== session start {datetime.now().isoformat(timespec='seconds')} ===\n" + ) + fh.flush() + except Exception as _exc: + logger.debug("session log unavailable: %s", _exc) + + def close_session_log(self) -> None: + with self.lock: + fh = self.session_log_fh + self.session_log_fh = None + if fh is not None: + try: + fh.write( + f"=== session end {datetime.now().isoformat(timespec='seconds')} ===\n" + ) + fh.close() + except Exception as _exc: + logger.debug("session log close failed: %s", _exc) + + def begin_agent(self, name: str, objective: str) -> None: + with self.lock: + card = self.agents.setdefault(name, AgentCard(name=name)) + card.status = AgentStatus.WORKING + card.current_task = objective + card.last_started_mono = time.monotonic() + # Reset transient telemetry from the previous run. + card.prompt_preview = "" + card.response_preview = "" + card.files_written = [] + card.last_thought = "" + card.live_pid = 0 + card.live_last_line = "" + card.live_stdout_chars = 0 + card.live_stderr_chars = 0 + card.live_last_update_mono = time.monotonic() + card.provider_issue = False + card.provider_resolution = "" + card.live_cpu_pct = "" + card.live_rss_kb = "" + card.live_idle_s = 0.0 + card.live_elapsed_s = 0.0 + # Take the first non-empty line of the objective so that multi-line + # objectives (e.g. recovery dispatches that start with a blank line + # after the header) don't produce a stray fragment in the log pane. + _obj_first = next( + (ln for ln in (objective or "").splitlines() if ln.strip()), + (objective or ""), + ) + self.add_log(f"{name} :: start :: {_obj_first[:80]}", level="INFO") + + def report_progress(self, name: str, kind: str, message: str) -> None: + """Capture streaming progress from a provider's subprocess. + + Updates the card's live_* fields and (selectively) emits log + entries so the user can see what the underlying CLI is doing + in real time. We log every stderr line (these are usually + status / auth / error messages from claude/codex/gemini), the + first 5 stdout lines (so very long responses don't flood the + pane), and the start / end / timeout events. + """ + message = (message or "").rstrip() + with self.lock: + card = self.agents.setdefault(name, AgentCard(name=name)) + now = time.monotonic() + card.live_last_update_mono = now + if kind == "start": + # parse "pid=NNNN cmd=..." into the live fields + pid = 0 + for tok in message.split(): + if tok.startswith("pid="): + try: + pid = int(tok.split("=", 1)[1]) + except Exception: + pid = 0 + break + card.live_pid = pid + card.live_last_line = "starting..." + elif kind in ("stdout_line", "stderr_line"): + card.live_last_line = message[:200] + if kind == "stdout_line": + card.live_stdout_chars += len(message) + 1 + else: + card.live_stderr_chars += len(message) + 1 + elif kind == "tick": + # Strip pid= token before storing — live_pid tracks it separately + # and we don't want it duplicated in the card's status line. + stripped = re.sub(r"\bpid=\S+\s*", "", message).strip() + card.live_last_line = stripped[:200] + for tok in message.split(): + if tok.startswith("cpu="): + card.live_cpu_pct = tok.split("=", 1)[1] + elif tok.startswith("rss_kb="): + card.live_rss_kb = tok.split("=", 1)[1] + elif tok.startswith("idle_s="): + try: + card.live_idle_s = float(tok.split("=", 1)[1]) + except Exception as _exc: + logger.debug("ignoring malformed idle_s token %r: %s", tok, _exc) + elif tok.startswith("elapsed_s="): + try: + card.live_elapsed_s = float(tok.split("=", 1)[1]) + except Exception as _exc: + logger.debug("ignoring malformed elapsed_s token %r: %s", tok, _exc) + elif kind in ("command", "retry", "killed"): + card.live_last_line = message[:200] + elif kind in ("end", "timeout"): + card.live_pid = 0 + card.live_last_line = f"{kind}: {message}"[:200] + # Route events: high-volume telemetry goes to the session file only; + # actionable events go to both the TUI pane and the file. + if kind == "start": + # A new subprocess started — if this agent was in RECOVERING + # (backoff after a provider error), it is now retrying; show yellow. + with self.lock: + card2 = self.agents.get(name) + if card2 and card2.status == AgentStatus.RECOVERING: + card2.status = AgentStatus.WORKING + card2.provider_issue = False + self.add_log(f"{name} :: subprocess {message}", level="SYSTEM") + elif kind == "command": + # Full command metadata is verbose; useful for forensics, not for + # the live status pane. Write to session log file only. + try: + meta = json.loads(message) + args = meta.get("args") or [] + arg_note = "no args" if not args else f"args={args}" + line = ( + f"{name} :: command :: {meta.get('cmd')} " + f"(argv_count={meta.get('argv_count')}; {arg_note}; " + f"stdin={meta.get('stdin')} {meta.get('stdin_chars')} chars; " + f"cwd={meta.get('cwd')})" + ) + except Exception: + line = f"{name} :: command :: {message[:240]}" + self.add_file_log(line, level="SYSTEM") + elif kind == "tick": + # Per-process telemetry ticks are high-frequency; file log only. + self.add_file_log(f"{name} :: telemetry :: {message[:240]}", level="INFO") + elif kind == "retry": + # Provider error during a run — enter red RECOVERING state. + with self.lock: + card2 = self.agents.get(name) + if card2: + card2.status = AgentStatus.RECOVERING + card2.provider_issue = True + self.add_log(f"{name} :: retry :: {message[:240]}", level="WARN") + elif kind == "killed": + self.add_log(f"{name} :: killed :: {message[:240]}", level="WARN") + elif kind.startswith("http_"): + self.add_log(f"{name} :: {kind} :: {message[:240]}", level="SYSTEM") + elif kind == "stderr_line" and message: + self.add_log(f"{name} stderr: {message[:200]}", level="INFO") + elif kind == "stdout_line": + with self.lock: + cnt = self.agents[name].live_stdout_chars + if cnt < 1024: + self.add_log(f"{name} stdout: {message[:200]}", level="INFO") + elif kind == "timeout": + self.add_log(f"{name} :: TIMEOUT :: {message}", level="ERROR") + elif kind == "end": + self.add_log(f"{name} :: subprocess {message}", level="SYSTEM") + + def set_agent_prompt(self, name: str, prompt: str) -> None: + # Keep a short head-of-prompt; the full prompt is on disk under + # .clk/runs/. We only need a glanceable preview here. + snippet = (prompt or "").strip() + if not snippet: + return + # Strip the boilerplate header lines so the preview shows the + # role-specific objective, not the same operating constraints + # for every card. + candidates = [ln for ln in snippet.splitlines() if ln.strip()] + head = " ".join(candidates[:6]) + with self.lock: + card = self.agents.setdefault(name, AgentCard(name=name)) + card.prompt_preview = head[:240] + + def end_agent( + self, + name: str, + ok: bool, + preview: str = "", + error: str = "", + files_written: Optional[List[str]] = None, + usage: Optional[Dict[str, Any]] = None, + ) -> None: + files_written = list(files_written or []) + usage = dict(usage or {}) + provider_issue = (not ok) and is_provider_failure(error) + with self.lock: + card = self.agents.setdefault(name, AgentCard(name=name)) + card.status = AgentStatus.DONE if ok else ( + AgentStatus.PROVIDER_ERROR if provider_issue else AgentStatus.FAILED + ) + card.current_task = "" + card.last_result = (preview or "").strip().replace("\n", " ")[:240] + card.last_error = error[:240] + card.response_preview = (preview or "").strip()[:400] + card.files_written = files_written + card.last_thought = _extract_thought(preview) + card.runs += 1 + card.total_files += len(files_written) + if card.last_started_mono: + card.last_duration_s = time.monotonic() - card.last_started_mono + in_tok = int(usage.get("input_tokens") or 0) + out_tok = int(usage.get("output_tokens") or 0) + tot_tok = int(usage.get("total_tokens") or (in_tok + out_tok)) + card.input_tokens += in_tok + card.output_tokens += out_tok + card.total_tokens += tot_tok + card.last_run_tokens = tot_tok + card.last_usage_source = str(usage.get("source") or card.last_usage_source) + card.provider_issue = provider_issue + if provider_issue: + card.provider_resolution = self._provider_resolution_message(error) + kind, _, cmd = classify_error(error) + self.last_error_kind = kind + self.last_error_command = cmd + elif ok: + # Clear the hint after a successful run so the user + # doesn't keep seeing a stale "install pi" suggestion. + self.last_error_kind = "" + self.last_error_command = "" + self.total_input_tokens += in_tok + self.total_output_tokens += out_tok + # Cost accumulation. We look up pricing per-provider so a + # mixed-provider session (chief on claude, engineer on + # ollama) gets accurate per-provider totals. + try: + prov_name = card.provider or self.provider or "" + prov_overrides = {} + if self.paths is not None: + try: + from ..config import load_providers_config as _lpc + prov_cfg = (_lpc(self.paths).get("providers") or {}).get(prov_name) or {} + prov_overrides = { + "pricing": prov_cfg.get("pricing"), + "pricing_by_model": prov_cfg.get("pricing_by_model"), + } + except Exception: + prov_overrides = {} + model = (usage.get("model") or "") or "" + run_usd = estimate_usd(prov_name, model, in_tok, out_tok, prov_overrides) + card.last_usd = run_usd + card.total_usd = (getattr(card, "total_usd", 0.0) or 0.0) + run_usd + self.total_usd += run_usd + self.cost_per_provider[prov_name] = self.cost_per_provider.get(prov_name, 0.0) + run_usd + except Exception as exc: + log_exception("tui.end_agent.cost", exc) + self.total_tokens += tot_tok + self.total_files += len(files_written) + if tot_tok > self.peak_run_tokens: + self.peak_run_tokens = tot_tok + self.add_log( + f"{name} :: {'ok' if ok else 'fail'} :: " + f"tok={_format_tokens(int(usage.get('total_tokens') or 0))} " + f"files={len(files_written)} :: " + f"{(preview or '').strip().splitlines()[0][:60] if preview else ''}", + level="INFO" if ok else "WARN", + ) + if provider_issue: + self.add_log( + f"{name} :: provider issue :: {error[:200]}", + level="ERROR", + ) + self.add_log( + f"{name} :: resolution :: {self._provider_resolution_message(error)}", + level="WARN", + ) + # File-action log lines: one INFO entry per file so the user + # sees creation activity as it happens. + for fpath in files_written: + self.add_log(f"{name} :: wrote {fpath}", level="SYSTEM") + + def _provider_resolution_message(self, error: str) -> str: + msg = (error or "").lower() + if "rate limit" in msg or "quota" in msg: + return ( + "provider rate/quota failure; backing off by aborting this cycle, " + "then retry after quota/reset or switch provider" + ) + if "timeout" in msg or "no output" in msg or "operation was aborted" in msg: + return ( + "provider call stalled/aborted; stalled PID is killed, configured retries are reissued " + "with backoff, then the cycle stops if retries fail" + ) + if "api key" in msg or "authentication" in msg or "unauthorized" in msg or "forbidden" in msg: + return "provider auth/config failure; fix credentials or switch provider before retrying" + if "no endpoints available" in msg or "guardrail restrictions" in msg or "data policy" in msg: + return ( + "provider endpoint/policy routing issue; configured retries are reissued with backoff " + "because this can be transient, then switch provider or adjust provider privacy settings " + "if retries fail" + ) + if "cli not found" in msg or "not found" in msg: + return "provider executable/config missing; install/configure provider or switch provider" + return "provider failure; workflow recovery is aborted until the provider is fixed or changed" + + def upsert_agent(self, name: str, *, role: str = "", baseline: bool = False, status: str = "added") -> None: + with self.lock: + card = self.agents.setdefault(name, AgentCard(name=name)) + if role: + card.role = role + card.is_baseline = baseline + card.roster_status = status + + def drop_agent(self, name: str) -> None: + with self.lock: + self.agents.pop(name, None) + + def set_phase(self, phase: str, busy: Optional[bool] = None) -> None: + with self.lock: + self.phase = phase + if busy is not None: + self.busy = busy + + def set_idea(self, idea: str) -> None: + with self.lock: + self.idea = idea[:500] + + def add_user_message(self, text: str) -> None: + with self.lock: + self.conversation.append(("user", text)) + self.add_log(text, level="USER") + + def add_system_message(self, text: str) -> None: + with self.lock: + self.conversation.append(("system", text)) + self.add_log(text, level="SYSTEM") + + def request_stop(self) -> None: + with self.lock: + self.stop_requested = True + self.add_log("stop requested by user", level="WARN") + + def clear_stop(self) -> None: + with self.lock: + self.stop_requested = False + + def is_stop_requested(self) -> bool: + with self.lock: + return self.stop_requested + + def snapshot(self) -> Dict[str, Any]: + with self.lock: + return { + "agents": {k: vars(v).copy() for k, v in self.agents.items()}, + "log": list(self.log), + "idea": self.idea, + "project_name": self.project_name, + "provider": self.provider, + "phase": self.phase, + "busy": self.busy, + "input_buffer": self.input_buffer, + "input_cursor": self.input_cursor, + "conversation": list(self.conversation), + } + + +# --------------------------------------------------------------------------- +# Observer that updates the dashboard +# --------------------------------------------------------------------------- + + +class DashboardObserver(AgentObserver): + def __init__(self, state: "DashboardState") -> None: + self.state = state + + def begin(self, agent: str, objective: str) -> None: + self.state.begin_agent(agent, objective) + + def prompt_sent(self, agent: str, prompt: str) -> None: + self.state.set_agent_prompt(agent, prompt) + + def end(self, agent: str, run) -> None: # type: ignore[override] + ok = bool(run.response.ok) + preview = run.response.text or "" + err = run.response.error or "" + self.state.end_agent( + agent, + ok=ok, + preview=preview, + error=err, + files_written=list(run.files_written or []), + usage=dict(run.response.usage or {}), + ) + + def progress(self, agent: str, kind: str, message: str) -> None: + self.state.report_progress(agent, kind, message) + + def log(self, line: str) -> None: + self.state.add_log(line) + + def roster_changed(self, name: str, status: str) -> None: + # workflow_written is a workflow event, not a roster change — log it + # separately and don't create/modify an agent card for it. + if status == "workflow_written": + self.state.add_log(f"workflow :: {name} :: written", level="INFO") + return + # Refresh the card from the (just-mutated) agents config so the + # role / baseline / provider fields stay accurate. + try: + _paths = getattr(self.state, "paths", None) + agents = (load_agents_config(_paths).get("agents") or {}) if _paths is not None else {} + cfg = agents.get(name) or {} + except Exception: + cfg = {} + if status == "removed": + self.state.drop_agent(name) + else: + self.state.upsert_agent( + name, + role=cfg.get("role", ""), + baseline=is_baseline(name), + status=status, + ) + self.state.add_log(f"roster :: {name} :: {status}", level="SYSTEM") + diff --git a/clk_harness/tui/stream.py b/clk_harness/tui/stream.py new file mode 100644 index 0000000..dc832fd --- /dev/null +++ b/clk_harness/tui/stream.py @@ -0,0 +1,85 @@ +"""stderr/stdout capture for the TUI. + +Routes every write into the dashboard log pane so subprocess and +traceback output cannot reach the real terminal and corrupt the +curses display. +""" + +from __future__ import annotations + +import re + +from ..log import get_logger +from .dashboard import DashboardState # noqa: F401 (annotation use) + +logger = get_logger(__name__) + + +class _StreamToLog: + """File-like object that routes writes into the dashboard log pane. + + Replaces ``sys.stderr`` (and optionally ``sys.stdout``) while the TUI + is active so that ``log()`` calls, ``print()`` statements, and + ``traceback.print_exc()`` output never reach the terminal directly + and therefore never corrupt the curses screen. + + Lines are buffered until a ``\\n`` is seen so partial writes from + ``print(..., end="")`` don't produce noisy fragments. Severity is + inferred from the ``[LEVEL]`` tag the harness's own logger emits; + everything else falls back to the default level. + """ + + LEVEL_TAGS = ("[ERROR]", "[WARN]", "[INFO]") + LOG_PREFIX_RE = re.compile( + r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\s+\[(ERROR|WARN|INFO)\]\s*(.*)$" + ) + + def __init__(self, state: "DashboardState", default_level: str = "INFO") -> None: + self.state = state + self.default_level = default_level + self._buf = "" + + def write(self, s: str) -> int: + if not s: + return 0 + self._buf += s + out_len = len(s) + while "\n" in self._buf: + line, _, self._buf = self._buf.partition("\n") + line = line.rstrip("\r") + if not line.strip(): + continue + level = self.default_level + m = self.LOG_PREFIX_RE.match(line) + if m: + level = m.group(1) + line = m.group(2) + else: + for tag in self.LEVEL_TAGS: + if tag in line: + level = tag.strip("[]") + break + try: + self.state.add_log(line[:300], level=level) + except Exception: + # Last resort: never raise from a stream.write. + pass + return out_len + + def flush(self) -> None: + if self._buf.strip(): + try: + self.state.add_log(self._buf[:300], level=self.default_level) + except Exception: + pass + self._buf = "" + + def isatty(self) -> bool: + return False + + def writable(self) -> bool: + return True + + def fileno(self): # raise so subprocess.* doesn't grab us + raise OSError("StreamToLog has no fileno") + diff --git a/clk_harness/tui/theme.py b/clk_harness/tui/theme.py new file mode 100644 index 0000000..bfaa9ca --- /dev/null +++ b/clk_harness/tui/theme.py @@ -0,0 +1,63 @@ +"""Presentation helpers shared by the TUI widgets. + +Token-count formatting and word wrapping used by the dashboard +cards, the worker's status output, and the curses renderer. +""" + +from __future__ import annotations + +from typing import List + +from ..log import get_logger + +logger = get_logger(__name__) + + +def _format_tokens(n: int) -> str: + if n >= 1_000_000: + return f"{n/1_000_000:.2f}M" + if n >= 1_000: + return f"{n/1_000:.1f}k" + return str(n) + + +def _word_wrap(text: str, width: int) -> List[str]: + """Wrap ``text`` to ``width`` columns, breaking on word boundaries. + + Falls back to mid-word splits when a single token exceeds width + (e.g. a long URL). Empty input returns ``[""]`` so callers can rely + on at least one row. + """ + if width <= 1: + return [text] + if not text: + return [""] + out: List[str] = [] + for paragraph in text.splitlines() or [text]: + if not paragraph: + out.append("") + continue + words = paragraph.split(" ") + current = "" + for word in words: + if len(word) > width: + # Hard-break the giant token. Flush whatever we have first. + if current: + out.append(current) + current = "" + while len(word) > width: + out.append(word[:width]) + word = word[width:] + current = word + continue + if not current: + current = word + elif len(current) + 1 + len(word) <= width: + current = f"{current} {word}" + else: + out.append(current) + current = word + if current: + out.append(current) + return out or [""] + diff --git a/clk_harness/utils/activity_log.py b/clk_harness/utils/activity_log.py index ddb4a32..e05821b 100644 --- a/clk_harness/utils/activity_log.py +++ b/clk_harness/utils/activity_log.py @@ -32,11 +32,12 @@ import sys import threading from datetime import datetime -from pathlib import Path -from typing import Any, Dict, Optional +from typing import Any, Dict from ..config import Paths +from ..log import get_logger +logger = get_logger(__name__) _LOCK = threading.Lock() _HANDLES: Dict[str, Any] = {} @@ -103,6 +104,6 @@ def close_all() -> None: for fh in list(_HANDLES.values()): try: fh.close() - except Exception: - pass + except Exception as _exc: + logger.debug("activity log close failed: %s", _exc) _HANDLES.clear() diff --git a/clk_harness/utils/logging_utils.py b/clk_harness/utils/logging_utils.py index 19e22b4..965708d 100644 --- a/clk_harness/utils/logging_utils.py +++ b/clk_harness/utils/logging_utils.py @@ -1,88 +1,31 @@ -"""Logging helpers for CLK. +"""Logging helpers for CLK (compatibility shim). -Writes both to stderr and to per-run log files inside ``.clk/logs/``. -Every caught exception in callers should pass through :func:`log_exception`, -which prefixes the location and prints a traceback. +The implementation now lives in :mod:`clk_harness.log`, which builds the +same `` [LEVEL] message`` output on stdlib :mod:`logging` and adds +per-module loggers via :func:`clk_harness.log.get_logger`. This module +re-exports the legacy helpers so existing imports keep working: + +- :func:`log` / :func:`log_exception` — one-shot diagnostics +- :func:`init_log_file` / :func:`close_log` / :func:`current_log_path` — + per-run log files inside ``.clk/logs/`` """ from __future__ import annotations -import datetime as _dt -import sys -import traceback -from pathlib import Path -from typing import Optional, TextIO - - -_LOG_FH: Optional[TextIO] = None -_LOG_PATH: Optional[Path] = None - - -def _ts() -> str: - return _dt.datetime.now().isoformat(timespec="seconds") - - -def init_log_file(log_dir: Path, name: str = "clk") -> Path: - """Open a log file in ``log_dir`` and return its path. - - Subsequent calls reopen a new file. The previous handle is closed. - """ - global _LOG_FH, _LOG_PATH - try: - log_dir.mkdir(parents=True, exist_ok=True) - path = log_dir / f"{name}-{_dt.datetime.now().strftime('%Y%m%d-%H%M%S')}.log" - if _LOG_FH is not None: - try: - _LOG_FH.close() - except Exception as exc: - print(f"[logging_utils.init_log_file] failed to close previous log: {exc}", file=sys.stderr) - traceback.print_exc() - _LOG_FH = path.open("a", encoding="utf-8") - _LOG_PATH = path - return path - except Exception as exc: - print(f"[logging_utils.init_log_file] failed: {exc}", file=sys.stderr) - traceback.print_exc() - return log_dir / "clk-fallback.log" - - -def current_log_path() -> Optional[Path]: - return _LOG_PATH - - -def log(msg: str, level: str = "INFO") -> None: - line = f"{_ts()} [{level}] {msg}" - print(line, file=sys.stderr) - if _LOG_FH is not None: - try: - _LOG_FH.write(line + "\n") - _LOG_FH.flush() - except Exception as exc: - print(f"[logging_utils.log] failed to write: {exc}", file=sys.stderr) - traceback.print_exc() - - -def log_exception(where: str, exc: BaseException) -> None: - """Standard exception logger. Call from every ``except`` block.""" - print(f"{_ts()} [ERROR] [{where}] {exc.__class__.__name__}: {exc}", file=sys.stderr) - traceback.print_exc() - if _LOG_FH is not None: - try: - _LOG_FH.write(f"{_ts()} [ERROR] [{where}] {exc.__class__.__name__}: {exc}\n") - traceback.print_exc(file=_LOG_FH) - _LOG_FH.flush() - except Exception as inner: - print(f"[logging_utils.log_exception] failed to write: {inner}", file=sys.stderr) - traceback.print_exc() - - -def close_log() -> None: - global _LOG_FH - if _LOG_FH is not None: - try: - _LOG_FH.close() - except Exception as exc: - print(f"[logging_utils.close_log] failed: {exc}", file=sys.stderr) - traceback.print_exc() - finally: - _LOG_FH = None +from ..log import ( # noqa: F401 (re-exported legacy API) + close_log, + current_log_path, + get_logger, + init_log_file, + log, + log_exception, +) + +__all__ = [ + "close_log", + "current_log_path", + "get_logger", + "init_log_file", + "log", + "log_exception", +] diff --git a/clk_harness/web_snapshot.py b/clk_harness/web_snapshot.py index fb98cda..72e26e1 100644 --- a/clk_harness/web_snapshot.py +++ b/clk_harness/web_snapshot.py @@ -20,9 +20,11 @@ from pathlib import Path from typing import Any, Dict, List, Optional, Tuple +from .log import get_logger from .pricing import estimate_usd from .utils.text_extract import classify_error, extract_thought +logger = get_logger(__name__) # --------------------------------------------------------------------------- # Low-level event iteration (with byte-offset seek for streaming) @@ -64,7 +66,8 @@ def iter_events(path: Path, start_offset: int = 0) -> Tuple[List[dict], int]: obj = json.loads(raw_line.decode("utf-8", errors="replace")) if isinstance(obj, dict): events.append(obj) - except Exception: + except Exception as _exc: + logger.debug("skipping unparseable activity line: %s", _exc) continue return events, new_offset @@ -284,9 +287,10 @@ def card(name: str) -> Dict[str, Any]: elif kind == "action_applied": path = raw.get("path") if path: - c = card(agent) if agent else None - if c is not None and path not in c["files"]: - c["files"].append(path) + if agent: + c = card(agent) + if path not in c["files"]: + c["files"].append(path) if path not in files_changed: files_changed.append(path) diff --git a/clk_harness/webui_api/__init__.py b/clk_harness/webui_api/__init__.py new file mode 100644 index 0000000..ad6765a --- /dev/null +++ b/clk_harness/webui_api/__init__.py @@ -0,0 +1,21 @@ +"""Web-UI REST surface, decomposed from ``clk_harness/webui_router.py``. + +Importing this package registers every endpoint module on the shared +:data:`router`: + +* :mod:`.router` — the ``APIRouter`` + shared guards/helpers, workspace + config, global ``.env``, doctor, idea, and provider listing. +* :mod:`.events` — activity history, harness log tail, snapshot, and + the SSE activity stream. +* :mod:`.files` — workspace file listing/read/write/download and the + git-history endpoints. + +The provider probe/discovery endpoints stay in the legacy +``clk_harness.webui_router`` shim (see the note there). +""" + +from . import events as events +from . import files as files +from .router import router as router + +__all__ = ["router", "events", "files"] diff --git a/clk_harness/webui_api/events.py b/clk_harness/webui_api/events.py new file mode 100644 index 0000000..90d7ddc --- /dev/null +++ b/clk_harness/webui_api/events.py @@ -0,0 +1,144 @@ +"""Web-UI activity endpoints: history, harness logs, snapshot, SSE stream. +""" + +from __future__ import annotations + +import asyncio +import json +from typing import Any, Dict, List, Optional + +from fastapi import Query, Request +from fastapi.responses import StreamingResponse + +from .. import web_snapshot +from ..config import ( + load_clk_config, + load_providers_config, +) +from ..log import get_logger +from .router import _activity_path, _read_idea, _require_workspace, router + +logger = get_logger(__name__) + + +# --------------------------------------------------------------------------- +# Activity: history + snapshot + SSE stream +# --------------------------------------------------------------------------- + +@router.get("/api/workspaces/{workspace_id}/activity") +async def get_activity( + workspace_id: str, + offset: int = Query(0, ge=0), + limit: int = Query(500, ge=1, le=5000), + kinds: Optional[str] = None, +) -> Dict[str, Any]: + paths = _require_workspace(workspace_id) + log_path = _activity_path(paths) + raw_events, new_offset = web_snapshot.iter_events(log_path, offset) + wanted = {k.strip() for k in kinds.split(",")} if kinds else None + out: List[dict] = [] + for i, raw in enumerate(raw_events): + if wanted and raw.get("event") not in wanted: + continue + out.append(web_snapshot.normalize_event(raw, offset + i)) + out = out[:limit] + return {"ok": True, "events": out, "next_offset": new_offset, "count": len(out)} + + +@router.get("/api/workspaces/{workspace_id}/logs") +async def get_harness_logs( + workspace_id: str, + tail: int = Query(400, ge=1, le=5000), +) -> Dict[str, Any]: + """Tail the harness session logs (init/idea/run/...). + + These are the human-readable ``.clk/logs/*.log`` files the CLI writes — + distinct from activity.jsonl. The web Log tab shows them so users can see + initialization progress and orchestration decisions without a terminal. + """ + paths = _require_workspace(workspace_id) + logs_dir = paths.logs + entries: List[Dict[str, Any]] = [] + # The UI polls this every few seconds, so reads must stay bounded as + # logs grow: read at most ~120 bytes/line of tail from the end of each + # file instead of the whole file. + max_bytes = tail * 120 + if logs_dir.is_dir(): + files = sorted( + (p for p in logs_dir.glob("*.log") if p.is_file()), + key=lambda p: p.stat().st_mtime, + ) + # Read newest files last so the tail keeps the most recent lines. + for p in files: + try: + size = p.stat().st_size + with p.open("rb") as fh: + if size > max_bytes: + fh.seek(size - max_bytes) + fh.readline() # drop the partial first line + text = fh.read().decode("utf-8", errors="replace") + except OSError: + continue + for line in text.splitlines(): + if line.strip(): + entries.append({"file": p.name, "line": line}) + if len(entries) > tail: + entries = entries[-tail:] + return {"ok": True, "lines": entries, "count": len(entries)} + + +@router.get("/api/workspaces/{workspace_id}/snapshot") +async def get_snapshot(workspace_id: str) -> Dict[str, Any]: + paths = _require_workspace(workspace_id) + log_path = _activity_path(paths) + raw_events, _ = web_snapshot.iter_events(log_path, 0) + prov_cfg = load_providers_config(paths) + snap = web_snapshot.build_snapshot( + raw_events, + provider_overrides=(prov_cfg.get("providers") or {}), + idea=_read_idea(paths), + active_provider=prov_cfg.get("active") or load_clk_config(paths).get("default_provider") or "", + ) + return {"ok": True, "snapshot": snap} + + +@router.get("/api/workspaces/{workspace_id}/activity/stream") +async def stream_activity( + workspace_id: str, + request: Request, + from_: str = Query("end", alias="from"), +) -> StreamingResponse: + paths = _require_workspace(workspace_id) + log_path = _activity_path(paths) + + async def _generate(): + # from=start replays the whole log then follows; from=end follows + # only new events (default). + if from_ == "start": + offset = 0 + else: + _, offset = web_snapshot.iter_events(log_path, 0) + seq = 0 + idle_ticks = 0 + while True: + if await request.is_disconnected(): + break + events, new_offset = web_snapshot.iter_events(log_path, offset) + offset = new_offset + if events: + idle_ticks = 0 + for raw in events: + payload = json.dumps(web_snapshot.normalize_event(raw, seq)) + seq += 1 + yield f"data: {payload}\n\n" + else: + idle_ticks += 1 + if idle_ticks % 30 == 0: # ~every 9s, keep proxies alive + yield ": keepalive\n\n" + await asyncio.sleep(0.3) + + return StreamingResponse( + _generate(), + media_type="text/event-stream", + headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no", "Connection": "keep-alive"}, + ) diff --git a/clk_harness/webui_api/files.py b/clk_harness/webui_api/files.py new file mode 100644 index 0000000..fd105c3 --- /dev/null +++ b/clk_harness/webui_api/files.py @@ -0,0 +1,276 @@ +"""Web-UI workspace file + git-history endpoints. +""" + +from __future__ import annotations + +import asyncio +import os +import re +import zipfile +from pathlib import Path +from typing import Any, Dict, List, Optional + +from fastapi import Query +from fastapi.responses import FileResponse + +from ..log import get_logger +from .router import ( + _HIDDEN_DIRS, + _MAX_FILE_BYTES, + _MAX_FILES, + FileWrite, + _api, + _is_probably_binary, + _require_workspace, + _safe_unlink, + _safe_ws_file, + router, +) + +logger = get_logger(__name__) + + +# --------------------------------------------------------------------------- +# Workspace files: list / read / write + the "follow up with the agents" idea +# --------------------------------------------------------------------------- + +@router.get("/api/workspaces/{workspace_id}/files") +async def list_files(workspace_id: str) -> Dict[str, Any]: + """List the files the agents have produced in the workspace. + + Harness-internal directories (``.clk``, ``.git``, ``node_modules`` …) are + skipped so this reflects the user-facing deliverables, not bookkeeping. + """ + from datetime import datetime, timezone + + paths = _require_workspace(workspace_id) + root = paths.root.resolve() + files: List[Dict[str, Any]] = [] + truncated = False + if root.exists(): + for dirpath, dirnames, filenames in os.walk(root): + # Prune hidden/internal dirs in place so os.walk doesn't descend. + dirnames[:] = sorted(d for d in dirnames if d not in _HIDDEN_DIRS) + for name in sorted(filenames): + if len(files) >= _MAX_FILES: + truncated = True + break + fp = Path(dirpath) / name + # Skip symlinks: following them with stat() could leak + # size/mtime for targets outside the workspace. + if fp.is_symlink(): + continue + try: + stat = fp.stat() + except OSError: + continue + files.append({ + "path": str(fp.relative_to(root)), + "size": stat.st_size, + "modified": datetime.fromtimestamp(stat.st_mtime, timezone.utc) + .isoformat().replace("+00:00", "Z"), + }) + if truncated: + break + files.sort(key=lambda f: f["path"]) + return {"ok": True, "files": files, "count": len(files), "truncated": truncated} + + +@router.get("/api/workspaces/{workspace_id}/download") +async def download_workspace(workspace_id: str) -> FileResponse: + """Download the workspace's deliverables as a zip. + + Mirrors the file listing: harness-internal directories (``.clk``, + ``.git``, ``node_modules`` …) and symlinks are excluded so the archive + is just the user-facing files the agents produced. + """ + paths = _require_workspace(workspace_id) + root = paths.root.resolve() + + def _build_zip_to_tempfile() -> str: + # Build to a temp file on disk so we stream it back in chunks rather + # than holding the whole archive in memory. + import tempfile + fd, tmp_path = tempfile.mkstemp(suffix=".zip", prefix="clk-ws-") + os.close(fd) + # If zipping fails (e.g. a permission error while walking/writing) the + # FileResponse -- and its cleanup BackgroundTask -- is never created, so + # remove the temp file here before re-raising to avoid leaking it. + try: + with zipfile.ZipFile(tmp_path, "w", zipfile.ZIP_DEFLATED) as zf: + if root.exists(): + for dirpath, dirnames, filenames in os.walk(root): + dirnames[:] = [d for d in dirnames if d not in _HIDDEN_DIRS] + for name in filenames: + fp = Path(dirpath) / name + if fp.is_symlink(): + continue + try: + zf.write(fp, fp.relative_to(root).as_posix()) + except OSError: + continue + except BaseException: + _safe_unlink(tmp_path) + raise + return tmp_path + + tmp_path = await asyncio.to_thread(_build_zip_to_tempfile) + entry = _api().WORKSPACES.get(workspace_id) or {} + raw_name = str(entry.get("name") or workspace_id[:8]) + safe = re.sub(r"[^A-Za-z0-9._-]+", "-", raw_name).strip("-") or "workspace" + from starlette.background import BackgroundTask + return FileResponse( + tmp_path, + media_type="application/zip", + filename=f"{safe}.zip", + background=BackgroundTask(lambda: _safe_unlink(tmp_path)), + ) + + +@router.get("/api/workspaces/{workspace_id}/file") +async def read_file(workspace_id: str, path: str = Query(...)) -> Dict[str, Any]: + paths = _require_workspace(workspace_id) + target = _safe_ws_file(paths, path) + if not target.exists() or not target.is_file(): + raise _api()._err("file_not_found", f"File {path!r} not found.", 404) + raw = target.read_bytes() + too_big = len(raw) > _MAX_FILE_BYTES + chunk = raw[:_MAX_FILE_BYTES] + if _is_probably_binary(chunk): + return {"ok": True, "path": path, "binary": True, "size": len(raw)} + return { + "ok": True, + "path": path, + "binary": False, + "size": len(raw), + "truncated": too_big, + "content": chunk.decode("utf-8", errors="replace"), + } + + +@router.put("/api/workspaces/{workspace_id}/file") +async def write_file(workspace_id: str, body: FileWrite) -> Dict[str, Any]: + paths = _require_workspace(workspace_id) + paths.root.mkdir(parents=True, exist_ok=True) + target = _safe_ws_file(paths, body.path, for_write=True) + if body.path.endswith(("/", "\\")) or (target.exists() and target.is_dir()): + raise _api()._err("invalid_path", f"{body.path!r} is a directory, not a file.", 400) + data = body.content.encode("utf-8") + if len(data) > _MAX_FILE_BYTES: + raise _api()._err("too_large", "File exceeds the 1 MB editor limit.", 413) + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(body.content, encoding="utf-8") + return {"ok": True, "path": body.path, "size": len(data)} + + +# --------------------------------------------------------------------------- +# Git history: the Files tab's "how did these files evolve" view +# --------------------------------------------------------------------------- + +_SHA_RE = re.compile(r"^[0-9a-fA-F]{4,40}$") + + +def _require_sha(sha: str) -> str: + if not _SHA_RE.match(sha or ""): + raise _api()._err("invalid_sha", "A hex commit sha is required.", 400) + return sha + + +@router.get("/api/workspaces/{workspace_id}/git/log") +async def git_log( + workspace_id: str, + path: Optional[str] = Query(None), + limit: int = Query(100, ge=1, le=500), +) -> Dict[str, Any]: + """Commit history (newest first) for the workspace, or for one file. + + Harness-internal paths (.clk, .git, …) are filtered out so the + history mirrors what the Files tab lists. + """ + from .. import git_ops + + paths = _require_workspace(workspace_id) + if path: + _safe_ws_file(paths, path) # traversal + hidden-dir guard + commits = await asyncio.to_thread( + git_ops.log_entries, paths.root, path=path, limit=limit + ) + return {"ok": True, "commits": commits, "count": len(commits)} + + +@router.get("/api/workspaces/{workspace_id}/git/commit/{sha}") +async def git_commit_detail(workspace_id: str, sha: str) -> Dict[str, Any]: + """One commit's metadata + unified diff (internal paths excluded).""" + from .. import git_ops + + paths = _require_workspace(workspace_id) + _require_sha(sha) + commits = await asyncio.to_thread( + git_ops.log_entries, paths.root, limit=1, rev=sha + ) + meta = commits[0] if commits else None + patch = await asyncio.to_thread(git_ops.commit_patch, paths.root, sha) + if meta is None and patch is None: + raise _api()._err("commit_not_found", f"Commit {sha!r} not found.", 404) + return { + "ok": True, + "commit": meta, + "patch": (patch or {}).get("patch", ""), + "patch_truncated": bool((patch or {}).get("truncated")), + } + + +@router.get("/api/workspaces/{workspace_id}/git/status") +async def git_status(workspace_id: str) -> Dict[str, Any]: + """Uncommitted working-tree changes vs HEAD (the Files tab's + "not yet committed" view).""" + from .. import git_ops + + paths = _require_workspace(workspace_id) + files = await asyncio.to_thread(git_ops.status_entries, paths.root) + return {"ok": True, "dirty": bool(files), "files": files, "count": len(files)} + + +@router.get("/api/workspaces/{workspace_id}/git/diff") +async def git_working_diff(workspace_id: str) -> Dict[str, Any]: + """Unified diff of uncommitted changes vs HEAD. Untracked files don't + appear in the patch — pair with /git/status to list them.""" + from .. import git_ops + + paths = _require_workspace(workspace_id) + patch = await asyncio.to_thread(git_ops.working_tree_patch, paths.root) + return { + "ok": True, + "patch": (patch or {}).get("patch", ""), + "truncated": bool((patch or {}).get("truncated")), + } + + +@router.get("/api/workspaces/{workspace_id}/git/file") +async def git_file_at( + workspace_id: str, sha: str = Query(...), path: str = Query(...) +) -> Dict[str, Any]: + """File content as of a specific commit (read-only time travel).""" + from .. import git_ops + + paths = _require_workspace(workspace_id) + _require_sha(sha) + _safe_ws_file(paths, path) # traversal + hidden-dir guard + raw = await asyncio.to_thread(git_ops.file_at, paths.root, sha, path) + if raw is None: + raise _api()._err( + "file_not_found", f"File {path!r} not found at commit {sha[:12]}.", 404 + ) + too_big = len(raw) > _MAX_FILE_BYTES + chunk = raw[:_MAX_FILE_BYTES] + if _is_probably_binary(chunk): + return {"ok": True, "path": path, "sha": sha, "binary": True, "size": len(raw)} + return { + "ok": True, + "path": path, + "sha": sha, + "binary": False, + "size": len(raw), + "truncated": too_big, + "content": chunk.decode("utf-8", errors="replace"), + } diff --git a/clk_harness/webui_api/router.py b/clk_harness/webui_api/router.py new file mode 100644 index 0000000..368e2a9 --- /dev/null +++ b/clk_harness/webui_api/router.py @@ -0,0 +1,449 @@ +"""Web-UI REST router: shared helpers, config, .env, doctor, and idea. + +The shared :data:`router` every other ``webui_api`` module registers +its endpoints on lives here, together with the workspace/path guards +and the pydantic request models. +""" + +from __future__ import annotations + +import asyncio +import copy +import json +import os +from pathlib import Path +from typing import Any, Dict, List, Optional + +from fastapi import APIRouter +from pydantic import BaseModel, Field + +from .. import env_file +from ..config import ( + Paths, + load_agents_config, + load_clk_config, + load_providers_config, + save_agents_config, + save_json, + save_providers_config, +) +from ..log import get_logger +from ..providers import available_providers + +logger = get_logger(__name__) + +router = APIRouter() + +# --------------------------------------------------------------------------- +# Helpers (import lazily from api to avoid a circular import at module load) +# --------------------------------------------------------------------------- + +def _api(): + from .. import api # local import: api imports this module + return api + + +def _ws_paths(workspace_id: str) -> Paths: + api = _api() + ws_path = api._workspace_path(workspace_id) + return Paths(root=ws_path) + + +def _require_workspace(workspace_id: str) -> Paths: + api = _api() + if workspace_id not in api.WORKSPACES and not api._workspace_path(workspace_id).exists(): + raise api._err("workspace_not_found", f"Workspace {workspace_id!r} not found.", 404) + return _ws_paths(workspace_id) + + +def _activity_path(paths: Paths) -> Path: + """Resolve ``/.clk/logs/activity.jsonl`` with a traversal guard.""" + target = (paths.logs / "activity.jsonl").resolve() + root = paths.root.resolve() + try: + target.relative_to(root) + except ValueError: + raise _api()._err("forbidden", "Path escapes workspace boundary.", 403) + return target + + +# Directories that are harness internals / noise -- never listed as "files the +# agents generated for you", and never writable through the file editor. +_HIDDEN_DIRS = {".clk", ".git", "node_modules", "__pycache__", ".venv", ".mypy_cache", ".pytest_cache"} +_MAX_FILES = 3000 +_MAX_FILE_BYTES = 1_000_000 # 1 MB read/write cap for the in-browser editor + + +def _safe_ws_file(paths: Paths, rel: str, *, for_write: bool = False) -> Path: + """Resolve ``/`` with a traversal guard and a harness-internal + (``.clk``/``.git``/…) guard, raising the standard 403/400 envelope on + violation. The hidden-dir guard applies to reads and writes alike so + internal logs/state can't be fetched through the file endpoints. + """ + api = _api() + if not rel or rel.strip() == "": + raise api._err("invalid_path", "A file path is required.", 400) + root = paths.root.resolve() + target = (root / rel).resolve() + try: + target.relative_to(root) + except ValueError: + raise api._err("forbidden", "Path escapes workspace boundary.", 403) + parts = set(target.relative_to(root).parts) + if parts & _HIDDEN_DIRS: + verb = "write to" if for_write else "read from" + raise api._err("forbidden", f"Cannot {verb} harness-internal paths.", 403) + return target + + +def _is_probably_binary(data: bytes) -> bool: + return b"\x00" in data + + +def _safe_unlink(path: str) -> None: + """Best-effort temp-file removal that never raises. + + Used for download-zip cleanup both on the build-failure path and as the + response ``BackgroundTask``; swallows ``OSError`` so a TOCTOU race (file + already gone) or a permission hiccup can't surface a noisy exception + during response finalization. + """ + try: + os.remove(path) + except OSError: + pass + + +_SECRET_PROVIDER_FIELDS = ("api_key", "apikey", "token", "secret", "password") + + +def _mask_provider_block(providers: Dict[str, Any]) -> Dict[str, Any]: + """Return a deep-ish copy of a providers map with secret-looking + fields masked.""" + masked: Dict[str, Any] = {} + for name, block in (providers or {}).items(): + if not isinstance(block, dict): + masked[name] = block + continue + copy = dict(block) + for fld in list(copy.keys()): + if any(s in fld.lower() for s in _SECRET_PROVIDER_FIELDS) and copy[fld]: + copy[fld] = env_file.MASK_SENTINEL + masked[name] = copy + return masked + + +def _unmask_provider_block(incoming: Dict[str, Any], existing: Dict[str, Any]) -> Dict[str, Any]: + """Merge ``incoming`` over ``existing``, restoring masked secrets. + + Starts from ``existing`` so provider blocks the caller didn't include + are preserved (dropping them would leave ``active`` pointing at a + missing block, which silently degrades the run to the shell stub). + Masked secret fields fall back to the stored value so a round-trip + never clobbers a real secret. + """ + out: Dict[str, Any] = dict(existing or {}) + for name, block in (incoming or {}).items(): + if not isinstance(block, dict): + out[name] = block + continue + copy = dict(block) + prev = (existing or {}).get(name) or {} + for fld, val in list(copy.items()): + if val == env_file.MASK_SENTINEL: + copy[fld] = prev.get(fld, "") + out[name] = copy + return out + + +def _read_idea(paths: Paths) -> str: + idea_path = paths.state / "idea.json" + if not idea_path.exists(): + return "" + try: + data = json.loads(idea_path.read_text(encoding="utf-8")) + return str(data.get("title") or data.get("idea") or data.get("text") or "") + except Exception: + return "" + + +# --------------------------------------------------------------------------- +# Pydantic models +# --------------------------------------------------------------------------- + +class ClkConfigUpdate(BaseModel): + config: Dict[str, Any] + + +class ProvidersUpdate(BaseModel): + providers: Dict[str, Any] + active: Optional[str] = None + + +class AgentsUpdate(BaseModel): + agents: Dict[str, Any] + + +class FileWrite(BaseModel): + path: str + content: str + + +class IdeaUpdate(BaseModel): + statement: str + title: Optional[str] = None + tags: List[str] = Field(default_factory=list) + + +class EnvUpdate(BaseModel): + # value == MASK_SENTINEL -> leave unchanged; null -> blank; else set. + values: Dict[str, Optional[str]] = Field(default_factory=dict) + removals: List[str] = Field(default_factory=list) + + +# --------------------------------------------------------------------------- +# Per-workspace config +# --------------------------------------------------------------------------- + +@router.get("/api/workspaces/{workspace_id}/config/clk") +async def get_clk_config(workspace_id: str) -> Dict[str, Any]: + paths = _require_workspace(workspace_id) + return {"ok": True, "config": load_clk_config(paths)} + + +@router.put("/api/workspaces/{workspace_id}/config/clk") +async def put_clk_config(workspace_id: str, body: ClkConfigUpdate) -> Dict[str, Any]: + paths = _require_workspace(workspace_id) + paths.ensure() + save_json(paths.config / "clk.config.json", body.config) + return {"ok": True, "config": load_clk_config(paths)} + + +@router.get("/api/workspaces/{workspace_id}/config/providers") +async def get_providers_config(workspace_id: str) -> Dict[str, Any]: + paths = _require_workspace(workspace_id) + cfg = load_providers_config(paths) + # Always surface the full set of built-in providers (merged under any saved + # overrides) so the UI can always show every provider card — otherwise a + # sparse/empty providers.json leaves no "make active" buttons to click. + from ..config import DEFAULT_PROVIDERS + merged_blocks: Dict[str, Any] = dict(DEFAULT_PROVIDERS.get("providers") or {}) + for name, block in (cfg.get("providers") or {}).items(): + merged_blocks[name] = block + # available_providers() does blocking network probes for HTTP providers; + # run it off the event loop so the Providers tab never stalls the server. + available = await asyncio.to_thread(available_providers, {"providers": merged_blocks}) + return { + "ok": True, + "active": cfg.get("active"), + "providers": _mask_provider_block(merged_blocks), + "available": available, + } + + +@router.put("/api/workspaces/{workspace_id}/config/providers") +async def put_providers_config(workspace_id: str, body: ProvidersUpdate) -> Dict[str, Any]: + paths = _require_workspace(workspace_id) + paths.ensure() + existing = load_providers_config(paths) + merged = dict(existing) + merged["providers"] = _unmask_provider_block( + body.providers, existing.get("providers") or {} + ) + if body.active is not None: + merged["active"] = body.active + save_providers_config(paths, merged) + cfg = load_providers_config(paths) + return { + "ok": True, + "active": cfg.get("active"), + "providers": _mask_provider_block(cfg.get("providers") or {}), + "available": available_providers(cfg), + } + + +@router.get("/api/workspaces/{workspace_id}/config/agents") +async def get_agents_config(workspace_id: str) -> Dict[str, Any]: + paths = _require_workspace(workspace_id) + return {"ok": True, "agents": (load_agents_config(paths).get("agents") or {})} + + +@router.put("/api/workspaces/{workspace_id}/config/agents") +async def put_agents_config(workspace_id: str, body: AgentsUpdate) -> Dict[str, Any]: + paths = _require_workspace(workspace_id) + paths.ensure() + save_agents_config(paths, {"agents": body.agents}) + return {"ok": True, "agents": (load_agents_config(paths).get("agents") or {})} + + +@router.get("/api/workspaces/{workspace_id}/doctor") +async def workspace_doctor(workspace_id: str) -> Dict[str, Any]: + paths = _require_workspace(workspace_id) + clk_cfg = load_clk_config(paths) + prov_cfg = load_providers_config(paths) + auth_mode = (clk_cfg.get("auth_mode") or "cli").lower() + env = env_file.read_env() + # CLK_PROVIDER in the global .env overrides the workspace 'active' at + # runtime (see AgentRunner.get_provider), so the doctor must reflect it -- + # otherwise it would report 'shell' even when .env makes runs use Ollama. + env_provider = (env.get("CLK_PROVIDER") or os.environ.get("CLK_PROVIDER") or "").strip() + active = env_provider or prov_cfg.get("active") or clk_cfg.get("default_provider") or "shell" + from ..config import DEFAULT_PROVIDERS + # Probe the saved providers *plus* the resolved active provider's block. + # available_providers() only probes blocks present in providers.json, but + # the active provider can be an env/default-selected built-in with no saved + # block (e.g. CLK_PROVIDER=ollama on a workspace whose providers.json only + # has 'shell'). Without seeding the default block here, that provider would + # never get an availability finding and could never be marked 'fail'. + probe_blocks: Dict[str, Any] = dict(prov_cfg.get("providers") or {}) + if active != "shell" and active not in probe_blocks: + default_block = (DEFAULT_PROVIDERS.get("providers") or {}).get(active) + if default_block is not None: + # Deep-copy so a probe that rewrites 'endpoint' in place + # (ollama/openwebui docker-host fallback) can't mutate the shared + # module-global DEFAULT_PROVIDERS across requests. + probe_blocks[active] = copy.deepcopy(default_block) + # available_providers() does blocking TCP/network probes; run it off the + # event loop (as the Providers tab does) so the doctor endpoint can't stall + # the server while a probe waits on its socket timeout. + avail = await asyncio.to_thread(available_providers, {"providers": probe_blocks}) + findings: List[Dict[str, str]] = [] + # The most common "it runs but does nothing" trap: the active provider is + # the shell stub (echoes prompts, never calls an LLM), or it points at a + # name with no config block (which silently degrades to shell at runtime). + if active == "shell": + findings.append({ + "level": "warn", "name": "active_provider", + "message": ( + "active provider is 'shell' — a stub that echoes prompts and never calls an LLM. " + "Pick a real provider on this tab." + ), + }) + else: + known = set(prov_cfg.get("providers") or {}) | set(DEFAULT_PROVIDERS.get("providers") or {}) + if active not in known: + findings.append({ + "level": "fail", "name": "active_provider", + "message": ( + f"active provider '{active}' has no config block — runs will silently fall back to the shell stub." + ), + }) + for name, ok in sorted(avail.items()): + if ok: + findings.append({"level": "ok", "name": name, "message": "available"}) + else: + findings.append({ + "level": "fail" if name == active else "warn", + "name": name, "message": "unavailable", + }) + if ( + active == "claude" + and auth_mode == "apikey" + and not (env.get("ANTHROPIC_API_KEY") or os.environ.get("ANTHROPIC_API_KEY")) + ): + findings.append( + {"level": "fail", "name": "anthropic_key", "message": "auth=apikey but ANTHROPIC_API_KEY unset"} + ) + if ( + active == "codex" + and auth_mode == "apikey" + and not (env.get("OPENAI_API_KEY") or os.environ.get("OPENAI_API_KEY")) + ): + findings.append({"level": "fail", "name": "openai_key", "message": "auth=apikey but OPENAI_API_KEY unset"}) + if active == "gemini" and auth_mode == "apikey" and not any( + env.get(k) or os.environ.get(k) for k in ("GEMINI_API_KEY", "GOOGLE_API_KEY") + ): + findings.append({"level": "fail", "name": "gemini_key", "message": "auth=apikey but GEMINI/GOOGLE key unset"}) + return {"ok": True, "active_provider": active, "auth_mode": auth_mode, "findings": findings} + + +# --------------------------------------------------------------------------- +# Global .env +# --------------------------------------------------------------------------- + +@router.get("/api/env") +async def get_env() -> Dict[str, Any]: + variables, groups = env_file.describe_env(reveal=False) + return {"ok": True, "path": str(env_file.env_path()), "groups": groups, "vars": variables} + + +@router.get("/api/env/schema") +async def get_env_schema() -> Dict[str, Any]: + groups: Dict[str, List[dict]] = {} + for v in env_file.ENV_SCHEMA: + groups.setdefault(v.group, []).append({ + "key": v.key, "label": v.label, "type": v.type, + "choices": v.choices, "default": v.default, "help": v.help, + "is_secret": v.is_secret, + }) + ordered = [{"name": g, "vars": groups[g]} for g in env_file.GROUP_ORDER if g in groups] + return {"ok": True, "groups": ordered} + + +@router.put("/api/env") +async def put_env(body: EnvUpdate) -> Dict[str, Any]: + env_file.write_env(body.values, removals=set(body.removals or [])) + variables, groups = env_file.describe_env(reveal=False) + return {"ok": True, "path": str(env_file.env_path()), "groups": groups, "vars": variables} + + +@router.get("/api/env/reveal/{key}") +async def reveal_env(key: str) -> Dict[str, Any]: + """Explicitly reveal a single secret value. Disabled unless + ``CLK_API_ALLOW_REVEAL`` is truthy (defence-in-depth for the + masked-by-default contract).""" + allow = (os.environ.get("CLK_API_ALLOW_REVEAL") or "").strip().lower() in ("1", "true", "yes", "on") + if not allow: + raise _api()._err("reveal_disabled", "Secret reveal is disabled. Set CLK_API_ALLOW_REVEAL=1.", 403) + return {"ok": True, "key": key, "value": env_file.read_env().get(key, "")} + +@router.put("/api/workspaces/{workspace_id}/idea") +async def set_idea(workspace_id: str, body: IdeaUpdate) -> Dict[str, Any]: + """Seed the workspace idea/brief used by the next ``run``. + + Mirrors ``clk idea`` (idea.json + system_brief.md) but without the chief + casting pass, so the Files-view chat can attach context and immediately + kick off a workflow. + """ + import textwrap + from datetime import datetime + + paths = _require_workspace(workspace_id) + paths.ensure() + statement = body.statement.strip() + if not statement: + raise _api()._err("invalid_idea", "An idea statement is required.", 400) + title = (body.title or statement.split(".")[0])[:80] + captured_at = datetime.now().isoformat(timespec="seconds") + save_json(paths.state / "idea.json", { + "title": title, + "statement": statement, + "captured_at": captured_at, + "tags": body.tags or [], + }) + brief = textwrap.dedent( + f"""\ + # System brief + + **Title:** {title} + + ## Idea + {statement} + + ## Captured at + {captured_at} + """ + ) + (paths.state / "system_brief.md").write_text(brief, encoding="utf-8") + return {"ok": True, "title": title} + + +@router.get("/api/providers") +async def list_providers(workspace: Optional[str] = None) -> Dict[str, Any]: + if workspace: + paths = _require_workspace(workspace) + else: + from ..config import project_paths + paths = project_paths() + cfg = load_providers_config(paths) + return {"ok": True, "active": cfg.get("active"), "available": available_providers(cfg)} diff --git a/clk_harness/webui_router.py b/clk_harness/webui_router.py index 430db56..8548f02 100644 --- a/clk_harness/webui_router.py +++ b/clk_harness/webui_router.py @@ -21,803 +21,101 @@ import asyncio import copy -import json import os -import re -import zipfile -from pathlib import Path -from typing import Any, Dict, List, Optional +from typing import Any, Callable, Dict, List, Optional -from fastapi import APIRouter, Query, Request -from fastapi.responses import FileResponse, StreamingResponse -from pydantic import BaseModel, Field +from pydantic import BaseModel from . import env_file -from . import web_snapshot -from .config import ( - Paths, - load_agents_config, - load_clk_config, - load_providers_config, - save_agents_config, - save_json, - save_providers_config, +from .config import load_providers_config +from .log import get_logger + +# The REST surface now lives in the ``clk_harness.webui_api`` package. +# Importing it registers every endpoint module on the shared router; the +# explicit re-exports below preserve this module's original public surface +# for existing importers (tests included). +from .webui_api import events as _events # noqa: F401 (registers the activity endpoints) +from .webui_api.files import ( # noqa: F401 + _SHA_RE as _SHA_RE, +) +from .webui_api.files import ( + _require_sha as _require_sha, +) +from .webui_api.router import ( + _HIDDEN_DIRS as _HIDDEN_DIRS, +) +from .webui_api.router import ( + _MAX_FILE_BYTES as _MAX_FILE_BYTES, +) +from .webui_api.router import ( + _MAX_FILES as _MAX_FILES, +) +from .webui_api.router import ( + _SECRET_PROVIDER_FIELDS as _SECRET_PROVIDER_FIELDS, +) +from .webui_api.router import ( # noqa: F401, I001 + AgentsUpdate as AgentsUpdate, +) +from .webui_api.router import ( + ClkConfigUpdate as ClkConfigUpdate, +) +from .webui_api.router import ( + EnvUpdate as EnvUpdate, +) +from .webui_api.router import ( + FileWrite as FileWrite, +) +from .webui_api.router import ( + IdeaUpdate as IdeaUpdate, +) +from .webui_api.router import ( + ProvidersUpdate as ProvidersUpdate, +) +from .webui_api.router import ( + _activity_path as _activity_path, +) +from .webui_api.router import ( + _api as _api, +) +from .webui_api.router import ( + _is_probably_binary as _is_probably_binary, +) +from .webui_api.router import ( + _mask_provider_block as _mask_provider_block, +) +from .webui_api.router import ( + _read_idea as _read_idea, +) +from .webui_api.router import ( + _require_workspace as _require_workspace, +) +from .webui_api.router import ( + _safe_unlink as _safe_unlink, +) +from .webui_api.router import ( + _safe_ws_file as _safe_ws_file, +) +from .webui_api.router import ( + _unmask_provider_block as _unmask_provider_block, +) +from .webui_api.router import ( + _ws_paths as _ws_paths, +) +from .webui_api.router import ( + router as router, ) -from .providers import available_providers - -router = APIRouter() - - -# --------------------------------------------------------------------------- -# Helpers (import lazily from api to avoid a circular import at module load) -# --------------------------------------------------------------------------- - -def _api(): - from . import api # local import: api imports this module - return api - - -def _ws_paths(workspace_id: str) -> Paths: - api = _api() - ws_path = api._workspace_path(workspace_id) - return Paths(root=ws_path) - - -def _require_workspace(workspace_id: str) -> Paths: - api = _api() - if workspace_id not in api.WORKSPACES and not api._workspace_path(workspace_id).exists(): - raise api._err("workspace_not_found", f"Workspace {workspace_id!r} not found.", 404) - return _ws_paths(workspace_id) - - -def _activity_path(paths: Paths) -> Path: - """Resolve ``/.clk/logs/activity.jsonl`` with a traversal guard.""" - target = (paths.logs / "activity.jsonl").resolve() - root = paths.root.resolve() - try: - target.relative_to(root) - except ValueError: - raise _api()._err("forbidden", "Path escapes workspace boundary.", 403) - return target - - -# Directories that are harness internals / noise -- never listed as "files the -# agents generated for you", and never writable through the file editor. -_HIDDEN_DIRS = {".clk", ".git", "node_modules", "__pycache__", ".venv", ".mypy_cache", ".pytest_cache"} -_MAX_FILES = 3000 -_MAX_FILE_BYTES = 1_000_000 # 1 MB read/write cap for the in-browser editor - - -def _safe_ws_file(paths: Paths, rel: str, *, for_write: bool = False) -> Path: - """Resolve ``/`` with a traversal guard and a harness-internal - (``.clk``/``.git``/…) guard, raising the standard 403/400 envelope on - violation. The hidden-dir guard applies to reads and writes alike so - internal logs/state can't be fetched through the file endpoints. - """ - api = _api() - if not rel or rel.strip() == "": - raise api._err("invalid_path", "A file path is required.", 400) - root = paths.root.resolve() - target = (root / rel).resolve() - try: - target.relative_to(root) - except ValueError: - raise api._err("forbidden", "Path escapes workspace boundary.", 403) - parts = set(target.relative_to(root).parts) - if parts & _HIDDEN_DIRS: - verb = "write to" if for_write else "read from" - raise api._err("forbidden", f"Cannot {verb} harness-internal paths.", 403) - return target - - -def _is_probably_binary(data: bytes) -> bool: - return b"\x00" in data - - -def _safe_unlink(path: str) -> None: - """Best-effort temp-file removal that never raises. - - Used for download-zip cleanup both on the build-failure path and as the - response ``BackgroundTask``; swallows ``OSError`` so a TOCTOU race (file - already gone) or a permission hiccup can't surface a noisy exception - during response finalization. - """ - try: - os.remove(path) - except OSError: - pass - - -_SECRET_PROVIDER_FIELDS = ("api_key", "apikey", "token", "secret", "password") - - -def _mask_provider_block(providers: Dict[str, Any]) -> Dict[str, Any]: - """Return a deep-ish copy of a providers map with secret-looking - fields masked.""" - masked: Dict[str, Any] = {} - for name, block in (providers or {}).items(): - if not isinstance(block, dict): - masked[name] = block - continue - copy = dict(block) - for fld in list(copy.keys()): - if any(s in fld.lower() for s in _SECRET_PROVIDER_FIELDS) and copy[fld]: - copy[fld] = env_file.MASK_SENTINEL - masked[name] = copy - return masked - - -def _unmask_provider_block(incoming: Dict[str, Any], existing: Dict[str, Any]) -> Dict[str, Any]: - """Merge ``incoming`` over ``existing``, restoring masked secrets. - - Starts from ``existing`` so provider blocks the caller didn't include - are preserved (dropping them would leave ``active`` pointing at a - missing block, which silently degrades the run to the shell stub). - Masked secret fields fall back to the stored value so a round-trip - never clobbers a real secret. - """ - out: Dict[str, Any] = dict(existing or {}) - for name, block in (incoming or {}).items(): - if not isinstance(block, dict): - out[name] = block - continue - copy = dict(block) - prev = (existing or {}).get(name) or {} - for fld, val in list(copy.items()): - if val == env_file.MASK_SENTINEL: - copy[fld] = prev.get(fld, "") - out[name] = copy - return out - - -def _read_idea(paths: Paths) -> str: - idea_path = paths.state / "idea.json" - if not idea_path.exists(): - return "" - try: - data = json.loads(idea_path.read_text(encoding="utf-8")) - return str(data.get("title") or data.get("idea") or data.get("text") or "") - except Exception: - return "" - - -# --------------------------------------------------------------------------- -# Pydantic models -# --------------------------------------------------------------------------- - -class ClkConfigUpdate(BaseModel): - config: Dict[str, Any] - - -class ProvidersUpdate(BaseModel): - providers: Dict[str, Any] - active: Optional[str] = None - - -class AgentsUpdate(BaseModel): - agents: Dict[str, Any] - - -class FileWrite(BaseModel): - path: str - content: str - - -class IdeaUpdate(BaseModel): - statement: str - title: Optional[str] = None - tags: List[str] = Field(default_factory=list) - - -class EnvUpdate(BaseModel): - # value == MASK_SENTINEL -> leave unchanged; null -> blank; else set. - values: Dict[str, Optional[str]] = Field(default_factory=dict) - removals: List[str] = Field(default_factory=list) - - -# --------------------------------------------------------------------------- -# Per-workspace config -# --------------------------------------------------------------------------- - -@router.get("/api/workspaces/{workspace_id}/config/clk") -async def get_clk_config(workspace_id: str) -> Dict[str, Any]: - paths = _require_workspace(workspace_id) - return {"ok": True, "config": load_clk_config(paths)} - - -@router.put("/api/workspaces/{workspace_id}/config/clk") -async def put_clk_config(workspace_id: str, body: ClkConfigUpdate) -> Dict[str, Any]: - paths = _require_workspace(workspace_id) - paths.ensure() - save_json(paths.config / "clk.config.json", body.config) - return {"ok": True, "config": load_clk_config(paths)} - - -@router.get("/api/workspaces/{workspace_id}/config/providers") -async def get_providers_config(workspace_id: str) -> Dict[str, Any]: - paths = _require_workspace(workspace_id) - cfg = load_providers_config(paths) - # Always surface the full set of built-in providers (merged under any saved - # overrides) so the UI can always show every provider card — otherwise a - # sparse/empty providers.json leaves no "make active" buttons to click. - from .config import DEFAULT_PROVIDERS - merged_blocks: Dict[str, Any] = dict(DEFAULT_PROVIDERS.get("providers") or {}) - for name, block in (cfg.get("providers") or {}).items(): - merged_blocks[name] = block - # available_providers() does blocking network probes for HTTP providers; - # run it off the event loop so the Providers tab never stalls the server. - available = await asyncio.to_thread(available_providers, {"providers": merged_blocks}) - return { - "ok": True, - "active": cfg.get("active"), - "providers": _mask_provider_block(merged_blocks), - "available": available, - } - - -@router.put("/api/workspaces/{workspace_id}/config/providers") -async def put_providers_config(workspace_id: str, body: ProvidersUpdate) -> Dict[str, Any]: - paths = _require_workspace(workspace_id) - paths.ensure() - existing = load_providers_config(paths) - merged = dict(existing) - merged["providers"] = _unmask_provider_block( - body.providers, existing.get("providers") or {} - ) - if body.active is not None: - merged["active"] = body.active - save_providers_config(paths, merged) - cfg = load_providers_config(paths) - return { - "ok": True, - "active": cfg.get("active"), - "providers": _mask_provider_block(cfg.get("providers") or {}), - "available": available_providers(cfg), - } - - -@router.get("/api/workspaces/{workspace_id}/config/agents") -async def get_agents_config(workspace_id: str) -> Dict[str, Any]: - paths = _require_workspace(workspace_id) - return {"ok": True, "agents": (load_agents_config(paths).get("agents") or {})} - - -@router.put("/api/workspaces/{workspace_id}/config/agents") -async def put_agents_config(workspace_id: str, body: AgentsUpdate) -> Dict[str, Any]: - paths = _require_workspace(workspace_id) - paths.ensure() - save_agents_config(paths, {"agents": body.agents}) - return {"ok": True, "agents": (load_agents_config(paths).get("agents") or {})} - - -@router.get("/api/workspaces/{workspace_id}/doctor") -async def workspace_doctor(workspace_id: str) -> Dict[str, Any]: - paths = _require_workspace(workspace_id) - clk_cfg = load_clk_config(paths) - prov_cfg = load_providers_config(paths) - auth_mode = (clk_cfg.get("auth_mode") or "cli").lower() - env = env_file.read_env() - # CLK_PROVIDER in the global .env overrides the workspace 'active' at - # runtime (see AgentRunner.get_provider), so the doctor must reflect it -- - # otherwise it would report 'shell' even when .env makes runs use Ollama. - env_provider = (env.get("CLK_PROVIDER") or os.environ.get("CLK_PROVIDER") or "").strip() - active = env_provider or prov_cfg.get("active") or clk_cfg.get("default_provider") or "shell" - from .config import DEFAULT_PROVIDERS - # Probe the saved providers *plus* the resolved active provider's block. - # available_providers() only probes blocks present in providers.json, but - # the active provider can be an env/default-selected built-in with no saved - # block (e.g. CLK_PROVIDER=ollama on a workspace whose providers.json only - # has 'shell'). Without seeding the default block here, that provider would - # never get an availability finding and could never be marked 'fail'. - probe_blocks: Dict[str, Any] = dict(prov_cfg.get("providers") or {}) - if active != "shell" and active not in probe_blocks: - default_block = (DEFAULT_PROVIDERS.get("providers") or {}).get(active) - if default_block is not None: - # Deep-copy so a probe that rewrites 'endpoint' in place - # (ollama/openwebui docker-host fallback) can't mutate the shared - # module-global DEFAULT_PROVIDERS across requests. - probe_blocks[active] = copy.deepcopy(default_block) - # available_providers() does blocking TCP/network probes; run it off the - # event loop (as the Providers tab does) so the doctor endpoint can't stall - # the server while a probe waits on its socket timeout. - avail = await asyncio.to_thread(available_providers, {"providers": probe_blocks}) - findings: List[Dict[str, str]] = [] - # The most common "it runs but does nothing" trap: the active provider is - # the shell stub (echoes prompts, never calls an LLM), or it points at a - # name with no config block (which silently degrades to shell at runtime). - if active == "shell": - findings.append({ - "level": "warn", "name": "active_provider", - "message": "active provider is 'shell' — a stub that echoes prompts and never calls an LLM. Pick a real provider on this tab.", - }) - else: - known = set(prov_cfg.get("providers") or {}) | set(DEFAULT_PROVIDERS.get("providers") or {}) - if active not in known: - findings.append({ - "level": "fail", "name": "active_provider", - "message": f"active provider '{active}' has no config block — runs will silently fall back to the shell stub.", - }) - for name, ok in sorted(avail.items()): - if ok: - findings.append({"level": "ok", "name": name, "message": "available"}) - else: - findings.append({ - "level": "fail" if name == active else "warn", - "name": name, "message": "unavailable", - }) - if active == "claude" and auth_mode == "apikey" and not (env.get("ANTHROPIC_API_KEY") or os.environ.get("ANTHROPIC_API_KEY")): - findings.append({"level": "fail", "name": "anthropic_key", "message": "auth=apikey but ANTHROPIC_API_KEY unset"}) - if active == "codex" and auth_mode == "apikey" and not (env.get("OPENAI_API_KEY") or os.environ.get("OPENAI_API_KEY")): - findings.append({"level": "fail", "name": "openai_key", "message": "auth=apikey but OPENAI_API_KEY unset"}) - if active == "gemini" and auth_mode == "apikey" and not any( - env.get(k) or os.environ.get(k) for k in ("GEMINI_API_KEY", "GOOGLE_API_KEY") - ): - findings.append({"level": "fail", "name": "gemini_key", "message": "auth=apikey but GEMINI/GOOGLE key unset"}) - return {"ok": True, "active_provider": active, "auth_mode": auth_mode, "findings": findings} - - -# --------------------------------------------------------------------------- -# Global .env -# --------------------------------------------------------------------------- - -@router.get("/api/env") -async def get_env() -> Dict[str, Any]: - variables, groups = env_file.describe_env(reveal=False) - return {"ok": True, "path": str(env_file.env_path()), "groups": groups, "vars": variables} - - -@router.get("/api/env/schema") -async def get_env_schema() -> Dict[str, Any]: - groups: Dict[str, List[dict]] = {} - for v in env_file.ENV_SCHEMA: - groups.setdefault(v.group, []).append({ - "key": v.key, "label": v.label, "type": v.type, - "choices": v.choices, "default": v.default, "help": v.help, - "is_secret": v.is_secret, - }) - ordered = [{"name": g, "vars": groups[g]} for g in env_file.GROUP_ORDER if g in groups] - return {"ok": True, "groups": ordered} - - -@router.put("/api/env") -async def put_env(body: EnvUpdate) -> Dict[str, Any]: - env_file.write_env(body.values, removals=set(body.removals or [])) - variables, groups = env_file.describe_env(reveal=False) - return {"ok": True, "path": str(env_file.env_path()), "groups": groups, "vars": variables} - - -@router.get("/api/env/reveal/{key}") -async def reveal_env(key: str) -> Dict[str, Any]: - """Explicitly reveal a single secret value. Disabled unless - ``CLK_API_ALLOW_REVEAL`` is truthy (defence-in-depth for the - masked-by-default contract).""" - allow = (os.environ.get("CLK_API_ALLOW_REVEAL") or "").strip().lower() in ("1", "true", "yes", "on") - if not allow: - raise _api()._err("reveal_disabled", "Secret reveal is disabled. Set CLK_API_ALLOW_REVEAL=1.", 403) - return {"ok": True, "key": key, "value": env_file.read_env().get(key, "")} - - -# --------------------------------------------------------------------------- -# Activity: history + snapshot + SSE stream -# --------------------------------------------------------------------------- - -@router.get("/api/workspaces/{workspace_id}/activity") -async def get_activity( - workspace_id: str, - offset: int = Query(0, ge=0), - limit: int = Query(500, ge=1, le=5000), - kinds: Optional[str] = None, -) -> Dict[str, Any]: - paths = _require_workspace(workspace_id) - log_path = _activity_path(paths) - raw_events, new_offset = web_snapshot.iter_events(log_path, offset) - wanted = {k.strip() for k in kinds.split(",")} if kinds else None - out: List[dict] = [] - seq = offset # use byte offset as a coarse monotonic base for history - for i, raw in enumerate(raw_events): - if wanted and raw.get("event") not in wanted: - continue - out.append(web_snapshot.normalize_event(raw, offset + i)) - out = out[:limit] - return {"ok": True, "events": out, "next_offset": new_offset, "count": len(out)} - - -@router.get("/api/workspaces/{workspace_id}/logs") -async def get_harness_logs( - workspace_id: str, - tail: int = Query(400, ge=1, le=5000), -) -> Dict[str, Any]: - """Tail the harness session logs (init/idea/run/...). - - These are the human-readable ``.clk/logs/*.log`` files the CLI writes — - distinct from activity.jsonl. The web Log tab shows them so users can see - initialization progress and orchestration decisions without a terminal. - """ - paths = _require_workspace(workspace_id) - logs_dir = paths.logs - entries: List[Dict[str, Any]] = [] - # The UI polls this every few seconds, so reads must stay bounded as - # logs grow: read at most ~120 bytes/line of tail from the end of each - # file instead of the whole file. - max_bytes = tail * 120 - if logs_dir.is_dir(): - files = sorted( - (p for p in logs_dir.glob("*.log") if p.is_file()), - key=lambda p: p.stat().st_mtime, - ) - # Read newest files last so the tail keeps the most recent lines. - for p in files: - try: - size = p.stat().st_size - with p.open("rb") as fh: - if size > max_bytes: - fh.seek(size - max_bytes) - fh.readline() # drop the partial first line - text = fh.read().decode("utf-8", errors="replace") - except OSError: - continue - for line in text.splitlines(): - if line.strip(): - entries.append({"file": p.name, "line": line}) - if len(entries) > tail: - entries = entries[-tail:] - return {"ok": True, "lines": entries, "count": len(entries)} - - -@router.get("/api/workspaces/{workspace_id}/snapshot") -async def get_snapshot(workspace_id: str) -> Dict[str, Any]: - paths = _require_workspace(workspace_id) - log_path = _activity_path(paths) - raw_events, _ = web_snapshot.iter_events(log_path, 0) - prov_cfg = load_providers_config(paths) - snap = web_snapshot.build_snapshot( - raw_events, - provider_overrides=(prov_cfg.get("providers") or {}), - idea=_read_idea(paths), - active_provider=prov_cfg.get("active") or load_clk_config(paths).get("default_provider") or "", - ) - return {"ok": True, "snapshot": snap} - - -@router.get("/api/workspaces/{workspace_id}/activity/stream") -async def stream_activity( - workspace_id: str, - request: Request, - from_: str = Query("end", alias="from"), -) -> StreamingResponse: - paths = _require_workspace(workspace_id) - log_path = _activity_path(paths) - - async def _generate(): - # from=start replays the whole log then follows; from=end follows - # only new events (default). - if from_ == "start": - offset = 0 - else: - _, offset = web_snapshot.iter_events(log_path, 0) - seq = 0 - idle_ticks = 0 - while True: - if await request.is_disconnected(): - break - events, new_offset = web_snapshot.iter_events(log_path, offset) - offset = new_offset - if events: - idle_ticks = 0 - for raw in events: - payload = json.dumps(web_snapshot.normalize_event(raw, seq)) - seq += 1 - yield f"data: {payload}\n\n" - else: - idle_ticks += 1 - if idle_ticks % 30 == 0: # ~every 9s, keep proxies alive - yield ": keepalive\n\n" - await asyncio.sleep(0.3) - - return StreamingResponse( - _generate(), - media_type="text/event-stream", - headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no", "Connection": "keep-alive"}, - ) - - -# --------------------------------------------------------------------------- -# Workspace files: list / read / write + the "follow up with the agents" idea -# --------------------------------------------------------------------------- - -@router.get("/api/workspaces/{workspace_id}/files") -async def list_files(workspace_id: str) -> Dict[str, Any]: - """List the files the agents have produced in the workspace. - - Harness-internal directories (``.clk``, ``.git``, ``node_modules`` …) are - skipped so this reflects the user-facing deliverables, not bookkeeping. - """ - from datetime import datetime, timezone - - paths = _require_workspace(workspace_id) - root = paths.root.resolve() - files: List[Dict[str, Any]] = [] - truncated = False - if root.exists(): - for dirpath, dirnames, filenames in os.walk(root): - # Prune hidden/internal dirs in place so os.walk doesn't descend. - dirnames[:] = sorted(d for d in dirnames if d not in _HIDDEN_DIRS) - for name in sorted(filenames): - if len(files) >= _MAX_FILES: - truncated = True - break - fp = Path(dirpath) / name - # Skip symlinks: following them with stat() could leak - # size/mtime for targets outside the workspace. - if fp.is_symlink(): - continue - try: - stat = fp.stat() - except OSError: - continue - files.append({ - "path": str(fp.relative_to(root)), - "size": stat.st_size, - "modified": datetime.fromtimestamp(stat.st_mtime, timezone.utc) - .isoformat().replace("+00:00", "Z"), - }) - if truncated: - break - files.sort(key=lambda f: f["path"]) - return {"ok": True, "files": files, "count": len(files), "truncated": truncated} - - -@router.get("/api/workspaces/{workspace_id}/download") -async def download_workspace(workspace_id: str) -> FileResponse: - """Download the workspace's deliverables as a zip. - - Mirrors the file listing: harness-internal directories (``.clk``, - ``.git``, ``node_modules`` …) and symlinks are excluded so the archive - is just the user-facing files the agents produced. - """ - paths = _require_workspace(workspace_id) - root = paths.root.resolve() - - def _build_zip_to_tempfile() -> str: - # Build to a temp file on disk so we stream it back in chunks rather - # than holding the whole archive in memory. - import tempfile - fd, tmp_path = tempfile.mkstemp(suffix=".zip", prefix="clk-ws-") - os.close(fd) - # If zipping fails (e.g. a permission error while walking/writing) the - # FileResponse -- and its cleanup BackgroundTask -- is never created, so - # remove the temp file here before re-raising to avoid leaking it. - try: - with zipfile.ZipFile(tmp_path, "w", zipfile.ZIP_DEFLATED) as zf: - if root.exists(): - for dirpath, dirnames, filenames in os.walk(root): - dirnames[:] = [d for d in dirnames if d not in _HIDDEN_DIRS] - for name in filenames: - fp = Path(dirpath) / name - if fp.is_symlink(): - continue - try: - zf.write(fp, fp.relative_to(root).as_posix()) - except OSError: - continue - except BaseException: - _safe_unlink(tmp_path) - raise - return tmp_path - - tmp_path = await asyncio.to_thread(_build_zip_to_tempfile) - entry = _api().WORKSPACES.get(workspace_id) or {} - raw_name = str(entry.get("name") or workspace_id[:8]) - safe = re.sub(r"[^A-Za-z0-9._-]+", "-", raw_name).strip("-") or "workspace" - from starlette.background import BackgroundTask - return FileResponse( - tmp_path, - media_type="application/zip", - filename=f"{safe}.zip", - background=BackgroundTask(lambda: _safe_unlink(tmp_path)), - ) - - -@router.get("/api/workspaces/{workspace_id}/file") -async def read_file(workspace_id: str, path: str = Query(...)) -> Dict[str, Any]: - paths = _require_workspace(workspace_id) - target = _safe_ws_file(paths, path) - if not target.exists() or not target.is_file(): - raise _api()._err("file_not_found", f"File {path!r} not found.", 404) - raw = target.read_bytes() - too_big = len(raw) > _MAX_FILE_BYTES - chunk = raw[:_MAX_FILE_BYTES] - if _is_probably_binary(chunk): - return {"ok": True, "path": path, "binary": True, "size": len(raw)} - return { - "ok": True, - "path": path, - "binary": False, - "size": len(raw), - "truncated": too_big, - "content": chunk.decode("utf-8", errors="replace"), - } - -@router.put("/api/workspaces/{workspace_id}/file") -async def write_file(workspace_id: str, body: FileWrite) -> Dict[str, Any]: - paths = _require_workspace(workspace_id) - paths.root.mkdir(parents=True, exist_ok=True) - target = _safe_ws_file(paths, body.path, for_write=True) - if body.path.endswith(("/", "\\")) or (target.exists() and target.is_dir()): - raise _api()._err("invalid_path", f"{body.path!r} is a directory, not a file.", 400) - data = body.content.encode("utf-8") - if len(data) > _MAX_FILE_BYTES: - raise _api()._err("too_large", "File exceeds the 1 MB editor limit.", 413) - target.parent.mkdir(parents=True, exist_ok=True) - target.write_text(body.content, encoding="utf-8") - return {"ok": True, "path": body.path, "size": len(data)} +logger = get_logger(__name__) # --------------------------------------------------------------------------- -# Git history: the Files tab's "how did these files evolve" view +# Provider probe + discovery (guided mode) +# +# These stay in this module (rather than moving into ``webui_api``) because +# the test suite patches ``webui_router._probe_blocking`` and the discovery +# path below must resolve that patched module-global at call time. # --------------------------------------------------------------------------- -_SHA_RE = re.compile(r"^[0-9a-fA-F]{4,40}$") - - -def _require_sha(sha: str) -> str: - if not _SHA_RE.match(sha or ""): - raise _api()._err("invalid_sha", "A hex commit sha is required.", 400) - return sha - - -@router.get("/api/workspaces/{workspace_id}/git/log") -async def git_log( - workspace_id: str, - path: Optional[str] = Query(None), - limit: int = Query(100, ge=1, le=500), -) -> Dict[str, Any]: - """Commit history (newest first) for the workspace, or for one file. - - Harness-internal paths (.clk, .git, …) are filtered out so the - history mirrors what the Files tab lists. - """ - from . import git_ops - - paths = _require_workspace(workspace_id) - if path: - _safe_ws_file(paths, path) # traversal + hidden-dir guard - commits = await asyncio.to_thread( - git_ops.log_entries, paths.root, path=path, limit=limit - ) - return {"ok": True, "commits": commits, "count": len(commits)} - - -@router.get("/api/workspaces/{workspace_id}/git/commit/{sha}") -async def git_commit_detail(workspace_id: str, sha: str) -> Dict[str, Any]: - """One commit's metadata + unified diff (internal paths excluded).""" - from . import git_ops - - paths = _require_workspace(workspace_id) - _require_sha(sha) - commits = await asyncio.to_thread( - git_ops.log_entries, paths.root, limit=1, rev=sha - ) - meta = commits[0] if commits else None - patch = await asyncio.to_thread(git_ops.commit_patch, paths.root, sha) - if meta is None and patch is None: - raise _api()._err("commit_not_found", f"Commit {sha!r} not found.", 404) - return { - "ok": True, - "commit": meta, - "patch": (patch or {}).get("patch", ""), - "patch_truncated": bool((patch or {}).get("truncated")), - } - - -@router.get("/api/workspaces/{workspace_id}/git/status") -async def git_status(workspace_id: str) -> Dict[str, Any]: - """Uncommitted working-tree changes vs HEAD (the Files tab's - "not yet committed" view).""" - from . import git_ops - - paths = _require_workspace(workspace_id) - files = await asyncio.to_thread(git_ops.status_entries, paths.root) - return {"ok": True, "dirty": bool(files), "files": files, "count": len(files)} - - -@router.get("/api/workspaces/{workspace_id}/git/diff") -async def git_working_diff(workspace_id: str) -> Dict[str, Any]: - """Unified diff of uncommitted changes vs HEAD. Untracked files don't - appear in the patch — pair with /git/status to list them.""" - from . import git_ops - - paths = _require_workspace(workspace_id) - patch = await asyncio.to_thread(git_ops.working_tree_patch, paths.root) - return { - "ok": True, - "patch": (patch or {}).get("patch", ""), - "truncated": bool((patch or {}).get("truncated")), - } - - -@router.get("/api/workspaces/{workspace_id}/git/file") -async def git_file_at( - workspace_id: str, sha: str = Query(...), path: str = Query(...) -) -> Dict[str, Any]: - """File content as of a specific commit (read-only time travel).""" - from . import git_ops - - paths = _require_workspace(workspace_id) - _require_sha(sha) - _safe_ws_file(paths, path) # traversal + hidden-dir guard - raw = await asyncio.to_thread(git_ops.file_at, paths.root, sha, path) - if raw is None: - raise _api()._err( - "file_not_found", f"File {path!r} not found at commit {sha[:12]}.", 404 - ) - too_big = len(raw) > _MAX_FILE_BYTES - chunk = raw[:_MAX_FILE_BYTES] - if _is_probably_binary(chunk): - return {"ok": True, "path": path, "sha": sha, "binary": True, "size": len(raw)} - return { - "ok": True, - "path": path, - "sha": sha, - "binary": False, - "size": len(raw), - "truncated": too_big, - "content": chunk.decode("utf-8", errors="replace"), - } - - -@router.put("/api/workspaces/{workspace_id}/idea") -async def set_idea(workspace_id: str, body: IdeaUpdate) -> Dict[str, Any]: - """Seed the workspace idea/brief used by the next ``run``. - - Mirrors ``clk idea`` (idea.json + system_brief.md) but without the chief - casting pass, so the Files-view chat can attach context and immediately - kick off a workflow. - """ - import textwrap - from datetime import datetime - - paths = _require_workspace(workspace_id) - paths.ensure() - statement = body.statement.strip() - if not statement: - raise _api()._err("invalid_idea", "An idea statement is required.", 400) - title = (body.title or statement.split(".")[0])[:80] - captured_at = datetime.now().isoformat(timespec="seconds") - save_json(paths.state / "idea.json", { - "title": title, - "statement": statement, - "captured_at": captured_at, - "tags": body.tags or [], - }) - brief = textwrap.dedent( - f"""\ - # System brief - - **Title:** {title} - - ## Idea - {statement} - - ## Captured at - {captured_at} - """ - ) - (paths.state / "system_brief.md").write_text(brief, encoding="utf-8") - return {"ok": True, "title": title} - - -@router.get("/api/providers") -async def list_providers(workspace: Optional[str] = None) -> Dict[str, Any]: - if workspace: - paths = _require_workspace(workspace) - else: - from .config import project_paths - paths = project_paths() - cfg = load_providers_config(paths) - return {"ok": True, "active": cfg.get("active"), "available": available_providers(cfg)} - class ProbeRequest(BaseModel): type: str @@ -828,8 +126,11 @@ class ProbeRequest(BaseModel): def _probe_blocking(ptype: str, endpoint: str, api_key: str) -> Dict[str, Any]: """Synchronous probe worker (runs off the event loop via to_thread).""" from .providers._endpoint_fallback import ( - normalize_endpoint, probe_endpoint, docker_host_swap, + docker_host_swap, + normalize_endpoint, + probe_endpoint, ) + _list: Callable[[str], list] if ptype == "ollama": from .providers.ollama import list_models as _ollama_models ep = normalize_endpoint(endpoint) or "http://localhost:11434" @@ -911,13 +212,13 @@ def _discover_blocking() -> List[Dict[str, Any]]: for name, block in saved.items(): if name in blocks and isinstance(block, dict): blocks[name] = {**blocks[name], **block} - except Exception: - pass + except Exception as _exc: + logger.debug("providers: could not merge saved config: %s", _exc) env: Dict[str, str] = {} try: env = env_file.read_env() - except Exception: - pass + except Exception as _exc: + logger.debug("providers: could not read .env: %s", _exc) def _env(*keys: str) -> str: for k in keys: @@ -1046,4 +347,5 @@ async def probe_provider(body: ProbeRequest) -> Dict[str, Any]: ) + __all__ = ["router"] diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..8307a73 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,104 @@ +# Architecture + +Part of the [CLK documentation](../README.md). Why CLK exists and how the package is laid out. + +## Why CLK + +- **Local-first.** Everything lives under `.clk/` in the project + directory. No global installs, no `sudo`. +- **Provider-agnostic.** Works with Claude Code, OpenAI Codex, Google + Gemini, OpenWebUI (any OpenAI-compatible HTTP server), Pi, local + Ollama, or a built-in dummy "shell" provider for testing. +- **Dynamic team.** A baseline of three agents (`chief`, `qa`, `ralph`) + ships with the harness; the chief invents project-specific specialists + on the fly — including `engineer` when an implementer is needed — writes + their prompts, and authors the workflow YAML that wires them together. +- **Real actions, not just descriptions.** Agents emit `ACTION:` blocks + (write/edit/append/delete/run/done) that the harness applies with + path-safety checks, automatic backups, and per-agent git commits. +- **Self-healing.** When a stage's dependencies fail, the chief is + dispatched in recovery mode (capped) to fix or re-cast rather than + silently skipping. +- **Iterative by design.** Ships with Archon-style YAML workflows and + a Ralph/gnhf-style improvement loop; the same ralph agent also drives + Karpathy-style autoresearch cycles when the state has open questions. +- **Memory through git.** Every successful milestone (and every action + batch) is committed with a structured message so future agent runs + can mine the log for context. A separate `.clk/state/casting.log` + records every roster decision, and `.clk/logs/session.log` mirrors + the TUI status pane. +## Layout + +The package itself: + +``` +clk_harness/ + api.py # FastAPI REST API server + _api_launcher.py # background daemon thread launcher (auto-start on CLI) + _api_shim.py # console-script shim for clk-api (guards ImportError) + cli.py # argparse entrypoint + config.py # paths, default configs, JSON load/save + git_ops.py # init, commit, revert, status helpers + providers/ # claude, codex, pi, ollama, shell adapters + orchestration/ # agent runner, workflow runner, ralph loop (refinement + autoresearch) + templates/ # bundled prompts and workflows + utils/ # logging +scripts/ + clk # launcher (prefers .clk/venv/bin/python) + install_local.sh # creates .clk/venv and installs PyYAML + run_loop.sh # convenience wrapper around clk loop + run_all_tests.sh # orchestrator: build + test in ephemeral Docker +tests/ # pytest regression suite (CI-gated) +user_tests/ # pytest end-to-end suite (drives CLI + REST API) +pi-extension/ # standalone Pi extension (TypeScript) + src/ + index.ts # /clk + /clk-resume + /clk-help + /clk-doctor + /clk-undo, + # session lifecycle + watchdog wiring + prompts.ts # the chief's operator's manual + tools.ts # clk_cast / clk_progress / clk_checkpoint / clk_branch / + # clk_merge / clk_revert / clk_consensus / clk_subagent_quality / + # clk_autoresearch / clk_ralph / clk_done + watchdog.ts # supervise loop: continue → stall rescue → stop ladder + validate.ts # shell validation gate for clk_merge / clk_done + subagent.ts # raw clk_subagent — spawnSubagent() exposed for consensus + consensus.ts # dispatchWithQuality + runConsensus (port of agent.py) + quality.ts # scoreResponse + repairHint + progressSignal + # (port of response_quality.py) + git.ts # checkpoint, branch, merge, revert + hasRemote / commitsAhead / + # pushBestEffort (port of git_ops.py auto-push helpers) + state.ts / abort.ts / errors.ts / types.ts + tests/ # node --test suites covering every file in src/ +docs/ + REST_API.md # full REST API reference +``` + +The harness state, written by `clk init` and grown by every command: + +``` +.clk/ + config/ + clk.config.json # project-wide config (incl. casting + recovery caps) + providers.json # provider registry + active provider + agents.json # agent -> prompt + provider mapping (mutable) + workflows/*.yaml # Archon-style workflows (chief authors per project) + prompts/ # editable prompt templates (one per agent; + # dynamic roles get a generated file here) + state/ + idea.json # captured idea + system_brief.md # initial brief + prd.json # product manager output + progress.md # human-readable timeline + decisions.md # decisions log + experiments.jsonl # per-iteration outcomes + agent_memory.jsonl # all agent invocations (incl. token usage) + casting.log # JSONL of every roster decision (add/update/remove) + done.md # written only when completion criteria met + logs/ + activity.jsonl # detailed agent activity log + session.log # mirror of the TUI status pane + -.log # per-command log files + runs/ # per-invocation prompt + response capture + tools/ # locally-cloned external tools (e.g. pi) + venv/ # local python venv + backups/ # safety copies of overwritten files (per run) +``` diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md new file mode 100644 index 0000000..fc1d375 --- /dev/null +++ b/docs/CHANGELOG.md @@ -0,0 +1,113 @@ +# What's new + +Part of the [CLK documentation](../README.md). Release highlights, most recent first. + +## What's new + +If you've used CLK before, the highlights of this release: + +- **Autonomous missions — one prompt to done.** `clk run` (and the TUI's first + message) now drive the whole lifecycle autonomously: the chief writes a + **charter**, authors a **living plan**, and walks discovery → … → + deployment through chief-evaluated phase gates to a **code-gated** done. + Reliability is enforced, not hoped for: a machine-checkable **done-gate** + (tests/qa/ralph/deliverables, adaptive for test-less projects) makes + `ACTION:done` a *request*; a **no-op guard** re-dispatches stages that changed + no files; evaluation **auto-derives** a real command instead of vacuously + passing; refinement is on for every producing stage by default; agents + **deliberate** (blocking Q&A + self-reflection); and every boundary leaves a + structured **git commit trace** with a per-cycle telemetry line. Use + `clk mission ""` for the explicit form or `clk run --once` for a single + cycle. See [Autonomous missions](MISSIONS.md#autonomous-missions). +- **Web dashboard (`clk web`).** A beautiful browser UI that mirrors the + TUI: configure every feature and `.env` setting, kick off workflows, + and watch the agents work in real time with live cards, a colour-coded + activity timeline, and animated token/cost meters. See + [Web dashboard](WEBUI.md#web-dashboard). +- **Guided mode.** A beginner-friendly step-by-step wizard in the web + console: scan for available LLM providers, pick a model, describe your + idea in plain language, watch a friendly progress view, browse and + download the files, then loop with follow-up requests. First-time + visitors land here automatically; the full console is one click away. +- **Files tab with git history.** Browse the live workspace, toggle to a + commit **History** view (agent badge, relative time, +/− stats, colored + diff per commit), time-travel any single file to a past version, and + see **uncommitted changes** as a pseudo-entry with new/modified/deleted + badges — files changed since the last commit carry an amber dot. +- **Work is never silently lost.** Failed stage validations no longer + hard-reset the workspace by default (`validation.rollback_on_failure: + careful` — only `careful: true` stages roll back, and even then the + discarded work is preserved behind a `refs/clk/rollbacks/` snapshot + ref). Agent `PATH:`s are resolved chroot-style, so absolute paths no + longer cause writes to be silently skipped. +- **A chief that keeps going.** Supervise/review prompts now carry an + explicit low-bar-to-continue / high-bar-to-stop asymmetry, stalled + cycles trigger a one-shot chief **stall rescue** before the loop gives + up (`supervise.stall_rescue`), unmet outputs contracts dispatch a + chief recovery pass, and dynamic agents receive the full ACTION/POST + protocol automatically so first dispatches comply. +- **Robustness loops by default.** Every meaningful dispatch is now + scored after the provider returns; empty / malformed / contract- + violating / low-confidence responses are re-dispatched with a repair + preamble, escalating to a stochastic consensus fan-out on the final + retry. Stages marked `careful: true` fan into N parallel samples + proactively (configurable via `robustness.auto_consensus`). The + critic-judge inner loop (`refine:` stage attribute, or default-on + for careful stages) drives draft → critic → revise until the critic + signs off. Ralph and autoresearch detect plateau / regression and + escalate-then-reframe instead of burning the full iteration budget. + Agents can ask peers directed clarifying questions via + `POST: question TO: URGENCY: blocking` and the harness + routes the answer inline. Everything is gated by + `clk.config.json::robustness.*` (or `CLK_ROBUSTNESS_*` env vars) so + you can throttle cost — see **Robustness loops** below. +- **The setup wizard explains itself.** `kickoff.sh --setup` is now a + series of explain-then-ask blocks (provider, loop settings, tool + detection, telegram, GitHub, git identity) — every question is + preceded by a short block telling you what the value does. Modeled + on `scripts/install_local.sh`'s narration style. +- **Tool auto-install.** Pick a provider whose CLI isn't installed and + the wizard surfaces the canonical install command (`npm install -g + …`, `curl -fsSL https://ollama.ai/install.sh | sh`, etc.) and asks + before running it. The same registry powers `/install` from inside + the TUI. +- **First-use configuration.** After install, every tool goes through + the same four-step shape: auth → upstream route → model → verify. + Pi prompts for its upstream provider (openrouter / anthropic / + openai / google) and the right env-var receives your API key. + Ollama runs `ollama list`, lets you pick a local model or pull a + new one with progress streaming. Re-run any time via `/configure + [tool]`. +- **GitHub integration.** The wizard offers to skip, link an existing + repo, or create a new private one. A hardened `.gitignore` and a + pre-push secret scanner protect against accidental `.env` / + API-key leaks. `CLK_GITHUB_PUSH_ON_COMMIT=true` makes each agent + commit push automatically. +- **Friendlier TUI.** First-run welcome banner, `/help` modal + overlay (F1 or `?`), state-aware hint bar above the input, + in-title USD cost estimate, narrative status snapshots, and + follow-on suggestions after every workflow and loop ("next: + `/loop ralph 5` to refine, `/undo` to revert, or type a follow-up + message"). The user always knows the next move. +- **Recoverability everywhere.** Atomic `.env` and JSON writes with + `.bak` rotation; `kickoff.sh --restore` swaps it back. Per-step + resume in the wizard via `.clk/.setup-progress`. Crashed-session + detection in the TUI surfaces "recovered from a crashed session" + and points at the preserved `conversation.md`. `/undo` reverts the + last clk-authored commit after explicit confirm. +- **`/doctor` and `/diag`.** Health-check every provider and config; + `--fix` prompts before repairing. `/diag` builds a redacted + tarball for bug reports — API keys are replaced with + ``. +- **`/tutorial`.** A 30-second sample idea against the `shell` + provider so first-time users see agents working end-to-end without + spending a cent. +- **Workspace management.** `./kickoff.sh --list`, `--clean 7d`, + `/workspaces` inside the TUI. Old kickoff dirs no longer pile up. +- **Always-confirm policy.** Every install, push, undo, ollama pull, + cost-cap crossing, or `--clean` removal asks `[y/N]` every single + time. There is no "remember my answer" setting — by design. + +See the **Recoverability**, **GitHub integration**, **Diagnostics**, +**Workspaces**, and **Cost guardrails** sections below for the full +walkthroughs. diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md new file mode 100644 index 0000000..50b2222 --- /dev/null +++ b/docs/CONFIGURATION.md @@ -0,0 +1,98 @@ +# Configuration + +Part of the [CLK documentation](../README.md). Cost guardrails, customization points, and dry-run mode. Provider/`.env` setup lives in [QUICKSTART](QUICKSTART.md), [WEBUI](WEBUI.md), and [KICKOFF](KICKOFF.md). + +## Cost guardrails + +Title-bar dollar cost is computed from the per-provider table in +`clk_harness/pricing.py`: + +| Provider | Default $/1k in | Default $/1k out | +|---|---|---| +| claude (sonnet-4-5) | $0.003 | $0.015 | +| claude (haiku-latest)| $0.0008 | $0.004 | +| claude (opus-latest) | $0.015 | $0.075 | +| codex (gpt-4o) | $0.0025 | $0.010 | +| codex (gpt-4o-mini) | $0.00015 | $0.0006 | +| codex (o1) | $0.015 | $0.060 | +| gemini (1.5-pro) | $0.00125 | $0.005 | +| gemini (1.5-flash) | $0.000075| $0.0003 | +| pi | $0.003 | $0.015 (blended default; override per route) | +| ollama / shell | $0.00 | $0.00 | + +**Override per project** by adding to `.clk/config/providers.json`: + +```jsonc +"providers": { + "pi": { + "type": "pi", + "pricing": { "input_per_1k": 0.002, "output_per_1k": 0.008 } + } +} +``` + +Or per model: + +```jsonc +"pricing_by_model": { "openrouter/free": { "input_per_1k": 0.0, "output_per_1k": 0.0 } } +``` + +`/status` prints the per-provider breakdown so you can see which +provider is eating the budget. Updated lazily from the same numbers +the title bar shows. + +### Robustness-loop multipliers + +The robustness loops (see **Robustness loops**) trade tokens for +quality. Use this table to pick a regime: + +| Knob | Worst-case multiplier per affected dispatch | Recommended starting point | +|----------------------------------------|--------------------------------------------------------------------------|----------------------------| +| `robustness.auto_consensus` | `off` → ×1; `on_careful` → ×(N+1) on careful stages only; `always` → ×(N+1) on every dispatch (where N = `consensus.max_samples`, default 6) | `on_careful` (default) | +| `robustness.auto_refine` | `off` → ×1; `careful_only` → ×(1 + 1 worker revision + 1 critic) on careful stages; `all` → that on every producing stage | `all` (default — every producing stage refines) | +| `robustness.max_quality_retries` | At most this many extra dispatches when a response fails the quality check; 0 disables | 4 (default) | +| `robustness.refine_max_rounds` | Cap on critic↔worker round-trips inside a refine loop | 10 (default) | +| `robustness.debate` | `off` → ×1; `careful_only` → an adversarial panel (one critic per lens) on careful stages; `all` → on every producing stage | `careful_only` (default) | +| `robustness.debate_lenses` | Adversarial lenses (one parallel critic each) — more lenses = more critic dispatches per round | `[correctness, security, simplicity]` | +| `robustness.debate_max_rounds` | Cap on debate rounds (panel critique + worker revision) per stage | 2 (default) | +| `robustness.max_qa_depth` | Cap on inter-agent Q&A chain depth (each peer answer can ask one peer) | 3 (default) | +| `robustness.plateau_window` | How many no-improvement Ralph/autoresearch iterations before escalation | 3 (default) | +| `robustness.plateau_action` | `off` disables adaptive loop termination entirely | `escalate_then_reframe` | + +Cost-minimal regime (closest to legacy CLK behavior, no extra tokens): + +```jsonc +"robustness": { + "auto_consensus": "off", + "auto_refine": "off", + "max_quality_retries": 0, + "plateau_action": "off" +} +``` + +Cost-maximal "lean into the loop" regime (every dispatch fans out, +critic gates every careful stage, plateau detection on, Q&A protocol +fully open): + +```jsonc +"robustness": { + "auto_consensus": "always", + "auto_refine": "all", + "max_quality_retries": 3, + "refine_max_rounds": 4, + "plateau_action": "escalate_then_reframe" +} +``` +## Customization + +- Edit prompts in `.clk/prompts/` to change agent behavior. +- Edit `.clk/config/agents.json` to bind specific agents to specific + providers (e.g. `engineer` -> `claude`, `researcher` -> `ollama`). +- Edit `.clk/config/workflows/*.yaml` to add new stages or new + workflows. Reference any new workflow with `clk run --workflow NAME`. +- `clk configure --set key=value` updates `.clk/config/clk.config.json`. +## Dry-run mode + +Every loop and workflow command accepts `--dry-run`. Providers honor it +and skip side effects. Use it to preview prompt rendering and stage +ordering without writing files or committing. diff --git a/docs/KICKOFF.md b/docs/KICKOFF.md new file mode 100644 index 0000000..6f36303 --- /dev/null +++ b/docs/KICKOFF.md @@ -0,0 +1,311 @@ +# Docker & kickoff + +Part of the [CLK documentation](../README.md). Running CLK from a container or a bare clone via `kickoff.sh`, and the workspace layout it creates. + +## Docker + +The harness ships with a `Dockerfile`. Kickoff directories are created under +`workspace/` inside the container; mount a volume there to keep them after +the container exits. + +The default mode is the interactive TUI dashboard — run with `-it` so the +container has a terminal. If no `.env` is present it will prompt for provider +and settings before launching. Pass your idea as the first argument to skip +the prompt and go straight to the engineering workflow. + +> **`install_local.sh` is not needed inside Docker.** The `Dockerfile` runs +> `pip install -e .` at image-build time, so all Python dependencies are +> already present. Keep `CLK_RUN_INSTALL=false` (the default) — setting it to +> `true` in a Docker environment would redundantly re-create a `.clk/venv` +> that the container doesn't need. + +All examples below assume the image is tagged `clk` locally — either +build it from source or pull a prebuilt image and re-tag it (see the next +two sections). + +### Build + +```bash +docker build -t clk . +``` + +### Pull from GHCR + +Prebuilt images are published to GitHub Container Registry on every push to +`main` (tagged `latest` and `main`), every semver tag (`vX.Y.Z` → `X.Y.Z`, +`X.Y`), and every commit (`sha-`): + +```bash +docker pull ghcr.io/billjr99/cognitiveloopkernel:latest +docker tag ghcr.io/billjr99/cognitiveloopkernel:latest clk +``` + +The `docker tag` step lets every later command in this README refer to the +image simply as `clk`. If you'd rather not re-tag, substitute +`ghcr.io/billjr99/cognitiveloopkernel:latest` for `clk` in the examples +below. + +### Configuration via .env + +`kickoff.sh` loads `/app/.env` at startup, so any setting that can be +configured via `CLK_*` env vars (provider, API keys, git identity, etc.) +can also live in a single file. There are two ways to provide it: + +**Bind-mount a host file at `/app/.env`** — recommended when you want the +setup wizard's edits to persist back to disk: + +```bash +touch ~/clk.env # create empty file first (Docker quirk) +docker run --rm -it \ + -v ~/clk.env:/app/.env \ + -v clk-workspace:/app/workspace \ + clk "My idea here" +``` + +**Pass it via `--env-file`** — simpler when the file is read-only config: + +```bash +docker run --rm -it \ + --env-file ~/clk.env \ + -v clk-workspace:/app/workspace \ + clk "My idea here" +``` + +The bind-mount approach is required if you want to use `--setup` (the wizard +writes back into `/app/.env`); `--env-file` only injects vars at start. + +### First-run setup + +Run the setup wizard to create your `.env`. The wizard is structured +as a series of **explain-then-ask** blocks — each section tells you +what the value does before asking for it, modeled on the +`scripts/install_local.sh` narration style. Sections (in order): + +1. **Provider** — pick the AI that writes code (`shell`, `claude`, + `codex`, `gemini`, `pi`, `ollama`, `openwebui`). One-liner per + choice. +2. **Loop settings** — max iterations, project name, install flag, + TUI/no-TUI. The **install flag** (`CLK_RUN_INSTALL`) controls + whether `scripts/install_local.sh` runs inside each kickoff + directory to create a local `.clk/venv`. **Leave it `false` + (the default) when running in Docker** — the image already has + all Python dependencies installed at build time, so the local + venv step is unnecessary. +3. **Auth mode** — only for CLI providers; `cli` reuses your local + `claude login` / `codex login` / `gemini login`, `apikey` + prompts for a key directly. +4. **Tool detection + auto-install** — checks whether the chosen + provider's CLI is on PATH; if not, surfaces the canonical install + command and asks before running it. Backed by + `scripts/install_tool.sh`'s registry — same commands the TUI's + `/install` uses. +5. **First-use configure** — auth → upstream route → model → + verify. Pi picks `openrouter` / `anthropic` / `openai` / `google` + and sets the right `{ROUTE}_API_KEY` env var. Ollama runs + `ollama list`, lets you pick a local model or pull a new one + (progress streamed). State recorded in + `.clk/state/configured-tools.json` so the wizard knows not to + re-prompt next time. +6. **Telegram** — same flow as before. Says yes here triggers the + dedicated bot wizard at `scripts/telegram_setup_wizard.sh`. +7. **GitHub** — optional remote (skip / existing / create); writes a + hardened `.gitignore` and a pre-push secret scan hook. See + [GitHub integration](TUI.md#github-integration). +8. **Git identity** — `CLK_GIT_NAME` / `CLK_GIT_EMAIL` for the + in-container fallback. + +**Atomic writes.** Every answer is persisted to `.env` immediately +via `env_set` (sourced from `scripts/lib_env.sh`). The previous +content rotates to `.env.bak`. If the wizard crashes mid-flow, the +next run looks at `.clk/.setup-progress` and offers to resume from +the last completed step. To undo a bad wizard run entirely, run +`./kickoff.sh --restore`. + +**Always-confirm.** Every install, push, ollama pull, and +destructive step asks `[y/N]` every single time. Pressing Enter +defaults to the safe option. + +```bash +# Create an empty config file on the host (once) +touch ~/clk.env + +# Run the wizard — writes into the bind-mounted file +docker run --rm -it \ + -v ~/clk.env:/app/.env \ + -v clk-workspace:/app/workspace \ + clk --setup +``` + +`--setup` also works locally (outside Docker) and updates `./kickoff.sh`'s +own `.env` in-place. + +### Run (interactive TUI — default) + +**Named volume** — kickoffs persist in a Docker-managed volume across runs: + +```bash +docker volume create clk-workspace + +docker run --rm -it \ + -v clk-workspace:/app/workspace \ + clk "A local-first journaling app that summarizes my week" +``` + +**Host directory** — kickoffs written directly to a directory on your machine: + +```bash +docker run --rm -it \ + -v /path/to/my/projects:/app/workspace \ + clk "A local-first journaling app that summarizes my week" +``` + +**Anonymous volume** — Docker allocates a temporary volume that is +automatically removed when the container exits (`--rm` handles cleanup): + +```bash +docker run --rm -it \ + -v /app/workspace \ + clk "A local-first journaling app that summarizes my week" +``` + +**Ephemeral** — no explicit volume mount; Docker creates an anonymous volume +for `/app/workspace` (declared in the image) and removes it with `--rm`: + +```bash +docker run --rm -it clk "A local-first journaling app that summarizes my week" +``` + +### Provider and authentication + +Pass any `CLK_*` variable or API key with `-e`: + +```bash +docker run --rm -it \ + -v clk-workspace:/app/workspace \ + -e CLK_PROVIDER=claude \ + -e CLK_AUTH_MODE=apikey \ + -e ANTHROPIC_API_KEY=sk-ant-... \ + clk "A local-first journaling app that summarizes my week" +``` + +For the `pi` provider with an OpenRouter key: + +```bash +docker run --rm -it \ + -v clk-workspace:/app/workspace \ + -e CLK_PROVIDER=pi \ + -e CLK_PI_MODEL=openrouter/free \ + -e CLK_PI_KEY_TYPE=openrouter \ + -e CLK_PI_API_KEY=sk-or-... \ + clk "A local-first journaling app that summarizes my week" +``` + +For `ollama` or `openwebui` running on the host, use `host.docker.internal` +as the endpoint (macOS/Windows) or `--network host` (Linux): + +```bash +docker run --rm -it \ + -v clk-workspace:/app/workspace \ + -e CLK_PROVIDER=ollama \ + -e CLK_OLLAMA_ENDPOINT=http://host.docker.internal:11434 \ + clk "My idea" +``` + +### Non-interactive / CI mode + +For scripted or CI use, skip the TUI entirely. The pipeline runs +`init → idea → plan → run → loop` without any curses UI: + +```bash +docker run --rm \ + -v clk-workspace:/app/workspace \ + -e CLK_NO_TUI=true \ + -e CLK_PROVIDER=claude \ + -e CLK_AUTH_MODE=apikey \ + -e ANTHROPIC_API_KEY=sk-ant-... \ + clk "A local-first journaling app that summarizes my week" +``` + +### Run the REST API + +To run the [REST API](REST_API.md#rest-api) server inside the container instead of the +TUI, override the entrypoint command: + +```bash +docker run --rm -p 8001:8001 \ + -v clk-workspaces:/workspaces \ + clk python -m clk_harness.api +``` + +Mount `/workspaces` to persist workspace *directories* across container +restarts. + +> **Note: workspace state is in-memory and is NOT recoverable after restart.** +> Even when the `/workspaces` volume is mounted, the in-memory registry of +> workspace IDs and task history is lost every time the container restarts. +> The files inside `/workspaces` survive on disk, but you must create new +> workspace registrations via `POST /api/workspaces` after each restart — +> previous workspace IDs and task IDs will not be recognised by the new +> container instance. + +Override the workspace root with `CLK_WORKSPACES_DIR`. +## Layout + +The kickoff dir lays the agents' work out as a normal project tree +with all harness machinery folded under `.clk/`: + +``` +workspace/kickoff-/ + src/, tests/, README.md ... # the project the agents are building + # (agents write directly to project root) + scripts/clk # convenience launcher shim + KICKOFF.md # provenance manifest + .gitignore # hardened — blocks .env, .env.bak, *.pem, … + .git/hooks/pre-push # secret scanner; aborts on key patterns + .clk/ # ALL harness state — sandboxed off + .setup-progress # per-step resume marker for the wizard + harness/clk_harness/ # harness sources copied from parent + harness/scripts/ # original launcher / installer + harness/pyproject.toml # package metadata for pip install -e + config/ # clk.config.json, providers.json, agents.json + # each written atomically with a .bak rotation + state/ # idea.json, prd.json, decisions.md ... + # plus: + # .seen-welcome first-run banner marker + # .seen-tutorial /tutorial done marker + # .tui-active PID lock (crashed-session detection) + # configured-tools.json which tools have had configure_tool run + # session-cost.json persisted USD totals + prompts/ # per-agent system prompts + blackboard/ # cross-agent shared scratchpad (POST blocks land here) + runs/ # per-dispatch prompt + response logs + backups/ # pre-write copies of mutated files + cache/, logs/, venv/ # local-only artifacts +``` + +The repo root also adds: + +- `scripts/lib_env.sh` — shared atomic-write helpers (`env_set`, + `env_get`, `env_atomic_write`, `env_restore`) sourced by both + wizards. +- `scripts/install_tool.sh` — install + check + configure registry + for every supported tool. Used by `kickoff.sh --setup` and by the + TUI's `/install` / `/configure` commands. +- `clk_harness/pricing.py` — per-provider USD pricing table backing + the title-bar cost estimate. +- `~/.config/clk/github-token` — when present (chmod 600), used in + place of the `gh` CLI for GitHub operations. + +ACTION blocks resolve relative to the project root. The harness rejects +any path that resolves into `.clk/` so agents can't accidentally (or +intentionally) write into harness state. `run` commands cwd into the +project root. To share findings across agents, workers emit POST +blocks; the harness routes those into `.clk/blackboard/` even though +agents cannot write there directly. + +The kickoff `.gitignore` keeps `.clk/` out of git except for the +curated state files (`idea.json`, `system_brief.md`, `prd.json`, +`decisions.md`, `progress.md`, `casting.log`, `done.md`, plus the +blackboard) so `git log` in the kickoff dir tells the project's story +without harness chatter. Deleting `.clk/` resets the harness without +touching the project tree. diff --git a/docs/MISSIONS.md b/docs/MISSIONS.md new file mode 100644 index 0000000..45b92d7 --- /dev/null +++ b/docs/MISSIONS.md @@ -0,0 +1,553 @@ +# Autonomous missions & orchestration + +Part of the [CLK documentation](../README.md). The mission lifecycle, chief supervisor, dynamic casting, the action protocol, workflows, loops, robustness layers, and completion criteria. + +## Autonomous missions + +Give CLK one objective and it drives the **whole lifecycle to a code-gated +"done" on its own** — no babysitting with separate `plan` / `run` / `loop` +commands. This is the plan→execute→evaluate→refine→iterate loop made +*reliable*: the guarantees that used to live only in prompt text are now +enforced in the harness. + +**Autonomy is the default.** `clk run` (and the TUI's first message, and the +web/REST single-prompt flow) drive the full mission. `clk mission ""` +(alias `clk auto`) is the explicit form; `clk run --once` restores the legacy +single-workflow pass. + +```bash +clk mission "a local-first journaling app that summarizes my week" +# charter → plan → discovery → product → engineering(+ralph) → validation +# → deployment → done-gate → done_granted.md +``` + +### Charter first, then a living plan + +1. **Charter** — before any work, the chief authors `.clk/state/charter.md` + (+ `charter.json`): mission statement, scope, explicit non-goals, success + criteria, constraints. The plan and the done-gate are *derived from it*, so + "done" is judged against an up-front commitment instead of drifting. +2. **Living plan** — the chief emits a `PROPOSE_PLAN` block; the harness + persists an ordered phase plan to `.clk/state/mission.json` (human mirror + `MISSION.md`). After each phase a chief **phase-gate** returns + `pass | repeat | revise | done`; `revise` re-plans the remaining phases, so + the chief can reorder, insert, or skip phases as it learns. + +Three nested loops: the per-stage **supervise/ralph** loops (unchanged) inside a +per-phase **repeat** loop inside the **phase-sequencing** loop — bounded by +`mission.max_phases` and `mission.max_total_cycles`. + +### Reliability reinforcements (enforced in code, not prose) + +- **Machine-checkable done-gate.** `ACTION:done` / `done.md` is now a *request*. + The loop only stops when `done_gate.evaluate_done_gate` passes and writes + `.clk/state/done_granted.md`. Default checks: tests green, deliverables on + disk, a `POST: qa` PASS, a ralph refinement pass, plus any file-named charter + success criteria. **Adaptive:** the tests-green check is auto-relaxed when no + real test command can be derived (docs / research / content projects), so the + gate is strict where it's meaningful but never deadlocks. Each `require_*` is + an independent switch; `done_gate.enabled: false` restores legacy behavior. +- **No-op guard.** A producing stage (engineer/ralph, or any stage with an + `outputs` contract) whose response changed **no files** is re-dispatched with + an escalating "emit ACTION blocks now" preamble — descriptions stop counting + as work. Kill: `noop_guard.enabled: false`. +- **Evaluation never silently skips.** A producing stage with no `validation:` + no longer auto-passes — the harness derives a real command from the project + shape (`pytest` / `npm test` / a `compileall` smoke). Kill: + `validation.auto_derive: false`. +- **Ungated refinement.** `robustness.auto_refine` defaults to `all`, so every + producing stage gets a critic pass without the chief having to mark it + `careful`. +- **Deliberation — the team thinks.** Producing dispatches get a + self-reflect preamble and an invitation to ask peers + `POST: question TO: URGENCY: blocking`; a phase gate cannot `pass` + while a blocking question is unanswered. Kill: `deliberation.enabled: false`. +- **Execution trace.** Structured `[clk:charter|plan|phase-start|phase-gate:*|done]` + commits land at every boundary, so the git log *is* the audit trail. Kill: + `mission.commit_trace: false`. +- **Observability.** Each cycle prints a one-line summary and writes a + `loop_cycle_summary` event to `.clk/logs/activity.jsonl`, e.g. + `cycle 3/60 | phase engineering | stages 5 (4 ok) | actions 7 | refine 1r | eval FAIL(2) | done-gate REJECT(no_qa_pass)`. + +### Mission config (`clk.config.json`) + +```jsonc +"mission": { "max_phases": 12, "max_iterations_per_phase": 3, + "max_total_cycles": 60, "phase_gate": true, + "refine_required": true, "charter_first": true, + "commit_trace": true, "telemetry_stdout": true, + "default_phases": ["discovery","product","engineering","validation","deployment"] }, +"done_gate": { "enabled": true, "require_tests_green": true, + "require_deliverables": true, "min_deliverable_files": 1, + "require_qa_pass": true, "require_ralph_pass": true, + "forbid_todo_markers": false }, +"noop_guard": { "enabled": true, "max_redispatch": 2, + "producing_agents": ["engineer","ralph"] }, +"deliberation": { "enabled": true, "encourage_questions": true, + "require_open_questions_resolved": true, + "self_reflect_preamble": true, "min_debate_rounds": 1 } +``` + +Every knob above is also overridable from the environment via the matching +`CLK_MISSION_*`, `CLK_DONE_GATE_*`, `CLK_NOOP_GUARD_*`, and +`CLK_DELIBERATION_*` variables (see `.env.example`; `kickoff.sh` maps them into +`clk.config.json`). CLI overrides: `clk run --max-phases N --max-cycles M`, +`clk run --once`, `clk mission "" --resume` (continue a persisted plan). + +## Chief supervisor loop + +The default `engineering` workflow ends with a `supervise` stage where +the chief evaluates whether the user's prompt has been fully addressed. +The chief either: + +- emits `ACTION: done` with a one-line reason — writes + `.clk/state/done.md` and terminates the loop, or +- emits `PROPOSE_WORKFLOW` with the next iteration's stages — the + workflow runner picks them up and runs another cycle. + +The prompts enforce an explicit asymmetry: a **low bar to continue** +(any single trigger — missing tests, no ralph pass on the latest +output, open TODOs, stale docs, any nameable improvement — starts the +next cycle immediately) and a **high bar to stop** (`ACTION: done` +requires every done-checklist item: deliverables on disk, tests +passing, a QA PASS, a ralph refinement pass, docs updated). So no agent +is ever truly "done" until the chief proves completion. Capped at +`clk.config.json::supervise.max_cycles` (default 100). + +Stall handling: a cycle with no commits, no file writes, and/or an +explicit `PROGRESS: no` self-report counts against +`supervise.max_consecutive_no_progress` (default 8). Hitting the cap +dispatches the chief once in **stall-rescue** mode (restructure the +plan, unblock, or justify done) before the loop gives up — disable via +`supervise.stall_rescue: false`. + +## Dynamic agents (casting) + +The harness ships with three baseline agents that cannot be removed: + +- `chief` — decomposes objectives, casts the team, authors workflow YAML. +- `qa` — default validator. +- `ralph` — drives both the Ralph refinement loop and Karpathy-style + autoresearch cycles; the mode is inferred from the current project state. + +Everything else is dynamic. On the first user message, the chief is +auto-dispatched with the captured idea and casts the project-specific team, +including `engineer` when an implementer is needed (e.g. `data_steward`, +`ml_evaluator`, `api_contract`, `ux_writer`, `security_auditor`). + +The name `engineer` is reserved: the harness actively rejects any attempt +to create `engineering`, `coder`, `developer`, or other aliases, and +reports the denial directly to the chief via its `$casting_feedback` context +so it learns to use `engineer` directly. Each role decision is +applied immediately and persisted to `.clk/config/agents.json` plus +`.clk/state/casting.log` (JSONL, one entry per add/update/remove). + +Type `/cast` in the TUI to force a re-cast at any time, or run +`clk cast` from the CLI. To inspect or edit by hand: +`clk roles list|add --name X --role "..."|remove --name X`. + +Agents communicate via a **blackboard** at `.clk/blackboard/` — short +markdown POST blocks each agent emits at the end of its run, filtered +into peers' prompts based on each stage's `inputs:` selectors. +Directed clarifying questions are a special POST type +(`POST: question TO: URGENCY: blocking`) routed inline by the +harness — see **Robustness loops** for the protocol details and depth +caps. + +## Action protocol + +Agents drive real changes by emitting `ACTION:` blocks the harness +parses and applies — descriptions alone do nothing. Supported kinds: + +- `ACTION: write` / `edit` / `append` / `delete` — file mutations + (paths must resolve inside the project root; originals are backed up + to `.clk/backups//`). Paths are resolved **chroot-style**: a + leading `/` maps to the project root and a fully-qualified workspace + path has the root prefix stripped, so agents that emit absolute paths + don't silently lose their work. Escapes (`../`) and `.clk/` stay + rejected. +- `ACTION: run` — shell command, runs in project root, output captured + to the log; rejects `sudo` and obvious-foot-gun patterns. +- `ACTION: done` — writes `.clk/state/done.md`, signaling the loops to + stop. + +Every agent run that mutates files produces an immediate structured +git commit (`[agent] objective` with files, commands, token totals in +the body). A cap from `clk.config.json::validation.max_files_per_batch` +(default 25) limits damage from a runaway agent. + +## Self-healing on unmet deps + +When a workflow stage's dependencies fail, the harness dispatches the +chief in *recovery mode* with the exact failure reasons (agent error, +validation output) and asks them to either re-cast the workflow, +emit `ACTION` blocks that fix the upstream failure, or `PROPOSE_ROLE` +a specialist that can. Capped at 3 recovery passes per stage +(configurable via `clk.config.json::recovery::max_per_stage`). + +Two more recovery paths run automatically: + +- **Unmet outputs contracts.** When a stage's declared `outputs:` keys + never land in any POST block's `PRODUCES` line, the chief gets a + recovery dispatch to fill the gap (re-dispatch the worker, post a + substitute, or accept it) instead of letting downstream stages consume + missing inputs. Toggle via `recovery.dispatch_on_unmet_outputs`. +- **Failed validations keep the work.** A failed stage validation no + longer hard-resets the workspace by default — the failure is recorded + and later cycles repair forward, so batch-committed files stay on disk + and visible in the Files tab. Policy via + `validation.rollback_on_failure`: `never` | `careful` (default — only + `careful: true` stages roll back) | `always` (legacy). When a rollback + does run, the discarded work is first preserved behind a + `refs/clk/rollbacks/-` ref so it stays recoverable in git. + +This section is about *dependency and stage* failures. *Content* +failures — empty, malformed, or low-confidence agent output that +nonetheless returned `ok=True` — are handled by the response-quality +re-dispatch loop documented in **Robustness loops** above. + +## Workflows + +YAML workflows live in `.clk/config/workflows/`. The default +`engineering.yaml` is intentionally minimal (chief → engineer → qa); +the chief overwrites it on first cast with a project-tailored cycle. +The bundled scaffolds: + +- `discovery.yaml` - validate problem, users, landscape. +- `product.yaml` - PRD + technical architecture. +- `engineering.yaml` - baseline cycle; chief replaces this per project. +- `validation.yaml` - drive toward a green test suite. +- `deployment.yaml` - deployment recipe + checklist. +- `ralph_loop.yaml` - single Ralph iteration (use `clk loop` to repeat). + +Stage schema: + +```yaml +- id: implement + agent: engineer + objective: Implement the smallest vertical slice. + depends_on: [architect] + validation: "pytest -q" + commit: true +``` + +When `validation` is set, the command must exit 0 before the harness +will commit. Failed validations leave the working tree untouched (and +in the Ralph loop, are reverted to the pre-iteration HEAD). + +## Loops + +Ralph runs in two modes (selected automatically based on project state, +or forced via `/loop`): + +- **Refinement mode (`/loop ralph N`, default).** Each iteration: ralph + picks one measurable improvement, the engineer implements it, QA + validates, and the harness commits or reverts. +- **Autoresearch mode (`/loop autoresearch N`).** Each iteration: ralph + surveys state, picks the highest-value open question, designs and runs + a small experiment, and records the learning regardless of pass/fail. + +Both modes respect `max_iterations` and stop early when +`.clk/state/done.md` is created. Both also auto-detect plateau and +regression and adapt — see **Robustness loops** below. + +## Robustness loops + +CLK leans into the loop: every dispatch is wrapped in self-correcting +behavior so the harness does not just accept the first thing a +sub-agent returns. This section is a single index of every loop the +harness runs — old and new — with the config knob that tunes each +one and the activity-log event you can grep for in `.clk/logs/`. + +All knobs live under `clk.config.json::robustness.*` (and the +parallel `CLK_ROBUSTNESS_*` env-var family — see `.env.example`). +Every layer has an off-switch so you can throttle cost. + +### 1. Provider retry (existing) + +Transient provider errors (rate limits, timeouts, "no endpoints +available", HTTP 429) are retried with exponential backoff before the +response surfaces at the workflow layer. + +- Code: `clk_harness/orchestration/agent.py::AgentRunner._should_retry_provider` +- Config: `clk.config.json::provider_retry.{max_retries, backoff_s}` +- Logged events: `provider_attempt`, `provider_retry` +- Kill switch: set `provider_retry.max_retries: 0` + +### 2. Stage retry (existing) + +When a workflow stage fails with a retryable provider error after the +inner provider-retry budget is exhausted, the workflow runner retries +the entire stage with a larger backoff before giving up on the stage. + +- Code: `workflow.py::WorkflowRunner._is_retryable_stage_error` +- Config: `clk.config.json::provider_retry.{stage_max_retries, stage_backoff_s}` +- Logged events: `workflow_stage_retry` +- Kill switch: set `provider_retry.stage_max_retries: 0` + +### 3. Supervise cycles (existing) + +The chief's `supervise` stage decides whether the user's prompt has +been fully addressed; if not, it emits a `PROPOSE_WORKFLOW` and the +whole workflow re-runs. See **Chief supervisor loop** for the full +description. + +- Config: `clk.config.json::supervise.max_cycles` (default 20) +- Kill switch: set `supervise.max_cycles: 1` + +### 4. Recovery on unmet deps (existing) + +When a stage's dependencies fail, the chief is dispatched in recovery +mode to re-cast, remediate, or accept the gap. See **Self-healing on +unmet deps**. This handles *dependency* failures; *content* failures +are handled by Layer 6 below. + +- Config: `clk.config.json::recovery.max_per_stage` (default 3) + +### 5. Review & checkpoint stages (existing) + +Stages marked `phase: review` automatically receive a chief-authored +review prompt containing the upstream stages' POST blocks, and the +chief emits a verdict (continue / redirect / abort). Stages marked +`careful: true` add a post-stage checkpoint and (when configured) +trigger meta-prompt drafting on dispatch. + +Example: + +```yaml +- id: design_spec + agent: architect + careful: true + outputs: [design_brief] + objective: Draft the API contract. +- id: review_design + agent: chief + phase: review + depends_on: [design_spec] +``` + +- Config: `clk.config.json::review.per_stage` (apply to *every* stage) +- Logged events: `workflow_checkpoint`, `consensus_coalesced` + +### 6. Auto-quality re-dispatch (new) + +After every dispatch, the response is scored against +`response_quality`: + +- empty / sub-threshold text +- malformed `ACTION:` or `POST:` blocks +- missing declared `outputs` (the stage's contract keys) +- self-reported low confidence (`CONFIDENCE: <0..1>` parsed from the + response) +- refusal patterns (treated as not-recoverable — surfaces to the + chief instead of retrying blindly) + +Recoverable failures are re-dispatched with a repair preamble that +quotes the specific reasons back to the worker, up to +`robustness.max_quality_retries`. On the final retry, when +`auto_consensus` is not `"off"`, the dispatch escalates to a +stochastic consensus fan-out rather than another single-shot retry. + +- Code: `orchestration/response_quality.py`, `agent.py::_dispatch_with_quality_loop` +- Config: `robustness.{max_quality_retries, min_response_chars}` +- Logged events: `agent_quality_retry`, `agent_quality_final` +- Kill switch: `robustness.max_quality_retries: 0` + +### 7. Stochastic consensus, opt-in + automatic (existing + new) + +Any agent can emit `PROPOSE_CONSENSUS` to fan a question into N +independent samples; the harness runs them in parallel, logs them, +and dispatches the chief to coalesce. New in this release: +`robustness.auto_consensus` makes the fan-out automatic. + +| `auto_consensus` | Behavior | +|--------------------------|--------------------------------------------------------------------------| +| `off` | Only `PROPOSE_CONSENSUS` triggers fan-out (legacy behavior). | +| `on_careful` *(default)* | Stages marked `careful: true` fan out automatically. | +| `always` | Every non-chief dispatch fans out (×N samples — most expensive setting). | + +Cost: a fan-out costs roughly N + 1 dispatches (N samples + 1 chief +coalescing). Caps at `consensus.max_samples` (default 6) and +`consensus.max_parallel` (default 4). + +- Logged events: `consensus_started`, `consensus_sample_dispatch`, + `consensus_samples_completed`, `consensus_coalesced` +- Kill switch: `robustness.auto_consensus: "off"` + +### 8. Inter-agent clarifying Q&A (new) + +Agents emit: + +``` +POST: question +TO: architect +URGENCY: blocking +BODY: +Are user IDs opaque strings or integers? +END_POST +``` + +With `URGENCY: blocking`, the harness dispatches the target peer +immediately to answer; the peer's `POST: answer` lists the question +id in its `CONSUMES`, and the asker sees the answer in the next +blackboard digest. `URGENCY: async` records the question for the +chief to schedule in a later cycle. + +Chain depth is capped at `robustness.max_qa_depth` (default 3) so a +question can't trigger an unbounded chain of clarifications. + +- Code: `agent.py::_route_blocking_questions`, `blackboard.py` +- Config: `robustness.{max_qa_depth, qa_parallel_judges}` +- Logged events: `qa_dispatch`, `qa_chain_capped`, `qa_chain_cycle`, + `qa_target_unknown` +- Kill switch: omit the `TO:` field in your `POST: question` blocks; + no protocol-level off-switch (Q&A is opt-in per post). + +### 9. Critic-judge refinement (new) + +Stages may declare a refinement loop that threads a critic between +worker rounds. The critic scores the worker's output 0..1; if below +the accept threshold, the worker is re-dispatched with the critic's +feedback until accept or `max_rounds` is reached. + +```yaml +- id: design_spec + agent: architect + refine: + critic: critic + max_rounds: 4 + accept_threshold: 0.8 + objective: Draft the spec. +``` + +When the stage has no explicit `refine:` block, `robustness.auto_refine` +decides whether one round runs anyway: + +| `auto_refine` | Behavior | +|----------------------------|---------------------------------------------------------| +| `off` | Only stages with `refine:` use the inner loop. | +| `careful_only` | Stages marked `careful: true` get one critic pass. | +| `all` *(default)* | Every non-chief, non-qa, non-critic stage gets a critic pass — so refinement fires without relying on the chief marking stages careful. | + +The critic's last two lines must be: + +``` +VERDICT: accept # or `revise` +SCORE: <0..1> +``` + +- Code: `workflow.py::WorkflowRunner._refine_loop` +- Config: `robustness.{auto_refine, refine_max_rounds, + refine_accept_threshold}` +- Logged events: `refine_critic_verdict` +- Kill switch: `robustness.auto_refine: "off"` AND remove any + `refine:` blocks from your workflow YAML. + +### 10. Adaptive Ralph & autoresearch (new) + +Both loops record every iteration's outcome to +`.clk/state/experiments.jsonl`. After `robustness.plateau_window` +consecutive iterations without measurable improvement, the loop: + +1. **Escalates** — the next iteration's dispatches carry + `careful=true` in their extra, which (via Layer 7) fans them into + stochastic consensus. +2. **Reframes** — the chief is dispatched with a "plateau dispatch" + prompt asking it to re-cast roles or re-author the workflow with a + qualitatively different approach (new metric, new experiment + family) rather than another marginal tweak. +3. **Terminates gracefully** — if escalation + reframe fail to break + the plateau across two more iterations, `done.md` is written with + reason "plateau" rather than burning the full iteration budget. + +Regression (last iteration failed after at least one earlier success +in the window) triggers an additional critic dispatch on the failing +diff before the next plan, so the next iteration starts from an +informed view of what broke. + +Autoresearch additionally gains an evaluator gate (previously only in +Ralph): if the analyst's writes break the build, the working tree is +reverted rather than committed. + +Both loops also short-circuit when a planner or surveyor returns +empty / unrecoverable output; rather than commit garbage, the +iteration is recorded with `improved=False`. + +- Code: `ralph_loop.py::RalphLoop`, `autoresearch_loop.py::AutoresearchLoop` +- Config: `robustness.{plateau_window, plateau_action}` + (`escalate_then_reframe` | `escalate_only` | `reframe_only` | `off`) +- Logged events: `ralph_plateau_detected`, `ralph_plateau_escalate`, + `ralph_plateau_terminated`, `ralph_regression_detected`, + `ralph_iteration_skipped_low_quality`, + `autoresearch_step_skipped_low_quality`, `autoresearch_revert` +- Kill switch: `robustness.plateau_action: "off"` + +### 11. Adversarial debate panel (new) + +Instead of a single critic, a stage can be refined by a **panel of +adversarial critics** that each take a distinct lens, try to *break* the work, +and engage with each other across rounds before the worker revises. This +catches failure modes a single reviewer misses (a correctness reviewer won't +think like a security reviewer). + +Each round fans out one critic per lens **in parallel** (reusing the +`critic` agent with a lens-specific adversarial prompt); the worker output is +kept only when a **majority of lenses accept** and the mean score clears +`refine_accept_threshold`. Otherwise the combined critiques drive a revision, +and the next round's critics see the prior panel transcript (posted to the +blackboard as `post_type: debate`) so they can reinforce, refute, or concede +each other's points. Bounded by `debate_max_rounds`. + +```yaml +- id: implement + agent: engineer + refine: + mode: debate + critics: [correctness, security, performance] + max_rounds: 2 + objective: Implement the slice. +``` + +When a stage has no explicit `refine: {mode: debate}`, `robustness.debate` +decides whether the panel runs anyway (`off` | `careful_only` *(default)* | +`all`). The debate panel takes precedence over the single-critic loop +(layer 9) when both would apply. Built-in lenses: `correctness`, `security`, +`simplicity`, `performance`, `robustness`, `tests`, `ux` (configure via +`robustness.debate_lenses`). + +- Code: `workflow.py::WorkflowRunner._debate_loop` / `_dispatch_lens_critic` +- Config: `robustness.{debate, debate_lenses, debate_max_rounds}` +- Logged events: `debate_round` +- Kill switch: `robustness.debate: "off"` AND remove any `refine: {mode: debate}` blocks. + +### Putting it together + +A typical "careful" engineering stage now runs: + +1. Stage dispatched with `careful: true`. +2. `auto_consensus=on_careful` → N samples fan out in parallel. +3. Chief coalesces the samples. +4. `auto_refine=all` (default) → critic scores the coalesced output; + the worker is revised until critic accepts or `max_rounds`. +5. Stage validation runs. +6. Checkpoint (if enabled) — chief CONTINUE / REDIRECT / ABORT + verdict. +7. Outputs contract check; warn if any declared key was not posted. + +Tracing this in `.clk/logs/`: + +``` +grep -E '^(consensus_|refine_|workflow_checkpoint|agent_quality_)' \ + .clk/logs/activity.jsonl | jq . +``` +## Completion criteria + +CLK considers the system "done" when `.clk/state/done.md` exists. By +convention you create it only when: + +- the MVP runs locally, +- the test suite passes, +- the README explains setup, +- a deployment plan exists, +- a deployment checklist exists, +- at least one user-facing interaction path exists. diff --git a/docs/PROVIDERS.md b/docs/PROVIDERS.md new file mode 100644 index 0000000..325423b --- /dev/null +++ b/docs/PROVIDERS.md @@ -0,0 +1,246 @@ +# Providers + +Part of the [CLK documentation](../README.md). Provider adapters, authentication modes, Ollama, the Pi provider, and the Pi extension. + +## Providers + +| Provider | Detection | Notes | +|-------------|------------------------------------------| +`shell` | always available | dummy; echoes prompts and writes stub files. Use for tests, CI, dry runs. | +| `claude` | `claude` on PATH | runs `claude --print` non-interactively. Add `"args": ["--print", "--output-format", "json"]` to `providers.json` to get real token counts. | +| `codex` | `codex` on PATH | runs `codex exec`. | +| `gemini` | `gemini` on PATH | runs the Google Gemini CLI; prompt fed on stdin. | +| `pi` | `pi` on PATH or `.clk/tools/pi/bin/pi` | pi.dev terminal harness; supports model selection, OpenRouter, and any API-key provider. See below. | +| `ollama` | TCP reachable at `endpoint` | local-only LLM via HTTP. **Use a ≥14B model** (e.g. `qwen3:14b`) — see [Ollama provider](#ollama-provider) for why. | +| `openwebui` | TCP reachable at `endpoint` | any OpenAI-compatible server. Configure `endpoint`, `api_key`, `model` in `providers.json`; kickoff offers a numbered model picker fetched from `/api/models`. | + +`./scripts/clk providers` prints availability as JSON. Customize per +provider in `.clk/config/providers.json`. + +### Authentication: CLI vs API key + +For the CLI-driven providers (`claude`, `codex`, `gemini`) you can +choose how authentication works at kickoff: + +- `CLK_AUTH_MODE=cli` (default) — spawn the provider's local CLI as a + subprocess and trust whatever auth that CLI already has. If you've + run `claude login` / `codex login` / Gemini sign-in, no API key is + required and kickoff will *not* prompt for one. Persisted to + `providers.json` as `"mode": "cli"`. +- `CLK_AUTH_MODE=apikey` — call the upstream HTTP API directly (no + local CLI is spawned at all). Kickoff prompts for the standard env + var (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GEMINI_API_KEY` / + `GOOGLE_API_KEY`) and stores it in `providers.json` along with + `"mode": "api"`. Each provider has a built-in HTTP client (Anthropic + Messages, OpenAI Chat Completions, Gemini `generateContent`). + +The other providers don't need this knob: `shell` and `ollama` are +local, `openwebui` uses an explicit bearer token, and `pi` has its own +authentication model described below. + +### Ollama provider + +Ollama is local and free — no API key, no rate limits — which makes +it tempting to default to. The catch is that **CLK asks the chief to +emit machine-parseable YAML workflows**, and small open-weight models +(≤8B parameters) are inconsistent at this. Specifically, the chief +will occasionally produce a `PROPOSE_WORKFLOW` block where a list +item contains an unquoted colon (e.g. `[type:finding, +stage:create_file]`), which YAML can't parse. + +What you'll see when this happens: + +``` +[workflow] PROPOSE_WORKFLOW parse failed: mapping values are not +allowed here :: keeping prior workflow +[supervise] cycle N/M no progress (workflow still has zero new stages) +``` + +The harness handles this safely — it detects the bad YAML, refuses +to clobber the existing workflow file, falls back to the bundled +engineering template, and the supervise loop keeps the run alive +until its cap. But the visible symptom is a loop that "spins" without +forward progress, which is frustrating. + +**Recommendation: use `qwen3:14b` or larger as the minimum.** It +follows the structured-output instructions reliably enough that the +chief's proposals parse on the first try. Pull it with: + +```bash +ollama pull qwen3:14b +``` + +Other ≥14B options that work well: `llama3.1:70b`, `qwen2.5-coder:32b`, +`deepseek-r1:14b`. Models ≤8B (`llama3.2`, `gemma2`, `qwen2.5:7b`, +`phi3`) are fine for chat but flaky for workflow generation — they'll +get through some cycles cleanly but fail the YAML contract often +enough that the loop won't make steady progress. + +Memory rule of thumb: a 14B Q4 model needs ~10 GB of RAM/VRAM; 32B +needs ~20 GB; 70B needs ~40 GB. The setup wizard's ollama section +streams `ollama pull` progress so you can see download size before +it lands. + +### Pi provider + +`pi` (from [pi.dev](https://pi.dev)) is an extensible terminal +harness. CLK drives it as a subprocess, piping the prompt on stdin and +capturing stdout as the agent response. + +**Model selection** + +Pass a model to `pi` via `CLK_PI_MODEL`: + +```bash +CLK_PI_MODEL=openrouter/free # free tier via OpenRouter +CLK_PI_MODEL=openrouter/auto # let OpenRouter pick the best available free model +CLK_PI_MODEL=anthropic/claude-3-5-sonnet # specific model via OpenRouter +``` + +Leave `CLK_PI_MODEL` blank to use pi's own active profile or default. +The value is forwarded to pi as `pi --model `. + +**API keys** + +Pi reads provider-specific environment variables — one per backend. +Two settings control this: + +| Setting | Purpose | +|---|---| +| `CLK_PI_KEY_TYPE` | The provider your key belongs to (default: `openrouter`) | +| `CLK_PI_API_KEY` | The actual key value | + +The harness derives the env var name by convention: +`{CLK_PI_KEY_TYPE.upper()}_API_KEY`. So: + +| `CLK_PI_KEY_TYPE` | Env var set for pi | +|---|---| +| `openrouter` | `OPENROUTER_API_KEY` | +| `openai` | `OPENAI_API_KEY` | +| `anthropic` | `ANTHROPIC_API_KEY` | +| `mistral` | `MISTRAL_API_KEY` | +| any future provider | `{NAME}_API_KEY` automatically | + +This means new providers require no code changes — just set +`CLK_PI_KEY_TYPE` to the provider name and `CLK_PI_API_KEY` to your key. + +Leave `CLK_PI_API_KEY` blank if you have already run `pi login` and pi +has its own stored credentials. + +**Interactive pi setup** + +If you need to run `pi login`, configure a profile, or verify your +setup interactively, kickoff offers to open pi's TUI before launching +the harness. You'll be prompted at the end of the pi configuration +questions during both `--setup` and a normal kickoff run (when `pi` is +on PATH). Exit pi normally when done and kickoff will continue. + +This is useful for first-time Docker sessions where pi has no stored +credentials yet: + +```bash +# Run the setup wizard — it will offer to open pi if found on PATH +./kickoff.sh --setup +``` + +Pi's own state (credentials, profiles) is stored in pi's own config +directory (e.g. `~/.pi/`) — no extra Docker volume is required for +CLK's harness state, but if you want pi credentials to persist across +container restarts, mount the pi config directory: + +```bash +docker run --rm -it \ + -v ~/.pi:/root/.pi \ + -v clk-workspace:/app/workspace \ + -e CLK_PROVIDER=pi \ + -e CLK_PI_MODEL=openrouter/free \ + -e CLK_PI_KEY_TYPE=openrouter \ + -e OPENROUTER_API_KEY=sk-or-... \ + clk "My idea" +``` + +Alternatively, pass the API key directly via `CLK_PI_API_KEY` and skip +`pi login` altogether — kickoff will set the right env var for you. +## Pi extension + +A native [pi.dev](https://pi.dev) extension that brings the full CLK +orchestration model — dynamic casting, stochastic consensus, Ralph +refinement, and Karpathy-style autoresearch — into Pi behind a single +`/clk` command. No Python harness required at runtime. + +The TypeScript extension now ports the harness's response-quality +scoring and consensus fan-out as **real tools** (`clk_consensus`, +`clk_subagent_quality`, `clk_autoresearch`, `clk_ralph`) rather than +relying on chief compliance — every parallel sample is scored by the +same rules `clk_harness/orchestration/response_quality.py` uses, the +winner is picked in code, and Ralph branches are created by the tool so +the protocol can't be skipped. + +It also ports the **supervise loop** as a run watchdog: every chief +turn that ends without `clk_done` gets re-prompted with the run state, +consecutive no-progress turns trigger a one-shot stall-rescue prompt, +and a cycle cap bounds token spend — so a run keeps iterating without +the user babysitting it. `clk_merge`/`clk_done` accept `validate` shell +commands and **refuse** on a non-zero exit, and `clk_ralph` refuses a +fourth identical attempt after three consecutive reverted iterations +(plateau guard). See [`pi-extension/README.md`](pi-extension/README.md) +for the full tool reference, state layout, error handling, and +customisation notes. + +**Requirements:** Pi on `PATH`; tmux on `PATH`; Git on `PATH`. + +**Install:** + +| Option | Command | When to use | +|--------|---------|-------------| +| Quick test | `pi -e /path/to/CognitiveLoopKernel/pi-extension/src/index.ts` | Try it out; reloads on `/reload` | +| Project-local | `mkdir -p .pi/extensions && ln -s /path/to/CognitiveLoopKernel/pi-extension .pi/extensions/clk` | Version-controlled per project | +| Global | `mkdir -p ~/.pi/agent/extensions && ln -s /path/to/CognitiveLoopKernel/pi-extension ~/.pi/agent/extensions/clk` | Available in every Pi session | + +**Commands:** + +| Command | Effect | +|---------|--------| +| `/clk ` | Capture the idea and hand off to the chief. The watchdog keeps the chief iterating until `clk_done`. | +| `/clk-resume` | Continue an interrupted run (session restart, abort, or watchdog stall-stop) from persisted state with a fresh stall budget. | +| `/clk-abort` | End the active run. State is preserved; `/clk-resume` continues it later. | +| `/clk-help` | List every CLK slash command, every orchestration tool the chief uses, and the active safety nets. | +| `/clk-doctor` | Health-check tmux, git, the workspace `.clk/` layout, the pre-push hook, and (when a remote exists) the count of local commits not yet pushed. | +| `/clk-undo` | Preview the last CLK commit; `/clk-undo confirm` creates a revert commit on top of it. | + +**Orchestration tools the chief uses (you don't call these directly):** + +| Tool | Purpose | +|---|---| +| `clk_cast` | Persist a roster of project-specific specialist roles. | +| `clk_subagent` | Raw single-subagent dispatch via a detached tmux pi session. | +| `clk_subagent_quality` | One subagent + automatic repair-preamble re-rolls on quality failures. | +| `clk_consensus` | Fan out N parallel samples (default 3, max 6), score each, return the winner plus every candidate's score. | +| `clk_autoresearch` | Bounded researcher + critic alternation; each iteration recorded on the progress log. | +| `clk_ralph` | Create a `ralph/` branch and run a consensus fan-out in one call; chief then calls `clk_merge` or `clk_revert`. Refuses a 4th attempt after 3 consecutive reverts (plateau guard) until the chief acknowledges with a different approach. | +| `clk_branch` / `clk_merge` / `clk_revert` / `clk_checkpoint` | Git plumbing for the Ralph iteration cycle. `clk_merge({ validate })` runs the command first and refuses the merge on a non-zero exit. | +| `clk_progress` | Append a one-line entry to `.clk/state/progress.md`. | +| `clk_done` | Mark the run complete and write `.clk/state/done.md`. `clk_done({ validate: [...] })` refuses completion while any command fails. | + +**Optional env vars:** + +| Variable | Effect | +|---|---| +| `CLK_GITHUB_PUSH_ON_COMMIT=true` | After every `clk_checkpoint` and `clk_merge`, run `git push origin HEAD` best-effort and surface an `↑N` ahead counter if the push fails. Same env var as the Python TUI. | +| `CLK_STALL_CAP` | Consecutive no-progress chief turns before the watchdog's one-shot stall-rescue prompt (default 3). | +| `CLK_MAX_AUTO_CONTINUES` | Hard cap on watchdog auto-continuations per run (default 100) — the extension's `supervise.max_cycles`. | + +A typical session: + +```text +> /clk a local-first journaling app that summarizes my week +[CLK run started. The chief is taking over.] +[chief casts engineer, ux_writer, summarizer, qa] +[chief calls clk_consensus({agent:"architect", samples:3, task:"... storage design ..."})] +[harness fans out 3 parallel tmux pi subagents, scores each, returns the winner] +[chief calls clk_autoresearch({question:"sync model: append-only vs CRDT?"})] +[chief calls clk_ralph({iterationName:"iter-1-mvp", agent:"engineer", task:"... build MVP ..."})] +[chief calls bash: pytest -q] +[chief calls clk_merge: "ralph win: MVP capture+persist+summarize"] +[chief calls clk_done: "MVP runs; tests pass; README + deploy plan present"] +``` diff --git a/docs/QUICKSTART.md b/docs/QUICKSTART.md new file mode 100644 index 0000000..8aac3fe --- /dev/null +++ b/docs/QUICKSTART.md @@ -0,0 +1,142 @@ +# Quick start + +Part of the [CLK documentation](../README.md). Getting CLK running locally, and the lower-level CLI. + +## Pick your path + +Skim this matrix to jump straight to the right tutorial. Every path +goes through the same `kickoff.sh --setup` wizard at some point, so once +you've configured CLK in one place you can mix and match the rest. + +| Platform / mode | Tutorial | +|--------------------------------------|-----------------------------------------------------------| +| Local Linux / macOS / WSL (Python) | [Quick start](#quick-start) → [Lower-level CLI](#lower-level-cli) | +| Browser dashboard (point & click) | [Web dashboard](WEBUI.md#web-dashboard) | +| Docker container (build locally) | [Docker](KICKOFF.md#docker) → [First-run setup](KICKOFF.md#first-run-setup) | +| Pre-built image from GHCR | [Docker → Pull from GHCR](KICKOFF.md#pull-from-ghcr) | +| Raspberry Pi (`pi` runtime) | [Pi extension](PROVIDERS.md#pi-extension) | +| REST API (drive CLK from code) | [REST API](REST_API.md#rest-api) | +| Chat-control from your phone | [Telegram Bot](TELEGRAM.md#telegram-bot) | + +Every tutorial ends with a **"You should now see…"** verification step. +If something differs, check the **Troubleshooting** notes inline in the +section you followed. +## Quick start + +The fastest path is `clk kickoff`, which copies the harness into a +fresh `workspace/kickoff-/` directory, gives it its own git repo, and +launches the TUI dashboard. The source tree is never modified. +(`./kickoff.sh` still works — it is now a thin wrapper that execs +`clk kickoff` with the same flags, so existing scripts keep running.) + +> **Want chat control?** After running `--setup` once, see the +> [Telegram Bot](TELEGRAM.md#telegram-bot) section to drive CLK from your phone with +> live status updates. + +```bash +# Optional: copy .env.example to .env to set defaults non-interactively. +clk kickoff "A local-first journaling app that summarizes my week" + +# First time? Run the setup wizard to create your .env: +clk kickoff --setup + +# Or omit the prompt and type your idea into the TUI: +clk kickoff +``` + +`clk kickoff` reads all settings from `.env` (and optional CLI overrides) and +requires no interactive prompts during a normal run. If required config is +missing it prints exactly what's needed and offers to run `--setup` for you. + +```bash +# CLI overrides (override any .env value for a single run) +clk kickoff --provider claude --max-iterations 10 "My idea" +clk kickoff --no-tui "My idea" + +# Re-run setup at any time to update your .env: +clk kickoff --setup + +# No install? ./kickoff.sh is an equivalent entry point from a bare clone. +./kickoff.sh --no-tui "My idea" +``` + +The TUI shows live agent cards (idle / working / done / failed), a +status log that updates in place, and a Claude-Code-style ``>`` input +field. Use it to type follow-ups; each message dispatches another +engineering cycle so the agents react to the new context. + +| TUI command | Effect | +|--------------------------------------|--------| +| free text | first message becomes the idea, then auto-runs casting + `engineering`; later messages append to the conversation and re-cast + re-run | +| `/help` (or F1, or `?` when empty) | open the in-place help overlay with every command listed | +| `/idea ` | replace the captured idea | +| `/cast` | force a fresh chief casting pass against the current state | +| `/roles list` | print the current roster (baseline + dynamic) | +| `/roles add NAME "role description"` | add a dynamic role (the chief usually does this for you) | +| `/roles drop NAME` | remove a dynamic role (baseline cannot be removed) | +| `/run [workflow]` | run a single workflow cycle (default `engineering`) | +| `/loop ralph 5` | start a Ralph refinement loop with 5 iterations | +| `/loop autoresearch 3` | start a Karpathy-style research loop (ralph agent, research mode) | +| `/stop` | request the active loop to stop after the current iteration | +| `/abort` | SIGTERM any running CLI subprocess (use when an agent is genuinely hung; the heartbeat tells you when this is likely) | +| `/provider ` | switch the active provider; verifies it's reachable and warns if not | +| `/install [tool]` | install a missing provider CLI (claude, pi, ollama, …) via the registry in `scripts/install_tool.sh` | +| `/configure [tool]` | (re-)run a tool's first-use config — auth, upstream route, model picking | +| `/github` | inspect the current remote and link instructions for adding one | +| `/undo` | preview the last clk-authored commit; `/undo confirm` reverts it | +| `/doctor [--fix]` | health-check every provider, config, and git state; `--fix` prompts before repairing | +| `/diag` | bundle the logs, last 3 runs, and a redacted `.env` into `clk-diag-.tar.gz` for bug reports | +| `/tutorial` | run a 30-second sample idea on the `shell` provider — costs nothing | +| `/workspaces list\|rename\|switch\|clean` | manage past kickoff dirs under `workspace/` | +| `/status` | print a narrative session snapshot (idea, agents, tokens, files, per-provider cost) | +| `/quit` | exit the TUI | + +PgUp/PgDn scroll the log pane; Backspace edits the input; Enter sends. +The input area wraps when you type past one row and the status log +word-wraps every entry. A one-line hint bar above the input adapts to +state: if no idea is captured yet it says "type your idea, or +`/tutorial`, or `/help`"; if a run failed with a missing CLI it says +"try `/install ` to fix"; if an agent is working it points +at `/abort`. You always know your next move. + +The title bar shows: project, active provider, current phase, total +tokens, **estimated USD cost for the session** (via the per-provider +table in `clk_harness/pricing.py`), files written, and a `↑N` counter +for commits not yet pushed to the GitHub remote (when configured). + +CLI providers (`claude`, `codex`, `gemini`, `pi`) stream their +subprocess stdout/stderr live: every line the CLI prints (auth status, +"Connecting...", retries, etc.) appears in the status pane within +milliseconds, and each agent card has a "live" rotating view showing +PID + bytes received + the most recent line. The heartbeat fires every +~15s while an agent is working and tells you whether the subprocess is +actively streaming or silent — and if it's been silent for more than +two minutes it suggests typing `/abort`. So you can immediately tell +"this is just a slow model call" from "this is genuinely hung." + +### Lower-level CLI + +If you'd rather drive the harness without the TUI: + +```bash +./scripts/install_local.sh # local pip install (optional) +./scripts/clk init +./scripts/clk idea "A local-first journaling app that summarizes my week" +./scripts/clk plan +./scripts/clk run +./scripts/clk loop --max-iterations 10 +./scripts/clk status +./scripts/clk providers +``` + +Set `CLK_NO_TUI=true` in your environment (or `.env`) to make `kickoff.sh` +fall back to this non-interactive pipeline. + +The shell/dummy provider is the default and always works, so you can +exercise the entire harness with no API keys. Switch providers by +editing `.clk/config/providers.json`, via the TUI's `/provider` command, +or: + +```bash +./scripts/clk configure --set default_provider=claude +``` diff --git a/docs/REST_API.md b/docs/REST_API.md index 5c937c7..68bae26 100644 --- a/docs/REST_API.md +++ b/docs/REST_API.md @@ -428,12 +428,95 @@ curl -sN $BASE/api/research/$TASK2/stream ## Docker -See the main [README](../README.md#rest-api) for Docker-specific instructions. +See [Docker & kickoff](KICKOFF.md#run-the-rest-api) for Docker-specific instructions. ## Telegram bot The `clk-telegram-bot` console script consumes this API: `/api/workspaces`, `/api/research`, `/api/research/{id}`, `/api/research/{id}/cancel`, `/api/research/{id}/stream`, and `/api/research/{id}/artifacts`. See -[README → Telegram Bot](../README.md#telegram-bot) for setup, the wizard, +[Telegram bot](TELEGRAM.md#telegram-bot) for setup, the wizard, and the chat command reference. + +## REST API + +CLK ships a FastAPI-based HTTP server that exposes a subset of CLI +commands programmatically — specifically: `init`, `idea`, `plan`, `run`, +`loop`, and `status` (see `/api/capabilities` for the authoritative list). +Use it to integrate CLK into your own tooling, drive it from a web UI, +or orchestrate it from CI pipelines without spawning a terminal. + +### Install + +```bash +pip install "clk-harness[api]" +``` + +### Start the server + +The REST API starts **automatically in the background** whenever you run +any `clk` sub-command (provided the optional `[api]` extras are installed). +A `[clk] REST API listening on http://…` banner is printed to stderr at +startup. You can also start it standalone: + +```bash +# Using the console-script entry point (recommended) +clk-api + +# Or via the module entry point +python -m clk_harness.api + +# Or via uvicorn directly +uvicorn clk_harness.api:app --host 0.0.0.0 --port 8001 +``` + +The server listens on port `8001` by default. Override with +`CLK_API_PORT=`. + +### Security and network bind address + +> **Warning: the REST API has no authentication and binds to `0.0.0.0` +> (all interfaces) by default.** This default suits sandbox / container +> environments where network isolation is provided by the runtime. +> **Do not expose the API port to an untrusted network without additional +> access controls.** For local development, restrict the server to +> loopback (`127.0.0.1`) using the mechanisms below. + +When the CLI starts, the REST API auto-starts on a background daemon thread +and prints a `[clk]` banner to stderr. Override the bind address or disable +the API entirely: + +| Mechanism | Effect | +|---|---| +| `CLK_API_HOST=127.0.0.1` | Restrict the API to loopback (recommended for local dev) | +| `CLK_API_PORT=` | Change the listen port (default `8001`) | +| `clk --no-api ` | Skip the background API for this invocation | +| `CLK_DISABLE_API=1` | Disable the background API for all CLI invocations | + +If the optional `[api]` extras (`fastapi`, `uvicorn`) are not installed, +the background thread is silently skipped and the CLI works normally. + +### Quick curl example + +```bash +# Health check +curl http://localhost:8001/api/healthz + +# Create a workspace +WS=$(curl -s -X POST http://localhost:8001/api/workspaces \ + -H 'Content-Type: application/json' \ + -d '{"name": "my-project"}' \ + | python3 -c "import sys,json; print(json.load(sys.stdin)['workspace_id'])") + +# Capture an idea +TASK=$(curl -s -X POST http://localhost:8001/api/research \ + -H 'Content-Type: application/json' \ + -d "{\"command\":\"idea\",\"args\":[\"A local-first journaling app\"],\"workspace_id\":\"$WS\"}" \ + | python3 -c "import sys,json; print(json.load(sys.stdin)['task_id'])") + +# Stream live output +curl -sN http://localhost:8001/api/research/$TASK/stream +``` + +See [docs/REST_API.md](docs/REST_API.md) for the full endpoint reference, +SSE event format, and more examples. diff --git a/docs/TELEGRAM.md b/docs/TELEGRAM.md new file mode 100644 index 0000000..51732f1 --- /dev/null +++ b/docs/TELEGRAM.md @@ -0,0 +1,209 @@ +# Telegram bot + +Part of the [CLK documentation](../README.md). Chat-control CLK from your phone. + +## Telegram Bot + +Two-way chat control for CLK. The bot lets you kick off runs, watch live +status updates, tail the activity log, and cancel tasks from anywhere +Telegram works — no SSH, no port forwarding, no public URL. It connects +via long polling, so it works behind NAT (your home network, a Pi behind +a router, a Docker container). + +### How it works + +`clk-telegram-bot` is a separate process that: + +1. Long-polls Telegram's servers for messages from allowlisted users. +2. Translates commands into calls against the local CLK REST API + (`clk-api`, default `http://127.0.0.1:8001`). +3. Tails `.clk/logs/activity.jsonl` and pushes interesting events (agent + dispatches, action applied, iteration outcomes, errors) to subscribed + chats in real time. + +Access is gated by a numeric-user-ID allowlist. Unknown users get a single +canned reply that prints their own user ID (so the operator can add them) +and are otherwise ignored. + +### One-time setup (any platform) + +Three steps. The wizard automates the last two: + +1. **Create the bot** with [@BotFather](https://t.me/BotFather): + - Open Telegram, message `@BotFather`. + - Send `/newbot`. Pick a display name and a unique username that ends + in `bot` (e.g. `my_clk_bot`). + - BotFather replies with an HTTP API token like + `123456789:AAH...xyz`. Copy it. +2. **Run the wizard**: + ```bash + ./scripts/telegram_setup_wizard.sh + ``` + The wizard: + - Validates the token by calling `getMe` against Telegram. + - Prints "Send any message to your new bot, then press Enter". + - Reads `getUpdates` to capture your numeric user ID automatically + (you can also enter one manually). + - Writes `CLK_TELEGRAM_BOT_TOKEN`, `CLK_TELEGRAM_ALLOWED_USERS`, and + `CLK_TELEGRAM_ENABLED=true` to `.env` (preserving other keys). +3. **Start the bot**: + ```bash + # Make sure the REST API is running first (so the bot has something to drive): + clk-api & + # Then start the bot: + clk-telegram-bot + ``` + +The wizard is idempotent: re-run any time to rotate the token, add more +allowed users, or re-discover your ID after switching accounts. + +**You should now see:** in your Telegram chat with the new bot, sending +`/start` replies with your user ID and the help text. Sending `/status` +lists workspaces. + +### Setup inside Docker + +`kickoff.sh` offers Telegram setup automatically the first time it runs +without a token configured. The image already includes +`python-telegram-bot`, the wizard script, and the `clk-telegram-bot` +entry point. + +```bash +# 1. Create an empty config file on the host (once). +touch ~/clk.env + +# 2. Run kickoff with --setup; answer "y" at the Telegram prompt. +docker run --rm -it \ + -v ~/clk.env:/app/.env \ + -v clk-workspace:/app/workspace \ + clk --setup +``` + +To run only the Telegram wizard (no kickoff prompts): + +```bash +docker run --rm -it \ + -v ~/clk.env:/app/.env \ + --entrypoint scripts/telegram_setup_wizard.sh \ + clk +``` + +Once `~/clk.env` has the Telegram keys, run the bot in its own container +alongside `clk-api`: + +```bash +# REST API server (port 8001 published so the bot container can reach it) +docker run -d --name clk-api \ + -v ~/clk.env:/app/.env \ + -v clk-workspaces:/workspaces \ + -p 127.0.0.1:8001:8001 \ + --entrypoint python clk -m clk_harness.api + +# Telegram bot — talks to clk-api via Docker's bridge network +docker run -d --name clk-telegram-bot \ + --link clk-api \ + -v ~/clk.env:/app/.env \ + -v clk-workspaces:/workspaces \ + -e CLK_API_HOST=clk-api \ + -e CLK_API_PORT=8001 \ + --entrypoint clk-telegram-bot clk +``` + +The bot makes **outbound** HTTPS calls to `api.telegram.org`, so no +inbound port forwarding is needed. The default Docker bridge network is +enough. + +### Setup on Raspberry Pi (systemd) + +Install CLK via the [Pi extension](PROVIDERS.md#pi-extension) or `pip install +'clk-harness[api,telegram]'`, then drop two systemd units: + +```ini +# /etc/systemd/system/clk-api.service +[Unit] +Description=CLK REST API +After=network-online.target + +[Service] +User=pi +WorkingDirectory=/home/pi/clk +EnvironmentFile=/home/pi/clk/.env +ExecStart=/usr/local/bin/clk-api +Restart=on-failure + +[Install] +WantedBy=multi-user.target +``` + +```ini +# /etc/systemd/system/clk-telegram-bot.service +[Unit] +Description=CLK Telegram bot +After=clk-api.service +Requires=clk-api.service + +[Service] +User=pi +WorkingDirectory=/home/pi/clk +EnvironmentFile=/home/pi/clk/.env +ExecStart=/usr/local/bin/clk-telegram-bot +Restart=on-failure + +[Install] +WantedBy=multi-user.target +``` + +Enable both: `sudo systemctl enable --now clk-api clk-telegram-bot`. + +**You should now see:** from your phone, `/status` returns the current +workspace list. Sending `/run improve the README` kicks off a CLK run and +the bot replies with a task ID. + +### Commands + +| Command | Effect | +|---------|--------| +| `/start` | Greet, show your user ID, indicate whether allowlisted | +| `/help` | Show this command list | +| `/status` | List workspaces and last task ID | +| `/run ` | Start a single CLK run with the given objective | +| `/loop [args]` | Start the Ralph / autoresearch loop | +| `/plan ` | Run the planning workflow | +| `/idea ` | Capture an idea | +| `/cancel [task_id]` | Cancel a running task (latest if omitted) | +| `/tail [N]` | Print the last N lines of `activity.jsonl` (default 20) | +| `/subscribe` | Receive live event pushes in this chat | +| `/unsubscribe` | Stop receiving live event pushes | +| `/workspace ` | Set the default workspace for this chat | + +Any plain text (no slash) from an allowlisted user is treated as +`/run ` — so you can just describe what you want. + +### Adding more allowed users + +Either re-run `scripts/telegram_setup_wizard.sh` (it appends new IDs to +the existing list) or edit `CLK_TELEGRAM_ALLOWED_USERS` in `.env` +directly: + +```bash +# .env +CLK_TELEGRAM_ALLOWED_USERS=123456789,987654321,555666777 +``` + +Restart `clk-telegram-bot` to pick up the change. + +### Troubleshooting + +- **Bot doesn't reply.** Send `/start` and check the reply for your user + ID. If you get the "Not allowlisted" message, add the ID to + `CLK_TELEGRAM_ALLOWED_USERS` and restart the bot. +- **`token rejected by Telegram`** (during the wizard). The token is + wrong or was revoked. Get a fresh one from BotFather with `/token`. +- **No live updates** even after `/subscribe`. Confirm that the bot can + read the activity log: `CLK_TELEGRAM_ACTIVITY_LOG` overrides the + default path, or the bot auto-detects + `$CLK_WORKSPACES_DIR//.clk/logs/activity.jsonl`. +- **`clk-telegram-bot --check-config` exits non-zero.** It prints which + variable is missing (`2` = token, `3` = empty allowlist). +- **Kickoff prompts every run.** Set `CLK_TELEGRAM_SKIP=true` in `.env` + to permanently suppress the "Set up Telegram bot now?" prompt. diff --git a/docs/TESTING.md b/docs/TESTING.md new file mode 100644 index 0000000..2011964 --- /dev/null +++ b/docs/TESTING.md @@ -0,0 +1,138 @@ +# Testing + +Part of the [CLK documentation](../README.md). Test suites and how to run them. + +## Testing + +CLK ships three test suites and a one-command orchestrator that runs them +all in an ephemeral Docker container. + +| Suite | What it covers | Runner | +|------------------------|---------------------------------------------------------|--------| +| `tests/` | Unit + integration regression tests (CI-gated) | pytest | +| `user_tests/` | End-to-end CLI / REST API / `kickoff.sh` user tests | pytest | +| `pi-extension/tests/` | TypeScript Node tests for the Pi extension | npm | + +### One-command run + +```bash +# Interactive: prompts for LLM provider, API key, base URL, model. +# Builds an ephemeral Docker image, runs every suite inside, then tears +# the container down (success or failure). +./scripts/run_all_tests.sh + +# CI / scripted use — skip the prompts and use the shell provider: +./scripts/run_all_tests.sh --non-interactive + +# Single suite (no Docker, runs directly on the host): +./scripts/run_all_tests.sh --local --suite=user +./scripts/run_all_tests.sh --local --suite=ci +./scripts/run_all_tests.sh --local --suite=pi +``` + +The interactive menu asks four questions: + +1. **LLM provider** (shell / claude / codex / gemini / pi / ollama / openwebui) +2. **Auth mode** (cli vs apikey) for the CLI-driven providers +3. **API key**, base URL, model name — only for the chosen provider +4. **Confirm + go** + +All deterministic tests (CLI plumbing, REST API contract, etc.) run +against the `shell` provider regardless — they need no credentials and +always succeed. The opt-in *real-provider smoke* test +(`test_kickoff_with_user_selected_provider` in `user_tests/`) runs +kickoff.sh end-to-end with whatever provider you selected, and the +`pi-extension` runtime smoke verifies the `pi` CLI is reachable when you +chose `pi` and gave it a model + key. + +### What runs inside the Docker container + +`run_all_tests.sh` (Docker mode): + +1. Builds `clk:tests-` from the project `Dockerfile`. +2. Mounts the repo read-only at `/repo`, copies it into a writable + `/work` inside the container. +3. Runs `pytest tests/` then `pytest user_tests/` then + `npm test` inside `pi-extension/`. +4. **Always tears down** the container on exit (success, failure, or + ^C) and removes the ephemeral image, unless `--keep` is passed. + +Useful flags: + +| Flag | Effect | +|---------------------|--------| +| `--local` | Run on the host directly; no Docker daemon required. | +| `--non-interactive` | Skip all prompts; force `CLK_PROVIDER=shell`. | +| `--suite=all` | Default — run all three test directories. | +| `--suite=ci` | Only `tests/` (regression). | +| `--suite=user` | Only `user_tests/`. | +| `--suite=pi` | Only `pi-extension/tests/`. | +| `--keep` | Don't remove the container or image on exit. | +| `--no-build` | Reuse a pre-built `clk:tests-latest` image. | +| `-k ` | Forward a `-k` filter to pytest. | +| `-- ` | Pass remaining args verbatim to pytest. | + +### Running suites manually + +Each suite is just pytest / npm and can be invoked on its own: + +```bash +# Regression suite (existing CI tests) +pip install -e ".[api,dev]" pytest pytest-asyncio httpx +pytest tests/ -v + +# User-perspective end-to-end suite (CLI subprocess + live REST API + +# real kickoff.sh runs). Uses the shell provider — no API keys needed. +pytest user_tests/ -v + +# Pi extension TypeScript suite +cd pi-extension +npm install +npm test # unit + integration tests (96 tests, ~2s) +npm run test:strict # also runs `tsc --noEmit` +``` + +The `user_tests/` suite verifies, from a real user's vantage point: + +- Every `clk` sub-command (`init`, `idea`, `cast`, `roles`, + `plan`, `run`, `loop`, `status`, `providers`, `configure`) exits + cleanly and writes the documented `.clk/` artefacts. +- All seven shipped providers register and the `shell` provider is + always available. +- The REST API serves health, capabilities, workflows, workspace CRUD, + research task creation, SSE streaming, artifact listing, path + traversal blocking, and cancellation. +- `kickoff.sh` produces a self-contained workspace dir with its own git + repo, and respects `--provider` / `CLK_PROVIDER` overrides. +- Filesystem invariants (commit history, `.clk/runs/shell-stubs/`, + per-command `.clk/logs/-.log`, etc.). + +The `pi-extension/tests/` suite verifies: + +- `classifyError`, `withRetry`, `looksRedacted`, `isMaxTurnsResult`, + and all `recoveryHint` branches. +- `clkChiefPrimer` renders the captured idea + every CLK tool name + (`clk_cast`, `clk_subagent`, `clk_subagent_quality`, `clk_consensus`, + `clk_autoresearch`, `clk_ralph`, `clk_checkpoint`, `clk_done`). +- `scoreResponse` flags every documented failure mode (empty / refusal / + malformed ACTION / malformed POST / missing outputs / low confidence / + needs-review / missing-confidence) and `repairHint` quotes each reason + to the worker. +- `runConsensus` fans out N samples, scores them, picks the winner, caps + to `maxParallel`, and captures spawn errors without throwing. + `dispatchWithQuality` retries with a repair preamble on recoverable + failures and stops on refusal or `maxRetries`. +- `setIdea`, `setRoster`, `appendProgress`, `markDone`, `isDone` + round-trip state through `.clk/state/*.json` and `progress.md`. +- The `git` wrapper does init, checkpoint, branch, merge, revert, + `hasRemote`, `commitsAhead`, and `pushBestEffort` correctly against a + real `git` binary (including the bare-upstream sync, the unreachable- + remote failure path, and the no-remote no-op). +- The extension's `default` export registers every documented tool + (`clk_cast`, `clk_progress`, `clk_checkpoint`, `clk_revert`, + `clk_branch`, `clk_merge`, `clk_done`, `clk_consensus`, + `clk_subagent_quality`, `clk_autoresearch`, `clk_ralph`, + `clk_subagent`) and the `/clk` slash command, and handles an + empty-idea invocation cleanly. +- `firstLineShort` returns single-line, capped output so a multi-line + idea never bleeds line 2 into the Pi status bar. diff --git a/docs/TUI.md b/docs/TUI.md new file mode 100644 index 0000000..b20b783 --- /dev/null +++ b/docs/TUI.md @@ -0,0 +1,141 @@ +# TUI guide + +Part of the [CLK documentation](../README.md). Living with the terminal UI: recoverability, GitHub integration, diagnostics, the tutorial, and workspace management. + +## Recoverability + +CLK tries hard to never leave you with a broken setup or a stuck +session. The safety nets: + +| Safety net | When it kicks in | How to use it | +|---|---|---| +| `.env.bak` rotation | Every wizard run rotates the old `.env` to `.env.bak` before writing. | `./kickoff.sh --restore` swaps it back. | +| Atomic `.env` writes | Wizards write to `.env.tmp` and rename — Ctrl-C mid-write leaves either the old or the new file intact, never half. | Automatic; no user action. | +| Atomic JSON config writes | Same pattern for `.clk/config/*.json` and any agent-written JSON, with `.bak` rotation. | Implemented in `clk_harness.config.save_json`. | +| Per-step wizard resume | Wizard tracks last completed step in `.clk/.setup-progress`. If you Ctrl-C, the next run offers to resume. | `./kickoff.sh --setup` prompts "Resume from after step X? [Y/n]". | +| Crashed-session detection | The TUI writes its PID to `.clk/state/.tui-active`. If a previous TUI exited uncleanly, the next launch surfaces "recovered from a crashed session" and points to the preserved `.clk/state/conversation.md`. | Automatic. | +| `/undo` | After every agent commit, `/undo` lets you preview and revert the last commit. Two-step (preview first, then `/undo confirm`) so it's never accidental. | Type `/undo` in the TUI. | +| `/abort` | When an agent subprocess is stuck, SIGTERM it without killing the TUI. The provider returns a timeout error, the cycle reports the failure cleanly. | Type `/abort` in the TUI. | +| `/install` / `/configure` | Recover from "CLI not found" / "auth failed" without leaving the dashboard. | `/install [provider]` then `/configure [provider]`. | +| Pre-push secret scanner | Installed in the kickoff dir's `.git/hooks/pre-push`. Greps for `ANTHROPIC_API_KEY=`, `OPENAI_API_KEY=`, `sk-…`, private-key headers. Bypass with `git push --no-verify` when sure. | Automatic in every kickoff dir. | + +**Confirmation policy.** Every install, push, undo, cost-cap +crossing, ollama pull, and destructive `--clean` action asks `[y/N]` +every single time. There is no "remember my answer" shortcut — by +design. +## GitHub integration + +`kickoff.sh --setup` offers to wire each kickoff workspace up to a +GitHub remote so every CLK commit is checkpointed off your machine. + +**Three modes:** + +- `skip` — no GitHub, local commits only (default). +- `existing` — paste a `https://github.com/OWNER/REPO` or + `git@github.com:OWNER/REPO.git` URL; the wizard validates it via + `gh repo view` (or `git ls-remote` if `gh` isn't on PATH). +- `create` — provide `owner/repo` (default + `$USER/$CLK_PROJECT_NAME-kickoff`), the wizard runs + `gh repo create … --private` from inside the kickoff dir. Default + visibility is private — making it public requires an explicit + choice. + +**Auth.** Prefer the `gh` CLI if it's on PATH and authenticated. If +not, the wizard offers to install `gh` and drops you into a shell +for `gh auth login` (same pattern as `pi login`). PATs are stashed +in `~/.config/clk/github-token` (chmod 600), never `.env`. + +**Hardened `.gitignore`.** Written before the first push so secrets +can't leak. Blocks `.env`, `.env.bak`, `.env.local`, `*.pem`, +`*.key`, `*_id_rsa*`, `/secrets/`, plus editor / OS junk. + +**Pre-push hook.** `.git/hooks/pre-push` greps the about-to-push +objects for obvious key patterns (Anthropic / OpenAI / OpenRouter / +Gemini / Google keys, generic `sk-…` strings, Slack `xoxb-` tokens, +private key headers). On a hit the push aborts with the offending +lines and the bypass instructions. Bypass once with `git push +--no-verify`. + +**`CLK_GITHUB_PUSH_ON_COMMIT=true`** makes the harness follow every +auto-commit with a `git push origin HEAD`. Failures are non-fatal — +the commit stays local until the network or remote is back. The TUI +title bar shows `↑N` for the count of unpushed commits. + +**Re-link from the TUI.** Type `/github` to see current remotes and +re-link instructions. +## Diagnostics & Doctor + +Two new commands help when something feels off. + +### `/doctor` (or `clk doctor`) + +Health-check every provider, validate `.env` against known-bad +combos, and check git/GitHub state. + +- Reports each finding as `ok | warn | fail`. +- Exits non-zero on any `fail` so it slots into CI. +- `/doctor --fix` prompts before each automated remedy (running + `/install`, re-running `configure_tool`, writing a missing key). + +Common findings: + +| Finding | Meaning | Fix | +|---|---|---| +| `claude: unavailable` | `claude` CLI not on PATH or API key missing | `/install claude` then `/configure claude` | +| `anthropic_key: fail` | `CLK_AUTH_MODE=apikey` but `ANTHROPIC_API_KEY` is empty | `/configure claude` to set it | +| `git: warn` | no git repo at project root; auto-commit disabled | `git init` | +| `ollama: unavailable` | endpoint not reachable | `/install ollama`, then `ollama serve &` | + +### `/diag` (or `clk diag`) + +Bundles the current state into a `clk-diag-.tar.gz` for sharing +in bug reports. Contents: + +- `.clk/logs/*` (recent only — capped so the bundle stays small) +- `.clk/runs//` +- `.clk/state/*.{md,json}` +- `clk doctor` output +- `pyproject.toml` version, `python --version`, `git --version`, + `uname -a` +- A redacted copy of `.env` — every value under a key containing + `KEY`, `TOKEN`, `SECRET`, or `PASS` is replaced with + `` so the recipient can confirm you *had* a + key without seeing it. + +Always confirms before writing the tarball. +## Tutorial mode + +First-time users can type `/tutorial` in the TUI to run a +30-second sample idea — `"Add a hello() function to greeter.py"` — +against the `shell` provider. Costs nothing, takes no API keys, +demonstrates the cast → engineer → qa → commit loop end-to-end so +the user knows what a "real" run will look like. + +The tutorial backs up your active provider, runs one engineering +cycle in `.clk/state/.tutorial/`, then restores. A marker at +`.clk/state/.seen-tutorial` suppresses the "type /tutorial" hint +in the welcome banner on subsequent runs. +## Workspace management + +Each `kickoff.sh` creates `workspace/kickoff-/`. To keep +the directory navigable: + +```bash +./kickoff.sh --list # show every kickoff with its idea +./kickoff.sh --clean 7d # delete kickoff dirs older than 7 days (after y/N) +./kickoff.sh --clean 30m # same, in minutes +./kickoff.sh --restore # roll .env back to .env.bak (undo last wizard run) +``` + +From inside the TUI: + +```text +/workspaces list # numbered list, * marks the current one +/workspaces rename old-name new # rename a kickoff dir +/workspaces switch # prints instructions (/quit, then cd) +/workspaces clean # points at ./kickoff.sh --clean +``` + +The kickoff manifest at `KICKOFF.md` (written by `kickoff.sh` into +each new workspace) records timestamp, source dir, project name, +provider, max iterations, install flag, and idea. diff --git a/docs/WEBUI.md b/docs/WEBUI.md new file mode 100644 index 0000000..16cb919 --- /dev/null +++ b/docs/WEBUI.md @@ -0,0 +1,250 @@ +# Web dashboard + +Part of the [CLK documentation](../README.md). The browser dashboard: launch, tour, `.env` configuration, and UI development. + +## Web dashboard + +Everything the TUI does, in your browser — and then some. The web +dashboard is a React single-page app served by CLK's own FastAPI server. +It lets you **configure every feature and `.env` setting**, **kick off +agent workflows**, and — the star of the show — **watch what the agents +are doing in real time** with live agent cards, a colour-coded activity +timeline, animated token/cost meters, and a prompt/response inspector. + +### Launch it + +```bash +pip install "clk-harness[api]" + +# Build the UI bundle (needs Node/npm) the first time, then serve it. +clk web --build + +# Subsequent runs (bundle already built) just serve it and open a browser: +clk web +``` + +`clk web` runs a uvicorn server (default `http://127.0.0.1:8001`) and +opens your browser. Flags: + +| Flag | Purpose | +|--------------|----------------------------------------------------------------| +| `--build` | Compile the React bundle first (`npm ci && npm run build`). | +| `--no-build` | Never auto-build, even if the bundle is missing. | +| `--host` | Bind host (default `CLK_API_HOST` or `127.0.0.1`). | +| `--port` | Bind port (default `CLK_API_PORT` or `8001`). | +| `--no-open` | Don't open a browser window. | + +> The pre-built Docker image already contains the compiled bundle, so +> inside a container you can run `clk web --no-build` directly. + +To open the dashboard from the Docker image without kicking off a run +(the default `kickoff.sh` entrypoint starts an agent — `clk web` does +not), override the entrypoint and publish the port: + +```bash +docker run --rm -it \ + -p 127.0.0.1:8001:8001 \ + -e CLK_API_HOST=0.0.0.0 \ + -v "$(pwd):/workspaces" \ + --entrypoint clk \ + clk web --no-open --no-build +``` + +Then browse to `http://localhost:8001`. Notes: + +- `--entrypoint clk` replaces `kickoff.sh`, so nothing runs until you + press **Run** in the UI. +- `CLK_API_HOST=0.0.0.0` lets the container's server accept the forwarded + connection (it binds loopback-only *inside* the container by default). + The `127.0.0.1:` prefix on `-p` publishes the port to your host's + loopback only, so the unauthenticated UI isn't reachable from other + machines on your network. Drop the prefix (`-p 8001:8001`) only if you + deliberately want LAN access and have firewalled appropriately. +- `-v "$(pwd):/workspaces"` bind-mounts the current directory as the + workspace root — no named volumes — and `--rm` discards the image's + anonymous volumes on exit. +- `--no-build` serves the bundle already baked into the image (no npm at + runtime); `--no-open` skips the in-container browser launch. + +### Tutorial: from `docker run` to your first shipped feature + +This walks the whole loop — start the server, configure a provider, kick +off a job, and watch (and steer) the agents — entirely in the browser. + +**1. Start the server.** Using the published image (nothing is built or +run until you ask): + +```bash +docker run --rm -it \ + -p 127.0.0.1:8001:8001 \ + -e CLK_API_HOST=0.0.0.0 \ + -e CLK_ENV_FILE=/workspaces/.env \ + -v "$(pwd):/workspaces" \ + --entrypoint clk \ + ghcr.io/billjr99/cognitiveloopkernel:latest web --no-open --no-build +``` + +Open **http://localhost:8001**. The `CLK_ENV_FILE=/workspaces/.env` line +makes your settings persist to `./.env` on the host (see +[Configuring `.env`](#configuring-env-where-settings-live) below). + +**2. Create a workspace.** In the left rail under **Workspaces**, click +the **+** and name your project (e.g. `markdown-cli`). A workspace is one +isolated project directory; the whole UI focuses on one at a time. + +**3. Configure a provider.** Open the **Configure** tab: + - On **Providers**, pick the **active** provider and click **make + active**. **This matters:** the default active provider is `shell` — + a *stub that echoes prompts and never calls an LLM*. If you leave it + on `shell`, runs will complete instantly and "do things" without ever + touching your model (the Health strip flags this). Choose a real + provider (claude / codex / gemini / pi / ollama / openwebui). + - For HTTP providers (**ollama**, **openwebui**), set the `endpoint`, + then click **models** next to the `model` field — CLK probes the + endpoint and offers a **dropdown of installed models** (falling back + to a text box if the endpoint is unreachable, which also tells you + the server isn't reachable from where CLK runs). + - On **.env (global)**, set any keys your provider needs — e.g. + `ANTHROPIC_API_KEY` (secrets show as `••••••••` and are preserved on + save). Click **Save**. + - Auth: set `CLK_AUTH_MODE` to `apikey` to use the keys above, or `cli` + to trust a provider CLI you've already logged in to. + + > **Running CLK in Docker with a local Ollama/OpenWebUI?** A + > `localhost` endpoint points at the *container*, not your host. CLK + > auto-retries `host.docker.internal`, but the host must be reachable — + > run the container with `--add-host=host.docker.internal:host-gateway` + > on Linux (Docker Desktop adds it automatically). The model dropdown is + > the quickest way to confirm the endpoint resolves. + +**4. Kick off a job.** Open the **Run** tab: + - Type your idea / problem statement (e.g. *"Build a Markdown-to-HTML + CLI with a parser, renderer, and golden-file tests"*). + - Choose a mode: **Run workflow** (one development cycle — pick a + workflow like `engineering`), **Loop** (iterative ralph / + autoresearch with an iteration count), **Plan** (discovery + product + passes), or **Set idea** (just capture it). + - Click **Start**. A raw-output panel streams stdout; the structured + view comes alive on the other tabs. + +**5. Watch the team work.** Three tabs give you live visibility: + - **Dashboard** — the "now happening" banner, per-agent cards (status, + tokens, cost, last thought), the colour-coded activity timeline, + token/cost charts, and a files-changed list. + - **Think** — a live, timestamped feed of every **dispatch**, **prompt**, + and **response**. Filter by type and expand any entry to read the full + prompt or the agent's full response inline (or pop the full inspector). + - **Files** — browse everything the agents created. Click a file to view + and **edit** it (Save writes back to the workspace). + +**6. Steer them — follow up in chat.** On the **Files** tab, the bottom +panel is a chat with the agents. Select a file for context, type a +follow-up (e.g. *"add error handling and a test for empty input"*), pick a +workflow, and **Send**. Each message seeds a new workflow run scoped to +your request and streams the agents' work straight back into the thread — +so you can iterate on the generated code conversationally. + +That's the full loop: configure → run → watch → edit/steer → repeat, all +from `http://localhost:8001`. + +### What you can do from the browser + +- **Workspaces** — create, switch between, and delete isolated projects + from the left rail. The whole UI focuses on one active workspace at a + time, just like the TUI. +- **Run** — capture an idea and launch a workflow (`run`), an iterative + `loop` (ralph / autoresearch with an iteration count), `plan`, or just + set the idea. A raw-output tab streams stdout while the structured + view animates on the Dashboard. +- **Dashboard** — a live **"now happening"** banner, per-agent cards + (status, runs, tokens, cost, last "thought", activity meter), a + filterable real-time **activity timeline** (dispatches, prompts, + responses, actions, retries, commits…), **token & cost charts**, and a + files-changed list. Click any timeline event to inspect the full prompt + and response. +- **Think** — a dedicated, live **thinking & dispatching** feed: every + dispatch, prompt, and response as a timestamped row, filterable by type + and expandable to the full text inline (or in the inspector). +- **Files** — browse the files the agents generated, **view and edit** them + in-browser (Save writes back to the workspace), and **chat with the + agents**: each follow-up message seeds a workflow run scoped to your + request (optionally with a selected file as context) and streams the + result back into the thread. A **History** toggle shows the commit + timeline (agent badge parsed from the commit subject, relative time, + +/− line stats); clicking a commit opens its changed-file list and a + colored diff. The file editor's history button **time-travels a single + file** to any past version (read-only, "Back to latest" banner). When + the working tree is dirty, an **Uncommitted changes** entry tops the + history with new/modified/deleted badges and the working-tree diff, + and changed files carry an amber dot in the list. The file list reads + live from disk (2 s refresh), so it always shows the latest state — + committed or not. +- **Guided mode** — a full-screen step-by-step wizard for newcomers: + provider discovery (Ollama/OpenWebUI probed with a docker-host + fallback, CLI providers detected on PATH or unlocked by an API key) → + model pick → plain-language idea → friendly progress view → files → + follow-up loop. First visit with no workspaces lands here; the + sidebar's sparkle button or "Advanced mode" toggles between the wizard + and the full console mid-run without losing the workspace. +- **Configure** — tabbed settings for the global **`.env`** (grouped, + typed widgets; secrets masked with `••••••••` and preserved on save), + per-workspace **harness config** (`clk.config.json`), **providers** + (pick the active one, edit endpoints/keys), and the **agent roster**. + A health strip surfaces `doctor` findings (missing keys, unavailable + providers) at a glance. + +### How the live view works + +Under the hood the dashboard streams the harness's structured event log +(`.clk/logs/activity.jsonl`) over Server-Sent Events +(`GET /api/workspaces/{id}/activity/stream`) and folds it into a snapshot +(`GET /api/workspaces/{id}/snapshot`) that mirrors the TUI's model. The +connection auto-reconnects, so you can leave the tab open across runs. + +### Configuring `.env` (where settings live) + +The **Configure → .env (global)** tab edits a single `.env` file shared by +all workspaces (provider, API keys, git identity, feature flags…). The API +injects it into every agent subprocess, so edits take effect on the **next +run** without restarting the server. The tab header shows exactly which +file it's editing. + +CLK resolves that path as follows: + +1. **`CLK_ENV_FILE`** — if set, this exact path wins (`~` is expanded). +2. Otherwise, `/../.env`. + +In an installed image the fallback resolves next to the installed +package (e.g. `…/site-packages/.env`) — not where you'd want it. **Set +`CLK_ENV_FILE` to a path inside your bind mount** so the file lives on your +host and persists across containers: + +```bash +-e CLK_ENV_FILE=/workspaces/.env -v "$(pwd):/workspaces" +``` + +The file is created on first save (you don't need to pre-create it); just +make sure the parent directory exists. + +### Secrets & network safety + +`.env` editing includes API keys. Secret-looking values +(`*_API_KEY`, `*_TOKEN`, …) are **masked** in every response and never +echoed back; saving an unchanged masked field preserves the stored value. +A single-key reveal endpoint exists but is **disabled by default** — set +`CLK_API_ALLOW_REVEAL=1` to enable it. The server binds to loopback +(`127.0.0.1`) by default; only set `CLK_API_HOST=0.0.0.0` on a trusted, +isolated network (there is no built-in auth). + +### Developing the UI + +The source lives in `webui/` (Vite + React + TypeScript). For hot-reload +development against a running server: + +```bash +clk web --no-open # serve the API on :8001 in one terminal +cd webui && npm install && npm run dev # Vite dev server on :5173 (proxies /api) +``` + +`npm run build` emits the bundle to `clk_harness/webui_dist/` (shipped in +the wheel via `package-data`); `npm test` runs the Vitest suite. diff --git a/kickoff.sh b/kickoff.sh index 036198c..2302889 100644 --- a/kickoff.sh +++ b/kickoff.sh @@ -1,1178 +1,53 @@ #!/usr/bin/env bash -# CLK kickoff — driven entirely by .env and optional --arg overrides. +# CLK kickoff — thin wrapper around the `clk kickoff` subcommand. # # Usage: # ./kickoff.sh [OPTIONS] ["idea or problem statement"] # -# Options: +# Options (run `./kickoff.sh --help` for the authoritative list): # --setup Interactive wizard to write or update .env +# --restore Restore .env from .env.bak (undo last --setup) +# --list List past kickoff dirs under workspace/ +# --clean DURATION Delete kickoff dirs older than DURATION (e.g. 7d, 30m) # --provider

Override CLK_PROVIDER # --max-iterations Override CLK_MAX_ITERATIONS # --project-name Override CLK_PROJECT_NAME -# --no-tui Set CLK_NO_TUI=true (non-interactive pipeline) +# --no-tui Set CLK_NO_TUI=true (non-interactive pipeline) # --tui Set CLK_NO_TUI=false (TUI dashboard, the default) # --run-install Set CLK_RUN_INSTALL=true # -h, --help Show this help # -# Configuration (highest-to-lowest precedence): -# --arg overrides → shell environment vars → .env file → built-in defaults +# The kickoff flow itself — .env handling, the setup wizard, workspace +# scaffolding, CLK_* env-var overrides, provider activation, and the +# pipeline/TUI drivers — lives in clk_harness/kickoff.py. This script +# only resolves a Python interpreter and execs: # -# Normal runs ask no questions. If required configuration is missing, kickoff -# prints exactly what is needed and offers to launch --setup. Run --setup at -# any time to create or update .env; existing values become the default answer -# to every question so you can press Enter to keep them. - -set -euo pipefail - -SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd)" - -# Shared helpers — atomic .env writes and tool install/configure registry. -# Sourced once so _clk_setup, _clk_setup_github, and the TUI all behave -# the same way. -# shellcheck source=scripts/lib_env.sh -. "$SCRIPT_DIR/scripts/lib_env.sh" -# shellcheck source=scripts/install_tool.sh -. "$SCRIPT_DIR/scripts/install_tool.sh" - -# =========================================================================== -# Setup wizard — invoked by --setup or offered when config is incomplete. -# Reads from /dev/tty so it works inside `docker run -it` even when stdin -# is not a terminal. +# python -m clk_harness.cli kickoff "$@" # -# Design notes — see /root/.claude/plans/recommend-ways-to-make-imperative-wreath.md -# * Explain-then-ask: every prompt is preceded by a short block telling -# the user what the value does and what reasonable choices are. -# * Atomic writes: each answer is persisted to .env immediately through -# env_set, so a Ctrl-C mid-wizard does not leave the file half-written -# and the next run can resume. -# * Per-step resume: `.clk/.setup-progress` records the last completed -# step name; the next run offers to skip ahead. -# * Tool autopilot: after the provider is chosen, the wizard runs -# `check_tool` and, on miss, asks before invoking `install_tool`. -# Then `configure_tool` walks the user through auth, upstream route -# (for pi), and model picking (including `ollama pull`). -# * Always-confirm: every install/push/destructive action prompts y/N -# every time. No "remember the answer" shortcut. -# =========================================================================== -_clk_setup() { - { exec 3/dev/null || exec 3<&0 - { exec 4>/dev/tty; } 2>/dev/null || exec 4>&2 - - _sv_read() { - local prompt="$1" default="$2" v - if [ -n "$default" ]; then - printf '%s [%s]: ' "$prompt" "$default" >&4 - else - printf '%s: ' "$prompt" >&4 - fi - IFS= read -r v <&3 || v="" - printf '%s' "${v:-$default}" - } - - _sv_secret() { - local prompt="$1" v - printf '%s (leave blank to keep): ' "$prompt" >&4 - stty -echo /dev/null || true - IFS= read -r v <&3 || v="" - stty echo /dev/null || true - printf '\n' >&4 - printf '%s' "$v" - } - - _sv_explain() { - # One blank line above, then the explanation, then a separator. The - # tone mirrors install_local.sh: tell the user what you're about to - # do and why before doing it. - printf '\n%s\n' "$1" >&4 - } - - _sv_confirm() { - local prompt="$1" default="${2:-N}" v hint - case "${default^^}" in - Y|YES) hint="Y/n" ;; - *) hint="y/N" ;; - esac - printf '%s [%s]: ' "$prompt" "$hint" >&4 - IFS= read -r v <&3 || v="" - v="${v:-$default}" - case "${v,,}" in y|yes) return 0 ;; *) return 1 ;; esac - } - - local env_file="${CLK_ENV_FILE:-$SCRIPT_DIR/.env}" - export CLK_ENV_FILE="$env_file" - local progress_file="$SCRIPT_DIR/.clk/.setup-progress" - mkdir -p "$(dirname "$progress_file")" - - # Seed defaults from .env.example first, then let an existing .env override. - if [ -f "$SCRIPT_DIR/.env.example" ]; then - set -a; . "$SCRIPT_DIR/.env.example"; set +a - fi - if [ -s "$env_file" ]; then - set -a; . "$env_file"; set +a - printf '[setup] loaded existing values from %s\n' "$env_file" >&4 - else - printf '[setup] %s is empty or missing — using .env.example defaults\n' "$env_file" >&4 - fi - - # Per-step resume: each completed step writes its name here. On the - # next run, we look at the last name and offer to skip ahead. - local last_step="" - if [ -s "$progress_file" ]; then - last_step="$(tail -n1 "$progress_file" 2>/dev/null || true)" - fi - local skip_until="" - if [ -n "$last_step" ]; then - if _sv_confirm "[setup] Resume from after step '$last_step'?" "Y"; then - skip_until="$last_step" - else - : > "$progress_file" - fi - fi - - printf '\n=== CLK Setup Wizard ===\n' >&4 - printf 'Press Enter to keep the value shown in [brackets].\n' >&4 - printf 'Every install, push, and destructive action is confirmed y/N first.\n' >&4 - - # Mark a step as complete. Used both for the progress file and to - # short-circuit when resuming. - _mark_step() { printf '%s\n' "$1" >> "$progress_file"; } - _should_run_step() { - # Returns 0 (run) unless we're skipping past this step. - [ -z "$skip_until" ] && return 0 - if [ "$1" = "$skip_until" ]; then - # Stop skipping after this match — the next step runs normally. - skip_until="" - return 1 - fi - return 1 - } - - local provider max_iter proj_name run_install no_tui auth_mode - local git_name git_email new tg_setup tg_skip - - # --- provider -------------------------------------------------------- - if _should_run_step "provider"; then - _sv_explain "=== Provider === -The provider is the AI that actually writes your code each cycle. - - shell no AI — useful for smoke tests and the /tutorial walkthrough - claude Anthropic Claude Code CLI (best at writing code, supports tools) - codex OpenAI Codex CLI - gemini Google Gemini CLI - pi Pi terminal harness — routes through OpenRouter/Anthropic/OpenAI/Google - ollama local LLM via the Ollama daemon (no external API, free) - openwebui OpenWebUI server (self-hosted, OpenAI-compatible)" - provider="$(_sv_read "Provider" "${CLK_PROVIDER:-shell}")" - env_set "$env_file" CLK_PROVIDER "$provider" - _mark_step provider - else - provider="${CLK_PROVIDER:-shell}" - fi - - # --- max iterations + project name + flags --------------------------- - if _should_run_step "loop_settings"; then - _sv_explain "=== Loop settings === -\`max iterations\` caps how many refinement cycles the Ralph and -autoresearch loops can run. \`project name\` becomes the title of the -captured idea and (optionally) the GitHub repo name. The \`run install\` -flag triggers .clk/harness/scripts/install_local.sh inside each kickoff -dir so providers like pi can find PyYAML and other deps — leave it -\`false\` (the default) when running inside Docker, because the image -already has all Python dependencies installed at build time. -\`no TUI\` switches to a non-interactive pipeline — handy for CI." - max_iter="$(_sv_read "Max loop iterations" "${CLK_MAX_ITERATIONS:-10}")" - proj_name="$(_sv_read "Project name" "${CLK_PROJECT_NAME:-clk-app}")" - run_install="$(_sv_read "Run install_local.sh in each kickoff (true|false)" "${CLK_RUN_INSTALL:-false}")" - no_tui="$(_sv_read "Skip TUI / non-interactive (true|false)" "${CLK_NO_TUI:-false}")" - env_set "$env_file" CLK_MAX_ITERATIONS "$max_iter" - env_set "$env_file" CLK_PROJECT_NAME "$proj_name" - env_set "$env_file" CLK_RUN_INSTALL "$run_install" - env_set "$env_file" CLK_NO_TUI "$no_tui" - _mark_step loop_settings - else - max_iter="${CLK_MAX_ITERATIONS:-10}" - proj_name="${CLK_PROJECT_NAME:-clk-app}" - run_install="${CLK_RUN_INSTALL:-false}" - no_tui="${CLK_NO_TUI:-false}" - fi - - # --- auth mode (CLI providers) --------------------------------------- - auth_mode="${CLK_AUTH_MODE:-cli}" - case "$provider" in - claude|codex|gemini) - if _should_run_step "auth_mode"; then - _sv_explain "=== Auth mode ($provider) === -'cli' — use your existing local CLI login (run \`$provider login\` once; - best when you already use $provider day-to-day). -'apikey' — call the provider's HTTP API directly using an API key. - No CLI dependency, but you must paste a key below." - auth_mode="$(_sv_read "Auth mode" "$auth_mode")" - env_set "$env_file" CLK_AUTH_MODE "$auth_mode" - _mark_step auth_mode - fi - ;; - esac - - # --- install + configure the chosen tool ----------------------------- - if _should_run_step "tool_setup" && [ "$provider" != "shell" ]; then - _sv_explain "=== Tool detection ($provider) === -Checking whether \`$provider\` is installed and reachable. If it -isn't, the wizard will suggest an install command and ask before -running it. After the tool is available, we'll walk through -first-use config (auth -> route -> model -> verify)." - if check_tool "$provider"; then - printf '[setup] %s is available on this machine.\n' "$provider" >&4 - else - install_tool "$provider" --prompt || printf '[setup] %s install was skipped or failed; continuing.\n' "$provider" >&4 - fi - if check_tool "$provider"; then - if tool_configured "$provider" && ! _sv_confirm "Re-run first-use config for $provider?" "N"; then - printf '[setup] %s already configured (per .clk/state/configured-tools.json).\n' "$provider" >&4 - else - configure_tool "$provider" || printf '[setup] %s configure step exited non-zero; continuing.\n' "$provider" >&4 - fi - else - printf '[setup] %s is still unavailable — provider calls will fail until you install it.\n' "$provider" >&4 - fi - _mark_step tool_setup - # Reload .env so values just written by configure_tool become visible - # to the rest of the wizard. - [ -s "$env_file" ] && { set -a; . "$env_file"; set +a; } - elif _should_run_step "tool_setup"; then - _mark_step tool_setup - fi - - # --- docker host fallback for local LLM endpoints -------------------- - # Catches the common Docker-in-container case where CLK_OLLAMA_ENDPOINT - # or CLK_OPENWEBUI_ENDPOINT default to http://localhost:... but the - # actual server is on the host. We probe both the configured URL and - # the host.docker.internal equivalent; if only the latter answers, - # we offer to rewrite .env — even if the user picked a different - # provider as active (the TUI's health check surfaces all of them). - if _should_run_step "docker_host_fallback"; then - _sv_explain "=== Local LLM endpoint check === -If you have ollama or OpenWebUI running on the host but CLK is in a -container, 'localhost' won't reach them. We'll probe each configured -endpoint and, when only host.docker.internal works, offer to switch." - _it_offer_docker_host_fallback "Ollama" CLK_OLLAMA_ENDPOINT \ - "${CLK_OLLAMA_ENDPOINT:-http://localhost:11434}" || true - _it_offer_docker_host_fallback "OpenWebUI" CLK_OPENWEBUI_ENDPOINT \ - "${CLK_OPENWEBUI_ENDPOINT:-http://localhost:8080}" || true - _mark_step docker_host_fallback - [ -s "$env_file" ] && { set -a; . "$env_file"; set +a; } - fi - - # --- telegram -------------------------------------------------------- - if _should_run_step "telegram"; then - _sv_explain "=== Telegram bot (optional) === -If enabled, you can drive CLK from your phone: send the bot an idea, -get progress updates back, /stop or /abort remotely. The dedicated -wizard at scripts/telegram_setup_wizard.sh walks through BotFather -token creation and discovers your numeric user ID so we can allowlist -only you." - local default_tg="N" - [ "${CLK_TELEGRAM_ENABLED:-false}" = "true" ] && default_tg="y" - tg_setup="$(_sv_read "Set up Telegram bot now? (y/N)" "$default_tg")" - if [ "${tg_setup,,}" = "y" ]; then - tg_skip="false" - else - tg_skip="true" - printf '[setup] Skipping Telegram. CLK_TELEGRAM_SKIP=true will be written to .env.\n' >&4 - fi - env_set "$env_file" CLK_TELEGRAM_SKIP "$tg_skip" - _mark_step telegram - fi - - # --- GitHub ---------------------------------------------------------- - if _should_run_step "github"; then - _clk_setup_github "$env_file" "$proj_name" - _mark_step github - fi - - # --- git identity ---------------------------------------------------- - if _should_run_step "git_identity"; then - _sv_explain "=== Git identity (used in kickoff commits) === -Each kickoff workspace is its own git repo and CLK auto-commits after -every successful agent run. The author/committer comes from your -global git config unless you set CLK_GIT_NAME / CLK_GIT_EMAIL here -(useful inside containers where the global config doesn't propagate)." - local cur_name cur_email - cur_name="$(git config --global user.name 2>/dev/null || true)" - cur_email="$(git config --global user.email 2>/dev/null || true)" - printf ' Current global git name: %s\n' "${cur_name:-}" >&4 - printf ' Current global git email: %s\n' "${cur_email:-}" >&4 - git_name="$(_sv_read "Git user.name (blank = keep current)" "${CLK_GIT_NAME:-}")" - git_email="$(_sv_read "Git user.email (blank = keep current)" "${CLK_GIT_EMAIL:-}")" - env_set "$env_file" CLK_GIT_NAME "$git_name" - env_set "$env_file" CLK_GIT_EMAIL "$git_email" - _mark_step git_identity - fi - - exec 3<&- 2>/dev/null || true - - printf '\n[setup] saved %s\n' "$env_file" >&4 - printf '[setup] previous values are in %s.bak\n' "$env_file" >&4 - - if [ "${tg_setup:-N}" = "y" ] || [ "${tg_setup:-N}" = "Y" ]; then - printf '\n[setup] launching Telegram wizard...\n' >&4 - CLK_ENV_FILE="$env_file" "$SCRIPT_DIR/scripts/telegram_setup_wizard.sh" >&4 2>&4 || \ - printf '[setup] telegram wizard exited non-zero; continuing\n' >&4 - fi - - # Wizard finished cleanly — clear progress so a future --setup starts - # at the top instead of asking to resume. - rm -f "$progress_file" - - exec 4>&- 2>/dev/null || true -} - -# =========================================================================== -# GitHub connection block — invoked by _clk_setup. Adds a `origin` remote -# to the local kickoff repo, hardens the .gitignore so secrets can't be -# pushed, and installs a pre-push hook that greps for obvious key -# patterns. The caller is responsible for git_init; we only configure -# the remote. The kickoff dir already gets its own `git init` later in -# this script, so the wizard records the choice in .env and the run-time -# kickoff sequence applies it. -# =========================================================================== -_clk_setup_github() { - local env_file="$1" proj_name="$2" - _sv_explain "=== GitHub (optional) === -Each kickoff workspace is already a local git repo. You can optionally -connect it to a GitHub remote so: - - every agent commit is checkpointed off your machine - - you (or another machine) can resume the work later by cloning - - friends/teammates can review the run - - skip no GitHub — local commits only (default) - existing connect to a repo you already own (paste URL) - create create a brand new private repo under your account - -The wizard will write a hardened .gitignore (blocking .env, .env.bak, -SSH keys, etc.) and install a pre-push hook that aborts when an -obvious API key pattern appears in the diff." - - local choice - choice="$(_sv_read "Connect to GitHub?" "${CLK_GITHUB_MODE:-skip}")" - case "$choice" in - skip|"") - env_set "$env_file" CLK_GITHUB_MODE skip - env_set "$env_file" CLK_GITHUB_REMOTE "" - env_set "$env_file" CLK_GITHUB_PUSH_ON_COMMIT "false" - printf '[setup] GitHub disabled.\n' >&4 - return 0 - ;; - existing) - local url - url="$(_sv_read "Existing repo (https://github.com/OWNER/REPO or git@github.com:OWNER/REPO.git)" "${CLK_GITHUB_REMOTE:-}")" - if [ -z "$url" ]; then - printf '[setup] no URL provided; skipping GitHub.\n' >&4 - env_set "$env_file" CLK_GITHUB_MODE skip - return 0 - fi - env_set "$env_file" CLK_GITHUB_MODE existing - env_set "$env_file" CLK_GITHUB_REMOTE "$url" - ;; - create) - if ! _it_has gh; then - printf '[setup] gh CLI is required to create a repo from here.\n' >&4 - if ! install_tool gh --prompt; then - printf '[setup] gh unavailable; cannot create. Falling back to "existing" — paste a URL.\n' >&4 - local url - url="$(_sv_read "Existing repo URL" "")" - if [ -n "$url" ]; then - env_set "$env_file" CLK_GITHUB_MODE existing - env_set "$env_file" CLK_GITHUB_REMOTE "$url" - else - env_set "$env_file" CLK_GITHUB_MODE skip - fi - return 0 - fi - fi - if ! gh auth status >/dev/null 2>&1; then - printf '[setup] gh is installed but not authenticated.\n' >&4 - if _sv_confirm "Run \`gh auth login\` now?" "Y"; then - _it_login_shell gh - fi - fi - local owner_repo default_or - default_or="$(gh api user --jq .login 2>/dev/null || echo "$USER")" - owner_repo="$(_sv_read "owner/repo to create" "${default_or}/${proj_name}-kickoff")" - env_set "$env_file" CLK_GITHUB_MODE create - env_set "$env_file" CLK_GITHUB_REMOTE "$owner_repo" - printf '[setup] GitHub repo "%s" will be created (private) on the first kickoff push.\n' "$owner_repo" >&4 - ;; - *) - printf '[setup] unknown GitHub choice "%s"; skipping.\n' "$choice" >&4 - env_set "$env_file" CLK_GITHUB_MODE skip - return 0 - ;; - esac - - if _sv_confirm "Auto-push to GitHub after every CLK commit?" "Y"; then - env_set "$env_file" CLK_GITHUB_PUSH_ON_COMMIT "true" - else - env_set "$env_file" CLK_GITHUB_PUSH_ON_COMMIT "false" - fi -} - -# =========================================================================== -# Apply built-in defaults for every var that has one. -# Call this after loading .env and applying --arg overrides. -# =========================================================================== -_apply_defaults() { - CLK_PROVIDER="${CLK_PROVIDER:-shell}" - CLK_MAX_ITERATIONS="${CLK_MAX_ITERATIONS:-10}" - CLK_PROJECT_NAME="${CLK_PROJECT_NAME:-clk-app}" - CLK_RUN_INSTALL="${CLK_RUN_INSTALL:-false}" - CLK_NO_TUI="${CLK_NO_TUI:-false}" - CLK_AUTH_MODE="${CLK_AUTH_MODE:-cli}" - CLK_OLLAMA_ENDPOINT="${CLK_OLLAMA_ENDPOINT:-http://localhost:11434}" - CLK_OLLAMA_MODEL="${CLK_OLLAMA_MODEL:-llama3.1}" -} - -# =========================================================================== -# Validate resolved config. Prints one line per problem; silent when OK. -# =========================================================================== -_clk_missing() { - case "$CLK_PROVIDER" in - shell) ;; - claude) - if [ "${CLK_AUTH_MODE}" = "apikey" ] && [ -z "${ANTHROPIC_API_KEY:-}" ]; then - echo "ANTHROPIC_API_KEY is unset — required when CLK_PROVIDER=claude and CLK_AUTH_MODE=apikey (or set CLK_AUTH_MODE=cli to use 'claude login')" - fi ;; - codex) - if [ "${CLK_AUTH_MODE}" = "apikey" ] && [ -z "${OPENAI_API_KEY:-}" ]; then - echo "OPENAI_API_KEY is unset — required when CLK_PROVIDER=codex and CLK_AUTH_MODE=apikey (or set CLK_AUTH_MODE=cli)" - fi ;; - gemini) - if [ "${CLK_AUTH_MODE}" = "apikey" ] && [ -z "${GEMINI_API_KEY:-}" ] && [ -z "${GOOGLE_API_KEY:-}" ]; then - echo "GEMINI_API_KEY (or GOOGLE_API_KEY) is unset — required when CLK_PROVIDER=gemini and CLK_AUTH_MODE=apikey" - fi ;; - pi) ;; # Nothing strictly required; pi login handles auth - ollama) ;; # Has built-in defaults - openwebui) - [ -z "${CLK_OPENWEBUI_ENDPOINT:-}" ] && \ - echo "CLK_OPENWEBUI_ENDPOINT is unset — required for CLK_PROVIDER=openwebui" - [ -z "${CLK_OPENWEBUI_MODEL:-}" ] && \ - echo "CLK_OPENWEBUI_MODEL is unset — required for CLK_PROVIDER=openwebui (use --setup to pick from a live model list)" - ;; - *) - echo "CLK_PROVIDER='$CLK_PROVIDER' is not recognised (valid: shell|claude|codex|gemini|pi|ollama|openwebui)" - ;; - esac - - if ! [[ "$CLK_MAX_ITERATIONS" =~ ^[0-9]+$ ]]; then - echo "CLK_MAX_ITERATIONS must be a positive integer (got '$CLK_MAX_ITERATIONS')" - fi - - if [ "${CLK_NO_TUI:-false}" = "true" ] && [ -z "${IDEA:-}" ]; then - echo "An idea argument is required when CLK_NO_TUI=true — pass it as the first positional argument" - fi -} - -# =========================================================================== -# 1. Parse arguments -# =========================================================================== -SETUP_MODE=false -_OVR_PROVIDER="" -_OVR_MAX_ITER="" -_OVR_PROJ_NAME="" -_OVR_NO_TUI="" -_OVR_RUN_INSTALL="" -IDEA="" - -RESTORE_MODE=false -LIST_MODE=false -CLEAN_OLDER_THAN="" - -while [[ $# -gt 0 ]]; do - case "$1" in - --setup) SETUP_MODE=true; shift ;; - --restore) RESTORE_MODE=true; shift ;; - --list) LIST_MODE=true; shift ;; - --clean=*) CLEAN_OLDER_THAN="${1#*=}"; shift ;; - --clean) - [[ $# -lt 2 || "$2" == -* ]] && { printf '[kickoff] --clean requires a value like 7d\n' >&2; exit 2; } - CLEAN_OLDER_THAN="$2"; shift 2 ;; - -h|--help) - cat <"] - -Options: - --setup Interactive wizard to write or update .env - --restore Restore .env from .env.bak (undo last --setup) - --list List past kickoff dirs under workspace/ - --clean DURATION Delete kickoff dirs older than DURATION (e.g. 7d, 30d) - Always asks y/N before deleting. --clean alone shows --dry-run. - --provider

Override CLK_PROVIDER - --max-iterations Override CLK_MAX_ITERATIONS - --project-name Override CLK_PROJECT_NAME - --no-tui Set CLK_NO_TUI=true (non-interactive pipeline) - --tui Set CLK_NO_TUI=false (TUI dashboard, the default) - --run-install Set CLK_RUN_INSTALL=true - -h, --help Show this help - -Configuration (highest-to-lowest precedence): - --arg overrides → shell env vars → .env file → built-in defaults - -Providers: - shell No AI — harness scaffolding only - claude Anthropic Claude CLI (CLK_AUTH_MODE=cli, default) or API key - codex OpenAI Codex CLI (CLK_AUTH_MODE=cli, default) or API key - gemini Google Gemini CLI (CLK_AUTH_MODE=cli, default) or API key - pi Pi coding agent (CLK_PI_MODEL, CLK_PI_KEY_TYPE, CLK_PI_API_KEY) - ollama Local Ollama (CLK_OLLAMA_ENDPOINT, CLK_OLLAMA_MODEL) - openwebui OpenWebUI (CLK_OPENWEBUI_ENDPOINT, CLK_OPENWEBUI_MODEL; - CLK_OPENWEBUI_API_KEY is optional — only needed - for authenticated instances) - -Run --setup to configure interactively; values are saved to .env and used as -defaults in future runs. If required config is missing on a normal run, -kickoff prints what is needed and offers to run --setup immediately. - -Environment variables (accepted directly or via .env): - CLK_PROVIDER, CLK_MAX_ITERATIONS, CLK_PROJECT_NAME, CLK_RUN_INSTALL, - CLK_NO_TUI, CLK_AUTH_MODE, ANTHROPIC_API_KEY, OPENAI_API_KEY, - GEMINI_API_KEY, GOOGLE_API_KEY, CLK_OLLAMA_ENDPOINT, CLK_OLLAMA_MODEL, - CLK_OPENWEBUI_ENDPOINT, CLK_OPENWEBUI_MODEL, - CLK_OPENWEBUI_API_KEY (optional — only for authenticated OpenWebUI instances), - CLK_PI_MODEL, CLK_PI_KEY_TYPE, CLK_PI_API_KEY, CLK_GIT_NAME, CLK_GIT_EMAIL -USAGE - exit 0 - ;; - --provider=*) _OVR_PROVIDER="${1#*=}"; shift ;; - --provider) - [[ $# -lt 2 || "$2" == -* ]] && { printf '[kickoff] --provider requires a value\n' >&2; exit 2; } - _OVR_PROVIDER="$2"; shift 2 ;; - --max-iterations=*) _OVR_MAX_ITER="${1#*=}"; shift ;; - --max-iterations) - [[ $# -lt 2 || "$2" == -* ]] && { printf '[kickoff] --max-iterations requires a value\n' >&2; exit 2; } - _OVR_MAX_ITER="$2"; shift 2 ;; - --project-name=*) _OVR_PROJ_NAME="${1#*=}"; shift ;; - --project-name) - [[ $# -lt 2 || "$2" == -* ]] && { printf '[kickoff] --project-name requires a value\n' >&2; exit 2; } - _OVR_PROJ_NAME="$2"; shift 2 ;; - --no-tui) _OVR_NO_TUI="true"; shift ;; - --tui) _OVR_NO_TUI="false"; shift ;; - --run-install) _OVR_RUN_INSTALL="true"; shift ;; - --) shift; [ $# -gt 0 ] && { IDEA="$1"; shift; }; break ;; - -*) - printf '[kickoff] unknown option: %s\n' "$1" >&2 - printf 'Run %s --help for usage.\n' "'$(basename "$0")'" >&2 - exit 2 ;; - *) IDEA="$1"; shift ;; - esac -done - -if $RESTORE_MODE; then - env_restore "$SCRIPT_DIR/.env" && \ - printf '[kickoff] .env restored from .env.bak\n' || \ - { printf '[kickoff] no .env.bak to restore\n' >&2; exit 1; } - exit 0 -fi - -if $LIST_MODE; then - ws_dir="$(pwd)/workspace" - if [ ! -d "$ws_dir" ]; then - printf '[kickoff] no workspace/ dir yet — nothing to list.\n' - exit 0 - fi - printf '%-32s %-19s %s\n' "kickoff dir" "last activity" "idea" - for d in "$ws_dir"/kickoff-*; do - [ -d "$d" ] || continue - last="$(stat -c "%y" "$d" 2>/dev/null | cut -d. -f1)" - idea="" - if [ -f "$d/.clk/state/idea.json" ]; then - idea="$(python3 -c "import json,sys;print((json.load(open(sys.argv[1])).get('title') or '')[:60])" "$d/.clk/state/idea.json" 2>/dev/null || true)" - fi - printf '%-32s %-19s %s\n' "$(basename "$d")" "${last:-?}" "$idea" - done - exit 0 -fi - -if [ -n "$CLEAN_OLDER_THAN" ]; then - ws_dir="$(pwd)/workspace" - if [ ! -d "$ws_dir" ]; then - printf '[kickoff] no workspace/ dir; nothing to clean.\n' - exit 0 - fi - # Convert e.g. 7d -> 7 (days), 30m -> 30 (minutes); -mtime expects days, - # -mmin expects minutes. Anything else aborts. - unit="${CLEAN_OLDER_THAN: -1}" - qty="${CLEAN_OLDER_THAN%?}" - if ! [[ "$qty" =~ ^[0-9]+$ ]]; then - printf '[kickoff] --clean expects something like 7d or 30m (got %s)\n' "$CLEAN_OLDER_THAN" >&2 - exit 2 - fi - case "$unit" in - d) find_flag=(-mtime "+$qty") ;; - m) find_flag=(-mmin "+$qty") ;; - *) printf '[kickoff] --clean unit must be d (days) or m (minutes)\n' >&2; exit 2 ;; - esac - mapfile -t targets < <(find "$ws_dir" -mindepth 1 -maxdepth 1 -type d -name "kickoff-*" "${find_flag[@]}" 2>/dev/null) - if [ "${#targets[@]}" -eq 0 ]; then - printf '[kickoff] no kickoff dirs older than %s.\n' "$CLEAN_OLDER_THAN" - exit 0 - fi - printf '[kickoff] would remove %d kickoff dirs older than %s:\n' "${#targets[@]}" "$CLEAN_OLDER_THAN" - for t in "${targets[@]}"; do printf ' - %s\n' "$t"; done - if { exec 7<>/dev/tty; } 2>/dev/null; then - IFS= read -r -p "Delete these? [y/N]: " _ans <&7 - exec 7>&- - if [ "${_ans,,}" = "y" ] || [ "${_ans,,}" = "yes" ]; then - for t in "${targets[@]}"; do rm -rf -- "$t"; printf '[kickoff] removed %s\n' "$t"; done - else - printf '[kickoff] nothing deleted.\n' - fi - else - printf '[kickoff] non-interactive; refusing to delete without confirmation.\n' >&2 - printf '[kickoff] re-run from a terminal to confirm.\n' >&2 - exit 2 - fi - exit 0 -fi - -if $SETUP_MODE; then - _clk_setup - printf '[setup] run %s to start a new session\n' "'$(basename "$0")'" >/dev/tty - exit 0 -fi - -# =========================================================================== -# 1b. First-run nudge: if .env is missing and we have a TTY, offer setup -# inline so the user doesn't have to know about --setup. Declining falls -# through to defaults. CI / non-interactive containers skip silently. -# =========================================================================== -if [ ! -s "$SCRIPT_DIR/.env" ]; then - if { exec 6<>/dev/tty; } 2>/dev/null; then - if [ -f "$SCRIPT_DIR/.env" ]; then - printf '[kickoff] %s is empty (placeholder) — first run?\n' "$SCRIPT_DIR/.env" >&2 - else - printf '[kickoff] No .env found at %s — first run?\n' "$SCRIPT_DIR/.env" >&2 - fi - IFS= read -r -p "[kickoff] Run --setup now to configure? [Y/n]: " _firstrun_ans <&6 - exec 6>&- - case "${_firstrun_ans,,}" in - ""|y|yes) - _clk_setup - ;; - *) - printf '[kickoff] Skipping setup; continuing with defaults.\n' >&2 - ;; - esac - fi -fi +# so the historical `./kickoff.sh` entry point keeps working from a bare +# `git clone` with nothing but bash + python3 installed. -# =========================================================================== -# 2. Load .env (export every assigned var so subprocesses inherit it) -# =========================================================================== -if [ -f "$SCRIPT_DIR/.env" ]; then - echo "[kickoff] loading $SCRIPT_DIR/.env" - set -a - # shellcheck disable=SC1091 - . "$SCRIPT_DIR/.env" - set +a -fi - -# Apply git identity overrides from .env (useful inside Docker containers). -if [ -n "${CLK_GIT_NAME:-}" ]; then - git config --global user.name "$CLK_GIT_NAME" 2>/dev/null || true -fi -if [ -n "${CLK_GIT_EMAIL:-}" ]; then - git config --global user.email "$CLK_GIT_EMAIL" 2>/dev/null || true -fi - -# =========================================================================== -# 3. Apply --arg overrides, then fill in built-in defaults -# =========================================================================== -[ -n "$_OVR_PROVIDER" ] && CLK_PROVIDER="$_OVR_PROVIDER" -[ -n "$_OVR_MAX_ITER" ] && CLK_MAX_ITERATIONS="$_OVR_MAX_ITER" -[ -n "$_OVR_PROJ_NAME" ] && CLK_PROJECT_NAME="$_OVR_PROJ_NAME" -[ -n "$_OVR_NO_TUI" ] && CLK_NO_TUI="$_OVR_NO_TUI" -[ -n "$_OVR_RUN_INSTALL" ] && CLK_RUN_INSTALL="$_OVR_RUN_INSTALL" - -_apply_defaults - -# =========================================================================== -# 4. Validate; if anything is missing, offer --setup then retry or exit -# =========================================================================== -_MISSING="$(_clk_missing)" -if [ -n "$_MISSING" ]; then - printf '[kickoff] Cannot start — missing or invalid configuration:\n\n' >&2 - while IFS= read -r _line; do - printf ' • %s\n' "$_line" >&2 - done <<< "$_MISSING" - printf '\n' >&2 - - _do_setup=false - # Test whether /dev/tty is actually openable (it exists but may not be - # accessible in CI or non-interactive Docker containers). - if { exec 3<>/dev/tty; } 2>/dev/null; then - printf '[kickoff] Run %s --setup to configure, or answer below.\n' \ - "'$(basename "$0")'" >&2 - IFS= read -r -p "[kickoff] Run --setup now? [y/N]: " _ans <&3 - exec 3>&- - [ "${_ans,,}" = "y" ] && _do_setup=true - else - printf '[kickoff] Re-run with %s --setup to configure interactively.\n' \ - "'$(basename "$0")'" >&2 - fi - - if $_do_setup; then - _clk_setup - # Reload .env and re-apply overrides + defaults. - [ -f "$SCRIPT_DIR/.env" ] && { set -a; . "$SCRIPT_DIR/.env"; set +a; } - [ -n "$_OVR_PROVIDER" ] && CLK_PROVIDER="$_OVR_PROVIDER" - [ -n "$_OVR_MAX_ITER" ] && CLK_MAX_ITERATIONS="$_OVR_MAX_ITER" - [ -n "$_OVR_PROJ_NAME" ] && CLK_PROJECT_NAME="$_OVR_PROJ_NAME" - [ -n "$_OVR_NO_TUI" ] && CLK_NO_TUI="$_OVR_NO_TUI" - [ -n "$_OVR_RUN_INSTALL" ] && CLK_RUN_INSTALL="$_OVR_RUN_INSTALL" - _apply_defaults - _MISSING="$(_clk_missing)" - if [ -n "$_MISSING" ]; then - printf '[kickoff] Still missing after setup — cannot continue:\n\n' >&2 - while IFS= read -r _line; do printf ' • %s\n' "$_line" >&2; done <<< "$_MISSING" - exit 2 - fi - else - exit 2 - fi -fi - -# =========================================================================== -# 5. Create kickoff directory under workspace/; never touch the source tree -# =========================================================================== -TS="$(date +%Y%m%d-%H%M%S)" -WORKSPACE_DIR="$(pwd)/workspace" -KICKOFF_DIR="$WORKSPACE_DIR/kickoff-$TS" -mkdir -p "$WORKSPACE_DIR" -if [ -e "$KICKOFF_DIR" ]; then - echo "[kickoff] $KICKOFF_DIR already exists; refusing to overwrite" >&2 - exit 1 -fi -echo "[kickoff] creating $KICKOFF_DIR" -mkdir -p "$KICKOFF_DIR" - -copy_if_present() { - local src="$1" dst="$2" - if [ -e "$src" ]; then cp -R "$src" "$dst"; fi -} - -# Harness sources, scripts, and packaging metadata all live under -# .clk/harness/ so the project root looks like a normal codebase from -# the agents' point of view. The launcher (.clk/scripts/clk) and the -# installer know to look here. -mkdir -p "$KICKOFF_DIR/.clk/harness" -copy_if_present "$SCRIPT_DIR/clk_harness" "$KICKOFF_DIR/.clk/harness/clk_harness" -copy_if_present "$SCRIPT_DIR/scripts" "$KICKOFF_DIR/.clk/harness/scripts" -copy_if_present "$SCRIPT_DIR/pyproject.toml" "$KICKOFF_DIR/.clk/harness/pyproject.toml" -copy_if_present "$SCRIPT_DIR/requirements.txt" "$KICKOFF_DIR/.clk/harness/requirements.txt" -copy_if_present "$SCRIPT_DIR/README.md" "$KICKOFF_DIR/.clk/harness/README.md" +set -euo pipefail -# Launcher shim lives under .clk/scripts/ so the project root stays clean. -# The shim exports CLK_PROJECT_ROOT so the harness launcher resolves harness -# state in the right place even though the script is inside .clk/. -mkdir -p "$KICKOFF_DIR/.clk/scripts" -cat > "$KICKOFF_DIR/.clk/scripts/clk" <<'SHIM' -#!/usr/bin/env bash SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd)" -PROJECT_ROOT="$(cd -- "$SCRIPT_DIR/../.." >/dev/null 2>&1 && pwd)" -export CLK_PROJECT_ROOT="$PROJECT_ROOT" -exec "$PROJECT_ROOT/.clk/harness/scripts/clk" "$@" -SHIM -chmod +x "$KICKOFF_DIR/.clk/scripts/clk" - -# Strip __pycache__ that cp -R may have picked up. -find "$KICKOFF_DIR/.clk/harness" -type d -name "__pycache__" -prune -exec rm -rf {} + 2>/dev/null || true - -chmod +x "$KICKOFF_DIR/.clk/harness/scripts/clk" \ - "$KICKOFF_DIR/.clk/harness/scripts/install_local.sh" \ - "$KICKOFF_DIR/.clk/harness/scripts/run_loop.sh" 2>/dev/null || true - -# Manifest so future-you knows how this dir was launched. -cat > "$KICKOFF_DIR/KICKOFF.md" < .gitignore <<'GITIGNORE' -# All harness state lives under .clk/ — ignore it entirely. -.clk/ -# Secrets -/.env -/.env.example -/.env.bak -/.env.partial -.env.local -*.pem -*.key -*_id_rsa* -/secrets/ -/.secrets/ -# Editor / OS junk -__pycache__/ -*.pyc -.DS_Store -.idea/ -.vscode/ -GITIGNORE - -# Give the kickoff dir its OWN git repo. Otherwise, when this directory is -# created inside an existing repo, clk init's `git commit` would land in the -# outer repo because git walks upward to find `.git/`. -if command -v git >/dev/null 2>&1; then - if [ ! -d .git ]; then - git init -q - git config user.name "CLK Kickoff" - git config user.email "kickoff@local.invalid" - fi - - # Install a pre-push hook that aborts on obvious secret patterns so a - # leaked .env or key file can't reach GitHub. Pure bash so it ships - # with the kickoff bundle. The user can bypass with `git push --no-verify` - # when they know better. - mkdir -p .git/hooks - cat > .git/hooks/pre-push <<'HOOK' -#!/usr/bin/env bash -# CLK pre-push secret scan. Bypass with `git push --no-verify` when sure. -set -eo pipefail -while read -r local_ref local_sha remote_ref remote_sha; do - [ "$local_sha" = "0000000000000000000000000000000000000000" ] && continue - range="$local_sha" - if [ "$remote_sha" != "0000000000000000000000000000000000000000" ]; then - range="$remote_sha..$local_sha" - fi - hits=$(git log -p "$range" 2>/dev/null | grep -E \ - -e 'ANTHROPIC_API_KEY=[A-Za-z0-9_\-]+' \ - -e 'OPENAI_API_KEY=[A-Za-z0-9_\-]+' \ - -e 'OPENROUTER_API_KEY=[A-Za-z0-9_\-]+' \ - -e 'GEMINI_API_KEY=[A-Za-z0-9_\-]+' \ - -e 'GOOGLE_API_KEY=[A-Za-z0-9_\-]+' \ - -e 'sk-[A-Za-z0-9]{20,}' \ - -e 'xoxb-[A-Za-z0-9-]{20,}' \ - -e 'BEGIN (RSA|OPENSSH|EC|DSA|PGP) PRIVATE KEY' \ - || true) - if [ -n "$hits" ]; then - echo "[pre-push] aborting — possible secret(s) in $range:" >&2 - echo "$hits" | head -n 5 >&2 - echo "" >&2 - echo "To override: git push --no-verify (only when you're sure)." >&2 - exit 1 - fi -done -HOOK - chmod +x .git/hooks/pre-push - - # Connect a GitHub remote if the wizard recorded one. - if [ -n "${CLK_GITHUB_REMOTE:-}" ] && [ "${CLK_GITHUB_MODE:-skip}" != "skip" ]; then - case "$CLK_GITHUB_MODE" in - existing) - if ! git remote get-url origin >/dev/null 2>&1; then - echo "[kickoff] linking existing GitHub remote: $CLK_GITHUB_REMOTE" - git remote add origin "$CLK_GITHUB_REMOTE" - fi - ;; - create) - if command -v gh >/dev/null 2>&1 && gh auth status >/dev/null 2>&1; then - if ! git remote get-url origin >/dev/null 2>&1; then - echo "[kickoff] creating GitHub repo: $CLK_GITHUB_REMOTE (private)" - gh repo create "$CLK_GITHUB_REMOTE" --private --source=. --remote=origin \ - || echo "[kickoff] gh repo create failed (continuing without remote)" - fi - else - echo "[kickoff] CLK_GITHUB_MODE=create but gh CLI is not authenticated; skipping remote." - fi - ;; - esac - fi -fi -CLK="./.clk/scripts/clk" - -if [ "${CLK_RUN_INSTALL,,}" = "true" ]; then - echo "[kickoff] running .clk/harness/scripts/install_local.sh" - ./.clk/harness/scripts/install_local.sh || echo "[kickoff] install_local.sh reported a problem (continuing)" -fi - -echo "[kickoff] clk init" -"$CLK" init --name "$CLK_PROJECT_NAME" - -# --------------------------------------------------------------------------- -# Apply CLK_* env-var overrides into .clk/config/clk.config.json. -# -# The harness ships sane defaults in DEFAULT_CLK_CONFIG (see -# clk_harness/config.py). This block lets the user override any of them -# from the environment without hand-editing the JSON. Recognised vars: -# -# Robustness loops (see README "Robustness loops" section): -# CLK_ROBUSTNESS_AUTO_CONSENSUS off | on_careful | always -# CLK_ROBUSTNESS_AUTO_REFINE off | careful_only | all -# CLK_ROBUSTNESS_MAX_QUALITY_RETRIES int >= 0 -# CLK_ROBUSTNESS_MIN_RESPONSE_CHARS int > 0 -# CLK_ROBUSTNESS_REFINE_MAX_ROUNDS int >= 1 -# CLK_ROBUSTNESS_REFINE_ACCEPT_THRESHOLD float 0..1 -# CLK_ROBUSTNESS_QA_PARALLEL_JUDGES int >= 1 -# CLK_ROBUSTNESS_MAX_QA_DEPTH int >= 1 -# CLK_ROBUSTNESS_PLATEAU_WINDOW int >= 2 -# CLK_ROBUSTNESS_PLATEAU_ACTION off | escalate_only | -# reframe_only | escalate_then_reframe -# CLK_ROBUSTNESS_DEBATE off | careful_only | all -# CLK_ROBUSTNESS_DEBATE_LENSES comma-separated lens names -# CLK_ROBUSTNESS_DEBATE_MAX_ROUNDS int >= 1 -# -# Autonomous-mission knobs (clk run drives a full mission to done): -# CLK_MISSION_* max_phases / max_total_cycles / -# phase_gate / charter_first / ... -# CLK_DONE_GATE_* enabled + require_* completion checks -# CLK_NOOP_GUARD_* enabled / max_redispatch / ... -# CLK_DELIBERATION_* enabled / require_open_questions_resolved / ... -# CLK_VALIDATION_AUTO_DERIVE true | false -# -# Prior knobs (already supported, surfaced here for parity): -# CLK_PROVIDER_TIMEOUT_S int seconds, 0 = harness default -# CLK_PROVIDER_NO_OUTPUT_TIMEOUT_S int seconds -# CLK_PROVIDER_RETRY_MAX_RETRIES int -# CLK_PROVIDER_RETRY_BACKOFF_S float seconds -# CLK_PROVIDER_RETRY_STAGE_MAX_RETRIES int -# CLK_PROVIDER_RETRY_STAGE_BACKOFF_S float seconds -# CLK_SUPERVISE_MAX_CYCLES int -# CLK_CONSENSUS_MAX_SAMPLES int -# CLK_CONSENSUS_MAX_PARALLEL int -# CLK_CASTING_MAX_DYNAMIC_ROLES int -# CLK_AUTO_COMMIT true | false -# CLK_VALIDATION_MAX_FILES_PER_BATCH int -# CLK_VALIDATION_WARN_FILES_PER_BATCH int -# CLK_META_PROMPT_DISPATCH off | careful_only | always -# CLK_META_PROMPT_ROLE off | careful_only | always -# CLK_REVIEW_PER_STAGE true | false -# CLK_RECOVERY_MAX_PER_STAGE int -# -# Any unset variable falls through to the harness's default. We don't -# touch keys we didn't see — so a partially-set env still gets the rest -# from DEFAULT_CLK_CONFIG. -# --------------------------------------------------------------------------- -echo "[kickoff] applying CLK_* env-var overrides to .clk/config/clk.config.json" -python3 - <<'PY' -import json, os -from pathlib import Path - -p = Path(".clk/config/clk.config.json") -if not p.exists(): - raise SystemExit(0) # nothing to override -cfg = json.loads(p.read_text(encoding="utf-8")) - - -def _set(env_var, *path, cast=str): - raw = os.environ.get(env_var) - if raw is None or raw == "": - return - try: - val = cast(raw) - except (TypeError, ValueError): - return - cur = cfg - for key in path[:-1]: - cur = cur.setdefault(key, {}) - cur[path[-1]] = val - - -def _bool(s): - return str(s).strip().lower() in {"1", "true", "yes", "y", "on"} - - -def _csv(s): - return [item.strip() for item in str(s).split(",") if item.strip()] - - -# Robustness block -_set("CLK_ROBUSTNESS_AUTO_CONSENSUS", "robustness", "auto_consensus") -_set("CLK_ROBUSTNESS_AUTO_REFINE", "robustness", "auto_refine") -_set("CLK_ROBUSTNESS_MAX_QUALITY_RETRIES", "robustness", "max_quality_retries", cast=int) -_set("CLK_ROBUSTNESS_MIN_RESPONSE_CHARS", "robustness", "min_response_chars", cast=int) -_set("CLK_ROBUSTNESS_REFINE_MAX_ROUNDS", "robustness", "refine_max_rounds", cast=int) -_set("CLK_ROBUSTNESS_REFINE_ACCEPT_THRESHOLD", "robustness", "refine_accept_threshold", cast=float) -_set("CLK_ROBUSTNESS_QA_PARALLEL_JUDGES", "robustness", "qa_parallel_judges", cast=int) -_set("CLK_ROBUSTNESS_MAX_QA_DEPTH", "robustness", "max_qa_depth", cast=int) -_set("CLK_ROBUSTNESS_PLATEAU_WINDOW", "robustness", "plateau_window", cast=int) -_set("CLK_ROBUSTNESS_PLATEAU_ACTION", "robustness", "plateau_action") -_set("CLK_ROBUSTNESS_DEBATE", "robustness", "debate") -_set("CLK_ROBUSTNESS_DEBATE_LENSES", "robustness", "debate_lenses", cast=_csv) -_set("CLK_ROBUSTNESS_DEBATE_MAX_ROUNDS", "robustness", "debate_max_rounds", cast=int) - -# Autonomous mission block -_set("CLK_MISSION_MAX_PHASES", "mission", "max_phases", cast=int) -_set("CLK_MISSION_MAX_ITERATIONS_PER_PHASE", "mission", "max_iterations_per_phase", cast=int) -_set("CLK_MISSION_MAX_TOTAL_CYCLES", "mission", "max_total_cycles", cast=int) -_set("CLK_MISSION_PHASE_GATE", "mission", "phase_gate", cast=_bool) -_set("CLK_MISSION_REFINE_REQUIRED", "mission", "refine_required", cast=_bool) -_set("CLK_MISSION_AUTO_CONSENSUS_ON_STALL", "mission", "auto_consensus_on_stall", cast=_bool) -_set("CLK_MISSION_CHARTER_FIRST", "mission", "charter_first", cast=_bool) -_set("CLK_MISSION_COMMIT_TRACE", "mission", "commit_trace", cast=_bool) -_set("CLK_MISSION_COMMIT_GRANULARITY", "mission", "commit_granularity") -_set("CLK_MISSION_MIN_CYCLES_BEFORE_DONE", "mission", "min_cycles_before_done", cast=int) -_set("CLK_MISSION_TELEMETRY_STDOUT", "mission", "telemetry_stdout", cast=_bool) -_set("CLK_MISSION_ON_BUDGET_EXHAUSTED", "mission", "on_budget_exhausted") -_set("CLK_MISSION_DEFAULT_PHASES", "mission", "default_phases", cast=_csv) - -# Done-gate block -_set("CLK_DONE_GATE_ENABLED", "done_gate", "enabled", cast=_bool) -_set("CLK_DONE_GATE_REQUIRE_TESTS_GREEN", "done_gate", "require_tests_green", cast=_bool) -_set("CLK_DONE_GATE_REQUIRE_DELIVERABLES", "done_gate", "require_deliverables", cast=_bool) -_set("CLK_DONE_GATE_MIN_DELIVERABLE_FILES", "done_gate", "min_deliverable_files", cast=int) -_set("CLK_DONE_GATE_REQUIRE_QA_PASS", "done_gate", "require_qa_pass", cast=_bool) -_set("CLK_DONE_GATE_REQUIRE_RALPH_PASS", "done_gate", "require_ralph_pass", cast=_bool) -_set("CLK_DONE_GATE_FORBID_TODO_MARKERS", "done_gate", "forbid_todo_markers", cast=_bool) -_set("CLK_DONE_GATE_MAX_FINISH_ATTEMPTS", "done_gate", "max_finish_attempts", cast=int) - -# No-op guard block -_set("CLK_NOOP_GUARD_ENABLED", "noop_guard", "enabled", cast=_bool) -_set("CLK_NOOP_GUARD_MAX_REDISPATCH", "noop_guard", "max_redispatch", cast=int) -_set("CLK_NOOP_GUARD_PRODUCING_AGENTS", "noop_guard", "producing_agents", cast=_csv) -_set("CLK_NOOP_GUARD_TREAT_OUTPUTS_STAGE_AS_PRODUCING", "noop_guard", "treat_outputs_stage_as_producing", cast=_bool) - -# Deliberation block -_set("CLK_DELIBERATION_ENABLED", "deliberation", "enabled", cast=_bool) -_set("CLK_DELIBERATION_ENCOURAGE_QUESTIONS", "deliberation", "encourage_questions", cast=_bool) -_set("CLK_DELIBERATION_REQUIRE_OPEN_QUESTIONS_RESOLVED", "deliberation", "require_open_questions_resolved", cast=_bool) -_set("CLK_DELIBERATION_SELF_REFLECT_PREAMBLE", "deliberation", "self_reflect_preamble", cast=_bool) -_set("CLK_DELIBERATION_MIN_DEBATE_ROUNDS", "deliberation", "min_debate_rounds", cast=int) - -# Validation auto-derive -_set("CLK_VALIDATION_AUTO_DERIVE", "validation", "auto_derive", cast=_bool) - -# Prior knobs -_set("CLK_PROVIDER_TIMEOUT_S", "provider_timeout_s", cast=int) -_set("CLK_PROVIDER_NO_OUTPUT_TIMEOUT_S", "provider_no_output_timeout_s", cast=int) -_set("CLK_PROVIDER_RETRY_MAX_RETRIES", "provider_retry", "max_retries", cast=int) -_set("CLK_PROVIDER_RETRY_BACKOFF_S", "provider_retry", "backoff_s", cast=float) -_set("CLK_PROVIDER_RETRY_STAGE_MAX_RETRIES", "provider_retry", "stage_max_retries", cast=int) -_set("CLK_PROVIDER_RETRY_STAGE_BACKOFF_S", "provider_retry", "stage_backoff_s", cast=float) -_set("CLK_SUPERVISE_MAX_CYCLES", "supervise", "max_cycles", cast=int) -_set("CLK_CONSENSUS_MAX_SAMPLES", "consensus", "max_samples", cast=int) -_set("CLK_CONSENSUS_MAX_PARALLEL", "consensus", "max_parallel", cast=int) -_set("CLK_CASTING_MAX_DYNAMIC_ROLES", "casting", "max_dynamic_roles", cast=int) -_set("CLK_AUTO_COMMIT", "auto_commit", cast=_bool) -_set("CLK_VALIDATION_MAX_FILES_PER_BATCH", "validation", "max_files_per_batch", cast=int) -_set("CLK_VALIDATION_WARN_FILES_PER_BATCH", "validation", "warn_files_per_batch", cast=int) -_set("CLK_META_PROMPT_DISPATCH", "meta_prompt", "dispatch") -_set("CLK_META_PROMPT_ROLE", "meta_prompt", "role") -_set("CLK_REVIEW_PER_STAGE", "review", "per_stage", cast=_bool) -_set("CLK_RECOVERY_MAX_PER_STAGE", "recovery", "max_per_stage", cast=int) - -p.write_text(json.dumps(cfg, indent=2, sort_keys=True) + "\n", encoding="utf-8") -PY - -echo "[kickoff] activating provider: $CLK_PROVIDER" -CLK_PROVIDER="$CLK_PROVIDER" \ -CLK_OLLAMA_ENDPOINT="${CLK_OLLAMA_ENDPOINT:-http://localhost:11434}" \ -CLK_OLLAMA_MODEL="${CLK_OLLAMA_MODEL:-llama3.1}" \ -CLK_OPENWEBUI_ENDPOINT="${CLK_OPENWEBUI_ENDPOINT:-}" \ -CLK_OPENWEBUI_API_KEY="${CLK_OPENWEBUI_API_KEY:-}" \ -CLK_OPENWEBUI_MODEL="${CLK_OPENWEBUI_MODEL:-}" \ -CLK_PI_MODEL="${CLK_PI_MODEL:-}" \ -CLK_PI_API_KEY="${CLK_PI_API_KEY:-}" \ -CLK_PI_KEY_TYPE="${CLK_PI_KEY_TYPE:-}" \ -CLK_AUTH_MODE="${CLK_AUTH_MODE:-cli}" \ -python3 - <<'PY' -import json, os -from pathlib import Path -p = Path(".clk/config/providers.json") -data = json.loads(p.read_text(encoding="utf-8")) -provider = os.environ["CLK_PROVIDER"] -data["active"] = provider -provs = data.setdefault("providers", {}) -auth_mode = os.environ.get("CLK_AUTH_MODE", "cli") -# For CLI-driven providers, mode=cli (default) spawns the CLI subprocess. -# mode=api makes the provider call the upstream HTTP API directly with no -# subprocess — which is exactly what the user expects when they choose -# "apikey" auth: the API key alone, no local CLI dependency. -for cli_provider in ("claude", "codex", "gemini"): - provs.setdefault(cli_provider, {"type": cli_provider}) - provs[cli_provider]["mode"] = "api" if auth_mode == "apikey" else "cli" -if provider == "claude" and auth_mode == "apikey": - provs["claude"]["api_key"] = os.environ.get("ANTHROPIC_API_KEY", "") -if provider == "codex" and auth_mode == "apikey": - provs["codex"]["api_key"] = os.environ.get("OPENAI_API_KEY", "") -if provider == "gemini" and auth_mode == "apikey": - provs["gemini"]["api_key"] = ( - os.environ.get("GEMINI_API_KEY", "") - or os.environ.get("GOOGLE_API_KEY", "") - ) -if provider == "ollama": - provs.setdefault("ollama", {}) - provs["ollama"]["endpoint"] = os.environ["CLK_OLLAMA_ENDPOINT"] - provs["ollama"]["model"] = os.environ["CLK_OLLAMA_MODEL"] -elif provider == "openwebui": - provs.setdefault("openwebui", {"type": "openwebui"}) - provs["openwebui"]["type"] = "openwebui" - provs["openwebui"]["endpoint"] = os.environ["CLK_OPENWEBUI_ENDPOINT"] - provs["openwebui"]["api_key"] = os.environ["CLK_OPENWEBUI_API_KEY"] - provs["openwebui"]["model"] = os.environ["CLK_OPENWEBUI_MODEL"] -elif provider == "pi": - provs.setdefault("pi", {"type": "pi", "command": "pi", "args": []}) - pi_model = os.environ.get("CLK_PI_MODEL", "").strip() - pi_key = os.environ.get("CLK_PI_API_KEY", "").strip() - pi_key_type = os.environ.get("CLK_PI_KEY_TYPE", "").strip().lower() - if pi_model: - provs["pi"]["model"] = pi_model - if pi_key: - provs["pi"]["api_key"] = pi_key - if pi_key_type: - provs["pi"]["key_type"] = pi_key_type -p.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n", encoding="utf-8") -PY -"$CLK" configure --set "default_provider=$CLK_PROVIDER" >/dev/null - -if [ "${CLK_NO_TUI:-false}" = "true" ]; then - # Non-interactive pipeline. Useful for CI / smoke tests and Docker without -it. - echo "[kickoff] clk idea" - "$CLK" idea "$IDEA" --title "$CLK_PROJECT_NAME" - echo "[kickoff] clk plan" - "$CLK" plan || echo "[kickoff] plan reported failures (continuing)" - echo "[kickoff] clk run" - "$CLK" run || echo "[kickoff] run reported failures (continuing)" - echo "[kickoff] clk loop --max-iterations $CLK_MAX_ITERATIONS" - "$CLK" loop --max-iterations "$CLK_MAX_ITERATIONS" +# Interpreter resolution mirrors scripts/clk: prefer a project-local +# .clk/venv (populated by scripts/install_local.sh), then fall back to +# python3 / python on PATH. +PY="" +if [ -x "$SCRIPT_DIR/.clk/venv/bin/python" ]; then + PY="$SCRIPT_DIR/.clk/venv/bin/python" +elif command -v python3 >/dev/null 2>&1; then + PY="$(command -v python3)" +elif command -v python >/dev/null 2>&1; then + PY="$(command -v python)" else - # Default: hand control to the TUI dashboard. If $IDEA is set, it pre-seeds - # the idea and starts an engineering cycle; otherwise the dashboard waits - # for the user to type one into the input field. - echo "[kickoff] launching TUI (use /quit to exit, /help-style commands listed inside)" - if [ -n "$IDEA" ]; then - "$CLK" tui "$IDEA" - else - "$CLK" tui - fi + echo "kickoff: no python interpreter found on PATH" >&2 + exit 127 fi -echo -echo "[kickoff] complete" -echo "[kickoff] kickoff dir: $KICKOFF_DIR" -echo "[kickoff] inspect: cd \"$KICKOFF_DIR\" && ./.clk/scripts/clk status" -echo "[kickoff] workspace: $WORKSPACE_DIR" -echo "[kickoff] reset: rm -rf \"$KICKOFF_DIR\"" +# Make the adjacent clk_harness/ package importable from a bare checkout +# (no pip install needed); an installed package keeps working too. +export PYTHONPATH="${SCRIPT_DIR}${PYTHONPATH:+:${PYTHONPATH}}" + +exec "$PY" -m clk_harness.cli kickoff "$@" diff --git a/pyproject.toml b/pyproject.toml index b09640c..313ebe8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,6 +39,32 @@ include = ["clk_harness*"] # inside the wheel so `clk web` can serve it without a Node toolchain. clk_harness = ["webui_dist/**/*"] +[tool.ruff] +# Matches the prevailing style: most lines are <100 chars, with a small tail +# of longer prompt/format strings; 120 flags only the egregious outliers. +line-length = 120 + +[tool.ruff.lint] +# pycodestyle errors/warnings, pyflakes, import sorting, and no bare excepts. +# (E722 is part of "E"; listed explicitly because it is a hard requirement.) +select = ["E", "F", "W", "I", "E722"] + +[tool.ruff.lint.per-file-ignores] +# Agent prompt templates hold long lines inside triple-quoted strings whose +# content must not be rewrapped (it is sent verbatim to the model). +"clk_harness/templates/prompts.py" = ["E501"] + +[tool.mypy] +# Lenient baseline: keep the run clean without requiring annotations yet. +ignore_missing_imports = true +# PyYAML ships untyped; don't require types-PyYAML to be installed. +disable_error_code = ["import-untyped"] +check_untyped_defs = false +disallow_untyped_defs = false +warn_unused_configs = true +no_implicit_optional = false +follow_imports = "normal" + [tool.pytest.ini_options] asyncio_mode = "auto" asyncio_default_fixture_loop_scope = "function" diff --git a/scripts/run_loop.sh b/scripts/run_loop.sh index 99f4559..09e3578 100644 --- a/scripts/run_loop.sh +++ b/scripts/run_loop.sh @@ -2,7 +2,7 @@ # Convenience wrapper: run the Ralph loop with a sensible default budget. # # This is a thin shim around `clk loop`. The loop's behavior is -# documented in the README sections: +# documented in docs/MISSIONS.md sections: # # * "Loops" — the two Ralph modes (refinement, # autoresearch) and how they pick what to do diff --git a/tests/golden/kickoff/base_clk_config.json b/tests/golden/kickoff/base_clk_config.json new file mode 100644 index 0000000..3b14bd1 --- /dev/null +++ b/tests/golden/kickoff/base_clk_config.json @@ -0,0 +1,111 @@ +{ + "auto_commit": true, + "casting": { + "auto_cast_on_idea": true, + "max_dynamic_roles": 12 + }, + "consensus": { + "max_parallel": 4, + "max_samples": 6 + }, + "default_provider": "shell", + "default_workflow": "engineering", + "deliberation": { + "enabled": true, + "encourage_questions": true, + "min_debate_rounds": 1, + "require_open_questions_resolved": true, + "self_reflect_preamble": true + }, + "done_gate": { + "enabled": true, + "forbid_todo_markers": false, + "max_finish_attempts": 5, + "min_deliverable_files": 1, + "require_deliverables": true, + "require_qa_pass": true, + "require_ralph_pass": true, + "require_tests_green": true + }, + "dry_run": false, + "max_iterations": 100, + "meta_prompt": { + "dispatch": "always", + "role": "on" + }, + "mission": { + "auto_consensus_on_stall": true, + "charter_first": true, + "commit_granularity": "batch", + "commit_trace": true, + "default_phases": [ + "discovery", + "product", + "engineering", + "validation", + "deployment" + ], + "max_iterations_per_phase": 3, + "max_phases": 12, + "max_total_cycles": 60, + "min_cycles_before_done": 1, + "on_budget_exhausted": "advance", + "phase_gate": true, + "refine_required": true, + "telemetry_stdout": true + }, + "noop_guard": { + "enabled": true, + "max_redispatch": 2, + "producing_agents": [ + "engineer", + "ralph" + ], + "treat_outputs_stage_as_producing": true + }, + "project_name": "golden-proj", + "provider_no_output_timeout_s": 0, + "provider_retry": { + "backoff_s": 5, + "max_retries": 10, + "stage_backoff_s": 30, + "stage_max_retries": 10 + }, + "provider_timeout_s": 0, + "recovery": { + "dispatch_on_unmet_outputs": true, + "max_per_stage": 3 + }, + "robustness": { + "auto_consensus": "on_careful", + "auto_refine": "all", + "debate": "careful_only", + "debate_lenses": [ + "correctness", + "security", + "simplicity" + ], + "debate_max_rounds": 2, + "max_qa_depth": 6, + "max_quality_retries": 4, + "min_response_chars": 40, + "plateau_action": "escalate_then_reframe", + "plateau_window": 15, + "qa_parallel_judges": 1, + "refine_accept_threshold": 0.8, + "refine_max_rounds": 10 + }, + "supervise": { + "max_consecutive_no_progress": 8, + "max_cycles": 100, + "stall_rescue": true + }, + "validation": { + "auto_derive": true, + "derived_command": null, + "max_files_per_batch": 25, + "rollback_on_failure": "careful", + "warn_files_per_batch": 5 + }, + "version": 1 +} diff --git a/tests/golden/kickoff/base_providers.json b/tests/golden/kickoff/base_providers.json new file mode 100644 index 0000000..620d26e --- /dev/null +++ b/tests/golden/kickoff/base_providers.json @@ -0,0 +1,54 @@ +{ + "active": "shell", + "providers": { + "claude": { + "args": [ + "--print" + ], + "command": "claude", + "description": "Claude Code CLI. Detected via 'claude' on PATH.", + "type": "claude" + }, + "codex": { + "args": [ + "exec" + ], + "command": "codex", + "description": "OpenAI Codex CLI. Detected via 'codex' on PATH.", + "type": "codex" + }, + "gemini": { + "args": [], + "command": "gemini", + "description": "Google Gemini CLI. Detected via 'gemini' on PATH.", + "type": "gemini" + }, + "ollama": { + "description": "Local Ollama HTTP API.", + "endpoint": "http://localhost:11434", + "model": "llama3.1", + "type": "ollama" + }, + "openwebui": { + "api_key": "", + "description": "OpenWebUI server (OpenAI-compatible HTTP). Set host/key/model on kickoff.", + "endpoint": "http://localhost:8080", + "model": "", + "type": "openwebui" + }, + "pi": { + "api_key": "", + "args": [], + "command": "pi", + "description": "Pi terminal harness. Cloned to .clk/tools/pi if needed.", + "key_type": "openrouter", + "model": "", + "type": "pi" + }, + "shell": { + "command": null, + "description": "Dummy provider that echoes prompts. Always available.", + "type": "shell" + } + } +} diff --git a/tests/golden/kickoff/config_bool_falsy_variants.json b/tests/golden/kickoff/config_bool_falsy_variants.json new file mode 100644 index 0000000..e2352f4 --- /dev/null +++ b/tests/golden/kickoff/config_bool_falsy_variants.json @@ -0,0 +1,111 @@ +{ + "auto_commit": false, + "casting": { + "auto_cast_on_idea": true, + "max_dynamic_roles": 12 + }, + "consensus": { + "max_parallel": 4, + "max_samples": 6 + }, + "default_provider": "shell", + "default_workflow": "engineering", + "deliberation": { + "enabled": false, + "encourage_questions": true, + "min_debate_rounds": 1, + "require_open_questions_resolved": true, + "self_reflect_preamble": true + }, + "done_gate": { + "enabled": false, + "forbid_todo_markers": false, + "max_finish_attempts": 5, + "min_deliverable_files": 1, + "require_deliverables": true, + "require_qa_pass": true, + "require_ralph_pass": true, + "require_tests_green": true + }, + "dry_run": false, + "max_iterations": 100, + "meta_prompt": { + "dispatch": "always", + "role": "on" + }, + "mission": { + "auto_consensus_on_stall": true, + "charter_first": true, + "commit_granularity": "batch", + "commit_trace": true, + "default_phases": [ + "discovery", + "product", + "engineering", + "validation", + "deployment" + ], + "max_iterations_per_phase": 3, + "max_phases": 12, + "max_total_cycles": 60, + "min_cycles_before_done": 1, + "on_budget_exhausted": "advance", + "phase_gate": false, + "refine_required": true, + "telemetry_stdout": true + }, + "noop_guard": { + "enabled": false, + "max_redispatch": 2, + "producing_agents": [ + "engineer", + "ralph" + ], + "treat_outputs_stage_as_producing": true + }, + "project_name": "golden-proj", + "provider_no_output_timeout_s": 0, + "provider_retry": { + "backoff_s": 5, + "max_retries": 10, + "stage_backoff_s": 30, + "stage_max_retries": 10 + }, + "provider_timeout_s": 0, + "recovery": { + "dispatch_on_unmet_outputs": true, + "max_per_stage": 3 + }, + "robustness": { + "auto_consensus": "on_careful", + "auto_refine": "all", + "debate": "careful_only", + "debate_lenses": [ + "correctness", + "security", + "simplicity" + ], + "debate_max_rounds": 2, + "max_qa_depth": 6, + "max_quality_retries": 4, + "min_response_chars": 40, + "plateau_action": "escalate_then_reframe", + "plateau_window": 15, + "qa_parallel_judges": 1, + "refine_accept_threshold": 0.8, + "refine_max_rounds": 10 + }, + "supervise": { + "max_consecutive_no_progress": 8, + "max_cycles": 100, + "stall_rescue": true + }, + "validation": { + "auto_derive": false, + "derived_command": null, + "max_files_per_batch": 25, + "rollback_on_failure": "careful", + "warn_files_per_batch": 5 + }, + "version": 1 +} diff --git a/tests/golden/kickoff/config_bool_truthy_variants.json b/tests/golden/kickoff/config_bool_truthy_variants.json new file mode 100644 index 0000000..ee09bcd --- /dev/null +++ b/tests/golden/kickoff/config_bool_truthy_variants.json @@ -0,0 +1,114 @@ +{ + "auto_commit": true, + "casting": { + "auto_cast_on_idea": true, + "max_dynamic_roles": 12 + }, + "consensus": { + "max_parallel": 4, + "max_samples": 6 + }, + "default_provider": "shell", + "default_workflow": "engineering", + "deliberation": { + "enabled": true, + "encourage_questions": true, + "min_debate_rounds": 1, + "require_open_questions_resolved": true, + "self_reflect_preamble": true + }, + "done_gate": { + "enabled": true, + "forbid_todo_markers": false, + "max_finish_attempts": 5, + "min_deliverable_files": 1, + "require_deliverables": true, + "require_qa_pass": true, + "require_ralph_pass": true, + "require_tests_green": true + }, + "dry_run": false, + "max_iterations": 100, + "meta_prompt": { + "dispatch": "always", + "role": "on" + }, + "mission": { + "auto_consensus_on_stall": true, + "charter_first": true, + "commit_granularity": "batch", + "commit_trace": true, + "default_phases": [ + "discovery", + "product", + "engineering", + "validation", + "deployment" + ], + "max_iterations_per_phase": 3, + "max_phases": 12, + "max_total_cycles": 60, + "min_cycles_before_done": 1, + "on_budget_exhausted": "advance", + "phase_gate": true, + "refine_required": true, + "telemetry_stdout": true + }, + "noop_guard": { + "enabled": true, + "max_redispatch": 2, + "producing_agents": [ + "engineer", + "ralph" + ], + "treat_outputs_stage_as_producing": true + }, + "project_name": "golden-proj", + "provider_no_output_timeout_s": 0, + "provider_retry": { + "backoff_s": 5, + "max_retries": 10, + "stage_backoff_s": 30, + "stage_max_retries": 10 + }, + "provider_timeout_s": 0, + "recovery": { + "dispatch_on_unmet_outputs": true, + "max_per_stage": 3 + }, + "review": { + "per_stage": true + }, + "robustness": { + "auto_consensus": "on_careful", + "auto_refine": "all", + "debate": "careful_only", + "debate_lenses": [ + "correctness", + "security", + "simplicity" + ], + "debate_max_rounds": 2, + "max_qa_depth": 6, + "max_quality_retries": 4, + "min_response_chars": 40, + "plateau_action": "escalate_then_reframe", + "plateau_window": 15, + "qa_parallel_judges": 1, + "refine_accept_threshold": 0.8, + "refine_max_rounds": 10 + }, + "supervise": { + "max_consecutive_no_progress": 8, + "max_cycles": 100, + "stall_rescue": true + }, + "validation": { + "auto_derive": true, + "derived_command": null, + "max_files_per_batch": 25, + "rollback_on_failure": "careful", + "warn_files_per_batch": 5 + }, + "version": 1 +} diff --git a/tests/golden/kickoff/config_csv_edge_cases.json b/tests/golden/kickoff/config_csv_edge_cases.json new file mode 100644 index 0000000..f4a2a6f --- /dev/null +++ b/tests/golden/kickoff/config_csv_edge_cases.json @@ -0,0 +1,104 @@ +{ + "auto_commit": true, + "casting": { + "auto_cast_on_idea": true, + "max_dynamic_roles": 12 + }, + "consensus": { + "max_parallel": 4, + "max_samples": 6 + }, + "default_provider": "shell", + "default_workflow": "engineering", + "deliberation": { + "enabled": true, + "encourage_questions": true, + "min_debate_rounds": 1, + "require_open_questions_resolved": true, + "self_reflect_preamble": true + }, + "done_gate": { + "enabled": true, + "forbid_todo_markers": false, + "max_finish_attempts": 5, + "min_deliverable_files": 1, + "require_deliverables": true, + "require_qa_pass": true, + "require_ralph_pass": true, + "require_tests_green": true + }, + "dry_run": false, + "max_iterations": 100, + "meta_prompt": { + "dispatch": "always", + "role": "on" + }, + "mission": { + "auto_consensus_on_stall": true, + "charter_first": true, + "commit_granularity": "batch", + "commit_trace": true, + "default_phases": [ + "solo" + ], + "max_iterations_per_phase": 3, + "max_phases": 12, + "max_total_cycles": 60, + "min_cycles_before_done": 1, + "on_budget_exhausted": "advance", + "phase_gate": true, + "refine_required": true, + "telemetry_stdout": true + }, + "noop_guard": { + "enabled": true, + "max_redispatch": 2, + "producing_agents": [], + "treat_outputs_stage_as_producing": true + }, + "project_name": "golden-proj", + "provider_no_output_timeout_s": 0, + "provider_retry": { + "backoff_s": 5, + "max_retries": 10, + "stage_backoff_s": 30, + "stage_max_retries": 10 + }, + "provider_timeout_s": 0, + "recovery": { + "dispatch_on_unmet_outputs": true, + "max_per_stage": 3 + }, + "robustness": { + "auto_consensus": "on_careful", + "auto_refine": "all", + "debate": "careful_only", + "debate_lenses": [ + "a", + "b", + "c" + ], + "debate_max_rounds": 2, + "max_qa_depth": 6, + "max_quality_retries": 4, + "min_response_chars": 40, + "plateau_action": "escalate_then_reframe", + "plateau_window": 15, + "qa_parallel_judges": 1, + "refine_accept_threshold": 0.8, + "refine_max_rounds": 10 + }, + "supervise": { + "max_consecutive_no_progress": 8, + "max_cycles": 100, + "stall_rescue": true + }, + "validation": { + "auto_derive": true, + "derived_command": null, + "max_files_per_batch": 25, + "rollback_on_failure": "careful", + "warn_files_per_batch": 5 + }, + "version": 1 +} diff --git a/tests/golden/kickoff/config_full_valid.json b/tests/golden/kickoff/config_full_valid.json new file mode 100644 index 0000000..134c30d --- /dev/null +++ b/tests/golden/kickoff/config_full_valid.json @@ -0,0 +1,111 @@ +{ + "auto_commit": false, + "casting": { + "auto_cast_on_idea": true, + "max_dynamic_roles": 6 + }, + "consensus": { + "max_parallel": 2, + "max_samples": 5 + }, + "default_provider": "shell", + "default_workflow": "engineering", + "deliberation": { + "enabled": false, + "encourage_questions": false, + "min_debate_rounds": 2, + "require_open_questions_resolved": false, + "self_reflect_preamble": false + }, + "done_gate": { + "enabled": true, + "forbid_todo_markers": true, + "max_finish_attempts": 9, + "min_deliverable_files": 3, + "require_deliverables": true, + "require_qa_pass": false, + "require_ralph_pass": false, + "require_tests_green": false + }, + "dry_run": false, + "max_iterations": 100, + "meta_prompt": { + "dispatch": "off", + "role": "careful_only" + }, + "mission": { + "auto_consensus_on_stall": false, + "charter_first": false, + "commit_granularity": "coarse", + "commit_trace": true, + "default_phases": [ + "discovery", + "engineering" + ], + "max_iterations_per_phase": 2, + "max_phases": 8, + "max_total_cycles": 33, + "min_cycles_before_done": 2, + "on_budget_exhausted": "fail", + "phase_gate": false, + "refine_required": true, + "telemetry_stdout": false + }, + "noop_guard": { + "enabled": false, + "max_redispatch": 4, + "producing_agents": [ + "engineer", + "ralph", + "builder" + ], + "treat_outputs_stage_as_producing": false + }, + "project_name": "golden-proj", + "provider_no_output_timeout_s": 120, + "provider_retry": { + "backoff_s": 2.5, + "max_retries": 3, + "stage_backoff_s": 12.75, + "stage_max_retries": 4 + }, + "provider_timeout_s": 600, + "recovery": { + "dispatch_on_unmet_outputs": true, + "max_per_stage": 1 + }, + "review": { + "per_stage": true + }, + "robustness": { + "auto_consensus": "always", + "auto_refine": "careful_only", + "debate": "all", + "debate_lenses": [ + "correctness", + "performance" + ], + "debate_max_rounds": 5, + "max_qa_depth": 2, + "max_quality_retries": 7, + "min_response_chars": 99, + "plateau_action": "escalate_only", + "plateau_window": 9, + "qa_parallel_judges": 3, + "refine_accept_threshold": 0.65, + "refine_max_rounds": 4 + }, + "supervise": { + "max_consecutive_no_progress": 8, + "max_cycles": 50, + "stall_rescue": true + }, + "validation": { + "auto_derive": false, + "derived_command": null, + "max_files_per_batch": 10, + "rollback_on_failure": "careful", + "warn_files_per_batch": 3 + }, + "version": 1 +} diff --git a/tests/golden/kickoff/config_invalid_and_empty_values.json b/tests/golden/kickoff/config_invalid_and_empty_values.json new file mode 100644 index 0000000..5c1205e --- /dev/null +++ b/tests/golden/kickoff/config_invalid_and_empty_values.json @@ -0,0 +1,111 @@ +{ + "auto_commit": true, + "casting": { + "auto_cast_on_idea": true, + "max_dynamic_roles": 12 + }, + "consensus": { + "max_parallel": 4, + "max_samples": 7 + }, + "default_provider": "shell", + "default_workflow": "engineering", + "deliberation": { + "enabled": true, + "encourage_questions": true, + "min_debate_rounds": 1, + "require_open_questions_resolved": true, + "self_reflect_preamble": true + }, + "done_gate": { + "enabled": true, + "forbid_todo_markers": false, + "max_finish_attempts": 5, + "min_deliverable_files": 1, + "require_deliverables": true, + "require_qa_pass": true, + "require_ralph_pass": true, + "require_tests_green": true + }, + "dry_run": false, + "max_iterations": 100, + "meta_prompt": { + "dispatch": "always", + "role": "on" + }, + "mission": { + "auto_consensus_on_stall": true, + "charter_first": true, + "commit_granularity": "batch", + "commit_trace": true, + "default_phases": [ + "discovery", + "product", + "engineering", + "validation", + "deployment" + ], + "max_iterations_per_phase": 3, + "max_phases": 12, + "max_total_cycles": 60, + "min_cycles_before_done": 1, + "on_budget_exhausted": "advance", + "phase_gate": true, + "refine_required": true, + "telemetry_stdout": true + }, + "noop_guard": { + "enabled": true, + "max_redispatch": 2, + "producing_agents": [ + "engineer", + "ralph" + ], + "treat_outputs_stage_as_producing": true + }, + "project_name": "golden-proj", + "provider_no_output_timeout_s": 0, + "provider_retry": { + "backoff_s": 1.5, + "max_retries": 10, + "stage_backoff_s": 30, + "stage_max_retries": 10 + }, + "provider_timeout_s": 0, + "recovery": { + "dispatch_on_unmet_outputs": true, + "max_per_stage": 3 + }, + "robustness": { + "auto_consensus": "on_careful", + "auto_refine": "all", + "debate": "careful_only", + "debate_lenses": [ + "correctness", + "security", + "simplicity" + ], + "debate_max_rounds": 2, + "max_qa_depth": 6, + "max_quality_retries": 4, + "min_response_chars": 40, + "plateau_action": "escalate_then_reframe", + "plateau_window": 15, + "qa_parallel_judges": 1, + "refine_accept_threshold": 0.8, + "refine_max_rounds": 10 + }, + "supervise": { + "max_consecutive_no_progress": 8, + "max_cycles": 100, + "stall_rescue": true + }, + "validation": { + "auto_derive": true, + "derived_command": null, + "max_files_per_batch": 25, + "rollback_on_failure": "careful", + "warn_files_per_batch": 5 + }, + "version": 1 +} diff --git a/tests/golden/kickoff/config_no_overrides.json b/tests/golden/kickoff/config_no_overrides.json new file mode 100644 index 0000000..3b14bd1 --- /dev/null +++ b/tests/golden/kickoff/config_no_overrides.json @@ -0,0 +1,111 @@ +{ + "auto_commit": true, + "casting": { + "auto_cast_on_idea": true, + "max_dynamic_roles": 12 + }, + "consensus": { + "max_parallel": 4, + "max_samples": 6 + }, + "default_provider": "shell", + "default_workflow": "engineering", + "deliberation": { + "enabled": true, + "encourage_questions": true, + "min_debate_rounds": 1, + "require_open_questions_resolved": true, + "self_reflect_preamble": true + }, + "done_gate": { + "enabled": true, + "forbid_todo_markers": false, + "max_finish_attempts": 5, + "min_deliverable_files": 1, + "require_deliverables": true, + "require_qa_pass": true, + "require_ralph_pass": true, + "require_tests_green": true + }, + "dry_run": false, + "max_iterations": 100, + "meta_prompt": { + "dispatch": "always", + "role": "on" + }, + "mission": { + "auto_consensus_on_stall": true, + "charter_first": true, + "commit_granularity": "batch", + "commit_trace": true, + "default_phases": [ + "discovery", + "product", + "engineering", + "validation", + "deployment" + ], + "max_iterations_per_phase": 3, + "max_phases": 12, + "max_total_cycles": 60, + "min_cycles_before_done": 1, + "on_budget_exhausted": "advance", + "phase_gate": true, + "refine_required": true, + "telemetry_stdout": true + }, + "noop_guard": { + "enabled": true, + "max_redispatch": 2, + "producing_agents": [ + "engineer", + "ralph" + ], + "treat_outputs_stage_as_producing": true + }, + "project_name": "golden-proj", + "provider_no_output_timeout_s": 0, + "provider_retry": { + "backoff_s": 5, + "max_retries": 10, + "stage_backoff_s": 30, + "stage_max_retries": 10 + }, + "provider_timeout_s": 0, + "recovery": { + "dispatch_on_unmet_outputs": true, + "max_per_stage": 3 + }, + "robustness": { + "auto_consensus": "on_careful", + "auto_refine": "all", + "debate": "careful_only", + "debate_lenses": [ + "correctness", + "security", + "simplicity" + ], + "debate_max_rounds": 2, + "max_qa_depth": 6, + "max_quality_retries": 4, + "min_response_chars": 40, + "plateau_action": "escalate_then_reframe", + "plateau_window": 15, + "qa_parallel_judges": 1, + "refine_accept_threshold": 0.8, + "refine_max_rounds": 10 + }, + "supervise": { + "max_consecutive_no_progress": 8, + "max_cycles": 100, + "stall_rescue": true + }, + "validation": { + "auto_derive": true, + "derived_command": null, + "max_files_per_batch": 25, + "rollback_on_failure": "careful", + "warn_files_per_batch": 5 + }, + "version": 1 +} diff --git a/tests/golden/kickoff/providers_auth_mode_empty_defaults_cli.json b/tests/golden/kickoff/providers_auth_mode_empty_defaults_cli.json new file mode 100644 index 0000000..4e7d2ff --- /dev/null +++ b/tests/golden/kickoff/providers_auth_mode_empty_defaults_cli.json @@ -0,0 +1,57 @@ +{ + "active": "claude", + "providers": { + "claude": { + "args": [ + "--print" + ], + "command": "claude", + "description": "Claude Code CLI. Detected via 'claude' on PATH.", + "mode": "cli", + "type": "claude" + }, + "codex": { + "args": [ + "exec" + ], + "command": "codex", + "description": "OpenAI Codex CLI. Detected via 'codex' on PATH.", + "mode": "cli", + "type": "codex" + }, + "gemini": { + "args": [], + "command": "gemini", + "description": "Google Gemini CLI. Detected via 'gemini' on PATH.", + "mode": "cli", + "type": "gemini" + }, + "ollama": { + "description": "Local Ollama HTTP API.", + "endpoint": "http://localhost:11434", + "model": "llama3.1", + "type": "ollama" + }, + "openwebui": { + "api_key": "", + "description": "OpenWebUI server (OpenAI-compatible HTTP). Set host/key/model on kickoff.", + "endpoint": "http://localhost:8080", + "model": "", + "type": "openwebui" + }, + "pi": { + "api_key": "", + "args": [], + "command": "pi", + "description": "Pi terminal harness. Cloned to .clk/tools/pi if needed.", + "key_type": "openrouter", + "model": "", + "type": "pi" + }, + "shell": { + "command": null, + "description": "Dummy provider that echoes prompts. Always available.", + "type": "shell" + } + } +} diff --git a/tests/golden/kickoff/providers_claude_apikey.json b/tests/golden/kickoff/providers_claude_apikey.json new file mode 100644 index 0000000..fcff965 --- /dev/null +++ b/tests/golden/kickoff/providers_claude_apikey.json @@ -0,0 +1,58 @@ +{ + "active": "claude", + "providers": { + "claude": { + "api_key": "sk-ant-golden", + "args": [ + "--print" + ], + "command": "claude", + "description": "Claude Code CLI. Detected via 'claude' on PATH.", + "mode": "api", + "type": "claude" + }, + "codex": { + "args": [ + "exec" + ], + "command": "codex", + "description": "OpenAI Codex CLI. Detected via 'codex' on PATH.", + "mode": "api", + "type": "codex" + }, + "gemini": { + "args": [], + "command": "gemini", + "description": "Google Gemini CLI. Detected via 'gemini' on PATH.", + "mode": "api", + "type": "gemini" + }, + "ollama": { + "description": "Local Ollama HTTP API.", + "endpoint": "http://localhost:11434", + "model": "llama3.1", + "type": "ollama" + }, + "openwebui": { + "api_key": "", + "description": "OpenWebUI server (OpenAI-compatible HTTP). Set host/key/model on kickoff.", + "endpoint": "http://localhost:8080", + "model": "", + "type": "openwebui" + }, + "pi": { + "api_key": "", + "args": [], + "command": "pi", + "description": "Pi terminal harness. Cloned to .clk/tools/pi if needed.", + "key_type": "openrouter", + "model": "", + "type": "pi" + }, + "shell": { + "command": null, + "description": "Dummy provider that echoes prompts. Always available.", + "type": "shell" + } + } +} diff --git a/tests/golden/kickoff/providers_claude_apikey_missing_key.json b/tests/golden/kickoff/providers_claude_apikey_missing_key.json new file mode 100644 index 0000000..c20dc6f --- /dev/null +++ b/tests/golden/kickoff/providers_claude_apikey_missing_key.json @@ -0,0 +1,58 @@ +{ + "active": "claude", + "providers": { + "claude": { + "api_key": "", + "args": [ + "--print" + ], + "command": "claude", + "description": "Claude Code CLI. Detected via 'claude' on PATH.", + "mode": "api", + "type": "claude" + }, + "codex": { + "args": [ + "exec" + ], + "command": "codex", + "description": "OpenAI Codex CLI. Detected via 'codex' on PATH.", + "mode": "api", + "type": "codex" + }, + "gemini": { + "args": [], + "command": "gemini", + "description": "Google Gemini CLI. Detected via 'gemini' on PATH.", + "mode": "api", + "type": "gemini" + }, + "ollama": { + "description": "Local Ollama HTTP API.", + "endpoint": "http://localhost:11434", + "model": "llama3.1", + "type": "ollama" + }, + "openwebui": { + "api_key": "", + "description": "OpenWebUI server (OpenAI-compatible HTTP). Set host/key/model on kickoff.", + "endpoint": "http://localhost:8080", + "model": "", + "type": "openwebui" + }, + "pi": { + "api_key": "", + "args": [], + "command": "pi", + "description": "Pi terminal harness. Cloned to .clk/tools/pi if needed.", + "key_type": "openrouter", + "model": "", + "type": "pi" + }, + "shell": { + "command": null, + "description": "Dummy provider that echoes prompts. Always available.", + "type": "shell" + } + } +} diff --git a/tests/golden/kickoff/providers_claude_cli.json b/tests/golden/kickoff/providers_claude_cli.json new file mode 100644 index 0000000..4e7d2ff --- /dev/null +++ b/tests/golden/kickoff/providers_claude_cli.json @@ -0,0 +1,57 @@ +{ + "active": "claude", + "providers": { + "claude": { + "args": [ + "--print" + ], + "command": "claude", + "description": "Claude Code CLI. Detected via 'claude' on PATH.", + "mode": "cli", + "type": "claude" + }, + "codex": { + "args": [ + "exec" + ], + "command": "codex", + "description": "OpenAI Codex CLI. Detected via 'codex' on PATH.", + "mode": "cli", + "type": "codex" + }, + "gemini": { + "args": [], + "command": "gemini", + "description": "Google Gemini CLI. Detected via 'gemini' on PATH.", + "mode": "cli", + "type": "gemini" + }, + "ollama": { + "description": "Local Ollama HTTP API.", + "endpoint": "http://localhost:11434", + "model": "llama3.1", + "type": "ollama" + }, + "openwebui": { + "api_key": "", + "description": "OpenWebUI server (OpenAI-compatible HTTP). Set host/key/model on kickoff.", + "endpoint": "http://localhost:8080", + "model": "", + "type": "openwebui" + }, + "pi": { + "api_key": "", + "args": [], + "command": "pi", + "description": "Pi terminal harness. Cloned to .clk/tools/pi if needed.", + "key_type": "openrouter", + "model": "", + "type": "pi" + }, + "shell": { + "command": null, + "description": "Dummy provider that echoes prompts. Always available.", + "type": "shell" + } + } +} diff --git a/tests/golden/kickoff/providers_codex_apikey.json b/tests/golden/kickoff/providers_codex_apikey.json new file mode 100644 index 0000000..c2a522f --- /dev/null +++ b/tests/golden/kickoff/providers_codex_apikey.json @@ -0,0 +1,58 @@ +{ + "active": "codex", + "providers": { + "claude": { + "args": [ + "--print" + ], + "command": "claude", + "description": "Claude Code CLI. Detected via 'claude' on PATH.", + "mode": "api", + "type": "claude" + }, + "codex": { + "api_key": "sk-oai-golden", + "args": [ + "exec" + ], + "command": "codex", + "description": "OpenAI Codex CLI. Detected via 'codex' on PATH.", + "mode": "api", + "type": "codex" + }, + "gemini": { + "args": [], + "command": "gemini", + "description": "Google Gemini CLI. Detected via 'gemini' on PATH.", + "mode": "api", + "type": "gemini" + }, + "ollama": { + "description": "Local Ollama HTTP API.", + "endpoint": "http://localhost:11434", + "model": "llama3.1", + "type": "ollama" + }, + "openwebui": { + "api_key": "", + "description": "OpenWebUI server (OpenAI-compatible HTTP). Set host/key/model on kickoff.", + "endpoint": "http://localhost:8080", + "model": "", + "type": "openwebui" + }, + "pi": { + "api_key": "", + "args": [], + "command": "pi", + "description": "Pi terminal harness. Cloned to .clk/tools/pi if needed.", + "key_type": "openrouter", + "model": "", + "type": "pi" + }, + "shell": { + "command": null, + "description": "Dummy provider that echoes prompts. Always available.", + "type": "shell" + } + } +} diff --git a/tests/golden/kickoff/providers_gemini_apikey_gemini_key.json b/tests/golden/kickoff/providers_gemini_apikey_gemini_key.json new file mode 100644 index 0000000..b503876 --- /dev/null +++ b/tests/golden/kickoff/providers_gemini_apikey_gemini_key.json @@ -0,0 +1,58 @@ +{ + "active": "gemini", + "providers": { + "claude": { + "args": [ + "--print" + ], + "command": "claude", + "description": "Claude Code CLI. Detected via 'claude' on PATH.", + "mode": "api", + "type": "claude" + }, + "codex": { + "args": [ + "exec" + ], + "command": "codex", + "description": "OpenAI Codex CLI. Detected via 'codex' on PATH.", + "mode": "api", + "type": "codex" + }, + "gemini": { + "api_key": "gm-golden", + "args": [], + "command": "gemini", + "description": "Google Gemini CLI. Detected via 'gemini' on PATH.", + "mode": "api", + "type": "gemini" + }, + "ollama": { + "description": "Local Ollama HTTP API.", + "endpoint": "http://localhost:11434", + "model": "llama3.1", + "type": "ollama" + }, + "openwebui": { + "api_key": "", + "description": "OpenWebUI server (OpenAI-compatible HTTP). Set host/key/model on kickoff.", + "endpoint": "http://localhost:8080", + "model": "", + "type": "openwebui" + }, + "pi": { + "api_key": "", + "args": [], + "command": "pi", + "description": "Pi terminal harness. Cloned to .clk/tools/pi if needed.", + "key_type": "openrouter", + "model": "", + "type": "pi" + }, + "shell": { + "command": null, + "description": "Dummy provider that echoes prompts. Always available.", + "type": "shell" + } + } +} diff --git a/tests/golden/kickoff/providers_gemini_apikey_google_fallback.json b/tests/golden/kickoff/providers_gemini_apikey_google_fallback.json new file mode 100644 index 0000000..bc340b8 --- /dev/null +++ b/tests/golden/kickoff/providers_gemini_apikey_google_fallback.json @@ -0,0 +1,58 @@ +{ + "active": "gemini", + "providers": { + "claude": { + "args": [ + "--print" + ], + "command": "claude", + "description": "Claude Code CLI. Detected via 'claude' on PATH.", + "mode": "api", + "type": "claude" + }, + "codex": { + "args": [ + "exec" + ], + "command": "codex", + "description": "OpenAI Codex CLI. Detected via 'codex' on PATH.", + "mode": "api", + "type": "codex" + }, + "gemini": { + "api_key": "gg-golden", + "args": [], + "command": "gemini", + "description": "Google Gemini CLI. Detected via 'gemini' on PATH.", + "mode": "api", + "type": "gemini" + }, + "ollama": { + "description": "Local Ollama HTTP API.", + "endpoint": "http://localhost:11434", + "model": "llama3.1", + "type": "ollama" + }, + "openwebui": { + "api_key": "", + "description": "OpenWebUI server (OpenAI-compatible HTTP). Set host/key/model on kickoff.", + "endpoint": "http://localhost:8080", + "model": "", + "type": "openwebui" + }, + "pi": { + "api_key": "", + "args": [], + "command": "pi", + "description": "Pi terminal harness. Cloned to .clk/tools/pi if needed.", + "key_type": "openrouter", + "model": "", + "type": "pi" + }, + "shell": { + "command": null, + "description": "Dummy provider that echoes prompts. Always available.", + "type": "shell" + } + } +} diff --git a/tests/golden/kickoff/providers_gemini_cli.json b/tests/golden/kickoff/providers_gemini_cli.json new file mode 100644 index 0000000..4318394 --- /dev/null +++ b/tests/golden/kickoff/providers_gemini_cli.json @@ -0,0 +1,57 @@ +{ + "active": "gemini", + "providers": { + "claude": { + "args": [ + "--print" + ], + "command": "claude", + "description": "Claude Code CLI. Detected via 'claude' on PATH.", + "mode": "cli", + "type": "claude" + }, + "codex": { + "args": [ + "exec" + ], + "command": "codex", + "description": "OpenAI Codex CLI. Detected via 'codex' on PATH.", + "mode": "cli", + "type": "codex" + }, + "gemini": { + "args": [], + "command": "gemini", + "description": "Google Gemini CLI. Detected via 'gemini' on PATH.", + "mode": "cli", + "type": "gemini" + }, + "ollama": { + "description": "Local Ollama HTTP API.", + "endpoint": "http://localhost:11434", + "model": "llama3.1", + "type": "ollama" + }, + "openwebui": { + "api_key": "", + "description": "OpenWebUI server (OpenAI-compatible HTTP). Set host/key/model on kickoff.", + "endpoint": "http://localhost:8080", + "model": "", + "type": "openwebui" + }, + "pi": { + "api_key": "", + "args": [], + "command": "pi", + "description": "Pi terminal harness. Cloned to .clk/tools/pi if needed.", + "key_type": "openrouter", + "model": "", + "type": "pi" + }, + "shell": { + "command": null, + "description": "Dummy provider that echoes prompts. Always available.", + "type": "shell" + } + } +} diff --git a/tests/golden/kickoff/providers_ollama_custom.json b/tests/golden/kickoff/providers_ollama_custom.json new file mode 100644 index 0000000..54c8523 --- /dev/null +++ b/tests/golden/kickoff/providers_ollama_custom.json @@ -0,0 +1,57 @@ +{ + "active": "ollama", + "providers": { + "claude": { + "args": [ + "--print" + ], + "command": "claude", + "description": "Claude Code CLI. Detected via 'claude' on PATH.", + "mode": "cli", + "type": "claude" + }, + "codex": { + "args": [ + "exec" + ], + "command": "codex", + "description": "OpenAI Codex CLI. Detected via 'codex' on PATH.", + "mode": "cli", + "type": "codex" + }, + "gemini": { + "args": [], + "command": "gemini", + "description": "Google Gemini CLI. Detected via 'gemini' on PATH.", + "mode": "cli", + "type": "gemini" + }, + "ollama": { + "description": "Local Ollama HTTP API.", + "endpoint": "http://host.docker.internal:11434", + "model": "qwen2.5-coder", + "type": "ollama" + }, + "openwebui": { + "api_key": "", + "description": "OpenWebUI server (OpenAI-compatible HTTP). Set host/key/model on kickoff.", + "endpoint": "http://localhost:8080", + "model": "", + "type": "openwebui" + }, + "pi": { + "api_key": "", + "args": [], + "command": "pi", + "description": "Pi terminal harness. Cloned to .clk/tools/pi if needed.", + "key_type": "openrouter", + "model": "", + "type": "pi" + }, + "shell": { + "command": null, + "description": "Dummy provider that echoes prompts. Always available.", + "type": "shell" + } + } +} diff --git a/tests/golden/kickoff/providers_ollama_defaults.json b/tests/golden/kickoff/providers_ollama_defaults.json new file mode 100644 index 0000000..8603e93 --- /dev/null +++ b/tests/golden/kickoff/providers_ollama_defaults.json @@ -0,0 +1,57 @@ +{ + "active": "ollama", + "providers": { + "claude": { + "args": [ + "--print" + ], + "command": "claude", + "description": "Claude Code CLI. Detected via 'claude' on PATH.", + "mode": "cli", + "type": "claude" + }, + "codex": { + "args": [ + "exec" + ], + "command": "codex", + "description": "OpenAI Codex CLI. Detected via 'codex' on PATH.", + "mode": "cli", + "type": "codex" + }, + "gemini": { + "args": [], + "command": "gemini", + "description": "Google Gemini CLI. Detected via 'gemini' on PATH.", + "mode": "cli", + "type": "gemini" + }, + "ollama": { + "description": "Local Ollama HTTP API.", + "endpoint": "http://localhost:11434", + "model": "llama3.1", + "type": "ollama" + }, + "openwebui": { + "api_key": "", + "description": "OpenWebUI server (OpenAI-compatible HTTP). Set host/key/model on kickoff.", + "endpoint": "http://localhost:8080", + "model": "", + "type": "openwebui" + }, + "pi": { + "api_key": "", + "args": [], + "command": "pi", + "description": "Pi terminal harness. Cloned to .clk/tools/pi if needed.", + "key_type": "openrouter", + "model": "", + "type": "pi" + }, + "shell": { + "command": null, + "description": "Dummy provider that echoes prompts. Always available.", + "type": "shell" + } + } +} diff --git a/tests/golden/kickoff/providers_openwebui_blank.json b/tests/golden/kickoff/providers_openwebui_blank.json new file mode 100644 index 0000000..67e4781 --- /dev/null +++ b/tests/golden/kickoff/providers_openwebui_blank.json @@ -0,0 +1,57 @@ +{ + "active": "openwebui", + "providers": { + "claude": { + "args": [ + "--print" + ], + "command": "claude", + "description": "Claude Code CLI. Detected via 'claude' on PATH.", + "mode": "cli", + "type": "claude" + }, + "codex": { + "args": [ + "exec" + ], + "command": "codex", + "description": "OpenAI Codex CLI. Detected via 'codex' on PATH.", + "mode": "cli", + "type": "codex" + }, + "gemini": { + "args": [], + "command": "gemini", + "description": "Google Gemini CLI. Detected via 'gemini' on PATH.", + "mode": "cli", + "type": "gemini" + }, + "ollama": { + "description": "Local Ollama HTTP API.", + "endpoint": "http://localhost:11434", + "model": "llama3.1", + "type": "ollama" + }, + "openwebui": { + "api_key": "", + "description": "OpenWebUI server (OpenAI-compatible HTTP). Set host/key/model on kickoff.", + "endpoint": "", + "model": "", + "type": "openwebui" + }, + "pi": { + "api_key": "", + "args": [], + "command": "pi", + "description": "Pi terminal harness. Cloned to .clk/tools/pi if needed.", + "key_type": "openrouter", + "model": "", + "type": "pi" + }, + "shell": { + "command": null, + "description": "Dummy provider that echoes prompts. Always available.", + "type": "shell" + } + } +} diff --git a/tests/golden/kickoff/providers_openwebui_full.json b/tests/golden/kickoff/providers_openwebui_full.json new file mode 100644 index 0000000..48ae875 --- /dev/null +++ b/tests/golden/kickoff/providers_openwebui_full.json @@ -0,0 +1,57 @@ +{ + "active": "openwebui", + "providers": { + "claude": { + "args": [ + "--print" + ], + "command": "claude", + "description": "Claude Code CLI. Detected via 'claude' on PATH.", + "mode": "cli", + "type": "claude" + }, + "codex": { + "args": [ + "exec" + ], + "command": "codex", + "description": "OpenAI Codex CLI. Detected via 'codex' on PATH.", + "mode": "cli", + "type": "codex" + }, + "gemini": { + "args": [], + "command": "gemini", + "description": "Google Gemini CLI. Detected via 'gemini' on PATH.", + "mode": "cli", + "type": "gemini" + }, + "ollama": { + "description": "Local Ollama HTTP API.", + "endpoint": "http://localhost:11434", + "model": "llama3.1", + "type": "ollama" + }, + "openwebui": { + "api_key": "owui-golden", + "description": "OpenWebUI server (OpenAI-compatible HTTP). Set host/key/model on kickoff.", + "endpoint": "http://localhost:8080", + "model": "llama3.1", + "type": "openwebui" + }, + "pi": { + "api_key": "", + "args": [], + "command": "pi", + "description": "Pi terminal harness. Cloned to .clk/tools/pi if needed.", + "key_type": "openrouter", + "model": "", + "type": "pi" + }, + "shell": { + "command": null, + "description": "Dummy provider that echoes prompts. Always available.", + "type": "shell" + } + } +} diff --git a/tests/golden/kickoff/providers_pi_blank.json b/tests/golden/kickoff/providers_pi_blank.json new file mode 100644 index 0000000..362bd12 --- /dev/null +++ b/tests/golden/kickoff/providers_pi_blank.json @@ -0,0 +1,57 @@ +{ + "active": "pi", + "providers": { + "claude": { + "args": [ + "--print" + ], + "command": "claude", + "description": "Claude Code CLI. Detected via 'claude' on PATH.", + "mode": "cli", + "type": "claude" + }, + "codex": { + "args": [ + "exec" + ], + "command": "codex", + "description": "OpenAI Codex CLI. Detected via 'codex' on PATH.", + "mode": "cli", + "type": "codex" + }, + "gemini": { + "args": [], + "command": "gemini", + "description": "Google Gemini CLI. Detected via 'gemini' on PATH.", + "mode": "cli", + "type": "gemini" + }, + "ollama": { + "description": "Local Ollama HTTP API.", + "endpoint": "http://localhost:11434", + "model": "llama3.1", + "type": "ollama" + }, + "openwebui": { + "api_key": "", + "description": "OpenWebUI server (OpenAI-compatible HTTP). Set host/key/model on kickoff.", + "endpoint": "http://localhost:8080", + "model": "", + "type": "openwebui" + }, + "pi": { + "api_key": "", + "args": [], + "command": "pi", + "description": "Pi terminal harness. Cloned to .clk/tools/pi if needed.", + "key_type": "openrouter", + "model": "", + "type": "pi" + }, + "shell": { + "command": null, + "description": "Dummy provider that echoes prompts. Always available.", + "type": "shell" + } + } +} diff --git a/tests/golden/kickoff/providers_pi_full.json b/tests/golden/kickoff/providers_pi_full.json new file mode 100644 index 0000000..82da0a0 --- /dev/null +++ b/tests/golden/kickoff/providers_pi_full.json @@ -0,0 +1,57 @@ +{ + "active": "pi", + "providers": { + "claude": { + "args": [ + "--print" + ], + "command": "claude", + "description": "Claude Code CLI. Detected via 'claude' on PATH.", + "mode": "cli", + "type": "claude" + }, + "codex": { + "args": [ + "exec" + ], + "command": "codex", + "description": "OpenAI Codex CLI. Detected via 'codex' on PATH.", + "mode": "cli", + "type": "codex" + }, + "gemini": { + "args": [], + "command": "gemini", + "description": "Google Gemini CLI. Detected via 'gemini' on PATH.", + "mode": "cli", + "type": "gemini" + }, + "ollama": { + "description": "Local Ollama HTTP API.", + "endpoint": "http://localhost:11434", + "model": "llama3.1", + "type": "ollama" + }, + "openwebui": { + "api_key": "", + "description": "OpenWebUI server (OpenAI-compatible HTTP). Set host/key/model on kickoff.", + "endpoint": "http://localhost:8080", + "model": "", + "type": "openwebui" + }, + "pi": { + "api_key": "pi-golden", + "args": [], + "command": "pi", + "description": "Pi terminal harness. Cloned to .clk/tools/pi if needed.", + "key_type": "openrouter", + "model": "openrouter/free", + "type": "pi" + }, + "shell": { + "command": null, + "description": "Dummy provider that echoes prompts. Always available.", + "type": "shell" + } + } +} diff --git a/tests/golden/kickoff/providers_shell_default.json b/tests/golden/kickoff/providers_shell_default.json new file mode 100644 index 0000000..8a5f5e4 --- /dev/null +++ b/tests/golden/kickoff/providers_shell_default.json @@ -0,0 +1,57 @@ +{ + "active": "shell", + "providers": { + "claude": { + "args": [ + "--print" + ], + "command": "claude", + "description": "Claude Code CLI. Detected via 'claude' on PATH.", + "mode": "cli", + "type": "claude" + }, + "codex": { + "args": [ + "exec" + ], + "command": "codex", + "description": "OpenAI Codex CLI. Detected via 'codex' on PATH.", + "mode": "cli", + "type": "codex" + }, + "gemini": { + "args": [], + "command": "gemini", + "description": "Google Gemini CLI. Detected via 'gemini' on PATH.", + "mode": "cli", + "type": "gemini" + }, + "ollama": { + "description": "Local Ollama HTTP API.", + "endpoint": "http://localhost:11434", + "model": "llama3.1", + "type": "ollama" + }, + "openwebui": { + "api_key": "", + "description": "OpenWebUI server (OpenAI-compatible HTTP). Set host/key/model on kickoff.", + "endpoint": "http://localhost:8080", + "model": "", + "type": "openwebui" + }, + "pi": { + "api_key": "", + "args": [], + "command": "pi", + "description": "Pi terminal harness. Cloned to .clk/tools/pi if needed.", + "key_type": "openrouter", + "model": "", + "type": "pi" + }, + "shell": { + "command": null, + "description": "Dummy provider that echoes prompts. Always available.", + "type": "shell" + } + } +} diff --git a/tests/integrations/telegram/test_bot_main.py b/tests/integrations/telegram/test_bot_main.py index f99a678..03adc8f 100644 --- a/tests/integrations/telegram/test_bot_main.py +++ b/tests/integrations/telegram/test_bot_main.py @@ -6,8 +6,6 @@ from __future__ import annotations -import pytest - from clk_harness.integrations.telegram import bot as bot_mod diff --git a/tests/integrations/telegram/test_kickoff_prompt.py b/tests/integrations/telegram/test_kickoff_prompt.py index b71f97d..189036c 100644 --- a/tests/integrations/telegram/test_kickoff_prompt.py +++ b/tests/integrations/telegram/test_kickoff_prompt.py @@ -1,20 +1,21 @@ -"""Confirm kickoff.sh's Telegram-setup wizard integration. +"""Confirm the kickoff setup wizard's Telegram integration. Telegram is configured from the `--setup` wizard rather than from every -kickoff run. We can't drive the full setup flow in a unit test, so we -assert structurally that: +kickoff run. The wizard lives in ``clk_harness/kickoff.py`` (kickoff.sh +is a thin wrapper over `clk kickoff`). We can't drive the full setup +flow in a unit test, so we assert structurally that: * the setup wizard asks about Telegram and conditionally invokes the standalone telegram_setup_wizard.sh helper, * CLK_TELEGRAM_SKIP is persisted to .env based on that answer, * pre-existing CLK_TELEGRAM_* values are preserved across re-runs of - --setup (i.e. referenced from the heredoc). + --setup (answers are written through env_file.write_env, which only + touches the specific key it's told about). """ from pathlib import Path - ROOT = Path(__file__).resolve().parents[3] -KICKOFF = ROOT / "kickoff.sh" +KICKOFF = ROOT / "clk_harness" / "kickoff.py" def test_kickoff_has_telegram_block(): @@ -28,22 +29,23 @@ def test_setup_invokes_wizard_only_on_yes(): body = KICKOFF.read_text() # The wizard is asked about during --setup and invoked only if the # user answered y/Y. - assert 'Set up Telegram bot now?' in body - assert '"${tg_setup,,}" = "y"' in body - assert 'CLK_ENV_FILE="$env_file" "$SCRIPT_DIR/scripts/telegram_setup_wizard.sh"' in body + assert "Set up Telegram bot now?" in body + assert 'tg_setup.lower() == "y"' in body + # The standalone helper is launched with CLK_ENV_FILE pointing at the + # wizard's env file so both write the same .env. + assert '"CLK_ENV_FILE": str(env_path)' in body def test_setup_persists_skip_and_existing_values(): body = KICKOFF.read_text() # Declining the prompt writes CLK_TELEGRAM_SKIP=true atomically via - # env_set (sourced from scripts/lib_env.sh). Pre-existing Telegram - # values survive a --setup re-run because env_set only touches the - # specific key it's told about — every other line in .env is - # preserved verbatim by the awk pass inside lib_env.sh. - assert 'env_set "$env_file" CLK_TELEGRAM_SKIP "$tg_skip"' in body - # The wizard sources .env up top so existing values become defaults + # _env_set -> env_file.write_env (tmp + fsync + .bak + os.replace). + # Pre-existing Telegram values survive a --setup re-run because + # write_env only touches the specific key it's told about — every + # other line in .env is preserved verbatim. + assert '_env_set("CLK_TELEGRAM_SKIP", "true")' in body + assert '_env_set("CLK_TELEGRAM_SKIP", "false")' in body + assert "env_file.write_env" in body + # The wizard loads .env up top so existing values become defaults # for any prompt that uses them. - assert ". \"$env_file\"" in body or '. "$env_file"' in body - # And the shared env helper is sourced so atomic writes + .bak - # rotation are in effect. - assert "scripts/lib_env.sh" in body + assert "_load_env_into(os.environ, env_path)" in body diff --git a/tests/integrations/telegram/test_setup_wizard.py b/tests/integrations/telegram/test_setup_wizard.py index 1b2cdc5..19376eb 100644 --- a/tests/integrations/telegram/test_setup_wizard.py +++ b/tests/integrations/telegram/test_setup_wizard.py @@ -10,15 +10,12 @@ from __future__ import annotations import os -import shutil import stat import subprocess -import sys from pathlib import Path import pytest - PROJECT_ROOT = Path(__file__).resolve().parents[3] WIZARD = PROJECT_ROOT / "scripts" / "telegram_setup_wizard.sh" @@ -35,7 +32,8 @@ def fake_curl(tmp_path: Path) -> Path: "url=\"${@: -1}\"\n" 'case "$url" in\n' " *getMe*) echo '{\"ok\":true,\"result\":{\"id\":1,\"username\":\"clk_test_bot\"}}';;\n" - " *getUpdates*) echo '{\"ok\":true,\"result\":[{\"update_id\":1,\"message\":{\"from\":{\"id\":42,\"username\":\"alice\"},\"text\":\"hi\"}}]}';;\n" + " *getUpdates*) echo '{\"ok\":true,\"result\":[{\"update_id\":1," + "\"message\":{\"from\":{\"id\":42,\"username\":\"alice\"},\"text\":\"hi\"}}]}';;\n" " *) echo '{}';;\n" "esac\n" ) diff --git a/tests/test_api.py b/tests/test_api.py index 80306fb..a658d7f 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -27,14 +27,13 @@ import pytest import pytest_asyncio -from httpx import AsyncClient, ASGITransport +from httpx import ASGITransport, AsyncClient # Point workspaces at a temp location before importing the app so the module- # level WORKSPACES_DIR is set correctly. os.environ.setdefault("CLK_WORKSPACES_DIR", "/tmp/clk-workspaces-test") -from clk_harness.api import app, TASKS, WORKSPACES, _task_handles # noqa: E402 - +from clk_harness.api import TASKS, WORKSPACES, _task_handles, app # noqa: E402 # --------------------------------------------------------------------------- # Helpers diff --git a/tests/test_atomic_save_json.py b/tests/test_atomic_save_json.py index d8f6160..f4f5c5e 100644 --- a/tests/test_atomic_save_json.py +++ b/tests/test_atomic_save_json.py @@ -1,9 +1,8 @@ """Tests for the atomic-write behavior of clk_harness.config.save_json.""" import json -from pathlib import Path -from clk_harness.config import save_json, load_json +from clk_harness.config import load_json, save_json def test_save_json_writes_payload(tmp_path): diff --git a/tests/test_blackboard.py b/tests/test_blackboard.py index 4605aa7..d766926 100644 --- a/tests/test_blackboard.py +++ b/tests/test_blackboard.py @@ -85,7 +85,7 @@ def test_digest_truncates_long_bodies(paths: Paths) -> None: text = bb.digest(paths, max_chars_per_post=200) assert "…" in text # No single line should exceed the truncation cap by much - assert max(len(l) for l in text.splitlines()) < 400 + assert max(len(ln) for ln in text.splitlines()) < 400 def test_digest_empty_when_no_posts(paths: Paths) -> None: diff --git a/tests/test_debate.py b/tests/test_debate.py index c901150..ade8643 100644 --- a/tests/test_debate.py +++ b/tests/test_debate.py @@ -4,8 +4,6 @@ from pathlib import Path -import pytest - from clk_harness.config import Paths from clk_harness.orchestration.workflow import WorkflowRunner, WorkflowStage diff --git a/tests/test_discover.py b/tests/test_discover.py index 910b2e1..613938e 100644 --- a/tests/test_discover.py +++ b/tests/test_discover.py @@ -17,8 +17,8 @@ from httpx import ASGITransport, AsyncClient # noqa: E402 -from clk_harness.api import app # noqa: E402 from clk_harness import env_file, webui_router # noqa: E402 +from clk_harness.api import app # noqa: E402 @pytest_asyncio.fixture diff --git a/tests/test_docs_consistency.py b/tests/test_docs_consistency.py index e9faafa..a619993 100644 --- a/tests/test_docs_consistency.py +++ b/tests/test_docs_consistency.py @@ -1,16 +1,19 @@ """Documentation-consistency tests. -These assertions exist so that future edits don't let the README, -``.env.example``, the ``DEFAULT_CLK_CONFIG`` schema, and ``kickoff.sh``'s -env-var mapping drift apart. They run against the literal file +These assertions exist so that future edits don't let the docs +(``docs/MISSIONS.md`` / ``docs/CONFIGURATION.md``, split out of the +README), ``.env.example``, the ``DEFAULT_CLK_CONFIG`` schema, and the +kickoff env-var mapping (``clk_harness/kickoff.py``, which the +``kickoff.sh`` wrapper delegates to) drift apart. They run against the literal file contents — no harness behavior is exercised. When a knob is added or renamed, the change must touch four places: 1. ``clk_harness/config.py::DEFAULT_CLK_CONFIG`` — the schema. 2. ``.env.example`` — the user-facing override. -3. ``kickoff.sh`` — translation from env var → JSON key. -4. ``README.md`` — the Robustness-loops or Cost-guardrails section. +3. ``clk_harness/kickoff.py`` — translation from env var → JSON key. +4. ``docs/MISSIONS.md`` (Robustness loops) or ``docs/CONFIGURATION.md`` + (Cost guardrails) — the user-facing knob documentation. The tests below check (1) ↔ (2) ↔ (4) for the new ``robustness`` block specifically, and that (3) sees the same env-var family. The "prior @@ -20,18 +23,20 @@ from __future__ import annotations -import re from pathlib import Path -import pytest - from clk_harness.config import DEFAULT_CLK_CONFIG - REPO = Path(__file__).resolve().parent.parent ENV_EXAMPLE = (REPO / ".env.example").read_text(encoding="utf-8") -KICKOFF = (REPO / "kickoff.sh").read_text(encoding="utf-8") -README = (REPO / "README.md").read_text(encoding="utf-8") +# kickoff.sh is a thin wrapper now; the env-var → config mapping lives in +# the `clk kickoff` subcommand. +KICKOFF = (REPO / "clk_harness" / "kickoff.py").read_text(encoding="utf-8") +# The README's deep-dive sections were split into docs/; the robustness +# documentation now lives in MISSIONS.md (Robustness loops) and +# CONFIGURATION.md (Cost guardrails / multiplier table). +MISSIONS_DOC = (REPO / "docs" / "MISSIONS.md").read_text(encoding="utf-8") +CONFIG_DOC = (REPO / "docs" / "CONFIGURATION.md").read_text(encoding="utf-8") # --------------------------------------------------------------------------- @@ -40,10 +45,10 @@ # Some keys are introspected at runtime by name (e.g. "plateau_action") -# while others are knobs the user only sees on a coarser axis (the README -# explains plateau as a single concept rather than enumerating +# while others are knobs the user only sees on a coarser axis (the docs +# explain plateau as a single concept rather than enumerating # `plateau_window` and `plateau_action` separately). Keys listed here are -# excluded from the README-mention check below; everything else must +# excluded from the docs-mention check below; everything else must # appear by name in the Robustness-loops or Cost-guardrails section. _README_OPTIONAL_KEYS: frozenset = frozenset({ "qa_parallel_judges", # internal cap, documented as part of the Q&A protocol @@ -88,40 +93,41 @@ def test_env_example_documents_every_robustness_key() -> None: def test_kickoff_sh_maps_every_robustness_key() -> None: - """kickoff.sh's env-var override block must recognise every + """The kickoff env-var override block (clk_harness/kickoff.py) must recognise every CLK_ROBUSTNESS_* variable.""" block = DEFAULT_CLK_CONFIG["robustness"] for key in block.keys(): var = _env_var_for(key) assert var in KICKOFF, ( - f"{var} not handled in kickoff.sh's env→config mapping " + f"{var} not handled in the kickoff env→config mapping (clk_harness/kickoff.py) " f"(needed to make the .env.example override actually take effect)" ) def test_readme_documents_robustness_keys() -> None: - """The README's Robustness-loops or Cost-guardrails section must - mention every robustness knob by name.""" + """The docs' Robustness-loops (docs/MISSIONS.md) or Cost-guardrails + (docs/CONFIGURATION.md) section must mention every robustness knob + by name.""" # Slice off the relevant chunk so we don't count incidental mentions # elsewhere (e.g. in the changelog "What's new" block which is # already lossier on purpose). - rl_start = README.find("## Robustness loops") + rl_start = MISSIONS_DOC.find("## Robustness loops") rl_end_marker = "## Completion criteria" - rl_end = README.find(rl_end_marker, rl_start) if rl_start != -1 else -1 + rl_end = MISSIONS_DOC.find(rl_end_marker, rl_start) if rl_start != -1 else -1 assert rl_start != -1 and rl_end != -1, ( - "README is missing the ## Robustness loops section " + "docs/MISSIONS.md is missing the ## Robustness loops section " "(must live between ## Loops and ## Completion criteria)" ) - rl_section = README[rl_start:rl_end] + rl_section = MISSIONS_DOC[rl_start:rl_end] - cost_start = README.find("### Robustness-loop multipliers") - cost_end_marker = "## Pi extension" - cost_end = README.find(cost_end_marker, cost_start) if cost_start != -1 else -1 + cost_start = CONFIG_DOC.find("### Robustness-loop multipliers") + cost_end_marker = "## Customization" + cost_end = CONFIG_DOC.find(cost_end_marker, cost_start) if cost_start != -1 else -1 assert cost_start != -1 and cost_end != -1, ( - "README is missing the ### Robustness-loop multipliers section " - "under ## Cost guardrails" + "docs/CONFIGURATION.md is missing the ### Robustness-loop multipliers " + "section under ## Cost guardrails" ) - cost_section = README[cost_start:cost_end] + cost_section = CONFIG_DOC[cost_start:cost_end] combined = rl_section + "\n" + cost_section block = DEFAULT_CLK_CONFIG["robustness"] @@ -129,8 +135,9 @@ def test_readme_documents_robustness_keys() -> None: if key in _README_OPTIONAL_KEYS: continue assert key in combined, ( - f"robustness key '{key}' not mentioned in README " - "(Robustness-loops section or Cost-guardrails table)" + f"robustness key '{key}' not mentioned in the docs " + "(docs/MISSIONS.md Robustness-loops section or " + "docs/CONFIGURATION.md Cost-guardrails table)" ) @@ -139,7 +146,7 @@ def test_readme_documents_robustness_keys() -> None: # --------------------------------------------------------------------------- # The autonomy blocks must be overridable from the environment the same way # robustness is: every key gets a CLK__ var in .env.example AND a -# matching mapping in kickoff.sh, so an override actually takes effect. +# matching mapping in clk_harness/kickoff.py, so an override actually takes effect. _AUTONOMY_BLOCKS = ("mission", "done_gate", "noop_guard", "deliberation") @@ -164,7 +171,7 @@ def test_kickoff_sh_maps_new_blocks() -> None: for key in DEFAULT_CLK_CONFIG[block].keys(): var = _block_env_var(block, key) assert var in KICKOFF, ( - f"{var} not handled in kickoff.sh's env→config mapping" + f"{var} not handled in the kickoff env→config mapping (clk_harness/kickoff.py)" ) @@ -210,11 +217,11 @@ def test_env_example_documents_prior_knobs() -> None: def test_kickoff_handles_prior_knobs() -> None: - """kickoff.sh's env-var override block must recognise every prior + """The kickoff env-var override block (clk_harness/kickoff.py) must recognise every prior CLK_* knob too — the docs claim parity, so the script must honor it.""" for var in _PRIOR_KNOBS: assert var in KICKOFF, ( - f"{var} listed in .env.example but not handled by kickoff.sh — " + f"{var} listed in .env.example but not handled by the kickoff env→config mapping — " "the override would silently no-op." ) @@ -241,7 +248,7 @@ def test_install_local_header_mentions_layout() -> None: def test_run_loop_header_links_robustness_section() -> None: - """scripts/run_loop.sh should point users at the README section so + """scripts/run_loop.sh should point users at the docs section so the wrapper isn't a black box.""" text = (REPO / "scripts" / "run_loop.sh").read_text(encoding="utf-8") assert "Robustness loops" in text or "robustness" in text.lower(), ( diff --git a/tests/test_done_gate.py b/tests/test_done_gate.py index cb40140..c14a7e4 100644 --- a/tests/test_done_gate.py +++ b/tests/test_done_gate.py @@ -5,13 +5,11 @@ import json from pathlib import Path -import pytest - from clk_harness.config import Paths from clk_harness.orchestration import blackboard as bb -from clk_harness.orchestration.done_gate import evaluate_done_gate, deliverable_files -from clk_harness.orchestration.evaluator import EvalResult, CheckResult from clk_harness.orchestration.charter import Charter, derive_done_criteria +from clk_harness.orchestration.done_gate import deliverable_files, evaluate_done_gate +from clk_harness.orchestration.evaluator import CheckResult, EvalResult def _paths(tmp_path: Path) -> Paths: diff --git a/tests/test_endpoint_resolution.py b/tests/test_endpoint_resolution.py index 5794592..2d232d8 100644 --- a/tests/test_endpoint_resolution.py +++ b/tests/test_endpoint_resolution.py @@ -13,7 +13,6 @@ from clk_harness import webui_router from clk_harness.providers import _endpoint_fallback, ollama, openwebui - LOCAL = "http://localhost:11434" SWAP = "http://host.docker.internal:11434" diff --git a/tests/test_env_file.py b/tests/test_env_file.py index 1e0eef0..72e84c0 100644 --- a/tests/test_env_file.py +++ b/tests/test_env_file.py @@ -115,7 +115,7 @@ def test_unknown_key_lands_in_extra_group(env: Path) -> None: def test_quotes_and_export_parsed() -> None: lines = env_file.parse_env('export FOO="a b"\nBAR=plain\n# c\n') - kv = {l.key: l.value for l in lines if l.kind == "kv"} + kv = {ln.key: ln.value for ln in lines if ln.kind == "kv"} assert kv["FOO"] == "a b" assert kv["BAR"] == "plain" - assert any(l.kind == "comment" for l in lines) + assert any(ln.kind == "comment" for ln in lines) diff --git a/tests/test_install_tool_script.py b/tests/test_install_tool_script.py index b0bc708..31a7487 100644 --- a/tests/test_install_tool_script.py +++ b/tests/test_install_tool_script.py @@ -98,7 +98,6 @@ def test_install_tool_check_handles_missing_tool(): def test_install_tool_install_print_only_prints_command(tmp_path): # --print-only never executes — just confirms the recipe surfaces a # candidate command without prompting. - fake_tool_marker = tmp_path / "fake_marker" # We use the `tmux` recipe because it's deterministic across platforms # and the script's --print-only branch returns rc=1 (user-declined). r = subprocess.run( diff --git a/tests/test_kickoff_cmd.py b/tests/test_kickoff_cmd.py new file mode 100644 index 0000000..dca32b8 --- /dev/null +++ b/tests/test_kickoff_cmd.py @@ -0,0 +1,397 @@ +"""Tests for the ``clk kickoff`` subcommand (clk_harness/kickoff.py). + +Golden-file parity: the goldens under ``tests/golden/kickoff/`` were +generated by running the *original* kickoff.sh Python heredocs (the +CLK_* env → clk.config.json override block and the provider-activation +block) against snapshot base configs for the env matrices below. The +ported :func:`apply_config_env_overrides` / :func:`activate_provider` +must produce byte-identical output for the same inputs. + +The matrices are imported by the one-shot golden generator, so they are +the single source of truth for both sides of the comparison. Do not +edit a case without regenerating its golden from the shell heredoc (or +consciously re-deriving it). +""" + +from __future__ import annotations + +import argparse +import json +import os +import shutil +import time +from pathlib import Path + +import pytest + +from clk_harness import kickoff +from clk_harness.cli import build_parser +from clk_harness.kickoff import _bool, _csv, activate_provider, apply_config_env_overrides + +GOLDEN = Path(__file__).resolve().parent / "golden" / "kickoff" + + +# --------------------------------------------------------------------------- +# Env-input matrices (shared with the golden generator) +# --------------------------------------------------------------------------- + +CONFIG_CASES: dict = { + "no_overrides": {}, + # Every recognised var with a distinctive valid value. + "full_valid": { + "CLK_ROBUSTNESS_AUTO_CONSENSUS": "always", + "CLK_ROBUSTNESS_AUTO_REFINE": "careful_only", + "CLK_ROBUSTNESS_MAX_QUALITY_RETRIES": "7", + "CLK_ROBUSTNESS_MIN_RESPONSE_CHARS": "99", + "CLK_ROBUSTNESS_REFINE_MAX_ROUNDS": "4", + "CLK_ROBUSTNESS_REFINE_ACCEPT_THRESHOLD": "0.65", + "CLK_ROBUSTNESS_QA_PARALLEL_JUDGES": "3", + "CLK_ROBUSTNESS_MAX_QA_DEPTH": "2", + "CLK_ROBUSTNESS_PLATEAU_WINDOW": "9", + "CLK_ROBUSTNESS_PLATEAU_ACTION": "escalate_only", + "CLK_ROBUSTNESS_DEBATE": "all", + "CLK_ROBUSTNESS_DEBATE_LENSES": "correctness,performance", + "CLK_ROBUSTNESS_DEBATE_MAX_ROUNDS": "5", + "CLK_MISSION_MAX_PHASES": "8", + "CLK_MISSION_MAX_ITERATIONS_PER_PHASE": "2", + "CLK_MISSION_MAX_TOTAL_CYCLES": "33", + "CLK_MISSION_PHASE_GATE": "false", + "CLK_MISSION_REFINE_REQUIRED": "true", + "CLK_MISSION_AUTO_CONSENSUS_ON_STALL": "false", + "CLK_MISSION_CHARTER_FIRST": "false", + "CLK_MISSION_COMMIT_TRACE": "true", + "CLK_MISSION_COMMIT_GRANULARITY": "coarse", + "CLK_MISSION_MIN_CYCLES_BEFORE_DONE": "2", + "CLK_MISSION_TELEMETRY_STDOUT": "false", + "CLK_MISSION_ON_BUDGET_EXHAUSTED": "fail", + "CLK_MISSION_DEFAULT_PHASES": "discovery,engineering", + "CLK_DONE_GATE_ENABLED": "true", + "CLK_DONE_GATE_REQUIRE_TESTS_GREEN": "false", + "CLK_DONE_GATE_REQUIRE_DELIVERABLES": "true", + "CLK_DONE_GATE_MIN_DELIVERABLE_FILES": "3", + "CLK_DONE_GATE_REQUIRE_QA_PASS": "false", + "CLK_DONE_GATE_REQUIRE_RALPH_PASS": "false", + "CLK_DONE_GATE_FORBID_TODO_MARKERS": "true", + "CLK_DONE_GATE_MAX_FINISH_ATTEMPTS": "9", + "CLK_NOOP_GUARD_ENABLED": "false", + "CLK_NOOP_GUARD_MAX_REDISPATCH": "4", + "CLK_NOOP_GUARD_PRODUCING_AGENTS": "engineer,ralph,builder", + "CLK_NOOP_GUARD_TREAT_OUTPUTS_STAGE_AS_PRODUCING": "false", + "CLK_DELIBERATION_ENABLED": "false", + "CLK_DELIBERATION_ENCOURAGE_QUESTIONS": "false", + "CLK_DELIBERATION_REQUIRE_OPEN_QUESTIONS_RESOLVED": "false", + "CLK_DELIBERATION_SELF_REFLECT_PREAMBLE": "false", + "CLK_DELIBERATION_MIN_DEBATE_ROUNDS": "2", + "CLK_VALIDATION_AUTO_DERIVE": "false", + "CLK_PROVIDER_TIMEOUT_S": "600", + "CLK_PROVIDER_NO_OUTPUT_TIMEOUT_S": "120", + "CLK_PROVIDER_RETRY_MAX_RETRIES": "3", + "CLK_PROVIDER_RETRY_BACKOFF_S": "2.5", + "CLK_PROVIDER_RETRY_STAGE_MAX_RETRIES": "4", + "CLK_PROVIDER_RETRY_STAGE_BACKOFF_S": "12.75", + "CLK_SUPERVISE_MAX_CYCLES": "50", + "CLK_CONSENSUS_MAX_SAMPLES": "5", + "CLK_CONSENSUS_MAX_PARALLEL": "2", + "CLK_CASTING_MAX_DYNAMIC_ROLES": "6", + "CLK_AUTO_COMMIT": "false", + "CLK_VALIDATION_MAX_FILES_PER_BATCH": "10", + "CLK_VALIDATION_WARN_FILES_PER_BATCH": "3", + "CLK_META_PROMPT_DISPATCH": "off", + "CLK_META_PROMPT_ROLE": "careful_only", + "CLK_REVIEW_PER_STAGE": "true", + "CLK_RECOVERY_MAX_PER_STAGE": "1", + }, + # _bool truthy spellings: {"1","true","yes","y","on"} after strip+lower. + "bool_truthy_variants": { + "CLK_MISSION_PHASE_GATE": "Yes", + "CLK_MISSION_CHARTER_FIRST": "1", + "CLK_DONE_GATE_ENABLED": "ON", + "CLK_AUTO_COMMIT": " TRUE ", + "CLK_REVIEW_PER_STAGE": "y", + }, + # Everything else (including garbage) is False — never an error. + "bool_falsy_variants": { + "CLK_MISSION_PHASE_GATE": "0", + "CLK_DONE_GATE_ENABLED": "off", + "CLK_NOOP_GUARD_ENABLED": "no", + "CLK_DELIBERATION_ENABLED": "garbage", + "CLK_AUTO_COMMIT": "False", + "CLK_VALIDATION_AUTO_DERIVE": "n", + }, + # _csv: split on commas, strip items, drop empties. + "csv_edge_cases": { + "CLK_ROBUSTNESS_DEBATE_LENSES": " a , ,b ,,c ", + "CLK_MISSION_DEFAULT_PHASES": "solo", + "CLK_NOOP_GUARD_PRODUCING_AGENTS": " , ,, ", + }, + # Uncastable / empty values are skipped; the default survives. + "invalid_and_empty_values": { + "CLK_ROBUSTNESS_MAX_QUALITY_RETRIES": "abc", # int() fails -> skipped + "CLK_ROBUSTNESS_REFINE_ACCEPT_THRESHOLD": "x.y", # float() fails -> skipped + "CLK_PROVIDER_TIMEOUT_S": "", # empty -> skipped + "CLK_SUPERVISE_MAX_CYCLES": "3.5", # int("3.5") fails -> skipped + "CLK_PROVIDER_RETRY_BACKOFF_S": "1.5", # float ok + "CLK_CONSENSUS_MAX_SAMPLES": " 7 ", # int tolerates whitespace + "CLK_UNRELATED_VAR": "ignored", # unknown var -> no effect + }, +} + +# Raw user env for the provider-activation block. The wrapper defaults +# (CLK_AUTH_MODE=cli, ollama endpoint/model, blank openwebui/pi vars) +# are applied by activate_provider itself, mirroring the `VAR=${VAR:-def}` +# prefixes kickoff.sh put on the heredoc invocation. +PROVIDER_CASES: dict = { + "shell_default": {"CLK_PROVIDER": "shell"}, + # cli auth: key present in env must NOT be wired into providers.json. + "claude_cli": {"CLK_PROVIDER": "claude", "CLK_AUTH_MODE": "cli", + "ANTHROPIC_API_KEY": "sk-ant-unused"}, + "claude_apikey": {"CLK_PROVIDER": "claude", "CLK_AUTH_MODE": "apikey", + "ANTHROPIC_API_KEY": "sk-ant-golden"}, + "claude_apikey_missing_key": {"CLK_PROVIDER": "claude", "CLK_AUTH_MODE": "apikey"}, + "codex_apikey": {"CLK_PROVIDER": "codex", "CLK_AUTH_MODE": "apikey", + "OPENAI_API_KEY": "sk-oai-golden"}, + "gemini_apikey_gemini_key": {"CLK_PROVIDER": "gemini", "CLK_AUTH_MODE": "apikey", + "GEMINI_API_KEY": "gm-golden", "GOOGLE_API_KEY": "gg-golden"}, + "gemini_apikey_google_fallback": {"CLK_PROVIDER": "gemini", "CLK_AUTH_MODE": "apikey", + "GOOGLE_API_KEY": "gg-golden"}, + "gemini_cli": {"CLK_PROVIDER": "gemini"}, + # ${CLK_AUTH_MODE:-cli}: empty collapses to cli. + "auth_mode_empty_defaults_cli": {"CLK_PROVIDER": "claude", "CLK_AUTH_MODE": "", + "ANTHROPIC_API_KEY": "sk-ant-unused"}, + "ollama_defaults": {"CLK_PROVIDER": "ollama"}, + "ollama_custom": {"CLK_PROVIDER": "ollama", + "CLK_OLLAMA_ENDPOINT": "http://host.docker.internal:11434", + "CLK_OLLAMA_MODEL": "qwen2.5-coder"}, + "openwebui_full": {"CLK_PROVIDER": "openwebui", + "CLK_OPENWEBUI_ENDPOINT": "http://localhost:8080", + "CLK_OPENWEBUI_API_KEY": "owui-golden", + "CLK_OPENWEBUI_MODEL": "llama3.1"}, + "openwebui_blank": {"CLK_PROVIDER": "openwebui"}, + # pi values are stripped; key_type is lowercased. + "pi_full": {"CLK_PROVIDER": "pi", "CLK_PI_MODEL": " openrouter/free ", + "CLK_PI_API_KEY": " pi-golden ", "CLK_PI_KEY_TYPE": " OpenRouter "}, + "pi_blank": {"CLK_PROVIDER": "pi"}, +} + + +# --------------------------------------------------------------------------- +# Golden-file parity +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("case", sorted(CONFIG_CASES)) +def test_config_env_overrides_match_shell_heredoc(tmp_path: Path, case: str) -> None: + cfg_path = tmp_path / "clk.config.json" + shutil.copy(GOLDEN / "base_clk_config.json", cfg_path) + apply_config_env_overrides(cfg_path, env=CONFIG_CASES[case]) + expected = (GOLDEN / f"config_{case}.json").read_text(encoding="utf-8") + assert cfg_path.read_text(encoding="utf-8") == expected + + +@pytest.mark.parametrize("case", sorted(PROVIDER_CASES)) +def test_provider_activation_matches_shell_heredoc(tmp_path: Path, case: str) -> None: + prov_path = tmp_path / "providers.json" + shutil.copy(GOLDEN / "base_providers.json", prov_path) + activate_provider(prov_path, env=PROVIDER_CASES[case]) + expected = (GOLDEN / f"providers_{case}.json").read_text(encoding="utf-8") + assert prov_path.read_text(encoding="utf-8") == expected + + +def test_config_overrides_noop_when_file_missing(tmp_path: Path) -> None: + """`if not p.exists(): raise SystemExit(0)` in the heredoc == no-op.""" + missing = tmp_path / "clk.config.json" + apply_config_env_overrides(missing, env=CONFIG_CASES["full_valid"]) + assert not missing.exists() + + +def test_config_overrides_preserve_unrelated_keys(tmp_path: Path) -> None: + cfg_path = tmp_path / "clk.config.json" + cfg_path.write_text(json.dumps({"custom": {"x": 1}, "robustness": {"keep": True}}), + encoding="utf-8") + apply_config_env_overrides(cfg_path, env={"CLK_ROBUSTNESS_MAX_QA_DEPTH": "5"}) + out = json.loads(cfg_path.read_text(encoding="utf-8")) + assert out["custom"] == {"x": 1} + assert out["robustness"] == {"keep": True, "max_qa_depth": 5} + + +# --------------------------------------------------------------------------- +# Cast helpers (the heredoc's _bool/_csv semantics) +# --------------------------------------------------------------------------- + + +def test_bool_cast_semantics() -> None: + for truthy in ("1", "true", "TRUE", " Yes ", "y", "on", "ON"): + assert _bool(truthy) is True, truthy + for falsy in ("0", "false", "no", "n", "off", "", "garbage", "2"): + assert _bool(falsy) is False, falsy + + +def test_csv_cast_semantics() -> None: + assert _csv(" a , ,b ,,c ") == ["a", "b", "c"] + assert _csv("solo") == ["solo"] + assert _csv(" , ,, ") == [] + assert _csv("") == [] + + +# --------------------------------------------------------------------------- +# Config validation (port of _clk_missing) +# --------------------------------------------------------------------------- + + +def _cfg(**over: str) -> dict: + base = {"CLK_PROVIDER": "shell", "CLK_MAX_ITERATIONS": "10", + "CLK_PROJECT_NAME": "clk-app", "CLK_RUN_INSTALL": "false", + "CLK_NO_TUI": "false", "CLK_AUTH_MODE": "cli"} + base.update(over) + return base + + +def test_missing_config_ok_for_shell() -> None: + assert kickoff._missing_config(_cfg(), {}, "an idea") == [] + + +def test_missing_config_claude_apikey_requires_key() -> None: + problems = kickoff._missing_config( + _cfg(CLK_PROVIDER="claude", CLK_AUTH_MODE="apikey"), {}, "idea") + assert any("ANTHROPIC_API_KEY" in p for p in problems) + assert kickoff._missing_config( + _cfg(CLK_PROVIDER="claude", CLK_AUTH_MODE="apikey"), + {"ANTHROPIC_API_KEY": "sk"}, "idea") == [] + # cli auth mode never requires the key. + assert kickoff._missing_config(_cfg(CLK_PROVIDER="claude"), {}, "idea") == [] + + +def test_missing_config_gemini_accepts_google_key() -> None: + cfg = _cfg(CLK_PROVIDER="gemini", CLK_AUTH_MODE="apikey") + assert kickoff._missing_config(cfg, {"GOOGLE_API_KEY": "g"}, "idea") == [] + assert kickoff._missing_config(cfg, {}, "idea") + + +def test_missing_config_openwebui_requires_endpoint_and_model() -> None: + problems = kickoff._missing_config(_cfg(CLK_PROVIDER="openwebui"), {}, "idea") + assert any("CLK_OPENWEBUI_ENDPOINT" in p for p in problems) + assert any("CLK_OPENWEBUI_MODEL" in p for p in problems) + + +def test_missing_config_rejects_unknown_provider_and_bad_iterations() -> None: + problems = kickoff._missing_config( + _cfg(CLK_PROVIDER="hal9000", CLK_MAX_ITERATIONS="lots"), {}, "idea") + assert any("not recognised" in p for p in problems) + assert any("CLK_MAX_ITERATIONS" in p for p in problems) + + +def test_missing_config_no_tui_requires_idea() -> None: + problems = kickoff._missing_config(_cfg(CLK_NO_TUI="true"), {}, "") + assert any("idea argument is required" in p for p in problems) + assert kickoff._missing_config(_cfg(CLK_NO_TUI="true"), {}, "an idea") == [] + + +# --------------------------------------------------------------------------- +# CLI flag surface + early-exit modes (--list / --clean / --restore) +# --------------------------------------------------------------------------- + + +def _parse(*argv: str) -> argparse.Namespace: + return build_parser().parse_args(["kickoff", *argv]) + + +def test_parser_mirrors_kickoff_sh_flags() -> None: + ns = _parse("--setup", "--restore", "--list", "--clean", "7d", + "--provider", "shell", "--max-iterations", "3", + "--project-name", "p", "--no-tui", "--run-install", "my idea") + assert ns.setup and ns.restore and ns.list_mode + assert ns.clean == "7d" + assert ns.provider == "shell" + assert ns.max_iterations == "3" + assert ns.project_name == "p" + assert ns.no_tui_override == "true" + assert ns.run_install is True + assert ns.idea == "my idea" + assert ns.func is kickoff.cmd_kickoff + + +def test_parser_tui_flag_wins_when_last() -> None: + assert _parse("--no-tui", "--tui").no_tui_override == "false" + assert _parse("--tui", "--no-tui").no_tui_override == "true" + assert _parse().no_tui_override is None + + +def test_list_without_workspace(tmp_path: Path, monkeypatch, capsys) -> None: + monkeypatch.chdir(tmp_path) + assert kickoff.cmd_kickoff(_parse("--list")) == 0 + assert "no workspace" in capsys.readouterr().out.lower() + + +def test_list_shows_kickoff_dirs(tmp_path: Path, monkeypatch, capsys) -> None: + monkeypatch.chdir(tmp_path) + (tmp_path / "workspace" / "kickoff-20260101-120000").mkdir(parents=True) + state = tmp_path / "workspace" / "kickoff-20260201-120000" / ".clk" / "state" + state.mkdir(parents=True) + (state / "idea.json").write_text(json.dumps({"title": "A grand idea"}), encoding="utf-8") + assert kickoff.cmd_kickoff(_parse("--list")) == 0 + out = capsys.readouterr().out + assert "kickoff-20260101-120000" in out + assert "kickoff-20260201-120000" in out + assert "A grand idea" in out + + +def test_clean_rejects_unparseable_duration(tmp_path: Path, monkeypatch, capsys) -> None: + monkeypatch.chdir(tmp_path) + (tmp_path / "workspace" / "kickoff-x").mkdir(parents=True) + assert kickoff.cmd_kickoff(_parse("--clean", "garbage")) == 2 + assert "--clean expects" in capsys.readouterr().err + + +def test_clean_without_workspace(tmp_path: Path, monkeypatch, capsys) -> None: + monkeypatch.chdir(tmp_path) + assert kickoff.cmd_kickoff(_parse("--clean", "7d")) == 0 + assert "nothing to clean" in capsys.readouterr().out.lower() + + +def test_clean_nothing_old_enough(tmp_path: Path, monkeypatch, capsys) -> None: + monkeypatch.chdir(tmp_path) + (tmp_path / "workspace" / "kickoff-fresh").mkdir(parents=True) + assert kickoff.cmd_kickoff(_parse("--clean", "7d")) == 0 + assert "no kickoff dirs older" in capsys.readouterr().out.lower() + + +def test_clean_refuses_without_tty(tmp_path: Path, monkeypatch, capsys) -> None: + monkeypatch.chdir(tmp_path) + stale = tmp_path / "workspace" / "kickoff-stale" + stale.mkdir(parents=True) + old = time.time() - 8 * 86400 + os.utime(stale, (old, old)) + monkeypatch.setattr(kickoff, "_open_tty", lambda: None) + assert kickoff.cmd_kickoff(_parse("--clean", "7d")) == 2 + captured = capsys.readouterr() + assert "non-interactive" in (captured.out + captured.err).lower() + assert stale.exists() + + +def test_restore_swaps_env_bak_back(tmp_path: Path, monkeypatch) -> None: + monkeypatch.setattr(kickoff, "harness_root", lambda: tmp_path) + (tmp_path / ".env.bak").write_text("KEY=from-bak\n", encoding="utf-8") + assert kickoff.cmd_kickoff(_parse("--restore")) == 0 + assert (tmp_path / ".env").read_text(encoding="utf-8") == "KEY=from-bak\n" + assert not (tmp_path / ".env.bak").exists() + + +def test_restore_without_bak_fails_cleanly(tmp_path: Path, monkeypatch, capsys) -> None: + monkeypatch.setattr(kickoff, "harness_root", lambda: tmp_path) + assert kickoff.cmd_kickoff(_parse("--restore")) == 1 + assert "no" in capsys.readouterr().err.lower() + + +def test_missing_config_exits_2_without_tty(tmp_path: Path, monkeypatch, capsys) -> None: + """A non-interactive run with invalid config prints the problems and + exits 2 instead of creating a kickoff dir.""" + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(kickoff, "harness_root", lambda: tmp_path) # no .env here + monkeypatch.setattr(kickoff, "_open_tty", lambda: None) + monkeypatch.delenv("CLK_PROVIDER", raising=False) + rc = kickoff.cmd_kickoff(_parse("--provider", "hal9000", "--no-tui", "idea")) + assert rc == 2 + err = capsys.readouterr().err + assert "missing or invalid configuration" in err + assert "not recognised" in err + assert not (tmp_path / "workspace").exists() diff --git a/tests/test_mission_living_plan.py b/tests/test_mission_living_plan.py index 0d4fad3..b63177b 100644 --- a/tests/test_mission_living_plan.py +++ b/tests/test_mission_living_plan.py @@ -5,14 +5,15 @@ import json from pathlib import Path -import pytest - from clk_harness.config import Paths +from clk_harness.orchestration.charter import Charter, load_charter, save_charter +from clk_harness.orchestration.evaluator import Evaluator, derive_validation from clk_harness.orchestration.mission import ( - MissionPlan, PhaseSpec, load_plan, save_plan, + MissionPlan, + PhaseSpec, + load_plan, + save_plan, ) -from clk_harness.orchestration.charter import Charter, load_charter, save_charter -from clk_harness.orchestration.evaluator import Evaluator, EvalResult, derive_validation def _paths(tmp_path: Path) -> Paths: diff --git a/tests/test_noop_guard.py b/tests/test_noop_guard.py index 9e680b0..a1576a3 100644 --- a/tests/test_noop_guard.py +++ b/tests/test_noop_guard.py @@ -2,12 +2,9 @@ from __future__ import annotations -import pytest - from clk_harness.orchestration import noop_guard from clk_harness.orchestration.response_quality import score - _CFG = {"noop_guard": {"enabled": True, "max_redispatch": 2, "producing_agents": ["engineer", "ralph"], "treat_outputs_stage_as_producing": True}} diff --git a/tests/test_robustness_integration.py b/tests/test_robustness_integration.py index cdb8e0d..943dd14 100644 --- a/tests/test_robustness_integration.py +++ b/tests/test_robustness_integration.py @@ -18,7 +18,7 @@ import pytest -from clk_harness.config import Paths, DEFAULT_CLK_CONFIG +from clk_harness.config import DEFAULT_CLK_CONFIG, Paths from clk_harness.orchestration import blackboard as bb from clk_harness.orchestration import workflow as wf from clk_harness.orchestration.agent import AgentRunner diff --git a/tests/test_tui_state.py b/tests/test_tui_state.py index 5f0037f..3f40d91 100644 --- a/tests/test_tui_state.py +++ b/tests/test_tui_state.py @@ -9,14 +9,12 @@ from __future__ import annotations -import threading -from collections import deque from pathlib import Path import pytest from clk_harness.config import Paths -from clk_harness.tui import AgentCard, AgentStatus, DashboardState +from clk_harness.tui import AgentStatus, DashboardState @pytest.fixture diff --git a/tests/test_tui_worker.py b/tests/test_tui_worker.py index 39b6754..c42228e 100644 --- a/tests/test_tui_worker.py +++ b/tests/test_tui_worker.py @@ -8,7 +8,6 @@ from __future__ import annotations -import json from pathlib import Path import pytest diff --git a/tests/test_webui_api.py b/tests/test_webui_api.py index ce597bf..f76ba9a 100644 --- a/tests/test_webui_api.py +++ b/tests/test_webui_api.py @@ -20,8 +20,8 @@ from httpx import ASGITransport, AsyncClient # noqa: E402 -from clk_harness.api import app # noqa: E402 from clk_harness import env_file # noqa: E402 +from clk_harness.api import app # noqa: E402 @pytest_asyncio.fixture @@ -191,10 +191,13 @@ def _seed_activity(ws_path: str) -> None: log = Path(ws_path) / ".clk" / "logs" / "activity.jsonl" log.parent.mkdir(parents=True, exist_ok=True) events = [ - {"event": "agent_dispatch", "agent": "engineer", "run_id": "r1", "provider": "shell (shell)", "workflow": "engineering"}, - {"event": "prompt_sent", "agent": "engineer", "run_id": "r1", "prompt": "go", "prompt_path": ".clk/runs/r1/prompt.txt"}, + {"event": "agent_dispatch", "agent": "engineer", "run_id": "r1", "provider": "shell (shell)", + "workflow": "engineering"}, + {"event": "prompt_sent", "agent": "engineer", "run_id": "r1", "prompt": "go", + "prompt_path": ".clk/runs/r1/prompt.txt"}, {"event": "agent_response", "agent": "engineer", "run_id": "r1", "ok": True, - "tokens_in": 100, "tokens_out": 50, "tokens_total": 150, "response_text": "Decision: done", "files_reported": ["a.py"]}, + "tokens_in": 100, "tokens_out": 50, "tokens_total": 150, "response_text": "Decision: done", + "files_reported": ["a.py"]}, {"event": "git_commit", "message": "init"}, ] log.write_text("\n".join(json.dumps(e) for e in events) + "\n", encoding="utf-8") diff --git a/tests/test_workflow_stage_fields.py b/tests/test_workflow_stage_fields.py index 358c116..41c7089 100644 --- a/tests/test_workflow_stage_fields.py +++ b/tests/test_workflow_stage_fields.py @@ -3,7 +3,7 @@ from __future__ import annotations from pathlib import Path -from typing import Any, Dict +from typing import Dict import pytest diff --git a/user_tests/test_kickoff_e2e.py b/user_tests/test_kickoff_e2e.py index 6fd43d2..34019ae 100644 --- a/user_tests/test_kickoff_e2e.py +++ b/user_tests/test_kickoff_e2e.py @@ -122,7 +122,10 @@ def test_kickoff_with_provider_override(kickoff_sandbox: Path) -> None: def test_kickoff_rejects_unknown_option(kickoff_sandbox: Path) -> None: res = _run_kickoff(kickoff_sandbox, "--definitely-not-a-real-flag") assert res.returncode != 0 - assert "unknown option" in (res.stdout + res.stderr).lower() + # The shell parser said "unknown option"; argparse (which the thin + # wrapper delegates to) says "unrecognized arguments". + out = (res.stdout + res.stderr).lower() + assert "unknown option" in out or "unrecognized arguments" in out def test_kickoff_creates_independent_git_repo(kickoff_sandbox: Path) -> None: diff --git a/user_tests/test_mission_e2e.py b/user_tests/test_mission_e2e.py index f279818..6ccd63e 100644 --- a/user_tests/test_mission_e2e.py +++ b/user_tests/test_mission_e2e.py @@ -115,6 +115,75 @@ def test_mission_charter_before_plan_in_trace(initialized_project: Path): assert charter_idx > plan_idx, f"charter should precede plan: {log}" +def test_kickoff_cli_drives_mission_end_to_end(tmp_path: Path): + """`clk kickoff --no-tui --provider shell` in a fresh directory must run + the whole bootstrap + init → idea → plan → run → loop pipeline offline, + and the mission must behave exactly as the direct e2e above: telemetry + fires and the done-gate blocks premature completion. + + CLK_* env overrides are passed through so the kickoff env→config + mapping (the ported heredoc) is exercised end to end as well. + """ + res = run_clk( + "kickoff", "--no-tui", "--provider", "shell", + "--max-iterations", "1", "--project-name", "e2e-kickoff", + "build a tiny tool", + cwd=tmp_path, + env={ + # Keep the run fast and prove the CLK_* -> clk.config.json + # override path works through the real CLI. + "CLK_MISSION_MAX_TOTAL_CYCLES": "1", + "CLK_MISSION_DEFAULT_PHASES": "engineering", + "CLK_MISSION_MAX_ITERATIONS_PER_PHASE": "1", + "CLK_SUPERVISE_MAX_CYCLES": "1", + "CLK_ROBUSTNESS_AUTO_REFINE": "off", + "CLK_ROBUSTNESS_AUTO_CONSENSUS": "off", + "CLK_ROBUSTNESS_MAX_QUALITY_RETRIES": "0", + "CLK_ROBUSTNESS_PLATEAU_ACTION": "off", + "CLK_META_PROMPT_DISPATCH": "off", + "CLK_META_PROMPT_ROLE": "off", + "CLK_DELIBERATION_ENABLED": "false", + }, + timeout=600, + ) + assert res.returncode == 0, ( + f"kickoff exited {res.returncode}\nSTDOUT:\n{res.stdout}\nSTDERR:\n{res.stderr}" + ) + assert "[kickoff] complete" in res.stdout + + kickoffs = sorted((tmp_path / "workspace").glob("kickoff-*")) + assert kickoffs, "no kickoff-* dir under workspace/" + proj = kickoffs[-1] + + # Bootstrap artifacts. + assert (proj / "KICKOFF.md").is_file() + assert (proj / ".clk" / "harness" / "clk_harness").is_dir() + assert (proj / ".clk" / "scripts" / "clk").is_file() + assert (proj / ".git").is_dir() + + # Idea + provider wiring. + idea = json.loads((proj / ".clk" / "state" / "idea.json").read_text()) + assert idea["statement"] == "build a tiny tool" + providers = json.loads((proj / ".clk" / "config" / "providers.json").read_text()) + assert providers["active"] == "shell" + cfg = json.loads((proj / ".clk" / "config" / "clk.config.json").read_text()) + assert cfg["default_provider"] == "shell" + # The env overrides passed above must have landed in the config. + assert cfg["mission"]["max_total_cycles"] == 1 + assert cfg["mission"]["default_phases"] == ["engineering"] + assert cfg["robustness"]["auto_refine"] == "off" + + # Mission ran and completed the same way the direct e2e does: the + # shell provider yields no qa PASS / deliverables, so the done-gate + # must NOT grant completion, and telemetry must have fired. + assert (proj / ".clk" / "state" / "mission.json").exists() + plan = json.loads((proj / ".clk" / "state" / "mission.json").read_text()) + assert plan["status"] in ("stalled", "running") + assert not (proj / ".clk" / "state" / "done_granted.md").exists() + events = _read_jsonl(proj / ".clk" / "logs" / "activity.jsonl") + assert any(e.get("event") == "loop_cycle_summary" for e in events) + + def test_mission_writes_plan_post_to_blackboard(initialized_project: Path): proj = initialized_project _fast_mission(proj)