agent-notes 2.0.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (162) hide show
  1. agent_notes/VERSION +1 -0
  2. agent_notes/__init__.py +1 -0
  3. agent_notes/__main__.py +4 -0
  4. agent_notes/cli.py +348 -0
  5. agent_notes/commands/__init__.py +27 -0
  6. agent_notes/commands/_install_helpers.py +262 -0
  7. agent_notes/commands/build.py +170 -0
  8. agent_notes/commands/doctor.py +112 -0
  9. agent_notes/commands/info.py +95 -0
  10. agent_notes/commands/install.py +99 -0
  11. agent_notes/commands/list.py +169 -0
  12. agent_notes/commands/memory.py +430 -0
  13. agent_notes/commands/regenerate.py +152 -0
  14. agent_notes/commands/set_role.py +143 -0
  15. agent_notes/commands/uninstall.py +26 -0
  16. agent_notes/commands/update.py +169 -0
  17. agent_notes/commands/validate.py +199 -0
  18. agent_notes/commands/wizard.py +720 -0
  19. agent_notes/config.py +154 -0
  20. agent_notes/data/agents/agents.yaml +352 -0
  21. agent_notes/data/agents/analyst.md +45 -0
  22. agent_notes/data/agents/api-reviewer.md +47 -0
  23. agent_notes/data/agents/architect.md +46 -0
  24. agent_notes/data/agents/coder.md +28 -0
  25. agent_notes/data/agents/database-specialist.md +45 -0
  26. agent_notes/data/agents/debugger.md +47 -0
  27. agent_notes/data/agents/devil.md +47 -0
  28. agent_notes/data/agents/devops.md +38 -0
  29. agent_notes/data/agents/explorer.md +23 -0
  30. agent_notes/data/agents/integrations.md +44 -0
  31. agent_notes/data/agents/lead.md +216 -0
  32. agent_notes/data/agents/performance-profiler.md +44 -0
  33. agent_notes/data/agents/refactorer.md +48 -0
  34. agent_notes/data/agents/reviewer.md +44 -0
  35. agent_notes/data/agents/security-auditor.md +44 -0
  36. agent_notes/data/agents/system-auditor.md +38 -0
  37. agent_notes/data/agents/tech-writer.md +32 -0
  38. agent_notes/data/agents/test-runner.md +36 -0
  39. agent_notes/data/agents/test-writer.md +39 -0
  40. agent_notes/data/cli/claude.yaml +25 -0
  41. agent_notes/data/cli/copilot.yaml +18 -0
  42. agent_notes/data/cli/opencode.yaml +22 -0
  43. agent_notes/data/commands/brainstorm.md +8 -0
  44. agent_notes/data/commands/debug.md +9 -0
  45. agent_notes/data/commands/review.md +10 -0
  46. agent_notes/data/global-claude.md +290 -0
  47. agent_notes/data/global-copilot.md +27 -0
  48. agent_notes/data/global-opencode.md +40 -0
  49. agent_notes/data/hooks/session-context.md.tpl +19 -0
  50. agent_notes/data/models/claude-haiku-4-5.yaml +15 -0
  51. agent_notes/data/models/claude-opus-4-1.yaml +16 -0
  52. agent_notes/data/models/claude-opus-4-5.yaml +16 -0
  53. agent_notes/data/models/claude-opus-4-6.yaml +16 -0
  54. agent_notes/data/models/claude-opus-4-7.yaml +15 -0
  55. agent_notes/data/models/claude-sonnet-4-5.yaml +16 -0
  56. agent_notes/data/models/claude-sonnet-4-6.yaml +15 -0
  57. agent_notes/data/models/claude-sonnet-4.yaml +16 -0
  58. agent_notes/data/pricing.yaml +33 -0
  59. agent_notes/data/roles/orchestrator.yaml +5 -0
  60. agent_notes/data/roles/reasoner.yaml +5 -0
  61. agent_notes/data/roles/scout.yaml +5 -0
  62. agent_notes/data/roles/worker.yaml +5 -0
  63. agent_notes/data/rules/code-quality.md +9 -0
  64. agent_notes/data/rules/safety.md +10 -0
  65. agent_notes/data/scripts/cost-report +211 -0
  66. agent_notes/data/skills/brainstorming/SKILL.md +57 -0
  67. agent_notes/data/skills/code-review/SKILL.md +64 -0
  68. agent_notes/data/skills/debugging-protocol/SKILL.md +51 -0
  69. agent_notes/data/skills/docker-compose/SKILL.md +318 -0
  70. agent_notes/data/skills/docker-compose-advanced/SKILL.md +575 -0
  71. agent_notes/data/skills/docker-dockerfile/SKILL.md +385 -0
  72. agent_notes/data/skills/docker-dockerfile-languages/SKILL.md +293 -0
  73. agent_notes/data/skills/git/SKILL.md +87 -0
  74. agent_notes/data/skills/rails-active-storage/SKILL.md +321 -0
  75. agent_notes/data/skills/rails-broadcasting/SKILL.md +374 -0
  76. agent_notes/data/skills/rails-concerns/SKILL.md +806 -0
  77. agent_notes/data/skills/rails-controllers/SKILL.md +510 -0
  78. agent_notes/data/skills/rails-controllers-advanced/SKILL.md +441 -0
  79. agent_notes/data/skills/rails-helpers/SKILL.md +677 -0
  80. agent_notes/data/skills/rails-initializers/SKILL.md +79 -0
  81. agent_notes/data/skills/rails-javascript/SKILL.md +567 -0
  82. agent_notes/data/skills/rails-jobs/SKILL.md +700 -0
  83. agent_notes/data/skills/rails-kamal/SKILL.md +483 -0
  84. agent_notes/data/skills/rails-lib/SKILL.md +101 -0
  85. agent_notes/data/skills/rails-mailers/SKILL.md +321 -0
  86. agent_notes/data/skills/rails-migrations/SKILL.md +268 -0
  87. agent_notes/data/skills/rails-models/SKILL.md +459 -0
  88. agent_notes/data/skills/rails-models-advanced/SKILL.md +398 -0
  89. agent_notes/data/skills/rails-routes/SKILL.md +804 -0
  90. agent_notes/data/skills/rails-style/SKILL.md +538 -0
  91. agent_notes/data/skills/rails-testing-controllers/SKILL.md +343 -0
  92. agent_notes/data/skills/rails-testing-models/SKILL.md +296 -0
  93. agent_notes/data/skills/rails-testing-system/SKILL.md +375 -0
  94. agent_notes/data/skills/rails-validations/SKILL.md +108 -0
  95. agent_notes/data/skills/rails-view-components/SKILL.md +511 -0
  96. agent_notes/data/skills/rails-view-components-advanced/SKILL.md +376 -0
  97. agent_notes/data/skills/rails-views/SKILL.md +413 -0
  98. agent_notes/data/skills/rails-views-advanced/SKILL.md +450 -0
  99. agent_notes/data/skills/refactoring-protocol/SKILL.md +64 -0
  100. agent_notes/data/skills/tdd/SKILL.md +57 -0
  101. agent_notes/data/templates/__init__.py +1 -0
  102. agent_notes/data/templates/__pycache__/__init__.cpython-314.pyc +0 -0
  103. agent_notes/data/templates/frontmatter/__init__.py +1 -0
  104. agent_notes/data/templates/frontmatter/__pycache__/__init__.cpython-314.pyc +0 -0
  105. agent_notes/data/templates/frontmatter/__pycache__/claude.cpython-314.pyc +0 -0
  106. agent_notes/data/templates/frontmatter/__pycache__/cursor.cpython-314.pyc +0 -0
  107. agent_notes/data/templates/frontmatter/__pycache__/opencode.cpython-314.pyc +0 -0
  108. agent_notes/data/templates/frontmatter/claude.py +44 -0
  109. agent_notes/data/templates/frontmatter/opencode.py +104 -0
  110. agent_notes/doctor_checks.py +189 -0
  111. agent_notes/domain/__init__.py +17 -0
  112. agent_notes/domain/agent.py +34 -0
  113. agent_notes/domain/cli_backend.py +40 -0
  114. agent_notes/domain/diagnostics.py +29 -0
  115. agent_notes/domain/diff.py +44 -0
  116. agent_notes/domain/model.py +27 -0
  117. agent_notes/domain/role.py +13 -0
  118. agent_notes/domain/rule.py +13 -0
  119. agent_notes/domain/skill.py +15 -0
  120. agent_notes/domain/state.py +46 -0
  121. agent_notes/install_state.py +11 -0
  122. agent_notes/registries/__init__.py +16 -0
  123. agent_notes/registries/_base.py +46 -0
  124. agent_notes/registries/agent_registry.py +107 -0
  125. agent_notes/registries/cli_registry.py +89 -0
  126. agent_notes/registries/model_registry.py +85 -0
  127. agent_notes/registries/role_registry.py +64 -0
  128. agent_notes/registries/rule_registry.py +80 -0
  129. agent_notes/registries/skill_registry.py +141 -0
  130. agent_notes/services/__init__.py +8 -0
  131. agent_notes/services/diagnostics/__init__.py +47 -0
  132. agent_notes/services/diagnostics/_checks.py +272 -0
  133. agent_notes/services/diagnostics/_display.py +346 -0
  134. agent_notes/services/diagnostics/_fix.py +169 -0
  135. agent_notes/services/diff.py +349 -0
  136. agent_notes/services/fs.py +195 -0
  137. agent_notes/services/install_state_builder.py +210 -0
  138. agent_notes/services/installer.py +293 -0
  139. agent_notes/services/memory_backend.py +155 -0
  140. agent_notes/services/rendering.py +329 -0
  141. agent_notes/services/session_context.py +23 -0
  142. agent_notes/services/settings_writer.py +79 -0
  143. agent_notes/services/state_store.py +249 -0
  144. agent_notes/services/ui.py +419 -0
  145. agent_notes/services/user_config.py +62 -0
  146. agent_notes/services/validation.py +67 -0
  147. agent_notes/state.py +21 -0
  148. agent_notes-2.0.4.dist-info/METADATA +14 -0
  149. agent_notes-2.0.4.dist-info/RECORD +162 -0
  150. agent_notes-2.0.4.dist-info/WHEEL +5 -0
  151. agent_notes-2.0.4.dist-info/entry_points.txt +2 -0
  152. agent_notes-2.0.4.dist-info/licenses/LICENSE +21 -0
  153. agent_notes-2.0.4.dist-info/top_level.txt +2 -0
  154. tests/conftest.py +20 -0
  155. tests/functional/__init__.py +0 -0
  156. tests/functional/test_build_commands.py +88 -0
  157. tests/functional/test_registries.py +128 -0
  158. tests/integration/__init__.py +0 -0
  159. tests/integration/test_build_output.py +129 -0
  160. tests/plugins/__init__.py +0 -0
  161. tests/plugins/test_agents.py +93 -0
  162. tests/plugins/test_skills.py +77 -0
@@ -0,0 +1,169 @@
1
+ """Fix actions for agent-notes diagnostics."""
2
+
3
+ from pathlib import Path
4
+ from typing import List
5
+
6
+ from ...domain.diagnostics import Issue, FixAction
7
+
8
+
9
+ def do_fix(issues: List[Issue], fix_actions: List[FixAction]) -> bool:
10
+ """Apply fixes with user confirmation and safety guards."""
11
+ from ... import install_state
12
+ from ...config import Color
13
+
14
+ non_build = [i for i in issues if i.type != "build_stale"]
15
+ if non_build and all(i.type == "missing_group" for i in non_build):
16
+ print(f"Not installed. Run '{Color.CYAN}agent-notes install{Color.NC}' to set up.")
17
+ return True
18
+
19
+ if not fix_actions:
20
+ print(f"{Color.GREEN}No fixes needed.{Color.NC}")
21
+ return True
22
+
23
+ print("The following changes will be made:")
24
+ print("")
25
+
26
+ # Safety check: verify DELETE actions are safe
27
+ state = install_state.load_current_state()
28
+ safe_delete_paths = set()
29
+ if state is not None:
30
+ # All paths in state.json are safe to delete
31
+ # Check global install
32
+ if state.global_install:
33
+ for backend_name, bs in state.global_install.clis.items():
34
+ for component_type, items in bs.installed.items():
35
+ for name, item in items.items():
36
+ safe_delete_paths.add(str(Path(item.target)))
37
+
38
+ # Check local installs
39
+ for project_path, scope_state in state.local_installs.items():
40
+ for backend_name, bs in scope_state.clis.items():
41
+ for component_type, items in bs.installed.items():
42
+ for name, item in items.items():
43
+ safe_delete_paths.add(str(Path(item.target)))
44
+
45
+ for action in fix_actions:
46
+ if action.action == "DELETE":
47
+ file_path = Path(action.file)
48
+ # Safety check: only allow DELETE if path is in state.json or is a symlink to our dist/
49
+ if str(file_path) not in safe_delete_paths:
50
+ if file_path.is_symlink():
51
+ target = file_path.readlink()
52
+ if not target.is_absolute():
53
+ target = file_path.parent / target
54
+ # Check if symlink target is within our dist/ directory
55
+ try:
56
+ from ...config import DIST_DIR
57
+ target_resolved = target.resolve()
58
+ dist_resolved = DIST_DIR.resolve()
59
+ if not str(target_resolved).startswith(str(dist_resolved)):
60
+ print(f" {Color.RED}UNSAFE DELETE BLOCKED:{Color.NC} {action.file}")
61
+ print(f" Symlink target {target} is not in agent-notes dist/")
62
+ print(f" This appears to be a third-party file. Skipping for safety.")
63
+ continue
64
+ except (OSError, ValueError):
65
+ print(f" {Color.RED}UNSAFE DELETE BLOCKED:{Color.NC} {action.file}")
66
+ print(f" Cannot verify symlink target safety. Skipping.")
67
+ continue
68
+ else:
69
+ print(f" {Color.RED}UNSAFE DELETE BLOCKED:{Color.NC} {action.file}")
70
+ print(f" File not in state.json and not a symlink to our dist/")
71
+ print(f" This may be a user file. Skipping for safety.")
72
+ continue
73
+
74
+ print(f" {Color.RED}DELETE{Color.NC} {action.file} ({action.details})")
75
+ elif action.action == "RELINK":
76
+ print(f" {Color.CYAN}RELINK{Color.NC} {action.file} ({action.details})")
77
+ elif action.action == "INSTALL":
78
+ print(f" {Color.GREEN}INSTALL{Color.NC} {action.file} ({action.details})")
79
+ elif action.action == "BUILD":
80
+ print(f" {Color.CYAN}BUILD{Color.NC} {action.file} ({action.details})")
81
+
82
+ print("")
83
+ response = input("Proceed? [y/N] ")
84
+
85
+ if response.lower() != 'y':
86
+ print("Aborted.")
87
+ return False
88
+
89
+ print("")
90
+ print("Applying fixes...")
91
+
92
+ needs_install = False
93
+ needs_build = False
94
+
95
+ for action in fix_actions:
96
+ if action.action == "DELETE":
97
+ file_path = Path(action.file)
98
+ # Recheck safety (same logic as above)
99
+ if str(file_path) not in safe_delete_paths:
100
+ if file_path.is_symlink():
101
+ target = file_path.readlink()
102
+ if not target.is_absolute():
103
+ target = file_path.parent / target
104
+ try:
105
+ from ...config import DIST_DIR
106
+ target_resolved = target.resolve()
107
+ dist_resolved = DIST_DIR.resolve()
108
+ if not str(target_resolved).startswith(str(dist_resolved)):
109
+ print(f" {Color.RED}SKIPPED{Color.NC} {action.file} (unsafe)")
110
+ continue
111
+ except (OSError, ValueError):
112
+ print(f" {Color.RED}SKIPPED{Color.NC} {action.file} (unsafe)")
113
+ continue
114
+ else:
115
+ print(f" {Color.RED}SKIPPED{Color.NC} {action.file} (unsafe)")
116
+ continue
117
+
118
+ if file_path.exists() or file_path.is_symlink():
119
+ if file_path.is_symlink():
120
+ file_path.unlink()
121
+ elif file_path.is_dir():
122
+ import shutil
123
+ shutil.rmtree(file_path)
124
+ else:
125
+ file_path.unlink()
126
+ print(f" {Color.RED}DELETED{Color.NC} {action.file}")
127
+
128
+ elif action.action == "RELINK":
129
+ # Extract source from details
130
+ if "symlink to " in action.details:
131
+ source_file_str = action.details.split("symlink to ")[1]
132
+ source_file = Path(source_file_str)
133
+
134
+ if source_file.exists():
135
+ file_path = Path(action.file)
136
+ # Backup original
137
+ if file_path.exists() and not file_path.is_symlink():
138
+ backup_path = Path(str(file_path) + ".bak")
139
+ file_path.rename(backup_path)
140
+
141
+ if file_path.exists():
142
+ file_path.unlink()
143
+
144
+ file_path.parent.mkdir(parents=True, exist_ok=True)
145
+ file_path.symlink_to(source_file.resolve())
146
+ print(f" {Color.CYAN}RELINKED{Color.NC} {action.file}")
147
+ else:
148
+ print(f" {Color.RED}FAILED{Color.NC} {action.file} (source not found: {source_file})")
149
+
150
+ elif action.action == "INSTALL":
151
+ needs_install = True
152
+
153
+ elif action.action == "BUILD":
154
+ needs_build = True
155
+
156
+ # Handle bulk operations
157
+ if needs_install:
158
+ print(f" {Color.GREEN}RUNNING{Color.NC} install to install missing components...")
159
+ # Invocation is deferred to the caller (commands layer) — services must
160
+ # not reach into the commands/top-level namespace. The caller checks
161
+ # for action.action == "INSTALL" in fix_actions and dispatches install().
162
+ # We flag it here by attaching a marker on the actions list.
163
+ fix_actions.append(FixAction("_TRIGGER_INSTALL", "-", "run install"))
164
+
165
+ if needs_build:
166
+ print(f" {Color.CYAN}NOTICE{Color.NC} Build stale issues detected.")
167
+ print(" Run the build process to regenerate files from source.")
168
+
169
+ return True
@@ -0,0 +1,349 @@
1
+ """Diff installed state vs. newly built state."""
2
+
3
+ from __future__ import annotations
4
+ from typing import Optional
5
+ from ..domain.state import State, ScopeState, BackendState, InstalledItem
6
+ from ..domain.diff import ComponentDiff, StateDiff
7
+
8
+
9
+ def _get_color_and_version():
10
+ """Get Color and version avoiding circular import."""
11
+ from ..config import Color, get_version
12
+ return Color, get_version
13
+
14
+
15
+ COMPONENT_TYPES = ("agents", "skills", "rules", "commands", "config", "settings")
16
+
17
+
18
+ def diff_scope_states(old_scope: Optional[ScopeState], new_scope: ScopeState) -> StateDiff:
19
+ """Compare a specific ScopeState's clis against another ScopeState's clis."""
20
+ if old_scope is None:
21
+ old_scope = ScopeState() # Empty scope for comparison
22
+
23
+ # Determine backend changes
24
+ old_backends = set(old_scope.clis.keys())
25
+ new_backends = set(new_scope.clis.keys())
26
+
27
+ added_backends = list(new_backends - old_backends)
28
+ removed_backends = list(old_backends - new_backends)
29
+
30
+ components = []
31
+
32
+ # Process all backends that appear in either old or new
33
+ all_backends = old_backends | new_backends
34
+
35
+ for backend_name in sorted(all_backends):
36
+ old_backend = old_scope.clis.get(backend_name, BackendState())
37
+ new_backend = new_scope.clis.get(backend_name, BackendState())
38
+
39
+ for component in COMPONENT_TYPES:
40
+ old_items = old_backend.installed.get(component, {})
41
+ new_items = new_backend.installed.get(component, {})
42
+
43
+ old_keys = set(old_items.keys())
44
+ new_keys = set(new_items.keys())
45
+
46
+ added = list(new_keys - old_keys)
47
+ removed = list(old_keys - new_keys)
48
+
49
+ # Check for modifications (keys in both with different sha)
50
+ modified = []
51
+ unchanged = []
52
+ for key in old_keys & new_keys:
53
+ old_sha = old_items[key].sha
54
+ new_sha = new_items[key].sha
55
+ if old_sha != new_sha:
56
+ modified.append(key)
57
+ else:
58
+ unchanged.append(key)
59
+
60
+ # Only include ComponentDiff if there's any content
61
+ if added or removed or modified or unchanged:
62
+ components.append(ComponentDiff(
63
+ backend=backend_name,
64
+ component=component,
65
+ added=sorted(added),
66
+ removed=sorted(removed),
67
+ modified=sorted(modified),
68
+ unchanged=sorted(unchanged)
69
+ ))
70
+
71
+ # For version/commit, take from the overall State (not scope-specific)
72
+ old_version = getattr(old_scope, 'version', None) if hasattr(old_scope, 'version') else None
73
+ new_version = getattr(new_scope, 'version', '') if hasattr(new_scope, 'version') else ''
74
+ old_commit = getattr(old_scope, 'source_commit', None) if hasattr(old_scope, 'source_commit') else None
75
+ new_commit = getattr(new_scope, 'source_commit', '') if hasattr(new_scope, 'source_commit') else ''
76
+
77
+ return StateDiff(
78
+ old_version=old_version,
79
+ new_version=new_version,
80
+ old_commit=old_commit,
81
+ new_commit=new_commit,
82
+ added_backends=sorted(added_backends),
83
+ removed_backends=sorted(removed_backends),
84
+ components=components
85
+ )
86
+
87
+
88
+ def diff_states(old: Optional[State], new: State) -> StateDiff:
89
+ """Compute StateDiff between two full State objects.
90
+
91
+ This is a compatibility shim that extracts the appropriate scopes and calls diff_scope_states.
92
+ For now, it compares global scope if present in new, otherwise tries to find a matching local scope.
93
+ """
94
+ current_version = _get_color_and_version()[1]() # Get version from VERSION file
95
+
96
+ if old is None:
97
+ # If there's a global install being created, compare that
98
+ if new.global_install:
99
+ temp_scope = ScopeState(
100
+ installed_at=new.global_install.installed_at,
101
+ updated_at=new.global_install.updated_at,
102
+ mode=new.global_install.mode,
103
+ clis=new.global_install.clis.copy()
104
+ )
105
+ # Copy state-level metadata to the scope for diff purposes
106
+ setattr(temp_scope, 'version', current_version)
107
+ setattr(temp_scope, 'source_commit', new.source_commit)
108
+ return diff_scope_states(None, temp_scope)
109
+ # If it's local-only, compare the first local install
110
+ elif new.local_installs:
111
+ first_local = next(iter(new.local_installs.values()))
112
+ temp_scope = ScopeState(
113
+ installed_at=first_local.installed_at,
114
+ updated_at=first_local.updated_at,
115
+ mode=first_local.mode,
116
+ clis=first_local.clis.copy()
117
+ )
118
+ # Copy state-level metadata to the scope for diff purposes
119
+ setattr(temp_scope, 'version', current_version)
120
+ setattr(temp_scope, 'source_commit', new.source_commit)
121
+ return diff_scope_states(None, temp_scope)
122
+ else:
123
+ # Empty new state
124
+ temp_scope = ScopeState()
125
+ setattr(temp_scope, 'version', current_version)
126
+ setattr(temp_scope, 'source_commit', new.source_commit)
127
+ return diff_scope_states(None, temp_scope)
128
+
129
+ # For old state, try to get version info if it was set previously, otherwise use current version
130
+ old_version = getattr(old, 'version', current_version)
131
+
132
+ # Determine which scope to compare based on what exists in new
133
+ if new.global_install:
134
+ old_scope = old.global_install
135
+ new_scope_raw = new.global_install
136
+ # Create temporary scopes with metadata
137
+ new_scope = ScopeState(
138
+ installed_at=new_scope_raw.installed_at,
139
+ updated_at=new_scope_raw.updated_at,
140
+ mode=new_scope_raw.mode,
141
+ clis=new_scope_raw.clis.copy()
142
+ )
143
+ setattr(new_scope, 'version', current_version)
144
+ setattr(new_scope, 'source_commit', new.source_commit)
145
+
146
+ if old_scope:
147
+ old_scope_temp = ScopeState(
148
+ installed_at=old_scope.installed_at,
149
+ updated_at=old_scope.updated_at,
150
+ mode=old_scope.mode,
151
+ clis=old_scope.clis.copy()
152
+ )
153
+ setattr(old_scope_temp, 'version', old_version)
154
+ setattr(old_scope_temp, 'source_commit', old.source_commit)
155
+ else:
156
+ old_scope_temp = None
157
+
158
+ return diff_scope_states(old_scope_temp, new_scope)
159
+ elif new.local_installs:
160
+ # Find a matching local scope in old, or use None
161
+ first_new_path = next(iter(new.local_installs.keys()))
162
+ old_scope_raw = old.local_installs.get(first_new_path) if old.local_installs else None
163
+ new_scope_raw = new.local_installs[first_new_path]
164
+
165
+ new_scope = ScopeState(
166
+ installed_at=new_scope_raw.installed_at,
167
+ updated_at=new_scope_raw.updated_at,
168
+ mode=new_scope_raw.mode,
169
+ clis=new_scope_raw.clis.copy()
170
+ )
171
+ setattr(new_scope, 'version', current_version)
172
+ setattr(new_scope, 'source_commit', new.source_commit)
173
+
174
+ if old_scope_raw:
175
+ old_scope = ScopeState(
176
+ installed_at=old_scope_raw.installed_at,
177
+ updated_at=old_scope_raw.updated_at,
178
+ mode=old_scope_raw.mode,
179
+ clis=old_scope_raw.clis.copy()
180
+ )
181
+ setattr(old_scope, 'version', old_version)
182
+ setattr(old_scope, 'source_commit', old.source_commit)
183
+ else:
184
+ old_scope = None
185
+
186
+ return diff_scope_states(old_scope, new_scope)
187
+ else:
188
+ # Empty new state - should not happen in practice
189
+ temp_scope = ScopeState()
190
+ setattr(temp_scope, 'version', current_version)
191
+ setattr(temp_scope, 'source_commit', new.source_commit)
192
+ return diff_scope_states(None, temp_scope)
193
+
194
+
195
+ def render_diff_report(diff: StateDiff, use_color: bool = True) -> str:
196
+ """Return a human-readable report as a multi-line string.
197
+
198
+ Format:
199
+ agent-notes update: 3d447ca -> abc1234 (5 commits)
200
+
201
+ + 2 new agents in claude:
202
+ + analyst.md
203
+ + devil.md
204
+ ~ 1 agent updated in claude:
205
+ ~ coder.md (content changed)
206
+ - 1 skill removed in opencode:
207
+ - rails-legacy
208
+
209
+ Summary: +2 added, ~1 modified, -1 removed across 2 backends.
210
+ """
211
+ Color, _ = _get_color_and_version()
212
+
213
+ if not use_color:
214
+ # Create a color-disabled version
215
+ class NoColor:
216
+ GREEN = ""
217
+ YELLOW = ""
218
+ RED = ""
219
+ CYAN = ""
220
+ NC = ""
221
+ color = NoColor()
222
+ else:
223
+ color = Color()
224
+
225
+ if not diff.has_changes():
226
+ return "No changes."
227
+
228
+ lines = []
229
+
230
+ # Header line
231
+ if diff.old_commit and diff.new_commit:
232
+ if diff.old_commit == diff.new_commit:
233
+ header = f"agent-notes update: {diff.new_commit} (no new commits)"
234
+ else:
235
+ header = f"agent-notes update: {diff.old_commit} -> {diff.new_commit}"
236
+ elif diff.new_commit:
237
+ header = f"agent-notes update: initial install at {diff.new_commit}"
238
+ else:
239
+ header = "agent-notes update"
240
+
241
+ lines.append(header)
242
+ lines.append("")
243
+
244
+ # Group by backend, then by component type
245
+ backends_with_changes = {}
246
+ for comp in diff.components:
247
+ if comp.has_changes():
248
+ if comp.backend not in backends_with_changes:
249
+ backends_with_changes[comp.backend] = []
250
+ backends_with_changes[comp.backend].append(comp)
251
+
252
+ # Handle added/removed backends
253
+ for backend in diff.added_backends:
254
+ lines.append(f"{color.GREEN}+ New backend: {backend}{color.NC}")
255
+
256
+ for backend in diff.removed_backends:
257
+ lines.append(f"{color.RED}- Backend removed: {backend}{color.NC}")
258
+
259
+ # Process component changes
260
+ for backend_name in sorted(backends_with_changes.keys()):
261
+ backend_components = backends_with_changes[backend_name]
262
+
263
+ for comp in backend_components:
264
+ if comp.added:
265
+ count = len(comp.added)
266
+ plural = "s" if count != 1 else ""
267
+ lines.append(f"{color.GREEN}+ {count} new {comp.component}{plural} in {backend_name}:{color.NC}")
268
+
269
+ items_to_show = comp.added[:20] # Show max 20
270
+ for item in items_to_show:
271
+ lines.append(f" {color.GREEN}+ {item}{color.NC}")
272
+
273
+ if len(comp.added) > 20:
274
+ remaining = len(comp.added) - 20
275
+ lines.append(f" {color.GREEN}... and {remaining} more{color.NC}")
276
+
277
+ if comp.modified:
278
+ count = len(comp.modified)
279
+ plural = "s" if count != 1 else ""
280
+ lines.append(f"{color.YELLOW}~ {count} {comp.component}{plural} updated in {backend_name}:{color.NC}")
281
+
282
+ items_to_show = comp.modified[:20]
283
+ for item in items_to_show:
284
+ lines.append(f" {color.YELLOW}~ {item} (content changed){color.NC}")
285
+
286
+ if len(comp.modified) > 20:
287
+ remaining = len(comp.modified) - 20
288
+ lines.append(f" {color.YELLOW}... and {remaining} more{color.NC}")
289
+
290
+ if comp.removed:
291
+ count = len(comp.removed)
292
+ plural = "s" if count != 1 else ""
293
+ lines.append(f"{color.RED}- {count} {comp.component}{plural} removed from {backend_name}:{color.NC}")
294
+
295
+ items_to_show = comp.removed[:20]
296
+ for item in items_to_show:
297
+ lines.append(f" {color.RED}- {item}{color.NC}")
298
+
299
+ if len(comp.removed) > 20:
300
+ remaining = len(comp.removed) - 20
301
+ lines.append(f" {color.RED}... and {remaining} more{color.NC}")
302
+
303
+ # Summary line
304
+ if lines and lines[-1] != "":
305
+ lines.append("")
306
+
307
+ total_added = sum(len(c.added) for c in diff.components)
308
+ total_modified = sum(len(c.modified) for c in diff.components)
309
+ total_removed = sum(len(c.removed) for c in diff.components)
310
+ backend_count = len(set(c.backend for c in diff.components if c.has_changes()))
311
+
312
+ summary_parts = []
313
+ if total_added:
314
+ summary_parts.append(f"{color.GREEN}+{total_added} added{color.NC}")
315
+ if total_modified:
316
+ summary_parts.append(f"{color.YELLOW}~{total_modified} modified{color.NC}")
317
+ if total_removed:
318
+ summary_parts.append(f"{color.RED}-{total_removed} removed{color.NC}")
319
+
320
+ if summary_parts:
321
+ summary = f"Summary: {', '.join(summary_parts)} across {backend_count} backend{'s' if backend_count != 1 else ''}."
322
+ lines.append(summary)
323
+
324
+ return "\n".join(lines)
325
+
326
+
327
+ def filter_diff(diff: StateDiff, only: Optional[list[str]] = None) -> StateDiff:
328
+ """Return a new StateDiff keeping only the listed component types.
329
+
330
+ `only` can contain "agents", "skills", "rules", "commands", "config", "settings".
331
+ If None or empty, return the diff unchanged.
332
+ """
333
+ if not only:
334
+ return diff
335
+
336
+ filtered_components = []
337
+ for comp in diff.components:
338
+ if comp.component in only:
339
+ filtered_components.append(comp)
340
+
341
+ return StateDiff(
342
+ old_version=diff.old_version,
343
+ new_version=diff.new_version,
344
+ old_commit=diff.old_commit,
345
+ new_commit=diff.new_commit,
346
+ added_backends=diff.added_backends,
347
+ removed_backends=diff.removed_backends,
348
+ components=filtered_components
349
+ )