mirror of
https://github.com/cexll/myclaude.git
synced 2026-03-02 15:23:16 +08:00
Fixes two bugs: - False positive: stale harness-tasks.json from previous runs triggered self-reflect when harness wasn't active - False negative: self-reflect didn't trigger after harness completed all tasks Generated with SWE-Agent.ai Co-Authored-By: SWE-Agent.ai <noreply@swe-agent.ai>
217 lines
6.9 KiB
Python
217 lines
6.9 KiB
Python
#!/usr/bin/env python3
|
||
"""Self-reflection Stop hook — harness 任务循环完成后注入自省 prompt。
|
||
|
||
仅在以下条件同时满足时生效:
|
||
1. harness-tasks.json 存在(harness 曾被初始化)
|
||
2. .harness-active 不存在(harness 任务已全部完成)
|
||
|
||
当 harness 未曾启动时,本 hook 是完全的 no-op。
|
||
|
||
配置:
|
||
- REFLECT_MAX_ITERATIONS 环境变量(默认 5)
|
||
- 设为 0 可禁用
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import os
|
||
import sys
|
||
import tempfile
|
||
from pathlib import Path
|
||
from typing import Any, Optional
|
||
|
||
# Add hooks directory to sys.path for _harness_common import
|
||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||
try:
|
||
import _harness_common as hc
|
||
except ImportError:
|
||
hc = None # type: ignore[assignment]
|
||
|
||
DEFAULT_MAX_ITERATIONS = 5
|
||
|
||
|
||
def _read_payload() -> dict[str, Any]:
|
||
raw = sys.stdin.read()
|
||
if not raw.strip():
|
||
return {}
|
||
try:
|
||
data = json.loads(raw)
|
||
return data if isinstance(data, dict) else {}
|
||
except Exception:
|
||
return {}
|
||
|
||
|
||
def _find_harness_root(payload: dict[str, Any]) -> Optional[Path]:
|
||
"""查找 harness-tasks.json 所在的目录。存在则说明 harness 曾被使用。"""
|
||
if hc is not None:
|
||
return hc.find_harness_root(payload)
|
||
|
||
# Fallback: inline discovery if _harness_common not available
|
||
candidates: list[Path] = []
|
||
state_root = os.environ.get("HARNESS_STATE_ROOT")
|
||
if state_root:
|
||
p = Path(state_root)
|
||
if (p / "harness-tasks.json").is_file():
|
||
try:
|
||
return p.resolve()
|
||
except Exception:
|
||
return p
|
||
env_dir = os.environ.get("CLAUDE_PROJECT_DIR")
|
||
if env_dir:
|
||
candidates.append(Path(env_dir))
|
||
cwd = payload.get("cwd") or os.getcwd()
|
||
candidates.append(Path(cwd))
|
||
seen: set[str] = set()
|
||
for base in candidates:
|
||
try:
|
||
base = base.resolve()
|
||
except Exception:
|
||
continue
|
||
if str(base) in seen:
|
||
continue
|
||
seen.add(str(base))
|
||
for parent in [base, *list(base.parents)[:8]]:
|
||
if (parent / "harness-tasks.json").is_file():
|
||
return parent
|
||
return None
|
||
|
||
|
||
def _counter_path(session_id: str) -> Path:
|
||
"""每个 session 独立计数文件。"""
|
||
return Path(tempfile.gettempdir()) / f"claude-reflect-{session_id}"
|
||
|
||
|
||
def _read_counter(session_id: str) -> int:
|
||
p = _counter_path(session_id)
|
||
try:
|
||
return int(p.read_text("utf-8").strip().split("\n")[0])
|
||
except Exception:
|
||
return 0
|
||
|
||
|
||
def _write_counter(session_id: str, count: int) -> None:
|
||
p = _counter_path(session_id)
|
||
try:
|
||
p.write_text(str(count), encoding="utf-8")
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
def _extract_original_prompt(transcript_path: str, max_bytes: int = 100_000) -> str:
|
||
"""从 transcript JSONL 中提取第一条用户消息作为原始 prompt。"""
|
||
try:
|
||
p = Path(transcript_path)
|
||
if not p.is_file():
|
||
return ""
|
||
with p.open("r", encoding="utf-8") as f:
|
||
# JSONL 格式,逐行解析找第一条 user message
|
||
for line in f:
|
||
line = line.strip()
|
||
if not line:
|
||
continue
|
||
try:
|
||
entry = json.loads(line)
|
||
except Exception:
|
||
continue
|
||
if not isinstance(entry, dict):
|
||
continue
|
||
# Claude Code transcript 格式:role + content
|
||
role = entry.get("role") or entry.get("type", "")
|
||
if role == "user":
|
||
content = entry.get("content", "")
|
||
if isinstance(content, list):
|
||
# content 可能是 list of blocks
|
||
texts = []
|
||
for block in content:
|
||
if isinstance(block, dict):
|
||
t = block.get("text", "")
|
||
if t:
|
||
texts.append(t)
|
||
elif isinstance(block, str):
|
||
texts.append(block)
|
||
content = "\n".join(texts)
|
||
if isinstance(content, str) and content.strip():
|
||
# 截断过长的 prompt
|
||
if len(content) > 2000:
|
||
content = content[:2000] + "..."
|
||
return content.strip()
|
||
except Exception:
|
||
pass
|
||
return ""
|
||
|
||
|
||
def main() -> int:
|
||
payload = _read_payload()
|
||
session_id = payload.get("session_id", "")
|
||
if not session_id:
|
||
return 0 # 无 session_id,放行
|
||
|
||
# 守卫:仅当 harness 完成所有任务后(.harness-reflect 存在)才触发自省
|
||
# 这避免了两个问题:
|
||
# 1. 历史残留的 harness-tasks.json 导致误触发(false positive)
|
||
# 2. harness-stop.py 移除 .harness-active 后 Claude Code 跳过后续 hook(false negative)
|
||
root = _find_harness_root(payload)
|
||
if root is None:
|
||
return 0
|
||
|
||
if not (root / ".harness-reflect").is_file():
|
||
return 0
|
||
|
||
# 读取最大迭代次数
|
||
try:
|
||
max_iter = int(os.environ.get("REFLECT_MAX_ITERATIONS", DEFAULT_MAX_ITERATIONS))
|
||
except (ValueError, TypeError):
|
||
max_iter = DEFAULT_MAX_ITERATIONS
|
||
|
||
# 禁用
|
||
if max_iter <= 0:
|
||
return 0
|
||
|
||
# 读取当前计数
|
||
count = _read_counter(session_id)
|
||
|
||
# 超过最大次数,清理 marker 并放行
|
||
if count >= max_iter:
|
||
try:
|
||
(root / ".harness-reflect").unlink(missing_ok=True)
|
||
except Exception:
|
||
pass
|
||
return 0
|
||
|
||
# 递增计数
|
||
_write_counter(session_id, count + 1)
|
||
|
||
# 提取原始 prompt
|
||
transcript_path = payload.get("transcript_path", "")
|
||
original_prompt = _extract_original_prompt(transcript_path)
|
||
last_message = payload.get("last_assistant_message", "")
|
||
if last_message and len(last_message) > 3000:
|
||
last_message = last_message[:3000] + "..."
|
||
|
||
# 构建自省 prompt
|
||
parts = [
|
||
f"[Self-Reflect] 迭代 {count + 1}/{max_iter} — 请在继续之前进行自省检查:",
|
||
]
|
||
|
||
if original_prompt:
|
||
parts.append(f"\n📋 原始请求:\n{original_prompt}")
|
||
|
||
parts.append(
|
||
"\n🔍 自省清单:"
|
||
"\n1. 对照原始请求,逐项确认每个需求点是否已完整实现"
|
||
"\n2. 检查是否有遗漏的边界情况、错误处理或异常场景"
|
||
"\n3. 代码质量:是否有可以改进的地方(可读性、性能、安全性)"
|
||
"\n4. 是否需要补充测试或文档"
|
||
"\n5. 最终确认:所有改动是否一致且不互相冲突"
|
||
"\n\n如果一切已完成,简要总结成果即可结束。如果发现问题,继续修复。"
|
||
)
|
||
|
||
reason = "\n".join(parts)
|
||
|
||
print(json.dumps({"decision": "block", "reason": reason}, ensure_ascii=False))
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main())
|