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,155 @@
1
+ """Memory backend implementations for the three storage strategies."""
2
+
3
+ from __future__ import annotations
4
+ import re
5
+ from datetime import date
6
+ from pathlib import Path
7
+ from typing import Optional
8
+
9
+
10
+ OBSIDIAN_CATEGORIES = ["Patterns", "Decisions", "Mistakes", "Context", "Sessions"]
11
+
12
+
13
+ def _slug(title: str) -> str:
14
+ return re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-")[:60]
15
+
16
+
17
+ def _today() -> str:
18
+ return date.today().isoformat()
19
+
20
+
21
+ # ── Obsidian backend ───────────────────────────────────────────────────────────
22
+
23
+ def obsidian_init(vault: Path) -> None:
24
+ """Create category folders and a stub Index.md if the vault is new."""
25
+ vault.mkdir(parents=True, exist_ok=True)
26
+ for cat in OBSIDIAN_CATEGORIES:
27
+ (vault / cat).mkdir(exist_ok=True)
28
+ index = vault / "Index.md"
29
+ if not index.exists():
30
+ obsidian_regenerate_index(vault)
31
+
32
+
33
+ def obsidian_write_note(
34
+ vault: Path,
35
+ *,
36
+ title: str,
37
+ body: str,
38
+ note_type: str, # "pattern"|"decision"|"mistake"|"context"|"session"
39
+ agent: str = "",
40
+ project: str = "",
41
+ tags: list[str] | None = None,
42
+ ) -> Path:
43
+ """Write a structured note to the correct category folder."""
44
+ category_map = {
45
+ "pattern": "Patterns",
46
+ "decision": "Decisions",
47
+ "mistake": "Mistakes",
48
+ "context": "Context",
49
+ "session": "Sessions",
50
+ }
51
+ folder = vault / category_map.get(note_type, "Context")
52
+ folder.mkdir(parents=True, exist_ok=True)
53
+
54
+ filename = f"{_today()}-{_slug(title)}.md"
55
+ path = folder / filename
56
+
57
+ frontmatter_lines = [
58
+ "---",
59
+ f"date: {_today()}",
60
+ f"type: {note_type}",
61
+ ]
62
+ if agent:
63
+ frontmatter_lines.append(f"agent: {agent}")
64
+ if project:
65
+ frontmatter_lines.append(f"project: {project}")
66
+ if tags:
67
+ frontmatter_lines.append(f"tags: [{', '.join(tags)}]")
68
+ frontmatter_lines.append("---")
69
+
70
+ path.write_text("\n".join(frontmatter_lines) + f"\n\n# {title}\n\n{body}\n")
71
+ obsidian_regenerate_index(vault)
72
+ return path
73
+
74
+
75
+ def obsidian_regenerate_index(vault: Path) -> None:
76
+ """Regenerate Index.md from all notes in the vault (last 20 per category)."""
77
+ lines = [f"# Agent Memory Index", f"Last updated: {_today()}", ""]
78
+ for cat in OBSIDIAN_CATEGORIES:
79
+ folder = vault / cat
80
+ if not folder.exists():
81
+ continue
82
+ notes = sorted(folder.glob("*.md"), reverse=True)[:20]
83
+ if not notes:
84
+ continue
85
+ lines.append(f"## {cat} ({len(list(folder.glob('*.md')))})")
86
+ for note in notes:
87
+ stem = note.stem
88
+ # Extract title from first H1 if possible
89
+ try:
90
+ first_h1 = next(
91
+ (l.lstrip("# ").strip() for l in note.read_text().splitlines() if l.startswith("# ")),
92
+ stem,
93
+ )
94
+ except OSError:
95
+ first_h1 = stem
96
+ lines.append(f"- [[{stem}]] — {first_h1}")
97
+ lines.append("")
98
+ (vault / "Index.md").write_text("\n".join(lines))
99
+
100
+
101
+ def obsidian_list_notes(vault: Path) -> list[dict]:
102
+ """Return list of note metadata dicts from the vault."""
103
+ notes = []
104
+ for cat in OBSIDIAN_CATEGORIES:
105
+ folder = vault / cat
106
+ if not folder.exists():
107
+ continue
108
+ for f in sorted(folder.glob("*.md")):
109
+ notes.append({"category": cat, "file": f.name, "path": str(f)})
110
+ return notes
111
+
112
+
113
+ def obsidian_claude_md_section(vault: Path) -> str:
114
+ return (
115
+ f"## Agent Memory\n\n"
116
+ f"Your memory vault is at `{vault}`. Start at `Index.md` to find relevant context.\n"
117
+ f"Categories: Patterns, Decisions, Mistakes, Context, Sessions.\n"
118
+ f"Use `agent-notes memory add` to save insights, `agent-notes memory index` to refresh Index.md."
119
+ )
120
+
121
+
122
+ # ── Local backend ──────────────────────────────────────────────────────────────
123
+
124
+ def local_init(memory_dir: Path) -> None:
125
+ memory_dir.mkdir(parents=True, exist_ok=True)
126
+
127
+
128
+ def local_list_notes(memory_dir: Path) -> list[dict]:
129
+ if not memory_dir.exists():
130
+ return []
131
+ return [
132
+ {"agent": d.name, "path": str(d), "size": sum(f.stat().st_size for f in d.rglob("*") if f.is_file())}
133
+ for d in sorted(memory_dir.iterdir()) if d.is_dir()
134
+ ]
135
+
136
+
137
+ def local_regenerate_index(memory_dir: Path) -> None:
138
+ agents = [d.name for d in sorted(memory_dir.iterdir()) if d.is_dir()] if memory_dir.exists() else []
139
+ lines = [f"# Agent Memory", f"Last updated: {_today()}", ""]
140
+ for agent in agents:
141
+ agent_dir = memory_dir / agent
142
+ files = list(agent_dir.glob("*.md"))
143
+ lines.append(f"## {agent} ({len(files)} files)")
144
+ for f in sorted(files):
145
+ lines.append(f"- [{f.stem}]({agent}/{f.name})")
146
+ lines.append("")
147
+ (memory_dir / "Index.md").write_text("\n".join(lines))
148
+
149
+
150
+ def local_claude_md_section(memory_dir: Path) -> str:
151
+ return (
152
+ f"## Agent Memory\n\n"
153
+ f"Your memory is at `{memory_dir}/`. Each agent has its own subdirectory.\n"
154
+ f"Files are plain markdown — read and write freely."
155
+ )
@@ -0,0 +1,329 @@
1
+ """Frontmatter and agent file rendering."""
2
+
3
+ import yaml
4
+ import shutil
5
+ import importlib
6
+ import re
7
+ from pathlib import Path
8
+ from typing import Dict, Any, Optional
9
+
10
+
11
+ def expand_includes(text: str, shared_dir: Path) -> str:
12
+ """Expand include directives in text by substituting shared content.
13
+
14
+ Scans for lines matching `<!-- include: NAME -->` (where NAME is [a-z0-9_-]+)
15
+ and replaces each entire line with the contents of `shared_dir/NAME.md`.
16
+
17
+ Args:
18
+ text: Input text that may contain include directives
19
+ shared_dir: Path to directory containing shared .md files
20
+
21
+ Returns:
22
+ Text with include directives expanded to their content
23
+
24
+ Raises:
25
+ ValueError: If an include directive references a file that doesn't exist
26
+
27
+ Notes:
28
+ - If shared_dir doesn't exist, returns text unchanged (backward compatibility)
29
+ - Include directives must be on their own line (may have surrounding whitespace)
30
+ - Included files cannot contain other include directives (non-recursive)
31
+ - Trailing newlines are stripped from included content to avoid double blanks
32
+ - Include directives inside code fences are still processed (v1 simplicity)
33
+ """
34
+ if not shared_dir.exists():
35
+ return text
36
+
37
+ # Pattern matches <!-- include: NAME --> on its own line with optional whitespace
38
+ # NAME must be [a-z0-9_-]+
39
+ pattern = r'^\s*<!--\s*include:\s*([a-z0-9_-]+)\s*-->\s*$'
40
+
41
+ def replace_include(match):
42
+ include_name = match.group(1)
43
+ include_file = shared_dir / f"{include_name}.md"
44
+
45
+ if not include_file.exists():
46
+ raise ValueError(f"Unknown include: {include_name} (file not found: {include_file})")
47
+
48
+ content = include_file.read_text()
49
+ # Strip trailing newline to avoid double blanks
50
+ return content.rstrip('\n')
51
+
52
+ return re.sub(pattern, replace_include, text, flags=re.MULTILINE)
53
+
54
+
55
+ def _load_frontmatter_template(template_name):
56
+ """Load a frontmatter template plugin by name.
57
+
58
+ Args:
59
+ template_name: Name of the template (e.g., 'claude', 'opencode', None)
60
+
61
+ Returns:
62
+ The template module, or None if template_name is None
63
+
64
+ Raises:
65
+ ValueError: if template_name is not a simple identifier (defensive: prevents
66
+ directory traversal or loading arbitrary modules) or if no such template
67
+ file exists under agent_notes/data/templates/frontmatter/.
68
+ """
69
+ if template_name is None:
70
+ return None
71
+ if not isinstance(template_name, str) or not template_name.isidentifier():
72
+ raise ValueError(
73
+ f"Invalid frontmatter template name: {template_name!r}. "
74
+ f"Must be a simple Python identifier (e.g. 'claude', 'opencode')."
75
+ )
76
+ try:
77
+ return importlib.import_module(f"agent_notes.data.templates.frontmatter.{template_name}")
78
+ except ModuleNotFoundError as e:
79
+ raise ValueError(
80
+ f"Frontmatter template '{template_name}' not found. "
81
+ f"Expected agent_notes/data/templates/frontmatter/{template_name}.py to exist. "
82
+ f"Original error: {e}"
83
+ ) from e
84
+
85
+
86
+ def generate_agent_files(agents_config: Dict[str, Any], tiers: Dict[str, Any],
87
+ state=None, scope='global', project_path=None) -> list[Path]:
88
+ """Generate agent files for all CLI backends.
89
+
90
+ Args:
91
+ agents_config: Dict of agent configurations from agents.yaml
92
+ tiers: Dict mapping tiers to model names per backend (legacy fallback)
93
+ state: Optional State object for role-based model resolution
94
+ scope: 'global' or 'local' (only used if state is provided)
95
+ project_path: Path for local scope (only used if state is provided and scope='local')
96
+
97
+ If state is None, behaves exactly as before (uses tiers dict).
98
+ If state is provided, tries state-driven resolution first, falls back to tiers on miss.
99
+ """
100
+ from ..registries.cli_registry import load_registry
101
+ from ..registries.model_registry import load_model_registry
102
+ from .. import state as state_module
103
+ from ..config import AGENTS_DIR, DIST_DIR
104
+
105
+ generated_files = []
106
+
107
+ from ..services.user_config import load_user_config, resolve_agent_role, resolve_role_model, get_patch, merge_configs
108
+
109
+ user_config = load_user_config()
110
+ # Merge project-level config if provided
111
+ if project_path is not None:
112
+ project_config_file = Path(project_path) / ".claude" / "agent-notes.yaml"
113
+ if project_config_file.exists():
114
+ from ..services.user_config import load_user_config as _load
115
+ project_config = _load(project_config_file)
116
+ user_config = merge_configs(user_config, project_config)
117
+
118
+ registry = load_registry()
119
+ model_registry = None # Lazy load only if needed
120
+ scope_state = None
121
+
122
+ # Get scope state if state is provided
123
+ if state is not None:
124
+ scope_state = state_module.get_scope(state, scope, project_path)
125
+
126
+ for agent_name, agent_config in agents_config.items():
127
+ # Read the source prompt
128
+ prompt_file = AGENTS_DIR / f'{agent_name}.md'
129
+ if not prompt_file.exists():
130
+ print(f"Warning: Missing source file {prompt_file}")
131
+ continue
132
+
133
+ prompt_content = prompt_file.read_text()
134
+
135
+ # Expand shared-content include directives (<!-- include: NAME -->)
136
+ # No-op if shared/ directory is absent.
137
+ prompt_content = expand_includes(prompt_content, AGENTS_DIR / "shared")
138
+
139
+ # Generate for each backend that supports agents
140
+ for backend in registry.all():
141
+ if not backend.supports("agents"):
142
+ continue
143
+
144
+ # Skip if agent is excluded for this backend
145
+ # Check both new backend-specific exclusion and legacy exclude_flag
146
+ excluded = False
147
+
148
+ # New backend-specific exclusion: check if backend.name key exists and has exclude: true
149
+ if backend.name in agent_config and isinstance(agent_config[backend.name], dict):
150
+ backend_cfg = agent_config[backend.name]
151
+ if backend_cfg.get("exclude"):
152
+ excluded = True
153
+
154
+ # Legacy exclusion: check if exclude_flag is set (for backward compat)
155
+ exclude_flag = backend.exclude_flag
156
+ if not excluded and exclude_flag and agent_config.get(exclude_flag):
157
+ excluded = True
158
+
159
+ if excluded:
160
+ continue
161
+
162
+ # Get frontmatter generator
163
+ frontmatter_type = backend.features.get("frontmatter")
164
+ if frontmatter_type is None:
165
+ continue # Skip backends without frontmatter (like copilot)
166
+
167
+ # Load template dynamically
168
+ template = _load_frontmatter_template(frontmatter_type)
169
+ if template is None:
170
+ continue
171
+
172
+ # Resolve model. Resolution chain:
173
+ # 1. State-driven: state.clis[backend].role_models[role] -> model_id
174
+ # 2. Role-class fallback: role.typical_class matched against
175
+ # any model's class, with a compatible provider for this backend
176
+ # 3. Legacy tier fallback: agent_config['tier'] -> tiers[tier][backend.name]
177
+ model_str = None
178
+ agent_role = resolve_agent_role(agent_name, agent_config.get('role'), user_config)
179
+
180
+ if (scope_state is not None and
181
+ agent_role is not None and
182
+ backend.name in scope_state.clis and
183
+ agent_role in scope_state.clis[backend.name].role_models):
184
+
185
+ # Step 1: state-driven resolution
186
+ model_id = scope_state.clis[backend.name].role_models[agent_role]
187
+
188
+ # Lazy load model registry
189
+ if model_registry is None:
190
+ model_registry = load_model_registry()
191
+
192
+ try:
193
+ model = model_registry.get(model_id)
194
+ resolved = model.resolve_for_providers(list(backend.accepted_providers))
195
+ if resolved is not None:
196
+ _provider, alias_str = resolved
197
+ model_str = alias_str
198
+ except KeyError:
199
+ pass # fall through to class/tier fallback
200
+
201
+ # User config override: explicit model for this role+backend
202
+ if model_str is None:
203
+ user_model = resolve_role_model(agent_role, backend.name, user_config)
204
+ if user_model:
205
+ model_str = user_model
206
+
207
+ # Step 2: role.typical_class fallback — the "works from shipped YAMLs
208
+ # with zero state" path. Loads the role and picks any model whose
209
+ # class matches role.typical_class and which has an alias for one
210
+ # of this backend's accepted providers.
211
+ if model_str is None and agent_role is not None:
212
+ if model_registry is None:
213
+ model_registry = load_model_registry()
214
+ try:
215
+ from ..registries.role_registry import load_role_registry
216
+ role_registry = load_role_registry()
217
+ role = role_registry.get(agent_role)
218
+ except (KeyError, FileNotFoundError, ValueError):
219
+ role = None
220
+
221
+ if role is not None:
222
+ # Prefer newer model IDs when multiple match the class.
223
+ # Registries are sorted ascending by id, so iterate reversed
224
+ # to pick e.g. claude-opus-4-7 over claude-opus-4-6.
225
+ for model in reversed(model_registry.all()):
226
+ if model.model_class != role.typical_class:
227
+ continue
228
+ resolved = model.resolve_for_providers(list(backend.accepted_providers))
229
+ if resolved is not None:
230
+ _provider, alias_str = resolved
231
+ model_str = alias_str
232
+ break
233
+
234
+ # Step 3: legacy tier fallback (for pre-v1.1 agents.yaml files that
235
+ # still declare `tier:` instead of `role:`).
236
+ if model_str is None:
237
+ if 'tier' not in agent_config:
238
+ raise ValueError(
239
+ f"Agent '{agent_name}' has role='{agent_role}' but no model could be "
240
+ f"resolved for backend '{backend.name}'. Tried: state.role_models, "
241
+ f"role.typical_class->model.class matching, and legacy 'tier' fallback. "
242
+ f"Check that data/roles/{agent_role}.yaml exists and that at least one "
243
+ f"model in data/models/*.yaml has class={role.typical_class if 'role' in locals() and role else '?'} "
244
+ f"with an alias for one of {list(backend.accepted_providers)}."
245
+ )
246
+ tier = agent_config['tier']
247
+ if backend.name not in tiers[tier]:
248
+ raise ValueError(f"tier '{tier}' missing model for CLI '{backend.name}' in agents.yaml")
249
+ model_str = tiers[tier][backend.name]
250
+
251
+ # Build context for template
252
+ ctx = {
253
+ 'agent_name': agent_name,
254
+ 'agent_config': agent_config,
255
+ 'model_str': model_str,
256
+ 'backend_name': backend.name,
257
+ 'backend': backend,
258
+ }
259
+
260
+ # Generate frontmatter using template
261
+ frontmatter = template.render(ctx)
262
+
263
+ # Apply post-processing transformation (e.g., strip memory section)
264
+ content = template.post_process(prompt_content, ctx)
265
+
266
+ # Apply user patch if present
267
+ patch = get_patch(agent_name, user_config)
268
+ if patch:
269
+ content = content.rstrip() + "\n\n" + patch.strip()
270
+
271
+ # Combine and write
272
+ full_content = f"{frontmatter}\n\n{content}"
273
+
274
+ # Write to backend's agents directory
275
+ from ..services import installer
276
+ agents_dir = installer.dist_source_for(backend, "agents")
277
+ if agents_dir is None:
278
+ agents_dir = DIST_DIR / backend.name / "agents"
279
+
280
+ agent_file = agents_dir / f'{agent_name}.md'
281
+ agent_file.parent.mkdir(parents=True, exist_ok=True)
282
+ agent_file.write_text(full_content)
283
+ generated_files.append(agent_file)
284
+
285
+ return generated_files
286
+
287
+
288
+ def render_globals() -> list[Path]:
289
+ """Copy global files to destinations."""
290
+ from ..config import (
291
+ GLOBAL_CLAUDE_MD, GLOBAL_OPENCODE_MD, GLOBAL_COPILOT_MD,
292
+ DIST_CLAUDE_DIR, DIST_OPENCODE_DIR, DIST_GITHUB_DIR
293
+ )
294
+
295
+ copied_files = []
296
+
297
+ # Copy global-claude.md to CLAUDE.md
298
+ claude_global_content = GLOBAL_CLAUDE_MD.read_text()
299
+ claude_global = DIST_CLAUDE_DIR / 'CLAUDE.md'
300
+ claude_global.parent.mkdir(parents=True, exist_ok=True)
301
+ claude_global.write_text(claude_global_content)
302
+ copied_files.append(claude_global)
303
+
304
+ # Copy global-opencode.md to AGENTS.md
305
+ opencode_global_content = GLOBAL_OPENCODE_MD.read_text()
306
+ agents_global = DIST_OPENCODE_DIR / 'AGENTS.md'
307
+ agents_global.parent.mkdir(parents=True, exist_ok=True)
308
+ agents_global.write_text(opencode_global_content)
309
+ copied_files.append(agents_global)
310
+
311
+ # Copy global-copilot.md to copilot-instructions.md
312
+ copilot_content = GLOBAL_COPILOT_MD.read_text()
313
+ copilot_global = DIST_GITHUB_DIR / 'copilot-instructions.md'
314
+ copilot_global.parent.mkdir(parents=True, exist_ok=True)
315
+ copilot_global.write_text(copilot_content)
316
+ copied_files.append(copilot_global)
317
+
318
+ return copied_files
319
+
320
+
321
+ def load_agents_config() -> tuple[Dict[str, Any], Dict[str, Any]]:
322
+ """Load agents configuration from agents.yaml."""
323
+ from ..config import AGENTS_YAML
324
+
325
+ if not AGENTS_YAML.exists():
326
+ raise FileNotFoundError(f"Configuration file not found: {AGENTS_YAML}")
327
+
328
+ config = yaml.safe_load(AGENTS_YAML.read_text())
329
+ return config.get('agents', {}), config.get('tiers', {})
@@ -0,0 +1,23 @@
1
+ """Generate the session context file injected by the SessionStart hook."""
2
+ from __future__ import annotations
3
+ from pathlib import Path
4
+
5
+
6
+ def _template_path() -> Path:
7
+ return Path(__file__).parent.parent / "data" / "hooks" / "session-context.md.tpl"
8
+
9
+
10
+ def render_context(agents: list[str], version: str) -> str:
11
+ tpl = _template_path().read_text()
12
+ if agents:
13
+ agents_list = "\n".join(f"- {name}" for name in sorted(agents))
14
+ else:
15
+ agents_list = "- (see ~/.claude/agents/ for installed agents)"
16
+ return (tpl
17
+ .replace("{{version}}", version)
18
+ .replace("{{agents_list}}", agents_list))
19
+
20
+
21
+ def write_context(dest: Path, agents: list[str], version: str) -> None:
22
+ dest.parent.mkdir(parents=True, exist_ok=True)
23
+ dest.write_text(render_context(agents, version))
@@ -0,0 +1,79 @@
1
+ """Read, merge, and write Claude Code settings.json without clobbering user config."""
2
+ import json
3
+ from pathlib import Path
4
+
5
+
6
+ def _deep_merge(base: dict, override: dict) -> dict:
7
+ result = dict(base)
8
+ for key, value in override.items():
9
+ if key in result and isinstance(result[key], dict) and isinstance(value, dict):
10
+ result[key] = _deep_merge(result[key], value)
11
+ else:
12
+ result[key] = value
13
+ return result
14
+
15
+
16
+ def install_hook(settings_path: Path, hook_event: str, command: str) -> None:
17
+ """Add a hook entry to settings.json. Idempotent — does not duplicate."""
18
+ data = {}
19
+ if settings_path.exists():
20
+ try:
21
+ data = json.loads(settings_path.read_text())
22
+ except json.JSONDecodeError:
23
+ pass
24
+
25
+ # Check if already present
26
+ existing = data.get("hooks", {}).get(hook_event, [])
27
+ for entry in existing:
28
+ for h in entry.get("hooks", []):
29
+ if h.get("command") == command:
30
+ return # already installed
31
+
32
+ hook_entry = {
33
+ "hooks": {
34
+ hook_event: [
35
+ {"matcher": "", "hooks": [{"type": "command", "command": command}]}
36
+ ]
37
+ }
38
+ }
39
+ merged = _deep_merge(data, hook_entry)
40
+ settings_path.parent.mkdir(parents=True, exist_ok=True)
41
+ settings_path.write_text(json.dumps(merged, indent=2) + "\n")
42
+
43
+
44
+ def remove_hook(settings_path: Path, hook_event: str, command: str) -> None:
45
+ """Remove a specific hook entry from settings.json."""
46
+ if not settings_path.exists():
47
+ return
48
+ try:
49
+ data = json.loads(settings_path.read_text())
50
+ except json.JSONDecodeError:
51
+ return
52
+
53
+ hooks = data.get("hooks", {}).get(hook_event, [])
54
+ cleaned = [
55
+ entry for entry in hooks
56
+ if not any(h.get("command") == command for h in entry.get("hooks", []))
57
+ ]
58
+ if cleaned:
59
+ data["hooks"][hook_event] = cleaned
60
+ else:
61
+ data.get("hooks", {}).pop(hook_event, None)
62
+ if not data.get("hooks"):
63
+ data.pop("hooks", None)
64
+ settings_path.write_text(json.dumps(data, indent=2) + "\n")
65
+
66
+
67
+ def has_hook(settings_path: Path, hook_event: str, command: str) -> bool:
68
+ """Return True if the hook is present in settings.json."""
69
+ if not settings_path.exists():
70
+ return False
71
+ try:
72
+ data = json.loads(settings_path.read_text())
73
+ except json.JSONDecodeError:
74
+ return False
75
+ for entry in data.get("hooks", {}).get(hook_event, []):
76
+ for h in entry.get("hooks", []):
77
+ if h.get("command") == command:
78
+ return True
79
+ return False