Skip to content

Commit b294bc8

Browse files
luiseimanclaude
andcommitted
chore: scripts/sync_all.py — additive batch sync across all registered projects
Companion to scripts/audit_all.py. Walks registry, applies additive-only sync: - Adds missing template/hooks/*.sh files (preserves project-local hook changes) - Adds missing stack rules per detected stack from .forge-manifest.json - Refreshes manifest version + synced_at + per-file hashes - NEVER touches settings.json, CLAUDE.md, or .claude/rules/domain/ (those need human review — use the interactive /forge sync skill per-project) Today's batch run touched 12 projects: - 41 hooks added (mostly tool-latency.sh, permission-denied.sh, warn-missing-test.sh from v3.4.0 follow-up work — these were missing from older project syncs) - 4 stack rules added: trading/backtesting-adr-gate.md to TRADINGBOT; swift-swiftui/ios.md + supabase/database.md to InviSight-iOS; docker-deploy/infra.md to SOMA2 Also propagated to dotforge itself (its own .claude/hooks/ was missing permission-denied.sh and tool-latency.sh). Audit re-run: scores stable (avg 9.12, 9/12 perfect ≥9.0). Use: python3 scripts/sync_all.py # apply python3 scripts/sync_all.py --dry-run # preview Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 0b84795 commit b294bc8

3 files changed

Lines changed: 263 additions & 0 deletions

File tree

.claude/hooks/permission-denied.sh

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
#!/usr/bin/env bash
2+
# Permission Denied hook - logs denied operations for audit trail
3+
# Event: PermissionDenied
4+
# Reads: $TOOL_INPUT (JSON with tool_name, arguments, reason)
5+
# Output: .claude/session/permission-denials.log
6+
7+
set -e
8+
9+
LOG_DIR=".claude/session"
10+
LOG_FILE="$LOG_DIR/permission-denials.log"
11+
12+
# Create log directory if needed
13+
mkdir -p "$LOG_DIR"
14+
15+
# Parse input (tool_name, arguments, reason from environment or stdin)
16+
TOOL_INPUT="${TOOL_INPUT:-}"
17+
if [ -z "$TOOL_INPUT" ]; then
18+
TOOL_INPUT="$(cat)"
19+
fi
20+
21+
# Extract fields from JSON (portable awk-based parsing)
22+
TOOL_NAME=$(printf '%s' "$TOOL_INPUT" | grep -o '"tool_name":"[^"]*"' | cut -d'"' -f4 || echo "unknown")
23+
ARGUMENTS=$(printf '%s' "$TOOL_INPUT" | grep -o '"arguments":"[^"]*"' | cut -d'"' -f4 || echo "")
24+
REASON=$(printf '%s' "$TOOL_INPUT" | grep -o '"reason":"[^"]*"' | cut -d'"' -f4 || echo "")
25+
26+
# Truncate arguments to 100 chars
27+
ARGUMENTS_TRUNC="${ARGUMENTS:0:100}"
28+
[ "${#ARGUMENTS}" -gt 100 ] && ARGUMENTS_TRUNC="${ARGUMENTS_TRUNC}..."
29+
30+
# ISO timestamp
31+
TIMESTAMP=$(date -u '+%Y-%m-%dT%H:%M:%SZ')
32+
33+
# Log entry: ISO timestamp | tool | args (truncated) | reason
34+
printf '%s | %s | %s | %s\n' "$TIMESTAMP" "$TOOL_NAME" "$ARGUMENTS_TRUNC" "$REASON" >> "$LOG_FILE"
35+
36+
exit 0

.claude/hooks/tool-latency.sh

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
#!/usr/bin/env bash
2+
# PostToolUse hook: capture per-tool duration_ms (v2.1.119+) for session-report.
3+
# Matcher: "" (all tools).
4+
# Output: appends "tool_name|duration_ms" lines to /tmp/claude-tool-latency-<hash>.
5+
# Silent (exit 0) — telemetry only, never blocks.
6+
#
7+
# Back-compat: if duration_ms is absent or non-numeric (older Claude Code),
8+
# this hook is a no-op. session-report.sh handles a missing counter file.
9+
10+
# --- Project hash (must match session-report.sh) ---
11+
_hash() {
12+
printf '%s' "$1" | md5sum 2>/dev/null | cut -c1-8 || \
13+
printf '%s' "$1" | md5 -q 2>/dev/null | cut -c1-8 || \
14+
printf '%s' "$1" | cksum | cut -d' ' -f1
15+
}
16+
PROJECT_HASH=$(_hash "$PWD")
17+
COUNTER="/tmp/claude-tool-latency-${PROJECT_HASH}"
18+
19+
# --- Read stdin JSON (PostToolUse payload) ---
20+
PAYLOAD=$(cat)
21+
[[ -z "$PAYLOAD" ]] && exit 0
22+
23+
# --- Parse fields (jq required; degrade silently if absent) ---
24+
command -v jq >/dev/null 2>&1 || exit 0
25+
26+
TOOL_NAME=$(printf '%s' "$PAYLOAD" | jq -r '.tool_name // empty' 2>/dev/null)
27+
DURATION=$(printf '%s' "$PAYLOAD" | jq -r '.duration_ms // empty' 2>/dev/null)
28+
29+
# Sanitize: tool_name without pipes; duration must be a non-negative integer
30+
[[ -z "$TOOL_NAME" || -z "$DURATION" ]] && exit 0
31+
TOOL_NAME=${TOOL_NAME//|/_}
32+
DURATION=${DURATION//[!0-9]/}
33+
[[ -z "$DURATION" ]] && exit 0
34+
35+
printf '%s|%s\n' "$TOOL_NAME" "$DURATION" >> "$COUNTER" 2>/dev/null || true
36+
exit 0

scripts/sync_all.py

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
#!/usr/bin/env python3
2+
"""sync_all.py — additive sync of dotforge template + stack rules into every
3+
registered project.
4+
5+
Scope (additive only, by design — full merge is the interactive skill):
6+
- Adds missing template/hooks/*.sh files
7+
- Adds missing stack rules for each detected stack (per .forge-manifest.json)
8+
- Updates .forge-manifest.json: version + synced_at + per-file hashes
9+
- NEVER touches:
10+
* existing hook files (preserves project-local customizations)
11+
* .claude/settings.json (allow/deny merge needs human review)
12+
* CLAUDE.md (custom sections risk)
13+
* .claude/rules/domain/ (project-owned per sync-template skill)
14+
15+
For full merge sync (settings.json union, CLAUDE.md sections), use the
16+
interactive /forge sync skill per-project.
17+
18+
Usage:
19+
python3 scripts/sync_all.py # apply
20+
python3 scripts/sync_all.py --dry-run # preview
21+
"""
22+
from __future__ import annotations
23+
24+
import argparse
25+
import hashlib
26+
import json
27+
import shutil
28+
import sys
29+
from datetime import date
30+
from pathlib import Path
31+
32+
try:
33+
import yaml
34+
except ImportError:
35+
print("ERROR: pyyaml required", file=sys.stderr)
36+
sys.exit(2)
37+
38+
DOTFORGE = Path(__file__).resolve().parent.parent
39+
REGISTRY = DOTFORGE / "registry/projects.local.yml"
40+
TEMPLATE_HOOKS = DOTFORGE / "template/hooks"
41+
STACKS_DIR = DOTFORGE / "stacks"
42+
TODAY = date.today().isoformat()
43+
VERSION = (DOTFORGE / "VERSION").read_text().strip()
44+
45+
46+
def sha256(p: Path) -> str:
47+
return "sha256:" + hashlib.sha256(p.read_bytes()).hexdigest()
48+
49+
50+
def detect_stacks_from_manifest(claude_dir: Path) -> list[str]:
51+
m = claude_dir / ".forge-manifest.json"
52+
if not m.exists():
53+
return []
54+
try:
55+
return json.loads(m.read_text()).get("stacks", []) or []
56+
except Exception:
57+
return []
58+
59+
60+
def add_missing_hooks(claude_dir: Path, dry: bool) -> list[str]:
61+
hooks_dir = claude_dir / "hooks"
62+
if not hooks_dir.exists():
63+
return []
64+
added = []
65+
for tmpl in sorted(TEMPLATE_HOOKS.glob("*.sh")):
66+
target = hooks_dir / tmpl.name
67+
if not target.exists():
68+
if not dry:
69+
shutil.copy2(tmpl, target)
70+
target.chmod(0o755)
71+
added.append(tmpl.name)
72+
return added
73+
74+
75+
def add_missing_stack_rules(claude_dir: Path, stacks: list[str], dry: bool) -> list[str]:
76+
rules_dir = claude_dir / "rules"
77+
if not rules_dir.exists() or not stacks:
78+
return []
79+
added = []
80+
for stack in stacks:
81+
src_rules = STACKS_DIR / stack / "rules"
82+
if not src_rules.exists():
83+
continue
84+
for rule in sorted(src_rules.glob("*.md")):
85+
target = rules_dir / rule.name
86+
if not target.exists():
87+
if not dry:
88+
shutil.copy2(rule, target)
89+
added.append(f"{stack}/{rule.name}")
90+
return added
91+
92+
93+
def update_manifest(claude_dir: Path, stacks: list[str], dry: bool) -> bool:
94+
"""Refresh manifest: version + synced_at + per-file hashes for managed files."""
95+
manifest_path = claude_dir / ".forge-manifest.json"
96+
existing = {}
97+
if manifest_path.exists():
98+
try:
99+
existing = json.loads(manifest_path.read_text())
100+
except Exception:
101+
existing = {}
102+
103+
files = existing.get("files", {})
104+
# Hash each managed file currently in .claude/
105+
for hook in (claude_dir / "hooks").glob("*.sh") if (claude_dir / "hooks").exists() else []:
106+
rel = f".claude/hooks/{hook.name}"
107+
files[rel] = {"hash": sha256(hook), "source": "template"}
108+
for rule in (claude_dir / "rules").glob("*.md") if (claude_dir / "rules").exists() else []:
109+
rel = f".claude/rules/{rule.name}"
110+
existing_entry = files.get(rel)
111+
if not isinstance(existing_entry, dict):
112+
files[rel] = {"hash": sha256(rule), "source": "template+stacks"}
113+
else:
114+
existing_entry["hash"] = sha256(rule)
115+
116+
new_manifest = {
117+
"dotforge_version": VERSION,
118+
"synced_at": TODAY,
119+
"stacks": stacks,
120+
"files": files,
121+
"sync_method": "additive",
122+
"sync_note": "Settings.json + CLAUDE.md + domain/ NOT touched. Run interactive /forge sync per-project for full merge.",
123+
}
124+
125+
if dry:
126+
return True
127+
manifest_path.write_text(json.dumps(new_manifest, indent=2) + "\n")
128+
return True
129+
130+
131+
def main() -> int:
132+
ap = argparse.ArgumentParser()
133+
ap.add_argument("--dry-run", action="store_true")
134+
args = ap.parse_args()
135+
136+
with open(REGISTRY) as f:
137+
data = yaml.safe_load(f)
138+
139+
print(f"═══ ADDITIVE SYNC → v{VERSION} ═══")
140+
print(f"Dry-run: {args.dry_run}\n")
141+
142+
summary = []
143+
for proj in data["projects"]:
144+
name = proj["name"]
145+
path = Path(proj["path"])
146+
if str(path) == ".":
147+
path = DOTFORGE
148+
if not path.exists():
149+
print(f"── {name:<20} SKIP (missing path)")
150+
summary.append((name, "missing", 0, 0))
151+
continue
152+
claude_dir = path / ".claude"
153+
if not claude_dir.exists():
154+
print(f"── {name:<20} SKIP (no .claude/)")
155+
summary.append((name, "no .claude", 0, 0))
156+
continue
157+
158+
stacks = detect_stacks_from_manifest(claude_dir)
159+
added_hooks = add_missing_hooks(claude_dir, args.dry_run)
160+
added_rules = add_missing_stack_rules(claude_dir, stacks, args.dry_run)
161+
update_manifest(claude_dir, stacks, args.dry_run)
162+
163+
bits = []
164+
if added_hooks:
165+
bits.append(f"+{len(added_hooks)} hooks: {', '.join(added_hooks)}")
166+
if added_rules:
167+
bits.append(f"+{len(added_rules)} stack rules: {', '.join(added_rules)}")
168+
if not bits:
169+
bits.append("manifest refreshed only")
170+
171+
print(f"── {name:<20} {' | '.join(bits)}")
172+
summary.append((name, "synced", len(added_hooks), len(added_rules)))
173+
174+
print("\n═══ SUMMARY ═══")
175+
total_hooks = sum(s[2] for s in summary)
176+
total_rules = sum(s[3] for s in summary)
177+
synced = sum(1 for s in summary if s[1] == "synced")
178+
print(f"Projects synced: {synced}/{len(summary)}")
179+
print(f"Total hooks added: {total_hooks}")
180+
print(f"Total stack rules added: {total_rules}")
181+
print(f"Manifest version: v{VERSION}")
182+
if args.dry_run:
183+
print("\n(dry-run: no files written)")
184+
else:
185+
print("\nNote: settings.json / CLAUDE.md / domain/ NOT touched. Run")
186+
print(" interactive /forge sync per-project for full merge.")
187+
return 0
188+
189+
190+
if __name__ == "__main__":
191+
sys.exit(main())

0 commit comments

Comments
 (0)