* fix(continuous-learning-v2): warn when the observer never survives a hook invocation (#2489)
The observer is lazy-started from a hook process that exits immediately
afterwards. start-observer.sh's liveness check runs inside that still-living
process tree, so it always sees a healthy observer and prints "Observer
started (PID: N)". On native Windows (Git Bash/MSYS2) the reap happens later,
when the hook's Job Object closes, so no self-check placed in
start-observer.sh can ever observe the failure.
The next hook invocation is the only place the death is visible, and
_CHECK_OBSERVER_RUNNING already found it there -- then discarded it, deleting
the stale PID file and restarting silently, once per tool call, forever. Users
were left with an observer-start.log full of success lines and an observer
that never completed a single analysis cycle.
Record the "well-formed PID that is no longer alive" case, count consecutive
non-survivals in ${PROJECT_DIR}/.observer-nosurvive-count, and log one
explanatory warning when the streak reaches ECC_OBSERVER_NOSURVIVE_WARN_AFTER
(default 3). Warning fires on equality so a persistent failure logs once per
streak rather than once per tool call; finding the observer alive resets the
streak. The Windows-specific explanation is gated on uname so Linux/macOS
users are pointed at observer.log instead of a wrong diagnosis.
Counting happens in the caller, not inside _CHECK_OBSERVER_RUNNING, because
that function is invoked once per PID file and again under the start lock.
The PowerShell backgrounding rewrite is deliberately not included: it cannot
be exercised on a non-Windows machine, and untested process-spawning code is
a worse outcome than an accurate diagnostic.
* docs(continuous-learning-v2): state observer platform support and the new warn threshold
The observer's Windows limitation was only discoverable by hitting it. Record
it next to observer.enabled, where it is read before the flag is set, and
document ECC_OBSERVER_NOSURVIVE_WARN_AFTER so the knob added alongside the
warning does not repeat the undocumented-env-var problem tracked in #2573.
zh-TW is intentionally left alone: translation parity is not enforced here and
the repo rejects blind translation imports without translator review.
* fix(continuous-learning-v2): serialize the non-survival streak under the lazy-start lock
observe.sh runs on every tool call, so the streak read-modify-write could race
between concurrent invocations -- losing an increment or logging the warning
twice. That is the same class of bug the signal counter hit in #2296, and this
repo's rule is to never fall back to an unlocked read-modify-write.
Rather than add a second lock, move the increment into _START_OBSERVER_LOGGED.
All three of its call sites already run inside the lazy-start lock
(flock / lockfile / mkdir), so the update is serialized with no new machinery.
Counting at the restart instead of at detection also means N racing hooks
record one death rather than N.
The reset stays in the caller: it is an idempotent unlink, not a
read-modify-write, so it needs no lock.
Adds a regression case pinning the increment inside _START_OBSERVER_LOGGED and
asserting all three call sites remain locked.
* fix(continuous-learning-v2): harden the non-survival threshold and warning output
Three review findings on the #2489 diagnostic:
- An all-zero threshold silently disabled it. `00` passes a digits-only check
but compares as zero, and the streak only grows, so the warning could never
fire. Normalize with base-10 arithmetic and fall back to the default for
anything below 1. Base 10 is forced explicitly because a leading zero would
otherwise be read as octal, and `08` is an arithmetic error that would abort
the hook under `set -e`. The same normalization now guards the streak read.
- An unwritable log silently swallowed the diagnostic. Build the message once
and fall back to stderr when the append fails. This cannot spam: the block
runs once per streak, not once per tool call. The counter write keeps its
`|| true` -- observe.sh runs on every tool call and the repo rule is that
hooks exit 0 on non-critical errors, so a full disk must not break tool use.
- The live-PID test fixture used process.pid, which is 1 in a container and is
deliberately rejected by _CHECK_OBSERVER_RUNNING; the reset case would then
fail for the wrong reason. Use a spawned child and clean it up.
Adds a regression case for the all-zero threshold. Verified on bash 3.2 (the
macOS CI runner shell) as well as bash 5.
* fix(continuous-learning-v2): warn only on a persisted streak increment
If the counter write fails, the file stays below the threshold, so every later
hook invocation rereads it, re-increments in memory, hits the equality check
and warns again -- turning the once-per-streak diagnostic into once-per-tool-
call spam. That is worse in exactly the case the stderr fallback added in the
previous commit was meant to cover, since a disk that cannot take the log
usually cannot take the counter either.
Gate the warning on the write succeeding. The write stays non-fatal: it runs
as an `if` condition, so `set -e` is satisfied and an unwritable counter costs
a delayed diagnostic rather than a broken tool call.
Tests: an unwritable counter must stay silent across repeated invocations while
the hook still exits 0, and a leading-zero threshold ("08") must be read as
decimal -- "00" alone did not exercise the base-10 conversion, since it is zero
either way.
---------
Co-authored-by: haelyra <49814733+haelyra@users.noreply.github.com>
* fix(skills): move version into metadata and normalize to semver
29 skills declared `version` at the top level of their frontmatter. The
schema reads it from `metadata`, so tooling that follows the schema either
misses it or has to special-case the top level.
Three motion skills also declared `version: 1.0`, which is not a valid
semantic version; normalized to `1.0.0`.
No behavioral change — frontmatter metadata only.
* fix(skills): state activation triggers in skill descriptions
148 skills described what they cover but never named the situation that
should trigger them. Since the description is what Claude matches against
to decide whether to load a skill, a description without a trigger makes
activation guesswork — the skill is either missed or loaded at the wrong
time.
Added a "Use when ..." clause to each, derived from the skill's own body
(most already stated the trigger under "## When to Use" or in the opening
line; that intent is now reflected in the frontmatter where it is actually
read from).
Descriptions were only appended to; no existing wording was removed.
* fix(skills): sync activation triggers into the Codex skill mirror
10 of the skills whose descriptions changed are also mirrored under
`.agents/skills/`, where the description was previously a verbatim copy.
Left alone, the two surfaces would disagree about when the skill applies.
Only the description line is synced; the Codex copies keep their reduced
frontmatter, since that validator accepts only name, description,
metadata, license, and allowed-tools.
* fix(skills): correct three activation clauses from review
- autonomous-loops: the clause pulled new loop work into a skill that its
own body marks as a compatibility shim retained for one release. It now
points at the canonical continuous-agent-loop instead.
- continuous-learning: the description carried the v1 routing directive
twice; collapsed to one.
- homelab-pihole-dns: the clause fired on any broken home DNS. Narrowed to
tasks that actually involve Pi-hole.
* chore: retain current main lockfile
---------
Co-authored-by: Çağrı Solakoğlu <cagri.solakoglu@vtcenerji.com>
Co-authored-by: haelyra <49814733+haelyra@users.noreply.github.com>
* fix(continuous-learning): cluster instincts by keyword overlap in /evolve
`cmd_evolve` grouped instincts by exact string equality of the whole
normalized trigger sentence. Triggers are free-form sentences, so every
instinct landed in its own bucket and `skill_candidates` was always empty.
`agent_candidates` is derived from `skill_candidates`, so agents never
generated either — `/evolve --generate` could only ever emit commands.
Measured on a 42-instinct project: 42 instincts produced 42 unique cluster
keys, largest cluster size 1.
Group on keyword overlap instead. Jaccard is the wrong metric here — trigger
keyword sets average ~7 words, so even clearly related pairs top out around
0.33 — so this uses the overlap coefficient (shared / smaller set) at 0.5,
plus a floor of 2 shared keywords so one incidental word cannot pull
unrelated instincts together. The same 42 instincts now yield 4 clusters.
Also unify the command/agent slug used by the preview and the writer. The
preview called `.replace('a ', '')`, which strips "a " anywhere in the
string, mangling "extracting data from Reddit" into
`/extracting-datfrom-R` while `--generate` wrote `extracting-data-from.md`.
Both paths now share `_evolved_command_name()` / `_evolved_agent_name()`.
Adds tests/scripts/instinct-cli-evolve.test.js, which fails on the previous
implementation (0 clusters instead of 1; preview name `extracting-datfrom-R`)
and covers the negative cases so unrelated triggers still stay apart.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs(continuous-learning): correct clustering metric name in docstring
The docstring said "Jaccard" while the implementation uses the overlap
coefficient, which is the point of the change.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(continuous-learning): generate every evolve candidate and cut slugs on word boundaries
_generate_evolved() wrote only skill_candidates[:5], workflow_instincts[:5]
and agent_candidates[:3]. On a project with 36 command candidates that meant
5 files and no warning, so the output read as complete while 86% of the
candidates were dropped.
Generation is now unbounded by default and takes a --limit N flag for callers
that want a cap. A cap that truncates says so:
Note: writing 3 of 36 command candidates (--limit 3); 33 skipped.
The analysis preview keeps showing five per kind but now names the remainder
("... and 31 more command candidates not shown") instead of presenting a
sample as the whole set.
Slugs were also cut with a hard slice, which split words mid-token and
produced /investigating-comple, /learning-about-compl and
/researching-mechanis. _truncate_slug() retreats to the last separator that
fits, and keeps the full head when the cut already lands on one, so
"analyzing large text files" stays /analyzing-large-text rather than losing
a word. A first word longer than the limit still falls back to a hard cut
because no boundary is available.
Shorter slugs collide more easily, and a collision used to mean one file
silently overwriting another. _assign_unique_slugs() suffixes duplicates
(-2, -3) and is called by both the preview and the writer over the same
ordered list, so advertised names and written names cannot drift apart.
Skill directory naming moved to _evolved_skill_name(); it previously used its
own inline slug expression, so it was the one truncation the shared helper
did not cover.
Adds tests/scripts/instinct-cli-evolve-generate.test.js: 7 cases covering
word-boundary cuts, the separator-aligned cut, unbounded generation, --limit
reporting, collision dedup, preview remainder and preview/writer agreement.
Six of the seven fail against the previous implementation.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: haelyra <49814733+haelyra@users.noreply.github.com>
Honor an explicit non-git CLAUDE_PROJECT_DIR as an isolated project scope, canonicalize and hash it consistently in the shell observer and Python CLI, and preserve the global fallback for arbitrary non-git working directories.
Two continuous-learning-v2 observer regressions reported in #2452:
- start-observer.sh still called _ecc_resolve_homunculus_dir, but the
shared lib was renamed to _clv2_resolve_homunculus_dir (with observe.sh
and detect-project.sh updated, start-observer.sh missed). Under set -e
every launch dies with exit 127 at line 40 - daemon boot is broken on
all platforms, not just Windows.
- observer-loop.sh backgrounds the analysis claude call with stdin left
open; on Git Bash/MSYS2 the child inherits it, waits, warns 'no stdin
data received', and exits 1 before reading the analysis file. Close
stdin with </dev/null while keeping the -p prompt flag, preserving the
Windows-compat decision from #842 instead of reverting to a stdin
redirect.
Adds two source invariant guards to tests/hooks/hooks.test.js: every
*_resolve_homunculus_dir call site must match a function the shared lib
defines, and the backgrounded claude call must close stdin.
Fixes#2452
Commit 2d40baac (PR #2304) renamed `_ecc_*` -> `_clv2_*` but missed this
single call site. The launcher sources `scripts/lib/homunculus-dir.sh` which
only exports `_clv2_resolve_homunculus_dir`, so any user enabling the
observer (`observer.enabled: true`) gets:
start-observer.sh: line 40: _ecc_resolve_homunculus_dir: command not found
The hook (`observe.sh`) uses the correct name and writes observations, but
the lazy-start path fails silently via nohup, so the symptom is
"observations grow forever, no new instincts". Confirmed on
affaan-m/ECC@40927950c (HEAD of main).
Default `observer.enabled: false` masks the bug for new users. Opt-in
users hit it on first manual `start-observer.sh start` or first lazy-start
after enabling.
Fix: rename the single call to `_clv2_resolve_homunculus_dir` to match
the lib export and every other caller in the skill.
2026-07-20 15:42:14 -04:00
devin-ai-integration[bot]GitHubDevin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>affaanDevin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>Affaan Mustafa
* chore(continuous-learning-v2): standardize shell shebangs to env bash
Three scripts under skills/continuous-learning-v2/ used the hardcoded
`#!/bin/bash` shebang while the other four already used the portable
`#!/usr/bin/env bash`:
- hooks/observe.sh (runs on every hook invocation)
- scripts/detect-project.sh
- agents/start-observer.sh
The hardcoded interpreter path fails to execute on systems where bash is
not installed at /bin/bash (NixOS, some Homebrew layouts, FreeBSD).
Standardize all three to `#!/usr/bin/env bash`, matching the repo-wide
majority convention, and add a regression test that asserts shebang
uniformity for every shell script in this skill so the inconsistency
cannot reappear.
Fixes#2303
* test(continuous-learning-v2): harden shebang test runner
Address review feedback on the shebang-consistency regression test:
- firstLine() now splits on /\r?\n/ so a script checked out with CRLF
line endings does not leave a trailing carriage return that would
break the shebang comparison on Windows.
- The test() helper now surfaces the full error (stack trace, not just
the message) on failure and writes pass/fail lines via
process.stdout/stderr so diagnostics are preserved.
* test(continuous-learning-v2): skip hidden dirs in shebang scan
The recursive shell-script scan now skips hidden directories (e.g. the
observer's runtime `.observer-tmp`). This keeps the shebang-consistency
check deterministic: only committed skill scripts are examined, and an
untracked local artifact left over from an observer run can no longer
cause a false failure.
* fix(observer): replace hardcoded sleep 2 with PID file poll in start-observer.sh
Fixes#2295
The previous `sleep 2` after launching the observer loop has two
problems: on slow filesystems or loaded systems 2 seconds may not be
enough, producing a false-negative on the subsequent PID file check; on
healthy systems it adds unnecessary latency.
Replace with a poll loop that exits as soon as the PID file appears:
for _i in $(seq 1 50); do [ -f "$PID_FILE" ] && break; sleep 0.2; done
50 × 0.2s = 10s max wait (vs the previous fixed 2s), but typical startup
returns within the first iteration. No behavior change in the success
path — only the wait strategy changes.
Tests: `node tests/run-all.js` 2891 passed / 0 failed; `npm run lint`,
`catalog:check`, `command-registry:check` all clean.
* test(observer): add regression guard for sleep-2 → PID-file poll (#2295)
Asserts start-observer.sh never reverts to the fixed `sleep 2` wait and
keeps the 50 × 0.2s `$PID_FILE` poll in place. Sits next to the existing
observer-loop invariant block in tests/hooks/hooks.test.js, matching the
repo's pattern of guarding shell-script invariants via source-content
assertions.
Without this, any future "cleanup" that reintroduces a fixed sleep would
silently regress the slow-filesystem fix from the previous commit.
* fix(observer): loosen poll-regression assertions and document failure-path latency (#2356 review)
Addresses CodeRabbit + Greptile feedback on PR #2356:
- tests/hooks/hooks.test.js: split the over-specific positive assertion
(which pinned the exact `for _i in $(seq 1 50); … sleep 0.2; done`
line) into three intent-based assertions — bounded iteration count,
early-exit on $PID_FILE, sub-second interval. Valid refactors (rename
loop var, switch to `while`, retune to 100 × 0.1s) no longer false-fail
while the `sleep 2` regression remains guarded.
- start-observer.sh: extend the inline comment to record the trade-off
Greptile flagged — a loop that crashes before writing $PID_FILE is now
detected in ~10s instead of ~2s. Healthy startups still return in
iteration 1.
Tests: node tests/run-all.js (Node v22.18.0) → 2892 passed / 0 failed.
SKILL.md and _promote_auto already use avg_conf >= 0.8; observer.md
(EN + zh-CN) was missed and still says per-instance. Same drift as
#2274.
Refs #2298.
* feat(continuous-learning-v2): make observer model configurable via ECC_OBSERVER_MODEL
The observer hardcoded `--model haiku`. Parameterize as "${ECC_OBSERVER_MODEL:-haiku}": the haiku default is preserved (no behavior change for existing users), but users can opt into a stronger model — e.g. `ECC_OBSERVER_MODEL=opus` — for higher-quality instinct extraction. Useful on subscription plans where model cost isn't the limiting factor.
* fix(continuous-learning-v2): address review — update wiring test + docs
- Update source-inspection test to assert the ${ECC_OBSERVER_MODEL:-haiku} defaulting behavior (was matching the literal `claude --model haiku`, which this PR changed). All 31 tests pass.
- Add guidance to raise ECC_OBSERVER_TIMEOUT_SECONDS for slower models (e.g. opus) so the 120s watchdog doesn't kill analysis mid-run.
- Fix now-stale 'Haiku session' comment -> 'observer session' (model is configurable).
analyze_observations moved observations.jsonl into observations.archive/
unconditionally, even when the Claude analysis failed (timeout, non-zero
exit, rate limit). Because the analyzer only reads the live file, a failed
batch was archived and never re-analyzed, silently dropping the instincts
it would have produced.
Return early on a non-zero analysis exit so the archive mv runs only on
success, retaining observations for the next cycle to retry. Resolve the
script's own directory from ${BASH_SOURCE[0]} (SCRIPT_DIR) so sibling
scripts (session-guardian.sh) and relative helpers resolve correctly under
both execution and sourcing, and add a source-guard so observer-loop.sh can
be sourced without starting the loop. Add a regression test covering both
the failure (retain) and success (archive) paths.
Fixes#2370
* test(clv2): cover instinct-cli prune, projects ops, promote dry-run, normalize-url
Add pytest coverage for previously-untested functions in
skills/continuous-learning-v2/scripts/instinct-cli.py:
- _normalize_remote_url: scp/https/file forms, credential + .git
stripping, network lowercasing, case-preserving local paths, idempotence
- _promote_specific dry-run: returns 0 and writes no global file
- projects delete/gc/merge: invalid-id, not-found, dry-run, and force
paths over registry + storage, asserting destructive ops are gated
- cmd_prune: dry-run keeps files; non-dry-run deletes only expired; quiet
Test-only change; no production code modified.
Fixes#2302
* test(clv2): assert dry-run storage no-op and quiet-mode stderr silence
Address CodeRabbit review on #2374:
- projects gc/merge dry-run tests now also assert on-disk storage is
untouched (empty1 project dir survives; nothing copied into dest
personal), closing the gap where a storage-mutating dry-run regression
would still pass.
- cmd_prune quiet test now asserts stderr is empty too, not just stdout.
* test(clv2): cover merge missing-destination and prune empty-pending branches
* fix(clv2): surface SIGALRM timeout drops in observe.sh
The inline-Python observation writers in observe.sh arm a signal.SIGALRM
alarm (8s) so they self-terminate before the async hook's 10s timeout can
orphan them (#2278). The handler _ecc_bail called sys.exit(0) with no
logging, so when the alarm fired the in-flight observation was silently
dropped: nothing was logged, no partial write occurred, and the shell saw
a clean exit. There was no way to detect or count how many observations
were being lost.
Add a single stderr visibility line to both _ecc_bail handlers (the
parse-error fallback path and the main observation-writing path) before
sys.exit(0), using the repo's "[observe]" log prefix. Exit code stays 0:
in a Claude Code hook a non-zero exit signals a block, so changing it
would turn an internal timeout into a user-facing tool block. The warning
goes to stderr (not stdout) because both blocks redirect stdout into the
observations file.
Add tests/hooks/observe-signal-timeout.test.js: a static regression guard
that every _ecc_bail handler logs to stderr before exiting and keeps exit
0, plus a behavioral check that runs the real handler text extracted from
observe.sh and confirms a fired alarm exits 0 and emits the [observe]
warning on stderr only.
Fixes#2300
* test(clv2): exercise both _ecc_bail handlers end-to-end
The behavioral SIGALRM-fire test ran only handlers[0] (the parse-error
fallback path); the main observation-write path (handlers[1]) was covered
only by the static regex guard. The write path is the higher-value one to
verify end-to-end since it carries valid, parseable data that would succeed
given more time, so a silent drop there is the worst case.
Loop the behavioral check over every extracted handler so a regression that
silenced the second handler's stderr write is caught at runtime, not just by
the static guard.
* test(clv2): select timeout handlers by marker, not array index
The behavioral check looped over all extracted _ecc_bail handlers by index.
If an unrelated _ecc_bail were ever added to observe.sh, the loop would
either test the wrong block or be diluted. Filter the handlers to those
carrying the "[observe] SIGALRM timeout" marker so the live SIGALRM check
stays pinned to the two #2300 timeout handlers regardless of array order or
future additions.
* test(clv2): fail fast when python is missing in SIGALRM check
The behavioral test returned early when no python interpreter was found,
which the test harness records as a PASS — so the SIGALRM contract could go
entirely unverified yet still look green. Throw instead, matching the
existing insaits-security-monitor convention of failing when a required
Python runtime is absent, and drop the in-test console.log.
observe.sh bumps the SIGUSR1 throttle counter in
${PROJECT_DIR}/.observer-signal-counter with an unlocked read-modify-write.
The hook runs on every tool call, so concurrent invocations read the same
value, both increment, and lose a write, signaling the observer at
unpredictable intervals and defeating the #521 throttle.
Serialize the read-modify-write under a lock, and only ever bump the counter
while that lock is held:
- Prefer flock with a bounded -w wait (the OS auto-releases it when the fd
closes or the process dies, so there is no stale lock and no lost increment);
on a timeout the tick is skipped rather than bumped unlocked.
- Fall back to an atomic mkdir lock on platforms without flock, with a bounded
spin. An EXIT trap cleans up on normal completion; INT/TERM traps release the
lock and exit, so a signal cannot drop the lock and then continue the
read-modify-write without ownership. If the lock cannot be acquired in the
budget the tick is skipped rather than raced. No hand-rolled PID stale-reclaim
(which is racy and can delete a live re-acquirer's lock).
- Guard the counter read against a corrupt (non-integer) file that would abort
the hook under set -e.
Add tests/hooks/observe-signal-counter-race.test.js: 20 concurrent observe.sh
invocations must not lose increments (exact under flock; at most one dropped on
the best-effort mkdir fallback), the runner rejects on any hook execution
failure or hang, plus content guards for the lock and the corrupt-counter
handling.
Fixes#2296
* fix(clv2): align Python _update_registry schema with shell counterpart
The Python `_update_registry` in instinct-cli.py wrote registry entries
without the `id` and `created_at` fields, while the shell counterpart in
detect-project.sh writes both. A projects.json entry could therefore have a
different shape depending on which path (Python CLI or shell hook) last
touched it.
Emit the same field set and order as the shell version: id, name, root,
remote, created_at (preserved from any existing entry), last_seen. Add
regression tests asserting field parity and created_at preservation.
Fixes#2299
* fix(clv2): guard _update_registry against a non-dict registry entry
A malformed projects.json (a non-dict value for the current project id, e.g.
null) would make existing.get("created_at", ...) raise and crash the update,
losing the old code's ability to self-heal a corrupt per-entry value. Normalize
existing to {} when it is not a dict so the entry is healed by the rewrite. Add
a regression test for the malformed-entry path.
* test(clv2): assert the first-write created_at == last_seen contract
The new _update_registry tests only checked both timestamps were truthy. On the
initial write both derive from the same `now`, so created_at must equal
last_seen; assert that explicitly so a later refactor that breaks the contract
is caught. Split the compound assertions into single-expression checks.
* fix(clv2): heal a non-dict top-level registry in _update_registry
A projects.json that is valid JSON but not a mapping (e.g. `[]` or a
string) previously crashed _update_registry on registry.get(), before
the per-entry guard could run, so the corrupt file could not be healed.
Guard the top-level shape right after the load and fall back to {} so the
rewrite repairs the file — matching the per-entry healing already in place.
Resolves the remaining CodeRabbit finding on #2299.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Two security-priority fixes in continuous-learning-v2/scripts/instinct-cli.py:
- #2294: _write_registry wrote projects.json without the advisory lock that
_update_registry holds, so concurrent 'projects delete/gc/merge' could race an
observe-time update and corrupt the registry. Extract the lock into a shared
_registry_lock() context manager and use it in both writers.
- #2297: _remove_project_storage called shutil.rmtree on PROJECTS_DIR/project_id
with no containment check. Add defense-in-depth: resolve the path and refuse to
delete anything that is not strictly inside PROJECTS_DIR (or is the root
itself), so a relaxed validator or future caller can never cause an
arbitrary-directory delete.
Adds 5 pytest regression tests (atomic write under lock, contained delete,
missing-dir no-op, traversal refused, root refused). Node integration suite
(tests/scripts/instinct-cli-projects.test.js) green 9/9.
* fix(clv2): escape $HOME before pgrep -f in migrate-homunculus.sh
pgrep -f treats its argument as an extended regular expression, but the
running-observer guard interpolated $HOME unescaped. Paths containing regex
metacharacters (e.g. /home/user.name, /home/c++dev, /home/user (work)) made the
match over-broad or invalid, causing either a false negative (live observer
missed, migration proceeds and risks registry corruption) or a false positive
(migration blocked unnecessarily).
Escape the ERE metacharacters in $HOME via sed before building the pattern so
the home prefix is matched literally while the trailing .*observer-loop\.sh
regex is preserved. Portable across BSD and GNU sed.
Fixes#2301
* test(clv2): add regression test for migrate-homunculus.sh $HOME escaping
Guards the #2301 fix: extracts the script's sed escaping command and asserts
the resulting pgrep -f pattern matches the literal home path while no longer
over-matching a regex-expanded decoy (HOME=/home/user.name must not match
/home/userXname). Also pins that the guard uses escaped_home rather than $HOME
directly. Follows the existing clv2 shell-test convention in
tests/hooks/observe-entrypoint-allowlist.test.js.
Refs #2301
* test(clv2): skip migrate-homunculus escaping test on Windows
The test relies on POSIX bash/sed/grep -E semantics, which differ on the
Windows CI runners. Guard with the same process.platform === 'win32' early
exit used by tests/hooks/observe-subdirectory-detection.test.js so the
bash-dependent assertions only run on POSIX platforms.
Refs #2301
The observe hook's secret-scrub regex used a generic ([A-Za-z]+\s+)? group
that overlapped the separator and value classes, causing exponential
backtracking on identifier-dense tool I/O — an orphaned python child then
pegged a core at ~100% CPU for days because the async hook timed out without
killing it.
- Rewrite _SECRET_RE as a linear matcher: bounded separator {1,8}, a fixed
set of auth schemes (bearer|basic|token|bot) instead of [A-Za-z]+, and a
bounded value {8,256}. Pathological input drops from hang to <1ms; real
secrets still redact (verified incl. 'Bearer <token>').
- Add a signal.alarm(8) self-timeout to both scrub blocks so any runaway
child self-terminates before the 10s async-hook timeout can orphan it.
* fix: surface legacy data warning in instinct-cli status (#2036)
When the data directory moved from ~/.claude/homunculus/ to the
XDG-compliant ~/.local/share/ecc-homunculus/, legacy installs with data
still in the old path saw "No instincts found" with no explanation.
Add _warn_legacy_data() to cmd_status so users get a clear, actionable
warning pointing them to the migration script or the CLV2_HOMUNCULUS_DIR
override. Wrap the directory scan in try/except to handle permission
errors gracefully.
Closes#2036
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix: address review feedback — drop unused f-strings, resolve absolute migrate path
Remove extraneous f-prefix from strings without interpolation (ruff F541).
Resolve migrate-homunculus.sh path relative to instinct-cli.py instead of
hard-coding a repo-relative path that only works from the repo root.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix: quote migrate script path to handle spaces
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: kky <lingmu141592@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* fix(observer): auto-scale max_turns by analysis batch size (#2035)
The hardcoded default of MAX_TURNS=20 is insufficient when
MAX_ANALYSIS_LINES=500 (also the default). Claude exhausts its turn
budget before it can write all discovered instinct files, producing:
Error: Reached max turns (20)
Fix: when ECC_OBSERVER_MAX_TURNS is not explicitly set, compute
max_turns proportionally to the actual analysis batch size:
max_turns = clamp(analysis_count / 10, 20, 100)
This gives:
- 20–199 lines → 20 turns (existing floor, unchanged)
- 500 lines → 50 turns (resolves the reported failure)
- 1000 lines → 100 turns (cap)
Explicitly setting ECC_OBSERVER_MAX_TURNS still overrides the
auto-scaled value, preserving the existing escape hatch.
* test(observer): update max_turns test for auto-scaling; document validation
The max-turns budget test in tests/hooks/hooks.test.js still asserted the removed literal max_turns="${ECC_OBSERVER_MAX_TURNS:-20}", which would fail against the new auto-scaling logic. Assert the auto-scale formula and the 20/100 clamp bounds instead.
Also add the explanatory comment CodeRabbit requested above the max_turns sanitization block, clarifying it guards the explicit ECC_OBSERVER_MAX_TURNS override path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
The observe.sh Layer 1 entrypoint guard short-circuits with exit 0 when
CLAUDE_CODE_ENTRYPOINT is not in {cli, sdk-ts, claude-desktop}. Claude
Code's VS Code extension sets CLAUDE_CODE_ENTRYPOINT=claude-vscode, so
VS Code users see no observations recorded — observations.jsonl never
gets created and the instinct pipeline stays empty.
Add claude-vscode to the allowlist, mirroring the precedent in #1522
which added claude-desktop the same way.
Add a regression test that spawns observe.sh under bash -x for each
allowed entrypoint (cli, sdk-ts, claude-desktop, claude-vscode) and
each denied entrypoint (unknown-host, claude-cody, mcp), asserting
that allowed entrypoints reach Layer 2's ECC_HOOK_PROFILE check while
denied entrypoints stop at Layer 1.
Fixes#2102
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.
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.
- 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>
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>
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>
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>
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: 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>