From 7d5270f74e0d1ccb86fea12752d3a8311eb9b062 Mon Sep 17 00:00:00 2001 From: bob Date: Sun, 24 May 2026 10:45:19 -0400 Subject: [PATCH] fix(schema-hygiene): retire //-stub pattern + drop unvalidatable skillOverrides MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dogfood from upgrading an adjacent project to the latest configurator version surfaced two real Claude Code settings-validator complaints: 1. `templates/token-efficiency/settings-patch.tier-pro.json` shipped `"skillOverrides": "name-only"` (string form), but the current Claude Code schema requires the per-skill object map. The string form was based on a comment in the patch file that claimed a global "apply to all skills" form existed; verification against the official doc (code.claude.com/docs/en/settings) shows it never did. Also: the setting explicitly doesn't apply to plugin skills, further narrowing its usefulness as a tier-wide default. 2. The `// foo` "commented opt-in stub" pattern propagated literal top-level keys into the user's `.claude/settings.json`. PR #56's `_is_doc_label` filter intentionally kept `// foo` stubs (stripping only bare `//` and `//`) so users could uncomment them. Empirically, those propagated keys trigger schema-validator complaints in the user's editor — the intent was wrong. A third bug was found during verification: `compute_merged_settings` applies the doc-label filter to file-based patches but bypasses inline `extraSettings` (ui module). So the ui module's bare `"//"` doc-labels were also leaking to user settings.json. Fixes: - `_is_doc_label` now strips ALL `//`-prefixed keys. - New `_strip_doc_labels()` recursively scrubs merged settings — catches nested cases (e.g., `statusLine.// hideVimModeIndicator`) the shallow per-merge filters miss. - `extraSettings` merge path now applies the filter. - `skillOverrides: "name-only"` deleted from tier-pro (replaced with a docstring explaining why no default is shipped). - All `// foo` stubs deleted from 4 source patch files + the ui module's inline `extraSettings`. - Opt-in discovery moved to `templates/core/dot-claude/settings.local.json.example` — the `.example` suffix means Claude Code doesn't parse it directly, so `// foo` stubs there are safe. - New `check_settings_validates()` runtime preflight emits a `[ SETTINGS WARNINGS ]` block when violations are detected. - New static `--check` step asserts no patch file ships `skillOverrides` as a non-object (regression guard — fires on the current bug when first run). New tests under `test/schema-hygiene/`: - All 5 personas render settings.json with zero `//`-prefixed keys - No settings-patch ships `skillOverrides` as a non-object - `check_settings_validates` catches 4 violation classes + clean case - `_strip_doc_labels` recursively strips at all depths Compat: existing users get the cleanup on next `cc-configure --retrofit`. Deep-merge preserves their customizations; the new strip removes leaks. Co-Authored-By: Claude Opus 4.7 --- CHANGELOG.md | 1 + config_schema.py | 15 ++- configure.py | 125 ++++++++++++++++-- .../dot-claude/settings.local.json.example | 55 +++++++- templates/git-workflow/settings-patch.json | 10 +- templates/multi-agent/settings-patch.json | 9 +- templates/safety/settings-patch.json | 26 +--- .../settings-patch.tier-pro.json | 3 +- .../test-no-doc-labels-rendered.sh | 38 ++++++ .../test-preflight-detects-violations.sh | 38 ++++++ .../test-skill-overrides-shape.sh | 33 +++++ .../test-strip-doc-labels-recursive.sh | 42 ++++++ 12 files changed, 332 insertions(+), 63 deletions(-) create mode 100755 test/schema-hygiene/test-no-doc-labels-rendered.sh create mode 100755 test/schema-hygiene/test-preflight-detects-violations.sh create mode 100755 test/schema-hygiene/test-skill-overrides-shape.sh create mode 100755 test/schema-hygiene/test-strip-doc-labels-recursive.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b1f235..758308e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ All notable changes to this project. Format: [Keep a Changelog](https://keepacha ## Unreleased +- **fix(schema-hygiene): retire //-stub pattern + drop unvalidatable skillOverrides default + add settings preflight.** Dogfood from upgrading an adjacent project surfaced two real validator-rejection bugs introduced by PRs #17, #18, #56, and #57: (1) `templates/token-efficiency/settings-patch.tier-pro.json` shipped `"skillOverrides": "name-only"` (string form), but the current Claude Code schema requires the per-skill object map (`{"skill-name": "name-only"}`) and rejects the string — `name-only` was a legacy/imagined "apply to all skills" form that the official doc never sanctioned; further, per code.claude.com/docs/en/settings the setting doesn't apply to plugin skills at all, narrowing its usefulness as a tier-wide default; (2) the `// foo` "commented opt-in stub" pattern (`// sandbox`, `// env`, `// prUrlTemplate`, `// worktree`, `// subagentStatusLine`, `// hideVimModeIndicator`) propagated as literal top-level keys into the user's `.claude/settings.json`, where Claude Code's editor schema validator flags them as unknown properties. The design intent (PR #56's `_is_doc_label` filter) was to keep `// foo` stubs and strip only bare `//` / `//N` doc labels; the dogfood proves the intent was wrong. **Fixes:** `_is_doc_label` now strips ALL `//`-prefixed keys; new `_strip_doc_labels` recursively scrubs the merged settings before render (catches nested cases like `statusLine.// hideVimModeIndicator` that the shallow per-merge filter missed); `extraSettings` merge path now applies the filter (was previously bypassed — UI module's bare `"//"` was leaking too); `skillOverrides: "name-only"` deleted from tier-pro (replaced with a docstring explaining why no default ships); all `// foo` stubs deleted from source patch files (`safety/settings-patch.json`, `multi-agent/settings-patch.json`, `git-workflow/settings-patch.json`) and from the ui module's inline `extraSettings`; opt-in discovery moved to `templates/core/dot-claude/settings.local.json.example` (the `.example` suffix means Claude Code doesn't parse it directly, so `// foo` stubs there are safe). New `check_settings_validates()` preflight catches both bug classes at scaffold time + emits a `[ SETTINGS WARNINGS ]` block; new static `--check` step asserts no settings-patch ships `skillOverrides` as a non-object (regression guard, caught the current bug when run for the first time). New test directory `test/schema-hygiene/` with 4 fixtures: cross-persona no-leak assertion, source-patch shape assertion, preflight-detects-violations (4 violation classes + clean case), recursive `_strip_doc_labels` invariant. Compat note: users on cc-configure 2.6.0 + a populated `.claude/settings.json` get the cleanup on next `cc-configure --retrofit` — the deep-merge preserves their customizations and the new strip removes the stub-leaks. - **feat: discipline-skills module — curated 7-skill subset forked from obra/superpowers v5.1.0.** New `templates/discipline-skills/` module ships seven discipline skills as project-level `.claude/skills//SKILL.md`: `brainstorming`, `writing-plans`, `executing-plans`, `verification-before-completion`, `using-git-worktrees`, `subagent-driven-development`, `finishing-a-development-branch`. Forked verbatim from the MIT-licensed upstream plugin with three surgical edits: visual-companion section stripped from `brainstorming` (the upstream's browser-based companion server is omitted — text-only mode is the default fallback anyway); `superpowers:` prefix stripped from every inter-skill cross-reference so they resolve correctly as project-level skills; the `requesting-code-review` template embedded inline into `subagent-driven-development/code-quality-reviewer-prompt.md` and the cosmetic `test-driven-development` companion line dropped from `subagent-driven-development/SKILL.md`'s Integration section. Module includes a slim SessionStart bootstrap (`hooks/sessionstart-discipline.sh`) that primes the model with a ~400-token seven-skill overview (vs. upstream's ~1,200-token `using-superpowers` injection) and auto-suppresses when the upstream `superpowers` plugin is also installed (detects `~/.claude/plugins/cache/claude-plugins-official/superpowers/`). Wired into `config_schema.py` with `extraSettingsHook` for the SessionStart registration and into the `solo-newer`, `solo-experienced`, `small-team` personas by default. Upstream MIT LICENSE ships at `.claude/skills/_LICENSE-discipline-skills.md`; attribution to Jesse Vincent (© 2025) added under README `## Acknowledgments`. New `/verify-setup` Check #12 flags duplicate installation (both the configurator's module and the upstream plugin present) so users can pick one. New `docs/10-plugin-ecosystem.md` section "Discipline skills: bundled vs. upstream plugin" documents the trade-off (~930 tokens saved per session vs. losing access to the 7 unused upstream skills) and the maintainer-internal `templates/discipline-skills/SYNC.md` documents the upstream-sync workflow. Recommend-plugins copy reframes the upstream `superpowers` row as the "full-suite alternative" rather than the configurator's first-choice recommendation. ## [2.6.0] — 2026-05-23 diff --git a/config_schema.py b/config_schema.py index a86fa7b..c9bd68f 100644 --- a/config_schema.py +++ b/config_schema.py @@ -278,14 +278,15 @@ "statusLine": { "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/statusline.sh", - "//": "Optional: hideVimModeIndicator (CC 2.1.143+, schemastore-validated 2026-05-23). When the statusline script renders its own vim mode display, set to true to suppress Claude Code's built-in one. Uncomment the next key by removing the leading '// ' (and drop this '//' explainer).", - "// hideVimModeIndicator": True, - }, - "//": "Optional: subagentStatusLine (CC 2.1.143+, schemastore-validated 2026-05-23). Distinct statusline for subagent runs so they're visually separable from the parent session. Uncomment the next block by removing the leading '// ' from the key.", - "// subagentStatusLine": { - "type": "command", - "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/statusline.sh --subagent", }, + # Optional opt-ins (statusLine.hideVimModeIndicator, + # subagentStatusLine — both CC 2.1.143+, schemastore-validated + # 2026-05-23) are documented in + # templates/core/dot-claude/settings.local.json.example with + # copy-paste-ready stubs. Removed from inline extraSettings on + # 2026-05-24 because the `// foo` stub keys propagated to user + # settings.json and triggered schema-validator complaints — + # see CHANGELOG for the dogfood-driven correction. }, }, ] diff --git a/configure.py b/configure.py index c0a744a..8a19b82 100755 --- a/configure.py +++ b/configure.py @@ -436,13 +436,31 @@ def repl(m): def _is_doc_label(k: str) -> bool: - # Source patch files use two `//` conventions: numbered doc labels - # ("//", "//2", "//9") that explain the patch to humans reading the - # source and must NOT propagate to the user's generated settings.json, - # and stub keys ("// sandbox", "// prUrlTemplate") that DO propagate so - # the user can uncomment them. Distinguish by the bit after `//`: pure - # digits (or empty) = doc label. - return k == "//" or (k.startswith("//") and k[2:].isdigit()) + # ANY key starting with `//` is maintainer-facing documentation and must + # NOT propagate to the user's generated settings.json. Claude Code's + # settings schema rejects unknown top-level keys (including `// foo` + # commented-stub keys), so propagating them produces validator complaints + # in the user's editor. Source patch files may use either bare `//` / + # numbered (`//2`, `//9`) doc labels for explanatory text, or `// foo` + # commented-stub keys that show maintainers an opt-in's exact shape. + # Both forms are stripped at merge time. User-facing opt-in discovery + # lives in `templates/core/dot-claude/settings.local.json.example`. + # See CHANGELOG entry for the dogfood-driven correction (was: PR #56's + # original filter kept `// foo` stubs intentionally). + return isinstance(k, str) and k.startswith("//") + + +def _strip_doc_labels(obj): + """Recursively strip all dict keys starting with `//` from obj. + Applied as the final pass over compute_merged_settings's output so + nested doc labels (e.g. `statusLine.// hideVimModeIndicator`) are + also caught — not just the top-level ones the shallow per-merge + filter handles.""" + if isinstance(obj, dict): + return {k: _strip_doc_labels(v) for k, v in obj.items() if not _is_doc_label(k)} + if isinstance(obj, list): + return [_strip_doc_labels(x) for x in obj] + return obj def deep_merge(a, b): @@ -475,7 +493,8 @@ def compute_merged_settings(form_values: dict, selected: set, module_flags: dict if m.get("extraSettingsHook"): settings["hooks"] = deep_merge(settings.get("hooks", {}), m["extraSettingsHook"]) if m.get("extraSettings"): - settings = deep_merge(settings, m["extraSettings"]) + extra = {k: v for k, v in m["extraSettings"].items() if not _is_doc_label(k)} + settings = deep_merge(settings, extra) # Apply per-module flag-gated extra patches. for flag_name, flag_def in m.get("flags", {}).items(): selected_value = module_flags.get(m["id"], {}).get(flag_name, flag_def["default"]) @@ -517,6 +536,9 @@ def compute_merged_settings(form_values: dict, selected: set, module_flags: dict else: settings.setdefault("env", {})["CLAUDE_BASH_MAX_LINES"] = str(cap) + # Final pass: strip any `//`-prefixed keys at any nesting depth. Catches + # nested doc labels / stubs that escape the shallow per-merge filters. + settings = _strip_doc_labels(settings) return settings @@ -720,6 +742,21 @@ def err(source, msg): if not re.search(r"^description:\s*\S+", fm, flags=re.MULTILINE): err(src, "frontmatter missing required `description:` field") + # --- 3b. Source-patch shape: skillOverrides must be an object, never a + # string. Catches the dogfood-driven regression class where a patch + # ships `"skillOverrides": "name-only"` — schema-invalid since CC + # 2.1.129+ requires the per-skill object form. + for f in sorted(TEMPLATE_DIR.rglob("settings-patch*.json")): + try: + data = json.loads(f.read_text(encoding="utf-8")) + except json.JSONDecodeError: + continue # already reported above + rel = f.relative_to(TEMPLATE_DIR) + if "skillOverrides" in data and not isinstance(data["skillOverrides"], dict): + err(f"templates/{rel}", + f"skillOverrides must be an object map " + f"(got {type(data['skillOverrides']).__name__})") + # --- 4. Cross-cutting pattern integration (rigor skills) --- # Each rigor skill must embed its named pattern blocks via `include # _patterns/.md` references. Catches drift where a future edit @@ -788,6 +825,69 @@ def check_schema_url(settings: dict) -> list: return warnings +def _find_doc_label_paths(obj, prefix=""): + """Recursively yield JSON-pointer-ish paths to any `//`-prefixed key + found in obj. Used by check_settings_validates as a regression guard + against _strip_doc_labels leaking a stub into the rendered settings.""" + if isinstance(obj, dict): + for k, v in obj.items(): + here = f"{prefix}.{k}" if prefix else k + if _is_doc_label(k): + yield here + yield from _find_doc_label_paths(v, here) + elif isinstance(obj, list): + for i, v in enumerate(obj): + yield from _find_doc_label_paths(v, f"{prefix}[{i}]") + + +def check_settings_validates(settings: dict) -> list: + """Catch the two settings-validator complaint classes surfaced by + dogfooding cc-configure on a downstream project (2026-05-24): + + 1. Top-level `//`-prefixed keys (doc labels / commented stubs) — the + Claude Code settings schema rejects unknown top-level keys, so + editors flag them and the user sees red squiggles. _strip_doc_labels + is supposed to remove these before render; this check is the + regression guard. + 2. `skillOverrides` shape — schema requires an object map keyed by + skill name with enum values (`"on"|"name-only"|"user-invocable-only"|"off"`). + Earlier versions shipped a top-level string `"name-only"` which the + current schema rejects. Also: per code.claude.com/docs/en/settings + the setting does not apply to plugin skills at all. + """ + warnings = [] + valid_overrides = {"on", "name-only", "user-invocable-only", "off"} + + leaks = list(_find_doc_label_paths(settings)) + if leaks: + warnings.append( + "rendered settings.json contains `//`-prefixed keys that the " + f"Claude Code schema rejects: {', '.join(leaks[:5])}" + + (f" (+{len(leaks) - 5} more)" if len(leaks) > 5 else "") + + ". This is a configurator bug — _strip_doc_labels should have " + "removed these before render." + ) + + if "skillOverrides" in settings: + so = settings["skillOverrides"] + if not isinstance(so, dict): + warnings.append( + f"settings.json `skillOverrides` is a {type(so).__name__} " + f"({so!r}) but the Claude Code schema requires an object map " + "keyed by skill name. Editors will reject the file; CC may drop " + "the setting silently." + ) + else: + bad = [(k, v) for k, v in so.items() if v not in valid_overrides] + if bad: + warnings.append( + "settings.json `skillOverrides` contains entries whose " + f"value isn't one of {sorted(valid_overrides)}: " + f"{bad[:3]}" + (f" (+{len(bad) - 3} more)" if len(bad) > 3 else "") + ) + return warnings + + KNOWN_STACK_MANIFESTS = ( "package.json", "pyproject.toml", @@ -2139,6 +2239,15 @@ def main(): print(dim(" Heavy interpreters on high-frequency events add hundreds of ms per tool call.")) print(dim(" Prefer .sh wrappers or native binaries when attaching to PreToolUse/PostToolUse.")) + settings_warnings = check_settings_validates(merged) + if settings_warnings: + print() + print(bold(yellow("[ SETTINGS WARNINGS ]"))) + for w in settings_warnings: + print(f" {yellow('!')} {w}") + print(dim(" These trigger Claude Code's settings-validator complaints in the user's editor.")) + print(dim(" File a configurator issue with the offending key + module — this is a template bug.")) + # Surface module-level prerequisites that can't be fixed by the configurator. module_warnings = [] if "github-actions" in config["selected"]: diff --git a/templates/core/dot-claude/settings.local.json.example b/templates/core/dot-claude/settings.local.json.example index 7e56b10..4fe61a8 100644 --- a/templates/core/dot-claude/settings.local.json.example +++ b/templates/core/dot-claude/settings.local.json.example @@ -1,14 +1,61 @@ { "$schema": "https://json.schemastore.org/claude-code-settings.json", - "//": "Personal settings — gitignored. Override anything from .claude/settings.json here.", + "//": "Personal settings — gitignored. Override anything from .claude/settings.json here. Copy this file to .claude/settings.local.json to activate; the .example suffix means Claude Code does not parse this file directly.", + + "// env": "Durable home for env vars including MCP auth tokens — e.g. SONATYPE_TOKEN (security-auditor agent) or GITHUB_TOKEN (mcp_github). Persists across shells, so MCPs keep working even when Claude is launched from a shell that doesn't inherit your tokens. Add entries below alongside EDITOR. CLAUDE_CODE_STOP_HOOK_BLOCK_CAP (CC 2.1.143+, overrides default 8-block cap for consecutive Stop hook blocks) lives here too — add the env var if a safety Stop hook intentionally blocks repeatedly.", + "env": { + "EDITOR": "code" + }, + "permissions": { "allow": [ "Bash(op read:*)", "Bash(op run:*)" ] }, - "// env": "Durable home for env vars including MCP auth tokens — e.g. SONATYPE_TOKEN (security-auditor agent) or GITHUB_TOKEN (mcp_github). Persists across shells, so MCPs keep working even when Claude is launched from a shell that doesn't inherit your tokens. Add entries below alongside EDITOR.", - "env": { - "EDITOR": "code" + + "//opt-ins": "===== OPT-IN STUBS — uncomment by removing the leading `// ` from any block, then drop this `//opt-ins` line. Each block is a documented Claude Code setting the configurator does not ship as an active default but supports users opting into per-project. All schemastore-validated as of 2026-05-23. Inline `//N` keys are explainer notes; the values shown are reasonable defaults you'll likely want to tune.", + + "// sandbox": { + "//1": "sandbox.network.deniedDomains (CC 2.1.113+) — data-exfiltration-resistant baseline. Takes effect only when the sandbox is otherwise active for the command. Supports wildcards (*.example.com).", + "//2": "sandbox.failIfUnavailable (CC 2.1.143+) — when true, sandbox startup is a hard failure if dependencies are missing (no fall-back to non-sandboxed execution). Fail-closed posture for safety-sensitive projects.", + "network": { + "deniedDomains": [ + "pastebin.com", + "paste.ee", + "hastebin.com", + "ix.io", + "0x0.st", + "bashupload.com", + "transfer.sh", + "file.io", + "anonfiles.com", + "uguu.se" + ] + }, + "failIfUnavailable": true + }, + + "// worktree": { + "//1": "worktree.baseRef (CC 2.1.133+) — values: 'fresh' (default; new worktrees from origin/HEAD) or 'head' (preserves unpushed local commits).", + "//2": "worktree.bgIsolation (CC 2.1.143+) — values: 'worktree' (default; background sessions get their own worktree) or 'none' (background sessions edit the live working copy directly).", + "baseRef": "head", + "bgIsolation": "worktree" + }, + + "// prUrlTemplate": "https://gitlab.com/{owner}/{repo}/-/merge_requests/{number}", + "//prUrlTemplate-notes": "prUrlTemplate (CC 2.1.119+) — footer PR badge + tool-result summaries follow this URL template instead of github.com. Placeholders: {host}, {owner}, {repo}, {number}. Examples: GitLab as shown above; Bitbucket: https://bitbucket.org/{owner}/{repo}/pull-requests/{number}; GHE: https://gh.example.com/{owner}/{repo}/pull/{number}.", + + "// subagentStatusLine": { + "//1": "subagentStatusLine (CC 2.1.143+) — distinct statusline for subagent runs so they're visually separable from the parent session. Requires statusline.sh to handle the --subagent flag (the shipped script does not yet — wrap or fork before uncommenting).", + "//2": "Note on the sibling statusLine.hideVimModeIndicator opt-in (CC 2.1.143+): only useful if you wrote a custom statusline.sh that renders its own vim mode display. To enable, copy your full statusLine block from .claude/settings.json (settings.local.json's statusLine REPLACES rather than merges with the base config) and add `\"hideVimModeIndicator\": true` to it.", + "type": "command", + "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/statusline.sh --subagent" + }, + + "// skillOverrides": { + "//1": "skillOverrides (CC 2.1.129+) — per-skill visibility overrides. Object map keyed by skill name; values: 'on' | 'name-only' | 'user-invocable-only' | 'off'. Use 'name-only' to hide a skill's description (cuts per-turn context) while keeping the model aware the skill exists.", + "//2": "Per code.claude.com/docs/en/settings: 'Does not apply to plugin skills, which are managed through /plugin.' So this only affects project-level and user-level skills.", + "//3": "Add your own skill-name → enum-value entries below. The configurator does not ship default entries because guessing which skills to compress is project-specific." } } diff --git a/templates/git-workflow/settings-patch.json b/templates/git-workflow/settings-patch.json index 607fc63..285c982 100644 --- a/templates/git-workflow/settings-patch.json +++ b/templates/git-workflow/settings-patch.json @@ -1,14 +1,6 @@ { "//": "Merge into .claude/settings.json hooks.", - "//2": "Optional: prUrlTemplate (Claude Code 2.1.119+, schemastore-validated 2026-04-28).", - "//3": "Footer PR badge + tool-result summaries follow this URL template instead of github.com.", - "//4": "Placeholders: {host}, {owner}, {repo}, {number}.", - "//5": "GitLab: https://gitlab.com/{owner}/{repo}/-/merge_requests/{number}", - "//6": "Bitbucket: https://bitbucket.org/{owner}/{repo}/pull-requests/{number}", - "//7": "GHE: https://gh.example.com/{owner}/{repo}/pull/{number}", - "//8": "Hook authoring note (CC 2.1.139+, schemastore-validated 2026-05-23): hookCommand entries now accept 'args: string[]' for direct-exec form (no shell wrapping, no quote-escaping pain) and 'continueOnBlock: boolean' for prompt hooks (default false; when true, ok:false feeds reason back to Claude and continues the turn instead of stopping). Useable on the hooks below if you customize them.", - "//9": "Uncomment prUrlTemplate by removing the leading '// ' on the next line, then set the template, then drop these //N keys.", - "// prUrlTemplate": "https://gitlab.com/{owner}/{repo}/-/merge_requests/{number}", + "//2": "Optional opt-ins (prUrlTemplate for non-github.com platforms, CC 2.1.119+) are documented in templates/core/dot-claude/settings.local.json.example with copy-paste-ready stubs. Hook authoring note (CC 2.1.139+): hookCommand entries accept 'args: string[]' for direct-exec form and 'continueOnBlock: boolean' for prompt hooks.", "hooks": { "PostToolUse": [ { diff --git a/templates/multi-agent/settings-patch.json b/templates/multi-agent/settings-patch.json index a320247..6612c89 100644 --- a/templates/multi-agent/settings-patch.json +++ b/templates/multi-agent/settings-patch.json @@ -1,10 +1,3 @@ { - "//": "Merged into .claude/settings.json. Multi-agent module ships no active settings by default — the commented opt-ins below are for users running parallel sessions or worktrees and pair naturally with /merge-worktrees.", - "//2": "Optional: worktree.baseRef (CC 2.1.133+, schemastore-validated 2026-05-23). Values: 'fresh' (default; creates new worktrees from origin/HEAD) or 'head' (preserves unpushed local commits in the new worktree). Pick 'head' if you frequently spin up worktrees from in-progress work.", - "//3": "Optional: worktree.bgIsolation (CC 2.1.143+, schemastore-validated 2026-05-23). Values: 'worktree' (default; background sessions get their own worktree) or 'none' (background sessions edit the live working copy directly, skipping EnterWorktree). 'none' is useful for repos where worktrees are impractical (build artifacts outside the tree, hardcoded paths).", - "//4": "Uncomment by removing the leading '// ' on the next key + tuning the values, then drop these //N keys.", - "// worktree": { - "baseRef": "head", - "bgIsolation": "worktree" - } + "//": "Merged into .claude/settings.json. Multi-agent module ships no active settings by default — the available opt-ins (worktree.baseRef, worktree.bgIsolation, CC 2.1.133+/2.1.143+) are documented in templates/core/dot-claude/settings.local.json.example with copy-paste-ready stubs." } diff --git a/templates/safety/settings-patch.json b/templates/safety/settings-patch.json index ff2502c..3d78266 100644 --- a/templates/safety/settings-patch.json +++ b/templates/safety/settings-patch.json @@ -1,37 +1,13 @@ { "//": "Merged into .claude/settings.json. PreToolUse hooks guard dangerous Bash and scan Write/Edit for secrets. permissions.disableBypassPermissionsMode='disable' hard-disables --dangerously-skip-permissions for this project, even if the user passes the flag.", "//2": "autoMode.hard_deny (CC 2.1.136+, schemastore-validated 2026-05-23) is on by default with two reasonable category blocks. The classifier only fires under `claude --auto-mode`, so this is a no-op for standard manual sessions — pure upside for auto-mode users. Tune the strings or remove entries to match your project's threat model.", - "//3": "Optional opt-in: sandbox.network.deniedDomains (CC 2.1.113+, schemastore-validated 2026-04-28). Only takes effect when the sandbox is otherwise active for the command (see sandboxing docs). Supports wildcards (*.example.com); the // sandbox block below has a small data-exfiltration-resistant baseline — tune for your threat model.", - "//4": "Inside the // sandbox block: failIfUnavailable (CC 2.1.143+, schemastore-validated 2026-05-23) makes sandbox startup a hard failure when required dependencies are missing instead of falling back to non-sandboxed execution. Fail-closed posture for safety-sensitive projects — uncomment the inner '// failIfUnavailable: true' line after uncommenting the outer block.", - "//5": "Optional: env CLAUDE_CODE_STOP_HOOK_BLOCK_CAP (CC 2.1.143+) overrides the default 8-block cap for consecutive Stop hook blocks. Raise only if a safety Stop hook intentionally blocks repeatedly and you want to tolerate it without the cap kicking in.", - "//6": "Hook authoring note (CC 2.1.139+, schemastore-validated 2026-05-23): hookCommand entries now accept 'args: string[]' for direct-exec form (no shell, no quote-escaping pain) and 'continueOnBlock: boolean' for prompt hooks (default false; when true, ok:false feeds the reason back to Claude and the turn continues instead of stopping). Use these on custom hooks you add below.", - "//7": "Uncomment any opt-in by removing the leading '// ' on its key, then drop the explainer //N keys.", + "//3": "Optional opt-ins (sandbox.network.deniedDomains, sandbox.failIfUnavailable, env CLAUDE_CODE_STOP_HOOK_BLOCK_CAP) are documented in templates/core/dot-claude/settings.local.json.example with copy-paste-ready stubs. Hook authoring note (CC 2.1.139+): hookCommand entries accept 'args: string[]' for direct-exec form and 'continueOnBlock: boolean' for prompt hooks.", "autoMode": { "hard_deny": [ "Running executable files", "Writing to system directories" ] }, - "// sandbox": { - "network": { - "deniedDomains": [ - "pastebin.com", - "paste.ee", - "hastebin.com", - "ix.io", - "0x0.st", - "bashupload.com", - "transfer.sh", - "file.io", - "anonfiles.com", - "uguu.se" - ] - }, - "// failIfUnavailable": true - }, - "// env": { - "CLAUDE_CODE_STOP_HOOK_BLOCK_CAP": "16" - }, "hooks": { "PreToolUse": [ { diff --git a/templates/token-efficiency/settings-patch.tier-pro.json b/templates/token-efficiency/settings-patch.tier-pro.json index d855978..6b71677 100644 --- a/templates/token-efficiency/settings-patch.tier-pro.json +++ b/templates/token-efficiency/settings-patch.tier-pro.json @@ -1,6 +1,5 @@ { - "//": "skillOverrides (CC 2.1.129+) is set to 'name-only' by default in the pro tier: the model sees skill names but not descriptions, cutting the largest contributor to per-turn context overhead (the metric /check-context flags). Other values: 'off' (hides from model AND /-menu — too aggressive for most), 'user-invocable-only' (hides from model only, you keep /-menu access). Override here if 'name-only' is too aggressive for your skill set.", - "skillOverrides": "name-only", + "//": "Pro tier ships an aggressive PostToolUse Bash-output truncation hook + a tighter CLAUDE_BASH_MAX_LINES cap. The largest per-turn context overhead /check-context flags is skill descriptions, but the Claude Code schema for skillOverrides requires a per-skill object map (`{\"skill-name\": \"name-only\"}`); there is no documented \"apply to all skills\" form. Users wanting to compress specific noisy skills can add their own object-form override in .claude/settings.local.json.example — the configurator does not ship a default because guessing which skills to compress is project-specific. Also: per code.claude.com/docs/en/settings the setting does not apply to plugin skills at all, further narrowing its usefulness as a tier-wide default.", "env": { "CLAUDE_BASH_MAX_LINES": "80" }, diff --git a/test/schema-hygiene/test-no-doc-labels-rendered.sh b/test/schema-hygiene/test-no-doc-labels-rendered.sh new file mode 100755 index 0000000..8168599 --- /dev/null +++ b/test/schema-hygiene/test-no-doc-labels-rendered.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# Scaffold every persona and assert the rendered .claude/settings.json has +# ZERO `//`-prefixed keys at any nesting depth. Catches the regression class +# where a future patch adds a `// foo` stub that escapes _strip_doc_labels. +# +# Dogfood origin: 2026-05-24 — downstream project's editor flagged validator +# complaints on `// sandbox`, `// env`, `// prUrlTemplate`, `// subagentStatusLine` +# keys that the configurator was writing directly to disk. +set -euo pipefail + +fail=0 +for persona in solo-newer solo-experienced small-team library-author custom; do + target=$(mktemp -d) + python3 configure.py --persona "$persona" --yes --dir "$target" >/dev/null + leaks=$(python3 -c " +import json, sys +def walk(o, prefix=''): + if isinstance(o, dict): + for k, v in o.items(): + here = f'{prefix}.{k}' if prefix else k + if isinstance(k, str) and k.startswith('//'): + print(here) + walk(v, here) + elif isinstance(o, list): + for i, v in enumerate(o): + walk(v, f'{prefix}[{i}]') +walk(json.load(open('$target/.claude/settings.json'))) +") + if [ -n "$leaks" ]; then + echo "FAIL: persona=$persona has //-prefixed keys in settings.json:" + echo "$leaks" | sed 's/^/ /' + fail=1 + fi + rm -rf "$target" +done + +[ "$fail" -eq 0 ] || exit 1 +echo "PASS: all 5 personas produce settings.json with zero //-prefixed keys" diff --git a/test/schema-hygiene/test-preflight-detects-violations.sh b/test/schema-hygiene/test-preflight-detects-violations.sh new file mode 100755 index 0000000..2ed92a1 --- /dev/null +++ b/test/schema-hygiene/test-preflight-detects-violations.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# Verify check_settings_validates() actually catches the two regression +# classes: top-level //-prefixed keys and a non-object skillOverrides value. +# Constructs synthetic settings dicts and asserts the check fires. +set -euo pipefail + +python3 - <<'EOF' +import sys +sys.path.insert(0, '.') +from configure import check_settings_validates + +# Case 1: top-level //-prefixed key +bad1 = {"// sandbox": {"foo": "bar"}, "$schema": "x"} +w = check_settings_validates(bad1) +assert any("//" in msg and "schema rejects" in msg for msg in w), f"expected //-leak warning, got: {w}" + +# Case 2: nested //-prefixed key (statusLine.// hideVimModeIndicator) +bad2 = {"statusLine": {"// hideVimModeIndicator": True}} +w = check_settings_validates(bad2) +assert any("//" in msg for msg in w), f"expected nested //-leak warning, got: {w}" + +# Case 3: skillOverrides as string +bad3 = {"skillOverrides": "name-only"} +w = check_settings_validates(bad3) +assert any("skillOverrides" in msg and "object map" in msg for msg in w), f"expected skillOverrides-shape warning, got: {w}" + +# Case 4: skillOverrides as object with invalid value +bad4 = {"skillOverrides": {"my-skill": "unknown-value"}} +w = check_settings_validates(bad4) +assert any("skillOverrides" in msg for msg in w), f"expected skillOverrides-value warning, got: {w}" + +# Case 5: clean settings — no warnings +clean = {"$schema": "x", "skillOverrides": {"my-skill": "name-only"}, "env": {"FOO": "bar"}} +w = check_settings_validates(clean) +assert w == [], f"expected clean output, got: {w}" + +print("PASS: check_settings_validates catches all 4 violation classes + clean case") +EOF diff --git a/test/schema-hygiene/test-skill-overrides-shape.sh b/test/schema-hygiene/test-skill-overrides-shape.sh new file mode 100755 index 0000000..c4f3270 --- /dev/null +++ b/test/schema-hygiene/test-skill-overrides-shape.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# Catch the regression class where a patch ships skillOverrides as a string +# (e.g., "name-only") instead of the schema-required per-skill object map. +# Walks every settings-patch*.json file under templates/ and asserts the +# shape. The configurator's static --check enforces this too; this test +# pins the invariant independently. +set -euo pipefail + +fail=0 +while IFS= read -r -d '' f; do + shape=$(python3 -c " +import json, sys +data = json.load(open('$f')) +so = data.get('skillOverrides') +if so is None: + print('absent') +elif isinstance(so, dict): + print('object') +else: + print(f'WRONG: {type(so).__name__} ({so!r})') +") + case "$shape" in + absent|object) + ;; + *) + echo "FAIL: $f — $shape" + fail=1 + ;; + esac +done < <(find templates -name 'settings-patch*.json' -type f -print0) + +[ "$fail" -eq 0 ] || exit 1 +echo "PASS: no settings-patch ships skillOverrides as a non-object" diff --git a/test/schema-hygiene/test-strip-doc-labels-recursive.sh b/test/schema-hygiene/test-strip-doc-labels-recursive.sh new file mode 100755 index 0000000..57e0711 --- /dev/null +++ b/test/schema-hygiene/test-strip-doc-labels-recursive.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +# Verify _strip_doc_labels() recursively removes all //-prefixed keys at +# any nesting depth, not just top-level. This is the regression guard for +# nested cases like statusLine.// hideVimModeIndicator that the shallow +# per-merge filters miss. +set -euo pipefail + +python3 - <<'EOF' +import sys +sys.path.insert(0, '.') +from configure import _strip_doc_labels + +# Top-level doc labels +out = _strip_doc_labels({"//": "doc", "//2": "more doc", "active": "keep"}) +assert out == {"active": "keep"}, f"top-level: {out}" + +# Top-level stub +out = _strip_doc_labels({"// sandbox": {"x": 1}, "real": "keep"}) +assert out == {"real": "keep"}, f"top-level stub: {out}" + +# Nested doc label +out = _strip_doc_labels({"statusLine": {"//": "doc", "type": "command"}}) +assert out == {"statusLine": {"type": "command"}}, f"nested label: {out}" + +# Nested stub +out = _strip_doc_labels({"statusLine": {"// hideVimModeIndicator": True, "type": "command"}}) +assert out == {"statusLine": {"type": "command"}}, f"nested stub: {out}" + +# Stub inside list element (defensive — JSON lists can contain dicts) +out = _strip_doc_labels({"hooks": [{"// label": "doc", "matcher": "Bash"}]}) +assert out == {"hooks": [{"matcher": "Bash"}]}, f"list: {out}" + +# Empty + non-dict pass-through +assert _strip_doc_labels({}) == {} +assert _strip_doc_labels([]) == [] +assert _strip_doc_labels("string") == "string" +assert _strip_doc_labels(42) == 42 +assert _strip_doc_labels(True) == True +assert _strip_doc_labels(None) == None + +print("PASS: _strip_doc_labels recursively removes //-prefixed keys at all depths") +EOF