Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 が二重に登録される(コマンド自体の改名は identity ごと変わる別クラスで、従来どおり `_strip_retired_hook_entries` が対象コマンドを掃除する)
- **一度発生すると解消しなかった**: `_update_phase_snapshot` は settings.json の snapshot に「マージ結果ではなくキットが生成した版」を保存する(利用者の変更を黙って上書きしないための意図的な設計)。stale エントリは snapshot に入らないため、以降の更新でも毎回「ユーザー追加」と分類され続けていた
- **判定を 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 が二重に登録されていた。単一の登録として扱う以上どちらかを選ぶ必要があり、キット管理下のエントリはキット版を正とする。キット版を残したまま同じコマンド(`hooks[].command` の集合が一致するエントリ)を別の matcher で**追加登録**していた場合は、更新のたびに世代掃除の対象となりキット版 1 件に統合される。利用者独自のコマンドを持つ hook は影響を受けない

## [0.76.2] - 2026-08-31

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion README.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 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
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -526,7 +526,7 @@ SessionStart / SessionEnd フック発火

- **毎セッション確認**: 起動直後と終了時の両方で最新版を確認します
- **lock で重複防止**: すでに自動更新が動いている間は二重実行しません
- ユーザー設定は 3-way merge で保持されます(手動カスタマイズが消えることはありません)
- ユーザー設定は 3-way merge で保持されます(自分のコマンドを使う設定・hook が消えることはありません)。ただし kit が配布している hook エントリは kit 版が正です: エントリ自体を書き換えていた場合や、kit のコマンドを別の matcher で追加登録していた場合は、更新時に kit 版 1 件へ統合されます
- ワンライナーインストール(`~/.claude-starter-kit/`)の場合のみ動作します
- **SessionEnd は best-effort**: 異常終了時は start 側のチェックだけに依存します
- **dirty check**: kit リポジトリにローカル変更がある場合は更新をスキップし、`git stash` を案内します
Expand Down
61 changes: 53 additions & 8 deletions lib/merge.sh
Original file line number Diff line number Diff line change
Expand Up @@ -167,41 +167,84 @@ _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) 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.
# ---------------------------------------------------------------------------
_merge_arrays_3way() {
local s_val="$1" # snapshot array JSON
local c_val="$2" # current array JSON
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;
Comment on lines +200 to +201

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

($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" \
--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

# 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
Expand All @@ -213,7 +256,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
Comment on lines +259 to +260

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

else
# Interactive: ask for each removed item
local i=0
Expand All @@ -238,7 +282,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))
Expand Down
74 changes: 74 additions & 0 deletions lib/update.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 <settings-file> <kit-built-file>
#
# 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
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading