Skip to content

fix(init): refresh stale global hooks after upgrades#177

Open
eggrollofchaos wants to merge 3 commits into
Ruya-AI:mainfrom
eggrollofchaos:codex-guard/owner-cleanup
Open

fix(init): refresh stale global hooks after upgrades#177
eggrollofchaos wants to merge 3 commits into
Ruya-AI:mainfrom
eggrollofchaos:codex-guard/owner-cleanup

Conversation

@eggrollofchaos

Copy link
Copy Markdown

Summary

  • Refresh existing stale global Cozempic hooks even when the global-init marker is present.
  • Preserve the marker-without-hooks path as a prior user opt-out.
  • Cover migration of a legacy schema-v10 global hook without prompting.

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 -q
  • uv run --frozen --with pytest pytest tests/test_guard_race_2026_05_18.py::TestR1_DaemonProcessRace::test_two_processes_one_winner -q
  • uv run --frozen --with pytest pytest -x -q reached 791 passed and 5 skipped before the existing nondeterministic guard-race test produced two winners in one of 50 iterations; its isolated rerun passed.

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
eggrollofchaos marked this pull request as ready for review July 15, 2026 06:27

@eggrollofchaos eggrollofchaos left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review result at exact head 0dcf2ba06af7a77c74d51ce977ecd35064881b38.

Critical

  1. src/cozempic/cli.py:2098-2117, 2234-2272 — the new admission decision scans both settings.json and settings.local.json, but the selected mutator (run_initwire_hooks) only refreshes settings.json. This makes a stale Cozempic hook in settings.local.json permanently unrefreshable. More seriously, src/cozempic/init.py:636-666 promises that the marker left by global uninstall prevents future rewiring, but uninstall also removes hooks only from settings.json. If a stale hook remains in settings.local.json, the next ordinary command treats it as installation consent and silently recreates all global hooks in settings.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 by cozempic list recreated 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 exclude settings.local.json from that decision and preserve an explicit uninstall/decline state that residual hooks cannot override. Add regression tests for stale-only settings.local.json, mixed current/stale files, duplicate same-matcher entries, and uninstall followed by a normal command.

High

None.

Medium

  1. 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 in init.py and 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 eggrollofchaos left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review verdict: Critical/High findings remain.

High

  1. 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.json is now reported as uninitialized, so _maybe_auto_init() calls run_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 confirmed run_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 managed settings.json, while project auto-init should continue recognizing current effective hooks in either project settings file. Add a regression test for current hooks in project settings.local.json with no project settings.json.

  2. src/cozempic/cli.py:2107-2110 and src/cozempic/init.py:139-144 — a marker plus malformed/unreadable settings.json is 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 before wire_hooks() can surface its parse error. I reproduced this with a marker and {broken json: the command emitted no failure and never called run_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

  1. 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

  1. src/cozempic/cli.py:2115-2117 — the comment says existing stale hooks establish consent, but interactive only 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.
@sonarqubecloud

Copy link
Copy Markdown

@eggrollofchaos eggrollofchaos left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Self-review verdict: Critical/High findings remain.

High

  1. src/cozempic/cli.py:2280-2295 — the global-current skip still calls the project-wide predicate for ~/.claude, so unmanaged ~/.claude/settings.local.json state can override a current managed settings.json. At this exact head, a current global settings.json plus a stale global settings.local.json makes 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.

  2. src/cozempic/cli.py:2161-2175 and tests/test_global_init.py:621-636 — an operational run_init() failure is still converted into the same marker state as an explicit decline. An exact-head PermissionError probe showed the first attempt touched the marker and the second invocation silently skipped run_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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant