diff --git a/.github/workflows/skill-harness.yml b/.github/workflows/skill-harness.yml new file mode 100644 index 0000000..f918ca3 --- /dev/null +++ b/.github/workflows/skill-harness.yml @@ -0,0 +1,275 @@ +name: skill / harness + +on: + pull_request: + paths: + - skills/harness/** + - .github/workflows/skill-harness.yml + push: + branches: [main] + paths: + - skills/harness/** + - .github/workflows/skill-harness.yml + +jobs: + shellcheck: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - name: Run shellcheck on every .sh in skills/harness + run: | + sudo apt-get update -qq && sudo apt-get install -y -qq shellcheck + # SC2155: declare and assign separately — too noisy for these scripts. + # SC2034: unused variable — false-positives on label vars. + shellcheck -e SC2155,SC2034 \ + skills/harness/scripts/*.sh \ + skills/harness/assets/hooks/*.sh + + roundtrip: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v5 + + - name: Set up isolated $HOME with skill at $HOME/.claude/skills/harness/ + run: | + export TMPHOME="$RUNNER_TEMP/harness-test-home" + mkdir -p "$TMPHOME/.claude/skills" + cp -r skills/harness "$TMPHOME/.claude/skills/" + # Drop a fake package.json at $HOME so _detect_stack picks something up + # → exercises the auto-fill of '## Stack signals' in CLAUDE.md. + cat > "$TMPHOME/package.json" <<'JSON' + { + "dependencies": { "react": "^18.0.0", "next": "^14.0.0" }, + "devDependencies": { "typescript": "^5.0.0", "vitest": "^1.0.0" }, + "scripts": { "lint:check": "eslint .", "test": "vitest" } + } + JSON + echo "TMPHOME=$TMPHOME" >> $GITHUB_ENV + echo "SK=$TMPHOME/.claude/skills/harness" >> $GITHUB_ENV + + - name: install (auto-detected user scope) + run: | + HOME="$TMPHOME" USER_PROJECT_KEY="-ci-test" SKILL_DIR="$SK" \ + bash "$SK/scripts/install.sh" + + - name: stack signals were auto-filled into CLAUDE.md + run: | + # Placeholder block should be gone; detected bullets should be present. + if grep -q "Replace with your default stack" "$TMPHOME/.claude/CLAUDE.md"; then + echo "::error::Stack signals placeholder still present after install — auto-fill didn't run" + grep -n "Stack signals" "$TMPHOME/.claude/CLAUDE.md" | head -5 + exit 1 + fi + # Detected bullets must include something from the fake package.json. + grep -E "TypeScript|Next|React|npm" "$TMPHOME/.claude/CLAUDE.md" >/dev/null \ + || { echo "::error::detected stack bullets not found in CLAUDE.md"; sed -n '/## Stack signals/,/^## /p' "$TMPHOME/.claude/CLAUDE.md"; exit 1; } + + - name: status — every surface should report installed/wired/set + run: | + out=$(HOME="$TMPHOME" USER_PROJECT_KEY="-ci-test" SKILL_DIR="$SK" \ + bash "$SK/scripts/status.sh") + echo "$out" + echo "$out" | grep -q "missing" && { echo "::error::status reports missing surfaces after install"; exit 1; } || true + # CLAUDE.md is expected to differ from the template after install + # (stack signals were auto-filled). Filter it out before checking + # for any other 'modified' surfaces. + if echo "$out" | grep "modified" | grep -v "CLAUDE.md" | grep -q .; then + echo "::error::status reports modified non-CLAUDE.md surfaces on fresh install" + exit 1 + fi + + - name: re-install — should report 'already current' + run: | + out=$(HOME="$TMPHOME" USER_PROJECT_KEY="-ci-test" SKILL_DIR="$SK" \ + bash "$SK/scripts/install.sh") + echo "$out" + echo "$out" | grep -q "already current" || { echo "::error::idempotency broken — second install didn't say 'already current'"; exit 1; } + + - name: env-var insertion is tracked in 'changed' flag + run: | + python3 -c " + import json, sys + p = '$TMPHOME/.claude/settings.json' + s = json.load(open(p)) + s.get('env', {}).pop('CLAUDE_CODE_AUTO_COMPACT_WINDOW', None) + json.dump(s, open(p, 'w'), indent=2) + " + out=$(HOME="$TMPHOME" USER_PROJECT_KEY="-ci-test" SKILL_DIR="$SK" \ + bash "$SK/scripts/install.sh") + echo "$out" + echo "$out" | grep -q "settings.json updated" || { echo "::error::env-var change not detected as 'updated'"; exit 1; } + + - name: block-force-push hook fires correctly + run: | + # Should block: + out=$(echo '{"tool_name":"Bash","tool_input":{"command":"git push --force origin main"}}' \ + | "$TMPHOME/.claude/hooks/block-force-push.sh" 2>&1; echo "rc=$?") + echo "$out" + echo "$out" | grep -q "rc=2" || { echo "::error::force-push to main not blocked"; exit 1; } + # Should allow: + rc=$(echo '{"tool_name":"Bash","tool_input":{"command":"git push --force-with-lease origin feature"}}' \ + | "$TMPHOME/.claude/hooks/block-force-push.sh" 2>/dev/null; echo $?) + [ "$rc" = "0" ] || { echo "::error::--force-with-lease incorrectly blocked"; exit 1; } + # Should allow (worktree branch -D): + rc=$(echo '{"tool_name":"Bash","tool_input":{"command":"git branch -D feature/foo"}}' \ + | "$TMPHOME/.claude/hooks/block-force-push.sh" 2>/dev/null; echo $?) + [ "$rc" = "0" ] || { echo "::error::worktree branch -D should be allowed"; exit 1; } + # Should block (protected branch -D): + rc=$(echo '{"tool_name":"Bash","tool_input":{"command":"git branch -D main"}}' \ + | "$TMPHOME/.claude/hooks/block-force-push.sh" 2>/dev/null; echo $?) + [ "$rc" = "2" ] || { echo "::error::branch -D main should be blocked"; exit 1; } + # Should block (refspec push-delete of protected branch): + rc=$(echo '{"tool_name":"Bash","tool_input":{"command":"git push origin :main"}}' \ + | "$TMPHOME/.claude/hooks/block-force-push.sh" 2>/dev/null; echo $?) + [ "$rc" = "2" ] || { echo "::error::refspec push-delete of main should be blocked"; exit 1; } + rc=$(echo '{"tool_name":"Bash","tool_input":{"command":"git push origin :refs/heads/master"}}' \ + | "$TMPHOME/.claude/hooks/block-force-push.sh" 2>/dev/null; echo $?) + [ "$rc" = "2" ] || { echo "::error::refspec push-delete refs/heads/master should be blocked"; exit 1; } + # Should allow (refspec push-delete of feature branch): + rc=$(echo '{"tool_name":"Bash","tool_input":{"command":"git push origin :feature/foo"}}' \ + | "$TMPHOME/.claude/hooks/block-force-push.sh" 2>/dev/null; echo $?) + [ "$rc" = "0" ] || { echo "::error::refspec push-delete of feature branch incorrectly blocked"; exit 1; } + # Should block (--delete on protected branch): + rc=$(echo '{"tool_name":"Bash","tool_input":{"command":"git push --delete origin main"}}' \ + | "$TMPHOME/.claude/hooks/block-force-push.sh" 2>/dev/null; echo $?) + [ "$rc" = "2" ] || { echo "::error::git push --delete origin main should be blocked"; exit 1; } + # Should block (+refspec to protected branch — force without --force): + rc=$(echo '{"tool_name":"Bash","tool_input":{"command":"git push origin +HEAD:main"}}' \ + | "$TMPHOME/.claude/hooks/block-force-push.sh" 2>/dev/null; echo $?) + [ "$rc" = "2" ] || { echo "::error::+refspec push to main should be blocked"; exit 1; } + + - name: settings.json write is atomic (no .tmp leftover) + run: | + HOME="$TMPHOME" USER_PROJECT_KEY="-ci-test" SKILL_DIR="$SK" \ + bash "$SK/scripts/install.sh" --force > /dev/null + [ ! -e "$TMPHOME/.claude/settings.json.tmp" ] \ + || { echo "::error::settings.json.tmp left behind — atomic rename failed"; exit 1; } + python3 -m json.tool < "$TMPHOME/.claude/settings.json" > /dev/null \ + || { echo "::error::settings.json is not valid JSON after install"; exit 1; } + + - name: modify a hook → status reports 'modified' + run: | + echo "# user customisation" >> "$TMPHOME/.claude/hooks/format-on-edit.sh" + out=$(HOME="$TMPHOME" USER_PROJECT_KEY="-ci-test" SKILL_DIR="$SK" \ + bash "$SK/scripts/status.sh") + echo "$out" | grep "format-on-edit" | grep -q modified \ + || { echo "::error::status didn't flag modified hook"; exit 1; } + + - name: uninstall (default) — keeps modified hook, removes the rest + run: | + out=$(HOME="$TMPHOME" USER_PROJECT_KEY="-ci-test" SKILL_DIR="$SK" \ + bash "$SK/scripts/uninstall.sh") + echo "$out" + echo "$out" | grep -q "keep (modified)" || { echo "::error::uninstall didn't preserve modified hook"; exit 1; } + echo "$out" | grep -q "keeping memory" || { echo "::error::uninstall removed memory at default level"; exit 1; } + + - name: uninstall --all — full sweep + run: | + # Re-install for the --all test + HOME="$TMPHOME" USER_PROJECT_KEY="-ci-test" SKILL_DIR="$SK" \ + bash "$SK/scripts/install.sh" --force > /dev/null + HOME="$TMPHOME" USER_PROJECT_KEY="-ci-test" SKILL_DIR="$SK" \ + bash "$SK/scripts/uninstall.sh" --all > /dev/null + left=$(find "$TMPHOME/.claude" -type f -not -path '*/skills/*' | wc -l | tr -d ' ') + [ "$left" = "1" ] || { echo "::error::--all left $left files behind (expected 1: settings.json={})"; exit 1; } + settings=$(cat "$TMPHOME/.claude/settings.json") + [ "$settings" = "{}" ] || { echo "::error::settings.json after --all is not {}: $settings"; exit 1; } + + - name: project-scope install (skill at /.claude/skills/harness/) + run: | + PROJ="$RUNNER_TEMP/proj-scope" + mkdir -p "$PROJ/proj/.claude/skills" + cp -r skills/harness "$PROJ/proj/.claude/skills/" + export HOME_NEW="$PROJ/home" + mkdir -p "$HOME_NEW" + HOME="$HOME_NEW" USER_PROJECT_KEY="-ci-proj" \ + SKILL_DIR="$PROJ/proj/.claude/skills/harness" \ + bash "$PROJ/proj/.claude/skills/harness/scripts/install.sh" + # Hooks must be at /.claude/hooks/, not under $HOME + test -f "$PROJ/proj/.claude/hooks/block-force-push.sh" \ + || { echo "::error::project-scope hooks not installed"; exit 1; } + test ! -d "$HOME_NEW/.claude/hooks" \ + || { echo "::error::project-scope install touched \$HOME"; exit 1; } + # settings.json hook command should use $CLAUDE_PROJECT_DIR + grep -q 'CLAUDE_PROJECT_DIR' "$PROJ/proj/.claude/settings.json" \ + || { echo "::error::project settings missing CLAUDE_PROJECT_DIR in hook commands"; exit 1; } + + - name: adopt drops harness-check.sh starter and refuses $HOME + run: | + PROJ="$RUNNER_TEMP/adopt-test" + mkdir -p "$PROJ" + # Pretend it's a Node project so the detector emits something. + echo '{"scripts":{"lint:check":"echo lint","test":"echo test"}}' > "$PROJ/package.json" + out=$(SKILL_DIR="$SK" bash "$SK/scripts/adopt.sh" --target="$PROJ") + echo "$out" + test -x "$PROJ/scripts/harness-check.sh" \ + || { echo "::error::adopt didn't write scripts/harness-check.sh"; exit 1; } + echo "$out" | grep -q "Stack signals" \ + || { echo "::error::adopt preflight missing Stack signals section"; exit 1; } + # Re-running without --force keeps the existing file + echo "# user-edit" >> "$PROJ/scripts/harness-check.sh" + before=$(sha256sum "$PROJ/scripts/harness-check.sh" 2>/dev/null || shasum -a 256 "$PROJ/scripts/harness-check.sh" | awk '{print $1}') + SKILL_DIR="$SK" bash "$SK/scripts/adopt.sh" --target="$PROJ" > /dev/null + after=$(sha256sum "$PROJ/scripts/harness-check.sh" 2>/dev/null || shasum -a 256 "$PROJ/scripts/harness-check.sh" | awk '{print $1}') + [ "$before" = "$after" ] || { echo "::error::adopt overwrote a customised harness-check.sh without --force"; exit 1; } + # Refuse $HOME + rc=$(SKILL_DIR="$SK" bash "$SK/scripts/adopt.sh" --target="$HOME" >/dev/null 2>&1; echo $?) + [ "$rc" = "2" ] || { echo "::error::adopt should refuse \$HOME with exit 2"; exit 1; } + # Starter has at least one sensor wired by virtue of grep matching package.json + grep -q "step \"npm run lint:check\"" "$PROJ/scripts/harness-check.sh" \ + || { echo "::error::starter harness-check.sh missing the npm lint:check block"; exit 1; } + + - name: doctor — exits 0 against a healthy install + run: | + # Re-install to undo the prior --all sweep + uninstall round. + HOME="$TMPHOME" USER_PROJECT_KEY="-ci-test" SKILL_DIR="$SK" \ + bash "$SK/scripts/install.sh" --force > /dev/null + out=$(HOME="$TMPHOME" USER_PROJECT_KEY="-ci-test" SKILL_DIR="$SK" \ + bash "$SK/scripts/doctor.sh" 2>&1) + echo "$out" + echo "$out" | grep -q "0 fail" || { echo "::error::doctor reported failures on a fresh install"; exit 1; } + echo "$out" | grep -q "block-force-push.sh fires correctly" \ + || { echo "::error::doctor missing block-force-push smoke result"; exit 1; } + + - name: update — identical files are no-op, modified files are skipped + diffed + run: | + # Modify a hook so update has something to skip + diff. + echo "# user customisation for update test" >> "$TMPHOME/.claude/hooks/format-on-edit.sh" + mtime_before=$(stat -c '%Y' "$TMPHOME/.claude/hooks/block-force-push.sh" 2>/dev/null \ + || stat -f '%m' "$TMPHOME/.claude/hooks/block-force-push.sh") + out=$(HOME="$TMPHOME" USER_PROJECT_KEY="-ci-test" SKILL_DIR="$SK" \ + bash "$SK/scripts/update.sh" 2>&1) + echo "$out" + # Modified file must be flagged as such. + echo "$out" | grep "format-on-edit.sh" | grep -q "modified" \ + || { echo "::error::update.sh didn't flag modified format-on-edit.sh"; exit 1; } + # Identical file must NOT be rewritten — mtime should be unchanged. + mtime_after=$(stat -c '%Y' "$TMPHOME/.claude/hooks/block-force-push.sh" 2>/dev/null \ + || stat -f '%m' "$TMPHOME/.claude/hooks/block-force-push.sh") + [ "$mtime_before" = "$mtime_after" ] \ + || { echo "::error::update.sh rewrote an identical file (mtime $mtime_before → $mtime_after)"; exit 1; } + # Modified file must NOT be overwritten without --force. + tail -1 "$TMPHOME/.claude/hooks/format-on-edit.sh" | grep -q "user customisation" \ + || { echo "::error::update.sh clobbered a customised file without --force"; exit 1; } + + - name: update --merge writes .new alongside + run: | + out=$(HOME="$TMPHOME" USER_PROJECT_KEY="-ci-test" SKILL_DIR="$SK" \ + bash "$SK/scripts/update.sh" --merge 2>&1) + echo "$out" + [ -f "$TMPHOME/.claude/hooks/format-on-edit.sh.new" ] \ + || { echo "::error::update --merge didn't write format-on-edit.sh.new"; exit 1; } + # Original is still customised. + tail -1 "$TMPHOME/.claude/hooks/format-on-edit.sh" | grep -q "user customisation" \ + || { echo "::error::update --merge overwrote the original"; exit 1; } + + - name: ambiguous scope (no .claude ancestor) errors helpfully + run: | + out=$(SKILL_DIR="$PWD/skills/harness" bash skills/harness/scripts/install.sh 2>&1; echo "rc=$?") + echo "$out" + echo "$out" | grep -q "rc=2" || { echo "::error::ambiguous scope should exit 2"; exit 1; } + echo "$out" | grep -q "auto-detect scope" || { echo "::error::ambiguous error message missing 'auto-detect scope'"; exit 1; } diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5a878a5 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 datashaman + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 6bd9f15..35e2059 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,28 @@ Trello board health audit. Queries five dimensions of card data to identify thro /audit-trello board="My Board" since=2024-07-01 ``` +### `/harness` + +Control surface for a harness-engineering Claude Code setup at user or project scope. Sub-actions: **install** (operating-contract CLAUDE.md, four guardrail hooks, `/verify` and `/plan` slash commands, auto-memory seeds, settings.json patch); **uninstall** (symmetric reversal with content-match protection for customised files); **update** (refresh installed files vs current templates with diffable `--merge` mode); **doctor** (end-to-end diagnostic — perms, hook smoke-test, settings JSON validity); **adopt** (retrofit into an existing project — detects stack, writes a starter `scripts/harness-check.sh` pass/fail gate, prints next-step install command); **snapshot** (sanitised mirror of `~/.claude/` to a private git repo); **status** (report installed / modified / missing per surface); **audit** (prepare a monthly remote routine that PRs deltas against the latest Anthropic releases and Claude Code community patterns). All idempotent. + +Builds on [OpenAI's harness-engineering article](https://openai.com/index/harness-engineering/), [Martin Fowler's writeup](https://martinfowler.com/articles/harness-engineering.html), and patterns from Boris Cherny, Simon Willison, Jesse Vincent (Superpowers), Geoffrey Huntley, Hamel Husain, and Steve Yegge. Sibling to [datashaman/harness-template](https://github.com/datashaman/harness-template) (the project-scope counterpart). + +**Arguments:** None — the skill detects intent from natural language ("install", "uninstall", "update", "doctor", "adopt", "snapshot", "status", "audit"). Just `/harness` runs `status`. + +**Usage:** +``` +/harness # status, then ask +/harness install +/harness uninstall +/harness uninstall --all # also remove CLAUDE.md, memory, env var +/harness update # refresh files; --merge for diffable side-by-side +/harness doctor # end-to-end diagnostic +/harness adopt # retrofit into existing project (scaffolds scripts/harness-check.sh) +/harness snapshot +/harness status +/harness audit # prep a monthly remote routine +``` + ## Other skills I like ### [`agent-ready-codebase`](https://skills.sh/casper-studios/casper-marketplace/agent-ready-codebase) @@ -129,3 +151,7 @@ skills/ ## Adding a skill Create a new directory under `skills/` with a `SKILL.md` file. The frontmatter should include `name` and `description` fields. See existing skills for examples. + +## License + +MIT — see [LICENSE](./LICENSE). diff --git a/skills/harness/README.md b/skills/harness/README.md new file mode 100644 index 0000000..8713c86 --- /dev/null +++ b/skills/harness/README.md @@ -0,0 +1,201 @@ +# `/harness` — Claude Code harness control surface + +A small opinionated skill that turns `~/.claude/` into a proper **harness**: feedforward guides Claude reads before it acts, deterministic sensors that catch drift after, and an optional drift-detection loop that PRs deltas against the latest releases each month. + +Sub-actions: **install**, **uninstall**, **update**, **doctor**, **adopt**, **snapshot**, **status**, **audit**. All idempotent. No surface gets clobbered without consent. + +## Where the idea comes from + +The vocabulary and structure follow two pieces of writing that ought to be required reading for anyone running coding agents seriously: + +- **OpenAI — *Harness engineering*** · [openai.com/index/harness-engineering](https://openai.com/index/harness-engineering/) + Frames the work as building a *harness around the agent*: feedforward guides ("here's how to act"), feedback sensors ("here's how you went wrong"), and garbage collection ("clean up while you sleep"). +- **Martin Fowler — *Harness engineering*** · [martinfowler.com/articles/harness-engineering.html](https://martinfowler.com/articles/harness-engineering.html) + Independent treatment of the same idea — the harness is the discipline, the model is just the engine. + +The day-to-day patterns this skill ships came from these voices, who keep publishing the highest-signal Claude Code material: + +- **Boris Cherny** (Claude Code lead, Anthropic) · [howborisusesclaudecode.com](https://howborisusesclaudecode.com/) — the canonical reference for hooks, slash commands, plan-mode, parallel worktrees, and the `CLAUDE_CODE_AUTO_COMPACT_WINDOW=400000` tip. +- **Simon Willison** · [simonwillison.net/tags/claude-code/](https://simonwillison.net/tags/claude-code/) — the Auto Mode safety analysis (deterministic hooks beat AI classifiers) and the [Skills > MCP](https://simonw.substack.com/p/claude-skills-are-awesome-maybe-a) argument that decided the format. +- **Jesse Vincent — Superpowers** · [github.com/obra/superpowers](https://github.com/obra/superpowers) and [the original blog post](https://blog.fsck.com/2025/10/09/superpowers/) — the brainstorm → plan → fresh-subagent-per-task → verify-before-completion workflow. +- **Geoffrey Huntley — the Ralph loop** · [ghuntley.com/loop/](https://ghuntley.com/loop/) — context engineering as a programmable surface; the verification loop *is* the work. +- **Hamel Husain — Evals skills for coding agents** · [hamel.dev/blog/posts/evals-skills/](https://hamel.dev/blog/posts/evals-skills/) — "improving the infrastructure around the agent mattered more than improving the model." +- **Steve Yegge — Gas Town / Gas City** · [Gas Town](https://steve-yegge.medium.com/welcome-to-gas-town-4f25ee16dd04) and [Gas City](https://steve-yegge.medium.com/welcome-to-gas-city-57f564bb3607) — multi-agent orchestration above the level this skill operates at, but the role-decomposition (Mayor / Polecats / Refinery) generalises. + +This skill is also a sibling of [datashaman/harness-template](https://github.com/datashaman/harness-template) — that repo is the *project-scope* harness (drop-in stack profiles, `harness/policies/`, `scripts/harness-check.sh`, `harness/grades.yml`). `/harness` is the user-scope counterpart. + +## When to use + +`/harness` — the skill detects intent from your phrasing. Examples: + +| You say | It runs | +| -------------------------------------------------- | ---------- | +| "set up my Claude Code", "install harness" | `install` | +| "uninstall harness", "remove the bootstrap" | `uninstall`| +| "update harness", "pull latest templates" | `update` | +| "doctor", "diagnose my setup", "is it working?" | `doctor` | +| "adopt harness", "retrofit into my project" | `adopt` | +| "snapshot my setup", "back up `~/.claude/`" | `snapshot` | +| "what's installed?", "is the harness wired?" | `status` | +| "schedule a monthly audit", "audit my setup" | `audit` | +| Just `/harness` | `status`, then asks | + +## Scope auto-detection (no forced user-default) + +The skill installs at the **scope where it itself lives** — derived from `$SKILL_DIR`. If the skill is at `/.claude/skills/harness/` (or under a plugin cache below `/.claude/`), the install lands at `/.claude/`. That `` becomes user scope if it's `$HOME`, project scope otherwise. + +You can override: +- `--scope=user` → `$HOME/.claude/` +- `--scope=project` → `$CLAUDE_PROJECT_DIR/.claude/` (or `$PWD/.claude/` if unset) +- `--target=PATH` → explicit `.claude/` directory + +If the skill is being invoked from a checkout (no `.claude/` ancestor), the script errors with a clear message asking for one of the flags above. + +## What `install` lays down + +**User scope** (`~/.claude/`): + +| Surface | What lands | +| ------------------------ | --------------------------------------------------------------------------------------------------- | +| `~/.claude/CLAUDE.md` | Operating-contract template (default stance, editing rules, expected tools) | +| `~/.claude/hooks/` | `block-force-push.sh`, `format-on-edit.sh`, `post-compact-reinject.sh`, `verify-before-stop.sh` | +| `~/.claude/commands/` | `/verify` (run the project's pass/fail check), `/plan` (Goal/Constraints/Acceptance template) | +| `~/.claude/projects//memory/` | `MEMORY.md` index + `user_role`, `feedback_concise`, `feedback_plan_first`, `feedback_verification` | +| `~/.claude/settings.json`| Adds `env.CLAUDE_CODE_AUTO_COMPACT_WINDOW=400000` + 4 hook entries (only if missing) | + +**Project scope** (`/.claude/`): + +| Surface | What lands | +| --------------- | ------------------------------------------------------------------------------------------------ | +| `/CLAUDE.md` | Operating contract — **skipped if a project CLAUDE.md already exists** (`--force` overrides) | +| `/.claude/hooks/` | Same 4 hooks | +| `/.claude/commands/` | `/verify`, `/plan` | +| `/.claude/settings.json` | 4 hook entries with **project-relative** paths (`.claude/hooks/...`) | +| Memory | NOT seeded at project scope — memory is per-user by design and lives under `$HOME` | +| Env var | NOT set at project scope — `CLAUDE_CODE_AUTO_COMPACT_WINDOW` is session-wide | + +Project-scope install never modifies `$HOME`. + +The hooks: + +- **`block-force-push.sh`** (PreToolUse:Bash) — segment-aware matcher. Blocks force-push to main/master, hard reset to remote, `rm -rf ~`, `--no-verify`, world-writable chmod, branch -D. Allows `--force-with-lease`. Doesn't false-trigger on echoed strings. +- **`format-on-edit.sh`** (PostToolUse:Write|Edit) — runs Pint / `bun run format` / `npm run format` / ruff / gofmt / cargo fmt if the project's config is present. Silent on success. +- **`post-compact-reinject.sh`** (PostCompact) — re-cats `./CLAUDE.md`, `./AGENTS.md`, `~/.claude/CLAUDE.md` after autocompact, so the operating contract survives compression. +- **`verify-before-stop.sh`** (Stop) — refuses Stop if `./scripts/harness-check.sh` fails. `CLAUDE_SKIP_VERIFY=1` to override mid-investigation. + +## Escape hatches — pick what to accept + +Both `install.sh` and `uninstall.sh` print a **preflight banner** showing the scope, target, and per-surface plan before any change is made. To customise the plan: + +- **install:** `--skip-claude-md`, `--skip-hooks`, `--skip-commands`, `--skip-memory`, `--skip-settings`. Or a positive list: `--include=hooks,commands` (everything not listed is skipped). +- **uninstall:** `--keep-hooks`, `--keep-commands`, `--keep-settings` to preserve specific surfaces that the default would remove. Plus the standard `--remove-claude-md`, `--remove-memory`, `--remove-env`, `--all` to broaden. +- Always available: `--dry-run` (recommended first run), `--force`. + +## What `uninstall` does + +Symmetric reversal. Conservative defaults: + +- Removes hooks + commands **only if their sha256 still matches** the installed template — your customisations stay. +- Strips the 4 hook entries from `settings.json`. Drops empty hook event arrays. Doesn't touch permissions, marketplaces, statusLine, advisorModel, theme, or anything else. +- Keeps `CLAUDE.md`, memory entries, and the env var by default — opt in with `--remove-claude-md`, `--remove-memory`, `--remove-env`, or `--all`. +- Flags: `--dry-run`, `--force` (override content-match check). + +## What `adopt` does + +Retrofits the harness into an existing project that wasn't built around it. Walkthrough: + +1. **Detects state.** Reports what's already there — `CLAUDE.md`, `.claude/`, `settings.json`, `scripts/harness-check.sh` — plus stack signals from manifest files. +2. **Writes a starter `scripts/harness-check.sh`** — the project-side pass/fail gate that `verify-before-stop.sh` and `/verify` invoke. Stack-aware: runs lint / types / tests for whichever ecosystem files are present (`package.json`, `composer.json`, `pyproject.toml`, `go.mod`, `Cargo.toml`, `Gemfile`). Refuses to overwrite an existing one (`--force` to override). Empty-sensor case is treated as PASS so the script never strands `Stop`. +3. **Prints the next-step install command.** Doesn't run install itself — you read the preflight banner there separately. + +```bash +/harness adopt # detect + drop starter + print next steps +/harness install --scope=project # then this — 4 hooks, /verify, /plan, CLAUDE.md +# edit scripts/harness-check.sh to taste +# restart Claude Code so hooks load +/verify # smoke-test the gate +``` + +For a deeper project-scope harness (policy YAMLs, `harness/grades.yml`, `.skip` ledger, stack profiles), see [`datashaman/harness-template`](https://github.com/datashaman/harness-template). `adopt` gives you the spine; harness-template is the next floor up. + +Refuses to write into `$HOME` — adopt is project-scope only. + +## What `update` does + +Refreshes installed files against the current templates without clobbering customisations. For each surface compares installed vs template via sha256: + +- **identical** → re-install (no-op cosmetically) +- **missing** → install +- **modified** → print diff and SKIP, unless you pass `--merge` or `--force` + +`--merge` writes the new template to `.new` alongside the original — diff/merge by hand. Run after pulling new versions of this skill. + +## What `doctor` does + +End-to-end diagnostic that catches drift `status` doesn't see. Sample checks: + +- a sha256 tool is on PATH +- target dir is writable +- `settings.json` is valid JSON +- all 4 hooks are executable and wired into `settings.json` +- hook entries don't point at unexpected paths (foreign installs) +- smoke-tests `block-force-push.sh` with a known-bad command (must exit 2) +- memory dir is populated +- `CLAUDE.md` `## Stack signals` isn't still placeholder text +- snapshot repo (if `$SNAPSHOT_REPO` set) hasn't gone stale + +Exits non-zero on any FAIL. Run after `install` and after each Claude Code upgrade. + +## What `snapshot` does + +Mirrors `~/.claude/` into a target git repo (you specify, must be PRIVATE), scrubs caches and known secret patterns, commits + pushes only on diff. Idempotent — second run is a no-op. Used as the input to the monthly `audit` routine. + +```bash +SNAPSHOT_REPO=~/Projects//claude-setup bash $SKILL_DIR/scripts/snapshot.sh +``` + +## What `audit` does + +Doesn't run an audit *here*. Prepares a prompt for a **remote** monthly routine that the user creates via `/schedule`. The remote agent clones your snapshot repo, researches the last ~30 days of Anthropic releases (release notes, CHANGELOG, blog) and the canonical voices listed above, and opens a PR with `audits/YYYY-MM-DD-setup-audit.md` containing prioritised deltas. It can never modify `CLAUDE.md` / `settings.json` / hooks — only proposes. + +## Arguments + +None. The skill detects intent from natural language. If unclear, it runs `status` first and asks. + +## Files in this folder + +| File | Role | +| --------------------------------- | --------------------------------------------------------------------- | +| `SKILL.md` | Agent instructions (what Claude reads when invoked) | +| `README.md` | This — human-facing overview | +| `assets/CLAUDE.md.tmpl` | Operating-contract template | +| `assets/hooks/*.sh` | Four hook scripts | +| `assets/commands/*.md` | `/verify`, `/plan` | +| `assets/memory/*.tmpl` | MEMORY.md index + 3 feedback memories + user_role template | +| `scripts/install.sh` | Idempotent installer (`--dry-run` / `--force` / `--skip-*`) | +| `scripts/uninstall.sh` | Symmetric uninstaller (content-match check; `--all` for full sweep) | +| `scripts/update.sh` | Refresh installed files vs current templates (`--merge` / `--force`) | +| `scripts/doctor.sh` | End-to-end diagnostic (perms, hook smoke-test, settings JSON, etc.) | +| `scripts/adopt.sh` | Retrofit into an existing project — writes `scripts/harness-check.sh`| +| `scripts/snapshot.sh` | Sanitised mirror of `~/.claude/` → target git repo | +| `scripts/status.sh` | Read-only — reports installed / modified / missing per surface | +| `scripts/_detect_stack.py` | Stack-signal detector — auto-fills `## Stack signals` at install time | +| `assets/harness-check.sh.tmpl` | Starter project pass/fail gate written by `adopt` | +| `scripts/audit-prompt.md` | Prompt template for the monthly remote-audit routine | + +## Requirements + +- macOS or Linux with `python3` and `grep -E` on PATH (both standard). +- A SHA-256 tool for `uninstall`/`status` content-match: any one of `sha256sum` (default on Linux), `shasum` (default on macOS), or `python3` (already required, so this is automatic). +- For `snapshot`: `git`, plus `gh` if you want help creating a private repo. +- For `audit`: a Claude Code account where `/schedule` is available. + +## Install via `skills.sh` + +```bash +npx skills add https://github.com/datashaman/code-skills --skill harness +``` + +## A note on the name + +This was originally `bootstrap-harness`. Renamed because the skill is a *control surface*, not a one-shot bootstrap — `install` is just one of five sub-actions, and "bootstrap" undersells what it does. `harness` is the noun the discipline already uses (see the OpenAI / Fowler articles above), so `/harness install`, `/harness uninstall`, `/harness snapshot`, `/harness status`, `/harness audit` all read the way the corresponding sub-CLI would in any other tool. diff --git a/skills/harness/SKILL.md b/skills/harness/SKILL.md new file mode 100644 index 0000000..3a739b1 --- /dev/null +++ b/skills/harness/SKILL.md @@ -0,0 +1,269 @@ +--- +name: harness +description: > + Control surface for a "harness-engineering" Claude Code setup at user or + project scope. Sub-actions: install (operating-contract CLAUDE.md, four + guardrail hooks, /verify and /plan slash commands, auto-memory seeds, + settings.json patch); uninstall (symmetric reversal with content-match + protection for customised files); update (refresh installed files vs + current templates, with --merge for diffable side-by-side); doctor + (end-to-end diagnostic — sha256 tools, write perms, hook smoke-test, + settings JSON validity, memory + CLAUDE.md state); adopt (retrofit into + an existing project — detects stack, scaffolds a starter scripts/harness- + check.sh, prints next-step install command); snapshot (sanitised mirror + of ~/.claude/ to a private git repo); status (report what's installed, + modified, or missing); audit (prepare a monthly remote-audit routine + that PRs deltas against the latest Anthropic releases and Claude Code + community patterns). All sub-actions are idempotent. Use when asked to + "set up my Claude Code", "install harness", "uninstall harness", "update + harness", "diagnose my setup", "adopt harness in this project", + "retrofit", "snapshot my setup", "audit my setup", "harden my Claude", + or any request matching the sub-actions. +user-invocable: true +--- + +# Harness + +Control surface for the user-scope Claude Code "harness" — feedforward guides (CLAUDE.md, memory), feedback sensors (hooks), and an optional drift-detection loop (snapshot + monthly audit). + +The vocabulary follows OpenAI's *Harness engineering* (https://openai.com/index/harness-engineering/) and Martin Fowler's writeup (https://martinfowler.com/articles/harness-engineering.html). Day-to-day patterns are convergent picks from Boris Cherny, Simon Willison, Jesse Vincent (Superpowers), Geoffrey Huntley (Ralph loop), Hamel Husain (eval skills), and Steve Yegge (Gas Town). See README.md for citations. + +## Sub-action dispatch + +The user invokes this skill, optionally with an action word. Detect intent and run the matching sub-action. If the user says `/harness` without context, run **status** first (it's read-only and informative), then ask which action they want. + +| Said by user | Sub-action | Script | +| --------------------------------------------- | ----------- | ------------------------------- | +| "install", "set up", "bootstrap" | `install` | `scripts/install.sh` | +| "uninstall", "remove", "undo" | `uninstall` | `scripts/uninstall.sh` | +| "update", "pull latest templates", "refresh" | `update` | `scripts/update.sh` | +| "doctor", "diagnose", "is it working" | `doctor` | `scripts/doctor.sh` | +| "adopt", "add to existing project", "retrofit"| `adopt` | `scripts/adopt.sh` | +| "snapshot", "backup", "mirror to git" | `snapshot` | `scripts/snapshot.sh` | +| "status", "what's installed", "audit local" | `status` | `scripts/status.sh` | +| "audit", "schedule audit", "monthly check" | `audit` | (prep work — see below) | + +Substitute the skill's absolute base directory for `$SKILL_DIR` in every command — it's announced at the top of this invocation. + +## install + +Lays down the harness at the **scope where the skill itself lives**, derived from `$SKILL_DIR`: + +- Skill at `/.claude/skills/harness/` (or under a plugin cache below `/.claude/`) → install at `/.claude/`. +- If `` is `$HOME`, that's user scope; otherwise project scope. +- Override with `--scope=user`, `--scope=project`, or `--target=PATH`. +- If the skill is being run from a checkout (no `.claude/` ancestor), the script errors with a clear message asking for `--scope` or `--target`. + +What lands at user scope: + +| Surface | Path | +| ------------------------ | --------------------------------------------------------------------------------------------------- | +| Operating contract | `~/.claude/CLAUDE.md` | +| Hooks | `~/.claude/hooks/{block-force-push,format-on-edit,post-compact-reinject,verify-before-stop}.sh` | +| Slash commands | `~/.claude/commands/{verify,plan}.md` | +| Auto-memory | `~/.claude/projects//memory/{MEMORY.md, user_role, feedback_concise, feedback_plan_first, feedback_verification}` | +| settings.json | Adds `env.CLAUDE_CODE_AUTO_COMPACT_WINDOW=400000` + 4 hook entries (uses `~/.claude/hooks/...` form) | + +What lands at project scope (`/.claude/`): + +| Surface | Path | +| --------------- | ----------------------------------------------------------------------------------------------- | +| Operating contract | `/CLAUDE.md` (skipped if it already exists — most projects have one. `--force` overrides) | +| Hooks | `/.claude/hooks/*.sh` | +| Commands | `/.claude/commands/*.md` | +| settings.json | `/.claude/settings.json` — 4 hook entries with `.claude/hooks/...` (project-relative) form. **No env var, no memory** at project scope. | +| Memory | (skipped — memory is per-user by design and lives under `$HOME` regardless of project scope) | + +```bash +bash "$SKILL_DIR/scripts/install.sh" +``` + +Common flags: +- `--dry-run` — show the preflight + plan, change nothing. +- `--force` — overwrite existing files (including project CLAUDE.md). + +Per-surface skip flags (escape hatch — pick & choose what to install): +- `--skip-claude-md`, `--skip-hooks`, `--skip-commands`, `--skip-memory`, `--skip-settings` + +Or a positive list (everything else is skipped): +- `--include=hooks,commands` — install only those. +- `--include=claude-md,settings` — only the operating contract + settings patch. +- Valid items: `claude-md`, `hooks`, `commands`, `memory`, `settings`. + +The script always prints a **preflight banner** showing scope, target, the per-surface plan (with SKIP markers reflecting the active flags), and a pointer to the uninstaller with `--all` warnings. Read it before proceeding. + +After install, walk the user through the hand-edits printed under "Next steps": + +1. Fill in `## Stack signals` in `CLAUDE.md` (user scope: `~/.claude/CLAUDE.md`; project scope: `/CLAUDE.md`). At install time the script tries to auto-fill this from manifests it finds — verify it picked up your stack correctly. If you need hints, look at `~/.claude/projects/` slugs and `installed_plugins.json`; ask if unclear. **Don't auto-fill from guesswork.** +2. (User scope only) Replace placeholders in `~/.claude/projects//memory/user_role.md` with the user's actual role / projects / stack. **Ask, don't invent.** + +Tell them to **restart Claude Code** so hooks load. + +## uninstall + +```bash +bash "$SKILL_DIR/scripts/uninstall.sh" +``` + +Same scope auto-detection as install. Conservative defaults: + +- Removes hooks + commands **only if their sha256 still matches** the installed template. User-modified files are kept and reported as `keep (modified)`. +- Strips the 4 hook entries from `settings.json`; drops empty hook event arrays. Leaves all other settings untouched. +- **Keeps by default:** `CLAUDE.md`, memory files, the `CLAUDE_CODE_AUTO_COMPACT_WINDOW` env var. + +Flags: +- `--dry-run`, `--force` (skip content-match) +- Broaden the sweep: `--remove-claude-md`, `--remove-memory`, `--remove-env`, `--all` +- **Escape hatch — keep specific surfaces that default would remove:** `--keep-hooks`, `--keep-commands`, `--keep-settings` + +The script prints a preflight banner showing scope, target, what will be removed vs kept, and which `--keep-*` / `--remove-*` flags are active. Always run `--dry-run` first if unsure. + +Tell the user to **restart Claude Code** so hook deregistration takes effect. + +## adopt + +Retrofit the harness into an existing project (project scope only). Use when the user says "I have a project, how do I add this?", "adopt", "retrofit", or any variant that implies *this isn't a greenfield install*. + +```bash +bash "$SKILL_DIR/scripts/adopt.sh" +``` + +What it does: + +- Detects the project root (`$CLAUDE_PROJECT_DIR` or `$PWD`; refuses to write into `$HOME`). +- Reports what's already there: `CLAUDE.md`, `.claude/`, `settings.json`, `scripts/harness-check.sh`, plus stack signals. +- Writes a stack-aware starter file to `scripts/harness-check.sh` — the project-side pass/fail gate that `verify-before-stop.sh` and `/verify` invoke. The starter runs lint / types / tests for whichever ecosystem files are present (`package.json`, `composer.json`, `pyproject.toml`, `go.mod`, `Cargo.toml`, `Gemfile`). Skipped if `harness-check.sh` already exists (`--force` to overwrite). Empty-sensor case is treated as PASS so the script never strands `Stop`. +- Prints the next-step `install.sh --scope=project` command. **Does not run install itself** — the user reviews the preflight banner there separately. + +Walk-through for the agent: + +1. Run `adopt.sh`. Read its preflight to the user. +2. If the user is happy, run `install.sh --scope=project` (or with `--include=hooks,commands` for a minimum-viable retrofit that doesn't add a CLAUDE.md). +3. Tell the user to edit `scripts/harness-check.sh` to match their project's commands (the starter is intentionally generous; comment out or delete blocks that don't apply). +4. Tell them to **restart Claude Code** so hooks load. +5. Suggest they run `/verify` once to smoke-test the gate end-to-end. +6. Mention `datashaman/harness-template` for the deeper project-scope layer (policy YAMLs, grades, `.skip` ledger) when they've outgrown the starter. + +Flags: +- `--target=PATH` — explicit project root. +- `--dry-run` — show plan, don't write. +- `--force` — overwrite an existing `scripts/harness-check.sh` (you'll lose edits). + +## update + +```bash +bash "$SKILL_DIR/scripts/update.sh" +``` + +Smarter than `install --force`. For each surface compares installed vs current template via sha256 and: + +- identical → re-install (no-op cosmetically) +- missing → install +- modified → print diff and **SKIP**, unless `--merge` or `--force` + +Flags: +- `--dry-run` — show plan only. +- `--force` — overwrite ALL files including modified ones (loses customisations). +- `--merge` — for modified files, write the new template to `.new` alongside the original. The user can diff/merge interactively. + +Default behaviour is non-destructive: you'll see diffs but no customised file is overwritten without consent. + +## doctor + +```bash +bash "$SKILL_DIR/scripts/doctor.sh" +``` + +End-to-end diagnostic. Combines `status` with sanity checks: + +- sha256 tool present +- target dir writable +- `settings.json` is valid JSON +- all 4 hooks exist, are executable, and are wired in `settings.json` +- hook entries don't point at unexpected paths (foreign installs) +- smoke-test: invoke `block-force-push.sh` with a known-bad command and verify exit code 2 +- memory dir populated (user scope) +- `CLAUDE.md` present and `## Stack signals` not still placeholder +- snapshot repo (if `$SNAPSHOT_REPO` set) — last commit recency + +Exits non-zero if any FAIL, zero on warnings. Run `doctor` after `install` and after each Claude Code upgrade. + +## snapshot + +```bash +SNAPSHOT_REPO=~/Projects// bash "$SKILL_DIR/scripts/snapshot.sh" +``` + +Mirrors `~/.claude/` into a target git repo, scrubs caches and secret patterns, commits + pushes only on diff. Idempotent. + +If the user doesn't have a snapshot repo yet, prompt them to create one (PRIVATE — the snapshot has personal config): + +```bash +mkdir -p ~/Projects//claude-setup +cd ~/Projects//claude-setup +git init -b main +gh repo create /claude-setup --private --source=. --remote=origin +``` + +Then run `snapshot.sh` against it. The first push lands; subsequent runs are no-ops if nothing changed. + +Override sources via env: `CLAUDE_DIR=...`, `USER_PROJECT_KEY=...`. + +## status + +```bash +bash "$SKILL_DIR/scripts/status.sh" +``` + +Read-only. Reports: + +- For each hook + command + memory file + CLAUDE.md: `installed` (matches template), `modified` (customised), or `missing`. +- For `settings.json`: which of the 4 hook entries are wired, plus the env var. +- For snapshot repo (if `SNAPSHOT_REPO` env is set): commits ahead of origin, last snapshot timestamp. + +Use `status` first when the user says `/harness` without an action word, when they say "what's installed?", when they say "is this still set up?", or before any `install` to show the diff. + +## audit + +The audit is a monthly **remote** routine — Claude Code's `/schedule` skill creates it. This skill prepares the prompt and suggests config; the user runs `/schedule` themselves. + +Steps: + +1. Read `$SKILL_DIR/scripts/audit-prompt.md` — that's the prompt for the remote agent. Confirm with the user that it covers what they want. +2. Suggested config: + - cron: `0 6 1 * *` (1st of month, 06:00 UTC) + - model: `claude-opus-4-7` (audit quality matters) + - tools: Bash, Read, Write, Edit, Glob, Grep, WebFetch, WebSearch, Agent + - sources: the user's snapshot repo URL (must exist — run `snapshot` first) +3. Tell the user to invoke `/schedule` and paste the prompt + config. Or, if they have `RemoteTrigger` available in their session, build the body and call it directly. + +The remote agent clones the snapshot repo, researches the last ~30 days of Anthropic releases and canonical Claude Code voices, and PRs `audits/YYYY-MM-DD-setup-audit.md` with prioritised deltas. It never modifies tracked files outside `audits/`. + +## Constraints + +- **Never auto-fill stack signals or user_role.** Templates have placeholders; ask the user to fill them. +- **Never modify `settings.json` outside `env` and `hooks`.** Don't touch permissions, marketplaces, statusLine, advisorModel, theme. +- **Memory is sensitive.** If `MEMORY.md` already exists with the user's entries, leave it alone unless they explicitly say otherwise. +- **Snapshot repos must be private.** They contain personal config. +- **Hooks load on session start.** Tell the user to restart Claude Code after install/uninstall. + +## Files in this skill + +| File | Role | +| --------------------------------- | --------------------------------------------------------------------- | +| `SKILL.md` | This file — agent instructions | +| `README.md` | Human-facing overview (with sources / inspiration) | +| `assets/CLAUDE.md.tmpl` | Operating-contract template | +| `assets/hooks/*.sh` | Four hook scripts | +| `assets/commands/*.md` | `/verify`, `/plan` | +| `assets/memory/*.tmpl` | MEMORY.md index + 3 feedback memories + user_role template | +| `scripts/install.sh` | Idempotent installer (`--dry-run` / `--force` / `--skip-*`) | +| `scripts/uninstall.sh` | Symmetric uninstaller (content-match check; `--all` for full sweep) | +| `scripts/update.sh` | Refresh installed files vs current templates (`--merge` / `--force`) | +| `scripts/doctor.sh` | End-to-end diagnostic (perms, hook smoke-test, settings JSON, etc.) | +| `scripts/adopt.sh` | Retrofit into existing project — writes `scripts/harness-check.sh` | +| `scripts/snapshot.sh` | Sanitised mirror of `~/.claude/` → target git repo | +| `scripts/status.sh` | Read-only — reports installed / modified / missing per surface | +| `scripts/_detect_stack.py` | Stack-signal detector — auto-fills `## Stack signals` at install time | +| `assets/harness-check.sh.tmpl` | Starter project pass/fail gate written by `adopt` | +| `scripts/audit-prompt.md` | Prompt template for the monthly remote-audit routine | diff --git a/skills/harness/assets/CLAUDE.md.tmpl b/skills/harness/assets/CLAUDE.md.tmpl new file mode 100644 index 0000000..5b85037 --- /dev/null +++ b/skills/harness/assets/CLAUDE.md.tmpl @@ -0,0 +1,39 @@ +# Operating contract + + + +## Default stance +- Treat me as an engineer reviewing your output, not a pair programmer. Take Goal/Constraints/Acceptance and run; don't narrate every line. +- For any non-trivial task: enter plan mode (shift-tab twice) before writing code. One task per conversation — `/clear` between tasks. +- Don't claim done until verification passes. If `./scripts/harness-check.sh` exists, run it. Otherwise run the project's lint + types + tests. +- Concise replies. No trailing summaries — I read diffs. + +## Editing +- `Edit`/`Write` for files. No `sed`/`awk` from Bash. +- ripgrep for search; `Explore` agent only for broad sweeps across many files. +- Don't add comments unless the WHY is non-obvious. Don't add docs unless asked. +- Don't add error handling for impossible cases. Don't introduce abstractions for hypothetical futures. + +## Tools I expect you to use +- `advisor()` before committing to an approach on tasks longer than a few steps, and before declaring done. +- Sub-agents (`Explore`, `general-purpose`) for parallel research — don't duplicate their searches in the main thread. +- `/verify` to run the project's pass/fail check. +- `/plan` to draft Goal/Constraints/Acceptance for a non-trivial task. + +## Stack signals + + + +## When the harness disagrees with you +The harness is authoritative. If a sensor (lint/types/tests) is wrong, fix the rule in the same change with a one-line rationale. Silently disabling a check is worse than the original bug. + +The verify-before-stop hook only fires when the working tree has uncommitted changes — read-only sessions can stop normally. If you legitimately need to stop with work in progress (e.g., handing off to the user mid-debug), set `CLAUDE_SKIP_VERIFY=1` for that turn. + +## Memory +The auto-memory system at `~/.claude/projects//memory/` is active — read `MEMORY.md` and update it when you learn durable facts about me, my projects, or my preferences. diff --git a/skills/harness/assets/commands/plan.md b/skills/harness/assets/commands/plan.md new file mode 100644 index 0000000..d69aa66 --- /dev/null +++ b/skills/harness/assets/commands/plan.md @@ -0,0 +1,34 @@ +--- +description: Draft a Goal/Constraints/Acceptance plan for the task in $ARGUMENTS, then enter plan mode +--- + +Before writing any code, produce a plan in this exact shape: + +## Goal +One sentence. The outcome, not the activity. + +## Constraints +Bulleted. Include: +- Files / modules in scope (and explicitly out of scope). +- Stack/version constraints (look at CLAUDE.md, AGENTS.md, composer.json, package.json). +- Backwards-compat or migration concerns. +- Performance, security, or ergonomic budgets the user has flagged. + +## Acceptance criteria +Bulleted, testable. Each one should be checkable by running a command or reading a diff. Examples: +- `composer lint:check` passes. +- `php artisan test --filter=Foo` passes with new cases X and Y. +- `/route X` returns 200 with payload shape Z. +- No new TODOs left behind. + +## Approach +3–7 bullets, ordered. The smallest plan that covers the goal. Identify the *risky* step and how you'll de-risk (spike, isolated test, advisor() call). + +## Open questions +Anything that would change the approach materially. If the answer is guessable from the codebase, go look — don't ask. + +--- + +Task: $ARGUMENTS + +After producing the plan, ask the user to approve or amend before any edits. If they say "go", enter plan-mode-style execution: small steps, run the verification check after each material change, and use `advisor()` once before declaring done. diff --git a/skills/harness/assets/commands/verify.md b/skills/harness/assets/commands/verify.md new file mode 100644 index 0000000..44d19f4 --- /dev/null +++ b/skills/harness/assets/commands/verify.md @@ -0,0 +1,22 @@ +--- +description: Run the project's pass/fail verification check (harness-check.sh, or stack-default lint+types+tests) +--- + +Run the project's verification check and report pass/fail with concise output. Use this before declaring any task done. + +Order of preference: + +1. If `./scripts/harness-check.sh` exists and is executable → run it. +2. Else if `composer.json` exists with a `lint:check` script → run `composer lint:check && composer test` (and `npm run types:check` if `package.json` exists). +3. Else if `package.json` exists → run `npm run lint:check && npm run types:check && npm test` (skip any that aren't defined). +4. Else if `pyproject.toml` exists → `ruff check . && mypy . && pytest -q`. +5. Else if `Cargo.toml` → `cargo check && cargo test`. +6. Else if `go.mod` → `go vet ./... && go test ./...`. + +Report: +- ✅ pass → one line. +- ❌ fail → the failing command, the salient lines from output (not full log), and the smallest fix proposal. + +Do NOT propose a fix that disables a rule unless the user explicitly asks. Never use `--no-verify`. + +If `$ARGUMENTS` is provided, treat it as a path filter — run checks scoped to that path where the tool supports it. diff --git a/skills/harness/assets/harness-check.sh.tmpl b/skills/harness/assets/harness-check.sh.tmpl new file mode 100755 index 0000000..2b7070c --- /dev/null +++ b/skills/harness/assets/harness-check.sh.tmpl @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# scripts/harness-check.sh — project pass/fail gate. +# Called by ~/.claude/hooks/verify-before-stop.sh and `/verify`. +# Returns non-zero on the first failed sensor. +# +# Customise the blocks below for your stack — uncomment, edit, or delete. +# Keep the script fast (under ~10s) so it can run on every Stop. +set -uo pipefail + +step() { + local label="$1"; shift + echo "→ $label" + if "$@"; then return 0; fi + echo "❌ harness-check failed: $label" >&2 + exit 1 +} + +ran=0 + +# Node / JS / TS. +if [ -f package.json ]; then + grep -q '"lint:check"' package.json && { step "npm run lint:check" npm run lint:check; ran=1; } + grep -q '"types:check"' package.json && { step "npm run types:check" npm run types:check; ran=1; } + grep -q '"test"' package.json && { step "npm test" npm test; ran=1; } +fi + +# PHP / Laravel. +if [ -f composer.json ]; then + grep -q '"lint:check"' composer.json && { step "composer lint:check" composer lint:check; ran=1; } + [ -x ./vendor/bin/pint ] && { step "pint --test" ./vendor/bin/pint --test; ran=1; } + [ -x ./vendor/bin/phpstan ] && { step "phpstan" ./vendor/bin/phpstan analyse --no-progress; ran=1; } + [ -x ./vendor/bin/pest ] && { step "pest" ./vendor/bin/pest; ran=1; } + [ -x ./vendor/bin/phpunit ] && [ ! -x ./vendor/bin/pest ] && { step "phpunit" ./vendor/bin/phpunit; ran=1; } +fi + +# Python. +if [ -f pyproject.toml ]; then + command -v ruff >/dev/null 2>&1 && { step "ruff check" ruff check .; ran=1; } + command -v mypy >/dev/null 2>&1 && { step "mypy" mypy .; ran=1; } + command -v pytest >/dev/null 2>&1 && { step "pytest" pytest; ran=1; } +fi + +# Go. +[ -f go.mod ] && { step "go vet" go vet ./...; step "go test" go test ./...; ran=1; } + +# Rust. +[ -f Cargo.toml ] && { step "cargo check" cargo check; step "cargo test" cargo test; ran=1; } + +# Ruby / Rails. +if [ -f Gemfile ]; then + [ -x ./bin/rubocop ] && { step "rubocop" ./bin/rubocop; ran=1; } + [ -x ./bin/rspec ] && { step "rspec" ./bin/rspec; ran=1; } +fi + +if [ $ran -eq 0 ]; then + echo "harness-check: no sensors fired." + echo " → edit scripts/harness-check.sh to wire your project's lint / types / tests." + echo " → an empty harness-check is treated as PASS so this script doesn't strand Stop." + exit 0 +fi + +echo "✅ harness-check passed" diff --git a/skills/harness/assets/hooks/block-force-push.sh b/skills/harness/assets/hooks/block-force-push.sh new file mode 100644 index 0000000..0918dc7 --- /dev/null +++ b/skills/harness/assets/hooks/block-force-push.sh @@ -0,0 +1,120 @@ +#!/usr/bin/env bash +# PreToolUse hook for Bash. Block destructive operations. +# Reads JSON on stdin: {tool_name, tool_input: {command, ...}, ...} +# Exit 2 = block + tell model why. Exit 0 = allow. +# +# Matching strategy: split the command on shell separators (; && || | newline), +# then for each *segment*, look at its leading words. This avoids false positives +# from strings inside `echo`, `printf`, comments, heredocs, etc. + +set -u +input="$(cat)" +cmd="$(printf '%s' "$input" | python3 -c 'import json,sys +try: + d=json.load(sys.stdin); print(d.get("tool_input",{}).get("command","")) +except Exception: pass' 2>/dev/null || true)" + +[ -z "$cmd" ] && exit 0 + +block() { + printf 'BLOCKED by ~/.claude/hooks/block-force-push.sh\nSegment: %s\nReason: %s\nIf you genuinely need this, ask the user first.\n' "$1" "$2" >&2 + exit 2 +} + +# Split into segments on ; && || | and newlines. Lone & (background) is not +# split — none of the patterns we block are about backgrounding. +segments="$(CLAUDE_HOOK_CMD="$cmd" python3 - <<'PY' +import os, re +src = os.environ.get("CLAUDE_HOOK_CMD","") +out = [] +buf = [] +i = 0 +in_s = None +while i < len(src): + c = src[i] + if in_s: + buf.append(c) + if c == in_s and (i == 0 or src[i-1] != "\\"): + in_s = None + elif c in ("'", '"'): + in_s = c + buf.append(c) + elif c in (";", "\n"): + out.append("".join(buf)); buf = [] + elif c == "&" and i+1 < len(src) and src[i+1] == "&": + out.append("".join(buf)); buf = []; i += 1 + elif c == "|" and i+1 < len(src) and src[i+1] == "|": + out.append("".join(buf)); buf = []; i += 1 + elif c == "|": + out.append("".join(buf)); buf = [] + else: + buf.append(c) + i += 1 +out.append("".join(buf)) +for seg in out: + s = seg.strip() + if s: print(s) +PY +)" + +while IFS= read -r seg; do + [ -z "$seg" ] && continue + + case "$seg" in + echo*|printf*|cat*|"# "*|"#"*) continue ;; + esac + + case "$seg" in *"--force-with-lease"*) continue ;; esac + + if echo "$seg" | grep -Eq '^[[:space:]]*git[[:space:]]+push[[:space:]].*(--force|[[:space:]]-f[[:space:]]).*[[:space:]](main|master|HEAD:main|HEAD:master)([[:space:]]|$)'; then + block "$seg" "force-push to main/master" + fi + if echo "$seg" | grep -Eq '^[[:space:]]*git[[:space:]]+push[[:space:]].*(--force|[[:space:]]-f[[:space:]])'; then + block "$seg" "force-push without --force-with-lease" + fi + # Refspec push-delete: `git push origin :main`, `git push origin :refs/heads/main`. + # The leading colon means "delete" — equivalent to a force-overwrite of nothing + # over the protected branch. Block protected names only, same list as branch -D. + if echo "$seg" | grep -Eq '^[[:space:]]*git[[:space:]]+push[[:space:]]+[^[:space:]]+[[:space:]]+:(refs/heads/)?(main|master|develop|trunk|production|prod|staging|release[/-])'; then + block "$seg" "refspec push-delete of protected branch (push origin :branch)" + fi + # Explicit `--delete` flag. + if echo "$seg" | grep -Eq '^[[:space:]]*git[[:space:]]+push[[:space:]].*--delete[[:space:]]+([^[:space:]]+[[:space:]]+)*(main|master|develop|trunk|production|prod|staging|release[/-])'; then + block "$seg" "git push --delete on protected branch" + fi + # `+` refspec prefix is force without `--force`. Block when target is protected. + if echo "$seg" | grep -Eq '^[[:space:]]*git[[:space:]]+push[[:space:]].*[[:space:]]\+[^[:space:]:]*:(refs/heads/)?(main|master|develop|trunk|production|prod|staging|release[/-])'; then + block "$seg" "force-push via +refspec to protected branch" + fi + if echo "$seg" | grep -Eq '^[[:space:]]*git[[:space:]]+reset[[:space:]]+--hard[[:space:]]+(origin|upstream)/'; then + block "$seg" "hard reset to remote" + fi + if echo "$seg" | grep -Eq '^[[:space:]]*git[[:space:]]+(checkout|restore)[[:space:]]+\.[[:space:]]*$'; then + block "$seg" "wholesale discard of working tree" + fi + # Force-delete branch — only block on protected names. Worktree workflows + # rely on `git branch -D` for completed feature branches, so the rule has + # to be specific. Block: main, master, develop, trunk, production, staging, + # and any release/* or release-* branch. + if echo "$seg" | grep -Eq '^[[:space:]]*git[[:space:]]+branch[[:space:]]+-D[[:space:]]+(main|master|develop|trunk|production|prod|staging|release[/-])'; then + block "$seg" "force-delete protected branch" + fi + if echo "$seg" | grep -Eq '^[[:space:]]*git[[:space:]]+(commit|merge|push|rebase)[[:space:]].*--no-verify'; then + block "$seg" "skipping git hooks (--no-verify)" + fi + # The \$HOME pattern matches the literal string "$HOME" in user shell commands. + # Single quotes preserve the regex unchanged for grep — SC2016 is a false + # positive here (we don't want shell to expand $HOME). + # shellcheck disable=SC2016 + if echo "$seg" | grep -Eq '^[[:space:]]*rm[[:space:]]+(-[rRf]+[[:space:]]+)+(/|~|\$HOME|/\*|~/?\*)([[:space:]]|$)'; then + block "$seg" "rm -rf on \$HOME or /" + fi + if echo "$seg" | grep -Eq '^[[:space:]]*git[[:space:]]+clean[[:space:]].*-f[dx]*[[:space:]]*$'; then + block "$seg" "git clean -fd (destroys untracked work)" + fi + if echo "$seg" | grep -Eq '^[[:space:]]*chmod[[:space:]]+-R[[:space:]]+777'; then + block "$seg" "chmod -R 777" + fi +done <<< "$segments" + +exit 0 diff --git a/skills/harness/assets/hooks/format-on-edit.sh b/skills/harness/assets/hooks/format-on-edit.sh new file mode 100644 index 0000000..221d32e --- /dev/null +++ b/skills/harness/assets/hooks/format-on-edit.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# PostToolUse hook for Write|Edit. Run the project's formatter if available. +# Silent on success, prints output on failure (advisory — non-blocking). + +set -u +cd "$(pwd)" || exit 0 + +run() { "$@" >/dev/null 2>&1 || { echo "format hint: '$*' had non-zero exit"; return 0; }; } + +# Laravel/PHP +if [ -f vendor/bin/pint ]; then + run vendor/bin/pint --quiet +fi + +# Node — only if a format script exists +if [ -f package.json ] && grep -q '"format"' package.json 2>/dev/null; then + if command -v bun >/dev/null 2>&1; then + run bun run format + elif command -v npm >/dev/null 2>&1; then + run npm run format --silent + fi +fi + +# Python — ruff if config exists +if [ -f pyproject.toml ] && grep -q 'ruff' pyproject.toml 2>/dev/null && command -v ruff >/dev/null 2>&1; then + run ruff format . +fi + +# Go +if [ -f go.mod ] && command -v gofmt >/dev/null 2>&1; then + run gofmt -w . +fi + +# Rust +if [ -f Cargo.toml ] && command -v cargo >/dev/null 2>&1; then + run cargo fmt --quiet +fi + +exit 0 diff --git a/skills/harness/assets/hooks/post-compact-reinject.sh b/skills/harness/assets/hooks/post-compact-reinject.sh new file mode 100644 index 0000000..9563009 --- /dev/null +++ b/skills/harness/assets/hooks/post-compact-reinject.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# PostCompact hook. Re-inject the project's CLAUDE.md (and AGENTS.md if present) +# so context-compression doesn't strip the operating contract. +# Output goes to the model. + +set -u + +emit() { + [ -f "$1" ] || return 0 + echo + echo "--- $1 (re-injected after compact) ---" + cat "$1" +} + +emit ./CLAUDE.md +emit ./AGENTS.md +emit ~/.claude/CLAUDE.md + +exit 0 diff --git a/skills/harness/assets/hooks/verify-before-stop.sh b/skills/harness/assets/hooks/verify-before-stop.sh new file mode 100644 index 0000000..8f3e99a --- /dev/null +++ b/skills/harness/assets/hooks/verify-before-stop.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# Stop hook. Refuse to stop if there are uncommitted code changes AND the +# project's verification check fails. Allow stop on read-only sessions +# (no working-tree changes) so investigation/exploration can end naturally. +# +# Override at any time: CLAUDE_SKIP_VERIFY=1 +# Exit 2 = block stop and feed stderr back to the model. + +set -u + +# Explicit opt-out — for sessions where you intentionally want to stop with +# work in progress (e.g., handing off to the user, mid-debug breakpoint). +[ "${CLAUDE_SKIP_VERIFY:-}" = "1" ] && exit 0 + +# Only enforce on sessions that have actually modified the tree. Without this, +# a read-only session ("explain this code", "what does X do") gets trapped if +# pre-existing tests fail. We want the hook to fire when CLAUDE made changes, +# not when the user happens to be in a broken-tree state. +if command -v git >/dev/null 2>&1 && git rev-parse --is-inside-work-tree >/dev/null 2>&1; then + if [ -z "$(git status --porcelain 2>/dev/null)" ]; then + # Clean working tree — nothing to verify. Allow stop. + exit 0 + fi +else + # Not a git repo — be permissive. Verify-on-stop is a sharp tool; only use it + # where we have a clear "code was changed" signal. + exit 0 +fi + +# Working tree is dirty. Run the verification check. +if [ -x ./scripts/harness-check.sh ]; then + if ! out="$(./scripts/harness-check.sh 2>&1)"; then + printf 'Stop blocked: harness-check.sh failed (working tree has changes).\n\n%s\n\nFix the failure, or set CLAUDE_SKIP_VERIFY=1 to stop anyway (e.g. handing off to user).\n' "$out" >&2 + exit 2 + fi + exit 0 +fi + +# Fallback — language-default fast checks. Only run when explicitly available. +if [ -f composer.json ] && grep -q '"lint:check"' composer.json 2>/dev/null; then + if ! out="$(composer lint:check 2>&1)"; then + printf 'Stop blocked: composer lint:check failed (working tree has changes).\n\n%s\n\nSet CLAUDE_SKIP_VERIFY=1 to override.\n' "$out" >&2 + exit 2 + fi +fi + +exit 0 diff --git a/skills/harness/assets/memory/MEMORY.md.tmpl b/skills/harness/assets/memory/MEMORY.md.tmpl new file mode 100644 index 0000000..800aef3 --- /dev/null +++ b/skills/harness/assets/memory/MEMORY.md.tmpl @@ -0,0 +1,8 @@ + +- [User role](user_role.md) — who you are, your stack, how you like to work +- [Concise output](feedback_concise.md) — terse responses, no trailing summaries +- [Plan-first for non-trivial tasks](feedback_plan_first.md) — plan mode before code; one task per conversation +- [Verification gate](feedback_verification.md) — never declare done without running the project's pass/fail check diff --git a/skills/harness/assets/memory/feedback_concise.md.tmpl b/skills/harness/assets/memory/feedback_concise.md.tmpl new file mode 100644 index 0000000..da904a5 --- /dev/null +++ b/skills/harness/assets/memory/feedback_concise.md.tmpl @@ -0,0 +1,11 @@ +--- +name: Concise output +description: Terse responses, no trailing summaries, no narration of internal deliberation +type: feedback +--- + +I want terse output. No trailing "summary of what I just did" — I read the diff. No narration of internal thought process. State results and decisions directly. + +**Why:** Reading dense, well-edited responses is faster than skimming verbose ones. The information density per token is what matters. + +**How to apply:** End-of-turn = one or two sentences max. No headers/sections for simple tasks. No emojis unless asked. When in doubt, cut it. diff --git a/skills/harness/assets/memory/feedback_plan_first.md.tmpl b/skills/harness/assets/memory/feedback_plan_first.md.tmpl new file mode 100644 index 0000000..b70970e --- /dev/null +++ b/skills/harness/assets/memory/feedback_plan_first.md.tmpl @@ -0,0 +1,11 @@ +--- +name: Plan-first for non-trivial tasks +description: Enter plan mode and structure Goal/Constraints/Acceptance before coding; one task per conversation +type: feedback +--- + +For any non-trivial task, enter plan mode before writing code. Structure the plan as Goal / Constraints / Acceptance Criteria. One task per conversation — use `/clear` between tasks rather than continuing in a degraded session. + +**Why:** Plan-first is the convergent recommendation across Cherny, Vincent, Schluntz, Wu. "One task per conversation — starting fresh costs ~20k tokens, trivial vs. quality loss from a degraded session." + +**How to apply:** If a task involves more than one file or has any ambiguity, plan first. For one-line bug fixes, just fix it. The signal is the task's *blast radius*, not its line count. diff --git a/skills/harness/assets/memory/feedback_verification.md.tmpl b/skills/harness/assets/memory/feedback_verification.md.tmpl new file mode 100644 index 0000000..0e7e84a --- /dev/null +++ b/skills/harness/assets/memory/feedback_verification.md.tmpl @@ -0,0 +1,11 @@ +--- +name: Verification gate +description: Never declare done without running the project's pass/fail check; harness is authoritative +type: feedback +--- + +Never claim a task is done without running the project's verification command. If `./scripts/harness-check.sh` exists, that's the canonical check. Otherwise run lint + types + tests for the stack. + +**Why:** The harness is authoritative — Cherny: "the #1 thing to give Claude is a way to verify its work — 2-3× quality." Disabling a check silently is worse than the original bug. + +**How to apply:** Before saying "done" or proposing a commit, run the check. If it fails, fix the code OR amend the rule with a one-line rationale in the same change. Never `--no-verify` to make it pass. diff --git a/skills/harness/assets/memory/user_role.md.tmpl b/skills/harness/assets/memory/user_role.md.tmpl new file mode 100644 index 0000000..ea8c3a9 --- /dev/null +++ b/skills/harness/assets/memory/user_role.md.tmpl @@ -0,0 +1,17 @@ +--- +name: User role +description: My professional context, stack, and preferred working style +type: user +--- + + + +I'm a senior engineer working primarily in . Active projects: +- `/` — +- `/` — + +Default stack: . + +Tooling I use heavily: . + +**Implications:** Frame architectural suggestions in vocabulary I'd recognize. Don't suggest dependency changes without explicit ask. I value token economics — assume I run multiple sessions and care about cache-hit rates. diff --git a/skills/harness/scripts/_detect_stack.py b/skills/harness/scripts/_detect_stack.py new file mode 100755 index 0000000..5a9faaa --- /dev/null +++ b/skills/harness/scripts/_detect_stack.py @@ -0,0 +1,196 @@ +#!/usr/bin/env python3 +""" +Detect the stack of a project from its manifest files. Emits a Markdown bullet +list suitable for dropping into the `## Stack signals` section of CLAUDE.md. + +Usage: _detect_stack.py [project-dir] (defaults to $PWD) + +Reads (best-effort): +- composer.json → PHP/Laravel ecosystem +- package.json → Node/JS/TS ecosystem (incl. React, Next, Vue, Svelte hints) +- pyproject.toml → Python (ruff, mypy, pytest hints) +- go.mod → Go +- Cargo.toml → Rust +- Gemfile → Ruby/Rails +- mix.exs → Elixir/Phoenix + +Output format: one bullet per stack family, plus a few "useful commands" hints +derived from script entries. Never raises — degrades to empty output if no +manifests are found. +""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +import re +import sys + + +def _safe_read(p: Path) -> str | None: + try: + return p.read_text(encoding="utf-8", errors="replace") + except OSError: + return None + + +def _safe_json(p: Path) -> dict | None: + txt = _safe_read(p) + if not txt: + return None + try: + return json.loads(txt) + except json.JSONDecodeError: + return None + + +def _composer_packages(j: dict) -> dict[str, str]: + out: dict[str, str] = {} + for key in ("require", "require-dev"): + for name, ver in (j.get(key) or {}).items(): + if name == "php": + continue + # Strip carets / tildes / wildcards for display. + v = re.sub(r"^[\^~>=<\s]+", "", str(ver)).split("|")[0].strip() + out[name] = v + return out + + +def _node_packages(j: dict) -> dict[str, str]: + out: dict[str, str] = {} + for key in ("dependencies", "devDependencies"): + for name, ver in (j.get(key) or {}).items(): + v = re.sub(r"^[\^~>=<\s]+", "", str(ver)).split("|")[0].strip() + out[name] = v + return out + + +def detect(root: Path) -> list[str]: + bullets: list[str] = [] + + # PHP / Laravel. + composer = _safe_json(root / "composer.json") + if composer: + pkgs = _composer_packages(composer) + php_ver = (composer.get("require") or {}).get("php", "") + php_ver = re.sub(r"^[\^~>=<\s]+", "", php_ver).split("|")[0].strip() + line_parts = [] + if php_ver: + line_parts.append(f"PHP {php_ver}") + if "laravel/framework" in pkgs: + line_parts.append(f"Laravel {pkgs['laravel/framework']}") + if "livewire/livewire" in pkgs: + line_parts.append(f"Livewire {pkgs['livewire/livewire']}") + if "laravel/pint" in pkgs: + line_parts.append("Pint") + if "larastan/larastan" in pkgs or "nunomaduro/larastan" in pkgs: + line_parts.append("Larastan") + if "pestphp/pest" in pkgs: + line_parts.append("Pest") + if "phpunit/phpunit" in pkgs and "pestphp/pest" not in pkgs: + line_parts.append("PHPUnit") + if line_parts: + bullets.append("- " + ", ".join(line_parts)) + scripts = composer.get("scripts") or {} + cmds = [s for s in ("lint:check", "lint", "test", "types:check") if s in scripts] + if cmds: + bullets.append("- Composer scripts: " + ", ".join(f"`composer {c}`" for c in cmds)) + + # Node / JS / TS. + pkgjson = _safe_json(root / "package.json") + if pkgjson: + deps = _node_packages(pkgjson) + line_parts = [] + if "typescript" in deps: + line_parts.append(f"TypeScript {deps['typescript']}") + if "next" in deps: + line_parts.append(f"Next {deps['next']}") + elif "react" in deps: + line_parts.append(f"React {deps['react']}") + if "vue" in deps: + line_parts.append(f"Vue {deps['vue']}") + if "svelte" in deps or "@sveltejs/kit" in deps: + line_parts.append( + "SvelteKit" if "@sveltejs/kit" in deps else f"Svelte {deps['svelte']}" + ) + if "tailwindcss" in deps: + line_parts.append(f"Tailwind {deps['tailwindcss']}") + if "vitest" in deps: + line_parts.append("Vitest") + elif "jest" in deps: + line_parts.append("Jest") + if not line_parts and (deps or pkgjson.get("scripts")): + line_parts.append("Node + npm") + if line_parts: + bullets.append("- " + ", ".join(line_parts)) + scripts = pkgjson.get("scripts") or {} + cmds = [ + s + for s in ("lint:check", "lint", "types:check", "typecheck", "test", "format:check") + if s in scripts + ] + if cmds: + bullets.append("- npm scripts: " + ", ".join(f"`npm run {c}`" for c in cmds)) + + # Python. + pyproj = root / "pyproject.toml" + if pyproj.exists(): + txt = _safe_read(pyproj) or "" + line_parts = ["Python"] + py_match = re.search(r'^\s*requires-python\s*=\s*"([^"]+)"', txt, re.MULTILINE) + if py_match: + line_parts.append(py_match.group(1).strip()) + for tool in ("ruff", "mypy", "pytest", "black"): + if re.search(rf"^\s*\[tool\.{tool}", txt, re.MULTILINE): + line_parts.append(tool) + if "fastapi" in txt.lower(): + line_parts.append("FastAPI") + if "django" in txt.lower(): + line_parts.append("Django") + bullets.append("- " + ", ".join(line_parts)) + + # Go. + if (root / "go.mod").exists(): + txt = _safe_read(root / "go.mod") or "" + m = re.search(r"^go\s+(\S+)", txt, re.MULTILINE) + bullets.append(f"- Go{f' {m.group(1)}' if m else ''}") + + # Rust. + if (root / "Cargo.toml").exists(): + txt = _safe_read(root / "Cargo.toml") or "" + m = re.search(r'^\s*edition\s*=\s*"(\d{4})"', txt, re.MULTILINE) + bullets.append(f"- Rust{f' (edition {m.group(1)})' if m else ''}, cargo") + + # Ruby. + if (root / "Gemfile").exists(): + txt = _safe_read(root / "Gemfile") or "" + line_parts = ["Ruby"] + if "rails" in txt: + line_parts.append("Rails") + if "rspec" in txt: + line_parts.append("RSpec") + bullets.append("- " + ", ".join(line_parts)) + + # Elixir. + if (root / "mix.exs").exists(): + txt = _safe_read(root / "mix.exs") or "" + line_parts = ["Elixir"] + if "phoenix" in txt: + line_parts.append("Phoenix") + bullets.append("- " + ", ".join(line_parts)) + + return bullets + + +def main() -> int: + root = Path(sys.argv[1] if len(sys.argv) > 1 else os.getcwd()).resolve() + bullets = detect(root) + if not bullets: + return 0 # silent — no manifests found + print("\n".join(bullets)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills/harness/scripts/adopt.sh b/skills/harness/scripts/adopt.sh new file mode 100755 index 0000000..70e0064 --- /dev/null +++ b/skills/harness/scripts/adopt.sh @@ -0,0 +1,121 @@ +#!/usr/bin/env bash +# /harness adopt — retrofit harness into an existing project. +# Scaffolds a starter scripts/harness-check.sh — the project-side pass/fail +# gate that verify-before-stop.sh and /verify call — then prints the install +# command to run next. Project scope only. +# +# Usage: +# bash adopt.sh # auto-detect project (CWD or $CLAUDE_PROJECT_DIR) +# bash adopt.sh --target=PATH # explicit project root +# bash adopt.sh --dry-run # show plan, change nothing +# bash adopt.sh --force # overwrite an existing harness-check.sh +set -euo pipefail + +DRY=0 +FORCE=0 +TARGET="" +for arg in "$@"; do + case "$arg" in + --dry-run) DRY=1 ;; + --force) FORCE=1 ;; + --target=*) TARGET="${arg#--target=}" ;; + -h|--help) + awk 'NR>1 { if (/^#/) { sub(/^# ?/, ""); print } else { exit } }' "${BASH_SOURCE[0]}" + exit 0 ;; + *) echo "unknown flag: $arg" >&2; exit 2 ;; + esac +done + +SKILL_DIR="${SKILL_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}" +TEMPLATE="$SKILL_DIR/assets/harness-check.sh.tmpl" +[ -f "$TEMPLATE" ] || { echo "error: template not found at $TEMPLATE" >&2; exit 1; } + +[ -z "$TARGET" ] && TARGET="${CLAUDE_PROJECT_DIR:-$PWD}" + +# Refuse to write into $HOME — adopt is for project scope only. User scope +# already has its own gate (~/.claude/CLAUDE.md says how to run /verify). +if [ "$TARGET" = "$HOME" ]; then + echo "error: refusing to adopt into \$HOME — adopt is for project scope." >&2 + echo " cd into a project, or pass --target=PATH." >&2 + exit 2 +fi + +CHECK_PATH="$TARGET/scripts/harness-check.sh" + +# Detect what's already there so the preflight is honest. +have_claude_md=no; [ -f "$TARGET/CLAUDE.md" ] && have_claude_md=yes +have_claude_dir=no; [ -d "$TARGET/.claude" ] && have_claude_dir=yes +have_check=no; [ -e "$CHECK_PATH" ] && have_check=yes +have_settings=no; [ -f "$TARGET/.claude/settings.json" ] && have_settings=yes + +stack_bullets="" +if command -v python3 >/dev/null 2>&1 && [ -x "$SKILL_DIR/scripts/_detect_stack.py" ]; then + stack_bullets="$("$SKILL_DIR/scripts/_detect_stack.py" "$TARGET" 2>/dev/null || true)" +fi + +cat </claude-setup`) +and have access to Bash, Read, Write, Edit, Glob, Grep, WebFetch, WebSearch, Agent. + +--- + +You are auditing my Claude Code setup against the latest Anthropic releases and Claude Code community best practice. The repo you are running in is a sanitized mirror of `~/.claude/` — see README.md for the layout. + +## Your task + +1. **Inventory the current setup.** Read CLAUDE.md, settings.json, hooks/*.sh, commands/*.md, agents/*.md (if any), memory/MEMORY.md and the linked memory files, plugins/installed_plugins.json, and skills-installed.txt. + +2. **Research what shipped in the last ~30 days.** Use WebFetch + WebSearch on: + - https://platform.claude.com/docs/en/release-notes/overview + - https://code.claude.com/docs/en/changelog + - https://github.com/anthropics/claude-code/blob/main/CHANGELOG.md + - https://www.anthropic.com/news + - https://www.anthropic.com/engineering + +3. **Read the canonical Claude Code voices for new posts.** WebFetch each: + - https://howborisusesclaudecode.com/ + - https://simonwillison.net/tags/claude-code/ + - https://blog.fsck.com/ + - https://ghuntley.com/ + - https://hamel.dev/blog/ + - https://steve-yegge.medium.com/ + +4. **Compare and propose deltas.** For each finding, decide: does this suggest a change to CLAUDE.md / settings.json / hooks / commands / agents / memory / plugins? + +5. **Write the report** to `audits/YYYY-MM-DD-setup-audit.md`. Structure: + + ```markdown + # Setup audit — {{date}} + + ## TL;DR + 3-5 bullets of the highest-leverage changes. + + ## What shipped (last ~30 days) + ### Anthropic + - Dated bullets, source-linked. + ### Community + - Dated bullets, source-linked. + + ## Gaps in current setup + Per surface: what's missing or stale, with the specific change. + + ## Proposed deltas (ordered by leverage) + - **What:** one-line summary + - **Where:** exact file path + - **Diff:** old → new + - **Why:** which release/post motivates this, with link + + ## Skip-list + Things that looked relevant but aren't worth doing. + + ## Sources + ``` + +6. **Open a PR.** Branch `audit/YYYY-MM-DD`, commit `audit: monthly setup audit YYYY-MM-DD`, PR title `Setup audit — {{date}}`, PR body = TL;DR. Use `gh pr create`. + +## Constraints + +- DO NOT modify any tracked file outside `audits/`. +- If a recommendation involves a model migration (deprecation), call it URGENT in TL;DR. +- Prefer specific over comprehensive. 5 items I'll act on > 50 I won't. +- If nothing material shipped, write a short report saying so. Don't pad. +- Concise output — frame in feedforward / sensors / GC vocabulary if I use it. + +Report length target: 800-1500 words. diff --git a/skills/harness/scripts/doctor.sh b/skills/harness/scripts/doctor.sh new file mode 100755 index 0000000..6b48cce --- /dev/null +++ b/skills/harness/scripts/doctor.sh @@ -0,0 +1,219 @@ +#!/usr/bin/env bash +# Diagnose a harness install end-to-end. +# Combines status + sanity checks: hashing tool, write permissions, hook script +# is executable AND fires correctly when invoked, no conflicting hook entries +# pointing elsewhere, settings.json is parseable, memory dir exists at user scope. +# +# Usage: +# bash doctor.sh # auto-detect scope +# bash doctor.sh --scope=user|project +# bash doctor.sh --target=PATH + +set -u + +SCOPE="" +TARGET="" +for arg in "$@"; do + case "$arg" in + --scope=user) SCOPE=user ;; + --scope=project) SCOPE=project ;; + --scope=*) echo "invalid --scope: $arg" >&2; exit 2 ;; + --target=*) TARGET="${arg#--target=}" ;; + *) echo "unknown flag: $arg" >&2; exit 2 ;; + esac +done + +SKILL_DIR="${SKILL_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}" +ASSETS="$SKILL_DIR/assets" +[ -d "$ASSETS" ] || { echo "error: $ASSETS not found" >&2; exit 1; } + +detect_scope_root() { + local d="$SKILL_DIR" + while [ "$d" != "/" ] && [ -n "$d" ]; do + [ "$(basename "$d")" = ".claude" ] && { dirname "$d"; return 0; } + d="$(dirname "$d")" + done + return 1 +} + +if [ -n "$TARGET" ]; then : +elif [ "$SCOPE" = "user" ]; then TARGET="$HOME/.claude" +elif [ "$SCOPE" = "project" ]; then TARGET="${CLAUDE_PROJECT_DIR:-$PWD}/.claude" +else + if scope_root="$(detect_scope_root)"; then + TARGET="$scope_root/.claude" + [ "$scope_root" = "$HOME" ] && SCOPE=user || SCOPE=project + else + echo "error: cannot auto-detect scope; pass --scope or --target" >&2 + exit 2 + fi +fi +[ -z "$SCOPE" ] && { [ "$TARGET" = "$HOME/.claude" ] && SCOPE=user || SCOPE=project; } + +green() { printf '\033[32m%s\033[0m' "$1"; } +yellow() { printf '\033[33m%s\033[0m' "$1"; } +red() { printf '\033[31m%s\033[0m' "$1"; } +if ! [ -t 1 ]; then green() { printf '%s' "$1"; }; yellow() { printf '%s' "$1"; }; red() { printf '%s' "$1"; }; fi + +PASS=0; WARN=0; FAIL=0 +ok() { printf ' [%s] %s\n' "$(green ' OK ')" "$*"; PASS=$((PASS+1)); } +warn() { printf ' [%s] %s\n' "$(yellow ' WARN ')" "$*"; WARN=$((WARN+1)); } +bad() { printf ' [%s] %s\n' "$(red ' FAIL ')" "$*"; FAIL=$((FAIL+1)); } + +echo "harness doctor — scope=$SCOPE target=$TARGET" +echo + +# 1. Hashing tool available. +if command -v sha256sum >/dev/null 2>&1 || command -v shasum >/dev/null 2>&1 || command -v python3 >/dev/null 2>&1; then + ok "sha256 tool available" +else + bad "no sha256 tool (need sha256sum, shasum, or python3) — content-match checks won't work" +fi + +# 1b. python3 (used by JSON parsing + hook-wiring check below). +HAVE_PY=0 +if command -v python3 >/dev/null 2>&1; then + HAVE_PY=1 + ok "python3 available" +else + bad "no python3 — settings.json parsing + hook-wiring checks will be skipped" +fi + +# 2. Target writable. +if [ -w "$TARGET" ] || [ ! -e "$TARGET" ]; then + ok "target dir is writable: $TARGET" +else + bad "target dir is not writable: $TARGET" +fi + +# 3. settings.json parseable. +SETTINGS="$TARGET/settings.json" +if [ ! -f "$SETTINGS" ]; then + warn "no settings.json at $SETTINGS — install.sh will create one" +elif [ $HAVE_PY -eq 0 ]; then + warn "settings.json present but python3 missing — skipping JSON validity check" +elif python3 -m json.tool < "$SETTINGS" >/dev/null 2>&1; then + ok "settings.json is valid JSON" +else + bad "settings.json is not valid JSON — Claude Code will refuse to load it" +fi + +# 4. Hook scripts exist and are executable; foreign hooks pointing elsewhere flagged. +say_hook() { printf ' %s\n' "$1"; } +if [ -f "$SETTINGS" ]; then + for name in block-force-push.sh format-on-edit.sh post-compact-reinject.sh verify-before-stop.sh; do + f="$TARGET/hooks/$name" + if [ -x "$f" ]; then + ok "hook executable: $name" + elif [ -e "$f" ]; then + warn "hook not executable (need chmod +x): $f" + else + warn "hook missing on disk: $f" + fi + done + + # Check for hook entries in settings.json that point at *unexpected* paths. + if [ $HAVE_PY -eq 0 ]; then + warn "skipping settings.json hook-wiring check (python3 not available)" + else + SETTINGS="$SETTINGS" python3 - <<'PY' || true +import json, os, sys +p = os.environ["SETTINGS"] +try: + s = json.load(open(p)) +except Exception as e: + print(f" [ FAIL ] settings.json parse error: {e}") + sys.exit(0) +hooks = s.get("hooks", {}) +expected_names = { + "block-force-push.sh", "format-on-edit.sh", + "post-compact-reinject.sh", "verify-before-stop.sh", +} +seen = set() +for event, blocks in hooks.items(): + for b in blocks: + for h in b.get("hooks", []): + cmd = h.get("command", "") + for n in expected_names: + if n in cmd: + seen.add(n) + if "harness" not in cmd and ".claude/hooks/" not in cmd: + print(f" [ WARN ] hook entry '{event}' for {n} points at unexpected path: {cmd}") +missing = expected_names - seen +if missing: + for n in sorted(missing): + print(f" [ WARN ] no settings.json entry references {n} — hook won't fire") +else: + print(f" [ OK ] all 4 hooks wired in settings.json") +PY + fi +fi + +# 5. Hook smoke-test: invoke block-force-push with a known-bad command. +HOOK="$TARGET/hooks/block-force-push.sh" +if [ -x "$HOOK" ]; then + if echo '{"tool_name":"Bash","tool_input":{"command":"git push --force origin main"}}' \ + | "$HOOK" >/dev/null 2>&1; then + bad "block-force-push.sh did NOT block 'git push --force origin main' (should exit 2)" + else + rc=$? + if [ $rc -eq 2 ]; then + ok "block-force-push.sh fires correctly on dangerous input" + else + warn "block-force-push.sh exited $rc (expected 2); behavior unclear" + fi + fi +fi + +# 6. Memory dir at user scope. +if [ "$SCOPE" = "user" ]; then + USER_PROJECT_KEY="${USER_PROJECT_KEY:-$(printf '%s' "$HOME" | tr '/' '-')}" + MEMORY_DIR="$HOME/.claude/projects/$USER_PROJECT_KEY/memory" + if [ -d "$MEMORY_DIR" ]; then + if [ -f "$MEMORY_DIR/MEMORY.md" ]; then + ok "memory store populated at $MEMORY_DIR" + else + warn "memory dir exists but no MEMORY.md index" + fi + else + warn "no memory dir at $MEMORY_DIR — run install.sh" + fi +fi + +# 7. CLAUDE.md present + Stack signals filled. +if [ "$SCOPE" = "user" ]; then + CLAUDE_MD_PATH="$TARGET/CLAUDE.md" +else + CLAUDE_MD_PATH="$(dirname "$TARGET")/CLAUDE.md" +fi +if [ -f "$CLAUDE_MD_PATH" ]; then + if grep -q '## Stack signals' "$CLAUDE_MD_PATH" 2>/dev/null; then + if grep -A1 '^## Stack signals' "$CLAUDE_MD_PATH" | grep -q 'Replace with your default'; then + warn "CLAUDE.md still has placeholder Stack signals — fill it in" + else + ok "CLAUDE.md present and Stack signals look filled" + fi + else + ok "CLAUDE.md present (no Stack signals heading — custom format)" + fi +else + warn "no CLAUDE.md at $CLAUDE_MD_PATH" +fi + +# 8. Snapshot repo (if env points at one) — last commit recency. +if [ -n "${SNAPSHOT_REPO:-}" ] && [ -d "$SNAPSHOT_REPO/.git" ]; then + if [ -d "$SNAPSHOT_REPO/.git" ]; then + cd "$SNAPSHOT_REPO" || true + days_old=$(($(date +%s) - $(git log -1 --format=%ct 2>/dev/null || echo 0))) + days_old=$((days_old / 86400)) + if [ "$days_old" -gt 60 ]; then + warn "snapshot repo last commit is $days_old days old — run snapshot.sh" + else + ok "snapshot repo last commit: $days_old days ago" + fi + fi +fi + +echo +echo "Summary: $(green "$PASS pass"), $(yellow "$WARN warn"), $(red "$FAIL fail")" +[ $FAIL -gt 0 ] && exit 1 || exit 0 diff --git a/skills/harness/scripts/install.sh b/skills/harness/scripts/install.sh new file mode 100755 index 0000000..baf2333 --- /dev/null +++ b/skills/harness/scripts/install.sh @@ -0,0 +1,429 @@ +#!/usr/bin/env bash +# Idempotent installer for the harness skill. +# +# Scope-agnostic: targets either user scope (~/.claude/) or project scope +# (/.claude/). Default is auto-detected from $SKILL_DIR — if the skill +# lives at /.claude/skills/harness/ (or under a plugin cache below /.claude/), +# the install lands at /.claude/. +# +# Usage: +# bash install.sh # auto-detect scope; install everything +# bash install.sh --scope=user # force user scope ($HOME/.claude) +# bash install.sh --scope=project # force project scope ($CLAUDE_PROJECT_DIR or $PWD) +# bash install.sh --target=PATH # explicit target dir (PATH should end in .claude) +# bash install.sh --force # overwrite existing CLAUDE.md / hooks / commands / memory +# bash install.sh --dry-run # show what would be done; change nothing +# +# Per-surface skip flags (pick what NOT to install): +# --skip-claude-md --skip-hooks --skip-commands --skip-memory --skip-settings +# +# Or a positive list (everything not listed is skipped): +# --include=hooks,commands # install only hooks + commands +# --include=claude-md,settings # only the operating contract + settings.json patch +# +# At project scope, memory is NOT seeded (memory is per-user by design and lives +# under $HOME regardless of where the project's .claude/ is). CLAUDE.md is +# skipped if a project CLAUDE.md already exists — most projects have one. + +set -euo pipefail + +FORCE=0 +DRY=0 +# Per-surface skip flags (pick what NOT to install). Defaults: install everything +# applicable to the scope. --include=LIST flips this to a positive list. +SKIP_CLAUDE_MD=0 +SKIP_HOOKS=0 +SKIP_COMMANDS=0 +SKIP_MEMORY=0 +SKIP_SETTINGS=0 +INCLUDE="" +SCOPE="" +TARGET="" +for arg in "$@"; do + case "$arg" in + --force) FORCE=1 ;; + --dry-run) DRY=1 ;; + --skip-claude-md) SKIP_CLAUDE_MD=1 ;; + --skip-hooks) SKIP_HOOKS=1 ;; + --skip-commands) SKIP_COMMANDS=1 ;; + --skip-memory) SKIP_MEMORY=1 ;; + --skip-settings) SKIP_SETTINGS=1 ;; + --include=*) INCLUDE="${arg#--include=}" ;; + --scope=user) SCOPE=user ;; + --scope=project) SCOPE=project ;; + --scope=*) echo "invalid --scope (use user|project): $arg" >&2; exit 2 ;; + --target=*) TARGET="${arg#--target=}" ;; + *) echo "unknown flag: $arg" >&2; exit 2 ;; + esac +done + +# --include=LIST is shorthand: skip everything not in the comma-separated list. +# Valid items: claude-md, hooks, commands, memory, settings. +if [ -n "$INCLUDE" ]; then + SKIP_CLAUDE_MD=1; SKIP_HOOKS=1; SKIP_COMMANDS=1; SKIP_MEMORY=1; SKIP_SETTINGS=1 + IFS=',' read -ra _items <<< "$INCLUDE" + for it in "${_items[@]}"; do + case "$it" in + claude-md) SKIP_CLAUDE_MD=0 ;; + hooks) SKIP_HOOKS=0 ;; + commands) SKIP_COMMANDS=0 ;; + memory) SKIP_MEMORY=0 ;; + settings) SKIP_SETTINGS=0 ;; + *) echo "invalid --include item: $it (valid: claude-md, hooks, commands, memory, settings)" >&2; exit 2 ;; + esac + done +fi + +SKILL_DIR="${SKILL_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}" +ASSETS="$SKILL_DIR/assets" +[ -d "$ASSETS" ] || { echo "error: $ASSETS not found" >&2; exit 1; } + +# Detect scope from $SKILL_DIR by walking up looking for a `.claude` ancestor. +# If found, the parent of that .claude is the scope root. We then check whether +# the scope root is $HOME (user scope) or something else (project scope). +detect_scope_root() { + local d="$SKILL_DIR" + while [ "$d" != "/" ] && [ -n "$d" ]; do + if [ "$(basename "$d")" = ".claude" ]; then + dirname "$d" + return 0 + fi + d="$(dirname "$d")" + done + return 1 +} + +# Resolve TARGET (the .claude/ directory we install into). +if [ -n "$TARGET" ]; then + # Explicit path. Trust the caller. + : +elif [ "$SCOPE" = "user" ]; then + TARGET="$HOME/.claude" +elif [ "$SCOPE" = "project" ]; then + TARGET="${CLAUDE_PROJECT_DIR:-$PWD}/.claude" +else + # Auto-detect from $SKILL_DIR. + if scope_root="$(detect_scope_root)"; then + TARGET="$scope_root/.claude" + if [ "$scope_root" = "$HOME" ]; then SCOPE=user; else SCOPE=project; fi + else + cat >&2 </dev/null || true)" + if [ -z "$detected" ]; then + return 0 # no manifests — leave the placeholder in place for the user to fill + fi + # Replace the placeholder block (the HTML comment "Replace with your default + # stack..." + its example block) with the detected bullets. Use python for + # robust multi-line replacement. DETECT_ROOT is exported here so the log + # line is accurate regardless of caller convention. + CLAUDE_MD="$md" DETECTED="$detected" DETECT_ROOT="$detect_root" python3 - <<'PY' +import os, re +p = os.environ["CLAUDE_MD"] +detected = os.environ["DETECTED"].rstrip() +text = open(p).read() +# Match: through the closing --> +# of the example block. Be flexible about exact whitespace. +pattern = re.compile( + r"\s*\n" + r"(\s*\n)?", + re.DOTALL, +) +new = pattern.sub(detected + "\n", text, count=1) +if new != text: + open(p, "w").write(new) + print(f" auto-filled Stack signals from manifests in {os.environ['DETECT_ROOT']}") +PY +} + +if [ $SKIP_CLAUDE_MD -eq 1 ]; then + say "skipping CLAUDE.md (--skip-claude-md)" +elif [ "$SCOPE" = "user" ]; then + say "installing global CLAUDE.md" + copy_safe "$ASSETS/CLAUDE.md.tmpl" "$TARGET/CLAUDE.md" + # At user scope, scan the user's home for a top-level manifest. Usually + # there isn't one — the section will stay as a placeholder for hand-edit. + # But if the user keeps a default-project at $HOME, this picks it up. + [ $DRY -eq 0 ] && fill_stack_signals "$TARGET/CLAUDE.md" "$HOME" +else + PROJECT_ROOT="$(dirname "$TARGET")" + PROJECT_CLAUDE_MD="$PROJECT_ROOT/CLAUDE.md" + if [ -e "$PROJECT_CLAUDE_MD" ] && [ $FORCE -eq 0 ]; then + say "skipping CLAUDE.md (project already has one at $PROJECT_CLAUDE_MD — merge by hand, or --force)" + else + say "installing project CLAUDE.md" + copy_safe "$ASSETS/CLAUDE.md.tmpl" "$PROJECT_CLAUDE_MD" + [ $DRY -eq 0 ] && fill_stack_signals "$PROJECT_CLAUDE_MD" "$PROJECT_ROOT" + fi +fi + +# 3. Hooks. +if [ $SKIP_HOOKS -eq 1 ]; then + say "skipping hooks (--skip-hooks)" +else + say "installing hooks" + for f in "$ASSETS/hooks/"*.sh; do + name="$(basename "$f")" + copy_safe "$f" "$TARGET/hooks/$name" + do_or_dry chmod +x "$TARGET/hooks/$name" + done +fi + +# 4. Commands. +if [ $SKIP_COMMANDS -eq 1 ]; then + say "skipping slash commands (--skip-commands)" +else + say "installing slash commands" + for f in "$ASSETS/commands/"*.md; do + name="$(basename "$f")" + copy_safe "$f" "$TARGET/commands/$name" + done +fi + +# 5. Memory templates — user scope only (memory is per-user by design). +if [ "$SCOPE" != "user" ]; then + say "skipping memory (project scope; memory is per-user)" +elif [ $SKIP_MEMORY -eq 1 ]; then + say "skipping memory (--skip-memory)" +else + say "installing memory templates" + for f in "$ASSETS/memory/"*.tmpl; do + name="$(basename "$f" .tmpl)" + copy_safe "$f" "$MEMORY_DIR/$name" + done +fi + +# 6. Patch settings.json — add env var + hooks blocks if missing. +if [ $SKIP_SETTINGS -eq 1 ]; then + say "skipping settings.json (--skip-settings)" +else + say "patching settings.json" + SETTINGS="$TARGET/settings.json" + if [ ! -f "$SETTINGS" ]; then + if [ $DRY -eq 1 ]; then + echo " [dry-run] would create empty $SETTINGS" + else + printf '{}\n' > "$SETTINGS" + fi + fi + if [ $DRY -eq 0 ]; then + SETTINGS="$SETTINGS" SCOPE="$SCOPE" HOOK_CMD_BASE="$HOOK_CMD_BASE" python3 - <<'PY' +import json, os +p = os.environ["SETTINGS"] +scope = os.environ["SCOPE"] +base = os.environ["HOOK_CMD_BASE"] + +with open(p) as f: + s = json.load(f) + +changed = False + +# CLAUDE_CODE_AUTO_COMPACT_WINDOW only makes sense at user scope (it's a +# session-wide knob). At project scope, leave the env block alone — projects +# shouldn't override the user's compact window. +if scope == "user": + env = s.setdefault("env", {}) + if env.get("CLAUDE_CODE_AUTO_COMPACT_WINDOW") != "400000": + env["CLAUDE_CODE_AUTO_COMPACT_WINDOW"] = "400000" + changed = True + +hooks = s.setdefault("hooks", {}) + +OUR_HOOKS = [ + ("PreToolUse", "Bash", f"{base}/block-force-push.sh"), + ("PostToolUse", "Write|Edit", f"{base}/format-on-edit.sh"), + ("PostCompact", None, f"{base}/post-compact-reinject.sh"), + ("Stop", None, f"{base}/verify-before-stop.sh"), +] + +def ensure_hook(event, matcher, cmd): + blocks = hooks.setdefault(event, []) + for b in blocks: + if b.get("matcher") == matcher: + for h in b.get("hooks", []): + if h.get("command") == cmd: + return False + b.setdefault("hooks", []).append({"type": "command", "command": cmd}) + return True + block = {"hooks": [{"type": "command", "command": cmd}]} + if matcher is not None: + block["matcher"] = matcher + blocks.append(block) + return True + +for event, matcher, cmd in OUR_HOOKS: + if ensure_hook(event, matcher, cmd): + changed = True + +# Atomic write: tmp file in the same dir, then rename. Avoids leaving an +# empty/partial settings.json if the process is interrupted mid-write — +# Claude Code refuses to load invalid JSON. +tmp = p + ".tmp" +with open(tmp, "w") as f: + json.dump(s, f, indent=2) + f.write("\n") +os.replace(tmp, p) +print(" settings.json updated" if changed else " settings.json already current") +PY + else + echo " [dry-run] would patch $SETTINGS with $([ "$SCOPE" = user ] && echo 'env + ')4 hooks" + fi +fi + +echo +say "done" +echo +echo "Next steps:" +if [ "$SCOPE" = "user" ]; then + echo " 1. Edit $TARGET/CLAUDE.md — fill in the 'Stack signals' section." + echo " 2. Edit $MEMORY_DIR/user_role.md — replace placeholders with your actual context." +else + echo " 1. Review $(dirname "$TARGET")/CLAUDE.md (or merge with your existing one)." + echo " 2. Decide whether to commit $TARGET/settings.json (shared) or move the hook block" + echo " to $TARGET/settings.local.json (personal)." +fi +echo " 3. Restart Claude Code (or open a new session) — hooks load on session start." +echo " 4. Optional: run scripts/snapshot.sh to mirror $TARGET into a private git repo" +echo " and use scripts/audit-prompt.md to schedule a monthly remote audit." diff --git a/skills/harness/scripts/snapshot.sh b/skills/harness/scripts/snapshot.sh new file mode 100755 index 0000000..27730b4 --- /dev/null +++ b/skills/harness/scripts/snapshot.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash +# Mirror ~/.claude/ into a target git repo (sanitised), commit, and push if there's a diff. +# Use this to keep a versioned snapshot of your harness for the monthly audit routine. +# +# Usage: +# SNAPSHOT_REPO=~/Projects//claude-setup bash snapshot.sh +# +# Override sources with env: +# CLAUDE_DIR=/some/path bash snapshot.sh +# USER_PROJECT_KEY=-Users-foo bash snapshot.sh + +set -euo pipefail + +CLAUDE_DIR="${CLAUDE_DIR:-$HOME/.claude}" +USER_PROJECT_KEY="${USER_PROJECT_KEY:-$(printf '%s' "$HOME" | tr '/' '-')}" +MEMORY_SRC="$CLAUDE_DIR/projects/$USER_PROJECT_KEY/memory" + +if [ -z "${SNAPSHOT_REPO:-}" ]; then + echo "error: SNAPSHOT_REPO must be set to the target repo path" >&2 + echo " e.g. SNAPSHOT_REPO=~/Projects/you/claude-setup bash snapshot.sh" >&2 + exit 2 +fi + +REPO_ROOT="$(cd "$SNAPSHOT_REPO" 2>/dev/null && pwd)" || { + echo "error: $SNAPSHOT_REPO does not exist (mkdir + git init it first)" >&2 + exit 2 +} + +cd "$REPO_ROOT" +[ -d .git ] || { echo "error: $REPO_ROOT is not a git repo" >&2; exit 2; } + +# 1. Wipe sync targets (preserves audits/, .git/, README.md, .gitignore, scripts/). +for d in hooks commands agents memory plugins; do + rm -rf "${REPO_ROOT:?}/$d" + mkdir -p "$d" +done +rm -f CLAUDE.md settings.json skills-installed.txt + +# 2. Mirror. +[ -f "$CLAUDE_DIR/CLAUDE.md" ] && cp "$CLAUDE_DIR/CLAUDE.md" ./CLAUDE.md +[ -f "$CLAUDE_DIR/settings.json" ] && cp "$CLAUDE_DIR/settings.json" ./settings.json + +shopt -s nullglob +for f in "$CLAUDE_DIR/hooks/"*.sh; do cp "$f" ./hooks/; done +for f in "$CLAUDE_DIR/commands/"*.md; do cp "$f" ./commands/; done +for f in "$CLAUDE_DIR/agents/"*.md; do cp "$f" ./agents/; done +shopt -u nullglob + +if [ -d "$MEMORY_SRC" ]; then + find "$MEMORY_SRC" -maxdepth 1 -type f \( -name '*.md' -o -name 'MEMORY.md' \) \ + -exec cp {} ./memory/ \; +fi +if [ -f "$CLAUDE_DIR/plugins/installed_plugins.json" ]; then + cp "$CLAUDE_DIR/plugins/installed_plugins.json" ./plugins/installed_plugins.json +fi +if [ -d "$CLAUDE_DIR/skills" ]; then + ls "$CLAUDE_DIR/skills" > ./skills-installed.txt +fi + +# 3. Drop empty dirs. +for d in hooks commands agents memory plugins; do + if [ -d "$d" ] && [ -z "$(ls -A "$d" 2>/dev/null)" ]; then + rmdir "$d" + fi +done + +# 4. Secret scan. +SECRET_PATTERNS='(sk-ant-|ghp_|gho_|ghu_|AIza[0-9A-Za-z_-]{35}|AKIA[0-9A-Z]{16}|xox[baprs]-[0-9A-Za-z-]{10,}|-----BEGIN [A-Z ]*PRIVATE KEY-----)' +if grep -rEln --exclude-dir=.git --exclude-dir=node_modules --exclude-dir=__pycache__ \ + "$SECRET_PATTERNS" . 2>/dev/null \ + | grep -vE '^(\./)?scripts/snapshot\.sh$'; then + echo "abort: potential secret detected — review above and re-run after scrubbing" >&2 + exit 1 +fi + +# 5. Commit + push. +git add -A +if git diff --cached --quiet; then + echo "snapshot: no changes" + exit 0 +fi +ts="$(date -u +%Y-%m-%dT%H:%M:%SZ)" +summary="$(git diff --cached --stat | tail -n 1)" +git -c commit.gpgsign=false commit -q -m "snapshot: refresh ~/.claude/ — $ts + +$summary" +if git remote get-url origin >/dev/null 2>&1; then + git push -q origin HEAD + echo "snapshot: pushed" +else + echo "snapshot: committed locally (no remote)" +fi diff --git a/skills/harness/scripts/status.sh b/skills/harness/scripts/status.sh new file mode 100755 index 0000000..2c861a9 --- /dev/null +++ b/skills/harness/scripts/status.sh @@ -0,0 +1,220 @@ +#!/usr/bin/env bash +# Status reporter for the harness skill. Read-only. +# Reports for each surface: installed (matches template) / modified / missing. +# +# Scope-agnostic: same auto-detection as install.sh / uninstall.sh. +# bash status.sh # auto-detect +# bash status.sh --scope=user # force user scope +# bash status.sh --scope=project +# bash status.sh --target=PATH # explicit .claude/ path + +set -u + +SCOPE="" +TARGET="" +for arg in "$@"; do + case "$arg" in + --scope=user) SCOPE=user ;; + --scope=project) SCOPE=project ;; + --scope=*) echo "invalid --scope (use user|project): $arg" >&2; exit 2 ;; + --target=*) TARGET="${arg#--target=}" ;; + *) echo "unknown flag: $arg" >&2; exit 2 ;; + esac +done + +SKILL_DIR="${SKILL_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}" +ASSETS="$SKILL_DIR/assets" +[ -d "$ASSETS" ] || { echo "error: $ASSETS not found" >&2; exit 1; } + +detect_scope_root() { + local d="$SKILL_DIR" + while [ "$d" != "/" ] && [ -n "$d" ]; do + if [ "$(basename "$d")" = ".claude" ]; then + dirname "$d" + return 0 + fi + d="$(dirname "$d")" + done + return 1 +} + +if [ -n "$TARGET" ]; then + : +elif [ "$SCOPE" = "user" ]; then + TARGET="$HOME/.claude" +elif [ "$SCOPE" = "project" ]; then + TARGET="${CLAUDE_PROJECT_DIR:-$PWD}/.claude" +else + if scope_root="$(detect_scope_root)"; then + TARGET="$scope_root/.claude" + if [ "$scope_root" = "$HOME" ]; then SCOPE=user; else SCOPE=project; fi + else + cat >&2 </dev/null 2>&1; then + sha256sum "$f" 2>/dev/null | awk '{print $1}' + elif command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$f" 2>/dev/null | awk '{print $1}' + elif command -v python3 >/dev/null 2>&1; then + python3 -c 'import hashlib,sys; print(hashlib.sha256(open(sys.argv[1],"rb").read()).hexdigest())' "$f" 2>/dev/null + else + return 1 + fi +} + +report() { + local installed="$1" template="$2" label="$3" + local lhs rhs status + if [ ! -e "$installed" ]; then + status="$(red missing) " + elif [ -f "$template" ]; then + if lhs="$(sha "$installed")" && [ -n "$lhs" ] \ + && rhs="$(sha "$template")" && [ -n "$rhs" ]; then + if [ "$lhs" = "$rhs" ]; then + status="$(green installed) " + else + status="$(yellow modified) " + fi + else + status="$(red 'cannot hash') " + fi + else + status="$(green present) " + fi + printf ' %s %s %s\n' "$status" "$label" "$(dim "$installed")" +} + +echo "harness status — scope=$SCOPE target=$TARGET" +echo + +echo "CLAUDE.md" +report "$CLAUDE_MD_PATH" "$ASSETS/CLAUDE.md.tmpl" "CLAUDE.md" + +echo +echo "hooks" +for f in "$ASSETS/hooks/"*.sh; do + name="$(basename "$f")" + report "$TARGET/hooks/$name" "$f" "$name" +done + +echo +echo "commands" +for f in "$ASSETS/commands/"*.md; do + name="$(basename "$f")" + report "$TARGET/commands/$name" "$f" "$name" +done + +if [ "$SCOPE" = "user" ]; then + echo + echo "memory (user scope)" + for f in "$ASSETS/memory/"*.tmpl; do + name="$(basename "$f" .tmpl)" + report "$MEMORY_DIR/$name" "$f" "$name" + done +fi + +echo +echo "settings.json" +SETTINGS="$TARGET/settings.json" +if [ ! -f "$SETTINGS" ]; then + printf ' %s no settings.json\n' "$(red 'missing ')" +elif ! command -v python3 >/dev/null 2>&1; then + printf ' %s python3 not on PATH — settings check skipped\n' "$(yellow 'unknown ')" +else + SETTINGS="$SETTINGS" SCOPE="$SCOPE" HOOK_CMD_BASE="$HOOK_CMD_BASE" python3 - <<'PY' || printf ' %s settings.json: parse error or missing key\n' "cannot read " +import json, sys, os + +p = os.environ["SETTINGS"] +scope = os.environ["SCOPE"] +base = os.environ["HOOK_CMD_BASE"] +try: + with open(p) as f: + s = json.load(f) +except (OSError, json.JSONDecodeError) as e: + print(f" cannot parse settings.json: {e}") + sys.exit(1) + +OUR_CMDS = { + "PreToolUse": f"{base}/block-force-push.sh", + "PostToolUse": f"{base}/format-on-edit.sh", + "PostCompact": f"{base}/post-compact-reinject.sh", + "Stop": f"{base}/verify-before-stop.sh", +} + +is_tty = sys.stdout.isatty() +def green(s): return f"\033[32m{s}\033[0m" if is_tty else s +def red(s): return f"\033[31m{s}\033[0m" if is_tty else s +def dim(s): return f"\033[90m{s}\033[0m" if is_tty else s + +def fmt(label, colour): + return colour(label) + (" " * max(0, 12 - len(label))) + +hooks = s.get("hooks", {}) +for event, cmd in OUR_CMDS.items(): + found = any( + h.get("command") == cmd + for b in hooks.get(event, []) + for h in b.get("hooks", []) + ) + label = fmt("wired", green) if found else fmt("missing", red) + print(f" {label} {event} -> {dim(cmd)}") + +# Env var only meaningful at user scope. +if scope == "user": + env = s.get("env", {}) + v = env.get("CLAUDE_CODE_AUTO_COMPACT_WINDOW") + if v is not None: + print(f" {fmt('set', green)} env.CLAUDE_CODE_AUTO_COMPACT_WINDOW = {dim(str(v))}") + else: + print(f" {fmt('missing', red)} env.CLAUDE_CODE_AUTO_COMPACT_WINDOW") +PY +fi + +if [ -n "${SNAPSHOT_REPO:-}" ] && [ -d "$SNAPSHOT_REPO/.git" ]; then + echo + echo "snapshot ($SNAPSHOT_REPO)" + cd "$SNAPSHOT_REPO" || { echo " (cannot cd into $SNAPSHOT_REPO)"; exit 0; } + last_commit="$(git log -1 --format='%cr %s' 2>/dev/null || echo unknown)" + ahead="$(git rev-list --count '@{upstream}..HEAD' 2>/dev/null || echo '?')" + behind="$(git rev-list --count 'HEAD..@{upstream}' 2>/dev/null || echo '?')" + printf ' %s last commit: %s\n' "$(dim '·')" "$last_commit" + printf ' %s ahead/behind origin: %s/%s\n' "$(dim '·')" "$ahead" "$behind" +fi + +echo diff --git a/skills/harness/scripts/uninstall.sh b/skills/harness/scripts/uninstall.sh new file mode 100755 index 0000000..d28dbd8 --- /dev/null +++ b/skills/harness/scripts/uninstall.sh @@ -0,0 +1,351 @@ +#!/usr/bin/env bash +# Uninstaller for the harness skill. +# +# Scope-agnostic: targets either user scope (~/.claude/) or project scope +# (/.claude/). Default is auto-detected from $SKILL_DIR — same logic +# as install.sh. Conservative: only removes files whose content still matches +# the installed template; user-modified files are kept. +# +# Usage: +# bash uninstall.sh # auto-detect scope; remove unmodified hooks + commands + hook-entries +# bash uninstall.sh --scope=user # force user scope +# bash uninstall.sh --scope=project # force project scope +# bash uninstall.sh --target=PATH # explicit .claude/ target dir +# bash uninstall.sh --dry-run # show what would happen; remove nothing +# bash uninstall.sh --force # remove hooks/commands even if user-modified +# +# Opt in to wider removal (default keeps these): +# --remove-memory # remove auto-memory entries (user scope only) +# --remove-claude-md # remove CLAUDE.md (content-match policy) +# --remove-env # remove CLAUDE_CODE_AUTO_COMPACT_WINDOW env (user scope only) +# --all # = --force + --remove-memory + --remove-claude-md + --remove-env +# +# Or keep specific surfaces that the default WOULD remove: +# --keep-hooks # leave hook .sh files alone +# --keep-commands # leave slash command .md files alone +# --keep-settings # leave settings.json untouched (don't strip hook entries) + +set -euo pipefail + +FORCE=0 +DRY=0 +REMOVE_MEMORY=0 +REMOVE_CLAUDE_MD=0 +REMOVE_ENV=0 +# Per-surface keep flags (escape hatch — keep these regardless of defaults). +KEEP_HOOKS=0 +KEEP_COMMANDS=0 +KEEP_SETTINGS=0 +SCOPE="" +TARGET="" +for arg in "$@"; do + case "$arg" in + --force) FORCE=1 ;; + --dry-run) DRY=1 ;; + --remove-memory) REMOVE_MEMORY=1 ;; + --remove-claude-md) REMOVE_CLAUDE_MD=1 ;; + --remove-env) REMOVE_ENV=1 ;; + --keep-hooks) KEEP_HOOKS=1 ;; + --keep-commands) KEEP_COMMANDS=1 ;; + --keep-settings) KEEP_SETTINGS=1 ;; + --all) FORCE=1; REMOVE_MEMORY=1; REMOVE_CLAUDE_MD=1; REMOVE_ENV=1 ;; + --scope=user) SCOPE=user ;; + --scope=project) SCOPE=project ;; + --scope=*) echo "invalid --scope (use user|project): $arg" >&2; exit 2 ;; + --target=*) TARGET="${arg#--target=}" ;; + -h|--help) + # Print the leading comment block (after the shebang) up to the first + # non-comment line. Robust to script edits — no fixed line numbers. + awk 'NR>1 { if (/^#/) { sub(/^# ?/, ""); print } else { exit } }' "${BASH_SOURCE[0]}" + exit 0 ;; + *) echo "unknown flag: $arg" >&2; exit 2 ;; + esac +done + +SKILL_DIR="${SKILL_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}" +ASSETS="$SKILL_DIR/assets" +[ -d "$ASSETS" ] || { echo "error: $ASSETS not found" >&2; exit 1; } + +detect_scope_root() { + local d="$SKILL_DIR" + while [ "$d" != "/" ] && [ -n "$d" ]; do + if [ "$(basename "$d")" = ".claude" ]; then + dirname "$d" + return 0 + fi + d="$(dirname "$d")" + done + return 1 +} + +if [ -n "$TARGET" ]; then + : +elif [ "$SCOPE" = "user" ]; then + TARGET="$HOME/.claude" +elif [ "$SCOPE" = "project" ]; then + TARGET="${CLAUDE_PROJECT_DIR:-$PWD}/.claude" +else + if scope_root="$(detect_scope_root)"; then + TARGET="$scope_root/.claude" + if [ "$scope_root" = "$HOME" ]; then SCOPE=user; else SCOPE=project; fi + else + cat >&2 </dev/null 2>&1; then + sha256sum "$f" | awk '{print $1}' + elif command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$f" | awk '{print $1}' + elif command -v python3 >/dev/null 2>&1; then + python3 -c 'import hashlib,sys; print(hashlib.sha256(open(sys.argv[1],"rb").read()).hexdigest())' "$f" + else + return 1 + fi +} + +sha256_eq() { + [ -f "$1" ] && [ -f "$2" ] || return 1 + local a b + a="$(sha256 "$1")" || return 1 + b="$(sha256 "$2")" || return 1 + [ "$a" = "$b" ] +} + +remove_safe() { + local installed="$1" template="$2" + if [ ! -e "$installed" ]; then + return 0 + fi + if [ $FORCE -eq 0 ] && [ -f "$template" ] && ! sha256_eq "$installed" "$template"; then + echo " keep (modified): $installed" + return 0 + fi + if [ $DRY -eq 1 ]; then + echo " [dry-run] would remove: $installed" + else + rm -f "$installed" + echo " removed: $installed" + fi +} + +# Preflight: tell the user what this run will touch and what it WON'T. +cat </CLAUDE.md (the project root, not under .claude/). +if [ "$SCOPE" = "user" ]; then + CLAUDE_MD_PATH="$TARGET/CLAUDE.md" +else + CLAUDE_MD_PATH="$(dirname "$TARGET")/CLAUDE.md" +fi +if [ $REMOVE_CLAUDE_MD -eq 1 ]; then + say "removing CLAUDE.md" + remove_safe "$CLAUDE_MD_PATH" "$ASSETS/CLAUDE.md.tmpl" +else + say "keeping CLAUDE.md (pass --remove-claude-md to opt in)" +fi + +# 5. Patch settings.json — remove our hook entries (and env var if user scope + --remove-env). +SETTINGS="$TARGET/settings.json" +if [ $KEEP_SETTINGS -eq 1 ]; then + say "keeping settings.json untouched (--keep-settings)" +elif [ ! -f "$SETTINGS" ]; then + say "no settings.json — skipping settings patch" +else + say "cleaning settings.json" + if [ $DRY -eq 1 ]; then + echo " [dry-run] would strip 4 hook entries$([ $REMOVE_ENV -eq 1 ] && [ "$SCOPE" = user ] && echo ' + env var')" + else + SETTINGS="$SETTINGS" SCOPE="$SCOPE" HOOK_CMD_BASE="$HOOK_CMD_BASE" REMOVE_ENV="$REMOVE_ENV" python3 - <<'PY' +import json, os +p = os.environ["SETTINGS"] +scope = os.environ["SCOPE"] +base = os.environ["HOOK_CMD_BASE"] +with open(p) as f: + s = json.load(f) + +removed = [] + +OUR_CMDS = { + "PreToolUse": f"{base}/block-force-push.sh", + "PostToolUse": f"{base}/format-on-edit.sh", + "PostCompact": f"{base}/post-compact-reinject.sh", + "Stop": f"{base}/verify-before-stop.sh", +} + +hooks = s.get("hooks", {}) +for event, cmd in OUR_CMDS.items(): + blocks = hooks.get(event, []) + new_blocks = [] + for b in blocks: + new_inner = [h for h in b.get("hooks", []) if h.get("command") != cmd] + if len(new_inner) != len(b.get("hooks", [])): + removed.append(f"{event} -> {cmd}") + if new_inner: + b["hooks"] = new_inner + new_blocks.append(b) + if new_blocks: + hooks[event] = new_blocks + elif event in hooks: + del hooks[event] + removed.append(f"{event} (empty)") + +if not hooks and "hooks" in s: + del s["hooks"] + +# Env var only exists at user scope. +if scope == "user" and os.environ.get("REMOVE_ENV") == "1": + env = s.get("env", {}) + if env.pop("CLAUDE_CODE_AUTO_COMPACT_WINDOW", None) is not None: + removed.append("env.CLAUDE_CODE_AUTO_COMPACT_WINDOW") + if not env and "env" in s: + del s["env"] + +# Atomic write — see install.sh for rationale. +tmp = p + ".tmp" +with open(tmp, "w") as f: + json.dump(s, f, indent=2) + f.write("\n") +os.replace(tmp, p) + +if removed: + for r in removed: + print(f" removed: {r}") +else: + print(" settings.json already clean") +PY + fi +fi + +# 6. Clean up empty dirs (best-effort). +say "tidying empty dirs" +for d in "$TARGET/hooks" "$TARGET/commands" "$TARGET/agents"; do + if [ -d "$d" ] && [ -z "$(ls -A "$d" 2>/dev/null)" ]; then + if [ $DRY -eq 1 ]; then + echo " [dry-run] would rmdir $d" + else + rmdir "$d" 2>/dev/null && echo " rmdir $d" + fi + fi +done + +echo +say "done" + +if [ $REMOVE_MEMORY -eq 0 ] || [ $REMOVE_CLAUDE_MD -eq 0 ] || [ $REMOVE_ENV -eq 0 ]; then + echo + echo "Kept by default — re-run with the matching flag if you want them gone:" + if [ "$SCOPE" = "user" ]; then + [ $REMOVE_MEMORY -eq 0 ] && echo " --remove-memory (memory entries in $MEMORY_DIR)" + [ $REMOVE_ENV -eq 0 ] && echo " --remove-env (env.CLAUDE_CODE_AUTO_COMPACT_WINDOW in settings.json)" + fi + [ $REMOVE_CLAUDE_MD -eq 0 ] && echo " --remove-claude-md ($CLAUDE_MD_PATH)" + echo " --all (everything above + --force)" +fi diff --git a/skills/harness/scripts/update.sh b/skills/harness/scripts/update.sh new file mode 100755 index 0000000..a55e754 --- /dev/null +++ b/skills/harness/scripts/update.sh @@ -0,0 +1,192 @@ +#!/usr/bin/env bash +# Update an existing harness install to the current skill's templates. +# Smarter than `install --force`: never silently overwrites a customised file. +# For each surface compares installed vs template via sha256 and: +# - identical → re-install (no-op cosmetically) +# - missing → install +# - modified → print diff and SKIP, unless --merge or --force +# +# Usage: +# bash update.sh # auto-detect scope; show diffs for modified files +# bash update.sh --scope=user|project # force scope +# bash update.sh --target=PATH # explicit .claude/ target +# bash update.sh --dry-run # show plan, change nothing +# bash update.sh --force # overwrite ALL files including modified ones +# bash update.sh --merge # for modified files, write template to .new alongside +# # the original so the user can diff/merge interactively + +set -euo pipefail + +DRY=0 +FORCE=0 +MERGE=0 +SCOPE="" +TARGET="" +for arg in "$@"; do + case "$arg" in + --dry-run) DRY=1 ;; + --force) FORCE=1 ;; + --merge) MERGE=1 ;; + --scope=user) SCOPE=user ;; + --scope=project) SCOPE=project ;; + --scope=*) echo "invalid --scope: $arg" >&2; exit 2 ;; + --target=*) TARGET="${arg#--target=}" ;; + -h|--help) + awk 'NR>1 { if (/^#/) { sub(/^# ?/, ""); print } else { exit } }' "${BASH_SOURCE[0]}" + exit 0 ;; + *) echo "unknown flag: $arg" >&2; exit 2 ;; + esac +done + +SKILL_DIR="${SKILL_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}" +ASSETS="$SKILL_DIR/assets" +[ -d "$ASSETS" ] || { echo "error: $ASSETS not found" >&2; exit 1; } + +detect_scope_root() { + local d="$SKILL_DIR" + while [ "$d" != "/" ] && [ -n "$d" ]; do + if [ "$(basename "$d")" = ".claude" ]; then + dirname "$d"; return 0 + fi + d="$(dirname "$d")" + done + return 1 +} + +if [ -n "$TARGET" ]; then + : +elif [ "$SCOPE" = "user" ]; then + TARGET="$HOME/.claude" +elif [ "$SCOPE" = "project" ]; then + TARGET="${CLAUDE_PROJECT_DIR:-$PWD}/.claude" +else + if scope_root="$(detect_scope_root)"; then + TARGET="$scope_root/.claude" + if [ "$scope_root" = "$HOME" ]; then SCOPE=user; else SCOPE=project; fi + else + echo "error: cannot auto-detect scope; pass --scope or --target" >&2 + exit 2 + fi +fi +[ -z "$SCOPE" ] && { [ "$TARGET" = "$HOME/.claude" ] && SCOPE=user || SCOPE=project; } + +USER_PROJECT_KEY="${USER_PROJECT_KEY:-$(printf '%s' "$HOME" | tr '/' '-')}" +MEMORY_DIR="$HOME/.claude/projects/$USER_PROJECT_KEY/memory" +if [ "$SCOPE" = "user" ]; then + CLAUDE_MD_PATH="$TARGET/CLAUDE.md" +else + CLAUDE_MD_PATH="$(dirname "$TARGET")/CLAUDE.md" +fi + +say() { echo "→ $*"; } + +sha256() { + if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | awk '{print $1}' + elif command -v shasum >/dev/null 2>&1; then shasum -a 256 "$1" | awk '{print $1}' + elif command -v python3 >/dev/null 2>&1; then python3 -c 'import hashlib,sys; print(hashlib.sha256(open(sys.argv[1],"rb").read()).hexdigest())' "$1" + else return 1; fi +} + +# update_one