feat(ack): add project knowledge guardrails
This commit is contained in:
@@ -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
@@ -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
@@ -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.md 与 tasks.yaml 模板和 schema
|
||||
├── templates/ # project.md、tasks.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
@@ -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
@@ -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: []
|
||||
@@ -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` 并继续。
|
||||
- 不提交或推送,除非用户明确要求。
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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`。
|
||||
|
||||
@@ -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>)
|
||||
-> wait:Developer 的 worker_done / escalation
|
||||
-> wait:Developer 的 worker_done / escalation(含 knowledgeApplied / knowledgeCandidates)
|
||||
-> writeback fixed_by_dev
|
||||
-> dispatch 给 Test(retesting)
|
||||
-> wait:Test 的 retest_result
|
||||
-> wait:Test 的 retest_result(含 knowledgeChecks 和 candidate 独立证据)
|
||||
-> Test 通过:gate(Coordinator 读证据对齐意图)
|
||||
-> 通过 gate:writeback 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
|
||||
| 要保持基线分支干净 | 新 worktree(feature 分支)|
|
||||
| 小改动、追求快 | 当前 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 对齐」)。
|
||||
|
||||
|
||||
@@ -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` 条目没有被派发。
|
||||
|
||||
## 初始化报告
|
||||
|
||||
完成后报告:
|
||||
|
||||
- 创建或确认的两个项目文件。
|
||||
- 创建或确认的三个项目文件。
|
||||
- 检测到的技术栈和验证命令。
|
||||
- 任务板校验结果。
|
||||
- 任务板和项目知识校验结果。
|
||||
- 仍需用户补充的值。
|
||||
|
||||
只有结构校验通过且必填项目事实完整时才称“初始化完成”;否则称“部分完成”,并列出
|
||||
|
||||
@@ -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 / knowledgeCandidates,Test 回 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`。
|
||||
|
||||
@@ -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 的知识?
|
||||
|
||||
答不清楚,闭环就还没结束。
|
||||
|
||||
@@ -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`。
|
||||
|
||||
@@ -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_done,subject 使用:"<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>
|
||||
|
||||
@@ -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`。
|
||||
- 用户可见行为符合验收标准。
|
||||
|
||||
Executable
+430
@@ -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())
|
||||
Executable
+275
@@ -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())
|
||||
Executable
+1152
File diff suppressed because it is too large
Load Diff
@@ -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 才能解析 YAML:pip 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")
|
||||
|
||||
@@ -0,0 +1,686 @@
|
||||
#!/usr/bin/env python3
|
||||
"""ACK YAML 的零依赖、fail-closed 子集解析器。
|
||||
|
||||
这不是通用 YAML 实现。它只覆盖 ACK 状态文件所需的 mapping、
|
||||
sequence、flow collection、标量和 ``>`` / ``|`` block scalar。锚点、
|
||||
alias、tag、多文档和其它未实现语法会显式失败,不会猜测或静默误解析。
|
||||
"""
|
||||
|
||||
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
|
||||
@@ -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: []
|
||||
@@ -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` 并继续下一个。
|
||||
- 不提交或推送,除非用户明确要求。
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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.>
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user