fix: 非対話更新で kit の hook エントリが重複する問題を修正(v0.77.0) - #165
Conversation
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) <noreply@anthropic.com>
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: c106a3919e
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| 'reduce ($m + $kr)[] as $x ([]; | ||
| if any(.[]; . == $x) then . else . + [$x] end)')" || return 1 |
There was a problem hiding this comment.
Do not re-add superseded snapshot hooks
When a non-interactive update first changes a kit hook, the live entry normally still equals the snapshot, so it is excluded from user_added but classified as kit_removed; this reducer then appends that old entry after the new kit entry. For example, changing only matcher from "*" to "startup" produces both entries until another update, while a command rename never heals because the new identity differs permanently. This means auto-update can still register both generations, so superseded snapshot hooks must be distinguished from genuinely removed hooks before retaining kit_removed.
AGENTS.md reference: AGENTS.md:L168-L172
Useful? React with 👍 / 👎.
レビューで確定した 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 件が 失敗する。
|
マージ前レビュー(多視点サブエージェント + Codex 独立レビュー + 敵対的検証)で確定した 3 点を bff2f05 で修正しました。 1. kit_removed 経路の identity 判定漏れ(major) 2. 「次回の非対話更新で自動的に解消」が成立しないケース(major) 3. 「コマンドの改名」は identity で治らないクラス(minor) 回帰テスト 6 件を追加(kit_removed 経路 2 件・世代掃除 4 件)。修正前のコードでは 4 件が失敗します。 |
…hook-entries # Conflicts: # CHANGELOG.md
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: c184d5c9c8
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| 'def ident: if type == "object" and ((.hooks? | type) == "array") | ||
| then [.hooks[]?.command?] else . end; |
There was a problem hiding this comment.
Preserve custom commands appended to kit hook entries
When a user appends a custom command to an existing kit-owned outer hook entry, this identity becomes [kit-command, user-command] instead of the snapshot/new-kit identity [kit-command]. On a non-interactive update that changes the kit entry's matcher or async fields, the reducer therefore retains the entire edited entry alongside the new kit entry, causing the kit command to execute twice; the post-merge sweep cannot heal it for the same identity mismatch. This is a valid way to customize the inner hooks array and conflicts with the update contract to preserve user-added settings, so identity matching needs to recognize the kit command independently of additional user commands.
AGENTS.md reference: AGENTS.md:L168-L172
Useful? React with 👍 / 👎.
Fixes #163
概要
_merge_arrays_3wayが配列要素を完全一致で比較していたため、キット側がエントリを変更すると(matcherの変更、async/asyncTimeoutの追加、コマンドの改名)、旧世代が「ユーザーが追加した要素」に分類され、キットの新エントリと並べて残されていた。同じ hook が二重に登録される。あわせて、同関数の重複排除に使っていた
uniqueが配列をソートしており、safety-net の先頭固定という不変条件を壊していたことも修正する。原因
user_addedは完全一致で判定するため、キットが要素の一部だけを変更すると live 側の旧エントリがどの snapshot 要素とも一致しなくなる。そして最後の行は無条件に$n + $uaを連結する。分岐もプロンプトも対話ゲートもない。イシューを起票した時点では「kit removed → 既定 keep」の分岐が原因だと考えていたが、これは誤りだった。実データでは
kit_removedは 0 件でこの分岐は実行されない。一度発生すると解消しない
_update_phase_snapshot(lib/update.sh:1904-1926)は settings.json の snapshot に「マージ結果ではなくキットが生成した版」を保存する。これは利用者の変更を黙って上書きしないための意図的な設計で、コメントにも理由が明記されている。そのため stale エントリは snapshot に入らず、以降の更新でも毎回
current \ snapshot= ユーザー追加と分類され続ける。既存の除去パスが捕捉できない理由
_strip_retired_hook_entriesのルールはすべてコマンド文字列で判定する。今回のケースは stale と現行でコマンドが byte 同一でmatcherだけが異なるため、どれにも一致しない。同関数のコメント(
lib/update.sh:1325-1329)はこの障害クラス自体を既に認識している。対処は 2 つのコマンドに対する場当たり的な allowlist にとどまっていた。
修正 1: identity ベースの判定
hook エントリは
hooks[].commandの集合を論理的な登録単位とみなす。この identity が snapshot と新キットの両方に存在する要素は「キットが変更した既存エントリ」であり、ユーザー追加ではないと判断してキット版を採用する。permissions.allow/deny)→ identity が要素そのものになるため identity 一致が定義上ありえず、挙動は変わらない既存の壊れた環境は次回の非対話更新で自動的に解消される。 手作業での
settings.json編集は不要。修正 2: 重複排除がソートしていた点
uniqueは配列をソートする。.hooks.PreToolUseは配列順に実行され、lib/features.sh:54は safety-net が先頭であることを要求している。この不変条件は
lib/deploy.sh:1996-1999の FATAL アサーションで守られているが、検査対象は_FEATURE_ORDER[0]、すなわちビルド時の順序のみ。更新時のマージはこの検査を通らない。実測(
_MERGE_INTERACTIVE=false):コマンド文字列が
cc-safety-netより辞書順で前に来るユーザー hook があると safety hook が後ろに回る。現状先頭に残っているのは辞書順の偶然。順序を保つ重複排除に置き換えた。docs/GUIDES/hooks-reference.md:19-20の「safety-netのPreToolUseエントリは常に先頭」という記述は、この修正により更新経路でも成立するようになる。発生条件(イシューより限定的)
_merge_arrays_3wayの本番呼び出し元はlib/merge.sh:288の 1 箇所だけで、if [[ "${_MERGE_INTERACTIVE:-true}" != "true" ]]の内側にある。したがって重複は非対話マージでのみ発生する。--non-interactive、install.shの--update/update-kit、対話的なcurl \| bash再実行(配列まるごと置換の経路)MDM も該当しない。
_merge_settings_mdm_documentsがキット文書を丸ごと採用して早期に return するため、_merge_arrays_3wayに到達しない(lib/merge.sh:779-780、925-926)。テスト
tests/unit/test-merge.shに 6 件追加した。_merge_arrays_3wayの「キットが既存要素を変更した」ケースにはこれまで回帰テストが無かった。matcherのみの差分で重複しないことasync/asyncTimeoutのみの差分で重複しないこと修正前のコードでは 6 件すべてが失敗する(
Total: 39 Pass: 33 Fail: 6)。修正後は 39/39 通過。実行した検証
shellcheck -S warning lib/merge.sh tests/unit/test-merge.sh— いずれもクリーンtests/unit/test-merge.sh— 39/39 通過tests/run-unit-tests.sh— 790 PASS。失敗はfonts: direct download fallback receives font definition1 件のみで、変更前(554e455)にも再現する既存のものtests/run-scenarios.sh— 更新経路(update-from-v019/update-from-v020/update-from-no-manifest/update-v019-to-latest-direct/update-partial-failure-recovery/auto-update-session-hooks/auto-update-legacy-claude-fallback/update-kit-*)を含め失敗なしアップグレード経路
ENABLE_*フラグ、profile 既定値、生成ファイル、settings キー、manifest のいずれも追加・変更していない。既存インストールが取りこぼす新規キーはない。逆に、既存インストールの是正がこの変更の主目的にあたる。すでに hook が二重化している環境は、次回の非対話更新(auto-update hook 経由を含む)で余分なエントリが取り除かれる。
破壊的でない挙動変更(minor とした理由)
CLAUDE.md の基準では「既存機能の動作変更」は minor。バグ修正だが、修正の副作用として次の 2 点が変わるため patch ではなく 0.77.0 とした。
permissions.allow/denyはアルファベット順ではなく「キットの順序 → ユーザー追加分」になる。要素の集合は変わらず権限判定にも影響しないが、settings.jsonの差分としては見えるドキュメント
CHANGELOG.md— 更新済み(## [0.77.0]、Fixed と Changed)README.md/README.en.md— 更新済み。「手動カスタマイズが消えることはありません」という記述は、キット所有の hook エントリを書き換えていた場合に不正確になるため限定したdocs/GUIDES/hooks-reference.md— 変更不要。既存の「safety-net は常に先頭」という記述が、本修正で更新経路でも正しくなる補足
バージョンは 0.77.0 とした。#161(0.76.1)と #162(0.76.2)が先にマージされる前提。順序が変わる場合は付け替える。
🤖 Generated with Claude Code