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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2,375 changes: 21 additions & 2,354 deletions README.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion clk_harness/_api_launcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@
import threading
from typing import Optional


logger = logging.getLogger(__name__)


Expand Down Expand Up @@ -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(
Expand Down
16 changes: 10 additions & 6 deletions clk_harness/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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


Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -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:
Expand Down
80 changes: 61 additions & 19 deletions clk_harness/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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__
Expand All @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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 \"<your idea>\"")
close_log()
return 0
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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: "
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"))
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.")
Expand Down
7 changes: 5 additions & 2 deletions clk_harness/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@
from pathlib import Path
from typing import Any, Dict, Optional


CLK_DIR_NAME = ".clk"


Expand Down Expand Up @@ -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"},
}
Expand Down
4 changes: 1 addition & 3 deletions clk_harness/git_ops.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,6 @@

import shutil
import subprocess
import sys
import traceback
from pathlib import Path
from typing import Iterable, List, Optional

Expand Down Expand Up @@ -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 []
Expand Down
15 changes: 8 additions & 7 deletions clk_harness/integrations/telegram/bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading