diff --git a/skills/ack/SKILL.md b/skills/ack/SKILL.md index 4178484..c76f143 100644 --- a/skills/ack/SKILL.md +++ b/skills/ack/SKILL.md @@ -13,6 +13,13 @@ description: >- 不要复制 Skill 内容,不要修改项目的 `AGENTS.md`、 `CLAUDE.md`。 +执行位置和 profile 选择可以统一写在项目根 `.pouch/agent.yaml` 的 `ack.` 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`、 diff --git a/skills/ack/references/orca-adapter.md b/skills/ack/references/orca-adapter.md index 0d75c8a..34b6076 100644 --- a/skills/ack/references/orca-adapter.md +++ b/skills/ack/references/orca-adapter.md @@ -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.` binding 映射到 +`.pouch/ack/agents.yaml`(存在时)或 `tasks.yaml.project.orchestration` 的结构化 profile 决定,并且只能由 ACK launcher 构造。 先只读生成计划: diff --git a/skills/agent-runtime/README.md b/skills/agent-runtime/README.md new file mode 100644 index 0000000..03efd31 --- /dev/null +++ b/skills/agent-runtime/README.md @@ -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。 diff --git a/skills/agent-runtime/SKILL.md b/skills/agent-runtime/SKILL.md new file mode 100644 index 0000000..0dd75dc --- /dev/null +++ b/skills/agent-runtime/SKILL.md @@ -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 /scripts/agent.py validate --root +python3 /scripts/agent.py show --root --binding builder.android +python3 /scripts/agent.py worktree resolve --root --binding builder.ios +python3 /scripts/agent.py worktree create --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 /scripts/agent.py run start --root --binding builder.android +python3 /scripts/agent.py run status --root --run +python3 /scripts/agent.py run attach --root --run +python3 /scripts/agent.py run resume --root --run +python3 /scripts/agent.py run stop --root --run +``` + +`run start` 只为 builder 生成受控的 make 命令;tester/ACK 的执行入口仍由对应 skill +适配器提供。环境、项目或精确 worktree 缺失时 fail closed,并报告需要补的字段。 + +## 验证 + +- `python3 /scripts/agent.py validate --root ` 通过。 +- remote binding 必须能按 `environment + host + path + project` 精确找到 Orca worktree。 +- 新 binding 只增加配置和对应 adapter 支持,不复制一套 skill 或修改固定枚举。 diff --git a/skills/agent-runtime/agents/openai.yaml b/skills/agent-runtime/agents/openai.yaml new file mode 100644 index 0000000..6ff50a4 --- /dev/null +++ b/skills/agent-runtime/agents/openai.yaml @@ -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." diff --git a/skills/agent-runtime/references/config.md b/skills/agent-runtime/references/config.md new file mode 100644 index 0000000..c8a3949 --- /dev/null +++ b/skills/agent-runtime/references/config.md @@ -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:`,由 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 清理语义。 diff --git a/skills/agent-runtime/scripts/agent.py b/skills/agent-runtime/scripts/agent.py new file mode 100755 index 0000000..2e54b39 --- /dev/null +++ b/skills/agent-runtime/scripts/agent.py @@ -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:") + 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) diff --git a/skills/builder/README.md b/skills/builder/README.md index 204fcbe..d17d3ee 100644 --- a/skills/builder/README.md +++ b/skills/builder/README.md @@ -5,6 +5,9 @@ [references/contract.md](references/contract.md),`scripts/check.py` 是契约的 可执行校验器。 +构建执行位置使用共享 `.pouch/agent.yaml` 的任意 `builder.` 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` 里,不要写进 diff --git a/skills/builder/SKILL.md b/skills/builder/SKILL.md index d8267e6..bf8b893 100644 --- a/skills/builder/SKILL.md +++ b/skills/builder/SKILL.md @@ -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.` 是可自由增加的 binding:可以指向本地 +worktree,也可以指向已配对的远程 Mac/其它构建机;不要把远程主机、模型或 terminal 参数 +硬编码进 `makefile.builder`。跨机器或跨 session 时先读 `agent-runtime`,构建目标仍只能 +由 `makefile.builder` 提供。 + 开始时解析当前 `SKILL.md` 所在目录,记为 ``。优先 `git rev-parse --show-toplevel` 解析项目根。 @@ -28,6 +34,7 @@ submodule,submodule 是独立 builder 项目:它的 `makefile.builder`、`.e - 初始化、接入 builder,或还没有 `makefile.builder`:执行「初始化」。 - 检查契约或发布配置:执行「检查」。 - 构建、发布、上传:执行「工作流」。不要静默初始化。 +- 指定 `builder.`、远程构建机或跨 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 `;需要工具链与发布键时加 `--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.` 执行,先精确解析 `environment + host + project + path` 对应的 worktree;远程 binding 不能 fallback 到本机。持久 session 只显式 attach/resume,不能按标题复用旧 terminal。 4. DEB 上传前:对每个包 `verify_deb.sh `。多个包时不凭文件时间猜测。镜像轨道由 `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、工作区状态和未完成项。 diff --git a/skills/builder/references/contract.md b/skills/builder/references/contract.md index b4d6f3a..08d3534 100644 --- a/skills/builder/references/contract.md +++ b/skills/builder/references/contract.md @@ -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`,调用方式: diff --git a/skills/ios-tester/SKILL.md b/skills/ios-tester/SKILL.md index 0752906..237027b 100644 --- a/skills/ios-tester/SKILL.md +++ b/skills/ios-tester/SKILL.md @@ -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`。 diff --git a/skills/ios-tester/references/environment.md b/skills/ios-tester/references/environment.md index 0faa70c..a500b24 100644 --- a/skills/ios-tester/references/environment.md +++ b/skills/ios-tester/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。 + ## 目标判定 先明确以下之一: diff --git a/skills/tester/SKILL.md b/skills/tester/SKILL.md index 689f4a0..3162ac2 100644 --- a/skills/tester/SKILL.md +++ b/skills/tester/SKILL.md @@ -13,6 +13,11 @@ description: >- 开始时解析本 `SKILL.md` 所在目录为 ``。项目根用 `git rev-parse --show-toplevel`。目录固定为 `/.pouch/tester/`。 +Agent 执行目标优先来自项目根 `.pouch/agent.yaml` 的 `tester.` 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 /scripts/catalog.py ` 时,先用共享 `agent-runtime` 校验并精确解析,再写入兼容的 +tester agent 配置。 + ## 选择模式 - 还没有 `.pouch/tester/catalog.yaml`,或用户说初始化 tester:执行「初始化」。 diff --git a/skills/tester/references/execution.md b/skills/tester/references/execution.md index 130f46e..e3df5eb 100644 --- a/skills/tester/references/execution.md +++ b/skills/tester/references/execution.md @@ -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 选择提交到项目。 diff --git a/skills/tester/scripts/resolve_agent.py b/skills/tester/scripts/resolve_agent.py index 183841a..b6b09bf 100755 --- a/skills/tester/scripts/resolve_agent.py +++ b/skills/tester/scripts/resolve_agent.py @@ -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,16 +269,21 @@ 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. 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: - config = ( - config_from_terminal(args.terminal, args.environment) - if args.terminal - else config_from_project(args.project, args.environment, args.path) - ) + 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 + else config_from_project(args.project, args.environment, args.path) + ) if args.write: path, saved_agent = write_local(Path(args.root).resolve(), config) config = {**config, **saved_agent}