Files
myclaude/skills/harness/hooks/self-reflect-stop.py
cexll 08877af530 fix(harness): replace implicit .harness-active absence with explicit .harness-reflect marker for self-reflect triggering
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>
2026-03-02 00:30:34 +08:00

217 lines
6.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 跳过后续 hookfalse 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())