diff --git a/skills/ack/README.md b/skills/ack/README.md index 9435b4f..a686694 100644 --- a/skills/ack/README.md +++ b/skills/ack/README.md @@ -37,7 +37,8 @@ pouch add ack -g # 全局 ## 使用前准备 - 项目是 Git 仓库。 -- 自动拉起 Developer / Test worker 时需要 Orca,以及项目里配置好的 Codex、Cursor、Grok 或 OMP。 +- 自动拉起 Developer / Test worker 时需要 Orca,以及项目里配置好的 Codex、Cursor、Grok、OMP 或 Pi。 +- 使用 Pi 时需安装 `pi`;默认模型用 `agents.yaml` 中 `cli: pi`、`model: null` 表示。 - 布测试环境需要同时安装 [deployer](../deployer/README.md),并准备好 SSH 和目标机器的 Docker Compose。 - 飞书收 bug 是可选的,要在 `project.md` 里配置 `bugIntake`。 - 不要 `pouch init builder` 或 `pouch init deployer`。测试环境和发版直接对 `/ack` 说;缺 deployer 时 ACK 会转去它的初始化。 @@ -57,7 +58,8 @@ pouch init ack --project ~/code/my-app ├── tasks.yaml ├── knowledge.yaml ├── delivery.yaml # 默认 enabled: false -└── regression.yaml # 默认 cases: [] +├── regression.yaml # 默认 cases: [] +└── agents.yaml # 可选:临时覆盖 Developer/Test 的 CLI、模型和权限 ``` 不会把 ACK Skill 复制进项目。规范、模板和脚本始终从已安装的 Skill 目录读取。 @@ -94,6 +96,16 @@ pouch init ack --project ~/code/my-app /ack 处理这个需求:…。任务验证通过后执行 review profile。 ``` +临时调整角色启动方式时直接说明意图,例如: + +```text +/ack 让这个项目的 dev agent 用 pi 默认模型,test agent 用 cursor-agent auto 模型 +``` + +ACK 会在 `.pouch/ack/agents.yaml` 保存结构化覆盖配置。它存在时优先于 +`tasks.yaml.project.orchestration`;删除后自动回退。配置 hash 会绑定启动计划和 receipt, +修改后必须重新审阅并生成 plan。 + ## Agent 会做什么 1. 初始化或检查 `.pouch/ack/`,缺项按统一格式列出;不从 README/CI 猜测并启用交付或知识。 diff --git a/skills/ack/SKILL.md b/skills/ack/SKILL.md index 5c6494f..67288f2 100644 --- a/skills/ack/SKILL.md +++ b/skills/ack/SKILL.md @@ -9,9 +9,14 @@ description: >- # ACK 项目协作入口 项目只在 `.pouch/ack/` 保存 `project.md`、`tasks.yaml`、`knowledge.yaml`、 -`delivery.yaml` 和 `regression.yaml`。不要复制 Skill 内容,不要修改 `AGENTS.md`、 +`delivery.yaml`、`regression.yaml`,以及可选的 `agents.yaml` 角色启动覆盖配置。 +不要复制 Skill 内容,不要修改项目的 `AGENTS.md`、 `CLAUDE.md`。 +固定状态文件为 `.pouch/ack/project.md`、`.pouch/ack/tasks.yaml`、 +`.pouch/ack/knowledge.yaml`、`.pouch/ack/delivery.yaml` 和 +`.pouch/ack/regression.yaml`。 + 开始时解析当前 `SKILL.md` 所在目录为 ``。优先 `git rev-parse --show-toplevel` 作为项目根。 @@ -19,22 +24,25 @@ description: >- - 初始化、接入 ACK:执行「初始化」。 - 检查配置是否完整:执行「检查」。 +- 临时调整 Developer/Test 的 CLI 或模型:执行「角色启动配置维护」。 - 做需求、修 bug 或继续任务:执行「工作」。修 bug 不写大 PRD。 - 增改关闭交付、说明怎么布测试环境或发版:执行「交付配置维护」。 -- 部署或重布测试环境:执行「运行测试环境」(加载 deployer,不复制 compose)。 +- 部署或重布测试环境:执行「运行测试环境」(加载 deployer skill 的「初始化」或运行流程,不复制 compose)。 - 发布版本:执行「运行版本发布」。回归:执行「运行回归」。 - 任务板使用 Orca 时,编排命令见 [orca-adapter.md](references/orca-adapter.md)。 ## 边界 -- 不覆盖已有 `.pouch/ack`;除用户确认的任务或 delivery profile 外,不擅自提交、推送、创建终端、新 worktree、发布或部署。 +- 不覆盖已有 `.pouch/ack`;除用户确认的任务、agents profile 或 delivery profile 外,不擅自提交、推送、创建终端、新 worktree、发布或部署。 - 不猜测命令、地址、worker handle 或模型名;无法确定写 `n/a`。 +- `agents.yaml` 是可选的临时角色启动覆盖;存在时优先于 `tasks.yaml.project.orchestration`,不存在时回退到任务板配置。 +- `agents.yaml` 只能描述结构化 CLI、模型、reasoning effort 和权限,不能保存 command、argv、shell 或环境变量;Pi 的默认模型用 `cli: pi`、`model: null` 表示。 - 不把完整 `tasks.yaml` / `knowledge.yaml` / `regression.yaml` 注入上下文;用对应 `select_*.py`。写回前跑完整校验。 - 只有 Coordinator 写任务板、知识库、回归目录、`deliveryRuns` 和 `regressionRuns`。`delivery.yaml` 只在「交付配置维护」中改。见 [roles-and-permissions.md](references/roles-and-permissions.md)。 - 知识检查只经 `run_verification.py` 的 registry ID;不把知识正文拼成 shell。 - 自动 worker 禁止 full-access、bypass、关闭 sandbox、Grok `--yolo` / bypassPermissions。OMP `--approval-mode yolo` 只用于 OMP 审批(workspace-write → yolo,read-only → always-ask),禁止 `--auto-approve`,不适用于其它 backend。 -- 无清理证明不复用 worker;无密钥 `receiptHash` 或 Orca live metadata 不能授权复用。当前 Orca 走 fresh。`indeterminate` / `reconcile required` 不直接重试。 -- 产品失败才占三轮;环境失败写 `environmentIncidents`。见 [optimization-method.md](references/optimization-method.md) §4。 +- 无清理证明不复用 worker;无密钥 `receiptHash` 或 Orca live metadata 不能授权复用。当前 Orca 走 fresh。只复用同轮、空闲且完成清理历史消息的 worker;无法确认清理成功时创建 fresh worker。`indeterminate` / `reconcile required` 不直接重试。 +- 产品失败才占三轮;环境失败写 `environmentIncidents`,环境失败不占复验轮次。见 [optimization-method.md](references/optimization-method.md) §4。 ## 初始化 @@ -50,9 +58,12 @@ python3 /scripts/validate_delivery.py .pouch/ack/delivery.yaml \ --tasks .pouch/ack/tasks.yaml --project-root python3 /scripts/validate_regression.py .pouch/ack/regression.yaml \ --tasks .pouch/ack/tasks.yaml + +# 仅在存在临时角色配置时运行 +python3 /scripts/validate_agents.py .pouch/ack/agents.yaml ``` -4. 检查上述文件是否仍有 `<...>` 占位符。结构校验通过且必填项目事实完整才称「完成」。除非用户明确要求,不提交、不推送。 +4. 检查上述文件是否仍有 `<...>` 占位符;没有真实任务时使用 `tasks: []`。结构校验通过且必填项目事实完整才称「完成」。除非用户明确要求,不提交、不推送。 ```text ## ack 初始化:完成 | 部分完成 | 阻塞 @@ -65,18 +76,26 @@ python3 /scripts/validate_regression.py .pouch/ack/regression.yam ## 检查 -只读,不自动修复。核对清单见 [adoption-checklist.md](references/adoption-checklist.md)。用 `select_tasks.py` 看配置;跑与「初始化」相同的四个校验器。对比 `VERSION` 与 `ackVersion`(旧 `kitVersion` 仍可读);从 `0.10.0` 起 `project.orchestration` 与 `workerReceipts` 必须同时存在。旧项目可无 delivery/regression,存在或被引用时必须校验。若有 `bugIntake` 再跑 `feishu_bug_intake.py check`。若测试环境走 deployer,只读跑其 `check.py`;未通过列入待配置,不要复制 compose 或静默初始化 deployer。用同一报告格式,标题改为 `## ack 检查:…`。 +只读,不自动修复。核对清单见 [adoption-checklist.md](references/adoption-checklist.md)。用 `select_tasks.py` 看配置;跑与「初始化」相同的四个校验器;存在 `agents.yaml` 时再跑 `validate_agents.py`。对比 `VERSION` 与 `ackVersion`(旧 `kitVersion` 仍可读);从 `0.10.0` 起 `project.orchestration` 与 `workerReceipts` 必须同时存在。旧项目可无 delivery/regression,存在或被引用时必须校验。若有 `bugIntake` 再跑 `feishu_bug_intake.py check`。若测试环境走 deployer,只读跑其 `check.py`;未通过列入待配置,不要复制 compose 或静默初始化 deployer。用同一报告格式,标题改为 `## ack 检查:…`。 ## 工作 1. `.pouch/ack` 不存在:停止并建议先 `/ack` 初始化;不要静默初始化。 -2. 读 `project.md`;用 `select_tasks.py`(已知任务加 `--task-id`)和 `select_knowledge.py` 取当前任务相关 `active` 条目。超预算时缩小选择,不回退为完整 yaml。 +2. 读 `project.md`;若存在 `agents.yaml`,先用 `validate_agents.py` 校验并读取其 `defaults`;用 `select_tasks.py`(已知任务加 `--task-id`)和 `select_knowledge.py` 取当前任务相关 `active` 条目。超预算时缩小选择,不回退为完整 yaml。 3. 新需求或尚未确认的计划:读 [kickoff.md](references/kickoff.md)。修 bug 写短问题说明、复现和可观测验收,不写大 PRD。 -4. 用户已确认后按 [closed-loop.md](references/closed-loop.md) 执行。当前会话担任 Coordinator,不亲自写代码或跑测试。 +4. 用户已确认后按 [closed-loop.md](references/closed-loop.md) 执行。当前会话担任 Coordinator,不亲自写代码或跑测试;worker profile 按 `agents.yaml`(若存在)否则按任务板配置选择。 5. 存在 `project.bugIntake` 时先完成「飞书收件」。创建或更换 worker 走「启动 worker」。若 `intents.testEnvironment` 已启用,派 Test 前先走「运行测试环境」。 6. 知识 `verification.ref` 只经 `run_verification.py`。不把 `worker_done` 或 Test 自报成功当作完成。环境失败先有界恢复并报告 `userAction`;三轮产品失败记 `leftover`。 7. 任务 `verified` 且改了可见行为或 API 后,转入「运行回归」收获用例。若本次计划含交付,再走对应交付模式;默认最多到 `validation_ready` 或 `review_ready`。 -8. 整轮完成后 `reclaim_workers.py` 先 dry-run 再 `--apply`,只回收仅属于 `verified` 任务的 worker。关闭结果不确定则记录,不伪报。 +8. 整轮任务完成后 `reclaim_workers.py` 先 dry-run 再 `--apply`,回收所有只属于 `verified` 任务的 worker 终端,并核对关闭回执;仍关联未完成任务的 worker 不设置 TTL。关闭结果不确定则记录,不伪报。 + +不能停在“任务已 `verified` 却声称整轮 ACK 已结束”:必须完成回归决定和 worker 回收。 + +## 角色启动配置维护 + +用户明确说「让这个项目的 dev agent 用 pi 默认模型、test agent 用 cursor-agent auto」等临时调整时,创建或更新 `.pouch/ack/agents.yaml`。配置文件形状见 [agents.template.yaml](templates/agents.template.yaml) 和 [agents.example.yaml](examples/agents.example.yaml),只写结构化 profile,不写自由命令。 + +存在 `agents.yaml` 时,ACK 的 `profile-hash`、`plan`、`launch` 和 receipt 全部绑定该文件的 hash;修改配置后必须重新 plan,旧 fingerprint 不能继续启动。删除该文件即可回退到 `tasks.yaml.project.orchestration`。配置本身不代表提交、推送、发布或部署授权。 ## 飞书收件 diff --git a/skills/ack/VERSION b/skills/ack/VERSION index 1cf0537..5a03fb7 100644 --- a/skills/ack/VERSION +++ b/skills/ack/VERSION @@ -1 +1 @@ -0.19.0 +0.20.0 diff --git a/skills/ack/examples/agents.example.yaml b/skills/ack/examples/agents.example.yaml new file mode 100644 index 0000000..5a746ff --- /dev/null +++ b/skills/ack/examples/agents.example.yaml @@ -0,0 +1,37 @@ +# Example .pouch/ack/agents.yaml for a temporary routing choice. +profileVersion: 1 +mode: "orca" +modelAllowlist: + pi: + developer: + standard: [null] + strong: [null] + cursor-agent: + test: + standard: ["auto"] +profiles: + pi-dev-default: + role: "developer" + cli: "pi" + tier: "standard" + model: null + reasoningEffort: "medium" + permissionMode: "workspace-write" + pi-dev-strong: + role: "developer" + cli: "pi" + tier: "strong" + model: null + reasoningEffort: "high" + permissionMode: "workspace-write" + cursor-test-auto: + role: "test" + cli: "cursor-agent" + tier: "standard" + model: "auto" + reasoningEffort: null + permissionMode: "workspace-write" +defaults: + developer: "pi-dev-default" + test: "cursor-test-auto" + developerUpgraded: "pi-dev-strong" diff --git a/skills/ack/examples/project.example.md b/skills/ack/examples/project.example.md index 872741a..54395d8 100644 --- a/skills/ack/examples/project.example.md +++ b/skills/ack/examples/project.example.md @@ -1,6 +1,6 @@ # notes-web Agent 协作协议(示例,项目覆盖层) -> 本项目基于 ack v0.19.0。 +> 本项目基于 ack v0.20.0。 > 通用规范由 `/ack` 从 Skill 自身的 `references/` 读取,本文件只填项目差异。 > 覆盖层文件放在 `.pouch/ack/project.md`,不占用 `AGENTS.md`。 > ACK 不会自动修改 `AGENTS.md`、`CLAUDE.md` 或其它 Agent 指令文件。 @@ -33,8 +33,9 @@ ## Worker 路由 -结构化配置位于 `.pouch/ack/tasks.yaml` 的 `project.orchestration`,启动记录位于顶层 -`workerReceipts`。本项目默认使用: +结构化配置默认位于 `.pouch/ack/tasks.yaml` 的 `project.orchestration`;存在 +`.pouch/ack/agents.yaml` 时以后者为准。启动记录位于顶层 `workerReceipts`。 +本项目默认使用: | 角色 | profile ID | 档位 | |------|------------|------| @@ -42,8 +43,8 @@ | Test | `codex-test-standard` | standard | | Developer 升级 | `codex-dev-strong` | strong | -项目如改用 Cursor 或 Grok,应修改结构化 profile、allowlist 和 defaults,再运行 -任务板校验。本文件不保存或拼接 worker shell 命令。 +项目如临时改用 Pi、Cursor 或 Grok,应修改 `agents.yaml` 的结构化 profile、allowlist +和 defaults,再运行对应校验。本文件不保存或拼接 worker shell 命令。 ## 路径权限 diff --git a/skills/ack/examples/tasks.example.yaml b/skills/ack/examples/tasks.example.yaml index e57ebb1..6d1bceb 100644 --- a/skills/ack/examples/tasks.example.yaml +++ b/skills/ack/examples/tasks.example.yaml @@ -3,7 +3,7 @@ version: 1 updatedAt: "2026-07-06T09:40:00+08:00" source: "Coordinator (PM) Agent" -ackVersion: "0.19.0" +ackVersion: "0.20.0" project: name: "notes-web" baseUrl: "http://localhost:5173" diff --git a/skills/ack/references/adoption-checklist.md b/skills/ack/references/adoption-checklist.md index 5d9f6a8..2e36c40 100644 --- a/skills/ack/references/adoption-checklist.md +++ b/skills/ack/references/adoption-checklist.md @@ -10,6 +10,8 @@ `project.knowledgeFile` 指针,没有重跑初始化或覆盖其它项目状态。 - [ ] 项目中没有 ACK Skill 的复制目录或 `kit`、`framework` 软链接。 - [ ] `tasks.yaml` 使用 `ackVersion` 记录 ACK Skill 版本。 +- [ ] 若存在 `.pouch/ack/agents.yaml`,已运行 `validate_agents.py`;它优先于任务板 + `project.orchestration`,且只包含结构化 profile,不包含自由 command/argv/env。 ## 项目覆盖层 @@ -24,7 +26,9 @@ - [ ] 技术栈、运行、构建、单测和集成测试命令均来自项目证据。 - [ ] Coordinator、Developer、Test 的模型档位和升级规则已明确。 - [ ] `project.orchestration` 使用受支持的 profileVersion,模型都命中项目 - allowlist,默认 profile 与角色/档位一致。 + allowlist,默认 profile 与角色/档位一致。 +- [ ] `agents.yaml`(若存在)使用受支持的 profileVersion,Pi 默认模型使用 + `cli: pi` + `model: null`,配置 hash 会绑定启动 plan/fingerprint/receipt。 - [ ] `allowedWorktrees` 已废弃(v0.19 起),新任务板不配置;worker 默认在 `--project-root` 工作,其它 worktree 由 launcher 按同 git 仓库且已注册约束放行。 - [ ] `tasks.yaml` 不需要保存 `repoPath` 或 `devWorktree`;项目状态从当前 diff --git a/skills/ack/references/closed-loop.md b/skills/ack/references/closed-loop.md index cf5ab5d..6bc997c 100644 --- a/skills/ack/references/closed-loop.md +++ b/skills/ack/references/closed-loop.md @@ -34,7 +34,7 @@ Coordinator 发现或读取 open 任务 -> 从 knowledge.yaml 按 scope 推荐 active 知识,Coordinator 确认固定 revision 的 knowledgeRefs -> 为新轮次生成稳定 attemptId(-A),Developer 与 Test 共用 -> 决定 worktree:当前 worktree 起子 agent,还是新建隔离 worktree(见下节「子任务放哪」) - -> 解析安全 profile:机器配置只读 tasks.yaml.project.orchestration + -> 解析安全 profile:机器配置只读 agents.yaml(存在时),否则读取 tasks.yaml.project.orchestration -> 运行 launcher plan 并审阅 launchFingerprint -> 检查同轮空闲 worker;可信清理历史消息成功才复用,否则带 expected fingerprint 启动 fresh worker -> 把本次 task/attempt receipt 写回 tasks.yaml(见 orca-adapter.md) diff --git a/skills/ack/references/init-new-project.md b/skills/ack/references/init-new-project.md index d3d0bef..292c920 100644 --- a/skills/ack/references/init-new-project.md +++ b/skills/ack/references/init-new-project.md @@ -103,6 +103,9 @@ pouch init ack --project - `allowedWorktrees` 已废弃(v0.19 起),新任务板不生成该字段;worker 默认在 `--project-root` 工作。模型 allowlist、profiles 和 defaults 使用项目实际允许值。 不要把完整启动命令、`extraArgs`、`env` 或任意 executable 写进任务板。 +- `.pouch/ack/agents.yaml` 是可选的临时 worker 路由覆盖;存在时优先于任务板中的 + `project.orchestration`。它必须使用结构化 profile;Pi 默认模型写成 `cli: pi`、 + `model: null`,不能写自由命令。 - 非服务项目的 `baseUrl` 写为 `n/a`。 - 没有真实任务时使用 `tasks: []`,不要保留或虚构示例任务。 @@ -152,6 +155,8 @@ python3 /scripts/validate_delivery.py .pouch/ack/delivery.yaml \ --tasks .pouch/ack/tasks.yaml --project-root python3 /scripts/validate_regression.py .pouch/ack/regression.yaml \ --tasks .pouch/ack/tasks.yaml +# 存在临时角色配置时 +python3 /scripts/validate_agents.py .pouch/ack/agents.yaml ``` 同时确认: @@ -164,6 +169,8 @@ python3 /scripts/validate_regression.py .pouch/ack/regression.yam - Developer 与 Test 的验证命令可执行。 - `project.orchestration` 的 profile/allowlist/defaults 通过校验,自动模式只允许 `read-only` 或 `workspace-write`;旧任务板未迁移时保持手动模式。 +- 若存在 `agents.yaml`,其 profile/allowlist/defaults 也通过校验,且启动配置 hash + 会绑定 plan、fingerprint 和 receipt。 - 顶层 `workerReceipts` 和 `dispatch.developer/test` 的 task/role/profile/attempt 引用一致;`receiptId` 与 `attemptId` 同时为空或同时填写。持久 receipt 只作审计, 不能单独授权复用旧终端;复用还需要空闲状态、身份匹配和可信历史清理证明。 diff --git a/skills/ack/references/kickoff.md b/skills/ack/references/kickoff.md index e0c9637..c5e53a7 100644 --- a/skills/ack/references/kickoff.md +++ b/skills/ack/references/kickoff.md @@ -10,8 +10,9 @@ **你(发起编排的强模型会话)就是 Coordinator (PM) / 产品。** 你负责写文档、拆任务、 编排、终检,**不亲自写代码、不亲自跑测试**。开发和测试是另起的 worker agent; -具体 CLI、模型、reasoning effort 和执行模式的机器事实源是 -`.pouch/ack/tasks.yaml` 的 `project.orchestration`。`.pouch/ack/project.md` 只解释项目 +具体 CLI、模型、reasoning effort 和执行模式的机器事实源优先是 +`.pouch/ack/agents.yaml`,不存在时回退到 `.pouch/ack/tasks.yaml` 的 +`project.orchestration`。`.pouch/ack/project.md` 只解释项目 差异,不能提供另一套启动命令。 --- @@ -65,8 +66,8 @@ python3 /scripts/validate_tasks.py .pouch/ack/tasks.yaml python3 /scripts/validate_knowledge.py .pouch/ack/knowledge.yaml --tasks .pouch/ack/tasks.yaml ``` - `project.orchestration` 是 worker profile 的机器 SSOT;未知字段、非 allowlist 模型、 - 不匹配的角色/档位和非安全权限必须在派发前 fail closed。 + 有效 worker routing(`agents.yaml` 优先,否则 `project.orchestration`)是 profile 的机器 + SSOT;未知字段、非 allowlist 模型、不匹配的角色/档位和非安全权限必须在派发前 fail closed。 读取任务上下文使用: @@ -96,7 +97,7 @@ python3 /scripts/select_tasks.py .pouch/ack/tasks.yaml \ ## 第 3 步:审阅并启动 worker -先读 `tasks.yaml.project.orchestration` 的结构化 profiles。历史 receipt 或既有 Orca +先读有效 routing(`agents.yaml` 优先,否则 `tasks.yaml.project.orchestration`)的结构化 profiles。历史 receipt 或既有 Orca terminal 不能单独授权复用。复用候选必须属于同一轮 ACK、处于空闲状态,且角色、 profile、worktree 与启动身份仍完全匹配;还必须通过受信后端清理历史消息并取得可核对 的新会话身份。当前 Orca 接口缺少该清理证明,所以 Orca 派发仍创建 fresh worker。 @@ -196,7 +197,7 @@ Coordinator 最后标记整轮任务完成后,用 `scripts/reclaim_workers.py` ## 一句话 产品文档 + 验收信号写在前(你,强模型)→ 确认显式 `knowledgeRefs` → 从 -`tasks.yaml.project.orchestration` 解析安全 profile → 审阅 plan 并用 expected +有效 routing(`agents.yaml` 优先,否则 `tasks.yaml.project.orchestration`)解析安全 profile → 审阅 plan 并用 expected fingerprint 启动 fresh DEV/TEST → dispatch / 复测 / 终检循环 → 任务结论落 `tasks.yaml` → 可选收获回归用例到 `regression.yaml` → 可选 delivery profile 到审核点,验证后的跨任务知识由 Coordinator 落 `knowledge.yaml` → 整轮完成后回收 diff --git a/skills/ack/references/model-routing.md b/skills/ack/references/model-routing.md index 951fc9b..e5a159f 100644 --- a/skills/ack/references/model-routing.md +++ b/skills/ack/references/model-routing.md @@ -1,6 +1,6 @@ # 模型路由(稳定核心) -本文件是**三角色默认模型档位、升级规则和 worker profile 选择规则**的单一规范源。 +本文件是**三角色默认模型档位、升级规则和 worker profile 选择规则**的规范源。 目标是在不牺牲质量的前提下降低 token 和模型成本:把昂贵的强模型留给需要判断的 工作,把机械执行交给较弱模型。 @@ -64,23 +64,32 @@ profile 升级时不得复用旧 worker,Test 也不得使用 Developer 的强 ## 结构化 worker profile -### 机器事实源 +### 机器事实源与项目临时覆盖 -worker 路由的机器可读事实只保存在 `.pouch/ack/tasks.yaml` 的 -`project.orchestration`。`project.md` 可以解释项目为何选某个档位,但不能另写一份 -完整启动命令或覆盖机器配置。字段结构以 `templates/tasks.schema.json` 为准。 +worker 路由的机器可读事实默认保存在 `.pouch/ack/tasks.yaml` 的 +`project.orchestration`。项目需要临时调整角色 CLI 或模型时,可以在同一目录增加 +`.pouch/ack/agents.yaml`;它使用与 `project.orchestration` 相同的结构,且优先级更高。 +删除该文件即回退到任务板配置。`project.md` 只能解释项目为何选某个档位,不能另写一份 +完整启动命令或覆盖机器配置。字段结构以 `templates/tasks.schema.json` 和 +`templates/agents.schema.json` 为准。 + +`agents.yaml` 的配置 hash 会进入 profile-hash、launch plan、fingerprint 和 receipt。 +因此修改临时配置后必须重新审阅 plan;旧 fingerprint 不会被接受。 每个 profile 明确声明: - `role`:`developer` 或 `test`; -- `cli`:受支持的 Agent CLI(`codex`、`cursor-agent`、`grok`、`omp`); +- `cli`:受支持的 Agent CLI(`codex`、`cursor-agent`、`grok`、`omp`、`pi`); - `tier`:角色模型档位; -- `model`:项目 allowlist 中的精确模型 ID; +- `model`:项目 allowlist 中的精确模型 ID;Pi 可用 `null` 表示其默认模型; - `reasoningEffort`:CLI 支持时显式声明; - `permissionMode`:只允许 `read-only` 或 `workspace-write`。 -`project.orchestration` 必须声明按 CLI/角色/档位分组的 `modelAllowlist`、命名 -`profiles` 和角色 `defaults`(`allowedWorktrees` 自 v0.19 起废弃,不再配置)。 +有效路由配置必须声明按 CLI/角色/档位分组的 `modelAllowlist`、命名 `profiles` 和角色 +`defaults`(`allowedWorktrees` 自 v0.19 起废弃,不再配置)。`cli: pi` 使用真实的 +Pi CLI;`model: null` 表示使用 Pi 自己保存的默认模型。当前 launcher 对 Pi 只构造 +固定的 `--thinking`,工作目录由受信 bootstrap 设置;Pi 的 `read-only` profile 只允许 +`read,grep,find,ls` 工具。 launcher 请求的 worktree 必须是同 Git 仓库的已注册 worktree,profile 的模型也必须 命中对应 CLI/角色/档位的精确列表。 @@ -142,10 +151,10 @@ worker 实际工作的绝对路径,两者可以不同。后者仍必须与项 项目状态文件固定从 `--project-root/.pouch/ack/` 解析;任务板不需要保存 `repoPath` 或 `devWorktree`。旧任务板中的这两个字段仅作兼容信息,launcher 不使用它们授权或定位。 -`projectRoot`、任务板内容摘要和 worker worktree identity 都会进入 launch fingerprint, +`projectRoot`、任务板内容摘要、有效 agents 配置 hash 和 worker worktree identity 都会进入 launch fingerprint, 因此切换权威项目根、任务板内容或 worker 路径后必须重新生成并审阅 plan。 -- `profile-hash` 读取 `tasks.yaml` 中的 profile,输出确定性 hash。 +- `profile-hash` 读取有效路由配置中的 profile,并输出配置来源与确定性 hash。 - `plan` 只校验并展示将使用的结构化计划,不创建终端。 - `launch` 是唯一允许创建 worker 的入口;它要求传入刚审阅的 `plan.launchFingerprint`,当前 profile、任务/attempt、worktree、CLI 或 argv diff --git a/skills/ack/references/orca-adapter.md b/skills/ack/references/orca-adapter.md index dcf2b78..0665d96 100644 --- a/skills/ack/references/orca-adapter.md +++ b/skills/ack/references/orca-adapter.md @@ -63,8 +63,8 @@ reset/attestation 后才能启用上述复用路径,不能用项目文档声 ## 通过 launcher 创建 worker **模型不由 Orca dispatch 设置。** `task-create` / `dispatch` 只投递任务;Agent CLI、 -模型、reasoning effort 和权限由 `tasks.yaml.project.orchestration` 的结构化 profile -决定,并且只能由 ACK launcher 构造。 +模型、reasoning effort 和权限由 `.pouch/ack/agents.yaml`(存在时)或 +`tasks.yaml.project.orchestration` 的结构化 profile 决定,并且只能由 ACK launcher 构造。 先只读生成计划: diff --git a/skills/ack/scripts/launch_worker.py b/skills/ack/scripts/launch_worker.py index a68d57c..3e07c8a 100755 --- a/skills/ack/scripts/launch_worker.py +++ b/skills/ack/scripts/launch_worker.py @@ -45,13 +45,14 @@ from worker_profiles import ( # noqa: E402 executable_basename_matches_cli, profile_hash, render_worker_argv, - validate_routing_document, + validate_orchestration, ) PROTOCOL_VERSION = LAUNCH_PROTOCOL_VERSION RECEIPT_VERSION = 1 ENVIRONMENT_POLICY = "per-cli-allowlist-v1" TASKS_RELATIVE_PATH = Path(".pouch/ack/tasks.yaml") +AGENTS_RELATIVE_PATH = Path(".pouch/ack/agents.yaml") MAX_CONTROL_OUTPUT = 1024 * 1024 MAX_RECORD_SIZE = 256 * 1024 LAUNCH_TTL_SECONDS = 120 @@ -85,12 +86,49 @@ WORKER_CREDENTIAL_NAMES = { "cursor-agent": frozenset({"CURSOR_API_KEY"}), "grok": frozenset({"XAI_API_KEY"}), "omp": frozenset({"OPENCODE_API_KEY"}), + "pi": frozenset( + { + "ANTHROPIC_API_KEY", + "ANT_LING_API_KEY", + "AZURE_OPENAI_API_KEY", + "OPENAI_API_KEY", + "DEEPSEEK_API_KEY", + "NVIDIA_API_KEY", + "GEMINI_API_KEY", + "MISTRAL_API_KEY", + "GROQ_API_KEY", + "CEREBRAS_API_KEY", + "CLOUDFLARE_API_KEY", + "CLOUDFLARE_ACCOUNT_ID", + "CLOUDFLARE_GATEWAY_ID", + "XAI_API_KEY", + "OPENROUTER_API_KEY", + "AI_GATEWAY_API_KEY", + "ZAI_API_KEY", + "ZAI_CODING_CN_API_KEY", + "OPENCODE_API_KEY", + "RADIUS_API_KEY", + "FIREWORKS_API_KEY", + "TOGETHER_API_KEY", + "BASETEN_API_KEY", + "KIMI_API_KEY", + "MINIMAX_API_KEY", + "MINIMAX_CN_API_KEY", + "QWEN_TOKEN_PLAN_API_KEY", + "QWEN_TOKEN_PLAN_CN_API_KEY", + "XIAOMI_API_KEY", + "XIAOMI_TOKEN_PLAN_CN_API_KEY", + "XIAOMI_TOKEN_PLAN_AMS_API_KEY", + "XIAOMI_TOKEN_PLAN_SGP_API_KEY", + } + ), } CLI_TITLE_LABELS = { "codex": "CODEX", "cursor-agent": "CURSOR", "grok": "GROK", "omp": "OMP", + "pi": "PI", } INHERITED_ENVIRONMENT_PREFIXES = ( "LC_", @@ -262,12 +300,55 @@ def authoritative_tasks_path(project_root: Path) -> Path: return tasks_path +def optional_agents_path(project_root: Path) -> Path | None: + agents_path = project_root / AGENTS_RELATIVE_PATH + try: + metadata = os.lstat(agents_path) + except FileNotFoundError: + return None + except OSError as exc: + raise LaunchError(f"agents.yaml 不可访问: {agents_path}") from exc + if stat.S_ISLNK(metadata.st_mode) or not stat.S_ISREG(metadata.st_mode): + raise LaunchError(f"agents.yaml 必须是普通文件且不能是 symlink: {agents_path}") + _assert_no_symlink_components(agents_path, "agents.yaml 路径") + if metadata.st_size > MAX_RECORD_SIZE: + raise LaunchError(f"agents.yaml 超过 {MAX_RECORD_SIZE} 字节上限") + return agents_path + + +def load_effective_orchestration( + project_root: Path, + board: dict, +) -> tuple[dict, str, str]: + """Load the optional project override and return routing, hash, source.""" + + agents_path = optional_agents_path(project_root) + if agents_path is not None: + agents = load_document(agents_path) + errors = validate_orchestration(agents, where="agents.yaml") + if errors: + formatted = "\n".join(f" - {error}" for error in dict.fromkeys(errors)) + raise LaunchError(f"agents.yaml 未通过 worker 路由校验:\n{formatted}") + return agents, canonical_sha256(agents), str(agents_path) + + project = board.get("project") + orchestration = project.get("orchestration") if isinstance(project, dict) else None + if not isinstance(orchestration, dict): + raise LaunchError("任务板缺少 project.orchestration,且没有 agents.yaml") + return orchestration, canonical_sha256(orchestration), "tasks.yaml:project.orchestration" + + def load_authoritative_board(project_root_value: str) -> tuple[Path, dict]: project_root = canonical_directory(project_root_value, "项目根目录") tasks_path = authoritative_tasks_path(project_root) board = load_document(tasks_path) - errors = validate_builtin(board) - errors.extend(validate_routing_document(board)) + effective, _, _ = load_effective_orchestration(project_root, board) + errors = validate_builtin(board, orchestration_override=effective) + if optional_agents_path(project_root) is not None: + project = board.get("project") + board_orchestration = project.get("orchestration") if isinstance(project, dict) else None + if board_orchestration is not None: + errors.extend(validate_orchestration(board_orchestration)) if errors: formatted = "\n".join(f" - {error}" for error in dict.fromkeys(errors)) raise LaunchError(f"任务板未通过 worker 路由校验:\n{formatted}") @@ -307,8 +388,25 @@ def _is_trusted_grok_executable(resolved: Path, metadata: os.stat_result) -> boo return metadata.st_uid == os.getuid() and metadata.st_gid == os.getgid() +def _is_trusted_pi_shim(candidate: Path, resolved: Path, metadata: os.stat_result) -> bool: + """Accept the mise-managed ``pi`` shim while retaining target identity checks.""" + + home, _ = account_identity() + expected_shim = home / ".local" / "share" / "mise" / "shims" / "pi" + if candidate != expected_shim or candidate.name != "pi": + return False + if resolved.name != "mise": + return False + if metadata.st_uid not in {0, os.getuid()} or stat.S_IMODE(metadata.st_mode) & 0o022: + return False + try: + return candidate.is_symlink() and candidate.resolve(strict=True) == resolved + except OSError: + return False + + def resolve_executable(name: str) -> Path: - supported = {"codex", "cursor-agent", "grok", "omp", "git", "orca"} + supported = {"codex", "cursor-agent", "grok", "omp", "pi", "git", "orca"} if name not in supported: raise LaunchError(f"不支持的可执行文件: {name}") search_paths = trusted_path_entries() @@ -337,6 +435,10 @@ def resolve_executable(name: str) -> Path: if _is_trusted_grok_executable(resolved, metadata): return resolved continue + if name == "pi": + if _is_trusted_pi_shim(candidate, resolved, metadata): + return candidate + continue if stat.S_IMODE(metadata.st_mode) & 0o022: continue if resolved.name != name: @@ -672,14 +774,13 @@ def build_plan( raise LaunchError("slot 必须是 1..99 的整数") project_root, board = load_authoritative_board(project_root_value) + orchestration, routing_hash, routing_source = load_effective_orchestration( + project_root, board + ) board_hash = canonical_sha256(board) find_task(board, task_id) - project = board["project"] - orchestration = project.get("orchestration") - if not isinstance(orchestration, dict): - raise LaunchError("旧任务板缺少 project.orchestration,只能使用手动模式") if orchestration.get("mode") != "orca": - raise LaunchError("project.orchestration.mode 不是 orca,拒绝自动创建 worker") + raise LaunchError("有效 worker 配置的 mode 不是 orca,拒绝自动创建 worker") profiles = orchestration.get("profiles") profile = profiles.get(profile_id) if isinstance(profiles, dict) else None if not isinstance(profile, dict): @@ -718,6 +819,11 @@ def build_plan( profile, profile_version=orchestration["profileVersion"], ) + routing_facts = ( + {} + if routing_source == "tasks.yaml:project.orchestration" + else {"routingSource": routing_source, "routingHash": routing_hash} + ) created_for = { "taskId": task_id, "attemptId": attempt_id, @@ -735,6 +841,7 @@ def build_plan( "worktree": worktree, "requested": requested, "slot": slot, + **routing_facts, } ) try: @@ -762,6 +869,7 @@ def build_plan( "requested": requested, "slot": slot, "title": title, + **routing_facts, } @@ -1136,6 +1244,9 @@ def build_receipt( }, "createdAt": created_at, } + if "routingSource" in plan and "routingHash" in plan: + receipt["routingSource"] = plan["routingSource"] + receipt["routingHash"] = plan["routingHash"] receipt["receiptHash"] = canonical_sha256(receipt) return receipt @@ -1579,8 +1690,10 @@ def build_parser() -> argparse.ArgumentParser: def profile_hash_command(project_root_value: str, profile_id: str) -> dict: if not PROFILE_ID_RE.fullmatch(profile_id): raise LaunchError("profile-id 格式非法") - _, board = load_authoritative_board(project_root_value) - orchestration = board["project"].get("orchestration") + project_root, board = load_authoritative_board(project_root_value) + orchestration, routing_hash, routing_source = load_effective_orchestration( + project_root, board + ) profiles = orchestration.get("profiles") if isinstance(orchestration, dict) else None profile = profiles.get(profile_id) if isinstance(profiles, dict) else None if not isinstance(profile, dict): @@ -1588,6 +1701,8 @@ def profile_hash_command(project_root_value: str, profile_id: str) -> dict: return { "profileVersion": orchestration["profileVersion"], "profileId": profile_id, + "routingSource": routing_source, + "routingHash": routing_hash, "profileHash": profile_hash( profile, profile_version=orchestration["profileVersion"], diff --git a/skills/ack/scripts/validate_agents.py b/skills/ack/scripts/validate_agents.py new file mode 100755 index 0000000..a4ec349 --- /dev/null +++ b/skills/ack/scripts/validate_agents.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 +"""Validate an optional ACK .pouch/ack/agents.yaml routing override.""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +from validate_tasks import load_document, validate_with_schema +from worker_profiles import validate_orchestration + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="校验 ACK agents.yaml 结构") + parser.add_argument("agents", nargs="?", default="agents.yaml", help="agents.yaml 路径") + parser.add_argument("--schema", help="agents.schema.json 路径(默认自动探测)") + args = parser.parse_args(argv) + + agents_path = Path(args.agents) + if not agents_path.is_file() or agents_path.is_symlink(): + sys.stderr.write(f"agents.yaml 必须是普通文件且不能是 symlink: {agents_path}\n") + return 2 + + data = load_document(agents_path) + schema_path = Path(args.schema) if args.schema else ( + Path(__file__).resolve().parent.parent / "templates" / "agents.schema.json" + ) + errors = validate_orchestration(data, where="agents.yaml") + mode = "内置语义规则" + try: + import jsonschema # type: ignore # noqa: F401 + + if schema_path.is_file(): + errors = validate_with_schema(data, schema_path) + errors + mode = f"schema ({schema_path.name}) + 内置语义规则" + except ImportError: + pass + + if errors: + sys.stderr.write(f"agents.yaml 校验失败({mode}),共 {len(errors)} 项:\n") + for error in dict.fromkeys(errors): + sys.stderr.write(f" - {error}\n") + return 1 + + sys.stdout.write(f"agents.yaml 校验通过({mode}):{agents_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 afbb32e..a2e2476 100755 --- a/skills/ack/scripts/validate_tasks.py +++ b/skills/ack/scripts/validate_tasks.py @@ -32,6 +32,7 @@ from yaml_subset import ( load_yaml_subset, make_unique_pyyaml_loader, ) +from worker_profiles import validate_orchestration from worker_profiles import validate_routing_document STATUS_ENUM = { @@ -894,7 +895,11 @@ def validate_with_schema(data: dict, schema_path: Path) -> list[str]: return errors -def validate_builtin(data: dict) -> list[str]: +def validate_builtin( + data: dict, + *, + orchestration_override: dict | None = None, +) -> list[str]: errors: list[str] = [] project_intake_workflow = "read-only-v1" @@ -1044,6 +1049,8 @@ def validate_builtin(data: dict) -> list[str]: if "ackVersion" in data and version_match is None: errors.append("ackVersion 必须是合法 SemVer(例如 0.10.0)") routing_required = ( + orchestration_override is not None + or (isinstance(project, dict) and "orchestration" in project) or "workerReceipts" in data or ( @@ -1053,7 +1060,12 @@ def validate_builtin(data: dict) -> list[str]: ) ) if routing_required: - errors.extend(validate_routing_document(data)) + errors.extend( + validate_routing_document( + data, + orchestration_override=orchestration_override, + ) + ) if "summary" in data: summary = data["summary"] @@ -1415,7 +1427,27 @@ def main(argv: list[str] | None = None) -> int: sys.stderr.write(f"找不到指定的 schema 文件: {schema_path}\n") return 2 - errors = validate_builtin(data) + agents_path = tasks_path.with_name("agents.yaml") + agents = None + if agents_path.is_symlink(): + errors = ["agents.yaml 不能是 symlink"] + agents = None + elif agents_path.is_file(): + agents = load_document(agents_path) + if not isinstance(agents, dict): + errors = ["agents.yaml 顶层必须是对象(mapping)"] + agents = None + else: + errors = validate_orchestration(agents, where="agents.yaml") + else: + errors = [] + + errors.extend(validate_builtin(data, orchestration_override=agents)) + if agents is not None: + project = data.get("project") + board_orchestration = project.get("orchestration") if isinstance(project, dict) else None + if board_orchestration is not None: + errors.extend(validate_orchestration(board_orchestration)) mode = "内置语义规则" try: import jsonschema # type: ignore # noqa: F401 diff --git a/skills/ack/scripts/validate_worker_command.py b/skills/ack/scripts/validate_worker_command.py index 915cb3f..82ad8a1 100755 --- a/skills/ack/scripts/validate_worker_command.py +++ b/skills/ack/scripts/validate_worker_command.py @@ -13,7 +13,7 @@ import sys MIGRATION_MESSAGE = ( "ACK v0.10 已停用自由 worker command 校验器;请在 " - ".pouch/ack/tasks.yaml 的 project.orchestration 中声明 profile,并仅调用 " + ".pouch/ack/agents.yaml(优先)或 .pouch/ack/tasks.yaml 的 project.orchestration 中声明 profile,并仅调用 " "scripts/launch_worker.py profile-hash|plan|launch。" ) diff --git a/skills/ack/scripts/worker_profiles.py b/skills/ack/scripts/worker_profiles.py index 1bc1856..2520c66 100644 --- a/skills/ack/scripts/worker_profiles.py +++ b/skills/ack/scripts/worker_profiles.py @@ -24,7 +24,7 @@ LAUNCH_PROTOCOL_VERSION = 1 MAX_ROUNDS = 3 ROLES = frozenset({"developer", "test"}) -CLIS = frozenset({"codex", "cursor-agent", "grok", "omp"}) +CLIS = frozenset({"codex", "cursor-agent", "grok", "omp", "pi"}) TIERS = frozenset({"standard", "strong"}) REASONING_EFFORTS = frozenset({"low", "medium", "high", "xhigh"}) PERMISSION_MODES = frozenset({"read-only", "workspace-write"}) @@ -32,6 +32,7 @@ ORCHESTRATION_MODES = frozenset({"orca", "manual"}) DEFAULT_KEYS = frozenset({"developer", "test", "developerUpgraded"}) CLI_REQUIRES_REASONING_EFFORT = frozenset({"codex", "grok", "omp"}) CLI_REQUIRES_NULL_REASONING_EFFORT = frozenset({"cursor-agent"}) +CLI_ALLOWS_DEFAULT_MODEL = frozenset({"pi"}) def _cli_choice_text() -> str: @@ -95,7 +96,9 @@ RECEIPT_FIELDS = frozenset( "receiptHash", } ) -RECEIPT_CONTEXT_FIELDS = frozenset({"projectRoot", "boardHash"}) +RECEIPT_CONTEXT_FIELDS = frozenset( + {"projectRoot", "boardHash", "routingSource", "routingHash"} +) CREATED_FOR_FIELDS = frozenset({"taskId", "attemptId", "role"}) WORKTREE_FIELDS = frozenset( { @@ -247,8 +250,10 @@ def validate_profile(profile: Any, *, where: str = "profile") -> list[str]: errors.append(f"{where}.cli: must be {_cli_choice_text()}") if not isinstance(tier, str) or tier not in TIERS: errors.append(f"{where}.tier: must be standard/strong") - if not isinstance(model, str) or MODEL_ID_RE.fullmatch(model) is None: - errors.append(f"{where}.model: must be a safe model ID") + if not (isinstance(cli, str) and cli in CLI_ALLOWS_DEFAULT_MODEL and model is None) and ( + not isinstance(model, str) or MODEL_ID_RE.fullmatch(model) is None + ): + errors.append(f"{where}.model: must be a safe model ID or null for Pi default") if not isinstance(permission, str) or permission not in PERMISSION_MODES: errors.append( f"{where}.permissionMode: must be read-only/workspace-write" @@ -265,6 +270,10 @@ def validate_profile(profile: Any, *, where: str = "profile") -> list[str]: and effort is not None ): errors.append(f"{where}.reasoningEffort: Cursor requires null") + elif isinstance(cli, str) and cli == "pi" and ( + not isinstance(effort, str) or effort not in REASONING_EFFORTS + ): + errors.append(f"{where}.reasoningEffort: Pi requires low/medium/high/xhigh") if role == "test" and tier != "standard": errors.append(f"{where}.tier: Test may only use standard") @@ -312,15 +321,22 @@ def _validate_model_allowlist(value: Any, where: str) -> list[str]: if not isinstance(models, list) or not models: errors.append(f"{tier_where}: must be a non-empty model list") continue - seen: set[str] = set() + seen: set[object] = set() for index, model in enumerate(models): item_where = f"{tier_where}[{index}]" - if not isinstance(model, str) or MODEL_ID_RE.fullmatch(model) is None: - errors.append(f"{item_where}: must be a safe model ID") - elif model in seen: + if model is None and cli in CLI_ALLOWS_DEFAULT_MODEL: + model_key: object = ("default",) + elif not isinstance(model, str) or MODEL_ID_RE.fullmatch(model) is None: + errors.append( + f"{item_where}: must be a safe model ID or null for Pi default" + ) + continue + else: + model_key = model + if model_key in seen: errors.append(f"{item_where}: duplicate model {model!r}") else: - seen.add(model) + seen.add(model_key) return errors @@ -329,7 +345,7 @@ def _allowed_models( cli: Any, role: Any, tier: Any, -) -> list[str] | None: +) -> list[Any] | None: if not isinstance(allowlist, dict): return None roles = allowlist.get(cli) @@ -519,6 +535,19 @@ def render_worker_argv( "--no-session", ] + if cli == "pi": + # Pi's omitted --model selects its configured startup default. Its + # cwd is supplied by the trusted bootstrap process, because Pi has no + # --cwd flag. Read-only profiles are restricted to observation tools; + # workspace-write profiles use Pi's normal editing tool set. + argv = [executable] + if model is not None: + argv.extend(["--model", model]) + argv.extend(["--thinking", profile["reasoningEffort"]]) + if permission == "read-only": + argv.extend(["--tools", "read,grep,find,ls"]) + return argv + if cli != "grok": raise ValueError(f"unsupported cli: {cli}") grok_permission = "plan" if permission == "read-only" else "acceptEdits" @@ -597,8 +626,10 @@ def _validate_requested(value: Any, where: str) -> list[str]: if not isinstance(tier, str) or tier not in TIERS: errors.append(f"{where}.tier: must be standard/strong") model = value.get("model") - if not isinstance(model, str) or MODEL_ID_RE.fullmatch(model) is None: - errors.append(f"{where}.model: must be a safe model ID") + if not (isinstance(cli, str) and cli in CLI_ALLOWS_DEFAULT_MODEL and model is None) and ( + not isinstance(model, str) or MODEL_ID_RE.fullmatch(model) is None + ): + errors.append(f"{where}.model: must be a safe model ID or null for Pi default") effort = value.get("reasoningEffort") if isinstance(cli, str) and cli in CLI_REQUIRES_REASONING_EFFORT and ( not isinstance(effort, str) or effort not in REASONING_EFFORTS @@ -611,6 +642,10 @@ def _validate_requested(value: Any, where: str) -> list[str]: and effort is not None ): errors.append(f"{where}.reasoningEffort: Cursor requires null") + if isinstance(cli, str) and cli == "pi" and ( + not isinstance(effort, str) or effort not in REASONING_EFFORTS + ): + errors.append(f"{where}.reasoningEffort: Pi requires low/medium/high/xhigh") if not isinstance(permission, str) or permission not in PERMISSION_MODES: errors.append(f"{where}.permissionMode: must be read-only/workspace-write") executable = value.get("executable") @@ -697,6 +732,16 @@ def validate_worker_receipt( not isinstance(board_hash, str) or SHA256_RE.fullmatch(board_hash) is None ): errors.append(f"{where}.boardHash: must be a canonical sha256 hex digest") + routing_source = receipt.get("routingSource") + routing_hash = receipt.get("routingHash") + if (routing_source is None) != (routing_hash is None): + errors.append(f"{where}: routingSource and routingHash must be present together") + if routing_source is not None and not _is_nonempty_text(routing_source, max_length=512): + errors.append(f"{where}.routingSource: must be non-empty single-line text") + if routing_hash is not None and ( + not isinstance(routing_hash, str) or SHA256_RE.fullmatch(routing_hash) is None + ): + errors.append(f"{where}.routingHash: must be a canonical sha256 hex digest") version = receipt.get("receiptVersion") if version != RECEIPT_VERSION or isinstance(version, bool): @@ -818,6 +863,9 @@ def validate_worker_receipt( if project_root is not None and board_hash is not None: facts["projectRoot"] = project_root facts["boardHash"] = board_hash + if routing_source is not None and routing_hash is not None: + facts["routingSource"] = routing_source + facts["routingHash"] = routing_hash expected_fingerprint = canonical_sha256(facts) except ValueError: errors.append(f"{where}.launchFingerprint: cannot hash launch facts") @@ -1006,17 +1054,30 @@ def _validate_dispatch_links( return errors -def validate_routing_document(data: Any) -> list[str]: - """Validate routing and receipts embedded in an ACK tasks document.""" +def validate_routing_document( + data: Any, + *, + orchestration_override: dict[str, Any] | None = None, +) -> list[str]: + """Validate routing and receipts in an ACK tasks document. + + ``orchestration_override`` is the optional project-local agents.yaml + routing document. The task board remains the state source, while the + override becomes the effective profile source for receipt checks. + """ if not isinstance(data, dict): return [": must be an object"] project = data.get("project") if not isinstance(project, dict): return ["project: must be an object"] - if "orchestration" not in project: + if "orchestration" not in project and orchestration_override is None: return ["project.orchestration: is required"] - orchestration = project.get("orchestration") + orchestration = ( + orchestration_override + if orchestration_override is not None + else project.get("orchestration") + ) errors = validate_orchestration(orchestration) task_ids: set[str] = set() diff --git a/skills/ack/templates/agents.schema.json b/skills/ack/templates/agents.schema.json new file mode 100644 index 0000000..6d790df --- /dev/null +++ b/skills/ack/templates/agents.schema.json @@ -0,0 +1,82 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://git.yumee.top/laily/pouch/skills/ack/templates/agents.schema.json", + "title": "ACK project agent routing override", + "description": "Optional .pouch/ack/agents.yaml. It has the same routing shape as project.orchestration.", + "type": "object", + "required": ["profileVersion", "mode", "modelAllowlist", "profiles", "defaults"], + "additionalProperties": false, + "properties": { + "profileVersion": {"type": "integer", "const": 1}, + "mode": {"type": "string", "enum": ["orca", "manual"]}, + "modelAllowlist": {"$ref": "#/definitions/modelAllowlist"}, + "profiles": { + "type": "object", + "propertyNames": {"$ref": "#/definitions/profileId"}, + "additionalProperties": {"$ref": "#/definitions/workerProfile"} + }, + "defaults": {"$ref": "#/definitions/defaults"} + }, + "definitions": { + "profileId": {"type": "string", "pattern": "^[a-z][a-z0-9-]{1,63}$"}, + "model": { + "oneOf": [ + {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/+@-]{0,127}$"}, + {"type": "null", "description": "仅 pi 使用 null 表示默认模型"} + ] + }, + "modelList": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"$ref": "#/definitions/model"}}, + "modelTierAllowlist": { + "type": "object", + "minProperties": 1, + "additionalProperties": false, + "properties": { + "standard": {"$ref": "#/definitions/modelList"}, + "strong": {"$ref": "#/definitions/modelList"} + } + }, + "modelRoleAllowlist": { + "type": "object", + "minProperties": 1, + "additionalProperties": false, + "properties": { + "developer": {"$ref": "#/definitions/modelTierAllowlist"}, + "test": {"$ref": "#/definitions/modelTierAllowlist"} + } + }, + "modelAllowlist": { + "type": "object", + "additionalProperties": false, + "properties": { + "codex": {"$ref": "#/definitions/modelRoleAllowlist"}, + "cursor-agent": {"$ref": "#/definitions/modelRoleAllowlist"}, + "grok": {"$ref": "#/definitions/modelRoleAllowlist"}, + "omp": {"$ref": "#/definitions/modelRoleAllowlist"}, + "pi": {"$ref": "#/definitions/modelRoleAllowlist"} + } + }, + "workerProfile": { + "type": "object", + "required": ["role", "cli", "tier", "model", "reasoningEffort", "permissionMode"], + "additionalProperties": false, + "properties": { + "role": {"type": "string", "enum": ["developer", "test"]}, + "cli": {"type": "string", "enum": ["codex", "cursor-agent", "grok", "omp", "pi"]}, + "tier": {"type": "string", "enum": ["standard", "strong"]}, + "model": {"$ref": "#/definitions/model"}, + "reasoningEffort": {"type": ["string", "null"], "enum": ["low", "medium", "high", "xhigh", null]}, + "permissionMode": {"type": "string", "enum": ["read-only", "workspace-write"]} + } + }, + "defaults": { + "type": "object", + "additionalProperties": false, + "required": ["developer", "test"], + "properties": { + "developer": {"$ref": "#/definitions/profileId"}, + "test": {"$ref": "#/definitions/profileId"}, + "developerUpgraded": {"$ref": "#/definitions/profileId"} + } + } + } +} diff --git a/skills/ack/templates/agents.template.yaml b/skills/ack/templates/agents.template.yaml new file mode 100644 index 0000000..5a065dd --- /dev/null +++ b/skills/ack/templates/agents.template.yaml @@ -0,0 +1,39 @@ +# Optional project-local worker routing override. +# Save as .pouch/ack/agents.yaml when a temporary role/CLI/model selection is needed. +# This file intentionally has the same shape as tasks.yaml.project.orchestration. +profileVersion: 1 +mode: "orca" +modelAllowlist: + pi: + developer: + standard: [null] + strong: [null] + cursor-agent: + test: + standard: ["auto"] +profiles: + pi-dev-standard: + role: "developer" + cli: "pi" + tier: "standard" + model: null + reasoningEffort: "medium" + permissionMode: "workspace-write" + pi-dev-strong: + role: "developer" + cli: "pi" + tier: "strong" + model: null + reasoningEffort: "high" + permissionMode: "workspace-write" + cursor-test-standard: + role: "test" + cli: "cursor-agent" + tier: "standard" + model: "auto" + reasoningEffort: null + permissionMode: "workspace-write" +defaults: + developer: "pi-dev-standard" + test: "cursor-test-standard" + developerUpgraded: "pi-dev-strong" diff --git a/skills/ack/templates/project.template.md b/skills/ack/templates/project.template.md index 9d20da5..6906d41 100644 --- a/skills/ack/templates/project.template.md +++ b/skills/ack/templates/project.template.md @@ -9,7 +9,7 @@ > 不会自动修改 `AGENTS.md`、`CLAUDE.md` 或其它 Agent 指令文件。 > 无论叫什么,都在 `tasks.yaml` 的 `project.overlayFile` 记录实际路径。 > `.pouch/ack/` 只保存本项目的 `project.md`、`tasks.yaml`、`knowledge.yaml`、默认关闭的 -> `delivery.yaml` 与空的 `regression.yaml`, +> `delivery.yaml`、空的 `regression.yaml`,以及可选的 `agents.yaml` 角色启动覆盖, > 不复制或链接 Skill。 ## 项目概览 @@ -22,6 +22,7 @@ - 项目知识:`.pouch/ack/knowledge.yaml` - 交付契约:`.pouch/ack/delivery.yaml`(默认关闭) - 回归目录:`.pouch/ack/regression.yaml` +- 角色启动覆盖(可选):`.pouch/ack/agents.yaml` - 覆盖层文件:``(默认 `.pouch/ack/project.md`) ## 通用规范(由 ACK Skill 按需读取) @@ -39,8 +40,8 @@ ## Worker 路由 机器可校验的模型、reasoning effort、权限模式、默认 profile、允许 worktree 和启动 -receipt 全部以 `.pouch/ack/tasks.yaml` 的 `project.orchestration` 与顶层 -`workerReceipts` 为准。本文件不保存可执行 worker 命令。 +receipt 以 `.pouch/ack/agents.yaml`(存在时)或 `.pouch/ack/tasks.yaml` 的 +`project.orchestration` 与顶层 `workerReceipts` 为准。本文件不保存可执行 worker 命令。 默认 profile: diff --git a/skills/ack/templates/tasks.schema.json b/skills/ack/templates/tasks.schema.json index 2dbf1d4..56cc3fc 100644 --- a/skills/ack/templates/tasks.schema.json +++ b/skills/ack/templates/tasks.schema.json @@ -591,8 +591,16 @@ "minItems": 1, "uniqueItems": true, "items": { - "type": "string", - "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/+@-]{0,127}$" + "oneOf": [ + { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/+@-]{0,127}$" + }, + { + "type": "null", + "description": "仅 Pi 使用 null 表示其配置的默认模型" + } + ] } }, "modelTierAllowlist": { @@ -633,6 +641,12 @@ }, "grok": { "$ref": "#/definitions/modelRoleAllowlist" + }, + "omp": { + "$ref": "#/definitions/modelRoleAllowlist" + }, + "pi": { + "$ref": "#/definitions/modelRoleAllowlist" } } }, @@ -661,7 +675,8 @@ "codex", "cursor-agent", "grok", - "omp" + "omp", + "pi" ] }, "tier": { @@ -672,8 +687,16 @@ ] }, "model": { - "type": "string", - "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/+@-]{0,127}$" + "oneOf": [ + { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/+@-]{0,127}$" + }, + { + "type": "null", + "description": "仅 Pi 使用 null 表示其配置的默认模型" + } + ] }, "reasoningEffort": { "type": [ @@ -860,7 +883,8 @@ "codex", "cursor-agent", "grok", - "omp" + "omp", + "pi" ] }, "tier": { @@ -871,8 +895,16 @@ ] }, "model": { - "type": "string", - "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/+@-]{0,127}$" + "oneOf": [ + { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/+@-]{0,127}$" + }, + { + "type": "null", + "description": "仅 Pi 使用 null 表示其配置的默认模型" + } + ] }, "reasoningEffort": { "type": [ @@ -1018,6 +1050,13 @@ "boardHash": { "$ref": "#/definitions/sha256" }, + "routingSource": { + "type": "string", + "minLength": 1 + }, + "routingHash": { + "$ref": "#/definitions/sha256" + }, "slot": { "type": "integer", "minimum": 1, diff --git a/skills/ack/templates/tasks.template.yaml b/skills/ack/templates/tasks.template.yaml index a963801..c00d8c1 100644 --- a/skills/ack/templates/tasks.template.yaml +++ b/skills/ack/templates/tasks.template.yaml @@ -29,6 +29,7 @@ project: # attachments: "" # updatedAt: "" orchestration: + # 可选:临时角色路由写到 .pouch/ack/agents.yaml;存在时由 launcher 优先读取。 # worker 默认在 --project-root(权威状态目录)工作;不再配置 allowedWorktrees 白名单 # (v0.19 起废弃)。需要隔离 worktree 时由 Coordinator 在派发时显式指定。 profileVersion: 1 diff --git a/tests/test_ack_launch_worker.py b/tests/test_ack_launch_worker.py index f0edc58..85e1471 100644 --- a/tests/test_ack_launch_worker.py +++ b/tests/test_ack_launch_worker.py @@ -432,6 +432,80 @@ class EnvironmentAndExecutableTests(unittest.TestCase): class PlanTests(unittest.TestCase): + def test_agents_yaml_overrides_board_routing_and_binds_hash(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + project = Path(temporary).resolve() + ack_dir = project / ".pouch" / "ack" + ack_dir.mkdir(parents=True) + task_board = board(project) + agent_routing = { + "profileVersion": 1, + "mode": "orca", + "modelAllowlist": { + "pi": {"developer": {"standard": [None]}}, + "cursor-agent": {"test": {"standard": ["auto"]}}, + }, + "profiles": { + "pi-dev": { + "role": "developer", + "cli": "pi", + "tier": "standard", + "model": None, + "reasoningEffort": "medium", + "permissionMode": "workspace-write", + }, + "cursor-test": { + "role": "test", + "cli": "cursor-agent", + "tier": "standard", + "model": "auto", + "reasoningEffort": None, + "permissionMode": "workspace-write", + }, + }, + "defaults": {"developer": "pi-dev", "test": "cursor-test"}, + } + agents_path = ack_dir / "agents.yaml" + agents_path.write_text(json.dumps(agent_routing), encoding="utf-8") + (ack_dir / "tasks.yaml").write_text( + json.dumps(task_board), encoding="utf-8" + ) + executable = project / "pi" + executable.write_text("#!/bin/sh\n", encoding="utf-8") + executable.chmod(0o700) + + with ( + mock.patch.object( + launch_worker, + "capture_worktree_identity", + return_value=worktree_identity(project), + ), + mock.patch.object( + launch_worker, + "resolve_executable", + return_value=executable, + ), + mock.patch.object( + launch_worker, + "run_text", + return_value="pi 0.85.1", + ), + ): + plan = launch_worker.build_plan( + project_root_value=str(project), + task_id="TASK-001", + attempt_id="TASK-001-A1", + role="developer", + profile_id="pi-dev", + worktree_value=str(project), + slot=1, + ) + + self.assertEqual(plan["routingSource"], str(agents_path)) + self.assertEqual(plan["routingHash"], worker_profiles.canonical_sha256(agent_routing)) + self.assertEqual(plan["requested"]["model"], None) + self.assertEqual(plan["requested"]["argv"], [str(executable), "--thinking", "medium"]) + def test_authoritative_board_is_derived_from_project_root_without_repo_path(self) -> None: with tempfile.TemporaryDirectory() as temporary: project = Path(temporary).resolve() diff --git a/tests/test_ack_skill.py b/tests/test_ack_skill.py index 46d2ac0..619f352 100644 --- a/tests/test_ack_skill.py +++ b/tests/test_ack_skill.py @@ -28,6 +28,9 @@ class AckSkillContentTests(unittest.TestCase): "select_knowledge.py", "select_regression.py", "references/kickoff.md", + "agents.yaml", + "validate_agents.py", + "model: null", "不要修改项目的 `AGENTS.md`", "当前会话担任 Coordinator", "intents.testEnvironment", @@ -137,6 +140,10 @@ class AckSkillContentTests(unittest.TestCase): "scripts/run_verification.py", "scripts/worker_profiles.py", "scripts/launch_worker.py", + "scripts/validate_agents.py", + "templates/agents.schema.json", + "templates/agents.template.yaml", + "examples/agents.example.yaml", "templates/delivery.template.yaml", "templates/delivery.schema.json", "examples/delivery.example.yaml", @@ -144,7 +151,7 @@ class AckSkillContentTests(unittest.TestCase): ): self.assertTrue((ack_dir / relative_path).is_file(), relative_path) version = (ack_dir / "VERSION").read_text(encoding="utf-8").strip() - self.assertEqual(version, "0.19.0") + self.assertEqual(version, "0.20.0") self.assertIn( f'ackVersion: "{version}"', (ack_dir / "examples" / "tasks.example.yaml").read_text(encoding="utf-8"), diff --git a/tests/test_ack_worker_profiles.py b/tests/test_ack_worker_profiles.py index 1f6d6e1..b1d600d 100644 --- a/tests/test_ack_worker_profiles.py +++ b/tests/test_ack_worker_profiles.py @@ -224,6 +224,39 @@ class CanonicalHashTests(unittest.TestCase): class ProfileValidationTests(unittest.TestCase): + def test_pi_default_model_is_explicitly_supported(self) -> None: + routing = { + "profileVersion": 1, + "mode": "orca", + "modelAllowlist": { + "pi": { + "developer": {"standard": [None], "strong": [None]}, + }, + "cursor-agent": {"test": {"standard": ["auto"]}}, + }, + "profiles": { + "pi-dev": { + "role": "developer", + "cli": "pi", + "tier": "standard", + "model": None, + "reasoningEffort": "medium", + "permissionMode": "workspace-write", + }, + "cursor-test": { + "role": "test", + "cli": "cursor-agent", + "tier": "standard", + "model": "auto", + "reasoningEffort": None, + "permissionMode": "workspace-write", + }, + }, + "defaults": {"developer": "pi-dev", "test": "cursor-test"}, + } + + self.assertEqual(worker_profiles.validate_orchestration(routing), []) + def test_valid_orchestration_passes(self) -> None: self.assertEqual( worker_profiles.validate_orchestration(valid_orchestration()), @@ -406,6 +439,44 @@ class ProfileValidationTests(unittest.TestCase): class ArgvRendererTests(unittest.TestCase): + def test_pi_default_model_omits_model_flag(self) -> None: + profile = { + "role": "developer", + "cli": "pi", + "tier": "standard", + "model": None, + "reasoningEffort": "medium", + "permissionMode": "workspace-write", + } + + self.assertEqual( + worker_profiles.render_worker_argv( + profile, + "/home/ace/.local/share/mise/shims/pi", + "/repo/demo", + ), + [ + "/home/ace/.local/share/mise/shims/pi", + "--thinking", + "medium", + ], + ) + + def test_pi_read_only_limits_tools(self) -> None: + profile = { + "role": "test", + "cli": "pi", + "tier": "standard", + "model": None, + "reasoningEffort": "low", + "permissionMode": "read-only", + } + + self.assertEqual( + worker_profiles.render_worker_argv(profile, "/usr/local/bin/pi", "/repo/demo"), + ["/usr/local/bin/pi", "--thinking", "low", "--tools", "read,grep,find,ls"], + ) + def test_codex_exact_safe_argv(self) -> None: profile = valid_orchestration()["profiles"]["codex-dev-standard"]