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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<name>/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
Expand Down
15 changes: 8 additions & 7 deletions config_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
},
},
]
Expand Down
125 changes: 117 additions & 8 deletions configure.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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"])
Expand Down Expand Up @@ -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


Expand Down Expand Up @@ -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/<name>.md` references. Catches drift where a future edit
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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"]:
Expand Down
55 changes: 51 additions & 4 deletions templates/core/dot-claude/settings.local.json.example
Original file line number Diff line number Diff line change
@@ -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."
}
}
Loading
Loading