From 421c865ad21c692c029f10d1af949e21d9603e1f Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Mon, 3 Aug 2026 17:00:41 +0800 Subject: [PATCH] feat(tasks): adopt Vikunja as task authority with one-way export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 把任务的协调状态迁到自建 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 --- .gitignore | 6 + .mcp.json | 10 + AGENTS.md | 19 ++ docs/agent-context.json | 30 +- docs/agent-context.schema.json | 27 +- docs/tasks/README.md | 19 ++ docs/tasks/T-008.md | 239 +++++++++++++++ docs/tasks/_template.md | 1 + scripts/validate_agent_context.py | 197 ++++++++++++- scripts/vikunja-mcp.sh | 67 +++++ scripts/vikunja_export.py | 476 ++++++++++++++++++++++++++++++ vikunja.env.example | 11 + 12 files changed, 1098 insertions(+), 4 deletions(-) create mode 100644 .mcp.json create mode 100644 docs/tasks/T-008.md create mode 100644 scripts/vikunja-mcp.sh create mode 100644 scripts/vikunja_export.py create mode 100644 vikunja.env.example diff --git a/.gitignore b/.gitignore index 97b16e7..666b6ee 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,12 @@ gitea.env.* !gitea.env.example *.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 .playwright-mcp/ *-wide.png diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..6279dc5 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,10 @@ +{ + "mcpServers": { + "vikunja": { + "type": "stdio", + "command": "./scripts/vikunja-mcp.sh", + "args": [], + "env": {} + } + } +} \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 7360bd8..58eecfd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -67,6 +67,10 @@ cmbuyer 是一个自动化采购系统:**采购服务**(网页端,`admin/` - 默认**单任务、单责任 agent、单写入者**:一个任务只有一个负责人,同时只有一个 agent 修改该任务的 `write_paths`。 - 多 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) 第六节的验证矩阵判断层级: diff --git a/docs/agent-context.json b/docs/agent-context.json index 00bce78..7459a22 100644 --- a/docs/agent-context.json +++ b/docs/agent-context.json @@ -1,6 +1,6 @@ { "schema": "docs/agent-context.schema.json", - "schema_version": 1, + "schema_version": 2, "authority": { "bootstrap": "local_checkout", "framework_templates": "current_repository", @@ -57,6 +57,34 @@ "directory": "docs/tasks/", "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": { "context_ref": "default_branch_head_sha", "cache_key": "file_sha", diff --git a/docs/agent-context.schema.json b/docs/agent-context.schema.json index b4deffe..a72850a 100644 --- a/docs/agent-context.schema.json +++ b/docs/agent-context.schema.json @@ -11,6 +11,8 @@ "bootstrap", "routes", "tasks", + "shared_documents", + "tracker", "refresh", "degraded_mode" ], @@ -19,7 +21,7 @@ "const": "docs/agent-context.schema.json" }, "schema_version": { - "const": 1 + "const": 2 }, "authority": { "type": "object", @@ -67,6 +69,29 @@ "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": { "type": "object", "additionalProperties": false, diff --git a/docs/tasks/README.md b/docs/tasks/README.md index 979b0d3..6d9b139 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -26,6 +26,7 @@ 1. 每个 agent 同时最多一个 `DOING` 任务。 2. 领取编号最小、状态 `TODO`、依赖全部 `DONE` 的任务。 3. 开工前写清 `write_paths`;与其他活跃任务路径重叠时不得并行。 + `scripts/validate_agent_context.py` 会强制这一条,不通过就不是「注意一下」而是失败。 4. 记录默认分支头 `context_ref` 和工作分支。 5. 状态改为 `DOING` 后再修改生产代码。 6. 验收全部有证据后改 `DONE`;无法继续时标 `BLOCKED` 并写清所需外部输入。 @@ -40,6 +41,7 @@ phase: 1 deps: [T-002] status: TODO created: 2026-08-03 +vikunja_task_id: null # Vikunja 上对应任务的 id,是两边唯一的连接键 context_ref: null work_branch: null 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` 证据。** diff --git a/docs/tasks/T-008.md b/docs/tasks/T-008.md new file mode 100644 index 0000000..9197471 --- /dev/null +++ b/docs/tasks/T-008.md @@ -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 +--- + + +## 问题 / 背景 + +多 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(实测读到 `

触发原因:…SavePerson`),不是 Markdown。用 `html.parser` 实现确定性转换。 +- **遇到未支持标签时报错退出,不得静默丢弃。** 静默丢弃会让安全相关文字无声消失, 与本项目 fail closed 的一贯要求冲突。 +- 幂等:内容未变时不重写文件,避免产生噪音 diff。 +- 行尾统一 LF,禁止写出 CRLF。 + +### 5. MCP 接入与凭据 + +- `scripts/vikunja-mcp.sh`:MCP 启动包装,**提交进仓库**。职责是读凭据、剥掉 `apiurl` 末尾的 `/api/v1`(`@0xk3vin/vikunja-mcp` 内部会自己拼接,不剥会请求 `/api/v1/api/v1/...` 得到 404)、导出 `VIKUNJA_URL` 与 `VIKUNJA_API_TOKEN`、 `exec` 到**版本写死**的 `@0xk3vin/vikunja-mcp@1.1.1`。优先用全局安装的二进制, 缺失时回退 `npx`。 +- 版本写死的理由:上游改工具名或行为会直接改变 agent 行为,与 `CLAUDE.md` 「交付产物新旧以 SHA-256 判断」同源,不接受静默升级。 +- 凭据文件 `vikunja.env` 放仓库根目录,比照既有 `gitea.env` 模式加入 `.gitignore`; 同时提交 `vikunja.env.example`。脚本按 `$VIKUNJA_ENV_FILE` → 仓库根 → 家目录顺序 查找,便于其他 agent 换路径。 +- `.mcp.json` 的 `command` 改为仓库内相对路径,使配置不绑定某台机器的家目录。 +- **签发窄权限 token**:当前 token 权限为全量,任何配置了此 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 转换:含 ``、``、`