feat(tasks): adopt Vikunja as task authority with one-way export

把任务的协调状态迁到自建 Vikunja 的 cmbuyer 项目,git 保留单向投影,
并把「活跃任务写路径不得重叠」从自然语言规则变成机器门禁。

- scripts/vikunja_export.py:单向导出(只发 GET),stdlib-only 的
  HTML→Markdown,只覆盖标记区块内,幂等,含 --selftest
- scripts/vikunja-mcp.sh:MCP 启动包装从家目录迁入仓库,版本写死 1.1.1
- scripts/validate_agent_context.py:新增四项离线检查——DOING 任务写路径
  互斥、通配不得圈走共享文档、导出区块 sha256、连接键一致性
- docs/agent-context.json:新增 shared_documents 与 tracker,schema_version 2
- vikunja.env 比照 gitea.env 加入 gitignore,提交 vikunja.env.example

write_paths、## 边界与安全边界条目的权威留在 git,不迁往 Vikunja:
安全边界能否收紧靠 git diff 逐条复核,权威搬到远端会切断审计链。

门禁上线即报出存量违规:T-005 与 T-006 同为 DOING,在 06-tasks.md、
08-interaction-checklist.md、routes.md、current-state.md 四处重叠。
按 T-008 规定不得绕过,须由两者所有者收窄写路径或人工确认后转 DONE。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
QiuSW
2026-08-03 17:00:41 +08:00
co-authored by Claude Opus 5
parent b9e29ed970
commit 421c865ad2
12 changed files with 1098 additions and 4 deletions
+6
View File
@@ -5,6 +5,12 @@ gitea.env.*
!gitea.env.example !gitea.env.example
*.stderr.log *.stderr.log
# Local Vikunja MCP credentials must never enter Git.
# scripts/vikunja-mcp.sh 本身不含凭据,必须提交,不在此列。
vikunja.env
vikunja.env.*
!vikunja.env.example
# Local browser-prototype verification artifacts # Local browser-prototype verification artifacts
.playwright-mcp/ .playwright-mcp/
*-wide.png *-wide.png
+10
View File
@@ -0,0 +1,10 @@
{
"mcpServers": {
"vikunja": {
"type": "stdio",
"command": "./scripts/vikunja-mcp.sh",
"args": [],
"env": {}
}
}
}
+19
View File
@@ -67,6 +67,10 @@ cmbuyer 是一个自动化采购系统:**采购服务**(网页端,`admin/`
- 默认**单任务、单责任 agent、单写入者**:一个任务只有一个负责人,同时只有一个 agent - 默认**单任务、单责任 agent、单写入者**:一个任务只有一个负责人,同时只有一个 agent
修改该任务的 `write_paths`。 修改该任务的 `write_paths`。
- 多 agent 并行只拆到写路径互不重叠的任务。`admin/` 采购服务与 `client/` 采购工具天然可并行。 - 多 agent 并行只拆到写路径互不重叠的任务。`admin/` 采购服务与 `client/` 采购工具天然可并行。
这条不再只靠自觉:`scripts/validate_agent_context.py` 会拒绝两个 `DOING` 任务写同一路径。
- **共享文档只由任务所有者写入。** `docs/agent-context.json` 的 `shared_documents` 列出
跨任务共享的文档;受托执行者不得直接改它们,只提交建议由所有者合入。共享文档可以出现在
某个任务的 `write_paths`,但必须逐条显式列出,不得用通配圈走。
- 复杂任务先规划再编码。方案、不可变约束、写路径和验收门禁必须写入任务文件, - 复杂任务先规划再编码。方案、不可变约束、写路径和验收门禁必须写入任务文件,
不能只停留在对话里。 不能只停留在对话里。
- 任务内委派不是默认流程。委派后仍保持唯一写入者,执行者必须继承任务文件中的不可变 - 任务内委派不是默认流程。委派后仍保持唯一写入者,执行者必须继承任务文件中的不可变
@@ -74,6 +78,21 @@ cmbuyer 是一个自动化采购系统:**采购服务**(网页端,`admin/`
- 无论是否委派,任务所有者都对结果负责,独立审阅差异、重跑验证; - 无论是否委派,任务所有者都对结果负责,独立审阅差异、重跑验证;
**不能把执行者或工具的自我报告当成完成证据**。 **不能把执行者或工具的自我报告当成完成证据**。
## 任务状态存放在哪
任务的**协调状态**(标题、状态、标签、依赖、方案正文、执行记录)权威在自建 Vikunja 的
`cmbuyer` 项目;`docs/tasks/T-XXX.md` 里两行 `VIKUNJA EXPORT` 标记之间的内容是它的
**单向投影**,由 `scripts/vikunja_export.py` 覆盖写入,不要手工编辑。
**`write_paths`、`## 边界` 与安全边界条目的权威始终在 git**,不迁往 Vikunja。理由见
本文第 2 条:安全边界能不能收紧靠 `git diff` 逐条复核,权威一旦搬到远端,放宽边界的改动
在 diff 里只呈现为「导出内容更新」,审计链就断了。
只读任务内容不需要接入 Vikunja——导出产物就在仓库里,`git clone` 即可。只有写状态和
执行记录才需要配置 MCP(`.mcp.json` → `scripts/vikunja-mcp.sh`,凭据见 `vikunja.env.example`)。
Vikunja 不可达时按 `degraded_mode` 处理:继续手头任务,不领新任务,不写远端。
## 验证 ## 验证
按 [`docs/03-tech-stack.md`](docs/03-tech-stack.md) 第六节的验证矩阵判断层级: 按 [`docs/03-tech-stack.md`](docs/03-tech-stack.md) 第六节的验证矩阵判断层级:
+29 -1
View File
@@ -1,6 +1,6 @@
{ {
"schema": "docs/agent-context.schema.json", "schema": "docs/agent-context.schema.json",
"schema_version": 1, "schema_version": 2,
"authority": { "authority": {
"bootstrap": "local_checkout", "bootstrap": "local_checkout",
"framework_templates": "current_repository", "framework_templates": "current_repository",
@@ -57,6 +57,34 @@
"directory": "docs/tasks/", "directory": "docs/tasks/",
"template": "docs/tasks/_template.md" "template": "docs/tasks/_template.md"
}, },
"shared_documents": [
"AGENTS.md",
"README.md",
"docs/00-ai-start-here.md",
"docs/02-requirements.md",
"docs/03-tech-stack.md",
"docs/04-architecture.md",
"docs/05-coding-rules.md",
"docs/06-tasks.md",
"docs/07-user-stories.md",
"docs/08-interaction-checklist.md",
"docs/api.md",
"docs/current-state.md",
"docs/routes.md",
"docs/tasks/README.md"
],
"tracker": {
"kind": "vikunja",
"project": "cmbuyer",
"export_script": "scripts/vikunja_export.py",
"direction": "tracker_to_git",
"git_native_fields": [
"write_paths",
"context_ref",
"work_branch",
"boundaries_section"
]
},
"refresh": { "refresh": {
"context_ref": "default_branch_head_sha", "context_ref": "default_branch_head_sha",
"cache_key": "file_sha", "cache_key": "file_sha",
+26 -1
View File
@@ -11,6 +11,8 @@
"bootstrap", "bootstrap",
"routes", "routes",
"tasks", "tasks",
"shared_documents",
"tracker",
"refresh", "refresh",
"degraded_mode" "degraded_mode"
], ],
@@ -19,7 +21,7 @@
"const": "docs/agent-context.schema.json" "const": "docs/agent-context.schema.json"
}, },
"schema_version": { "schema_version": {
"const": 1 "const": 2
}, },
"authority": { "authority": {
"type": "object", "type": "object",
@@ -67,6 +69,29 @@
"template": {"$ref": "#/$defs/repositoryPath"} "template": {"$ref": "#/$defs/repositoryPath"}
} }
}, },
"shared_documents": {
"type": "array",
"minItems": 1,
"uniqueItems": true,
"items": {"$ref": "#/$defs/repositoryPath"}
},
"tracker": {
"type": "object",
"additionalProperties": false,
"required": ["kind", "project", "export_script", "direction", "git_native_fields"],
"properties": {
"kind": {"const": "vikunja"},
"project": {"type": "string", "minLength": 1},
"export_script": {"$ref": "#/$defs/repositoryPath"},
"direction": {"const": "tracker_to_git"},
"git_native_fields": {
"type": "array",
"minItems": 1,
"uniqueItems": true,
"items": {"type": "string", "minLength": 1}
}
}
},
"refresh": { "refresh": {
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,
+19
View File
@@ -26,6 +26,7 @@
1. 每个 agent 同时最多一个 `DOING` 任务。 1. 每个 agent 同时最多一个 `DOING` 任务。
2. 领取编号最小、状态 `TODO`、依赖全部 `DONE` 的任务。 2. 领取编号最小、状态 `TODO`、依赖全部 `DONE` 的任务。
3. 开工前写清 `write_paths`;与其他活跃任务路径重叠时不得并行。 3. 开工前写清 `write_paths`;与其他活跃任务路径重叠时不得并行。
`scripts/validate_agent_context.py` 会强制这一条,不通过就不是「注意一下」而是失败。
4. 记录默认分支头 `context_ref` 和工作分支。 4. 记录默认分支头 `context_ref` 和工作分支。
5. 状态改为 `DOING` 后再修改生产代码。 5. 状态改为 `DOING` 后再修改生产代码。
6. 验收全部有证据后改 `DONE`;无法继续时标 `BLOCKED` 并写清所需外部输入。 6. 验收全部有证据后改 `DONE`;无法继续时标 `BLOCKED` 并写清所需外部输入。
@@ -40,6 +41,7 @@ phase: 1
deps: [T-002] deps: [T-002]
status: TODO status: TODO
created: 2026-08-03 created: 2026-08-03
vikunja_task_id: null # Vikunja 上对应任务的 id,是两边唯一的连接键
context_ref: null context_ref: null
work_branch: null work_branch: null
needs_device: true # 是否需要真机验收 needs_device: true # 是否需要真机验收
@@ -78,6 +80,23 @@ write_paths:
- 是否触发了创建订单的动作;若触发,订单是否已产生、如何处置。 - 是否触发了创建订单的动作;若触发,订单是否已产生、如何处置。
- 安全停止证据(若涉及)。 - 安全停止证据(若涉及)。
## 混合文件格式
任务文件是 Vikunja 投影与 git 原生内容的混合体,自上而下:
1. frontmatter。`vikunja_task_id` 是连接键;`write_paths`、`context_ref`、`work_branch`
是 git 原生,导出脚本永不写入。
2. 一行 BEGIN 标记(HTML 注释,含 `id`、`synced`、`sha256`)。
3. 区块正文:问题 / 背景、关联需求与交互、方案、验收要点、执行记录。
**Vikunja 权威**,`scripts/vikunja_export.py` 整段覆盖。
4. 一行 END 标记。
5. `## 边界`。**git 权威**,脚本不触碰。
改区块内的内容要去 Vikunja 改,然后重新导出。直接手改会被 `sha256` 校验抓到并失败——
这是刻意的,两个权威同时可写就一定漂移。
尚未迁移到 Vikunja 的任务文件(没有导出区块)保持原样,门禁豁免。
## 硬规则 ## 硬规则
- **「代码写完」「看起来可以」不能作为 `DONE` 证据。** - **「代码写完」「看起来可以」不能作为 `DONE` 证据。**
+239
View File
@@ -0,0 +1,239 @@
---
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-03T08:59:53Z sha256=45757cbb73e6ff3eda9072621cd8db9ed0f5fcebcef52a33f6e253ae9f5a9e17 -->
## 问题 / 背景
多 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 就无法一次
取全。
状态用 Kanban bucket 表达,不另造字段。实测该 MCP 没有 bucket 改名工具,因此沿用
Vikunja 自动创建的英文名,不为了对齐中文表述去动 API:
| 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 字段 / bucket / label / relation |
| 问题 / 背景、方案、验收要点 | Vikunja | task `description` |
| 执行记录 | Vikunja | task comments |
| **`write_paths`** | **git** | frontmatter |
| **边界与安全边界条目** | **git** | 正文 `## 边界` |
| `context_ref`、`work_branch` | git | frontmatter |
`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 权限为全量,任何配置了此 MCP 的 agent 都能执行 `projects_delete`。须在 Vikunja 后台另签一个只覆盖 tasks、comments、labels 读写的 token 供 agent 使用,管理操作保留给人工 token。此步只能人工完成。
### 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`,把共享文档移出,改为产出建议由任务所有者合入。
在此之前 T-008 保持 `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` 已提交。
- **人工待办(未完成前不得标 `DONE`)**:在 Vikunja 后台签发窄权限 token 并替换 `vikunja.env`;确认看板视图可用。
## 执行记录
### 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 完整。
<!-- 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;确需写入时各自配置,不属本任务范围。
+1
View File
@@ -5,6 +5,7 @@ phase: 0
deps: [] deps: []
status: TODO status: TODO
created: YYYY-MM-DD created: YYYY-MM-DD
vikunja_task_id: null
context_ref: null context_ref: null
work_branch: null work_branch: null
needs_device: false needs_device: false
+195 -2
View File
@@ -4,6 +4,9 @@
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import fnmatch
import hashlib
import itertools
import json import json
import re import re
import sys import sys
@@ -19,9 +22,21 @@ REQUIRED_TOP_LEVEL = {
"bootstrap", "bootstrap",
"routes", "routes",
"tasks", "tasks",
"shared_documents",
"tracker",
"refresh", "refresh",
"degraded_mode", "degraded_mode",
} }
TRACKER_KEYS = {"kind", "project", "export_script", "direction", "git_native_fields"}
ACTIVE_STATUS = "DOING"
# 导出区块标记。区块内是 Vikunja 投影,区块外是 git 原生内容。
# sha256 覆盖两行标记之间的正文,用于发现手工改动投影。
EXPORT_BEGIN = re.compile(
r"<!-- BEGIN VIKUNJA EXPORT id=(?P<id>\d+) synced=(?P<synced>\S+) "
r"sha256=(?P<sha>[0-9a-f]{64}) -->"
)
EXPORT_END = "<!-- END VIKUNJA EXPORT -->"
REQUIRED_BOOTSTRAP = { REQUIRED_BOOTSTRAP = {
"AGENTS.md", "AGENTS.md",
"docs/00-ai-start-here.md", "docs/00-ai-start-here.md",
@@ -98,6 +113,142 @@ def find_sensitive_keys(value: Any, location: str, errors: list[str]) -> None:
find_sensitive_keys(child, f"{location}[{index}]", errors) find_sensitive_keys(child, f"{location}[{index}]", errors)
def parse_task_file(path: Path) -> dict[str, Any]:
"""抽取任务文件里门禁需要的字段。只认约定的扁平 frontmatter,不引入 YAML 依赖。"""
text = path.read_text(encoding="utf-8")
parts = text.split("---\n", 2)
front = parts[1] if len(parts) >= 3 else ""
status = re.search(r"^status:[ \t]*(\S+)", front, re.MULTILINE)
task_id = re.search(r"^vikunja_task_id:[ \t]*(\S+)", front, re.MULTILINE)
write_paths: list[str] = []
in_block = False
for line in front.splitlines():
if re.match(r"^write_paths:", line):
in_block = True
continue
if in_block:
item = re.match(r"^[ \t]+-[ \t]+(\S+)", line)
if item:
write_paths.append(item.group(1))
elif line.strip():
break
return {
"path": path,
"status": status.group(1) if status else None,
"vikunja_task_id": task_id.group(1) if task_id else None,
"write_paths": write_paths,
"text": text,
}
def find_export_markers(text: str) -> tuple[int | None, int | None, re.Match | None]:
"""按行定位导出标记,跳过围栏代码块内的同名文本。
任务文件会在代码块里举例说明区块格式,直接做子串匹配会把示例当成真标记。
"""
lines = text.splitlines(keepends=True)
in_fence = False
begin_idx = end_idx = None
begin_match = None
for index, line in enumerate(lines):
if line.lstrip().startswith("```"):
in_fence = not in_fence
continue
if in_fence:
continue
stripped = line.rstrip("\n")
if begin_idx is None:
match = EXPORT_BEGIN.fullmatch(stripped)
if match:
begin_idx, begin_match = index, match
continue
if stripped == EXPORT_END and end_idx is None:
end_idx = index
return begin_idx, end_idx, begin_match
def check_export_block(task: dict[str, Any], errors: list[str], root: Path) -> None:
"""校验导出区块未被手工改动,且与 frontmatter 的 vikunja_task_id 一致。"""
text = task["text"]
name = display_path(task["path"], root)
begin_idx, end_idx, begin = find_export_markers(text)
if begin is None:
if end_idx is not None:
errors.append(f"{name}:有 END VIKUNJA EXPORT 标记但缺少合法的 BEGIN 标记。")
# 尚未迁移的任务文件豁免,避免为让门禁转绿去改属于其他活跃任务的文件。
return
if end_idx is None:
errors.append(f"{name}:有 BEGIN VIKUNJA EXPORT 标记但缺少 END 标记。")
return
if end_idx < begin_idx:
errors.append(f"{name}:END VIKUNJA EXPORT 标记出现在 BEGIN 之前。")
return
lines = text.splitlines(keepends=True)
body = "".join(lines[begin_idx + 1 : end_idx])
actual = hashlib.sha256(body.encode("utf-8")).hexdigest()
if actual != begin.group("sha"):
errors.append(
f"{name}:导出区块内容与标记中的 sha256 不符,"
"说明投影被手工修改。请改 Vikunja 后重新导出,不要直接编辑区块内容。"
)
declared = task["vikunja_task_id"]
if declared is None:
errors.append(f"{name}:存在导出区块但 frontmatter 缺少 vikunja_task_id。")
elif declared != begin.group("id"):
errors.append(
f"{name}:frontmatter 的 vikunja_task_id={declared} "
f"与导出区块的 id={begin.group('id')} 不一致。"
)
def validate_task_files(root: Path, shared_documents: list[str]) -> list[str]:
"""任务文件层面的门禁,全部离线可跑,不访问 Vikunja。"""
errors: list[str] = []
task_dir = root / "docs" / "tasks"
if not task_dir.is_dir():
return [f"任务目录不存在:{display_path(task_dir, root)}"]
tasks = [parse_task_file(p) for p in sorted(task_dir.glob("T-*.md"))]
# 检查 1:同时活跃的任务不得写入同一路径。单写入者是并行的前提。
active = [t for t in tasks if t["status"] == ACTIVE_STATUS]
for left, right in itertools.combinations(active, 2):
overlap = sorted(set(left["write_paths"]) & set(right["write_paths"]))
if overlap:
errors.append(
f"{display_path(left['path'], root)} 与 "
f"{display_path(right['path'], root)} 同为 {ACTIVE_STATUS},"
"write_paths 重叠:" + "、".join(overlap)
)
# 检查 2:通配条目不得悄悄圈走共享文档,共享文档必须逐条显式声明。
for task in tasks:
for pattern in task["write_paths"]:
if "*" not in pattern:
continue
swallowed = sorted(
doc for doc in shared_documents if fnmatch.fnmatch(doc, pattern)
)
if swallowed:
errors.append(
f"{display_path(task['path'], root)}:write_paths 的通配条目 "
f"{pattern} 覆盖了共享文档 " + "、".join(swallowed) +
"。共享文档必须逐条显式列出,便于检查 1 判定排他。"
)
# 检查 3、4:导出区块完整性与连接键一致性。
for task in tasks:
check_export_block(task, errors, root)
return errors
def validate_manifest(root: Path) -> list[str]: def validate_manifest(root: Path) -> list[str]:
root = root.resolve() root = root.resolve()
manifest_path = root / "docs" / "agent-context.json" manifest_path = root / "docs" / "agent-context.json"
@@ -119,8 +270,8 @@ def validate_manifest(root: Path) -> list[str]:
errors.append("存在未知顶层字段:" + ", ".join(unexpected)) errors.append("存在未知顶层字段:" + ", ".join(unexpected))
if root_object.get("schema") != EXPECTED_SCHEMA: if root_object.get("schema") != EXPECTED_SCHEMA:
errors.append(f"schema 必须是 {EXPECTED_SCHEMA}。") errors.append(f"schema 必须是 {EXPECTED_SCHEMA}。")
if root_object.get("schema_version") != 1: if root_object.get("schema_version") != 2:
errors.append("schema_version 必须为 1。") errors.append("schema_version 必须为 2。")
authority = require_mapping(root_object.get("authority"), "authority", errors) authority = require_mapping(root_object.get("authority"), "authority", errors)
for key in ("bootstrap", "framework_templates", "project_facts", "coordination"): for key in ("bootstrap", "framework_templates", "project_facts", "coordination"):
@@ -155,6 +306,45 @@ def validate_manifest(root: Path) -> list[str]:
else: else:
errors.append(f"tasks.{key} 必须是非空字符串。") errors.append(f"tasks.{key} 必须是非空字符串。")
shared_documents = require_string_list(
root_object.get("shared_documents"), "shared_documents", errors
)
if not shared_documents:
errors.append("shared_documents 至少需要登记一份跨任务共享文档。")
path_values.extend((path, "shared_documents") for path in shared_documents)
tracker = require_mapping(root_object.get("tracker"), "tracker", errors)
if set(tracker) != TRACKER_KEYS:
errors.append(
"tracker 必须且只能包含 "
+ "、".join(sorted(TRACKER_KEYS))
+ "。"
)
if tracker.get("kind") != "vikunja":
errors.append("tracker.kind 目前只支持 vikunja。")
if tracker.get("direction") != "tracker_to_git":
errors.append(
"tracker.direction 必须是 tracker_to_git:"
"反向同步会重新引入两个权威,本项目不实现。"
)
if not isinstance(tracker.get("project"), str) or not tracker.get("project"):
errors.append("tracker.project 必须是非空字符串。")
export_script = tracker.get("export_script")
if isinstance(export_script, str) and export_script:
path_values.append((export_script, "tracker.export_script"))
else:
errors.append("tracker.export_script 必须是非空字符串。")
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"):
if required not in git_native:
errors.append(
f"tracker.git_native_fields 必须包含 {required}:"
"该项迁往 Vikunja 会切断安全边界的审计链。"
)
refresh = require_mapping(root_object.get("refresh"), "refresh", errors) refresh = require_mapping(root_object.get("refresh"), "refresh", errors)
expected_refresh = { expected_refresh = {
"context_ref": "default_branch_head_sha", "context_ref": "default_branch_head_sha",
@@ -177,6 +367,7 @@ def validate_manifest(root: Path) -> list[str]:
for value, name in path_values: for value, name in path_values:
validate_repo_path(root, value, name, errors) validate_repo_path(root, value, name, errors)
find_sensitive_keys(root_object, "manifest", errors) find_sensitive_keys(root_object, "manifest", errors)
errors.extend(validate_task_files(root, shared_documents))
return errors return errors
@@ -189,6 +380,8 @@ def manifest_summary(root: Path) -> tuple[int, int]:
for values in manifest["routes"].values(): for values in manifest["routes"].values():
paths.update(values) paths.update(values)
paths.update(manifest["tasks"].values()) paths.update(manifest["tasks"].values())
paths.update(manifest["shared_documents"])
paths.add(manifest["tracker"]["export_script"])
return len(manifest["routes"]), len(paths) return len(manifest["routes"]), len(paths)
+67
View File
@@ -0,0 +1,67 @@
#!/usr/bin/env bash
# Vikunja MCP 启动包装。
#
# 存在理由:
# 1. 凭据只留在 vikunja.env(已 gitignore),不进 .mcp.json、不进仓库、不进 shell 历史。
# 2. @0xk3vin/vikunja-mcp 内部自己拼 /api/v1(dist/vikunja-client.js:128),
# 而 vikunja.env 里的 apiurl 是带 /api/v1 的完整地址。直接透传会请求
# /api/v1/api/v1/... 并 404,所以这里必须剥掉一次。
# 3. 版本写死。上游改了工具名或行为会直接改变 agent 行为,与
# 「交付产物新旧以 SHA-256 判断」同源,不接受静默升级。
# 4. 优先用全局安装的二进制,起得快且不依赖网络;缺失时才回退 npx。
#
# 本脚本不含凭据,必须提交进仓库:其他 agent 与其他机器都靠它接入同一套配置。
set -euo pipefail
MCP_VERSION="${VIKUNJA_MCP_VERSION:-1.1.1}"
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# 凭据查找顺序:显式指定 > 仓库根目录 > 旧的家目录位置(向后兼容)。
find_env_file() {
if [ -n "${VIKUNJA_ENV_FILE:-}" ]; then
printf '%s\n' "$VIKUNJA_ENV_FILE"
return
fi
for candidate in "$REPO_ROOT/vikunja.env" "$HOME/.config/vikunja/env" "$HOME/.claude/vikunja.env"; do
if [ -r "$candidate" ]; then
printf '%s\n' "$candidate"
return
fi
done
printf '%s\n' "$REPO_ROOT/vikunja.env"
}
ENV_FILE="$(find_env_file)"
if [ ! -r "$ENV_FILE" ]; then
echo "vikunja-mcp: 读不到凭据文件 $ENV_FILE(参照 vikunja.env.example 创建)" >&2
exit 1
fi
# 只取需要的两个键,忽略文件里其余内容;tr -d '\r' 兼容 Windows 侧写出的 CRLF。
read_key() {
sed -n "s/^[[:space:]]*$1[[:space:]]*=[[:space:]]*//Ip" "$ENV_FILE" | tr -d '\r' | tail -n1
}
apiurl="$(read_key apiurl)"
apikey="$(read_key apikey)"
if [ -z "$apiurl" ] || [ -z "$apikey" ]; then
echo "vikunja-mcp: $ENV_FILE 缺少 apiurl 或 apikey" >&2
exit 1
fi
VIKUNJA_URL="${apiurl%/}"
VIKUNJA_URL="${VIKUNJA_URL%/api/v1}"
export VIKUNJA_URL
export VIKUNJA_API_TOKEN="$apikey"
# 全局安装存在且版本相符时直接用它;否则回退 npx(首次启动会联网拉包)。
if command -v vikunja-mcp >/dev/null 2>&1 &&
[ "$(npm ls -g --depth=0 --json @0xk3vin/vikunja-mcp 2>/dev/null |
sed -n 's/.*"version": *"\([^"]*\)".*/\1/p' | tail -n1)" = "$MCP_VERSION" ]; then
exec vikunja-mcp "$@"
fi
exec npx -y "@0xk3vin/vikunja-mcp@${MCP_VERSION}" "$@"
+476
View File
@@ -0,0 +1,476 @@
#!/usr/bin/env python3
"""把 Vikunja 上的任务内容单向导出到 docs/tasks/T-XXX.md 的标记区块。
设计约束(改动前先读 docs/tasks/T-008.md 的「方案」与「边界」):
- **单向**。本脚本只对 Vikunja 发 GET。任何写回路径都会重新引入两个权威,
因此 http_get() 把方法写死为 GET,并拒绝其余方法;--selftest 会验证这一点。
- **只写标记区块内**。区块外是 git 原生内容(write_paths、context_ref、
## 边界),承载安全边界的审计链,脚本绝不触碰。
- **只用标准库**。T-002 完成前仓库没有 Python 虚拟环境,本脚本必须能用系统
python3 直接跑通,和 validate_agent_context.py 一致。
- **未支持的 HTML 标签一律报错退出**,不静默丢弃。静默丢弃会让安全相关文字
无声消失,与本项目 fail closed 的要求冲突。
"""
from __future__ import annotations
import argparse
import hashlib
import html
import json
import re
import sys
import urllib.error
import urllib.request
from datetime import datetime, timezone
from html.parser import HTMLParser
from pathlib import Path
from typing import Any
REPO_ROOT = Path(__file__).resolve().parent.parent
MANIFEST = REPO_ROOT / "docs" / "agent-context.json"
ENV_CANDIDATES = (
REPO_ROOT / "vikunja.env",
Path.home() / ".config" / "vikunja" / "env",
Path.home() / ".claude" / "vikunja.env",
)
TIMEOUT_SECONDS = 20
EXPORT_BEGIN_TEMPLATE = (
"<!-- BEGIN VIKUNJA EXPORT id={task_id} synced={synced} sha256={sha256} -->"
)
EXPORT_END = "<!-- END VIKUNJA EXPORT -->"
EXPORT_BEGIN_RE = re.compile(
r"<!-- BEGIN VIKUNJA EXPORT id=(?P<id>\d+) synced=(?P<synced>\S+) "
r"sha256=(?P<sha>[0-9a-f]{64}) -->"
)
class ExportError(RuntimeError):
"""导出失败。一律以非零码退出,不产生半截文件。"""
# --------------------------------------------------------------------------
# 凭据与 HTTP(只读)
# --------------------------------------------------------------------------
def read_credentials() -> tuple[str, str]:
for candidate in ENV_CANDIDATES:
if candidate.is_file():
values: dict[str, str] = {}
for line in candidate.read_text(encoding="utf-8").splitlines():
line = line.strip().lstrip("")
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
values[key.strip().lower()] = value.strip()
apiurl, apikey = values.get("apiurl"), values.get("apikey")
if not apiurl or not apikey:
raise ExportError(f"{candidate} 缺少 apiurl 或 apikey。")
return apiurl.rstrip("/"), apikey
raise ExportError(
"找不到凭据文件,尝试过:"
+ "、".join(str(p) for p in ENV_CANDIDATES)
+ "(参照 vikunja.env.example 创建)"
)
def http_get(apiurl: str, apikey: str, path: str, method: str = "GET") -> Any:
"""只读取。method 参数存在是为了让「拒绝写方法」这条约束可被测试断言。"""
if method != "GET":
raise ExportError(
f"本脚本只允许 GET,收到 {method}。"
"写回 Vikunja 会重新引入两个权威,见 docs/tasks/T-008.md 的边界。"
)
request = urllib.request.Request(
f"{apiurl}{path}",
headers={"Authorization": f"Bearer {apikey}", "Accept": "application/json"},
method="GET",
)
try:
with urllib.request.urlopen(request, timeout=TIMEOUT_SECONDS) as response:
return json.loads(response.read().decode("utf-8"))
except urllib.error.HTTPError as error:
raise ExportError(f"GET {path} 返回 HTTP {error.code}。") from error
except (urllib.error.URLError, TimeoutError, OSError) as error:
raise ExportError(
f"GET {path} 连接失败:{error}。"
"Vikunja 不可达时不导出,本地投影保持上一次的内容。"
) from error
# --------------------------------------------------------------------------
# HTML -> Markdown
# --------------------------------------------------------------------------
INLINE_MARKS = {
"strong": "**",
"b": "**",
"em": "*",
"i": "*",
"del": "~~",
"s": "~~",
}
BLOCK_TAGS = {
"p", "h1", "h2", "h3", "h4", "h5", "h6", "ul", "ol", "li",
"pre", "blockquote", "table", "thead", "tbody", "tr", "th", "td", "hr", "div",
}
SUPPORTED = BLOCK_TAGS | set(INLINE_MARKS) | {"a", "code", "br", "span"}
class _Node:
__slots__ = ("tag", "attrs", "children")
def __init__(self, tag: str, attrs: dict[str, str] | None = None) -> None:
self.tag = tag
self.attrs = attrs or {}
self.children: list[Any] = []
class _TreeBuilder(HTMLParser):
"""把 Vikunja(TipTap) 产出的 HTML 建成树;遇到未知标签立即失败。"""
VOID = {"br", "hr"}
def __init__(self) -> None:
super().__init__(convert_charrefs=True)
self.root = _Node("#root")
self.stack = [self.root]
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
if tag not in SUPPORTED:
raise ExportError(
f"description 含未支持的 HTML 标签 <{tag}>。"
"不静默丢弃:请在 scripts/vikunja_export.py 显式支持它,"
"或改用已支持的写法。"
)
node = _Node(tag, {k: (v or "") for k, v in attrs})
self.stack[-1].children.append(node)
if tag not in self.VOID:
self.stack.append(node)
def handle_startendtag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
self.handle_starttag(tag, attrs)
def handle_endtag(self, tag: str) -> None:
if tag in self.VOID:
return
for index in range(len(self.stack) - 1, 0, -1):
if self.stack[index].tag == tag:
del self.stack[index:]
return
def handle_data(self, data: str) -> None:
self.stack[-1].children.append(data)
def _render_inline(node: _Node) -> str:
out: list[str] = []
for child in node.children:
if isinstance(child, str):
out.append(child.replace("\xa0", " "))
elif child.tag == "br":
out.append("\n")
elif child.tag in INLINE_MARKS:
inner = _render_inline(child).strip()
out.append(f"{INLINE_MARKS[child.tag]}{inner}{INLINE_MARKS[child.tag]}" if inner else "")
elif child.tag == "code":
out.append(f"`{_render_inline(child)}`")
elif child.tag == "a":
text = _render_inline(child).strip()
href = child.attrs.get("href", "")
out.append(f"[{text}]({href})" if href else text)
elif child.tag == "span":
out.append(_render_inline(child))
else:
out.append(_render_block(child, 0))
return "".join(out)
def _render_rows(node: _Node) -> list[list[str]]:
rows: list[list[str]] = []
for child in node.children:
if isinstance(child, str):
continue
if child.tag in {"thead", "tbody"}:
rows.extend(_render_rows(child))
elif child.tag == "tr":
cells = [
_render_inline(cell).strip().replace("\n", " ").replace("|", "\\|")
for cell in child.children
if isinstance(cell, _Node) and cell.tag in {"th", "td"}
]
rows.append(cells)
return rows
def _render_block(node: _Node, depth: int) -> str:
tag = node.tag
if tag in {"p", "div"}:
return _render_inline(node).strip()
if tag in {"h1", "h2", "h3", "h4", "h5", "h6"}:
return "#" * int(tag[1]) + " " + _render_inline(node).strip()
if tag == "hr":
return "---"
if tag == "blockquote":
inner = _render_children(node, depth)
return "\n".join(f"> {line}" if line else ">" for line in inner.split("\n"))
if tag == "pre":
text = "".join(_collect_text(child) for child in node.children)
return "```\n" + text.rstrip("\n") + "\n```"
if tag in {"ul", "ol"}:
items: list[str] = []
counter = 0
for child in node.children:
if not isinstance(child, _Node) or child.tag != "li":
continue
counter += 1
marker = "- " if tag == "ul" else f"{counter}. "
body = _render_children(child, depth + 1).strip()
pad = " " * len(marker)
lines = body.split("\n")
items.append(
marker + lines[0]
+ "".join("\n" + (pad + line if line else "") for line in lines[1:])
)
return "\n".join(items)
if tag == "table":
rows = _render_rows(node)
if not rows:
return ""
width = max(len(row) for row in rows)
rows = [row + [""] * (width - len(row)) for row in rows]
head, *body = rows
out = ["| " + " | ".join(head) + " |",
"| " + " | ".join(["---"] * width) + " |"]
out.extend("| " + " | ".join(row) + " |" for row in body)
return "\n".join(out)
return _render_inline(node).strip()
def _collect_text(node: Any) -> str:
if isinstance(node, str):
return node
return "".join(_collect_text(child) for child in node.children)
def _render_children(node: _Node, depth: int) -> str:
blocks: list[str] = []
inline_buffer: list[Any] = []
def flush() -> None:
if inline_buffer:
holder = _Node("p")
holder.children = list(inline_buffer)
text = _render_inline(holder).strip()
if text:
blocks.append(text)
inline_buffer.clear()
for child in node.children:
if isinstance(child, str):
if child.strip():
inline_buffer.append(child)
elif child.tag in BLOCK_TAGS:
flush()
rendered = _render_block(child, depth)
if rendered:
blocks.append(rendered)
else:
inline_buffer.append(child)
flush()
return "\n\n".join(blocks)
def html_to_markdown(source: str) -> str:
if not source or not source.strip():
return ""
builder = _TreeBuilder()
builder.feed(source)
builder.close()
text = _render_children(builder.root, 0)
text = re.sub(r"\n{3,}", "\n\n", text)
return text.strip()
# --------------------------------------------------------------------------
# 组装区块
# --------------------------------------------------------------------------
def build_block_body(task: dict[str, Any], comments: list[dict[str, Any]]) -> str:
parts = [html_to_markdown(task.get("description") or "")]
records: list[str] = []
for comment in comments:
author = (comment.get("author") or {}).get("username", "unknown")
created = comment.get("created", "")
body = html_to_markdown(comment.get("comment") or "")
records.append(f"### {created} · {author}\n\n{body}".rstrip())
parts.append("## 执行记录\n\n" + ("\n\n".join(records) if records else "(暂无)"))
body = "\n\n".join(part for part in parts if part.strip())
return body.strip("\n") + "\n"
def splice(original: str, task_id: int, body: str, synced: str) -> str:
"""替换标记区块,区块外内容原样保留。"""
digest = hashlib.sha256(body.encode("utf-8")).hexdigest()
begin = EXPORT_BEGIN_TEMPLATE.format(task_id=task_id, synced=synced, sha256=digest)
block = f"{begin}\n{body}{EXPORT_END}\n"
lines = original.splitlines(keepends=True)
in_fence = False
begin_idx = end_idx = None
for index, line in enumerate(lines):
if line.lstrip().startswith("```"):
in_fence = not in_fence
continue
if in_fence:
continue
stripped = line.rstrip("\n")
if begin_idx is None and EXPORT_BEGIN_RE.fullmatch(stripped):
begin_idx = index
elif stripped == EXPORT_END and end_idx is None:
end_idx = index
if begin_idx is None or end_idx is None or end_idx < begin_idx:
raise ExportError(
"目标文件缺少成对的导出标记。首次迁移时请先手工插入空区块:"
f"\n{EXPORT_BEGIN_TEMPLATE.format(task_id=task_id, synced=synced, sha256='0' * 64)}"
f"\n{EXPORT_END}"
)
return "".join(lines[:begin_idx]) + block + "".join(lines[end_idx + 1 :])
def frontmatter_task_id(text: str) -> int | None:
parts = text.split("---\n", 2)
if len(parts) < 3:
return None
match = re.search(r"^vikunja_task_id:[ \t]*(\d+)", parts[1], re.MULTILINE)
return int(match.group(1)) if match else None
# --------------------------------------------------------------------------
# 自检
# --------------------------------------------------------------------------
def selftest() -> int:
failures: list[str] = []
def check(name: str, actual: Any, expected: Any) -> None:
if actual != expected:
failures.append(f"{name}\n 实际: {actual!r}\n 期望: {expected!r}")
check("段落与粗体", html_to_markdown("<p>纸面<b>价格</b></p>"), "纸面**价格**")
check("行内代码", html_to_markdown("<p>调用 <code>SavePerson</code></p>"),
"调用 `SavePerson`")
check("链接", html_to_markdown('<p><a href="https://x/y">文档</a></p>'),
"[文档](https://x/y)")
check("无序列表", html_to_markdown("<ul><li><p>甲</p></li><li><p>乙</p></li></ul>"),
"- 甲\n- 乙")
check("有序列表", html_to_markdown("<ol><li><p>先</p></li><li><p>后</p></li></ol>"),
"1. 先\n2. 后")
check("标题", html_to_markdown("<h2>方案</h2>"), "## 方案")
check("代码块", html_to_markdown("<pre><code>go test ./...</code></pre>"),
"```\ngo test ./...\n```")
check("表格",
html_to_markdown("<table><tr><th>项</th><th>值</th></tr>"
"<tr><td>闸门</td><td>三道</td></tr></table>"),
"| 项 | 值 |\n| --- | --- |\n| 闸门 | 三道 |")
check("引用", html_to_markdown("<blockquote><p>不碰钱</p></blockquote>"), "> 不碰钱")
check("换行", html_to_markdown("<p>上<br>下</p>"), "上\n下")
check("空输入", html_to_markdown(""), "")
# 未支持标签必须失败,不得静默丢弃。
try:
html_to_markdown("<p><marquee>价格</marquee></p>")
failures.append("未支持标签 <marquee> 应当报错,但通过了。")
except ExportError:
pass
# 只读约束:任何非 GET 方法必须被拒绝。
for method in ("POST", "PUT", "PATCH", "DELETE"):
try:
http_get("http://unused.invalid", "tk_x", "/tasks/1", method=method)
failures.append(f"{method} 应当被拒绝,但通过了。")
except ExportError as error:
if "只允许 GET" not in str(error):
failures.append(f"{method} 被拒绝的原因不对:{error}")
# 幂等:同样输入两次拼接结果必须一致。
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"
f"旧内容\n{EXPORT_END}\n\n## 边界\n\n不可放宽。\n")
first = splice(template, 7, "新内容\n", "2026-08-03T00:00:00Z")
second = splice(first, 7, "新内容\n", "2026-08-03T00:00:00Z")
check("幂等", first, second)
if "## 边界" not in first or "不可放宽。" not in first:
failures.append("区块外的 git 原生内容被破坏。")
for failure in failures:
print(f"FAIL {failure}", file=sys.stderr)
print(f"自检:{'通过' if not failures else str(len(failures)) + ' 项失败'}")
return 1 if failures else 0
# --------------------------------------------------------------------------
def export_file(path: Path, apiurl: str, apikey: str, check_only: bool) -> bool:
text = path.read_text(encoding="utf-8")
task_id = frontmatter_task_id(text)
if task_id is None:
return False
task = http_get(apiurl, apikey, f"/tasks/{task_id}")
comments = http_get(apiurl, apikey, f"/tasks/{task_id}/comments") or []
body = build_block_body(task, comments)
existing = EXPORT_BEGIN_RE.search(text)
if existing and existing.group("sha") == hashlib.sha256(body.encode("utf-8")).hexdigest():
return False # 内容未变,不重写,避免噪音 diff。
synced = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
updated = splice(text, task_id, body, synced)
if check_only:
print(f"DIFF {path.resolve().relative_to(REPO_ROOT)}")
return True
path.write_text(updated, encoding="utf-8", newline="\n")
print(f"更新 {path.resolve().relative_to(REPO_ROOT)}(vikunja#{task_id})")
return True
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--file", type=Path, help="只导出指定任务文件")
parser.add_argument("--check", action="store_true", help="只报告差异,不写文件")
parser.add_argument("--selftest", action="store_true", help="离线自检,不访问网络")
args = parser.parse_args()
if args.selftest:
return selftest()
try:
apiurl, apikey = read_credentials()
targets = [args.file] if args.file else sorted(
(REPO_ROOT / "docs" / "tasks").glob("T-*.md")
)
changed = sum(export_file(p, apiurl, apikey, args.check) for p in targets)
except ExportError as error:
print(f"ERROR: {error}", file=sys.stderr)
return 1
if args.check and changed:
print(f"{changed} 个文件与 Vikunja 不同步。", file=sys.stderr)
return 1
print(f"完成:{changed} 个文件有变更。")
return 0
if __name__ == "__main__":
sys.exit(main())
+11
View File
@@ -0,0 +1,11 @@
# Vikunja 凭据样例。复制为 vikunja.env 后填写真实值。
# vikunja.env 已在 .gitignore 中,绝不提交。
#
# apiurl 写到 /api/v1 为止。scripts/vikunja-mcp.sh 会剥掉这一段再交给 MCP,
# 因为 @0xk3vin/vikunja-mcp 内部会自己拼接。
apiurl=http://vikunja.example.internal:3456/api/v1
# 供 agent 使用的 API token(Vikunja 后台 Settings → API Tokens 签发)。
# 权限只勾选 tasks / comments / labels 的读写,不要勾 projects 的删除。
# 理由:任何配置了本 MCP 的 agent 都持有此凭据,能力不需要就不要给。
apikey=tk_replace_me