diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bf910d2..bffe574 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -84,7 +84,7 @@ jobs: "$smoke_root/wheel-venv/bin/python" -c "import experiments.local_agent_dispatch" "$smoke_root/wheel-venv/bin/python" -I -c "import dyro.continuation" "$smoke_root/wheel-venv/bin/python" -I -c "from dyro.console.assets import validate_assets; validate_assets()" - "$smoke_root/wheel-venv/bin/python" -I -c "from importlib.resources import files; root=files('dyro.integrations').joinpath('assets'); assert root.joinpath('dyro-control-plane','SKILL.md').is_file(); assert root.joinpath('dyro-control-plane','agents','openai.yaml').is_file(); assert root.joinpath('dyro-dispatch','SKILL.md').is_file(); assert root.joinpath('dyro-dispatch','agents','openai.yaml').is_file(); assert not root.joinpath('dyro-readonly').is_dir()" + "$smoke_root/wheel-venv/bin/python" -I -c "from importlib.resources import files; root=files('dyro.integrations').joinpath('assets'); assert root.joinpath('dyro-control-plane','SKILL.md').is_file(); assert root.joinpath('dyro-control-plane','agents','openai.yaml').is_file(); assert root.joinpath('dyro-dispatch','SKILL.md').is_file(); assert root.joinpath('dyro-dispatch','agents','openai.yaml').is_file(); assert root.joinpath('dyro-executor','SKILL.md').is_file(); assert root.joinpath('dyro-executor','agents','openai.yaml').is_file(); assert root.joinpath('dyro-board','SKILL.md').is_file(); assert root.joinpath('dyro-board','agents','openai.yaml').is_file(); assert not root.joinpath('dyro-readonly').is_dir()" test -x "$smoke_root/wheel-venv/bin/dyro" test ! -e "$smoke_root/wheel-venv/bin/dyro-bridge" test ! -e "$smoke_root/wheel-venv/bin/dyro-mcp" @@ -96,7 +96,7 @@ jobs: "$smoke_root/sdist-venv/bin/pip" install "${sdist_artifact}" "$smoke_root/sdist-venv/bin/python" -I -c "import dyro.continuation" "$smoke_root/sdist-venv/bin/python" -I -c "from dyro.console.assets import validate_assets; validate_assets()" - "$smoke_root/sdist-venv/bin/python" -I -c "from importlib.resources import files; root=files('dyro.integrations').joinpath('assets'); assert root.joinpath('dyro-control-plane','SKILL.md').is_file(); assert root.joinpath('dyro-control-plane','agents','openai.yaml').is_file(); assert root.joinpath('dyro-dispatch','SKILL.md').is_file(); assert root.joinpath('dyro-dispatch','agents','openai.yaml').is_file(); assert not root.joinpath('dyro-readonly').is_dir()" + "$smoke_root/sdist-venv/bin/python" -I -c "from importlib.resources import files; root=files('dyro.integrations').joinpath('assets'); assert root.joinpath('dyro-control-plane','SKILL.md').is_file(); assert root.joinpath('dyro-control-plane','agents','openai.yaml').is_file(); assert root.joinpath('dyro-dispatch','SKILL.md').is_file(); assert root.joinpath('dyro-dispatch','agents','openai.yaml').is_file(); assert root.joinpath('dyro-executor','SKILL.md').is_file(); assert root.joinpath('dyro-executor','agents','openai.yaml').is_file(); assert root.joinpath('dyro-board','SKILL.md').is_file(); assert root.joinpath('dyro-board','agents','openai.yaml').is_file(); assert not root.joinpath('dyro-readonly').is_dir()" test -x "$smoke_root/sdist-venv/bin/dyro" test ! -e "$smoke_root/sdist-venv/bin/dyro-bridge" test ! -e "$smoke_root/sdist-venv/bin/dyro-mcp" diff --git a/.github/workflows/pypi-publish.yml b/.github/workflows/pypi-publish.yml index 4eabcc4..27b81ec 100644 --- a/.github/workflows/pypi-publish.yml +++ b/.github/workflows/pypi-publish.yml @@ -149,7 +149,7 @@ jobs: "$smoke_root/wheel-venv/bin/pip" install "$GITHUB_WORKSPACE"/dist/dyro-*.whl "$smoke_root/wheel-venv/bin/python" -c "import experiments.local_agent_dispatch" "$smoke_root/wheel-venv/bin/python" -I -c "from dyro.console.assets import validate_assets; validate_assets()" - "$smoke_root/wheel-venv/bin/python" -I -c "from importlib.resources import files; root=files('dyro.integrations').joinpath('assets'); assert root.joinpath('dyro-control-plane','SKILL.md').is_file(); assert root.joinpath('dyro-control-plane','agents','openai.yaml').is_file(); assert root.joinpath('dyro-dispatch','SKILL.md').is_file(); assert root.joinpath('dyro-dispatch','agents','openai.yaml').is_file()" + "$smoke_root/wheel-venv/bin/python" -I -c "from importlib.resources import files; root=files('dyro.integrations').joinpath('assets'); assert root.joinpath('dyro-control-plane','SKILL.md').is_file(); assert root.joinpath('dyro-control-plane','agents','openai.yaml').is_file(); assert root.joinpath('dyro-dispatch','SKILL.md').is_file(); assert root.joinpath('dyro-dispatch','agents','openai.yaml').is_file(); assert root.joinpath('dyro-executor','SKILL.md').is_file(); assert root.joinpath('dyro-executor','agents','openai.yaml').is_file(); assert root.joinpath('dyro-board','SKILL.md').is_file(); assert root.joinpath('dyro-board','agents','openai.yaml').is_file()" "$smoke_root/wheel-venv/bin/python" -I -c "import importlib.util; assert importlib.util.find_spec('dyro.bridge') is None" test -x "$smoke_root/wheel-venv/bin/dyro" test ! -e "$smoke_root/wheel-venv/bin/dyro-bridge" @@ -161,7 +161,7 @@ jobs: "$smoke_root/sdist-venv/bin/pip" install "$GITHUB_WORKSPACE"/dist/dyro-*.tar.gz "$smoke_root/sdist-venv/bin/python" -c "import experiments.local_agent_dispatch" "$smoke_root/sdist-venv/bin/python" -I -c "from dyro.console.assets import validate_assets; validate_assets()" - "$smoke_root/sdist-venv/bin/python" -I -c "from importlib.resources import files; root=files('dyro.integrations').joinpath('assets'); assert root.joinpath('dyro-control-plane','SKILL.md').is_file(); assert root.joinpath('dyro-control-plane','agents','openai.yaml').is_file(); assert root.joinpath('dyro-dispatch','SKILL.md').is_file(); assert root.joinpath('dyro-dispatch','agents','openai.yaml').is_file()" + "$smoke_root/sdist-venv/bin/python" -I -c "from importlib.resources import files; root=files('dyro.integrations').joinpath('assets'); assert root.joinpath('dyro-control-plane','SKILL.md').is_file(); assert root.joinpath('dyro-control-plane','agents','openai.yaml').is_file(); assert root.joinpath('dyro-dispatch','SKILL.md').is_file(); assert root.joinpath('dyro-dispatch','agents','openai.yaml').is_file(); assert root.joinpath('dyro-executor','SKILL.md').is_file(); assert root.joinpath('dyro-executor','agents','openai.yaml').is_file(); assert root.joinpath('dyro-board','SKILL.md').is_file(); assert root.joinpath('dyro-board','agents','openai.yaml').is_file()" "$smoke_root/sdist-venv/bin/python" -I -c "import importlib.util; assert importlib.util.find_spec('dyro.bridge') is None" test -x "$smoke_root/sdist-venv/bin/dyro" test ! -e "$smoke_root/sdist-venv/bin/dyro-bridge" diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b8934b..46b5295 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ ## Unreleased +- First-party Skill seats now include `dyro-executor` and `dyro-board` beside + the existing control-plane and dispatch Skills. Setup and an opted-in + control plane install or sync the whole first batch. Each seat auto-loads + from context; loading a seat is not consent to mutate. Opening a task + prints the executor seat; opening a line prints the navigator seat. + `dyro-reviewer` and personal skill directories stay out. + ## 0.7.3 - 2026-08-18 - Console home no longer treats empty attention as idle. The overview diff --git a/README.md b/README.md index 93c22a8..0182753 100644 --- a/README.md +++ b/README.md @@ -288,11 +288,13 @@ python3 -m pip install --user --upgrade dyro ``` Interactive `dyro setup` can install the first-party Skill bundle during personal -preferences. That one opt-in installs both `dyro-control-plane` and the separate -`dyro-dispatch` Skill. Existing managed control-plane installations automatically -gain the Dispatch companion on the next interactive launch or package refresh, and -both managed Skills then stay synchronized with Dyro updates. Machines that have -never opted into a Dyro Skill are not modified silently. +preferences. That one opt-in installs the first-batch seats: `dyro-control-plane`, +`dyro-executor`, `dyro-board`, and `dyro-dispatch`. Existing managed control-plane +installations automatically gain the new companions on the next interactive +launch or package refresh, and managed Skills then stay synchronized with Dyro +updates. Machines that have never opted into a Dyro Skill are not modified +silently. Each seat auto-loads from context; loading a seat is not consent to +mutate. Multi-harness delegation remains separate from the read-only control plane so its process/network effects are explicit. Manual installation is still available: diff --git a/README.zh-CN.md b/README.zh-CN.md index b17bb5d..20f2493 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -285,10 +285,11 @@ dyro --version python3 -m pip install --user --upgrade dyro ``` -交互式 `dyro setup` 可在个人偏好步骤一次启用第一方 Skill 套件,同时安装 -`dyro-control-plane` 与独立的 `dyro-dispatch`。已经托管控制面 Skill 的用户会在下次 -交互启动或包更新后自动补装 Dispatch,此后两项都会随 Dyro 自动同步;从未启用过 -Dyro Skill 的机器不会被后台静默写入。 +交互式 `dyro setup` 可在个人偏好步骤一次启用第一方 Skill 套件,同时安装首批座位: +`dyro-control-plane`、`dyro-executor`、`dyro-board` 与 `dyro-dispatch`。已经托管 +控制面 Skill 的用户会在下次交互启动或包更新后自动补装新座位,此后都会随 Dyro +自动同步;从未启用过 Dyro Skill 的机器不会被后台静默写入。座位按情景自动戴上, +戴上不等于同意突变。 多 Harness 委派仍与只读控制面隔离,确保 Provider 进程与网络副作用需要明确请求。 也可手动运行 `dyro integration install dispatch --dry-run` 预览,再用 diff --git a/docs/adr/0006-delivery-physics-and-capability-plane.md b/docs/adr/0006-delivery-physics-and-capability-plane.md index c31f153..5d46364 100644 --- a/docs/adr/0006-delivery-physics-and-capability-plane.md +++ b/docs/adr/0006-delivery-physics-and-capability-plane.md @@ -64,7 +64,7 @@ - 产品叙事从「启动 agent」转为「核验完成」。 - `0.7.0` 落地 Proof、Capability Card、Host Compiler 与 `verify-bundle`。`trusted_usage` 默认 `false`;生产 `BudgetUsage` / `BudgetRequest` 从 Card 读取该字段。`objective tick` 对 automatic Objective 做预算预览(`automatic=True`),受监督 apply 仍 `automatic=False`。未信任用量只在存在 `workspace.max_provider_usage` 时硬停。Proof 仍不接入 `no_progress`。Console summary 保持 `proof_inspection=not_inspected`,不探 Git / Proof;`dyro objective attention` 走完整快照,可报 `PROOF_DECAYED`。两套入口不得写成同一套 Proof 展示。 -- Console 独立 inspect、`trigger_observation`、陌生人核验与叙事锁已在 `0.7.1` 落地。`0.7.2` 收口换工具 briefing 与 Console「现在需要你」。`0.7.3` 把空关注收回未读,并去掉首页与 `next` repair 的路径泄漏。后续功能与产品面收口继续开发和上线,版本号保持 `0.7.x`。 +- Console 独立 inspect、`trigger_observation`、陌生人核验与叙事锁已在 `0.7.1` 落地。`0.7.2` 收口换工具 briefing 与 Console「现在需要你」。`0.7.3` 把空关注收回未读,并去掉首页与 `next` repair 的路径泄漏。第一方 Skill 首批座位是控制面、执行、评审板与 Dispatch:按情景自动戴上,戴上不是突变授权;不扫描用户 skill 目录。后续功能与产品面收口继续开发和上线,版本号保持 `0.7.x`。 - 可携带核验的对外承诺仍是:陌生人拿着 Proof Bundle 和自己提供的 git 对象,能得到与源机**相同的完整性结论**(字节仍在、钉死 SHA 可解析)。这不是身份证明,也不是「现在工作区还能 merge」。`schema_version = 1` 的合同在 `0.7.x` 锁住语义;冻结成 `1.0.0` 身份号须另做产品决定。 - 实施成本是新的投影层与兼容层,而不是第二套调度器。 diff --git a/docs/agent-orchestration-discipline.md b/docs/agent-orchestration-discipline.md index 5b89a7f..e5e38ed 100644 --- a/docs/agent-orchestration-discipline.md +++ b/docs/agent-orchestration-discipline.md @@ -58,7 +58,7 @@ Agent 输出永远是**建议**;进入交付链必须经过 Dyro 契约与独 ## 3. 对抗评审记录协议(摘要) -完整模板见 [可选本地 Agent 派发设计 §6](designs/optional-local-agent-dispatch.md#6-对抗评审记录协议)。 +第一方座位 `dyro-board` 在用户提出会审 / 对抗 / Go/No-Go 时自动戴上;记录仍不是 Proof。完整模板见 [可选本地 Agent 派发设计 §6](designs/optional-local-agent-dispatch.md#6-对抗评审记录协议)。 硬规则: diff --git a/docs/architecture.md b/docs/architecture.md index c0038ea..cf5499a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -241,6 +241,6 @@ Dyro 的交付拓扑与之**实质相近**:TaskGraph(`depends_on` / conflict 未来的 adapter、通知、签名规则、发布平台与审批系统应使用 Python entry point 或独立 Profile 扩展包接入;不要把某个组织的策略加入 core 默认行为。 -`0.7.0` 把已有证据物理学抽成可复验的 Proof,并把 argv adapter 升级为 Capability Card,再把定律编译为只收缩权威的宿主投影。衰减与现有 merge / 下游检查同真值;`proof verify` 看当前工作区,`verify-bundle` 只核完整性,两套结论不得混称。Console summary 与 `dyro objective attention` 不是同一套 Proof 展示。Console 独立 inspect、`trigger_observation` 与可携带核验门禁已在 `0.7.1` 落地。`0.7.2` 把换工具后的开场白收成事项加一条只读下一步,不另开会话层。`0.7.3` 把 Console 空关注收回未读,并不再在首页和下一条修复命令里漏本地路径。后续功能继续在 `0.7.x` 开发和上线,不另开 `0.8.0` / `0.9.0` / `1.0.0` 功能号。可携带核验是 Proof Bundle 加调用方提供的 git 对象,核验完整性而不是身份,也不承诺与当前 merge 同一套 `live`。这不另造 TaskGraph 或完成状态机;见 [`交付物理学`](designs/delivery-physics.md) 与 [`ADR-0006`](adr/0006-delivery-physics-and-capability-plane.md)。 +`0.7.0` 把已有证据物理学抽成可复验的 Proof,并把 argv adapter 升级为 Capability Card,再把定律编译为只收缩权威的宿主投影。衰减与现有 merge / 下游检查同真值;`proof verify` 看当前工作区,`verify-bundle` 只核完整性,两套结论不得混称。Console summary 与 `dyro objective attention` 不是同一套 Proof 展示。Console 独立 inspect、`trigger_observation` 与可携带核验门禁已在 `0.7.1` 落地。`0.7.2` 把换工具后的开场白收成事项加一条只读下一步,不另开会话层。`0.7.3` 把 Console 空关注收回未读,并不再在首页和下一条修复命令里漏本地路径。第一方 Skill 首批座位是控制面、执行、评审板与 Dispatch:按情景自动戴上,戴上不等于同意突变;不扫描用户 skill 目录。后续功能继续在 `0.7.x` 开发和上线,不另开 `0.8.0` / `0.9.0` / `1.0.0` 功能号。可携带核验是 Proof Bundle 加调用方提供的 git 对象,核验完整性而不是身份,也不承诺与当前 merge 同一套 `live`。这不另造 TaskGraph 或完成状态机;见 [`交付物理学`](designs/delivery-physics.md) 与 [`ADR-0006`](adr/0006-delivery-physics-and-capability-plane.md)。 开发者侧的可选本地多 Agent 派发(五段式任务契约、注入前机密守卫、locator 核验、隔离 patch)与上述控制面分层并列,随 `dyro` 安装包分发(`dyro dispatch` / `import experiments.local_agent_dispatch`),但**不**替代 gates/合并。同时写多块走 Core Peer Wave(task worktree + `conflict_group`),见 [`peer-wave-execution.md`](designs/peer-wave-execution.md)、[`ADR-0002`](adr/0002-optional-local-agent-dispatch.md)、[`多智能体编排纪律`](agent-orchestration-discipline.md) 与 [`可选本地 Agent 派发设计`](designs/optional-local-agent-dispatch.md)。 diff --git a/docs/updates.md b/docs/updates.md index 5c4f9f4..488ded5 100644 --- a/docs/updates.md +++ b/docs/updates.md @@ -61,8 +61,9 @@ After a successful `dyro update` or patch auto-update, Dyro best-effort syncs the managed bundle through the fresh `dyro` entry point. Interactive `dyro`, `dyro home`, and `dyro start` launches also repair outdated managed Skills. The control-plane opt-in covers first-party companions, so an existing managed -control plane automatically gains `dyro-dispatch`. A machine with no prior Dyro -Skill ownership remains untouched. Manual control-plane commands are: +control plane automatically gains `dyro-dispatch`, `dyro-executor`, and +`dyro-board`. A machine with no prior Dyro Skill ownership remains untouched. +Manual control-plane commands are: ```bash dyro integration install skill --dry-run @@ -72,17 +73,20 @@ dyro integration sync skill --yes # upgrade-only; skips absent installs (`codex` is an alias for `skill`.) -The outbound `dyro-dispatch` Skill keeps independent ownership state from the -read-only control-plane Skill. One `dyro setup` opt-in installs both. An existing -managed control-plane installation also gains the Dispatch companion on the next -interactive launch or post-update refresh; after that, both stay synchronized. -Hosts with no prior Dyro Skill opt-in remain untouched. Manual lifecycle commands -are still available: +The outbound `dyro-dispatch`, executor, and board Skills keep independent +ownership state from the read-only control-plane Skill. One `dyro setup` opt-in +installs the first-batch seat bundle. An existing managed control-plane +installation also gains those companions on the next interactive launch or +post-update refresh; after that they stay synchronized. Hosts with no prior +Dyro Skill opt-in remain untouched. Manual lifecycle commands are still +available: ```bash dyro integration install dispatch --dry-run dyro integration install dispatch --yes dyro integration sync dispatch --yes +dyro integration install executor --yes +dyro integration install board --yes ``` Editable source installations are deliberately rejected. Update those through diff --git a/pyproject.toml b/pyproject.toml index b676254..f52c2e9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -67,6 +67,10 @@ packages = [ "assets/dyro-control-plane/agents/openai.yaml", "assets/dyro-dispatch/SKILL.md", "assets/dyro-dispatch/agents/openai.yaml", + "assets/dyro-executor/SKILL.md", + "assets/dyro-executor/agents/openai.yaml", + "assets/dyro-board/SKILL.md", + "assets/dyro-board/agents/openai.yaml", ] [tool.unittest] diff --git a/src/dyro/cli.py b/src/dyro/cli.py index f69bc9d..47c153b 100644 --- a/src/dyro/cli.py +++ b/src/dyro/cli.py @@ -139,6 +139,7 @@ sync_managed_skill, uninstall_integration, ) +from .integrations.seats import COMPANION_IDS, managed_skill_bundle from .onboarding import ( SetupPlan, append_repository, @@ -265,10 +266,7 @@ """ -_MANAGED_SKILL_BUNDLE: tuple[tuple[str, str], ...] = ( - ("skill", "控制面"), - ("dispatch", "Dispatch"), -) +_MANAGED_SKILL_BUNDLE: tuple[tuple[str, str], ...] = managed_skill_bundle() def _config(args: argparse.Namespace) -> Config: @@ -891,7 +889,7 @@ def _setup_skill_preference() -> bool: ) if hosts: host_text = "、".join(hosts) - prompt = f"Dyro Skills(控制面 + Dispatch;挂接到:{host_text})" + prompt = f"Dyro Skills(控制面 + 执行 + 评审板 + Dispatch;挂接到:{host_text})" option_one = "安装 / 同步到已检测宿主(推荐)" default = "1" else: @@ -902,7 +900,7 @@ def _setup_skill_preference() -> bool: prompt, ( ("1", option_one), - ("2", "稍后手动安装(dyro integration install skill / dispatch)"), + ("2", "稍后手动安装(dyro integration install skill / executor / board / dispatch)"), ), default=default, ) @@ -969,7 +967,7 @@ def _render_setup_personal_preferences( print(" - Skills:无法安装:未检测到 Agent 宿主目录") print( " · 确认后会 soft-fail;请安装宿主后运行 " - "dyro integration install skill / dispatch" + "dyro integration install skill / executor / board / dispatch" ) return plans = [] @@ -985,7 +983,7 @@ def _render_setup_personal_preferences( if not plans: print(" - Skills:" + muted("已是当前版本")) else: - print(" - Skills:安装 / 同步控制面与 Dispatch(镜像 + 宿主分身)") + print(" - Skills:安装 / 同步控制面、执行、评审板与 Dispatch(镜像 + 宿主分身)") for label, plan in plans: print(f" · {label}") for change in plan.changes: @@ -1149,7 +1147,7 @@ def _print_setup_completion( print( " - Skills:" + warning( - "安装未成功;可运行 dyro integration status skill / dispatch 排查" + "安装未成功;可运行 dyro integration status skill / executor / board / dispatch 排查" ) ) else: @@ -4260,7 +4258,7 @@ def build_parser() -> argparse.ArgumentParser: integration_install_parser.add_argument( "id", choices=INTEGRATION_CHOICES, - help="skill 为控制面 Skill;dispatch 为多 Harness 派发 Skill;codex 为兼容别名", + help="skill 为控制面;executor 为执行座位;board 为评审板;dispatch 为派发;codex 为 skill 别名", ) integration_install_parser.add_argument( "--yes", action="store_true", help="确认执行已预览的安装或升级" @@ -4279,7 +4277,7 @@ def build_parser() -> argparse.ArgumentParser: integration_sync_parser.add_argument( "id", choices=INTEGRATION_CHOICES, - help="skill 为控制面 Skill;dispatch 为多 Harness 派发 Skill;codex 为兼容别名", + help="skill 为控制面;executor 为执行座位;board 为评审板;dispatch 为派发;codex 为 skill 别名", ) integration_sync_parser.add_argument( "--yes", action="store_true", help="确认执行已预览的同步或升级" @@ -5053,24 +5051,34 @@ def _fresh_dyro_argv(*cli_args: str) -> list[str]: def _refresh_skill_via_new_cli() -> None: """Best-effort Skill-bundle sync through the freshly installed CLI.""" requests = [("sync", "skill")] - try: - control_status = integration_status("skill") - dispatch_status = integration_status("dispatch") - except (DyroError, OSError, ValidationError): - control_status = None - dispatch_status = None - if control_status is not None and control_status.state in { - IntegrationState.CURRENT, - IntegrationState.OUTDATED, - }: + statuses = {} + for integration, _label in _MANAGED_SKILL_BUNDLE: + try: + statuses[integration] = integration_status(integration) + except (DyroError, OSError, ValidationError): + continue + control_status = statuses.get("skill") + control_opted_in = ( + control_status is not None + and control_status.state + in {IntegrationState.CURRENT, IntegrationState.OUTDATED} + ) + if control_opted_in: # Existing control-plane ownership is the user's one-time opt-in to the - # first-party Skill bundle. Install a newly shipped companion safely. - requests.append(("install", "dispatch")) - elif dispatch_status is not None and dispatch_status.state in { - IntegrationState.CURRENT, - IntegrationState.OUTDATED, - }: - requests.append(("sync", "dispatch")) + # first-party Skill bundle. Install newly shipped companions safely. + for integration in COMPANION_IDS: + status = statuses.get(integration) + if status is not None and ( + status.state is IntegrationState.CURRENT + or _skill_status_blocks_automatic_change(status.state) + ): + continue + requests.append(("install", integration)) + else: + for integration in COMPANION_IDS: + status = statuses.get(integration) + if status is not None and status.state is IntegrationState.OUTDATED: + requests.append(("sync", integration)) print("正在同步已托管的 Dyro Skills……") for action, integration in requests: @@ -5127,7 +5135,7 @@ def _maybe_sync_managed_skill() -> None: if status is None or _skill_status_blocks_automatic_change(status.state): continue allow_first_install = ( - integration == "dispatch" + integration in COMPANION_IDS and status.state is IntegrationState.ABSENT and control_opted_in ) diff --git a/src/dyro/home.py b/src/dyro/home.py index 36f9796..6092f4f 100644 --- a/src/dyro/home.py +++ b/src/dyro/home.py @@ -12,6 +12,7 @@ from .continuation.briefing import render_briefing_text from .continuation.ready_briefing import build_ready_briefing from .errors import DyroError, ValidationError +from .integrations.seats import render_seat_notice, select_launch_seat from .hub import ( WorkspaceRecord, add_workspace, @@ -642,6 +643,9 @@ def launch_start_tool( prompt: str = "", dry_run: bool, ) -> None: + print( + render_seat_notice(select_launch_seat(task=task, line=line)) + ) if tool.kind == "adapter": launch_adapter( config, @@ -2219,9 +2223,13 @@ def _run_config_home( if line is None: return kind, target_id = line.kind, line.id + task_id = "" + line_id = target_id if kind == "task": task = load_task(config, target_id) target_workspace = existing_task_workspace(config, task) + line_id = task.line + task_id = task.id else: _, target_workspace = existing_line_workspace(config, target_id, kind) tool = _choose_tool(config, record, workspace=target_workspace, dry_run=dry_run) @@ -2231,14 +2239,15 @@ def _run_config_home( mark_workspace_used( record.name, target_kind=kind, target_id=target_id, agent=tool.id ) - if tool.kind == "launcher": - launch_home_tool(workspace=target_workspace, tool=tool, dry_run=dry_run) - elif kind == "task": - open_task(config, target_id, agent=tool.id, prompt="", dry_run=dry_run) - else: - open_line( - config, target_id, kind=kind, agent=tool.id, prompt="", dry_run=dry_run - ) + launch_start_tool( + config, + workspace=target_workspace, + tool=tool, + line=line_id, + task=task_id, + prompt="", + dry_run=dry_run, + ) def run_home(*, root: str | None, workspace: str | None, dry_run: bool) -> None: diff --git a/src/dyro/integrations/assets/dyro-board/SKILL.md b/src/dyro/integrations/assets/dyro-board/SKILL.md new file mode 100644 index 0000000..f833dd5 --- /dev/null +++ b/src/dyro/integrations/assets/dyro-board/SKILL.md @@ -0,0 +1,56 @@ +--- +name: dyro-board +description: Run Dyro's first-party adversarial review-board protocol. Use when the user asks for multi-model review, 会审, 对抗, 能不能发, Go/No-Go, or P0/P1 arbitration. Auto-load this board seat. Findings are advisory records, not Proof and not a task review PASS. +--- + +# Dyro Review Board + +Run the first-party adversarial record protocol. Auto-load this seat when the +user asks for a board, 会审, 对抗, release Go/No-Go, or P0/P1 arbitration. Do +not wait to be named. Loading the seat is not merge, signoff, or Proof. + +## Auto-trigger + +Load immediately when the user asks to: + +- run a review board, multi-model review, or independent opinions +- 会审, 对抗, 能不能发 +- produce P0/P1/P2 or Go/No-Go + +Do not load this seat to write product code. That is `dyro-executor`. Do not +load it for a receipt-bound `task review`; that Core command still owns PASS. +Do not scan personal host skill directories. + +## Record protocol + +1. One shared review file. Prefer + `docs/reviews/YYYY-MM-DD--adversarial-board.md`. +2. Each reviewer writes only in a signed section. Do not edit, rewrite, or + summarize another section. +3. Source code and live contracts outrank plans and prior reviews. +4. Unprovable claims are `须人工核`. +5. Final arbitration deduplicates, resolves conflicts, and emits P0/P1/P2, + Go/No-Go, and the next human Dyro command if one already exists. + +## Authority + +- Board output is a record. It is not a Proof, gate, or `task review` PASS. +- Do not merge, push, signoff, `objective apply`, `dispatch run`, or + `task run` because the board said Go. +- Do not promote model votes, screenshots, or summaries into delivery truth. +- If Dyro `next.commands` is empty, do not manufacture a mutation. + +## Optional orientation + +When the user already named a workspace alias, a read-only status check is +allowed: + +```bash +dyro --workspace next --format json +``` + +## Handoff + +Keep the shared file as the record. In chat, state the verdict, the P0/P1 +list, unknowns marked `须人工核`, and that delivery still uses Dyro gates +and review. Omit local paths the user did not supply. diff --git a/src/dyro/integrations/assets/dyro-board/agents/openai.yaml b/src/dyro/integrations/assets/dyro-board/agents/openai.yaml new file mode 100644 index 0000000..122f937 --- /dev/null +++ b/src/dyro/integrations/assets/dyro-board/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Dyro Review Board" + short_description: "Adversarial review record, not delivery proof" + default_prompt: "Use $dyro-board. Run the signed review-board protocol. Findings are advisory; they are not Proof or task review PASS." diff --git a/src/dyro/integrations/assets/dyro-control-plane/SKILL.md b/src/dyro/integrations/assets/dyro-control-plane/SKILL.md index 5428b36..eb35dd7 100644 --- a/src/dyro/integrations/assets/dyro-control-plane/SKILL.md +++ b/src/dyro/integrations/assets/dyro-control-plane/SKILL.md @@ -1,12 +1,14 @@ --- name: dyro-control-plane -description: Inspect Dyro control-plane state and prepare bounded read-only explanations and plans from a coding agent. Use for registered workspace discovery, workspace health, status, or safe-next-step questions, development-line or Change Set inspection, integration health, or existing Objective status, blockers, attention, graph, wave preview, and plan requests. Never use for execution or delivery mutations. +description: Inspect Dyro control-plane state and prepare bounded read-only explanations and plans from a coding agent. Use when the user asks what is next, what is blocked, workspace or objective status, 下一步, 堵住了, or after switching coding tools. Auto-load this navigator seat; do not wait to be named. Never use for execution or delivery mutations. --- # Dyro Control Plane Treat Dyro as the delivery control plane. Run only the allowlisted observations below, prefer their JSON output, and leave every state-changing action to the user in Dyro. +Auto-load this navigator seat at a workspace or line root, after switching tools, or when the user asks 下一步 / 堵住了 / status. Loading the seat is not consent to mutate. Writing in a task worktree is `dyro-executor`. 会审 / 对抗 is `dyro-board`. Parallel harnesses are `dyro-dispatch`. + ## Read-only routing When the request already supplies a workspace alias, skip global discovery and use that alias directly. Otherwise start with `dyro workspace list --format json`; when more than one workspace is available, ask the user to choose an alias and never guess. Then use the narrowest matching command: @@ -17,6 +19,8 @@ When the request already supplies a workspace alias, skip global discovery and u - Lines or hotfixes: `dyro --workspace line list [--kind line|hotfix] --format json` - Change Sets: `dyro --workspace changeset list --format json` or `dyro --workspace changeset verify --format json` - Installed control-plane Skill health: `dyro integration status skill --format json` +- Installed executor Skill health: `dyro integration status executor --format json` +- Installed board Skill health: `dyro integration status board --format json` - Installed dispatch Skill health: `dyro integration status dispatch --format json` - Objective inventory or facts: `dyro --workspace objective list --format json` or `dyro --workspace objective status --format json` - Objective explanation: `dyro --workspace objective explain --format json`. JSON may include `briefing` (human matter plus one read-only command). @@ -43,7 +47,7 @@ Keep the handoff short and separate evidence from judgment: Only hand off a workspace-scoped mutation when the returned command retains an explicit `--workspace ` or absolute `--root ` selector. Never reconstruct, shorten, retarget, or strip that selector. When `next.commands` is empty or `mutation_available` is false, do not manufacture a mutation from `diagnostic_commands`, findings, or prose. -Use P0/P1/P2 and Go/No-Go only when the user explicitly requests an adversarial review or release decision. A CLI summary alone is never final runtime or production acceptance. +If the user asks for 会审, 对抗, or Go/No-Go, follow `dyro-board` instead of inventing P0 here. A CLI summary alone is never final runtime or production acceptance. ## Hard safety boundary diff --git a/src/dyro/integrations/assets/dyro-dispatch/SKILL.md b/src/dyro/integrations/assets/dyro-dispatch/SKILL.md index 96a40fa..8ca7785 100644 --- a/src/dyro/integrations/assets/dyro-dispatch/SKILL.md +++ b/src/dyro/integrations/assets/dyro-dispatch/SKILL.md @@ -1,6 +1,6 @@ --- name: dyro-dispatch -description: Plan and dispatch bounded advisory work to local coding-agent harnesses, or help split delivery work into parallel Core tasks. Use when the user explicitly asks to parallelize, delegate, compare harnesses, or obtain independent opinions. Default write-parallel work is Core peer wave (task worktrees), not one writer plus watchers. +description: Plan and dispatch bounded advisory work to local coding-agent harnesses, or help split delivery work into parallel Core tasks. Use when the user asks to parallelize, compare harnesses, delegate, 并行, 对比, or 几个 agent. Auto-load this seat on those words; starting a Provider still requires this-turn parallel intent. Default write-parallel work is Core peer wave (task worktrees), not one writer plus watchers. --- # Dyro Dispatch @@ -10,8 +10,9 @@ Treat `dyro dispatch` as an outbound harness, separate from the read-only ## Authorization boundary -- Start Providers only after the user explicitly requests parallel, delegated, - multi-harness, or independent-agent work. A Skill trigger alone is not consent. +- Auto-load this seat when the user asks to parallelize, 并行, 对比, or split + work across agents. A Skill appearing alone is not consent to start a + Provider. Starting one still requires this-turn parallel intent. - Treat Provider execution as a local-state, process, and potentially network or usage-billed effect even when the delegated task is read-only. - Use `mode=edit` only when the user also authorizes code changes. Scratch diff --git a/src/dyro/integrations/assets/dyro-executor/SKILL.md b/src/dyro/integrations/assets/dyro-executor/SKILL.md new file mode 100644 index 0000000..ee33a98 --- /dev/null +++ b/src/dyro/integrations/assets/dyro-executor/SKILL.md @@ -0,0 +1,57 @@ +--- +name: dyro-executor +description: Constrain a coding agent that is already writing in a Dyro task worktree. Use when the working directory is a task worktree, the user says fix this, implement, 修这个, 做完, or Dyro opened a task. Auto-load this executor seat. Write only in the claimed task; never merge, push, signoff, or resume another harness chat. +--- + +# Dyro Executor + +You are the single writer for one claimed Dyro task worktree. Auto-load this +seat from that context. Do not wait to be named. Loading the seat is not +permission to merge, push, start another harness, or leave this task tree. + +## Auto-trigger + +Load immediately when any of these are true: + +- The current working directory is a Dyro task worktree. +- Dyro just opened a task (`task open` / start into a task). +- The user says to implement, fix, finish, 修这个, 做完, or 继续写. + +Do not load this seat at a workspace or line root. Use `dyro-control-plane` +there. Do not load it for 会审 / 对抗; that is `dyro-board`. + +## Write boundary + +- Write only inside the current task worktree and its declared repositories. +- One `conflict_group` has exactly one writer. You are that writer. Do not + supervise other agents and do not edit a sibling task. +- If a Capability Card exists for your executor and lacks `execute`, stop + writing. PATH discovery is not a Card. +- Do not `git merge`, `git push`, `dyro task merge`, `task signoff`, + `objective apply`, `dispatch`, `console`, or `start` another tool. +- Do not resume another harness conversation or read vendor session directories. +- Do not invent mutations from `doctor`, `next`, briefing text, or this skill. + +## Evidence + +Agent text is not completion. Leave the files, tests, and notes the task asked +for. Do not mark the task `done`. Do not claim gates passed because tests +looked green. If Dyro attention says the user must act, stop and say so. + +## When you need orientation + +Prefer one path-free read if the user did not already supply the workspace +alias: + +```bash +dyro --workspace next --format json +``` + +Treat `briefing.command` as a read. When `next.commands` is empty, do not +manufacture `apply` or `task run`. + +## Handoff + +Report what changed in this task tree, what you did not touch, and any +attention that needs the user. Keep local paths out of the reply unless the +user supplied that exact path. diff --git a/src/dyro/integrations/assets/dyro-executor/agents/openai.yaml b/src/dyro/integrations/assets/dyro-executor/agents/openai.yaml new file mode 100644 index 0000000..58a4790 --- /dev/null +++ b/src/dyro/integrations/assets/dyro-executor/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Dyro Executor" + short_description: "Stay on the claimed task write seat" + default_prompt: "Use $dyro-executor. You are already the writer in this Dyro task worktree. Stay in seat; do not merge or resume another chat." diff --git a/src/dyro/integrations/manager.py b/src/dyro/integrations/manager.py index 5493168..1a5d214 100644 --- a/src/dyro/integrations/manager.py +++ b/src/dyro/integrations/manager.py @@ -22,15 +22,31 @@ bounded_directory_names, ) from ..state import atomic_write_text, exclusive_lock, fsync_directory +from .seats import ( + BOARD_ID, + BOARD_SKILL, + CONTROL_PLANE_ID, + CONTROL_PLANE_SKILL, + DISPATCH_ID, + DISPATCH_SKILL, + EXECUTOR_ID, + EXECUTOR_SKILL, +) -CANONICAL_INTEGRATION_ID = "skill" +CANONICAL_INTEGRATION_ID = CONTROL_PLANE_ID LEGACY_INTEGRATION_ID = "codex" -SKILL_NAME = "dyro-control-plane" -ASSET_VERSION = 3 -DISPATCH_INTEGRATION_ID = "dispatch" -DISPATCH_SKILL_NAME = "dyro-dispatch" -DISPATCH_ASSET_VERSION = 1 +SKILL_NAME = CONTROL_PLANE_SKILL +ASSET_VERSION = 4 +DISPATCH_INTEGRATION_ID = DISPATCH_ID +DISPATCH_SKILL_NAME = DISPATCH_SKILL +DISPATCH_ASSET_VERSION = 2 +EXECUTOR_INTEGRATION_ID = EXECUTOR_ID +EXECUTOR_SKILL_NAME = EXECUTOR_SKILL +EXECUTOR_ASSET_VERSION = 1 +BOARD_INTEGRATION_ID = BOARD_ID +BOARD_SKILL_NAME = BOARD_SKILL +BOARD_ASSET_VERSION = 1 MANIFEST_SCHEMA_VERSION = 2 LEGACY_MANIFEST_SCHEMA_VERSION = 1 _SHA256_PREFIX = "sha256:" @@ -57,9 +73,21 @@ class SkillIntegrationSpec: skill_name=DISPATCH_SKILL_NAME, asset_version=DISPATCH_ASSET_VERSION, ) +EXECUTOR_SPEC = SkillIntegrationSpec( + integration_id=EXECUTOR_INTEGRATION_ID, + skill_name=EXECUTOR_SKILL_NAME, + asset_version=EXECUTOR_ASSET_VERSION, +) +BOARD_SPEC = SkillIntegrationSpec( + integration_id=BOARD_INTEGRATION_ID, + skill_name=BOARD_SKILL_NAME, + asset_version=BOARD_ASSET_VERSION, +) SKILL_INTEGRATIONS: tuple[SkillIntegrationSpec, ...] = ( CONTROL_PLANE_SPEC, DISPATCH_SPEC, + EXECUTOR_SPEC, + BOARD_SPEC, ) INTEGRATION_CHOICES: tuple[str, ...] = tuple( choice diff --git a/src/dyro/integrations/seats.py b/src/dyro/integrations/seats.py new file mode 100644 index 0000000..23a5709 --- /dev/null +++ b/src/dyro/integrations/seats.py @@ -0,0 +1,123 @@ +"""First-party Skill seats: auto-trigger load, never auto-mutate.""" + +from __future__ import annotations + +from dataclasses import dataclass + + +CONTROL_PLANE_ID = "skill" +DISPATCH_ID = "dispatch" +EXECUTOR_ID = "executor" +BOARD_ID = "board" + +CONTROL_PLANE_SKILL = "dyro-control-plane" +DISPATCH_SKILL = "dyro-dispatch" +EXECUTOR_SKILL = "dyro-executor" +BOARD_SKILL = "dyro-board" + + +@dataclass(frozen=True) +class SeatSpec: + integration_id: str + skill_name: str + label: str + intent: str + companion: bool + trigger_terms: tuple[str, ...] + + +SEATS: tuple[SeatSpec, ...] = ( + SeatSpec( + CONTROL_PLANE_ID, + CONTROL_PLANE_SKILL, + "控制面", + "observe", + False, + ( + "what is next", + "下一步", + "堵住了", + "switching coding tools", + "Auto-load this navigator seat", + ), + ), + SeatSpec( + DISPATCH_ID, + DISPATCH_SKILL, + "Dispatch", + "dispatch", + True, + ( + "parallelize", + "并行", + "对比", + "几个 agent", + "Auto-load this seat on those words", + ), + ), + SeatSpec( + EXECUTOR_ID, + EXECUTOR_SKILL, + "执行", + "execute", + True, + ( + "task worktree", + "修这个", + "做完", + "implement", + "Auto-load this executor seat", + ), + ), + SeatSpec( + BOARD_ID, + BOARD_SKILL, + "评审板", + "board", + True, + ( + "会审", + "对抗", + "能不能发", + "Go/No-Go", + "Auto-load this board seat", + ), + ), +) + +FIRST_BATCH_IDS: tuple[str, ...] = tuple(seat.integration_id for seat in SEATS) +COMPANION_IDS: tuple[str, ...] = tuple( + seat.integration_id for seat in SEATS if seat.companion +) + + +def seat_by_id(integration_id: str) -> SeatSpec: + for seat in SEATS: + if seat.integration_id == integration_id: + return seat + raise KeyError(integration_id) + + +def managed_skill_bundle() -> tuple[tuple[str, str], ...]: + return tuple((seat.integration_id, seat.label) for seat in SEATS) + + +def select_launch_seat(*, task: str = "", line: str = "") -> str: + """Pick the seat that must auto-load when Dyro opens a tool.""" + _ = line + if str(task).strip(): + return EXECUTOR_ID + return CONTROL_PLANE_ID + + +def render_seat_notice(seat_id: str) -> str: + seat = seat_by_id(seat_id) + if seat.integration_id == EXECUTOR_ID: + closer = "只在本任务 worktree 写;不要 merge / push / 恢复另一家会话。" + elif seat.integration_id == CONTROL_PLANE_ID: + closer = "先观察 next / attention;不要自己 apply 或 merge。" + elif seat.integration_id == DISPATCH_ID: + closer = "先 dry-run 拆法;本轮没有并行意图就不要启动 Provider。" + else: + closer = "意见写入共享审查文件;不要当成 Proof 或 task review PASS。" + return f"座位 {seat.label} · {seat.skill_name}\n{closer}" diff --git a/tests/test_cli.py b/tests/test_cli.py index 142c3bc..d39e78e 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -684,6 +684,8 @@ def test_apply_setup_personal_preferences_installs_skill_when_requested( [ call("skill", yes=True, allow_first_install=True), call("dispatch", yes=True, allow_first_install=True), + call("executor", yes=True, allow_first_install=True), + call("board", yes=True, allow_first_install=True), ], ) self.assertEqual(outcome, "success") @@ -713,6 +715,26 @@ def test_setup_skill_preference_offers_missing_dispatch_companion(self) -> None: AvatarStatus("codex", Path("/tmp/codex"), "missing", "missing"), ), ), + "executor": IntegrationStatus( + "executor", + IntegrationState.ABSENT, + Path("/tmp/executor"), + Path("/tmp/executor.json"), + "absent", + avatars=( + AvatarStatus("codex", Path("/tmp/codex"), "missing", "missing"), + ), + ), + "board": IntegrationStatus( + "board", + IntegrationState.ABSENT, + Path("/tmp/board"), + Path("/tmp/board.json"), + "absent", + avatars=( + AvatarStatus("codex", Path("/tmp/codex"), "missing", "missing"), + ), + ), } with ( patch("dyro.cli.integration_status", side_effect=statuses.__getitem__), @@ -749,18 +771,23 @@ class StartTests(WorkspaceCase): def test_start_dry_run_uses_selected_line_and_adapter(self) -> None: config = load(self.root) create_line(config, line_id="alpha", branch="feat/alpha", base="main") - main( - [ - "--root", - str(self.root), - "--dry-run", - "start", - "--line", - "alpha", - "--agent", - "noop", - ] - ) + output = StringIO() + with redirect_stdout(output): + main( + [ + "--root", + str(self.root), + "--dry-run", + "start", + "--line", + "alpha", + "--agent", + "noop", + ] + ) + rendered = output.getvalue() + self.assertIn("座位 控制面 · dyro-control-plane", rendered) + self.assertIn("先观察 next / attention", rendered) def test_next_without_a_profile_explains_how_to_begin(self) -> None: with tempfile.TemporaryDirectory(prefix="dyro-cli-") as tmp: diff --git a/tests/test_hub.py b/tests/test_hub.py index 03dd03f..888f026 100644 --- a/tests/test_hub.py +++ b/tests/test_hub.py @@ -1339,7 +1339,9 @@ def test_explicit_workspace_alias_works_from_any_directory(self) -> None: main( ["--workspace", "demo", "--dry-run", "open", "alpha", "--agent", "noop"] ) - self.assertIn(str(self.root / "versions/alpha"), output.getvalue()) + rendered = output.getvalue() + self.assertIn(str(self.root / "versions/alpha"), rendered) + self.assertIn("座位 控制面 · dyro-control-plane", rendered) def test_runner_workspace_option_does_not_collide_with_global_alias(self) -> None: arguments = [ diff --git a/tests/test_integrations.py b/tests/test_integrations.py index 0721e6f..fa3bcea 100644 --- a/tests/test_integrations.py +++ b/tests/test_integrations.py @@ -120,6 +120,8 @@ def test_packaged_skill_is_concise_and_has_required_metadata(self) -> None: "workspace list --format json", "status --format json", "doctor --format json", + "integration status executor --format json", + "integration status board --format json", "integration status dispatch --format json", "objective attention --format json", "objective explain --format json", @@ -210,6 +212,23 @@ def test_dispatch_skill_installs_independently_from_control_plane(self) -> None: self.dispatch_avatar.exists() or self.dispatch_avatar.is_symlink() ) + def test_executor_and_board_skills_install_independently(self) -> None: + for integration, skill_name in ( + ("executor", "dyro-executor"), + ("board", "dyro-board"), + ): + with self.subTest(integration=integration): + installed = install_integration(integration, yes=True) + self.assertEqual(installed.status.state, IntegrationState.CURRENT) + mirror = self.dyro_home / "skills" / skill_name + avatar = self.codex_home / "skills" / skill_name + self.assertTrue(mirror.joinpath("SKILL.md").is_file()) + self.assertTrue(avatar.is_symlink()) + self.assertEqual(avatar.resolve(), mirror.resolve()) + removed = uninstall_integration(integration, yes=True) + self.assertEqual(removed.status.state, IntegrationState.ABSENT) + self.assertFalse(mirror.exists()) + def test_dispatch_skill_status_and_dry_run_are_zero_write(self) -> None: before = self._tree_snapshot() status = integration_status("dispatch") diff --git a/tests/test_release_source.py b/tests/test_release_source.py index 3f0f177..fc2e7c0 100644 --- a/tests/test_release_source.py +++ b/tests/test_release_source.py @@ -95,6 +95,8 @@ def test_publish_workflow_requires_successful_exact_sha_ci_gate(self) -> None: self.assertNotIn("bridge-gate-run.tsv", workflow) self.assertIn("dyro-dispatch','SKILL.md", workflow) self.assertIn("dyro-dispatch','agents','openai.yaml", workflow) + self.assertIn("dyro-executor','SKILL.md", workflow) + self.assertIn("dyro-board','SKILL.md", workflow) def test_ci_no_longer_ships_agent_bridge_zero_effect_gate(self) -> None: workflow = (ROOT / ".github" / "workflows" / "ci.yml").read_text( @@ -107,6 +109,8 @@ def test_ci_no_longer_ships_agent_bridge_zero_effect_gate(self) -> None: self.assertNotIn("Agent Bridge source/wheel/sdist gate", workflow) self.assertIn("dyro-dispatch','SKILL.md", workflow) self.assertIn("dyro-dispatch','agents','openai.yaml", workflow) + self.assertIn("dyro-executor','SKILL.md", workflow) + self.assertIn("dyro-board','SKILL.md", workflow) self.assertGreaterEqual(workflow.count("verify_bundle_stranger.py"), 2) self.assertGreaterEqual( workflow.count("find_spec('dyro.bridge') is None"), 2 diff --git a/tests/test_seats.py b/tests/test_seats.py new file mode 100644 index 0000000..259dda3 --- /dev/null +++ b/tests/test_seats.py @@ -0,0 +1,118 @@ +from __future__ import annotations + +from contextlib import redirect_stdout +from io import StringIO +from pathlib import Path +import re +import unittest +from unittest.mock import Mock, patch + +from dyro.home import HomeTool, launch_start_tool +from dyro.integrations import manager +from dyro.integrations.seats import ( + BOARD_ID, + COMPANION_IDS, + CONTROL_PLANE_ID, + EXECUTOR_ID, + FIRST_BATCH_IDS, + SEATS, + managed_skill_bundle, + render_seat_notice, + select_launch_seat, +) +from dyro.tooling import ToolState + + +class FirstBatchSeatTests(unittest.TestCase): + def test_first_batch_is_four_seats_and_omits_reviewer(self) -> None: + self.assertEqual( + FIRST_BATCH_IDS, + ("skill", "dispatch", "executor", "board"), + ) + self.assertEqual(COMPANION_IDS, ("dispatch", "executor", "board")) + self.assertEqual( + managed_skill_bundle(), + ( + ("skill", "控制面"), + ("dispatch", "Dispatch"), + ("executor", "执行"), + ("board", "评审板"), + ), + ) + names = {seat.skill_name for seat in SEATS} + self.assertNotIn("dyro-reviewer", names) + + def test_launch_seat_follows_task_then_navigator(self) -> None: + self.assertEqual(select_launch_seat(task="API-101", line="dev"), EXECUTOR_ID) + self.assertEqual(select_launch_seat(task="", line="dev"), CONTROL_PLANE_ID) + notice = render_seat_notice(EXECUTOR_ID) + self.assertIn("dyro-executor", notice) + self.assertIn("不要 merge", notice) + self.assertNotIn(BOARD_ID, notice) + + def test_launch_start_tool_prints_seat_for_desktop_launcher(self) -> None: + tool = HomeTool( + "cursor", + "Cursor Desktop", + "launcher", + ("open", "-a", "Cursor"), + (), + ToolState.READY, + ) + output = StringIO() + with ( + patch("dyro.home.launch_home_tool") as launch, + redirect_stdout(output), + ): + launch_start_tool( + Mock(), + workspace=Path("/tmp/ws"), + tool=tool, + line="dev", + task="API-101", + dry_run=True, + ) + launch.assert_called_once() + self.assertIn("座位 执行 · dyro-executor", output.getvalue()) + + def test_packaged_first_batch_skills_auto_trigger_and_stay_bounded(self) -> None: + for seat in SEATS: + root = manager._asset_root(seat.integration_id) + skill = root / "SKILL.md" + metadata = root / "agents" / "openai.yaml" + content = skill.read_text(encoding="utf-8") + self.assertLessEqual( + len(content.encode("utf-8")), 8 * 1024, msg=seat.skill_name + ) + self.assertNotIn("TODO", content) + frontmatter = content.split("---", 2)[1] + keys = { + line.split(":", 1)[0] + for line in frontmatter.splitlines() + if line.strip() + } + self.assertEqual(keys, {"name", "description"}, msg=seat.skill_name) + self.assertIn(f"name: {seat.skill_name}", frontmatter) + lowered = content.lower() + for term in seat.trigger_terms: + self.assertIn(term.lower(), lowered, msg=f"{seat.skill_name}: {term}") + self.assertIn(f"${seat.skill_name}", metadata.read_text(encoding="utf-8")) + description = frontmatter.split("description:", 1)[1].strip() + self.assertLessEqual(len(description), 1024, msg=seat.skill_name) + self.assertNotIn("~/", content) + for pattern in ( + r"/Users/[^<\s]", + r"/home/[^<\s]", + r"-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----", + ): + self.assertIsNone( + re.search(pattern, content), + msg=f"{seat.skill_name}: {pattern}", + ) + + def test_wheel_package_data_lists_every_first_batch_skill(self) -> None: + metadata = Path(__file__).resolve().parents[1].joinpath("pyproject.toml") + text = metadata.read_text(encoding="utf-8") + for seat in SEATS: + self.assertIn(f"assets/{seat.skill_name}/SKILL.md", text) + self.assertIn(f"assets/{seat.skill_name}/agents/openai.yaml", text) diff --git a/tests/test_updates.py b/tests/test_updates.py index 145aa30..e09d67b 100644 --- a/tests/test_updates.py +++ b/tests/test_updates.py @@ -604,6 +604,20 @@ def test_startup_syncs_outdated_managed_skill(self) -> None: Path("/tmp/dispatch.json"), "outdated", ), + "executor": IntegrationStatus( + "executor", + IntegrationState.CURRENT, + Path("/tmp/executor"), + Path("/tmp/executor.json"), + "current", + ), + "board": IntegrationStatus( + "board", + IntegrationState.CURRENT, + Path("/tmp/board"), + Path("/tmp/board.json"), + "current", + ), } plan = Mock() plan.changes = ("升级镜像",) @@ -642,6 +656,20 @@ def test_startup_auto_installs_dispatch_for_managed_control_plane(self) -> None: Path("/tmp/dispatch.json"), "absent", ), + "executor": IntegrationStatus( + "executor", + IntegrationState.ABSENT, + Path("/tmp/executor"), + Path("/tmp/executor.json"), + "absent", + ), + "board": IntegrationStatus( + "board", + IntegrationState.ABSENT, + Path("/tmp/board"), + Path("/tmp/board.json"), + "absent", + ), } plan = Mock(changes=("创建 Dispatch Skill",)) output = StringIO() @@ -652,8 +680,13 @@ def test_startup_auto_installs_dispatch_for_managed_control_plane(self) -> None: ): _maybe_sync_managed_skill() - sync.assert_called_once_with( - "dispatch", yes=True, allow_first_install=True + self.assertEqual( + sync.call_args_list, + [ + call("dispatch", yes=True, allow_first_install=True), + call("executor", yes=True, allow_first_install=True), + call("board", yes=True, allow_first_install=True), + ], ) self.assertIn("自动安装 / 同步", output.getvalue()) @@ -697,6 +730,20 @@ def test_post_update_refresh_installs_dispatch_companion(self) -> None: Path("/tmp/dispatch.json"), "absent", ), + "executor": IntegrationStatus( + "executor", + IntegrationState.ABSENT, + Path("/tmp/executor"), + Path("/tmp/executor.json"), + "absent", + ), + "board": IntegrationStatus( + "board", + IntegrationState.ABSENT, + Path("/tmp/board"), + Path("/tmp/board.json"), + "absent", + ), } completed = Mock(returncode=0, stdout="synced\n", stderr="") with ( @@ -715,6 +762,63 @@ def test_post_update_refresh_installs_dispatch_companion(self) -> None: [ call("integration", "sync", "skill", "--yes"), call("integration", "install", "dispatch", "--yes"), + call("integration", "install", "executor", "--yes"), + call("integration", "install", "board", "--yes"), + ], + ) + self.assertEqual(run.call_count, 4) + + def test_post_update_refresh_skips_current_and_blocked_companions(self) -> None: + from dyro.cli import _refresh_skill_via_new_cli + from dyro.integrations import IntegrationState, IntegrationStatus + + statuses = { + "skill": IntegrationStatus( + "skill", + IntegrationState.CURRENT, + Path("/tmp/control"), + Path("/tmp/control.json"), + "current", + ), + "dispatch": IntegrationStatus( + "dispatch", + IntegrationState.CURRENT, + Path("/tmp/dispatch"), + Path("/tmp/dispatch.json"), + "current", + ), + "executor": IntegrationStatus( + "executor", + IntegrationState.DRIFTED, + Path("/tmp/executor"), + Path("/tmp/executor.json"), + "drifted", + ), + "board": IntegrationStatus( + "board", + IntegrationState.ABSENT, + Path("/tmp/board"), + Path("/tmp/board.json"), + "absent", + ), + } + completed = Mock(returncode=0, stdout="synced\n", stderr="") + with ( + patch("dyro.cli.integration_status", side_effect=statuses.__getitem__), + patch( + "dyro.cli._fresh_dyro_argv", + side_effect=lambda *args: ["dyro", *args], + ) as argv, + patch("dyro.cli.subprocess.run", return_value=completed) as run, + redirect_stdout(StringIO()), + ): + _refresh_skill_via_new_cli() + + self.assertEqual( + argv.call_args_list, + [ + call("integration", "sync", "skill", "--yes"), + call("integration", "install", "board", "--yes"), ], ) self.assertEqual(run.call_count, 2) @@ -783,10 +887,19 @@ def test_refresh_skill_uses_install_bound_argv(self) -> None: def test_refresh_skill_warns_on_nonzero_exit(self) -> None: from dyro.cli import _refresh_skill_via_new_cli + from dyro.integrations import IntegrationState, IntegrationStatus completed = Mock(returncode=2, stdout="", stderr="boom") + absent = IntegrationStatus( + "skill", + IntegrationState.ABSENT, + Path("/tmp/mirror"), + Path("/tmp/manifest"), + "absent", + ) output = StringIO() with ( + patch("dyro.cli.integration_status", return_value=absent), patch( "dyro.cli._fresh_dyro_argv", return_value=["dyro", "integration", "sync", "skill", "--yes"],