Skip to content
Closed
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
11 changes: 9 additions & 2 deletions src/brigade/cursor_user_cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from pathlib import Path
from typing import Any

from . import __version__, component_bins, localio
from . import __version__, component_bins, harness_profiles, localio

STATE_VERSION = 1
MANAGED_MCP_NAMES = ("brigade", "graphtrail", "miseledger")
Expand Down Expand Up @@ -138,7 +138,14 @@ def _load_state(root: Path) -> dict[str, Any]:
empty["_read_error"] = error or "ownership state is unreadable"
return empty
if payload.get("version") != STATE_VERSION:
empty["_read_error"] = f"unsupported ownership state version: {payload.get('version')!r}"
if payload.get("schema_version") == harness_profiles.PROFILE_STATE_VERSION:
empty["_read_error"] = (
"ownership state is managed by `brigade harness sync` (profile schema "
f"v{harness_profiles.PROFILE_STATE_VERSION}); recover by running `brigade harness "
"uninstall --target cursor --scope user` from the owning surface first"
)
else:
empty["_read_error"] = f"unsupported ownership state version: {payload.get('version')!r}"
return empty
for key in ("files", "hooks", "mcp"):
if not isinstance(payload.get(key), dict):
Expand Down
83 changes: 82 additions & 1 deletion src/brigade/harness_profile_cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ class SurfacePlan:
class LoadedProfileState:
state: dict[str, Any]
error: str | None
migration: dict[str, Any] | None = None


def digest_text(text: str) -> str:
Expand Down Expand Up @@ -62,6 +63,78 @@ def write_profile_state(*, state_path: Path, state: dict[str, Any]) -> None:
localio.write_json(state_path, state)


_LEGACY_CURSOR_RULE = "plugins/local/brigade-loop/rules/brigade-loop.mdc"
_LEGACY_CURSOR_PLUGIN_MANIFEST = "plugins/local/brigade-loop/.cursor-plugin/plugin.json"


def _migrate_legacy_cursor_state(
*, state: dict[str, Any], state_path: Path, workspace: Path
) -> tuple[dict[str, Any], dict[str, Any]] | None:
"""Adopt a legacy `brigade harness install cursor` ownership state (schema v1).

The legacy installer is documented as superseded by `harness sync`, so its
attestations are carried into the v2 ownership model instead of deadlocking:
uninstall still removes exactly what the legacy installer wrote. Returns
``None`` when the payload is not a migratable legacy state.
"""
from . import cursor_user_cmd

if state.get("schema_version") is not None or state.get("version") != cursor_user_cmd.STATE_VERSION:
return None
files = state.get("files")
hooks = state.get("hooks")
mcp = state.get("mcp")
if not (isinstance(files, dict) and isinstance(hooks, dict) and isinstance(mcp, dict)):
return None
sections = (files, hooks, mcp)
if any(not isinstance(fingerprint, str) for section in sections for fingerprint in section.values()):
return None
root = state_path.parent.parent
migrated = empty_profile_state(workspace=workspace, harness="cursor")
rule_fingerprint = files.get(_LEGACY_CURSOR_RULE)
if rule_fingerprint is not None:
migrated["instructions"] = {
"digest": rule_fingerprint,
"created_file": True,
# Leaf directories the legacy uninstaller removed once empty.
"created_directories": [
str(root / "plugins" / "local" / "brigade-loop" / "rules"),
str(root / "plugins" / "local" / "brigade-loop"),
],
}
for relative, fingerprint in sorted(files.items()):
if relative == _LEGACY_CURSOR_RULE:
continue
parts = relative.split("/")
if len(parts) >= 3 and parts[0] == "skills":
skill_id = parts[1]
record = migrated["skills"].setdefault(
skill_id, {"files": {}, "created_directories": [str(root / "skills" / skill_id)]}
)
record["files"]["/".join(parts[2:])] = fingerprint
continue
created: list[str] = []
if relative == _LEGACY_CURSOR_PLUGIN_MANIFEST:
created = [
str(root / "plugins" / "local" / "brigade-loop" / ".cursor-plugin"),
str(root / "plugins" / "local" / "brigade-loop"),
]
migrated["generated"][relative] = {"digest": fingerprint, "created_directories": created}
hook_fingerprint = hooks.get("sessionStart")
if hook_fingerprint is not None:
migrated["generated"][_HOOK_STATE_KEY] = {"entry_fingerprint": hook_fingerprint}
for name, fingerprint in sorted(mcp.items()):
# cursor_user_cmd._digest_value and localio.stable_hash share the same
# canonical JSON rendering; stable_hash truncates to 16 chars, so the
# legacy digest's prefix attests to an unedited live entry.
migrated["mcp"][name] = {"projected_fingerprint": fingerprint[:16], "managed": True}
report = {
"from": "legacy-install-v1",
"adopted": {"files": len(files), "hooks": len(hooks), "mcp": len(mcp)},
}
return migrated, report


def load_profile_state(*, state_path: Path, workspace: Path, harness: str) -> LoadedProfileState:
"""Read ownership state without writing, including version refreshes."""
if not state_path.exists():
Expand All @@ -73,6 +146,11 @@ def load_profile_state(*, state_path: Path, workspace: Path, harness: str) -> Lo
if not isinstance(state, dict):
return LoadedProfileState({}, "ownership state is not an object")
if state.get("schema_version") != harness_profiles.PROFILE_STATE_VERSION:
if harness == "cursor":
migrated = _migrate_legacy_cursor_state(state=state, state_path=state_path, workspace=workspace)
if migrated is not None:
migrated_state, report = migrated
return LoadedProfileState(migrated_state, None, report)
return LoadedProfileState({}, f"unsupported ownership state version: {state.get('schema_version')}")
if state.get("harness") != harness:
return LoadedProfileState({}, f"ownership harness mismatch: {state.get('harness')} != {harness}")
Expand Down Expand Up @@ -1003,6 +1081,7 @@ def _result(
mcp: dict[str, Any],
receipt_path: Path,
receipt_state: str,
migration: dict[str, Any] | None = None,
) -> dict[str, Any]:
return {
"harness": profile.harness,
Expand All @@ -1015,7 +1094,7 @@ def _result(
"conflicts": conflicts,
"files_written": sorted(files_written),
"files_removed": sorted(files_removed),
"migration": None,
"migration": migration,
"capabilities": {},
"mcp": mcp,
"receipt_path": str(receipt_path),
Expand Down Expand Up @@ -1299,6 +1378,7 @@ def _sync_profile(
mcp={"status": "ready", "items": mcp_plan["items"]},
receipt_path=profile.receipt_path,
receipt_state="applied" if files_written or files_removed else "current",
migration=loaded.migration,
), bool(files_written or files_removed)
receipt_state = "present" if profile.receipt_path.exists() else "missing"
return _result(
Expand All @@ -1312,6 +1392,7 @@ def _sync_profile(
mcp={"status": "ready" if not mcp_plan["conflicts"] else "conflict", "items": mcp_plan["items"]},
receipt_path=profile.receipt_path,
receipt_state=receipt_state,
migration=loaded.migration,
), False


Expand Down
54 changes: 54 additions & 0 deletions tests/test_harness_profile_cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,3 +53,57 @@ def test_load_state_is_read_only_when_package_version_is_stale(tmp_path):
assert loaded.error is None
assert loaded.state["package_version"] == "old"
assert (path.read_bytes(), path.stat().st_mtime_ns) == before


def test_load_state_migrates_legacy_cursor_install_state_read_only(tmp_path):
root = tmp_path / "home" / ".cursor"
path = root / "brigade" / "install-state.json"
path.parent.mkdir(parents=True)
legacy = {
"version": 1,
"package_version": "old",
"files": {
"plugins/local/brigade-loop/rules/brigade-loop.mdc": "a" * 64,
"plugins/local/brigade-loop/.cursor-plugin/plugin.json": "b" * 64,
"hooks/brigade-session-start": "c" * 64,
"brigade/mcp.json": "d" * 64,
"skills/brigade-work/SKILL.md": "e" * 64,
},
"hooks": {"sessionStart": "f" * 64},
"mcp": {"brigade": "0123456789abcdef" + "0" * 48},
}
path.write_text(json.dumps(legacy))
before = path.read_bytes(), path.stat().st_mtime_ns

loaded = harness_profile_cmd.load_profile_state(state_path=path, workspace=tmp_path, harness="cursor")
assert loaded.error is None
assert loaded.migration == {"from": "legacy-install-v1", "adopted": {"files": 5, "hooks": 1, "mcp": 1}}
state = loaded.state
assert state["schema_version"] == harness_profiles.PROFILE_STATE_VERSION
assert state["harness"] == "cursor"
assert state["instructions"]["digest"] == "a" * 64
assert state["instructions"]["created_file"] is True
assert state["generated"]["plugins/local/brigade-loop/.cursor-plugin/plugin.json"]["digest"] == "b" * 64
assert state["generated"]["hooks/brigade-session-start"]["digest"] == "c" * 64
assert state["generated"]["brigade/mcp.json"]["digest"] == "d" * 64
assert state["generated"]["hooks.json#sessionStart"]["entry_fingerprint"] == "f" * 64
assert state["skills"]["brigade-work"]["files"] == {"SKILL.md": "e" * 64}
# The projected fingerprint is the stable_hash-compatible prefix of the legacy digest.
assert state["mcp"]["brigade"] == {"projected_fingerprint": "0123456789abcdef", "managed": True}
# Migration is in-memory only; the on-disk legacy state is untouched until a sync write.
assert (path.read_bytes(), path.stat().st_mtime_ns) == before

# Other harnesses keep the fail-closed version error.
rejected = harness_profile_cmd.load_profile_state(state_path=path, workspace=tmp_path, harness="codex")
assert rejected.error == "unsupported ownership state version: None"
assert rejected.migration is None


def test_load_state_rejects_non_dict_legacy_cursor_sections(tmp_path):
root = tmp_path / "home" / ".cursor"
path = root / "brigade" / "install-state.json"
path.parent.mkdir(parents=True)
path.write_text(json.dumps({"version": 1, "files": [], "hooks": {}, "mcp": {}}))
loaded = harness_profile_cmd.load_profile_state(state_path=path, workspace=tmp_path, harness="cursor")
assert loaded.error == "unsupported ownership state version: None"
assert loaded.migration is None
85 changes: 85 additions & 0 deletions tests/test_harness_user_scope_slice2.py
Original file line number Diff line number Diff line change
Expand Up @@ -461,3 +461,88 @@ def test_target_all_dry_run_reports_seven_results(tmp_path, monkeypatch, capsys)
payload = json.loads(capsys.readouterr().out)
assert [result["harness"] for result in payload["results"]] == list(harness_profiles.USER_SCOPE_HARNESS_IDS)
assert len(payload["results"]) == 7


def _legacy_cursor_install(home: Path, monkeypatch, capsys) -> None:
"""Run the superseded `harness install cursor` surface against the temp home."""
from brigade import cli, cursor_user_cmd

monkeypatch.setattr(cursor_user_cmd, "_home_dir", lambda: home)
assert cli.main(["harness", "install", "cursor", "--scope", "user", "--write", "--json"]) == 0
capsys.readouterr()


def test_cursor_legacy_install_migrates_into_profile_sync(tmp_path, monkeypatch, capsys):
from brigade import cli

home = _use_home(monkeypatch, tmp_path)
workspace = _workspace_with_stdio_server(tmp_path, capsys, ["cursor"])
_legacy_cursor_install(home, monkeypatch, capsys)
cursor = home / ".cursor"
state_path = home / SURFACES["cursor"]["state"]
assert json.loads(state_path.read_text())["version"] == 1
rule = home / SURFACES["cursor"]["instruction"]
rule_text = rule.read_text()

assert cli.main(_sync_base(workspace, "cursor") + ["--allow-global-stdio", "--write", "--json"]) == 0
payload = json.loads(capsys.readouterr().out)
result = payload["results"][0]
assert result["status"] == "updated"
assert result["migration"]["from"] == "legacy-install-v1"

migrated = json.loads(state_path.read_text())
assert migrated["schema_version"] == harness_profiles.PROFILE_STATE_VERSION
assert migrated["harness"] == "cursor"
# Legacy attestations are carried into the v2 ownership model, not clobbered.
assert migrated["instructions"]["digest"]
assert "hooks.json#sessionStart" in migrated["generated"]
assert "brigade" in migrated["mcp"]
# The legacy-owned rule survives byte-for-byte.
assert rule.read_text() == rule_text

# A second sync over the migrated state is a no-op.
assert cli.main(_sync_base(workspace, "cursor") + ["--allow-global-stdio", "--write", "--json"]) == 0
second = json.loads(capsys.readouterr().out)
assert second["results"][0]["status"] == "current"
assert second["results"][0]["files_written"] == []
assert second["results"][0]["migration"] is None

# Uninstall removes exactly the artifacts, legacy-originated and synced alike.
assert cli.main(_uninstall_base(workspace, "cursor") + ["--write", "--json"]) == 0
capsys.readouterr()
assert not rule.exists()
# Leaf directories the legacy uninstaller owned are pruned once empty.
assert not (cursor / "plugins" / "local" / "brigade-loop").exists()
assert not (cursor / "hooks" / "brigade-session-start").exists()
assert not (cursor / "skills" / "brigade-work").exists()
assert not (cursor / "brigade" / "mcp.json").exists()
assert not state_path.exists()
assert not (home / SURFACES["cursor"]["receipt"]).exists()
hooks_doc = json.loads((cursor / "hooks.json").read_text())
assert "sessionStart" not in hooks_doc.get("hooks", {})
servers = json.loads((cursor / "mcp.json").read_text()).get("mcpServers", {})
assert not {"brigade", "graphtrail", "miseledger"} & set(servers)


def test_cursor_profile_sync_then_legacy_install_names_recovery(tmp_path, monkeypatch, capsys):
from brigade import cli, cursor_user_cmd

home = _use_home(monkeypatch, tmp_path)
workspace = _workspace(tmp_path)
assert cli.main(_sync_base(workspace, "cursor") + ["--write", "--json"]) == 0
capsys.readouterr()
state_path = home / SURFACES["cursor"]["state"]
before = state_path.read_bytes()

monkeypatch.setattr(cursor_user_cmd, "_home_dir", lambda: home)
assert cli.main(["harness", "install", "cursor", "--scope", "user", "--write", "--json"]) == 1
payload = json.loads(capsys.readouterr().out)
assert payload["ready"] is False
assert payload["files_written"] == []
state_conflicts = [item for item in payload["conflicts"] if item["surface"] == "ownership-state"]
assert len(state_conflicts) == 1
assert "brigade harness uninstall --target cursor --scope user" in state_conflicts[0]["detail"]

# Fails closed: the profile-owned state and artifacts are untouched.
assert state_path.read_bytes() == before
assert json.loads(state_path.read_text())["schema_version"] == harness_profiles.PROFILE_STATE_VERSION
Loading