From c106a3919e7e99c72ae7e546fa4d958090004648 Mon Sep 17 00:00:00 2001 From: Shinji Saito Date: Mon, 31 Aug 2026 14:27:57 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20=E9=9D=9E=E5=AF=BE=E8=A9=B1=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=E3=81=A7=20kit=20=E3=81=AE=20hook=20=E3=82=A8?= =?UTF-8?q?=E3=83=B3=E3=83=88=E3=83=AA=E3=81=8C=E9=87=8D=E8=A4=87=E3=81=99?= =?UTF-8?q?=E3=82=8B=E5=95=8F=E9=A1=8C=E3=82=92=E4=BF=AE=E6=AD=A3=EF=BC=88?= =?UTF-8?q?v0.77.0=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #163 _merge_arrays_3way は配列要素を完全一致で比較していた。キット側がエントリを 変更すると(matcher の変更、async / asyncTimeout の追加、コマンドの改名)、 live 側の旧世代が snapshot のどの要素とも一致しなくなり「ユーザーが追加した 要素」に分類され、次の行で無条件にキットの新エントリと並べて連結されていた。 merged="$(jq -n --argjson n --argjson ua '$n + $ua | unique')" 結果として同じ hook が二重に登録される。matcher の差分だけで発生し、実測では 3 エントリが 6 エントリになる。 一度発生すると解消しない。_update_phase_snapshot は settings.json の snapshot に「マージ結果ではなくキットが生成した版」を保存する(利用者の変更を黙って 上書きしないための意図的な設計)。stale エントリは snapshot に入らないため、 以降の更新でも毎回「ユーザー追加」と分類され続ける。 _strip_retired_hook_entries はコマンド文字列で判定するため捕捉できない。今回の ケースは stale と現行でコマンドが byte 同一で、matcher だけが異なる。同関数の コメント(L1325-1329)はこの障害クラス自体を既に認識しているが、対処は 2 つの コマンドに対する場当たり的な allowlist にとどまっていた。 修正: identity ベースの判定 hook エントリは hooks[].command の集合を論理的な登録単位とみなす。この identity が snapshot と新キットの両方に存在する要素は「キットが変更した既存エントリ」で あり、ユーザー追加ではないと判断してキット版を採用する。 - identity が snapshot に無い要素(利用者独自の hook)は従来どおり保持 - identity が snapshot にあり新キットに無い要素は従来どおり kit-removed - 文字列配列(permissions.allow / deny)は identity が要素そのものになるため identity 一致が定義上ありえず、挙動は変わらない 既存の壊れた環境は次回の非対話更新で自動的に解消される。手作業での settings.json 編集は不要。 あわせて修正: 重複排除がソートしていた点 unique は配列をソートする。.hooks.PreToolUse は配列順に実行され、 lib/features.sh は safety-net が先頭であることを要求している(lib/deploy.sh が ビルド時に FATAL で検査)。しかしこの検査はビルド時の _FEATURE_ORDER のみを見て おり、更新時のマージは通らない。実測で、コマンド文字列が cc-safety-net より 辞書順で前に来るユーザー hook("!" 始まり)が index 0 を奪うことを確認した。 順序を保つ重複排除に置き換えた。 docs/GUIDES/hooks-reference.md の「safety-net の PreToolUse エントリは常に先頭」 という記述は、この修正により更新経路でも成立するようになる。 影響範囲 発生していたのは _MERGE_INTERACTIVE が false の経路のみ。_merge_arrays_3way の 本番呼び出し元は lib/merge.sh:288 の 1 箇所で、この条件の内側にある。/update-kit と対話的な再実行は配列まるごと置換の経路を通るため該当しない。 MDM は _merge_settings_mdm_documents がキット文書を丸ごと採用して早期に return するため、_merge_arrays_3way に到達しない(lib/merge.sh:779-780, 925-926)。 検証: - shellcheck -S warning lib/merge.sh / tests/unit/test-merge.sh ともにクリーン - tests/unit/test-merge.sh: 39/39 通過(修正前は 33/39、新規 6 件が全て失敗) - tests/run-unit-tests.sh: 790 PASS。失敗は既存の fonts 1 件のみ - tests/run-scenarios.sh: 更新経路(update-from-v019 / v020 / no-manifest / v019-to-latest-direct / partial-failure-recovery)を含め失敗なし Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 14 +++++ README.en.md | 2 +- README.md | 2 +- lib/merge.sh | 47 ++++++++++++-- tests/unit/test-merge.sh | 131 ++++++++++++++++++++++++++++++++++++++- 5 files changed, 187 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 90fae83..146f3fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,20 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/), and this project adheres to [Semantic Versioning](https://semver.org/). +## [0.77.0] - 2026-08-31 + +### Fixed +- **非対話更新で kit の hook エントリが重複しなくなった(#163)**: `_merge_arrays_3way` は配列要素を**完全一致**で比較していた。キット側がエントリを変更すると(`matcher` の変更、`async` / `asyncTimeout` の追加、コマンドの改名)、live 側の旧世代が snapshot のどの要素とも一致しなくなり「ユーザーが追加した要素」に分類され、`$n + $ua` の**無条件連結**でキットの新エントリと並べて残されていた。結果として同じ hook が二重に登録される + - **一度発生すると解消しなかった**: `_update_phase_snapshot` は settings.json の snapshot に「マージ結果ではなくキットが生成した版」を保存する(利用者の変更を黙って上書きしないための意図的な設計)。stale エントリは snapshot に入らないため、以降の更新でも毎回「ユーザー追加」と分類され続けていた + - **判定を identity ベースにした**: hook エントリは `hooks[].command` の集合を論理的な登録単位とみなす。この identity が snapshot と新キットの両方に存在する要素は「キットが変更した既存エントリ」であり、ユーザー追加ではないと判断してキット版を採用する。identity が snapshot に無い要素(利用者独自の hook)は従来どおり保持され、identity が snapshot にあり新キットに無い要素は従来どおり kit-removed として扱われる + - **既存の壊れた環境は次回の非対話更新で自動的に解消される**。手作業での `settings.json` 編集は不要 + - 発生していたのは `_MERGE_INTERACTIVE` が false の経路(auto-update hook 経由の更新、`--non-interactive`、`install.sh` の `--update`)のみ。`/update-kit` と対話的な再実行は配列まるごと置換の経路を通るため該当しない +- **配列マージがキットの並び順を保持するようになった**: 重複排除に使っていた `unique` は配列を**ソート**する。`.hooks.PreToolUse` は配列順に実行され、`lib/features.sh` は safety-net が先頭であることを要求している(`lib/deploy.sh` がビルド時に FATAL で検査)。しかしこの検査はビルド時の `_FEATURE_ORDER` のみを見ており、更新時のマージは通らない。実測で、コマンド文字列が `cc-safety-net` より辞書順で前に来るユーザー hook(例: `!` 始まり)が index 0 を奪うことを確認した。順序を保つ重複排除に置き換えた + +### Changed +- **`permissions.allow` / `deny` などの文字列配列の並び順が変わる**: 上記の重複排除の変更により、非対話更新後の配列はアルファベット順ではなく「キットの順序 → ユーザー追加分」の順になる。要素の集合は変わらず、権限判定の挙動にも影響はないが、更新後の `settings.json` の差分としては見える +- **キット所有の hook エントリを利用者が直接編集していた場合、更新でキット版に戻るようになった**: 従来は新旧が両方残って同じ hook が二重に登録されていた。単一の登録として扱う以上どちらかを選ぶ必要があり、キット管理下のエントリはキット版を正とする。利用者独自のコマンドを持つ hook は影響を受けない + ## [0.76.0] - 2026-08-24 ### Added diff --git a/README.en.md b/README.en.md index fcec842..65a1135 100644 --- a/README.en.md +++ b/README.en.md @@ -255,7 +255,7 @@ Automatically checks for new starter kit releases on GitHub on both `SessionStar - **Every-session checks**: Checks at both session start and session end - **Background execution**: Hooks run asynchronously, so the session is not blocked - **Lock-based deduplication**: A running auto-update prevents duplicate concurrent runs -- **Settings preserved**: 3-way merge keeps your customizations intact +- **Settings preserved**: 3-way merge keeps settings and hooks you added yourself. A kit-shipped hook entry you edited in place is restored to the kit's version on update - **One-liner installs only**: Only works when the kit is installed at `~/.claude-starter-kit/` - **SessionEnd is best-effort**: abrupt termination may skip the end-of-session check - **Compatibility**: Verified on Claude Code `2.1.89`. When an older Claude Code is detected, the kit falls back to the legacy `SessionStart` + 24h cache hook diff --git a/README.md b/README.md index 2e4fc4f..f81b7bd 100644 --- a/README.md +++ b/README.md @@ -526,7 +526,7 @@ SessionStart / SessionEnd フック発火 - **毎セッション確認**: 起動直後と終了時の両方で最新版を確認します - **lock で重複防止**: すでに自動更新が動いている間は二重実行しません -- ユーザー設定は 3-way merge で保持されます(手動カスタマイズが消えることはありません) +- ユーザー設定は 3-way merge で保持されます(自分で追加した設定・hook が消えることはありません)。ただし kit が配布している hook エントリ自体を書き換えていた場合は、更新時に kit 版が優先されます - ワンライナーインストール(`~/.claude-starter-kit/`)の場合のみ動作します - **SessionEnd は best-effort**: 異常終了時は start 側のチェックだけに依存します - **dirty check**: kit リポジトリにローカル変更がある場合は更新をスキップし、`git stash` を案内します diff --git a/lib/merge.sh b/lib/merge.sh index 1ccfe29..47cd14a 100644 --- a/lib/merge.sh +++ b/lib/merge.sh @@ -167,7 +167,20 @@ _merge_settings_mdm_documents() { # - Items in snapshot but not newkit, still in current # → kit removed → ask (interactive) # keep (non-interactive) -# - Deduplicate via jq 'unique' +# - Items whose IDENTITY the kit shipped before and still ships +# → kit modified → kit's version wins +# - Deduplicate while preserving order +# +# Identity exists because elements are compared by exact equality. A kit-side +# EDIT of a hook entry (matcher, async, asyncTimeout, a renamed command) makes +# the live copy unequal to every snapshot element, so it used to be classified +# as a user addition and re-appended NEXT TO the kit's new version — leaving +# the same hook registered twice, permanently (the snapshot stores the kit +# build, so the stale copy is re-classified as a user addition on every later +# update). Hook entries carry a "hooks" array of commands; that command set is +# the logical registration, independent of the matcher it is bound to. Any +# other element (permissions strings, plain scalars) is its own identity, so +# an identity match is impossible for them and their behaviour is unchanged. # --------------------------------------------------------------------------- _merge_arrays_3way() { local s_val="$1" # snapshot array JSON @@ -175,13 +188,27 @@ _merge_arrays_3way() { local n_val="$3" # new-kit array JSON # Determine items to keep/remove through set operations via jq - # user_added = current \ snapshot (user introduced these) + # user_added = current \ snapshot, minus superseded kit generations # kit_removed = snapshot \ newkit, still in current (kit deleted these) local user_added kit_removed user_added="$(jq -n \ --argjson s "$s_val" \ --argjson c "$c_val" \ - '[$c[] | select(. as $item | $s | map(. == $item) | any | not)]')" || return 1 + --argjson n "$n_val" \ + 'def ident: if type == "object" and ((.hooks? | type) == "array") + then [.hooks[]?.command?] else . end; + ($s | map(ident)) as $sids | + ($n | map(ident)) as $nids | + [ $c[] + | . as $item + | ($item | ident) as $id + | select(($s | any(. == $item)) | not) + # Superseded kit generation, not a user addition: the kit shipped this + # identity before and still ships it, so the live copy is an older or + # locally edited generation of an entry the kit owns. Drop it and let + # the kit version in $n stand alone. + | select((($sids | any(. == $id)) and ($nids | any(. == $id))) | not) + ]')" || return 1 kit_removed="$(jq -n \ --argjson s "$s_val" \ @@ -197,11 +224,17 @@ _merge_arrays_3way() { # Base result: newkit array (authoritative — already includes kit_added items) # plus user additions (items the user added that the kit never had) + # + # Dedup preserves order. `unique` sorts, and .hooks.* arrays are ordered: + # PreToolUse runs in array order and safety-net is required to be first + # (lib/features.sh, asserted for the build in lib/deploy.sh). Sorting let a + # user hook whose command sorts earlier take index 0 away from safety-net. local merged merged="$(jq -n \ --argjson n "$n_val" \ --argjson ua "$user_added" \ - '$n + $ua | unique')" || return 1 + 'reduce ($n + $ua)[] as $x ([]; + if any(.[]; . == $x) then . else . + [$x] end)')" || return 1 # Handle kit-removed items that still exist in current local removed_count @@ -213,7 +246,8 @@ _merge_arrays_3way() { merged="$(jq -n \ --argjson m "$merged" \ --argjson kr "$kit_removed" \ - '$m + $kr | unique')" || return 1 + 'reduce ($m + $kr)[] as $x ([]; + if any(.[]; . == $x) then . else . + [$x] end)')" || return 1 else # Interactive: ask for each removed item local i=0 @@ -238,7 +272,8 @@ _merge_arrays_3way() { merged="$(jq -n \ --argjson m "$merged" \ --argjson item "$item" \ - '$m + [$item] | unique')" || return 1 + 'reduce ($m + [$item])[] as $x ([]; + if any(.[]; . == $x) then . else . + [$x] end)')" || return 1 ;; esac i=$((i + 1)) diff --git a/tests/unit/test-merge.sh b/tests/unit/test-merge.sh index 7db3db2..dd96476 100644 --- a/tests/unit/test-merge.sh +++ b/tests/unit/test-merge.sh @@ -212,7 +212,7 @@ _large_merge_doc() { run_func _merge_arrays_3way "$snapshot" "$current" "$new_kit" - # Result should contain: a, b, kit-added, user-added (deduplicated, sorted by unique) + # Result should contain: a, b, kit-added, user-added (deduplicated, kit order first) result="$_RF_STDOUT" has_user="$(printf '%s' "$result" | jq 'map(select(. == "user-added")) | length')" has_kit="$(printf '%s' "$result" | jq 'map(select(. == "kit-added")) | length')" @@ -889,3 +889,132 @@ _large_merge_doc() { fi unset s_val c_val n_val merged key_bytes } + +# --------------------------------------------------------------------------- +# 31. _merge_arrays_3way: a kit-side EDIT of a hook entry is not a user addition +# +# Elements are compared by exact equality, so changing a hook entry's matcher +# (as v0.55.0 did for "*" -> "startup") made the live copy unequal to every +# snapshot element. It was then re-appended next to the kit's new version and +# the same hook stayed registered twice. The snapshot stores the kit build, so +# the stale copy was re-classified as a user addition on every later update and +# never went away. +# --------------------------------------------------------------------------- +{ + test_name="arrays_3way: a kit-side matcher change does not duplicate the hook" + snapshot='[{"matcher":"startup","hooks":[{"type":"command","command":"A.sh"}]},{"matcher":"startup","hooks":[{"type":"command","command":"B.sh"}]}]' + current='[{"matcher":"*","hooks":[{"type":"command","command":"A.sh"}]},{"matcher":"*","hooks":[{"type":"command","command":"B.sh"}]}]' + new_kit="$snapshot" + + run_func _merge_arrays_3way "$snapshot" "$current" "$new_kit" + + if [[ "$_RF_RC" -eq 0 ]] \ + && printf '%s' "$_RF_STDOUT" | jq -e ' + length == 2 and all(.[]; .matcher == "startup")' >/dev/null; then + pass "$test_name" + else + fail "$test_name (got '$_RF_STDOUT')" + fi +} + +# An install already carrying both generations must converge on the next run. +{ + test_name="arrays_3way: an already-duplicated hooks array heals" + snapshot='[{"matcher":"startup","hooks":[{"type":"command","command":"A.sh"}]},{"matcher":"startup","hooks":[{"type":"command","command":"B.sh"}]}]' + current='[{"matcher":"startup","hooks":[{"type":"command","command":"A.sh"}]},{"matcher":"*","hooks":[{"type":"command","command":"A.sh"}]},{"matcher":"*","hooks":[{"type":"command","command":"B.sh"}]},{"matcher":"startup","hooks":[{"type":"command","command":"B.sh"}]}]' + new_kit="$snapshot" + + run_func _merge_arrays_3way "$snapshot" "$current" "$new_kit" + + if [[ "$_RF_RC" -eq 0 ]] \ + && printf '%s' "$_RF_STDOUT" | jq -e ' + length == 2 + and ([.[] | .hooks[0].command] | sort) == ["A.sh","B.sh"]' >/dev/null; then + pass "$test_name" + else + fail "$test_name (got '$_RF_STDOUT')" + fi +} + +# async/asyncTimeout drift is the same class: only the payload differs. +{ + test_name="arrays_3way: an async/asyncTimeout-only difference does not duplicate" + snapshot='[{"matcher":"startup","hooks":[{"type":"command","command":"A.sh","async":true,"asyncTimeout":300000}]}]' + current='[{"matcher":"startup","hooks":[{"type":"command","command":"A.sh"}]}]' + new_kit='[{"matcher":"startup","hooks":[{"type":"command","command":"A.sh","async":true,"asyncTimeout":720000}]}]' + + run_func _merge_arrays_3way "$snapshot" "$current" "$new_kit" + + if [[ "$_RF_RC" -eq 0 ]] \ + && printf '%s' "$_RF_STDOUT" | jq -e ' + length == 1 and .[0].hooks[0].asyncTimeout == 720000' >/dev/null; then + pass "$test_name" + else + fail "$test_name (got '$_RF_STDOUT')" + fi +} + +# Identity only matches entries the kit shipped, so a user's own hook is safe. +{ + test_name="arrays_3way: a user's own hook entry survives the identity check" + snapshot='[{"matcher":"startup","hooks":[{"type":"command","command":"A.sh"}]}]' + current='[{"matcher":"*","hooks":[{"type":"command","command":"A.sh"}]},{"matcher":"startup","hooks":[{"type":"command","command":"MY-OWN.sh"}]}]' + new_kit="$snapshot" + + run_func _merge_arrays_3way "$snapshot" "$current" "$new_kit" + + if [[ "$_RF_RC" -eq 0 ]] \ + && printf '%s' "$_RF_STDOUT" | jq -e ' + length == 2 + and any(.[]; .hooks[0].command == "MY-OWN.sh") + and ([.[] | select(.hooks[0].command == "A.sh")] | length) == 1' >/dev/null; then + pass "$test_name" + else + fail "$test_name (got '$_RF_STDOUT')" + fi +} + +# --------------------------------------------------------------------------- +# 32. _merge_arrays_3way: dedup must not reorder +# +# PreToolUse runs in array order and safety-net is required to be first +# (lib/features.sh; lib/deploy.sh asserts it for the build). `unique` sorts, so +# a user hook whose command sorted earlier took index 0 away from safety-net. +# --------------------------------------------------------------------------- +{ + test_name="arrays_3way: dedup preserves kit order (safety-net stays first)" + snapshot='[{"matcher":"Bash","hooks":[{"type":"command","command":"cc-safety-net"}]}]' + current='[{"matcher":"Bash","hooks":[{"type":"command","command":"cc-safety-net"}]},{"matcher":"Bash","hooks":[{"type":"command","command":"!my-own-hook"}]}]' + new_kit='[{"matcher":"Bash","hooks":[{"type":"command","command":"cc-safety-net"}]},{"matcher":"Bash","hooks":[{"type":"command","command":"zz-new-kit"}]}]' + + run_func _merge_arrays_3way "$snapshot" "$current" "$new_kit" + + if [[ "$_RF_RC" -eq 0 ]] \ + && printf '%s' "$_RF_STDOUT" | jq -e ' + .[0].hooks[0].command == "cc-safety-net" + and length == 3' >/dev/null; then + pass "$test_name" + else + fail "$test_name (got '$_RF_STDOUT')" + fi +} + +# String arrays have no "hooks" key, so identity is the element itself and no +# identity match is possible — permissions keep their existing semantics. +{ + test_name="arrays_3way: string arrays keep user additions and kit removals" + snapshot='["Read","WebFetch"]' + current='["Read","WebFetch","Mine"]' + new_kit='["Read","Bash(git diff)"]' + + run_func _merge_arrays_3way "$snapshot" "$current" "$new_kit" + + if [[ "$_RF_RC" -eq 0 ]] \ + && printf '%s' "$_RF_STDOUT" | jq -e ' + (. | sort) == ["Bash(git diff)","Mine","Read","WebFetch"] + and .[0] == "Read"' >/dev/null; then + pass "$test_name" + else + fail "$test_name (got '$_RF_STDOUT')" + fi +} From bff2f05e1dd372fd7ecdc86d13e15a8e44c8bec9 Mon Sep 17 00:00:00 2001 From: okash1n <48118431+okash1n@users.noreply.github.com> Date: Mon, 31 Aug 2026 16:37:08 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20kit=5Fremoved=20=E3=81=AB=E3=82=82?= =?UTF-8?q?=20identity=20=E5=88=A4=E5=AE=9A=E3=82=92=E9=81=A9=E7=94=A8?= =?UTF-8?q?=E3=81=97=E3=80=81=E6=9B=B4=E6=96=B0=E6=AF=8E=E3=81=AE=E4=B8=96?= =?UTF-8?q?=E4=BB=A3=E6=8E=83=E9=99=A4=E3=81=A7=E6=97=A2=E5=AD=98=E3=81=AE?= =?UTF-8?q?=E9=87=8D=E8=A4=87=E3=82=92=E8=A7=A3=E6=B6=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit レビューで確定した 3 点を修正する。 1. kit_removed 経路に identity フィルタが無く、キットがエントリを変更する その更新で snapshot と完全一致する旧世代が kit-removed に分類され、 非対話分岐で新版の隣に復活していた(#163 と同じ重複が再発する)。 identity が新キットに残る snapshot 要素を kit_removed から除外した。 2. 「既存の壊れた環境は次回の非対話更新で自動的に解消される」が誤りだった。 3-way マージは snapshot・現行・新キットがすべて異なるときにしか走らず、 キットが該当配列を変更しない更新では治癒しない。マージ後に毎回実行される _strip_superseded_kit_hook_generations を追加し、キット現行エントリと 並んで残る旧世代(同じ command 集合・内容違い・キット版が同配列に存在)を 取り除くようにした。対話・非対話の両経路で治癒する。 3. 「コマンドの改名」は identity(command 集合)自体が変わるため本修正では 治らないクラス。コメント・CHANGELOG から除き、_strip_retired_hook_entries の担当であることを明記した。README の「消えることはありません」も kit コマンド再利用エントリの統合を含む正確な但し書きに改めた。 回帰テスト: test-merge.sh に kit_removed 経路 2 件、test-retired-hooks.sh に 世代掃除 4 件を追加。修正前のコードでは kit_removed 系 1 件と世代掃除 3 件が 失敗する。 --- CHANGELOG.md | 10 +-- README.en.md | 2 +- README.md | 2 +- lib/merge.sh | 28 +++++--- lib/update.sh | 74 +++++++++++++++++++ tests/unit/test-merge.sh | 43 +++++++++++ tests/unit/test-retired-hooks.sh | 120 +++++++++++++++++++++++++++++++ 7 files changed, 263 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 146f3fc..b950a14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,16 +7,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this ## [0.77.0] - 2026-08-31 ### Fixed -- **非対話更新で kit の hook エントリが重複しなくなった(#163)**: `_merge_arrays_3way` は配列要素を**完全一致**で比較していた。キット側がエントリを変更すると(`matcher` の変更、`async` / `asyncTimeout` の追加、コマンドの改名)、live 側の旧世代が snapshot のどの要素とも一致しなくなり「ユーザーが追加した要素」に分類され、`$n + $ua` の**無条件連結**でキットの新エントリと並べて残されていた。結果として同じ hook が二重に登録される +- **非対話更新で kit の hook エントリが重複しなくなった(#163)**: `_merge_arrays_3way` は配列要素を**完全一致**で比較していた。キット側がエントリを変更すると(`matcher` の変更、`async` / `asyncTimeout` の追加)、live 側の旧世代が snapshot のどの要素とも一致しなくなり「ユーザーが追加した要素」に分類され、`$n + $ua` の**無条件連結**でキットの新エントリと並べて残されていた。結果として同じ hook が二重に登録される(コマンド自体の改名は identity ごと変わる別クラスで、従来どおり `_strip_retired_hook_entries` が対象コマンドを掃除する) - **一度発生すると解消しなかった**: `_update_phase_snapshot` は settings.json の snapshot に「マージ結果ではなくキットが生成した版」を保存する(利用者の変更を黙って上書きしないための意図的な設計)。stale エントリは snapshot に入らないため、以降の更新でも毎回「ユーザー追加」と分類され続けていた - - **判定を identity ベースにした**: hook エントリは `hooks[].command` の集合を論理的な登録単位とみなす。この identity が snapshot と新キットの両方に存在する要素は「キットが変更した既存エントリ」であり、ユーザー追加ではないと判断してキット版を採用する。identity が snapshot に無い要素(利用者独自の hook)は従来どおり保持され、identity が snapshot にあり新キットに無い要素は従来どおり kit-removed として扱われる - - **既存の壊れた環境は次回の非対話更新で自動的に解消される**。手作業での `settings.json` 編集は不要 - - 発生していたのは `_MERGE_INTERACTIVE` が false の経路(auto-update hook 経由の更新、`--non-interactive`、`install.sh` の `--update`)のみ。`/update-kit` と対話的な再実行は配列まるごと置換の経路を通るため該当しない + - **判定を identity ベースにした**: hook エントリは `hooks[].command` の集合を論理的な登録単位とみなす。この identity が snapshot と新キットの両方に存在する要素は「キットが変更した既存エントリ」であり、ユーザー追加ではないと判断してキット版を採用する。kit-removed 側にも同じ判定を適用し、identity が新キットに残っている snapshot 要素(=キットが「変更」したエントリ)を「キットが削除したエントリ」として復活させない。identity が snapshot に無い要素(利用者独自の hook)は従来どおり保持され、identity が新キットからも消えた要素は従来どおり kit-removed として扱われる + - **既存の壊れた環境は次回の更新で自動的に解消される**。手作業での `settings.json` 編集は不要。3-way マージは snapshot・現行・新キットがすべて異なるときにしか走らないため、マージだけでは「キットが該当配列を次に変更する更新」まで重複が残る。これを埋めるため、更新のたびにマージ後の settings.json をキット生成版と突き合わせ、キットの現行エントリと並んで残っている旧世代(同じ command 集合で内容が異なり、かつキット現行版が同じ配列に存在するもの)を取り除く `_strip_superseded_kit_hook_generations` を追加した。対話・非対話のどちらの更新経路でも実行される + - 重複が**発生**するのは `_MERGE_INTERACTIVE` が false の経路(auto-update hook 経由の更新、`--non-interactive`、`install.sh` の `--update`)のみ。`/update-kit` と対話的な再実行は配列まるごと置換の経路を通るため該当しない - **配列マージがキットの並び順を保持するようになった**: 重複排除に使っていた `unique` は配列を**ソート**する。`.hooks.PreToolUse` は配列順に実行され、`lib/features.sh` は safety-net が先頭であることを要求している(`lib/deploy.sh` がビルド時に FATAL で検査)。しかしこの検査はビルド時の `_FEATURE_ORDER` のみを見ており、更新時のマージは通らない。実測で、コマンド文字列が `cc-safety-net` より辞書順で前に来るユーザー hook(例: `!` 始まり)が index 0 を奪うことを確認した。順序を保つ重複排除に置き換えた ### Changed - **`permissions.allow` / `deny` などの文字列配列の並び順が変わる**: 上記の重複排除の変更により、非対話更新後の配列はアルファベット順ではなく「キットの順序 → ユーザー追加分」の順になる。要素の集合は変わらず、権限判定の挙動にも影響はないが、更新後の `settings.json` の差分としては見える -- **キット所有の hook エントリを利用者が直接編集していた場合、更新でキット版に戻るようになった**: 従来は新旧が両方残って同じ hook が二重に登録されていた。単一の登録として扱う以上どちらかを選ぶ必要があり、キット管理下のエントリはキット版を正とする。利用者独自のコマンドを持つ hook は影響を受けない +- **キット所有の hook エントリを利用者が直接編集していた場合、キットが該当エントリを変更する更新の時点でキット版に戻るようになった**: 従来は新旧が両方残って同じ hook が二重に登録されていた。単一の登録として扱う以上どちらかを選ぶ必要があり、キット管理下のエントリはキット版を正とする。キット版を残したまま同じコマンド(`hooks[].command` の集合が一致するエントリ)を別の matcher で**追加登録**していた場合は、更新のたびに世代掃除の対象となりキット版 1 件に統合される。利用者独自のコマンドを持つ hook は影響を受けない ## [0.76.0] - 2026-08-24 diff --git a/README.en.md b/README.en.md index 65a1135..e8394da 100644 --- a/README.en.md +++ b/README.en.md @@ -255,7 +255,7 @@ Automatically checks for new starter kit releases on GitHub on both `SessionStar - **Every-session checks**: Checks at both session start and session end - **Background execution**: Hooks run asynchronously, so the session is not blocked - **Lock-based deduplication**: A running auto-update prevents duplicate concurrent runs -- **Settings preserved**: 3-way merge keeps settings and hooks you added yourself. A kit-shipped hook entry you edited in place is restored to the kit's version on update +- **Settings preserved**: 3-way merge keeps settings and hooks that use your own commands. Kit-shipped hook entries are kit-owned: an entry you edited in place, or a kit command you re-registered under another matcher, is consolidated back to the kit's single version on update - **One-liner installs only**: Only works when the kit is installed at `~/.claude-starter-kit/` - **SessionEnd is best-effort**: abrupt termination may skip the end-of-session check - **Compatibility**: Verified on Claude Code `2.1.89`. When an older Claude Code is detected, the kit falls back to the legacy `SessionStart` + 24h cache hook diff --git a/README.md b/README.md index f81b7bd..c15f2b8 100644 --- a/README.md +++ b/README.md @@ -526,7 +526,7 @@ SessionStart / SessionEnd フック発火 - **毎セッション確認**: 起動直後と終了時の両方で最新版を確認します - **lock で重複防止**: すでに自動更新が動いている間は二重実行しません -- ユーザー設定は 3-way merge で保持されます(自分で追加した設定・hook が消えることはありません)。ただし kit が配布している hook エントリ自体を書き換えていた場合は、更新時に kit 版が優先されます +- ユーザー設定は 3-way merge で保持されます(自分のコマンドを使う設定・hook が消えることはありません)。ただし kit が配布している hook エントリは kit 版が正です: エントリ自体を書き換えていた場合や、kit のコマンドを別の matcher で追加登録していた場合は、更新時に kit 版 1 件へ統合されます - ワンライナーインストール(`~/.claude-starter-kit/`)の場合のみ動作します - **SessionEnd は best-effort**: 異常終了時は start 側のチェックだけに依存します - **dirty check**: kit リポジトリにローカル変更がある場合は更新をスキップし、`git stash` を案内します diff --git a/lib/merge.sh b/lib/merge.sh index 47cd14a..7545712 100644 --- a/lib/merge.sh +++ b/lib/merge.sh @@ -172,13 +172,15 @@ _merge_settings_mdm_documents() { # - Deduplicate while preserving order # # Identity exists because elements are compared by exact equality. A kit-side -# EDIT of a hook entry (matcher, async, asyncTimeout, a renamed command) makes -# the live copy unequal to every snapshot element, so it used to be classified -# as a user addition and re-appended NEXT TO the kit's new version — leaving -# the same hook registered twice, permanently (the snapshot stores the kit -# build, so the stale copy is re-classified as a user addition on every later -# update). Hook entries carry a "hooks" array of commands; that command set is -# the logical registration, independent of the matcher it is bound to. Any +# EDIT of a hook entry (matcher, async, asyncTimeout) makes the live copy +# unequal to every snapshot element, so it used to be classified as a user +# addition and re-appended NEXT TO the kit's new version — leaving the same +# hook registered twice, permanently (the snapshot stores the kit build, so +# the stale copy is re-classified as a user addition on every later update). +# Hook entries carry a "hooks" array of commands; that command set is the +# logical registration, independent of the matcher it is bound to. A renamed +# command changes the identity itself and is out of scope here — retired and +# renamed commands are swept by _strip_retired_hook_entries instead. Any # other element (permissions strings, plain scalars) is its own identity, so # an identity match is impossible for them and their behaviour is unchanged. # --------------------------------------------------------------------------- @@ -214,11 +216,19 @@ _merge_arrays_3way() { --argjson s "$s_val" \ --argjson c "$c_val" \ --argjson n "$n_val" \ - '[ + 'def ident: if type == "object" and ((.hooks? | type) == "array") + then [.hooks[]?.command?] else . end; + ($n | map(ident)) as $nids | + [ $s[] | . as $item | select( ($n | map(. == $item) | any | not) and - ($c | map(. == $item) | any) + ($c | map(. == $item) | any) and + # Identity still shipped by the new kit → the kit MODIFIED this entry + # rather than removing it. The updated version in $n replaces this + # generation; re-adding the snapshot copy would register the same + # hook twice — the exact failure this merge exists to prevent. + (($nids | any(. == ($item | ident))) | not) ) ]')" || return 1 diff --git a/lib/update.sh b/lib/update.sh index a2b163a..03f1ccd 100644 --- a/lib/update.sh +++ b/lib/update.sh @@ -1374,6 +1374,74 @@ _strip_retired_hook_entries() { fi } +# --------------------------------------------------------------------------- +# _strip_superseded_kit_hook_generations - Drop stale generations of kit hooks +# +# Usage: _strip_superseded_kit_hook_generations +# +# A hooks array that carries BOTH the kit's current entry and an older kit +# generation of it (same command set, different matcher/async/etc — the +# pre-identity-merge failure mode, #163) runs the same hook twice. The 3-way +# merge only executes when snapshot, current and new kit all differ, so an +# already-duplicated array would otherwise persist until the kit next edits +# that very array. This sweep runs on every update, after the merge, against +# the freshly built kit settings. +# +# An entry is dropped ONLY when all three hold: it differs from every kit +# entry, its command set matches a kit entry's, and that kit entry is itself +# present in the array. A kit entry the user edited IN PLACE (kit's exact +# version absent) is left alone — removing it would deregister the hook. +# User hooks with their own commands never match a kit identity. +# --------------------------------------------------------------------------- +_strip_superseded_kit_hook_generations() { + local settings_file="$1" + local kit_file="$2" + [[ -f "$settings_file" ]] || return 1 + [[ -f "$kit_file" ]] || return 0 + local filter=' + def ident: if type == "object" and ((.hooks? | type) == "array") + then [.hooks[]?.command?] else null end; + def stale($K; $L): + . as $e | + (($K | any(. == $e)) | not) and + (($e | ident) != null) and + ($K | any(ident == ($e | ident))) and + ([$K[] | select(ident == ($e | ident))] | any(. as $k | $L | any(. == $k))); + ($kit[0].hooks // {}) as $KH' + local probe_rc=0 + jq -e --slurpfile kit "$kit_file" "$filter"' | + any((.hooks // {}) | to_entries[]; + ($KH[.key] // []) as $K | .value as $L | + ($L | type) == "array" and any($L[]; stale($K; $L))) + ' "$settings_file" >/dev/null 2>&1 || probe_rc=$? + case "$probe_rc" in + 0) ;; + 1) return 0 ;; + *) return 0 ;; # unreadable kit build or settings — nothing safe to do + esac + local tmp + tmp="$(mktemp)" || return 1 + _SETUP_TMP_FILES+=("$tmp") + if jq --slurpfile kit "$kit_file" "$filter"' | + if .hooks then + .hooks |= (to_entries + | map(($KH[.key] // []) as $K + | .value as $L + | .value |= (if type == "array" + then map(select(stale($K; $L) | not)) + else . end)) + | from_entries) + else . end + ' "$settings_file" > "$tmp" 2>/dev/null; then + mv "$tmp" "$settings_file" || return 1 + ok "Removed superseded kit hook generations from settings.json" + else + rm -f "$tmp" 2>/dev/null || true + warn "Could not strip superseded kit hook generations from settings.json" + return 1 + fi +} + _retired_relative_path_is_safe() { local rel="$1" [[ -n "$rel" && "$rel" != /* && ! "$rel" =~ [[:cntrl:]] ]] || return 1 @@ -1688,6 +1756,12 @@ _update_phase_settings() { # which would leave commands pointing at scripts the retired-file sweep # deletes. Strip them explicitly. _strip_retired_hook_entries "$current_settings" || return 1 + + # Heal arrays that already carry a stale kit hook generation next to the + # kit's current entry (#163). The 3-way merge above only runs when snapshot, + # current and new kit all differ, so without this sweep an already-duplicated + # array persists until the kit next edits that very array. + _strip_superseded_kit_hook_generations "$current_settings" "$new_settings" || return 1 } # _claude_md_user_section_has_content - Returns 0 when the user section of a diff --git a/tests/unit/test-merge.sh b/tests/unit/test-merge.sh index dd96476..bf5fcb9 100644 --- a/tests/unit/test-merge.sh +++ b/tests/unit/test-merge.sh @@ -1018,3 +1018,46 @@ _large_merge_doc() { fail "$test_name (got '$_RF_STDOUT')" fi } + +# The identity rule must also hold on the very update that SHIPS the kit edit. +# There the stale generation is byte-equal to the snapshot, so it never reaches +# the user_added filter — it lands in kit_removed instead, and the +# non-interactive branch used to re-append it next to the kit's new version, +# recreating the duplicate this merge exists to prevent. +{ + test_name="arrays_3way: the update shipping a kit edit does not resurrect the old generation" + snapshot='[{"matcher":"*","hooks":[{"type":"command","command":"A.sh"}]}]' + current='[{"matcher":"*","hooks":[{"type":"command","command":"A.sh"}]},{"matcher":"Bash","hooks":[{"type":"command","command":"MY-OWN.sh"}]}]' + new_kit='[{"matcher":"startup","hooks":[{"type":"command","command":"A.sh"}]}]' + + run_func _merge_arrays_3way "$snapshot" "$current" "$new_kit" + + if [[ "$_RF_RC" -eq 0 ]] \ + && printf '%s' "$_RF_STDOUT" | jq -e ' + length == 2 + and (.[0] == {"matcher":"startup","hooks":[{"type":"command","command":"A.sh"}]}) + and (.[1].hooks[0].command == "MY-OWN.sh")' >/dev/null; then + pass "$test_name" + else + fail "$test_name (got '$_RF_STDOUT')" + fi +} + +# An entry whose identity is GONE from the new kit is genuinely kit-removed and +# keeps the existing non-interactive default: the user's value stays. +{ + test_name="arrays_3way: a truly removed kit entry is still kept non-interactively" + snapshot='[{"matcher":"*","hooks":[{"type":"command","command":"GONE.sh"}]}]' + current='[{"matcher":"*","hooks":[{"type":"command","command":"GONE.sh"}]}]' + new_kit='[]' + + run_func _merge_arrays_3way "$snapshot" "$current" "$new_kit" + + if [[ "$_RF_RC" -eq 0 ]] \ + && printf '%s' "$_RF_STDOUT" | jq -e ' + length == 1 and .[0].hooks[0].command == "GONE.sh"' >/dev/null; then + pass "$test_name" + else + fail "$test_name (got '$_RF_STDOUT')" + fi +} diff --git a/tests/unit/test-retired-hooks.sh b/tests/unit/test-retired-hooks.sh index fa9ffbd..ded9ff1 100644 --- a/tests/unit/test-retired-hooks.sh +++ b/tests/unit/test-retired-hooks.sh @@ -178,4 +178,124 @@ JSON fi } +# ── _strip_superseded_kit_hook_generations ───────────────────────────────── +# +# The 3-way merge only runs when snapshot, current and new kit all differ, so +# a hooks array that already carries a stale kit generation next to the kit's +# current entry (#163) would persist until the kit next edits that very array. +# This post-merge sweep heals it on every update. +_rh_sweep() { # + HOME=/home/u bash -c ' + set -uo pipefail + PROJECT_DIR="'"$PROJECT_DIR"'" + ok(){ :; }; warn(){ :; }; info(){ :; }; is_true(){ [[ "$1" == "true" ]]; } + _SETUP_TMP_FILES=() + source "$PROJECT_DIR/lib/features.sh" + source "$PROJECT_DIR/lib/snapshot.sh" + source "$PROJECT_DIR/lib/update.sh" 2>/dev/null || true + _strip_superseded_kit_hook_generations "'"$1"'" "'"$2"'" + ' +} + +_rh_kit="$_rh_tmp/kit-built.json" +printf '%s\n' '{ + "hooks": { + "SessionStart": [ + {"matcher": "startup", "hooks": [{"type": "command", "command": "A.sh"}]} + ], + "PreToolUse": [ + {"matcher": "*", "hooks": [{"type": "command", "command": "safety.sh"}]} + ] + } +}' > "$_rh_kit" + +{ + test_name="superseded-generations: a stale kit generation next to the current entry is dropped" + _rh_dup="$_rh_tmp/dup.json" + printf '%s\n' '{ + "hooks": { + "SessionStart": [ + {"matcher": "startup", "hooks": [{"type": "command", "command": "A.sh"}]}, + {"matcher": "*", "hooks": [{"type": "command", "command": "A.sh"}]} + ], + "PreToolUse": [ + {"matcher": "*", "hooks": [{"type": "command", "command": "safety.sh"}]}, + {"matcher": "Bash", "hooks": [{"type": "command", "command": "mine.sh"}]} + ] + } +}' > "$_rh_dup" + _rh_sweep "$_rh_dup" "$_rh_kit" >/dev/null 2>&1 + if jq -e ' + (.hooks.SessionStart | length) == 1 + and .hooks.SessionStart[0].matcher == "startup" + and (.hooks.PreToolUse | length) == 2 + and .hooks.PreToolUse[1].hooks[0].command == "mine.sh"' \ + "$_rh_dup" >/dev/null 2>&1; then + pass "$test_name" + else + fail "$test_name" + fi +} + +{ + test_name="superseded-generations: a kit entry edited in place is left alone" + _rh_edited="$_rh_tmp/edited.json" + printf '%s\n' '{ + "hooks": { + "SessionStart": [ + {"matcher": "*", "hooks": [{"type": "command", "command": "A.sh"}]} + ] + } +}' > "$_rh_edited" + _rh_sweep "$_rh_edited" "$_rh_kit" >/dev/null 2>&1 + if jq -e ' + (.hooks.SessionStart | length) == 1 + and .hooks.SessionStart[0].matcher == "*"' \ + "$_rh_edited" >/dev/null 2>&1; then + pass "$test_name" + else + fail "$test_name" + fi +} + +{ + test_name="superseded-generations: user hooks and unknown events are untouched" + _rh_mixed="$_rh_tmp/mixed.json" + printf '%s\n' '{ + "hooks": { + "SessionStart": [ + {"matcher": "startup", "hooks": [{"type": "command", "command": "A.sh"}]}, + {"matcher": "startup", "hooks": [{"type": "command", "command": "mine.sh"}]} + ], + "Stop": [ + {"matcher": "*", "hooks": [{"type": "command", "command": "A.sh"}]} + ] + }, + "permissions": {"allow": ["Read"]} +}' > "$_rh_mixed" + _rh_before="$(jq -cS . "$_rh_mixed")" + _rh_sweep "$_rh_mixed" "$_rh_kit" >/dev/null 2>&1 + if [[ "$(jq -cS . "$_rh_mixed")" == "$_rh_before" ]]; then + pass "$test_name" + else + fail "$test_name" + fi +} + +{ + test_name="superseded-generations: a missing or broken kit build changes nothing" + _rh_safe="$_rh_tmp/safe.json" + printf '{"hooks":{"SessionStart":[{"matcher":"*","hooks":[{"type":"command","command":"A.sh"}]}]}}\n' > "$_rh_safe" + printf 'not json' > "$_rh_tmp/broken-kit.json" + _rh_before="$(jq -cS . "$_rh_safe")" + _rh_rc1=0; _rh_sweep "$_rh_safe" "$_rh_tmp/no-such-kit.json" >/dev/null 2>&1 || _rh_rc1=$? + _rh_rc2=0; _rh_sweep "$_rh_safe" "$_rh_tmp/broken-kit.json" >/dev/null 2>&1 || _rh_rc2=$? + if [[ "$_rh_rc1" -eq 0 && "$_rh_rc2" -eq 0 \ + && "$(jq -cS . "$_rh_safe")" == "$_rh_before" ]]; then + pass "$test_name" + else + fail "$test_name" + fi +} + rm -rf "$_rh_tmp"