From f5bf35c7222ac104f83c091bc78795a991ccbf5a Mon Sep 17 00:00:00 2001 From: laily Date: Sat, 1 Aug 2026 20:04:55 +0800 Subject: [PATCH] feat(orc): centralize host-aware routing --- skills/orc/README.md | 36 ++-- skills/orc/SKILL.md | 168 +++++++++------ skills/orc/agents/openai.yaml | 4 +- skills/orc/config.yaml | 54 +++++ skills/orc/references/orca-adapter.md | 20 +- skills/orc/references/routing.md | 10 +- skills/orc/scripts/resolve_profile.py | 238 ++++++++++++---------- skills/orc/templates/config.template.yaml | 36 ---- tests/test_orc_skill.py | 208 +++++++++++++++---- 9 files changed, 499 insertions(+), 275 deletions(-) create mode 100644 skills/orc/config.yaml delete mode 100644 skills/orc/templates/config.template.yaml diff --git a/skills/orc/README.md b/skills/orc/README.md index b336a18..b4a1d49 100644 --- a/skills/orc/README.md +++ b/skills/orc/README.md @@ -1,7 +1,7 @@ # orc -ORC 是显式调用的工程编排入口:把开发、版本发布、DEB 和 Docker 任务拆成阶段, -交给对应 Skill,并为每个执行 Agent 选择 `low`、`mid` 或 `high` 档位。 +ORC 是显式调用的薄路由器:只把开发、版本发布、DEB 和 Docker 意图映射成阶段, +按静态配置选择 `low`、`mid` 或 `high` worker,再交给对应 Skill。 ## 什么时候使用 @@ -16,13 +16,14 @@ ORC 是显式调用的工程编排入口:把开发、版本发布、DEB 和 Do - Orca 正在运行并启用了 orchestration。 - 安装本次需要的 `$ack`、`$manage-release`、`$deb-publisher` 或 `$publish-docker-image`。 -- 运行 `$orc 初始化` 生成 `docs/orc/config.yaml`,确认三档对应的精确模型。 -- `/usr/bin/python3`;ORC v1 配置必须保持为内置模板使用的 JSON-compatible YAML。 +- ORC 直接使用 skill 内共享的 `config.yaml`,不需要在每个项目初始化配置。修改这份 + 配置会影响所有项目;旧的项目级 `docs/orc/config.yaml` 不再参与解析。 +- `/usr/bin/python3`;ORC v2 配置必须保持为 JSON-compatible YAML。 resolver 以 `-I -S` 隔离模式运行,不加载项目模块、用户 site-packages 或第三方解析器。 -- ORC v1 worker 使用 `workspace-write` sandbox,以便发送 Orca lifecycle 消息; +- ORC v2 worker 使用 `workspace-write` sandbox,以便发送 Orca lifecycle 消息; 只读任务会在阶段 prompt 中禁止文件修改。 -## Agent 档位 +## Worker 档位 | 档位 | 典型任务 | |------|----------| @@ -31,27 +32,34 @@ ORC 是显式调用的工程编排入口:把开发、版本发布、DEB 和 Do | `high` | 需求理解、跨系统改动、异常恢复和高风险裁决 | 档位不是权限。三个档位仍受各自 profile 和下游 Skill 的授权边界约束。 -当前 ORC v1 的结构化启动适配器支持 Codex worker;其它 Agent CLI 需要独立适配器, -不会通过自由命令接入。 +这些档位只选择下游 worker,不会切换当前 ORC Coordinator 已经使用的模型。 +结构化启动适配器支持 Codex 与 Cursor worker,并固定跟随当前宿主:从 Codex 调用就使用 +Codex,从 Cursor 调用就使用 Cursor。共享配置为两个 CLI 分别维护三档模型,不设置跨宿主 +默认值。 ## 使用示例 ```text $orc high 修复登录问题,验证通过后发布新版本。 $orc code=high release=mid docker=low,完成修复、发版并推送镜像。 +$orc low,检查并准备下一个版本,不执行远端写操作。 $orc mid 继续上次中断的 v1.4.0 发布流程。 ``` -阶段级档位优先于全局档位。用户显式指定后,ORC 不会静默改档;能力不足时会暂停并 -请求确认。 +阶段级档位优先于全局档位;未指定时只读取 `stageDefaults` / `defaultLevel`。ORC 不根据 +任务复杂度动态升降档;worker 报告能力不足时,ORC 只转发请求。 ## Agent 会做什么 -1. 识别开发、源码发布、DEB 与 Docker 阶段及其依赖。 -2. 解析每个阶段的 Agent profile,把配置快照、可信 Codex executable 和真实 Git +1. 识别开发、源码发布、DEB 与 Docker 意图,并按固定表建立阶段依赖。 +2. 从当前 Agent 的系统身份锁定宿主 CLI,再解析每个阶段的 profile,把共享配置快照、 + 可信 Agent CLI executable 和真实 Git worktree 绑定成 launch fingerprint,并展示计划和外部写入边界。 -3. 通过 Orca 分发给对应 Skill,监督完成消息、异常和决策门。 -4. 汇总每个阶段的实际状态、证据和安全恢复入口。 +3. 通过 Orca 分发给对应 Skill,转发完成消息、异常和决策门。 +4. 按下游 Skill 声明的证据字段汇总状态和恢复入口,不重新做领域判断。 + +ORC 不分析实现、不建议版本、不评估发布风险、不选择合并或构建方案;这些工作全部属于 +下游 Skill。 ## 如何判断完成 diff --git a/skills/orc/SKILL.md b/skills/orc/SKILL.md index 93843cd..fa5c3d4 100644 --- a/skills/orc/SKILL.md +++ b/skills/orc/SKILL.md @@ -1,95 +1,128 @@ --- name: orc description: >- - 显式编排开发、源码版本发布、DEB 和 Docker 产物任务,把阶段分发给对应 Skill, - 并用 low、mid、high 选择执行 Agent 档位。仅在用户显式调用 $orc 或 /orc,要求 - 跨阶段协调、指定 Agent 级别、监督多个 worker 或继续 ORC 编排时使用。 + 作为薄路由器显式识别开发、源码版本发布、DEB 和 Docker 意图,把阶段分发给对应 + Skill,并按静态规则选择 low、mid、high 执行档位。仅在用户显式调用 $orc 或 /orc, + 要求跨阶段协调、指定 Agent 级别、监督多个 worker 或继续 ORC 编排时使用。 --- # ORC 工程编排入口 -当前会话担任 Coordinator:拆分阶段、选择档位、派发 worker、监督依赖与决策门, -但不替代下游 Skill 执行其领域流程。 +当前会话担任薄路由器(Thin Coordinator):只识别阶段、宿主 CLI、静态档位、依赖和 +授权边界,然后派发 worker、转发决策门并汇总结构化状态。ORC 不做领域判断,不替代 +下游 Skill 执行、分析或复核其领域流程。 开始时解析当前 `SKILL.md` 所在目录,记为 ``;解析真实项目根目录, -优先使用 `git rev-parse --show-toplevel`。项目配置固定为 -`/docs/orc/config.yaml`。 +优先使用 `git rev-parse --show-toplevel`。所有项目共享唯一配置 +`/config.yaml`,不得在项目中创建 `docs/orc/config.yaml` 或其它配置副本。 ## 选择模式 -- 用户要求初始化 ORC:执行“初始化”。 -- 用户要求检查 ORC、档位或运行环境:执行“检查”。 +- 用户要求初始化 ORC:说明 ORC 已改为共享配置、不需要项目初始化,然后执行“检查”。 +- 用户要求检查 ORC、CLI、档位或运行环境:执行“检查”。 +- 用户明确要求修改 ORC 的共享模型或档位默认值:执行“修改共享配置”。 - 用户要求用 ORC 完成任务:执行“编排”。 -不要静默初始化,也不要在配置缺失或无效时退回裸命令或当前会话直接执行。 +配置或运行时无效时 fail closed,不退回裸命令或当前会话直接执行。 -## 初始化 +## 薄路由器边界 -1. 若 `docs/orc/config.yaml` 已存在,停止创建并转入“检查”,不得覆盖。 -2. 确认 `docs/orc/` 和目标文件都不是 symlink,再从 - `/templates/config.template.yaml` 以 create-only 方式创建配置,不覆盖 - 或跟随既有路径。ORC v1 配置必须保持 JSON-compatible YAML,只由隔离的 Python - 标准库解析;模板中的 `.` 表示当前项目根。模型 ID 必须由用户或项目的可信配置确认, - 不能从任务文本猜测。 -3. 运行: +ORC 只执行以下机械步骤: - ```bash - /usr/bin/python3 -I -S /scripts/resolve_profile.py validate \ - /docs/orc/config.yaml - ``` +1. 把用户明确表达的意图映射到 `code`、`release`、`deb`、`docker`。 +2. 从系统身份映射宿主 CLI,并按显式覆盖或共享配置解析档位。 +3. 根据固定路由表建立任务依赖,生成满足契约的 worker prompt 并派发。 +4. 转发 `decision_gate` / `escalation`,按下游 Skill 的完成证据汇总状态。 -4. 报告三个档位的 CLI、模型、reasoning、权限和阶段默认值。除非用户明确要求, - 不安装下游 Skill、不创建终端、不修改 Agent 全局配置。 +不得阅读项目实现来形成技术判断,不得决定版本号、实现方案、测试策略、发布风险、 +合并方式或产物策略,不得代替 worker 执行命令。意图无法映射时报告范围外;缺少派发所需 +的关键输入时建立 decision gate。领域问题原样交给对应下游 Skill,不由 ORC 推理补全。 + +## 共享配置 + +`/config.yaml` 是 ORC profile 的 SSOT,对所有项目生效。配置使用 +JSON-compatible YAML,并由隔离的 Python 标准库解析。它固定包含: + +- `cliPolicy: current-host`:Codex 宿主只启动 Codex worker,Cursor 宿主只启动 Cursor worker。 +- `defaultLevel` 与 `stageDefaults`:只用于下游 worker 的 `low`、`mid`、`high` 默认选择, + 不表示 ORC Coordinator 自身的模型档位。 +- `worktreePolicy: registered-same-repository`:允许当前仓库中已注册且身份一致的 worktree。 +- `profiles.codex` 与 `profiles.cursor-agent`:两个 CLI 各自完整的三档 profile。 + +不要把项目路径、命令、argv、环境变量、secret、hook 或 shell 片段写入共享配置。 +旧项目若残留 `docs/orc/config.yaml`,resolver 会忽略它;没有用户明确清理授权时不要删除。 + +## 修改共享配置 + +只有用户明确要求修改 ORC 的全局档位或模型时才编辑 +`/config.yaml`。修改前说明它会影响所有项目;模型 ID 必须来自用户输入、 +目标 CLI 的模型列表或其它可信配置,不能从任务文本猜测。修改后运行: + +```bash +/usr/bin/python3 -I -S /scripts/resolve_profile.py validate +``` + +报告两个 CLI 的三个档位、reasoning、权限策略、阶段默认值和宿主 CLI 策略。不要创建 +项目配置。 ## 检查 -1. 校验 `docs/orc/config.yaml`,确认只存在 `low`、`mid`、`high` 三档。 +1. 运行共享配置校验,确认 `codex` 和 `cursor-agent` 都完整配置 `low`、`mid`、`high`。 2. 检查 `orca status --json`,并确认 orchestration 命令可用。 -3. 确认本次所需下游 Skill 已安装:`ack`、`manage-release`、`deb-publisher`、 +3. 从当前 Agent 的系统身份确定宿主 CLI:Codex 映射为 `codex`,Cursor 映射为 + `cursor-agent`。身份不明确时停止,不从用户任务文本、默认值或已安装 executable 猜测。 + 确认宿主 CLI 可用;在 Cursor 中还要用 `cursor-agent --list-models` + 核对精确模型 ID 对当前账号可见。 +4. 确认本次所需下游 Skill 已安装:`ack`、`manage-release`、`deb-publisher`、 `publish-docker-image`。只检查实际会用到的项。 -4. 解析 profile 时把项目根和目标 worktree 一并交给 resolver;只有 resolver 验证目标 - 命中 `allowedWorktrees`、属于当前 Git 仓库且身份稳定后才可创建终端。不得只做文本 - 比较或跳过机器校验。 -5. 任何 profile、Skill、运行时或 worktree 不可用时 fail closed;不得选择相邻档位、 - 复用身份不明的终端或手写替代流程。 +5. 解析 profile 时把项目根和目标 worktree 一并交给 resolver;只有 resolver 验证目标是 + 当前 Git 仓库已注册的 worktree 且身份稳定后才可创建终端。不得只做文本比较或跳过 + 机器校验。 +6. 任何 profile、CLI、Skill、运行时或 worktree 不可用时 fail closed;不得切换其它 CLI、 + 相邻档位、复用身份不明的终端或手写替代流程。 ## 编排 1. 读取 [routing.md](references/routing.md),把请求拆成 `code`、`release`、`deb`、 `docker` 阶段。没有匹配下游 Skill 的工作留在范围外并明确报告。 -2. 锁定用户授权的最远动作、目标版本、产物目标、源 commit/tag 与停止点。ORC 的调用 - 本身不扩大 push、合并、打 tag、上传或部署权限;每个下游 Skill 的授权边界继续生效。 -3. 解析档位:阶段级指定 > 全局指定 > `stageDefaults` > `defaultLevel`。只接受 - `low`、`mid`、`high`;用户显式指定后不得静默升降级。若该档位不足以安全完成, - 建立 decision gate,等待用户改档或缩小范围。 -4. 用户未指定档位时采用以下判断:清晰、机械的构建或上传可用 `low`;常规版本流程 - 用 `mid`;需求理解、跨系统改动、恢复中断流程、目标含糊或高风险裁决用 `high`。 -5. 对每个阶段运行 profile resolver。全局档位用 `--global-level`,阶段档位用 - `--stage-level`;项目根与目标 worktree 必须使用规范绝对路径。模型认证默认复用 - `codex-login`,只有明确使用对应环境凭据时才选 `openai` 或 `azure-openai`。远端认证 - 默认 `none`;只有目标 provider 与 transport 已确认时,才选择一个精确的 - `github-token`、`gitlab-token`、`gitea-token`、`forgejo-token`、`ssh-agent` 或 - `deb-token`。不得把多个 provider 凭据一起交给 worker。resolver 不存在静默 - fallback: +2. 原样提取用户明确给出的授权最远动作、目标版本、产物目标、源 commit/tag 与停止点; + 未给出的领域参数保持未指定并交给下游 Skill。ORC 的调用本身不扩大 push、合并、 + 打 tag、上传或部署权限。 +3. 从系统身份锁定当前宿主 CLI。运行在 Codex 时传 `--host-cli codex`;运行在 Cursor 时传 + `--host-cli cursor-agent`。宿主 CLI 不是任务选项,即使用户文本点名另一个 CLI 也不得 + 跨宿主启动;身份不明确时 fail closed。 +4. 解析档位:阶段级指定 > 全局指定 > `stageDefaults` > `defaultLevel`。只接受 + `low`、`mid`、`high`;用户显式指定后不得静默升降级。ORC 不根据任务复杂度动态判断 + 档位;worker 报告能力不足时,只转发 escalation 或 decision gate。 +5. 对每个阶段运行 profile resolver。宿主用必填的 `--host-cli`,全局档位用 + `--global-level`,阶段 + 档位用 `--stage-level`;项目根与目标 worktree 必须使用规范绝对路径。Codex 默认复用 + `codex-login`,Cursor 默认复用 `cursor-login`;只有明确使用环境凭据时才分别选择 + `openai`、`azure-openai` 或 `cursor-api-key`。远端认证默认 `none`;只有目标 provider + 与 transport 已确认时,才选择一个精确的 `github-token`、`gitlab-token`、 + `gitea-token`、`forgejo-token`、`ssh-agent` 或 `deb-token`。不得把多个 provider 凭据 + 一起交给 worker。resolver 不存在静默 fallback: ```bash /usr/bin/python3 -I -S /scripts/resolve_profile.py resolve \ - /docs/orc/config.yaml --stage \ + --stage \ --project-root \ --worktree \ + --host-cli \ [--global-level ] [--stage-level ] \ - [--model-auth ] \ + [--model-auth ] \ [--remote-auth ] ``` -6. 核对 resolver 返回的 `launchFingerprint`、绝对 executable、worktree identity 和选择 - 来源,再读取 [orca-adapter.md](references/orca-adapter.md),把阶段组织为 Orca task DAG。 - worker prompt 必须显式写出对应 `$skill`、阶段范围、输入 revision、用户授权边界、 - 验收证据和依赖结果;下游 Skill 无需知道 ORC。 +6. 核对 resolver 返回的 `launchFingerprint`、绝对 executable、worktree identity、CLI 与 + 档位选择来源,再读取 [orca-adapter.md](references/orca-adapter.md),把阶段组织为 Orca + task DAG。worker prompt 必须显式写出对应 `$skill`、阶段范围、输入 revision、用户授权 + 边界、验收证据和依赖结果;下游 Skill 无需知道 ORC。 7. 监督 `worker_done`、`escalation` 与 `decision_gate`。`worker_done` 只代表该 worker - 回报完成;Coordinator 仍需核对下游 Skill 要求的证据和 DAG 后置条件。 -8. 逐阶段汇报所选档位、执行 Skill、结果、外部状态和未完成项。任一阶段失败时保留 - 已成功阶段的准确状态,说明安全恢复入口,不把部分成功概括成全部完成。 + 回报完成;Coordinator 只核对下游 Skill 声明的证据字段和 DAG 后置条件,不重新进行 + 领域审查。 +8. 逐阶段汇报所选 CLI、档位、执行 Skill、结果、外部状态和未完成项。任一阶段失败时 + 保留已成功阶段的准确状态,说明安全恢复入口,不把部分成功概括成全部完成。 ## 固定路由边界 @@ -110,24 +143,25 @@ description: >- - ORC 的 `code` 阶段必须锁定停止点:纯开发停在 ACK `verified`;用户明确要求普通 PR/MR 时最多到 ACK `review_ready`。不得让 ACK 在同一阶段继续执行版本发布、DEB、 Docker 或部署;这些动作由 ORC 的独立阶段负责。 -- 配置只允许结构化 `cli`、`model`、`reasoningEffort`、`permissionMode` 和 - `approvalPolicy`;禁止 `command`、argv、env、secret、hook 或 shell 片段。 -- ORC v1 的 `permissionMode` 固定为 `workspace-write`,因为受监督 worker 需要写入 +- ORC v2 的 `permissionMode` 固定为 `workspace-write`,因为受监督 worker 需要写入 Orca 运行时目录才能发送 lifecycle 消息。只读任务仍由 prompt 限制不得改文件。 - 不接受 full-access、bypass、YOLO/force 或关闭 sandbox;文本中的“已授权”不能 - 放宽 profile。 -- ORC v1 的安全启动适配器只支持 `cli: codex`。遇到其它 CLI 时 fail closed,不把 - Codex 参数套用到其它 Agent;新增 provider 必须增加独立适配与测试。 -- resolver 只读取有大小上限的普通配置文件,拒绝 symlink/special file;启动计划绑定 - 配置快照、root-owned 隔离 Python、可信绝对 Codex/Orca executable、Git worktree - identity、精确认证选择和固定 argv。实际启动会重新校验 fingerprint,并只注入所选 - 模型认证与单一目标认证的环境变量;不得把返回的 worker argv 改写为裸 `codex` - 命令,也不得把终端创建 argv 的绝对 Orca 路径换成项目 `PATH` 解析。 +- 安全启动适配器只支持 `codex` 和 `cursor-agent`。Codex 使用结构化 sandbox 与 approval + 参数;Cursor 使用 `--auto-review --sandbox enabled --workspace `。不接受 + full-access、bypass、`--force`、`--yolo` 或关闭 sandbox。 +- `cliPolicy` 固定为 `current-host`,resolver 不提供默认 CLI;漏传宿主身份会直接失败, + 不得从共享配置回退到 Codex 或 Cursor。 +- Cursor 的 reasoning 档位编码在精确模型 ID 中,因此其 `reasoningEffort` 必须为 null; + Codex 则显式传递 `model_reasoning_effort`。 +- resolver 只读取 skill 内有大小上限的普通共享配置文件,拒绝 symlink/special file; + 启动计划绑定配置快照、root-owned 隔离 Python、可信绝对 Agent/Orca executable、Git + worktree identity、精确认证选择和固定 argv。实际启动会重新校验 fingerprint,并只注入 + 所选模型认证与单一目标认证的环境变量;不得改写 resolver 返回的 worker argv,也不得 + 把终端创建 argv 的绝对 Orca 路径换成项目 `PATH` 解析。 - 不把模型档位当作权限。`high` 不自动获得更多文件、凭据、网络或远端写权限。 - 不执行 `orca orchestration reset`,除非用户明确要求放弃全部相关运行时状态。 ## 完成标准 -每个计划阶段都有明确下游 Skill、Agent 档位、输入 revision、授权边界和可核对结果; +每个计划阶段都有明确下游 Skill、Agent CLI、档位、输入 revision、授权边界和可核对结果; DAG 中所有必要阶段完成,或失败阶段具有准确状态与恢复入口。ACK 和其它下游 Skill 保持独立且不存在对 ORC 的反向引用。 diff --git a/skills/orc/agents/openai.yaml b/skills/orc/agents/openai.yaml index 411dbfa..dba6f55 100644 --- a/skills/orc/agents/openai.yaml +++ b/skills/orc/agents/openai.yaml @@ -1,6 +1,6 @@ interface: display_name: "ORC" - short_description: "按阶段编排开发、版本发布与产物构建,并选择 Agent 档位" - default_prompt: "Use $orc to coordinate this engineering task with explicit low, mid, or high worker levels." + short_description: "用 Codex 或 Cursor 分档编排开发、发布与产物任务" + default_prompt: "Use $orc to coordinate this engineering task on the current host CLI with a low, mid, or high worker level." policy: allow_implicit_invocation: false diff --git a/skills/orc/config.yaml b/skills/orc/config.yaml new file mode 100644 index 0000000..34075f1 --- /dev/null +++ b/skills/orc/config.yaml @@ -0,0 +1,54 @@ +{ + "version": 2, + "cliPolicy": "current-host", + "defaultLevel": "mid", + "stageDefaults": { + "code": "high", + "release": "mid", + "deb": "low", + "docker": "low" + }, + "worktreePolicy": "registered-same-repository", + "profiles": { + "codex": { + "low": { + "model": "gpt-5.6-luna", + "reasoningEffort": "low", + "permissionMode": "workspace-write", + "approvalPolicy": "never" + }, + "mid": { + "model": "gpt-5.6-terra", + "reasoningEffort": "medium", + "permissionMode": "workspace-write", + "approvalPolicy": "never" + }, + "high": { + "model": "gpt-5.6-sol", + "reasoningEffort": "high", + "permissionMode": "workspace-write", + "approvalPolicy": "never" + } + }, + "cursor-agent": { + "low": { + "model": "auto", + "reasoningEffort": null, + "permissionMode": "workspace-write", + "approvalPolicy": "auto-review" + }, + "mid": { + "model": "gpt-5.6-terra-medium", + "reasoningEffort": null, + "permissionMode": "workspace-write", + "approvalPolicy": "auto-review" + }, + "high": { + "model": "gpt-5.6-sol-high", + "reasoningEffort": null, + "permissionMode": "workspace-write", + "approvalPolicy": "auto-review" + } + } + } +} diff --git a/skills/orc/references/orca-adapter.md b/skills/orc/references/orca-adapter.md index 74294b4..febd272 100644 --- a/skills/orc/references/orca-adapter.md +++ b/skills/orc/references/orca-adapter.md @@ -16,9 +16,12 @@ orca orchestration inbox --limit 20 --json ## 解析安全启动计划 -对每个阶段调用 `resolve_profile.py resolve`,同时传入规范绝对 `--project-root` 和 -`--worktree`,以及本阶段精确的 `--model-auth`、`--remote-auth`。JSON 结果包含选择来源、 -结构化 profile、认证选择、配置与 worktree identity、可信 Python/Codex/Orca executable、 +对每个阶段调用 `resolve_profile.py resolve`,同时传入规范绝对 `--project-root`、 +`--worktree` 和从当前 Agent 系统身份确定的必填 `--host-cli`,以及本阶段精确的 +`--model-auth`、`--remote-auth`。 +resolver 始终读取 skill 内共享的 `config.yaml`,不接受项目配置路径。JSON 结果包含 +选择来源、结构化 profile、认证选择、配置与 worktree identity、可信 Python/Agent +CLI/Orca executable、 `launchFingerprint`、固定 worker/launcher argv,以及完整的 `terminalCreateArgv` 和 `terminalCreateShellCommand`。 @@ -31,11 +34,16 @@ orca orchestration inbox --limit 20 --json 同时暴露多个 token 或 SSH agent。需要代理、额外凭据或 Docker registry env secret 时 fail closed,由用户确认新的最小权限适配,不继承 ambient proxy/credential 变量。 - resolver 失败时停止;不改用默认模型、邻近档位或已有未知终端。 -- 不重复手写 worktree 检查;resolver 已拒绝 root、不存在、非规范、含 symlink、未注册、 - 跨仓库或不在 allowlist 的目标,并把身份写入 fingerprint。 -- ORC v1 profile 必须使用 `workspace-write`。`read-only` sandbox 可能阻止 Orca CLI +- 不从任务文本、可执行文件存在性或共享配置猜宿主;Codex 传 `--host-cli codex`,Cursor + 传 `--host-cli cursor-agent`。宿主身份不明确时停止。 +- 不重复手写 worktree 检查;resolver 已拒绝 root、不存在、非规范、含 symlink、未注册 + 或跨仓库的目标,并把身份写入 fingerprint。 +- ORC v2 profile 必须使用 `workspace-write`。`read-only` sandbox 可能阻止 Orca CLI 写入自身运行时或挂载目录,导致 worker 无法发送 `worker_done`;只读工作通过 task spec 限制,而不是换成无法完成 lifecycle 的 sandbox。 +- Codex 与 Cursor 使用不同的固定 argv 适配器。Cursor 只允许 `--auto-review`、启用的 + sandbox 和精确 workspace;不得把 Codex 参数套给 Cursor,也不得给 Cursor 追加 + `--force` 或 `--yolo`。 ## 创建与派发 diff --git a/skills/orc/references/routing.md b/skills/orc/references/routing.md index 01440d1..e1b281a 100644 --- a/skills/orc/references/routing.md +++ b/skills/orc/references/routing.md @@ -1,7 +1,7 @@ # ORC 阶段路由 -ORC 只负责拆分、依赖、档位和监督。领域步骤、授权检查和完成标准由下游 Skill 自己 -决定。 +ORC 是薄路由器,只负责意图映射、依赖、静态档位和结构化状态汇总。它不判断版本号、 +实现方案、发布风险或产物策略;领域步骤、授权检查和完成标准由下游 Skill 自己决定。 ## 路由表 @@ -15,6 +15,10 @@ ORC 只负责拆分、依赖、档位和监督。领域步骤、授权检查和 没有匹配项时不要临时扩写某个 Skill 的职责,也不要让 ORC 自己模仿领域流程。报告缺少 的能力,由用户决定直接执行、安装新 Skill 或另行设计。 +路由只依据用户明确表达的目标。缺少目标版本、revision、产物目标或授权停止点时,保留 +为未指定并交给下游 Skill;只有缺少创建 task 所必需的项目或阶段身份时才建立 +decision gate。不得为了填满 worker prompt 而分析代码、推断版本或设计执行方案。 + ## 拆分规则 1. 先从用户请求提取最终结果,再拆出真正需要的阶段;不要因为安装了某个 Skill 就 @@ -52,7 +56,7 @@ deb ($deb-publisher) || docker ($publish-docker-image) release ($manage-release) ``` -依赖不是固定模板,但 ORC v1 不拆分一个 `manage-release` 生命周期。若项目要求在打开 +依赖不是固定模板,但 ORC v2 不拆分一个 `manage-release` 生命周期。若项目要求在打开 release PR 与合并之间插入 DEB/Docker gate,当前 task 粒度无法安全表达该中间里程碑; 在打开 PR 前建立 decision gate 并报告该流程暂不支持,不得用循环依赖或两个 release worker 临时拼接。 diff --git a/skills/orc/scripts/resolve_profile.py b/skills/orc/scripts/resolve_profile.py index 5ca5ec6..02632ad 100755 --- a/skills/orc/scripts/resolve_profile.py +++ b/skills/orc/scripts/resolve_profile.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Validate ORC config and build a worktree-bound Codex launch plan.""" +"""Validate shared ORC config and build a worktree-bound Agent launch plan.""" from __future__ import annotations @@ -18,16 +18,17 @@ from typing import Any LEVELS = ("low", "mid", "high") STAGES = ("code", "release", "deb", "docker") +CLIS = ("codex", "cursor-agent") EXPECTED_EFFORT = {"low": "low", "mid": "medium", "high": "high"} TOP_LEVEL_KEYS = { "version", + "cliPolicy", "defaultLevel", "stageDefaults", - "allowedWorktrees", + "worktreePolicy", "profiles", } PROFILE_KEYS = { - "cli", "model", "reasoningEffort", "permissionMode", @@ -37,7 +38,7 @@ MODEL_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}\Z") MAX_CONFIG_SIZE = 64 * 1024 MAX_CONTROL_OUTPUT = 64 * 1024 CONTROL_TIMEOUT_SECONDS = 15 -CONFIG_RELATIVE_PATH = Path("docs/orc/config.yaml") +SHARED_CONFIG_PATH = Path(__file__).resolve().parents[1] / "config.yaml" COMMON_ENVIRONMENT_NAMES = { "COLORTERM", "LANG", @@ -50,6 +51,16 @@ MODEL_AUTH_ENVIRONMENT = { "codex-login": frozenset(), "openai": frozenset({"OPENAI_API_KEY"}), "azure-openai": frozenset({"AZURE_OPENAI_API_KEY"}), + "cursor-login": frozenset(), + "cursor-api-key": frozenset({"CURSOR_API_KEY"}), +} +CLI_MODEL_AUTH = { + "codex": frozenset({"codex-login", "openai", "azure-openai"}), + "cursor-agent": frozenset({"cursor-login", "cursor-api-key"}), +} +DEFAULT_MODEL_AUTH = { + "codex": "codex-login", + "cursor-agent": "cursor-login", } REMOTE_AUTH_ENVIRONMENT = { "none": frozenset(), @@ -169,9 +180,11 @@ def validate_config(document: Any) -> dict[str, Any]: _exact_keys(config, required=TOP_LEVEL_KEYS, path="config") version = config["version"] - if isinstance(version, bool) or version != 1: - raise ConfigError("version must be integer 1") + if isinstance(version, bool) or version != 2: + raise ConfigError("version must be integer 2") + if config["cliPolicy"] != "current-host": + raise ConfigError("cliPolicy must be current-host in ORC v2") _enum(config["defaultLevel"], LEVELS, "defaultLevel") stage_defaults = _mapping(config["stageDefaults"], "stageDefaults") @@ -183,53 +196,51 @@ def validate_config(document: Any) -> dict[str, Any]: for stage, level in stage_defaults.items(): _enum(level, LEVELS, f"stageDefaults.{stage}") - allowed = config["allowedWorktrees"] - if not isinstance(allowed, list) or not allowed: - raise ConfigError("allowedWorktrees must be a non-empty list") - seen_worktrees: set[str] = set() - for index, entry in enumerate(allowed): - if ( - not isinstance(entry, str) - or not entry - or entry != entry.strip() - or any(character in entry for character in ("\x00", "\n", "\r")) - ): - raise ConfigError( - f"allowedWorktrees[{index}] must be a safe non-empty path" - ) - if entry != "." and not Path(entry).is_absolute(): - raise ConfigError( - f"allowedWorktrees[{index}] must be '.' or an absolute path" - ) - normalized = entry if entry == "." else str(Path(entry).absolute()) - if normalized in seen_worktrees: - raise ConfigError(f"allowedWorktrees contains duplicate path: {entry}") - seen_worktrees.add(normalized) + if config["worktreePolicy"] != "registered-same-repository": + raise ConfigError( + "worktreePolicy must be registered-same-repository in ORC v2" + ) profiles = _mapping(config["profiles"], "profiles") - _exact_keys(profiles, required=set(LEVELS), path="profiles") - for level in LEVELS: - profile = _mapping(profiles[level], f"profiles.{level}") - _exact_keys(profile, required=PROFILE_KEYS, path=f"profiles.{level}") - if profile["cli"] != "codex": - raise ConfigError(f"profiles.{level}.cli must be codex in ORC v1") - model = profile["model"] - if not isinstance(model, str) or not MODEL_RE.fullmatch(model): - raise ConfigError(f"profiles.{level}.model is not a safe exact model ID") - expected_effort = EXPECTED_EFFORT[level] - if profile["reasoningEffort"] != expected_effort: - raise ConfigError( - f"profiles.{level}.reasoningEffort must be {expected_effort}" - ) - if profile["permissionMode"] != "workspace-write": - raise ConfigError( - f"profiles.{level}.permissionMode must be workspace-write in ORC v1" - ) - _enum( - profile["approvalPolicy"], - {"untrusted", "on-request", "never"}, - f"profiles.{level}.approvalPolicy", - ) + _exact_keys(profiles, required=set(CLIS), path="profiles") + for cli in CLIS: + cli_profiles = _mapping(profiles[cli], f"profiles.{cli}") + _exact_keys(cli_profiles, required=set(LEVELS), path=f"profiles.{cli}") + for level in LEVELS: + path = f"profiles.{cli}.{level}" + profile = _mapping(cli_profiles[level], path) + _exact_keys(profile, required=PROFILE_KEYS, path=path) + model = profile["model"] + if not isinstance(model, str) or not MODEL_RE.fullmatch(model): + raise ConfigError(f"{path}.model is not a safe exact model ID") + if cli == "codex": + expected_effort = EXPECTED_EFFORT[level] + if profile["reasoningEffort"] != expected_effort: + raise ConfigError( + f"{path}.reasoningEffort must be {expected_effort}" + ) + _enum( + profile["approvalPolicy"], + {"untrusted", "on-request", "never"}, + f"{path}.approvalPolicy", + ) + else: + if profile["reasoningEffort"] is not None: + raise ConfigError(f"{path}.reasoningEffort: Cursor requires null") + model_tokens = set(re.split(r"[^a-z0-9]+", model.lower())) + expected_token = "medium" if level == "mid" else level + if model != "auto" and expected_token not in model_tokens: + raise ConfigError( + f"{path}.model must encode the {expected_token} effort level" + ) + if profile["approvalPolicy"] != "auto-review": + raise ConfigError( + f"{path}.approvalPolicy must be auto-review for Cursor" + ) + if profile["permissionMode"] != "workspace-write": + raise ConfigError( + f"{path}.permissionMode must be workspace-write in ORC v2" + ) return config @@ -260,7 +271,8 @@ def resolve_profile( stage: str, global_level: str | None = None, stage_level: str | None = None, - model_auth: str = "codex-login", + host_cli: str | None = None, + model_auth: str | None = None, remote_auth: str = "none", ) -> dict[str, Any]: _enum(stage, STAGES, "stage") @@ -268,9 +280,19 @@ def resolve_profile( _enum(global_level, LEVELS, "global level") if stage_level is not None: _enum(stage_level, LEVELS, "stage level") - _enum(model_auth, set(MODEL_AUTH_ENVIRONMENT), "model auth") _enum(remote_auth, STAGE_REMOTE_AUTH[stage], f"{stage} remote auth") + if host_cli is None: + raise ConfigError("host CLI is required; resolve it from the current Agent") + selected_cli = _enum(host_cli, CLIS, "host CLI") + cli_source = "runtime.host" + selected_auth = model_auth or DEFAULT_MODEL_AUTH[selected_cli] + _enum(selected_auth, set(MODEL_AUTH_ENVIRONMENT), "model auth") + if selected_auth not in CLI_MODEL_AUTH[selected_cli]: + raise ConfigError( + f"model auth {selected_auth} is not valid for {selected_cli}" + ) + if stage_level is not None: level, source = stage_level, "request.stage" elif global_level is not None: @@ -280,25 +302,36 @@ def resolve_profile( else: level, source = config["defaultLevel"], "config.defaultLevel" - profile = config["profiles"].get(level) + profile = config["profiles"][selected_cli].get(level) if profile is None: - raise ConfigError(f"requested profile does not exist: {level}") - worker_args = [ - "--model", - profile["model"], - "-c", - f'model_reasoning_effort="{profile["reasoningEffort"]}"', - "--sandbox", - profile["permissionMode"], - "--ask-for-approval", - profile["approvalPolicy"], - "--strict-config", - ] + raise ConfigError(f"requested profile does not exist: {selected_cli}/{level}") + if selected_cli == "codex": + worker_args = [ + "--model", + profile["model"], + "-c", + f'model_reasoning_effort="{profile["reasoningEffort"]}"', + "--sandbox", + profile["permissionMode"], + "--ask-for-approval", + profile["approvalPolicy"], + "--strict-config", + ] + else: + worker_args = [ + "--model", + profile["model"], + "--auto-review", + "--sandbox", + "enabled", + ] return { "stage": stage, + "cli": selected_cli, + "cliSelectionSource": cli_source, "level": level, "selectionSource": source, - "modelAuth": model_auth, + "modelAuth": selected_auth, "remoteAuth": remote_auth, "profile": dict(profile), "workerArgs": worker_args, @@ -400,7 +433,7 @@ def _trusted_executable(path: Path, expected_name: str) -> Path | None: def resolve_trusted_executable(name: str) -> Path: - if name not in {"codex", "git", "orca"}: + if name not in {"codex", "cursor-agent", "git", "orca"}: raise ConfigError(f"unsupported executable: {name}") search_paths = trusted_path_entries() if name == "git": @@ -572,17 +605,10 @@ def validate_worktree( except ConfigError: continue - allowed: set[Path] = set() - for entry in config["allowedWorktrees"]: - allowed.add( - project_root - if entry == "." - else canonical_directory(entry, "allowed worktree") - ) - if not allowed <= registered: - raise ConfigError("allowedWorktrees contains an unregistered Git worktree") - if worktree not in allowed: - raise ConfigError("target worktree is not in allowedWorktrees") + if config["worktreePolicy"] != "registered-same-repository": + raise ConfigError("unsupported worktree policy") + if worktree not in registered: + raise ConfigError("target worktree is not registered in the project repository") target_top = canonical_directory( _run_control( @@ -631,7 +657,7 @@ def _file_facts(path: Path) -> dict[str, Any]: def _executable_facts(path: Path) -> dict[str, Any]: return { **_file_facts(path), - "version": _run_control([str(path), "--version"], "Codex version check"), + "version": _run_control([str(path), "--version"], "Agent CLI version check"), } @@ -663,22 +689,19 @@ def _fingerprint(value: dict[str, Any]) -> str: def build_launch_plan( - config_path: Path, *, project_root: str | Path, worktree: str | Path, stage: str, + host_cli: str, global_level: str | None = None, stage_level: str | None = None, - model_auth: str = "codex-login", + model_auth: str | None = None, remote_auth: str = "none", ) -> dict[str, Any]: project = canonical_directory(project_root, "project root") - expected_config = project / CONFIG_RELATIVE_PATH - if config_path.absolute() != expected_config: - raise ConfigError(f"config path must be {expected_config}") - _assert_no_symlink_components(expected_config, "config path") - config, config_snapshot = load_config_snapshot(expected_config) + _assert_no_symlink_components(SHARED_CONFIG_PATH, "shared config path") + config, config_snapshot = load_config_snapshot(SHARED_CONFIG_PATH) worktree_facts = validate_worktree( config, project_root_value=project, @@ -689,16 +712,17 @@ def build_launch_plan( stage=stage, global_level=global_level, stage_level=stage_level, + host_cli=host_cli, model_auth=model_auth, remote_auth=remote_auth, ) selected_credential_names( stage, - model_auth=model_auth, + model_auth=profile["modelAuth"], remote_auth=remote_auth, ) - codex = resolve_trusted_executable("codex") - executable = _executable_facts(codex) + agent_cli = resolve_trusted_executable(profile["cli"]) + executable = _executable_facts(agent_cli) orca = resolve_trusted_executable("orca") orca_executable = _file_facts(orca) python = resolve_trusted_python() @@ -710,7 +734,7 @@ def build_launch_plan( **profile, **worktree_facts, "config": { - "path": str(expected_config), + "path": str(SHARED_CONFIG_PATH), **config_snapshot, }, "executable": executable, @@ -726,14 +750,15 @@ def build_launch_plan( }, } fingerprint = _fingerprint(launch_facts) - worker_argv = [str(codex), *profile["workerArgs"]] + worker_argv = [str(agent_cli), *profile["workerArgs"]] + if profile["cli"] == "cursor-agent": + worker_argv.extend(["--workspace", str(worktree_facts["worktree"])]) launcher_argv = [ str(python), "-I", "-S", str(script), "_launch", - str(expected_config), "--project-root", str(project), "--worktree", @@ -747,13 +772,17 @@ def build_launch_plan( launcher_argv.extend(["--global-level", global_level]) if stage_level is not None: launcher_argv.extend(["--stage-level", stage_level]) - if model_auth != "codex-login": + launcher_argv.extend(["--host-cli", host_cli]) + if model_auth is not None: launcher_argv.extend(["--model-auth", model_auth]) if remote_auth != "none": launcher_argv.extend(["--remote-auth", remote_auth]) terminal_command = shlex.join(launcher_argv) worktree_selector = "path:" + str(worktree_facts["worktree"]) - terminal_title = f"ORC-{stage}-{profile['level']}-{fingerprint[7:15]}" + cli_label = "CODEX" if profile["cli"] == "codex" else "CURSOR" + terminal_title = ( + f"ORC-{stage}-{cli_label}-{profile['level']}-{fingerprint[7:15]}" + ) terminal_create_argv = [ str(orca), "terminal", @@ -781,10 +810,10 @@ def build_launch_plan( def execute_launch(args: argparse.Namespace) -> int: plan = build_launch_plan( - args.config, project_root=args.project_root, worktree=args.worktree, stage=args.stage, + host_cli=args.host_cli, global_level=args.global_level, stage_level=args.stage_level, model_auth=args.model_auth, @@ -802,7 +831,7 @@ def execute_launch(args: argparse.Namespace) -> int: raise ConfigError("Python executable changed before launch") executable = Path(plan["executable"]["path"]) if _executable_facts(executable) != plan["executable"]: - raise ConfigError("Codex executable changed before launch") + raise ConfigError("Agent CLI executable changed before launch") try: os.execve( executable, @@ -814,21 +843,20 @@ def execute_launch(args: argparse.Namespace) -> int: ), ) except OSError as exc: - raise ConfigError("Codex worker could not be launched") from exc + raise ConfigError("Agent worker could not be launched") from exc return 1 # pragma: no cover - os.execve does not return on success def _add_resolution_arguments(parser: argparse.ArgumentParser) -> None: - parser.add_argument("config", type=Path) parser.add_argument("--project-root", required=True) parser.add_argument("--worktree", required=True) parser.add_argument("--stage", required=True, choices=STAGES) parser.add_argument("--global-level", choices=LEVELS) parser.add_argument("--stage-level", choices=LEVELS) + parser.add_argument("--host-cli", required=True, choices=CLIS) parser.add_argument( "--model-auth", choices=tuple(MODEL_AUTH_ENVIRONMENT), - default="codex-login", ) parser.add_argument( "--remote-auth", @@ -842,8 +870,6 @@ def build_parser() -> argparse.ArgumentParser: subparsers = parser.add_subparsers(dest="command", required=True) validate = subparsers.add_parser("validate", help="validate config only") - validate.add_argument("config", type=Path) - resolve = subparsers.add_parser("resolve", help="resolve a bound launch plan") _add_resolution_arguments(resolve) @@ -857,20 +883,22 @@ def main(argv: list[str] | None = None) -> int: args = build_parser().parse_args(argv) try: if args.command == "validate": - config = load_config(args.config) + config = load_config(SHARED_CONFIG_PATH) result: dict[str, Any] = { "ok": True, - "config": str(args.config.absolute()), + "config": str(SHARED_CONFIG_PATH), + "clis": list(CLIS), "levels": list(LEVELS), "stages": list(STAGES), + "cliPolicy": config["cliPolicy"], "defaultLevel": config["defaultLevel"], } elif args.command == "resolve": result = build_launch_plan( - args.config, project_root=args.project_root, worktree=args.worktree, stage=args.stage, + host_cli=args.host_cli, global_level=args.global_level, stage_level=args.stage_level, model_auth=args.model_auth, diff --git a/skills/orc/templates/config.template.yaml b/skills/orc/templates/config.template.yaml deleted file mode 100644 index da0c413..0000000 --- a/skills/orc/templates/config.template.yaml +++ /dev/null @@ -1,36 +0,0 @@ -{ - "version": 1, - "defaultLevel": "mid", - "stageDefaults": { - "code": "high", - "release": "mid", - "deb": "low", - "docker": "low" - }, - "allowedWorktrees": [ - "." - ], - "profiles": { - "low": { - "cli": "codex", - "model": "gpt-5.6-luna", - "reasoningEffort": "low", - "permissionMode": "workspace-write", - "approvalPolicy": "never" - }, - "mid": { - "cli": "codex", - "model": "gpt-5.6-terra", - "reasoningEffort": "medium", - "permissionMode": "workspace-write", - "approvalPolicy": "never" - }, - "high": { - "cli": "codex", - "model": "gpt-5.6-sol", - "reasoningEffort": "high", - "permissionMode": "workspace-write", - "approvalPolicy": "never" - } - } -} diff --git a/tests/test_orc_skill.py b/tests/test_orc_skill.py index f1ee2b6..8fa4037 100644 --- a/tests/test_orc_skill.py +++ b/tests/test_orc_skill.py @@ -18,7 +18,7 @@ from unittest import mock REPO_ROOT = Path(__file__).resolve().parents[1] ORC_DIR = REPO_ROOT / "skills" / "orc" SCRIPT = ORC_DIR / "scripts" / "resolve_profile.py" -CONFIG = ORC_DIR / "templates" / "config.template.yaml" +CONFIG = ORC_DIR / "config.yaml" spec = importlib.util.spec_from_file_location("orc_resolve_profile", SCRIPT) assert spec is not None and spec.loader is not None @@ -36,7 +36,13 @@ class OrcSkillTests(unittest.TestCase): self.assertIn("low", skill) self.assertIn("mid", skill) self.assertIn("high", skill) - self.assertIn("只支持 `cli: codex`", skill) + self.assertIn("`codex` 和 `cursor-agent`", skill) + self.assertIn("/config.yaml", skill) + self.assertIn("不得在项目中创建 `docs/orc/config.yaml`", skill) + self.assertIn("薄路由器", skill) + self.assertIn("不做领域判断", skill) + self.assertNotIn("用户未指定档位时采用以下判断", skill) + self.assertNotIn("若该档位不足以安全完成", skill) for child in ("$ack", "$manage-release", "$deb-publisher", "$publish-docker-image"): self.assertIn(child, routing) @@ -64,12 +70,20 @@ class OrcSkillTests(unittest.TestCase): self.assertIn("最多到", routing) self.assertIn("review_ready", routing) self.assertIn("当前 task 粒度无法安全表达", routing) + self.assertRegex( + routing, + r"不判断版本号、\s*实现方案、发布风险或产物策略", + ) - def test_template_and_cli_validate(self) -> None: + def test_shared_config_and_cli_validate(self) -> None: config = orc_profiles.load_config(CONFIG) - self.assertEqual(set(config["profiles"]), {"low", "mid", "high"}) + self.assertEqual(config["version"], 2) + self.assertEqual(config["cliPolicy"], "current-host") + self.assertEqual(set(config["profiles"]), {"codex", "cursor-agent"}) + for cli in ("codex", "cursor-agent"): + self.assertEqual(set(config["profiles"][cli]), {"low", "mid", "high"}) completed = subprocess.run( - [sys.executable, str(SCRIPT), "validate", str(CONFIG)], + [sys.executable, str(SCRIPT), "validate"], text=True, capture_output=True, check=False, @@ -77,8 +91,16 @@ class OrcSkillTests(unittest.TestCase): self.assertEqual(completed.returncode, 0, completed.stderr) self.assertIn('"ok": true', completed.stdout) + project_config_argument = subprocess.run( + [sys.executable, str(SCRIPT), "validate", str(CONFIG)], + text=True, + capture_output=True, + check=False, + ) + self.assertEqual(project_config_argument.returncode, 2) + clean_python = subprocess.run( - [sys.executable, "-I", "-S", str(SCRIPT), "validate", str(CONFIG)], + [sys.executable, "-I", "-S", str(SCRIPT), "validate"], text=True, capture_output=True, check=False, @@ -88,24 +110,39 @@ class OrcSkillTests(unittest.TestCase): def test_level_precedence_is_stage_then_global_then_config(self) -> None: config = orc_profiles.load_config(CONFIG) stage = orc_profiles.resolve_profile( - config, stage="docker", global_level="mid", stage_level="high" + config, + stage="docker", + host_cli="codex", + global_level="mid", + stage_level="high", ) global_choice = orc_profiles.resolve_profile( - config, stage="docker", global_level="mid" + config, stage="docker", host_cli="codex", global_level="mid" + ) + configured = orc_profiles.resolve_profile( + config, stage="docker", host_cli="codex" ) - configured = orc_profiles.resolve_profile(config, stage="docker") fallback_config = copy.deepcopy(config) del fallback_config["stageDefaults"]["docker"] - fallback = orc_profiles.resolve_profile(fallback_config, stage="docker") + fallback = orc_profiles.resolve_profile( + fallback_config, stage="docker", host_cli="codex" + ) self.assertEqual((stage["level"], stage["selectionSource"]), ("high", "request.stage")) self.assertEqual((global_choice["level"], global_choice["selectionSource"]), ("mid", "request.global")) self.assertEqual((configured["level"], configured["selectionSource"]), ("low", "config.stageDefaults.docker")) self.assertEqual((fallback["level"], fallback["selectionSource"]), ("mid", "config.defaultLevel")) + self.assertEqual(configured["cli"], "codex") + self.assertEqual(configured["cliSelectionSource"], "runtime.host") + + with self.assertRaisesRegex(orc_profiles.ConfigError, "host CLI is required"): + orc_profiles.resolve_profile(config, stage="docker") def test_resolver_builds_fixed_worker_args_without_free_command_fields(self) -> None: config = orc_profiles.load_config(CONFIG) - plan = orc_profiles.resolve_profile(config, stage="release") + plan = orc_profiles.resolve_profile( + config, stage="release", host_cli="codex" + ) self.assertEqual(plan["workerArgs"][:2], ["--model", "gpt-5.6-terra"]) self.assertIn("--strict-config", plan["workerArgs"]) @@ -113,6 +150,40 @@ class OrcSkillTests(unittest.TestCase): self.assertNotIn("env", plan["profile"]) self.assertNotIn("command", plan["profile"]) + cursor = orc_profiles.resolve_profile( + config, + stage="release", + host_cli="cursor-agent", + stage_level="low", + ) + self.assertEqual(cursor["cli"], "cursor-agent") + self.assertEqual(cursor["cliSelectionSource"], "runtime.host") + self.assertEqual(cursor["modelAuth"], "cursor-login") + self.assertEqual( + cursor["workerArgs"], + [ + "--model", + "auto", + "--auto-review", + "--sandbox", + "enabled", + ], + ) + with self.assertRaisesRegex(orc_profiles.ConfigError, "not valid for cursor-agent"): + orc_profiles.resolve_profile( + config, + stage="release", + host_cli="cursor-agent", + model_auth="openai", + ) + with self.assertRaisesRegex(orc_profiles.ConfigError, "not valid for codex"): + orc_profiles.resolve_profile( + config, + stage="release", + host_cli="codex", + model_auth="cursor-api-key", + ) + def test_worker_environment_selects_one_model_and_remote_auth(self) -> None: ambient = { "OPENAI_API_KEY": "openai-secret", @@ -145,6 +216,19 @@ class OrcSkillTests(unittest.TestCase): ): self.assertNotIn(rejected, environment) + cursor_ambient = { + "CURSOR_API_KEY": "cursor-secret", + "OPENAI_API_KEY": "openai-secret", + } + with mock.patch.dict(os.environ, cursor_ambient, clear=True): + cursor_environment = orc_profiles.worker_environment( + "release", + model_auth="cursor-api-key", + remote_auth="none", + ) + self.assertEqual(cursor_environment["CURSOR_API_KEY"], "cursor-secret") + self.assertNotIn("OPENAI_API_KEY", cursor_environment) + with self.assertRaisesRegex(orc_profiles.ConfigError, "deb remote auth"): orc_profiles.worker_environment( "deb", @@ -240,35 +324,60 @@ class OrcSkillTests(unittest.TestCase): base = orc_profiles.load_config(CONFIG) free_command = copy.deepcopy(base) - free_command["profiles"]["low"]["command"] = "codex --dangerously-bypass" + free_command["profiles"]["codex"]["low"]["command"] = "codex --dangerously-bypass" with self.assertRaisesRegex(orc_profiles.ConfigError, "unknown fields"): orc_profiles.validate_config(free_command) full_access = copy.deepcopy(base) - full_access["profiles"]["high"]["permissionMode"] = "danger-full-access" - with self.assertRaisesRegex(orc_profiles.ConfigError, "workspace-write in ORC v1"): + full_access["profiles"]["codex"]["high"]["permissionMode"] = "danger-full-access" + with self.assertRaisesRegex(orc_profiles.ConfigError, "workspace-write in ORC v2"): orc_profiles.validate_config(full_access) read_only = copy.deepcopy(base) - read_only["profiles"]["low"]["permissionMode"] = "read-only" - with self.assertRaisesRegex(orc_profiles.ConfigError, "workspace-write in ORC v1"): + read_only["profiles"]["codex"]["low"]["permissionMode"] = "read-only" + with self.assertRaisesRegex(orc_profiles.ConfigError, "workspace-write in ORC v2"): orc_profiles.validate_config(read_only) missing_level = copy.deepcopy(base) - del missing_level["profiles"]["mid"] + del missing_level["profiles"]["cursor-agent"]["mid"] with self.assertRaisesRegex(orc_profiles.ConfigError, "missing fields: mid"): orc_profiles.validate_config(missing_level) option_model = copy.deepcopy(base) - option_model["profiles"]["low"]["model"] = "--model" + option_model["profiles"]["codex"]["low"]["model"] = "--model" with self.assertRaisesRegex(orc_profiles.ConfigError, "safe exact model ID"): orc_profiles.validate_config(option_model) list_policy = copy.deepcopy(base) - list_policy["profiles"]["low"]["approvalPolicy"] = ["never"] + list_policy["profiles"]["codex"]["low"]["approvalPolicy"] = ["never"] with self.assertRaisesRegex(orc_profiles.ConfigError, "approvalPolicy"): orc_profiles.validate_config(list_policy) + cursor_effort = copy.deepcopy(base) + cursor_effort["profiles"]["cursor-agent"]["low"]["reasoningEffort"] = "low" + with self.assertRaisesRegex(orc_profiles.ConfigError, "Cursor requires null"): + orc_profiles.validate_config(cursor_effort) + + mislabeled_cursor = copy.deepcopy(base) + mislabeled_cursor["profiles"]["cursor-agent"]["low"]["model"] = ( + "gpt-5.6-sol-high" + ) + with self.assertRaisesRegex(orc_profiles.ConfigError, "encode the low"): + orc_profiles.validate_config(mislabeled_cursor) + + project_specific = copy.deepcopy(base) + project_specific["worktreePolicy"] = ["."] + with self.assertRaisesRegex( + orc_profiles.ConfigError, + "registered-same-repository", + ): + orc_profiles.validate_config(project_specific) + + default_cli = copy.deepcopy(base) + default_cli["cliPolicy"] = "default-codex" + with self.assertRaisesRegex(orc_profiles.ConfigError, "current-host"): + orc_profiles.validate_config(default_cli) + def test_config_reader_rejects_symlinks_and_redacts_parser_input(self) -> None: with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) @@ -282,16 +391,11 @@ class OrcSkillTests(unittest.TestCase): malformed = root / "malformed.yaml" secret_marker = "PRIVATE_MATERIAL_MUST_NOT_APPEAR" malformed.write_text(f"{secret_marker}: [unterminated\n", encoding="utf-8") - completed = subprocess.run( - [sys.executable, str(SCRIPT), "validate", str(malformed)], - text=True, - capture_output=True, - check=False, - ) - self.assertEqual(completed.returncode, 1) - self.assertNotIn(secret_marker, completed.stderr) + with self.assertRaises(orc_profiles.ConfigError) as error: + orc_profiles.load_config(malformed) + self.assertNotIn(secret_marker, str(error.exception)) - def test_bound_plan_enforces_registered_allowlisted_worktree(self) -> None: + def test_bound_plan_enforces_registered_same_repository_worktree(self) -> None: git = shutil.which("git") self.assertIsNotNone(git) assert git is not None @@ -306,13 +410,14 @@ class OrcSkillTests(unittest.TestCase): check=True, capture_output=True, ) - config_path = root / "docs" / "orc" / "config.yaml" - config_path.parent.mkdir(parents=True) - config_path.write_text(CONFIG.read_text(encoding="utf-8"), encoding="utf-8") fake_codex = root / "trusted-codex" / "codex" fake_codex.parent.mkdir() fake_codex.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") fake_codex.chmod(0o700) + fake_cursor = root / "trusted-cursor" / "cursor-agent" + fake_cursor.parent.mkdir() + fake_cursor.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") + fake_cursor.chmod(0o700) fake_orca = root / "trusted-orca" / "orca" fake_orca.parent.mkdir() fake_orca.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") @@ -322,9 +427,20 @@ class OrcSkillTests(unittest.TestCase): return { "git": git_path, "codex": fake_codex, + "cursor-agent": fake_cursor, "orca": fake_orca, }[name] + def executable_facts(path: Path) -> dict[str, object]: + return { + "path": str(path), + "device": 1, + "inode": 2, + "size": 3, + "mtimeNs": 4, + "version": f"{path.name}-test 1", + } + with ( mock.patch.object( orc_profiles, @@ -339,25 +455,33 @@ class OrcSkillTests(unittest.TestCase): mock.patch.object( orc_profiles, "_executable_facts", - return_value={ - "path": str(fake_codex), - "device": 1, - "inode": 2, - "size": 3, - "mtimeNs": 4, - "version": "codex-test 1", - }, + side_effect=executable_facts, ), ): plan = orc_profiles.build_launch_plan( - config_path, project_root=root, worktree=root, stage="code", + host_cli="codex", stage_level="high", ) + cursor_plan = orc_profiles.build_launch_plan( + project_root=root, + worktree=root, + stage="release", + host_cli="cursor-agent", + stage_level="low", + ) + self.assertEqual(plan["argv"][0], str(fake_codex)) + self.assertEqual(plan["config"]["path"], str(CONFIG)) + self.assertEqual(cursor_plan["argv"][0], str(fake_cursor)) + self.assertEqual(cursor_plan["executable"]["path"], str(fake_cursor)) + self.assertEqual(cursor_plan["argv"][1:3], ["--model", "auto"]) + self.assertIn("--auto-review", cursor_plan["argv"]) + self.assertEqual(cursor_plan["modelAuth"], "cursor-login") + self.assertIn("--host-cli", cursor_plan["launcherArgv"]) self.assertEqual(plan["terminalCreateArgv"][0], str(fake_orca)) self.assertEqual(plan["worktree"], str(root)) self.assertEqual(plan["selectionSource"], "request.stage") @@ -392,10 +516,10 @@ class OrcSkillTests(unittest.TestCase): ): with self.assertRaisesRegex( orc_profiles.ConfigError, - "not in allowedWorktrees", + "not registered in the project repository", ): orc_profiles.validate_worktree( - orc_profiles.load_config(config_path), + orc_profiles.load_config(CONFIG), project_root_value=root, worktree_value=outside, )