whyfence 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.
whyfence/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Temporal memory for coding agents."""
2
+
3
+ from whyfence.cli import main
4
+
5
+ __all__ = ["main"]
whyfence/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from whyfence.cli import main
2
+
3
+ raise SystemExit(main())
whyfence/cli.py ADDED
@@ -0,0 +1,190 @@
1
+ """Command-line entry point: `whyfence <command>`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ from contextlib import closing
8
+ from pathlib import Path
9
+
10
+ from rich.console import Console
11
+ from rich.markup import escape
12
+ from rich.table import Table
13
+ from rich.text import Text
14
+
15
+ from whyfence import linter, store
16
+ from whyfence.console import harden_streams
17
+ from whyfence.ingest import events, git, symbols
18
+
19
+ DEFAULT_DB = Path(".whyfence") / "index.db"
20
+
21
+
22
+ def _plural(count: int, word: str) -> str:
23
+ return f"{count} {word}{'s' * (count != 1)}"
24
+
25
+
26
+ def _ingest(args: argparse.Namespace) -> int:
27
+ console = Console(highlight=False)
28
+ try:
29
+ root = git.repo_root(args.repo)
30
+ db_path = args.db or root / DEFAULT_DB
31
+ with closing(store.connect(db_path)) as conn:
32
+ commits = git.ingest_git(conn, root)
33
+ with console.status("Indexing functions and classes…"):
34
+ indexed = symbols.ingest_symbols(conn, root)
35
+ with console.status("Mining commits and transcripts for decisions…"):
36
+ mined = events.ingest_events(conn, root, projects_dir=args.transcripts,
37
+ transcripts=not args.no_transcripts)
38
+ total = store.count_commits(conn)
39
+ symbol_count = store.count_symbols(conn)
40
+ except (git.GitError, store.StoreError, symbols.SymbolError) as exc:
41
+ console.print(f"[red]error:[/] {escape(str(exc))}")
42
+ return 2
43
+ console.print(f"Read {_plural(commits.seen, 'commit')} from {escape(root.name)}: "
44
+ f"[green]{commits.inserted} new[/], {total} indexed in {escape(str(db_path))}")
45
+ if indexed.rebuilt:
46
+ console.print("[yellow]History was rewritten since the last run; rebuilt symbol history.[/]")
47
+ console.print(f"Walked {_plural(indexed.commits, 'commit')}: {_plural(indexed.created, 'new symbol')}, "
48
+ f"{_plural(indexed.versions, 'version')}, {indexed.renamed} renamed or moved, "
49
+ f"{indexed.deleted} deleted · {symbol_count} symbols indexed")
50
+ console.print(f"Found {_plural(mined.commit_events, 'event')} in commit messages and "
51
+ f"{_plural(mined.transcript_events, 'event')} in {_plural(mined.sessions, 'Claude Code session')}, "
52
+ f"with {_plural(mined.links, 'symbol link')}")
53
+ return 0
54
+
55
+
56
+ def _default_db(repo: Path) -> Path:
57
+ try:
58
+ root = git.repo_root(repo)
59
+ except git.GitError:
60
+ root = repo
61
+ return (root / DEFAULT_DB).resolve()
62
+
63
+
64
+ _KIND_STYLES = {
65
+ "constraint": "bold magenta", "decision": "cyan", "correction": "yellow", "dead_end": "red",
66
+ "incident": "bright_red", "revert": "bright_red",
67
+ }
68
+
69
+
70
+ def _lookup(args: argparse.Namespace, lookup) -> tuple[dict | None, int]:
71
+ """Run a query against the index, or explain why it can't be answered."""
72
+ console = Console(highlight=False, stderr=True)
73
+ db_path = args.db.resolve() if args.db else _default_db(args.repo)
74
+ if not db_path.is_file():
75
+ console.print(f"[red]error:[/] no whyfence index at {escape(str(db_path))}; run `whyfence ingest` first")
76
+ return None, 2
77
+ with closing(store.connect(db_path)) as conn:
78
+ answer = lookup(conn, args.symbol)
79
+ if args.json:
80
+ print(json.dumps(answer, indent=2, ensure_ascii=False))
81
+ elif answer["status"] != "ok":
82
+ console.print(escape(answer["message"]))
83
+ for candidate in answer.get("candidates", []):
84
+ console.print(f" {escape(candidate)}")
85
+ return (answer if answer["status"] == "ok" and not args.json else None), (0 if answer["status"] == "ok" else 1)
86
+
87
+
88
+ def _symbol_header(console: Console, symbol: dict) -> None:
89
+ console.print(f"[bold]{escape(symbol['name'])}[/] [grey50]{symbol['kind']} · {symbol['language']}[/]")
90
+
91
+
92
+ def _why(args: argparse.Namespace) -> int:
93
+ from whyfence import queries
94
+
95
+ answer, code = _lookup(args, queries.lookup_why)
96
+ if answer is None:
97
+ return code
98
+ console = Console(highlight=False)
99
+ _symbol_header(console, answer["symbol"])
100
+ if not answer["events"]:
101
+ console.print(f" [grey50]{escape(answer['message'])}[/]")
102
+ return 0
103
+ table = Table(box=None, show_header=False, padding=(0, 2, 0, 0), pad_edge=False)
104
+ table.add_column(no_wrap=True)
105
+ table.add_column(style="grey50", no_wrap=True)
106
+ table.add_column(overflow="fold")
107
+ for event in answer["events"]:
108
+ source = event["source"] or {}
109
+ origin = f"commit {event['commit'][:7]}" if event["commit"] else \
110
+ " · ".join(filter(None, [source.get("kind"), source.get("agent")]))
111
+ text = Text(event["summary"])
112
+ text.append(f"\n{origin}" + (" · inferred" if event["confidence"] == "INFERRED" else ""), style="grey50")
113
+ table.add_row(Text(event["kind"], style=_KIND_STYLES.get(event["kind"], "")), event["recorded_at"][:10], text)
114
+ console.print(table)
115
+ return 0
116
+
117
+
118
+ def _history(args: argparse.Namespace) -> int:
119
+ from whyfence import queries
120
+
121
+ answer, code = _lookup(args, queries.lookup_history)
122
+ if answer is None:
123
+ return code
124
+ console = Console(highlight=False)
125
+ _symbol_header(console, answer["symbol"])
126
+ table = Table(box=None, show_header=False, padding=(0, 2, 0, 0), pad_edge=False)
127
+ for column in ({"style": "yellow", "no_wrap": True}, {"style": "grey50", "no_wrap": True},
128
+ {"no_wrap": True}, {"overflow": "fold"}):
129
+ table.add_column(**column)
130
+ for version in answer["versions"]:
131
+ introduced = version["valid_from"] or {}
132
+ start, end = version["lines"]
133
+ subject = introduced.get("subject") or "(commit not indexed)"
134
+ if version["current"]:
135
+ subject += " [current]"
136
+ table.add_row(introduced.get("sha", "")[:7], (introduced.get("date") or "")[:10],
137
+ f"{version['file']}:{start}-{end}", Text(f"{version['name']} ", style="bold").append(
138
+ subject, style="green" if version["current"] else ""))
139
+ console.print(table)
140
+ return 0
141
+
142
+
143
+ def _mcp(args: argparse.Namespace) -> int:
144
+ from whyfence import mcp_server # deferred: the MCP stack is slow to import
145
+
146
+ mcp_server.serve(args.db.resolve() if args.db else _default_db(args.repo))
147
+ return 0
148
+
149
+
150
+ def main(argv: list[str] | None = None) -> int:
151
+ harden_streams()
152
+ parser = argparse.ArgumentParser(
153
+ prog="whyfence",
154
+ description="Temporal memory for coding agents: why the code is the way it is.",
155
+ )
156
+ commands = parser.add_subparsers(dest="command", required=True)
157
+ linter.add_arguments(commands.add_parser(
158
+ "lint",
159
+ help="check that commands in CLAUDE.md / AGENTS.md still exist",
160
+ description=linter.__doc__.splitlines()[0],
161
+ ))
162
+
163
+ ingest = commands.add_parser("ingest", help="index git history into the local database")
164
+ ingest.add_argument("repo", nargs="?", type=Path, default=Path("."), help="repository (default: .)")
165
+ ingest.add_argument("--db", type=Path, help=f"database path (default: <repo>/{DEFAULT_DB.as_posix()})")
166
+ ingest.add_argument("--transcripts", type=Path, metavar="DIR",
167
+ help="Claude Code projects folder, or one project's transcript folder "
168
+ "(default: ~/.claude/projects, or $CLAUDE_CONFIG_DIR/projects)")
169
+ ingest.add_argument("--no-transcripts", action="store_true", help="don't read Claude Code transcripts")
170
+ ingest.set_defaults(func=_ingest)
171
+
172
+ for name, func, help_text in (
173
+ ("why", _why, "show the decisions, rules and dead ends recorded against a function or class"),
174
+ ("history", _history, "show how a function or class changed, commit by commit"),
175
+ ):
176
+ query = commands.add_parser(name, help=help_text, description=help_text)
177
+ query.add_argument("symbol", help="qualified name (app.auth.refresh), bare name (refresh) or symbol id")
178
+ query.add_argument("--repo", type=Path, default=Path("."), help="repository whose index to read (default: .)")
179
+ query.add_argument("--db", type=Path, help=f"database path (default: <repo>/{DEFAULT_DB.as_posix()})")
180
+ query.add_argument("--json", action="store_true", help="print the same JSON the MCP tool returns")
181
+ query.set_defaults(func=func)
182
+
183
+ mcp = commands.add_parser("mcp", help="serve the why/history tools to MCP clients over stdio")
184
+ mcp.add_argument("--repo", type=Path, default=Path("."),
185
+ help="repository whose index to serve (default: .)")
186
+ mcp.add_argument("--db", type=Path, help=f"database path (default: <repo>/{DEFAULT_DB.as_posix()})")
187
+ mcp.set_defaults(func=_mcp)
188
+
189
+ args = parser.parse_args(argv)
190
+ return args.func(args)
whyfence/console.py ADDED
@@ -0,0 +1,21 @@
1
+ """Terminal output that can't crash on encoding."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+
8
+ def harden_streams() -> None:
9
+ """Replace characters stdout/stderr can't encode instead of raising.
10
+
11
+ Windows reports the NUL device as a terminal and encodes it (and legacy
12
+ consoles) as cp1252, so a spinner frame or a "✗" would otherwise crash the
13
+ command with UnicodeEncodeError.
14
+ """
15
+ for stream in (sys.stdout, sys.stderr):
16
+ reconfigure = getattr(stream, "reconfigure", None)
17
+ if reconfigure is not None:
18
+ try:
19
+ reconfigure(errors="replace")
20
+ except (ValueError, OSError): # detached or closed stream
21
+ pass
@@ -0,0 +1 @@
1
+ """Ingestors that turn project history (git, transcripts, ADRs) into events."""