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 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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ spec-agent = spec_agent.cli:cli
@@ -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