Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 22 additions & 4 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,28 @@

- Attach first-party Skill avatars to OpenCode and Hermes when those host
homes already exist (`~/.config/opencode/skills/<skill>`,
`~/.hermes/skills/<skill>`). Detection stays fail-closed: absent homes
stay silent, and Dyro does not create OpenCode or Hermes directories.
`OPENCODE_CONFIG_DIR` and `HERMES_HOME` remain the existing overrides.
Pi (`PI_CODING_AGENT_DIR`, `~/.pi/agent`) is unchanged.
`~/.hermes/skills/<skill>`). Detection stays fail-closed: absent default
homes stay silent, and Dyro does not create host homes — including when
`host_homes` or host env vars (`OPENCODE_CONFIG_DIR`, `HERMES_HOME`,
`PI_CODING_AGENT_DIR`, and the other host overrides) point at a path that
is not already a real directory. Missing override homes are skipped
(isolated-mirror-only if no host remains). Symlink path components stay
fail-closed. Pi (`PI_CODING_AGENT_DIR`, `~/.pi/agent`) still uses the
same existing-home rule.
- `dyro start` now refuses the same way `dyro next` does when doctor has
any FAIL, including missing-origin-only. The 0.7.10 note that start
treated missing-origin as non-blocking is no longer the live contract.
Setup, join, and `dyro open` / home create-and-open
(`existing_line_workspace`) skip only the constructed missing-origin
shape so a just-created SHA-pinned local-only line can be created and
opened. Every other doctor FAIL — including workspace-level
`FAIL external Profile requires …` — blocks open.
- `is_missing_origin_finding` parses the doctor FAIL shape
(`FAIL <kind>:<id>/<repo>: missing origin/<branch>`) instead of a
substring, so a path that embeds `: missing origin/...` is not classified
as missing-origin and still blocks start.
- Redact a non-fictional product-line token from the public 2026-08-19
slash-review record. Public docs keep fictional placeholders.

## 0.7.10 - 2026-08-21

Expand Down
4 changes: 2 additions & 2 deletions docs/reviews/2026-08-19-slash-review-and-task-merge.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

Date: 2026-08-19

Scope: 本会话新增的用户斜杠 `/dyro-task-merge` 与 `/dyro-review-board`(skills-library),不是 Huiyichu 产品线,也不是 `dyro` 包装源码。
Scope: 本会话新增的用户斜杠 `/dyro-task-merge` 与 `/dyro-review-board`(skills-library),不是 example-line 产品线,也不是 `dyro` 包装源码。

SSOT: 当前 skill 文件 + 已安装 `dyro-board` + `dyroengineeringflow` 中 `merge_task` / `explain_task` / `cmd_next`。本记录不是 Proof,不是 `task review` PASS。

Expand Down Expand Up @@ -78,7 +78,7 @@ Final verdict: **No-Go for 提交 / 推送 / 发布。** 用户斜杠可继续
| 对象 | 结论 |
| --- | --- |
| 当作用户斜杠继续用 | Conditional Go(先收下 P1 文本) |
| 提交 / 推送 / 发布这些 skill 或任何 Dyro/Huiyichu 仓 | **No-Go** |
| 提交 / 推送 / 发布这些 skill 或任何 Dyro/example-line 仓 | **No-Go** |
| 用本会审代替 `task review` / 发版 | **No-Go** |

## 须人工核
Expand Down
6 changes: 0 additions & 6 deletions src/dyro/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -2483,14 +2483,8 @@ def cmd_start(args: argparse.Namespace) -> None:
config = _config(args)
findings = doctor(config)
failures = [finding for finding in findings if finding.startswith("FAIL")]
blocking = [
finding
for finding in failures
if not is_missing_origin_finding(finding)
]
if failures:
print("\n".join(failures))
if blocking:
raise DyroError(
"工作区尚未就绪;先修复 doctor 失败项,或运行 dyro bootstrap --yes"
)
Expand Down
13 changes: 8 additions & 5 deletions src/dyro/home.py
Original file line number Diff line number Diff line change
Expand Up @@ -747,13 +747,15 @@ def existing_line_workspace(
config: Config, line_id: str, kind: str | None
) -> tuple[Line, Path]:
line = get_line(config, line_id, kind)
relevant = {f"FAIL repository {repo_id}:" for repo_id in line.repositories}
relevant.add(f"FAIL {line.kind}:{line.id}/")
# Same FAIL set as `dyro next` → needs_repair, minus the constructed
# missing-origin shape. Workspace-level FAILs (external Profile, …)
# and other-line FAILs block open; only missing-origin-only may skip
# so a just-created local-only line can be opened. start/next refuse
# even that skip.
failures = [
finding
for finding in doctor(config)
if any(finding.startswith(prefix) for prefix in relevant)
and not is_missing_origin_finding(finding)
if finding.startswith("FAIL") and not is_missing_origin_finding(finding)
]
if failures:
raise DyroError(
Expand Down Expand Up @@ -2178,7 +2180,8 @@ def _run_config_home(
failures = [finding for finding in doctor(config) if finding.startswith("FAIL")]
if failures:
print(
f"\n检测到 {len(failures)} 个结构问题;只会阻止进入受影响的目标。"
f"\n检测到 {len(failures)} 个结构问题;除尚未发布的 "
"origin/<branch> 外,doctor FAIL 会阻止打开开发线。"
"运行 dyro doctor 查看详情。"
)
briefing = _print_ready_briefing(config, record)
Expand Down
30 changes: 23 additions & 7 deletions src/dyro/integrations/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -250,21 +250,33 @@ def _state_paths(
)


def _usable_existing_host_home(candidate: Path) -> Path | None:
"""Accept only a real directory that already exists. Never create one."""
if candidate.exists() and candidate.is_dir() and not candidate.is_symlink():
if _symlink_component(candidate) is None:
return candidate
return None


def _host_home(
spec: HostSpec, overrides: Mapping[str, Path] | None
) -> Path | None:
if overrides is not None and spec.host_id in overrides:
return _absolute_path(overrides[spec.host_id], f"{spec.host_id} home")
candidate = _absolute_path(overrides[spec.host_id], f"{spec.host_id} home")
# Fail-closed: surface symlink / non-dir path components so install
# refuses instead of mkdir-through or silent-skip.
if _symlink_component(candidate) is not None:
return candidate
return _usable_existing_host_home(candidate)
if spec.env_var:
raw = os.environ.get(spec.env_var, "").strip()
if raw:
return _absolute_path(Path(raw), spec.env_var)
candidate = _absolute_path(Path(raw), spec.env_var)
if _symlink_component(candidate) is not None:
return candidate
return _usable_existing_host_home(candidate)
candidate = _user_home() / spec.default_dirname
if candidate.exists() and candidate.is_dir() and not candidate.is_symlink():
unsafe = _symlink_component(candidate)
if unsafe is None:
return candidate
return None
return _usable_existing_host_home(candidate)


def _avatar_path(host_home: Path, spec: SkillIntegrationSpec) -> Path:
Expand Down Expand Up @@ -1235,6 +1247,10 @@ def _install_avatars(
f"{host_spec.host_id} skills 目录不安全:{unsafe}"
)
continue
if not home.exists() or not home.is_dir() or home.is_symlink():
# Never mkdir host homes. Missing env / host_homes overrides
# stay silent, matching absent default homes.
continue
if _is_link(avatar) and _resolves_to(avatar, mirror):
avatars[host_spec.host_id] = {
"path": str(avatar),
Expand Down
24 changes: 17 additions & 7 deletions src/dyro/workspace.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import json
import os
from pathlib import Path
import re
import shutil
import stat
from typing import Iterable, Mapping
Expand Down Expand Up @@ -1309,16 +1310,25 @@ def doctor(config: Config, *, read_budget: ReadBudget | None = None) -> list[str
return findings


_MISSING_ORIGIN_TOKEN = ": missing origin/"
_SAFE_FINDING_ID = r"[A-Za-z0-9][A-Za-z0-9._-]{0,79}"
_MISSING_ORIGIN_FINDING = re.compile(
rf"^FAIL (?:line|hotfix):{_SAFE_FINDING_ID}/{_SAFE_FINDING_ID}: missing origin/\S+$"
)


def is_missing_origin_finding(finding: str) -> bool:
"""True only for doctor FAILs that mean origin/<line.branch> is absent.

Join completion, setup post-doctor, start, and home-open skip these so
SHA-pinned / local-only lines can exist before the remote-tracking ref
is published. ``dyro next`` and Isolated Console do not: a FAIL is not
ready. Wrong upstream, wrong branch, missing worktree, common-dir, and
symlink FAILs still fail.
Matches the constructed shape
``FAIL <kind>:<id>/<repo>: missing origin/<branch>`` and nothing else.
A path or message that merely embeds ``: missing origin/`` does not match.

Join completion, setup post-doctor, home create-and-open, and
``existing_line_workspace`` / ``dyro open`` skip only this constructed
shape so SHA-pinned / local-only lines can exist (and be opened)
before the remote-tracking ref is published. Every other doctor FAIL
— including workspace-level ``FAIL external Profile requires …`` —
still blocks open. ``dyro next``, ``dyro start``, and Isolated Console
do not skip: a FAIL is not ready.
"""
return finding.startswith("FAIL ") and _MISSING_ORIGIN_TOKEN in finding
return _MISSING_ORIGIN_FINDING.fullmatch(finding) is not None
83 changes: 82 additions & 1 deletion tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,14 @@
from dyro.tasks import load_task, status, task_template
from dyro.tooling import ToolState, load_tool_preferences
from dyro.updates import load_update_state
from dyro.workspace import create_line, get_line, line_repository_path, spawn_line
from dyro.workspace import (
create_line,
doctor,
get_line,
is_missing_origin_finding,
line_repository_path,
spawn_line,
)

from .support import WorkspaceCase, publish_origin_branch

Expand Down Expand Up @@ -801,6 +808,7 @@ def test_print_setup_completion_reflects_skill_failure(self) -> None:

class StartTests(WorkspaceCase):
def test_start_dry_run_uses_selected_line_and_adapter(self) -> None:
publish_origin_branch(self.anchor, "feat/alpha")
config = load(self.root)
create_line(config, line_id="alpha", branch="feat/alpha", base="main")
output = StringIO()
Expand All @@ -821,6 +829,78 @@ def test_start_dry_run_uses_selected_line_and_adapter(self) -> None:
self.assertIn("座位 控制面 · dyro-control-plane", rendered)
self.assertIn("先观察 next / attention", rendered)

def test_start_refuses_when_doctor_has_missing_origin_only(self) -> None:
config = load(self.root)
create_line(config, line_id="local-only", branch="feat/local-only", base="main")
stdout = StringIO()
stderr = StringIO()
with (
redirect_stdout(stdout),
redirect_stderr(stderr),
self.assertRaises(SystemExit) as raised,
):
main(
[
"--root",
str(self.root),
"--dry-run",
"start",
"--line",
"local-only",
"--agent",
"noop",
]
)
self.assertEqual(raised.exception.code, 2)
combined = stdout.getvalue() + stderr.getvalue()
self.assertIn("missing origin/feat/local-only", combined)
self.assertIn("尚未就绪", combined)
self.assertNotIn("座位", stdout.getvalue())

def test_start_refuses_fail_whose_path_embeds_missing_origin_token(self) -> None:
config_path = self.root / "dyro.toml"
config_path.write_text(
config_path.read_text(encoding="utf-8")
+ "\n[repositories.evil]\n"
+ 'path = "repositories/api: missing origin/evil"\n'
+ 'mount = "evil"\n',
encoding="utf-8",
)
findings = doctor(load(self.root))
embedded = [
item
for item in findings
if item.startswith("FAIL") and ": missing origin/evil" in item
]
self.assertTrue(embedded, findings)
for item in embedded:
self.assertFalse(is_missing_origin_finding(item), item)
stdout = StringIO()
stderr = StringIO()
with (
redirect_stdout(stdout),
redirect_stderr(stderr),
self.assertRaises(SystemExit) as raised,
):
main(
[
"--root",
str(self.root),
"--dry-run",
"start",
"--agent",
"noop",
]
)
self.assertEqual(raised.exception.code, 2)
combined = stdout.getvalue() + stderr.getvalue()
self.assertIn("尚未就绪", combined)
self.assertTrue(
any(": missing origin/evil" in item for item in embedded),
embedded,
)
self.assertNotIn("座位", stdout.getvalue())

def test_next_without_a_profile_explains_how_to_begin(self) -> None:
with tempfile.TemporaryDirectory(prefix="dyro-cli-") as tmp:
output = StringIO()
Expand Down Expand Up @@ -927,6 +1007,7 @@ def test_config_and_agent_management_do_not_require_manual_toml_edits(self) -> N
)

def test_start_can_launch_an_installed_tool_without_a_profile_adapter(self) -> None:
publish_origin_branch(self.anchor, "feat/alpha")
create_line(load(self.root), line_id="alpha", branch="feat/alpha", base="main")
launched: list[object] = []

Expand Down
12 changes: 8 additions & 4 deletions tests/test_hub.py
Original file line number Diff line number Diff line change
Expand Up @@ -1207,7 +1207,7 @@ def run(argv: tuple[str, ...], **_: object) -> subprocess.CompletedProcess[str]:
self.assertNotIn("openclaw", load(self.root).adapters)
self.assertEqual(self.root.joinpath("dyro.toml").read_bytes(), before)

def test_unhealthy_line_does_not_block_opening_a_healthy_line(self) -> None:
def test_unhealthy_sibling_fail_blocks_opening_a_healthy_line(self) -> None:
self._create_line()
create_line(load(self.root), line_id="beta", branch="feat/beta", base="main")
shell(
Expand All @@ -1220,18 +1220,22 @@ def test_unhealthy_line_does_not_block_opening_a_healthy_line(self) -> None:
add_workspace(self.root, name="demo", make_default=True)

output = StringIO()
stderr = StringIO()
with (
patch("dyro.home.Path.cwd", return_value=self.root.parent),
patch("dyro.home.interactive_terminal", return_value=True),
patch("builtins.input", return_value=""),
redirect_stdout(output),
redirect_stderr(stderr),
self.assertRaises(SystemExit) as raised,
):
main(["--dry-run"])

rendered = output.getvalue()
self.assertEqual(raised.exception.code, 2)
rendered = output.getvalue() + stderr.getvalue()
self.assertIn("检测到 1 个结构问题", rendered)
self.assertIn(str(self.root / "versions/alpha"), rendered)
self.assertIn("/usr/bin/true", rendered)
self.assertIn("尚未就绪", rendered)
self.assertNotIn("/usr/bin/true", output.getvalue())

def test_task_open_uses_existing_worktree_without_changing_status(self) -> None:
self._create_task_worktree()
Expand Down
Loading