Shared prompt-optimization engine. Hill-climbs a prompt template against a project-supplied rubric: mutate → score on held-out-safe fixtures → accept improvements → validate the winner never saw during training. Generic across projects; each project supplies its own **plugin** (target + rubric + fixtures). This skill dir (`engine/`) is symlinked into every project via `~/.claude/skills` — it is READ-ONLY code. Nothing it runs ever writes here.
Installs into .claude/skills of the current project.
Are you the author of Prompt Optimizer?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/masharratt-prompt-optimizer)
# prompt-optimizer
Shared prompt-optimization engine. Hill-climbs a prompt template against a
project-supplied rubric: mutate → score on held-out-safe fixtures → accept
improvements → validate the winner never saw during training. Generic across
projects; each project supplies its own **plugin** (target + rubric +
fixtures). This skill dir (`engine/`) is symlinked into every project via
`~/.claude/skills` — it is READ-ONLY code. Nothing it runs ever writes here.
## Why a shared engine
Multiple projects independently forked a one-off optimizer and hand-rolled
the same fixes (held-out validation, tri-state no-run handling, deterministic
scoring, reject-and-regenerate, cost-aware tie-breaks). This skill is the
one place those fixes live, so every consumer inherits them for free instead
of re-solving the same bugs per project.
## Architecture
```
engine/
types.ts the plugin contract (Target, Rubric, Fixture, RubricScore, Hit)
paths.ts project-local path resolver (BLOCKER-1 — see below)
rubric-core.ts generic aggregate + isImprovement + cost-Pareto tie-break
budget.ts BudgetTracker + per-model pricing table
source-patcher.ts sentinel-delimited source patcher (optional, plugin-invoked)
eval.ts eval loop: tri-state exclusion, temp-0 scoring, reject-and-regenerate
mutator.ts rubric-agnostic strategy-rotation mutator
optimize.ts orchestrator: baseline -> mutate/accept on train -> holdout gate -> report
execute.sh entry point: resolves the project-local plugin, runs optimize.ts
```
## The plugin contract
A project supplies a **plugin**: real TS/JS modules (not declarative config),
because a real rubric needs logic (e.g. computed-age/scene scoring can't be
expressed as JSON).
```ts
interface Target {
id: string;
loadTemplate(): string | Promise<string>;
renderPrompt(template: string, fixture: Fixture): { prompt: string };
// The ONLY place a provider SDK may be imported — never inside engine/*.
generate(prompt: string, options: { temperature: number }): Promise<GenerateResult>;
extractScript(raw: string): { ok: true; text: string } | { ok: false; reason: string };
// Optional — declare only if the target needs it. Both default to
// engine behavior when absent, so every existing plugin keeps working.
evalTemperature?: number; // lowest temp this target can honor (default: 0)
nondeterministic?: boolean; // provider ignores temperature 0 (default: false)
pricing?: { input: number; output: number }; // USD/1M tokens (default: engine's built-in table)
// Optional, all three together: opts into `--apply` source patching (see
// "Source-patch auto-apply" below). Omit all three (default) and this
// target keeps writing only to templates/<id>.md, unchanged.
sourceFile?: string; // path to the prompt's source, relative to the CONSUMING project's cwd
varMap?: Record<string, string>; // {{PLACEHOLDER}} -> local expression string, e.g. { NAME: 'input.name' }
assignmentVar?: string; // local var name for the emitted `const <assignmentVar> = \`...\`;`
}
interface Rubric {
categories: string[];
describe(): string; // fed to the mutator — no engine-owned rubric text
// E1: may return RubricScore directly OR Promise<RubricScore>. The engine
// always awaits it, so an async rubric (LLM-as-judge, network lookup)
// works unchanged next to every existing synchronous rubric. Known limit:
// an async rubric adds one call per scored example and its own cost.
// budget.ts only tracks Target.generate costs, never a rubric's own spend.
score(text: string, ctx: Fixture): RubricScore | Promise<RubricScore>;
regenerateOn?: string[]; // categories that trigger a bounded 1-retry regen
}
interface Fixture {
id: string;
split: 'train' | 'holdout'; // frozen — the holdout gate can never train on these
[key: string]: unknown; // whatever data your renderPrompt/score need
}
```
Notice `mutateTemplate` calls the LLM only through `target.generate` — the
engine reuses the plugin's own provider client for the meta-prompt that asks
the model to rewrite the template. The engine itself never imports a
provider SDK (BLOCKER-2): `grep -rn "from 'openai'" engine/` must return zero,
and it does.
## Project-local plugin convention
```
<project>/.claude/prompt-optimizer/
config.json { "<target-id>": { "target": "./targets/x.ts",
"rubric": "./rubrics/x.ts",
"fixtures": "./fixtures/x.json" } }
targets/<id>.ts implements Target
rubrics/<id>.ts implements Rubric
fixtures/<id>.json Fixture[] with frozen split tags
# all writable below here, created on demand — never under the engine's own dir
state/<id>.json per-iteration log
runs/<id>-<ts>.md human-readable run report
templates/<id>.md the current winning template
backups/<id>-<ts>.md seed backed up here before EVERY overwrite of templates/<id>.md
(never lost — see fix #1 below), plus source-patcher
backups if a plugin invokes it
_budget.json spend ledger. Per-project, SHARED across every
target, and persists across every run forever.
--budget caps only the current run's fresh spend;
--lifetime-budget (optional) caps this ledger's
cumulative total across all runs.
```
Dynamic-imports the `.ts`/`.js` modules from the project's own cwd via
`import()` (works with plain JS or, if run through `npx tsx`, `.ts` directly).
**Absent `config.json`** → the engine prints `no plugin configured` and
exits 0. Inheriting this shared skill never breaks a project that hasn't
opted in.
## BLOCKER-1 — state isolation
Every writable path resolves under `<cwd>/.claude/prompt-optimizer/`
(`engine/paths.ts`), never under the engine's own `SKILL_DIR`. Two projects
running this same symlinked engine write to two disjoint directories —
concurrency-safe by construction, no shared budget ledger, no cross-project
fixture leakage.
## BLOCKER-2 — provider-agnostic engine
`engine/*` imports only `p-limit` and node builtins. A provider client
(OpenAI SDK, xAI, Gemini, whatever) lives ENTIRELY inside a plugin's
`Target.generate`. Peer dependencies each consumer project needs:
- `tsx` — to run `.ts` plugin modules directly (or ship plugins as plain `.js`)
- its own provider SDK (`openai`, `@google/genai`, etc.) — inside the plugin only
- `p-limit` — eval concurrency (already a dependency here)
- `vitest` — to run the engine's own test suite in that project, if desired
## Engine fixes baked in
1. **Held-out split** (`optimize.ts`) — mutate/accept only on `split:'train'`;
score the winner ONCE (or, for a nondeterministic target, `--holdout-repeats`
times — see fix #3 below) on `split:'holdout'` after convergence. If
holdout regresses relative to the baseline's holdout score, the run is
labeled **OVERFIT** and the win is refused — the reported/persisted
template reverts to the baseline. Before ANY overwrite of
`templates/<id>.md`, the prior template is backed up to
`backups/<id>-<ISO-timestamp>.md` and a unified diff (seed vs. final) is
appended to the run report — the human-authored seed is never destroyed.
2. **Tri-state no-run exclusion** (`eval.ts`, `rubric-core.ts`) — an
`extractScript` `ok:false` result (parse failure / refusal / empty) is
excluded from the aggregate and counted separately. The eval aborts if
fewer than 50% of fixtures produced a scoreable result.
3. **Temperature-0 scoring, with an opt-out** (`eval.ts`) — eval pins
`temperature: 0` for a stable ranking by default. A target that cannot
honor temperature 0 (e.g. a provider that rejects any temperature but 1)
declares `Target.evalTemperature` to override this; the engine then
stamps a **NONDETERMINISTIC SCORING** warning into the run report and
per-iteration state, since every total in that run is noisy rather than a
clean deterministic measurement.
**A provider may accept temperature 0 and ignore it.** Measured true for
xAI Grok: two runs of one byte-identical prompt set gave train baselines of
3, 8 and 5, and a direct two-call probe returned 175 vs 201 words diverging
at word 5. Nondeterminism is a property of the provider, not of the number
we send it, so such a target declares `nondeterministic: true` and gets the
same warning and repeat sampling. Without it the riskiest case had the
LEAST protection, silently reporting noise as an exact measurement.
Declaring `nondeterministic: false` cannot suppress a non-zero
`evalTemperature` — a target may not opt out of noise it demonstrably
generates.
Repeat sampling covers the HOLDOUT gate only. Train comparisons stay
single-sample, so on a nondeterministic target an accepted train win reads
honestly as "did not regress", not "improved".
For a nondeterministic target, the
holdout gate (fix #1) re-scores baseline AND final `--holdout-repeats`
times (default 2) and requires the final to beat the baseline on EVERY
repeat; mixed results (wins some, loses others) are labeled
**INCONCLUSIVE** and the win is refused, same as OVERFIT — a noise floor
that catches the case where a single lucky/unlucky sample would otherwise
accept or reject a win on chance alone. Deterministic targets keep the
original single-sample holdout check, no extra cost.
4. **Reject-and-regenerate** (`eval.ts`) — a rubric hit in a
`regenerateOn` category triggers exactly ONE regeneration with a
plugin-supplied corrective nudge appended to the prompt, then scores the
retry result as-is (never a second retry).
5. **Cost-Pareto tie-break + sample-count integrity** (`rubric-core.ts`) —
`isImprovement` requires no per-category regression; on an exact tie,
prefers the candidate with fewer prompt tokens. A pure tie with no cost
info is not accepted.
Every comparison is a **per-example rate** (`total / ranCount`), never a
raw sum, and a candidate that ran on FEWER examples than the incumbent is
refused outright. An excluded example contributes 0 to the sum, so
comparing raw sums let a template win by breaking its own output. Found
live (L9): a candidate scoring `total=2 ran=11 excluded=4` was accepted
over a `total=3 ran=15 excluded=0` baseline, having improved nothing except
how many generations failed extraction. Rate normalisation alone does not
close this — 2/11 beats 3/15 — because the examples an exclusion removes
are the hard ones. The ran-count floor is the load-bearing guard.
The floor is deliberately strict: a real win that incidentally loses one
example is refused too. Nothing distinguishes "dropped a hard example" from
"dropped one at random", and refusing a real win costs one iteration while
accepting a fake one corrupts the template that ships.
6. **Non-throwing, overridable pricing** (`budget.ts`) — `costFor` never
throws on an unknown model: a `Target.pricing` override (USD per 1M
tokens) is preferred when supplied, else the engine's built-in table;
an unknown model with neither warns loudly and records cost as `$0`
rather than crashing the shared budget ledger or silently misreporting
spend.
7. **Rubric saturation note** (`optimize.ts`) — when the accepted/final
train total hits exactly `0`, the report notes **RUBRIC SATURATED
(total=0, no remaining signal)** — distinct from normal convergence,
since a zero total means the rubric has no more violations left to find.
8. **Per-run budget, with an optional lifetime cap** (`budget.ts`,
`optimize.ts`): `_budget.json` persists `spentUsd` FOREVER, shared across
every target in the project. `--budget=N` caps only THIS run
(`BudgetTracker.runSpent`, which always starts at `0`); it is never
compared against the ledger's cumulative lifetime total. An OPTIONAL
`--lifetime-budget=N` sets an absolute ceiling across every run that ever
wrote to the ledger. `BudgetTracker.exhausted()` trips on either cap;
`trippedCap()` reports which one (`'run' | 'lifetime' | null`) so every
print/abort message states which cap tripped and shows both numbers, e.g.
`[abort] run budget exhausted ($0.4501 of $0.45 this run; $1.0528
lifetime)`. The run report and per-iteration state record carry both
`runSpendUsd` and the lifetime `spendUsd`, never only the lifetime figure.
9. **Async rubric scoring** (`types.ts`, `eval.ts`): `Rubric.score` may
return `RubricScore` or `Promise<RubricScore>`; the engine always
`await`s it, so an LLM-as-judge or network-backed rubric works unchanged
next to every existing synchronous rubric. Known limit: an async rubric's
own call cost is NOT tracked by the budget ledger (only `Target.generate`
costs are recorded there).
10. **Source-patch auto-apply, opt-in via `--apply`** (`optimize.ts`,
`source-patcher.ts`, `types.ts`): DEFAULT OFF. A target that declares
`sourceFile` + `varMap` + `assignmentVar` together (see the plugin
contract above) can be patched directly: the engine replaces the region
between `// PROMPT-OPTIMIZER:START id=<target-id>` and
`// PROMPT-OPTIMIZER:END` in that file with the winning template. The
patch is attempted ONLY when ALL of these hold, so a refused or
unchanged result can never reach a real source file:
- `--apply` was passed, AND
- the target declares `sourceFile` (with `varMap`/`assignmentVar`), AND
- the run was NOT `--dry-run`, AND
- the holdout gate produced a REAL win: not OVERFIT, not
HOLDOUT INCONCLUSIVE, and the final template actually differs from
the baseline template.
Before writing, the replaced region is backed up to
`backups/<id>-<ISO-timestamp>.txt` (a distinct filename/extension from
the `.md` template backups already written there). The apply step never
throws: a missing file, a missing or malformed sentinel pair, or any
other patcher error is caught, reported as a warning line, and recorded
on the run's `applyResult` field and in the run report, without failing
the run or losing the report. Every existing plugin (no `sourceFile`
declared, or `--apply` never passed) is completely unaffected.
## Usage
```bash
./.claude/skills/prompt-optimizer/execute.sh <target-id> \
[--dry-run] [--apply] [--budget=N] [--lifetime-budget=N] [--max-iters=N] [--patience=N] [--holdout-repeats=N]
```
Run from inside the consuming project (or `execute.sh` `cd`s there via its
own resolution — see `execute.sh`). Resolves `<project>/.claude/prompt-optimizer/config.json`,
loads the named target's plugin, and runs `engine/optimize.ts` via `npx tsx`.
`--budget=N` (default 5.0) caps THIS run only. `_budget.json` is a single
ledger, per-project, SHARED across every target in that project, and its
`spentUsd` persists across every run forever. `--budget` is compared only
against this run's own fresh spend, never against that cumulative total.
`--lifetime-budget=N` (default: unset, no lifetime ceiling) is an OPTIONAL
absolute cap across every run that ever wrote to the ledger. Use this when
you want a hard stop on total project spend in addition to the per-run cap.
`--holdout-repeats=N` (default 2) — only takes effect for a target the engine
considers nondeterministic: `evalTemperature` is not 0, OR the target declares
`nondeterministic: true` (see fix #3 and L10). Ignored (no extra API calls)
for deterministic targets.
`--apply` (default OFF): see fix #10 above. Only patches a target's
declared `sourceFile` when the run produced a real, non-refused win and the
run was not `--dry-run`. Every target without a declared `sourceFile` is
unaffected regardless of this flag.
## Testing
Engine tests live at `engine/*.test.ts`, run with vitest, provider fully
mocked (no live API calls, no `openai` import anywhere in engine code):
```bash
npx vitest run .claude/skills/prompt-optimizer/engine --reporter=verbose
```