Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed

- **ContextGuard resolved cognition paths against the anchor root instead of
the session directory**, so every guard read a path that does not exist.
`config.yaml` documents `capsule_path` / `checkpoint_path` / `handoff_path`
as relative to `.context/sessions/<CL_SESSION_ID>/`, but both Claude adapters
joined them onto `CL_ANCHOR` directly — `CL_SESSION_ID` appeared nowhere in
either hook. Lease expiry never fired, worker `forbidden_paths` never loaded,
and `require_capsule: true` would have blocked every tool call with "No active
capsule found" rather than enforcing anything. Both hooks now resolve against
`COGNITION_ROOT`, and the path defaults match the config convention.
- **`block()` emitted invalid JSON for any reason containing a Windows path.**
The payload was hand-built, so backslashes went through unescaped and `\U`
made it unparseable — a blocked call reached the operator with no reason
attached. Now serialized with `json.dumps`.

### Added

- Opportunistic auto-GC in `cl session start` (throttled to once per 24h
Expand Down
66 changes: 49 additions & 17 deletions adapters/claude/hooks/pre_tool_use.sh
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,20 @@ fi
REPO_ROOT="${CL_ANCHOR}"
CONFIG_FILE="${REPO_ROOT}/.context/config.yaml"

# --- Cognition root: session-scoped, not anchor-scoped ---
# config.yaml documents capsule/checkpoint/handoff paths as relative to
# .context/sessions/<CL_SESSION_ID>/. Resolving them against the anchor root
# instead pointed every guard at a directory that does not exist, so the
# capsule, lease, and forbidden-path checks all silently found nothing.
if [[ -n "${CL_SESSION_ID:-}" ]]; then
COGNITION_ROOT="${REPO_ROOT}/.context/sessions/${CL_SESSION_ID}"
else
# No session id: fall back to the anchor's .context/ so a mis-set env warns
# loudly rather than blocking every tool call.
COGNITION_ROOT="${REPO_ROOT}/.context"
echo "ContextGuard warning: CL_SESSION_ID is not set — cognition paths fall back to ${COGNITION_ROOT}. Run: cl session start <manifest>." >&2
fi

# --- Session marker (created on first tool call; stop.sh uses it to detect fresh checkpoints) ---
_SESSION_HASH="$(echo "$REPO_ROOT" | cksum | cut -d' ' -f1)"
SESSION_MARKER="/tmp/clp_session_${_SESSION_HASH}"
Expand All @@ -38,9 +52,9 @@ fi
# --- Load config (with defaults) ---
REQUIRE_CAPSULE=false
ENFORCE_LEASE=true
CAPSULE_PATH=".context/active/"
CHECKPOINT_PATH=".context/checkpoints/"
HANDOFF_PATH=".context/handoffs/"
CAPSULE_PATH="active/"
CHECKPOINT_PATH="checkpoints/"
HANDOFF_PATH="handoffs/"

if [[ -f "${CONFIG_FILE}" ]] && command -v python3 &>/dev/null; then
REQUIRE_CAPSULE=$(python3 -c "
Expand Down Expand Up @@ -70,25 +84,32 @@ try:
import yaml
with open('${CONFIG_FILE}') as f:
c = yaml.safe_load(f)
print(c.get('guard', {}).get('capsule_path', '.context/active/'))
print(c.get('guard', {}).get('capsule_path', 'active/'))
except Exception:
print('.context/active/')
" 2>/dev/null || echo ".context/active/")
print('active/')
" 2>/dev/null || echo "active/")

HANDOFF_PATH=$(python3 -c "
try:
import yaml
with open('${CONFIG_FILE}') as f:
c = yaml.safe_load(f)
print(c.get('guard', {}).get('handoff_path', '.context/handoffs/'))
print(c.get('guard', {}).get('handoff_path', 'handoffs/'))
except Exception:
print('.context/handoffs/')
" 2>/dev/null || echo ".context/handoffs/")
print('handoffs/')
" 2>/dev/null || echo "handoffs/")
fi

# Tolerate legacy config values that still carry the `.context/` prefix: the
# base is now COGNITION_ROOT, which already includes it. Without this, a config
# pinned to the old default resolves to `<anchor>/.context/.context/active/`.
CAPSULE_PATH="${CAPSULE_PATH#.context/}"
CHECKPOINT_PATH="${CHECKPOINT_PATH#.context/}"
HANDOFF_PATH="${HANDOFF_PATH#.context/}"

# --- Helper: find active capsule ---
find_active_capsule() {
local capsule_dir="${REPO_ROOT}/${CAPSULE_PATH}"
local capsule_dir="${COGNITION_ROOT}/${CAPSULE_PATH}"
if [[ -d "$capsule_dir" ]]; then
find "$capsule_dir" -name "*.yaml" -not -name ".gitkeep" | head -1
fi
Expand All @@ -112,8 +133,19 @@ except Exception:

# --- Helper: block with reason ---
block() {
local reason="$1"
echo "{\"decision\": \"block\", \"reason\": \"ContextGuard: ${reason}\"}"
# The reason routinely contains a filesystem path. On Windows those carry
# backslashes, which are invalid unescaped inside a JSON string — hand-built
# JSON produced an unparseable payload and the operator saw no reason at all.
local reason="ContextGuard: $1"
if command -v python3 &>/dev/null; then
CG_REASON="$reason" python3 -c "
import json, os
print(json.dumps({'decision': 'block', 'reason': os.environ['CG_REASON']}))
"
else
reason="${reason//\\/\\\\}"
echo "{\"decision\": \"block\", \"reason\": \"${reason//\"/\\\"}\"}"
fi
exit 2
}

Expand Down Expand Up @@ -148,7 +180,7 @@ fi

# --- Check: lease expiry ---
if [[ "$ENFORCE_LEASE" == "true" ]]; then
HANDOFF_DIR="${REPO_ROOT}/${HANDOFF_PATH}"
HANDOFF_DIR="${COGNITION_ROOT}/${HANDOFF_PATH}"
if [[ -d "$HANDOFF_DIR" ]]; then
ACTIVE_HANDOFF="$(find "$HANDOFF_DIR" -name "*.yaml" -not -name ".gitkeep" | head -1)"
if [[ -n "$ACTIVE_HANDOFF" ]]; then
Expand All @@ -173,7 +205,7 @@ if [[ "$TOOL_NAME" == "Write" || "$TOOL_NAME" == "Edit" ]]; then
fi

if [[ -n "$TARGET_PATH" ]]; then
HANDOFF_DIR="${REPO_ROOT}/${HANDOFF_PATH}"
HANDOFF_DIR="${COGNITION_ROOT}/${HANDOFF_PATH}"
if [[ -d "$HANDOFF_DIR" ]]; then
ACTIVE_HANDOFF="$(find "$HANDOFF_DIR" -name "*.yaml" -not -name ".gitkeep" | head -1)"
if [[ -n "$ACTIVE_HANDOFF" ]]; then
Expand Down Expand Up @@ -241,7 +273,7 @@ fi

# --- Check: pre_spawn — subagent budget ---
if [[ "$TOOL_NAME" == "Agent" ]]; then
HANDOFF_DIR="${REPO_ROOT}/${HANDOFF_PATH}"
HANDOFF_DIR="${COGNITION_ROOT}/${HANDOFF_PATH}"
if [[ -d "$HANDOFF_DIR" ]]; then
ACTIVE_HANDOFF="$(find "$HANDOFF_DIR" -name "*.yaml" -not -name ".gitkeep" | head -1)"
if [[ -n "$ACTIVE_HANDOFF" ]]; then
Expand All @@ -262,7 +294,7 @@ except Exception:
fi

# Check context_risk.high_parallelism from latest checkpoint
CHECKPOINT_DIR="${REPO_ROOT}/${CHECKPOINT_PATH}"
CHECKPOINT_DIR="${COGNITION_ROOT}/${CHECKPOINT_PATH}"
if [[ -d "$CHECKPOINT_DIR" ]]; then
LATEST_CHECKPOINT="$(find "$CHECKPOINT_DIR" -name "*.yaml" -not -name ".gitkeep" | sort | tail -1)"
if [[ -n "$LATEST_CHECKPOINT" ]]; then
Expand Down Expand Up @@ -300,7 +332,7 @@ except Exception:
fi

# --- context_risk flags from latest checkpoint ---
CHECKPOINT_DIR="${REPO_ROOT}/${CHECKPOINT_PATH}"
CHECKPOINT_DIR="${COGNITION_ROOT}/${CHECKPOINT_PATH}"
if [[ -d "$CHECKPOINT_DIR" ]]; then
LATEST_CHECKPOINT="$(find "$CHECKPOINT_DIR" -name "*.yaml" -not -name ".gitkeep" | sort | tail -1)"
if [[ -n "$LATEST_CHECKPOINT" ]]; then
Expand Down
36 changes: 24 additions & 12 deletions adapters/claude/hooks/stop.sh
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,21 @@ fi
REPO_ROOT="${CL_ANCHOR}"
CONFIG_FILE="${REPO_ROOT}/.context/config.yaml"

# --- Cognition root: session-scoped, not anchor-scoped (see pre_tool_use.sh) ---
if [[ -n "${CL_SESSION_ID:-}" ]]; then
COGNITION_ROOT="${REPO_ROOT}/.context/sessions/${CL_SESSION_ID}"
else
COGNITION_ROOT="${REPO_ROOT}/.context"
fi

# --- Session marker (written by pre_tool_use.sh on first tool call this session) ---
_SESSION_HASH="$(echo "$REPO_ROOT" | cksum | cut -d' ' -f1)"
SESSION_MARKER="/tmp/clp_session_${_SESSION_HASH}"

# --- Load config ---
CHECKPOINT_ON_STOP=true
CAPSULE_PATH=".context/active/"
CHECKPOINT_PATH=".context/checkpoints/"
CAPSULE_PATH="active/"
CHECKPOINT_PATH="checkpoints/"

if [[ -f "${CONFIG_FILE}" ]] && command -v python3 &>/dev/null; then
CHECKPOINT_ON_STOP=$(python3 -c "
Expand All @@ -45,22 +52,27 @@ try:
import yaml
with open('${CONFIG_FILE}') as f:
c = yaml.safe_load(f)
print(c.get('guard', {}).get('capsule_path', '.context/active/'))
print(c.get('guard', {}).get('capsule_path', 'active/'))
except Exception:
print('.context/active/')
" 2>/dev/null || echo ".context/active/")
print('active/')
" 2>/dev/null || echo "active/")

CHECKPOINT_PATH=$(python3 -c "
try:
import yaml
with open('${CONFIG_FILE}') as f:
c = yaml.safe_load(f)
print(c.get('guard', {}).get('checkpoint_path', '.context/checkpoints/'))
print(c.get('guard', {}).get('checkpoint_path', 'checkpoints/'))
except Exception:
print('.context/checkpoints/')
" 2>/dev/null || echo ".context/checkpoints/")
print('checkpoints/')
" 2>/dev/null || echo "checkpoints/")
fi

# Tolerate legacy config values that still carry the `.context/` prefix (see
# pre_tool_use.sh) — COGNITION_ROOT already includes it.
CAPSULE_PATH="${CAPSULE_PATH#.context/}"
CHECKPOINT_PATH="${CHECKPOINT_PATH#.context/}"

# --- Helper: warn ---
warn() {
echo "ContextGuard warning: $1" >&2
Expand All @@ -71,7 +83,7 @@ warn() {
# timestamp reference. find -newer detects only checkpoints written after session start.
# Falls back to existence check if the marker is absent (session without tool calls).

CHECKPOINT_DIR="${REPO_ROOT}/${CHECKPOINT_PATH}"
CHECKPOINT_DIR="${COGNITION_ROOT}/${CHECKPOINT_PATH}"
CHECKPOINT_FOUND=false

if [[ -d "$CHECKPOINT_DIR" ]]; then
Expand All @@ -91,7 +103,7 @@ fi
if [[ "$CHECKPOINT_FOUND" == "false" ]]; then
if [[ "$CHECKPOINT_ON_STOP" == "true" ]]; then
echo "ContextGuard: Session ending without a LoopCheckpoint. Write a checkpoint before terminating." >&2
echo " Create: .context/checkpoints/<checkpoint-id>.yaml" >&2
echo " Create: ${CHECKPOINT_DIR}/<checkpoint-id>.yaml" >&2
echo " Template: .context/templates/loop_checkpoint.template.yaml" >&2
# Non-fatal warn — Claude Code Stop hooks can't hard-block session end in all cases
# but surfacing this prominently is the enforcement mechanism
Expand All @@ -101,7 +113,7 @@ if [[ "$CHECKPOINT_FOUND" == "false" ]]; then
fi

# --- Check: active capsule not updated ---
CAPSULE_DIR="${REPO_ROOT}/${CAPSULE_PATH}"
CAPSULE_DIR="${COGNITION_ROOT}/${CAPSULE_PATH}"
if [[ -d "$CAPSULE_DIR" ]]; then
ACTIVE_CAPSULE=$(find "$CAPSULE_DIR" -name "*.yaml" -not -name ".gitkeep" | head -1)
if [[ -n "$ACTIVE_CAPSULE" ]] && command -v python3 &>/dev/null; then
Expand Down Expand Up @@ -139,7 +151,7 @@ except Exception:
fi

if [[ "$INJECT_ENABLED" == "true" ]]; then
CAPSULE_DIR="${REPO_ROOT}/${CAPSULE_PATH}"
CAPSULE_DIR="${COGNITION_ROOT}/${CAPSULE_PATH}"
if [[ -d "$CAPSULE_DIR" ]] && command -v python3 &>/dev/null; then
ACTIVE_CAPSULE=$(find "$CAPSULE_DIR" -name "*.yaml" -not -name ".gitkeep" | head -1)
if [[ -n "$ACTIVE_CAPSULE" ]]; then
Expand Down
Loading