Skip to content

fix: 非対話更新で kit の hook エントリが重複する問題を修正(v0.77.0) - #165

Merged
okash1n merged 3 commits into
mainfrom
fix/merge-superseded-hook-entries
Aug 31, 2026
Merged

fix: 非対話更新で kit の hook エントリが重複する問題を修正(v0.77.0)#165
okash1n merged 3 commits into
mainfrom
fix/merge-superseded-hook-entries

Conversation

@loadbalance-sudachi-kun

Copy link
Copy Markdown
Contributor

Fixes #163

概要

_merge_arrays_3way が配列要素を完全一致で比較していたため、キット側がエントリを変更すると(matcher の変更、async / asyncTimeout の追加、コマンドの改名)、旧世代が「ユーザーが追加した要素」に分類され、キットの新エントリと並べて残されていた。同じ hook が二重に登録される。

あわせて、同関数の重複排除に使っていた unique が配列をソートしており、safety-net の先頭固定という不変条件を壊していたことも修正する。

原因

user_added="$(jq -n --argjson s --argjson c \
  '[$c[] | select(. as $item | $s | map(. == $item) | any | not)]')"
...
merged="$(jq -n --argjson n --argjson ua '$n + $ua | unique')"

user_added は完全一致で判定するため、キットが要素の一部だけを変更すると live 側の旧エントリがどの snapshot 要素とも一致しなくなる。そして最後の行は無条件$n + $ua を連結する。分岐もプロンプトも対話ゲートもない。

イシューを起票した時点では「kit removed → 既定 keep」の分岐が原因だと考えていたが、これは誤りだった。実データでは kit_removed は 0 件でこの分岐は実行されない。

一度発生すると解消しない

_update_phase_snapshotlib/update.sh:1904-1926)は settings.json の snapshot に「マージ結果ではなくキットが生成した版」を保存する。これは利用者の変更を黙って上書きしないための意図的な設計で、コメントにも理由が明記されている。

そのため stale エントリは snapshot に入らず、以降の更新でも毎回 current \ snapshot = ユーザー追加と分類され続ける。

既存の除去パスが捕捉できない理由

_strip_retired_hook_entries のルールはすべてコマンド文字列で判定する。今回のケースは stale と現行でコマンドが byte 同一で matcher だけが異なるため、どれにも一致しない。

同関数のコメント(lib/update.sh:1325-1329)はこの障害クラス自体を既に認識している。

A user-touched hooks array can make the 3-way merge retain both generations, causing the old and new hooks to run together.

対処は 2 つのコマンドに対する場当たり的な allowlist にとどまっていた。

修正 1: identity ベースの判定

hook エントリは hooks[].command の集合を論理的な登録単位とみなす。この identity が snapshot と新キットの両方に存在する要素は「キットが変更した既存エントリ」であり、ユーザー追加ではないと判断してキット版を採用する。

  • identity が snapshot に無い要素(利用者独自の hook)→ 従来どおり保持
  • identity が snapshot にあり新キットに無い要素 → 従来どおり kit-removed(対話なら確認、非対話なら保持)
  • 文字列配列(permissions.allow / deny)→ identity が要素そのものになるため identity 一致が定義上ありえず、挙動は変わらない

既存の壊れた環境は次回の非対話更新で自動的に解消される。 手作業での settings.json 編集は不要。

修正 2: 重複排除がソートしていた点

unique は配列をソートする。.hooks.PreToolUse は配列順に実行され、lib/features.sh:54 は safety-net が先頭であることを要求している。

# CRITICAL: safety-net MUST be first (PreToolUse runs in array order)

この不変条件は lib/deploy.sh:1996-1999 の FATAL アサーションで守られているが、検査対象は _FEATURE_ORDER[0]、すなわちビルド時の順序のみ。更新時のマージはこの検査を通らない。

実測(_MERGE_INTERACTIVE=false):

current: [cc-safety-net, !my-own-hook]
newkit : [cc-safety-net, zz-new-kit]

修正前: ["!my-own-hook", "cc-safety-net", "zz-new-kit"]   ← index 0 を奪われる
修正後: ["cc-safety-net", "zz-new-kit", "!my-own-hook"]

コマンド文字列が cc-safety-net より辞書順で前に来るユーザー hook があると safety hook が後ろに回る。現状先頭に残っているのは辞書順の偶然。順序を保つ重複排除に置き換えた。

docs/GUIDES/hooks-reference.md:19-20 の「safety-netPreToolUse エントリは常に先頭」という記述は、この修正により更新経路でも成立するようになる。

発生条件(イシューより限定的)

_merge_arrays_3way の本番呼び出し元は lib/merge.sh:288 の 1 箇所だけで、if [[ "${_MERGE_INTERACTIVE:-true}" != "true" ]] の内側にある。したがって重複は非対話マージでのみ発生する。

  • 該当する: auto-update hook 経由の更新、--non-interactiveinstall.sh--update
  • 該当しない: /update-kit、対話的な curl \| bash 再実行(配列まるごと置換の経路)

MDM も該当しない。_merge_settings_mdm_documents がキット文書を丸ごと採用して早期に return するため、_merge_arrays_3way に到達しない(lib/merge.sh:779-780925-926)。

テスト

tests/unit/test-merge.sh に 6 件追加した。_merge_arrays_3way の「キットが既存要素を変更した」ケースにはこれまで回帰テストが無かった。

  • matcher のみの差分で重複しないこと
  • 既に二重化している配列が収束すること
  • async / asyncTimeout のみの差分で重複しないこと
  • 利用者独自の hook が identity 判定で消えないこと
  • 重複排除が順序を保ち safety-net が先頭に残ること
  • 文字列配列の挙動(ユーザー追加と kit-removed の保持)が変わらないこと

修正前のコードでは 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 definition 1 件のみで、変更前(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 とした。

  1. 文字列配列の並び順: 非対話更新後の permissions.allow / deny はアルファベット順ではなく「キットの順序 → ユーザー追加分」になる。要素の集合は変わらず権限判定にも影響しないが、settings.json の差分としては見える
  2. キット所有の hook エントリを直接編集していた場合、キット版に戻る: 従来は新旧が両方残って同じ hook が二重に登録されていた。単一の登録として扱う以上どちらかを選ぶ必要があり、キット管理下のエントリはキット版を正とした。利用者独自のコマンドを持つ hook は影響を受けない

ドキュメント

  • 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

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>
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Aug 31, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-08-31T07:42:20.409728Z c184d5c New commits
ℹ️ 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 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".

Comment thread lib/merge.sh
Comment on lines +249 to +250
'reduce ($m + $kr)[] as $x ([];
if any(.[]; . == $x) then . else . + [$x] end)')" || return 1

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge 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 件が
失敗する。
@okash1n

okash1n commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

マージ前レビュー(多視点サブエージェント + Codex 独立レビュー + 敵対的検証)で確定した 3 点を bff2f05 で修正しました。

1. kit_removed 経路の identity 判定漏れ(major)
identity フィルタが user_added の計算にしか無く、キットがエントリを変更するまさにその更新では、旧世代が snapshot と完全一致するため kit-removed に分類され、非対話分岐で新版の隣に復活していました(#163 と同じ重複が再発)。実証: snapshot=[{matcher:"*",A.sh}], current=[同+ユーザーhook], newkit=[{matcher:"startup",A.sh}] で A.sh が 2 件残る。kit_removed にも同じ identity フィルタを適用しました。

2. 「次回の非対話更新で自動的に解消」が成立しないケース(major)
3-way マージは snapshot・現行・新キットがすべて異なるときにしか走らないため、既に二重化した環境はキットが該当配列を次に変更するリリースまで治癒しませんでした(本 PR 自体は features/ の hooks を変更しないため 0.77.0 への更新では治癒ゼロ)。マージ後に毎回実行される _strip_superseded_kit_hook_generations を lib/update.sh に追加し、キット現行エントリと並ぶ旧世代を更新のたびに除去するようにしました。これで CHANGELOG の主張が対話・非対話の両経路で真になります。in-place 編集(キット版が配列に無い)は従来どおり保持されます。

3. 「コマンドの改名」は identity で治らないクラス(minor)
改名は command 集合=identity 自体が変わるため本修正の対象外です。コメント・CHANGELOG から除外し、_strip_retired_hook_entries の担当であることを明記しました。あわせて README の「消えることはありません」を、kit コマンドを別 matcher で追加登録したエントリがキット版に統合される点を含む正確な但し書きに改めました。

回帰テスト 6 件を追加(kit_removed 経路 2 件・世代掃除 4 件)。修正前のコードでは 4 件が失敗します。

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 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".

Comment thread lib/merge.sh
Comment on lines +200 to +201
'def ident: if type == "object" and ((.hooks? | type) == "array")
then [.hooks[]?.command?] else . end;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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 👍 / 👎.

@okash1n
okash1n merged commit 3c69fee into main Aug 31, 2026
11 checks passed
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.

非対話更新で kit の hook エントリが重複する(_merge_arrays_3way の完全一致比較 + 無条件 union)

2 participants