From 4e6f75f1a305d9281dc49bb7aa0897ef6ff9b5ca Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Tue, 11 Aug 2026 18:21:31 +0800 Subject: [PATCH] chore: bootstrap YoVision DevHarness --- .gitea/issue_template/epic.md | 31 +++ .gitea/issue_template/mvp.md | 27 +++ .gitea/issue_template/task.md | 87 +++++++ .gitignore | 20 ++ AGENTS.md | 181 +++++++++++++++ Bell/AGENTS.md | 9 + Brain/AGENTS.md | 9 + CLAUDE.md | 31 +++ README.md | 11 + Sense/AGENTS.md | 9 + contracts/AGENTS.md | 7 + dev_scripts/check_harness.py | 384 +++++++++++++++++++++++++++++++ dev_scripts/new_task_archive.py | 101 ++++++++ dev_scripts/sync_wiki_docs.py | 33 +++ dev_scripts/wiki_docs.py | 394 ++++++++++++++++++++++++++++++++ tests/test_harness_docs.py | 184 +++++++++++++++ tests/test_wiki_docs.py | 163 +++++++++++++ wiki-docs.json | 25 ++ 18 files changed, 1706 insertions(+) create mode 100644 .gitea/issue_template/epic.md create mode 100644 .gitea/issue_template/mvp.md create mode 100644 .gitea/issue_template/task.md create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 Bell/AGENTS.md create mode 100644 Brain/AGENTS.md create mode 100644 CLAUDE.md create mode 100644 README.md create mode 100644 Sense/AGENTS.md create mode 100644 contracts/AGENTS.md create mode 100644 dev_scripts/check_harness.py create mode 100644 dev_scripts/new_task_archive.py create mode 100644 dev_scripts/sync_wiki_docs.py create mode 100644 dev_scripts/wiki_docs.py create mode 100644 tests/test_harness_docs.py create mode 100644 tests/test_wiki_docs.py create mode 100644 wiki-docs.json diff --git a/.gitea/issue_template/epic.md b/.gitea/issue_template/epic.md new file mode 100644 index 0000000..d908c1e --- /dev/null +++ b/.gitea/issue_template/epic.md @@ -0,0 +1,31 @@ +## 背景 + + + +## 目标 + +- + +## 非目标 + +- + +## 总体方案 + + + +## 阶段路线 + +1. + +## MVP 与任务索引 + +- [ ] # MVP 工单 + +## 依赖、风险和回退 + +- + +## 最终验收标准 + +- [ ] diff --git a/.gitea/issue_template/mvp.md b/.gitea/issue_template/mvp.md new file mode 100644 index 0000000..eba1f36 --- /dev/null +++ b/.gitea/issue_template/mvp.md @@ -0,0 +1,27 @@ +## 基本信息 + +- 所属 Epic:# + +## MVP 目标 + + + +## 包含范围 + +- + +## 排除范围 + +- + +## 阶段与单元任务 + +- [ ] # 单元任务 + +## 集成风险和回退 + +- + +## MVP 验收标准 + +- [ ] diff --git a/.gitea/issue_template/task.md b/.gitea/issue_template/task.md new file mode 100644 index 0000000..423b407 --- /dev/null +++ b/.gitea/issue_template/task.md @@ -0,0 +1,87 @@ +## 基本信息 + +- 类型:需求 / 缺陷 / 重构 +- 所属 Epic:# +- 所属 MVP / 版本:# +- 阶段: + +## 依赖与并行 + +- 前置工单:无 / #编号 +- 是否允许与前置工单并行:是 / 否 +- 原因: + +## 子项目影响 + + + +- 仅影响的子项目 / 交付单元: +- 是否跨子项目:是 / 否 +- 是否修改共享接口或契约:是 / 否;唯一事实来源: +- 各子项目需要执行的验证: + +## 原始需求 + +- 来源:用户对话 / Gitea / 其他 +- 提出时间: +- 关键原话或脱敏摘要: + + + +## 要解决什么 + + + +## 做什么 / 不做什么 + +- 做: +- 不做: + +## 已确认方案 + + + +预计修改文件: + +- + +## 需求变化记录 + + + +| 日期 | 变化内容 | 原因 | 用户确认 | +|---|---|---|---| +| | | | 是 / 否 | + +## 文档影响 + + + +- [ ] 不影响长期文档,原因: +- [ ] 更新项目档案或本地开发与验证 +- [ ] 更新架构与代码地图 +- [ ] 更新业务规则与术语 +- [ ] 更新常见修改或故障排查 +- [ ] 更新其他 Wiki 页面: + +## 交付文档影响 + + + +- [ ] 无交付文档影响,原因: +- [ ] 更新已有交付文档,受众与页面: +- [ ] 新增交付文档,受众与页面: +- [ ] 需要目标岗位或客户代表验证:是 / 否;验证方式: + +## 验收标准 + +- [ ] +- [ ] + +## 验证方式 + + + +## 风险和回退 + + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0125a16 --- /dev/null +++ b/.gitignore @@ -0,0 +1,20 @@ +.codex/gitea.env +.codex/*.log +.codex/config.toml +__pycache__/ +*.py[cod] +.pytest_cache/ +.venv/ +wiki_drafts/ + +# Local configuration and secrets +*.env +!.env.example +*.local.* + +# Build and test output +coverage/ +dist/ +build/ +tmp/ +*.log diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..1bcfb91 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,181 @@ +# Agent 开发规则 + +本仓库采用 DevHarness 工作流:Gitea 工单是任务过程的事实来源,Gitea Wiki 是长期开发文档和任务归档的事实来源,Git 是代码与版本绑定资料的变更记录,`docs/` 只保存 Wiki 的只读镜像。人负责确认方案与验收,Agent 负责检查、实现、测试和留下证据。 + +开始工作前先阅读 [项目档案](docs/00-project-profile.md) 和任务涉及目录中的 `AGENTS.md`。目录越深的规则越具体,但不得削弱上级安全规则。 + +## 1. 永久规则 + +- 不把密码、令牌、Cookie、私钥、个人数据或生产数据写入代码、日志、工单、Wiki 和文档。 +- 不执行未经用户明确授权的发布、付款、删除数据、破坏性迁移或其他不可逆操作。 +- 保留用户已有和任务无关的工作区改动,不擅自重置、覆盖或混入提交。 +- 发现需求与安全规则、已确认方案或现有数据冲突时,先停止实施并说明影响。 +- 测试结果必须真实;未执行或无法覆盖的验证必须明确记录。 + +项目专用红线写入本文件的“项目专用规则”或对应子目录的 `AGENTS.md`,不要散落在聊天记录中。 + +## 2. 哪些改动需要工单 + +新功能、缺陷修复、重构以及任何用户可感知或改变程序行为的修改,必须先建立单元任务工单。 + +以下小改动可以直接提交,不要求工单和任务归档: + +- 只改错别字、注释或文档措辞; +- 只做格式化、导入排序或不跨文件的内部变量改名; +- 补充类型标注或文档字符串且不改变行为; +- 删除已经确认无人使用的死代码。 + +只要涉及接口、数据库、状态、权限、安全、并发、用户界面,或者无法确定是否改变行为,就必须建工单。 + +## 3. 需求到实施 + +1. 先复述目标,阅读相关代码、日志和文档,区分事实与假设。 +2. 给出目标、非目标、方案、影响范围、风险、回退方式、验证方法和文档影响。 +3. 方案没有得到用户明确确认前,只做只读诊断和方案整理,不实施正式代码。 +4. 方案确认后,先建立单元任务工单,再修改代码。 +5. 建立新工单不要求其他工单已经完成;开始实施前检查工单声明的前置工单。真实依赖未满足时保持“待实施”,允许并行时必须写明原因。 +6. 开始实施前检查分支和工作区,明确哪些现有改动不属于本任务。 +7. 严格按工单范围实现;新发现的问题先记录,不顺手混入当前任务。 +8. 执行与风险相称的测试,把关键结果和未验证部分更新到工单。实施过程中出现计划外、当前无法解除的问题时才标记“阻塞”。 +9. 长期文档必须先修改 Wiki、读取确认,再运行 `python dev_scripts/sync_wiki_docs.py` 导出本地镜像;不得直接编辑 `docs/` 后反向覆盖 Wiki。 + +Gitea 不可用时,输出完整工单草稿并说明阻塞。未经用户明确授权,不得默认绕过建单。 + +### 自然语言快捷指令 + +快捷指令只是本工作流的自然语言别名,不得绕过方案确认、前置依赖、安全规则、工单范围、Wiki 主源、必要验证或人工验收: + +- `只分析`:只读检查并给出方案;不建单、不修改,停在等待确认。 +- `建工单`:根据已确认方案创建单元任务工单;建单后停止,不修改代码。 +- `执行工单 #N`:检查工单和依赖,实施、测试、提交、归档、推送并回写证据;停在“待验收”。 +- `建工单并做`:依次建单和执行,`建工单,做`、`建工单,做` 含义相同;停在“待验收”。 +- `继续工单 #N`:核对工单、Git 和 Wiki 证据,从首个未完成步骤继续,不重复仍然有效的检查。 +- `检查工单 #N`:只读核对范围、验收、测试和证据并输出报告;不自动修复。 +- `同步文档`:读取 Wiki、导出 `docs/` 并检查一致性;不修改 Wiki、不自动提交。 +- `#N 验收通过`:仅在用户明确验收后,更新归档、同步并提交镜像、推送、同步父工单并关闭任务。 + +方案未确认或前置依赖未满足时,实施类指令必须停在对应门禁;除 `#N 验收通过` 外,快捷指令不得关闭待验收工单。Gitea 工单不导出全文,本地只保存 Wiki 任务归档镜像。详细语义见 [开发工作流](docs/01-workflow.md)。 + +### 需求记录与流转 + +- 创建单元任务工单时,记录原始需求的来源、提出时间,以及能表达用户目的、场景和限制的少量关键原话或脱敏摘要;不得臆造用户原话。 +- 工单中的目标、非目标、已确认方案、验收标准和文档影响构成确认后的正式任务需求。 +- 影响范围、接口、数据、风险或验收的需求变化必须记录日期、内容、原因和用户确认;会改变已确认结果时先更新工单并等待再次确认。 +- 不复制完整聊天,不保存 Agent 内部推理,不写入密码、令牌、个人数据或生产数据;包含敏感信息的原话必须删除敏感部分或改写为脱敏摘要。 +- 长期有效的产品需求、业务规则和系统边界进入对应 Wiki 主题页并导出 `docs/`;完成结果进入 Wiki 任务归档并导出 `docs/task/`。Gitea 工单全文不导出到仓库。 + +详细记录边界见 [开发工作流](docs/01-workflow.md) 与 [业务规则和术语](docs/03-business-rules-and-glossary.md)。 + +### 效率与范围控制 + +本节只用于减少无关工作和重复检查,不得削弱安全规则、已确认方案、工单范围、必要测试、Wiki 同步、Git 提交和验收归档要求。 + +#### 严格控制范围 + +- 默认严格按用户确认的目标和单元任务范围执行,不主动扩展相邻问题。 +- 除非任务目标、仓库强制规则或已发现的真实阻塞需要,不新增额外文档、辅助脚本、备份文件、框架、重构或扩展性设计。 +- 不执行与本次验收无关的验证;安全检查、受影响范围测试、回归测试和仓库规定的闭环验证不属于“额外验证”。 +- 新发现的相邻问题最多用一句话提示或记录到独立工单,不自动修复或混入当前提交。 + +#### 渐进执行和修复 + +- 完成已知必要的安全与前置检查后,优先执行能够产生真实反馈的最小命令。 +- 一次执行后先处理首个可定位、可行动的真实错误,不同时猜测并修改多个可能原因。 +- 采用“执行 → 查看错误 → 最小修复 → 从失败点继续或按需重跑”的闭环。 +- 不在真实证据出现前堆叠与已知风险无关的预防性检查。 +- 涉及凭据、权限、安全、数据、迁移、并发、删除、发布或不可逆操作时,必须先完成相应前置检查,不得通过试错获取风险反馈。 + +#### 复用已验证事实 + +- 在同一任务和同一环境状态下,已经通过的路由、连接、恢复和环境检查不重复执行。 +- 只有会话、环境、代码、配置、依赖、凭据、远端状态或关键前提发生变化时才重新检查。 +- 代码修改后,受影响测试和最终验收必须重新执行;提交前工作区检查、推送前远端分支检查不得因为之前通过而省略。 +- Skill 和平台规则是否需要重新读取,按当前 Agent 平台和任务触发规则执行,不自行跳过。 + +#### 明确停止条件 + +- 完成用户确认的验收标准和仓库规定的必要闭环后立即停止,不主动继续优化。 +- “最小验收条件”包括当前工单要求的实现、必要测试、文档影响处理、Wiki 镜像检查、提交和证据回写,不等同于功能第一次运行成功。 +- 未影响当前验收的相邻问题只提示或建单,不顺手处理。 + +## 4. 工单层级 + +- 单元任务是唯一正式实施单位,记录方案、范围、验收标准、过程和测试结果。 +- Epic 和 MVP 只维护目标、风险、汇总及 `- [ ] #编号` / `- [x] #编号` 子工单索引,不复制单元任务全文。 +- 新任务先建单元工单,再把编号同步到所属 MVP 和 Epic。 +- 详细层级、状态和模板入口见 [开发工作流](docs/01-workflow.md) 与 [业务规则和术语](docs/03-business-rules-and-glossary.md)。 + +## 5. 实施中的变化 + +- 范围、接口、数据结构、依赖、验收标准或风险变化时,先更新单元工单。 +- 变化影响 MVP 或 Epic 时,同时更新父工单。 +- 会改变用户已确认结果的变化,更新工单后必须再次等待用户确认。 +- 阻塞、失败方案和新发现根因不能只留在聊天或代码注释中。 +- Wiki 页面删除、重命名或事实源边界变化时,必须先更新工单并等待用户确认;同步工具不得自动传播删除或重命名。 +- 工单状态应使用:待确认、待实施、进行中、阻塞、待验收、已完成。 + +## 6. Git 与验证 + +- 提交只包含当前工单相关文件。 +- 实现提交信息引用工单号,例如:`fix: 修复登录超时 (#123)`。 +- 不为流程制造空提交。 +- 优先运行项目档案中记录的格式检查、静态检查、单元测试和必要的集成测试。 +- 不能验证的真机、生产、迁移或并发行为必须写入工单和归档。 + +## 7. 完成、验收和归档 + +1. 逐项完成验收、测试和实现提交,并把最终方案、差异、结果、提交及遗留问题写回工单。 +2. 工单保持“待验收”,用户没有明确验收通过前不得关闭。 +3. 运行 `python dev_scripts/new_task_archive.py <编号> "<短标题>"`,先创建 Wiki 归档,再登记并导出本地镜像。 +4. 读取确认 Wiki,运行 `python dev_scripts/sync_wiki_docs.py --check`;归档镜像单独提交,并把页面、revision、路径和提交哈希写回工单。 +5. 用户验收通过后关闭单元工单,并同步更新 MVP 和 Epic。 + +MVP 内所有单元任务通过后才能做 MVP 集成验收;MVP 通过后才能关闭 MVP。Epic 的全部范围完成后才能关闭 Epic。 + +详细归档顺序和字段见 [开发工作流](docs/01-workflow.md)。 + +## 8. 可维护性 + +- 优先使用直白、常见的实现;不要为了少写几行引入晦涩技巧。 +- 类和函数保持单一职责,名称表达业务含义。 +- 注释解释原因、边界和风险,不逐行翻译代码。 +- 错误必须可定位,不静默吞掉失败。 +- Wiki 文档先写结论和用途,再写步骤;示例命令应可直接复制,本地 `docs/` 由同步工具生成。 +- 面向初级维护者说明从哪里开始读、怎样运行和怎样验证。 + +### 修改风险 + +- 低风险修改可由初级程序员在 Agent 协助下处理;接口、配置、依赖、跨模块逻辑和数据结构由 Agent 实现并验证。 +- 权限、安全、并发、迁移、支付、删除数据和不可逆操作属于高风险;高风险修改必须停止,由 Agent 分析并等待人工确认。 +- 风险按影响范围判断,不按代码行数判断;示例见 [常见修改指南](docs/05-common-changes.md)。 + +### 文档影响 + +- 每个单元任务必须在工单中选择“无长期文档影响并说明原因”或列出需要更新的 Wiki 页面。 +- 启动、测试、部署、排错命令,模块入口、目录职责、主要调用路径,配置、API、数据结构、状态、业务规则、安全边界、日志位置发生变化时,必须更新对应 Wiki。 +- 普通内部重构只有在入口、行为、配置和验证方式均未改变时,才可以记录为不影响长期文档。 +- 必需核心页面及结构以 `python dev_scripts/check_harness.py --strict` 和 [新项目文档初始化](docs/07-new-project-documentation-setup.md) 为准;稳定文档与任务归档的分工见 [开发工作流](docs/01-workflow.md)。 + +## 9. 引导提交例外 + +从本模板创建全新仓库时,Gitea 远端和工单尚不存在,允许一次不带工单号的初始引导提交。该提交只能包含仓库骨架、Harness 规则和远端配置准备,不能包含产品功能。 + +远端建立并推送后,这个例外立即失效。 + +## 10. 项目专用规则 + + + +- 长期开发文档以 Gitea Wiki 为事实来源,`docs/` 是显式映射生成的只读镜像。 +- Wiki 与镜像的固定顺序是:修改 Wiki → 读取确认 → 导出 `docs` → 校验差异 → 提交镜像。 +- 同步配置只允许写入 `docs/` 下的 Markdown;发现镜像有未提交修改时必须停止。 +- Gitea 凭据只通过进程环境或 MCP 安全配置提供,不得写入仓库。 +- `dev_scripts/` 只存放 DevHarness 自身工具;业务项目的通用脚本必须使用独立目录,不得混放。 +- 本仓库包含 `Sense/`、`Brain/`、`Bell/` 三个独立开发与交付单元;Sense 与 Bell 是认证、数据、部署和发布完全独立的销售产品,Brain 是无界面推理交付单元。 +- 三个主 agent 默认分别只写自己的子项目目录;任务必须声明主 agent、子项目影响和精确 `write_paths`。 +- `contracts/`、根级构建/部署配置和端到端测试属于共享写路径,只能由明确的协调工单和单一协调 agent 修改。 +- 同一路径以及父子目录视为冲突;发现重叠时停止写入,不依靠事后合并解决并发所有权。 +- 跨项目只共享版本化协议,不共享用户表、JWT、Cookie、数据库内部模型、摄像头凭据或业务实现代码。 +- 旧仓库 `D:\OPC\yovision_old` 只读用于需求和证据追溯;不得继承旧任务状态,也不得不经筛选整树复制代码。 +- 16 路是默认交付配额,不得成为数据库、数组、循环、分页、批处理或单机容量的硬上限。 +- 开始子项目工作前还必须读取对应目录的 `AGENTS.md`;共享契约工作读取 `contracts/AGENTS.md`。 diff --git a/Bell/AGENTS.md b/Bell/AGENTS.md new file mode 100644 index 0000000..c36026c --- /dev/null +++ b/Bell/AGENTS.md @@ -0,0 +1,9 @@ +# Bell 目录规则 + +- Bell 是独立销售和部署的事件预警与处置产品。 +- 主实现技术栈为 Go 后端与 Vue 3 + Element Plus 管理端;正式数据库为 PostgreSQL。 +- Bell 拥有独立账户、角色、会话、数据库角色、审计和密码策略,不信任 Sense 用户 token。 +- Event 与 Alert 分离;原始 Event 不可变,Alert 状态迁移、ack/close、投递和升级事实只追加并可审计。 +- 外部事件以 `(producer_id, source_event_id)` 幂等;Bell 不读取 Sense 数据库、摄像头密码或内部文件路径。 +- 修改 `contracts/`、Sense connector 或 Brain 输出语义前,必须转为跨项目协调工单。 +- Bell agent 默认写路径仅为 `Bell/` 及工单明确列出的共享文件。 diff --git a/Brain/AGENTS.md b/Brain/AGENTS.md new file mode 100644 index 0000000..bc1c92b --- /dev/null +++ b/Brain/AGENTS.md @@ -0,0 +1,9 @@ +# Brain 目录规则 + +- Brain 是无界面的独立 Python/CUDA 推理交付单元,默认随 Sense 产品部署。 +- Brain 负责解码、推理、跟踪、ReID、区域/警戒线判定和标准事件映射;不持有账户、RBAC、Alert 或通知状态。 +- 业务上保持无状态;模型、缓存和 GPU 运行态不得成为事件或告警的唯一事实来源。 +- 输出必须符合 `contracts/` 的冻结事件契约;不得把摄像头密码、内部文件路径或未经授权的人脸信息写入事件。 +- 模型与规则分层:模型输出观测,版本化规则作业务判定。 +- 修改 Sense 源接口、Bell 入站接口或共享契约前,必须转为跨项目协调工单。 +- Brain agent 默认写路径仅为 `Brain/` 及工单明确列出的共享文件。 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..1e2bac7 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,31 @@ +# Claude Code 项目入口 + +@AGENTS.md + +## Claude Code 专用说明 + +- 上方导入的 `AGENTS.md` 是所有编码 Agent 的共同规则事实来源,Claude Code 必须完整遵守。 +- 本文件只记录 Claude Code 特有的模型路由、工具和 Agent 协作规则。 +- 共同规则变化时只修改 `AGENTS.md`,不要在本文件重复维护。 + +## 模型路由 + +- 目标、范围和修改位置已经明确时,默认使用 Sonnet 分析、建单和实施。 +- 需求模糊、根因不明,或涉及跨模块架构、安全、权限、并发、迁移和不可逆操作时,使用 Opus 或 `opusplan` 制定方案。 +- Opus 输出方案后必须等待用户确认;确认后由 Sonnet 根据方案创建工单并实施。 +- Haiku 只用于范围明确的只读任务,例如查找代码入口、读取项目文档、提取日志事实和整理调用关系。 +- 不让 Haiku 决定最终根因、技术方案、风险等级或验收结论。 +- 当前模型足以完成任务时不升级模型,也不为了形式固定依次调用三个模型。 + +## Agent 交接 + +- Opus 向 Sonnet 交接目标、非目标、事实、假设、方案、修改范围、风险、回退方式和验收标准。 +- Haiku 向主 Agent 交接结论、证据位置和仍不确定的内容,不返回与任务无关的大段原文。 +- Sonnet 严格按照已确认方案和工单实施;发现范围变化时返回主流程重新确认。 +- 不同 Agent 复用仍然有效的检查结果;会话、环境或关键前提变化时才重新检查。 + +## Haiku 只读约束 + +- Haiku 子 Agent 只允许使用读取、搜索和只读代码图谱工具。 +- 禁止 Haiku 修改文件、创建或更新工单、提交 Git、更新 Wiki 或执行具有副作用的命令。 +- 只读必须通过子 Agent 工具权限实现,不能只依赖提示语;未配置只读权限时不得调用 Haiku 处理项目内容。 diff --git a/README.md b/README.md new file mode 100644 index 0000000..b160f72 --- /dev/null +++ b/README.md @@ -0,0 +1,11 @@ +# YoVision + +YoVision 是一个单仓多项目的智能视频事件平台,包含三个可独立开发和交付的子项目: + +- `Sense/`:设备、ONVIF/RTSP、MediaMTX、实时监看和边缘运维; +- `Brain/`:解码、推理、跟踪、区域判定和标准事件生成; +- `Bell/`:事件接入、规则、Alert、ack/close、升级和通知。 + +Sense 与 Bell 是账户、数据、部署和发布边界完全独立的两个销售产品;Brain 是无界面的独立推理交付单元,默认随 Sense 部署。跨项目协作只通过 `contracts/` 中的版本化契约。 + +开始工作前阅读 [AGENTS.md](AGENTS.md) 和 [文档中心](docs/README.md)。本仓库采用 DevHarness:Gitea 工单记录任务过程,Gitea Wiki 保存长期文档,`docs/` 是 Wiki 的只读镜像。 diff --git a/Sense/AGENTS.md b/Sense/AGENTS.md new file mode 100644 index 0000000..10e4b35 --- /dev/null +++ b/Sense/AGENTS.md @@ -0,0 +1,9 @@ +# Sense 目录规则 + +- Sense 是独立销售和部署的智能 NVR/视频感知产品管理面。 +- 主实现技术栈为 Go 后端与 Vue 3 + Element Plus 管理端;正式数据库为 PostgreSQL。 +- MediaMTX 保持独立二进制,Sense 只管理配置、生命周期与对账,不把媒体内核写进 HTTP handler 或 ORM model。 +- Sense 不读取 Bell 数据库、不接受 Bell 用户会话,也不依赖 Bell 才能启动。 +- 摄像头凭据只能从仓库外安全配置读取,日志、工单、测试夹具和 URL 不得包含明文凭据。 +- 修改 `contracts/`、Brain/Bell 消费接口或根级部署文件前,必须转为跨项目协调工单。 +- Sense agent 默认写路径仅为 `Sense/` 及工单明确列出的共享文件。 diff --git a/contracts/AGENTS.md b/contracts/AGENTS.md new file mode 100644 index 0000000..282d0b3 --- /dev/null +++ b/contracts/AGENTS.md @@ -0,0 +1,7 @@ +# 共享契约目录规则 + +- `contracts/` 是跨 Sense、Brain、Bell 接口的唯一事实来源。 +- 只保存版本化 OpenAPI、JSON Schema、SQL 契约、示例和兼容说明,不保存共享业务实现。 +- 任一变更必须由跨项目协调工单负责,明确生产者、消费者、兼容周期、迁移和回退。 +- 破坏性变更必须发布新版本;不得原地改变已经发布的字段语义。 +- 每次变更必须验证所有受影响的生产者与消费者。 diff --git a/dev_scripts/check_harness.py b/dev_scripts/check_harness.py new file mode 100644 index 0000000..159b811 --- /dev/null +++ b/dev_scripts/check_harness.py @@ -0,0 +1,384 @@ +"""检查 DevHarness 必需文件、核心文档和任务归档的基本结构。""" + +from __future__ import annotations + +import argparse +import re +from pathlib import Path + +from wiki_docs import WikiDocsError, load_config, parse_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", + "New-Project-Documentation-Setup": ( + "docs/07-new-project-documentation-setup.md" + ), + "Existing-Project-Adoption-Guide": ( + "docs/08-existing-project-adoption.md" + ), + "Delivery-Documentation-Guide": "docs/delivery/README.md", + "Audience-Document-Template": ( + "docs/delivery/audience-document-template.md" + ), + "Task-Archive-Template": "docs/templates/task-archive.md", +} +CORE_DOCUMENT_REQUIREMENTS = { + "docs/README.md": ( + "## 第一次阅读", + "## 五分钟开始", + "## 简单修改从哪里开始", + "## 事实来源", + ), + "docs/00-project-profile.md": ( + "## 基本信息", + "## 子项目与交付单元", + "## 技术栈与运行环境", + "## 阅读入口", + "## 常用命令", + "## 环境、配置与凭据", + ), + "docs/01-workflow.md": ( + "## 面向初级维护者的修改边界", + "## 每个任务的文档影响", + "## 需求记录与流转", + "## 稳定文档与任务归档", + "## 自然语言快捷指令", + "## 效率与范围控制", + "### 严格控制范围", + "### 渐进执行和修复", + "### 复用已验证事实", + "### 明确停止条件", + ), + "docs/02-architecture-and-code-map.md": ( + "## 项目定位", + "## 代码地图", + "## 两条主要执行路径", + "## 不可破坏的边界", + ), + "docs/03-business-rules-and-glossary.md": ( + "## 核心术语", + "## 工单状态", + "## 稳定业务规则", + "## 新项目需要补充什么", + ), + "docs/04-local-development-and-verification.md": ( + "## 环境要求", + "## 第一次运行", + "## 常用调试方式", + "## 完成修改前", + ), + "docs/05-common-changes.md": ( + "## 风险分级", + "## 修改 Wiki 文案", + "## 调整 Harness 检查", + "## 看懂 Agent 的修改", + ), + "docs/06-troubleshooting.md": ( + "## 排查顺序", + "## 必须停止的情况", + ), + "docs/07-new-project-documentation-setup.md": ( + "## 初始化顺序", + "### 2. 识别子项目与交付单元", + "### 6. 确定交付对象和文档", + "## 完成标准", + ), + "docs/08-existing-project-adoption.md": ( + "## 与新项目初始化的区别", + "## 接入前只读盘点", + "## 已有内容保护原则", + "## 多应用单仓库判断", + "### 适合继续单仓库", + "### 可以考虑拆仓", + "### 保持单仓库时的最小规则", + "## 增量接入顺序", + "## 冲突处理和停止条件", + "## 可复制 Agent 指令", + "### 只分析", + "### 方案确认后实施", + "## 最小验收清单", + "## 回退原则", + ), + "docs/delivery/README.md": ( + "## 什么时候需要交付文档", + "## 受众与文档选择", + "## 内部文档与交付文档边界", + "## 编写和维护流程", + "## 最小验收清单", + ), + "docs/delivery/audience-document-template.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/sync_wiki_docs.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") + 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 / 其他", + "- 提出时间:", + "- 关键原话或脱敏摘要:", + "## 需求变化记录", + "| 日期 | 变化内容 | 原因 | 用户确认 |", + "## 文档影响", + "- [ ] 不影响长期文档,原因:", + "- [ ] 更新架构与代码地图", + "- [ ] 更新业务规则与术语", + "- [ ] 更新常见修改或故障排查", + "## 交付文档影响", + "- [ ] 无交付文档影响,原因:", + "- [ ] 更新已有交付文档,受众与页面:", + "- [ ] 新增交付文档,受众与页面:", + "- [ ] 需要目标岗位或客户代表验证:是 / 否;验证方式:", + ) + 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", + "提交只包含当前工单相关文件", + "### 自然语言快捷指令", + "`只分析`", + "`建工单`", + "`执行工单 #N`", + "`建工单并做`", + "`继续工单 #N`", + "`检查工单 #N`", + "`同步文档`", + "`#N 验收通过`", + "### 需求记录与流转", + "不得臆造用户原话", + "不复制完整聊天", + "Gitea 工单全文不导出到仓库", + ) + for section in missing_sections(content, required): + errors.append(f"AGENTS.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: + """检查每份本地文档都有显式映射和可追踪的镜像头。""" + + 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") + } + 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 main() -> int: + parser = argparse.ArgumentParser(description="检查 DevHarness 项目结构") + parser.add_argument( + "--strict", + action="store_true", + help="项目档案有占位内容时返回失败", + ) + args = parser.parse_args() + + 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_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 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/dev_scripts/new_task_archive.py b/dev_scripts/new_task_archive.py new file mode 100644 index 0000000..a1a01ad --- /dev/null +++ b/dev_scripts/new_task_archive.py @@ -0,0 +1,101 @@ +"""先在 Gitea Wiki 创建任务归档,再登记并导出本地镜像。""" + +from __future__ import annotations + +import argparse +import re +from datetime import date +from pathlib import Path + +from wiki_docs import ( + DEFAULT_CONFIG, + Mapping, + WikiClient, + WikiDocsError, + append_mapping, + load_config, + sync_all, +) + + +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) + + +def main() -> int: + parser = argparse.ArgumentParser( + description="在 Gitea Wiki 创建任务归档并导出 docs/task 镜像" + ) + parser.add_argument("issue_number", help="Gitea 工单号,例如 123") + parser.add_argument("title", help="简短任务标题") + parser.add_argument("--config", default=str(DEFAULT_CONFIG), help="Wiki 映射配置") + args = parser.parse_args() + + 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}" + local_path = f"docs/task/{args.issue_number}-{short_title}.md" + mapping = Mapping(page=page_name, path=local_path) + if any( + item.page == mapping.page or item.path == mapping.path + for item in config.mappings + ): + raise WikiDocsError(f"任务归档已经登记:{page_name}") + + client = WikiClient(config) + 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} 归档草稿", + ) + append_mapping(config, mapping) + updated_config = load_config(config.path) + messages = sync_all(updated_config, client) + except WikiDocsError as exc: + print(f"错误:{exc}") + return 1 + + print(f"已创建 Wiki:{page.html_url}") + for message in messages: + print(message) + print(f"已登记镜像:{local_path}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/dev_scripts/sync_wiki_docs.py b/dev_scripts/sync_wiki_docs.py new file mode 100644 index 0000000..e44b9d6 --- /dev/null +++ b/dev_scripts/sync_wiki_docs.py @@ -0,0 +1,33 @@ +"""从 Gitea Wiki 单向导出本地 docs 镜像。""" + +from __future__ import annotations + +import argparse +from pathlib import Path + +from wiki_docs import DEFAULT_CONFIG, WikiClient, WikiDocsError, load_config, sync_all + + +def main() -> int: + parser = argparse.ArgumentParser(description="从 Gitea Wiki 单向同步 docs 镜像") + parser.add_argument( + "--check", action="store_true", help="只检查 Wiki 与镜像是否一致,不写文件" + ) + parser.add_argument( + "--config", default=str(DEFAULT_CONFIG), help="Wiki 页面映射 JSON 文件" + ) + args = parser.parse_args() + 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 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/dev_scripts/wiki_docs.py b/dev_scripts/wiki_docs.py new file mode 100644 index 0000000..5f111fd --- /dev/null +++ b/dev_scripts/wiki_docs.py @@ -0,0 +1,394 @@ +"""Gitea Wiki 到本地 docs 镜像的共享实现。""" + +from __future__ import annotations + +import base64 +import json +import os +import re +import subprocess +import tempfile +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path, PurePosixPath +from typing import Any +from urllib.error import HTTPError, URLError +from urllib.parse import quote, urlencode +from urllib.request import Request, urlopen + + +ROOT = Path(__file__).resolve().parents[1] +DEFAULT_CONFIG = ROOT / "wiki-docs.json" +MIRROR_START = "" +MIRROR_END = "" +HEADER_PATTERN = re.compile( + rf"\A{re.escape(MIRROR_START)}\n(?P.*?)\n" + rf"{re.escape(MIRROR_END)}\n\n(?P.*)\Z", + re.DOTALL, +) + + +class WikiDocsError(RuntimeError): + """可供命令行直接展示的 Wiki 文档错误。""" + + +@dataclass(frozen=True) +class Mapping: + page: str + path: str + + +@dataclass(frozen=True) +class Config: + path: Path + gitea_url: str + owner: str + repository: str + mappings: tuple[Mapping, ...] + + +@dataclass(frozen=True) +class WikiPage: + title: str + sub_url: str + text: str + revision: str + html_url: str + + +def _required_string(data: dict[str, Any], key: str) -> str: + value = data.get(key) + if not isinstance(value, str) or not value.strip(): + raise WikiDocsError(f"配置字段 {key!r} 必须是非空字符串") + return value.strip() + + +def validate_mappings(raw_mappings: Any) -> tuple[Mapping, ...]: + """校验显式页面映射,确保只会写入 docs 下的 Markdown。""" + + if not isinstance(raw_mappings, list) or not raw_mappings: + raise WikiDocsError("配置字段 'mappings' 必须是非空数组") + + mappings: list[Mapping] = [] + pages: set[str] = set() + paths: set[str] = set() + for index, item in enumerate(raw_mappings, start=1): + if not isinstance(item, dict): + raise WikiDocsError(f"第 {index} 个映射必须是对象") + page = _required_string(item, "page") + path = _required_string(item, "path").replace("\\", "/") + pure_path = PurePosixPath(path) + if ( + pure_path.is_absolute() + or ".." in pure_path.parts + or not pure_path.parts + or pure_path.parts[0] != "docs" + or pure_path.suffix.lower() != ".md" + ): + raise WikiDocsError(f"镜像路径必须是 docs/ 下的 Markdown:{path}") + if page in pages: + raise WikiDocsError(f"Wiki 页面重复映射:{page}") + if path in paths: + raise WikiDocsError(f"本地路径重复映射:{path}") + pages.add(page) + paths.add(path) + mappings.append(Mapping(page=page, path=path)) + return tuple(mappings) + + +def load_config(path: Path = DEFAULT_CONFIG) -> Config: + try: + raw = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise WikiDocsError(f"无法读取 Wiki 映射配置 {path}: {exc}") from exc + if not isinstance(raw, dict) or raw.get("schema_version") != 1: + raise WikiDocsError("wiki-docs.json 的 schema_version 必须为 1") + configured_url = _required_string(raw, "gitea_url") + gitea_url = os.environ.get("GITEA_URL", configured_url).rstrip("/") + if gitea_url.endswith("/api/v1"): + gitea_url = gitea_url[: -len("/api/v1")] + return Config( + path=path, + gitea_url=gitea_url, + owner=_required_string(raw, "owner"), + repository=_required_string(raw, "repository"), + mappings=validate_mappings(raw.get("mappings")), + ) + + +class WikiClient: + """只使用标准库访问 Gitea Wiki API。""" + + def __init__(self, config: Config, token: str | None = None) -> None: + self.config = config + self.token = token if token is not None else os.environ.get("GITEA_TOKEN") + + def _request( + self, + method: str, + api_path: str, + *, + payload: dict[str, Any] | None = None, + query: dict[str, Any] | None = None, + ) -> Any: + url = f"{self.config.gitea_url}/api/v1{api_path}" + if query: + url = f"{url}?{urlencode(query)}" + headers = {"Accept": "application/json"} + if self.token: + headers["Authorization"] = f"Bearer {self.token}" + data = None + if payload is not None: + data = json.dumps(payload, ensure_ascii=False).encode("utf-8") + headers["Content-Type"] = "application/json" + request = Request(url, data=data, headers=headers, method=method) + try: + with urlopen(request, timeout=30) as response: + body = response.read() + except HTTPError as exc: + if self.token and method == "GET" and exc.code in {401, 403, 404}: + # 公共仓库可能可匿名读取,而当前 shell 中的通用令牌属于 + # 另一个实例或已失效。只对只读请求安全降级为匿名访问。 + anonymous_headers = {"Accept": "application/json"} + anonymous_request = Request( + url, data=data, headers=anonymous_headers, method=method + ) + try: + with urlopen(anonymous_request, timeout=30) as response: + body = response.read() + except HTTPError as anonymous_exc: + detail = anonymous_exc.read().decode("utf-8", errors="replace") + raise WikiDocsError( + f"Gitea API {method} {api_path} 返回 " + f"{anonymous_exc.code}: {detail}" + ) from anonymous_exc + except URLError as anonymous_exc: + raise WikiDocsError( + f"无法连接 Gitea:{anonymous_exc.reason}" + ) from anonymous_exc + else: + detail = exc.read().decode("utf-8", errors="replace") + raise WikiDocsError( + f"Gitea API {method} {api_path} 返回 {exc.code}: {detail}" + ) from exc + except URLError as exc: + raise WikiDocsError(f"无法连接 Gitea:{exc.reason}") from exc + if not body: + return None + try: + return json.loads(body.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise WikiDocsError("Gitea API 返回了无效的 UTF-8 JSON") from exc + + def list_pages(self) -> list[dict[str, Any]]: + pages: list[dict[str, Any]] = [] + page_number = 1 + while True: + batch = self._request( + "GET", + f"/repos/{quote(self.config.owner, safe='')}/" + f"{quote(self.config.repository, safe='')}/wiki/pages", + query={"page": page_number, "limit": 50}, + ) + if not isinstance(batch, list): + raise WikiDocsError("Gitea Wiki 页面列表格式无效") + pages.extend(item for item in batch if isinstance(item, dict)) + if len(batch) < 50: + return pages + page_number += 1 + + def get_page(self, page_name: str) -> WikiPage: + metadata = next( + ( + item + for item in self.list_pages() + if item.get("title") == page_name or item.get("sub_url") == page_name + ), + None, + ) + if metadata is None: + raise WikiDocsError( + f"Wiki 页面不存在:{page_name};不会自动删除或重命名本地镜像" + ) + sub_url = _required_string(metadata, "sub_url") + page = self._request( + "GET", + f"/repos/{quote(self.config.owner, safe='')}/" + f"{quote(self.config.repository, safe='')}/wiki/page/" + f"{quote(sub_url, safe='%')}", + ) + if not isinstance(page, dict): + raise WikiDocsError(f"Wiki 页面响应格式无效:{page_name}") + encoded_content = page.get("content_base64") + if not isinstance(encoded_content, str): + raise WikiDocsError(f"Wiki 页面没有 content_base64:{page_name}") + try: + text = base64.b64decode(encoded_content, validate=True).decode("utf-8") + except (ValueError, UnicodeDecodeError) as exc: + raise WikiDocsError(f"Wiki 页面不是有效的 UTF-8 Markdown:{page_name}") from exc + last_commit = page.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}") + title = page.get("title") + resolved_title = title if isinstance(title, str) and title else page_name + html_url = ( + f"{self.config.gitea_url}/{quote(self.config.owner, safe='')}/" + f"{quote(self.config.repository, safe='')}/wiki/{quote(sub_url, safe='%')}" + ) + return WikiPage( + title=resolved_title, + sub_url=sub_url, + text=normalize_body(text), + revision=revision, + html_url=html_url, + ) + + def create_page(self, title: str, content: str, message: str) -> WikiPage: + if not self.token: + raise WikiDocsError("创建 Wiki 页面需要通过 GITEA_TOKEN 提供写入令牌") + encoded = base64.b64encode(content.encode("utf-8")).decode("ascii") + self._request( + "POST", + f"/repos/{quote(self.config.owner, safe='')}/" + f"{quote(self.config.repository, safe='')}/wiki/new", + payload={"title": title, "content_base64": encoded, "message": message}, + ) + return self.get_page(title) + + +def normalize_body(text: str) -> str: + return text.replace("\r\n", "\n").replace("\r", "\n").rstrip() + "\n" + + +def parse_mirror(text: str) -> tuple[dict[str, str], str]: + match = HEADER_PATTERN.match(text.replace("\r\n", "\n").replace("\r", "\n")) + if match is None: + raise WikiDocsError("缺少或损坏 gitea-wiki-mirror 元数据头") + metadata: dict[str, str] = {} + for line in match.group("metadata").splitlines(): + key, separator, value = line.partition(": ") + if not separator or not key or not value: + raise WikiDocsError(f"无效的镜像元数据行:{line}") + metadata[key] = value + return metadata, normalize_body(match.group("body")) + + +def render_mirror(page: WikiPage, existing: str | None = None) -> str: + synchronized_at: str | None = None + if existing is not None: + try: + metadata, body = parse_mirror(existing) + except WikiDocsError: + pass + else: + if metadata.get("wiki_revision") == page.revision and body == page.text: + synchronized_at = metadata.get("synchronized_at") + if not synchronized_at: + synchronized_at = datetime.now(timezone.utc).isoformat(timespec="seconds").replace( + "+00:00", "Z" + ) + header = "\n".join( + ( + MIRROR_START, + "generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)", + f"wiki_page: {page.title}", + f"wiki_url: {page.html_url}", + f"wiki_revision: {page.revision}", + f"synchronized_at: {synchronized_at}", + MIRROR_END, + ) + ) + return f"{header}\n\n{page.text}" + + +def dirty_mirror_paths(config: Config, root: Path = ROOT) -> list[str]: + paths = [mapping.path for mapping in config.mappings] + result = subprocess.run( + ["git", "status", "--porcelain", "--", *paths], + cwd=root, + check=True, + capture_output=True, + text=True, + encoding="utf-8", + ) + return [line for line in result.stdout.splitlines() if line.strip()] + + +def _write_atomic(path: Path, content: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + handle, temporary_name = tempfile.mkstemp( + prefix=f".{path.name}.", suffix=".tmp", dir=path.parent + ) + try: + with os.fdopen(handle, "w", encoding="utf-8", newline="\n") as stream: + stream.write(content) + os.replace(temporary_name, path) + except BaseException: + Path(temporary_name).unlink(missing_ok=True) + raise + + +def check_mirror(mapping: Mapping, page: WikiPage, path: Path) -> list[str]: + if not path.is_file(): + return [f"缺少镜像:{mapping.path}"] + try: + metadata, body = parse_mirror(path.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError, WikiDocsError) as exc: + return [f"镜像无效 {mapping.path}: {exc}"] + expected = { + "wiki_page": page.title, + "wiki_url": page.html_url, + "wiki_revision": page.revision, + } + errors = [ + f"{mapping.path} 的 {key} 不一致" + for key, value in expected.items() + if metadata.get(key) != value + ] + if not metadata.get("synchronized_at"): + errors.append(f"{mapping.path} 缺少 synchronized_at") + if body != page.text: + errors.append(f"{mapping.path} 的正文与 Wiki 不一致") + return errors + + +def sync_all(config: Config, client: WikiClient, *, check: bool = False) -> list[str]: + """检查或写入所有显式映射;绝不处理映射外的文件。""" + + if not check: + dirty = dirty_mirror_paths(config) + if dirty: + details = "\n".join(dirty) + raise WikiDocsError( + "已映射的本地镜像存在未提交改动,已停止以防覆盖:\n" + details + ) + + messages: list[str] = [] + for mapping in config.mappings: + page = client.get_page(mapping.page) + target = ROOT / PurePosixPath(mapping.path) + if check: + errors = check_mirror(mapping, page, target) + if errors: + raise WikiDocsError("\n".join(errors)) + messages.append(f"一致:{mapping.path} <- {page.title}@{page.revision[:12]}") + continue + existing = target.read_text(encoding="utf-8") if target.is_file() else None + rendered = render_mirror(page, existing) + if existing != rendered: + _write_atomic(target, rendered) + messages.append(f"已更新:{mapping.path} <- {page.title}@{page.revision[:12]}") + else: + messages.append(f"无变化:{mapping.path} <- {page.title}@{page.revision[:12]}") + return messages + + +def append_mapping(config: Config, mapping: Mapping) -> None: + raw = json.loads(config.path.read_text(encoding="utf-8")) + mappings = validate_mappings(raw.get("mappings")) + if any(item.page == mapping.page or item.path == mapping.path for item in mappings): + raise WikiDocsError(f"页面或路径已经登记:{mapping.page} -> {mapping.path}") + raw["mappings"].append({"page": mapping.page, "path": mapping.path}) + rendered = json.dumps(raw, ensure_ascii=False, indent=2) + "\n" + _write_atomic(config.path, rendered) diff --git a/tests/test_harness_docs.py b/tests/test_harness_docs.py new file mode 100644 index 0000000..649a02e --- /dev/null +++ b/tests/test_harness_docs.py @@ -0,0 +1,184 @@ +from __future__ import annotations + +import sys +import tempfile +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "dev_scripts")) + +from check_harness import ( # noqa: E402 + CORE_DOCUMENT_REQUIREMENTS, + CORE_PAGE_PATHS, + REQUIRED_FILES, + check_claude_code_entry, + check_core_documents, + check_agent_efficiency_rules, + check_task_template, + core_mapping_errors, + missing_sections, +) +from wiki_docs import load_config # noqa: E402 + + +class CoreDocumentTests(unittest.TestCase): + def test_current_core_documents_have_required_sections(self) -> None: + errors: list[str] = [] + check_core_documents(errors) + self.assertEqual(errors, []) + + def test_missing_sections_reports_each_heading(self) -> None: + missing = missing_sections("# 页面\n## 已有\n", ("## 已有", "## 缺少")) + self.assertEqual(missing, ["## 缺少"]) + + def test_every_core_document_is_required(self) -> None: + for path in CORE_DOCUMENT_REQUIREMENTS: + self.assertIn(path, REQUIRED_FILES) + + def test_every_core_page_has_exact_mapping(self) -> None: + config = load_config() + mappings = {mapping.page: mapping.path for mapping in config.mappings} + for page, path in CORE_PAGE_PATHS.items(): + self.assertEqual(mappings.get(page), path) + + def test_existing_project_adoption_guide_is_core_document(self) -> None: + path = "docs/08-existing-project-adoption.md" + self.assertEqual( + CORE_PAGE_PATHS.get("Existing-Project-Adoption-Guide"), + path, + ) + self.assertIn(path, CORE_DOCUMENT_REQUIREMENTS) + self.assertIn("## 可复制 Agent 指令", CORE_DOCUMENT_REQUIREMENTS[path]) + + def test_missing_or_wrong_core_mapping_is_reported(self) -> None: + errors = core_mapping_errors({"Home": "docs/wrong.md"}) + self.assertTrue(any("Home -> docs/README.md" in error for error in errors)) + self.assertTrue( + any("Architecture-and-Code-Map" in error for error in errors) + ) + + +class TaskTemplateTests(unittest.TestCase): + def test_task_template_requires_document_impact(self) -> None: + errors: list[str] = [] + check_task_template(errors) + self.assertEqual(errors, []) + + def test_task_template_requires_delivery_document_impact(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + template = root / ".gitea" / "issue_template" / "task.md" + template.parent.mkdir(parents=True) + template.write_text( + "## 依赖与并行\n" + "- 前置工单:无 / #编号\n" + "- 是否允许与前置工单并行:是 / 否\n" + "- 原因:\n" + "## 原始需求\n" + "- 来源:用户对话 / Gitea / 其他\n" + "- 提出时间:\n" + "- 关键原话或脱敏摘要:\n" + "## 需求变化记录\n" + "| 日期 | 变化内容 | 原因 | 用户确认 |\n" + "## 文档影响\n" + "- [ ] 不影响长期文档,原因:\n" + "- [ ] 更新架构与代码地图\n" + "- [ ] 更新业务规则与术语\n" + "- [ ] 更新常见修改或故障排查\n", + encoding="utf-8", + ) + errors: list[str] = [] + check_task_template(errors, root) + self.assertIn("单元任务模板缺少:## 交付文档影响", errors) + self.assertIn( + "单元任务模板缺少:- [ ] 无交付文档影响,原因:", + errors, + ) + + def test_task_template_requires_subproject_impact(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + template = root / ".gitea" / "issue_template" / "task.md" + template.parent.mkdir(parents=True) + template.write_text("## 基本信息\n", encoding="utf-8") + errors: list[str] = [] + check_task_template(errors, root) + self.assertIn("单元任务模板缺少:## 子项目影响", errors) + self.assertIn( + "单元任务模板缺少:- 是否跨子项目:是 / 否", + errors, + ) + self.assertIn( + "单元任务模板缺少:- 是否修改共享接口或契约:是 / 否;唯一事实来源:", + errors, + ) + + def test_task_template_requires_dependency_fields(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + template = root / ".gitea" / "issue_template" / "task.md" + template.parent.mkdir(parents=True) + template.write_text( + "## 文档影响\n" + "- [ ] 不影响长期文档,原因:\n" + "- [ ] 更新架构与代码地图\n" + "- [ ] 更新业务规则与术语\n" + "- [ ] 更新常见修改或故障排查\n", + encoding="utf-8", + ) + errors: list[str] = [] + check_task_template(errors, root) + self.assertIn("单元任务模板缺少:## 依赖与并行", errors) + self.assertIn("单元任务模板缺少:- 前置工单:无 / #编号", errors) + + def test_task_template_requires_requirement_traceability(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + template = root / ".gitea" / "issue_template" / "task.md" + template.parent.mkdir(parents=True) + template.write_text( + "## 依赖与并行\n" + "- 前置工单:无 / #编号\n" + "- 是否允许与前置工单并行:是 / 否\n" + "- 原因:\n" + "## 文档影响\n" + "- [ ] 不影响长期文档,原因:\n" + "- [ ] 更新架构与代码地图\n" + "- [ ] 更新业务规则与术语\n" + "- [ ] 更新常见修改或故障排查\n", + encoding="utf-8", + ) + errors: list[str] = [] + check_task_template(errors, root) + self.assertIn("单元任务模板缺少:## 原始需求", errors) + self.assertIn("单元任务模板缺少:## 需求变化记录", errors) + + +class AgentRuleTests(unittest.TestCase): + def test_required_agent_rules_are_present(self) -> None: + errors: list[str] = [] + check_agent_efficiency_rules(errors) + self.assertEqual(errors, []) + + def test_claude_code_entry_imports_shared_rules(self) -> None: + errors: list[str] = [] + check_claude_code_entry(errors) + self.assertEqual(errors, []) + + def test_claude_code_entry_requires_exact_import_line(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + (root / "CLAUDE.md").write_text( + "共同规则事实来源\n只记录 Claude Code 特有\n" + "共同规则只修改 `AGENTS.md`\n", + encoding="utf-8", + ) + errors: list[str] = [] + check_claude_code_entry(errors, root) + self.assertIn("CLAUDE.md 缺少独立的 @AGENTS.md 导入", errors) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_wiki_docs.py b/tests/test_wiki_docs.py new file mode 100644 index 0000000..4d8c19a --- /dev/null +++ b/tests/test_wiki_docs.py @@ -0,0 +1,163 @@ +from __future__ import annotations + +import json +import os +import sys +import tempfile +import unittest +from pathlib import Path +from unittest.mock import Mock, patch + + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "dev_scripts")) + +from new_task_archive import build_archive, safe_title # noqa: E402 +from wiki_docs import ( # noqa: E402 + Config, + Mapping, + WikiClient, + WikiDocsError, + WikiPage, + dirty_mirror_paths, + load_config, + parse_mirror, + render_mirror, + sync_all, + validate_mappings, +) + + +class MappingTests(unittest.TestCase): + def test_rejects_path_outside_docs(self) -> None: + with self.assertRaisesRegex(WikiDocsError, "docs/"): + validate_mappings([{"page": "Home", "path": "README.md"}]) + + def test_rejects_duplicate_page(self) -> None: + with self.assertRaisesRegex(WikiDocsError, "重复映射"): + validate_mappings( + [ + {"page": "Home", "path": "docs/README.md"}, + {"page": "Home", "path": "docs/other.md"}, + ] + ) + + def test_normalizes_api_suffix_from_environment(self) -> None: + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "wiki-docs.json" + path.write_text( + json.dumps( + { + "schema_version": 1, + "gitea_url": "http://configured.example", + "owner": "owner", + "repository": "repo", + "mappings": [ + {"page": "Home", "path": "docs/README.md"} + ], + } + ), + encoding="utf-8", + ) + with patch.dict( + os.environ, {"GITEA_URL": "http://gitea.example/api/v1"}, clear=False + ): + config = load_config(path) + self.assertEqual(config.gitea_url, "http://gitea.example") + + +class MirrorTests(unittest.TestCase): + def setUp(self) -> None: + self.page = WikiPage( + title="Home", + sub_url="Home", + text="# 首页\n", + revision="a" * 40, + html_url="http://gitea.example/o/r/wiki/Home", + ) + + def test_render_includes_traceable_metadata(self) -> None: + rendered = render_mirror(self.page) + metadata, body = parse_mirror(rendered) + self.assertEqual(metadata["wiki_page"], "Home") + self.assertEqual(metadata["wiki_revision"], "a" * 40) + self.assertTrue(metadata["synchronized_at"].endswith("Z")) + self.assertEqual(body, "# 首页\n") + + def test_unchanged_revision_preserves_sync_time(self) -> None: + first = render_mirror(self.page) + second = render_mirror(self.page, first) + self.assertEqual(first, second) + + @patch("wiki_docs.subprocess.run") + def test_dirty_mirror_paths_are_reported(self, run) -> None: + run.return_value.stdout = " M docs/README.md\n" + config = Config( + path=Path("wiki-docs.json"), + gitea_url="http://gitea.example", + owner="o", + repository="r", + mappings=(Mapping("Home", "docs/README.md"),), + ) + self.assertEqual(dirty_mirror_paths(config), [" M docs/README.md"]) + + @patch("wiki_docs.dirty_mirror_paths", return_value=[" M docs/README.md"]) + def test_sync_stops_before_reading_wiki_when_mirror_is_dirty(self, _dirty) -> None: + config = Config( + path=Path("wiki-docs.json"), + gitea_url="http://gitea.example", + owner="o", + repository="r", + mappings=(Mapping("Home", "docs/README.md"),), + ) + client = Mock() + with self.assertRaisesRegex(WikiDocsError, "未提交改动"): + sync_all(config, client) + client.get_page.assert_not_called() + + +class WikiClientTests(unittest.TestCase): + def test_encoded_unicode_sub_url_is_not_double_encoded(self) -> None: + config = Config( + path=Path("wiki-docs.json"), + gitea_url="http://gitea.example", + owner="o", + repository="r", + mappings=(Mapping("中文", "docs/chinese.md"),), + ) + client = WikiClient(config, token="") + client.list_pages = Mock( + return_value=[{"title": "中文", "sub_url": "%E4%B8%AD%E6%96%87.-"}] + ) + encoded = __import__("base64").b64encode("# 中文\n".encode()).decode() + with patch.object( + client, + "_request", + return_value={ + "title": "中文", + "content_base64": encoded, + "last_commit": {"sha": "b" * 40}, + }, + ) as request: + page = client.get_page("中文") + api_path = request.call_args.args[1] + self.assertIn("%E4%B8%AD%E6%96%87.-", api_path) + self.assertNotIn("%25E4", api_path) + self.assertTrue(page.html_url.endswith("/%E4%B8%AD%E6%96%87.-")) + + +class ArchiveTests(unittest.TestCase): + def test_safe_title_handles_windows_characters(self) -> None: + self.assertEqual(safe_title(' 修复:"登录" / 超时 '), "修复-登录-超时") + + def test_build_archive_replaces_known_fields(self) -> None: + template = "# <工单号> <标题>\nYYYY-MM-DD\n<链接>\n<页面名>\n" + result = build_archive(template, "12", "修复登录", "Task-12-login", "http://i/12") + self.assertIn("# 12 修复登录", result) + self.assertIn("http://i/12", result) + self.assertIn("Task-12-login", result) + self.assertNotIn("YYYY-MM-DD", result) + + +if __name__ == "__main__": + unittest.main() diff --git a/wiki-docs.json b/wiki-docs.json new file mode 100644 index 0000000..f350e30 --- /dev/null +++ b/wiki-docs.json @@ -0,0 +1,25 @@ +{ + "schema_version": 1, + "gitea_url": "https://git.ilapage.cn", + "owner": "ila", + "repository": "yovision", + "mappings": [ + { "page": "Home", "path": "docs/README.md" }, + { "page": "Project-Profile", "path": "docs/00-project-profile.md" }, + { "page": "Development-Workflow", "path": "docs/01-workflow.md" }, + { "page": "Architecture-and-Code-Map", "path": "docs/02-architecture-and-code-map.md" }, + { "page": "Business-Rules-and-Glossary", "path": "docs/03-business-rules-and-glossary.md" }, + { "page": "Local-Development-and-Verification", "path": "docs/04-local-development-and-verification.md" }, + { "page": "Common-Changes", "path": "docs/05-common-changes.md" }, + { "page": "Troubleshooting", "path": "docs/06-troubleshooting.md" }, + { "page": "New-Project-Documentation-Setup", "path": "docs/07-new-project-documentation-setup.md" }, + { "page": "Existing-Project-Adoption-Guide", "path": "docs/08-existing-project-adoption.md" }, + { "page": "Product-Requirements", "path": "docs/09-product-requirements.md" }, + { "page": "Requirements-Migration-Matrix", "path": "docs/10-requirements-migration-matrix.md" }, + { "page": "Multi-Agent-Collaboration", "path": "docs/11-multi-agent-collaboration.md" }, + { "page": "Product-Roadmap", "path": "docs/12-product-roadmap.md" }, + { "page": "Delivery-Documentation-Guide", "path": "docs/delivery/README.md" }, + { "page": "Audience-Document-Template", "path": "docs/delivery/audience-document-template.md" }, + { "page": "Task-Archive-Template", "path": "docs/templates/task-archive.md" } + ] +}