From 7dfdf80e9ec4c7a9241fd875a32c76ddf157ff48 Mon Sep 17 00:00:00 2001 From: laily Date: Sun, 23 Aug 2026 19:08:54 +0800 Subject: [PATCH] feat(ack): add grok workers and allow --always-approve Grok is a first-class worker CLI. Launcher argv includes --always-approve so unattended tool calls are not blocked; sandbox stays required. --- skills/ack/README.md | 52 ++++++--- skills/ack/SKILL.md | 44 ++++++-- skills/ack/VERSION | 2 +- skills/ack/agents/openai.yaml | 2 +- skills/ack/examples/delivery.example.yaml | 17 +++ skills/ack/examples/project.example.md | 5 +- skills/ack/examples/tasks.example.yaml | 29 ++++- skills/ack/references/adoption-checklist.md | 2 + skills/ack/references/closed-loop.md | 4 +- skills/ack/references/delivery.md | 25 +++++ skills/ack/references/init-new-project.md | 9 +- skills/ack/references/kickoff.md | 8 +- skills/ack/references/model-routing.md | 18 ++- skills/ack/references/prompt-templates.md | 2 +- skills/ack/scripts/launch_worker.py | 52 ++++++++- skills/ack/scripts/validate_delivery.py | 39 +++++++ skills/ack/scripts/validate_tasks.py | 22 +++- skills/ack/scripts/worker_profiles.py | 92 ++++++++++++---- skills/ack/templates/delivery.schema.json | 16 +++ skills/ack/templates/delivery.template.yaml | 5 + skills/ack/templates/project.template.md | 12 +- skills/ack/templates/tasks.schema.json | 14 ++- skills/ack/templates/tasks.template.yaml | 27 +++++ tests/test_ack_delivery.py | 37 +++++++ tests/test_ack_launch_worker.py | 61 ++++++++++- tests/test_ack_skill.py | 5 +- tests/test_ack_tasks_validation.py | 41 +++++++ tests/test_ack_worker_profiles.py | 115 ++++++++++++++++++++ 28 files changed, 679 insertions(+), 78 deletions(-) diff --git a/skills/ack/README.md b/skills/ack/README.md index d697305..c8e4895 100644 --- a/skills/ack/README.md +++ b/skills/ack/README.md @@ -9,8 +9,9 @@ ACK 是一个显式调用的 Agent Skill,用三种独立角色运行工程协 关键约束是验证者不等于实现者。每个任务最多修复三轮,仍未通过时记录为 `leftover`,然后继续处理其它任务。 -项目还可以声明一个可选的交付阶段:任务全部验证后,ACK 按项目维护的 profile -构建 DEB 或镜像、发布产物、创建 PR,并在授权范围内部署。交付配置默认关闭, +项目还可以在同一份 `docs/ack/delivery.yaml` 里声明测试环境部署和版本发布。 +用户告诉 ACK 这两件事怎么做之后,再说「重新布测试环境」或「发布一个版本」, +ACK 按对应 intent 执行。任务全部验证后仍可按 profile 做常规交付。配置默认关闭, 稳定发布与生产部署始终保留人工批准点。 ## 安装 @@ -70,8 +71,8 @@ skills/ack/ `docs/ack/project.md` 只保存当前项目的命令、路径和权限差异; `docs/ack/tasks.yaml` 保存当前任务状态;`docs/ack/knowledge.yaml` 保存跨任务复用、 已经独立验证的项目知识护栏。 -`docs/ack/delivery.yaml` 声明项目特有的构建、发布和部署能力;每次执行结果另记在 -`tasks.yaml.deliveryRuns`,配置与运行状态不会混在一起。 +`docs/ack/delivery.yaml` 是测试环境部署和版本发布的唯一契约,也声明常规构建、 +发布和部署能力;每次执行结果另记在 `tasks.yaml.deliveryRuns`。 ## 检查项目状态 @@ -139,16 +140,29 @@ python3 /scripts/run_verification.py \ ## 配置与运行交付 -用户可以直接向 `/ack` 描述项目差异,例如“这个项目用 `make deb` 构建 DEB,推到 -preview APT 源,再部署到开发机”。ACK 会把它维护成 -`docs/ack/delivery.yaml` 中的声明式 entrypoint、artifact、destination、environment -和 profile,校验后展示 diff;首次配置保持关闭,确认后才启用。 +用户可以直接向 `/ack` 说明两件独立操作,并写进同一份契约: + +```text +/ack 测试时先 go build -o garden ./cmd/garden,再启动这个二进制; +发版方式以后再告诉你。 +``` + +ACK 把它维护成 `docs/ack/delivery.yaml` 的 `intents.testEnvironment` / +`intents.release`、entrypoint、artifact、environment 和 profile。首次配置保持 +关闭,确认后才启用。之后用户可以说: + +```text +/ack 重新布一下测试环境,我要测试 +/ack 发布一个版本 +``` + +对应 intent 未配置时先问清楚并写回同一文件,不猜测。intent 运行不要求当前有 +`verified` 任务;`deliveryRuns.intent` 记录是测试环境还是发版。 交付配置只允许声明式工具 target 或仓库内可执行脚本,不接受自由 shell,也不保存 -凭据值。ACK 在任务进入 `verified` 后,按用户确认的 profile 执行,并把 revision、 -PR、产物摘要、部署目标、健康检查和日志引用写入 `tasks.yaml.deliveryRuns`。默认 -profile 只能停在 `review_ready`;稳定发布或生产部署必须经过对应 approval 步骤。 -具体契约见 `references/delivery.md`。 +凭据值。任务进入 `verified` 后的常规交付仍按确认过的 profile 执行。默认 +profile 只能停在 `validation_ready` 或 `review_ready`;稳定发布或生产部署必须经过 +对应 approval 步骤。具体契约见 `references/delivery.md`。 ## 启动 Worker @@ -191,8 +205,10 @@ identity。Coordinator 将 receipt 追加到顶层 `workerReceipts`,再把 rec 不能跨任务或跨轮次改挂。 v0.10 自动 launcher 仅支持 `read-only` 与 `workspace-write`。full-access、 -Codex bypass、Cursor YOLO/force 和关闭 sandbox 都会 fail closed;在有可信平台 -审批或独立签发通道之前,不用项目文件伪装成用户授权。旧任务板没有结构化 +Codex bypass、Cursor YOLO/force、Grok `--yolo` / bypassPermissions 和关闭 +sandbox 都会 fail closed;在有可信平台审批或独立签发通道之前,不用项目文件 +伪装成用户授权。Grok worker 由 launcher 固定带 `--always-approve`,避免工具调用 +停在确认框,sandbox 仍必须启用。v0.17 起 `cli: grok` 是一等 worker CLI。旧任务板没有结构化 `project.orchestration` 时仍可读取和手动协作,但不得自动创建 worker。 持久化 `receiptHash` 是无密钥 checksum,不是 launcher 身份证明。ACK 只复用同一轮 @@ -243,5 +259,9 @@ ACK 会自动读取 `delivery.yaml`,无需再逐步提醒它构建、上传、 `ackVersion`。从 `0.10.0` 起,`project.orchestration` 与顶层 `workerReceipts` 必须 同时存在;从 `0.11.0` 起,新项目还会生成默认关闭的 `delivery.yaml`,并在任务板声明 `project.deliveryFile` 与 `deliveryRuns`;从 `0.13.0` 起,Coordinator 使用 -`select_tasks.py` 获取有预算的任务上下文,不再把完整任务板注入模型。旧项目可以不迁移 -而继续使用原闭环。旧项目的 `kitVersion` 可以继续读取,但建议迁移为 `ackVersion`。 +`select_tasks.py` 获取有预算的任务上下文,不再把完整任务板注入模型;从 `0.16.0` 起, +`delivery.yaml` 可用 `intents.testEnvironment` 与 `intents.release` 把测试环境部署和 +版本发布写成用户可单独触发的操作;从 `0.17.0` 起,结构化 worker 路由支持 +`cli: grok`(与 Codex、Cursor 并列);从 `0.17.1` 起 Grok worker argv 固定带 +`--always-approve`,sandbox 仍必开。旧项目可以不迁移而继续使用原闭环。旧项目的 +`kitVersion` 可以继续读取,但建议迁移为 `ackVersion`。 diff --git a/skills/ack/SKILL.md b/skills/ack/SKILL.md index d38fd35..3d7d7e5 100644 --- a/skills/ack/SKILL.md +++ b/skills/ack/SKILL.md @@ -3,7 +3,7 @@ name: ack description: >- 初始化、检查并运行 ACK 三角色协作闭环。仅在用户显式调用 /ack 或 $ack,并要求 初始化 ACK、检查 docs/ack 配置、按 ACK 规划需求、指挥 Coordinator/Developer/Test - 工作,或配置并执行任务验证后的项目交付流程时使用。 + 工作,配置测试环境与发版方式,重新部署测试环境,或发布版本时使用。 --- # ACK 项目协作入口 @@ -21,7 +21,11 @@ description: >- - 用户要求初始化、接入或安装 ACK:执行“初始化”。 - 用户要求检查 ACK 是否可用、配置是否完整:执行“检查”。 - 用户要求用 ACK 做需求、修复问题或继续任务:执行“工作”。 -- 用户用自然语言要求增加、修改或关闭项目交付流程:执行“交付配置维护”。 +- 用户用自然语言说明怎么部署测试环境、怎么发布版本,或要求增加、修改、关闭交付 + 流程:执行“交付配置维护”。测试环境和发版必须写进同一份 + `docs/ack/delivery.yaml`。 +- 用户要求部署、重新部署测试环境,或按已配置方式开始测试:执行“运行测试环境”。 +- 用户要求发布版本:执行“运行版本发布”。 始终先解析真实项目根目录。优先使用 `git rev-parse --show-toplevel`;不是 Git 项目时使用用户指定目录或当前目录。不要修改项目的 `AGENTS.md`、`CLAUDE.md` @@ -169,8 +173,9 @@ description: >- 任一条件不符、清理能力不存在或无法确认清理成功时创建 fresh worker。持久化 receipt 只作审计与 dispatch 关联,不能单独授权复用。当前 Orca 终端接口不能提供 可验证的历史消息清理,因此使用 Orca 时仍走 fresh worker。 -6. 用户已确认的任务按 ACK 闭环执行:Developer 实现与白盒验证,Test 独立黑盒 - 复测,Coordinator 读取证据终检并唯一写入 `tasks.yaml`。Developer 回报 +6. 用户已确认的任务按 ACK 闭环执行:Developer 实现与白盒验证;若 + `intents.testEnvironment` 已启用,Coordinator 先按「运行测试环境」拉起服务,再 + 派 Test 独立黑盒复测。Coordinator 读取证据终检并唯一写入 `tasks.yaml`。Developer 回报 `knowledgeApplied` 和 `knowledgeCandidates`,Test 回报 `knowledgeChecks`; `candidate` 只有在独立验证和 gate 后才能由 Coordinator 写入或激活。 7. 执行知识项的 `verification.ref` 时,只调用 @@ -202,8 +207,10 @@ description: >- ## 交付配置维护 1. 读取 `references/delivery.md`、模板、schema、现有 `delivery.yaml`、项目构建/发布 - 入口和 CI;把用户自然语言描述转换为结构化 entrypoint、artifact、destination、 - environment 与 profile。配置只引用仓库内脚本或声明式工具 target,不保存 shell。 + 入口和 CI;把用户自然语言描述转换为结构化 `intents`、entrypoint、artifact、 + destination、environment 与 profile。测试环境部署和版本发布都写进这一份 + `delivery.yaml`,不要拆成第二份文档。配置只引用仓库内脚本或声明式工具 target, + 不保存 shell。 2. 若旧项目首次启用,生成 `docs/ack/delivery.yaml`,在 `tasks.yaml.project` 增加 `deliveryFile: docs/ack/delivery.yaml`,并增加顶层 `deliveryRuns: []`;不改写其它 项目状态。首次生成保持 `enabled: false`,先展示 diff 和解析出的执行顺序。 @@ -213,13 +220,34 @@ description: >- 执行的 run 使用开始时审阅的 commit/config revision 快照,不能借当前分支修改 扩大权限。 +## 运行测试环境 + +1. 读取 `docs/ack/delivery.yaml` 与 `references/delivery.md`。 +2. `enabled` 不为 true,或 `intents.testEnvironment` 为 null:停止,请用户说明如何 + 部署测试环境,转入交付配置维护。不猜测编译或启动命令。 +3. 不要求任务已 `verified`。按该 profile 执行 build → deploy → health-check。 +4. 把访问地址交给用户或随后的 Test 黑盒。证据写入 `deliveryRuns`, + `intent: testEnvironment`,`taskIds` 可为空。 +5. 派发 Test 前若该 intent 已启用,必须先完成本步骤。 + +## 运行版本发布 + +1. 读取同一份 `docs/ack/delivery.yaml` 与 `references/delivery.md`。 +2. `enabled` 不为 true,或 `intents.release` 为 null:停止,请用户说明如何发版, + 写入同一文件后再执行。 +3. 按该 profile 顺序执行。stable 发布和生产部署的 `approval` 不能用口头「发版」 + 代替。 +4. 证据写入 `deliveryRuns`,`intent: release`;绑定了任务时 `taskIds` 仍只能引用 + `verified` 任务。 + ## 边界 - 不修改或追加任何项目 Agent 指令文件,包括 `AGENTS.md`。 - 不在项目中维护第二份 ACK 通用规范、模板或任务 schema。 - 不猜测项目命令、服务地址、worker handle 或模型名称。 -- 不把 full-access、bypass、YOLO/force、关闭 sandbox 或项目内“授权”字段当成 - v0.10 自动 worker 的合法配置;当前一律 fail closed。 +- 不把 full-access、bypass、YOLO/force、Grok `--yolo` / bypassPermissions、 + 关闭 sandbox 或项目内“授权”字段当成 v0.10 自动 worker 的合法配置;当前一律 + fail closed。Grok worker 由 launcher 固定带 `--always-approve`,仍必须带 sandbox。 - 不把无密钥 `receiptHash` 或 Orca live metadata 当作旧终端的启动 attestation; 没有可信空闲状态、配置匹配和历史消息清理证明时不复用既有 worker。 - launcher 返回 `indeterminate` 或 `reconcile required` 时,不直接重试;先按 diff --git a/skills/ack/VERSION b/skills/ack/VERSION index a551051..7cca771 100644 --- a/skills/ack/VERSION +++ b/skills/ack/VERSION @@ -1 +1 @@ -0.15.0 +0.17.1 diff --git a/skills/ack/agents/openai.yaml b/skills/ack/agents/openai.yaml index 337c950..b91d034 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." + default_prompt: "Use $ack to initialize or check ACK, coordinate verified work, record how to deploy the test environment and publish a release in delivery.yaml, redeploy the test environment, or publish a version." policy: allow_implicit_invocation: false diff --git a/skills/ack/examples/delivery.example.yaml b/skills/ack/examples/delivery.example.yaml index f6f5c97..43439d0 100644 --- a/skills/ack/examples/delivery.example.yaml +++ b/skills/ack/examples/delivery.example.yaml @@ -6,6 +6,10 @@ project: enabled: true defaultProfile: "review" +intents: + testEnvironment: test-env + release: null + entrypoints: verify: kind: make @@ -100,6 +104,19 @@ environments: mutex: "notes-test-deploy" profiles: + test-env: + stopAt: validation_ready + steps: + - id: build-deb + action: build + artifact: service-deb + - id: deploy-test + action: deploy + artifact: service-deb + environment: test-server + - id: smoke-test + action: health-check + environment: test-server review: stopAt: review_ready steps: diff --git a/skills/ack/examples/project.example.md b/skills/ack/examples/project.example.md index 3e6136a..bbda81e 100644 --- a/skills/ack/examples/project.example.md +++ b/skills/ack/examples/project.example.md @@ -1,6 +1,6 @@ # notes-web Agent 协作协议(示例,项目覆盖层) -> 本项目基于 ack v0.15.0。 +> 本项目基于 ack v0.17.1。 > 通用规范由 `/ack` 从 Skill 自身的 `references/` 读取,本文件只填项目差异。 > 覆盖层文件放在 `docs/ack/project.md`,不占用 `AGENTS.md`。 > ACK 不会自动修改 `AGENTS.md`、`CLAUDE.md` 或其它 Agent 指令文件。 @@ -39,7 +39,8 @@ | Test | `codex-test-standard` | standard | | Developer 升级 | `codex-dev-strong` | strong | -本文件不保存或拼接 worker shell 命令。 +项目如改用 Cursor 或 Grok,应修改结构化 profile、allowlist 和 defaults,再运行 +任务板校验。本文件不保存或拼接 worker shell 命令。 ## 路径权限 diff --git a/skills/ack/examples/tasks.example.yaml b/skills/ack/examples/tasks.example.yaml index 298bb45..90bfec5 100644 --- a/skills/ack/examples/tasks.example.yaml +++ b/skills/ack/examples/tasks.example.yaml @@ -3,7 +3,7 @@ version: 1 updatedAt: "2026-07-06T09:40:00+08:00" source: "Coordinator (PM) Agent" -ackVersion: "0.15.0" +ackVersion: "0.17.1" project: name: "notes-web" baseUrl: "http://localhost:5173" @@ -27,6 +27,12 @@ project: standard: ["auto"] test: standard: ["auto"] + grok: + developer: + standard: ["grok-4.5"] + strong: ["grok-4.6"] + test: + standard: ["grok-4.5"] profiles: codex-dev-standard: role: "developer" @@ -63,6 +69,27 @@ project: model: "auto" reasoningEffort: null permissionMode: "workspace-write" + grok-dev-standard: + role: "developer" + cli: "grok" + tier: "standard" + model: "grok-4.5" + reasoningEffort: "medium" + permissionMode: "workspace-write" + grok-test-standard: + role: "test" + cli: "grok" + tier: "standard" + model: "grok-4.5" + reasoningEffort: "low" + permissionMode: "workspace-write" + grok-dev-strong: + role: "developer" + cli: "grok" + tier: "strong" + model: "grok-4.6" + reasoningEffort: "high" + permissionMode: "workspace-write" defaults: developer: "codex-dev-standard" test: "codex-test-standard" diff --git a/skills/ack/references/adoption-checklist.md b/skills/ack/references/adoption-checklist.md index 3ffc0f9..f42d4d8 100644 --- a/skills/ack/references/adoption-checklist.md +++ b/skills/ack/references/adoption-checklist.md @@ -48,6 +48,8 @@ ## 可选交付 - [ ] `delivery.yaml` 首次生成保持 `enabled: false`,没有根据 README/CI 自动启用。 +- [ ] 测试环境部署和版本发布都写在同一份 `delivery.yaml` 的 `intents` 中,没有第二份 + 操作文档。未说明的 intent 保持 `null`。 - [ ] entrypoint 只使用声明式工具 target 或仓库内无 symlink 的可执行脚本;没有 shell、自由 command、凭据值或环境变量值。 - [ ] artifact、destination、environment 和 profile 引用均通过 diff --git a/skills/ack/references/closed-loop.md b/skills/ack/references/closed-loop.md index 8693fda..66888ef 100644 --- a/skills/ack/references/closed-loop.md +++ b/skills/ack/references/closed-loop.md @@ -41,6 +41,8 @@ Coordinator 发现或读取 open 任务 -> dispatch 给 Developer(--to ) -> wait:Developer 的 worker_done / escalation(含 knowledgeApplied / knowledgeCandidates) -> writeback fixed_by_dev + -> 若 delivery.yaml intents.testEnvironment 已启用:Coordinator 先执行该 profile + 拉起待测服务,再派 Test;Test 不发明编译或启动命令 -> 为 Test 独立解析安全 profile;安全重置同角色空闲 worker,或重新 plan/launch fresh worker -> dispatch 给 Test(retesting) -> wait:Test 的 retest_result(含 knowledgeChecks 和 candidate 独立证据) @@ -265,7 +267,7 @@ frontendDir: worktreePath: ``` -如果开发在 `` 修复,但服务跑的是另一个 worktree,必须**停止并重启正确服务**后再测。长跑服务或静态前端尤其要确认加载的是最新构建产物。 +如果开发在 `` 修复,但服务跑的是另一个 worktree,必须**停止并重启正确服务**后再测。长跑服务或静态前端尤其要确认加载的是最新构建产物。若项目配置了 `intents.testEnvironment`,重启方式以该 profile 为准,不另写一套启动命令。 --- diff --git a/skills/ack/references/delivery.md b/skills/ack/references/delivery.md index 1976986..cc3087c 100644 --- a/skills/ack/references/delivery.md +++ b/skills/ack/references/delivery.md @@ -52,6 +52,31 @@ channel、environment 或 source revision 漂移时重新确认。 凭据系统或执行环境注入。复杂逻辑放在受版本控制的项目入口中。entrypoint 使用 argv 语义执行,不能拼成 `sh -c` 字符串。 +## 3.1 测试环境与发版写在同一份契约 + +`docs/ack/delivery.yaml` 是测试环境部署和版本发布的唯一文档。不要另写操作手册, +也不要把其中一项写进 `project.md`。用户用自然语言说明「怎么布测试环境」或 +「怎么发版」时,Coordinator 把两者都维护进这份文件的 `intents`、entrypoint、 +artifact、environment 和 profile。 + +```yaml +intents: + testEnvironment: local-binary # profile ID,或 null + release: null # profile ID,或 null +``` + +- `testEnvironment` 指向 `stopAt: validation_ready` 的 profile:build 产物、部署到 + development/staging、健康检查。用户说「重新布测试环境」「我要测试」时执行它; + 派发 Test 复测前,若该 intent 已配置且 `enabled: true`,Coordinator 也先执行它。 + 不要求当前有 `verified` 任务。Test 不对这个 intent 发明编译或启动命令。 +- `release` 指向 `stopAt: released` 的 profile。用户说「发布一个版本」时执行它。 + 口头「发版」不能代替 stable/production 的 `approval` 步骤。 +- 对应 intent 为 `null` 或交付未启用:停止,请用户说明怎么做,按「交付配置维护」 + 写入同一文件后再执行。不猜测 Makefile、镜像仓库或发布通道。 +- 用户触发的 intent 运行写入 `tasks.yaml.deliveryRuns`,`intent` 填 + `testEnvironment` 或 `release`,`taskIds` 可为空。绑定任务的常规交付 run 不填 + `intent`,仍只能引用 `verified` 任务。 + ## 4. 运行前检查 1. 从 `tasks.yaml.project.deliveryFile` 解析文件;未引用或 `enabled=false` 时保持旧 ACK diff --git a/skills/ack/references/init-new-project.md b/skills/ack/references/init-new-project.md index e5c0745..ab2e507 100644 --- a/skills/ack/references/init-new-project.md +++ b/skills/ack/references/init-new-project.md @@ -116,10 +116,11 @@ candidate 留在任务证据中,不会被派发。只有 Test 独立验证且 ## 初始化项目交付 -新项目的 `docs/ack/delivery.yaml` 保持 `enabled: false`、空能力表和空 profile。 -不要根据 README 或 CI 自动推断并启用发布/部署。用户用自然语言描述交付要求后, -Coordinator 按 `delivery.md` 维护声明式配置:工具 target 与仓库脚本分开引用,产物、 -目的地、环境和有序 profile 使用稳定 ID。配置中不保存 shell、环境变量值或凭据 +新项目的 `docs/ack/delivery.yaml` 保持 `enabled: false`、空能力表、空 profile,以及 +`intents.testEnvironment: null` 与 `intents.release: null`。 +不要根据 README 或 CI 自动推断并启用发布/部署。用户用自然语言说明测试环境或发版 +方式后,Coordinator 按 `delivery.md` 把两者都写入这一份契约:`intents` 指向对应 +profile,工具 target 与仓库脚本分开引用。配置中不保存 shell、环境变量值或凭据 正文;稳定发布和生产部署必须有显式 approval 步骤。 ## 校验 diff --git a/skills/ack/references/kickoff.md b/skills/ack/references/kickoff.md index cb3f530..fd5692f 100644 --- a/skills/ack/references/kickoff.md +++ b/skills/ack/references/kickoff.md @@ -37,7 +37,8 @@ Developer/Test profile;优先选择同一轮内角色/profile/worktree 匹配的空闲 worker, 只有历史消息已可信清理并取得新会话身份才复用,否则审阅 plan 后用 expected fingerprint 创建 fresh worker; - dispatch 开发 → worker_done → dispatch 测试独立复测 → 你读证据终检 → 回写 tasks.yaml; + dispatch 开发 → worker_done → 若 intents.testEnvironment 已启用则先拉起测试环境 → + dispatch 测试独立复测 → 你读证据终检 → 回写 tasks.yaml; 每个任务最多三轮有效产品复验,三轮不过记 leftover 并升级我复盘;环境失败单独 记录、恢复并告诉我下一步,不占产品复验轮次。 7. 所选任务都 verified 后,只有本次计划包含交付时才按 profile 顺序执行并写 @@ -154,7 +155,7 @@ worktree 走同一套 `plan` -> 带 expected fingerprint 的 `launch`。在调 task-create → dispatch 给 DEV → 等 worker_done → 每个角色先检查可安全重置的空闲 worker;不符合即通过 plan + expected fingerprint launch fresh worker → 每轮使用 Coordinator 分配的稳定 -A - → 回写 fixed_by_dev → dispatch 给 TEST 复测 → 等 retest_result + → 回写 fixed_by_dev → 若 intents.testEnvironment 已启用则先拉起测试环境 → dispatch 给 TEST 复测 → 等 retest_result → Developer 回 knowledgeApplied / knowledgeCandidates,Test 回 knowledgeChecks → 环境无法完成:记录 environmentIncidents,报告影响与用户下一步,恢复后重新复验(不计轮次) → Coordinator 读证据终检 → 过则 verified,产品失败则 failed_retest 再派 DEV(最多累计 3 轮) @@ -173,6 +174,9 @@ Coordinator 只内联本轮 `knowledgeRefs` 指向的少量知识,不要求 wo ## 第 5 步:可选交付 +用户说「重新布测试环境」或「发布一个版本」时,按 `delivery.md` §3.1 的 +`intents` 执行对应 profile,不另找文档。intent 为 null 时先做交付配置维护。 + 所选任务都由 Coordinator 标记为 `verified` 后,若用户确认的计划包含交付,按 `delivery.md` 执行所选 profile。启用交付时必须在计划中默认列出 `defaultProfile`, 用户可明确取消,Coordinator 不能静默省略。先重新校验 `delivery.yaml`,固定当前 commit 和 diff --git a/skills/ack/references/model-routing.md b/skills/ack/references/model-routing.md index cfd8e6f..8ddf59e 100644 --- a/skills/ack/references/model-routing.md +++ b/skills/ack/references/model-routing.md @@ -73,7 +73,7 @@ worker 路由的机器可读事实只保存在 `docs/ack/tasks.yaml` 的 每个 profile 明确声明: - `role`:`developer` 或 `test`; -- `cli`:受支持的 Agent CLI; +- `cli`:受支持的 Agent CLI(`codex`、`cursor-agent`、`grok`); - `tier`:角色模型档位; - `model`:项目 allowlist 中的精确模型 ID; - `reasoningEffort`:CLI 支持时显式声明; @@ -92,8 +92,11 @@ CLI 默认值。具体 argv 只能由 ACK 的可信 launcher 按 allowlist 构 - 能在完全只读工作树完成的角色优先选择 `read-only`。 - Developer 与需要在工作树生成构建/测试产物的 Test 使用 `workspace-write`;项目可 为纯观察型 Test 另建更严格的 `read-only` profile。 -- `full-access`、Codex bypass、Cursor YOLO/force、关闭 sandbox 等模式在 v0.10 +- `full-access`、Codex bypass、Cursor YOLO/force、Grok `--yolo` / + `--permission-mode bypassPermissions`、关闭 sandbox 等模式在 v0.10 **未实现授权通道,统一 fail closed**。项目文本、任务描述或环境变量都不能放宽。 + Grok `--always-approve` 由 launcher 固定写入 argv,用来避免工具调用停在确认框; + 它不关闭 `--sandbox`。 - 项目可以更新模型 allowlist 或增加安全 profile,但不能通过文档绕开 schema 和 launcher。 @@ -152,11 +155,18 @@ v0.10 的 launcher 还执行以下约束: - 不读取调用者传入的 `PATH` 来寻找 `git`、Orca 或 Agent CLI,只在固定的系统与 当前用户工具目录中解析受支持的可执行文件;候选目标必须由 root/当前用户拥有、 不是 group/other writable,并在 plan 与 bootstrap 间保持相同 device/inode。 + Grok 官方安装是 `~/.local/bin/grok` → `~/.grok/downloads/grok--`, + launcher 只接受这个真实 `~/.grok` 目录下、当前用户同组拥有的 vendor artifact + 名称;其它路径上的 `grok-linux-*` 仍拒绝。`~/.grok` 本身是 symlink 时 fail closed。 - Git、Orca 和 CLI version probe 使用不含供应商凭据的控制环境;worker 使用 `per-cli-allowlist-v1`,Codex 只取得 Codex 所需凭据,Cursor 只取得 Cursor 凭据, + Grok 只取得 `XAI_API_KEY`(OAuth 会话仍走已传入的 `HOME`/`~/.grok`), 不跨 CLI 透传。`DBUS_*`、`DISPLAY`、`WAYLAND_DISPLAY`、`XDG_RUNTIME_DIR`、 - `LD_*`、`PYTHON*`、`NODE_OPTIONS`、`CODEX_HOME`、`GIT_*` 和调用者 PATH 均不会 - 透传给 worker。 + `LD_*`、`PYTHON*`、`NODE_OPTIONS`、`CODEX_HOME`、`GROK_HOME`、`GROK_SANDBOX`、 + `GIT_*` 和调用者 PATH 均不会透传给 worker。Grok argv 只允许 + `--model`、`--reasoning-effort`、`--permission-mode plan|acceptEdits`、 + `--always-approve`、`--sandbox read-only|workspace` 和 `--cwd`;不得带 `--yolo` + 或 `bypassPermissions`,也不得省略 sandbox。 - worktree 除了命中项目 allowlist、属于同一 Git common-dir,还必须精确出现在 `git worktree list --porcelain -z` 中;`.git` symlink 冒充的目录会失败。 - Orca 的 `--command` 只含固定 bootstrap 与随机 launch ID。终端创建后,父进程先 diff --git a/skills/ack/references/prompt-templates.md b/skills/ack/references/prompt-templates.md index 0347465..0855b7e 100644 --- a/skills/ack/references/prompt-templates.md +++ b/skills/ack/references/prompt-templates.md @@ -128,7 +128,7 @@ Developer 本轮声称(仅供参考,不作数): - : 复测要求(见 roles-and-permissions.md §三角色能力清单 · Test): -- 先对齐运行环境(pwd / 分支 / commit / 服务 worktree,见 closed-loop.md),避免测错实例或旧构建;网站类先确认服务已按新代码重启。 +- 先对齐运行环境(pwd / 分支 / commit / 服务 worktree,见 closed-loop.md),避免测错实例或旧构建。测试环境由 Coordinator 按 `delivery.yaml` 的 `intents.testEnvironment` 拉起;不要自行发明编译或启动命令。网站类确认 Base URL 已指向这次产物后再测。 - 网站类任务优先用浏览器复测真实交互,其次才是 API / 脚本。 - 逐条验证下列验收信号,不要只看静态文案,要验证交互后的真实状态: 1. diff --git a/skills/ack/scripts/launch_worker.py b/skills/ack/scripts/launch_worker.py index ede1ba3..b2e1e3f 100755 --- a/skills/ack/scripts/launch_worker.py +++ b/skills/ack/scripts/launch_worker.py @@ -39,8 +39,10 @@ if str(SCRIPT_DIR) not in sys.path: from validate_tasks import load_document, validate_builtin # noqa: E402 from worker_profiles import ( # noqa: E402 + GROK_EXECUTABLE_NAME_RE, LAUNCH_PROTOCOL_VERSION, canonical_sha256, + executable_basename_matches_cli, profile_hash, render_worker_argv, validate_routing_document, @@ -81,6 +83,12 @@ WORKER_ENVIRONMENT_NAMES = CONTROL_ENVIRONMENT_NAMES | { WORKER_CREDENTIAL_NAMES = { "codex": frozenset({"AZURE_OPENAI_API_KEY", "OPENAI_API_KEY"}), "cursor-agent": frozenset({"CURSOR_API_KEY"}), + "grok": frozenset({"XAI_API_KEY"}), +} +CLI_TITLE_LABELS = { + "codex": "CODEX", + "cursor-agent": "CURSOR", + "grok": "GROK", } INHERITED_ENVIRONMENT_PREFIXES = ( "LC_", @@ -263,8 +271,41 @@ def load_authoritative_board(project_root_value: str) -> tuple[Path, dict]: return project_root, board +def _is_under_real_grok_home(resolved: Path) -> bool: + """Return True when ``resolved`` is under a non-symlink ``$HOME/.grok``.""" + + home, _ = account_identity() + grok_root = home / ".grok" + try: + root_metadata = os.lstat(grok_root) + if stat.S_ISLNK(root_metadata.st_mode) or not stat.S_ISDIR(root_metadata.st_mode): + return False + grok_root.resolve(strict=True) + resolved.relative_to(grok_root.resolve(strict=True)) + except (OSError, ValueError): + return False + return True + + +def _is_trusted_grok_executable(resolved: Path, metadata: os.stat_result) -> bool: + """Accept Grok's vendor artifact names under ``~/.grok``, or a 0755 ``grok``.""" + + if GROK_EXECUTABLE_NAME_RE.fullmatch(resolved.name) is None: + return False + if not executable_basename_matches_cli(str(resolved), "grok"): + return False + mode = stat.S_IMODE(metadata.st_mode) + if mode & 0o002: + return False + if resolved.name == "grok" and not (mode & 0o020): + return True + if not _is_under_real_grok_home(resolved): + return False + return metadata.st_uid == os.getuid() and metadata.st_gid == os.getgid() + + def resolve_executable(name: str) -> Path: - supported = {"codex", "cursor-agent", "git", "orca"} + supported = {"codex", "cursor-agent", "grok", "git", "orca"} if name not in supported: raise LaunchError(f"不支持的可执行文件: {name}") search_paths = trusted_path_entries() @@ -289,6 +330,10 @@ def resolve_executable(name: str) -> Path: continue if metadata.st_uid not in {0, os.getuid()}: continue + if name == "grok": + if _is_trusted_grok_executable(resolved, metadata): + return resolved + continue if stat.S_IMODE(metadata.st_mode) & 0o022: continue if resolved.name != name: @@ -697,7 +742,10 @@ def build_plan( "slot": slot, } ) - cli_label = "CODEX" if profile["cli"] == "codex" else "CURSOR" + try: + cli_label = CLI_TITLE_LABELS[str(profile["cli"])] + except KeyError as exc: + raise LaunchError(f"不支持的 worker CLI: {profile['cli']}") from exc role_label = "DEV" if role == "developer" else "TEST" digest_short = launch_fingerprint.split(":", 1)[-1][:10] title = ( diff --git a/skills/ack/scripts/validate_delivery.py b/skills/ack/scripts/validate_delivery.py index dab32fb..2b5454c 100755 --- a/skills/ack/scripts/validate_delivery.py +++ b/skills/ack/scripts/validate_delivery.py @@ -43,6 +43,7 @@ TOP_LEVEL_FIELDS = { "project", "enabled", "defaultProfile", + "intents", "entrypoints", "artifacts", "destinations", @@ -99,6 +100,11 @@ CHANNELS = {"preview", "staging", "stable"} ENVIRONMENT_TYPES = {"ssh-host", "docker-compose", "kubernetes", "custom"} CLASSIFICATIONS = {"development", "staging", "production"} STOP_POINTS = {"verified", "validation_ready", "review_ready", "released"} +INTENT_FIELDS = {"testEnvironment", "release"} +INTENT_STOP_AT = { + "testEnvironment": "validation_ready", + "release": "released", +} ACTIONS = { "verify", "pull-request", @@ -675,6 +681,38 @@ def _validate_profiles( errors.append(f"{where}: defaultProfile 不能部署 production 环境") +def _validate_intents( + values: Any, + profiles: dict[str, Any], + errors: list[str], +) -> None: + if values is None: + return + if not _mapping(values): + errors.append("intents: 必须是对象") + return + _reject_unknown(values, INTENT_FIELDS, "intents", errors) + for field in sorted(INTENT_FIELDS): + if field not in values: + errors.append(f"intents.{field}: 必填") + continue + profile_id = values[field] + if profile_id is None: + continue + if not isinstance(profile_id, str) or ID_RE.fullmatch(profile_id) is None: + errors.append(f"intents.{field}: 必须是 null 或小写连字符 profile ID") + continue + profile = profiles.get(profile_id) + if profile is None: + errors.append(f"intents.{field}: 未定义 profile {profile_id!r}") + continue + expected_stop = INTENT_STOP_AT[field] + if _mapping(profile) and profile.get("stopAt") != expected_stop: + errors.append( + f"intents.{field}: profile {profile_id!r} 必须 stopAt {expected_stop}" + ) + + def validate_builtin(data: dict[str, Any], project_root: Path | None = None) -> list[str]: errors: list[str] = [] _reject_unknown(data, TOP_LEVEL_FIELDS, "", errors) @@ -720,6 +758,7 @@ def validate_builtin(data: dict[str, Any], project_root: Path | None = None) -> environments=environments, errors=errors, ) + _validate_intents(data.get("intents"), profiles, errors) if enabled: if default_profile not in profiles: diff --git a/skills/ack/scripts/validate_tasks.py b/skills/ack/scripts/validate_tasks.py index 6b64b6e..08c2a20 100755 --- a/skills/ack/scripts/validate_tasks.py +++ b/skills/ack/scripts/validate_tasks.py @@ -103,6 +103,8 @@ DELIVERY_RUN_FIELDS = { "evidence", "updatedAt", } +DELIVERY_RUN_OPTIONAL_FIELDS = {"intent"} +DELIVERY_RUN_INTENTS = {"testEnvironment", "release"} DELIVERY_STATUSES = { "planned", "running", @@ -481,7 +483,9 @@ def validate_delivery_runs( if not isinstance(run, dict): errors.append(f"{where}: 必须是对象") continue - reject_unknown_fields(run, DELIVERY_RUN_FIELDS, where, errors) + reject_unknown_fields( + run, DELIVERY_RUN_FIELDS | DELIVERY_RUN_OPTIONAL_FIELDS, where, errors + ) missing = sorted(DELIVERY_RUN_FIELDS - set(run)) for field in missing: errors.append(f"{where}.{field}: 必填") @@ -501,13 +505,23 @@ def validate_delivery_runs( if status not in DELIVERY_STATUSES: errors.append(f"{where}.status: 必须是 {sorted(DELIVERY_STATUSES)}") + intent = run.get("intent") + if "intent" in run and intent not in DELIVERY_RUN_INTENTS: + errors.append( + f"{where}.intent: 必须是 {sorted(DELIVERY_RUN_INTENTS)}" + ) task_ids = run.get("taskIds") + allow_empty_tasks = intent in DELIVERY_RUN_INTENTS if ( not isinstance(task_ids, list) - or not task_ids - or any(not _nonempty_string(task_id) for task_id in task_ids) + or (not task_ids and not allow_empty_tasks) + or any(not _nonempty_string(task_id) for task_id in (task_ids or [])) ): - errors.append(f"{where}.taskIds: 必须是非空任务 ID 列表") + errors.append( + f"{where}.taskIds: 必须是任务 ID 列表" + if allow_empty_tasks + else f"{where}.taskIds: 必须是非空任务 ID 列表" + ) task_ids = [] elif len(task_ids) != len(set(task_ids)): errors.append(f"{where}.taskIds: 不能包含重复值") diff --git a/skills/ack/scripts/worker_profiles.py b/skills/ack/scripts/worker_profiles.py index fc261df..4f37398 100644 --- a/skills/ack/scripts/worker_profiles.py +++ b/skills/ack/scripts/worker_profiles.py @@ -24,12 +24,27 @@ LAUNCH_PROTOCOL_VERSION = 1 MAX_ROUNDS = 3 ROLES = frozenset({"developer", "test"}) -CLIS = frozenset({"codex", "cursor-agent"}) +CLIS = frozenset({"codex", "cursor-agent", "grok"}) 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"}) +CLI_REQUIRES_REASONING_EFFORT = frozenset({"codex", "grok"}) +CLI_REQUIRES_NULL_REASONING_EFFORT = frozenset({"cursor-agent"}) + + +def _cli_choice_text() -> str: + return "/".join(sorted(CLIS)) + + +def executable_basename_matches_cli(executable: str, cli: str) -> bool: + """Return whether a resolved executable basename is valid for ``cli``.""" + + name = Path(executable).name + if name == cli: + return True + return cli == "grok" and GROK_EXECUTABLE_NAME_RE.fullmatch(name) is not None ORCHESTRATION_FIELDS = frozenset( { @@ -114,6 +129,9 @@ ROLE_DISPATCH_FIELDS = frozenset( PROFILE_ID_RE = re.compile(r"^[a-z][a-z0-9-]{1,63}$") MODEL_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:/+@-]{0,127}$") +GROK_EXECUTABLE_NAME_RE = re.compile( + r"^grok(?:-(?:linux|darwin|windows)-(?:x86_64|aarch64|arm64))?$" +) RECEIPT_ID_RE = re.compile(r"^WR-[0-9a-f]{64}$") LAUNCH_ID_RE = re.compile(r"^[0-9a-f]{64}$") TASK_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$") @@ -214,7 +232,7 @@ def validate_profile(profile: Any, *, where: str = "profile") -> list[str]: if not isinstance(role, str) or role not in ROLES: errors.append(f"{where}.role: must be developer/test") if not isinstance(cli, str) or cli not in CLIS: - errors.append(f"{where}.cli: must be codex/cursor-agent") + 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: @@ -223,12 +241,17 @@ def validate_profile(profile: Any, *, where: str = "profile") -> list[str]: errors.append( f"{where}.permissionMode: must be read-only/workspace-write" ) - if cli == "codex": + if isinstance(cli, str) and cli in CLI_REQUIRES_REASONING_EFFORT: if not isinstance(effort, str) or effort not in REASONING_EFFORTS: + label = "Codex" if cli == "codex" else "Grok" errors.append( - f"{where}.reasoningEffort: Codex requires low/medium/high/xhigh" + f"{where}.reasoningEffort: {label} requires low/medium/high/xhigh" ) - elif cli == "cursor-agent" and effort is not None: + elif ( + isinstance(cli, str) + and cli in CLI_REQUIRES_NULL_REASONING_EFFORT + and effort is not None + ): errors.append(f"{where}.reasoningEffort: Cursor requires null") if role == "test" and tier != "standard": @@ -446,14 +469,15 @@ def render_worker_argv( raise ValueError("invalid profile: " + "; ".join(errors)) if not _is_absolute_safe_path(executable): raise ValueError("executable must be a safe absolute path other than root") - if Path(executable).name != profile["cli"]: + if not executable_basename_matches_cli(executable, profile["cli"]): raise ValueError("executable basename must match profile.cli") if not _is_absolute_safe_path(worktree): raise ValueError("worktree must be a safe absolute path other than root") model = profile["model"] permission = profile["permissionMode"] - if profile["cli"] == "codex": + cli = profile["cli"] + if cli == "codex": return [ executable, "--strict-config", @@ -469,13 +493,33 @@ def render_worker_argv( worktree, ] - argv = [executable, "--model", model] - if permission == "read-only": - argv.extend(["--mode", "plan"]) - else: - argv.append("--auto-review") - argv.extend(["--sandbox", "enabled", "--workspace", worktree]) - return argv + if cli == "cursor-agent": + argv = [executable, "--model", model] + if permission == "read-only": + argv.extend(["--mode", "plan"]) + else: + argv.append("--auto-review") + argv.extend(["--sandbox", "enabled", "--workspace", worktree]) + return argv + + if cli != "grok": + raise ValueError(f"unsupported cli: {cli}") + grok_permission = "plan" if permission == "read-only" else "acceptEdits" + grok_sandbox = "read-only" if permission == "read-only" else "workspace" + return [ + executable, + "--model", + model, + "--reasoning-effort", + profile["reasoningEffort"], + "--permission-mode", + grok_permission, + "--always-approve", + "--sandbox", + grok_sandbox, + "--cwd", + worktree, + ] def receipt_hash(receipt: dict[str, Any]) -> str: @@ -532,25 +576,34 @@ def _validate_requested(value: Any, where: str) -> list[str]: tier = value.get("tier") permission = value.get("permissionMode") if not isinstance(cli, str) or cli not in CLIS: - errors.append(f"{where}.cli: must be codex/cursor-agent") + 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") 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") effort = value.get("reasoningEffort") - if cli == "codex" and ( + if isinstance(cli, str) and cli in CLI_REQUIRES_REASONING_EFFORT and ( not isinstance(effort, str) or effort not in REASONING_EFFORTS ): - errors.append(f"{where}.reasoningEffort: invalid Codex effort") - if cli == "cursor-agent" and effort is not None: + label = "Codex" if cli == "codex" else "Grok" + errors.append(f"{where}.reasoningEffort: invalid {label} effort") + if ( + isinstance(cli, str) + and cli in CLI_REQUIRES_NULL_REASONING_EFFORT + and effort is not None + ): errors.append(f"{where}.reasoningEffort: Cursor requires null") 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") if not _is_absolute_safe_path(executable): errors.append(f"{where}.executable: must be a safe absolute path") - elif isinstance(cli, str) and cli in CLIS and Path(executable).name != cli: + elif ( + isinstance(cli, str) + and cli in CLIS + and not executable_basename_matches_cli(str(executable), cli) + ): errors.append(f"{where}.executable: basename must match cli") if not _is_nonnegative_int(value.get("executableDevice")): errors.append(f"{where}.executableDevice: must be a non-negative integer") @@ -995,6 +1048,7 @@ __all__ = [ "profile_hash", "receipt_hash", "render_worker_argv", + "executable_basename_matches_cli", "validate_profile", "validate_orchestration", "validate_worker_receipt", diff --git a/skills/ack/templates/delivery.schema.json b/skills/ack/templates/delivery.schema.json index 138a704..3cef5c4 100644 --- a/skills/ack/templates/delivery.schema.json +++ b/skills/ack/templates/delivery.schema.json @@ -32,6 +32,7 @@ "type": ["string", "null"], "pattern": "^[a-z][a-z0-9-]{0,63}$" }, + "intents": { "$ref": "#/definitions/intents" }, "entrypoints": { "type": "object", "propertyNames": { "$ref": "#/definitions/id" }, @@ -63,6 +64,21 @@ "type": "string", "pattern": "^[a-z][a-z0-9-]{0,63}$" }, + "intents": { + "type": "object", + "required": ["testEnvironment", "release"], + "additionalProperties": false, + "properties": { + "testEnvironment": { + "type": ["string", "null"], + "pattern": "^[a-z][a-z0-9-]{0,63}$" + }, + "release": { + "type": ["string", "null"], + "pattern": "^[a-z][a-z0-9-]{0,63}$" + } + } + }, "relativePath": { "type": "string", "minLength": 1, diff --git a/skills/ack/templates/delivery.template.yaml b/skills/ack/templates/delivery.template.yaml index 80ced57..80e4378 100644 --- a/skills/ack/templates/delivery.template.yaml +++ b/skills/ack/templates/delivery.template.yaml @@ -7,6 +7,11 @@ project: enabled: false defaultProfile: null +# 测试环境部署和版本发布都写在本文件。null 表示用户尚未说明该操作。 +intents: + testEnvironment: null + release: null + # 所有可执行入口都使用结构化 argv 语义;requiredSecrets 只保存名称,不保存值。 # 不在此保存 shell、env 或凭据正文。 entrypoints: {} diff --git a/skills/ack/templates/project.template.md b/skills/ack/templates/project.template.md index ed4719c..c631940 100644 --- a/skills/ack/templates/project.template.md +++ b/skills/ack/templates/project.template.md @@ -48,7 +48,7 @@ receipt 全部以 `docs/ack/tasks.yaml` 的 `project.orchestration` 与顶层 | Test | `codex-test-standard` | standard | | Developer 升级 | `codex-dev-strong` | strong | -项目如改用 Cursor,应修改结构化 profile、allowlist 和 defaults,再运行任务板 +项目如改用 Cursor 或 Grok,应修改结构化 profile、allowlist 和 defaults,再运行任务板 校验;不能在这里粘贴 `orca --command`、Agent CLI 参数或自由 shell。 每次自动派发必须先审阅 launcher plan,再把其中的 `launchFingerprint` 作为 `--expected-launch-fingerprint` 启动 fresh worker。`receiptHash` 只作审计 checksum, @@ -79,7 +79,7 @@ Developer 白盒验证: ``` -Test 黑盒复测: +Test 黑盒复测(服务启动以 `delivery.yaml` 的 `intents.testEnvironment` 为准): ```bash @@ -97,7 +97,8 @@ Skill 的 `scripts/run_verification.py` 执行,不直接拼接 path/args。检 项目状态校验由 `/ack` 使用 Skill 自带的 `scripts/validate_tasks.py`、 `scripts/validate_knowledge.py` 和 `scripts/validate_delivery.py` 执行。 -构建/发布/部署的机器入口以 `delivery.yaml` 为准;本文件不维护第二套交付命令。 +构建、测试环境部署和版本发布的机器入口以 `delivery.yaml` 的 `intents` 为准; +本文件不维护第二套交付命令。 ## 硬规则(其余见 references/) @@ -118,8 +119,9 @@ Skill 的 `scripts/run_verification.py` 执行,不直接拼接 path/args。检 - Developer 回报 `knowledgeApplied` 与 `knowledgeCandidates`,Test 回报 `knowledgeChecks`。关键约束应下沉为测试、lint、CI 或正式规范。 - ACK 不自动修改 `AGENTS.md`、`CLAUDE.md` 或其它 Agent 指令文件。 -- `delivery.yaml` 默认关闭,只描述能力,不自动授权提交、推送、发布或部署;交付仅在 - 任务 `verified` 且本次 profile/目标/停止点得到确认后运行。 +- `delivery.yaml` 默认关闭,只描述能力,不自动授权提交、推送、发布或部署。测试环境 + 与发版都写在这份契约的 `intents` 里;用户明确要求重新部署测试环境或发布版本时 + 才执行对应 intent。常规交付仍在任务 `verified` 且本次 profile 得到确认后运行。 - 默认交付 profile 最多到 `validation_ready` 或 `review_ready`;stable 发布或 production 部署必须有 approval 步骤并再次获得明确批准。配置变更只影响下一次 run。 - 每个任务最多派发 3 轮,仍不过标记 `leftover` 并继续下一个。 diff --git a/skills/ack/templates/tasks.schema.json b/skills/ack/templates/tasks.schema.json index 3ee2988..8c485ca 100644 --- a/skills/ack/templates/tasks.schema.json +++ b/skills/ack/templates/tasks.schema.json @@ -338,7 +338,8 @@ "additionalProperties": false, "properties": { "codex": { "$ref": "#/definitions/modelRoleAllowlist" }, - "cursor-agent": { "$ref": "#/definitions/modelRoleAllowlist" } + "cursor-agent": { "$ref": "#/definitions/modelRoleAllowlist" }, + "grok": { "$ref": "#/definitions/modelRoleAllowlist" } } }, "workerProfile": { @@ -359,7 +360,7 @@ }, "cli": { "type": "string", - "enum": ["codex", "cursor-agent"] + "enum": ["codex", "cursor-agent", "grok"] }, "tier": { "type": "string", @@ -517,7 +518,7 @@ "properties": { "cli": { "type": "string", - "enum": ["codex", "cursor-agent"] + "enum": ["codex", "cursor-agent", "grok"] }, "tier": { "type": "string", @@ -989,9 +990,14 @@ "type": "string", "pattern": "^[a-z][a-z0-9-]{0,63}$" }, + "intent": { + "type": "string", + "enum": ["testEnvironment", "release"], + "description": "用户触发的测试环境或发版操作;有 intent 时 taskIds 可为 []" + }, "taskIds": { "type": "array", - "minItems": 1, + "minItems": 0, "uniqueItems": true, "items": { "type": "string", "minLength": 1 } }, diff --git a/skills/ack/templates/tasks.template.yaml b/skills/ack/templates/tasks.template.yaml index 6fbdf84..13da651 100644 --- a/skills/ack/templates/tasks.template.yaml +++ b/skills/ack/templates/tasks.template.yaml @@ -44,6 +44,12 @@ project: standard: ["auto"] test: standard: ["auto"] + grok: + developer: + standard: ["grok-4.5"] + strong: ["grok-4.6"] + test: + standard: ["grok-4.5"] profiles: codex-dev-standard: role: "developer" @@ -80,6 +86,27 @@ project: model: "auto" reasoningEffort: null permissionMode: "workspace-write" + grok-dev-standard: + role: "developer" + cli: "grok" + tier: "standard" + model: "grok-4.5" + reasoningEffort: "medium" + permissionMode: "workspace-write" + grok-test-standard: + role: "test" + cli: "grok" + tier: "standard" + model: "grok-4.5" + reasoningEffort: "low" + permissionMode: "workspace-write" + grok-dev-strong: + role: "developer" + cli: "grok" + tier: "strong" + model: "grok-4.6" + reasoningEffort: "high" + permissionMode: "workspace-write" defaults: developer: "codex-dev-standard" test: "codex-test-standard" diff --git a/tests/test_ack_delivery.py b/tests/test_ack_delivery.py index f5dcd6e..6a97fc0 100644 --- a/tests/test_ack_delivery.py +++ b/tests/test_ack_delivery.py @@ -303,6 +303,7 @@ class AckDeliveryValidationTests(unittest.TestCase): { "enabled": False, "defaultProfile": None, + "intents": {"testEnvironment": None, "release": None}, "entrypoints": {}, "artifacts": {}, "destinations": {}, @@ -313,6 +314,42 @@ class AckDeliveryValidationTests(unittest.TestCase): self.assertEqual(validate_delivery.validate_builtin(contract), []) + def test_intents_must_point_at_matching_stop_points(self) -> None: + contract = valid_contract() + contract["intents"] = { + "testEnvironment": "review", + "release": None, + } + + errors = validate_delivery.validate_builtin(contract) + self.assertTrue( + any("intents.testEnvironment" in item and "validation_ready" in item for item in errors) + ) + + contract["intents"]["testEnvironment"] = "local-validation" + contract["profiles"]["local-validation"] = { + "stopAt": "validation_ready", + "steps": [ + {"id": "build-local", "action": "build", "artifact": "service-deb"}, + { + "id": "deploy-local", + "action": "deploy", + "artifact": "service-deb", + "environment": "test-server", + }, + { + "id": "health-local", + "action": "health-check", + "environment": "test-server", + }, + ], + } + self.assertEqual(validate_delivery.validate_builtin(contract), []) + + contract["intents"]["release"] = "missing-release" + errors = validate_delivery.validate_builtin(contract) + self.assertTrue(any("未定义 profile 'missing-release'" in item for item in errors)) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_ack_launch_worker.py b/tests/test_ack_launch_worker.py index 95aceab..9786eb5 100644 --- a/tests/test_ack_launch_worker.py +++ b/tests/test_ack_launch_worker.py @@ -324,6 +324,9 @@ class EnvironmentAndExecutableTests(unittest.TestCase): "OPENAI_API_KEY": "test-token", "AZURE_OPENAI_API_KEY": "azure-token", "CURSOR_API_KEY": "cursor-token", + "XAI_API_KEY": "xai-token", + "GROK_HOME": "/tmp/evil-grok", + "GROK_SANDBOX": "off", "ANTHROPIC_API_KEY": "anthropic-token", "DBUS_SESSION_BUS_ADDRESS": "unix:path=/tmp/dbus", "DISPLAY": ":99", @@ -336,17 +339,24 @@ class EnvironmentAndExecutableTests(unittest.TestCase): control = launch_worker.control_environment() codex = launch_worker.worker_environment("codex") cursor = launch_worker.worker_environment("cursor-agent") + grok = launch_worker.worker_environment("grok") - for environment in (control, codex, cursor): + for environment in (control, codex, cursor, grok): self.assertNotIn(hostile_path, environment["PATH"].split(os.pathsep)) self.assertEqual(environment["LANG"], "C.UTF-8") self.assertNotIn("OPENAI_API_KEY", control) self.assertNotIn("CURSOR_API_KEY", control) + self.assertNotIn("XAI_API_KEY", control) self.assertEqual(codex["OPENAI_API_KEY"], "test-token") self.assertEqual(codex["AZURE_OPENAI_API_KEY"], "azure-token") self.assertNotIn("CURSOR_API_KEY", codex) + self.assertNotIn("XAI_API_KEY", codex) self.assertEqual(cursor["CURSOR_API_KEY"], "cursor-token") self.assertNotIn("OPENAI_API_KEY", cursor) + self.assertNotIn("XAI_API_KEY", cursor) + self.assertEqual(grok["XAI_API_KEY"], "xai-token") + self.assertNotIn("OPENAI_API_KEY", grok) + self.assertNotIn("CURSOR_API_KEY", grok) for forbidden in ( "ANTHROPIC_API_KEY", "DBUS_SESSION_BUS_ADDRESS", @@ -356,11 +366,13 @@ class EnvironmentAndExecutableTests(unittest.TestCase): "NODE_OPTIONS", "PYTHONPATH", "CODEX_HOME", + "GROK_HOME", + "GROK_SANDBOX", "GIT_SSH_COMMAND", "WAYLAND_DISPLAY", "XDG_RUNTIME_DIR", ): - for environment in (control, codex, cursor): + for environment in (control, codex, cursor, grok): self.assertNotIn(forbidden, environment) def test_executable_resolution_ignores_hostile_path(self) -> None: @@ -373,6 +385,51 @@ class EnvironmentAndExecutableTests(unittest.TestCase): self.assertEqual(resolved, Path("/usr/bin/git")) + def test_grok_vendor_layout_is_trusted_and_outside_artifacts_are_not(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + home = Path(temporary) / "home" + downloads = home / ".grok" / "downloads" + downloads.mkdir(parents=True) + artifact = downloads / "grok-linux-x86_64" + artifact.write_text("#!/bin/sh\n", encoding="utf-8") + artifact.chmod(0o775) + bindir = home / ".local" / "bin" + bindir.mkdir(parents=True) + (bindir / "grok").symlink_to(artifact) + + hostile_dir = Path(temporary) / "tmp" + hostile_dir.mkdir() + hostile = hostile_dir / "grok-linux-x86_64" + hostile.write_text("#!/bin/sh\n", encoding="utf-8") + hostile.chmod(0o775) + hostile_bin = Path(temporary) / "hostile-bin" + hostile_bin.mkdir() + (hostile_bin / "grok").symlink_to(hostile) + + with mock.patch.object( + launch_worker, + "account_identity", + return_value=(home.resolve(strict=True), "ace"), + ), mock.patch.object( + launch_worker, + "trusted_path_entries", + return_value=[bindir.resolve(strict=True)], + ): + resolved = launch_worker.resolve_executable("grok") + self.assertEqual(resolved, artifact.resolve(strict=True)) + + with mock.patch.object( + launch_worker, + "account_identity", + return_value=(home.resolve(strict=True), "ace"), + ), mock.patch.object( + launch_worker, + "trusted_path_entries", + return_value=[hostile_bin.resolve(strict=True)], + ): + with self.assertRaises(launch_worker.LaunchError): + launch_worker.resolve_executable("grok") + class PlanTests(unittest.TestCase): def test_authoritative_board_is_derived_from_project_root_without_repo_path(self) -> None: diff --git a/tests/test_ack_skill.py b/tests/test_ack_skill.py index 13dbfd3..0b34c9c 100644 --- a/tests/test_ack_skill.py +++ b/tests/test_ack_skill.py @@ -27,6 +27,9 @@ class AckSkillContentTests(unittest.TestCase): "references/kickoff.md", "不要修改项目的 `AGENTS.md`", "当前会话担任 Coordinator", + "intents.testEnvironment", + "运行测试环境", + "运行版本发布", ): self.assertIn(expected, content) @@ -128,7 +131,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.15.0") + self.assertEqual(version, "0.17.1") self.assertIn( f'ackVersion: "{version}"', (ack_dir / "examples" / "tasks.example.yaml").read_text(encoding="utf-8"), diff --git a/tests/test_ack_tasks_validation.py b/tests/test_ack_tasks_validation.py index 62079e6..213fbc7 100644 --- a/tests/test_ack_tasks_validation.py +++ b/tests/test_ack_tasks_validation.py @@ -438,6 +438,47 @@ class AckTaskValidationTests(unittest.TestCase): self.assert_board_accepted_in_all_modes(board) + def test_intent_delivery_run_allows_empty_task_ids(self) -> None: + board = valid_manual_routing_board() + board["project"]["deliveryFile"] = "docs/ack/delivery.yaml" + board["deliveryRuns"] = [ + { + "id": "DR-test-env-1", + "profile": "test-local", + "intent": "testEnvironment", + "taskIds": [], + "status": "validation_ready", + "sourceRevision": "a" * 40, + "configRevision": "b" * 40, + "pullRequest": None, + "artifacts": [ + { + "id": "garden-bin", + "type": "file", + "reference": "garden", + "digest": "sha256:" + "c" * 64, + } + ], + "deployments": [ + { + "environment": "local-write", + "result": "succeeded", + "evidence": "GET /login returned 200", + } + ], + "evidence": ["http://write.localhost:8080/ ready"], + "updatedAt": "2026-08-23T00:57:00+08:00", + } + ] + + self.assert_board_accepted_in_all_modes(board) + + del board["deliveryRuns"][0]["intent"] + self.assert_board_rejected_in_all_modes( + board, + "taskIds: 必须是非空任务 ID 列表", + ) + def test_delivery_runs_and_delivery_file_must_appear_together(self) -> None: board = valid_manual_routing_board() board["deliveryRuns"] = [] diff --git a/tests/test_ack_worker_profiles.py b/tests/test_ack_worker_profiles.py index c75c2bd..3ac3803 100644 --- a/tests/test_ack_worker_profiles.py +++ b/tests/test_ack_worker_profiles.py @@ -30,6 +30,13 @@ def valid_orchestration() -> dict: "developer": {"standard": ["cursor-auto"]}, "test": {"standard": ["cursor-auto"]}, }, + "grok": { + "developer": { + "standard": ["grok-4.5"], + "strong": ["grok-4.6"], + }, + "test": {"standard": ["grok-4.5"]}, + }, }, "profiles": { "codex-dev-standard": { @@ -64,6 +71,30 @@ def valid_orchestration() -> dict: "reasoningEffort": None, "permissionMode": "workspace-write", }, + "grok-dev-standard": { + "role": "developer", + "cli": "grok", + "tier": "standard", + "model": "grok-4.5", + "reasoningEffort": "medium", + "permissionMode": "workspace-write", + }, + "grok-dev-strong": { + "role": "developer", + "cli": "grok", + "tier": "strong", + "model": "grok-4.6", + "reasoningEffort": "high", + "permissionMode": "workspace-write", + }, + "grok-test-readonly": { + "role": "test", + "cli": "grok", + "tier": "standard", + "model": "grok-4.5", + "reasoningEffort": "low", + "permissionMode": "read-only", + }, }, "defaults": { "developer": "codex-dev-standard", @@ -278,12 +309,16 @@ class ProfileValidationTests(unittest.TestCase): codex["profiles"]["codex-dev-standard"]["reasoningEffort"] = None cursor = valid_orchestration() cursor["profiles"]["cursor-test-standard"]["reasoningEffort"] = "low" + grok = valid_orchestration() + grok["profiles"]["grok-dev-standard"]["reasoningEffort"] = None codex_errors = worker_profiles.validate_orchestration(codex) cursor_errors = worker_profiles.validate_orchestration(cursor) + grok_errors = worker_profiles.validate_orchestration(grok) self.assertTrue(any("Codex requires" in error for error in codex_errors)) self.assertTrue(any("Cursor requires null" in error for error in cursor_errors)) + self.assertTrue(any("Grok requires" in error for error in grok_errors)) def test_test_cannot_use_strong_tier(self) -> None: routing = valid_orchestration() @@ -444,6 +479,86 @@ class ArgvRendererTests(unittest.TestCase): self.assertNotIn("--yolo", argv) self.assertNotIn("--force", argv) + def test_grok_workspace_write_exact_safe_argv(self) -> None: + profile = valid_orchestration()["profiles"]["grok-dev-standard"] + + argv = worker_profiles.render_worker_argv( + profile, + "/usr/local/bin/grok", + "/repo/demo", + ) + + self.assertEqual( + argv, + [ + "/usr/local/bin/grok", + "--model", + "grok-4.5", + "--reasoning-effort", + "medium", + "--permission-mode", + "acceptEdits", + "--always-approve", + "--sandbox", + "workspace", + "--cwd", + "/repo/demo", + ], + ) + for forbidden in ( + "--yolo", + "bypassPermissions", + "auto", + "dontAsk", + "off", + ): + self.assertNotIn(forbidden, argv) + + def test_grok_read_only_uses_plan_and_read_only_sandbox(self) -> None: + profile = valid_orchestration()["profiles"]["grok-test-readonly"] + + argv = worker_profiles.render_worker_argv( + profile, + "/usr/local/bin/grok", + "/repo/demo", + ) + + self.assertEqual( + argv, + [ + "/usr/local/bin/grok", + "--model", + "grok-4.5", + "--reasoning-effort", + "low", + "--permission-mode", + "plan", + "--always-approve", + "--sandbox", + "read-only", + "--cwd", + "/repo/demo", + ], + ) + self.assertNotIn("--yolo", argv) + self.assertNotIn("bypassPermissions", argv) + + def test_grok_accepts_vendor_artifact_basename(self) -> None: + profile = valid_orchestration()["profiles"]["grok-dev-standard"] + artifact = "/home/ace/.grok/downloads/grok-linux-x86_64" + + argv = worker_profiles.render_worker_argv(profile, artifact, "/repo/demo") + + self.assertEqual(argv[0], artifact) + self.assertTrue( + worker_profiles.executable_basename_matches_cli(artifact, "grok") + ) + self.assertFalse( + worker_profiles.executable_basename_matches_cli( + artifact, "cursor-agent" + ) + ) + def test_renderer_rejects_wrong_executable_or_unsafe_worktree(self) -> None: profile = valid_orchestration()["profiles"]["codex-dev-standard"]