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"]