"""DevHarness 单一命令行入口。 子命令: check 检查必需文件、核心文档和任务归档结构 sync 从 Gitea Wiki 单向导出或校验核心 docs 镜像 archive 在 Gitea Wiki 创建任务归档 export 人工按需把 Wiki 任务归档导出到 docs/task 各子命令的实现逻辑取自原来的 check_harness.py、sync_wiki_docs.py、 new_task_archive.py 和 export_task_archives.py,行为未改变。 """ from __future__ import annotations import argparse import re from datetime import date from pathlib import Path from typing import Any from wiki_docs import ( DEFAULT_CONFIG, WikiClient, WikiDocsError, dirty_paths, load_config, parse_mirror, sync_all, write_mirror, ) # ---------------------------------------------------------------- 结构检查 ROOT = Path(__file__).resolve().parents[1] CORE_PAGE_PATHS = { "Home": "docs/README.md", "Project-Profile": "docs/00-project-profile.md", "Development-Workflow": "docs/01-workflow.md", "Architecture-and-Code-Map": "docs/02-architecture-and-code-map.md", "Business-Rules-and-Glossary": "docs/03-business-rules-and-glossary.md", "Local-Development-and-Verification": ( "docs/04-local-development-and-verification.md" ), "Common-Changes": "docs/05-common-changes.md", "Troubleshooting": "docs/06-troubleshooting.md", "Product-Requirements-Overview": "docs/07-mvp-requirements.md", "Android-Agent-API-Contract": "docs/08-agent-api-contract.md", "Delivery-Issues": "docs/09-delivery-issues.md", "OnePlus-Real-Device-Acceptance": "docs/10-real-device-acceptance.md", "PDD-Detail-Rule-Migration-Analysis": ( "docs/11-pdd-detail-rule-migration-analysis.md" ), "Task-Archive-Template": "docs/templates/task-archive.md", } # 按 GoAuto 实际文档结构定义,不照抄 DevHarness 模板章节名。 # 只登记结构性、稳定的小标题;随业务演进的具体条目(如故障现象、单条业务规则) # 不纳入校验,避免文档正常更新即触发 check 失败。 CORE_DOCUMENT_REQUIREMENTS = { "docs/README.md": ( "## 建议阅读顺序", "## 事实来源", "## 五分钟检查", "## 同步与归档", ), "docs/00-project-profile.md": ( "## 基本信息", "## 建设基线", "## 交付单元", "## 文档事实来源", "## 环境与凭据", "## 当前阶段", ), "docs/01-workflow.md": ( "## 事实来源", "## 新项目 Wiki 初始化门禁", "## 工单与设计证据双门禁", "### 本地 HTML 审核快照", "## 任务层级与状态", "## 单元任务闭环", "## 需求记录与流转", "## 自然语言快捷指令", "## 效率与范围控制", "## 文档影响", ), "docs/02-architecture-and-code-map.md": ( "## MVP 架构", "## 最小闭环", "## 关键设计", "## 最小业务数据", "## 已建立的工程入口", ), "docs/03-business-rules-and-glossary.md": ( "## 当前范围", "## 自动化边界", "## 术语", ), "docs/04-local-development-and-verification.md": ( "## 通用检查", "## 服务端验证", "## Web 验证", "## Android 验证", "## 原型验证", ), "docs/05-common-changes.md": ( "## 风险分级", "## 更新文档", "## 验收 Agent 修改", ), "docs/06-troubleshooting.md": ( "## 排查顺序", ), "docs/07-mvp-requirements.md": ( "## 长期需求索引", ), } REQUIRED_FILES = ( "AGENTS.md", "CLAUDE.md", "README.md", "docs/00-project-profile.md", "docs/01-workflow.md", "docs/templates/task-archive.md", *CORE_DOCUMENT_REQUIREMENTS, "wiki-docs.json", "dev_scripts/wiki_docs.py", "dev_scripts/harness.py", ".gitea/issue_template/epic.md", ".gitea/issue_template/mvp.md", ".gitea/issue_template/task.md", ) ARCHIVE_HEADINGS = ( "## 背景与目标", "## 最终方案", "## 修改文件", "## 验收结果", "## 测试", "## 相关提交", ) def check_required_files(errors: list[str]) -> None: for relative_path in REQUIRED_FILES: if not (ROOT / relative_path).is_file(): errors.append(f"缺少必需文件:{relative_path}") def check_project_profile(errors: list[str], warnings: list[str], strict: bool) -> None: profile = ROOT / "docs" / "00-project-profile.md" if not profile.is_file(): return if "<填写" in profile.read_text(encoding="utf-8"): message = "项目档案仍有未填写内容" (errors if strict else warnings).append(message) def check_archives(errors: list[str]) -> None: task_dir = ROOT / "docs" / "task" for path in task_dir.glob("*.md"): if not re.match(r"^\d+-.+\.md$", path.name): errors.append(f"归档文件名不符合 <编号>-<标题>.md:{path.name}") content = path.read_text(encoding="utf-8") try: metadata, _ = parse_mirror(content) except WikiDocsError as exc: errors.append(f"{path.name} 的任务镜像无效:{exc}") continue if re.fullmatch(r"Task-\d+-.+", metadata.get("wiki_page", "")) is None: errors.append(f"{path.name} 的 wiki_page 不是任务归档页面") if re.fullmatch(r"[0-9a-f]{40,64}", metadata.get("wiki_revision", "")) is None: errors.append(f"{path.name} 的 wiki_revision 无效") for heading in ARCHIVE_HEADINGS: if heading not in content: errors.append(f"{path.name} 缺少章节:{heading}") if "**未验证部分**:" not in content: errors.append(f"{path.name} 没有记录未验证部分") def missing_sections(content: str, required: tuple[str, ...]) -> list[str]: return [section for section in required if section not in content] def check_core_documents(errors: list[str], root: Path = ROOT) -> None: """检查初级维护者所需主题页的固定结构。""" for relative_path, required in CORE_DOCUMENT_REQUIREMENTS.items(): path = root / relative_path if not path.is_file(): continue try: _, body = parse_mirror(path.read_text(encoding="utf-8")) except (OSError, UnicodeDecodeError, WikiDocsError): continue for section in missing_sections(body, required): errors.append(f"{relative_path} 缺少核心章节:{section}") def check_task_template(errors: list[str], root: Path = ROOT) -> None: path = root / ".gitea" / "issue_template" / "task.md" if not path.is_file(): return content = path.read_text(encoding="utf-8") required = ( "## 依赖与并行", "- 前置工单:无 / #编号", "- 是否允许与前置工单并行:是 / 否", "- 原因:", "## 子项目影响", "- 仅影响的子项目 / 交付单元:", "- 是否跨子项目:是 / 否", "- 是否修改共享接口或契约:是 / 否;唯一事实来源:", "- 各子项目需要执行的验证:", "## 原始需求", "- 来源:用户对话 / Gitea / 其他", "- 提出时间:", "- 关键原话或脱敏摘要:", "## 需求变化记录", "| 日期 | 变化内容 | 原因 | 用户确认 |", "## 设计与原型门禁", "- 修改类型:纯显示文案 / 小范围 UI / 新组件 / 新页面或独立用户功能 / 重大交互或导航 / 非 UI / 恢复既有行为的 Bug", "- 所需设计证据:无 / 标注截图 / 低保真图 / 已确认原型 / 架构、API、数据、状态或流程设计 / 原设计或复现证据", "- 可编辑设计源链接、版本或事实来源:", "- 本地 HTML 审核快照路径和版本(不适用时说明原因):", "- 本地浏览方式和资源完整性检查:", "- 版本、revision 或确认日期:", "- 状态:无 / 草稿 / 已确认 / 已废弃", "- 确认人、确认时间和覆盖范围:", "- 无需 UI 原型或无需任何原型的原因:", "## 文档影响", "- [ ] 不影响长期文档,原因:", "- [ ] 更新架构与代码地图", "- [ ] 更新业务规则与术语", "- [ ] 更新常见修改或故障排查", "## 交付文档影响", "- [ ] 无交付文档影响,原因:", "- [ ] 更新已有交付文档,受众与页面:", "- [ ] 新增交付文档,受众与页面:", "- [ ] 需要目标岗位或客户代表验证:是 / 否;验证方式:", ) for section in missing_sections(content, required): errors.append(f"单元任务模板缺少:{section}") def check_agent_efficiency_rules(errors: list[str], root: Path = ROOT) -> None: path = root / "AGENTS.md" if not path.is_file(): return content = path.read_text(encoding="utf-8") required = ( "### 效率与范围控制", "#### 严格控制范围", "#### 渐进执行和修复", "#### 复用已验证事实", "#### 明确停止条件", "单元任务是唯一正式实施单位", "高风险修改必须停止", "用户没有明确验收通过前不得关闭", "长期核心文档必须先修改 Wiki", "### 新项目 Wiki 初始化门禁", "`Home` 不存在时必须先创建 `Home`", "不得把模板自带的本地 `docs/` 当作新项目 Wiki 已初始化的证据", "提交只包含当前工单相关文件", "### 工单与设计证据双门禁", "新页面、独立用户功能、重大交互或导航变化", "`prototypes/<工单号>/<版本>/index.html`", "已确认的 HTML 快照不得原位覆盖", "代码组件名、类名、变量、国际化键、API 字段和数据库字段不是显示文案", "### 自然语言快捷指令", "`只分析`", "`建工单`", "`执行工单 #N`", "`建工单并做`", "`继续工单 #N`", "`检查工单 #N`", "`同步文档`", "`导出任务归档`", "`导出全部任务归档`", "`#N 验收通过`", "### 需求记录与流转", "不得臆造用户原话", "不复制完整聊天", "Gitea 工单全文不导出到仓库", ) for section in missing_sections(content, required): errors.append(f"AGENTS.md 缺少:{section}") def check_repository_readme(errors: list[str], root: Path = ROOT) -> None: """检查产品 README 提供阅读入口与验证方式。 与 DevHarness 模板的差异(按 GoAuto 事实改写,见 #47): 模板此处校验的是「新项目快速开始」中的线上 Wiki 初始化顺序,面向从模板 派生新仓库的场景。GoAuto 是已建成的产品仓库,Wiki 已于 2026-08-17 初始化, 其 README 面向本项目的使用者与维护者,写入建仓步骤会误导读者。 因此本函数只校验产品 README 应有的结构;新项目 Wiki 初始化门禁改由 AGENTS.md 与 docs/01-workflow.md 承载,两处仍由 check 强制校验。 """ path = root / "README.md" if not path.is_file(): return content = path.read_text(encoding="utf-8") required = ( "## 阅读入口", "## 当前状态", "## 验证", ) for section in missing_sections(content, required): errors.append(f"README.md 缺少:{section}") def check_claude_code_entry(errors: list[str], root: Path = ROOT) -> None: """检查 Claude Code 入口直接复用共同 Agent 规则。""" path = root / "CLAUDE.md" if not path.is_file(): return content = path.read_text(encoding="utf-8") lines = {line.strip() for line in content.splitlines()} if "@AGENTS.md" not in lines: errors.append("CLAUDE.md 缺少独立的 @AGENTS.md 导入") required = ( "共同规则事实来源", "只记录 Claude Code 特有", "只修改 `AGENTS.md`", "## 模型路由", "## Agent 交接", "## Haiku 只读约束", "当前模型足以完成任务时不升级模型", "Opus 输出方案后必须等待用户确认", "不让 Haiku 决定最终根因", "只读必须通过子 Agent 工具权限实现", ) for section in missing_sections(content, required): errors.append(f"CLAUDE.md 缺少:{section}") def core_mapping_errors(configured_mappings: dict[str, str]) -> list[str]: errors: list[str] = [] for page, expected_path in CORE_PAGE_PATHS.items(): if configured_mappings.get(page) != expected_path: errors.append( f"核心 Wiki 页面映射缺失或路径错误:{page} -> {expected_path}" ) return errors def check_wiki_mirrors(errors: list[str]) -> None: """检查核心映射与镜像头;任务快照由 check_archives 单独检查。""" try: config = load_config() except WikiDocsError as exc: errors.append(str(exc)) return configured_mappings = {mapping.page: mapping.path for mapping in config.mappings} errors.extend(core_mapping_errors(configured_mappings)) mapped_paths = {mapping.path for mapping in config.mappings} actual_paths = { path.relative_to(ROOT).as_posix() for path in (ROOT / "docs").rglob("*.md") if path.parent != ROOT / "docs" / "task" } for path in sorted(actual_paths - mapped_paths): errors.append(f"docs 中存在未登记的 Wiki 镜像:{path}") for mapping in config.mappings: path = ROOT / mapping.path if not path.is_file(): errors.append(f"缺少 Wiki 镜像:{mapping.path}") continue try: metadata, _ = parse_mirror(path.read_text(encoding="utf-8")) except (OSError, UnicodeDecodeError, WikiDocsError) as exc: errors.append(f"Wiki 镜像无效 {mapping.path}:{exc}") continue if metadata.get("generated") != "true (请先修改 Gitea Wiki,禁止直接编辑本文件)": errors.append(f"{mapping.path} 没有只读镜像标记") if metadata.get("wiki_page") != mapping.page: errors.append(f"{mapping.path} 的 wiki_page 与映射不一致") revision = metadata.get("wiki_revision", "") if re.fullmatch(r"[0-9a-f]{40,64}", revision) is None: errors.append(f"{mapping.path} 的 wiki_revision 无效") if not metadata.get("synchronized_at"): errors.append(f"{mapping.path} 缺少 synchronized_at") # ---------------------------------------------------------------- 任务归档 def safe_title(title: str) -> str: """把标题转换为适合 Wiki 页面名和 Windows 文件名的短文本。""" cleaned = re.sub(r'[<>:"/\\|?*]', "-", title.strip()) cleaned = re.sub(r"\s+", "-", cleaned) cleaned = re.sub(r"-+", "-", cleaned) return cleaned.strip(".-") def build_archive( template: str, issue_number: str, title: str, page_name: str, issue_url: str, ) -> str: content = template.replace("<工单号>", issue_number, 1) content = content.replace("<标题>", title.strip(), 1) content = content.replace("YYYY-MM-DD", date.today().isoformat(), 1) content = content.replace("<链接>", issue_url, 1) return content.replace("<页面名>", page_name, 1) # ---------------------------------------------------------------- 归档导出 TASK_PAGE_PATTERN = re.compile(r"^Task-(?P\d+)-(?P.+)$") def task_revision(metadata: dict[str, Any], page_name: str) -> str: last_commit = metadata.get("last_commit") revision = last_commit.get("sha") if isinstance(last_commit, dict) else None if not isinstance(revision, str) or not revision: raise WikiDocsError(f"Wiki 页面缺少 revision:{page_name}") return revision def existing_task_mirrors(root: Path = ROOT) -> dict[str, Path]: """按镜像头匹配已有文件,兼容历史自定义文件名。""" mirrors: dict[str, Path] = {} task_dir = root / "docs" / "task" if not task_dir.is_dir(): return mirrors for path in task_dir.glob("*.md"): try: metadata, _ = parse_mirror(path.read_text(encoding="utf-8")) except (OSError, UnicodeDecodeError, WikiDocsError) as exc: raise WikiDocsError(f"已有任务镜像无效 {path.name}:{exc}") from exc page_name = metadata.get("wiki_page", "") if not TASK_PAGE_PATTERN.fullmatch(page_name): raise WikiDocsError(f"已有任务镜像页面名无效 {path.name}:{page_name}") if page_name in mirrors: raise WikiDocsError(f"任务页面存在重复本地镜像:{page_name}") mirrors[page_name] = path return mirrors def task_target(page_name: str, root: Path = ROOT) -> Path: match = TASK_PAGE_PATTERN.fullmatch(page_name) if match is None: raise WikiDocsError(f"不是任务归档页面:{page_name}") title = safe_title(match.group("title")) if not title: raise WikiDocsError(f"任务归档标题无效:{page_name}") return root / "docs" / "task" / f"{match.group('number')}-{title}.md" def export_task_archives( client: WikiClient, *, export_all: bool = False, root: Path = ROOT ) -> list[str]: """增量或全量读取任务归档;绝不删除本地文件。""" dirty = dirty_paths(["docs/task"], root) if dirty: raise WikiDocsError( "本地任务镜像存在未提交改动,已停止以防覆盖:\n" + "\n".join(dirty) ) existing = existing_task_mirrors(root) pages = [] for metadata in client.list_pages(): title = metadata.get("title") if isinstance(title, str) and TASK_PAGE_PATTERN.fullmatch(title): pages.append((int(title.split("-", 2)[1]), title, metadata)) pages.sort(key=lambda item: (item[0], item[1])) messages: list[str] = [] targets: set[Path] = set() for _, page_name, metadata in pages: target = existing.get(page_name, task_target(page_name, root)) if target in targets: raise WikiDocsError(f"多个任务页面映射到同一本地路径:{target.name}") targets.add(target) revision = task_revision(metadata, page_name) if not export_all and target.is_file(): local_metadata, _ = parse_mirror(target.read_text(encoding="utf-8")) if ( local_metadata.get("wiki_page") == page_name and local_metadata.get("wiki_revision") == revision ): messages.append(f"跳过:{target.relative_to(root)} <- {page_name}@{revision[:12]}") continue page = client.get_page_from_metadata(metadata, page_name) changed = write_mirror(target, page) action = "已导出" if changed else "无变化" messages.append(f"{action}:{target.relative_to(root)} <- {page_name}@{revision[:12]}") return messages # ---------------------------------------------------------------- 子命令入口 def run_check(args: argparse.Namespace) -> int: errors: list[str] = [] warnings: list[str] = [] check_required_files(errors) check_project_profile(errors, warnings, args.strict) check_wiki_mirrors(errors) check_core_documents(errors) check_task_template(errors) check_agent_efficiency_rules(errors) check_repository_readme(errors) check_claude_code_entry(errors) check_archives(errors) for warning in warnings: print(f"警告:{warning}") for error in errors: print(f"错误:{error}") if errors: print(f"检查失败:{len(errors)} 个问题") return 1 print("DevHarness 检查通过") return 0 def run_sync(args: argparse.Namespace) -> int: """--verify 依次执行导出、结构检查和一致性校验,替代原来的三条命令。""" if args.verify: steps = ( ("同步", lambda: run_sync( argparse.Namespace(check=False, verify=False, config=args.config))), ("结构检查", lambda: run_check(argparse.Namespace(strict=True))), ("一致性校验", lambda: run_sync( argparse.Namespace(check=True, verify=False, config=args.config))), ) for name, step in steps: code = step() if code != 0: print(f"错误:{name}未通过,已停止") return code return 0 try: config = load_config(Path(args.config).resolve()) messages = sync_all(config, WikiClient(config), check=args.check) except WikiDocsError as exc: print(f"错误:{exc}") return 1 for message in messages: print(message) print("Wiki 镜像检查通过" if args.check else "Wiki 镜像同步完成") return 0 def run_archive(args: argparse.Namespace) -> int: short_title = safe_title(args.title) if not args.issue_number.isdigit(): print("错误:工单号必须是数字") return 1 if not short_title: print("错误:标题不能为空") return 1 try: config = load_config(Path(args.config).resolve()) page_name = f"Task-{args.issue_number}-{short_title}" client = WikiClient(config) if any(item.get("title") == page_name for item in client.list_pages()): raise WikiDocsError(f"任务归档已经存在:{page_name}") template = client.get_page("Task-Archive-Template").text issue_url = ( f"{config.gitea_url}/{config.owner}/{config.repository}/issues/" f"{args.issue_number}" ) content = build_archive( template, args.issue_number, args.title, page_name, issue_url ) page = client.create_page( page_name, content, f"docs: 创建任务 #{args.issue_number} 归档草稿", ) except WikiDocsError as exc: print(f"错误:{exc}") return 1 print(f"已创建 Wiki:{page.html_url}") print("未导出本地任务归档;需要时运行 harness.py export") return 0 def run_export(args: argparse.Namespace) -> int: try: config = load_config(Path(args.config).resolve()) messages = export_task_archives(WikiClient(config), export_all=args.all) except WikiDocsError as exc: print(f"错误:{exc}") return 1 for message in messages: print(message) print("任务归档全量导出完成" if args.all else "任务归档增量导出完成") return 0 def main() -> int: parser = argparse.ArgumentParser(description="DevHarness 检查、同步与归档工具") sub = parser.add_subparsers(dest="command", required=True) p_check = sub.add_parser("check", help="检查 DevHarness 项目结构") p_check.add_argument( "--strict", action="store_true", help="项目档案有占位内容时返回失败" ) p_check.set_defaults(func=run_check) p_sync = sub.add_parser("sync", help="从 Gitea Wiki 单向同步核心 docs 镜像") p_sync.add_argument( "--check", action="store_true", help="只检查 Wiki 与镜像是否一致,不写文件" ) p_sync.add_argument( "--verify", action="store_true", help="依次执行导出、check --strict 和一致性校验", ) p_sync.add_argument( "--config", default=str(DEFAULT_CONFIG), help="Wiki 页面映射 JSON 文件" ) p_sync.set_defaults(func=run_sync) p_archive = sub.add_parser("archive", help="在 Gitea Wiki 创建任务归档") p_archive.add_argument("issue_number", help="Gitea 工单号,例如 123") p_archive.add_argument("title", help="简短任务标题") p_archive.add_argument( "--config", default=str(DEFAULT_CONFIG), help="Wiki 映射配置" ) p_archive.set_defaults(func=run_archive) p_export = sub.add_parser("export", help="人工按需导出 Gitea Wiki 任务归档") p_export.add_argument( "--all", action="store_true", help="全量读取全部线上任务归档;默认按 revision 增量", ) p_export.add_argument( "--config", default=str(DEFAULT_CONFIG), help="核心 Wiki 映射配置" ) p_export.set_defaults(func=run_export) args = parser.parse_args() return args.func(args) if __name__ == "__main__": raise SystemExit(main())