diff --git a/.claude/agents/architect.md b/.claude/agents/architect.md new file mode 100644 index 0000000..5a06fac --- /dev/null +++ b/.claude/agents/architect.md @@ -0,0 +1,14 @@ +--- +name: architect +description: 负责 doc-plan 和 review 阶段,编写实现计划、维护 scope,并检查实现是否符合需求确认结果。 +tools: Read, Write, Edit, Bash, Glob, Grep, WebSearch, WebFetch +--- +# Architect Agent + +读取 `clarification.md`、`implementation-plan.md`、`docs/standards/index.md` 和 `context.architect.jsonl` 中明确引用的文件。 + +在 `doc-plan` 阶段,编写 `implementation-plan.md` 和 `scope.json`。计划文件只能保存实现计划,必须包含固定章节:开发意图摘要、影响范围、技术方案、可测试契约、业务契约覆盖、Slice 顺序、验证方式、已知限制。 + +在 `review` 阶段,检查当前变更是否符合需求确认、实现计划、业务契约和团队规范,并通过 `task.py review record` 写入 `review-result.json`。需要修正代码时保持测试通过。业务契约未覆盖时,使用 `--business-contract-status failed` 和 `--missing-contract <契约编号>` 记录。 + +禁止手工编辑受控文件:`task.json`、`clarification.jsonl`、`clarification.md`、`test-result.red.json`、`test-result.green.json`、`review-result.json`、`verify-result.json`。 diff --git a/.claude/agents/developer.md b/.claude/agents/developer.md new file mode 100644 index 0000000..f01fb2d --- /dev/null +++ b/.claude/agents/developer.md @@ -0,0 +1,14 @@ +--- +name: developer +description: 负责 green 阶段,根据 RED 测试实现最小代码变更。 +tools: Read, Write, Edit, Bash, Glob, Grep +--- +# Developer Agent + +读取 `clarification.md`、`implementation-plan.md`、`docs/standards/index.md` 和 `context.developer.jsonl` 中明确引用的文件。 + +在 `green` 阶段,先确认 `test-result.red.json` 已经记录目标测试的预期失败,再实现代码使同一组目标测试通过。通过后使用 `verify.py green` 写入 `test-result.green.json`。实现代码需要满足 `implementation-plan.md` 中的业务契约覆盖要求;测试暂未覆盖但计划明确要求的业务契约,也需要在实现报告中说明对应代码位置。 + +实现必须遵守 `scope.json` 的变更范围。发现需求、计划或测试之间存在冲突时,停止实现并返回主会话处理。 + +禁止执行 git commit,禁止手工编辑 harness 受控文件。 diff --git a/.claude/agents/tester.md b/.claude/agents/tester.md new file mode 100644 index 0000000..bf5e238 --- /dev/null +++ b/.claude/agents/tester.md @@ -0,0 +1,14 @@ +--- +name: tester +description: 负责 red 和 validate 阶段,编写失败测试、补充边界测试并生成验证证据。 +tools: Read, Write, Edit, Bash, Glob, Grep +--- +# Tester Agent + +读取 `clarification.md`、`implementation-plan.md`、`docs/standards/index.md` 和 `context.tester.jsonl` 中明确引用的文件。 + +在 `red` 阶段,根据可测试契约和业务契约覆盖要求编写目标测试,并使用 `verify.py red` 写入 `test-result.red.json`。该阶段要求目标测试出现预期失败。测试证据需要通过 `--contract-coverage BC-001=TestName` 记录业务契约与测试的映射;暂时无法测试的契约使用 `--uncovered-contract BC-001` 记录。 + +在 `validate` 阶段,补充边界测试并运行必要验证,最终由主会话运行 `verify.py all` 写入 `verify-result.json`。 + +禁止执行 git commit,禁止手工编辑 harness 受控文件。 diff --git a/.claude/commands/harness/continue.md b/.claude/commands/harness/continue.md new file mode 100644 index 0000000..e19dcf5 --- /dev/null +++ b/.claude/commands/harness/continue.md @@ -0,0 +1,3 @@ +查看当前需求开发状态。 + +读取 `.harness/runtime/sessions/` 中的当前任务指针,展示 `docs/tasks//task.json` 的 `status`、`phase` 和下一阶段需要的证据文件。 diff --git a/.claude/commands/harness/finish.md b/.claude/commands/harness/finish.md new file mode 100644 index 0000000..a4fd913 --- /dev/null +++ b/.claude/commands/harness/finish.md @@ -0,0 +1,3 @@ +归档当前需求开发任务。 + +任务必须先进入 `phase=done`,并且 `verify-result.json.success=true`。归档后任务移动到 `docs/tasks/archive/YYYY-MM//`。 diff --git a/.claude/hooks/harness-inject-context.py b/.claude/hooks/harness-inject-context.py new file mode 100644 index 0000000..a438c17 --- /dev/null +++ b/.claude/hooks/harness-inject-context.py @@ -0,0 +1,386 @@ +#!/usr/bin/env python3 +"""PreToolUse hook: inject phase-safe role context.""" + +from __future__ import annotations + +import json +import os +import sys +import fnmatch +from pathlib import Path + +LOCAL_CONTEXT_KEY = "local" +KNOWN_ROLES = ("architect", "developer", "tester") +PHASE_ROLE = { + "doc-plan": "architect", + "red": "tester", + "green": "developer", + "review": "architect", + "validate": "tester", +} +ROLE_TOOLS = ("Task", "Agent", "TaskCreate", "TeamCreate", "spawn_agent", "followup_task") +EDIT_TOOLS = ("Write", "Edit", "MultiEdit") +TEST_PATH_PARTS = ("/test/", "/tests/", "_test.", ".test.", ".spec.") +CODE_EXTENSIONS = ( + ".go", + ".py", + ".js", + ".jsx", + ".ts", + ".tsx", + ".java", + ".kt", + ".rs", + ".c", + ".cc", + ".cpp", + ".h", + ".hpp", + ".cs", + ".php", + ".rb", + ".swift", +) +CONTROLLED_SUFFIXES = ( + "task.json", + "clarification.jsonl", + "clarification.md", + "test-result.red.json", + "test-result.green.json", + "review-result.json", + "verify-result.json", +) +DOC_PLAN_TASK_FILES = ( + "implementation-plan.md", + "scope.json", + "context.architect.jsonl", + "context.developer.jsonl", + "context.tester.jsonl", +) + + +def find_project_root(start: Path) -> Path | None: + cur = start.resolve() + while cur != cur.parent: + if (cur / ".harness").is_dir(): + return cur + cur = cur.parent + return None + + +def resolve_session_key(data: dict) -> str | None: + for key in ("session_id", "sessionId"): + value = data.get(key) + if isinstance(value, str) and value.strip(): + return value.strip() + return os.environ.get("HARNESS_CONTEXT_ID") + + +def task_dir_from_ref(root: Path, task_ref: str) -> Path | None: + task_dir = root / task_ref + return task_dir if task_dir.is_dir() else None + + +def get_active_task_dir(root: Path, data: dict) -> Path | None: + sessions_dir = root / ".harness" / "runtime" / "sessions" + key = resolve_session_key(data) + candidates = [] + if key: + candidates.append(sessions_dir / f"{key}.json") + candidates.append(sessions_dir / f"{LOCAL_CONTEXT_KEY}.json") + for path in candidates: + if not path.is_file(): + continue + session = json.loads(path.read_text(encoding="utf-8")) + task_ref = session.get("current_task") + if task_ref: + task_dir = task_dir_from_ref(root, task_ref) + if task_dir: + return task_dir + return unique_in_progress_task_dir(root) + + +def unique_in_progress_task_dir(root: Path) -> Path | None: + tasks_dir = root / "docs" / "tasks" + if not tasks_dir.is_dir(): + return None + matches = [] + for task_dir in tasks_dir.iterdir(): + if not task_dir.is_dir() or task_dir.name == "archive": + continue + task_json = task_dir / "task.json" + if not task_json.is_file(): + continue + data = json.loads(task_json.read_text(encoding="utf-8")) + if data.get("status") == "in_progress": + matches.append(task_dir) + return matches[0] if len(matches) == 1 else None + + +def read_file_safe(path: Path) -> str: + try: + return path.read_text(encoding="utf-8") + except (FileNotFoundError, PermissionError): + return "" + + +def read_jsonl_context(root: Path, jsonl_path: Path) -> list[tuple[str, str]]: + if not jsonl_path.is_file(): + return [] + results = [] + for line in jsonl_path.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line: + continue + try: + item = json.loads(line) + except json.JSONDecodeError: + continue + if "_example" in item: + continue + file_path = item.get("file") + if not isinstance(file_path, str) or not file_path: + continue + content = read_file_safe(root / file_path) + if content: + results.append((file_path, content)) + return results + + +def task_phase(task_dir: Path) -> str: + data = json.loads((task_dir / "task.json").read_text(encoding="utf-8")) + return data.get("phase", "unknown") + + +def build_role_context(root: Path, task_dir: Path, role: str) -> str: + parts = [] + + standards = read_file_safe(root / "docs" / "standards" / "index.md") + if standards: + parts.append(f"=== docs/standards/index.md ===\n{standards}") + + clarification = read_file_safe(task_dir / "clarification.md") + if clarification: + parts.append(f"=== clarification.md ===\n{clarification}") + + plan = read_file_safe(task_dir / "implementation-plan.md") + if plan: + parts.append(f"=== implementation-plan.md ===\n{plan}") + + for file_path, content in read_jsonl_context(root, task_dir / f"context.{role}.jsonl"): + parts.append(f"=== {file_path} ===\n{content}") + + return "\n\n".join(parts) + + +def infer_role(tool_input: dict) -> str: + direct = tool_input.get("subagent_type") or tool_input.get("subagentType") or tool_input.get("role") or "" + if direct in KNOWN_ROLES: + return direct + for key in ("task_name", "name", "target"): + value = tool_input.get(key) + if not isinstance(value, str): + continue + lowered = value.lower() + for role in KNOWN_ROLES: + if role in lowered: + return role + return "" + + +def prompt_field(tool_input: dict) -> str: + if "prompt" in tool_input: + return "prompt" + if "message" in tool_input: + return "message" + return "prompt" + + +def normalize_target_path(root: Path, value: str) -> str: + path = Path(value) + if path.is_absolute(): + try: + return path.resolve().relative_to(root).as_posix() + except ValueError: + return path.as_posix() + return path.as_posix() + + +def edit_target(tool_input: dict) -> str | None: + for key in ("file_path", "path", "target_file"): + value = tool_input.get(key) + if isinstance(value, str) and value.strip(): + return value.strip() + return None + + +def controlled_edit_target(tool_input: dict) -> str | None: + target = edit_target(tool_input) + if target: + normalized = target.replace("\\", "/") + if "/docs/tasks/" in normalized or normalized.startswith("docs/tasks/"): + if any(normalized.endswith(suffix) for suffix in CONTROLLED_SUFFIXES): + return target + return None + + +def is_test_path(path: str) -> bool: + normalized_path = path.replace("\\", "/") + normalized = f"/{normalized_path}" + return any(part in normalized for part in TEST_PATH_PARTS) + + +def is_code_path(path: str) -> bool: + return Path(path).suffix in CODE_EXTENSIONS + + +def matches_pattern(path: str, pattern: str) -> bool: + normalized = pattern.strip() + if normalized.endswith("/"): + normalized = f"{normalized}**" + plain = normalized.rstrip("/") + if not any(ch in normalized for ch in "*?[]"): + return path == plain or path.startswith(f"{plain}/") + return fnmatch.fnmatchcase(path, normalized) + + +def matches_any(path: str, patterns: list[str]) -> bool: + return any(matches_pattern(path, pattern) for pattern in patterns) + + +def scope_allowed(task_dir: Path) -> list[str]: + path = task_dir / "scope.json" + if not path.is_file(): + return [] + try: + data = json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError: + return [] + allowed = data.get("allowed") + if not isinstance(allowed, list): + return [] + return [item for item in allowed if isinstance(item, str) and item.strip()] + + +def is_doc_plan_artifact(path: str, task_dir: Path, root: Path) -> bool: + try: + rel_task = task_dir.relative_to(root).as_posix() + except ValueError: + return False + if not path.startswith(f"{rel_task}/"): + return False + name = path.removeprefix(f"{rel_task}/") + return name in DOC_PLAN_TASK_FILES + + +def phase_edit_violation(root: Path, task_dir: Path | None, phase: str | None, target: str) -> str | None: + rel = normalize_target_path(root, target) + if task_dir is None: + if is_code_path(rel): + return f"没有 active task,禁止修改业务代码 {rel}。请先进入 requirement-confirmation 完成需求确认。" + return None + if phase == "clarify": + if is_code_path(rel): + return f"当前阶段 clarify 禁止修改业务代码 {rel}。请先完成需求确认并推进到 doc-plan。" + return None + if phase == "doc-plan": + if is_doc_plan_artifact(rel, task_dir, root): + return None + if is_code_path(rel) or is_test_path(rel): + return f"当前阶段 doc-plan 禁止修改业务代码 {rel}。只能编写 implementation-plan.md、scope.json 和 context.*.jsonl。" + return None + if phase in ("red", "validate"): + if is_code_path(rel) and not is_test_path(rel): + return f"当前阶段 {phase} 禁止修改业务实现文件 {rel}。该阶段只允许测试相关变更。" + return None + if phase == "green": + if is_test_path(rel): + return f"当前阶段 green 禁止修改测试文件 {rel}。请回到 red 或 validate 阶段处理测试。" + allowed = scope_allowed(task_dir) + if allowed and not matches_any(rel, allowed): + return f"文件 {rel} 不在 scope.json.allowed 范围内,禁止修改。" + if phase == "review": + allowed = scope_allowed(task_dir) + if allowed and not matches_any(rel, allowed): + return f"文件 {rel} 不在 scope.json.allowed 范围内,禁止修改。" + return None + + +def emit_block(reason: str) -> int: + output = { + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "deny", + "additionalContext": reason, + } + } + print(json.dumps(output, ensure_ascii=False)) + return 0 + + +def main() -> int: + try: + data = json.load(sys.stdin) + except (json.JSONDecodeError, ValueError): + return 0 + + tool_input = data.get("tool_input", {}) + tool_name = data.get("tool_name", "") + target = controlled_edit_target(tool_input) + if target: + return emit_block(f"受控文件 {target} 只能通过 harness 内部工具生成,禁止手工编辑。") + + root = find_project_root(Path(data.get("cwd") or ".")) + if root is None: + return 0 + task_dir = get_active_task_dir(root, data) + phase = task_phase(task_dir) if task_dir else None + + if tool_name in EDIT_TOOLS: + target = edit_target(tool_input) + if target: + violation = phase_edit_violation(root, task_dir, phase, target) + if violation: + return emit_block(violation) + return 0 + + if tool_name in ROLE_TOOLS and task_dir is None: + return emit_block( + "没有 active task,禁止启动开发子任务。请先使用 requirement-confirmation 完成需求确认," + "确认后再使用 python3 .harness/scripts/task.py create \"<任务名>\" 创建任务。" + ) + + role = infer_role(tool_input) + if role not in KNOWN_ROLES: + if tool_name in ROLE_TOOLS and phase in PHASE_ROLE: + expected = PHASE_ROLE[phase] + return emit_block(f"当前阶段 {phase} 必须调用 {expected},禁止使用未声明角色的子任务绕过 harness。") + return 0 + + if task_dir is None: + return 0 + + expected = PHASE_ROLE.get(phase) + if expected != role: + return emit_block(f"当前阶段 {phase} 不允许调用 {role}。应执行的角色职责是 {expected or '无开发角色'}。") + + context = build_role_context(root, task_dir, role) + if not context: + return 0 + + field = prompt_field(tool_input) + original = tool_input.get(field, "") + updated = {**tool_input, field: f"## Injected Context\n\n{context}\n\n---\n\n## Task\n\n{original}"} + output = { + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "allow", + "updatedInput": updated, + } + } + print(json.dumps(output, ensure_ascii=False)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.claude/hooks/harness-session-start.py b/.claude/hooks/harness-session-start.py new file mode 100644 index 0000000..13db647 --- /dev/null +++ b/.claude/hooks/harness-session-start.py @@ -0,0 +1,164 @@ +#!/usr/bin/env python3 +"""SessionStart hook: inject current harness task summary.""" + +from __future__ import annotations + +import json +import os +import shlex +import sys +from pathlib import Path + +LOCAL_CONTEXT_KEY = "local" +PHASE_ROLE = { + "doc-plan": "architect", + "red": "tester", + "green": "developer", + "review": "architect", + "validate": "tester", +} + + +def find_project_root(start: Path) -> Path | None: + cur = start.resolve() + while cur != cur.parent: + if (cur / ".harness").is_dir(): + return cur + cur = cur.parent + return None + + +def resolve_session_key(data: dict) -> str | None: + for key in ("session_id", "sessionId"): + value = data.get(key) + if isinstance(value, str) and value.strip(): + return value.strip() + return os.environ.get("HARNESS_CONTEXT_ID") + + +def export_context_id_to_env_file(context_key: str | None) -> None: + if not context_key: + return + env_file = os.environ.get("CLAUDE_ENV_FILE") + if not env_file: + return + try: + with open(env_file, "a", encoding="utf-8") as fh: + fh.write(f"export HARNESS_CONTEXT_ID={shlex.quote(context_key)}\n") + except OSError: + pass + + +def task_info_from_ref(root: Path, task_ref: str) -> dict | None: + task_dir = root / task_ref + if not task_dir.is_dir(): + return None + task_json = task_dir / "task.json" + if not task_json.is_file(): + return {"title": task_dir.name, "path": task_ref, "status": "unknown", "phase": "unknown"} + data = json.loads(task_json.read_text(encoding="utf-8")) + return { + "title": data.get("title", task_dir.name), + "path": task_ref, + "status": data.get("status", "unknown"), + "phase": data.get("phase", "unknown"), + "executionMode": data.get("executionMode", "unknown"), + } + + +def get_active_task(root: Path, data: dict) -> dict | None: + sessions_dir = root / ".harness" / "runtime" / "sessions" + key = resolve_session_key(data) + candidates = [] + if key: + candidates.append(sessions_dir / f"{key}.json") + candidates.append(sessions_dir / f"{LOCAL_CONTEXT_KEY}.json") + for path in candidates: + if not path.is_file(): + continue + session = json.loads(path.read_text(encoding="utf-8")) + task_ref = session.get("current_task") + if task_ref: + task = task_info_from_ref(root, task_ref) + if task: + return task + return unique_in_progress_task(root) + + +def unique_in_progress_task(root: Path) -> dict | None: + tasks_dir = root / "docs" / "tasks" + if not tasks_dir.is_dir(): + return None + refs = [] + for task_dir in tasks_dir.iterdir(): + if not task_dir.is_dir() or task_dir.name == "archive": + continue + task_json = task_dir / "task.json" + if not task_json.is_file(): + continue + data = json.loads(task_json.read_text(encoding="utf-8")) + if data.get("status") == "in_progress": + refs.append(f"docs/tasks/{task_dir.name}") + return task_info_from_ref(root, refs[0]) if len(refs) == 1 else None + + +def build_context(task: dict | None) -> str: + parts = [] + if task: + required_role = PHASE_ROLE.get(task["phase"]) + role_line = f"\nRequired subagent: {required_role}" if required_role else "" + parts.append( + f"Active task: {task['title']} ({task['status']})\n" + f"Phase: {task['phase']}\n" + f"Execution mode: {task['executionMode']}\n" + f"Path: {task['path']}\n" + "Required skill: requirement-development" + f"{role_line}\n" + "继续当前任务时必须使用需求开发 skill,并通过必选子代理推进阶段。" + ) + else: + parts.append( + "No active task.\n" + "Required skill: requirement-confirmation\n" + "新需求、实现请求、模块规划和按文档开发请求,都必须先完成需求确认。" + ) + + parts.append( + "\nNatural language entries:\n" + "- 按 design.md 开发: use requirement-confirmation first\n" + "- 继续需求开发: use requirement-development after confirmed clarification\n" + "- 查看当前需求开发状态\n" + "- 归档当前任务" + ) + parts.append( + "\nHarness roles:\n" + "- requirement-confirmation: confirm intent, acceptance criteria, and boundaries\n" + "- requirement-development: orchestrate phase progression\n" + "- architect: doc-plan and review\n" + "- tester: RED and validate\n" + "- developer: GREEN implementation" + ) + return "\n".join(parts) + + +def main() -> int: + try: + data = json.load(sys.stdin) + except (json.JSONDecodeError, ValueError): + data = {} + root = find_project_root(Path(data.get("cwd") or ".")) + if root is None: + return 0 + export_context_id_to_env_file(resolve_session_key(data)) + output = { + "hookSpecificOutput": { + "hookEventName": "SessionStart", + "additionalContext": build_context(get_active_task(root, data)), + } + } + print(json.dumps(output, ensure_ascii=False)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.claude/hooks/harness-workflow-state.py b/.claude/hooks/harness-workflow-state.py new file mode 100644 index 0000000..2784b6a --- /dev/null +++ b/.claude/hooks/harness-workflow-state.py @@ -0,0 +1,157 @@ +#!/usr/bin/env python3 +"""UserPromptSubmit hook: emit phase-aware workflow breadcrumb.""" + +from __future__ import annotations + +import json +import os +import re +import sys +from pathlib import Path + +LOCAL_CONTEXT_KEY = "local" +PHASE_ROLE = { + "doc-plan": "architect", + "red": "tester", + "green": "developer", + "review": "architect", + "validate": "tester", +} +TAG_RE = re.compile( + r"\[workflow-phase:([A-Za-z0-9_-]+)\]\s*\n(.*?)\n\s*\[/workflow-phase:\1\]", + re.DOTALL, +) + + +def find_project_root(start: Path) -> Path | None: + cur = start.resolve() + while cur != cur.parent: + if (cur / ".harness").is_dir(): + return cur + cur = cur.parent + return None + + +def load_breadcrumbs(root: Path) -> dict[str, str]: + workflow = root / ".harness" / "workflow.md" + if not workflow.is_file(): + return {} + content = workflow.read_text(encoding="utf-8") + return {m.group(1): m.group(2).strip() for m in TAG_RE.finditer(content)} + + +def resolve_session_key(data: dict) -> str | None: + for key in ("session_id", "sessionId"): + value = data.get(key) + if isinstance(value, str) and value.strip(): + return value.strip() + env_key = os.environ.get("HARNESS_CONTEXT_ID") + return env_key or None + + +def task_info_from_ref(root: Path, task_ref: str) -> dict | None: + task_dir = root / task_ref + if not task_dir.is_dir(): + return None + task_json = task_dir / "task.json" + if not task_json.is_file(): + return {"path": task_ref, "status": "unknown", "phase": "unknown"} + data = json.loads(task_json.read_text(encoding="utf-8")) + return { + "path": task_ref, + "title": data.get("title", task_dir.name), + "status": data.get("status", "unknown"), + "phase": data.get("phase", data.get("status", "unknown")), + } + + +def get_active_task(root: Path, data: dict) -> dict | None: + sessions_dir = root / ".harness" / "runtime" / "sessions" + key = resolve_session_key(data) + candidates = [] + if key: + candidates.append(sessions_dir / f"{key}.json") + candidates.append(sessions_dir / f"{LOCAL_CONTEXT_KEY}.json") + + for path in candidates: + if not path.is_file(): + continue + session = json.loads(path.read_text(encoding="utf-8")) + task_ref = session.get("current_task") + if task_ref: + task_info = task_info_from_ref(root, task_ref) + if task_info: + return task_info + + files = list(sessions_dir.glob("*.json")) if sessions_dir.is_dir() else [] + if len(files) == 1: + session = json.loads(files[0].read_text(encoding="utf-8")) + task_ref = session.get("current_task") + if task_ref: + return task_info_from_ref(root, task_ref) + return unique_in_progress_task(root) + + +def unique_in_progress_task(root: Path) -> dict | None: + tasks_dir = root / "docs" / "tasks" + if not tasks_dir.is_dir(): + return None + matches = [] + for task_dir in tasks_dir.iterdir(): + if not task_dir.is_dir() or task_dir.name == "archive": + continue + task_json = task_dir / "task.json" + if not task_json.is_file(): + continue + data = json.loads(task_json.read_text(encoding="utf-8")) + if data.get("status") == "in_progress": + matches.append(f"docs/tasks/{task_dir.name}") + return task_info_from_ref(root, matches[0]) if len(matches) == 1 else None + + +def build_breadcrumb(task: dict | None, body: str) -> str: + if task: + required_role = PHASE_ROLE.get(task["phase"]) + role_line = f"\nRequired subagent: {required_role}" if required_role else "" + header = ( + f"Task: {task['path']} ({task['status']})\n" + f"Phase: {task['phase']}\n" + "Required skill: requirement-development" + f"{role_line}\n" + "继续当前任务时必须使用需求开发 skill,并通过必选子代理推进阶段。" + ) + else: + header = ( + "Status: no_task\n" + "Phase: no_task\n" + "Required skill: requirement-confirmation\n" + "新需求、实现请求、模块规划和按文档开发请求,都必须先完成需求确认。" + ) + return f"\n{header}\n{body}\n" + + +def main() -> int: + try: + data = json.load(sys.stdin) + except (json.JSONDecodeError, ValueError): + data = {} + root = find_project_root(Path(data.get("cwd") or ".")) + if root is None: + return 0 + + breadcrumbs = load_breadcrumbs(root) + task = get_active_task(root, data) + phase = task["phase"] if task else "no_task" + body = breadcrumbs.get(phase, "Refer to workflow.md for current phase.") + output = { + "hookSpecificOutput": { + "hookEventName": "UserPromptSubmit", + "additionalContext": build_breadcrumb(task, body), + } + } + print(json.dumps(output, ensure_ascii=False)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..8fd8bd4 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,89 @@ +{ + "hooks": { + "SessionStart": [ + { + "matcher": "startup", + "hooks": [ + { + "type": "command", + "command": "python3 .claude/hooks/harness-session-start.py", + "timeout": 10 + } + ] + } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "python3 .claude/hooks/harness-workflow-state.py", + "timeout": 5 + } + ] + } + ], + "PreToolUse": [ + { + "matcher": "Task", + "hooks": [ + { + "type": "command", + "command": "python3 .claude/hooks/harness-inject-context.py", + "timeout": 30 + } + ] + }, + { + "matcher": "Agent", + "hooks": [ + { + "type": "command", + "command": "python3 .claude/hooks/harness-inject-context.py", + "timeout": 30 + } + ] + }, + { + "matcher": "Write", + "hooks": [ + { + "type": "command", + "command": "python3 .claude/hooks/harness-inject-context.py", + "timeout": 30 + } + ] + }, + { + "matcher": "Edit", + "hooks": [ + { + "type": "command", + "command": "python3 .claude/hooks/harness-inject-context.py", + "timeout": 30 + } + ] + }, + { + "matcher": "MultiEdit", + "hooks": [ + { + "type": "command", + "command": "python3 .claude/hooks/harness-inject-context.py", + "timeout": 30 + } + ] + }, + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "python3 .claude/hooks/harness-inject-context.py", + "timeout": 30 + } + ] + } + ] + } +} diff --git a/.claude/skills/grill-me/SKILL.md b/.claude/skills/grill-me/SKILL.md new file mode 100644 index 0000000..29ff80b --- /dev/null +++ b/.claude/skills/grill-me/SKILL.md @@ -0,0 +1,12 @@ +--- +name: grill-me +description: 兼容入口。旧触发语 "grill me" 会转入 requirement-confirmation,也就是需求确认 skill。 +--- + +# Grill Me Compatibility + + + +`grill-me` 是旧名称。当前正式入口是 `requirement-confirmation`,中文名称为需求确认 skill。 + +继续保持每次只问一个问题,每个问题给出推荐回答,并以 `clarification.jsonl` 作为需求确认门禁依据。 diff --git a/.claude/skills/harness-configure-verify/SKILL.md b/.claude/skills/harness-configure-verify/SKILL.md new file mode 100644 index 0000000..b16f946 --- /dev/null +++ b/.claude/skills/harness-configure-verify/SKILL.md @@ -0,0 +1,69 @@ +--- +name: harness-configure-verify +description: 当 harness 项目需要配置 .harness/verify.json,或者需要设置提交前 lint、类型检查、测试和覆盖率检查时使用。 +--- + +# Harness Configure Verify + + + +为当前项目配置 `.harness/verify.json`。目标是让 +`python3 .harness/scripts/verify.py all` 在每个 harness slice 提交前真正执行 +lint、类型检查、测试、覆盖率和文件变更范围检查。 + +该 skill 同时供 Claude Code 和 Codex agents 使用。 + +## 必须执行的流程 + +1. 读取当前 `.harness/verify.json`。 +2. 检查项目入口文件:`Makefile`、`go.mod`、`package.json`、 + `pyproject.toml`、`Cargo.toml`、`README.md` 和 `scripts/`。 +3. 识别不会修改文件的检查命令: + - `commands.lint` + - `commands.type` + - `commands.test` + - `commands.coverage` +4. 先给出推荐 JSON patch,等待确认后再写入文件。 +5. 确认后更新 `.harness/verify.json`。 +6. 条件允许时运行重点验证命令,然后报告结果。 + +## 命令规则 + +1. 优先选择只检查的命令,避免会重写文件的命令。 +2. 避免使用带 `-w`、`--write` 或同类修改参数的 formatter 脚本。 +3. 覆盖率阈值由项目自己的 coverage 命令负责,harness 只检查命令退出码。 +4. 如果无法从项目文件推断命令,说明不确定的字段,并给出最保守的占位命令。 + +## 常见示例 + +Go 项目常用: + +```json +{ + "commands": { + "lint": "test -z \"$(gofmt -l .)\" && go vet ./...", + "type": "go test -run '^$' ./...", + "test": "go test ./...", + "coverage": "go test ./... -coverprofile=.harness/runtime/coverage.out" + }, + "scope": { + "denied": [".harness/runtime/**", "output/**", "log/**"] + } +} +``` + +Node 项目常用 package scripts: + +```json +{ + "commands": { + "lint": "npm run lint", + "type": "npm run typecheck", + "test": "npm test", + "coverage": "npm run coverage" + }, + "scope": { + "denied": [".harness/runtime/**", "dist/**", "coverage/**"] + } +} +``` diff --git a/.claude/skills/harness-implement/SKILL.md b/.claude/skills/harness-implement/SKILL.md new file mode 100644 index 0000000..9ef0b37 --- /dev/null +++ b/.claude/skills/harness-implement/SKILL.md @@ -0,0 +1,13 @@ +--- +name: harness-implement +description: | + 兼容入口。旧触发语 "harness-implement"、"按 design.md 开发"、"implement design.md" 会转入 requirement-development。 +--- + +# Harness Implement Compatibility + + + +`harness-implement` 是旧名称。当前正式入口是 `requirement-development`,中文名称为需求开发 skill。 + +收到实现类请求时,按照 `requirement-development` 执行,并先转入 `requirement-confirmation` 完成需求确认。 diff --git a/.claude/skills/project-doc-scanner/SKILL.md b/.claude/skills/project-doc-scanner/SKILL.md new file mode 100644 index 0000000..146e97e --- /dev/null +++ b/.claude/skills/project-doc-scanner/SKILL.md @@ -0,0 +1,84 @@ +--- +name: project-doc-scanner +description: | + 当协作者要求扫描当前项目、初始化项目文档、生成接口文档、更新 docs/standards 下项目知识文档时使用。 + 首版面向 Claude Code 使用。 +--- + +# Project Doc Scanner + + + +该 skill 用于把当前项目扫描结果整理成支持 AI 自动化开发的长期项目文档。文档会放在 `docs/standards/`,负责人可以 review 文档是否准确。 + +## 核心规则 + +固定采用“先检测,再确认,再扫描”。 + +1. 先运行 `python3 .harness/scripts/project.py docs status --json` 检查当前项目文档状态。 +2. 如果 `.harness/project-docs.json` 或 `.harness/project-profile.json` 缺失,先询问是否执行初始化。 +3. 初始化命令为 `python3 .harness/scripts/project.py docs init-config`。 +4. 如果已有 `approved` 文档,覆盖前必须先得到明确确认,并保留备份。 +5. 扫描时优先使用 Claude Code 子代理读取不同代码区域,避免主会话窗口被大量代码细节污染。 +6. 生成文档时,每个关键判断都要写明“判断、证据、置信度、待确认项”。 + +## 默认产物 + +| 文件 | 用途 | +| --- | --- | +| `.harness/project-docs.json` | 项目文档初始化配置 | +| `.harness/project-profile.json` | 文档审阅状态、哈希和负责人确认记录 | +| `docs/standards/project-guide.md` | 项目架构、模块职责、启动方式和开发入口 | +| `docs/standards/api/url-index.md` | 接口地址索引 | +| `docs/standards/api/detail.md` | 接口请求、响应、鉴权、错误码和业务约束 | +| `.harness/analysis/latest/*.json` | 子代理扫描中间结果 | + +## 操作顺序 + +### 1. 检测状态 + +```bash +python3 .harness/scripts/project.py docs status --json +``` + +根据输出判断文档是否缺失、草稿、已确认、需要更新或过期。 + +### 2. 初始化配置 + +在得到确认后运行: + +```bash +python3 .harness/scripts/project.py docs init-config +``` + +该命令只创建配置、状态文件、`docs/standards/api/` 目录,并维护 `docs/index.md` 和 `docs/standards/index.md` 中的 harness 管理区块。 + +### 3. 子代理扫描 + +根据项目类型拆分扫描任务。常见拆分方式: + +| 子代理 | 扫描范围 | +| --- | --- | +| 架构扫描 | 入口文件、模块目录、依赖方向、启动方式 | +| 接口扫描 | 路由、控制器、RPC 定义、事件入口 | +| 质量扫描 | 测试命令、构建命令、配置文件、常见变更约束 | + +子代理只返回结构化摘要和证据文件位置,主会话负责汇总成文档。 + +### 4. 文档生成与确认 + +文档生成后先保持 `draft` 状态。负责人确认准确后运行: + +```bash +python3 .harness/scripts/project.py docs approve --all --approved-by "" +``` + +审批后 `.harness/project-profile.json` 会记录文档哈希。后续文档内容变化会显示为 `stale`。 + +## 生成要求 + +1. 接口文档必须覆盖入口地址、代码位置、请求字段、响应字段、鉴权方式、错误情况和待确认项。 +2. 项目说明必须覆盖目录结构、核心模块、启动方式、测试方式、常见开发入口和重要约束。 +3. 对无法确认的内容,写入待确认项,禁止编造成确定事实。 +4. 已确认文档默认不能覆盖。只有在协作者明确同意覆盖后,才生成新版本。 +5. 临时扫描结果默认只保留 `.harness/analysis/latest/`,配置 `keepHistory=true` 时再保存历史。 diff --git a/.claude/skills/requirement-confirmation/SKILL.md b/.claude/skills/requirement-confirmation/SKILL.md new file mode 100644 index 0000000..021dd44 --- /dev/null +++ b/.claude/skills/requirement-confirmation/SKILL.md @@ -0,0 +1,43 @@ +--- +name: requirement-confirmation +description: | + 需求确认 skill。用于在需求开发前逐项确认开发意图、验收标准、边界条件、依赖关系和未决问题。 + 初始实现请求也必须先使用本 skill,例如 "按 design.md 开发"、"按照需求开发"、"参考 docs 进行业务逻辑开发"、"进行模块规划"。 + 触发语包括 "需求确认"、"确认需求"、"grill me"、"先问清楚需求"、"需求还要再确认"。 +--- + +# 需求确认 + + + +该 skill 是 harness 需求开发前置环节。目标是让开发意图、验收标准、边界条件和关键依赖形成可核验记录,避免模型根据模糊描述自行补全。 + +## 必须遵守 + +每次只提出一个问题。 + +每个问题都给出推荐回答,推荐回答应当基于已知需求文档和代码检查结果。 + +能够通过检查仓库回答的问题,先检查仓库,再继续提问。 + +即使 `design.md`、`spec.md` 或 `requirements.md` 内容完整,也必须先复述开发意图,并等待协作者确认。 + +## 完成标准 + +确认完成后,通过 harness 内部工具写入 `clarification.jsonl`,并生成 `clarification.md` 阅读快照。有效确认记录必须包含: + +| 字段 | 要求 | +| --- | --- | +| `developmentIntent` | 开发者理解的开发意图 | +| `acceptanceCriteria` | 可验证的验收标准 | +| `boundaries` | 明确的范围边界 | +| `businessContracts` | 业务契约列表,记录场景、输入、预期行为、可观测信息和测试要求 | +| `openQuestions` | 必须为空数组 | +| `confirmed` | 必须为 `true` | +| `confirmedBy` | 必须为 `collaborator` | +| `sourceDoc` | 需求来源文件或 `inline-request` | +| `sourceDocHash` | 需求来源内容哈希 | + +业务契约是通用结构,用于让订单、支付、推荐、运营后台等日常需求都能把业务细节转成可测试、可审查的记录。每条契约至少包含 `id`、`scenario` 和 `expectedBehavior`。 + +`clarification.jsonl` 是阶段推进门禁依据,`clarification.md` 只作为阅读快照。 diff --git a/.claude/skills/requirement-development/SKILL.md b/.claude/skills/requirement-development/SKILL.md new file mode 100644 index 0000000..719c511 --- /dev/null +++ b/.claude/skills/requirement-development/SKILL.md @@ -0,0 +1,61 @@ +--- +name: requirement-development +description: | + 需求开发 skill。仅在需求确认完成后使用,用于依据有效 clarification.jsonl 在 harness 项目中推进后续阶段。 + 触发语包括 "继续需求开发"、"查看当前需求开发状态"、"归档当前任务"。 +--- + +# 需求开发 + + + +该 skill 负责组织 harness 需求开发流程。协作者通过自然语言表达任务,模型使用 `.harness/scripts/task.py`、`.harness/scripts/verify.py` 和项目 hooks 维护阶段状态与证据文件。 + +## 前置要求 + +进入开发前必须先使用 `requirement-confirmation`。如果当前任务尚未生成有效 `clarification.jsonl` 确认记录,必须停止需求开发流程,改用 `requirement-confirmation`。 + +即使需求文档完整,也至少复述开发意图,确认验收标准和范围边界。 + +未完成需求确认时,不得创建 task、不得进行模块规划、不得编写 `implementation-plan.md`,也不得调度 `architect`、`developer` 或 `tester`。 + +## 阶段顺序 + +| 阶段 | 责任角色 | 必要证据 | +| --- | --- | --- | +| `clarify` | 主会话 | `clarification.jsonl`、`clarification.md` | +| `doc-plan` | `architect` | `implementation-plan.md`、`scope.json`、三份 `context..jsonl` | +| `red` | `tester` | `test-result.red.json` | +| `green` | `developer` | `test-result.green.json` | +| `review` | `architect` | `review-result.json` | +| `validate` | `tester` | `verify-result.json` | +| `done` | 主会话 | 任务可以归档 | + +阶段推进只能通过 `task.py advance ` 完成。`task.json`、`clarification.jsonl`、`clarification.md`、`test-result.red.json`、`test-result.green.json`、`review-result.json` 和 `verify-result.json` 属于受控文件,由 harness 工具生成。 + +## 计划文件 + +`implementation-plan.md` 只保存实现计划,固定包含以下章节: + +```markdown +# 实现计划 + +## 开发意图摘要 +## 影响范围 +## 技术方案 +## 可测试契约 +## 业务契约覆盖 +## Slice 顺序 +## 验证方式 +## 已知限制 +``` + +## 执行规则 + +固定使用 `agent-team` 执行模式。每个阶段必须调用对应子代理,角色会通过 hook 注入 `docs/standards/index.md`、`clarification.md`、`implementation-plan.md` 和角色自己的 `context..jsonl`。 + +阶段与必选子代理的对应关系固定为:`doc-plan` 和 `review` 使用 `architect`,`red` 和 `validate` 使用 `tester`,`green` 使用 `developer`。主会话负责阶段协调、验证和提交,不负责代替子代理编写阶段产物或业务代码。 + +业务契约必须贯穿后续阶段:`red` 和 `green` 证据通过 `contractCoverage` 和 `uncoveredContracts` 记录测试映射;`review-result.json` 通过 `businessContractCoverage` 记录审查结果;进入 `validate` 前业务契约审查必须通过。 + +每个阶段完成后必须写入对应证据文件,再通过 `task.py advance` 进入下一阶段。最终使用 `verify.py all` 生成 `verify-result.json`,通过后才能进入 `done`。 diff --git a/.codex/config.toml b/.codex/config.toml new file mode 100644 index 0000000..5246926 --- /dev/null +++ b/.codex/config.toml @@ -0,0 +1,3 @@ +sandbox_mode = "danger-full-access" + +approval_policy = "never" diff --git a/.codex/hooks.json b/.codex/hooks.json new file mode 100644 index 0000000..70b6b45 --- /dev/null +++ b/.codex/hooks.json @@ -0,0 +1,50 @@ +{ + "hooks": { + "SessionStart": [ + { + "matcher": "startup|resume", + "hooks": [ + { + "type": "command", + "command": "python3 .codex/hooks/harness-session-start.py", + "timeout": 10, + "statusMessage": "Loading harness context..." + } + ] + } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "python3 .codex/hooks/harness-workflow-state.py", + "timeout": 5 + } + ] + } + ], + "PreToolUse": [ + { + "matcher": "spawn_agent", + "hooks": [ + { + "type": "command", + "command": "python3 .codex/hooks/harness-inject-context.py", + "timeout": 30 + } + ] + }, + { + "matcher": "followup_task", + "hooks": [ + { + "type": "command", + "command": "python3 .codex/hooks/harness-inject-context.py", + "timeout": 30 + } + ] + } + ] + } +} diff --git a/.codex/hooks/harness-inject-context.py b/.codex/hooks/harness-inject-context.py new file mode 100644 index 0000000..a438c17 --- /dev/null +++ b/.codex/hooks/harness-inject-context.py @@ -0,0 +1,386 @@ +#!/usr/bin/env python3 +"""PreToolUse hook: inject phase-safe role context.""" + +from __future__ import annotations + +import json +import os +import sys +import fnmatch +from pathlib import Path + +LOCAL_CONTEXT_KEY = "local" +KNOWN_ROLES = ("architect", "developer", "tester") +PHASE_ROLE = { + "doc-plan": "architect", + "red": "tester", + "green": "developer", + "review": "architect", + "validate": "tester", +} +ROLE_TOOLS = ("Task", "Agent", "TaskCreate", "TeamCreate", "spawn_agent", "followup_task") +EDIT_TOOLS = ("Write", "Edit", "MultiEdit") +TEST_PATH_PARTS = ("/test/", "/tests/", "_test.", ".test.", ".spec.") +CODE_EXTENSIONS = ( + ".go", + ".py", + ".js", + ".jsx", + ".ts", + ".tsx", + ".java", + ".kt", + ".rs", + ".c", + ".cc", + ".cpp", + ".h", + ".hpp", + ".cs", + ".php", + ".rb", + ".swift", +) +CONTROLLED_SUFFIXES = ( + "task.json", + "clarification.jsonl", + "clarification.md", + "test-result.red.json", + "test-result.green.json", + "review-result.json", + "verify-result.json", +) +DOC_PLAN_TASK_FILES = ( + "implementation-plan.md", + "scope.json", + "context.architect.jsonl", + "context.developer.jsonl", + "context.tester.jsonl", +) + + +def find_project_root(start: Path) -> Path | None: + cur = start.resolve() + while cur != cur.parent: + if (cur / ".harness").is_dir(): + return cur + cur = cur.parent + return None + + +def resolve_session_key(data: dict) -> str | None: + for key in ("session_id", "sessionId"): + value = data.get(key) + if isinstance(value, str) and value.strip(): + return value.strip() + return os.environ.get("HARNESS_CONTEXT_ID") + + +def task_dir_from_ref(root: Path, task_ref: str) -> Path | None: + task_dir = root / task_ref + return task_dir if task_dir.is_dir() else None + + +def get_active_task_dir(root: Path, data: dict) -> Path | None: + sessions_dir = root / ".harness" / "runtime" / "sessions" + key = resolve_session_key(data) + candidates = [] + if key: + candidates.append(sessions_dir / f"{key}.json") + candidates.append(sessions_dir / f"{LOCAL_CONTEXT_KEY}.json") + for path in candidates: + if not path.is_file(): + continue + session = json.loads(path.read_text(encoding="utf-8")) + task_ref = session.get("current_task") + if task_ref: + task_dir = task_dir_from_ref(root, task_ref) + if task_dir: + return task_dir + return unique_in_progress_task_dir(root) + + +def unique_in_progress_task_dir(root: Path) -> Path | None: + tasks_dir = root / "docs" / "tasks" + if not tasks_dir.is_dir(): + return None + matches = [] + for task_dir in tasks_dir.iterdir(): + if not task_dir.is_dir() or task_dir.name == "archive": + continue + task_json = task_dir / "task.json" + if not task_json.is_file(): + continue + data = json.loads(task_json.read_text(encoding="utf-8")) + if data.get("status") == "in_progress": + matches.append(task_dir) + return matches[0] if len(matches) == 1 else None + + +def read_file_safe(path: Path) -> str: + try: + return path.read_text(encoding="utf-8") + except (FileNotFoundError, PermissionError): + return "" + + +def read_jsonl_context(root: Path, jsonl_path: Path) -> list[tuple[str, str]]: + if not jsonl_path.is_file(): + return [] + results = [] + for line in jsonl_path.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line: + continue + try: + item = json.loads(line) + except json.JSONDecodeError: + continue + if "_example" in item: + continue + file_path = item.get("file") + if not isinstance(file_path, str) or not file_path: + continue + content = read_file_safe(root / file_path) + if content: + results.append((file_path, content)) + return results + + +def task_phase(task_dir: Path) -> str: + data = json.loads((task_dir / "task.json").read_text(encoding="utf-8")) + return data.get("phase", "unknown") + + +def build_role_context(root: Path, task_dir: Path, role: str) -> str: + parts = [] + + standards = read_file_safe(root / "docs" / "standards" / "index.md") + if standards: + parts.append(f"=== docs/standards/index.md ===\n{standards}") + + clarification = read_file_safe(task_dir / "clarification.md") + if clarification: + parts.append(f"=== clarification.md ===\n{clarification}") + + plan = read_file_safe(task_dir / "implementation-plan.md") + if plan: + parts.append(f"=== implementation-plan.md ===\n{plan}") + + for file_path, content in read_jsonl_context(root, task_dir / f"context.{role}.jsonl"): + parts.append(f"=== {file_path} ===\n{content}") + + return "\n\n".join(parts) + + +def infer_role(tool_input: dict) -> str: + direct = tool_input.get("subagent_type") or tool_input.get("subagentType") or tool_input.get("role") or "" + if direct in KNOWN_ROLES: + return direct + for key in ("task_name", "name", "target"): + value = tool_input.get(key) + if not isinstance(value, str): + continue + lowered = value.lower() + for role in KNOWN_ROLES: + if role in lowered: + return role + return "" + + +def prompt_field(tool_input: dict) -> str: + if "prompt" in tool_input: + return "prompt" + if "message" in tool_input: + return "message" + return "prompt" + + +def normalize_target_path(root: Path, value: str) -> str: + path = Path(value) + if path.is_absolute(): + try: + return path.resolve().relative_to(root).as_posix() + except ValueError: + return path.as_posix() + return path.as_posix() + + +def edit_target(tool_input: dict) -> str | None: + for key in ("file_path", "path", "target_file"): + value = tool_input.get(key) + if isinstance(value, str) and value.strip(): + return value.strip() + return None + + +def controlled_edit_target(tool_input: dict) -> str | None: + target = edit_target(tool_input) + if target: + normalized = target.replace("\\", "/") + if "/docs/tasks/" in normalized or normalized.startswith("docs/tasks/"): + if any(normalized.endswith(suffix) for suffix in CONTROLLED_SUFFIXES): + return target + return None + + +def is_test_path(path: str) -> bool: + normalized_path = path.replace("\\", "/") + normalized = f"/{normalized_path}" + return any(part in normalized for part in TEST_PATH_PARTS) + + +def is_code_path(path: str) -> bool: + return Path(path).suffix in CODE_EXTENSIONS + + +def matches_pattern(path: str, pattern: str) -> bool: + normalized = pattern.strip() + if normalized.endswith("/"): + normalized = f"{normalized}**" + plain = normalized.rstrip("/") + if not any(ch in normalized for ch in "*?[]"): + return path == plain or path.startswith(f"{plain}/") + return fnmatch.fnmatchcase(path, normalized) + + +def matches_any(path: str, patterns: list[str]) -> bool: + return any(matches_pattern(path, pattern) for pattern in patterns) + + +def scope_allowed(task_dir: Path) -> list[str]: + path = task_dir / "scope.json" + if not path.is_file(): + return [] + try: + data = json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError: + return [] + allowed = data.get("allowed") + if not isinstance(allowed, list): + return [] + return [item for item in allowed if isinstance(item, str) and item.strip()] + + +def is_doc_plan_artifact(path: str, task_dir: Path, root: Path) -> bool: + try: + rel_task = task_dir.relative_to(root).as_posix() + except ValueError: + return False + if not path.startswith(f"{rel_task}/"): + return False + name = path.removeprefix(f"{rel_task}/") + return name in DOC_PLAN_TASK_FILES + + +def phase_edit_violation(root: Path, task_dir: Path | None, phase: str | None, target: str) -> str | None: + rel = normalize_target_path(root, target) + if task_dir is None: + if is_code_path(rel): + return f"没有 active task,禁止修改业务代码 {rel}。请先进入 requirement-confirmation 完成需求确认。" + return None + if phase == "clarify": + if is_code_path(rel): + return f"当前阶段 clarify 禁止修改业务代码 {rel}。请先完成需求确认并推进到 doc-plan。" + return None + if phase == "doc-plan": + if is_doc_plan_artifact(rel, task_dir, root): + return None + if is_code_path(rel) or is_test_path(rel): + return f"当前阶段 doc-plan 禁止修改业务代码 {rel}。只能编写 implementation-plan.md、scope.json 和 context.*.jsonl。" + return None + if phase in ("red", "validate"): + if is_code_path(rel) and not is_test_path(rel): + return f"当前阶段 {phase} 禁止修改业务实现文件 {rel}。该阶段只允许测试相关变更。" + return None + if phase == "green": + if is_test_path(rel): + return f"当前阶段 green 禁止修改测试文件 {rel}。请回到 red 或 validate 阶段处理测试。" + allowed = scope_allowed(task_dir) + if allowed and not matches_any(rel, allowed): + return f"文件 {rel} 不在 scope.json.allowed 范围内,禁止修改。" + if phase == "review": + allowed = scope_allowed(task_dir) + if allowed and not matches_any(rel, allowed): + return f"文件 {rel} 不在 scope.json.allowed 范围内,禁止修改。" + return None + + +def emit_block(reason: str) -> int: + output = { + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "deny", + "additionalContext": reason, + } + } + print(json.dumps(output, ensure_ascii=False)) + return 0 + + +def main() -> int: + try: + data = json.load(sys.stdin) + except (json.JSONDecodeError, ValueError): + return 0 + + tool_input = data.get("tool_input", {}) + tool_name = data.get("tool_name", "") + target = controlled_edit_target(tool_input) + if target: + return emit_block(f"受控文件 {target} 只能通过 harness 内部工具生成,禁止手工编辑。") + + root = find_project_root(Path(data.get("cwd") or ".")) + if root is None: + return 0 + task_dir = get_active_task_dir(root, data) + phase = task_phase(task_dir) if task_dir else None + + if tool_name in EDIT_TOOLS: + target = edit_target(tool_input) + if target: + violation = phase_edit_violation(root, task_dir, phase, target) + if violation: + return emit_block(violation) + return 0 + + if tool_name in ROLE_TOOLS and task_dir is None: + return emit_block( + "没有 active task,禁止启动开发子任务。请先使用 requirement-confirmation 完成需求确认," + "确认后再使用 python3 .harness/scripts/task.py create \"<任务名>\" 创建任务。" + ) + + role = infer_role(tool_input) + if role not in KNOWN_ROLES: + if tool_name in ROLE_TOOLS and phase in PHASE_ROLE: + expected = PHASE_ROLE[phase] + return emit_block(f"当前阶段 {phase} 必须调用 {expected},禁止使用未声明角色的子任务绕过 harness。") + return 0 + + if task_dir is None: + return 0 + + expected = PHASE_ROLE.get(phase) + if expected != role: + return emit_block(f"当前阶段 {phase} 不允许调用 {role}。应执行的角色职责是 {expected or '无开发角色'}。") + + context = build_role_context(root, task_dir, role) + if not context: + return 0 + + field = prompt_field(tool_input) + original = tool_input.get(field, "") + updated = {**tool_input, field: f"## Injected Context\n\n{context}\n\n---\n\n## Task\n\n{original}"} + output = { + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "allow", + "updatedInput": updated, + } + } + print(json.dumps(output, ensure_ascii=False)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.codex/hooks/harness-session-start.py b/.codex/hooks/harness-session-start.py new file mode 100644 index 0000000..13db647 --- /dev/null +++ b/.codex/hooks/harness-session-start.py @@ -0,0 +1,164 @@ +#!/usr/bin/env python3 +"""SessionStart hook: inject current harness task summary.""" + +from __future__ import annotations + +import json +import os +import shlex +import sys +from pathlib import Path + +LOCAL_CONTEXT_KEY = "local" +PHASE_ROLE = { + "doc-plan": "architect", + "red": "tester", + "green": "developer", + "review": "architect", + "validate": "tester", +} + + +def find_project_root(start: Path) -> Path | None: + cur = start.resolve() + while cur != cur.parent: + if (cur / ".harness").is_dir(): + return cur + cur = cur.parent + return None + + +def resolve_session_key(data: dict) -> str | None: + for key in ("session_id", "sessionId"): + value = data.get(key) + if isinstance(value, str) and value.strip(): + return value.strip() + return os.environ.get("HARNESS_CONTEXT_ID") + + +def export_context_id_to_env_file(context_key: str | None) -> None: + if not context_key: + return + env_file = os.environ.get("CLAUDE_ENV_FILE") + if not env_file: + return + try: + with open(env_file, "a", encoding="utf-8") as fh: + fh.write(f"export HARNESS_CONTEXT_ID={shlex.quote(context_key)}\n") + except OSError: + pass + + +def task_info_from_ref(root: Path, task_ref: str) -> dict | None: + task_dir = root / task_ref + if not task_dir.is_dir(): + return None + task_json = task_dir / "task.json" + if not task_json.is_file(): + return {"title": task_dir.name, "path": task_ref, "status": "unknown", "phase": "unknown"} + data = json.loads(task_json.read_text(encoding="utf-8")) + return { + "title": data.get("title", task_dir.name), + "path": task_ref, + "status": data.get("status", "unknown"), + "phase": data.get("phase", "unknown"), + "executionMode": data.get("executionMode", "unknown"), + } + + +def get_active_task(root: Path, data: dict) -> dict | None: + sessions_dir = root / ".harness" / "runtime" / "sessions" + key = resolve_session_key(data) + candidates = [] + if key: + candidates.append(sessions_dir / f"{key}.json") + candidates.append(sessions_dir / f"{LOCAL_CONTEXT_KEY}.json") + for path in candidates: + if not path.is_file(): + continue + session = json.loads(path.read_text(encoding="utf-8")) + task_ref = session.get("current_task") + if task_ref: + task = task_info_from_ref(root, task_ref) + if task: + return task + return unique_in_progress_task(root) + + +def unique_in_progress_task(root: Path) -> dict | None: + tasks_dir = root / "docs" / "tasks" + if not tasks_dir.is_dir(): + return None + refs = [] + for task_dir in tasks_dir.iterdir(): + if not task_dir.is_dir() or task_dir.name == "archive": + continue + task_json = task_dir / "task.json" + if not task_json.is_file(): + continue + data = json.loads(task_json.read_text(encoding="utf-8")) + if data.get("status") == "in_progress": + refs.append(f"docs/tasks/{task_dir.name}") + return task_info_from_ref(root, refs[0]) if len(refs) == 1 else None + + +def build_context(task: dict | None) -> str: + parts = [] + if task: + required_role = PHASE_ROLE.get(task["phase"]) + role_line = f"\nRequired subagent: {required_role}" if required_role else "" + parts.append( + f"Active task: {task['title']} ({task['status']})\n" + f"Phase: {task['phase']}\n" + f"Execution mode: {task['executionMode']}\n" + f"Path: {task['path']}\n" + "Required skill: requirement-development" + f"{role_line}\n" + "继续当前任务时必须使用需求开发 skill,并通过必选子代理推进阶段。" + ) + else: + parts.append( + "No active task.\n" + "Required skill: requirement-confirmation\n" + "新需求、实现请求、模块规划和按文档开发请求,都必须先完成需求确认。" + ) + + parts.append( + "\nNatural language entries:\n" + "- 按 design.md 开发: use requirement-confirmation first\n" + "- 继续需求开发: use requirement-development after confirmed clarification\n" + "- 查看当前需求开发状态\n" + "- 归档当前任务" + ) + parts.append( + "\nHarness roles:\n" + "- requirement-confirmation: confirm intent, acceptance criteria, and boundaries\n" + "- requirement-development: orchestrate phase progression\n" + "- architect: doc-plan and review\n" + "- tester: RED and validate\n" + "- developer: GREEN implementation" + ) + return "\n".join(parts) + + +def main() -> int: + try: + data = json.load(sys.stdin) + except (json.JSONDecodeError, ValueError): + data = {} + root = find_project_root(Path(data.get("cwd") or ".")) + if root is None: + return 0 + export_context_id_to_env_file(resolve_session_key(data)) + output = { + "hookSpecificOutput": { + "hookEventName": "SessionStart", + "additionalContext": build_context(get_active_task(root, data)), + } + } + print(json.dumps(output, ensure_ascii=False)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.codex/hooks/harness-workflow-state.py b/.codex/hooks/harness-workflow-state.py new file mode 100644 index 0000000..2784b6a --- /dev/null +++ b/.codex/hooks/harness-workflow-state.py @@ -0,0 +1,157 @@ +#!/usr/bin/env python3 +"""UserPromptSubmit hook: emit phase-aware workflow breadcrumb.""" + +from __future__ import annotations + +import json +import os +import re +import sys +from pathlib import Path + +LOCAL_CONTEXT_KEY = "local" +PHASE_ROLE = { + "doc-plan": "architect", + "red": "tester", + "green": "developer", + "review": "architect", + "validate": "tester", +} +TAG_RE = re.compile( + r"\[workflow-phase:([A-Za-z0-9_-]+)\]\s*\n(.*?)\n\s*\[/workflow-phase:\1\]", + re.DOTALL, +) + + +def find_project_root(start: Path) -> Path | None: + cur = start.resolve() + while cur != cur.parent: + if (cur / ".harness").is_dir(): + return cur + cur = cur.parent + return None + + +def load_breadcrumbs(root: Path) -> dict[str, str]: + workflow = root / ".harness" / "workflow.md" + if not workflow.is_file(): + return {} + content = workflow.read_text(encoding="utf-8") + return {m.group(1): m.group(2).strip() for m in TAG_RE.finditer(content)} + + +def resolve_session_key(data: dict) -> str | None: + for key in ("session_id", "sessionId"): + value = data.get(key) + if isinstance(value, str) and value.strip(): + return value.strip() + env_key = os.environ.get("HARNESS_CONTEXT_ID") + return env_key or None + + +def task_info_from_ref(root: Path, task_ref: str) -> dict | None: + task_dir = root / task_ref + if not task_dir.is_dir(): + return None + task_json = task_dir / "task.json" + if not task_json.is_file(): + return {"path": task_ref, "status": "unknown", "phase": "unknown"} + data = json.loads(task_json.read_text(encoding="utf-8")) + return { + "path": task_ref, + "title": data.get("title", task_dir.name), + "status": data.get("status", "unknown"), + "phase": data.get("phase", data.get("status", "unknown")), + } + + +def get_active_task(root: Path, data: dict) -> dict | None: + sessions_dir = root / ".harness" / "runtime" / "sessions" + key = resolve_session_key(data) + candidates = [] + if key: + candidates.append(sessions_dir / f"{key}.json") + candidates.append(sessions_dir / f"{LOCAL_CONTEXT_KEY}.json") + + for path in candidates: + if not path.is_file(): + continue + session = json.loads(path.read_text(encoding="utf-8")) + task_ref = session.get("current_task") + if task_ref: + task_info = task_info_from_ref(root, task_ref) + if task_info: + return task_info + + files = list(sessions_dir.glob("*.json")) if sessions_dir.is_dir() else [] + if len(files) == 1: + session = json.loads(files[0].read_text(encoding="utf-8")) + task_ref = session.get("current_task") + if task_ref: + return task_info_from_ref(root, task_ref) + return unique_in_progress_task(root) + + +def unique_in_progress_task(root: Path) -> dict | None: + tasks_dir = root / "docs" / "tasks" + if not tasks_dir.is_dir(): + return None + matches = [] + for task_dir in tasks_dir.iterdir(): + if not task_dir.is_dir() or task_dir.name == "archive": + continue + task_json = task_dir / "task.json" + if not task_json.is_file(): + continue + data = json.loads(task_json.read_text(encoding="utf-8")) + if data.get("status") == "in_progress": + matches.append(f"docs/tasks/{task_dir.name}") + return task_info_from_ref(root, matches[0]) if len(matches) == 1 else None + + +def build_breadcrumb(task: dict | None, body: str) -> str: + if task: + required_role = PHASE_ROLE.get(task["phase"]) + role_line = f"\nRequired subagent: {required_role}" if required_role else "" + header = ( + f"Task: {task['path']} ({task['status']})\n" + f"Phase: {task['phase']}\n" + "Required skill: requirement-development" + f"{role_line}\n" + "继续当前任务时必须使用需求开发 skill,并通过必选子代理推进阶段。" + ) + else: + header = ( + "Status: no_task\n" + "Phase: no_task\n" + "Required skill: requirement-confirmation\n" + "新需求、实现请求、模块规划和按文档开发请求,都必须先完成需求确认。" + ) + return f"\n{header}\n{body}\n" + + +def main() -> int: + try: + data = json.load(sys.stdin) + except (json.JSONDecodeError, ValueError): + data = {} + root = find_project_root(Path(data.get("cwd") or ".")) + if root is None: + return 0 + + breadcrumbs = load_breadcrumbs(root) + task = get_active_task(root, data) + phase = task["phase"] if task else "no_task" + body = breadcrumbs.get(phase, "Refer to workflow.md for current phase.") + output = { + "hookSpecificOutput": { + "hookEventName": "UserPromptSubmit", + "additionalContext": build_breadcrumb(task, body), + } + } + print(json.dumps(output, ensure_ascii=False)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.gitignore b/.gitignore index a7033c2..aae4bc3 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,32 @@ __pycache__/ .DS_Store .idea/ .vscode/ + +# harness defaults — keep these so __pycache__ etc. don't leak into commits + +# harness runtime state (session pointers — local, never commit) +.harness/runtime/ + +# Python +__pycache__/ +*.py[cod] +*$py.class +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ + +# Node +node_modules/ +dist/ +build/ +.vite/ +coverage/ + +# OS +.DS_Store +Thumbs.db + +# IDE +.idea/ +.vscode/ +*.swp diff --git a/.harness/scripts/context.py b/.harness/scripts/context.py new file mode 100644 index 0000000..122d393 --- /dev/null +++ b/.harness/scripts/context.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python3 +"""Build role-specific harness context for runtimes without hook support.""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +STANDARD_ROLES = ("architect", "developer", "tester") + + +def find_project_root(start: Path) -> Path: + current = start.resolve() + while current != current.parent: + if (current / ".harness").is_dir(): + return current + current = current.parent + print("Error: .harness/ directory not found", file=sys.stderr) + sys.exit(1) + + +def resolve_task_dir(root: Path, task: str) -> Path: + task_path = Path(task) + if task_path.is_absolute(): + candidate = task_path + elif task.startswith("docs/tasks/"): + candidate = root / task_path + else: + candidate = root / "docs" / "tasks" / task + if not candidate.is_dir(): + print(f"Error: task directory not found: {task}", file=sys.stderr) + sys.exit(1) + return candidate + + +def read_file_safe(path: Path) -> str: + try: + return path.read_text(encoding="utf-8") + except (FileNotFoundError, PermissionError): + return "" + + +def read_jsonl_context(root: Path, jsonl_path: Path) -> list[tuple[str, str]]: + if not jsonl_path.is_file(): + return [] + results = [] + for line in jsonl_path.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line: + continue + try: + item = json.loads(line) + except json.JSONDecodeError: + continue + if "_example" in item: + continue + file_path = item.get("file") + if not isinstance(file_path, str) or not file_path: + continue + content = read_file_safe(root / file_path) + if content: + results.append((file_path, content)) + return results + + +def build_role_context(root: Path, task_dir: Path, role: str) -> str: + parts = [] + standards = read_file_safe(root / "docs" / "standards" / "index.md") + if standards: + parts.append(f"=== docs/standards/index.md ===\n{standards}") + clarification = read_file_safe(task_dir / "clarification.md") + if clarification: + parts.append(f"=== clarification.md ===\n{clarification}") + plan = read_file_safe(task_dir / "implementation-plan.md") + if plan: + parts.append(f"=== implementation-plan.md ===\n{plan}") + for file_path, content in read_jsonl_context(root, task_dir / f"context.{role}.jsonl"): + parts.append(f"=== {file_path} ===\n{content}") + return "\n\n".join(parts) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Build harness role context") + parser.add_argument("role", choices=STANDARD_ROLES) + parser.add_argument("--task", required=True, help="Task dir name or docs/tasks/") + parser.add_argument("--prompt", default="", help="Optional task prompt appended after context") + return parser.parse_args() + + +def main() -> int: + args = parse_args() + root = find_project_root(Path.cwd()) + task_dir = resolve_task_dir(root, args.task) + context = build_role_context(root, task_dir, args.role) + if not context: + print("Error: no context found for role", file=sys.stderr) + return 1 + print("## Injected Context") + print() + print(context) + if args.prompt: + print() + print("---") + print() + print("## Task") + print() + print(args.prompt) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.harness/scripts/project.py b/.harness/scripts/project.py new file mode 100644 index 0000000..613f9a5 --- /dev/null +++ b/.harness/scripts/project.py @@ -0,0 +1,342 @@ +#!/usr/bin/env python3 +"""Project documentation CLI for harness.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import sys +from datetime import datetime, timezone +from pathlib import Path + + +CONFIG_PATH = ".harness/project-docs.json" +PROFILE_PATH = ".harness/project-profile.json" +BLOCK_START = "" +BLOCK_END = "" + +DEFAULT_DOCUMENTS = [ + { + "id": "projectGuide", + "title": "项目说明", + "output": "docs/standards/project-guide.md", + "description": "记录项目架构、模块职责、启动方式、开发约定和常见变更入口。", + }, + { + "id": "apiUrlIndex", + "title": "接口地址索引", + "output": "docs/standards/api/url-index.md", + "description": "记录项目暴露的 HTTP、RPC 或事件接口入口和代码位置。", + }, + { + "id": "apiDetail", + "title": "接口详情", + "output": "docs/standards/api/detail.md", + "description": "记录接口请求、响应、鉴权、错误码和重要业务约束。", + }, +] + + +def _utc_now() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _find_project_root() -> Path: + current = Path.cwd().resolve() + while current != current.parent: + if (current / ".harness").is_dir(): + return current + current = current.parent + script = Path(__file__).resolve() + if script.parent.name == "scripts" and script.parent.parent.name == ".harness": + return script.parent.parent.parent + print("Error: .harness/ directory not found", file=sys.stderr) + sys.exit(1) + + +def _read_json(path: Path) -> dict: + if not path.is_file(): + return {} + try: + data = json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + print(f"Error: invalid JSON: {path}: {exc}", file=sys.stderr) + sys.exit(2) + if not isinstance(data, dict): + print(f"Error: JSON root must be object: {path}", file=sys.stderr) + sys.exit(2) + return data + + +def _write_json(path: Path, data: dict) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") + + +def _default_config() -> dict: + return { + "version": 1, + "preset": "default", + "documents": DEFAULT_DOCUMENTS, + "review": { + "initialStatus": "draft", + "statuses": ["draft", "approved", "needs_update", "stale", "missing"], + }, + "analysis": { + "cacheDir": ".harness/analysis/latest", + "keepHistory": False, + }, + "contextInjection": { + "standardsIndex": "docs/standards/index.md", + "documents": [item["output"] for item in DEFAULT_DOCUMENTS], + }, + } + + +def _document_from_config(config: dict) -> list[dict]: + documents = config.get("documents") + if not isinstance(documents, list) or not documents: + return DEFAULT_DOCUMENTS + valid = [] + for item in documents: + if not isinstance(item, dict): + continue + output = item.get("output") + doc_id = item.get("id") + if isinstance(output, str) and output and isinstance(doc_id, str) and doc_id: + valid.append(item) + return valid or DEFAULT_DOCUMENTS + + +def _sha256_file(path: Path) -> str: + digest = hashlib.sha256() + digest.update(path.read_bytes()) + return "sha256:" + digest.hexdigest() + + +def _profile_document(root: Path, doc: dict, existing: dict | None = None) -> dict: + existing = existing or {} + rel_path = doc["output"] + path = root / rel_path + content_hash = _sha256_file(path) if path.is_file() else "" + old_hash = existing.get("contentHash") if isinstance(existing.get("contentHash"), str) else "" + old_status = existing.get("reviewStatus") if isinstance(existing.get("reviewStatus"), str) else "" + if not path.is_file(): + review_status = "missing" + elif old_status == "approved" and old_hash == content_hash: + review_status = "approved" + elif old_status == "approved" and old_hash and old_hash != content_hash: + review_status = "stale" + else: + review_status = old_status if old_status in {"draft", "needs_update"} else "draft" + return { + "id": doc["id"], + "title": doc.get("title", doc["id"]), + "path": rel_path, + "reviewStatus": review_status, + "contentHash": content_hash if review_status == "approved" else old_hash, + "generatedAt": existing.get("generatedAt"), + "approvedAt": existing.get("approvedAt") if review_status == "approved" else None, + "approvedBy": existing.get("approvedBy") if review_status == "approved" else None, + } + + +def _load_config(root: Path) -> dict: + config = _read_json(root / CONFIG_PATH) + return config if config else _default_config() + + +def _load_profile(root: Path) -> dict: + return _read_json(root / PROFILE_PATH) + + +def _build_profile(root: Path, config: dict) -> dict: + previous = _load_profile(root) + previous_docs = { + item.get("id"): item + for item in previous.get("documents", []) + if isinstance(item, dict) and isinstance(item.get("id"), str) + } + return { + "version": 1, + "updatedAt": _utc_now(), + "documents": [ + _profile_document(root, doc, previous_docs.get(doc["id"])) + for doc in _document_from_config(config) + ], + "review": { + "openQuestionsCount": previous.get("review", {}).get("openQuestionsCount", 0), + "highPriorityOpenQuestionsCount": previous.get("review", {}).get("highPriorityOpenQuestionsCount", 0), + }, + } + + +def _replace_managed_block(content: str, block: str) -> str: + if BLOCK_START in content and BLOCK_END in content: + before = content.split(BLOCK_START, 1)[0].rstrip() + after = content.split(BLOCK_END, 1)[1].lstrip() + pieces = [before, block.strip(), after] + return "\n\n".join(piece for piece in pieces if piece) + "\n" + sep = "\n\n" if content.strip() else "" + return content.rstrip() + sep + block.strip() + "\n" + + +def _docs_index_block() -> str: + return f"""\ +{BLOCK_START} +## 项目知识文档 + +| 文档 | 用途 | +| --- | --- | +| `docs/standards/project-guide.md` | 项目架构、模块职责和开发入口 | +| `docs/standards/api/` | 接口索引和接口详情 | +{BLOCK_END} +""" + + +def _standards_index_block(config: dict) -> str: + rows = [ + f"| `{doc['output']}` | {doc.get('description', doc.get('title', doc['id']))} |" + for doc in _document_from_config(config) + ] + return "\n".join( + [ + BLOCK_START, + "## 项目知识文档", + "", + "| 文档 | 用途 |", + "| --- | --- |", + *rows, + BLOCK_END, + "", + ] + ) + + +def _update_index(path: Path, block: str, default_title: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + content = path.read_text(encoding="utf-8") if path.is_file() else f"# {default_title}\n" + path.write_text(_replace_managed_block(content, block), encoding="utf-8") + + +def _ensure_dirs(root: Path, config: dict) -> None: + (root / ".harness" / "analysis" / "latest").mkdir(parents=True, exist_ok=True) + for doc in _document_from_config(config): + (root / doc["output"]).parent.mkdir(parents=True, exist_ok=True) + + +def cmd_init_config(args: argparse.Namespace) -> int: + root = _find_project_root() + config_path = root / CONFIG_PATH + config = _load_config(root) + _ensure_dirs(root, config) + if not config_path.is_file(): + _write_json(config_path, config) + else: + _write_json(config_path, config) + _write_json(root / PROFILE_PATH, _build_profile(root, config)) + _update_index(root / "docs" / "index.md", _docs_index_block(), "文档索引") + _update_index(root / "docs" / "standards" / "index.md", _standards_index_block(config), "团队工程规范索引") + print(f"Project documentation config initialized: {CONFIG_PATH}") + return 0 + + +def _summarize(profile: dict) -> dict: + summary = {"total": 0, "missing": 0, "draft": 0, "approved": 0, "needs_update": 0, "stale": 0} + for doc in profile.get("documents", []): + if not isinstance(doc, dict): + continue + summary["total"] += 1 + status = doc.get("reviewStatus", "draft") + if status not in summary: + summary[status] = 0 + summary[status] += 1 + return summary + + +def cmd_status(args: argparse.Namespace) -> int: + root = _find_project_root() + initialized = (root / CONFIG_PATH).is_file() and (root / PROFILE_PATH).is_file() + config = _load_config(root) + profile = _build_profile(root, config) + if initialized: + _write_json(root / PROFILE_PATH, profile) + output = { + "initialized": initialized, + "summary": _summarize(profile), + "documents": profile["documents"], + } + if args.json: + print(json.dumps(output, indent=2, ensure_ascii=False)) + return 0 + print("Project documentation status:") + for doc in profile["documents"]: + print(f" {doc['reviewStatus']}: {doc['path']}") + return 0 + + +def cmd_approve(args: argparse.Namespace) -> int: + root = _find_project_root() + config = _load_config(root) + profile = _build_profile(root, config) + approved_by = args.approved_by or "local" + targets = {doc["id"] for doc in profile["documents"]} if args.all else set(args.document or []) + if not targets: + print("Error: use --all or --document ", file=sys.stderr) + return 2 + now = _utc_now() + changed = 0 + for doc in profile["documents"]: + if doc["id"] not in targets: + continue + path = root / doc["path"] + if not path.is_file(): + doc["reviewStatus"] = "missing" + continue + doc["reviewStatus"] = "approved" + doc["contentHash"] = _sha256_file(path) + doc["approvedAt"] = now + doc["approvedBy"] = approved_by + if not doc.get("generatedAt"): + doc["generatedAt"] = now + changed += 1 + profile["updatedAt"] = now + _write_json(root / PROFILE_PATH, profile) + print(f"Approved project documents: {changed}") + return 0 + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Manage harness project documentation") + subparsers = parser.add_subparsers(dest="command") + + docs = subparsers.add_parser("docs", help="Manage project documentation") + docs_subparsers = docs.add_subparsers(dest="docs_command") + + init_config = docs_subparsers.add_parser("init-config", help="Create project documentation config") + init_config.set_defaults(func=cmd_init_config) + + status = docs_subparsers.add_parser("status", help="Show project documentation status") + status.add_argument("--json", action="store_true", help="Print JSON output") + status.set_defaults(func=cmd_status) + + approve = docs_subparsers.add_parser("approve", help="Approve generated project documentation") + approve.add_argument("--all", action="store_true", help="Approve every existing configured document") + approve.add_argument("--document", action="append", help="Approve one configured document id") + approve.add_argument("--approved-by", default="local", help="Reviewer name recorded in profile") + approve.set_defaults(func=cmd_approve) + return parser + + +def main() -> int: + parser = build_parser() + args = parser.parse_args() + if not hasattr(args, "func"): + parser.print_help() + return 2 + return args.func(args) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.harness/scripts/task.py b/.harness/scripts/task.py new file mode 100644 index 0000000..9a009cc --- /dev/null +++ b/.harness/scripts/task.py @@ -0,0 +1,814 @@ +#!/usr/bin/env python3 +"""Task management CLI for the agent harness. + +The CLI is an internal tool for skills and hooks. Team members interact through +natural language; agents call this script to keep task state machine transitions +and evidence files deterministic. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import re +import shutil +import subprocess +import sys +from datetime import datetime, timezone +from pathlib import Path + +LOCAL_CONTEXT_KEY = "local" +TASKS_ROOT = Path("docs/tasks") +PHASE_ORDER = ("clarify", "doc-plan", "red", "green", "review", "validate", "done") +VALID_INTENTS = ("requirement-development", "requirement-confirmation") +VALID_EXECUTION_MODES = ("agent-team",) +PLAN_SECTIONS = ( + "开发意图摘要", + "影响范围", + "技术方案", + "可测试契约", + "业务契约覆盖", + "Slice 顺序", + "验证方式", + "已知限制", +) +BUSINESS_CONTRACT_REQUIRED_FIELDS = ("id", "scenario", "expectedBehavior") + + +def _utc_now() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _today_prefix() -> str: + return datetime.now().strftime("%m-%d") + + +def _find_project_root() -> Path: + current = Path.cwd().resolve() + while current != current.parent: + if (current / ".harness").is_dir(): + return current + current = current.parent + script_path = Path(__file__).resolve() + if script_path.parent.name == "scripts" and script_path.parent.parent.name == ".harness": + return script_path.parent.parent.parent + print("Error: .harness/ directory not found", file=sys.stderr) + sys.exit(1) + + +def _harness_root(root: Path) -> Path: + return root / ".harness" + + +def _tasks_root(root: Path) -> Path: + return root / TASKS_ROOT + + +def _context_key() -> str: + return os.environ.get("HARNESS_CONTEXT_ID") or LOCAL_CONTEXT_KEY + + +def _session_path(root: Path, key: str) -> Path: + return _harness_root(root) / "runtime" / "sessions" / f"{key}.json" + + +def _read_json(path: Path, label: str) -> dict: + if not path.is_file(): + print(f"Error: missing {label}: {path}", file=sys.stderr) + sys.exit(2) + try: + data = json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + print(f"Error: invalid {label}: {path}: {exc}", file=sys.stderr) + sys.exit(2) + if not isinstance(data, dict): + print(f"Error: {label} must be a JSON object: {path}", file=sys.stderr) + sys.exit(2) + return data + + +def _write_json(path: Path, data: dict) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") + + +def _read_session(root: Path, key: str) -> dict: + path = _session_path(root, key) + if path.is_file(): + return _read_json(path, "session") + return {} + + +def _write_session(root: Path, key: str, data: dict) -> None: + _write_json(_session_path(root, key), data) + + +def _slugify(title: str) -> str: + slug = title.lower().strip() + slug = re.sub(r"[^a-z0-9一-鿿]+", "-", slug) + slug = slug.strip("-") + return slug[:50] if slug else "requirement" + + +def _task_ref(task_dir: Path, root: Path) -> str: + return task_dir.relative_to(root).as_posix() + + +def _resolve_task_dir(root: Path, task_arg: str | None = None) -> Path: + if task_arg: + ref = task_arg + else: + session = _read_session(root, _context_key()) + ref = session.get("current_task") + if not ref: + print("Error: no active task. Create or select a task first.", file=sys.stderr) + sys.exit(2) + + path = Path(ref) + if path.is_absolute(): + task_dir = path + elif ref.startswith("docs/tasks/"): + task_dir = root / path + else: + task_dir = _tasks_root(root) / ref + + if not task_dir.is_dir(): + print(f"Error: task directory not found: {ref}", file=sys.stderr) + sys.exit(2) + return task_dir + + +def _read_task(task_dir: Path) -> dict: + return _read_json(task_dir / "task.json", "task.json") + + +def _write_task(task_dir: Path, data: dict) -> None: + _write_json(task_dir / "task.json", data) + + +def _history_event(task_dir: Path, event: dict) -> None: + data = _read_task(task_dir) + history = data.setdefault("phaseHistory", []) + event.setdefault("at", _utc_now()) + history.append(event) + _write_task(task_dir, data) + + +def _record_advance_failure(task_dir: Path, phase: str, reason: str) -> None: + data = _read_task(task_dir) + attempts = data.setdefault("phaseAttempts", {}) + phase_attempt = attempts.setdefault(phase, {}) + phase_attempt["autoFixCount"] = phase_attempt.get("autoFixCount", 0) + phase_attempt["lastError"] = reason + phase_attempt["lastFailedAt"] = _utc_now() + data.setdefault("phaseHistory", []).append( + {"event": "advance_failed", "phase": phase, "reason": reason, "at": _utc_now()} + ) + _write_task(task_dir, data) + + +def _advance_to(task_dir: Path, target: str, evidence: list[str]) -> None: + data = _read_task(task_dir) + old = data.get("phase") + data["phase"] = target + data["status"] = "done" if target == "done" else "in_progress" + data.setdefault("phaseHistory", []).append( + { + "event": "advanced", + "from": old, + "to": target, + "at": _utc_now(), + "evidence": evidence, + } + ) + _write_task(task_dir, data) + + +def _fail_advance(task_dir: Path, phase: str, reason: str) -> int: + _record_advance_failure(task_dir, phase, reason) + print(f"Error: {reason}", file=sys.stderr) + return 1 + + +def _git_lines(root: Path, args: list[str]) -> list[str]: + result = subprocess.run(["git", *args], cwd=root, capture_output=True, text=True) + if result.returncode != 0: + return [] + return [line.strip() for line in result.stdout.splitlines() if line.strip()] + + +def _changed_files(root: Path) -> list[str]: + tracked = _git_lines(root, ["diff", "--name-only", "HEAD", "--"]) + untracked = _git_lines(root, ["ls-files", "--others", "--exclude-standard"]) + return sorted(set(tracked + untracked)) + + +def _sha256_text(text: str) -> str: + return "sha256:" + hashlib.sha256(text.encode("utf-8")).hexdigest() + + +def cmd_create(args: argparse.Namespace) -> int: + root = _find_project_root() + tasks_root = _tasks_root(root) + tasks_root.mkdir(parents=True, exist_ok=True) + + slug = args.slug or _slugify(args.title) + dir_name = f"{_today_prefix()}-{slug}" + task_dir = tasks_root / dir_name + if task_dir.exists(): + print(f"Error: task directory already exists: {dir_name}", file=sys.stderr) + return 1 + + task_dir.mkdir(parents=True) + task_data = { + "id": slug, + "title": args.title, + "description": "", + "status": "in_progress", + "phase": "clarify", + "originIntent": args.origin_intent, + "executionMode": args.execution_mode, + "executionModeFallbackReason": None, + "priority": "P2", + "creator": os.environ.get("USER", "unknown"), + "assignee": os.environ.get("USER", "unknown"), + "createdAt": datetime.now().strftime("%Y-%m-%d"), + "completedAt": None, + "branch": None, + "sourceDoc": args.source_doc, + "sourceDocHash": args.source_hash, + "phaseHistory": [], + "phaseAttempts": {}, + "meta": {}, + } + _write_task(task_dir, task_data) + (task_dir / "clarification.jsonl").write_text("", encoding="utf-8") + + seed = ( + '{"_example":"请添加真实文件引用,例如 ' + '{\\"file\\":\\"docs/standards/index.md\\",\\"reason\\":\\"团队工程规范入口\\"}"}\n' + ) + for role in ("architect", "developer", "tester"): + (task_dir / f"context.{role}.jsonl").write_text(seed, encoding="utf-8") + + session = _read_session(root, _context_key()) + session["current_task"] = _task_ref(task_dir, root) + session["updated_at"] = _utc_now() + _write_session(root, _context_key(), session) + + print(f"✓ Created task: {dir_name}") + print(" Status: in_progress") + print(" Phase: clarify") + print(f" Path: docs/tasks/{dir_name}/") + return 0 + + +def _append_jsonl(path: Path, record: dict) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("a", encoding="utf-8") as fh: + fh.write(json.dumps(record, ensure_ascii=False) + "\n") + + +def _read_jsonl(path: Path) -> list[dict]: + if not path.is_file(): + return [] + records = [] + for idx, line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1): + line = line.strip() + if not line: + continue + try: + record = json.loads(line) + except json.JSONDecodeError as exc: + raise ValueError(f"{path.name}:{idx}: invalid JSON: {exc}") from exc + if isinstance(record, dict): + records.append(record) + return records + + +def _latest_confirmation(task_dir: Path) -> dict | None: + records = _read_jsonl(task_dir / "clarification.jsonl") + confirms = [record for record in records if record.get("event") == "confirm"] + return confirms[-1] if confirms else None + + +def _render_clarification(task_dir: Path, confirmation: dict) -> None: + criteria = "\n".join(f"{idx}. {item}" for idx, item in enumerate(confirmation["acceptanceCriteria"], start=1)) + boundaries = "\n".join(f"{idx}. {item}" for idx, item in enumerate(confirmation["boundaries"], start=1)) + business_contracts = confirmation.get("businessContracts") or [] + contract_section = "" + if business_contracts: + rows = [ + "| 编号 | 业务场景 | 输入条件 | 预期行为 | 可观测信息 | 是否需要测试 |", + "| --- | --- | --- | --- | --- | --- |", + ] + for item in business_contracts: + rows.append( + "| {id} | {scenario} | {input} | {expected} | {observable} | {test_required} |".format( + id=item.get("id", ""), + scenario=item.get("scenario", ""), + input=item.get("input", ""), + expected=item.get("expectedBehavior", ""), + observable=item.get("observable", ""), + test_required="是" if item.get("testRequired", True) else "否", + ) + ) + contract_section = "\n## 业务契约\n\n" + "\n".join(rows) + "\n" + content = f"""--- +confirmation_source: {confirmation["confirmationSource"]} +confirmed: true +confirmed_by: collaborator +open_questions: [] +source_doc: {confirmation["sourceDoc"]} +source_doc_hash: {confirmation["sourceDocHash"]} +business_contracts: {len(business_contracts)} +--- + +# 需求确认 + +## 开发意图 + +{confirmation["developmentIntent"]} + +## 验收标准 + +{criteria} + +## 边界条件 + +{boundaries} +{contract_section} +""" + (task_dir / "clarification.md").write_text(content, encoding="utf-8") + + +def _parse_business_contracts(raw_items: list[str] | None) -> tuple[list[dict], str | None]: + contracts = [] + for idx, raw in enumerate(raw_items or [], start=1): + try: + item = json.loads(raw) + except json.JSONDecodeError as exc: + return [], f"business-contract:{idx}: invalid JSON: {exc}" + if not isinstance(item, dict): + return [], f"business-contract:{idx}: must be a JSON object" + missing = [field for field in BUSINESS_CONTRACT_REQUIRED_FIELDS if not item.get(field)] + if missing: + return [], f"business-contract:{idx}: missing required fields: {', '.join(missing)}" + contracts.append(item) + return contracts, None + + +def cmd_clarify_confirm(args: argparse.Namespace) -> int: + root = _find_project_root() + task_dir = _resolve_task_dir(root, args.task) + business_contracts, parse_error = _parse_business_contracts(args.business_contract) + if parse_error: + print(f"Error: {parse_error}", file=sys.stderr) + return 1 + record = { + "event": "confirm", + "at": _utc_now(), + "confirmationSource": args.confirmation_source, + "sourceDoc": args.source_doc, + "sourceDocHash": args.source_hash or _sha256_text(args.development_intent), + "developmentIntent": args.development_intent.strip(), + "acceptanceCriteria": [item.strip() for item in args.acceptance_criterion if item.strip()], + "boundaries": [item.strip() for item in args.boundary if item.strip()], + "openQuestions": [], + "confirmed": True, + "confirmedBy": "collaborator", + "businessContracts": business_contracts, + } + error = _validate_confirmation(record) + if error: + print(f"Error: {error}", file=sys.stderr) + return 1 + _append_jsonl(task_dir / "clarification.jsonl", record) + _render_clarification(task_dir, record) + print("✓ Requirement clarification confirmed") + return 0 + + +def cmd_clarify_render(args: argparse.Namespace) -> int: + root = _find_project_root() + task_dir = _resolve_task_dir(root, args.task) + confirmation = _latest_confirmation(task_dir) + if not confirmation: + print("Error: no confirmed clarification found", file=sys.stderr) + return 1 + _render_clarification(task_dir, confirmation) + print("✓ Rendered clarification.md") + return 0 + + +def _validate_confirmation(record: dict) -> str | None: + if not record.get("confirmed"): + return "clarification is not confirmed" + if record.get("confirmedBy") != "collaborator": + return "confirmedBy must be collaborator" + if not record.get("developmentIntent"): + return "developmentIntent is required" + if not record.get("acceptanceCriteria"): + return "at least one acceptance criterion is required" + if not record.get("boundaries"): + return "at least one boundary is required" + if record.get("openQuestions"): + return "openQuestions must be empty" + if not record.get("sourceDoc"): + return "sourceDoc is required" + if not record.get("sourceDocHash"): + return "sourceDocHash is required" + return None + + +def _validate_plan_file(path: Path) -> str | None: + if not path.is_file(): + return "missing implementation-plan.md" + content = path.read_text(encoding="utf-8") + for section in PLAN_SECTIONS: + marker = f"## {section}" + if marker not in content: + return f"implementation-plan.md missing section: {section}" + after = content.split(marker, 1)[1] + body = after.split("\n## ", 1)[0].strip() + if not body: + return f"implementation-plan.md section is empty: {section}" + return None + + +def _normalize_patterns(value: object, label: str, required: bool = False) -> list[str]: + if value is None: + if required: + raise ValueError(f"missing required list: {label}") + return [] + if not isinstance(value, list) or not all(isinstance(item, str) and item.strip() for item in value): + raise ValueError(f"{label} must be a list of non-empty strings") + if required and not value: + raise ValueError(f"{label} must contain at least one pattern") + return value + + +def _validate_scope(task_dir: Path) -> str | None: + path = task_dir / "scope.json" + if not path.is_file(): + return "missing scope.json" + try: + scope = _read_json(path, "scope.json") + _normalize_patterns(scope.get("allowed"), "scope.allowed", required=True) + _normalize_patterns(scope.get("denied"), "scope.denied") + except SystemExit: + raise + except ValueError as exc: + return str(exc) + return None + + +def _validate_manifest(root: Path, task_dir: Path, role: str) -> str | None: + path = task_dir / f"context.{role}.jsonl" + if not path.is_file(): + return f"missing context.{role}.jsonl" + try: + records = _read_jsonl(path) + except ValueError as exc: + return str(exc) + valid = [] + for idx, record in enumerate(records, start=1): + if "_example" in record: + continue + file_path = record.get("file") + reason = record.get("reason") + if not file_path or not isinstance(file_path, str): + return f"context.{role}.jsonl:{idx}: file is required" + if any(ch in file_path for ch in "*?[]") or file_path.endswith("/"): + return f"context.{role}.jsonl:{idx}: file must reference a concrete file" + if not reason or not isinstance(reason, str): + return f"context.{role}.jsonl:{idx}: reason is required" + resolved = root / file_path + if not resolved.is_file(): + return f"context.{role}.jsonl:{idx}: referenced file not found: {file_path}" + valid.append(record) + if not valid: + return f"context.{role}.jsonl has no valid file entries" + return None + + +def _validate_test_result(path: Path, expected_key: str) -> tuple[dict | None, str | None]: + if not path.is_file(): + return None, f"missing {path.name}" + data = _read_json(path, path.name) + if data.get(expected_key) is not True: + return None, f"{path.name}.{expected_key} must be true" + target_tests = data.get("targetTests") + if not isinstance(target_tests, list) or not target_tests: + return None, f"{path.name}.targetTests must be non-empty" + return data, None + + +def _advance_doc_plan(root: Path, task_dir: Path) -> int: + confirmation = _latest_confirmation(task_dir) + if not confirmation: + return _fail_advance(task_dir, "doc-plan", "confirmed clarification is required") + error = _validate_confirmation(confirmation) + if error: + return _fail_advance(task_dir, "doc-plan", error) + data = _read_task(task_dir) + data["sourceDoc"] = confirmation["sourceDoc"] + data["sourceDocHash"] = confirmation["sourceDocHash"] + _write_task(task_dir, data) + _advance_to(task_dir, "doc-plan", [_task_ref(task_dir / "clarification.jsonl", root)]) + print("✓ Advanced to doc-plan") + return 0 + + +def _advance_red(root: Path, task_dir: Path) -> int: + for error in ( + _validate_plan_file(task_dir / "implementation-plan.md"), + _validate_scope(task_dir), + _validate_manifest(root, task_dir, "architect"), + _validate_manifest(root, task_dir, "developer"), + _validate_manifest(root, task_dir, "tester"), + ): + if error: + return _fail_advance(task_dir, "red", error) + _advance_to( + task_dir, + "red", + [ + _task_ref(task_dir / "implementation-plan.md", root), + _task_ref(task_dir / "scope.json", root), + _task_ref(task_dir / "context.architect.jsonl", root), + _task_ref(task_dir / "context.developer.jsonl", root), + _task_ref(task_dir / "context.tester.jsonl", root), + ], + ) + print("✓ Advanced to red") + return 0 + + +def _advance_green(root: Path, task_dir: Path) -> int: + _, error = _validate_test_result(task_dir / "test-result.red.json", "expectedFailureObserved") + if error: + return _fail_advance(task_dir, "green", error) + _advance_to(task_dir, "green", [_task_ref(task_dir / "test-result.red.json", root)]) + print("✓ Advanced to green") + return 0 + + +def _advance_review(root: Path, task_dir: Path) -> int: + red, error = _validate_test_result(task_dir / "test-result.red.json", "expectedFailureObserved") + if error: + return _fail_advance(task_dir, "review", error) + green, error = _validate_test_result(task_dir / "test-result.green.json", "expectedPassObserved") + if error: + return _fail_advance(task_dir, "review", error) + if red and green and red.get("targetTests") != green.get("targetTests"): + return _fail_advance(task_dir, "review", "RED and GREEN targetTests differ") + _advance_to(task_dir, "review", [_task_ref(task_dir / "test-result.green.json", root)]) + print("✓ Advanced to review") + return 0 + + +def _review_passed(data: dict) -> bool: + business = data.get("businessContractCoverage", {}) + return ( + data.get("specCompliance", {}).get("status") == "passed" + and data.get("codeQuality", {}).get("status") == "passed" + and business.get("status") == "passed" + and not business.get("missing") + and not data.get("blockingIssues") + ) + + +def _advance_validate(root: Path, task_dir: Path) -> int: + path = task_dir / "review-result.json" + if not path.is_file(): + return _fail_advance(task_dir, "validate", "missing review-result.json") + review = _read_json(path, "review-result.json") + if not _review_passed(review): + return _fail_advance(task_dir, "validate", "review-result.json has blocking issues or business contract review failed") + if sorted(review.get("changedFiles", [])) != _changed_files(root): + return _fail_advance(task_dir, "validate", "working tree changed after review") + _advance_to(task_dir, "validate", [_task_ref(path, root)]) + print("✓ Advanced to validate") + return 0 + + +def _advance_done(root: Path, task_dir: Path) -> int: + path = task_dir / "verify-result.json" + if not path.is_file(): + return _fail_advance(task_dir, "done", "missing verify-result.json") + verify = _read_json(path, "verify-result.json") + if verify.get("success") is not True: + return _fail_advance(task_dir, "done", "verify-result.json success must be true") + if sorted(verify.get("changedFiles", [])) != _changed_files(root): + return _fail_advance(task_dir, "done", "working tree changed after final verify") + _advance_to(task_dir, "done", [_task_ref(path, root)]) + print("✓ Advanced to done") + return 0 + + +def cmd_advance(args: argparse.Namespace) -> int: + root = _find_project_root() + task_dir = _resolve_task_dir(root, args.task) + target = args.phase + handlers = { + "doc-plan": _advance_doc_plan, + "red": _advance_red, + "green": _advance_green, + "review": _advance_review, + "validate": _advance_validate, + "done": _advance_done, + } + handler = handlers[target] + return handler(root, task_dir) + + +def cmd_review_record(args: argparse.Namespace) -> int: + root = _find_project_root() + task_dir = _resolve_task_dir(root, args.task) + blocking = args.blocking_issue or [] + record = { + "generatedBy": "task.py review record", + "baseRef": "HEAD", + "headRef": "working-tree", + "changedFiles": _changed_files(root), + "specCompliance": {"status": args.spec_compliance, "issues": []}, + "codeQuality": {"status": args.code_quality, "issues": []}, + "businessContractCoverage": { + "status": args.business_contract_status, + "missing": args.missing_contract or [], + }, + "blockingIssues": blocking, + "summary": args.summary or "", + "finishedAt": _utc_now(), + } + _write_json(task_dir / "review-result.json", record) + print("✓ Recorded review-result.json") + return 0 + + +def cmd_intent_set(args: argparse.Namespace) -> int: + root = _find_project_root() + task_dir = _resolve_task_dir(root, args.task) + data = _read_task(task_dir) + old = data.get("originIntent") + data["originIntent"] = args.intent + data.setdefault("phaseHistory", []).append( + { + "event": "origin_intent_updated", + "from": old, + "to": args.intent, + "at": _utc_now(), + "reason": args.reason, + } + ) + _write_task(task_dir, data) + print(f"✓ originIntent set to {args.intent}") + return 0 + + +def cmd_current(args: argparse.Namespace) -> int: + root = _find_project_root() + session = _read_session(root, _context_key()) + task_ref = session.get("current_task") + if not task_ref: + print("No active task") + return 0 + task_dir = _resolve_task_dir(root, task_ref) + data = _read_task(task_dir) + print(f"Task: {data.get('title', task_dir.name)}") + print(f"Status: {data.get('status', 'unknown')}") + print(f"Phase: {data.get('phase', 'unknown')}") + print(f"ExecutionMode: {data.get('executionMode', 'unknown')}") + print(f"Path: {task_ref}") + return 0 + + +def cmd_finish(args: argparse.Namespace) -> int: + root = _find_project_root() + session_path = _session_path(root, _context_key()) + if session_path.is_file(): + session_path.unlink() + print("✓ Session cleared") + return 0 + + +def cmd_archive(args: argparse.Namespace) -> int: + root = _find_project_root() + task_dir = _resolve_task_dir(root, args.dir) + data = _read_task(task_dir) + if data.get("phase") != "done": + print("Error: task must have phase=done before archive", file=sys.stderr) + return 1 + verify = _read_json(task_dir / "verify-result.json", "verify-result.json") + if verify.get("success") is not True: + print("Error: verify-result.json success must be true", file=sys.stderr) + return 1 + + data["status"] = "archived" + data["phase"] = "archived" + data["completedAt"] = datetime.now().strftime("%Y-%m-%d") + data.setdefault("phaseHistory", []).append({"event": "archived", "at": _utc_now()}) + _write_task(task_dir, data) + + archive_month = datetime.now().strftime("%Y-%m") + archive_dir = _tasks_root(root) / "archive" / archive_month + archive_dir.mkdir(parents=True, exist_ok=True) + dest = archive_dir / task_dir.name + shutil.move(str(task_dir), str(dest)) + + session_path = _session_path(root, _context_key()) + if session_path.is_file(): + session = _read_json(session_path, "session") + if session.get("current_task") == _task_ref(task_dir, root): + session_path.unlink() + + print(f"✓ Archived: {task_dir.name} -> docs/tasks/archive/{archive_month}/") + return 0 + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Harness task management") + sub = parser.add_subparsers(dest="command") + + p_create = sub.add_parser("create", help="Create a new clarify-phase task") + p_create.add_argument("title") + p_create.add_argument("--slug") + p_create.add_argument("--origin-intent", choices=VALID_INTENTS, default="requirement-development") + p_create.add_argument("--execution-mode", choices=VALID_EXECUTION_MODES, default="agent-team") + p_create.add_argument("--source-doc", default="inline-request") + p_create.add_argument("--source-hash") + + p_clarify = sub.add_parser("clarify", help="Manage requirement clarification") + clarify_sub = p_clarify.add_subparsers(dest="clarify_command") + p_confirm = clarify_sub.add_parser("confirm", help="Record confirmed requirement clarification") + p_confirm.add_argument("--task") + p_confirm.add_argument("--development-intent", required=True) + p_confirm.add_argument("--acceptance-criterion", action="append", required=True) + p_confirm.add_argument("--boundary", action="append", required=True) + p_confirm.add_argument("--business-contract", action="append") + p_confirm.add_argument("--source-doc", default="inline-request") + p_confirm.add_argument("--source-hash") + p_confirm.add_argument("--confirmation-source", choices=("live", "imported"), default="live") + p_render = clarify_sub.add_parser("render", help="Render clarification.md from jsonl") + p_render.add_argument("--task") + + p_advance = sub.add_parser("advance", help="Advance to the next phase") + p_advance.add_argument("phase", choices=PHASE_ORDER[1:]) + p_advance.add_argument("--task") + + p_review = sub.add_parser("review", help="Record review evidence") + review_sub = p_review.add_subparsers(dest="review_command") + p_record = review_sub.add_parser("record") + p_record.add_argument("--task") + p_record.add_argument("--spec-compliance", choices=("passed", "failed", "not_started"), required=True) + p_record.add_argument("--code-quality", choices=("passed", "failed", "not_started"), required=True) + p_record.add_argument("--business-contract-status", choices=("passed", "failed", "not_started"), default="passed") + p_record.add_argument("--missing-contract", action="append") + p_record.add_argument("--blocking-issue", action="append") + p_record.add_argument("--summary") + + p_intent = sub.add_parser("intent", help="Manage origin intent") + intent_sub = p_intent.add_subparsers(dest="intent_command") + p_intent_set = intent_sub.add_parser("set") + p_intent_set.add_argument("intent", choices=VALID_INTENTS) + p_intent_set.add_argument("--task") + p_intent_set.add_argument("--reason", default="collaborator requested intent change") + + sub.add_parser("current", help="Show active task") + sub.add_parser("finish", help="Clear active task pointer") + + p_archive = sub.add_parser("archive", help="Archive a done task") + p_archive.add_argument("dir") + return parser + + +def main() -> int: + parser = build_parser() + args = parser.parse_args() + if args.command == "create": + return cmd_create(args) + if args.command == "clarify" and args.clarify_command == "confirm": + return cmd_clarify_confirm(args) + if args.command == "clarify" and args.clarify_command == "render": + return cmd_clarify_render(args) + if args.command == "advance": + return cmd_advance(args) + if args.command == "review" and args.review_command == "record": + return cmd_review_record(args) + if args.command == "intent" and args.intent_command == "set": + return cmd_intent_set(args) + if args.command == "current": + return cmd_current(args) + if args.command == "finish": + return cmd_finish(args) + if args.command == "archive": + return cmd_archive(args) + parser.print_help() + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.harness/scripts/team_cleanup.py b/.harness/scripts/team_cleanup.py new file mode 100644 index 0000000..1255995 --- /dev/null +++ b/.harness/scripts/team_cleanup.py @@ -0,0 +1,151 @@ +#!/usr/bin/env python3 +"""Team cleanup — kill leftover sub-agent processes and remove team config. + +Usage: team_cleanup.py [--dry-run] + +Why this exists: + Claude Code's TeamDelete only removes the team config directory + worktree. + The actual sub-agent Claude processes survive and accumulate. This script + finds them by matching team-name in their command line, sends SIGTERM, then + SIGKILL after a short grace, and removes the team config dir. + +Safety: + - --dry-run lists targets without killing. + - Searches for `` in process command lines, which should be specific + enough to avoid collateral damage. Use a unique team name in production. + - Reports survivors after kill attempt; exits 2 if any remain. +""" +from __future__ import annotations + +import argparse +import os +import shutil +import signal +import subprocess +import sys +import time +from pathlib import Path + + +def find_team_config(team_name: str) -> Path | None: + """Locate the team config directory under HOME/.claude/teams/.""" + candidates = [ + Path.home() / ".claude" / "teams" / team_name, + ] + for path in candidates: + if path.is_dir(): + return path + return None + + +def find_team_processes(team_name: str) -> list[int]: + """Use pgrep -fl to find PIDs whose command line contains team_name. + + Returns sorted list of PIDs. Excludes the current process. + """ + try: + result = subprocess.run( + ["pgrep", "-fl", team_name], + capture_output=True, + text=True, + check=False, + ) + except FileNotFoundError: + return [] + + pids: list[int] = [] + self_pid = os.getpid() + for line in result.stdout.strip().splitlines(): + if not line: + continue + try: + pid_str = line.split(maxsplit=1)[0] + pid = int(pid_str) + except ValueError: + continue + if pid == self_pid: + continue + pids.append(pid) + return sorted(pids) + + +def kill_processes(pids: list[int], dry_run: bool) -> None: + """SIGTERM, wait, SIGKILL survivors.""" + if dry_run: + for pid in pids: + print(f" [dry-run] would SIGTERM {pid}") + return + + sigterm_sent: list[int] = [] + for pid in pids: + try: + os.kill(pid, signal.SIGTERM) + sigterm_sent.append(pid) + except ProcessLookupError: + pass + except PermissionError: + print(f" ⚠ permission denied for PID {pid}", file=sys.stderr) + + if not sigterm_sent: + return + + time.sleep(2) + + for pid in sigterm_sent: + try: + os.kill(pid, 0) # check if alive + os.kill(pid, signal.SIGKILL) + print(f" SIGKILL {pid} (survived SIGTERM)") + except ProcessLookupError: + pass + + +def remove_config(config_dir: Path | None, dry_run: bool) -> None: + if config_dir is None: + return + if dry_run: + print(f" [dry-run] would rm -rf {config_dir}") + return + shutil.rmtree(config_dir, ignore_errors=True) + if not config_dir.exists(): + print(f" ✓ removed {config_dir}") + + +def main() -> int: + parser = argparse.ArgumentParser(description="Cleanup leftover team processes + config") + parser.add_argument("team_name", help="Team name (matches in process cmdline + config path)") + parser.add_argument("--dry-run", action="store_true", help="List targets without killing") + args = parser.parse_args() + + team_name = args.team_name + print(f"Cleaning team: {team_name}{' (dry-run)' if args.dry_run else ''}") + + config = find_team_config(team_name) + if config: + print(f" config: {config}") + else: + print(" config: not found (already removed?)") + + pids = find_team_processes(team_name) + if pids: + print(f" processes: {len(pids)} found — {pids}") + else: + print(" processes: none found") + + kill_processes(pids, dry_run=args.dry_run) + remove_config(config, dry_run=args.dry_run) + + if args.dry_run: + return 0 + + survivors = find_team_processes(team_name) + if survivors: + print(f" ⚠ survivors after kill: {survivors}", file=sys.stderr) + return 2 + + print(" ✓ all clean") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.harness/scripts/verify.py b/.harness/scripts/verify.py new file mode 100644 index 0000000..b791fa8 --- /dev/null +++ b/.harness/scripts/verify.py @@ -0,0 +1,332 @@ +#!/usr/bin/env python3 +"""Run harness quality checks and write phase evidence files.""" + +from __future__ import annotations + +import argparse +import fnmatch +import json +import os +import subprocess +import sys +from datetime import datetime, timezone +from pathlib import Path + + +COMMAND_CHECKS = ("lint", "type", "test", "coverage") +ALL_CHECKS = ("all", *COMMAND_CHECKS, "scope", "red", "green") +LOCAL_CONTEXT_KEY = "local" + + +def utc_now() -> str: + return datetime.now(timezone.utc).isoformat() + + +def find_project_root(start: Path) -> Path: + current = start.resolve() + while current != current.parent: + if (current / ".harness").is_dir(): + return current + current = current.parent + script_path = Path(__file__).resolve() + if script_path.parent.name == "scripts" and script_path.parent.parent.name == ".harness": + return script_path.parent.parent.parent + print("Error: .harness/ directory not found", file=sys.stderr) + sys.exit(2) + + +def load_json(path: Path, label: str) -> dict: + if not path.is_file(): + print(f"Error: missing {label}: {path}", file=sys.stderr) + sys.exit(2) + try: + data = json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + print(f"Error: invalid {label}: {path}: {exc}", file=sys.stderr) + sys.exit(2) + if not isinstance(data, dict): + print(f"Error: {label} must be a JSON object: {path}", file=sys.stderr) + sys.exit(2) + return data + + +def write_json(path: Path, data: dict) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") + + +def load_config(root: Path) -> dict: + return load_json(root / ".harness" / "verify.json", "verify config") + + +def command_for(config: dict, name: str) -> str | None: + commands = config.get("commands") + if not isinstance(commands, dict): + return None + command = commands.get(name) + if isinstance(command, str) and command.strip(): + return command + return None + + +def required_checks(config: dict) -> list[str]: + value = config.get("required", ["test", "scope"]) + if not isinstance(value, list) or not all(isinstance(item, str) for item in value): + print("Error: required must be a list of check names", file=sys.stderr) + sys.exit(2) + allowed = set(COMMAND_CHECKS + ("scope",)) + unknown = [item for item in value if item not in allowed] + if unknown: + print(f"Error: unknown required checks: {', '.join(unknown)}", file=sys.stderr) + sys.exit(2) + return value + + +def run_configured_command(root: Path, config: dict, name: str) -> dict: + command = command_for(config, name) + if command is None: + print(f"Error: missing required command config: commands.{name}", file=sys.stderr) + return {"name": name, "command": None, "exitCode": 2, "success": False} + + print(f"==> {name}: {command}") + result = subprocess.run(command, cwd=root, shell=True, capture_output=True, text=True) + if result.returncode == 0: + print(f"✓ {name} passed") + else: + print(f"✗ {name} failed with exit code {result.returncode}", file=sys.stderr) + return { + "name": name, + "command": command, + "exitCode": result.returncode, + "success": result.returncode == 0, + "stdout": result.stdout[-4000:], + "stderr": result.stderr[-4000:], + } + + +def context_key() -> str: + return os.environ.get("HARNESS_CONTEXT_ID") or LOCAL_CONTEXT_KEY + + +def resolve_task_dir(root: Path, task_arg: str | None) -> Path: + if task_arg: + task_ref = task_arg + else: + session_path = root / ".harness" / "runtime" / "sessions" / f"{context_key()}.json" + if not session_path.is_file(): + print(f"Error: no active task session found: {session_path}. Pass --task .", file=sys.stderr) + sys.exit(2) + session = load_json(session_path, "session") + task_ref = session.get("current_task") + if not isinstance(task_ref, str) or not task_ref: + print("Error: current session has no active task. Pass --task .", file=sys.stderr) + sys.exit(2) + + path = Path(task_ref) + if path.is_absolute(): + task_dir = path + elif task_ref.startswith("docs/tasks/"): + task_dir = root / path + else: + task_dir = root / "docs" / "tasks" / task_ref + + if not task_dir.is_dir(): + print(f"Error: task directory not found: {task_ref}", file=sys.stderr) + sys.exit(2) + return task_dir + + +def run_git(root: Path, args: list[str]) -> list[str]: + result = subprocess.run(["git", *args], cwd=root, capture_output=True, text=True) + if result.returncode != 0: + return [] + return [line.strip() for line in result.stdout.splitlines() if line.strip()] + + +def changed_files(root: Path) -> list[str]: + tracked = run_git(root, ["diff", "--name-only", "HEAD", "--"]) + untracked = run_git(root, ["ls-files", "--others", "--exclude-standard"]) + return sorted(set(tracked + untracked)) + + +def normalize_patterns(value: object, label: str, *, required: bool = False) -> list[str]: + if value is None: + if required: + raise ValueError(f"missing required list: {label}") + return [] + if not isinstance(value, list) or not all(isinstance(item, str) and item.strip() for item in value): + raise ValueError(f"{label} must be a list of non-empty strings") + if required and not value: + raise ValueError(f"{label} must contain at least one pattern") + return value + + +def matches_pattern(path: str, pattern: str) -> bool: + pattern = pattern.strip() + if pattern.endswith("/"): + pattern = f"{pattern}**" + plain = pattern.rstrip("/") + if not any(ch in pattern for ch in "*?[]"): + return path == plain or path.startswith(f"{plain}/") + return fnmatch.fnmatchcase(path, pattern) + + +def matches_any(path: str, patterns: list[str]) -> bool: + return any(matches_pattern(path, pattern) for pattern in patterns) + + +def run_scope(root: Path, config: dict, task_arg: str | None) -> dict: + task_dir = resolve_task_dir(root, task_arg) + scope = load_json(task_dir / "scope.json", "task scope") + global_scope = config.get("scope", {}) + if not isinstance(global_scope, dict): + return {"success": False, "errors": ["scope in verify config must be an object"]} + + try: + allowed = normalize_patterns(scope.get("allowed"), "scope.allowed", required=True) + task_denied = normalize_patterns(scope.get("denied"), "scope.denied") + global_denied = normalize_patterns(global_scope.get("denied"), "verify.scope.denied") + except ValueError as exc: + return {"success": False, "errors": [str(exc)]} + + denied = [*global_denied, *task_denied] + files = changed_files(root) + errors = [] + for path in files: + if matches_any(path, denied): + errors.append(f"denied changed file: {path}") + elif not matches_any(path, allowed): + errors.append(f"changed file not allowed by task scope: {path}") + + if errors: + for error in errors: + print(f"Error: {error}", file=sys.stderr) + else: + print(f"✓ scope passed: {len(files)} changed file(s)") + return {"success": not errors, "changedFiles": files, "errors": errors} + + +def write_verify_result(task_dir: Path, result: dict) -> None: + write_json(task_dir / "verify-result.json", result) + + +def run_all(root: Path, config: dict, task_arg: str | None) -> int: + task_dir = resolve_task_dir(root, task_arg) + required = required_checks(config) + command_results = [] + success = True + + for check in required: + if check == "scope": + continue + result = run_configured_command(root, config, check) + command_results.append(result) + if not result["success"]: + success = False + + scope_result = {"success": True, "changedFiles": changed_files(root), "errors": []} + if "scope" in required: + scope_result = run_scope(root, config, task_arg) + if not scope_result["success"]: + success = False + + output = { + "success": success, + "commands": command_results, + "scope": scope_result, + "changedFiles": changed_files(root), + "finishedAt": utc_now(), + "generatedBy": "verify.py all", + } + write_verify_result(task_dir, output) + return 0 if success else 1 + + +def run_red_green(root: Path, args: argparse.Namespace, *, mode: str) -> int: + task_dir = resolve_task_dir(root, args.task) + if not args.target_test: + print("Error: at least one --target-test is required", file=sys.stderr) + return 2 + result = subprocess.run(args.command, cwd=root, shell=True, capture_output=True, text=True) + expected_failure = mode == "red" and result.returncode != 0 + expected_pass = mode == "green" and result.returncode == 0 + success = expected_failure if mode == "red" else expected_pass + payload = { + "success": success, + "command": args.command, + "exitCode": result.returncode, + "targetTests": args.target_test, + "stdout": result.stdout[-4000:], + "stderr": result.stderr[-4000:], + "contractCoverage": parse_contract_coverage(args.contract_coverage), + "uncoveredContracts": args.uncovered_contract or [], + "finishedAt": utc_now(), + "generatedBy": f"verify.py {mode}", + } + if mode == "red": + payload["expectedFailureObserved"] = expected_failure + filename = "test-result.red.json" + else: + payload["expectedPassObserved"] = expected_pass + filename = "test-result.green.json" + write_json(task_dir / filename, payload) + if success: + print(f"✓ {mode} evidence written: {filename}") + return 0 + print(f"Error: {mode} command did not meet expected result", file=sys.stderr) + return 1 + + +def parse_contract_coverage(values: list[str] | None) -> dict[str, list[str]]: + coverage: dict[str, list[str]] = {} + for raw in values or []: + if "=" not in raw: + print(f"Error: invalid contract coverage mapping: {raw}", file=sys.stderr) + sys.exit(2) + contract_id, tests = raw.split("=", 1) + contract_id = contract_id.strip() + target_tests = [item.strip() for item in tests.split(",") if item.strip()] + if not contract_id or not target_tests: + print(f"Error: invalid contract coverage mapping: {raw}", file=sys.stderr) + sys.exit(2) + coverage.setdefault(contract_id, []) + for target_test in target_tests: + if target_test not in coverage[contract_id]: + coverage[contract_id].append(target_test) + return coverage + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Run harness quality checks") + parser.add_argument("check", choices=ALL_CHECKS) + parser.add_argument("--task", help="Task dir name or docs/tasks/ for checks") + parser.add_argument("--command", help="Command for red/green evidence") + parser.add_argument("--target-test", action="append", help="Target test identifier for red/green evidence") + parser.add_argument("--contract-coverage", action="append", help="Business contract coverage mapping, for example BC-001=TestA,TestB") + parser.add_argument("--uncovered-contract", action="append", help="Business contract id without a current test mapping") + return parser.parse_args() + + +def main() -> int: + args = parse_args() + root = find_project_root(Path.cwd()) + config = load_config(root) + + if args.check == "all": + return run_all(root, config, args.task) + if args.check in COMMAND_CHECKS: + result = run_configured_command(root, config, args.check) + return result["exitCode"] + if args.check == "scope": + result = run_scope(root, config, args.task) + return 0 if result["success"] else 1 + if args.check in ("red", "green"): + if not args.command: + print(f"Error: verify.py {args.check} requires --command", file=sys.stderr) + return 2 + return run_red_green(root, args, mode=args.check) + return 2 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.harness/verify.json b/.harness/verify.json new file mode 100644 index 0000000..7c638d1 --- /dev/null +++ b/.harness/verify.json @@ -0,0 +1,14 @@ +{ + "required": ["test", "scope"], + "commands": { + "lint": "", + "type": "", + "test": "python3 -m unittest discover tests", + "coverage": "" + }, + "scope": { + "denied": [ + ".harness/runtime/**" + ] + } +} diff --git a/.harness/workflow.md b/.harness/workflow.md new file mode 100644 index 0000000..9a19d8e --- /dev/null +++ b/.harness/workflow.md @@ -0,0 +1,37 @@ +# Harness 阶段说明 + +[workflow-phase:no_task] +当前没有激活的需求开发任务。收到需求开发请求时,先进入 `requirement-confirmation`,确认开发意图、验收标准和范围边界。 +[/workflow-phase:no_task] + +[workflow-phase:clarify] +当前处于需求确认阶段。有效门禁是 `clarification.jsonl` 中最近一条 `event=confirm` 记录,且 `openQuestions=[]`、`confirmed=true`、`confirmedBy=collaborator`。 +[/workflow-phase:clarify] + +[workflow-phase:doc-plan] +当前处于实现计划阶段。只允许调用 `architect`,生成 `implementation-plan.md`、`scope.json`,并补齐三份 `context..jsonl` 的真实文件引用。 +[/workflow-phase:doc-plan] + +[workflow-phase:red] +当前处于 RED 阶段。只允许调用 `tester`,目标是写出预期失败测试,并通过 `verify.py red` 写入 `test-result.red.json`。 +[/workflow-phase:red] + +[workflow-phase:green] +当前处于 GREEN 阶段。只允许调用 `developer`,目标是让 RED 阶段同一组目标测试通过,并通过 `verify.py green` 写入 `test-result.green.json`。 +[/workflow-phase:green] + +[workflow-phase:review] +当前处于 REVIEW 阶段。只允许调用 `architect`,检查需求符合性和代码质量,并通过 `task.py review record` 写入 `review-result.json`。 +[/workflow-phase:review] + +[workflow-phase:validate] +当前处于 VALIDATE 阶段。只允许调用 `tester`,补充验证后由主会话运行 `verify.py all` 写入 `verify-result.json`。 +[/workflow-phase:validate] + +[workflow-phase:done] +任务已经完成验证,可以归档。 +[/workflow-phase:done] + +[workflow-phase:archived] +任务已经归档。 +[/workflow-phase:archived] diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..826fa4d --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,64 @@ +# Agent Harness + +本项目使用 harness 支持流程化需求开发。协作者使用自然语言表达任务,模型通过内部脚本维护状态和证据文件。 + +## 自然语言入口 + +| 表达 | 处理方式 | +| --- | --- | +| 按 `design.md` 开发 | 先进入 `requirement-confirmation`,确认后再进入 `requirement-development` | +| 继续需求开发 | 读取当前任务并推进下一阶段 | +| 查看当前需求开发状态 | 读取 `task.json` 的 `status` 和 `phase` | +| 归档当前任务 | 在 `phase=done` 后移动到 `docs/tasks/archive/` | + +## 目录约定 + +任务包保存在 `docs/tasks//`。团队工程规范保存在 `docs/standards/`。`docs/index.md` 记录这些目录用途。 + +## 阶段顺序 + +`task.json.status` 表示任务大状态,`task.json.phase` 表示细阶段。阶段顺序固定为: + +```text +clarify -> doc-plan -> red -> green -> review -> validate -> done -> archived +``` + +阶段推进只能通过 `python3 .harness/scripts/task.py advance ` 完成。 + +## 需求确认 + +需求开发前必须先完成 `requirement-confirmation`。即使需求文档完整,也要复述开发意图、验收标准和边界条件,并等待协作者确认。 + +`clarification.jsonl` 是需求确认门禁依据,`clarification.md` 是阅读快照。 + +需求确认中可以记录业务契约。业务契约用于保存业务场景、输入条件、预期行为、可观测信息和测试要求,使业务细节能够进入后续计划、测试和审查。 + +## 业务契约 + +业务契约在各阶段的使用方式如下: + +| 阶段 | 契约要求 | +| --- | --- | +| `clarify` | `clarification.jsonl` 可记录 `businessContracts` | +| `doc-plan` | `implementation-plan.md` 必须包含 `业务契约覆盖` | +| `red` | `test-result.red.json` 通过 `contractCoverage` 记录测试映射 | +| `green` | `test-result.green.json` 继续记录同一批契约的实现验证 | +| `review` | `review-result.json` 通过 `businessContractCoverage` 记录审查结果 | + +## 角色职责 + +| 阶段 | 角色 | 主要产物 | +| --- | --- | --- | +| `doc-plan` | `architect` | `implementation-plan.md`、`scope.json` | +| `red` | `tester` | `test-result.red.json` | +| `green` | `developer` | `test-result.green.json` | +| `review` | `architect` | `review-result.json` | +| `validate` | `tester` | `verify-result.json` | + +hooks 会根据 `phase` 限制角色调用,并注入 `docs/standards/index.md`、`clarification.md`、`implementation-plan.md` 和角色专属 `context..jsonl`。 + +## 受控文件 + +以下文件只能由 harness 内部工具生成或更新:`task.json`、`clarification.jsonl`、`clarification.md`、`test-result.red.json`、`test-result.green.json`、`review-result.json`、`verify-result.json`。 + +主会话负责最终验证和 git 提交,子代理负责阶段内专业任务。 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..826fa4d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,64 @@ +# Agent Harness + +本项目使用 harness 支持流程化需求开发。协作者使用自然语言表达任务,模型通过内部脚本维护状态和证据文件。 + +## 自然语言入口 + +| 表达 | 处理方式 | +| --- | --- | +| 按 `design.md` 开发 | 先进入 `requirement-confirmation`,确认后再进入 `requirement-development` | +| 继续需求开发 | 读取当前任务并推进下一阶段 | +| 查看当前需求开发状态 | 读取 `task.json` 的 `status` 和 `phase` | +| 归档当前任务 | 在 `phase=done` 后移动到 `docs/tasks/archive/` | + +## 目录约定 + +任务包保存在 `docs/tasks//`。团队工程规范保存在 `docs/standards/`。`docs/index.md` 记录这些目录用途。 + +## 阶段顺序 + +`task.json.status` 表示任务大状态,`task.json.phase` 表示细阶段。阶段顺序固定为: + +```text +clarify -> doc-plan -> red -> green -> review -> validate -> done -> archived +``` + +阶段推进只能通过 `python3 .harness/scripts/task.py advance ` 完成。 + +## 需求确认 + +需求开发前必须先完成 `requirement-confirmation`。即使需求文档完整,也要复述开发意图、验收标准和边界条件,并等待协作者确认。 + +`clarification.jsonl` 是需求确认门禁依据,`clarification.md` 是阅读快照。 + +需求确认中可以记录业务契约。业务契约用于保存业务场景、输入条件、预期行为、可观测信息和测试要求,使业务细节能够进入后续计划、测试和审查。 + +## 业务契约 + +业务契约在各阶段的使用方式如下: + +| 阶段 | 契约要求 | +| --- | --- | +| `clarify` | `clarification.jsonl` 可记录 `businessContracts` | +| `doc-plan` | `implementation-plan.md` 必须包含 `业务契约覆盖` | +| `red` | `test-result.red.json` 通过 `contractCoverage` 记录测试映射 | +| `green` | `test-result.green.json` 继续记录同一批契约的实现验证 | +| `review` | `review-result.json` 通过 `businessContractCoverage` 记录审查结果 | + +## 角色职责 + +| 阶段 | 角色 | 主要产物 | +| --- | --- | --- | +| `doc-plan` | `architect` | `implementation-plan.md`、`scope.json` | +| `red` | `tester` | `test-result.red.json` | +| `green` | `developer` | `test-result.green.json` | +| `review` | `architect` | `review-result.json` | +| `validate` | `tester` | `verify-result.json` | + +hooks 会根据 `phase` 限制角色调用,并注入 `docs/standards/index.md`、`clarification.md`、`implementation-plan.md` 和角色专属 `context..jsonl`。 + +## 受控文件 + +以下文件只能由 harness 内部工具生成或更新:`task.json`、`clarification.jsonl`、`clarification.md`、`test-result.red.json`、`test-result.green.json`、`review-result.json`、`verify-result.json`。 + +主会话负责最终验证和 git 提交,子代理负责阶段内专业任务。 diff --git a/README.md b/README.md index 5731d49..bcccd62 100644 --- a/README.md +++ b/README.md @@ -19,11 +19,11 @@ HARNESS_TARGET=/path/to/project \ curl -fsSL https://git.xiaojukeji.com/morganli/harness/raw/master/install-internal.sh | bash ``` -跳过 RTK 和 Caveman 自动安装: +跳过 RTK、Caveman 和 Cooper 自动安装: ```bash curl -fsSL https://git.xiaojukeji.com/morganli/harness/raw/master/install-internal.sh \ - | bash -s -- --no-rtk --no-caveman + | bash -s -- --no-rtk --no-caveman --no-cooper ``` ## 安装产物 @@ -46,6 +46,8 @@ curl -fsSL https://git.xiaojukeji.com/morganli/harness/raw/master/install-intern | `.claude/skills/requirement-development/SKILL.md` | 需求开发 skill | | `.claude/skills/grill-me/SKILL.md` | 旧名称兼容入口,转入需求确认 | | `.claude/skills/harness-implement/SKILL.md` | 旧名称兼容入口,转入需求开发 | +| `.claude/skills/cooper/SKILL.md` | Cooper 项目级 skill,默认通过 SkillsHub 安装 | +| `~/.codex/skills/cooper/SKILL.md` | Cooper Codex 全局 skill,默认通过 SkillsHub 安装 | | `CLAUDE.md`、`AGENTS.md` | 给模型读取的项目协作规则 | ## 使用方式 @@ -108,6 +110,7 @@ clarify -> doc-plan -> red -> green -> review -> validate -> done -> archived ## 影响范围 ## 技术方案 ## 可测试契约 +## 业务契约覆盖 ## Slice 顺序 ## 验证方式 ## 已知限制 @@ -115,6 +118,20 @@ clarify -> doc-plan -> red -> green -> review -> validate -> done -> archived 需求原文、长背景和讨论记录保存在需求来源文件与 `clarification.jsonl` 中。 +## 业务契约 + +业务契约用于把日常业务需求中的细节转成可测试、可审查的记录。每条契约描述一个业务场景,包含输入条件、预期行为、可观测信息和测试要求。 + +业务契约在各阶段的使用方式如下: + +| 阶段 | 契约要求 | +| --- | --- | +| `clarify` | `clarification.jsonl` 可记录 `businessContracts` | +| `doc-plan` | `implementation-plan.md` 必须包含 `业务契约覆盖` | +| `red` | `test-result.red.json` 通过 `contractCoverage` 记录测试映射 | +| `green` | `test-result.green.json` 继续记录契约验证结果 | +| `review` | `review-result.json` 通过 `businessContractCoverage` 记录审查结果 | + ## 三个角色 | 角色 | 阶段 | 职责 | diff --git a/docs/harness-enterprise-roadmap.md b/docs/harness-enterprise-roadmap.md index 69e5e75..edc4915 100644 --- a/docs/harness-enterprise-roadmap.md +++ b/docs/harness-enterprise-roadmap.md @@ -53,10 +53,10 @@ Harness │ ├── validate │ └── done ├── 项目知识 -│ ├── docs/dev-guide.md -│ ├── docs/api-url-index.md -│ ├── docs/api-detail.md -│ └── docs/standards/ +│ ├── docs/standards/project-guide.md +│ ├── docs/standards/api/url-index.md +│ ├── docs/standards/api/detail.md +│ └── .harness/project-profile.json ├── 质量证据 │ ├── clarification.jsonl │ ├── implementation-plan.md @@ -101,16 +101,291 @@ Harness | `.harness/install-state.json` | 记录 Harness 版本、安装时间、安装来源、写入文件、启用能力 | JSON | | 安装诊断命令 | 检查 `.harness/scripts`、hooks、skills、agents、`verify.json` 是否完整 | 控制台报告 | | 升级保护 | 安装时保留本地配置和运行时状态,避免覆盖团队自定义内容 | 安装日志 | +| 安装进度事件 | 安装器输出 CLI 进度条,并写入 `.harness/runtime/install-progress.json` | 进度 JSON | +| 项目扫描 Skill | 默认安装 `project-doc-scanner`,引导用户在 AI 会话中扫描当前项目 | Skill | ### P1:项目画像 | 功能 | 说明 | 产物 | | --- | --- | --- | -| 项目开发指南生成 | 扫描代码、README、架构文档,生成项目分层、命名、错误处理、日志、测试规则 | `docs/dev-guide.md` | -| 接口索引生成 | 扫描路由、controller、handler、proto,生成接口清单 | `docs/api-url-index.md` | -| 接口详情生成 | 提取出入参、认证、状态码、示例和敏感字段 | `docs/api-detail.md` | +| 项目开发指南生成 | 扫描代码、README、架构文档,生成项目分层、命名、错误处理、日志、测试规则 | `docs/standards/project-guide.md` | +| 接口索引生成 | 扫描路由、controller、handler、proto,生成接口清单 | `docs/standards/api/url-index.md` | +| 接口详情生成 | 提取出入参、认证、状态码、示例和敏感字段 | `docs/standards/api/detail.md` | +| 项目画像生成 | 结构化保存技术栈、入口文件、关键目录、接口统计、验证命令和审阅状态 | `.harness/project-profile.json` | | 验证命令识别 | 根据项目技术栈推荐 `lint`、`type`、`test`、`coverage` 命令 | `.harness/verify.json` 建议项 | +## 项目文档初始化能力 + +项目文档初始化是项目级前置能力,不并入单次需求开发阶段。安装命令只负责检查当前项目是否已有项目知识文档,并提示用户在 Codex 或 Claude Code 中执行项目扫描。真正的代码扫描、模板选择、子代理调度、文档生成和审阅状态写入由默认安装的 `project-doc-scanner` Skill 完成。 + +### 默认 Skill 入口 + +`project-doc-scanner` 是项目知识初始化的默认入口,随 Harness 安装进入目标项目。 + +| 项目 | 设计 | +| --- | --- | +| Skill 名称 | `project-doc-scanner` | +| 安装位置 | `.claude/skills/project-doc-scanner/SKILL.md`,后续补齐 Codex 侧等效入口 | +| 触发语 | 扫描当前项目、初始化项目文档、生成项目开发文档、生成接口文档、刷新项目知识文档 | +| 配套脚本 | `.harness/scripts/project.py`,负责配置、状态、哈希、审阅记录和索引更新 | + +Skill 固定采用“先检测,再确认,再扫描”的交互流程: + +| 步骤 | 行为 | +| --- | --- | +| 检测项目状态 | 检查 `.harness/`、`.harness/project-docs.json`、`.harness/project-profile.json` 和 `docs/standards/` 下的项目知识文档 | +| 判断扫描类型 | 文档缺失时首次扫描,文档存在时增量扫描,`stale` 或 `needs_update` 时刷新扫描 | +| 确认模板来源 | 已有 `.harness/project-docs.json` 时展示配置摘要;无配置时询问使用内置模板还是自定义模板 | +| 确认写入范围 | 展示将写入、覆盖、生成候选稿、保存缓存和审阅记录的文件 | +| 开始扫描 | 调度架构、接口、质量、规范子代理,汇总结果并生成文档 | + +### 产物结构 + +| 产物 | 路径 | 生命周期 | 是否纳入版本管理 | +| --- | --- | --- | --- | +| 项目开发指南 | `docs/standards/project-guide.md` | 长期保留 | 是 | +| 接口索引 | `docs/standards/api/url-index.md` | 长期保留 | 是 | +| 接口详情 | `docs/standards/api/detail.md` | 长期保留 | 是 | +| 项目画像 | `.harness/project-profile.json` | 长期保留 | 是 | +| 项目文档配置 | `.harness/project-docs.json` | 长期保留 | 是 | +| 子代理扫描缓存 | `.harness/analysis/latest/*.json` | 可再生成 | 默认否 | +| 项目级审阅记录 | `.harness/project-docs/reviews/*.json` | 长期保留 | 是 | +| 扫描摘要 | `.harness/project-docs/reviews/-scan-summary.md` | 长期保留 | 是 | +| 待审阅清单 | `docs/standards/project-docs-review.md` | 长期保留 | 是 | +| 候选更新稿 | `.harness/project-docs/proposals/*.proposed.md` | 临时到半长期 | 默认否 | +| 覆盖前备份 | `.harness/project-docs/backups//` | 长期保留 | 是 | + +`docs/tasks/` 继续只保存单次需求开发任务包。初始化文档属于长期项目知识,统一放在 `docs/standards/` 下,便于后续 `architect`、`developer`、`tester` 阶段读取。 + +### 文档证据结构 + +初始化文档的关键判断必须采用“判断 + 证据 + 置信度 + 待确认项”的结构,避免 AI 将推测内容写成项目事实。 + +| 字段 | 含义 | +| --- | --- | +| 判断 | AI 对项目架构、接口、测试方式、工程规范的理解 | +| 证据 | 对应的文件路径、行号、函数名、配置项或命令输出摘要 | +| 置信度 | `high`、`medium`、`low` | +| 待确认项 | 代码证据不足,需要负责人确认的内容 | + +### 子代理扫描机制 + +初始化过程由 `project-doc-scanner` Skill 的主会话调度子代理扫描,主会话只负责汇总结果和生成最终文档,避免大范围源码内容污染主会话窗口。 + +| 子代理 | 扫描内容 | 输出 | +| --- | --- | --- | +| 架构扫描子代理 | 入口文件、目录结构、模块分层、依赖方向、架构规则 | `.harness/analysis/latest/architecture-scan.json` | +| 接口扫描子代理 | 路由、controller、handler、proto、OpenAPI 文件、接口出入参 | `.harness/analysis/latest/api-scan.json` | +| 质量扫描子代理 | 测试框架、lint、type、coverage、CI 配置 | `.harness/analysis/latest/quality-scan.json` | +| 规范扫描子代理 | README、CONTRIBUTING、docs、已有团队规范 | `.harness/analysis/latest/standards-scan.json` | + +子代理输出结构化事实,主会话只基于这些事实生成最终文档。中间结果保存路径、行号和短摘要,不保存大段源码。 + +子代理扫描结果默认只保留 `.harness/analysis/latest/*.json`。如果 `.harness/project-docs.json` 中配置 `keepHistory=true`,则同时写入 `.harness/analysis/history//*.json`。 + +### 扫描成本控制 + +扫描分为三层执行。 + +| 层级 | 行为 | 输出 | +| --- | --- | --- | +| 索引优先 | 读取文件树、依赖清单、配置文件、路由注册入口 | 候选模块、候选接口、候选测试命令 | +| 抽样验证 | 每类目录抽取代表文件,验证架构和规范判断 | 带证据的 findings | +| 按需深入 | 对低置信度、冲突项、关键接口读取更多文件 | 待确认项和补充证据 | + +预算配置由 `.harness/project-docs.json` 控制,例如 `maxFiles`、`maxFindings`、`maxEvidencePerFinding`、`maxEndpoints`。 + +### 局部扫描 + +`project-doc-scanner` 支持局部扫描,避免接口文档、小范围规范变化时重复扫描整个项目。 + +| 意图 | 扫描范围 | 影响文件 | +| --- | --- | --- | +| 只更新接口文档 | `api` scanner | `docs/standards/api/url-index.md`、`docs/standards/api/detail.md` | +| 只刷新项目开发指南 | `architecture`、`quality`、`standards` scanner | `docs/standards/project-guide.md` | +| 重新识别测试命令 | `quality` scanner | `.harness/project-profile.json`、`docs/standards/project-guide.md` 中的验证章节 | +| 完整扫描当前项目 | 全部 scanner | 全部项目知识文档 | + +底层脚本预留参数: + +```bash +python3 .harness/scripts/project.py docs refresh --only api +python3 .harness/scripts/project.py docs refresh --only project-guide +python3 .harness/scripts/project.py docs refresh --only quality +python3 .harness/scripts/project.py docs refresh --all +``` + +### 配置方式 + +配置采用“内置 preset + 项目覆盖配置”。Harness 内置常见技术栈 preset,项目中只保存 `.harness/project-docs.json` 覆盖项。 + +```json +{ + "version": 1, + "preset": "go-service", + "documents": { + "projectGuide": { + "enabled": true, + "output": "docs/standards/project-guide.md", + "template": "default" + }, + "apiUrlIndex": { + "enabled": true, + "output": "docs/standards/api/url-index.md", + "template": "default" + }, + "apiDetail": { + "enabled": true, + "output": "docs/standards/api/detail.md", + "template": "default" + } + }, + "review": { + "initialStatus": "draft", + "requiredBeforeUseAsStandard": true, + "contextInjection": { + "approved": "standard", + "draft": "reference", + "needs_update": "limited", + "stale": "disabled" + } + }, + "analysis": { + "cacheDir": ".harness/analysis/latest", + "keepHistory": false, + "historyDir": ".harness/analysis/history" + } +} +``` + +建议内置 preset 包括 `default`、`go-service`、`java-spring`、`node-service`、`python-service`。 + +### 审阅状态 + +负责人审阅结果进入 `.harness/project-profile.json`,后续需求开发根据文档状态决定注入强度。 + +| 状态 | 含义 | 后续 AI 使用规则 | +| --- | --- | --- | +| `draft` | AI 刚生成,负责人尚未审阅 | 可作为参考,关键判断必须重新核对证据 | +| `approved` | 负责人已经审阅通过 | 可作为项目长期规范注入 | +| `needs_update` | 负责人发现内容不准确 | 只注入摘要和待确认项 | +| `stale` | 代码变化较大,文档可能过期 | 默认不注入正文,只提示需要刷新 | + +支持逐文档批准和一键全部批准。 + +```bash +python3 .harness/scripts/project.py docs approve project-guide +python3 .harness/scripts/project.py docs approve api-url-index +python3 .harness/scripts/project.py docs approve api-detail +python3 .harness/scripts/project.py docs approve --all +``` + +首版只同步文档状态、内容哈希、路径和更新时间。后续在模板稳定后,再支持从 Markdown 反写结构化字段。 + +`approved` 文档默认不允许直接覆盖。扫描发现需要更新已批准文档时,先生成候选稿: + +```text +.harness/project-docs/proposals/ +├── project-guide.proposed.md +├── api-url-index.proposed.md +└── api-detail.proposed.md +``` + +只有用户明确同意覆盖后,才允许把候选稿写回正式文档。覆盖前必须自动备份旧版本: + +```text +.harness/project-docs/backups/ +└── 2026-06-01T10-00-00/ + ├── project-guide.md + ├── api-url-index.md + ├── api-detail.md + └── backup-manifest.json +``` + +### 生成模式 + +| 模式 | 使用场景 | 行为 | +| --- | --- | --- | +| 全量生成 | 新项目首次接入 Harness | 扫描代码,生成全部项目知识文档和项目画像 | +| 增量刷新 | 代码结构、接口、测试命令发生变化 | 只刷新受影响部分,并保留负责人已批准内容 | +| 强制重建 | 文档明显过期或项目结构大改 | 重新生成全部文档,旧文档覆盖前备份 | + +建议命令: + +```bash +python3 .harness/scripts/project.py docs init +python3 .harness/scripts/project.py docs refresh +python3 .harness/scripts/project.py docs rebuild +python3 .harness/scripts/project.py docs status +``` + +### 安装前置提示 + +安装流程增加项目文档检查。安装完成 `.harness/`、hooks、skills、agents、`docs/tasks/`、`docs/standards/` 和 `project-doc-scanner` Skill 后,检查项目知识文档是否存在,并提示用户在 AI 会话中执行项目扫描。 + +| 场景 | 行为 | +| --- | --- | +| 交互式安装 | 检测缺失后提示用户在 Codex 或 Claude Code 中输入“扫描当前项目” | +| 非交互安装 | 不询问,只记录项目文档待初始化状态 | +| `--init-project-docs` | 不做深度扫描,只生成配置骨架并提示进入 AI 会话扫描 | +| `--no-init-project-docs` | 跳过项目文档提示,并记录原因 | + +安装命令不直接依赖 AI 子代理,因此安装成功和项目文档扫描成功天然分离。项目文档扫描失败时,由 `project-doc-scanner` Skill 和 `.harness/scripts/project.py` 写入 `.harness/project-profile.json` 与审阅记录。 + +安装完成提示示例: + +```text +Harness 安装完成。 + +当前项目尚未生成 AI 自动化开发文档。 +建议在 Codex 或 Claude Code 中输入: + +扫描当前项目 + +该操作会触发 project-doc-scanner Skill,生成 docs/standards/ 下的项目知识文档。 +``` + +### 安装进度 + +首版暂缓 HTML 安装页面,先提供 CLI 进度条和 `.harness/runtime/install-progress.json`。进度只覆盖安装本体、配置写入和项目文档状态检查,不覆盖 AI 会话中的深度扫描。后续再基于同一份进度事件接入本地页面或企业平台页面。 + +| 阶段 | 参考进度 | 文案 | +| --- | ---: | --- | +| `prepare` | 5% | 正在准备安装环境 | +| `install_files` | 15% | 正在写入 Harness 文件 | +| `configure_hooks` | 25% | 正在配置 Claude Code 和 Codex hooks | +| `install_skills` | 35% | 正在安装 Harness Skills | +| `docs_preflight` | 55% | 正在检查项目文档状态 | +| `write_project_docs_config` | 70% | 正在写入项目文档配置骨架 | +| `write_install_state` | 90% | 正在写入安装状态 | +| `done` | 100% | 安装完成 | + +### 扫描摘要与审阅入口 + +`project-doc-scanner` 每次扫描完成后生成面向负责人的扫描摘要: + +```text +.harness/project-docs/reviews/-scan-summary.md +``` + +摘要包含本次扫描类型、使用模板、子代理结果、生成文件、候选稿、待确认项和建议审阅顺序。 + +同时生成集中待审阅清单: + +```text +docs/standards/project-docs-review.md +``` + +建议审阅顺序: + +| 顺序 | 文档 | 审阅重点 | +| ---: | --- | --- | +| 1 | `docs/standards/project-docs-review.md` | 待确认项和高优先级问题 | +| 2 | `docs/standards/project-guide.md` | 架构、目录职责、开发规范、验证命令 | +| 3 | `docs/standards/api/url-index.md` | 接口是否遗漏、分组是否合理 | +| 4 | `docs/standards/api/detail.md` | 关键接口的出入参、认证、状态码 | +| 5 | `.harness/project-profile.json` | 自动化配置和排障信息 | + ### P1:评审材料 | 功能 | 说明 | 产物 | @@ -151,7 +426,7 @@ Harness | --- | --- | --- | | `v0.4` | 状态可见 | 增强 `task.py current`,增加 JSON 输出,补齐缺失证据提示 | | `v0.5` | 安装可信 | 增加 `install-state.json`、安装诊断、版本展示 | -| `v0.6` | 项目画像 | 增加项目开发指南和接口索引生成能力 | +| `v0.6` | 项目文档扫描 Skill | 默认安装 `project-doc-scanner`,生成项目开发指南、接口文档、项目画像和审阅状态 | | `v0.7` | 评审材料 | 增加 `review-spec.md` 生成器 | | `v0.8` | 团队看板 | 基于 JSON 状态输出做本地 Web 页面 | | `v1.0` | 企业试点 | 模板配置、团队规范、统计报表、接入文档统一成正式版本 | @@ -185,8 +460,31 @@ Harness | `EP-002` | 增加任务列表 JSON 输出 | `task.py list --json` 能返回全部任务状态 | | `EP-003` | 增加安装状态记录 | 安装后生成 `.harness/install-state.json` | | `EP-004` | 增加安装诊断命令 | 能检查脚本、hooks、skills、agents 是否齐全 | -| `EP-005` | 生成项目开发指南 | 能基于当前项目生成 `docs/dev-guide.md` | -| `EP-006` | 生成技术评审文档 | 能基于任务产物生成 `review-spec.md` | +| `EP-005` | 增加安装进度事件 | CLI 显示进度条,并写入 `.harness/runtime/install-progress.json` | +| `EP-006` | 默认安装项目文档扫描 Skill | 安装后存在 `.claude/skills/project-doc-scanner/SKILL.md` | +| `EP-007` | 增加项目文档配置 | Skill 能生成或读取 `.harness/project-docs.json` | +| `EP-008` | 实现项目文档扫描流程 | Skill 能先检测项目状态、确认模板来源和写入范围,再调度子代理扫描 | +| `EP-009` | 生成项目开发指南 | 能基于当前项目生成 `docs/standards/project-guide.md` | +| `EP-010` | 生成接口文档 | 能生成 `docs/standards/api/url-index.md` 和 `docs/standards/api/detail.md` | +| `EP-011` | 增加项目文档审阅状态 | 能记录 `draft`、`approved`、`needs_update`、`stale` | +| `EP-012` | 增加审阅保护能力 | `approved` 文档默认生成候选稿,用户同意覆盖后自动备份旧版本 | +| `EP-013` | 生成扫描摘要和待审阅清单 | 能生成 `.harness/project-docs/reviews/-scan-summary.md` 和 `docs/standards/project-docs-review.md` | +| `EP-014` | 生成技术评审文档 | 能基于任务产物生成 `review-spec.md` | + +### 项目文档扫描 Skill 首版任务 + +首版先支持 Claude Code,目标是让 `project-doc-scanner` 能随 Harness 安装进入项目,并完成项目文档扫描的基础状态管理、索引更新和文档骨架生成。 + +| 顺序 | 任务 | 交付内容 | 验收标准 | +| ---: | --- | --- | --- | +| 1 | 安装 `project-doc-scanner` Skill | 在安装器中写入 `.claude/skills/project-doc-scanner/SKILL.md` | `init-harness.py` 安装后目标项目存在该 Skill 文件 | +| 2 | 新增 `project.py` 脚本 | 写入 `.harness/scripts/project.py`,提供项目文档状态管理入口 | 支持 `python3 .harness/scripts/project.py docs status` 和 `docs init-config` | +| 3 | 管理项目文档配置 | 读写 `.harness/project-docs.json` | 缺失配置时能生成默认配置,已有配置时能读取并展示摘要 | +| 4 | 管理项目画像状态 | 读写 `.harness/project-profile.json` | 能记录文档位置、审阅状态、内容哈希、生成时间和扫描摘要位置 | +| 5 | 更新文档索引 | 使用受控区块更新 `docs/index.md` 和 `docs/standards/index.md` | 区块外原有内容保持不变,区块重复执行时只替换 Harness 管理区块 | +| 6 | 固化 Skill 交互流程 | 在 `SKILL.md` 中定义“先检测,再确认,再扫描” | 已有文档时提示增量扫描,缺少文档时提示首次扫描,缺少配置时询问模板来源 | +| 7 | 生成首版文档骨架 | 生成 `project-guide.md`、`api/url-index.md`、`api/detail.md`、`project-docs-review.md` | 文档包含判断、证据、置信度、待确认项结构 | +| 8 | 支持审阅状态管理 | 支持 `draft`、`approved`、`needs_update`、`stale` | `docs approve --all` 能把目标文档状态更新为 `approved` 并记录哈希 | ## 评估指标 @@ -194,6 +492,8 @@ Harness | --- | --- | | 接入项目数 | 已安装 Harness 的项目数量 | | 活跃任务数 | 一段时间内使用 Harness 推进的需求数量 | +| 项目文档初始化率 | 已生成项目知识包的项目比例 | +| 项目文档审阅通过率 | 进入 `approved` 状态的项目知识文档比例 | | 阶段完成率 | 任务从 `clarify` 推进到 `done` 的比例 | | 验证通过率 | `verify.py all` 成功的比例 | | 证据完整率 | 必要产物齐全的任务比例 | diff --git a/docs/harness-share-report.html b/docs/harness-share-report.html new file mode 100644 index 0000000..dbeddf3 --- /dev/null +++ b/docs/harness-share-report.html @@ -0,0 +1,1492 @@ + + + + + + HARNESS · 企业 AI 研发方法论 · 2026/06 + + + + + + +
+
+
HARNESS·企业 AI 研发方法论
+ +
+
+ +
+
+
VOL. 01 / 2026.06 / 编辑特刊
+

AI 很强,但企业研发
不能让模型裸奔

+

Harness 给 AI 方向、边界、工具、反馈和纠偏机制,让需求开发从不确定的模型输出,变成可检查、可复用、可推广的工程过程。

+ +
+
+ +
+ +
+ +
+
+
+
+
+ CHAPTER 01 / WHY HARNESS +

问题不是 AI 不够强,而是没有护栏。

+

把 AI 用在企业研发里,速度并不稀缺,稀缺的是把速度控制成结果。

+
+
+ +
+ 导言 · EDITOR'S NOTE +

日常开发需求不能交给不确定的模型独立处理。

+

大模型像一匹跑得很快的马,速度很强,但企业研发不能只是给一句提示词就放它向前冲。代码任务里有需求理解、架构边界、接口契约、测试验证、评审验收和失败修正,任何一环失控都会把局部效率变成整体返工。

+
+ +
+
+ QUESTION 01 +

AI 从哪里理解需求?

+

需求需要先变成可交接文档,包含开发意图、范围边界、输入输出、业务流程和验收标准。

+
+
+ QUESTION 02 +

AI 依据什么规则做判断?

+

项目规范、接口契约、测试规则和架构边界需要进入长期文档,而不是临时聊天补充。

+
+
+ QUESTION 03 +

AI 能否自己查项目规范?

+

项目知识应当前置生成,让 AI 主动读取规范和接口文档,减少反复询问研发人员。

+
+
+ QUESTION 04 +

AI 写完以后谁验证?

+

实现者和验证者需要隔离,测试、架构、范围和质量检查共同判断交付是否成立。

+
+
+ QUESTION 05 +

验证失败以后如何修正?

+

失败结果要回到阶段状态和证据文件,重新进入正确阶段,而不是继续扩大修改范围。

+
+
+ QUESTION 06 +

这次问题下次如何避免?

+

问题、判断依据和修正方式需要进入项目知识或规范,成为下一次任务的前置上下文。

+
+
+
+
+ +
+
+
+
+
+ CHAPTER 02 / HARNESS LOOP +

关键不是让 AI 干活,而是给 AI 搭循环。

+

前置、执行、反馈、经验、复用,这五个环节让 AI 从一次性输出进入持续变好的工程系统。

+
+
+ +
+
+ 01 +

前置

+

把需求、项目规范、接口契约和历史经验放到 AI 开始工作之前。

+
+
+ 02 +

执行

+

AI 按阶段、角色和任务范围完成设计、测试、实现和评审。

+
+
+ 03 +

反馈

+

测试失败、验证失败、架构问题和人工意见进入明确的证据文件。

+
+
+ 04 +

经验

+

把已经出现的问题转成规则、约束、检查项或项目知识文档。

+
+
+ 05 +

复用

+

下一次需求开始前自动读取这些规则,让相同问题减少重复出现。

+
+
+

Harness 的目标不是限制 AI,而是让 AI 的能力更稳定、更可控、更可复用。

+
+
+ +
+
+
+
+
+ CHAPTER 03 / WHAT IS HARNESS +

Harness 是 AI Coding Agent 的工程护栏。

+

不是提示词,也不是单个 skill,而是一套围绕 AI 研发过程搭建的工程系统。

+
+
+ +
+
+ DEFINITION +

Harness 不是一个提示词,也不是单个 skill。

+

它是一套围绕 AI 研发过程搭建的工程系统:任务怎么定义,过程怎么执行,结果怎么评估,经验怎么积累,下次怎么复用。

+

需求先由人类想清楚,执行交给 AI,结果必须被 Harness 验证。

+
+ +
+
+
+
+ LAYER 01 +

人类需求层

+

解决「到底想要什么」。需求文档必须写清为什么做、做什么、不做什么、输入输出、业务流程和验收标准。

+
+
+
+
+
+ LAYER 02 +

工程契约层

+

把业务语言翻译成工程语言,形成技术计划、接口契约、任务拆分、修改范围和测试契约。人类必须 review 这份工程合同。

+
+
+
+
+
+ LAYER 03 +

代码执行层

+

只有前两层一致以后,AI 才进入实现。规划、实现、评估职责拆开,避免同一个 AI 自己写、自己测、自己通过。

+
+
+
+
+
+
+ +
+
+
+
+
+ CHAPTER 04 / CORE STRENGTHS +

阶段可控、证据可查、质量可验、知识可复用。

+

四项能力合在一起,构成 Harness 的企业级研发过程基础。

+
+
+ +
+ Harness 核心能力信息图 +
FIG. 01Harness 四大核心能力总览 · 阶段可控 · 证据可查 · 质量可验 · 项目知识可复用
+
+ +
+
+ 01 +

阶段可控

+

需求开发固定经过 clarify → doc-plan → red → green → review → validate → done。

+
    +
  • 状态写入 task.json
  • +
  • 推进通过 task.py advance
  • +
  • 单次需求独立成任务包
  • +
+
+
+ 02 +

证据可查

+

关键判断和执行结果都进入文件,减少只依赖对话记录的不可追溯问题。

+
    +
  • 需求确认有快照
  • +
  • 测试和评审有结果文件
  • +
  • 验证报告可复核
  • +
+
+
+ 03 +

质量可验

+

质量判断交给脚本、测试和范围检查,降低 AI 自述完成带来的不确定性。

+
    +
  • lint / type / test / coverage
  • +
  • scope.json 控制变更范围
  • +
  • verify.py all 汇总验证
  • +
+
+
+ 04 +

知识可复用

+

项目画像、接口文档和工程规范先进入 docs/standards/,后续任务持续复用。

+
    +
  • 项目开发指南
  • +
  • 接口索引和详情
  • +
  • 审阅状态与内容哈希
  • +
+
+
+
+
+ +
+
+
+
+
+ CHAPTER 05 / DESIGN POINTS +

七个设计要点,把流程变成可执行机制。

+

Harness 的设计不是增加一个流程说明,而是把状态、产物、角色、测试、范围、知识和安装全部变成可执行机制。

+
+
+ +
+ Harness 设计要点信息图 +
FIG. 02Harness 七个设计要点全貌 · 状态管理 / 阶段产物 / 角色拆分 / 测试先行 / 范围受控 / 知识前置 / 安装即接入
+
+ +
+
+ 01 +

脚本管理状态

+

状态变化由脚本完成,AI 负责执行,脚本负责记录和校验。

+
+
+ 02 +

阶段产物标准化

+

每个阶段都有固定产物,让 review 和自动化具备稳定输入。

+
+
+ 03 +

角色职责拆分

+

architect、developer、tester 分工明确,减少混合职责。

+
+
+ 04 +

测试先行

+

RED 先失败,GREEN 再实现,验证结果进入证据文件。

+
+
+ 05 +

范围受控

+

scope.json 描述允许范围,避免需求实现扩散到无关模块。

+
+
+ 06 +

项目知识前置

+

项目说明和接口文档先形成知识基线,后续任务持续复用。

+
+
+ 07 +

安装即接入

+

脚本、hooks、skills 和目录结构一次安装,进入项目即可使用。

+
+
+
+
+ +
+
+
+
+
+ CHAPTER 06 / HOW IT WORKS +

企业级 Harness 的完整运转过程。

+

从人类评审需求开始,到 AI 编码、脚本检查、测试 Agent 验收、架构 Agent 验收,再回到人类确认。

+
+
+ +
+
+ 01 +
+

团队先评审需求文档

+

复杂需求先在团队内部形成一致理解,避免 AI 从含混描述开始工作。

+
+
+
+ 02 +
+

把评审后的文档交给 AI

+

需求文档成为本次开发的原始输入,AI 只能基于这份材料继续推进。

+
+
+
+ 03 +
+

AI 读取项目规范审核需求

+

AI 基于项目知识、架构规范和接口文档检查需求是否缺失关键约束。

+
+
+
+ 04 +
+

人类批准后生成工程契约

+

AI 把需求翻译成技术计划、接口契约、测试契约和变更范围。

+
+
+
+ 05 +
+

人类审核工程契约

+

负责人检查工程文档是否符合原始需求,确认后才允许进入编码。

+
+
+
+ 06 +
+

AI 按 Spec 编码并自检

+

实现完成后调用 Harness Check,执行测试、覆盖率、静态扫描和范围检查。

+
+
+
+ 07 +
+

检查失败则打回修正

+

验证失败回到对应阶段修复,通过后再进入测试 Agent 和架构 Agent 验收。

+
+
+
+ 08 +
+

测试 Agent 验收需求

+

测试角色基于工程契约检查代码是否满足验收标准和关键边界场景。

+
+
+
+ 09 +
+

架构 Agent 审查边界

+

架构角色检查错误码、跨包调用、模块边界和项目规范是否被破坏。

+
+
+
+ 10 +
+

双角色通过后交给人类确认

+

测试与架构验收都通过后,向负责人呈现结果;失败则回到修正过程。

+
+
+
+
+
+ +
+
+
+
+
+ CHAPTER 07 / PROCESS & EVIDENCE +

让每一步都能被检查。

+

Harness 的重点不是口头承诺,而是文件、命令和退出码共同组成的证据体系。

+
+
+ +
+
+ PIPELINE / 阶段推进 +
+
clarify确认开发意图、验收标准和范围边界
+
doc-plan形成技术计划、修改范围和测试契约
+
red先写预期失败测试,锁定行为契约
+
green在测试约束下完成最小实现
+
review检查设计符合性、命名和边界
+
validate补充验证并执行统一质量检查
+
+
+ +
+ EVIDENCE / 关键证据文件 +
+
clarification.md
+
implementation-plan.md
+
scope.json
+
test-result.red.json
+
test-result.green.json
+
review-result.json
+
verify-result.json
+
project-profile.json
+
+
+
+
+
+ +
+
+
+
+
+ CHAPTER 08 / FUTURE CAPABILITY +

AI Coding 后半场,拼的是过程管理能力。

+

未来企业级 Coding 会越来越简单,并不是因为代码简单了,而是复杂执行过程会被压进更清晰的工程流水线里。

+
+
+ +
+
+ +

把模糊想法变成清晰需求

+

真正重要的不是马上让 AI 写代码,而是先把方向、边界、输入输出和验收标准讲清楚。

+
+
+ +

把需求变成工程契约

+

业务语言需要翻译成模块、接口、错误码、测试契约和修改范围,负责人 review 后才进入实现。

+
+
+ +

设计反馈系统

+

AI 犯错以后,错误需要变成证据、规则和项目知识,让下一次任务从更好的前置条件开始。

+
+
+
+
+ +
+
+ COLOPHON · 刊末 +

AI 写代码只是起点,企业真正需要的是可管理、可验证、可复用的 AI 研发过程。

+

Harness 提供阶段、角色、证据、项目知识和质量检查。人类负责方向和裁决,AI 负责执行,脚本和证据负责验证。

+
+
专题企业 AI 研发护栏
+
出品Harness 研究组
+
刊期2026 / 06 · 第 01 期
+
字数约 2,400 字 · 阅读 12 分钟
+
+
+
+ + diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..9c60c96 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,10 @@ +# 文档索引 + +项目文档通过本文件登记主要目录用途。 + +## Harness + +| 目录 | 内容 | +| --- | --- | +| `docs/tasks/` | 需求开发任务包,每个子目录对应一次需求开发 | +| `docs/standards/` | 团队长期工程规范,供需求开发过程自动注入上下文 | diff --git a/docs/standards/index.md b/docs/standards/index.md new file mode 100644 index 0000000..0735eb8 --- /dev/null +++ b/docs/standards/index.md @@ -0,0 +1,3 @@ +# 团队工程规范索引 + +此目录保存团队长期维护的工程规范。需求开发过程中会固定注入本索引文件,具体规范文件由任务的 `context..jsonl` 明确引用。 diff --git a/docs/tasks/06-02-business-contracts/clarification.jsonl b/docs/tasks/06-02-business-contracts/clarification.jsonl new file mode 100644 index 0000000..ecf66f2 --- /dev/null +++ b/docs/tasks/06-02-business-contracts/clarification.jsonl @@ -0,0 +1 @@ +{"event": "confirm", "at": "2026-06-02T08:05:40.775854+00:00", "confirmationSource": "live", "sourceDoc": "inline-request", "sourceDocHash": "sha256:192dc47569825d7fd8b2f52422236cf694008e7a5d3bc68533b21ad81a3aceda", "developmentIntent": "增强 Harness 的通用需求开发能力:在需求确认、实现计划、测试证据、开发证据和审查证据中加入业务契约结构,使日常业务需求能够明确业务场景、输入输出、状态变化、异常处理、业务规则、可观测信息和权限边界,并让测试、实现、审查、验证围绕这些契约执行。", "acceptanceCriteria": ["clarification 记录支持业务契约字段,并在 clarification.md 中渲染业务契约内容。", "implementation-plan.md 固定章节增加业务契约覆盖内容,阶段推进会校验该章节存在且非空。", "verify.py red 和 verify.py green 支持记录 contractCoverage 和 uncoveredContracts,用于保存测试与业务契约的映射关系。", "review-result.json 支持记录业务契约审查结果,进入 validate 前必须通过业务契约审查。", "init-harness.py 安装模板、角色提示词和 README 同步说明业务契约机制。", "相关单元测试覆盖新增 CLI 参数、计划章节校验、review 门禁和安装模板内容,测试全部通过。"], "boundaries": ["本次修改 Harness 框架、安装模板、角色提示词、README 和测试。", "本次只提供通用业务契约结构与校验能力,具体推荐引擎、订单、支付等领域规则由业务项目任务自行填写。", "本次不引入外部服务、不改变已有任务阶段顺序、不删除当前未提交变更。"], "openQuestions": [], "confirmed": true, "confirmedBy": "collaborator"} diff --git a/docs/tasks/06-02-business-contracts/clarification.md b/docs/tasks/06-02-business-contracts/clarification.md new file mode 100644 index 0000000..89fcaf8 --- /dev/null +++ b/docs/tasks/06-02-business-contracts/clarification.md @@ -0,0 +1,29 @@ +--- +confirmation_source: live +confirmed: true +confirmed_by: collaborator +open_questions: [] +source_doc: inline-request +source_doc_hash: sha256:192dc47569825d7fd8b2f52422236cf694008e7a5d3bc68533b21ad81a3aceda +--- + +# 需求确认 + +## 开发意图 + +增强 Harness 的通用需求开发能力:在需求确认、实现计划、测试证据、开发证据和审查证据中加入业务契约结构,使日常业务需求能够明确业务场景、输入输出、状态变化、异常处理、业务规则、可观测信息和权限边界,并让测试、实现、审查、验证围绕这些契约执行。 + +## 验收标准 + +1. clarification 记录支持业务契约字段,并在 clarification.md 中渲染业务契约内容。 +2. implementation-plan.md 固定章节增加业务契约覆盖内容,阶段推进会校验该章节存在且非空。 +3. verify.py red 和 verify.py green 支持记录 contractCoverage 和 uncoveredContracts,用于保存测试与业务契约的映射关系。 +4. review-result.json 支持记录业务契约审查结果,进入 validate 前必须通过业务契约审查。 +5. init-harness.py 安装模板、角色提示词和 README 同步说明业务契约机制。 +6. 相关单元测试覆盖新增 CLI 参数、计划章节校验、review 门禁和安装模板内容,测试全部通过。 + +## 边界条件 + +1. 本次修改 Harness 框架、安装模板、角色提示词、README 和测试。 +2. 本次只提供通用业务契约结构与校验能力,具体推荐引擎、订单、支付等领域规则由业务项目任务自行填写。 +3. 本次不引入外部服务、不改变已有任务阶段顺序、不删除当前未提交变更。 diff --git a/docs/tasks/06-02-business-contracts/context.architect.jsonl b/docs/tasks/06-02-business-contracts/context.architect.jsonl new file mode 100644 index 0000000..84a9675 --- /dev/null +++ b/docs/tasks/06-02-business-contracts/context.architect.jsonl @@ -0,0 +1,4 @@ +{"file":"README.md","reason":"Harness 阶段、计划章节和验证方式说明"} +{"file":"harness_scripts/task.py","reason":"任务状态、需求确认、计划校验和 review 门禁实现"} +{"file":"harness_scripts/verify.py","reason":"RED/GREEN 证据与验证结果生成"} +{"file":"init-harness.py","reason":"安装模板、技能和角色提示词来源"} diff --git a/docs/tasks/06-02-business-contracts/context.developer.jsonl b/docs/tasks/06-02-business-contracts/context.developer.jsonl new file mode 100644 index 0000000..2d75603 --- /dev/null +++ b/docs/tasks/06-02-business-contracts/context.developer.jsonl @@ -0,0 +1,4 @@ +{"file":"harness_scripts/task.py","reason":"业务契约记录、计划校验和 review 门禁实现"} +{"file":"harness_scripts/verify.py","reason":"契约覆盖证据字段实现"} +{"file":"init-harness.py","reason":"安装模板中的业务契约说明"} +{"file":"README.md","reason":"仓库级使用说明"} diff --git a/docs/tasks/06-02-business-contracts/context.tester.jsonl b/docs/tasks/06-02-business-contracts/context.tester.jsonl new file mode 100644 index 0000000..c045c88 --- /dev/null +++ b/docs/tasks/06-02-business-contracts/context.tester.jsonl @@ -0,0 +1,5 @@ +{"file":"tests/test_task_cli.py","reason":"task.py CLI 行为测试"} +{"file":"tests/test_verify.py","reason":"verify.py 证据字段测试"} +{"file":"tests/test_init_harness.py","reason":"安装模板测试"} +{"file":"harness_scripts/task.py","reason":"测试目标脚本"} +{"file":"harness_scripts/verify.py","reason":"测试目标脚本"} diff --git a/docs/tasks/06-02-business-contracts/implementation-plan.md b/docs/tasks/06-02-business-contracts/implementation-plan.md new file mode 100644 index 0000000..d935ad4 --- /dev/null +++ b/docs/tasks/06-02-business-contracts/implementation-plan.md @@ -0,0 +1,58 @@ +# 实现计划 + +## 开发意图摘要 + +增强 Harness 的通用需求开发能力,在需求确认、实现计划、测试证据、开发证据和审查证据中加入业务契约结构。业务契约用于记录业务场景、输入条件、预期行为、可观测信息和测试要求,使日常业务需求中的细节能够进入后续测试、实现、审查和验证。 + +## 影响范围 + +修改 `harness_scripts/task.py`、`harness_scripts/verify.py`、`init-harness.py`、`README.md` 和对应测试文件。安装到目标项目后的 `.harness/scripts/task.py`、`.harness/scripts/verify.py`、`.claude/skills/requirement-confirmation/SKILL.md`、`.claude/skills/requirement-development/SKILL.md`、`.claude/agents/*.md`、`AGENTS.md` 和 `CLAUDE.md` 都需要体现业务契约要求。 + +## 技术方案 + +`task.py clarify confirm` 增加 `--business-contract` 参数,参数值使用 JSON object 表示一条业务契约。`clarification.jsonl` 保存 `businessContracts`,`clarification.md` 渲染业务契约表格,并在 front matter 中记录业务契约数量。 + +`implementation-plan.md` 固定章节增加 `业务契约覆盖`,`task.py advance red` 会校验该章节存在且非空。 + +`verify.py red` 和 `verify.py green` 增加 `--contract-coverage` 与 `--uncovered-contract` 参数,分别写入 `contractCoverage` 和 `uncoveredContracts`。 + +`task.py review record` 增加业务契约审查字段,`task.py advance validate` 在业务契约审查失败或存在缺失契约时拒绝推进。 + +## 可测试契约 + +1. `task.py clarify confirm --business-contract ` 成功后,`clarification.jsonl` 包含 `businessContracts`,`clarification.md` 包含业务契约表格和契约数量。 +2. 缺少 `业务契约覆盖` 章节的 `implementation-plan.md` 无法推进到 `red`。 +3. `verify.py red` 与 `verify.py green` 能保存 `contractCoverage` 和 `uncoveredContracts`。 +4. `task.py review record` 默认记录业务契约审查通过,显式失败时 `advance validate` 会拒绝推进。 +5. `init-harness.py` 安装出的技能、角色和说明文件包含业务契约要求。 + +## 业务契约覆盖 + +| 契约编号 | 代码位置 | 测试位置 | 审查要点 | +| --- | --- | --- | --- | +| BC-001 | `harness_scripts/task.py` | `tests/test_task_cli.py` | 需求确认能保存并渲染业务契约 | +| BC-002 | `harness_scripts/task.py` | `tests/test_task_cli.py` | 实现计划必须包含业务契约覆盖章节 | +| BC-003 | `harness_scripts/verify.py` | `tests/test_verify.py` | RED 和 GREEN 证据能保存契约测试映射 | +| BC-004 | `harness_scripts/task.py` | `tests/test_task_cli.py` | review 结果能阻止缺少业务契约覆盖的任务进入 validate | +| BC-005 | `init-harness.py`、`README.md` | `tests/test_init_harness.py` | 安装模板和说明文件包含业务契约要求 | + +## Slice 顺序 + +1. 增加业务契约相关失败测试。 +2. 修改 `task.py`,支持业务契约记录、计划章节校验和 review 门禁。 +3. 修改 `verify.py`,支持契约覆盖证据字段。 +4. 修改安装模板和 README,使新安装项目也携带业务契约要求。 +5. 运行目标测试和全量测试。 + +## 验证方式 + +运行以下命令: + +```bash +python3 -m unittest tests.test_task_cli tests.test_verify tests.test_init_harness +python3 -m unittest discover tests +``` + +## 已知限制 + +业务契约的具体内容由每个任务提供。Harness 只校验业务契约的通用结构、测试映射和审查状态,无法自动判断某个领域规则本身是否完整。 diff --git a/docs/tasks/06-02-business-contracts/review-result.json b/docs/tasks/06-02-business-contracts/review-result.json new file mode 100644 index 0000000..31ed308 --- /dev/null +++ b/docs/tasks/06-02-business-contracts/review-result.json @@ -0,0 +1,80 @@ +{ + "generatedBy": "task.py review record", + "baseRef": "HEAD", + "headRef": "working-tree", + "changedFiles": [ + ".claude/agents/architect.md", + ".claude/agents/developer.md", + ".claude/agents/tester.md", + ".claude/commands/harness/continue.md", + ".claude/commands/harness/finish.md", + ".claude/hooks/harness-inject-context.py", + ".claude/hooks/harness-session-start.py", + ".claude/hooks/harness-workflow-state.py", + ".claude/settings.json", + ".claude/skills/grill-me/SKILL.md", + ".claude/skills/harness-configure-verify/SKILL.md", + ".claude/skills/harness-implement/SKILL.md", + ".claude/skills/project-doc-scanner/SKILL.md", + ".claude/skills/requirement-confirmation/SKILL.md", + ".claude/skills/requirement-development/SKILL.md", + ".codex/hooks.json", + ".codex/hooks/harness-inject-context.py", + ".codex/hooks/harness-session-start.py", + ".codex/hooks/harness-workflow-state.py", + ".gitignore", + ".harness/scripts/context.py", + ".harness/scripts/project.py", + ".harness/scripts/task.py", + ".harness/scripts/team_cleanup.py", + ".harness/scripts/verify.py", + ".harness/verify.json", + ".harness/workflow.md", + "AGENTS.md", + "CLAUDE.md", + "README.md", + "docs/harness-share-report.html", + "docs/index.md", + "docs/standards/index.md", + "docs/tasks/06-02-business-contracts/clarification.jsonl", + "docs/tasks/06-02-business-contracts/clarification.md", + "docs/tasks/06-02-business-contracts/context.architect.jsonl", + "docs/tasks/06-02-business-contracts/context.developer.jsonl", + "docs/tasks/06-02-business-contracts/context.tester.jsonl", + "docs/tasks/06-02-business-contracts/implementation-plan.md", + "docs/tasks/06-02-business-contracts/review-result.json", + "docs/tasks/06-02-business-contracts/scope.json", + "docs/tasks/06-02-business-contracts/task.json", + "docs/tasks/06-02-business-contracts/test-result.green.json", + "docs/tasks/06-02-business-contracts/test-result.red.json", + "docs/核心知识点.png", + "docs/核心能力.png", + "docs/需求开发流程.png", + "harness_hooks/harness-inject-context.py", + "harness_hooks/harness-session-start.py", + "harness_hooks/harness-workflow-state.py", + "harness_scripts/task.py", + "harness_scripts/verify.py", + "init-harness.py", + "tests/test_hooks.py", + "tests/test_init_harness.py", + "tests/test_integration.py", + "tests/test_task_cli.py", + "tests/test_verify.py" + ], + "specCompliance": { + "status": "passed", + "issues": [] + }, + "codeQuality": { + "status": "passed", + "issues": [] + }, + "businessContractCoverage": { + "status": "passed", + "missing": [] + }, + "blockingIssues": [], + "summary": "业务契约机制已覆盖需求确认、计划章节、RED/GREEN 证据、review 门禁、安装模板和 README。全量测试已通过,scope 覆盖当前保留变更。", + "finishedAt": "2026-06-02T08:14:21.863831+00:00" +} diff --git a/docs/tasks/06-02-business-contracts/scope.json b/docs/tasks/06-02-business-contracts/scope.json new file mode 100644 index 0000000..c9e2f32 --- /dev/null +++ b/docs/tasks/06-02-business-contracts/scope.json @@ -0,0 +1,19 @@ +{ + "allowed": [ + "harness_scripts/task.py", + "harness_scripts/verify.py", + "harness_hooks/**", + "init-harness.py", + "README.md", + "tests/**", + "docs/tasks/**", + ".harness/**", + ".claude/**", + ".codex/**", + "AGENTS.md", + "CLAUDE.md", + ".gitignore", + "docs/**" + ], + "denied": [] +} diff --git a/docs/tasks/06-02-business-contracts/task.json b/docs/tasks/06-02-business-contracts/task.json new file mode 100644 index 0000000..064522a --- /dev/null +++ b/docs/tasks/06-02-business-contracts/task.json @@ -0,0 +1,92 @@ +{ + "id": "business-contracts", + "title": "增加通用业务契约机制", + "description": "", + "status": "done", + "phase": "done", + "originIntent": "requirement-confirmation", + "executionMode": "agent-team", + "executionModeFallbackReason": null, + "priority": "P2", + "creator": "didi", + "assignee": "didi", + "createdAt": "2026-06-02", + "completedAt": null, + "branch": null, + "sourceDoc": "inline-request", + "sourceDocHash": "sha256:192dc47569825d7fd8b2f52422236cf694008e7a5d3bc68533b21ad81a3aceda", + "phaseHistory": [ + { + "event": "advanced", + "from": "clarify", + "to": "doc-plan", + "at": "2026-06-02T08:10:45.308197+00:00", + "evidence": [ + "docs/tasks/06-02-business-contracts/clarification.jsonl" + ] + }, + { + "event": "advanced", + "from": "doc-plan", + "to": "red", + "at": "2026-06-02T08:11:24.316460+00:00", + "evidence": [ + "docs/tasks/06-02-business-contracts/implementation-plan.md", + "docs/tasks/06-02-business-contracts/scope.json", + "docs/tasks/06-02-business-contracts/context.architect.jsonl", + "docs/tasks/06-02-business-contracts/context.developer.jsonl", + "docs/tasks/06-02-business-contracts/context.tester.jsonl" + ] + }, + { + "event": "advanced", + "from": "red", + "to": "green", + "at": "2026-06-02T08:12:19.537861+00:00", + "evidence": [ + "docs/tasks/06-02-business-contracts/test-result.red.json" + ] + }, + { + "event": "advanced", + "from": "green", + "to": "review", + "at": "2026-06-02T08:12:19.703745+00:00", + "evidence": [ + "docs/tasks/06-02-business-contracts/test-result.green.json" + ] + }, + { + "event": "advanced", + "from": "review", + "to": "validate", + "at": "2026-06-02T08:14:22.048780+00:00", + "evidence": [ + "docs/tasks/06-02-business-contracts/review-result.json" + ] + }, + { + "event": "advance_failed", + "phase": "done", + "reason": "working tree changed after final verify", + "at": "2026-06-02T08:14:59.798503+00:00" + }, + { + "event": "advanced", + "from": "validate", + "to": "done", + "at": "2026-06-02T08:15:45.998321+00:00", + "evidence": [ + "docs/tasks/06-02-business-contracts/verify-result.json" + ] + } + ], + "phaseAttempts": { + "done": { + "autoFixCount": 0, + "lastError": "working tree changed after final verify", + "lastFailedAt": "2026-06-02T08:14:59.798185+00:00" + } + }, + "meta": {} +} diff --git a/docs/tasks/06-02-business-contracts/test-result.green.json b/docs/tasks/06-02-business-contracts/test-result.green.json new file mode 100644 index 0000000..9176577 --- /dev/null +++ b/docs/tasks/06-02-business-contracts/test-result.green.json @@ -0,0 +1,19 @@ +{ + "success": true, + "command": "python3 -m unittest tests.test_task_cli.TestClarifyAndAdvance.test_clarify_confirm_records_business_contracts", + "exitCode": 0, + "targetTests": [ + "tests.test_task_cli.TestClarifyAndAdvance.test_clarify_confirm_records_business_contracts" + ], + "stdout": "", + "stderr": ".\n----------------------------------------------------------------------\nRan 1 test in 0.415s\n\nOK\n", + "contractCoverage": { + "BC-001": [ + "tests.test_task_cli.TestClarifyAndAdvance.test_clarify_confirm_records_business_contracts" + ] + }, + "uncoveredContracts": [], + "finishedAt": "2026-06-02T08:11:40.384311+00:00", + "generatedBy": "verify.py green", + "expectedPassObserved": true +} diff --git a/docs/tasks/06-02-business-contracts/test-result.red.json b/docs/tasks/06-02-business-contracts/test-result.red.json new file mode 100644 index 0000000..9c8e85b --- /dev/null +++ b/docs/tasks/06-02-business-contracts/test-result.red.json @@ -0,0 +1,19 @@ +{ + "success": true, + "command": "python3 -m unittest tests.test_task_cli.TestClarifyAndAdvance.test_clarify_confirm_records_business_contracts", + "exitCode": 1, + "targetTests": [ + "tests.test_task_cli.TestClarifyAndAdvance.test_clarify_confirm_records_business_contracts" + ], + "stdout": "", + "stderr": "F\n======================================================================\nFAIL: test_clarify_confirm_records_business_contracts (tests.test_task_cli.TestClarifyAndAdvance.test_clarify_confirm_records_business_contracts)\n----------------------------------------------------------------------\nTraceback (most recent call last):\n File \"/Users/didi/go/src/git.xiaojukeji.com/harness/tests/test_task_cli.py\", line 196, in test_clarify_confirm_records_business_contracts\n self.assertIn(\"business_contracts: 1\", markdown)\n ~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\nAssertionError: 'business_contracts: 1' not found in '---\\nconfirmation_source: live\\nconfirmed: true\\nconfirmed_by: collaborator\\nopen_questions: []\\nsource_doc: inline-request\\nsource_doc_hash: sha256:test\\n---\\n\\n# 需求确认\\n\\n## 开发意图\\n\\n增加订单超时控制能力\\n\\n## 验收标准\\n\\n1. 超时订单会被拒绝\\n\\n## 边界条件\\n\\n1. 本次不修改支付流程\\n\\n## 业务契约\\n\\n| 编号 | 业务场景 | 输入条件 | 预期行为 | 可观测信息 | 是否需要测试 |\\n| --- | --- | --- | --- | --- | --- |\\n| BC-001 | 订单已超过允许支付时间 | 订单状态为待支付,当前时间晚于超时时间 | 订单被拒绝继续支付 | 日志包含 order_id 和 reject_reason | 是 |\\n\\n'\n\n----------------------------------------------------------------------\nRan 1 test in 0.220s\n\nFAILED (failures=1)\n", + "contractCoverage": { + "BC-001": [ + "tests.test_task_cli.TestClarifyAndAdvance.test_clarify_confirm_records_business_contracts" + ] + }, + "uncoveredContracts": [], + "finishedAt": "2026-06-02T08:11:24.884909+00:00", + "generatedBy": "verify.py red", + "expectedFailureObserved": true +} diff --git a/docs/tasks/06-02-business-contracts/verify-result.json b/docs/tasks/06-02-business-contracts/verify-result.json new file mode 100644 index 0000000..826e9f9 --- /dev/null +++ b/docs/tasks/06-02-business-contracts/verify-result.json @@ -0,0 +1,143 @@ +{ + "success": true, + "commands": [ + { + "name": "test", + "command": "python3 -m unittest discover tests", + "exitCode": 0, + "success": true, + "stdout": "", + "stderr": ".......................................................................\n----------------------------------------------------------------------\nRan 71 tests in 22.938s\n\nOK\n" + } + ], + "scope": { + "success": true, + "changedFiles": [ + ".claude/agents/architect.md", + ".claude/agents/developer.md", + ".claude/agents/tester.md", + ".claude/commands/harness/continue.md", + ".claude/commands/harness/finish.md", + ".claude/hooks/harness-inject-context.py", + ".claude/hooks/harness-session-start.py", + ".claude/hooks/harness-workflow-state.py", + ".claude/settings.json", + ".claude/skills/grill-me/SKILL.md", + ".claude/skills/harness-configure-verify/SKILL.md", + ".claude/skills/harness-implement/SKILL.md", + ".claude/skills/project-doc-scanner/SKILL.md", + ".claude/skills/requirement-confirmation/SKILL.md", + ".claude/skills/requirement-development/SKILL.md", + ".codex/config.toml", + ".codex/hooks.json", + ".codex/hooks/harness-inject-context.py", + ".codex/hooks/harness-session-start.py", + ".codex/hooks/harness-workflow-state.py", + ".gitignore", + ".harness/scripts/context.py", + ".harness/scripts/project.py", + ".harness/scripts/task.py", + ".harness/scripts/team_cleanup.py", + ".harness/scripts/verify.py", + ".harness/verify.json", + ".harness/workflow.md", + "AGENTS.md", + "CLAUDE.md", + "README.md", + "docs/harness-share-report.html", + "docs/index.md", + "docs/standards/index.md", + "docs/tasks/06-02-business-contracts/clarification.jsonl", + "docs/tasks/06-02-business-contracts/clarification.md", + "docs/tasks/06-02-business-contracts/context.architect.jsonl", + "docs/tasks/06-02-business-contracts/context.developer.jsonl", + "docs/tasks/06-02-business-contracts/context.tester.jsonl", + "docs/tasks/06-02-business-contracts/implementation-plan.md", + "docs/tasks/06-02-business-contracts/review-result.json", + "docs/tasks/06-02-business-contracts/scope.json", + "docs/tasks/06-02-business-contracts/task.json", + "docs/tasks/06-02-business-contracts/test-result.green.json", + "docs/tasks/06-02-business-contracts/test-result.red.json", + "docs/tasks/06-02-business-contracts/verify-result.json", + "docs/核心知识点.png", + "docs/核心能力.png", + "docs/需求开发流程.png", + "harness_hooks/harness-inject-context.py", + "harness_hooks/harness-session-start.py", + "harness_hooks/harness-workflow-state.py", + "harness_scripts/task.py", + "harness_scripts/verify.py", + "init-harness.py", + "tests/test_hooks.py", + "tests/test_init_harness.py", + "tests/test_integration.py", + "tests/test_task_cli.py", + "tests/test_verify.py" + ], + "errors": [] + }, + "changedFiles": [ + ".claude/agents/architect.md", + ".claude/agents/developer.md", + ".claude/agents/tester.md", + ".claude/commands/harness/continue.md", + ".claude/commands/harness/finish.md", + ".claude/hooks/harness-inject-context.py", + ".claude/hooks/harness-session-start.py", + ".claude/hooks/harness-workflow-state.py", + ".claude/settings.json", + ".claude/skills/grill-me/SKILL.md", + ".claude/skills/harness-configure-verify/SKILL.md", + ".claude/skills/harness-implement/SKILL.md", + ".claude/skills/project-doc-scanner/SKILL.md", + ".claude/skills/requirement-confirmation/SKILL.md", + ".claude/skills/requirement-development/SKILL.md", + ".codex/config.toml", + ".codex/hooks.json", + ".codex/hooks/harness-inject-context.py", + ".codex/hooks/harness-session-start.py", + ".codex/hooks/harness-workflow-state.py", + ".gitignore", + ".harness/scripts/context.py", + ".harness/scripts/project.py", + ".harness/scripts/task.py", + ".harness/scripts/team_cleanup.py", + ".harness/scripts/verify.py", + ".harness/verify.json", + ".harness/workflow.md", + "AGENTS.md", + "CLAUDE.md", + "README.md", + "docs/harness-share-report.html", + "docs/index.md", + "docs/standards/index.md", + "docs/tasks/06-02-business-contracts/clarification.jsonl", + "docs/tasks/06-02-business-contracts/clarification.md", + "docs/tasks/06-02-business-contracts/context.architect.jsonl", + "docs/tasks/06-02-business-contracts/context.developer.jsonl", + "docs/tasks/06-02-business-contracts/context.tester.jsonl", + "docs/tasks/06-02-business-contracts/implementation-plan.md", + "docs/tasks/06-02-business-contracts/review-result.json", + "docs/tasks/06-02-business-contracts/scope.json", + "docs/tasks/06-02-business-contracts/task.json", + "docs/tasks/06-02-business-contracts/test-result.green.json", + "docs/tasks/06-02-business-contracts/test-result.red.json", + "docs/tasks/06-02-business-contracts/verify-result.json", + "docs/核心知识点.png", + "docs/核心能力.png", + "docs/需求开发流程.png", + "harness_hooks/harness-inject-context.py", + "harness_hooks/harness-session-start.py", + "harness_hooks/harness-workflow-state.py", + "harness_scripts/task.py", + "harness_scripts/verify.py", + "init-harness.py", + "tests/test_hooks.py", + "tests/test_init_harness.py", + "tests/test_integration.py", + "tests/test_task_cli.py", + "tests/test_verify.py" + ], + "finishedAt": "2026-06-02T08:18:03.754891+00:00", + "generatedBy": "verify.py all" +} diff --git "a/docs/\346\240\270\345\277\203\347\237\245\350\257\206\347\202\271.png" "b/docs/\346\240\270\345\277\203\347\237\245\350\257\206\347\202\271.png" new file mode 100644 index 0000000..396f7c6 Binary files /dev/null and "b/docs/\346\240\270\345\277\203\347\237\245\350\257\206\347\202\271.png" differ diff --git "a/docs/\346\240\270\345\277\203\350\203\275\345\212\233.png" "b/docs/\346\240\270\345\277\203\350\203\275\345\212\233.png" new file mode 100644 index 0000000..a3ea52e Binary files /dev/null and "b/docs/\346\240\270\345\277\203\350\203\275\345\212\233.png" differ diff --git "a/docs/\351\234\200\346\261\202\345\274\200\345\217\221\346\265\201\347\250\213.png" "b/docs/\351\234\200\346\261\202\345\274\200\345\217\221\346\265\201\347\250\213.png" new file mode 100644 index 0000000..b769d8d Binary files /dev/null and "b/docs/\351\234\200\346\261\202\345\274\200\345\217\221\346\265\201\347\250\213.png" differ diff --git a/harness_hooks/harness-inject-context.py b/harness_hooks/harness-inject-context.py index 0ab2b50..a438c17 100644 --- a/harness_hooks/harness-inject-context.py +++ b/harness_hooks/harness-inject-context.py @@ -277,7 +277,7 @@ def phase_edit_violation(root: Path, task_dir: Path | None, phase: str | None, t rel = normalize_target_path(root, target) if task_dir is None: if is_code_path(rel): - return f"没有 active task,禁止修改业务代码 {rel}。请先进入 requirement-development 并创建 task。" + return f"没有 active task,禁止修改业务代码 {rel}。请先进入 requirement-confirmation 完成需求确认。" return None if phase == "clarify": if is_code_path(rel): @@ -346,8 +346,8 @@ def main() -> int: if tool_name in ROLE_TOOLS and task_dir is None: return emit_block( - "没有 active task,禁止启动开发子任务。请先通过 requirement-development 创建 task," - "并使用 python3 .harness/scripts/task.py create \"<任务名>\" 进入 clarify 阶段。" + "没有 active task,禁止启动开发子任务。请先使用 requirement-confirmation 完成需求确认," + "确认后再使用 python3 .harness/scripts/task.py create \"<任务名>\" 创建任务。" ) role = infer_role(tool_input) diff --git a/harness_hooks/harness-session-start.py b/harness_hooks/harness-session-start.py index a6ab09d..13db647 100644 --- a/harness_hooks/harness-session-start.py +++ b/harness_hooks/harness-session-start.py @@ -10,6 +10,13 @@ from pathlib import Path LOCAL_CONTEXT_KEY = "local" +PHASE_ROLE = { + "doc-plan": "architect", + "red": "tester", + "green": "developer", + "review": "architect", + "validate": "tester", +} def find_project_root(start: Path) -> Path | None: @@ -98,21 +105,28 @@ def unique_in_progress_task(root: Path) -> dict | None: def build_context(task: dict | None) -> str: parts = [] if task: + required_role = PHASE_ROLE.get(task["phase"]) + role_line = f"\nRequired subagent: {required_role}" if required_role else "" parts.append( f"Active task: {task['title']} ({task['status']})\n" f"Phase: {task['phase']}\n" f"Execution mode: {task['executionMode']}\n" f"Path: {task['path']}\n" - "Required skill: requirement-development\n" - "继续当前任务时必须使用需求开发 skill 推进阶段,禁止退回原生直接开发流程。" + "Required skill: requirement-development" + f"{role_line}\n" + "继续当前任务时必须使用需求开发 skill,并通过必选子代理推进阶段。" ) else: - parts.append("No active task.") + parts.append( + "No active task.\n" + "Required skill: requirement-confirmation\n" + "新需求、实现请求、模块规划和按文档开发请求,都必须先完成需求确认。" + ) parts.append( "\nNatural language entries:\n" - "- 按 design.md 开发\n" - "- 继续需求开发\n" + "- 按 design.md 开发: use requirement-confirmation first\n" + "- 继续需求开发: use requirement-development after confirmed clarification\n" "- 查看当前需求开发状态\n" "- 归档当前任务" ) diff --git a/harness_hooks/harness-workflow-state.py b/harness_hooks/harness-workflow-state.py index 72841dc..2784b6a 100644 --- a/harness_hooks/harness-workflow-state.py +++ b/harness_hooks/harness-workflow-state.py @@ -10,6 +10,13 @@ from pathlib import Path LOCAL_CONTEXT_KEY = "local" +PHASE_ROLE = { + "doc-plan": "architect", + "red": "tester", + "green": "developer", + "review": "architect", + "validate": "tester", +} TAG_RE = re.compile( r"\[workflow-phase:([A-Za-z0-9_-]+)\]\s*\n(.*?)\n\s*\[/workflow-phase:\1\]", re.DOTALL, @@ -104,14 +111,22 @@ def unique_in_progress_task(root: Path) -> dict | None: def build_breadcrumb(task: dict | None, body: str) -> str: if task: + required_role = PHASE_ROLE.get(task["phase"]) + role_line = f"\nRequired subagent: {required_role}" if required_role else "" header = ( f"Task: {task['path']} ({task['status']})\n" f"Phase: {task['phase']}\n" - "Required skill: requirement-development\n" - "继续当前任务时必须使用需求开发 skill 推进阶段,禁止退回原生直接开发流程。" + "Required skill: requirement-development" + f"{role_line}\n" + "继续当前任务时必须使用需求开发 skill,并通过必选子代理推进阶段。" ) else: - header = "Status: no_task\nPhase: no_task" + header = ( + "Status: no_task\n" + "Phase: no_task\n" + "Required skill: requirement-confirmation\n" + "新需求、实现请求、模块规划和按文档开发请求,都必须先完成需求确认。" + ) return f"\n{header}\n{body}\n" diff --git a/harness_scripts/project.py b/harness_scripts/project.py new file mode 100644 index 0000000..613f9a5 --- /dev/null +++ b/harness_scripts/project.py @@ -0,0 +1,342 @@ +#!/usr/bin/env python3 +"""Project documentation CLI for harness.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import sys +from datetime import datetime, timezone +from pathlib import Path + + +CONFIG_PATH = ".harness/project-docs.json" +PROFILE_PATH = ".harness/project-profile.json" +BLOCK_START = "" +BLOCK_END = "" + +DEFAULT_DOCUMENTS = [ + { + "id": "projectGuide", + "title": "项目说明", + "output": "docs/standards/project-guide.md", + "description": "记录项目架构、模块职责、启动方式、开发约定和常见变更入口。", + }, + { + "id": "apiUrlIndex", + "title": "接口地址索引", + "output": "docs/standards/api/url-index.md", + "description": "记录项目暴露的 HTTP、RPC 或事件接口入口和代码位置。", + }, + { + "id": "apiDetail", + "title": "接口详情", + "output": "docs/standards/api/detail.md", + "description": "记录接口请求、响应、鉴权、错误码和重要业务约束。", + }, +] + + +def _utc_now() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _find_project_root() -> Path: + current = Path.cwd().resolve() + while current != current.parent: + if (current / ".harness").is_dir(): + return current + current = current.parent + script = Path(__file__).resolve() + if script.parent.name == "scripts" and script.parent.parent.name == ".harness": + return script.parent.parent.parent + print("Error: .harness/ directory not found", file=sys.stderr) + sys.exit(1) + + +def _read_json(path: Path) -> dict: + if not path.is_file(): + return {} + try: + data = json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + print(f"Error: invalid JSON: {path}: {exc}", file=sys.stderr) + sys.exit(2) + if not isinstance(data, dict): + print(f"Error: JSON root must be object: {path}", file=sys.stderr) + sys.exit(2) + return data + + +def _write_json(path: Path, data: dict) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") + + +def _default_config() -> dict: + return { + "version": 1, + "preset": "default", + "documents": DEFAULT_DOCUMENTS, + "review": { + "initialStatus": "draft", + "statuses": ["draft", "approved", "needs_update", "stale", "missing"], + }, + "analysis": { + "cacheDir": ".harness/analysis/latest", + "keepHistory": False, + }, + "contextInjection": { + "standardsIndex": "docs/standards/index.md", + "documents": [item["output"] for item in DEFAULT_DOCUMENTS], + }, + } + + +def _document_from_config(config: dict) -> list[dict]: + documents = config.get("documents") + if not isinstance(documents, list) or not documents: + return DEFAULT_DOCUMENTS + valid = [] + for item in documents: + if not isinstance(item, dict): + continue + output = item.get("output") + doc_id = item.get("id") + if isinstance(output, str) and output and isinstance(doc_id, str) and doc_id: + valid.append(item) + return valid or DEFAULT_DOCUMENTS + + +def _sha256_file(path: Path) -> str: + digest = hashlib.sha256() + digest.update(path.read_bytes()) + return "sha256:" + digest.hexdigest() + + +def _profile_document(root: Path, doc: dict, existing: dict | None = None) -> dict: + existing = existing or {} + rel_path = doc["output"] + path = root / rel_path + content_hash = _sha256_file(path) if path.is_file() else "" + old_hash = existing.get("contentHash") if isinstance(existing.get("contentHash"), str) else "" + old_status = existing.get("reviewStatus") if isinstance(existing.get("reviewStatus"), str) else "" + if not path.is_file(): + review_status = "missing" + elif old_status == "approved" and old_hash == content_hash: + review_status = "approved" + elif old_status == "approved" and old_hash and old_hash != content_hash: + review_status = "stale" + else: + review_status = old_status if old_status in {"draft", "needs_update"} else "draft" + return { + "id": doc["id"], + "title": doc.get("title", doc["id"]), + "path": rel_path, + "reviewStatus": review_status, + "contentHash": content_hash if review_status == "approved" else old_hash, + "generatedAt": existing.get("generatedAt"), + "approvedAt": existing.get("approvedAt") if review_status == "approved" else None, + "approvedBy": existing.get("approvedBy") if review_status == "approved" else None, + } + + +def _load_config(root: Path) -> dict: + config = _read_json(root / CONFIG_PATH) + return config if config else _default_config() + + +def _load_profile(root: Path) -> dict: + return _read_json(root / PROFILE_PATH) + + +def _build_profile(root: Path, config: dict) -> dict: + previous = _load_profile(root) + previous_docs = { + item.get("id"): item + for item in previous.get("documents", []) + if isinstance(item, dict) and isinstance(item.get("id"), str) + } + return { + "version": 1, + "updatedAt": _utc_now(), + "documents": [ + _profile_document(root, doc, previous_docs.get(doc["id"])) + for doc in _document_from_config(config) + ], + "review": { + "openQuestionsCount": previous.get("review", {}).get("openQuestionsCount", 0), + "highPriorityOpenQuestionsCount": previous.get("review", {}).get("highPriorityOpenQuestionsCount", 0), + }, + } + + +def _replace_managed_block(content: str, block: str) -> str: + if BLOCK_START in content and BLOCK_END in content: + before = content.split(BLOCK_START, 1)[0].rstrip() + after = content.split(BLOCK_END, 1)[1].lstrip() + pieces = [before, block.strip(), after] + return "\n\n".join(piece for piece in pieces if piece) + "\n" + sep = "\n\n" if content.strip() else "" + return content.rstrip() + sep + block.strip() + "\n" + + +def _docs_index_block() -> str: + return f"""\ +{BLOCK_START} +## 项目知识文档 + +| 文档 | 用途 | +| --- | --- | +| `docs/standards/project-guide.md` | 项目架构、模块职责和开发入口 | +| `docs/standards/api/` | 接口索引和接口详情 | +{BLOCK_END} +""" + + +def _standards_index_block(config: dict) -> str: + rows = [ + f"| `{doc['output']}` | {doc.get('description', doc.get('title', doc['id']))} |" + for doc in _document_from_config(config) + ] + return "\n".join( + [ + BLOCK_START, + "## 项目知识文档", + "", + "| 文档 | 用途 |", + "| --- | --- |", + *rows, + BLOCK_END, + "", + ] + ) + + +def _update_index(path: Path, block: str, default_title: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + content = path.read_text(encoding="utf-8") if path.is_file() else f"# {default_title}\n" + path.write_text(_replace_managed_block(content, block), encoding="utf-8") + + +def _ensure_dirs(root: Path, config: dict) -> None: + (root / ".harness" / "analysis" / "latest").mkdir(parents=True, exist_ok=True) + for doc in _document_from_config(config): + (root / doc["output"]).parent.mkdir(parents=True, exist_ok=True) + + +def cmd_init_config(args: argparse.Namespace) -> int: + root = _find_project_root() + config_path = root / CONFIG_PATH + config = _load_config(root) + _ensure_dirs(root, config) + if not config_path.is_file(): + _write_json(config_path, config) + else: + _write_json(config_path, config) + _write_json(root / PROFILE_PATH, _build_profile(root, config)) + _update_index(root / "docs" / "index.md", _docs_index_block(), "文档索引") + _update_index(root / "docs" / "standards" / "index.md", _standards_index_block(config), "团队工程规范索引") + print(f"Project documentation config initialized: {CONFIG_PATH}") + return 0 + + +def _summarize(profile: dict) -> dict: + summary = {"total": 0, "missing": 0, "draft": 0, "approved": 0, "needs_update": 0, "stale": 0} + for doc in profile.get("documents", []): + if not isinstance(doc, dict): + continue + summary["total"] += 1 + status = doc.get("reviewStatus", "draft") + if status not in summary: + summary[status] = 0 + summary[status] += 1 + return summary + + +def cmd_status(args: argparse.Namespace) -> int: + root = _find_project_root() + initialized = (root / CONFIG_PATH).is_file() and (root / PROFILE_PATH).is_file() + config = _load_config(root) + profile = _build_profile(root, config) + if initialized: + _write_json(root / PROFILE_PATH, profile) + output = { + "initialized": initialized, + "summary": _summarize(profile), + "documents": profile["documents"], + } + if args.json: + print(json.dumps(output, indent=2, ensure_ascii=False)) + return 0 + print("Project documentation status:") + for doc in profile["documents"]: + print(f" {doc['reviewStatus']}: {doc['path']}") + return 0 + + +def cmd_approve(args: argparse.Namespace) -> int: + root = _find_project_root() + config = _load_config(root) + profile = _build_profile(root, config) + approved_by = args.approved_by or "local" + targets = {doc["id"] for doc in profile["documents"]} if args.all else set(args.document or []) + if not targets: + print("Error: use --all or --document ", file=sys.stderr) + return 2 + now = _utc_now() + changed = 0 + for doc in profile["documents"]: + if doc["id"] not in targets: + continue + path = root / doc["path"] + if not path.is_file(): + doc["reviewStatus"] = "missing" + continue + doc["reviewStatus"] = "approved" + doc["contentHash"] = _sha256_file(path) + doc["approvedAt"] = now + doc["approvedBy"] = approved_by + if not doc.get("generatedAt"): + doc["generatedAt"] = now + changed += 1 + profile["updatedAt"] = now + _write_json(root / PROFILE_PATH, profile) + print(f"Approved project documents: {changed}") + return 0 + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Manage harness project documentation") + subparsers = parser.add_subparsers(dest="command") + + docs = subparsers.add_parser("docs", help="Manage project documentation") + docs_subparsers = docs.add_subparsers(dest="docs_command") + + init_config = docs_subparsers.add_parser("init-config", help="Create project documentation config") + init_config.set_defaults(func=cmd_init_config) + + status = docs_subparsers.add_parser("status", help="Show project documentation status") + status.add_argument("--json", action="store_true", help="Print JSON output") + status.set_defaults(func=cmd_status) + + approve = docs_subparsers.add_parser("approve", help="Approve generated project documentation") + approve.add_argument("--all", action="store_true", help="Approve every existing configured document") + approve.add_argument("--document", action="append", help="Approve one configured document id") + approve.add_argument("--approved-by", default="local", help="Reviewer name recorded in profile") + approve.set_defaults(func=cmd_approve) + return parser + + +def main() -> int: + parser = build_parser() + args = parser.parse_args() + if not hasattr(args, "func"): + parser.print_help() + return 2 + return args.func(args) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/harness_scripts/task.py b/harness_scripts/task.py index 7c8c768..9a009cc 100644 --- a/harness_scripts/task.py +++ b/harness_scripts/task.py @@ -23,16 +23,18 @@ TASKS_ROOT = Path("docs/tasks") PHASE_ORDER = ("clarify", "doc-plan", "red", "green", "review", "validate", "done") VALID_INTENTS = ("requirement-development", "requirement-confirmation") -VALID_EXECUTION_MODES = ("agent-team", "single-session", "hybrid") +VALID_EXECUTION_MODES = ("agent-team",) PLAN_SECTIONS = ( "开发意图摘要", "影响范围", "技术方案", "可测试契约", + "业务契约覆盖", "Slice 顺序", "验证方式", "已知限制", ) +BUSINESS_CONTRACT_REQUIRED_FIELDS = ("id", "scenario", "expectedBehavior") def _utc_now() -> str: @@ -295,6 +297,25 @@ def _latest_confirmation(task_dir: Path) -> dict | None: def _render_clarification(task_dir: Path, confirmation: dict) -> None: criteria = "\n".join(f"{idx}. {item}" for idx, item in enumerate(confirmation["acceptanceCriteria"], start=1)) boundaries = "\n".join(f"{idx}. {item}" for idx, item in enumerate(confirmation["boundaries"], start=1)) + business_contracts = confirmation.get("businessContracts") or [] + contract_section = "" + if business_contracts: + rows = [ + "| 编号 | 业务场景 | 输入条件 | 预期行为 | 可观测信息 | 是否需要测试 |", + "| --- | --- | --- | --- | --- | --- |", + ] + for item in business_contracts: + rows.append( + "| {id} | {scenario} | {input} | {expected} | {observable} | {test_required} |".format( + id=item.get("id", ""), + scenario=item.get("scenario", ""), + input=item.get("input", ""), + expected=item.get("expectedBehavior", ""), + observable=item.get("observable", ""), + test_required="是" if item.get("testRequired", True) else "否", + ) + ) + contract_section = "\n## 业务契约\n\n" + "\n".join(rows) + "\n" content = f"""--- confirmation_source: {confirmation["confirmationSource"]} confirmed: true @@ -302,6 +323,7 @@ def _render_clarification(task_dir: Path, confirmation: dict) -> None: open_questions: [] source_doc: {confirmation["sourceDoc"]} source_doc_hash: {confirmation["sourceDocHash"]} +business_contracts: {len(business_contracts)} --- # 需求确认 @@ -317,13 +339,34 @@ def _render_clarification(task_dir: Path, confirmation: dict) -> None: ## 边界条件 {boundaries} +{contract_section} """ (task_dir / "clarification.md").write_text(content, encoding="utf-8") +def _parse_business_contracts(raw_items: list[str] | None) -> tuple[list[dict], str | None]: + contracts = [] + for idx, raw in enumerate(raw_items or [], start=1): + try: + item = json.loads(raw) + except json.JSONDecodeError as exc: + return [], f"business-contract:{idx}: invalid JSON: {exc}" + if not isinstance(item, dict): + return [], f"business-contract:{idx}: must be a JSON object" + missing = [field for field in BUSINESS_CONTRACT_REQUIRED_FIELDS if not item.get(field)] + if missing: + return [], f"business-contract:{idx}: missing required fields: {', '.join(missing)}" + contracts.append(item) + return contracts, None + + def cmd_clarify_confirm(args: argparse.Namespace) -> int: root = _find_project_root() task_dir = _resolve_task_dir(root, args.task) + business_contracts, parse_error = _parse_business_contracts(args.business_contract) + if parse_error: + print(f"Error: {parse_error}", file=sys.stderr) + return 1 record = { "event": "confirm", "at": _utc_now(), @@ -336,6 +379,7 @@ def cmd_clarify_confirm(args: argparse.Namespace) -> int: "openQuestions": [], "confirmed": True, "confirmedBy": "collaborator", + "businessContracts": business_contracts, } error = _validate_confirmation(record) if error: @@ -527,9 +571,12 @@ def _advance_review(root: Path, task_dir: Path) -> int: def _review_passed(data: dict) -> bool: + business = data.get("businessContractCoverage", {}) return ( data.get("specCompliance", {}).get("status") == "passed" and data.get("codeQuality", {}).get("status") == "passed" + and business.get("status") == "passed" + and not business.get("missing") and not data.get("blockingIssues") ) @@ -540,7 +587,7 @@ def _advance_validate(root: Path, task_dir: Path) -> int: return _fail_advance(task_dir, "validate", "missing review-result.json") review = _read_json(path, "review-result.json") if not _review_passed(review): - return _fail_advance(task_dir, "validate", "review-result.json has blocking issues") + return _fail_advance(task_dir, "validate", "review-result.json has blocking issues or business contract review failed") if sorted(review.get("changedFiles", [])) != _changed_files(root): return _fail_advance(task_dir, "validate", "working tree changed after review") _advance_to(task_dir, "validate", [_task_ref(path, root)]) @@ -589,6 +636,10 @@ def cmd_review_record(args: argparse.Namespace) -> int: "changedFiles": _changed_files(root), "specCompliance": {"status": args.spec_compliance, "issues": []}, "codeQuality": {"status": args.code_quality, "issues": []}, + "businessContractCoverage": { + "status": args.business_contract_status, + "missing": args.missing_contract or [], + }, "blockingIssues": blocking, "summary": args.summary or "", "finishedAt": _utc_now(), @@ -697,6 +748,7 @@ def build_parser() -> argparse.ArgumentParser: p_confirm.add_argument("--development-intent", required=True) p_confirm.add_argument("--acceptance-criterion", action="append", required=True) p_confirm.add_argument("--boundary", action="append", required=True) + p_confirm.add_argument("--business-contract", action="append") p_confirm.add_argument("--source-doc", default="inline-request") p_confirm.add_argument("--source-hash") p_confirm.add_argument("--confirmation-source", choices=("live", "imported"), default="live") @@ -713,6 +765,8 @@ def build_parser() -> argparse.ArgumentParser: p_record.add_argument("--task") p_record.add_argument("--spec-compliance", choices=("passed", "failed", "not_started"), required=True) p_record.add_argument("--code-quality", choices=("passed", "failed", "not_started"), required=True) + p_record.add_argument("--business-contract-status", choices=("passed", "failed", "not_started"), default="passed") + p_record.add_argument("--missing-contract", action="append") p_record.add_argument("--blocking-issue", action="append") p_record.add_argument("--summary") diff --git a/harness_scripts/verify.py b/harness_scripts/verify.py index 35fef05..b791fa8 100755 --- a/harness_scripts/verify.py +++ b/harness_scripts/verify.py @@ -258,6 +258,8 @@ def run_red_green(root: Path, args: argparse.Namespace, *, mode: str) -> int: "targetTests": args.target_test, "stdout": result.stdout[-4000:], "stderr": result.stderr[-4000:], + "contractCoverage": parse_contract_coverage(args.contract_coverage), + "uncoveredContracts": args.uncovered_contract or [], "finishedAt": utc_now(), "generatedBy": f"verify.py {mode}", } @@ -275,12 +277,33 @@ def run_red_green(root: Path, args: argparse.Namespace, *, mode: str) -> int: return 1 +def parse_contract_coverage(values: list[str] | None) -> dict[str, list[str]]: + coverage: dict[str, list[str]] = {} + for raw in values or []: + if "=" not in raw: + print(f"Error: invalid contract coverage mapping: {raw}", file=sys.stderr) + sys.exit(2) + contract_id, tests = raw.split("=", 1) + contract_id = contract_id.strip() + target_tests = [item.strip() for item in tests.split(",") if item.strip()] + if not contract_id or not target_tests: + print(f"Error: invalid contract coverage mapping: {raw}", file=sys.stderr) + sys.exit(2) + coverage.setdefault(contract_id, []) + for target_test in target_tests: + if target_test not in coverage[contract_id]: + coverage[contract_id].append(target_test) + return coverage + + def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description="Run harness quality checks") parser.add_argument("check", choices=ALL_CHECKS) parser.add_argument("--task", help="Task dir name or docs/tasks/ for checks") parser.add_argument("--command", help="Command for red/green evidence") parser.add_argument("--target-test", action="append", help="Target test identifier for red/green evidence") + parser.add_argument("--contract-coverage", action="append", help="Business contract coverage mapping, for example BC-001=TestA,TestB") + parser.add_argument("--uncovered-contract", action="append", help="Business contract id without a current test mapping") return parser.parse_args() diff --git a/init-harness.py b/init-harness.py index 69fb474..3232cf8 100644 --- a/init-harness.py +++ b/init-harness.py @@ -12,6 +12,7 @@ import argparse import json +import os import shutil import subprocess import sys @@ -23,6 +24,9 @@ CAVEMAN_INSTALL_URL = ( "https://git.xiaojukeji.com/morganli/caveman/raw/main/install-internal.sh" ) +COOPER_SKILL_URL = "https://skillshub.intra.xiaojukeji.com/skill/cooper" +COOPER_MCP_BASE_URL = "http://127.0.0.1:28582/v1/hub/cooper_mcp" +D_SKILLS_NPM_REGISTRY = "http://npm.intra.xiaojukeji.com" HARNESS_HOOKS = { "SessionStart": [ @@ -230,6 +234,7 @@ def create_harness_skeleton(target: Path) -> None: "team_cleanup.py": TEAM_CLEANUP_STUB, "context.py": CONTEXT_PY_STUB, "verify.py": VERIFY_PY_STUB, + "project.py": PROJECT_PY_STUB, } for filename, stub in script_files.items(): real = src_scripts / filename @@ -369,6 +374,10 @@ def create_claude_commands(target: Path) -> None: "Ask the questions one at a time.", "requirement-confirmation", ), + "project-doc-scanner": ( + "先检测,再确认,再扫描", + ".harness/scripts/project.py docs status", + ), } @@ -392,7 +401,12 @@ def _write_managed_skill(path: Path, content: str, skill_name: str) -> None: path.write_text(content, encoding="utf-8") -def create_skill_files(skills_dir: Path, harness_implement: str | None = None) -> None: +def create_skill_files( + skills_dir: Path, + harness_implement: str | None = None, + *, + include_project_doc_scanner: bool = True, +) -> None: """Write harness skill files into a skills directory.""" requirement_confirmation_dir = skills_dir / "requirement-confirmation" requirement_confirmation_dir.mkdir(parents=True, exist_ok=True) @@ -427,6 +441,15 @@ def create_skill_files(skills_dir: Path, harness_implement: str | None = None) - grill_me_skill_dir.mkdir(parents=True, exist_ok=True) _write_managed_skill(grill_me_skill_dir / "SKILL.md", SKILL_GRILL_ME_COMPAT, "grill-me") + if include_project_doc_scanner: + project_doc_scanner_dir = skills_dir / "project-doc-scanner" + project_doc_scanner_dir.mkdir(parents=True, exist_ok=True) + _write_managed_skill( + project_doc_scanner_dir / "SKILL.md", + SKILL_PROJECT_DOC_SCANNER, + "project-doc-scanner", + ) + def create_claude_skills(target: Path) -> None: """Write skill files for Claude Code (v1.5+).""" @@ -457,7 +480,11 @@ def remove_managed_deepseek_skills() -> None: def create_codex_skills() -> None: """Write skill files for Codex.""" - create_skill_files(Path.home() / ".codex" / "skills", SKILL_HARNESS_IMPLEMENT_COMPAT) + create_skill_files( + Path.home() / ".codex" / "skills", + SKILL_HARNESS_IMPLEMENT_COMPAT, + include_project_doc_scanner=False, + ) HARNESS_SECTION_MARKER = "# Agent Harness" @@ -671,6 +698,14 @@ def _write_if_missing(path: Path, content: str) -> None: sys.exit(1) """ +PROJECT_PY_STUB = """\ +#!/usr/bin/env python3 +\"\"\"project.py — placeholder. Real implementation in harness_scripts/.\"\"\" +import sys +print("project.py: real implementation not deployed", file=sys.stderr) +sys.exit(1) +""" + DOCS_INDEX_MD = """\ # 文档索引 @@ -752,6 +787,13 @@ def _write_if_missing(path: Path, content: str) -> None: from pathlib import Path LOCAL_CONTEXT_KEY = "local" +PHASE_ROLE = { + "doc-plan": "architect", + "red": "tester", + "green": "developer", + "review": "architect", + "validate": "tester", +} def find_project_root(start: Path) -> Path | None: @@ -840,21 +882,28 @@ def unique_in_progress_task(root: Path) -> dict | None: def build_context(task: dict | None) -> str: parts = [] if task: + required_role = PHASE_ROLE.get(task["phase"]) + role_line = f"\\nRequired subagent: {required_role}" if required_role else "" parts.append( f"Active task: {task['title']} ({task['status']})\n" f"Phase: {task['phase']}\n" f"Execution mode: {task['executionMode']}\n" f"Path: {task['path']}\n" - "Required skill: requirement-development\n" - "继续当前任务时必须使用需求开发 skill 推进阶段,禁止退回原生直接开发流程。" + "Required skill: requirement-development" + f"{role_line}\n" + "继续当前任务时必须使用需求开发 skill,并通过必选子代理推进阶段。" ) else: - parts.append("No active task.") + parts.append( + "No active task.\n" + "Required skill: requirement-confirmation\n" + "新需求、实现请求、模块规划和按文档开发请求,都必须先完成需求确认。" + ) parts.append( "\nNatural language entries:\n" - "- 按 design.md 开发\n" - "- 继续需求开发\n" + "- 按 design.md 开发: use requirement-confirmation first\n" + "- 继续需求开发: use requirement-development after confirmed clarification\n" "- 查看当前需求开发状态\n" "- 归档当前任务" ) @@ -906,8 +955,15 @@ def main() -> int: from pathlib import Path LOCAL_CONTEXT_KEY = "local" +PHASE_ROLE = { + "doc-plan": "architect", + "red": "tester", + "green": "developer", + "review": "architect", + "validate": "tester", +} TAG_RE = re.compile( - r"\[workflow-phase:([A-Za-z0-9_-]+)\]\s*\n(.*?)\n\s*\[/workflow-phase:\1\]", + r"\\[workflow-phase:([A-Za-z0-9_-]+)\\]\\s*\\n(.*?)\\n\\s*\\[/workflow-phase:\\1\\]", re.DOTALL, ) @@ -1000,14 +1056,22 @@ def unique_in_progress_task(root: Path) -> dict | None: def build_breadcrumb(task: dict | None, body: str) -> str: if task: + required_role = PHASE_ROLE.get(task["phase"]) + role_line = f"\\nRequired subagent: {required_role}" if required_role else "" header = ( f"Task: {task['path']} ({task['status']})\n" f"Phase: {task['phase']}\n" - "Required skill: requirement-development\n" - "继续当前任务时必须使用需求开发 skill 推进阶段,禁止退回原生直接开发流程。" + "Required skill: requirement-development" + f"{role_line}\n" + "继续当前任务时必须使用需求开发 skill,并通过必选子代理推进阶段。" ) else: - header = "Status: no_task\nPhase: no_task" + header = ( + "Status: no_task\n" + "Phase: no_task\n" + "Required skill: requirement-confirmation\n" + "新需求、实现请求、模块规划和按文档开发请求,都必须先完成需求确认。" + ) return f"\n{header}\n{body}\n" @@ -1319,7 +1383,7 @@ def phase_edit_violation(root: Path, task_dir: Path | None, phase: str | None, t rel = normalize_target_path(root, target) if task_dir is None: if is_code_path(rel): - return f"没有 active task,禁止修改业务代码 {rel}。请先进入 requirement-development 并创建 task。" + return f"没有 active task,禁止修改业务代码 {rel}。请先进入 requirement-confirmation 完成需求确认。" return None if phase == "clarify": if is_code_path(rel): @@ -1388,8 +1452,8 @@ def main() -> int: if tool_name in ROLE_TOOLS and task_dir is None: return emit_block( - "没有 active task,禁止启动开发子任务。请先通过 requirement-development 创建 task," - "并使用 python3 .harness/scripts/task.py create \"<任务名>\" 进入 clarify 阶段。" + "没有 active task,禁止启动开发子任务。请先使用 requirement-confirmation 完成需求确认," + "确认后再使用 python3 .harness/scripts/task.py create \"<任务名>\" 创建任务。" ) role = infer_role(tool_input) @@ -2113,11 +2177,99 @@ def main() -> int: ``` """ +SKILL_PROJECT_DOC_SCANNER = """\ +--- +name: project-doc-scanner +description: | + 当协作者要求扫描当前项目、初始化项目文档、生成接口文档、更新 docs/standards 下项目知识文档时使用。 + 首版面向 Claude Code 使用。 +--- + +# Project Doc Scanner + + + +该 skill 用于把当前项目扫描结果整理成支持 AI 自动化开发的长期项目文档。文档会放在 `docs/standards/`,负责人可以 review 文档是否准确。 + +## 核心规则 + +固定采用“先检测,再确认,再扫描”。 + +1. 先运行 `python3 .harness/scripts/project.py docs status --json` 检查当前项目文档状态。 +2. 如果 `.harness/project-docs.json` 或 `.harness/project-profile.json` 缺失,先询问是否执行初始化。 +3. 初始化命令为 `python3 .harness/scripts/project.py docs init-config`。 +4. 如果已有 `approved` 文档,覆盖前必须先得到明确确认,并保留备份。 +5. 扫描时优先使用 Claude Code 子代理读取不同代码区域,避免主会话窗口被大量代码细节污染。 +6. 生成文档时,每个关键判断都要写明“判断、证据、置信度、待确认项”。 + +## 默认产物 + +| 文件 | 用途 | +| --- | --- | +| `.harness/project-docs.json` | 项目文档初始化配置 | +| `.harness/project-profile.json` | 文档审阅状态、哈希和负责人确认记录 | +| `docs/standards/project-guide.md` | 项目架构、模块职责、启动方式和开发入口 | +| `docs/standards/api/url-index.md` | 接口地址索引 | +| `docs/standards/api/detail.md` | 接口请求、响应、鉴权、错误码和业务约束 | +| `.harness/analysis/latest/*.json` | 子代理扫描中间结果 | + +## 操作顺序 + +### 1. 检测状态 + +```bash +python3 .harness/scripts/project.py docs status --json +``` + +根据输出判断文档是否缺失、草稿、已确认、需要更新或过期。 + +### 2. 初始化配置 + +在得到确认后运行: + +```bash +python3 .harness/scripts/project.py docs init-config +``` + +该命令只创建配置、状态文件、`docs/standards/api/` 目录,并维护 `docs/index.md` 和 `docs/standards/index.md` 中的 harness 管理区块。 + +### 3. 子代理扫描 + +根据项目类型拆分扫描任务。常见拆分方式: + +| 子代理 | 扫描范围 | +| --- | --- | +| 架构扫描 | 入口文件、模块目录、依赖方向、启动方式 | +| 接口扫描 | 路由、控制器、RPC 定义、事件入口 | +| 质量扫描 | 测试命令、构建命令、配置文件、常见变更约束 | + +子代理只返回结构化摘要和证据文件位置,主会话负责汇总成文档。 + +### 4. 文档生成与确认 + +文档生成后先保持 `draft` 状态。负责人确认准确后运行: + +```bash +python3 .harness/scripts/project.py docs approve --all --approved-by "" +``` + +审批后 `.harness/project-profile.json` 会记录文档哈希。后续文档内容变化会显示为 `stale`。 + +## 生成要求 + +1. 接口文档必须覆盖入口地址、代码位置、请求字段、响应字段、鉴权方式、错误情况和待确认项。 +2. 项目说明必须覆盖目录结构、核心模块、启动方式、测试方式、常见开发入口和重要约束。 +3. 对无法确认的内容,写入待确认项,禁止编造成确定事实。 +4. 已确认文档默认不能覆盖。只有在协作者明确同意覆盖后,才生成新版本。 +5. 临时扫描结果默认只保留 `.harness/analysis/latest/`,配置 `keepHistory=true` 时再保存历史。 +""" + SKILL_REQUIREMENT_CONFIRMATION = """\ --- name: requirement-confirmation description: | 需求确认 skill。用于在需求开发前逐项确认开发意图、验收标准、边界条件、依赖关系和未决问题。 + 初始实现请求也必须先使用本 skill,例如 "按 design.md 开发"、"按照需求开发"、"参考 docs 进行业务逻辑开发"、"进行模块规划"。 触发语包括 "需求确认"、"确认需求"、"grill me"、"先问清楚需求"、"需求还要再确认"。 --- @@ -2146,12 +2298,15 @@ def main() -> int: | `developmentIntent` | 开发者理解的开发意图 | | `acceptanceCriteria` | 可验证的验收标准 | | `boundaries` | 明确的范围边界 | +| `businessContracts` | 业务契约列表,记录场景、输入、预期行为、可观测信息和测试要求 | | `openQuestions` | 必须为空数组 | | `confirmed` | 必须为 `true` | | `confirmedBy` | 必须为 `collaborator` | | `sourceDoc` | 需求来源文件或 `inline-request` | | `sourceDocHash` | 需求来源内容哈希 | +业务契约是通用结构,用于让订单、支付、推荐、运营后台等日常需求都能把业务细节转成可测试、可审查的记录。每条契约至少包含 `id`、`scenario` 和 `expectedBehavior`。 + `clarification.jsonl` 是阶段推进门禁依据,`clarification.md` 只作为阅读快照。 """ @@ -2159,8 +2314,8 @@ def main() -> int: --- name: requirement-development description: | - 需求开发 skill。用于依据 design.md、spec.md、requirements.md 或协作者的内联需求,在 harness 项目中完成流程化开发。 - 触发语包括 "按 design.md 开发"、"按照需求开发"、"继续需求开发"、"走 harness 流程"、"implement design.md"。 + 需求开发 skill。仅在需求确认完成后使用,用于依据有效 clarification.jsonl 在 harness 项目中推进后续阶段。 + 触发语包括 "继续需求开发"、"查看当前需求开发状态"、"归档当前任务"。 --- # 需求开发 @@ -2171,10 +2326,12 @@ def main() -> int: ## 前置要求 -进入开发前必须先使用 `requirement-confirmation`。如果当前任务尚未生成有效 `clarification.jsonl` 确认记录,应当自动转入需求确认。 +进入开发前必须先使用 `requirement-confirmation`。如果当前任务尚未生成有效 `clarification.jsonl` 确认记录,必须停止需求开发流程,改用 `requirement-confirmation`。 即使需求文档完整,也至少复述开发意图,确认验收标准和范围边界。 +未完成需求确认时,不得创建 task、不得进行模块规划、不得编写 `implementation-plan.md`,也不得调度 `architect`、`developer` 或 `tester`。 + ## 阶段顺序 | 阶段 | 责任角色 | 必要证据 | @@ -2200,6 +2357,7 @@ def main() -> int: ## 影响范围 ## 技术方案 ## 可测试契约 +## 业务契约覆盖 ## Slice 顺序 ## 验证方式 ## 已知限制 @@ -2207,9 +2365,11 @@ def main() -> int: ## 执行规则 -默认使用 `agent-team` 执行模式。每个阶段按需调用对应角色,角色会通过 hook 注入 `docs/standards/index.md`、`clarification.md`、`implementation-plan.md` 和角色自己的 `context..jsonl`。 +固定使用 `agent-team` 执行模式。每个阶段必须调用对应子代理,角色会通过 hook 注入 `docs/standards/index.md`、`clarification.md`、`implementation-plan.md` 和角色自己的 `context..jsonl`。 + +阶段与必选子代理的对应关系固定为:`doc-plan` 和 `review` 使用 `architect`,`red` 和 `validate` 使用 `tester`,`green` 使用 `developer`。主会话负责阶段协调、验证和提交,不负责代替子代理编写阶段产物或业务代码。 -如果当前运行环境没有子代理能力,可以降级为 `single-session`,并在 `task.json.executionModeFallbackReason` 记录原因。 +业务契约必须贯穿后续阶段:`red` 和 `green` 证据通过 `contractCoverage` 和 `uncoveredContracts` 记录测试映射;`review-result.json` 通过 `businessContractCoverage` 记录审查结果;进入 `validate` 前业务契约审查必须通过。 每个阶段完成后必须写入对应证据文件,再通过 `task.py advance` 进入下一阶段。最终使用 `verify.py all` 生成 `verify-result.json`,通过后才能进入 `done`。 """ @@ -2255,9 +2415,9 @@ def main() -> int: 读取 `clarification.md`、`implementation-plan.md`、`docs/standards/index.md` 和 `context.architect.jsonl` 中明确引用的文件。 -在 `doc-plan` 阶段,编写 `implementation-plan.md` 和 `scope.json`。计划文件只能保存实现计划,必须包含固定章节:开发意图摘要、影响范围、技术方案、可测试契约、Slice 顺序、验证方式、已知限制。 +在 `doc-plan` 阶段,编写 `implementation-plan.md` 和 `scope.json`。计划文件只能保存实现计划,必须包含固定章节:开发意图摘要、影响范围、技术方案、可测试契约、业务契约覆盖、Slice 顺序、验证方式、已知限制。 -在 `review` 阶段,检查当前变更是否符合需求确认、实现计划和团队规范,并通过 `task.py review record` 写入 `review-result.json`。需要修正代码时保持测试通过。 +在 `review` 阶段,检查当前变更是否符合需求确认、实现计划、业务契约和团队规范,并通过 `task.py review record` 写入 `review-result.json`。需要修正代码时保持测试通过。业务契约未覆盖时,使用 `--business-contract-status failed` 和 `--missing-contract <契约编号>` 记录。 禁止手工编辑受控文件:`task.json`、`clarification.jsonl`、`clarification.md`、`test-result.red.json`、`test-result.green.json`、`review-result.json`、`verify-result.json`。 """ @@ -2272,7 +2432,7 @@ def main() -> int: 读取 `clarification.md`、`implementation-plan.md`、`docs/standards/index.md` 和 `context.developer.jsonl` 中明确引用的文件。 -在 `green` 阶段,先确认 `test-result.red.json` 已经记录目标测试的预期失败,再实现代码使同一组目标测试通过。通过后使用 `verify.py green` 写入 `test-result.green.json`。 +在 `green` 阶段,先确认 `test-result.red.json` 已经记录目标测试的预期失败,再实现代码使同一组目标测试通过。通过后使用 `verify.py green` 写入 `test-result.green.json`。实现代码需要满足 `implementation-plan.md` 中的业务契约覆盖要求;测试暂未覆盖但计划明确要求的业务契约,也需要在实现报告中说明对应代码位置。 实现必须遵守 `scope.json` 的变更范围。发现需求、计划或测试之间存在冲突时,停止实现并返回主会话处理。 @@ -2289,7 +2449,7 @@ def main() -> int: 读取 `clarification.md`、`implementation-plan.md`、`docs/standards/index.md` 和 `context.tester.jsonl` 中明确引用的文件。 -在 `red` 阶段,根据可测试契约编写目标测试,并使用 `verify.py red` 写入 `test-result.red.json`。该阶段要求目标测试出现预期失败。 +在 `red` 阶段,根据可测试契约和业务契约覆盖要求编写目标测试,并使用 `verify.py red` 写入 `test-result.red.json`。该阶段要求目标测试出现预期失败。测试证据需要通过 `--contract-coverage BC-001=TestName` 记录业务契约与测试的映射;暂时无法测试的契约使用 `--uncovered-contract BC-001` 记录。 在 `validate` 阶段,补充边界测试并运行必要验证,最终由主会话运行 `verify.py all` 写入 `verify-result.json`。 @@ -2589,7 +2749,7 @@ def main() -> int: | 表达 | 处理方式 | | --- | --- | -| 按 `design.md` 开发 | 进入 `requirement-development` | +| 按 `design.md` 开发 | 先进入 `requirement-confirmation`,确认后再进入 `requirement-development` | | 继续需求开发 | 读取当前任务并推进下一阶段 | | 查看当前需求开发状态 | 读取 `task.json` 的 `status` 和 `phase` | | 归档当前任务 | 在 `phase=done` 后移动到 `docs/tasks/archive/` | @@ -2614,6 +2774,20 @@ def main() -> int: `clarification.jsonl` 是需求确认门禁依据,`clarification.md` 是阅读快照。 +需求确认中可以记录业务契约。业务契约用于保存业务场景、输入条件、预期行为、可观测信息和测试要求,使业务细节能够进入后续计划、测试和审查。 + +## 业务契约 + +业务契约在各阶段的使用方式如下: + +| 阶段 | 契约要求 | +| --- | --- | +| `clarify` | `clarification.jsonl` 可记录 `businessContracts` | +| `doc-plan` | `implementation-plan.md` 必须包含 `业务契约覆盖` | +| `red` | `test-result.red.json` 通过 `contractCoverage` 记录测试映射 | +| `green` | `test-result.green.json` 继续记录同一批契约的实现验证 | +| `review` | `review-result.json` 通过 `businessContractCoverage` 记录审查结果 | + ## 角色职责 | 阶段 | 角色 | 主要产物 | @@ -2860,6 +3034,221 @@ def install_caveman(skip: bool, dry_run: bool) -> str: return "installed" if _caveman_present() else "install_failed" +def _cooper_present(target: Path) -> bool: + """Return true when Cooper is available to Claude Code and Codex.""" + claude_skill = target / ".claude" / "skills" / "cooper" / "SKILL.md" + codex_skill_candidates = [ + Path.home() / ".codex" / "skills" / "cooper" / "SKILL.md", + Path.home() / ".agents" / "skills" / "cooper" / "SKILL.md", + ] + return claude_skill.is_file() and any(path.is_file() for path in codex_skill_candidates) + + +def _d_skills_usable() -> bool: + """Check whether d-skills can execute commands in the current environment.""" + if not shutil.which("d-skills"): + return False + try: + result = subprocess.run( + ["d-skills", "--version"], + capture_output=True, + text=True, + check=False, + timeout=15, + ) + except (subprocess.SubprocessError, OSError): + return False + combined = result.stdout + result.stderr + if "过低" in combined or "最低要求" in combined: + return False + return result.returncode == 0 + + +def _npm_global_bin_dir() -> Path | None: + if not shutil.which("npm"): + return None + try: + result = subprocess.run( + ["npm", "prefix", "-g"], + capture_output=True, + text=True, + check=False, + timeout=15, + ) + except (subprocess.SubprocessError, OSError): + return None + if result.returncode != 0: + return None + prefix = result.stdout.strip() + return Path(prefix) / "bin" if prefix else None + + +def _prepend_path(path: Path | None) -> None: + if path is None or not path.is_dir(): + return + path_text = str(path) + current = os.environ.get("PATH", "") + parts = current.split(os.pathsep) if current else [] + if parts and parts[0] == path_text: + return + os.environ["PATH"] = os.pathsep.join([path_text, *[part for part in parts if part != path_text]]) + + +def _install_global_npm_package(package: str) -> bool: + if not shutil.which("npm"): + return False + try: + result = subprocess.run( + ["npm", "install", package, f"--registry={D_SKILLS_NPM_REGISTRY}", "-g"], + check=False, + timeout=180, + ) + except (subprocess.SubprocessError, OSError): + return False + _prepend_path(_npm_global_bin_dir()) + return result.returncode == 0 + + +def _ensure_d_skills() -> bool: + """Install or refresh d-skills so Cooper can be installed from SkillsHub.""" + if _d_skills_usable(): + return True + _prepend_path(_npm_global_bin_dir()) + if _d_skills_usable(): + return True + if not _install_global_npm_package("d-skills@latest"): + return False + return _d_skills_usable() + + +def _ensure_mcporter() -> bool: + if shutil.which("mcporter"): + return True + _prepend_path(_npm_global_bin_dir()) + if shutil.which("mcporter"): + return True + if not _install_global_npm_package("mcporter"): + return False + return shutil.which("mcporter") is not None + + +def _cooper_mcp_status() -> str: + if not shutil.which("mcporter"): + return "needs_mcporter" + + config_path = Path.home() / ".mcporter" / "mcporter.json" + if not config_path.is_file(): + return "missing_config" + + try: + config = json.loads(config_path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError): + return "invalid_config" + + cooper = config.get("mcpServers", {}).get("Cooper") + if not isinstance(cooper, dict): + return "missing_cooper" + if cooper.get("baseUrl") != COOPER_MCP_BASE_URL: + return "wrong_base_url" + + headers = cooper.get("headers", {}) + authorization = headers.get("Authorization") if isinstance(headers, dict) else None + if not isinstance(authorization, str) or len(authorization) < 100: + return "needs_authorization" + return "configured" + + +def install_cooper(skip: bool, dry_run: bool, target: Path) -> str: + """Best-effort Cooper skill install via SkillsHub d-skills CLI. + + Status values: skipped / already_installed / would_install / would_skip / + installed / install_failed / needs_d_skills / needs_d_skills_login. + """ + if skip: + return "skipped" + if _cooper_present(target): + return "already_installed" + + if dry_run: + return "would_install" if _d_skills_usable() or shutil.which("npm") else "would_skip" + + if not _ensure_d_skills(): + return "needs_d_skills" + _ensure_mcporter() + + commands = [ + ["d-skills", "add", "cooper", "-a", "claude-code", "-y", "--copy"], + ["d-skills", "add", "cooper", "-g", "-a", "codex", "-y", "--copy"], + ] + for command in commands: + try: + result = subprocess.run( + command, + cwd=target, + capture_output=True, + text=True, + check=False, + timeout=180, + ) + except (subprocess.SubprocessError, OSError): + return "install_failed" + if result.returncode != 0: + combined = result.stdout + result.stderr + if "d-skills login" in combined or "需要先登录" in combined: + return "needs_d_skills_login" + return "install_failed" + + return "installed" if _cooper_present(target) else "install_failed" + + +def report_cooper_status(status: str) -> None: + """Print a human-readable line summarizing Cooper install status.""" + messages = { + "skipped": "Cooper auto-install skipped (--no-cooper).", + "already_installed": "Cooper skill already installed for Claude Code and Codex.", + "would_install": "Cooper would be installed from SkillsHub (--check-deps; not run).", + "would_skip": "Cooper missing AND d-skills/npm unavailable — would skip.", + "installed": "Cooper skill installed from SkillsHub for Claude Code and Codex.", + "needs_d_skills_login": "Cooper skill requires d-skills login:\n" + " d-skills login\n" + " d-skills add cooper -a claude-code -y --copy\n" + " d-skills add cooper -g -a codex -y --copy", + "install_failed": "Cooper install attempted but verification failed; install manually:\n" + " d-skills add cooper -a claude-code -y --copy\n" + " d-skills add cooper -g -a codex -y --copy", + "needs_d_skills": "Cooper not installed and d-skills could not be prepared.\n" + f" Install d-skills first: npm install d-skills@latest --registry={D_SKILLS_NPM_REGISTRY} -g\n" + f" Then open {COOPER_SKILL_URL}", + } + print(f" cooper: {messages.get(status, status)}") + + +def report_cooper_environment(status: str) -> None: + if status == "skipped": + return + + _prepend_path(_npm_global_bin_dir()) + if _d_skills_usable(): + print(" d-skills: available.") + else: + print( + " d-skills: unavailable; install manually:\n" + f" npm install d-skills@latest --registry={D_SKILLS_NPM_REGISTRY} -g" + ) + + mcp_messages = { + "configured": f"configured ({COOPER_MCP_BASE_URL}).", + "needs_mcporter": "mcporter not found; install manually: npm install -g mcporter", + "missing_config": "missing ~/.mcporter/mcporter.json.", + "invalid_config": "invalid ~/.mcporter/mcporter.json.", + "missing_cooper": "missing Cooper entry in ~/.mcporter/mcporter.json.", + "wrong_base_url": f"Cooper baseUrl should be {COOPER_MCP_BASE_URL}.", + "needs_authorization": "Cooper Authorization is missing or too short.", + } + mcp_status = _cooper_mcp_status() + print(f" cooper mcp: {mcp_messages.get(mcp_status, mcp_status)}") + + def report_caveman_status(status: str) -> None: """Print a human-readable line summarizing Caveman install status.""" messages = { @@ -2912,6 +3301,11 @@ def main(): action="store_true", help="Skip Caveman auto-install (CI / restricted networks)", ) + parser.add_argument( + "--no-cooper", + action="store_true", + help="Skip Cooper skill auto-install from SkillsHub (CI / restricted networks)", + ) parser.add_argument( "--check-deps", action="store_true", @@ -2924,9 +3318,12 @@ def main(): # Dry-run: just report status, don't write files rtk_status = install_rtk(skip=args.no_rtk, dry_run=True) caveman_status = install_caveman(skip=args.no_caveman, dry_run=True) + cooper_status = install_cooper(skip=args.no_cooper, dry_run=True, target=target) print(f"Check-deps for harness target: {target}") report_rtk_status(rtk_status) report_caveman_status(caveman_status) + report_cooper_status(cooper_status) + report_cooper_environment(cooper_status) return create_harness_skeleton(target) @@ -2944,6 +3341,7 @@ def main(): rtk_status = install_rtk(skip=args.no_rtk, dry_run=False) caveman_status = install_caveman(skip=args.no_caveman, dry_run=False) + cooper_status = install_cooper(skip=args.no_cooper, dry_run=False, target=target) print(f"✓ Harness initialized in {target}") print(f" .harness/ — scripts, workflow, verification config") @@ -2956,10 +3354,18 @@ def main(): print(f" AGENTS.md — harness conventions (created or appended)") report_rtk_status(rtk_status) report_caveman_status(caveman_status) + report_cooper_status(cooper_status) + report_cooper_environment(cooper_status) print("") - print("下一步建议配置提交前检查:") - print(" 请配置 harness verify。先读取当前项目的构建和测试入口,给出 .harness/verify.json 推荐配置,等确认后再写入。") - print(" 已安装 skill: harness-configure-verify") + print("下一步建议:") + print(" 1. 配置提交前检查") + print(" 请配置 harness verify。先读取当前项目的构建和测试入口,给出 .harness/verify.json 推荐配置,确认后再写入。") + print(" 已安装 skill: harness-configure-verify") + print(" 2. 扫描当前项目") + print(" 可先扫描一次项目,生成 docs/standards 下的项目知识文档,后续需求开发会读取这些资料。") + print(" 已安装 skill: project-doc-scanner") + print(" 3. 开始需求开发") + print(" 准备 design.md、spec.md 或 requirements.md 后,使用「按 design.md 开发」先完成 requirement-confirmation,确认后再进入 requirement-development。") if __name__ == "__main__": diff --git a/install-internal.sh b/install-internal.sh index aa20eff..ab901e2 100755 --- a/install-internal.sh +++ b/install-internal.sh @@ -8,7 +8,7 @@ # Pass extra flags to init-harness.py (note the -s -- separator): # # curl -fsSL https://git.xiaojukeji.com/morganli/harness/raw/master/install-internal.sh \ -# | bash -s -- --no-rtk --no-caveman +# | bash -s -- --no-rtk --no-caveman --no-cooper # # Override defaults via env vars: # diff --git a/tests/test_hooks.py b/tests/test_hooks.py index 0f336a3..21e9a94 100644 --- a/tests/test_hooks.py +++ b/tests/test_hooks.py @@ -94,6 +94,8 @@ def test_no_task_uses_no_task_phase(self): ctx = output["hookSpecificOutput"]["additionalContext"] self.assertIn("", ctx) self.assertIn("没有当前任务", ctx) + self.assertIn("Required skill: requirement-confirmation", ctx) + self.assertNotIn("Required skill: requirement-development", ctx) def test_task_uses_phase_not_status(self): self.write_task(phase="green") @@ -102,6 +104,7 @@ def test_task_uses_phase_not_status(self): self.assertIn("Phase: green", ctx) self.assertIn("编码实现阶段", ctx) self.assertIn("Required skill: requirement-development", ctx) + self.assertIn("Required subagent: developer", ctx) class TestSessionStartHook(HookTestCase): @@ -116,9 +119,18 @@ def test_includes_active_task_phase_and_natural_language_commands(self): self.assertIn("订单超时控制", ctx) self.assertIn("doc-plan", ctx) self.assertIn("Required skill: requirement-development", ctx) + self.assertIn("Required subagent: architect", ctx) self.assertIn("继续需求开发", ctx) self.assertIn("查看当前需求开发状态", ctx) + def test_no_active_task_requires_requirement_confirmation(self): + output = _run_hook(self.hook_path, {"cwd": str(self.project_dir)}) + ctx = output["hookSpecificOutput"]["additionalContext"] + self.assertIn("No active task.", ctx) + self.assertIn("Required skill: requirement-confirmation", ctx) + self.assertIn("按 design.md 开发", ctx) + self.assertNotIn("Required skill: requirement-development", ctx) + def test_exports_context_id_to_claude_env_file(self): env_file = self.project_dir / "claude_env" env_file.write_text("", encoding="utf-8") @@ -191,7 +203,8 @@ def test_no_active_task_blocks_development_subtask(self): ) ctx = output["hookSpecificOutput"]["additionalContext"] self.assertIn("没有 active task", ctx) - self.assertIn("task.py create", ctx) + self.assertIn("requirement-confirmation", ctx) + self.assertNotIn("requirement-development 创建 task", ctx) def test_no_active_task_blocks_task_create_style_development_tool(self): output = _run_hook( @@ -205,6 +218,7 @@ def test_no_active_task_blocks_task_create_style_development_tool(self): ctx = output["hookSpecificOutput"]["additionalContext"] self.assertIn("没有 active task", ctx) self.assertIn("禁止启动开发子任务", ctx) + self.assertIn("requirement-confirmation", ctx) def test_no_active_task_blocks_business_code_edit(self): (self.project_dir / "src").mkdir(exist_ok=True) @@ -218,6 +232,7 @@ def test_no_active_task_blocks_business_code_edit(self): ) ctx = output["hookSpecificOutput"]["additionalContext"] self.assertIn("没有 active task", ctx) + self.assertIn("requirement-confirmation", ctx) def test_doc_plan_blocks_business_code_edit(self): self.write_task(phase="doc-plan") diff --git a/tests/test_init_harness.py b/tests/test_init_harness.py index a67271b..3fb2752 100644 --- a/tests/test_init_harness.py +++ b/tests/test_init_harness.py @@ -25,19 +25,24 @@ def tearDown(self): shutil.rmtree(self.project_dir) shutil.rmtree(self.home_dir) - def run_init(self, *extra_args): + def run_init(self, *extra_args, install_cooper=False, extra_env=None): args = [sys.executable, str(INIT_SCRIPT), "--target", str(self.project_dir)] if "--check-deps" not in extra_args: if "--no-rtk" not in extra_args: args.append("--no-rtk") if "--no-caveman" not in extra_args: args.append("--no-caveman") + if not install_cooper and "--no-cooper" not in extra_args: + args.append("--no-cooper") args.extend(extra_args) + env = {**os.environ, "HOME": str(self.home_dir)} + if extra_env: + env.update(extra_env) return subprocess.run( args, capture_output=True, text=True, - env={**os.environ, "HOME": str(self.home_dir)}, + env=env, ) @@ -53,6 +58,7 @@ def test_creates_harness_and_docs_structure(self): self.assertTrue((harness / "scripts" / "task.py").is_file()) self.assertTrue((harness / "scripts" / "verify.py").is_file()) self.assertTrue((harness / "scripts" / "context.py").is_file()) + self.assertTrue((harness / "scripts" / "project.py").is_file()) self.assertFalse((harness / "tasks").exists()) self.assertFalse((harness / "spec").exists()) @@ -61,6 +67,18 @@ def test_creates_harness_and_docs_structure(self): self.assertTrue((self.project_dir / "docs" / "index.md").is_file()) self.assertTrue((self.project_dir / "docs" / "standards" / "index.md").is_file()) + def test_prints_numbered_next_steps_including_project_scan(self): + result = self.run_init() + self.assertEqual(result.returncode, 0, result.stderr) + + self.assertIn("下一步建议:", result.stdout) + self.assertIn("1. 配置提交前检查", result.stdout) + self.assertIn("2. 扫描当前项目", result.stdout) + self.assertIn("3. 开始需求开发", result.stdout) + self.assertIn("已安装 skill: harness-configure-verify", result.stdout) + self.assertIn("已安装 skill: project-doc-scanner", result.stdout) + self.assertIn("先完成 requirement-confirmation", result.stdout) + def test_verify_config_template_uses_required_checks(self): self.run_init() @@ -113,6 +131,8 @@ def test_creates_new_and_compat_skills_for_claude_and_codex(self): claude_skills = self.project_dir / ".claude" / "skills" codex_skills = self.home_dir / ".codex" / "skills" + self.assertTrue((claude_skills / "project-doc-scanner" / "SKILL.md").is_file()) + self.assertFalse((codex_skills / "project-doc-scanner").exists()) for base in (claude_skills, codex_skills): for skill_name in ( "requirement-confirmation", @@ -129,15 +149,30 @@ def test_creates_new_and_compat_skills_for_claude_and_codex(self): grill_compat = (claude_skills / "grill-me" / "SKILL.md").read_text(encoding="utf-8") self.assertIn("name: requirement-confirmation", confirmation) + self.assertIn("按 design.md 开发", confirmation) + self.assertIn("按照需求开发", confirmation) self.assertIn("每次只提出一个问题", confirmation) + self.assertIn("业务契约", confirmation) self.assertIn("confirmedBy", confirmation) self.assertIn("name: requirement-development", development) + self.assertIn("仅在需求确认完成后使用", development) + self.assertIn("业务契约覆盖", development) + self.assertNotIn('触发语包括 "按 design.md 开发"', development) + self.assertNotIn("按照需求开发", development) self.assertIn("requirement-confirmation", development) + self.assertIn("停止需求开发流程,改用 `requirement-confirmation`", development) + self.assertIn("不得创建 task", development) + self.assertIn("必须调用对应子代理", development) + self.assertNotIn("single-session", development) self.assertIn("task.py advance", development) self.assertIn("name: harness-implement", harness_compat) self.assertIn("requirement-development", harness_compat) self.assertIn("name: grill-me", grill_compat) self.assertIn("requirement-confirmation", grill_compat) + project_doc_scanner = (claude_skills / "project-doc-scanner" / "SKILL.md").read_text(encoding="utf-8") + self.assertIn("name: project-doc-scanner", project_doc_scanner) + self.assertIn("先检测,再确认,再扫描", project_doc_scanner) + self.assertIn(".harness/scripts/project.py docs status", project_doc_scanner) def test_managed_skills_refresh_without_overwriting_custom_skills(self): managed_dir = self.project_dir / ".claude" / "skills" / "harness-implement" @@ -181,6 +216,304 @@ def test_removes_managed_deepseek_skills_and_preserves_custom(self): self.assertFalse(managed.exists()) self.assertTrue((custom / "SKILL.md").is_file()) + def test_installs_cooper_skill_for_claude_and_codex_via_d_skills(self): + fake_bin = Path(tempfile.mkdtemp()) + self.addCleanup(shutil.rmtree, fake_bin) + d_skills = fake_bin / "d-skills" + d_skills.write_text( + """#!/usr/bin/env python3 +import os +import sys +from pathlib import Path + +args = sys.argv[1:] +if "--version" in args: + print("0.3.16") + raise SystemExit(0) +if args[:2] == ["add", "cooper"]: + if "-g" in args: + skill_dir = Path(os.environ["HOME"]) / ".codex" / "skills" / "cooper" + else: + skill_dir = Path.cwd() / ".claude" / "skills" / "cooper" + skill_dir.mkdir(parents=True, exist_ok=True) + (skill_dir / "SKILL.md").write_text("---\\nname: cooper\\n---\\n", encoding="utf-8") + raise SystemExit(0) +raise SystemExit(2) +""", + encoding="utf-8", + ) + d_skills.chmod(0o755) + mcporter = fake_bin / "mcporter" + mcporter.write_text("#!/usr/bin/env sh\nexit 0\n", encoding="utf-8") + mcporter.chmod(0o755) + mcporter_config = self.home_dir / ".mcporter" + mcporter_config.mkdir() + (mcporter_config / "mcporter.json").write_text( + json.dumps( + { + "mcpServers": { + "Cooper": { + "baseUrl": "http://127.0.0.1:28582/v1/hub/cooper_mcp", + "headers": {"Authorization": f"Bearer {'a' * 120}"}, + } + } + } + ), + encoding="utf-8", + ) + + result = self.run_init( + install_cooper=True, + extra_env={"PATH": f"{fake_bin}{os.pathsep}{os.environ.get('PATH', '')}"}, + ) + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertTrue((self.project_dir / ".claude" / "skills" / "cooper" / "SKILL.md").is_file()) + self.assertTrue((self.home_dir / ".codex" / "skills" / "cooper" / "SKILL.md").is_file()) + self.assertIn("cooper", result.stdout.lower()) + self.assertIn("d-skills: available", result.stdout) + self.assertIn("cooper mcp: configured", result.stdout) + + def test_uses_npm_global_d_skills_after_refreshing_stale_path_entry(self): + fake_bin = Path(tempfile.mkdtemp()) + fake_prefix = Path(tempfile.mkdtemp()) + self.addCleanup(shutil.rmtree, fake_bin) + self.addCleanup(shutil.rmtree, fake_prefix) + + stale_d_skills = fake_bin / "d-skills" + stale_d_skills.write_text( + """#!/usr/bin/env sh +echo "🚨 您的 d-skills 版本 (0.3.9) 过低,无法继续使用。最低要求: 0.3.16。" +exit 0 +""", + encoding="utf-8", + ) + stale_d_skills.chmod(0o755) + + npm = fake_bin / "npm" + npm.write_text( + """#!/usr/bin/env python3 +import os +import stat +import sys +from pathlib import Path + +prefix = Path(os.environ["FAKE_NPM_PREFIX"]) +args = sys.argv[1:] +if args == ["prefix", "-g"]: + print(prefix) + raise SystemExit(0) +if args[:2] == ["install", "d-skills@latest"]: + bin_dir = prefix / "bin" + bin_dir.mkdir(parents=True, exist_ok=True) + d_skills = bin_dir / "d-skills" + d_skills.write_text('''#!/usr/bin/env python3 +import os +import sys +from pathlib import Path +args = sys.argv[1:] +if "--version" in args: + print("0.3.16") + raise SystemExit(0) +if args[:2] == ["add", "cooper"]: + if "-g" in args: + skill_dir = Path(os.environ["HOME"]) / ".codex" / "skills" / "cooper" + else: + skill_dir = Path.cwd() / ".claude" / "skills" / "cooper" + skill_dir.mkdir(parents=True, exist_ok=True) + (skill_dir / "SKILL.md").write_text("---\\\\nname: cooper\\\\n---\\\\n", encoding="utf-8") + raise SystemExit(0) +raise SystemExit(2) +''', encoding="utf-8") + d_skills.chmod(d_skills.stat().st_mode | stat.S_IXUSR) + raise SystemExit(0) +if args[:2] == ["install", "mcporter"]: + bin_dir = prefix / "bin" + bin_dir.mkdir(parents=True, exist_ok=True) + mcporter = bin_dir / "mcporter" + mcporter.write_text("#!/usr/bin/env sh\\nexit 0\\n", encoding="utf-8") + mcporter.chmod(mcporter.stat().st_mode | stat.S_IXUSR) + raise SystemExit(0) +raise SystemExit(2) +""", + encoding="utf-8", + ) + npm.chmod(0o755) + + result = self.run_init( + install_cooper=True, + extra_env={ + "PATH": f"{fake_bin}{os.pathsep}{os.environ.get('PATH', '')}", + "FAKE_NPM_PREFIX": str(fake_prefix), + }, + ) + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("cooper: Cooper skill installed", result.stdout) + self.assertIn("d-skills: available", result.stdout) + self.assertNotIn("d-skills could not be prepared", result.stdout) + self.assertNotIn("mcporter not found", result.stdout) + + def test_reports_d_skills_login_required_when_cooper_add_requires_auth(self): + fake_bin = Path(tempfile.mkdtemp()) + self.addCleanup(shutil.rmtree, fake_bin) + + d_skills = fake_bin / "d-skills" + d_skills.write_text( + """#!/usr/bin/env python3 +import sys + +args = sys.argv[1:] +if "--version" in args: + print("0.3.16") + raise SystemExit(0) +if args[:2] == ["add", "cooper"]: + print("当前命令需要先登录后才能执行。") + print("请先运行 d-skills login 完成登录。") + raise SystemExit(1) +raise SystemExit(2) +""", + encoding="utf-8", + ) + d_skills.chmod(0o755) + + mcporter = fake_bin / "mcporter" + mcporter.write_text("#!/usr/bin/env sh\nexit 0\n", encoding="utf-8") + mcporter.chmod(0o755) + mcporter_config = self.home_dir / ".mcporter" + mcporter_config.mkdir() + (mcporter_config / "mcporter.json").write_text( + json.dumps( + { + "mcpServers": { + "Cooper": { + "baseUrl": "http://127.0.0.1:28582/v1/hub/cooper_mcp", + "headers": {"Authorization": f"Bearer {'a' * 120}"}, + } + } + } + ), + encoding="utf-8", + ) + + result = self.run_init( + install_cooper=True, + extra_env={"PATH": f"{fake_bin}{os.pathsep}{os.environ.get('PATH', '')}"}, + ) + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("d-skills login", result.stdout) + self.assertIn("cooper: Cooper skill requires d-skills login", result.stdout) + self.assertIn("d-skills: available", result.stdout) + self.assertIn("cooper mcp: configured", result.stdout) + + def test_accepts_codex_cooper_skill_installed_in_agents_global_dir(self): + fake_bin = Path(tempfile.mkdtemp()) + self.addCleanup(shutil.rmtree, fake_bin) + d_skills = fake_bin / "d-skills" + d_skills.write_text( + """#!/usr/bin/env python3 +import os +import sys +from pathlib import Path + +args = sys.argv[1:] +if "--version" in args: + print("0.3.16") + raise SystemExit(0) +if args[:2] == ["add", "cooper"]: + if "-g" in args: + skill_dir = Path(os.environ["HOME"]) / ".agents" / "skills" / "cooper" + else: + skill_dir = Path.cwd() / ".claude" / "skills" / "cooper" + skill_dir.mkdir(parents=True, exist_ok=True) + (skill_dir / "SKILL.md").write_text("---\\nname: cooper\\n---\\n", encoding="utf-8") + raise SystemExit(0) +raise SystemExit(2) +""", + encoding="utf-8", + ) + d_skills.chmod(0o755) + + result = self.run_init( + install_cooper=True, + extra_env={"PATH": f"{fake_bin}{os.pathsep}{os.environ.get('PATH', '')}"}, + ) + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertTrue((self.project_dir / ".claude" / "skills" / "cooper" / "SKILL.md").is_file()) + self.assertTrue((self.home_dir / ".agents" / "skills" / "cooper" / "SKILL.md").is_file()) + self.assertIn("cooper: Cooper skill installed", result.stdout) + self.assertNotIn("verification failed", result.stdout) + + def test_reports_d_skills_available_when_cooper_is_already_installed_and_path_has_stale_binary(self): + fake_bin = Path(tempfile.mkdtemp()) + fake_prefix = Path(tempfile.mkdtemp()) + self.addCleanup(shutil.rmtree, fake_bin) + self.addCleanup(shutil.rmtree, fake_prefix) + + stale_d_skills = fake_bin / "d-skills" + stale_d_skills.write_text( + """#!/usr/bin/env sh +echo "🚨 您的 d-skills 版本 (0.3.9) 过低,无法继续使用。最低要求: 0.3.16。" +exit 0 +""", + encoding="utf-8", + ) + stale_d_skills.chmod(0o755) + + prefix_bin = fake_prefix / "bin" + prefix_bin.mkdir(parents=True) + fresh_d_skills = prefix_bin / "d-skills" + fresh_d_skills.write_text( + """#!/usr/bin/env sh +echo "0.3.16" +exit 0 +""", + encoding="utf-8", + ) + fresh_d_skills.chmod(0o755) + + npm = fake_bin / "npm" + npm.write_text( + """#!/usr/bin/env python3 +import os +import sys +from pathlib import Path + +if sys.argv[1:] == ["prefix", "-g"]: + print(Path(os.environ["FAKE_NPM_PREFIX"])) + raise SystemExit(0) +raise SystemExit(2) +""", + encoding="utf-8", + ) + npm.chmod(0o755) + + (self.project_dir / ".claude" / "skills" / "cooper").mkdir(parents=True) + (self.project_dir / ".claude" / "skills" / "cooper" / "SKILL.md").write_text( + "---\nname: cooper\n---\n", + encoding="utf-8", + ) + (self.home_dir / ".agents" / "skills" / "cooper").mkdir(parents=True) + (self.home_dir / ".agents" / "skills" / "cooper" / "SKILL.md").write_text( + "---\nname: cooper\n---\n", + encoding="utf-8", + ) + + result = self.run_init( + install_cooper=True, + extra_env={ + "PATH": f"{fake_bin}{os.pathsep}{os.environ.get('PATH', '')}", + "FAKE_NPM_PREFIX": str(fake_prefix), + }, + ) + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("cooper: Cooper skill already installed", result.stdout) + self.assertIn("d-skills: available", result.stdout) + self.assertNotIn("d-skills: unavailable", result.stdout) + class TestInitHarnessHooksAndInstructions(InitHarnessTestCase): def test_creates_hooks_settings_and_instruction_files(self): @@ -205,6 +538,7 @@ def test_creates_hooks_settings_and_instruction_files(self): self.assertIn("# Agent Harness", content) self.assertIn("docs/tasks/", content) self.assertIn("requirement-confirmation", content) + self.assertIn("| 按 `design.md` 开发 | 先进入 `requirement-confirmation`", content) self.assertIn("clarify -> doc-plan -> red -> green -> review -> validate -> done -> archived", content) def test_instruction_append_is_idempotent(self): @@ -280,6 +614,15 @@ def test_context_script_outputs_docs_task_context(self): self.assertIn("implementation-plan.md", output.stdout) self.assertIn("继续开发", output.stdout) + def test_installed_agent_instructions_include_business_contract_section(self): + result = self.run_init() + self.assertEqual(result.returncode, 0, result.stderr) + + content = (self.project_dir / "AGENTS.md").read_text(encoding="utf-8") + + self.assertIn("业务契约", content) + self.assertIn("业务契约覆盖", content) + def test_embedded_inject_hook_template_reads_docs_task_context(self): standalone_dir = self.project_dir / "standalone" standalone_dir.mkdir() @@ -287,7 +630,7 @@ def test_embedded_inject_hook_template_reads_docs_task_context(self): shutil.copy2(INIT_SCRIPT, standalone_init) result = subprocess.run( - [sys.executable, str(standalone_init), "--target", str(self.project_dir), "--no-rtk", "--no-caveman"], + [sys.executable, str(standalone_init), "--target", str(self.project_dir), "--no-rtk", "--no-caveman", "--no-cooper"], capture_output=True, text=True, env={**os.environ, "HOME": str(self.home_dir)}, @@ -335,18 +678,20 @@ def test_embedded_inject_hook_template_reads_docs_task_context(self): class TestDependencyFlags(InitHarnessTestCase): def test_no_rtk_and_no_caveman_flags_report_skipped(self): - result = self.run_init("--no-rtk", "--no-caveman") + result = self.run_init("--no-rtk", "--no-caveman", "--no-cooper") self.assertEqual(result.returncode, 0, result.stderr) combined = result.stdout + result.stderr self.assertIn("rtk", combined.lower()) self.assertIn("caveman", combined.lower()) + self.assertIn("cooper", combined.lower()) self.assertIn("skipped", combined.lower()) def test_check_deps_reports_without_writing_project_files(self): - result = self.run_init("--check-deps", "--no-rtk", "--no-caveman") + result = self.run_init("--check-deps", "--no-rtk", "--no-caveman", "--no-cooper") self.assertEqual(result.returncode, 0, result.stderr) combined = result.stdout + result.stderr self.assertIn("Check-deps", combined) + self.assertIn("cooper", combined.lower()) self.assertFalse((self.project_dir / ".harness").exists()) def test_help_mentions_dependency_flags(self): @@ -354,6 +699,7 @@ def test_help_mentions_dependency_flags(self): self.assertEqual(result.returncode, 0) self.assertIn("--no-rtk", result.stdout) self.assertIn("--no-caveman", result.stdout) + self.assertIn("--no-cooper", result.stdout) self.assertIn("--check-deps", result.stdout) diff --git a/tests/test_integration.py b/tests/test_integration.py index b43cfdb..1513e5b 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -51,7 +51,7 @@ def test_full_loop(self): wf_hook = str(Path(__file__).parent.parent / "harness_hooks" / "harness-workflow-state.py") # 1. Init - result = self._run(init_script, "--target", str(self.project_dir), "--no-rtk", "--no-caveman") + result = self._run(init_script, "--target", str(self.project_dir), "--no-rtk", "--no-caveman", "--no-cooper") self.assertEqual(result.returncode, 0, f"init failed: {result.stderr}") # 2. Create task @@ -102,6 +102,9 @@ def test_full_loop(self): ## 可测试契约 登录成功返回 token。 +## 业务契约覆盖 +BC-001 由登录成功测试覆盖。 + ## Slice 顺序 1. 登录接口。 diff --git a/tests/test_project_cli.py b/tests/test_project_cli.py new file mode 100644 index 0000000..54e6434 --- /dev/null +++ b/tests/test_project_cli.py @@ -0,0 +1,152 @@ +"""Tests for .harness/scripts/project.py project documentation state.""" + +from __future__ import annotations + +import json +import os +import shutil +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + + +REPO_ROOT = Path(__file__).parent.parent +PROJECT_SCRIPT = REPO_ROOT / "harness_scripts" / "project.py" + + +def _copy_project_script(project_dir: Path) -> None: + scripts = project_dir / ".harness" / "scripts" + scripts.mkdir(parents=True, exist_ok=True) + shutil.copy2(PROJECT_SCRIPT, scripts / "project.py") + + +def _run_project(project_dir: Path, *args: str) -> subprocess.CompletedProcess: + return subprocess.run( + [sys.executable, ".harness/scripts/project.py", *args], + capture_output=True, + text=True, + cwd=str(project_dir), + env=os.environ.copy(), + ) + + +class ProjectCliTestCase(unittest.TestCase): + def setUp(self): + self.project_dir = Path(tempfile.mkdtemp()) + (self.project_dir / ".harness").mkdir(parents=True) + _copy_project_script(self.project_dir) + + def tearDown(self): + shutil.rmtree(self.project_dir) + + def write_project_docs(self) -> None: + docs = { + "docs/standards/project-guide.md": "# 项目说明\n\n核心业务说明。\n", + "docs/standards/api/url-index.md": "# 接口索引\n\nGET /api/orders\n", + "docs/standards/api/detail.md": "# 接口详情\n\nGET /api/orders 返回订单列表。\n", + } + for rel_path, content in docs.items(): + path = self.project_dir / rel_path + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + + +class TestProjectDocsInit(ProjectCliTestCase): + def test_init_config_creates_project_docs_state_and_indexes(self): + result = _run_project(self.project_dir, "docs", "init-config") + self.assertEqual(result.returncode, 0, result.stderr) + + config_path = self.project_dir / ".harness" / "project-docs.json" + profile_path = self.project_dir / ".harness" / "project-profile.json" + self.assertTrue(config_path.is_file()) + self.assertTrue(profile_path.is_file()) + self.assertTrue((self.project_dir / "docs" / "standards" / "api").is_dir()) + + config = json.loads(config_path.read_text(encoding="utf-8")) + self.assertEqual(config["preset"], "default") + self.assertEqual(config["analysis"]["cacheDir"], ".harness/analysis/latest") + self.assertFalse(config["analysis"]["keepHistory"]) + outputs = [item["output"] for item in config["documents"]] + self.assertEqual( + outputs, + [ + "docs/standards/project-guide.md", + "docs/standards/api/url-index.md", + "docs/standards/api/detail.md", + ], + ) + + profile = json.loads(profile_path.read_text(encoding="utf-8")) + self.assertEqual(profile["documents"][0]["reviewStatus"], "missing") + self.assertEqual(profile["documents"][0]["path"], "docs/standards/project-guide.md") + + def test_init_config_preserves_existing_index_content_and_updates_managed_blocks(self): + docs = self.project_dir / "docs" + standards = docs / "standards" + standards.mkdir(parents=True) + (docs / "index.md").write_text("# Existing Docs\n\n业务文档说明。\n", encoding="utf-8") + (standards / "index.md").write_text("# Existing Standards\n\n团队规范说明。\n", encoding="utf-8") + + result = _run_project(self.project_dir, "docs", "init-config") + self.assertEqual(result.returncode, 0, result.stderr) + + docs_index = (docs / "index.md").read_text(encoding="utf-8") + standards_index = (standards / "index.md").read_text(encoding="utf-8") + self.assertIn("业务文档说明", docs_index) + self.assertIn("团队规范说明", standards_index) + self.assertEqual(docs_index.count(""), 1) + self.assertEqual(standards_index.count(""), 1) + self.assertIn("docs/standards/project-guide.md", docs_index) + self.assertIn("docs/standards/api/detail.md", standards_index) + + second = _run_project(self.project_dir, "docs", "init-config") + self.assertEqual(second.returncode, 0, second.stderr) + self.assertEqual((docs / "index.md").read_text(encoding="utf-8").count(""), 1) + + +class TestProjectDocsStatusAndApprove(ProjectCliTestCase): + def test_status_json_before_init_is_read_only(self): + result = _run_project(self.project_dir, "docs", "status", "--json") + self.assertEqual(result.returncode, 0, result.stderr) + data = json.loads(result.stdout) + + self.assertFalse(data["initialized"]) + self.assertEqual(data["summary"]["missing"], 3) + self.assertFalse((self.project_dir / ".harness" / "project-docs.json").exists()) + self.assertFalse((self.project_dir / ".harness" / "project-profile.json").exists()) + + def test_status_json_reports_missing_documents(self): + self.assertEqual(_run_project(self.project_dir, "docs", "init-config").returncode, 0) + + result = _run_project(self.project_dir, "docs", "status", "--json") + self.assertEqual(result.returncode, 0, result.stderr) + data = json.loads(result.stdout) + + self.assertTrue(data["initialized"]) + self.assertEqual(data["summary"]["missing"], 3) + self.assertEqual(data["summary"]["approved"], 0) + self.assertEqual(data["documents"][0]["reviewStatus"], "missing") + + def test_approve_all_marks_existing_documents_with_hash_and_reviewer(self): + self.assertEqual(_run_project(self.project_dir, "docs", "init-config").returncode, 0) + self.write_project_docs() + + result = _run_project(self.project_dir, "docs", "approve", "--all", "--approved-by", "owner") + self.assertEqual(result.returncode, 0, result.stderr) + + profile = json.loads((self.project_dir / ".harness" / "project-profile.json").read_text(encoding="utf-8")) + for doc in profile["documents"]: + self.assertEqual(doc["reviewStatus"], "approved") + self.assertTrue(doc["contentHash"].startswith("sha256:")) + self.assertEqual(doc["approvedBy"], "owner") + self.assertIsNotNone(doc["approvedAt"]) + + status = _run_project(self.project_dir, "docs", "status", "--json") + self.assertEqual(status.returncode, 0, status.stderr) + self.assertEqual(json.loads(status.stdout)["summary"]["approved"], 3) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_task_cli.py b/tests/test_task_cli.py index dbc7fc7..b1574ab 100644 --- a/tests/test_task_cli.py +++ b/tests/test_task_cli.py @@ -101,6 +101,18 @@ def test_create_can_record_confirmation_origin(self): data = json.loads((self.task_dir() / "task.json").read_text(encoding="utf-8")) self.assertEqual(data["originIntent"], "requirement-confirmation") + def test_create_rejects_single_session_execution_mode(self): + result = _run_task( + self.project_dir, + "create", + "订单超时控制", + "--execution-mode", + "single-session", + ) + + self.assertNotEqual(result.returncode, 0) + self.assertIn("invalid choice", result.stderr) + class TestClarifyAndAdvance(TaskCliTestCase): def setUp(self): @@ -144,6 +156,48 @@ def test_clarify_confirm_renders_markdown_and_allows_doc_plan(self): self.assertEqual(data["phase"], "doc-plan") self.assertEqual(data["sourceDocHash"], "sha256:test") + def test_clarify_confirm_records_business_contracts(self): + result = _run_task( + self.project_dir, + "clarify", + "confirm", + "--development-intent", + "增加订单超时控制能力", + "--acceptance-criterion", + "超时订单会被拒绝", + "--boundary", + "本次不修改支付流程", + "--source-doc", + "inline-request", + "--source-hash", + "sha256:test", + "--business-contract", + json.dumps( + { + "id": "BC-001", + "scenario": "订单已超过允许支付时间", + "input": "订单状态为待支付,当前时间晚于超时时间", + "expectedBehavior": "订单被拒绝继续支付", + "observable": "日志包含 order_id 和 reject_reason", + "testRequired": True, + }, + ensure_ascii=False, + ), + ) + + self.assertEqual(result.returncode, 0, result.stderr) + records = [ + json.loads(line) + for line in (self.task / "clarification.jsonl").read_text(encoding="utf-8").splitlines() + if line.strip() + ] + self.assertEqual(records[-1]["businessContracts"][0]["id"], "BC-001") + markdown = (self.task / "clarification.md").read_text(encoding="utf-8") + self.assertIn("business_contracts: 1", markdown) + self.assertIn("## 业务契约", markdown) + self.assertIn("BC-001", markdown) + self.assertIn("订单已超过允许支付时间", markdown) + def test_advance_red_checks_plan_scope_and_manifests(self): _confirm(self.project_dir) self.assertEqual(_run_task(self.project_dir, "advance", "doc-plan").returncode, 0) @@ -158,6 +212,19 @@ def test_advance_red_checks_plan_scope_and_manifests(self): data = json.loads((self.task / "task.json").read_text(encoding="utf-8")) self.assertEqual(data["phase"], "red") + def test_advance_red_requires_business_contract_plan_section(self): + _confirm(self.project_dir) + self.assertEqual(_run_task(self.project_dir, "advance", "doc-plan").returncode, 0) + write_valid_plan_package(self.project_dir, self.task) + content = (self.task / "implementation-plan.md").read_text(encoding="utf-8") + content = content.replace("## 业务契约覆盖\nBC-001 由订单超时测试覆盖。\n\n", "") + (self.task / "implementation-plan.md").write_text(content, encoding="utf-8") + + result = _run_task(self.project_dir, "advance", "red") + + self.assertNotEqual(result.returncode, 0) + self.assertIn("业务契约覆盖", result.stderr) + def test_intent_set_records_history(self): result = _run_task(self.project_dir, "intent", "set", "requirement-confirmation") self.assertEqual(result.returncode, 0, result.stderr) @@ -200,6 +267,31 @@ def test_review_record_writes_changed_files(self): self.assertEqual(review["headRef"], "working-tree") self.assertIn("feature.txt", review["changedFiles"]) self.assertEqual(review["specCompliance"]["status"], "passed") + self.assertEqual(review["businessContractCoverage"]["status"], "passed") + + def test_validate_requires_business_contract_review_passed(self): + (self.project_dir / "feature.txt").write_text("new\n", encoding="utf-8") + result = _run_task( + self.project_dir, + "review", + "record", + "--spec-compliance", + "passed", + "--code-quality", + "passed", + "--business-contract-status", + "failed", + "--missing-contract", + "BC-001", + "--summary", + "业务契约缺少测试映射", + ) + self.assertEqual(result.returncode, 0, result.stderr) + + advance = _run_task(self.project_dir, "advance", "validate") + + self.assertNotEqual(advance.returncode, 0) + self.assertIn("business contract review", advance.stderr) def test_archive_requires_done_phase(self): result = _run_task(self.project_dir, "archive", self.task.name) @@ -266,6 +358,9 @@ def write_valid_plan_package(project_dir: Path, task_dir: Path) -> None: ## 可测试契约 超时订单会被拒绝。 +## 业务契约覆盖 +BC-001 由订单超时测试覆盖。 + ## Slice 顺序 1. 增加测试。 diff --git a/tests/test_verify.py b/tests/test_verify.py index cc0cddf..45dcc7b 100644 --- a/tests/test_verify.py +++ b/tests/test_verify.py @@ -172,6 +172,24 @@ def test_red_records_expected_failure(self): self.assertEqual(data["exitCode"], 7) self.assertEqual(data["targetTests"], ["TestCreateOrder"]) + def test_red_records_contract_coverage(self): + result = self.run_verify( + "red", + "--command", + "exit 7", + "--target-test", + "TestCreateOrder", + "--contract-coverage", + "BC-001=TestCreateOrder", + "--uncovered-contract", + "BC-002", + ) + + self.assertEqual(result.returncode, 0, result.stderr + result.stdout) + data = json.loads((self.task_dir / "test-result.red.json").read_text(encoding="utf-8")) + self.assertEqual(data["contractCoverage"], {"BC-001": ["TestCreateOrder"]}) + self.assertEqual(data["uncoveredContracts"], ["BC-002"]) + def test_red_fails_when_command_passes(self): result = self.run_verify( "red", @@ -199,6 +217,22 @@ def test_green_records_expected_pass(self): self.assertTrue(data["expectedPassObserved"]) self.assertEqual(data["targetTests"], ["TestCreateOrder"]) + def test_green_records_contract_coverage(self): + result = self.run_verify( + "green", + "--command", + "true", + "--target-test", + "TestCreateOrder", + "--contract-coverage", + "BC-001=TestCreateOrder,TestCreateOrderAudit", + ) + + self.assertEqual(result.returncode, 0, result.stderr + result.stdout) + data = json.loads((self.task_dir / "test-result.green.json").read_text(encoding="utf-8")) + self.assertEqual(data["contractCoverage"], {"BC-001": ["TestCreateOrder", "TestCreateOrderAudit"]}) + self.assertEqual(data["uncoveredContracts"], []) + if __name__ == "__main__": unittest.main()