fix(init): refresh stale global hooks after upgrades#177
fix(init): refresh stale global hooks after upgrades#177eggrollofchaos wants to merge 3 commits into
Conversation
A global-init marker previously short-circuited all future initialization, even when a package upgrade shipped a newer hook schema. Existing installations could therefore keep an ownerless guard-launch command indefinitely. Refresh stale Cozempic-owned global hooks when the marker exists, while preserving the marker-only no-hook path as an explicit opt-out. Add coverage for a legacy v10 hook and marker.
eggrollofchaos
left a comment
There was a problem hiding this comment.
Review result at exact head 0dcf2ba06af7a77c74d51ce977ecd35064881b38.
Critical
src/cozempic/cli.py:2098-2117, 2234-2272— the new admission decision scans bothsettings.jsonandsettings.local.json, but the selected mutator (run_init→wire_hooks) only refreshessettings.json. This makes a stale Cozempic hook insettings.local.jsonpermanently unrefreshable. More seriously,src/cozempic/init.py:636-666promises that the marker left by global uninstall prevents future rewiring, but uninstall also removes hooks only fromsettings.json. If a stale hook remains insettings.local.json, the next ordinary command treats it as installation consent and silently recreates all global hooks insettings.json, reversing the explicit uninstall. I reproduced both paths: a local-only stale hook remained stale after repeated_maybe_global_init()calls, and an uninstall followed bycozempic listrecreated global hooks while state stayed(found=True, current=False). Fix the contract categorically: every file used to decide freshness/consent must be covered by refresh and uninstall, or excludesettings.local.jsonfrom that decision and preserve an explicit uninstall/decline state that residual hooks cannot override. Add regression tests for stale-onlysettings.local.json, mixed current/stale files, duplicate same-matcher entries, and uninstall followed by a normal command.
High
None.
Medium
src/cozempic/cli.py:2234-2272— the new hook-state traversal has cognitive complexity 40 (the PR's Sonar annotation; threshold 15). This is a real maintenance risk on the exact logic that now distinguishes current, stale, decline, and uninstall states. Move the settings/hook iterator into a small shared helper ininit.pyand have detection, refresh, and uninstall consume the same normalized records. The Critical fix above should make this reduction natural rather than cosmetic.
Low
None.
Nit
None.
Verification: env -u COZEMPIC_NO_AUTO_INIT uv run --frozen --with pytest pytest tests/test_global_init.py tests/test_auto_init_global_skip.py -q → 31 passed, 2 subtests passed; the isolated guard-race test passed; git diff --check passed. The unmodified ambient command initially failed four auto-init tests only because this reviewer environment exports COZEMPIC_NO_AUTO_INIT; rerunning with the variable removed matched the packet evidence. Two adversarial tempfile probes reproduced the Critical state-machine gap.
Counts: Critical 1 | High 0 | Medium 1 | Low 0 | Nit 0
Verdict: Critical/High findings remain.
Merge blocker: the Critical uninstall/refresh contract violation remains.
Treat settings.json as the only global-init managed settings file, so an unmanaged stale settings.local.json cannot override a user’s explicit global uninstall or decline. Consolidate duplicate legacy hook entries while retaining user commands.
eggrollofchaos
left a comment
There was a problem hiding this comment.
Review verdict: Critical/High findings remain.
High
-
src/cozempic/cli.py:2234-2238, 2280— the new settings.json-only helper is shared by global init and project auto-init. That fixes the global uninstall contract, but it regresses project detection: a project whose effective Cozempic hook exists only in.claude/settings.local.jsonis now reported as uninitialized, so_maybe_auto_init()callsrun_init()and creates a second managed hook set in.claude/settings.json. The base implementation scanned both files, and an adversarial probe at this head confirmedrun_init()is called for a current local-only hook. This can double-fire the guard/checkpoint hooks. Split the predicates: global init/uninstall should inspect only the managedsettings.json, while project auto-init should continue recognizing current effective hooks in either project settings file. Add a regression test for current hooks in projectsettings.local.jsonwith no projectsettings.json. -
src/cozempic/cli.py:2107-2110andsrc/cozempic/init.py:139-144— a marker plus malformed/unreadablesettings.jsonis silently classified as an explicit decline.cozempic_hook_schema_state()collapses parse failure to(False, True),_project_has_cozempic_hooks()returns false, and_maybe_global_init()returns beforewire_hooks()can surface its parse error. I reproduced this with a marker and{broken json: the command emitted no failure and never calledrun_init(), leaving global protection silently absent. Preserve an error/unknown state instead of equating it with “no hooks”; fail safely without rewriting the file, but surface the parse failure and retry guidance. Add a marker-plus-malformed-settings regression test.
Medium
src/cozempic/init.py:139-168— the extracted schema-state scanner still has cognitive complexity 27 (the current Sonar annotation; threshold 15). This is the same load-bearing state parser that now controls current/stale/declined decisions. Extract a normalized iterator over hook commands (or reuse one shared with uninstall/wiring) so malformed shapes and managed-command classification are handled once.
Low
src/cozempic/cli.py:2115-2117— the comment says existing stale hooks establish consent, butinteractiveonly checks marker presence. A pre-marker installation with stale hooks still receives the first-install prompt; answering “no” touches the marker, then the next invocation refreshes those hooks anyway because marker + hooks is treated as prior consent. Include_project_has_cozempic_hooks(home_claude)in the prompt gate so the behavior matches the documented state machine.
Nit
None.
Verification: env -u COZEMPIC_NO_AUTO_INIT -u COZEMPIC_NO_GLOBAL_INIT uv run --frozen --with pytest pytest tests/test_global_init.py tests/test_uninstall.py tests/test_auto_init_global_skip.py -q --rootdir <worktree> → 44 passed, 2 subtests passed; git diff --check passed. Two isolated probes reproduced the local-only duplicate-init path and the marker-plus-malformed-settings error masking.
PR context checked: head 5fc98f9230decda3652e2153cf2ce6bfd76ee5d0; top-level comments 1; inline comments 0; review threads 0 (0 active); prior review records 1; Sonar check passed with 1 current annotation, accepted above as Medium; latest review/comment at 2026-07-15T06:54:02Z.
Counts: Critical 0 | High 2 | Medium 1 | Low 1 | Nit 0
Merge blocker: the two High state-classification regressions remain.
Keep global init scoped to managed settings.json while project auto-init observes both effective settings files. Preserve malformed settings as an explicit failure, and treat existing stale hooks as prior consent for refresh.
|
eggrollofchaos
left a comment
There was a problem hiding this comment.
Self-review verdict: Critical/High findings remain.
High
-
src/cozempic/cli.py:2280-2295— the global-current skip still calls the project-wide predicate for~/.claude, so unmanaged~/.claude/settings.local.jsonstate can override a current managedsettings.json. At this exact head, a current globalsettings.jsonplus a stale globalsettings.local.jsonmakes the combined predicate false;_maybe_auto_init()then writes a complete hook set into the current project even though the managed global hooks are already active. The exact-head probe created local PostCompact/PostToolUse/PreCompact/SessionStart/Stop hooks, producing redundant global + project firing. The malformed-local sibling path also collapses to false. Use_managed_cozempic_hook_state(home_claude)(including its error handling) for the global skip, and reserve_project_cozempic_hook_state()for the actual project. Add mixed current-global/stale-global-local and malformed-global-local regressions. -
src/cozempic/cli.py:2161-2175andtests/test_global_init.py:621-636— an operationalrun_init()failure is still converted into the same marker state as an explicit decline. An exact-headPermissionErrorprobe showed the first attempt touched the marker and the second invocation silently skippedrun_init(), so a transient read-only/permission/disk failure permanently disables automatic protection until the user discovers and deletes the marker. That directly violates the requested “failure without becoming an opt-out” state. Keep explicit decline/uninstall distinct from retry throttling (for example, a separate failure/backoff record), and replace the test that currently requires the marker on failure with a retry/non-opt-out regression.
Medium
None.
Low
None.
Nit
None.
Verification: focused exact-head suite passed (47 tests, 2 subtests); git diff --check passed. Adversarial tempfile probes reproduced both High paths, including creation of seven local hook entries and failure-marker suppression on the second invocation. PR context checked: head d7415913b5c174b40a56739d2378c9f3daab4b4c; 1 top-level bot comment; 0 inline comments; 0 review threads; Sonar check green with 0 new issues.
Counts: Critical 0 | High 2 | Medium 0 | Low 0 | Nit 0
Verdict: Critical/High findings remain.
Approval workflow: not-authority. No approval or merge was attempted.



Summary
Why
A package upgrade can ship a safer SessionStart hook while an earlier globally installed hook remains in
~/.claude/settings.json. The marker previously suppressed the refresh path, leaving the old hook active.Tests
uv run --frozen --with pytest pytest tests/test_global_init.py tests/test_auto_init_global_skip.py -quv run --frozen --with pytest pytest tests/test_guard_race_2026_05_18.py::TestR1_DaemonProcessRace::test_two_processes_one_winner -quv run --frozen --with pytest pytest -x -qreached 791 passed and 5 skipped before the existing nondeterministic guard-race test produced two winners in one of 50 iterations; its isolated rerun passed.