From 3c458bcf5ba457a49a1549588c879cece84b98fb Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Mon, 3 Aug 2026 17:17:35 +0800 Subject: [PATCH] fix(tasks): make status git-authoritative, not Vikunja MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修掉一处会让并行保护失效的设计缺口:AGENTS.md 声明「状态」权威在 Vikunja, 但离线门禁的检查 1 读的是 frontmatter 的 status,两者之间没有任何同步。 有人在看板上拖了卡片,frontmatter 不变,门禁就用过期数据放行了。 status 的权威定为 git frontmatter,理由与 write_paths 相同:它是判定 「两个活跃任务不得写同一路径」的输入,而门禁必须离线可跑;status 变更 决定谁能碰哪些文件,本来就该产生 commit。 - AGENTS.md:把 status 从 Vikunja 权威列表移入 git 原生表,并说明 bucket 与 done 仅为人类视图,不一致时以 git 为准 - agent-context.json:tracker.git_native_fields 增加 status - validate_agent_context.py:强制 git_native_fields 必须含 status, 已用反例验证缺失时报错 - vikunja_export.py:新增只读漂移检测,导出时比对 frontmatter status 与 Vikunja done,不一致则提示;只提示不修正,不反向写回 - docs/tasks/README.md、T-008 方案第 1/2 节:同步口径 顺带解决了窄 token 时期的 bucket 401 遗留问题——status 权威在 git, agent 不再依赖 bucket 移动来表达状态。 Co-Authored-By: Claude Opus 5 --- AGENTS.md | 20 +++++++++++--- docs/agent-context.json | 1 + docs/tasks/README.md | 4 +++ docs/tasks/T-008.md | 12 ++++---- scripts/validate_agent_context.py | 7 +++-- scripts/vikunja_export.py | 46 +++++++++++++++++++++++++++++++ 6 files changed, 78 insertions(+), 12 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4e65254..bd6eeda 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -80,13 +80,25 @@ cmbuyer 是一个自动化采购系统:**采购服务**(网页端,`admin/` ## 任务状态存放在哪 -任务的**协调状态**(标题、状态、标签、依赖、方案正文、执行记录)权威在自建 Vikunja 的 +任务的**叙述性内容**(标题、标签、依赖、方案正文、验收要点、执行记录)权威在自建 Vikunja 的 `cmbuyer` 项目;`docs/tasks/T-XXX.md` 里两行 `VIKUNJA EXPORT` 标记之间的内容是它的 **单向投影**,由 `scripts/vikunja_export.py` 覆盖写入,不要手工编辑。 -**`write_paths`、`## 边界` 与安全边界条目的权威始终在 git**,不迁往 Vikunja。理由见 -本文第 2 条:安全边界能不能收紧靠 `git diff` 逐条复核,权威一旦搬到远端,放宽边界的改动 -在 diff 里只呈现为「导出内容更新」,审计链就断了。 +**以下四项的权威始终在 git,不迁往 Vikunja:** + +| 项 | 位置 | 为什么不能搬 | +| --- | --- | --- | +| `status` | frontmatter | 门禁靠它判定谁能写哪些路径,且必须离线可跑;权威在远端等于校验要联网 | +| `write_paths` | frontmatter | 同上,是并行安全的判定输入 | +| `context_ref` / `work_branch` | frontmatter | 锚定 commit,离开 git 就失去意义 | +| `## 边界`与安全边界条目 | 正文区块外 | 见本文第 2 条:能否收紧靠 `git diff` 逐条复核 | + +安全边界的权威一旦搬到远端,放宽边界的改动在 diff 里只呈现为「导出内容更新」, +审计链就断了。`status` 同理:它不是看板装饰,是决定并行写入安全的协调契约, +每次变更都应当产生一个 commit。 + +Vikunja 的 bucket 与 `done` 标志是**人类视图**,不是判定依据。两者不一致时以 git 为准; +`scripts/vikunja_export.py` 会在导出时只读比对并提示漂移,但不会反向写回。 只读任务内容不需要接入 Vikunja——导出产物就在仓库里,`git clone` 即可。只有写状态和 执行记录才需要配置 MCP。 diff --git a/docs/agent-context.json b/docs/agent-context.json index 7459a22..d614d18 100644 --- a/docs/agent-context.json +++ b/docs/agent-context.json @@ -79,6 +79,7 @@ "export_script": "scripts/vikunja_export.py", "direction": "tracker_to_git", "git_native_fields": [ + "status", "write_paths", "context_ref", "work_branch", diff --git a/docs/tasks/README.md b/docs/tasks/README.md index 6d9b139..0058ad0 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -95,6 +95,10 @@ write_paths: 改区块内的内容要去 Vikunja 改,然后重新导出。直接手改会被 `sha256` 校验抓到并失败—— 这是刻意的,两个权威同时可写就一定漂移。 +**`status` 例外:它的权威在 frontmatter,不在 Vikunja。** 改状态就改 frontmatter 并提交; +Vikunja 的 bucket 位置和 `done` 标志只是人类视图,不一致时以 git 为准。导出脚本会在 +比对到不一致时提示,但不会反向写回。 + 尚未迁移到 Vikunja 的任务文件(没有导出区块)保持原样,门禁豁免。 ## 硬规则 diff --git a/docs/tasks/T-008.md b/docs/tasks/T-008.md index 6952a2e..8ccfad2 100644 --- a/docs/tasks/T-008.md +++ b/docs/tasks/T-008.md @@ -25,7 +25,7 @@ write_paths: - docs/tasks/_template.md --- - + ## 问题 / 背景 多 agent 并行时任务文档会互相覆盖。已观测到的事实:T-005 与 T-006 同时为 `DOING`, @@ -64,8 +64,9 @@ frontmatter 拼凑。 `/projects/{id}/views/{view_id}/tasks` 正常。任务一旦分散到多个项目,agent 就无法一次 取全。 -状态用 Kanban bucket 表达,不另造字段。实测该 MCP 没有 bucket 改名工具,因此沿用 -Vikunja 自动创建的英文名,不为了对齐中文表述去动 API: +`status` 的权威在 git 的 frontmatter,**不在 Vikunja**。离线门禁 `validate_agent_context.py` 靠它判定「两个活跃任务不得写同一路径」,权威放到远端就等于让唯一能跑的校验命令依赖网络;status 变更本身也决定谁能碰哪些文件,本来就该产生 commit。 + +Kanban bucket 与 `done` 标志是**人类视图**,不是判定依据,两者不一致时以 git 为准。导出脚本只读比对并提示漂移,不反向写回。实测该 MCP 没有 bucket 改名工具,因此沿用 Vikunja 自动创建的英文名: | frontmatter `status` | bucket | | --- | --- | @@ -81,14 +82,15 @@ Vikunja 自动创建的英文名,不为了对齐中文表述去动 API: | 内容 | 权威 | 载体 | | --- | --- | --- | -| 标题、状态、标签、依赖关系 | Vikunja | task 字段 / bucket / label / relation | +| 标题、标签、依赖关系 | Vikunja | task 字段 / label / relation | +| **`status`** | **git** | frontmatter(bucket / `done` 仅为人类视图) | | 问题 / 背景、方案、验收要点 | Vikunja | task `description` | | 执行记录 | Vikunja | task comments | | **`write_paths`** | **git** | frontmatter | | **边界与安全边界条目** | **git** | 正文 `## 边界` | | `context_ref`、`work_branch` | git | frontmatter | -`write_paths` 与安全边界留在 git 是本任务的核心约束。理由:`AGENTS.md` 第 2 条规定安全 +`status`、`write_paths` 与安全边界留在 git 是本任务的核心约束。理由:`AGENTS.md` 第 2 条规定安全 边界只能收紧,而收紧与否靠 `git diff` 逐条复核。若这两项的权威在 Vikunja,agent 放宽一条 闸门时改的是远端字段,本地文件下次同步才变,diff 里只呈现为「导出内容更新」,看不出是谁 在什么理由下放宽了边界——**审计链在此断裂**。任何后续任务不得把这两项迁往 Vikunja。 diff --git a/scripts/validate_agent_context.py b/scripts/validate_agent_context.py index c5961df..d0aaa7a 100644 --- a/scripts/validate_agent_context.py +++ b/scripts/validate_agent_context.py @@ -337,12 +337,13 @@ def validate_manifest(root: Path) -> list[str]: git_native = require_string_list( tracker.get("git_native_fields"), "tracker.git_native_fields", errors ) - # write_paths 与边界章节的权威必须留在 git,否则安全边界被放宽时 git diff 看不出来。 - for required in ("write_paths", "boundaries_section"): + # 这三项的权威必须留在 git:status 与 write_paths 是本脚本判定并行安全的输入, + # 权威搬到远端就等于让离线门禁依赖网络;边界章节搬走则切断安全边界的审计链。 + for required in ("status", "write_paths", "boundaries_section"): if required not in git_native: errors.append( f"tracker.git_native_fields 必须包含 {required}:" - "该项迁往 Vikunja 会切断安全边界的审计链。" + "该项迁往 Vikunja 会让离线门禁失去判定依据或切断审计链。" ) refresh = require_mapping(root_object.get("refresh"), "refresh", errors) diff --git a/scripts/vikunja_export.py b/scripts/vikunja_export.py index 9913c8b..8346f6e 100644 --- a/scripts/vikunja_export.py +++ b/scripts/vikunja_export.py @@ -354,6 +354,33 @@ def frontmatter_task_id(text: str) -> int | None: return int(match.group(1)) if match else None +def frontmatter_status(text: str) -> str | None: + parts = text.split("---\n", 2) + if len(parts) < 3: + return None + match = re.search(r"^status:[ \t]*(\S+)", parts[1], re.MULTILINE) + return match.group(1) if match else None + + +def report_status_drift(path: Path, status: str | None, task: dict[str, Any]) -> None: + """只读比对 frontmatter 的 status 与 Vikunja 的 done 标志。 + + git 是 status 的权威,看板只是人类视图,所以这里只提示、不修正, + 更不会反向写回——反向同步会重新引入两个权威。 + """ + if status is None: + return + expected_done = status == "DONE" + actual_done = bool(task.get("done")) + if expected_done != actual_done: + name = path.resolve().relative_to(REPO_ROOT) + print( + f"提示 {name}:frontmatter status={status},但 Vikunja done={actual_done}。" + "以 git 为准;看板卡片需要人工归位。", + file=sys.stderr, + ) + + # -------------------------------------------------------------------------- # 自检 # -------------------------------------------------------------------------- @@ -402,6 +429,24 @@ def selftest() -> int: if "只允许 GET" not in str(error): failures.append(f"{method} 被拒绝的原因不对:{error}") + # status 漂移检测:只提示不修正,且不得反向写回。 + import io + from contextlib import redirect_stderr + + for status, done, should_warn in ( + ("DONE", True, False), ("DOING", False, False), + ("DONE", False, True), ("DOING", True, True), + ): + buffer = io.StringIO() + with redirect_stderr(buffer): + report_status_drift(REPO_ROOT / "docs/tasks/T-000.md", status, {"done": done}) + warned = "提示" in buffer.getvalue() + if warned != should_warn: + failures.append( + f"漂移检测 status={status} done={done}:预期 {'提示' if should_warn else '静默'}," + f"实际 {'提示' if warned else '静默'}" + ) + # 幂等:同样输入两次拼接结果必须一致。 template = ("---\nid: T-999\nvikunja_task_id: 7\n---\n\n" f"{EXPORT_BEGIN_TEMPLATE.format(task_id=7, synced='x', sha256='0' * 64)}\n" @@ -436,6 +481,7 @@ def export_file(path: Path, apiurl: str, apikey: str, check_only: bool) -> bool: task = http_get(apiurl, apikey, f"/tasks/{task_id}") comments = http_get(apiurl, apikey, f"/tasks/{task_id}/comments") or [] + report_status_drift(path, frontmatter_status(text), task) body = build_block_body(task, comments) existing = EXPORT_BEGIN_RE.search(text)