feat(ack): support project agent routing overrides

This commit is contained in:
2026-09-19 17:52:14 +08:00
parent 44cee4be91
commit f469c8f5f5
25 changed files with 746 additions and 83 deletions
+14 -2
View File
@@ -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 猜测并启用交付或知识。
+29 -10
View File
@@ -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` 所在目录为 `<ack-skill-dir>`。优先
`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 <ack-skill-dir>/scripts/validate_delivery.py .pouch/ack/delivery.yaml \
--tasks .pouch/ack/tasks.yaml --project-root <project-root>
python3 <ack-skill-dir>/scripts/validate_regression.py .pouch/ack/regression.yaml \
--tasks .pouch/ack/tasks.yaml
# 仅在存在临时角色配置时运行
python3 <ack-skill-dir>/scripts/validate_agents.py .pouch/ack/agents.yaml
```
4. 检查上述文件是否仍有 `<...>` 占位符。结构校验通过且必填项目事实完整才称「完成」。除非用户明确要求,不提交、不推送。
4. 检查上述文件是否仍有 `<...>` 占位符;没有真实任务时使用 `tasks: []`。结构校验通过且必填项目事实完整才称「完成」。除非用户明确要求,不提交、不推送。
```text
## ack 初始化:完成 | 部分完成 | 阻塞
@@ -65,18 +76,26 @@ python3 <ack-skill-dir>/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`。配置本身不代表提交、推送、发布或部署授权。
## 飞书收件
+1 -1
View File
@@ -1 +1 @@
0.19.0
0.20.0
+37
View File
@@ -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"
+6 -5
View File
@@ -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 命令。
## 路径权限
+1 -1
View File
@@ -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"
+5 -1
View File
@@ -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`;项目状态从当前
+1 -1
View File
@@ -34,7 +34,7 @@ Coordinator 发现或读取 open 任务
-> 从 knowledge.yaml 按 scope 推荐 active 知识,Coordinator 确认固定 revision 的 knowledgeRefs
-> 为新轮次生成稳定 attemptId(<task-id>-A<round>),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)
@@ -103,6 +103,9 @@ pouch init ack --project <project-root>
- `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 <ack-skill-dir>/scripts/validate_delivery.py .pouch/ack/delivery.yaml \
--tasks .pouch/ack/tasks.yaml --project-root <project-root>
python3 <ack-skill-dir>/scripts/validate_regression.py .pouch/ack/regression.yaml \
--tasks .pouch/ack/tasks.yaml
# 存在临时角色配置时
python3 <ack-skill-dir>/scripts/validate_agents.py .pouch/ack/agents.yaml
```
同时确认:
@@ -164,6 +169,8 @@ python3 <ack-skill-dir>/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 只作审计,
不能单独授权复用旧终端;复用还需要空闲状态、身份匹配和可信历史清理证明。
+7 -6
View File
@@ -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 <ack-skill-dir>/scripts/validate_tasks.py .pouch/ack/tasks.yaml
python3 <ack-skill-dir>/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 <ack-skill-dir>/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` → 整轮完成后回收
+20 -11
View File
@@ -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
+2 -2
View File
@@ -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 构造。
先只读生成计划:
+126 -11
View File
@@ -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"],
+51
View File
@@ -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())
+35 -3
View File
@@ -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
@@ -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。"
)
+77 -16
View File
@@ -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 ["<root>: 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()
+82
View File
@@ -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"}
}
}
}
}
+39
View File
@@ -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"
+4 -3
View File
@@ -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`
- 覆盖层文件:`<overlay_file_path>`(默认 `.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:
+47 -8
View File
@@ -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,
+1
View File
@@ -29,6 +29,7 @@ project:
# attachments: "<field_id>"
# updatedAt: "<field_id>"
orchestration:
# 可选:临时角色路由写到 .pouch/ack/agents.yaml;存在时由 launcher 优先读取。
# worker 默认在 --project-root(权威状态目录)工作;不再配置 allowedWorktrees 白名单
# (v0.19 起废弃)。需要隔离 worktree 时由 Coordinator 在派发时显式指定。
profileVersion: 1
+74
View File
@@ -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()
+8 -1
View File
@@ -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"),
+71
View File
@@ -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"]