diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 0630a67..3baf0b9 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -1,6 +1,6 @@ { "name": "wisdom", - "version": "3.0.2", + "version": "3.0.3", "owner": { "name": "cmj", "url": "https://github.com/cmj0121" @@ -9,7 +9,7 @@ { "name": "wisdom", "description": "Single-purpose skills to plan, build, review and ship software, led by smith.", - "version": "3.0.2", + "version": "3.0.3", "source": "./plugins/wisdom" } ] diff --git a/CLAUDE.md b/CLAUDE.md index 369fb6f..b181d40 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -46,12 +46,12 @@ scripts/ # the checks pre-commit runs the same four on every commit, plus `scripts/validate-fixtures` when `scripts/` changes; CI runs all five on every push. -| Script | Fails when | -| ---------------------------- | ---------------------------------------------------------------- | -| `scripts/test` | a required file is missing or a manifest name disagrees | -| `scripts/check-version-sync` | plugin versions disagree, or the release lags the latest tag | -| `scripts/validate` | a magic word repeats, a README table drifts, a reference dangles | -| `scripts/check-skill-spec` | frontmatter breaks the Agent Skills standard or the budgets | +| Script | Fails when | +| ---------------------------- | ------------------------------------------------------------------------- | +| `scripts/test` | a required file is missing or a manifest name disagrees | +| `scripts/check-version-sync` | versions disagree, the release lags the tag, or a plugin changed unbumped | +| `scripts/validate` | a magic word repeats, a README table drifts, a reference dangles | +| `scripts/check-skill-spec` | frontmatter breaks the Agent Skills standard or the budgets | Run `bash scripts/validate-fixtures` after editing `validate` or `check-skill-spec`: it breaks a temp copy one way at a time and asserts each check notices. `make doctor` reports a @@ -64,6 +64,8 @@ Green checks do not prove the prose is right; a stale sentence still needs readi - One version, in two places that must agree: the plugin's entry in `marketplace.json` and `plugins/wisdom/.claude-plugin/plugin.json`. The top-level `version` in `marketplace.json` must not lag the latest `v*` tag. -- Bump the plugin when what a skill does changes; a description-only edit bumps the - top-level version alone. At most one plugin bump between two tags. +- Bump the plugin whenever anything under `plugins/wisdom/` changes, a description + included: an installed copy is re-fetched only when the version moves. A change outside + it (README, scripts) bumps the top-level version alone. At most one plugin bump between + two tags. - Never tag or release without the user asking. diff --git a/Makefile b/Makefile index d41998a..e9adfac 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ SUBDIR := -.PHONY: all clean test eval doctor run build upgrade install help $(SUBDIR) +.PHONY: all clean test doctor run build upgrade install help $(SUBDIR) all: $(SUBDIR) # default action @[ -f .git/hooks/pre-commit ] || pre-commit install --install-hooks @@ -38,26 +38,6 @@ test: # run the four repo checks (not the validator self-test) printf '==> %d of %d checks FAILED -- re-run with WISDOM_VERBOSE=1 for per-item detail\n' "$$failed" "$$total"; \ exit 1 -# Deliberately NOT part of `test`: every case is a full Claude child session on -# the operator's own credential, so it wants a network and real money, and CI -# has no secret to give it. --ablation none keeps a `tool_used: Skill` grader -# scored; --no-publish keeps the report off claude.ai. Plugins without cases -# under evals/ are never invoked. -eval: # run the committed trigger cases (needs a credential and a network) - @failed=0; \ - for plugin in $$(find plugins -path '*/evals/*/prompt.md' | cut -d/ -f1-2 | sort -u); do \ - printf '\n==> %s\n' "$$plugin"; \ - claude plugin eval "$$plugin" \ - --trust-plugin \ - --ablation none \ - --no-publish \ - --max-cost-usd 3.00 || failed=$$((failed + 1)); \ - done; \ - if [ $$failed -ne 0 ]; then \ - printf '\n==> %d plugin suite(s) FAILED\n' "$$failed"; \ - exit 1; \ - fi - # Deliberately NOT part of `test`: this one asserts on the machine, not on the # repo. There is no $$HOME/.claude in CI, where it would pass for the wrong # reason, and a contributor's own skills are not this repo's business. diff --git a/README.md b/README.md index 0e1f5fa..a7dd5f4 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ v3 is a rewrite from scratch around three rules. 1. **One skill, one purpose.** Each skill does one thing and is named after it. A skill that needs a second job calls the skill that owns it instead of growing one. 2. **Shortcuts where they help.** Every skill runs with `/wisdom:`. A skill used often - enough to type in passing also declares a magic word — say `smith` and the dispatcher + enough to type in passing also declares a magic word — say `smith` and a prompt hook starts the lead skill. Words are unique across the plugin. 3. **Fewer tokens.** A description is loaded in every session, so it stays one short sentence. A body is loaded each time its skill runs, so it stays short too; anything only @@ -72,13 +72,13 @@ other skills in turn. Every skill also works on its own. ### Output -| Skill | Purpose | Magic word | -| ---------- | --------------------------------------------------------- | ---------------- | -| `diagram` | Draw an ASCII diagram of a decided structure | `draw a diagram` | -| `compact` | Re-render the previous answer as a dense table | `compact it` | -| `brief` | Keep answers short and discuss one topic at a time | `brief me` | -| `lingua` | Answer in your chosen language, remembered per project | `reply in` | -| `shortcut` | Dispatch the skill whose magic word appears in the prompt | — | +| Skill | Purpose | Magic word | +| ---------- | ------------------------------------------------------ | ---------------- | +| `diagram` | Draw an ASCII diagram of a decided structure | `draw a diagram` | +| `compact` | Re-render the previous answer as a dense table | `compact it` | +| `brief` | Keep answers short and discuss one topic at a time | `brief me` | +| `lingua` | Answer in your chosen language, remembered per project | `reply in` | +| `shortcut` | List every magic word, or dispatch one the hook missed | — | ## How smith runs @@ -114,6 +114,7 @@ Skills are Markdown with YAML frontmatter following the ```text plugins/wisdom/ ├── .claude-plugin/plugin.json +├── hooks/ # hooks.json and the scripts it runs └── skills// ├── SKILL.md └── references/ # optional diff --git a/README.zh-TW.md b/README.zh-TW.md index 2ef03d4..6bb399c 100644 --- a/README.zh-TW.md +++ b/README.zh-TW.md @@ -20,7 +20,7 @@ v3 從零重寫,圍繞三條規則。 1. **一個 skill,一個目的。** 每個 skill 只做一件事,並以該件事命名。需要第二項工作時, 呼叫負責那件事的 skill,而不是自己長出新職責。 2. **需要時才用 shortcut。** 每個 skill 都能以 `/wisdom:` 執行。常用到會順口說出的 - skill 另外宣告 magic word——例如說 `smith`,dispatcher 就會啟動主導 skill。magic word 在 + skill 另外宣告 magic word——例如說 `smith`,prompt hook 就會啟動主導 skill。magic word 在 整個 plugin 內不得重複。 3. **更少的 token。** `description` 每個 session 都會載入,所以只寫一句短句;body 在每次 執行時載入,同樣保持精簡;只有部分執行才需要的內容放在 `references/`,需要時才讀。不設 @@ -69,13 +69,13 @@ v3 從零重寫,圍繞三條規則。 ### 輸出 -| Skill | 目的 | Magic word | -| ---------- | -------------------------------------------- | ---------------- | -| `diagram` | 為已定案的結構畫 ASCII 圖 | `draw a diagram` | -| `compact` | 把上一個回答重新整理成精簡表格 | `compact it` | -| `brief` | 讓回答保持簡短,一次只討論一個主題 | `brief me` | -| `lingua` | 以你選定的語言回答,並依專案記住 | `reply in` | -| `shortcut` | 依 prompt 中出現的 magic word 派送對應 skill | — | +| Skill | 目的 | Magic word | +| ---------- | ----------------------------------------------- | ---------------- | +| `diagram` | 為已定案的結構畫 ASCII 圖 | `draw a diagram` | +| `compact` | 把上一個回答重新整理成精簡表格 | `compact it` | +| `brief` | 讓回答保持簡短,一次只討論一個主題 | `brief me` | +| `lingua` | 以你選定的語言回答,並依專案記住 | `reply in` | +| `shortcut` | 列出所有 magic word,或手動派送 hook 漏掉的那個 | — | ## smith 如何運作 @@ -108,6 +108,7 @@ Skill 是帶有 YAML frontmatter 的 Markdown,遵循 ```text plugins/wisdom/ ├── .claude-plugin/plugin.json +├── hooks/ # hooks.json 與它執行的腳本 └── skills// ├── SKILL.md └── references/ # optional diff --git a/plugins/wisdom/.claude-plugin/plugin.json b/plugins/wisdom/.claude-plugin/plugin.json index 18ad38d..ee9ca0c 100644 --- a/plugins/wisdom/.claude-plugin/plugin.json +++ b/plugins/wisdom/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "wisdom", "description": "Single-purpose skills to plan, build, review and ship software, led by smith.", - "version": "3.0.2", + "version": "3.0.3", "license": "MIT", "author": { "name": "cmj", diff --git a/plugins/wisdom/hooks/hooks.json b/plugins/wisdom/hooks/hooks.json index ababa51..3be75da 100644 --- a/plugins/wisdom/hooks/hooks.json +++ b/plugins/wisdom/hooks/hooks.json @@ -1,5 +1,5 @@ { - "description": "Load the per-project lingua language preference at session start.", + "description": "Load the per-project lingua language at session start; dispatch magic words on every prompt.", "hooks": { "SessionStart": [ { @@ -12,6 +12,17 @@ } ] } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/shortcut-prompt.py", + "timeout": 10 + } + ] + } ] } } diff --git a/plugins/wisdom/hooks/shortcut-prompt.py b/plugins/wisdom/hooks/shortcut-prompt.py new file mode 100644 index 0000000..ed54159 --- /dev/null +++ b/plugins/wisdom/hooks/shortcut-prompt.py @@ -0,0 +1,90 @@ +#!/usr/bin/env python3 +"""Shortcut UserPromptSubmit hook. + +Matches the prompt against every magic word the installed skills declare +(`metadata.shortcut` in their frontmatter) and, on a hit, tells the model which +skill to run. Dispatch therefore no longer depends on the model choosing to run +the `shortcut` skill, and no skill file is read at the model's expense. +Silent no-op when no word matches. +""" +import glob +import json +import os +import re +import sys + +# Highest priority first, mirroring how Claude Code resolves a skill name. +# The wisdom skills are invoked as `wisdom:`; user and project ones bare. +def sources(cwd, plugin_root): + yield os.path.join(os.path.expanduser("~"), ".claude", "skills"), "" + yield os.path.join(cwd, ".claude", "skills"), "" + yield os.path.join(plugin_root, "skills"), "wisdom:" + + +def frontmatter(path): + try: + with open(path, encoding="utf-8") as handle: + text = handle.read(8192) + except OSError: + return "" + if not text.startswith("---\n"): + return "" + end = text.find("\n---", 4) + return text[4:end] if end != -1 else "" + + +def words(skills_dir, prefix): + for path in sorted(glob.glob(os.path.join(skills_dir, "*", "SKILL.md"))): + match = re.search(r"^\s+shortcut:\s*(.+?)\s*$", frontmatter(path), re.M) + if not match: + continue + skill = prefix + os.path.basename(os.path.dirname(path)) + for word in match.group(1).strip("\"'").split(","): + word = word.strip() + if word: + yield word, skill + + +def main() -> int: + try: + data = json.load(sys.stdin) if not sys.stdin.isatty() else {} + except (json.JSONDecodeError, ValueError): + data = {} + + prompt = data.get("prompt") or "" + # A slash command already names its skill; matching its text would only + # tell the model to run what it is running. + if not prompt.strip() or prompt.lstrip().startswith("/"): + return 0 + + cwd = data.get("cwd") or os.environ.get("CLAUDE_PROJECT_DIR") or os.getcwd() + plugin_root = os.environ.get("CLAUDE_PLUGIN_ROOT") or os.path.dirname(os.path.dirname(__file__)) + + # First source with a hit wins; within it, the longest word. + for skills_dir, prefix in sources(cwd, plugin_root): + hits = [ + (word, skill) + for word, skill in words(skills_dir, prefix) + if re.search(r"(?` and `:` -- so the model picks between two routers whose -# descriptions have since diverged. Every trigger eval in this repo then measures -# a description the session may never have loaded. +# `` and `:` -- so the model picks between two skills whose +# descriptions have since diverged, and a session may never load the shipped one. # # This is the one check that asserts on the MACHINE rather than on the repo, so # it is deliberately not part of `make test`, pre-commit or CI: there is no diff --git a/scripts/check-version-sync b/scripts/check-version-sync index e6cd300..ad84d04 100755 --- a/scripts/check-version-sync +++ b/scripts/check-version-sync @@ -3,13 +3,16 @@ # 1. Every marketplace.json plugin version matches its plugin.json version. # 2. The marketplace.json top-level version never lags the latest git tag # (equal = released, ahead = next release in prep, behind = error). +# 3. A plugin whose files changed since the latest tag carries a new version: +# an installed copy is re-fetched only when the version moves, so a change +# without a bump never reaches anyone who already has the plugin. set -euo pipefail REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" LATEST_TAG="$(git -C "$REPO_ROOT" tag --sort=-v:refname --list 'v*' 2>/dev/null | head -n1 || true)" python3 - "$REPO_ROOT" "$LATEST_TAG" <<'PY' -import json, os, re, sys +import json, os, re, subprocess, sys root = sys.argv[1] latest_tag = sys.argv[2] if len(sys.argv) > 2 else "" @@ -79,6 +82,35 @@ else: else: bad(f"marketplace version {mk_version} is behind latest tag {latest_tag}") +print("==> Checking plugins changed since the latest tag were bumped") +if not latest_tag: + print(" skip: no 'v*' tags found") +else: + for p in mk["plugins"]: + name = p["name"] + src = p["source"][2:] if p["source"].startswith("./") else p["source"] + changed = subprocess.run( + ["git", "-C", root, "diff", "--quiet", latest_tag, "--", src], + ).returncode != 0 + if not changed: + ok(f"{name}: unchanged since {latest_tag}") + continue + pj_path = os.path.join(root, src, ".claude-plugin", "plugin.json") + try: + now = json.load(open(pj_path)).get("version", "") + shown = subprocess.run( + ["git", "-C", root, "show", f"{latest_tag}:{src}/.claude-plugin/plugin.json"], + capture_output=True, text=True, check=True, + ).stdout + then = json.loads(shown).get("version", "") + except (OSError, ValueError, subprocess.CalledProcessError): + ok(f"{name}: no plugin.json at {latest_tag} to compare with") + continue + if semver(now) > semver(then): + ok(f"{name}: changed since {latest_tag}, bumped {then} -> {now}") + else: + bad(f"{name}: files changed since {latest_tag} but the version is still {now}") + if errors: print(f"==> {errors} version problem(s) found") sys.exit(1) diff --git a/scripts/test b/scripts/test index 9808cba..260a1ae 100755 --- a/scripts/test +++ b/scripts/test @@ -4,7 +4,9 @@ # 2. Every registered plugin declares its four entry fields, its source # directory exists, its plugin.json parses and names the same plugin, # and it ships a LICENSE. -# 3. Every script in scripts/ is present and executable. +# 3. Every ${CLAUDE_PLUGIN_ROOT} path a plugin's hooks/hooks.json runs exists, +# so a renamed hook script fails here and not at the next session start. +# 4. Every script in scripts/ is present and executable. # # Deliberately NOT checked here — each is covered by a sibling script, and # duplicating it would only make one more place to keep in step: @@ -18,6 +20,7 @@ REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" python3 - "$REPO_ROOT" <<'PY' import json import os +import re import sys root = sys.argv[1] @@ -128,6 +131,24 @@ for entry in plugins: else: bad("missing LICENSE") + # Claude Code resolves ${CLAUDE_PLUGIN_ROOT} to the plugin directory when + # it runs a hook, so a path after it is relative to plugin_dir. Only + # those are checked: a bare command like `python3` is the machine's. + hooks_path = os.path.join(plugin_dir, "hooks", "hooks.json") + if os.path.isfile(hooks_path): + hooks = load_json(hooks_path) + if hooks is None: + bad("hooks/hooks.json is not valid JSON") + else: + for event, groups in hooks.get("hooks", {}).items(): + for group in groups: + for hook in group.get("hooks", []): + for ref in re.findall(r"\$\{CLAUDE_PLUGIN_ROOT\}/(\S+)", hook.get("command", "")): + if os.path.isfile(os.path.join(plugin_dir, ref)): + ok(f"hooks.json {event} runs {ref}") + else: + bad(f"hooks.json {event} runs {ref}, which does not exist") + # The check scripts themselves. Deleting one is invisible to every `files:` # gated hook — pre-commit builds its candidate list with # `--diff-filter=ACMRTUXB`, which drops deletions — and a hook whose own entry