feat(agent-runtime): unify agent bindings and sessions

This commit is contained in:
2026-10-12 13:22:47 +08:00
parent 32da57d19b
commit 558c6facc2
15 changed files with 759 additions and 8 deletions
+7
View File
@@ -13,6 +13,13 @@ description: >-
不要复制 Skill 内容,不要修改项目的 `AGENTS.md`、
`CLAUDE.md`。
执行位置和 profile 选择可以统一写在项目根 `.pouch/agent.yaml` 的 `ack.<name>` binding:
`location` 提供 Orca environment/host/project/path,`profile` 提供 cli/model/
`reasoningEffort`/permission。ACK 的 `agents.yaml` 或 tasks orchestration 仍保留其
`profileVersion`、`modelAllowlist`、fingerprint/receipt 字段,并由 ACK adapter 做兼容
映射;不要为了接入 common runtime 删除旧 receipt 或绕过 launcher。远程 ACK worker
必须先按 binding 精确定位 worktree,不能把本机目录当远程目录。
固定配置文件为 `.pouch/ack/project.md`、`.pouch/ack/tasks.yaml`;任务执行记录从
`.pouch/ack/task-records/` 追加读取。旧项目没有 `taskStore` 时仍兼容直接读取
`tasks.yaml`。固定状态文件为 `.pouch/ack/project.md`、`.pouch/ack/tasks.yaml`、
+2 -1
View File
@@ -64,7 +64,8 @@ reset/attestation 后才能启用上述复用路径,不能用项目文档声
## 通过 launcher 创建 worker
**模型不由 Orca dispatch 设置。** `task-create` / `dispatch` 只投递任务;Agent CLI、
模型、reasoning effort 和权限由 `.pouch/ack/agents.yaml`(存在时)或
模型、reasoning effort 和权限由 common `agent.yaml` 的 `ack.<name>` binding 映射到
`.pouch/ack/agents.yaml`(存在时)或
`tasks.yaml.project.orchestration` 的结构化 profile 决定,并且只能由 ACK launcher 构造。
先只读生成计划:
+38
View File
@@ -0,0 +1,38 @@
# agent-runtime
统一维护项目的 Agent profile、Orca 执行环境、worktree 和跨 session terminal。它让
`builder.android`、`builder.ios` 等 binding 成为可配置路由,而不是写死在 skill 代码里的
枚举。
## 什么时候使用
- 给不同 builder/tester/ACK 绑定不同机器、项目目录、Agent CLI 或模型。
- 创建远程 Mac/本地 worktree,并需要后续 session 继续查看构建。
- 检查 `.pouch/agent.yaml` 是否能被所有 adapter 安全读取。
## 使用前准备
- 项目根已有 `.pouch/agent.yaml`。
- 远程 environment 已通过 Orca 配对,且 project/worktree 已存在或被明确创建。
- 机器路径、project id/name 和模型信息由使用者确认;配置不包含凭据。
## 使用示例
```text
把 builder.android 绑定到本地 app,把 builder.ios 绑定到 macmini,创建 iOS worktree,
然后让 builder 继续使用同一个 session 构建。
```
## Agent 会做什么
1. 校验并解析通用配置。
2. 按 environment、host、project、绝对 path 精确定位 Orca worktree。
3. 按需要创建持久 terminal,并记录可显式恢复的 run receipt。
不会自动复用旧 terminal、执行配置中的自由 shell、读取 secrets 或上传产物。
## 如何判断完成
- `agent.py validate` 通过。
- 每个 binding 能唯一解析到目标 worktree,或明确报告需创建/登记的环境。
- session 可用 run id 查询、attach 和 stop。
+59
View File
@@ -0,0 +1,59 @@
---
name: agent-runtime
description: >-
维护项目通用 Agent 运行配置、环境绑定、Orca worktree 与跨 session terminal;
适用于 builder.android、builder.ios、tester.test、ack.dev 等可扩展绑定。
不负责具体构建、测试或 ACK 闭环。
---
# Agent Runtime:统一环境、Agent 与工作区
本 skill 是 builder、tester、ACK 和 iOS tester 共用的配置层。每个 Git 项目在自己的
根目录维护 `.pouch/agent.yaml`;父仓库与 submodule 各自拥有配置,不跨仓库继承。机器
专属覆盖放 `.pouch/agent.local.yaml`,运行记录放 `.pouch/agent/runs/`,两者都不提交。
## 配置模型
- `locations`:执行环境、Orca project、目标机器上的绝对路径;不放模型和命令。
- `profiles`:CLI、模型、reasoning effort、权限和档位;不放路径和 secrets。
- `bindings`:任意安全的 `namespace.name`,绑定一个 adapter、location、profile 和
adapter 目标。`builder.android`、`builder.ios`、`builder.harmony` 不需要修改代码即可并存。
- `adapter` 目前允许 `builder`、`tester`、`ack`。adapter 的具体执行规则仍由对应 skill
负责;本层只提供解析、worktree 和 session 生命周期。
- `reasoningEffort` 是规范字段;不要新增 `effect`、自由 `command`、`argv`、`shell`、
`env` 或 token 字段。
完整字段、合并和安全规则见 [references/config.md](references/config.md)。需要实际解析或
创建工作区时使用 `scripts/agent.py`,不要手工拼接 Orca JSON:
```bash
python3 <skill-dir>/scripts/agent.py validate --root <project-root>
python3 <skill-dir>/scripts/agent.py show --root <project-root> --binding builder.android
python3 <skill-dir>/scripts/agent.py worktree resolve --root <project-root> --binding builder.ios
python3 <skill-dir>/scripts/agent.py worktree create --root <project-root> \
--binding builder.ios --name writes-ios --base main
```
## Session
`builder` 绑定可通过统一入口创建持久 terminal;返回的 run 记录包含 binding、profile
hash、环境、worktree、terminal handle 和 source commit。跨 session 只能显式 `status`、
`attach`、`stop` 或 `resume`,不能自动复用不明 terminal。tester 的每轮 terminal 仍然
是临时的,ACK 仍然必须走其带 fingerprint/receipt 的 launcher。
```bash
python3 <skill-dir>/scripts/agent.py run start --root <project-root> --binding builder.android
python3 <skill-dir>/scripts/agent.py run status --root <project-root> --run <run-id>
python3 <skill-dir>/scripts/agent.py run attach --root <project-root> --run <run-id>
python3 <skill-dir>/scripts/agent.py run resume --root <project-root> --run <run-id>
python3 <skill-dir>/scripts/agent.py run stop --root <project-root> --run <run-id>
```
`run start` 只为 builder 生成受控的 make 命令;tester/ACK 的执行入口仍由对应 skill
适配器提供。环境、项目或精确 worktree 缺失时 fail closed,并报告需要补的字段。
## 验证
- `python3 <skill-dir>/scripts/agent.py validate --root <project-root>` 通过。
- remote binding 必须能按 `environment + host + path + project` 精确找到 Orca worktree。
- 新 binding 只增加配置和对应 adapter 支持,不复制一套 skill 或修改固定枚举。
+4
View File
@@ -0,0 +1,4 @@
interface:
display_name: "Agent Runtime"
short_description: "统一 Agent 环境、worktree、profile 与跨 session 运行配置"
default_prompt: "Use $agent-runtime to validate shared agent config, resolve or create a worktree, or inspect a persistent session."
+73
View File
@@ -0,0 +1,73 @@
# Agent Runtime 配置
## 文件与合并
提交 `.pouch/agent.yaml`。若存在 `.pouch/agent.local.yaml`,按 mapping 深度合并;local
文件只能覆盖同名字段,不能增加未声明的顶层结构。合并后的配置必须再次完整校验。
```yaml
schemaVersion: 1
locations:
local-app:
environment: local
host: local
project: git:git.yumee.top/bloggarden/app
path: /home/ace/code/draft2/blogger/app
remote-ios:
environment: macmini
host: runtime:14b6d8ce-4528-49f4-8d46-56b266a5ad69
project: git:git.yumee.top/bloggarden/app
path: /Users/laily/workrepo/bloggarden-app
profiles:
android-builder:
role: build
cli: codex
tier: standard
model: gpt-5
reasoningEffort: medium
permissionMode: workspace-write
ios-builder:
role: build
cli: codex
tier: strong
model: gpt-5
reasoningEffort: high
permissionMode: workspace-write
bindings:
builder.android:
adapter: builder
location: local-app
profile: android-builder
makefile: makefile.builder
target: android
session:
lifecycle: persistent
builder.ios:
adapter: builder
location: remote-ios
profile: ios-builder
makefile: makefile.builder
target: ios
session:
lifecycle: persistent
```
## 字段原则
- binding、location、profile ID 只允许小写字母、数字和 `-`,binding 用一个或多个点
分隔;ID 是路由名,不是任意 shell 文本。
- `environment: local` 使用当前 Orca runtime;远程环境使用已保存的名称。`host` 必须
是 `local` 或 `runtime:<environment-id>`,由 Orca 查询结果证明,不能凭主机名猜测。
- `project` 必须是 Orca project id 或精确 display name;`path` 必须是目标机器上的绝对
路径。解析 worktree 时四者必须同时匹配,标题、分支和 basename 不能消歧。
- `profile` 的 CLI 只能来自 ACK/tester 共同 allowlist:`codex`、`cursor-agent`、`grok`、
`omp`、`pi`。`model` 为安全模型 ID 或 null;`reasoningEffort` 为 `low|medium|high|xhigh`
或 null;权限只允许 `read-only|workspace-write`。
- builder 的 `target` 只能是 make target 名;`makefile` 只能是项目内相对文件名。脚本、
远端命令、环境变量和凭据由 builder/Orca adapter 提供,不能写入此配置。
- `session.lifecycle` 为 `persistent` 或 `ephemeral`。persistent 只表示允许显式跨
session attach/resume,不表示自动复用旧 terminal。
旧版 `.pouch/tester/local.yaml` 和 `.pouch/ack/agents.yaml` 继续兼容。迁移时应让它们
由 adapter 从 common profile/location 生成或覆盖,不能删除旧 receipt、fingerprint 或
tester 的每轮 terminal 清理语义。
+480
View File
@@ -0,0 +1,480 @@
#!/usr/bin/env python3
"""Validate and execute the shared project Agent runtime contract.
The command surface is deliberately small. It never evaluates project text as
shell, and it only creates builder sessions from a fixed make invocation.
"""
from __future__ import annotations
import argparse
import copy
import hashlib
import json
import os
import re
import subprocess
import sys
import time
import uuid
from pathlib import Path
from typing import Any
try:
import yaml
except ImportError as exc: # pragma: no cover - dependency is checked at runtime
raise SystemExit("需要 PyYAML: python3 -m pip install pyyaml") from exc
CLI_NAMES = {"codex", "cursor-agent", "grok", "omp", "pi"}
ADAPTERS = {"builder", "tester", "ack"}
EFFORTS = {None, "low", "medium", "high", "xhigh"}
PERMISSIONS = {"read-only", "workspace-write"}
TIERS = {"standard", "strong"}
LIFECYCLES = {"persistent", "ephemeral"}
ID_RE = re.compile(r"^[a-z][a-z0-9-]{1,63}$")
BINDING_RE = re.compile(r"^[a-z][a-z0-9-]*(?:\.[a-z][a-z0-9-]*)+$")
TARGET_RE = re.compile(r"^[A-Za-z0-9_.-]{1,96}$")
MODEL_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:/+@-]{0,127}$")
MAKEFILE_RE = re.compile(r"^[A-Za-z0-9_.-]+(?:/[A-Za-z0-9_.-]+)*$")
FORBIDDEN_KEYS = {"command", "argv", "shell", "env", "environmentVariables", "token", "secret", "password"}
class ConfigError(ValueError):
pass
class UniqueLoader(yaml.SafeLoader):
pass
def _construct_mapping(loader: UniqueLoader, node: yaml.MappingNode, deep: bool = False) -> dict[str, Any]:
mapping: dict[str, Any] = {}
for key_node, value_node in node.value:
key = loader.construct_object(key_node, deep=deep)
if not isinstance(key, str):
raise ConfigError("YAML mapping key 必须是字符串")
if key in mapping:
raise ConfigError(f"YAML 存在重复键: {key}")
mapping[key] = loader.construct_object(value_node, deep=deep)
return mapping
UniqueLoader.add_constructor(yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, _construct_mapping)
def load_yaml(path: Path) -> dict[str, Any]:
try:
value = yaml.load(path.read_text(encoding="utf-8"), Loader=UniqueLoader)
except (OSError, yaml.YAMLError) as exc:
raise ConfigError(f"无法读取 {path}: {exc}") from exc
if value is None:
return {}
if not isinstance(value, dict):
raise ConfigError(f"{path} 顶层必须是 mapping")
return value
def deep_merge(base: dict[str, Any], override: dict[str, Any], where: str = "root") -> dict[str, Any]:
result = copy.deepcopy(base)
for key, value in override.items():
if key not in result:
raise ConfigError(f"{where}: local 配置不能增加未声明字段 {key!r}")
if isinstance(result[key], dict) and isinstance(value, dict):
result[key] = deep_merge(result[key], value, f"{where}.{key}")
else:
result[key] = copy.deepcopy(value)
return result
def text(value: Any, where: str) -> str:
if not isinstance(value, str) or not value.strip() or any(ord(c) < 32 for c in value):
raise ConfigError(f"{where}: 必须是非空文本")
return value
def safe_id(value: Any, where: str) -> str:
value = text(value, where)
if not ID_RE.fullmatch(value):
raise ConfigError(f"{where}: ID 只能使用小写字母、数字和连字符")
return value
def safe_binding_id(value: Any, where: str) -> str:
value = text(value, where)
if not BINDING_RE.fullmatch(value):
raise ConfigError(f"{where}: binding 必须是 namespace.name 形式")
return value
def safe_path(value: Any, where: str) -> str:
value = text(value, where)
path = Path(value)
if not path.is_absolute() or value == os.path.sep or ".." in path.parts or os.path.normpath(value) != value:
raise ConfigError(f"{where}: 必须是规范绝对路径")
return value
def validate(config: dict[str, Any]) -> None:
if config.get("schemaVersion") != 1:
raise ConfigError("schemaVersion 必须为 1")
for key in ("locations", "profiles", "bindings"):
if not isinstance(config.get(key), dict) or not config[key]:
raise ConfigError(f"{key}: 必须是非空 mapping")
locations = config["locations"]
for location_id, location in locations.items():
safe_id(location_id, f"locations.{location_id}")
if not isinstance(location, dict):
raise ConfigError(f"locations.{location_id}: 必须是 mapping")
for required in ("environment", "project", "path"):
if required not in location:
raise ConfigError(f"locations.{location_id}: 缺少 {required}")
environment = text(location["environment"], f"locations.{location_id}.environment")
if environment != "local" and not ID_RE.fullmatch(environment):
raise ConfigError(f"locations.{location_id}.environment: 必须是 local 或安全环境名")
text(location["project"], f"locations.{location_id}.project")
safe_path(location["path"], f"locations.{location_id}.path")
host = location.get("host")
if host is not None:
host = text(host, f"locations.{location_id}.host")
if host != "local" and not re.fullmatch(r"runtime:[0-9a-f-]{16,}", host):
raise ConfigError(f"locations.{location_id}.host: 必须是 local 或 runtime:<environment-id>")
profiles = config["profiles"]
for profile_id, profile in profiles.items():
safe_id(profile_id, f"profiles.{profile_id}")
if not isinstance(profile, dict):
raise ConfigError(f"profiles.{profile_id}: 必须是 mapping")
for required in ("cli", "model", "reasoningEffort", "permissionMode"):
if required not in profile:
raise ConfigError(f"profiles.{profile_id}: 缺少 {required}")
if profile["cli"] not in CLI_NAMES:
raise ConfigError(f"profiles.{profile_id}.cli: 不支持 {profile['cli']!r}")
if profile["model"] is not None and MODEL_RE.fullmatch(str(profile["model"])) is None:
raise ConfigError(f"profiles.{profile_id}.model: 不是安全模型 ID")
if profile["reasoningEffort"] not in EFFORTS:
raise ConfigError(f"profiles.{profile_id}.reasoningEffort: 不支持该值")
if profile["permissionMode"] not in PERMISSIONS:
raise ConfigError(f"profiles.{profile_id}.permissionMode: 不支持该值")
if profile.get("tier", "standard") not in TIERS:
raise ConfigError(f"profiles.{profile_id}.tier: 必须是 standard/strong")
for binding_id, binding in config["bindings"].items():
safe_binding_id(binding_id, f"bindings.{binding_id}")
if not isinstance(binding, dict):
raise ConfigError(f"bindings.{binding_id}: 必须是 mapping")
for required in ("adapter", "location", "profile"):
if required not in binding:
raise ConfigError(f"bindings.{binding_id}: 缺少 {required}")
if binding["adapter"] not in ADAPTERS:
raise ConfigError(f"bindings.{binding_id}.adapter: 不支持 {binding['adapter']!r}")
location_id = safe_id(binding["location"], f"bindings.{binding_id}.location")
profile_id = safe_id(binding["profile"], f"bindings.{binding_id}.profile")
if location_id not in locations or profile_id not in profiles:
raise ConfigError(f"bindings.{binding_id}: location/profile 引用不存在")
if binding["adapter"] == "builder":
makefile = text(binding.get("makefile", "makefile.builder"), f"bindings.{binding_id}.makefile")
if Path(makefile).is_absolute() or ".." in Path(makefile).parts or MAKEFILE_RE.fullmatch(makefile) is None:
raise ConfigError(f"bindings.{binding_id}.makefile: 必须是项目内相对路径")
target = text(binding.get("target"), f"bindings.{binding_id}.target")
if TARGET_RE.fullmatch(target) is None:
raise ConfigError(f"bindings.{binding_id}.target: 不是安全 make target")
session = binding.get("session", {})
if not isinstance(session, dict) or session.get("lifecycle", "ephemeral") not in LIFECYCLES:
raise ConfigError(f"bindings.{binding_id}.session.lifecycle: 必须是 persistent/ephemeral")
scan_forbidden(config)
def scan_forbidden(value: Any, where: str = "root") -> None:
if isinstance(value, dict):
for key, child in value.items():
if key in FORBIDDEN_KEYS:
raise ConfigError(f"{where}: 禁止配置字段 {key!r}")
scan_forbidden(child, f"{where}.{key}")
elif isinstance(value, list):
for index, child in enumerate(value):
scan_forbidden(child, f"{where}[{index}]")
def load_config(root: Path) -> dict[str, Any]:
path = root / ".pouch" / "agent.yaml"
if not path.is_file():
raise ConfigError(f"缺少 {path},请先创建项目通用 Agent 配置")
config = load_yaml(path)
local_path = root / ".pouch" / "agent.local.yaml"
if local_path.is_file():
config = deep_merge(config, load_yaml(local_path))
validate(config)
return config
def binding(config: dict[str, Any], binding_id: str) -> dict[str, Any]:
if binding_id not in config["bindings"]:
raise ConfigError(f"不存在 binding: {binding_id}")
value = copy.deepcopy(config["bindings"][binding_id])
value["id"] = binding_id
value["locationId"] = value["location"]
value["location"] = copy.deepcopy(config["locations"][value["locationId"]])
value["profileId"] = value["profile"]
value["profile"] = copy.deepcopy(config["profiles"][value["profileId"]])
return value
def orca_json(args: list[str], environment: str | None = None) -> dict[str, Any]:
command = ["orca", *args]
if environment and environment != "local":
command.extend(["--environment", environment])
command.append("--json")
proc = subprocess.run(command, capture_output=True, text=True, check=False)
raw = (proc.stdout or "").strip()
try:
data = json.loads(raw)
except json.JSONDecodeError as exc:
detail = (proc.stderr or raw or str(exc)).strip()
raise ConfigError(f"Orca 没有返回 JSON: {' '.join(command)}\n{detail}") from exc
if not data.get("ok"):
error = data.get("error") or {}
raise ConfigError(f"{' '.join(command)}: {error.get('message') or error.get('code') or 'unknown error'}")
result = data.get("result")
return result if isinstance(result, dict) else {}
def environment_id(name: str) -> str:
if name == "local":
return "local"
result = orca_json(["environment", "list"])
for item in result.get("environments", []):
if isinstance(item, dict) and item.get("name") == name:
return str(item.get("id"))
raise ConfigError(f"找不到已保存 Orca environment: {name}")
def location_host(location: dict[str, Any]) -> str:
host = location.get("host")
if host:
return str(host)
if location["environment"] == "local":
return "local"
return f"runtime:{environment_id(str(location['environment']))}"
def worktree_for(binding_value: dict[str, Any]) -> dict[str, Any]:
location = binding_value["location"]
environment = str(location["environment"])
result = orca_json(["worktree", "list"], environment)
candidates = []
for item in result.get("worktrees", []):
if not isinstance(item, dict):
continue
if str(item.get("projectId")) != str(location["project"]):
continue
if str(item.get("path")) != str(location["path"]):
continue
expected_host = location_host(location)
# A routed remote runtime stamps its own worktrees with hostId=local;
# the environment name/ID is the remote-machine discriminator.
if location["environment"] == "local" and expected_host == "local" and str(item.get("hostId")) != "local":
continue
candidates.append(item)
if len(candidates) != 1:
if not candidates:
raise ConfigError(
f"找不到精确 worktree: environment={environment}, host={location_host(location)}, "
f"project={location['project']}, path={location['path']};先创建或登记该 worktree"
)
raise ConfigError(f"binding 匹配到多个 worktree: {len(candidates)}")
return candidates[0]
def project_root_commit(root: Path) -> str | None:
proc = subprocess.run(["git", "-C", str(root), "rev-parse", "HEAD"], capture_output=True, text=True, check=False)
return proc.stdout.strip() if proc.returncode == 0 else None
def run_dir(root: Path) -> Path:
path = root / ".pouch" / "agent" / "runs"
path.mkdir(parents=True, exist_ok=True)
return path
def run_path(root: Path, run_id: str) -> Path:
if not re.fullmatch(r"run_[0-9a-f]{16,64}", run_id):
raise ConfigError("run id 格式无效")
return run_dir(root) / f"{run_id}.json"
def cmd_validate(args: argparse.Namespace) -> int:
config = load_config(Path(args.root).resolve())
print(json.dumps({"ok": True, "schemaVersion": config["schemaVersion"], "bindings": sorted(config["bindings"])}, ensure_ascii=False, indent=2))
return 0
def cmd_show(args: argparse.Namespace) -> int:
config = load_config(Path(args.root).resolve())
value = binding(config, args.binding)
print(json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True))
return 0
def cmd_worktree_resolve(args: argparse.Namespace) -> int:
root = Path(args.root).resolve()
value = binding(load_config(root), args.binding)
worktree = worktree_for(value)
print(json.dumps({"binding": args.binding, "worktree": worktree}, ensure_ascii=False, indent=2))
return 0
def cmd_worktree_create(args: argparse.Namespace) -> int:
root = Path(args.root).resolve()
value = binding(load_config(root), args.binding)
location = value["location"]
try:
existing = worktree_for(value)
except ConfigError as exc:
if "找不到精确 worktree" not in str(exc):
raise
existing = None
if existing:
print(json.dumps({"created": False, "binding": args.binding, "worktree": existing}, ensure_ascii=False, indent=2))
return 0
host = location_host(location)
command = ["worktree", "create", "--project", str(location["project"]), "--host", host, "--name", args.name, "--no-parent"]
if args.base:
command.extend(["--base-branch", args.base])
result = orca_json(command, str(location["environment"]))
print(json.dumps({"created": True, "binding": args.binding, "result": result, "next": "将返回 worktree 的 path 写回 location.path 后再运行 resolve"}, ensure_ascii=False, indent=2))
return 0
def make_command(value: dict[str, Any]) -> str:
return f"make -f {value.get('makefile', 'makefile.builder')} {value['target']}"
def cmd_run_start(args: argparse.Namespace) -> int:
root = Path(args.root).resolve()
value = binding(load_config(root), args.binding)
if value["adapter"] != "builder":
raise ConfigError(f"run start 目前只为 builder 提供受控 make session;{value['adapter']} 请走对应 skill adapter")
if value.get("session", {}).get("lifecycle", "ephemeral") != "persistent":
raise ConfigError(f"binding {args.binding} 不是 persistent session")
worktree = worktree_for(value)
environment = str(value["location"]["environment"])
result = orca_json(["terminal", "create", "--worktree", f"id:{worktree['id']}", "--command", make_command(value), "--title", args.binding], environment)
terminal = result.get("terminal") or result
handle = terminal.get("handle") or result.get("handle")
if not handle:
raise ConfigError("Orca terminal create 没有返回 handle")
run_id = "run_" + uuid.uuid4().hex
record = {
"schemaVersion": 1,
"id": run_id,
"binding": args.binding,
"adapter": value["adapter"],
"profileId": value["profileId"],
"profileHash": hashlib.sha256(json.dumps(value["profile"], sort_keys=True).encode()).hexdigest(),
"environment": environment,
"project": value["location"]["project"],
"path": value["location"]["path"],
"worktreeId": worktree["id"],
"terminalHandle": handle,
"command": make_command(value),
"sourceCommit": project_root_commit(root),
"createdAt": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"status": "started",
}
run_path(root, run_id).write_text(json.dumps(record, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(json.dumps(record, ensure_ascii=False, indent=2))
if args.wait_ms:
wait = orca_json(["terminal", "wait", "--terminal", handle, "--for", "exit", "--timeout-ms", str(args.wait_ms)], environment)
record["wait"] = wait
record["status"] = "completed" if wait.get("exited") or wait.get("status") == "exited" else "waited"
run_path(root, run_id).write_text(json.dumps(record, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(json.dumps(wait, ensure_ascii=False, indent=2))
return 0
def load_run(root: Path, run_id: str) -> dict[str, Any]:
path = run_path(root, run_id)
if not path.is_file():
raise ConfigError(f"找不到 run 记录: {run_id}")
value = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(value, dict):
raise ConfigError(f"run 记录无效: {run_id}")
return value
def cmd_run_status(args: argparse.Namespace) -> int:
root = Path(args.root).resolve()
record = load_run(root, args.run)
result = orca_json(["terminal", "show", "--terminal", record["terminalHandle"]], record.get("environment"))
print(json.dumps({"run": record, "terminal": result}, ensure_ascii=False, indent=2))
return 0
def cmd_run_attach(args: argparse.Namespace) -> int:
root = Path(args.root).resolve()
record = load_run(root, args.run)
result = orca_json(["terminal", "read", "--terminal", record["terminalHandle"]], record.get("environment"))
print(json.dumps(result, ensure_ascii=False, indent=2))
return 0
def cmd_run_resume(args: argparse.Namespace) -> int:
"""Explicitly reattach to a recorded terminal; never search for another one."""
root = Path(args.root).resolve()
record = load_run(root, args.run)
result = orca_json(["terminal", "show", "--terminal", record["terminalHandle"]], record.get("environment"))
print(json.dumps({"resumed": True, "run": record, "terminal": result}, ensure_ascii=False, indent=2))
return 0
def cmd_run_stop(args: argparse.Namespace) -> int:
root = Path(args.root).resolve()
record = load_run(root, args.run)
result = orca_json(["terminal", "close", "--terminal", record["terminalHandle"]], record.get("environment"))
record["status"] = "stopped"
record["stoppedAt"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
run_path(root, args.run).write_text(json.dumps(record, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(json.dumps({"run": record, "close": result}, ensure_ascii=False, indent=2))
return 0
def parser() -> argparse.ArgumentParser:
root_parent = argparse.ArgumentParser(add_help=False)
root_parent.add_argument("--root", default=".")
parser = argparse.ArgumentParser(description="共享 Agent 配置、worktree 和 session 运行时")
sub = parser.add_subparsers(dest="command", required=True)
validate_parser = sub.add_parser("validate", parents=[root_parent])
validate_parser.set_defaults(func=cmd_validate)
show = sub.add_parser("show", parents=[root_parent])
show.add_argument("--binding", required=True)
show.set_defaults(func=cmd_show)
wt = sub.add_parser("worktree")
wt_sub = wt.add_subparsers(dest="worktree_command", required=True)
resolve = wt_sub.add_parser("resolve", parents=[root_parent])
resolve.add_argument("--binding", required=True)
resolve.set_defaults(func=cmd_worktree_resolve)
create = wt_sub.add_parser("create", parents=[root_parent])
create.add_argument("--binding", required=True)
create.add_argument("--name", required=True)
create.add_argument("--base", default="")
create.set_defaults(func=cmd_worktree_create)
run = sub.add_parser("run")
run_sub = run.add_subparsers(dest="run_command", required=True)
start = run_sub.add_parser("start", parents=[root_parent])
start.add_argument("--binding", required=True)
start.add_argument("--wait-ms", type=int, default=0)
start.set_defaults(func=cmd_run_start)
for name, func in (("status", cmd_run_status), ("attach", cmd_run_attach), ("resume", cmd_run_resume), ("stop", cmd_run_stop)):
item = run_sub.add_parser(name, parents=[root_parent])
item.add_argument("--run", required=True)
item.set_defaults(func=func)
return parser
if __name__ == "__main__":
try:
parsed = parser().parse_args()
raise SystemExit(parsed.func(parsed))
except ConfigError as exc:
print(f"environment-fail: {exc}", file=sys.stderr)
raise SystemExit(2)
+6
View File
@@ -5,6 +5,9 @@
[references/contract.md](references/contract.md),`scripts/check.py` 是契约的
可执行校验器。
构建执行位置使用共享 `.pouch/agent.yaml` 的任意 `builder.<name>` binding 配置;本地
Android、远程 Mac iOS 或未来其它平台都可以各自绑定 environment/project/path/profile。
## 什么时候使用
- "用 builder 初始化这个项目"
@@ -67,6 +70,9 @@ COPY_AS=latest.apk
`make -f makefile.builder push-docker`。
写了 `ARCHES=amd64 arm64` 时,这两条会打出并上传两个 DEB;镜像一次推多平台。
跨机器或需要继续查看的构建先用 `agent-runtime` 精确解析 binding 和 Orca worktree;
builder 只在授权后执行对应 `makefile.builder` target,不自动上传。
## 多架构
项目要发哪些 CPU 架构,写在**已提交的** `makefile.builder` 里,不要写进
+8 -1
View File
@@ -20,6 +20,12 @@ submodule,submodule 是独立 builder 项目:它的 `makefile.builder`、`.e
发布目标和本轮别名放在被忽略的 `.env.builder`。`COPY_SCRIPT` 若指向项目内
`.pouch/builder/` 的脚本,优先写入 `makefile.builder`,这样迁移项目时配置与脚本一起进入 Git。
构建执行位置、Agent CLI、模型、推理强度和 session 生命周期由共享 `agent-runtime` 的
`.pouch/agent.yaml` 管理。`builder.<name>` 是可自由增加的 binding:可以指向本地
worktree,也可以指向已配对的远程 Mac/其它构建机;不要把远程主机、模型或 terminal 参数
硬编码进 `makefile.builder`。跨机器或跨 session 时先读 `agent-runtime`,构建目标仍只能
由 `makefile.builder` 提供。
开始时解析当前 `SKILL.md` 所在目录,记为 `<skill-dir>`。优先
`git rev-parse --show-toplevel` 解析项目根。
@@ -28,6 +34,7 @@ submodule,submodule 是独立 builder 项目:它的 `makefile.builder`、`.e
- 初始化、接入 builder,或还没有 `makefile.builder`:执行「初始化」。
- 检查契约或发布配置:执行「检查」。
- 构建、发布、上传:执行「工作流」。不要静默初始化。
- 指定 `builder.<name>`、远程构建机或跨 session:先读共享 `agent-runtime`。
- 不适用:本地安装/卸载 DEB;RPM/语言包;从零设计打包体系(先出方案);普通编码与 Dockerfile 编辑。
- 改本 skill 自身:契约先改 `scripts/check.py`,再同步 [contract.md](references/contract.md) 与 templates;不在生产上传上试脚本。
@@ -70,7 +77,7 @@ COPY_AS=latest.apk
1. 跑 `check.py <project-dir>`;需要工具链与发布键时加 `--ready`;要实构 deb 时加 `--build`。契约 FAIL 或轨道工具缺失:停下,转入「初始化」,不要绕过校验,不要改用户 Makefile。只缺发布键:允许构建,禁止上传。未接契约的存量项目按 contract.md §6 发现已有入口,上传仍用 builder 脚本;成功交付一次后引导迁到 `makefile.builder`。
2. 仅当用户明确要求发布、上传或提交时才上传。不猜测生产端点;不把 token 写入输出、文件、提交或回复;不用 `set -x` 跑含凭据的命令。可能覆盖同版本时先问。脏工作树默认拒绝发布;用户明确接受时设 `ALLOW_UNCOMMITTED=1` 并注明未提交修改。
3. 构建:用户指定了架构才传 `ARCH=`;否则不要传,让 makefile 按 `ARCHES` 展开(`make -s -f makefile.builder arches`)。`make -f makefile.builder build` 始终单架构;DEB 用 `make -f makefile.builder deb`。版本按契约从 Git 祖先稳定 tag 推导,不要调用 releaser,不要用全仓库最新 tag。仅获构建授权时不要走会自动上传的目标。脚本含 `rm -rf` 时先确认为受限构建目录。
3. 构建:用户指定了架构才传 `ARCH=`;否则不要传,让 makefile 按 `ARCHES` 展开(`make -s -f makefile.builder arches`)。`make -f makefile.builder build` 始终单架构;DEB 用 `make -f makefile.builder deb`。版本按契约从 Git 祖先稳定 tag 推导,不要调用 releaser,不要用全仓库最新 tag。仅获构建授权时不要走会自动上传的目标。脚本含 `rm -rf` 时先确认为受限构建目录。若通过 `builder.<name>` 执行,先精确解析 `environment + host + project + path` 对应的 worktree;远程 binding 不能 fallback 到本机。持久 session 只显式 attach/resume,不能按标题复用旧 terminal。
4. DEB 上传前:对每个包 `verify_deb.sh <exact-package-path.deb> <version> <arch>`。多个包时不凭文件时间猜测。镜像轨道由 `publish_docker.sh` 自带远端 inspect。
5. 发布优先 `make -f makefile.builder push[-deb|-docker|-copy]`,或直接调 `upload_deb.sh` / `publish_docker.sh` / `copy_artifact.sh`。未指定 `ARCH=` 时 `push-deb` 上传清单里每一个 `.deb`。拷贝轨道至少配置 `COPY_DEST`(本地目录或 `container:/dir`)或 `COPY_SCRIPT`(项目内脚本优先在 `makefile.builder` 设置;同时配置时命令行/环境值优先)。自定义脚本按 `FILE [FILE ...]` 接收产物路径,自行完成重命名、上传、远端校验和通知;相对路径按项目根解析,`BUILDER_PROJECT_ROOT` 提供项目根。`COPY_AS` 只由内置 `COPY_DEST` 路径处理。脚本缺环境变量时加载 `.env.builder`(shell 显式值优先),不读 `.env`。不要把 token 当命令行参数;脚本可随项目放在 `.pouch/builder/` 下。多平台镜像只能走 `publish_docker.sh`,不能拆进 make;不要把项目架构清单写进 `.env.builder`。`build` 里禁止 `docker cp`。
6. 发布成功不能只看「curl/push 已执行」。要有退出码、HTTP 成功或远端 digest;索引异步时报告「上传已接受,索引尚待更新」。最终回复给出包名/镜像引用、版本、架构、SHA-256 或 digest、源 commit、工作区状态和未完成项。
+6 -1
View File
@@ -9,7 +9,12 @@ builder 脚本只做发布,不做项目特定的构建逻辑。
`.pouch/builder/` 分发脚本属于 submodule;父仓库不得替它承载这些配置。
分工原则:**make 管构建(项目内、确定性),skill 脚本管发布(跨项目 SSOT),
Agent 只保留授权判断和歧义处理。**
Agent 只保留授权判断和歧义处理。** 构建在哪台机器执行、使用哪个 CLI/model 和
session 如何保存,由共享 `agent-runtime` 的 binding 决定;makefile 不能承载主机或
Agent 选择。
`builder.android`、`builder.ios`、`builder.harmony` 都是合法的可配置 binding。新增平台
只增加 binding 和对应 make target,不改 builder skill 的固定枚举。
契约文件固定为项目根 `makefile.builder`,调用方式:
+4
View File
@@ -11,6 +11,10 @@ description: >-
本 skill 负责 iOS 测试环境判定、测试执行编排和结果报告,不伪造 Linux 上不存在的
iOS Simulator。项目根是 `git rev-parse --show-toplevel`。
远程 Mac 的 environment/project/path、Agent CLI、model 和 reasoning effort 使用共享
`agent-runtime` 的 binding(例如 `builder.ios` 或 `tester.ios`);本 skill 只负责 iOS
工具链和设备环境门禁。Linux 没有本地 iOS 工具链时,不能把远程 binding 静默改成本机。
## 模式路由
- 用户要求检查环境:先读 `references/environment.md`。
@@ -1,5 +1,9 @@
# iOS 通用环境检查
远程 Mac 的执行位置统一由 `.pouch/agent.yaml` 的 `locations`/`bindings` 提供。必须按
environment、host、project、绝对 path 精确解析 Orca worktree;没有唯一 worktree 时记为
`environment-fail`,不能按项目名称、分支或 basename 猜测,也不能 fallback 到 Linux。
## 目标判定
先明确以下之一:
+8
View File
@@ -13,6 +13,11 @@ description: >-
开始时解析本 `SKILL.md` 所在目录为 `<skill-dir>`。项目根用
`git rev-parse --show-toplevel`。目录固定为 `<root>/.pouch/tester/`。
Agent 执行目标优先来自项目根 `.pouch/agent.yaml` 的 `tester.<name>` binding,由共享
`agent-runtime` 解析 environment、host、project、path 和 profile。`.pouch/tester/local.yaml`
仍是兼容覆盖层:解析 binding 后可 materialize 为旧 `agent` mapping,但不应再维护两套
互相矛盾的主配置;binding 名称不是固定枚举。
用脚本读写目录,不要把 `cases/` 整库灌进上下文。术语与命名理由见 [references/concepts.md](references/concepts.md):
```bash
@@ -24,6 +29,9 @@ python3 <skill-dir>/scripts/catalog.py <list|select|show|validate|next-id|run-sc
`.pouch/tester/local.yaml` 的 `agent` 配置;配置更新完成后输出生效配置列表。该列表是
更新结果,不是测试启动前的人工确认门槛。
用户给出 `tester.<name>` 时,先用共享 `agent-runtime` 校验并精确解析,再写入兼容的
tester agent 配置。
## 选择模式
- 还没有 `.pouch/tester/catalog.yaml`,或用户说初始化 tester:执行「初始化」。
+14
View File
@@ -6,6 +6,20 @@
## 配置
推荐在项目根统一配置:
```yaml
bindings:
tester.test:
adapter: tester
location: local-app
profile: test-agent
```
`locations` 和 `profiles` 的定义见共享 `agent-runtime`。tester adapter 只把它 materialize
到本轮兼容配置;每轮 terminal 仍然新建并在结束时关闭,不能因为 binding 是 persistent
就复用测试 terminal。
`catalog.yaml` 提供可提交的默认值,`.pouch/tester/local.yaml` 可以覆盖同名字段。
机器名、远程环境、项目路径和 Agent 选择优先写在 `local.yaml`,不要把个人机器路径
或临时 Agent 选择提交到项目。
+41
View File
@@ -4,6 +4,7 @@
from __future__ import annotations
import argparse
import importlib.util
import json
import subprocess
import sys
@@ -176,6 +177,41 @@ def clean_config(config: dict[str, Any]) -> dict[str, Any]:
return {key: value for key, value in config.items() if not key.startswith("_")}
def config_from_binding(root: Path, selector: str) -> dict[str, Any]:
"""Materialize a common agent-runtime tester binding for legacy local.yaml."""
runtime_path = Path(__file__).resolve().parents[2] / "skills" / "agent-runtime" / "scripts" / "agent.py"
if not runtime_path.is_file():
raise RuntimeError(f"找不到共享 agent-runtime: {runtime_path}")
spec = importlib.util.spec_from_file_location("pouch_agent_runtime", runtime_path)
if spec is None or spec.loader is None:
raise RuntimeError(f"无法加载共享 agent-runtime: {runtime_path}")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
try:
common = module.load_config(root)
value = module.binding(common, selector)
except (ValueError, OSError) as exc:
raise RuntimeError(str(exc)) from exc
if value.get("adapter") != "tester":
raise RuntimeError(f"binding {selector} 的 adapter 不是 tester")
location = value["location"]
profile = value["profile"]
return {
"environment": "" if location.get("environment") == "local" else str(location.get("environment") or ""),
"host": str(location.get("host") or "local"),
"project": str(location.get("project") or ""),
"path": str(location.get("path") or ""),
"role": "test",
"cli": str(profile.get("cli") or "codex"),
"tier": str(profile.get("tier") or "standard"),
"model": profile.get("model"),
"reasoningEffort": profile.get("reasoningEffort"),
"permissionMode": str(profile.get("permissionMode") or "workspace-write"),
"_source": f"binding:{selector}",
}
def write_local(root: Path, config: dict[str, Any]) -> tuple[Path, dict[str, Any]]:
path = root / ".pouch" / "tester" / "local.yaml"
path.parent.mkdir(parents=True, exist_ok=True)
@@ -233,11 +269,16 @@ def main(argv: list[str] | None = None) -> int:
source = parser.add_mutually_exclusive_group(required=True)
source.add_argument("--terminal", help="Orca terminal handle")
source.add_argument("--project", help="Orca project id、名称或路径")
source.add_argument("--binding", help="common agent.yaml 中的 tester.<name> binding")
parser.add_argument("--path", help="项目有多个 worktree 时按目标机器上的绝对路径消歧")
parser.add_argument("--environment", help="限定 Orca environment")
parser.add_argument("--write", action="store_true", help="写入 .pouch/tester/local.yaml")
args = parser.parse_args(argv)
try:
root = Path(args.root).resolve()
if args.binding:
config = config_from_binding(root, args.binding)
else:
config = (
config_from_terminal(args.terminal, args.environment)
if args.terminal