diff --git a/CHANGELOG.md b/CHANGELOG.md index 537a9b0f..42c545c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 show/watch/resume surfaces). Recoverable from git history; see #442 / #471. ### Added +- `brigade work resolve-target --cwd PATH [--harness NAME]` prints the + Brigade-wired project root (requires `.brigade/config.json`) so shell hooks + share Claude's discovery contract instead of matching any `.brigade/` dir. +- Shared `brigade.wiring.resolve_wired_target` helper used by Claude hooks and + the new resolver CLI; optional harness filter. +- Grok work-loop hook templates under `src/brigade/templates/grok/hooks/` that + use `resolve-target`, timeout the session brief, and do not deny edits while + a brief is running (#536). - Imported `stations/notify` Go module into the Brigade monorepo. Unified release manifests now enumerate five native components (25 platform assets plus `component-manifest-v1.json` and `checksums.txt`) with managed resolution @@ -31,6 +39,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `brigade mcp sync --user-scope` (and `brigade operator sync-mcp --user-scope`) no longer writes stdio MCP servers into a user-wide client config silently: interactive runs show the destination, stdio count, and the servers-times-sessions process formula and ask for confirmation, non-interactive and `--json` runs require `--allow-global-stdio`, and plan/sync items now carry `transport` and `scope`. (#349) ### Fixed +- Grok/T3 work-loop discovery no longer treats `~/.brigade` (user-level aboyeur + roster) as a project work root. Hooks and `work resolve-target` require + `.brigade/config.json`, so sessions under `$HOME` or unwired dirs do not + background `brigade work brief --target $HOME` (#536). - `brigade run` no longer dies on the first unparsable plan when the chef's final message is prose. The corrective plan turn now restates the output contract ("reply with the JSON plan object and nothing else") alongside the parse error, diff --git a/docs/command-inventory.md b/docs/command-inventory.md index 689c8165..85793b60 100644 --- a/docs/command-inventory.md +++ b/docs/command-inventory.md @@ -69,7 +69,7 @@ enabled: run `brigade extras on` once, or set `BRIGADE_EXTRAS=1`. - `brigade untrusted` (extras): 2 command path(s) - `brigade update`: 1 command path(s) - `brigade version`: 1 command path(s) -- `brigade work`: 144 command path(s) +- `brigade work`: 145 command path(s) - `brigade workflow` (extras): 3 command path(s) ## Commands @@ -666,6 +666,7 @@ enabled: run `brigade extras on` once, or set `BRIGADE_EXTRAS=1`. - `brigade work plan-proposals` - `brigade work plans` - `brigade work recap` +- `brigade work resolve-target` - `brigade work resume` - `brigade work review closeout` - `brigade work review finding-show` diff --git a/src/brigade/claude_hooks/runtime.py b/src/brigade/claude_hooks/runtime.py index 10157986..204c73a3 100644 --- a/src/brigade/claude_hooks/runtime.py +++ b/src/brigade/claude_hooks/runtime.py @@ -13,7 +13,7 @@ from typing import Any, Iterator from .. import localio -from ..config import load_config +from ..wiring import resolve_wired_target from .package import PACKAGE_REF BRIEF_TIMEOUT_SECONDS = 10 @@ -116,28 +116,6 @@ def iter_session_states( yield state -def resolve_wired_target(cwd: object) -> Path | None: - if not isinstance(cwd, str) or not cwd.strip(): - return None - try: - current = Path(cwd).expanduser().resolve() - except OSError: - return None - if not current.is_dir(): - current = current.parent - for candidate in (current, *current.parents): - if not (candidate / ".brigade" / "config.json").is_file(): - continue - try: - config = load_config(candidate) - except (OSError, ValueError, json.JSONDecodeError): - return None - if config is not None and "claude" in config.selection.harnesses: - return candidate - return None - return None - - def _advance_quote_state(text: str, quote: str | None) -> str | None: index = 0 while index < len(text): diff --git a/src/brigade/cli/work/dispatching.py b/src/brigade/cli/work/dispatching.py index b5ae328f..fbd9731d 100644 --- a/src/brigade/cli/work/dispatching.py +++ b/src/brigade/cli/work/dispatching.py @@ -40,6 +40,22 @@ def dispatch(args) -> int: return work_cmd.resume(target=args.target) if args.work_command == "brief": return work_cmd.brief(target=args.target, limit=args.limit, json_output=args.json) + if args.work_command == "resolve-target": + from ...wiring import resolve_wired_target + + harness = args.harness.strip() if isinstance(args.harness, str) and args.harness.strip() else None + try: + cwd = str(args.cwd.expanduser().resolve()) + except OSError: + cwd = str(args.cwd) + target = resolve_wired_target(cwd, harness=harness) + if args.json: + print(json.dumps({"ok": target is not None, "target": str(target) if target else None}, indent=2)) + return 0 if target is not None else 1 + if target is None: + return 1 + print(target) + return 0 if args.work_command == "hooks": from ...claude_hooks import install_cmd diff --git a/src/brigade/cli/work/registration.py b/src/brigade/cli/work/registration.py index a72df489..0eab039e 100644 --- a/src/brigade/cli/work/registration.py +++ b/src/brigade/cli/work/registration.py @@ -47,6 +47,22 @@ def register(sub: argparse._SubParsersAction) -> None: p_work_brief.add_argument("--target", "-t", type=Path, default=Path("."), help="Repo or workspace to inspect.") p_work_brief.add_argument("--limit", type=int, default=3, help="Maximum recent sessions to include.") p_work_brief.add_argument("--json", action="store_true", help="Print machine-readable JSON.") + p_work_resolve_target = work_sub.add_parser( + "resolve-target", + help="Print the Brigade-wired project root for a cwd (requires .brigade/config.json).", + ) + p_work_resolve_target.add_argument( + "--cwd", + type=Path, + default=Path("."), + help="Starting directory to walk upward from.", + ) + p_work_resolve_target.add_argument( + "--harness", + default=None, + help="Require this harness in .brigade/config.json (default: any wired project).", + ) + p_work_resolve_target.add_argument("--json", action="store_true", help="Print machine-readable JSON.") p_work_hooks = work_sub.add_parser("hooks", help="Manage project-scoped Claude work-loop hooks.") hooks_sub = p_work_hooks.add_subparsers(dest="hooks_command", metavar="") hooks_sub.required = True diff --git a/src/brigade/templates/grok/hooks/brigade-work.json b/src/brigade/templates/grok/hooks/brigade-work.json new file mode 100644 index 00000000..326e6d73 --- /dev/null +++ b/src/brigade/templates/grok/hooks/brigade-work.json @@ -0,0 +1,61 @@ +{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "REPLACE_WITH_HOOK_SCRIPT", + "timeout": 5 + } + ] + } + ], + "PreToolUse": [ + { + "matcher": "Bash|run_terminal_command|Edit|Write|MultiEdit|search_replace|write_file|apply_patch", + "hooks": [ + { + "type": "command", + "command": "REPLACE_WITH_HOOK_SCRIPT", + "timeout": 5 + } + ] + } + ], + "PostToolUse": [ + { + "matcher": "Bash|run_terminal_command|Edit|Write|MultiEdit|search_replace|write_file|apply_patch", + "hooks": [ + { + "type": "command", + "command": "REPLACE_WITH_HOOK_SCRIPT", + "timeout": 5 + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "REPLACE_WITH_HOOK_SCRIPT", + "timeout": 30 + } + ] + } + ], + "SessionEnd": [ + { + "hooks": [ + { + "type": "command", + "command": "REPLACE_WITH_HOOK_SCRIPT", + "timeout": 30 + } + ] + } + ] + } +} diff --git a/src/brigade/templates/grok/hooks/brigade-work.sh b/src/brigade/templates/grok/hooks/brigade-work.sh new file mode 100755 index 00000000..4e98aade --- /dev/null +++ b/src/brigade/templates/grok/hooks/brigade-work.sh @@ -0,0 +1,262 @@ +#!/usr/bin/env bash +# Grok/T3 Brigade work-loop hook (issue #536). +# +# Discovery rule: only a directory with .brigade/config.json is a project +# work root. ~/.brigade (user-level aboyeur roster) must never match. +set -u + +payload="$(cat)" +event="" +session_id="" +workspace="" +tool_name="" +tool_command="" +hook_fields="$( + printf '%s' "$payload" | python3 -c ' +import json +import shlex +import sys + +try: + payload = json.load(sys.stdin) +except (TypeError, ValueError): + payload = {} + +tool_input = payload.get("toolInput") +if not isinstance(tool_input, dict): + tool_input = {} + +def text(value): + return value if isinstance(value, str) else "" + +fields = { + "event": text(payload.get("hookEventName")), + "session_id": text(payload.get("sessionId")), + "workspace": text(payload.get("workspaceRoot")) or text(payload.get("cwd")), + "tool_name": text(payload.get("toolName")), + "tool_command": text(tool_input.get("command")), +} +for name, value in fields.items(): + print(f"{name}={shlex.quote(value)}") +' +)" || hook_fields="" +if [[ -n "$hook_fields" ]]; then + # Values are shell-quoted by the Python parser above, not by hook input. + eval "$hook_fields" +fi + +event="$(printf '%s' "$event" | tr '[:upper:]' '[:lower:]')" +session_id="${session_id:-unknown-session}" +safe_session="$(printf '%s' "$session_id" | tr -c 'A-Za-z0-9._-' '_')" +brigade_bin="${BRIGADE_BIN:-$(command -v brigade 2>/dev/null || true)}" +state_root="${GROK_BRIGADE_STATE_DIR:-$HOME/.grok/state/brigade-work}" +state_dir="$state_root/$safe_session" +brief_timeout_seconds="${BRIGADE_GROK_BRIEF_TIMEOUT_SECONDS:-30}" + +allow() { + printf '{"decision":"allow"}\n' + exit 0 +} + +deny() { + python3 -c 'import json, sys; print(json.dumps({"decision": "deny", "reason": sys.argv[1]}))' "$1" + exit 0 +} + +find_brigade_target() { + local path="$1" + [[ -n "$path" ]] || return 1 + # Prefer the shared resolver so shell hooks cannot reimplement discovery wrong. + if [[ -n "$brigade_bin" ]] && "$brigade_bin" work resolve-target --help >/dev/null 2>&1; then + if target="$("$brigade_bin" work resolve-target --cwd "$path" --harness grok 2>/dev/null)"; then + [[ -n "$target" ]] || return 1 + printf '%s\n' "$target" + return 0 + fi + return 1 + fi + # Fallback for older Brigade installs: require config.json, never match roster-only ~/.brigade. + path="$(cd "$path" 2>/dev/null && pwd -P)" || return 1 + local home="${HOME:-}" + if [[ -n "$home" ]]; then + home="$(cd "$home" 2>/dev/null && pwd -P)" || home="" + fi + while [[ -n "$path" && "$path" != "/" ]]; do + if [[ -f "$path/.brigade/config.json" ]]; then + if python3 - "$path/.brigade/config.json" <<'PY' +import json +import sys + +try: + with open(sys.argv[1], encoding="utf-8") as config_file: + config = json.load(config_file) +except (OSError, TypeError, ValueError): + raise SystemExit(1) + +harnesses = config.get("harnesses") if isinstance(config, dict) else None +selected = { + harness.strip().lower() + for harness in harnesses + if isinstance(harness, str) +} if isinstance(harnesses, list) else set() +raise SystemExit(0 if "grok" in selected else 1) +PY + then + printf '%s\n' "$path" + return 0 + fi + # Match resolve-target: the first project config is authoritative. + return 1 + fi + if [[ -n "$home" && "$path" == "$home" ]]; then + break + fi + path="$(dirname "$path")" + done + return 1 +} + +target="$(find_brigade_target "$workspace" 2>/dev/null || true)" +if [[ -z "$target" || -z "$brigade_bin" ]]; then + if [[ "$event" == "pre_tool_use" ]]; then + allow + fi + exit 0 +fi + +mkdir -p "$state_dir" "$state_root/logs" +log_file="$state_root/logs/$safe_session.log" +printf '%s\t%s\t%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$event" "$tool_name" >>"$log_file" + +is_edit_tool() { + case "$tool_name" in + search_replace|write_file|apply_patch|Edit|Write|MultiEdit) return 0 ;; + *) return 1 ;; + esac +} + +is_raw_verification() { + [[ -n "$tool_command" ]] || return 1 + grep -Eiq '(^|[;&|[:space:]])(\.?/?scripts/verify|pytest|uv[[:space:]]+run[[:space:]]+pytest|python[0-9.]*[[:space:]]+-m[[:space:]]+pytest|ruff|mypy|pyright|eslint|tsc|vitest|jest|npm[[:space:]]+(run[[:space:]]+)?(test|lint|build|check|typecheck)|pnpm[[:space:]]+(run[[:space:]]+)?(test|lint|build|check|typecheck)|yarn[[:space:]]+(run[[:space:]]+)?(test|lint|build|check|typecheck)|bun[[:space:]]+(run[[:space:]]+)?(test|lint|build|check|typecheck)|go[[:space:]]+(test|vet|build)|cargo[[:space:]]+(test|check|clippy|build)|make[[:space:]]+(test|check|verify|lint|build)|just[[:space:]]+(test|check|verify|lint|build))([[:space:]]|$)' <<<"$tool_command" +} + +is_direct_brigade_verification() { + [[ "$tool_command" =~ ^[[:space:]]*brigade[[:space:]]+work[[:space:]]+verify[[:space:]]+run([[:space:]]|$) ]] || return 1 + is_simple_shell_command +} + +is_direct_brigade_status() { + [[ "$tool_command" =~ ^[[:space:]]*brigade[[:space:]]+(work[[:space:]]+brief|daily[[:space:]]+status)([[:space:]]|$) ]] || return 1 + is_simple_shell_command +} + +is_simple_shell_command() { + case "$tool_command" in + *';'*|*'&&'*|*'||'*|*'|'*|*'`'*|*'$('*|*'>'*|*'<') return 1 ;; + esac + return 0 +} + +run_brief_bounded() { + python3 - "$brief_timeout_seconds" "$brigade_bin" "$target" "$state_dir/brief.log" <<'PY' +import os +import signal +import subprocess +import sys + +try: + timeout = float(sys.argv[1]) +except ValueError: + timeout = 30.0 +if timeout <= 0: + timeout = 30.0 + +with open(sys.argv[4], "w", encoding="utf-8") as brief_log: + process = subprocess.Popen( + [sys.argv[2], "work", "brief", "--target", sys.argv[3]], + stdout=brief_log, + stderr=subprocess.STDOUT, + start_new_session=True, + ) + try: + raise SystemExit(process.wait(timeout=timeout)) + except subprocess.TimeoutExpired: + os.killpg(process.pid, signal.SIGTERM) + try: + process.wait(timeout=1) + except subprocess.TimeoutExpired: + os.killpg(process.pid, signal.SIGKILL) + process.wait() + raise SystemExit(124) +PY +} + +has_new_receipt() { + [[ -f "$state_dir/substantive" ]] || return 1 + [[ -d "$target/.brigade/work/verify-runs" ]] || return 1 + find "$target/.brigade/work/verify-runs" -type f -name receipt.json -newer "$state_dir/substantive" -print -quit 2>/dev/null | grep -q . +} + +close_out_work() { + [[ -f "$state_dir/substantive" ]] || return 0 + if has_new_receipt; then + "$brigade_bin" receipts export miseledger --target "$target" --new-only --import >>"$log_file" 2>&1 || true + rm -f "$state_dir/substantive" "$state_dir/friction.reported" + elif [[ ! -f "$state_dir/friction.reported" ]]; then + "$brigade_bin" friction add --target "$target" --type workflow_correction --severity high --workflow grok/brigade-work --evidence "grok-session:$session_id" "Grok session $session_id ended after edits without a new Brigade verify receipt" >>"$log_file" 2>&1 || true + touch "$state_dir/friction.reported" + fi +} + +case "$event" in + session_start) + printf '%s\n' "$target" >"$state_dir/target" + touch "$state_dir/started" + rm -f "$state_dir/brief.done" "$state_dir/substantive" "$state_dir/ended" "$state_dir/brief.failed" + ( + # Python's process-group timeout works on BSD and GNU userlands. + if run_brief_bounded; then + touch "$state_dir/brief.done" + else + touch "$state_dir/brief.failed" + fi + ) >>"$log_file" 2>&1 & + ;; + + pre_tool_use) + if [[ "$tool_name" == "run_terminal_command" || "$tool_name" == "Bash" ]]; then + if is_direct_brigade_status; then + allow + fi + if ! is_direct_brigade_verification && is_raw_verification; then + deny "This repository is Brigade-wired. Run the check through: brigade work verify run --target . --command \"\" --capture brigade-work" + fi + fi + # Do not deny edits while a background brief is running (#536). + allow + ;; + + post_tool_use) + if [[ "$tool_name" == "run_terminal_command" || "$tool_name" == "Bash" ]]; then + if is_direct_brigade_status; then + touch "$state_dir/brief.done" + fi + fi + if is_edit_tool; then + touch "$state_dir/substantive" + rm -f "$state_dir/friction.reported" + fi + ;; + + stop) + close_out_work + ;; + + session_end) + [[ -f "$state_dir/ended" ]] && exit 0 + touch "$state_dir/ended" + close_out_work + ;; +esac + +exit 0 diff --git a/src/brigade/wiring.py b/src/brigade/wiring.py new file mode 100644 index 00000000..d0f71ed9 --- /dev/null +++ b/src/brigade/wiring.py @@ -0,0 +1,49 @@ +"""Shared discovery for Brigade-wired project roots. + +A directory is a project work root only when `.brigade/config.json` exists. +`~/.brigade` (the user-level aboyeur roster fallback for `brigade run`) has no +config.json and must never be treated as a project target by harness hooks. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +from .config import load_config + + +def resolve_wired_target(cwd: object, *, harness: str | None = "claude") -> Path | None: + """Walk from ``cwd`` upward looking for a Brigade-wired project. + + A candidate qualifies only when ``.brigade/config.json`` is present and + loads. When ``harness`` is set, that harness must also appear in the + project's selected harnesses. When a config is found but the harness is + absent (or the config is unreadable), the walk stops — nested non-matching + projects do not fall through to a parent. + """ + if not isinstance(cwd, str) or not cwd.strip(): + return None + try: + current = Path(cwd).expanduser().resolve() + except OSError: + return None + if not current.is_dir(): + current = current.parent + required = harness.strip().lower() if isinstance(harness, str) and harness.strip() else None + for candidate in (current, *current.parents): + if not (candidate / ".brigade" / "config.json").is_file(): + continue + try: + config = load_config(candidate) + except (OSError, ValueError, json.JSONDecodeError): + return None + if config is None: + return None + if required is None: + return candidate + selected = {name.lower() for name in config.selection.harnesses} + if required in selected: + return candidate + return None + return None diff --git a/tests/test_grok_hook_runtime.py b/tests/test_grok_hook_runtime.py new file mode 100644 index 00000000..cb9888b0 --- /dev/null +++ b/tests/test_grok_hook_runtime.py @@ -0,0 +1,165 @@ +"""Runtime coverage for the Grok work-loop hook.""" + +from __future__ import annotations + +import json +import os +import shutil +import subprocess +import sys +import time +from pathlib import Path + + +HOOK = Path(__file__).parents[1] / "src" / "brigade" / "templates" / "grok" / "hooks" / "brigade-work.sh" + + +def _write_config(target: Path, harnesses: list[str]) -> None: + config = target / ".brigade" / "config.json" + config.parent.mkdir(parents=True) + config.write_text( + json.dumps({"version": 1, "depth": "repo", "harnesses": harnesses, "owner": "this-repo", "includes": []}) + ) + + +def _without_jq_path(tmp_path: Path) -> str: + bin_dir = tmp_path / "bin" + bin_dir.mkdir(exist_ok=True) + for command in ("cat", "date", "dirname", "find", "grep", "mkdir", "rm", "sleep", "touch", "tr"): + source = shutil.which(command) + assert source is not None + destination = bin_dir / command + if not destination.exists(): + destination.symlink_to(source) + python = bin_dir / "python3" + if not python.exists(): + python.symlink_to(sys.executable) + return str(bin_dir) + + +def _run_hook(tmp_path: Path, payload: dict[str, object], **env: str) -> subprocess.CompletedProcess[str]: + environment = os.environ.copy() + environment.update( + { + "BRIGADE_BIN": str(tmp_path / "missing-brigade"), + "GROK_BRIGADE_STATE_DIR": str(tmp_path / "state"), + "PATH": _without_jq_path(tmp_path), + } + ) + environment.update(env) + return subprocess.run( + ["/usr/bin/bash", str(HOOK)], + input=json.dumps(payload), + text=True, + capture_output=True, + check=False, + env=environment, + ) + + +def _pre_tool_payload(target: Path, command: str, *, event: str = "pre_tool_use") -> dict[str, object]: + return { + "hookEventName": event, + "sessionId": "session-1", + "workspaceRoot": str(target), + "toolName": "run_terminal_command", + "toolInput": {"command": command}, + } + + +def test_hook_parses_json_without_jq_and_uses_bash32_safe_event_lowering(tmp_path: Path): + target = tmp_path / "repo" + _write_config(target, ["grok"]) + + result = _run_hook(tmp_path, _pre_tool_payload(target, "pytest -q", event="PRE_TOOL_USE")) + + assert result.returncode == 0 + assert json.loads(result.stdout)["decision"] == "deny" + assert "jq" not in HOOK.read_text() + assert "${event,,}" not in HOOK.read_text() + + +def test_fallback_stops_at_canonical_home_and_requires_grok_config(tmp_path: Path): + real_home = tmp_path / "real-home" + real_home.mkdir() + home_link = tmp_path / "home-link" + home_link.symlink_to(real_home, target_is_directory=True) + parent = real_home / "parent" + _write_config(parent, ["GROK"]) + target = parent / "repo" + _write_config(target, ["claude"]) + nested = target / "src" + nested.mkdir() + + result = _run_hook( + tmp_path, + _pre_tool_payload(nested, "pytest -q"), + HOME=str(home_link), + ) + + assert result.returncode == 0 + assert json.loads(result.stdout) == {"decision": "allow"} + + +def test_fallback_recognizes_grok_config_and_blocks_raw_verification(tmp_path: Path): + target = tmp_path / "repo" + _write_config(target, [" GROK "]) + + result = _run_hook(tmp_path, _pre_tool_payload(target, "pytest -q")) + + assert result.returncode == 0 + assert json.loads(result.stdout)["decision"] == "deny" + + +def test_direct_brigade_verify_is_allowed_but_substring_bypasses_are_denied(tmp_path: Path): + target = tmp_path / "repo" + _write_config(target, ["grok"]) + + direct = _run_hook( + tmp_path, + _pre_tool_payload(target, 'brigade work verify run --target . --command "pytest -q" --capture brigade-work'), + ) + bypass = _run_hook( + tmp_path, + _pre_tool_payload(target, 'echo "brigade work verify run"; pytest -q'), + ) + brief_bypass = _run_hook( + tmp_path, + _pre_tool_payload(target, 'echo "brigade work brief"; pytest -q'), + ) + + assert json.loads(direct.stdout) == {"decision": "allow"} + assert json.loads(bypass.stdout)["decision"] == "deny" + assert json.loads(brief_bypass.stdout)["decision"] == "deny" + + +def test_session_start_uses_portable_bounded_brief_runner(tmp_path: Path): + target = tmp_path / "repo" + _write_config(target, ["grok"]) + brigade = tmp_path / "brigade" + brigade.write_text( + "#!/usr/bin/env bash\n" + 'if [ "$1" = "work" ] && [ "$2" = "resolve-target" ]; then exit 1; fi\n' + 'if [ "$1" = "work" ] && [ "$2" = "brief" ]; then sleep 5; fi\n' + ) + brigade.chmod(0o755) + payload = { + "hookEventName": "session_start", + "sessionId": "slow-brief", + "workspaceRoot": str(target), + } + + result = _run_hook( + tmp_path, + payload, + BRIGADE_BIN=str(brigade), + BRIGADE_GROK_BRIEF_TIMEOUT_SECONDS="1", + ) + + assert result.returncode == 0 + failed = tmp_path / "state" / "slow-brief" / "brief.failed" + deadline = time.monotonic() + 3 + while not failed.exists() and time.monotonic() < deadline: + time.sleep(0.05) + assert failed.is_file() + assert 'timeout "$brief_timeout_seconds"' not in HOOK.read_text() diff --git a/tests/test_wiring_resolve_target.py b/tests/test_wiring_resolve_target.py new file mode 100644 index 00000000..62590491 --- /dev/null +++ b/tests/test_wiring_resolve_target.py @@ -0,0 +1,79 @@ +"""Wired-target discovery must not treat ~/.brigade as a project (#536).""" + +from __future__ import annotations + +import json +from pathlib import Path + +from brigade import cli +from brigade.config import Config, write_config +from brigade.selection import Selection +from brigade.wiring import resolve_wired_target + + +def _write_config(target: Path, harnesses: list[str]) -> None: + write_config( + target, + Config( + version=1, + selection=Selection(depth="repo", harnesses=harnesses, owner="this-repo", includes=[]), + ), + ) + + +def test_home_roster_without_config_is_not_a_wired_target(tmp_path: Path, monkeypatch): + home = tmp_path / "home" + home.mkdir() + roster = home / ".brigade" + roster.mkdir() + (roster / "roster.toml").write_text("# user-level roster\n") + plain = home / "plain" + plain.mkdir() + monkeypatch.setenv("HOME", str(home)) + + assert resolve_wired_target(str(home), harness=None) is None + assert resolve_wired_target(str(plain), harness=None) is None + assert resolve_wired_target(str(plain), harness="grok") is None + + +def test_project_with_config_resolves_and_respects_harness(tmp_path: Path): + repo = tmp_path / "repo" + nested = repo / "src" / "pkg" + nested.mkdir(parents=True) + _write_config(repo, ["claude", "grok"]) + + assert resolve_wired_target(str(nested), harness=None) == repo.resolve() + assert resolve_wired_target(str(nested), harness="grok") == repo.resolve() + assert resolve_wired_target(str(nested), harness="claude") == repo.resolve() + assert resolve_wired_target(str(nested), harness="openclaw") is None + + +def test_home_roster_does_not_shadow_child_project(tmp_path: Path, monkeypatch): + home = tmp_path / "home" + (home / ".brigade").mkdir(parents=True) + (home / ".brigade" / "roster.toml").write_text("# roster\n") + repo = home / "repos" / "app" + repo.mkdir(parents=True) + _write_config(repo, ["grok"]) + monkeypatch.setenv("HOME", str(home)) + + assert resolve_wired_target(str(repo), harness="grok") == repo.resolve() + assert resolve_wired_target(str(home), harness="grok") is None + + +def test_work_resolve_target_cli(tmp_path: Path, capsys): + home = tmp_path / "home" + (home / ".brigade").mkdir(parents=True) + (home / ".brigade" / "roster.toml").write_text("# roster\n") + repo = home / "repo" + repo.mkdir() + _write_config(repo, ["grok"]) + + assert cli.main(["work", "resolve-target", "--cwd", str(home)]) == 1 + assert cli.main(["work", "resolve-target", "--cwd", str(repo), "--harness", "grok"]) == 0 + out = capsys.readouterr().out.strip() + assert out == str(repo.resolve()) + + assert cli.main(["work", "resolve-target", "--cwd", str(repo), "--harness", "grok", "--json"]) == 0 + payload = json.loads(capsys.readouterr().out) + assert payload == {"ok": True, "target": str(repo.resolve())}