diff --git a/skills/ack/README.md b/skills/ack/README.md index c322765..a9cdd25 100644 --- a/skills/ack/README.md +++ b/skills/ack/README.md @@ -13,6 +13,11 @@ ACK 是一个显式调用的 Agent Skill,用三种独立角色运行工程协 构建 DEB 或镜像、发布产物、创建 PR,并在授权范围内部署。交付配置默认关闭, 稳定发布与生产部署始终保留人工批准点。 +显式调用 `$ack` 也可以直接处理一次性交付:ACK 根据请求把源码发布/发布 PR、DEB、 +Docker 镜像分别路由给 `manage-release`、`deb-publisher` 或 +`publish-docker-image`。实际操作由独立 Operator 执行;它使用与 Test 相同的低成本 +模型和 reasoning effort,但不复用 Test terminal,也不进入 Developer → Test 闭环。 + ## 安装 全局安装: @@ -137,6 +142,23 @@ PR、产物摘要、部署目标、健康检查和日志引用写入 `tasks.yaml profile 只能停在 `review_ready`;稳定发布或生产部署必须经过对应 approval 步骤。 具体契约见 `references/delivery.md`。 +## 一次性交付路由 + +直接发布时可以说: + +```text +$ack 为 v1.4.0 创建 release PR,合并后打 tag。 +$ack 把 1.4.0 的 amd64 DEB 发布到 testing 仓库。 +$ack 把当前提交发布成 registry.example.com/team/app:1.4.0。 +``` + +源码版本生命周期和一次性 PR/MR 路由到 `manage-release`,DEB/APT 路由到 +`deb-publisher`,Docker/OCI 镜像路由到 `publish-docker-image`。普通 PR/MR 只执行 +`manage-release` 的 `PR-only` 流程,不会被误当成完整版本发布;功能任务 verified 后 +按 profile 自动开 PR 时仍使用 ACK 原有的 `pull-request` 动作。一次性交付要求任务板配置 +`defaults.operator`;当前模板已经提供,并强制其 CLI、standard 模型和 reasoning +effort 与 Test default 相同。详见 `references/delivery-routing.md`。 + ## 启动 Worker worker 的机器配置位于 `tasks.yaml.project.orchestration`:项目显式维护模型 @@ -173,7 +195,8 @@ argv,忽略调用者 PATH、使用环境 allowlist,并验证真实 Git workt 通过仓库外的单次启动记录、terminal-bound nonce/proof 和受限 bootstrap 调用 Orca。 返回的 receipt 含 `runtimeId`、handle、incarnation、profile hash、slot 和 worktree identity。Coordinator 将 receipt 追加到顶层 `workerReceipts`,再把 receipt ID -写入任务的 `dispatch.developer` 或 `dispatch.test`,并同步写入本轮 `attemptId`。 +写入任务的 `dispatch.developer`、`dispatch.test` 或 `dispatch.operator`,并同步写入 +本轮 `attemptId`。 校验器要求 receipt 与当前 ACK task、角色、profile 和 attempt 完全一致;历史 receipt 不能跨任务或跨轮次改挂。 @@ -223,5 +246,7 @@ ACK 会自动读取 `delivery.yaml`,无需再逐步提醒它构建、上传、 当前 Skill 版本见 `VERSION`。新项目在 `tasks.yaml` 中以合法 SemVer 记录 `ackVersion`。从 `0.10.0` 起,`project.orchestration` 与顶层 `workerReceipts` 必须 同时存在;从 `0.11.0` 起,新项目还会生成默认关闭的 `delivery.yaml`,并在任务板声明 -`project.deliveryFile` 与 `deliveryRuns`。旧项目可以不迁移而继续使用原闭环。旧项目的 -`kitVersion` 可以继续读取,但建议迁移为 `ackVersion`。 +`project.deliveryFile` 与 `deliveryRuns`;从 `0.12.0` 起,新模板包含与 Test 同档模型的 +Operator,用于一次性交付路由。旧项目可以不迁移而继续使用原闭环,但补齐 Operator +前不能使用一次性交付。旧项目的 `kitVersion` 可以继续读取,但建议迁移为 +`ackVersion`。 diff --git a/skills/ack/SKILL.md b/skills/ack/SKILL.md index 15917ff..599a527 100644 --- a/skills/ack/SKILL.md +++ b/skills/ack/SKILL.md @@ -3,7 +3,8 @@ name: ack description: >- 初始化、检查并运行 ACK 三角色协作闭环。仅在用户显式调用 /ack 或 $ack,并要求 初始化 ACK、检查 docs/ack 配置、按 ACK 规划需求、指挥 Coordinator/Developer/Test - 工作,或配置并执行任务验证后的项目交付流程时使用。 + 工作、配置并执行任务验证后的项目交付流程,或把一次性 PR/发布请求路由给 + manage-release、deb-publisher、publish-docker-image 普通 worker 时使用。 --- # ACK 项目协作入口 @@ -22,6 +23,8 @@ description: >- - 用户要求检查 ACK 是否可用、配置是否完整:执行“检查”。 - 用户要求用 ACK 做需求、修复问题或继续任务:执行“工作”。 - 用户用自然语言要求增加、修改或关闭项目交付流程:执行“交付配置维护”。 +- 用户直接要求创建发布 PR/MR、发布源码版本、DEB 或 Docker 镜像:执行 + “一次性交付路由”。 始终先解析真实项目根目录。优先使用 `git rev-parse --show-toplevel`;不是 Git 项目时使用用户指定目录或当前目录。不要修改项目的 `AGENTS.md`、`CLAUDE.md` @@ -103,12 +106,43 @@ description: >- 6. 检查知识引用能解析到固定 revision,candidate 仍留在任务证据中,且 `stale`、`superseded` 和 `archived` 不会被当作可派发的 `active` 知识。 7. 若存在 `project.orchestration`,检查 profile、model allowlist、默认 profile、 - 允许 worktree、顶层 `workerReceipts` 与 `dispatch.developer/test` 的引用;receipt + 允许 worktree、顶层 `workerReceipts` 与 `dispatch.developer/test/operator` 的引用;receipt 必须绑定当前 ACK task、同一 role/profile/attempt,`receiptId` 与 `attemptId` 必须同时为空或同时填写。 缺少结构化路由的旧任务板只能使用手动模式,不能自动创建 worker。 + `defaults.operator` 对旧项目可选;存在时必须是 standard profile,且 CLI、模型与 + reasoning effort 必须和 `defaults.test` 相同。缺少它只表示一次性交付路由不可用, + 不影响原 Developer/Test 闭环。 8. 检查不会自动修复或覆盖现有配置;用户明确要求修复后再修改。 +## 一次性交付路由 + +1. 读取 `references/delivery-routing.md` 并按用户原始请求分类。源码版本、release/hotfix + 分支、普通或发布 PR/MR、tag 或 Forge Release 使用 `manage-release`;普通 PR/MR + 只授权它的 `PR-only` 流程,不推断版本升级、合并或 tag。DEB/APT 使用 + `deb-publisher`;Docker/OCI registry 使用 `publish-docker-image`。 +2. 若 `docs/ack/tasks.yaml` 不存在,或结构化 orchestration 没有通过校验的 + `defaults.operator`,停止并建议初始化或按当前模板升级。不要由 Coordinator 亲自 + 执行,也不要猜模型。用户请求有多个合理路由且无法从项目事实唯一确定时,只问一个 + 最小澄清问题。 +3. 在权威任务板新增 `type: delivery-operation` 的最小操作记录,`operation.skill` 保存 + 精确 Skill 名,`operation.request` 保留本次用户请求的授权语义;若原文含凭据值, + 必须替换为 `[REDACTED]`,不能落盘。`dispatch.operator` 使用 + `defaults.operator`。不为一次性交付写 PRD,也不进入 Developer → Test 闭环, + 不读取或修改 `delivery.yaml`。 +4. 运行 `validate_tasks.py`,再只用 `launch_worker.py plan|launch --role operator` + 创建 fresh worker。审阅并绑定 fingerprint/receipt 后,把 + `references/delivery-routing.md` 的 Operator prompt 和原始请求投递给 worker; + Orca 模式同时读取 `references/orca-adapter.md`,登记单一操作任务与 dispatch。 + Operator 使用与 Test 相同的低成本模型/effort,但它是独立角色和独立终端,不复用 + Test worker。 +5. 被选中的低层 Skill 决定实际步骤、确认点、硬停止与恢复。ACK 路由和项目配置都不能 + 扩大用户授权;Docker 路由只因用户显式调用 `$ack` 且明确要求发布镜像才视为对 + `publish-docker-image` 的显式委派。 +6. Coordinator 只读 worker 证据并对照低层 Skill 完成标准终检。满足原始请求才把操作 + 标为 `verified`;可恢复的安全停止标为 `blocked`。远端写入部分成功或状态不确定时 + 不自动重试,先按低层 Skill 发现真实状态。 + ## 工作 1. 若 `docs/ack` 不存在,停止并建议先用 `/ack` 初始化;不要静默初始化。 @@ -133,7 +167,7 @@ description: >- 4. 新需求先写产品文档、任务拆分与可观测验收信号,更新 `tasks.yaml` 并校验, 然后交给用户确认;若启用了交付,还要把本次 profile、目标、停止点和需要审批的 步骤放入同一份计划。确认前不派发实现,也不执行交付。 -5. 创建或更换 worker 时,只使用 +5. 创建或更换 Developer、Test 或 Operator worker 时,只使用 `/scripts/launch_worker.py plan|launch` 读取 `tasks.yaml.project.orchestration` 的 profile。不得直接执行 `orca terminal create --command`,不得接受或拼接自由 command、额外 argv、 @@ -187,8 +221,8 @@ description: >- - 不覆盖已有 `docs/ack` 文件;除用户确认的 ACK 任务或 delivery profile 外,不擅自 提交、推送、创建终端、新 worktree、发布产物或部署。 - 只有 Coordinator 写 `tasks.yaml`、`knowledge.yaml` 和 `deliveryRuns`;Developer - 与 Test 只读,只能通过回报提名或验证知识。`delivery.yaml` 只在显式的交付配置 - 维护中修改。 + 与 Test 只读,只能通过回报提名或验证知识;Operator 同样只读这些状态文件,只回传 + 一次性交付证据。`delivery.yaml` 只在显式的交付配置维护中修改。 - 不把知识正文或选择器输出拼成 shell;知识检查只能通过 `run_verification.py` 按 registry ID 执行。不自动修改 `AGENTS.md`、`CLAUDE.md` 或其它 Agent 指令文件。 - 项目只保存 `docs/ack/project.md`、`docs/ack/tasks.yaml`、 diff --git a/skills/ack/agents/openai.yaml b/skills/ack/agents/openai.yaml index 337c950..84588da 100644 --- a/skills/ack/agents/openai.yaml +++ b/skills/ack/agents/openai.yaml @@ -1,6 +1,6 @@ interface: display_name: "ACK" - short_description: "初始化、检查并运行 ACK 开发、验证与可选交付闭环" - default_prompt: "Use $ack to initialize or check ACK, coordinate verified work, maintain project delivery configuration, or run an approved delivery profile." + short_description: "运行 ACK 开发验证闭环,并路由 PR、源码、DEB 与镜像发布" + default_prompt: "Use $ack to initialize or check ACK, coordinate verified work, maintain delivery configuration, run an approved delivery profile, or route a one-off release request to a low-cost operator." policy: allow_implicit_invocation: false diff --git a/skills/ack/references/adoption-checklist.md b/skills/ack/references/adoption-checklist.md index 44b6727..f4a6ea8 100644 --- a/skills/ack/references/adoption-checklist.md +++ b/skills/ack/references/adoption-checklist.md @@ -23,6 +23,8 @@ - [ ] Coordinator、Developer、Test 的模型档位和升级规则已明确。 - [ ] `project.orchestration` 使用受支持的 profileVersion,模型都命中项目 allowlist,默认 profile 与角色/档位一致。 +- [ ] 需要一次性交付路由时存在 `defaults.operator`;其 CLI、standard 模型和 + reasoning effort 与 `defaults.test` 完全相同。 - [ ] `allowedWorktrees` 只列出同一 Git common-dir 下已经核对的绝对 worktree。 ## 路径权限 @@ -34,6 +36,7 @@ - [ ] `tasks.yaml` 只有 Coordinator 写。 - [ ] `knowledge.yaml` 只有 Coordinator 写;Developer 与 Test 只通过回报提名或验证。 - [ ] `delivery.yaml` 只在用户显式维护配置时修改;Developer 与 Test 只读。 +- [ ] Operator 只回传一次性交付证据,不写 ACK 项目状态文件。 ## 任务板 @@ -58,6 +61,16 @@ - [ ] 每次运行固定 commit/config revision,证据写入 `tasks.yaml.deliveryRuns`; 失败不会把已验证任务回退为失败。 +## 可选一次性交付路由 + +- [ ] PR/MR 与源码发布、DEB、Docker 镜像分别路由到 `manage-release`、 + `deb-publisher`、`publish-docker-image`;普通 PR/MR 只使用 `PR-only`,没有扩大 + 到版本升级、合并、tag 或 Forge Release。 +- [ ] `delivery-operation.operation.request` 保留用户原始请求,没有扩大授权。 +- [ ] Operator 使用 `launch_worker.py --role operator` 创建的 fresh worker,没有复用 + Test terminal,也没有让 Coordinator 代跑。 +- [ ] 部分成功或外部状态不确定时按低层 Skill 恢复,没有盲目自动重试。 + ## 项目知识 - [ ] 新项目没有已验证知识时使用 `verificationRegistry: {}` 与 `entries: []`, @@ -83,13 +96,15 @@ `orca terminal create --command` 或自由 worker 命令。 - [ ] `launch` 使用刚审阅的 `plan.launchFingerprint` 作为 `--expected-launch-fingerprint`;漂移时重新 plan。 -- [ ] Developer/Test 只使用 `read-only` 或 `workspace-write` profile; +- [ ] Developer/Test/Operator 只使用 `read-only` 或 `workspace-write` profile; v0.10 没有 full-access、bypass、YOLO/force 或关闭 sandbox。 - [ ] v0.10 每次自动派发都启动 fresh worker;没有把历史 receipt checksum 或 Orca live metadata 当作自动复用授权。 - [ ] launcher receipt 已追加到顶层 `workerReceipts`,任务分别引用 `dispatch.developer` 与 `dispatch.test`;每个引用的 task/role/profile/attempt 都与当前任务、本轮 receipt 完全一致,没有用单一 handle 混淆两个角色。 +- [ ] 一次性交付 receipt 引用 `dispatch.operator`,与当前操作、profile 和 attempt + 完全一致。 - [ ] 多 worktree 场景只有一个权威 `tasks.yaml` 和 `knowledge.yaml`。 - [ ] Test 使用的服务来自正确 worktree。 diff --git a/skills/ack/references/delivery-routing.md b/skills/ack/references/delivery-routing.md new file mode 100644 index 0000000..7c562d1 --- /dev/null +++ b/skills/ack/references/delivery-routing.md @@ -0,0 +1,108 @@ +# ACK 一次性交付路由 + +本文件定义用户显式调用 `$ack` 后,直接要求创建发布相关 PR/MR、发布源码版本、 +上传 DEB 或发布 Docker 镜像时的路由。它不要求先跑 Developer → Test 闭环,也不读取 +或修改 `delivery.yaml`;项目已有的“verified 后按 profile 交付”仍按 `delivery.md` +执行。 + +## 1. 只按用户目标选择能力 + +| 用户目标 | 路由 | 不得顺带执行 | +| --- | --- | --- | +| 创建普通或发布 PR/MR,以及版本号、release/hotfix 分支、合并、正式 tag、Forge Release、恢复中断的源码发布 | `manage-release` | 未请求的版本升级、合并、tag、Forge Release、清理、普通产物上传 | +| 构建、检查、上传 `.deb`,发布到 APT/DEB 仓库 | `deb-publisher` | tag、Forge Release、Docker 镜像 | +| 构建并推送 Docker/OCI 镜像到 registry | `publish-docker-image` | 源码版本、DEB、额外 tag;用户说的 `docker-publisher` 视为这个已安装 Skill 的别名 | + +“创建 PR/MR”路由到 `manage-release`,但必须区分权限上限:发布版本、release/hotfix +分支或版本升级对应的 PR/MR 走发布流程;普通 PR/MR 只走它的 ACK `PR-only` 入口, +不得推断版本升级、release 分支、合并、tag 或 Forge Release。ACK 功能任务在 verified +后按 profile 自动开 PR 时,仍使用 `delivery.md` 的 `pull-request` 动作,不重复建立 +一次性操作。 + +只说“发布一下”时先从用户文字和项目发布入口判断目标。DEB、镜像和源码发布仍有两个 +以上合理候选时,先问一个最小澄清问题;不要根据最近文件或历史命令猜。用户明确要求 +多个产物时,为每个能力建立独立操作,按用户给出的顺序执行,不能由一个 Skill 暗中 +扩大到另一个 Skill。 + +## 2. 用低成本 Operator 派发 + +一次性交付由 `operator` worker 执行,Coordinator 不亲自运行低层 Skill。机器事实仍 +只读 `docs/ack/tasks.yaml.project.orchestration`: + +- `defaults.operator` 必须指向 `role: operator`、`tier: standard` 的 profile。 +- Operator 默认 profile 的 `cli`、`tier`、`model` 和 `reasoningEffort` 必须与 + `defaults.test` 完全相同;校验器会拒绝漂移。`permissionMode` 仍按项目需要显式写 + `read-only` 或 `workspace-write`,发布通常需要后者。 +- 旧项目没有 `defaults.operator` 时,普通 ACK 闭环仍可运行,但一次性交付路由必须 + fail closed。先按当前模板补 operator allowlist/profile/default 并校验,不能临时猜 + 模型或让 Coordinator 代跑。 +- launcher 只给 Operator 固定的发布凭据 allowlist:`DEB_SERVER_URL`、`DEB_TOKEN`、 + `DEB_REPOSITORY`、`DEB_UPLOAD_PATH` 和 `SSH_AUTH_SOCK`;Docker/Forge 使用 HOME + 中自己的凭据存储。值不写入任务板或 receipt。缺少凭据时由低层 Skill 标记 blocked, + 不通过 prompt、自由 env 或命令行注入秘密。 + +路由后在权威任务板新增一条最小记录: + +```yaml +- id: "DELIVERY-001" + type: "delivery-operation" + title: "publish one DEB" + status: "open" + operation: + skill: "deb-publisher" + request: "<保留用户本次请求的授权语义;凭据值必须替换为 [REDACTED]>" + dispatch: + operator: + profileId: "" + receiptId: null + attemptId: null + taskId: null + dispatchId: null + rounds: [] +``` + +运行 `validate_tasks.py` 后,用 `launch_worker.py plan|launch` 创建 fresh worker,参数 +使用 `--role operator`、本操作 ID 和 `-A1`。审阅 fingerprint,成功后 +把 receipt 写入 `workerReceipts` 并与 `dispatch.operator` 精确绑定,再投递下节的 +prompt。Orca 模式按 `orca-adapter.md` 登记单一操作任务,并把 runtime task/dispatch +ID 写入 `dispatch.operator`;不创建 Developer/Test 子任务链。不得复用 Test terminal; +“同模型档位”不等于“同角色或同 worker”。 + +## 3. Operator 派发 Prompt + +```text +你是 ACK 一次性交付 Operator。请在 执行以下原始请求: + + +ACK 已选择且只授权你加载:$ +先完整读取该 Skill 及它要求的 references,再读取可信 base 上的项目发布规则。 + +边界: +- 原始请求是授权上限;除已脱敏的凭据值外保持原意,不得因 ACK 路由、项目配置或 + 历史操作扩大远端写权限。 +- 严格遵守低层 Skill 的确认点、硬停止条件、恢复流程和完成标准。 +- 不修改 docs/ack/tasks.yaml、knowledge.yaml 或 delivery.yaml。 +- 不把 token、密码、私钥路径或认证配置写入消息、文件、提交或日志。 +- 发生部分成功或外部状态不确定时停止自动重试,先按低层 Skill 核对真实状态。 + +完成或阻塞后只回传:所选 Skill、实际执行到的阶段、准确目标、非敏感证据、 +未完成项和安全恢复动作。不要把命令已运行等同于发布已验证。 +``` + +## 4. 状态与完成 + +Coordinator 在派发后把操作标为 `dispatched`,只读 Operator 的结构化证据做终检: + +- `manage-release` 必须按其完成标准证明 PR/MR、merged commit、远端 tag 或 Forge + Release 中用户实际要求的最远阶段。 +- `deb-publisher` 必须分别证明包元数据/摘要、上传接受和仓库可见性。 +- `publish-docker-image` 必须证明完整镜像引用、platform、源 commit 和远端 digest; + registry 无法查询时明确限制。 + +证据满足原始请求才把操作标为 `verified`;安全停止但可恢复时标为 `blocked` 并记录 +恢复动作。`worker_done` 不等于完成。涉及远端写入的失败不自动进入第二轮;先由对应 +低层 Skill 执行真实状态发现和恢复,再由用户决定是否继续。 + +Operator receipt 和操作证据写在该任务中,不写入 `deliveryRuns`。一次性交付不会因 +项目其它任务未 `verified` 而被拦截,也不会触发 `delivery.yaml` 的 profile;两种流程 +必须保持独立,避免同一请求被重复发布。 diff --git a/skills/ack/references/delivery.md b/skills/ack/references/delivery.md index cf13f3c..ad72016 100644 --- a/skills/ack/references/delivery.md +++ b/skills/ack/references/delivery.md @@ -121,3 +121,7 @@ ACK 只负责读取项目交付契约、编排顺序、守住审批点并汇总 `manage-release` 仍是可独立使用、独立安装的能力;缺失时 ACK 使用契约中已审查的 项目 entrypoint,二者都不可用时把对应步骤标为 `blocked`。低层 skill 自身要求显式 调用时,ACK 不能绕过它的触发与授权边界。 + +用户显式调用 `$ack` 直接要求创建发布 PR/MR、发布源码版本、DEB 或镜像时,走 +`delivery-routing.md` 的一次性交付流程,不要求其它 ACK 任务先 `verified`,也不创建 +本文件定义的 `deliveryRuns`。两种流程不能在同一请求中隐式重复执行。 diff --git a/skills/ack/references/init-new-project.md b/skills/ack/references/init-new-project.md index 8ae9b0a..29b904b 100644 --- a/skills/ack/references/init-new-project.md +++ b/skills/ack/references/init-new-project.md @@ -82,6 +82,9 @@ docs/ack/ `project.orchestration` 与顶层 `workerReceipts` 必须同时存在。 - 从 `0.11.0` 起的新项目初始化包含默认关闭的交付契约;旧项目不要求为了版本号升级 自动补交付配置。 +- 从 `0.12.0` 起的新项目 orchestration 模板包含可选一次性交付所需的 Operator;其 + 默认 CLI、standard 模型和 reasoning effort 与 Test default 相同。旧项目不补也能 + 运行原闭环,但不能使用一次性交付路由。 - `updatedAt` 使用当前带时区时间。 - `project.name`、`repoPath`、`devWorktree`、`overlayFile` 和 `knowledgeFile` 使用 真实值。 @@ -141,7 +144,8 @@ python3 /scripts/validate_delivery.py docs/ack/delivery.yaml \ - Developer 与 Test 的验证命令可执行。 - `project.orchestration` 的 profile/allowlist/defaults 通过校验,自动模式只允许 `read-only` 或 `workspace-write`;旧任务板未迁移时保持手动模式。 -- 顶层 `workerReceipts` 和 `dispatch.developer/test` 的 task/role/profile/attempt +- 若配置 Operator,其 default 与 Test 使用相同 CLI、standard 模型和 effort。 +- 顶层 `workerReceipts` 和 `dispatch.developer/test/operator` 的 task/role/profile/attempt 引用一致;`receiptId` 与 `attemptId` 同时为空或同时填写。持久 receipt 只作审计, v0.10 不自动复用旧终端。 - 网站或 API 项目写清服务启动、重启和 Base URL。 diff --git a/skills/ack/references/model-routing.md b/skills/ack/references/model-routing.md index 9c2bc0b..4875c22 100644 --- a/skills/ack/references/model-routing.md +++ b/skills/ack/references/model-routing.md @@ -1,6 +1,6 @@ # 模型路由(稳定核心) -本文件是**三角色默认模型档位、升级规则和 worker profile 选择规则**的单一规范源。 +本文件是**三角色与可选 Operator 的默认模型档位、升级规则和 worker profile 选择规则**的单一规范源。 目标是在不牺牲质量的前提下降低 token 和模型成本:把昂贵的强模型留给需要判断的 工作,把机械执行交给较弱模型。 @@ -16,6 +16,7 @@ | Coordinator (PM) | 强模型 | 需求拆解、验收信号设计、优先级、终检对齐意图、三轮失败复盘都需要高质量推理 | | Test | 中低模型 | 按既定验收信号执行浏览器/API/脚本,主要做观察、记录、逐条 pass/fail | | Developer | 中低模型(按任务升级) | 多数实现可照规格执行;跨系统、数据迁移、重复失败时再升级 | +| Operator(一次性交付) | 与 Test 相同的中低模型 | 按低层发布 Skill 执行机械步骤并回传证据,不承担范围决策 | Coordinator 用强模型但**不亲自跑测试**(测试由 Test 承担),所以强模型的 token 花在思考和终检上,而不是反复点击页面、跑 smoke、复制日志。这一分工天然省 token, @@ -36,6 +37,7 @@ Coordinator 用强模型但**不亲自跑测试**(测试由 Test 承担), - Test:跑浏览器用例、API smoke、逐条比对期望与实际、产出证据。 - Developer:从清晰规格实现范围明确的任务、跑构建与单测、回报 worker_done。 +- Operator:执行 `delivery-routing.md` 已选定的发布 Skill,按其停止点记录证据。 --- @@ -73,7 +75,7 @@ worker 路由的机器可读事实只保存在 `docs/ack/tasks.yaml` 的 每个 profile 明确声明: -- `role`:`developer` 或 `test`; +- `role`:`developer`、`test` 或 `operator`; - `cli`:受支持的 Agent CLI; - `tier`:角色模型档位; - `model`:项目 allowlist 中的精确模型 ID; @@ -83,6 +85,9 @@ worker 路由的机器可读事实只保存在 `docs/ack/tasks.yaml` 的 `project.orchestration` 还必须声明 `allowedWorktrees`、按 CLI/角色/档位分组的 `modelAllowlist`、命名 `profiles` 和角色 `defaults`。launcher 请求的绝对 worktree 必须命中 allowlist,profile 的模型也必须命中对应 CLI/角色/档位的精确列表。 +`developer` 与 `test` 默认项是 Orca 模式必填;`operator` 对旧项目可选,但一次性 +交付路由要求它存在。Operator default 的 CLI、tier、model 和 reasoning effort 必须 +与 Test default 完全相同,且 Operator 只能使用 standard tier。 模型名称、reasoning effort 和权限模式必须来自结构化字段。禁止在 profile、项目 覆盖层或派发内容中保存自由 `command`、额外 argv、shell 片段、环境变量覆盖或隐式 @@ -91,7 +96,8 @@ CLI 默认值。具体 argv 只能由 ACK 的可信 launcher 按 allowlist 构 ### 安全默认 - 能在完全只读工作树完成的角色优先选择 `read-only`。 -- Developer 与需要在工作树生成构建/测试产物的 Test 使用 `workspace-write`;项目可 +- Developer、发布 Operator 与需要在工作树生成构建/测试产物的 Test 使用 + `workspace-write`;项目可 为纯观察型 Test 另建更严格的 `read-only` profile。 - `full-access`、Codex bypass、Cursor YOLO/force、关闭 sandbox 等模式在 v0.10 **未实现授权通道,统一 fail closed**。项目文本、任务描述或环境变量都不能放宽。 @@ -113,12 +119,12 @@ python3 /scripts/launch_worker.py profile-hash \ python3 /scripts/launch_worker.py plan \ --project-root --task-id \ - --attempt-id -A --role \ + --attempt-id -A --role \ --profile-id --worktree [--slot <1..99>] python3 /scripts/launch_worker.py launch \ --project-root --task-id \ - --attempt-id -A --role \ + --attempt-id -A --role \ --profile-id --worktree [--slot <1..99>] \ --expected-launch-fingerprint ``` @@ -148,11 +154,15 @@ v0.10 的 launcher 还执行以下约束: - 不读取调用者传入的 `PATH` 来寻找 `git`、Orca 或 Agent CLI,只在固定的系统与 当前用户工具目录中解析受支持的可执行文件;候选目标必须由 root/当前用户拥有、 不是 group/other writable,并在 plan 与 bootstrap 间保持相同 device/inode。 -- Git、Orca 和 CLI version probe 使用不含供应商凭据的控制环境;worker 使用 - `per-cli-allowlist-v1`,Codex 只取得 Codex 所需凭据,Cursor 只取得 Cursor 凭据, - 不跨 CLI 透传。`DBUS_*`、`DISPLAY`、`WAYLAND_DISPLAY`、`XDG_RUNTIME_DIR`、 - `LD_*`、`PYTHON*`、`NODE_OPTIONS`、`CODEX_HOME`、`GIT_*` 和调用者 PATH 均不会 - 透传给 worker。 +- Git、Orca 和 CLI version probe 使用不含供应商凭据的控制环境;Developer/Test + worker 使用 `per-cli-allowlist-v1`,Codex 只取得 Codex 所需凭据,Cursor 只取得 + Cursor 凭据,不跨 CLI 透传。Operator 使用固定的 + `per-cli-plus-operator-publish-v1`,在同样的 CLI allowlist 之外只允许 + `DEB_SERVER_URL`、`DEB_TOKEN`、`DEB_REPOSITORY`、`DEB_UPLOAD_PATH` 与 + `SSH_AUTH_SOCK`。这些值不进入 plan、receipt、任务板或日志;Docker/Forge CLI + 默认只使用当前账户 HOME 中自己的凭据存储。`DBUS_*`、`DISPLAY`、 + `WAYLAND_DISPLAY`、`XDG_RUNTIME_DIR`、`LD_*`、`PYTHON*`、`NODE_OPTIONS`、 + `CODEX_HOME`、`GIT_*`、任意 Docker 密码变量和调用者 PATH 均不会透传给 worker。 - worktree 除了命中项目 allowlist、属于同一 Git common-dir,还必须精确出现在 `git worktree list --porcelain -z` 中;`.git` symlink 冒充的目录会失败。 - Orca 的 `--command` 只含固定 bootstrap 与随机 launch ID。终端创建后,父进程先 @@ -232,7 +242,7 @@ worker 自报或单独的 Orca live metadata 都不能把旧终端提升为可 强模型产出高密度、可复用的产物:需求、架构决策、验收信号、任务拆分、失败复盘。 中低模型消费这些产物,产出可核对的执行证据:测试结果、快照、API 响应、构建日志、 -改动文件清单。 +改动文件清单和发布目标摘要。 这样把昂贵推理挡在重复执行之外。 @@ -240,6 +250,7 @@ worker 自报或单独的 Orca live metadata 都不能把旧终端提升为可 ## 一句话 -Coordinator 是脑,Test 是眼,Developer 是手。脑用强模型且不做机械测试,眼和手 -默认用安全、较低成本的结构化 profile;只有常规闭环卡住时才升级,并且所有 worker -都必须经 launcher 产生可核对的 receipt。 +Coordinator 是脑,Test 是眼,Developer 是手;一次性交付 Operator 是按已选 Skill +操作发布系统的手。Coordinator 用强模型做判断,其余角色默认用安全、较低成本的结构化 +profile;只有常规闭环卡住时才升级,并且所有 worker 都必须经 launcher 产生可核对的 +receipt。 diff --git a/skills/ack/references/orca-adapter.md b/skills/ack/references/orca-adapter.md index 9506da8..1de3c3c 100644 --- a/skills/ack/references/orca-adapter.md +++ b/skills/ack/references/orca-adapter.md @@ -24,7 +24,8 @@ orca orchestration inbox --limit 20 --json ``` 确认:Orca runtime 可达并取得本次 `runtimeId`;Coordinator 终端存在;当前没有冲突 -的活跃编排任务。Developer/Test worker 必须通过本次 launcher 新建,不要求预先存在。 +的活跃编排任务。Developer/Test worker 与一次性交付 Operator 都必须通过本次 launcher +新建,不要求预先存在。 --- @@ -59,7 +60,7 @@ dispatch。未来若 Orca/ACP 增加启动参数 attestation,或 ACK 接入项 ```bash python3 /scripts/launch_worker.py plan \ --project-root --task-id \ - --attempt-id -A --role \ + --attempt-id -A --role \ --profile-id --worktree --slot <1..99> ``` @@ -69,7 +70,7 @@ python3 /scripts/launch_worker.py plan \ ```bash python3 /scripts/launch_worker.py launch \ --project-root --task-id \ - --attempt-id -A --role \ + --attempt-id -A --role \ --profile-id --worktree --slot <1..99> \ --expected-launch-fingerprint ``` @@ -101,6 +102,11 @@ launcher fail closed,不能改用手写命令兜底。模型档位与升级规 该路径上走 `plan` -> 带 expected fingerprint 的 `launch`,并把 receipt 留作审计。 既有会话可以由用户直接操作,但不能进入 ACK v0.10 的自动派发信任路径。 +一次性交付不创建 Developer/Test 子任务链。按 `delivery-routing.md` 建立一个 +`delivery-operation`,使用 fresh Operator receipt 中的 handle 创建/登记单一 Orca +任务并投递 Operator prompt;把返回的 runtime task/dispatch ID 写入 +`dispatch.operator`。低层 Skill 的完成或 blocked 回报仍由 Coordinator 读证据后落盘。 + --- ## 创建父任务 diff --git a/skills/ack/references/roles-and-permissions.md b/skills/ack/references/roles-and-permissions.md index d850b3c..ba22e64 100644 --- a/skills/ack/references/roles-and-permissions.md +++ b/skills/ack/references/roles-and-permissions.md @@ -21,6 +21,11 @@ ACK 默认三个独立 Agent:**Coordinator 只编排、Test 只验证、Develo **模型档位(正交层)。** 三角色默认按成本分层:Coordinator 用强模型,Test 与 Developer 用中低模型,必要时升级。完整档位表与升级规则见 `model-routing.md`。Coordinator 用强模型但不跑测试,这一分工天然省 token 又不破坏「验证者 ≠ 实现者」。 +**一次性交付 Operator 不进入三角色开发闭环。** 用户显式调用 `$ack` 直接要求发布时, +Coordinator 按 `delivery-routing.md` 选择低层 Skill,并把原始请求交给独立 Operator。 +Operator 与 Test 使用相同低成本模型/effort,但不承担独立复测;它只执行被选 Skill、 +遵守其授权边界并回传发布证据,且不得写 ACK 状态文件。 + --- ## 三角色能力清单(SSOT) @@ -146,6 +151,11 @@ failed_retest(累计 3 轮) -> leftover 三轮失败的处理细则见 `optimization-method.md` §「三轮失败策略」。 +`type: delivery-operation` 使用独立的短状态流:`open -> dispatched -> verified | blocked`。 +这里的 `verified` 只表示 Coordinator 已按被路由 Skill 的完成标准核对一次性交付证据, +不表示代码经过 Test 复测,也不能作为普通功能任务的验证证据。远端部分成功不会自动 +重派;恢复规则见 `delivery-routing.md`。 + ## 交付状态(与任务状态正交) 任务进入 `verified` 后不再改写为发布或部署状态。可选交付的每次执行单独记录在 diff --git a/skills/ack/scripts/launch_worker.py b/skills/ack/scripts/launch_worker.py index 467d7be..1a128ee 100755 --- a/skills/ack/scripts/launch_worker.py +++ b/skills/ack/scripts/launch_worker.py @@ -41,6 +41,7 @@ from validate_tasks import load_document, validate_builtin # noqa: E402 from worker_profiles import ( # noqa: E402 LAUNCH_PROTOCOL_VERSION, canonical_sha256, + environment_policy_for_role, profile_hash, render_worker_argv, validate_routing_document, @@ -48,7 +49,6 @@ from worker_profiles import ( # noqa: E402 PROTOCOL_VERSION = LAUNCH_PROTOCOL_VERSION RECEIPT_VERSION = 1 -ENVIRONMENT_POLICY = "per-cli-allowlist-v1" TASKS_RELATIVE_PATH = Path("docs/ack/tasks.yaml") MAX_CONTROL_OUTPUT = 1024 * 1024 MAX_RECORD_SIZE = 256 * 1024 @@ -82,6 +82,15 @@ WORKER_CREDENTIAL_NAMES = { "codex": frozenset({"AZURE_OPENAI_API_KEY", "OPENAI_API_KEY"}), "cursor-agent": frozenset({"CURSOR_API_KEY"}), } +OPERATOR_CREDENTIAL_NAMES = frozenset( + { + "DEB_REPOSITORY", + "DEB_SERVER_URL", + "DEB_TOKEN", + "DEB_UPLOAD_PATH", + "SSH_AUTH_SOCK", + } +) INHERITED_ENVIRONMENT_PREFIXES = ( "LC_", ) @@ -184,13 +193,23 @@ def control_environment() -> dict[str, str]: return _sanitized_environment(CONTROL_ENVIRONMENT_NAMES) -def worker_environment(cli: str) -> dict[str, str]: +def worker_environment(cli: str, role: str | None = None) -> dict[str, str]: """Return only the supported CLI's own credentials and common runtime data.""" credential_names = WORKER_CREDENTIAL_NAMES.get(cli) if credential_names is None: raise LaunchError(f"不支持的 worker CLI 环境: {cli}") - return _sanitized_environment(WORKER_ENVIRONMENT_NAMES | credential_names) + if role is not None: + try: + environment_policy_for_role(role) + except ValueError as exc: + raise LaunchError(f"不支持的 worker role 环境: {role}") from exc + role_credentials = ( + OPERATOR_CREDENTIAL_NAMES if role == "operator" else frozenset() + ) + return _sanitized_environment( + WORKER_ENVIRONMENT_NAMES | credential_names | role_credentials + ) def reject_duplicate_or_separator_args(argv: list[str]) -> None: @@ -633,15 +652,20 @@ def build_plan( raise LaunchError("task-id 只允许字母、数字、点、下划线和连字符") if attempt_id not in {f"{task_id}-A1", f"{task_id}-A2", f"{task_id}-A3"}: raise LaunchError("attempt-id 必须精确为 -A1..A3") - if role not in {"developer", "test"}: - raise LaunchError("role 必须是 developer 或 test") + if role not in {"developer", "test", "operator"}: + raise LaunchError("role 必须是 developer、test 或 operator") if not PROFILE_ID_RE.fullmatch(profile_id): raise LaunchError("profile-id 格式非法") if not isinstance(slot, int) or isinstance(slot, bool) or not 1 <= slot <= 99: raise LaunchError("slot 必须是 1..99 的整数") project_root, board = load_authoritative_board(project_root_value) - find_task(board, task_id) + task = find_task(board, task_id) + is_delivery_operation = task.get("type") == "delivery-operation" + if role == "operator" and not is_delivery_operation: + raise LaunchError("operator 只能用于 delivery-operation 任务") + if role != "operator" and is_delivery_operation: + raise LaunchError("delivery-operation 任务只能由 operator 执行") project = board["project"] orchestration = project.get("orchestration") if not isinstance(orchestration, dict): @@ -681,7 +705,7 @@ def build_plan( "cliVersion": cli_version, "argv": argv, "argvHash": canonical_sha256(argv), - "environmentPolicy": ENVIRONMENT_POLICY, + "environmentPolicy": environment_policy_for_role(role), } current_profile_hash = profile_hash( profile, @@ -705,7 +729,11 @@ def build_plan( } ) cli_label = "CODEX" if profile["cli"] == "codex" else "CURSOR" - role_label = "DEV" if role == "developer" else "TEST" + role_label = { + "developer": "DEV", + "test": "TEST", + "operator": "OP", + }[role] digest_short = launch_fingerprint.split(":", 1)[-1][:10] title = ( f"ACK-{role_label}-{cli_label}-{str(profile['tier']).upper()}-" @@ -1453,7 +1481,10 @@ def bootstrap_worker(launch_id: str) -> int: rebuilt["requested"]["argv"], shell=False, cwd=rebuilt["worktree"]["path"], - env=worker_environment(str(rebuilt["requested"]["cli"])), + env=worker_environment( + str(rebuilt["requested"]["cli"]), + str(rebuilt["role"]), + ), ) current_record.update( state="bootstrap-ready", @@ -1488,7 +1519,11 @@ def add_launch_arguments(parser: argparse.ArgumentParser) -> None: parser.add_argument("--project-root", required=True) parser.add_argument("--task-id", required=True) parser.add_argument("--attempt-id", required=True) - parser.add_argument("--role", required=True, choices=("developer", "test")) + parser.add_argument( + "--role", + required=True, + choices=("developer", "test", "operator"), + ) parser.add_argument("--profile-id", required=True) parser.add_argument("--worktree", required=True) parser.add_argument("--slot", type=int, default=1) diff --git a/skills/ack/scripts/validate_tasks.py b/skills/ack/scripts/validate_tasks.py index 5e6bc1c..818ed00 100755 --- a/skills/ack/scripts/validate_tasks.py +++ b/skills/ack/scripts/validate_tasks.py @@ -128,8 +128,15 @@ DISPATCH_FIELDS = { "worker", "developer", "test", + "operator", "rounds", } +DELIVERY_OPERATION_FIELDS = {"skill", "request"} +DELIVERY_OPERATION_SKILLS = { + "manage-release", + "deb-publisher", + "publish-docker-image", +} KNOWLEDGE_CANDIDATE_REQUIRED_FIELDS = { "kind", "title", @@ -432,11 +439,14 @@ def validate_delivery_runs( value: object, task_statuses: dict[str, object], errors: list[str], + *, + delivery_operation_ids: set[str] | None = None, ) -> None: if not isinstance(value, list): errors.append("deliveryRuns 必须是列表") return + excluded_task_ids = delivery_operation_ids or set() seen_run_ids: set[str] = set() for index, run in enumerate(value): where = f"deliveryRuns[{index}]" @@ -476,6 +486,11 @@ def validate_delivery_runs( for task_id in task_ids: if task_id not in task_statuses: errors.append(f"{where}.taskIds: 未知任务 {task_id!r}") + elif task_id in excluded_task_ids: + errors.append( + f"{where}.taskIds: deliveryRuns 不能引用 delivery-operation " + f"{task_id!r}" + ) elif task_statuses[task_id] != "verified": errors.append( f"{where}: delivery run 只能引用 verified 任务," @@ -807,6 +822,30 @@ def validate_builtin(data: dict) -> list[str]: if not _nonempty_string(source.get("updatedAt")): errors.append(f"{where}.source.updatedAt: 必须是非空字符串") + operation = task.get("operation") + if operation is not None: + if not isinstance(operation, dict): + errors.append(f"{where}.operation: 必须是对象") + else: + reject_unknown_fields( + operation, + DELIVERY_OPERATION_FIELDS, + f"{where}.operation", + errors, + ) + skill = operation.get("skill") + if skill not in DELIVERY_OPERATION_SKILLS: + errors.append( + f"{where}.operation.skill: 必须是 " + "manage-release/deb-publisher/publish-docker-image" + ) + if not _nonempty_string(operation.get("request")): + errors.append(f"{where}.operation.request: 必须保留非空用户请求") + + if task.get("type") == "delivery-operation": + if not isinstance(operation, dict): + errors.append(f"{where}: delivery-operation 必须声明 operation") + validate_knowledge_fields(task, where, status, errors) if "dispatch" not in task: @@ -829,6 +868,12 @@ def validate_builtin(data: dict) -> list[str]: nullable=True, ) + if task.get("type") == "delivery-operation" and ( + not isinstance(dispatch, dict) + or not isinstance(dispatch.get("operator"), dict) + ): + errors.append(f"{where}: delivery-operation 必须声明 dispatch.operator") + rounds = dispatch.get("rounds", []) if not isinstance(rounds, list): errors.append(f"{where}.dispatch.rounds: 必须是列表") @@ -929,7 +974,19 @@ def validate_builtin(data: dict) -> list[str]: for task in tasks if isinstance(task, dict) and _nonempty_string(task.get("id")) } - validate_delivery_runs(data["deliveryRuns"], task_statuses, errors) + delivery_operation_ids = { + task["id"] + for task in tasks + if isinstance(task, dict) + and _nonempty_string(task.get("id")) + and task.get("type") == "delivery-operation" + } + validate_delivery_runs( + data["deliveryRuns"], + task_statuses, + errors, + delivery_operation_ids=delivery_operation_ids, + ) return errors diff --git a/skills/ack/scripts/worker_profiles.py b/skills/ack/scripts/worker_profiles.py index 9d46631..698c402 100644 --- a/skills/ack/scripts/worker_profiles.py +++ b/skills/ack/scripts/worker_profiles.py @@ -23,13 +23,22 @@ RECEIPT_VERSION = 1 LAUNCH_PROTOCOL_VERSION = 1 MAX_ROUNDS = 3 -ROLES = frozenset({"developer", "test"}) +ROLES = frozenset({"developer", "test", "operator"}) +REQUIRED_DEFAULT_ROLES = frozenset({"developer", "test"}) +STANDARD_ONLY_ROLES = frozenset({"test", "operator"}) CLIS = frozenset({"codex", "cursor-agent"}) TIERS = frozenset({"standard", "strong"}) REASONING_EFFORTS = frozenset({"low", "medium", "high", "xhigh"}) PERMISSION_MODES = frozenset({"read-only", "workspace-write"}) ORCHESTRATION_MODES = frozenset({"orca", "manual"}) -DEFAULT_KEYS = frozenset({"developer", "test", "developerUpgraded"}) +BASE_ENVIRONMENT_POLICY = "per-cli-allowlist-v1" +OPERATOR_ENVIRONMENT_POLICY = "per-cli-plus-operator-publish-v1" +ENVIRONMENT_POLICIES = frozenset( + {BASE_ENVIRONMENT_POLICY, OPERATOR_ENVIRONMENT_POLICY} +) +DEFAULT_KEYS = frozenset( + {"developer", "test", "operator", "developerUpgraded"} +) ORCHESTRATION_FIELDS = frozenset( { @@ -184,6 +193,16 @@ def _is_positive_int(value: Any) -> bool: return isinstance(value, int) and not isinstance(value, bool) and value > 0 +def environment_policy_for_role(role: Any) -> str: + """Return the fixed credential policy for a validated worker role.""" + + if role == "operator": + return OPERATOR_ENVIRONMENT_POLICY + if role in {"developer", "test"}: + return BASE_ENVIRONMENT_POLICY + raise ValueError("role must be developer/test/operator") + + def _is_timestamp(value: Any) -> bool: if not isinstance(value, str): return False @@ -211,7 +230,7 @@ def validate_profile(profile: Any, *, where: str = "profile") -> list[str]: permission = profile.get("permissionMode") if not isinstance(role, str) or role not in ROLES: - errors.append(f"{where}.role: must be developer/test") + errors.append(f"{where}.role: must be developer/test/operator") if not isinstance(cli, str) or cli not in CLIS: errors.append(f"{where}.cli: must be codex/cursor-agent") if not isinstance(tier, str) or tier not in TIERS: @@ -231,8 +250,13 @@ def validate_profile(profile: Any, *, where: str = "profile") -> list[str]: elif cli == "cursor-agent" and effort is not None: errors.append(f"{where}.reasoningEffort: Cursor requires null") - if role == "test" and tier != "standard": - errors.append(f"{where}.tier: Test may only use standard") + if ( + isinstance(role, str) + and role in STANDARD_ONLY_ROLES + and tier != "standard" + ): + label = "Test" if role == "test" else "Operator" + errors.append(f"{where}.tier: {label} may only use standard") if tier == "strong" and role != "developer": errors.append(f"{where}.tier: strong may only be used by Developer") @@ -268,8 +292,11 @@ def _validate_model_allowlist(value: Any, where: str) -> list[str]: errors.append(f"{role_where}: must not be empty") for tier in sorted(set(tiers) - TIERS, key=repr): errors.append(f"{role_where}: unknown tier {tier!r}") - if role == "test" and "strong" in tiers: - errors.append(f"{role_where}: Test cannot define a strong allowlist") + if role in STANDARD_ONLY_ROLES and "strong" in tiers: + label = "Test" if role == "test" else "Operator" + errors.append( + f"{role_where}: {label} cannot define a strong allowlist" + ) for tier, models in tiers.items(): tier_where = f"{role_where}.{tier}" if tier not in TIERS: @@ -383,7 +410,7 @@ def validate_orchestration( for default_key in sorted(set(defaults) - DEFAULT_KEYS, key=repr): errors.append(f"{where}.defaults: unknown key {default_key!r}") if mode == "orca": - for role in sorted(ROLES - set(defaults)): + for role in sorted(REQUIRED_DEFAULT_ROLES - set(defaults)): errors.append(f"{where}.defaults: missing role {role!r}") for default_key, profile_id in defaults.items(): default_where = f"{where}.defaults.{default_key}" @@ -407,6 +434,22 @@ def validate_orchestration( if profile.get("permissionMode") not in PERMISSION_MODES: errors.append(f"{default_where}: default profile has unsafe permissions") + operator_profile_id = defaults.get("operator") + test_profile_id = defaults.get("test") + operator_profile = valid_profiles.get(operator_profile_id) + test_profile = valid_profiles.get(test_profile_id) + if operator_profile_id is not None and test_profile is None: + errors.append( + f"{where}.defaults.operator: requires a valid Test default profile" + ) + elif operator_profile is not None and test_profile is not None: + for field in ("cli", "tier", "model", "reasoningEffort"): + if operator_profile.get(field) != test_profile.get(field): + errors.append( + f"{where}.defaults.operator: operator default must use " + f"the Test default {field}" + ) + return errors @@ -502,7 +545,7 @@ def _validate_created_for(value: Any, where: str) -> list[str]: errors.append(f"{where}.attemptId: must belong to taskId") role = value.get("role") if not isinstance(role, str) or role not in ROLES: - errors.append(f"{where}.role: must be developer/test") + errors.append(f"{where}.role: must be developer/test/operator") return errors @@ -571,9 +614,10 @@ def _validate_requested(value: Any, where: str) -> list[str]: elif isinstance(argv, list) and all(isinstance(arg, str) for arg in argv): if argv_hash != canonical_sha256(argv): errors.append(f"{where}.argvHash: does not match argv") - if value.get("environmentPolicy") != "per-cli-allowlist-v1": + if value.get("environmentPolicy") not in ENVIRONMENT_POLICIES: errors.append( - f"{where}.environmentPolicy: must be 'per-cli-allowlist-v1'" + f"{where}.environmentPolicy: must be 'per-cli-allowlist-v1' or " + "'per-cli-plus-operator-publish-v1'" ) return errors @@ -722,6 +766,19 @@ def validate_worker_receipt( if binding.get("observedWorktreePath") != worktree.get("path"): errors.append(f"{where}.binding.observedWorktreePath: does not match worktree.path") + if isinstance(created_for, dict) and isinstance(requested, dict): + try: + expected_environment_policy = environment_policy_for_role( + created_for.get("role") + ) + except ValueError: + pass + else: + if requested.get("environmentPolicy") != expected_environment_policy: + errors.append( + f"{where}.requested.environmentPolicy: does not match role" + ) + if ( isinstance(created_for, dict) and isinstance(worktree, dict) @@ -977,7 +1034,10 @@ __all__ = [ "RECEIPT_VERSION", "LAUNCH_PROTOCOL_VERSION", "MAX_ROUNDS", + "BASE_ENVIRONMENT_POLICY", + "OPERATOR_ENVIRONMENT_POLICY", "canonical_sha256", + "environment_policy_for_role", "profile_hash", "receipt_hash", "render_worker_argv", diff --git a/skills/ack/templates/project.template.md b/skills/ack/templates/project.template.md index d1ce6c1..ab5e6a4 100644 --- a/skills/ack/templates/project.template.md +++ b/skills/ack/templates/project.template.md @@ -33,6 +33,7 @@ - 派发 prompt 模板:`references/prompt-templates.md` - Orca 编排命令(可选):`references/orca-adapter.md` - 验证后交付与配置维护(可选):`references/delivery.md` +- 一次性源码/DEB/镜像发布路由(可选):`references/delivery-routing.md` ## Worker 路由 @@ -46,6 +47,7 @@ receipt 全部以 `docs/ack/tasks.yaml` 的 `project.orchestration` 与顶层 |------|------------|------| | Developer | `codex-dev-standard` | standard | | Test | `codex-test-standard` | standard | +| Operator | `codex-operator-standard` | standard(模型/effort 与 Test 相同) | | Developer 升级 | `codex-dev-strong` | strong | 项目如改用 Cursor,应修改结构化 profile、allowlist 和 defaults,再运行任务板 @@ -102,6 +104,8 @@ Skill 的 `scripts/run_verification.py` 执行,不直接拼接 path/args。检 ## 硬规则(其余见 references/) - 三角色独立:Coordinator 只编排、Test 只验证、Developer 只实现(验证者 ≠ 实现者)。 +- 一次性交付 Operator 不进入三角色闭环;它使用 Test 同档模型执行被路由的低层 Skill, + 只回传证据,不写 ACK 状态文件。 - 模型分层:Coordinator 用强模型且不亲自跑测试,Test/Developer 用中低模型,必要时升级(见 references/model-routing.md)。 - 自动 worker 只能由 ACK 的 `scripts/launch_worker.py` 按结构化 profile 启动; 禁止直接拼 `orca terminal create --command`,禁止 `command`、`extraArgs`、`env` diff --git a/skills/ack/templates/tasks.schema.json b/skills/ack/templates/tasks.schema.json index 3c5a225..783c277 100644 --- a/skills/ack/templates/tasks.schema.json +++ b/skills/ack/templates/tasks.schema.json @@ -249,7 +249,8 @@ "additionalProperties": false, "properties": { "developer": { "$ref": "#/definitions/modelTierAllowlist" }, - "test": { "$ref": "#/definitions/modelTierAllowlist" } + "test": { "$ref": "#/definitions/modelTierAllowlist" }, + "operator": { "$ref": "#/definitions/modelTierAllowlist" } } }, "modelAllowlist": { @@ -274,7 +275,7 @@ "properties": { "role": { "type": "string", - "enum": ["developer", "test"] + "enum": ["developer", "test", "operator"] }, "cli": { "type": "string", @@ -304,6 +305,7 @@ "properties": { "developer": { "$ref": "#/definitions/profileId" }, "test": { "$ref": "#/definitions/profileId" }, + "operator": { "$ref": "#/definitions/profileId" }, "developerUpgraded": { "$ref": "#/definitions/profileId" } } }, @@ -482,7 +484,10 @@ }, "environmentPolicy": { "type": "string", - "const": "per-cli-allowlist-v1" + "enum": [ + "per-cli-allowlist-v1", + "per-cli-plus-operator-publish-v1" + ] } } }, @@ -591,7 +596,7 @@ }, "role": { "type": "string", - "enum": ["developer", "test"] + "enum": ["developer", "test", "operator"] } } }, @@ -954,6 +959,22 @@ } ] }, + "deliveryOperation": { + "type": "object", + "required": ["skill", "request"], + "additionalProperties": false, + "properties": { + "skill": { + "type": "string", + "enum": [ + "manage-release", + "deb-publisher", + "publish-docker-image" + ] + }, + "request": { "type": "string", "minLength": 1, "pattern": "\\S" } + } + }, "task": { "type": "object", "required": ["id", "title", "status"], @@ -999,6 +1020,7 @@ "stepsToReproduce": { "type": "array", "items": { "type": "string" } }, "expected": { "type": "string" }, "actual": { "type": "string" }, + "operation": { "$ref": "#/definitions/deliveryOperation" }, "evidence": { "type": "object" }, "verification": { "type": "object" }, "dispatch": { @@ -1010,6 +1032,7 @@ "worker": { "type": ["string", "null"] }, "developer": { "$ref": "#/definitions/roleDispatch" }, "test": { "$ref": "#/definitions/roleDispatch" }, + "operator": { "$ref": "#/definitions/roleDispatch" }, "rounds": { "type": "array", "items": { "$ref": "#/definitions/round" } @@ -1047,6 +1070,21 @@ }, "required": ["resolution"] } + }, + { + "if": { + "properties": { "type": { "const": "delivery-operation" } }, + "required": ["type"] + }, + "then": { + "required": ["operation", "dispatch"], + "properties": { + "dispatch": { + "type": "object", + "required": ["operator"] + } + } + } } ] } diff --git a/skills/ack/templates/tasks.template.yaml b/skills/ack/templates/tasks.template.yaml index dfb097d..3cca05b 100644 --- a/skills/ack/templates/tasks.template.yaml +++ b/skills/ack/templates/tasks.template.yaml @@ -39,11 +39,15 @@ project: strong: ["gpt-5.6-sol"] test: standard: ["gpt-5.6-luna"] + operator: + standard: ["gpt-5.6-luna"] cursor-agent: developer: standard: ["auto"] test: standard: ["auto"] + operator: + standard: ["auto"] profiles: codex-dev-standard: role: "developer" @@ -59,6 +63,13 @@ project: model: "gpt-5.6-luna" reasoningEffort: "low" permissionMode: "workspace-write" + codex-operator-standard: + role: "operator" + cli: "codex" + tier: "standard" + model: "gpt-5.6-luna" + reasoningEffort: "low" + permissionMode: "workspace-write" codex-dev-strong: role: "developer" cli: "codex" @@ -80,9 +91,17 @@ project: model: "auto" reasoningEffort: null permissionMode: "workspace-write" + cursor-operator-standard: + role: "operator" + cli: "cursor-agent" + tier: "standard" + model: "auto" + reasoningEffort: null + permissionMode: "workspace-write" defaults: developer: "codex-dev-standard" test: "codex-test-standard" + operator: "codex-operator-standard" developerUpgraded: "codex-dev-strong" workerReceipts: [] diff --git a/skills/manage-release/README.md b/skills/manage-release/README.md index 5d064d8..c8b5c22 100644 --- a/skills/manage-release/README.md +++ b/skills/manage-release/README.md @@ -15,6 +15,16 @@ ## 常见用法 +### 通过 ACK 只创建普通 PR + +```text +$ack 把当前分支推送并创建一个指向 main 的 PR,不要合并。 +``` + +ACK 会把这类一次性请求路由到 `manage-release` 的 PR-only 流程。Agent 只核对当前 +head、base、remote、提交边界、验证和 Forge 账号,然后创建或复用 PR;不会顺带升级 +版本、创建 release 分支、合并、打 tag 或创建 Forge Release。 + ### 确定下一个版本号 ```text @@ -106,5 +116,6 @@ Agent 会先发现项目自己的版本、分支和发布规则,再检查本 ## 不适用的场景 普通功能开发、普通 worktree 或 PR/MR 操作、代码审查、构建 Docker 镜像或上传 DEB 包 -不需要触发 `manage-release`。只有任务明确涉及版本发布、发布分支、版本号、发布 PR/MR -或 release tag 时,才交给这个 Skill。 +不需要直接触发 `manage-release`。只有任务明确涉及版本发布、发布分支、版本号、发布 +PR/MR 或 release tag,或者用户显式调用 `$ack` 要求 PR-only 委派时,才交给这个 +Skill。 diff --git a/skills/manage-release/SKILL.md b/skills/manage-release/SKILL.md index 2acc8e0..6c12e63 100644 --- a/skills/manage-release/SKILL.md +++ b/skills/manage-release/SKILL.md @@ -8,6 +8,8 @@ description: >- 升版本、提交或合并发布 PR/MR、打 release tag、完成发版、处理 hotfix 或继续未完成 发布时使用。只做普通编码、普通 worktree 或 PR/MR 操作、代码审查、构建或上传 DEB/Docker 等产物、管理仓库权限时不使用。 + 由显式调用的 `$ack` 根据用户明确的普通 PR/MR 请求路由时,可只执行本文的 + PR-only 流程,不把它扩大成版本发布。 --- # Manage Release @@ -28,6 +30,18 @@ description: >- ## 授权边界 +### ACK PR-only 委派 + +当且仅当用户显式调用 `$ack`,且 ACK 把用户原始的普通 PR/MR 请求委派给本 Skill 时, +使用 PR-only 流程:执行“发现项目规则和当前状态”,锁定准确 head、base、remote、 +worktree、验证命令与活动 Forge 账号,然后只执行“推送并创建 PR/MR”及其完成检查。 +用户没有明确要求时,不创建 release 分支、不修改版本或 CHANGELOG、不合并、不打 tag、 +不创建 Forge Release、不清理分支或 worktree。缺少 head/base/remote、提交边界或平台 +认证时按硬停止条件报告,不把普通 PR 伪装成发布。 + +ACK 只负责路由,不增加授权。Operator 收到的原始请求仍是权限上限;“创建 PR”只授权 +为准确 head/base 创建或复用 PR,不自动授权合并。 + 按用户明确要求执行到对应阶段: - 分析版本或检查状态:只执行读取和计算,不修改文件或远端。 @@ -46,6 +60,9 @@ description: >- ## 工作流 +PR-only 仍按以下章节做实时状态发现与安全检查,但跳过与用户请求无关的版本计算、 +release worktree、版本文件、合并和 tag 阶段。已有用户工作不得被 stash、移动或覆盖。 + ### 1. 发现项目规则和当前状态 从项目根目录开始: diff --git a/skills/publish-docker-image/SKILL.md b/skills/publish-docker-image/SKILL.md index e64bfbf..b62061e 100644 --- a/skills/publish-docker-image/SKILL.md +++ b/skills/publish-docker-image/SKILL.md @@ -2,7 +2,8 @@ name: publish-docker-image description: >- 构建当前项目的 Docker 镜像,并将其上传到用户指定的镜像仓库。仅当用户显式指定 - $publish-docker-image 或明确说“使用 publish-docker-image skill”时使用; + $publish-docker-image、明确说“使用 publish-docker-image skill”,或由显式调用的 `$ack` + 根据用户明确的 Docker/OCI 镜像发布请求路由时使用; 不要因普通编码、编辑 Dockerfile、本地构建、测试或一般 Docker 问题而自动触发。 --- diff --git a/tests/test_ack_delivery_routing.py b/tests/test_ack_delivery_routing.py new file mode 100644 index 0000000..9ba9014 --- /dev/null +++ b/tests/test_ack_delivery_routing.py @@ -0,0 +1,353 @@ +from __future__ import annotations + +import copy +import sys +import tempfile +import unittest +from pathlib import Path +from unittest import mock + + +REPO_ROOT = Path(__file__).resolve().parents[1] +ACK_DIR = REPO_ROOT / "skills" / "ack" +SCRIPTS_DIR = ACK_DIR / "scripts" +sys.path.insert(0, str(SCRIPTS_DIR)) + +import launch_worker # noqa: E402 +import validate_tasks # noqa: E402 +import worker_profiles # noqa: E402 + + +def operator_orchestration() -> dict: + return { + "profileVersion": 1, + "mode": "orca", + "allowedWorktrees": ["/repo/demo"], + "modelAllowlist": { + "codex": { + "developer": {"standard": ["gpt-dev"]}, + "test": {"standard": ["gpt-low"]}, + "operator": {"standard": ["gpt-low"]}, + } + }, + "profiles": { + "codex-dev-standard": { + "role": "developer", + "cli": "codex", + "tier": "standard", + "model": "gpt-dev", + "reasoningEffort": "medium", + "permissionMode": "workspace-write", + }, + "codex-test-standard": { + "role": "test", + "cli": "codex", + "tier": "standard", + "model": "gpt-low", + "reasoningEffort": "low", + "permissionMode": "workspace-write", + }, + "codex-operator-standard": { + "role": "operator", + "cli": "codex", + "tier": "standard", + "model": "gpt-low", + "reasoningEffort": "low", + "permissionMode": "workspace-write", + }, + }, + "defaults": { + "developer": "codex-dev-standard", + "test": "codex-test-standard", + "operator": "codex-operator-standard", + }, + } + + +def routed_board() -> dict: + return { + "version": 1, + "ackVersion": "0.12.0", + "project": { + "name": "demo", + "orchestration": operator_orchestration(), + }, + "workerReceipts": [], + "tasks": [ + { + "id": "DELIVERY-001", + "type": "delivery-operation", + "title": "publish one DEB", + "status": "open", + "operation": { + "skill": "deb-publisher", + "request": "发布 1.2.3 的 amd64 DEB 到 testing 仓库", + }, + "dispatch": { + "operator": { + "profileId": "codex-operator-standard", + "receiptId": None, + "attemptId": None, + "taskId": None, + "dispatchId": None, + }, + "rounds": [], + }, + } + ], + } + + +class AckDeliveryRoutingTests(unittest.TestCase): + def test_operator_profile_uses_the_test_low_cost_model(self) -> None: + routing = operator_orchestration() + + self.assertEqual(worker_profiles.validate_orchestration(routing), []) + + routing["profiles"]["codex-operator-standard"]["model"] = "gpt-other" + routing["modelAllowlist"]["codex"]["operator"]["standard"] = [ + "gpt-other" + ] + errors = worker_profiles.validate_orchestration(routing) + self.assertTrue( + any("operator default must use the Test default model" in error for error in errors), + errors, + ) + + def test_operator_is_standard_only_and_optional_for_legacy_projects(self) -> None: + routing = operator_orchestration() + operator = routing["profiles"]["codex-operator-standard"] + operator["tier"] = "strong" + routing["modelAllowlist"]["codex"]["operator"] = { + "strong": ["gpt-low"] + } + + errors = worker_profiles.validate_orchestration(routing) + self.assertTrue(any("Operator may only use standard" in error for error in errors)) + + legacy = operator_orchestration() + del legacy["defaults"]["operator"] + del legacy["profiles"]["codex-operator-standard"] + del legacy["modelAllowlist"]["codex"]["operator"] + self.assertEqual(worker_profiles.validate_orchestration(legacy), []) + + def test_delivery_operation_requires_a_supported_route_and_operator_dispatch(self) -> None: + board = routed_board() + self.assertEqual(validate_tasks.validate_builtin(board), []) + + missing_operation = copy.deepcopy(board) + del missing_operation["tasks"][0]["operation"] + errors = validate_tasks.validate_builtin(missing_operation) + self.assertTrue(any("delivery-operation 必须声明 operation" in error for error in errors)) + + unsupported = copy.deepcopy(board) + unsupported["tasks"][0]["operation"]["skill"] = "shell" + errors = validate_tasks.validate_builtin(unsupported) + self.assertTrue(any("manage-release/deb-publisher/publish-docker-image" in error for error in errors)) + + missing_dispatch = copy.deepcopy(board) + del missing_dispatch["tasks"][0]["dispatch"]["operator"] + errors = validate_tasks.validate_builtin(missing_dispatch) + self.assertTrue(any("delivery-operation 必须声明 dispatch.operator" in error for error in errors)) + + def test_delivery_operation_cannot_be_reused_as_a_profile_delivery_run(self) -> None: + board = routed_board() + board["project"]["deliveryFile"] = "docs/ack/delivery.yaml" + board["tasks"][0]["status"] = "verified" + board["deliveryRuns"] = [ + { + "id": "DR-duplicate-route", + "profile": "review", + "taskIds": ["DELIVERY-001"], + "status": "planned", + "sourceRevision": "a" * 40, + "configRevision": "b" * 40, + "pullRequest": None, + "artifacts": [], + "deployments": [], + "evidence": [], + "updatedAt": "2026-08-01T10:00:00+08:00", + } + ] + + errors = validate_tasks.validate_builtin(board) + + self.assertTrue( + any("deliveryRuns 不能引用 delivery-operation" in error for error in errors), + errors, + ) + + def test_launcher_creates_an_operator_plan_on_the_low_cost_profile(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + project = Path(temporary).resolve() + executable = project / "codex" + executable.write_text("#!/bin/sh\n", encoding="utf-8") + executable.chmod(0o700) + routing = operator_orchestration() + routing["allowedWorktrees"] = [str(project)] + board = routed_board() + board["project"]["repoPath"] = str(project) + board["project"]["orchestration"] = routing + metadata = project.stat() + identity = { + "path": str(project), + "device": metadata.st_dev, + "inode": metadata.st_ino, + "gitCommonDir": str(project / ".git"), + "gitCommonDevice": metadata.st_dev, + "gitCommonInode": metadata.st_ino, + } + + with ( + mock.patch.object( + launch_worker, + "load_authoritative_board", + return_value=(project, board), + ), + mock.patch.object( + launch_worker, + "capture_worktree_identity", + return_value=identity, + ), + mock.patch.object( + launch_worker, + "resolve_executable", + return_value=executable, + ), + mock.patch.object( + launch_worker, + "run_text", + return_value="codex-cli 1.0", + ), + ): + plan = launch_worker.build_plan( + project_root_value=str(project), + task_id="DELIVERY-001", + attempt_id="DELIVERY-001-A1", + role="operator", + profile_id="codex-operator-standard", + worktree_value=str(project), + slot=1, + ) + receipt = launch_worker.build_receipt( + "c" * 64, + plan, + "runtime-1", + { + "handle": "terminal-1", + "incarnationId": "incarnation-1", + "connected": True, + "writable": True, + "worktreePath": str(project), + }, + "2026-08-01T10:00:00+08:00", + ) + receipt_errors = worker_profiles.validate_worker_receipt( + receipt, + orchestration=routing, + task_ids={"DELIVERY-001"}, + ) + + self.assertEqual(plan["role"], "operator") + self.assertEqual(plan["requested"]["model"], "gpt-low") + self.assertEqual( + plan["requested"]["environmentPolicy"], + "per-cli-plus-operator-publish-v1", + ) + self.assertTrue(plan["title"].startswith("ACK-OP-CODEX-STANDARD-")) + self.assertEqual(receipt_errors, []) + + def test_launcher_binds_operator_to_delivery_operation_tasks(self) -> None: + delivery_board = routed_board() + ordinary_board = copy.deepcopy(delivery_board) + ordinary_task = ordinary_board["tasks"][0] + ordinary_task["id"] = "TASK-001" + ordinary_task["type"] = "feature" + del ordinary_task["operation"] + + with mock.patch.object( + launch_worker, + "load_authoritative_board", + return_value=(Path("/repo/demo"), ordinary_board), + ): + with self.assertRaisesRegex( + launch_worker.LaunchError, + "operator 只能用于 delivery-operation", + ): + launch_worker.build_plan( + project_root_value="/repo/demo", + task_id="TASK-001", + attempt_id="TASK-001-A1", + role="operator", + profile_id="codex-operator-standard", + worktree_value="/repo/demo", + slot=1, + ) + + with mock.patch.object( + launch_worker, + "load_authoritative_board", + return_value=(Path("/repo/demo"), delivery_board), + ): + with self.assertRaisesRegex( + launch_worker.LaunchError, + "delivery-operation 任务只能由 operator", + ): + launch_worker.build_plan( + project_root_value="/repo/demo", + task_id="DELIVERY-001", + attempt_id="DELIVERY-001-A1", + role="test", + profile_id="codex-test-standard", + worktree_value="/repo/demo", + slot=1, + ) + + def test_operator_gets_only_fixed_release_credentials(self) -> None: + with mock.patch.dict( + "os.environ", + { + "OPENAI_API_KEY": "agent-token", + "DEB_TOKEN": "deb-token", + "DEB_SERVER_URL": "https://packages.example.com", + "DEB_REPOSITORY": "testing", + "SSH_AUTH_SOCK": "/tmp/agent.sock", + "GIT_SSH_COMMAND": "unsafe override", + "DOCKER_PASSWORD": "must-not-pass", + }, + clear=True, + ): + operator = launch_worker.worker_environment("codex", "operator") + test = launch_worker.worker_environment("codex", "test") + + self.assertEqual(operator["DEB_TOKEN"], "deb-token") + self.assertEqual(operator["SSH_AUTH_SOCK"], "/tmp/agent.sock") + self.assertEqual(operator["OPENAI_API_KEY"], "agent-token") + self.assertNotIn("DEB_TOKEN", test) + self.assertNotIn("SSH_AUTH_SOCK", test) + self.assertNotIn("GIT_SSH_COMMAND", operator) + self.assertNotIn("DOCKER_PASSWORD", operator) + + def test_ack_documents_the_three_routes_and_non_release_pr_boundary(self) -> None: + skill = (ACK_DIR / "SKILL.md").read_text(encoding="utf-8") + routing = (ACK_DIR / "references" / "delivery-routing.md").read_text( + encoding="utf-8" + ) + docker = ( + REPO_ROOT / "skills" / "publish-docker-image" / "SKILL.md" + ).read_text(encoding="utf-8") + release = ( + REPO_ROOT / "skills" / "manage-release" / "SKILL.md" + ).read_text(encoding="utf-8") + + self.assertIn("references/delivery-routing.md", skill) + for name in ("manage-release", "deb-publisher", "publish-docker-image"): + self.assertIn(name, routing) + self.assertIn("普通 PR/MR", routing) + self.assertIn("由显式调用的 `$ack`", docker) + self.assertIn("由显式调用的 `$ack`", release) + self.assertIn("PR-only", release) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_ack_skill.py b/tests/test_ack_skill.py index 5556b10..f0c272e 100644 --- a/tests/test_ack_skill.py +++ b/tests/test_ack_skill.py @@ -62,6 +62,7 @@ class AckSkillContentTests(unittest.TestCase): "templates/delivery.schema.json", "examples/delivery.example.yaml", "references/delivery.md", + "references/delivery-routing.md", ): self.assertTrue((ack_dir / relative_path).is_file(), relative_path) version = (ack_dir / "VERSION").read_text(encoding="utf-8").strip()