diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..93b9d1b --- /dev/null +++ b/.gitattributes @@ -0,0 +1,12 @@ +# 仓库内文本统一以 LF 存储,避免 Windows/WSL 混用产生全量行尾差异。 +# 行尾差异会淹没真实改动,也会让 gitea.env 之类的配置在 bash 中带上 \r。 +* text=auto eol=lf + +*.py text eol=lf +*.md text eol=lf +*.json text eol=lf +*.ps1 text eol=crlf + +*.png binary +*.jpg binary +*.zip binary diff --git a/.gitea/issue_template/task.md b/.gitea/issue_template/task.md index 8d7d785..d85b925 100644 --- a/.gitea/issue_template/task.md +++ b/.gitea/issue_template/task.md @@ -1,9 +1,6 @@ ## 基本信息 - 类型:需求 / 缺陷 / 重构 -- 任务类型:单项目 / 协同 -- 主项目:Sense / Brain / Bell / contracts / 根级 -- 主 agent: - 所属 Epic:# - 所属 MVP / 版本:# - 阶段: @@ -21,20 +18,8 @@ - 仅影响的子项目 / 交付单元: - 是否跨子项目:是 / 否 - 是否修改共享接口或契约:是 / 否;唯一事实来源: -- write_paths: - 各子项目需要执行的验证: -## 协同接口 - - - -- 生产者: -- 消费者: -- 契约/共享事实源: -- 兼容策略:不适用 / 向后兼容 / 发布新版本 -- 被阻塞或需要适配的工单: -- 集成顺序: - ## 原始需求 - 来源:用户对话 / Gitea / 其他 @@ -68,6 +53,22 @@ |---|---|---|---| | | | | 是 / 否 | +## 设计与原型门禁 + + + +- 修改类型:纯显示文案 / 小范围 UI / 新组件 / 新页面或独立用户功能 / 重大交互或导航 / 非 UI / 恢复既有行为的 Bug +- 所需设计证据:无 / 标注截图 / 低保真图 / 已确认原型 / 架构、API、数据、状态或流程设计 / 原设计或复现证据 +- 可编辑设计源、线上原型链接和访问检查: +- 审核版本、revision、复制版本或确认日期及识别方式: +- 本地 HTML 导出:未要求 / 用户明确要求 / 项目规则要求 +- 本地 HTML 路径、版本和资源检查(仅显式导出时填写): +- 状态:无 / 草稿 / 已确认 / 已废弃 +- 确认人、确认时间和覆盖范围: +- 无需 UI 原型或无需任何原型的原因: + + + ## 文档影响 @@ -88,6 +89,15 @@ - [ ] 新增交付文档,受众与页面: - [ ] 需要目标岗位或客户代表验证:是 / 否;验证方式: +## 任务记录与可选快照 + +- 单次任务事实来源:当前 Gitea 工单正文与评论 +- [ ] 默认不创建任务快照 +- [ ] 用户明确要求专项快照;用途和范围: +- [ ] 项目专用规则要求任务快照;规则入口: + + + ## 验收标准 - [ ] diff --git a/.gitignore b/.gitignore index 0125a16..9c058d5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .codex/gitea.env +gitea.env .codex/*.log .codex/config.toml __pycache__/ diff --git a/AGENTS.md b/AGENTS.md index 78bffb2..4da1ae1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,8 +1,25 @@ # Agent 开发规则 -本仓库采用 DevHarness 工作流:Gitea 工单是任务过程的事实来源,Gitea Wiki 是长期开发文档和任务归档的事实来源,Git 是代码与版本绑定资料的变更记录,`docs/` 只保存 Wiki 的只读镜像。人负责确认方案与验收,Agent 负责检查、实现、测试和留下证据。 +本仓库采用 DevHarness 工作流:Gitea 工单是单次任务需求、变化、实现、测试、提交和验收的事实来源,Gitea Wiki 是长期开发文档的事实来源,Git 是代码与版本绑定资料的变更记录。`docs/` 默认保存核心 Wiki 的只读镜像,`docs/task/` 只保存人工明确要求的专项或历史兼容快照。人负责确认方案与验收,Agent 负责检查、实现、测试和留下证据。 -开始工作前先阅读 [项目档案](docs/00-project-profile.md) 和任务涉及目录中的 `AGENTS.md`。目录越深的规则越具体,但不得削弱上级安全规则。 +开始工作前先阅读任务涉及目录中的 `AGENTS.md`。目录越深的规则越具体,但不得削弱上级安全规则。 + +[项目档案](docs/00-project-profile.md) 按需阅读,不作为每次任务的固定前置。出现下列情况之一时必须读:需要环境、配置或凭据来源;需要确认目录边界;需要判断子项目与交付单元划分;需要 DevHarness 来源与基线;需要项目专用验收要求。只为查命令不必打开项目档案。 + +## 常用命令 + +所有命令默认从仓库根目录执行,与项目档案保持一致。 + +| 用途 | 命令 | +|---|---| +| 查看工作区 | `git status --short --branch` | +| 检查模板结构 | `python dev_scripts/harness.py check --strict` | +| 运行单元测试 | `python -m unittest discover -s tests -v` | +| 导出核心 Wiki 镜像 | `python dev_scripts/harness.py sync` | +| 检查核心 Wiki 镜像 | `python dev_scripts/harness.py sync --check` | +| 创建可选任务快照 | `python dev_scripts/harness.py archive 123 "修复登录超时"` | +| 增量导出已有快照 | `python dev_scripts/harness.py export` | +| 全量导出已有快照 | `python dev_scripts/harness.py export --all` | ## 1. 永久规则 @@ -18,14 +35,15 @@ 新功能、缺陷修复、重构以及任何用户可感知或改变程序行为的修改,必须先建立单元任务工单。 -以下小改动可以直接提交,不要求工单和任务归档: +以下小改动可以直接提交,不要求工单: - 只改错别字、注释或文档措辞; - 只做格式化、导入排序或不跨文件的内部变量改名; - 补充类型标注或文档字符串且不改变行为; - 删除已经确认无人使用的死代码。 +- 只修改用户看到的界面显示文案,并且满足本文件“工单与设计证据双门禁”的全部豁免条件。 -只要涉及接口、数据库、状态、权限、安全、并发、用户界面,或者无法确定是否改变行为,就必须建工单。 +除严格符合界面显示文案豁免的修改外,只要涉及接口、数据库、状态、权限、安全、并发、用户界面,或者无法确定是否改变行为,就必须建工单。代码组件名、类名、变量、国际化键、API 字段和数据库字段不是显示文案,不适用豁免。 ## 3. 需求到实施 @@ -37,24 +55,61 @@ 6. 开始实施前检查分支和工作区,明确哪些现有改动不属于本任务。 7. 严格按工单范围实现;新发现的问题先记录,不顺手混入当前任务。 8. 执行与风险相称的测试,把关键结果和未验证部分更新到工单。实施过程中出现计划外、当前无法解除的问题时才标记“阻塞”。 -9. 长期文档必须先修改 Wiki、读取确认,再运行 `python dev_scripts/sync_wiki_docs.py` 导出本地镜像;不得直接编辑 `docs/` 后反向覆盖 Wiki。 +9. 只有长期事实变化时才修改 Wiki、读取确认,再运行 `python dev_scripts/harness.py sync` 导出本地镜像;没有长期文档影响时在工单说明原因并跳过 Wiki 同步。不得直接编辑镜像后反向覆盖 Wiki。 + +工单正文保存用户确认的任务基线;根因、范围、方案、风险或阻塞发生重要变化时追加评论。完成实现后用一条评论集中记录最终差异、测试、未验证内容、提交哈希和文档影响;用户验收后再追加验收时间与结论,不重复覆盖或抄写已有证据。 Gitea 不可用时,输出完整工单草稿并说明阻塞。未经用户明确授权,不得默认绕过建单。 +### Gitea 交互与工单最小读取 + +- 所有 Gitea 工单和 Wiki 的查询、创建、更新、评论、状态变更及关闭操作,优先使用项目已配置的 Gitea MCP。 +- MCP 不可用或不支持所需操作时才回退 Gitea API,并在当前工单记录回退原因;初始化阶段尚无工单时记录到初始化工单草稿,建单后补回。凭据只从环境或 MCP 安全配置读取。 +- 首次接手任务时读取工单确认基线和完成当前判断所需的评论,不因节省 Token 跳过范围、依赖、安全、验收或重要变更。 +- 同一任务、同一会话且关键前提未变化时,复用仍有效的工单事实,优先关注当前状态、最新评论和首个未完成步骤,不重复分析已经确认且仍有效的内容。 +- 会话、代码、配置、依赖、凭据、远端状态或关键前提变化,任务基线不清楚,或最新评论声明历史需求、方案、范围、风险或验收发生变化时,重新读取必要历史;无法判断影响范围时读取完整工单。 +- 连接器不支持评论分页或增量读取时允许读取完整工单,但不得把“已读取全文”误当成需要重新分析全部历史,也不得为规避完整读取而新增本地工单、缓存或第二事实来源。 +- 正确性、安全规则和已确认范围优先于 Token 优化;读取边界存在不确定时补读必要证据。 + +### 新项目 Wiki 初始化门禁 + +- 从本模板创建新项目时,先创建 Gitea 远端仓库并启用工单和 Wiki,再配置 `wiki-docs.json`;不得把模板自带的本地 `docs/` 当作新项目 Wiki 已初始化的证据。 +- 优先使用项目已配置的 Gitea MCP 查询和写入 Wiki;MCP 不可用或不支持所需写操作时,才使用 Gitea API,并在初始化工单记录回退原因。凭据只从环境或 MCP 安全配置读取。 +- 先查询线上页面列表;`Home` 不存在时必须先创建 `Home`,在线回读正文并记录 revision,然后再逐页创建或更新其他核心映射页面。 +- 每个核心页面写入后必须在线回读并取得 revision。页面缺失、回读失败或没有 revision 时停止初始化,不得开始产品代码。 +- 产品编码前必须运行 `python dev_scripts/harness.py sync --verify`;全部成功才表示线上 Wiki 和核心镜像初始化完成。 + +### 工单与设计证据双门禁 + +- 纯界面显示文案只有在不改变业务含义、流程、权限、状态、接口、数据、法律/安全/支付/单位等高风险含义、国际化键、程序标识符、布局和可访问性,且没有任何不确定时,才免工单和原型;修改后执行最小界面检查。 +- 新页面、独立用户功能、重大交互或导航变化,必须先用 Quant-UX 或其他合适工具制作可审阅原型;用户确认原型、文字需求和覆盖范围后,才能建立或放行实现工单并编写生产代码。 +- 上述完整原型形成待审核版本后,默认直接通过 Quant-UX 或其他设计工具的线上链接审核,不要求每次导出本地 HTML。工单必须记录可访问链接、版本/revision 或确认日期、审核版本识别方式、确认人、确认时间和覆盖范围;线上链接无法访问或无法区分版本时停止审核,等待用户确认等效方案。 +- 只有用户明确要求 `导出原型 #N`、`导出全部原型`,或项目专用规则明确要求离线交付时,才导出到 `prototypes/<工单号>/<版本>/index.html`。已确认的本地快照不得原位覆盖;版本目录内资源使用相对路径,导出后检查入口、主要交互和资源完整性,但不自动提交。 +- 现有界面的小范围样式或布局调整使用标注截图、低保真图或明确复用的现有规范;新组件记录状态、错误和边界。两者只要不符合纯文案豁免就必须建单。 +- 后端、接口、数据处理和定时任务不强制 UI 原型,但必须先确认架构、API、数据、状态或流程设计;恢复既有确认行为的 Bug 可以复用原设计、截图、复现步骤或已有验收证据。 +- 需要设计证据的工单记录链接或路径、版本/revision 或日期、状态、确认人、确认时间和覆盖范围;没有 UI 原型时记录替代技术设计或原因。 +- 页面结构、主要流程、状态、权限、异常处理或验收结果变化时,必须更新原型或文字需求并重新确认后再继续正式编码。 +- 草稿原型可以用于讨论;写入 Git/Wiki、多人协作或单独实施时建立设计任务。草稿和经明确授权的隔离技术验证都不得直接作为生产实现。 +- 设计工具无法生成用户明确要求的可用 HTML 时,在工单记录限制并停止该导出或离线交付,等待用户确认等效方案;线上原型可访问且版本明确时不因此阻塞线上审核。纯显示文案、小范围 UI、非 UI 需求和恢复既有行为的 Bug 不强制建立完整线上原型或导出 HTML。 + ### 自然语言快捷指令 快捷指令只是本工作流的自然语言别名,不得绕过方案确认、前置依赖、安全规则、工单范围、Wiki 主源、必要验证或人工验收: - `只分析`:只读检查并给出方案;不建单、不修改,停在等待确认。 - `建工单`:根据已确认方案创建单元任务工单;建单后停止,不修改代码。 -- `执行工单 #N`:检查工单和依赖,实施、测试、提交、归档、推送并回写证据;停在“待验收”。 +- `执行工单 #N`:检查工单和依赖,实施、测试、提交、推送并回写证据;仅在明确要求时创建任务快照;停在“待验收”。 - `建工单并做`:依次建单和执行,`建工单,做`、`建工单,做` 含义相同;停在“待验收”。 -- `继续工单 #N`:核对工单、Git 和 Wiki 证据,从首个未完成步骤继续,不重复仍然有效的检查。 +- `继续工单 #N`:优先核对当前状态、最新评论、Git 和必要 Wiki 证据,从首个未完成步骤继续;关键前提未变化时不重复读取和分析仍有效的内容。 - `检查工单 #N`:只读核对范围、验收、测试和证据并输出报告;不自动修复。 -- `同步文档`:读取 Wiki、导出 `docs/` 并检查一致性;不修改 Wiki、不自动提交。 -- `#N 验收通过`:仅在用户明确验收后,更新归档、同步并提交镜像、推送、同步父工单并关闭任务。 +- `同步文档`:读取 Wiki、导出核心 `docs/` 并检查一致性,不处理任务归档;不修改 Wiki、不自动提交。 +- `导出原型 #N`:人工触发导出指定工单已确认的原型版本,按工单和版本写入 `prototypes/`;不扩展范围、不自动提交。 +- `导出全部原型`:人工触发导出当前项目已明确范围内的全部已确认原型;不自动提交。 +- `导出任务归档`:人工触发 `python dev_scripts/harness.py export`,只导出新增或 revision 已变化的任务归档;不删除本地文件、不自动提交。 +- `导出全部任务归档`:人工触发 `python dev_scripts/harness.py export --all`,读取并导出全部线上任务归档;不删除本地文件、不自动提交。 +- `#N 验收通过`:仅在用户明确验收后,在工单追加验收结论;按需更新真实变化的长期 Wiki,推送、同步父工单并关闭任务;不创建或导出任务归档。 -方案未确认或前置依赖未满足时,实施类指令必须停在对应门禁;除 `#N 验收通过` 外,快捷指令不得关闭待验收工单。Gitea 工单不导出全文,本地只保存 Wiki 任务归档镜像。详细语义见 [开发工作流](docs/01-workflow.md)。 +方案未确认或前置依赖未满足时,实施类指令必须停在对应门禁;除 `#N 验收通过` 外,快捷指令不得关闭待验收工单。原型和任务快照的导出必须由用户明确提出或项目专用规则明确要求,其他指令不得隐式执行。Gitea 工单不导出全文,`docs/task/` 只是可能不完整的专项或历史兼容快照。详细语义见 [开发工作流](docs/01-workflow.md)。 ### 需求记录与流转 @@ -62,13 +117,13 @@ Gitea 不可用时,输出完整工单草稿并说明阻塞。未经用户明 - 工单中的目标、非目标、已确认方案、验收标准和文档影响构成确认后的正式任务需求。 - 影响范围、接口、数据、风险或验收的需求变化必须记录日期、内容、原因和用户确认;会改变已确认结果时先更新工单并等待再次确认。 - 不复制完整聊天,不保存 Agent 内部推理,不写入密码、令牌、个人数据或生产数据;包含敏感信息的原话必须删除敏感部分或改写为脱敏摘要。 -- 长期有效的产品需求、业务规则和系统边界进入对应 Wiki 主题页并导出 `docs/`;完成结果进入 Wiki 任务归档并导出 `docs/task/`。Gitea 工单全文不导出到仓库。 +- 长期有效的产品需求、业务规则和系统边界进入对应 Wiki 主题页并导出核心 `docs/`;单次任务的完成结果和验收保留在工单正文与评论。只有用户明确要求专项快照或项目专用规则要求时才创建 Wiki 任务快照并按需导出 `docs/task/`。Gitea 工单全文不导出到仓库。 详细记录边界见 [开发工作流](docs/01-workflow.md) 与 [业务规则和术语](docs/03-business-rules-and-glossary.md)。 ### 效率与范围控制 -本节只用于减少无关工作和重复检查,不得削弱安全规则、已确认方案、工单范围、必要测试、Wiki 同步、Git 提交和验收归档要求。 +本节只用于减少无关工作和重复检查,不得削弱安全规则、已确认方案、工单范围、必要测试、必要的长期文档同步、Git 提交和人工验收要求。 #### 严格控制范围 @@ -116,28 +171,26 @@ Gitea 不可用时,输出完整工单草稿并说明阻塞。未经用户明 ## 6. Git 与验证 -- `explore` 是旧实现的只读聚合快照,不接受新功能、修复或文档演进;需要恢复旧行为时从其来源提交读取证据,不在该分支续写产品。 -- `main` 是用户审核基线,禁止直接开发、直接提交或未经用户明确审核的合并。 -- 新任务分支必须从当前 `dev` 创建,完成后通过 PR 合回 `dev`;不得把功能分支直接合入 `main`。 -- `dev` 完成集成测试后仍不能自动进入 `main`;只有用户明确表示审核/验收通过,Agent 才能执行 `dev → main`。 -- 紧急修复也必须建单并取得用户对分支与合并路径的明确授权,不默认绕过 `dev`。 - 提交只包含当前工单相关文件。 - 实现提交信息引用工单号,例如:`fix: 修复登录超时 (#123)`。 - 不为流程制造空提交。 - 优先运行项目档案中记录的格式检查、静态检查、单元测试和必要的集成测试。 -- 不能验证的真机、生产、迁移或并发行为必须写入工单和归档。 +- 不能验证的真机、生产、迁移或并发行为必须写入工单;存在明确要求的任务快照时再同步记录。 +- Windows 环境优先使用当前已配置的 PowerShell;可选择时优先 PowerShell 7 `pwsh.exe`,不得仅为设置编码重复启动一层 PowerShell。 +- 文本文件读写在命令支持时显式指定 UTF-8;文件解码和控制台输出分别处理,只有出现真实乱码或已知宿主非 UTF-8 时才设置当前进程的输出编码或 Python UTF-8 环境变量。 +- 不得默认使用 `-ExecutionPolicy Bypass`;只有可信 `.ps1`确实被执行策略阻止且没有更小替代方案时,才对该次进程使用并在工单记录原因。 -## 7. 完成、验收和归档 +## 7. 完成和验收 -1. 逐项完成验收、测试和实现提交,并把最终方案、差异、结果、提交及遗留问题写回工单。 +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。 +3. 有长期文档影响时,读取确认 Wiki,运行 `python dev_scripts/harness.py sync --check` 检查核心镜像,并把页面 revision 和镜像提交哈希写回工单;没有长期文档影响时不运行 Wiki 同步。 +4. 用户验收通过后,在工单追加验收时间和结论,关闭单元工单,并同步更新 MVP 和 Epic;没有真实变化的 Wiki 不重复更新或检查。 +5. 默认不创建任务归档。只有用户明确要求专项快照或项目专用规则明确要求时,才运行 `python dev_scripts/harness.py archive <编号> "<短标题>"`;`export` 和 `export --all` 同样必须显式触发。 MVP 内所有单元任务通过后才能做 MVP 集成验收;MVP 通过后才能关闭 MVP。Epic 的全部范围完成后才能关闭 Epic。 -详细归档顺序和字段见 [开发工作流](docs/01-workflow.md)。 +详细证据回写、可选快照和文档同步边界见 [开发工作流](docs/01-workflow.md)。 ## 8. 可维护性 @@ -158,8 +211,9 @@ MVP 内所有单元任务通过后才能做 MVP 集成验收;MVP 通过后才 - 每个单元任务必须在工单中选择“无长期文档影响并说明原因”或列出需要更新的 Wiki 页面。 - 启动、测试、部署、排错命令,模块入口、目录职责、主要调用路径,配置、API、数据结构、状态、业务规则、安全边界、日志位置发生变化时,必须更新对应 Wiki。 +- 部署命令变化时,有常驻服务的项目更新自己的 `Deployment-and-Operations` 页面(由 [部署文档模板](docs/templates/deployment.md) 复制建立);没有常驻服务的项目记录为无部署文档影响,不创建空的部署页。 - 普通内部重构只有在入口、行为、配置和验证方式均未改变时,才可以记录为不影响长期文档。 -- 必需核心页面及结构以 `python dev_scripts/check_harness.py --strict` 和 [新项目文档初始化](docs/07-new-project-documentation-setup.md) 为准;稳定文档与任务归档的分工见 [开发工作流](docs/01-workflow.md)。 +- 必需核心页面及结构以 `python dev_scripts/harness.py check --strict` 和 [新项目文档初始化](docs/07-new-project-documentation-setup.md) 为准;稳定文档与可选历史快照的分工见 [开发工作流](docs/01-workflow.md)。 ## 9. 引导提交例外 @@ -169,11 +223,20 @@ MVP 内所有单元任务通过后才能做 MVP 集成验收;MVP 通过后才 ## 10. 项目专用规则 +### YoVision 分支治理 + +- `explore` 是旧实现的只读聚合快照,不接受新功能、修复或文档演进;需要恢复旧行为时从其来源提交读取证据,不在该分支续写产品。 +- `main` 是用户审核基线,禁止直接开发、直接提交或未经用户明确审核的合并。 +- 新任务分支必须从当前 `dev` 创建,完成后通过 PR 合回 `dev`;不得把功能分支直接合入 `main`。 +- `dev` 完成集成测试后仍不能自动进入 `main`;只有用户明确表示审核/验收通过,Agent 才能执行 `dev → main`。 +- 紧急修复也必须建单并取得用户对分支与合并路径的明确授权,不默认绕过 `dev`。 + -- 长期开发文档以 Gitea Wiki 为事实来源,`docs/` 是显式映射生成的只读镜像。 -- Wiki 与镜像的固定顺序是:修改 Wiki → 读取确认 → 导出 `docs` → 校验差异 → 提交镜像。 -- 同步配置只允许写入 `docs/` 下的 Markdown;发现镜像有未提交修改时必须停止。 +- 长期开发文档以 Gitea Wiki 为事实来源,单次任务证据以 Gitea 工单为事实来源;`docs/` 默认保存显式映射生成的核心只读镜像,`docs/task/` 是人工明确要求的专项或历史兼容快照,可能不完整或不是最新状态。 +- 核心 Wiki 与镜像的固定顺序是:修改 Wiki → 读取确认 → 导出核心 `docs` → 校验差异 → 提交镜像。 +- 默认不创建任务归档;`archive`、`导出任务归档` 或 `导出全部任务归档` 必须由用户明确提出或项目专用规则明确要求,且不得自动传播删除或重命名。 +- 同步配置只允许写入 `docs/` 下的 Markdown;发现核心镜像有未提交修改时必须停止。 - Gitea 凭据只通过进程环境或 MCP 安全配置提供,不得写入仓库。 - `dev_scripts/` 只存放 DevHarness 自身工具;业务项目的通用脚本必须使用独立目录,不得混放。 - 本仓库包含 `Sense/`、`Brain/`、`Bell/` 三个独立开发与交付单元;Sense 与 Bell 是认证、数据、部署和发布完全独立的销售产品,Brain 是无界面推理交付单元。 diff --git a/README.md b/README.md index bdbedba..fa953b9 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,48 @@ YoVision 是一个单仓多项目的智能视频事件平台,包含三个可 Sense 与 Bell 是账户、数据、部署和发布边界完全独立的两个销售产品;Brain 是无界面的独立推理交付单元,默认随 Sense 部署。跨项目协作只通过 `contracts/` 中的版本化契约。 -开始工作前阅读 [AGENTS.md](AGENTS.md) 和 [文档中心](docs/README.md)。本仓库采用 DevHarness:Gitea 工单记录任务过程,Gitea Wiki 保存长期文档,`docs/` 是 Wiki 的只读镜像。 +开始工作前阅读 [AGENTS.md](AGENTS.md) 和 [文档中心](docs/README.md)。本仓库采用 DevHarness:Gitea 工单是单次任务唯一事实来源,Gitea Wiki 保存长期文档,`docs/` 默认保存核心 Wiki 的只读镜像。 + +## 工作闭环 + +```text +讨论需求或缺陷 + -> 阅读代码并提出方案 + -> 人工确认方案 + -> 创建 Epic / MVP / 单元任务工单 + -> Agent 实现并测试 + -> 提交代码并集中回写工单证据 + -> 人工验收后记录结论并关闭工单 + -> 长期事实变化时才同步 Wiki +``` + +默认不创建任务归档;`docs/task/` 只保存人工明确要求的专项或历史兼容快照。 + +## 首次初始化顺序 + +1. 创建 Gitea 远端仓库并推送当前引导提交,启用工单和 Wiki。 +2. 配置 `wiki-docs.json` 和安全访问方式;优先使用已配置的 Gitea MCP,MCP 不可用时才使用 Gitea API并记录原因。 +3. 查询线上 Wiki;`Home` 不存在时先创建并回读 `Home`,取得 revision 后再创建其他核心页面。 +4. 本地 `docs/` 的存在不能证明线上 Wiki 已初始化。 +5. 开始产品编码前运行: + + ```powershell + python dev_scripts/harness.py sync --verify + ``` + +YoVision 已完成远端与 Wiki 初始化,上述步骤用于维护者理解门禁和以后建立新仓库。 + +## 常用命令 + +```powershell +python dev_scripts/harness.py check --strict +python dev_scripts/harness.py sync +python dev_scripts/harness.py sync --check +python -m unittest discover -s tests -v +git diff --check +``` + +只有明确要求时才运行 `python dev_scripts/harness.py archive <编号> "<标题>"`、`export` 或 `export --all`。 ## 分支模型 @@ -18,3 +59,15 @@ Sense 与 Bell 是账户、数据、部署和发布边界完全独立的两个 - 只有用户明确审核通过,才能将 `dev` 合入 `main`。 Sense 与 Bell 的新实现必须从根 `goadmin-baseline.json` 冻结的 go-admin 和 go-admin-ui 完整提交派生,开发时必须核对对应 commit 的 go-admin-doc。仅参考 GoAdmin 的技术栈、布局或视觉不属于本项目认可的二次开发。 + +## Harness 目录 + +```text +AGENTS.md Agent 通用规则与 YoVision 专用门禁 +CLAUDE.md Claude Code 入口和三项目角色路由 +.gitea/issue_template/ Epic、MVP、单元任务模板 +docs/ 核心 Wiki 只读镜像及可选历史快照 +wiki-docs.json 核心 Wiki 页面显式映射 +dev_scripts/harness.py check / sync / archive / export 单一入口 +dev_scripts/wiki_docs.py Gitea Wiki 客户端与镜像生成库 +``` diff --git a/dev_scripts/check_harness.py b/dev_scripts/harness.py similarity index 55% rename from dev_scripts/check_harness.py rename to dev_scripts/harness.py index b1c2d54..bed92a6 100644 --- a/dev_scripts/check_harness.py +++ b/dev_scripts/harness.py @@ -1,15 +1,38 @@ -"""检查 DevHarness 必需文件、核心文档和任务归档的基本结构。""" +"""DevHarness 单一命令行入口。 + +子命令: + check 检查必需文件、核心文档和已有任务快照结构 + sync 从 Gitea Wiki 单向导出或校验核心 docs 镜像 + archive 显式在 Gitea Wiki 创建可选任务快照 + export 人工按需把已有 Wiki 任务快照导出到 docs/task + +各子命令的实现逻辑取自原来的 check_harness.py、sync_wiki_docs.py、 +new_task_archive.py 和 export_task_archives.py,行为未改变。 +""" from __future__ import annotations import argparse import json import re +from datetime import date from pathlib import Path +from typing import Any -from wiki_docs import WikiDocsError, load_config, parse_mirror +from wiki_docs import ( + DEFAULT_CONFIG, + WikiClient, + WikiDocsError, + dirty_paths, + load_config, + parse_mirror, + sync_all, + write_mirror, +) +# ---------------------------------------------------------------- 结构检查 + ROOT = Path(__file__).resolve().parents[1] CORE_PAGE_PATHS = { "Home": "docs/README.md", @@ -28,10 +51,20 @@ CORE_PAGE_PATHS = { "Existing-Project-Adoption-Guide": ( "docs/08-existing-project-adoption.md" ), + "Product-Requirements": ( + "docs/09-product-requirements.md" + ), + "Requirements-Migration-Matrix": "docs/10-requirements-migration-matrix.md", + "Multi-Agent-Collaboration": "docs/11-multi-agent-collaboration.md", + "Product-Roadmap": "docs/12-product-roadmap.md", "Delivery-Documentation-Guide": "docs/delivery/README.md", "Audience-Document-Template": ( "docs/delivery/audience-document-template.md" ), + "Deployment-and-Operations": ( + "docs/delivery/deployment-and-operations.md" + ), + "Deployment-Template": "docs/templates/deployment.md", "Task-Archive-Template": "docs/templates/task-archive.md", } CORE_DOCUMENT_REQUIREMENTS = { @@ -43,6 +76,7 @@ CORE_DOCUMENT_REQUIREMENTS = { ), "docs/00-project-profile.md": ( "## 基本信息", + "## DevHarness 来源与基线", "## 子项目与交付单元", "## 技术栈与运行环境", "## 阅读入口", @@ -51,11 +85,17 @@ CORE_DOCUMENT_REQUIREMENTS = { "## Sense/Bell GoAdmin 固定技术基线", ), "docs/01-workflow.md": ( + "## Gitea 交互与工单最小读取", + "## 新项目 Wiki 初始化门禁", + "## 工单与设计证据双门禁", + "### 先判断是否需要工单", + "### 再判断设计证据", + "### 线上原型审核与按需导出", + "### 记录和重新确认", "## 面向初级维护者的修改边界", - "## go-admin / go-admin-ui 开发约束", "## 每个任务的文档影响", "## 需求记录与流转", - "## 稳定文档与任务归档", + "## 稳定文档与可选历史快照", "## 自然语言快捷指令", "## 效率与范围控制", "### 严格控制范围", @@ -77,6 +117,7 @@ CORE_DOCUMENT_REQUIREMENTS = { ), "docs/04-local-development-and-verification.md": ( "## 环境要求", + "## Windows PowerShell 与 UTF-8", "## Sense/Bell 固定工具链与只读参考源", "## 第一次运行", "## 常用调试方式", @@ -94,8 +135,13 @@ CORE_DOCUMENT_REQUIREMENTS = { ), "docs/07-new-project-documentation-setup.md": ( "## 初始化顺序", - "### 2. 识别子项目与交付单元", - "### 6. 确定交付对象和文档", + "#### 需求总览启用条件", + "### 2. 选择建设基线", + "#### 工程基线裁剪", + "#### 判断案例", + "### 3. 识别子项目与交付单元", + "### 7. 确定交付对象和文档", + "#### 在线创建与回读门禁", "## 完成标准", ), "docs/08-existing-project-adoption.md": ( @@ -107,6 +153,9 @@ CORE_DOCUMENT_REQUIREMENTS = { "### 可以考虑拆仓", "### 保持单仓库时的最小规则", "## 增量接入顺序", + "## 后续升级", + "### 升级步骤", + "### 可复制升级指令", "## 冲突处理和停止条件", "## 可复制 Agent 指令", "### 只分析", @@ -114,6 +163,19 @@ CORE_DOCUMENT_REQUIREMENTS = { "## 最小验收清单", "## 回退原则", ), + "docs/09-product-requirements.md": ( + "## 本页用途", + "## 事实来源边界", + "## 当前需求索引", + "## 登记规则", + "## 原型与设计资产", + "### 原型门禁", + "### 线上原型与按需 HTML 快照", + "### 原型确认记录", + "## 状态规则", + "## 更新时机", + "## 最小验收清单", + ), "docs/delivery/README.md": ( "## 什么时候需要交付文档", "## 受众与文档选择", @@ -140,12 +202,12 @@ REQUIRED_FILES = ( "README.md", "docs/00-project-profile.md", "docs/01-workflow.md", + "docs/templates/deployment.md", "docs/templates/task-archive.md", *CORE_DOCUMENT_REQUIREMENTS, "wiki-docs.json", - "goadmin-baseline.json", "dev_scripts/wiki_docs.py", - "dev_scripts/sync_wiki_docs.py", + "dev_scripts/harness.py", ".gitea/issue_template/epic.md", ".gitea/issue_template/mvp.md", ".gitea/issue_template/task.md", @@ -181,6 +243,15 @@ def check_archives(errors: list[str]) -> None: if not re.match(r"^\d+-.+\.md$", path.name): errors.append(f"归档文件名不符合 <编号>-<标题>.md:{path.name}") content = path.read_text(encoding="utf-8") + try: + metadata, _ = parse_mirror(content) + except WikiDocsError as exc: + errors.append(f"{path.name} 的任务镜像无效:{exc}") + continue + if re.fullmatch(r"Task-\d+-.+", metadata.get("wiki_page", "")) is None: + errors.append(f"{path.name} 的 wiki_page 不是任务归档页面") + if re.fullmatch(r"[0-9a-f]{40,64}", metadata.get("wiki_revision", "")) is None: + errors.append(f"{path.name} 的 wiki_revision 无效") for heading in ARCHIVE_HEADINGS: if heading not in content: errors.append(f"{path.name} 缺少章节:{heading}") @@ -213,9 +284,6 @@ def check_task_template(errors: list[str], root: Path = ROOT) -> None: return content = path.read_text(encoding="utf-8") required = ( - "- 任务类型:单项目 / 协同", - "- 主项目:Sense / Brain / Bell / contracts / 根级", - "- 主 agent:", "## 依赖与并行", "- 前置工单:无 / #编号", "- 是否允许与前置工单并行:是 / 否", @@ -224,21 +292,23 @@ def check_task_template(errors: list[str], root: Path = ROOT) -> None: "- 仅影响的子项目 / 交付单元:", "- 是否跨子项目:是 / 否", "- 是否修改共享接口或契约:是 / 否;唯一事实来源:", - "- write_paths:", "- 各子项目需要执行的验证:", - "## 协同接口", - "- 生产者:", - "- 消费者:", - "- 契约/共享事实源:", - "- 兼容策略:不适用 / 向后兼容 / 发布新版本", - "- 被阻塞或需要适配的工单:", - "- 集成顺序:", "## 原始需求", "- 来源:用户对话 / Gitea / 其他", "- 提出时间:", "- 关键原话或脱敏摘要:", "## 需求变化记录", "| 日期 | 变化内容 | 原因 | 用户确认 |", + "## 设计与原型门禁", + "- 修改类型:纯显示文案 / 小范围 UI / 新组件 / 新页面或独立用户功能 / 重大交互或导航 / 非 UI / 恢复既有行为的 Bug", + "- 所需设计证据:无 / 标注截图 / 低保真图 / 已确认原型 / 架构、API、数据、状态或流程设计 / 原设计或复现证据", + "- 可编辑设计源、线上原型链接和访问检查:", + "- 审核版本、revision、复制版本或确认日期及识别方式:", + "- 本地 HTML 导出:未要求 / 用户明确要求 / 项目规则要求", + "- 本地 HTML 路径、版本和资源检查(仅显式导出时填写):", + "- 状态:无 / 草稿 / 已确认 / 已废弃", + "- 确认人、确认时间和覆盖范围:", + "- 无需 UI 原型或无需任何原型的原因:", "## 文档影响", "- [ ] 不影响长期文档,原因:", "- [ ] 更新架构与代码地图", @@ -249,6 +319,11 @@ def check_task_template(errors: list[str], root: Path = ROOT) -> None: "- [ ] 更新已有交付文档,受众与页面:", "- [ ] 新增交付文档,受众与页面:", "- [ ] 需要目标岗位或客户代表验证:是 / 否;验证方式:", + "## 任务记录与可选快照", + "- 单次任务事实来源:当前 Gitea 工单正文与评论", + "- [ ] 默认不创建任务快照", + "- [ ] 用户明确要求专项快照;用途和范围:", + "- [ ] 项目专用规则要求任务快照;规则入口:", ) for section in missing_sections(content, required): errors.append(f"单元任务模板缺少:{section}") @@ -268,12 +343,30 @@ def check_agent_efficiency_rules(errors: list[str], root: Path = ROOT) -> None: "单元任务是唯一正式实施单位", "高风险修改必须停止", "用户没有明确验收通过前不得关闭", - "长期文档必须先修改 Wiki", + "只有长期事实变化时才修改 Wiki", + "Gitea 工单是单次任务需求、变化、实现、测试、提交和验收的事实来源", + "默认不创建任务归档", + "### Gitea 交互与工单最小读取", + "查询、创建、更新、评论、状态变更及关闭操作", + "优先关注当前状态、最新评论和首个未完成步骤", + "连接器不支持评论分页或增量读取时允许读取完整工单", + "不得为规避完整读取而新增本地工单、缓存或第二事实来源", + "### 新项目 Wiki 初始化门禁", + "`Home` 不存在时必须先创建 `Home`", + "不得把模板自带的本地 `docs/` 当作新项目 Wiki 已初始化的证据", "提交只包含当前工单相关文件", + "不得仅为设置编码重复启动一层 PowerShell", + "文件解码和控制台输出分别处理", + "不得默认使用 `-ExecutionPolicy Bypass`", + "### 工单与设计证据双门禁", + "新页面、独立用户功能、重大交互或导航变化", + "`prototypes/<工单号>/<版本>/index.html`", + "默认直接通过 Quant-UX 或其他设计工具的线上链接审核", + "已确认的本地快照不得原位覆盖", + "`导出原型 #N`", + "`导出全部原型`", + "代码组件名、类名、变量、国际化键、API 字段和数据库字段不是显示文案", "### 自然语言快捷指令", - "### 三项目并行建单顺序", - "建单顺序不等于实施顺序", - "主 agent / dispatcher", "`只分析`", "`建工单`", "`执行工单 #N`", @@ -281,6 +374,10 @@ def check_agent_efficiency_rules(errors: list[str], root: Path = ROOT) -> None: "`继续工单 #N`", "`检查工单 #N`", "`同步文档`", + "`导出原型 #N`", + "`导出全部原型`", + "`导出任务归档`", + "`导出全部任务归档`", "`#N 验收通过`", "### 需求记录与流转", "不得臆造用户原话", @@ -291,6 +388,31 @@ def check_agent_efficiency_rules(errors: list[str], root: Path = ROOT) -> None: errors.append(f"AGENTS.md 缺少:{section}") +def check_repository_readme(errors: list[str], root: Path = ROOT) -> None: + """检查快速开始包含线上 Wiki 初始化顺序和产品编码门禁。""" + + path = root / "README.md" + if not path.is_file(): + return + content = path.read_text(encoding="utf-8") + required = ( + "创建 Gitea 远端仓库并推送当前引导提交,启用工单和 Wiki", + "优先使用已配置的 Gitea MCP", + "`Home` 不存在时先创建并回读 `Home`", + "本地 `docs/` 的存在不能证明线上 Wiki 已初始化", + "python dev_scripts/harness.py sync --verify", + "Gitea 工单是单次任务唯一事实来源", + "默认不创建任务归档", + ) + for section in missing_sections(content, required): + errors.append(f"README.md 缺少:{section}") + + remote_index = content.find("创建 Gitea 远端仓库") + wiki_index = content.find("`Home` 不存在时先创建") + if remote_index < 0 or wiki_index < 0 or remote_index > wiki_index: + errors.append("README.md 必须先创建 Gitea 远端,再创建 Wiki Home") + + def check_go_admin_ui_rules(errors: list[str], root: Path = ROOT) -> None: """检查 Sense、Bell 共用的框架精简和组件复用规则。""" @@ -455,6 +577,7 @@ def check_goadmin_baseline(errors: list[str], root: Path = ROOT) -> None: errors.append(f"{relative_path} 缺少 GoAdmin 基线规则:{section}") + def check_claude_code_entry(errors: list[str], root: Path = ROOT) -> None: """检查 Claude Code 入口直接复用共同 Agent 规则。""" @@ -471,14 +594,11 @@ def check_claude_code_entry(errors: list[str], root: Path = ROOT) -> None: "只修改 `AGENTS.md`", "## 模型路由", "## Agent 交接", - "## 三项目角色路由", "## Haiku 只读约束", "当前模型足以完成任务时不升级模型", "Opus 输出方案后必须等待用户确认", "不让 Haiku 决定最终根因", "只读必须通过子 Agent 工具权限实现", - "主 Claude 作为 dispatcher", - "coordination agent", ) for section in missing_sections(content, required): errors.append(f"CLAUDE.md 缺少:{section}") @@ -495,7 +615,7 @@ def core_mapping_errors(configured_mappings: dict[str, str]) -> list[str]: def check_wiki_mirrors(errors: list[str]) -> None: - """检查每份本地文档都有显式映射和可追踪的镜像头。""" + """检查核心映射与镜像头;任务快照由 check_archives 单独检查。""" try: config = load_config() @@ -509,6 +629,7 @@ def check_wiki_mirrors(errors: list[str]) -> None: mapped_paths = {mapping.path for mapping in config.mappings} actual_paths = { path.relative_to(ROOT).as_posix() for path in (ROOT / "docs").rglob("*.md") + if path.parent != ROOT / "docs" / "task" } for path in sorted(actual_paths - mapped_paths): errors.append(f"docs 中存在未登记的 Wiki 镜像:{path}") @@ -534,15 +655,121 @@ def check_wiki_mirrors(errors: list[str]) -> None: 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() +# ---------------------------------------------------------------- 任务归档 +def safe_title(title: str) -> str: + """把标题转换为适合 Wiki 页面名和 Windows 文件名的短文本。""" + + cleaned = re.sub(r'[<>:"/\\|?*]', "-", title.strip()) + cleaned = re.sub(r"\s+", "-", cleaned) + cleaned = re.sub(r"-+", "-", cleaned) + return cleaned.strip(".-") + + +def build_archive( + template: str, + issue_number: str, + title: str, + page_name: str, + issue_url: str, +) -> str: + content = template.replace("<工单号>", issue_number, 1) + content = content.replace("<标题>", title.strip(), 1) + content = content.replace("YYYY-MM-DD", date.today().isoformat(), 1) + content = content.replace("<链接>", issue_url, 1) + return content.replace("<页面名>", page_name, 1) + + +# ---------------------------------------------------------------- 归档导出 + +TASK_PAGE_PATTERN = re.compile(r"^Task-(?P\d+)-(?P.+)$") + + +def task_revision(metadata: dict[str, Any], page_name: str) -> str: + last_commit = metadata.get("last_commit") + revision = last_commit.get("sha") if isinstance(last_commit, dict) else None + if not isinstance(revision, str) or not revision: + raise WikiDocsError(f"Wiki 页面缺少 revision:{page_name}") + return revision + + +def existing_task_mirrors(root: Path = ROOT) -> dict[str, Path]: + """按镜像头匹配已有文件,兼容历史自定义文件名。""" + + mirrors: dict[str, Path] = {} + task_dir = root / "docs" / "task" + if not task_dir.is_dir(): + return mirrors + for path in task_dir.glob("*.md"): + try: + metadata, _ = parse_mirror(path.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError, WikiDocsError) as exc: + raise WikiDocsError(f"已有任务镜像无效 {path.name}:{exc}") from exc + page_name = metadata.get("wiki_page", "") + if not TASK_PAGE_PATTERN.fullmatch(page_name): + raise WikiDocsError(f"已有任务镜像页面名无效 {path.name}:{page_name}") + if page_name in mirrors: + raise WikiDocsError(f"任务页面存在重复本地镜像:{page_name}") + mirrors[page_name] = path + return mirrors + + +def task_target(page_name: str, root: Path = ROOT) -> Path: + match = TASK_PAGE_PATTERN.fullmatch(page_name) + if match is None: + raise WikiDocsError(f"不是任务归档页面:{page_name}") + title = safe_title(match.group("title")) + if not title: + raise WikiDocsError(f"任务归档标题无效:{page_name}") + return root / "docs" / "task" / f"{match.group('number')}-{title}.md" + + +def export_task_archives( + client: WikiClient, *, export_all: bool = False, root: Path = ROOT +) -> list[str]: + """增量或全量读取任务归档;绝不删除本地文件。""" + + dirty = dirty_paths(["docs/task"], root) + if dirty: + raise WikiDocsError( + "本地任务镜像存在未提交改动,已停止以防覆盖:\n" + "\n".join(dirty) + ) + + existing = existing_task_mirrors(root) + pages = [] + for metadata in client.list_pages(): + title = metadata.get("title") + if isinstance(title, str) and TASK_PAGE_PATTERN.fullmatch(title): + pages.append((int(title.split("-", 2)[1]), title, metadata)) + pages.sort(key=lambda item: (item[0], item[1])) + + messages: list[str] = [] + targets: set[Path] = set() + for _, page_name, metadata in pages: + target = existing.get(page_name, task_target(page_name, root)) + if target in targets: + raise WikiDocsError(f"多个任务页面映射到同一本地路径:{target.name}") + targets.add(target) + revision = task_revision(metadata, page_name) + if not export_all and target.is_file(): + local_metadata, _ = parse_mirror(target.read_text(encoding="utf-8")) + if ( + local_metadata.get("wiki_page") == page_name + and local_metadata.get("wiki_revision") == revision + ): + messages.append(f"跳过:{target.relative_to(root)} <- {page_name}@{revision[:12]}") + continue + page = client.get_page_from_metadata(metadata, page_name) + changed = write_mirror(target, page) + action = "已导出" if changed else "无变化" + messages.append(f"{action}:{target.relative_to(root)} <- {page_name}@{revision[:12]}") + return messages + + +# ---------------------------------------------------------------- 子命令入口 + + +def run_check(args: argparse.Namespace) -> int: errors: list[str] = [] warnings: list[str] = [] check_required_files(errors) @@ -551,6 +778,7 @@ def main() -> int: check_core_documents(errors) check_task_template(errors) check_agent_efficiency_rules(errors) + check_repository_readme(errors) check_go_admin_ui_rules(errors) check_goadmin_baseline(errors) check_claude_code_entry(errors) @@ -568,5 +796,132 @@ def main() -> int: return 0 +def run_sync(args: argparse.Namespace) -> int: + """--verify 依次执行导出、结构检查和一致性校验,替代原来的三条命令。""" + + if args.verify: + steps = ( + ("同步", lambda: run_sync( + argparse.Namespace(check=False, verify=False, config=args.config))), + ("结构检查", lambda: run_check(argparse.Namespace(strict=True))), + ("一致性校验", lambda: run_sync( + argparse.Namespace(check=True, verify=False, config=args.config))), + ) + for name, step in steps: + code = step() + if code != 0: + print(f"错误:{name}未通过,已停止") + return code + return 0 + + try: + config = load_config(Path(args.config).resolve()) + messages = sync_all(config, WikiClient(config), check=args.check) + except WikiDocsError as exc: + print(f"错误:{exc}") + return 1 + for message in messages: + print(message) + print("Wiki 镜像检查通过" if args.check else "Wiki 镜像同步完成") + return 0 + + +def run_archive(args: argparse.Namespace) -> int: + short_title = safe_title(args.title) + if not args.issue_number.isdigit(): + print("错误:工单号必须是数字") + return 1 + if not short_title: + print("错误:标题不能为空") + return 1 + + try: + config = load_config(Path(args.config).resolve()) + page_name = f"Task-{args.issue_number}-{short_title}" + client = WikiClient(config) + if any(item.get("title") == page_name for item in client.list_pages()): + raise WikiDocsError(f"任务归档已经存在:{page_name}") + template = client.get_page("Task-Archive-Template").text + issue_url = ( + f"{config.gitea_url}/{config.owner}/{config.repository}/issues/" + f"{args.issue_number}" + ) + content = build_archive( + template, args.issue_number, args.title, page_name, issue_url + ) + page = client.create_page( + page_name, + content, + f"docs: 创建任务 #{args.issue_number} 归档草稿", + ) + except WikiDocsError as exc: + print(f"错误:{exc}") + return 1 + + print(f"已创建 Wiki:{page.html_url}") + print("未导出本地任务归档;需要时运行 harness.py export") + return 0 + + +def run_export(args: argparse.Namespace) -> int: + try: + config = load_config(Path(args.config).resolve()) + messages = export_task_archives(WikiClient(config), export_all=args.all) + except WikiDocsError as exc: + print(f"错误:{exc}") + return 1 + for message in messages: + print(message) + print("任务归档全量导出完成" if args.all else "任务归档增量导出完成") + return 0 + + +def main() -> int: + parser = argparse.ArgumentParser(description="DevHarness 检查、同步与归档工具") + sub = parser.add_subparsers(dest="command", required=True) + + p_check = sub.add_parser("check", help="检查 DevHarness 项目结构") + p_check.add_argument( + "--strict", action="store_true", help="项目档案有占位内容时返回失败" + ) + p_check.set_defaults(func=run_check) + + p_sync = sub.add_parser("sync", help="从 Gitea Wiki 单向同步核心 docs 镜像") + p_sync.add_argument( + "--check", action="store_true", help="只检查 Wiki 与镜像是否一致,不写文件" + ) + p_sync.add_argument( + "--verify", + action="store_true", + help="依次执行导出、check --strict 和一致性校验", + ) + p_sync.add_argument( + "--config", default=str(DEFAULT_CONFIG), help="Wiki 页面映射 JSON 文件" + ) + p_sync.set_defaults(func=run_sync) + + p_archive = sub.add_parser("archive", help="显式在 Gitea Wiki 创建可选任务快照") + p_archive.add_argument("issue_number", help="Gitea 工单号,例如 123") + p_archive.add_argument("title", help="简短任务标题") + p_archive.add_argument( + "--config", default=str(DEFAULT_CONFIG), help="Wiki 映射配置" + ) + p_archive.set_defaults(func=run_archive) + + p_export = sub.add_parser("export", help="人工按需导出 Gitea Wiki 任务归档") + p_export.add_argument( + "--all", + action="store_true", + help="全量读取全部线上任务归档;默认按 revision 增量", + ) + p_export.add_argument( + "--config", default=str(DEFAULT_CONFIG), help="核心 Wiki 映射配置" + ) + p_export.set_defaults(func=run_export) + + args = parser.parse_args() + return args.func(args) + + if __name__ == "__main__": raise SystemExit(main()) diff --git a/dev_scripts/new_task_archive.py b/dev_scripts/new_task_archive.py deleted file mode 100644 index a1a01ad..0000000 --- a/dev_scripts/new_task_archive.py +++ /dev/null @@ -1,101 +0,0 @@ -"""先在 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 deleted file mode 100644 index e44b9d6..0000000 --- a/dev_scripts/sync_wiki_docs.py +++ /dev/null @@ -1,33 +0,0 @@ -"""从 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 index 5f111fd..6b1008c 100644 --- a/dev_scripts/wiki_docs.py +++ b/dev_scripts/wiki_docs.py @@ -210,6 +210,14 @@ class WikiClient: raise WikiDocsError( f"Wiki 页面不存在:{page_name};不会自动删除或重命名本地镜像" ) + return self.get_page_from_metadata(metadata, page_name) + + def get_page_from_metadata( + self, metadata: dict[str, Any], page_name: str | None = None + ) -> WikiPage: + """使用页面列表元数据读取正文,避免重复获取完整页面列表。""" + + resolved_name = page_name or _required_string(metadata, "title") sub_url = _required_string(metadata, "sub_url") page = self._request( "GET", @@ -218,20 +226,22 @@ class WikiClient: f"{quote(sub_url, safe='%')}", ) if not isinstance(page, dict): - raise WikiDocsError(f"Wiki 页面响应格式无效:{page_name}") + raise WikiDocsError(f"Wiki 页面响应格式无效:{resolved_name}") encoded_content = page.get("content_base64") if not isinstance(encoded_content, str): - raise WikiDocsError(f"Wiki 页面没有 content_base64:{page_name}") + raise WikiDocsError(f"Wiki 页面没有 content_base64:{resolved_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 + raise WikiDocsError( + f"Wiki 页面不是有效的 UTF-8 Markdown:{resolved_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}") + raise WikiDocsError(f"Wiki 页面缺少 revision:{resolved_name}") title = page.get("title") - resolved_title = title if isinstance(title, str) and title else page_name + resolved_title = title if isinstance(title, str) and title else resolved_name html_url = ( f"{self.config.gitea_url}/{quote(self.config.owner, safe='')}/" f"{quote(self.config.repository, safe='')}/wiki/{quote(sub_url, safe='%')}" @@ -303,7 +313,14 @@ def render_mirror(page: WikiPage, existing: str | None = None) -> str: def dirty_mirror_paths(config: Config, root: Path = ROOT) -> list[str]: - paths = [mapping.path for mapping in config.mappings] + return dirty_paths([mapping.path for mapping in config.mappings], root) + + +def dirty_paths(paths: list[str], root: Path = ROOT) -> list[str]: + """返回指定路径中已有、修改或未跟踪的工作区条目。""" + + if not paths: + return [] result = subprocess.run( ["git", "status", "--porcelain", "--", *paths], cwd=root, @@ -329,6 +346,17 @@ def _write_atomic(path: Path, content: str) -> None: raise +def write_mirror(path: Path, page: WikiPage) -> bool: + """写入一份 Wiki 镜像;内容无变化时返回 False。""" + + existing = path.read_text(encoding="utf-8") if path.is_file() else None + rendered = render_mirror(page, existing) + if existing == rendered: + return False + _write_atomic(path, rendered) + return True + + def check_mirror(mapping: Mapping, page: WikiPage, path: Path) -> list[str]: if not path.is_file(): return [f"缺少镜像:{mapping.path}"] diff --git a/tests/test_harness_docs.py b/tests/test_harness_docs.py index 2c0b360..aa760f3 100644 --- a/tests/test_harness_docs.py +++ b/tests/test_harness_docs.py @@ -3,21 +3,25 @@ from __future__ import annotations import sys import tempfile import unittest +import unittest.mock from pathlib import Path ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT / "dev_scripts")) -from check_harness import ( # noqa: E402 +import harness # noqa: E402 +from harness import ( # noqa: E402 CORE_DOCUMENT_REQUIREMENTS, CORE_PAGE_PATHS, REQUIRED_FILES, check_claude_code_entry, + check_required_files, check_core_documents, check_agent_efficiency_rules, check_go_admin_ui_rules, check_goadmin_baseline, + check_repository_readme, check_task_template, core_mapping_errors, missing_sections, @@ -45,6 +49,12 @@ class CoreDocumentTests(unittest.TestCase): for page, path in CORE_PAGE_PATHS.items(): self.assertEqual(mappings.get(page), path) + def test_task_archives_are_not_core_mappings(self) -> None: + config = load_config() + self.assertFalse( + any(mapping.path.startswith("docs/task/") for mapping in config.mappings) + ) + def test_existing_project_adoption_guide_is_core_document(self) -> None: path = "docs/08-existing-project-adoption.md" self.assertEqual( @@ -54,6 +64,102 @@ class CoreDocumentTests(unittest.TestCase): self.assertIn(path, CORE_DOCUMENT_REQUIREMENTS) self.assertIn("## 可复制 Agent 指令", CORE_DOCUMENT_REQUIREMENTS[path]) + def test_product_requirements_overview_is_core_document(self) -> None: + path = "docs/09-product-requirements.md" + self.assertEqual( + CORE_PAGE_PATHS.get("Product-Requirements"), + path, + ) + required = CORE_DOCUMENT_REQUIREMENTS[path] + self.assertIn("## 当前需求索引", required) + self.assertIn("## 原型与设计资产", required) + self.assertIn("### 原型门禁", required) + self.assertIn("### 线上原型与按需 HTML 快照", required) + self.assertIn("### 原型确认记录", required) + self.assertIn("## 更新时机", required) + + def test_workflow_requires_ticket_and_design_evidence_gates(self) -> None: + required = CORE_DOCUMENT_REQUIREMENTS["docs/01-workflow.md"] + self.assertIn("## 工单与设计证据双门禁", required) + self.assertIn("### 先判断是否需要工单", required) + self.assertIn("### 再判断设计证据", required) + self.assertIn("### 线上原型审核与按需导出", required) + self.assertIn("### 记录和重新确认", required) + + def test_workflow_requires_online_wiki_initialization_gate(self) -> None: + workflow = CORE_DOCUMENT_REQUIREMENTS["docs/01-workflow.md"] + setup = CORE_DOCUMENT_REQUIREMENTS[ + "docs/07-new-project-documentation-setup.md" + ] + self.assertIn("## 新项目 Wiki 初始化门禁", workflow) + self.assertIn("#### 在线创建与回读门禁", setup) + + def test_workflow_requires_issue_only_task_record(self) -> None: + workflow = CORE_DOCUMENT_REQUIREMENTS["docs/01-workflow.md"] + setup = CORE_DOCUMENT_REQUIREMENTS[ + "docs/07-new-project-documentation-setup.md" + ] + self.assertIn("## 稳定文档与可选历史快照", workflow) + self.assertIn("#### 工程基线裁剪", setup) + + def test_workflow_requires_gitea_mcp_and_minimal_issue_reading(self) -> None: + workflow = CORE_DOCUMENT_REQUIREMENTS["docs/01-workflow.md"] + self.assertIn("## Gitea 交互与工单最小读取", workflow) + + errors: list[str] = [] + check_agent_efficiency_rules(errors) + self.assertEqual(errors, []) + + def test_existing_project_adoption_requires_upgrade_process(self) -> None: + required = CORE_DOCUMENT_REQUIREMENTS[ + "docs/08-existing-project-adoption.md" + ] + self.assertIn("## 后续升级", required) + self.assertIn("### 升级步骤", required) + self.assertIn("### 可复制升级指令", required) + + def test_project_profile_requires_dev_harness_baseline(self) -> None: + required = CORE_DOCUMENT_REQUIREMENTS["docs/00-project-profile.md"] + self.assertIn("## DevHarness 来源与基线", required) + + def test_new_project_setup_requires_baseline_selection(self) -> None: + required = CORE_DOCUMENT_REQUIREMENTS[ + "docs/07-new-project-documentation-setup.md" + ] + self.assertIn("### 2. 选择建设基线", required) + self.assertIn("#### 判断案例", required) + self.assertIn("### 3. 识别子项目与交付单元", required) + self.assertIn("#### 需求总览启用条件", required) + + def test_local_development_requires_powershell_utf8_boundaries(self) -> None: + required = CORE_DOCUMENT_REQUIREMENTS[ + "docs/04-local-development-and-verification.md" + ] + self.assertIn("## Windows PowerShell 与 UTF-8", required) + + errors: list[str] = [] + check_agent_efficiency_rules(errors) + self.assertEqual(errors, []) + + def test_deployment_template_is_required_and_mapped(self) -> None: + path = "docs/templates/deployment.md" + self.assertIn(path, REQUIRED_FILES) + config = load_config() + mappings = {mapping.page: mapping.path for mapping in config.mappings} + self.assertEqual(mappings.get("Deployment-Template"), path) + # 部署页按项目需要创建,不强制每个项目产出实例页。 + self.assertNotIn(path, CORE_DOCUMENT_REQUIREMENTS) + + def test_missing_deployment_template_is_reported(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + with unittest.mock.patch.object(harness, "ROOT", root): + errors: list[str] = [] + check_required_files(errors) + self.assertTrue( + any("docs/templates/deployment.md" in error for error in errors) + ) + 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)) @@ -99,6 +205,42 @@ class TaskTemplateTests(unittest.TestCase): errors, ) + def test_task_template_requires_design_and_prototype_gate(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( + "单元任务模板缺少:- 本地 HTML 导出:未要求 / 用户明确要求 / 项目规则要求", + errors, + ) + self.assertIn( + "单元任务模板缺少:- 本地 HTML 路径、版本和资源检查(仅显式导出时填写):", + errors, + ) + + def test_task_template_requires_optional_snapshot_policy(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, + ) + def test_task_template_requires_subproject_impact(self) -> None: with tempfile.TemporaryDirectory() as directory: root = Path(directory) @@ -117,23 +259,6 @@ class TaskTemplateTests(unittest.TestCase): errors, ) - def test_task_template_requires_coordination_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", encoding="utf-8") - errors: list[str] = [] - check_task_template(errors, root) - self.assertIn( - "单元任务模板缺少:- 任务类型:单项目 / 协同", - errors, - ) - self.assertIn("单元任务模板缺少:- 主 agent:", errors) - self.assertIn("单元任务模板缺少:## 协同接口", errors) - self.assertIn("单元任务模板缺少:- 生产者:", errors) - self.assertIn("单元任务模板缺少:- 消费者:", errors) - self.assertIn("单元任务模板缺少:- write_paths:", errors) def test_task_template_requires_dependency_fields(self) -> None: with tempfile.TemporaryDirectory() as directory: @@ -176,73 +301,44 @@ class TaskTemplateTests(unittest.TestCase): 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_go_admin_ui_rules_are_present(self) -> None: +class YoVisionBaselineTests(unittest.TestCase): + def test_go_admin_ui_rules_are_preserved(self) -> None: errors: list[str] = [] check_go_admin_ui_rules(errors) self.assertEqual(errors, []) - def test_go_admin_ui_rules_require_each_scope(self) -> None: - with tempfile.TemporaryDirectory() as directory: - root = Path(directory) - (root / "Sense").mkdir() - (root / "Bell").mkdir() - (root / "AGENTS.md").write_text("", encoding="utf-8") - (root / "Sense" / "AGENTS.md").write_text("", encoding="utf-8") - (root / "Bell" / "AGENTS.md").write_text("", encoding="utf-8") - errors: list[str] = [] - check_go_admin_ui_rules(errors, root) - self.assertTrue(any(error.startswith("AGENTS.md ") for error in errors)) - self.assertTrue(any(error.startswith("Sense/AGENTS.md ") for error in errors)) - self.assertTrue(any(error.startswith("Bell/AGENTS.md ") for error in errors)) - - def test_go_admin_ui_rules_report_missing_scope_file(self) -> None: - with tempfile.TemporaryDirectory() as directory: - errors: list[str] = [] - check_go_admin_ui_rules(errors, Path(directory)) - self.assertIn("缺少 go-admin 规则文件:AGENTS.md", errors) - self.assertIn("缺少 go-admin 规则文件:Sense/AGENTS.md", errors) - self.assertIn("缺少 go-admin 规则文件:Bell/AGENTS.md", errors) - def test_goadmin_baseline_is_frozen(self) -> None: errors: list[str] = [] check_goadmin_baseline(errors) self.assertEqual(errors, []) - def test_goadmin_baseline_rejects_unpinned_source(self) -> None: + +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_repository_readme_requires_online_wiki_gate(self) -> None: + errors: list[str] = [] + check_repository_readme(errors) + self.assertEqual(errors, []) + + def test_repository_readme_rejects_missing_gate(self) -> None: with tempfile.TemporaryDirectory() as directory: root = Path(directory) - (root / "goadmin-baseline.json").write_text( - '{"authority":"tag","runtimes":{},' - '"sources":{"go-admin":{}},"rules":{}}', + (root / "README.md").write_text( + "# 项目\n创建 Gitea 远端仓库\n", encoding="utf-8", ) errors: list[str] = [] - check_goadmin_baseline(errors, root) - self.assertTrue(any("go-admin.commit" in error for error in errors)) - self.assertIn( - "GoAdmin 技术基线必须以上游 URL 和 commit 为权威标识", - errors, - ) + check_repository_readme(errors, root) + self.assertTrue(any("README.md 缺少" in error for error in errors)) - def test_goadmin_baseline_reports_missing_file(self) -> None: - with tempfile.TemporaryDirectory() as directory: - errors: list[str] = [] - check_goadmin_baseline(errors, Path(directory)) - self.assertEqual( - errors, - ["缺少 GoAdmin 技术基线:goadmin-baseline.json"], - ) + 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: diff --git a/tests/test_wiki_docs.py b/tests/test_wiki_docs.py index 4d8c19a..bc6b58e 100644 --- a/tests/test_wiki_docs.py +++ b/tests/test_wiki_docs.py @@ -12,7 +12,14 @@ 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 harness import ( # noqa: E402 + build_archive, + existing_task_mirrors, + export_task_archives, + main as harness_main, + safe_title, + task_target, +) from wiki_docs import ( # noqa: E402 Config, Mapping, @@ -158,6 +165,154 @@ class ArchiveTests(unittest.TestCase): self.assertIn("Task-12-login", result) self.assertNotIn("YYYY-MM-DD", result) + @patch("harness.WikiClient") + def test_create_archive_does_not_change_core_mapping(self, client_class) -> None: + with tempfile.TemporaryDirectory() as directory: + config_path = Path(directory) / "wiki-docs.json" + original = json.dumps( + { + "schema_version": 1, + "gitea_url": "http://gitea.example", + "owner": "o", + "repository": "r", + "mappings": [ + {"page": "Home", "path": "docs/README.md"} + ], + } + ) + config_path.write_text(original, encoding="utf-8") + client = client_class.return_value + client.list_pages.return_value = [] + client.get_page.return_value = WikiPage( + title="Task-Archive-Template", + sub_url="Task-Archive-Template.-", + text="# <工单号> <标题>\nYYYY-MM-DD\n<链接>\n<页面名>\n", + revision="a" * 40, + html_url="http://gitea.example/wiki/template", + ) + client.create_page.return_value = WikiPage( + title="Task-14-按需导出", + sub_url="Task-14.-", + text="# 14 按需导出\n", + revision="b" * 40, + html_url="http://gitea.example/wiki/task-14", + ) + with patch.object( + sys, + "argv", + [ + "harness.py", + "archive", + "14", + "按需导出", + "--config", + str(config_path), + ], + ): + result = harness_main() + self.assertEqual(config_path.read_text(encoding="utf-8"), original) + self.assertEqual(result, 0) + client.create_page.assert_called_once() + + def test_task_target_uses_stable_safe_name(self) -> None: + with tempfile.TemporaryDirectory() as directory: + target = task_target("Task-14-修复:导出", Path(directory)) + self.assertEqual(target.name, "14-修复-导出.md") + + def test_existing_mirror_keeps_historical_custom_filename(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + path = root / "docs" / "task" / "2-初级维护者文档体系.md" + path.parent.mkdir(parents=True) + page = WikiPage( + title="Task-2-Junior-Maintainer-Docs", + sub_url="Task-2-Junior-Maintainer-Docs.-", + text="# 2 文档\n", + revision="c" * 40, + html_url="http://gitea.example/wiki/task-2", + ) + path.write_text(render_mirror(page), encoding="utf-8") + mirrors = existing_task_mirrors(root) + self.assertEqual( + mirrors["Task-2-Junior-Maintainer-Docs"].name, + "2-初级维护者文档体系.md", + ) + + @patch("harness.dirty_paths", return_value=[]) + def test_incremental_export_skips_same_revision(self, _dirty) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + path = root / "docs" / "task" / "14-按需导出.md" + path.parent.mkdir(parents=True) + page = WikiPage( + title="Task-14-按需导出", + sub_url="Task-14.-", + text="# 14 按需导出\n", + revision="d" * 40, + html_url="http://gitea.example/wiki/task-14", + ) + path.write_text(render_mirror(page), encoding="utf-8") + client = Mock() + client.list_pages.return_value = [ + { + "title": page.title, + "sub_url": page.sub_url, + "last_commit": {"sha": page.revision}, + } + ] + messages = export_task_archives(client, root=root) + self.assertTrue(messages[0].startswith("跳过:")) + client.get_page_from_metadata.assert_not_called() + + @patch( + "harness.dirty_paths", + return_value=[" M docs/task/14-按需导出.md"], + ) + def test_export_stops_before_wiki_read_when_task_mirror_is_dirty( + self, _dirty + ) -> None: + client = Mock() + with self.assertRaisesRegex(WikiDocsError, "未提交改动"): + export_task_archives(client) + client.list_pages.assert_not_called() + + @patch("harness.dirty_paths", return_value=[]) + def test_full_export_reads_all_and_never_deletes_extra_file(self, _dirty) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + task_dir = root / "docs" / "task" + task_dir.mkdir(parents=True) + extra = task_dir / "99-历史快照.md" + extra_page = WikiPage( + title="Task-99-历史快照", + sub_url="Task-99.-", + text="# 99 历史快照\n", + revision="e" * 40, + html_url="http://gitea.example/wiki/task-99", + ) + extra.write_text(render_mirror(extra_page), encoding="utf-8") + page = WikiPage( + title="Task-14-按需导出", + sub_url="Task-14.-", + text="# 14 按需导出\n", + revision="f" * 40, + html_url="http://gitea.example/wiki/task-14", + ) + client = Mock() + metadata = { + "title": page.title, + "sub_url": page.sub_url, + "last_commit": {"sha": page.revision}, + } + client.list_pages.return_value = [metadata] + client.get_page_from_metadata.return_value = page + messages = export_task_archives(client, export_all=True, root=root) + exported = root / "docs" / "task" / "14-按需导出.md" + self.assertTrue(exported.is_file()) + self.assertTrue(extra.is_file()) + self.assertTrue(messages[0].startswith("已导出:")) + client.get_page_from_metadata.assert_called_once_with(metadata, page.title) + if __name__ == "__main__": unittest.main() diff --git a/wiki-docs.json b/wiki-docs.json index ea0f9da..63745d1 100644 --- a/wiki-docs.json +++ b/wiki-docs.json @@ -68,101 +68,17 @@ "page": "Audience-Document-Template", "path": "docs/delivery/audience-document-template.md" }, + { + "page": "Deployment-and-Operations", + "path": "docs/delivery/deployment-and-operations.md" + }, + { + "page": "Deployment-Template", + "path": "docs/templates/deployment.md" + }, { "page": "Task-Archive-Template", "path": "docs/templates/task-archive.md" - }, - { - "page": "Task-1-三项目并行建单与协同工单顺序", - "path": "docs/task/1-三项目并行建单与协同工单顺序.md" - }, - { - "page": "Task-3-GoAdmin默认模块精简与UI组件复用", - "path": "docs/task/3-GoAdmin默认模块精简与UI组件复用.md" - }, - { - "page": "Task-5-冻结Sense与Bell-GoAdmin技术基线", - "path": "docs/task/5-冻结Sense与Bell-GoAdmin技术基线.md" - }, - { - "page": "Task-58-建立explore-main-dev分支治理并重置GoAdmin开发基线", - "path": "docs/task/58-建立explore-main-dev分支治理并重置GoAdmin开发基线.md" - }, - { - "page": "Task-28-三项目当前MVP交互原型", - "path": "docs/task/28-三项目当前MVP交互原型.md" - }, - { - "page": "Task-30-Sense原型对齐GoAdmin与Element-Plus", - "path": "docs/task/30-Sense原型对齐GoAdmin与Element-Plus.md" - }, - { - "page": "Task-32-Sense网管与非技术人员原型", - "path": "docs/task/32-Sense网管与非技术人员原型.md" - }, - { - "page": "Task-61-Sense冻结GoAdmin源码产品骨架", - "path": "docs/task/61-Sense冻结GoAdmin源码产品骨架.md" - }, - { - "page": "Task-64-Sense登录RBAC与审计", - "path": "docs/task/64-Sense登录RBAC与审计.md" - }, - { - "page": "Task-65-Sense设备台账与凭据边界", - "path": "docs/task/65-Sense设备台账与凭据边界.md" - }, - { - "page": "Task-92-Sense旧设备能力JSONB兼容迁移", - "path": "docs/task/92-Sense旧设备能力JSONB兼容迁移.md" - }, - { - "page": "Task-66-Sense视频接入与Profile", - "path": "docs/task/66-Sense视频接入与Profile.md" - }, - { - "page": "Task-67-Sense视频服务生命周期与状态对账", - "path": "docs/task/67-Sense视频服务生命周期与状态对账.md" - }, - { - "page": "Task-68-Sense单路实时监看与播放状态反馈", - "path": "docs/task/68-Sense单路实时监看与播放状态反馈.md" - }, - { - "page": "Task-69-Sense多边形区域与方向警戒线配置", - "path": "docs/task/69-Sense多边形区域与方向警戒线配置.md" - }, - { - "page": "Task-90-Sense项目根目录Windows启动脚本", - "path": "docs/task/90-Sense项目根目录Windows启动脚本.md" - }, - { - "page": "Task-70-Sense-Windows配置启动与打包交付", - "path": "docs/task/70-Sense-Windows配置启动与打包交付.md" - }, - { - "page": "Task-95-Sense旧媒体路由唯一约束兼容迁移", - "path": "docs/task/95-Sense旧媒体路由唯一约束兼容迁移.md" - }, - { - "page": "Task-97-Sense免验证码登录与管理员密码重置", - "path": "docs/task/97-Sense免验证码登录与管理员密码重置.md" - }, - { - "page": "Task-101-Sense-GoAdmin-应用外壳", - "path": "docs/task/101-Sense-GoAdmin-应用外壳.md" - }, - { - "page": "Task-99-Sense登录页只读配置接口", - "path": "docs/task/99-Sense登录页只读配置接口.md" - }, - { - "page": "Task-104-Sense-30天登录有效期", - "path": "docs/task/104-Sense-30天登录有效期.md" - }, - { - "page": "Task-106-Sense-本机-Supervisor-实例", - "path": "docs/task/106-Sense-本机-Supervisor-实例.md" } ] }