spec-agent 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.
- spec_agent/__init__.py +1 -0
- spec_agent/agent.py +174 -0
- spec_agent/cli.py +157 -0
- spec_agent/config.py +59 -0
- spec_agent/tools/__init__.py +1 -0
- spec_agent/tools/wiki_index.py +31 -0
- spec_agent/tools/wiki_read.py +37 -0
- spec_agent/tools/wiki_search.py +71 -0
- spec_agent/tools/wiki_write.py +38 -0
- spec_agent-0.1.0.dist-info/METADATA +390 -0
- spec_agent-0.1.0.dist-info/RECORD +15 -0
- spec_agent-0.1.0.dist-info/WHEEL +5 -0
- spec_agent-0.1.0.dist-info/entry_points.txt +2 -0
- spec_agent-0.1.0.dist-info/licenses/LICENSE +21 -0
- spec_agent-0.1.0.dist-info/top_level.txt +1 -0
spec_agent/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# spec_agent package
|
spec_agent/agent.py
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import anthropic
|
|
5
|
+
from typing import Optional
|
|
6
|
+
from spec_agent.config import Config
|
|
7
|
+
from spec_agent.tools.wiki_read import read_wiki_file
|
|
8
|
+
from spec_agent.tools.wiki_write import write_wiki_file
|
|
9
|
+
from spec_agent.tools.wiki_search import search_wiki
|
|
10
|
+
from spec_agent.tools.wiki_index import update_index
|
|
11
|
+
|
|
12
|
+
TOOL_DEFINITIONS = [
|
|
13
|
+
{
|
|
14
|
+
"name": "classify_commit",
|
|
15
|
+
"description": (
|
|
16
|
+
"Classify the git commit(s) to determine what type of change was made "
|
|
17
|
+
"and extract key concepts for wiki linking. Call this first."
|
|
18
|
+
),
|
|
19
|
+
"input_schema": {
|
|
20
|
+
"type": "object",
|
|
21
|
+
"properties": {
|
|
22
|
+
"diff": {"type": "string", "description": "The git diff content"},
|
|
23
|
+
"messages": {"type": "array", "items": {"type": "string"}, "description": "Commit messages"},
|
|
24
|
+
"repo": {"type": "string", "description": "Repository name"},
|
|
25
|
+
},
|
|
26
|
+
"required": ["diff", "messages", "repo"],
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
"name": "search_wiki",
|
|
31
|
+
"description": "Full-text search across the Obsidian vault to find related existing pages before writing.",
|
|
32
|
+
"input_schema": {
|
|
33
|
+
"type": "object",
|
|
34
|
+
"properties": {
|
|
35
|
+
"query": {"type": "string", "description": "Search terms"},
|
|
36
|
+
"limit": {"type": "integer", "default": 5},
|
|
37
|
+
},
|
|
38
|
+
"required": ["query"],
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
"name": "read_wiki_file",
|
|
43
|
+
"description": "Read an existing wiki file to understand its content before updating it.",
|
|
44
|
+
"input_schema": {
|
|
45
|
+
"type": "object",
|
|
46
|
+
"properties": {
|
|
47
|
+
"path": {"type": "string", "description": "Path relative to vault root, e.g. features/auth.md"},
|
|
48
|
+
},
|
|
49
|
+
"required": ["path"],
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
"name": "write_wiki_file",
|
|
54
|
+
"description": (
|
|
55
|
+
"Write a markdown file to the vault. Use mode='create' for new specs, "
|
|
56
|
+
"mode='update' to append a changelog entry to an existing spec."
|
|
57
|
+
),
|
|
58
|
+
"input_schema": {
|
|
59
|
+
"type": "object",
|
|
60
|
+
"properties": {
|
|
61
|
+
"path": {"type": "string", "description": "Path relative to vault root, e.g. features/auth.md"},
|
|
62
|
+
"content": {"type": "string", "description": "Full markdown content (create) or changelog line(s) (update)"},
|
|
63
|
+
"mode": {"type": "string", "enum": ["create", "update"], "default": "create"},
|
|
64
|
+
},
|
|
65
|
+
"required": ["path", "content"],
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
"name": "update_index",
|
|
70
|
+
"description": "Append an entry to index.md — the master log. Call this after writing the spec file.",
|
|
71
|
+
"input_schema": {
|
|
72
|
+
"type": "object",
|
|
73
|
+
"properties": {
|
|
74
|
+
"date": {"type": "string", "description": "ISO date, e.g. 2026-04-07"},
|
|
75
|
+
"type": {"type": "string", "enum": ["feature", "bug", "refactor", "arch", "chore"]},
|
|
76
|
+
"title": {"type": "string"},
|
|
77
|
+
"project": {"type": "string"},
|
|
78
|
+
"path": {"type": "string", "description": "Vault path without .md, e.g. features/auth"},
|
|
79
|
+
},
|
|
80
|
+
"required": ["date", "type", "title", "project", "path"],
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
_SYSTEM_PROMPT = """You are a spec-writing agent. When given a git diff and commit messages, you:
|
|
86
|
+
1. Call classify_commit to understand what changed.
|
|
87
|
+
2. If type is "chore", stop — no spec needed.
|
|
88
|
+
3. Search the wiki for related pages to understand existing context and find pages to link to.
|
|
89
|
+
4. Read any highly relevant existing pages (especially if you might be updating them).
|
|
90
|
+
5. Write or update the appropriate spec file using the right template:
|
|
91
|
+
- feature: Summary, Problem it solves, Implementation details, Files touched, Related [[wikilinks]], Open questions, Changelog
|
|
92
|
+
- bug: Root cause, Fix applied, Related [[wikilinks]], Changelog
|
|
93
|
+
- refactor: What changed, Why, Before/After key diff, Related [[wikilinks]]
|
|
94
|
+
- arch: Context, Decision, Consequences, Alternatives considered, Related [[wikilinks]]
|
|
95
|
+
6. Call update_index with the new entry.
|
|
96
|
+
|
|
97
|
+
Use [[wikilink]] syntax for related pages you found via search_wiki. Keep specs factual and grounded in the diff.
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _dispatch_tool(name: str, tool_input: dict, vault_path: str) -> str:
|
|
102
|
+
if name == "classify_commit":
|
|
103
|
+
# The agent classifies via its own reasoning — this tool is a no-op
|
|
104
|
+
# (the agent fills in the classification itself and returns structured JSON)
|
|
105
|
+
return json.dumps({"status": "classified", "note": "Use your reasoning to determine type and concepts"})
|
|
106
|
+
elif name == "search_wiki":
|
|
107
|
+
results = search_wiki(vault_path, tool_input["query"], tool_input.get("limit", 5))
|
|
108
|
+
return json.dumps(results)
|
|
109
|
+
elif name == "read_wiki_file":
|
|
110
|
+
return json.dumps(read_wiki_file(vault_path, tool_input["path"]))
|
|
111
|
+
elif name == "write_wiki_file":
|
|
112
|
+
return json.dumps(write_wiki_file(
|
|
113
|
+
vault_path, tool_input["path"], tool_input["content"],
|
|
114
|
+
mode=tool_input.get("mode", "create")
|
|
115
|
+
))
|
|
116
|
+
elif name == "update_index":
|
|
117
|
+
return json.dumps(update_index(vault_path, tool_input))
|
|
118
|
+
else:
|
|
119
|
+
return json.dumps({"error": f"Unknown tool: {name}"})
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def run_agent(
|
|
123
|
+
diff: str,
|
|
124
|
+
commit_messages: list[str],
|
|
125
|
+
repo_name: str,
|
|
126
|
+
branch: str,
|
|
127
|
+
cfg: Config,
|
|
128
|
+
_force_type: Optional[str] = None, # test hook
|
|
129
|
+
) -> None:
|
|
130
|
+
"""Run the tool-using agent loop."""
|
|
131
|
+
# Test hook: skip API calls for known chore commits
|
|
132
|
+
if _force_type == "chore":
|
|
133
|
+
return
|
|
134
|
+
|
|
135
|
+
vault_path = str(cfg.vault_path)
|
|
136
|
+
client = anthropic.Anthropic(api_key=os.environ.get("ANTHROPIC_API_KEY"))
|
|
137
|
+
|
|
138
|
+
user_message = (
|
|
139
|
+
f"Repository: {repo_name}\n"
|
|
140
|
+
f"Branch: {branch}\n"
|
|
141
|
+
f"Commit messages:\n" + "\n".join(f"- {m}" for m in commit_messages) +
|
|
142
|
+
f"\n\nGit diff (truncated to 50,000 chars):\n```\n{diff[:50_000]}\n```"
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
messages = [{"role": "user", "content": user_message}]
|
|
146
|
+
|
|
147
|
+
while True:
|
|
148
|
+
response = client.messages.create(
|
|
149
|
+
model=cfg.model,
|
|
150
|
+
max_tokens=4096,
|
|
151
|
+
system=_SYSTEM_PROMPT,
|
|
152
|
+
tools=TOOL_DEFINITIONS,
|
|
153
|
+
messages=messages,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
if response.stop_reason == "end_turn":
|
|
157
|
+
break
|
|
158
|
+
|
|
159
|
+
if response.stop_reason == "tool_use":
|
|
160
|
+
tool_results = []
|
|
161
|
+
for block in response.content:
|
|
162
|
+
if block.type == "tool_use":
|
|
163
|
+
result = _dispatch_tool(block.name, block.input, vault_path)
|
|
164
|
+
tool_results.append({
|
|
165
|
+
"type": "tool_result",
|
|
166
|
+
"tool_use_id": block.id,
|
|
167
|
+
"content": result,
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
messages.append({"role": "assistant", "content": response.content})
|
|
171
|
+
messages.append({"role": "user", "content": tool_results})
|
|
172
|
+
else:
|
|
173
|
+
# Unexpected stop reason — exit
|
|
174
|
+
break
|
spec_agent/cli.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import os
|
|
3
|
+
import subprocess
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
|
|
10
|
+
from spec_agent.agent import run_agent
|
|
11
|
+
from spec_agent.config import Config, DEFAULT_CONFIG_PATH, load_config, save_config
|
|
12
|
+
|
|
13
|
+
console = Console()
|
|
14
|
+
|
|
15
|
+
_HOOK_SCRIPT = """\
|
|
16
|
+
#!/usr/bin/env bash
|
|
17
|
+
# spec-agent post-push hook
|
|
18
|
+
# Fires after every git push. Passes diff via temp file to avoid shell escaping issues.
|
|
19
|
+
|
|
20
|
+
set -euo pipefail
|
|
21
|
+
|
|
22
|
+
REPO_NAME=$(basename "$(git rev-parse --show-toplevel)")
|
|
23
|
+
BRANCH=$(git rev-parse --abbrev-ref HEAD)
|
|
24
|
+
|
|
25
|
+
# Get commit range pushed
|
|
26
|
+
RANGE=$(git log @{u}..HEAD --format="%H" 2>/dev/null | tail -1)
|
|
27
|
+
if [ -z "$RANGE" ]; then
|
|
28
|
+
exit 0
|
|
29
|
+
fi
|
|
30
|
+
|
|
31
|
+
COMMITS=$(git log @{u}..HEAD --format="%s" 2>/dev/null)
|
|
32
|
+
MSG_LEN=$(echo "$COMMITS" | wc -c)
|
|
33
|
+
MIN_CHARS=$(spec-agent config-get min_commit_chars 2>/dev/null || echo "50")
|
|
34
|
+
|
|
35
|
+
if [ "$MSG_LEN" -lt "$MIN_CHARS" ]; then
|
|
36
|
+
exit 0
|
|
37
|
+
fi
|
|
38
|
+
|
|
39
|
+
# Write diff to a temp file to safely handle special characters
|
|
40
|
+
DIFF_FILE=$(mktemp /tmp/spec-agent-diff.XXXXXX)
|
|
41
|
+
git diff @{u}..HEAD 2>/dev/null | head -c 50000 > "$DIFF_FILE"
|
|
42
|
+
|
|
43
|
+
spec-agent run \\
|
|
44
|
+
--repo "$REPO_NAME" \\
|
|
45
|
+
--branch "$BRANCH" \\
|
|
46
|
+
--messages "$COMMITS" \\
|
|
47
|
+
--diff-file "$DIFF_FILE" &
|
|
48
|
+
|
|
49
|
+
exit 0
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@click.group()
|
|
54
|
+
def cli():
|
|
55
|
+
"""spec-agent: Auto-generate wiki specs from git commits."""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@cli.command()
|
|
59
|
+
@click.option("--repo", required=True, help="Repository name")
|
|
60
|
+
@click.option("--branch", required=True, help="Branch that was pushed")
|
|
61
|
+
@click.option("--messages", required=True, help="Newline-separated commit messages")
|
|
62
|
+
@click.option("--diff-file", required=True, help="Path to temp file containing the git diff")
|
|
63
|
+
@click.option("--config", default=str(DEFAULT_CONFIG_PATH), help="Path to config.yaml")
|
|
64
|
+
def run(repo, branch, messages, diff_file, config):
|
|
65
|
+
"""Run the spec agent (called by git hook)."""
|
|
66
|
+
cfg = load_config(Path(config))
|
|
67
|
+
|
|
68
|
+
if cfg.is_repo_ignored(repo):
|
|
69
|
+
console.print(f"[dim]spec-agent: skipping ignored repo {repo}[/dim]")
|
|
70
|
+
return
|
|
71
|
+
|
|
72
|
+
if cfg.is_branch_ignored(branch):
|
|
73
|
+
console.print(f"[dim]spec-agent: skipping ignored branch {branch}[/dim]")
|
|
74
|
+
return
|
|
75
|
+
|
|
76
|
+
commit_messages = [m.strip() for m in messages.strip().splitlines() if m.strip()]
|
|
77
|
+
|
|
78
|
+
if not cfg.vault_path.exists():
|
|
79
|
+
console.print(f"[yellow]spec-agent: vault not found at {cfg.vault_path}. Run: spec-agent init[/yellow]")
|
|
80
|
+
return
|
|
81
|
+
|
|
82
|
+
# Read diff from temp file then clean up
|
|
83
|
+
diff_path = Path(diff_file)
|
|
84
|
+
diff = diff_path.read_text(encoding="utf-8", errors="replace") if diff_path.exists() else ""
|
|
85
|
+
try:
|
|
86
|
+
diff_path.unlink(missing_ok=True)
|
|
87
|
+
except Exception:
|
|
88
|
+
pass
|
|
89
|
+
|
|
90
|
+
console.print(f"[cyan]spec-agent:[/cyan] processing push to {repo}/{branch}")
|
|
91
|
+
run_agent(
|
|
92
|
+
diff=diff,
|
|
93
|
+
commit_messages=commit_messages,
|
|
94
|
+
repo_name=repo,
|
|
95
|
+
branch=branch,
|
|
96
|
+
cfg=cfg,
|
|
97
|
+
)
|
|
98
|
+
console.print(f"[green]spec-agent:[/green] done — check {cfg.vault_path}")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@cli.command()
|
|
102
|
+
@click.option("--vault", required=True, help="Path to Obsidian vault directory")
|
|
103
|
+
def init(vault):
|
|
104
|
+
"""Initialize vault and write config file."""
|
|
105
|
+
vault_path = Path(vault).expanduser().resolve()
|
|
106
|
+
vault_path.mkdir(parents=True, exist_ok=True)
|
|
107
|
+
|
|
108
|
+
for folder in ["features", "bugs", "refactors", "concepts", "projects"]:
|
|
109
|
+
(vault_path / folder).mkdir(exist_ok=True)
|
|
110
|
+
|
|
111
|
+
index = vault_path / "index.md"
|
|
112
|
+
if not index.exists():
|
|
113
|
+
index.write_text(
|
|
114
|
+
"# Dev Wiki — Index\n\n"
|
|
115
|
+
"| Date | Type | Title | Project | Link |\n"
|
|
116
|
+
"|------|------|-------|---------|------|\n"
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
cfg = Config(vault_path=vault_path)
|
|
120
|
+
save_config(cfg)
|
|
121
|
+
|
|
122
|
+
console.print(f"[green]✓[/green] Vault created at {vault_path}")
|
|
123
|
+
console.print(f"[green]✓[/green] Config saved to {DEFAULT_CONFIG_PATH}")
|
|
124
|
+
console.print("\nNext step: [bold]spec-agent install-hook[/bold]")
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@cli.command("install-hook")
|
|
128
|
+
def install_hook():
|
|
129
|
+
"""Install global git post-push hook."""
|
|
130
|
+
hooks_dir = Path.home() / ".git-hooks"
|
|
131
|
+
hooks_dir.mkdir(exist_ok=True)
|
|
132
|
+
|
|
133
|
+
hook_path = hooks_dir / "post-push"
|
|
134
|
+
hook_path.write_text(_HOOK_SCRIPT)
|
|
135
|
+
hook_path.chmod(0o755)
|
|
136
|
+
|
|
137
|
+
subprocess.run(
|
|
138
|
+
["git", "config", "--global", "core.hooksPath", str(hooks_dir)],
|
|
139
|
+
check=True
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
console.print(f"[green]✓[/green] Hook installed at {hook_path}")
|
|
143
|
+
console.print(f"[green]✓[/green] Global git hooksPath set to {hooks_dir}")
|
|
144
|
+
console.print("\n[bold]Done.[/bold] Every git push will now trigger spec-agent.")
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@cli.command("config-get")
|
|
148
|
+
@click.argument("key")
|
|
149
|
+
@click.option("--config", default=str(DEFAULT_CONFIG_PATH))
|
|
150
|
+
def config_get(key, config):
|
|
151
|
+
"""Get a config value (used by the hook script)."""
|
|
152
|
+
cfg = load_config(Path(config))
|
|
153
|
+
value = getattr(cfg, key, None)
|
|
154
|
+
if value is not None:
|
|
155
|
+
click.echo(str(value))
|
|
156
|
+
else:
|
|
157
|
+
sys.exit(1)
|
spec_agent/config.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import fnmatch
|
|
3
|
+
import yaml
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
DEFAULT_CONFIG_PATH = Path.home() / ".spec-agent" / "config.yaml"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class Config:
|
|
13
|
+
vault_path: Path
|
|
14
|
+
model: str = "claude-sonnet-4-6"
|
|
15
|
+
ignored_repos: list[str] = field(default_factory=list)
|
|
16
|
+
ignored_branches: list[str] = field(default_factory=lambda: ["dependabot/*", "renovate/*"])
|
|
17
|
+
min_commit_chars: int = 50
|
|
18
|
+
|
|
19
|
+
def is_repo_ignored(self, repo_name: str) -> bool:
|
|
20
|
+
return repo_name in self.ignored_repos
|
|
21
|
+
|
|
22
|
+
def is_branch_ignored(self, branch: str) -> bool:
|
|
23
|
+
return any(fnmatch.fnmatch(branch, pattern) for pattern in self.ignored_branches)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _defaults() -> dict:
|
|
27
|
+
return {
|
|
28
|
+
"vault_path": str(Path.home() / "Documents" / "dev-wiki"),
|
|
29
|
+
"model": "claude-sonnet-4-6",
|
|
30
|
+
"ignored_repos": [],
|
|
31
|
+
"ignored_branches": ["dependabot/*", "renovate/*"],
|
|
32
|
+
"min_commit_chars": 50,
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def load_config(config_path: Path = DEFAULT_CONFIG_PATH) -> Config:
|
|
37
|
+
data = _defaults()
|
|
38
|
+
if config_path.exists():
|
|
39
|
+
with open(config_path) as f:
|
|
40
|
+
data.update(yaml.safe_load(f) or {})
|
|
41
|
+
return Config(
|
|
42
|
+
vault_path=Path(data["vault_path"]),
|
|
43
|
+
model=data["model"],
|
|
44
|
+
ignored_repos=data.get("ignored_repos", []),
|
|
45
|
+
ignored_branches=data.get("ignored_branches", ["dependabot/*", "renovate/*"]),
|
|
46
|
+
min_commit_chars=int(data.get("min_commit_chars", 50)),
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def save_config(cfg: Config, config_path: Path = DEFAULT_CONFIG_PATH) -> None:
|
|
51
|
+
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
52
|
+
with open(config_path, "w") as f:
|
|
53
|
+
yaml.dump({
|
|
54
|
+
"vault_path": str(cfg.vault_path),
|
|
55
|
+
"model": cfg.model,
|
|
56
|
+
"ignored_repos": cfg.ignored_repos,
|
|
57
|
+
"ignored_branches": cfg.ignored_branches,
|
|
58
|
+
"min_commit_chars": cfg.min_commit_chars,
|
|
59
|
+
}, f, default_flow_style=False)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# spec_agent.tools package
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
_HEADER = "# Dev Wiki — Index\n\n| Date | Type | Title | Project | Link |\n|------|------|-------|---------|------|\n"
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def update_index(vault_path: str, entry: dict) -> dict:
|
|
8
|
+
"""
|
|
9
|
+
Append a row to index.md.
|
|
10
|
+
|
|
11
|
+
entry keys: date, type, title, project, path
|
|
12
|
+
path is the relative vault path without .md extension, e.g. "features/auth"
|
|
13
|
+
|
|
14
|
+
Returns: {"success": bool}
|
|
15
|
+
"""
|
|
16
|
+
index_path = Path(vault_path) / "index.md"
|
|
17
|
+
|
|
18
|
+
if not index_path.exists():
|
|
19
|
+
index_path.write_text(_HEADER, encoding="utf-8")
|
|
20
|
+
|
|
21
|
+
row = (
|
|
22
|
+
f"| {entry['date']} "
|
|
23
|
+
f"| {entry['type']} "
|
|
24
|
+
f"| {entry['title']} "
|
|
25
|
+
f"| {entry['project']} "
|
|
26
|
+
f"| [[{entry['path']}]] |\n"
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
existing = index_path.read_text(encoding="utf-8")
|
|
30
|
+
index_path.write_text(existing + row, encoding="utf-8")
|
|
31
|
+
return {"success": True}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
import frontmatter
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def read_wiki_file(vault_path: str, relative_path: str) -> dict:
|
|
7
|
+
"""
|
|
8
|
+
Read a markdown file from the vault.
|
|
9
|
+
|
|
10
|
+
Returns:
|
|
11
|
+
{
|
|
12
|
+
"exists": bool,
|
|
13
|
+
"content": str, # body text without frontmatter
|
|
14
|
+
"frontmatter": dict, # parsed YAML frontmatter (empty if none)
|
|
15
|
+
"last_updated": str, # ISO date from frontmatter or ""
|
|
16
|
+
}
|
|
17
|
+
"""
|
|
18
|
+
full_path = Path(vault_path) / relative_path
|
|
19
|
+
if not full_path.exists():
|
|
20
|
+
return {"exists": False, "content": "", "frontmatter": {}, "last_updated": ""}
|
|
21
|
+
|
|
22
|
+
raw = full_path.read_text(encoding="utf-8")
|
|
23
|
+
|
|
24
|
+
try:
|
|
25
|
+
post = frontmatter.loads(raw)
|
|
26
|
+
meta = dict(post.metadata)
|
|
27
|
+
body = post.content
|
|
28
|
+
except Exception:
|
|
29
|
+
meta = {}
|
|
30
|
+
body = raw
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
"exists": True,
|
|
34
|
+
"content": body,
|
|
35
|
+
"frontmatter": meta,
|
|
36
|
+
"last_updated": str(meta.get("date", "")),
|
|
37
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import subprocess
|
|
3
|
+
import shutil
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _find_grep() -> str:
|
|
8
|
+
"""Return path to system grep binary."""
|
|
9
|
+
for candidate in ["/usr/bin/grep", "/usr/local/bin/grep", "grep"]:
|
|
10
|
+
found = shutil.which(candidate)
|
|
11
|
+
if found:
|
|
12
|
+
return found
|
|
13
|
+
return "grep"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def search_wiki(vault_path: str, query: str, limit: int = 5) -> list[dict]:
|
|
17
|
+
"""
|
|
18
|
+
Full-text search across the vault using grep.
|
|
19
|
+
|
|
20
|
+
Returns:
|
|
21
|
+
[{"path": str, "title": str, "excerpt": str}]
|
|
22
|
+
path is relative to vault root.
|
|
23
|
+
"""
|
|
24
|
+
vault = Path(vault_path)
|
|
25
|
+
results = []
|
|
26
|
+
seen_files: set[str] = set()
|
|
27
|
+
|
|
28
|
+
grep = _find_grep()
|
|
29
|
+
# -r recursive, -i case-insensitive, -n line numbers, --include only .md
|
|
30
|
+
# Output format: /path/file.md:linenum:matching line
|
|
31
|
+
cmd = [grep, "-r", "-i", "-n", "--include=*.md", query, str(vault)]
|
|
32
|
+
|
|
33
|
+
try:
|
|
34
|
+
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
|
|
35
|
+
except subprocess.TimeoutExpired:
|
|
36
|
+
return []
|
|
37
|
+
|
|
38
|
+
for line in proc.stdout.splitlines():
|
|
39
|
+
if len(results) >= limit:
|
|
40
|
+
break
|
|
41
|
+
|
|
42
|
+
# Parse: /path/to/file.md:line_num:matching line
|
|
43
|
+
parts = line.split(":", 2)
|
|
44
|
+
if len(parts) < 3:
|
|
45
|
+
continue
|
|
46
|
+
file_path, _, excerpt = parts
|
|
47
|
+
|
|
48
|
+
file_path = file_path.strip()
|
|
49
|
+
if file_path in seen_files or not file_path.endswith(".md"):
|
|
50
|
+
continue
|
|
51
|
+
seen_files.add(file_path)
|
|
52
|
+
|
|
53
|
+
try:
|
|
54
|
+
rel_path = str(Path(file_path).relative_to(vault))
|
|
55
|
+
except ValueError:
|
|
56
|
+
continue
|
|
57
|
+
|
|
58
|
+
# Read first heading as title
|
|
59
|
+
try:
|
|
60
|
+
first_line = Path(file_path).read_text(encoding="utf-8").splitlines()[0]
|
|
61
|
+
title = first_line.lstrip("#").strip() if first_line.startswith("#") else rel_path
|
|
62
|
+
except Exception:
|
|
63
|
+
title = rel_path
|
|
64
|
+
|
|
65
|
+
results.append({
|
|
66
|
+
"path": rel_path,
|
|
67
|
+
"title": title,
|
|
68
|
+
"excerpt": excerpt.strip()[:200],
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
return results
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def write_wiki_file(vault_path: str, relative_path: str, content: str, mode: str = "create") -> dict:
|
|
6
|
+
"""
|
|
7
|
+
Write a markdown file to the vault.
|
|
8
|
+
|
|
9
|
+
Args:
|
|
10
|
+
vault_path: Absolute path to the Obsidian vault directory.
|
|
11
|
+
relative_path: Path relative to vault root, e.g. "features/auth.md".
|
|
12
|
+
content: In create mode: full file content. In update mode: changelog line(s) to append.
|
|
13
|
+
mode: "create" (overwrite) or "update" (append changelog entry).
|
|
14
|
+
|
|
15
|
+
Returns:
|
|
16
|
+
{"success": bool, "path": str, "error": str | None}
|
|
17
|
+
"""
|
|
18
|
+
full_path = Path(vault_path) / relative_path
|
|
19
|
+
full_path.parent.mkdir(parents=True, exist_ok=True)
|
|
20
|
+
|
|
21
|
+
try:
|
|
22
|
+
if mode == "create":
|
|
23
|
+
full_path.write_text(content, encoding="utf-8")
|
|
24
|
+
elif mode == "update":
|
|
25
|
+
existing = full_path.read_text(encoding="utf-8") if full_path.exists() else ""
|
|
26
|
+
if "## Changelog" in existing:
|
|
27
|
+
# Append after the last changelog entry
|
|
28
|
+
updated = existing.rstrip() + "\n" + content + "\n"
|
|
29
|
+
else:
|
|
30
|
+
# Add a Changelog section
|
|
31
|
+
updated = existing.rstrip() + "\n\n## Changelog\n\n" + content + "\n"
|
|
32
|
+
full_path.write_text(updated, encoding="utf-8")
|
|
33
|
+
else:
|
|
34
|
+
return {"success": False, "path": relative_path, "error": f"Unknown mode: {mode}"}
|
|
35
|
+
|
|
36
|
+
return {"success": True, "path": relative_path, "error": None}
|
|
37
|
+
except Exception as e:
|
|
38
|
+
return {"success": False, "path": relative_path, "error": str(e)}
|
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: spec-agent
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Auto-generate an Obsidian knowledge wiki from every git push, powered by Claude
|
|
5
|
+
Author-email: Vishesh Chaitanya <visheshchaitanya@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/visheshchaitanya/spec-agent
|
|
8
|
+
Project-URL: Repository, https://github.com/visheshchaitanya/spec-agent
|
|
9
|
+
Project-URL: Issues, https://github.com/visheshchaitanya/spec-agent/issues
|
|
10
|
+
Keywords: obsidian,wiki,git,documentation,anthropic,claude,ai,developer-tools
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Topic :: Software Development :: Documentation
|
|
20
|
+
Classifier: Topic :: Software Development :: Version Control :: Git
|
|
21
|
+
Classifier: Topic :: Utilities
|
|
22
|
+
Requires-Python: >=3.11
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
License-File: LICENSE
|
|
25
|
+
Requires-Dist: anthropic>=0.40.0
|
|
26
|
+
Requires-Dist: click>=8.1
|
|
27
|
+
Requires-Dist: pyyaml>=6.0
|
|
28
|
+
Requires-Dist: python-frontmatter>=1.1
|
|
29
|
+
Requires-Dist: rich>=13.0
|
|
30
|
+
Provides-Extra: dev
|
|
31
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
32
|
+
Requires-Dist: pytest-cov>=5.0; extra == "dev"
|
|
33
|
+
Dynamic: license-file
|
|
34
|
+
|
|
35
|
+
# spec-agent
|
|
36
|
+
|
|
37
|
+
> Auto-generate an Obsidian knowledge wiki from every `git push` — powered by Claude.
|
|
38
|
+
|
|
39
|
+
Every time you push code, **spec-agent** reads the diff, classifies the change, and writes a structured spec document into your [Obsidian](https://obsidian.md) vault. Over time, your vault becomes a living, visually-navigable graph of everything you've ever built.
|
|
40
|
+
|
|
41
|
+
Inspired by [Andrej Karpathy's LLM Wiki pattern](https://gist.github.com/karpathy/442a6bf555914893e9891c11519de94f): the LLM acts as a compiler (raw commits → structured wiki), not a retrieval engine. No RAG, no embeddings — just well-organized markdown with `[[wikilinks]]` that Obsidian renders as a knowledge graph.
|
|
42
|
+
|
|
43
|
+
---
|
|
44
|
+
|
|
45
|
+
## How It Works
|
|
46
|
+
|
|
47
|
+
```
|
|
48
|
+
git push
|
|
49
|
+
└── ~/.git-hooks/post-push ← global hook, fires on every repo
|
|
50
|
+
└── spec-agent run ← Python CLI
|
|
51
|
+
└── Claude (tool-use) ← agentic loop
|
|
52
|
+
├── classify_commit
|
|
53
|
+
├── search_wiki ← finds related existing pages
|
|
54
|
+
├── read_wiki_file ← reads context before updating
|
|
55
|
+
├── write_wiki_file ← creates or updates spec
|
|
56
|
+
└── update_index ← appends row to index.md
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
The agent:
|
|
60
|
+
1. Classifies the commit type (`feature`, `bug`, `refactor`, `arch`, `chore`)
|
|
61
|
+
2. Searches your vault for related pages to link to
|
|
62
|
+
3. Writes an adaptive spec using the appropriate template
|
|
63
|
+
4. Updates `index.md` — the master log that Claude reads at session start
|
|
64
|
+
|
|
65
|
+
**Chore commits are skipped.** Bot branches (`dependabot/*`, `renovate/*`) are skipped. Tiny commits (below a configurable character threshold) are skipped.
|
|
66
|
+
|
|
67
|
+
---
|
|
68
|
+
|
|
69
|
+
## Features
|
|
70
|
+
|
|
71
|
+
- **Zero friction** — fires automatically on every `git push`, no developer action needed
|
|
72
|
+
- **Adaptive templates** — selects the right format based on commit type
|
|
73
|
+
- `feature` → full spec (summary, problem, implementation, files, open questions)
|
|
74
|
+
- `bug` → short report (root cause, fix applied)
|
|
75
|
+
- `refactor` → brief note (what changed, why, before/after)
|
|
76
|
+
- `arch` → ADR format (context, decision, consequences, alternatives)
|
|
77
|
+
- **Accurate `[[wikilinks]]`** — searches vault for existing pages before writing, so links are real
|
|
78
|
+
- **Living index** — `index.md` is a table of every spec ever written; share it with Claude at session start to give it full project memory
|
|
79
|
+
- **Obsidian graph** — concepts referenced by many specs become visual hubs after 10+ specs
|
|
80
|
+
- **Works on every repo** — one global hook installation covers all your projects
|
|
81
|
+
|
|
82
|
+
---
|
|
83
|
+
|
|
84
|
+
## Prerequisites
|
|
85
|
+
|
|
86
|
+
- **Python 3.11+**
|
|
87
|
+
- **An Anthropic API key** — [get one here](https://console.anthropic.com)
|
|
88
|
+
- **Obsidian** — [download here](https://obsidian.md) (free)
|
|
89
|
+
- **Git 2.9+** (for `core.hooksPath` support)
|
|
90
|
+
|
|
91
|
+
---
|
|
92
|
+
|
|
93
|
+
## Installation
|
|
94
|
+
|
|
95
|
+
### From PyPI (recommended)
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
pip install spec-agent
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### From source
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
git clone https://github.com/visheshchaitanya/spec-agent.git ~/.spec-agent
|
|
105
|
+
pip install -e ~/.spec-agent
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
---
|
|
109
|
+
|
|
110
|
+
## Setup
|
|
111
|
+
|
|
112
|
+
### 1. Set your API key
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
export ANTHROPIC_API_KEY="sk-ant-..."
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Add this to your shell profile (`~/.zshrc`, `~/.bashrc`, or `~/.zshenv`) to make it permanent:
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
echo 'export ANTHROPIC_API_KEY="sk-ant-..."' >> ~/.zshrc
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
### 2. Initialize your vault
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
spec-agent init --vault ~/Documents/dev-wiki
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
This creates the vault directory structure and writes a default `~/.spec-agent/config.yaml`.
|
|
131
|
+
|
|
132
|
+
Then open `~/Documents/dev-wiki` as a vault in Obsidian (**File → Open vault as folder**).
|
|
133
|
+
|
|
134
|
+
### 3. Install the global git hook
|
|
135
|
+
|
|
136
|
+
```bash
|
|
137
|
+
spec-agent install-hook
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
This creates `~/.git-hooks/post-push` and sets `git config --global core.hooksPath ~/.git-hooks`. The hook fires on every push in every repository on your machine.
|
|
141
|
+
|
|
142
|
+
### 4. Push anything to test
|
|
143
|
+
|
|
144
|
+
```bash
|
|
145
|
+
cd ~/any-repo
|
|
146
|
+
git push
|
|
147
|
+
# → spec-agent fires in the background
|
|
148
|
+
# → spec appears in ~/Documents/dev-wiki
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
---
|
|
152
|
+
|
|
153
|
+
## Configuration
|
|
154
|
+
|
|
155
|
+
Config lives at `~/.spec-agent/config.yaml`:
|
|
156
|
+
|
|
157
|
+
```yaml
|
|
158
|
+
vault_path: ~/Documents/dev-wiki
|
|
159
|
+
model: claude-sonnet-4-6
|
|
160
|
+
ignored_repos: [] # list of repo names to skip entirely
|
|
161
|
+
ignored_branches:
|
|
162
|
+
- dependabot/*
|
|
163
|
+
- renovate/*
|
|
164
|
+
min_commit_chars: 50 # skip pushes where total commit message length is below this
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
| Key | Default | Description |
|
|
168
|
+
|-----|---------|-------------|
|
|
169
|
+
| `vault_path` | `~/Documents/dev-wiki` | Absolute path to your Obsidian vault |
|
|
170
|
+
| `model` | `claude-sonnet-4-6` | Anthropic model to use |
|
|
171
|
+
| `ignored_repos` | `[]` | Exact repo names to never process |
|
|
172
|
+
| `ignored_branches` | `[dependabot/*, renovate/*]` | Glob patterns — matching branches are skipped |
|
|
173
|
+
| `min_commit_chars` | `50` | Skip pushes where total commit message length is below this (filters "wip", "fix typo") |
|
|
174
|
+
|
|
175
|
+
---
|
|
176
|
+
|
|
177
|
+
## Vault Structure
|
|
178
|
+
|
|
179
|
+
```
|
|
180
|
+
~/Documents/dev-wiki/
|
|
181
|
+
├── index.md ← master log — give this to Claude at session start
|
|
182
|
+
├── features/
|
|
183
|
+
│ ├── alert-ingestion.md
|
|
184
|
+
│ └── auth-system.md
|
|
185
|
+
├── bugs/
|
|
186
|
+
│ ├── fix-status-migration.md
|
|
187
|
+
│ └── fix-null-pointer.md
|
|
188
|
+
├── refactors/
|
|
189
|
+
│ └── extract-auth-middleware.md
|
|
190
|
+
├── concepts/ ← auto-created graph hubs when first referenced
|
|
191
|
+
│ ├── clickhouse.md
|
|
192
|
+
│ └── jwt.md
|
|
193
|
+
└── projects/
|
|
194
|
+
└── my-app.md
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
### index.md — your project memory
|
|
198
|
+
|
|
199
|
+
`index.md` is a markdown table of every spec ever written:
|
|
200
|
+
|
|
201
|
+
```markdown
|
|
202
|
+
# Dev Wiki — Index
|
|
203
|
+
|
|
204
|
+
| Date | Type | Title | Project | Link |
|
|
205
|
+
|------|------|-------|---------|------|
|
|
206
|
+
| 2026-04-07 | bug | Fix status migration | my-app | [[bugs/fix-status-migration]] |
|
|
207
|
+
| 2026-04-05 | feature | Alert ingestion pipeline | my-app | [[features/alert-ingestion]] |
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
Paste the contents of `index.md` at the start of any Claude session — Claude immediately knows everything you've built across all projects.
|
|
211
|
+
|
|
212
|
+
### Spec frontmatter
|
|
213
|
+
|
|
214
|
+
Every generated spec has YAML frontmatter:
|
|
215
|
+
|
|
216
|
+
```yaml
|
|
217
|
+
---
|
|
218
|
+
type: feature
|
|
219
|
+
project: my-app
|
|
220
|
+
date: 2026-04-07
|
|
221
|
+
commit: a3f9c12
|
|
222
|
+
status: shipped
|
|
223
|
+
---
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
---
|
|
227
|
+
|
|
228
|
+
## Giving Claude context at session start
|
|
229
|
+
|
|
230
|
+
Paste this into your Claude session to give it full project memory:
|
|
231
|
+
|
|
232
|
+
```
|
|
233
|
+
Here is my dev wiki index — everything I've built:
|
|
234
|
+
|
|
235
|
+
<paste contents of ~/Documents/dev-wiki/index.md>
|
|
236
|
+
|
|
237
|
+
I'm working on <task>. Based on the index, what related specs should I look at?
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
Claude can then ask you to paste specific spec files for deeper context.
|
|
241
|
+
|
|
242
|
+
---
|
|
243
|
+
|
|
244
|
+
## CLI Reference
|
|
245
|
+
|
|
246
|
+
```
|
|
247
|
+
spec-agent [COMMAND] [OPTIONS]
|
|
248
|
+
|
|
249
|
+
Commands:
|
|
250
|
+
run Run the agent (called automatically by git hook)
|
|
251
|
+
init Initialize vault directory and write config
|
|
252
|
+
install-hook Install global git post-push hook
|
|
253
|
+
config-get Read a config value (used internally by hook)
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
### `spec-agent run`
|
|
257
|
+
|
|
258
|
+
```
|
|
259
|
+
Options:
|
|
260
|
+
--repo TEXT Repository name (required)
|
|
261
|
+
--branch TEXT Branch that was pushed (required)
|
|
262
|
+
--messages TEXT Newline-separated commit messages (required)
|
|
263
|
+
--diff-file TEXT Path to temp file containing the git diff (required)
|
|
264
|
+
--config TEXT Path to config.yaml [default: ~/.spec-agent/config.yaml]
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
### `spec-agent init`
|
|
268
|
+
|
|
269
|
+
```
|
|
270
|
+
Options:
|
|
271
|
+
--vault TEXT Path to Obsidian vault directory (required)
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
### `spec-agent install-hook`
|
|
275
|
+
|
|
276
|
+
No options. Installs `~/.git-hooks/post-push` and sets the global git hooks path.
|
|
277
|
+
|
|
278
|
+
---
|
|
279
|
+
|
|
280
|
+
## Manual run (without pushing)
|
|
281
|
+
|
|
282
|
+
You can run the agent manually against any repo:
|
|
283
|
+
|
|
284
|
+
```bash
|
|
285
|
+
DIFF_FILE=$(mktemp /tmp/spec-agent-diff.XXXXXX)
|
|
286
|
+
git diff HEAD~1..HEAD | head -c 50000 > "$DIFF_FILE"
|
|
287
|
+
|
|
288
|
+
spec-agent run \
|
|
289
|
+
--repo "$(basename $(pwd))" \
|
|
290
|
+
--branch "$(git rev-parse --abbrev-ref HEAD)" \
|
|
291
|
+
--messages "$(git log HEAD~1..HEAD --format='%s')" \
|
|
292
|
+
--diff-file "$DIFF_FILE"
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
---
|
|
296
|
+
|
|
297
|
+
## Per-repo opt-out
|
|
298
|
+
|
|
299
|
+
To exclude a specific repo from spec generation, add its name to `ignored_repos` in `~/.spec-agent/config.yaml`:
|
|
300
|
+
|
|
301
|
+
```yaml
|
|
302
|
+
ignored_repos:
|
|
303
|
+
- my-private-repo
|
|
304
|
+
- dotfiles
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
---
|
|
308
|
+
|
|
309
|
+
## Development
|
|
310
|
+
|
|
311
|
+
### Requirements
|
|
312
|
+
|
|
313
|
+
```bash
|
|
314
|
+
git clone https://github.com/visheshchaitanya/spec-agent.git
|
|
315
|
+
cd spec-agent
|
|
316
|
+
pip install -e ".[dev]"
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
### Run tests
|
|
320
|
+
|
|
321
|
+
```bash
|
|
322
|
+
pytest
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
### Run tests with coverage
|
|
326
|
+
|
|
327
|
+
```bash
|
|
328
|
+
pytest --cov=spec_agent --cov-report=term-missing
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
All tests use temporary directories — no vault or API key required.
|
|
332
|
+
|
|
333
|
+
---
|
|
334
|
+
|
|
335
|
+
## Architecture
|
|
336
|
+
|
|
337
|
+
### Tool-using agent loop
|
|
338
|
+
|
|
339
|
+
The agent runs an Anthropic `messages.create` loop until `stop_reason == "end_turn"`:
|
|
340
|
+
|
|
341
|
+
```
|
|
342
|
+
client.messages.create(tools=TOOL_DEFINITIONS, ...)
|
|
343
|
+
→ stop_reason == "tool_use"
|
|
344
|
+
→ dispatch tool, collect results
|
|
345
|
+
→ append assistant + user messages
|
|
346
|
+
→ loop
|
|
347
|
+
→ stop_reason == "end_turn"
|
|
348
|
+
→ done
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
### Tools
|
|
352
|
+
|
|
353
|
+
| Tool | Purpose |
|
|
354
|
+
|------|---------|
|
|
355
|
+
| `classify_commit` | Agent classifies diff type and extracts concepts (no-op server-side — agent reasons internally) |
|
|
356
|
+
| `search_wiki` | Full-text search across vault using `grep -r` — finds related pages for wikilinks |
|
|
357
|
+
| `read_wiki_file` | Reads existing spec file — enables update mode instead of duplicate creation |
|
|
358
|
+
| `write_wiki_file` | Writes markdown to vault; `mode=update` appends a dated changelog section |
|
|
359
|
+
| `update_index` | Appends row to `index.md` master log |
|
|
360
|
+
|
|
361
|
+
### Diff safety
|
|
362
|
+
|
|
363
|
+
Git diffs larger than 50,000 characters are truncated before being passed to the agent. The diff is passed via a temp file (not shell arguments) to avoid escaping issues with special characters.
|
|
364
|
+
|
|
365
|
+
---
|
|
366
|
+
|
|
367
|
+
## Roadmap
|
|
368
|
+
|
|
369
|
+
- [ ] GitHub webhook / cloud daemon (replace local hook with HTTP POST)
|
|
370
|
+
- [ ] Multi-agent parallel processing (Classifier + Writer + Linker)
|
|
371
|
+
- [ ] Obsidian Dataview dashboards
|
|
372
|
+
- [ ] Per-project vault paths
|
|
373
|
+
- [ ] Slack/email notifications on spec creation
|
|
374
|
+
|
|
375
|
+
---
|
|
376
|
+
|
|
377
|
+
## Contributing
|
|
378
|
+
|
|
379
|
+
Pull requests welcome. Please:
|
|
380
|
+
|
|
381
|
+
1. Fork the repo and create a branch from `main`
|
|
382
|
+
2. Add tests for any new behavior
|
|
383
|
+
3. Ensure `pytest` passes
|
|
384
|
+
4. Open a pull request
|
|
385
|
+
|
|
386
|
+
---
|
|
387
|
+
|
|
388
|
+
## License
|
|
389
|
+
|
|
390
|
+
[MIT](LICENSE) — © 2026 Vishesh Chaitanya
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
spec_agent/__init__.py,sha256=ih_ux0aSsiTj8Z9Xp6zbI2Y_CcU8_ppqmkdxYwILJk8,21
|
|
2
|
+
spec_agent/agent.py,sha256=Btzs3bUETaGWsJY_7jmD6vxZ1xE2iBaNlcyJNfcM4Z4,7097
|
|
3
|
+
spec_agent/cli.py,sha256=DYFj3G53PxVnA3OjArE4ovYPgKMQua0LAmph9nM5OEs,4907
|
|
4
|
+
spec_agent/config.py,sha256=PM9N9JBO-0h26vCpiCRQrvCWw4j6jsoMEP51mRl1-NY,1997
|
|
5
|
+
spec_agent/tools/__init__.py,sha256=T8z5Dlv-OrCyaYeks_mWv06ypMPbC-7FlNja0Dk5zWE,27
|
|
6
|
+
spec_agent/tools/wiki_index.py,sha256=VL6Wq7wYWCw5XbV3zW4cJkdKuNo5G9x-1Ui9aE4Hyrg,909
|
|
7
|
+
spec_agent/tools/wiki_read.py,sha256=RctG9647QARLKd7PNo65MJ3R85MzS2T6cxsAy8F13jc,1019
|
|
8
|
+
spec_agent/tools/wiki_search.py,sha256=KN-C29NhG6nUrCkkn14NxFW988pgcdk-d7RLS-VYHBE,2053
|
|
9
|
+
spec_agent/tools/wiki_write.py,sha256=YKaXzwaY9NZdJXKcoUgRzpbliJpw_zHweVAE2nwZYcc,1600
|
|
10
|
+
spec_agent-0.1.0.dist-info/licenses/LICENSE,sha256=KojDbQvCgVU70vl_bW281P6F7vKyKIQEUk2ztXcitFY,1074
|
|
11
|
+
spec_agent-0.1.0.dist-info/METADATA,sha256=xTW9bF01aGu_bjrpQFp0GQ55g-3EpP5GESKSpwJnbHE,11363
|
|
12
|
+
spec_agent-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
13
|
+
spec_agent-0.1.0.dist-info/entry_points.txt,sha256=-ZkKHI-FJ8KzcGikiEy6Jc-xilb9peF3HeiZ2wM2a7I,50
|
|
14
|
+
spec_agent-0.1.0.dist-info/top_level.txt,sha256=GSYY8kK7um73Bh3VvVcHCNHMyUerYEl_7EU96gQ807A,11
|
|
15
|
+
spec_agent-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Vishesh Chaitanya
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
spec_agent
|