diff --git a/CHANGELOG.md b/CHANGELOG.md index ef23a58..2aa6d9c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ All notable changes to this project. Format: [Keep a Changelog](https://keepacha ## Unreleased +- **feat(preflight): `[ STACK WARNINGS ]` — flag a configured check stack that doesn't exist at the repo root (dogfood F2).** A user who keeps the default Node/pnpm intake answers (or otherwise configures `pnpm`/`uv`/`cargo`/… check commands) on a project whose manifest lives in a subdir or behind a container got **zero feedback** that the scaffolded Stop hook would never run: `stop-run-checks.sh` skips any check whose stack manifest is absent at the root (skip rule 3), so the whole typecheck/lint/test loop self-disables silently. New `check_stack_reality(target_dir, form_values)` preflight maps each configured check command's (typecheck/lint/test) first-binary — via the shlex-guarded `extract_first_binaries` — to its stack manifest (new `BINARY_TO_MANIFEST`, mirroring `manifest_for()` in `stop-run-checks.sh`) and warns when that manifest is missing from the project root, naming the subdir when it's one level down (`frontend/package.json`). When a warned binary is **also absent from the host PATH** and a `docker-compose.yml`/`Dockerfile` is present, it appends a note that the host-side format/check hooks will no-op because the toolchain lives in the container — host-PATH-gated, so a monorepo whose compose file only backs services (host toolchain present) doesn't get a false "no-op" warning. Warning-only; never blocks scaffolding; stays silent when the stack matches the root or no command maps to a known manifest (bare `tsc`/`pytest`/`ruff`). Rendered as a `[ STACK WARNINGS ]` block alongside the existing preflights. New `test/cc-manifest/test-stack-reality-preflight.sh` (matched-silent, unmappable-silent, monorepo-names-subdir, container-note-when-toolchain-off-host, container-note-suppressed-when-on-host, absent-everywhere, install-only-silent, malformed-quote-safe) + a malformed-quote case in `test-check-commands-extracted.sh`. Split out of the originally-bundled #87 per `CONTRIBUTING.md` (one logical change per PR); F1 (the SessionStart matcher fix) ships separately. Surfaced by dogfooding the configurator into a Python + FastAPI + Vite project that runs entirely via Docker. + - **chore(skills): sync discipline-skills v5.1.0 → v6.0.2 (obra/superpowers).** First sync since the module landed (PR #59, 2026-05-24); upstream shipped **v6.0.0** on 2026-06-16 (major), then **v6.0.1** + **v6.0.2** on 2026-06-17 as fast-follow patch releases. Skill inventory unchanged (14/14 upstream; we still fork the same 7). **Structural change in `subagent-driven-development`**: the two per-task reviewer prompts upstream removed (`spec-reviewer-prompt.md` + `code-quality-reviewer-prompt.md`) are replaced by a single `task-reviewer-prompt.md` that returns spec-compliance + quality verdicts in one pass; two new bash scripts (`scripts/review-package` + `scripts/task-brief`) move the review diff and task text to files so they don't park in the controller's context (file-handoff, not paste-handoff). Our v5-era "embed code-reviewer.md inline in code-quality-reviewer-prompt.md" SYNC.md edit is RETIRED — the new `task-reviewer-prompt.md` has no external dependency. **Configurator change**: `configure.py`'s `executable` heuristic widened from `rel.endswith(".sh")` to also honor the source file's user-execute bit, so the new `scripts/review-package` and `scripts/task-brief` (no `.sh` extension upstream) arrive at user installs with `+x` set. Other ports are mostly clean re-applies: `using-git-worktrees` + `finishing-a-development-branch` inherit the v6 drop of the global `~/.config/superpowers/worktrees/` path (project-local `.worktrees/` only) and `finishing-a-development-branch`'s forge-neutral rewrite (no hardcoded `gh pr create`); `writing-plans` gains v6's Global Constraints + per-task Interfaces template sections; `brainstorming`'s checklist drops the "offer visual companion just-in-time" step (the v6 process-flow digraph no longer carries the visual-companion nodes either); `executing-plans` re-applies the "This skill works much better" reframe (project-neutral). `superpowers:` prefix re-stripped across all SKILL.md and `*-prompt.md` files — `test-no-superpowers-prefix.sh` clean. Path list in `config_schema.py` updated (removed 2, added 3 → 15 total); test fixtures (`test-module-files-exist.sh` + `test-scaffold-installs-skills.sh`) updated to match. SYNC.md pin bumped to v6.0.2 with a fresh delta paragraph and revised canonical-edit list (5 items → 4; the retired embed-template item documented as removed). README's discipline-skills paragraphs refreshed (v5.1.0 → v6.0.2; carve-out section + module description). Known papercut documented: `subagent-driven-development/SKILL.md` retains one "final whole-branch review" line referencing `../requesting-code-review/code-reviewer.md` (a skill we don't ship) — pre-existing in v5.1.0, not addressed in this sync to keep port scope tight. - **chore(compat): CC 2.1.176–2.1.181 survey — held subagent-nesting wording lifted; no `tested_up_to` bump.** SchemaStore sync PR #5723 still open/draft (untouched since 2026-05-24 despite dep #5728 merged 2026-06-01), so `tested_up_to` stays pinned at 2.1.150 per the no-lone-bumps rule. **Held constraint resolved:** v2.7.0's 2.1.172–175 survey held the subagent-nesting wording on a CHANGELOG/docs contradiction (CHANGELOG said subagents can nest ≤5 levels; live docs said they cannot). The contradiction is now reconciled — `code.claude.com/docs/en/sub-agents` documents nested subagents under `min-version: 2.1.172`, and CC 2.1.181 explicitly unifies the foreground+background depth cap at 5 levels (*"Fixed foreground subagents spawning unbounded nested chains; they now respect the same 5-level depth limit as background subagents"*). Three shipped-template wording sites updated: `docs/04-subagents-mcp-orchestration.md` lines 59 + 140 and `templates/commands/infinite/SKILL.md` line 65. Leaf-design guidance preserved and remains the recommended pattern within the testing envelope (`tested_up_to < 2.1.172` means most users on the tested band don't have nesting; the `/infinite` skill's no-sibling-coordination invariant still holds because subagent contexts don't share even when nesting is available). **2.1.176–181 versions surveyed** (2.1.177 + 2.1.180 not present in CHANGELOG — skipped or internal-only per the verbatim-pull discipline): no new in-territory `settings.json` keys; out-of-territory by precedent — `footerLinksRegexes` + `language` (2.1.176, UI-pref tier), `sandbox.allowAppleEvents` (2.1.181, macOS-only opt-in). Future opt-in stub candidate: the new `Tool(param:value)` permission-rule form with `*` wildcard (2.1.178, e.g. `Agent(model:opus)`) — no shipped template uses it yet, SchemaStore acceptance status unverified, parking for the next compat batch. Nested `.claude/skills` collision handling (2.1.178) gains `:` namespacing — reinforces the existing plugin-skill carve-out pattern, no shipped-template impact. MCP server-level specs in subagent `disallowedTools` (`mcp__server`, `mcp__server__*`, `mcp__*`) silently-ignored bug fixed in 2.1.178 — grep against shipped templates clean. Compat comment refreshed in `config_schema.py` with the full survey. diff --git a/configure.py b/configure.py index 968b2d8..c462da3 100755 --- a/configure.py +++ b/configure.py @@ -944,6 +944,23 @@ def check_settings_validates(settings: dict) -> list: ) +# Maps a check-command's first binary to the stack manifest that signals +# "this stack is scaffolded at this directory." Mirrors manifest_for() in +# templates/git-workflow/hooks/stop-run-checks.sh — keep the two in sync. +# Values are a subset of KNOWN_STACK_MANIFESTS. +BINARY_TO_MANIFEST = { + "pnpm": "package.json", "npm": "package.json", + "yarn": "package.json", "bun": "package.json", + "uv": "pyproject.toml", "poetry": "pyproject.toml", + "pip": "pyproject.toml", "pip3": "pyproject.toml", + "cargo": "Cargo.toml", "rustc": "Cargo.toml", + "go": "go.mod", + "bundle": "Gemfile", "gem": "Gemfile", + "mvn": "pom.xml", + "gradle": "build.gradle", "./gradlew": "build.gradle", +} + + def detect_stack_manifests(target_dir: Path) -> list: """Return sorted list of known stack-manifest filenames present at target_dir. @@ -967,12 +984,102 @@ def extract_first_binaries(typecheck: str = None, lint: str = None, test: str = for kind, cmd in (("typecheck", typecheck), ("lint", lint), ("test", test)): if not cmd or not cmd.strip(): continue - parts = shlex.split(cmd) + try: + parts = shlex.split(cmd) + except ValueError: + parts = cmd.split() # unbalanced quote etc. — degrade, don't crash if parts: out[kind] = parts[0] return out +def check_stack_reality(target_dir, form_values: dict, which=None) -> list: + """Warn when a configured check command's stack manifest is missing from the + project root — the exact condition that makes the Stop hook + (stop-run-checks.sh, skip rule 3) silently skip that check at runtime. + + For each missing manifest, note whether it exists one directory level down + (monorepo / split frontend-backend layout) so the user knows the toolchain + is present but the root-anchored hook won't find it. When a warned toolchain + binary is also absent from the host PATH and the project looks containerized + (a compose file or root Dockerfile is present), append a note that the + host-side format/check hooks will no-op because the toolchain lives in the + container. `which` resolves a binary on the host PATH (defaults to + shutil.which; injectable for tests). + + Warning-only; never blocks scaffolding. Returns a list of strings for the + caller to render under a [ STACK WARNINGS ] block. Silent when every + configured command's manifest is present at the root, or when no configured + command maps to a known manifest (e.g. bare tsc / pytest / ruff).""" + fv = form_values or {} + if which is None: + import shutil + which = shutil.which + + # Reuse extract_first_binaries (shlex-guarded) for the three commands the + # Stop hook runs; install/build/dev aren't run by stop-run-checks.sh, so + # naming them here would misattribute the runtime skip to that hook. + bins = set(extract_first_binaries( + typecheck=fv.get("cmd_typecheck"), + lint=fv.get("cmd_lint"), + test=fv.get("cmd_test"), + ).values()) + + wanted = {} # manifest -> set of configured binaries that imply it + for b in bins: + manifest = BINARY_TO_MANIFEST.get(b) + if manifest: + wanted.setdefault(manifest, set()).add(b) + if not wanted: + return [] + + try: + subdirs = [p for p in target_dir.iterdir() + if p.is_dir() and not p.name.startswith(".")] + except OSError: + subdirs = [] + + warnings = [] + warned_bins = set() + for manifest in sorted(wanted): + if (target_dir / manifest).exists(): + continue # configured stack matches the repo root — fine + warned_bins |= wanted[manifest] + bins_str = "/".join(sorted(wanted[manifest])) + found_in = sorted(p.name for p in subdirs if (p / manifest).exists()) + if found_in: + where = ", ".join(d + "/" for d in found_in[:3]) + warnings.append( + "configured `{b}` checks but no {m} at the project root " + "(found in {w}). stop-run-checks.sh runs from the root and " + "will silently skip these — point your cmd_* values at the " + "subdir (e.g. `cd {d} && ...`) or scaffold it separately.".format( + b=bins_str, m=manifest, w=where, d=found_in[0])) + else: + warnings.append( + "configured `{b}` checks but no {m} anywhere in the tree — " + "these checks can't run here. Fix the stack in " + ".claude-config.json or re-run cc-configure.".format( + b=bins_str, m=manifest)) + + # Container note only when a warned toolchain binary isn't on the host PATH — + # then a compose/Dockerfile means it likely lives in the container and the + # host-side hooks no-op. If the binary IS on the host (e.g. a monorepo whose + # compose file is only for backing services), the hooks run fine. + if warnings and any(which(b) is None for b in warned_bins): + container = next( + (n for n in ("docker-compose.yml", "docker-compose.yaml", + "compose.yml", "compose.yaml", "Dockerfile") + if (target_dir / n).exists()), None) + if container: + warnings.append( + "detected {c} — if your toolchain runs in containers, the " + "format-on-write and stop-run-checks hooks call host tools and " + "will no-op. Point cmd_* at `docker compose exec ...`.".format( + c=container)) + return warnings + + def _configurator_sha(): """Short git SHA of the configurator's OWN checkout (REPO_ROOT), or None when unavailable (not a git repo, git missing, timeout, any error). @@ -2612,6 +2719,17 @@ def main(): for w in env_warnings: print(f" {yellow('!')} {w}") + # Surface a configured check stack that doesn't exist at the repo root — + # the runtime condition that makes stop-run-checks.sh silently self-disable. + stack_warnings = check_stack_reality(target_dir, config["formValues"]) + if stack_warnings: + print() + print(bold(yellow("[ STACK WARNINGS ]"))) + for w in stack_warnings: + print(f" {yellow('!')} {w}") + print(dim(" stop-run-checks.sh skips any check whose stack manifest is absent at the")) + print(dim(" project root, so a mismatched stack disables the typecheck/lint/test loop.")) + # Surface pre-existing design docs (typical output of a prior superpowers # brainstorming session). Informational, not a warning — the install is # fine; we just want to nudge the user to fold their design into CLAUDE.md diff --git a/test/cc-manifest/test-check-commands-extracted.sh b/test/cc-manifest/test-check-commands-extracted.sh index b378114..09ef368 100755 --- a/test/cc-manifest/test-check-commands-extracted.sh +++ b/test/cc-manifest/test-check-commands-extracted.sh @@ -37,4 +37,14 @@ print(json.dumps(extract_first_binaries(typecheck=' ', lint='ruff check', test expected='{"lint": "ruff"}' [ "$result" = "$expected" ] || { echo "FAIL: whitespace not skipped: $result != $expected"; exit 1; } -echo "PASS: extract_first_binaries pulls first token, skips empty/None/whitespace" +# Malformed quote must not crash (B1): degrades to a plain split, first token kept +result=$(python3 -c " +import sys; sys.path.insert(0, '$proj_root') +from configure import extract_first_binaries +import json +print(json.dumps(extract_first_binaries(test=\"pnpm test --grep 'foo\"))) +") +expected='{"test": "pnpm"}' +[ "$result" = "$expected" ] || { echo "FAIL: malformed quote should degrade, not crash: $result != $expected"; exit 1; } + +echo "PASS: extract_first_binaries pulls first token, skips empty/None/whitespace, survives malformed quotes" diff --git a/test/cc-manifest/test-stack-reality-preflight.sh b/test/cc-manifest/test-stack-reality-preflight.sh new file mode 100755 index 0000000..e1a57f4 --- /dev/null +++ b/test/cc-manifest/test-stack-reality-preflight.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env bash +# check_stack_reality() (dogfood F2) warns when a configured check command's +# stack manifest is absent from the project root — the same condition that makes +# stop-run-checks.sh silently skip at runtime (skip rule 3). It must: +# 1. stay SILENT when the manifest is present at root (matched stack) +# 2. stay SILENT when no configured command maps to a known manifest +# 3. WARN + name the subdir when the manifest lives one level down (monorepo) +# 4. add a container note ONLY when a warned binary is absent from the host +# PATH (toolchain lives in the container) and a compose/Dockerfile is present +# 4b. SUPPRESS the container note when the binary IS on the host (monorepo whose +# compose file is only for backing services — the hooks run fine) +# 5. WARN "can't run here" when the manifest is absent everywhere +# 6. ignore cmd_install/build/dev (stop-run-checks runs only typecheck/lint/test) +# 7. survive a malformed quote in a check command (shlex guard) +set -euo pipefail + +proj_root=$(pwd) +tmp=$(mktemp -d) +trap "rm -rf $tmp" EXIT + +# Pass DIR + form-values JSON via env to dodge nested-quote hell. $3 picks the +# host-PATH stub: none = nothing installed, all = everything installed, else real. +run() { # $1 = dir ; $2 = form_values JSON ; $3 = which mode (none|all|real) + DIR="$1" FV="$2" WHICH="${3:-real}" python3 -c " +import os, json, sys; sys.path.insert(0, '$proj_root') +from configure import check_stack_reality +from pathlib import Path +wm = os.environ['WHICH'] +if wm == 'none': + which = lambda b: None # nothing on the host PATH +elif wm == 'all': + which = lambda b: '/usr/bin/' + b # everything on the host PATH +else: + which = None # real shutil.which +print(json.dumps(check_stack_reality(Path(os.environ['DIR']), json.loads(os.environ['FV']), which=which))) +" +} + +# 1. Matched stack (pnpm + root package.json) -> silent +d="$tmp/matched"; mkdir -p "$d"; touch "$d/package.json" +out=$(run "$d" '{"cmd_test":"pnpm test","cmd_lint":"pnpm lint"}') +[ "$out" = "[]" ] || { echo "FAIL: matched stack should be silent; got: $out"; exit 1; } + +# 2. No mappable binary (bare tsc / pytest) -> silent +d="$tmp/nomap"; mkdir -p "$d" +out=$(run "$d" '{"cmd_typecheck":"tsc --noEmit","cmd_test":"pytest"}') +[ "$out" = "[]" ] || { echo "FAIL: unmappable binaries should be silent; got: $out"; exit 1; } + +# 3. Monorepo: pnpm configured, package.json only in frontend/ -> warn + name subdir +d="$tmp/monorepo"; mkdir -p "$d/frontend"; touch "$d/frontend/package.json" +out=$(run "$d" '{"cmd_test":"pnpm test"}' all) +echo "$out" | grep -q "no package.json at the project root" \ + || { echo "FAIL: monorepo should warn about missing root package.json; got: $out"; exit 1; } +echo "$out" | grep -q "frontend/" \ + || { echo "FAIL: monorepo warning should name the subdir frontend/; got: $out"; exit 1; } + +# 4. Containerized + toolchain ABSENT from host (which=none) -> warn + container note +d="$tmp/container"; mkdir -p "$d/frontend" +touch "$d/frontend/package.json" "$d/docker-compose.yml" +out=$(run "$d" '{"cmd_typecheck":"pnpm typecheck","cmd_lint":"pnpm lint","cmd_test":"pnpm test"}' none) +echo "$out" | grep -q "docker compose exec" \ + || { echo "FAIL: containerized + toolchain-off-host should get the container note; got: $out"; exit 1; } + +# 4b. A1: same layout but toolchain IS on host (which=all) -> subdir warn, NO container note +out=$(run "$d" '{"cmd_typecheck":"pnpm typecheck","cmd_lint":"pnpm lint","cmd_test":"pnpm test"}' all) +echo "$out" | grep -q "no package.json at the project root" \ + || { echo "FAIL: subdir warning should still fire when toolchain on host; got: $out"; exit 1; } +echo "$out" | grep -q "docker compose exec" \ + && { echo "FAIL: container note must be SUPPRESSED when the binary is on the host PATH; got: $out"; exit 1; } || true + +# 5. Manifest absent everywhere -> warn "can't run here" +d="$tmp/absent"; mkdir -p "$d" +out=$(run "$d" '{"cmd_test":"cargo test"}') +echo "$out" | grep -q "anywhere in the tree" \ + || { echo "FAIL: fully-absent manifest should warn can't-run; got: $out"; exit 1; } + +# 6. cmd_install/build/dev are NOT scanned (stop-run-checks.sh runs only +# typecheck/lint/test) — a missing manifest for install alone stays silent. +d="$tmp/installonly"; mkdir -p "$d" +out=$(run "$d" '{"cmd_install":"pnpm install"}') +[ "$out" = "[]" ] || { echo "FAIL: cmd_install alone must not warn (stop-run-checks never runs it); got: $out"; exit 1; } + +# 7. Malformed quote in a check command must NOT crash (shlex guard) — +# it degrades to a plain split and still extracts the first binary. +d="$tmp/badquote"; mkdir -p "$d/frontend"; touch "$d/frontend/package.json" +out=$(run "$d" "{\"cmd_test\":\"pnpm test --grep 'foo\"}" all) +echo "$out" | grep -q "no package.json at the project root" \ + || { echo "FAIL: malformed-quote cmd should degrade gracefully and still warn; got: $out"; exit 1; } + +echo "PASS: check_stack_reality — root-mismatch warns, names subdirs, container note gated on host-PATH absence, stop-hook-key scope, survives malformed quotes, silent when matched"