Skip to content

Commit ab89e45

Browse files
authored
Merge pull request #64 from constk/develop
release: bring main up to develop (every harness ticket)
2 parents 173f611 + a16fca2 commit ab89e45

112 files changed

Lines changed: 13455 additions & 53 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
#!/usr/bin/env python3
2+
"""PostToolUse hook for Write | Edit — formatter dispatch.
3+
4+
After a successful Write or Edit, format the touched file in place:
5+
6+
- `.py` -> `uv run ruff check --fix` + `uv run ruff format`
7+
- `.ts .tsx .js .jsx .json` -> `npx --no-install prettier -w` (from frontend/)
8+
- `.css .html .md` -> `npx --no-install prettier -w` (from frontend/)
9+
10+
Silent on failure — the write already landed; formatting is best-effort. Exit code
11+
is always 0 so Claude Code never rolls back the edit over a missing formatter.
12+
"""
13+
14+
from __future__ import annotations
15+
16+
import contextlib
17+
import json
18+
import os
19+
import subprocess
20+
import sys
21+
from pathlib import Path
22+
23+
PY_EXTENSIONS = {".py"}
24+
PRETTIER_EXTENSIONS = {
25+
".ts",
26+
".tsx",
27+
".js",
28+
".jsx",
29+
".json",
30+
".css",
31+
".html",
32+
".md",
33+
}
34+
35+
36+
def project_dir() -> Path:
37+
env = os.environ.get("CLAUDE_PROJECT_DIR")
38+
if env:
39+
return Path(env)
40+
return Path(__file__).resolve().parent.parent.parent
41+
42+
43+
def run_quiet(cmd: list[str], cwd: Path) -> None:
44+
with contextlib.suppress(FileNotFoundError, subprocess.TimeoutExpired):
45+
subprocess.run(
46+
cmd,
47+
cwd=cwd,
48+
stdout=subprocess.DEVNULL,
49+
stderr=subprocess.DEVNULL,
50+
check=False,
51+
timeout=60,
52+
)
53+
54+
55+
def format_python(path: Path, base: Path) -> None:
56+
run_quiet(["uv", "run", "ruff", "check", "--fix", str(path)], cwd=base)
57+
run_quiet(["uv", "run", "ruff", "format", str(path)], cwd=base)
58+
59+
60+
def format_prettier(path: Path, base: Path) -> None:
61+
frontend = base / "frontend"
62+
if not frontend.is_dir():
63+
return
64+
# --no-install so a checkout without prettier installed silently no-ops
65+
# instead of triggering an npx download.
66+
run_quiet(
67+
["npx", "--no-install", "prettier", "-w", str(path)],
68+
cwd=frontend,
69+
)
70+
71+
72+
def main() -> None:
73+
try:
74+
payload = json.load(sys.stdin)
75+
except json.JSONDecodeError:
76+
return
77+
if not isinstance(payload, dict):
78+
return
79+
tool_input = payload.get("tool_input")
80+
if not isinstance(tool_input, dict):
81+
return
82+
file_path = tool_input.get("file_path", "")
83+
if not isinstance(file_path, str) or not file_path:
84+
return
85+
86+
path = Path(file_path)
87+
if not path.is_absolute():
88+
path = project_dir() / path
89+
if not path.exists():
90+
return
91+
92+
suffix = path.suffix.lower()
93+
base = project_dir()
94+
95+
if suffix in PY_EXTENSIONS:
96+
format_python(path, base)
97+
elif suffix in PRETTIER_EXTENSIONS:
98+
format_prettier(path, base)
99+
100+
101+
if __name__ == "__main__":
102+
main()

.claude/hooks/pretooluse_bash.py

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
#!/usr/bin/env python3
2+
"""PreToolUse hook for Bash — forbidden-flag blocker, secret scanner, audit log.
3+
4+
Invoked by Claude Code on every Bash tool call. Reads the hook payload as JSON
5+
on stdin and performs three checks in order:
6+
7+
1. Forbidden-flag blocker — deny `git --no-verify`, `--no-hooks`, `--no-gpg-sign`.
8+
2. Secret scanner — on `git commit`, scan the staged diff for known
9+
secret shapes (AWS keys, sk-*, ghp_/ghs_*, PEMs,
10+
Slack tokens).
11+
3. Audit log — append a timestamped record of every command the
12+
agent runs to .claude/bash-log.txt (gitignored).
13+
14+
Exit codes
15+
----------
16+
0 — command allowed
17+
2 — command blocked (Claude Code surfaces stderr back to the agent)
18+
"""
19+
20+
from __future__ import annotations
21+
22+
import json
23+
import os
24+
import re
25+
import subprocess
26+
import sys
27+
from datetime import UTC, datetime
28+
from pathlib import Path
29+
30+
FORBIDDEN_FLAG = re.compile(r"\bgit\b[^\n]*--(?:no-verify|no-hooks|no-gpg-sign)\b")
31+
32+
SECRET_PATTERNS: list[tuple[str, re.Pattern[str]]] = [
33+
("AWS access key id", re.compile(r"AKIA[0-9A-Z]{16}")),
34+
(
35+
"AWS secret access key assignment",
36+
re.compile(r"aws_secret_access_key\s*=", re.IGNORECASE),
37+
),
38+
("OpenAI-style API key", re.compile(r"sk-[A-Za-z0-9]{20,}")),
39+
("GitHub personal access token", re.compile(r"ghp_[A-Za-z0-9]{36}")),
40+
("GitHub server-to-server token", re.compile(r"ghs_[A-Za-z0-9]{36}")),
41+
("Slack bot token", re.compile(r"xoxb-[A-Za-z0-9-]+")),
42+
("PEM private key block", re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----")),
43+
]
44+
45+
46+
def project_dir() -> Path:
47+
env = os.environ.get("CLAUDE_PROJECT_DIR")
48+
if env:
49+
return Path(env)
50+
return Path(__file__).resolve().parent.parent.parent
51+
52+
53+
def load_payload() -> dict[str, object]:
54+
try:
55+
data = json.load(sys.stdin)
56+
except json.JSONDecodeError:
57+
return {}
58+
return data if isinstance(data, dict) else {}
59+
60+
61+
def block(reason: str) -> None:
62+
print(reason, file=sys.stderr)
63+
sys.exit(2)
64+
65+
66+
def scan_staged_diff(cwd: Path) -> str | None:
67+
"""Return matched pattern name when a secret shape appears in staged diff."""
68+
try:
69+
diff = subprocess.check_output(
70+
["git", "diff", "--cached", "--no-color"],
71+
cwd=cwd,
72+
stderr=subprocess.DEVNULL,
73+
text=True,
74+
errors="replace",
75+
)
76+
except subprocess.CalledProcessError, FileNotFoundError:
77+
return None
78+
for name, pattern in SECRET_PATTERNS:
79+
if pattern.search(diff):
80+
return name
81+
return None
82+
83+
84+
def audit(command: str, base: Path) -> None:
85+
log = base / ".claude" / "bash-log.txt"
86+
try:
87+
log.parent.mkdir(parents=True, exist_ok=True)
88+
ts = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
89+
with log.open("a", encoding="utf-8") as fh:
90+
fh.write(f"{ts}\t{command}\n")
91+
except OSError:
92+
pass
93+
94+
95+
def main() -> None:
96+
payload = load_payload()
97+
tool_input = payload.get("tool_input")
98+
if not isinstance(tool_input, dict):
99+
return
100+
command = tool_input.get("command", "")
101+
if not isinstance(command, str) or not command.strip():
102+
return
103+
104+
if FORBIDDEN_FLAG.search(command):
105+
block(
106+
"Blocked by PreToolUse hook: forbidden git flag "
107+
"(--no-verify | --no-hooks | --no-gpg-sign). "
108+
"Fix the underlying issue instead of bypassing the check."
109+
)
110+
111+
base = project_dir()
112+
stripped = command.lstrip()
113+
if stripped.startswith(("git commit", '"git commit', "'git commit")):
114+
match = scan_staged_diff(base)
115+
if match:
116+
block(
117+
"Blocked by PreToolUse hook: "
118+
f"staged diff matches secret pattern ({match}). "
119+
"Remove the secret, rotate if already committed, and re-stage."
120+
)
121+
122+
audit(command, base)
123+
124+
125+
if __name__ == "__main__":
126+
main()

.claude/hooks/sessionstart.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
#!/usr/bin/env python3
2+
"""SessionStart hook — inject current branch + working-tree state as session context.
3+
4+
Claude Code treats stdout as `additionalContext` on SessionStart, so this script
5+
prints the current git branch and `git status --short` — giving the agent the
6+
same orientation a human gets from opening the terminal. Runs once per session.
7+
8+
Silent and zero-exit on any failure: a missing git directory or a transient
9+
command error must never block session startup.
10+
"""
11+
12+
from __future__ import annotations
13+
14+
import os
15+
import subprocess
16+
import sys
17+
from pathlib import Path
18+
19+
20+
def project_dir() -> Path:
21+
env = os.environ.get("CLAUDE_PROJECT_DIR")
22+
if env:
23+
return Path(env)
24+
return Path(__file__).resolve().parent.parent.parent
25+
26+
27+
def git_output(args: list[str], cwd: Path) -> str:
28+
try:
29+
return subprocess.check_output(
30+
["git", *args],
31+
cwd=cwd,
32+
stderr=subprocess.DEVNULL,
33+
text=True,
34+
errors="replace",
35+
timeout=5,
36+
).strip()
37+
except (
38+
subprocess.CalledProcessError,
39+
subprocess.TimeoutExpired,
40+
FileNotFoundError,
41+
):
42+
return ""
43+
44+
45+
def main() -> None:
46+
base = project_dir()
47+
branch = git_output(["branch", "--show-current"], base)
48+
status = git_output(["status", "--short"], base)
49+
50+
if not branch and not status:
51+
return
52+
53+
lines = ["# Repository context (injected by SessionStart hook)"]
54+
if branch:
55+
lines.append(f"- Current branch: `{branch}`")
56+
if status:
57+
lines.append("")
58+
lines.append("Working tree (`git status --short`):")
59+
lines.append("```")
60+
lines.append(status)
61+
lines.append("```")
62+
else:
63+
lines.append("- Working tree: clean")
64+
65+
sys.stdout.write("\n".join(lines) + "\n")
66+
67+
68+
if __name__ == "__main__":
69+
main()
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
{
2+
"$schema": "https://json.schemastore.org/claude-code-settings.json",
3+
"_comment": "Copy this file to .claude/settings.local.json (gitignored) to enable the agent hook stack. See docs/DEVELOPMENT.md#local-agent-hook-setup.",
4+
"hooks": {
5+
"PreToolUse": [
6+
{
7+
"matcher": "Bash",
8+
"hooks": [
9+
{
10+
"type": "command",
11+
"command": "python \"$CLAUDE_PROJECT_DIR/.claude/hooks/pretooluse_bash.py\""
12+
}
13+
]
14+
}
15+
],
16+
"PostToolUse": [
17+
{
18+
"matcher": "Write|Edit",
19+
"hooks": [
20+
{
21+
"type": "command",
22+
"command": "python \"$CLAUDE_PROJECT_DIR/.claude/hooks/posttooluse_writeedit.py\""
23+
}
24+
]
25+
}
26+
],
27+
"SessionStart": [
28+
{
29+
"matcher": "startup|resume",
30+
"hooks": [
31+
{
32+
"type": "command",
33+
"command": "python \"$CLAUDE_PROJECT_DIR/.claude/hooks/sessionstart.py\""
34+
}
35+
]
36+
}
37+
]
38+
}
39+
}

.claude/skills/architect/SKILL.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
---
2+
name: architect
3+
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.
4+
user-invocable: false
5+
---
6+
7+
You are the Solution Architect for this project.
8+
9+
## Responsibilities
10+
11+
- Design the system before code is written
12+
- Define module boundaries, data flow, API contracts, and integration points
13+
- Make build vs. import decisions — when to use a library, when to write it
14+
- Review overall structure after each major task to check for architectural drift
15+
- Think about what a production version would look like
16+
17+
## Constraints
18+
19+
- Refer to `docs/ARCHITECTURE.md` and `docs/BOUNDARIES.md` as the source of truth for all design decisions
20+
- All API endpoints must be versioned under `/api/v1/`
21+
- No file over 300 lines, no function over ~50 lines
22+
- Pydantic models for all data crossing module boundaries (`src/models/`)
23+
- No unnecessary dependencies — if it can be written in 20 lines, write it
24+
- OTel observability is a first-class concern, not a bolt-on
25+
- Layer flow is one-way (`api | eval -> agent -> tools -> data -> observability -> models`); enforced by `import-linter`
26+
27+
## When reviewing structure
28+
29+
- Check that new code fits the module boundaries defined in BOUNDARIES.md
30+
- Flag if a module is taking on responsibilities that belong elsewhere
31+
- Ensure new tools/endpoints follow the patterns established by existing ones
32+
- Verify that data flows through the correct layers (API -> Agent -> Tools -> Data)
33+
- Check that OTel spans are planned for any new component in the request path

0 commit comments

Comments
 (0)