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.
- agent_notes/VERSION +1 -0
- agent_notes/__init__.py +1 -0
- agent_notes/__main__.py +4 -0
- agent_notes/cli.py +348 -0
- agent_notes/commands/__init__.py +27 -0
- agent_notes/commands/_install_helpers.py +262 -0
- agent_notes/commands/build.py +170 -0
- agent_notes/commands/doctor.py +112 -0
- agent_notes/commands/info.py +95 -0
- agent_notes/commands/install.py +99 -0
- agent_notes/commands/list.py +169 -0
- agent_notes/commands/memory.py +430 -0
- agent_notes/commands/regenerate.py +152 -0
- agent_notes/commands/set_role.py +143 -0
- agent_notes/commands/uninstall.py +26 -0
- agent_notes/commands/update.py +169 -0
- agent_notes/commands/validate.py +199 -0
- agent_notes/commands/wizard.py +720 -0
- agent_notes/config.py +154 -0
- agent_notes/data/agents/agents.yaml +352 -0
- agent_notes/data/agents/analyst.md +45 -0
- agent_notes/data/agents/api-reviewer.md +47 -0
- agent_notes/data/agents/architect.md +46 -0
- agent_notes/data/agents/coder.md +28 -0
- agent_notes/data/agents/database-specialist.md +45 -0
- agent_notes/data/agents/debugger.md +47 -0
- agent_notes/data/agents/devil.md +47 -0
- agent_notes/data/agents/devops.md +38 -0
- agent_notes/data/agents/explorer.md +23 -0
- agent_notes/data/agents/integrations.md +44 -0
- agent_notes/data/agents/lead.md +216 -0
- agent_notes/data/agents/performance-profiler.md +44 -0
- agent_notes/data/agents/refactorer.md +48 -0
- agent_notes/data/agents/reviewer.md +44 -0
- agent_notes/data/agents/security-auditor.md +44 -0
- agent_notes/data/agents/system-auditor.md +38 -0
- agent_notes/data/agents/tech-writer.md +32 -0
- agent_notes/data/agents/test-runner.md +36 -0
- agent_notes/data/agents/test-writer.md +39 -0
- agent_notes/data/cli/claude.yaml +25 -0
- agent_notes/data/cli/copilot.yaml +18 -0
- agent_notes/data/cli/opencode.yaml +22 -0
- agent_notes/data/commands/brainstorm.md +8 -0
- agent_notes/data/commands/debug.md +9 -0
- agent_notes/data/commands/review.md +10 -0
- agent_notes/data/global-claude.md +290 -0
- agent_notes/data/global-copilot.md +27 -0
- agent_notes/data/global-opencode.md +40 -0
- agent_notes/data/hooks/session-context.md.tpl +19 -0
- agent_notes/data/models/claude-haiku-4-5.yaml +15 -0
- agent_notes/data/models/claude-opus-4-1.yaml +16 -0
- agent_notes/data/models/claude-opus-4-5.yaml +16 -0
- agent_notes/data/models/claude-opus-4-6.yaml +16 -0
- agent_notes/data/models/claude-opus-4-7.yaml +15 -0
- agent_notes/data/models/claude-sonnet-4-5.yaml +16 -0
- agent_notes/data/models/claude-sonnet-4-6.yaml +15 -0
- agent_notes/data/models/claude-sonnet-4.yaml +16 -0
- agent_notes/data/pricing.yaml +33 -0
- agent_notes/data/roles/orchestrator.yaml +5 -0
- agent_notes/data/roles/reasoner.yaml +5 -0
- agent_notes/data/roles/scout.yaml +5 -0
- agent_notes/data/roles/worker.yaml +5 -0
- agent_notes/data/rules/code-quality.md +9 -0
- agent_notes/data/rules/safety.md +10 -0
- agent_notes/data/scripts/cost-report +211 -0
- agent_notes/data/skills/brainstorming/SKILL.md +57 -0
- agent_notes/data/skills/code-review/SKILL.md +64 -0
- agent_notes/data/skills/debugging-protocol/SKILL.md +51 -0
- agent_notes/data/skills/docker-compose/SKILL.md +318 -0
- agent_notes/data/skills/docker-compose-advanced/SKILL.md +575 -0
- agent_notes/data/skills/docker-dockerfile/SKILL.md +385 -0
- agent_notes/data/skills/docker-dockerfile-languages/SKILL.md +293 -0
- agent_notes/data/skills/git/SKILL.md +87 -0
- agent_notes/data/skills/rails-active-storage/SKILL.md +321 -0
- agent_notes/data/skills/rails-broadcasting/SKILL.md +374 -0
- agent_notes/data/skills/rails-concerns/SKILL.md +806 -0
- agent_notes/data/skills/rails-controllers/SKILL.md +510 -0
- agent_notes/data/skills/rails-controllers-advanced/SKILL.md +441 -0
- agent_notes/data/skills/rails-helpers/SKILL.md +677 -0
- agent_notes/data/skills/rails-initializers/SKILL.md +79 -0
- agent_notes/data/skills/rails-javascript/SKILL.md +567 -0
- agent_notes/data/skills/rails-jobs/SKILL.md +700 -0
- agent_notes/data/skills/rails-kamal/SKILL.md +483 -0
- agent_notes/data/skills/rails-lib/SKILL.md +101 -0
- agent_notes/data/skills/rails-mailers/SKILL.md +321 -0
- agent_notes/data/skills/rails-migrations/SKILL.md +268 -0
- agent_notes/data/skills/rails-models/SKILL.md +459 -0
- agent_notes/data/skills/rails-models-advanced/SKILL.md +398 -0
- agent_notes/data/skills/rails-routes/SKILL.md +804 -0
- agent_notes/data/skills/rails-style/SKILL.md +538 -0
- agent_notes/data/skills/rails-testing-controllers/SKILL.md +343 -0
- agent_notes/data/skills/rails-testing-models/SKILL.md +296 -0
- agent_notes/data/skills/rails-testing-system/SKILL.md +375 -0
- agent_notes/data/skills/rails-validations/SKILL.md +108 -0
- agent_notes/data/skills/rails-view-components/SKILL.md +511 -0
- agent_notes/data/skills/rails-view-components-advanced/SKILL.md +376 -0
- agent_notes/data/skills/rails-views/SKILL.md +413 -0
- agent_notes/data/skills/rails-views-advanced/SKILL.md +450 -0
- agent_notes/data/skills/refactoring-protocol/SKILL.md +64 -0
- agent_notes/data/skills/tdd/SKILL.md +57 -0
- agent_notes/data/templates/__init__.py +1 -0
- agent_notes/data/templates/__pycache__/__init__.cpython-314.pyc +0 -0
- agent_notes/data/templates/frontmatter/__init__.py +1 -0
- agent_notes/data/templates/frontmatter/__pycache__/__init__.cpython-314.pyc +0 -0
- agent_notes/data/templates/frontmatter/__pycache__/claude.cpython-314.pyc +0 -0
- agent_notes/data/templates/frontmatter/__pycache__/cursor.cpython-314.pyc +0 -0
- agent_notes/data/templates/frontmatter/__pycache__/opencode.cpython-314.pyc +0 -0
- agent_notes/data/templates/frontmatter/claude.py +44 -0
- agent_notes/data/templates/frontmatter/opencode.py +104 -0
- agent_notes/doctor_checks.py +189 -0
- agent_notes/domain/__init__.py +17 -0
- agent_notes/domain/agent.py +34 -0
- agent_notes/domain/cli_backend.py +40 -0
- agent_notes/domain/diagnostics.py +29 -0
- agent_notes/domain/diff.py +44 -0
- agent_notes/domain/model.py +27 -0
- agent_notes/domain/role.py +13 -0
- agent_notes/domain/rule.py +13 -0
- agent_notes/domain/skill.py +15 -0
- agent_notes/domain/state.py +46 -0
- agent_notes/install_state.py +11 -0
- agent_notes/registries/__init__.py +16 -0
- agent_notes/registries/_base.py +46 -0
- agent_notes/registries/agent_registry.py +107 -0
- agent_notes/registries/cli_registry.py +89 -0
- agent_notes/registries/model_registry.py +85 -0
- agent_notes/registries/role_registry.py +64 -0
- agent_notes/registries/rule_registry.py +80 -0
- agent_notes/registries/skill_registry.py +141 -0
- agent_notes/services/__init__.py +8 -0
- agent_notes/services/diagnostics/__init__.py +47 -0
- agent_notes/services/diagnostics/_checks.py +272 -0
- agent_notes/services/diagnostics/_display.py +346 -0
- agent_notes/services/diagnostics/_fix.py +169 -0
- agent_notes/services/diff.py +349 -0
- agent_notes/services/fs.py +195 -0
- agent_notes/services/install_state_builder.py +210 -0
- agent_notes/services/installer.py +293 -0
- agent_notes/services/memory_backend.py +155 -0
- agent_notes/services/rendering.py +329 -0
- agent_notes/services/session_context.py +23 -0
- agent_notes/services/settings_writer.py +79 -0
- agent_notes/services/state_store.py +249 -0
- agent_notes/services/ui.py +419 -0
- agent_notes/services/user_config.py +62 -0
- agent_notes/services/validation.py +67 -0
- agent_notes/state.py +21 -0
- agent_notes-2.0.4.dist-info/METADATA +14 -0
- agent_notes-2.0.4.dist-info/RECORD +162 -0
- agent_notes-2.0.4.dist-info/WHEEL +5 -0
- agent_notes-2.0.4.dist-info/entry_points.txt +2 -0
- agent_notes-2.0.4.dist-info/licenses/LICENSE +21 -0
- agent_notes-2.0.4.dist-info/top_level.txt +2 -0
- tests/conftest.py +20 -0
- tests/functional/__init__.py +0 -0
- tests/functional/test_build_commands.py +88 -0
- tests/functional/test_registries.py +128 -0
- tests/integration/__init__.py +0 -0
- tests/integration/test_build_output.py +129 -0
- tests/plugins/__init__.py +0 -0
- tests/plugins/test_agents.py +93 -0
- 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
|