68 Commits

  • fix: port continuous-learning observer fixes
    Ports continuous-learning observer signal, storage, remote normalization, and v1 deprecation fixes onto current main.
  • fix(observe): skip Windows AppInstallerPythonRedirector.exe in resolve_python_cmd (#1511)
    On Windows 10/11 without Python installed from the Microsoft Store, the
    "App Execution Alias" stubs at %LOCALAPPDATA%\Microsoft\WindowsApps\python.exe
    and python3.exe are symlinks to AppInstallerPythonRedirector.exe. These
    stubs neither launch Python nor honor `-c`; calls print a bare "Python "
    line and exit, silently breaking every JSON-parsing step in observe.sh.
    
    Net effect: observations.jsonl is never written, CLV2 appears installed
    correctly, and the only residual artifact is `.last-purge`.
    
    This commit:
      1. Adds `_is_windows_app_installer_stub` helper that detects the stub
         via `command -v` output and optional `readlink -f` resolution.
      2. Teaches `resolve_python_cmd` to skip stub candidates and fall
         through to the next real interpreter (typically C:\...\Python3xx\python.exe).
      3. Exports the stub-aware CLV2_PYTHON_CMD before sourcing
         detect-project.sh, which already honors an already-set value,
         so the shared helper does not re-resolve and re-select the stub.
    
    POSIX-compatible. No behavior change on macOS / Linux / WSL where no
    such stub exists.
    
    Refs: observations.jsonl empty on Windows Claude Desktop users.
  • fix: make detect-project.sh locale-independent and handle Windows backslash paths
    Two bugs in skills/continuous-learning-v2/scripts/detect-project.sh that
    silently split the same project into multiple project_id records:
    
    1. Locale-dependent SHA-256 input (HIGH)
       The project_id hash was computed with
         printf '%s' "$hash_input" | python -c 'sys.stdin.buffer.read()'
       which ships shell-locale-encoded bytes to Python. On a system with a
       non-UTF-8 LC_ALL (e.g. ja_JP.CP932 / CP1252) the same project root
       produced a different 12-char hash than the UTF-8 locale would produce,
       so observations/instincts were silently written under a separate
       project directory. Fixed by passing the value via an env var and
       encoding as UTF-8 inside Python, making the hash locale-independent.
    
    2. basename cannot split Windows backslash paths (MEDIUM)
       basename "C:\Users\...\ECC作成" returns the whole string on POSIX
       bash, so project_name was garbled whenever CLAUDE_PROJECT_DIR was
       passed as a native Windows path. Normalize backslashes to forward
       slashes before calling basename.
    
    Both the primary project_id hash and the legacy-compat fallback hash
    are updated to use the env-var / UTF-8 approach.
    
    Verified: id is stable across en_US.UTF-8, ja_JP.UTF-8, ja_JP.CP932, C,
    and POSIX locales; Windows-path input yields project_name=ECC作成;
    ASCII-only paths regress-free.
  • fix(observer): clean up prompt_file early and fix test for analysis_relpath
    - Remove prompt_file immediately after shell expansion into -p arg,
      avoiding stale temp files during long analysis windows (greptile feedback)
    - Update test assertion to check analysis_relpath instead of analysis_file,
      matching the cross-platform relative path change from earlier commits
    
    Signed-off-by: Lidang-Jiang <lidangjiang@gmail.com>
  • fix(observer): clean up temp files on cd failure early return
    The cd "$PROJECT_DIR" failure path returned without removing prompt_file
    and analysis_file, leaving stale temp files in .observer-tmp/.
    
    Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
    
    Signed-off-by: Lidang-Jiang <lidangjiang@gmail.com>
  • fix(observer): guard cd failure with early return and log message
    Address reviewer feedback: under set +e, a failing cd would silently
    leave CWD unchanged, causing the relative analysis path to break.
    Add || return with a diagnostic log entry.
    
    Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
    
    Signed-off-by: Lidang-Jiang <lidangjiang@gmail.com>
  • fix(observer): anchor CWD to PROJECT_DIR before Claude invocation
    Reviewers correctly identified that the relative analysis_relpath
    (.observer-tmp/<file>) only resolves when CWD equals PROJECT_DIR.
    Without an explicit cd, non-Windows users launching the observer from
    a different directory would fail to read the analysis file.
    
    Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
    
    Signed-off-by: Lidang-Jiang <lidangjiang@gmail.com>
  • fix(observer): improve Windows compatibility for temp files and Haiku prompt
    Address remaining issues from #842 after PR #903 moved temp files to
    PROJECT_DIR/.observer-tmp:
    
    Bug A (path resolution): Use relative paths (.observer-tmp/filename)
    in the prompt instead of absolute paths from mktemp. On Windows
    Git Bash/MSYS2, absolute paths use MSYS-style prefixes (/c/Users/...)
    that the spawned Claude subprocess may fail to resolve.
    
    Bug B (asks for permission): Add explicit IMPORTANT instruction block
    at the prompt start telling the Haiku agent it is in non-interactive
    --print mode and must use the Write tool directly without asking for
    confirmation.
    
    Additional improvements:
    - Pass prompt via -p flag instead of stdin redirect for Windows compat
    - Add .observer-tmp/ to .gitignore to prevent accidental commits
    
    Fixes #842
    
    Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
    
    Signed-off-by: Lidang-Jiang <lidangjiang@gmail.com>
  • feat: pending instinct TTL pruning and /prune command (#725)
    * feat: add pending instinct TTL pruning and /prune command
    
    Pending instincts generated by the observer accumulate indefinitely
    with no cleanup mechanism. This adds lifecycle management:
    
    - `instinct-cli.py prune` — delete pending instincts older than 30 days
      (configurable via --max-age). Supports --dry-run and --quiet flags.
    - Enhanced `status` command — shows pending count, warns at 5+,
      highlights instincts expiring within 7 days.
    - `observer-loop.sh` — runs prune before each analysis cycle.
    - `/prune` slash command — user-facing command for manual pruning.
    
    Design rationale: council consensus (4/4) rejected auto-promote in
    favor of TTL-based garbage collection. Frequency of observation does
    not establish correctness. Unreviewed pending instincts auto-delete
    after 30 days; if the pattern is real, the observer will regenerate it.
    
    Generated with [Claude Code](https://claude.ai/code)
    via [Happy](https://happy.engineering)
    
    Co-Authored-By: Claude <noreply@anthropic.com>
    Co-Authored-By: Happy <yesreply@happy.engineering>
    
    * fix: remove duplicate functions, broaden extension filter, fix prune output
    
    - Remove duplicate _collect_pending_dirs and _parse_created_date defs
    - Use ALLOWED_INSTINCT_EXTENSIONS (.md/.yaml/.yml) instead of .md-only
    - Track actually-deleted items separately from expired for accurate output
    - Update README.md and AGENTS.md command counts: 59 → 60
    
    Generated with [Claude Code](https://claude.ai/code)
    via [Happy](https://happy.engineering)
    
    Co-Authored-By: Claude <noreply@anthropic.com>
    Co-Authored-By: Happy <yesreply@happy.engineering>
    
    * fix: address Copilot and CodeRabbit review findings
    
    - Use is_dir() instead of exists() for pending path checks
    - Change > to >= for --max-age boundary (--max-age 0 now prunes all)
    - Use CLV2_PYTHON_CMD env var in observer-loop.sh prune call
    - Remove unused source_dupes variable
    - Remove extraneous f-string prefix on static string
    
    Generated with [Claude Code](https://claude.ai/code)
    via [Happy](https://happy.engineering)
    
    Co-Authored-By: Claude <noreply@anthropic.com>
    Co-Authored-By: Happy <yesreply@happy.engineering>
    
    * fix: update AGENTS.md project structure command count 59 → 60
    
    Co-Authored-By: Claude <noreply@anthropic.com>
    
    * fix: address cubic and coderabbit review findings
    
    - Fix status early return skipping pending instinct warnings (cubic #1)
    - Exclude already-expired items from expiring-soon filter (cubic #2)
    - Warn on unparseable pending instinct age instead of silent skip (cubic #4)
    - Log prune failures to observer.log instead of silencing (cubic #5)
    
    Co-Authored-By: Claude <noreply@anthropic.com>
    
    * fix: YAML single-quote unescaping, f-string cleanup, add /prune to README
    
    - Fix single-quoted YAML unescaping: use '' doubling (YAML spec) not
      backslash escaping which only applies to double-quoted strings (greptile P1)
    - Remove extraneous f-string prefix on static string (coderabbit)
    - Add /prune to README command catalog and file tree (cubic)
    
    Co-Authored-By: Claude <noreply@anthropic.com>
    
    ---------
    
    Co-authored-by: Claude <noreply@anthropic.com>
    Co-authored-by: Happy <yesreply@happy.engineering>
  • security: remove supply chain risks, external promotions, and unauthorized credits
    - Remove zenith.chat references and @DRodriguezFX shoutout from README
    - Remove Inspiration Credits section (already in CHANGELOG.md)
    - Remove awesome-agent-skills reference from Links
    - Remove Plankton H3 section by @alxfazio (skill stays in skills/)
    - Remove brand names (InsAIts, VideoDB, Evos) from v1.9.0 notes
    - Remove @ericcai0814 individual credit from README (kept in CHANGELOG)
    - Add Security Guide to Links section
    - Replace curl-pipe-to-bash in autonomous-loops with review warning
    - Replace git clone in plankton-code-quality with review warning
    - Replace pip install git+ in agent-eval with review warning
    - Replace npm install -g in dmux-workflows with review warning
    - Add commercial API notice to nutrient-document-processing
    - Remove VideoDB maintainer credit from videodb skill
    - Replace skill-creator.app link with ECC-Tools GitHub App reference
  • Merge pull request #664 from ymdvsymd/fix/observer-sandbox-access-661
    fix(clv2): add --allowedTools to observer Haiku invocation (#661)
  • fix(clv2): use -e instead of -d for .git check in detect-project.sh
    In git worktrees, .git is a file (not a directory) containing a gitdir
    pointer. The -d test fails for worktree checkouts, causing project
    detection to fall through to the "global" fallback. Changing to -e
    (exists) handles both regular repos and worktrees correctly.
    
    Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
  • fix(clv2): add --allowedTools to observer Haiku invocation (#661)
    The observer's Haiku subprocess cannot access files outside the project
    sandbox (/tmp/ for observations, ~/.claude/homunculus/ for instincts).
    Adding --allowedTools "Read,Write" grants the necessary file access
    while keeping the subprocess constrained by --max-turns and timeout.
    
    Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
  • fix(observe): allow sdk-ts entrypoint in observation hook (#614)
    Clean surgical fix allowing sdk-ts entrypoint in observe hook for Agent SDK sessions. Has APPROVED review.
  • fix: observer memory explosion with throttling, re-entrancy guard, and tail sampling (#536)
    Three fixes for the positive feedback loop causing runaway memory usage:
    
    1. SIGUSR1 throttling in observe.sh: Signal observer only every 20
       observations (configurable via ECC_OBSERVER_SIGNAL_EVERY_N) instead
       of on every tool call. Uses a counter file to track invocations.
    
    2. Re-entrancy guard in observer-loop.sh on_usr1(): ANALYZING flag
       prevents parallel Claude analysis processes from spawning when
       signals arrive while analysis is already running.
    
    3. Cooldown + tail-based sampling in observer-loop.sh:
       - 60s cooldown between analyses (ECC_OBSERVER_ANALYSIS_COOLDOWN)
       - Only last 500 lines sent to LLM (ECC_OBSERVER_MAX_ANALYSIS_LINES)
         instead of the entire observations file
    
    Closes #521
  • fix(continuous-learning-v2): add lazy-start observer logic (#508)
    * feat(continuous-learning-v2): add lazy-start observer logic
    
    Auto-starts observer when observer.enabled: true in config and no .observer.pid exists.
    
    Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
    
    * fix(continuous-learning-v2): address PR review concerns
    
    - Use flock for atomic check-then-act to prevent race conditions
    - Check both project-scoped AND global PID files before starting
    - Support CLV2_CONFIG override for config file path
    - Check disabled file in lazy-start logic
    - Use double-check pattern after acquiring lock
    
    Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
    
    * fix(observe.sh): address PR review comments
    
    - Add stale PID cleanup via _CHECK_OBSERVER_RUNNING function
    - Add macOS fallback using lockfile when flock unavailable
    - Fix CLV2_CONFIG override: use EFFECTIVE_CONFIG for both check and read
    - Use proper Python context manager (with open() as f)
    - Deduplicate signaled PIDs to avoid duplicate USR1 signals
    
    Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
    
    * fix(observe.sh): wrap macOS lockfile fallback in subshell with trap
    
    - Wrap lockfile block in subshell so exit 0 only terminates that block
    - Add trap for EXIT to clean up lock file on script interruption
    - Add -l 30 (30 second expiry) to prevent permanent lock file stuck
    
    Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
    
    * fix(observe.sh): address remaining PR review comments
    
    - Validate PID is a positive integer before kill calls to prevent
      signaling invalid targets (e.g. -1 could signal all processes)
    - Pass config path via env var instead of interpolating shell variable
      into Python -c string to prevent injection/breakage on special paths
    - Check CLV2_CONFIG-derived directory for disabled file so disable
      guard respects the same config source as lazy-start
    
    Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
    
    ---------
    
    Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
  • feat: active hours + idle detection gates for session-guardian (#413)
    * feat: add project cooldown log to prevent rapid observer re-spawn
    
    Adds session-guardian.sh, called by observer-loop.sh before each Haiku
    spawn. It reads ~/.claude/observer-last-run.log and blocks the cycle if
    the same project was observed within OBSERVER_INTERVAL_SECONDS (default
    300s).
    
    Prevents self-referential loops where a spawned session triggers
    observe.sh, which signals the observer before the cooldown has elapsed.
    
    Uses a mkdir-based lock for safe concurrent access across multiple
    simultaneously-observed projects. Log entries use tab-delimited format
    to handle paths containing spaces. Fails open on lock contention.
    
    Config:
      OBSERVER_INTERVAL_SECONDS   default: 300
      OBSERVER_LAST_RUN_LOG       default: ~/.claude/observer-last-run.log
    
    No external dependencies. Works on macOS, Linux, Windows (Git Bash/MSYS2).
    
    * feat: extend session-guardian with time window and idle detection gates
    
    Adds Gate 1 (active hours check) and Gate 3 (system idle detection) to
    session-guardian.sh, building on the per-project cooldown log from PR 1.
    
    Gate 1 — Time Window:
    - OBSERVER_ACTIVE_HOURS_START/END (default 800–2300 local time)
    - Uses date +%k%M with 10# prefix to avoid octal crash at midnight
    - Toolless on all platforms; set both vars to 0 to disable
    
    Gate 3 — Idle Detection:
    - macOS: ioreg + awk (built-in, no deps)
    - Linux: xprintidle if available, else fail open
    - Windows (Git Bash/MSYS2): PowerShell GetLastInputInfo via Add-Type
    - Unknown/headless: always returns 0 (fail open)
    - OBSERVER_MAX_IDLE_SECONDS=0 disables gate
    
    Fixes in this commit:
    - 10# base-10 prefix prevents octal arithmetic crash on midnight minutes
      containing digits 8 or 9 (e.g. 00:08 = "008" is invalid octal)
    - PowerShell output piped through tr -d '\r' to strip Windows CRLF;
      also uses [long] cast to avoid TickCount 32-bit overflow after 24 days
    - mktemp now uses log file directory instead of TMPDIR to ensure
      same-filesystem mv on Linux (atomic rename instead of copy+unlink)
    - mkdir -p failure exits 0 (fail open) rather than crashing under set -e
    - Numeric validation on last_spawn prevents arithmetic error on corrupt log
    
    Gate execution order: 1 (time, ~0ms) → 2 (cooldown, ~1ms) → 3 (idle, ~50ms)
    
    * fix: harden session guardian gates
    
    ---------
    
    Co-authored-by: Affaan Mustafa <affaan@dcube.ai>
  • fix(observe): 5-layer automated session guard to prevent self-loop observations (#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 (#398)
    
    * fix(observe): address review feedback — reorder guards cheapest-first, fix empty pattern bug
    
    Two issues flagged by Copilot and CodeRabbit in PR #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>
  • fix: add confirmation-prompt guard to start-observer.sh (issue #400)
    - Redirect observer output to temp log before appending to main log
    - Check temp log for confirmation/permission language immediately after start
    - Fail closed with exit 2 if detected, preventing retry loops
  • fix: read tool_response field in observe.sh (#377)
    Claude Code sends tool output as `tool_response` in PostToolUse hook
    payloads, but observe.sh only checked for `tool_output` and `output`.
    This caused all observations to have empty output fields, making the
    observer pipeline blind to tool results.
    
    Adds `tool_response` as the primary field to check, with backward-
    compatible fallback to the existing `tool_output` and `output` fields.
  • fix(hooks): scrub secrets and harden hook security (#348)
    * fix(hooks): scrub secrets and harden hook security
    
    - Scrub common secret patterns (api_key, token, password, etc.) from
      observation logs before persisting to JSONL (observe.sh)
    - Auto-purge observation files older than 30 days (observe.sh)
    - Strip embedded credentials from git remote URLs before saving to
      projects.json (detect-project.sh)
    - Add command prefix allowlist to runCommand — only git, node, npx,
      which, where are permitted (utils.js)
    - Sanitize CLAUDE_SESSION_ID in temp file paths to prevent path
      traversal (suggest-compact.js)
    
    Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
    
    * fix(hooks): address review feedback from CodeRabbit and Cubic
    
    - Reject shell command-chaining operators (;|&`) in runCommand, strip
      quoted sections before checking to avoid false positives (utils.js)
    - Remove command string from blocked error message to avoid leaking
      secrets (utils.js)
    - Fix Python regex quoting: switch outer shell string from double to
      single quotes so regex compiles correctly (observe.sh)
    - Add optional auth scheme match (Bearer, Basic) to secret scrubber
      regex (observe.sh)
    - Scope auto-purge to current project dir and match only archived
      files (observations-*.jsonl), not live queue (observe.sh)
    - Add second fallback after session ID sanitization to prevent empty
      string (suggest-compact.js)
    - Preserve backward compatibility when credential stripping changes
      project hash — detect and migrate legacy directories
      (detect-project.sh)
    
    Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
    
    * fix(hooks): block $() substitution, fix Bearer redaction, add security tests
    
    - Add $ and \n to blocked shell metacharacters in runCommand to prevent
      command substitution via $(cmd) and newline injection (utils.js)
    - Make auth scheme group capturing so Bearer/Basic is preserved in
      redacted output instead of being silently dropped (observe.sh)
    - Add 10 unit tests covering runCommand allowlist blocking (rm, curl,
      bash prefixes) and metacharacter rejection (;|&`$ chaining), plus
      error message leak prevention (utils.test.js)
    
    Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
    
    * fix(hooks): scrub parse-error fallback, strengthen security tests
    
    Address remaining reviewer feedback from CodeRabbit and Cubic:
    
    - Scrub secrets in observe.sh parse-error fallback path (was writing
      raw unsanitized input to observations file)
    - Remove redundant re.IGNORECASE flag ((?i) inline flag already set)
    - Add inline comment documenting quote-stripping limitation trade-off
    - Fix misleading test name for error-output test
    - Add 5 new security tests: single-quote passthrough, mixed
      quoted+unquoted metacharacters, prefix boundary (no trailing space),
      npx acceptance, and newline injection
    - Improve existing quoted-metacharacter test to actually exercise
      quote-stripping logic
    
    Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
    
    * fix(security): block $() and backtick inside quotes in runCommand
    
    Shell evaluates $() and backticks inside double quotes, so checking
    only the unquoted portion was insufficient. Now $ and ` are rejected
    anywhere in the command string, while ; | & remain quote-aware.
    
    Addresses CodeRabbit and Cubic review feedback on PR #348.
    
    Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
    
    ---------
    
    Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
  • fix(continuous-learning-v2): observer background process crashes immediately (#312)
    * fix(continuous-learning-v2): observer background process crashes immediately
    
    Three bugs prevent the observer from running:
    
    1. Nested session detection: When launched from a Claude Code session,
       the child process inherits CLAUDECODE env var, causing `claude` CLI
       to refuse with "cannot be launched inside another session". Fix: unset
       CLAUDECODE in the background process.
    
    2. set -e kills the loop: The parent script's `set -e` is inherited by
       the subshell. When `claude` exits non-zero (e.g. max turns reached),
       the entire observer loop dies. Fix: `set +e` in the background process.
    
    3. Subshell dies when parent exits: `( ... ) & disown` loses IO handles
       when the parent shell exits, killing the background process. Fix: use
       `nohup /bin/bash -c '...'` for full detachment, and `sleep & wait`
       to allow SIGUSR1 to interrupt sleep without killing the process.
    
    Additionally, the prompt for Haiku now includes the exact instinct file
    format inline (YAML frontmatter with id/trigger/confidence/domain/source
    fields), since the previous prompt referenced "the observer agent spec"
    which Haiku could not actually read, resulting in instinct files that
    the CLI parser could not parse.
    
    * fix: address review feedback on observer process management
    
    - Use `env` to pass variables to child process instead of quote-splicing,
      avoiding shell injection risk from special chars in paths
    - Add USR1_FIRED flag to prevent double analysis when SIGUSR1 interrupts
      the sleep/wait cycle
    - Track SLEEP_PID and kill it in both TERM trap and USR1 handler to
      prevent orphaned sleep processes from accumulating
    - Consolidate cleanup logic into a dedicated cleanup() function
    
    * fix: guard PID file cleanup against race condition on restart
    
    Only remove PID file in cleanup trap if it still belongs to the
    current process, preventing a restarted observer from losing its
    PID file when the old process exits.