fix(observe): 5-layer automated session guard to prevent self-loop observations#399
Conversation
…p observations
observe.sh currently fires for ALL hook events including automated/programmatic
sessions: the ECC observer's own Haiku analysis runs, claude-mem observer
sessions, CI pipelines, and any other tool that spawns `claude --print`.
This causes an infinite feedback loop where automated sessions generate
observations that trigger more automated analysis, burning Haiku tokens with
no human activity.
Add a 5-layer guard block after the `disabled` check:
Layer 1: agent_id payload field — only present in subagent hooks; skip any
subagent-scoped session (always automated by definition).
Layer 2: CLAUDE_CODE_ENTRYPOINT env var — Claude Code sets this to sdk-ts,
sdk-py, sdk-cli, mcp, or remote for programmatic/SDK invocations.
Skip if any non-cli entrypoint is detected. This is universal: catches
any tool using the Anthropic SDK without requiring tool cooperation.
Layer 3: ECC_HOOK_PROFILE=minimal — existing ECC mechanism; respect it here
to suppress non-essential hooks in observer contexts.
Layer 4: ECC_SKIP_OBSERVE=1 — cooperative env var any external tool can set
before spawning automated sessions (explicit opt-out contract).
Layer 5: CWD path exclusions — skip sessions whose working directory matches
known observer-session path patterns. Configurable via
ECC_OBSERVE_SKIP_PATHS (comma-separated substrings, default:
"observer-sessions,.claude-mem").
Also fix observer-loop.sh to set ECC_SKIP_OBSERVE=1 and ECC_HOOK_PROFILE=minimal
before spawning the Haiku analysis subprocess, making the observer loop
self-aware and closing the ECC→ECC self-observation loop without needing
external coordination.
Fixes: observe.sh fires unconditionally on automated sessions (affaan-m#398)
|
Caution Review failedPull request was closed or merged during review 📝 WalkthroughWalkthroughAdds cooperative guards to the observer flow: Changes
Sequence Diagram(s)sequenceDiagram
participant ObserverLoop as ObserverLoop (agent script)
participant Claude as Claude (haiku)
participant Hook as observe.sh
participant FS as Filesystem/Env
ObserverLoop->>FS: export ECC_SKIP_OBSERVE=1, ECC_HOOK_PROFILE=minimal
ObserverLoop->>Claude: spawn Claude --model haiku (stdin prompt)
Claude->>Hook: invoke hook with INPUT_JSON / stdin (tool call)
Hook->>FS: read ECC_SKIP_OBSERVE / ECC_HOOK_PROFILE / STDIN_CWD
alt any guard matches
Hook-->>Claude: exit 0 (skip observation)
else
Hook->>FS: run observation/purge logic (record observations)
Hook-->>Claude: finish processing
end
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~20 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
📝 Coding Plan
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Pull request overview
Adds multi-signal filtering to prevent continuous-learning-v2’s observation hook from running during automated/programmatic Claude sessions, avoiding ECC self-observation loops and third-party tool noise.
Changes:
- Add a 5-layer “automated session guard” block to
hooks/observe.shto early-exit for non-human sessions. - Update
agents/observer-loop.shto run Haiku analysis withECC_SKIP_OBSERVE=1andECC_HOOK_PROFILE=minimalto break ECC→ECC recursion.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| skills/continuous-learning-v2/hooks/observe.sh | Adds layered early-exit checks to skip observation in automated contexts (subagents/SDK entrypoints/minimal profile/explicit skip/path filters). |
| skills/continuous-learning-v2/agents/observer-loop.sh | Sets env vars on the Haiku subprocess so the observer’s own analysis doesn’t generate new observations. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
You can also share your feedback on Copilot code review. Take the survey.
| if [ -n "$STDIN_CWD" ]; then | ||
| IFS=',' read -ra _ECC_SKIP_ARRAY <<< "$_ECC_SKIP_PATHS" | ||
| for _pattern in "${_ECC_SKIP_ARRAY[@]}"; do | ||
| case "$STDIN_CWD" in *"$_pattern"*) exit 0 ;; esac |
There was a problem hiding this comment.
Layer 5 path skipping can unintentionally disable all observations if ECC_OBSERVE_SKIP_PATHS contains an empty entry (e.g. trailing comma foo, or double comma foo,,bar). In that case _pattern becomes empty and the case "$STDIN_CWD" in *"$_pattern"*) branch matches every path. Consider trimming whitespace and skipping empty patterns before doing the match.
| case "$STDIN_CWD" in *"$_pattern"*) exit 0 ;; esac | |
| # Trim whitespace and skip empty patterns to avoid matching all paths | |
| _trimmed_pattern="${_pattern//[[:space:]]/}" | |
| [ -z "$_trimmed_pattern" ] && continue | |
| case "$STDIN_CWD" in *"$_trimmed_pattern"*) exit 0 ;; esac |
| # Layer 1: Skip subagent sessions — agent_id is only present when a hook fires | ||
| # inside a subagent (automated by definition, never a human interactive session) | ||
| _ECC_AGENT_ID=$(echo "$INPUT_JSON" | "$PYTHON_CMD" -c "import json,sys; print(json.load(sys.stdin).get('agent_id',''))" 2>/dev/null || true) | ||
| [ -n "$_ECC_AGENT_ID" ] && exit 0 | ||
|
|
||
| # Layer 2: CLAUDE_CODE_ENTRYPOINT — set by Claude Code itself to indicate how | ||
| # it was invoked. Non-interactive SDK/programmatic sessions use sdk-ts, sdk-py, | ||
| # sdk-cli, mcp, or remote. Interactive terminal sessions use "cli". | ||
| # This universally catches automation from ANY tool using the Anthropic SDK | ||
| # without requiring that tool to set any special env vars. | ||
| case "${CLAUDE_CODE_ENTRYPOINT:-cli}" in | ||
| sdk-ts|sdk-py|sdk-cli|mcp|remote) exit 0 ;; | ||
| esac | ||
|
|
||
| # Layer 3: Respect ECC_HOOK_PROFILE=minimal — suppresses non-essential hooks | ||
| [ "${ECC_HOOK_PROFILE:-standard}" = "minimal" ] && exit 0 |
There was a problem hiding this comment.
The guard ordering does an extra JSON parse subprocess for agent_id before checking cheaper env-var based skips (CLAUDE_CODE_ENTRYPOINT, ECC_HOOK_PROFILE, ECC_SKIP_OBSERVE). To minimize overhead on automated runs (and match the “cheapest-first” intent), consider checking env vars first and/or extracting agent_id in the earlier Python parse that already reads the hook JSON.
There was a problem hiding this comment.
🧹 Nitpick comments (1)
skills/continuous-learning-v2/hooks/observe.sh (1)
130-138: Consider trimming whitespace from user-supplied patterns (optional).If a user configures
ECC_OBSERVE_SKIP_PATHS="path1, path2"(space after comma), the second pattern becomes" path2"with a leading space, which may cause unexpected matching failures. The default value doesn't have this issue.🔧 Optional fix to trim whitespace from patterns
_ECC_SKIP_PATHS="${ECC_OBSERVE_SKIP_PATHS:-observer-sessions,.claude-mem}" if [ -n "$STDIN_CWD" ]; then IFS=',' read -ra _ECC_SKIP_ARRAY <<< "$_ECC_SKIP_PATHS" for _pattern in "${_ECC_SKIP_ARRAY[@]}"; do + _pattern="${_pattern#"${_pattern%%[![:space:]]*}"}" # trim leading + _pattern="${_pattern%"${_pattern##*[![:space:]]}"}" # trim trailing case "$STDIN_CWD" in *"$_pattern"*) exit 0 ;; esac done fi🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@skills/continuous-learning-v2/hooks/observe.sh` around lines 130 - 138, The skip-path matching can fail when ECC_OBSERVE_SKIP_PATHS contains spaces (e.g., "path1, path2") because _pattern will include leading/trailing whitespace; update the splitting/loop that uses _ECC_SKIP_PATHS and _ECC_SKIP_ARRAY so each _pattern is trimmed of leading and trailing whitespace before the case match against STDIN_CWD (i.e., after reading into _ECC_SKIP_ARRAY, for each _pattern perform a trim of whitespace into a new variable and use that trimmed variable in the case check). Ensure you reference and trim the values of _pattern (from _ECC_SKIP_ARRAY) derived from ECC_OBSERVE_SKIP_PATHS/_ECC_SKIP_PATHS so matching is robust.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Nitpick comments:
In `@skills/continuous-learning-v2/hooks/observe.sh`:
- Around line 130-138: The skip-path matching can fail when
ECC_OBSERVE_SKIP_PATHS contains spaces (e.g., "path1, path2") because _pattern
will include leading/trailing whitespace; update the splitting/loop that uses
_ECC_SKIP_PATHS and _ECC_SKIP_ARRAY so each _pattern is trimmed of leading and
trailing whitespace before the case match against STDIN_CWD (i.e., after reading
into _ECC_SKIP_ARRAY, for each _pattern perform a trim of whitespace into a new
variable and use that trimmed variable in the case check). Ensure you reference
and trim the values of _pattern (from _ECC_SKIP_ARRAY) derived from
ECC_OBSERVE_SKIP_PATHS/_ECC_SKIP_PATHS so matching is robust.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 0d3f987c-0d8b-4f08-a47a-26ac210dedbf
📒 Files selected for processing (2)
skills/continuous-learning-v2/agents/observer-loop.shskills/continuous-learning-v2/hooks/observe.sh
…, fix empty pattern bug Two issues flagged by Copilot and CodeRabbit in PR affaan-m#399: 1. Layer ordering: the agent_id check spawns a Python subprocess but ran before the cheap env-var checks (CLAUDE_CODE_ENTRYPOINT, ECC_HOOK_PROFILE, ECC_SKIP_OBSERVE). Reorder to put all env-var checks first (Layers 1-3), then the subprocess-requiring agent_id check (Layer 4). Automated sessions that set env vars — the common case — now exit without spawning Python. 2. Empty pattern bug in Layer 5: if ECC_OBSERVE_SKIP_PATHS contains a trailing comma or spaces after commas (e.g. "path1, path2" or "path1,"), _pattern becomes empty or whitespace-only, and the glob *""* matches every CWD, silently disabling all observations. Fix: trim leading/trailing whitespace from each pattern and skip empty patterns with `continue`.
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@skills/continuous-learning-v2/hooks/observe.sh`:
- Around line 116-118: The current case on CLAUDE_CODE_ENTRYPOINT only lists a
finite set of non-cli values (sdk-ts|sdk-py|sdk-cli|mcp|remote) and will miss
any future non-`cli` entrypoints, allowing them to bypass the automation guard;
change the logic to explicitly allow only the `cli` entrypoint to proceed and
exit for any other value (i.e., treat anything not equal to "cli" as a guarded
case), updating the block that references CLAUDE_CODE_ENTRYPOINT and the
existing exit 0 use so new entrypoints automatically fall under the guard
without enumerating them.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 6389bf11-c495-4f81-bdd0-784791806a62
📒 Files selected for processing (1)
skills/continuous-learning-v2/hooks/observe.sh
CI Status NoteThe two failing checks are pre-existing failures on
This PR only modifies two |
|
Review outcome from local verification:
Remaining finding:
Safer shape: case "${CLAUDE_CODE_ENTRYPOINT:-cli}" in
cli) ;;
*) exit 0 ;;
esacThat keeps the unset fallback interactive, but treats every explicit non- Local recommendation: |
affaan-m
left a comment
There was a problem hiding this comment.
Reviewing this retrospectively against merged head 5466281.
The core fix is directionally correct and it solved the primary problem: automated observer sessions no longer write observations, and observer-loop.sh now marks its Haiku subprocess with ECC_SKIP_OBSERVE=1 and ECC_HOOK_PROFILE=minimal.
Two follow-ups are still warranted:
-
Project-registry side effects still happen before the guard exits.
observe.shextractscwdand sourcesdetect-project.shbefore it reaches the new automated-session guard block.detect-project.sheagerly creates homunculus project directories and updatesprojects.json, so automated sessions still leave metadata behind even though no observation is recorded. I reproduced this withCLAUDE_CODE_ENTRYPOINT=mcp,ECC_HOOK_PROFILE=minimal,ECC_SKIP_OBSERVE=1,agent_id, and skip-path payloads: each run exited0, wrote0observations, but still created one project directory and one registry entry. -
The new guard matrix needs direct regression coverage.
The existing hook suite still covers the legacytool_outputfallback path, but not the new guard branches forCLAUDE_CODE_ENTRYPOINT,ECC_HOOK_PROFILE=minimal,ECC_SKIP_OBSERVE,agent_id, or trimmedECC_OBSERVE_SKIP_PATHS.
Suggested follow-up:
- Move the automated-session guard block ahead of project detection, or add a no-side-effects project-detection path for skipped sessions.
- Add explicit hook tests for each new guard branch so the loop-prevention behavior stays locked in.
Validation I ran on the merged PR head:
node tests/hooks/hooks.test.js->204 passed, 0 failed- Confirmed normal
CLAUDE_CODE_ENTRYPOINT=clistill records observations. - Confirmed guarded paths suppress observation writes.
…servations (affaan-m#399) * fix(observe): add 5-layer automated session guard to prevent self-loop observations observe.sh currently fires for ALL hook events including automated/programmatic sessions: the ECC observer's own Haiku analysis runs, claude-mem observer sessions, CI pipelines, and any other tool that spawns `claude --print`. This causes an infinite feedback loop where automated sessions generate observations that trigger more automated analysis, burning Haiku tokens with no human activity. Add a 5-layer guard block after the `disabled` check: Layer 1: agent_id payload field — only present in subagent hooks; skip any subagent-scoped session (always automated by definition). Layer 2: CLAUDE_CODE_ENTRYPOINT env var — Claude Code sets this to sdk-ts, sdk-py, sdk-cli, mcp, or remote for programmatic/SDK invocations. Skip if any non-cli entrypoint is detected. This is universal: catches any tool using the Anthropic SDK without requiring tool cooperation. Layer 3: ECC_HOOK_PROFILE=minimal — existing ECC mechanism; respect it here to suppress non-essential hooks in observer contexts. Layer 4: ECC_SKIP_OBSERVE=1 — cooperative env var any external tool can set before spawning automated sessions (explicit opt-out contract). Layer 5: CWD path exclusions — skip sessions whose working directory matches known observer-session path patterns. Configurable via ECC_OBSERVE_SKIP_PATHS (comma-separated substrings, default: "observer-sessions,.claude-mem"). Also fix observer-loop.sh to set ECC_SKIP_OBSERVE=1 and ECC_HOOK_PROFILE=minimal before spawning the Haiku analysis subprocess, making the observer loop self-aware and closing the ECC→ECC self-observation loop without needing external coordination. Fixes: observe.sh fires unconditionally on automated sessions (affaan-m#398) * fix(observe): address review feedback — reorder guards cheapest-first, fix empty pattern bug Two issues flagged by Copilot and CodeRabbit in PR affaan-m#399: 1. Layer ordering: the agent_id check spawns a Python subprocess but ran before the cheap env-var checks (CLAUDE_CODE_ENTRYPOINT, ECC_HOOK_PROFILE, ECC_SKIP_OBSERVE). Reorder to put all env-var checks first (Layers 1-3), then the subprocess-requiring agent_id check (Layer 4). Automated sessions that set env vars — the common case — now exit without spawning Python. 2. Empty pattern bug in Layer 5: if ECC_OBSERVE_SKIP_PATHS contains a trailing comma or spaces after commas (e.g. "path1, path2" or "path1,"), _pattern becomes empty or whitespace-only, and the glob *""* matches every CWD, silently disabling all observations. Fix: trim leading/trailing whitespace from each pattern and skip empty patterns with `continue`. * fix: fail closed for non-cli entrypoints --------- Co-authored-by: Affaan Mustafa <affaan@dcube.ai>
…servations (affaan-m#399) * fix(observe): add 5-layer automated session guard to prevent self-loop observations observe.sh currently fires for ALL hook events including automated/programmatic sessions: the ECC observer's own Haiku analysis runs, claude-mem observer sessions, CI pipelines, and any other tool that spawns `claude --print`. This causes an infinite feedback loop where automated sessions generate observations that trigger more automated analysis, burning Haiku tokens with no human activity. Add a 5-layer guard block after the `disabled` check: Layer 1: agent_id payload field — only present in subagent hooks; skip any subagent-scoped session (always automated by definition). Layer 2: CLAUDE_CODE_ENTRYPOINT env var — Claude Code sets this to sdk-ts, sdk-py, sdk-cli, mcp, or remote for programmatic/SDK invocations. Skip if any non-cli entrypoint is detected. This is universal: catches any tool using the Anthropic SDK without requiring tool cooperation. Layer 3: ECC_HOOK_PROFILE=minimal — existing ECC mechanism; respect it here to suppress non-essential hooks in observer contexts. Layer 4: ECC_SKIP_OBSERVE=1 — cooperative env var any external tool can set before spawning automated sessions (explicit opt-out contract). Layer 5: CWD path exclusions — skip sessions whose working directory matches known observer-session path patterns. Configurable via ECC_OBSERVE_SKIP_PATHS (comma-separated substrings, default: "observer-sessions,.claude-mem"). Also fix observer-loop.sh to set ECC_SKIP_OBSERVE=1 and ECC_HOOK_PROFILE=minimal before spawning the Haiku analysis subprocess, making the observer loop self-aware and closing the ECC→ECC self-observation loop without needing external coordination. Fixes: observe.sh fires unconditionally on automated sessions (affaan-m#398) * fix(observe): address review feedback — reorder guards cheapest-first, fix empty pattern bug Two issues flagged by Copilot and CodeRabbit in PR affaan-m#399: 1. Layer ordering: the agent_id check spawns a Python subprocess but ran before the cheap env-var checks (CLAUDE_CODE_ENTRYPOINT, ECC_HOOK_PROFILE, ECC_SKIP_OBSERVE). Reorder to put all env-var checks first (Layers 1-3), then the subprocess-requiring agent_id check (Layer 4). Automated sessions that set env vars — the common case — now exit without spawning Python. 2. Empty pattern bug in Layer 5: if ECC_OBSERVE_SKIP_PATHS contains a trailing comma or spaces after commas (e.g. "path1, path2" or "path1,"), _pattern becomes empty or whitespace-only, and the glob *""* matches every CWD, silently disabling all observations. Fix: trim leading/trailing whitespace from each pattern and skip empty patterns with `continue`. * fix: fail closed for non-cli entrypoints --------- Co-authored-by: Affaan Mustafa <affaan@dcube.ai>
Problem
observe.shfires for all PostToolUse hook events indiscriminately, including automated/programmatic sessions:claude --printare treated as observation-worthy human sessions.CLAUDECODE=1is often unset by automated callers to bypass the nested session guard, making it useless for detection. There was no reliable way to filter automated sessions.Verified in the wild:
~/.claude/homunculus/projects/*/observer.logshowed 2,534+ consecutiveError: Reached max turns (3)entries — every single analysis attempt failed — yet the observation loop kept running and re-triggering.Solution
Add a 5-layer guard block immediately after the
disabledfile check inobserve.sh. Each layer catches a different class of automated session:Layer 1:
agent_idpayload fieldThe PostToolUse JSON payload includes
agent_idonly when the hook fires inside a subagent (Task tool dispatch). Subagents are automated by definition — skip them.Layer 2:
CLAUDE_CODE_ENTRYPOINTenv var (universal — no tool cooperation needed)Claude Code itself sets
CLAUDE_CODE_ENTRYPOINTto the invocation method:cli→ interactive terminal (human session — observe this)sdk-ts,sdk-py,sdk-cli→ programmatic SDK invocation (skip)mcp,remote→ MCP server / remote session (skip)This catches any tool using the Anthropic SDK without requiring that tool to set any special env vars. Works universally for claude-mem, CI runners, custom scripts, and future tools not yet written.
Layer 3:
ECC_HOOK_PROFILE=minimalExisting ECC mechanism — respect it in
observe.shto suppress non-essential hooks when the observer itself sets a minimal profile.Layer 4:
ECC_SKIP_OBSERVE=1Cooperative env var contract: any external tool can
export ECC_SKIP_OBSERVE=1before spawning automated sessions to explicitly opt out. Documents a clear API for tool authors.Layer 5: CWD path exclusions
Skip sessions whose working directory matches known observer-session path patterns. Configurable via
ECC_OBSERVE_SKIP_PATHS(comma-separated substrings). Default includesobserver-sessionsand.claude-mem.Self-referential fix in
observer-loop.shThe Haiku subprocess spawned by
observer-loop.shneeds to close the ECC→ECC loop without depending onCLAUDE_CODE_ENTRYPOINT(which may becliif invoked via raw subprocess). Set both env vars explicitly before the claude invocation:Why multiple layers?
No single signal covers all cases:
agent_id: only present for subagents, not top-levelclaude --printCLAUDE_CODE_ENTRYPOINT: catches SDK callers, but raw subprocess invocations still showcliECC_HOOK_PROFILE: requires ECC to set it (covered by observer-loop fix)ECC_SKIP_OBSERVE: requires caller cooperation (covered by observer-loop fix; available for any tool)The layers are ordered cheapest-first (env var checks before subprocess spawning).
Test plan
claude --printvia Anthropic Python SDK: Layer 2 exits early onsdk-pyagent_idECC_SKIP_OBSERVE=1set by third-party tool: Layer 4 exits early~/.claude-mem/observer-session/xyz: Layer 5 exits earlyRelated
mainbranch (post-v1.8.0)🤖 Generated with Claude Code
Summary by CodeRabbit
Summary by cubic
Prevents self-loop observations and automated session noise by adding a 5-layer guard in
observe.shand making the observer loop opt out by default. Fails closed for any non-cliCLAUDE_CODE_ENTRYPOINTto catch future automated entrypoints.observe.sh, ordered cheapest-first:CLAUDE_CODE_ENTRYPOINT(fail closed for any non-cli),ECC_HOOK_PROFILE=minimal,ECC_SKIP_OBSERVE=1,agent_id, and CWD exclusions viaECC_OBSERVE_SKIP_PATHS(defaults toobserver-sessions,.claude-mem; trims whitespace and skips empty patterns).observer-loop.shto exportECC_SKIP_OBSERVE=1andECC_HOOK_PROFILE=minimalbefore callingclaude, preventing the ECC→ECC self-observation loop.Written for commit 5466281. Summary will update on new commits.