feat(ack): add project knowledge guardrails

This commit is contained in:
2026-07-31 21:09:36 +08:00
parent 7d1994cf93
commit ee66dbe9ce
32 changed files with 8353 additions and 139 deletions
+637
View File
@@ -0,0 +1,637 @@
---
title: ACK 设计评审记录
date: "2026-07-31T17:15:37+08:00"
updated: "2026-07-31T20:45:09+08:00"
---
# ACK 设计评审记录
> 这是对 ACK v0.8.1 的设计评审快照,用于推动后续版本改进。文中的文件行号对应
> 2026-07-31 的仓库状态,不作为 ACK 的运行规范。
本文同时记录后续方案和实施结果。带“建议”“目标”或“待验证”的内容默认是设计
方向,不代表当前版本已经具备;实际运行契约仍以 `skills/ack/SKILL.md`、schema、
校验器和测试为准。知识护栏方案的 v0.9.0 落地边界见下文状态表。
## 背景
本轮评审覆盖 `skills/ack/` 的入口、角色规范、闭环流程、项目模板、任务板
schema、校验脚本、Orca 适配器,以及 `skiff init ack` 的真实运行路径。
评审主要回答两个问题:
- ACK 的三角色协作方向是否合理。
- 当前实现是否已经能稳定完成初始化、派发、独立复测、状态恢复和结果审计。
## 结论
ACK 的核心方向合理。显式触发、三角色分权、可观测验收、用户确认、Test 独立
复测、Coordinator 单写任务板和三轮止损都值得保留。
当前版本适合描述为“任务级受监督验收闭环”。它可以组织单个工作空间里的实现和
复测,但还没有覆盖批准状态持久化、跨 worktree 交接、编排状态恢复、目标分支集成
和交付状态,因此暂时不应把 `verified` 等同于“已集成”或“已交付”。
概念设计约为 8/10,当前可操作性约为 5 至 6/10。下一阶段应该补齐控制面契约,
不需要推翻三角色模型。
## 值得保留的设计
- Coordinator、Developer 和 Test 的职责分开,Developer 不能给自己的实现做最终
判定。
- 新需求先写产品文档、任务拆分和可观测验收信号,用户确认后再派发。
- `tasks.yaml` 由 Coordinator 单写,worker 消息只负责传递证据。
- Test 在复测前核对 worktree、服务实例和构建产物,减少测错代码和旧进程造成的
假通过。
- 每个任务最多三轮,失败后进入 `leftover`,不会无限消耗同一个 worker。
- 通用规范留在 Skill,项目只保存覆盖层与任务状态。
- 闭环核心不依赖 Orca,手动模式仍能保留相同的角色与验收逻辑。
## P0:状态和证据必须先成为可验证协议
### `verified` 目前可以没有证据
`roles-and-permissions.md` 定义的完成条件包括 Developer 白盒证据、Test 独立证据、
环境对齐和 Coordinator 终检,但任务 schema 只强制 `id``title``status`
内置校验器同样没有检查 `verified` 所需的证据。
本轮探针确认,下面这种任务可以通过内置校验:
```yaml
version: 1
project:
name: demo
tasks:
- id: T-1
title: hollow green
status: verified
```
相关位置:
- `skills/ack/templates/tasks.schema.json:76`
- `skills/ack/scripts/validate_tasks.py:70`
- `skills/ack/references/roles-and-permissions.md:140`
建议为不同状态定义语义约束。`verified` 至少要求:
- Developer 的修改文件、验证命令和结果。
- 独立 Test 的身份、逐条验收结果和原始证据。
- 被测代码或构建产物的不可变指纹。
- worktree、服务进程、Base URL 和测试时间。
- Coordinator 的 gate 结果与写入时间。
### 用户确认没有持久化
Skill 要求用户确认后再派发,但状态机从 `open` 直接进入 `dispatched`。会话在确认前
中断后,新的 Coordinator 无法知道任务是待确认,还是已经批准。
建议增加 `proposed``approved`,并记录 `approvedAt``approvedBy` 与验收版本。
### 推荐状态流
```text
proposed
-> approved
-> implementing
-> retesting
-> verified_in_workspace
-> integrated
-> verified
blocked
-> approved
-> cancelled
product_failed x 3
-> leftover
```
`verified_in_workspace` 只说明指定工作空间中的代码已经通过独立复测。只有目标分支
集成并完成集成后验证,才能进入最终 `verified`
## P0:Orca 适配器需要改成双任务模型
当前文档让 Developer 对一个 Orca task 发送 `worker_done`,然后把同一个 task 再次
派给 Test。截至评审日期,当前 Orca 的 orchestration 规则会在收到有效
`worker_done` 后自动把 task 和 dispatch 标记为 completed。第二次派发同一个 task
缺少可靠的生命周期语义。
相关位置:
- `skills/ack/references/orca-adapter.md:144`
- `skills/ack/references/orca-adapter.md:164`
- `skills/ack/references/orca-adapter.md:199`
建议每个 ACK attempt 创建两个 Orca 子任务:
```text
ACK task
Developer task
Test task, depends on Developer task
```
任务板分别保存:
```yaml
devTaskId: null
devDispatchId: null
testTaskId: null
testDispatchId: null
```
Developer 和 Test 都对自己的 dispatch 发送 `worker_done`Coordinator 最后只读
证据并执行 gate。
## P0:明确 worktree 和代码交接方式
当前流程先在 Coordinator worktree 写 PRD 和 `tasks.yaml`,随后才决定是否创建隔离
worktree。权威任务板只保留在 Coordinator worktree,但 worker prompt 又要求在自己
的 worktree 读取相对路径。Developer 的未提交代码如何传给独立的 Test worktree
也没有定义。
相关位置:
- `skills/ack/references/kickoff.md:30`
- `skills/ack/references/closed-loop.md:65`
- `skills/ack/references/closed-loop.md:87`
- `skills/ack/references/prompt-templates.md:31`
- `skills/ack/references/orca-adapter.md:74`
建议默认让 Developer 和 Test 串行使用同一个 worktree。必须隔离时,在 attempt 中
明确:
```yaml
transfer:
type: commit | patch | artifact
source: "<immutable reference>"
digest: "<sha256 or diff hash>"
```
派发内容应携带验收快照和规范的绝对路径,不能假设每个 worktree 都有同一份未提交
文档。
## P0:Worker 启动策略改为安全默认
当前校验器强制 Codex 使用 bypass、Cursor 使用 YOLO,并写死模型和 CLI。项目文档
允许收紧权限或替换模型,但合法的安全覆盖会被校验器拒绝。
校验器还只检查自由 shell 字符串里的部分 token。本轮探针确认,带有 shell 控制符
和额外命令的字符串仍可通过校验。这样的结果会给调用方错误的安全感。
相关位置:
- `skills/ack/scripts/validate_worker_command.py:49`
- `skills/ack/references/model-routing.md:98`
- `skills/ack/templates/project.template.md:31`
建议把项目覆盖层改成结构化配置:
```yaml
orchestration:
mode: orca
workers:
developer:
cli: codex
model: "<project-approved-model>"
effort: medium
permissionMode: sandbox
test:
cli: codex
model: "<project-approved-model>"
effort: low
permissionMode: sandbox
```
可信代码根据这些字段构造 argv,不接受自由 shell 拼接。安全模式默认通过,
full-access 需要用户单独授权,并把授权范围和时间写入任务板。
## P0:初始化需要原子化
`skiff init ack` 会先写 `project.md``tasks.yaml`,再运行任务板校验。缺少 PyYAML
时,命令会失败但保留两个文件。再次运行又会因为拒绝覆盖而失败。
相关位置:
- `requirements.txt:1`
- `skills/ack/scripts/validate_tasks.py:37`
- `skiff/cli.py:1144`
- `skiff/cli.py:1156`
本轮在不含第三方包的隔离 Python 环境中复现了这个状态。
建议:
- 在临时目录渲染和校验,全部通过后再原子 rename。
- 失败时只清理由本次调用创建的临时文件。
- 提供 `skiff init ack --repair` 或等价恢复路径。
- 默认模板使用 `tasks: []`,完整示例继续放在 `examples/`
- CLI 输出“脚手架已创建,待配置”,检查通过后再称为“初始化完成”。
## P1:任务板改成追加式 attempts
当前 `dispatch.rounds` 只记录轮次、结果和一段证据,无法支持恢复、并发和审计。
建议改成追加式 `attempts[]`
```yaml
attempts:
- id: "T-1-A1"
round: 1
idempotencyKey: "<stable key>"
startedAt: "<timestamp>"
workspace:
path: "<worktree>"
baseCommit: "<sha>"
diffHash: "<hash>"
developer:
worker: "<identity>"
taskId: "<runtime task id>"
dispatchId: "<runtime dispatch id>"
evidence: {}
test:
worker: "<independent identity>"
taskId: "<runtime task id>"
dispatchId: "<runtime dispatch id>"
evidence: {}
gate:
result: pending | passed | failed
failureKind: null
```
`failureKind` 建议区分:
- `product_failed`:实现不满足验收,消耗三轮预算。
- `environment_blocked`:环境或服务不可用,不消耗轮次。
- `needs_decision`:需要用户决定范围,不消耗轮次。
- `acceptance_invalid`:验收标准有误,返回 `proposed`
- `worker_lost`worker 消失,由 Coordinator 重新派发。
外部 dispatch 前先持久化 intent。Coordinator 重启后,用 `idempotencyKey` 和 runtime
ID 查询 Orca,再决定继续等待、恢复状态或重新派发。
## P1:统一 schema 和语义校验
安装 `jsonschema` 时,`validate_tasks.py` 只运行 schema;未安装时只运行内置规则。
两条路径的约束不同。schema 会放过重复 ID、四轮 dispatch 和空
`leftoverReason`,内置规则也会放过部分错误结构。
建议:
- 始终先执行 schema,再无条件执行语义 invariant。
-`structural``ready` 提供两个显式模式。
- `ready` 模式检查状态对应的证据、环境、批准和轮次。
- 显式传入的 schema 路径不存在时直接失败,不能静默降级。
- `summary``tasks` 派生,避免双写。
- 收紧 `additionalProperties`,扩展字段统一放入 `extensions`
## P1:补齐完成边界和批次验证
ACK 默认不提交、不推送。隔离 worktree 中的任务即使复测通过,也可能尚未进入目标
分支。最终报告需要明确区分:
- 已在指定工作空间验证。
- 已集成到目标分支。
- 已完成集成后回归。
- 已提交、已推送或已发布。
多个任务分别通过后,还需要一次批次级集成回归,避免组合后出现冲突或行为变化。
## P2:减少规范重复和模型漂移
- 保留一份权威状态机、一份角色权限表和一份模型档位规则。
- `kickoff.md` 只做一页运行手册,adapter 只保存工具命令。
- 具体模型名称放到可更新的映射或项目覆盖层,稳定核心只描述能力档位和升级条件。
- `ackVersion` 不能只记录旧版本。需要独立的 `schemaVersion`、兼容范围和迁移命令。
- 用真实运行数据观察首轮通过率、环境失败率、平均轮次、`leftover` 原因、耗时和
token,再决定三轮规则是否需要按任务风险调整。
- 为低风险文档或机械改动提供轻量模式,高风险和用户可见行为继续使用完整三角色
闭环。
## 低成本清理
- `skills/ack/SKILL.md:66` 的旧字段应为 `kitVersion`
- `skills/ack/templates/project.template.md:8` 建议修改或替换 `AGENTS.md`,与 Skill
的禁止规则冲突。
- `skills/ack/references/orca-adapter.md:201` 应引用 prompt §4。
- `skills/ack/scripts/validate_worker_command.py:2` 残留 “Music Pilot”。
- `skills/ack/examples/tasks.example.yaml` 缺少 checklist 要求的 `overlayFile`
- Test 负责黑盒与集成测试,Developer 负责单元测试;当前优化文档对单元测试所有权
的表述需要统一。
## 建议实施顺序
1. 收紧任务状态、批准状态和 `verified` 证据门。
2. 把 Orca adapter 改成每轮 Developer/Test 双任务。
3. 定义同 worktree 默认策略和跨 worktree transfer。
4. 改造 worker launcher 与安全授权。
5. 原子化 `skiff init ack`,空任务板作为默认模板。
6. 引入追加式 attempts、幂等恢复和失败分类。
7. 统一 schema 与语义校验,补齐对抗性 fixture。
8. 区分工作空间验证、集成验证和发布状态。
9. 去重文档,补版本迁移和运行指标。
## 验证记录
本轮执行了:
- `skiff check ack`:通过。这个命令只证明 Skill 的元数据和基础结构有效。
- `validate_tasks.py examples/tasks.example.yaml`:通过,当前机器使用内置规则。
- `validate_worker_command.py --self-test`7 项通过。
- `python3 -m unittest discover -s tests -v`58 项通过。
- 隔离全局安装 smoke:通过,安装结果为指向 ACK SSOT 的 symlink。
- 隔离初始化探针:缺少 PyYAML 时失败,留下半初始化文件,重试被拒绝。
- 对抗性任务板探针:无证据的 `verified` 可以通过。
- 对抗性 worker 命令探针:附加 shell 控制符的命令字符串可以通过。
- 当前 Orca orchestration 指南复核:有效 `worker_done` 会自动完成对应 task 和
dispatch。
现有 ACK 测试主要覆盖字符串、Skill 元数据和初始化冒烟,没有覆盖状态机语义、
schema 与 fallback 一致性、Orca 双阶段生命周期、Coordinator 崩溃恢复和跨 worktree
代码交接。
## 待验证
- [ ] 用真实项目跑一轮同 worktree 的 Developer/Test 闭环。
- [ ] 用独立 worktree 验证 commit、patch 和 artifact 三种交接方式。
- [ ] 验证 Coordinator 在 task-create、dispatch 和 Test 完成三个时间点崩溃后的
恢复行为。
- [ ] 为安全 worker profile、路径 containment 和 shell 注入增加对抗性测试。
- [ ] 定义 ACK v0.9 的 schema 迁移策略,再决定是否保留旧状态名称。
## 扩展方案:项目知识护栏库
### v0.9.0 落地边界
| 能力 | v0.9.0 状态 |
|------|-------------|
| `knowledge.yaml` 模板、schema、初始化和跨文件校验 | 已实现 |
| active/stale/superseded/archived、作用域、冲突、复查时间和来源校验 | 已实现 |
| 确定性选择、全项目规则优先、固定 revision 引用 | 已实现 |
| registry ID + 结构化 argv + fd 固定根目录的安全检查入口 | 已实现 |
| Developer candidate、Test check、Coordinator 单写的协作约定 | 已实现为协议和校验字段 |
| 不同 revision 的历史内容不可篡改或可独立恢复 | 延后;v0.9.0 没有外部不可变账本 |
| 依赖或路径变化后自动转 stale | 延后;当前由 `reviewAfter` 和人工复核驱动 |
| 在运行时强制只有获授权身份能写入或激活知识 | 延后;当前依赖 Coordinator 单写边界 |
| 自动识别任意提示注入、PII 或所有秘密 | 延后;当前只有结构禁区和常见秘密模式 |
| `approved`、追加式 attempts、崩溃恢复和最终集成 gate | 延后到控制面改造 |
### 定位
ACK 需要增加第三类项目事实,用来保存跨任务复用、会改变后续开发或验证行为的已
验证知识。它不保存聊天记忆,也不承担项目 Wiki 的职责。
| 项目事实 | 保存内容 | 写入者 |
|----------|----------|--------|
| `project.md` | 相对稳定的项目配置、路径和命令 | Coordinator 或项目维护者 |
| `tasks.yaml` | 当前任务状态、attempt 和执行证据 | Coordinator |
| `knowledge.yaml` | 跨任务复用的已验证经验 | Coordinator |
建议新增项目级 SSOT`docs/ack/knowledge.yaml`。知识归项目所有,与 Orca 等编排
工具无关;ACK 负责在任务闭环中生产、选择和消费这些知识。
这项设计会修改当前“`docs/ack/` 只保存 `project.md``tasks.yaml`”的边界。
新增文件保存项目事实,不复制 ACK Skill 的通用规范,因此不违反 Skill 内容仍以
`skills/ack/` 为 SSOT 的原则。
### 知识类型
第一版只支持三类知识:
| 类型 | 含义 | 例子 |
|------|------|------|
| `guardrail` | 必须执行或明确禁止的项目约束 | 修改数据库迁移时必须验证回滚 |
| `pitfall` | 已证实的失败模式、触发条件和避免方法 | Test 连到了另一个 worktree 的旧服务 |
| `verification` | 特定条件下必须增加的检查 | 修改缓存键后执行跨版本兼容测试 |
架构决策正文继续写入 ADR 或正式规格,知识项只引用决策及其适用条件。任务进度和
单次失败证据继续留在 `tasks.yaml`。通用且可跨项目复用的规则应回流 ACK Skill,
不能作为某个项目的知识长期保存。
### 生命周期
```text
Developer / Test 发现经验
-> candidate
-> Test 独立验证 + Coordinator gate
-> active
-> stale
-> superseded | archived
```
- Developer 和 Test 只能提交 candidate,不能直接写入或激活知识。
- candidate 保存在当前 task 或 attempt 的证据中,不参与后续任务的自动选择。
- 根因得到证实、修复通过独立 Test、Coordinator 完成 gate 后,candidate 才能转为
`active`
- 全项目范围的 `must``never` 或权限类规则需要 User 或 Decision Owner 确认。
- 依赖、配置、路径或适用版本发生变化后,相关知识转为 `stale`,默认不再派发。
- 新知识替代旧知识时必须记录 `supersedes`,不能静默改写历史。
- 临时 workaround 必须有复查时间和移除条件,不能无限期保持 `active`
同一 Agent 不能把自己读到的旧知识直接作为新证据再次激活。新的 candidate 必须
包含当前任务产生的独立观测,避免形成自我强化的错误闭环。
### 最小数据结构
每条知识至少包含:
```yaml
id: K-001
revision: 1
kind: pitfall
status: active
title: "复测前确认服务来自当前 worktree"
scope:
components: [web]
paths: ["web/**"]
dependencies: []
versions: []
tags: [long-running-service]
appliesWhen: "修改常驻 Web 服务或前端构建产物"
directive: "复测前重启服务,并核对服务实例对应的 commit"
rationale: "曾因复测旧进程产生假通过"
verification:
ref: service-worktree-alignment
expected: "服务实例、worktree 和 commit 一致"
provenance:
taskId: BUG-017
attemptId: BUG-017-A2
codeRef: "<commit or tree hash>"
evidenceRef: "tasks.yaml#BUG-017"
owner: "<owner>"
author: "<candidate author>"
reviewer: "<independent reviewer>"
createdAt: "<timestamp>"
lastValidatedAt: "<timestamp>"
reviewAfter: "<timestamp or null>"
removalCondition: null
supersedes: []
conflictsWith: []
```
`revision` 是逻辑版本标识。修改知识的约束含义时必须新增 revision,并保留被引用
的旧条目;任务引用 `K-001@1` 后,可以说明当时选择的逻辑版本。v0.9.0 尚未用
content hash、签名或外部账本封存条目内容,因此不能只凭这个字符串证明历史字节
不可变;项目需要把状态纳入版本控制,强不可变审计留待后续实现。
`directive` 只能描述应采取的动作。需要执行命令时,`verification.ref` 应引用项目中
已审查的测试或检查 ID,由可信配置解析成结构化 argv。知识库不保存可自动执行的
自由 shell 命令。
### 与三角色闭环的集成
| 角色 | 权限和职责 |
|------|------------|
| Coordinator | 单写 `knowledge.yaml`;选择本轮适用知识;激活、废弃和处理冲突 |
| Developer | 只读本轮知识;回报遵守情况;提交 `knowledgeCandidates` |
| Test | 只读本轮知识;执行额外检查;独立验证 candidate |
| User / Decision Owner | 批准全项目强制规则、权限规则和无法通过测试证明的政策 |
`prepare(task)` 时,Coordinator 按 component、path、dependency、version、tag 和失败
特征筛选 `active` 知识。匹配结果只作为候选,Coordinator 确认后把固定版本引用
写入本轮上下文:
```yaml
knowledgeRefs:
- K-001@1
- K-014@2
```
引入前文建议的追加式 `attempts[]` 后,引用和回报建议放在:
```text
attempts[].context.knowledgeRefs
attempts[].developer.knowledgeApplied
attempts[].developer.knowledgeCandidates
attempts[].test.knowledgeChecks
```
派发时把已引用知识的必要内容内联到 Developer 和 Test 的 prompt,避免 worker 因
worktree 不同而读不到 Coordinator worktree 中未提交的知识文件。每次只派发当前
任务命中的少量条目,不全量注入知识库。
Developer 回报实际遵守了哪些知识,以及新发现的 candidate。Test 对每条适用的
`verification` 回报 pass、fail 和证据。要求执行的知识检查没有覆盖时,
Coordinator 不得把任务写成 `verified_in_workspace``verified`
### 检索和冲突规则
第一版使用确定性匹配,不引入向量数据库、Embedding 或语义 RAG
- 先按 path、component、dependency、version 和 tag 选择条目。
- 失败排查时可以额外匹配错误签名和相关 symbol。
- 只返回 `active` 且适用条件成立的条目。
- 设置条目数和上下文预算,详情按需读取。`scope.all=true` 的全项目规则优先占用
预算;全项目规则本身超过预算时显式失败,不能静默丢弃。
- `stale``superseded``archived` 默认不返回。
- 没有匹配结果只表示本轮没有找到知识,不能据此声称项目没有相关约束。
同一 subject 和 scope 不能存在互相矛盾的 `active` 条目。校验器发现冲突时应阻止
进入 ready 状态,由 Coordinator 或 Decision Owner 选择保留项并记录
`supersedes`。运行时不能使用“最后写入者获胜”解决冲突。
自动匹配只负责推荐。Coordinator 写入当前 attempt 的显式 `knowledgeRefs` 才是
本轮权威上下文。这个边界可以降低作用域标注不准造成的漏选和误选。
### 知识晋升后的去向
知识库不是所有经验的最终归宿。不同内容应继续进入对应的权威载体:
| 内容 | 最终位置 |
|------|----------|
| 当前任务状态和单次验证证据 | `tasks.yaml` |
| 稳定项目路径、命令和运行配置 | `project.md` |
| 架构或产品决策及其取舍 | ADR 或正式规格 |
| 安全、正确性和兼容性约束 | 测试、lint、CI 或正式规范 |
| 原始日志、截图和构建产物 | 受控 artifact 存储 |
| 跨项目通用规则 | ACK Skill |
| 项目特有且跨任务复用的经验 | `knowledge.yaml` |
关键 `verification` 应逐步转成可执行测试或 CI gate。知识项继续保存触发条件、原因
和证据引用,不能用自然语言规则替代可执行控制。
### 禁止写入的内容
- Token、密钥、Cookie、含凭据 URL、生产数据、PII、客户内容和未脱敏日志。
- 系统提示、开发者提示、角色或权限覆盖,以及绕过审批、测试和安全限制的指令。
- 可自动执行的破坏性命令或生产命令。
- 原始聊天、整段 issue 或网页内容、巨量日志、截图和构建产物。
- 未复现猜测、LLM 单方推断、一次性巧合和个人评价。
- PID、临时端口、个人 worktree 绝对路径等短命机器状态。
- 对代码、配置和正式文档的重复抄写。
- 没有作用域、证据和失效条件的 `always``never` 或 workaround。
- 需要权限隔离的漏洞 PoC、敏感内部拓扑和安全调查材料。
issue、日志和外部网页只能作为不可信 evidence。进入知识库前必须提炼为可审查的
项目结论,不能原样晋升,也不能获得高于用户指令、批准规格、当前代码、配置和测试
的优先级。
### 第一版范围
第一版只实现一个 `docs/ack/knowledge.yaml`,不拆目录。活跃条目达到几十条、单文件
开始影响审阅和选择时,再平滑迁移为 `docs/ack/knowledge/index.yaml` 加独立知识卡,
条目 schema 和引用格式保持不变。
第一版包括:
1. `knowledge.yaml` schema 与语义校验。
2. task 或 attempt 的 `knowledgeRefs`
3. Developer 的 `knowledgeApplied``knowledgeCandidates`
4. Test 的 `knowledgeChecks`
5. Coordinator 在 prepare、gate 和任务收尾三个节点处理知识。
6. 基于 scope 的确定性匹配。
7. stale、冲突、revision 和 supersedes 校验。
第一版不包括:
- 自动抓取或总结全部对话。
- Agent 自动激活、删除或提交知识。
- 向量数据库、Embedding 和语义 RAG。
- 自动改写 `AGENTS.md``CLAUDE.md` 或其它 Agent 指令文件。
- 从知识正文直接执行 shell 命令。
- 组织级、跨仓库知识同步。
### 目标验收与当前状态
| 目标 | v0.9.0 状态 |
|------|-------------|
| candidate 不会作为 active 派发 | 已实现 |
| 未授权 worker 不能激活、废弃或删除知识 | 协议约束;运行时身份授权延后 |
| path、component、version 只返回匹配条目 | 已实现确定性匹配;准确率仍取决于 scope 标注 |
| 非 active 条目默认不返回 | 已实现 |
| 冲突 active 规则被拒绝 | 已实现显式冲突和相同 subject/scope 检查 |
| 历史 attempt 的知识内容不可变 | 延后;当前只有 revision 约定,没有不可变账本 |
| `verification.ref` 无法解析时校验失败 | 已实现 |
| evidence 中的提示注入不会变成命令 | 部分实现;禁止自由执行字段,语义晋升仍需人工 gate |
| 秘密和生产数据不能进入知识库 | 部分实现;常见秘密模式会阻断,不能替代完整 DLP |
| 依赖、路径变化后自动转 stale | 延后;当前依赖复查日期和 Coordinator |
| 增长后仍在预算内且不漏全项目规则 | 已实现;全项目规则超预算时显式失败 |
### 与 ACK 改造顺序的关系
知识护栏库最终仍依赖可信的任务状态、attempt、证据来源和 ready 校验。v0.9.0 先
落地存储、选择和基础 gate,不能据此声称前述控制面问题已经解决。后续应继续:
1. 确定知识 schema、生命周期和角色权限。
2. 在 attempt 中固定 `knowledgeRefs` 和 candidate 证据。
3. 接入 Coordinator 的匹配、派发和晋升流程。
4. 增加 stale、冲突、权限、注入和敏感信息的对抗性 fixture。
5. 选一个真实项目试运行,再根据活跃条目数量决定是否拆分存储。
这套方案最依赖 scope 标注的质量。项目知识如果很难用组件、路径、依赖、版本或
触发条件限定,自动匹配会漏掉关键项或产生大量噪声。这类知识应继续由人维护在 ADR
或项目文档中,不能强行进入自动派发流程。
### 参考依据
- [GitHub 仓库自定义指令](https://docs.github.com/en/copilot/how-tos/copilot-on-github/customize-copilot/add-custom-instructions/add-repository-instructions)
区分仓库级、路径级和就近生效的 Agent 指令。
- [Claude Code 项目记忆](https://code.claude.com/docs/zh-CN/memory)区分持久指令与
自动记忆,并建议把较大的内容按作用域拆分、按需加载。
- [Architectural Decision Records](https://adr.github.io/)用于保存单个重要决策的
理由、取舍和后果,适合承载不应混入项目知识库的决策正文。
+455 -12
View File
@@ -3,9 +3,15 @@
from __future__ import annotations
import argparse
import ctypes
import errno
import os
import re
import secrets
import stat
import subprocess
import sys
import tempfile
from datetime import datetime
from pathlib import Path
@@ -63,6 +69,20 @@ from skiff.sources import (
from skiff.yaml_io import safe_dump
from skiff.symlinks import check_link, copy_template, create_link, find_repo_root, remove_link
_RENAME_NOREPLACE = 1
def _encode_single_path_component(value: str) -> bytes:
encoded = os.fsencode(value)
if (
not encoded
or encoded in {b".", b".."}
or b"/" in encoded
or b"\0" in encoded
):
raise ValueError(f"必须是单一路径组件: {value!r}")
return encoded
def _print(msg: str = "") -> None:
print(msg, file=sys.stdout)
@@ -78,11 +98,146 @@ def _project_root(explicit: str | None = None) -> Path:
return find_repo_root() or Path.cwd()
def _render_template(source: Path, destination: Path, values: dict[str, str]) -> None:
def _render_template(
source: Path,
destination: Path,
values: dict[str, str],
) -> str:
content = source.read_text(encoding="utf-8")
for placeholder, value in values.items():
content = content.replace(placeholder, value)
destination.write_text(content, encoding="utf-8")
return content
def _open_or_create_directory_at(
parent_fd: int,
name: str,
) -> tuple[int, bool]:
if (
not name
or name in {".", ".."}
or Path(name).name != name
or "/" in name
or "\\" in name
or (os.altsep is not None and os.altsep in name)
):
raise SystemExit(f"初始化目录名必须是单个安全路径段: {name!r}")
created = False
try:
os.mkdir(name, dir_fd=parent_fd)
created = True
except FileExistsError:
pass
try:
directory_fd = os.open(
name,
os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW,
dir_fd=parent_fd,
)
except OSError as exc:
raise SystemExit(
f"初始化路径必须是普通目录且不能是软链接: {name}: {exc}"
) from exc
return directory_fd, created
def _rename_directory_noreplace(
source_parent_fd: int,
source_name: str,
destination_parent_fd: int,
destination_name: str,
) -> None:
"""Atomically publish a directory without replacing an existing path."""
source = _encode_single_path_component(source_name)
destination = _encode_single_path_component(destination_name)
if source == destination:
raise ValueError("暂存目录名与目标目录名不能相同")
try:
renameat2 = ctypes.CDLL(None, use_errno=True).renameat2
except (AttributeError, OSError) as exc:
raise SystemExit(
"当前平台缺少原子 no-replace 目录发布能力,拒绝执行初始化"
) from exc
renameat2.argtypes = [
ctypes.c_int,
ctypes.c_char_p,
ctypes.c_int,
ctypes.c_char_p,
ctypes.c_uint,
]
renameat2.restype = ctypes.c_int
ctypes.set_errno(0)
result = renameat2(
source_parent_fd,
source,
destination_parent_fd,
destination,
_RENAME_NOREPLACE,
)
if result == 0:
return
error_number = ctypes.get_errno()
if error_number in {errno.EEXIST, errno.ENOTEMPTY}:
raise FileExistsError(
error_number,
os.strerror(error_number),
destination_name,
)
if error_number in {
errno.ENOSYS,
errno.EINVAL,
getattr(errno, "ENOTSUP", errno.EOPNOTSUPP),
errno.EOPNOTSUPP,
}:
raise SystemExit(
"当前文件系统不支持原子 no-replace 目录发布,拒绝执行初始化"
)
if error_number == 0:
raise RuntimeError("renameat2 失败但未设置 errno")
raise OSError(
error_number,
os.strerror(error_number),
f"{source_name} -> {destination_name}",
)
def _assert_open_directory_path(
directory_fd: int,
path: Path,
*,
phase: str,
label: str = "项目目录",
) -> None:
"""Fail if a named directory no longer resolves to the opened inode."""
opened = os.fstat(directory_fd)
try:
current = os.stat(path, follow_symlinks=False)
except OSError as exc:
raise SystemExit(f"{phase}{label}已移动或不可访问: {path}") from exc
if (
not stat.S_ISDIR(current.st_mode)
or (opened.st_dev, opened.st_ino) != (current.st_dev, current.st_ino)
):
raise SystemExit(f"{phase}{label}已被替换: {path}")
def _directory_entry_matches_open_fd(
parent_fd: int,
name: str,
opened_fd: int,
) -> bool:
try:
current = os.stat(name, dir_fd=parent_fd, follow_symlinks=False)
except OSError:
return False
opened = os.fstat(opened_fd)
return (
stat.S_ISDIR(current.st_mode)
and (current.st_dev, current.st_ino) == (opened.st_dev, opened.st_ino)
)
def _collect_skill_names(positional: list[str] | None, flagged: list[str] | None) -> list[str]:
@@ -1130,17 +1285,35 @@ def cmd_doctor(args: argparse.Namespace) -> None:
def cmd_init(args: argparse.Namespace) -> None:
"""使用 builtin skill 自带的模板初始化目标项目状态。"""
ensure_skills_home()
skill_source = SKILLS_DIR / args.name
validate_skill_name(args.name)
skills_root = SKILLS_DIR.resolve()
skill_source = (SKILLS_DIR / args.name).resolve()
try:
skill_source.relative_to(skills_root)
except ValueError as exc:
raise SystemExit(f"builtin skill 路径逃逸仓库边界: {args.name}") from exc
if not (skill_source / "SKILL.md").is_file():
raise SystemExit(f"builtin skill 不存在: {args.name}")
project = _project_root(args.project)
if not project.is_dir():
try:
initial_project_stat = os.stat(project, follow_symlinks=False)
except OSError as exc:
raise SystemExit(f"项目目录不存在: {project}") from exc
if not stat.S_ISDIR(initial_project_stat.st_mode):
raise SystemExit(f"项目目录不存在: {project}")
initial_project_identity = (
initial_project_stat.st_dev,
initial_project_stat.st_ino,
stat.S_IFMT(initial_project_stat.st_mode),
)
destination = project / "docs" / args.name
project_file = destination / "project.md"
tasks_file = destination / "tasks.yaml"
managed_targets = (project_file, tasks_file)
knowledge_file = destination / "knowledge.yaml"
managed_targets = [project_file, tasks_file]
if args.name == "ack":
managed_targets.append(knowledge_file)
existing = [path for path in managed_targets if path.exists() or path.is_symlink()]
if existing:
paths = ", ".join(str(path.relative_to(project)) for path in existing)
@@ -1148,12 +1321,29 @@ def cmd_init(args: argparse.Namespace) -> None:
project_template = skill_source / "templates" / "project.template.md"
tasks_template = skill_source / "templates" / "tasks.template.yaml"
missing = [path for path in (project_template, tasks_template) if not path.is_file()]
template_targets = [
(project_template, project_file),
(tasks_template, tasks_file),
]
if args.name == "ack":
template_targets.append(
(skill_source / "templates" / "knowledge.template.yaml", knowledge_file)
)
missing = [path for path, _ in template_targets if not path.is_file()]
if missing:
paths = ", ".join(str(path.relative_to(SKILLS_HOME)) for path in missing)
raise SystemExit(f"skill 缺少初始化模板: {paths}")
destination.mkdir(parents=True, exist_ok=True)
validator = skill_source / "scripts" / "validate_tasks.py"
knowledge_validator = skill_source / "scripts" / "validate_knowledge.py"
if args.name == "ack":
missing_validators = [
path
for path in (validator, knowledge_validator)
if not path.is_file()
]
if missing_validators:
paths = ", ".join(path.name for path in missing_validators)
raise SystemExit(f"ACK skill 缺少初始化校验器: {paths}")
version_file = skill_source / "VERSION"
ack_version = version_file.read_text(encoding="utf-8").strip() if version_file.is_file() else "unknown"
@@ -1167,17 +1357,270 @@ def cmd_init(args: argparse.Namespace) -> None:
"<接入时的 ack skill 版本>": ack_version,
"<YYYY-MM-DDTHH:mm:ss+TZ>": now,
}
_render_template(project_template, project_file, values)
_render_template(tasks_template, tasks_file, values)
validator = skill_source / "scripts" / "validate_tasks.py"
if validator.is_file():
subprocess.run([sys.executable, str(validator), str(tasks_file)], check=True)
with tempfile.TemporaryDirectory(prefix=f"skiff-{args.name}-init-") as temp_dir:
staging = Path(temp_dir)
staged_files: dict[Path, Path] = {}
rendered_files: dict[Path, str] = {}
for template, target in template_targets:
staged = staging / target.relative_to(project)
staged.parent.mkdir(parents=True, exist_ok=True)
rendered_files[target] = _render_template(template, staged, values)
staged_files[target] = staged
if validator.is_file():
completed = subprocess.run(
[sys.executable, str(validator), str(staged_files[tasks_file])],
check=False,
)
if completed.returncode != 0:
raise SystemExit(
f"初始化任务板校验失败(exit {completed.returncode}"
)
if args.name == "ack" and knowledge_validator.is_file():
completed = subprocess.run(
[
sys.executable,
str(knowledge_validator),
str(staged_files[knowledge_file]),
"--tasks",
str(staged_files[tasks_file]),
"--project-root",
str(staging),
],
check=False,
)
if completed.returncode != 0:
raise SystemExit(
f"初始化知识库校验失败(exit {completed.returncode}"
)
for target, staged in staged_files.items():
if staged.read_text(encoding="utf-8") != rendered_files[target]:
raise SystemExit(
f"初始化临时文件在校验期间发生变化: {target.name}"
)
# Validation may take time, so guard against a concurrent initializer before writing.
existing = [path for path in managed_targets if path.exists() or path.is_symlink()]
if existing:
paths = ", ".join(str(path.relative_to(project)) for path in existing)
raise SystemExit(f"拒绝覆盖已有路径: {paths}")
project_fd: int | None = None
docs_fd: int | None = None
transaction_fd: int | None = None
staging_fd: int | None = None
docs_created = False
transaction_name: str | None = None
staged_names: list[str] = []
published = False
committed = False
try:
try:
project_fd = os.open(
project,
os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW,
)
except OSError as exc:
raise SystemExit(
f"校验期间项目目录已移动或不可访问: {project}"
) from exc
opened_project_stat = os.fstat(project_fd)
opened_project_identity = (
opened_project_stat.st_dev,
opened_project_stat.st_ino,
stat.S_IFMT(opened_project_stat.st_mode),
)
if opened_project_identity != initial_project_identity:
raise SystemExit(f"校验期间项目目录已被替换: {project}")
_assert_open_directory_path(
project_fd,
project,
phase="初始化",
)
docs_fd, docs_created = _open_or_create_directory_at(project_fd, "docs")
if docs_created:
os.fsync(project_fd)
_assert_open_directory_path(
project_fd,
project,
phase="初始化",
)
_assert_open_directory_path(
docs_fd,
project / "docs",
phase="初始化",
label="docs 目录",
)
try:
destination_stat = os.stat(
args.name,
dir_fd=docs_fd,
follow_symlinks=False,
)
except FileNotFoundError:
pass
else:
if stat.S_ISLNK(destination_stat.st_mode):
raise SystemExit(
"初始化路径必须是普通目录且不能是软链接: "
f"docs/{args.name}"
)
raise SystemExit(f"拒绝覆盖已有路径: docs/{args.name}")
for _ in range(32):
candidate = f".{args.name}-init-{secrets.token_hex(8)}"
try:
os.mkdir(candidate, mode=0o700, dir_fd=docs_fd)
except FileExistsError:
continue
transaction_name = candidate
break
if transaction_name is None:
raise SystemExit("无法创建唯一的初始化暂存目录")
transaction_fd = os.open(
transaction_name,
os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW,
dir_fd=docs_fd,
)
os.mkdir("payload", mode=0o755, dir_fd=transaction_fd)
staging_fd = os.open(
"payload",
os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW,
dir_fd=transaction_fd,
)
for target in staged_files:
file_fd = os.open(
target.name,
os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW,
0o644,
dir_fd=staging_fd,
)
staged_names.append(target.name)
with os.fdopen(file_fd, "w", encoding="utf-8") as destination_file:
destination_file.write(rendered_files[target])
destination_file.flush()
os.fsync(destination_file.fileno())
os.fsync(staging_fd)
os.fsync(transaction_fd)
_assert_open_directory_path(
project_fd,
project,
phase="发布",
)
_assert_open_directory_path(
docs_fd,
project / "docs",
phase="发布",
label="docs 目录",
)
try:
_rename_directory_noreplace(
transaction_fd,
"payload",
docs_fd,
args.name,
)
except FileExistsError as exc:
raise SystemExit(
f"拒绝覆盖已有路径: docs/{args.name}"
) from exc
published = True
_assert_open_directory_path(
staging_fd,
destination,
phase="发布",
label="ACK 目录",
)
if (
transaction_name is not None
and _directory_entry_matches_open_fd(
docs_fd,
transaction_name,
transaction_fd,
)
):
try:
os.rmdir(transaction_name, dir_fd=docs_fd)
except OSError:
pass
else:
transaction_name = None
try:
os.fsync(docs_fd)
except OSError as exc:
raise SystemExit(
"初始化目录已完整发布,但无法确认目录项持久化;"
f"请检查 docs/{args.name} 后再重试"
) from exc
_assert_open_directory_path(
project_fd,
project,
phase="完成初始化",
)
_assert_open_directory_path(
docs_fd,
project / "docs",
phase="完成初始化",
label="docs 目录",
)
_assert_open_directory_path(
staging_fd,
destination,
phase="完成初始化",
label="ACK 目录",
)
committed = True
except BaseException:
if not published:
for name in reversed(staged_names):
try:
if staging_fd is not None:
os.unlink(name, dir_fd=staging_fd)
except FileNotFoundError:
pass
if transaction_fd is not None:
try:
os.rmdir("payload", dir_fd=transaction_fd)
except OSError:
pass
if (
transaction_name is not None
and transaction_fd is not None
and docs_fd is not None
and _directory_entry_matches_open_fd(
docs_fd,
transaction_name,
transaction_fd,
)
):
try:
os.rmdir(transaction_name, dir_fd=docs_fd)
except OSError:
pass
raise
finally:
for directory_fd in (
staging_fd,
transaction_fd,
docs_fd,
project_fd,
):
if directory_fd is not None:
os.close(directory_fd)
if not committed:
raise SystemExit("初始化事务未提交")
_print(f"✓ skill 项目状态初始化完成: {args.name}")
_print(f" 项目: {project}")
_print(f" 覆盖层: {project_file}")
_print(f" 任务板: {tasks_file}")
if args.name == "ack":
_print(f" 知识库: {knowledge_file}")
_print("下一步: 填写 project.md 中的项目命令、路径权限和 Base URL")
+52 -5
View File
@@ -37,7 +37,8 @@ skiff init ack --project ~/code/my-app
```text
docs/ack/
├── project.md
── tasks.yaml
── tasks.yaml
└── knowledge.yaml
```
不会在项目中复制或链接 ACK Skill。通用规范、模板和脚本始终从已安装的 Skill
@@ -51,23 +52,69 @@ skills/ack/
├── README.md
├── VERSION
├── references/ # 三角色规范、闭环流程和初始化说明
├── templates/ # project.mdtasks.yaml 模板和 schema
├── templates/ # project.mdtasks.yaml、knowledge.yaml 模板和 schema
├── examples/ # 完整示例
└── scripts/ # tasks.yaml 与 worker 命令校验器
└── scripts/ # 状态校验、知识选择、安全验证执行与 worker 命令工具
```
`SKILL.md` 是 Agent 的工作流入口。`references/` 是按需读取的稳定规范;
`docs/ack/project.md` 只保存当前项目的命令、路径和权限差异;
`docs/ack/tasks.yaml` 保存当前任务状态
`docs/ack/tasks.yaml` 保存当前任务状态`docs/ack/knowledge.yaml` 保存跨任务复用、
已经独立验证的项目知识护栏。
## 检查任务板
## 检查项目状态
Agent 会从当前 ACK Skill 目录解析校验脚本:
```bash
python3 <ack-skill-dir>/scripts/validate_tasks.py docs/ack/tasks.yaml
python3 <ack-skill-dir>/scripts/validate_knowledge.py docs/ack/knowledge.yaml \
--tasks docs/ack/tasks.yaml
```
Coordinator 可以按当前任务上下文做确定性推荐:
```bash
python3 <ack-skill-dir>/scripts/select_knowledge.py docs/ack/knowledge.yaml \
--component web --path web/app.py --tag long-running-service --limit 10
```
默认 JSON 输出会同时给出固定知识引用和已解析的 `verificationTarget.path/args`
选择器只输出数据,不执行检查。`scope.all=true` 的全项目 active 规则优先占用
`--limit`;如果全项目规则本身超过预算,选择器会显式失败,不会静默漏派。
需要执行知识项引用的检查时,只传 registry ID 给 ACK 的安全执行入口:
```bash
python3 <ack-skill-dir>/scripts/run_verification.py \
docs/ack/knowledge.yaml check-api-contract --project-root <project-root>
```
该入口会在执行前重新校验知识库,只打开一次项目根目录 fd,再从同一个 fd 逐段以
`O_NOFOLLOW` 打开知识库和检查文件;检查内容复制到匿名、尽可能 sealed 的稳定
快照,再以结构化 argv 和 `shell=False` 启动。它不接受临时命令或额外参数。
选择器输出的 path/args 只用于审阅,不应由 Agent 自行拼接执行。Runner 只读取
项目内无 symlink 的权威
`docs/ack/knowledge.yaml`,不接受替代知识文件或放宽后的项目根。检查进程的 cwd
`ACK_PROJECT_ROOT` 都固定到该根 fd;后者是只在检查进程存活期间有效的
`/proc/self/fd/...``/dev/fd/...` 路径。原始可读路径另放在
`ACK_PROJECT_ROOT_DISPLAY`,只能用于日志,不能用于资源访问。Runner 还提供
`ACK_VERIFICATION_REF``ACK_VERIFICATION_PATH`;检查脚本必须据此定位资源,
不能依赖 `$0``__file__` 所在目录,因为实际执行的是匿名快照。
知识先由 Developer 或 Test 作为 `candidate` 提名,经独立验证和 Coordinator gate
后才能成为 `active`。Coordinator 按路径、组件、依赖、版本和标签推荐相关知识,
确认后将固定 revision 的 `knowledgeRefs` 写入任务上下文;每轮只派发命中的少量
条目,不全量注入知识库。
旧项目只有 `project.md``tasks.yaml` 时,不要重跑初始化。由 `/ack` 检查现有
状态,获得用户授权后补一个空的 `knowledge.yaml`;如果任务板尚未声明知识库,
同时只补 `project.knowledgeFile: docs/ack/knowledge.yaml`,再运行跨文件校验。
只有 Coordinator 写 `tasks.yaml``knowledge.yaml`。知识正文不能作为自由 shell
执行;关键约束应继续下沉到测试、lint、CI 或正式规范。ACK 不自动修改项目的
`AGENTS.md``CLAUDE.md` 或其它 Agent 指令文件。
## 开始一个需求
初始化完成后可以直接说:
+48 -17
View File
@@ -9,8 +9,8 @@ description: >-
# ACK 项目协作入口
本 Skill 是 ACK 的完整能力包:`references/` 保存通用规范,`templates/` 保存项目
状态模板,`scripts/` 保存校验工具。目标项目只在 `docs/ack/` 保存 `project.md`
`tasks.yaml`,不要复制或链接 Skill 内容。
状态模板,`scripts/` 保存校验工具。目标项目只在 `docs/ack/` 保存 `project.md`
`tasks.yaml``knowledge.yaml`,不要复制或链接 Skill 内容。
开始时解析当前 `SKILL.md` 所在目录,记为 `<ack-skill-dir>`。所有通用规范、模板和
脚本都相对此目录访问,不依赖固定的全局安装路径。
@@ -36,8 +36,12 @@ description: >-
该命令从本 Skill 的 `templates/` 生成项目状态,不会在项目中创建 Skill
软链接或资源副本。
3. 如果 `docs/ack` 已存在,不重复初始化、不覆盖文件;转入“检查”,报告缺失项并
只补用户授权且能安全确定的内容。
3. 如果 `docs/ack` 已存在,不重复初始化、不覆盖文件;转入“检查”。旧项目只有
`project.md` 与 `tasks.yaml` 时,先报告缺少 `knowledge.yaml`。用户授权后,
从 `templates/knowledge.template.yaml` 生成这个缺失文件并替换项目名和时间;若
`tasks.yaml` 尚无 `project.knowledgeFile`,同时只补
`docs/ack/knowledge.yaml` 这一项。不要重跑 `skiff init`,也不要改写其它已有
项目状态。
4. 读取项目的公开配置和文档,例如 README、语言清单、包管理清单、测试配置与
CI,确定项目名、技术栈、源码/规格/测试路径及真实可执行命令。
5. 完善 `docs/ack/project.md`
@@ -47,15 +51,21 @@ description: >-
- 只写项目差异,不复制 `references/` 中的通用规范。
6. 完善 `docs/ack/tasks.yaml` 的项目信息。纯初始化且用户没有提供真实任务时,
删除模板示例任务并保留 `tasks: []`;不要虚构需求或缺陷。
7. 更新 `updatedAt`,并运行:
7. 检查 `docs/ack/knowledge.yaml`。新项目没有已验证的项目经验时保留
`verificationRegistry: {}` 与 `entries: []`,不从聊天、README 或单次失败中
猜测并激活知识。
8. 更新 `updatedAt`,并运行:
```bash
python3 <ack-skill-dir>/scripts/validate_tasks.py docs/ack/tasks.yaml
python3 <ack-skill-dir>/scripts/validate_knowledge.py docs/ack/knowledge.yaml \
--tasks docs/ack/tasks.yaml
```
8. 检查 `project.md``tasks.yaml` 是否仍有 `<...>` 占位符。结构校验通过且
必填项目事实完整时才称“初始化完成”;否则称“部分完成”并列出缺失值。
9. 报告创建的路径、检测到的命令、校验结果和下一步。除非用户明确要求,不提交、
9. 检查 `project.md``tasks.yaml` 与 `knowledge.yaml` 是否仍有 `<...>` 占位符。
结构校验通过且必填项目事实完整时才称“初始化完成”;否则称“部分完成”并列出
缺失值。
10. 报告创建的路径、检测到的命令、校验结果和下一步。除非用户明确要求,不提交、
不推送。
## 检查
@@ -63,13 +73,18 @@ description: >-
1. 检查以下路径:
- `docs/ack/project.md`
- `docs/ack/tasks.yaml`
- `docs/ack/knowledge.yaml`
2. 读取 `<ack-skill-dir>/VERSION`,对比 `tasks.yaml` 的 `ackVersion`。旧项目只有
`ackVersion` 时仍可读取,但建议迁移为 `ackVersion`。
`kitVersion` 时仍可读取,但建议迁移为 `ackVersion`。
3. 查找未替换占位符,并核对项目路径、覆盖层路径、Developer 白盒命令、Test
黑盒命令和 Base URL。
4. 使用 `<ack-skill-dir>/scripts/validate_tasks.py` 校验任务板。只报告证据明确的
问题,不因可选字段缺失而宣称失败。
5. 检查不会自动修复或覆盖现有配置;用户明确要求修复后再修改。
4. 使用 `<ack-skill-dir>/scripts/validate_tasks.py` 校验任务板,使用
`<ack-skill-dir>/scripts/validate_knowledge.py docs/ack/knowledge.yaml --tasks
docs/ack/tasks.yaml` 校验项目知识和跨文件引用。只报告证据明确的问题,不因可选
字段缺失而宣称失败。
5. 检查知识引用能解析到固定 revision,candidate 仍留在任务证据中,且
`stale`、`superseded` 和 `archived` 不会被当作可派发的 `active` 知识。
6. 检查不会自动修复或覆盖现有配置;用户明确要求修复后再修改。
## 工作
@@ -77,16 +92,28 @@ description: >-
2. 依次读取:
- `docs/ack/project.md`
- `docs/ack/tasks.yaml`
- 通过 `<ack-skill-dir>/scripts/select_knowledge.py` 从
`docs/ack/knowledge.yaml` 选择的当前任务相关 `active` 条目
- `<ack-skill-dir>/references/kickoff.md`
- kickoff 指定且与当前任务相关的 references 文件
3. 当前会话担任 Coordinator,遵守项目覆盖层中的命令、路径权限、模型路由和
worker 复用规则。项目覆盖层优先于通用示例命令。
worker 复用规则。项目覆盖层优先于通用示例命令。按 scope 推荐相关 `active`
知识,经确认后把固定 revision 的显式 `knowledgeRefs` 写入当前任务上下文;
不全量注入知识库。
4. 新需求先写产品文档、任务拆分与可观测验收信号,更新 `tasks.yaml` 并校验,
然后交给用户确认;确认前不派发实现。
5. 用户已确认的任务按 ACK 闭环执行:Developer 实现与白盒验证,Test 独立黑盒
复测,Coordinator 读取证据终检并唯一写入 `tasks.yaml`。
6. 不把 `worker_done` 或 Test 自报成功直接当作完成。每项最多三轮,仍失败则记录
复测,Coordinator 读取证据终检并唯一写入 `tasks.yaml`。Developer 回报
`knowledgeApplied` 和 `knowledgeCandidates`Test 回报 `knowledgeChecks`
`candidate` 只有在独立验证和 gate 后才能由 Coordinator 写入或激活。
6. 执行知识项的 `verification.ref` 时,只调用
`<ack-skill-dir>/scripts/run_verification.py docs/ack/knowledge.yaml
<verification-ref> --project-root <project-root>`。不要直接执行选择器返回的 path/args,
也不要给 runner 注入额外命令或参数。
7. 不把 `worker_done` 或 Test 自报成功直接当作完成。每项最多三轮,仍失败则记录
`leftover` 并继续其它任务。
8. 关键的安全、正确性和兼容性约束应下沉为测试、lint、CI 或正式规范;
`knowledge.yaml` 只保存触发条件、原因与证据引用,不能替代可执行控制。
## 边界
@@ -94,5 +121,9 @@ description: >-
- 不在项目中维护第二份 ACK 通用规范、模板或任务 schema。
- 不猜测项目命令、服务地址、worker handle 或模型名称。
- 不覆盖已有 `docs/ack` 文件,不擅自提交、推送、创建终端或新 worktree。
- 项目只保存 `docs/ack/project.md` 和 `docs/ack/tasks.yaml`通用资源始终从当前
ACK Skill 目录读取
- 只有 Coordinator 写 `tasks.yaml` 和 `knowledge.yaml`Developer 与 Test 只读,
只能通过回报提名或验证知识
- 不把知识正文或选择器输出拼成 shell;知识检查只能通过 `run_verification.py`
按 registry ID 执行。不自动修改 `AGENTS.md`、`CLAUDE.md` 或其它 Agent 指令文件。
- 项目只保存 `docs/ack/project.md`、`docs/ack/tasks.yaml` 和
`docs/ack/knowledge.yaml`;通用资源始终从当前 ACK Skill 目录读取。
+1 -1
View File
@@ -1 +1 @@
0.8.1
0.9.0
@@ -0,0 +1,47 @@
version: 1
updatedAt: "2026-07-31T12:00:00+08:00"
project:
name: "notes-web"
verificationRegistry:
service-worktree-alignment:
path: "tests/ack/check_service_worktree.py"
args: ["--require-current-commit"]
entries:
- id: "K-001"
revision: 1
kind: "verification"
status: "active"
title: "复测前确认服务与 worktree/commit 对齐"
subject: "service-worktree-alignment"
scope:
all: false
components: ["web"]
paths: ["web/**"]
dependencies: []
versions: []
tags: ["long-running-service"]
symbols: []
errorSignatures: []
appliesWhen: "修改常驻 Web 服务或前端构建产物"
directive: "复测前重启服务,并核对服务实例对应的 worktree 与 commit"
rationale: "历史任务曾因 Test 连接旧进程而产生假通过"
verification:
ref: "service-worktree-alignment"
expected: "服务实例、worktree 和 commit 一致"
provenance:
taskId: "BUG-002"
attemptId: "BUG-002-A2"
codeRef: "abc1234"
evidenceRef: "tasks.yaml#BUG-002"
owner: "web-team"
author: "developer"
reviewer: "test"
approval: null
createdAt: "2026-07-30T10:00:00+08:00"
lastValidatedAt: "2026-07-31T11:00:00+08:00"
reviewAfter: "2099-10-31T11:00:00+08:00"
temporary: false
removalCondition: null
statusReason: null
supersedes: []
conflictsWith: []
+16 -4
View File
@@ -1,9 +1,10 @@
# notes-web Agent 协作协议(示例,项目覆盖层)
> 本项目基于 ack v0.8.1
> 本项目基于 ack v0.9.0
> 通用规范由 `/ack` 从 Skill 自身的 `references/` 读取,本文件只填项目差异。
> 覆盖层文件放在 `docs/ack/project.md`,不占用 `AGENTS.md`。
> `docs/ack/` 只保存 `project.md` 与 `tasks.yaml`
> ACK 不会自动修改 `AGENTS.md`、`CLAUDE.md` 或其它 Agent 指令文件
> `docs/ack/` 只保存 `project.md`、`tasks.yaml` 与 `knowledge.yaml`。
## 项目概览
@@ -12,6 +13,7 @@
- 运行命令:`npm run dev`(前端)、`go run ./server`(后端)
- Base URL`http://localhost:5173`
- 任务板:`docs/ack/tasks.yaml`
- 项目知识:`docs/ack/knowledge.yaml`
- 覆盖层文件:`docs/ack/project.md`
## 稳定规范(引用,不重复)
@@ -44,6 +46,7 @@
| `config/*.example.*` | Read-only | Read-only | R/W | 可提交配置模板 |
| `.env``config/local.*` | Read-only | Read-only | Read-only | 本地私有配置 |
| `tasks.yaml` | R/W | Read-only | Read-only | 只有 Coordinator 写 |
| `knowledge.yaml` | R/W | Read-only | Read-only | 只有 Coordinator 写;Developer/Test 通过回报提名或验证 |
## 命令
@@ -63,13 +66,22 @@ curl -s -X POST http://localhost:5173/api/fix/preview -d @fixtures/preview.json
# 浏览器回归:tests/browser/cases/*.md
```
任务板校验由 `/ack` 使用 Skill 自带的 `scripts/validate_tasks.py` 执行。
项目已审查的知识检查入口保存在 `knowledge.yaml.verificationRegistry`,格式为检查
ID 对应仓库内相对 path 和结构化 args。知识正文不保存或自动执行自由 shell 命令。
执行时只把检查 ID 交给 Skill 的 `scripts/run_verification.py`,不直接拼接
path/args。
项目状态校验由 `/ack` 使用 Skill 自带的 `scripts/validate_tasks.py`
`scripts/validate_knowledge.py` 执行。
## 硬规则(其余见 references/
- 三角色独立:Coordinator 只编排、Test 只验证、Developer 只实现。
- 模型分层:Coordinator 强模型不跑测试,Test/Developer 中低模型(见 references/model-routing.md)。
- `worker_done` 与复测报告都不等于完成,必须 Test 独立复测 + Coordinator 终检后才能 `verified`
- 只有 Coordinator 写 `tasks.yaml`Test 与 Developer 只读。
- 只有 Coordinator 写 `tasks.yaml``knowledge.yaml`Test 与 Developer 只读。
- Coordinator 只派发按 scope 命中并显式写入 `knowledgeRefs``active` 知识;
`candidate` 不派发,知识库不全量注入。
- Developer 回报 `knowledgeApplied``knowledgeCandidates`Test 回报
`knowledgeChecks`;关键约束应继续下沉到测试、lint、CI 或正式规范。
- 每个任务最多派发 3 轮,仍不过标记 `leftover` 并继续。
- 不提交或推送,除非用户明确要求。
+39 -1
View File
@@ -2,12 +2,14 @@
version: 1
updatedAt: "2026-07-06T09:40:00+08:00"
source: "Coordinator (PM) Agent"
ackVersion: "0.8.1"
ackVersion: "0.9.0"
project:
name: "notes-web"
repoPath: "/home/dev/notes-web"
baseUrl: "http://localhost:5173"
devWorktree: "/home/dev/notes-web-wt/fix-preview"
overlayFile: "docs/ack/project.md"
knowledgeFile: "docs/ack/knowledge.yaml"
summary:
verified: ["BUG-002"]
@@ -27,6 +29,19 @@ tasks:
- "docs/spec/fix-preview.md#preview"
testRefs:
- "tests/browser/cases/01-preview.md"
knowledgeRefs:
- "K-001@1"
knowledgeApplied:
- ref: "K-001@1"
result: "applied"
evidence: "重启服务并核对 commit 后再交给 Test"
knowledgeCandidates: []
knowledgeChecks:
- ref: "K-001@1"
result: "passed"
evidence: "服务实例、worktree 与 commit 9f8e7d6 一致"
checkedBy: "test-worker-1"
checkedAt: "2026-07-06T09:36:00+08:00"
description: >
用户在 /fix 页点击“预览变更”后,确认区不渲染 API 返回的 diff。
stepsToReproduce:
@@ -55,9 +70,11 @@ tasks:
worker: "dev-worker-1"
rounds:
- round: 1
attemptId: "BUG-002-A1"
result: failed
evidence: "只渲染了 title,缺少 before/after/coverChanged"
- round: 2
attemptId: "BUG-002-A2"
result: passed
evidence: "复测 4 行 diff 全部出现,取消不触发写入"
resolution:
@@ -76,6 +93,24 @@ tasks:
specRefs:
- "docs/spec/concurrency.md"
testRefs: []
knowledgeRefs: []
knowledgeApplied: []
knowledgeCandidates:
- kind: "pitfall"
title: "乐观锁检查必须覆盖批量导入路径"
claim: "只在 API 写入路径检查版本号会漏掉批量导入"
scope:
components: ["server-store"]
paths: ["server/store/**"]
tags: ["concurrency"]
appliesWhen: "修改 note 的并发写入或批量导入逻辑"
directive: "同时验证 API 与批量导入的版本冲突处理"
rationale: "第二轮修复只覆盖 API,批量导入仍可静默覆盖"
evidenceRefs:
- "tasks.yaml#BUG-003-round-2"
proposedBy: "test-worker-1"
proposedAt: "2026-07-06T09:32:00+08:00"
knowledgeChecks: []
description: >
两个会话同时保存同一条 note 时,后写覆盖先写,无冲突提示。
stepsToReproduce:
@@ -100,12 +135,15 @@ tasks:
worker: "dev-worker-1"
rounds:
- round: 1
attemptId: "BUG-003-A1"
result: failed
evidence: "加了版本号但未在写入路径校验"
- round: 2
attemptId: "BUG-003-A2"
result: failed
evidence: "校验只覆盖 API,未覆盖批量导入路径"
- round: 3
attemptId: "BUG-003-A3"
result: failed
evidence: "乐观锁与前端重试逻辑冲突,需重新设计"
resolution:
+27 -3
View File
@@ -4,7 +4,9 @@
- [ ] ACK Skill 已全局安装或安装到当前项目。
- [ ] 已运行 `skiff init ack --project <project-root>`
- [ ] `docs/ack/` 只包含项目自己的 `project.md``tasks.yaml`
- [ ] `docs/ack/` 只包含项目自己的 `project.md``tasks.yaml``knowledge.yaml`
- [ ] 旧项目缺少 `knowledge.yaml` 时,只补空文件及缺失的
`project.knowledgeFile` 指针,没有重跑初始化或覆盖其它项目状态。
- [ ] 项目中没有 ACK Skill 的复制目录或 `kit``framework` 软链接。
- [ ] `tasks.yaml` 使用 `ackVersion` 记录 ACK Skill 版本。
@@ -12,6 +14,8 @@
- [ ] `project.md` 只保存项目差异,不复制 Skill 的通用规范。
- [ ] `tasks.yaml``project.overlayFile` 指向实际覆盖层。
- [ ] `tasks.yaml``project.knowledgeFile` 固定为
`docs/ack/knowledge.yaml`
- [ ] 技术栈、运行、构建、单测和集成测试命令均来自项目证据。
- [ ] Coordinator、Developer、Test 的模型档位和升级规则已明确。
@@ -22,6 +26,7 @@
- [ ] Developer 可写源码与单元测试,但不能改规格或黑盒验收。
- [ ] 私有配置只读且不提交。
- [ ] `tasks.yaml` 只有 Coordinator 写。
- [ ] `knowledge.yaml` 只有 Coordinator 写;Developer 与 Test 只通过回报提名或验证。
## 任务板
@@ -30,12 +35,30 @@
- [ ] 真实任务的验收是可观测信号。
- [ ] 已运行 `<ack-skill-dir>/scripts/validate_tasks.py` 并通过。
## 项目知识
- [ ] 新项目没有已验证知识时使用 `verificationRegistry: {}``entries: []`
不虚构 active 条目。
- [ ] 已运行 `<ack-skill-dir>/scripts/validate_knowledge.py` 并通过;同时校验任务引用。
- [ ] Coordinator 只从 `active` 条目按 scope 推荐知识,并显式确认固定 revision 的
`knowledgeRefs`
- [ ] `candidate` 留在任务证据中,不派发;`stale``superseded``archived`
不默认选择。
- [ ] 每轮只内联命中的少量知识,不全量注入 `knowledge.yaml`
- [ ] Developer 回报 `knowledgeApplied``knowledgeCandidates`Test 回报
`knowledgeChecks`
- [ ] `verification.ref` 只引用 `verificationRegistry` 中已审查的仓库内相对 path
和结构化 args;执行时只把 registry ID 交给 `run_verification.py`,不从
知识正文或选择器输出拼接 shell。
- [ ] 关键约束已规划下沉到测试、lint、CI 或正式规范。
- [ ] ACK 不自动修改 `AGENTS.md``CLAUDE.md` 或其它 Agent 指令文件。
## 编排
- [ ] 已选择 Orca 或手动模式。
- [ ] 派发前优先复用同 worktree、同角色、同配置的空闲 worker。
- [ ] Developer 与 Test 的启动命令通过校验。
- [ ] 多 worktree 场景只有一个权威 `tasks.yaml`
- [ ] 多 worktree 场景只有一个权威 `tasks.yaml``knowledge.yaml`
- [ ] Test 使用的服务来自正确 worktree。
## 闭环
@@ -48,4 +71,5 @@
首次接入建议选择一个低风险问题跑完整闭环。项目差异写回
`docs/ack/project.md`;通用问题回流到 ACK Skill 的 `references/``templates/`
`scripts/`,并更新 `VERSION`
`scripts/`,并更新 `VERSION`项目特有、跨任务复用且已经验证的经验才写入
`docs/ack/knowledge.yaml`
+71 -11
View File
@@ -2,7 +2,10 @@
本文件定义**与具体编排工具无关**的三角色协作闭环。运行时调度可以用 Orca(见 `orca-adapter.md`),也可以手动跑(见下方「手动模式」)。
原则:调度消息只是运行时载体,**所有结论必须回写到 `tasks.yaml`**(事实源),不要把消息当最终记录。角色定义见 `roles-and-permissions.md`Coordinator 只编排、Test 只验证、Developer 只实现。
原则:调度消息只是运行时载体,任务结论必须回写到 `tasks.yaml`;跨任务复用、已经
验证的项目经验必须由 Coordinator 回写到 `knowledge.yaml`。不要把消息当最终记录。
角色定义见 `roles-and-permissions.md`Coordinator 只编排、Test 只验证、
Developer 只实现。
---
@@ -12,12 +15,12 @@
| 抽象动作 | 含义 | Orca 实现 | 手动实现 |
|----------|------|-----------|----------|
| `prepare(task)` | Coordinator 把任务和验收信号写进 `tasks.yaml` | 同左 | 同左 |
| `prepare(task)` | Coordinator 把任务和验收信号写进 `tasks.yaml`,按 scope 推荐知识并确认显式 `knowledgeRefs` | 同左 | 同左 |
| `dispatch(task, developer)` | 把修复任务连同上下文交给 Developer | `orca orchestration dispatch` | 复制 prompt 到 Developer 终端/会话 |
| `dispatch(task, test)` | 把复测任务连同验收信号交给 Test | `orca orchestration dispatch` | 复制 prompt 到 Test 终端/会话 |
| `wait()` | 等待 `worker_done` / `retest_result` / `escalation` / `decision_gate` | `orca orchestration check --wait` | 人工等待回报 |
| `gate(task)` | Coordinator 读 Test 证据并对齐原始意图(不重测) | 同左 | 同左 |
| `writeback(task, result)` | Coordinator 把结果写回 `tasks.yaml` | 同左 | 同左 |
| `gate(task)` | Coordinator 读 Test 证据`knowledgeChecks` 并对齐原始意图(不重测) | 同左 | 同左 |
| `writeback(task, result)` | Coordinator 把任务结果写回 `tasks.yaml`,把验证通过的跨任务经验按权限写入 `knowledge.yaml` | 同左 | 同左 |
派发用的 prompt 见 `prompt-templates.md`。状态流转见 `roles-and-permissions.md` §「任务状态机」。**独立复测由 Test 执行,不是 Coordinator**Coordinator 只做读证据的终检。
@@ -28,13 +31,15 @@
```text
Coordinator 发现或读取 open 任务
-> prepare:写/补全 tasks.yaml 验收信号
-> 从 knowledge.yaml 按 scope 推荐 active 知识,Coordinator 确认固定 revision 的 knowledgeRefs
-> 为新轮次生成稳定 attemptId<task-id>-A<round>),Developer 与 Test 共用
-> 决定 worktree:当前 worktree 起子 agent,还是新建隔离 worktree(见下节「子任务放哪」)
-> 解析 worker:先复用同 worktree、同角色的空闲 worker;没有可复用项时才校验命令并新建(见 orca-adapter.md
-> dispatch 给 Developer--to <worker handle>
-> waitDeveloper 的 worker_done / escalation
-> waitDeveloper 的 worker_done / escalation(含 knowledgeApplied / knowledgeCandidates
-> writeback fixed_by_dev
-> dispatch 给 Testretesting
-> waitTest 的 retest_result
-> waitTest 的 retest_result(含 knowledgeChecks 和 candidate 独立证据)
-> Test 通过:gateCoordinator 读证据对齐意图)
-> 通过 gatewriteback verified
-> gate 不满足意图:writeback failed_retest,带意图差异再派发 Developer
@@ -44,6 +49,51 @@ Coordinator 发现或读取 open 任务
一次派发只修一个明确问题(细则见 `optimization-method.md` §「每轮派发只修一个明确问题」)。
「决定 worktree」「解析 worker」两步的决策见下节与 `model-routing.md` / `orca-adapter.md`
Coordinator 默认给新逻辑轮次生成 `<task-id>-A<round>`,并在记录轮次结果时写入
`tasks[].dispatch.rounds[].attemptId`。它独立于编排工具产生的 `taskId`
`dispatchId`:前者用于知识来源追溯,后两者只用于查询运行时。0.9.0 之前的历史
轮次允许暂时缺省;但要把该轮证据晋升为知识前,必须按确定性格式补齐。晋升时,
`provenance.taskId``provenance.attemptId` 必须能精确回到该任务及轮次。
### 知识选择与晋升
`prepare(task)` 时可以使用 Skill 自带的 `scripts/select_knowledge.py`
component、path、dependency、version 和 tag 确定性推荐条目。选择器只返回
`active`,并受条目数限制;`stale``superseded``archived` 不默认派发。自动
匹配只是推荐。条目中每个非空 scope 维度都必须被任务上下文命中,因此调用时应
提供当前任务已知的全部 component、path、dependency、version、tag、symbol 和错误
特征。Coordinator 确认并写入当前任务的显式 `knowledgeRefs` 后才成为本轮权威
上下文。
`scope.paths` 使用 POSIX 路径 glob`*` 不跨目录分隔符,`**` 才能跨目录。多个
pattern 是同一维度内的 OR,候选越多表示范围越宽;超过选择预算时,选择器优先保留
命中维度更多、OR 候选更少、字面约束更多且通配符更少的条目。
派发时只内联本轮引用的少量知识,不要求 worker 全量读取 `knowledge.yaml`。这也
避免隔离 worktree 读不到 Coordinator 工作树中尚未提交的项目状态。
进行中的任务只能引用 `active` 条目。已经进入 `verified``leftover` 的终态任务
可以继续保留固定 revision 的历史引用,即使对应知识后来变成 `stale`
`superseded``archived`;这些旧条目不会再次被选择器派发。
Developer 或 Test 发现经验时只能通过 `knowledgeCandidates` 提名,并带上当前任务
的独立观测、scope、触发条件和证据。candidate 留在任务证据中,不进入知识选择器,
也不能被下一轮当作 active 指令。只有 Test 独立验证、Coordinator gate 通过后,
Coordinator 才能写入或更新 `knowledge.yaml`;全项目强制或权限类规则还需
User / Decision Owner 确认。
Test 对显式引用回报 `not_applicable` 时,说明 Coordinator 的选择需要纠正。该引用
仍在 `knowledgeRefs` 中就不能把任务标为 `verified`Coordinator 应记录原因、移除
误选引用并重新校验,或继续补充验证。
知识中的 `verification.ref` 只能引用
`knowledge.yaml.verificationRegistry` 里已审查的仓库内相对 path 和结构化 args,
不能从知识正文拼接或执行自由 shell。Developer/Test 只把 `verification.ref` 交给
`<ack-skill-dir>/scripts/run_verification.py`;该入口从项目根目录逐段安全打开检查
文件,拒绝 symlink 路径,并从匿名稳定快照执行。关键约束应下沉为测试、lint、CI
或正式规范。检查进程从固定的项目根 fd cwd 运行;检查脚本应使用 cwd 或
`ACK_PROJECT_ROOT` 定位资源。后者是 fd 路径,原始展示路径只在
`ACK_PROJECT_ROOT_DISPLAY` 中用于日志;不能依赖 `$0` / `__file__` 的目录。
---
@@ -84,7 +134,10 @@ orca terminal create --worktree path:<new> --command "cursor-agent --yolo --mode
| 要保持基线分支干净 | 新 worktreefeature 分支)|
| 小改动、追求快 | 当前 worktree |
**任务板SSOT)只落一处**:无论开几个 worktree`tasks.yaml` 只认一个权威副本(通常在基线/协调所在 worktree),由 Coordinator 单写。不要每个 worktree 各留一份会分叉的任务板。模型固定方式见 `model-routing.md``orca-adapter.md`
**项目状态SSOT)只落一处**:无论开几个 worktree`tasks.yaml`
`knowledge.yaml` 都只认一个权威副本(通常在基线/协调所在 worktree),由
Coordinator 单写。不要每个 worktree 各留一份会分叉的项目状态。模型固定方式见
`model-routing.md``orca-adapter.md`
---
@@ -93,14 +146,17 @@ orca terminal create --worktree path:<new> --command "cursor-agent --yolo --mode
没有编排工具时,闭环不变,只是 `dispatch` / `wait` 由人工承担:
1. Coordinator 在 `tasks.yaml` 写好任务和验收信号。
2. `prompt-templates.md` §1 的初始派发模板生成 prompt,手动发给 Developer(另一个会话/终端/人)。
2. `knowledge.yaml` 推荐相关 active 知识,Coordinator 确认固定 revision 的
`knowledgeRefs`;用 `prompt-templates.md` §1 的初始派发模板生成 prompt,
手动发给 Developer(另一个会话/终端/人)。
3. Developer 完成后按 worker_done 模板回报。
4. Coordinator 写回 `fixed_by_dev`,用 `prompt-templates.md` §3 的复测派发模板把任务发给 Test。
5. Test 独立复测后按复测报告模板回报证据。
5. Test 独立复测后按复测报告模板回报证据`knowledgeChecks`
6. Coordinator 做终检并回写 `tasks.yaml`:通过 `verified`,不过 `failed_retest`
7. 失败则用「复测失败再派发模板」重新发给 Developer,最多累计三轮。
手动模式下同样遵守:worker_done / 复测报告都不等于最终结论、只有 Coordinator`tasks.yaml`、三轮失败留档。
手动模式下同样遵守:worker_done / 复测报告都不等于最终结论、只有 Coordinator
`tasks.yaml``knowledge.yaml`、三轮失败留档。
---
@@ -125,7 +181,9 @@ actual:
snapshot evidence:
```
Test 只回传证据,不写 `tasks.yaml`;由 Coordinator 落盘。
Test 只回传证据,不写 `tasks.yaml``knowledge.yaml`;由 Coordinator 落盘。
每条适用的显式 `knowledgeRef` 还应报告 `passed``failed`
`not_applicable` 及证据。
---
@@ -134,6 +192,8 @@ Test 只回传证据,不写 `tasks.yaml`;由 Coordinator 落盘。
Test 报通过后,Coordinator 不重测,而是做一次读证据的终检:
- Test 证据是否覆盖了任务的**每一条**验收信号(见 `optimization-method.md` §1)。
- 当前任务显式 `knowledgeRefs` 的必需 `knowledgeChecks` 是否全部覆盖,检查引用
是否能解析到项目已审查的入口。
- 结果是否符合任务的**原始意图**,而不只是通过了字面文案。
- 运行环境是否对齐(见下方「服务与 worktree 对齐」)。
+40 -7
View File
@@ -11,8 +11,10 @@
2. ACK Skill 已全局安装或安装到当前项目。
3. `skiff` 命令可用。
不要覆盖已有的 `docs/ack/project.md``docs/ack/tasks.yaml``AGENTS.md` 或其它
Agent 指令文件。不要把 token、`.env` 内容或其它私有配置写入 ACK 项目状态。
不要覆盖已有的 `docs/ack/project.md``docs/ack/tasks.yaml`
`docs/ack/knowledge.yaml``AGENTS.md` 或其它 Agent 指令文件。ACK 不会自动
修改 `AGENTS.md``CLAUDE.md` 或其它 Agent 指令文件。不要把 token、`.env`
内容或其它私有配置写入 ACK 项目状态。
## 初始化
@@ -33,12 +35,23 @@ skiff init ack --project <project-root>
```text
docs/ack/
├── project.md
── tasks.yaml
── tasks.yaml
└── knowledge.yaml
```
如果任一目标文件已经存在,命令会拒绝覆盖。初始化过程不会创建 `kit`
`framework` 或其它指向 Skill 的软链接。
### 旧项目补充知识库
旧项目已经有 `project.md``tasks.yaml`、但没有 `knowledge.yaml` 时,不要重跑
`skiff init ack`。先检查现有文件并向用户报告缺失项;用户授权后,只从
`templates/knowledge.template.yaml` 生成 `docs/ack/knowledge.yaml`,替换项目名和
当前时间,保留 `entries: []`。如果现有任务板缺少
`project.knowledgeFile`,同一次授权只补
`knowledgeFile: docs/ack/knowledge.yaml`,不改写其它项目状态。生成后运行任务板、
知识库和跨文件引用校验。
## 完善项目覆盖层
编辑 `docs/ack/project.md`,填入:
@@ -57,35 +70,55 @@ docs/ack/
- `ackVersion` 使用 ACK Skill 的 `VERSION`
- `updatedAt` 使用当前带时区时间。
- `project.name``repoPath``devWorktree``overlayFile` 使用真实值。
- `project.name``repoPath``devWorktree``overlayFile` `knowledgeFile` 使用
真实值。
- 非服务项目的 `baseUrl` 写为 `n/a`
- 没有真实任务时使用 `tasks: []`,不要保留或虚构示例任务。
每个真实任务的验收必须是可观测信号,例如可见文本、API 状态和字段,或明确的交互
结果;不要只写“功能正常”。
## 初始化项目知识
新项目的 `docs/ack/knowledge.yaml` 保持 `verificationRegistry: {}`
`entries: []`。不要从聊天、README、issue 或单次失败中猜测并激活知识。
项目运行 ACK 后,Developer 和 Test 可以通过回报提名 `knowledgeCandidates`
candidate 留在任务证据中,不会被派发。只有 Test 独立验证且 Coordinator gate
通过后,Coordinator 才能把它写成 `active` 条目。全项目范围的强制或权限类规则
还需要 User / Decision Owner 确认。
知识条目只引用项目已审查的 `verification.ref`。对应入口保存在
`knowledge.yaml.verificationRegistry`,只允许仓库内相对 path 和结构化 args,
不保存或自动执行自由 shell 命令。需要执行时只把 registry ID 交给
`<ack-skill-dir>/scripts/run_verification.py`,不直接运行 path/args。关键约束应
最终下沉为测试、lint、CI 或正式规范。
## 校验
Agent 从当前 `SKILL.md` 解析 ACK Skill 目录后运行:
```bash
python3 <ack-skill-dir>/scripts/validate_tasks.py docs/ack/tasks.yaml
python3 <ack-skill-dir>/scripts/validate_knowledge.py docs/ack/knowledge.yaml --tasks docs/ack/tasks.yaml
```
同时确认:
- `project.md``tasks.yaml` 没有未替换的 `<...>` 占位符。
- `project.md``tasks.yaml``knowledge.yaml` 没有未替换的 `<...>` 占位符。
- `project.overlayFile` 指向真实文件。
- `project.knowledgeFile` 指向 `docs/ack/knowledge.yaml`
- Developer 与 Test 的验证命令可执行。
- 网站或 API 项目写清服务启动、重启和 Base URL。
- 任务中的固定 revision `knowledgeRefs` 都能解析,非 `active` 条目没有被派发。
## 初始化报告
完成后报告:
- 创建或确认的个项目文件。
- 创建或确认的个项目文件。
- 检测到的技术栈和验证命令。
- 任务板校验结果。
- 任务板和项目知识校验结果。
- 仍需用户补充的值。
只有结构校验通过且必填项目事实完整时才称“初始化完成”;否则称“部分完成”,并列出
+25 -7
View File
@@ -16,11 +16,15 @@
我要做一个新需求:<一句话需求>。
你作为 ack 的 Coordinator(PM),按 ACK Skill 的 references 规范执行:
1. 先读 docs/ack/project.md、references/roles-and-permissions.md、closed-loop.md、optimization-method.md。
1. 先读 docs/ack/project.md、docs/ack/tasks.yaml,校验 docs/ack/knowledge.yaml 并
用 `scripts/select_knowledge.py` 只读取当前任务相关的 active 条目,再读
references/roles-and-permissions.md、closed-loop.md、optimization-method.md。
2. 写产品文档到 docs/(PRD / 交互 / 验收),把需求拆成任务,每个任务的验收写成可观测信号(可见文本 / API 结果 / 交互结果)。
3. 任务写进 docs/ack/tasks.yaml(只有你写),跑 validate 校验结构。
4. 先把「产品文档 + 任务拆分 + 验收信号」给我确认,不要急着派发
5. 我确认后,按 ack 闭环循环:先按 docs/ack/project.md 校验 Developer/Test worker 启动命令,
3. 任务 scope 从 knowledge.yaml 推荐 active 知识,确认后把固定 revision 的
knowledgeRefs 写入任务;不要派发 candidate 或全量知识库
4. 把任务写进 docs/ack/tasks.yaml(只有你写),校验 tasks.yaml 和 knowledge.yaml。
5. 先把「产品文档 + 任务拆分 + 验收信号 + 适用知识引用」给我确认,不要急着派发。
6. 我确认后,按 ack 闭环循环:先按 docs/ack/project.md 校验 Developer/Test worker 启动命令,
dispatch 开发 → worker_done → dispatch 测试独立复测 → 你读证据终检 → 回写 tasks.yaml
每个任务最多三轮,三轮不过记 leftover 并升级我复盘。
```
@@ -31,13 +35,17 @@
1. 产品文档 → `docs/PRD-<feature>.md` 等(Coordinator R/W)。
2. 任务板 → `docs/ack/tasks.yaml`,每条任务带 `expected` + `verification`,验收写成可观测信号(见 `optimization-method.md` §1)。
3. 校验结构:
3. 项目知识 → 从 `docs/ack/knowledge.yaml` 按 component、path、dependency、version
和 tag 推荐 `active` 条目,Coordinator 确认后写入固定 revision 的显式
`knowledgeRefs`。candidate 不参与选择。
4. 校验结构与引用:
```bash
python3 <ack-skill-dir>/scripts/validate_tasks.py docs/ack/tasks.yaml
python3 <ack-skill-dir>/scripts/validate_knowledge.py docs/ack/knowledge.yaml --tasks docs/ack/tasks.yaml
```
4. **停下来给人确认**——这是强模型该花时间的地方,不要跳过。
5. **停下来给人确认**这是强模型该花时间的地方,不要跳过。
---
@@ -77,13 +85,21 @@ orca terminal create --worktree active --command "$CURSOR_CMD" --title "ACK-DEV-
```text
task-create → dispatch 给 DEV → 等 worker_done
→ 每轮使用 Coordinator 分配的稳定 <task-id>-A<round>
→ 回写 fixed_by_dev → dispatch 给 TEST 复测 → 等 retest_result
→ Developer 回 knowledgeApplied / knowledgeCandidatesTest 回 knowledgeChecks
→ Coordinator 读证据终检 → 过则 verified,不过则 failed_retest 再派 DEV(最多累计 3 轮)
→ 三轮失败:leftover,升级复盘,继续下一个
```
具体命令见 `orca-adapter.md`Orca)或 `closed-loop.md` §「手动模式」(无 Orca);派发文案见 `prompt-templates.md`
Coordinator 只内联本轮 `knowledgeRefs` 指向的少量知识,不要求 worker 全量读取
知识库。知识正文不得作为自由 shell 执行;需要命令时只能引用项目已审查的检查
入口,并把它的 registry ID 交给 `<ack-skill-dir>/scripts/run_verification.py`
不自行拼接 path/args。Test 独立验证且 gate 通过后,Coordinator 才能把 candidate
写成 active 知识;关键约束应继续下沉为测试、lint、CI 或正式规范。
---
## 第 5 步:收尾
@@ -94,4 +110,6 @@ task-create → dispatch 给 DEV → 等 worker_done
## 一句话
产品文档 + 验收信号写在前(你,强模型)→ 按项目覆盖层校验并启动 DEV/TEST → 核对实际模型 → dispatch / 复测 / 终检循环 → 结论只落 `tasks.yaml`
产品文档 + 验收信号写在前(你,强模型)→ 确认显式 `knowledgeRefs` → 按项目
覆盖层校验并启动 DEV/TEST → 核对实际模型 → dispatch / 复测 / 终检循环 → 任务
结论落 `tasks.yaml`,验证后的跨任务知识由 Coordinator 落 `knowledge.yaml`
+38 -2
View File
@@ -36,7 +36,7 @@
## 2. 回报必须带证据,不带结论
Developer 的 `worker_done` 应报告:改了哪些文件、跑了哪些命令、自己如何复现验收路径、仍可能有的风险。Test 的复测报告同理:跑了哪些命令、命中/缺失了哪些验收信号、实际观察到什么、证据(snapshot / API 结果)。两者的"结论"都不作数只有 Coordinator 落盘 `tasks.yaml`是事实
Developer 的 `worker_done` 应报告:改了哪些文件、跑了哪些命令、自己如何复现验收路径、仍可能有的风险。Test 的复测报告同理:跑了哪些命令、命中/缺失了哪些验收信号、实际观察到什么、证据(snapshot / API 结果)。两者的"结论"都不作数;任务结论只有 Coordinator 落盘 `tasks.yaml`算数,跨任务知识只有 Coordinator 落盘到 `knowledge.yaml` 才会生效
不要写:
@@ -138,7 +138,41 @@ one dispatch = one bug = one acceptance path
---
## 9. 结束条件
## 9. 让经验成为有证据、会过期的知识护栏
项目特有、跨任务复用且会改变后续开发或验证行为的经验,可以提名到项目知识护栏。
不要把全部对话、日志或单次猜测自动保存成“记忆”。
```text
Developer / Test 当前任务观测
-> knowledgeCandidates(只留在任务证据)
-> Test 独立验证 + Coordinator gate
-> Coordinator 写入 active knowledge
-> stale
-> superseded / archived
```
`prepare(task)` 时只按 component、path、dependency、version 和 tag 推荐相关
`active` 条目;自动匹配只负责推荐,Coordinator 确认固定 revision 的显式
`knowledgeRefs` 才是本轮权威上下文。每轮只派发命中的少量条目,不全量注入知识库。
Developer 回报实际采用的 `knowledgeApplied` 和带当前观测证据的
`knowledgeCandidates`。Test 对本轮显式引用回报 `knowledgeChecks`,并用独立观测
确认或否定 candidate。同一 Agent 不能把自己读到的旧知识复述成新证据,避免错误
知识自我强化。
知识中的验证只能引用 `knowledge.yaml.verificationRegistry` 中已经审查的仓库内
相对 path 和结构化 args,不能把正文或选择器输出拼接成自由 shell 执行。实际运行
只把 registry ID 交给 `<ack-skill-dir>/scripts/run_verification.py`。安全、正确性
和兼容性等关键约束一旦稳定,应下沉为测试、lint、CI 或正式规范;知识项继续解释
触发条件、原因与证据,不替代可执行控制。
依赖、配置、路径或版本变化后应重新审查相关知识。临时 workaround 必须有失效或
移除条件;冲突规则不能靠“最后写入者获胜”处理。
---
## 10. 结束条件
一轮闭环结束时,必须能回答:
@@ -146,5 +180,7 @@ one dispatch = one bug = one acceptance path
- 每个 leftover 失败了几轮?最后一轮失败证据是什么?
- 当前工作树有哪些未提交改动?
- 是否还有 open / failed_retest 未处理?
- 本轮显式 `knowledgeRefs` 是否都有必要的 `knowledgeChecks`
- 是否有待验证 candidate,或因依赖、路径、版本变化需要转为 stale 的知识?
答不清楚,闭环就还没结束。
+34 -4
View File
@@ -112,7 +112,8 @@ Test: owns independent black-box retest and evidence (verifier != implementer).
Policy:
- Each issue can be dispatched at most 3 rounds.
- If still failing after 3 rounds, record as leftover and continue next issue.
- worker_done and retest reports are not final completion; only Coordinator writes tasks.yaml.
- worker_done and retest reports are not final completion; only Coordinator writes tasks.yaml and knowledge.yaml.
- Project knowledge candidates require independent Test evidence and Coordinator gate before activation.
EOF
)" --json
```
@@ -122,6 +123,7 @@ EOF
```bash
orca orchestration task-create --parent <parent_task_id> --spec "$(cat <<'EOF'
Fix <task_id>: <title>
Logical attempt: <task_id>-A<round>
Repository:
- Path: <repo_path>
@@ -130,10 +132,14 @@ Repository:
Read: <overlay_file> (project overlay), tasks.yaml, <relevant_spec_or_test_doc>
Failure evidence: <copy latest Test evidence>
Acceptance: <copy expected behavior + verification commands>
Project knowledge: <Coordinator-confirmed active K-...@revision entries with verification.ref>
Constraints:
- Follow the overlay file path scope.
- Do not write tasks.yaml, do not mark verified.
- Do not write tasks.yaml or knowledge.yaml, do not mark verified.
- Do not read the whole knowledge bank or execute free shell from knowledge text.
- Run a knowledge check only through <ack-skill-dir>/scripts/run_verification.py
with its verification.ref; do not execute resolved path/args directly.
- Do not commit or push unless user asks.
EOF
)" --json
@@ -198,7 +204,7 @@ orca orchestration check \
## Developer 回报 worker_done
字段含义见 `prompt-templates.md` §3
字段含义见 `prompt-templates.md` §4
```bash
orca orchestration send \
@@ -209,8 +215,24 @@ orca orchestration send \
--payload '{
"taskId": "<orca_task_id>",
"dispatchId": "<orca_dispatch_id>",
"attemptId": "<task_id>-A<n>",
"filesModified": ["<file_a>", "<file_b>"],
"verification": ["<command_a>: passed", "<command_b>: passed"],
"knowledgeApplied": [
{"ref": "<K-001@1>", "result": "applied", "evidence": "<what was done>"}
],
"knowledgeCandidates": [
{
"kind": "pitfall",
"title": "<title>",
"claim": "<evidence-backed claim>",
"scope": {"components": ["<component>"], "paths": ["<path glob>"]},
"appliesWhen": "<trigger>",
"directive": "<action, not a shell command>",
"rationale": "<why it matters>",
"evidenceRefs": ["<current task evidence>"]
}
],
"risk": "<remaining risk or none>"
}' \
--json
@@ -233,11 +255,19 @@ orca orchestration send \
--payload '{
"taskId": "<orca_task_id>",
"dispatchId": "<orca_dispatch_id>",
"attemptId": "<task_id>-A<n>",
"env": {"worktree": "<path>", "branch": "<branch>", "commit": "<sha>", "baseUrl": "<base_url>"},
"signals": ["<signal 1>: pass", "<signal 2>: fail (<evidence>)"],
"knowledgeChecks": [
{"ref": "<K-001@1>", "result": "passed", "evidence": "<independent evidence>"}
],
"knowledgeCandidates": [],
"conclusion": "all-signals-pass | signals-failed"
}' \
--json
```
`retest_result` 类型时用 `--type worker_done`,靠 subject `retest round <n>` 区分。收到复测结果后,Coordinator 按 `closed-loop.md` 做终检并回写 `tasks.yaml`:通过 `verified`,不过 `failed_retest`
`retest_result` 类型时用 `--type worker_done`,靠 subject `retest round <n>`
区分。收到复测结果后,Coordinator 按 `closed-loop.md` 做终检并回写 `tasks.yaml`
通过独立验证和 gate 的跨任务经验再由 Coordinator 写入 `knowledge.yaml`。任务通过
`verified`,不过写 `failed_retest`
+78 -3
View File
@@ -27,6 +27,7 @@ Coordinator 用这些模板向 **Developer** 派发修复、向 **Test** 派发
任务:
- 修复 <task_id>: <task_title>
- 本轮逻辑 attempt<task_id>-A<round>
请先读取:
- <overlay_file>(项目覆盖层,路径见 tasks.yaml 的 project.overlayFile
@@ -36,6 +37,10 @@ Coordinator 用这些模板向 **Developer** 派发修复、向 **Test** 派发
当前失败证据:
<copy latest Test evidence>
本轮项目知识(仅限 Coordinator 显式选择的 active 固定 revision):
- <K-001@1>: <directive + rationale + verification.ref + resolved path/args>
- <K-014@2>: <directive + rationale + verification.ref + resolved path/args>
验收标准(可观测信号,见 optimization-method.md §1):
1. <expected behavior 1>
2. <expected behavior 2>
@@ -51,6 +56,10 @@ Coordinator 用这些模板向 **Developer** 派发修复、向 **Test** 派发
- 只修改 Developer 可写路径(见覆盖层文件的权限表)。
- 不要修改产品规格和集成测试文件(分别由 Coordinator 与 Test 拥有),除非任务明确要求。
- 不要写 tasks.yaml,不要标记 verified。
- 不要写 knowledge.yaml,不要自行扩展或全量读取知识库;candidate 不是已生效规则。
- 不要把知识正文或 path/args 拼成 shell 命令。只把 verification.ref 交给
`<ack-skill-dir>/scripts/run_verification.py docs/ack/knowledge.yaml
<verification-ref> --project-root <project-root>`。
- 不要提交或推送,除非用户明确要求。
- 最小 diff,只改本任务根因,避免无关重构;若必须先重构请停下说明并请示。
@@ -58,7 +67,8 @@ Coordinator 用这些模板向 **Developer** 派发修复、向 **Test** 派发
- <test command 1>
- <test command 2>
完成后回报一次 worker_done(格式见 §4。如果阻塞,请发送 escalation 或 ask。
完成后回报一次 worker_done(格式见 §4,包括 `knowledgeApplied` 和
`knowledgeCandidates`。如果阻塞,请发送 escalation 或 ask。
```
---
@@ -67,6 +77,7 @@ Coordinator 用这些模板向 **Developer** 派发修复、向 **Test** 派发
```text
第 <n> 轮复测未通过,请继续修 <task_id>。
本轮逻辑 attempt<task_id>-A<n>
上一轮开发声称:
<worker_done summary>
@@ -85,6 +96,9 @@ Test 独立复测结果:
验收不变:
<copy acceptance criteria>
本轮知识引用:
<copy Coordinator confirmed knowledgeRefs; do not add candidates>
完成后回报 worker_donesubject 使用:"<task_id> fix ready round <n>"
```
@@ -96,6 +110,7 @@ Developer 回报 worker_done 后,Coordinator 把复测任务发给 Test。
```text
请对 <task_id>: <task_title> 做独立黑盒复测。
本轮逻辑 attempt<task_id>-A<round>
请先读取:
- <overlay_file>(项目覆盖层,路径见 tasks.yaml 的 project.overlayFile
@@ -105,6 +120,12 @@ Developer 回报 worker_done 后,Coordinator 把复测任务发给 Test。
Developer 本轮声称(仅供参考,不作数):
- 改动文件:<files>
- 自测命令:<commands>
- 实际采用知识:<knowledgeApplied>
- 新知识候选:<knowledgeCandidates; candidate only>
本轮项目知识(仅限 Coordinator 显式选择的 active 固定 revision):
- <K-001@1>: <directive + rationale + verification.ref + resolved path/args>
- <K-014@2>: <directive + rationale + verification.ref + resolved path/args>
复测要求(见 roles-and-permissions.md §三角色能力清单 · Test):
- 先对齐运行环境(pwd / 分支 / commit / 服务 worktree,见 closed-loop.md),避免测错实例或旧构建;网站类先确认服务已按新代码重启。
@@ -114,9 +135,14 @@ Developer 本轮声称(仅供参考,不作数):
2. <observable signal 2>
3. <observable signal 3>
- 需要时把易反复误判的路径沉淀成可执行测试(见 optimization-method.md §8)。
- 对每条适用的 `knowledgeRef`,把它的 verification.ref 交给
`<ack-skill-dir>/scripts/run_verification.py docs/ack/knowledge.yaml
<verification-ref> --project-root <project-root>`,并回报 `knowledgeChecks`。
对 candidate 使用独立观测验证,不能复述 Developer 的结论作为证据。
约束:
- 只读源码,不修改应用代码,不写 tasks.yaml。
- 只读源码,不修改应用代码,不写 tasks.yaml 或 knowledge.yaml
- candidate 不属于 active 知识;不要全量注入知识库,不要从知识正文执行自由 shell。
- 只回传证据和逐条结论,最终判定由 Coordinator 终检后落盘。
完成后回报一次复测报告(格式见 §5),subject"<task_id> retest round <n>"。
@@ -130,14 +156,36 @@ Developer 本轮声称(仅供参考,不作数):
```text
subject: <task_id> fix ready round <n>
attemptId: <task_id>-A<n>
filesModified: [<file_a>, <file_b>]
verification:
- <command_a>: passed
- <command_b>: passed
reproduce: 我如何复现验收路径 <steps>
knowledgeApplied:
- ref: <K-001@1>
result: <applied|not_applicable>
evidence: <what was done or why not applicable>
knowledgeCandidates:
- kind: guardrail/pitfall/verification
title: <reusable project lesson>
claim: <evidence-backed project claim>
scope:
components: [<component>]
paths: [<path glob>]
dependencies: [<dependency>]
versions: [<version>]
tags: [<tag>]
appliesWhen: <trigger>
directive: <action, not a shell command>
rationale: <why this changes future work>
evidenceRefs: [<current task evidence reference>]
risk: <remaining risk or none>
```
没有命中知识或没有新 candidate 时,对应列表写 `[]`。Developer 不能把自己读到的
旧知识复述为新 candidate;每条 candidate 都需要当前任务产生的观测证据。
Orca 模式下用 `orca-adapter.md` §「Developer 回报 worker_done」的命令发送同样的字段。
---
@@ -148,6 +196,7 @@ Test 只回传证据和逐条结论,不下最终判定:
```text
subject: <task_id> retest round <n>
attemptId: <task_id>-A<n>
env:
worktree: <path>
branch: <branch>
@@ -159,10 +208,31 @@ signals:
- <signal 1>: pass/fail (<evidence>)
- <signal 2>: pass/fail (<evidence>)
browser: <snapshot / DOM / API evidence>
knowledgeChecks:
- ref: <K-001@1>
result: <passed|failed|not_applicable>
evidence: <independent evidence>
knowledgeCandidates:
- kind: guardrail/pitfall/verification
title: <new lesson found by Test>
claim: <evidence-backed project claim>
scope:
components: [<component>]
paths: [<path glob>]
dependencies: [<dependency>]
versions: [<version>]
tags: [<tag>]
appliesWhen: <trigger>
directive: <action, not a shell command>
rationale: <why this changes future work>
evidenceRefs: [<independent evidence reference>]
conclusion: all-signals-pass / signals-failed
notes: <observations, suspected cause if failed>
notes: <observations, suspected cause, and independent candidate validation evidence>
```
没有适用项时列表写 `[]`。Test 的知识结论仍只是证据;只有 Coordinator 能写入或
激活 `knowledge.yaml`
Orca 模式下用 `orca-adapter.md` §「Test 回报复测结果」的命令发送同样的字段。
---
@@ -181,6 +251,11 @@ Orca 模式下用 `orca-adapter.md` §「Test 回报复测结果」的命令发
验证命令:
- <command>: passed
项目知识:
- 本轮采用:<knowledgeRefs and checks>
- 新增或更新:<active/stale/superseded entries written by Coordinator, or none>
- 待验证 candidate<remaining candidates or none>
工作树状态:
- <repo_path>: <git status summary>
- <dev_worktree>: <git status summary>
+48 -16
View File
@@ -12,12 +12,12 @@ ACK 默认三个独立 Agent**Coordinator 只编排、Test 只验证、Develo
| 角色 | 主要职责 | 验证方式 | 不应做的事 |
|------|----------|----------|------------|
| Coordinator (PM) | 需求拆解、定验收信号、排优先级、写 `tasks.yaml`、向 Developer/Test 派发、跑三轮闭环、做最终 gate | 读 Test 证据并对齐原始意图(不亲自跑测试) | 修改源码、亲自复测、凭 worker_done 直接标 `verified` |
| Test | 黑盒复测、回归验证、沉淀可执行测试、产出证据 | 浏览器、API、集成脚本、用户可见行为 | 修改应用源码、修改产品规格、写 `tasks.yaml` |
| Developer | 实现修复、写单元测试、运行构建和白盒验证 | 单元测试、类型检查、构建、本地运行 | 修改产品规格与集成测试、标记 `verified`、绕过测试声称完成 |
| Coordinator (PM) | 需求拆解、定验收信号、排优先级、`tasks.yaml` / `knowledge.yaml`、选择知识、向 Developer/Test 派发、跑三轮闭环、做最终 gate | 读 Test 证据并对齐原始意图(不亲自跑测试) | 修改源码、亲自复测、凭 worker_done 直接标 `verified`、自动激活未验证知识 |
| Test | 黑盒复测、回归验证、执行知识检查、独立验证知识候选、沉淀可执行测试、产出证据 | 浏览器、API、集成脚本、用户可见行为 | 修改应用源码、修改产品规格、写 `tasks.yaml``knowledge.yaml` |
| Developer | 实现修复、写单元测试、运行构建和白盒验证、提名项目知识 | 单元测试、类型检查、构建、本地运行 | 修改产品规格与集成测试、写项目状态、标记 `verified`、绕过测试声称完成 |
| User / Decision Owner | 决定范围、优先级、阻塞项是否继续 | 审阅报告和遗留清单 | 直接替代复测证据 |
**独立验证权归 Test。** Coordinator 不亲自复测——它读 Test 的证据,并对照任务的原始意图做一次终检(见「完成定义」)。`worker_done` 不等于完成的原则同时适用于 Developer 和 Test:结论只有落到 `tasks.yaml` 才算数。
**独立验证权归 Test。** Coordinator 不亲自复测它读 Test 的证据,并对照任务的原始意图做一次终检(见「完成定义」)。`worker_done` 不等于完成的原则同时适用于 Developer 和 Test:结论只有落到 `tasks.yaml` 才算数。
**模型档位(正交层)。** 三角色默认按成本分层:Coordinator 用强模型,Test 与 Developer 用中低模型,必要时升级。完整档位表与升级规则见 `model-routing.md`。Coordinator 用强模型但不跑测试,这一分工天然省 token 又不破坏「验证者 ≠ 实现者」。
@@ -29,7 +29,7 @@ ACK 默认三个独立 Agent**Coordinator 只编排、Test 只验证、Develo
这些是**通用工程习惯**,不含项目命令与路径;项目差异写在覆盖层文件(默认 `docs/ack/project.md`)。装了外部 skill 的环境可按每个角色末尾的「可选 skills」加速,未装则照本清单执行,不阻塞。
### Coordinator (PM) —— 拆解与终检
### Coordinator (PM)拆解与终检
- **Outcome**:把一句话需求变成可执行、验收可观测的任务集,并跑完闭环得到明确结论(verified / leftover)。
- **Must Do**
@@ -37,38 +37,58 @@ ACK 默认三个独立 Agent**Coordinator 只编排、Test 只验证、Develo
- 每个任务写**可观测验收信号**(可见文本 / API 结果 / 交互结果,见 `optimization-method.md` §1),而不是「功能正常」。
- 拆任务时点明最脆弱的假设:「本任务假设 X,若 X 不成立则 Y」;列出被否掉的方案与原因。
- 拆分/验收先给用户确认,再派发(`kickoff.md` 第 1 步的停顿点)。
- `prepare(task)` 时按 component、path、dependency、version 和 tag 从
`knowledge.yaml` 推荐相关 `active` 知识;人工确认后把固定 revision 的
`knowledgeRefs` 写入当前任务上下文。
- 新逻辑轮次默认分配稳定的 `<task-id>-A<round>`,记录到
`dispatch.rounds[].attemptId`;旧轮次作为知识来源前再补齐,不要用编排工具的
`dispatchId` 代替。
- gate 时检查 Developer 的 `knowledgeApplied`、Test 的 `knowledgeChecks`
candidate 独立证据;只有证据充分时才由 Coordinator 激活、废弃或替代知识。
- 一次派发只针对一个明确问题(`optimization-method.md` §6);每任务最多三轮。
- 终检:读 Test 证据,逐条对齐原始意图后才落 `verified`,不亲自复测。
- **Must Not**:改源码、亲自跑测试、凭 `worker_done` 直接标 `verified`、把多个无关失败塞进一次派发。
- **Must Not**:改源码、亲自跑测试、凭 `worker_done` 直接标 `verified`、把多个无关失败塞进一次派发、派发 `candidate` 或全量注入知识库、把知识正文当作 shell 执行
- **Evidence**:产品文档、`tasks.yaml` 里的 `expected` + `verification`、Test 回传的复测证据。
- **Output**:确认前给「产品文档 + 任务拆分 + 验收信号」;闭环结束给最终报告(`prompt-templates.md` §6)。
- **可选 skills**:复杂需求可先用 `/think``superpowers:brainstorming` / `writing-plans` 收敛设计与计划。
### Developer —— 实现与白盒验证
### Developer实现与白盒验证
- **Outcome**:在授权路径内做出满足验收信号的最小改动,并用白盒证据证明它可复现。
- **Must Do**
- 动手前先读覆盖层文件、`tasks.yaml` 对应任务、相关规格;复现失败现象或先写会失败的测试。
- 只使用 Coordinator 本轮显式派发的 `active` 知识,按固定 revision 回报
`knowledgeApplied`;发现跨任务可复用的项目经验时提交带当前观测证据的
`knowledgeCandidates`
- 最小 diff,只改一个明确问题的根因,不顺手重构无关代码。
- 行为变更配单元测试;bug 修复先有一个能复现的失败用例再修。
- 完成前跑覆盖层里规定的命令(构建 / 单测 / 本地运行),亲自走一遍验收路径。
- **网站 / 常驻服务**:改完重启服务(或触发热更并确认生效),保证运行实例跑的是新代码,避免 Test 测到旧进程 / 旧构建。
- **Must Not**:改产品规格与集成测试、写 `tasks.yaml`、标 `verified`、绕过测试声称完成、把 bug 修复扩成大重构(需要就先停下说明并请示)。
- **Evidence**:改了哪些文件、跑了哪些命令及结果、如何复现验收路径、残留风险。
- **Must Not**:改产品规格与集成测试、写 `tasks.yaml` `knowledge.yaml`、标
`verified`、把 candidate 当作已生效规则、绕过测试声称完成、把 bug 修复扩成大
重构(需要就先停下说明并请示)。
- **Evidence**:改了哪些文件、跑了哪些命令及结果、如何复现验收路径、实际采用的
`knowledgeRefs`、新 candidate 的当前任务证据、残留风险。
- **Output**:一次 `worker_done`,字段见 `prompt-templates.md` §4(只报证据,不下最终结论)。
- **可选 skills**:排查用 `/hunt``superpowers:systematic-debugging`(先根因后修);实现行为变更用 `superpowers:test-driven-development`
### Test —— 独立黑盒复测
### Test独立黑盒复测
- **Outcome**:以独立视角复现验收路径,逐条给出通过/失败的可观测证据,供 Coordinator 终检。
- **Must Do**
- 先对齐运行环境(pwd / 分支 / commit / 服务 worktree,见 `closed-loop.md`),避免测错实例或旧构建;网站类先确认服务已按新代码重启。
- 逐条验证验收信号,验证交互后的真实状态,而不是只看静态文案。
- 对 Coordinator 派发的每条 `knowledgeRef` 执行适用的额外检查,回报
`knowledgeChecks`;对本轮 candidate 使用独立观测验证,不能复述 Developer
结论当作证据。
- **网站类任务优先用浏览器复测**真实交互(点击 / 跳转 / 渲染),其次才是 API / 脚本;纯后端 / CLI 则以 API smoke 或脚本为主。
- 把最容易反复误判的路径沉淀成可执行测试(`optimization-method.md` §8)。
- 只回传证据 + 逐条结论,最终判定留给 Coordinator。
- **Must Not**:改应用源码、改产品规格、写 `tasks.yaml`、凭 Developer 的 `worker_done` 直接下结论。
- **Evidence**:运行环境快照、命令结果、每条信号 pass/fail + 证据(snapshot / DOM / API 结果)
- **Must Not**:改应用源码、改产品规格、写 `tasks.yaml``knowledge.yaml`、凭
Developer 的 `worker_done` 直接下结论、把 candidate 作为 active 知识执行
- **Evidence**:运行环境快照、命令结果、每条信号 pass/fail + 证据
snapshot / DOM / API 结果)、每条适用知识的
`passed` / `failed` / `not_applicable` + 证据。
- **Output**:一次复测报告,字段见 `prompt-templates.md` §5。
- **可选 skills**:合并 / 发版前检查可用 `/check``superpowers:verification-before-completion`(证据先于结论)。
@@ -87,7 +107,8 @@ ACK 默认三个独立 Agent**Coordinator 只编排、Test 只验证、Develo
| `<unit_test_paths>` | Read-only | Read-only | R/W | 单元测试 |
| `<shared_config_templates>` | Read-only | Read-only | R/W | 可提交配置模板 |
| `<local_config>` | Read-only | Read-only | Read-only | 本地私有配置,不提交 |
| `tasks.yaml` | R/W | Read-only | Read-only | 见下方「任务板写入约定」 |
| `tasks.yaml` | R/W | Read-only | Read-only | 见下方「项目状态写入约定」 |
| `knowledge.yaml` | R/W | Read-only | Read-only | Coordinator 单写;Developer/Test 通过回报提名或验证 |
---
@@ -126,15 +147,24 @@ failed_retest(累计 3 轮) -> leftover
---
## 任务板写入约定(并发安全)
## 项目状态写入约定(并发安全)
`tasks.yaml`持久事实源,为避免多 Agent 并发写冲突:
`tasks.yaml`任务事实源,`knowledge.yaml` 是跨任务项目知识事实源。为避免多
Agent 并发写冲突:
- **只有 Coordinator 写 `tasks.yaml`**。Test 与 Developer 对它都是只读的。
- **只有 Coordinator 写 `tasks.yaml``knowledge.yaml`**。Test 与 Developer
对它们都是只读的。
- Developer 的实现状态、Test 的复测证据都通过消息回传(`worker_done` / 复测报告),由 Coordinator 落盘。
- Developer 和 Test 只能通过 `knowledgeCandidates` 提名知识;candidate 保存在
当前任务证据中,在 Test 独立验证和 Coordinator gate 前不写成可派发的 active
知识。
- 每次写入前先读最新内容,写入后更新顶层 `updatedAt`
- 单次写入应是一个任务的一次状态跃迁,避免整表批量重写。
全项目范围的 `must``never` 或权限类规则还需要 User / Decision Owner 确认。
关键约束应最终下沉为测试、lint、CI 或正式规范;知识条目保存触发条件、原因和
证据引用,不替代可执行控制。
---
## 完成定义(Definition of Done
@@ -144,6 +174,8 @@ failed_retest(累计 3 轮) -> leftover
- Developer 已提供修改文件和白盒验证证据(`worker_done`)。
- Test 在正确 worktree 和正确服务实例上独立复测通过(对齐检查见 `closed-loop.md`),并产出可观测证据。
- 相关单元测试、构建、集成或浏览器检查通过。
- 当前任务显式 `knowledgeRefs` 对应的必需 `knowledgeChecks` 已由 Test 覆盖;
未覆盖或检查引用无法解析时不能标记 `verified`
- **Coordinator 终检**:读 Test 的证据,确认它满足任务的原始意图与验收信号(不是重测,是审证据 + 对齐意图;避免"过了字面没过意图")。
- `tasks.yaml` 中记录了复测证据与 `resolution.verifiedBy`
- 用户可见行为符合验收标准。
+430
View File
@@ -0,0 +1,430 @@
#!/usr/bin/env python3
"""安全执行 knowledge.yaml 中已审查的 verificationRegistry 条目。
本入口只接受 registry ID,不接受额外命令或参数。执行前只打开一次项目根目录
fd,知识库、检查目标和子进程 cwd 都固定到该 fd;检查文件逐段以 O_NOFOLLOW
打开后复制到匿名、尽可能 sealed 的稳定快照,再使用结构化 argv 和 shell=False
启动,避免检查与执行之间被替换。
退出码: 0..125 沿用检查结果 / 1 知识或执行失败 / 2 环境、路径或用法错误。
"""
from __future__ import annotations
import argparse
import fcntl
import os
import stat
import subprocess
import sys
import tempfile
from pathlib import Path, PurePosixPath
from typing import Any
from validate_knowledge import ( # type: ignore
infer_project_root,
load_yaml_text,
validate_all,
)
MAX_KNOWLEDGE_BYTES = 16 * 1024 * 1024
MAX_TARGET_BYTES = 16 * 1024 * 1024
def _project_root(
value: str | None,
knowledge_path: Path,
) -> tuple[Path | None, str | None]:
inferred = infer_project_root(knowledge_path)
if value is not None:
candidate = Path(value).expanduser()
if not candidate.is_dir():
return None, f"项目根目录不存在: {candidate}"
resolved = candidate.resolve(strict=True)
if inferred is not None and resolved != inferred:
return (
None,
f"--project-root {resolved} 与 knowledge.yaml 推断的项目根目录 "
f"{inferred} 不一致",
)
return resolved, None
if inferred is None:
return None, "无法从 knowledge.yaml 确定现有项目根目录"
return inferred, None
def _validate_knowledge_location(
knowledge_path: Path,
project_root: Path,
) -> str | None:
expected = project_root / "docs" / "ack" / "knowledge.yaml"
lexical = Path(os.path.abspath(knowledge_path.expanduser()))
if lexical != expected:
return (
"只允许执行项目权威知识库 "
f"{expected},当前输入为 {lexical}"
)
return None
def _open_regular_beneath(
project_root: Path | int,
relative_path: str,
*,
require_executable: bool,
) -> tuple[int | None, str | None]:
"""从根目录 fd 逐段打开目标,不允许任一段通过 symlink 跳转。"""
if not hasattr(os, "O_NOFOLLOW") or not hasattr(os, "O_DIRECTORY"):
return None, "当前平台不支持安全的 O_NOFOLLOW/O_DIRECTORY 路径解析"
parts = PurePosixPath(relative_path).parts
if (
not parts
or PurePosixPath(relative_path).is_absolute()
or any(part in {"", ".", ".."} for part in parts)
):
return None, "检查目标必须是规范的项目内相对路径"
directory_fds: list[int] = []
target_fd: int | None = None
try:
root_fd = (
os.dup(project_root)
if isinstance(project_root, int)
else os.open(
project_root,
os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW,
)
)
directory_fds.append(root_fd)
current_fd = root_fd
for segment in parts[:-1]:
current_fd = os.open(
segment,
os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW,
dir_fd=current_fd,
)
directory_fds.append(current_fd)
target_fd = os.open(
parts[-1],
os.O_RDONLY | os.O_NOFOLLOW,
dir_fd=current_fd,
)
metadata = os.fstat(target_fd)
if not stat.S_ISREG(metadata.st_mode):
os.close(target_fd)
return None, "检查目标不是普通文件"
if require_executable and metadata.st_mode & 0o111 == 0:
os.close(target_fd)
return None, "检查目标不可执行"
return target_fd, None
except OSError as exc:
if target_fd is not None:
os.close(target_fd)
return None, f"检查目标不存在、不可访问或路径包含软链接: {exc}"
finally:
for directory_fd in reversed(directory_fds):
os.close(directory_fd)
def _stable_metadata(before: os.stat_result, after: os.stat_result) -> bool:
fields = (
"st_dev",
"st_ino",
"st_mode",
"st_size",
"st_mtime_ns",
"st_ctime_ns",
)
return all(getattr(before, field) == getattr(after, field) for field in fields)
def _read_stable_bytes(
source_fd: int,
*,
maximum: int,
) -> tuple[bytes | None, str | None]:
before = os.fstat(source_fd)
if before.st_size > maximum:
return None, f"知识库超过大小上限 {maximum} bytes"
chunks: list[bytes] = []
total = 0
try:
os.lseek(source_fd, 0, os.SEEK_SET)
while True:
chunk = os.read(source_fd, min(1024 * 1024, maximum - total + 1))
if not chunk:
break
chunks.append(chunk)
total += len(chunk)
if total > maximum:
return None, f"知识库超过大小上限 {maximum} bytes"
except OSError as exc:
return None, f"无法读取权威知识库快照: {exc}"
after = os.fstat(source_fd)
if not _stable_metadata(before, after):
return None, "权威知识库在读取期间发生变化,拒绝执行"
return b"".join(chunks), None
def load_authoritative_knowledge(
project_root: Path | int,
) -> tuple[dict[str, Any] | None, str | None]:
source_fd, open_error = _open_regular_beneath(
project_root,
"docs/ack/knowledge.yaml",
require_executable=False,
)
if open_error is not None or source_fd is None:
return None, open_error or "无法安全打开权威知识库"
try:
content, read_error = _read_stable_bytes(
source_fd,
maximum=MAX_KNOWLEDGE_BYTES,
)
finally:
os.close(source_fd)
if read_error is not None or content is None:
return None, read_error or "无法读取权威知识库"
try:
text = content.decode("utf-8")
except UnicodeDecodeError as exc:
return None, f"权威知识库不是有效 UTF-8: {exc}"
return load_yaml_text(text, "知识库"), None
def _write_all(file_descriptor: int, chunk: bytes) -> None:
remaining = memoryview(chunk)
while remaining:
written = os.write(file_descriptor, remaining)
if written <= 0:
raise OSError("无法写入检查快照")
remaining = remaining[written:]
def _snapshot_executable(source_fd: int) -> tuple[int | None, str | None]:
"""复制到匿名快照;Linux 上进一步 seal,冻结本次执行内容。"""
before = os.fstat(source_fd)
if before.st_size > MAX_TARGET_BYTES:
return None, f"检查目标超过大小上限 {MAX_TARGET_BYTES} bytes"
snapshot_fd: int | None = None
seal_snapshot = all(
hasattr(owner, name)
for owner, name in (
(os, "memfd_create"),
(os, "MFD_ALLOW_SEALING"),
(os, "MFD_CLOEXEC"),
(fcntl, "F_ADD_SEALS"),
(fcntl, "F_SEAL_SEAL"),
(fcntl, "F_SEAL_SHRINK"),
(fcntl, "F_SEAL_GROW"),
(fcntl, "F_SEAL_WRITE"),
)
)
try:
if seal_snapshot:
snapshot_fd = os.memfd_create( # type: ignore[attr-defined]
"ack-verification",
os.MFD_CLOEXEC | os.MFD_ALLOW_SEALING, # type: ignore[attr-defined]
)
else:
snapshot_fd, snapshot_path = tempfile.mkstemp(
prefix="ack-verification-"
)
os.unlink(snapshot_path)
os.lseek(source_fd, 0, os.SEEK_SET)
total = 0
while True:
chunk = os.read(
source_fd,
min(1024 * 1024, MAX_TARGET_BYTES - total + 1),
)
if not chunk:
break
_write_all(snapshot_fd, chunk)
total += len(chunk)
if total > MAX_TARGET_BYTES:
os.close(snapshot_fd)
return None, f"检查目标超过大小上限 {MAX_TARGET_BYTES} bytes"
after = os.fstat(source_fd)
if not _stable_metadata(before, after):
os.close(snapshot_fd)
return None, "检查目标在创建执行快照期间发生变化,拒绝执行"
os.fchmod(snapshot_fd, before.st_mode & 0o777)
os.lseek(snapshot_fd, 0, os.SEEK_SET)
if seal_snapshot:
seals = (
fcntl.F_SEAL_SEAL
| fcntl.F_SEAL_SHRINK
| fcntl.F_SEAL_GROW
| fcntl.F_SEAL_WRITE
)
fcntl.fcntl(snapshot_fd, fcntl.F_ADD_SEALS, seals)
return snapshot_fd, None
except OSError as exc:
if snapshot_fd is not None:
os.close(snapshot_fd)
return None, f"无法创建稳定的检查执行快照: {exc}"
def open_target(
data: dict[str, Any],
verification_ref: str,
project_root: Path | int,
) -> tuple[int | None, list[str] | None, str | None]:
registry = data.get("verificationRegistry")
if not isinstance(registry, dict) or verification_ref not in registry:
return None, None, f"verification.ref {verification_ref!r} 未在 registry 注册"
target = registry.get(verification_ref)
if not isinstance(target, dict):
return None, None, f"verificationRegistry.{verification_ref} 不是对象"
relative_path = target.get("path")
args = target.get("args")
if not isinstance(relative_path, str) or not isinstance(args, list):
return None, None, f"verificationRegistry.{verification_ref} 结构无效"
if any(not isinstance(arg, str) for arg in args):
return (
None,
None,
f"verificationRegistry.{verification_ref}.args 必须是字符串数组",
)
source_fd, error = _open_regular_beneath(
project_root,
relative_path,
require_executable=True,
)
if error is not None or source_fd is None:
return None, None, error or "无法安全打开检查目标"
try:
target_fd, snapshot_error = _snapshot_executable(source_fd)
finally:
os.close(source_fd)
if snapshot_error is not None or target_fd is None:
return None, None, snapshot_error or "无法创建检查执行快照"
return target_fd, args, None
def _fd_executable_path(target_fd: int) -> str | None:
for prefix in ("/proc/self/fd", "/dev/fd"):
candidate = f"{prefix}/{target_fd}"
if Path(candidate).exists():
return candidate
return None
def _fd_directory_path(directory_fd: int) -> str | None:
for prefix in ("/proc/self/fd", "/dev/fd"):
candidate = f"{prefix}/{directory_fd}"
if Path(candidate).is_dir():
return candidate
return None
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(
description="执行 ACK verificationRegistry 中已审查的检查"
)
parser.add_argument("knowledge", help="knowledge.yaml 路径")
parser.add_argument("verification_ref", help="verificationRegistry 中的检查 ID")
parser.add_argument(
"--project-root",
help="项目根目录;默认从 knowledge.yaml 的 docs/ack 布局或 Git 推断",
)
args = parser.parse_args(argv)
knowledge_path = Path(args.knowledge)
project_root, root_error = _project_root(args.project_root, knowledge_path)
if project_root is None:
sys.stderr.write(f"{root_error or '无法确定现有项目根目录'}\n")
return 2
location_error = _validate_knowledge_location(knowledge_path, project_root)
if location_error is not None:
sys.stderr.write(f"{location_error}\n")
return 2
try:
project_root_fd = os.open(
project_root,
os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW,
)
except OSError as exc:
sys.stderr.write(f"无法安全打开项目根目录: {exc}\n")
return 2
try:
stable_root_path = _fd_directory_path(project_root_fd)
if stable_root_path is None:
sys.stderr.write("当前平台无法固定项目根目录文件描述符\n")
return 2
data, knowledge_error = load_authoritative_knowledge(project_root_fd)
if knowledge_error is not None or data is None:
sys.stderr.write(f"{knowledge_error or '无法读取权威知识库'}\n")
return 2
schema_path = (
Path(__file__).resolve().parent.parent
/ "templates"
/ "knowledge.schema.json"
)
errors, mode = validate_all(
data,
schema_path,
project_root=Path(stable_root_path),
)
if errors:
sys.stderr.write(f"知识库校验失败({mode}),拒绝执行:\n")
for error in errors:
sys.stderr.write(f" - {error}\n")
return 1
target_fd, target_args, error = open_target(
data,
args.verification_ref,
project_root_fd,
)
if error is not None or target_fd is None or target_args is None:
sys.stderr.write(f"{error or '无法解析检查目标'}\n")
return 2
try:
executable_path = _fd_executable_path(target_fd)
if executable_path is None:
sys.stderr.write(
"当前平台无法从已打开的文件描述符安全执行检查\n"
)
return 2
environment = os.environ.copy()
environment["ACK_PROJECT_ROOT"] = stable_root_path
environment["ACK_PROJECT_ROOT_DISPLAY"] = str(project_root)
environment["ACK_VERIFICATION_REF"] = args.verification_ref
registry = data.get("verificationRegistry")
target = (
registry.get(args.verification_ref)
if isinstance(registry, dict)
else None
)
if isinstance(target, dict) and isinstance(target.get("path"), str):
environment["ACK_VERIFICATION_PATH"] = target["path"]
try:
completed = subprocess.run(
[executable_path, *target_args],
cwd=stable_root_path,
env=environment,
shell=False,
check=False,
pass_fds=(project_root_fd, target_fd),
)
except OSError as exc:
sys.stderr.write(f"检查启动失败: {exc}\n")
return 1
finally:
os.close(target_fd)
finally:
os.close(project_root_fd)
if 0 <= completed.returncode <= 125:
return completed.returncode
return 1
if __name__ == "__main__":
raise SystemExit(main())
+275
View File
@@ -0,0 +1,275 @@
#!/usr/bin/env python3
"""按显式项目上下文确定性选择 active ACK 知识。
匹配采用大小写敏感 glob;entry 中每个非空 scope 维度都必须被查询上下文命中。
scope.all=true 的条目始终命中并优先占用 --limit,数量超过预算时显式失败。其余
结果按作用域具体程度及稳定引用排序。本脚本只输出数据,绝不执行 knowledge.yaml
中的任何文本。
"""
from __future__ import annotations
import argparse
import fnmatch
import json
import sys
from functools import lru_cache
from pathlib import Path
from typing import Any
from validate_knowledge import ( # type: ignore
SCOPE_FIELDS,
infer_project_root,
load_yaml,
stable_ref,
validate_builtin_structure,
validate_semantics,
)
DEFAULT_LIMIT = 10
MAX_LIMIT = 100
def _path_glob_matches(value: str, pattern: str) -> bool:
"""路径 glob:* 只匹配单段,只有完整的 ** 段可以跨越 /。"""
value_parts = tuple(value.split("/"))
pattern_parts = tuple(pattern.split("/"))
@lru_cache(maxsize=None)
def match(pattern_index: int, value_index: int) -> bool:
if pattern_index == len(pattern_parts):
return value_index == len(value_parts)
pattern_part = pattern_parts[pattern_index]
if pattern_part == "**":
return match(pattern_index + 1, value_index) or (
value_index < len(value_parts)
and match(pattern_index, value_index + 1)
)
return (
value_index < len(value_parts)
and fnmatch.fnmatchcase(value_parts[value_index], pattern_part)
and match(pattern_index + 1, value_index + 1)
)
return match(0, 0)
def scope_matches(
scope: dict[str, Any], context: dict[str, list[str]]
) -> bool:
if scope.get("all") is True:
return True
constrained = False
for field in SCOPE_FIELDS:
patterns = scope.get(field)
if not isinstance(patterns, list) or not patterns:
continue
constrained = True
values = context.get(field, [])
matcher = _path_glob_matches if field == "paths" else fnmatch.fnmatchcase
if not values or not any(
matcher(value, pattern)
for pattern in patterns
if isinstance(pattern, str)
for value in values
):
return False
return constrained
def _pattern_specificity(pattern: str) -> tuple[int, int, int, int, int]:
wildcard_count = sum(pattern.count(char) for char in ("*", "?", "["))
double_star_count = sum(1 for part in pattern.split("/") if part == "**")
literal_count = sum(char not in "*?[]!" for char in pattern)
exact = int(wildcard_count == 0)
depth = len(pattern.split("/"))
return (exact, literal_count, -double_star_count, -wildcard_count, depth)
def _specificity(entry: dict[str, Any]) -> tuple[int, int, int, int, int, int, int]:
scope = entry.get("scope")
if not isinstance(scope, dict) or scope.get("all") is True:
return (0, 0, 0, 0, 0, 0, 0)
populated = 0
exact = 0
literal = 0
double_star = 0
wildcard = 0
depth = 0
extra_or_patterns = 0
for field in SCOPE_FIELDS:
values = scope.get(field)
if isinstance(values, list) and values:
populated += 1
scores = [
_pattern_specificity(value)
for value in values
if isinstance(value, str)
]
if scores:
dimension_score = min(scores)
exact += dimension_score[0]
literal += dimension_score[1]
double_star += dimension_score[2]
wildcard += dimension_score[3]
depth += dimension_score[4]
extra_or_patterns += len(scores) - 1
return (
populated,
exact,
literal,
double_star,
wildcard,
depth,
-extra_or_patterns,
)
def select_entries(
data: dict[str, Any],
context: dict[str, list[str]],
*,
limit: int = DEFAULT_LIMIT,
) -> list[dict[str, Any]]:
if isinstance(limit, bool) or not isinstance(limit, int) or not 1 <= limit <= MAX_LIMIT:
raise ValueError(f"limit 必须在 1..{MAX_LIMIT} 之间")
entries = data.get("entries")
if not isinstance(entries, list):
return []
matched = [
entry
for entry in entries
if isinstance(entry, dict)
and entry.get("status") == "active"
and isinstance(entry.get("scope"), dict)
and scope_matches(entry["scope"], context)
and stable_ref(entry) is not None
]
global_entries = [
entry
for entry in matched
if isinstance(entry.get("scope"), dict)
and entry["scope"].get("all") is True
]
scoped_entries = [entry for entry in matched if entry not in global_entries]
global_entries.sort(key=lambda entry: stable_ref(entry) or "")
if len(global_entries) > limit:
raise ValueError(
f"命中的全项目知识有 {len(global_entries)} 条,超过 --limit={limit}"
"提高 limit 后重试,不能静默丢弃全项目护栏"
)
scoped_entries.sort(
key=lambda entry: (
*(-part for part in _specificity(entry)),
stable_ref(entry) or "",
)
)
return [
*global_entries,
*scoped_entries[: limit - len(global_entries)],
]
def _parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="选择当前任务适用的 active ACK 知识")
parser.add_argument(
"knowledge", nargs="?", default="knowledge.yaml", help="知识库路径"
)
parser.add_argument("--component", action="append", default=[])
parser.add_argument("--path", action="append", default=[])
parser.add_argument("--dependency", action="append", default=[])
parser.add_argument("--version", action="append", default=[])
parser.add_argument("--tag", action="append", default=[])
parser.add_argument("--symbol", action="append", default=[])
parser.add_argument("--error-signature", action="append", default=[])
parser.add_argument("--limit", type=int, default=DEFAULT_LIMIT)
parser.add_argument(
"--project-root",
help="可选项目根目录,用于 verificationRegistry symlink containment 校验",
)
parser.add_argument(
"--format", choices=("json", "refs"), default="json", dest="output_format"
)
return parser
def main(argv: list[str] | None = None) -> int:
args = _parser().parse_args(argv)
knowledge_path = Path(args.knowledge)
if not knowledge_path.is_file():
sys.stderr.write(f"找不到知识库文件: {knowledge_path}\n")
return 2
if not 1 <= args.limit <= MAX_LIMIT:
sys.stderr.write(f"--limit 必须在 1..{MAX_LIMIT} 之间\n")
return 2
if args.project_root:
project_root = Path(args.project_root).expanduser()
if not project_root.is_dir():
sys.stderr.write(f"项目根目录不存在: {project_root}\n")
return 2
project_root = project_root.resolve()
else:
project_root = infer_project_root(knowledge_path)
data = load_yaml(knowledge_path, "知识库")
errors = validate_builtin_structure(data)
errors.extend(validate_semantics(data, project_root=project_root))
registry = data.get("verificationRegistry")
if project_root is None and isinstance(registry, dict) and registry:
errors.append(
"verificationRegistry 非空但无法确定项目根目录;请传入 --project-root"
)
errors = list(dict.fromkeys(errors))
if errors:
sys.stderr.write(f"知识库无效,拒绝选择,共 {len(errors)} 项:\n")
for error in errors:
sys.stderr.write(f" - {error}\n")
return 1
context = {
"components": args.component,
"paths": args.path,
"dependencies": args.dependency,
"versions": args.version,
"tags": args.tag,
"symbols": args.symbol,
"errorSignatures": args.error_signature,
}
try:
selected = select_entries(data, context, limit=args.limit)
except ValueError as exc:
sys.stderr.write(f"知识选择失败: {exc}\n")
return 1
refs = [stable_ref(entry) for entry in selected]
if args.output_format == "refs":
if refs:
sys.stdout.write("\n".join(ref for ref in refs if ref) + "\n")
return 0
payload = {
"count": len(selected),
"limit": args.limit,
"refs": refs,
"entries": [
{
"ref": stable_ref(entry),
**entry,
"verificationTarget": (
data.get("verificationRegistry", {}).get(
entry.get("verification", {}).get("ref")
)
if isinstance(data.get("verificationRegistry"), dict)
and isinstance(entry.get("verification"), dict)
else None
),
}
for entry in selected
],
}
sys.stdout.write(json.dumps(payload, ensure_ascii=False, indent=2) + "\n")
return 0
if __name__ == "__main__":
raise SystemExit(main())
File diff suppressed because it is too large Load Diff
+555 -38
View File
@@ -3,9 +3,11 @@
权威结构是同目录上层的 templates/tasks.schema.json跨语言可用
本脚本是参考实现
- 若安装了 jsonschema则用 schema 做完整校验
- 否则回退到内置的关键规则校验必填字段状态枚举三轮上限leftover 留档
YAML 解析优先用 pyyaml未安装时给出提示而非崩溃
- 始终执行内置语义校验
- 安装了 jsonschema 再叠加 schema 结构校验
- knowledge 字段会检查引用格式候选结构和 Test 检查结果
YAML 优先使用 PyYAML未安装时使用 fail-closed ACK YAML 子集
JSON 任务板只使用标准库两种格式都拒绝重复键
用法:
python3 validate_tasks.py [tasks.yaml]
@@ -18,9 +20,18 @@ from __future__ import annotations
import argparse
import json
import re
import sys
from pathlib import Path
from yaml_subset import (
DuplicateKeyError,
YamlSubsetError,
load_json_unique,
load_yaml_subset,
make_unique_pyyaml_loader,
)
STATUS_ENUM = {
"open",
"dispatched",
@@ -32,29 +43,337 @@ STATUS_ENUM = {
"leftover",
}
MAX_ROUNDS = 3
KNOWLEDGE_KINDS = {"guardrail", "pitfall", "verification"}
KNOWLEDGE_CHECK_RESULTS = {"passed", "failed", "not_applicable"}
KNOWLEDGE_REF_RE = re.compile(r"^K-[A-Z0-9][A-Z0-9-]*@[1-9][0-9]*$")
ATTEMPT_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*-A[1-9][0-9]*$")
KNOWLEDGE_SCOPE_FIELDS = {
"components",
"paths",
"dependencies",
"versions",
"tags",
"symbols",
"errorSignatures",
}
KNOWLEDGE_APPLICATION_FIELDS = {"ref", "result", "evidence"}
KNOWLEDGE_CANDIDATE_FIELDS = {
"kind",
"title",
"claim",
"scope",
"appliesWhen",
"directive",
"rationale",
"evidenceRefs",
"proposedBy",
"proposedAt",
}
KNOWLEDGE_CHECK_FIELDS = {
"ref",
"result",
"evidence",
"checkedBy",
"checkedAt",
}
KNOWLEDGE_CANDIDATE_REQUIRED_FIELDS = {
"kind",
"title",
"claim",
"scope",
"appliesWhen",
"directive",
"rationale",
"evidenceRefs",
}
KNOWLEDGE_CANDIDATE_TEXT_FIELDS = {
"title",
"claim",
"appliesWhen",
"directive",
"rationale",
}
def load_yaml(path: Path) -> dict:
def _nonempty_string(value: object) -> bool:
return isinstance(value, str) and bool(value.strip())
def load_document(path: Path) -> dict:
try:
import yaml # type: ignore
except ImportError:
sys.stderr.write(
"需要 PyYAML 才能解析 YAMLpip install pyyaml\n"
"(或把任务板导出为 JSON 后再校验)\n"
)
raise SystemExit(2)
try:
with path.open(encoding="utf-8") as fh:
data = yaml.safe_load(fh)
except yaml.YAMLError as exc: # type: ignore
sys.stderr.write(f"YAML 解析失败: {exc}\n")
content = path.read_text(encoding="utf-8")
except OSError as exc:
sys.stderr.write(f"任务板读取失败: {exc}\n")
raise SystemExit(1)
if path.suffix.lower() == ".json":
try:
data = load_json_unique(content)
except (json.JSONDecodeError, DuplicateKeyError) as exc:
sys.stderr.write(f"JSON 解析失败: {exc}\n")
raise SystemExit(1)
else:
try:
import yaml # type: ignore
except ImportError:
try:
data = load_yaml_subset(content)
except YamlSubsetError as exc:
sys.stderr.write(f"YAML 子集解析失败: {exc}\n")
raise SystemExit(1)
else:
try:
data = yaml.load(
content,
Loader=make_unique_pyyaml_loader(yaml),
)
except yaml.YAMLError as exc: # type: ignore
sys.stderr.write(f"YAML 解析失败: {exc}\n")
raise SystemExit(1)
if not isinstance(data, dict):
sys.stderr.write("任务板顶层必须是对象(mapping\n")
raise SystemExit(1)
return data
def validate_knowledge_ref_list(
value: object,
where: str,
errors: list[str],
) -> list[str]:
if not isinstance(value, list):
errors.append(f"{where}: 必须是列表")
return []
refs: list[str] = []
seen: set[str] = set()
for index, ref in enumerate(value):
item_where = f"{where}[{index}]"
if not isinstance(ref, str) or not KNOWLEDGE_REF_RE.fullmatch(ref):
errors.append(f"{item_where}: 必须使用 K-<id>@<revision> 格式")
continue
if ref in seen:
errors.append(f"{item_where}: 引用重复: {ref}")
seen.add(ref)
refs.append(ref)
return refs
def reject_unknown_fields(
value: dict,
allowed: set[str],
where: str,
errors: list[str],
) -> None:
"""Mirror additionalProperties=false for builtin knowledge validation."""
for field in sorted(set(value) - allowed):
errors.append(f"{where}: 未知字段 {field!r}")
def validate_optional_string_fields(
value: dict,
fields: set[str],
where: str,
errors: list[str],
) -> None:
for field in sorted(fields):
if field in value and not isinstance(value[field], str):
errors.append(f"{where}.{field}: 必须是字符串")
def validate_knowledge_fields(
task: dict,
where: str,
status: object,
errors: list[str],
) -> None:
refs = (
validate_knowledge_ref_list(
task["knowledgeRefs"],
f"{where}.knowledgeRefs",
errors,
)
if "knowledgeRefs" in task
else []
)
if "knowledgeApplied" in task:
applications = task["knowledgeApplied"]
if not isinstance(applications, list):
errors.append(f"{where}.knowledgeApplied: 必须是列表")
else:
seen_applications: set[str] = set()
for index, application in enumerate(applications):
application_where = f"{where}.knowledgeApplied[{index}]"
if not isinstance(application, dict):
errors.append(f"{application_where}: 必须是对象")
continue
reject_unknown_fields(
application,
KNOWLEDGE_APPLICATION_FIELDS,
application_where,
errors,
)
ref = application.get("ref")
if not isinstance(ref, str) or not KNOWLEDGE_REF_RE.fullmatch(ref):
errors.append(
f"{application_where}.ref: 必须使用 K-<id>@<revision> 格式"
)
else:
if ref in seen_applications:
errors.append(f"{application_where}.ref: 应用结果重复: {ref}")
seen_applications.add(ref)
if ref not in refs:
errors.append(f"{application_where}.ref: {ref} 不在 knowledgeRefs 中")
if application.get("result") not in {"applied", "not_applicable"}:
errors.append(
f"{application_where}.result: 必须是 applied/not_applicable"
)
evidence = application.get("evidence")
if not isinstance(evidence, str) or not evidence.strip():
errors.append(f"{application_where}.evidence: 必须提供非空证据")
if "knowledgeCandidates" in task:
candidates = task["knowledgeCandidates"]
if not isinstance(candidates, list):
errors.append(f"{where}.knowledgeCandidates: 必须是列表")
else:
for index, candidate in enumerate(candidates):
candidate_where = f"{where}.knowledgeCandidates[{index}]"
if not isinstance(candidate, dict):
errors.append(f"{candidate_where}: 必须是对象")
continue
reject_unknown_fields(
candidate,
KNOWLEDGE_CANDIDATE_FIELDS,
candidate_where,
errors,
)
missing = sorted(
key
for key in KNOWLEDGE_CANDIDATE_REQUIRED_FIELDS
if key not in candidate
)
if missing:
errors.append(
f"{candidate_where}: 缺少必填字段 {', '.join(missing)}"
)
if candidate.get("kind") not in KNOWLEDGE_KINDS:
errors.append(
f"{candidate_where}.kind: 必须是 {sorted(KNOWLEDGE_KINDS)}"
)
for field in sorted(KNOWLEDGE_CANDIDATE_TEXT_FIELDS):
value = candidate.get(field)
if not isinstance(value, str) or not value.strip():
errors.append(
f"{candidate_where}.{field}: 必须是非空字符串"
)
validate_optional_string_fields(
candidate,
{"proposedBy", "proposedAt"},
candidate_where,
errors,
)
scope = candidate.get("scope")
if not isinstance(scope, dict):
errors.append(f"{candidate_where}.scope: 至少包含一个非空作用域")
else:
unknown_scope_fields = sorted(
set(scope) - KNOWLEDGE_SCOPE_FIELDS - {"all"}
)
for field in unknown_scope_fields:
errors.append(
f"{candidate_where}.scope: 未知字段 {field!r}"
)
if "all" in scope and not isinstance(scope.get("all"), bool):
errors.append(f"{candidate_where}.scope.all: 必须是布尔值")
populated_scope_fields: list[str] = []
for field in sorted(KNOWLEDGE_SCOPE_FIELDS & set(scope)):
values = scope.get(field)
if not isinstance(values, list) or any(
not isinstance(item, str) or not item.strip()
for item in values
):
errors.append(
f"{candidate_where}.scope.{field}: "
"必须是非空字符串列表"
)
elif len(values) != len(set(values)):
errors.append(
f"{candidate_where}.scope.{field}: 不能包含重复值"
)
elif values:
populated_scope_fields.append(field)
if scope.get("all") is True and populated_scope_fields:
errors.append(
f"{candidate_where}.scope: all=true 时不能同时填写作用域维度"
)
if scope.get("all") is not True and not populated_scope_fields:
errors.append(
f"{candidate_where}.scope: 至少包含一个非空作用域"
)
evidence_refs = candidate.get("evidenceRefs")
if (
not isinstance(evidence_refs, list)
or not evidence_refs
or not all(
isinstance(ref, str) and ref.strip()
for ref in evidence_refs
)
):
errors.append(
f"{candidate_where}.evidenceRefs: 必须是非空字符串列表"
)
elif len(evidence_refs) != len(set(evidence_refs)):
errors.append(
f"{candidate_where}.evidenceRefs: 不能包含重复值"
)
if "knowledgeChecks" not in task:
return
checks = task["knowledgeChecks"]
if not isinstance(checks, list):
errors.append(f"{where}.knowledgeChecks: 必须是列表")
return
seen_checks: set[str] = set()
for index, check in enumerate(checks):
check_where = f"{where}.knowledgeChecks[{index}]"
if not isinstance(check, dict):
errors.append(f"{check_where}: 必须是对象")
continue
reject_unknown_fields(
check,
KNOWLEDGE_CHECK_FIELDS,
check_where,
errors,
)
validate_optional_string_fields(
check,
{"checkedBy", "checkedAt"},
check_where,
errors,
)
ref = check.get("ref")
result = check.get("result")
evidence = check.get("evidence")
if not isinstance(ref, str) or not KNOWLEDGE_REF_RE.fullmatch(ref):
errors.append(f"{check_where}.ref: 必须使用 K-<id>@<revision> 格式")
else:
if ref in seen_checks:
errors.append(f"{check_where}.ref: 检查结果重复: {ref}")
seen_checks.add(ref)
if ref not in refs:
errors.append(f"{check_where}.ref: {ref} 不在 knowledgeRefs 中")
if result not in KNOWLEDGE_CHECK_RESULTS:
errors.append(
f"{check_where}.result: 必须是 {sorted(KNOWLEDGE_CHECK_RESULTS)}"
)
if not isinstance(evidence, str) or not evidence.strip():
errors.append(f"{check_where}.evidence: 必须提供非空证据")
if status == "verified" and result == "failed":
errors.append(f"{check_where}: verified 任务不能保留失败的知识检查")
def validate_with_schema(data: dict, schema_path: Path) -> list[str]:
import jsonschema # type: ignore
@@ -70,11 +389,92 @@ def validate_with_schema(data: dict, schema_path: Path) -> list[str]:
def validate_builtin(data: dict) -> list[str]:
errors: list[str] = []
if not isinstance(data.get("version"), int) or data.get("version", 0) < 1:
def validate_string_fields(
value: dict,
fields: set[str],
where: str,
*,
nullable: bool = False,
) -> None:
expected = "必须是字符串或 null" if nullable else "必须是字符串"
for field in sorted(fields):
if field not in value:
continue
field_value = value[field]
if not isinstance(field_value, str) and not (
nullable and field_value is None
):
errors.append(f"{where}.{field}: {expected}")
def validate_string_lists(
value: dict,
fields: set[str],
where: str,
) -> None:
for field in sorted(fields):
if field not in value:
continue
items = value[field]
if not isinstance(items, list):
errors.append(f"{where}.{field}: 必须是列表")
elif any(not isinstance(item, str) for item in items):
errors.append(f"{where}.{field}: 列表项必须是字符串")
def validate_object_fields(
value: dict,
fields: set[str],
where: str,
) -> None:
for field in sorted(fields):
if field in value and not isinstance(value[field], dict):
errors.append(f"{where}.{field}: 必须是对象")
version = data.get("version")
if (
not isinstance(version, int)
or isinstance(version, bool)
or version < 1
):
errors.append("version 必须是 >=1 的整数")
validate_string_fields(
data,
{"updatedAt", "source", "ackVersion", "kitVersion"},
"<root>",
)
project = data.get("project")
if not isinstance(project, dict) or not project.get("name"):
errors.append("project.name 必填")
if not isinstance(project, dict):
errors.append("project 必须是对象")
else:
if not _nonempty_string(project.get("name")):
errors.append("project.name 必须是非空字符串")
validate_string_fields(
project,
{"repoPath", "baseUrl", "devWorktree", "overlayFile"},
"project",
)
if (
"knowledgeFile" in project
and project.get("knowledgeFile") != "docs/ack/knowledge.yaml"
):
errors.append(
"project.knowledgeFile 必须固定为 docs/ack/knowledge.yaml"
)
if "summary" in data:
summary = data["summary"]
if not isinstance(summary, dict):
errors.append("summary 必须是对象")
else:
validate_string_lists(
summary,
{"verified", "open", "failedRetest", "leftovers"},
"summary",
)
if "statusReference" in data and not isinstance(
data["statusReference"], dict
):
errors.append("statusReference 必须是对象")
tasks = data.get("tasks")
if not isinstance(tasks, list):
@@ -90,34 +490,148 @@ def validate_builtin(data: dict) -> list[str]:
tid = task.get("id")
title = task.get("title")
status = task.get("status")
if not tid:
errors.append(f"{where}: id 必")
if not _nonempty_string(tid):
errors.append(f"{where}: id 必须是非空字符串")
else:
where = f"tasks[{i}] {tid}"
if tid in seen_ids:
errors.append(f"{where}: id 重复")
seen_ids.add(tid)
if not title:
errors.append(f"{where}: title 必")
if not _nonempty_string(title):
errors.append(f"{where}: title 必须是非空字符串")
if status not in STATUS_ENUM:
errors.append(
f"{where}: status={status!r} 非法,应为 {sorted(STATUS_ENUM)}"
)
dispatch = task.get("dispatch") or {}
rounds = dispatch.get("rounds") or []
if isinstance(rounds, list):
validate_string_fields(
task,
{
"type",
"priority",
"assignee",
"component",
"description",
"expected",
"actual",
},
where,
)
validate_string_lists(
task,
{"specRefs", "testRefs", "stepsToReproduce"},
where,
)
validate_object_fields(task, {"evidence", "verification"}, where)
validate_knowledge_fields(task, where, status, errors)
if "dispatch" not in task:
dispatch = {}
elif not isinstance(task["dispatch"], dict):
errors.append(f"{where}.dispatch: 必须是对象")
dispatch = {}
else:
dispatch = task["dispatch"]
validate_string_fields(
dispatch,
{"taskId", "dispatchId", "worker"},
f"{where}.dispatch",
nullable=True,
)
rounds = dispatch.get("rounds", [])
if not isinstance(rounds, list):
errors.append(f"{where}.dispatch.rounds: 必须是列表")
else:
if len(rounds) > MAX_ROUNDS:
errors.append(
f"{where}: 派发轮次 {len(rounds)} 超过上限 {MAX_ROUNDS}"
)
for r in rounds:
if isinstance(r, dict) and r.get("result") not in {"passed", "failed"}:
errors.append(f"{where}: round.result 必须是 passed/failed")
seen_attempt_ids: set[str] = set()
round_numbers: list[int] = []
for round_index, round_item in enumerate(rounds):
round_where = f"{where}.dispatch.rounds[{round_index}]"
if not isinstance(round_item, dict):
errors.append(f"{round_where}: 必须是对象")
continue
if round_item.get("result") not in {"passed", "failed"}:
errors.append(f"{round_where}.result: 必须是 passed/failed")
if "evidence" in round_item and not isinstance(
round_item["evidence"], str
):
errors.append(f"{round_where}.evidence: 必须是字符串")
round_number = round_item.get("round")
round_number_is_valid = (
isinstance(round_number, int)
and not isinstance(round_number, bool)
and 1 <= round_number <= MAX_ROUNDS
)
if not round_number_is_valid:
errors.append(
f"{round_where}.round: 必须是 1..{MAX_ROUNDS} 的整数"
)
else:
round_numbers.append(round_number)
if "attemptId" in round_item:
attempt_id = round_item["attemptId"]
if (
not isinstance(attempt_id, str)
or not ATTEMPT_ID_RE.fullmatch(attempt_id)
):
errors.append(
f"{round_where}.attemptId: "
"必须使用 <task-id>-A<round> 格式"
)
else:
if attempt_id in seen_attempt_ids:
errors.append(
f"{round_where}.attemptId: "
f"轮次内不能重复: {attempt_id}"
)
seen_attempt_ids.add(attempt_id)
if (
isinstance(tid, str)
and round_number_is_valid
and attempt_id != f"{tid}-A{round_number}"
):
errors.append(
f"{round_where}.attemptId: 应为 "
f"{tid}-A{round_number}"
)
expected_rounds = list(range(1, len(rounds) + 1))
if round_numbers != expected_rounds:
errors.append(
f"{where}.dispatch.rounds: round 必须从 1 连续递增且不重复"
)
resolution = task.get("resolution")
if "resolution" in task:
if not isinstance(resolution, dict):
errors.append(f"{where}.resolution: 必须是对象")
else:
validate_string_fields(
resolution,
{
"fixedBy",
"verifiedBy",
"verifiedAt",
"leftoverReason",
},
f"{where}.resolution",
nullable=True,
)
validate_object_fields(
resolution,
{"evidence"},
f"{where}.resolution",
)
if status == "leftover":
resolution = task.get("resolution") or {}
if not resolution.get("leftoverReason"):
if (
not isinstance(resolution, dict)
or not _nonempty_string(resolution.get("leftoverReason"))
):
errors.append(f"{where}: leftover 必须填 resolution.leftoverReason")
return errors
@@ -134,24 +648,27 @@ def main(argv: list[str] | None = None) -> int:
sys.stderr.write(f"找不到任务板文件: {tasks_path}\n")
return 2
data = load_yaml(tasks_path)
data = load_document(tasks_path)
schema_path = Path(args.schema) if args.schema else (
Path(__file__).resolve().parent.parent / "templates" / "tasks.schema.json"
)
if args.schema and not schema_path.is_file():
sys.stderr.write(f"找不到指定的 schema 文件: {schema_path}\n")
return 2
mode = "内置规则"
errors = validate_builtin(data)
mode = "内置语义规则"
try:
import jsonschema # type: ignore # noqa: F401
if schema_path.is_file():
errors = validate_with_schema(data, schema_path)
mode = f"schema ({schema_path.name})"
errors = validate_with_schema(data, schema_path) + errors
mode = f"schema ({schema_path.name}) + 内置语义规则"
else:
errors = validate_builtin(data)
mode = "内置规则(未找到 schema 文件)"
mode = "内置语义规则(未找到 schema 文件)"
except ImportError:
errors = validate_builtin(data)
pass
if errors:
sys.stderr.write(f"任务板校验失败({mode}),共 {len(errors)} 项:\n")
+686
View File
@@ -0,0 +1,686 @@
#!/usr/bin/env python3
"""ACK YAML 的零依赖、fail-closed 子集解析器。
这不是通用 YAML 实现它只覆盖 ACK 状态文件所需的 mapping
sequenceflow collection标量和 ``>`` / ``|`` block scalar锚点
aliastag多文档和其它未实现语法会显式失败不会猜测或静默误解析
"""
from __future__ import annotations
import json
import re
from dataclasses import dataclass
from typing import Any
class YamlSubsetError(ValueError):
"""YAML 超出 ACK 子集或语法无效。"""
class DuplicateKeyError(ValueError):
"""JSON/YAML mapping 包含重复键。"""
@dataclass(frozen=True)
class _Line:
number: int
indent: int
content: str
_DECIMAL_INT_RE = re.compile(r"[-+]?(?:0|[1-9][0-9]*)\Z")
_AMBIGUOUS_NUMBER_RE = re.compile(
r"[-+]?(?:"
r"[0-9][0-9_]*\.[0-9_]*(?:[eE][-+]?[0-9]+)?|"
r"[0-9][0-9_]*(?:[eE][-+]?[0-9]+)|"
r"0[xX][0-9a-fA-F_]+|0[oO][0-7_]+|0[bB][01_]+|"
r"0[0-9_]+|[0-9][0-9_]*:[0-9_:]+"
r")\Z"
)
_PLAIN_KEY_FORBIDDEN_RE = re.compile(r"[\[\]{},#]")
_ANCHOR_OR_ALIAS_RE = re.compile(
r"(?:^|\s)[&*][A-Za-z0-9_-]+(?:\s|$)"
)
def load_json_unique(content: str) -> Any:
"""Parse JSON while rejecting duplicate object keys at every depth."""
def unique_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
result: dict[str, Any] = {}
for key, value in pairs:
if key in result:
raise DuplicateKeyError(f"JSON 存在重复键 {key!r}")
result[key] = value
return result
return json.loads(content, object_pairs_hook=unique_object)
def make_unique_pyyaml_loader(yaml_module: Any) -> type:
"""Build a SafeLoader that rejects duplicate keys and graph features.
ACK documents are trees. Anchors/aliases can introduce shared identity or
cycles, which are unnecessary here and make recursive validation unsafe.
Explicit tags are also outside the fallback grammar, so both code paths
reject them consistently.
"""
class UniqueKeySafeLoader(yaml_module.SafeLoader): # type: ignore[misc]
def compose_node(self, parent: Any, index: Any) -> Any:
if self.check_event(yaml_module.events.AliasEvent):
event = self.peek_event()
raise yaml_module.YAMLError(
f"ACK YAML 不支持 alias: *{event.anchor}"
)
event = self.peek_event()
if getattr(event, "anchor", None) is not None:
raise yaml_module.YAMLError(
f"ACK YAML 不支持 anchor: &{event.anchor}"
)
if getattr(event, "tag", None) is not None:
raise yaml_module.YAMLError(
f"ACK YAML 不支持显式 tag: {event.tag}"
)
return super().compose_node(parent, index)
def construct_unique_mapping(
loader: Any,
node: Any,
deep: bool = False,
) -> dict[str, Any]:
for key_node, _ in node.value:
if (
getattr(key_node, "tag", None) == "tag:yaml.org,2002:merge"
or getattr(key_node, "value", None) == "<<"
):
raise yaml_module.constructor.ConstructorError(
"while constructing an ACK mapping",
node.start_mark,
"merge keys are not supported",
key_node.start_mark,
)
loader.flatten_mapping(node)
mapping: dict[str, Any] = {}
for key_node, value_node in node.value:
key = loader.construct_object(key_node, deep=deep)
if not isinstance(key, str):
raise yaml_module.constructor.ConstructorError(
"while constructing an ACK mapping",
node.start_mark,
"mapping key must be a string",
key_node.start_mark,
)
if key == "<<":
raise yaml_module.constructor.ConstructorError(
"while constructing an ACK mapping",
node.start_mark,
"merge keys are not supported",
key_node.start_mark,
)
if key in mapping:
raise yaml_module.constructor.ConstructorError(
"while constructing an ACK mapping",
node.start_mark,
f"found duplicate key {key!r}",
key_node.start_mark,
)
mapping[key] = loader.construct_object(value_node, deep=deep)
return mapping
UniqueKeySafeLoader.add_constructor(
yaml_module.resolver.BaseResolver.DEFAULT_MAPPING_TAG,
construct_unique_mapping,
)
return UniqueKeySafeLoader
def load_yaml_subset(content: str) -> Any:
"""Parse the deliberately small YAML subset used by ACK files."""
return _SubsetParser(content).parse()
class _SubsetParser:
def __init__(self, content: str) -> None:
if content.startswith("\ufeff"):
content = content[1:]
if "\t" in content:
raise YamlSubsetError("ACK YAML 子集不支持 Tab,请使用空格")
self.lines = [
_Line(number, len(raw) - len(raw.lstrip(" ")), raw.lstrip(" "))
for number, raw in enumerate(content.splitlines(), start=1)
]
self.index = 0
def parse(self) -> Any:
self._skip_insignificant()
if self.index >= len(self.lines):
return None
first = self.lines[self.index]
if first.indent != 0:
self._error(first, "顶层不能缩进")
value = self._parse_node(0)
self._skip_insignificant()
if self.index != len(self.lines):
line = self.lines[self.index]
self._error(line, "文档尾部存在无法解析的内容")
return value
def _parse_node(self, indent: int) -> Any:
self._skip_insignificant()
if self.index >= len(self.lines):
return None
line = self.lines[self.index]
if line.indent != indent:
self._error(line, f"期望 {indent} 个空格的缩进")
content = self._without_comment(line.content).rstrip()
self._reject_document_syntax(content, line)
if self._is_sequence_marker(content):
return self._parse_sequence(indent)
return self._parse_mapping(indent)
def _parse_mapping(self, indent: int) -> dict[str, Any]:
result: dict[str, Any] = {}
while True:
self._skip_insignificant()
if self.index >= len(self.lines):
break
line = self.lines[self.index]
if line.indent < indent:
break
if line.indent > indent:
self._error(line, "mapping 存在意外缩进")
content = self._without_comment(line.content).rstrip()
self._reject_document_syntax(content, line)
if self._is_sequence_marker(content):
break
self.index += 1
self._consume_mapping_entry(
result,
content,
mapping_indent=indent,
line=line,
)
return result
def _parse_sequence(self, indent: int) -> list[Any]:
result: list[Any] = []
while True:
self._skip_insignificant()
if self.index >= len(self.lines):
break
line = self.lines[self.index]
if line.indent < indent:
break
if line.indent > indent:
self._error(line, "sequence 存在意外缩进")
content = self._without_comment(line.content).rstrip()
self._reject_document_syntax(content, line)
if not self._is_sequence_marker(content):
break
rest = content[1:].lstrip(" ")
self.index += 1
if not rest:
next_line = self._peek_significant()
if next_line is not None and next_line.indent > indent:
result.append(self._parse_node(next_line.indent))
else:
result.append(None)
continue
if self._is_sequence_marker(rest):
self._error(
line,
"不支持紧凑嵌套 sequence,请把内层 '-' 放到下一行",
)
if self._looks_like_mapping_entry(rest):
mapping_indent = indent + 2
item: dict[str, Any] = {}
self._consume_mapping_entry(
item,
rest,
mapping_indent=mapping_indent,
line=line,
)
while True:
self._skip_insignificant()
continuation = self._peek_significant()
if continuation is None or continuation.indent < mapping_indent:
break
if continuation.indent > mapping_indent:
self._error(
continuation,
"sequence mapping 存在意外缩进",
)
continuation_content = self._without_comment(
continuation.content
).rstrip()
if self._is_sequence_marker(continuation_content):
self._error(
continuation,
"sequence mapping 中需要 key: value",
)
self.index += 1
self._consume_mapping_entry(
item,
continuation_content,
mapping_indent=mapping_indent,
line=continuation,
)
result.append(item)
continue
if rest in {">", "|"}:
result.append(self._parse_block_scalar(indent, rest, line))
else:
result.append(self._parse_inline_value(rest, line))
next_line = self._peek_significant()
if next_line is not None and next_line.indent > indent:
self._error(next_line, "scalar sequence 项后存在意外缩进")
return result
def _consume_mapping_entry(
self,
result: dict[str, Any],
content: str,
*,
mapping_indent: int,
line: _Line,
) -> None:
key_text, value_text = self._split_mapping_entry(content, line)
key = self._parse_key(key_text, line)
if key in result:
self._error(line, f"mapping 存在重复键 {key!r}")
value_text = value_text.strip()
if value_text in {">", "|"}:
value = self._parse_block_scalar(mapping_indent, value_text, line)
elif value_text:
if value_text.startswith((">", "|")):
self._error(
line,
"block scalar 只支持 '>''|',不支持 chomping/indent 指示符",
)
value = self._parse_inline_value(value_text, line)
next_line = self._peek_significant()
if next_line is not None and next_line.indent > mapping_indent:
self._error(next_line, f"{key!r} 的 scalar 后存在意外缩进")
else:
next_line = self._peek_significant()
if next_line is not None and next_line.indent > mapping_indent:
value = self._parse_node(next_line.indent)
else:
value = None
result[key] = value
def _parse_block_scalar(
self,
parent_indent: int,
style: str,
header: _Line,
) -> str:
probe = self.index
while probe < len(self.lines) and not self.lines[probe].content.strip():
probe += 1
if probe >= len(self.lines) or self.lines[probe].indent <= parent_indent:
return ""
block_indent = self.lines[probe].indent
if block_indent <= parent_indent:
self._error(header, "block scalar 内容必须比键更深缩进")
values: list[str] = []
while self.index < len(self.lines):
line = self.lines[self.index]
if not line.content.strip():
values.append("")
self.index += 1
continue
if line.indent < block_indent:
break
values.append(" " * (line.indent - block_indent) + line.content)
self.index += 1
while values and values[-1] == "":
values.pop()
if not values:
return ""
if style == "|":
return "\n".join(values) + "\n"
output = ""
previous: str | None = None
blank_count = 0
for value in values:
if value == "":
blank_count += 1
continue
if previous is None:
output = "\n" * blank_count + value
elif blank_count:
output += "\n" * blank_count + value
elif previous.startswith(" ") or value.startswith(" "):
output += "\n" + value
else:
output += " " + value
previous = value
blank_count = 0
return output + "\n"
def _parse_key(self, text: str, line: _Line) -> str:
text = text.strip()
if not text:
self._error(line, "mapping key 不能为空")
if text[0] in {'"', "'"}:
parsed = _FlowParser(text, line.number).parse_complete_value()
if not isinstance(parsed, str):
self._error(line, "mapping key 必须是字符串")
return parsed
if text == "<<":
self._error(line, "ACK YAML 子集不支持 merge key '<<'")
if text[0] in "-?:!&*%@`" or _PLAIN_KEY_FORBIDDEN_RE.search(text):
self._error(line, f"不支持的 plain mapping key {text!r}")
parsed = _plain_scalar(text, line.number)
if not isinstance(parsed, str):
self._error(line, "mapping key 必须是字符串,特殊标量请加引号")
return text
def _parse_inline_value(self, text: str, line: _Line) -> Any:
parser = _FlowParser(text, line.number)
return parser.parse_complete_value()
def _split_mapping_entry(self, content: str, line: _Line) -> tuple[str, str]:
quote: str | None = None
escaped = False
depth = 0
index = 0
while index < len(content):
char = content[index]
if quote == '"':
if escaped:
escaped = False
elif char == "\\":
escaped = True
elif char == quote:
quote = None
index += 1
continue
if quote == "'":
if char == "'":
if index + 1 < len(content) and content[index + 1] == "'":
index += 2
continue
quote = None
index += 1
continue
if char in {'"', "'"}:
quote = char
elif char in "[{":
depth += 1
elif char in "]}":
depth -= 1
if depth < 0:
self._error(line, "flow collection 括号不匹配")
elif (
char == ":"
and depth == 0
and (index + 1 == len(content) or content[index + 1].isspace())
):
return content[:index], content[index + 1 :]
index += 1
self._error(line, "mapping 项必须使用 'key: value'")
def _looks_like_mapping_entry(self, content: str) -> bool:
try:
self._split_mapping_entry(content, self.lines[self.index - 1])
except YamlSubsetError:
return False
return True
@staticmethod
def _is_sequence_marker(content: str) -> bool:
return content == "-" or content.startswith("- ")
def _peek_significant(self) -> _Line | None:
probe = self.index
while probe < len(self.lines):
line = self.lines[probe]
if line.content.strip() and not line.content.lstrip().startswith("#"):
return line
probe += 1
return None
def _skip_insignificant(self) -> None:
while self.index < len(self.lines):
content = self.lines[self.index].content
if content.strip() and not content.lstrip().startswith("#"):
break
self.index += 1
def _without_comment(self, content: str) -> str:
quote: str | None = None
escaped = False
index = 0
while index < len(content):
char = content[index]
if quote == '"':
if escaped:
escaped = False
elif char == "\\":
escaped = True
elif char == quote:
quote = None
elif quote == "'":
if char == "'":
if index + 1 < len(content) and content[index + 1] == "'":
index += 1
else:
quote = None
elif char in {'"', "'"}:
quote = char
elif char == "#" and (index == 0 or content[index - 1].isspace()):
return content[:index]
index += 1
if quote is not None:
raise YamlSubsetError("未结束的引号标量")
return content
def _reject_document_syntax(self, content: str, line: _Line) -> None:
if content in {"---", "..."} or content.startswith("%"):
self._error(line, "ACK YAML 子集只支持单文档,不支持 directive/marker")
if content.startswith(("!", "&", "*")):
self._error(line, "ACK YAML 子集不支持 tag/anchor/alias")
@staticmethod
def _error(line: _Line, message: str) -> None:
raise YamlSubsetError(f"{line.number} 行: {message}")
class _FlowParser:
def __init__(self, text: str, line_number: int) -> None:
self.text = text
self.line_number = line_number
self.index = 0
def parse_complete_value(self) -> Any:
value = self._parse_value()
self._skip_space()
if self.index != len(self.text):
self._error(f"标量后存在未支持内容: {self.text[self.index:]!r}")
return value
def _parse_value(self) -> Any:
self._skip_space()
if self.index >= len(self.text):
self._error("缺少标量")
char = self.text[self.index]
if char == "[":
return self._parse_list()
if char == "{":
return self._parse_map()
if char in {'"', "'"}:
return self._parse_quoted()
if char in "]},":
self._error(f"意外字符 {char!r}")
return self._parse_plain({",", "]", "}"})
def _parse_list(self) -> list[Any]:
self.index += 1
result: list[Any] = []
self._skip_space()
if self._consume("]"):
return result
while True:
result.append(self._parse_value())
self._skip_space()
if self._consume("]"):
return result
if not self._consume(","):
self._error("flow list 项之间必须用 ',' 分隔")
self._skip_space()
if self.index >= len(self.text) or self.text[self.index] == "]":
self._error("flow list 不支持尾随逗号")
def _parse_map(self) -> dict[str, Any]:
self.index += 1
result: dict[str, Any] = {}
self._skip_space()
if self._consume("}"):
return result
while True:
self._skip_space()
if self.index >= len(self.text):
self._error("flow mapping 未结束")
if self.text[self.index] in {'"', "'"}:
key = self._parse_quoted()
else:
key = self._parse_plain({":"}, convert=False)
if not isinstance(key, str) or not key:
self._error("flow mapping key 必须是非空字符串")
if key == "<<":
self._error("ACK YAML 子集不支持 merge key '<<'")
self._skip_space()
if not self._consume(":"):
self._error("flow mapping key 后必须是 ':'")
value = self._parse_value()
if key in result:
self._error(f"flow mapping 存在重复键 {key!r}")
result[key] = value
self._skip_space()
if self._consume("}"):
return result
if not self._consume(","):
self._error("flow mapping 项之间必须用 ',' 分隔")
self._skip_space()
if self.index >= len(self.text) or self.text[self.index] == "}":
self._error("flow mapping 不支持尾随逗号")
def _parse_quoted(self) -> str:
quote = self.text[self.index]
self.index += 1
result: list[str] = []
while self.index < len(self.text):
char = self.text[self.index]
self.index += 1
if char == quote:
if quote == "'" and self.index < len(self.text) and self.text[
self.index
] == "'":
result.append("'")
self.index += 1
continue
return "".join(result)
if quote == "'" or char != "\\":
result.append(char)
continue
if self.index >= len(self.text):
self._error("双引号标量以转义符结尾")
escape = self.text[self.index]
self.index += 1
simple = {
"0": "\0",
"a": "\a",
"b": "\b",
"t": "\t",
"n": "\n",
"v": "\v",
"f": "\f",
"r": "\r",
"e": "\x1b",
" ": " ",
'"': '"',
"/": "/",
"\\": "\\",
}
if escape in simple:
result.append(simple[escape])
continue
widths = {"x": 2, "u": 4, "U": 8}
if escape in widths:
width = widths[escape]
digits = self.text[self.index : self.index + width]
if len(digits) != width or not re.fullmatch(
rf"[0-9a-fA-F]{{{width}}}", digits
):
self._error(f"无效 Unicode 转义 \\{escape}{digits}")
codepoint = int(digits, 16)
try:
result.append(chr(codepoint))
except ValueError as exc:
raise YamlSubsetError(
f"{self.line_number} 行: 无效 Unicode 码点"
) from exc
self.index += width
continue
self._error(f"不支持的双引号转义 \\{escape}")
self._error("引号标量未结束")
def _parse_plain(
self,
delimiters: set[str],
*,
convert: bool = True,
) -> Any:
start = self.index
while self.index < len(self.text) and self.text[self.index] not in delimiters:
self.index += 1
token = self.text[start : self.index].strip()
if not token:
self._error("空 plain scalar")
if "#" in token:
self._error("flow collection 内的注释不受支持")
if ": " in token:
self._error("plain scalar 中的 ': ' 必须加引号")
if token[0] in "!&*%@`?" or _ANCHOR_OR_ALIAS_RE.search(token):
self._error("ACK YAML 子集不支持 tag/anchor/alias/directive")
return _plain_scalar(token, self.line_number) if convert else token
def _skip_space(self) -> None:
while self.index < len(self.text) and self.text[self.index] == " ":
self.index += 1
def _consume(self, expected: str) -> bool:
if self.index < len(self.text) and self.text[self.index] == expected:
self.index += 1
return True
return False
def _error(self, message: str) -> None:
raise YamlSubsetError(f"{self.line_number} 行: {message}")
def _plain_scalar(token: str, line_number: int) -> Any:
lowered = token.lower()
if lowered in {"null", "~"}:
return None
if lowered in {"true", "yes", "on"}:
return True
if lowered in {"false", "no", "off"}:
return False
if _DECIMAL_INT_RE.fullmatch(token):
return int(token, 10)
if _AMBIGUOUS_NUMBER_RE.fullmatch(token):
raise YamlSubsetError(
f"{line_number} 行: ACK YAML 子集不支持该数字格式 {token!r}"
"如需字符串请加引号"
)
return token
+367
View File
@@ -0,0 +1,367 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://git.yumee.top/laily/skills/skills/ack/templates/knowledge.schema.json",
"title": "ACK project knowledge guardrails",
"description": "docs/ack/knowledge.yaml 的权威结构。知识只描述约束和验证引用,不保存可执行命令。",
"type": "object",
"required": ["version", "updatedAt", "project", "verificationRegistry", "entries"],
"additionalProperties": false,
"properties": {
"version": {
"type": "integer",
"minimum": 1
},
"updatedAt": {
"type": "string",
"format": "date-time"
},
"project": {
"type": "object",
"required": ["name"],
"additionalProperties": false,
"properties": {
"name": {
"type": "string",
"minLength": 1,
"pattern": "\\S"
}
}
},
"verificationRegistry": {
"type": "object",
"propertyNames": {
"pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$"
},
"additionalProperties": {
"$ref": "#/definitions/verificationTarget"
}
},
"entries": {
"type": "array",
"items": {
"$ref": "#/definitions/entry"
}
}
},
"definitions": {
"stableRef": {
"type": "string",
"pattern": "^K-[A-Z0-9][A-Z0-9-]*@[1-9][0-9]*$"
},
"stringSet": {
"type": "array",
"uniqueItems": true,
"items": {
"type": "string",
"minLength": 1,
"pattern": "\\S"
}
},
"scope": {
"type": "object",
"required": [
"all",
"components",
"paths",
"dependencies",
"versions",
"tags",
"symbols",
"errorSignatures"
],
"additionalProperties": false,
"properties": {
"all": {
"type": "boolean"
},
"components": {
"$ref": "#/definitions/stringSet"
},
"paths": {
"$ref": "#/definitions/stringSet"
},
"dependencies": {
"$ref": "#/definitions/stringSet"
},
"versions": {
"$ref": "#/definitions/stringSet"
},
"tags": {
"$ref": "#/definitions/stringSet"
},
"symbols": {
"$ref": "#/definitions/stringSet"
},
"errorSignatures": {
"$ref": "#/definitions/stringSet"
}
}
},
"verification": {
"type": "object",
"required": ["ref", "expected"],
"additionalProperties": false,
"properties": {
"ref": {
"type": "string",
"minLength": 1,
"pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$"
},
"expected": {
"type": "string",
"minLength": 1,
"pattern": "\\S"
}
}
},
"verificationTarget": {
"type": "object",
"required": ["path", "args"],
"additionalProperties": false,
"properties": {
"path": {
"type": "string",
"minLength": 1,
"pattern": "^(?=.*\\S)[A-Za-z0-9][A-Za-z0-9._/-]*$",
"description": "项目内可执行文件;运行时 cwd 为项目根,脚本不得依赖自身文件路径定位资源"
},
"args": {
"type": "array",
"items": {
"type": "string"
}
}
}
},
"provenance": {
"type": "object",
"required": ["taskId", "attemptId", "codeRef", "evidenceRef"],
"additionalProperties": false,
"properties": {
"taskId": {
"type": "string",
"minLength": 1,
"pattern": "\\S"
},
"attemptId": {
"type": "string",
"minLength": 1,
"pattern": "\\S"
},
"codeRef": {
"type": "string",
"minLength": 1,
"pattern": "\\S"
},
"evidenceRef": {
"type": "string",
"minLength": 1,
"pattern": "\\S"
}
}
},
"entry": {
"type": "object",
"required": [
"id",
"revision",
"kind",
"status",
"title",
"subject",
"scope",
"appliesWhen",
"directive",
"rationale",
"verification",
"provenance",
"owner",
"author",
"reviewer",
"approval",
"createdAt",
"lastValidatedAt",
"reviewAfter",
"temporary",
"removalCondition",
"statusReason",
"supersedes",
"conflictsWith"
],
"additionalProperties": false,
"properties": {
"id": {
"type": "string",
"pattern": "^K-[A-Z0-9][A-Z0-9-]*$"
},
"revision": {
"type": "integer",
"minimum": 1
},
"kind": {
"type": "string",
"enum": ["guardrail", "pitfall", "verification"]
},
"status": {
"type": "string",
"enum": ["active", "stale", "superseded", "archived"]
},
"title": {
"type": "string",
"minLength": 1,
"pattern": "\\S"
},
"subject": {
"type": "string",
"minLength": 1,
"pattern": "^[a-z0-9][a-z0-9-]*$"
},
"scope": {
"$ref": "#/definitions/scope"
},
"appliesWhen": {
"type": "string",
"minLength": 1,
"pattern": "\\S"
},
"directive": {
"type": "string",
"minLength": 1,
"pattern": "\\S"
},
"rationale": {
"type": "string",
"minLength": 1,
"pattern": "\\S"
},
"verification": {
"$ref": "#/definitions/verification"
},
"provenance": {
"$ref": "#/definitions/provenance"
},
"owner": {
"type": "string",
"minLength": 1,
"pattern": "\\S"
},
"author": {
"type": "string",
"minLength": 1,
"pattern": "\\S"
},
"reviewer": {
"type": "string",
"minLength": 1,
"pattern": "\\S"
},
"approval": {
"oneOf": [
{ "type": "null" },
{ "$ref": "#/definitions/approval" }
]
},
"createdAt": {
"type": "string",
"format": "date-time"
},
"lastValidatedAt": {
"type": "string",
"format": "date-time"
},
"reviewAfter": {
"type": ["string", "null"],
"format": "date-time"
},
"temporary": {
"type": "boolean"
},
"removalCondition": {
"type": ["string", "null"],
"minLength": 1,
"pattern": "\\S"
},
"statusReason": {
"type": ["string", "null"],
"minLength": 1,
"pattern": "\\S"
},
"supersedes": {
"type": "array",
"uniqueItems": true,
"items": {
"$ref": "#/definitions/stableRef"
}
},
"conflictsWith": {
"type": "array",
"uniqueItems": true,
"items": {
"$ref": "#/definitions/stableRef"
}
}
},
"allOf": [
{
"if": {
"properties": {
"temporary": {
"const": true
}
}
},
"then": {
"properties": {
"reviewAfter": {
"type": "string",
"format": "date-time"
},
"removalCondition": {
"type": "string",
"minLength": 1
}
}
}
},
{
"if": {
"properties": {
"status": {
"enum": ["stale", "superseded", "archived"]
}
}
},
"then": {
"properties": {
"statusReason": {
"type": "string",
"minLength": 1
}
}
}
}
]
},
"approval": {
"type": "object",
"required": ["approvedBy", "approvedAt", "evidenceRef"],
"additionalProperties": false,
"properties": {
"approvedBy": {
"type": "string",
"minLength": 1,
"pattern": "\\S"
},
"approvedAt": {
"type": "string",
"format": "date-time"
},
"evidenceRef": {
"type": "string",
"minLength": 1,
"pattern": "\\S"
}
}
}
}
}
@@ -0,0 +1,9 @@
# 复制为 docs/ack/knowledge.yaml,替换占位符。结构见 templates/knowledge.schema.json。
# Developer/Test 只能在任务证据中提出 candidate;只有 Coordinator 写入这里。
version: 1
updatedAt: "<YYYY-MM-DDTHH:mm:ss+TZ>"
project:
name: "<project_name>"
# 检查 ID 映射到仓库内相对 path 和结构化 args;这里的内容不会被校验器执行。
verificationRegistry: {}
entries: []
+23 -4
View File
@@ -5,9 +5,11 @@
>
> **本文件是「项目覆盖层」,文件名可配置。** 默认放 `docs/ack/project.md`
> 不占用 `AGENTS.md`,避免与团队已有的 `AGENTS.md` 约定冲突。
> 若希望 Agent 自动加载,可项目 `AGENTS.md` 里加一行指向本文件,或直接把本文件命名为 `AGENTS.md`
> 若希望 Agent 自动加载,可项目维护者自行在 `AGENTS.md` 中引用本文件;ACK
> 不会自动修改 `AGENTS.md``CLAUDE.md` 或其它 Agent 指令文件。
> 无论叫什么,都在 `tasks.yaml``project.overlayFile` 记录实际路径。
> `docs/ack/` 只保存本项目的 `project.md``tasks.yaml`,不复制或链接 Skill。
> `docs/ack/` 只保存本项目的 `project.md``tasks.yaml``knowledge.yaml`
> 不复制或链接 Skill。
## 项目概览
@@ -16,6 +18,7 @@
- 运行命令:`<run_command>`
- Base URL`<base_url>`
- 任务板:`docs/ack/tasks.yaml`
- 项目知识:`docs/ack/knowledge.yaml`
- 覆盖层文件:`<overlay_file_path>`(默认 `docs/ack/project.md`
## 通用规范(由 ACK Skill 按需读取)
@@ -48,6 +51,7 @@
| `<shared_config_templates>` | Read-only | Read-only | R/W | 可提交配置模板 |
| `<local_config_paths>` | Read-only | Read-only | Read-only | 本地私有配置 |
| `tasks.yaml` | R/W | Read-only | Read-only | 只有 Coordinator 写 |
| `knowledge.yaml` | R/W | Read-only | Read-only | 只有 Coordinator 写;Developer/Test 通过回报提名或验证 |
## 命令(项目覆盖层)
@@ -67,13 +71,28 @@ Test 黑盒复测:
<browser_regression_command>
```
任务板校验由 `/ack` 使用 Skill 自带的 `scripts/validate_tasks.py` 执行。
知识项的 `verification.ref` 只能引用 `knowledge.yaml.verificationRegistry` 中已
审查的检查入口。Registry 只保存仓库内相对 path 和结构化 args,不保存或执行自由
shell 命令;新项目没有知识时保持空对象。Developer/Test 只能把 registry ID 交给
Skill 的 `scripts/run_verification.py` 执行,不直接拼接 path/args。检查脚本从项目
根 cwd 或 `ACK_PROJECT_ROOT` 定位资源,不能依赖自身文件路径。
`ACK_PROJECT_ROOT` 是 runner 固定的根目录 fd 路径;仅用于日志的原始路径位于
`ACK_PROJECT_ROOT_DISPLAY`
项目状态校验由 `/ack` 使用 Skill 自带的 `scripts/validate_tasks.py`
`scripts/validate_knowledge.py` 执行。
## 硬规则(其余见 references/
- 三角色独立:Coordinator 只编排、Test 只验证、Developer 只实现(验证者 ≠ 实现者)。
- 模型分层:Coordinator 用强模型且不亲自跑测试,Test/Developer 用中低模型,必要时升级(见 references/model-routing.md)。
- `worker_done` 与复测报告都不等于完成。必须 Test 独立复测 + Coordinator 终检后才能 `verified`
- 只有 Coordinator 写 `tasks.yaml`Developer 与 Test 都只读,通过消息回报。
- 只有 Coordinator 写 `tasks.yaml``knowledge.yaml`Developer 与 Test 都只读,
通过消息回报。
- Coordinator 只派发按 scope 命中并显式写入 `knowledgeRefs``active` 知识;
`candidate` 不派发,知识库不全量注入。
- Developer 回报 `knowledgeApplied``knowledgeCandidates`Test 回报
`knowledgeChecks`。关键约束应下沉为测试、lint、CI 或正式规范。
- ACK 不自动修改 `AGENTS.md``CLAUDE.md` 或其它 Agent 指令文件。
- 每个任务最多派发 3 轮,仍不过标记 `leftover` 并继续下一个。
- 不提交或推送,除非用户明确要求。
+147 -4
View File
@@ -23,13 +23,18 @@
"required": ["name"],
"additionalProperties": true,
"properties": {
"name": { "type": "string" },
"name": { "type": "string", "minLength": 1, "pattern": "\\S" },
"repoPath": { "type": "string" },
"baseUrl": { "type": "string" },
"devWorktree": { "type": "string" },
"overlayFile": {
"type": "string",
"description": "项目覆盖层文件路径,默认 docs/ack/project.md,可自定义"
},
"knowledgeFile": {
"type": "string",
"const": "docs/ack/knowledge.yaml",
"description": "项目知识护栏库的唯一权威路径"
}
}
},
@@ -68,25 +73,155 @@
"required": ["round", "result"],
"additionalProperties": true,
"properties": {
"round": { "type": "integer", "minimum": 1 },
"round": { "type": "integer", "minimum": 1, "maximum": 3 },
"attemptId": {
"type": "string",
"pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*-A[1-9][0-9]*$",
"description": "稳定逻辑轮次 ID,应为 <task-id>-A<round>;旧轮次可缺省,但作为知识来源前必须补齐"
},
"result": { "type": "string", "enum": ["passed", "failed"] },
"evidence": { "type": "string" }
}
},
"knowledgeRef": {
"type": "string",
"pattern": "^K-[A-Z0-9][A-Z0-9-]*@[1-9][0-9]*$"
},
"knowledgeCandidate": {
"type": "object",
"required": [
"kind",
"title",
"claim",
"scope",
"appliesWhen",
"directive",
"rationale",
"evidenceRefs"
],
"additionalProperties": false,
"properties": {
"kind": {
"type": "string",
"enum": ["guardrail", "pitfall", "verification"]
},
"title": { "type": "string", "minLength": 1 },
"claim": { "type": "string", "minLength": 1 },
"scope": {
"type": "object",
"minProperties": 1,
"additionalProperties": false,
"properties": {
"all": { "type": "boolean" },
"components": {
"type": "array",
"items": { "type": "string", "minLength": 1 },
"uniqueItems": true
},
"paths": {
"type": "array",
"items": { "type": "string", "minLength": 1 },
"uniqueItems": true
},
"dependencies": {
"type": "array",
"items": { "type": "string", "minLength": 1 },
"uniqueItems": true
},
"versions": {
"type": "array",
"items": { "type": "string", "minLength": 1 },
"uniqueItems": true
},
"tags": {
"type": "array",
"items": { "type": "string", "minLength": 1 },
"uniqueItems": true
},
"symbols": {
"type": "array",
"items": { "type": "string", "minLength": 1 },
"uniqueItems": true
},
"errorSignatures": {
"type": "array",
"items": { "type": "string", "minLength": 1 },
"uniqueItems": true
}
}
},
"appliesWhen": { "type": "string", "minLength": 1 },
"directive": { "type": "string", "minLength": 1 },
"rationale": { "type": "string", "minLength": 1 },
"evidenceRefs": {
"type": "array",
"minItems": 1,
"items": { "type": "string", "minLength": 1 },
"uniqueItems": true
},
"proposedBy": { "type": "string" },
"proposedAt": { "type": "string" }
}
},
"knowledgeApplication": {
"type": "object",
"required": ["ref", "result", "evidence"],
"additionalProperties": false,
"properties": {
"ref": { "$ref": "#/definitions/knowledgeRef" },
"result": {
"type": "string",
"enum": ["applied", "not_applicable"]
},
"evidence": { "type": "string", "minLength": 1 }
}
},
"knowledgeCheck": {
"type": "object",
"required": ["ref", "result", "evidence"],
"additionalProperties": false,
"properties": {
"ref": { "$ref": "#/definitions/knowledgeRef" },
"result": {
"type": "string",
"enum": ["passed", "failed", "not_applicable"]
},
"evidence": { "type": "string", "minLength": 1 },
"checkedBy": { "type": "string" },
"checkedAt": { "type": "string" }
}
},
"task": {
"type": "object",
"required": ["id", "title", "status"],
"additionalProperties": true,
"properties": {
"id": { "type": "string", "minLength": 1 },
"id": { "type": "string", "minLength": 1, "pattern": "\\S" },
"type": { "type": "string" },
"title": { "type": "string", "minLength": 1 },
"title": { "type": "string", "minLength": 1, "pattern": "\\S" },
"priority": { "type": "string" },
"status": { "$ref": "#/definitions/status" },
"assignee": { "type": "string" },
"component": { "type": "string" },
"specRefs": { "type": "array", "items": { "type": "string" } },
"testRefs": { "type": "array", "items": { "type": "string" } },
"knowledgeRefs": {
"type": "array",
"items": { "$ref": "#/definitions/knowledgeRef" },
"uniqueItems": true
},
"knowledgeApplied": {
"type": "array",
"items": { "$ref": "#/definitions/knowledgeApplication" }
},
"knowledgeCandidates": {
"type": "array",
"items": { "$ref": "#/definitions/knowledgeCandidate" }
},
"knowledgeChecks": {
"type": "array",
"items": { "$ref": "#/definitions/knowledgeCheck" }
},
"description": { "type": "string" },
"stepsToReproduce": { "type": "array", "items": { "type": "string" } },
"expected": { "type": "string" },
@@ -124,6 +259,14 @@
"then": {
"properties": {
"resolution": {
"type": "object",
"properties": {
"leftoverReason": {
"type": "string",
"minLength": 1,
"pattern": "\\S"
}
},
"required": ["leftoverReason"]
}
},
+5
View File
@@ -9,6 +9,7 @@ project:
baseUrl: "<base_url>"
devWorktree: "<dev_worktree>"
overlayFile: "docs/ack/project.md"
knowledgeFile: "docs/ack/knowledge.yaml"
summary:
verified: []
@@ -28,6 +29,10 @@ tasks:
- "<docs/spec.md#section>"
testRefs:
- "<tests/browser/cases/01-case.md>"
knowledgeRefs: []
knowledgeApplied: []
knowledgeCandidates: []
knowledgeChecks: []
description: >
<What is wrong, in user-visible terms.>
+937
View File
@@ -0,0 +1,937 @@
from __future__ import annotations
import copy
import json
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
from unittest import mock
import yaml
REPO_ROOT = Path(__file__).resolve().parents[1]
ACK_DIR = REPO_ROOT / "skills" / "ack"
SCRIPTS_DIR = ACK_DIR / "scripts"
SCHEMA_PATH = ACK_DIR / "templates" / "knowledge.schema.json"
EXAMPLE_PATH = ACK_DIR / "examples" / "knowledge.example.yaml"
TASKS_EXAMPLE_PATH = ACK_DIR / "examples" / "tasks.example.yaml"
sys.path.insert(0, str(SCRIPTS_DIR))
import select_knowledge # noqa: E402
import validate_knowledge # noqa: E402
def example_data() -> dict:
return yaml.safe_load(EXAMPLE_PATH.read_text(encoding="utf-8"))
class AckKnowledgeTests(unittest.TestCase):
def test_schema_template_and_example_are_aligned(self) -> None:
schema = json.loads(SCHEMA_PATH.read_text(encoding="utf-8"))
template = yaml.safe_load(
(ACK_DIR / "templates" / "knowledge.template.yaml").read_text(
encoding="utf-8"
)
)
self.assertEqual(
schema["definitions"]["entry"]["properties"]["kind"]["enum"],
["guardrail", "pitfall", "verification"],
)
self.assertEqual(
schema["definitions"]["entry"]["properties"]["status"]["enum"],
["active", "stale", "superseded", "archived"],
)
self.assertEqual(template["entries"], [])
self.assertEqual(template["verificationRegistry"], {})
errors, _ = validate_knowledge.validate_all(
example_data(), SCHEMA_PATH, use_schema=False
)
self.assertEqual(errors, [])
self.assertEqual(
validate_knowledge.stable_ref(example_data()["entries"][0]), "K-001@1"
)
def test_semantics_run_even_when_schema_reports_no_errors(self) -> None:
data = example_data()
data["entries"].append(copy.deepcopy(data["entries"][0]))
with mock.patch.object(
validate_knowledge, "validate_with_schema", return_value=[]
):
errors, mode = validate_knowledge.validate_all(
data, SCHEMA_PATH, use_schema=True
)
self.assertIn("内置语义", mode)
self.assertTrue(any("稳定引用 K-001@1 重复" in error for error in errors))
def test_blank_required_text_fails_in_builtin_and_schema_modes(self) -> None:
data = example_data()
data["entries"][0]["title"] = " \t "
builtin_errors, _ = validate_knowledge.validate_all(
data, SCHEMA_PATH, use_schema=False
)
with mock.patch.object(
validate_knowledge, "validate_with_schema", return_value=[]
):
schema_errors, _ = validate_knowledge.validate_all(
data, SCHEMA_PATH, use_schema=True
)
self.assertTrue(any("title: 必须是非空字符串" in e for e in builtin_errors))
self.assertTrue(any("title: 必须是非空字符串" in e for e in schema_errors))
schema = json.loads(SCHEMA_PATH.read_text(encoding="utf-8"))
self.assertEqual(
schema["definitions"]["entry"]["properties"]["title"]["pattern"],
"\\S",
)
def test_temporary_entry_requires_removal_condition_and_review_date(self) -> None:
data = example_data()
entry = data["entries"][0]
entry["temporary"] = True
entry["removalCondition"] = None
entry["reviewAfter"] = None
errors = validate_knowledge.validate_semantics(data)
self.assertTrue(any("removalCondition" in error for error in errors))
self.assertTrue(any("reviewAfter" in error for error in errors))
def test_overdue_active_entry_requires_revalidation(self) -> None:
data = example_data()
data["entries"][0]["reviewAfter"] = "2020-01-01T00:00:00+00:00"
errors = validate_knowledge.validate_semantics(data)
self.assertTrue(any("必须重新验证或标记 stale" in error for error in errors))
def test_global_active_entry_requires_decision_owner_approval(self) -> None:
data = example_data()
data["entries"][0]["scope"] = {
"all": True,
"components": [],
"paths": [],
"dependencies": [],
"versions": [],
"tags": [],
"symbols": [],
"errorSignatures": [],
}
errors = validate_knowledge.validate_semantics(data)
self.assertTrue(any("Decision Owner approval" in error for error in errors))
def test_active_conflicts_and_duplicate_active_revision_are_rejected(self) -> None:
data = example_data()
conflicting = copy.deepcopy(data["entries"][0])
conflicting.update(
{
"id": "K-002",
"subject": "service-process-alignment",
"conflictsWith": ["K-001@1"],
}
)
data["entries"].append(conflicting)
errors = validate_knowledge.validate_semantics(data)
self.assertTrue(any("active 条目不能与 active K-001@1 冲突" in e for e in errors))
conflicting["id"] = "K-001"
conflicting["revision"] = 2
conflicting["conflictsWith"] = []
errors = validate_knowledge.validate_semantics(data)
self.assertTrue(any("多个 active revision" in error for error in errors))
def test_verification_ref_must_resolve_to_contained_registry_target(self) -> None:
data = example_data()
data["entries"][0]["verification"]["ref"] = "missing-check"
errors = validate_knowledge.validate_semantics(data)
self.assertTrue(any("未在 verificationRegistry 注册" in e for e in errors))
data = example_data()
data["verificationRegistry"]["service-worktree-alignment"]["path"] = "../run.sh"
errors = validate_knowledge.validate_semantics(data)
self.assertTrue(any("必须是仓库内相对路径" in error for error in errors))
def test_verification_registry_rejects_any_symlink_component(self) -> None:
data = example_data()
data["verificationRegistry"]["service-worktree-alignment"]["path"] = (
"checks/verify.py"
)
with tempfile.TemporaryDirectory() as temp_dir:
base = Path(temp_dir)
project = base / "project"
outside = base / "outside"
project.mkdir()
outside.mkdir()
(project / "checks").symlink_to(outside, target_is_directory=True)
errors = validate_knowledge.validate_semantics(
data, project_root=project
)
self.assertTrue(any("symlink" in error for error in errors))
data = example_data()
data["verificationRegistry"]["service-worktree-alignment"]["path"] = (
"checks/verify.py"
)
with tempfile.TemporaryDirectory() as temp_dir:
project = Path(temp_dir) / "project"
real_checks = project / "real-checks"
real_checks.mkdir(parents=True)
target = real_checks / "verify.py"
target.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8")
target.chmod(target.stat().st_mode | 0o111)
(project / "checks").symlink_to(
real_checks,
target_is_directory=True,
)
errors = validate_knowledge.validate_semantics(
data,
project_root=project,
)
self.assertTrue(any("symlink" in error for error in errors))
data = example_data()
data["verificationRegistry"]["service-worktree-alignment"]["path"] = (
"tests/check.sh;touch"
)
errors = validate_knowledge.validate_semantics(data)
self.assertTrue(any("必须是仓库内相对路径" in error for error in errors))
def test_verification_registry_requires_regular_executable_target(self) -> None:
data = example_data()
data["verificationRegistry"]["service-worktree-alignment"]["path"] = (
"checks/verify"
)
with tempfile.TemporaryDirectory() as temp_dir:
project = Path(temp_dir) / "project"
project.mkdir()
missing = validate_knowledge.validate_semantics(
data,
project_root=project,
)
self.assertTrue(any("不存在" in error for error in missing))
target = project / "checks" / "verify"
target.parent.mkdir()
target.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8")
not_executable = validate_knowledge.validate_semantics(
data,
project_root=project,
)
self.assertTrue(
any("不可执行" in error for error in not_executable)
)
target.chmod(target.stat().st_mode | 0o111)
valid = validate_knowledge.validate_semantics(
data,
project_root=project,
)
self.assertFalse(
any("verificationRegistry" in error for error in valid),
valid,
)
def test_free_command_fields_are_rejected_and_never_executed(self) -> None:
data = example_data()
data["entries"][0]["command"] = "echo unsafe"
errors = validate_knowledge.validate_semantics(data)
self.assertTrue(any("不保存或执行自由命令" in error for error in errors))
with tempfile.TemporaryDirectory() as temp_dir:
marker = Path(temp_dir) / "should-not-exist"
safe = example_data()
safe["entries"][0]["directive"] = f"touch {marker}"
selected = select_knowledge.select_entries(
safe,
{
"components": ["web"],
"paths": ["web/app.py"],
"dependencies": [],
"versions": [],
"tags": ["long-running-service"],
"symbols": [],
"errorSignatures": [],
},
)
self.assertEqual(len(selected), 1)
self.assertFalse(marker.exists())
def test_common_secret_material_is_rejected(self) -> None:
data = example_data()
data["entries"][0]["rationale"] = (
"debug token=sk-abcdefghijklmnopqrstuvwxyz123456"
)
errors = validate_knowledge.validate_semantics(data)
self.assertTrue(any("OpenAI-style token" in error for error in errors))
self.assertTrue(any("脱敏摘要" in error for error in errors))
def test_selection_is_active_deterministic_and_limited(self) -> None:
data = example_data()
stale = copy.deepcopy(data["entries"][0])
stale.update(
{
"id": "K-002",
"status": "stale",
"statusReason": "相关服务已移除",
}
)
global_entry = copy.deepcopy(data["entries"][0])
global_entry.update(
{
"id": "K-003",
"subject": "global-release-check",
"scope": {
"all": True,
"components": [],
"paths": [],
"dependencies": [],
"versions": [],
"tags": [],
"symbols": [],
"errorSignatures": [],
},
}
)
data["entries"].extend([stale, global_entry])
context = {
"components": ["web"],
"paths": ["web/app.py"],
"dependencies": [],
"versions": [],
"tags": ["long-running-service"],
"symbols": [],
"errorSignatures": [],
}
selected = select_knowledge.select_entries(data, context, limit=1)
self.assertEqual(
[validate_knowledge.stable_ref(entry) for entry in selected], ["K-003@1"]
)
self.assertFalse(
select_knowledge.scope_matches(
data["entries"][0]["scope"],
{**context, "tags": []},
)
)
def test_global_entries_are_never_silently_dropped_by_limit(self) -> None:
data = example_data()
for number in range(2, 13):
scoped = copy.deepcopy(data["entries"][0])
scoped.update(
{
"id": f"K-{number:03d}",
"subject": f"scoped-check-{number}",
}
)
data["entries"].append(scoped)
global_entry = copy.deepcopy(data["entries"][0])
global_entry.update(
{
"id": "K-999",
"subject": "global-release-check",
"scope": {
"all": True,
"components": [],
"paths": [],
"dependencies": [],
"versions": [],
"tags": [],
"symbols": [],
"errorSignatures": [],
},
}
)
data["entries"].append(global_entry)
context = {
"components": ["web"],
"paths": ["web/app.py"],
"dependencies": [],
"versions": [],
"tags": ["long-running-service"],
"symbols": [],
"errorSignatures": [],
}
selected = select_knowledge.select_entries(data, context, limit=10)
refs = [validate_knowledge.stable_ref(entry) for entry in selected]
self.assertEqual(len(refs), 10)
self.assertIn("K-999@1", refs)
second_global = copy.deepcopy(global_entry)
second_global.update({"id": "K-998", "subject": "global-security-check"})
data["entries"].append(second_global)
with self.assertRaisesRegex(ValueError, "不能静默丢弃"):
select_knowledge.select_entries(data, context, limit=1)
def test_path_glob_does_not_let_single_star_cross_directory(self) -> None:
self.assertTrue(select_knowledge._path_glob_matches("web/app.py", "web/*"))
self.assertFalse(
select_knowledge._path_glob_matches("web/pages/app.py", "web/*")
)
self.assertTrue(
select_knowledge._path_glob_matches("web/pages/app.py", "web/**")
)
def test_limit_prefers_narrow_scope_without_rewarding_or_patterns(self) -> None:
data = example_data()
narrow = copy.deepcopy(data["entries"][0])
narrow.update({"id": "K-002", "subject": "narrow-service-check"})
narrow["scope"]["paths"] = ["web/special/**"]
broad_or = copy.deepcopy(data["entries"][0])
broad_or.update({"id": "K-003", "subject": "broad-or-service-check"})
broad_or["scope"]["paths"] = ["web/special/**", "api/**"]
data["entries"].extend([narrow, broad_or])
context = {
"components": ["web"],
"paths": ["web/special/pages/app.py"],
"dependencies": [],
"versions": [],
"tags": ["long-running-service"],
"symbols": [],
"errorSignatures": [],
}
selected = select_knowledge.select_entries(data, context, limit=1)
self.assertEqual(validate_knowledge.stable_ref(selected[0]), "K-002@1")
def test_task_cross_validation_requires_active_exact_ref_and_passed_check(self) -> None:
data = example_data()
task = {
"id": "BUG-002",
"status": "verified",
"knowledgeRefs": ["K-001@1"],
"knowledgeApplied": [
{
"ref": "K-001@1",
"result": "applied",
"evidence": "developer report",
}
],
"knowledgeChecks": [
{
"ref": "K-001@1",
"result": "passed",
"evidence": "test report",
}
],
"dispatch": {
"rounds": [
{
"round": 1,
"attemptId": "BUG-002-A1",
"result": "failed",
},
{
"round": 2,
"attemptId": "BUG-002-A2",
"result": "passed",
}
]
},
}
tasks_data = {
"project": {
"name": "notes-web",
"knowledgeFile": "docs/ack/knowledge.yaml",
},
"tasks": [task],
}
self.assertEqual(
validate_knowledge.validate_task_references(data, tasks_data), []
)
task["knowledgeChecks"][0]["result"] = "not_applicable"
errors = validate_knowledge.validate_task_references(data, tasks_data)
self.assertTrue(any("缺少 passed knowledgeCheck" in error for error in errors))
data["entries"][0]["kind"] = "pitfall"
errors = validate_knowledge.validate_task_references(data, tasks_data)
self.assertTrue(any("缺少 passed knowledgeCheck" in error for error in errors))
task["knowledgeRefs"] = ["K-001@2"]
errors = validate_knowledge.validate_task_references(data, tasks_data)
self.assertTrue(any("找不到精确版本 K-001@2" in error for error in errors))
def test_task_cross_validation_rejects_another_project(self) -> None:
data = example_data()
tasks = {
"project": {
"name": "another-project",
"knowledgeFile": "docs/ack/knowledge.yaml",
},
"tasks": [],
}
errors = validate_knowledge.validate_task_references(data, tasks)
self.assertTrue(any("与知识库项目" in error for error in errors))
def test_cross_validation_requires_traceable_explicit_attempt_id(self) -> None:
data = example_data()
task = {
"id": "BUG-002",
"status": "open",
"dispatch": {
"rounds": [
{
"round": 1,
"attemptId": "BUG-002-A1",
"result": "failed",
},
{
"round": 2,
"attemptId": "orca-dispatch-91",
"result": "passed",
}
]
},
}
tasks_data = {
"project": {
"name": "notes-web",
"knowledgeFile": "docs/ack/knowledge.yaml",
},
"tasks": [task],
}
errors = validate_knowledge.validate_task_references(data, tasks_data)
self.assertTrue(any("必须精确等于 'BUG-002-A2'" in e for e in errors))
self.assertTrue(any("未命中任务 'BUG-002'" in e for e in errors))
task["dispatch"]["rounds"][1]["attemptId"] = "BUG-002-A2"
errors = validate_knowledge.validate_task_references(data, tasks_data)
self.assertFalse(any("provenance" in error for error in errors))
def test_cross_validation_rejects_non_contiguous_rounds(self) -> None:
data = example_data()
data["entries"][0]["provenance"]["attemptId"] = "BUG-002-A3"
tasks_data = {
"project": {
"name": "notes-web",
"knowledgeFile": "docs/ack/knowledge.yaml",
},
"tasks": [
{
"id": "BUG-002",
"status": "open",
"dispatch": {
"rounds": [
{
"round": 3,
"attemptId": "BUG-002-A3",
"result": "passed",
}
]
},
}
],
}
errors = validate_knowledge.validate_task_references(data, tasks_data)
self.assertTrue(any("round 必须按 1..N 连续" in error for error in errors))
def test_cross_validation_binds_configured_knowledge_file(self) -> None:
data = example_data()
task = {
"id": "BUG-002",
"status": "open",
"dispatch": {
"rounds": [
{
"round": 1,
"attemptId": "BUG-002-A1",
"result": "failed",
},
{
"round": 2,
"attemptId": "BUG-002-A2",
"result": "passed",
}
]
},
}
with tempfile.TemporaryDirectory() as temp_dir:
project = Path(temp_dir) / "project"
ack_dir = project / "docs" / "ack"
ack_dir.mkdir(parents=True)
knowledge_path = ack_dir / "knowledge.yaml"
tasks_path = ack_dir / "tasks.yaml"
knowledge_path.write_text(
yaml.safe_dump(data, allow_unicode=True), encoding="utf-8"
)
tasks_data = {
"project": {
"name": "notes-web",
"repoPath": str(project),
"knowledgeFile": "docs/ack/other.yaml",
},
"tasks": [task],
}
errors = validate_knowledge.validate_task_references(
data,
tasks_data,
knowledge_path=knowledge_path,
tasks_path=tasks_path,
)
self.assertTrue(any("与当前知识文件" in error for error in errors))
del tasks_data["project"]["knowledgeFile"]
errors = validate_knowledge.validate_task_references(data, tasks_data)
self.assertTrue(any("project.knowledgeFile 必填" in e for e in errors))
tasks_data["project"]["knowledgeFile"] = "docs/ack/knowledge.yaml"
staging_root = Path(temp_dir) / "staging"
staged = staging_root / "docs" / "ack" / "knowledge.yaml"
staged.parent.mkdir(parents=True)
staged.write_text(
yaml.safe_dump(data, allow_unicode=True), encoding="utf-8"
)
errors = validate_knowledge.validate_task_references(
data,
tasks_data,
knowledge_path=staged,
tasks_path=tasks_path,
project_root=staging_root,
project_root_is_explicit=True,
)
self.assertFalse(any("knowledgeFile" in error for error in errors))
def test_cli_fails_closed_for_missing_repo_root_and_wrong_binding(self) -> None:
data = example_data()
task = {
"id": "BUG-002",
"title": "source task",
"status": "open",
"dispatch": {
"rounds": [
{
"round": 1,
"attemptId": "BUG-002-A1",
"result": "failed",
},
{
"round": 2,
"attemptId": "BUG-002-A2",
"result": "passed",
}
]
},
}
with tempfile.TemporaryDirectory() as temp_dir:
base = Path(temp_dir)
project = base / "project"
ack_dir = project / "docs" / "ack"
outside = base / "outside"
ack_dir.mkdir(parents=True)
outside.mkdir()
(project / "checks").symlink_to(outside, target_is_directory=True)
data["verificationRegistry"]["service-worktree-alignment"]["path"] = (
"checks/verify.py"
)
knowledge_path = ack_dir / "knowledge.yaml"
knowledge_path.write_text(
yaml.safe_dump(data, allow_unicode=True), encoding="utf-8"
)
tasks_data = {
"version": 1,
"project": {
"name": "notes-web",
"repoPath": str(project / "missing"),
"knowledgeFile": "docs/ack/not-the-current-file.yaml",
},
"tasks": [task],
}
tasks_path = ack_dir / "tasks.yaml"
tasks_path.write_text(
yaml.safe_dump(tasks_data, allow_unicode=True), encoding="utf-8"
)
command = [
sys.executable,
str(SCRIPTS_DIR / "validate_knowledge.py"),
str(knowledge_path),
"--tasks",
str(tasks_path),
]
inferred = subprocess.run(
command,
capture_output=True,
text=True,
check=False,
)
explicit = subprocess.run(
[*command, "--project-root", str(project)],
capture_output=True,
text=True,
check=False,
)
self.assertEqual(inferred.returncode, 1)
self.assertIn("project.repoPath", inferred.stderr)
self.assertIn("与当前知识文件", inferred.stderr)
self.assertIn("symlink", inferred.stderr)
self.assertEqual(explicit.returncode, 1)
self.assertIn("与当前知识文件", explicit.stderr)
self.assertIn("symlink", explicit.stderr)
def test_cli_requires_root_for_nonempty_registry_outside_project_layout(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
knowledge_path = Path(temp_dir) / "knowledge.yaml"
knowledge_path.write_text(
EXAMPLE_PATH.read_text(encoding="utf-8"), encoding="utf-8"
)
result = subprocess.run(
[
sys.executable,
str(SCRIPTS_DIR / "validate_knowledge.py"),
str(knowledge_path),
],
capture_output=True,
text=True,
check=False,
)
self.assertEqual(result.returncode, 1)
self.assertIn("无法确定项目根目录", result.stderr)
self.assertIn("--project-root", result.stderr)
def test_terminal_task_keeps_historical_ref_after_entry_becomes_stale(self) -> None:
data = example_data()
data["entries"][0]["status"] = "stale"
data["entries"][0]["statusReason"] = "service architecture changed"
task = {
"id": "BUG-002",
"status": "verified",
"knowledgeRefs": ["K-001@1"],
"knowledgeApplied": [],
"knowledgeChecks": [
{
"ref": "K-001@1",
"result": "passed",
"evidence": "historical test evidence",
}
],
"dispatch": {
"rounds": [
{
"round": 1,
"attemptId": "BUG-002-A1",
"result": "failed",
},
{
"round": 2,
"attemptId": "BUG-002-A2",
"result": "passed",
}
]
},
}
tasks_data = {
"project": {
"name": "notes-web",
"knowledgeFile": "docs/ack/knowledge.yaml",
},
"tasks": [task],
}
terminal_errors = validate_knowledge.validate_task_references(
data, tasks_data
)
self.assertFalse(any("必须 active" in error for error in terminal_errors))
task["status"] = "open"
active_errors = validate_knowledge.validate_task_references(
data, tasks_data
)
self.assertTrue(any("必须 active" in error for error in active_errors))
def test_cli_validates_tasks_and_selector_outputs_stable_refs(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
project = Path(temp_dir) / "project"
ack_dir = project / "docs" / "ack"
ack_dir.mkdir(parents=True)
knowledge_path = ack_dir / "knowledge.yaml"
knowledge_path.write_text(
EXAMPLE_PATH.read_text(encoding="utf-8"), encoding="utf-8"
)
target = project / "tests" / "ack" / "check_service_worktree.py"
target.parent.mkdir(parents=True)
target.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8")
target.chmod(target.stat().st_mode | 0o111)
tasks = {
"project": {
"name": "notes-web",
"repoPath": str(project),
"knowledgeFile": "docs/ack/knowledge.yaml",
},
"tasks": [
{
"id": "BUG-002",
"status": "verified",
"knowledgeRefs": ["K-001@1"],
"knowledgeApplied": [],
"knowledgeChecks": [
{
"ref": "K-001@1",
"result": "passed",
"evidence": "independent retest",
}
],
"dispatch": {
"rounds": [
{
"round": 1,
"attemptId": "BUG-002-A1",
"result": "failed",
},
{
"round": 2,
"attemptId": "BUG-002-A2",
"result": "passed",
}
]
},
}
],
}
tasks_path = ack_dir / "tasks.yaml"
tasks_path.write_text(
yaml.safe_dump(tasks, allow_unicode=True), encoding="utf-8"
)
validated = subprocess.run(
[
sys.executable,
str(SCRIPTS_DIR / "validate_knowledge.py"),
str(knowledge_path),
"--tasks",
str(tasks_path),
],
capture_output=True,
text=True,
check=False,
)
self.assertEqual(validated.returncode, 0, validated.stderr)
selected = subprocess.run(
[
sys.executable,
str(SCRIPTS_DIR / "select_knowledge.py"),
str(knowledge_path),
"--component",
"web",
"--path",
"web/app.py",
"--tag",
"long-running-service",
"--limit",
"1",
"--format",
"refs",
],
capture_output=True,
text=True,
check=False,
)
self.assertEqual(selected.returncode, 0, selected.stderr)
self.assertEqual(selected.stdout.strip(), "K-001@1")
selected_json = subprocess.run(
[
sys.executable,
str(SCRIPTS_DIR / "select_knowledge.py"),
str(knowledge_path),
"--component",
"web",
"--path",
"web/app.py",
"--tag",
"long-running-service",
],
capture_output=True,
text=True,
check=False,
)
self.assertEqual(selected_json.returncode, 0, selected_json.stderr)
payload = json.loads(selected_json.stdout)
self.assertEqual(
payload["entries"][0]["verificationTarget"]["path"],
"tests/ack/check_service_worktree.py",
)
def test_paired_examples_validate_when_installed_in_project_layout(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
project = Path(temp_dir) / "notes-web"
ack_dir = project / "docs" / "ack"
ack_dir.mkdir(parents=True)
knowledge_path = ack_dir / "knowledge.yaml"
tasks_path = ack_dir / "tasks.yaml"
knowledge_path.write_text(
EXAMPLE_PATH.read_text(encoding="utf-8"),
encoding="utf-8",
)
tasks = yaml.safe_load(
TASKS_EXAMPLE_PATH.read_text(encoding="utf-8")
)
tasks["project"]["repoPath"] = str(project)
tasks["project"]["devWorktree"] = str(project)
tasks_path.write_text(
yaml.safe_dump(tasks, allow_unicode=True, sort_keys=False),
encoding="utf-8",
)
target = project / "tests" / "ack" / "check_service_worktree.py"
target.parent.mkdir(parents=True)
target.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8")
target.chmod(target.stat().st_mode | 0o111)
tasks_result = subprocess.run(
[
sys.executable,
str(SCRIPTS_DIR / "validate_tasks.py"),
str(tasks_path),
],
capture_output=True,
text=True,
check=False,
)
knowledge_result = subprocess.run(
[
sys.executable,
str(SCRIPTS_DIR / "validate_knowledge.py"),
str(knowledge_path),
"--tasks",
str(tasks_path),
"--project-root",
str(project),
],
capture_output=True,
text=True,
check=False,
)
self.assertEqual(tasks_result.returncode, 0, tasks_result.stderr)
self.assertEqual(
knowledge_result.returncode,
0,
knowledge_result.stderr,
)
if __name__ == "__main__":
unittest.main()
+17
View File
@@ -15,8 +15,11 @@ class AckSkillContentTests(unittest.TestCase):
"skiff init ack --project <project-root>",
"docs/ack/project.md",
"docs/ack/tasks.yaml",
"docs/ack/knowledge.yaml",
"tasks: []",
"validate_tasks.py",
"validate_knowledge.py",
"select_knowledge.py",
"references/kickoff.md",
"不要修改项目的 `AGENTS.md`",
"当前会话担任 Coordinator",
@@ -31,6 +34,20 @@ class AckSkillContentTests(unittest.TestCase):
self.assertIn('display_name: "ACK"', metadata)
self.assertIn("allow_implicit_invocation: false", metadata)
def test_ack_knowledge_resources_and_version_are_present(self) -> None:
ack_dir = REPO_ROOT / "skills" / "ack"
for relative_path in (
"templates/knowledge.template.yaml",
"templates/knowledge.schema.json",
"examples/knowledge.example.yaml",
"scripts/validate_knowledge.py",
"scripts/select_knowledge.py",
"scripts/run_verification.py",
):
self.assertTrue((ack_dir / relative_path).is_file(), relative_path)
self.assertEqual((ack_dir / "VERSION").read_text(encoding="utf-8").strip(), "0.9.0")
if __name__ == "__main__":
unittest.main()
+705
View File
@@ -0,0 +1,705 @@
from __future__ import annotations
import json
import subprocess
import sys
import tempfile
import textwrap
import unittest
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
VALIDATOR = REPO_ROOT / "skills" / "ack" / "scripts" / "validate_tasks.py"
EXAMPLE = REPO_ROOT / "skills" / "ack" / "examples" / "tasks.example.yaml"
def valid_knowledge_board() -> dict:
return {
"version": 1,
"project": {"name": "demo"},
"tasks": [
{
"id": "T-1",
"title": "validate knowledge fields",
"status": "open",
"knowledgeRefs": ["K-001@1"],
"knowledgeApplied": [
{
"ref": "K-001@1",
"result": "applied",
"evidence": "followed the guardrail",
}
],
"knowledgeCandidates": [
{
"kind": "pitfall",
"title": "candidate",
"claim": "the failure is reproducible",
"scope": {"components": ["web"]},
"appliesWhen": "the web component changes",
"directive": "run the reviewed check",
"rationale": "avoid the repeated failure",
"evidenceRefs": ["tasks.yaml#T-1"],
"proposedBy": "developer",
"proposedAt": "2026-07-31T10:00:00+08:00",
}
],
"knowledgeChecks": [
{
"ref": "K-001@1",
"result": "passed",
"evidence": "independently verified",
"checkedBy": "test",
"checkedAt": "2026-07-31T10:05:00+08:00",
}
],
}
],
}
class AckTaskValidationTests(unittest.TestCase):
def run_validator(
self,
content: str | None = None,
*extra_args: str,
no_site_packages: bool = False,
suffix: str = ".yaml",
) -> subprocess.CompletedProcess[str]:
command = [sys.executable]
if no_site_packages:
command.append("-S")
command.append(str(VALIDATOR))
if content is None:
return subprocess.run(
[*command, *extra_args, str(EXAMPLE)],
cwd=REPO_ROOT,
text=True,
capture_output=True,
check=False,
)
with tempfile.TemporaryDirectory() as temp_dir:
task_file = Path(temp_dir) / f"tasks{suffix}"
task_file.write_text(textwrap.dedent(content), encoding="utf-8")
return subprocess.run(
[*command, *extra_args, str(task_file)],
cwd=REPO_ROOT,
text=True,
capture_output=True,
check=False,
)
def assert_board_rejected_in_all_modes(
self,
board: dict,
*expected_messages: str,
) -> None:
for no_site_packages in (False, True):
with self.subTest(no_site_packages=no_site_packages):
result = self.run_validator(
json.dumps(board),
no_site_packages=no_site_packages,
suffix=".json",
)
self.assertEqual(result.returncode, 1, result.stdout)
for message in expected_messages:
self.assertIn(message, result.stderr)
if no_site_packages:
self.assertIn("内置语义规则", result.stderr)
self.assertNotIn("[schema]", result.stderr)
def assert_board_accepted_in_all_modes(self, board: dict) -> None:
for no_site_packages in (False, True):
with self.subTest(no_site_packages=no_site_packages):
result = self.run_validator(
json.dumps(board),
no_site_packages=no_site_packages,
suffix=".json",
)
self.assertEqual(result.returncode, 0, result.stderr)
def test_example_with_knowledge_fields_is_valid(self) -> None:
result = self.run_validator()
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("任务板校验通过", result.stdout)
def test_knowledge_applied_and_checks_must_reference_selected_knowledge(self) -> None:
result = self.run_validator(
"""
version: 1
project:
name: demo
tasks:
- id: T-1
title: invalid refs
status: verified
knowledgeRefs: ["K-001@1"]
knowledgeApplied:
- ref: "K-002@1"
result: applied
evidence: "used the rule"
knowledgeChecks:
- ref: "K-003@1"
result: failed
evidence: "still broken"
"""
)
self.assertEqual(result.returncode, 1)
self.assertIn("K-002@1 不在 knowledgeRefs 中", result.stderr)
self.assertIn("K-003@1 不在 knowledgeRefs 中", result.stderr)
self.assertIn("verified 任务不能保留失败", result.stderr)
def test_knowledge_refs_require_revision(self) -> None:
result = self.run_validator(
"""
version: 1
project:
name: demo
tasks:
- id: T-1
title: invalid ref
status: open
knowledgeRefs: ["K-001"]
"""
)
self.assertEqual(result.returncode, 1)
self.assertIn("K-<id>@<revision>", result.stderr)
def test_candidate_requires_actionable_scope_and_evidence(self) -> None:
result = self.run_validator(
"""
version: 1
project:
name: demo
tasks:
- id: T-1
title: invalid candidate
status: open
knowledgeCandidates:
- kind: guess
title: maybe
claim: uncertain
scope: {}
appliesWhen: sometimes
directive: retry
rationale: unknown
evidenceRefs: []
"""
)
self.assertEqual(result.returncode, 1)
self.assertIn(".kind: 必须是", result.stderr)
self.assertIn(".scope: 至少包含一个非空作用域", result.stderr)
self.assertIn(".evidenceRefs: 必须是非空字符串列表", result.stderr)
def test_forbidden_knowledge_properties_fail_with_and_without_jsonschema(self) -> None:
board = valid_knowledge_board()
task = board["tasks"][0]
task["knowledgeApplied"][0]["unexpectedApplication"] = True
task["knowledgeCandidates"][0]["unexpectedCandidate"] = True
task["knowledgeChecks"][0]["unexpectedCheck"] = True
self.assert_board_rejected_in_all_modes(
board,
"knowledgeApplied[0]: 未知字段 'unexpectedApplication'",
"knowledgeCandidates[0]: 未知字段 'unexpectedCandidate'",
"knowledgeChecks[0]: 未知字段 'unexpectedCheck'",
)
def test_valid_optional_knowledge_fields_pass_in_all_modes(self) -> None:
self.assert_board_accepted_in_all_modes(valid_knowledge_board())
def test_explicit_null_knowledge_collections_fail_in_all_modes(self) -> None:
board = {
"version": 1,
"project": {"name": "demo"},
"tasks": [
{
"id": "T-REFS",
"title": "null refs",
"status": "open",
"knowledgeRefs": None,
},
{
"id": "T-APPLIED",
"title": "null applications",
"status": "open",
"knowledgeApplied": None,
},
{
"id": "T-CANDIDATES",
"title": "null candidates",
"status": "open",
"knowledgeCandidates": None,
},
{
"id": "T-CHECKS",
"title": "null checks",
"status": "open",
"knowledgeChecks": None,
},
],
}
self.assert_board_rejected_in_all_modes(
board,
"knowledgeRefs: 必须是列表",
"knowledgeApplied: 必须是列表",
"knowledgeCandidates: 必须是列表",
"knowledgeChecks: 必须是列表",
)
def test_knowledge_item_types_and_blank_evidence_fail_in_all_modes(self) -> None:
board = valid_knowledge_board()
task = board["tasks"][0]
task["knowledgeApplied"][0].update(
{"ref": 1, "result": "unknown", "evidence": " "}
)
task["knowledgeChecks"][0].update(
{"ref": False, "result": "unknown", "evidence": "\t"}
)
self.assert_board_rejected_in_all_modes(
board,
"knowledgeApplied[0].ref: 必须使用 K-<id>@<revision> 格式",
"knowledgeApplied[0].result: 必须是 applied/not_applicable",
"knowledgeApplied[0].evidence: 必须提供非空证据",
"knowledgeChecks[0].ref: 必须使用 K-<id>@<revision> 格式",
"knowledgeChecks[0].result: 必须是",
"knowledgeChecks[0].evidence: 必须提供非空证据",
)
def test_candidate_text_scope_and_evidence_refs_fail_in_all_modes(self) -> None:
board = valid_knowledge_board()
candidate = board["tasks"][0]["knowledgeCandidates"][0]
candidate.update(
{
"title": 1,
"claim": " ",
"appliesWhen": [],
"directive": "",
"rationale": None,
"scope": {
"components": ["web", "web"],
"paths": [" "],
},
"evidenceRefs": ["tasks.yaml#T-1", "tasks.yaml#T-1"],
}
)
self.assert_board_rejected_in_all_modes(
board,
"knowledgeCandidates[0].title: 必须是非空字符串",
"knowledgeCandidates[0].claim: 必须是非空字符串",
"knowledgeCandidates[0].appliesWhen: 必须是非空字符串",
"knowledgeCandidates[0].directive: 必须是非空字符串",
"knowledgeCandidates[0].rationale: 必须是非空字符串",
"knowledgeCandidates[0].scope.components: 不能包含重复值",
"knowledgeCandidates[0].scope.paths: 必须是非空字符串列表",
"knowledgeCandidates[0].evidenceRefs: 不能包含重复值",
)
def test_candidate_evidence_refs_reject_blank_strings_in_all_modes(self) -> None:
board = valid_knowledge_board()
board["tasks"][0]["knowledgeCandidates"][0]["evidenceRefs"] = [" "]
self.assert_board_rejected_in_all_modes(
board,
"knowledgeCandidates[0].evidenceRefs: 必须是非空字符串列表",
)
def test_optional_knowledge_field_types_fail_in_all_modes(self) -> None:
board = valid_knowledge_board()
candidate = board["tasks"][0]["knowledgeCandidates"][0]
candidate["proposedBy"] = 1
candidate["proposedAt"] = []
check = board["tasks"][0]["knowledgeChecks"][0]
check["checkedBy"] = False
check["checkedAt"] = {}
self.assert_board_rejected_in_all_modes(
board,
"knowledgeCandidates[0].proposedBy: 必须是字符串",
"knowledgeCandidates[0].proposedAt: 必须是字符串",
"knowledgeChecks[0].checkedBy: 必须是字符串",
"knowledgeChecks[0].checkedAt: 必须是字符串",
)
def test_attempt_id_must_match_task_and_round_and_be_unique(self) -> None:
board = {
"version": 1,
"project": {"name": "demo"},
"tasks": [
{
"id": "BUG-017",
"title": "invalid attempt ids",
"status": "failed_retest",
"dispatch": {
"rounds": [
{
"round": 1,
"attemptId": "OTHER-A1",
"result": "failed",
},
{
"round": 1,
"attemptId": "OTHER-A1",
"result": "failed",
},
{
"round": 3,
"attemptId": "invalid/attempt",
"result": "failed",
},
]
},
}
],
}
self.assert_board_rejected_in_all_modes(
board,
"dispatch.rounds[0].attemptId: 应为 BUG-017-A1",
"dispatch.rounds[1].attemptId: 轮次内不能重复: OTHER-A1",
"dispatch.rounds[2].attemptId: 必须使用 <task-id>-A<round> 格式",
)
def test_valid_optional_attempt_ids_pass_in_all_modes(self) -> None:
board = {
"version": 1,
"project": {"name": "demo"},
"tasks": [
{
"id": "BUG-017",
"title": "valid attempt ids",
"status": "failed_retest",
"dispatch": {
"rounds": [
{
"round": 1,
"attemptId": "BUG-017-A1",
"result": "failed",
},
{
"round": 2,
"attemptId": "BUG-017-A2",
"result": "failed",
},
]
},
}
],
}
self.assert_board_accepted_in_all_modes(board)
def test_boolean_version_fails_in_all_modes(self) -> None:
board = valid_knowledge_board()
board["version"] = True
self.assert_board_rejected_in_all_modes(
board,
"version 必须是 >=1 的整数",
)
def test_knowledge_file_is_fixed_in_all_modes(self) -> None:
board = valid_knowledge_board()
board["project"]["knowledgeFile"] = "docs/ack/alternate.yaml"
self.assert_board_rejected_in_all_modes(
board,
"project.knowledgeFile 必须固定为 docs/ack/knowledge.yaml",
)
def test_basic_identifiers_must_be_nonempty_strings_in_all_modes(self) -> None:
board = valid_knowledge_board()
board["project"]["name"] = 7
board["tasks"][0]["id"] = 9
board["tasks"][0]["title"] = " "
self.assert_board_rejected_in_all_modes(
board,
"project.name 必须是非空字符串",
"id 必须是非空字符串",
"title 必须是非空字符串",
)
def test_root_project_and_summary_types_match_schema_in_all_modes(self) -> None:
board = valid_knowledge_board()
board.update(
{
"updatedAt": [],
"source": {},
"ackVersion": 1,
"kitVersion": False,
"summary": {
"verified": [1],
"open": {},
"failedRetest": [False],
"leftovers": None,
},
"statusReference": [],
}
)
board["project"].update(
{
"repoPath": [],
"baseUrl": {},
"devWorktree": 1,
"overlayFile": False,
}
)
self.assert_board_rejected_in_all_modes(
board,
"<root>.updatedAt: 必须是字符串",
"<root>.source: 必须是字符串",
"<root>.ackVersion: 必须是字符串",
"<root>.kitVersion: 必须是字符串",
"project.repoPath: 必须是字符串",
"project.baseUrl: 必须是字符串",
"project.devWorktree: 必须是字符串",
"project.overlayFile: 必须是字符串",
"summary.verified: 列表项必须是字符串",
"summary.open: 必须是列表",
"summary.failedRetest: 列表项必须是字符串",
"summary.leftovers: 必须是列表",
"statusReference 必须是对象",
)
def test_task_optional_types_match_schema_in_all_modes(self) -> None:
board = valid_knowledge_board()
board["tasks"][0].update(
{
"type": [],
"priority": {},
"assignee": False,
"component": 1,
"specRefs": {},
"testRefs": [1],
"description": [],
"stepsToReproduce": [{}],
"expected": False,
"actual": None,
"evidence": [],
"verification": [],
}
)
self.assert_board_rejected_in_all_modes(
board,
".type: 必须是字符串",
".priority: 必须是字符串",
".assignee: 必须是字符串",
".component: 必须是字符串",
".specRefs: 必须是列表",
".testRefs: 列表项必须是字符串",
".description: 必须是字符串",
".stepsToReproduce: 列表项必须是字符串",
".expected: 必须是字符串",
".actual: 必须是字符串",
".evidence: 必须是对象",
".verification: 必须是对象",
)
def test_dispatch_and_resolution_types_match_schema_in_all_modes(self) -> None:
board = valid_knowledge_board()
board["tasks"][0].update(
{
"dispatch": {
"taskId": [],
"dispatchId": {},
"worker": False,
"rounds": [
{
"round": 1,
"result": "failed",
"evidence": [],
}
],
},
"resolution": {
"fixedBy": [],
"verifiedBy": {},
"verifiedAt": False,
"leftoverReason": 1,
"evidence": [],
},
}
)
self.assert_board_rejected_in_all_modes(
board,
".dispatch.taskId: 必须是字符串或 null",
".dispatch.dispatchId: 必须是字符串或 null",
".dispatch.worker: 必须是字符串或 null",
".dispatch.rounds[0].evidence: 必须是字符串",
".resolution.fixedBy: 必须是字符串或 null",
".resolution.verifiedBy: 必须是字符串或 null",
".resolution.verifiedAt: 必须是字符串或 null",
".resolution.leftoverReason: 必须是字符串或 null",
".resolution.evidence: 必须是对象",
)
def test_explicit_null_structures_fail_in_all_modes(self) -> None:
board = {
"version": 1,
"project": {"name": "demo"},
"tasks": [
{
"id": "T-DISPATCH",
"title": "invalid dispatch",
"status": "open",
"dispatch": None,
},
{
"id": "T-ROUNDS",
"title": "invalid rounds",
"status": "open",
"dispatch": {"rounds": None},
},
{
"id": "T-RESOLUTION",
"title": "invalid resolution",
"status": "open",
"resolution": None,
},
],
}
self.assert_board_rejected_in_all_modes(
board,
"T-DISPATCH.dispatch: 必须是对象",
"T-ROUNDS.dispatch.rounds: 必须是列表",
"T-RESOLUTION.resolution: 必须是对象",
)
def test_valid_optional_schema_fields_pass_in_all_modes(self) -> None:
board = valid_knowledge_board()
board.update(
{
"updatedAt": "2026-07-31T10:00:00+08:00",
"source": "manual",
"ackVersion": "0.9.0",
"kitVersion": "0.8.0",
"summary": {
"verified": ["T-1"],
"open": [],
"failedRetest": [],
"leftovers": [],
},
"statusReference": {},
}
)
board["project"].update(
{
"repoPath": "/repo",
"baseUrl": "http://127.0.0.1:3000",
"devWorktree": "/repo-dev",
"overlayFile": "docs/ack/project.md",
"knowledgeFile": "docs/ack/knowledge.yaml",
}
)
board["tasks"][0].update(
{
"type": "bug",
"priority": "P1",
"assignee": "developer",
"component": "web",
"specRefs": ["spec.md"],
"testRefs": ["tests/test_web.py"],
"description": "description",
"stepsToReproduce": ["open page"],
"expected": "works",
"actual": "fails",
"evidence": {},
"verification": {},
"dispatch": {
"taskId": "orca-task",
"dispatchId": None,
"worker": "worker-1",
"rounds": [
{
"round": 1,
"attemptId": "T-1-A1",
"result": "failed",
"evidence": "test output",
}
],
},
"resolution": {
"fixedBy": "developer",
"verifiedBy": None,
"verifiedAt": None,
"leftoverReason": None,
"evidence": {},
},
}
)
self.assert_board_accepted_in_all_modes(board)
def test_round_numbers_must_be_contiguous_and_within_budget(self) -> None:
board = {
"version": 1,
"project": {"name": "demo"},
"tasks": [
{
"id": "BUG-017",
"title": "invalid round number",
"status": "failed_retest",
"dispatch": {
"rounds": [
{
"round": 999,
"attemptId": "BUG-017-A999",
"result": "failed",
}
]
},
}
],
}
self.assert_board_rejected_in_all_modes(
board,
"dispatch.rounds[0].round: 必须是 1..3 的整数",
"dispatch.rounds: round 必须从 1 连续递增且不重复",
)
def test_leftover_reason_must_be_nonempty_string_in_all_modes(self) -> None:
board = {
"version": 1,
"project": {"name": "demo"},
"tasks": [
{
"id": "T-1",
"title": "invalid leftover reason",
"status": "leftover",
"resolution": {"leftoverReason": True},
}
],
}
self.assert_board_rejected_in_all_modes(
board,
"leftover 必须填 resolution.leftoverReason",
)
def test_explicit_missing_schema_is_an_environment_error(self) -> None:
result = self.run_validator(None, "--schema", "/definitely/missing/schema.json")
self.assertEqual(result.returncode, 2)
self.assertIn("找不到指定的 schema 文件", result.stderr)
if __name__ == "__main__":
unittest.main()
+591
View File
@@ -0,0 +1,591 @@
from __future__ import annotations
import importlib.util
import os
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
from unittest import mock
import yaml
REPO_ROOT = Path(__file__).resolve().parents[1]
RUNNER = REPO_ROOT / "skills" / "ack" / "scripts" / "run_verification.py"
sys.path.insert(0, str(RUNNER.parent))
RUNNER_SPEC = importlib.util.spec_from_file_location("ack_run_verification", RUNNER)
assert RUNNER_SPEC is not None and RUNNER_SPEC.loader is not None
RUNNER_MODULE = importlib.util.module_from_spec(RUNNER_SPEC)
RUNNER_SPEC.loader.exec_module(RUNNER_MODULE)
def knowledge_with_target(path: str, args: list[str]) -> dict:
return {
"version": 1,
"updatedAt": "2026-07-31T12:00:00+08:00",
"project": {"name": "demo"},
"verificationRegistry": {
"reviewed-check": {
"path": path,
"args": args,
}
},
"entries": [],
}
class AckVerificationRunnerTests(unittest.TestCase):
def run_check(
self,
project: Path,
knowledge: dict,
) -> subprocess.CompletedProcess[str]:
knowledge_path = project / "docs" / "ack" / "knowledge.yaml"
knowledge_path.parent.mkdir(parents=True, exist_ok=True)
knowledge_path.write_text(
yaml.safe_dump(knowledge, allow_unicode=True),
encoding="utf-8",
)
return subprocess.run(
[
sys.executable,
str(RUNNER),
str(knowledge_path),
"reviewed-check",
"--project-root",
str(project),
],
cwd=REPO_ROOT,
text=True,
capture_output=True,
check=False,
)
def test_executes_reviewed_target_with_structured_args(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
project = Path(temp_dir) / "project"
target = project / "checks" / "write-marker"
target.parent.mkdir(parents=True)
target.write_text(
"#!/bin/sh\nprintf 'passed' > \"$1\"\n",
encoding="utf-8",
)
target.chmod(target.stat().st_mode | 0o111)
result = self.run_check(
project,
knowledge_with_target(
"checks/write-marker",
["verification-marker.txt"],
),
)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(
(project / "verification-marker.txt").read_text(encoding="utf-8"),
"passed",
)
def test_exposes_project_rooted_execution_contract(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
project = Path(temp_dir) / "project"
target = project / "checks" / "show-context"
target.parent.mkdir(parents=True)
target.write_text(
"#!/bin/sh\n"
"printf '%s\\n%s\\n%s\\n%s\\n' "
'"$PWD" "$ACK_PROJECT_ROOT" "$ACK_VERIFICATION_REF" '
'"$ACK_VERIFICATION_PATH" > "$1"\n',
encoding="utf-8",
)
target.chmod(target.stat().st_mode | 0o111)
result = self.run_check(
project,
knowledge_with_target(
"checks/show-context",
["verification-context.txt"],
),
)
self.assertEqual(result.returncode, 0, result.stderr)
context = (
(project / "verification-context.txt")
.read_text(encoding="utf-8")
.splitlines()
)
self.assertEqual(context[0], str(project))
self.assertRegex(context[1], r"^/(?:proc/self|dev)/fd/[0-9]+$")
self.assertEqual(context[2:], ["reviewed-check", "checks/show-context"])
def test_rejects_missing_or_non_executable_target(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
project = Path(temp_dir) / "project"
project.mkdir()
missing = self.run_check(
project,
knowledge_with_target("checks/missing", []),
)
self.assertEqual(missing.returncode, 1)
self.assertIn("不存在", missing.stderr)
target = project / "checks" / "not-executable"
target.parent.mkdir(parents=True)
target.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8")
target.chmod(target.stat().st_mode & ~0o111)
not_executable = self.run_check(
project,
knowledge_with_target("checks/not-executable", []),
)
self.assertEqual(not_executable.returncode, 1)
self.assertIn("不可执行", not_executable.stderr)
def test_rejects_project_root_that_is_wider_than_knowledge_project(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
base = Path(temp_dir)
project = base / "project"
outside = base / "outside"
outside.mkdir()
target = outside / "unsafe"
target.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8")
target.chmod(target.stat().st_mode | 0o111)
knowledge_path = project / "docs" / "ack" / "knowledge.yaml"
knowledge_path.parent.mkdir(parents=True)
knowledge_path.write_text(
yaml.safe_dump(
knowledge_with_target("outside/unsafe", []),
allow_unicode=True,
),
encoding="utf-8",
)
result = subprocess.run(
[
sys.executable,
str(RUNNER),
str(knowledge_path),
"reviewed-check",
"--project-root",
str(base),
],
cwd=REPO_ROOT,
text=True,
capture_output=True,
check=False,
)
self.assertEqual(result.returncode, 2)
self.assertIn("推断的项目根目录", result.stderr)
def test_rejects_duplicate_registry_ids_before_execution(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
project = Path(temp_dir) / "project"
checks = project / "checks"
checks.mkdir(parents=True)
marker = project / "unsafe-marker"
for name in ("safe", "unsafe"):
target = checks / name
target.write_text(
f"#!/bin/sh\nprintf '{name}' > {marker}\n",
encoding="utf-8",
)
target.chmod(target.stat().st_mode | 0o111)
knowledge_path = project / "docs" / "ack" / "knowledge.yaml"
knowledge_path.parent.mkdir(parents=True)
knowledge_path.write_text(
"""\
version: 1
updatedAt: "2026-07-31T12:00:00+08:00"
project:
name: demo
verificationRegistry:
reviewed-check:
path: checks/safe
args: []
reviewed-check:
path: checks/unsafe
args: []
entries: []
""",
encoding="utf-8",
)
result = subprocess.run(
[
sys.executable,
str(RUNNER),
str(knowledge_path),
"reviewed-check",
"--project-root",
str(project),
],
cwd=REPO_ROOT,
text=True,
capture_output=True,
check=False,
)
self.assertNotEqual(result.returncode, 0)
self.assertIn("duplicate key", result.stderr)
self.assertFalse(marker.exists())
@unittest.skipUnless(hasattr(os, "symlink"), "requires symlink support")
def test_rejects_non_authoritative_knowledge_files(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
base = Path(temp_dir)
project = base / "project"
ack_dir = project / "docs" / "ack"
ack_dir.mkdir(parents=True)
document = yaml.safe_dump(
knowledge_with_target("checks/reviewed", []),
allow_unicode=True,
)
for label, knowledge_path in (
("external", base / "attacker-knowledge.yaml"),
("alternate", ack_dir / "alternate.yaml"),
):
with self.subTest(label=label):
knowledge_path.write_text(document, encoding="utf-8")
result = subprocess.run(
[
sys.executable,
str(RUNNER),
str(knowledge_path),
"reviewed-check",
"--project-root",
str(project),
],
cwd=REPO_ROOT,
text=True,
capture_output=True,
check=False,
)
self.assertEqual(result.returncode, 2)
self.assertIn("权威知识库", result.stderr)
outside = base / "outside-knowledge.yaml"
outside.write_text(document, encoding="utf-8")
canonical = ack_dir / "knowledge.yaml"
canonical.symlink_to(outside)
symlinked = subprocess.run(
[
sys.executable,
str(RUNNER),
str(canonical),
"reviewed-check",
"--project-root",
str(project),
],
cwd=REPO_ROOT,
text=True,
capture_output=True,
check=False,
)
self.assertEqual(symlinked.returncode, 2)
self.assertIn("路径包含软链接", symlinked.stderr)
def test_authoritative_knowledge_is_read_from_an_opened_snapshot(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
base = Path(temp_dir)
project = base / "project"
canonical = project / "docs" / "ack" / "knowledge.yaml"
canonical.parent.mkdir(parents=True)
safe_document = knowledge_with_target("checks/safe", [])
forged_document = knowledge_with_target("checks/danger", [])
canonical.write_text(
yaml.safe_dump(safe_document, allow_unicode=True),
encoding="utf-8",
)
forged_path = base / "forged.yaml"
forged_path.write_text(
yaml.safe_dump(forged_document, allow_unicode=True),
encoding="utf-8",
)
original_reader = RUNNER_MODULE._read_stable_bytes
def swap_after_open(
source_fd: int,
*,
maximum: int,
) -> tuple[bytes | None, str | None]:
canonical.unlink()
canonical.symlink_to(forged_path)
return original_reader(source_fd, maximum=maximum)
with mock.patch.object(
RUNNER_MODULE,
"_read_stable_bytes",
side_effect=swap_after_open,
):
data, error = RUNNER_MODULE.load_authoritative_knowledge(project)
self.assertIsNone(error)
self.assertEqual(
data["verificationRegistry"]["reviewed-check"]["path"],
"checks/safe",
)
def test_authoritative_knowledge_rejects_same_inode_change_during_read(
self,
) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
project = Path(temp_dir) / "project"
canonical = project / "docs" / "ack" / "knowledge.yaml"
canonical.parent.mkdir(parents=True)
canonical.write_text(
yaml.safe_dump(
knowledge_with_target("checks/safe", []),
allow_unicode=True,
),
encoding="utf-8",
)
source_fd, error = RUNNER_MODULE._open_regular_beneath(
project,
"docs/ack/knowledge.yaml",
require_executable=False,
)
self.assertIsNone(error)
self.assertIsNotNone(source_fd)
assert source_fd is not None
original_read = os.read
changed = False
def mutate_during_read(
file_descriptor: int,
size: int,
) -> bytes:
nonlocal changed
chunk = original_read(file_descriptor, size)
if not changed:
changed = True
canonical.write_text(
yaml.safe_dump(
knowledge_with_target("checks/danger", []),
allow_unicode=True,
),
encoding="utf-8",
)
return chunk
try:
with mock.patch.object(
RUNNER_MODULE.os,
"read",
side_effect=mutate_during_read,
):
content, read_error = RUNNER_MODULE._read_stable_bytes(
source_fd,
maximum=RUNNER_MODULE.MAX_KNOWLEDGE_BYTES,
)
finally:
os.close(source_fd)
self.assertIsNone(content)
self.assertIn("读取期间发生变化", read_error)
@unittest.skipUnless(hasattr(os, "symlink"), "requires symlink support")
def test_rejects_target_that_resolves_outside_project(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
base = Path(temp_dir)
project = base / "project"
outside = base / "outside"
project.mkdir()
outside.mkdir()
target = outside / "unsafe"
target.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8")
target.chmod(target.stat().st_mode | 0o111)
(project / "checks").symlink_to(outside, target_is_directory=True)
result = self.run_check(
project,
knowledge_with_target("checks/unsafe", []),
)
self.assertNotEqual(result.returncode, 0)
self.assertIn("不能包含 symlink", result.stderr)
@unittest.skipUnless(
hasattr(os, "O_NOFOLLOW") and Path("/proc/self/fd").is_dir(),
"requires fd-based POSIX execution",
)
def test_opened_target_cannot_be_swapped_before_execution(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
base = Path(temp_dir)
project = base / "project"
target = project / "checks" / "reviewed"
outside = base / "outside"
marker = project / "marker.txt"
target.parent.mkdir(parents=True)
target.write_text(
"#!/bin/sh\nprintf 'reviewed' > \"$1\"\n",
encoding="utf-8",
)
target.chmod(target.stat().st_mode | 0o111)
outside.write_text(
"#!/bin/sh\nprintf 'swapped' > \"$1\"\n",
encoding="utf-8",
)
outside.chmod(outside.stat().st_mode | 0o111)
data = knowledge_with_target("checks/reviewed", [str(marker)])
target_fd, target_args, error = RUNNER_MODULE.open_target(
data,
"reviewed-check",
project,
)
self.assertIsNone(error)
self.assertIsNotNone(target_fd)
self.assertIsNotNone(target_args)
assert target_fd is not None and target_args is not None
target.unlink()
target.symlink_to(outside)
executable = RUNNER_MODULE._fd_executable_path(target_fd)
self.assertIsNotNone(executable)
try:
completed = subprocess.run(
[str(executable), *target_args],
cwd=project,
pass_fds=(target_fd,),
check=False,
)
finally:
os.close(target_fd)
self.assertEqual(completed.returncode, 0)
self.assertEqual(marker.read_text(encoding="utf-8"), "reviewed")
@unittest.skipUnless(
hasattr(os, "O_NOFOLLOW") and Path("/proc/self/fd").is_dir(),
"requires fd-based POSIX execution",
)
def test_opened_target_snapshot_ignores_same_inode_rewrite(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
project = Path(temp_dir) / "project"
target = project / "checks" / "reviewed"
marker = project / "marker.txt"
target.parent.mkdir(parents=True)
target.write_text(
"#!/bin/sh\nprintf 'reviewed' > \"$1\"\n",
encoding="utf-8",
)
target.chmod(target.stat().st_mode | 0o111)
data = knowledge_with_target("checks/reviewed", [str(marker)])
target_fd, target_args, error = RUNNER_MODULE.open_target(
data,
"reviewed-check",
project,
)
self.assertIsNone(error)
self.assertIsNotNone(target_fd)
self.assertIsNotNone(target_args)
assert target_fd is not None and target_args is not None
target.write_text(
"#!/bin/sh\nprintf 'mutated' > \"$1\"\n",
encoding="utf-8",
)
target.chmod(target.stat().st_mode | 0o111)
executable = RUNNER_MODULE._fd_executable_path(target_fd)
self.assertIsNotNone(executable)
try:
completed = subprocess.run(
[str(executable), *target_args],
cwd=project,
pass_fds=(target_fd,),
check=False,
)
finally:
os.close(target_fd)
self.assertEqual(completed.returncode, 0)
self.assertEqual(marker.read_text(encoding="utf-8"), "reviewed")
@unittest.skipUnless(
hasattr(os, "O_NOFOLLOW") and Path("/proc/self/fd").is_dir(),
"requires fd-based POSIX execution",
)
def test_project_root_replacement_cannot_swap_target_or_cwd(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
base = Path(temp_dir)
project = base / "project"
original = base / "original-project"
target = project / "checks" / "reviewed"
target.parent.mkdir(parents=True)
target.write_text(
"#!/bin/sh\nprintf 'reviewed' > marker.txt\n",
encoding="utf-8",
)
target.chmod(target.stat().st_mode | 0o111)
knowledge_path = project / "docs" / "ack" / "knowledge.yaml"
knowledge_path.parent.mkdir(parents=True)
knowledge_path.write_text(
yaml.safe_dump(
knowledge_with_target("checks/reviewed", []),
allow_unicode=True,
),
encoding="utf-8",
)
original_loader = RUNNER_MODULE.load_authoritative_knowledge
def replace_root_after_knowledge(
project_root: Path | int,
) -> tuple[dict | None, str | None]:
data, error = original_loader(project_root)
project.rename(original)
replacement = project / "checks" / "reviewed"
replacement.parent.mkdir(parents=True)
replacement.write_text(
"#!/bin/sh\nprintf 'swapped' > marker.txt\n",
encoding="utf-8",
)
replacement.chmod(replacement.stat().st_mode | 0o111)
return data, error
with mock.patch.object(
RUNNER_MODULE,
"load_authoritative_knowledge",
side_effect=replace_root_after_knowledge,
):
result = RUNNER_MODULE.main(
[
str(knowledge_path),
"reviewed-check",
"--project-root",
str(project),
]
)
self.assertEqual(result, 0)
self.assertEqual(
(original / "marker.txt").read_text(encoding="utf-8"),
"reviewed",
)
self.assertFalse((project / "marker.txt").exists())
def test_rejects_oversized_verification_target_before_snapshot(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
project = Path(temp_dir) / "project"
target = project / "checks" / "oversized"
target.parent.mkdir(parents=True)
with target.open("wb") as target_file:
target_file.truncate(RUNNER_MODULE.MAX_TARGET_BYTES + 1)
target.chmod(target.stat().st_mode | 0o111)
target_fd, target_args, error = RUNNER_MODULE.open_target(
knowledge_with_target("checks/oversized", []),
"reviewed-check",
project,
)
self.assertIsNone(target_fd)
self.assertIsNone(target_args)
self.assertIn("超过大小上限", error)
if __name__ == "__main__":
unittest.main()
+182
View File
@@ -0,0 +1,182 @@
from __future__ import annotations
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
SCRIPTS_DIR = REPO_ROOT / "skills" / "ack" / "scripts"
sys.path.insert(0, str(SCRIPTS_DIR))
import validate_knowledge # noqa: E402
import validate_tasks # noqa: E402
from yaml_subset import YamlSubsetError, load_yaml_subset # noqa: E402
class AckYamlSubsetTests(unittest.TestCase):
def test_real_templates_and_examples_parse_under_clean_python(self) -> None:
script = """
import sys
from pathlib import Path
sys.path.insert(0, str(Path('skills/ack/scripts').resolve()))
from yaml_subset import load_yaml_subset
paths = (
Path('skills/ack/templates/tasks.template.yaml'),
Path('skills/ack/examples/tasks.example.yaml'),
Path('skills/ack/templates/knowledge.template.yaml'),
Path('skills/ack/examples/knowledge.example.yaml'),
)
for path in paths:
value = load_yaml_subset(path.read_text(encoding='utf-8'))
if not isinstance(value, dict):
raise SystemExit(f'{path}: top-level value is not a mapping')
print('parsed=4')
"""
result = subprocess.run(
[sys.executable, "-S", "-c", script],
cwd=REPO_ROOT,
text=True,
capture_output=True,
check=False,
)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(result.stdout.strip(), "parsed=4")
def test_tasks_validator_runs_without_site_packages(self) -> None:
result = subprocess.run(
[
sys.executable,
"-S",
str(SCRIPTS_DIR / "validate_tasks.py"),
str(REPO_ROOT / "skills" / "ack" / "examples" / "tasks.example.yaml"),
],
cwd=REPO_ROOT,
text=True,
capture_output=True,
check=False,
)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("任务板校验通过", result.stdout)
def test_supported_subset_types_and_block_scalars(self) -> None:
document = load_yaml_subset(
"""
# comment
root:
list:
- null
- true
- -2
- name: 'single quoted'
flags: [false, "double quoted", {count: 3}]
emptyList: []
emptyMap: {}
folded: >
first line
second line
next paragraph
literal: |
first line
second line
"""
)
self.assertEqual(document["root"]["list"][:3], [None, True, -2])
self.assertEqual(
document["root"]["list"][3],
{
"name": "single quoted",
"flags": [False, "double quoted", {"count": 3}],
},
)
self.assertEqual(document["root"]["emptyList"], [])
self.assertEqual(document["root"]["emptyMap"], {})
self.assertEqual(
document["root"]["folded"],
"first line second line\nnext paragraph\n",
)
self.assertEqual(
document["root"]["literal"],
"first line\nsecond line\n",
)
def test_quoted_mapping_key_supports_yaml_single_quote_escape(self) -> None:
self.assertEqual(load_yaml_subset("'owner''s-key': value\n"), {"owner's-key": "value"})
def test_subset_rejects_duplicate_keys_at_any_depth(self) -> None:
invalid_documents = (
"name: first\nname: second\n",
"outer:\n name: first\n name: second\n",
"outer: {name: first, name: second}\n",
)
for document in invalid_documents:
with self.subTest(document=document), self.assertRaises(YamlSubsetError):
load_yaml_subset(document)
def test_subset_rejects_unsupported_yaml_instead_of_guessing(self) -> None:
invalid_documents = (
"root: &node\n value: 1\ncopy: *node\n",
"root: !custom value\n",
"root: {<<: {value: 1}}\n",
"---\nroot: value\n",
"root: >-\n value\n",
"root: 1.25\n",
"root:\n\tchild: value\n",
)
for document in invalid_documents:
with self.subTest(document=document), self.assertRaises(YamlSubsetError):
load_yaml_subset(document)
class AckDocumentLoaderTests(unittest.TestCase):
def test_tasks_yaml_loader_rejects_duplicate_keys_with_pyyaml(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
path = Path(temp_dir) / "tasks.yaml"
path.write_text("version: 1\nversion: 2\n", encoding="utf-8")
with self.assertRaises(SystemExit) as raised:
validate_tasks.load_document(path)
self.assertEqual(raised.exception.code, 1)
def test_knowledge_yaml_loader_rejects_alias_graphs(self) -> None:
with self.assertRaises(SystemExit) as raised:
validate_knowledge.load_yaml_text(
"root: &root\n child: *root\n",
"知识库",
)
self.assertEqual(raised.exception.code, 1)
def test_tasks_json_loader_rejects_duplicate_keys(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
path = Path(temp_dir) / "tasks.json"
path.write_text('{"version": 1, "version": 2}', encoding="utf-8")
with self.assertRaises(SystemExit) as raised:
validate_tasks.load_document(path)
self.assertEqual(raised.exception.code, 1)
def test_knowledge_json_loader_rejects_nested_duplicate_keys(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
path = Path(temp_dir) / "knowledge.json"
path.write_text(
'{"project": {"name": "first", "name": "second"}}',
encoding="utf-8",
)
with self.assertRaises(SystemExit) as raised:
validate_knowledge.load_yaml(path, "知识库")
self.assertEqual(raised.exception.code, 1)
if __name__ == "__main__":
unittest.main()
+616
View File
@@ -6,7 +6,10 @@ import sys
import tempfile
import unittest
from pathlib import Path
from types import SimpleNamespace
from unittest import mock
import skiff.cli
REPO_ROOT = Path(__file__).resolve().parents[1]
@@ -34,6 +37,18 @@ class SkillInitTests(unittest.TestCase):
' devWorktree: "<dev_worktree>"\n',
encoding="utf-8",
)
(skill / "templates" / "knowledge.template.yaml").write_text(
'updatedAt: "<YYYY-MM-DDTHH:mm:ss+TZ>"\n'
'project:\n'
' name: "<project_name>"\n'
' repoPath: "<repo_path>"\n',
encoding="utf-8",
)
for validator_name in ("validate_tasks.py", "validate_knowledge.py"):
(skill / "scripts" / validator_name).write_text(
"raise SystemExit(0)\n",
encoding="utf-8",
)
def tearDown(self) -> None:
self.temp_dir.cleanup()
@@ -63,10 +78,13 @@ class SkillInitTests(unittest.TestCase):
self.assertFalse((target / "framework").exists())
project_content = (target / "project.md").read_text(encoding="utf-8")
tasks_content = (target / "tasks.yaml").read_text(encoding="utf-8")
knowledge_content = (target / "knowledge.yaml").read_text(encoding="utf-8")
self.assertIn("# sample-app", project_content)
self.assertIn("version=1.2.3", project_content)
self.assertIn(f'repoPath: "{project}"', tasks_content)
self.assertIn(f'repoPath: "{project}"', knowledge_content)
self.assertNotIn("<project_name>", tasks_content)
self.assertNotIn("<project_name>", knowledge_content)
def test_init_refuses_to_overwrite_existing_files(self) -> None:
project = self.home / "existing-app"
@@ -81,6 +99,604 @@ class SkillInitTests(unittest.TestCase):
self.assertIn("拒绝覆盖已有路径", result.stderr)
self.assertEqual(existing.read_text(encoding="utf-8"), "keep me")
self.assertFalse((target / "tasks.yaml").exists())
self.assertFalse((target / "knowledge.yaml").exists())
def test_init_refuses_to_overwrite_existing_knowledge_file(self) -> None:
project = self.home / "existing-knowledge-app"
target = project / "docs" / "ack"
target.mkdir(parents=True)
existing = target / "knowledge.yaml"
existing.write_text("keep me", encoding="utf-8")
result = self.run_skiff("init", "ack", "--project", str(project))
self.assertNotEqual(result.returncode, 0)
self.assertIn("拒绝覆盖已有路径", result.stderr)
self.assertEqual(existing.read_text(encoding="utf-8"), "keep me")
self.assertFalse((target / "project.md").exists())
self.assertFalse((target / "tasks.yaml").exists())
def test_init_rejects_symlinked_destination_directories(self) -> None:
for symlink_level in ("docs", "ack"):
with self.subTest(symlink_level=symlink_level):
project = self.home / f"symlink-{symlink_level}-app"
outside = self.home / f"symlink-{symlink_level}-outside"
project.mkdir()
outside.mkdir()
if symlink_level == "docs":
(project / "docs").symlink_to(
outside,
target_is_directory=True,
)
else:
(project / "docs").mkdir()
(project / "docs" / "ack").symlink_to(
outside,
target_is_directory=True,
)
result = self.run_skiff(
"init",
"ack",
"--project",
str(project),
)
self.assertNotEqual(result.returncode, 0)
self.assertIn("不能是软链接", result.stderr)
self.assertFalse((outside / "project.md").exists())
self.assertFalse((outside / "tasks.yaml").exists())
self.assertFalse((outside / "knowledge.yaml").exists())
def test_init_rejects_path_like_skill_name_before_resolving_targets(self) -> None:
project = self.home / "path-traversal-app"
outside = self.home / "path-traversal-outside"
project.mkdir()
outside.mkdir()
(project / "skills").symlink_to(outside, target_is_directory=True)
result = self.run_skiff(
"init",
"../skills/ack",
"--project",
str(project),
)
self.assertNotEqual(result.returncode, 0)
self.assertIn("skill 名称无效", result.stderr)
self.assertFalse((outside / "ack").exists())
def test_atomic_publish_failure_never_exposes_partial_ack_directory(self) -> None:
project = self.home / "atomic-publish-app"
project.mkdir()
with (
mock.patch.object(skiff.cli, "SKILLS_HOME", self.skills_home),
mock.patch.object(
skiff.cli,
"SKILLS_DIR",
self.skills_home / "skills",
),
mock.patch.object(skiff.cli, "ensure_skills_home"),
mock.patch.object(
skiff.cli,
"_rename_directory_noreplace",
side_effect=RuntimeError("publish interrupted"),
),
):
with self.assertRaisesRegex(RuntimeError, "publish interrupted"):
skiff.cli.cmd_init(
SimpleNamespace(name="ack", project=str(project))
)
self.assertFalse((project / "docs" / "ack").exists())
docs = project / "docs"
if docs.exists():
self.assertEqual(list(docs.iterdir()), [])
def test_atomic_publish_never_replaces_a_raced_destination(self) -> None:
project = self.home / "atomic-no-replace-app"
project.mkdir()
raced_inode: int | None = None
real_publish = skiff.cli._rename_directory_noreplace
def create_destination_then_publish(
source_parent_fd: int,
source_name: str,
destination_parent_fd: int,
destination_name: str,
) -> None:
nonlocal raced_inode
os.mkdir(destination_name, mode=0o711, dir_fd=destination_parent_fd)
raced_inode = os.stat(
destination_name,
dir_fd=destination_parent_fd,
follow_symlinks=False,
).st_ino
real_publish(
source_parent_fd,
source_name,
destination_parent_fd,
destination_name,
)
with (
mock.patch.object(skiff.cli, "SKILLS_HOME", self.skills_home),
mock.patch.object(
skiff.cli,
"SKILLS_DIR",
self.skills_home / "skills",
),
mock.patch.object(skiff.cli, "ensure_skills_home"),
mock.patch.object(
skiff.cli,
"_rename_directory_noreplace",
side_effect=create_destination_then_publish,
),
):
with self.assertRaisesRegex(SystemExit, "拒绝覆盖已有路径"):
skiff.cli.cmd_init(
SimpleNamespace(name="ack", project=str(project))
)
target = project / "docs" / "ack"
self.assertTrue(target.is_dir())
self.assertEqual(target.stat().st_ino, raced_inode)
self.assertEqual(list(target.iterdir()), [])
self.assertEqual(target.stat().st_mode & 0o777, 0o711)
def test_published_destination_replacement_never_reports_success(self) -> None:
project = self.home / "published-destination-app"
moved_target = project / "docs" / "ack-moved"
project.mkdir()
real_publish = skiff.cli._rename_directory_noreplace
def replace_destination_after_publish(
source_parent_fd: int,
source_name: str,
destination_parent_fd: int,
destination_name: str,
) -> None:
real_publish(
source_parent_fd,
source_name,
destination_parent_fd,
destination_name,
)
target = project / "docs" / destination_name
target.rename(moved_target)
target.mkdir()
with (
mock.patch.object(skiff.cli, "SKILLS_HOME", self.skills_home),
mock.patch.object(
skiff.cli,
"SKILLS_DIR",
self.skills_home / "skills",
),
mock.patch.object(skiff.cli, "ensure_skills_home"),
mock.patch.object(
skiff.cli,
"_rename_directory_noreplace",
side_effect=replace_destination_after_publish,
),
):
with self.assertRaisesRegex(SystemExit, "ACK 目录已被替换"):
skiff.cli.cmd_init(
SimpleNamespace(name="ack", project=str(project))
)
self.assertEqual(list((project / "docs" / "ack").iterdir()), [])
self.assertEqual(
sorted(path.name for path in moved_target.iterdir()),
["knowledge.yaml", "project.md", "tasks.yaml"],
)
def test_transaction_container_replacement_cannot_forge_payload(self) -> None:
project = self.home / "transaction-source-app"
attacker = self.home / "transaction-attacker"
project.mkdir()
attacker.mkdir()
(attacker / "marker").write_text("forged", encoding="utf-8")
real_publish = skiff.cli._rename_directory_noreplace
def replace_outer_transaction_then_publish(
source_parent_fd: int,
source_name: str,
destination_parent_fd: int,
destination_name: str,
) -> None:
docs = project / "docs"
transactions = [
path
for path in docs.iterdir()
if path.name.startswith(".ack-init-")
]
self.assertEqual(len(transactions), 1)
transaction = transactions[0]
saved = docs / f"{transaction.name}.saved"
transaction.rename(saved)
transaction.symlink_to(attacker, target_is_directory=True)
real_publish(
source_parent_fd,
source_name,
destination_parent_fd,
destination_name,
)
with (
mock.patch.object(skiff.cli, "SKILLS_HOME", self.skills_home),
mock.patch.object(
skiff.cli,
"SKILLS_DIR",
self.skills_home / "skills",
),
mock.patch.object(skiff.cli, "ensure_skills_home"),
mock.patch.object(
skiff.cli,
"_rename_directory_noreplace",
side_effect=replace_outer_transaction_then_publish,
),
):
skiff.cli.cmd_init(
SimpleNamespace(name="ack", project=str(project))
)
target = project / "docs" / "ack"
self.assertTrue(target.is_dir())
self.assertFalse(target.is_symlink())
self.assertFalse((target / "marker").exists())
self.assertEqual(
sorted(path.name for path in target.iterdir()),
["knowledge.yaml", "project.md", "tasks.yaml"],
)
def test_post_publish_fsync_failure_preserves_complete_state(self) -> None:
project = self.home / "post-publish-fsync-app"
project.mkdir()
real_fsync = os.fsync
calls = 0
def fail_directory_fsync_after_publish(file_descriptor: int) -> None:
nonlocal calls
calls += 1
if calls == 7:
raise OSError("simulated directory fsync failure")
real_fsync(file_descriptor)
with (
mock.patch.object(skiff.cli, "SKILLS_HOME", self.skills_home),
mock.patch.object(
skiff.cli,
"SKILLS_DIR",
self.skills_home / "skills",
),
mock.patch.object(skiff.cli, "ensure_skills_home"),
mock.patch.object(
skiff.cli.os,
"fsync",
side_effect=fail_directory_fsync_after_publish,
),
):
with self.assertRaisesRegex(SystemExit, "已完整发布"):
skiff.cli.cmd_init(
SimpleNamespace(name="ack", project=str(project))
)
target = project / "docs" / "ack"
self.assertEqual(
sorted(path.name for path in target.iterdir()),
["knowledge.yaml", "project.md", "tasks.yaml"],
)
def test_project_root_replacement_aborts_before_publish(self) -> None:
project = self.home / "root-replacement-app"
moved_project = self.home / "root-replacement-moved"
project.mkdir()
real_open_docs = skiff.cli._open_or_create_directory_at
replaced = False
def replace_root_then_open_docs(
parent_fd: int,
name: str,
) -> tuple[int, bool]:
nonlocal replaced
if not replaced:
project.rename(moved_project)
project.mkdir()
replaced = True
return real_open_docs(parent_fd, name)
with (
mock.patch.object(skiff.cli, "SKILLS_HOME", self.skills_home),
mock.patch.object(
skiff.cli,
"SKILLS_DIR",
self.skills_home / "skills",
),
mock.patch.object(skiff.cli, "ensure_skills_home"),
mock.patch.object(
skiff.cli,
"_open_or_create_directory_at",
side_effect=replace_root_then_open_docs,
),
):
with self.assertRaisesRegex(SystemExit, "项目目录已被替换"):
skiff.cli.cmd_init(
SimpleNamespace(name="ack", project=str(project))
)
self.assertFalse((project / "docs" / "ack").exists())
self.assertFalse((moved_project / "docs" / "ack").exists())
def test_project_root_replacement_at_publish_never_reports_success(self) -> None:
project = self.home / "publish-root-replacement-app"
moved_project = self.home / "publish-root-replacement-moved"
project.mkdir()
real_publish = skiff.cli._rename_directory_noreplace
def replace_root_then_publish(
source_parent_fd: int,
source_name: str,
destination_parent_fd: int,
destination_name: str,
) -> None:
project.rename(moved_project)
project.mkdir()
real_publish(
source_parent_fd,
source_name,
destination_parent_fd,
destination_name,
)
with (
mock.patch.object(skiff.cli, "SKILLS_HOME", self.skills_home),
mock.patch.object(
skiff.cli,
"SKILLS_DIR",
self.skills_home / "skills",
),
mock.patch.object(skiff.cli, "ensure_skills_home"),
mock.patch.object(
skiff.cli,
"_rename_directory_noreplace",
side_effect=replace_root_then_publish,
),
):
with self.assertRaisesRegex(SystemExit, "ACK 目录已移动或不可访问"):
skiff.cli.cmd_init(
SimpleNamespace(name="ack", project=str(project))
)
self.assertFalse((project / "docs" / "ack").exists())
target = moved_project / "docs" / "ack"
self.assertEqual(
sorted(path.name for path in target.iterdir()),
["knowledge.yaml", "project.md", "tasks.yaml"],
)
def test_docs_replacement_aborts_before_publish(self) -> None:
project = self.home / "docs-replacement-app"
moved_docs = project / "docs-moved"
project.mkdir()
real_assert_binding = skiff.cli._assert_open_directory_path
replaced = False
def replace_docs_at_publish_check(
directory_fd: int,
path: Path,
*,
phase: str,
label: str = "项目目录",
) -> None:
nonlocal replaced
if label == "docs 目录" and phase == "发布" and not replaced:
(project / "docs").rename(moved_docs)
(project / "docs").mkdir()
replaced = True
real_assert_binding(
directory_fd,
path,
phase=phase,
label=label,
)
with (
mock.patch.object(skiff.cli, "SKILLS_HOME", self.skills_home),
mock.patch.object(
skiff.cli,
"SKILLS_DIR",
self.skills_home / "skills",
),
mock.patch.object(skiff.cli, "ensure_skills_home"),
mock.patch.object(
skiff.cli,
"_assert_open_directory_path",
side_effect=replace_docs_at_publish_check,
),
):
with self.assertRaisesRegex(SystemExit, "docs 目录已被替换"):
skiff.cli.cmd_init(
SimpleNamespace(name="ack", project=str(project))
)
self.assertFalse((project / "docs" / "ack").exists())
self.assertFalse((moved_docs / "ack").exists())
def test_docs_replacement_at_publish_never_reports_success(self) -> None:
project = self.home / "publish-docs-replacement-app"
moved_docs = project / "docs-moved"
project.mkdir()
real_publish = skiff.cli._rename_directory_noreplace
def replace_docs_then_publish(
source_parent_fd: int,
source_name: str,
destination_parent_fd: int,
destination_name: str,
) -> None:
(project / "docs").rename(moved_docs)
(project / "docs").mkdir()
real_publish(
source_parent_fd,
source_name,
destination_parent_fd,
destination_name,
)
with (
mock.patch.object(skiff.cli, "SKILLS_HOME", self.skills_home),
mock.patch.object(
skiff.cli,
"SKILLS_DIR",
self.skills_home / "skills",
),
mock.patch.object(skiff.cli, "ensure_skills_home"),
mock.patch.object(
skiff.cli,
"_rename_directory_noreplace",
side_effect=replace_docs_then_publish,
),
):
with self.assertRaisesRegex(SystemExit, "ACK 目录已移动或不可访问"):
skiff.cli.cmd_init(
SimpleNamespace(name="ack", project=str(project))
)
self.assertFalse((project / "docs" / "ack").exists())
target = moved_docs / "ack"
self.assertEqual(
sorted(path.name for path in target.iterdir()),
["knowledge.yaml", "project.md", "tasks.yaml"],
)
def test_ack_init_requires_knowledge_template(self) -> None:
project = self.home / "missing-knowledge-template-app"
project.mkdir()
(
self.skills_home
/ "skills"
/ "ack"
/ "templates"
/ "knowledge.template.yaml"
).unlink()
result = self.run_skiff("init", "ack", "--project", str(project))
self.assertNotEqual(result.returncode, 0)
self.assertIn("knowledge.template.yaml", result.stderr)
self.assertFalse((project / "docs" / "ack").exists())
def test_ack_init_requires_both_validators(self) -> None:
for validator_name in ("validate_tasks.py", "validate_knowledge.py"):
with self.subTest(validator_name=validator_name):
project = self.home / f"missing-{validator_name}-app"
project.mkdir()
validator = (
self.skills_home
/ "skills"
/ "ack"
/ "scripts"
/ validator_name
)
original = validator.read_text(encoding="utf-8")
validator.unlink()
try:
result = self.run_skiff(
"init",
"ack",
"--project",
str(project),
)
finally:
validator.write_text(original, encoding="utf-8")
self.assertNotEqual(result.returncode, 0)
self.assertIn("缺少初始化校验器", result.stderr)
self.assertIn(validator_name, result.stderr)
self.assertFalse((project / "docs" / "ack").exists())
def test_validator_failure_leaves_no_partial_initialization(self) -> None:
project = self.home / "invalid-knowledge-app"
project.mkdir()
validator = (
self.skills_home / "skills" / "ack" / "scripts" / "validate_knowledge.py"
)
validator.write_text("raise SystemExit(1)\n", encoding="utf-8")
result = self.run_skiff("init", "ack", "--project", str(project))
self.assertNotEqual(result.returncode, 0)
self.assertNotIn("Traceback", result.stderr)
self.assertIn("初始化知识库校验失败", result.stderr)
self.assertFalse((project / "docs" / "ack").exists())
def test_validator_cannot_replace_staged_bytes_before_install(self) -> None:
project = self.home / "mutated-staging-app"
project.mkdir()
validator = (
self.skills_home / "skills" / "ack" / "scripts" / "validate_knowledge.py"
)
validator.write_text(
"import sys\n"
"from pathlib import Path\n"
"Path(sys.argv[1]).write_text('forged: true\\n', encoding='utf-8')\n",
encoding="utf-8",
)
result = self.run_skiff("init", "ack", "--project", str(project))
self.assertNotEqual(result.returncode, 0)
self.assertIn("临时文件在校验期间发生变化", result.stderr)
self.assertFalse((project / "docs" / "ack").exists())
def test_ack_init_validates_mirrored_staging_root(self) -> None:
project = self.home / "staged-knowledge-app"
project.mkdir()
validator = (
self.skills_home / "skills" / "ack" / "scripts" / "validate_knowledge.py"
)
validator.write_text(
"import sys\n"
"from pathlib import Path\n"
"required = ['--tasks', '--project-root']\n"
"if any(item not in sys.argv for item in required):\n"
" raise SystemExit(3)\n"
"root = Path(sys.argv[sys.argv.index('--project-root') + 1])\n"
"knowledge = Path(sys.argv[1])\n"
"tasks = Path(sys.argv[sys.argv.index('--tasks') + 1])\n"
"expected = root / 'docs' / 'ack'\n"
"raise SystemExit(0 if knowledge.parent == expected and "
"tasks.parent == expected else 4)\n",
encoding="utf-8",
)
result = self.run_skiff("init", "ack", "--project", str(project))
self.assertEqual(result.returncode, 0, result.stderr)
self.assertTrue((project / "docs" / "ack" / "knowledge.yaml").is_file())
def test_non_ack_init_still_requires_only_project_and_tasks_templates(self) -> None:
skill = self.skills_home / "skills" / "plain"
(skill / "templates").mkdir(parents=True)
(skill / "SKILL.md").write_text("---\nname: plain\n---\n", encoding="utf-8")
(skill / "templates" / "project.template.md").write_text(
"# <project_name>\n", encoding="utf-8"
)
(skill / "templates" / "tasks.template.yaml").write_text(
'project: "<project_name>"\n', encoding="utf-8"
)
project = self.home / "plain-app"
project.mkdir()
result = self.run_skiff("init", "plain", "--project", str(project))
self.assertEqual(result.returncode, 0, result.stderr)
target = project / "docs" / "plain"
self.assertTrue((target / "project.md").is_file())
self.assertTrue((target / "tasks.yaml").is_file())
self.assertFalse((target / "knowledge.yaml").exists())
def test_init_rejects_missing_project_directory(self) -> None:
project = self.home / "missing-app"