From 1229cfa27fbcf8ca50a050093705a426e47624ea Mon Sep 17 00:00:00 2001 From: weijt606 Date: Thu, 9 Apr 2026 01:25:14 +0200 Subject: [PATCH] feat: ph shell-hook + harness guard + docs restructure (v0.2.1) --- CHANGELOG.md | 11 +++ README.md | 128 ++++++++++++++++++++------- README_CN.md | 128 ++++++++++++++++++++------- package.json | 2 +- pyproject.toml | 2 +- src/polyharness/__init__.py | 2 +- src/polyharness/cli.py | 147 ++++++++++++++++++++++++++++++++ src/polyharness/orchestrator.py | 6 ++ tests/test_evolution.py | 79 +++++++++++++++++ 9 files changed, 436 insertions(+), 69 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a67766..a2f792d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,17 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## [0.2.1] - 2026-04-09 + +### Added +- `ph shell-hook install/uninstall/status` — zero-config auto-wrap for agent commands via shell preexec hook +- Harness existence guard in orchestrator — clearer error when Proposer fails to generate `harness.py` +- 8 new tests (173 total) + +### Changed +- README flow diagrams moved to their corresponding modules (Step 4 and Step 6) for better readability +- CLI commands: 22 → 25 + ## [0.2.0] - 2026-04-08 ### Added diff --git a/README.md b/README.md index 1772bae..b57cd4a 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) [![Python 3.12+](https://img.shields.io/badge/python-3.12+-blue.svg)](https://www.python.org/downloads/) -[![Tests](https://img.shields.io/badge/tests-165%20passing-brightgreen.svg)]() +[![Tests](https://img.shields.io/badge/tests-173%20passing-brightgreen.svg)]() [![中文文档](https://img.shields.io/badge/文档-中文版-red.svg)](README_CN.md) --- @@ -242,6 +242,34 @@ ph run The orchestrator: copies your harness → asks the Proposer agent for a candidate change → evaluates the result → stores everything → repeats. +``` +┌──────────────────────────────────────────────────────────────┐ +│ │ +│ You PolyHarness │ +│ │ │ │ +│ ├── ph init ──────────────────→│ Creates workspace │ +│ │ (harness + tasks + eval) │ Copies files │ +│ │ │ Injects CLAUDE.md │ +│ │ │ │ +│ ├── ph run ───────────────────→│ Starts search loop: │ +│ │ │ │ +│ │ ┌──────────────────────────┤ │ +│ │ │ Step 1: SELECT parent │ Best or Tournament │ +│ │ │ Step 2: COPY harness │ From parent → candidate │ +│ │ │ Step 3: PROPOSE changes │ Agent reads all history │ +│ │ │ Step 4: EVALUATE │ Run tasks, get scores │ +│ │ │ Step 5: STORE results │ Code + scores + traces │ +│ │ │ Step 6: CHECK stopping │ Improved? Patience left? │ +│ │ └──────────┬───────────────┤ │ +│ │ └── loop ───────┘ │ +│ │ │ │ +│ ├── ph log ───────────────────→│ Shows search tree │ +│ ├── ph compare 0 5 ──────────→│ Score deltas + code diff │ +│ └── ph apply ─────────────────→│ Writes best back │ +│ │ +└──────────────────────────────────────────────────────────────┘ +``` + ### 5. Inspect and apply ```bash @@ -325,7 +353,66 @@ ph evolve # trigger evolution manually > **Tip:** Use `--no-record-output` if you don't want stdout/stderr saved (e.g., for sensitive output). Metadata is always recorded. -> **Tip:** Create a shell alias for even less typing: `alias cc="ph wrap --auto-evolve claude"` +#### Zero-config auto-wrap: `ph shell-hook` + +Don't want to type `ph wrap --auto-evolve` every time? Install a shell hook — it auto-intercepts agent commands: + +```bash +ph shell-hook install # one-time setup, writes to ~/.zshrc +``` + +After that, just use your agent as usual: + +```bash +claude -p "Refactor auth to JWT" # automatically becomes: ph wrap --auto-evolve claude -p ... +claw -p "Write payment tests" # same — auto-wrapped +codex "Add retry logic" # same +opencode -p "Fix flaky test" # same +``` + +How it works: a `preexec` hook in your shell detects `claude`/`claw`/`codex`/`opencode` commands and transparently redirects them through `ph wrap --auto-evolve`. Your output is unchanged. + +```bash +ph shell-hook status # check if installed +ph shell-hook uninstall # remove cleanly (restores original rc file) +``` + +#### Auto-Evolution flow + +``` +┌──────────────────────────────────────────────────────────────┐ +│ │ +│ You PolyHarness │ +│ │ │ │ +│ ├── ph shell-hook install ────→ │ Injects preexec hook │ +│ │ (one-time setup) │ into ~/.zshrc │ +│ │ │ │ +│ ├── claude -p "Fix bug" ──────→ │ Shell hook intercepts │ +│ │ (normal usage) │ │ +│ │ ├── Run agent │ +│ │ ┌─ output passes through ──┤ │ +│ │ │ ├── Record trace │ +│ │ │ │ (~/.polyharness/ │ +│ │ │ │ traces/) │ +│ │ │ │ │ +│ │ │ ├── Check threshold │ +│ │ │ │ traces < 50? │ +│ │ │ │ ├─ Yes: "7/50 traces" │ +│ │ │ │ └─ No: trigger ───┐ │ +│ │ │ │ │ │ +│ │ │ │ ┌─────────────────┘ │ +│ │ │ │ │ Evolution cycle │ +│ │ │ │ │ (same as ph run) │ +│ │ │ │ │ Propose → Evaluate │ +│ │ │ │ │ → Store → Repeat │ +│ │ │ │ └────────────────── │ +│ │ │ │ │ +│ └───┘ │ │ +│ │ +└──────────────────────────────────────────────────────────────┘ +``` + +The key difference: **you never run `ph run` manually.** You use your agent as always; PolyHarness silently collects data and triggers evolution when it has enough signal. ### Try it now (no API key needed) @@ -347,35 +434,7 @@ The score path above is the current measured result of the bundled `math-word-pr ## How It Works -PolyHarness runs a **Meta-Harness-style search loop** — an iterative process where an AI agent proposes, evaluates, and stores harness changes: - -``` -┌──────────────────────────────────────────────────────────────┐ -│ │ -│ You PolyHarness │ -│ │ │ │ -│ ├── ph init ──────────────────→│ Creates workspace │ -│ │ (harness + tasks + eval) │ Copies files │ -│ │ │ Injects CLAUDE.md │ -│ │ │ │ -│ ├── ph run ───────────────────→│ Starts search loop: │ -│ │ │ │ -│ │ ┌──────────────────────────┤ │ -│ │ │ Step 1: SELECT parent │ Best or Tournament │ -│ │ │ Step 2: COPY harness │ From parent → candidate │ -│ │ │ Step 3: PROPOSE changes │ Agent reads all history │ -│ │ │ Step 4: EVALUATE │ Run tasks, get scores │ -│ │ │ Step 5: STORE results │ Code + scores + traces │ -│ │ │ Step 6: CHECK stopping │ Improved? Patience left? │ -│ │ └──────────┬───────────────┤ │ -│ │ └── loop ───────┘ │ -│ │ │ │ -│ ├── ph log ───────────────────→│ Shows search tree │ -│ ├── ph compare 0 5 ──────────→│ Score deltas + code diff │ -│ └── ph apply ─────────────────→│ Writes best back │ -│ │ -└──────────────────────────────────────────────────────────────┘ -``` +PolyHarness runs a **Meta-Harness-style search loop** — an iterative process where an AI agent proposes, evaluates, and stores harness changes. See the detailed flow diagrams above in [Step 4](#4-run-the-optimization-loop) and [Step 6](#6-auto-evolution). ### Why it works: non-Markovian search @@ -555,6 +614,9 @@ python -m polyharness --version | `ph traces stats` | Summary statistics: total traces, scored count, agent distribution | | `ph traces clear` | Remove collected traces (`--keep N` to retain newest, `-y` to skip confirm) | | `ph evolve` | Trigger an online evolution cycle using collected traces as context | +| `ph shell-hook install` | Install shell hook to auto-wrap agent commands (claude, claw, codex, opencode) | +| `ph shell-hook uninstall` | Remove the shell hook from your rc file | +| `ph shell-hook status` | Check if the shell hook is installed | | `ph upgrade` | Upgrade PolyHarness to the latest version | | `ph uninstall` | Uninstall PolyHarness from the current environment (`-y` to skip confirm) | @@ -664,7 +726,7 @@ ph run --max-iterations 5 ``` polyharness/ ├── src/polyharness/ -│ ├── cli.py # Click CLI — 22 commands/subcommands +│ ├── cli.py # Click CLI — 25 commands/subcommands │ ├── config.py # Pydantic config models (+ EvolutionConfig) │ ├── collector.py # Trace collector for online evolution │ ├── orchestrator.py # Meta-Harness search loop + progress bar + error recovery @@ -689,7 +751,7 @@ polyharness/ │ ├── code-generation/ │ ├── rag-qa/ │ └── api-calling/ -├── tests/ # 165 tests (pytest) +├── tests/ # 173 tests (pytest) ├── bin/ # npm wrapper (ph.mjs, postinstall.mjs) ├── docs/ │ ├── development/ # Product roadmap & technical architecture diff --git a/README_CN.md b/README_CN.md index 0ee1934..24cb054 100644 --- a/README_CN.md +++ b/README_CN.md @@ -15,7 +15,7 @@ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) [![Python 3.12+](https://img.shields.io/badge/python-3.12+-blue.svg)](https://www.python.org/downloads/) -[![Tests](https://img.shields.io/badge/tests-165%20passing-brightgreen.svg)]() +[![Tests](https://img.shields.io/badge/tests-173%20passing-brightgreen.svg)]() [![English](https://img.shields.io/badge/Docs-English-blue.svg)](README.md) --- @@ -242,6 +242,34 @@ ph run 编排器会执行这样一个循环:复制你的 harness → 让 Proposer agent 提出候选修改 → 评估结果 → 存储一切 → 重复。 +``` +┌──────────────────────────────────────────────────────────────┐ +│ │ +│ 你 PolyHarness │ +│ │ │ │ +│ ├── ph init ──────────────────→│ 创建 workspace │ +│ │ (harness + tasks + eval) │ 复制文件 │ +│ │ │ 注入 CLAUDE.md │ +│ │ │ │ +│ ├── ph run ───────────────────→│ 启动搜索循环: │ +│ │ │ │ +│ │ ┌──────────────────────────┤ │ +│ │ │ 步骤 1: 选择父候选 │ 最优或 Tournament │ +│ │ │ 步骤 2: 复制 harness │ 从父候选 → 新候选 │ +│ │ │ 步骤 3: 提议改进 │ Agent 读取全部历史 │ +│ │ │ 步骤 4: 评估 │ 运行任务,计算分数 │ +│ │ │ 步骤 5: 存储结果 │ 代码 + 分数 + 轨迹 │ +│ │ │ 步骤 6: 检查停止条件 │ 有改进?还有耐心? │ +│ │ └──────────┬───────────────┤ │ +│ │ └── 循环 ───────┘ │ +│ │ │ │ +│ ├── ph log ───────────────────→│ 展示搜索树 │ +│ ├── ph compare 0 5 ──────────→│ 分数差异 + 代码 diff │ +│ └── ph apply ─────────────────→│ 回写最优结果 │ +│ │ +└──────────────────────────────────────────────────────────────┘ +``` + ### 5. 查看和应用 ```bash @@ -325,7 +353,66 @@ ph evolve # 手动触发进化 > **提示:** 如果不想保存 stdout/stderr(例如敏感输出),使用 `--no-record-output`。元数据始终会记录。 -> **提示:** 可以设置 shell alias 进一步简化输入:`alias cc="ph wrap --auto-evolve claude"` +#### 零配置自动包裹:`ph shell-hook` + +不想每次都输入 `ph wrap --auto-evolve`?安装 shell 钩子即可自动拦截 agent 命令: + +```bash +ph shell-hook install # 一次性设置,写入 ~/.zshrc +``` + +之后正常使用 agent 即可: + +```bash +claude -p "把 auth 重构为 JWT" # 自动变为:ph wrap --auto-evolve claude -p ... +claw -p "写支付测试" # 同理——自动包裹 +codex "加重试逻辑" # 同理 +opencode -p "修复不稳定测试" # 同理 +``` + +原理:shell 的 `preexec` 钩子检测到 `claude`/`claw`/`codex`/`opencode` 命令后,透明地通过 `ph wrap --auto-evolve` 转发。你的输出不会变。 + +```bash +ph shell-hook status # 查看是否已安装 +ph shell-hook uninstall # 干净移除(恢复原始 rc 文件) +``` + +#### 自动进化流程 + +``` +┌──────────────────────────────────────────────────────────────┐ +│ │ +│ 你 PolyHarness │ +│ │ │ │ +│ ├── ph shell-hook install ────→│ 注入 preexec 钩子 │ +│ │ (一次性设置) │ 到 ~/.zshrc │ +│ │ │ │ +│ ├── claude -p "修复 bug" ────→ │ Shell 钩子拦截 │ +│ │ (正常使用) │ │ +│ │ ├── 运行 agent │ +│ │ ┌─ 输出原样透传 ─────────────┤ │ +│ │ │ ├── 记录 trace │ +│ │ │ │ (~/.polyharness/ │ +│ │ │ │ traces/) │ +│ │ │ │ │ +│ │ │ ├── 检查阈值 │ +│ │ │ │ traces < 50? │ +│ │ │ │ ├─ 是: "7/50 traces" │ +│ │ │ │ └─ 否: 触发 ─────┐ │ +│ │ │ │ │ │ +│ │ │ │ ┌───────────────┘ │ +│ │ │ │ │ 进化循环 │ +│ │ │ │ │(等同 ph run) │ +│ │ │ │ │ 提议 → 评估 │ +│ │ │ │ │ → 存储 → 重复 │ +│ │ │ │ └────────────────── │ +│ │ │ │ │ +│ └───┘ │ │ +│ │ +└──────────────────────────────────────────────────────────────┘ +``` + +核心区别:**你不需要手动执行 `ph run`。**像往常一样使用 agent;PolyHarness 静默收集数据,当积累足够信号时自动触发进化。 ### 立即体验(无需 API key) @@ -347,35 +434,7 @@ ph log ## 工作原理 -PolyHarness 运行的是一个 **Meta-Harness 风格的搜索循环**,即让 AI agent 逐轮提出、评估并记录 harness 变更的迭代过程: - -``` -┌──────────────────────────────────────────────────────────────┐ -│ │ -│ 你 PolyHarness │ -│ │ │ │ -│ ├── ph init ──────────────────→│ 创建 workspace │ -│ │ (harness + tasks + eval) │ 复制文件 │ -│ │ │ 注入 CLAUDE.md │ -│ │ │ │ -│ ├── ph run ───────────────────→│ 启动搜索循环: │ -│ │ │ │ -│ │ ┌──────────────────────────┤ │ -│ │ │ 步骤 1: 选择父候选 │ 最优或 Tournament │ -│ │ │ 步骤 2: 复制 harness │ 从父候选 → 新候选 │ -│ │ │ 步骤 3: 提议改进 │ Agent 读取全部历史 │ -│ │ │ 步骤 4: 评估 │ 运行任务,计算分数 │ -│ │ │ 步骤 5: 存储结果 │ 代码 + 分数 + 轨迹 │ -│ │ │ 步骤 6: 检查停止条件 │ 有改进?还有耐心? │ -│ │ └──────────┬───────────────┤ │ -│ │ └── 循环 ───────┘ │ -│ │ │ │ -│ ├── ph log ───────────────────→│ 展示搜索树 │ -│ ├── ph compare 0 5 ──────────→│ 分数差异 + 代码 diff │ -│ └── ph apply ─────────────────→│ 回写最优结果 │ -│ │ -└──────────────────────────────────────────────────────────────┘ -``` +PolyHarness 运行的是一个 **Meta-Harness 风格的搜索循环**,即让 AI agent 逐轮提出、评估并记录 harness 变更的迭代过程。详细流程图见上方 [步骤 4](#4-运行优化循环) 和 [步骤 6](#6-自动进化)。 ### 为什么有效:非马尔可夫搜索 @@ -555,6 +614,9 @@ python -m polyharness --version | `ph traces stats` | 汇总统计:总 traces 数、已评分数、各 agent 分布 | | `ph traces clear` | 清除已收集的 traces(`--keep N` 保留最新、`-y` 跳过确认) | | `ph evolve` | 基于已收集的 traces 触发一轮在线进化循环 | +| `ph shell-hook install` | 安装 shell 钩子,自动包裹 agent 命令(claude、claw、codex、opencode) | +| `ph shell-hook uninstall` | 从 rc 文件中移除 shell 钩子 | +| `ph shell-hook status` | 检查 shell 钩子是否已安装 | | `ph upgrade` | 升级 PolyHarness 到最新版本 | | `ph uninstall` | 卸载 PolyHarness(`-y` 跳过确认) | @@ -664,7 +726,7 @@ ph run --max-iterations 5 ``` polyharness/ ├── src/polyharness/ -│ ├── cli.py # Click CLI —— 22 个命令/子命令 +│ ├── cli.py # Click CLI —— 25 个命令/子命令 │ ├── config.py # Pydantic 配置模型(+ EvolutionConfig) │ ├── collector.py # 在线进化 trace 收集器 │ ├── orchestrator.py # Meta-Harness 搜索循环 + 进度条 + 错误恢复 @@ -689,7 +751,7 @@ polyharness/ │ ├── code-generation/ │ ├── rag-qa/ │ └── api-calling/ -├── tests/ # 165 个测试(pytest) +├── tests/ # 173 个测试(pytest) ├── bin/ # npm 包装器(ph.mjs、postinstall.mjs) ├── docs/ │ ├── development/ # 产品路线图 & 技术架构 diff --git a/package.json b/package.json index 1a44128..d652445 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "polyharness", - "version": "0.2.0", + "version": "0.2.1", "description": "Make your AI agent evolve automatically through iterative harness optimization.", "keywords": ["agent", "harness", "optimization", "meta-harness", "cli"], "license": "MIT", diff --git a/pyproject.toml b/pyproject.toml index 044ef88..a104fa9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "polyharness" -version = "0.2.0" +version = "0.2.1" description = "Automated harness optimization for AI agents — make your agent evolve." readme = "README.md" license = "MIT" diff --git a/src/polyharness/__init__.py b/src/polyharness/__init__.py index 213d304..e93c856 100644 --- a/src/polyharness/__init__.py +++ b/src/polyharness/__init__.py @@ -1,3 +1,3 @@ """PolyHarness — Automated harness optimization for AI agents.""" -__version__ = "0.2.0" +__version__ = "0.2.1" diff --git a/src/polyharness/cli.py b/src/polyharness/cli.py index fc3d90c..4a97ccc 100644 --- a/src/polyharness/cli.py +++ b/src/polyharness/cli.py @@ -1775,3 +1775,150 @@ def evolve(workspace: str, store: str | None, max_iterations: int | None): console.print("Run [bold]ph apply[/bold] to use the improved harness.") else: console.print("[yellow]No improvement found in this evolution cycle.[/yellow]") + + +# --------------------------------------------------------------------------- +# ph shell-hook — auto-wrap agent commands via shell preexec hook +# --------------------------------------------------------------------------- + +_HOOK_MARKER_START = "# >>> polyharness shell-hook >>>" +_HOOK_MARKER_END = "# <<< polyharness shell-hook <<<" + +_HOOK_SCRIPT = r'''# >>> polyharness shell-hook >>> +# Auto-wrap agent commands with ph wrap --auto-evolve +# Installed by: ph shell-hook install +_ph_preexec() { + # Only intercept if ph is available + command -v ph >/dev/null 2>&1 || return + local cmd="$1" + case "$cmd" in + claude\ *|claw\ *|codex\ *|opencode\ *) + eval "ph wrap --auto-evolve $cmd" + # Return non-zero to prevent original command from running (zsh preexec) + return 1 + ;; + esac +} +# Install into zsh preexec (works with or without oh-my-zsh) +if [[ -n "$ZSH_VERSION" ]]; then + autoload -Uz add-zsh-hook 2>/dev/null + if typeset -f add-zsh-hook >/dev/null 2>&1; then + add-zsh-hook preexec _ph_preexec + else + preexec_functions+=(_ph_preexec) + fi +fi +# <<< polyharness shell-hook <<< +''' + + +def _detect_shell_rc() -> Path: + """Detect the user's shell rc file.""" + import os + + shell = os.environ.get("SHELL", "") + home = Path.home() + if "zsh" in shell: + return home / ".zshrc" + if "bash" in shell: + # Prefer .bash_profile on macOS, .bashrc on Linux + import platform + + if platform.system() == "Darwin": + return home / ".bash_profile" + return home / ".bashrc" + # Fallback to .profile + return home / ".profile" + + +def _hook_installed(rc_path: Path) -> bool: + """Check if the hook is already installed.""" + if not rc_path.exists(): + return False + content = rc_path.read_text() + return _HOOK_MARKER_START in content + + +@main.group(name="shell-hook") +def shell_hook(): + """Manage shell auto-wrap hook for agent commands.""" + pass + + +@shell_hook.command() +@click.option( + "--rc", + type=click.Path(), + default=None, + help="Shell rc file to modify (default: auto-detect).", +) +def install(rc: str | None): + """Install shell hook to auto-wrap agent commands. + + Adds a preexec hook to your shell rc file so that commands like + `claude -p ...`, `claw -p ...`, `codex ...`, `opencode -p ...` + are automatically wrapped with `ph wrap --auto-evolve`. + """ + rc_path = Path(rc) if rc else _detect_shell_rc() + + if _hook_installed(rc_path): + console.print(f"[yellow]Hook already installed in {rc_path}[/yellow]") + return + + # Append the hook script + with open(rc_path, "a") as f: + f.write("\n" + _HOOK_SCRIPT) + + console.print(f"[green]Shell hook installed in {rc_path}[/green]") + console.print(f"Run [bold]source {rc_path}[/bold] or open a new terminal to activate.") + console.print() + console.print("Agent commands that will be auto-wrapped:") + console.print(" claude, claw, codex, opencode") + console.print() + console.print("To remove: [bold]ph shell-hook uninstall[/bold]") + + +@shell_hook.command(name="uninstall") +@click.option( + "--rc", + type=click.Path(), + default=None, + help="Shell rc file to modify (default: auto-detect).", +) +def hook_uninstall(rc: str | None): + """Remove the shell hook from your rc file.""" + rc_path = Path(rc) if rc else _detect_shell_rc() + + if not _hook_installed(rc_path): + console.print(f"[yellow]No hook found in {rc_path}[/yellow]") + return + + content = rc_path.read_text() + # Remove everything between markers (inclusive), plus surrounding blank lines + import re + + pattern = r"\n*# >>> polyharness shell-hook >>>.*?# <<< polyharness shell-hook <<<\n*" + cleaned = re.sub(pattern, "\n", content, flags=re.DOTALL) + rc_path.write_text(cleaned) + + console.print(f"[green]Shell hook removed from {rc_path}[/green]") + console.print(f"Run [bold]source {rc_path}[/bold] or open a new terminal to apply.") + + +@shell_hook.command(name="status") +@click.option( + "--rc", + type=click.Path(), + default=None, + help="Shell rc file to check (default: auto-detect).", +) +def hook_status(rc: str | None): + """Check if the shell hook is installed.""" + rc_path = Path(rc) if rc else _detect_shell_rc() + + if _hook_installed(rc_path): + console.print(f"[green]Hook is installed in {rc_path}[/green]") + console.print("Auto-wrapped commands: claude, claw, codex, opencode") + else: + console.print(f"[yellow]Hook is not installed[/yellow] ({rc_path})") + console.print("Run [bold]ph shell-hook install[/bold] to set it up.") diff --git a/src/polyharness/orchestrator.py b/src/polyharness/orchestrator.py index d5ddf67..83a833a 100644 --- a/src/polyharness/orchestrator.py +++ b/src/polyharness/orchestrator.py @@ -154,6 +154,12 @@ def run(self, resume: bool = False) -> SearchResult: parent=parent, ) + # Step 3.5: Verify proposer produced a harness file + if not (cand_dir / "harness.py").exists(): + raise FileNotFoundError( + f"Proposer did not generate harness.py in iter_{i}" + ) + # Step 4: Evaluate score = self._evaluate_iteration(i) except Exception as exc: diff --git a/tests/test_evolution.py b/tests/test_evolution.py index a6c0693..8b36956 100644 --- a/tests/test_evolution.py +++ b/tests/test_evolution.py @@ -289,3 +289,82 @@ def test_config_roundtrip_yaml(self, tmp_path: Path): assert loaded.evolution.mode == "online" assert loaded.evolution.trigger.strategy == "degradation" assert loaded.evolution.max_iterations == 3 + + +# --------------------------------------------------------------------------- +# ph shell-hook +# --------------------------------------------------------------------------- + + +class TestShellHook: + def test_shell_hook_help(self, runner: CliRunner): + result = runner.invoke(main, ["shell-hook", "--help"]) + assert result.exit_code == 0 + assert "auto-wrap" in result.output.lower() + + def test_install_creates_hook(self, runner: CliRunner, tmp_path: Path): + rc = tmp_path / ".zshrc" + rc.write_text("# existing config\n") + result = runner.invoke(main, ["shell-hook", "install", "--rc", str(rc)]) + assert result.exit_code == 0 + assert "installed" in result.output.lower() + content = rc.read_text() + assert "polyharness shell-hook" in content + assert "_ph_preexec" in content + + def test_install_idempotent(self, runner: CliRunner, tmp_path: Path): + rc = tmp_path / ".zshrc" + rc.write_text("# existing config\n") + runner.invoke(main, ["shell-hook", "install", "--rc", str(rc)]) + result = runner.invoke(main, ["shell-hook", "install", "--rc", str(rc)]) + assert result.exit_code == 0 + assert "already installed" in result.output.lower() + # Should only appear once + content = rc.read_text() + assert content.count("polyharness shell-hook") == 2 # start + end markers + + def test_uninstall_removes_hook(self, runner: CliRunner, tmp_path: Path): + rc = tmp_path / ".zshrc" + rc.write_text("# existing config\n") + runner.invoke(main, ["shell-hook", "install", "--rc", str(rc)]) + result = runner.invoke(main, ["shell-hook", "uninstall", "--rc", str(rc)]) + assert result.exit_code == 0 + assert "removed" in result.output.lower() + content = rc.read_text() + assert "polyharness shell-hook" not in content + + def test_uninstall_no_hook(self, runner: CliRunner, tmp_path: Path): + rc = tmp_path / ".zshrc" + rc.write_text("# clean config\n") + result = runner.invoke(main, ["shell-hook", "uninstall", "--rc", str(rc)]) + assert result.exit_code == 0 + assert "no hook found" in result.output.lower() + + def test_status_not_installed(self, runner: CliRunner, tmp_path: Path): + rc = tmp_path / ".zshrc" + rc.write_text("# clean\n") + result = runner.invoke(main, ["shell-hook", "status", "--rc", str(rc)]) + assert result.exit_code == 0 + assert "not installed" in result.output.lower() + + def test_status_installed(self, runner: CliRunner, tmp_path: Path): + rc = tmp_path / ".zshrc" + rc.write_text("# existing\n") + runner.invoke(main, ["shell-hook", "install", "--rc", str(rc)]) + result = runner.invoke(main, ["shell-hook", "status", "--rc", str(rc)]) + assert result.exit_code == 0 + assert "installed" in result.output.lower() + assert "claude" in result.output.lower() + + def test_uninstall_preserves_surrounding(self, runner: CliRunner, tmp_path: Path): + rc = tmp_path / ".zshrc" + rc.write_text("# before\nexport FOO=1\n") + runner.invoke(main, ["shell-hook", "install", "--rc", str(rc)]) + # Add content after the hook + with open(rc, "a") as f: + f.write("# after\nexport BAR=2\n") + runner.invoke(main, ["shell-hook", "uninstall", "--rc", str(rc)]) + content = rc.read_text() + assert "FOO=1" in content + assert "BAR=2" in content + assert "polyharness" not in content