Files
cmbuyer/docs/tasks/T-008.md
T
QiuSWandClaude Opus 5 3c458bcf5b 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>
2026-08-03 17:17:35 +08:00

263 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
id: T-008
title: 接入 Vikunja 作为任务权威并建立单向导出
phase: 0
deps: [T-007]
status: DOING
created: 2026-08-03
vikunja_task_id: 15
context_ref: b9e29ed
work_branch: task/t-008-vikunja-task-authority
needs_device: false
needs_human_review: true
write_paths:
- docs/tasks/T-008.md
- docs/tasks/README.md
- docs/agent-context.json
- docs/agent-context.schema.json
- AGENTS.md
- .gitignore
- .mcp.json
- vikunja.env.example
- scripts/vikunja-mcp.sh
- scripts/vikunja_export.py
- scripts/validate_agent_context.py
- docs/tasks/_template.md
---
<!-- BEGIN VIKUNJA EXPORT id=15 synced=2026-08-03T09:16:43Z sha256=5caf1f2f9a0bf1d5dc78c3fe5f3cbd83789585c8fa2af7d03828140d00fc663e -->
## 问题 / 背景
多 agent 并行时任务文档会互相覆盖。已观测到的事实:T-005 与 T-006 同时为 `DOING`,
`write_paths` 在 `docs/06-tasks.md`、`docs/08-interaction-checklist.md`、`docs/routes.md`、
`docs/current-state.md` 四处重叠。`docs/tasks/README.md` 的「领取规则」第 3 条已经写明
「与其他活跃任务路径重叠时不得并行」,但该规则**只以自然语言存在,没有任何机器强制**,
所以被绕过时无人察觉。
同时,任务状态(谁在等人工确认、哪台真机在排队)没有人类可用的视图,只能靠翻
frontmatter 拼凑。
本任务把两件事分开解决:
- **协调状态**搬到已部署的 Vikunja(`http://ilaer.eicp.net:3456`,v2.4.0),获得看板视图。
- **契约与安全边界**留在 git,并新增离线门禁强制执行路径不重叠。
不做这件事的后果:进入 T-001 / T-002 两端并行后,`docs/api.md` 与
`docs/04-architecture.md` 第四节会在无人察觉的情况下被并发写入,而这两份文档承载
三道价格闸门与提交订单四条件。
## 关联需求与交互
- 功能:不适用(工程基础设施,不属于 MVP 功能面)
- 用户故事:不适用
- 交互:不适用(无产品界面;Vikunja 自带 Web 界面不属本项目交付物)
- 架构 / API:不涉及 `04-architecture.md` 第四节任何安全边界,不新增跨端接口
## 方案
### 1. Vikunja 项目与状态映射
在 Vikunja 建立**单个**项目 `cmbuyer`,全部任务集中在此。
集中的理由是硬约束而非偏好:v2.4.0 上 `/api/v1/tasks/all` 返回
`{"code":2004,"message":"Invalid model provided"}`,跨项目列举任务不可用;按项目列举走
`/projects/{id}/views/{view_id}/tasks` 正常。任务一旦分散到多个项目,agent 就无法一次
取全。
`status` 的权威在 git 的 frontmatter,**不在 Vikunja**。离线门禁 `validate_agent_context.py` 靠它判定「两个活跃任务不得写同一路径」,权威放到远端就等于让唯一能跑的校验命令依赖网络;status 变更本身也决定谁能碰哪些文件,本来就该产生 commit。
Kanban bucket 与 `done` 标志是**人类视图**,不是判定依据,两者不一致时以 git 为准。导出脚本只读比对并提示漂移,不反向写回。实测该 MCP 没有 bucket 改名工具,因此沿用 Vikunja 自动创建的英文名:
| frontmatter `status` | bucket |
| --- | --- |
| `TODO` | To-Do |
| `DOING` | Doing |
| `DONE` | Done(该视图的 `done_bucket_id`) |
| `BLOCKED` | Blocked(本任务新建) |
标签固定两个:`needs_device`、`needs_human_review`。
依赖用 `task_relations_add` 的关系表达,不写进正文。
### 2. 权威划分(不可放宽)
| 内容 | 权威 | 载体 |
| --- | --- | --- |
| 标题、标签、依赖关系 | 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 |
`status`、`write_paths` 与安全边界留在 git 是本任务的核心约束。理由:`AGENTS.md` 第 2 条规定安全
边界只能收紧,而收紧与否靠 `git diff` 逐条复核。若这两项的权威在 Vikunja,agent 放宽一条
闸门时改的是远端字段,本地文件下次同步才变,diff 里只呈现为「导出内容更新」,看不出是谁
在什么理由下放宽了边界——**审计链在此断裂**。任何后续任务不得把这两项迁往 Vikunja。
### 3. 本地混合文件格式
`docs/tasks/T-XXX.md` 是投影与 git 原生内容的混合体,不是纯导出。自上而下依次是:
1. frontmatter。其中 `vikunja_task_id` 是唯一连接键,创建后不再变更;`write_paths`、 `context_ref`、`work_branch` 是 git 原生,导出脚本永不写入。
2. 一行 BEGIN 标记,形如 `BEGIN VIKUNJA EXPORT id=<数字> synced=<时间戳> sha256=<64位十六进制>`, 由 HTML 注释包裹。
3. 区块正文:问题 / 背景、关联需求与交互、方案、验收要点、执行记录。**Vikunja 权威, 脚本整段覆盖。**
4. 一行 END 标记,同样由 HTML 注释包裹。
5. `## 边界`。**git 权威,脚本读取但绝不写入。**
`sha256` 覆盖两行标记之间的正文,供门禁发现手工改动。
本节刻意不贴出字面标记:任务文件本身要经过导出往返,正文里出现字面标记会让脚本
把示例误判为真区块。已实测踩中过一次,围栏在往返后丢失,示例里的二级标题变成真标题
并导致内容截断。
### 4. 导出脚本 `scripts/vikunja_export.py`
- **只读 Vikunja,只写标记区块内。** 不实现任何反向写入路径,且须有测试证明不可达。
- **仅用标准库。** T-002 未完成前仓库没有 Python 虚拟环境,本脚本必须与 `validate_agent_context.py` 一样用系统 `python3` 直接跑通。
- **HTML → Markdown 转换**:Vikunja 的 `description` 存的是 HTML(实测读到 `<p><b>触发原因</b>:…<code>SavePerson</code>`),不是 Markdown。用 `html.parser` 实现确定性转换。
- **遇到未支持标签时报错退出,不得静默丢弃。** 静默丢弃会让安全相关文字无声消失, 与本项目 fail closed 的一贯要求冲突。
- 幂等:内容未变时不重写文件,避免产生噪音 diff。
- 行尾统一 LF,禁止写出 CRLF。
### 5. MCP 接入与凭据
- `scripts/vikunja-mcp.sh`:MCP 启动包装,**提交进仓库**。职责是读凭据、剥掉 `apiurl` 末尾的 `/api/v1`(`@0xk3vin/vikunja-mcp` 内部会自己拼接,不剥会请求 `/api/v1/api/v1/...` 得到 404)、导出 `VIKUNJA_URL` 与 `VIKUNJA_API_TOKEN`、 `exec` 到**版本写死**的 `@0xk3vin/vikunja-mcp@1.1.1`。优先用全局安装的二进制, 缺失时回退 `npx`。
- 版本写死的理由:上游改工具名或行为会直接改变 agent 行为,与 `CLAUDE.md` 「交付产物新旧以 SHA-256 判断」同源,不接受静默升级。
- 凭据文件 `vikunja.env` 放仓库根目录,比照既有 `gitea.env` 模式加入 `.gitignore`; 同时提交 `vikunja.env.example`。脚本按 `$VIKUNJA_ENV_FILE` → 仓库根 → 家目录顺序 查找,便于其他 agent 换路径。
- `.mcp.json` 的 `command` 改为仓库内相对路径,使配置不绑定某台机器的家目录。
- **token 权限:采用全量 token**(2026-08-03 决定)。曾试过只覆盖 tasks / comments / labels 的窄 token,实测 `POST /projects/{p}/views/{v}/buckets/{b}/tasks` 返回 401——bucket 操作需要 project 级权限,导致 `TODO` / `DOING` / `BLOCKED` 三者的状态流转 agent 无法驱动,与方案第 1 节「状态用 Kanban bucket 表达」直接冲突。权衡后取全量 token。**代价必须记录**:任何配置了此 MCP 的 agent 都持有对该 Vikunja 实例的完整读写权,包含 `projects_delete`;且 `apiurl` 是 `http://` 走公网 DDNS,token 明文过网。凭据只允许存放在 `vikunja.env`,不得内联进任何 agent 配置文件。
### 6. 门禁 `scripts/validate_agent_context.py`
新增四项检查,**全部必须离线可跑**——本脚本是目前唯一可运行的验证命令,一旦依赖
Vikunja 网络,服务不可达时连校验都做不了:
1. 所有 `status: DOING` 的任务,`write_paths` 两两不得相交。
2. `write_paths` 中的**通配条目**不得匹配 `docs/agent-context.json` 中 `shared_documents` 登记的任何条目——共享文档必须逐条显式列出。共享文档本身**允许**出现在 `write_paths` (T-007 改 `AGENTS.md` 即正当交付),排他性由检查 1 保证;本检查只堵住用通配悄悄 圈走共享文档。
3. 存在导出标记区块时,重算 `sha256` 并与标记比对,不一致即失败(说明有人手改了投影)。
4. 任务文件存在导出区块时,必须有 `vikunja_task_id` 且与区块标记里的 id 一致。 尚未迁移(无导出区块)的任务文件豁免,避免为了让检查转绿而去改属于其他活跃任务的文件。
检查器必须感知围栏代码块,跳过其中的同名文本,否则会把文档里的示例当成真标记。
**检查 1 落地后会立刻报出存量违规**:T-005 与 T-006 同为 `DOING`,在
`docs/06-tasks.md`、`docs/08-interaction-checklist.md`、`docs/routes.md`、
`docs/current-state.md` 四处重叠。这是本检查的第一个真实发现,**不得通过放宽检查、加白
名单或延后启用来绕过**。处置只有两条合法路径,由 T-005 / T-006 的所有者选择:
- 人工确认 6 个原型后将两任务改为 `DONE`(`DONE` 不参与相交判定);或
- 收窄两者的 `write_paths`,把共享文档移出,改为产出建议由任务所有者合入。
存量违规已于 2026-08-03 消解:T-005 与 T-006 经人工确认 6 个原型后转 `DONE`,不再参与相交判定,门禁转绿。T-008 自身因 `needs_human_review: true`,按 `docs/tasks/README.md` 硬规则保持 `DOING`(不是 `BLOCKED`):工作可继续推进,只是未获人工确认前不得标 `DONE`。
`docs/agent-context.json` 相应新增 `shared_documents` 与 `tracker` 两个顶层键。该 schema
的 `additionalProperties` 为 `false`,必须同步修改 `agent-context.schema.json` 并将
`schema_version` 由 1 升为 2。
`degraded_mode` 三个字段(`continue_claimed_task`、`claim_new_task`、
`write_remote_state`)此前无远端状态、形同虚设,本任务后正式生效:Vikunja 不可达时
继续手头任务、不领新任务、不写远端。
## 验收要点
- Vikunja 中存在 `cmbuyer` 项目,四个 bucket 与上表一致,`needs_device` 与 `needs_human_review` 两个标签已建。
- T-005、T-006、T-007 三个既有任务已录入 Vikunja(远端操作,不改动 git 文件), bucket 与各自 `status` 一致。
- **仅 T-008 自身**回填 `vikunja_task_id` 与导出区块。T-005、T-006、T-007 的 frontmatter 回填推迟到各自任务关闭时由其所有者完成——那三个文件不在本任务 `write_paths` 内,越界写入会违反本任务正在建立的规则。
- `python scripts/validate_agent_context.py` 通过;**断网状态下同样通过**(验证离线性)。 该项以存量 T-005 与 T-006 重叠被消解为前提,消解前本任务保持 `BLOCKED`。
- 断网时执行 `scripts/vikunja_export.py` 以非零码退出并给出明确原因,不产生半截文件。
- 制造违规样本各跑一次,四项检查分别报错且信息指明具体文件与冲突路径:两个 `DOING` 任务写同一路径;`write_paths` 用通配圈走共享文档;手改导出区块一个字符;有导出区块 但 `vikunja_task_id` 与区块 id 不一致。
- 反向写入不可达:存在测试证明 `vikunja_export.py` 的代码路径不引用任何 Vikunja 写接口。
- HTML 转换:含 `<b>`、`<code>`、`<ul>`、`<a>`、`<table>` 的样例转换正确;含未支持标签 的样例以非零码退出。
- 导出产物行尾为 LF;连续执行两次导出,第二次无文件变更(幂等)。
- 重启 Claude Code 后 `claude mcp list` 显示 vikunja 为 Connected,`.mcp.json` 使用 相对路径且在另一家目录下同样可用。
- `git status` 确认 `vikunja.env` 未被跟踪,`vikunja.env.example` 已提交。
- 看板视图可用,卡片位置与 `status` 一致:`Doing` 放 #15,`Done` 放 #12 / #13 / #14。
- 各家 agent 的 MCP 配置片段写入 `AGENTS.md`,统一指向 `scripts/vikunja-mcp.sh`,配置文件内不出现凭据。
## 执行记录
### 2026-08-03T08:53:59Z · ila
**2026-08-03 · 第一轮实现**(分支 `task/t-008-vikunja-task-authority`,基线 `b9e29ed`)
**已完成**
- Vikunja 建 `cmbuyer` 项目(id=5)。bucket:To-Do 19 / Doing 20 / Done 21 / Blocked 22(新建)。标签 `needs_device` 3、`needs_human_review` 4。
- T-005 #12、T-006 #13、T-007 #14、T-008 #15 已录入,bucket 与各自 status 一致。三个既有任务未改动 git 文件。
- `scripts/vikunja-mcp.sh` 从家目录迁入仓库并提交;凭据 `vikunja.env` 留仓库根并 gitignore,比照既有 `gitea.env` 模式;新增 `vikunja.env.example`。
- `.mcp.json` 改为相对路径 `./scripts/vikunja-mcp.sh`,不再绑定家目录。
- `agent-context.json` 新增 `shared_documents`(14 项)与 `tracker`,`schema_version` 升到 2,schema 同步。
- `validate_agent_context.py` 新增四项离线检查,并校验 `tracker.git_native_fields` 必须含 `write_paths` 与 `boundaries_section`。
- `vikunja_export.py`:stdlib-only,HTML→Markdown,只发 GET,`--selftest` 断言非 GET 方法被拒。
**验证结果**
- 四项检查各造违规样本:全部命中;两个反例(共享文档显式列出、id 一致)正确放行。
- 导出幂等:连跑两次,第二次 0 变更。行尾 LF。
- 断网导出:exit=1,文件 sha256 不变,未产生半截文件。
- `--selftest` 通过(11 项转换用例 + 未支持标签报错 + 四种写方法被拒 + 幂等 + 区块外内容未被破坏)。
- 真实 token 未出现在任何将提交的文件中。
**踩坑与修正**
- v2.4.0 上 bucket 移动路由不是 `/views/{v}/tasks/{t}/position`(404),正确的是 `/views/{v}/buckets/{b}/tasks`。
- **自我引用**:任务正文原本用代码块举例说明导出标记格式,往返一次后围栏丢失,示例里的二级标题变成真标题,导致重传时截断、丢了方案 4~6 节与验收要点。已重建内容,并把方案第 3 节改为不贴字面标记;门禁与导出脚本均已加围栏感知。
- 草稿三处与自身规则冲突,已修正:检查 2 原本禁止共享文档进 `write_paths`(会挡住正当交付);检查 4 原本要求所有任务都有 `vikunja_task_id`(会逼迫越界改他人文件);验收原本要求回填 T-005/006/007 的 frontmatter(同样越界)。
**当前 blocker**
- 检查 1 报出存量违规:T-005 与 T-006 同为 DOING,在 `06-tasks.md`、`08-interaction-checklist.md`、`routes.md`、`current-state.md` 四处重叠。按本任务规定不得绕过,需两者所有者收窄 `write_paths` 或人工确认原型后转 DONE。
- 人工待办:签发只覆盖 tasks/comments/labels 的窄权限 token 替换当前全量 token;重启后确认 `claude mcp list` 显示 Connected。
- `needs_human_review: true`,以上两项完成前保持 DOING,不得标 DONE。
### 2026-08-03T08:59:43Z · ila
**2026-08-03 · 窄权限 token 切换与复核**
**窄 token 实测权限**(旧全量 token 已替换):
- 可读写:`GET/POST /tasks/{id}`、`GET/PUT/DELETE /tasks/{id}/comments`、`GET /labels`、`GET /projects/{id}/tasks`。
- 已被拒(401):`GET /projects/{id}`、`GET /projects/{id}/views`、`DELETE /projects/{id}`。收窄生效。
- 影响:MCP 的 `projects_list` / `projects_get` 不可用;`tasks_list` 正常(自动回退到 `/projects/{id}/tasks`)。`vikunja_export.py` 只用 `/tasks/{id}` 与 `/tasks/{id}/comments`,不受影响。
- `PUT /tasks/{id}/labels` 返回 400(重复添加已有标签),非权限问题。
**新发现的 API 陷阱**:Vikunja 的 `POST /tasks/{id}` 是**整体替换**,不是部分更新。只发 `{"id":15}` 会把 description 清空;只发 `{id, description}` 会把 `done` 重置。本轮因此误清过 #15 的 description(已用源文件恢复,本地投影未受损,sha256 校验反而验证了这一点),并把 #14 的 `done` 重置为 false(已修回 true)。今后任何字段更新必须携带完整对象。
**当前状态**:#12 / #13 / #15 done=false 带 needs_human_review 标签;#14 done=true。四个任务 description 完整。
### 2026-08-03T09:05:50Z · ila
**2026-08-03 · 重启验证与存量违规消解**
**已完成**
- 重启后 `.mcp.json` 的相对路径 `./scripts/vikunja-mcp.sh` 生效,会话内 `ping` 正常。(`claude mcp list` 显示 Pending 是那条独立 CLI 进程不共享会话批准状态,非连接问题。)
- 人工确认 6 个原型通过,T-005 与 T-006 转 `DONE`,并回填 `vikunja_task_id` 12 / 13。两者内容已冻结,不迁入导出区块——转换只增加往返风险无收益,门禁对无区块文件豁免检查 4。
- **门禁转绿**:`validate_agent_context.py` exit=0。检查 1 报出的存量违规按规定路径消解,未放宽任何检查。
- T-008 状态口径统一为 `DOING`(`needs_human_review` 硬规则优先于草稿里的 `BLOCKED` 表述),description 已更新并重新导出。
**新发现:窄 token 无法移动 kanban bucket**
`POST /projects/{p}/views/{v}/buckets/{b}/tasks` 返回 401——bucket 操作需要 project 级权限,而窄 token 只有 tasks / comments / labels。这与方案第 1 节「状态用 Kanban bucket 表达」冲突:
- `DONE` 不受影响:置 `done=true` 后 Vikunja 自动归入 `done_bucket_id`。
- `TODO` / `DOING` / `BLOCKED` 三者的区分,agent 目前**无法**驱动。首次播种时用的是旧全量 token,所以现有卡片位置正确,但后续状态流转会停滞。
三条出路待决策:用 label 表达 status(agent 可写 labels,最稳,但看板泳道失去意义);给 token 加开 project 读与 bucket 写(削弱收窄);或接受卡片位置由人维护、agent 只维护 `done`。**未决策前不修改方案第 1 节**,避免边写边改造成文档与实现不一致。
<!-- END VIKUNJA EXPORT -->
## 边界
- **不修改** `docs/api.md`、`docs/04-architecture.md`、`docs/02-requirements.md`——
本任务不触及任何跨端契约与安全边界。
- **不修改** `docs/current-state.md` 与 `docs/06-tasks.md`。这两份文档正被 `DOING` 状态的
T-005 / T-006 占用,按本任务自己建立的规则不得并行写入。相关快照与路线图更新推迟到
T-005 / T-006 关闭后另起任务补录。
- **不迁移** `write_paths`、`## 边界` 与安全边界条目到 Vikunja,任何后续任务同样不得迁移。
- **不实现**反向同步(git → Vikunja)。双向同步会重新引入两个权威。
- **不实现** webhook、自动定时同步、CI 集成。导出由人或任务显式触发。
- **不改变**现有四条不可协商规则、三道价格闸门、提交订单四条件的任何表述。
- 不引入任何第三方 Python 依赖。
- 不为 codex 或其他 agent 编写配置。其他 agent 通过读取已导出的本地任务文件即可获得
完整上下文,无需接入 MCP;确需写入时各自配置,不属本任务范围。