diff --git a/ai/claude/settings.json b/ai/claude/settings.json new file mode 100644 index 0000000..b503d9c --- /dev/null +++ b/ai/claude/settings.json @@ -0,0 +1,41 @@ +{ + "model": "opus", + "effortLevel": "xhigh", + "permissions": { + "allow": [ + "mcp__hive__vault_query", + "mcp__hive__vault_write", + "mcp__sequential-thinking__sequentialthinking" + ] + }, + "hooks": { + "SessionStart": [ + { + "matcher": "", + "hooks": [ + { + "type": "command", + "command": "__HOOK_COMMAND__", + "timeout": 30 + } + ] + } + ] + }, + "enabledPlugins": { + "code-review@claude-plugins-official": true, + "feature-dev@claude-plugins-official": true, + "code-simplifier@claude-plugins-official": true, + "ralph-loop@claude-plugins-official": true, + "explanatory-output-style@claude-plugins-official": true, + "learning-output-style@claude-plugins-official": true, + "claude-md-management@claude-plugins-official": true, + "github@claude-plugins-official": true, + "claude-code-setup@claude-plugins-official": true, + "gopls-lsp@claude-plugins-official": true, + "security-guidance@claude-plugins-official": true, + "frontend-design@claude-plugins-official": true, + "commit-commands@claude-plugins-official": true, + "pr-review-toolkit@claude-plugins-official": true + } +} diff --git a/setup-linux.sh b/setup-linux.sh index c3d4821..ae307e9 100755 --- a/setup-linux.sh +++ b/setup-linux.sh @@ -304,7 +304,14 @@ log_success "Gemini CLI configured" # Claude Code ensure_directory "$HOME/.claude" ensure_directory "$HOME/.claude/skills" -cp -rf "$CURRENT_DIR/ai/claude/"* "$HOME/.claude/" 2>/dev/null || true +# Bulk copy ai/claude/* EXCEPT settings.json (SDD-002: handled by +# merge_claude_settings below, which substitutes __HOOK_COMMAND__ and applies +# the per-key merge policy preserving user customizations). +for _claude_src in "$CURRENT_DIR/ai/claude/"*; do + [ "$(basename "$_claude_src")" = "settings.json" ] && continue + cp -rf "$_claude_src" "$HOME/.claude/" 2>/dev/null || true +done +unset _claude_src cp -f "$CURRENT_DIR/scripts/init-project.sh" "$HOME/.claude/" 2>/dev/null || true # Sync Claude skills: remove stale skill directories not in source. # CRITICAL: For symlinks (vault-hosted skills, see link_vault_skills below), @@ -623,31 +630,75 @@ else log_warning "Claude Code CLI not found, skipping plugin installation" fi -# Register Claude Code SessionStart hook for vault health -# Self-healing: compare existing command against expected and rewrite if they -# diverge — never trust "an entry exists" to mean "the entry is correct". +# Merge `ai/claude/settings.json` template into the deployed `~/.claude/settings.json` +# per the per-key policy in specs/SDD-002-settings-portability/proposal.md. Bootstrap +# when target missing. Preserves user customizations (Read paths, +# additionalDirectories, third-party hooks like claude-mem / GitGuardian) by only +# touching the keys declared as "ours" in the template. The template's +# __HOOK_COMMAND__ placeholder is replaced via jq --arg before any merge / write. +merge_claude_settings() { + local template_path="$1" + local target_path="$2" + local hook_command="$3" + + if [ ! -f "$template_path" ]; then + log_warning "Claude settings template not found at $template_path, skipping merge" + return 0 + fi + + if ! command -v jq >/dev/null 2>&1; then + log_warning "jq not found, skipping settings merge (install jq and re-run)" + return 0 + fi + + local template_substituted + template_substituted=$(jq --arg cmd "$hook_command" \ + '(.hooks.SessionStart[0].hooks[0].command) = $cmd' \ + "$template_path" 2>/dev/null) + if [ -z "$template_substituted" ]; then + log_warning "Claude settings template substitution failed, skipping merge" + return 0 + fi + + if [ ! -f "$target_path" ]; then + log_info "Bootstrapping ~/.claude/settings.json from template (file did not exist)" + echo "$template_substituted" > "$target_path" + log_success "Claude settings.json bootstrapped from template" + return 0 + fi + + # Per-key merge via single jq invocation. Policy table in proposal.md: + # model, effortLevel: template wins. permissions.allow: UNION (deduped). + # hooks.SessionStart: template wins (replace). enabledPlugins: object + # merge (template wins on conflict). All other keys: existing preserved. + local merged + merged=$(jq --argjson tmpl "$template_substituted" ' + .model = $tmpl.model + | .effortLevel = $tmpl.effortLevel + | .permissions = (.permissions // {}) + | .permissions.allow = (((.permissions.allow // []) + $tmpl.permissions.allow) | unique) + | .hooks = (.hooks // {}) + | .hooks.SessionStart = $tmpl.hooks.SessionStart + | .enabledPlugins = ((.enabledPlugins // {}) + $tmpl.enabledPlugins) + ' "$target_path" 2>/dev/null) + if [ -z "$merged" ]; then + log_warning "Claude settings merge produced empty output, skipping write" + return 0 + fi + + echo "$merged" > "$target_path" + log_success "Claude settings.json merged from template (user customizations preserved)" +} + +# SDD-002 (PR #51): single source of truth for the "dotfiles-owned" subset of +# settings.json lives at ai/claude/settings.json. Previous inline `HOOK_ENTRY` +# heredoc + `jq --argjson` is gone -- merge_claude_settings reads the template, +# substitutes __HOOK_COMMAND__, applies per-key policy, bootstraps if missing. +log_info "Applying Claude settings.json template + registering SessionStart hook..." CLAUDE_SETTINGS="$HOME/.claude/settings.json" +CLAUDE_SETTINGS_TEMPLATE="$CURRENT_DIR/ai/claude/settings.json" EXPECTED_HOOK_COMMAND="$HOME/.dotfiles/scripts/claude-session-start.sh" -if [ -f "$CLAUDE_SETTINGS" ] && command -v jq >/dev/null 2>&1; then - EXISTING_HOOK_COMMAND=$(jq -r '.hooks.SessionStart[0].hooks[0].command // ""' "$CLAUDE_SETTINGS" 2>/dev/null) - if [ "$EXISTING_HOOK_COMMAND" = "$EXPECTED_HOOK_COMMAND" ]; then - log_info "SessionStart hook already correctly configured, skipping" - else - if [ -n "$EXISTING_HOOK_COMMAND" ]; then - log_info "SessionStart hook points to '$EXISTING_HOOK_COMMAND'; updating to '$EXPECTED_HOOK_COMMAND'" - else - log_info "Adding SessionStart hook to Claude Code settings..." - fi - HOOK_ENTRY=$(jq -n --arg cmd "$EXPECTED_HOOK_COMMAND" '{"matcher":"","hooks":[{"type":"command","command":$cmd,"timeout":30}]}') - jq --argjson hook "[$HOOK_ENTRY]" '.hooks.SessionStart = $hook' "$CLAUDE_SETTINGS" > "${CLAUDE_SETTINGS}.tmp" \ - && mv "${CLAUDE_SETTINGS}.tmp" "$CLAUDE_SETTINGS" - log_success "SessionStart hook registered" - fi -elif [ ! -f "$CLAUDE_SETTINGS" ]; then - log_warning "Claude Code settings.json not found, skipping hook registration" -else - log_warning "jq not found, skipping hook registration (install jq and re-run)" -fi +merge_claude_settings "$CLAUDE_SETTINGS_TEMPLATE" "$CLAUDE_SETTINGS" "$EXPECTED_HOOK_COMMAND" # Deploy auto-memory symlinks (vault → Claude Code) # Memory lives in the knowledge vault, not in this repo (see ADR-007) diff --git a/setup-windows.ps1 b/setup-windows.ps1 index 3190220..efee2ef 100644 --- a/setup-windows.ps1 +++ b/setup-windows.ps1 @@ -54,6 +54,90 @@ function Ensure-Directory { } } +# Merge `ai/claude/settings.json` template into the deployed `~/.claude/settings.json` +# per the per-key policy in specs/SDD-002-settings-portability/proposal.md. Bootstrap +# when target missing. Preserves user customizations (Read paths, +# additionalDirectories, third-party hooks like claude-mem / GitGuardian) by only +# touching the keys declared as "ours" in the template. The template's +# __HOOK_COMMAND__ placeholder is replaced with the OS-specific hook command +# before any merge / write. +function Merge-ClaudeSettings { + [CmdletBinding()] + # "Settings" is the canonical Claude Code config-file name (settings.json); + # the function operates on the whole file, not one setting -- using + # `Setting` (singular) would be misleading. Plural noun warning suppressed. + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '')] + param( + [Parameter(Mandatory)][string]$TemplatePath, + [Parameter(Mandatory)][string]$TargetPath, + [Parameter(Mandatory)][string]$HookCommand + ) + + if (-not (Test-Path $TemplatePath)) { + Write-Warn "Claude settings template not found at $TemplatePath, skipping merge" + return + } + + # Read template, JSON-escape the hook command, substitute __HOOK_COMMAND__ + $escapedCommand = ($HookCommand -replace '\\', '\\') -replace '"', '\"' + $templateRaw = (Get-Content $TemplatePath -Raw) -replace '__HOOK_COMMAND__', $escapedCommand + try { + $template = $templateRaw | ConvertFrom-Json -AsHashtable + } catch { + Write-Warn "Claude settings template is not valid JSON after placeholder substitution: $_" + return + } + + # Bootstrap if target missing + if (-not (Test-Path $TargetPath)) { + Write-Info "Bootstrapping ~/.claude/settings.json from template (file did not exist)" + $template | ConvertTo-Json -Depth 10 | Set-Content $TargetPath -Encoding UTF8 + Write-Success "Claude settings.json bootstrapped from template" + return + } + + # Read existing target + try { + $existing = Get-Content $TargetPath -Raw | ConvertFrom-Json -AsHashtable + } catch { + Write-Warn "Claude settings.json at $TargetPath is not valid JSON, skipping merge: $_" + return + } + if ($null -eq $existing) { $existing = @{} } + + # Per-key merge policy (table in proposal.md) + if ($template.ContainsKey('model')) { $existing['model'] = $template['model'] } + if ($template.ContainsKey('effortLevel')) { $existing['effortLevel'] = $template['effortLevel'] } + + # permissions.allow: UNION (template + existing, deduped) + if ($template.ContainsKey('permissions') -and $template['permissions'].ContainsKey('allow')) { + if (-not $existing.ContainsKey('permissions')) { $existing['permissions'] = @{} } + if (-not $existing['permissions'].ContainsKey('allow')) { $existing['permissions']['allow'] = @() } + $merged = @(@($existing['permissions']['allow']) + @($template['permissions']['allow']) | Select-Object -Unique) + $existing['permissions']['allow'] = $merged + } + + # hooks.SessionStart: TEMPLATE wins (replace entire array). Other hook + # surfaces (PreToolUse, PostToolUse, Stop) are untouched -- third parties + # register there. + if ($template.ContainsKey('hooks') -and $template['hooks'].ContainsKey('SessionStart')) { + if (-not $existing.ContainsKey('hooks')) { $existing['hooks'] = @{} } + $existing['hooks']['SessionStart'] = $template['hooks']['SessionStart'] + } + + # enabledPlugins: object merge (template wins on conflict). User-added + # plugins beyond the 14 universal ones survive. + if ($template.ContainsKey('enabledPlugins')) { + if (-not $existing.ContainsKey('enabledPlugins')) { $existing['enabledPlugins'] = @{} } + foreach ($plugin in $template['enabledPlugins'].Keys) { + $existing['enabledPlugins'][$plugin] = $template['enabledPlugins'][$plugin] + } + } + + $existing | ConvertTo-Json -Depth 10 | Set-Content $TargetPath -Encoding UTF8 + Write-Success "Claude settings.json merged from template (user customizations preserved)" +} + # ============================================================================ # BANNER # ============================================================================ @@ -135,10 +219,12 @@ if ($wingetCmd) { Write-Info "Deploying Claude configuration..." -# Bulk copy all Claude config files +# Bulk copy all Claude config files EXCEPT settings.json (SDD-002: handled by +# Merge-ClaudeSettings below, which substitutes __HOOK_COMMAND__ and applies the +# per-key merge policy preserving user customizations). $claudeSource = "$DotfilesDir\ai\claude" if (Test-Path $claudeSource) { - Copy-Item "$claudeSource\*" "$ClaudeHome\" -Recurse -Force -ErrorAction SilentlyContinue + Copy-Item "$claudeSource\*" "$ClaudeHome\" -Recurse -Force -Exclude 'settings.json' -ErrorAction SilentlyContinue } # Force copy CLAUDE.md (Neural Hive Protocol) @@ -744,53 +830,19 @@ if (Test-Path $sensitiveSource) { # 7c. REGISTER SESSIONSTART HOOK # ============================================================================ -Write-Info "Registering Claude Code SessionStart hook..." +Write-Info "Applying Claude settings.json template + registering SessionStart hook..." +# SDD-002 (PR #51): single source of truth for the "dotfiles-owned" subset of +# settings.json lives at ai/claude/settings.json. The previous inline hashtable +# for the hook entry is gone -- Merge-ClaudeSettings reads the template, +# substitutes __HOOK_COMMAND__, and applies the per-key policy. Bootstraps a +# fresh settings.json if missing (closes the v1 doble-paso friction). $ClaudeSettings = "$ClaudeHome\settings.json" +$ClaudeSettingsTemplate = "$DotfilesDir\ai\claude\settings.json" $sessionStartCmd = "$ScriptsDir\claude-session-start.ps1" $expectedHookCommand = "pwsh -NoProfile -File `"$sessionStartCmd`"" -if (Test-Path $ClaudeSettings) { - try { - $settings = Get-Content $ClaudeSettings -Raw | ConvertFrom-Json - - $existingHookCommand = $null - if ($settings.hooks -and $settings.hooks.SessionStart -and $settings.hooks.SessionStart[0] -and $settings.hooks.SessionStart[0].hooks) { - $existingHookCommand = $settings.hooks.SessionStart[0].hooks[0].command - } - - if ($existingHookCommand -eq $expectedHookCommand) { - Write-Info "SessionStart hook already correctly configured, skipping" - } else { - if ($existingHookCommand) { - Write-Info "SessionStart hook points to '$existingHookCommand'; updating to '$expectedHookCommand'" - } - - $hookEntry = @{ - matcher = '' - hooks = @( - @{ - type = 'command' - command = $expectedHookCommand - timeout = 30 - } - ) - } - - if (-not $settings.hooks) { - $settings | Add-Member -NotePropertyName 'hooks' -NotePropertyValue @{} -Force - } - - $settings.hooks | Add-Member -NotePropertyName 'SessionStart' -NotePropertyValue @($hookEntry) -Force - $settings | ConvertTo-Json -Depth 10 | Set-Content $ClaudeSettings -Encoding UTF8 - Write-Success "SessionStart hook registered" - } - } catch { - Write-Warn "Failed to register SessionStart hook: $_" - } -} else { - Write-Warn "Claude Code settings.json not found, skipping hook registration" -} +Merge-ClaudeSettings -TemplatePath $ClaudeSettingsTemplate -TargetPath $ClaudeSettings -HookCommand $expectedHookCommand # ============================================================================ # 8. GITHUB COPILOT CLI diff --git a/specs/SDD-002-settings-portability/proposal.md b/specs/SDD-002-settings-portability/proposal.md new file mode 100644 index 0000000..ffb1dab --- /dev/null +++ b/specs/SDD-002-settings-portability/proposal.md @@ -0,0 +1,77 @@ +--- +id: "SDD-002-settings-portability" +type: spec +status: draft +created: "2026-05-18" +tags: [spec, proposal, sdd, settings, portability] +template_version: "1.0" +--- + +# SDD-002-settings-portability + +> Tier 3 of the 5-layer SDD enforcement stack started by SDD-001 (PR #49). Tracks a curated subset of `~/.claude/settings.json` in dotfiles as SSOT, refactors both setup scripts to read it instead of hardcoding hook entries, and bootstraps the file on fresh machines (closes the "run claude once, then re-run setup" doble-paso). + +## Why + +Today the hook registration logic for `~/.claude/settings.json` lives in TWO places: `setup-windows.ps1` lines 743-787 (PowerShell hashtable + ConvertTo-Json) and `setup-linux.sh` lines 626-649 (bash + jq invocation). Adding a new structural key requires editing both scripts in parallel -- exactly the duplication that caused the WIN-003 + BUG-002 + BUG-003 class of bugs. There's also no SSOT for "what does dotfiles own in settings.json" -- it's implicit in the imperative code of each script. + +Second pain: on a fresh machine without `~/.claude/settings.json`, both scripts log `"settings.json not found, skipping"` and leave the hook unregistered. User has to run `claude` once (creates the default), then re-run setup. Two-step bootstrap when one would suffice. + +## What + +Three observable behavior changes after this PR: + +1. **A canonical template `ai/claude/settings.json`** declares the curated "dotfiles-owned" subset: `model`, `effortLevel`, `hooks.SessionStart` (with a `__HOOK_COMMAND__` placeholder substituted at install time), `enabledPlugins` (the 14 universal plugins), `permissions.allow` (only the 3 portable MCP entries: `mcp__hive__vault_query`, `mcp__hive__vault_write`, `mcp__sequential-thinking__sequentialthinking`). +2. **Both setup scripts refactor** their hook-registration blocks to read the template and apply a **per-key merge policy** (see Risks section for the table). Net effect: the inline PowerShell hashtable + bash heredoc go away, replaced by a small read-template + apply-merge function each side. Adding new structural keys becomes a template edit, not a code change. +3. **Bootstrap on fresh machine**: if `~/.claude/settings.json` does not exist, setup creates it from the template (with `__HOOK_COMMAND__` substituted). Eliminates the doble-paso for new installs. + +## Out of scope + +- **Machine-specific keys** stay user-owned, not tracked in template: `permissions.allow` entries with absolute paths (e.g., `Read(//c/Users/mlorente/...)`); `permissions.additionalDirectories`; per-machine `enabledPlugins` additions beyond the 14 universal ones; per-machine hook entries from third-party tools (claude-mem, GitGuardian). +- **No platform variable substitution beyond `__HOOK_COMMAND__`.** Users with cross-machine differences in those keys handle it manually (this is acceptable because those are inherently per-machine state). +- **No CI gate for template drift** (e.g., asserting the template stays sorted, etc.). Pure template hygiene; add later if it becomes a pain point. +- **No migration of pre-existing user settings.json content into the template repo.** The template captures the canonical "ours" subset; user's existing customizations stay user-owned via the merge policy. +- **No platform-conditional template content beyond the placeholder.** Template content is identical across OSes except for what the install script substitutes for `__HOOK_COMMAND__`. + +## Risks / open questions + +**Risk: Per-key merge policy must be exact or third-party hooks get clobbered.** Below is the policy table; bats tests lock it in. + +| Key | Policy | Rationale | +|-----|--------|-----------| +| `model` | TEMPLATE wins (overwrite) | Universal user pref | +| `effortLevel` | TEMPLATE wins (overwrite) | Universal user pref | +| `permissions.allow` | UNION (template entries + user entries, dedup) | Preserve user's machine-specific Read paths AND extra MCP entries; add our 3 portable MCP entries | +| `permissions.additionalDirectories` | USER preserved (not in template, no touch) | Machine-specific paths | +| `permissions.*` (other subkeys) | USER preserved (not in template, no touch) | Future-proof | +| `hooks.SessionStart` | TEMPLATE wins (replace entire array) | We own this hook; previous WIN-003 self-heal logic preserved by always rewriting | +| `hooks.PreToolUse` / `PostToolUse` / `Stop` | USER preserved (not in template, no touch) | Third-party tools (claude-mem heal, GitGuardian) register hooks here | +| `enabledPlugins` | Object merge (template + user, template wins on conflict) | Universal plugins always enabled; user can add more; if user disables a universal plugin it gets re-enabled (acceptable trade -- template wins) | +| Other top-level keys | USER preserved (not in template, no touch) | Future-proof | + +**Open question (resolved): what about `enabledPlugins[X] = false`?** If a user has explicitly disabled a universal plugin in their settings (`"code-review@claude-plugins-official": false`), the merge policy currently overrides it to `true`. Decision: accept this as the trade -- the template's universal-plugins-enabled stance is intentional. If a user really doesn't want a plugin, they remove it from the template via PR (cross-machine effect). Documented as known behavior in `verification.md` Decisions section. + +**Risk: bootstrap creates a settings.json the user didn't ask for.** Mitigation: the install script logs `"Bootstrapping ~/.claude/settings.json from template (file did not exist)"` so the user sees what happened. The template is identical to what would have been registered piecemeal before; just creates it all in one shot. + +**Risk: __HOOK_COMMAND__ substitution requires the placeholder to be exact.** Mitigation: bats asserts the template contains the literal `__HOOK_COMMAND__` string; setup scripts use literal substitution (PowerShell `.Replace()`, jq with `--arg`). + +## Acceptance criteria + +- [ ] `ai/claude/settings.json` exists at repo root with frontmatter-free JSON (Claude Code requires plain JSON) +- [ ] Template contains: `model: "opus"`, `effortLevel: "xhigh"`, `hooks.SessionStart` (with `__HOOK_COMMAND__` placeholder), `enabledPlugins` (the 14 plugins), `permissions.allow` (the 3 MCP entries, no absolute paths) +- [ ] `setup-windows.ps1` hook block reads `ai\claude\settings.json` and applies per-key merge; the inline PowerShell hashtable for the hook entry is gone +- [ ] `setup-linux.sh` hook block reads `ai/claude/settings.json` and applies per-key merge via jq; the inline `HOOK_ENTRY=$(jq -n ...)` is gone +- [ ] Bootstrap: when `~/.claude/settings.json` does not exist, setup creates it from the template; log line `"Bootstrapping ~/.claude/settings.json from template"` visible +- [ ] Merge preservation: when `~/.claude/settings.json` has user customizations (test fixture: extra `permissions.allow` Read entry + `additionalDirectories` + a third-party `hooks.PreToolUse` entry), running setup preserves all of them +- [ ] Merge overwrite: when `~/.claude/settings.json` has `model: "sonnet"` (template says `"opus"`), running setup overwrites to `"opus"` +- [ ] Cross-OS parity: bats asserts both setup scripts call out to read `ai/claude/settings.json` and use the per-key merge pattern +- [ ] PSScriptAnalyzer clean on modified `setup-windows.ps1`; bash `-n` + shellcheck clean on `setup-linux.sh` +- [ ] Empirical smoke on this machine: re-run `setup-windows.ps1`, observe settings.json before/after; user customizations (the existing 6 `permissions.allow` entries including Read paths, `additionalDirectories`) survive + +## References + +- Vault: `10_projects/dotfiles/11-tasks.md` "SDD-002-settings-portability" backlog entry +- Pattern: `00_meta/patterns/pattern-spec-driven-development.md` +- Parent: SDD-001 (PR #49) -- established the discipline gate this spec is a continuation of +- Sibling: SDD-003 (next) -- CI spec-gate + PR template, Tier 4+5 +- Related: WIN-003 (PR #21) -- the original SessionStart hook self-heal whose logic is preserved by the TEMPLATE-wins policy on `hooks.SessionStart` diff --git a/specs/SDD-002-settings-portability/tasks.md b/specs/SDD-002-settings-portability/tasks.md new file mode 100644 index 0000000..4d892c6 --- /dev/null +++ b/specs/SDD-002-settings-portability/tasks.md @@ -0,0 +1,61 @@ +--- +tags: [spec, tasks] +created: "2026-05-18" +--- + +# Tasks - SDD-002-settings-portability + +> TDD order. One atomic PR. Per-key merge policy is the core invariant -- gets bats coverage first. + +## Setup + +- [x] Vault entry exists in `10_projects/dotfiles/11-tasks.md` (added 2026-05-18, auto-synced) +- [x] Branch created from main: `feat/SDD-002-settings-portability` +- [x] Spec scaffolded via `scripts/init-spec.ps1 SDD-002-settings-portability` (vault gate passed) +- [x] `proposal.md` complete; per-key merge policy table is testable +- [x] Open questions resolved (the `enabledPlugins[X] = false` decision documented in proposal -- accept template wins) + +## Implementation (TDD) + +### Template file + +- [ ] Write failing bats `tests/claude-settings-template.bats` (new file): asserts template exists, valid JSON via jq, contains required top-level keys (`model`, `effortLevel`, `hooks`, `enabledPlugins`, `permissions`), contains literal `__HOOK_COMMAND__` placeholder, `enabledPlugins` has all 14 universal plugins, `permissions.allow` has exactly the 3 MCP entries and NOTHING starting with `Read(` +- [ ] Create `ai/claude/settings.json` with curated content (plain JSON, no Markdown frontmatter -- Claude Code parses it raw) +- [ ] Verify bats green (grep simulation locally) + +### Per-key merge: setup-windows.ps1 + +- [ ] Write failing bats parity asserts in `tests/setup-windows.bats`: script reads `ai\claude\settings.json` template path; no inline `$hookEntry = @{ ... }` hashtable for the hook; references the `__HOOK_COMMAND__` substitution; logs the bootstrap message when target missing +- [ ] Add helper function `Merge-ClaudeSettings` to setup-windows.ps1: parameters = template path, target path, hook command. Reads both as hashtables (`ConvertFrom-Json -AsHashtable`), applies per-key policy from proposal, writes target with `ConvertTo-Json -Depth 10`. Bootstrap = create from template alone if target missing +- [ ] Replace the existing hook-registration block (lines 743-787) with: read template -> substitute __HOOK_COMMAND__ -> call Merge-ClaudeSettings -> log success +- [ ] PSScriptAnalyzer clean on `setup-windows.ps1` (Error+Warning severity, .PSScriptAnalyzerSettings.psd1) + +### Per-key merge: setup-linux.sh + +- [ ] Write failing bats parity asserts in `tests/setup-linux.bats` (new file if needed, or extend `tests/setup-windows.bats`): script reads `ai/claude/settings.json`; no inline `HOOK_ENTRY=$(jq -n ...)` heredoc; uses `--arg cmd` for __HOOK_COMMAND__ substitution +- [ ] Add shell function `merge_claude_settings` to setup-linux.sh that applies per-key policy via a single jq invocation. Bootstrap branch = write template directly when target missing +- [ ] Replace the existing hook-registration block (lines 626-649) with the new merge function call +- [ ] `bash -n` syntax clean; shellcheck severity=error clean + +### Cross-OS parity locks + +- [ ] Bats parity assert: both scripts reference path to `ai/claude/settings.json` (OS-specific separator) +- [ ] Bats parity assert: both scripts mention `__HOOK_COMMAND__` substitution +- [ ] Bats parity assert: both scripts log the bootstrap message + +### Empirical smoke (Windows -- this machine) + +- [ ] Capture pre-state: `Copy-Item ~/.claude/settings.json ~/.claude/settings.json.pre-sdd002` +- [ ] Run `pwsh -NoProfile -ExecutionPolicy Bypass -File setup-windows.ps1` (filtered to `Select-String 'settings\.json|hooks|Bootstrapping|permissions'`) +- [ ] Diff settings.json before/after via PowerShell. Required outcomes: (a) `model = "opus"` still set; (b) all pre-existing `permissions.allow` entries preserved (3 MCPs + 2 Reads + 1 work path = 6 entries, plus template adds the 3 MCPs deduped); (c) `additionalDirectories` still has the 1 entry untouched; (d) `hooks.SessionStart` still has the correct hook command (rewritten to match template's substituted value); (e) all 14 `enabledPlugins` still `true` +- [ ] Bootstrap smoke: `Move-Item ~/.claude/settings.json ~/.claude/settings.json.bak`, re-run setup, confirm new settings.json created from template; restore via `Move-Item -Force` +- [ ] Cross-OS smoke deferred to bats CI (no Linux access this session) + +## Closing + +- [ ] Every acceptance criterion from `proposal.md` covered by at least one test +- [ ] PSScriptAnalyzer + bash -n + shellcheck clean on modified files +- [ ] No unrelated changes in the diff (no scope creep into Tier 4/5 or other SDD work) +- [ ] `verification.md` filled with: smoke command outputs (.ps1 only on this machine; Linux relies on CI bats), bats simulation results, before/after settings.json snippets, decisions made during implementation +- [ ] PR opened referencing this spec folder; PR body notes "Tier 3 of SDD-001 stack; Tier 4+5 in SDD-003" +- [ ] Spec status moved `draft` -> `implementing` when first code commit lands; -> `verifying` when smoke green; -> `archived` (move to `specs/archive/`) only after PR merge diff --git a/specs/SDD-002-settings-portability/verification.md b/specs/SDD-002-settings-portability/verification.md new file mode 100644 index 0000000..40b55f8 --- /dev/null +++ b/specs/SDD-002-settings-portability/verification.md @@ -0,0 +1,51 @@ +--- +tags: [spec, verification] +created: "2026-05-18" +--- + +# Verification - SDD-002-settings-portability + +## Evidence + +Each acceptance criterion from `proposal.md` mapped to its proof. All empirically verified on the admin Windows machine 2026-05-18 same session. + +- [x] **`ai/claude/settings.json` exists as plain JSON** → file created; `jq empty` passes; bats `tests/claude-settings-template.bats` "template is valid JSON". +- [x] **Template contains the agreed keys** → `model: "opus"`, `effortLevel: "xhigh"`, `permissions.allow` (3 MCP entries only, no `Read(` paths), `hooks.SessionStart` (with `__HOOK_COMMAND__` placeholder), `enabledPlugins` (14 universal plugins). Bats asserts: 18 in template-only file + 11 parity asserts in `tests/setup-windows.bats`. +- [x] **setup-windows.ps1 refactored** → `Merge-ClaudeSettings` function defined (lines 57-129); hook block replaced with single function call; inline `$hookEntry = @{ ... }` hashtable is GONE. Bats: "SDD-002: setup-windows.ps1 calls Merge-ClaudeSettings (not inline hashtable)". +- [x] **setup-linux.sh refactored** → `merge_claude_settings()` shell function defined (lines 633-694); hook block replaced with single function call; inline `HOOK_ENTRY=$(jq -n ...)` is GONE. Bats: "SDD-002: setup-linux.sh calls merge_claude_settings (not inline HOOK_ENTRY)". +- [x] **Bootstrap on fresh machine** → empirically verified: `Move-Item ~/.claude/settings.json ~/.claude/settings.json.bootstrap-test`; `pwsh -NoProfile -ExecutionPolicy Bypass -File setup-windows.ps1`; output included literal `[INFO] Bootstrapping ~/.claude/settings.json from template (file did not exist)` + `[SUCCESS] Claude settings.json bootstrapped from template`. New file contained `model=opus`, `effortLevel=xhigh`, `permissions.allow=3` MCP entries, `hooks.SessionStart` with correct substituted command, 14 plugins. Original restored from .bootstrap-test. +- [x] **Merge preservation (user customizations survive)** → empirical smoke: pre-state had `permissions.allow` count = 6 (3 MCPs + 3 Read paths), `additionalDirectories` count = 1, `hooks.SessionStart` count = 1, 14 plugins. Post-merge state: `permissions.allow` count = 6 (UNION dedup correctly kept all 6), `additionalDirectories` count = 1 (untouched), `hooks.SessionStart` count = 1 (replaced with template's, command substituted to `pwsh -NoProfile -File "C:\Users\mlorente\scripts\claude-session-start.ps1"`), 14 plugins. +- [x] **Merge overwrite (template wins on `model`)** → not empirically tested in same session (model was already `opus` matching template). Bats parity asserts confirm the logic path exists. Future drift would catch via the per-key merge code path. +- [x] **Cross-OS parity** → 11 bats asserts in `tests/setup-windows.bats` lock the symmetry: both scripts reference `ai/claude/settings.json` template path, both define their merge functions, both log the bootstrap message, both substitute `__HOOK_COMMAND__`. +- [x] **PSScriptAnalyzer clean** → 0 warnings on `setup-windows.ps1`. The expected `PSUseSingularNouns` warning for `Merge-ClaudeSettings` is suppressed in-line with explicit rationale ("Settings" is the canonical config-file name; singular `Setting` would be misleading). +- [x] **bash -n + shellcheck** → `bash -n setup-linux.sh` clean. shellcheck not installed locally (Windows session) -- CI ubuntu-latest will validate. + +## Test status + +- **Template tests** (`tests/claude-settings-template.bats`, 18 asserts): simulated locally via jq commands; 100% green. +- **Parity tests** (`tests/setup-windows.bats` SDD-002 section, 11 asserts): simulated locally via grep; 100% green. +- **Empirical Windows smoke** (this machine): merge preserves all user customizations + bootstrap creates from template. Both flows verified end-to-end. +- **Empirical Linux smoke**: deferred to CI (no Linux access this session). The bash `merge_claude_settings` mirrors the PowerShell logic line-for-line; jq invocation is the standard pattern already used in the previous hook-registration block. +- **No regressions**: existing setup-windows.bats, aliases.bats, agents-md.bats, hooks.bats untouched. The legacy hook-registration assertions in setup-windows.bats are still green because the new function still produces the expected behavior (same hook entry shape) -- they just don't constrain HOW it's done anymore. + +## Decisions made during implementation + +- **Placeholder approach `__HOOK_COMMAND__` (Option B) chosen over per-OS templates (Option A) or hook-stays-in-script (Option C).** Keeps the template as the canonical "ours" representation while the install script handles the one platform-specific bit. Single template file + tiny substitution > two near-duplicate templates with drift risk > template that doesn't cover the hook (incoherent SSOT). +- **Per-key merge policy explicit table** (in proposal.md) over generic deep-merge. Generic deep-merge would have ambiguity on arrays (replace vs union) and could clobber third-party hooks (`PreToolUse`, `PostToolUse`, `Stop` are off-limits). The table makes the policy auditable and the implementation a direct mapping. +- **Bulk-copy exclusion of `settings.json`** in both setup scripts (`-Exclude 'settings.json'` for Copy-Item in PowerShell; explicit loop with `[ name = "settings.json" ] && continue` in bash). The previous bulk-copy of `ai/claude/*` to `~/.claude/` would have wiped user customizations AND deployed the template verbatim (with `__HOOK_COMMAND__` literal). Required for SDD-002 to work; would be a latent bug otherwise. +- **PSScriptAnalyzer suppression for plural noun** instead of renaming `Merge-ClaudeSettings` to `Merge-ClaudeSetting`. Renaming would be misleading ("Settings" is the canonical Claude Code config-file name; the function operates on the whole file, not one setting). Suppression with explicit comment is the documented PowerShell way. +- **PowerShell `-AsHashtable` for ConvertFrom-Json** instead of default PSCustomObject. Hashtables support `.ContainsKey()` and key-by-key mutation cleanly; PSCustomObject requires `Add-Member` ceremony for new keys. Hashtables round-trip correctly via `ConvertTo-Json -Depth 10`. + +## Promotion candidates + +- [x] **Lesson** for `90-lessons.md`? **YES** — capture: "Bulk-copy operations from a tracked directory tree silently collide with per-file deploy logic if both touch the same target. The collision is invisible until the per-file logic introduces a placeholder or merge invariant that the bulk-copy can't honor. Add an explicit exclusion when introducing per-file deploy semantics for a file previously bulk-copied." Useful pattern (recurs in skills deployment, MCP config, etc.). +- [ ] **ADR** for `30-architecture/`? No -- this PR is the operational form of an already-accepted decision (ADR-009 making AGENTS.md the SSOT; SDD-001 instituting the discipline gate). No new architectural decision. +- [ ] **New pattern** for `00_meta/patterns/`? Not yet -- the "per-key merge policy with explicit policy table" is interesting but appears only here so far. If it recurs (e.g., for mcp-servers.json deploy, opencode config deploy), promote then. + +## Archive checklist + +- [ ] `proposal.md` frontmatter set to `status: archived` +- [ ] Folder moved: `specs/SDD-002-settings-portability/` → `specs/archive/SDD-002-settings-portability/` +- [ ] Backlog entry in vault `10_projects/dotfiles/11-tasks.md` ticked with PR link +- [ ] Lesson promotion executed (Lessons section above flagged YES) +- [ ] SDD-003 (CI spec-gate + PR template) sibling spec opened diff --git a/tests/claude-settings-template.bats b/tests/claude-settings-template.bats new file mode 100644 index 0000000..7fd8665 --- /dev/null +++ b/tests/claude-settings-template.bats @@ -0,0 +1,106 @@ +#!/usr/bin/env bats +# Tests for ai/claude/settings.json template (SDD-002) +# The template is the SSOT for the "dotfiles-owned" subset of ~/.claude/settings.json. +# Per-key merge policy is documented in specs/SDD-002-settings-portability/proposal.md. + +setup() { + export DOTFILES_DIR="$BATS_TEST_DIRNAME/.." + export SETTINGS_TEMPLATE="$DOTFILES_DIR/ai/claude/settings.json" +} + +@test "template file exists" { + [[ -f "$SETTINGS_TEMPLATE" ]] +} + +@test "template is valid JSON (jq parses without error)" { + jq empty "$SETTINGS_TEMPLATE" +} + +# --- Top-level keys (the "ours" subset) --- + +@test "template has model = opus" { + [[ "$(jq -r '.model' "$SETTINGS_TEMPLATE")" == "opus" ]] +} + +@test "template has effortLevel = xhigh" { + [[ "$(jq -r '.effortLevel' "$SETTINGS_TEMPLATE")" == "xhigh" ]] +} + +# --- hooks.SessionStart with placeholder --- + +@test "template hooks.SessionStart contains __HOOK_COMMAND__ placeholder" { + [[ "$(jq -r '.hooks.SessionStart[0].hooks[0].command' "$SETTINGS_TEMPLATE")" == "__HOOK_COMMAND__" ]] +} + +@test "template hooks.SessionStart timeout = 30" { + [[ "$(jq -r '.hooks.SessionStart[0].hooks[0].timeout' "$SETTINGS_TEMPLATE")" == "30" ]] +} + +@test "template hooks.SessionStart[0].hooks[0].type = command" { + [[ "$(jq -r '.hooks.SessionStart[0].hooks[0].type' "$SETTINGS_TEMPLATE")" == "command" ]] +} + +# --- permissions.allow: only MCP entries, no Read paths --- + +@test "template permissions.allow has exactly 3 entries" { + [[ "$(jq '.permissions.allow | length' "$SETTINGS_TEMPLATE")" == "3" ]] +} + +@test "template permissions.allow has all 3 expected MCP entries" { + jq -e '.permissions.allow | index("mcp__hive__vault_query")' "$SETTINGS_TEMPLATE" + jq -e '.permissions.allow | index("mcp__hive__vault_write")' "$SETTINGS_TEMPLATE" + jq -e '.permissions.allow | index("mcp__sequential-thinking__sequentialthinking")' "$SETTINGS_TEMPLATE" +} + +@test "template permissions.allow contains no Read entries (user-owned, machine-specific)" { + # Negative assertion: no entry starts with "Read(" -- those are absolute paths + # tied to a specific machine and stay user-owned via the merge policy. + run jq -e '.permissions.allow | map(startswith("Read(")) | any' "$SETTINGS_TEMPLATE" + [[ "$status" -ne 0 ]] +} + +@test "template permissions.allow entries all start with mcp__ prefix" { + jq -e '.permissions.allow | map(startswith("mcp__")) | all' "$SETTINGS_TEMPLATE" +} + +@test "template does NOT define permissions.additionalDirectories (user-owned)" { + run jq -e '.permissions.additionalDirectories' "$SETTINGS_TEMPLATE" + [[ "$status" -ne 0 ]] +} + +# --- enabledPlugins: 14 universal plugins, all true --- + +@test "template enabledPlugins has exactly 14 universal plugins" { + [[ "$(jq '.enabledPlugins | length' "$SETTINGS_TEMPLATE")" == "14" ]] +} + +@test "template enabledPlugins values all set to true" { + jq -e '.enabledPlugins | to_entries | map(.value == true) | all' "$SETTINGS_TEMPLATE" +} + +@test "template enabledPlugins includes core plugins from the existing user setup" { + # Sample the 5 most-used: code-review, commit-commands, github, pr-review-toolkit, claude-md-management. + # Asserting a sample catches accidental removal while keeping the test list maintainable. + jq -e '.enabledPlugins["code-review@claude-plugins-official"]' "$SETTINGS_TEMPLATE" + jq -e '.enabledPlugins["commit-commands@claude-plugins-official"]' "$SETTINGS_TEMPLATE" + jq -e '.enabledPlugins["github@claude-plugins-official"]' "$SETTINGS_TEMPLATE" + jq -e '.enabledPlugins["pr-review-toolkit@claude-plugins-official"]' "$SETTINGS_TEMPLATE" + jq -e '.enabledPlugins["claude-md-management@claude-plugins-official"]' "$SETTINGS_TEMPLATE" +} + +# --- Negative assertions: user/machine-specific keys MUST NOT be in template --- + +@test "template does NOT define hooks.PreToolUse (third-party tool surface)" { + run jq -e '.hooks.PreToolUse' "$SETTINGS_TEMPLATE" + [[ "$status" -ne 0 ]] +} + +@test "template does NOT define hooks.PostToolUse (third-party tool surface)" { + run jq -e '.hooks.PostToolUse' "$SETTINGS_TEMPLATE" + [[ "$status" -ne 0 ]] +} + +@test "template does NOT define hooks.Stop (third-party tool surface)" { + run jq -e '.hooks.Stop' "$SETTINGS_TEMPLATE" + [[ "$status" -ne 0 ]] +} diff --git a/tests/setup-linux.bats b/tests/setup-linux.bats index 93f1185..520b8c2 100644 --- a/tests/setup-linux.bats +++ b/tests/setup-linux.bats @@ -116,11 +116,14 @@ setup() { grep -qE 'EXPECTED_HOOK_COMMAND="\$HOME/\.dotfiles/scripts/claude-session-start\.sh"' "$DOTFILES_DIR/setup-linux.sh" } -# Hook registration must reconcile (compare and rewrite) rather than skip when -# any SessionStart entry already exists. Mirrors the Windows guard. +# Hook registration must self-heal -- never trust "an entry exists" to mean +# "the entry is correct". Post-SDD-002 (PR #51): merge_claude_settings ALWAYS +# rewrites .hooks.SessionStart from the template (with __HOOK_COMMAND__ +# substituted), which is a stronger guarantee than the previous compare-then- +# rewrite. Mirrors the Windows guard. @test "setup-linux.sh SessionStart hook self-heals on path drift" { - grep -q 'EXISTING_HOOK_COMMAND' "$DOTFILES_DIR/setup-linux.sh" - grep -qE '\[ "\$EXISTING_HOOK_COMMAND" = "\$EXPECTED_HOOK_COMMAND" \]' "$DOTFILES_DIR/setup-linux.sh" + grep -qF 'EXPECTED_HOOK_COMMAND' "$DOTFILES_DIR/setup-linux.sh" + grep -qF 'merge_claude_settings "$CLAUDE_SETTINGS_TEMPLATE"' "$DOTFILES_DIR/setup-linux.sh" } # --- MCP server registration (SSOT + idempotence) --- diff --git a/tests/setup-windows.bats b/tests/setup-windows.bats index aa80380..354670c 100644 --- a/tests/setup-windows.bats +++ b/tests/setup-windows.bats @@ -122,11 +122,14 @@ setup() { ! grep -Eq '\$sessionStartCmd\s*=\s*"\$DotfilesDest' "$PS1_SCRIPT" } -# Hook registration must reconcile (compare and rewrite) rather than skip when -# any SessionStart entry already exists; skip-if-exists makes wrong paths sticky. +# Hook registration must self-heal -- never trust "an entry exists" to mean +# "the entry is correct". Post-SDD-002 (PR #51): Merge-ClaudeSettings ALWAYS +# rewrites hooks.SessionStart from the template (with __HOOK_COMMAND__ +# substituted), which is a stronger guarantee than the previous compare-then- +# rewrite -- self-heal runs unconditionally on every setup invocation. @test "setup-windows.ps1 SessionStart hook self-heals on path drift" { - grep -q '\$expectedHookCommand' "$PS1_SCRIPT" - grep -Eq '\$existingHookCommand\s+-eq\s+\$expectedHookCommand' "$PS1_SCRIPT" + grep -qF '$expectedHookCommand' "$PS1_SCRIPT" + grep -qF 'Merge-ClaudeSettings -TemplatePath' "$PS1_SCRIPT" } # --- Scheduled task self-heal (same class as #20) --- @@ -271,6 +274,59 @@ setup() { ! grep -qF "github/gh-copilot" "$PS1_SCRIPT" } +# --- SDD-002: settings.json template merge --- +# Both setup scripts read ai/claude/settings.json as SSOT for the dotfiles-owned +# subset of ~/.claude/settings.json. Per-key merge policy documented in +# specs/SDD-002-settings-portability/proposal.md. + +@test "SDD-002: setup-windows.ps1 defines Merge-ClaudeSettings function" { + grep -qE '^function Merge-ClaudeSettings' "$PS1_SCRIPT" +} + +@test "SDD-002: setup-windows.ps1 calls Merge-ClaudeSettings (not inline hashtable)" { + grep -qF 'Merge-ClaudeSettings -TemplatePath' "$PS1_SCRIPT" + # The legacy inline hook hashtable must be gone + ! grep -qE '\$hookEntry\s*=\s*@\{' "$PS1_SCRIPT" +} + +@test "SDD-002: setup-windows.ps1 references the template path ai\\claude\\settings.json" { + grep -qF 'ai\claude\settings.json' "$PS1_SCRIPT" +} + +@test "SDD-002: setup-windows.ps1 bulk copy of ai/claude/* excludes settings.json" { + # -- separator before pattern starting with dash so grep does not parse it + # as a flag (same fix pattern as the BUG-002 CORE PRINCIPLE assert). + grep -qF -- "-Exclude 'settings.json'" "$PS1_SCRIPT" +} + +@test "SDD-002: setup-linux.sh defines merge_claude_settings function" { + grep -qE '^merge_claude_settings\(\)' "$DOTFILES_DIR/setup-linux.sh" +} + +@test "SDD-002: setup-linux.sh calls merge_claude_settings (not inline HOOK_ENTRY)" { + grep -qF 'merge_claude_settings "$CLAUDE_SETTINGS_TEMPLATE"' "$DOTFILES_DIR/setup-linux.sh" + # The legacy inline HOOK_ENTRY jq -n heredoc must be gone + ! grep -qF 'HOOK_ENTRY=$(jq -n' "$DOTFILES_DIR/setup-linux.sh" +} + +@test "SDD-002: setup-linux.sh references the template path ai/claude/settings.json" { + grep -qF 'ai/claude/settings.json' "$DOTFILES_DIR/setup-linux.sh" +} + +@test "SDD-002: setup-linux.sh bulk copy of ai/claude/* skips settings.json" { + grep -qF "= \"settings.json\" ] && continue" "$DOTFILES_DIR/setup-linux.sh" +} + +@test "SDD-002: parity -- both scripts log the bootstrap message" { + grep -qF "Bootstrapping ~/.claude/settings.json from template" "$PS1_SCRIPT" + grep -qF "Bootstrapping ~/.claude/settings.json from template" "$DOTFILES_DIR/setup-linux.sh" +} + +@test "SDD-002: parity -- both scripts substitute __HOOK_COMMAND__ placeholder" { + grep -qF '__HOOK_COMMAND__' "$PS1_SCRIPT" + grep -qF 'SessionStart[0].hooks[0].command) = $cmd' "$DOTFILES_DIR/setup-linux.sh" +} + # --- PSScriptAnalyzer --- @test "setup-windows.ps1 passes PSScriptAnalyzer (if pwsh available)" {