diff --git a/.claude/hooks/delegation-guard.sh b/.claude/hooks/delegation-guard.sh index 91d95f0..51c54eb 100755 --- a/.claude/hooks/delegation-guard.sh +++ b/.claude/hooks/delegation-guard.sh @@ -46,6 +46,14 @@ case "$rel_path" in .claude/*|.agents/*|docs/*|.github/*) hook_allow ;; esac +# Merge conflict resolution is mechanical arbitration, not new Rust +# implementation work. Limit the exception to the Rust file that is +# currently unmerged so unrelated implementation files stay guarded. +conflicted_paths="$(git -C "$HOOK_CWD" diff --name-only --diff-filter=U 2>/dev/null || true)" +if printf '%s\n' "$conflicted_paths" | grep -qxF "$rel_path"; then + hook_allow +fi + if [ "$role" = "root" ] || [ -z "$role" ]; then hook_block "Root orchestrator should dispatch to a subagent for Rust implementation (file: $file_path). Use the 01-implementer agent." fi diff --git a/.claude/hooks/tests/test_delegation_guard_subagent.sh b/.claude/hooks/tests/test_delegation_guard_subagent.sh index 68ca4d2..e2f263e 100755 --- a/.claude/hooks/tests/test_delegation_guard_subagent.sh +++ b/.claude/hooks/tests/test_delegation_guard_subagent.sh @@ -118,7 +118,59 @@ for path in ".claude/hooks/role-marker.sh" ".agents/rules/foo.md" "docs/src/intr done pass "guard allows root edits of infra files (.claude/, .agents/, docs/, .github/)" -# --- Step 6: Backwards-compat — if a role file exists with +# --- Step 6: Merge conflict resolution is allowed from root. Conflict +# marker arbitration is mechanical, so the root orchestrator may edit +# Rust source files while the worktree has unmerged paths. +merge_repo="$TMP_DIR/merge-repo" +mkdir -p "$merge_repo/crates/plumb-core/src" +git -C "$merge_repo" init -q +git -C "$merge_repo" config user.name "Hook Test" +git -C "$merge_repo" config user.email "hook-test@example.com" +printf 'pub fn value() -> u8 { 1 }\n' > "$merge_repo/crates/plumb-core/src/lib.rs" +git -C "$merge_repo" add crates/plumb-core/src/lib.rs +git -C "$merge_repo" commit -qm "initial" +git -C "$merge_repo" checkout -qb feature +printf 'pub fn value() -> u8 { 2 }\n' > "$merge_repo/crates/plumb-core/src/lib.rs" +git -C "$merge_repo" commit -am "feature" -q +git -C "$merge_repo" checkout -q - +printf 'pub fn value() -> u8 { 3 }\n' > "$merge_repo/crates/plumb-core/src/lib.rs" +git -C "$merge_repo" commit -am "main" -q +git -C "$merge_repo" merge feature >/dev/null 2>&1 && fail "expected merge conflict" + +merge_input="$(jq -n \ + --arg sid "$session_id" \ + --arg cwd "$merge_repo" \ + --arg fp "$merge_repo/crates/plumb-core/src/lib.rs" \ + '{session_id: $sid, cwd: $cwd, hook_event_name: "PreToolUse", tool_name: "Edit", tool_input: {file_path: $fp, content: "x"}}')" +guard_output="$(env -u CLAUDE_SESSION_ID \ + CLAUDE_PROJECT_DIR="$merge_repo" \ + bash "$HOOKS_DIR/delegation-guard.sh" <<<"$merge_input")" +decision="$(printf '%s' "$guard_output" | jq -r '.decision // empty' 2>/dev/null || true)" +[ "$decision" = "allow" ] || fail "guard should ALLOW root .rs edit during merge conflict resolution, got: $guard_output" +pass "guard allows root Rust edits while unmerged paths exist" + +unrelated_merge_input="$(jq -n \ + --arg sid "$session_id" \ + --arg cwd "$merge_repo" \ + --arg fp "$merge_repo/crates/plumb-cli/src/main.rs" \ + '{session_id: $sid, cwd: $cwd, hook_event_name: "PreToolUse", tool_name: "Edit", tool_input: {file_path: $fp, content: "x"}}')" +guard_output="$(env -u CLAUDE_SESSION_ID \ + CLAUDE_PROJECT_DIR="$merge_repo" \ + bash "$HOOKS_DIR/delegation-guard.sh" <<<"$unrelated_merge_input")" +decision="$(printf '%s' "$guard_output" | jq -r '.decision // empty' 2>/dev/null || true)" +[ "$decision" = "block" ] || fail "guard should BLOCK unrelated root .rs edit during merge conflict resolution, got: $guard_output" +pass "guard keeps unrelated Rust files blocked during merge conflict resolution" + +printf 'pub fn value() -> u8 { 2 }\n' > "$merge_repo/crates/plumb-core/src/lib.rs" +git -C "$merge_repo" add crates/plumb-core/src/lib.rs +guard_output="$(env -u CLAUDE_SESSION_ID \ + CLAUDE_PROJECT_DIR="$merge_repo" \ + bash "$HOOKS_DIR/delegation-guard.sh" <<<"$merge_input")" +decision="$(printf '%s' "$guard_output" | jq -r '.decision // empty' 2>/dev/null || true)" +[ "$decision" = "block" ] || fail "guard should BLOCK root .rs edit after conflict resolution, got: $guard_output" +pass "guard blocks root Rust edits after the conflict is resolved" + +# --- Step 7: Backwards-compat — if a role file exists with # subagent:, guard still allows. This preserves the existing # escape hatch for harnesses that DO populate agent_type at # SessionStart (future Claude Code versions) or for manual override. diff --git a/.gitignore b/.gitignore index a22304a..4ead483 100644 --- a/.gitignore +++ b/.gitignore @@ -15,4 +15,5 @@ .claude/worktrees/ .claude/memory/ .claude/settings.local.json +.claude-pr/ node_modules/ diff --git a/crates/plumb-mcp/AGENTS.md b/crates/plumb-mcp/AGENTS.md index d1ccb9d..36f2000 100644 --- a/crates/plumb-mcp/AGENTS.md +++ b/crates/plumb-mcp/AGENTS.md @@ -33,15 +33,13 @@ Built on `rmcp 0.2.x` with the `#[tool_router]` + `#[tool]` + ## Adding a new tool -See `.agents/rules/mcp-tool-patterns.md`. Summary: +See `.agents/rules/mcp-tool-patterns.md` for the handoff path. Summary: 1. Add a `Deserialize + JsonSchema` struct for the tool's args. 2. Add a `#[tool(description = "…")]` async method on `PlumbServer`. 3. Add a protocol test case in `crates/plumb-cli/tests/mcp_stdio.rs`. 4. Update `docs/src/mcp.md` tool table. -Use the `09-mcp-tool-author` subagent for cookie-cutter execution. - ## Depends on - `plumb-core` (types; `test-fake` feature enabled so `lint_url` can