feat(skills): add per-task skill spec auto-detection and injection

Replace external inject-spec.py hook with built-in zero-config skill
detection in codeagent-wrapper. The system auto-detects project type
from fingerprint files (go.mod, package.json, etc.), maps to installed
skills, and injects SKILL.md content directly into sub-agent prompts.

Key changes:
- Add DetectProjectSkills/ResolveSkillContent in executor/prompt.go
- Add Skills field to TaskSpec with parallel config parsing
- Add --skills CLI flag for explicit override
- Update /do SKILL.md Phase 4 with per-task skill examples
- Remove on-stop.py global hook (not needed)
- Replace inject-spec.py with no-op (detection now internal)
- Add 20 unit tests covering detection, resolution, budget, security

Security: path traversal protection via validSkillName regex,
16K char budget with tag overhead accounting, CRLF normalization.

Generated with SWE-Agent.ai

Co-Authored-By: SWE-Agent.ai <noreply@swe-agent.ai>
This commit is contained in:
cexll
2026-02-09 11:06:36 +08:00
parent 5853539cab
commit 97dfa907d9
24 changed files with 1699 additions and 419 deletions

View File

@@ -1,5 +1,5 @@
{
"description": "do loop hook for 5-phase workflow",
"description": "do loop hooks for 5-phase workflow",
"hooks": {
"Stop": [
{
@@ -10,6 +10,17 @@
}
]
}
],
"SubagentStop": [
{
"matcher": "code-reviewer",
"hooks": [
{
"type": "command",
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/verify-loop.py"
}
]
}
]
}
}

View File

@@ -1,10 +1,20 @@
#!/usr/bin/env python3
"""
Stop hook for do skill workflow.
Checks if the do loop is complete before allowing exit.
Uses the new task directory structure under .claude/do-tasks/.
"""
import glob
import json
import os
import re
import sys
DIR_TASKS = ".claude/do-tasks"
FILE_CURRENT_TASK = ".current-task"
FILE_TASK_JSON = "task.json"
PHASE_NAMES = {
1: "Understand",
2: "Clarify",
@@ -13,98 +23,69 @@ PHASE_NAMES = {
5: "Complete",
}
def phase_name_for(n: int) -> str:
return PHASE_NAMES.get(n, f"Phase {n}")
def frontmatter_get(file_path: str, key: str) -> str:
def get_current_task(project_dir: str) -> str | None:
"""Read current task directory path."""
current_task_file = os.path.join(project_dir, DIR_TASKS, FILE_CURRENT_TASK)
if not os.path.exists(current_task_file):
return None
try:
with open(file_path, "r", encoding="utf-8") as f:
lines = f.readlines()
with open(current_task_file, "r", encoding="utf-8") as f:
content = f.read().strip()
return content if content else None
except Exception:
return ""
return None
if not lines or lines[0].strip() != "---":
return ""
for i, line in enumerate(lines[1:], start=1):
if line.strip() == "---":
break
match = re.match(rf"^{re.escape(key)}:\s*(.*)$", line)
if match:
value = match.group(1).strip()
if value.startswith('"') and value.endswith('"'):
value = value[1:-1]
return value
return ""
def get_body(file_path: str) -> str:
def get_task_info(project_dir: str, task_dir: str) -> dict | None:
"""Read task.json data."""
task_json_path = os.path.join(project_dir, task_dir, FILE_TASK_JSON)
if not os.path.exists(task_json_path):
return None
try:
with open(file_path, "r", encoding="utf-8") as f:
content = f.read()
with open(task_json_path, "r", encoding="utf-8") as f:
return json.load(f)
except Exception:
return None
def check_task_complete(project_dir: str, task_dir: str) -> str:
"""Check if task is complete. Returns blocking reason or empty string."""
task_info = get_task_info(project_dir, task_dir)
if not task_info:
return ""
parts = content.split("---", 2)
if len(parts) >= 3:
return parts[2]
return ""
def check_state_file(state_file: str, stdin_payload: str) -> str:
active_raw = frontmatter_get(state_file, "active")
active_lc = active_raw.lower()
if active_lc not in ("true", "1", "yes", "on"):
status = task_info.get("status", "")
if status == "completed":
return ""
current_phase_raw = frontmatter_get(state_file, "current_phase")
max_phases_raw = frontmatter_get(state_file, "max_phases")
phase_name = frontmatter_get(state_file, "phase_name")
completion_promise = frontmatter_get(state_file, "completion_promise")
current_phase = task_info.get("current_phase", 1)
max_phases = task_info.get("max_phases", 5)
phase_name = task_info.get("phase_name", phase_name_for(current_phase))
completion_promise = task_info.get("completion_promise", "<promise>DO_COMPLETE</promise>")
try:
current_phase = int(current_phase_raw)
except (ValueError, TypeError):
current_phase = 1
try:
max_phases = int(max_phases_raw)
except (ValueError, TypeError):
max_phases = 5
if not phase_name:
phase_name = phase_name_for(current_phase)
if not completion_promise:
completion_promise = "<promise>DO_COMPLETE</promise>"
phases_done = current_phase >= max_phases
if phases_done:
# 阶段已完成,清理状态文件并允许退出
# promise 检测作为可选确认,不阻止退出
try:
os.remove(state_file)
except Exception:
pass
if current_phase >= max_phases:
# Task is at final phase, allow exit
return ""
return (f"do loop incomplete: current phase {current_phase}/{max_phases} ({phase_name}). "
f"Continue with remaining phases; update {state_file} current_phase/phase_name after each phase. "
f"Include completion_promise in final output when done: {completion_promise}. "
f"To exit early, set active to false.")
return (
f"do loop incomplete: current phase {current_phase}/{max_phases} ({phase_name}). "
f"Continue with remaining phases; use 'task.py update-phase <N>' after each phase. "
f"Include completion_promise in final output when done: {completion_promise}. "
f"To exit early, set status to 'completed' in task.json."
)
def main():
project_dir = os.environ.get("CLAUDE_PROJECT_DIR", os.getcwd())
state_dir = os.path.join(project_dir, ".claude")
do_task_id = os.environ.get("DO_TASK_ID", "")
if do_task_id:
candidate = os.path.join(state_dir, f"do.{do_task_id}.local.md")
state_files = [candidate] if os.path.isfile(candidate) else []
else:
state_files = glob.glob(os.path.join(state_dir, "do.*.local.md"))
if not state_files:
task_dir = get_current_task(project_dir)
if not task_dir:
# No active task, allow exit
sys.exit(0)
stdin_payload = ""
@@ -114,18 +95,13 @@ def main():
except Exception:
pass
blocking_reasons = []
for state_file in state_files:
reason = check_state_file(state_file, stdin_payload)
if reason:
blocking_reasons.append(reason)
if not blocking_reasons:
reason = check_task_complete(project_dir, task_dir)
if not reason:
sys.exit(0)
combined_reason = " ".join(blocking_reasons)
print(json.dumps({"decision": "block", "reason": combined_reason}))
print(json.dumps({"decision": "block", "reason": reason}))
sys.exit(0)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,218 @@
#!/usr/bin/env python3
"""
Verify Loop Hook for do skill workflow.
SubagentStop hook that intercepts when code-reviewer agent tries to stop.
Runs verification commands to ensure code quality before allowing exit.
Mechanism:
- Intercepts SubagentStop event for code-reviewer agent
- Runs verify commands from task.json if configured
- Blocks stopping until verification passes
- Has max iterations as safety limit (MAX_ITERATIONS=5)
State file: .claude/do-tasks/.verify-state.json
"""
import json
import os
import subprocess
import sys
from datetime import datetime
from pathlib import Path
# Configuration
MAX_ITERATIONS = 5
STATE_TIMEOUT_MINUTES = 30
DIR_TASKS = ".claude/do-tasks"
FILE_CURRENT_TASK = ".current-task"
FILE_TASK_JSON = "task.json"
STATE_FILE = ".claude/do-tasks/.verify-state.json"
# Only control loop for code-reviewer agent
TARGET_AGENTS = {"code-reviewer"}
def get_project_root(cwd: str) -> str | None:
"""Find project root (directory with .claude folder)."""
current = Path(cwd).resolve()
while current != current.parent:
if (current / ".claude").exists():
return str(current)
current = current.parent
return None
def get_current_task(project_root: str) -> str | None:
"""Read current task directory path."""
current_task_file = os.path.join(project_root, DIR_TASKS, FILE_CURRENT_TASK)
if not os.path.exists(current_task_file):
return None
try:
with open(current_task_file, "r", encoding="utf-8") as f:
content = f.read().strip()
return content if content else None
except Exception:
return None
def get_task_info(project_root: str, task_dir: str) -> dict | None:
"""Read task.json data."""
task_json_path = os.path.join(project_root, task_dir, FILE_TASK_JSON)
if not os.path.exists(task_json_path):
return None
try:
with open(task_json_path, "r", encoding="utf-8") as f:
return json.load(f)
except Exception:
return None
def get_verify_commands(task_info: dict) -> list[str]:
"""Get verify commands from task.json."""
return task_info.get("verify_commands", [])
def run_verify_commands(project_root: str, commands: list[str]) -> tuple[bool, str]:
"""Run verify commands and return (success, message)."""
for cmd in commands:
try:
result = subprocess.run(
cmd,
shell=True,
cwd=project_root,
capture_output=True,
timeout=120,
)
if result.returncode != 0:
stderr = result.stderr.decode("utf-8", errors="replace")
stdout = result.stdout.decode("utf-8", errors="replace")
error_output = stderr or stdout
if len(error_output) > 500:
error_output = error_output[:500] + "..."
return False, f"Command failed: {cmd}\n{error_output}"
except subprocess.TimeoutExpired:
return False, f"Command timed out: {cmd}"
except Exception as e:
return False, f"Command error: {cmd} - {str(e)}"
return True, "All verify commands passed"
def load_state(project_root: str) -> dict:
"""Load verify loop state."""
state_path = os.path.join(project_root, STATE_FILE)
if not os.path.exists(state_path):
return {"task": None, "iteration": 0, "started_at": None}
try:
with open(state_path, "r", encoding="utf-8") as f:
return json.load(f)
except Exception:
return {"task": None, "iteration": 0, "started_at": None}
def save_state(project_root: str, state: dict) -> None:
"""Save verify loop state."""
state_path = os.path.join(project_root, STATE_FILE)
try:
os.makedirs(os.path.dirname(state_path), exist_ok=True)
with open(state_path, "w", encoding="utf-8") as f:
json.dump(state, f, indent=2, ensure_ascii=False)
except Exception:
pass
def main():
try:
input_data = json.load(sys.stdin)
except json.JSONDecodeError:
sys.exit(0)
hook_event = input_data.get("hook_event_name", "")
if hook_event != "SubagentStop":
sys.exit(0)
subagent_type = input_data.get("subagent_type", "")
agent_output = input_data.get("agent_output", "")
cwd = input_data.get("cwd", os.getcwd())
if subagent_type not in TARGET_AGENTS:
sys.exit(0)
project_root = get_project_root(cwd)
if not project_root:
sys.exit(0)
task_dir = get_current_task(project_root)
if not task_dir:
sys.exit(0)
task_info = get_task_info(project_root, task_dir)
if not task_info:
sys.exit(0)
verify_commands = get_verify_commands(task_info)
if not verify_commands:
# No verify commands configured, allow exit
sys.exit(0)
# Load state
state = load_state(project_root)
# Reset state if task changed or too old
should_reset = False
if state.get("task") != task_dir:
should_reset = True
elif state.get("started_at"):
try:
started = datetime.fromisoformat(state["started_at"])
if (datetime.now() - started).total_seconds() > STATE_TIMEOUT_MINUTES * 60:
should_reset = True
except (ValueError, TypeError):
should_reset = True
if should_reset:
state = {
"task": task_dir,
"iteration": 0,
"started_at": datetime.now().isoformat(),
}
# Increment iteration
state["iteration"] = state.get("iteration", 0) + 1
current_iteration = state["iteration"]
save_state(project_root, state)
# Safety check: max iterations
if current_iteration >= MAX_ITERATIONS:
state["iteration"] = 0
save_state(project_root, state)
output = {
"decision": "allow",
"reason": f"Max iterations ({MAX_ITERATIONS}) reached. Stopping to prevent infinite loop.",
}
print(json.dumps(output, ensure_ascii=False))
sys.exit(0)
# Run verify commands
passed, message = run_verify_commands(project_root, verify_commands)
if passed:
state["iteration"] = 0
save_state(project_root, state)
output = {
"decision": "allow",
"reason": "All verify commands passed. Review phase complete.",
}
print(json.dumps(output, ensure_ascii=False))
sys.exit(0)
else:
output = {
"decision": "block",
"reason": f"Iteration {current_iteration}/{MAX_ITERATIONS}. Verification failed:\n{message}\n\nPlease fix the issues and try again.",
}
print(json.dumps(output, ensure_ascii=False))
sys.exit(0)
if __name__ == "__main__":
main()