diff --git a/docs/claude/global-claude-settings.md b/docs/claude/global-claude-settings.md index c6ac8e0d0..6e3c9ae8e 100644 --- a/docs/claude/global-claude-settings.md +++ b/docs/claude/global-claude-settings.md @@ -62,12 +62,15 @@ In addition to the terminal panel, the hook always injects a message into Claude The source name is included so a stale mirror is visible at a glance: `GitHub` for the raw-URL method; for the git-fetch fallback the host is inferred from the local clone's `origin` URL (`GitHub` / `GitLab` / `Codeberg` / `git origin`). -**Update required** (prominently displayed, cannot be missed): +**Update required** (prominently displayed, cannot be missed; short since v2.8.0): -> NEW SESSION — GLOBAL CLAUDE SETTINGS: UPDATE REQUIRED -> Installed: v0.1.0 (outdated) | Latest: v1.0.0 (on origin/main) -> Say "update my global settings to 1.0.0" to apply the update. -> Afterwards, relock with (A) full lock, or (B) lock without settings.json (keeps the VSCode model picker working, but the main file loses the kernel lock and is then guarded only by the regex hooks). +> GLOBAL CLAUDE SETTINGS: UPDATE REQUIRED +> Installed: v2.7.2 (outdated). Latest: v2.8.0 (on origin/main) +> What this update adds: v2.8.0 — one sentence from `global-settings/CHANGELOG.md` +> Unlocking takes two commands, `sudo chattr -i` and `chmod u+w`. Afterwards you must re-apply `sudo chattr +i`. +> Ask me how to update for the full steps, or for more details about what these changes do. + +The notice returns in every new session until you update, so it is short by default. Ask how to update and Claude shows the full steps: unlock, verify, the phrase to say ("update my global settings to 2.8.0"), and the relock with (A) full lock or (B) lock without settings.json (keeps the VSCode model picker working, but the main file loses the kernel lock and is then guarded only by the regex hooks). Ask about the changes and Claude looks up the commits and pull requests behind each version and links them. See [A short notice, and what the update adds](../../global-settings/README.md#a-short-notice-and-what-the-update-adds-v280). The relock step always offers both options, each with its own command, and says why (B) is there. It is the same choice described under [Troubleshooting → keep `settings.json` unlocked](#model-or-the-model-picker-fails-with-eperm-operation-not-permitted-open-claudesettingsjson). Since v2.5.2 it is part of the update message itself, not a note in the docs. diff --git a/global-settings/CHANGELOG.md b/global-settings/CHANGELOG.md new file mode 100644 index 000000000..97181368b --- /dev/null +++ b/global-settings/CHANGELOG.md @@ -0,0 +1,161 @@ +# Global settings changelog + +One entry per released `VERSION`, newest first. The heading is the version number and the date it reached `main` (`## X.Y.Z — YYYY-MM-DD`). The first line under it is **one sentence** that says what the update adds or fixes, in words a developer understands without opening the code. + +`check-settings-version.sh` shows that sentence in the session-start UPDATE REQUIRED notice for every version between the installed one and the latest (since v2.8.0). The details live in the commit and the pull request. When a user asks for more, Claude looks those up from the version number. This file is not installed into `~/.claude/` and needs no `VERSION` bump of its own, but every `VERSION` bump needs an entry here (see [Bumping the version](README.md#️-bumping-the-version--required-on-every-change)). + +Versions 1.0.0 to 1.3.0 were released from the now archived `ConductionNL/claude-code-config` repository, and their date is the day they reached that repository's `main`. A few versions reached `main` together with the next one instead of on their own; their entry says so, and carries the date they reached `main`. Numbers that `main` never carried (1.5.0, 1.5.2, 1.5.3, 2.4.3) are listed under a `###` heading with a note on where their changes went. The update notice reads only `##` headings, so it skips them. + +## 2.8.0 — 2026-10-02 + +The update notice is now short at session start and says in one sentence what each pending version adds. Ask Claude how to update for the full steps, or for the details of the changes with links to the pull requests. + +## 2.7.3 — 2026-10-02 + +A hard block anywhere in a chained command now wins over a prompt for another part of it, and a git alias that pushes (or a command that defines one) needs the push phrase just like `git push`. + +## 2.7.2 — 2026-10-01 + +An unauthorized `git push` is now denied in every form, including `git -C push`, `git -c k=v push` and a push chained after a command Claude was asked to approve. + +## 2.7.1 — 2026-10-01 + +Picking a model or an effort level no longer reports `settings.json` as out of sync, because the keys Claude Code itself writes for that choice are ignored in the file check. + +## 2.7.0 — 2026-10-01 + +The update notice now installs every hook `settings.json` registers instead of a hand-kept list, and a session start checks that the installed files match the canonical copies. + +## 2.6.0 — 2026-09-29 + +Changes to production (`kubectl`, `oc` or `helm` writes against a `*-prod` namespace or context) are now hard-blocked, and text inside commit messages or heredocs no longer counts as a command. + +## 2.5.2 — 2026-09-28 + +The relock step now offers two options, a full lock or a lock without `settings.json`, so the VSCode model picker can keep working. + +## 2.5.1 — 2026-09-28 + +A push phrase typed next to a pasted image is recognised again, and a skill's text can no longer authorize a push by itself. + +## 2.5.0 — 2026-09-12 + +Hand-rolled waiting (CI watch commands, poll loops, long sleeps and idle heartbeats) is now blocked. + +## 2.4.6 — 2026-09-11 + +The unlock step now includes the `chmod u+w` that every update after the first needs, so an update no longer stalls halfway with `Permission denied`. + +## 2.4.5 — 2026-09-10 + +Seven `Write(...)` deny rules that Claude Code never applied are removed; the `Edit(...)` rules next to them already cover every file-editing tool. + +## 2.4.4 — 2026-09-10 + +The in-place edit guard no longer hard-denies a chain of harmless commands because the tool, its `-i` flag and the protected path came from different commands. + +### 2.4.3 — never released + +Two pull requests both took 2.4.3 on 2026-09-08, and `main` never carried it. Each branch was renumbered when `main` was merged into it, before its own merge on 2026-09-10. The destructive-guard fix ([#720](https://github.com/ConductionNL/.github/pull/720)) shipped as 2.4.4 and the removal of the inert `Write(...)` deny rules ([#716](https://github.com/ConductionNL/.github/pull/716)) as 2.4.5. + +## 2.4.2 — 2026-09-08 + +The update notice now also installs `sound-notify.sh` and `user-hooks-dispatch.sh`, which it had left out since they were added. + +## 2.4.1 — 2026-09-08 + +The `mcpServers` block is removed from `settings.json`, because Claude Code never read it there and the browsers it listed never loaded. + +## 2.4.0 — 2026-09-04 + +You can register your own hooks in `~/.claude/user-hooks.json`, and they survive every settings update. + +## 2.3.1 — 2026-09-04 + +The "up to date" message names its source ("via GitHub") again. + +## 2.3.0 — 2026-09-04 + +The canonical source moves back from Codeberg to GitHub (`ConductionNL/.github`), for the version check and for the update itself. + +## 2.2.2 — 2026-07-10 + +The permission-request sound now plays, because it is wired to the event Claude Code actually fires for a permission dialog. + +## 2.2.1 — 2026-07-09 + +Short notification sounds are no longer cut off when the hook exits. + +## 2.2.0 — 2026-07-09 + +Optional notification sounds when Claude asks a question, needs a permission or finishes, off by default and set in `~/.claude/sound-config.sh`. + +## 2.1.0 — 2026-06-12 + +`--legacy-peer-deps` is hard-denied for npm, pnpm, yarn and bun installs. + +## 2.0.0 — 2026-06-12 + +Breaking: the canonical source moves from GitHub to Codeberg (`codeberg.org/Conduction/.github`), so the version check and the update pull from there. + +It was committed on 2026-05-29 on the `feature/codeberg-global-settings-flip` branch and reached `main` in the same merge as 2.1.0, so `main` went from 1.7.0 straight to 2.1.0. + +## 1.7.0 — 2026-05-13 + +Claude's config files get a kernel-level lock (`chattr +i`), and a new hook stops the Write and Edit tools from changing them. + +## 1.6.1 — 2026-05-12 + +A push phrase now stays valid after Claude has run other tools in the same turn. + +## 1.6.0 — 2026-05-04 + +Tighter guards on writes to `~/.claude/`, `settings-repo-path` is no longer unlocked during an update, and the guard hook gets a test suite. + +### 1.5.3 — never released + +This number appears only in the commit message of the 1.6.0 change ("Bump VERSION 1.5.3 → 1.6.0"). That commit changed `VERSION` from 1.5.1 to 1.6.0, and no commit with `VERSION` 1.5.3 exists, so its changes are the ones listed under 1.6.0. + +### 1.5.2 — never released + +The push-phrase fix first took 1.5.2 on 2026-05-07, on a branch of a copy of `global-settings` kept in another repository. That branch was never merged. The same fix reached `main` here as 1.6.1 ([#43](https://github.com/ConductionNL/.github/pull/43)). + +## 1.5.1 — 2026-05-04 + +You can track a branch other than `main` with `~/.claude/settings-repo-ref`, and destructive commands such as `sudo`, `rm -rf /`, `git reset --hard` and `gh pr merge` are denied. + +It reached `main` together with 1.6.0, in [#22](https://github.com/ConductionNL/.github/pull/22), so `main` went from 1.4.0 straight to 1.6.0. + +### 1.5.0 — never released + +On 2026-04-13 a copy of `global-settings` kept in another repository took 1.5.0 for the destructive-command deny rules and went to 1.5.1 the same day. This repository went from 1.4.0 straight to 1.5.1, so those deny rules shipped as part of 1.5.1. + +## 1.4.0 — 2026-04-16 + +The version check reads the latest version straight from GitHub (`~/.claude/settings-repo-url`), with the local clone as fallback. + +## 1.3.0 — 2026-04-08 + +Write requests through `curl` are caught in more forms, including combined flags such as `-sX POST` and a `curl` behind a pipe. + +## 1.2.2 — 2026-04-03 + +`gh pr`, `gh issue`, `gh repo`, `gh release`, `gh workflow` and `gh run` write commands now ask for approval, also when chained after a read. + +## 1.2.1 — 2026-04-03 + +More read-only commands run without a prompt, and any output redirect to a file now asks for approval. + +It reached `main` of `claude-code-config` together with 1.2.2 (PR #20 there), so that `main` went from 1.2.0 straight to 1.2.2. + +## 1.2.0 — 2026-03-28 + +Claude can no longer change its own settings files or make them writable, and the version check retries once when the remote is briefly unreachable. + +## 1.1.0 — 2026-03-28 + +Commands that would leave the WSL workspace (Windows drives under `/mnt//`, `.exe` programs, `wsl`) are hard-blocked. + +## 1.0.0 — 2026-03-27 + +The first mandatory, versioned global settings: a shared `settings.json`, the write-guard hook and a session-start version check. diff --git a/global-settings/README.md b/global-settings/README.md index 8de292017..4c1212fd4 100644 --- a/global-settings/README.md +++ b/global-settings/README.md @@ -16,6 +16,7 @@ Current version: see [`VERSION`](VERSION) | `sound-notify.sh` | `~/.claude/hooks/sound-notify.sh` | Optional notification-sound wrapper. Reads `~/.claude/sound-config.sh` and plays a sound on question / permission / stop events. Silent by default (added in v2.2.0) | | `user-hooks-dispatch.sh` | `~/.claude/hooks/user-hooks-dispatch.sh` | Reads `~/.claude/user-hooks.json` and executes per-user hooks for every Claude Code event. Lets each user register private hooks that survive a settings update (added in v2.4.0) | | `VERSION` | `~/.claude/settings-version` | Installed version tracker (semver) | +| `CHANGELOG.md` | not installed | One sentence per version; shown in the update notice as "What this update adds" (added in v2.8.0) | | `settings-repo-url.example` | `~/.claude/settings-repo-url` | GitHub repo slug for online version checking | | `settings-repo-ref.example` | `~/.claude/settings-repo-ref` | Branch to track (defaults to `main` when absent; the GitHub raw URL uses `raw.githubusercontent.com///`) | | `sound-config.sh.example` | `~/.claude/sound-config.sh` (optional) | Opt-in sound configuration. Only install if you want notification sounds. User-editable — **not** `chattr +i`-locked | @@ -275,6 +276,16 @@ When the installed version already matches the online one, the hook also compare A missing or changed file turns the session-start message into **FILES OUT OF SYNC**. It names the files, the phrase for step 3 is **"repair my global settings"**, and the unlock, contract and relock steps are the same as for an update. Only the affected files are rewritten; `VERSION` is left alone. On the GitHub path the check costs two extra fetches per session (`settings.json`, then every registered hook in one `curl` call); on the git-fetch path it costs none. A canonical file that could not be fetched is reported as not verified, never as fine. +### A short notice, and what the update adds (v2.8.0) + +The update notice comes back in every new session until you update, so Claude shows it **short** at session start: the header, the installed and latest version, what the update adds, a reminder that unlocking takes two commands (`sudo chattr -i` and `chmod u+w`) and relocking one (`sudo chattr +i`), and that you can ask for more. Ask Claude how to update and you get the full steps 1 to 4 above, with every command in its own block and both relock options. After the update Claude always shows the relock step, whether or not you asked for the full steps. The FILES OUT OF SYNC notice is still shown in full. + +The UPDATE REQUIRED notice has a **What this update adds** block under the version lines: one sentence per version after the installed one, up to the latest, newest first, taken from [`CHANGELOG.md`](CHANGELOG.md). It shows at most five versions and counts the rest. A version without an entry is listed as such, not left out. If `CHANGELOG.md` cannot be fetched, the block says so, and the update itself is unaffected. On the GitHub path this costs one extra fetch, and only when an update is pending. + +Ask Claude for more details and it looks up the commit that set `VERSION` to each listed version, reads its message, changed files and pull request, and explains the change with a link to that pull request (or the commit when none merged it). The notice gives Claude the `gh api` and `git log` commands for this, so the answer comes from the history, not from the summary sentence. + +The notice is printed by the *installed* hook, so the short form and the block first appear when you update **from** v2.8.0 or later to a newer version. + ### The update contract Claude reinstalls each file with `chmod 555` (hooks) or `chmod 444` (`settings-version`) because `block-write-commands.sh` permits only those two modes on protected paths — `chmod 644`, `chmod u+w` and `chattr` are all hard-denied to Claude, by design, so it can never widen its own access. The consequence is the one step 1 covers: read-only mode is *sticky* across updates and only you can clear it. @@ -334,6 +345,8 @@ Now `ask()` only records its reason. Every guard runs, a hard deny anywhere wins Failing to bump the version means users will not be warned to update, and their installed settings will silently fall behind. +**Every bump also adds an entry to [`CHANGELOG.md`](CHANGELOG.md)**: a `## X.Y.Z — YYYY-MM-DD` heading, dated the day the version reaches `main`, with one sentence under it that says what the update adds, in words a developer understands without opening the code. The update notice shows that sentence. A missing entry, or a heading without a date, fails the version-check tests. + Changes to `global-settings/README.md` alone do **not** require a bump: nothing installed changes, so a bump would push every developer through the unlock/relock cycle to copy a file they don't have. Documentation under `docs/claude/` is likewise never a trigger. Semver rules: diff --git a/global-settings/VERSION b/global-settings/VERSION index 2c9b4ef42..834f26295 100644 --- a/global-settings/VERSION +++ b/global-settings/VERSION @@ -1 +1 @@ -2.7.3 +2.8.0 diff --git a/global-settings/check-settings-version.sh b/global-settings/check-settings-version.sh index 5bad6a145..b8c480992 100644 --- a/global-settings/check-settings-version.sh +++ b/global-settings/check-settings-version.sh @@ -379,6 +379,66 @@ needs_repair() { return 1 } +# ── What the update adds: one sentence per version from CHANGELOG.md ───────── +# Only for an outdated install. CHANGELOG.md is fetched from the same source as +# VERSION; it is not installed. Each "## X.Y.Z" heading is followed by one +# sentence, and the notice shows that sentence for every version after the +# installed one up to the online one, newest first. An install whose version is +# unreadable gets the online version's entry only. A version without an entry +# says so instead of being skipped, so the list never reads as complete when it +# is not. +CHANGES_MAX=5 +changes_lines=() +changes_fetched=false +changes_more=0 + +# changelog_summary — the first non-empty line under +# "## ", control characters removed and capped at 300 characters. +changelog_summary() { + awk -v v="$2" ' + /^## / { if (found) exit; found = ($2 == v); next } + found && NF { print; exit } + ' "$1" 2>/dev/null | tr -d '\000-\037\177' | cut -c1-300 +} + +if $online_fetch_ok && [ -n "$canon_dir" ] && semver_gt "$online_version" "$installed_version"; then + fetch_canonical "$canon_dir" CHANGELOG.md + _versions=("$online_version") + if [ -s "$canon_dir/CHANGELOG.md" ]; then + changes_fetched=true + if $installed_ok; then + _versions=() + while IFS= read -r _v; do + validate_semver "$_v" || continue + semver_gt "$_v" "$installed_version" || continue + semver_gt "$_v" "$online_version" && continue + _versions+=("$_v") + done < <(awk '/^## / { print $2 }' "$canon_dir/CHANGELOG.md" | sort -t. -k1,1nr -k2,2nr -k3,3nr -u) + # The online version always leads, with or without an entry. + [ "${_versions[0]:-}" = "$online_version" ] || _versions=("$online_version" "${_versions[@]}") + fi + fi + for _v in "${_versions[@]}"; do + if [ ${#changes_lines[@]} -ge "$CHANGES_MAX" ]; then + changes_more=$((changes_more + 1)) + continue + fi + _s="" + $changes_fetched && _s=$(changelog_summary "$canon_dir/CHANGELOG.md" "$_v") + changes_lines+=("v${_v} — ${_s:-(no summary in CHANGELOG.md for this version)}") + done +fi + +# The repository the details are looked up in: the GitHub slug the version came +# from, or the GitHub origin of the local clone on the git-fetch path. +changes_slug="$online_repo_slug" +if [ -z "$changes_slug" ] && [ -n "$git_root" ]; then + changes_slug=$(git -C "$git_root" remote get-url origin 2>/dev/null \ + | sed -nE 's#^(https://|git@|ssh://git@)github\.com[:/]([A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+)$#\2#p' \ + | sed 's#\.git$##') + validate_repo_slug "$changes_slug" || changes_slug="" +fi + # ── Color: installed ────────────────────────────────────────────────────────── if ! $installed_ok; then ic="${RED}" ii="✗" @@ -591,6 +651,37 @@ emit_claude_instruction() { # $1 = "the update" | "the repair" echo "started and that you checked the Global Claude Settings. Then immediately display the" echo "notice as a clearly visible warning block — bold, a header, or a highlighted quote block." echo "The user must not be able to overlook it. Do not bury it in the middle of your response." + emit_full_form_rules "$1" +} + +# emit_update_instruction — the update notice is shown short at session start +# (it returns in every new session until the update is done) and in full only +# when the user asks how to update. The relock options are never skipped: they +# come with the full form and again after the update. +emit_update_instruction() { + echo "INSTRUCTION FOR CLAUDE: At the very start of your response, mention that a new session has" + echo "started and that you checked the Global Claude Settings. Then show the SHORT form of this" + echo "notice as one clearly visible warning block — a header and a few lines, no command blocks:" + echo " - the header 'GLOBAL CLAUDE SETTINGS: UPDATE REQUIRED';" + echo " - the Installed and Latest lines;" + echo " - the 'What this update adds' lines, word for word;" + echo " - one line: unlocking takes TWO commands, 'sudo chattr -i' and 'chmod u+w', and afterwards" + echo " the user must re-apply 'sudo chattr +i', or the immutable lock stays off until the next" + echo " session;" + echo " - one line: the user can ask you how to update for the full steps, or for more details" + echo " about what these changes do." + echo "The user must not be able to overlook the block. Leave out steps 1 to 4, the command blocks" + echo "and the relock options at session start, and do not look anything up yet." + echo "FULL FORM: when the user asks how to update, for the update steps or for more information" + echo "about updating, display steps 1 to 4 of the notice with each command as its own fenced block." + echo "The same rules then apply as after the update below." + echo "AFTER THE UPDATE (the user said the step-3 phrase and you ran the file blocks): always show" + echo "the relock step, even if the full form was never shown." + emit_full_form_rules "the update" +} + +# emit_full_form_rules — what the full notice and the update itself must cover. +emit_full_form_rules() { # $1 = "the update" | "the repair" echo "Also remind the user that the unlock is TWO commands — 'sudo chattr -i' for the kernel flag" echo "and a plain 'chmod u+w' for the read-only mode a previous update left behind — and that they" echo "must run 'sudo chattr +i' afterward to re-apply the kernel-level lock. Without the relock the" @@ -607,6 +698,43 @@ emit_claude_instruction() { # $1 = "the update" | "the repair" echo "from the blocks above — do not introduce loops, variables for the repo slug or GitHub URL, or chmod 644/chattr." } +# emit_changes — the "What this update adds" lines, under the version lines. +emit_changes() { + echo " What this update adds:" + if ! $changes_fetched; then + echo " (CHANGELOG.md could not be fetched from origin/${tracking_ref} — ask Claude for the details)" + fi + local l + for l in "${changes_lines[@]}"; do echo " ${l}"; done + [ "$changes_more" -gt 0 ] && echo " … and ${changes_more} earlier version(s), listed in global-settings/CHANGELOG.md" + echo " Want to know more? Ask Claude how to update, or for the details of these changes." +} + +# emit_changes_instruction — how Claude, on request, digs into the commits and +# pull requests behind the "What this update adds" lines. +emit_changes_instruction() { + echo "MORE DETAILS ABOUT THE CHANGES: when the user asks what these changes do, look deeper before" + echo "answering — never expand on the summary sentence from memory. For each listed version, find" + echo "the commit that set global-settings/VERSION to it (its subject usually names the version)," + echo "read its full message and changed files, and find the pull request that merged it:" + if [ -n "$changes_slug" ]; then + printf '%s\n' " gh api 'repos/${changes_slug}/commits?path=global-settings/VERSION&sha=${tracking_ref}&per_page=20' --jq '.[] | .sha[0:8] + \" \" + (.commit.message | split(\"\\n\")[0])'" + echo " gh api 'repos/${changes_slug}/commits/' --jq '.commit.message, (.files[] | .filename)'" + echo " gh api 'repos/${changes_slug}/commits//pulls' --jq '.[] | .html_url + \" \" + .title'" + fi + if [ -n "$REPO_DIR" ]; then + echo " git -C '${REPO_DIR}' log 'origin/${tracking_ref}' --format='%h %s' -- '${rel_base}/VERSION'" + echo " git -C '${REPO_DIR}' show --stat " + fi + echo "Then explain per version what changed, why, and what it means for the user's daily work, and" + if [ -n "$changes_slug" ]; then + echo "link each pull request (https://github.com/${changes_slug}/pull/), or the commit" + echo "(https://github.com/${changes_slug}/commit/) when no pull request merged it." + else + echo "name each commit (short sha and subject); no GitHub repository is known to link to." + fi +} + # ── Stdout context injected into Claude's prompt ────────────────────────────── # Config problems — must be very prominent in chat @@ -638,13 +766,16 @@ if $online_fetch_ok && semver_gt "$online_version" "$installed_version"; then echo " Installed : v${installed_version} ❌ (outdated)" echo " Latest : v${online_version} ✅ (on origin/${tracking_ref})" echo "" + emit_changes + echo "" emit_unlock_steps "the update" "update my global settings to ${online_version}" echo "" emit_contract echo "" emit_file_blocks settings.json "${managed_hooks[@]}" VERSION echo "" - emit_claude_instruction "the update" + emit_update_instruction + emit_changes_instruction echo "==========================================" echo "" diff --git a/global-settings/tests/test-check-settings-version.sh b/global-settings/tests/test-check-settings-version.sh index 47a1f6787..e47f5daaa 100644 --- a/global-settings/tests/test-check-settings-version.sh +++ b/global-settings/tests/test-check-settings-version.sh @@ -11,6 +11,11 @@ # - the update notice lists every hook the canonical settings.json registers, # derived rather than hand-maintained: a hook registered only in # settings.json appears without any change to the hook script itself +# - the update notice shows the CHANGELOG.md sentence of every pending +# version, newest first and capped at five, says when one is missing or +# the file cannot be fetched, and gives Claude the commit/PR lookup; and +# CHANGELOG.md has an entry for the current VERSION, and every entry +# heading carries a date, newest first # - a matching install is reported up to date, with every file verified # - a missing or changed hook, or a changed settings.json, is reported # FILES OUT OF SYNC with repair blocks for those files only @@ -150,6 +155,58 @@ has 'sudo chattr +i'; check "update: relock steps present" $? has 'mkdir -p ~/.claude/hooks'; check "update: hooks directory is created before the first hook" $? lacks 'OUT OF SYNC'; check "update: no OUT OF SYNC notice alongside UPDATE REQUIRED" $? +# ── 1b. what the update adds: one sentence per version from CHANGELOG.md ───── +# changelog_versions — the version headings of the canonical CHANGELOG.md, +# newest first; summary_of — the sentence under one of them. +changelog_versions() { awk '/^## / { print $2 }' "$CANON_GS/CHANGELOG.md" | sort -t. -k1,1nr -k2,2nr -k3,3nr; } +summary_of() { awk -v v="$1" '/^## / { if (f) exit; f = ($2 == v); next } f && NF { print; exit }' "$CANON_GS/CHANGELOG.md"; } +CUR=$(cat "$SRC_DIR/VERSION") +has_entry() { [[ -n "$(summary_of "$1")" ]]; } +has_entry "$CUR"; check "changelog: CHANGELOG.md has a one-sentence entry for VERSION $CUR" $? +eq "$(changelog_versions | head -1)" "$CUR"; check "changelog: the newest CHANGELOG.md entry is the current VERSION" $? +# heading_problems — every "## " heading that is not "## X.Y.Z — YYYY-MM-DD" +# with a month 01-12 and a day 01-31, or whose date is later than the one above +# it. The "### " headings of never-released numbers are not checked. +heading_problems() { + awk '/^## / { + if ($0 !~ /^## [0-9]+\.[0-9]+\.[0-9]+ — [0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]$/) { print "format: " $0; next } + d = $4; m = substr(d, 6, 2) + 0; dd = substr(d, 9, 2) + 0 + if (m < 1 || m > 12 || dd < 1 || dd > 31) { print "date: " $0; next } + if (prev != "" && d > prev) print "order: " $0 + prev = d + }' "$1" +} +eq "$(heading_problems "$CANON_GS/CHANGELOG.md")" ""; check "changelog: every entry heading is dated, newest first" $? +printf '## 9.9.9\n\nx\n\n## 9.9.8 — 2026-13-01\n\nx\n\n## 9.9.7 — 2020-01-01\n\nx\n\n## 9.9.6 — 2021-01-01\n\nx\n' > "$TMP/CHANGELOG.bad.md" +eq "$(heading_problems "$TMP/CHANGELOG.bad.md" | cut -d: -f1 | tr '\n' ' ')" "format date order " +check "changelog: the date check catches a missing date, an impossible month and a date out of order" $? +mapfile -t CL_VERSIONS < <(changelog_versions) +has 'What this update adds:'; check "changes: block present in the update notice" $? +has "v${CUR} — $(summary_of "$CUR")"; check "changes: the latest version's sentence is shown" $? +has 'Ask Claude how to update, or for the details of these changes'; check "changes: the user is told they can ask for more" $? +has 'MORE DETAILS ABOUT THE CHANGES:'; check "changes: Claude is told how to dig deeper on request" $? +has 'show the SHORT form of this'; check "short: the update notice is shown short at session start" $? +has 'ask you how to update for the full steps'; check "short: the short form offers the full steps on request" $? +has 'FULL FORM: when the user asks how to update'; check "short: the full form is shown when asked" $? +has 'AFTER THE UPDATE'; check "short: the relock step is shown after the update" $? +has 'Show BOTH relock options from step 4'; check "short: the relock rules still travel with the notice" $? +has "log 'origin/main' --format='%h %s' -- 'global-settings/VERSION'"; check "changes: git-fetch path gives the local log command" $? +if [[ ${#CL_VERSIONS[@]} -gt 5 ]]; then + has "… and $(( ${#CL_VERSIONS[@]} - 5 )) earlier version(s)"; check "changes: more than five versions are counted, not listed" $? + lacks "v${CL_VERSIONS[5]} — "; check "changes: the sixth version is not listed" $? +fi +_order=$(grep -n -e "^ v${CL_VERSIONS[0]} — " -e "^ v${CL_VERSIONS[1]} — " <<<"$OUT" | cut -d: -f1 | tr '\n' ' ') +[[ "$_order" =~ ^([0-9]+)\ ([0-9]+)\ $ ]] && (( BASH_REMATCH[1] < BASH_REMATCH[2] )) +check "changes: newest version first" $? + +# installed = third-newest entry: the two newer versions are shown, it is not +echo "${CL_VERSIONS[2]}" > "$CLAUDE_DIR/settings-version" +run_hook +has "v${CL_VERSIONS[0]} — "; check "changes: newest pending version listed" $? +has "v${CL_VERSIONS[1]} — "; check "changes: second pending version listed" $? +lacks "v${CL_VERSIONS[2]} — "; check "changes: the installed version is not listed" $? +lacks 'earlier version(s)'; check "changes: no overflow line for two versions" $? + # ── 2. the list follows settings.json: register a new hook upstream ────────── printf '#!/bin/bash\nexit 0\n' > "$CANON_GS/extra-guard.sh" jq '.hooks.PreToolUse += [{"matcher":"Bash","hooks":[{"type":"command","command":"bash ~/.claude/hooks/extra-guard.sh"}]}]' \ @@ -161,6 +218,8 @@ git -C "$CANON" commit -q -m "register extra-guard.sh" run_hook has 'UPDATE REQUIRED'; check "derived: a bumped canonical VERSION triggers the update notice" $? block_for extra-guard.sh; check "derived: a hook registered only in settings.json appears in the update list" $? +has 'v9.9.9 — (no summary in CHANGELOG.md for this version)'; check "changes: a version without an entry is listed as such" $? +has "v${CUR} — $(summary_of "$CUR")"; check "changes: older entries still shown when the latest has none" $? git -C "$CANON" revert --no-edit HEAD >/dev/null fixture_restored; check "derived: canonical repo restored for the remaining scenarios (fixture sanity)" $? @@ -171,6 +230,7 @@ has 'Settings are up to date'; check "clean: reported up to date" $? has 'installed files match the canonical copies'; check "clean: files reported as verified" $? lacks 'OUT OF SYNC'; check "clean: no OUT OF SYNC notice" $? lacks 'UPDATE REQUIRED'; check "clean: no UPDATE REQUIRED notice" $? +lacks 'What this update adds'; check "clean: no changes block" $? grep -q 'verified ✓' <<<"$ERR"; check "clean: panel shows files verified" $? # ── 4. a registered hook is missing ────────────────────────────────────────── @@ -184,6 +244,7 @@ lacks "origin/main:global-settings/sound-notify.sh"; check "missing: no block fo lacks "origin/main:global-settings/settings.json"; check "missing: no block for an intact settings.json" $? lacks "origin/main:global-settings/VERSION"; check "missing: VERSION is not rewritten" $? has 'Then say: "repair my global settings"'; check "missing: repair phrase present" $? +lacks 'SHORT form'; check "missing: the repair notice keeps its full form" $? has 'sudo chattr -i'; check "missing: unlock steps present" $? lacks 'Settings are up to date'; check "missing: not reported up to date" $? grep -q '1 missing, 0 changed' <<<"$ERR"; check "missing: panel counts it" $? @@ -288,6 +349,19 @@ done block_for VERSION; check "raw update: block for VERSION" $? eq "$(last_fetched)" VERSION; check "raw update: VERSION block is emitted last" $? has "printf '%s\\n' \"\$content\" >"; check "raw update: content is written with printf, not curl -o" $? +has "v${CUR} — $(summary_of "$CUR")"; check "raw changes: the latest version's sentence is shown" $? +has "gh api 'repos/ConductionNL/.github/commits?path=global-settings/VERSION&sha=main"; check "raw changes: Claude gets the commit lookup for the repo" $? +has 'https://github.com/ConductionNL/.github/pull/'; check "raw changes: Claude is told to link the pull request" $? +lacks 'CHANGELOG.md could not be fetched'; check "raw changes: no fetch warning when it was fetched" $? + +# R1b. CHANGELOG.md unreachable: the update still works and the block says so +rm "$TMP/shimroot/CHANGELOG.md" +run_hook +has 'UPDATE REQUIRED'; check "raw no-changelog: update notice still present" $? +block_for VERSION; check "raw no-changelog: VERSION block still emitted" $? +has 'CHANGELOG.md could not be fetched'; check "raw no-changelog: the block says the summary is missing" $? +has "v${CUR} — (no summary in CHANGELOG.md for this version)"; check "raw no-changelog: the latest version is still named" $? +cp "$CANON_GS/CHANGELOG.md" "$TMP/shimroot/CHANGELOG.md" # R2. matching install: up to date via GitHub, every file verified install_all