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:
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"vikunja": {
|
||||||
|
"type": "stdio",
|
||||||
|
"command": "./scripts/vikunja-mcp.sh",
|
||||||
|
"args": [],
|
||||||
|
"env": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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` 证据。**
|
||||||
|
|||||||
@@ -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;确需写入时各自配置,不属本任务范围。
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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}" "$@"
|
||||||
@@ -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())
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user