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