From a001b83e85f129bdb3c41dab6c1d335911519a06 Mon Sep 17 00:00:00 2001 From: alexander-yevsyukov Date: Tue, 7 Jul 2026 20:11:46 +0100 Subject: [PATCH 1/7] Stop distributing the Claude `settings.local.json` personal layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `migrate` copied `.claude/settings.local.json` into every consumer and overwrote it on each `./config/pull` (the Hugo-only branch even `rm -f`'d it). In Claude Code that file is the gitignored, per-developer personal-override layer (precedence user < project < local), so distributing it silently wiped a developer's overrides and forced the file into Git — contrary to its "local, not committed" role. - `migrate` no longer creates, overwrites, or deletes `settings.local.json`; a `./config/pull` leaves the personal file untouched. - The genuinely org-wide permissions it carried (`Skill(pre-pr)` and the `version-bumped` guard) move into the shared `settings.json` / `settings-hugo.json`; the `echo "exit=$?"` session cruft is dropped. - The distributed `.gitignore` baseline now ignores `/.claude/settings.local.json`, and config un-tracks its own copy to dogfood the personal-layer contract. - Both distributed templates gain `"plansDirectory": ".agents/tasks"` so plan/status docs default into the repo-local, non-clobbered task dir org-wide. Co-Authored-By: Claude Opus 4.8 --- .../claude-settings-local-personal-layer.md | 98 +++++++++++++++++++ .claude/settings-hugo.json | 5 +- .claude/settings.json | 6 +- .claude/settings.local.json | 10 -- .gitignore | 4 + README.md | 7 +- migrate | 10 +- 7 files changed, 123 insertions(+), 17 deletions(-) create mode 100644 .agents/tasks/claude-settings-local-personal-layer.md delete mode 100644 .claude/settings.local.json diff --git a/.agents/tasks/claude-settings-local-personal-layer.md b/.agents/tasks/claude-settings-local-personal-layer.md new file mode 100644 index 000000000..651910aa2 --- /dev/null +++ b/.agents/tasks/claude-settings-local-personal-layer.md @@ -0,0 +1,98 @@ +# Stop `config` from clobbering the Claude `settings.local.json` personal layer + +## Context + +`config/migrate` distributes and overwrites `.claude/settings.local.json` into every +consumer on each `./config/pull`. In Claude Code, `settings.local.json` is the +**gitignored, per-developer personal-override layer** (merge precedence: +`user < project < local`, local wins). Treating it as a distributed file inverts that +contract twice: + +1. Any personal override a developer puts there is silently wiped on the next pull + (the non-Hugo branch `cp`s config's copy over it). +2. It is forced into Git, contrary to its "local, not committed" role — and the + Hugo-only branch even `rm -f`s the developer's personal file. + +The genuinely org-wide permissions the file carried (`Skill(pre-pr)` and the +`version-bumped` guard) belong in the **shared** layer (`settings.json` / +`settings-hugo.json`), not the personal one. The remaining entry +(`Bash(echo "exit=$?")`) is captured session cruft. + +Outcome: `config` treats `settings.local.json` as strictly personal — a pull never +creates, overwrites, or deletes it — while preserving the useful auto-approvals in the +shared templates, gitignoring the file in consumers, and un-tracking `config`'s own copy +so the repo dogfoods the contract. Same-PR follow-on (confirmed with the user): add +`"plansDirectory": ".agents/tasks"` to both templates so plan/status docs default into +the repo-local, non-clobbered task dir org-wide. + +## Changes + +### 1. `migrate` — stop touching `settings.local.json` (lines ~185–202) +- Delete the Hugo-branch `rm -f ../.claude/settings.local.json` (~line 195). +- Delete the else-branch `cp .claude/settings.local.json ../.claude/settings.local.json` (~line 198). +- Rewrite the block comment (~lines 185–188) to explain that `settings.local.json` is + the per-developer personal layer a pull must never create/overwrite/delete, and that + org-wide permissions live in the two shared templates. +- **Keep** `rm -f ../.claude/settings-hugo.json` (~line 202) — that removes a stale + *Hugo template*, unrelated to the personal file, still correct. + +Resulting branch logic: +```bash +if [ "$IS_HUGO_DOCS" = "true" ] && [ "$IS_JVM" = "false" ]; then + cp .claude/settings-hugo.json ../.claude/settings.json +else + cp .claude/settings.json ../.claude/settings.json +fi +rm -f ../.claude/settings-hugo.json +``` + +### 2. Move the genuinely-shared permissions into the shared templates +Drop `Bash(echo "exit=$?")`. Distribute the rest: +- `.claude/settings.json` `allow`: add `Bash(.agents/skills/version-bumped/scripts/version-bumped.sh)` + (next to the existing `.agents/skills/.../update_copyright.py` entry) **and** `Skill(pre-pr)`, + `Skill(pre-pr:*)` (appended after `Bash(./config/migrate)`). +- `.claude/settings-hugo.json` `allow`: add `Skill(pre-pr)`, `Skill(pre-pr:*)` only. + Not `version-bumped` — a pure-Hugo repo has no `version.gradle.kts`; a Hugo **and** JVM + repo takes the else branch and receives `settings.json`, so it still gets the guard. + +### 3. Gitignore `settings.local.json` in consumers +`.gitignore` (config's dual-purpose baseline that `scripts/update-gitignore.sh` distributes +in its managed block): add `/.claude/settings.local.json` beside the existing +`/.claude/worktrees/` under the "Claude working files" comment. The edit is far from the +`# Secrets` / `!*.gpg` span, so `scripts/test-update-gitignore.sh` invariants are untouched. +Because this file is *also* config's own `.gitignore` (dual-purpose header, lines 27–32), +it ignores the file locally too, enabling change #4. + +### 4. Un-track `config`'s own `settings.local.json` (dogfood the contract) +`git rm --cached .claude/settings.local.json` — stages a deletion from the index, leaves the +file on disk (now gitignored via #3). Its org-wide permissions survive because #2 moved them +into `settings.json`. + +### 5. Docs — `README.md` (lines ~65–67) +Update the AI-agent-config bullet: `settings.local.json` is no longer distributed; describe +it as Claude Code's gitignored, per-developer personal layer that `pull` never touches. + +### 6. `plansDirectory` (confirmed: include now) +Add top-level `"plansDirectory": ".agents/tasks"` to both `.claude/settings.json` and +`.claude/settings-hugo.json`. + +## Out of scope / caveat +A consumer that already committed the old config-distributed `settings.local.json` keeps +that now-redundant tracked file until it runs a one-time `git rm --cached +.claude/settings.local.json` itself. `migrate` deliberately does **not** clean it up — the +rule is that a pull must not touch a developer's personal file, and migrate cannot reliably +distinguish an old artifact from a real personal file. Flag this to the team; a content-gated +one-time cleanup could be a later follow-up if wanted. + +## Verification +- `bash -n migrate`; reason through all three branches (JVM/mixed else, Hugo-only, plain + else) — no path references `settings.local.json`, each still writes `settings.json`. +- JSON-validate both edited templates (`python3 -c "import json; json.load(open(...))"`). +- Dry-run into a scratch parent dir: run the settings `cp` block and + `bash scripts/update-gitignore.sh .gitignore /.gitignore`; confirm + (a) no `settings.local.json` is produced, (b) the merged `.gitignore` contains + `/.claude/settings.local.json`, (c) `settings.json` carries the moved permissions + + `plansDirectory`. +- `git status` + `git diff --staged` to show the un-tracking and every edit. **Stage only — + do not commit or push** (the planning file under `.agents/tasks/` is a work artifact and is + not part of the PR). diff --git a/.claude/settings-hugo.json b/.claude/settings-hugo.json index 09c4dd727..069b16bfc 100644 --- a/.claude/settings-hugo.json +++ b/.claude/settings-hugo.json @@ -1,5 +1,6 @@ { "$schema": "https://json.schemastore.org/claude-code-settings.json", + "plansDirectory": ".agents/tasks", "permissions": { "allow": [ "Bash(git status:*)", @@ -30,7 +31,9 @@ "Bash(touch:*)", "Bash(python3 .agents/skills/update-copyright/scripts/update_copyright.py:*)", "Bash(./config/pull)", - "Bash(./config/migrate)" + "Bash(./config/migrate)", + "Skill(pre-pr)", + "Skill(pre-pr:*)" ], "deny": [ "Bash(git push:*)", diff --git a/.claude/settings.json b/.claude/settings.json index 357650cf7..4b869f6b3 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -1,5 +1,6 @@ { "$schema": "https://json.schemastore.org/claude-code-settings.json", + "plansDirectory": ".agents/tasks", "permissions": { "allow": [ "Edit(version.gradle.kts)", @@ -32,8 +33,11 @@ "Bash(mkdir:*)", "Bash(touch:*)", "Bash(python3 .agents/skills/update-copyright/scripts/update_copyright.py:*)", + "Bash(.agents/skills/version-bumped/scripts/version-bumped.sh)", "Bash(./config/pull)", - "Bash(./config/migrate)" + "Bash(./config/migrate)", + "Skill(pre-pr)", + "Skill(pre-pr:*)" ], "deny": [ "Bash(git reset --hard:*)", diff --git a/.claude/settings.local.json b/.claude/settings.local.json deleted file mode 100644 index 602e6ceaa..000000000 --- a/.claude/settings.local.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "permissions": { - "allow": [ - "Skill(pre-pr)", - "Skill(pre-pr:*)", - "Bash(.agents/skills/version-bumped/scripts/version-bumped.sh)", - "Bash(echo \"exit=$?\")" - ] - } -} diff --git a/.gitignore b/.gitignore index 14d9a3ae3..f75a10898 100644 --- a/.gitignore +++ b/.gitignore @@ -177,6 +177,10 @@ __pycache__/ # Claude working files /.claude/worktrees/ +# Personal, per-developer Claude Code settings overrides (never committed; +# the distributed `.claude/settings.json` is the shared, committed layer). +/.claude/settings.local.json + # Auto-downloaded Lychee binary used by the `check-links` skill. /.agents/skills/check-links/.cache/ diff --git a/README.md b/README.md index 52ef1bfc3..e340c03cc 100644 --- a/README.md +++ b/README.md @@ -62,9 +62,10 @@ The `pull` script also wires up AI-agent configuration: the submodule on the first run and floats it to the latest `agents@master` (checked out *on* `master`, not a detached `HEAD`) on every subsequent run — so shared skills update everywhere with **no file churn** in consumer pull requests. - * `.claude/settings.json` — the permission allowlist distributed by `config` (Hugo-only - repos receive a Hugo-tuned variant). JVM and mixed repos also get `settings.local.json`; - Hugo-only repos do not. + * `.claude/settings.json` — the shared, committed permission allowlist distributed by + `config` (Hugo-only repos receive a Hugo-tuned variant). `.claude/settings.local.json` + is **not** distributed: it is Claude Code's gitignored, per-developer personal-override + layer, so `pull` never creates, overwrites, or deletes it. * `.junie/guidelines.md` — JetBrains Junie guidelines. Per-repo content is never overwritten: `docs/project.md` (linked from `.agents/project.md`), diff --git a/migrate b/migrate index f20fca7dc..9d666dee6 100644 --- a/migrate +++ b/migrate @@ -207,6 +207,14 @@ fi # --------------------------------------------------------------------------- # Claude settings — the commands/agents/skills come from the submodule above; # only the permission settings are repo configuration distributed by `config`. +# +# `settings.json` is the SHARED, committed permission layer (org-wide defaults); +# for a Hugo-only repo the Hugo-tuned `settings-hugo.json` is applied under that +# name. `settings.local.json` is deliberately NOT distributed: in Claude Code it +# is the gitignored, per-developer personal-override layer (precedence +# user < project < local), so a `./config/pull` must never create, overwrite, or +# delete it — doing either wiped a developer's personal overrides and forced the +# file into Git. Org-wide permissions belong in the two shared templates above. # --------------------------------------------------------------------------- echo "Updating Claude settings" @@ -214,10 +222,8 @@ mkdir -p ../.claude if [ "$IS_HUGO_DOCS" = "true" ] && [ "$IS_JVM" = "false" ]; then # Hugo-only repos use the Hugo-tuned permission set. cp .claude/settings-hugo.json ../.claude/settings.json - rm -f ../.claude/settings.local.json else cp .claude/settings.json ../.claude/settings.json - cp .claude/settings.local.json ../.claude/settings.local.json fi # settings-hugo.json is a config-internal template (applied as settings.json for # Hugo-only repos), never distributed as-is; drop any stale copy an older migrate left. From 3db66f41319022150bdd687dc16087d00a560758 Mon Sep 17 00:00:00 2001 From: alexander-yevsyukov Date: Tue, 7 Jul 2026 20:13:06 +0100 Subject: [PATCH 2/7] Bump ToolBase -> `2.0.0-SNAPSHOT.403` --- .../src/main/kotlin/io/spine/dependency/local/ToolBase.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/buildSrc/src/main/kotlin/io/spine/dependency/local/ToolBase.kt b/buildSrc/src/main/kotlin/io/spine/dependency/local/ToolBase.kt index cd11577fb..c0cbea386 100644 --- a/buildSrc/src/main/kotlin/io/spine/dependency/local/ToolBase.kt +++ b/buildSrc/src/main/kotlin/io/spine/dependency/local/ToolBase.kt @@ -34,8 +34,8 @@ package io.spine.dependency.local @Suppress("ConstPropertyName", "unused") object ToolBase { const val group = Spine.toolsGroup - const val version = "2.0.0-SNAPSHOT.402" - const val dogfoodingVersion = "2.0.0-SNAPSHOT.402" + const val version = "2.0.0-SNAPSHOT.403" + const val dogfoodingVersion = "2.0.0-SNAPSHOT.403" const val lib = "$group:tool-base:$version" const val classicCodegen = "$group:classic-codegen:$version" From 0a177ec6da0244e23433876fb78ff6828f95c692 Mon Sep 17 00:00:00 2001 From: alexander-yevsyukov Date: Tue, 7 Jul 2026 21:07:42 +0100 Subject: [PATCH 3/7] Tidy the settings-distribution task note Wrap two over-length lines and correct the stale "not part of the PR" line (the plan file is committed as the tracked task doc, per AGENTS.md). Co-Authored-By: Claude Opus 4.8 --- .../claude-settings-local-personal-layer.md | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/.agents/tasks/claude-settings-local-personal-layer.md b/.agents/tasks/claude-settings-local-personal-layer.md index 651910aa2..28c283404 100644 --- a/.agents/tasks/claude-settings-local-personal-layer.md +++ b/.agents/tasks/claude-settings-local-personal-layer.md @@ -29,7 +29,8 @@ the repo-local, non-clobbered task dir org-wide. ### 1. `migrate` — stop touching `settings.local.json` (lines ~185–202) - Delete the Hugo-branch `rm -f ../.claude/settings.local.json` (~line 195). -- Delete the else-branch `cp .claude/settings.local.json ../.claude/settings.local.json` (~line 198). +- Delete the else-branch `cp .claude/settings.local.json ../.claude/settings.local.json` + (~line 198). - Rewrite the block comment (~lines 185–188) to explain that `settings.local.json` is the per-developer personal layer a pull must never create/overwrite/delete, and that org-wide permissions live in the two shared templates. @@ -48,9 +49,10 @@ rm -f ../.claude/settings-hugo.json ### 2. Move the genuinely-shared permissions into the shared templates Drop `Bash(echo "exit=$?")`. Distribute the rest: -- `.claude/settings.json` `allow`: add `Bash(.agents/skills/version-bumped/scripts/version-bumped.sh)` - (next to the existing `.agents/skills/.../update_copyright.py` entry) **and** `Skill(pre-pr)`, - `Skill(pre-pr:*)` (appended after `Bash(./config/migrate)`). +- `.claude/settings.json` `allow`: add + `Bash(.agents/skills/version-bumped/scripts/version-bumped.sh)` (next to the existing + `.agents/skills/.../update_copyright.py` entry) **and** `Skill(pre-pr)`, `Skill(pre-pr:*)` + (appended after `Bash(./config/migrate)`). - `.claude/settings-hugo.json` `allow`: add `Skill(pre-pr)`, `Skill(pre-pr:*)` only. Not `version-bumped` — a pure-Hugo repo has no `version.gradle.kts`; a Hugo **and** JVM repo takes the else branch and receives `settings.json`, so it still gets the guard. @@ -93,6 +95,6 @@ one-time cleanup could be a later follow-up if wanted. (a) no `settings.local.json` is produced, (b) the merged `.gitignore` contains `/.claude/settings.local.json`, (c) `settings.json` carries the moved permissions + `plansDirectory`. -- `git status` + `git diff --staged` to show the un-tracking and every edit. **Stage only — - do not commit or push** (the planning file under `.agents/tasks/` is a work artifact and is - not part of the PR). +- `git status` + `git diff --cached` confirmed the un-tracking and every edit before + committing. Per `AGENTS.md`, this plan file is committed as the tracked task doc under + `.agents/tasks/` during the work and is deleted on merge to master. From 175827e88bb4165717fadb6dd1c4702cdb422f9c Mon Sep 17 00:00:00 2001 From: alexander-yevsyukov Date: Tue, 7 Jul 2026 21:22:14 +0100 Subject: [PATCH 4/7] Refine the `settings.local.json` note in `migrate` "doing either" implied two actions but three precede it; say "any of these ... or forced the file into Git" (per docs review). Comment-only. Co-Authored-By: Claude Opus 4.8 --- migrate | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/migrate b/migrate index 9d666dee6..c66229a64 100644 --- a/migrate +++ b/migrate @@ -213,8 +213,8 @@ fi # name. `settings.local.json` is deliberately NOT distributed: in Claude Code it # is the gitignored, per-developer personal-override layer (precedence # user < project < local), so a `./config/pull` must never create, overwrite, or -# delete it — doing either wiped a developer's personal overrides and forced the -# file into Git. Org-wide permissions belong in the two shared templates above. +# delete it — doing any of these wiped a developer's personal overrides or forced +# the file into Git. Org-wide permissions belong in the two shared templates above. # --------------------------------------------------------------------------- echo "Updating Claude settings" From a345512b9bbba7fda0979f0a51740d4e6c102c37 Mon Sep 17 00:00:00 2001 From: alexander-yevsyukov Date: Tue, 7 Jul 2026 21:34:59 +0100 Subject: [PATCH 5/7] Redirect `plansDirectory` to the gitignored `.claude/plans` scratch dir Claude Code's plan-mode files get harness-assigned random slugs, so routing them into `.agents/tasks/` would mix random-named files into the meaningfully-named task namespace. Point `plansDirectory` at a gitignored `.claude/plans/` scratch dir instead (in both distributed templates), and ignore it in the baseline; durable, agent-authored task docs stay under `.agents/tasks/`. Mirrors agents PR #30. Co-Authored-By: Claude Opus 4.8 --- .agents/tasks/claude-settings-local-personal-layer.md | 11 +++++++---- .claude/settings-hugo.json | 2 +- .claude/settings.json | 2 +- .gitignore | 2 ++ 4 files changed, 11 insertions(+), 6 deletions(-) diff --git a/.agents/tasks/claude-settings-local-personal-layer.md b/.agents/tasks/claude-settings-local-personal-layer.md index 28c283404..8db179de8 100644 --- a/.agents/tasks/claude-settings-local-personal-layer.md +++ b/.agents/tasks/claude-settings-local-personal-layer.md @@ -22,8 +22,9 @@ Outcome: `config` treats `settings.local.json` as strictly personal — a pull n creates, overwrites, or deletes it — while preserving the useful auto-approvals in the shared templates, gitignoring the file in consumers, and un-tracking `config`'s own copy so the repo dogfoods the contract. Same-PR follow-on (confirmed with the user): add -`"plansDirectory": ".agents/tasks"` to both templates so plan/status docs default into -the repo-local, non-clobbered task dir org-wide. +`"plansDirectory": ".claude/plans"` to both templates so Claude Code's ephemeral, +harness-slugged plan-mode files default into a gitignored scratch dir (durable, +meaningfully-named task docs stay in `.agents/tasks/`). ## Changes @@ -75,8 +76,10 @@ Update the AI-agent-config bullet: `settings.local.json` is no longer distribute it as Claude Code's gitignored, per-developer personal layer that `pull` never touches. ### 6. `plansDirectory` (confirmed: include now) -Add top-level `"plansDirectory": ".agents/tasks"` to both `.claude/settings.json` and -`.claude/settings-hugo.json`. +Add top-level `"plansDirectory": ".claude/plans"` to both `.claude/settings.json` and +`.claude/settings-hugo.json`, and gitignore `/.claude/plans/` in the baseline. Ephemeral +plan-mode files (harness-assigned random slugs) go to this gitignored scratch dir; +durable task docs stay meaningfully-named under `.agents/tasks/`. ## Out of scope / caveat A consumer that already committed the old config-distributed `settings.local.json` keeps diff --git a/.claude/settings-hugo.json b/.claude/settings-hugo.json index 069b16bfc..337c904bc 100644 --- a/.claude/settings-hugo.json +++ b/.claude/settings-hugo.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/claude-code-settings.json", - "plansDirectory": ".agents/tasks", + "plansDirectory": ".claude/plans", "permissions": { "allow": [ "Bash(git status:*)", diff --git a/.claude/settings.json b/.claude/settings.json index 4b869f6b3..3c7da68c3 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/claude-code-settings.json", - "plansDirectory": ".agents/tasks", + "plansDirectory": ".claude/plans", "permissions": { "allow": [ "Edit(version.gradle.kts)", diff --git a/.gitignore b/.gitignore index f75a10898..3e1f89add 100644 --- a/.gitignore +++ b/.gitignore @@ -176,6 +176,8 @@ __pycache__/ # Claude working files /.claude/worktrees/ +# Ephemeral plan-mode scratch (durable task docs live in `.agents/tasks/`). +/.claude/plans/ # Personal, per-developer Claude Code settings overrides (never committed; # the distributed `.claude/settings.json` is the shared, committed layer). From de8874c4a89f171528f25bc8a8ee9cbcc53d5c6d Mon Sep 17 00:00:00 2001 From: alexander-yevsyukov Date: Tue, 7 Jul 2026 22:07:11 +0100 Subject: [PATCH 6/7] Remove the settings-distribution task plan (delete-on-merge) The work is complete and the PR description is the durable record. Per .agents/tasks/README.md the plan file is deleted rather than landing in master; this also resolves the missing-frontmatter review note by not shipping the file. Co-Authored-By: Claude Opus 4.8 --- .../claude-settings-local-personal-layer.md | 103 ------------------ 1 file changed, 103 deletions(-) delete mode 100644 .agents/tasks/claude-settings-local-personal-layer.md diff --git a/.agents/tasks/claude-settings-local-personal-layer.md b/.agents/tasks/claude-settings-local-personal-layer.md deleted file mode 100644 index 8db179de8..000000000 --- a/.agents/tasks/claude-settings-local-personal-layer.md +++ /dev/null @@ -1,103 +0,0 @@ -# Stop `config` from clobbering the Claude `settings.local.json` personal layer - -## Context - -`config/migrate` distributes and overwrites `.claude/settings.local.json` into every -consumer on each `./config/pull`. In Claude Code, `settings.local.json` is the -**gitignored, per-developer personal-override layer** (merge precedence: -`user < project < local`, local wins). Treating it as a distributed file inverts that -contract twice: - -1. Any personal override a developer puts there is silently wiped on the next pull - (the non-Hugo branch `cp`s config's copy over it). -2. It is forced into Git, contrary to its "local, not committed" role — and the - Hugo-only branch even `rm -f`s the developer's personal file. - -The genuinely org-wide permissions the file carried (`Skill(pre-pr)` and the -`version-bumped` guard) belong in the **shared** layer (`settings.json` / -`settings-hugo.json`), not the personal one. The remaining entry -(`Bash(echo "exit=$?")`) is captured session cruft. - -Outcome: `config` treats `settings.local.json` as strictly personal — a pull never -creates, overwrites, or deletes it — while preserving the useful auto-approvals in the -shared templates, gitignoring the file in consumers, and un-tracking `config`'s own copy -so the repo dogfoods the contract. Same-PR follow-on (confirmed with the user): add -`"plansDirectory": ".claude/plans"` to both templates so Claude Code's ephemeral, -harness-slugged plan-mode files default into a gitignored scratch dir (durable, -meaningfully-named task docs stay in `.agents/tasks/`). - -## Changes - -### 1. `migrate` — stop touching `settings.local.json` (lines ~185–202) -- Delete the Hugo-branch `rm -f ../.claude/settings.local.json` (~line 195). -- Delete the else-branch `cp .claude/settings.local.json ../.claude/settings.local.json` - (~line 198). -- Rewrite the block comment (~lines 185–188) to explain that `settings.local.json` is - the per-developer personal layer a pull must never create/overwrite/delete, and that - org-wide permissions live in the two shared templates. -- **Keep** `rm -f ../.claude/settings-hugo.json` (~line 202) — that removes a stale - *Hugo template*, unrelated to the personal file, still correct. - -Resulting branch logic: -```bash -if [ "$IS_HUGO_DOCS" = "true" ] && [ "$IS_JVM" = "false" ]; then - cp .claude/settings-hugo.json ../.claude/settings.json -else - cp .claude/settings.json ../.claude/settings.json -fi -rm -f ../.claude/settings-hugo.json -``` - -### 2. Move the genuinely-shared permissions into the shared templates -Drop `Bash(echo "exit=$?")`. Distribute the rest: -- `.claude/settings.json` `allow`: add - `Bash(.agents/skills/version-bumped/scripts/version-bumped.sh)` (next to the existing - `.agents/skills/.../update_copyright.py` entry) **and** `Skill(pre-pr)`, `Skill(pre-pr:*)` - (appended after `Bash(./config/migrate)`). -- `.claude/settings-hugo.json` `allow`: add `Skill(pre-pr)`, `Skill(pre-pr:*)` only. - Not `version-bumped` — a pure-Hugo repo has no `version.gradle.kts`; a Hugo **and** JVM - repo takes the else branch and receives `settings.json`, so it still gets the guard. - -### 3. Gitignore `settings.local.json` in consumers -`.gitignore` (config's dual-purpose baseline that `scripts/update-gitignore.sh` distributes -in its managed block): add `/.claude/settings.local.json` beside the existing -`/.claude/worktrees/` under the "Claude working files" comment. The edit is far from the -`# Secrets` / `!*.gpg` span, so `scripts/test-update-gitignore.sh` invariants are untouched. -Because this file is *also* config's own `.gitignore` (dual-purpose header, lines 27–32), -it ignores the file locally too, enabling change #4. - -### 4. Un-track `config`'s own `settings.local.json` (dogfood the contract) -`git rm --cached .claude/settings.local.json` — stages a deletion from the index, leaves the -file on disk (now gitignored via #3). Its org-wide permissions survive because #2 moved them -into `settings.json`. - -### 5. Docs — `README.md` (lines ~65–67) -Update the AI-agent-config bullet: `settings.local.json` is no longer distributed; describe -it as Claude Code's gitignored, per-developer personal layer that `pull` never touches. - -### 6. `plansDirectory` (confirmed: include now) -Add top-level `"plansDirectory": ".claude/plans"` to both `.claude/settings.json` and -`.claude/settings-hugo.json`, and gitignore `/.claude/plans/` in the baseline. Ephemeral -plan-mode files (harness-assigned random slugs) go to this gitignored scratch dir; -durable task docs stay meaningfully-named under `.agents/tasks/`. - -## Out of scope / caveat -A consumer that already committed the old config-distributed `settings.local.json` keeps -that now-redundant tracked file until it runs a one-time `git rm --cached -.claude/settings.local.json` itself. `migrate` deliberately does **not** clean it up — the -rule is that a pull must not touch a developer's personal file, and migrate cannot reliably -distinguish an old artifact from a real personal file. Flag this to the team; a content-gated -one-time cleanup could be a later follow-up if wanted. - -## Verification -- `bash -n migrate`; reason through all three branches (JVM/mixed else, Hugo-only, plain - else) — no path references `settings.local.json`, each still writes `settings.json`. -- JSON-validate both edited templates (`python3 -c "import json; json.load(open(...))"`). -- Dry-run into a scratch parent dir: run the settings `cp` block and - `bash scripts/update-gitignore.sh .gitignore /.gitignore`; confirm - (a) no `settings.local.json` is produced, (b) the merged `.gitignore` contains - `/.claude/settings.local.json`, (c) `settings.json` carries the moved permissions + - `plansDirectory`. -- `git status` + `git diff --cached` confirmed the un-tracking and every edit before - committing. Per `AGENTS.md`, this plan file is committed as the tracked task doc under - `.agents/tasks/` during the work and is deleted on merge to master. From fcbe1a3ed78d4b20a4c23e3109da65d9ab28bf5b Mon Sep 17 00:00:00 2001 From: alexander-yevsyukov Date: Tue, 7 Jul 2026 23:57:57 +0100 Subject: [PATCH 7/7] Filter retired baseline negations during `.gitignore` migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A consumer whose `.gitignore` is a legacy unmarked raw copy of an older baseline still carries `!.idea/misc.xml` (the re-inclusion the current baseline dropped). `update-gitignore.sh`'s first-migration path preserves every consumer negation into the repo-local block, which sits AFTER the managed baseline — so, gitignore being last-match-wins, the stale negation re-included `.idea/misc.xml`, leaving it committable even after a `git rm --cached`. An already-migrated consumer hits the same via the steady-state path (the negation was baked into repo-local by an earlier first-migration). The secret trailer does not catch this — `misc.xml` is not a secret glob. Add a `retired_negations` set (currently `!.idea/misc.xml`) and strip it from the repo-local region in BOTH the steady-state and first-migration paths, so the baseline's `.idea/*.xml` ignore wins. Genuine consumer negations and custom lines are untouched. Covered by two new regression cases in `test-update-gitignore.sh` (steady-state and first-migration); the full suite passes. Co-Authored-By: Claude Opus 4.8 --- scripts/test-update-gitignore.sh | 53 ++++++++++++++++++++++++++++++++ scripts/update-gitignore.sh | 51 +++++++++++++++++++++--------- 2 files changed, 89 insertions(+), 15 deletions(-) diff --git a/scripts/test-update-gitignore.sh b/scripts/test-update-gitignore.sh index f505be3ca..c6bb2ae16 100755 --- a/scripts/test-update-gitignore.sh +++ b/scripts/test-update-gitignore.sh @@ -80,6 +80,9 @@ git -C "$consumer" config user.name test printf '%s\n' '!gradle-wrapper.jar' printf '%s\n' '!debug.log' printf '%s\n' '*.log' + # A retired baseline negation an earlier first-migration baked into repo-local; + # the steady-state merge must strip it so `.idea/misc.xml` stays ignored. + printf '%s\n' '!.idea/misc.xml' # A consumer comment that merely BEGINS like the legacy secret trailer, plus a # real entry after it. The legacy match is exact, so this comment is preserved # and the entry survives (regression guard for the start-anchored to-EOF drop). @@ -103,6 +106,7 @@ mkdir -p generated && : > generated/secret.gpg : > info.log : > creds.secret.properties.gpg mkdir -p keep-me-ignored && : > keep-me-ignored/x +mkdir -p .idea && : > .idea/misc.xml # (1) Every decrypted credential must be ignored despite the repo-local negations. for f in "${secrets[@]}"; do @@ -196,6 +200,21 @@ else f-ail "look-alike legacy comment triggered to-EOF drop; 'keep-me-ignored/' lost" fi +# (3h) A retired baseline negation baked into repo-local (`!.idea/misc.xml`) must be +# stripped so the baseline's `.idea/*.xml` ignore wins. The secret trailer does NOT +# cover it (misc.xml is not a secret glob), so without stripping it would re-expose the +# per-developer IDEA project file. +if git check-ignore -q .idea/misc.xml; then + pass "retired '!.idea/misc.xml' stripped from repo-local (.idea/misc.xml stays ignored)" +else + f-ail "retired '!.idea/misc.xml' survived in repo-local (.idea/misc.xml re-exposed)" +fi +if grep -qxF '!.idea/misc.xml' "$consumer/.gitignore"; then + f-ail "retired '!.idea/misc.xml' still present in the merged .gitignore" +else + pass "retired '!.idea/misc.xml' removed from the merged .gitignore" +fi + # --- (4) Idempotency: re-running yields byte-identical output. ----------------- cp "$consumer/.gitignore" "$work/first.gitignore" ( cd "$consumer/config" && bash "$script" ) @@ -312,6 +331,40 @@ else diff "$work/c3-first.gitignore" "$consumer3/.gitignore" >&2 || true fi +# --- (8) First-migration retired negation: a legacy raw copy carrying the OLD ------ +# baseline's `!.idea/misc.xml`. The bootstrap keeps consumer negations, so without the +# retired-negation filter this stale `!` lands in repo-local (after the managed block) +# and re-includes `.idea/misc.xml`. It must be stripped; a genuine consumer line beside +# it must still survive. +consumer4="$work/consumer4" +mkdir -p "$consumer4/config" +cp "$baseline" "$consumer4/config/.gitignore" +git -C "$consumer4" init -q +git -C "$consumer4" config user.email test@example.com +git -C "$consumer4" config user.name test +# Legacy raw copy of an OLDER baseline: current baseline + the retired negation it used +# to carry + a genuine consumer line. No managed markers. +{ cat "$baseline"; printf '%s\n' '!.idea/misc.xml' 'my-own-cache/'; } > "$consumer4/.gitignore" + +( cd "$consumer4/config" && bash "$script" ) + +mkdir -p "$consumer4/.idea" && : > "$consumer4/.idea/misc.xml" +if git -C "$consumer4" check-ignore -q .idea/misc.xml; then + pass "first-migration strips retired '!.idea/misc.xml' (.idea/misc.xml stays ignored)" +else + f-ail "first-migration kept retired '!.idea/misc.xml' (.idea/misc.xml re-exposed)" +fi +if git -C "$consumer4" check-ignore -q my-own-cache/x; then + pass "first-migration preserved the genuine consumer line beside the filtered negation" +else + f-ail "first-migration dropped the genuine consumer line" +fi +if grep -qxF '!.idea/misc.xml' "$consumer4/.gitignore"; then + f-ail "first-migration left the retired '!.idea/misc.xml' in the merged file" +else + pass "first-migration removed the retired '!.idea/misc.xml' from the merged file" +fi + echo if [ "$fail" -eq 0 ]; then echo "OK: all update_gitignore regression checks passed." diff --git a/scripts/update-gitignore.sh b/scripts/update-gitignore.sh index e6f177c84..3f56dd108 100755 --- a/scripts/update-gitignore.sh +++ b/scripts/update-gitignore.sh @@ -88,6 +88,16 @@ secret_end='# <<< secret ignores <<<' legacy_local_label='# --- repo-local entries (preserved across ./config/pull) ---' legacy_secret_label='# --- secret ignores re-asserted last so a repo-local negation cannot un-ignore a credential ---' +# Negations config once shipped as baseline `!`-re-inclusions but has since retired. +# A legacy raw-copy consumer (first-migration) — or an already-migrated consumer whose +# repo-local was written by an earlier first-migration — still carries them. Left in the +# repo-local region they sit AFTER the managed baseline and, gitignore being +# last-match-wins, re-include a path the current baseline now ignores. Strip them so the +# baseline's ignore wins. First entry: `!.idea/misc.xml` (the current baseline dropped its +# `!.idea/misc.xml` re-inclusion to keep the per-machine IDEA project file untracked). +# Newline-separated; extend as further negations are retired. +retired_negations='!.idea/misc.xml' + # Positive secret patterns from the baseline's Secrets section: the span from the # `# Secrets` header to the closing `!*.gpg`, excluding comments and negations. # (The `# Secrets` header and the trailing `!*.gpg` are asserted as invariants by @@ -131,16 +141,20 @@ fi if grep -qxF "$base_begin" "$dest"; then # STEADY STATE — the file already carries our markers. Repo-local entries are # everything OUTSIDE the managed blocks, preserved VERBATIM and IN ORDER (no - # de-duplication, no filtering). Stripped here: the baseline block, the secrets - # block, the repo-local markers themselves (their CONTENT is what we keep), and - # any legacy pre-marker scaffolding written by intermediate script versions (the + # de-duplication, no reordering). Stripped here: the baseline block, the secrets + # block, the repo-local markers themselves (their CONTENT is what we keep), any + # legacy pre-marker scaffolding written by intermediate script versions (the # `legacy_local_label` and a `legacy_secret_label` trailer that always ran to - # end-of-file), matched EXACTLY — so an already-migrated consumer converts to the - # marker format exactly once, and a look-alike consumer comment is never dropped. + # end-of-file), matched EXACTLY, and any retired baseline negation (see + # `retired_negations`) that would otherwise re-include a now-ignored path — so an + # already-migrated consumer converts to the marker format exactly once, and a + # look-alike consumer comment is never dropped. awk -v bb="$base_begin" -v eb="$base_end" \ -v sb="$secret_begin" -v se="$secret_end" \ -v lb="$local_begin" -v le="$local_end" \ - -v lll="$legacy_local_label" -v lsl="$legacy_secret_label" ' + -v lll="$legacy_local_label" -v lsl="$legacy_secret_label" \ + -v rn="$retired_negations" ' + BEGIN { k = split(rn, r, "\n"); for (i = 1; i <= k; i++) if (r[i] != "") retired[r[i]] = 1 } $0 == bb { inb = 1; next } inb { if ($0 == eb) inb = 0; next } $0 == sb { ins = 1; next } @@ -149,6 +163,7 @@ if grep -qxF "$base_begin" "$dest"; then legacy { next } $0 == lb || $0 == le { next } $0 == lll { next } + $0 in retired { next } { print } ' "$dest" | trim_blank_edges > "$locals" else @@ -156,16 +171,22 @@ else # with the consumer's own lines appended). One-time best-effort bootstrap: drop # lines duplicating a baseline POSITIVE pattern (already in the managed block), # keep every negation (a consumer may re-state an exception such as - # `!gradle-wrapper.jar` after their own broad ignore) and every custom line. - # Keeping negations means a raw baseline copy carries the baseline's OWN - # negations (e.g. `!*.gpg`) into the repo-local block on this one pull — cosmetic - # and harmless (they re-include already-included paths), and dropping them would - # reintroduce the order-sensitive de-dup this rewrite removed, so we don't. - # After this run writes the markers, the exact steady-state path above takes - # over. `|| true`: a pure raw copy leaves no custom lines, and `grep -v` exits 1 - # when it selects nothing — not an error here. + # `!gradle-wrapper.jar` after their own broad ignore) and every custom line — + # EXCEPT a retired baseline negation (see `retired_negations`), which a raw copy + # of an older baseline still carries and which, kept in repo-local, would + # re-include a path the current baseline now ignores (e.g. `!.idea/misc.xml`). + # Keeping the other negations means a raw baseline copy also carries the baseline's + # still-current OWN negations (e.g. `!*.gpg`) into the repo-local block on this one + # pull — cosmetic and harmless (they re-include already-included paths), and dropping + # them wholesale would reintroduce the order-sensitive de-dup this rewrite removed, + # so we drop only the retired set. After this run writes the markers, the exact + # steady-state path above takes over. `|| true`: a pure raw copy leaves no custom + # lines, and `grep -v` exits 1 when it selects nothing — not an error here. { grep -vxF -f <(grep -v '^!' "$src") "$dest" || true; } \ - | awk '!seen[$0]++' | trim_blank_edges > "$locals" + | awk -v rn="$retired_negations" \ + 'BEGIN { k = split(rn, r, "\n"); for (i = 1; i <= k; i++) if (r[i] != "") retired[r[i]] = 1 } + $0 in retired { next } !seen[$0]++' \ + | trim_blank_edges > "$locals" fi # Fail closed: re-asserting secrets only matters when there ARE repo-local lines