chore: bootstrap YoVision DevHarness

This commit is contained in:
QiuSW
2026-08-11 18:21:31 +08:00
commit 4e6f75f1a3
18 changed files with 1706 additions and 0 deletions
+31
View File
@@ -0,0 +1,31 @@
## 背景
<!-- 为什么要做这个产品或长期能力。 -->
## 目标
- <!-- 填写 -->
## 非目标
- <!-- 填写 -->
## 总体方案
<!-- 只写稳定的架构和关键决策,不复制子任务细节。 -->
## 阶段路线
1. <!-- 填写 -->
## MVP 与任务索引
- [ ] # MVP 工单
## 依赖、风险和回退
- <!-- 填写 -->
## 最终验收标准
- [ ] <!-- 填写 -->
+27
View File
@@ -0,0 +1,27 @@
## 基本信息
- 所属 Epic:#
## MVP 目标
<!-- 这个版本交付后,用户能够完成什么。 -->
## 包含范围
- <!-- 填写 -->
## 排除范围
- <!-- 填写 -->
## 阶段与单元任务
- [ ] # 单元任务
## 集成风险和回退
- <!-- 填写 -->
## MVP 验收标准
- [ ] <!-- 填写 -->
+87
View File
@@ -0,0 +1,87 @@
## 基本信息
- 类型:需求 / 缺陷 / 重构
- 所属 Epic:#
- 所属 MVP / 版本:#
- 阶段:
## 依赖与并行
- 前置工单:无 / #编号
- 是否允许与前置工单并行:是 / 否
- 原因:
## 子项目影响
<!-- 单应用项目填写唯一交付单元;多应用单仓库必须明确单端、跨端和共享契约影响。 -->
- 仅影响的子项目 / 交付单元:
- 是否跨子项目:是 / 否
- 是否修改共享接口或契约:是 / 否;唯一事实来源:
- 各子项目需要执行的验证:
## 原始需求
- 来源:用户对话 / Gitea / 其他
- 提出时间:
- 关键原话或脱敏摘要:
<!-- 只保留表达用户目的、场景和限制所需的内容;不要复制完整聊天、内部推理或敏感信息。 -->
## 要解决什么
<!-- 描述现状和目标。缺陷需要写清复现步骤、实际结果和期望结果。 -->
## 做什么 / 不做什么
- 做:
- 不做:
## 已确认方案
<!-- 写清修改范围、关键设计,以及是否影响接口、数据库和安全边界。 -->
预计修改文件:
- <!-- 填写 -->
## 需求变化记录
<!-- 只记录影响范围、接口、数据、风险或验收的变化;没有变化时填写“无”。 -->
| 日期 | 变化内容 | 原因 | 用户确认 |
|---|---|---|---|
| | | | 是 / 否 |
## 文档影响
<!-- 至少选择一项;不影响长期文档时必须写明原因。 -->
- [ ] 不影响长期文档,原因:
- [ ] 更新项目档案或本地开发与验证
- [ ] 更新架构与代码地图
- [ ] 更新业务规则与术语
- [ ] 更新常见修改或故障排查
- [ ] 更新其他 Wiki 页面:
## 交付文档影响
<!-- 至少选择一项;面向用户、客户或其他岗位的行为、配置、部署、接口或支持方式变化时,必须列出受众和页面。 -->
- [ ] 无交付文档影响,原因:
- [ ] 更新已有交付文档,受众与页面:
- [ ] 新增交付文档,受众与页面:
- [ ] 需要目标岗位或客户代表验证:是 / 否;验证方式:
## 验收标准
- [ ] <!-- 填写 -->
- [ ] <!-- 填写 -->
## 验证方式
<!-- 写出可复制的命令;需要真机、生产环境或人工检查时明确说明。 -->
## 风险和回退
<!-- 普通低风险任务可删除本节;涉及接口、迁移、安全或不可逆操作时必填。 -->
+20
View File
@@ -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
+181
View File
@@ -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. 项目专用规则
<!-- 在项目初始化时填写不可违反的技术、安全和业务约束。复杂子项目请在其目录中增加 AGENTS.md。 -->
- 长期开发文档以 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`。
+9
View File
@@ -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/` 及工单明确列出的共享文件。
+9
View File
@@ -0,0 +1,9 @@
# Brain 目录规则
- Brain 是无界面的独立 Python/CUDA 推理交付单元,默认随 Sense 产品部署。
- Brain 负责解码、推理、跟踪、ReID、区域/警戒线判定和标准事件映射;不持有账户、RBAC、Alert 或通知状态。
- 业务上保持无状态;模型、缓存和 GPU 运行态不得成为事件或告警的唯一事实来源。
- 输出必须符合 `contracts/` 的冻结事件契约;不得把摄像头密码、内部文件路径或未经授权的人脸信息写入事件。
- 模型与规则分层:模型输出观测,版本化规则作业务判定。
- 修改 Sense 源接口、Bell 入站接口或共享契约前,必须转为跨项目协调工单。
- Brain agent 默认写路径仅为 `Brain/` 及工单明确列出的共享文件。
+31
View File
@@ -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 处理项目内容。
+11
View File
@@ -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 的只读镜像。
+9
View File
@@ -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/` 及工单明确列出的共享文件。
+7
View File
@@ -0,0 +1,7 @@
# 共享契约目录规则
- `contracts/` 是跨 Sense、Brain、Bell 接口的唯一事实来源。
- 只保存版本化 OpenAPI、JSON Schema、SQL 契约、示例和兼容说明,不保存共享业务实现。
- 任一变更必须由跨项目协调工单负责,明确生产者、消费者、兼容周期、迁移和回退。
- 破坏性变更必须发布新版本;不得原地改变已经发布的字段语义。
- 每次变更必须验证所有受影响的生产者与消费者。
+384
View File
@@ -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())
+101
View File
@@ -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())
+33
View File
@@ -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())
+394
View File
@@ -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 = "<!-- gitea-wiki-mirror:start -->"
MIRROR_END = "<!-- gitea-wiki-mirror:end -->"
HEADER_PATTERN = re.compile(
rf"\A{re.escape(MIRROR_START)}\n(?P<metadata>.*?)\n"
rf"{re.escape(MIRROR_END)}\n\n(?P<body>.*)\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)
+184
View File
@@ -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()
+163
View File
@@ -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()
+25
View File
@@ -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" }
]
}