From e43db317b62ab6e12359b78c14c0dec576db1678 Mon Sep 17 00:00:00 2001 From: Solomon Neas Date: Fri, 24 Jul 2026 01:14:15 -0400 Subject: [PATCH] fix(harness): migrate legacy Cursor install-state to profile schema v2 Profile sync now upgrades version-1 legacy ~/.cursor/brigade/install-state.json instead of deadlocking, preserving files/hooks/mcp attestations so uninstall still works. Legacy install on an existing v2 state reports the sync/uninstall recovery commands explicitly. Fixes #468 Co-authored-by: Cursor --- src/brigade/cursor_user_cmd.py | 9 +- src/brigade/harness_profile_cmd.py | 142 ++++++++++- tests/test_cursor_install_state_migration.py | 239 +++++++++++++++++++ 3 files changed, 382 insertions(+), 8 deletions(-) create mode 100644 tests/test_cursor_install_state_migration.py diff --git a/src/brigade/cursor_user_cmd.py b/src/brigade/cursor_user_cmd.py index 68a64e61..69639477 100644 --- a/src/brigade/cursor_user_cmd.py +++ b/src/brigade/cursor_user_cmd.py @@ -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") @@ -137,6 +137,13 @@ def _load_state(root: Path) -> dict[str, Any]: if payload is None: empty["_read_error"] = error or "ownership state is unreadable" return empty + if payload.get("schema_version") == harness_profiles.PROFILE_STATE_VERSION: + empty["_read_error"] = ( + "ownership state is owned by harness sync (schema_version 2); " + "run `brigade harness sync --target cursor --scope user --workspace --write` instead, " + "or remove with `brigade harness uninstall --target cursor --scope user --workspace --write`" + ) + return empty if payload.get("version") != STATE_VERSION: empty["_read_error"] = f"unsupported ownership state version: {payload.get('version')!r}" return empty diff --git a/src/brigade/harness_profile_cmd.py b/src/brigade/harness_profile_cmd.py index 07fe2d57..aa6eb8d5 100644 --- a/src/brigade/harness_profile_cmd.py +++ b/src/brigade/harness_profile_cmd.py @@ -18,6 +18,15 @@ _RECOVERY_COMMAND = "brigade harness sync --target --scope user --adopt --write" _SECTIONS = ("instructions", "skills", "generated", "mcp") _HOOK_STATE_KEY = "hooks.json#sessionStart" +_LEGACY_MIGRATED = "legacy_migrated" +_LEGACY_CURSOR_INSTRUCTION = "plugins/local/brigade-loop/rules/brigade-loop.mdc" +_LEGACY_CURSOR_GENERATED = ( + "plugins/local/brigade-loop/.cursor-plugin/plugin.json", + "hooks/brigade-session-start", + "brigade/mcp.json", +) +_LEGACY_CURSOR_SKILL_ID = "brigade-work" +_LEGACY_CURSOR_SKILL_PREFIX = "skills/brigade-work/" @dataclass(frozen=True) @@ -35,6 +44,7 @@ class SurfacePlan: class LoadedProfileState: state: dict[str, Any] error: str | None + migrated_from_legacy: bool = False def digest_text(text: str) -> str: @@ -62,6 +72,69 @@ def write_profile_state(*, state_path: Path, state: dict[str, Any]) -> None: localio.write_json(state_path, state) +def is_legacy_install_state(state: dict[str, Any]) -> bool: + """Return True for the superseded Cursor legacy installer state shape.""" + return ( + state.get("schema_version") is None + and state.get("version") == 1 + and isinstance(state.get("files"), dict) + and isinstance(state.get("hooks"), dict) + and isinstance(state.get("mcp"), dict) + ) + + +def _legacy_migrated_record(**fields: Any) -> dict[str, Any]: + return {**fields, _LEGACY_MIGRATED: True} + + +def _legacy_projected_fingerprint(fingerprint: str) -> str: + """Normalize a legacy full-sha256 MCP attestation to profile projected form.""" + return fingerprint[:16] if len(fingerprint) >= 16 else fingerprint + + +def migrate_legacy_cursor_install_state(*, state: dict[str, Any], workspace: Path, harness: str) -> dict[str, Any]: + """Map legacy Cursor install attestations into schema_version 2 profile state.""" + files = state["files"] + hooks = state["hooks"] + mcp = state["mcp"] + migrated = empty_profile_state(workspace=workspace, harness=harness) + package_version = state.get("package_version") + if isinstance(package_version, str) and package_version: + migrated["package_version"] = package_version + + rule_digest = files.get(_LEGACY_CURSOR_INSTRUCTION) + if isinstance(rule_digest, str): + migrated["instructions"] = _legacy_migrated_record(digest=rule_digest, created_file=True) + + generated: dict[str, dict[str, Any]] = {} + for relative in _LEGACY_CURSOR_GENERATED: + digest = files.get(relative) + if isinstance(digest, str): + generated[relative] = _legacy_migrated_record(digest=digest) + hook_fingerprint = hooks.get("sessionStart") + if isinstance(hook_fingerprint, str): + generated[_HOOK_STATE_KEY] = _legacy_migrated_record(entry_fingerprint=hook_fingerprint) + migrated["generated"] = generated + + skill_files: dict[str, str] = {} + for relative, digest in files.items(): + if not isinstance(relative, str) or not isinstance(digest, str): + continue + if relative.startswith(_LEGACY_CURSOR_SKILL_PREFIX): + skill_files[relative.removeprefix(_LEGACY_CURSOR_SKILL_PREFIX)] = digest + if skill_files: + migrated["skills"][_LEGACY_CURSOR_SKILL_ID] = _legacy_migrated_record(files=skill_files) + + for name, fingerprint in mcp.items(): + if not isinstance(name, str) or not isinstance(fingerprint, str): + continue + migrated["mcp"][name] = _legacy_migrated_record( + projected_fingerprint=_legacy_projected_fingerprint(fingerprint), + managed=True, + ) + return migrated + + 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(): @@ -73,6 +146,12 @@ 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" and is_legacy_install_state(state): + return LoadedProfileState( + migrate_legacy_cursor_install_state(state=state, workspace=workspace, harness=harness), + None, + migrated_from_legacy=True, + ) 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}") @@ -445,8 +524,21 @@ def _skill_plans(profile, state: dict[str, Any], workspace: Path) -> dict[str, A elif path is None or not path.exists(): item.update(status="absent", action="remove") elif digest_bytes(path.read_bytes()) == owned_digest: - item.update(status="removed-registry", action="remove") - removes.append(path) + if record.get(_LEGACY_MIGRATED): + item.update(status="current", action="none") + preserved = desired_records.get(skill_id) + if not isinstance(preserved, dict): + preserved = {} + desired_records[skill_id] = preserved + files_map = preserved.get("files") + if not isinstance(files_map, dict): + files_map = {} + preserved["files"] = files_map + files_map[relative] = owned_digest + preserved[_LEGACY_MIGRATED] = True + else: + item.update(status="removed-registry", action="remove") + removes.append(path) else: item.update(status="changed", action="preserve", detail="removed-registry skill file was edited") conflicts.append(item) @@ -598,8 +690,12 @@ def _generated_plans(profile, state: dict[str, Any], *, adopt: bool) -> dict[str elif not path.exists(): item.update(status="absent", action="remove") elif digest_text(path.read_text(encoding="utf-8")) == record["digest"]: - item.update(status="removed-profile", action="remove") - removes.append(path) + if record.get(_LEGACY_MIGRATED): + item.update(status="current", action="none") + next_records[relative] = dict(record) + else: + item.update(status="removed-profile", action="remove") + removes.append(path) else: item.update(status="conflict", action="preserve", detail="removed-profile generated file was edited") conflicts.append(item) @@ -775,6 +871,28 @@ def _mcp_plan( "remove": set(), "next": {}, } + if all(isinstance(record, dict) and record.get(_LEGACY_MIGRATED) for record in state["mcp"].values()): + text = path.read_text(encoding="utf-8") if path.is_file() else None + legacy_items = [ + { + "surface": "mcp", + "path": str(path), + "name": name, + "status": "current", + "action": "none", + } + for name in sorted(state["mcp"]) + ] + return { + "items": legacy_items, + "conflicts": [], + "path": path, + "adapter": adapter, + "text": text, + "updates": {}, + "remove": set(), + "next": state["mcp"], + } item = { "surface": "mcp", "path": str(path), @@ -847,8 +965,12 @@ def _mcp_plan( elif name not in live: item.update(status="absent", action="remove") elif localio.stable_hash(live[name]) == record.get("projected_fingerprint"): - item.update(status="removed-catalog", action="remove") - remove.add(name) + if record.get(_LEGACY_MIGRATED): + item.update(status="current", action="none") + ownership[name] = record + else: + item.update(status="removed-catalog", action="remove") + remove.add(name) else: item.update(status="conflict", action="preserve", detail="removed-catalog MCP entry was edited") conflicts.append(item) @@ -1003,6 +1125,7 @@ def _result( mcp: dict[str, Any], receipt_path: Path, receipt_state: str, + migration: str | None = None, ) -> dict[str, Any]: return { "harness": profile.harness, @@ -1015,7 +1138,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), @@ -1137,6 +1260,7 @@ def _sync_profile( if surface_conflicts: return _surface_conflict_result(profile, surface_conflicts) loaded = load_profile_state(state_path=profile.state_path, workspace=workspace, harness=profile.harness) + migrated_from_legacy = loaded.migrated_from_legacy if loaded.error: conflict = { "surface": "ownership-state", @@ -1201,6 +1325,8 @@ def _sync_profile( } if isinstance(old_instruction, dict) and old_instruction.get("created_directories"): instruction_record["created_directories"] = list(old_instruction["created_directories"]) + if isinstance(old_instruction, dict) and old_instruction.get(_LEGACY_MIGRATED): + instruction_record[_LEGACY_MIGRATED] = True proposed["instructions"] = instruction_record else: proposed["instructions"] = {} @@ -1299,6 +1425,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="legacy-v1" if migrated_from_legacy else None, ), bool(files_written or files_removed) receipt_state = "present" if profile.receipt_path.exists() else "missing" return _result( @@ -1312,6 +1439,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="legacy-v1" if migrated_from_legacy and ready else None, ), False diff --git a/tests/test_cursor_install_state_migration.py b/tests/test_cursor_install_state_migration.py new file mode 100644 index 00000000..35ea6260 --- /dev/null +++ b/tests/test_cursor_install_state_migration.py @@ -0,0 +1,239 @@ +"""Issue #468: migrate legacy Cursor install-state.json to profile schema v2.""" + +from __future__ import annotations + +import json +from pathlib import Path + +from brigade import __version__ as BRIGADE_VERSION +from brigade import cli, harness_profile_cmd, harness_profiles + + +def _use_home(monkeypatch, tmp_path: Path) -> Path: + home = tmp_path / "home" + home.mkdir() + monkeypatch.setattr(Path, "home", classmethod(lambda cls: home)) + monkeypatch.setenv("HOME", str(home)) + return home + + +def _workspace(tmp_path: Path) -> Path: + workspace = tmp_path / "workspace" + workspace.mkdir() + return workspace + + +def _legacy_state(home: Path, capsys) -> dict: + assert cli.main(["harness", "install", "cursor", "--scope", "user", "--write", "--json"]) == 0 + capsys.readouterr() + return json.loads((home / ".cursor" / "brigade" / "install-state.json").read_text()) + + +def test_migrate_legacy_cursor_install_state_preserves_ownership_entries(tmp_path): + home = tmp_path / "home" + home.mkdir() + workspace = _workspace(tmp_path) + legacy = { + "version": 1, + "package_version": "0.24.0", + "files": { + "plugins/local/brigade-loop/rules/brigade-loop.mdc": "rule-digest", + "plugins/local/brigade-loop/.cursor-plugin/plugin.json": "plugin-digest", + "hooks/brigade-session-start": "hook-digest", + "brigade/mcp.json": "catalog-digest", + "skills/brigade-work/SKILL.md": "skill-md-digest", + "skills/brigade-work/skill.json": "skill-json-digest", + }, + "hooks": {"sessionStart": "hook-entry-digest"}, + "mcp": { + "brigade": "79ccc5bec9aa60af2028383f22a1d1f21a2e9a2d313b580bf50e106a2ffe8992", + "graphtrail": "239bb5bd3544ee1dc6603a7d85486728174a00f5770cb769d1982ce84fbb987e", + }, + } + + migrated = harness_profile_cmd.migrate_legacy_cursor_install_state( + state=legacy, + workspace=workspace, + harness="cursor", + ) + + assert migrated["schema_version"] == harness_profiles.PROFILE_STATE_VERSION + assert migrated["package_version"] == "0.24.0" + assert migrated["workspace"] == str(workspace.resolve()) + assert migrated["harness"] == "cursor" + assert migrated["instructions"] == { + "digest": "rule-digest", + "created_file": True, + "legacy_migrated": True, + } + assert migrated["generated"]["plugins/local/brigade-loop/.cursor-plugin/plugin.json"] == { + "digest": "plugin-digest", + "legacy_migrated": True, + } + assert migrated["generated"]["hooks/brigade-session-start"] == { + "digest": "hook-digest", + "legacy_migrated": True, + } + assert migrated["generated"]["brigade/mcp.json"] == { + "digest": "catalog-digest", + "legacy_migrated": True, + } + assert migrated["generated"]["hooks.json#sessionStart"] == { + "entry_fingerprint": "hook-entry-digest", + "legacy_migrated": True, + } + assert migrated["skills"]["brigade-work"]["files"] == { + "SKILL.md": "skill-md-digest", + "skill.json": "skill-json-digest", + } + assert migrated["skills"]["brigade-work"]["legacy_migrated"] is True + assert migrated["mcp"]["brigade"] == { + "projected_fingerprint": "79ccc5bec9aa60af", + "managed": True, + "legacy_migrated": True, + } + assert migrated["mcp"]["graphtrail"]["projected_fingerprint"] == "239bb5bd3544ee1d" + + +def test_load_profile_state_migrates_legacy_cursor_state(tmp_path): + workspace = _workspace(tmp_path) + state_path = tmp_path / "brigade" / "install-state.json" + state_path.parent.mkdir(parents=True) + state_path.write_text( + json.dumps( + { + "version": 1, + "package_version": BRIGADE_VERSION, + "files": {"plugins/local/brigade-loop/rules/brigade-loop.mdc": "digest"}, + "hooks": {}, + "mcp": {}, + } + ) + ) + + loaded = harness_profile_cmd.load_profile_state( + state_path=state_path, + workspace=workspace, + harness="cursor", + ) + + assert loaded.error is None + assert loaded.migrated_from_legacy is True + assert loaded.state["schema_version"] == harness_profiles.PROFILE_STATE_VERSION + assert loaded.state["instructions"]["digest"] == "digest" + + +def test_legacy_install_then_profile_sync_migrates_and_uninstalls(tmp_path, monkeypatch, capsys): + home = _use_home(monkeypatch, tmp_path) + workspace = _workspace(tmp_path) + legacy = _legacy_state(home, capsys) + + assert ( + cli.main( + [ + "harness", + "sync", + "--target", + "cursor", + "--scope", + "user", + "--workspace", + str(workspace), + "--write", + "--json", + ] + ) + == 0 + ) + sync_payload = json.loads(capsys.readouterr().out) + assert sync_payload["ready"] is True + assert sync_payload["results"][0]["migration"] == "legacy-v1" + + migrated = json.loads((home / ".cursor" / "brigade" / "install-state.json").read_text()) + assert migrated["schema_version"] == harness_profiles.PROFILE_STATE_VERSION + assert migrated["instructions"]["digest"] == legacy["files"]["plugins/local/brigade-loop/rules/brigade-loop.mdc"] + assert set(migrated["skills"]["brigade-work"]["files"]) == { + "SKILL.md", + "skill.json", + "CHANGELOG.md", + } + assert set(migrated["mcp"]) == set(legacy["mcp"]) + + assert ( + cli.main( + [ + "harness", + "uninstall", + "--target", + "cursor", + "--scope", + "user", + "--workspace", + str(workspace), + "--write", + "--json", + ] + ) + == 0 + ) + capsys.readouterr() + assert not (home / ".cursor" / "brigade" / "install-state.json").exists() + assert not (home / ".cursor" / "skills" / "brigade-work" / "SKILL.md").exists() + assert not (home / ".cursor" / "plugins" / "local" / "brigade-loop" / "rules" / "brigade-loop.mdc").exists() + + +def test_sync_on_already_v2_state_is_unchanged(tmp_path, monkeypatch, capsys): + home = _use_home(monkeypatch, tmp_path) + workspace = _workspace(tmp_path) + base = [ + "harness", + "sync", + "--target", + "cursor", + "--scope", + "user", + "--workspace", + str(workspace), + ] + + assert cli.main(base + ["--write", "--json"]) == 0 + capsys.readouterr() + state_path = home / ".cursor" / "brigade" / "install-state.json" + receipt_path = home / ".cursor" / "brigade" / "profile-receipt.json" + before = {path: (path.read_bytes(), path.stat().st_mtime_ns) for path in (state_path, receipt_path)} + + assert cli.main(base + ["--write", "--json"]) == 0 + payload = json.loads(capsys.readouterr().out) + assert payload["results"][0]["status"] == "current" + assert payload["results"][0]["migration"] is None + assert payload["results"][0]["files_written"] == [] + assert {path: (path.read_bytes(), path.stat().st_mtime_ns) for path in (state_path, receipt_path)} == before + + +def test_legacy_install_on_v2_state_reports_actionable_recovery(tmp_path, monkeypatch, capsys): + _use_home(monkeypatch, tmp_path) + workspace = _workspace(tmp_path) + assert ( + cli.main( + [ + "harness", + "sync", + "--target", + "cursor", + "--scope", + "user", + "--workspace", + str(workspace), + "--write", + ] + ) + == 0 + ) + capsys.readouterr() + + assert cli.main(["harness", "install", "cursor", "--scope", "user", "--write", "--json"]) == 1 + payload = json.loads(capsys.readouterr().out) + conflict = next(item for item in payload["conflicts"] if item["surface"] == "ownership-state") + assert "harness sync --target cursor" in conflict["detail"] + assert "harness uninstall --target cursor" in conflict["detail"] + assert "schema_version 2" in conflict["detail"]