Initial public release

A chezmoi-based fleet-dotfiles template for macOS workstations:

- Two-way auto-sync via launchd watcher + 5-min puller
- Mesh SSH via modify_authorized_keys driven by .chezmoidata/fleet.yaml
- age-encrypted secrets file
- Bundled Claude Code agentic team (11 agents) + /lite + /lite-sub commands
- Verify-before-claiming Stop hook
- Generic statusline + project-boundary validate-path hook
- Reference launchd plist for cross-fleet task-durations aggregation
  (companion repo: gitea.tojo.team/cardinale/task-durations)
- AGENTS.md walks an agent through the entire setup Q&A interactively
- docs/ covers architecture, security model, fleet onboarding
This commit is contained in:
Anthony Cardinale
2026-05-02 17:26:32 -04:00
commit ebccdda936
42 changed files with 2994 additions and 0 deletions
@@ -0,0 +1,43 @@
#!/usr/bin/env node
// Hook script to validate file paths are within PROJECT_PATH
// Generic project-boundary enforcement: requires PROJECT_PATH env var to be set;
const path = require('path');
const projectPath = process.env.PROJECT_PATH;
if (!projectPath) process.exit(0);
let input = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', chunk => { input += chunk; });
process.stdin.on('end', () => {
try {
const data = JSON.parse(input);
const tool = data.tool_name || data.tool || '';
const toolInput = data.tool_input || {};
let filePath = '';
if (['Write', 'Edit', 'Read'].includes(tool)) {
filePath = toolInput.file_path || '';
} else {
process.exit(0);
}
if (!filePath) process.exit(0);
const resolvedFile = path.resolve(filePath);
const resolvedProject = path.resolve(projectPath);
if (resolvedFile.startsWith(resolvedProject + path.sep) || resolvedFile === resolvedProject) {
process.exit(0);
}
if (resolvedFile.startsWith('/tmp/') || resolvedFile.startsWith('/tmp')) {
process.exit(0);
}
process.stderr.write('Security: Cannot access "' + filePath + '" - outside project directory "' + projectPath + '"\n');
process.exit(2);
} catch (e) {
process.exit(0);
}
});
@@ -0,0 +1,193 @@
#!/usr/bin/env python3
"""
Stop hook: enforces CLAUDE.md's "Verify Before Claiming" rule.
Blocks the turn if the final assistant message contains hedging language
that signals unverified factual claims, without either:
(a) an inline [unverified] tag, or
(b) a verifying tool call (WebSearch, WebFetch, Read, Bash, Grep, Glob) in the turn.
Improvements over v1 (2026-04-18, informed by Critic + Research Scout agents):
- Uses `assistant_message` from payload instead of parsing transcript
- Removed false-positive hedges: "should be", "must be", "seems", "appears to"
- Added real hedging signals: "I believe", "I think", "IIRC", "might be", etc.
- Removed Edit/Write/NotebookEdit from VERIFYING_TOOLS (mutation != verification)
- Narrowed MCP bypass to search/fetch/query tools only
- Skips short responses (<100 chars) as trivial
- Skips mid-turn stops (stop_reason == "tool_use")
"""
import json
import os
import re
import sys
# Hedging language that signals unverified factual claims.
# These are phrases people use when citing from memory, not from evidence.
HEDGES = [
r"I believe",
r"I think (?:it|the|this|that)",
r"if I recall",
r"IIRC",
r"as far as I know",
r"AFAIK",
r"from memory",
r"from what I remember",
r"might be",
r"could be",
r"probably",
r"likely",
r"presumably",
r"plausibly",
r"my understanding is",
]
# Tools that constitute actual verification of a claim.
# Mutation tools (Edit, Write) do NOT verify anything.
VERIFYING_TOOLS = {
"Bash", "Read", "Grep", "Glob", "WebSearch", "WebFetch",
}
# MCP tools that count as verification (must contain one of these substrings)
MCP_VERIFY_SUBSTRINGS = {"search", "fetch", "query", "get", "list", "read"}
# Minimum response length to check. Short responses are trivial.
MIN_RESPONSE_LENGTH = 100
def main() -> int:
try:
payload = json.load(sys.stdin)
except json.JSONDecodeError:
return 0
# Bypass: env var override
if os.environ.get("CLAUDE_SKIP_VERIFY_HOOK"):
return 0
# Bypass: already in forced-continuation (prevents infinite loops)
if payload.get("stop_hook_active"):
return 0
# Bypass: Claude is mid-turn (making a tool call, not presenting final answer)
if payload.get("stop_reason") == "tool_use":
return 0
# Get the assistant message — prefer payload field over transcript parsing
text = payload.get("assistant_message") or payload.get("last_assistant_message") or ""
# Fallback: parse transcript if payload doesn't have the message
if not text:
transcript_path = payload.get("transcript_path")
if transcript_path and os.path.isfile(transcript_path):
text = _extract_last_assistant_text(transcript_path)
if not text or len(text.strip()) < MIN_RESPONSE_LENGTH:
return 0
# Strip code blocks and inline code before scanning
text_clean = re.sub(r"```.*?```", "", text, flags=re.DOTALL)
text_clean = re.sub(r"`[^`\n]+`", "", text_clean)
# Strip blockquotes (lines starting with >)
text_clean = re.sub(r"^>.*$", "", text_clean, flags=re.MULTILINE)
# Bypass: explicit [unverified] tag present
if re.search(r"\[unverified\]", text_clean, re.IGNORECASE):
return 0
# Check for hedging language
pattern = re.compile(
r"(?:" + "|".join(HEDGES) + r")",
re.IGNORECASE,
)
matches = sorted({m.group(0).lower() for m in pattern.finditer(text_clean)})
if not matches:
return 0
# Check if any verifying tool was called in this turn
if _turn_has_verification(payload):
return 0
# Hedging found, no verification, no [unverified] tag — block
reason = (
"Hedging language detected without [unverified] tags or tool "
f"verification in this turn: {', '.join(matches)}. "
"Per your CLAUDE.md 'Verify Before Claiming' rule: either "
"(a) verify the claim via WebSearch/Bash/Read, or (b) tag it "
"inline, e.g. 'X supports Y [unverified]'. Revise before "
"ending the turn."
)
out = {"decision": "block", "reason": reason}
print(json.dumps(out))
return 0
def _turn_has_verification(payload: dict) -> bool:
"""Check if any verifying tool was called in the current turn."""
transcript_path = payload.get("transcript_path")
if not transcript_path or not os.path.isfile(transcript_path):
return False
try:
with open(transcript_path, "r", encoding="utf-8") as f:
entries = [json.loads(line) for line in f if line.strip()]
except (json.JSONDecodeError, OSError):
return False
# Find the last user message index
last_user_idx = -1
for i, entry in enumerate(entries):
if entry.get("type") == "user":
last_user_idx = i
# Check assistant messages after the last user message for tool calls
for entry in entries[last_user_idx + 1:]:
if entry.get("type") != "assistant":
continue
content = entry.get("message", {}).get("content", [])
if not isinstance(content, list):
continue
for block in content:
if not isinstance(block, dict) or block.get("type") != "tool_use":
continue
name = block.get("name", "")
# Direct tool match
if name in VERIFYING_TOOLS:
return True
# MCP tool match — only if the tool name suggests verification
if name.startswith("mcp__"):
name_lower = name.lower()
if any(sub in name_lower for sub in MCP_VERIFY_SUBSTRINGS):
return True
return False
def _extract_last_assistant_text(transcript_path: str) -> str:
"""Fallback: extract last assistant message text from transcript JSONL."""
try:
with open(transcript_path, "r", encoding="utf-8") as f:
entries = [json.loads(line) for line in f if line.strip()]
except (json.JSONDecodeError, OSError):
return ""
for entry in reversed(entries):
if entry.get("type") != "assistant":
continue
content = entry.get("message", {}).get("content", [])
if isinstance(content, str):
return content
if isinstance(content, list):
return "\n".join(
b.get("text", "")
for b in content
if isinstance(b, dict) and b.get("type") == "text"
)
return ""
if __name__ == "__main__":
sys.exit(main())