fix(tasks): make status git-authoritative, not Vikunja
修掉一处会让并行保护失效的设计缺口: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 <noreply@anthropic.com>
This commit is contained in:
@@ -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。
|
||||
|
||||
@@ -79,6 +79,7 @@
|
||||
"export_script": "scripts/vikunja_export.py",
|
||||
"direction": "tracker_to_git",
|
||||
"git_native_fields": [
|
||||
"status",
|
||||
"write_paths",
|
||||
"context_ref",
|
||||
"work_branch",
|
||||
|
||||
@@ -95,6 +95,10 @@ write_paths:
|
||||
改区块内的内容要去 Vikunja 改,然后重新导出。直接手改会被 `sha256` 校验抓到并失败——
|
||||
这是刻意的,两个权威同时可写就一定漂移。
|
||||
|
||||
**`status` 例外:它的权威在 frontmatter,不在 Vikunja。** 改状态就改 frontmatter 并提交;
|
||||
Vikunja 的 bucket 位置和 `done` 标志只是人类视图,不一致时以 git 为准。导出脚本会在
|
||||
比对到不一致时提示,但不会反向写回。
|
||||
|
||||
尚未迁移到 Vikunja 的任务文件(没有导出区块)保持原样,门禁豁免。
|
||||
|
||||
## 硬规则
|
||||
|
||||
+7
-5
@@ -25,7 +25,7 @@ write_paths:
|
||||
- docs/tasks/_template.md
|
||||
---
|
||||
|
||||
<!-- BEGIN VIKUNJA EXPORT id=15 synced=2026-08-03T09:13:45Z sha256=12b35fc74d559e5b15884fd223785c35ef08fa1750042d442dbb888e9ceb059b -->
|
||||
<!-- BEGIN VIKUNJA EXPORT id=15 synced=2026-08-03T09:16:43Z sha256=5caf1f2f9a0bf1d5dc78c3fe5f3cbd83789585c8fa2af7d03828140d00fc663e -->
|
||||
## 问题 / 背景
|
||||
|
||||
多 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。
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user