feat: export task archives on demand (#14)

This commit is contained in:
QiuSW
2026-08-16 19:21:36 +08:00
parent f23c2cf81f
commit d1f55f9df3
14 changed files with 424 additions and 134 deletions
+13 -10
View File
@@ -1,6 +1,6 @@
# 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`。目录越深的规则越具体,但不得削弱上级安全规则。
@@ -37,7 +37,7 @@
6. 开始实施前检查分支和工作区,明确哪些现有改动不属于本任务。
7. 严格按工单范围实现;新发现的问题先记录,不顺手混入当前任务。
8. 执行与风险相称的测试,把关键结果和未验证部分更新到工单。实施过程中出现计划外、当前无法解除的问题时才标记“阻塞”。
9. 长期文档必须先修改 Wiki、读取确认,再运行 `python dev_scripts/sync_wiki_docs.py` 导出本地镜像;不得直接编辑 `docs/` 后反向覆盖 Wiki。
9. 长期核心文档必须先修改 Wiki、读取确认,再运行 `python dev_scripts/sync_wiki_docs.py` 导出本地镜像;任务归档默认只更新 Wiki,不自动导出本地。不得直接编辑镜像后反向覆盖 Wiki。
Gitea 不可用时,输出完整工单草稿并说明阻塞。未经用户明确授权,不得默认绕过建单。
@@ -51,10 +51,12 @@ Gitea 不可用时,输出完整工单草稿并说明阻塞。未经用户明
- `建工单并做`:依次建单和执行,`建工单,做`、`建工单,做` 含义相同;停在“待验收”。
- `继续工单 #N`:核对工单、Git 和 Wiki 证据,从首个未完成步骤继续,不重复仍然有效的检查。
- `检查工单 #N`:只读核对范围、验收、测试和证据并输出报告;不自动修复。
- `同步文档`:读取 Wiki、导出 `docs/` 并检查一致性;不修改 Wiki、不自动提交。
- `#N 验收通过`:仅在用户明确验收后,更新归档、同步并提交镜像、推送、同步父工单并关闭任务。
- `同步文档`:读取 Wiki、导出核心 `docs/` 并检查一致性,不处理任务归档;不修改 Wiki、不自动提交。
- `导出任务归档`:人工触发 `python dev_scripts/export_task_archives.py`,只导出新增或 revision 已变化的任务归档;不删除本地文件、不自动提交。
- `导出全部任务归档`:人工触发 `python dev_scripts/export_task_archives.py --all`,读取并导出全部线上任务归档;不删除本地文件、不自动提交。
- `#N 验收通过`:仅在用户明确验收后,更新 Wiki 归档、同步必要的核心文档、推送、同步父工单并关闭任务;不自动导出任务归档。
方案未确认或前置依赖未满足时,实施类指令必须停在对应门禁;除 `#N 验收通过` 外,快捷指令不得关闭待验收工单。Gitea 工单不导出全文,本地只保存 Wiki 任务归档镜像。详细语义见 [开发工作流](docs/01-workflow.md)。
方案未确认或前置依赖未满足时,实施类指令必须停在对应门禁;除 `#N 验收通过` 外,快捷指令不得关闭待验收工单。任务归档导出必须由用户明确提出,其他指令不得隐式执行。Gitea 工单不导出全文,`docs/task/` 只是可能不完整的 Wiki 任务归档快照。详细语义见 [开发工作流](docs/01-workflow.md)。
### 需求记录与流转
@@ -62,7 +64,7 @@ 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)。
@@ -126,8 +128,8 @@ Gitea 不可用时,输出完整工单草稿并说明阻塞。未经用户明
1. 逐项完成验收、测试和实现提交,并把最终方案、差异、结果、提交及遗留问题写回工单。
2. 工单保持“待验收”,用户没有明确验收通过前不得关闭。
3. 运行 `python dev_scripts/new_task_archive.py <编号> "<短标题>"`,先创建 Wiki 归档,再登记并导出本地镜像。
4. 读取确认 Wiki,运行 `python dev_scripts/sync_wiki_docs.py --check`;归档镜像单独提交,并把页面、revision、路径和提交哈希写回工单。
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。
@@ -166,8 +168,9 @@ MVP 内所有单元任务通过后才能做 MVP 集成验收;MVP 通过后才
<!-- 在项目初始化时填写不可违反的技术、安全和业务约束。复杂子项目请在其目录中增加 AGENTS.md。 -->
- 长期开发文档以 Gitea Wiki 为事实来源,`docs/` 是显式映射生成的只读镜像。
- Wiki 与镜像的固定顺序是:修改 Wiki → 读取确认 → 导出 `docs` → 校验差异 → 提交镜像。
- 长期开发文档以 Gitea Wiki 为事实来源,`docs/` 默认保存显式映射生成的核心只读镜像;`docs/task/` 是人工按需快照,可能不完整或不是最新状态。
- 核心 Wiki 与镜像的固定顺序是:修改 Wiki → 读取确认 → 导出核心 `docs` → 校验差异 → 提交镜像。
- 任务归档默认只保存在 Wiki;`导出任务归档` 或 `导出全部任务归档` 必须由用户明确提出,且不得自动传播删除或重命名。
- 同步配置只允许写入 `docs/` 下的 Markdown;发现镜像有未提交修改时必须停止。
- Gitea 凭据只通过进程环境或 MCP 安全配置提供,不得写入仓库。
- `dev_scripts/` 只存放 DevHarness 自身工具;业务项目的通用脚本必须使用独立目录,不得混放。
+7 -6
View File
@@ -14,7 +14,7 @@ DevHarness 是一个以 Gitea 工单管理任务过程、以 Gitea Wiki 管理
-> Agent 实现并测试
-> 提交代码并更新工单
-> 人工验收
-> 先归档 Wiki,再导出 docs/task 镜像
-> 归档 Wiki;任务快照仅在人工提出时导出
-> 关闭工单并更新父工单
```
@@ -44,17 +44,18 @@ docs/00-project-profile.md Wiki 项目档案的只读镜像
docs/01-workflow.md Wiki 开发工作流的只读镜像
docs/02-07*.md 代码地图、业务、验证、修改、排错和初始化镜像
docs/templates/task-archive.md Wiki 任务归档模板的只读镜像
docs/task/ Wiki 任务归档页的只读镜像
wiki-docs.json Wiki 页面到本地镜像的显式映射
docs/task/ 人工按需导出的 Wiki 任务归档快照
wiki-docs.json 核心 Wiki 页面到本地镜像的显式映射
dev_scripts/check_harness.py 模板和归档的最小自检
dev_scripts/sync_wiki_docs.py 单向导出或检查 Wiki 镜像
dev_scripts/new_task_archive.py 先创建 Wiki 任务归档,再导出镜像
dev_scripts/new_task_archive.py 只创建 Wiki 任务归档
dev_scripts/export_task_archives.py 人工增量或全量导出任务归档
```
## 设计原则
- 人决定目标、范围和验收结果,Agent 负责检查、实现和验证。
- 工单记录实施过程,Wiki 保存长期有效的最终事实,`docs/` 只保存可审查的镜像。
- 工单记录实施过程,Wiki 保存长期有效的最终事实,`docs/` 默认保存核心镜像;任务归档快照按需导出。
- 一个单元工单只解决一个可独立测试和回退的问题。
- 实现提交与归档提交分开,便于审查与追溯。
- 实现提交与必要的核心文档镜像提交分开,便于审查与追溯。
- 凭据、个人数据和生产数据不得进入代码、工单或归档。
+15 -2
View File
@@ -141,6 +141,7 @@ REQUIRED_FILES = (
"wiki-docs.json",
"dev_scripts/wiki_docs.py",
"dev_scripts/sync_wiki_docs.py",
"dev_scripts/export_task_archives.py",
".gitea/issue_template/epic.md",
".gitea/issue_template/mvp.md",
".gitea/issue_template/task.md",
@@ -176,6 +177,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}")
@@ -252,7 +262,7 @@ def check_agent_efficiency_rules(errors: list[str], root: Path = ROOT) -> None:
"单元任务是唯一正式实施单位",
"高风险修改必须停止",
"用户没有明确验收通过前不得关闭",
"长期文档必须先修改 Wiki",
"长期核心文档必须先修改 Wiki",
"提交只包含当前工单相关文件",
"### 自然语言快捷指令",
"`只分析`",
@@ -262,6 +272,8 @@ def check_agent_efficiency_rules(errors: list[str], root: Path = ROOT) -> None:
"`继续工单 #N`",
"`检查工单 #N`",
"`同步文档`",
"`导出任务归档`",
"`导出全部任务归档`",
"`#N 验收通过`",
"### 需求记录与流转",
"不得臆造用户原话",
@@ -309,7 +321,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()
@@ -323,6 +335,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}")
+131
View File
@@ -0,0 +1,131 @@
"""把 Gitea Wiki 任务归档人工按需导出到 docs/task。"""
from __future__ import annotations
import argparse
import re
from pathlib import Path
from typing import Any
from new_task_archive import safe_title
from wiki_docs import (
DEFAULT_CONFIG,
ROOT,
WikiClient,
WikiDocsError,
dirty_paths,
load_config,
parse_mirror,
write_mirror,
)
TASK_PAGE_PATTERN = re.compile(r"^Task-(?P<number>\d+)-(?P<title>.+)$")
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 main() -> int:
parser = argparse.ArgumentParser(description="人工按需导出 Gitea Wiki 任务归档")
parser.add_argument(
"--all", action="store_true", help="全量读取全部线上任务归档;默认按 revision 增量"
)
parser.add_argument(
"--config", default=str(DEFAULT_CONFIG), help="核心 Wiki 映射配置"
)
args = parser.parse_args()
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
if __name__ == "__main__":
raise SystemExit(main())
+5 -19
View File
@@ -1,4 +1,4 @@
"""先在 Gitea Wiki 创建任务归档,再登记并导出本地镜像。"""
"""只在 Gitea Wiki 创建任务归档;本地镜像由人工按需导出。"""
from __future__ import annotations
@@ -9,12 +9,9 @@ from pathlib import Path
from wiki_docs import (
DEFAULT_CONFIG,
Mapping,
WikiClient,
WikiDocsError,
append_mapping,
load_config,
sync_all,
)
@@ -43,7 +40,7 @@ def build_archive(
def main() -> int:
parser = argparse.ArgumentParser(
description="在 Gitea Wiki 创建任务归档并导出 docs/task 镜像"
description="在 Gitea Wiki 创建任务归档,不自动导出本地镜像"
)
parser.add_argument("issue_number", help="Gitea 工单号,例如 123")
parser.add_argument("title", help="简短任务标题")
@@ -61,15 +58,9 @@ def main() -> int:
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)
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/"
@@ -83,17 +74,12 @@ def main() -> int:
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}")
print("未导出本地任务归档;需要时运行 export_task_archives.py")
return 0
+2 -2
View File
@@ -1,4 +1,4 @@
"""从 Gitea Wiki 单向导出本地 docs 镜像。"""
"""从 Gitea Wiki 单向导出配置中的核心 docs 镜像。"""
from __future__ import annotations
@@ -9,7 +9,7 @@ from wiki_docs import DEFAULT_CONFIG, WikiClient, WikiDocsError, load_config, sy
def main() -> int:
parser = argparse.ArgumentParser(description="从 Gitea Wiki 单向同步 docs 镜像")
parser = argparse.ArgumentParser(description="从 Gitea Wiki 单向同步核心 docs 镜像")
parser.add_argument(
"--check", action="store_true", help="只检查 Wiki 与镜像是否一致,不写文件"
)
+34 -6
View File
@@ -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}"]
+10 -8
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Project-Profile
wiki_url: http://ilaer.eicp.net:8418/opc/dev_harness/wiki/Project-Profile.-
wiki_revision: c35c1737161ea428c487c066cb39eba4923535d2
synchronized_at: 2026-08-10T11:02:51Z
wiki_revision: 62d1251f807f7d1b60017790c35d46186ffb9617
synchronized_at: 2026-08-16T11:18:50Z
<!-- gitea-wiki-mirror:end -->
# 项目档案
@@ -63,9 +63,11 @@ synchronized_at: 2026-08-10T11:02:51Z
| 查看工作区 | `git status --short --branch` | 显示分支且没有无关修改 |
| 检查模板结构 | `python dev_scripts/check_harness.py --strict` | 输出“DevHarness 检查通过” |
| 运行单元测试 | `python -m unittest discover -s tests -v` | 所有测试通过 |
| 导出 Wiki 镜像 | `python dev_scripts/sync_wiki_docs.py` | 映射页面写入 `docs/` |
| 检查 Wiki 镜像 | `python dev_scripts/sync_wiki_docs.py --check` | 输出镜像与 Wiki 一致 |
| 创建任务归档 | `python dev_scripts/new_task_archive.py 123 "修复登录超时"` | 先创建 Wiki 归档页,再登记并导出本地镜像 |
| 导出核心 Wiki 镜像 | `python dev_scripts/sync_wiki_docs.py` | 核心页面写入 `docs/`,不处理任务归档 |
| 检查核心 Wiki 镜像 | `python dev_scripts/sync_wiki_docs.py --check` | 输出核心镜像与 Wiki 一致 |
| 创建任务归档 | `python dev_scripts/new_task_archive.py 123 "修复登录超时"` | 只创建 Wiki 归档页,不写入本地 |
| 增量导出任务归档 | `python dev_scripts/export_task_archives.py` | 只导出新增或 revision 已变化的任务归档 |
| 全量导出任务归档 | `python dev_scripts/export_task_archives.py --all` | 读取并导出全部线上任务归档 |
## 目录边界
@@ -73,13 +75,13 @@ synchronized_at: 2026-08-10T11:02:51Z
|---|---|---|
| `.gitea/issue_template/` | Gitea 工单模板 | 凭据、任务最终归档 |
| `docs/` | Wiki 自动导出的只读镜像 | 人工直接维护的长期文档 |
| `docs/task/` | Wiki 任务归档页的只读镜像 | 讨论过程和临时方案 |
| `docs/task/` | 人工按需导出的 Wiki 任务归档只读快照,可能不是完整历史 | 讨论过程和临时方案 |
| `dev_scripts/` | Harness 检查、Wiki 同步和归档工具 | 产品功能代码 |
| `tests/` | Harness 工具自动化测试 | 生产数据 |
## 环境、配置与凭据
- Wiki 同步配置:仓库根目录 `wiki-docs.json`。
- 核心 Wiki 同步配置:仓库根目录 `wiki-docs.json`;任务归档不逐页登记,由按需导出工具动态发现。
- Gitea 地址可由配置提供,也可通过 `GITEA_URL` 覆盖。
- Gitea PAT 仅通过 `GITEA_TOKEN` 或 MCP 安全配置提供,不写入仓库。
- Token 至少需要读取仓库权限;创建或更新 Wiki 时还需要写仓库权限。
@@ -90,7 +92,7 @@ synchronized_at: 2026-08-10T11:02:51Z
## 项目专用验收要求
- 长期文档必须先更新 Wiki,再导出本地镜像。
- 长期核心文档必须先更新 Wiki,再导出本地镜像;任务归档默认只保存在 Wiki,用户明确要求时才增量或全量导出。
- 镜像必须包含来源页面、revision 和同步时间。
- 页面删除、重命名和映射变更必须人工确认。
- 新增核心文档时必须更新 Home、显式映射和 Harness 检查。
+32 -18
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Development-Workflow
wiki_url: http://ilaer.eicp.net:8418/opc/dev_harness/wiki/Development-Workflow.-
wiki_revision: ea3912217443b40f0fee66a6dc8e951d06e2c2f9
synchronized_at: 2026-08-10T03:51:34Z
wiki_revision: 3f50238b637bc2f896ceab6caa5ab8ab1ded2a4b
synchronized_at: 2026-08-16T11:18:52Z
<!-- gitea-wiki-mirror:end -->
# 开发工作流
@@ -67,13 +67,13 @@ Agent 检查分支和工作区,只修改工单范围内的文件。发现新
- Git 提交哈希;
- 相关 Wiki 页面及 revision。
长期文档遵循唯一顺序:
长期核心文档遵循唯一顺序:
```text
修改 Wiki → 读取确认 → 导出 docs → 校验差异 → 提交镜像
修改 Wiki → 读取确认 → 导出核心 docs → 校验差异 → 提交镜像
```
不得先编辑 `docs/` 再反向覆盖 Wiki。
任务归档默认只更新 Wiki,不自动导出到 `docs/task/`。不得先编辑本地镜像再反向覆盖 Wiki。
### 4. 待验收
@@ -81,21 +81,32 @@ Agent 检查分支和工作区,只修改工单范围内的文件。发现新
### 5. 归档和关闭
使用以下命令在 Wiki 创建任务归档页、登记显式映射并导出本地镜像:
使用以下命令只在 Wiki 创建任务归档页:
```powershell
python dev_scripts/new_task_archive.py 123 "修复登录超时"
```
归档内容以 Wiki 页面为主源;本地 `docs/task/<编号>-<短标题>.md` 是镜像。归档镜像单独提交,再把 Wiki 页面、revision、镜像路径和提交哈希写回工单。用户明确验收通过后,关闭单元工单并勾选父工单中的任务。
归档内容以 Wiki 页面为事实来源。默认不修改 `wiki-docs.json`,也不写入 `docs/task/`。把 Wiki 页面、revision 和实现提交哈希写回工单;用户明确验收通过后,关闭单元工单并勾选父工单中的任务。
只有用户明确提出时才导出任务归档:
```powershell
python dev_scripts/export_task_archives.py # 增量:新增或 revision 变化
python dev_scripts/export_task_archives.py --all # 全量:读取全部线上任务归档
```
导出不得自动删除本地文件。`docs/task/` 只是人工按需生成的只读快照,可能不是完整或最新的任务历史。
## 文档同步规则
- 映射保存在 `wiki-docs.json`,每个 Wiki 页面对应唯一仓库路径。
- 同步脚本只实现 Wiki → `docs/`,不提供反向同步。
- 核心页面映射保存在 `wiki-docs.json`;普通同步只处理这些核心长期文档。
- 任务归档不逐页登记映射,由按需导出工具根据 `Task-<编号>-<标题>` 动态发现;已有镜像优先按镜像头匹配原页面。
- 所有同步和导出只实现 Wiki → `docs/`,不提供反向同步。
- 镜像头必须记录页面名、页面地址、revision 和同步时间。
- 已跟踪镜像存在未提交改动时,同步必须停止;确认改动来源后再处理。
- `--check` 只检查,不写文件;页面缺失、revision 不一致或正文不一致均失败。
- 核心同步的 `--check` 只检查核心镜像,不要求线上任务归档全部存在于本地。
- 已经导出的任务镜像仍必须具有来源页面、revision 和同步时间,并通过 Harness 格式检查。
- 页面删除和重命名不会自动传播,必须先更新工单并人工确认映射变化。
- Wiki 更新成功而导出失败时,在工单记录部分完成状态,不得把任务标为完成。
- 与具体代码版本强绑定的接口或迁移资料可直接随代码维护,但必须在 Wiki 提供入口或适用版本说明。
@@ -149,14 +160,14 @@ python dev_scripts/new_task_archive.py 123 "修复登录超时"
| 关键原始需求、确认后的单次任务需求 | Gitea 单元任务工单 | 无 |
| 讨论、决定和需求变化 | Gitea 工单正文或评论 | 无 |
| 长期有效的产品需求、业务规则和系统边界 | 对应 Gitea Wiki 主题页 | `docs/` |
| 完成后的实现、验证和遗留问题 | Wiki 任务归档 | `docs/task/` |
| 完成后的实现、验证和遗留问题 | Wiki 任务归档 | 人工按需导出的 `docs/task/` 快照(可能不完整) |
任务产生长期结论时,先更新对应 Wiki 主题页,再导出本地镜像。Gitea 工单全文不导出到仓库,避免形成第二份任务过程记录。
## 稳定文档与任务归档
- Home、项目档案、代码地图、业务规则、开发验证、常见修改和故障排查描述项目现在怎样工作。
- 工单和任务归档解释某次为什么修改、实际改了什么以及如何验证。
- 工单和 Wiki 任务归档解释某次为什么修改、实际改了什么以及如何验证;本地任务快照不是完整历史。
- 新人先读稳定主题页,只有追查历史原因时才读任务归档。
- 任务产生的长期结论必须合并到主题页,不能只留在归档。
@@ -204,16 +215,19 @@ python dev_scripts/new_task_archive.py 123 "修复登录超时"
| `建工单并做` | 依次执行“建工单”和“执行工单”;`建工单,做`、`建工单,做` 含义相同 | 工单保持“待验收” |
| `继续工单 #N` | 核对工单、Git 和 Wiki 证据,从首个未完成步骤继续,不重复仍然有效的检查 | 到达该工单当前流程的停止条件 |
| `检查工单 #N` | 只读对照范围、验收标准、测试和证据,报告通过项、缺失项及未验证部分 | 输出检查报告;不自动修复 |
| `同步文档` | 读取 Wiki,导出已映射的 `docs/` 镜像并检查一致性 | 显示结果和差异;不修改 Wiki、不自动提交 |
| `#N 验收通过` | 记录明确验收,更新 Wiki 归档为“已完成”,导出并提交镜像,推送、同步父工单并关闭任务 | 工单“已完成”并关闭 |
| `同步文档` | 读取 Wiki,导出核心长期文档镜像并检查一致性,不处理任务归档 | 显示结果和差异;不修改 Wiki、不自动提交 |
| `导出任务归档` | 人工触发增量导出,只写入新增或 revision 已变化的任务归档 | 显示导出或跳过结果;不删除本地文件、不自动提交 |
| `导出全部任务归档` | 人工触发全量读取并导出线上全部任务归档 | 显示导出结果;不删除本地文件、不自动提交 |
| `#N 验收通过` | 记录明确验收,更新 Wiki 归档为“已完成”,同步必要的核心文档,推送、同步父工单并关闭任务;不自动导出任务归档 | 工单“已完成”并关闭 |
补充边界:
- 方案未确认时,`建工单`、`建工单并做` 和 `执行工单 #N` 不得绕过确认;Agent 应停在方案确认。
- 前置依赖未满足且不允许并行时,实施类指令停在“待实施”。
- `#N 验收通过` 必须来自用户明确表达;其他快捷指令不得关闭待验收工单。
- `同步文档` 发现镜像有未提交改动时停止,不覆盖现有修改。
- Gitea 工单保留讨论和过程,不把工单全文导出到本地;`docs/task/` 只保存 Wiki 最终任务归档的镜像。
- `同步文档` 或任务归档导出发现目标镜像有未提交改动时停止,不覆盖现有修改。
- `导出任务归档` 和 `导出全部任务归档` 必须由用户明确提出,其他快捷指令不隐式执行。
- Gitea 工单保留讨论和过程,不把工单全文导出到本地;`docs/task/` 只保存人工按需导出的 Wiki 最终任务归档快照。
## 什么时候重新确认方案
@@ -235,6 +249,6 @@ python dev_scripts/new_task_archive.py 123 "修复登录超时"
| 讨论过程和临时方案 | 是 | 否 | 否 |
| 实施进度和阻塞 | 是 | 否 | 否 |
| 长期有效的最终方案 | 链接 | 是 | 镜像 |
| 测试结果与未验证内容 | 是 | 任务归档 | 镜像 |
| 提交哈希 | 是 | 任务归档 | 镜像 |
| 测试结果与未验证内容 | 是 | 任务归档 | 按需镜像 |
| 提交哈希 | 是 | 任务归档 | 按需镜像 |
| 与具体代码版本绑定的说明 | 可链接 | 提供入口 | 是 |
+5 -5
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: New-Project-Documentation-Setup
wiki_url: http://ilaer.eicp.net:8418/opc/dev_harness/wiki/New-Project-Documentation-Setup.-
wiki_revision: a443c206780ecd40ae45732ca10686427f393349
synchronized_at: 2026-08-10T11:03:03Z
wiki_revision: cf49e125e7e73d8c818df61153d76abac6abfaaa
synchronized_at: 2026-08-16T11:19:05Z
<!-- gitea-wiki-mirror:end -->
# 新项目文档初始化
@@ -46,9 +46,9 @@ synchronized_at: 2026-08-10T11:03:03Z
### 4. 修改镜像配置
把 `wiki-docs.json` 中的地址、owner 和 repository 改成新项目;核心主题映射保留。
把 `wiki-docs.json` 中的地址、owner 和 repository 改成新项目;只保留核心主题映射,任务归档不逐页登记。
确认当前目录确实是新项目副本、且 DevHarness 历史归档不需要保留后,移除属于 DevHarness 的任务归档映射和对应 `docs/task/` 镜像。不要在原 DevHarness 仓库或已有业务项目中执行这项清理。
确认当前目录确实是新项目副本、且 DevHarness 历史归档快照不需要保留后,可以移除对应 `docs/task/` 文件。不要在原 DevHarness 仓库或已有业务项目中执行这项清理。
不要把 PAT 写入配置。
@@ -115,7 +115,7 @@ python dev_scripts/sync_wiki_docs.py --check
python -m unittest discover -s tests -v
```
只有线上 Wiki 确认后才导出 `docs/`。旧项目的任务归档不能带入新项目历史。
只有线上 Wiki 确认后才导出核心 `docs/`。任务归档默认不导出;用户明确要求时再运行 `python dev_scripts/export_task_archives.py` 或加 `--all`。旧项目的任务归档快照不能带入新项目历史。
## 完成标准
+7 -5
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Home
wiki_url: http://ilaer.eicp.net:8418/opc/dev_harness/wiki/Home
wiki_revision: 55d89b7aec659f55a61e7ceef881ef34a62d9379
synchronized_at: 2026-08-10T06:58:38Z
wiki_revision: 52e9a6f8fe4fe03db8c65792ef500cc9a5084384
synchronized_at: 2026-08-16T11:18:48Z
<!-- gitea-wiki-mirror:end -->
# DevHarness 文档中心
@@ -65,7 +65,8 @@ python dev_scripts/sync_wiki_docs.py --check
| 任务状态、讨论、阻塞、验收过程 | Gitea 工单 |
| 架构、业务规则、开发规范、操作手册、交付文档、任务归档 | Gitea Wiki |
| 源码和与特定代码版本强绑定的文档 | Git 仓库 |
| 离线浏览副本 | Git 仓库中的 `docs/` Wiki 镜像 |
| 核心长期文档的离线浏览副本 | Git 仓库中的 `docs/` Wiki 镜像 |
| 完整任务归档 | Gitea Wiki;`docs/task/` 仅是人工按需导出的快照 |
本地 `docs/` 不是编辑入口。长期文档必须先修改 Wiki,读取确认后再导出镜像。
@@ -84,9 +85,10 @@ python dev_scripts/sync_wiki_docs.py --check
修改 Wiki → 读取确认 → 导出 docs → 校验差异 → 提交镜像
```
- 页面和本地路径通过仓库中的 `wiki-docs.json` 显式映射。
- 核心页面和本地路径通过仓库中的 `wiki-docs.json` 显式映射;普通同步不处理任务归档。
- 任务归档默认只保存在 Wiki,只有用户明确提出时才增量或全量导出到 `docs/task/`。
- 镜像头记录来源页面、Wiki revision 和同步时间。
- 已映射镜像存在未提交修改时同步必须停止。
- 页面删除、重命名和映射变更必须人工确认。
- Wiki 或导出失败时,相关任务不能标记为完成。
- 核心 Wiki 或必要同步失败时,相关任务不能标记为完成;未请求任务归档导出不阻止任务完成。
- 凭据、个人数据和生产数据不得进入 Wiki 或镜像。
+6
View File
@@ -43,6 +43,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(
+157 -1
View File
@@ -12,7 +12,16 @@ 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 new_task_archive import ( # noqa: E402
build_archive,
main as new_archive_main,
safe_title,
)
from export_task_archives import ( # noqa: E402
existing_task_mirrors,
export_task_archives,
task_target,
)
from wiki_docs import ( # noqa: E402
Config,
Mapping,
@@ -158,6 +167,153 @@ class ArchiveTests(unittest.TestCase):
self.assertIn("Task-12-login", result)
self.assertNotIn("YYYY-MM-DD", result)
@patch("new_task_archive.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",
[
"new_task_archive.py",
"14",
"按需导出",
"--config",
str(config_path),
],
):
result = new_archive_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("export_task_archives.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(
"export_task_archives.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("export_task_archives.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()
-52
View File
@@ -55,58 +55,6 @@
{
"page": "Task-Archive-Template",
"path": "docs/templates/task-archive.md"
},
{
"page": "Task-1-Wiki-文档主源",
"path": "docs/task/1-Wiki-文档主源.md"
},
{
"page": "Task-2-Junior-Maintainer-Docs",
"path": "docs/task/2-初级维护者文档体系.md"
},
{
"page": "Task-3-Dev-Scripts-Rename",
"path": "docs/task/3-dev_scripts目录重命名.md"
},
{
"page": "Task-4-Agent-Efficiency-Scope",
"path": "docs/task/4-Agent效率与范围控制.md"
},
{
"page": "Task-5-Simplify-Agents",
"path": "docs/task/5-精简AGENTS重复说明.md"
},
{
"page": "Task-6-优化ClaudeCode规则入口",
"path": "docs/task/6-优化ClaudeCode规则入口.md"
},
{
"page": "Task-7-ClaudeCode模型路由",
"path": "docs/task/7-ClaudeCode模型路由.md"
},
{
"page": "Task-8-最小工单依赖规则",
"path": "docs/task/8-最小工单依赖规则.md"
},
{
"page": "Task-9-Agent自然语言快捷指令",
"path": "docs/task/9-Agent自然语言快捷指令.md"
},
{
"page": "Task-10-需求记录与流转规则",
"path": "docs/task/10-需求记录与流转规则.md"
},
{
"page": "Task-11-交付文档指南与岗位文档模板",
"path": "docs/task/11-交付文档指南与岗位文档模板.md"
},
{
"page": "Task-12-已有项目接入DevHarness指南",
"path": "docs/task/12-已有项目接入DevHarness指南.md"
},
{
"page": "Task-13-多子项目与独立交付单元",
"path": "docs/task/13-多子项目与独立交付单元.md"
}
]
}