From 86bbb511ff22372c22f67d1c1a1ac6923d501784 Mon Sep 17 00:00:00 2001 From: Aaron Newton Date: Thu, 21 May 2026 23:20:45 -0700 Subject: [PATCH] Publish updates from ai-ron Skills added: eli5, doitright, trust-action, trust-skills. Skills removed: close-spec-drift, logo, tp. Rule snippets reorganized; many SKILL.md updates across the toolkit. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 7 +- claude-rules/compile.sh | 4 +- claude-rules/lib/parse-frontmatter.sh | 106 ++ claude-rules/scope-presets.json | 26 + .../global/005-claudemd-management.md | 25 +- .../snippets/global/010-plan-formatting.md | 4 + .../snippets/global/015-writing-style.md | 4 + .../snippets/global/020-interaction-prefs.md | 17 + .../global/022-user-facing-framing.md | 35 - .../global/040-plan-execution-handoff.md | 4 + .../snippets/global/040-tech-stack.md | 6 +- .../snippets/global/045-bash-command-style.md | 140 ++ .../snippets/global/050-git-workflow.md | 29 - .../snippets/global/052-worktree-location.md | 4 + .../snippets/global/055-session-topics.md | 4 + .../global/060-plannotator-spec-review.md | 4 + .../global/065-plannotator-cli-hygiene.md | 23 - claude-rules/snippets/global/070-testing.md | 6 +- .../snippets/global/080-spec-driven-dev.md | 8 +- .../global/085-openspec-migration-prompt.md | 4 + .../snippets/global/090-plan-archiving.md | 4 + docs/workflow-guide.md | 229 ++- home/bin/set-session-topic.sh | 49 + skills/agent-driven-development/SKILL.md | 3 +- skills/anutron-install/SKILL.md | 104 +- skills/anutron-install/install.sh | 1287 ++++++++++++++--- .../source-repo/.claude-plugin/plugin.json | 1 + .../fixtures/source-repo/.publish-exclude | 4 + .../claude-rules/lib/parse-frontmatter.sh | 106 ++ .../claude-rules/scope-presets.json | 26 + .../snippets/global/010-shared-formatting.md | 7 + .../snippets/global/020-shared-spec.md | 7 + .../snippets/global/aaron-personal.md | 7 + .../fixtures/source-repo/hooks/hooks.json | 1 + .../source-repo/hooks/scripts/dummy-hook.sh | 2 + .../skills/airon-excluded/SKILL.md | 7 + .../source-repo/skills/brainstorm/SKILL.md | 7 + .../source-repo/skills/bugbash/SKILL.md | 7 + .../source-repo/skills/guard/SKILL.md | 7 + .../source-repo/skills/personal-only/SKILL.md | 7 + .../fixtures/source-repo/skills/pr/SKILL.md | 7 + .../source-repo/skills/untagged/SKILL.md | 6 + skills/anutron-install/tests/test-install.sh | 518 ++++++- skills/anutron-uninstall/SKILL.md | 22 +- .../anutron-uninstall/tests/test-uninstall.sh | 482 +++--- skills/anutron-uninstall/uninstall.sh | 143 +- skills/brainstorm/SKILL.md | 9 +- skills/bugbash/SKILL.md | 123 +- skills/changelog/SKILL.md | 1 + skills/close-spec-drift/SKILL.md | 362 ----- skills/close-worktree/SKILL.md | 1 + skills/debug/SKILL.md | 1 + skills/devils-advocate/SKILL.md | 1 + skills/disk-cleanup/SKILL.md | 1 + skills/doitright/SKILL.md | 56 + skills/eli5/SKILL.md | 49 + skills/execute-plan/SKILL.md | 92 +- skills/fixit/SKILL.md | 47 +- skills/guard/SKILL.md | 1 + skills/handoff/SKILL.md | 16 +- skills/improve/SKILL.md | 12 + skills/interview/SKILL.md | 1 + skills/kickoff/SKILL.md | 1 + skills/list-skills/SKILL.md | 1 + skills/logo/SKILL.md | 141 -- skills/mcp-prune/SKILL.md | 1 + skills/merge/SKILL.md | 1 + skills/migrate-to-openspec/SKILL.md | 22 +- skills/migrate-to-openspec/migrate.sh | 319 +--- .../specs/.audit-config.json | 43 - .../specs/feature-d-unbuilt.md | 33 - .../specs/plans/v1.0-feature-d-unbuilt.md | 27 - .../test/fixtures-golden/feature-d-unbuilt.md | 33 - skills/migrate-to-openspec/test/run-tests.sh | 243 +--- skills/plannotator-specs/SKILL.md | 1 + skills/pr-dashboard/SKILL.md | 1 + skills/pr-respond/SKILL.md | 1 + skills/pr/SKILL.md | 1 + skills/promote/SKILL.md | 1 + skills/ralph-review/SKILL.md | 136 +- skills/rereview/SKILL.md | 1 + skills/review/SKILL.md | 1 + skills/save-w-specs/SKILL.md | 1 + skills/set-topic/SKILL.md | 24 +- skills/setup/install.sh | 231 +++ skills/skill-audit/SKILL.md | 1 + skills/software-best-practices/SKILL.md | 1 + skills/spec-audit/SKILL.md | 1 + skills/spec-recommender/SKILL.md | 1 + skills/spec-todo/SKILL.md | 1 + skills/spec-writer/SKILL.md | 4 +- skills/steal/SKILL.md | 1 + skills/test-driven-development/SKILL.md | 1 + skills/test/SKILL.md | 1 + skills/tp/SKILL.md | 90 -- skills/trust-action/SKILL.md | 138 ++ skills/trust-action/scripts/add-rule.sh | 99 ++ skills/trust-skills/SKILL.md | 87 ++ skills/unstaged/SKILL.md | 1 + skills/upload-notion-image/SKILL.md | 1 + .../verification-before-completion/SKILL.md | 1 + skills/write-skill/SKILL.md | 1 + 102 files changed, 3727 insertions(+), 2250 deletions(-) create mode 100755 claude-rules/lib/parse-frontmatter.sh create mode 100644 claude-rules/scope-presets.json delete mode 100644 claude-rules/snippets/global/022-user-facing-framing.md create mode 100644 claude-rules/snippets/global/045-bash-command-style.md delete mode 100644 claude-rules/snippets/global/050-git-workflow.md delete mode 100644 claude-rules/snippets/global/065-plannotator-cli-hygiene.md create mode 100755 home/bin/set-session-topic.sh create mode 100644 skills/anutron-install/tests/fixtures/source-repo/.claude-plugin/plugin.json create mode 100644 skills/anutron-install/tests/fixtures/source-repo/.publish-exclude create mode 100755 skills/anutron-install/tests/fixtures/source-repo/claude-rules/lib/parse-frontmatter.sh create mode 100644 skills/anutron-install/tests/fixtures/source-repo/claude-rules/scope-presets.json create mode 100644 skills/anutron-install/tests/fixtures/source-repo/claude-rules/snippets/global/010-shared-formatting.md create mode 100644 skills/anutron-install/tests/fixtures/source-repo/claude-rules/snippets/global/020-shared-spec.md create mode 100644 skills/anutron-install/tests/fixtures/source-repo/claude-rules/snippets/global/aaron-personal.md create mode 100644 skills/anutron-install/tests/fixtures/source-repo/hooks/hooks.json create mode 100755 skills/anutron-install/tests/fixtures/source-repo/hooks/scripts/dummy-hook.sh create mode 100644 skills/anutron-install/tests/fixtures/source-repo/skills/airon-excluded/SKILL.md create mode 100644 skills/anutron-install/tests/fixtures/source-repo/skills/brainstorm/SKILL.md create mode 100644 skills/anutron-install/tests/fixtures/source-repo/skills/bugbash/SKILL.md create mode 100644 skills/anutron-install/tests/fixtures/source-repo/skills/guard/SKILL.md create mode 100644 skills/anutron-install/tests/fixtures/source-repo/skills/personal-only/SKILL.md create mode 100644 skills/anutron-install/tests/fixtures/source-repo/skills/pr/SKILL.md create mode 100644 skills/anutron-install/tests/fixtures/source-repo/skills/untagged/SKILL.md delete mode 100644 skills/close-spec-drift/SKILL.md create mode 100644 skills/doitright/SKILL.md create mode 100644 skills/eli5/SKILL.md delete mode 100644 skills/logo/SKILL.md delete mode 100644 skills/migrate-to-openspec/test/fixture-legacy-project/specs/.audit-config.json delete mode 100644 skills/migrate-to-openspec/test/fixture-legacy-project/specs/feature-d-unbuilt.md delete mode 100644 skills/migrate-to-openspec/test/fixture-legacy-project/specs/plans/v1.0-feature-d-unbuilt.md delete mode 100644 skills/migrate-to-openspec/test/fixtures-golden/feature-d-unbuilt.md create mode 100755 skills/setup/install.sh delete mode 100644 skills/tp/SKILL.md create mode 100644 skills/trust-action/SKILL.md create mode 100755 skills/trust-action/scripts/add-rule.sh create mode 100644 skills/trust-skills/SKILL.md diff --git a/README.md b/README.md index 4eca899..a8beaa1 100644 --- a/README.md +++ b/README.md @@ -190,7 +190,6 @@ That's it. One line. The `dir` field says where specs live (defaults to `specs/` | Skill | What it does | |-------|-------------| | [migrate-to-openspec](skills/migrate-to-openspec/SKILL.md) | One-time migration from a legacy `.specs` project to OpenSpec — preserves Given/When/Then fidelity, archives originals at `.workflow/legacy-specs/` | -| [close-spec-drift](skills/close-spec-drift/SKILL.md) | When an OpenSpec base spec is correct but code or peripheral spec text drifted from it — surfaces full extent of drift, scaffolds a thin change folder (no deltas), hands off to /execute-plan | | [spec-writer](skills/spec-writer/SKILL.md) | Thin orchestrator around `openspec instructions ` — returns enriched proposal/design/tasks/specs templates with project context | | [spec-recommender](skills/spec-recommender/SKILL.md) | Detect code without spec coverage, infer intent, recommend OpenSpec capabilities + requirements | | [spec-audit](skills/spec-audit/SKILL.md) | Audit OpenSpec coverage — inventory capabilities, map to code, dispatch agents to find behavioral gaps | @@ -261,7 +260,6 @@ These skills describe how agents should think and work. They're loaded by refere | [agent-driven-development](skills/agent-driven-development/SKILL.md) | Use when executing implementation plans with independent tasks -- worktree isolation, TDD discipline, two-stage review | | [test-driven-development](skills/test-driven-development/SKILL.md) | Use when implementing any feature or bugfix, before writing implementation code | | [verification-before-completion](skills/verification-before-completion/SKILL.md) | Use when about to claim work is complete, before committing or creating PRs -- evidence before assertions always | -| [tp](skills/tp/SKILL.md) | CLI for checkbox flips and one-line status annotations in markdown task lists (`tasks.md`, test plans) -- much cheaper than Read+Edit per tick. Ships source + prebuilt macOS arm64 binary at [bin/tp/](bin/tp/) | ### Git & PR @@ -285,7 +283,6 @@ These skills describe how agents should think and work. They're loaded by refere | [skill-audit](skills/skill-audit/SKILL.md) | Use after collecting usage data for a few weeks to identify dead weight -- recommends which skills to keep, prune, or consolidate | | [promote](skills/promote/SKILL.md) | Use when checking which project skills should be available globally | | [disk-cleanup](skills/disk-cleanup/SKILL.md) | Use when the user asks about disk space or storage -- scans for large consumers, never deletes without approval | -| [logo](skills/logo/SKILL.md) | Use when the user wants to create or generate a logo -- produces 6 SVG alternatives with a side-by-side comparison page | | [mcp-prune](skills/mcp-prune/SKILL.md) | Use when starting work in a project with many global MCP servers that waste context tokens | | [upload-notion-image](skills/upload-notion-image/SKILL.md) | Use when embedding images in Notion pages -- uploads natively via the Notion API file upload flow | | [set-topic](skills/set-topic/SKILL.md) | Set the session topic displayed in the [status line](bin/statusline.sh) | @@ -294,6 +291,10 @@ These skills describe how agents should think and work. They're loaded by refere | [steal](skills/steal/SKILL.md) | Use when the user wants to find reusable skills, patterns, or techniques from other repos -- scans tracked GitHub repos or evaluates new ones | | [list-skills](skills/list-skills/SKILL.md) | Use when you need a reminder of your toolkit -- quick reference of all available skills | | [migrate-to-openspec](skills/migrate-to-openspec/SKILL.md) | Convert a legacy `.specs` project to OpenSpec layout with verifiable fidelity -- one-time per project | +| [eli5](skills/eli5/SKILL.md) | Restate the prior response in plain language and orient the user around the decision they need to make | +| [doitright](skills/doitright/SKILL.md) | Pick the long-term-correct option from a multi-option recommendation -- "go with the proper fix unless there's a real downside beyond effort" | +| [trust-action](skills/trust-action/SKILL.md) | Eliminate a specific permission prompt by adding a targeted allowlist rule -- paste a permission prompt, choose global or project scope | +| [trust-skills](skills/trust-skills/SKILL.md) | Bulk-trust all skills in the current project's `.claude/skills/` directory to stop per-skill permission prompts | --- diff --git a/claude-rules/compile.sh b/claude-rules/compile.sh index ceeb2bd..c474cda 100755 --- a/claude-rules/compile.sh +++ b/claude-rules/compile.sh @@ -94,13 +94,15 @@ save_checksums() { resolve_variables() { local file="$1" - local project_dir + local project_dir personal_dir project_dir="$(dirname "$RULES_DIR")" + personal_dir="$(dirname "$project_dir")" # Built-in variables (name=value, one per line) local builtins builtins="CLAUDE_RULES_DIR=$RULES_DIR PROJECT_DIR=$project_dir +PERSONAL_DIR=$personal_dir GLOBAL_TARGET=$GLOBAL_TARGET" # Collect all variables: builtins first, then custom from variables.env diff --git a/claude-rules/lib/parse-frontmatter.sh b/claude-rules/lib/parse-frontmatter.sh new file mode 100755 index 0000000..d06f5be --- /dev/null +++ b/claude-rules/lib/parse-frontmatter.sh @@ -0,0 +1,106 @@ +#!/usr/bin/env bash +# parse-frontmatter.sh +# +# Extract a single YAML key's value from a markdown file's frontmatter. +# Frontmatter must be the very first block delimited by --- / --- lines. +# +# Supported value shapes: +# scalar: key: value +# inline list: key: [a, b, c] +# +# Block-list (- item lines) is NOT supported. +# Prints each list element (or the scalar) on its own line to stdout. +# Prints nothing if key is absent, no frontmatter, or file not found. +# Always exits 0. + +set -euo pipefail + +FILE="${1:-}" +KEY="${2:-}" + +if [ -z "$FILE" ] || [ -z "$KEY" ]; then + exit 0 +fi + +if [ ! -f "$FILE" ]; then + exit 0 +fi + +# Read the frontmatter block: must start at line 1 with --- +# and end at the next --- line. If no closing --- found, treat as no frontmatter. + +in_frontmatter=0 +found_open=0 +value="" + +while IFS= read -r line; do + if [ $found_open -eq 0 ]; then + # First line must be exactly --- + if [ "$line" = "---" ]; then + found_open=1 + in_frontmatter=1 + continue + else + # Not a frontmatter file + break + fi + fi + + # Inside frontmatter — look for closing --- + if [ "$line" = "---" ]; then + in_frontmatter=0 + break + fi + + # Try to match key: ... + # Strip leading whitespace for the comparison + trimmed="${line#"${line%%[![:space:]]*}"}" + key_prefix="${KEY}: " + if [[ "$trimmed" == "${KEY}: "* ]] || [[ "$trimmed" == "${KEY}:"* ]]; then + # Extract value after the colon + raw_value="${trimmed#*: }" + # Handle case where there's no space after colon + if [[ "$trimmed" == "${KEY}:" ]]; then + raw_value="" + fi + value="$raw_value" + fi +done < "$FILE" + +# If we never found an opening ---, nothing to output +if [ $found_open -eq 0 ]; then + exit 0 +fi + +# If key wasn't found, nothing to output +if [ -z "$value" ]; then + exit 0 +fi + +# Parse value: inline list or scalar +trimmed_value="${value#"${value%%[![:space:]]*}"}" +trimmed_value="${trimmed_value%"${trimmed_value##*[![:space:]]}"}" + +if [[ "$trimmed_value" == "["* ]]; then + # Inline list: [a, b, c] + # Strip brackets + inner="${trimmed_value#[}" + inner="${inner%]}" + # Split by comma, trim whitespace from each element + IFS=',' read -ra items <<< "$inner" + for item in "${items[@]}"; do + # Trim whitespace + trimmed_item="${item#"${item%%[![:space:]]*}"}" + trimmed_item="${trimmed_item%"${trimmed_item##*[![:space:]]}"}" + if [ -n "$trimmed_item" ]; then + echo "$trimmed_item" + fi + done +else + # Scalar + if [ -n "$trimmed_value" ]; then + echo "$trimmed_value" + fi +fi + +exit 0 diff --git a/claude-rules/scope-presets.json b/claude-rules/scope-presets.json new file mode 100644 index 0000000..67b5301 --- /dev/null +++ b/claude-rules/scope-presets.json @@ -0,0 +1,26 @@ +{ + "$schema": "scope-presets", + "presets": { + "full": { + "description": "Everything not matched by the source's hardcoded exclude list. Aaron's default for his own dev loop.", + "skills": "*", + "snippets": "*", + "excludeSkills": ["airon-*", "thanx-*", "baker_st-*", "frontend-design", "playground", "plannotator-*", "anutron-install", "anutron-uninstall"] + }, + "spec-discipline": { + "description": "Spec-first + TDD + quality gates. Recommended for contributor-facing repos.", + "skillTags": ["spec", "quality"], + "snippetTags": ["spec", "quality", "formatting"], + "snippetAudience": ["shared"] + }, + "dev-tools": { + "extends": "spec-discipline", + "description": "spec-discipline + parallel workflow + PR shipping.", + "skillTags": ["spec", "quality", "workflow", "pr"] + }, + "custom": { + "description": "Read includeSkills/excludeSkills/includeSnippets/excludeSnippets from .anutron-install.config.json in the target repo.", + "from": "manifest" + } + } +} diff --git a/claude-rules/snippets/global/005-claudemd-management.md b/claude-rules/snippets/global/005-claudemd-management.md index a11ad66..71ae8d2 100644 --- a/claude-rules/snippets/global/005-claudemd-management.md +++ b/claude-rules/snippets/global/005-claudemd-management.md @@ -1,14 +1,18 @@ +--- +tags: [personal] +audience: [aaron] +--- ## CLAUDE.md Management -Both `~/.claude/CLAUDE.md` (global) and `{{PROJECT_DIR}}/CLAUDE.md` (project) are **compiled from snippets** — never edit the CLAUDE.md files directly. +Both `~/.claude/CLAUDE.md` (global) and the project CLAUDE.md are **compiled from snippets** — never edit the CLAUDE.md files directly. -**Source of truth:** `{{CLAUDE_RULES_DIR}}/snippets/` +**Source of truth:** the `snippets/` directory in `claude-rules/` - `snippets/global/*.md` → compiled into `~/.claude/CLAUDE.md` -- `snippets/project/*.md` → compiled into `{{PROJECT_DIR}}/CLAUDE.md` +- `snippets/project/*.md` → compiled into the project's CLAUDE.md **Workflow:** 1. Edit or create a snippet in the appropriate `snippets/{global,project}/` directory -2. Run `{{CLAUDE_RULES_DIR}}/compile.sh` to regenerate the dist files +2. Run `compile.sh` (in `claude-rules/`) to regenerate the dist files 3. The CLAUDE.md files are symlinks to the compiled output — changes appear immediately **Commands:** @@ -21,13 +25,14 @@ Both `~/.claude/CLAUDE.md` (global) and `{{PROJECT_DIR}}/CLAUDE.md` (project) ar **Naming convention:** Snippets are numbered for ordering (e.g., `010-plan-formatting.md`, `040-tech-stack.md`). Use gaps to allow inserting new snippets without renumbering. **Template Variables:** -Snippets can use `{{VARIABLE}}` placeholders that compile.sh resolves during compilation. +Snippets can use `{{VAR}}`-style placeholders (double curly braces around an uppercase name) that compile.sh resolves during compilation. -Built-in variables: -- `{{CLAUDE_RULES_DIR}}` — absolute path to the claude-rules directory -- `{{PROJECT_DIR}}` — absolute path to the project root (parent of claude-rules/) -- `{{GLOBAL_TARGET}}` — `~/.claude/CLAUDE.md` +Built-in variables — refer to these names when writing placeholders in snippets: +- `CLAUDE_RULES_DIR` — absolute path to the claude-rules directory +- `PROJECT_DIR` — absolute path to the project root (parent of claude-rules/) +- `PERSONAL_DIR` — absolute path to the personal-projects root (parent of PROJECT_DIR) +- `GLOBAL_TARGET` — absolute path to `~/.claude/CLAUDE.md` -Custom variables: define in `{{CLAUDE_RULES_DIR}}/variables.env`, one per line (`KEY=value`). Values can reference other variables. +Custom variables: define in `variables.env` (next to `compile.sh`), one per line (`KEY=value`). Values can reference other variables using the same placeholder syntax. When editing or creating snippets, **always use template variables for paths** — never hardcode absolute paths. This keeps snippets portable and publishable. diff --git a/claude-rules/snippets/global/010-plan-formatting.md b/claude-rules/snippets/global/010-plan-formatting.md index 500af81..7dcfd71 100644 --- a/claude-rules/snippets/global/010-plan-formatting.md +++ b/claude-rules/snippets/global/010-plan-formatting.md @@ -1,3 +1,7 @@ +--- +tags: [formatting] +audience: [shared] +--- ## Markdown Formatting Requirements All markdown you produce — plans, reports, codebase gap summaries, agent outputs, specs, any structured text — MUST use proper markdown to ensure correct rendering in Plannotator. diff --git a/claude-rules/snippets/global/015-writing-style.md b/claude-rules/snippets/global/015-writing-style.md index 233f2d9..d1693d8 100644 --- a/claude-rules/snippets/global/015-writing-style.md +++ b/claude-rules/snippets/global/015-writing-style.md @@ -1,3 +1,7 @@ +--- +tags: [formatting] +audience: [shared] +--- ## Writing style - **Titles:** Always sentence case (capitalize first word only). Never Title Case or ALL CAPS. diff --git a/claude-rules/snippets/global/020-interaction-prefs.md b/claude-rules/snippets/global/020-interaction-prefs.md index 6378016..7bec405 100644 --- a/claude-rules/snippets/global/020-interaction-prefs.md +++ b/claude-rules/snippets/global/020-interaction-prefs.md @@ -1,5 +1,22 @@ +--- +tags: [formatting] +audience: [shared] +--- ## Interaction Preferences +### Asking for a decision + +When you need the user's input to choose between approaches, frame it in terms of outcomes — not implementation details. Structure every decision request like this: + +- **The decision** — one sentence naming the choice, framed as a question. Lead with the observable outcome ("the UI freezes briefly during X"), not the mechanism ("functionA() calls sleep()"). +- **Your options** — a bullet list, one short plain-language sentence each. +- **Tradeoffs** — the main upside and downside for each option, in plain terms. +- **My recommendation** — pick one, state it directly, give the one-line reason. No hedging. + +This applies anywhere a decision needs the user — after `/ralph-review`, `/review`, `/debug`, mid-implementation forks, plan reviews, anywhere. If the prior context was deeply technical, translate before asking. Strip jargon, acronyms, and code references unless the decision is literally about syntax. + +The user can still type `/eli5` to retroactively re-explain a prior response — that's a separate user-invoked path. + ### Question-by-Question Approach When you have multiple questions, ask them **one at a time** with progress indicators: diff --git a/claude-rules/snippets/global/022-user-facing-framing.md b/claude-rules/snippets/global/022-user-facing-framing.md deleted file mode 100644 index 15b8604..0000000 --- a/claude-rules/snippets/global/022-user-facing-framing.md +++ /dev/null @@ -1,35 +0,0 @@ -## User-facing framing for choices and findings - -When asking the user to **contemplate something** – review a design section, weigh a code-review question, pick among debugging hypotheses, evaluate a proposed change – the user is usually approving your output, not co-authoring it. Lead with intent and impact. Keep technical detail as a spot-check, not the headline. - -### Structure - -Skip parts that don't apply; keep parts that do: - -- **Why this matters** – situate the question. What does this touch (a user flow, a security boundary, a performance hotpath, an external contract)? What downstream behavior or decisions depend on the answer? Help them understand why they're being pulled in. -- **What's happening / Outcome** – plain-language summary of the current state or what the choice unlocks. No jargon, no line numbers in the narrative. -- **What could go wrong** (for findings) or **Tradeoffs** (for design choices) – consequences in system or user behavior, not code mechanics. Every existing behavior has a reason; surface it before recommending change. -- **Recommendation** – take a position. Defend it with the tradeoffs. Don't hedge between options – if the user disagrees, their response is where they redirect. -- **Technical details** – a brief 1-3 line summary of the concrete artifacts (file, type or function, key fields, the relevant delta or spec line). Users often skim this, but sometimes catch a real issue at this layer. - -### Examples - -| Good (outcome-first) | Bad (mechanism-first) | -|---|---| -| "the UI freezes briefly during resume" | "`functionA()` calls `time.Sleep()` on the main thread" | -| "users could see stale data" | "the backing array could be shared" | -| "removing the delay makes resume snappier but risks the previous session not stopping before the new one starts" | "remove the `tea.Cmd` dispatch and call directly" | - -### When it applies - -- Presenting design sections during brainstorming or planning -- Listing findings or questions in a code review -- Surfacing hypotheses during debugging -- Recommending changes during a retrospective or audit -- Any `AskUserQuestion` that asks the user to weigh tradeoffs - -### When it doesn't apply - -- Status updates and completion reports (just say what changed) -- Strictly procedural prompts ("which file?") -- Output the user reads but doesn't have to decide on (changelogs, summaries) diff --git a/claude-rules/snippets/global/040-plan-execution-handoff.md b/claude-rules/snippets/global/040-plan-execution-handoff.md index 9c9f7f3..40eb97f 100644 --- a/claude-rules/snippets/global/040-plan-execution-handoff.md +++ b/claude-rules/snippets/global/040-plan-execution-handoff.md @@ -1,3 +1,7 @@ +--- +tags: [spec] +audience: [shared] +--- ## Plan execution handoff After a plan is approved via `ExitPlanMode`, always: diff --git a/claude-rules/snippets/global/040-tech-stack.md b/claude-rules/snippets/global/040-tech-stack.md index c17964f..62db1db 100644 --- a/claude-rules/snippets/global/040-tech-stack.md +++ b/claude-rules/snippets/global/040-tech-stack.md @@ -1,3 +1,7 @@ +--- +tags: [personal] +audience: [aaron] +--- ## Tech Stack Spectrum New applications follow the **stack spectrum** defined in `{{PROJECT_DIR}}/docs/stack-spectrum.md`. Four web tiers plus a CLI track — pick the lightest that fits: @@ -7,7 +11,7 @@ New applications follow the **stack spectrum** defined in `{{PROJECT_DIR}}/docs/ | **Lightweight** | No database, simple web UI | HTML + CSS + JS (no build step) | | **Personal** | Local app with DB and real UI | Next.js + Prisma + MySQL + shadcn/ui | | **Distributed** | Local app, shared/hosted data | Personal tier + Supabase (Postgres) | -| **Deployable** | Production app for other users | Rails + Next.js monorepo (see `docs/thanx-dev-system.md`) | +| **Deployable** | Production app for other users | Rails + Next.js monorepo | | **CLI** | Terminal-first tool | Go + Cobra (+ Bubbletea for TUI) | ### Quick decision guide diff --git a/claude-rules/snippets/global/045-bash-command-style.md b/claude-rules/snippets/global/045-bash-command-style.md new file mode 100644 index 0000000..804538b --- /dev/null +++ b/claude-rules/snippets/global/045-bash-command-style.md @@ -0,0 +1,140 @@ +--- +tags: [quality] +audience: [shared] +--- +## Bash command style + +These patterns trigger Claude Code permission guardrails that **cannot be silenced by allowlist rules in `settings.json`**. They are static-analysis flags, not pattern matches. Avoid them — use the alternative forms below so commands run without prompting. + +### Prefer `git -C ` over `cd && git ...` + +Claude Code flags `cd && git ` as: *"This command changes directory before running git, which can execute untrusted hooks from the target directory."* Even when `Bash(cd:*)` and `Bash(git status:*)` are both allowed, the compound form prompts. + +**Bad:** + +```bash +cd /path/to/repo && git status +cd ~/repos/foo && git log --oneline -5 +``` + +**Good:** + +```bash +git -C /path/to/repo status +git -C ~/repos/foo log --oneline -5 +``` + +`Bash(git -C:*)` is auto-allowed. + +### Avoid heredocs and `$(...)` in shell — use a tempfile + +Claude Code flags any bash with `$(...)`, backticks, or heredocs as *"Contains shell syntax that cannot be statically analyzed"* (or *"Contains simple_expansion"* if the expansion is shorter). Prompts every time, regardless of allowlist. + +**Bad** (heredoc + command substitution for a multi-line commit message): + +```bash +git commit -m "$(cat <<'EOF' +Title + +Body line 1 +Body line 2 +EOF +)" +``` + +**Good** (multiple `-m` flags, each becoming a paragraph): + +```bash +git commit -m "Title" -m "Body line 1" -m "Body line 2" +``` + +This is the primary form. No tempfile, no extra Write call, no permission prompt. + +**Fallback** (tempfile, only when the message contains markdown/code blocks/quoting that breaks inside `-m`): + +Write to `/tmp/claude-commit-msgs/-.txt` (collision-proof across parallel Claude sessions), then commit with `-F`: + +```bash +# Use the Write tool to create /tmp/claude-commit-msgs/ai-ron-1779385427.txt +git commit -F /tmp/claude-commit-msgs/ai-ron-1779385427.txt +``` + +`Write(/tmp/claude-commit-msgs/**)` is globally allowlisted. The directory is OS-managed scratch (auto-cleared on reboot); flat layout means `ls` and `find -mtime +7 -delete` for cleanup. Filename = `-.txt`; append `-` if you somehow need sub-second uniqueness. + +### Avoid inline multi-line scripts in skills and ad-hoc bash + +If a skill (or any task) needs shell logic with variables, conditionals, or loops, **put it in a script file** under `.claude/bin/` (project) or `~/.claude/bin/` (global) and invoke that file with a simple command form. Allowlist the script path once and it never prompts again. + +**Bad** (multi-line inline script with expansions): + +```bash +SESSION_ID=$(cat ~/.claude/session-topics/pid-$PPID.map 2>/dev/null) +if [ -n "$SESSION_ID" ]; then + printf '%s' "$TOPIC" > ~/.claude/session-topics/${SESSION_ID}.txt +fi +``` + +**Good** (helper script invoked simply): + +```bash +~/.claude/bin/set-session-topic.sh "$TOPIC" +``` + +Add `Bash(~/.claude/bin/set-session-topic.sh:*)` to the allowlist once. + +### Never allowlist inline interpreter invocations (`python3 -c`, `node -e`, `ruby -e`, `perl -e`) + +When Claude Code prompts for `python3 -c ""`, the "don't ask again" option offers to allowlist that **exact code string**. Don't take it. The rule is brittle in two ways: + +- It only matches that exact string. Any tweak — a different regex, a different slice, a different attribute name — re-prompts. Over time you accumulate dozens of near-duplicate rules that each silence one variation. +- It's path-based trust on an inline script, **but worse** — the "script" lives in `settings.json`, not in version control. No `git diff` audit trail. + +**Bad** (extracting plain text from HTML inline): + +```bash +jq -r '.htmlBody' thread.json | python3 -c "import sys, html, re; t=sys.stdin.read(); t=re.sub(r'<[^>]+>',' ',t); t=html.unescape(t); print(re.sub(r'\s+',' ',t))" +``` + +**Good** (helper script with a stable, allowlistable path): + +```bash +jq -r '.htmlBody' thread.json | ~/.claude/bin/html-to-text.sh +``` + +Allowlist `Bash(~/.claude/bin/html-to-text.sh:*)` once. The interpreter code lives in the script file — auditable, version-controlled, and any future caller benefits. + +Same rule for `node -e ''`, `ruby -e ''`, `perl -e ''`. If the logic doesn't fit in a one-token verb, it belongs in a script file. + +### Why this matters — and the tradeoff + +Each unnecessary permission prompt breaks flow. The patterns above eliminate prompts that don't carry information — the same operation, just a syntactic form Claude Code can statically verify. + +But the flip side is real: **trivial-prompt fatigue makes you reflexively approve non-trivial prompts.** A user worn down by 20 `ls` approvals is exactly the user who'll hit Y on `rm -rf ` without reading it. So these patterns are for *eliminating prompts that genuinely don't carry information* — not for finding workarounds to silence prompts you should still review. + +### Safety guardrails for path-based script allowlists + +The "use a helper script" pattern works **only** if the path you allowlist is in version control. `git diff` is what makes the trust auditable. Without that, allowlisting `Bash(/path/to/script.sh:*)` becomes path-based trust that doesn't bind to content — Claude can rewrite the script, the rule still passes, and you have no audit trail. + +Rules of thumb when allowlisting a script path: + +- **Required:** the script must be tracked by git in a repo you control. +- **Required:** the script must NOT be in a location with broad `Write` access AND no version control (e.g. `/tmp/`, `/private/tmp/`, working directories). +- **Recommended:** review the script content before allowlisting. The `/trust-action` skill enforces this — it refuses to allowlist scripts in temporary locations or untracked files, and shows script content for review before adding any path-based rule. +- **Recommended:** prefer absolute paths over `~/`. Absolute paths are unambiguous in audits. + +### Always-prompt verbs + +A short list of bash verbs should always prompt, regardless of allowlist — keep them in the `ask` list: + +- `rm` — irreversible deletion +- `curl`, `wget` — network egress; exfiltration vector +- `chown` — ownership changes +- `dd` — block-level operations +- `sudo` — privilege escalation +- `nc`, `ncat` — arbitrary network sockets + +If you find yourself wanting to silence these, the right answer is almost never "broaden the allowlist." It's either: (a) script the operation, version-control the script, and allowlist the script path (which retains the audit trail), or (b) accept the per-call prompt. + +### Known gap + +`Bash(git -C:*)` is broadly allowed because `git -C` is the workaround for the `cd && git ...` static-analysis flag. This means `git -C push --force` and `git -C reset --hard` are not caught by the `git push --force` ask rules. Live with this — if you find yourself routinely running destructive `git -C` operations through Claude, add specific patterns to `ask` at that point. diff --git a/claude-rules/snippets/global/050-git-workflow.md b/claude-rules/snippets/global/050-git-workflow.md deleted file mode 100644 index c291999..0000000 --- a/claude-rules/snippets/global/050-git-workflow.md +++ /dev/null @@ -1,29 +0,0 @@ -## Git Workflow - -**Applies to:** `~/Personal/*` and `~/Development/ai/*` projects. Does NOT apply to `~/Development/thanx/*` (those follow Thanx conventions). - -**Every turn (chunk of completed work) gets committed.** - -**Process:** -1. Complete a logical unit of work -2. Run tests (if applicable) -3. Create git commit -4. Commit message: `` - -**Examples:** -- `"Add Fitbit sync to memory MCP server"` -- `"Implement query translation for swimming progress"` -- `"Create interaction logging table in Supabase"` - -**Repository Structure:** -- Each application in `~/Personal/applications/` is its own git repo -- Commit frequently, push when ready -- Tag releases: `v1.0.0`, `v1.1.0`, etc. - -**SPEC Pre-Commit Hook:** -All spec-driven repos have a pre-commit hook that blocks commits when behavioral code changes (*.go, *.ts, *.py, *.js, *.sh, *.rb) don't include a `specs/*.md` update. Bypass with `--no-verify` when appropriate. - -When creating a new repo, install the hook: -```bash -ln -sf {{PROJECT_DIR}}/scripts/spec-check-hook.sh .git/hooks/pre-commit -``` diff --git a/claude-rules/snippets/global/052-worktree-location.md b/claude-rules/snippets/global/052-worktree-location.md index cf479aa..b170689 100644 --- a/claude-rules/snippets/global/052-worktree-location.md +++ b/claude-rules/snippets/global/052-worktree-location.md @@ -1,3 +1,7 @@ +--- +tags: [spec] +audience: [shared] +--- ## Worktree Location **Applies to:** Any project with a `.specs` file (spec-driven projects). diff --git a/claude-rules/snippets/global/055-session-topics.md b/claude-rules/snippets/global/055-session-topics.md index 83db2be..c1e3064 100644 --- a/claude-rules/snippets/global/055-session-topics.md +++ b/claude-rules/snippets/global/055-session-topics.md @@ -1,3 +1,7 @@ +--- +tags: [personal] +audience: [aaron] +--- ## Session Topics When you have enough context to understand what the session is about, set the topic by invoking `/set-topic --initial `. Do this silently — don't announce it. diff --git a/claude-rules/snippets/global/060-plannotator-spec-review.md b/claude-rules/snippets/global/060-plannotator-spec-review.md index 01ab8cd..a0edafa 100644 --- a/claude-rules/snippets/global/060-plannotator-spec-review.md +++ b/claude-rules/snippets/global/060-plannotator-spec-review.md @@ -1,3 +1,7 @@ +--- +tags: [plannotator] +audience: [aaron] +--- ## Spec Review via Plannotator **When `/brainstorm` is NOT driving** (e.g., standalone spec edits or spec files written outside the brainstorming flow), use Plannotator for spec review: diff --git a/claude-rules/snippets/global/065-plannotator-cli-hygiene.md b/claude-rules/snippets/global/065-plannotator-cli-hygiene.md deleted file mode 100644 index 5f04924..0000000 --- a/claude-rules/snippets/global/065-plannotator-cli-hygiene.md +++ /dev/null @@ -1,23 +0,0 @@ -## Plannotator CLI hygiene - -When invoking `plannotator annotate` (or any plannotator command), **always redirect stdout to a file with `>`**. Never pipe through `tail`, `head`, `grep`, or any other truncating filter. - -Plannotator emits submitted annotations to **stdout only**. There is no on-disk log of submitted feedback — drafts at `~/.plannotator/drafts/.json` are in-progress buffers that get cleared on submit, and session metadata at `~/.plannotator/sessions/.json` only contains port/mode/label, not annotations. - -If stdout is truncated or lost, the user's submitted annotations are unrecoverable — they can't be retrieved from disk and would have to be re-typed from scratch. - -**Required pattern for background invocation:** - -```bash -OUT=/path/to/.annotations.txt -rm -f "$OUT" -plannotator annotate > "$OUT" 2>&1 -``` - -Then `Read` the full file when the user submits. Never `| tail -N` even for "preview" — there's no preview use case that justifies the risk of dropping real submissions. - -**Other plannotator notes:** - -- Plannotator can run multiple sessions concurrently on different ports (e.g., `plannotator review ` and `plannotator annotate ` are separate sessions with similar UIs but different content). Confirm port + content before assuming a session is the right one. -- `plannotator sessions` lists active sessions. `plannotator last` opens the most recent. -- The `--json` flag emits a structured decision object (`{ decision, feedback }`) — useful for hook integration; pairs with `--gate` for the three-button UX. diff --git a/claude-rules/snippets/global/070-testing.md b/claude-rules/snippets/global/070-testing.md index 8be5366..43e365a 100644 --- a/claude-rules/snippets/global/070-testing.md +++ b/claude-rules/snippets/global/070-testing.md @@ -1,7 +1,9 @@ +--- +tags: [spec, quality] +audience: [shared] +--- ## Testing -**Applies to:** `~/Personal/*` and `~/Development/ai/*` projects. Does NOT apply to `~/Development/thanx/*` (those follow Thanx conventions). - **Test-driven development:** - Write tests before implementation (when using SPEC-driven approach) - Test after implementation (minimum) diff --git a/claude-rules/snippets/global/080-spec-driven-dev.md b/claude-rules/snippets/global/080-spec-driven-dev.md index 50cd9f0..b020f6e 100644 --- a/claude-rules/snippets/global/080-spec-driven-dev.md +++ b/claude-rules/snippets/global/080-spec-driven-dev.md @@ -1,12 +1,14 @@ +--- +tags: [spec] +audience: [shared] +--- ## Spec-driven development (OpenSpec) -**Applies to:** `~/Personal/*` and `~/Development/ai/*` projects. Does NOT apply to `~/Development/thanx/*` (those follow Thanx conventions). - **Opt-in per project via the `openspec/` directory.** Projects with an `openspec/` directory at their root use spec-driven development. Projects without one do not. **Detection:** `test -d openspec` – zero-cost, no file snooping. -**Recommendation:** When working in `~/Personal/*` or `~/Development/ai/*`, if the user creates a new application or asks to create/modify code in a project that lacks an `openspec/` directory, recommend running `openspec init` (or `/migrate-to-openspec` if a legacy `.specs` system is present). +**Recommendation:** If the user creates a new application or asks to create/modify code in a project that lacks an `openspec/` directory, recommend running `openspec init` (or `/migrate-to-openspec` if a legacy `.specs` system is present). ### The process (spec-first, non-negotiable) diff --git a/claude-rules/snippets/global/085-openspec-migration-prompt.md b/claude-rules/snippets/global/085-openspec-migration-prompt.md index 8b03cef..6443b0c 100644 --- a/claude-rules/snippets/global/085-openspec-migration-prompt.md +++ b/claude-rules/snippets/global/085-openspec-migration-prompt.md @@ -1,3 +1,7 @@ +--- +tags: [spec] +audience: [aaron] +--- ## OpenSpec migration prompt If you encounter a project with the legacy AI-RON spec system (a `.specs` file or top-level `specs/*.md` files **without** an `openspec/` directory) and the user asks for spec-related work, suggest running `/migrate-to-openspec` first. The migration is one-time, parallelized, typically 1-5 minutes, and preserves originals at `.workflow/legacy-specs/`. diff --git a/claude-rules/snippets/global/090-plan-archiving.md b/claude-rules/snippets/global/090-plan-archiving.md index ce09a7d..adcb9fd 100644 --- a/claude-rules/snippets/global/090-plan-archiving.md +++ b/claude-rules/snippets/global/090-plan-archiving.md @@ -1,3 +1,7 @@ +--- +tags: [spec] +audience: [shared] +--- ` comment; if the commit matches, the copy is skipped. +- **Symlink mode** is a no-op for any skill whose symlink already points to the correct source path. +- `CLAUDE.md` always contains exactly one `BEGIN ANUTRON-INSTALL` block after a re-run. +- `.claude/settings.json` never contains duplicate hook command entries. + +On a copy-mode re-run against a source that has advanced, the summary lists skills updated since the previous install. + +## Uninstall + +Run `/anutron-uninstall` to reverse everything the installer did. + +## Locating the source repo + +The installer resolves its source in priority order: + +1. `ANUTRON_SOURCE` environment variable, if set. +2. `~/.claude/anutron-cache`, if it exists (plugin cache path). +3. Self-location: follows the symlink from the installed `install.sh` back to its real path, then walks up until a directory containing both `skills/` and `claude-rules/snippets/global/` is found. -If the script exits non-zero, show the error output and suggest fixes based on the error message. +If no source resolves, the installer exits non-zero and names all three options in the error message. diff --git a/skills/anutron-install/install.sh b/skills/anutron-install/install.sh index 2f81cfb..53f9ce0 100755 --- a/skills/anutron-install/install.sh +++ b/skills/anutron-install/install.sh @@ -1,10 +1,23 @@ #!/bin/bash # install.sh — Per-project installer for the anutron (claude-skills) kit. # -# Symlinks skills, installs hooks, compiles CLAUDE.md from snippets, -# and writes a breadcrumb for uninstall/update tracking. +# Installs skills (symlinks or copies), hooks, and compiles CLAUDE.md from +# snippets. Writes a breadcrumb for uninstall/update tracking. # # Runs in the current working directory. Idempotent — safe to re-run. +# +# Flags: +# --mode= Install mode (defaults: symlink for writable +# source, copy for ~/.claude/anutron-cache) +# --scope= +# Which preset of skills/snippets to install +# (default: full) +# --interactive Force interactive prompts even if a flag is set +# --for-contributors Shortcut for --mode=copy --scope=spec-discipline +# --help Print usage and exit +# +# Environment: +# ANUTRON_SOURCE Override source-repo location set -euo pipefail @@ -22,8 +35,103 @@ iso_timestamp() { date -u +"%Y-%m-%dT%H:%M:%SZ" } +print_usage() { + cat <<'USAGE' +Usage: install.sh [flags] + +Flags: + --mode= Install mode. symlink (default for writable source) + keeps live edits flowing from the source. copy + (default for read-only source / contributor installs) + produces self-contained files. + --scope= One of: full, spec-discipline, dev-tools, custom. + full installs everything (current behaviour). + spec-discipline installs spec + TDD + quality. + dev-tools adds workflow + PR. custom reads from + .anutron-install.config.json in the target. + --interactive Force prompts even if other flags would suppress them. + --for-contributors Shortcut: --mode=copy --scope=spec-discipline. + --help, -h Print this message. + +Environment: + ANUTRON_SOURCE Override the source repo path. +USAGE +} + +# ============================================================ +# 1. CLI flag parsing +# ============================================================ + +# Globals set by parse_args: +# FLAG_MODE "symlink" | "copy" | "" +# FLAG_SCOPE "full" | "spec-discipline" | "dev-tools" | "custom" | "" +# FLAG_INTERACTIVE "1" | "0" +# FLAG_FOR_CONTRIB "1" | "0" + +parse_args() { + FLAG_MODE="" + FLAG_SCOPE="" + FLAG_INTERACTIVE=0 + FLAG_FOR_CONTRIB=0 + + while [ $# -gt 0 ]; do + case "$1" in + --mode=*) + FLAG_MODE="${1#--mode=}" + case "$FLAG_MODE" in + symlink|copy) ;; + *) die "Invalid --mode value: $FLAG_MODE (expected symlink or copy)" ;; + esac + ;; + --mode) + shift + FLAG_MODE="${1:-}" + case "$FLAG_MODE" in + symlink|copy) ;; + *) die "Invalid --mode value: $FLAG_MODE (expected symlink or copy)" ;; + esac + ;; + --scope=*) + FLAG_SCOPE="${1#--scope=}" + case "$FLAG_SCOPE" in + full|spec-discipline|dev-tools|custom) ;; + *) die "Invalid --scope value: $FLAG_SCOPE (expected full, spec-discipline, dev-tools, or custom)" ;; + esac + ;; + --scope) + shift + FLAG_SCOPE="${1:-}" + case "$FLAG_SCOPE" in + full|spec-discipline|dev-tools|custom) ;; + *) die "Invalid --scope value: $FLAG_SCOPE (expected full, spec-discipline, dev-tools, or custom)" ;; + esac + ;; + --interactive) + FLAG_INTERACTIVE=1 + ;; + --for-contributors) + FLAG_FOR_CONTRIB=1 + ;; + --help|-h) + print_usage + exit 0 + ;; + *) + die "Unknown argument: $1 (try --help)" + ;; + esac + shift + done + + # --for-contributors expansion (does not override explicit flags) + if [ "$FLAG_FOR_CONTRIB" -eq 1 ]; then + [ -z "$FLAG_MODE" ] && FLAG_MODE="copy" + [ -z "$FLAG_SCOPE" ] && FLAG_SCOPE="spec-discipline" + fi +} + # ============================================================ -# 1. Locate source repo +# 2. Locate source repo # ============================================================ locate_source() { @@ -41,19 +149,15 @@ locate_source() { fi # Priority 3: self-locate via readlink (clone+promote mode) - # The skill lives at ~/.claude/skills/anutron-install/ which may be - # a symlink back to a clone of claude-skills local script_path script_path="$(cd "$(dirname "$0")" && pwd)/$(basename "$0")" - # Follow symlinks to find the real path local real_path if command -v greadlink >/dev/null 2>&1; then real_path="$(greadlink -f "$script_path")" elif readlink -f "$script_path" >/dev/null 2>&1; then real_path="$(readlink -f "$script_path")" else - # macOS readlink doesn't support -f; manual resolution real_path="$script_path" while [ -L "$real_path" ]; do local target @@ -67,7 +171,6 @@ locate_source() { real_path="$(cd "$(dirname "$real_path")" && pwd)/$(basename "$real_path")" fi - # Walk up from skills/anutron-install/install.sh to repo root local candidate candidate="$(dirname "$(dirname "$(dirname "$real_path")")")" if [ -d "$candidate/skills" ] && [ -d "$candidate/claude-rules/snippets/global" ]; then @@ -75,7 +178,10 @@ locate_source() { return fi - die "Cannot locate claude-skills source repo. Set \$ANUTRON_SOURCE or install via plugin." + die "Cannot locate claude-skills source repo. Tried all three resolution options: + 1. ANUTRON_SOURCE env var (not set) + 2. ~/.claude/anutron-cache plugin cache (not found) + 3. Self-location: install.sh walked back to a repo root containing skills/ and claude-rules/snippets/global/ (not found)" } validate_source() { @@ -85,128 +191,850 @@ validate_source() { [ -d "$src/hooks" ] || die "Source missing hooks/ directory: $src" } +# Return 0 if source is treated as read-only (cache install). +source_is_read_only() { + local src="$1" + local cache_dir="$HOME/.claude/anutron-cache" + [ "$src" = "$cache_dir" ] +} + # ============================================================ -# 2. Skill symlinking +# 3. Manifest reading # ============================================================ -load_exclude_patterns() { - local src="$1" - local exclude_file="$src/.publish-exclude" +# Globals set by read_manifest: +# MANIFEST_PRESENT "1" | "0" +# MANIFEST_MODE "" or value +# MANIFEST_SCOPE "" or value +# MANIFEST_INCLUDE_SKILLS (array) +# MANIFEST_EXCLUDE_SKILLS (array) +# MANIFEST_INCLUDE_SNIPPETS (array) +# MANIFEST_EXCLUDE_SNIPPETS (array) + +read_manifest() { + MANIFEST_PRESENT=0 + MANIFEST_MODE="" + MANIFEST_SCOPE="" + MANIFEST_INCLUDE_SKILLS=() + MANIFEST_EXCLUDE_SKILLS=() + MANIFEST_INCLUDE_SNIPPETS=() + MANIFEST_EXCLUDE_SNIPPETS=() + + local manifest="./.anutron-install.config.json" + if [ ! -f "$manifest" ]; then + return + fi - if [ -f "$exclude_file" ]; then - cat "$exclude_file" - else - # Default exclude patterns when no .publish-exclude exists - cat << 'DEFAULTS' -airon-* -thanx-* -baker_st-* -frontend-design -playground -plannotator-* -anutron-install -anutron-uninstall -DEFAULTS + MANIFEST_PRESENT=1 + + # Validate JSON + if ! jq empty "$manifest" >/dev/null 2>&1; then + die "Manifest .anutron-install.config.json is not valid JSON" + fi + + MANIFEST_MODE=$(jq -r '.mode // empty' "$manifest") + MANIFEST_SCOPE=$(jq -r '.scope // empty' "$manifest") + + if [ -n "$MANIFEST_MODE" ]; then + case "$MANIFEST_MODE" in + symlink|copy) ;; + *) die "Manifest mode invalid: $MANIFEST_MODE (expected symlink or copy)" ;; + esac + fi + + if [ -n "$MANIFEST_SCOPE" ]; then + case "$MANIFEST_SCOPE" in + full|spec-discipline|dev-tools|custom) ;; + *) die "Manifest scope invalid: $MANIFEST_SCOPE" ;; + esac + fi + + # Read array fields + local line + while IFS= read -r line; do + [ -n "$line" ] && MANIFEST_INCLUDE_SKILLS+=("$line") + done < <(jq -r '.includeSkills[]? // empty' "$manifest" 2>/dev/null) + + while IFS= read -r line; do + [ -n "$line" ] && MANIFEST_EXCLUDE_SKILLS+=("$line") + done < <(jq -r '.excludeSkills[]? // empty' "$manifest" 2>/dev/null) + + while IFS= read -r line; do + [ -n "$line" ] && MANIFEST_INCLUDE_SNIPPETS+=("$line") + done < <(jq -r '.includeSnippets[]? // empty' "$manifest" 2>/dev/null) + + while IFS= read -r line; do + [ -n "$line" ] && MANIFEST_EXCLUDE_SNIPPETS+=("$line") + done < <(jq -r '.excludeSnippets[]? // empty' "$manifest" 2>/dev/null) + + # Warn about unknown top-level keys + local known='["mode","scope","includeSkills","excludeSkills","includeSnippets","excludeSnippets"]' + local unknown_keys + unknown_keys=$(jq -r --argjson known "$known" ' + keys[] | select(. as $k | $known | index($k) | not) + ' "$manifest" 2>/dev/null || true) + if [ -n "$unknown_keys" ]; then + local k + while IFS= read -r k; do + [ -n "$k" ] && echo "Warning: unknown manifest key: $k" >&2 + done <<< "$unknown_keys" fi } -is_excluded() { - local name="$1" - shift - local patterns=("$@") +# ============================================================ +# 4. Interactive prompts +# ============================================================ - for pattern in "${patterns[@]}"; do - # Skip empty lines and comments - [[ -z "$pattern" || "$pattern" == \#* ]] && continue +interactive_prompt() { + # Sets PROMPTED_MODE and PROMPTED_SCOPE, optionally writes manifest. + PROMPTED_MODE="" + PROMPTED_SCOPE="" + + echo "" + echo "Install mode?" + echo " 1) symlink (live edits from source)" + echo " 2) copy (self-contained, share with contributors)" + local mode_choice="" + while [ -z "$mode_choice" ]; do + printf "Choice [1-2]: " + read -r mode_choice || mode_choice="" + case "$mode_choice" in + 1) PROMPTED_MODE="symlink" ;; + 2) PROMPTED_MODE="copy" ;; + *) echo "Please enter 1 or 2."; mode_choice="" ;; + esac + done - # Use bash pattern matching (glob-style) - # shellcheck disable=SC2254 - case "$name" in - $pattern) return 0 ;; + echo "" + echo "Scope?" + echo " 1) full (everything)" + echo " 2) spec-discipline (spec + TDD + quality gates — recommended for contributors)" + echo " 3) dev-tools (spec-discipline + workflow + PR shipping)" + echo " 4) custom (requires .anutron-install.config.json)" + local scope_choice="" + while [ -z "$scope_choice" ]; do + printf "Choice [1-4]: " + read -r scope_choice || scope_choice="" + case "$scope_choice" in + 1) PROMPTED_SCOPE="full" ;; + 2) PROMPTED_SCOPE="spec-discipline" ;; + 3) PROMPTED_SCOPE="dev-tools" ;; + 4) PROMPTED_SCOPE="custom" ;; + *) echo "Please enter 1-4."; scope_choice="" ;; esac done + + echo "" + printf "Save these choices to .anutron-install.config.json so re-runs are non-interactive? [Y/n]: " + local save_choice="" + read -r save_choice || save_choice="" + case "$save_choice" in + n|N|no|No|NO) ;; + *) + jq -n --arg mode "$PROMPTED_MODE" --arg scope "$PROMPTED_SCOPE" \ + '{mode: $mode, scope: $scope}' > ./.anutron-install.config.json + echo "Wrote .anutron-install.config.json" + ;; + esac +} + +# ============================================================ +# 5. Resolve final mode and scope +# ============================================================ + +# Inputs: FLAG_MODE/FLAG_SCOPE, MANIFEST_*, source path, TTY status +# Outputs: RESOLVED_MODE, RESOLVED_SCOPE +resolve_mode_scope() { + local src="$1" + + # Precedence: CLI flag > manifest > interactive (if TTY+no flag+no manifest) > default + RESOLVED_MODE="$FLAG_MODE" + RESOLVED_SCOPE="$FLAG_SCOPE" + + if [ -z "$RESOLVED_MODE" ] && [ -n "${MANIFEST_MODE:-}" ]; then + RESOLVED_MODE="$MANIFEST_MODE" + fi + if [ -z "$RESOLVED_SCOPE" ] && [ -n "${MANIFEST_SCOPE:-}" ]; then + RESOLVED_SCOPE="$MANIFEST_SCOPE" + fi + + # Interactive: TTY stdin, no CLI flag for mode/scope, no manifest + local need_interactive=0 + if [ -t 0 ]; then + if [ -z "$FLAG_MODE" ] && [ -z "$FLAG_SCOPE" ] && [ "${MANIFEST_PRESENT:-0}" -eq 0 ]; then + need_interactive=1 + fi + fi + if [ "$FLAG_INTERACTIVE" -eq 1 ]; then + need_interactive=1 + fi + + if [ "$need_interactive" -eq 1 ]; then + interactive_prompt + [ -z "$RESOLVED_MODE" ] && RESOLVED_MODE="$PROMPTED_MODE" + [ -z "$RESOLVED_SCOPE" ] && RESOLVED_SCOPE="$PROMPTED_SCOPE" + fi + + # Defaults + if [ -z "$RESOLVED_MODE" ]; then + if source_is_read_only "$src"; then + RESOLVED_MODE="copy" + else + RESOLVED_MODE="symlink" + fi + fi + if [ -z "$RESOLVED_SCOPE" ]; then + RESOLVED_SCOPE="full" + fi +} + +# ============================================================ +# 6. Scope resolution +# ============================================================ + +# Helper: glob-match. Returns 0 if $name matches glob pattern $pattern. +glob_match() { + local name="$1" pattern="$2" + case "$name" in + $pattern) return 0 ;; + esac return 1 } -install_skills() { +# Read a preset's resolved fields, recursively applying `extends`. +# Output to globals: +# PRESET_SKILLS_STAR "1" | "0" +# PRESET_SKILL_TAGS (array) +# PRESET_EXCLUDE_SKILLS (array) +# PRESET_SNIPPETS_STAR "1" | "0" +# PRESET_SNIPPET_TAGS (array) +# PRESET_SNIPPET_AUDIENCE (array) +# PRESET_FROM_MANIFEST "1" | "0" +load_preset() { + local src="$1" name="$2" + local presets_file="$src/claude-rules/scope-presets.json" + [ -f "$presets_file" ] || die "Scope presets not found: $presets_file" + + PRESET_SKILLS_STAR=0 + PRESET_SKILL_TAGS=() + PRESET_EXCLUDE_SKILLS=() + PRESET_SNIPPETS_STAR=0 + PRESET_SNIPPET_TAGS=() + PRESET_SNIPPET_AUDIENCE=() + PRESET_FROM_MANIFEST=0 + + _load_preset_recursive "$presets_file" "$name" +} + +_load_preset_recursive() { + local presets_file="$1" name="$2" + + # Check preset exists + if ! jq -e --arg n "$name" '.presets[$n]' "$presets_file" >/dev/null 2>&1; then + die "Scope preset not found: $name" + fi + + # If extends, resolve parent first + local parent + parent=$(jq -r --arg n "$name" '.presets[$n].extends // empty' "$presets_file") + if [ -n "$parent" ]; then + _load_preset_recursive "$presets_file" "$parent" + fi + + # from: "manifest" marker + local from + from=$(jq -r --arg n "$name" '.presets[$n].from // empty' "$presets_file") + if [ "$from" = "manifest" ]; then + PRESET_FROM_MANIFEST=1 + fi + + # skills field + local skills_kind + skills_kind=$(jq -r --arg n "$name" '.presets[$n].skills | type' "$presets_file" 2>/dev/null || echo "null") + if [ "$skills_kind" = "string" ]; then + local s + s=$(jq -r --arg n "$name" '.presets[$n].skills' "$presets_file") + if [ "$s" = "*" ]; then + PRESET_SKILLS_STAR=1 + fi + fi + + # snippets field + local snippets_kind + snippets_kind=$(jq -r --arg n "$name" '.presets[$n].snippets | type' "$presets_file" 2>/dev/null || echo "null") + if [ "$snippets_kind" = "string" ]; then + local s + s=$(jq -r --arg n "$name" '.presets[$n].snippets' "$presets_file") + if [ "$s" = "*" ]; then + PRESET_SNIPPETS_STAR=1 + fi + fi + + # skillTags (additive — replaces parent if specified per design intent: dev-tools sets its own) + local kind + kind=$(jq -r --arg n "$name" '.presets[$n].skillTags | type' "$presets_file" 2>/dev/null || echo "null") + if [ "$kind" = "array" ]; then + PRESET_SKILL_TAGS=() + local line + while IFS= read -r line; do + [ -n "$line" ] && PRESET_SKILL_TAGS+=("$line") + done < <(jq -r --arg n "$name" '.presets[$n].skillTags[]' "$presets_file") + fi + + # excludeSkills (merge with parent — patterns from both apply) + kind=$(jq -r --arg n "$name" '.presets[$n].excludeSkills | type' "$presets_file" 2>/dev/null || echo "null") + if [ "$kind" = "array" ]; then + local line + while IFS= read -r line; do + [ -n "$line" ] && PRESET_EXCLUDE_SKILLS+=("$line") + done < <(jq -r --arg n "$name" '.presets[$n].excludeSkills[]' "$presets_file") + fi + + # snippetTags + kind=$(jq -r --arg n "$name" '.presets[$n].snippetTags | type' "$presets_file" 2>/dev/null || echo "null") + if [ "$kind" = "array" ]; then + PRESET_SNIPPET_TAGS=() + local line + while IFS= read -r line; do + [ -n "$line" ] && PRESET_SNIPPET_TAGS+=("$line") + done < <(jq -r --arg n "$name" '.presets[$n].snippetTags[]' "$presets_file") + fi + + # snippetAudience + kind=$(jq -r --arg n "$name" '.presets[$n].snippetAudience | type' "$presets_file" 2>/dev/null || echo "null") + if [ "$kind" = "array" ]; then + PRESET_SNIPPET_AUDIENCE=() + local line + while IFS= read -r line; do + [ -n "$line" ] && PRESET_SNIPPET_AUDIENCE+=("$line") + done < <(jq -r --arg n "$name" '.presets[$n].snippetAudience[]' "$presets_file") + fi +} + +# Path to parse-frontmatter.sh helper in the source repo +parse_frontmatter_script() { local src="$1" + echo "$src/claude-rules/lib/parse-frontmatter.sh" +} + +# Read tags for a skill into the provided array variable name +get_skill_tags() { + local src="$1" skill_dir="$2" + local parser + parser="$(parse_frontmatter_script "$src")" + if [ -f "$parser" ] && [ -f "$skill_dir/SKILL.md" ]; then + bash "$parser" "$skill_dir/SKILL.md" tags + fi +} + +get_snippet_tags() { + local src="$1" snippet_file="$2" + local parser + parser="$(parse_frontmatter_script "$src")" + if [ -f "$parser" ] && [ -f "$snippet_file" ]; then + bash "$parser" "$snippet_file" tags + fi +} + +get_snippet_audience() { + local src="$1" snippet_file="$2" + local parser + parser="$(parse_frontmatter_script "$src")" + if [ -f "$parser" ] && [ -f "$snippet_file" ]; then + bash "$parser" "$snippet_file" audience + fi +} + +# Returns 0 if any element of array $1 is in array $2. +# Usage: arrays_intersect arr1_name arr2_name +arrays_intersect() { + # Bash 3.2 — pass as eval-friendly strings + local -a a1=("${!1}") a2=("${!2}") + local x y + for x in "${a1[@]+"${a1[@]}"}"; do + for y in "${a2[@]+"${a2[@]}"}"; do + [ "$x" = "$y" ] && return 0 + done + done + return 1 +} + +# Resolve to RESOLVED_SKILLS and RESOLVED_SNIPPETS arrays +resolve_scope() { + local src="$1" scope="$2" + + RESOLVED_SKILLS=() + RESOLVED_SNIPPETS=() + + load_preset "$src" "$scope" + + # Manifest-driven (custom scope) + if [ "$PRESET_FROM_MANIFEST" -eq 1 ]; then + if [ "${MANIFEST_PRESENT:-0}" -ne 1 ]; then + die "--scope=custom requires .anutron-install.config.json in the target directory." + fi + fi + + # --- Skills --- + local skill_dir name + for skill_dir in "$src/skills"/*/; do + [ -d "$skill_dir" ] || continue + name="$(basename "$skill_dir")" + + local included=0 + + # Per-preset exclude patterns + local pat + local excluded=0 + for pat in "${PRESET_EXCLUDE_SKILLS[@]+"${PRESET_EXCLUDE_SKILLS[@]}"}"; do + if glob_match "$name" "$pat"; then + excluded=1 + break + fi + done + + if [ "$excluded" -eq 1 ]; then + continue + fi + + if [ "$PRESET_SKILLS_STAR" -eq 1 ]; then + included=1 + elif [ "${#PRESET_SKILL_TAGS[@]}" -gt 0 ]; then + # Read skill tags and intersect with preset tags + local -a stags=() + local t + while IFS= read -r t; do + [ -n "$t" ] && stags+=("$t") + done < <(get_skill_tags "$src" "$skill_dir") + if [ "${#stags[@]}" -gt 0 ]; then + # Inline intersection for bash 3.2 compat + local x y matched=0 + for x in "${stags[@]}"; do + for y in "${PRESET_SKILL_TAGS[@]}"; do + if [ "$x" = "$y" ]; then matched=1; break; fi + done + [ "$matched" -eq 1 ] && break + done + [ "$matched" -eq 1 ] && included=1 + fi + fi + + # Custom-scope: manifest includeSkills picks up named skills + if [ "$PRESET_FROM_MANIFEST" -eq 1 ]; then + local m + for m in "${MANIFEST_INCLUDE_SKILLS[@]+"${MANIFEST_INCLUDE_SKILLS[@]}"}"; do + if [ "$m" = "$name" ]; then included=1; break; fi + done + fi + + if [ "$included" -eq 1 ]; then + RESOLVED_SKILLS+=("$name") + fi + done + + # Apply legacy .publish-exclude to scope=full only (preserves existing test 1) + if [ "$scope" = "full" ]; then + local exclude_file="$src/.publish-exclude" + if [ -f "$exclude_file" ]; then + local -a publish_patterns=() + local line + while IFS= read -r line; do + [[ -z "$line" || "$line" == \#* ]] && continue + publish_patterns+=("$line") + done < "$exclude_file" + + local -a filtered=() + local n keep p + for n in "${RESOLVED_SKILLS[@]+"${RESOLVED_SKILLS[@]}"}"; do + keep=1 + for p in "${publish_patterns[@]+"${publish_patterns[@]}"}"; do + if glob_match "$n" "$p"; then keep=0; break; fi + done + [ "$keep" -eq 1 ] && filtered+=("$n") + done + RESOLVED_SKILLS=("${filtered[@]+"${filtered[@]}"}") + fi + fi + + # Always exclude anutron-install / anutron-uninstall from any scope + local -a filtered2=() + local n + for n in "${RESOLVED_SKILLS[@]+"${RESOLVED_SKILLS[@]}"}"; do + if [ "$n" = "anutron-install" ] || [ "$n" = "anutron-uninstall" ]; then + continue + fi + filtered2+=("$n") + done + RESOLVED_SKILLS=("${filtered2[@]+"${filtered2[@]}"}") + + # Apply manifest overrides: excludeSkills removes; includeSkills adds (custom scope already handles add via above logic, but for non-custom scopes a manifest can also include extras). + if [ "${MANIFEST_PRESENT:-0}" -eq 1 ]; then + # excludeSkills + local m + if [ "${#MANIFEST_EXCLUDE_SKILLS[@]}" -gt 0 ]; then + local -a kept=() + local n keep + for n in "${RESOLVED_SKILLS[@]+"${RESOLVED_SKILLS[@]}"}"; do + keep=1 + for m in "${MANIFEST_EXCLUDE_SKILLS[@]}"; do + if [ "$n" = "$m" ]; then keep=0; break; fi + done + [ "$keep" -eq 1 ] && kept+=("$n") + done + RESOLVED_SKILLS=("${kept[@]+"${kept[@]}"}") + fi + # includeSkills (non-custom adds; custom handled above) + if [ "$PRESET_FROM_MANIFEST" -ne 1 ] && [ "${#MANIFEST_INCLUDE_SKILLS[@]}" -gt 0 ]; then + for m in "${MANIFEST_INCLUDE_SKILLS[@]}"; do + # Only add if skill dir exists + if [ -d "$src/skills/$m" ]; then + local already=0 + local n + for n in "${RESOLVED_SKILLS[@]+"${RESOLVED_SKILLS[@]}"}"; do + [ "$n" = "$m" ] && already=1 && break + done + [ "$already" -eq 0 ] && RESOLVED_SKILLS+=("$m") + fi + done + fi + fi + + # --- Snippets --- + local snip_file base + for snip_file in "$src/claude-rules/snippets/global"/*.md; do + [ -f "$snip_file" ] || continue + base="$(basename "$snip_file")" + + local included=0 + + if [ "$PRESET_SNIPPETS_STAR" -eq 1 ]; then + included=1 + else + # Read tags + audience + local -a stags=() saud=() + local t a + while IFS= read -r t; do + [ -n "$t" ] && stags+=("$t") + done < <(get_snippet_tags "$src" "$snip_file") + while IFS= read -r a; do + [ -n "$a" ] && saud+=("$a") + done < <(get_snippet_audience "$src" "$snip_file") + + # Must intersect snippetTags AND snippetAudience (when both specified) + local tag_ok=0 aud_ok=0 + + if [ "${#PRESET_SNIPPET_TAGS[@]}" -eq 0 ]; then + tag_ok=1 + else + local x y + for x in "${stags[@]+"${stags[@]}"}"; do + for y in "${PRESET_SNIPPET_TAGS[@]}"; do + [ "$x" = "$y" ] && tag_ok=1 && break + done + [ "$tag_ok" -eq 1 ] && break + done + fi + + if [ "${#PRESET_SNIPPET_AUDIENCE[@]}" -eq 0 ]; then + aud_ok=1 + else + local x y + for x in "${saud[@]+"${saud[@]}"}"; do + for y in "${PRESET_SNIPPET_AUDIENCE[@]}"; do + [ "$x" = "$y" ] && aud_ok=1 && break + done + [ "$aud_ok" -eq 1 ] && break + done + fi + + if [ "$tag_ok" -eq 1 ] && [ "$aud_ok" -eq 1 ]; then + included=1 + fi + fi + + # Custom scope: manifest includeSnippets adds named snippets + if [ "$PRESET_FROM_MANIFEST" -eq 1 ]; then + local m + for m in "${MANIFEST_INCLUDE_SNIPPETS[@]+"${MANIFEST_INCLUDE_SNIPPETS[@]}"}"; do + if [ "$m" = "$base" ]; then included=1; break; fi + done + fi + + if [ "$included" -eq 1 ]; then + RESOLVED_SNIPPETS+=("$base") + fi + done + + # Apply manifest snippet overrides + if [ "${MANIFEST_PRESENT:-0}" -eq 1 ]; then + if [ "${#MANIFEST_EXCLUDE_SNIPPETS[@]}" -gt 0 ]; then + local -a kept=() + local n keep m + for n in "${RESOLVED_SNIPPETS[@]+"${RESOLVED_SNIPPETS[@]}"}"; do + keep=1 + for m in "${MANIFEST_EXCLUDE_SNIPPETS[@]}"; do + [ "$n" = "$m" ] && keep=0 && break + done + [ "$keep" -eq 1 ] && kept+=("$n") + done + RESOLVED_SNIPPETS=("${kept[@]+"${kept[@]}"}") + fi + if [ "$PRESET_FROM_MANIFEST" -ne 1 ] && [ "${#MANIFEST_INCLUDE_SNIPPETS[@]}" -gt 0 ]; then + local m + for m in "${MANIFEST_INCLUDE_SNIPPETS[@]}"; do + if [ -f "$src/claude-rules/snippets/global/$m" ]; then + local already=0 + local n + for n in "${RESOLVED_SNIPPETS[@]+"${RESOLVED_SNIPPETS[@]}"}"; do + [ "$n" = "$m" ] && already=1 && break + done + [ "$already" -eq 0 ] && RESOLVED_SNIPPETS+=("$m") + fi + done + fi + fi +} + +# ============================================================ +# 7. Skill installation +# ============================================================ + +# Strip frontmatter --- ... --- block (if first block) from content read from stdin. +strip_frontmatter_from_file() { + local file="$1" + awk ' + BEGIN { state = 0 } + NR == 1 && /^---$/ { state = 1; next } + state == 1 && /^---$/ { state = 2; next } + state == 1 { next } + { print } + ' "$file" +} + +# Get the source commit (HEAD SHA) or print "null" if not git. +# Only treats $src as a git repo if its top-level matches $src — otherwise we'd +# pick up the commit of an enclosing repo (e.g. fixture inside the kit's worktree). +get_source_commit() { + local src="$1" + local toplevel + toplevel=$(git -C "$src" rev-parse --show-toplevel 2>/dev/null || echo "") + if [ -z "$toplevel" ]; then + echo "null" + return + fi + # Resolve both to absolute paths for comparison + local src_abs + src_abs=$(cd "$src" 2>/dev/null && pwd -P) + local top_abs + top_abs=$(cd "$toplevel" 2>/dev/null && pwd -P) + if [ "$src_abs" = "$top_abs" ]; then + git -C "$src" rev-parse HEAD 2>/dev/null || echo "null" + else + echo "null" + fi +} + +# Returns 0 if $name is in RESOLVED_SKILLS +in_resolved_skills() { + local needle="$1" + local n + for n in "${RESOLVED_SKILLS[@]+"${RESOLVED_SKILLS[@]}"}"; do + [ "$n" = "$needle" ] && return 0 + done + return 1 +} + +install_skills() { + local src="$1" mode="$2" local target_dir="./.claude/skills" mkdir -p "$target_dir" - # Load exclude patterns into array - local -a patterns=() - while IFS= read -r line; do - [[ -z "$line" || "$line" == \#* ]] && continue - patterns+=("$line") - done < <(load_exclude_patterns "$src") + local source_commit + source_commit=$(get_source_commit "$src") + + # Read prev source commit from breadcrumb (used for copy-mode idempotency) + local prev_source_commit="" + local prev_breadcrumb="./.anutron-install.json.prev" + if [ -f "$prev_breadcrumb" ]; then + prev_source_commit=$(jq -r '.sourceCommit // empty' "$prev_breadcrumb" 2>/dev/null || echo "") + fi local -a installed=() local -a added=() local -a removed=() local -a unchanged=() - # Remove dangling symlinks - for link in "$target_dir"/*/; do - [ -e "$link" ] && continue # valid, skip - [ -L "${link%/}" ] || continue # not a symlink, skip - local name - name="$(basename "${link%/}")" - rm -f "${link%/}" - removed+=("$name") - done + # Read previously-owned skill names from the prior breadcrumb. We only ever + # remove things we previously installed; foreign skills (added by the user or + # other tooling) are left untouched even if they aren't in the current scope. + local -a prev_owned_skills=() + if [ -f "$prev_breadcrumb" ]; then + while IFS= read -r line; do + [ -n "$line" ] && prev_owned_skills+=("$line") + done < <(jq -r '(.scopeResolution.skills // .skills // [])[]' "$prev_breadcrumb" 2>/dev/null) + fi - # Also check non-directory symlinks (in case trailing / doesn't work) - for link in "$target_dir"/*; do - [ -e "$link" ] && continue - [ -L "$link" ] || continue - local name - name="$(basename "$link")" - rm -f "$link" - # Avoid duplicate in removed array - local already=false - for r in "${removed[@]+"${removed[@]}"}"; do - [ "$r" = "$name" ] && already=true && break + was_previously_owned() { + local needle="$1" + local n + for n in "${prev_owned_skills[@]+"${prev_owned_skills[@]}"}"; do + [ "$n" = "$needle" ] && return 0 done - $already || removed+=("$name") - done - - # Install skills - for skill_dir in "$src/skills"/*/; do - [ -d "$skill_dir" ] || continue + return 1 + } + + # 1. Clean up only PREVIOUSLY-OWNED entries that aren't in the current + # resolved scope. Foreign skills are skipped. + local existing + for existing in "$target_dir"/* "$target_dir"/.[!.]*; do + [ -e "$existing" ] || [ -L "$existing" ] || continue local name - name="$(basename "$skill_dir")" + name="$(basename "$existing")" + + # Skip dotfiles we don't manage + case "$name" in + ""|".") continue ;; + esac - if is_excluded "$name" "${patterns[@]}"; then + if in_resolved_skills "$name"; then continue fi - local link_path="$target_dir/$name" + if ! was_previously_owned "$name"; then + # Foreign skill — never installed by anutron. Leave it alone. + continue + fi + + # Remove — we previously owned this and the new scope no longer includes it. + if [ -L "$existing" ]; then + rm -f "$existing" + removed+=("$name") + elif [ -d "$existing" ]; then + rm -rf "$existing" + removed+=("$name") + else + rm -f "$existing" + removed+=("$name") + fi + done + + # 2. Install/update each resolved skill + local name + for name in "${RESOLVED_SKILLS[@]+"${RESOLVED_SKILLS[@]}"}"; do + local skill_dir="$src/skills/$name" + [ -d "$skill_dir" ] || continue - if [ -L "$link_path" ]; then - # Already a symlink — check if target matches - local current_target - current_target="$(readlink "$link_path")" - local expected_target="${skill_dir%/}" + local target_path="$target_dir/$name" + + if [ "$mode" = "symlink" ]; then + if [ -L "$target_path" ]; then + local current_target + current_target="$(readlink "$target_path")" + if [ "$current_target" = "$skill_dir" ]; then + unchanged+=("$name") + else + rm -f "$target_path" + ln -s "$skill_dir" "$target_path" + added+=("$name") + fi + else + # Could be a stale copy directory; remove + if [ -d "$target_path" ]; then + rm -rf "$target_path" + elif [ -e "$target_path" ]; then + rm -f "$target_path" + fi + ln -s "$skill_dir" "$target_path" + added+=("$name") + fi + else + # copy mode + # Check if the skill is unchanged before re-copying (idempotency). + # Strategy: inspect the breadcrumb comment written into the dest SKILL.md during + # a prior copy. If the breadcrumb records the same source+commit as the current + # run, the content is identical and we can skip the rm+copy. + # + # For git sources: breadcrumb @ must match exactly. + # Fallback for non-git sources (sourceCommit null): breadcrumb @null must + # be present (proving same source path), then content-diff ignoring that line. + local skip_copy=0 + if [ ! -L "$target_path" ] && [ -d "$target_path" ]; then + local skill_md_check="$target_path/SKILL.md" + if [ -n "$source_commit" ] && [ "$source_commit" != "null" ] && \ + [ -n "$prev_source_commit" ] && [ "$prev_source_commit" != "null" ] && \ + [ "$prev_source_commit" = "$source_commit" ]; then + # Same git source commit: breadcrumb must match exactly + local expected_breadcrumb="" + if grep -qF "$expected_breadcrumb" "$skill_md_check" 2>/dev/null; then + skip_copy=1 + fi + else + # Non-git source or different commit: require breadcrumb showing same src, + # then verify content equality. The breadcrumb is always appended at the end + # of SKILL.md, so we compare only the first N lines (= source line count). + local src_breadcrumb_anchor="anutron-installed-from: ${src}@" + if grep -qF "$src_breadcrumb_anchor" "$skill_md_check" 2>/dev/null; then + local content_matches=1 + local src_skill_md="$skill_dir/SKILL.md" + # Compare SKILL.md: take only the first (wc -l of source) lines of dest + if [ -f "$src_skill_md" ]; then + local src_line_count + src_line_count=$(wc -l < "$src_skill_md" | tr -d ' ') + local tmp_trimmed + tmp_trimmed="$(mktemp /tmp/anutron-diff-XXXXXX)" + head -n "$src_line_count" "$skill_md_check" > "$tmp_trimmed" 2>/dev/null || true + diff "$src_skill_md" "$tmp_trimmed" >/dev/null 2>&1 || content_matches=0 + rm -f "$tmp_trimmed" + fi + # Compare all other files individually + if [ "$content_matches" -eq 1 ]; then + local f rel + for f in "$skill_dir"/* "$skill_dir"/.[!.]*; do + [ -f "$f" ] || continue + rel="$(basename "$f")" + [ "$rel" = "SKILL.md" ] && continue + diff "$f" "$target_path/$rel" >/dev/null 2>&1 || { content_matches=0; break; } + done + fi + if [ "$content_matches" -eq 1 ]; then + skip_copy=1 + fi + fi + fi + fi - if [ "$current_target" = "$expected_target" ]; then + if [ "$skip_copy" -eq 1 ]; then unchanged+=("$name") else - # Target changed — update - rm -f "$link_path" - ln -s "$expected_target" "$link_path" + # If existing target is a symlink, replace with copy + if [ -L "$target_path" ]; then + rm -f "$target_path" + fi + if [ -d "$target_path" ]; then + rm -rf "$target_path" + elif [ -e "$target_path" ]; then + rm -f "$target_path" + fi + mkdir -p "$target_path" + # Copy contents + # Use a portable form that copies hidden files too + if command -v cp >/dev/null 2>&1; then + # Copy everything inside the source dir to the target dir + ( cd "$skill_dir" && tar cf - . ) | ( cd "$target_path" && tar xf - ) + fi + + # Append source-commit breadcrumb to SKILL.md if present + local skill_md="$target_path/SKILL.md" + if [ -f "$skill_md" ]; then + printf '\n\n' "$src" "$source_commit" >> "$skill_md" + fi added+=("$name") fi - else - # New skill - rm -rf "$link_path" # remove if exists as regular dir - ln -s "${skill_dir%/}" "$link_path" - added+=("$name") fi installed+=("$name") done - # Return results via global vars SKILLS_INSTALLED=("${installed[@]+"${installed[@]}"}") SKILLS_ADDED=("${added[@]+"${added[@]}"}") SKILLS_REMOVED=("${removed[@]+"${removed[@]}"}") @@ -214,17 +1042,19 @@ install_skills() { } # ============================================================ -# 3. Hook installation +# 8. Hook installation # ============================================================ install_hooks() { - local src="$1" + local src="$1" mode="$2" local hooks_json="$src/hooks/hooks.json" + HOOKS_INSTALLED=() + HOOK_KEYS=() + HOOK_COMMANDS=() + SETTINGS_TIMESTAMP="" + if [ ! -f "$hooks_json" ]; then - HOOKS_INSTALLED=() - HOOK_KEYS=() - HOOK_COMMANDS=() return fi @@ -237,47 +1067,44 @@ install_hooks() { existing_settings="$(cat "$settings_file")" fi - # Extract hook commands from hooks.json and symlink the scripts - # hooks.json structure: { "hooks": { "EventName": [ { "hooks": [ { "type": "command", "command": "path" } ] } ] } } local -a hook_keys=() local -a hook_commands=() local -a new_hooks_entries=() - # Get all event keys from hooks.json local event_keys event_keys=$(jq -r '.hooks | keys[]' "$hooks_json" 2>/dev/null || true) for event in $event_keys; do hook_keys+=("$event") - - # Get all command paths for this event local commands commands=$(jq -r ".hooks[\"$event\"][] | .hooks[]? | select(.type == \"command\") | .command" "$hooks_json" 2>/dev/null || true) local -a event_hook_entries=() - for cmd_template in $commands; do - # Resolve ${CLAUDE_PLUGIN_ROOT} to the source repo path local resolved_cmd="${cmd_template//\$\{CLAUDE_PLUGIN_ROOT\}/$src}" - if [ -f "$resolved_cmd" ]; then - local basename - basename="$(basename "$resolved_cmd")" - - # Symlink the script into .claude/hooks/ - local local_script="$hooks_dir/$basename" - ln -sf "$resolved_cmd" "$local_script" - - # Build the rewritten command path (relative to project root, using ./) - local rewritten_cmd="./.claude/hooks/$basename" + local basename_v + basename_v="$(basename "$resolved_cmd")" + local local_script="$hooks_dir/$basename_v" + + if [ "$mode" = "symlink" ]; then + # Remove if existing as file + [ -L "$local_script" ] && rm -f "$local_script" + [ -f "$local_script" ] && [ ! -L "$local_script" ] && rm -f "$local_script" + ln -sf "$resolved_cmd" "$local_script" + else + # copy mode + [ -L "$local_script" ] && rm -f "$local_script" + cp -f "$resolved_cmd" "$local_script" + chmod +x "$local_script" 2>/dev/null || true + fi + + local rewritten_cmd="./.claude/hooks/$basename_v" hook_commands+=("$rewritten_cmd") - - # Build the hook entry JSON for this command event_hook_entries+=("$rewritten_cmd") fi done - # Build settings.json hook entries for this event if [ ${#event_hook_entries[@]} -gt 0 ]; then local hooks_array="[" local first=true @@ -290,7 +1117,6 @@ install_hooks() { fi done - # Build the anutron hooks object for settings.json local anutron_hooks="{" local first=true for entry in "${new_hooks_entries[@]+"${new_hooks_entries[@]}"}"; do @@ -299,17 +1125,11 @@ install_hooks() { done anutron_hooks+="}" - # Merge into settings.json - # Strategy: remove old anutron entries from hooks, add new ones - # We track which hook commands are ours via anutronInstalled.hookCommands - - # Get list of previously owned commands (if any) local old_commands_json="[]" if echo "$existing_settings" | jq -e '.anutronInstalled.hookCommands' >/dev/null 2>&1; then old_commands_json=$(echo "$existing_settings" | jq '.anutronInstalled.hookCommands') fi - # Remove old anutron hook entries from existing hooks local cleaned_hooks cleaned_hooks=$(echo "$existing_settings" | jq --argjson old_cmds "$old_commands_json" ' .hooks // {} | @@ -328,7 +1148,6 @@ install_hooks() { from_entries ') - # Merge anutron hooks into the cleaned hooks local merged_hooks merged_hooks=$(echo "$cleaned_hooks" | jq --argjson new "$anutron_hooks" ' . as $existing | @@ -341,7 +1160,6 @@ install_hooks() { ) ') - # Build hook_commands JSON array for the breadcrumb local hook_cmds_json="[" first=true for cmd in "${hook_commands[@]+"${hook_commands[@]}"}"; do @@ -350,7 +1168,6 @@ install_hooks() { done hook_cmds_json+="]" - # Build hook_keys JSON array local hook_keys_json="[" first=true for key in "${hook_keys[@]+"${hook_keys[@]}"}"; do @@ -359,7 +1176,6 @@ install_hooks() { done hook_keys_json+="]" - # Write updated settings.json local version version=$(get_version "$src") local timestamp @@ -390,7 +1206,7 @@ install_hooks() { } # ============================================================ -# 4. CLAUDE.md compilation +# 9. CLAUDE.md compilation # ============================================================ get_version() { @@ -411,13 +1227,14 @@ compile_claudemd() { local marker_begin="" local marker_end="" - # Compile snippets local snippet_dir="$src/claude-rules/snippets/global" local compiled="" local snippet_count=0 local first=true - for f in "$snippet_dir"/*.md; do + local base + for base in "${RESOLVED_SNIPPETS[@]+"${RESOLVED_SNIPPETS[@]}"}"; do + local f="$snippet_dir/$base" [ -f "$f" ] || continue snippet_count=$((snippet_count + 1)) @@ -426,14 +1243,10 @@ compile_claudemd() { else compiled+=$'\n\n---\n\n' fi - compiled+="$(cat "$f")" + # Strip frontmatter from the snippet content + compiled+="$(strip_frontmatter_from_file "$f")" done - # Resolve template variables - # Built-in variables for anutron context: - # CLAUDE_RULES_DIR -> source/claude-rules - # PROJECT_DIR -> source repo root (the snippets describe global behaviors) - # GLOBAL_TARGET -> ~/.claude/CLAUDE.md local rules_dir="$src/claude-rules" local global_target="$HOME/.claude/CLAUDE.md" @@ -441,16 +1254,13 @@ compile_claudemd() { compiled="${compiled//\{\{PROJECT_DIR\}\}/$src}" compiled="${compiled//\{\{GLOBAL_TARGET\}\}/$global_target}" - # Custom variables from variables.env local envfile="$rules_dir/variables.env" if [ -f "$envfile" ]; then - # Parallel arrays for bash 3.2 compatibility (no associative arrays) local var_keys=("CLAUDE_RULES_DIR" "PROJECT_DIR" "GLOBAL_TARGET") local var_vals=("$rules_dir" "$src" "$global_target") while IFS='=' read -r key val; do [[ -z "$key" || "$key" == \#* ]] && continue - # Resolve {{VAR}} references in the value using known variables local i for ((i = 0; i < ${#var_keys[@]}; i++)); do val="${val//\{\{${var_keys[$i]}\}\}/${var_vals[$i]}}" @@ -461,18 +1271,14 @@ compile_claudemd() { done < "$envfile" fi - # Build the delimited block local block block="$marker_begin"$'\n'"$compiled"$'\n'"$marker_end" local claudemd="./CLAUDE.md" if [ ! -f "$claudemd" ]; then - # No existing CLAUDE.md — create with block + placeholder printf '%s\n\n%s\n' "$block" "" > "$claudemd" elif grep -qF "BEGIN ANUTRON-INSTALL" "$claudemd"; then - # Existing markers — replace block in place - # Write block to temp file so awk can read it (avoids newline issues with -v) local block_file block_file=$(mktemp) printf '%s\n' "$block" > "$block_file" @@ -495,7 +1301,6 @@ compile_claudemd() { mv "$tmp" "$claudemd" rm -f "$block_file" else - # Existing CLAUDE.md without markers — insert block at top local tmp tmp=$(mktemp) printf '%s\n\n' "$block" > "$tmp" @@ -509,15 +1314,14 @@ compile_claudemd() { } # ============================================================ -# 5. Breadcrumb +# 10. Breadcrumb # ============================================================ write_breadcrumb() { - local src="$1" + local src="$1" mode="$2" scope="$3" local version version=$(get_version "$src") - # Build skills JSON array local skills_json="[" local first=true for s in "${SKILLS_INSTALLED[@]+"${SKILLS_INSTALLED[@]}"}"; do @@ -526,7 +1330,6 @@ write_breadcrumb() { done skills_json+="]" - # Build hooks JSON array local hooks_json="[" first=true for h in "${HOOK_KEYS[@]+"${HOOK_KEYS[@]}"}"; do @@ -535,7 +1338,6 @@ write_breadcrumb() { done hooks_json+="]" - # Build hookCommands JSON array (command paths for uninstall) local hook_cmds_json="[" first=true for cmd in "${HOOK_COMMANDS[@]+"${HOOK_COMMANDS[@]}"}"; do @@ -544,51 +1346,135 @@ write_breadcrumb() { done hook_cmds_json+="]" + local snippets_json="[" + first=true + for s in "${RESOLVED_SNIPPETS[@]+"${RESOLVED_SNIPPETS[@]}"}"; do + if $first; then first=false; else snippets_json+=","; fi + snippets_json+="\"$s\"" + done + snippets_json+="]" + local timestamp timestamp="${SETTINGS_TIMESTAMP:-$(iso_timestamp)}" - jq -n \ - --arg version "$version" \ - --arg source "$src" \ - --arg installedAt "$timestamp" \ - --argjson skills "$skills_json" \ - --argjson hooks "$hooks_json" \ - --argjson hookCommands "$hook_cmds_json" \ - --arg markerBegin "$CLAUDEMD_MARKER_BEGIN" \ - --arg markerEnd "$CLAUDEMD_MARKER_END" \ - '{ - version: $version, - source: $source, - installedAt: $installedAt, - skills: $skills, - hooks: $hooks, - hookCommands: $hookCommands, - claudeMdMarkers: { - begin: $markerBegin, - end: $markerEnd - } - }' > ./.anutron-install.json + local source_commit + source_commit=$(get_source_commit "$src") + + # Format sourceCommit: if "null", emit JSON null; otherwise as string + local source_commit_arg + if [ "$source_commit" = "null" ]; then + source_commit_arg="null" + jq -n \ + --arg version "$version" \ + --arg source "$src" \ + --arg installedAt "$timestamp" \ + --arg mode "$mode" \ + --arg scope "$scope" \ + --argjson skills "$skills_json" \ + --argjson hooks "$hooks_json" \ + --argjson hookCommands "$hook_cmds_json" \ + --argjson snippets "$snippets_json" \ + --arg markerBegin "$CLAUDEMD_MARKER_BEGIN" \ + --arg markerEnd "$CLAUDEMD_MARKER_END" \ + '{ + version: $version, + source: $source, + installedAt: $installedAt, + mode: $mode, + scope: $scope, + sourceCommit: null, + skills: $skills, + hooks: $hooks, + hookCommands: $hookCommands, + scopeResolution: { + skills: $skills, + snippets: $snippets, + hooks: $hooks + }, + claudeMdMarkers: { + begin: $markerBegin, + end: $markerEnd + } + }' > ./.anutron-install.json + else + jq -n \ + --arg version "$version" \ + --arg source "$src" \ + --arg installedAt "$timestamp" \ + --arg mode "$mode" \ + --arg scope "$scope" \ + --arg sourceCommit "$source_commit" \ + --argjson skills "$skills_json" \ + --argjson hooks "$hooks_json" \ + --argjson hookCommands "$hook_cmds_json" \ + --argjson snippets "$snippets_json" \ + --arg markerBegin "$CLAUDEMD_MARKER_BEGIN" \ + --arg markerEnd "$CLAUDEMD_MARKER_END" \ + '{ + version: $version, + source: $source, + installedAt: $installedAt, + mode: $mode, + scope: $scope, + sourceCommit: $sourceCommit, + skills: $skills, + hooks: $hooks, + hookCommands: $hookCommands, + scopeResolution: { + skills: $skills, + snippets: $snippets, + hooks: $hooks + }, + claudeMdMarkers: { + begin: $markerBegin, + end: $markerEnd + } + }' > ./.anutron-install.json + fi +} + +# ============================================================ +# 11. Stale source detection +# ============================================================ + +# Returns list of skill names whose files changed between prev_commit and current HEAD +detect_stale_skills() { + local src="$1" prev_commit="$2" + if [ -z "$prev_commit" ] || [ "$prev_commit" = "null" ]; then + return + fi + # Must be a git repo + if ! git -C "$src" rev-parse --git-dir >/dev/null 2>&1; then + return + fi + local changed + changed=$(git -C "$src" diff --name-only "$prev_commit"..HEAD -- skills/ 2>/dev/null || true) + if [ -z "$changed" ]; then + return + fi + echo "$changed" | awk -F/ '/^skills\// { print $2 }' | sort -u } # ============================================================ -# 6. Summary +# 12. Summary # ============================================================ print_summary() { - local src="$1" + local src="$1" mode="$2" scope="$3" local version version=$(get_version "$src") local project_dir project_dir="$(pwd)" - # Check for existing breadcrumb (determines first-install vs update) local old_breadcrumb="./.anutron-install.json.prev" local is_update=false local old_version="" + local old_source_commit="" if [ -f "$old_breadcrumb" ]; then is_update=true - old_version=$(jq -r '.version' "$old_breadcrumb" 2>/dev/null || echo "unknown") + old_version=$(jq -r '.version // "unknown"' "$old_breadcrumb" 2>/dev/null || echo "unknown") + old_source_commit=$(jq -r '.sourceCommit // empty' "$old_breadcrumb" 2>/dev/null || echo "") fi local skill_count=${#SKILLS_INSTALLED[@]} @@ -604,7 +1490,6 @@ print_summary() { echo "Updated anutron kit (v${version}):" fi - # Skills detail local skill_detail="" [ "$added_count" -gt 0 ] && skill_detail+=" +${added_count} added" [ "$removed_count" -gt 0 ] && skill_detail+=" -${removed_count} removed" @@ -615,6 +1500,9 @@ print_summary() { echo " Skills: ${skill_count} installed" fi + echo " Mode: ${mode}" + echo " Scope: ${scope}" + if [ "$hook_count" -gt 0 ]; then local keys_str keys_str=$(IFS=', '; echo "${HOOK_KEYS[*]}") @@ -625,13 +1513,34 @@ print_summary() { echo " CLAUDE.md: compiled from ${SNIPPET_COUNT} snippets" + # Stale source detection (copy mode re-run) + if $is_update && [ "$mode" = "copy" ] && [ -n "$old_source_commit" ] && [ "$old_source_commit" != "null" ]; then + local stale_skills + stale_skills=$(detect_stale_skills "$src" "$old_source_commit" || true) + if [ -n "$stale_skills" ]; then + echo "" + echo "Updated since previous install (source advanced):" + echo "$stale_skills" | while IFS= read -r s; do + [ -n "$s" ] && echo " - $s" + done + fi + fi + + if [ "$FLAG_FOR_CONTRIB" -eq 1 ]; then + echo "" + echo "This is a contributor-facing install. Commit the .claude/ tree so contributors" + echo "get the skills on clone:" + echo "" + echo " git add .claude/skills .claude/hooks .claude/settings.json CLAUDE.md" + echo " git commit -m \"Add anutron skills for spec-driven contribution workflow\"" + fi + if ! $is_update; then echo "" echo "Try: /brainstorm, /guard, /execute-plan" echo "Uninstall: /anutron-uninstall" fi - # Clean up the prev file rm -f "$old_breadcrumb" } @@ -642,7 +1551,7 @@ print_summary() { main() { require_jq - # Initialize global state + # Initialize globals SKILLS_INSTALLED=() SKILLS_ADDED=() SKILLS_REMOVED=() @@ -650,35 +1559,35 @@ main() { HOOKS_INSTALLED=() HOOK_KEYS=() HOOK_COMMANDS=() + RESOLVED_SKILLS=() + RESOLVED_SNIPPETS=() SNIPPET_COUNT=0 CLAUDEMD_MARKER_BEGIN="" CLAUDEMD_MARKER_END="" SETTINGS_TIMESTAMP="" - # Step 1: Locate and validate source + parse_args "$@" + local source source=$(locate_source) validate_source "$source" + read_manifest + + resolve_mode_scope "$source" + + resolve_scope "$source" "$RESOLVED_SCOPE" + # Save old breadcrumb for update detection if [ -f ./.anutron-install.json ]; then cp ./.anutron-install.json ./.anutron-install.json.prev fi - # Step 2: Install skills - install_skills "$source" - - # Step 3: Install hooks - install_hooks "$source" - - # Step 4: Compile CLAUDE.md + install_skills "$source" "$RESOLVED_MODE" + install_hooks "$source" "$RESOLVED_MODE" compile_claudemd "$source" - - # Step 5: Write breadcrumb - write_breadcrumb "$source" - - # Step 6: Print summary - print_summary "$source" + write_breadcrumb "$source" "$RESOLVED_MODE" "$RESOLVED_SCOPE" + print_summary "$source" "$RESOLVED_MODE" "$RESOLVED_SCOPE" } main "$@" diff --git a/skills/anutron-install/tests/fixtures/source-repo/.claude-plugin/plugin.json b/skills/anutron-install/tests/fixtures/source-repo/.claude-plugin/plugin.json new file mode 100644 index 0000000..a29b2aa --- /dev/null +++ b/skills/anutron-install/tests/fixtures/source-repo/.claude-plugin/plugin.json @@ -0,0 +1 @@ +{"name":"fixture","version":"0.1.0"} diff --git a/skills/anutron-install/tests/fixtures/source-repo/.publish-exclude b/skills/anutron-install/tests/fixtures/source-repo/.publish-exclude new file mode 100644 index 0000000..8fc9671 --- /dev/null +++ b/skills/anutron-install/tests/fixtures/source-repo/.publish-exclude @@ -0,0 +1,4 @@ +airon-* +personal-only +anutron-install +anutron-uninstall diff --git a/skills/anutron-install/tests/fixtures/source-repo/claude-rules/lib/parse-frontmatter.sh b/skills/anutron-install/tests/fixtures/source-repo/claude-rules/lib/parse-frontmatter.sh new file mode 100755 index 0000000..d06f5be --- /dev/null +++ b/skills/anutron-install/tests/fixtures/source-repo/claude-rules/lib/parse-frontmatter.sh @@ -0,0 +1,106 @@ +#!/usr/bin/env bash +# parse-frontmatter.sh +# +# Extract a single YAML key's value from a markdown file's frontmatter. +# Frontmatter must be the very first block delimited by --- / --- lines. +# +# Supported value shapes: +# scalar: key: value +# inline list: key: [a, b, c] +# +# Block-list (- item lines) is NOT supported. +# Prints each list element (or the scalar) on its own line to stdout. +# Prints nothing if key is absent, no frontmatter, or file not found. +# Always exits 0. + +set -euo pipefail + +FILE="${1:-}" +KEY="${2:-}" + +if [ -z "$FILE" ] || [ -z "$KEY" ]; then + exit 0 +fi + +if [ ! -f "$FILE" ]; then + exit 0 +fi + +# Read the frontmatter block: must start at line 1 with --- +# and end at the next --- line. If no closing --- found, treat as no frontmatter. + +in_frontmatter=0 +found_open=0 +value="" + +while IFS= read -r line; do + if [ $found_open -eq 0 ]; then + # First line must be exactly --- + if [ "$line" = "---" ]; then + found_open=1 + in_frontmatter=1 + continue + else + # Not a frontmatter file + break + fi + fi + + # Inside frontmatter — look for closing --- + if [ "$line" = "---" ]; then + in_frontmatter=0 + break + fi + + # Try to match key: ... + # Strip leading whitespace for the comparison + trimmed="${line#"${line%%[![:space:]]*}"}" + key_prefix="${KEY}: " + if [[ "$trimmed" == "${KEY}: "* ]] || [[ "$trimmed" == "${KEY}:"* ]]; then + # Extract value after the colon + raw_value="${trimmed#*: }" + # Handle case where there's no space after colon + if [[ "$trimmed" == "${KEY}:" ]]; then + raw_value="" + fi + value="$raw_value" + fi +done < "$FILE" + +# If we never found an opening ---, nothing to output +if [ $found_open -eq 0 ]; then + exit 0 +fi + +# If key wasn't found, nothing to output +if [ -z "$value" ]; then + exit 0 +fi + +# Parse value: inline list or scalar +trimmed_value="${value#"${value%%[![:space:]]*}"}" +trimmed_value="${trimmed_value%"${trimmed_value##*[![:space:]]}"}" + +if [[ "$trimmed_value" == "["* ]]; then + # Inline list: [a, b, c] + # Strip brackets + inner="${trimmed_value#[}" + inner="${inner%]}" + # Split by comma, trim whitespace from each element + IFS=',' read -ra items <<< "$inner" + for item in "${items[@]}"; do + # Trim whitespace + trimmed_item="${item#"${item%%[![:space:]]*}"}" + trimmed_item="${trimmed_item%"${trimmed_item##*[![:space:]]}"}" + if [ -n "$trimmed_item" ]; then + echo "$trimmed_item" + fi + done +else + # Scalar + if [ -n "$trimmed_value" ]; then + echo "$trimmed_value" + fi +fi + +exit 0 diff --git a/skills/anutron-install/tests/fixtures/source-repo/claude-rules/scope-presets.json b/skills/anutron-install/tests/fixtures/source-repo/claude-rules/scope-presets.json new file mode 100644 index 0000000..86d0d29 --- /dev/null +++ b/skills/anutron-install/tests/fixtures/source-repo/claude-rules/scope-presets.json @@ -0,0 +1,26 @@ +{ + "$schema": "scope-presets", + "presets": { + "full": { + "description": "Everything not matched by the source's hardcoded exclude list.", + "skills": "*", + "snippets": "*", + "excludeSkills": ["airon-*", "personal-only", "anutron-install", "anutron-uninstall"] + }, + "spec-discipline": { + "description": "Spec-first + TDD + quality gates.", + "skillTags": ["spec", "quality"], + "snippetTags": ["spec", "quality", "formatting"], + "snippetAudience": ["shared"] + }, + "dev-tools": { + "extends": "spec-discipline", + "description": "spec-discipline + parallel workflow + PR shipping.", + "skillTags": ["spec", "quality", "workflow", "pr"] + }, + "custom": { + "description": "Read from .anutron-install.config.json in the target repo.", + "from": "manifest" + } + } +} diff --git a/skills/anutron-install/tests/fixtures/source-repo/claude-rules/snippets/global/010-shared-formatting.md b/skills/anutron-install/tests/fixtures/source-repo/claude-rules/snippets/global/010-shared-formatting.md new file mode 100644 index 0000000..d49e5b0 --- /dev/null +++ b/skills/anutron-install/tests/fixtures/source-repo/claude-rules/snippets/global/010-shared-formatting.md @@ -0,0 +1,7 @@ +--- +tags: [formatting] +audience: [shared] +--- +## Shared Formatting Rules + +Use proper markdown with blank lines between sections. diff --git a/skills/anutron-install/tests/fixtures/source-repo/claude-rules/snippets/global/020-shared-spec.md b/skills/anutron-install/tests/fixtures/source-repo/claude-rules/snippets/global/020-shared-spec.md new file mode 100644 index 0000000..39ce00e --- /dev/null +++ b/skills/anutron-install/tests/fixtures/source-repo/claude-rules/snippets/global/020-shared-spec.md @@ -0,0 +1,7 @@ +--- +tags: [spec] +audience: [shared] +--- +## Spec-Driven Development + +Write specs before code. Tests before implementation. diff --git a/skills/anutron-install/tests/fixtures/source-repo/claude-rules/snippets/global/aaron-personal.md b/skills/anutron-install/tests/fixtures/source-repo/claude-rules/snippets/global/aaron-personal.md new file mode 100644 index 0000000..e269cd8 --- /dev/null +++ b/skills/anutron-install/tests/fixtures/source-repo/claude-rules/snippets/global/aaron-personal.md @@ -0,0 +1,7 @@ +--- +tags: [personal] +audience: [aaron] +--- +## Aaron's Personal Preferences + +This snippet is for Aaron only and should not appear in contributor installs. diff --git a/skills/anutron-install/tests/fixtures/source-repo/hooks/hooks.json b/skills/anutron-install/tests/fixtures/source-repo/hooks/hooks.json new file mode 100644 index 0000000..e0c5303 --- /dev/null +++ b/skills/anutron-install/tests/fixtures/source-repo/hooks/hooks.json @@ -0,0 +1 @@ +{"hooks": {"SessionStart": [{"hooks": [{"type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/scripts/dummy-hook.sh"}]}]}} diff --git a/skills/anutron-install/tests/fixtures/source-repo/hooks/scripts/dummy-hook.sh b/skills/anutron-install/tests/fixtures/source-repo/hooks/scripts/dummy-hook.sh new file mode 100755 index 0000000..06bd986 --- /dev/null +++ b/skills/anutron-install/tests/fixtures/source-repo/hooks/scripts/dummy-hook.sh @@ -0,0 +1,2 @@ +#!/bin/bash +exit 0 diff --git a/skills/anutron-install/tests/fixtures/source-repo/skills/airon-excluded/SKILL.md b/skills/anutron-install/tests/fixtures/source-repo/skills/airon-excluded/SKILL.md new file mode 100644 index 0000000..a8e36b4 --- /dev/null +++ b/skills/anutron-install/tests/fixtures/source-repo/skills/airon-excluded/SKILL.md @@ -0,0 +1,7 @@ +--- +name: airon-excluded +description: A personal skill matching the airon-* exclude pattern. +tags: [personal] +--- + +Fixture airon-excluded skill. diff --git a/skills/anutron-install/tests/fixtures/source-repo/skills/brainstorm/SKILL.md b/skills/anutron-install/tests/fixtures/source-repo/skills/brainstorm/SKILL.md new file mode 100644 index 0000000..06f36e6 --- /dev/null +++ b/skills/anutron-install/tests/fixtures/source-repo/skills/brainstorm/SKILL.md @@ -0,0 +1,7 @@ +--- +name: brainstorm +description: Explore ideas and design solutions collaboratively before building. +tags: [spec] +--- + +Fixture brainstorm skill. diff --git a/skills/anutron-install/tests/fixtures/source-repo/skills/bugbash/SKILL.md b/skills/anutron-install/tests/fixtures/source-repo/skills/bugbash/SKILL.md new file mode 100644 index 0000000..2339322 --- /dev/null +++ b/skills/anutron-install/tests/fixtures/source-repo/skills/bugbash/SKILL.md @@ -0,0 +1,7 @@ +--- +name: bugbash +description: Interactive QA session for reporting and fixing bugs in parallel. +tags: [workflow] +--- + +Fixture bugbash skill. diff --git a/skills/anutron-install/tests/fixtures/source-repo/skills/guard/SKILL.md b/skills/anutron-install/tests/fixtures/source-repo/skills/guard/SKILL.md new file mode 100644 index 0000000..235c057 --- /dev/null +++ b/skills/anutron-install/tests/fixtures/source-repo/skills/guard/SKILL.md @@ -0,0 +1,7 @@ +--- +name: guard +description: Pre-commit quality gate — checks for secrets and test failures. +tags: [quality] +--- + +Fixture guard skill. diff --git a/skills/anutron-install/tests/fixtures/source-repo/skills/personal-only/SKILL.md b/skills/anutron-install/tests/fixtures/source-repo/skills/personal-only/SKILL.md new file mode 100644 index 0000000..ab0eebd --- /dev/null +++ b/skills/anutron-install/tests/fixtures/source-repo/skills/personal-only/SKILL.md @@ -0,0 +1,7 @@ +--- +name: personal-only +description: A personal skill that should not appear in non-full scopes. +tags: [personal] +--- + +Fixture personal-only skill. diff --git a/skills/anutron-install/tests/fixtures/source-repo/skills/pr/SKILL.md b/skills/anutron-install/tests/fixtures/source-repo/skills/pr/SKILL.md new file mode 100644 index 0000000..ec58137 --- /dev/null +++ b/skills/anutron-install/tests/fixtures/source-repo/skills/pr/SKILL.md @@ -0,0 +1,7 @@ +--- +name: pr +description: Open a PR, wait for CI, fix failures, address review, loop until green. +tags: [pr] +--- + +Fixture pr skill. diff --git a/skills/anutron-install/tests/fixtures/source-repo/skills/untagged/SKILL.md b/skills/anutron-install/tests/fixtures/source-repo/skills/untagged/SKILL.md new file mode 100644 index 0000000..66a609f --- /dev/null +++ b/skills/anutron-install/tests/fixtures/source-repo/skills/untagged/SKILL.md @@ -0,0 +1,6 @@ +--- +name: untagged +description: A skill with no tags field — only installable under scope=full. +--- + +Fixture untagged skill. diff --git a/skills/anutron-install/tests/test-install.sh b/skills/anutron-install/tests/test-install.sh index 468ac82..49d864f 100755 --- a/skills/anutron-install/tests/test-install.sh +++ b/skills/anutron-install/tests/test-install.sh @@ -1,15 +1,21 @@ #!/bin/bash # test-install.sh — End-to-end tests for anutron-install # -# Sets up a sandbox directory, runs install.sh against the real -# claude-skills repo, then verifies all artifacts are correct. -# Also tests idempotent re-run. +# Sets up a sandbox directory, runs install.sh against a self-contained +# fixture source repo, then verifies all artifacts are correct. +# +# Usage: +# bash test-install.sh # uses bundled fixture +# ANUTRON_SOURCE=/path/to/source bash test-install.sh # override source set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" INSTALL_SH="$SCRIPT_DIR/../install.sh" -SOURCE_REPO="/Users/aaron/Personal/claude-skills" + +# Self-contained fixture — no external dependency on a real clone +FIXTURE_SOURCE="$SCRIPT_DIR/fixtures/source-repo" +SOURCE_REPO="${ANUTRON_SOURCE:-$FIXTURE_SOURCE}" # Sanity: source repo must exist if [ ! -d "$SOURCE_REPO/skills" ]; then @@ -17,10 +23,9 @@ if [ ! -d "$SOURCE_REPO/skills" ]; then exit 0 fi -# Create sandbox -SANDBOX="/tmp/anutron-test-$$-$(date +%s)" -mkdir -p "$SANDBOX" -trap 'rm -rf "$SANDBOX"' EXIT +# ============================================================ +# Test helpers +# ============================================================ passed=0 failed=0 @@ -50,6 +55,15 @@ assert_symlink() { assert "$1 is a symlink" test -L "$1" } +assert_not_symlink() { + assert "$1 is NOT a symlink" bash -c "! test -L '$1'" +} + +assert_regular_file() { + local path="$1" + assert "$path is a regular file (not symlink)" bash -c "test -f '$path' && ! test -L '$path'" +} + assert_file_contains() { local file="$1" pattern="$2" assert "$file contains '$pattern'" grep -q "$pattern" "$file" @@ -57,7 +71,15 @@ assert_file_contains() { assert_file_not_contains() { local file="$1" pattern="$2" - assert "$file does not contain '$pattern'" bash -c "! grep -q '$pattern' '$file'" + # Use the function-local args (no shell-quoting hazards) instead of + # interpolating $pattern into a bash -c string. + total=$((total + 1)) + if grep -qF "$pattern" "$file" >/dev/null 2>&1; then + failed=$((failed + 1)) + echo "FAIL: $file does not contain '$pattern'" + else + passed=$((passed + 1)) + fi } assert_json_key() { @@ -76,11 +98,27 @@ assert_equals() { fi } +skip_test() { + local reason="$1" + echo "SKIP: $reason" +} + +# Create a fresh sandbox and return its path +make_sandbox() { + local sb + sb="/tmp/anutron-test-$$-$(date +%s)-$RANDOM" + mkdir -p "$sb" + echo "$sb" +} + # ============================================================ -# Test 1: Fresh install +# Test 1: Fresh install (baseline — uses fixture) # ============================================================ echo "=== Test 1: Fresh install ===" +SANDBOX="$(make_sandbox)" +trap 'rm -rf "$SANDBOX"' EXIT + cd "$SANDBOX" ANUTRON_SOURCE="$SOURCE_REPO" bash "$INSTALL_SH" > /tmp/anutron-test-output-$$.txt 2>&1 install_exit=$? @@ -89,14 +127,15 @@ assert "install.sh exits 0" test "$install_exit" -eq 0 # --- Skills --- assert_dir_exists "$SANDBOX/.claude/skills" -# Check that publishable skills are symlinked +# Fixture has: brainstorm (spec), guard (quality), bugbash (workflow), pr (pr), +# personal-only (personal), untagged (no tags), airon-excluded (personal, matches airon-*) +# Full scope (default) excludes: airon-*, personal-only per fixture scope-presets.json assert_symlink "$SANDBOX/.claude/skills/brainstorm" assert_symlink "$SANDBOX/.claude/skills/guard" -assert_symlink "$SANDBOX/.claude/skills/execute-plan" -# Check that excluded skills are NOT present -assert "airon-blog not installed" test ! -e "$SANDBOX/.claude/skills/airon-blog" -assert "thanx-ai-adoption not installed" test ! -e "$SANDBOX/.claude/skills/thanx-ai-adoption" +# Excluded skills should not be present +assert "airon-excluded not installed" test ! -e "$SANDBOX/.claude/skills/airon-excluded" +assert "personal-only not installed" test ! -e "$SANDBOX/.claude/skills/personal-only" assert "anutron-install not installed (self-exclude)" test ! -e "$SANDBOX/.claude/skills/anutron-install" # Verify symlink targets resolve @@ -120,7 +159,6 @@ assert_json_key "$SANDBOX/.claude/settings.json" '.hooks' # Check that hooks reference scripts under .claude/hooks/ hook_cmds=$(jq -r '.. | .command? // empty' "$SANDBOX/.claude/settings.json" 2>/dev/null) for cmd in $hook_cmds; do - # Commands should be under .claude/hooks/ (relative or absolute) assert "hook command references local path: $cmd" bash -c "echo '$cmd' | grep -q '.claude/hooks/'" done @@ -186,19 +224,45 @@ assert "no duplicate hooks after re-run" test "$hook_count" -le 5 begin_count=$(grep -c "BEGIN ANUTRON-INSTALL" "$SANDBOX/CLAUDE.md") assert_equals "exactly one BEGIN marker" "1" "$begin_count" -# Breadcrumb updated (installedAt should change) +# Breadcrumb updated (installedAt should be present) new_timestamp=$(jq -r '.installedAt' "$SANDBOX/.anutron-install.json") -assert "breadcrumb timestamp updated" test -n "$new_timestamp" +assert "breadcrumb timestamp present" test -n "$new_timestamp" # Re-run summary should say "Updated" assert_file_contains "/tmp/anutron-test-output2-$$.txt" "Updated\|Installed" +echo "" +echo "=== Test 2b: copy-mode idempotent re-run (unchanged skill tracking) ===" +# Verifies that a second --mode=copy run against the same source marks skills as +# unchanged rather than re-copying them (spec: "unchanged skills are not re-copied"). +# Implementation choice: uses summary output "unchanged" tally (SKILLS_UNCHANGED count). + +SANDBOX_T2B="$(make_sandbox)" +T2B_OUT1="/tmp/anutron-test-t2b1-$$.txt" +T2B_OUT2="/tmp/anutron-test-t2b2-$$.txt" + +cd "$SANDBOX_T2B" +ANUTRON_SOURCE="$SOURCE_REPO" bash "$INSTALL_SH" --mode=copy > "$T2B_OUT1" 2>&1 +t2b_exit1=$? +assert "copy-mode first run exits 0" test "$t2b_exit1" -eq 0 + +# Re-run with same source — skills should be detected as unchanged +cd "$SANDBOX_T2B" +ANUTRON_SOURCE="$SOURCE_REPO" bash "$INSTALL_SH" --mode=copy > "$T2B_OUT2" 2>&1 +t2b_exit2=$? +assert "copy-mode second run exits 0" test "$t2b_exit2" -eq 0 + +# Summary on second run must mention "unchanged" (SKILLS_UNCHANGED count > 0) +assert_file_contains "$T2B_OUT2" "unchanged" + +rm -f "$T2B_OUT1" "$T2B_OUT2" +rm -rf "$SANDBOX_T2B" + echo "" echo "=== Test 3: Existing CLAUDE.md without markers ===" # Create a new sandbox with existing CLAUDE.md -SANDBOX2="/tmp/anutron-test2-$$-$(date +%s)" -mkdir -p "$SANDBOX2" +SANDBOX2="$(make_sandbox)" cat > "$SANDBOX2/CLAUDE.md" << 'EXISTING' # My Project @@ -226,7 +290,7 @@ rm -rf "$SANDBOX2" echo "" echo "=== Test 4: settings.json preserves user keys ===" -SANDBOX3="/tmp/anutron-test3-$$-$(date +%s)" +SANDBOX3="$(make_sandbox)" mkdir -p "$SANDBOX3/.claude" # Create existing settings with user config @@ -256,7 +320,7 @@ rm -rf "$SANDBOX3" echo "" echo "=== Test 5: Dangling symlink cleanup ===" -SANDBOX4="/tmp/anutron-test4-$$-$(date +%s)" +SANDBOX4="$(make_sandbox)" mkdir -p "$SANDBOX4/.claude/skills" # Create a dangling symlink (simulates a removed skill) @@ -271,6 +335,413 @@ assert "dangling symlink removed" test ! -e "$SANDBOX4/.claude/skills/old-skill" rm -rf "$SANDBOX4" +# ============================================================ +# RED TESTS — All blocks below SHOULD FAIL until Stage 3 +# implements the new features. Each block is marked with +# # RED: fails until Stage 3 implements +# ============================================================ + +echo "" +echo "=== Test 6: --mode=copy produces regular files, not symlinks ===" +# RED: fails until Stage 3 implements --mode=copy + +SANDBOX_T6="$(make_sandbox)" +cd "$SANDBOX_T6" +ANUTRON_SOURCE="$SOURCE_REPO" bash "$INSTALL_SH" --mode=copy > /dev/null 2>&1 +t6_exit=$? +assert "mode=copy install exits 0" test "$t6_exit" -eq 0 +assert_dir_exists "$SANDBOX_T6/.claude/skills/brainstorm" +assert_not_symlink "$SANDBOX_T6/.claude/skills/brainstorm" +# At least one file inside should be a regular file +assert_regular_file "$SANDBOX_T6/.claude/skills/brainstorm/SKILL.md" +rm -rf "$SANDBOX_T6" + +echo "" +echo "=== Test 7: copy-mode install survives source deletion ===" +# RED: fails until Stage 3 implements --mode=copy + +SANDBOX_T7="$(make_sandbox)" +# Copy fixture to a temp location so we can delete it +TEMP_SOURCE="$(make_sandbox)/source-copy" +cp -r "$SOURCE_REPO" "$TEMP_SOURCE" + +cd "$SANDBOX_T7" +ANUTRON_SOURCE="$TEMP_SOURCE" bash "$INSTALL_SH" --mode=copy > /dev/null 2>&1 +t7_exit=$? +assert "mode=copy install with temp source exits 0" test "$t7_exit" -eq 0 + +# Delete source +rm -rf "$TEMP_SOURCE" + +# Installed files should still be readable +assert "brainstorm/SKILL.md still readable after source deletion" test -r "$SANDBOX_T7/.claude/skills/brainstorm/SKILL.md" + +# Control: symlink mode with deleted source leaves dangling links +SANDBOX_T7_SYM="$(make_sandbox)" +TEMP_SOURCE2="$(make_sandbox)/source-copy2" +cp -r "$SOURCE_REPO" "$TEMP_SOURCE2" +cd "$SANDBOX_T7_SYM" +ANUTRON_SOURCE="$TEMP_SOURCE2" bash "$INSTALL_SH" --mode=symlink > /dev/null 2>&1 +rm -rf "$TEMP_SOURCE2" +assert "symlink mode: brainstorm not readable after source deletion (dangling)" bash -c "! test -r '$SANDBOX_T7_SYM/.claude/skills/brainstorm/SKILL.md'" + +rm -rf "$SANDBOX_T7" "$SANDBOX_T7_SYM" + +echo "" +echo "=== Test 8: --scope=spec-discipline includes brainstorm but not bugbash/pr ===" +# RED: fails until Stage 3 implements --scope + +SANDBOX_T8="$(make_sandbox)" +cd "$SANDBOX_T8" +ANUTRON_SOURCE="$SOURCE_REPO" bash "$INSTALL_SH" --scope=spec-discipline > /dev/null 2>&1 +t8_exit=$? +assert "scope=spec-discipline install exits 0" test "$t8_exit" -eq 0 +assert_dir_exists "$SANDBOX_T8/.claude/skills/brainstorm" +assert_dir_exists "$SANDBOX_T8/.claude/skills/guard" +assert "bugbash not installed under spec-discipline" test ! -e "$SANDBOX_T8/.claude/skills/bugbash" +assert "pr not installed under spec-discipline" test ! -e "$SANDBOX_T8/.claude/skills/pr" +rm -rf "$SANDBOX_T8" + +echo "" +echo "=== Test 9: tag-based selection picks up newly-added tagged skill ===" +# RED: fails until Stage 3 implements tag-based scope resolution + +# Use a copy of the fixture so we can add a skill to it +FIXTURE_COPY="$(make_sandbox)/fixture-copy" +cp -r "$SOURCE_REPO" "$FIXTURE_COPY" + +SANDBOX_T9="$(make_sandbox)" +cd "$SANDBOX_T9" +ANUTRON_SOURCE="$FIXTURE_COPY" bash "$INSTALL_SH" --scope=spec-discipline > /dev/null 2>&1 + +# New skill not yet added +assert "new-spec-skill absent before being added" test ! -e "$SANDBOX_T9/.claude/skills/new-spec-skill" + +# Add a new skill with tags: [spec] +mkdir -p "$FIXTURE_COPY/skills/new-spec-skill" +cat > "$FIXTURE_COPY/skills/new-spec-skill/SKILL.md" << 'NEWSKILL' +--- +name: new-spec-skill +description: A newly added spec skill for testing tag-based selection. +tags: [spec] +--- + +New spec skill body. +NEWSKILL + +# Re-run installer +cd "$SANDBOX_T9" +ANUTRON_SOURCE="$FIXTURE_COPY" bash "$INSTALL_SH" --scope=spec-discipline > /dev/null 2>&1 + +assert_dir_exists "$SANDBOX_T9/.claude/skills/new-spec-skill" +rm -rf "$SANDBOX_T9" "$FIXTURE_COPY" + +echo "" +echo "=== Test 10: --scope=custom without manifest fails clearly ===" +# RED: fails until Stage 3 implements --scope=custom manifest validation + +SANDBOX_T10="$(make_sandbox)" +cd "$SANDBOX_T10" +set +e +custom_output=$(ANUTRON_SOURCE="$SOURCE_REPO" bash "$INSTALL_SH" --scope=custom 2>&1) +custom_exit=$? +set -e +assert "scope=custom without manifest exits non-zero" test "$custom_exit" -ne 0 +assert "scope=custom error mentions .anutron-install.config.json" bash -c "echo '$custom_output' | grep -q '.anutron-install.config.json'" +rm -rf "$SANDBOX_T10" + +echo "" +echo "=== Test 11: manifest values used when no flags passed ===" +# RED: fails until Stage 3 implements manifest reading + +SANDBOX_T11="$(make_sandbox)" +cat > "$SANDBOX_T11/.anutron-install.config.json" << 'MANIFEST' +{"mode": "copy", "scope": "spec-discipline"} +MANIFEST + +cd "$SANDBOX_T11" +ANUTRON_SOURCE="$SOURCE_REPO" bash "$INSTALL_SH" > /dev/null 2>&1 +t11_exit=$? +assert "manifest-driven install exits 0" test "$t11_exit" -eq 0 + +# Should behave like --mode=copy --scope=spec-discipline +assert_dir_exists "$SANDBOX_T11/.claude/skills/brainstorm" +assert_not_symlink "$SANDBOX_T11/.claude/skills/brainstorm" +assert "bugbash not installed (scope from manifest)" test ! -e "$SANDBOX_T11/.claude/skills/bugbash" + +# Breadcrumb should confirm mode and scope +assert_json_key "$SANDBOX_T11/.anutron-install.json" '.mode' +t11_mode=$(jq -r '.mode' "$SANDBOX_T11/.anutron-install.json" 2>/dev/null || echo "") +assert_equals "breadcrumb mode=copy (from manifest)" "copy" "$t11_mode" +t11_scope=$(jq -r '.scope' "$SANDBOX_T11/.anutron-install.json" 2>/dev/null || echo "") +assert_equals "breadcrumb scope=spec-discipline (from manifest)" "spec-discipline" "$t11_scope" +rm -rf "$SANDBOX_T11" + +echo "" +echo "=== Test 12: command-line flags override manifest ===" +# RED: fails until Stage 3 implements flag/manifest precedence + +SANDBOX_T12="$(make_sandbox)" +cat > "$SANDBOX_T12/.anutron-install.config.json" << 'MANIFEST' +{"mode": "symlink", "scope": "spec-discipline"} +MANIFEST + +cd "$SANDBOX_T12" +ANUTRON_SOURCE="$SOURCE_REPO" bash "$INSTALL_SH" --scope=full > /dev/null 2>&1 +t12_exit=$? +assert "flag-override install exits 0" test "$t12_exit" -eq 0 + +# --scope=full overrides manifest's spec-discipline, so bugbash should be present +assert_dir_exists "$SANDBOX_T12/.claude/skills/bugbash" +rm -rf "$SANDBOX_T12" + +echo "" +echo "=== Test 13: --for-contributors equivalent to --mode=copy --scope=spec-discipline ===" +# RED: fails until Stage 3 implements --for-contributors + +SANDBOX_T13A="$(make_sandbox)" +SANDBOX_T13B="$(make_sandbox)" + +# Capture stdout so we can assert on the post-install message (not /dev/null) +T13A_OUT="/tmp/anutron-test-t13a-$$.txt" +cd "$SANDBOX_T13A" +ANUTRON_SOURCE="$SOURCE_REPO" bash "$INSTALL_SH" --for-contributors > "$T13A_OUT" 2>&1 + +cd "$SANDBOX_T13B" +ANUTRON_SOURCE="$SOURCE_REPO" bash "$INSTALL_SH" --mode=copy --scope=spec-discipline > /dev/null 2>&1 + +# Skill listings should match +skills_13a=$(ls "$SANDBOX_T13A/.claude/skills/" 2>/dev/null | sort || echo "") +skills_13b=$(ls "$SANDBOX_T13B/.claude/skills/" 2>/dev/null | sort || echo "") +assert_equals "--for-contributors and --mode=copy --scope=spec-discipline produce same skill list" "$skills_13a" "$skills_13b" + +# Breadcrumb should have mode=copy and scope=spec-discipline +if [ -f "$SANDBOX_T13A/.anutron-install.json" ]; then + t13_mode=$(jq -r '.mode' "$SANDBOX_T13A/.anutron-install.json" 2>/dev/null || echo "") + t13_scope=$(jq -r '.scope' "$SANDBOX_T13A/.anutron-install.json" 2>/dev/null || echo "") + assert_equals "--for-contributors breadcrumb mode=copy" "copy" "$t13_mode" + assert_equals "--for-contributors breadcrumb scope=spec-discipline" "spec-discipline" "$t13_scope" +fi + +# Post-install message must mention git add and .claude/skills (spec: "post-install message mentions commit") +assert_file_contains "$T13A_OUT" "git add" +assert_file_contains "$T13A_OUT" ".claude/skills" +rm -f "$T13A_OUT" +rm -rf "$SANDBOX_T13A" "$SANDBOX_T13B" + +echo "" +echo "=== Test 14: breadcrumb has mode, scope, sourceCommit, scopeResolution ===" +# RED: fails until Stage 3 implements extended breadcrumb + +# Sub-test A: source is a git repo +SANDBOX_T14A="$(make_sandbox)" +FIXTURE_GIT="$(make_sandbox)/fixture-git" +cp -r "$SOURCE_REPO" "$FIXTURE_GIT" + +# Make it a git repo +git -C "$FIXTURE_GIT" init -q +git -C "$FIXTURE_GIT" add . +GIT_AUTHOR_NAME="Test" GIT_AUTHOR_EMAIL="test@test.com" \ + GIT_COMMITTER_NAME="Test" GIT_COMMITTER_EMAIL="test@test.com" \ + git -C "$FIXTURE_GIT" commit -q --allow-empty -m "init" 2>/dev/null + +cd "$SANDBOX_T14A" +ANUTRON_SOURCE="$FIXTURE_GIT" bash "$INSTALL_SH" > /dev/null 2>&1 + +# Required new breadcrumb keys +assert_json_key "$SANDBOX_T14A/.anutron-install.json" '.mode' +assert_json_key "$SANDBOX_T14A/.anutron-install.json" '.scope' +assert_json_key "$SANDBOX_T14A/.anutron-install.json" '.sourceCommit' +assert_json_key "$SANDBOX_T14A/.anutron-install.json" '.scopeResolution' +assert_json_key "$SANDBOX_T14A/.anutron-install.json" '.scopeResolution.skills' +assert_json_key "$SANDBOX_T14A/.anutron-install.json" '.scopeResolution.snippets' +assert_json_key "$SANDBOX_T14A/.anutron-install.json" '.scopeResolution.hooks' + +# sourceCommit should be a 40-char hex string +source_commit=$(jq -r '.sourceCommit' "$SANDBOX_T14A/.anutron-install.json" 2>/dev/null || echo "") +assert "sourceCommit is 40-char hex when source is git repo" bash -c "echo '$source_commit' | grep -qE '^[0-9a-f]{40}$'" + +# scopeResolution.skills length should match actual installed skills count +skills_in_breadcrumb=$(jq '.scopeResolution.skills | length' "$SANDBOX_T14A/.anutron-install.json" 2>/dev/null || echo "0") +skills_on_disk=$(ls "$SANDBOX_T14A/.claude/skills/" 2>/dev/null | wc -l | tr -d ' ') +assert_equals "scopeResolution.skills count matches installed dirs" "$skills_on_disk" "$skills_in_breadcrumb" + +rm -rf "$SANDBOX_T14A" "$FIXTURE_GIT" + +# Sub-test B: source is NOT a git repo — sourceCommit should be null +SANDBOX_T14B="$(make_sandbox)" +cd "$SANDBOX_T14B" +ANUTRON_SOURCE="$SOURCE_REPO" bash "$INSTALL_SH" > /dev/null 2>&1 + +source_commit_b=$(jq -r '.sourceCommit' "$SANDBOX_T14B/.anutron-install.json" 2>/dev/null || echo "MISSING") +assert "sourceCommit is null when source has no git repo" bash -c "[ '$source_commit_b' = 'null' ]" +rm -rf "$SANDBOX_T14B" + +echo "" +echo "=== Test 15: interactive TTY flow ===" +# RED: fails until Stage 3 implements interactive prompts + +# Sub-test A: non-TTY (piped stdin) skips prompts, uses defaults +SANDBOX_T15A="$(make_sandbox)" +cd "$SANDBOX_T15A" +t15a_output=$(ANUTRON_SOURCE="$SOURCE_REPO" bash "$INSTALL_SH" < /dev/null 2>&1 || true) +t15a_exit=$? +assert "non-TTY stdin: install exits 0" test "$t15a_exit" -eq 0 +# Should use defaults: mode=symlink, scope=full +# brainstorm should be a symlink (symlink mode) +assert_symlink "$SANDBOX_T15A/.claude/skills/brainstorm" +# bugbash should be installed (full scope) +assert_dir_exists "$SANDBOX_T15A/.claude/skills/bugbash" +rm -rf "$SANDBOX_T15A" + +# Sub-test B: TTY simulation via expect (skip if expect not installed) +if command -v expect >/dev/null 2>&1; then + SANDBOX_T15B="$(make_sandbox)" + cd "$SANDBOX_T15B" + # Use expect to feed interactive answers: mode=copy, scope=spec-discipline, don't save manifest + export ANUTRON_SOURCE="$SOURCE_REPO" + expect_result=$(expect -c " + set timeout 15 + spawn bash $INSTALL_SH + expect -re {Install mode.*symlink.*copy} + send \"2\r\" + expect -re {Scope.*full.*spec-discipline} + send \"2\r\" + expect -re {Save.*manifest.*Y/n} + send \"n\r\" + expect eof + catch wait result + exit [lindex \$result 3] + " 2>&1 || true) + unset ANUTRON_SOURCE + expect_exit=$? + assert "TTY interactive: install exits 0" test "$expect_exit" -eq 0 + assert_dir_exists "$SANDBOX_T15B/.claude/skills/brainstorm" + assert_not_symlink "$SANDBOX_T15B/.claude/skills/brainstorm" + assert "bugbash not installed (interactive spec-discipline)" test ! -e "$SANDBOX_T15B/.claude/skills/bugbash" + if [ -f "$SANDBOX_T15B/.anutron-install.json" ]; then + t15b_mode=$(jq -r '.mode' "$SANDBOX_T15B/.anutron-install.json" 2>/dev/null || echo "") + assert_equals "interactive TTY: breadcrumb mode=copy" "copy" "$t15b_mode" + fi + rm -rf "$SANDBOX_T15B" +else + skip_test "expect not installed — skipping interactive TTY sub-test (Test 15b)" +fi + +echo "" +echo "=== Test 16: stale source detection on copy-mode re-run ===" +# RED: fails until Stage 3 implements stale source detection + +FIXTURE_GIT2="$(make_sandbox)/fixture-git2" +cp -r "$SOURCE_REPO" "$FIXTURE_GIT2" +git -C "$FIXTURE_GIT2" init -q +git -C "$FIXTURE_GIT2" add . +GIT_AUTHOR_NAME="Test" GIT_AUTHOR_EMAIL="test@test.com" \ + GIT_COMMITTER_NAME="Test" GIT_COMMITTER_EMAIL="test@test.com" \ + git -C "$FIXTURE_GIT2" commit -q -m "init" 2>/dev/null + +SANDBOX_T16="$(make_sandbox)" +cd "$SANDBOX_T16" +ANUTRON_SOURCE="$FIXTURE_GIT2" bash "$INSTALL_SH" --mode=copy --scope=full > /dev/null 2>&1 + +# Make a change to brainstorm in the fixture and commit +echo "# Updated" >> "$FIXTURE_GIT2/skills/brainstorm/SKILL.md" +git -C "$FIXTURE_GIT2" add skills/brainstorm/SKILL.md +GIT_AUTHOR_NAME="Test" GIT_AUTHOR_EMAIL="test@test.com" \ + GIT_COMMITTER_NAME="Test" GIT_COMMITTER_EMAIL="test@test.com" \ + git -C "$FIXTURE_GIT2" commit -q -m "update brainstorm" 2>/dev/null + +# Re-run installer +cd "$SANDBOX_T16" +t16_output=$(ANUTRON_SOURCE="$FIXTURE_GIT2" bash "$INSTALL_SH" --mode=copy --scope=full 2>&1 | tee /tmp/anutron-test-t16-$$.txt || true) + +# Summary should mention brainstorm as updated/changed +assert "stale re-run mentions brainstorm as updated" bash -c "grep -qi 'brainstorm' /tmp/anutron-test-t16-$$.txt" +rm -rf "$SANDBOX_T16" "$FIXTURE_GIT2" +rm -f /tmp/anutron-test-t16-$$.txt + +echo "" +echo "=== Test 17: snippet audience filter ===" +# RED: fails until Stage 3 implements snippet audience filtering + +SANDBOX_T17="$(make_sandbox)" +cd "$SANDBOX_T17" +ANUTRON_SOURCE="$SOURCE_REPO" bash "$INSTALL_SH" --scope=spec-discipline --mode=symlink > /dev/null 2>&1 +t17_exit=$? +assert "scope=spec-discipline snippet install exits 0" test "$t17_exit" -eq 0 + +# 010-shared-formatting.md (audience: [shared], tags: [formatting]) should be present +assert_file_contains "$SANDBOX_T17/CLAUDE.md" "Shared Formatting Rules" + +# aaron-personal.md (audience: [aaron]) should NOT be present +assert_file_not_contains "$SANDBOX_T17/CLAUDE.md" "Aaron's Personal Preferences" +rm -rf "$SANDBOX_T17" + +echo "" +echo "=== Test 18: no source resolvable — error names all three resolution options ===" +# Spec scenario: "no source resolvable" under Requirement "Source resolution." +# Run installer with ANUTRON_SOURCE unset, HOME pointing at an empty temp dir +# (so no ~/.claude/anutron-cache), and from a /tmp sandbox (no parent with skills/). +# Expect: non-zero exit, stderr mentions all three resolution options. + +SANDBOX_T18="$(mktemp -d /tmp/anutron-t18-home-XXXXXX)" +T18_OUT="/tmp/anutron-test-t18-$$.txt" + +# Run from a clean /tmp directory so self-location walk finds nothing +set +e +HOME="$SANDBOX_T18" bash "$INSTALL_SH" > "$T18_OUT" 2>&1 +t18_exit=$? +set -e + +assert "no-source install exits non-zero" test "$t18_exit" -ne 0 +# Error must mention ANUTRON_SOURCE env var +assert_file_contains "$T18_OUT" "ANUTRON_SOURCE" +# Error must mention plugin cache +assert_file_contains "$T18_OUT" "anutron-cache" +# Error must mention self-location +assert_file_contains "$T18_OUT" "skills/" + +rm -f "$T18_OUT" +rm -rf "$SANDBOX_T18" + +echo "" +echo "=== Test 19: foreign skills preserved across install + re-run ===" + +# Fresh sandbox with a foreign skill pre-existing +SANDBOX_T19="/tmp/anutron-test19-$$-$(date +%s)" +mkdir -p "$SANDBOX_T19/.claude/skills/my-foreign-skill" +cat > "$SANDBOX_T19/.claude/skills/my-foreign-skill/SKILL.md" << 'FOREIGN' +--- +name: my-foreign-skill +description: User-authored skill, not installed by anutron. +--- + +# My Foreign Skill + +This skill was here before /anutron-install ever ran. +FOREIGN + +cd "$SANDBOX_T19" +ANUTRON_SOURCE="$SOURCE_REPO" bash "$INSTALL_SH" --scope=spec-discipline --mode=copy > /dev/null 2>&1 +assert "T19a: foreign skill survives first install" test -f "$SANDBOX_T19/.claude/skills/my-foreign-skill/SKILL.md" + +# Re-run with same scope (should still leave foreign skill alone) +ANUTRON_SOURCE="$SOURCE_REPO" bash "$INSTALL_SH" --scope=spec-discipline --mode=copy > /dev/null 2>&1 +assert "T19b: foreign skill survives same-scope re-run" test -f "$SANDBOX_T19/.claude/skills/my-foreign-skill/SKILL.md" + +# Re-run with full scope. This adds more skills but must still leave the foreign one alone, +# and must remove anutron-owned skills that drop out of scope if any. +ANUTRON_SOURCE="$SOURCE_REPO" bash "$INSTALL_SH" --scope=full --mode=copy > /dev/null 2>&1 +assert "T19c: foreign skill survives scope-widening re-run" test -f "$SANDBOX_T19/.claude/skills/my-foreign-skill/SKILL.md" + +# Now narrow scope back to spec-discipline. bugbash was just added under full and is +# anutron-owned; it must be removed. The foreign skill must still survive. +ANUTRON_SOURCE="$SOURCE_REPO" bash "$INSTALL_SH" --scope=spec-discipline --mode=copy > /dev/null 2>&1 +assert "T19d: foreign skill survives scope-narrowing re-run" test -f "$SANDBOX_T19/.claude/skills/my-foreign-skill/SKILL.md" +assert "T19d: previously-owned bugbash removed on scope narrow" test ! -e "$SANDBOX_T19/.claude/skills/bugbash" + +rm -rf "$SANDBOX_T19" + # ============================================================ # Results # ============================================================ @@ -283,6 +754,9 @@ echo "============================================" rm -f /tmp/anutron-test-output-$$.txt /tmp/anutron-test-output2-$$.txt if [ "$failed" -gt 0 ]; then + # Determine how many are "expected red" vs truly broken + echo "" + echo "Note: Tests 6-17 (new red tests) are expected to fail until Stage 3." exit 1 fi echo "All tests passed." diff --git a/skills/anutron-uninstall/SKILL.md b/skills/anutron-uninstall/SKILL.md index bdeaa2c..468eb6c 100644 --- a/skills/anutron-uninstall/SKILL.md +++ b/skills/anutron-uninstall/SKILL.md @@ -1,16 +1,32 @@ --- name: anutron-uninstall description: Uninstall the anutron (claude-skills) kit from the current project — reverses everything /anutron-install did. +tags: [personal] --- # anutron-uninstall -Run the uninstaller script from this skill's directory. It reads the breadcrumb and reverses every install operation. +Reverses everything `/anutron-install` wrote into the current working directory. Reads `.anutron-install.json` (the breadcrumb) to know exactly what to undo. Mode-aware: behaviour differs based on how the original install was done. + +## How to invoke ```bash bash "$(dirname "$SKILL_PATH")/uninstall.sh" ``` -Print the script's output directly to the user. The script produces a clear summary of what was removed. +Print the script's output directly to the user. If the script exits non-zero, show the error. The most common cause is a missing breadcrumb — anutron is not installed, or was already uninstalled. + +## What it undoes + +- **Skills** — removed according to the install mode recorded in the breadcrumb: + - `mode=symlink` — each `.claude/skills/` symlink is `rm`'d; source files are untouched. + - `mode=copy` — each `.claude/skills/` directory is `rm -rf`'d. + - Legacy breadcrumb (no `mode` field) — falls back to the safest combined behaviour: `rm -f` for symlinks and regular files; directories are skipped to avoid data loss. +- **Hook scripts** — same mode-aware logic applied to `.claude/hooks/` files. +- **`CLAUDE.md`** — the `BEGIN ANUTRON-INSTALL` … `END ANUTRON-INSTALL` block is stripped; everything outside that block is preserved. If the file is empty (or contained only the anutron block), it is deleted. +- **`.claude/settings.json`** — the `anutronInstalled` key is removed, and only the hook command entries that the breadcrumb recorded as anutron-owned are stripped from the `hooks` object. All other user keys are preserved. +- **Breadcrumb** — `.anutron-install.json` is deleted as the final step. + +## Idempotent -If the script exits non-zero, show the error output. Common case: breadcrumb missing means anutron isn't installed (or was already uninstalled). +Running a second time after a clean uninstall exits cleanly with a "not installed" message (breadcrumb not found). No partial state is left behind. diff --git a/skills/anutron-uninstall/tests/test-uninstall.sh b/skills/anutron-uninstall/tests/test-uninstall.sh index 23810eb..e423a42 100755 --- a/skills/anutron-uninstall/tests/test-uninstall.sh +++ b/skills/anutron-uninstall/tests/test-uninstall.sh @@ -1,38 +1,36 @@ #!/bin/bash # test-uninstall.sh — End-to-end tests for anutron-uninstall # -# Sets up a sandbox directory, runs install.sh to create state, -# adds user-owned content, runs uninstall.sh, then verifies -# cleanup is correct and user content is preserved. -# Also tests that re-running uninstall errors cleanly. +# Verifies that uninstall.sh correctly reverses both copy-mode and symlink-mode +# installs, including legacy breadcrumbs that predate the mode/scopeResolution fields. +# +# Usage: +# bash test-uninstall.sh # uses bundled fixture +# ANUTRON_SOURCE=/path/to/source bash test-uninstall.sh # override source set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" UNINSTALL_SH="$SCRIPT_DIR/../uninstall.sh" INSTALL_SH="$SCRIPT_DIR/../../anutron-install/install.sh" -SOURCE_REPO="/Users/aaron/Personal/claude-skills" +FIXTURE_SOURCE="$SCRIPT_DIR/../../anutron-install/tests/fixtures/source-repo" +SOURCE_REPO="${ANUTRON_SOURCE:-$FIXTURE_SOURCE}" -# Sanity: required files must exist +# Sanity: source repo must exist if [ ! -d "$SOURCE_REPO/skills" ]; then echo "SKIP: source repo not found at $SOURCE_REPO" exit 0 fi +# Also require install.sh if [ ! -f "$INSTALL_SH" ]; then echo "SKIP: install.sh not found at $INSTALL_SH" exit 0 fi -if [ ! -f "$UNINSTALL_SH" ]; then - echo "FAIL: uninstall.sh not found at $UNINSTALL_SH" - exit 1 -fi - -# Create sandbox -SANDBOX="/tmp/anutron-uninstall-test-$$-$(date +%s)" -mkdir -p "$SANDBOX" -trap 'rm -rf "$SANDBOX"' EXIT +# ============================================================ +# Test helpers +# ============================================================ passed=0 failed=0 @@ -50,249 +48,375 @@ assert() { fi } -assert_equals() { - local desc="$1" expected="$2" actual="$3" - total=$((total + 1)) - if [ "$expected" = "$actual" ]; then - passed=$((passed + 1)) - else - failed=$((failed + 1)) - echo "FAIL: $desc (expected '$expected', got '$actual')" - fi -} - assert_file_exists() { assert "$1 exists" test -f "$1" } assert_file_not_exists() { - assert "$1 does not exist" test ! -f "$1" + assert "$1 does not exist" bash -c "! test -e '$1'" } assert_dir_not_exists() { - assert "$1 does not exist" test ! -d "$1" + assert "$1 directory does not exist" bash -c "! test -d '$1'" +} + +assert_symlink() { + assert "$1 is a symlink" test -L "$1" +} + +assert_not_symlink() { + assert "$1 is NOT a symlink" bash -c "! test -L '$1'" } assert_file_contains() { local file="$1" pattern="$2" - assert "$file contains '$pattern'" grep -qF "$pattern" "$file" + assert "$file contains '$pattern'" grep -q "$pattern" "$file" } assert_file_not_contains() { local file="$1" pattern="$2" - assert "$file does not contain '$pattern'" bash -c "! grep -qF '$pattern' '$file'" + total=$((total + 1)) + if grep -qF "$pattern" "$file" >/dev/null 2>&1; then + failed=$((failed + 1)) + echo "FAIL: $file should NOT contain '$pattern'" + else + passed=$((passed + 1)) + fi } -assert_json_key() { +assert_json_key_absent() { local file="$1" key="$2" - assert "$file has JSON key '$key'" bash -c "jq -e '$key' '$file' > /dev/null" + total=$((total + 1)) + if jq -e "$key" "$file" >/dev/null 2>&1; then + failed=$((failed + 1)) + echo "FAIL: $file should NOT have JSON key '$key' but does" + else + passed=$((passed + 1)) + fi } -assert_no_json_key() { +assert_json_key_present() { local file="$1" key="$2" - assert "$file lacks JSON key '$key'" bash -c "! jq -e '$key' '$file' > /dev/null 2>&1" + assert "$file has JSON key '$key'" bash -c "jq -e '$key' '$file' > /dev/null" +} + +assert_equals() { + local desc="$1" expected="$2" actual="$3" + total=$((total + 1)) + if [ "$expected" = "$actual" ]; then + passed=$((passed + 1)) + else + failed=$((failed + 1)) + echo "FAIL: $desc (expected '$expected', got '$actual')" + fi +} + +# Count non-dotfile items in a directory +count_entries() { + local dir="$1" + ls -1 "$dir" 2>/dev/null | wc -l | tr -d ' ' +} + +# Create a fresh sandbox and return its path +make_sandbox() { + local sb + sb="/tmp/uninstall-test-$$-$(date +%s)-$RANDOM" + mkdir -p "$sb" + echo "$sb" } # ============================================================ -# Setup: Run install first +# Test 1: Copy-mode uninstall # ============================================================ -echo "=== Setup: Installing anutron kit ===" +echo "=== Test 1: Copy-mode uninstall ===" + +SANDBOX1="$(make_sandbox)" -cd "$SANDBOX" -ANUTRON_SOURCE="$SOURCE_REPO" bash "$INSTALL_SH" > /dev/null 2>&1 +# Install in copy mode +cd "$SANDBOX1" +ANUTRON_SOURCE="$SOURCE_REPO" bash "$INSTALL_SH" --mode=copy --scope=full > /dev/null 2>&1 install_exit=$? -assert "install.sh exits 0" test "$install_exit" -eq 0 +assert "copy-mode install succeeded" test "$install_exit" -eq 0 +assert "breadcrumb exists after install" test -f "$SANDBOX1/.anutron-install.json" + +# Snapshot: skills are real dirs (not symlinks) in copy mode +assert_not_symlink "$SANDBOX1/.claude/skills/brainstorm" +assert "brainstorm is a real directory" test -d "$SANDBOX1/.claude/skills/brainstorm" +skills_before="$(count_entries "$SANDBOX1/.claude/skills")" +assert "copy-mode install has at least one skill dir" test "$skills_before" -gt 0 + +# Run uninstall +cd "$SANDBOX1" +uninstall_exit=0 +bash "$UNINSTALL_SH" > /dev/null 2>&1 || uninstall_exit=$? +assert "copy-mode uninstall exits 0" test "$uninstall_exit" -eq 0 + +# Assert: every skill dir from snapshot is gone (fully removed, not just emptied) +assert "brainstorm dir gone after copy-mode uninstall" bash -c "! test -e '$SANDBOX1/.claude/skills/brainstorm'" +assert "guard dir gone after copy-mode uninstall" bash -c "! test -e '$SANDBOX1/.claude/skills/guard'" + +# No dangling symlinks left in .claude/skills +if [ -d "$SANDBOX1/.claude/skills" ]; then + leftover_links="$(find "$SANDBOX1/.claude/skills" -maxdepth 1 -type l 2>/dev/null | wc -l | tr -d ' ')" + assert "no dangling symlinks left in .claude/skills" test "$leftover_links" -eq 0 +fi -# Verify install worked -assert "breadcrumb exists after install" test -f "$SANDBOX/.anutron-install.json" -assert "skills dir exists after install" test -d "$SANDBOX/.claude/skills" -assert "CLAUDE.md exists after install" test -f "$SANDBOX/CLAUDE.md" +# Breadcrumb gone +assert_file_not_exists "$SANDBOX1/.anutron-install.json" -# ============================================================ -# Setup: Add user-owned content -# ============================================================ -echo "=== Setup: Adding user-owned content ===" +# CLAUDE.md ANUTRON-INSTALL block gone +if [ -f "$SANDBOX1/CLAUDE.md" ]; then + assert_file_not_contains "$SANDBOX1/CLAUDE.md" "BEGIN ANUTRON-INSTALL" +fi -# Add a user-owned hook entry to settings.json -jq '.hooks.PreToolUse = [{"hooks": [{"type": "command", "command": "./my-custom-hook.sh"}]}]' \ - "$SANDBOX/.claude/settings.json" > "$SANDBOX/.claude/settings.json.tmp" -mv "$SANDBOX/.claude/settings.json.tmp" "$SANDBOX/.claude/settings.json" +# Settings cleaned +if [ -f "$SANDBOX1/.claude/settings.json" ]; then + assert_json_key_absent "$SANDBOX1/.claude/settings.json" '.anutronInstalled' +fi -# Also add a user key to settings.json -jq '.myUserKey = "preserved"' \ - "$SANDBOX/.claude/settings.json" > "$SANDBOX/.claude/settings.json.tmp" -mv "$SANDBOX/.claude/settings.json.tmp" "$SANDBOX/.claude/settings.json" +rm -rf "$SANDBOX1" -# Add user content below the CLAUDE.md delimited block -cat >> "$SANDBOX/CLAUDE.md" << 'USERCONTENT' +echo "" +echo "=== Test 1b: Copy-mode uninstall preserves other user settings keys ===" + +SANDBOX1B="$(make_sandbox)" +mkdir -p "$SANDBOX1B/.claude" + +# Settings with user keys that must survive +cat > "$SANDBOX1B/.claude/settings.json" << 'USERSETTINGS' +{ + "permissions": { + "allow": ["Read", "Write", "Bash"] + }, + "mcpPermissions": { + "memory": { "allowAllTools": true } + } +} +USERSETTINGS -## My Project Notes +cd "$SANDBOX1B" +ANUTRON_SOURCE="$SOURCE_REPO" bash "$INSTALL_SH" --mode=copy --scope=full > /dev/null 2>&1 +assert "copy-mode install with user settings succeeded" test "$?" -eq 0 -This is user-written content that should survive uninstall. +cd "$SANDBOX1B" +bash "$UNINSTALL_SH" > /dev/null 2>&1 -- Important build instructions -- Custom workflows -USERCONTENT +# User keys must survive; anutron key must be gone +if [ -f "$SANDBOX1B/.claude/settings.json" ]; then + assert_json_key_present "$SANDBOX1B/.claude/settings.json" '.permissions' + assert_json_key_present "$SANDBOX1B/.claude/settings.json" '.mcpPermissions' + assert_json_key_absent "$SANDBOX1B/.claude/settings.json" '.anutronInstalled' +fi -# Save skill count for later verification -skill_count_before=$(ls "$SANDBOX/.claude/skills/" 2>/dev/null | wc -l | tr -d ' ') +rm -rf "$SANDBOX1B" -# ============================================================ -# Test 1: Uninstall -# ============================================================ echo "" -echo "=== Test 1: Uninstall ===" - -cd "$SANDBOX" -UNINSTALL_OUTPUT=$(bash "$UNINSTALL_SH" 2>&1) -uninstall_exit=$? -assert "uninstall.sh exits 0" test "$uninstall_exit" -eq 0 - -# --- Skill symlinks removed --- -# .claude/skills/ should be empty or gone (all symlinks were anutron-owned) -if [ -d "$SANDBOX/.claude/skills" ]; then - remaining_skills=$(ls "$SANDBOX/.claude/skills/" 2>/dev/null | wc -l | tr -d ' ') - assert_equals "skills dir is empty" "0" "$remaining_skills" -fi +echo "=== Test 1c: Copy-mode uninstall idempotent (second run fails cleanly) ===" -# --- Hook symlinks removed --- -if [ -d "$SANDBOX/.claude/hooks" ]; then - remaining_hooks=$(ls "$SANDBOX/.claude/hooks/" 2>/dev/null | wc -l | tr -d ' ') - assert_equals "hooks dir is empty" "0" "$remaining_hooks" -fi +SANDBOX1C="$(make_sandbox)" +cd "$SANDBOX1C" +ANUTRON_SOURCE="$SOURCE_REPO" bash "$INSTALL_SH" --mode=copy > /dev/null 2>&1 +cd "$SANDBOX1C" +bash "$UNINSTALL_SH" > /dev/null 2>&1 -# --- settings.json cleaned --- -assert_file_exists "$SANDBOX/.claude/settings.json" -assert_no_json_key "$SANDBOX/.claude/settings.json" '.anutronInstalled' +# Second run: breadcrumb is gone — must error with a clear message (not crash) +set +e +second_run_output="$(bash "$UNINSTALL_SH" 2>&1)" +second_run_exit=$? +set -e +assert "second copy-mode uninstall exits non-zero (no breadcrumb)" test "$second_run_exit" -ne 0 +assert "second run error mentions breadcrumb" bash -c "echo '$second_run_output' | grep -qi 'anutron-install.json'" -# Anutron hook entries should be gone -# The SessionStart entry should be gone since it was anutron-owned -session_start_hooks=$(jq '.hooks.SessionStart // [] | length' "$SANDBOX/.claude/settings.json" 2>/dev/null) -assert_equals "SessionStart hooks removed" "0" "$session_start_hooks" +rm -rf "$SANDBOX1C" -# User-owned hook should be preserved -assert_json_key "$SANDBOX/.claude/settings.json" '.hooks.PreToolUse' -user_hook_cmd=$(jq -r '.hooks.PreToolUse[0].hooks[0].command' "$SANDBOX/.claude/settings.json" 2>/dev/null) -assert_equals "user hook command preserved" "./my-custom-hook.sh" "$user_hook_cmd" +echo "" +echo "=== Test 2: Symlink-mode uninstall ===" -# User key should be preserved -assert_json_key "$SANDBOX/.claude/settings.json" '.myUserKey' -user_key_val=$(jq -r '.myUserKey' "$SANDBOX/.claude/settings.json" 2>/dev/null) -assert_equals "user key value preserved" "preserved" "$user_key_val" +SANDBOX2="$(make_sandbox)" +cd "$SANDBOX2" +ANUTRON_SOURCE="$SOURCE_REPO" bash "$INSTALL_SH" --mode=symlink --scope=full > /dev/null 2>&1 +assert "symlink-mode install succeeded" test "$?" -eq 0 -# --- CLAUDE.md cleaned --- -assert_file_exists "$SANDBOX/CLAUDE.md" -assert_file_not_contains "$SANDBOX/CLAUDE.md" "BEGIN ANUTRON-INSTALL" -assert_file_not_contains "$SANDBOX/CLAUDE.md" "END ANUTRON-INSTALL" +# Verify skills are symlinks +assert_symlink "$SANDBOX2/.claude/skills/brainstorm" +skills_sym_before="$(count_entries "$SANDBOX2/.claude/skills")" +assert "symlink install has at least one skill" test "$skills_sym_before" -gt 0 -# User content should be preserved -assert_file_contains "$SANDBOX/CLAUDE.md" "My Project Notes" -assert_file_contains "$SANDBOX/CLAUDE.md" "user-written content" -assert_file_contains "$SANDBOX/CLAUDE.md" "Important build instructions" +cd "$SANDBOX2" +sym_uninstall_exit=0 +bash "$UNINSTALL_SH" > /dev/null 2>&1 || sym_uninstall_exit=$? +assert "symlink-mode uninstall exits 0" test "$sym_uninstall_exit" -eq 0 -# --- Breadcrumb removed --- -assert_file_not_exists "$SANDBOX/.anutron-install.json" +# All symlinks gone +assert "brainstorm symlink gone after symlink-mode uninstall" bash -c "! test -e '$SANDBOX2/.claude/skills/brainstorm'" +assert "guard symlink gone after symlink-mode uninstall" bash -c "! test -e '$SANDBOX2/.claude/skills/guard'" -# --- Summary output --- -assert "summary mentions uninstalled" bash -c "echo '$UNINSTALL_OUTPUT' | grep -qi 'uninstall'" -assert "summary mentions skills" bash -c "echo '$UNINSTALL_OUTPUT' | grep -qi 'skill'" +# Breadcrumb gone +assert_file_not_exists "$SANDBOX2/.anutron-install.json" -# ============================================================ -# Test 2: Re-run uninstall errors cleanly -# ============================================================ -echo "" -echo "=== Test 2: Re-run uninstall (should error) ===" +# CLAUDE.md block gone +if [ -f "$SANDBOX2/CLAUDE.md" ]; then + assert_file_not_contains "$SANDBOX2/CLAUDE.md" "BEGIN ANUTRON-INSTALL" +fi -cd "$SANDBOX" -rerun_output=$(bash "$UNINSTALL_SH" 2>&1 || true) -rerun_exit=$? -# Capture exit code properly -set +e -bash "$UNINSTALL_SH" > /dev/null 2>&1 -rerun_exit=$? -set -e +# Settings cleaned +if [ -f "$SANDBOX2/.claude/settings.json" ]; then + assert_json_key_absent "$SANDBOX2/.claude/settings.json" '.anutronInstalled' +fi -assert "re-run exits non-zero" test "$rerun_exit" -ne 0 +# Source files untouched +assert "source repo brainstorm skill still intact after symlink uninstall" test -d "$SOURCE_REPO/skills/brainstorm" + +rm -rf "$SANDBOX2" -# ============================================================ -# Test 3: CLAUDE.md deletion when only markers present -# ============================================================ echo "" -echo "=== Test 3: CLAUDE.md deleted when empty after strip ===" +echo "=== Test 2b: Symlink-mode uninstall preserves user settings keys ===" -SANDBOX2="/tmp/anutron-uninstall-test2-$$-$(date +%s)" -mkdir -p "$SANDBOX2" +SANDBOX2B="$(make_sandbox)" +mkdir -p "$SANDBOX2B/.claude" -cd "$SANDBOX2" -ANUTRON_SOURCE="$SOURCE_REPO" bash "$INSTALL_SH" > /dev/null 2>&1 +cat > "$SANDBOX2B/.claude/settings.json" << 'USERSETTINGS2' +{ + "permissions": { + "allow": ["Read"] + }, + "theme": "dark" +} +USERSETTINGS2 -# CLAUDE.md should just have the block + placeholder comment -# The placeholder heading is "" -# Uninstall should delete the file since it's effectively empty +cd "$SANDBOX2B" +ANUTRON_SOURCE="$SOURCE_REPO" bash "$INSTALL_SH" --mode=symlink > /dev/null 2>&1 +cd "$SANDBOX2B" bash "$UNINSTALL_SH" > /dev/null 2>&1 -assert "CLAUDE.md deleted when empty" test ! -f "$SANDBOX2/CLAUDE.md" -rm -rf "$SANDBOX2" +if [ -f "$SANDBOX2B/.claude/settings.json" ]; then + assert_json_key_present "$SANDBOX2B/.claude/settings.json" '.permissions' + assert_json_key_absent "$SANDBOX2B/.claude/settings.json" '.anutronInstalled' +fi + +rm -rf "$SANDBOX2B" -# ============================================================ -# Test 4: Handles replaced symlinks (regular files instead) -# ============================================================ echo "" -echo "=== Test 4: Handles replaced symlinks gracefully ===" +echo "=== Test 3: Legacy breadcrumb (no mode/scopeResolution fields) ===" -SANDBOX3="/tmp/anutron-uninstall-test3-$$-$(date +%s)" -mkdir -p "$SANDBOX3" +SANDBOX3="$(make_sandbox)" +# Step 1: Install with symlink mode to generate a real breadcrumb cd "$SANDBOX3" -ANUTRON_SOURCE="$SOURCE_REPO" bash "$INSTALL_SH" > /dev/null 2>&1 - -# Replace one skill symlink with a regular directory -first_skill=$(jq -r '.skills[0]' "$SANDBOX3/.anutron-install.json") -if [ -n "$first_skill" ] && [ "$first_skill" != "null" ]; then - rm -f "$SANDBOX3/.claude/skills/$first_skill" - mkdir -p "$SANDBOX3/.claude/skills/$first_skill" - echo "user-owned" > "$SANDBOX3/.claude/skills/$first_skill/SKILL.md" -fi +ANUTRON_SOURCE="$SOURCE_REPO" bash "$INSTALL_SH" --mode=symlink --scope=full > /dev/null 2>&1 +assert "install for legacy test succeeded" test "$?" -eq 0 +assert "breadcrumb exists for legacy stripping" test -f "$SANDBOX3/.anutron-install.json" + +# Step 2: Strip mode and scopeResolution — simulate a legacy breadcrumb +jq 'del(.mode) | del(.scopeResolution)' "$SANDBOX3/.anutron-install.json" > "$SANDBOX3/.anutron-install.json.tmp" +mv "$SANDBOX3/.anutron-install.json.tmp" "$SANDBOX3/.anutron-install.json" + +# Verify stripping worked +legacy_mode_val="$(jq -r '.mode // "ABSENT"' "$SANDBOX3/.anutron-install.json")" +assert_equals "mode field stripped from legacy breadcrumb" "ABSENT" "$legacy_mode_val" + +# Step 3: Run uninstall with the legacy breadcrumb +cd "$SANDBOX3" +legacy_exit=0 +bash "$UNINSTALL_SH" > /dev/null 2>&1 || legacy_exit=$? +assert "legacy breadcrumb uninstall exits 0" test "$legacy_exit" -eq 0 -# Uninstall should still succeed -uninstall3_output=$(bash "$UNINSTALL_SH" 2>&1) -uninstall3_exit=$? -assert "uninstall succeeds with replaced symlinks" test "$uninstall3_exit" -eq 0 +# Step 4: Assert clean removal — symlinks are gone +assert "brainstorm symlink gone (legacy uninstall)" bash -c "! test -e '$SANDBOX3/.claude/skills/brainstorm'" +assert "guard symlink gone (legacy uninstall)" bash -c "! test -e '$SANDBOX3/.claude/skills/guard'" -# The replaced skill dir should still exist (user-owned) -if [ -n "$first_skill" ] && [ "$first_skill" != "null" ]; then - assert "user-replaced skill preserved" test -d "$SANDBOX3/.claude/skills/$first_skill" +# Breadcrumb gone +assert_file_not_exists "$SANDBOX3/.anutron-install.json" + +# CLAUDE.md block gone +if [ -f "$SANDBOX3/CLAUDE.md" ]; then + assert_file_not_contains "$SANDBOX3/CLAUDE.md" "BEGIN ANUTRON-INSTALL" +fi + +# Settings cleaned +if [ -f "$SANDBOX3/.claude/settings.json" ]; then + assert_json_key_absent "$SANDBOX3/.claude/settings.json" '.anutronInstalled' fi rm -rf "$SANDBOX3" -# ============================================================ -# Test 5: settings.json removed when it becomes empty -# ============================================================ echo "" -echo "=== Test 5: settings.json removed when empty ===" +echo "=== Test 3b: Legacy breadcrumb + copy-mode install exits 0 (graceful skip) ===" +# When a copy-mode install has had mode/scopeResolution stripped, the uninstaller +# sees real directories in legacy mode where it only expects symlinks or plain files. +# The uninstaller must exit 0 (not crash) and still clean up breadcrumb and settings. + +SANDBOX3B="$(make_sandbox)" +cd "$SANDBOX3B" +ANUTRON_SOURCE="$SOURCE_REPO" bash "$INSTALL_SH" --mode=copy --scope=full > /dev/null 2>&1 + +# Strip mode and scopeResolution +jq 'del(.mode) | del(.scopeResolution)' "$SANDBOX3B/.anutron-install.json" > "$SANDBOX3B/.anutron-install.json.tmp" +mv "$SANDBOX3B/.anutron-install.json.tmp" "$SANDBOX3B/.anutron-install.json" + +cd "$SANDBOX3B" +legacy_copy_exit=0 +bash "$UNINSTALL_SH" > /dev/null 2>&1 || legacy_copy_exit=$? +assert "legacy-breadcrumb + copy-mode: uninstall exits 0" test "$legacy_copy_exit" -eq 0 + +# Breadcrumb gone +assert_file_not_exists "$SANDBOX3B/.anutron-install.json" + +# Settings cleaned +if [ -f "$SANDBOX3B/.claude/settings.json" ]; then + assert_json_key_absent "$SANDBOX3B/.claude/settings.json" '.anutronInstalled' +fi -SANDBOX4="/tmp/anutron-uninstall-test4-$$-$(date +%s)" -mkdir -p "$SANDBOX4" +rm -rf "$SANDBOX3B" -cd "$SANDBOX4" -ANUTRON_SOURCE="$SOURCE_REPO" bash "$INSTALL_SH" > /dev/null 2>&1 +echo "" +echo "=== Test 4: Pre-existing CLAUDE.md content preserved after uninstall ===" + +SANDBOX4="$(make_sandbox)" + +# Lay down project instructions before installing +cat > "$SANDBOX4/CLAUDE.md" << 'EXISTING' +# My Project + +These are my project instructions. -# Remove all non-anutron keys from settings.json so uninstall leaves it empty -jq 'del(.permissions) | del(.mcpPermissions) | del(.myUserKey)' \ - "$SANDBOX4/.claude/settings.json" > "$SANDBOX4/.claude/settings.json.tmp" 2>/dev/null || true -mv "$SANDBOX4/.claude/settings.json.tmp" "$SANDBOX4/.claude/settings.json" 2>/dev/null || true +## Build + +Run `make build`. +EXISTING + +cd "$SANDBOX4" +ANUTRON_SOURCE="$SOURCE_REPO" bash "$INSTALL_SH" --mode=copy > /dev/null 2>&1 -# Make sure settings.json only has anutron keys -jq '{hooks: .hooks, anutronInstalled: .anutronInstalled}' \ - "$SANDBOX4/.claude/settings.json" > "$SANDBOX4/.claude/settings.json.tmp" -mv "$SANDBOX4/.claude/settings.json.tmp" "$SANDBOX4/.claude/settings.json" +# Verify markers are present alongside existing content +assert_file_contains "$SANDBOX4/CLAUDE.md" "BEGIN ANUTRON-INSTALL" +assert_file_contains "$SANDBOX4/CLAUDE.md" "My Project" +cd "$SANDBOX4" bash "$UNINSTALL_SH" > /dev/null 2>&1 -assert "settings.json removed when empty" test ! -f "$SANDBOX4/.claude/settings.json" + +# After uninstall: original content preserved, markers gone +assert "CLAUDE.md still exists (had project content)" test -f "$SANDBOX4/CLAUDE.md" +assert_file_not_contains "$SANDBOX4/CLAUDE.md" "BEGIN ANUTRON-INSTALL" +assert_file_contains "$SANDBOX4/CLAUDE.md" "My Project" +assert_file_contains "$SANDBOX4/CLAUDE.md" "make build" rm -rf "$SANDBOX4" +echo "" +echo "=== Test 5: Uninstall summary mentions mode ===" + +SANDBOX5="$(make_sandbox)" +cd "$SANDBOX5" +ANUTRON_SOURCE="$SOURCE_REPO" bash "$INSTALL_SH" --mode=copy > /dev/null 2>&1 + +cd "$SANDBOX5" +summary_out="$(bash "$UNINSTALL_SH" 2>&1)" +assert "uninstall summary mentions mode" bash -c "echo '$summary_out' | grep -qi 'mode'" +assert "uninstall summary mentions skills" bash -c "echo '$summary_out' | grep -qi 'skill'" + +rm -rf "$SANDBOX5" + # ============================================================ # Results # ============================================================ diff --git a/skills/anutron-uninstall/uninstall.sh b/skills/anutron-uninstall/uninstall.sh index 72d9915..8a2302b 100755 --- a/skills/anutron-uninstall/uninstall.sh +++ b/skills/anutron-uninstall/uninstall.sh @@ -2,12 +2,17 @@ # uninstall.sh — Per-project uninstaller for the anutron (claude-skills) kit. # # Reads the breadcrumb (.anutron-install.json) and reverses every -# operation that install.sh performed: removes skill symlinks, -# hook symlinks, anutron entries from settings.json, the delimited +# operation that install.sh performed: removes skill symlinks/directories, +# hook symlinks/files, anutron entries from settings.json, the delimited # block from CLAUDE.md, and the breadcrumb itself. # # Runs in the current working directory. If run twice: first run # cleans, second run errors cleanly. +# +# Mode awareness: +# mode=symlink (new breadcrumb) — rm each link in .claude/skills/ and hooks +# mode=copy (new breadcrumb) — rm -rf each dir in .claude/skills/; rm -f hook files +# (no mode field, legacy breadcrumb) — rm -f unconditionally (prior behaviour) set -euo pipefail @@ -34,7 +39,7 @@ read_breadcrumb() { } # ============================================================ -# 2. Remove skill symlinks +# 2. Remove skill symlinks / directories # ============================================================ remove_skills() { @@ -51,23 +56,70 @@ remove_skills() { return fi + # Determine install mode from breadcrumb. + # Falls back to "" (legacy) if the field is missing or null. + local mode + mode=$(echo "$breadcrumb" | jq -r '.mode // empty') + + # Determine skill list: prefer scopeResolution.skills (new breadcrumb), + # fall back to legacy .skills array. + local has_scope_resolution + has_scope_resolution=$(echo "$breadcrumb" | jq -r 'if .scopeResolution.skills then "1" else "0" end') + local skill_count - skill_count=$(echo "$breadcrumb" | jq -r '.skills | length') + if [ "$has_scope_resolution" = "1" ]; then + skill_count=$(echo "$breadcrumb" | jq -r '.scopeResolution.skills | length') + else + skill_count=$(echo "$breadcrumb" | jq -r '.skills | length') + fi + local i=0 while [ "$i" -lt "$skill_count" ]; do local name - name=$(echo "$breadcrumb" | jq -r ".skills[$i]") - local link_path="$target_dir/$name" - - if [ -L "$link_path" ]; then - rm -f "$link_path" - removed=$((removed + 1)) - elif [ -e "$link_path" ]; then - # Regular file/dir — user may have replaced it - skipped=$((skipped + 1)) - skipped_names+=("$name") + if [ "$has_scope_resolution" = "1" ]; then + name=$(echo "$breadcrumb" | jq -r ".scopeResolution.skills[$i]") + else + name=$(echo "$breadcrumb" | jq -r ".skills[$i]") + fi + local skill_path="$target_dir/$name" + + if [ "$mode" = "copy" ]; then + # Copy mode: skill was installed as a real directory + if [ -d "$skill_path" ] && [ ! -L "$skill_path" ]; then + rm -rf "$skill_path" + removed=$((removed + 1)) + elif [ -L "$skill_path" ]; then + # Shouldn't happen in copy mode but clean up if present + rm -f "$skill_path" + removed=$((removed + 1)) + fi + # If doesn't exist: nothing to do (idempotent) + elif [ "$mode" = "symlink" ]; then + # Symlink mode: skill was installed as a symlink + if [ -L "$skill_path" ]; then + rm -f "$skill_path" + removed=$((removed + 1)) + elif [ -e "$skill_path" ]; then + # Regular file/dir — user may have replaced it + skipped=$((skipped + 1)) + skipped_names+=("$name") + fi + # If doesn't exist: nothing to do + else + # Legacy mode (no mode field): best-effort removal — rm -f works for both + # symlinks and regular files; does not recurse into directories the user owns. + if [ -L "$skill_path" ]; then + rm -f "$skill_path" + removed=$((removed + 1)) + elif [ -f "$skill_path" ]; then + rm -f "$skill_path" + removed=$((removed + 1)) + elif [ -e "$skill_path" ]; then + # Unexpected: a directory here in legacy mode. Skip to avoid data loss. + skipped=$((skipped + 1)) + skipped_names+=("$name") + fi fi - # If doesn't exist: nothing to do i=$((i + 1)) done @@ -82,7 +134,7 @@ remove_skills() { } # ============================================================ -# 3. Remove hook symlinks +# 3. Remove hook symlinks / files # ============================================================ remove_hooks() { @@ -101,6 +153,10 @@ remove_hooks() { return fi + # Determine install mode from breadcrumb. + local mode + mode=$(echo "$breadcrumb" | jq -r '.mode // empty') + local cmd_count cmd_count=$(echo "$breadcrumb" | jq -r '.hookCommands | length') local i=0 @@ -108,15 +164,35 @@ remove_hooks() { local cmd_path cmd_path=$(echo "$breadcrumb" | jq -r ".hookCommands[$i]") - if [ -L "$cmd_path" ]; then - rm -f "$cmd_path" - removed=$((removed + 1)) - elif [ -f "$cmd_path" ]; then - # Regular file — user may have replaced it - local basename - basename="$(basename "$cmd_path")" - skipped=$((skipped + 1)) - skipped_names+=("$basename") + if [ "$mode" = "copy" ]; then + # Copy mode: hook was installed as a regular file (not a symlink) + if [ -L "$cmd_path" ]; then + # Symlink where we expected a regular file — still clean it up + rm -f "$cmd_path" + removed=$((removed + 1)) + elif [ -f "$cmd_path" ]; then + rm -f "$cmd_path" + removed=$((removed + 1)) + fi + # If doesn't exist: idempotent + elif [ "$mode" = "symlink" ]; then + # Symlink mode: hook was installed as a symlink + if [ -L "$cmd_path" ]; then + rm -f "$cmd_path" + removed=$((removed + 1)) + elif [ -f "$cmd_path" ]; then + # Regular file — user may have replaced it + local basename + basename="$(basename "$cmd_path")" + skipped=$((skipped + 1)) + skipped_names+=("$basename") + fi + else + # Legacy: rm -f handles symlinks and regular files + if [ -L "$cmd_path" ] || [ -f "$cmd_path" ]; then + rm -f "$cmd_path" + removed=$((removed + 1)) + fi fi i=$((i + 1)) done @@ -294,11 +370,16 @@ delete_breadcrumb() { # ============================================================ print_summary() { + local breadcrumb="$1" local project_dir project_dir="$(pwd)" - echo "Uninstalled anutron kit from ${project_dir}:" - echo " Skills: ${SKILLS_REMOVED} symlinks removed" + # Determine mode for display + local mode + mode=$(echo "$breadcrumb" | jq -r '.mode // "legacy"') + + echo "Uninstalled anutron kit from ${project_dir} (mode: ${mode}):" + echo " Skills: ${SKILLS_REMOVED} removed" if [ "${SKILLS_SKIPPED:-0}" -gt 0 ]; then local names_str="" @@ -307,7 +388,7 @@ print_summary() { if $first; then first=false; else names_str+=", "; fi names_str+="$name" done - echo " (skipped ${SKILLS_SKIPPED}: ${names_str} — not symlinks, may be user-owned)" + echo " (skipped ${SKILLS_SKIPPED}: ${names_str} — not managed by anutron)" fi if [ "${HOOKS_REMOVED:-0}" -gt 0 ]; then @@ -367,10 +448,10 @@ main() { local breadcrumb breadcrumb=$(read_breadcrumb) - # Step 2: Remove skill symlinks + # Step 2: Remove skill symlinks / directories (mode-aware) remove_skills "$breadcrumb" - # Step 3: Remove hook symlinks + # Step 3: Remove hook symlinks / files (mode-aware) remove_hooks "$breadcrumb" # Step 4: Clean settings.json @@ -383,7 +464,7 @@ main() { delete_breadcrumb # Step 7: Print summary - print_summary + print_summary "$breadcrumb" } main "$@" diff --git a/skills/brainstorm/SKILL.md b/skills/brainstorm/SKILL.md index 604d55f..3ee3dda 100644 --- a/skills/brainstorm/SKILL.md +++ b/skills/brainstorm/SKILL.md @@ -2,6 +2,7 @@ name: brainstorm description: "You MUST use this before any creative work -- creating features, building components, adding functionality, or modifying behavior. Explores intent, designs the solution, scaffolds an OpenSpec change folder (proposal + design + delta specs + tasks), and hands off to execution." user-invocable: true +tags: [spec] --- # Brainstorm: From Idea to OpenSpec Change @@ -160,14 +161,6 @@ Once you understand what you're building, present the design. Scale each section Ask after each section whether it looks right so far via `AskUserQuestion`. Be ready to go back and revise. -**Section framing.** If your `~/.claude/CLAUDE.md` (or any project-level CLAUDE.md) defines user-facing framing instructions for choices and findings, apply them here. Otherwise, the default brainstorm-section structure is: - -- **Outcome** – what this section unlocks, in plain language -- **Decisions I'm making** – the opinionated choice, with the constraint that drove it (a line in `design.md`, a norm from Step 7, a user preference surfaced earlier). Don't hedge – the user is approving decisions, not arbitrating between alternatives -- **Technical details** – brief 1-3 line summary of the concrete artifacts (type names, key fields, APIs, file locations) - -Collapse parts that don't apply. If a section is pure architecture/rationale, Decisions may collapse into Outcome. If there are no concrete artifacts yet, omit Technical details. - **Design for isolation and clarity:** - Break the system into smaller units with one clear purpose and well-defined interfaces. diff --git a/skills/bugbash/SKILL.md b/skills/bugbash/SKILL.md index 615ed05..6f2a4ac 100644 --- a/skills/bugbash/SKILL.md +++ b/skills/bugbash/SKILL.md @@ -1,6 +1,7 @@ --- name: bugbash description: Use when the user wants to do a QA session or report multiple bugs — interactive session where bugs are reported conversationally and agents fix them in parallel +tags: [workflow] --- # Bug Bash @@ -21,7 +22,7 @@ If agent teams are not enabled, report: "Agent teams required. Add `CLAUDE_CODE_ ## Arguments -- `$ARGUMENTS` - Optional subcommand: `status`, `done`, `report`, `review`, or empty to start/continue +- `$ARGUMENTS` - Optional subcommand: `status`, `done`, `report`, or empty to start/continue ## Context @@ -46,7 +47,6 @@ Bugs live in status folders — the folder IS the status. No need to read files bug-004.md in-progress/ # Fix agent actively working bug-002.md - pending-merge/ # Agent done, gated for user review before merge (see Merge gate) blocked/ # Agent stopped — needs user input before continuing merged/ # Fix merged, awaiting acceptance testing verified/ # Passed acceptance testing — done @@ -64,10 +64,7 @@ Bugs live in status folders — the folder IS the status. No need to read files mv .bug-bash/todo/bug-001.md .bug-bash/investigating/ # investigation started mv .bug-bash/investigating/bug-001.md .bug-bash/in-progress/ # investigation done, fix dispatched mv .bug-bash/investigating/bug-001.md .bug-bash/blocked/ # high-risk conflict, needs user input -mv .bug-bash/in-progress/bug-001.md .bug-bash/merged/ # fix merged (clean case, no gate) -mv .bug-bash/in-progress/bug-001.md .bug-bash/pending-merge/ # gate triggered, awaiting user review -mv .bug-bash/pending-merge/bug-001.md .bug-bash/merged/ # user approved, merged -mv .bug-bash/pending-merge/bug-001.md .bug-bash/failed/ # user discarded +mv .bug-bash/in-progress/bug-001.md .bug-bash/merged/ # fix merged mv .bug-bash/in-progress/bug-001.md .bug-bash/failed/ # agent failed mv .bug-bash/in-progress/bug-001.md .bug-bash/conflict/ # merge conflict mv .bug-bash/in-progress/bug-001.md .bug-bash/blocked/ # agent needs user input @@ -84,7 +81,7 @@ When invoked with no arguments (or the session is already active): 1. **Initialize if needed:** - Create status folders: ```bash - mkdir -p .bug-bash/{todo,investigating,in-progress,pending-merge,blocked,merged,verified,failed,conflict,attachments} + mkdir -p .bug-bash/{todo,investigating,in-progress,blocked,merged,verified,failed,conflict,attachments} ``` - Add `.bug-bash/` to `.gitignore` if not already there (append, don't overwrite) - Initialize internal state: @@ -149,18 +146,6 @@ Parse the user's description and classify into one of three tiers: → If user clarifies: update understanding, proceed. → Max 1 clarification round, then dispatch with best understanding. -#### Pattern-cleanup pre-flight - -If the bug report contains pattern-cleanup keywords ("remove all", "clean up", "drop references to", "delete all", "legacy", "dead", "deprecated"), exceed the search budget once: run a single comprehensive `Grep` for the pattern across the relevant scope. Then surface the full extent to the user before filing the bug: - -> "You named A; the same pattern exists in B and C. Scope this bug to all three, or just A?" - -Wait for the user's answer before writing the bug file. Pattern cleanup is the one class where missing the full extent guarantees rework. - -#### Workflow-instruction detection - -If the bug report includes wording that prescribes a workflow (e.g., "via a PR", "via a proper OpenSpec change", "with `--no-verify`", "as drift cleanup", "as a hotfix"), capture that wording verbatim into the bug file's "User's Exact Ask" section (see Step 4). The fix agent will defer to that section over the dispatcher's drift-vs-gap classification. If the prescribed workflow doesn't make sense for the project, surface that to the user before dispatching the fix. - ### Step 3: Save Attachments If the user provided screenshots or images: @@ -185,9 +170,6 @@ attachments: - --- -## User's Exact Ask - - ## Description @@ -313,8 +295,6 @@ This prevents agents from working against stale code that references APIs change Each bug fix follows the agent-driven-development pattern. The implementer agent follows TDD (`skills/test-driven-development/SKILL.md`), self-reviews per verification-before-completion (`skills/verification-before-completion/SKILL.md`), and references debugging docs for root cause analysis. -Before dispatching, do a one-line self-review: "Did I add anything to the agent prompt that the user didn't ask for, or omit anything they did ask for?" If yes, set `merge_gate=divergence` on the bug file's frontmatter (so the completion handler holds it for review — see Merge gate, below). - Use the Agent tool with `run_in_background: true` and `mode: "bypassPermissions"`: ``` @@ -333,11 +313,6 @@ For debugging, also read: - `skills/debug/root-cause-tracing.md` — systematic hypothesis-driven debugging - `skills/debug/defense-in-depth.md` — making fixes robust against related failures -### User's Exact Ask - - -This section is the highest-priority guidance. If anything below conflicts with it (including the Spec-Aware Project branching), defer to this section. - ### Bug Description @@ -445,43 +420,6 @@ Execution is autonomous -- the controller handles all statuses internally withou ### Step 2: Merge (on success) -#### Followup capture - -Every "out of scope" / "concerns" / "follow-up" item in the agent's report must resolve into one of three outcomes — they cannot be silently dropped: - -- **Extend scope** — re-dispatch the agent on the same branch with the additional work (after user approval), OR -- **Capture as task** — call `TaskCreate` to track the followup, OR -- **Explicit won't-fix** — the user reviews and acknowledges the item as out of scope. - -Surface the followups to the user as a short list and ask which outcome applies. If anything remains unresolved, set `merge_gate=concerns` on the bug file frontmatter. - -#### Merge gate - -Auto-merge is the default. Hold the bug in `pending-merge/` (instead of merging) when any of the following are true: - -- `merge_gate=divergence` was set during dispatch (dispatcher prompt diverged from user's literal ask) -- Agent reported `DONE_WITH_CONCERNS` -- `merge_gate=concerns` was set during followup capture (work queued or pending decision) -- Project is OpenSpec (`test -d openspec`) and the fix touched files under `openspec/specs/**/*.md` - -When gating: -```bash -mv .bug-bash/in-progress/bug-.md .bug-bash/pending-merge/ -``` - -Surface to the user with a one-line summary: -``` -BUG- pending merge: - Reason: <divergence | concerns | base-spec edit> - Run /bugbash review to merge or discard. -``` - -The worktree and branch stay intact until the user decides. Do NOT auto-merge gated bugs even on `done` — they require explicit review. - -#### Clean merge path - -If no gate triggered, proceed to merge: - ```bash # Make sure we're on the main branch git checkout <original-branch> @@ -568,10 +506,10 @@ When invoked with `status` argument, or user says "status": Get status by listing each folder (no file reads needed for counts): ```bash -ls .bug-bash/todo/ .bug-bash/investigating/ .bug-bash/in-progress/ .bug-bash/pending-merge/ .bug-bash/blocked/ .bug-bash/merged/ .bug-bash/verified/ .bug-bash/failed/ .bug-bash/conflict/ 2>/dev/null +ls .bug-bash/todo/ .bug-bash/investigating/ .bug-bash/in-progress/ .bug-bash/blocked/ .bug-bash/merged/ .bug-bash/verified/ .bug-bash/failed/ .bug-bash/conflict/ 2>/dev/null ``` -Read titles only from in-progress, pending-merge, blocked, and todo bugs for the table. Print: +Read titles only from in-progress, blocked, and todo bugs for the table. Print: ``` ## Bug Bash Status @@ -581,12 +519,10 @@ Read titles only from in-progress, pending-merge, blocked, and todo bugs for the | 001 | <title> | verified | | 002 | <title> | merged | | 003 | <title> | in-progress | -| 004 | <title> | pending-merge (divergence) | -| 005 | <title> | blocked | -| 006 | <title> | todo | +| 004 | <title> | blocked | +| 005 | <title> | todo | Active: <N> agents (count of in-progress/) -Pending review: <N> (run /bugbash review to merge or discard) Queue: <N> todo (file-overlap conflicts) Blocked: <N> (needs user input) Merged: <N> (awaiting acceptance testing) @@ -596,27 +532,6 @@ Issues: <N> failed, <N> conflict --- -## Review (Pending-Merge Resolution) - -When invoked with `review` argument, or user says "review pending": - -For each bug in `.bug-bash/pending-merge/`: - -1. Read the bug file. Print a one-screen summary: - - Title and gate reason (`merge_gate=divergence | concerns | base-spec edit | DONE_WITH_CONCERNS`) - - Resolution section (what the agent did) - - Any unresolved followups - - Files changed (from agent report or `git diff <branch>`) -2. Ask the user via `AskUserQuestion`: **Merge / Extend scope / Discard** - - **Merge** — proceed with the clean merge path (`git merge bug-bash/BUG-<NNN> --no-edit`), then `mv .bug-bash/pending-merge/bug-<NNN>.md .bug-bash/merged/` - - **Extend scope** — re-dispatch the agent on the same branch with the extension instructions, move back to `in-progress/` - - **Discard** — abandon the work: `git worktree remove --force`, `git branch -D`, move to `failed/` -3. Process bugs serially — one decision at a time. The user reviews each gated bug before moving to the next. - -Skip this command if `pending-merge/` is empty. - ---- - ## Wrap-up When invoked with `done` argument, or user says "done" or "wrap up": @@ -629,10 +544,6 @@ If agents are still running (files in `in-progress/`): ``` Wait for all active agents to complete (check with TaskOutput). -### Step 1b: Resolve Pending-Merge - -If any bugs are in `.bug-bash/pending-merge/`, run the Review flow (see above) before generating the summary. Wrap-up cannot complete with gated bugs unresolved — they require explicit user decisions. - ### Step 2: Final Summary ``` @@ -764,22 +675,6 @@ grep -l "<filename>" .bug-bash/in-progress/bug-*.md 2>/dev/null This is not optional. Dispatching two agents that touch the same file wastes both agents' work when the second merge conflicts. -### Cross-bug Capability Overlap Check (SOFT GATE) - -After the file-overlap check passes, scan in-progress and todo bugs for *capability* overlap with the new bug: - -- Same OpenSpec capability mentioned (`openspec/specs/<capability>/spec.md`) -- Same component/system named in titles or descriptions (heuristic: shared proper-noun keyword like a class name or feature name) -- Same domain area (auth, billing, sync, etc.) - -When overlap is detected, surface to the user before dispatching: - -> "BUG-<NNN> overlaps with BUG-<MMM> (both touch <capability/component>). Batch into one fix, dispatch separately and merge sequentially, or proceed in parallel?" - -This is a soft gate — if the user doesn't respond, default to proceeding in parallel after a brief pause. Capture the user's decision in the bug file's `## Notes` section so it's visible to the fix agent. - -Treat capability-only overlap as advisory; same-file overlap above is the hard gate. - ### Merge Order Merge in completion order (first done, first merged). If a later merge conflicts because an earlier merge changed the same area, follow the conflict flow above. @@ -824,8 +719,6 @@ When processing a bug report, you MUST NOT use: - **Total: max 3 search calls per bug, zero file reads** - Goal: populate "Files Likely Involved" so the agent has a starting point -**Pattern-cleanup exception:** when the bug report contains pattern-cleanup keywords (see Bug Intake → Pattern-cleanup pre-flight), one additional comprehensive `Grep` is allowed — the goal is to surface the full scope to the user before dispatch, which is the highest-leverage thing the dispatcher can do for that bug class. - ### Always Allowed - `Bash` — only for: git commands, mkdir, mv, file copy, worktree management diff --git a/skills/changelog/SKILL.md b/skills/changelog/SKILL.md index 0d10120..b1dc383 100644 --- a/skills/changelog/SKILL.md +++ b/skills/changelog/SKILL.md @@ -2,6 +2,7 @@ name: changelog allowed-tools: Bash(git log:*), Bash(git diff:*), Bash(git show:*), Bash(git tag:*) description: Use when the user asks for a changelog, release notes, or summary of recent changes +tags: [personal] --- ## Context diff --git a/skills/close-spec-drift/SKILL.md b/skills/close-spec-drift/SKILL.md deleted file mode 100644 index 0dd1042..0000000 --- a/skills/close-spec-drift/SKILL.md +++ /dev/null @@ -1,362 +0,0 @@ ---- -name: close-spec-drift -description: "Use when an OpenSpec base spec is correct but code or peripheral spec text has drifted from it — surfaces the full extent of the drift, scaffolds a thin change folder (proposal + tasks, no deltas), and hands off to /execute-plan. OpenSpec-only." ---- - -# Close Spec Drift - -Targeted workflow for "make reality match the spec." The base spec at `openspec/specs/<capability>/spec.md` already describes the canonical post-cleanup state. The job is to delete dead code, scrub stale references, and commit – with traceability through a thin change folder but **no delta files**, because the spec is not changing in any way OpenSpec's delta system can express. - -## When to use this skill - -- A base spec mentions deleted files, retired endpoints, or removed classes that still appear in source. -- Code carries compat shims for a producer that is gone (the shim has no caller). -- Spec text in non-requirement sections (`## Purpose`, `## Notes`, `### Interface`) references things that were removed in a prior change. -- An audit (`.workflow/audits/<date>/gaps.md`) flagged drift gaps to close. - -## When NOT to use this skill - -- **New behavior or design** – use `/brainstorm`. -- **Spec gap** (behavior exists in code but is not described anywhere in the spec) – use `/brainstorm` or `/spec-recommender`. -- **Reverse drift** (the spec is wrong, the code is the canonical truth and the spec needs to be re-derived) – use `/spec-recommender`. -- **Project doesn't use OpenSpec** – use `/fixit`. -- **Bug fix where the right behavior is unclear** – use `/fixit` or `/brainstorm`. - -The distinct value of `close-spec-drift` is the workflow guard (no deltas, `--no-verify` commit) plus exhaustive scope discovery before any work starts. If neither of those would change how you'd handle the cleanup, a different skill is the better fit. - -## Arguments - -`$ARGUMENTS` is flexible: - -- **Capability name** – `/close-spec-drift annotation-client`. Scope to one capability's spec and its source files. -- **Audit gap IDs** – `/close-spec-drift ux-9, api-app-prod-3`. Reads `.workflow/audits/<latest-date>/gaps.md`, identifies the affected specs, and proceeds. -- **Free-text drift description** – `/close-spec-drift "legacy Node server references in scenario text"`. Skill greps for the symptom across `openspec/specs/` and source dirs. -- **Multiple capabilities** – `/close-spec-drift annotation-client, prototype-switcher`. Scoped sweep. - -Optional flags: - -- `--scope=cap` (default) – only edit the named capability's spec/code. -- `--scope=sweep` – grep across all of `openspec/specs/` for the same drift pattern; surface and ask before editing. -- `--in-thread` – skip the `/execute-plan` handoff and execute in the current thread (for very small cleanups). - -If no arguments are provided, reply with a usage message and stop: - -``` -Usage: - /close-spec-drift <capability> – clean drift in one capability - /close-spec-drift <gap-id-list> – pull gaps from latest audit - /close-spec-drift "<symptom phrase>" – grep-driven sweep - /close-spec-drift <a>, <b> [--scope=…] – multi-capability scope -``` - -## Context - -- OpenSpec project: !`test -d openspec && echo "yes" || echo "no openspec/ dir"` -- Latest audit: !`ls -1d .workflow/audits/* 2>/dev/null | sort | tail -1` -- Active changes: !`openspec list --changes 2>/dev/null | head -10` -- Existing capabilities: !`openspec list --specs 2>/dev/null | head -20` - -## Prerequisites - -Fail immediately and stop if any of these don't hold: - -- No `openspec/` directory in CWD → "This project doesn't use OpenSpec. For drift cleanup outside OpenSpec, use `/fixit`." -- `openspec` CLI is not on `PATH` → "OpenSpec CLI is required. Install with `npm install -g @openspec/cli`." -- An OpenSpec change is already active on the current branch and is unrelated to this drift cleanup → ask the user whether to abort, or to add the cleanup as additional tasks on the existing change. - ---- - -## Phase 1: Resolve inputs - -Parse `$ARGUMENTS` into a normalized `{capabilities, symptom, source_files, audit_gaps}` shape: - -1. **If `$ARGUMENTS` looks like a capability name or comma-separated capability list** (matches entries from `openspec list --specs`): - - Capture as `capabilities`. - - `symptom` is initially unknown – Phase 2 discovers it. - -2. **If `$ARGUMENTS` looks like audit gap IDs** (kebab/numeric tokens like `ux-9`, `api-app-prod-3`, `tools-1`): - - Read the latest `.workflow/audits/<date>/gaps.md` (or whichever path the audit uses; check `.workflow/audits/<date>/index.md` if `gaps.md` doesn't exist). - - For each gap ID, extract the affected capability and a one-line symptom description. - - If the audit file can't be found or a gap ID isn't present, fail with the path and the missing IDs. - -3. **If `$ARGUMENTS` is free-text** (a phrase describing the drift): - - Capture as `symptom`. - - `capabilities` is initially `*` – Phase 2's grep determines which specs are affected. - -Normalize the result into a working set the rest of the skill operates on. - ---- - -## Phase 2: Surface the full extent of drift (mandatory pre-flight) - -This phase is non-negotiable. Drift cleanup misses are almost always scope-discovery failures – the user named one place; the same pattern existed in three others. Surface everything before touching anything. - -### 2a. Search the spec tree - -Run a comprehensive `Grep` across `openspec/specs/` for the symptom (or, when scoped to a capability, for known stale tokens like deleted file names, removed class names, retired endpoints). Use the symptom as the grep pattern; for capability-driven invocations, derive a pattern from the capability's recent diff (e.g., recently deleted file basenames, removed function names – `git log -p openspec/specs/<cap>/spec.md` and `git log --name-status` on related source). - -Capture: -- Every base spec file that contains a match. -- The line number and surrounding context (1-2 lines) for each match. -- For each match, classify as **Requirement-level** (under a `### Requirement: …` header) or **non-Requirement** (under `## Purpose`, `## Notes`, `### Interface`, etc.). The skill needs both, but they're handled differently downstream (see Phase 3). - -### 2b. Search the source tree - -Run a comprehensive `Grep` across the project's source directories (skipping `node_modules`, build outputs, and `.workflow/`) for: -- The same symptom tokens. -- The deleted file/function names (when discoverable from the spec text or the user input). - -Capture: -- Every source file that contains a match. -- Line numbers and context. -- Whether the match is in a comment, a string, or executable code – the implications differ. - -### 2c. Surface the full extent to the user - -Print a concise summary table before doing anything else. Wait for confirmation: - -``` -Drift inventory for "<symptom or capability>": - -Specs with stale references: - openspec/specs/annotation-server/spec.md:42 (### Interface: legacy Node POST endpoint) - openspec/specs/annotation-client/spec.md:58 (## Notes: "legacy Node server compatibility") - openspec/specs/prototype-switcher/spec.md:11 (## Purpose: mentions tools/annotate/server.js) - -Source with potentially-dead references: - tools/annotate/src/api-client.js:204 (legacy producer compat shim) - tools/annotate/src/helpers.js:88 (normalizeAnnotation legacy branch) - ux/src/lib/api/client.ts:155 (legacy attribution path) - ux/src/components/AnnotationList.tsx:33 (comment referencing removed server) - -Out of scope (informational): 0 audit gaps unrelated to this symptom. - -Scope this cleanup to all of the above? Or narrow it? - [a] All – proceed with full sweep - [b] Specs only – cleanup is text-only, skip source - [c] Custom – I'll list which to include/exclude -``` - -Use `AskUserQuestion` with these options. The user's response is the **confirmed scope**, recorded as a structured list that downstream phases iterate over. - -### 2d. Classify each match - -Once scope is confirmed, classify every entry into one of three buckets. This determines what work the change folder describes: - -- **Spec text drift (cheap)** – the file just contains stale text. Direct edit, no investigation needed. -- **Spec/code drift requiring investigation** – source code looks like a dead branch, but you can't be sure without reading it. The change folder will include an explicit "verify reachability" task before deletion. -- **Cross-spec drift** – the same stale reference appears in multiple specs. Each one is a separate edit but they all close together. - -Display the classification: - -``` -Classification: - Spec text drift: 3 files (annotation-server:42, annotation-client:58, prototype-switcher:11) - Spec/code drift: 4 files (api-client.js, helpers.js, client.ts, AnnotationList.tsx) - ↳ requires reachability check before deletion - Cross-spec drift: yes (3 specs share the same stale references) -``` - -If the user wants to defer any classified items to follow-up tasks, capture that decision now (`TaskCreate` calls happen in Phase 5 once the change folder is open). - ---- - -## Phase 3: Scaffold a thin change folder - -The change folder exists for traceability. It does **not** contain delta files because the base spec is already correct – there is no requirement to add, modify, or remove. The pre-commit hook will object to behavioral code changes without staged delta files; that is expected, and the workflow guard below addresses it. - -### 3a. Pick the change name - -`close-spec-drift-<short-slug>` where `<short-slug>` is derived from the symptom or capability. Examples: - -- `close-spec-drift-legacy-node-references` -- `close-spec-drift-annotation-client-shim` -- `close-spec-drift-audit-2026-05-04` (when audit-driven and spanning multiple gaps) - -If the user passed a clear phrase in `$ARGUMENTS`, use it as the slug seed. Confirm the slug with the user via `AskUserQuestion` if there's any ambiguity (offer 2 candidates). - -### 3b. Scaffold the change - -``` -openspec new change <change-name> -``` - -This creates `openspec/changes/<change-name>/` with the empty boilerplate. - -### 3c. Generate the proposal - -Call `spec-writer` to fetch the enriched proposal template: - -``` -/spec-writer proposal <change-name> -``` - -Fill in the template with drift-specific content: - -- **Why** – describe the drift cluster: what is canonical (the base spec), what drifted (the listed files), what root event caused the drift (a prior change that didn't sweep all touchpoints, an unfinished cleanup, etc.). Cite audit gap IDs if applicable. -- **What Changes** – enumerate the cleanup, grouped by spec / by source file. For each entry, mark whether it's a direct edit or a "verify-then-edit" item. -- **Impact** – lists affected specs and source files. Explicitly note: "**No spec deltas — the base specs already describe the canonical post-cleanup state. This change closes drift, not new behavior.**" Cite which audit gap IDs (if any) this closes. - -Write to `openspec/changes/<change-name>/proposal.md`. - -Do **not** scaffold `openspec/changes/<change-name>/specs/` or any delta files. The change folder is intentionally thin. - -### 3d. Generate the tasks - -Call `spec-writer` to fetch the enriched tasks template: - -``` -/spec-writer tasks <change-name> -``` - -Fill in with drift-specific stages: - -```markdown -## 1. Verify scope - -- [ ] 1.1 Confirm dead code in {file:line} is unreachable (no callers) -- [ ] 1.2 Confirm spec text references in {spec:line} are stale (the named entity is gone from the codebase) - -## 2. Apply cleanup - -**Depends on:** Stage 1 - -- [ ] 2.1 Delete dead code in {files} (lines pinpointed in 1.1) -- [ ] 2.2 Edit non-Requirement sections in {specs} to remove stale references -- [ ] 2.3 Run `openspec validate --all --strict` – must pass with no spec changes parsed as deltas - -## 3. Test and commit - -**Depends on:** Stage 2 - -- [ ] 3.1 Run the project's test suite – nothing should break (drift cleanup is non-behavioral) -- [ ] 3.2 Commit with `git commit --no-verify` and a message that explicitly notes "drift fix, no delta needed" -``` - -For pure spec-text drift cleanups (no source code touched), Stage 1.1 and 2.1 are omitted; the tasks file becomes a 3-line cleanup. - -For cross-spec drift, each spec gets its own line under 2.2 so the agent doesn't conflate them. - -Write to `openspec/changes/<change-name>/tasks.md`. - -### 3e. Validate the change folder - -``` -openspec validate <change-name> --strict -``` - -Without delta files, OpenSpec validates this as a "doc-only" change. If the validation flags missing deltas as an error, surface the message to the user and proceed (the validation is informational here – the workflow guard explicitly accepts this). - ---- - -## Phase 4: Hand off - -Two paths, picked via `AskUserQuestion`: - -### Path A: `/execute-plan` (recommended for non-trivial sweeps) - -Print the next step: - -``` -Change `<change-name>` scaffolded. Run /clear and then: - -/execute-plan <change-name> -``` - -Copy the command to clipboard so the user can paste after `/clear`: - -```bash -echo -n "/execute-plan <change-name>" | pbcopy -``` - -This is the standard handoff per the global plan execution guidance. - -### Path B: In-thread execution (for tiny cleanups) - -If the user picks `--in-thread`, or the cleanup is genuinely small (e.g., 1-3 spec text edits, no source files), execute the tasks here using the agent-driven-development pattern (see `skills/agent-driven-development/SKILL.md`). One worktree, one agent, two-stage review, merge back. - -Apply the same workflow guards as `/fixit`: - -- **User's Exact Ask** – pass `$ARGUMENTS` verbatim to the implementer agent as the highest-priority guidance. -- **Followup capture** – any "out of scope" findings the agent reports must resolve into extend-scope, `TaskCreate`, or explicit won't-fix. -- **Merge gate** – auto-merge if all clean; hold for confirmation if the agent flagged divergence, concerns, or touched anything beyond the confirmed scope. - -In both paths, the implementer agent's prompt MUST include this workflow guard verbatim: - -``` -### Workflow guard: --no-verify is the right call here - -This change has NO delta files. The base specs at openspec/specs/<cap>/spec.md -already describe the canonical post-cleanup state. The pre-commit hook will -object to behavioral code changes without staged deltas — that is expected. - -Commit with: git commit --no-verify -m "..." - -Include "drift fix, no delta needed" in the commit message body. Do not invent -phantom delta files just to satisfy the hook. Do not scaffold openspec/changes/ -<name>/specs/ — that path stays empty. - -If you discover that a change requires a delta (e.g., the spec is actually -wrong, or new behavior surfaced), STOP and report it — that's a different -class of work and belongs in a different change folder. -``` - ---- - -## Phase 5: Followup capture - -Before declaring done, surface any items the user deferred in Phase 2c (or that the implementer agent flagged) and resolve each into one of: - -- **Extend scope on this change** – re-dispatch with the additional cleanup; the same change folder absorbs it. -- **`TaskCreate` for follow-up** – capture as a task in the native task system so it isn't lost. -- **Explicit won't-fix** – the user reviews and acknowledges it as out of scope; the rationale goes into the proposal's `Out of scope` line. - -These cannot be silently dropped in the agent's final report. - ---- - -## Phase 6: Archive (when applicable) - -If the cleanup is purely spec-text drift (no source code, no scenario changes), `openspec archive` is not strictly necessary – the base specs were edited directly and the change folder is just a paper trail. Leave the change folder in `openspec/changes/` for one cycle of audit-trail visibility, then archive on the next session: - -``` -openspec archive <change-name> --skip-specs --yes -``` - -`--skip-specs` is required because there are no deltas to merge into base specs. - -If the cleanup touched source code, follow the same archival flow – `--skip-specs` still applies because no deltas exist. - -Surface this to the user as a one-line reminder in the final summary; do not auto-archive. - ---- - -## Workflow guards - -These rules are non-negotiable and are referenced by the agent prompt above: - -- **No phantom deltas.** If the base spec is correct, do not scaffold delta files just to satisfy the pre-commit hook. The hook is bypassed via `--no-verify` – that's the explicit, documented correct path for drift cleanup. -- **Scope discovery happens before work, not after.** Phase 2's grep sweep is mandatory. The agent must not "discover" cross-spec drift in its final report; the dispatcher surfaces it before dispatch. -- **Edit non-Requirement sections directly on the base spec.** Drift in `## Purpose`, `## Notes`, `### Interface`, etc. cannot be expressed as a delta. Direct edit + `openspec validate --all --strict` is the verification path. -- **Verify reachability before deleting code.** When source code looks dead, read enough to confirm no callers before deletion. Tasks 1.1 in the tasks template enforces this. -- **Defer to the user's literal ask.** If `$ARGUMENTS` named one capability and Phase 2 surfaces drift in three, the user's confirmation in 2c is the binding scope. Do not silently expand. - -## What this skill does NOT do - -- **No new behavior.** If the spec is wrong or behavior is missing, this is the wrong skill. -- **No delta authorship.** This skill never writes `openspec/changes/<name>/specs/<cap>/spec.md`. Use `/brainstorm` for changes that need deltas. -- **No code rewrites beyond deletion of dead branches.** If the cleanup turns into a refactor, abort and route to `/brainstorm`. -- **No spec-from-code derivation.** That's `/spec-recommender`'s job. - -## Failure handling - -| Failure | Action | -|---------|--------| -| `openspec/` missing | Fail with `/fixit` suggestion | -| Capability name unknown | List actual capabilities from `openspec list --specs`, ask user to pick | -| Audit gap IDs not found | Surface the audit path and missing IDs, abort | -| Phase 2 grep returns nothing | Tell the user the symptom isn't present; ask whether they want to refine or abort | -| Phase 2 grep returns far more than expected | Surface the inventory and pause; do not auto-proceed with mass edits | -| Active OpenSpec change unrelated to drift on current branch | Ask user whether to add tasks to it or stash and start fresh | -| Pre-commit hook blocks commit (despite `--no-verify` flag in instructions) | The agent didn't follow the workflow guard; re-dispatch with the workflow guard re-emphasized | -| `openspec validate` fails after spec text edits | The edits broke spec structure; revert and re-edit more carefully | diff --git a/skills/close-worktree/SKILL.md b/skills/close-worktree/SKILL.md index 31a99c3..3fdf61e 100644 --- a/skills/close-worktree/SKILL.md +++ b/skills/close-worktree/SKILL.md @@ -2,6 +2,7 @@ name: close-worktree allowed-tools: Bash(git *), Bash(cd *), Bash(ls *), AskUserQuestion description: Use when done working in a git worktree and ready to merge it back to the main branch — asks whether to merge or squash +tags: [personal] --- ## Context diff --git a/skills/debug/SKILL.md b/skills/debug/SKILL.md index ffe7ccc..8f30791 100644 --- a/skills/debug/SKILL.md +++ b/skills/debug/SKILL.md @@ -1,6 +1,7 @@ --- name: debug description: Use when encountering any bug, test failure, or unexpected behavior, before proposing fixes — multi-agent competing hypotheses debugging +tags: [quality] --- # Competing Hypotheses Debugging diff --git a/skills/devils-advocate/SKILL.md b/skills/devils-advocate/SKILL.md index 4927d93..458ade7 100644 --- a/skills/devils-advocate/SKILL.md +++ b/skills/devils-advocate/SKILL.md @@ -1,6 +1,7 @@ --- name: devils-advocate description: Use when the user wants to stress-test an idea, plan, or approach — challenges assumptions and finds weaknesses before committing +tags: [personal] --- # Devil's Advocate diff --git a/skills/disk-cleanup/SKILL.md b/skills/disk-cleanup/SKILL.md index 9f1dfd1..770cd8d 100644 --- a/skills/disk-cleanup/SKILL.md +++ b/skills/disk-cleanup/SKILL.md @@ -1,6 +1,7 @@ --- name: disk-cleanup description: Use when the user asks about disk space or storage — scans for large storage consumers and identifies cleanup opportunities. Read-only, never deletes without approval +tags: [personal] --- # Disk Cleanup diff --git a/skills/doitright/SKILL.md b/skills/doitright/SKILL.md new file mode 100644 index 0000000..25f33a6 --- /dev/null +++ b/skills/doitright/SKILL.md @@ -0,0 +1,56 @@ +--- +name: doitright +description: Pick the long-term-correct option from the choices you just presented. Use when the user types /doitright in response to a multi-option recommendation, meaning "go with the proper long-term fix unless there's a real downside beyond effort." +user_invocable: true +tags: [personal] +--- + +# Do it right + +The user invoked this in response to a list of options you presented (typically shortcut / defer / proper long-term variants). They're invoking the principle: **"If it's worth doing at all, it's worth doing right."** + +Translation: pick the long-term-correct option unless there's a real downside beyond effort. + +## Instructions + +1. **Identify the options you just presented.** Look at your most recent set of options. The "long-term-correct" option is the one that solves the underlying problem properly, not just the immediate symptom. It is usually – but not always – the option labeled "proper," "long-term," or the highest-effort one. + +2. **Stress-test before committing.** Before announcing the pick, scan for downsides beyond effort: + + - Does the long-term option risk breaking something else (regressions, blast radius)? + - Is there a deadline or external constraint that makes "now" matter more than "right"? + - Does it require knowledge, access, or context the user doesn't have right now? + - Is it overengineering for a problem that may not actually exist (YAGNI)? + - Does it depend on an unproven assumption that a shortcut would let us validate cheaply? + + If any of these apply, **surface it before proceeding.** Say something like: "You said /doitright, but I want to flag X first – it's a downside beyond effort. Still want the long-term option, or does that change your read?" + +3. **If no real downside, just go.** State the pick in one line, then proceed with the work. No re-litigating the tradeoffs you already presented – the user made the call. + +4. **If you presented only one option, or the options are not a defer/shortcut/long-term shape**, say so and ask what they want you to do. Don't invent a "long-term" option that wasn't on the table. + +## Tone + +Confident, no hedging. The user invoked a shortcut precisely so they wouldn't have to re-explain. Confirm in one line and move. + +## Example + +You presented: + +- Option 1 (Recommended): Defer – add a TODO, wait for a real user to hit it +- Option 2: Quick fix – 10-minute timeout, ~5 min change +- Option 3: Proper fix – streaming upload with progress and configurable deadline, ~30 min + tests + +User types: `/doitright` + +You respond: + +> Going with option 3 – streaming upload with progress and configurable deadline. No downsides beyond the ~30 min of effort and the test surface, both of which you've already signed up for. Starting now. + +Then proceed. + +## When NOT to use + +- The user didn't actually invoke `/doitright` – don't reinterpret other phrases as this skill +- You didn't just present a multi-option list – if there's nothing to "pick," ask what they're referring to +- The long-term option in your list has a real downside beyond effort – flag it instead of silently overriding diff --git a/skills/eli5/SKILL.md b/skills/eli5/SKILL.md new file mode 100644 index 0000000..4edca63 --- /dev/null +++ b/skills/eli5/SKILL.md @@ -0,0 +1,49 @@ +--- +name: eli5 +description: Restate your prior response in plain, non-technical language and orient the user around the decision they need to make. Use when the user types /eli5 or asks to have something explained simply. +user_invocable: true +tags: [personal] +--- + +# ELI5 + +Re-explain your most recent substantive response in plain English. Strip the jargon, surface the choice, and make a recommendation. + +## Instructions + +1. **Identify the prior response.** Look at your last substantive assistant turn (skip trivial acknowledgements). That is the content to translate. If the prior turn was itself an ELI5 or there's nothing meaningful to restate, say so and ask the user what they'd like explained. + +2. **Translate, don't summarize.** Rewrite the content so a smart non-technical reader can follow it. Replace acronyms, library names, and code references with everyday analogies or plain descriptions. Do not add new information the prior response didn't contain — if a tradeoff wasn't discussed, don't invent one. + +3. **Structure the answer with these four sections, in this order.** Use the exact headings below. Keep each section tight — bullets over paragraphs. + + ### The decision + + One or two sentences naming the choice the user is being asked to make. Frame it as a question they need to answer. + + ### Your options + + A bullet list of the available options. One short sentence per option, in plain language. If the prior response only presented one path, say that explicitly and note what alternatives exist (or that no real alternative was offered). + + ### Tradeoffs + + For each option, the main upside and the main downside in plain terms. A short table is fine if it fits; otherwise nested bullets. + + ### My recommendation + + Pick one. State it directly. Give the one-line reason. If the recommendation depends on something the user knows but you don't, name that condition. + +4. **Tone.** Conversational, confident, no hedging. No emojis unless the user has used them. No code blocks unless the original choice is literally about syntax. Sentence case for headings (already set above). + +5. **Length.** Aim for under 250 words total. If the prior response was a one-liner, the ELI5 can be even shorter — don't pad. + +## When to use + +- User types `/eli5` +- User says "explain that simply," "in plain English," "ELI5," or similar +- User seems lost after a technical explanation and you want to reset the conversation around the actual decision + +## When NOT to use + +- The prior response was already plain-language with a clear recommendation — just answer the user's follow-up directly +- The user is asking a new question, not asking you to restate the old answer diff --git a/skills/execute-plan/SKILL.md b/skills/execute-plan/SKILL.md index 45e1dfe..2ebb9a9 100644 --- a/skills/execute-plan/SKILL.md +++ b/skills/execute-plan/SKILL.md @@ -2,6 +2,7 @@ name: execute-plan description: "Use when an OpenSpec change is approved and ready to implement — executes the change's tasks.md with agent-driven development, worktree isolation, TDD discipline, two-stage review, native Task dependencies for parallel execution, and `openspec archive` at the end." user-invocable: true +tags: [spec] --- # Execute Plan (OpenSpec change) @@ -80,9 +81,7 @@ This skill is OpenSpec-only. The target project MUST have an `openspec/` directo - [ ] 3.1 ... <!-- parallel with stage 2 --> ``` - Both `## N. <name>` (canonical, emitted by `/brainstorm`) and `## Phase N: <name>` (legacy / hand-written) headings are accepted as stage boundaries. Phase-style files often omit the explicit `**Depends on:**` lines — that's fine; fall back to numerical ordering for those (Stage N depends on Stage N-1). - - Each H2 group is one stage. Extract: + Each `## N. <name>` group is one stage. Extract: - **Stages**: ordered list of discrete work chunks (one per H2 group). - **Dependencies**: the `**Depends on:** Stage N[, Stage M]` line under each H2. If absent, infer from numerical order (Stage N depends on Stage N-1). @@ -98,27 +97,21 @@ This skill is OpenSpec-only. The target project MUST have an `openspec/` directo ## Worktree decision -Inspect the dependency graph from Phase 0 step 7. **If any stages share a blocker set** (siblings — multiple stages with the same `**Depends on:**` and no shared files, eligible to run concurrently), worktree mode is required. Notify the user, don't ask: - -> **This change has parallel-eligible stages: <list>. Running in worktree mode at `.claude/worktree/<name>/` so they can execute concurrently.** - -If the dependency graph is **fully sequential** (each stage depends only on the previous one), ask: +Before execution begins, ask the user how to run the change: > **How do you want to run this change?** > > - **Execute on current branch** (recommended) — stages run here, commits land on this branch as they complete. -> - **Execute in a worktree** — creates an isolated branch so you can keep working or run another agent in this session. +> - **Execute in a worktree** — creates an isolated branch so you can keep working or run another agent in this session. Only useful if you're running multiple coding workstreams at the same time. -Default to "current branch" for sequential changes if the user doesn't have a preference. The branch name should match the change name (`<name>`) wherever possible — this is the convention `/save-w-specs` and the pre-commit hook use to identify the active change. +Default to "current branch" if the user doesn't have a preference. The branch name should match the change name (`<name>`) wherever possible — this is the convention `/save-w-specs` and the pre-commit hook use to identify the active change. -When worktree mode is in effect (either auto-selected for parallel changes or chosen by the user): +If the user chooses worktree mode: 1. Create a worktree at `.claude/worktree/<name>/` for the overall execution. 2. All per-stage worktrees nest inside that (`.claude/worktree/<name>/<task-slug>/`). 3. After all stages complete and merge to the worktree's branch, present the result for the user to merge back to their original branch (via `/close-worktree` or manual merge). -**Why current-branch mode can't parallelize:** it has one working directory. Two implementer agents writing to the same tree would clobber each other. Worktrees give each agent isolated state — the only way parallel-eligible stages actually run in parallel. - --- ## Phase 1: Create task graph @@ -183,26 +176,9 @@ The implementer reports one of: `DONE`, `DONE_WITH_CONCERNS`, `NEEDS_CONTEXT`, ` Handle all statuses internally per the autonomous execution rules (see below). Never ask the user. -### Review (with test-only fast-path) - -**Implementer self-verification runs first, regardless of stage type.** Before reporting `DONE`, the implementer must run the project's linter and test suite (per `verification-before-completion`) and include the captured output in their result. Lint and trivial test failures must never reach review — they're cheap to catch in the implementer's own loop. - -**Test-only stages** — stages that produce only test files (no production code). Detect by either: - -- The stage is the canonical "## 1. Tests" section, OR -- The implementer's diff touches only test-like paths (`*_test.go`, `**/spec/**`, `**/__tests__/**`, `**/*.test.{ts,tsx,js,jsx}`, `**/test_*.py`, `**/tests/**`, etc.) - -For test-only stages, run **one combined review pass** that covers both spec compliance and test correctness: +### Two-stage review -1. Dispatch a single reviewer with the spec-reviewer prompt PLUS instructions to also flag test-correctness issues (wrong assertions, missing edge cases that the deltas imply, misuse of test fixtures, brittle setup) and obvious structural problems (duplicated test bodies, hard-coded values that should be derived from the scenario). -2. Verify every scenario in the relevant delta has a corresponding test. -3. Verify the tests fail in the expected way (not from import errors, syntax errors, or fixture wiring problems — they should fail because the production code doesn't yet implement the behavior). -4. If issues found: implementer fixes, reviewer re-reviews. **Cap at one fix loop** for test-only stages — if the second pass still has blocking issues, escalate (per the BLOCKED ladder) rather than looping further. Test code that's still wrong after one fix is a signal the deltas themselves are unclear; surface it. -5. Skip the separate code quality reviewer entirely. - -Rationale: test code with no production code to validate against has limited "quality" surface. The cheap stuff (lint, syntax) is caught by implementer self-verification; the substantive stuff (scenario coverage, correctness, structure) is caught by the combined pass. Two full review cycles before any production code lands is over-rotated. - -**All other stages** — full two-stage review: +After the implementer finishes: 1. **Dispatch spec reviewer** — checks implementation matches the change's deltas (every scenario in the relevant delta has a corresponding passing test, behavior matches `**WHEN**`/`**THEN**`). Uses `skills/agent-driven-development/spec-reviewer-prompt.md`. Provide the delta specs and the design doc as inputs. 2. If issues found: implementer fixes, spec reviewer re-reviews. Loop until clean. @@ -254,34 +230,9 @@ If anything fails, route the failure back into Phase 2 (re-dispatch the affected --- -## Phase 4: Quality gates - -Before archiving, offer quality checks via `AskUserQuestion`: - -> "Change `<name>` ready. Run quality checks before archiving?" - -Options: - -- **Both** (recommended) — run `/ralph-review` and `/spec-audit`. Ralph reviews against the active deltas; ralph closes the gate (archives) when done. -- **Ralph-review only** — autonomous review loop against active deltas. Ralph closes the gate when done. -- **Spec-audit only** — audit spec coverage. Execute-plan closes the gate after. -- **Done** — skip checks. Execute-plan closes the gate immediately. - -Handoff logic: - -- **Both / Ralph-review only**: invoke `/ralph-review`. Ralph owns the rest of the flow — it runs the review loop, addresses findings, and archives the change at its Done step. Stop here. Do not proceed to Phase 5 or Phase 6. If "Both" was chosen, also dispatch `/spec-audit` in parallel before handing off. -- **Spec-audit only**: run it, then proceed to Phase 5. -- **Done**: proceed to Phase 5. - -Quality gates are token-heavy, so opt-in — but offering them while the change is still active (deltas intact, reviewable) makes them actually usable. - ---- - -## Phase 5: Archive the change - -Skipped if the user chose ralph-review in Phase 4 — ralph closes the gate. +## Phase 4: Archive the change -Otherwise: +Once Phase 3 passes, archive the change: ``` openspec archive <name> --yes @@ -299,11 +250,9 @@ If the archive fails (e.g. validation errors when merging deltas), surface the e --- -## Phase 6: Summary +## Phase 5: Summary -Skipped if ralph-review took over — ralph produces its own completion summary. - -Otherwise, produce one final report after archiving: +Produce one final report after archiving: ```markdown ## Change Execution Complete @@ -340,6 +289,23 @@ Otherwise, produce one final report after archiving: --- +## Phase 6: Quality gates + +After presenting the summary report, offer quality checks via `AskUserQuestion`: + +> "Change `<name>` archived. Want to run quality checks?" + +Options: + +- **Both** (recommended) — run `/ralph-review` and `/spec-audit` in parallel. +- **Ralph-review only** — autonomous review loop comparing implementation against the merged base specs. +- **Spec-audit only** — audit spec coverage, find behavioral gaps post-archive. +- **Done** — skip quality gates. + +These are token-heavy, so they are opt-in. But offering them at the natural completion point makes them easy to reach. + +--- + ## Autonomous execution Once execution starts (Phases 1-4), the controller never asks the user anything. Handle all statuses internally: diff --git a/skills/fixit/SKILL.md b/skills/fixit/SKILL.md index be587cc..460d074 100644 --- a/skills/fixit/SKILL.md +++ b/skills/fixit/SKILL.md @@ -1,6 +1,7 @@ --- name: fixit description: Use when the user reports a bug or issue that can be fixed without blocking their current work — backgrounds an agent in a worktree to fix and merge back without breaking stride +tags: [workflow] --- # Fixit @@ -30,16 +31,7 @@ You are a dispatcher, not a debugger. Do NOT read source code or investigate. - Parse the user's description - Run up to 3 `Glob`/`Grep` calls (paths only, no content reads) to locate likely files -- If the description is ambiguous about *what* is broken, echo back a 1-line interpretation and proceed — don't block on clarification -- If the description prescribes a *workflow* (e.g., "via a PR", "via a proper OpenSpec change", "with `--no-verify`", "as drift cleanup", "as a hotfix") and you would otherwise dispatch differently, stop and surface the mismatch to the user before dispatching. Do not silently substitute a different workflow. - -#### Pattern-cleanup pre-flight - -If the description contains pattern-cleanup keywords ("remove all", "clean up", "drop references to", "delete all", "legacy", "dead", "deprecated"), exceed the 3-call budget once: run a single comprehensive `Grep` for the pattern across the relevant scope (typically the named directory tree). Then surface the full extent to the user before dispatching: - -> "You named A; the same pattern exists in B and C. Scope to all three or just A?" - -Wait for the user's answer before creating the worktree. Pattern cleanup is the one class of bug where missing the full extent guarantees rework. +- If the description is ambiguous, echo back a 1-line interpretation and proceed — don't block on clarification ### 2. Create Worktree @@ -61,8 +53,6 @@ git branch -D "$SLUG" 2>/dev/null ### 3. Dispatch Background Agent -Before dispatching, do a one-line self-review: "Did I add anything to the agent prompt that the user didn't ask for, or omit anything they did ask for?" If yes, set a `merge_gate=divergence` flag for the completion handler (see Merge gate, below). - Use the `Agent` tool with `run_in_background: true` and `mode: "bypassPermissions"`: ``` @@ -73,11 +63,6 @@ Use the `Agent` tool with `run_in_background: true` and `mode: "bypassPermission - Working directory: <worktree path> - Branch: <SLUG> -### User's Exact Ask -<verbatim copy of $ARGUMENTS — the user's command-line instruction, unedited> - -This section is the highest-priority guidance. If anything below conflicts with it (including the Spec-aware project branching), defer to this section. - ### Bug Description <user's description> @@ -168,28 +153,7 @@ Before merging, run both reviews from agent-driven-development (see prompt templ Spec compliance must pass before code quality review begins. -#### Followup capture - -Every "out of scope" / "concerns" / "follow-up" item in the agent's report must resolve into one of three outcomes — they cannot be silently dropped: - -- **Extend scope** — re-dispatch the agent on the same branch with the additional work (after user approval), OR -- **Capture as task** — call `TaskCreate` to track the followup, OR -- **Explicit won't-fix** — the user reviews and acknowledges the item as out of scope. - -Surface the followups to the user as a short list and ask which outcome applies. If anything remains unresolved, set `merge_gate=concerns`. - -#### Merge gate - -Auto-merge is the default. Hold for one-line user confirmation when any of the following are true: - -- `merge_gate=divergence` was set in step 3 (dispatcher prompt diverged from user's literal ask) -- Agent reported `DONE_WITH_CONCERNS` -- `merge_gate=concerns` was set during followup capture (work queued or pending decision) -- Project is OpenSpec (`test -d openspec`) and the fix touched files under `openspec/specs/**/*.md` - -When gating, print one summary: "Agent did X, flagged Y, diverged on Z. Merge / extend scope / discard?" and wait for the user's call. - -Once both reviews pass and the gate (if any) is cleared: +Once both reviews pass: ```bash git checkout <original-branch> @@ -240,7 +204,6 @@ Report to user: - **Never read source code in the main thread** — agents do that - **Never investigate root causes** — agents do that -- **Defer to the user's literal workflow** — if their wording prescribes a process (e.g., "via a PR", "via an OpenSpec change", "with `--no-verify`", "as drift cleanup"), follow it. The list is examples, not exhaustive — any prescribed workflow wins over the dispatcher's judgment, including the drift-vs-gap classification embedded in the agent prompt. If the prescribed workflow doesn't make sense for the project, surface that to the user before dispatching. -- **Dispatch and return immediately** — except when a workflow-instruction mismatch or pattern-cleanup scope check requires confirmation (see Triage), or when the merge gate triggers on completion. +- **Never block the user** — dispatch and return immediately - **One bug, one agent, one worktree** — no queues, no sessions -- **Triage search budget**: max 3 Glob/Grep calls plus the pattern-cleanup pre-flight grep when triggered, zero file reads +- **Triage search budget**: max 3 Glob/Grep calls, zero file reads diff --git a/skills/guard/SKILL.md b/skills/guard/SKILL.md index 609c113..0c90640 100644 --- a/skills/guard/SKILL.md +++ b/skills/guard/SKILL.md @@ -1,6 +1,7 @@ --- name: guard description: Use before any git commit to check for secrets, security antipatterns, and test breakage +tags: [quality] --- # Pre-commit Guard diff --git a/skills/handoff/SKILL.md b/skills/handoff/SKILL.md index eab2dea..bac8ddb 100644 --- a/skills/handoff/SKILL.md +++ b/skills/handoff/SKILL.md @@ -1,6 +1,7 @@ --- name: handoff description: Generate a handoff prompt to pass context to another agent thread. Use when switching repos, handing off work, or sharing context between agents. +tags: [personal] --- # Context Handoff @@ -69,6 +70,17 @@ This ensures the handoff is immediately ready to paste into a new session withou ### Step 3: Persist to Memory -Save a summary observation to the built-in Claude auto-memory system so the context survives beyond the clipboard. Write a project-type memory file at `~/.claude/projects/<encoded-project-path>/memory/project_<topic>.md` (with the standard frontmatter — `name`, `description`, `type: project`, plus a body containing the 1-2 sentence summary, branch, repo, and remaining-items hook), then add a one-line index entry to `MEMORY.md` in that same directory. +Also save a summary observation to the memory system so this context survives beyond the clipboard. Use the memory-query MCP tool to insert an observation: + +```sql +INSERT INTO observations (category, observation, confidence, supporting_data, created_at) +VALUES ( + 'session-handoff', + '[1-2 sentence summary of what was handed off]', + 0.9, + '{"branch": "[branch]", "repo": "[repo]", "remaining_items": [count], "key_files": ["file1", "file2"]}', + NOW() +); +``` -Follow the auto-memory format described in the global CLAUDE.md (`### Step 1` writes the memory file with frontmatter; `### Step 2` adds the `MEMORY.md` index pointer). This ensures the handoff context is recoverable even if the clipboard is lost. +This ensures the handoff context is recoverable even if the clipboard is lost. diff --git a/skills/improve/SKILL.md b/skills/improve/SKILL.md index 3378b21..9dd4ac5 100644 --- a/skills/improve/SKILL.md +++ b/skills/improve/SKILL.md @@ -1,6 +1,7 @@ --- name: improve description: Use at the end of a session to run a retrospective — upgrades skills, fixes codebase gaps, and captures durable knowledge +tags: [personal] --- # Improve Skills and Capture Knowledge @@ -172,6 +173,17 @@ After applying changes, present a brief summary of what was done: - reason for each skipped item ``` +### Step 7: Offer to run /fewer-permission-prompts + +After the summary, use `AskUserQuestion` to offer running `/fewer-permission-prompts`. That skill scans transcripts for read-only Bash/MCP calls that triggered permission prompts and proposes an allowlist for `.claude/settings.json` — a natural follow-up to a retrospective. + +- **Question:** "Run /fewer-permission-prompts to trim permission prompts from this session?" +- **Options:** + - "Yes" — invoke `/fewer-permission-prompts` immediately via the Skill tool + - "No" — end here + +If the user picks Yes, invoke the skill. If No, stop. + ## What NOT to Improve - Do not add session-specific details (specific file paths, query results) diff --git a/skills/interview/SKILL.md b/skills/interview/SKILL.md index cfaaca0..be7ffed 100644 --- a/skills/interview/SKILL.md +++ b/skills/interview/SKILL.md @@ -1,6 +1,7 @@ --- name: interview description: Structured interview-style review of any system, feature, or codebase. Builds an inventory, walks through items one-by-one in small chunks, tracks progress, captures decisions as artifacts. Use when the user wants to systematically review, audit, or evaluate something collaboratively. +tags: [personal] --- # Structured Interview Review diff --git a/skills/kickoff/SKILL.md b/skills/kickoff/SKILL.md index 870883d..c6b10bc 100644 --- a/skills/kickoff/SKILL.md +++ b/skills/kickoff/SKILL.md @@ -2,6 +2,7 @@ name: kickoff description: "Use when starting a brand new project from scratch -- runs discovery, picks a tech stack tier, then hands off to brainstorm and build. Guides non-technical and technical users alike." user-invocable: true +tags: [personal] --- # Kickoff: New Project from Zero diff --git a/skills/list-skills/SKILL.md b/skills/list-skills/SKILL.md index f6ff837..086a4ed 100644 --- a/skills/list-skills/SKILL.md +++ b/skills/list-skills/SKILL.md @@ -1,6 +1,7 @@ --- name: list-skills description: Quick reference of all available skills and what they do. Use when you need a reminder of your toolkit. +tags: [personal] --- # Skill Quick Reference diff --git a/skills/logo/SKILL.md b/skills/logo/SKILL.md deleted file mode 100644 index d44bb11..0000000 --- a/skills/logo/SKILL.md +++ /dev/null @@ -1,141 +0,0 @@ ---- -name: logo -description: Use when the user wants to create or generate a logo ---- -# Logo Generation - -Generate SVG logo alternatives for a project, then present them in a side-by-side comparison page for review. - -## Arguments - -- `$ARGUMENTS` - Description of the logo concept, style direction, or project name - -## Instructions - -You are generating SVG logo alternatives and a visual comparison page. Your goal is to produce **6 distinct design directions** so the user can pick and refine. - -### Step 1: Understand the Brief - -Determine what the logo should communicate: - -1. **Project name** — What is the logo for? -2. **Core concept** — What metaphor or visual idea? (e.g., "forge + AI agents", "speed + reliability") -3. **Style direction** — Modern/minimal, geometric, organic, playful, corporate? -4. **Color palette** — Dark background? Brand colors? Warm/cool? -5. **Shape** — Circle, rounded rect, hexagon, freeform? -6. **Existing logo** — Is there a current logo to improve on? Read it first. - -If unclear, ask the user before generating. - -### Step 2: Design 6 Alternatives - -Create 6 **meaningfully different** SVG logo files. Each should explore a distinct visual direction: - -| # | Direction | What to Try | -|---|-----------|-------------| -| 1 | **Minimal** | Strip to the essential mark. One shape, one gradient. App icon clean. | -| 2 | **Geometric** | Low-poly, faceted, angular. Crystal/tech aesthetic. | -| 3 | **Organic** | Flowing curves, natural forms. Warmth and craft. | -| 4 | **Structural** | Architectural forms, layered shapes, or negative space. The structure is the mark. | -| 5 | **Conceptual** | Symbolic/metaphorical. Combine two ideas into one mark (e.g., flame + orbits). | -| 6 | **Bold** | High-contrast, strong presence. Thick strokes, confident shapes. Statement piece. | - -**CRITICAL: No text in logos.** Never use text elements, letters, words, or typographic marks in the SVG logos. Every logo must be purely symbolic — shapes, icons, and abstract marks only. Text/wordmarks are added separately by the user if needed. - -**SVG quality standards:** -- Always include explicit `width="200" height="200"` on the svg element (required for img tag rendering) -- **Transparent background** — do not include a background rect. The comparison page provides the dark background via the .well container. Logos must work on any background. -- Use defs for gradients, filters, and reusable elements -- Include glow/blur filters for light-emitting elements (feGaussianBlur + feMerge) -- Use linearGradient for directional surfaces, radialGradient for point-light and glow -- Layer elements: ambient light then structure then hero element then accents -- Keep the viewBox at 0 0 200 200 for the main logo - -Save to the project's assets/ directory: -- assets/logo-alt-1.svg through assets/logo-alt-6.svg -- If there is an existing logo, include it as the "Current" option - -### Step 3: Generate Comparison Page - -Create assets/logo-compare.html — a dark-themed grid page showing all options side-by-side. - -**Template:** - -```html -<!DOCTYPE html> -<html> -<head> -<meta charset="utf-8"> -<title>[Project] Logo Alternatives - - - -

[Project] Logo Alternatives

-

6 design directions. Click any logo to open full-size SVG.

-
- -
- ALT 1 -
Alt 1
-

[Short Name]

-

[1-line description]

-
- -
- - -``` - -### Step 4: Open and Present - -1. Open the comparison HTML in the browser: `open assets/logo-compare.html` -2. Present a summary table of all options with name and concept -3. Offer to refine, mix elements, or apply the chosen design - -### Step 5: Apply the Winner - -When the user picks a logo: - -1. Copy it to favicon.svg in the project root -2. Add the logo to the top of the project README, centered at 120px width: - ```html -

- ``` - Insert this as the very first line, before any existing heading or content. -3. Clean up the logo-alt-*.svg files and logo-compare.html - -## Design Principles - -1. **Distinct directions** — Each alternative should be recognizably different at a glance, not minor tweaks -2. **SVG-native** — Use gradients, filters, and transforms. No raster effects. -3. **Scale well** — Design should read clearly at 32px (favicon) and 200px (logo) -4. **Dark-first** — Assume dark backgrounds. Light elements should glow. -5. **Layered depth** — Background then ambient glow then structure then hero then sparkle/accent -6. **Name each option** — Short, memorable names make discussion easier ("Orbital Forge" vs "Alt 5") diff --git a/skills/mcp-prune/SKILL.md b/skills/mcp-prune/SKILL.md index 7d558e7..8420336 100644 --- a/skills/mcp-prune/SKILL.md +++ b/skills/mcp-prune/SKILL.md @@ -1,6 +1,7 @@ --- name: mcp-prune description: Analyze active MCP servers and disable irrelevant ones for the current project. Use when starting work in a project with many global MCP servers that waste context tokens. Saves config to project settings. +tags: [personal] --- ## Context diff --git a/skills/merge/SKILL.md b/skills/merge/SKILL.md index c33aa07..b50a3fd 100644 --- a/skills/merge/SKILL.md +++ b/skills/merge/SKILL.md @@ -2,6 +2,7 @@ name: merge allowed-tools: Bash(git add:*), Bash(git commit:*), Bash(git checkout:*), Bash(git pull:*), Bash(git push:*), Bash(git branch:*), Bash(git fetch:*), Bash(git log:*), Bash(git diff:*), Bash(git reset:*), Bash(git rebase:*), mcp__plugin_github_github__list_pull_requests, mcp__plugin_github_github__create_pull_request, mcp__plugin_github_github__update_pull_request, mcp__plugin_github_github__merge_pull_request description: Use when the user wants to merge the current branch to master — merges via GitHub PR +tags: [pr] --- ## Context diff --git a/skills/migrate-to-openspec/SKILL.md b/skills/migrate-to-openspec/SKILL.md index e41283b..aa58dfe 100644 --- a/skills/migrate-to-openspec/SKILL.md +++ b/skills/migrate-to-openspec/SKILL.md @@ -1,6 +1,7 @@ --- name: migrate-to-openspec description: Convert a legacy AI-RON spec project (`.specs` file + `specs/*.md`) to OpenSpec layout with verifiable fidelity. One-time per project. Translator + verifier agents preserve every Given/When/Then case as an OpenSpec scenario, archive originals at `.workflow/legacy-specs/`, and install the new pre-commit hook + CLAUDE.md snippets. +tags: [spec] --- # Migrate to OpenSpec @@ -15,7 +16,6 @@ The tool refuses to run on a project that already has an `openspec/` directory o - `--max-parallel ` — Cap on concurrent translator/verifier agents (default 20). - `--auto-stash` — Auto-stash dirty working tree instead of failing preflight. Stash pop runs at the end of Phase 5. -- `--change ` — Repeatable. Marks the spec at `/.md` as describing **future** behavior that should land at `openspec/changes//` instead of `openspec/specs//spec.md`. The migration auto-discovers the matching plan under `/plans/` and uses its basename as the change name (see Phase 1 "Change candidates" below). Fails fast if no matching plan exists. ## Phase 0: Preflight @@ -43,16 +43,6 @@ Once preflight passes, the CLI tags the current HEAD as `pre-openspec-migration- The CLI writes the result to `migration-inventory.json` at the project root. Arrays are sorted so the same input always produces the same output. The `run` orchestrator commits the inventory and the in-progress state file on the migration branch before handing off to later phases. -### Change candidates - -When the user passes one or more `--change ` flags, Phase 1 auto-discovers the matching plan for each candidate and emits a `change_candidates: { : }` map alongside the existing buckets. Plan-discovery rules: - -1. Direct match: `/plans/.md`. Change name = ``. -2. Prefixed match: `/plans/*-.md` (any prefix, e.g. `v1.1-revise.md`). Change name = matched plan basename without `.md`. -3. No match: `migrate.sh run` exits non-zero before any tag/branch is created. - -Change-candidate spec paths still appear in `inventory.base` so Phase 2 dispatches the same translator + verifier on them. The `change_candidates` map only affects Phase 4, which routes accepted candidates to `openspec/changes//` rather than `openspec/specs//`. - ## Phase 2: Translate + verify This phase converts every base spec into an OpenSpec capability spec and verifies fidelity. The work is split between the orchestrator (Claude reading this skill) and the deterministic CLI (`migrate.sh translate` / `migrate.sh verify`). @@ -131,16 +121,8 @@ After resolution, `migrate.sh execute` (or the tail of `migrate.sh run`) writes 1. **Initialize OpenSpec.** If `openspec/` doesn't exist, run `openspec init --tools none .`. 2. **Confirm accepted translations.** Each accepted capability already has a translated spec at `openspec/specs//spec.md` from Phase 2; the executor just confirms presence. -2b. **Route change candidates to `openspec/changes/`.** For each accepted capability whose spec base appears in `inventory.change_candidates`, the executor: - - Reads the Phase 2 translation at `openspec/specs//spec.md`. - - Rewrites it as a delta at `openspec/changes//specs//spec.md`: drops the `# Capability:` and `## Purpose` headers, replaces `## Requirements` with `## ADDED Requirements`, keeps every `### Requirement:` block verbatim. - - Generates `openspec/changes//proposal.md` from a deterministic template (Why/What Changes/Capabilities/Impact, with the capability listed under New Capabilities and pointers to `.workflow/plans/` and `.workflow/legacy-specs/`). - - Generates `openspec/changes//tasks.md` by parsing `### Phase N:` headings out of the matched plan (or `## ` headings as a fallback). Each heading becomes one `- [ ] N. ` task under `## Implementation`. - - Removes `openspec/specs//spec.md` so the change folder is the sole home of the spec until the change is later archived via `openspec archive`. - The standard `openspec validate --all --strict` pass at step 6 covers the new change folder, so no extra validation call is needed. -3. **Archive every source spec** at `.workflow/legacy-specs/.md` with a forwarding banner. The banner reads `# [Legacy] ` followed by `> Migrated to OpenSpec on <YYYY-MM-DD>.` and a list of new locations. Skipped capabilities get a "translation skipped" banner instead so the source is never silently dropped. Accepted change candidates get a third banner: `> Translated to in-flight OpenSpec change \`<change-name>\`.` so readers know to follow the change folder, not `openspec/specs/`. +3. **Archive every source spec** at `.workflow/legacy-specs/<filename>.md` with a forwarding banner. The banner reads `# [Legacy] <title>` followed by `> Migrated to OpenSpec on <YYYY-MM-DD>.` and a list of new locations. Skipped capabilities get a "translation skipped" banner instead so the source is never silently dropped. 4. **Move sibling artifacts:** `<spec-dir>/plans/` → `.workflow/plans/`, `<spec-dir>/docs/` → `.workflow/docs/`, `<spec-dir>/audits/` → `.workflow/audits/`, `<spec-dir>/todo/` → `.workflow/todo/`. Subdirectories are preserved. -4b. **Translate legacy `/spec-audit` config (if present).** If `<spec-dir>/.audit-config.json` exists, write a translated copy to `openspec/.audit-config.json`: each `modules[*].specs` entry is normalized from a legacy filename (`<name>.md`, possibly path-prefixed) to the OpenSpec capability slug (`<name>`); entries whose slug is not in the accepted-capabilities set (skipped capabilities, or capabilities that became `openspec/changes/` rather than `openspec/specs/`) are dropped; `mapping_cache` is reset to `{}` (spec paths and content hashes all change in the migration); `pitfalls`, `extensions`, `excludes`, `test_suites`, and `version` are preserved verbatim. Best-effort: missing config or jq failure is logged and skipped, never fatal. 5. **Preserve `.specs`** as `.workflow/legacy-specs/.specs`, then delete the original `.specs` and the (now-empty) spec dir. 6. **Validate.** Run `openspec validate --all --strict`. On failure, the executor rolls back via `git reset --hard <pre-migration-tag>` + `git clean -fdq` and aborts. 7. **Install templates** from `templates/` into the project: three CLAUDE.md snippets at `claude-rules/snippets/global/` and the new pre-commit hook at `scripts/spec-check-hook.sh`. If a template is missing (Stage 6 hasn't filled it in), the executor writes a one-line stub so the install step still succeeds — Stage 6 replaces the stubs with real content. diff --git a/skills/migrate-to-openspec/migrate.sh b/skills/migrate-to-openspec/migrate.sh index d55dfb0..ddd4b77 100755 --- a/skills/migrate-to-openspec/migrate.sh +++ b/skills/migrate-to-openspec/migrate.sh @@ -176,7 +176,6 @@ classify_path() { # Build the inventory JSON from a deterministic walk of the spec dir. build_inventory_json() { local root="$1" - local change_candidates_json="${2:-{\}}" # JSON object mapping <spec-base> -> <change-name> local spec_dir spec_dir=$(read_spec_dir "$root") local abs="$root/$spec_dir" @@ -225,7 +224,6 @@ build_inventory_json() { --argjson audits "$audits_json" \ --argjson todo "$todo_json" \ --argjson other "$other_json" \ - --argjson change_candidates "$change_candidates_json" \ '{ spec_dir: $spec_dir, base: $base, @@ -233,40 +231,10 @@ build_inventory_json() { docs: $docs, audits: $audits, todo: $todo, - other: $other, - change_candidates: $change_candidates + other: $other }' } -# Discover the plan file for a change candidate. Echoes the project-relative -# plan path on success; returns 1 with a message on stderr otherwise. -# Lookup order: -# 1. <spec-dir>/plans/<spec-base>.md (direct match) -# 2. <spec-dir>/plans/*-<spec-base>.md (any version-prefixed match) -discover_plan_for_change() { - local root="$1" - local spec_base="$2" - local spec_dir - spec_dir=$(read_spec_dir "$root") - - local direct="$root/$spec_dir/plans/$spec_base.md" - if [[ -f "$direct" ]]; then - printf '%s\n' "$spec_dir/plans/$spec_base.md" - return 0 - fi - - local match - match=$(find "$root/$spec_dir/plans" -maxdepth 1 -type f -name "*-$spec_base.md" 2>/dev/null \ - | LC_ALL=C sort | head -1) - if [[ -n "$match" ]]; then - printf '%s\n' "${match#$root/}" - return 0 - fi - - err "no matching plan found for change candidate '$spec_base' (looked for $spec_dir/plans/$spec_base.md and $spec_dir/plans/*-$spec_base.md)" - return 1 -} - cmd_inventory() { require_jq local root @@ -308,12 +276,10 @@ cmd_run() { # this stage's CLI; the orchestrator parses them). local auto_stash=0 local auto_accept=0 - local change_bases=() while [[ $# -gt 0 ]]; do case "$1" in --auto-stash) auto_stash=1; shift ;; --auto-accept) auto_accept=1; shift ;; - --change) change_bases+=("$2"); shift 2 ;; --max-parallel) shift 2 ;; --) shift; break ;; *) shift ;; @@ -329,23 +295,9 @@ cmd_run() { # Tag + branch create_pre_migration_tag_and_branch "$root" >/dev/null - # Resolve change candidates -> matched plan basenames. Fail fast if any - # flagged spec lacks a discoverable plan. - local change_candidates_json='{}' - if [[ ${#change_bases[@]} -gt 0 ]]; then - local cb plan_path change_name - for cb in "${change_bases[@]}"; do - plan_path=$(discover_plan_for_change "$root" "$cb") || die "Phase 1: --change $cb: no matching plan" - change_name="${plan_path##*/}" - change_name="${change_name%.md}" - change_candidates_json=$(jq --arg k "$cb" --arg v "$change_name" \ - '. + {($k): $v}' <<< "$change_candidates_json") - done - fi - # Phase 1 inventory local inv - inv=$(build_inventory_json "$root" "$change_candidates_json") + inv=$(build_inventory_json "$root") printf '%s\n' "$inv" > "$root/migration-inventory.json" # Commit the inventory on the migration branch so the state is durable. @@ -1115,193 +1067,6 @@ _install_template() { fi } -# Translate a legacy spec-audit config (`<spec-dir>/.audit-config.json`) into -# the OpenSpec location (`openspec/.audit-config.json`). Modules' `specs` -# arrays are normalized from legacy filenames (`foo.md`, `path/foo.md`) to -# capability slugs (`foo`); entries whose slug is not in the accepted-caps -# set are dropped; `mapping_cache` is reset to `{}`; everything else -# (`pitfalls`, `extensions`, `excludes`, `test_suites`, `version`) is -# preserved verbatim. No-op if the legacy config is absent. -# Install a change-folder at openspec/changes/<change-name>/ for an accepted -# change candidate, using the Phase 2 translation as the source for the delta -# spec and the matched plan as the source for tasks.md. Removes the original -# openspec/specs/<cap>/spec.md so the change folder is the sole home of the -# capability until the change is later archived. -# -# Args: <root> <spec-base> <change-name> <capability> <spec-relpath> <plan-relpath> -_install_change_folder() { - local root="$1" - local spec_base="$2" - local change_name="$3" - local cap="$4" - local spec_relpath="$5" # e.g. "specs/revise.md" - local plan_relpath="$6" # e.g. "specs/plans/v1.1-revise.md" - - local src_translation="$root/openspec/specs/$cap/spec.md" - if [[ ! -f "$src_translation" ]]; then - err "Phase 4: change candidate '$cap' has no Phase 2 translation at $src_translation" - return 1 - fi - - local change_dir="$root/openspec/changes/$change_name" - mkdir -p "$change_dir/specs/$cap" - - # ---- 1. Convert translation -> delta. - # The translator emits: - # # <title> - # ## Purpose - # <text> - # ## Requirements - # ### Requirement: ... - # For the delta, drop everything up to (but not including) ## Requirements, - # and rename that header to ## ADDED Requirements. - local delta_path="$change_dir/specs/$cap/spec.md" - awk ' - BEGIN { found = 0 } - /^## Requirements[[:space:]]*$/ { - print "## ADDED Requirements" - found = 1 - next - } - found { print } - ' "$src_translation" > "$delta_path.tmp" - - if [[ ! -s "$delta_path.tmp" ]]; then - rm -f "$delta_path.tmp" - err "Phase 4: failed to extract requirements section from translation for '$cap'" - return 1 - fi - mv "$delta_path.tmp" "$delta_path" - - # ---- 2. Generate proposal.md (deterministic). - local title - title=$(_derive_title "$root/$spec_relpath") - local plan_basename="${plan_relpath##*/}" - local spec_basename="${spec_relpath##*/}" - - cat > "$change_dir/proposal.md" <<PROPOSAL -## Why - -\`$cap\` describes future behavior preserved during the OpenSpec migration. The original brainstorm and implementation plan live at \`.workflow/plans/$plan_basename\`; the legacy requirements doc is preserved at \`.workflow/legacy-specs/$spec_basename\`. This change captures that future state in OpenSpec form so the work can be tracked, validated, and ultimately archived once shipped. - -## What Changes - -- Add \`$cap\` capability with the requirements lifted from the legacy spec (see \`specs/$cap/spec.md\` for the delta). - -## Capabilities - -### New Capabilities - -- \`$cap\`: $title - -### Modified Capabilities - -(none) - -## Impact - -- Affected code: TBD - the change has not been implemented yet. See \`tasks.md\` for the implementation phases. -- Original design: \`.workflow/plans/$plan_basename\`. -- Legacy requirements: \`.workflow/legacy-specs/$spec_basename\`. -PROPOSAL - - # ---- 3. Generate tasks.md from the matched plan. - # Parse "### Phase N:" headings first; fall back to "## " headings. - local plan_full="$root/$plan_relpath" - { - printf '# Tasks\n\n' - printf '## Implementation\n\n' - if grep -qE '^### Phase [0-9]+' "$plan_full" 2>/dev/null; then - local n=0 line heading - while IFS= read -r line; do - n=$((n + 1)) - heading="${line#### }" - printf -- '- [ ] %d. %s\n' "$n" "$heading" - done < <(grep -E '^### Phase [0-9]+' "$plan_full") - else - local n=0 line heading - while IFS= read -r line; do - n=$((n + 1)) - heading="${line## }" - heading="${heading## }" - printf -- '- [ ] %d. %s\n' "$n" "$heading" - done < <(grep -E '^## [^#]' "$plan_full" 2>/dev/null) - if [[ $n -eq 0 ]]; then - printf -- '- [ ] 1. Implement %s per `.workflow/plans/%s`\n' "$cap" "$plan_basename" - fi - fi - } > "$change_dir/tasks.md" - - # ---- 4. Remove the openspec/specs/ landing the translator wrote, since - # the change folder is the sole home of the capability until archive. - # rm -rf to clean up sidecars (spec.md.meta.json) too. - rm -rf "$root/openspec/specs/$cap" - - printf 'Phase 4 (change candidate): wrote openspec/changes/%s/{proposal.md,tasks.md,specs/%s/spec.md}\n' \ - "$change_name" "$cap" -} - -_translate_audit_config() { - local root="$1" - local accepted_caps="$2" # newline-separated list - local change_cap_slugs_csv="${3:-}" # comma-separated list of change-candidate slugs - - local spec_dir - spec_dir=$(read_spec_dir "$root") - local src_config="$root/$spec_dir/.audit-config.json" - [[ -f "$src_config" ]] || return 0 - - # Drop change-candidate slugs from accepted: they live at - # openspec/changes/<name>/specs/<cap>/, not openspec/specs/<cap>/, so - # the spec-audit skill's spec walk won't see them and any module - # reference to them would dangle. - local accepted_in_specs - if [[ -n "$change_cap_slugs_csv" ]]; then - local IFS=',' - local -a change_arr=($change_cap_slugs_csv) - accepted_in_specs="$accepted_caps" - local cs - for cs in "${change_arr[@]}"; do - [[ -n "$cs" ]] || continue - accepted_in_specs=$(printf '%s\n' "$accepted_in_specs" | grep -vx "$cs" || true) - done - else - accepted_in_specs="$accepted_caps" - fi - - local accepted_json - accepted_json=$(printf '%s\n' "$accepted_in_specs" | awk 'NF' | jq -R . | jq -s .) - - local dest_config="$root/openspec/.audit-config.json" - mkdir -p "$root/openspec" - - if ! jq --argjson accepted "$accepted_json" ' - def to_slug: - sub(".*/"; "") - | sub("\\.md$"; "") - | ascii_downcase - | gsub("[^a-z0-9]+"; "-") - | sub("^-+"; "") - | sub("-+$"; ""); - (.modules // []) |= map( - .specs |= ( - (. // []) - | map(to_slug) - | map(select(. as $s | $accepted | any(. == $s))) - | unique - ) - ) - | .mapping_cache = {} - ' "$src_config" > "$dest_config.tmp"; then - rm -f "$dest_config.tmp" - err "Phase 4: failed to translate $spec_dir/.audit-config.json; skipping (config not migrated)" - return 0 - fi - mv "$dest_config.tmp" "$dest_config" - - printf 'Phase 4 (audit config): translated %s/.audit-config.json -> openspec/.audit-config.json\n' "$spec_dir" -} - _execute() { local root="$1" @@ -1323,46 +1088,6 @@ _execute() { accepted_caps=$(jq -r 'to_entries | map(select(.value=="accept")) | .[].key' "$res_path") skipped_caps=$(jq -r 'to_entries | map(select(.value=="skip")) | .[].key' "$res_path") - # --- Read change-candidate routing from inventory. - # Build parallel arrays indexed by slug for cheap O(N) lookup. The count is - # tiny (typically < 5) so a linear scan is fine. - local change_cap_slugs=() change_cap_names=() change_cap_specs=() change_cap_plans=() - local cb cn cap_slug spec_rel plan_rel - while IFS=$'\t' read -r cb cn; do - [[ -n "$cb" ]] || continue - cap_slug=$(slugify "$cb") - spec_rel=$(jq -r --arg b "${cb}.md" '.base[]? | select(((.|sub(".*/"; ""))==$b))' "$inv_path" | head -1) - plan_rel=$(jq -r --arg n "${cn}.md" '.plans[]? | select(((.|sub(".*/"; ""))==$n))' "$inv_path" | head -1) - if [[ -z "$spec_rel" ]]; then - err "Phase 4: change candidate '$cb' not found in inventory.base; skipping" - continue - fi - if [[ -z "$plan_rel" ]]; then - err "Phase 4: change candidate '$cb': plan '$cn' not found in inventory.plans; skipping" - continue - fi - change_cap_slugs+=("$cap_slug") - change_cap_names+=("$cn") - change_cap_specs+=("$spec_rel") - change_cap_plans+=("$plan_rel") - done < <(jq -r '(.change_candidates // {}) | to_entries[]? | "\(.key)\t\(.value)"' "$inv_path") - - is_change_cap() { - local needle="$1" i - for i in "${change_cap_slugs[@]:-}"; do - [[ "$i" == "$needle" ]] && return 0 - done - return 1 - } - - get_change_cap_index() { - local needle="$1" i - for i in "${!change_cap_slugs[@]}"; do - [[ "${change_cap_slugs[$i]}" == "$needle" ]] && { printf '%s' "$i"; return 0; } - done - return 1 - } - # --- Initialize OpenSpec dir. if [[ ! -d "$root/openspec" ]]; then (cd "$root" && openspec init --tools none . >/dev/null 2>&1) || true @@ -1385,25 +1110,6 @@ _execute() { fi done <<< "$accepted_caps" - # --- 1b. Convert accepted change-candidate translations into change folders. - # Reads each translation that Phase 2 wrote to openspec/specs/<cap>/spec.md, - # rewrites it as a delta + proposal + tasks under openspec/changes/<name>/, - # then deletes the openspec/specs/<cap>/ landing. - local cap_idx cn sr pr cb_for_cap - while IFS= read -r cap; do - [[ -n "$cap" ]] || continue - if is_change_cap "$cap"; then - cap_idx=$(get_change_cap_index "$cap") || continue - cn="${change_cap_names[$cap_idx]}" - sr="${change_cap_specs[$cap_idx]}" - pr="${change_cap_plans[$cap_idx]}" - cb_for_cap="${sr##*/}" - cb_for_cap="${cb_for_cap%.md}" - _install_change_folder "$root" "$cb_for_cap" "$cn" "$cap" "$sr" "$pr" \ - || err "Phase 4: _install_change_folder failed for '$cap'" - fi - done <<< "$accepted_caps" - # --- 2. Archive each source spec with banner; build basename->capability map. mkdir -p "$root/.workflow/legacy-specs" local spec_dir @@ -1424,16 +1130,7 @@ _execute() { local banner_caps="$cap_for_src" local banner="" if grep -qx "$cap_for_src" <<< "$accepted_caps"; then - if is_change_cap "$cap_for_src"; then - local _cn_idx cn_for_cap - _cn_idx=$(get_change_cap_index "$cap_for_src") || true - cn_for_cap="${change_cap_names[$_cn_idx]:-}" - banner=$(printf '# [Legacy] %s\n\n' "$title" - printf '> Translated to in-flight OpenSpec change `%s`.\n>\n' "$cn_for_cap" - printf '> Source preserved for reference. Behavior changes belong in `openspec/changes/%s/specs/<capability>/spec.md` until the change is archived.\n\n' "$cn_for_cap") - else - banner=$(_render_banner "$title" "$banner_caps") - fi + banner=$(_render_banner "$title" "$banner_caps") elif grep -qx "$cap_for_src" <<< "$skipped_caps"; then banner=$(printf '# [Legacy] %s\n\n' "$title" printf '> Source preserved as-is — translation was skipped during migration.\n\n') @@ -1461,16 +1158,6 @@ _execute() { fi done - # --- 3b. Translate legacy spec-audit config (best-effort, no-op if absent). - # Must run before step 5's spec-dir removal so the source is still readable. - # Pass change-candidate slugs so the helper can drop them from modules' - # specs arrays (those caps live at openspec/changes/, not openspec/specs/). - local _ccs_csv="" - if [[ ${#change_cap_slugs[@]} -gt 0 ]]; then - _ccs_csv=$(IFS=,; printf '%s' "${change_cap_slugs[*]}") - fi - _translate_audit_config "$root" "$accepted_caps" "$_ccs_csv" - # --- 4. Preserve original .specs. if [[ -f "$root/.specs" ]]; then cp "$root/.specs" "$root/.workflow/legacy-specs/.specs" diff --git a/skills/migrate-to-openspec/test/fixture-legacy-project/specs/.audit-config.json b/skills/migrate-to-openspec/test/fixture-legacy-project/specs/.audit-config.json deleted file mode 100644 index 4a3bb37..0000000 --- a/skills/migrate-to-openspec/test/fixture-legacy-project/specs/.audit-config.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "version": 1, - "modules": [ - { - "name": "core", - "paths": ["src/"], - "specs": [ - "feature-a.md", - "specs/feature-c-mixed.md", - "feature-d-unbuilt.md" - ] - }, - { - "name": "cli", - "paths": ["cmd/"], - "specs": [ - "feature-b-cli.md", - "feature-removed.md" - ] - } - ], - "extensions": ["go", "ts"], - "excludes": [ - "**/node_modules/**", - "**/vendor/**" - ], - "pitfalls": [ - "Fixture pitfall: feature-a and feature-b share state via the shared/ package — flag cross-module references with care.", - "Fixture pitfall: feature-c-mixed historically lived under cli/ before being moved to src/." - ], - "test_suites": [ - { - "dir": "src", - "command": "go test ./..." - } - ], - "mapping_cache": { - "feature-a.md": { - "sha": "deadbeefcafef00d", - "modules": ["core"] - } - } -} diff --git a/skills/migrate-to-openspec/test/fixture-legacy-project/specs/feature-d-unbuilt.md b/skills/migrate-to-openspec/test/fixture-legacy-project/specs/feature-d-unbuilt.md deleted file mode 100644 index e1ae2c1..0000000 --- a/skills/migrate-to-openspec/test/fixture-legacy-project/specs/feature-d-unbuilt.md +++ /dev/null @@ -1,33 +0,0 @@ -# Feature D — unbuilt change candidate - -## Purpose - -Feature D describes future behavior that has not been implemented. It exists as a legacy spec alongside a matching plan under `specs/plans/`, and the migration tool must route it to `openspec/changes/<change-name>/` rather than `openspec/specs/<capability>/spec.md`. - -## Requirements - -### Requirement: Daily digest delivery - -The system SHALL deliver one digest email per recipient per day at the recipient's configured local-time slot. The digest MUST include every event published to that recipient's subscription channels during the prior 24 hours. - -#### Scenario: digest delivered at the configured time - -- **GIVEN** a recipient configured for 09:00 local digest delivery -- **WHEN** the daily scheduler ticks past 09:00 in the recipient's timezone -- **THEN** the digest email is queued for delivery within 60 seconds - -#### Scenario: digest skipped on quiet days - -- **GIVEN** a recipient with no events in the prior 24-hour window -- **WHEN** the daily scheduler ticks past their delivery slot -- **THEN** no email is queued and a "no-events" log line is written - -### Requirement: Recipient digest preferences - -The system SHALL expose a settings endpoint that lets recipients configure their delivery slot (one of 06:00, 09:00, 12:00, 18:00 local time) and toggle the digest on or off entirely. Updates MUST take effect on the next scheduler tick. - -#### Scenario: recipient changes delivery slot - -- **GIVEN** a recipient with delivery slot set to 09:00 -- **WHEN** the recipient submits a settings update changing the slot to 18:00 -- **THEN** subsequent digests are queued for the 18:00 slot in their timezone diff --git a/skills/migrate-to-openspec/test/fixture-legacy-project/specs/plans/v1.0-feature-d-unbuilt.md b/skills/migrate-to-openspec/test/fixture-legacy-project/specs/plans/v1.0-feature-d-unbuilt.md deleted file mode 100644 index e4c3239..0000000 --- a/skills/migrate-to-openspec/test/fixture-legacy-project/specs/plans/v1.0-feature-d-unbuilt.md +++ /dev/null @@ -1,27 +0,0 @@ -# Plan: Feature D digest delivery (v1.0) - -## Context - -This plan describes how to build the daily digest feature described in `specs/feature-d-unbuilt.md`. It's the matching plan for the migration tool's change-candidate flow. - -## Approach - -Build the scheduler, recipient preferences endpoint, and digest renderer in three independently-deployable phases. Each phase is gated by an integration test against a recipient fixture. - -## Implementation order - -### Phase 1: Recipient preferences model - -Add the `RecipientDigestPreference` model with columns for delivery slot, timezone, and active flag. Add a migration. Wire up the settings endpoint. - -### Phase 2: Daily scheduler - -Build the scheduler job that wakes once per minute, checks for recipients whose local-time slot just rolled over, and enqueues digest jobs. Include the no-events skip logic. - -### Phase 3: Digest renderer - -Render the digest email template. Pull events from the prior 24 hours per recipient. Send via the existing transactional mailer. - -## Verification - -Each phase ships its own integration test. The phase-3 test exercises end-to-end delivery against a fixture recipient. diff --git a/skills/migrate-to-openspec/test/fixtures-golden/feature-d-unbuilt.md b/skills/migrate-to-openspec/test/fixtures-golden/feature-d-unbuilt.md deleted file mode 100644 index e1ae2c1..0000000 --- a/skills/migrate-to-openspec/test/fixtures-golden/feature-d-unbuilt.md +++ /dev/null @@ -1,33 +0,0 @@ -# Feature D — unbuilt change candidate - -## Purpose - -Feature D describes future behavior that has not been implemented. It exists as a legacy spec alongside a matching plan under `specs/plans/`, and the migration tool must route it to `openspec/changes/<change-name>/` rather than `openspec/specs/<capability>/spec.md`. - -## Requirements - -### Requirement: Daily digest delivery - -The system SHALL deliver one digest email per recipient per day at the recipient's configured local-time slot. The digest MUST include every event published to that recipient's subscription channels during the prior 24 hours. - -#### Scenario: digest delivered at the configured time - -- **GIVEN** a recipient configured for 09:00 local digest delivery -- **WHEN** the daily scheduler ticks past 09:00 in the recipient's timezone -- **THEN** the digest email is queued for delivery within 60 seconds - -#### Scenario: digest skipped on quiet days - -- **GIVEN** a recipient with no events in the prior 24-hour window -- **WHEN** the daily scheduler ticks past their delivery slot -- **THEN** no email is queued and a "no-events" log line is written - -### Requirement: Recipient digest preferences - -The system SHALL expose a settings endpoint that lets recipients configure their delivery slot (one of 06:00, 09:00, 12:00, 18:00 local time) and toggle the digest on or off entirely. Updates MUST take effect on the next scheduler tick. - -#### Scenario: recipient changes delivery slot - -- **GIVEN** a recipient with delivery slot set to 09:00 -- **WHEN** the recipient submits a settings update changing the slot to 18:00 -- **THEN** subsequent digests are queued for the 18:00 slot in their timezone diff --git a/skills/migrate-to-openspec/test/run-tests.sh b/skills/migrate-to-openspec/test/run-tests.sh index b7c7b83..c6f6c45 100755 --- a/skills/migrate-to-openspec/test/run-tests.sh +++ b/skills/migrate-to-openspec/test/run-tests.sh @@ -8,7 +8,7 @@ # # Usage: # ./run-tests.sh # run all suites -# ./run-tests.sh --suite preflight # run a single suite (preflight|inventory|translator|verifier|execution|idempotence|templates|hook|audit-config) +# ./run-tests.sh --suite preflight # run a single suite (preflight|inventory|translator|verifier|execution|idempotence|templates|hook) # # Exit code: 0 if all tests pass, 1 otherwise. @@ -528,235 +528,6 @@ assert_hook_installed() { fi } -# --------------------------------------------------------------------------- -# Audit-config translation -# --------------------------------------------------------------------------- - -assert_audit_config_translated() { - local dir - dir="$(setup_fixture audit-config-translated)" - - if ! run_migrate "$dir" run --auto-accept >/dev/null 2>&1; then - echo "full run failed; cannot assert audit config translation" - return 1 - fi - - local out="$dir/openspec/.audit-config.json" - if [[ ! -f "$out" ]]; then - echo "expected translated audit config at $out" - return 1 - fi - - python3 - "$out" <<'PY' || return 1 -import json, sys -cfg = json.load(open(sys.argv[1])) - -errors = [] - -# Modules: every spec entry must be a slug (no .md, no path), and only known -# capabilities should remain. The fixture lists feature-a.md, feature-b-cli.md, -# feature-c-mixed.md (real), feature-removed.md (orphan, must be dropped), -# and specs/feature-c-mixed.md (path-prefixed, must be normalized). -modules = {m["name"]: m for m in cfg.get("modules", [])} -core = modules.get("core", {}) -cli = modules.get("cli", {}) - -if not core or not cli: - errors.append(f"expected modules 'core' and 'cli'; got {list(modules)}") - -core_specs = sorted(core.get("specs", [])) -# Without --change flag, feature-d-unbuilt is a regular accepted cap and stays in the specs array. -if core_specs != ["feature-a", "feature-c-mixed", "feature-d-unbuilt"]: - errors.append(f"core.specs = {core_specs}, expected ['feature-a', 'feature-c-mixed', 'feature-d-unbuilt']") - -cli_specs = sorted(cli.get("specs", [])) -if cli_specs != ["feature-b-cli"]: - errors.append(f"cli.specs = {cli_specs}, expected ['feature-b-cli'] (feature-removed should drop)") - -# mapping_cache must be reset. -if cfg.get("mapping_cache") != {}: - errors.append(f"mapping_cache = {cfg.get('mapping_cache')}, expected {{}}") - -# pitfalls preserved verbatim. -pitfalls = cfg.get("pitfalls", []) -if len(pitfalls) != 2 or not all("Fixture pitfall" in p for p in pitfalls): - errors.append(f"pitfalls not preserved verbatim: {pitfalls}") - -# extensions/excludes preserved. -if cfg.get("extensions") != ["go", "ts"]: - errors.append(f"extensions changed: {cfg.get('extensions')}") -if cfg.get("excludes") != ["**/node_modules/**", "**/vendor/**"]: - errors.append(f"excludes changed: {cfg.get('excludes')}") - -# test_suites preserved. -ts = cfg.get("test_suites", []) -if ts != [{"dir": "src", "command": "go test ./..."}]: - errors.append(f"test_suites changed: {ts}") - -# version preserved. -if cfg.get("version") != 1: - errors.append(f"version changed: {cfg.get('version')}") - -if errors: - for e in errors: - print(e, file=sys.stderr) - sys.exit(1) -PY - - # Source must be gone (cleaned up by spec-dir removal). - if [[ -f "$dir/specs/.audit-config.json" ]]; then - echo "expected source $dir/specs/.audit-config.json to be removed" - return 1 - fi -} - -assert_audit_config_missing_is_noop() { - local dir - dir="$(setup_fixture audit-config-noop)" - - # Remove the fixture's audit config to exercise the no-op path. - rm -f "$dir/specs/.audit-config.json" - (cd "$dir" && git add -A && git commit -q -m "remove audit config") >/dev/null - - if ! run_migrate "$dir" run --auto-accept >/dev/null 2>&1; then - echo "full run failed on a project without audit config" - return 1 - fi - - if [[ -f "$dir/openspec/.audit-config.json" ]]; then - echo "openspec/.audit-config.json should not exist when no source config was present" - return 1 - fi -} - -# --------------------------------------------------------------------------- -# Change-candidate routing (--change flag) -# --------------------------------------------------------------------------- - -assert_change_candidate_creates_change_folder() { - local dir - dir="$(setup_fixture change-candidate-folder)" - - local out rc - out=$(run_migrate "$dir" run --auto-accept --change feature-d-unbuilt 2>&1) - rc=$? - if [[ $rc -ne 0 ]]; then - echo "full run exited non-zero (rc=$rc): $out" - return 1 - fi - - local fail=0 - local change_dir="$dir/openspec/changes/v1.0-feature-d-unbuilt" - - for f in proposal.md tasks.md specs/feature-d-unbuilt/spec.md; do - if [[ ! -f "$change_dir/$f" ]]; then - echo "missing $change_dir/$f" - fail=1 - fi - done - - # Delta must start with `## ADDED Requirements` header. - local delta="$change_dir/specs/feature-d-unbuilt/spec.md" - if [[ -f "$delta" ]]; then - local first - first=$(grep -m1 -E '^##' "$delta") - if [[ "$first" != "## ADDED Requirements" ]]; then - echo "expected delta first ## header to be '## ADDED Requirements'; got '$first'" - fail=1 - fi - if grep -q "^# Capability:" "$delta" || grep -q "^## Purpose" "$delta"; then - echo "delta must not contain '# Capability:' or '## Purpose' headers" - fail=1 - fi - # Each requirement should still have at least one Scenario. - local req_count scenario_count - req_count=$(grep -cE '^### Requirement:' "$delta") - scenario_count=$(grep -cE '^#### Scenario:' "$delta") - if [[ $scenario_count -lt $req_count ]]; then - echo "delta has $req_count requirements but only $scenario_count scenarios" - fail=1 - fi - fi - - # tasks.md should contain at least one Phase line from the plan. - if [[ -f "$change_dir/tasks.md" ]]; then - if ! grep -qE 'Phase 1:' "$change_dir/tasks.md"; then - echo "tasks.md should reference 'Phase 1:' from the plan" - fail=1 - fi - fi - - # proposal.md should mention the capability under New Capabilities. - if [[ -f "$change_dir/proposal.md" ]]; then - if ! grep -q "feature-d-unbuilt" "$change_dir/proposal.md"; then - echo "proposal.md should mention 'feature-d-unbuilt'" - fail=1 - fi - fi - - # The plain openspec/specs/<cap>/ landing must NOT exist for change candidates. - if [[ -e "$dir/openspec/specs/feature-d-unbuilt" ]]; then - echo "openspec/specs/feature-d-unbuilt should not exist for change candidates" - fail=1 - fi - - # The translated audit config must NOT reference change candidates in its - # specs arrays (those caps live at openspec/changes/, not openspec/specs/). - if [[ -f "$dir/openspec/.audit-config.json" ]]; then - local has_ref - has_ref=$(jq -r '.modules[].specs[]' "$dir/openspec/.audit-config.json" | grep -cx "feature-d-unbuilt" || true) - if [[ "$has_ref" != "0" ]]; then - echo "openspec/.audit-config.json modules reference 'feature-d-unbuilt' but that capability is at openspec/changes/, not openspec/specs/" - fail=1 - fi - fi - - # Source archive should have the change-specific banner. - local archive="$dir/.workflow/legacy-specs/feature-d-unbuilt.md" - if [[ -f "$archive" ]]; then - if ! head -n 5 "$archive" | grep -qF "in-flight OpenSpec change"; then - echo "$archive: expected change-specific banner; first 5 lines:" - head -n 5 "$archive" - fail=1 - fi - else - echo "missing archive at $archive" - fail=1 - fi - - # OpenSpec validation should accept the change. - if ! (cd "$dir" && OPENSPEC_TELEMETRY=0 openspec validate v1.0-feature-d-unbuilt --strict >/dev/null 2>&1); then - echo "openspec validate v1.0-feature-d-unbuilt --strict failed" - fail=1 - fi - - return $fail -} - -assert_change_flag_with_missing_plan_fails() { - local dir - dir="$(setup_fixture change-missing-plan)" - - local out rc - out=$(run_migrate "$dir" run --auto-accept --change ghost 2>&1) - rc=$? - if [[ $rc -eq 0 ]]; then - echo "expected non-zero exit when --change refers to a spec with no matching plan" - echo "output: $out" - return 1 - fi - if ! grep -qE "no matching plan|ghost" <<<"$out"; then - echo "expected error mentioning the missing plan / ghost spec; got:" - echo "$out" - return 1 - fi - # Inventory should NOT exist because we fail before writing it. - if [[ -f "$dir/migration-inventory.json" ]]; then - echo "expected migration-inventory.json to NOT exist after fail-fast" - return 1 - fi -} - # --------------------------------------------------------------------------- # Sanity: migrate.sh exists and is executable (runs even if all suites filtered) # --------------------------------------------------------------------------- @@ -829,18 +600,6 @@ if ! skip_if_filtered; then test_case "Phase 4 installs scripts/spec-check-hook.sh as executable" assert_hook_installed fi -suite audit-config -if ! skip_if_filtered; then - test_case "Phase 4 translates legacy <spec-dir>/.audit-config.json to openspec/.audit-config.json" assert_audit_config_translated - test_case "Phase 4 is a no-op when no <spec-dir>/.audit-config.json exists" assert_audit_config_missing_is_noop -fi - -suite change-candidate -if ! skip_if_filtered; then - test_case "--change flag routes spec to openspec/changes/<name>/ with proposal+tasks+delta" assert_change_candidate_creates_change_folder - test_case "--change flag with no matching plan fails fast before any work" assert_change_flag_with_missing_plan_fails -fi - # --------------------------------------------------------------------------- # Summary # --------------------------------------------------------------------------- diff --git a/skills/plannotator-specs/SKILL.md b/skills/plannotator-specs/SKILL.md index 0d4050d..ca7b9f1 100644 --- a/skills/plannotator-specs/SKILL.md +++ b/skills/plannotator-specs/SKILL.md @@ -2,6 +2,7 @@ name: plannotator-specs description: Interactive spec review via Plannotator. Use after writing a spec document (design doc, SPEC file) to let the user review it with inline annotations before committing. Auto-detects the most recently written spec file. allowed-tools: Bash(plannotator:*) +tags: [plannotator] --- # Spec Review via Plannotator diff --git a/skills/pr-dashboard/SKILL.md b/skills/pr-dashboard/SKILL.md index 254da60..18c0737 100644 --- a/skills/pr-dashboard/SKILL.md +++ b/skills/pr-dashboard/SKILL.md @@ -3,6 +3,7 @@ name: pr-dashboard description: Use when the user asks about PR status, open PRs, review requests, or wants a PR overview — shows open PRs, review requests, and recently closed PRs with age and status allowed-tools: Bash user-invocable: true +tags: [pr] --- # PR Dashboard diff --git a/skills/pr-respond/SKILL.md b/skills/pr-respond/SKILL.md index 353541d..3bc1872 100644 --- a/skills/pr-respond/SKILL.md +++ b/skills/pr-respond/SKILL.md @@ -1,6 +1,7 @@ --- name: pr-respond description: Read PR review feedback, triage each comment (adopt/reject with reasoning), optionally apply changes and commit. Writes artifacts to ~/.claude/pr-responses/. Use when a PR has received review comments that need to be addressed. +tags: [pr] --- # PR Respond diff --git a/skills/pr/SKILL.md b/skills/pr/SKILL.md index 1c26883..8af4286 100644 --- a/skills/pr/SKILL.md +++ b/skills/pr/SKILL.md @@ -1,6 +1,7 @@ --- name: pr description: Use when code is ready to ship — opens a PR, waits for CI to pass, fixes failures, addresses review comments, and loops until fully green +tags: [pr] --- ## Context diff --git a/skills/promote/SKILL.md b/skills/promote/SKILL.md index cedb85b..ec21bc9 100644 --- a/skills/promote/SKILL.md +++ b/skills/promote/SKILL.md @@ -1,6 +1,7 @@ --- name: promote description: Use when checking which project skills should be available globally — finds skills not yet promoted and recommends which to symlink to ~/.claude/skills/ +tags: [personal] --- # Skill Promotion Audit diff --git a/skills/ralph-review/SKILL.md b/skills/ralph-review/SKILL.md index efa91c2..af11549 100644 --- a/skills/ralph-review/SKILL.md +++ b/skills/ralph-review/SKILL.md @@ -1,6 +1,7 @@ --- name: ralph-review description: "Use after implementing changes in an OpenSpec project to review implementation against the active change's deltas — auto-fixes confident issues, parks questions for the user" +tags: [quality] --- # Ralph review (OpenSpec) @@ -356,24 +357,15 @@ For each [AUTO-FIX], include: - Design concern not addressed by deltas or `tasks.md` - Behavioral change that looks intentional but has no delta coverage -If your `~/.claude/CLAUDE.md` (or any project-level CLAUDE.md) defines user-facing framing instructions for choices and findings, apply them. The default question structure below works on its own; user-supplied framing extends or overrides it. For each [QUESTION], include: - +For each [QUESTION], include: - File and line number (for the main thread's reference) -- **Why this matters** – what part of the change this touches (a user flow, a security boundary, a performance hotpath, an external contract) and what depends on the answer -- **What's happening** – plain language summary -- **What could go wrong** – consequence in system behavior, not code mechanics -- **Tradeoffs** – gain vs. lose with each option -- **Recommendation** – take a position -- **Inevitability** – a neutral fact-label about the underlying work, set independently of the recommendation. Use one of: - - **Inevitable** – this work will need to happen eventually regardless of what's chosen now. Deferring just postpones it. "If it's worth doing at all, it's worth doing it right the first time" applies – a simpler-now / complex-later split usually means paying the cost twice. Name the bad outcome that arrives if it's never done. - - **Order-dependent** – better done after something else happens (another change lands, scope solidifies, more data is available). Deferral is genuine. Name what has to happen first. - - **Avoidable** – may never need to happen; depends on usage patterns that may or may not emerge. Name the condition under which this stays unneeded. - - Record this even when recommending defer. Do not let the label adjust the recommendation – the point is to surface the tradeoff, not bias it. -- **Options** – recommendation first, then alternatives -- **Technical details** – brief 1-3 line summary of concrete artifacts (file, function or type, key fields, relevant delta requirement) - -The code was likely written by Claude, not the user. Reference deltas and desired behavior, not implementation details. +- **What's happening** — plain language summary accessible to someone who didn't write the code. Lead with the observable behavior, not the implementation. e.g., "the UI freezes briefly during X" not "functionA() calls sleep() on the main thread." +- **What could go wrong** — the consequence in terms of system behavior, not code mechanics. e.g., "users could see stale data" not "the backing array could be shared." +- **Tradeoffs** — what you gain and what you lose with each option. Every design choice has a reason it exists; surface that. e.g., "removing the delay makes the operation feel snappier but risks the previous process not fully completing before the new one starts." +- **Recommendation** — what you think should be done and why, informed by the tradeoffs. Take a position. +- **Options** — put the recommendation first, then alternatives + +Frame questions for a user who may not know the codebase. The code was likely written by Claude, not the user. Explain the *so what*, not the *how*. Reference deltas and desired behavior, not implementation details. **[SPEC-DRIFT]** — Behavioral code without delta coverage (spec tier only): - New behavior with no delta mention @@ -519,19 +511,22 @@ IF iteration == max_iterations AND auto_fixes were made in last loop: ### Post-report resolution -When the user chooses to address spec drift (see Phase 3), use the same dispatch-as-you-go pattern as Option 2 (Questions for you). +When the user chooses to address spec drift (see Phase 3): -For each drift item, one at a time, present via `AskUserQuestion` with the draft delta recommendation. Ask the user to approve, edit, or reject. +Present each drift item one at a time via `AskUserQuestion`. For each item, show the draft recommendation and ask the user to approve, edit, or reject. -After each answer, act on it immediately, then move to the next item: +Same background dispatch pattern as Questions — handle the user's response and present the next drift item in the same response. -1. **Approve / edit:** write the delta to `openspec/changes/<active>/specs/<capability>/spec.md` inline (use `/spec-recommender` → `/spec-writer` if available, otherwise edit directly). Prefer `## ADDED Requirements` for new behavior; copy the full requirement block when using `## MODIFIED Requirements`. Run `openspec validate <active> --type change --strict`. If validation fails, surface the error and revert the offending edit. -2. **If the drift item also needs a code change to match the new delta:** dispatch a background fix agent, subject to the conflict-avoidance check in Option 2 (queue if overlapping with in-flight fixes). -3. **Reject:** record and move on. +After the user responds to each item: -Print one short status line after each answer ("Delta updated and code fix dispatched." / "Delta updated.") and immediately present the next item. Do not block on background fixes. +1. **Update the delta inline** – delta edits are fast (just markdown), so do them in the main thread. Use `/spec-recommender` → `/spec-writer` if available, otherwise edit `openspec/changes/<active>/specs/<capability>/spec.md` directly. Prefer `## ADDED Requirements` for new behavior; copy the full requirement block when using `## MODIFIED Requirements`. +2. **Validate the change** – run `openspec validate <active> --type change --strict` after each delta edit. Fail loudly if the edit broke validation; revert if needed. +3. **If code changes are needed** to match the updated delta, dispatch a background fix agent using the same process described in Phase 3 ("Dispatching a background fix agent"). Print "Fix dispatched in background. Moving to the next drift item." +4. **Show the next drift item immediately** in the same response — don't wait for the fix to complete. -After all drift items are processed, offer: "Deltas updated. Restart ralph-review to validate against updated deltas? (y/n)" +Apply the same conflict avoidance rule as questions: if two fixes would touch the same files, serialize them. + +After all drift items are addressed and any in-flight fixes complete, offer: "Deltas updated. Restart ralph-review to validate against updated deltas? (y/n)" --- @@ -622,34 +617,22 @@ See "Spec drift handling → Post-report resolution" above. ### Option 2: Questions for you -**Dispatch as you go.** Each answer kicks off its work immediately — the user keeps answering the next question while the previous one's fix runs in the background. Don't batch; the user is in the thread, not waiting on a queue. - -Print one line up front: - -``` -Walking through {N} questions. As you answer each one I'll dispatch the work in the background and move to the next question. -``` - -#### Per-question flow - -For each finding, in order, present via `AskUserQuestion`. If your CLAUDE.md defines user-facing framing instructions, apply them; otherwise use the template below. Template: +Present each finding one at a time via `AskUserQuestion`. Frame every question for someone who didn't write the code — the user likely directed Claude to build it, not hand-authored it. Lead with the situation and consequence, not implementation details. ``` Question {N} of {total}: {short descriptive title} -{Why this matters — 1-2 sentences situating the question.} - -{What's happening — plain language, 2-3 sentences. No jargon, no line numbers.} +{What's happening — plain language, 2-3 sentences. No jargon, no line numbers +in the narrative. Explain the situation as you would to a product owner.} -{What could go wrong — consequence in terms of system behavior.} +{What could go wrong — the consequence in terms of system behavior. +e.g., "users could see stale data" not "the goroutine reads a shared pointer."} -{Tradeoffs — gain vs. lose; why the current behavior might exist.} +{Tradeoffs — what you gain vs. what you lose with each option. Surface why +the current behavior might exist before recommending a change.} -Recommendation: {Position + reasoning.} - -Inevitability: {Inevitable | Order-dependent | Avoidable} — {one-line justification. Inevitable → name the bad outcome that arrives if this is never done. Order-dependent → name what has to happen first. Avoidable → name the condition under which this stays unneeded.} - -Technical details: {1-3 lines naming concrete artifacts.} +Recommendation: {What Ralph thinks should be done and why, informed by +the tradeoffs. Take a position.} 1. Accept recommendation — {what that means concretely} 2. Ignore — mark as expected with an inline comment so future reviews skip it @@ -657,39 +640,18 @@ Technical details: {1-3 lines naming concrete artifacts.} 4. Defer — park this for a future session ``` -After the user answers, act on the decision immediately, then present the next question. The user does not wait between questions for any fix to finish. - -- **Accept recommendation (code fix):** - - **Trivial** (one-liner, lint, formatting, single-file rename): apply inline in the main thread, commit, move on. - - **Substantial fix:** check file overlap against in-flight background fixes. If files are disjoint → dispatch a background fix agent in a worktree (see "Dispatching a background fix agent"). If files overlap with an in-flight fix → queue it; the queue drains as conflicting fixes complete. -- **Add to delta:** edit `openspec/changes/<active>/specs/<capability>/spec.md` inline, run `openspec validate <active> --type change --strict`. If the delta change also requires code changes, dispatch a fix agent (subject to the same overlap check). -- **Ignore:** apply the pre-drafted `// expected:` comment inline, commit. -- **Defer:** write a todo marker to `.workflow/todo/` (see "Todo markers" below), commit. +After the user answers each item, dispatch any work in the background and present the next question in the same response. Every response that handles a user answer contains both: -Print one short status line after each answer ("Dispatched fix for {N} in background." / "Delta updated." / "Annotation applied." / "Deferred to todo.") and immediately present the next question. Do not block on background fixes. +1. The background dispatch (Agent tool call with `run_in_background: true`) +2. The next `AskUserQuestion` for the next item -#### Conflict avoidance +**Dispatching by answer type:** -Maintain a running set of files touched by in-flight background fixes. Before dispatching a new fix: - -- If its files are disjoint from every in-flight fix → dispatch in parallel. -- If its files overlap with any in-flight fix → queue it. Drain the queue when the conflicting fix completes. - -This is per-question, not per-batch — most pairs of fixes are independent, so most dispatch immediately. - -#### After the last question - -Print a summary: - -``` -{N} answers processed: -- {X} background fixes dispatched ({Y} still running, {Z} queued behind file conflicts) -- {A} delta updates applied -- {B} ignore annotations applied -- {C} deferred to todo -``` - -Return control to the user immediately. Report each background fix's result as it completes; drain the conflict queue as in-flight fixes finish. +- **Fix code** → Dispatch a background fix agent following fixit's process (see dispatch template below). Print "Fix dispatched in background. Moving to the next item." then immediately show the next question. +- **Update delta** → Edit `openspec/changes/<active>/specs/<capability>/spec.md` inline in the main thread (just markdown, fast). Run `openspec validate <active> --type change --strict` after the edit. If code changes are also needed to match the updated delta, dispatch a background fix agent. Move to next item immediately. +- **Ignore** → Offer to add an `// expected:` comment to the relevant line(s) so future reviews don't re-report the same finding. Show the proposed annotation for approval (e.g., `// expected: broadcastMessage ignores handled bool`). If the user approves, add the comment and commit it. If they decline, skip silently. Either way, move to next finding. +- **Add to delta** → Same as "update delta" — capture the intentional behavior in the active change's deltas, then dispatch a background fix agent if code changes are needed. Move to the next question immediately. +- **Defer** → Create a todo marker in `.workflow/todo/` (see "Todo markers" below). Move to the next question immediately. ### Dispatching a background fix agent @@ -784,11 +746,11 @@ Implementation follows agent-driven-development pattern for a single task. Read **Step 4: Print one line and move on** — do not wait for the agent. -**On agent completion:** Follow the same completion flow as `/fixit` — two-stage review (spec reviewer → code quality reviewer), merge to original branch, worktree cleanup. Report result to user when done. If queued fixes were waiting on this one's files, dispatch the next one in the queue now. +**On agent completion:** Follow the same completion flow as `/fixit` — two-stage review (spec reviewer → code quality reviewer), merge to original branch, worktree cleanup. Report result to user when done. -**Conflict avoidance:** Concurrent worktrees editing the same files will cause merge conflicts. Track the files touched by each in-flight fix; dispatch only when a new fix's files are disjoint from every in-flight fix, otherwise queue. +**Conflict avoidance:** Before dispatching, check if a previously dispatched fix from this session touches the same file(s). If so, wait for that fix to complete before dispatching the new one — concurrent worktrees editing the same files will cause merge conflicts. Independent files can run in parallel. -**After the user finishes answering questions:** Background fixes continue running. Return control to the user immediately — don't block. Report each fix's result as it completes. Once all in-flight and queued fixes are done, re-present the remaining review options. +**After all items are answered:** Wait for any in-flight fixes to complete and report their results. Then re-present the remaining review options. ### Option 3: Skipped findings @@ -821,27 +783,13 @@ How would you like to review ralph's changes? ### Option 5: Done -Accept everything and exit. - -**Close the gate.** If there's still an active OpenSpec change in `openspec/changes/`, offer to archive it via `AskUserQuestion`: - -> "Archive `<change-name>` now? Merges deltas into base specs and removes the change folder." -> -> - **Archive** (recommended if review is final) — runs `openspec archive <name> --yes` then `openspec validate --all --strict`. -> - **Keep active** — leave the change in flight; archive later via `/save-w-specs` or `openspec archive`. - -If invoked from `/execute-plan`'s quality gate, the user already opted to let ralph close the gate — Archive is the expected choice. For standalone ralph invocations, the user may legitimately want to keep iterating. Don't auto-archive. - -If archive fails (e.g. validation errors when merging deltas), surface the error and stop. The user resolves and re-runs `openspec archive` manually. Skip this step entirely in conservative mode (no active change to begin with). - -Print: +Accept everything and exit. Print: ``` Ralph-review complete. - {N} issues auto-fixed across {N} loops - {N} questions parked for you - {N} spec drift items {resolved | pending} -- {Change archived: <name> | Change kept active: <name> | (no active change)} - Report saved to: .claude/reviews/YYYY-MM-DD/ralph-review-report.md ``` diff --git a/skills/rereview/SKILL.md b/skills/rereview/SKILL.md index 4270cff..bbd0c1d 100644 --- a/skills/rereview/SKILL.md +++ b/skills/rereview/SKILL.md @@ -1,6 +1,7 @@ --- name: rereview description: "Use when a previous review missed something or the user wants a thorough second pass — re-review with fresh eyes, zero regressions, go slow and analyze everything" +tags: [quality] --- # Fresh-Eyes Re-Review diff --git a/skills/review/SKILL.md b/skills/review/SKILL.md index e4eaa5f..272b202 100644 --- a/skills/review/SKILL.md +++ b/skills/review/SKILL.md @@ -1,6 +1,7 @@ --- name: review description: Use when the user asks to review code, review current changes, or review a PR number +tags: [quality] --- # Code Review diff --git a/skills/save-w-specs/SKILL.md b/skills/save-w-specs/SKILL.md index a779b87..502272f 100644 --- a/skills/save-w-specs/SKILL.md +++ b/skills/save-w-specs/SKILL.md @@ -1,6 +1,7 @@ --- name: save-w-specs description: Use when ready to commit completed work — saves progress and verifies that behavioral code changes are accompanied by deltas in an active OpenSpec change (but never derives specs from code) +tags: [spec] --- # Save progress (OpenSpec) diff --git a/skills/set-topic/SKILL.md b/skills/set-topic/SKILL.md index 37b106f..83c7fa7 100644 --- a/skills/set-topic/SKILL.md +++ b/skills/set-topic/SKILL.md @@ -2,6 +2,7 @@ name: set-topic description: Set the session topic displayed in the status line. Usage: /set-topic <topic text> user_invocable: true +tags: [personal] --- # Set Session Topic @@ -10,13 +11,14 @@ Set the status line topic for this session. ## Instructions -0. **Preflight check.** Before doing anything, verify the reminder hook exists: +0. **Preflight checks.** Verify the reminder hook and the helper script exist: ```bash -[ -x ~/.claude/hooks/remind-session-topic.sh ] || echo "MISSING: remind-session-topic.sh hook is not installed. Session topics will not be enforced. See https://github.com/anutron/claude-skills#session-topics for setup." +[ -x ~/.claude/hooks/remind-session-topic.sh ] || echo "MISSING: remind-session-topic.sh hook is not installed. Session topics will not be enforced." +[ -x ~/.claude/bin/set-session-topic.sh ] || echo "MISSING: ~/.claude/bin/set-session-topic.sh helper is not installed. Run /setup to install user-env tooling. Falling back to inline bash (may trigger permission prompts)." ``` -If the hook is missing, print the warning and continue with the set (don't block). +Print whichever warnings fire, then continue. Do not block. 1. Parse the arguments: - `--initial <text>` — set only if no topic exists yet. If a topic is already set, exit silently (no output, no confirmation). @@ -25,7 +27,21 @@ If the hook is missing, print the warning and continue with the set (don't block 2. Keep it concise (under ~50 chars). The statusline renders it in ALL CAPS. -3. Run this bash command: +3. Write the topic. + +**Preferred path — call the installed helper:** + +```bash +~/.claude/bin/set-session-topic.sh --initial "TOPIC_TEXT_HERE" +``` + +```bash +~/.claude/bin/set-session-topic.sh "TOPIC_TEXT_HERE" +``` + +The helper handles both PID→SESSION_ID resolution and the `--initial` no-op case internally. + +**Fallback path — only if the helper is missing.** Use the inline form (this triggers `Contains simple_expansion` prompts; the warning in step 0 should have told the user to run `/setup`): For `--initial`: ```bash diff --git a/skills/setup/install.sh b/skills/setup/install.sh new file mode 100755 index 0000000..a1919f6 --- /dev/null +++ b/skills/setup/install.sh @@ -0,0 +1,231 @@ +#!/bin/bash +# install.sh — User-env installer for the anutron (claude-skills) kit. +# +# Symlinks helper scripts from <source>/.claude/home/bin/ into ~/.claude/bin/ +# and registers allowlist entries in ~/.claude/settings.json so the helpers +# run without prompts. +# +# Idempotent — safe to re-run. Never overwrites a non-anutron symlink. + +set -euo pipefail + +die() { echo "Error: $*" >&2; exit 1; } + +require_jq() { + command -v jq >/dev/null 2>&1 || die "jq is required but not installed. Install with: brew install jq" +} + +# ============================================================ +# Locate source repo (mirrors anutron-install/install.sh) +# ============================================================ + +locate_source() { + if [ -n "${ANUTRON_SOURCE:-}" ]; then + echo "$ANUTRON_SOURCE" + return + fi + + local cache_dir="$HOME/.claude/anutron-cache" + if [ -d "$cache_dir" ]; then + echo "$cache_dir" + return + fi + + local script_path + script_path="$(cd "$(dirname "$0")" && pwd)/$(basename "$0")" + + local real_path + if command -v greadlink >/dev/null 2>&1; then + real_path="$(greadlink -f "$script_path")" + elif readlink -f "$script_path" >/dev/null 2>&1; then + real_path="$(readlink -f "$script_path")" + else + real_path="$script_path" + while [ -L "$real_path" ]; do + local target + target="$(readlink "$real_path")" + if [[ "$target" == /* ]]; then + real_path="$target" + else + real_path="$(dirname "$real_path")/$target" + fi + done + real_path="$(cd "$(dirname "$real_path")" && pwd)/$(basename "$real_path")" + fi + + # Walk up from .claude/skills/setup/install.sh to repo root + local candidate + candidate="$(dirname "$(dirname "$(dirname "$(dirname "$real_path")")")")" + if [ -d "$candidate/.claude/home/bin" ] || [ -d "$candidate/home/bin" ]; then + echo "$candidate" + return + fi + + die "Cannot locate claude-skills source repo. Set \$ANUTRON_SOURCE or install via plugin." +} + +# ============================================================ +# Main +# ============================================================ + +require_jq + +SOURCE="$(locate_source)" + +# Source dirs: prefer .claude/home/{bin,hooks} (AI-RON layout), fall back to home/{bin,hooks} (published layout) +if [ -d "$SOURCE/.claude/home/bin" ]; then + BIN_SOURCE="$SOURCE/.claude/home/bin" +elif [ -d "$SOURCE/home/bin" ]; then + BIN_SOURCE="$SOURCE/home/bin" +else + BIN_SOURCE="" +fi + +if [ -d "$SOURCE/.claude/home/hooks" ]; then + HOOKS_SOURCE="$SOURCE/.claude/home/hooks" +elif [ -d "$SOURCE/home/hooks" ]; then + HOOKS_SOURCE="$SOURCE/home/hooks" +else + HOOKS_SOURCE="" +fi + +if [ -z "$BIN_SOURCE" ] && [ -z "$HOOKS_SOURCE" ]; then + die "Neither home/bin nor home/hooks found in source: $SOURCE" +fi + +BIN_DEST="$HOME/.claude/bin" +HOOKS_DEST="$HOME/.claude/hooks" +SETTINGS="$HOME/.claude/settings.json" + +mkdir -p "$BIN_DEST" "$HOOKS_DEST" + +installed=0 +already=0 +conflicts=0 +allowlist_added=0 + +# Backup settings.json once if we'll be editing it +BACKUP_DONE=false +backup_settings() { + if ! $BACKUP_DONE; then + if [ -f "$SETTINGS" ]; then + cp "$SETTINGS" "${SETTINGS}.bak.$(date +%s)" + fi + BACKUP_DONE=true + fi +} + +add_allowlist() { + local rule="$1" + if [ ! -f "$SETTINGS" ]; then + echo " WARN: $SETTINGS not found; skipping allowlist entry for $rule" + return + fi + if jq -e --arg r "$rule" '.permissions.allow | index($r)' "$SETTINGS" >/dev/null 2>&1; then + return + fi + backup_settings + local tmp + tmp="$(mktemp)" + jq --arg r "$rule" '.permissions.allow += [$r]' "$SETTINGS" > "$tmp" + if ! jq empty "$tmp" >/dev/null 2>&1; then + rm -f "$tmp" + echo " ERROR: would have produced invalid JSON; skipping $rule" + return + fi + mv "$tmp" "$SETTINGS" + allowlist_added=$((allowlist_added + 1)) + echo " allowlist: $rule" +} + +# Generic per-section sync. Returns conflicts on stderr-style line, mutates counters. +sync_dir() { + local label="$1" src_dir="$2" dest_dir="$3" with_allowlist="$4" + + if [ -z "$src_dir" ]; then + echo "[$label] no source dir — skipped" + return + fi + + echo "[$label] $src_dir -> $dest_dir" + + for src in "$src_dir"/*; do + [ -f "$src" ] || continue + local name dest current_target + name="$(basename "$src")" + dest="$dest_dir/$name" + + if [ -L "$dest" ]; then + current_target="$(readlink "$dest")" + if [ "$current_target" = "$src" ]; then + echo " ok: $name (already linked)" + already=$((already + 1)) + else + echo " CONFLICT: $name -> $current_target (expected $src) — skipped" + conflicts=$((conflicts + 1)) + continue + fi + elif [ -e "$dest" ]; then + echo " CONFLICT: $name exists as a regular file at $dest — skipped" + conflicts=$((conflicts + 1)) + continue + else + ln -s "$src" "$dest" + echo " link: $name" + installed=$((installed + 1)) + fi + + if [ "$with_allowlist" = "yes" ]; then + add_allowlist "Bash($dest:*)" + fi + done + + echo "" +} + +sync_dir "bin" "$BIN_SOURCE" "$BIN_DEST" yes +sync_dir "hooks" "$HOOKS_SOURCE" "$HOOKS_DEST" no + +# settings.json: single-file sync with stricter semantics (no auto-split — that's +# scripts/split-settings.sh, run once by the originator). +SETTINGS_SOURCE="$SOURCE/.claude/home/settings.json" +[ -f "$SETTINGS_SOURCE" ] || SETTINGS_SOURCE="$SOURCE/home/settings.json" + +if [ -f "$SETTINGS_SOURCE" ]; then + echo "[settings] $SETTINGS_SOURCE -> $SETTINGS" + if [ -L "$SETTINGS" ]; then + current_target="$(readlink "$SETTINGS")" + if [ "$current_target" = "$SETTINGS_SOURCE" ]; then + echo " ok: settings.json (already linked)" + already=$((already + 1)) + else + echo " CONFLICT: settings.json -> $current_target (expected $SETTINGS_SOURCE) — skipped" + conflicts=$((conflicts + 1)) + fi + elif [ -f "$SETTINGS" ]; then + echo " CONFLICT: $SETTINGS exists as a regular file. To split secrets and adopt" + echo " the versioned source, run scripts/split-settings.sh in the source repo." + echo " /setup will NOT auto-split — that's a one-time originator operation." + conflicts=$((conflicts + 1)) + else + ln -s "$SETTINGS_SOURCE" "$SETTINGS" + echo " link: settings.json" + echo " reminder: ~/.claude/settings.local.json holds machine-specific env vars and is NOT versioned." + echo " Set OTEL_EXPORTER_OTLP_HEADERS etc. there if you want telemetry." + installed=$((installed + 1)) + fi + echo "" +fi + +echo "Summary:" +echo " installed: $installed" +echo " already in place: $already" +echo " conflicts: $conflicts" +echo " allowlist added: $allowlist_added" + +if [ "$conflicts" -gt 0 ]; then + echo "" + echo "Resolve conflicts manually: inspect the listed paths and either remove the existing symlink/file or move the source to match." + echo "Note: hooks listed as conflicts likely point to legacy locations (e.g. scripts/). Update them to point at .claude/home/hooks/ once you're ready to converge." + exit 3 +fi diff --git a/skills/skill-audit/SKILL.md b/skills/skill-audit/SKILL.md index c4e19e9..2f6e842 100644 --- a/skills/skill-audit/SKILL.md +++ b/skills/skill-audit/SKILL.md @@ -1,6 +1,7 @@ --- name: skill-audit description: Analyze skill usage logs and recommend which skills to keep, prune, or consolidate. Use after collecting usage data for a few weeks to identify dead weight. +tags: [personal] --- ## Context diff --git a/skills/software-best-practices/SKILL.md b/skills/software-best-practices/SKILL.md index 55b6642..eb7f85e 100644 --- a/skills/software-best-practices/SKILL.md +++ b/skills/software-best-practices/SKILL.md @@ -2,6 +2,7 @@ name: software-best-practices description: Use after completing implementation to validate code quality — checks tests, linting, run scripts, error handling, executes code and iterates until success allowed-tools: Read, Write, Edit, Bash, Glob, Grep +tags: [spec] --- # Software Best Practices Skill diff --git a/skills/spec-audit/SKILL.md b/skills/spec-audit/SKILL.md index f86bc35..25945f2 100644 --- a/skills/spec-audit/SKILL.md +++ b/skills/spec-audit/SKILL.md @@ -1,6 +1,7 @@ --- name: spec-audit description: "Audit codebase spec coverage – inventory OpenSpec capabilities, map them to code, dispatch agents to find behavioral gaps. Use when the user wants to check spec health or find coverage holes." +tags: [spec] --- # Spec Audit diff --git a/skills/spec-recommender/SKILL.md b/skills/spec-recommender/SKILL.md index c346fc7..8a3fbe9 100644 --- a/skills/spec-recommender/SKILL.md +++ b/skills/spec-recommender/SKILL.md @@ -1,6 +1,7 @@ --- name: spec-recommender description: "Use when code exists without spec coverage to detect gaps, infer intent, and recommend OpenSpec capabilities + requirements. Output points the user at `openspec instructions specs` to scaffold the right structure." +tags: [spec] --- # Spec Recommender diff --git a/skills/spec-todo/SKILL.md b/skills/spec-todo/SKILL.md index 02d5716..e044a60 100644 --- a/skills/spec-todo/SKILL.md +++ b/skills/spec-todo/SKILL.md @@ -1,6 +1,7 @@ --- name: spec-todo description: "List, pick, and execute deferred work items from .workflow/todo/. Use when the user asks about backlog, deferred work, or what needs doing." +tags: [spec] --- # Spec todo diff --git a/skills/spec-writer/SKILL.md b/skills/spec-writer/SKILL.md index dd9e1f0..b659bd9 100644 --- a/skills/spec-writer/SKILL.md +++ b/skills/spec-writer/SKILL.md @@ -1,6 +1,7 @@ --- name: spec-writer description: "Use whenever writing or updating OpenSpec artifact text — thin orchestrator around `openspec instructions <artifact>`. Returns enriched templates plus project context per artifact (proposal, design, tasks, specs)." +tags: [spec] --- # Spec Writer @@ -93,8 +94,7 @@ Augment the template with project-specific signal so the user (or the next skill - List **existing capabilities** in this project: `openspec list --specs` (a parsed view, not raw output). - For any capability already mentioned in the change's `proposal.md` Capabilities list, fetch its current base spec via `openspec show <capability>` and include the requirement names verbatim — those are the requirements being modified, deltas must reference them by exact header. - Include 1-2 short scenario examples drawn from `openspec/specs/<capability>/spec.md` files in this project so the writer matches the project's voice (`#### Scenario:` shape, WHEN/THEN cadence, units, etc.). -- Always remind the writer: scenarios use **exactly four `#`** characters (`#### Scenario:` is correct; `### Scenario:` with three hashes will fail silently). -- **MODIFIED vs ADDED:** `## MODIFIED Requirements` requires the `### Requirement: <name>` to ALREADY EXIST in the base spec at `openspec/specs/<cap>/spec.md`, with the requirement text repeated verbatim above the new/modified scenarios. `## ADDED Requirements` introduces a brand-new requirement that must NOT exist in the base. Putting a new requirement under MODIFIED makes `openspec archive` fail mid-merge with `MODIFIED failed for header "### Requirement: <name>" - not found`. Always grep the base spec for `### Requirement: <name>` before choosing which header to write under. +- Always remind the writer: scenarios use **exactly four `#`** characters; `### Scenario:` will fail silently. ### For artifact = `proposal` diff --git a/skills/steal/SKILL.md b/skills/steal/SKILL.md index 907f3c0..0b1f24f 100644 --- a/skills/steal/SKILL.md +++ b/skills/steal/SKILL.md @@ -1,6 +1,7 @@ --- name: steal description: Use when the user wants to find reusable skills, patterns, or techniques from other repos — scans tracked GitHub repos or evaluates new ones +tags: [personal] --- # Steal diff --git a/skills/test-driven-development/SKILL.md b/skills/test-driven-development/SKILL.md index fbefbdd..64ee45a 100644 --- a/skills/test-driven-development/SKILL.md +++ b/skills/test-driven-development/SKILL.md @@ -1,6 +1,7 @@ --- name: test-driven-development description: Use when implementing any feature or bugfix, before writing implementation code +tags: [spec] --- # Test-Driven Development (TDD) diff --git a/skills/test/SKILL.md b/skills/test/SKILL.md index 09a237c..705ff67 100644 --- a/skills/test/SKILL.md +++ b/skills/test/SKILL.md @@ -1,6 +1,7 @@ --- name: test description: Use after writing or modifying code to run targeted tests and identify coverage gaps, before claiming code works +tags: [quality] --- # Smart Test Runner diff --git a/skills/tp/SKILL.md b/skills/tp/SKILL.md deleted file mode 100644 index 062b48b..0000000 --- a/skills/tp/SKILL.md +++ /dev/null @@ -1,90 +0,0 @@ ---- -name: tp -description: ALWAYS use the `tp` CLI instead of Read+Edit for any single-line checkbox edit in markdown task lists. TRIGGER when ticking items in `openspec/changes/*/tasks.md`, `openspec/specs/**/*.md` task lists, `.workflow/test-plans/*.md`, or any markdown file with `- [ ] N.M` or `- [ ] **N.M**` checkbox lines; flipping `- [ ]` ↔ `- [x]`; appending one-line status annotations (✅ pass / ❌ fail / 🟡 partial / ⏭ skip). SKIP for multi-paragraph findings (use Edit), structural changes to a file (Write/Edit), or bulk operations across many IDs in one call (run multiple `tp` invocations instead). ---- - -# tp — checkbox CLI - -Always reach for `tp` over Read+Edit when the operation is a single-line checkbox flip or a one-line status annotation in a markdown task list. Streaming the whole file through Edit costs ~150–200 tokens per tick; `tp` does it in one Bash call. - -## TRIGGER / SKIP - -- **TRIGGER** — checkbox-shaped lines: `- [ ] 1.1 Description`, `- [x] **2.4** Description`, `- [ ] **3.1a** Description – ✅ note`. Files include `openspec/changes/*/tasks.md`, `openspec/specs/**/*.md` task lists, `.workflow/test-plans/*.md`, and any other markdown with the same line shape. -- **SKIP** — anything that is not a single-line checkbox edit: multi-paragraph findings, structural changes (new sections, reordered groups), prose edits, deltas, scaffolding new files. Use Edit / Write for those. - -## When to use `tp` - -- Ticking items in `openspec/changes/<name>/tasks.md` as stages complete during `/execute-plan`. -- Marking rows in `.workflow/test-plans/*.md` (or any test plan with the bold-ID + emoji shape) as you work through validation passes. -- Any markdown file with `- [ ] <id> Description` or `- [ ] **<id>** Description` lines. - -## When NOT to use `tp` - -- **Multi-paragraph findings.** `tp` annotations are one line. If you have a finding that spans multiple sentences with rich detail, use Edit. Single-line summaries are `tp`'s job. -- **Bulk operations across many IDs.** No `tp pass 3.1 3.2 3.3` form. Run multiple invocations (in parallel via separate Bash calls if independent). -- **Scaffolding new files or sections.** `tp` only rewrites existing checkbox lines. Use Write/Edit for structural changes. -- **Anything outside the checkbox shape.** `tp` matches `^- [ ]` or `^- [x]` lines with a numeric ID (`N`, `N.M`, `N.M.M`). Free-form markdown stays on Edit. - -## Command surface - -Always pass `-f <file>` (required). Always pass exactly one positional `<id>`. - -| Verb | Effect on `[ ]` | Effect on `[x]` | Annotation appended | Note required | -|------|-----------------|-----------------|---------------------|---------------| -| `tp pass <id> -f F -n "note"` | flips to `[x]` | rewrites | ` – ✅ note` | yes | -| `tp fail <id> -f F -n "note"` | flips to `[x]` | rewrites | ` – ❌ note` | yes | -| `tp partial <id> -f F -n "note"` | flips to `[x]` | rewrites | ` – 🟡 note` | yes | -| `tp skip <id> -f F -n "reason"` | leaves `[ ]` | leaves `[x]` | ` – ⏭ reason` | yes | -| `tp done <id> -f F` | flips to `[x]` | no-op | (none) | not allowed | -| `tp untick <id> -f F` | no-op | flips to `[ ]`, strips annotation | (none) | not allowed | - -Re-running with a new note overwrites the existing annotation. Re-running `done` on a checked line (or `untick` on an unchecked line) is a no-op (exit 0, single stderr line). - -## ID matching - -- ID is the dotted-numeric string before the description (`2.4`, `3.1`, `0.1`). -- Match must be unique. Multiple matches → exit 1 with the line numbers listed. -- Lines inside fenced code blocks and IDs in prose are not matched. -- Bold (`**2.4**`) and plain (`2.4`) ID styles are both recognized; the original style is preserved on rewrite. - -## Output - -- Success: `marked <id> <verb>` to stdout, exit 0. -- No-op: `<id> already <state>` to stderr, exit 0. -- Error: single-line message to stderr, exit 1. - -## Examples - -Tick an OpenSpec stage's tasks: - -```bash -tp done 2.1 -f openspec/changes/add-tp-cli/tasks.md -tp done 2.2 -f openspec/changes/add-tp-cli/tasks.md -tp done 2.3 -f openspec/changes/add-tp-cli/tasks.md -``` - -Annotate a test plan row: - -```bash -tp pass 2.4 -f .workflow/test-plans/2026-05-09-validate-24h.md \ - -n "stale session worked end-to-end after fixing CSRF regression" -tp fail 5.2 -f .workflow/test-plans/2026-05-09-validate-24h.md \ - -n "actual XFO value is DENY, stricter than spec'd" -tp partial 6.1 -f .workflow/test-plans/2026-05-09-validate-24h.md \ - -n "regenerated against sibling fallback" -tp skip 4.7 -f .workflow/test-plans/2026-05-09-validate-24h.md \ - -n "depends on policy decision" -``` - -Reverse a tick: - -```bash -tp untick 2.4 -f .workflow/test-plans/2026-05-09-validate-24h.md -``` - -## Notes for Claude - -- `tp` must be on `$PATH`. If `command -v tp` fails: install from the source directory shipped with this kit. The kit places source at `bin/tp/` (under the published claude-skills repo) or `.claude/bin/tp/` (when developing in AI-RON). Run `make install` there (links `~/.local/bin/tp` by default; pass `PREFIX=/usr/local` for a system-wide install). -- A prebuilt macOS arm64 binary is checked in alongside the source. Other platforms must `make install` to rebuild for their architecture. -- Atomic writes mean partial files are never observable; safe to invoke without locking. -- Other workflow skills (`/execute-plan`, `/ralph-review`, `/bugbash`, `/fixit`) still call Edit directly today. A follow-on change will update them to call `tp`. Until then, reach for `tp` yourself any time the trigger matches. diff --git a/skills/trust-action/SKILL.md b/skills/trust-action/SKILL.md new file mode 100644 index 0000000..9ee0fcd --- /dev/null +++ b/skills/trust-action/SKILL.md @@ -0,0 +1,138 @@ +--- +name: trust-action +description: Eliminate a specific Claude Code permission prompt by adding a targeted allowlist rule to global (~/.claude/settings.json) or project (.claude/settings.json) scope. Use when the user pastes a single permission prompt (text containing "Do you want to proceed?" or "don't ask again") and wants future occurrences of that exact action silenced. Always asks the user to choose global vs project scope before writing. Refuses unfixable patterns (`$(...)`, heredocs, `cd && ...`) and bypass-prone path-based rules (scripts in /tmp/, untracked files) and proposes CLAUDE.md hardening instead. Companion skill `/trust-skills` handles bulk-trust of project-local skills. +tags: [personal] +--- + +## Context + +- Global settings (allow list relevant portion): + + !{python3 -c "import json; d=json.load(open('/Users/aaron/.claude/settings.json')); a=d.get('permissions',{}).get('allow',[]); print('\n'.join(a[-30:]))" 2>/dev/null} + +- Bash-style snippet exists: !{test -f ~/Development/Personal/ai-ron/claude-rules/snippets/global/045-bash-command-style.md && echo yes || echo no} + +## Purpose + +The user pasted (or will paste) a Claude Code permission prompt and wants to stop seeing it. Parse the prompt, classify it, and either: +- **Allowlist** it via the helper script (when the pattern can be silenced), or +- **Refuse** with an explanation and a CLAUDE.md hardening proposal (when static-analysis flags make allowlisting impossible). + +## Instructions + +### Step 1: Classify the prompt + +Scan the pasted text for these signals. + +**Static-analysis flags (unfixable via allowlist — go to Step 4):** + +- `Contains simple_expansion` +- `Contains shell syntax that cannot be statically analyzed` +- `Contains command substitution` +- `Contains heredoc` +- `changes directory before running` + +**Tool types (allowlistable — go to Step 2):** + +- **MCP**: pasted text mentions `(MCP)` and a server name like `claude.ai Granola` or `Notion - notion-fetch` +- **Bash**: pasted text starts with `Bash command` or shows a shell command without static-analysis flags +- **File**: pasted text shows `Read`, `Write`, or `Edit` plus a path + +### Step 2: Build a smart-default allowlist pattern + +**MCP tools:** + +- Convert the server display name to the prefix used in tool names: + - `claude.ai Granola` → `claude_ai_Granola` + - `claude.ai Notion` → `claude_ai_Notion` + - `plugin playwright playwright` → `plugin_playwright_playwright` +- Rule: `mcp__<server>__*` +- Do **not** rely on the existing `mcp__*` catch-all — it has been observed not to match in practice. + +**Bash commands:** + +- Take the first 1-2 tokens of the command (verb + subcommand). Examples: + - `gh api repos/x/y` → `Bash(gh api:*)` + - `npm install foo` → `Bash(npm install:*)` + - `git push origin main` → `Bash(git push:*)` + - `kubectl get pods` → `Bash(kubectl get:*)` +- **Refuse to broaden** these verbs — propose an exact-args pattern only, or refuse outright: + - `rm`, `sudo`, `chmod`, `chown`, `dd`, `mkfs`, `kill`, `pkill`, `curl`, `wget`, `npm publish`, `gh repo delete` +- If the command points to a script under `~/.claude/bin/`, `~/Development/`, or a project's `.claude/bin/`, prefer the exact path pattern: `Bash(/full/path/script.sh:*)`. + +**File operations:** + +- Read/Write/Edit on a specific path → use the directory + `**` for projects, or exact path for one-offs: + - `Read(/Users/aaron/Development/Personal/foo/bar.md)` → `Read(/Users/aaron/Development/Personal/foo/**)` + - `Read(/tmp/x.txt)` → `Read(/tmp/**)` (already allowed) + +### Step 3: Choose scope (global vs project) + +Before writing the rule, always ask the user where it should land. Use `AskUserQuestion` with two options (no auto-pick — the user gets the final say every time): + +- **Global** (`~/.claude/settings.json`) — applies everywhere. Default for rules that aren't project-specific (helper scripts under `~/.claude/bin/`, generic verbs like `gh api`, MCP tools, etc.). +- **Project** (`<project-root>/.claude/settings.json`) — applies only inside that repo. Right choice when the rule references a path under that project, or when the project is a Thanx repo (`~/Development/thanx/*`) where global rules shouldn't bleed. + +Recommend the better default in the question (mark it "Recommended"), but always present both options: + +- Path under `~/Development/thanx/*` → **Recommend Project** +- Path inside a non-global project (e.g. `~/Development/Personal/<repo>/.claude/bin/foo.sh`) → **Recommend Project** +- Global helper (`~/.claude/bin/*`) or verb-only Bash rule or MCP tool → **Recommend Global** + +Resolve `<project-root>` from the prompt path or the current working directory (`git -C <cwd> rev-parse --show-toplevel`). If the user picks Project, the target settings file is `<project-root>/.claude/settings.json`. + +### Step 4: Apply the rule + +Run the helper script (already allowlisted, no prompts). The second argument is the target settings file — omit it for global, pass the project path for project scope: + +```bash +# Global +~/Development/Personal/ai-ron/.claude/skills/trust-action/scripts/add-rule.sh "<rule>" + +# Project +~/Development/Personal/ai-ron/.claude/skills/trust-action/scripts/add-rule.sh "<rule>" "<project-root>/.claude/settings.json" +``` + +The helper: + +- Reads the target settings file (creates a minimal one if it's a project settings path that doesn't exist yet) +- Appends the rule to `permissions.allow` if missing +- Validates the JSON +- Writes a `.bak.<timestamp>` backup +- Prints a diff + +Show the diff back to the user. Done. + +### Step 5: Refuse + propose hardening (unfixable patterns) + +When a static-analysis flag is present, do **not** write any allowlist rule. Instead: + +1. **Explain the flag.** Cite which static-analysis message Claude Code emitted (e.g. "Contains simple_expansion") and why no allowlist can silence it. + +2. **Propose the calling-side fix.** Match the flag to its remedy: + + - `Contains simple_expansion` / `Contains command substitution` / `Contains heredoc` → use a helper script under `~/.claude/bin/` (global) or `.claude/bin/` (project), invoked with simple arguments. Or use the Write tool to create a tempfile, then pass it to the command via `-F` / similar. + - `changes directory before running` → use the tool's `-C` flag (e.g. `git -C <path>`, `make -C <path>`) instead of `cd <path> && cmd`. + +3. **Check the bash-style snippet.** Look at `~/Development/Personal/ai-ron/claude-rules/snippets/global/045-bash-command-style.md`: + + - If the specific pattern is already documented there: note that Claude is ignoring its own guidance. Suggest *strengthening* the snippet (concrete example, stronger language, or a new explicit "never do X" line). + - If the pattern is new: propose appending a new section to the snippet with bad/good examples. + +4. **Stop and ask.** Do not edit CLAUDE.md or the snippet without explicit user confirmation. Just propose the text and offer to apply it. + +### Step 6: Edge cases + +- **Already allowlisted:** the helper script will detect this and no-op. Tell the user. +- **Conflicts with deny list:** if the rule pattern is in `permissions.deny`, refuse and explain. +- **Multiple prompts pasted at once:** process each one (re-asking scope each time unless they share a project), then summarize. +- **Ambiguous bash command** (looks dangerous, looks like a one-off): default to refusing and asking the user which broadening they want. + +## Output format + +End with a one-paragraph summary: + +- What rule (if any) was added +- What was refused and why +- Any CLAUDE.md hardening proposed +- What backup file was written (path to `.bak.<timestamp>`) diff --git a/skills/trust-action/scripts/add-rule.sh b/skills/trust-action/scripts/add-rule.sh new file mode 100755 index 0000000..fdaa84f --- /dev/null +++ b/skills/trust-action/scripts/add-rule.sh @@ -0,0 +1,99 @@ +#!/usr/bin/env bash +# Append a rule to a settings.json permissions.allow array (dedup, validate, backup). +# Usage: add-rule.sh '<rule>' [target-file] +# <rule>: the allowlist string (single-quote to preserve glob chars) +# target-file: optional, defaults to ~/.claude/settings.json. May be a project +# .claude/settings.json or .claude/settings.local.json. + +set -euo pipefail + +RULE="${1:?usage: add-rule.sh <rule> [target-file]}" +SETTINGS="${2:-$HOME/.claude/settings.json}" + +if [ ! -f "$SETTINGS" ]; then + # If target is a project settings file and doesn't exist, create a minimal one + if [[ "$SETTINGS" == *".claude/settings"*".json" ]]; then + mkdir -p "$(dirname "$SETTINGS")" + echo '{"permissions":{"allow":[],"ask":[],"deny":[]}}' > "$SETTINGS" + echo "Created: $SETTINGS" + else + echo "ERROR: $SETTINGS not found" >&2 + exit 1 + fi +fi + +# Safety guardrail: refuse bypass-prone path-based Bash rules. +# Pattern: Bash(/absolute/path/...:*) — path-allowlisting a script. +# Refuse if the script is in a location Claude can freely write to (so the rule +# would grant trust to content that can be silently rewritten). Refuse if it's +# not tracked by git (no audit trail on modifications). Otherwise, surface the +# script content for review before adding the rule. +if [[ "$RULE" =~ ^Bash\(([^:]+) ]]; then + CMD_PATH="${BASH_REMATCH[1]}" + if [[ "$CMD_PATH" == /* ]]; then + # Refuse temp locations Claude has broad Write access to + case "$CMD_PATH" in + /tmp/*|/private/tmp/*|/var/folders/*|/var/tmp/*) + echo "REFUSED: $CMD_PATH is in a temporary location Claude can freely write to." >&2 + echo " Path-based trust doesn't bind to content — Claude could rewrite the script and the rule would still pass." >&2 + echo " Move the script to a versioned location (under a git-tracked directory) first." >&2 + exit 4 + ;; + esac + + # Require the file to exist and be inside a git repo, tracked + if [ -e "$CMD_PATH" ]; then + script_dir="$(dirname "$CMD_PATH")" + if ! git -C "$script_dir" rev-parse --is-inside-work-tree >/dev/null 2>&1; then + echo "REFUSED: $CMD_PATH is not inside a git repository." >&2 + echo " Path-based allowlists require git tracking so modifications are auditable." >&2 + exit 5 + fi + if ! git -C "$script_dir" ls-files --error-unmatch "$CMD_PATH" >/dev/null 2>&1; then + echo "REFUSED: $CMD_PATH exists in a git repo but is not tracked." >&2 + echo " Commit it first so future modifications appear in git diff." >&2 + echo " (Untracked file content preview:)" >&2 + head -50 "$CMD_PATH" >&2 2>/dev/null || true + exit 5 + fi + + # Path is tracked — show content so the user can review what they're authorizing + echo "--- script content review (first 50 lines) ---" >&2 + head -50 "$CMD_PATH" >&2 + echo "--- end content review ---" >&2 + fi + fi +fi + +if jq -e --arg r "$RULE" '.permissions.allow | index($r)' "$SETTINGS" >/dev/null; then + echo "Already allowlisted: $RULE" + exit 0 +fi + +if jq -e --arg r "$RULE" '.permissions.deny // [] | index($r)' "$SETTINGS" >/dev/null; then + echo "CONFLICT: rule is in deny list: $RULE" >&2 + exit 2 +fi + +TS=$(date +%s) +BACKUP="${SETTINGS}.bak.${TS}" +cp "$SETTINGS" "$BACKUP" + +TMP=$(mktemp) +jq --arg r "$RULE" '.permissions.allow += [$r]' "$SETTINGS" > "$TMP" + +if ! jq empty "$TMP" >/dev/null 2>&1; then + echo "ERROR: invalid JSON after edit. Backup at $BACKUP" >&2 + rm -f "$TMP" + exit 1 +fi + +# Write through the symlink (cat redirection follows symlinks, unlike mv which +# replaces them). This matters now that ~/.claude/settings.json is symlinked to +# the versioned source in the repo. +cat "$TMP" > "$SETTINGS" +rm -f "$TMP" +echo "Added: $RULE" +echo "Backup: $BACKUP" +echo "--- diff ---" +diff "$BACKUP" "$SETTINGS" || true diff --git a/skills/trust-skills/SKILL.md b/skills/trust-skills/SKILL.md new file mode 100644 index 0000000..ec31ba4 --- /dev/null +++ b/skills/trust-skills/SKILL.md @@ -0,0 +1,87 @@ +--- +name: trust-skills +description: Bulk-trust all skills defined in the current project's `.claude/skills/` directory. Discovers local skills, shows them to the user, asks where to write the rules (project settings.json vs settings.local.json), then adds `Skill(<name>)` allowlist entries. Use when working in a project that has its own skills and you keep getting per-skill permission prompts. +tags: [personal] +--- + +## Context + +- Working directory: !{pwd} +- Local skills dir present: !{test -d .claude/skills && echo yes || echo no} +- Local skills count: !{ls -1 .claude/skills/ 2>/dev/null | wc -l | tr -d ' '} +- Project settings.json present: !{test -f .claude/settings.json && echo yes || echo no} +- Project settings.local.json present: !{test -f .claude/settings.local.json && echo yes || echo no} + +## Purpose + +Project-local equivalent of `/trust-action`, but proactive and bulk. When a project has its own `.claude/skills/` directory full of skills the user trusts (they live in the repo, they're committed), repeatedly approving each one is friction without benefit. This skill discovers them all and adds them to the project's allow list in one shot. + +## Instructions + +### Step 1: Abort if no local skills + +If `.claude/skills/` does not exist or is empty, tell the user there's nothing to trust and stop. Suggest they may be looking for `/trust-action` (global, reactive) instead. + +### Step 2: Discover local skills + +List every directory under `.claude/skills/`. For each, read `<name>/SKILL.md`'s frontmatter to extract: + +- `name:` — the skill identifier used in `Skill(<name>)` rules +- `description:` — the one-line summary + +If a directory has no SKILL.md, skip it with a note. + +### Step 3: Present and confirm + +Show the user a markdown list of discovered skills: + +``` +Found N skills in .claude/skills/: + +- **skill-one** — brief description +- **skill-two** — brief description +- ... +``` + +Then use AskUserQuestion to confirm the action: + +- **Question:** "Trust all N skills?" +- **Options:** + - "Yes — trust all" (recommended) + - "Choose individually" — fall through to a multi-select (one AskUserQuestion per group of ≤4 skills) + - "Cancel" + +### Step 4: Ask where to write the rules + +Use AskUserQuestion to pick the destination file. **Always ask — never default.** + +- **Question:** "Where should the allowlist entries go?" +- **Options:** + - "Project `.claude/settings.json`" — versioned, shared with collaborators. Use when the trust decision should apply to everyone working on the repo. + - "Project `.claude/settings.local.json`" — per-user, gitignored. Use when the decision is yours and might differ across contributors. + +### Step 5: Apply rules + +For each confirmed skill, invoke the shared helper at the absolute path: + +```bash +/Users/aaron/Development/Personal/ai-ron/.claude/skills/trust-action/scripts/add-rule.sh "Skill(<name>)" "<destination-file>" +``` + +The helper handles dedup, deny-conflict, JSON validation, and backup. If the destination file doesn't exist, it creates a minimal one with `{"permissions":{"allow":[],"ask":[],"deny":[]}}`. + +### Step 6: Report + +Print a short summary: + +- N skills processed +- M added (newly allowlisted) +- K already present (no-op) +- Backup path (from the helper) +- Reminder: tell the user to restart Claude Code if rule changes don't take effect immediately + +### Safety notes + +- This skill only adds `Skill(<name>)` rules — never `Bash(...)`, `Read(...)`, etc. Skills are instruction text; dangerous operations inside still hit their own permission checks. +- Refuses to add rules that conflict with the `deny` list (handled by the helper). +- Project-local: scope of impact is bounded to this repo's settings, not global. diff --git a/skills/unstaged/SKILL.md b/skills/unstaged/SKILL.md index 23968f9..f8b5e34 100644 --- a/skills/unstaged/SKILL.md +++ b/skills/unstaged/SKILL.md @@ -1,6 +1,7 @@ --- name: unstaged description: Use when the user wants to see what's changed or plan commits — shows uncommitted/unstaged changes grouped by logical commit themes +tags: [quality] --- # Unstaged Changes Overview diff --git a/skills/upload-notion-image/SKILL.md b/skills/upload-notion-image/SKILL.md index 45d2b92..24befd6 100644 --- a/skills/upload-notion-image/SKILL.md +++ b/skills/upload-notion-image/SKILL.md @@ -1,6 +1,7 @@ --- name: upload-notion-image description: Upload local images to Notion pages natively via the Notion API file upload flow. No external hosting needed — images live inside Notion. Use when embedding images in Notion pages. +tags: [personal] --- ## What This Skill Does diff --git a/skills/verification-before-completion/SKILL.md b/skills/verification-before-completion/SKILL.md index a5bd97a..6075904 100644 --- a/skills/verification-before-completion/SKILL.md +++ b/skills/verification-before-completion/SKILL.md @@ -1,6 +1,7 @@ --- name: verification-before-completion description: Use when about to claim work is complete, fixed, or passing, before committing or creating PRs - requires running verification commands and confirming output before making any success claims; evidence before assertions always +tags: [spec] --- # Verification Before Completion diff --git a/skills/write-skill/SKILL.md b/skills/write-skill/SKILL.md index c8a797c..462b367 100644 --- a/skills/write-skill/SKILL.md +++ b/skills/write-skill/SKILL.md @@ -1,6 +1,7 @@ --- name: write-skill description: Use when creating a new skill or improving an existing one — applies best practices for structure, dynamic context, and safety +tags: [personal] --- # Skill Author