agentfluent 0.1.0__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.
- agentfluent/__init__.py +8 -0
- agentfluent/agents/__init__.py +0 -0
- agentfluent/agents/extractor.py +78 -0
- agentfluent/agents/models.py +71 -0
- agentfluent/analytics/__init__.py +0 -0
- agentfluent/analytics/agent_metrics.py +127 -0
- agentfluent/analytics/pipeline.py +278 -0
- agentfluent/analytics/pricing.py +96 -0
- agentfluent/analytics/tokens.py +137 -0
- agentfluent/analytics/tools.py +93 -0
- agentfluent/cli/__init__.py +0 -0
- agentfluent/cli/commands/__init__.py +0 -0
- agentfluent/cli/commands/analyze.py +162 -0
- agentfluent/cli/commands/config_check.py +110 -0
- agentfluent/cli/commands/list_cmd.py +165 -0
- agentfluent/cli/exit_codes.py +18 -0
- agentfluent/cli/formatters/__init__.py +0 -0
- agentfluent/cli/formatters/helpers.py +59 -0
- agentfluent/cli/formatters/json_output.py +60 -0
- agentfluent/cli/formatters/table.py +358 -0
- agentfluent/cli/main.py +63 -0
- agentfluent/config/__init__.py +31 -0
- agentfluent/config/models.py +118 -0
- agentfluent/config/scanner.py +146 -0
- agentfluent/config/scoring.py +299 -0
- agentfluent/core/__init__.py +0 -0
- agentfluent/core/discovery.py +158 -0
- agentfluent/core/parser.py +251 -0
- agentfluent/core/session.py +133 -0
- agentfluent/diagnostics/__init__.py +51 -0
- agentfluent/diagnostics/correlator.py +248 -0
- agentfluent/diagnostics/models.py +75 -0
- agentfluent/diagnostics/signals.py +162 -0
- agentfluent/py.typed +0 -0
- agentfluent-0.1.0.dist-info/METADATA +52 -0
- agentfluent-0.1.0.dist-info/RECORD +39 -0
- agentfluent-0.1.0.dist-info/WHEEL +4 -0
- agentfluent-0.1.0.dist-info/entry_points.txt +3 -0
- agentfluent-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Agent definition scanner and parser.
|
|
2
|
+
|
|
3
|
+
Discovers and parses agent `.md` files from user and project scopes.
|
|
4
|
+
Agent definitions use YAML frontmatter followed by a markdown prompt body.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import yaml
|
|
14
|
+
|
|
15
|
+
from agentfluent.config.models import AgentConfig, Scope
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
DEFAULT_USER_AGENTS_DIR = Path.home() / ".claude" / "agents"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _parse_frontmatter(content: str) -> tuple[dict[str, Any], str]:
|
|
23
|
+
"""Split a markdown file into YAML frontmatter and body.
|
|
24
|
+
|
|
25
|
+
Expects the format:
|
|
26
|
+
---
|
|
27
|
+
yaml content
|
|
28
|
+
---
|
|
29
|
+
markdown body
|
|
30
|
+
|
|
31
|
+
Returns (frontmatter_dict, body_string). If no valid frontmatter
|
|
32
|
+
is found, returns ({}, full_content).
|
|
33
|
+
"""
|
|
34
|
+
content = content.strip()
|
|
35
|
+
if not content.startswith("---"):
|
|
36
|
+
return {}, content
|
|
37
|
+
|
|
38
|
+
# Find the closing ---
|
|
39
|
+
end_idx = content.find("---", 3)
|
|
40
|
+
if end_idx == -1:
|
|
41
|
+
return {}, content
|
|
42
|
+
|
|
43
|
+
yaml_str = content[3:end_idx].strip()
|
|
44
|
+
body = content[end_idx + 3:].strip()
|
|
45
|
+
|
|
46
|
+
try:
|
|
47
|
+
frontmatter = yaml.safe_load(yaml_str)
|
|
48
|
+
except yaml.YAMLError:
|
|
49
|
+
return {}, content
|
|
50
|
+
|
|
51
|
+
if not isinstance(frontmatter, dict):
|
|
52
|
+
return {}, content
|
|
53
|
+
|
|
54
|
+
return frontmatter, body
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _to_string_list(value: Any) -> list[str]:
|
|
58
|
+
"""Coerce a frontmatter value to a list of strings."""
|
|
59
|
+
if value is None:
|
|
60
|
+
return []
|
|
61
|
+
if isinstance(value, list):
|
|
62
|
+
return [str(item) for item in value]
|
|
63
|
+
if isinstance(value, str):
|
|
64
|
+
return [value]
|
|
65
|
+
return []
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def parse_agent_file(path: Path, scope: Scope) -> AgentConfig | None:
|
|
69
|
+
"""Parse a single agent definition `.md` file.
|
|
70
|
+
|
|
71
|
+
Returns None if the file cannot be read. Files without valid
|
|
72
|
+
frontmatter are still returned with a warning.
|
|
73
|
+
"""
|
|
74
|
+
try:
|
|
75
|
+
content = path.read_text(encoding="utf-8")
|
|
76
|
+
except OSError:
|
|
77
|
+
logger.warning("Cannot read agent file: %s", path)
|
|
78
|
+
return None
|
|
79
|
+
|
|
80
|
+
frontmatter, body = _parse_frontmatter(content)
|
|
81
|
+
|
|
82
|
+
if not frontmatter:
|
|
83
|
+
logger.warning("No valid YAML frontmatter in: %s", path.name)
|
|
84
|
+
|
|
85
|
+
name = frontmatter.get("name") or path.stem
|
|
86
|
+
|
|
87
|
+
return AgentConfig(
|
|
88
|
+
name=name,
|
|
89
|
+
file_path=path.resolve(),
|
|
90
|
+
scope=scope,
|
|
91
|
+
description=str(frontmatter.get("description", "")),
|
|
92
|
+
model=frontmatter.get("model"),
|
|
93
|
+
prompt_body=body,
|
|
94
|
+
tools=_to_string_list(frontmatter.get("tools")),
|
|
95
|
+
disallowed_tools=_to_string_list(frontmatter.get("disallowedTools")),
|
|
96
|
+
mcp_servers=_to_string_list(frontmatter.get("mcpServers")),
|
|
97
|
+
hooks=frontmatter.get("hooks") or {},
|
|
98
|
+
skills=_to_string_list(frontmatter.get("skills")),
|
|
99
|
+
memory=frontmatter.get("memory"),
|
|
100
|
+
isolation=frontmatter.get("isolation"),
|
|
101
|
+
color=frontmatter.get("color"),
|
|
102
|
+
raw_frontmatter=frontmatter,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _scan_directory(agents_dir: Path, scope: Scope) -> list[AgentConfig]:
|
|
107
|
+
"""Scan a directory for agent `.md` files."""
|
|
108
|
+
if not agents_dir.is_dir():
|
|
109
|
+
return []
|
|
110
|
+
|
|
111
|
+
agents: list[AgentConfig] = []
|
|
112
|
+
for entry in sorted(agents_dir.iterdir()):
|
|
113
|
+
if entry.is_file() and entry.suffix == ".md":
|
|
114
|
+
agent = parse_agent_file(entry, scope)
|
|
115
|
+
if agent is not None:
|
|
116
|
+
agents.append(agent)
|
|
117
|
+
return agents
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def scan_agents(
|
|
121
|
+
scope: str = "all",
|
|
122
|
+
*,
|
|
123
|
+
user_path: Path | None = None,
|
|
124
|
+
project_path: Path | None = None,
|
|
125
|
+
) -> list[AgentConfig]:
|
|
126
|
+
"""Discover and parse agent definition files.
|
|
127
|
+
|
|
128
|
+
Args:
|
|
129
|
+
scope: Which locations to scan -- "user", "project", or "all".
|
|
130
|
+
user_path: Override for user agents directory. Defaults to ~/.claude/agents/.
|
|
131
|
+
project_path: Override for project agents directory. Defaults to .claude/agents/.
|
|
132
|
+
|
|
133
|
+
Returns:
|
|
134
|
+
List of parsed AgentConfig objects, sorted by scope then name.
|
|
135
|
+
"""
|
|
136
|
+
agents: list[AgentConfig] = []
|
|
137
|
+
|
|
138
|
+
if scope in ("user", "all"):
|
|
139
|
+
user_dir = user_path or DEFAULT_USER_AGENTS_DIR
|
|
140
|
+
agents.extend(_scan_directory(user_dir, Scope.USER))
|
|
141
|
+
|
|
142
|
+
if scope in ("project", "all"):
|
|
143
|
+
project_dir = project_path or (Path.cwd() / ".claude" / "agents")
|
|
144
|
+
agents.extend(_scan_directory(project_dir, Scope.PROJECT))
|
|
145
|
+
|
|
146
|
+
return agents
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
"""Agent configuration scoring rubric.
|
|
2
|
+
|
|
3
|
+
Scores agent definitions against best practices across four dimensions.
|
|
4
|
+
Rule-based scoring (no LLM). Weights and thresholds are configurable.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
|
|
11
|
+
from agentfluent.config.models import (
|
|
12
|
+
AgentConfig,
|
|
13
|
+
ConfigRecommendation,
|
|
14
|
+
ConfigScore,
|
|
15
|
+
Severity,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
# Read-only tools suggest simpler tasks where cheaper models suffice
|
|
19
|
+
READ_ONLY_TOOLS: frozenset[str] = frozenset({
|
|
20
|
+
"Read", "Glob", "Grep", "WebFetch", "WebSearch",
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
# Action verbs that indicate a well-described agent purpose
|
|
24
|
+
ACTION_VERBS: frozenset[str] = frozenset({
|
|
25
|
+
"analyze", "build", "check", "create", "debug", "deploy", "design",
|
|
26
|
+
"evaluate", "extract", "fix", "generate", "implement", "investigate",
|
|
27
|
+
"manage", "monitor", "optimize", "plan", "refactor", "review",
|
|
28
|
+
"scan", "search", "test", "translate", "validate", "verify", "write",
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
# Expensive models that may be overkill for simple tasks
|
|
32
|
+
EXPENSIVE_MODELS: frozenset[str] = frozenset({
|
|
33
|
+
"claude-opus-4-6", "claude-opus-4-5-20251101",
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
# Patterns suggesting structured prompt sections
|
|
37
|
+
SECTION_PATTERN = re.compile(r"^#{1,3}\s+", re.MULTILINE)
|
|
38
|
+
|
|
39
|
+
# Keywords suggesting error handling guidance
|
|
40
|
+
ERROR_KEYWORDS: frozenset[str] = frozenset({
|
|
41
|
+
"error", "fail", "exception", "fallback", "retry", "graceful",
|
|
42
|
+
"handle", "recover", "warning",
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
# Keywords suggesting success criteria
|
|
46
|
+
SUCCESS_KEYWORDS: frozenset[str] = frozenset({
|
|
47
|
+
"success", "complete", "done", "finish", "output", "return",
|
|
48
|
+
"produce", "deliver", "result", "criteria",
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _score_description(config: AgentConfig) -> tuple[int, list[ConfigRecommendation]]:
|
|
54
|
+
"""Score the agent description quality (0-25)."""
|
|
55
|
+
score = 0
|
|
56
|
+
recs: list[ConfigRecommendation] = []
|
|
57
|
+
desc = config.description.strip()
|
|
58
|
+
|
|
59
|
+
# Present (5 pts)
|
|
60
|
+
if desc:
|
|
61
|
+
score += 5
|
|
62
|
+
else:
|
|
63
|
+
recs.append(ConfigRecommendation(
|
|
64
|
+
dimension="description",
|
|
65
|
+
severity=Severity.CRITICAL,
|
|
66
|
+
message="Agent has no description.",
|
|
67
|
+
suggested_action="Add a description that explains when to invoke this agent.",
|
|
68
|
+
))
|
|
69
|
+
return score, recs
|
|
70
|
+
|
|
71
|
+
# Length >= 20 chars (5 pts)
|
|
72
|
+
if len(desc) >= 20:
|
|
73
|
+
score += 5
|
|
74
|
+
else:
|
|
75
|
+
recs.append(ConfigRecommendation(
|
|
76
|
+
dimension="description",
|
|
77
|
+
severity=Severity.WARNING,
|
|
78
|
+
message=f"Description is only {len(desc)} characters.",
|
|
79
|
+
current_value=desc[:50],
|
|
80
|
+
suggested_action="Expand the description to at least 20 characters.",
|
|
81
|
+
))
|
|
82
|
+
|
|
83
|
+
# Contains action verbs (5 pts)
|
|
84
|
+
desc_lower = desc.lower()
|
|
85
|
+
if any(v in desc_lower for v in ACTION_VERBS):
|
|
86
|
+
score += 5
|
|
87
|
+
else:
|
|
88
|
+
recs.append(ConfigRecommendation(
|
|
89
|
+
dimension="description",
|
|
90
|
+
severity=Severity.INFO,
|
|
91
|
+
message="Description lacks action verbs.",
|
|
92
|
+
current_value=desc[:80],
|
|
93
|
+
suggested_action="Include verbs like 'review', 'analyze', 'create' to clarify purpose.",
|
|
94
|
+
))
|
|
95
|
+
|
|
96
|
+
# Specific to task -- more than 50 chars suggests specificity (5 pts)
|
|
97
|
+
if len(desc) >= 50:
|
|
98
|
+
score += 5
|
|
99
|
+
else:
|
|
100
|
+
recs.append(ConfigRecommendation(
|
|
101
|
+
dimension="description",
|
|
102
|
+
severity=Severity.INFO,
|
|
103
|
+
message="Description could be more specific.",
|
|
104
|
+
current_value=desc[:80],
|
|
105
|
+
suggested_action="Add details about what tasks this agent handles and when to use it.",
|
|
106
|
+
))
|
|
107
|
+
|
|
108
|
+
# Distinguishes from other agents -- mentions "Do NOT" or exclusions (5 pts)
|
|
109
|
+
if "not" in desc_lower or "don't" in desc_lower or "skip" in desc_lower:
|
|
110
|
+
score += 5
|
|
111
|
+
|
|
112
|
+
return score, recs
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _score_tool_restrictions(config: AgentConfig) -> tuple[int, list[ConfigRecommendation]]:
|
|
116
|
+
"""Score tool access restrictions (0-25).
|
|
117
|
+
|
|
118
|
+
Awards points for having explicit tool restrictions (both allowlist
|
|
119
|
+
and denylist), which is a best practice for agent safety.
|
|
120
|
+
"""
|
|
121
|
+
score = 0
|
|
122
|
+
recs: list[ConfigRecommendation] = []
|
|
123
|
+
|
|
124
|
+
has_allowlist = len(config.tools) > 0
|
|
125
|
+
has_denylist = len(config.disallowed_tools) > 0
|
|
126
|
+
|
|
127
|
+
# Has an allowlist (10 pts)
|
|
128
|
+
if has_allowlist:
|
|
129
|
+
score += 10
|
|
130
|
+
else:
|
|
131
|
+
recs.append(ConfigRecommendation(
|
|
132
|
+
dimension="tool_restrictions",
|
|
133
|
+
severity=Severity.WARNING,
|
|
134
|
+
message="No tools allowlist defined.",
|
|
135
|
+
suggested_action="Add a 'tools' list to restrict which tools the agent can use.",
|
|
136
|
+
))
|
|
137
|
+
|
|
138
|
+
# Has a denylist (5 pts)
|
|
139
|
+
if has_denylist:
|
|
140
|
+
score += 5
|
|
141
|
+
|
|
142
|
+
# Has either restriction (5 pts bonus for having any restriction at all)
|
|
143
|
+
if has_allowlist or has_denylist:
|
|
144
|
+
score += 5
|
|
145
|
+
else:
|
|
146
|
+
recs.append(ConfigRecommendation(
|
|
147
|
+
dimension="tool_restrictions",
|
|
148
|
+
severity=Severity.CRITICAL,
|
|
149
|
+
message="Agent has no tool restrictions at all.",
|
|
150
|
+
suggested_action=(
|
|
151
|
+
"Add 'tools' (allowlist) and/or 'disallowedTools' (denylist) "
|
|
152
|
+
"to control agent capabilities."
|
|
153
|
+
),
|
|
154
|
+
))
|
|
155
|
+
|
|
156
|
+
# Has MCP server awareness (5 pts) -- shows intentional integration config
|
|
157
|
+
if config.mcp_servers:
|
|
158
|
+
score += 5
|
|
159
|
+
|
|
160
|
+
return score, recs
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _score_model_selection(config: AgentConfig) -> tuple[int, list[ConfigRecommendation]]:
|
|
164
|
+
"""Score model selection (0-25).
|
|
165
|
+
|
|
166
|
+
Awards points for specifying a model and flags clearly mismatched
|
|
167
|
+
model-task combinations.
|
|
168
|
+
"""
|
|
169
|
+
score = 0
|
|
170
|
+
recs: list[ConfigRecommendation] = []
|
|
171
|
+
|
|
172
|
+
# Model is specified (10 pts)
|
|
173
|
+
if config.model:
|
|
174
|
+
score += 10
|
|
175
|
+
else:
|
|
176
|
+
recs.append(ConfigRecommendation(
|
|
177
|
+
dimension="model_selection",
|
|
178
|
+
severity=Severity.WARNING,
|
|
179
|
+
message="No model specified -- defaults will be used.",
|
|
180
|
+
suggested_action="Specify a model to control cost and capability.",
|
|
181
|
+
))
|
|
182
|
+
return score, recs
|
|
183
|
+
|
|
184
|
+
# Model-task complexity heuristic (15 pts)
|
|
185
|
+
# Only flag clearly wrong: expensive model + ALL tools are read-only
|
|
186
|
+
tools_set = set(config.tools)
|
|
187
|
+
all_read_only = tools_set and tools_set.issubset(READ_ONLY_TOOLS)
|
|
188
|
+
|
|
189
|
+
if config.model in EXPENSIVE_MODELS and all_read_only:
|
|
190
|
+
score += 5
|
|
191
|
+
recs.append(ConfigRecommendation(
|
|
192
|
+
dimension="model_selection",
|
|
193
|
+
severity=Severity.INFO,
|
|
194
|
+
message=f"Using {config.model} with only read-only tools.",
|
|
195
|
+
current_value=config.model,
|
|
196
|
+
suggested_action=(
|
|
197
|
+
"Consider a cheaper model (Sonnet/Haiku) if the task is "
|
|
198
|
+
"primarily reading and searching."
|
|
199
|
+
),
|
|
200
|
+
))
|
|
201
|
+
else:
|
|
202
|
+
score += 15
|
|
203
|
+
|
|
204
|
+
return score, recs
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _score_prompt_body(config: AgentConfig) -> tuple[int, list[ConfigRecommendation]]:
|
|
208
|
+
"""Score prompt body quality (0-25)."""
|
|
209
|
+
score = 0
|
|
210
|
+
recs: list[ConfigRecommendation] = []
|
|
211
|
+
body = config.prompt_body.strip()
|
|
212
|
+
|
|
213
|
+
# Present and non-empty (5 pts)
|
|
214
|
+
if body:
|
|
215
|
+
score += 5
|
|
216
|
+
else:
|
|
217
|
+
recs.append(ConfigRecommendation(
|
|
218
|
+
dimension="prompt_body",
|
|
219
|
+
severity=Severity.CRITICAL,
|
|
220
|
+
message="Agent has no prompt body.",
|
|
221
|
+
suggested_action="Add a prompt body with instructions for the agent.",
|
|
222
|
+
))
|
|
223
|
+
return score, recs
|
|
224
|
+
|
|
225
|
+
# Length >= 100 chars (5 pts)
|
|
226
|
+
if len(body) >= 100:
|
|
227
|
+
score += 5
|
|
228
|
+
else:
|
|
229
|
+
recs.append(ConfigRecommendation(
|
|
230
|
+
dimension="prompt_body",
|
|
231
|
+
severity=Severity.WARNING,
|
|
232
|
+
message=f"Prompt body is only {len(body)} characters.",
|
|
233
|
+
suggested_action="Expand the prompt to at least 100 characters with instructions.",
|
|
234
|
+
))
|
|
235
|
+
|
|
236
|
+
# Has structured sections (5 pts)
|
|
237
|
+
if SECTION_PATTERN.search(body):
|
|
238
|
+
score += 5
|
|
239
|
+
else:
|
|
240
|
+
recs.append(ConfigRecommendation(
|
|
241
|
+
dimension="prompt_body",
|
|
242
|
+
severity=Severity.INFO,
|
|
243
|
+
message="Prompt body has no markdown sections.",
|
|
244
|
+
suggested_action="Add ## sections to organize the prompt (e.g., responsibilities).",
|
|
245
|
+
))
|
|
246
|
+
|
|
247
|
+
# Mentions error handling (5 pts)
|
|
248
|
+
body_lower = body.lower()
|
|
249
|
+
if any(kw in body_lower for kw in ERROR_KEYWORDS):
|
|
250
|
+
score += 5
|
|
251
|
+
else:
|
|
252
|
+
recs.append(ConfigRecommendation(
|
|
253
|
+
dimension="prompt_body",
|
|
254
|
+
severity=Severity.INFO,
|
|
255
|
+
message="Prompt body doesn't mention error handling.",
|
|
256
|
+
suggested_action="Add guidance for how the agent should handle errors or failures.",
|
|
257
|
+
))
|
|
258
|
+
|
|
259
|
+
# Defines success criteria (5 pts)
|
|
260
|
+
if any(kw in body_lower for kw in SUCCESS_KEYWORDS):
|
|
261
|
+
score += 5
|
|
262
|
+
else:
|
|
263
|
+
recs.append(ConfigRecommendation(
|
|
264
|
+
dimension="prompt_body",
|
|
265
|
+
severity=Severity.INFO,
|
|
266
|
+
message="Prompt body doesn't define success criteria.",
|
|
267
|
+
suggested_action="Add criteria for what constitutes a successful outcome.",
|
|
268
|
+
))
|
|
269
|
+
|
|
270
|
+
return score, recs
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def score_agent(config: AgentConfig) -> ConfigScore:
|
|
275
|
+
"""Score an agent configuration against the best-practices rubric.
|
|
276
|
+
|
|
277
|
+
Four dimensions, each 0-25 points, for a total of 0-100.
|
|
278
|
+
"""
|
|
279
|
+
all_recs: list[ConfigRecommendation] = []
|
|
280
|
+
dimension_scores: dict[str, int] = {}
|
|
281
|
+
|
|
282
|
+
for dimension_name, scorer in [
|
|
283
|
+
("description", _score_description),
|
|
284
|
+
("tool_restrictions", _score_tool_restrictions),
|
|
285
|
+
("model_selection", _score_model_selection),
|
|
286
|
+
("prompt_body", _score_prompt_body),
|
|
287
|
+
]:
|
|
288
|
+
dim_score, dim_recs = scorer(config)
|
|
289
|
+
dimension_scores[dimension_name] = dim_score
|
|
290
|
+
all_recs.extend(dim_recs)
|
|
291
|
+
|
|
292
|
+
overall = sum(dimension_scores.values())
|
|
293
|
+
|
|
294
|
+
return ConfigScore(
|
|
295
|
+
agent_name=config.name,
|
|
296
|
+
overall_score=overall,
|
|
297
|
+
dimension_scores=dimension_scores,
|
|
298
|
+
recommendations=all_recs,
|
|
299
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Project and session discovery from ~/.claude/projects/."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from datetime import UTC, datetime
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
DEFAULT_PROJECTS_DIR = Path.home() / ".claude" / "projects"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class SessionInfo:
|
|
14
|
+
"""Metadata for a single JSONL session file."""
|
|
15
|
+
|
|
16
|
+
filename: str
|
|
17
|
+
path: Path
|
|
18
|
+
size_bytes: int
|
|
19
|
+
modified: datetime
|
|
20
|
+
subagent_count: int = 0
|
|
21
|
+
"""Number of subagent trace files in <session-uuid>/subagents/."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class ProjectInfo:
|
|
26
|
+
"""Metadata for a discovered project directory."""
|
|
27
|
+
|
|
28
|
+
slug: str
|
|
29
|
+
"""Directory name as-is (e.g., '-home-fdpearce-Documents-Projects-git-codefluent')."""
|
|
30
|
+
|
|
31
|
+
display_name: str
|
|
32
|
+
"""Human-friendly name derived from slug (e.g., 'codefluent')."""
|
|
33
|
+
|
|
34
|
+
path: Path
|
|
35
|
+
session_count: int = 0
|
|
36
|
+
total_size_bytes: int = 0
|
|
37
|
+
earliest_session: datetime | None = None
|
|
38
|
+
latest_session: datetime | None = None
|
|
39
|
+
sessions: list[SessionInfo] = field(default_factory=list)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def slug_to_display_name(slug: str) -> str:
|
|
43
|
+
"""Convert a dash-encoded project directory name to a human-friendly name.
|
|
44
|
+
|
|
45
|
+
The directory format is: -home-user-path-to-project
|
|
46
|
+
We take the last path segment as the display name.
|
|
47
|
+
"""
|
|
48
|
+
# Remove leading dash and split
|
|
49
|
+
parts = slug.lstrip("-").split("-")
|
|
50
|
+
# The last segment is typically the project name
|
|
51
|
+
# For paths like -home-fdpearce-Documents-Projects-git-codefluent -> codefluent
|
|
52
|
+
return parts[-1] if parts else slug
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _count_subagent_files(session_path: Path) -> int:
|
|
56
|
+
"""Count subagent JSONL files for a session.
|
|
57
|
+
|
|
58
|
+
Subagent traces live at: <session-uuid>/subagents/agent-<agentId>.jsonl
|
|
59
|
+
where <session-uuid> is a directory named the same as the session file (minus .jsonl).
|
|
60
|
+
"""
|
|
61
|
+
session_dir = session_path.parent / session_path.stem
|
|
62
|
+
subagents_dir = session_dir / "subagents"
|
|
63
|
+
if not subagents_dir.is_dir():
|
|
64
|
+
return 0
|
|
65
|
+
return sum(1 for f in subagents_dir.iterdir() if f.suffix == ".jsonl")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def discover_sessions(project_path: Path) -> list[SessionInfo]:
|
|
69
|
+
"""Discover all JSONL session files within a project directory.
|
|
70
|
+
|
|
71
|
+
Returns session metadata sorted by modification time (newest first).
|
|
72
|
+
Only top-level .jsonl files are returned; subagent files are counted but not listed.
|
|
73
|
+
"""
|
|
74
|
+
sessions: list[SessionInfo] = []
|
|
75
|
+
|
|
76
|
+
if not project_path.is_dir():
|
|
77
|
+
return sessions
|
|
78
|
+
|
|
79
|
+
for entry in project_path.iterdir():
|
|
80
|
+
if entry.is_file() and entry.suffix == ".jsonl":
|
|
81
|
+
stat = entry.stat()
|
|
82
|
+
sessions.append(
|
|
83
|
+
SessionInfo(
|
|
84
|
+
filename=entry.name,
|
|
85
|
+
path=entry,
|
|
86
|
+
size_bytes=stat.st_size,
|
|
87
|
+
modified=datetime.fromtimestamp(stat.st_mtime, tz=UTC),
|
|
88
|
+
subagent_count=_count_subagent_files(entry),
|
|
89
|
+
)
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
sessions.sort(key=lambda s: s.modified, reverse=True)
|
|
93
|
+
return sessions
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def discover_projects(base_path: Path | None = None) -> list[ProjectInfo]:
|
|
97
|
+
"""Discover all projects in the Claude projects directory.
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
base_path: Override for the projects directory. Defaults to ~/.claude/projects/.
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
List of ProjectInfo sorted by latest session (newest first).
|
|
104
|
+
|
|
105
|
+
Raises:
|
|
106
|
+
FileNotFoundError: If the base path does not exist.
|
|
107
|
+
"""
|
|
108
|
+
projects_dir = base_path or DEFAULT_PROJECTS_DIR
|
|
109
|
+
|
|
110
|
+
if not projects_dir.exists():
|
|
111
|
+
msg = f"Projects directory not found: {projects_dir}"
|
|
112
|
+
raise FileNotFoundError(msg)
|
|
113
|
+
|
|
114
|
+
projects: list[ProjectInfo] = []
|
|
115
|
+
|
|
116
|
+
for entry in sorted(projects_dir.iterdir()):
|
|
117
|
+
if not entry.is_dir():
|
|
118
|
+
continue
|
|
119
|
+
# Skip hidden directories and non-project entries
|
|
120
|
+
if entry.name.startswith("."):
|
|
121
|
+
continue
|
|
122
|
+
|
|
123
|
+
sessions = discover_sessions(entry)
|
|
124
|
+
|
|
125
|
+
total_size = sum(s.size_bytes for s in sessions)
|
|
126
|
+
earliest = min((s.modified for s in sessions), default=None)
|
|
127
|
+
latest = max((s.modified for s in sessions), default=None)
|
|
128
|
+
|
|
129
|
+
projects.append(
|
|
130
|
+
ProjectInfo(
|
|
131
|
+
slug=entry.name,
|
|
132
|
+
display_name=slug_to_display_name(entry.name),
|
|
133
|
+
path=entry,
|
|
134
|
+
session_count=len(sessions),
|
|
135
|
+
total_size_bytes=total_size,
|
|
136
|
+
earliest_session=earliest,
|
|
137
|
+
latest_session=latest,
|
|
138
|
+
sessions=sessions,
|
|
139
|
+
)
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
# Sort by latest session, projects with no sessions last
|
|
143
|
+
projects.sort(
|
|
144
|
+
key=lambda p: p.latest_session or datetime.min.replace(tzinfo=UTC),
|
|
145
|
+
reverse=True,
|
|
146
|
+
)
|
|
147
|
+
return projects
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def find_project(slug_or_name: str, base_path: Path | None = None) -> ProjectInfo | None:
|
|
151
|
+
"""Find a project by slug or display name.
|
|
152
|
+
|
|
153
|
+
Matches against both the full slug and the derived display name (case-insensitive).
|
|
154
|
+
"""
|
|
155
|
+
for project in discover_projects(base_path):
|
|
156
|
+
if project.slug == slug_or_name or project.display_name.lower() == slug_or_name.lower():
|
|
157
|
+
return project
|
|
158
|
+
return None
|