--- id: T-008 title: 接入 Vikunja 作为任务权威并建立单向导出 phase: 0 deps: [T-007] status: DONE 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 --- ## 问题 / 背景 多 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(实测读到 `

触发原因:…SavePerson`),不是 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 转换:含 ``、``、`