Compare commits

..

3 Commits

Author SHA1 Message Date
Kevin Veen-Birkenbach
cd32841847 feat(refactor): require a test net and SRP, SPOT, KISS, DRY
Some checks failed
🧪 Test / 🧪 Lock + lint (push) Has been cancelled
🔄 Update / 🧠 Update skills-lock.json (push) Has been cancelled
Before editing, the skill now establishes a safety net: run existing
coverage for a green baseline, or write characterization tests against
the current behaviour where the code is testable logic. Those tests must
still pass unchanged afterwards, so a test that had to be edited means
the refactor moved behaviour and gets reverted.

The refactor itself is now governed by SRP, SPOT, KISS and DRY, and no
file containing program code may exceed 250 lines. Description and
markup languages are exempt, as is a file the language or framework
genuinely forbids splitting.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 18:35:04 +02:00
Kevin Veen-Birkenbach
1bc7711f38 feat(refactor): add skill for cleaning up the uncommitted change set
The skill refactors every file that is currently uncommitted in the git
working tree up to the project's coding standards, without changing
behaviour and without widening the scope to untouched files.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 18:35:04 +02:00
Kevin Veen-Birkenbach
27aaabf305 feat(autotune): add skill for generating and tuning skills
The skill generates new skills and optimizes existing ones for token
usage, error rate, focus and persisted learnings. Every decision is
confirmed through single-select (radio button) questions, and the skill
only runs on an explicit operator trigger.

A UserPromptSubmit hook reminds the operator at most once an hour that
/autotune exists; the reminder is a hint for the operator, never a
trigger for the agent. The install-time settings patch registers it
alongside the caveman and ponytail plugins.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 18:35:03 +02:00
6 changed files with 340 additions and 5 deletions

View File

@@ -26,7 +26,7 @@ Copy the skills into a specific project (`<repo>/.agents/skills` and `<repo>/.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.

View File

@@ -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}`);

92
skills/autotune/SKILL.md Normal file
View File

@@ -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/<name>/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.

View File

@@ -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

108
skills/refactor/SKILL.md Normal file
View File

@@ -0,0 +1,108 @@
---
name: refactor
description: >
Refactor every file that is currently uncommitted in the git working tree -
staged, unstaged and untracked - up to the project's coding standards,
without changing behaviour. Trigger on /refactor or when the operator asks to
clean up, tidy, or polish the current changes before committing. Portable
across projects.
---
The unit of work is the uncommitted change set, not the repository. Behaviour
stays identical: a refactor that changes what the code does is a bug, not a
cleanup.
## Step 1: fix the file set
Read the set from git, never from memory of what was edited:
```bash
git status --porcelain
```
Take modified, added, renamed and untracked files. Drop deleted files, binaries,
lockfiles, vendored trees and generated output. Restate the resulting list in
one line before touching anything; if it is empty, say so and stop.
Never `git stash` and never revert a working-tree file to compare - other agents
and the operator share this tree. Read the committed version with
`git show HEAD:<path>` into a temporary file instead.
## Step 2: get a safety net first
A refactor is only provably behaviour-preserving if something failed before it
and passes after it. Before editing, check what covers the change set:
- **Coverage exists**: run it now and record the green baseline. A suite that
was already red gets reported, not refactored around.
- **No coverage and the code is testable logic**: write the characterization
tests first, against the current behaviour - including the behaviour you
consider wrong, so the refactor cannot silently change it. Keep them small
and framework-free unless the project already has a framework. Fix the
captured wrongness in a separate change, never in this one.
- **Not sensibly testable**: pure glue, declarative config, a thin wrapper over
a service the test would have to mock into meaninglessness, or a project with
no test setup at all. Say so in one line and refactor without a net; do not
invent a test harness the project never asked for.
The new tests are part of the deliverable and must still pass unchanged after
the refactor. A test that had to be edited to stay green means the behaviour
moved: revert the refactor, not the test.
## Step 3: refactor each file
Read the whole file first, then apply the standards that the surrounding code
and the project's own config already establish (formatter config, linter rules,
existing idioms) over any generic preference of your own.
Four principles govern the result:
- **SRP**: one file, one responsibility; one function, one reason to change. A
unit that needs an "and" to describe it is two units.
- **SPOT**: every fact - a constant, a path, a schema, a rule - lives in exactly
one place and everything else references it.
- **KISS**: the simplest construct that works. No abstraction without a second
caller today, no configuration for a value that never varies.
- **DRY**: the same logic is written once. A literal repeated three times is a
missing constant.
Concretely:
- **File size**: no file containing program code exceeds 250 lines. Split the
overflow along the SRP seam - it is the symptom, not the cause. Description
and markup languages are exempt (Markdown, YAML, JSON, HTML, translation and
data files), as is the rare file the language or framework genuinely forbids
splitting: name that reason in the report, "it was easier" does not qualify.
- **Duplication**: fold repeated logic into the helper that already exists;
create a new one only when two or more call sites need it now.
- **Dead weight**: unused imports, variables, parameters, branches that cannot
be reached, flags with a single value, abstractions with one implementation.
- **Naming**: names that state what a thing is, matching the file's convention.
- **Shape**: split a function that needs a scroll to read; collapse nesting by
returning early; replace magic values with a named constant at its use site.
- **Errors**: never widen a caught exception, never swallow one, never remove a
validation or a security check to make the code shorter.
- **Comments**: invoke the `comments-clean` skill rather than reimplementing its
rules here.
Do not reformat regions you have no substantive fix for - cosmetic churn buries
the real change in the diff. If a file's problem is architectural and cannot be
fixed inside the change set, report it instead of half-doing it.
## Step 4: keep behaviour provable
- Re-run the step 2 net and require the same green result as the baseline.
- Never fold a bug fix, a feature, or a dependency change into a refactor.
Found a real bug? Name it in the report and leave it for a separate change.
- Run the cheap checks the project already has for the touched files (formatter,
linter, the module's own unit tests). Do not start a full suite, a build, or a
deploy on your own initiative - propose it and let the operator trigger it.
- State plainly what was verified and what was not. An unverified refactor is
reported as unverified.
## Step 5: report
One line per file: what changed and why it is safe. Then one line naming what
was deliberately left alone (architectural findings, bugs, files skipped) and
the exact command the operator can run to validate. Do not commit unless the
operator asked for a commit.

92
tests/test_autotune.py Normal file
View File

@@ -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()