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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "wisdom",
"version": "3.0.2",
"version": "3.0.3",
"owner": {
"name": "cmj",
"url": "https://github.com/cmj0121"
Expand All @@ -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"
}
]
Expand Down
18 changes: 10 additions & 8 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
22 changes: 1 addition & 21 deletions Makefile
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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.
Expand Down
17 changes: 9 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<name>`. 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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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/<name>/
├── SKILL.md
└── references/ # optional
Expand Down
17 changes: 9 additions & 8 deletions README.zh-TW.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ v3 從零重寫,圍繞三條規則。
1. **一個 skill,一個目的。** 每個 skill 只做一件事,並以該件事命名。需要第二項工作時,
呼叫負責那件事的 skill,而不是自己長出新職責。
2. **需要時才用 shortcut。** 每個 skill 都能以 `/wisdom:<name>` 執行。常用到會順口說出的
skill 另外宣告 magic word——例如說 `smith`,dispatcher 就會啟動主導 skill。magic word 在
skill 另外宣告 magic word——例如說 `smith`,prompt hook 就會啟動主導 skill。magic word 在
整個 plugin 內不得重複。
3. **更少的 token。** `description` 每個 session 都會載入,所以只寫一句短句;body 在每次
執行時載入,同樣保持精簡;只有部分執行才需要的內容放在 `references/`,需要時才讀。不設
Expand Down Expand Up @@ -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 如何運作

Expand Down Expand Up @@ -108,6 +108,7 @@ Skill 是帶有 YAML frontmatter 的 Markdown,遵循
```text
plugins/wisdom/
├── .claude-plugin/plugin.json
├── hooks/ # hooks.json 與它執行的腳本
└── skills/<name>/
├── SKILL.md
└── references/ # optional
Expand Down
2 changes: 1 addition & 1 deletion plugins/wisdom/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
13 changes: 12 additions & 1 deletion plugins/wisdom/hooks/hooks.json
Original file line number Diff line number Diff line change
@@ -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": [
{
Expand All @@ -12,6 +12,17 @@
}
]
}
],
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/shortcut-prompt.py",
"timeout": 10
}
]
}
]
}
}
90 changes: 90 additions & 0 deletions plugins/wisdom/hooks/shortcut-prompt.py
Original file line number Diff line number Diff line change
@@ -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:<name>`; 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"(?<!\w)" + re.escape(word) + r"(?!\w)", prompt, re.I)
]
if hits:
word, skill = max(hits, key=lambda hit: len(hit[0]))
break
else:
return 0

context = (
f"The prompt contains the magic word `{word}`. Run the `{skill}` skill now with the "
"rest of the prompt as its arguments, and say in one line which word triggered it."
)
print(json.dumps({
"hookSpecificOutput": {
"hookEventName": "UserPromptSubmit",
"additionalContext": context,
}
}))
return 0


if __name__ == "__main__":
sys.exit(main())
10 changes: 7 additions & 3 deletions plugins/wisdom/skills/shortcut/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
name: shortcut
description: Dispatches the skill whose magic word appears in the prompt, or lists every magic word available. Use at the start of a turn that may contain one.
description: Lists every magic word the installed skills declare, or dispatches one by hand when the prompt hook did not. Use to see which words are available.
license: MIT
allowed-tools:
- Glob
Expand All @@ -9,7 +9,10 @@ allowed-tools:

# Shortcut

Find the magic word in the prompt and run the skill that declares it.
The plugin's `UserPromptSubmit` hook (`hooks/shortcut-prompt.py`) matches every prompt
against the magic words and names the skill to run, so a word normally dispatches before
this skill is read. This skill lists the words, and dispatches by hand when the hook did
not fire — hooks disabled, or a word handed over as an argument.

## Sources

Expand All @@ -23,7 +26,8 @@ first:

## Dispatch

1. Match each word against the prompt as a case-insensitive substring.
1. Match each word against the prompt, case-insensitive, at word boundaries — `smith`
matches "Hi, Smith" and not "blacksmith".
2. More than one match: take the highest-priority source, then the longest word.
3. Invoke the matched skill with the rest of the prompt as its arguments, and say in one
line which word triggered it.
Expand Down
7 changes: 4 additions & 3 deletions plugins/wisdom/skills/smith/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,9 +71,10 @@ are already merged. For each batch:
A finding that says the design is wrong goes to `wisdom:design`, not to `code`. After two
failed fix rounds on the same unit, stop and bring it to the user.

4. **Merge** each passing worktree branch into the feature branch, one at a time; resolve a
conflict with a `wisdom:code` subagent, then review the result again.
5. **Commit** each unit with `wisdom:commit`, and mark it `done` in `PLAN.md`.
4. **Commit** each passing unit with `wisdom:commit` — inside its worktree when it has one,
since an uncommitted branch cannot be merged — and mark it `done` in `PLAN.md`.
5. **Merge** each committed worktree branch into the feature branch, one at a time; resolve
a conflict with a `wisdom:code` subagent, then review and commit the result again.

## 4. Check

Expand Down
5 changes: 2 additions & 3 deletions scripts/check-install-drift
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,8 @@
# 3. plugin the installed marketplace
#
# A leftover copy in tier 1 does not replace the plugin -- both are offered, as
# `<name>` and `<plugin>:<name>` -- 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.
# `<name>` and `<plugin>:<name>` -- 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
Expand Down
34 changes: 33 additions & 1 deletion scripts/check-version-sync
Original file line number Diff line number Diff line change
Expand Up @@ -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 ""
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading