diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 8f8803bd..6989ea04 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -378,7 +378,7 @@ {"id":"bd-haxi","title":"Restart running daemons","description":"Kill and restart any running bd daemons to pick up new version: pkill -f 'bd daemon' \u0026\u0026 bd daemon --start","status":"tombstone","priority":1,"issue_type":"task","created_at":"2025-12-21T13:52:33.066262-08:00","updated_at":"2025-12-21T13:53:49.757078-08:00","deleted_at":"2025-12-21T13:53:49.757078-08:00","deleted_by":"stevey","delete_reason":"manual delete","original_type":"task"} {"id":"bd-haze","title":"Fix beads-9yc: pinned column missing from schema. gt mail...","description":"Fix beads-9yc: pinned column missing from schema. gt mail send fails because some beads DBs lack the pinned column. Add migration to ensure it exists.","status":"closed","priority":2,"issue_type":"task","created_at":"2025-12-19T15:05:33.394801-08:00","updated_at":"2025-12-21T15:26:35.171757-08:00","closed_at":"2025-12-21T15:26:35.171757-08:00"} {"id":"bd-hhv3","title":"Test and document molecular chemistry commands","description":"## Context\n\nImplemented the molecular chemistry UX commands per the design docs:\n- gastown/mayor/rig/docs/molecular-chemistry.md\n- gastown/mayor/rig/docs/chemistry-design-changes.md\n\nCommit: cadf798b\n\n## New Commands to Test\n\n| Command | Purpose |\n|---------|---------|\n| `bd pour \u003cproto\u003e` | Instantiate proto as persistent mol |\n| `bd wisp create \u003cproto\u003e` | Instantiate proto as ephemeral wisp |\n| `bd hook [--agent]` | Inspect what's on an agent's hook |\n\n## Enhanced Commands to Test\n\n| Command | Changes |\n|---------|---------|\n| `bd mol spawn --pour` | New flag, `--persistent` deprecated |\n| `bd mol bond --pour` | Force liquid phase on wisp target |\n| `bd pin --for \u003cagent\u003e --start` | Chemistry workflow support |\n\n## Test Scenarios\n\n1. **bd pour**: Create persistent mol from a proto\n - Verify creates in .beads/ (not .beads-wisp/)\n - Verify variable substitution works\n - Verify --dry-run works\n\n2. **bd wisp create**: Create ephemeral wisp from proto\n - Verify creates in .beads-wisp/\n - Verify bd wisp list shows it\n - Verify bd mol squash works\n - Verify bd mol burn works\n\n3. **bd hook**: Inspect pinned work\n - Pin something, verify bd hook shows it\n - Test --agent flag\n - Test --json output\n\n4. **bd pin --for**: Assign work to agent\n - Verify sets pinned=true\n - Verify sets assignee\n - Verify --start sets status=in_progress\n\n5. **bd mol bond --pour**: Force liquid on wisp target\n - Bond a proto to a wisp with --pour\n - Verify spawned issues are in .beads/\n\n## Documentation\n\n- Update CLAUDE.md with new commands\n- Add examples to --help output (already done)\n- Consider adding to docs/CLI_REFERENCE.md\n\n## Code Review\n\n- Check for edge cases\n- Verify error messages are helpful\n- Ensure --json output is consistent","status":"closed","priority":1,"issue_type":"task","created_at":"2025-12-22T02:22:10.906646-08:00","updated_at":"2025-12-22T02:55:37.983703-08:00","closed_at":"2025-12-22T02:55:37.983703-08:00"} -{"id":"bd-hj0s","title":"Add 'convoy' issue type with reactive completion","description":"Add convoy as a new issue type with reactive completion semantics.\n\nBehavior:\n- Convoy has list of tracked issues (via 'tracks' relation)\n- When all tracked issues close (including wontfix), convoy auto-closes\n- Supports cross-prefix tracking (convoy in hq-* tracks gt-*, bd-*)\n\nImplementation:\n- New type: convoy\n- Reactive completion trigger on tracked issue closure\n- Query support: 'bd list --type=convoy'\n\nRelated: hq-7h8jx (Convoy System epic in town beads)","status":"hooked","priority":1,"issue_type":"task","assignee":"beads/crew/dave","created_at":"2025-12-29T18:47:02.011011-08:00","created_by":"mayor","updated_at":"2025-12-29T23:56:30.617961-08:00","dependencies":[{"issue_id":"bd-hj0s","depends_on_id":"bd-3roq","type":"blocks","created_at":"2025-12-29T18:47:10.59211-08:00","created_by":"daemon"}]} +{"id":"bd-hj0s","title":"Add 'convoy' issue type with reactive completion","description":"Add convoy as a new issue type with reactive completion semantics.\n\nBehavior:\n- Convoy has list of tracked issues (via 'tracks' relation)\n- When all tracked issues close (including wontfix), convoy auto-closes\n- Supports cross-prefix tracking (convoy in hq-* tracks gt-*, bd-*)\n\nImplementation:\n- New type: convoy\n- Reactive completion trigger on tracked issue closure\n- Query support: 'bd list --type=convoy'\n\nRelated: hq-7h8jx (Convoy System epic in town beads)","status":"closed","priority":1,"issue_type":"task","assignee":"beads/crew/dave","created_at":"2025-12-29T18:47:02.011011-08:00","created_by":"mayor","updated_at":"2025-12-30T00:05:13.515078-08:00","closed_at":"2025-12-30T00:05:13.515078-08:00","close_reason":"Implemented convoy type with reactive completion","dependencies":[{"issue_id":"bd-hj0s","depends_on_id":"bd-3roq","type":"blocks","created_at":"2025-12-29T18:47:10.59211-08:00","created_by":"daemon"}]} {"id":"bd-hkr6","title":"GH#518: Document bd setup command","description":"bd setup is undiscoverable. Add to README/docs. Currently only findable by grepping source. See GitHub issue #518.","status":"tombstone","priority":2,"issue_type":"task","created_at":"2025-12-16T01:03:54.664668-08:00","updated_at":"2025-12-17T16:11:17.070763-08:00","deleted_at":"2025-12-17T16:11:17.070763-08:00","deleted_by":"batch delete","delete_reason":"batch delete","original_type":"task"} {"id":"bd-hlsw","title":"Add sync resilience guardrails for forced pushes and prefix mismatches","description":"Beads can get into unrecoverable sync states when remote forces pushes occur (e.g., rebases) combined with prefix mismatches from multi-worker scenarios. Add detection, prevention, and auto-recovery features to handle this gracefully.","status":"open","priority":2,"issue_type":"epic","created_at":"2025-12-14T10:40:14.872875259-07:00","updated_at":"2025-12-14T10:40:14.872875259-07:00"} {"id":"bd-hlsw.3","title":"Auto-recovery mode (bd sync --auto-recover)","description":"Add bd sync --auto-recover flag that: detects problematic sync state, backs up .beads/issues.db with timestamp, rebuilds DB from JSONL atomically, verifies consistency, reports what was fixed. Provides safety valve when sync integrity fails.","status":"open","priority":3,"issue_type":"task","created_at":"2025-12-14T10:40:20.599836875-07:00","updated_at":"2025-12-14T10:40:20.599836875-07:00","dependencies":[{"issue_id":"bd-hlsw.3","depends_on_id":"bd-hlsw","type":"parent-child","created_at":"2025-12-14T10:40:20.600435888-07:00","created_by":"daemon"}]} diff --git a/integrations/beads-mcp/src/beads_mcp/tools.py b/integrations/beads-mcp/src/beads_mcp/tools.py index 7e907758..b544f7f8 100644 --- a/integrations/beads-mcp/src/beads_mcp/tools.py +++ b/integrations/beads-mcp/src/beads_mcp/tools.py @@ -64,45 +64,103 @@ def _register_client_for_cleanup(client: BdClientBase) -> None: pass +def _resolve_beads_redirect(beads_dir: str, workspace_root: str) -> str | None: + """Follow a .beads/redirect file to the actual beads directory. + + Args: + beads_dir: Path to the .beads directory that may contain a redirect + workspace_root: The workspace root directory (parent of beads_dir) + + Returns: + Resolved workspace root if redirect is valid, None otherwise + """ + import glob + + redirect_path = os.path.join(beads_dir, "redirect") + if not os.path.isfile(redirect_path): + return None + + try: + with open(redirect_path, 'r') as f: + redirect_target = f.read().strip() + + if not redirect_target: + return None + + # Resolve relative to workspace_root (the redirect is written from the perspective + # of being inside workspace_root, not inside workspace_root/.beads) + # e.g., redirect contains "../../mayor/rig/.beads" + # from polecats/capable/, this resolves to mayor/rig/.beads + resolved = os.path.normpath(os.path.join(workspace_root, redirect_target)) + + if not os.path.isdir(resolved): + logger.debug(f"Redirect target {resolved} does not exist") + return None + + # Verify the redirected location has a valid database + db_files = glob.glob(os.path.join(resolved, "*.db")) + valid_dbs = [f for f in db_files if ".backup" not in os.path.basename(f)] + + if not valid_dbs: + logger.debug(f"Redirect target {resolved} has no valid .db files") + return None + + # Return the workspace root of the redirected location (parent of .beads) + return os.path.dirname(resolved) + + except Exception as e: + logger.debug(f"Failed to follow redirect: {e}") + return None + + def _find_beads_db_in_tree(start_dir: str | None = None) -> str | None: """Walk up directory tree looking for .beads/*.db (matches Go CLI behavior). - + + Also follows .beads/redirect files to shared beads locations, which is + essential for polecat/crew directories that share a central database. + Args: start_dir: Starting directory (default: current working directory) - + Returns: Absolute path to workspace root containing .beads/*.db, or None if not found """ import glob - + try: current = os.path.abspath(start_dir or os.getcwd()) - + # Resolve symlinks like Go CLI does try: current = os.path.realpath(current) except Exception: pass - + # Walk up directory tree while True: beads_dir = os.path.join(current, ".beads") if os.path.isdir(beads_dir): - # Find any .db file in .beads/ (excluding backups) + # First, check for redirect file (polecat/crew directories use this) + redirected = _resolve_beads_redirect(beads_dir, current) + if redirected: + logger.debug(f"Followed redirect from {current} to {redirected}") + return redirected + + # No redirect, check for local .db files db_files = glob.glob(os.path.join(beads_dir, "*.db")) valid_dbs = [f for f in db_files if ".backup" not in os.path.basename(f)] - + if valid_dbs: # Return workspace root (parent of .beads), not the db path return current - + parent = os.path.dirname(current) if parent == current: # Reached filesystem root break current = parent - + return None - + except Exception as e: logger.debug(f"Failed to search for .beads in tree: {e}") return None diff --git a/integrations/beads-mcp/tests/test_workspace_auto_detect.py b/integrations/beads-mcp/tests/test_workspace_auto_detect.py index a6299de1..f7c9e44f 100644 --- a/integrations/beads-mcp/tests/test_workspace_auto_detect.py +++ b/integrations/beads-mcp/tests/test_workspace_auto_detect.py @@ -135,22 +135,139 @@ async def test_get_client_prefers_context_var_over_auto_detect(): current_workspace.reset(token) -@pytest.mark.asyncio +@pytest.mark.asyncio async def test_get_client_env_var_over_auto_detect(): """Test that BEADS_WORKING_DIR env var takes precedence over auto-detect.""" env_workspace = "/env/path" - + token = current_workspace.set(None) try: with patch.dict(os.environ, {"BEADS_WORKING_DIR": env_workspace}): with patch("beads_mcp.tools._canonicalize_path", return_value=env_workspace): mock_client = AsyncMock() mock_client.ping = AsyncMock(return_value=None) - + with patch("beads_mcp.tools.create_bd_client", return_value=mock_client): client = await _get_client() - + # Should use env var, not call auto-detect assert client is not None finally: current_workspace.reset(token) + + +def test_find_beads_db_follows_redirect(): + """Test that _find_beads_db_in_tree follows .beads/redirect files (gt-tnw). + + This is essential for polecat/crew directories that use redirect files + to share a central beads database. + """ + with tempfile.TemporaryDirectory() as tmpdir: + # Create main workspace with actual database + main_dir = Path(tmpdir) / "mayor" / "rig" + main_dir.mkdir(parents=True) + main_beads = main_dir / ".beads" + main_beads.mkdir() + (main_beads / "beads.db").touch() + + # Create polecat directory with redirect + polecat_dir = Path(tmpdir) / "polecats" / "capable" + polecat_dir.mkdir(parents=True) + polecat_beads = polecat_dir / ".beads" + polecat_beads.mkdir() + + # Write redirect file (relative path from polecat dir) + redirect_file = polecat_beads / "redirect" + redirect_file.write_text("../../mayor/rig/.beads") + + # Should find workspace via redirect + result = _find_beads_db_in_tree(str(polecat_dir)) + assert result == os.path.realpath(str(main_dir)) + + +def test_find_beads_db_redirect_invalid_target(): + """Test that invalid redirect targets are handled gracefully.""" + with tempfile.TemporaryDirectory() as tmpdir: + # Create polecat directory with redirect to nonexistent location + polecat_dir = Path(tmpdir) / "polecats" / "capable" + polecat_dir.mkdir(parents=True) + polecat_beads = polecat_dir / ".beads" + polecat_beads.mkdir() + + # Write redirect file pointing to nonexistent location + redirect_file = polecat_beads / "redirect" + redirect_file.write_text("../../nonexistent/.beads") + + # Should return None (graceful failure) + result = _find_beads_db_in_tree(str(polecat_dir)) + assert result is None + + +def test_find_beads_db_redirect_empty(): + """Test that empty redirect files are handled gracefully.""" + with tempfile.TemporaryDirectory() as tmpdir: + polecat_dir = Path(tmpdir) / "polecats" / "capable" + polecat_dir.mkdir(parents=True) + polecat_beads = polecat_dir / ".beads" + polecat_beads.mkdir() + + # Write empty redirect file + redirect_file = polecat_beads / "redirect" + redirect_file.write_text("") + + # Should return None (graceful failure) + result = _find_beads_db_in_tree(str(polecat_dir)) + assert result is None + + +def test_find_beads_db_redirect_no_db_at_target(): + """Test that redirects to directories without .db files are handled.""" + with tempfile.TemporaryDirectory() as tmpdir: + # Create main workspace WITHOUT database + main_dir = Path(tmpdir) / "mayor" / "rig" + main_dir.mkdir(parents=True) + main_beads = main_dir / ".beads" + main_beads.mkdir() # No .db file! + + # Create polecat directory with redirect + polecat_dir = Path(tmpdir) / "polecats" / "capable" + polecat_dir.mkdir(parents=True) + polecat_beads = polecat_dir / ".beads" + polecat_beads.mkdir() + + redirect_file = polecat_beads / "redirect" + redirect_file.write_text("../../mayor/rig/.beads") + + # Should return None (redirect target has no valid db) + result = _find_beads_db_in_tree(str(polecat_dir)) + assert result is None + + +def test_find_beads_db_prefers_redirect_over_parent(): + """Test that redirect in current dir is followed before walking up.""" + with tempfile.TemporaryDirectory() as tmpdir: + # Create two beads locations + # 1. Parent directory with its own database + parent_dir = Path(tmpdir) + parent_beads = parent_dir / ".beads" + parent_beads.mkdir() + (parent_beads / "beads.db").touch() + + # 2. Remote directory that redirect points to + remote_dir = Path(tmpdir) / "remote" + remote_dir.mkdir() + remote_beads = remote_dir / ".beads" + remote_beads.mkdir() + (remote_beads / "beads.db").touch() + + # Create child directory with redirect to remote + child_dir = Path(tmpdir) / "child" + child_dir.mkdir() + child_beads = child_dir / ".beads" + child_beads.mkdir() + redirect_file = child_beads / "redirect" + redirect_file.write_text("../remote/.beads") + + # Should follow redirect (to remote), not walk up to parent + result = _find_beads_db_in_tree(str(child_dir)) + assert result == os.path.realpath(str(remote_dir))