diff --git a/README.md b/README.md index c1fbae9..f1d9a57 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ Copy the skills into a specific project (`/.agents/skills` and `/.cl make project TARGET=/path/to/repo ``` -Both commands also enable the caveman and ponytail plugins in the target's `.claude/settings.json` so their modes auto-activate on session start; existing settings are preserved. +Both commands also patch the target's `.claude/settings.json`: the caveman and ponytail plugins are enabled so their modes auto-activate on session start, and the `autotune` reminder hook is registered so the agent points you at `/autotune` at most once an hour. Existing settings are preserved. Restart your agent afterwards so it loads the new skills. diff --git a/scripts/enable-plugins.js b/scripts/enable-plugins.js index 0ccf901..2c06153 100644 --- a/scripts/enable-plugins.js +++ b/scripts/enable-plugins.js @@ -1,11 +1,12 @@ #!/usr/bin/env node // Enable the caveman and ponytail plugins in a Claude Code settings.json so -// their SessionStart hooks auto-activate both modes. Merges non-destructively: -// existing marketplaces and enabled plugins are preserved, unparseable files -// are left untouched. +// their SessionStart hooks auto-activate both modes, and register the autotune +// reminder hook. Merges non-destructively: existing marketplaces, plugins and +// hooks are preserved, unparseable files are left untouched. "use strict"; const fs = require("fs"); +const path = require("path"); const settingsPath = process.argv[2]; if (!settingsPath) { @@ -38,5 +39,15 @@ for (const [name, entry] of Object.entries(MARKETPLACES)) { } data.enabledPlugins = Object.assign(data.enabledPlugins || {}, PLUGINS); +const reminder = path.join(path.dirname(settingsPath), "skills", "autotune", "hooks", "reminder.sh"); +data.hooks = data.hooks || {}; +data.hooks.UserPromptSubmit = data.hooks.UserPromptSubmit || []; +const registered = JSON.stringify(data.hooks.UserPromptSubmit).includes("autotune/hooks/reminder.sh"); +if (!registered) { + data.hooks.UserPromptSubmit.push({ + hooks: [{ type: "command", command: `bash ${JSON.stringify(reminder)}` }], + }); +} + fs.writeFileSync(settingsPath, JSON.stringify(data, null, 2) + "\n"); -console.log(`skills: enabled caveman + ponytail plugins in ${settingsPath}`); +console.log(`skills: enabled caveman + ponytail plugins and the autotune reminder in ${settingsPath}`); diff --git a/skills/autotune/SKILL.md b/skills/autotune/SKILL.md new file mode 100644 index 0000000..7158ed7 --- /dev/null +++ b/skills/autotune/SKILL.md @@ -0,0 +1,92 @@ +--- +name: autotune +description: > + Generate new agent skills and optimize existing ones so the agent works more + efficiently: fewer input and output tokens, fewer errors, more focus, and + learnings that persist across sessions. Every proposal is confirmed with the + operator through single-select (radio button) questions. Trigger ONLY on an + explicit operator request (/autotune, "autotune", "tune the skills"); never + run it on your own initiative. Portable across projects. +--- + +Autotune turns observed friction into durable skills. It runs on demand only, +it changes nothing without an explicit answer from the operator, and every +skill it writes or rewrites lands in the operator's skills repository. + +## Trigger discipline + +- Run **only** when the operator asks: `/autotune`, "autotune", "tune the + skills", or an equivalent instruction. +- The 60-minute reminder hook is a hint for the operator, not a trigger. When it + fires, print the hint and continue the operator's actual request. +- Never propose, write, or edit a skill as a side effect of unrelated work. + +## Step 1: collect evidence + +Before asking anything, gather concrete friction from the current session and, +where readable, from the project's memory and agent instruction files: + +- **Token waste**: repeated file re-reads, whole-file reads where a range was + enough, long tool output pasted back, re-running a command instead of + grepping a saved log, verbose report prose. +- **Errors**: rejected tool calls, permission prompts, retried commands, + corrections the operator had to give twice. +- **Focus loss**: work that drifted beyond the request, unrequested tests or + deploys, questions that stalled an autonomous run. +- **Lost learnings**: facts re-derived this session that a memory file or a + skill should already have carried. + +List each finding as one line: `symptom -> cost -> candidate fix`. Skip +anything that happened once and is not a pattern. + +## Step 2: ask, do not decide + +Use `AskUserQuestion` with `multiSelect: false` so every question renders as +radio buttons. Never assume an answer, never batch several decisions into one +option, and never write a file before the answers are in. + +Ask in this order, at most four questions per call: + +1. **Scope** - "What should autotune do this run?" Options: `Create a new + skill`, `Optimize an existing skill`, `Both`, plus the ranked findings if + more than one candidate exists. +2. **Target** - which finding becomes a skill, or which existing skill gets + optimized. One option per candidate, each with its measured cost in the + description. +3. **Shape** - for a new skill: `Standalone skill`, `Row in the shortcuts + skill`, `Extend an existing skill`, `Memory entry instead of a skill`. + For an optimization: `Tighten wording only`, `Change the trigger`, `Add + rules`, `Split into two skills`, `Merge into another skill`. +4. **Placement** - `Operator skills repository (portable)` or + `This project's skills directory (project-specific)`. + +Present the drafted skill text (name, trigger phrasing, body outline) and ask +for a final `Write it` / `Revise it` / `Discard` confirmation before touching +disk. Rewrites of an existing skill additionally show a before/after diff of +the changed lines in that confirmation. + +## Step 3: write + +- Portable skills go to `skills//SKILL.md` in the operator's skills + repository; project-specific ones to that project's skills directory. +- Frontmatter carries `name` and a `description` that states what the skill + does **and** when to trigger it - the description is the only part loaded into + every session, so it decides whether the skill ever fires. +- Body is thin and single-purpose: the smallest instruction that changes + behaviour. Route to an authoritative doc instead of duplicating it. +- Optimizing an existing skill means the file gets shorter or sharper, not + longer. Delete rules the agent already follows, merge duplicated ones, and + cut every sentence that does not change an action. +- Report what changed in at most three lines, then refresh the reminder stamp + so the next hint is an hour out: `date +%s > ~/.claude/.autotune-stamp`. + +## Rules + +- One skill per run unless the operator picked `Both`; a broad rewrite of the + whole collection is not what autotune is for. +- Never write a skill for one-off work, for secrets, or for behaviour an + existing skill already covers - propose extending that skill instead. +- A skill that saves tokens but loses correctness is a regression; validation, + error handling, and security steps are never the thing that gets trimmed. +- Efficiency claims stay honest: name the observed friction the skill removes, + never a guessed percentage. diff --git a/skills/autotune/hooks/reminder.sh b/skills/autotune/hooks/reminder.sh new file mode 100755 index 0000000..5585763 --- /dev/null +++ b/skills/autotune/hooks/reminder.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# UserPromptSubmit hook: emit the autotune hint at most once per interval. +# Stdout lands in the agent's context, which is why the payload tells the agent +# to show the hint and keep going instead of running autotune by itself. +set -euo pipefail + +interval="${AUTOTUNE_INTERVAL_SECONDS:-3600}" +stamp="${AUTOTUNE_STAMP:-${HOME}/.claude/.autotune-stamp}" + +now="$(date +%s)" +last=0 +if [[ -f "${stamp}" ]]; then + read -r last <"${stamp}" || true + [[ "${last}" =~ ^[0-9]+$ ]] || last=0 +fi + +if ((now - last < interval)); then + exit 0 +fi + +mkdir -p "$(dirname "${stamp}")" +printf '%s\n' "${now}" >"${stamp}" + +cat <<'HINT' +AUTOTUNE HINT: over an hour since the last hint. Show the operator this notice +verbatim as a short block, then continue with their request. Do NOT run +autotune, do not propose skills, do not ask questions about it. + + 💡 /autotune - generate a new skill or optimize an existing one + (fewer tokens, fewer errors, more focus, learnings that persist). + Runs only when you trigger it. +HINT diff --git a/tests/test_autotune.py b/tests/test_autotune.py new file mode 100644 index 0000000..dd11059 --- /dev/null +++ b/tests/test_autotune.py @@ -0,0 +1,92 @@ +"""Validate the autotune reminder hook and its settings registration.""" + +from __future__ import annotations + +import json +import shutil +import subprocess +import tempfile +import unittest +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +SKILL = REPO_ROOT / "skills" / "autotune" / "SKILL.md" +HOOK = REPO_ROOT / "skills" / "autotune" / "hooks" / "reminder.sh" +PATCHER = REPO_ROOT / "scripts" / "enable-plugins.js" + + +def _run_hook(stamp: Path, interval: str = "3600") -> subprocess.CompletedProcess: + return subprocess.run( + ["bash", str(HOOK)], + capture_output=True, + text=True, + check=True, + env={ + "PATH": "/usr/bin:/bin", + "HOME": str(stamp.parent), + "AUTOTUNE_STAMP": str(stamp), + "AUTOTUNE_INTERVAL_SECONDS": interval, + }, + ) + + +class TestAutotuneSkill(unittest.TestCase): + def test_skill_is_trigger_only(self): + text = SKILL.read_text(encoding="utf-8") + self.assertIn("name: autotune", text) + self.assertIn("multiSelect: false", text) + + +class TestAutotuneHook(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.stamp = Path(self.tmp.name) / ".claude" / ".autotune-stamp" + + def test_first_run_hints_and_stamps(self): + result = _run_hook(self.stamp) + self.assertIn("/autotune", result.stdout) + self.assertTrue(self.stamp.is_file()) + + def test_second_run_is_silent_within_interval(self): + _run_hook(self.stamp) + self.assertEqual(_run_hook(self.stamp).stdout, "") + + def test_hint_returns_after_the_interval(self): + _run_hook(self.stamp) + self.assertIn("/autotune", _run_hook(self.stamp, interval="0").stdout) + + def test_corrupt_stamp_does_not_crash(self): + self.stamp.parent.mkdir(parents=True, exist_ok=True) + self.stamp.write_text("not-a-timestamp\n", encoding="utf-8") + self.assertIn("/autotune", _run_hook(self.stamp).stdout) + + +@unittest.skipUnless(shutil.which("node"), "node not installed") +class TestSettingsRegistration(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.settings = Path(self.tmp.name) / "settings.json" + + def _patch(self) -> dict: + subprocess.run(["node", str(PATCHER), str(self.settings)], check=True, capture_output=True) + return json.loads(self.settings.read_text(encoding="utf-8")) + + def test_hook_registered_once(self): + self._patch() + data = self._patch() + entries = json.dumps(data["hooks"]["UserPromptSubmit"]) + self.assertEqual(entries.count("autotune/hooks/reminder.sh"), 1) + + def test_existing_hooks_preserved(self): + self.settings.write_text( + json.dumps({"hooks": {"UserPromptSubmit": [{"hooks": [{"type": "command", "command": "true"}]}]}}), + encoding="utf-8", + ) + data = self._patch() + self.assertEqual(len(data["hooks"]["UserPromptSubmit"]), 2) + + +if __name__ == "__main__": + unittest.main()