Skip to content

fix(hooks): JSON-aware claude settings upgrade preserves customizations#2072

Merged
sjarmak merged 7 commits into
gastownhall:mainfrom
sjarmak:maintainer/2021-hooks-json-aware
May 14, 2026
Merged

fix(hooks): JSON-aware claude settings upgrade preserves customizations#2072
sjarmak merged 7 commits into
gastownhall:mainfrom
sjarmak:maintainer/2021-hooks-json-aware

Conversation

@sjarmak
Copy link
Copy Markdown
Contributor

@sjarmak sjarmak commented May 13, 2026

Take-the-good of #2021 by @realies. The 2 commits from #2021 cherry-pick cleanly against current main — the hooks.go conflict the original review anticipated has been absorbed by intervening main commits. One follow-up commit on top fixes two misspell lint findings (marshalled/marshalling → marshaled/marshaling, doc/error-string only). All gates green.

Co-authored-by: realies 5107843+realies@users.noreply.github.com

Closes #2021 via supersession.

realies and others added 6 commits May 13, 2026 11:49
Adds a discriminator that exercises pipex-city's exact shape: an extra
managed PreCompact command stuck on the bare `gc handoff "context cycle"`
form (legacy from before gastownhall#1825 introduced `--auto`), combined with a
user-customized Stop hook event that the embedded template never ships.

The byte-enumerated claudeFileNeedsUpgrade enumerates 16 transforms of the
canonical template; any custom addition defeats every variant match.
This left cities like pipex-city silently stuck on the bare-handoff form
for weeks after 7b3b913 landed, because their non-canonical Stop hook
prevented the upgrade gate from firing.

The test fails on parent (PreCompact stays bare) and is paired with a
JSON-aware upgrader in the follow-up commit that handles the customized
shape correctly.
Replaces the byte-enumerated claudeFileNeedsUpgrade with a JSON-aware
walker (upgradeClaudeFile) that parses settings.json, iterates hook
events, and applies event-scoped command/matcher upgrades to managed
gascity commands while leaving any custom hook events or custom
commands verbatim.

desiredClaudeSettings now calls upgradeClaudeFile on the user override
before merging with the embedded base, so customizations survive while
managed commands get upgraded. The previous "use base instead" gate in
readClaudeSettingsOverride is removed: cities whose settings.json had
stale managed-hook commands AND user customizations no longer fall
through to the base, which silently discarded customizations.

Currently-known upgrades, all event-scoped:

  - PreCompact: `gc prime --hook` (pre-handoff legacy) →
    `gc handoff --auto "context cycle"`
  - PreCompact: bare `gc handoff "context cycle"` →
    `gc handoff --auto "context cycle"` (the gastownhall#1825 / 7b3b913 patch
    that previously never propagated to customized cities)
  - SessionStart: bare `gc prime --hook` →
    `GC_MANAGED_SESSION_HOOK=1 GC_HOOK_EVENT_NAME=SessionStart gc prime --hook`
  - SessionStart: empty matcher → "startup"

Custom hook events (Stop, custom UserPromptSubmit entries, etc.) and
any non-managed commands within a hook entry are untouched.

The companion test in a4418c18 (TestInstallClaudeUpgradesPreCompact
PreservingCustomHookEvent) exercises pipex-city's exact shape (bare
PreCompact + extra Stop hook): FAIL on parent, PASS here.

Observed locally: pipex-city had been stuck on bare-handoff for weeks
after 7b3b913 landed, every PreCompact event killing the session,
because the city's extra Stop hook defeated the byte-enumerate match.
…configs

PR gastownhall#2072's initial JSON-aware upgrade used bare strings.Contains on
'gc prime --hook' and similar legacy command tokens. The adversarial
multi-model review surfaced two real regressions:

1. isStaleHookFile (via claudeFileNeedsUpgrade) would treat any
   user-owned hooks/claude.json containing 'gc prime --hook' as a
   stale GC-managed file and overwrite it on Install, violating the
   'remains user-owned unless gc can prove it is safe to update a
   stale generated copy' contract.

2. upgradeClaudeHookEntry would normalize ANY SessionStart entry with
   matcher:"" to matcher:"startup" and rewrite ANY command body
   containing the legacy substring, silently mutating user-authored
   wrappers like 'my-wrapper gc prime --hook --foo'.

Fix:

- Introduce startsWithLegacyCommandToken (HasPrefix + shell-token
  boundary check) so 'gc prime --hookable' and 'my-wrapper gc prime
  --hook --foo' no longer match.
- Introduce commandBodyAfterCanonicalPrefix to strip the canonical
  PATH-export prefix so gc's own managed forms still upgrade while
  user-arbitrary prefixes do not.
- Introduce isLegacyGCManagedCommand and gate matcher normalization
  on at least one hook command in the entry matching a known legacy
  form; user-authored entries with matcher:"" survive untouched.
- Log unexpected upgrade failures (previously silent) so a
  MarshalCanonicalJSON regression would be discoverable.

Tests added:

- TestInstallClaudeDoesNotClobberUserWrappedCommand — regression for
  Codex finding 1.
- TestInstallClaudeDoesNotNormalizeUserAuthoredEmptyMatcher —
  regression for Codex finding 2.
- TestInstallClaudeIdempotent — code-reviewer finding (Codex path had
  a byte-stability test; Claude path did not).

All gates green: go test -race ./internal/hooks/, go vet, make build,
make lint (0 issues).
The pass-1 fixup blocked wrapper prefixes ("my-wrapper gc prime --hook")
via token-anchored prefix matching, but the token boundary accepted any
whitespace-bounded suffix — so user-authored commands like
"gc prime --hook --my-flag" or "gc prime --hook && echo extra" still
matched as managed and were silently rewritten on every gc run, with
matcher:"" normalized to "startup" alongside.

Tighten the legacy-form check to require exact-body match after the
canonical PATH-prefix strip. gc has only ever emitted these tokens as
the complete command body, never with trailing user content, so any
deviation is user authorship and must be left alone. Also anchor the
SessionStart env-var-prefix check on the full current-form preamble
rather than the bare "GC_MANAGED_SESSION_HOOK=" token.

Add two regression tests covering the suffix-append shapes
("gc prime --hook --my-flag" on SessionStart, "gc prime --hook && echo"
on PreCompact) — both fail against the prior token-prefix check and
pass against the exact-body check.
…cognition

The iter-2 change tightened the legacy-form recognition to exact-body
match but left the SessionStart current-form path using
strings.HasPrefix(body, sessionStartCurrentFormBody) on the rationale
of "tolerate future trailing args gc may add." That carve-out
re-opens the same suffix-append class the prior commit closed: a
user-authored command beginning with the canonical env-var preamble
plus extra args (e.g. "GC_MANAGED_SESSION_HOOK=1 GC_HOOK_EVENT_NAME=
SessionStart gc prime --hook --my-flag") still classified as managed,
driving matcher:"" → "startup" normalization on a user-authored entry.

Switch the current-form recognition to equalsLegacyCommandBody. If gc
ever extends the current-form emission with additional arguments,
update sessionStartCurrentFormBody alongside the emission site rather
than relaxing the recognition gate. The function-level doc on
isLegacyGCManagedCommand is updated so the exact-body invariant
applies uniformly across all recognition paths.

Add a regression test exercising the current-form suffix-append shape
that fails against the iter-2 HasPrefix variant and passes under
exact-body match.
@sjarmak sjarmak marked this pull request as ready for review May 14, 2026 13:19
Copilot AI review requested due to automatic review settings May 14, 2026 13:19
@github-actions github-actions Bot added the status/needs-triage Inbox — we haven't looked at it yet label May 14, 2026
@sjarmak sjarmak enabled auto-merge (squash) May 14, 2026 13:21
@sjarmak sjarmak merged commit b9fdfee into gastownhall:main May 14, 2026
90 checks passed
@sjarmak sjarmak deleted the maintainer/2021-hooks-json-aware branch May 14, 2026 13:24
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR makes Claude hook/settings upgrades JSON-aware so managed Gas City hook commands (and SessionStart matcher) can be upgraded in-place while preserving user customizations, fixing cases where byte-exact template matching prevented upstream hook fixes from propagating to customized cities.

Changes:

  • Replace Claude settings “needs upgrade” detection with a JSON-walking upgrader (upgradeClaudeFile) that applies event-scoped command/matcher upgrades without touching non-managed entries.
  • Apply the upgrader to the user override before merging with embedded defaults; remove the previous !claudeFileNeedsUpgrade(...) gating that caused customized files to fall back to base.
  • Add regression tests covering preservation of custom hook events and preventing accidental rewrites of user-authored wrapper/suffix/chained commands; add an idempotence test.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

File Description
internal/hooks/hooks.go Adds JSON-aware Claude settings upgrade logic; adjusts override selection/merge behavior to preserve customizations while upgrading managed hook commands.
internal/hooks/hooks_test.go Adds focused regression coverage for customization-preserving upgrades and non-clobbering heuristics, plus an idempotence check.
Comments suppressed due to low confidence (1)

internal/hooks/hooks.go:687

  • upgradeClaudeFile’s doc comment says custom hook events/commands are preserved “verbatim”, but the function re-emits canonical JSON when it makes any change, so whitespace/key ordering won’t be preserved. Suggest rewording to avoid implying byte-level preservation.
// upgradeClaudeFile parses the existing Claude settings.json and patches
// known legacy forms of managed gascity hook commands and matchers to their
// current shape. Walks the hook events so upgrades can be event-aware
// (e.g. SessionStart matcher upgrade, PreCompact command upgrade); custom
// hook events and custom commands are preserved verbatim.
//
// Returns the (possibly re-marshaled) JSON bytes and whether any patch
// was applied.

Comment thread internal/hooks/hooks.go
Comment on lines +327 to +332
// Upgrade failure (e.g., malformed JSON) — fall back to original
// override; better to keep the user file as-is than fail install.
// Log so unexpected upgrade failures are discoverable rather than
// silent: a malformed user file is benign here, but a
// MarshalCanonicalJSON failure would indicate a gascity bug.
log.Printf("hooks: claude settings upgrade failed, using original override: %v", upgradeErr)
Comment thread internal/hooks/hooks.go
Comment on lines +322 to +324
// preserved verbatim. The previous "use base instead" path discarded
// user customizations along with stale managed-hook bytes; this path
// patches the managed bytes while keeping customizations intact.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

status/needs-triage Inbox — we haven't looked at it yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants