Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
2531201
chore: bootstrap repo (pyproject, uv lockfile, Python 3.14, MIT licen…
constk Apr 26, 2026
d36870f
chore: ruff + mypy strict + import-linter + commitizen config (#2) (#30)
constk Apr 26, 2026
6ccac57
chore: .gitignore, .editorconfig, .dockerignore (#5) (#31)
constk Apr 26, 2026
e67dc3a
chore: pre-commit hook stack (ruff, gitleaks, commitizen, mypy, hygie…
constk Apr 26, 2026
522990c
chore: justfile recipes (lint, typecheck, test, architecture, check, …
constk Apr 26, 2026
5b10276
chore: Dockerfile (multi-stage, Python 3.14, non-root, healthcheck) (…
constk Apr 26, 2026
77f0bf0
chore: docker-compose.yml (app + frontend + jaeger) (#7) (#35)
constk Apr 26, 2026
07565ea
chore: GitHub issue + PR templates + CODEOWNERS (#8) (#36)
constk Apr 26, 2026
69de68f
chore: .claude hooks (pretooluse_bash, posttooluse_writeedit, session…
constk Apr 26, 2026
bd9a986
chore: port portable .claude/skills (#16) (#38)
constk Apr 26, 2026
09ec3ac
chore: CI workflow (lint, typecheck, tests, coverage >=75%, architect…
constk Apr 26, 2026
9037e10
fix: dereference astral-sh/setup-uv annotated tag to commit SHA (#40)
constk Apr 26, 2026
a7fa6bf
fix: simplify ci.yml to clear workflow startup error (#41)
constk Apr 26, 2026
5cb469e
chore: bare-minimum ci.yml to isolate startup failure (#42)
constk Apr 26, 2026
deeafee
chore: ci.yml with lint job + setup-uv v8 (#43)
constk Apr 26, 2026
cd48bed
chore: restore full ci.yml with setup-uv v8 (fixes startup failure) (…
constk Apr 26, 2026
4a6c846
chore: drop frontend CI jobs until #21 lands the scaffold (#45)
constk Apr 26, 2026
4131d46
chore: placeholder test so pytest does not exit 5 on empty scaffold (…
constk Apr 26, 2026
887c129
chore: CI meta-gates (branch-protection contexts sync, commit-types s…
constk Apr 26, 2026
6ed4193
chore: security workflow (gitleaks, pip-audit, npm audit, trivy) (#11…
constk Apr 26, 2026
579bfb4
fix: update trivy-action SHA (Teller's SHA no longer in repo) (#49)
constk Apr 26, 2026
81b8fff
chore: drop npm-audit job from security.yml until #21 lands frontend …
constk Apr 26, 2026
fa459c2
fix: ignore pip CVE-2026-3219 (#11) (#51)
constk Apr 26, 2026
7290e10
chore: PR title lint + release-drafter (#12) (#52)
constk Apr 26, 2026
6bafc1d
chore: release workflow (tag-triggered, SBOM, GH Release publish) (#1…
constk Apr 26, 2026
85fd2ec
chore: branch-protection JSON + apply workflow + artifact cleanup + C…
constk Apr 26, 2026
6a3516b
feat: backend scaffold + Pydantic StrictModel + example schemas (#17,…
constk Apr 26, 2026
8116629
feat: observability setup (OTel SDK, OTLP exporter, structured loggin…
constk Apr 26, 2026
1c09345
feat: tool-registry pattern + example echo_tool (#20) (#57)
constk Apr 26, 2026
6cbcde5
feat: frontend scaffold (Vite + React 19.2 + TS strict, eslint flat +…
constk Apr 27, 2026
3d71869
feat: SSE client primitive + hello page + CSS palette + component tes…
constk Apr 27, 2026
00880e5
feat: eval harness scaffold + 1 example golden case + nightly workflo…
constk Apr 27, 2026
ab37fc2
docs: HARNESS, INVARIANTS, BOUNDARIES, DEVELOPMENT, EVAL_HARNESS, SEC…
constk Apr 27, 2026
707f49d
chore: scrub remaining Teller / financial / DuckDB references (#27) (…
constk Apr 27, 2026
a16fca2
docs: README badges + screenshot section (#28) (#63)
constk Apr 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 102 additions & 0 deletions .claude/hooks/posttooluse_writeedit.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
#!/usr/bin/env python3
"""PostToolUse hook for Write | Edit — formatter dispatch.

After a successful Write or Edit, format the touched file in place:

- `.py` -> `uv run ruff check --fix` + `uv run ruff format`
- `.ts .tsx .js .jsx .json` -> `npx --no-install prettier -w` (from frontend/)
- `.css .html .md` -> `npx --no-install prettier -w` (from frontend/)

Silent on failure — the write already landed; formatting is best-effort. Exit code
is always 0 so Claude Code never rolls back the edit over a missing formatter.
"""

from __future__ import annotations

import contextlib
import json
import os
import subprocess
import sys
from pathlib import Path

PY_EXTENSIONS = {".py"}
PRETTIER_EXTENSIONS = {
".ts",
".tsx",
".js",
".jsx",
".json",
".css",
".html",
".md",
}


def project_dir() -> Path:
env = os.environ.get("CLAUDE_PROJECT_DIR")
if env:
return Path(env)
return Path(__file__).resolve().parent.parent.parent


def run_quiet(cmd: list[str], cwd: Path) -> None:
with contextlib.suppress(FileNotFoundError, subprocess.TimeoutExpired):
subprocess.run(
cmd,
cwd=cwd,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
timeout=60,
)


def format_python(path: Path, base: Path) -> None:
run_quiet(["uv", "run", "ruff", "check", "--fix", str(path)], cwd=base)
run_quiet(["uv", "run", "ruff", "format", str(path)], cwd=base)


def format_prettier(path: Path, base: Path) -> None:
frontend = base / "frontend"
if not frontend.is_dir():
return
# --no-install so a checkout without prettier installed silently no-ops
# instead of triggering an npx download.
run_quiet(
["npx", "--no-install", "prettier", "-w", str(path)],
cwd=frontend,
)


def main() -> None:
try:
payload = json.load(sys.stdin)
except json.JSONDecodeError:
return
if not isinstance(payload, dict):
return
tool_input = payload.get("tool_input")
if not isinstance(tool_input, dict):
return
file_path = tool_input.get("file_path", "")
if not isinstance(file_path, str) or not file_path:
return

path = Path(file_path)
if not path.is_absolute():
path = project_dir() / path
if not path.exists():
return

suffix = path.suffix.lower()
base = project_dir()

if suffix in PY_EXTENSIONS:
format_python(path, base)
elif suffix in PRETTIER_EXTENSIONS:
format_prettier(path, base)


if __name__ == "__main__":
main()
126 changes: 126 additions & 0 deletions .claude/hooks/pretooluse_bash.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
#!/usr/bin/env python3
"""PreToolUse hook for Bash — forbidden-flag blocker, secret scanner, audit log.

Invoked by Claude Code on every Bash tool call. Reads the hook payload as JSON
on stdin and performs three checks in order:

1. Forbidden-flag blocker — deny `git --no-verify`, `--no-hooks`, `--no-gpg-sign`.
2. Secret scanner — on `git commit`, scan the staged diff for known
secret shapes (AWS keys, sk-*, ghp_/ghs_*, PEMs,
Slack tokens).
3. Audit log — append a timestamped record of every command the
agent runs to .claude/bash-log.txt (gitignored).

Exit codes
----------
0 — command allowed
2 — command blocked (Claude Code surfaces stderr back to the agent)
"""

from __future__ import annotations

import json
import os
import re
import subprocess
import sys
from datetime import UTC, datetime
from pathlib import Path

FORBIDDEN_FLAG = re.compile(r"\bgit\b[^\n]*--(?:no-verify|no-hooks|no-gpg-sign)\b")

SECRET_PATTERNS: list[tuple[str, re.Pattern[str]]] = [
("AWS access key id", re.compile(r"AKIA[0-9A-Z]{16}")),
(
"AWS secret access key assignment",
re.compile(r"aws_secret_access_key\s*=", re.IGNORECASE),
),
("OpenAI-style API key", re.compile(r"sk-[A-Za-z0-9]{20,}")),
("GitHub personal access token", re.compile(r"ghp_[A-Za-z0-9]{36}")),
("GitHub server-to-server token", re.compile(r"ghs_[A-Za-z0-9]{36}")),
("Slack bot token", re.compile(r"xoxb-[A-Za-z0-9-]+")),
("PEM private key block", re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----")),
]


def project_dir() -> Path:
env = os.environ.get("CLAUDE_PROJECT_DIR")
if env:
return Path(env)
return Path(__file__).resolve().parent.parent.parent


def load_payload() -> dict[str, object]:
try:
data = json.load(sys.stdin)
except json.JSONDecodeError:
return {}
return data if isinstance(data, dict) else {}


def block(reason: str) -> None:
print(reason, file=sys.stderr)
sys.exit(2)


def scan_staged_diff(cwd: Path) -> str | None:
"""Return matched pattern name when a secret shape appears in staged diff."""
try:
diff = subprocess.check_output(
["git", "diff", "--cached", "--no-color"],
cwd=cwd,
stderr=subprocess.DEVNULL,
text=True,
errors="replace",
)
except subprocess.CalledProcessError, FileNotFoundError:
return None
for name, pattern in SECRET_PATTERNS:
if pattern.search(diff):
return name
return None


def audit(command: str, base: Path) -> None:
log = base / ".claude" / "bash-log.txt"
try:
log.parent.mkdir(parents=True, exist_ok=True)
ts = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
with log.open("a", encoding="utf-8") as fh:
fh.write(f"{ts}\t{command}\n")
except OSError:
pass


def main() -> None:
payload = load_payload()
tool_input = payload.get("tool_input")
if not isinstance(tool_input, dict):
return
command = tool_input.get("command", "")
if not isinstance(command, str) or not command.strip():
return

if FORBIDDEN_FLAG.search(command):
block(
"Blocked by PreToolUse hook: forbidden git flag "
"(--no-verify | --no-hooks | --no-gpg-sign). "
"Fix the underlying issue instead of bypassing the check."
)

base = project_dir()
stripped = command.lstrip()
if stripped.startswith(("git commit", '"git commit', "'git commit")):
match = scan_staged_diff(base)
if match:
block(
"Blocked by PreToolUse hook: "
f"staged diff matches secret pattern ({match}). "
"Remove the secret, rotate if already committed, and re-stage."
)

audit(command, base)


if __name__ == "__main__":
main()
69 changes: 69 additions & 0 deletions .claude/hooks/sessionstart.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
#!/usr/bin/env python3
"""SessionStart hook — inject current branch + working-tree state as session context.

Claude Code treats stdout as `additionalContext` on SessionStart, so this script
prints the current git branch and `git status --short` — giving the agent the
same orientation a human gets from opening the terminal. Runs once per session.

Silent and zero-exit on any failure: a missing git directory or a transient
command error must never block session startup.
"""

from __future__ import annotations

import os
import subprocess
import sys
from pathlib import Path


def project_dir() -> Path:
env = os.environ.get("CLAUDE_PROJECT_DIR")
if env:
return Path(env)
return Path(__file__).resolve().parent.parent.parent


def git_output(args: list[str], cwd: Path) -> str:
try:
return subprocess.check_output(
["git", *args],
cwd=cwd,
stderr=subprocess.DEVNULL,
text=True,
errors="replace",
timeout=5,
).strip()
except (
subprocess.CalledProcessError,
subprocess.TimeoutExpired,
FileNotFoundError,
):
return ""


def main() -> None:
base = project_dir()
branch = git_output(["branch", "--show-current"], base)
status = git_output(["status", "--short"], base)

if not branch and not status:
return

lines = ["# Repository context (injected by SessionStart hook)"]
if branch:
lines.append(f"- Current branch: `{branch}`")
if status:
lines.append("")
lines.append("Working tree (`git status --short`):")
lines.append("```")
lines.append(status)
lines.append("```")
else:
lines.append("- Working tree: clean")

sys.stdout.write("\n".join(lines) + "\n")


if __name__ == "__main__":
main()
39 changes: 39 additions & 0 deletions .claude/settings.local.json.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
{
"$schema": "https://json.schemastore.org/claude-code-settings.json",
"_comment": "Copy this file to .claude/settings.local.json (gitignored) to enable the agent hook stack. See docs/DEVELOPMENT.md#local-agent-hook-setup.",
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "python \"$CLAUDE_PROJECT_DIR/.claude/hooks/pretooluse_bash.py\""
}
]
}
],
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "python \"$CLAUDE_PROJECT_DIR/.claude/hooks/posttooluse_writeedit.py\""
}
]
}
],
"SessionStart": [
{
"matcher": "startup|resume",
"hooks": [
{
"type": "command",
"command": "python \"$CLAUDE_PROJECT_DIR/.claude/hooks/sessionstart.py\""
}
]
}
]
}
}
33 changes: 33 additions & 0 deletions .claude/skills/architect/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
---
name: architect
description: Activate when designing system components, defining module boundaries, making tech stack decisions, reviewing data flow, or planning API contracts. Triggers on architecture discussions, new module creation, or when structural decisions are being made.
user-invocable: false
---

You are the Solution Architect for this project.

## Responsibilities

- Design the system before code is written
- Define module boundaries, data flow, API contracts, and integration points
- Make build vs. import decisions — when to use a library, when to write it
- Review overall structure after each major task to check for architectural drift
- Think about what a production version would look like

## Constraints

- Refer to `docs/ARCHITECTURE.md` and `docs/BOUNDARIES.md` as the source of truth for all design decisions
- All API endpoints must be versioned under `/api/v1/`
- No file over 300 lines, no function over ~50 lines
- Pydantic models for all data crossing module boundaries (`src/models/`)
- No unnecessary dependencies — if it can be written in 20 lines, write it
- OTel observability is a first-class concern, not a bolt-on
- Layer flow is one-way (`api | eval -> agent -> tools -> data -> observability -> models`); enforced by `import-linter`

## When reviewing structure

- Check that new code fits the module boundaries defined in BOUNDARIES.md
- Flag if a module is taking on responsibilities that belong elsewhere
- Ensure new tools/endpoints follow the patterns established by existing ones
- Verify that data flows through the correct layers (API -> Agent -> Tools -> Data)
- Check that OTel spans are planned for any new component in the request path
Loading
Loading