From ee66dbe9ce4fa5b795ae78d80427658fdb13b6db Mon Sep 17 00:00:00 2001 From: laily Date: Fri, 31 Jul 2026 21:09:36 +0800 Subject: [PATCH] feat(ack): add project knowledge guardrails --- docs/ack-design-review.md | 637 +++++++++ skiff/cli.py | 467 ++++++- skills/ack/README.md | 57 +- skills/ack/SKILL.md | 65 +- skills/ack/VERSION | 2 +- skills/ack/examples/knowledge.example.yaml | 47 + skills/ack/examples/project.example.md | 20 +- skills/ack/examples/tasks.example.yaml | 40 +- skills/ack/references/adoption-checklist.md | 30 +- skills/ack/references/closed-loop.md | 82 +- skills/ack/references/init-new-project.md | 47 +- skills/ack/references/kickoff.md | 32 +- skills/ack/references/optimization-method.md | 40 +- skills/ack/references/orca-adapter.md | 38 +- skills/ack/references/prompt-templates.md | 81 +- .../ack/references/roles-and-permissions.md | 64 +- skills/ack/scripts/run_verification.py | 430 ++++++ skills/ack/scripts/select_knowledge.py | 275 ++++ skills/ack/scripts/validate_knowledge.py | 1152 +++++++++++++++++ skills/ack/scripts/validate_tasks.py | 593 ++++++++- skills/ack/scripts/yaml_subset.py | 686 ++++++++++ skills/ack/templates/knowledge.schema.json | 367 ++++++ skills/ack/templates/knowledge.template.yaml | 9 + skills/ack/templates/project.template.md | 27 +- skills/ack/templates/tasks.schema.json | 151 ++- skills/ack/templates/tasks.template.yaml | 5 + tests/test_ack_knowledge.py | 937 ++++++++++++++ tests/test_ack_skill.py | 17 + tests/test_ack_tasks_validation.py | 705 ++++++++++ tests/test_ack_verification_runner.py | 591 +++++++++ tests/test_ack_yaml_fallback.py | 182 +++ tests/test_skill_init.py | 616 +++++++++ 32 files changed, 8353 insertions(+), 139 deletions(-) create mode 100644 docs/ack-design-review.md create mode 100644 skills/ack/examples/knowledge.example.yaml create mode 100755 skills/ack/scripts/run_verification.py create mode 100755 skills/ack/scripts/select_knowledge.py create mode 100755 skills/ack/scripts/validate_knowledge.py create mode 100644 skills/ack/scripts/yaml_subset.py create mode 100644 skills/ack/templates/knowledge.schema.json create mode 100644 skills/ack/templates/knowledge.template.yaml create mode 100644 tests/test_ack_knowledge.py create mode 100644 tests/test_ack_tasks_validation.py create mode 100644 tests/test_ack_verification_runner.py create mode 100644 tests/test_ack_yaml_fallback.py diff --git a/docs/ack-design-review.md b/docs/ack-design-review.md new file mode 100644 index 0000000..f6da546 --- /dev/null +++ b/docs/ack-design-review.md @@ -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: "" + digest: "" +``` + +派发内容应携带验收快照和规范的绝对路径,不能假设每个 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: "" + effort: medium + permissionMode: sandbox + test: + cli: codex + 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: "" + startedAt: "" + workspace: + path: "" + baseCommit: "" + diffHash: "" + developer: + worker: "" + taskId: "" + dispatchId: "" + evidence: {} + test: + worker: "" + taskId: "" + dispatchId: "" + 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: "" + evidenceRef: "tasks.yaml#BUG-017" + +owner: "" +author: "" +reviewer: "" +createdAt: "" +lastValidatedAt: "" +reviewAfter: "" +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/)用于保存单个重要决策的 + 理由、取舍和后果,适合承载不应混入项目知识库的决策正文。 diff --git a/skiff/cli.py b/skiff/cli.py index a8b7397..50f1ddb 100644 --- a/skiff/cli.py +++ b/skiff/cli.py @@ -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, "": 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") diff --git a/skills/ack/README.md b/skills/ack/README.md index a6d1b53..d262aaf 100644 --- a/skills/ack/README.md +++ b/skills/ack/README.md @@ -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 /scripts/validate_tasks.py docs/ack/tasks.yaml +python3 /scripts/validate_knowledge.py docs/ack/knowledge.yaml \ + --tasks docs/ack/tasks.yaml ``` +Coordinator 可以按当前任务上下文做确定性推荐: + +```bash +python3 /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 /scripts/run_verification.py \ + docs/ack/knowledge.yaml check-api-contract --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 指令文件。 + ## 开始一个需求 初始化完成后可以直接说: diff --git a/skills/ack/SKILL.md b/skills/ack/SKILL.md index 96495fc..db1159a 100644 --- a/skills/ack/SKILL.md +++ b/skills/ack/SKILL.md @@ -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` 所在目录,记为 ``。所有通用规范、模板和 脚本都相对此目录访问,不依赖固定的全局安装路径。 @@ -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 /scripts/validate_tasks.py docs/ack/tasks.yaml + python3 /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. 读取 `/VERSION`,对比 `tasks.yaml` 的 `ackVersion`。旧项目只有 - `ackVersion` 时仍可读取,但建议迁移为 `ackVersion`。 + `kitVersion` 时仍可读取,但建议迁移为 `ackVersion`。 3. 查找未替换占位符,并核对项目路径、覆盖层路径、Developer 白盒命令、Test 黑盒命令和 Base URL。 -4. 使用 `/scripts/validate_tasks.py` 校验任务板。只报告证据明确的 - 问题,不因可选字段缺失而宣称失败。 -5. 检查不会自动修复或覆盖现有配置;用户明确要求修复后再修改。 +4. 使用 `/scripts/validate_tasks.py` 校验任务板,使用 + `/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` + - 通过 `/scripts/select_knowledge.py` 从 + `docs/ack/knowledge.yaml` 选择的当前任务相关 `active` 条目 - `/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` 时,只调用 + `/scripts/run_verification.py docs/ack/knowledge.yaml + --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 目录读取。 diff --git a/skills/ack/VERSION b/skills/ack/VERSION index 6f4eebd..ac39a10 100644 --- a/skills/ack/VERSION +++ b/skills/ack/VERSION @@ -1 +1 @@ -0.8.1 +0.9.0 diff --git a/skills/ack/examples/knowledge.example.yaml b/skills/ack/examples/knowledge.example.yaml new file mode 100644 index 0000000..c0d6bdd --- /dev/null +++ b/skills/ack/examples/knowledge.example.yaml @@ -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: [] diff --git a/skills/ack/examples/project.example.md b/skills/ack/examples/project.example.md index 214b29e..ed1af52 100644 --- a/skills/ack/examples/project.example.md +++ b/skills/ack/examples/project.example.md @@ -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` 并继续。 - 不提交或推送,除非用户明确要求。 diff --git a/skills/ack/examples/tasks.example.yaml b/skills/ack/examples/tasks.example.yaml index ebcfeda..8ecf345 100644 --- a/skills/ack/examples/tasks.example.yaml +++ b/skills/ack/examples/tasks.example.yaml @@ -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: diff --git a/skills/ack/references/adoption-checklist.md b/skills/ack/references/adoption-checklist.md index 9e738b8..f72acca 100644 --- a/skills/ack/references/adoption-checklist.md +++ b/skills/ack/references/adoption-checklist.md @@ -4,7 +4,9 @@ - [ ] ACK Skill 已全局安装或安装到当前项目。 - [ ] 已运行 `skiff init ack --project `。 -- [ ] `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 @@ - [ ] 真实任务的验收是可观测信号。 - [ ] 已运行 `/scripts/validate_tasks.py` 并通过。 +## 项目知识 + +- [ ] 新项目没有已验证知识时使用 `verificationRegistry: {}` 与 `entries: []`, + 不虚构 active 条目。 +- [ ] 已运行 `/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`。 diff --git a/skills/ack/references/closed-loop.md b/skills/ack/references/closed-loop.md index 13afbb3..80d899a 100644 --- a/skills/ack/references/closed-loop.md +++ b/skills/ack/references/closed-loop.md @@ -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(-A),Developer 与 Test 共用 -> 决定 worktree:当前 worktree 起子 agent,还是新建隔离 worktree(见下节「子任务放哪」) -> 解析 worker:先复用同 worktree、同角色的空闲 worker;没有可复用项时才校验命令并新建(见 orca-adapter.md) -> dispatch 给 Developer(--to ) - -> 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 默认给新逻辑轮次生成 `-A`,并在记录轮次结果时写入 +`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` 交给 +`/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: --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: --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 对齐」)。 diff --git a/skills/ack/references/init-new-project.md b/skills/ack/references/init-new-project.md index 152ae01..8267975 100644 --- a/skills/ack/references/init-new-project.md +++ b/skills/ack/references/init-new-project.md @@ -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 ```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 交给 +`/scripts/run_verification.py`,不直接运行 path/args。关键约束应 +最终下沉为测试、lint、CI 或正式规范。 + ## 校验 Agent 从当前 `SKILL.md` 解析 ACK Skill 目录后运行: ```bash python3 /scripts/validate_tasks.py docs/ack/tasks.yaml +python3 /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` 条目没有被派发。 ## 初始化报告 完成后报告: -- 创建或确认的两个项目文件。 +- 创建或确认的三个项目文件。 - 检测到的技术栈和验证命令。 -- 任务板校验结果。 +- 任务板和项目知识校验结果。 - 仍需用户补充的值。 只有结构校验通过且必填项目事实完整时才称“初始化完成”;否则称“部分完成”,并列出 diff --git a/skills/ack/references/kickoff.md b/skills/ack/references/kickoff.md index 6cd2f25..1da09b4 100644 --- a/skills/ack/references/kickoff.md +++ b/skills/ack/references/kickoff.md @@ -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-.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 /scripts/validate_tasks.py docs/ack/tasks.yaml +python3 /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 分配的稳定 -A → 回写 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 交给 `/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`。 diff --git a/skills/ack/references/optimization-method.md b/skills/ack/references/optimization-method.md index 9893ba9..514aec9 100644 --- a/skills/ack/references/optimization-method.md +++ b/skills/ack/references/optimization-method.md @@ -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 交给 `/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 的知识? 答不清楚,闭环就还没结束。 diff --git a/skills/ack/references/orca-adapter.md b/skills/ack/references/orca-adapter.md index bbd01b1..e3dfc67 100644 --- a/skills/ack/references/orca-adapter.md +++ b/skills/ack/references/orca-adapter.md @@ -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 --spec "$(cat <<'EOF' Fix : +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`。 diff --git a/skills/ack/references/prompt-templates.md b/skills/ack/references/prompt-templates.md index 337277f..db1138a 100644 --- a/skills/ack/references/prompt-templates.md +++ b/skills/ack/references/prompt-templates.md @@ -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> diff --git a/skills/ack/references/roles-and-permissions.md b/skills/ack/references/roles-and-permissions.md index 2b3dcd9..372fa2b 100644 --- a/skills/ack/references/roles-and-permissions.md +++ b/skills/ack/references/roles-and-permissions.md @@ -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`。 - 用户可见行为符合验收标准。 diff --git a/skills/ack/scripts/run_verification.py b/skills/ack/scripts/run_verification.py new file mode 100755 index 0000000..99a8b00 --- /dev/null +++ b/skills/ack/scripts/run_verification.py @@ -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()) diff --git a/skills/ack/scripts/select_knowledge.py b/skills/ack/scripts/select_knowledge.py new file mode 100755 index 0000000..15ef604 --- /dev/null +++ b/skills/ack/scripts/select_knowledge.py @@ -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()) diff --git a/skills/ack/scripts/validate_knowledge.py b/skills/ack/scripts/validate_knowledge.py new file mode 100755 index 0000000..2815c59 --- /dev/null +++ b/skills/ack/scripts/validate_knowledge.py @@ -0,0 +1,1152 @@ +#!/usr/bin/env python3 +"""校验 ACK 项目知识护栏库及其任务引用。 + +权威结构位于 templates/knowledge.schema.json。jsonschema 是可选依赖;无论是否 +安装,引用、作用域、复核、时效和冲突等语义规则都会执行。本脚本只解析数据, +不会执行 directive、verification 或其它自由文本中的命令。 + +用法: + python3 validate_knowledge.py [knowledge.yaml] + python3 validate_knowledge.py knowledge.yaml --tasks tasks.yaml + python3 validate_knowledge.py --schema path/to/knowledge.schema.json knowledge.yaml + +退出码: 0 通过 / 1 校验失败 / 2 环境或用法错误。 +""" + +from __future__ import annotations + +import argparse +import json +import re +import stat +import sys +from datetime import datetime +from pathlib import Path +from typing import Any + +from yaml_subset import ( + DuplicateKeyError, + YamlSubsetError, + load_json_unique, + load_yaml_subset, + make_unique_pyyaml_loader, +) + +KINDS = {"guardrail", "pitfall", "verification"} +STATUSES = {"active", "stale", "superseded", "archived"} +CHECK_RESULTS = {"passed", "failed", "not_applicable"} +APPLIED_RESULTS = {"applied", "not_applicable"} +TERMINAL_TASK_STATUSES = {"verified", "leftover"} +MAX_ROUNDS = 3 +ID_RE = re.compile(r"^K-[A-Z0-9][A-Z0-9-]*$") +REF_RE = re.compile(r"^(K-[A-Z0-9][A-Z0-9-]*)@([1-9][0-9]*)$") +SUBJECT_RE = re.compile(r"^[a-z0-9][a-z0-9-]*$") +VERIFICATION_REF_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$") +SAFE_RELATIVE_PATH_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/-]*$") +SCOPE_FIELDS = ( + "components", + "paths", + "dependencies", + "versions", + "tags", + "symbols", + "errorSignatures", +) +FORBIDDEN_EXECUTION_KEYS = { + "argv", + "command", + "commands", + "executable", + "script", + "shell", +} +SECRET_PATTERNS = ( + ( + "private key", + re.compile(r"-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----"), + ), + ( + "GitHub token", + re.compile(r"\bgh[pousr]_[A-Za-z0-9]{20,}\b"), + ), + ( + "OpenAI-style token", + re.compile(r"\bsk-[A-Za-z0-9_-]{20,}\b"), + ), + ( + "AWS access key", + re.compile(r"\b(?:AKIA|ASIA)[A-Z0-9]{16}\b"), + ), + ( + "URL credentials", + re.compile(r"https?://[^/\s:@]+:[^/\s@]+@"), + ), + ( + "inline secret assignment", + re.compile( + r"(?i)\b(?:api[_-]?key|access[_-]?token|password|secret|token)" + r"\s*[:=]\s*[\"']?[^\s,\"']{8,}" + ), + ), +) +ENTRY_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", +} + + +def load_yaml_text(content: str, label: str = "YAML") -> dict[str, Any]: + try: + import yaml # type: ignore + except ImportError: + try: + data = load_yaml_subset(content) + except YamlSubsetError as exc: + sys.stderr.write(f"{label} 子集解析失败: {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"{label} 解析失败: {exc}\n") + raise SystemExit(1) + if not isinstance(data, dict): + sys.stderr.write(f"{label} 顶层必须是对象(mapping)\n") + raise SystemExit(1) + return data + + +def load_yaml(path: Path, label: str = "YAML") -> dict[str, Any]: + try: + content = path.read_text(encoding="utf-8") + except OSError as exc: + sys.stderr.write(f"{label} 读取失败: {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"{label} JSON 解析失败: {exc}\n") + raise SystemExit(1) + if not isinstance(data, dict): + sys.stderr.write(f"{label} 顶层必须是对象(mapping)\n") + raise SystemExit(1) + return data + return load_yaml_text(content, label) + + +def stable_ref(entry: dict[str, Any]) -> str | None: + entry_id = entry.get("id") + revision = entry.get("revision") + if ( + isinstance(entry_id, str) + and ID_RE.fullmatch(entry_id) + and isinstance(revision, int) + and not isinstance(revision, bool) + and revision >= 1 + ): + return f"{entry_id}@{revision}" + return None + + +def _nonempty(value: Any) -> bool: + return isinstance(value, str) and bool(value.strip()) + + +def _timestamp(value: Any) -> datetime | None: + if not isinstance(value, str): + return None + try: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + return None + return parsed if parsed.tzinfo is not None else None + + +def infer_project_root(document_path: Path) -> Path | None: + """从标准 docs/ack 布局或 Git marker 推断项目根,不解析文档 symlink。""" + lexical = document_path.expanduser().absolute() + parent = lexical.parent + if parent.name == "ack" and parent.parent.name == "docs": + return parent.parent.parent.resolve() + for candidate in (parent, *parent.parents): + if (candidate / ".git").exists(): + return candidate.resolve() + return None + + +def _is_within(candidate: Path, root: Path) -> bool: + try: + candidate.relative_to(root) + except ValueError: + return False + return True + + +def _validate_registry_target( + project_root: Path, + relative_path: str, + where: str, +) -> list[str]: + """Mirror runner path policy without executing or following symlinks.""" + errors: list[str] = [] + current = project_root + parts = relative_path.split("/") + for index, segment in enumerate(parts): + current = current / segment + try: + metadata = current.lstat() + except FileNotFoundError: + return [f"{where}: 检查目标不存在: {relative_path!r}"] + except OSError as exc: + return [f"{where}: 检查目标不可访问: {relative_path!r}: {exc}"] + if stat.S_ISLNK(metadata.st_mode): + return [f"{where}: 检查目标路径不能包含 symlink: {relative_path!r}"] + if index < len(parts) - 1 and not stat.S_ISDIR(metadata.st_mode): + return [f"{where}: 中间路径不是目录: {relative_path!r}"] + + if not stat.S_ISREG(metadata.st_mode): + errors.append(f"{where}: 检查目标必须是普通文件: {relative_path!r}") + elif metadata.st_mode & 0o111 == 0: + errors.append(f"{where}: 检查目标不可执行: {relative_path!r}") + return errors + + +def _tasks_project_root( + tasks_data: dict[str, Any], tasks_path: Path | None +) -> Path | None: + project = tasks_data.get("project") + repo_path = project.get("repoPath") if isinstance(project, dict) else None + if _nonempty(repo_path): + candidate = Path(repo_path).expanduser() + if not candidate.is_absolute(): + inferred = infer_project_root(tasks_path) if tasks_path else None + if inferred is None: + return None + candidate = inferred / candidate + if not candidate.is_dir(): + return None + return candidate.resolve(strict=True) + inferred = infer_project_root(tasks_path) if tasks_path else None + return inferred if inferred is not None and inferred.is_dir() else None + + +def _unknown_keys(value: dict[str, Any], allowed: set[str], where: str) -> list[str]: + return [f"{where}: 未知字段 {key!r}" for key in sorted(set(value) - allowed)] + + +def validate_builtin_structure(data: dict[str, Any]) -> list[str]: + """jsonschema 不可用时覆盖关键结构规则。""" + errors: list[str] = [] + errors.extend( + _unknown_keys( + data, + {"version", "updatedAt", "project", "verificationRegistry", "entries"}, + "<root>", + ) + ) + version = data.get("version") + if not isinstance(version, int) or isinstance(version, bool) or version < 1: + errors.append("version 必须是 >=1 的整数") + if _timestamp(data.get("updatedAt")) is None: + errors.append("updatedAt 必须是带时区的 ISO 8601 时间") + + project = data.get("project") + if not isinstance(project, dict): + errors.append("project 必须是对象") + else: + errors.extend(_unknown_keys(project, {"name"}, "project")) + if not _nonempty(project.get("name")): + errors.append("project.name 必填") + + registry = data.get("verificationRegistry") + if not isinstance(registry, dict): + errors.append("verificationRegistry 必须是对象") + else: + for ref, target in registry.items(): + where = f"verificationRegistry.{ref}" + if not isinstance(ref, str) or not VERIFICATION_REF_RE.fullmatch(ref): + errors.append(f"{where}: 检查 ID 格式非法") + if not isinstance(target, dict): + errors.append(f"{where}: 必须是对象") + continue + errors.extend(_unknown_keys(target, {"path", "args"}, where)) + if not _nonempty(target.get("path")): + errors.append(f"{where}.path: 必须是非空字符串") + args = target.get("args") + if not isinstance(args, list) or any( + not isinstance(arg, str) for arg in args + ): + errors.append(f"{where}.args: 必须是结构化字符串数组") + + entries = data.get("entries") + if not isinstance(entries, list): + errors.append("entries 必须是列表") + return errors + + for index, entry in enumerate(entries): + where = f"entries[{index}]" + if not isinstance(entry, dict): + errors.append(f"{where}: 必须是对象") + continue + errors.extend(_unknown_keys(entry, ENTRY_REQUIRED, where)) + for field in sorted(ENTRY_REQUIRED - set(entry)): + errors.append(f"{where}: {field} 必填") + + entry_id = entry.get("id") + if not isinstance(entry_id, str) or not ID_RE.fullmatch(entry_id): + errors.append(f"{where}.id: 必须匹配 K-[A-Z0-9-]+") + revision = entry.get("revision") + if ( + not isinstance(revision, int) + or isinstance(revision, bool) + or revision < 1 + ): + errors.append(f"{where}.revision: 必须是 >=1 的整数") + if entry.get("kind") not in KINDS: + errors.append(f"{where}.kind: 应为 {sorted(KINDS)}") + if entry.get("status") not in STATUSES: + errors.append(f"{where}.status: 应为 {sorted(STATUSES)}") + if not isinstance(entry.get("temporary"), bool): + errors.append(f"{where}.temporary: 必须是布尔值") + + for field in ( + "title", + "appliesWhen", + "directive", + "rationale", + "owner", + "author", + "reviewer", + ): + if not _nonempty(entry.get(field)): + errors.append(f"{where}.{field}: 必须是非空字符串") + subject = entry.get("subject") + if not isinstance(subject, str) or not SUBJECT_RE.fullmatch(subject): + errors.append(f"{where}.subject: 必须是小写连字符标识") + + scope = entry.get("scope") + if not isinstance(scope, dict): + errors.append(f"{where}.scope: 必须是对象") + else: + allowed = {"all", *SCOPE_FIELDS} + errors.extend(_unknown_keys(scope, allowed, f"{where}.scope")) + if not isinstance(scope.get("all"), bool): + errors.append(f"{where}.scope.all: 必须是布尔值") + for field in SCOPE_FIELDS: + values = scope.get(field) + if not isinstance(values, list): + errors.append(f"{where}.scope.{field}: 必须是列表") + elif ( + any(not _nonempty(item) for item in values) + or len(values) != len(set(values)) + ): + errors.append( + f"{where}.scope.{field}: 必须是非空且不重复的字符串列表" + ) + + verification = entry.get("verification") + if not isinstance(verification, dict): + errors.append(f"{where}.verification: 必须是对象") + else: + errors.extend( + _unknown_keys( + verification, {"ref", "expected"}, f"{where}.verification" + ) + ) + verification_ref = verification.get("ref") + if ( + not isinstance(verification_ref, str) + or not VERIFICATION_REF_RE.fullmatch(verification_ref) + ): + errors.append( + f"{where}.verification.ref: 必须是检查 ID,不能是命令" + ) + if not _nonempty(verification.get("expected")): + errors.append(f"{where}.verification.expected: 必填") + + provenance = entry.get("provenance") + provenance_fields = {"taskId", "attemptId", "codeRef", "evidenceRef"} + if not isinstance(provenance, dict): + errors.append(f"{where}.provenance: 必须是对象") + else: + errors.extend( + _unknown_keys(provenance, provenance_fields, f"{where}.provenance") + ) + for field in sorted(provenance_fields): + if not _nonempty(provenance.get(field)): + errors.append(f"{where}.provenance.{field}: 必填") + + approval = entry.get("approval") + approval_fields = {"approvedBy", "approvedAt", "evidenceRef"} + if approval is not None: + if not isinstance(approval, dict): + errors.append(f"{where}.approval: 必须为 null 或对象") + else: + errors.extend( + _unknown_keys(approval, approval_fields, f"{where}.approval") + ) + for field in ("approvedBy", "evidenceRef"): + if not _nonempty(approval.get(field)): + errors.append(f"{where}.approval.{field}: 必填") + if _timestamp(approval.get("approvedAt")) is None: + errors.append( + f"{where}.approval.approvedAt: 必须是带时区的 ISO 8601 时间" + ) + + for field in ("createdAt", "lastValidatedAt"): + if _timestamp(entry.get(field)) is None: + errors.append(f"{where}.{field}: 必须是带时区的 ISO 8601 时间") + if entry.get("reviewAfter") is not None and _timestamp( + entry.get("reviewAfter") + ) is None: + errors.append( + f"{where}.reviewAfter: 必须为 null 或带时区的 ISO 8601 时间" + ) + for field in ("removalCondition", "statusReason"): + if entry.get(field) is not None and not _nonempty(entry.get(field)): + errors.append(f"{where}.{field}: 必须为 null 或非空字符串") + for field in ("supersedes", "conflictsWith"): + refs = entry.get(field) + if not isinstance(refs, list): + errors.append(f"{where}.{field}: 必须是列表") + elif ( + any(not isinstance(ref, str) or not REF_RE.fullmatch(ref) for ref in refs) + or len(refs) != len(set(refs)) + ): + errors.append( + f"{where}.{field}: 必须是不重复的 K-...@revision 引用列表" + ) + return errors + + +def _find_forbidden_keys(value: Any, path: str = "<root>") -> list[str]: + errors: list[str] = [] + if isinstance(value, dict): + for key, child in value.items(): + child_path = f"{path}.{key}" + if str(key).casefold() in FORBIDDEN_EXECUTION_KEYS: + errors.append( + f"{child_path}: knowledge.yaml 不保存或执行自由命令;请使用 verification.ref" + ) + errors.extend(_find_forbidden_keys(child, child_path)) + elif isinstance(value, list): + for index, child in enumerate(value): + errors.extend(_find_forbidden_keys(child, f"{path}[{index}]")) + return errors + + +def _find_secrets(value: Any, path: str = "<root>") -> list[str]: + errors: list[str] = [] + if isinstance(value, dict): + for key, child in value.items(): + errors.extend(_find_secrets(child, f"{path}.{key}")) + elif isinstance(value, list): + for index, child in enumerate(value): + errors.extend(_find_secrets(child, f"{path}[{index}]")) + elif isinstance(value, str): + for label, pattern in SECRET_PATTERNS: + if pattern.search(value): + errors.append( + f"{path}: 疑似包含 {label},知识库只能保存脱敏摘要和证据引用" + ) + return errors + + +def _canonical_scope(scope: Any) -> tuple[Any, ...] | None: + if not isinstance(scope, dict): + return None + values: list[Any] = [scope.get("all")] + for field in SCOPE_FIELDS: + field_values = scope.get(field) + if not isinstance(field_values, list): + return None + values.append(tuple(sorted(item for item in field_values if isinstance(item, str)))) + return tuple(values) + + +def validate_semantics( + data: dict[str, Any], *, project_root: Path | None = None +) -> list[str]: + """始终执行的跨字段和跨条目语义校验。""" + errors = _find_forbidden_keys(data) + errors.extend(_find_secrets(data)) + project = data.get("project") + if isinstance(project, dict) and not _nonempty(project.get("name")): + errors.append("project.name 必填") + entries = data.get("entries") + if not isinstance(entries, list): + return errors + registry = data.get("verificationRegistry") + resolved_root: Path | None = None + if project_root is not None and project_root.is_dir(): + try: + resolved_root = project_root.resolve(strict=True) + except OSError as exc: + errors.append(f"项目根目录无法解析: {exc}") + if isinstance(registry, dict): + for ref, target in registry.items(): + if not isinstance(target, dict): + continue + path = target.get("path") + if not isinstance(path, str): + continue + segments = path.replace("\\", "/").split("/") + lexically_invalid = ( + path.startswith(("/", "\\")) + or re.match(r"^[A-Za-z]:", path) + or "\\" in path + or ".." in segments + or not SAFE_RELATIVE_PATH_RE.fullmatch(path) + ) + if lexically_invalid: + errors.append( + f"verificationRegistry.{ref}.path: {path!r} 必须是仓库内相对路径" + ) + elif not _nonempty(path): + errors.append(f"verificationRegistry.{ref}.path: 必须是非空字符串") + elif resolved_root is not None: + errors.extend( + _validate_registry_target( + resolved_root, + path, + f"verificationRegistry.{ref}.path", + ) + ) + + by_ref: dict[str, dict[str, Any]] = {} + locations: dict[str, str] = {} + active_by_id: dict[str, str] = {} + active_subject_scopes: dict[tuple[str, tuple[Any, ...]], str] = {} + superseded_targets: set[str] = set() + + for index, entry in enumerate(entries): + if not isinstance(entry, dict): + continue + where = f"entries[{index}]" + for field in ( + "title", + "appliesWhen", + "directive", + "rationale", + "owner", + "author", + "reviewer", + ): + if not _nonempty(entry.get(field)): + errors.append(f"{where}.{field}: 必须是非空字符串") + verification = entry.get("verification") + if isinstance(verification, dict) and not _nonempty( + verification.get("expected") + ): + errors.append(f"{where}.verification.expected: 必填") + provenance = entry.get("provenance") + if isinstance(provenance, dict): + for field in ("taskId", "attemptId", "codeRef", "evidenceRef"): + if not _nonempty(provenance.get(field)): + errors.append(f"{where}.provenance.{field}: 必填") + approval = entry.get("approval") + if isinstance(approval, dict): + for field in ("approvedBy", "evidenceRef"): + if not _nonempty(approval.get(field)): + errors.append(f"{where}.approval.{field}: 必填") + ref = stable_ref(entry) + if ref: + if ref in by_ref: + errors.append(f"{where}: 稳定引用 {ref} 重复(首次位于 {locations[ref]})") + else: + by_ref[ref] = entry + locations[ref] = where + if entry.get("status") == "active" and isinstance(entry.get("id"), str): + previous = active_by_id.get(entry["id"]) + if previous: + errors.append( + f"{where}: {entry['id']} 同时存在多个 active revision({previous}, {ref})" + ) + elif ref: + active_by_id[entry["id"]] = ref + + scope = entry.get("scope") + if isinstance(scope, dict): + populated = [ + field + for field in SCOPE_FIELDS + if isinstance(scope.get(field), list) and scope.get(field) + ] + if scope.get("all") is True and populated: + errors.append(f"{where}.scope: all=true 时不能同时填写作用域维度") + if scope.get("all") is not True and not populated: + errors.append(f"{where}.scope: 必须设置 all=true 或至少一个作用域维度") + if ( + scope.get("all") is True + and entry.get("status") == "active" + and not isinstance(entry.get("approval"), dict) + ): + errors.append( + f"{where}: 全项目 active 知识必须记录 Decision Owner approval" + ) + paths = scope.get("paths") + if isinstance(paths, list): + for pattern in paths: + if not isinstance(pattern, str): + continue + segments = pattern.replace("\\", "/").split("/") + if ( + pattern.startswith(("/", "\\")) + or re.match(r"^[A-Za-z]:", pattern) + or "\\" in pattern + or ".." in segments + ): + errors.append( + f"{where}.scope.paths: {pattern!r} 必须是仓库内相对 glob" + ) + + created = _timestamp(entry.get("createdAt")) + validated = _timestamp(entry.get("lastValidatedAt")) + review_after = _timestamp(entry.get("reviewAfter")) + if created and validated and validated < created: + errors.append(f"{where}: lastValidatedAt 不能早于 createdAt") + if validated and review_after and review_after <= validated: + errors.append(f"{where}: reviewAfter 必须晚于 lastValidatedAt") + if ( + entry.get("status") == "active" + and review_after is not None + and review_after <= datetime.now().astimezone() + ): + errors.append( + f"{where}: active 条目已超过 reviewAfter,必须重新验证或标记 stale" + ) + if ( + entry.get("status") == "active" + and _nonempty(entry.get("author")) + and entry.get("author") == entry.get("reviewer") + ): + errors.append(f"{where}: active 条目的 reviewer 必须独立于 author") + if entry.get("temporary") is True: + if not _nonempty(entry.get("removalCondition")): + errors.append(f"{where}: temporary 条目必须填写 removalCondition") + if review_after is None: + errors.append(f"{where}: temporary 条目必须填写 reviewAfter") + if entry.get("status") in {"stale", "superseded", "archived"} and not _nonempty( + entry.get("statusReason") + ): + errors.append(f"{where}: 非 active 条目必须填写 statusReason") + verification = entry.get("verification") + if isinstance(verification, dict) and _nonempty(verification.get("ref")): + verification_ref = verification["ref"] + if not isinstance(registry, dict) or verification_ref not in registry: + errors.append( + f"{where}.verification.ref: {verification_ref!r} 未在 verificationRegistry 注册" + ) + + if ( + entry.get("status") == "active" + and isinstance(entry.get("subject"), str) + and (scope_key := _canonical_scope(scope)) is not None + ): + key = (entry["subject"], scope_key) + previous = active_subject_scopes.get(key) + if previous: + errors.append( + f"{where}: 相同 subject 和 scope 已有 active 条目 {previous}" + ) + elif ref: + active_subject_scopes[key] = ref + + refs = entry.get("supersedes") + if isinstance(refs, list): + superseded_targets.update(ref for ref in refs if isinstance(ref, str)) + + for ref, entry in by_ref.items(): + for field in ("supersedes", "conflictsWith"): + related = entry.get(field) + if not isinstance(related, list): + continue + for target_ref in related: + if not isinstance(target_ref, str) or not REF_RE.fullmatch(target_ref): + continue + if target_ref == ref: + errors.append(f"{locations[ref]}.{field}: 不能引用自身 {ref}") + continue + target = by_ref.get(target_ref) + if target is None: + errors.append( + f"{locations[ref]}.{field}: 找不到精确版本 {target_ref}" + ) + continue + if field == "supersedes" and target.get("status") != "superseded": + errors.append( + f"{locations[ref]}.supersedes: {target_ref} 必须标记为 superseded" + ) + if ( + field == "conflictsWith" + and entry.get("status") == "active" + and target.get("status") == "active" + ): + errors.append( + f"{locations[ref]}.conflictsWith: active 条目不能与 active {target_ref} 冲突" + ) + + for ref, entry in by_ref.items(): + if entry.get("status") == "superseded" and ref not in superseded_targets: + errors.append(f"{locations[ref]}: superseded 条目必须被其它 revision 显式引用") + return errors + + +def validate_with_schema( + data: dict[str, Any], schema_path: Path +) -> list[str]: + import jsonschema # type: ignore + + schema = json.loads(schema_path.read_text(encoding="utf-8")) + validator = jsonschema.Draft7Validator( + schema, format_checker=jsonschema.FormatChecker() + ) + errors: list[str] = [] + for error in sorted(validator.iter_errors(data), key=lambda item: list(item.path)): + location = "/".join(str(part) for part in error.path) or "<root>" + errors.append(f"[schema] {location}: {error.message}") + return errors + + +def validate_all( + data: dict[str, Any], + schema_path: Path, + *, + use_schema: bool | None = None, + project_root: Path | None = None, +) -> tuple[list[str], str]: + """运行 schema/fallback,并且无条件叠加语义校验。""" + if use_schema is None: + try: + import jsonschema # type: ignore # noqa: F401 + except ImportError: + use_schema = False + else: + use_schema = schema_path.is_file() + + errors = validate_builtin_structure(data) + if use_schema and schema_path.is_file(): + errors = validate_with_schema(data, schema_path) + errors + mode = f"schema ({schema_path.name}) + 内置结构 + 内置语义" + else: + mode = "内置结构 + 内置语义" + errors.extend(validate_semantics(data, project_root=project_root)) + return list(dict.fromkeys(errors)), mode + + +def _validate_knowledge_file_binding( + tasks_data: dict[str, Any], + *, + knowledge_path: Path | None, + tasks_path: Path | None, + project_root: Path | None, + project_root_is_explicit: bool, +) -> list[str]: + errors: list[str] = [] + project = tasks_data.get("project") + if not isinstance(project, dict): + return ["[tasks] project 必须是对象,无法核对 knowledgeFile"] + knowledge_file = project.get("knowledgeFile") + if not _nonempty(knowledge_file): + return ["[tasks] project.knowledgeFile 必填"] + if knowledge_file != "docs/ack/knowledge.yaml": + errors.append( + "[tasks] project.knowledgeFile 必须固定为 " + "'docs/ack/knowledge.yaml'" + ) + relative = Path(knowledge_file) + segments = knowledge_file.replace("\\", "/").split("/") + if ( + relative.is_absolute() + or re.match(r"^[A-Za-z]:", knowledge_file) + or "\\" in knowledge_file + or ".." in segments + ): + errors.append("[tasks] project.knowledgeFile 必须是项目内相对路径") + return errors + + if knowledge_path is None or tasks_path is None: + return errors + + declared_repo_path = project.get("repoPath") + declared_root = _tasks_project_root(tasks_data, tasks_path) + if ( + _nonempty(declared_repo_path) + and declared_root is None + and not project_root_is_explicit + ): + errors.append( + f"[tasks] project.repoPath={declared_repo_path!r} 不存在或不是目录" + ) + + binding_root = project_root or declared_root + if binding_root is None or not binding_root.is_dir(): + errors.append( + "[tasks] 无法确定现有项目根目录;请修正 project.repoPath " + "或传入 --project-root" + ) + return errors + try: + resolved_root = binding_root.resolve(strict=True) + expected = (resolved_root / relative).resolve(strict=False) + except (OSError, RuntimeError) as exc: + errors.append(f"[tasks] project.knowledgeFile 无法安全解析: {exc}") + return errors + if not _is_within(expected, resolved_root): + errors.append("[tasks] project.knowledgeFile 经 symlink 解析后逃逸项目根目录") + return errors + try: + actual = knowledge_path.resolve(strict=True) + except (OSError, RuntimeError) as exc: + errors.append(f"[tasks] 当前知识文件无法安全解析: {exc}") + return errors + if actual != expected: + errors.append( + f"[tasks] project.knowledgeFile 指向 {expected}," + f"与当前知识文件 {actual} 不一致" + ) + return errors + + +def _collect_task_attempts( + tasks: list[Any], errors: list[str] +) -> tuple[dict[str, dict[str, Any]], dict[str, set[str]]]: + tasks_by_id: dict[str, dict[str, Any]] = {} + attempts_by_task: dict[str, set[str]] = {} + all_attempts: set[str] = set() + for index, task in enumerate(tasks): + if not isinstance(task, dict) or not _nonempty(task.get("id")): + continue + task_id = task["id"] + if task_id in tasks_by_id: + continue + tasks_by_id[task_id] = task + attempts: set[str] = set() + dispatch = task.get("dispatch") + rounds = dispatch.get("rounds") if isinstance(dispatch, dict) else None + if isinstance(rounds, list): + round_numbers = [ + round_item.get("round") if isinstance(round_item, dict) else None + for round_item in rounds + ] + expected_rounds = list(range(1, len(rounds) + 1)) + if round_numbers != expected_rounds: + errors.append( + f"[tasks] {task_id}.dispatch.rounds: round 必须按 " + f"1..N 连续且不超过 {MAX_ROUNDS}" + ) + for round_index, round_item in enumerate(rounds): + if not isinstance(round_item, dict): + continue + attempt_id = round_item.get("attemptId") + if attempt_id is None: + continue + where = ( + f"[tasks] {task_id}.dispatch.rounds[{round_index}].attemptId" + ) + round_number = round_item.get("round") + if ( + not isinstance(round_number, int) + or isinstance(round_number, bool) + or not 1 <= round_number <= MAX_ROUNDS + ): + errors.append( + f"{where}: round 必须先是 1..{MAX_ROUNDS} 的整数" + ) + continue + expected = f"{task_id}-A{round_number}" + if attempt_id != expected: + errors.append( + f"{where}: 必须精确等于 {expected!r},不能使用 dispatchId" + ) + continue + if attempt_id in attempts or attempt_id in all_attempts: + errors.append(f"{where}: attemptId 重复 {attempt_id!r}") + continue + attempts.add(attempt_id) + all_attempts.add(attempt_id) + attempts_by_task[task_id] = attempts + return tasks_by_id, attempts_by_task + + +def validate_task_references( + knowledge: dict[str, Any], + tasks_data: dict[str, Any], + *, + knowledge_path: Path | None = None, + tasks_path: Path | None = None, + project_root: Path | None = None, + project_root_is_explicit: bool = False, +) -> list[str]: + """校验 tasks.yaml 对固定知识版本的引用和 verified gate。""" + errors = _validate_knowledge_file_binding( + tasks_data, + knowledge_path=knowledge_path, + tasks_path=tasks_path, + project_root=project_root, + project_root_is_explicit=project_root_is_explicit, + ) + knowledge_project = knowledge.get("project") + tasks_project = tasks_data.get("project") + if isinstance(knowledge_project, dict) and isinstance(tasks_project, dict): + knowledge_name = knowledge_project.get("name") + tasks_name = tasks_project.get("name") + if ( + isinstance(knowledge_name, str) + and isinstance(tasks_name, str) + and knowledge_name != tasks_name + ): + errors.append( + f"[tasks] project.name={tasks_name!r} 与知识库项目 " + f"{knowledge_name!r} 不一致" + ) + entries = knowledge.get("entries") + if not isinstance(entries, list): + return errors + by_ref = { + ref: entry + for entry in entries + if isinstance(entry, dict) and (ref := stable_ref(entry)) is not None + } + tasks = tasks_data.get("tasks") + if not isinstance(tasks, list): + return ["[tasks] tasks 必须是列表"] + tasks_by_id, attempts_by_task = _collect_task_attempts(tasks, errors) + + for index, entry in enumerate(entries): + if not isinstance(entry, dict): + continue + provenance = entry.get("provenance") + if not isinstance(provenance, dict): + continue + task_id = provenance.get("taskId") + attempt_id = provenance.get("attemptId") + where = f"[knowledge] entries[{index}].provenance" + if not _nonempty(task_id) or task_id not in tasks_by_id: + errors.append(f"{where}.taskId: 在 tasks.yaml 中找不到 {task_id!r}") + continue + if ( + not _nonempty(attempt_id) + or attempt_id not in attempts_by_task.get(task_id, set()) + ): + errors.append( + f"{where}.attemptId: {attempt_id!r} 未命中任务 {task_id!r} " + "dispatch.rounds[].attemptId" + ) + + for index, task in enumerate(tasks): + if not isinstance(task, dict): + continue + task_id = task.get("id") or index + where = f"[tasks] {task_id}" + requires_active = task.get("status") not in TERMINAL_TASK_STATUSES + raw_refs = task.get("knowledgeRefs", []) + if not isinstance(raw_refs, list): + errors.append(f"{where}.knowledgeRefs: 必须是列表") + continue + selected_refs: set[str] = set() + for ref in raw_refs: + if not isinstance(ref, str) or not REF_RE.fullmatch(ref): + errors.append(f"{where}.knowledgeRefs: {ref!r} 不是 K-...@revision") + continue + if ref in selected_refs: + errors.append(f"{where}.knowledgeRefs: 重复引用 {ref}") + continue + selected_refs.add(ref) + entry = by_ref.get(ref) + if entry is None: + errors.append(f"{where}.knowledgeRefs: 找不到精确版本 {ref}") + elif requires_active and entry.get("status") != "active": + errors.append( + f"{where}.knowledgeRefs: {ref} 状态为 {entry.get('status')!r},必须 active" + ) + + applied = task.get("knowledgeApplied", []) + if not isinstance(applied, list): + errors.append(f"{where}.knowledgeApplied: 必须是对象列表") + else: + seen_applied: set[str] = set() + for item in applied: + if not isinstance(item, dict): + errors.append(f"{where}.knowledgeApplied: 每项必须是对象") + continue + ref = item.get("ref") + if not isinstance(ref, str) or not REF_RE.fullmatch(ref): + errors.append(f"{where}.knowledgeApplied: ref 必须是 K-...@revision") + continue + if ref in seen_applied: + errors.append(f"{where}.knowledgeApplied: 重复结果 {ref}") + seen_applied.add(ref) + if ref not in selected_refs: + errors.append(f"{where}.knowledgeApplied: {ref} 未列入 knowledgeRefs") + if ref not in by_ref: + errors.append(f"{where}.knowledgeApplied: 找不到 {ref}") + elif requires_active and by_ref[ref].get("status") != "active": + errors.append(f"{where}.knowledgeApplied: {ref} 不是 active") + if item.get("result") not in APPLIED_RESULTS: + errors.append( + f"{where}.knowledgeApplied[{ref}].result: 应为 {sorted(APPLIED_RESULTS)}" + ) + + checks = task.get("knowledgeChecks", []) + passed_checks: set[str] = set() + if not isinstance(checks, list): + errors.append(f"{where}.knowledgeChecks: 必须是对象列表") + else: + seen_checks: set[str] = set() + for check in checks: + if not isinstance(check, dict): + errors.append(f"{where}.knowledgeChecks: 每项必须是对象") + continue + ref = check.get("ref") + if not isinstance(ref, str) or not REF_RE.fullmatch(ref): + errors.append(f"{where}.knowledgeChecks: ref 必须是 K-...@revision") + continue + if ref in seen_checks: + errors.append(f"{where}.knowledgeChecks: 重复结果 {ref}") + seen_checks.add(ref) + if ref not in selected_refs: + errors.append(f"{where}.knowledgeChecks: {ref} 未列入 knowledgeRefs") + if ref not in by_ref: + errors.append(f"{where}.knowledgeChecks: 找不到 {ref}") + elif requires_active and by_ref[ref].get("status") != "active": + errors.append(f"{where}.knowledgeChecks: {ref} 不是 active") + result = check.get("result") + if result not in CHECK_RESULTS: + errors.append( + f"{where}.knowledgeChecks[{ref}].result: 应为 {sorted(CHECK_RESULTS)}" + ) + elif result == "passed": + passed_checks.add(ref) + + if task.get("status") == "verified": + for ref in selected_refs: + entry = by_ref.get(ref) + if ( + entry is not None + and ref not in passed_checks + ): + errors.append( + f"{where}: verified 任务的 knowledgeRef {ref} 缺少 passed knowledgeCheck" + ) + return errors + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="校验 ACK knowledge.yaml") + parser.add_argument( + "knowledge", nargs="?", default="knowledge.yaml", help="知识库路径" + ) + parser.add_argument("--schema", help="knowledge.schema.json 路径") + parser.add_argument("--tasks", help="可选 tasks.yaml,用于跨文件引用校验") + parser.add_argument( + "--project-root", + help="可选项目根目录;默认从 tasks.project.repoPath 或 docs/ack 布局推断", + ) + 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 + schema_path = ( + Path(args.schema) + if args.schema + else Path(__file__).resolve().parent.parent + / "templates" + / "knowledge.schema.json" + ) + if args.schema and not schema_path.is_file(): + sys.stderr.write(f"找不到 schema 文件: {schema_path}\n") + return 2 + + tasks_path: Path | None = None + tasks_data: dict[str, Any] | None = None + if args.tasks: + tasks_path = Path(args.tasks) + if not tasks_path.is_file(): + sys.stderr.write(f"找不到任务板文件: {tasks_path}\n") + return 2 + tasks_data = load_yaml(tasks_path, "任务板") + + project_root_is_explicit = bool(args.project_root) + if project_root_is_explicit: + 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 = ( + _tasks_project_root(tasks_data, tasks_path) + if tasks_data is not None + else None + ) + if project_root is None: + project_root = infer_project_root(knowledge_path) + + data = load_yaml(knowledge_path, "知识库") + errors, mode = validate_all( + data, schema_path, project_root=project_root + ) + registry = data.get("verificationRegistry") + if ( + project_root is None + and isinstance(registry, dict) + and registry + ): + errors.append( + "verificationRegistry 非空但无法确定项目根目录;请传入 --project-root" + ) + + if tasks_data is not None: + errors.extend( + validate_task_references( + data, + tasks_data, + knowledge_path=knowledge_path, + tasks_path=tasks_path, + project_root=project_root, + project_root_is_explicit=project_root_is_explicit, + ) + ) + mode += " + tasks 引用" + + errors = list(dict.fromkeys(errors)) + if errors: + sys.stderr.write(f"知识库校验失败({mode}),共 {len(errors)} 项:\n") + for error in errors: + sys.stderr.write(f" - {error}\n") + return 1 + sys.stdout.write(f"知识库校验通过({mode}):{knowledge_path}\n") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/skills/ack/scripts/validate_tasks.py b/skills/ack/scripts/validate_tasks.py index 0ab7c92..ae6dc9f 100755 --- a/skills/ack/scripts/validate_tasks.py +++ b/skills/ack/scripts/validate_tasks.py @@ -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") diff --git a/skills/ack/scripts/yaml_subset.py b/skills/ack/scripts/yaml_subset.py new file mode 100644 index 0000000..e4af572 --- /dev/null +++ b/skills/ack/scripts/yaml_subset.py @@ -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 diff --git a/skills/ack/templates/knowledge.schema.json b/skills/ack/templates/knowledge.schema.json new file mode 100644 index 0000000..f899d1f --- /dev/null +++ b/skills/ack/templates/knowledge.schema.json @@ -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" + } + } + } + } +} diff --git a/skills/ack/templates/knowledge.template.yaml b/skills/ack/templates/knowledge.template.yaml new file mode 100644 index 0000000..6653d6c --- /dev/null +++ b/skills/ack/templates/knowledge.template.yaml @@ -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: [] diff --git a/skills/ack/templates/project.template.md b/skills/ack/templates/project.template.md index d09f291..6e1affb 100644 --- a/skills/ack/templates/project.template.md +++ b/skills/ack/templates/project.template.md @@ -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` 并继续下一个。 - 不提交或推送,除非用户明确要求。 diff --git a/skills/ack/templates/tasks.schema.json b/skills/ack/templates/tasks.schema.json index 1227600..880252b 100644 --- a/skills/ack/templates/tasks.schema.json +++ b/skills/ack/templates/tasks.schema.json @@ -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"] } }, diff --git a/skills/ack/templates/tasks.template.yaml b/skills/ack/templates/tasks.template.yaml index 24678a9..0d86657 100644 --- a/skills/ack/templates/tasks.template.yaml +++ b/skills/ack/templates/tasks.template.yaml @@ -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.> diff --git a/tests/test_ack_knowledge.py b/tests/test_ack_knowledge.py new file mode 100644 index 0000000..77cd053 --- /dev/null +++ b/tests/test_ack_knowledge.py @@ -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() diff --git a/tests/test_ack_skill.py b/tests/test_ack_skill.py index 7a8cf46..eb5a563 100644 --- a/tests/test_ack_skill.py +++ b/tests/test_ack_skill.py @@ -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() diff --git a/tests/test_ack_tasks_validation.py b/tests/test_ack_tasks_validation.py new file mode 100644 index 0000000..bbb3638 --- /dev/null +++ b/tests/test_ack_tasks_validation.py @@ -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() diff --git a/tests/test_ack_verification_runner.py b/tests/test_ack_verification_runner.py new file mode 100644 index 0000000..b19c10a --- /dev/null +++ b/tests/test_ack_verification_runner.py @@ -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() diff --git a/tests/test_ack_yaml_fallback.py b/tests/test_ack_yaml_fallback.py new file mode 100644 index 0000000..ecd9bd9 --- /dev/null +++ b/tests/test_ack_yaml_fallback.py @@ -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() diff --git a/tests/test_skill_init.py b/tests/test_skill_init.py index c81f82a..4c6598a 100644 --- a/tests/test_skill_init.py +++ b/tests/test_skill_init.py @@ -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"