SLOPSHOPPER

prompt-boost

Analysiert und optimiert Prompts und Agenten-Aufträge passend zum aktiven Modell und macht Subagenten sparsam: Lagebild, schlanke Varianten, Briefing-Gate…

newpanebandguardcommandtoast
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · prompt-boost
│ ┃ prompt-boost ✕ › fix the failing auth test and add an audit log call │ ┃ Letzter Prompt │ ┃ claude-opus-5-5 · Profil Opus/Fable · Modus ⏺ Read(src/auth.ts) │ ┃ ergaenzen · Komplexität mittel ⎿ Read 6 lines │ ┃ Unverändert: Optimierung fehlgeschlagen ⏺ Update(src/auth.ts) │ ┃ (keine lesbare Antwort), Original gesendet. ⎿ Added 2 lines, removed 1 line │ ┃ Agenten ⏺ Bash(bun test) │ ┃ Noch kein Agent gestartet. ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /prompt-boost │ ⎿ prompt-boost: Modus: ergänzen (Original bleibt, Briefing als Zus │ ⎿ prompt-boost: Aktives Modell: claude-opus-5-5 (Profil Opus/Fable │ ⎿ prompt-boost: Optimierer: sonnet · Verlaufsanalyse an · Mindestl │ ⎿ prompt-boost: Agenten: Umschreiben an · Regel vorher fragen │ ⎿ prompt-boost: Sparsame Agenten: Varianten an · Gate an · Lagebil │ ⎿ prompt-boost: Bewertungen: 0 gespeichert · Band an │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ prompt-boost: unverändert · Optimierung fehlgeschlagen (keine lesbare Antwort), Original

Draws

Pane · prompt-boost
Letzter Prompt claude-opus-5-5 · Profil Opus/Fable · Modus ergaenzen · Komplexität mittel Unverändert: Optimierung fehlgeschlagen (keine lesbare Antwort), Original gesendet. Agenten Noch kein Agent gestartet.
README

prompt-boost

Eine Mod für Claude Code, die jeden Prompt analysiert, passend zum gerade gewählten Modell optimiert und Claude mit der verbesserten Fassung arbeiten lässt. Sie verbessert auch die Aufträge an Subagenten, macht Subagenten sparsam und lernt aus deinen Bewertungen, wie Briefings für dich aussehen sollten.

Ziel ist effizienteres und genaueres Arbeiten mit Claude. Gleichzeitig zeigt dir die Mod nach jedem Prompt, wie du ihn selbst besser hättest schreiben können.

Version 0.4.2 · Autor Wilhelm Peters · Lizenz MIT


Was sie macht

SchrittWas passiert
1. AnalyseEin Optimierer-Modell (Standard Sonnet) liest deinen Prompt. Es prüft Absicht, Lücken, Annahmen und Komplexität und entscheidet, ob sich eine Optimierung lohnt.
2. OptimierungDas Briefing wird auf das aktive Modell zugeschnitten (siehe Modellprofile).
3. ArbeitenDein Originaltext bleibt unverändert. Das Briefing geht als unsichtbarer Zusatzkontext ans Modell mit, und bei Widersprüchen gilt dein Original.
4. AgentenJeder Subagent-Auftrag wird vor dem Start geprüft, wenn möglich auf eine schlanke Variante umgeleitet, umgeschrieben und mit Lagebild und Arbeitsvertrag versehen. Ein Abschnitt im Systemprompt regelt, wann und wie Claude Agenten überhaupt anlegt (siehe Sparsame Agenten).

Dazu kommen diese Funktionen:

  • Verlaufsanalyse. Bezieht sich dein Prompt auf Früheres („Punkt 2 von oben"), liest ein zweiter Durchlauf den Gesprächsverlauf mit.
  • Rückfragen. Was sich nicht sinnvoll annehmen lässt, klärt Claude vor der Arbeit gebündelt mit dir, als Auswahl mit Empfehlung. Mögliche Rückfragen prüft vorher ein Blick in den Gesprächsverlauf, was dort schon steht, fällt weg.
  • Statusleiste. Über der Eingabe siehst du, in welcher Phase die Verarbeitung steht.
  • Verbesserter Prompt im Verlauf. Du siehst dein Original, die verbesserte Fassung und 1 bis 3 konkrete Schreibtipps.
  • Lernschleife. Nach optimierten Antworten bewertest du mit Gut oder Daneben. Alle 5 Bewertungen leitet die Mod daraus deinen persönlichen Prompt-Stil ab.
  • Modell-Tipp. Ein Hinweis kommt, wenn das gewählte Modell klar zu groß oder zu klein für die Aufgabe ist.
  • Berichtsprüfung. Fehlen im Bericht eines Agenten Belege oder der Abschnitt „Nicht geprüft", bekommst du einen Hinweis, und Claude prüft selbst nach.
  • Skill-Vorschlag. Passt ein Eintrag aus der Kernliste deines Skill-Routers, schlägt die Mod den Skill vor.

Installation

Die Mod liegt in ~/Documents/Claude Mods/prompt-boost. Der Ordner ~/Documents/Claude Mods ist zugleich der lokale Marketplace willis-mods.

claude plugin marketplace add "$HOME/Documents/Claude Mods"
claude plugin install prompt-boost@willis-mods --scope user

Danach lädt die Mod in jeder neuen Session, im Terminal wie in der Desktop-App. Die installierte Mod liest direkt aus dem Ordner. Nach Änderungen am Code reicht deshalb /reload-plugins, eine Neuinstallation ist nicht nötig.

Prüfen, ob sie aktiv ist:

claude plugin list
claude -p "/prompt-boost status"

Bedienung

Befehle

BefehlWirkung
/prompt-boost oder /prompt-boost statusZeigt Modus, aktives Modell, Einstellungen und die letzte Analyse.
/prompt-boost anModus „ergänzen" (Standard). Das Original bleibt, das Briefing geht als Zusatzkontext mit.
/prompt-boost ersetzenDas Briefing ersetzt den Prompt, dein Wortlaut hängt darunter.
/prompt-boost ausSchaltet Prompt-Optimierung, Agenten-Umschreiben und Agenten-Leitfaden ab.
/prompt-boost zeigenÖffnet das Panel mit der letzten Analyse und den letzten Agenten.
/prompt-boost probe <Text>Trockenlauf. Zeigt Analyse, Briefing und Schreibtipps, ohne etwas zu senden.
/prompt-boost stilZeigt deinen gelernten Prompt-Stil.
/prompt-boost stil zurücksetzenLöscht Stil und alle Bewertungen.
/lagebildZeigt das Lagebild, das der nächste Agent mitbekommt.
/agent-kostenMisst die Tokenkosten deiner Subagenten vor und seit „Sparsame Agenten“ und prüft die Ziele.

Modus und Stil gelten sitzungsübergreifend.

Einen Prompt unverändert senden

Schreib roh: davor:

roh: mach einfach weiter

Ohne Analyse gehen auch Prompts unter 40 Zeichen raus, Slash-Befehle und alles über 20.000 Zeichen (meist eingefügtes Material).

Was du siehst

Statusleiste über der Eingabe, während die Mod arbeitet:

prompt-boost  … Analyse  ○ Verlauf  ○ Briefing  · analysiert deinen Prompt
prompt-boost  ✓ Analyse  – Verlauf  ✓ Briefing  · fertig in 4,8 s · verbesserte Fassung steht im Verlauf  [Details]
ZeichenBedeutung
…läuft
✓erledigt
○wartet
–übersprungen
✗fehlgeschlagen

Eintrag im Verlauf nach jeder Analyse. Den siehst nur du, er geht nicht ans Modell:

prompt-boost · optimiert für Opus/Fable · 4,8 s

Dein Prompt:
mach mal die website schneller, die lädt ewig

Verbesserte Fassung:
## Ziel
Die Ladezeit der Website soll messbar verkürzt werden.
…

So schreibst du es nächstes Mal gleich so:
• Nenne das Projekt oder den Ordner und die URL der Website.
• Beschreibe „lädt ewig" mit einer Zahl und nenne einen Zielwert.
• Gib den Stack an, weil davon die Maßnahmen abhängen.

Bewertungsband nach einer optimierten Antwort, mit Gut, Daneben und Ausblenden. Bei „Daneben" kannst du einen Satz dazu schreiben, was gefehlt hat. Das Band verschwindet mit deinem nächsten Prompt.


Einstellungen

Zu ändern im Terminal mit /plugin configure prompt-boost@willis-mods oder beim Installieren mit --config schluessel=wert.

SchlüsselTypStandardBedeutung
optimizerModelhaiku · sonnet · opussonnetWelches Modell analysiert und umschreibt. haiku ist am schnellsten.
minCharsZahl40Kürzere Prompts werden nicht analysiert.
optimizeAgentsja/neinjaSubagent-Aufträge umschreiben und Arbeitsvertrag anhängen.
contextAnalysisja/neinjaZweiter Durchlauf mit Gesprächsverlauf, wenn der Prompt darauf verweist.
askRatingja/neinjaBewertungsband nach optimierten Antworten.
agentPolicyfragen · freifragenfragen: Claude fragt vor jedem Agenten um Erlaubnis. frei: Claude entscheidet selbst.
routerPathPfad~/.claude/skill-router/ROUTER.mdSkill-Router mit Abschnitt „Kernliste". Leer schaltet Skill-Vorschläge ab.
showBriefja/neinjaOriginal, verbesserte Fassung und Schreibtipps im Verlauf zeigen.
showProgressja/neinjaStatusleiste über der Eingabe.
leanAgentsja/neinjaSchlanke Varianten registrieren und general-purpose passend umleiten.
briefGateja/neinjaBriefing ohne Ziel, Fertig wenn oder Rückgabe einmal zurückweisen.
lagebildja/neinjaJedem neuen Agenten das Lagebild mitgeben, Werkzeug Lagebild anbieten.
forkGateja/neinjaForks erst nach deiner Zustimmung im Chat.
roundWarningja/neinjaNotiz an den Agenten bei Runde 25 und 40.
agentDisplayja/neinjaLaufende Agenten in der Statusleiste, Kostenzeile nach dem Ende.

Modellprofile

Die Mod liest das aktive Modell und schreibt das Briefing passend dazu.

ProfilModelleSo wird das Briefing geschrieben
Opus/Fableopus, fableZiel, Zweck, Kontext und Qualitätsmaßstab. Keine Mikro-Schritte, dafür Abwägungen und Prüfung gegen das Endkriterium.
SonnetsonnetKlare Gliederung mit Anforderungen als Liste, Endkriterium und Ausgabeformat. Bei mehreren Schritten eine Reihenfolge.
HaikuhaikuEine eng umrissene Aufgabe, kurze direkte Sätze, explizite Schritte, festes Format, bei Bedarf ein Beispiel.

Subagenten bekommen das Profil ihres Modells, nicht das der Hauptsession.

Modell-Tipp: Bei einfachen Aufgaben schlägt die Mod auf Opus Haiku oder Sonnet vor, auf Sonnet nur Haiku. Bei schweren Aufgaben auf Haiku oder Sonnet schlägt sie das größere Modell vor. Bei normaler Arbeit bleibt sie still.


Agenten

Leitfaden im Systemprompt. Mit agentPolicy: fragen startet Claude Agenten nur auf deine ausdrückliche Ansage. Hält Claude einen Agenten für nötig, fragt es vorher mit Zweck und grober Kostenschätzung. Außerdem prüft Claude, ob die Aufgabe nicht selbst zu erledigen ist, wählt das kleinste passende Modell und brieft den Agenten wie eine Kollegin ohne Vorwissen.

Umschreiben. Jeder Agent-Auftrag wird zu einem eigenständigen Briefing mit Ziel, Kontext, Grenzen, Endkriterium und Rückgabeformat. Ist die Umschreibung kürzer als 60 % des Originals, wird sie verworfen, weil dann vermutlich Details fehlen.

Arbeitsvertrag. An jeden Auftrag wird angehängt, dass der Agent im Umfang bleibt, nichts Ungeprüftes behauptet und mit einem Bericht in vier Teilen abschließt:

  1. Ergebnis
  2. Belege
  3. Nicht geprüft oder unsicher
  4. Offene Fragen

Berichtsprüfung. Fehlt einer der ersten drei Teile, bekommst du einen Hinweis, und Claude liest die Aufforderung, die Aussagen selbst zu prüfen.

Nicht angefasst werden Forks (die den Verlauf ohnehin erben) und Agenten aus Workflows (deren Auftrag sich nicht ändern lässt).

Sparsame Agenten

Gemessen an 365 Läufen vor dieser Version kostet ein Subagent im Median 68k Tokens beim Start und braucht 54 Runden. Insgesamt verarbeitet er 7,8 Mio. Tokens Input, der Start macht davon nur etwa 1 % aus. Teuer ist also nicht der Start, sondern Sockel mal Runden. Der feste Sockel (Systemprompt, Werkzeuge, CLAUDE.md, Regeln) läuft in jeder Runde mit, und ein Agent mit dünnem Briefing braucht viele Runden, um sich zu erarbeiten, was die Hauptsession schon weiß. Diese Version setzt an beiden Faktoren an.

BausteinWas passiert
LagebildJeder Agent außer Forks bekommt einen Abschnitt mit Arbeitsverzeichnis, Ziel der Session, Git-Stand, den in der Hauptsession gelesenen und geänderten Dateien sowie den Notizen aus dem Werkzeug Lagebild (Befund, Ausgeschlossen, Entscheidung). Höchstens etwa 1.500 Tokens, die ältesten Notizen fallen zuerst heraus.
Schlanke Variantenprompt-boost:suche (haiku, Read, Grep, Glob, Bash), prompt-boost:umsetzung (sonnet, dazu Edit und Write), prompt-boost:pruefung (sonnet, lesend plus Bash). Sie laden weder CLAUDE.md noch Regeln und haben einen eigenen Regelkern von etwa 300 Tokens.
UmleitungEin general-purpose-Auftrag geht nach Stichworten im Ziel: an die passende Variante. Ist der Auftrag gemischt oder unklar, geht er an umsetzung. Braucht er Browser, MCP, Web oder Agenten, bleibt es bei general-purpose. Andere Agententypen bleiben unberührt.
ModellEin ausdrücklich gesetztes Modell bleibt. Sonst gilt das Modell der Variante, opus wird nie automatisch gewählt.
Briefing-GatePflicht sind Ziel:, Fertig wenn: und Rückgabe:. Fehlt eines, wird der Start einmal mit einer Vorlage abgelehnt, der zweite Versuch mit derselben Beschreibung geht immer durch.
Fork-SperreEin Fork erbt den ganzen Verlauf (bis zu 180k Tokens Start). Er wird abgelehnt, bis du im nächsten Prompt zustimmst („ja“, „ok“, „fork“). Dann geht genau einer durch.
RundenwarnungBei Runde 25 und 40 bekommt der Agent eine Notiz, mit dem Bericht abzuschließen, wenn „Fertig wenn“ erreicht ist.
AnzeigeLaufende Agenten stehen in der Statusleiste (Variante, Runde, Kontext). Nach dem Ende kommt eine Kostenzeile in den Verlauf, die nur du siehst.

Gate, Sperre und Umleitung gelten nur für Agenten, die das Modell startet. Starts aus dem Code anderer Mods, Workflow-Agenten und Teammates bleiben unberührt. /agent-kosten vergleicht ab dem ersten Start dieser Version mit den 28 Tagen davor. Ziel ist ein Start unter 30k und jeweils mindestens 40 % weniger Runden und Gesamtinput.


Kosten und Wartezeit

WannZusätzlicher Aufruf
Jeder analysierte Prompt1 Aufruf des Optimierer-Modells (Standard Sonnet, geringer Denkaufwand, höchstens 25 s)
Prompt mit Verweis auf den Verlaufzusätzlich 1 Durchlauf über das Gespräch mit dem Hauptmodell, der größte Teil kommt aus dem Prompt-Cache
Jeder Subagent1 Aufruf des Optimierer-Modells, dazu ein git status für das Lagebild
Alle 5 Bewertungen1 Aufruf zum Ableiten des Stils

Die Analyse verzögert den Start jeder Antwort um einige Sekunden. Geht dir das zu langsam, stell optimizerModel auf haiku. Scheitert der Optimierer (Überlast, Zeitlimit, unlesbare Antwort), geht dein Original unverändert raus. Die Mod blockiert also nie deine Arbeit.


Daten

WasWoUmfang
ModusPlugin-Speicher von Claude Code (mode)ein Wert
BewertungenPlugin-Speicher (feedback)höchstens 60, je Absicht, Modell, Bewertung, Notiz, Zeitpunkt
Gelernter StilPlugin-Speicher (style)höchstens 10 Stichpunkte
Letzte Analyse, letzte 20 Agenten, StatusleisteSitzungszustandnur für die laufende Session
Lagebild, Agentenzähler, Fork-FreigabeSitzungszustandnur für die laufende Session, nichts landet im Projekt
Startzeitpunkt für /agent-kostenPlugin-Speicher (leanSinceAt)ein Zeitstempel

Prompts und Analysen laufen über den API-Zugang deiner Claude-Session, also über denselben Weg wie deine normalen Anfragen an Claude. Ein Drittdienst ist nicht beteiligt. /prompt-boost stil zurücksetzen löscht Stil und Bewertungen.


Grenzen

  • Ohne Verlauf. Der erste Analysedurchlauf sieht den Gesprächsverlauf nicht. Er erkennt Verweise auf den Verlauf und gibt dann an den zweiten Durchlauf ab. Hat er Rückfragen, läuft der zweite Durchlauf ebenfalls und streicht, was der Verlauf schon beantwortet. Ist contextAnalysis aus, bleiben die Fragen ungeprüft.
  • Nur für Getipptes. Die Mod greift nur bei Prompts, die du selbst tippst, in der Session oder über Remote Control. Prompts aus Skripten (claude -p, SDK), Benachrichtigungen und anderen Sessions bleiben unberührt.
  • Kein Notizfeld auf dem Handy. Die mobile App hat kein Eingabefeld, dort speichert „Daneben" ohne Notiz.
  • Kürzere Anzeige im Terminal. Das Terminal zeigt vom Verlaufseintrag die ersten 2000 Zeichen, die Desktop-App bis zu 10.000.
  • Faustregel für Berichte. Die Berichtsprüfung sucht nach Stichworten (Ergebnis, Belege, Nicht geprüft). Sie ist eine Faustregel, keine inhaltliche Prüfung.
  • Faustregel für Umleitung und Gate. Beide erkennen Feldnamen und Stichworte, sie verstehen den Auftrag nicht. Eine Fehlzuordnung fällt im Zweifel auf umsetzung, also auf die Variante mit den meisten Werkzeugen.
  • Varianten ohne CLAUDE.md. Was nicht im Regelkern oder im Briefing steht, weiß eine Variante nicht. Fachagenten wie steuerberater bleiben deshalb unverändert.
  • Lagebild nur aus der Hauptsession. Dateien, die ein Agent liest, landen nicht im Lagebild, nur die der Hauptsession.

Entwicklung

Aufbau

prompt-boost/
├── .claude-plugin/plugin.json   Manifest und Einstellungen
├── hooks/
│   ├── hooks.json               verweist auf register.tsx
│   ├── register.tsx             Prompt-Hooks, Befehle, Statusleiste, Panel
│   ├── agent-watch.ts           Agentenstart, Rundenzähler, Lagebild-Werkzeug, /lagebild, /agent-kosten
│   ├── lean-agents.ts           Varianten, Umleitung, Gate, Fork-Freigabe, Texte
│   ├── lagebild.ts              Lagebild-Zustand und Darstellung
│   ├── state.ts                 gemeinsame Startwerte des Sitzungszustands
│   ├── optimizer.ts             Anfragen an den Optimierer, Auswertung, Modell-Tipp
│   ├── model-profiles.ts        Modellfamilien und ihre Schreibweise
│   ├── agent-guide.ts           Agenten-Leitfaden und Arbeitsvertrag
│   ├── display.ts               Texte für Statusleiste und Verlaufseintrag
│   ├── feedback.ts              Bewertungen und Stil-Ableitung
│   ├── reports.ts               Prüfung von Agenten-Berichten
│   └── router.ts                Kernliste aus dem Skill-Router
├── types/index.d.ts             Typen und Vertrag für den Sitzungszustand
├── scripts/agent-kosten.py      Kostenmessung aus den Subagent-Transkripten
└── tests/                       prompt-boost.test.ts, lean-agents.test.ts

Vier Regeln der Engine bestimmen den Aufbau.

  1. Das Engine-Objekt $ darf nur an Funktionen gehen, die in derselben Datei stehen, nie über einen Import.
  2. Ein Plugin hat genau ein Hooks-Modul. register.tsx reicht on an registerAgentWatch in agent-watch.ts weiter, das ist erlaubt.
  3. Ein Ereignis ohne Matcher darf nur einmal registriert sein. session.start, prompt.submit und turn.complete gehören deshalb register.tsx.
  4. Atome müssen in der Datei deklariert sein, die sie liest. Beide Dateien deklarieren dieselben Schlüssel, und state.ts liefert nur die Startwerte.

Alle übrigen Module sind reine Funktionen ohne Zugriff auf $ und dadurch ohne Engine testbar.

Benutzte Hooks

HookZweck
prompt.submitAnalyse, Optimierung, Kontext, Statusleiste, Verlaufseintrag
session.startBefehle, Varianten und Werkzeug Lagebild registrieren
agent.spawnFork-Sperre, Gate, Umleitung, Umschreiben, Lagebild, Vertrag
turn.stepRunden und Kontext je Subagent zählen, Rundenwarnung
turn.completeBewertungsband anbieten, Agenten-Bericht prüfen, Kostenzeile
tool.call (Agent)Hinweis zu einem Bericht ans Ergebnis hängen
tool.call (Read, Edit, Write, Lagebild)Lagebild der Hauptsession führen
prompt.composeAgenten-Leitfaden in den Systemprompt
ui.render (AbovePrompt, Pane)Statusleiste, Bewertungsband, Panel
command.run/prompt-boost, /lagebild, /agent-kosten

Prüfen

claude plugin validate "$HOME/Documents/Claude Mods/prompt-boost"
claude plugin test "$HOME/Documents/Claude Mods/prompt-boost"
claude -p "/prompt-boost probe mach mal die website schneller"

Die Tests simulieren die Modellaufrufe. Sie decken Analyse, Modi, Ausnahmen, Verlaufsanalyse, Rückfragen, Modell-Tipp, Skill-Router, Bewertung und Stil-Ableitung ab, außerdem Agenten, Berichtsprüfung, Statusleiste und Verlaufseintrag. Der echte Weg lässt sich mit probe prüfen, das ruft das Optimierer-Modell wirklich auf.

Weitergeben

Die Mod liegt öffentlich unter github.com/ptrzptrz888-create/claude-mods. Jemand anderes installiert sie in einer Claude-Code-Session im Terminal mit:

/plugin install prompt-boost --marketplace ptrzptrz888-create/claude-mods

Die Frage „Add marketplace?" mit y bestätigen und als Scope „user" wählen. Danach zeigt Claude die Einstellungen der Mod, Enter übernimmt jeweils den Standard bis „Save configuration".

Im Code-Tab der Desktop-App und in claude -p gibt es den Befehl /plugin nicht. Dort, oder ganz ohne laufende Session, installierst du mit diesen zwei Befehlen im Terminal:

claude plugin marketplace add ptrzptrz888-create/claude-mods
claude plugin install prompt-boost@willis-mods --scope user

Der Pfad in routerPath zeigt dann ins Leere. Das ist harmlos, es kommen nur keine Skill-Vorschläge.

Source 13 files
hooks/register.tsx 639 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, ModelForkResult, PluginOptions, PromptOrigin, Register } from 'claude-code'
3
4import type { Analysis, Feedback, Mode, Progress, RatingAsk } from '../types'
5import { agentGuide, asAgentPolicy } from './agent-guide'
6import { briefLog, headlineOf, MARK, skippedLog, stepsOf } from './display'
7import type { AgentPolicy } from './agent-guide'
8import { addFeedback, asFeedbackList, DISTILL_EVERY, distillRequest } from './feedback'
9import { registerAgentWatch } from './agent-watch'
10import { agentLine, LAGEBILD_TOOL_SPEC, LEAN_COMMANDS, leanSettingsOf, settleFork, summaryLine, variantSpec, VARIANTS } from './lean-agents'
11import type { LeanSettings } from './lean-agents'
12import { familyOf, profileOf } from './model-profiles'
13import {
14  analyseRequest,
15  forkPrompt,
16  MAX_INPUT_CHARS,
17  modelTip,
18  parseVerdict,
19} from './optimizer'
20import type { Extras, Verdict } from './optimizer'
21import { missingSections, reportNote } from './reports'
22import { coreTable, DEFAULT_ROUTER_PATH, expandHome } from './router'
23import { CLOSED_FORK_GATE } from './state'
24
25const PANE = 'prompt-boost'
26const COMMAND = 'prompt-boost'
27const RAW_PREFIX = /^roh:\s*/i
28const BAND_AGENT_LINES = 3
29
30const last = atom({ plugin: 'prompt-boost', key: 'last' } as const, null)
31const agents = atom({ plugin: 'prompt-boost', key: 'agents' } as const, [])
32const rating = atom({ plugin: 'prompt-boost', key: 'rating' } as const, null)
33const progress = atom({ plugin: 'prompt-boost', key: 'progress' } as const, null)
34const agentStats = atom({ plugin: 'prompt-boost', key: 'agentStats' } as const, [])
35const forkGate = atom({ plugin: 'prompt-boost', key: 'forkGate' } as const, CLOSED_FORK_GATE)
36
37type Settings = {
38  optimizerModel: string
39  minChars: number
40  isAgentBoostOn: boolean
41  isContextOn: boolean
42  isRatingOn: boolean
43  isBriefShown: boolean
44  isProgressShown: boolean
45  agentPolicy: AgentPolicy
46  routerPath: string
47}
48
49const settingsOf = (options: PluginOptions): Settings => ({
50  optimizerModel: String(options.optimizerModel ?? 'sonnet'),
51  minChars: Number(options.minChars ?? 40),
52  isAgentBoostOn: options.optimizeAgents !== false,
53  isContextOn: options.contextAnalysis !== false,
54  isRatingOn: options.askRating !== false,
55  isBriefShown: options.showBrief !== false,
56  isProgressShown: options.showProgress !== false,
57  agentPolicy: asAgentPolicy(options.agentPolicy),
58  routerPath: String(options.routerPath ?? DEFAULT_ROUTER_PATH),
59})
60
61// The module's own values start over on a reload, which suits all three.
62let routerCache: string | null = null
63/** Notes on agent reports that broke the contract, for the main loop to read. */
64let pendingNotes: readonly string[] = []
65/** The boosted prompt whose turn runs now, to ask for a rating after it. */
66let awaitingRating: RatingAsk | null = null
67/**
68 * Whether this loaded copy registered variants, tool and commands. A reload
69 * (/reload-plugins) starts a fresh copy without session.start, so the next
70 * prompt registers them again; a name registered twice is replaced.
71 */
72let isLeanSetUp = false
73
74const takeNotes = () => {
75  const taken = pendingNotes
76  pendingNotes = []
77
78  return taken
79}
80
81const MODE_WORDS: Record<string, Mode> = {
82  an: 'ergaenzen',
83  ein: 'ergaenzen',
84  ergaenzen: 'ergaenzen',
85  'ergänzen': 'ergaenzen',
86  ersetzen: 'ersetzen',
87  aus: 'aus',
88}
89
90const MODE_LABEL: Record<Mode, string> = {
91  ergaenzen: 'ergänzen (Original bleibt, Briefing als Zusatzkontext)',
92  ersetzen: 'ersetzen (Briefing ersetzt den Prompt, Original hängt an)',
93  aus: 'aus',
94}
95
96const isFromPerson = (origin: PromptOrigin) => origin.kind === 'composer' || origin.kind === 'bridge'
97
98const bullets = (title: string, items: readonly string[]) =>
99  items.length === 0 ? '' : `\n\n${title}\n${items.map(item => `- ${item}`).join('\n')}`
100
101const toVerdict = (result: ModelForkResult): Verdict | { failed: string } =>
102  result.isAnswered ? (parseVerdict(result.text) ?? { failed: 'keine lesbare Antwort' }) : { failed: result.reason }
103
104const toAnalysis = (
105  verdict: Verdict | { failed: string },
106  base: { original: string; model: string; mode: Mode; usedContext: boolean; durationMs: number },
107): Analysis => {
108  const family = familyOf(base.model)
109  if ('failed' in verdict) {
110    return {
111      ...base,
112      family,
113      isSkipped: true,
114      reason: `Optimierung fehlgeschlagen (${verdict.failed}), Original gesendet.`,
115      intent: '',
116      gaps: [],
117      questions: [],
118      assumptions: [],
119      complexity: 'mittel',
120      skill: '',
121      tip: '',
122      lessons: [],
123      brief: '',
124    }
125  }
126  const { needsContext: _needsContext, ...rest } = verdict
127
128  return { ...base, ...rest, family, tip: modelTip(family, verdict.complexity) }
129}
130
131/** Step 3, mode "ergänzen": the brief rides beside the untouched prompt. */
132const briefContext = (analysis: Analysis) =>
133  `[prompt-boost] Strukturierte Fassung des Auftrags oben, optimiert für ${profileOf(analysis.family).label}. ` +
134  `Bei Widersprüchen gilt der Originaltext der Person.\n\n${analysis.brief}` +
135  bullets('Offene Punkte (mit Annahme überbrückbar):', analysis.gaps) +
136  '\n\nArbeite mit den markierten Annahmen und nenne sie im Ergebnis.'
137
138/** Idea 2: what the analyser could not settle, asked before any work starts. */
139const questionContext = (questions: readonly string[]) =>
140  '[prompt-boost] Kläre vor Beginn der Arbeit diese Punkte mit der Person, gebündelt in einem einzigen ' +
141  'AskUserQuestion-Aufruf mit deiner Empfehlung als erster Option (fehlt das Werkzeug, frag direkt). ' +
142  'Was du aus dem Gesprächsverlauf oder dem Code sicher beantworten kannst, frag nicht.' +
143  bullets('Fragen:', questions)
144
145const skillContext = (skill: string) =>
146  `[prompt-boost] Passender Skill laut Skill-Router: ${skill}. Prüfe kurz, ob er wirklich passt, und nutze ihn dann.`
147
148/** Everything the model reads beside the prompt, from one analysis. */
149const contextFor = (analysis: Analysis, mode: Mode): string[] => [
150  ...(analysis.isSkipped || mode === 'ersetzen' ? [] : [briefContext(analysis)]),
151  ...(analysis.questions.length === 0 ? [] : [questionContext(analysis.questions)]),
152  ...(analysis.skill === '' ? [] : [skillContext(analysis.skill)]),
153]
154
155/** Step 3, mode "ersetzen": the brief leads, the original stays below it. */
156const asReplacement = (analysis: Analysis, original: string) =>
157  `${analysis.brief}${bullets('Offene Punkte:', analysis.gaps)}\n\n---\nOriginalwortlaut:\n${original}`
158
159/** prompt-boost only proposes questions; the main model decides whether to ask them. */
160const possibleQuestions = (count: number) => `${count} mögliche Rückfrage${count === 1 ? '' : 'n'}`
161
162/** The status line text; the engine itself puts the plugin name in front. */
163const statusOf = (analysis: Analysis, mode: Mode) => {
164  if (analysis.isSkipped) return `unverändert · ${analysis.reason.slice(0, 60)}`
165  const verb = mode === 'ersetzen' ? 'ersetzt' : 'ergänzt'
166  const asks = analysis.questions.length === 0 ? '' : ` · ${possibleQuestions(analysis.questions.length)}`
167
168  return `${verb} für ${profileOf(analysis.family).label}${analysis.usedContext ? ' (mit Verlauf)' : ''}${asks}`
169}
170
171const summaryOf = (analysis: Analysis | null) => {
172  if (analysis === null) return 'Noch kein Prompt analysiert.'
173  if (analysis.isSkipped) return `Letzter Prompt übersprungen: ${analysis.reason}`
174
175  return `Letzter Prompt: ${analysis.intent} (${analysis.gaps.length} offene Punkte, ${possibleQuestions(analysis.questions.length)})`
176}
177
178const withContext = <E extends { context?: readonly string[] }>(e: E, extra: readonly string[]): E =>
179  extra.length === 0 ? e : { ...e, context: [...(e.context ?? []), ...extra] }
180
181const leanLine = (lean: LeanSettings) =>
182  `Sparsame Agenten: Varianten ${lean.isRoutingOn ? 'an' : 'aus'} · Gate ${lean.isBriefGateOn ? 'an' : 'aus'}` +
183  ` · Lagebild ${lean.isLagebildOn ? 'an' : 'aus'} · Fork-Sperre ${lean.isForkGateOn ? 'an' : 'aus'}` +
184  ` · Rundenwarnung ${lean.isRoundWarningOn ? 'an' : 'aus'}`
185
186async function readMode($: EngineInterface): Promise<Mode> {
187  const stored = await $.store.get('mode')
188
189  return stored === 'ergaenzen' || stored === 'ersetzen' || stored === 'aus' ? stored : 'ergaenzen'
190}
191
192async function readRouter($: EngineInterface, path: string): Promise<string> {
193  if (routerCache !== null) return routerCache
194  let table = ''
195  try {
196    table = coreTable(await $.fs.read(expandHome(path, await $.env.get('HOME'))))
197  } catch {
198    // No router file: the analyser simply suggests no skill.
199  }
200  routerCache = table
201
202  return table
203}
204
205async function readExtras($: EngineInterface, settings: Settings): Promise<Extras> {
206  const style = await $.store.get('style')
207
208  return {
209    personalStyle: typeof style === 'string' ? style : '',
210    router: settings.routerPath === '' ? '' : await readRouter($, settings.routerPath),
211  }
212}
213
214/**
215 * Steps 1 and 2. A quick analysis without the conversation first; when it
216 * finds the prompt leans on earlier turns (idea 1) or has questions, a fork
217 * reads the whole conversation, served from the main thread's prompt cache,
218 * and keeps only the questions the conversation leaves open.
219 */
220async function analyse($: EngineInterface, text: string, mode: Mode, settings: Settings): Promise<Analysis> {
221  const startedAt = await $.clock.now()
222  await setProgress($, { phase: 'analyse', isReadingContext: false, startedAt, durationMs: 0, detail: '' })
223  const model = await $.session.model()
224  const extras = await readExtras($, settings)
225  const quick = toVerdict(
226    await $.model.complete(analyseRequest({ text, model, optimizerModel: settings.optimizerModel, extras })),
227  )
228  // Questions from the blind analysis are only candidates: the fork checks them against the conversation.
229  const candidates = 'failed' in quick ? [] : quick.questions
230  const isContextNeeded = settings.isContextOn && 'needsContext' in quick && (quick.needsContext || candidates.length > 0)
231  if (!isContextNeeded) {
232    const durationMs = (await $.clock.now()) - startedAt
233
234    return toAnalysis(quick, { original: text, model, mode, usedContext: false, durationMs })
235  }
236
237  $.ui.status('liest den Gesprächsverlauf …')
238  await setProgress($, { phase: 'verlauf', isReadingContext: true, startedAt, durationMs: 0, detail: '' })
239  const deep = toVerdict(await $.model.fork({ prompt: forkPrompt({ text, model, extras, candidates }) }))
240  const isDeepUsable = !('failed' in deep)
241  const durationMs = (await $.clock.now()) - startedAt
242
243  return toAnalysis(isDeepUsable ? deep : quick, { original: text, model, mode, usedContext: isDeepUsable, durationMs })
244}
245
246async function setProgress($: EngineInterface, value: Progress | null) {
247  await update($, progress, () => value)
248}
249
250/** The status band's last word on one prompt, and its transcript entry. */
251async function finish($: EngineInterface, analysis: Analysis, settings: Settings) {
252  const isFailed = analysis.reason.startsWith('Optimierung fehlgeschlagen')
253  const phase: Progress['phase'] = isFailed ? 'fehler' : analysis.isSkipped ? 'unveraendert' : 'fertig'
254  const startedAt = (await $.clock.now()) - analysis.durationMs
255  await setProgress($, {
256    phase,
257    isReadingContext: analysis.usedContext,
258    startedAt,
259    durationMs: analysis.durationMs,
260    detail: analysis.reason.slice(0, 80),
261  })
262  if (!settings.isBriefShown || isFailed) return
263
264  $.ui.log(analysis.isSkipped ? skippedLog(analysis) : briefLog(analysis))
265}
266
267/** Idea 3: stores one rating and distills the style every few ratings. */
268async function saveRating(
269  $: EngineInterface,
270  ask: RatingAsk,
271  value: Feedback['rating'],
272  note: string,
273  optimizerModel: string,
274) {
275  await update($, rating, () => null)
276  const entry: Feedback = {
277    at: new Date(await $.clock.now()).toISOString(),
278    intent: ask.intent,
279    model: ask.model,
280    rating: value,
281    note: note.trim(),
282  }
283  const list = addFeedback(asFeedbackList(await $.store.get('feedback')), entry)
284  await $.store.set('feedback', list)
285
286  const since = Number((await $.store.get('ratingsSinceDistill')) ?? 0) + 1
287  if (since < DISTILL_EVERY) {
288    await $.store.set('ratingsSinceDistill', since)
289    $.ui.toast('prompt-boost: Bewertung gespeichert.')
290
291    return
292  }
293
294  await $.store.set('ratingsSinceDistill', 0)
295  const result = await $.model.complete(distillRequest(list, optimizerModel))
296  if (!result.isAnswered) return
297
298  await $.store.set('style', result.text.trim())
299  $.ui.toast('prompt-boost: Persönlicher Prompt-Stil aktualisiert (/prompt-boost stil).')
300}
301
302/** Registers the lean variants, the Lagebild tool and the agent commands. */
303async function setupLean($: EngineInterface, lean: LeanSettings) {
304  if (lean.isRoutingOn) {
305    for (const name of VARIANTS) await $.agent.register(variantSpec(name))
306  }
307  if (lean.isLagebildOn) await $.tool.register(LAGEBILD_TOOL_SPEC)
308  for (const command of LEAN_COMMANDS) await $.command.register(command)
309  // To the second: agents from earlier the same day must not count as "since".
310  if (typeof (await $.store.get('leanSinceAt')) !== 'string') {
311    await $.store.set('leanSinceAt', new Date(await $.clock.now()).toISOString())
312  }
313  isLeanSetUp = true
314}
315
316/** Marks a subagent done and writes its cost line for the person. */
317async function finishAgentStat($: EngineInterface, agentId: string, lean: LeanSettings) {
318  const stat = (await read($, agentStats)).find(item => item.agentId === agentId)
319  if (stat === undefined || stat.isDone) return
320  await update($, agentStats, list => list.map(item => (item.agentId === agentId ? { ...item, isDone: true } : item)))
321  if (lean.isAgentDisplayOn) $.ui.log(summaryLine(stat))
322}
323
324/** Idea 5: checks a finished subagent's report against the work contract. */
325async function checkReport($: EngineInterface, agentId: string, answer: string) {
326  const entry = (await read($, agents)).find(item => item.agentId === agentId)
327  if (entry === undefined) return
328
329  const missing = missingSections(answer)
330  await update($, agents, list => list.map(item => (item.agentId === agentId ? { ...item, missing } : item)))
331  if (missing.length === 0) return
332
333  pendingNotes = [...pendingNotes, reportNote(entry.description, missing)]
334  $.ui.toast(`prompt-boost: Bericht „${entry.description}" ohne ${missing.join(', ')}`)
335}
336
337export const register: Register = (on, options) => {
338  const settings = settingsOf(options)
339  const lean = leanSettingsOf(options)
340  registerAgentWatch(on, options)
341
342  on('session.start', async ($, e, next) => {
343    await $.command.register({
344      name: COMMAND,
345      description: 'Prompt-Optimierung: status, an, ersetzen, aus, zeigen, probe <text>, stil',
346      argumentHint: '[status|an|ersetzen|aus|zeigen|probe <text>|stil [zurücksetzen]]',
347    })
348    await setupLean($, lean)
349
350    return next(e)
351  })
352
353  // Steps 1 to 3: analyse, optimize for the selected model, hand it on.
354  on('prompt.submit', async ($, e, next) => {
355    if (!isLeanSetUp) {
356      isLeanSetUp = true
357      try {
358        await setupLean($, lean)
359      } catch {
360        // Variants and tool stay missing until the next session; the prompt goes on.
361        $.ui.toast('prompt-boost: Agentenvarianten konnten nicht registriert werden.')
362      }
363    }
364    const base = withContext(e, takeNotes())
365    if (!isFromPerson(e.origin)) return next(base)
366
367    await update($, forkGate, gate => settleFork(gate, e.text))
368    awaitingRating = null
369    await update($, rating, () => null)
370    if (RAW_PREFIX.test(e.text)) return next({ ...base, text: e.text.replace(RAW_PREFIX, '') })
371
372    const mode = await readMode($)
373    const text = e.text.trim()
374    const isOutOfRange = text.length < settings.minChars || text.length > MAX_INPUT_CHARS
375    if (mode === 'aus' || isOutOfRange || text.startsWith('/')) return next(base)
376
377    $.ui.status('analysiert …')
378    const analysis = await analyse($, e.text, mode, settings)
379    await update($, last, () => analysis)
380    await finish($, analysis, settings)
381    $.ui.status(statusOf(analysis, mode))
382    if (analysis.tip !== '') $.ui.toast(analysis.tip)
383    if (!analysis.isSkipped && settings.isRatingOn) {
384      awaitingRating = { intent: analysis.intent, model: analysis.model, stage: 'frage' }
385    }
386
387    const isReplaced = mode === 'ersetzen' && !analysis.isSkipped
388    const submitted = isReplaced ? { ...base, text: asReplacement(analysis, e.text) } : base
389
390    return next(withContext(submitted, contextFor(analysis, mode)))
391  }).catch(($, e, next) => next(e))
392
393  // Idea 3 (asking) and idea 5 (checking), both when a turn ends.
394  on('turn.complete', async ($, e, next) => {
395    if (e.agentId !== undefined) {
396      await checkReport($, e.agentId, e.answer)
397      await finishAgentStat($, e.agentId, lean)
398
399      return next(e)
400    }
401
402    await setProgress($, null)
403    const ask = awaitingRating
404    awaitingRating = null
405    if (ask !== null && e.reason === 'answer') await update($, rating, () => ask)
406
407    return next(e)
408  })
409
410  // Step 4a (gate, routing, rewrite, Lagebild) lives in agent-watch.ts.
411
412  // Idea 5: a foreground agent's result carries the note straight back.
413  on('tool.call', { tool: 'Agent' }, async ($, e, next) => {
414    const ran = await next(e)
415    if (ran.deny !== undefined || pendingNotes.length === 0) return ran
416
417    return withContext(ran, takeNotes())
418  }).catch(($, e, next) => next(e))
419
420  // Step 4b: whether to start an agent at all, and how to brief it.
421  on('prompt.compose', async ($, e, next) => {
422    const composed = await next(e)
423    if (!e.tools.includes('Agent') || (await readMode($)) === 'aus') return composed
424    const guide = { id: 'prompt-boost:agent-guide', text: agentGuide(settings.agentPolicy), scope: 'session' as const }
425
426    return { sections: [...composed.sections, guide] }
427  }).catch(($, e, next) => next(e))
428
429  // Idea 3: the rating band above the prompt after a boosted turn.
430  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
431    if (e.props.hasSurvey) return next(e)
432    const ask = await read($, rating)
433    const elements = $.ui.resolve(e)
434    const { Box, Button, Text } = elements
435
436    if (ask === null || e.props.isWorking) {
437      const current = settings.isProgressShown ? await read($, progress) : null
438      const running = lean.isAgentDisplayOn
439        ? (await read($, agentStats)).filter(stat => !stat.isDone).slice(-BAND_AGENT_LINES)
440        : []
441      if (current === null && running.length === 0) return next(e)
442      if (current === null) {
443        return (
444          <Box flexDirection="column">
445            {running.map(stat => (
446              <Text dimColor>prompt-boost Agent: {agentLine(stat)}</Text>
447            ))}
448          </Box>
449        )
450      }
451      const isRunning = current.phase === 'analyse' || current.phase === 'verlauf'
452
453      return (
454        <Box flexDirection="column">
455          <Box>
456          <Text bold>prompt-boost </Text>
457          {stepsOf(current).map(step => (
458            <Text bold={step.state === 'running'} dimColor={step.state === 'waiting' || step.state === 'skipped'}>
459              {MARK[step.state]} {step.label}{'  '}
460            </Text>
461          ))}
462          <Text dimColor>· {headlineOf(current)} </Text>
463          {!isRunning && current.phase !== 'fehler' && (
464            <Button key="details" label="Details" onPress={() => $.ui.open({ id: PANE, title: 'prompt-boost' })} />
465          )}
466          </Box>
467          {running.map(stat => (
468            <Text dimColor>prompt-boost Agent: {agentLine(stat)}</Text>
469          ))}
470        </Box>
471      )
472    }
473
474    const save = (value: Feedback['rating'], note: string) => saveRating($, ask, value, note, settings.optimizerModel)
475
476    if (ask.stage === 'notiz' && 'Input' in elements) {
477      const { Input } = elements
478
479      return (
480        <Box flexDirection="column">
481          <Text dimColor>prompt-boost: Was hat gefehlt oder gestört? Ein Satz reicht.</Text>
482          <Input
483            key="note"
484            placeholder="z. B. zu ausführlich, falscher Fokus, Rückfrage unnötig"
485            submitLabel="Speichern"
486            autoFocus
487            onSubmit={value => save('daneben', value)}
488          />
489          <Button key="skip" label="Ohne Notiz speichern" onPress={() => save('daneben', '')} />
490        </Box>
491      )
492    }
493
494    const toNote = () =>
495      'Input' in elements ? update($, rating, () => ({ ...ask, stage: 'notiz' as const })) : save('daneben', '')
496
497    return (
498      <Box>
499        <Text dimColor>prompt-boost: Hat das optimierte Briefing geholfen? </Text>
500        <Button key="gut" label="Gut" onPress={() => save('gut', '')} />
501        <Button key="daneben" label="Daneben" onPress={toNote} />
502        <Button key="weg" label="Ausblenden" role="dismiss" onPress={() => update($, rating, () => null)} />
503      </Box>
504    )
505  })
506
507  on('command.run', { command: COMMAND }, async ($, e) => {
508    const [word = 'status', ...rest] = e.args.trim().split(/\s+/)
509    const verb = word.toLowerCase()
510    const mode = MODE_WORDS[verb]
511
512    if (mode !== undefined) {
513      await $.store.set('mode', mode)
514
515      return { text: `Modus ${MODE_LABEL[mode]}` }
516    }
517
518    if (verb === 'zeigen') {
519      await $.ui.open({ id: PANE, title: 'prompt-boost' })
520
521      return { text: 'Analyse-Panel geöffnet.' }
522    }
523
524    if (verb === 'stil') {
525      if (rest[0]?.toLowerCase().startsWith('zur') === true) {
526        await $.store.delete('style')
527        await $.store.delete('feedback')
528        await $.store.set('ratingsSinceDistill', 0)
529
530        return { text: 'Prompt-Stil und Bewertungen zurückgesetzt.' }
531      }
532      const style = await $.store.get('style')
533      const count = asFeedbackList(await $.store.get('feedback')).length
534
535      return {
536        text:
537          typeof style === 'string' && style !== ''
538            ? `Dein gelernter Prompt-Stil (aus ${count} Bewertungen):\n${style}`
539            : `Noch kein Stil gelernt. ${count} von ${DISTILL_EVERY} Bewertungen bis zur ersten Auswertung.`,
540      }
541    }
542
543    if (verb === 'probe') {
544      const text = rest.join(' ')
545      if (text === '') return { text: 'Aufruf: /prompt-boost probe <dein Prompt>' }
546
547      const analysis = await analyse($, text, await readMode($), settings)
548      await update($, last, () => analysis)
549      await setProgress($, null)
550      if (analysis.isSkipped) return { text: `Würde unverändert gesendet: ${analysis.reason}` }
551
552      return {
553        text:
554          `Absicht: ${analysis.intent} · Komplexität ${analysis.complexity}` +
555          (analysis.skill === '' ? '' : ` · Skill ${analysis.skill}`) +
556          bullets('Rückfragen:', analysis.questions) +
557          bullets('Offene Punkte:', analysis.gaps) +
558          bullets('Annahmen:', analysis.assumptions) +
559          `\n\nBriefing:\n${analysis.brief}` +
560          bullets('So schreibst du es nächstes Mal:', analysis.lessons) +
561          (analysis.tip === '' ? '' : `\n\n${analysis.tip}`),
562      }
563    }
564
565    const model = await $.session.model()
566    const ratings = asFeedbackList(await $.store.get('feedback')).length
567    const lines = [
568      `Modus: ${MODE_LABEL[await readMode($)]}`,
569      `Aktives Modell: ${model} (Profil ${profileOf(familyOf(model)).label})`,
570      `Optimierer: ${settings.optimizerModel} · Verlaufsanalyse ${settings.isContextOn ? 'an' : 'aus'} · Mindestlänge ${settings.minChars} Zeichen`,
571      `Agenten: Umschreiben ${settings.isAgentBoostOn ? 'an' : 'aus'} · Regel ${settings.agentPolicy === 'fragen' ? 'vorher fragen' : 'frei'}`,
572      leanLine(lean),
573      `Bewertungen: ${ratings} gespeichert · Band ${settings.isRatingOn ? 'an' : 'aus'}`,
574      summaryOf(await read($, last)),
575      'Tipp: „roh:“ vor einem Prompt sendet ihn unverändert.',
576    ]
577
578    return { text: lines.join('\n') }
579  })
580
581  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
582    const { Box, Text } = $.ui.resolve(e)
583    const analysis = await read($, last)
584    const rewrites = await read($, agents)
585
586    return (
587      <Box flexDirection="column">
588        <Text bold>Letzter Prompt</Text>
589        {analysis === null && <Text dimColor>Noch kein Prompt analysiert.</Text>}
590        {analysis !== null && (
591          <Box flexDirection="column">
592            <Text dimColor>
593              {analysis.model} · Profil {profileOf(analysis.family).label} · Modus {analysis.mode} · Komplexität{' '}
594              {analysis.complexity}
595              {analysis.usedContext ? ' · mit Verlauf' : ''}
596            </Text>
597            {analysis.tip !== '' && <Text>{analysis.tip}</Text>}
598            {analysis.skill !== '' && <Text>Skill: {analysis.skill}</Text>}
599            {analysis.isSkipped ? (
600              <Text>Unverändert: {analysis.reason}</Text>
601            ) : (
602              <Box flexDirection="column">
603                <Text>Absicht: {analysis.intent}</Text>
604                {analysis.questions.map(question => (
605                  <Text>· Rückfrage: {question}</Text>
606                ))}
607                {analysis.gaps.map(gap => (
608                  <Text>· offen: {gap}</Text>
609                ))}
610                {analysis.assumptions.map(item => (
611                  <Text dimColor>· {item}</Text>
612                ))}
613                <Text bold>Verbesserte Fassung</Text>
614                <Text>{analysis.brief}</Text>
615                {analysis.lessons.length > 0 && <Text bold>So schreibst du es nächstes Mal</Text>}
616                {analysis.lessons.map(lesson => (
617                  <Text>• {lesson}</Text>
618                ))}
619              </Box>
620            )}
621          </Box>
622        )}
623        <Text bold>Agenten</Text>
624        {rewrites.length === 0 && <Text dimColor>Noch kein Agent gestartet.</Text>}
625        {rewrites
626          .slice(-5)
627          .reverse()
628          .map(item => (
629            <Text dimColor={!item.isOptimized}>
630              {item.subagentType} ({item.model}): {item.description} · {item.before} → {item.after} Zeichen
631              {item.isOptimized ? '' : ' · nur Vertrag'}
632              {item.missing === null ? ' · läuft' : item.missing.length === 0 ? ' · Bericht vollständig' : ` · fehlt: ${item.missing.join(', ')}`}
633            </Text>
634          ))}
635      </Box>
636    )
637  })
638}
639
hooks/agent-guide.ts 57 lines
1/** Marks a prompt the contract was already appended to. */
2export const CONTRACT_MARK = '## Arbeitsvertrag (prompt-boost)'
3
4/** Whether the main loop may start agents on its own or asks first. */
5export type AgentPolicy = 'fragen' | 'frei'
6
7const POLICY_TEXT: Record<AgentPolicy, string> = {
8  fragen:
9    'Starte Subagenten, Workflows oder parallele Agenten nur, wenn die Person es in diesem Gespräch ausdrücklich verlangt hat. ' +
10    'Hältst du einen Agenten für nötig, frag vorher kurz nach (Zweck, grobe Kostenschätzung, warum du es nicht selbst erledigst) und warte auf ein Ja. Fragen ist jederzeit erlaubt.',
11  frei: 'Du darfst Subagenten selbst starten, wenn die Prüfung unten dafür spricht.',
12}
13
14export const asAgentPolicy = (value: unknown): AgentPolicy => (value === 'frei' ? 'frei' : 'fragen')
15
16/**
17 * A system-prompt section for the main loop: whether to start an agent at
18 * all, how to brief it and how to check its report.
19 */
20export const agentGuide = (policy: AgentPolicy) => `# Delegation an Subagenten (prompt-boost)
21
22${POLICY_TEXT[policy]}
23
24Bevor du einen Subagenten startest oder vorschlägst, prüfe drei Dinge:
251. Passt die Aufgabe in deinen eigenen Kontext? Dann erledige sie selbst. Agenten lohnen sich für unabhängige, gut abgrenzbare Teilaufgaben oder breite Suchen, deren Rohdaten du nicht brauchst.
262. Welches Modell reicht? haiku für Suchen und Lesen, sonnet für Umsetzung, opus nur für schwierige Abwägungen. Setze \`model\` bewusst.
273. Sind die Teilaufgaben unabhängig? Dann parallel in einer Nachricht starten, sonst nacheinander.
28
29Schreibe den Prompt wie ein Briefing für eine fähige Kollegin, die nichts von diesem Gespräch weiß, mit genau diesen Feldnamen am Zeilenanfang:
30- Ziel: was herauskommen soll und wofür es gebraucht wird
31- Kontext: konkrete Pfade, Namen, bisherige Befunde, was schon ausgeschlossen ist
32- Grenzen: was ausdrücklich nicht zu tun ist
33- Fertig wenn: ein prüfbares Endkriterium
34- Rückgabe: Format und Länge des Berichts
35Ziel, Fertig wenn und Rückgabe sind Pflicht, ein Briefing ohne sie wird einmal zurückgewiesen.
36
37Sparsam delegieren:
38- Nimm für einfache Aufträge die schlanken Typen prompt-boost:suche, prompt-boost:umsetzung oder prompt-boost:pruefung. Sie laden weder CLAUDE.md noch Regeln und kosten pro Runde ein Vielfaches weniger. general-purpose wird passend umgeleitet, wenn der Auftrag keine Browser-, MCP- oder Agentenwerkzeuge braucht.
39- Jeder Agent bekommt automatisch ein Lagebild mit Ziel, gelesenen und geänderten Dateien. Halte Befunde, ausgeschlossene Spuren und Entscheidungen mit dem Werkzeug Lagebild fest, sobald sie feststehen, damit kein Agent sie neu erarbeitet.
40- Einen Fork (erbt den ganzen Verlauf) startest du nur nach ausdrücklicher Zustimmung der Person.
41
42Behandle den Bericht eines Agenten als Hinweis, nicht als Beleg. Prüfe die entscheidenden Aussagen selbst, bevor du darauf handelst.`
43
44const CONTRACT = `${CONTRACT_MARK}
45- Du startest ohne den Gesprächsverlauf. Fehlt etwas Entscheidendes, nenne es im Bericht, statt zu raten.
46- Bleib im genannten Umfang. Lies gezielt die nötigen Stellen statt ganzer Verzeichnisse.
47- Behaupte nichts, was du nicht geprüft hast.
48- Schließe mit diesem Bericht ab, knapp:
49  1. Ergebnis
50  2. Belege (Datei:Zeile, ausgeführte Befehle, Messwerte)
51  3. Nicht geprüft oder unsicher
52  4. Offene Fragen an den Auftraggeber`
53
54/** Appends the work contract once; a prompt that has it is left alone. */
55export const withContract = (prompt: string): string =>
56  prompt.includes(CONTRACT_MARK) ? prompt : `${prompt.trimEnd()}\n\n${CONTRACT}`
57
hooks/display.ts 113 lines
1import type { Analysis, Progress } from '../types'
2import { profileOf } from './model-profiles'
3
4const ORIGINAL_PREVIEW_CHARS = 600
5
6export type StepState = 'done' | 'running' | 'waiting' | 'skipped' | 'failed'
7export type Step = { label: string; state: StepState }
8
9export const MARK: Record<StepState, string> = {
10  done: '✓',
11  running: '…',
12  waiting: '○',
13  skipped: '–',
14  failed: '✗',
15}
16
17export const seconds = (ms: number) => `${(ms / 1000).toFixed(1).replace('.', ',')} s`
18
19/** The three steps of the status band, from where processing stands. */
20export const stepsOf = (progress: Progress): Step[] => {
21  const context: StepState = progress.isReadingContext ? 'done' : 'skipped'
22  switch (progress.phase) {
23    case 'analyse':
24      return [
25        { label: 'Analyse', state: 'running' },
26        { label: 'Verlauf', state: 'waiting' },
27        { label: 'Briefing', state: 'waiting' },
28      ]
29    case 'verlauf':
30      return [
31        { label: 'Analyse', state: 'done' },
32        { label: 'Verlauf', state: 'running' },
33        { label: 'Briefing', state: 'waiting' },
34      ]
35    case 'fertig':
36      return [
37        { label: 'Analyse', state: 'done' },
38        { label: 'Verlauf', state: context },
39        { label: 'Briefing', state: 'done' },
40      ]
41    case 'unveraendert':
42      return [
43        { label: 'Analyse', state: 'done' },
44        { label: 'Verlauf', state: context },
45        { label: 'Briefing', state: 'skipped' },
46      ]
47    case 'fehler':
48      return [
49        { label: 'Analyse', state: progress.isReadingContext ? 'done' : 'failed' },
50        { label: 'Verlauf', state: progress.isReadingContext ? 'failed' : 'skipped' },
51        { label: 'Briefing', state: 'skipped' },
52      ]
53  }
54}
55
56/** The words after the steps: what is happening or how it ended. */
57export const headlineOf = (progress: Progress): string => {
58  switch (progress.phase) {
59    case 'analyse':
60      return 'analysiert deinen Prompt'
61    case 'verlauf':
62      return 'liest den Gesprächsverlauf'
63    case 'fertig':
64      return `fertig in ${seconds(progress.durationMs)} · verbesserte Fassung steht im Verlauf`
65    case 'unveraendert':
66      return `unverändert gesendet · ${progress.detail}`
67    case 'fehler':
68      return `Fehler, Original gesendet · ${progress.detail}`
69  }
70}
71
72const preview = (text: string) => {
73  const trimmed = text.trim()
74
75  return trimmed.length > ORIGINAL_PREVIEW_CHARS ? `${trimmed.slice(0, ORIGINAL_PREVIEW_CHARS)} …` : trimmed
76}
77
78const section = (title: string, lines: readonly string[]) => (lines.length === 0 ? [] : ['', title, ...lines])
79
80/**
81 * The transcript entry for a boosted prompt: original and improved version
82 * side by side, plus what to write differently next time.
83 */
84export const briefLog = (analysis: Analysis): string =>
85  [
86    `prompt-boost · optimiert für ${profileOf(analysis.family).label}` +
87      `${analysis.usedContext ? ' mit Verlauf' : ''} · ${seconds(analysis.durationMs)}`,
88    '',
89    'Dein Prompt:',
90    preview(analysis.original),
91    '',
92    'Verbesserte Fassung:',
93    analysis.brief,
94    ...section(
95      'So schreibst du es nächstes Mal gleich so:',
96      analysis.lessons.map(lesson => `• ${lesson}`),
97    ),
98    ...section(
99      'Mögliche Rückfragen (Claude fragt nur, was es nicht selbst klären kann):',
100      analysis.questions.map(question => `• ${question}`),
101    ),
102  ].join('\n')
103
104/** The transcript entry for a prompt that went out as written. */
105export const skippedLog = (analysis: Analysis): string =>
106  [
107    `prompt-boost · unverändert gesendet · ${seconds(analysis.durationMs)} · ${analysis.reason}`,
108    ...section(
109      'Trotzdem ein Tipp:',
110      analysis.lessons.map(lesson => `• ${lesson}`),
111    ),
112  ].join('\n')
113
hooks/feedback.ts 36 lines
1import type { ModelCompleteRequest } from 'claude-code'
2
3import type { Feedback } from '../types'
4
5/** How many ratings are kept to learn from. */
6export const FEEDBACK_LIMIT = 60
7/** After this many new ratings the style is distilled again. */
8export const DISTILL_EVERY = 5
9
10/** Reads the stored list defensively: anything malformed is dropped. */
11export const asFeedbackList = (value: unknown): Feedback[] =>
12  Array.isArray(value)
13    ? value.filter(
14        (item): item is Feedback =>
15          typeof item === 'object' && item !== null && (item.rating === 'gut' || item.rating === 'daneben'),
16      )
17    : []
18
19export const addFeedback = (list: readonly Feedback[], entry: Feedback): Feedback[] =>
20  [...list, entry].slice(-FEEDBACK_LIMIT)
21
22/** Idea 3: turns the ratings into a short personal prompt style. */
23export const distillRequest = (list: readonly Feedback[], optimizerModel: string): ModelCompleteRequest => ({
24  model: optimizerModel,
25  system: `Du leitest aus Bewertungen ab, wie Arbeitsbriefings für eine bestimmte Person aussehen sollten.
26Jede Zeile ist ein Auftrag, den ein Briefing begleitet hat, mit Bewertung (gut oder daneben) und optional einer Notiz der Person.
27Schreibe höchstens 10 knappe Stichpunkte: was bei dieser Person funktioniert und was zu vermeiden ist.
28Nur Muster, die sich aus mehreren Bewertungen oder einer ausdrücklichen Notiz ergeben. Keine Vermutungen, keine Einleitung.`,
29  prompt: list
30    .map(item => `- [${item.rating}] ${item.intent}${item.note === '' ? '' : ` · Notiz: ${item.note}`}`)
31    .join('\n'),
32  maxTokens: 800,
33  effort: 'low',
34  timeoutMs: 25_000,
35})
36
hooks/agent-watch.ts 262 lines
1import { atom, read, update } from 'claude-code'
2import type { AgentSpawnInput, EngineInterface, ModelUsage, On, PluginOptions } from 'claude-code'
3
4import type { AgentRewrite, AgentStat, ForkGate } from '../types'
5import { CONTRACT_MARK, withContract } from './agent-guide'
6import { asNoteKind, LAGEBILD_MARK, renderLagebild, withFile, withNote } from './lagebild'
7import {
8  briefDenial,
9  classify,
10  COSTS_COMMAND,
11  FORK_DENIAL,
12  GENERAL_PURPOSE,
13  LAGEBILD_COMMAND,
14  LAGEBILD_TOOL_ID,
15  leanSettingsOf,
16  missingBriefFields,
17  ROUND_WARNINGS,
18  roundNote,
19  variantType,
20} from './lean-agents'
21import type { LeanSettings } from './lean-agents'
22import { acceptAgentRewrite, agentRequest, MAX_INPUT_CHARS } from './optimizer'
23import { CLOSED_FORK_GATE, EMPTY_LAGEBILD } from './state'
24
25const AGENT_LOG_SIZE = 20
26
27const last = atom({ plugin: 'prompt-boost', key: 'last' } as const, null)
28const agents = atom({ plugin: 'prompt-boost', key: 'agents' } as const, [])
29const agentStats = atom({ plugin: 'prompt-boost', key: 'agentStats' } as const, [])
30const forkGate = atom({ plugin: 'prompt-boost', key: 'forkGate' } as const, CLOSED_FORK_GATE)
31const lagebild = atom({ plugin: 'prompt-boost', key: 'lagebild' } as const, EMPTY_LAGEBILD)
32const STATS_SIZE = 20
33const MAX_REMEMBERED_DENIALS = 50
34const GIT_TIMEOUT_MS = 3000
35const COSTS_TIMEOUT_MS = 120_000
36const FALLBACK_SINCE = '2026-10-08T12:00:00.000Z'
37
38const TRACKED_READS = new Set(['Read'])
39const TRACKED_CHANGES = new Set(['Edit', 'Write', 'NotebookEdit'])
40
41// Starts over on a reload, which at worst lets one thin brief through.
42/** Descriptions whose brief the gate refused once; a retry passes. */
43let deniedBriefs: readonly string[] = []
44
45const keyOf = (description: string) => description.trim().toLowerCase()
46
47/**
48 * The fork lock, the gate and the routing, in that order. Returns the spawn
49 * as it should start, or the refusal the main loop reads as the tool's error.
50 * Only spawns the main model asked for are judged; a plugin's own spawn, a
51 * workflow's agents and teammates pass untouched.
52 */
53const planSpawn = (
54  e: AgentSpawnInput,
55  isFromModel: boolean,
56  lean: LeanSettings,
57  gate: ForkGate,
58): AgentSpawnInput | { deny: string } => {
59  if (!isFromModel || e.workflow !== undefined || e.isTeammate === true) return e
60
61  if (e.fork) return !lean.isForkGateOn || gate.isApproved ? e : { deny: FORK_DENIAL }
62
63  const missing = missingBriefFields(e.prompt)
64  const key = keyOf(e.description)
65  if (lean.isBriefGateOn && missing.length > 0 && !deniedBriefs.includes(key)) {
66    deniedBriefs = [...deniedBriefs, key].slice(-MAX_REMEMBERED_DENIALS)
67
68    return { deny: briefDenial(missing) }
69  }
70
71  if (!lean.isRoutingOn || e.subagentType !== GENERAL_PURPOSE) return e
72  const variant = classify(e.prompt)
73
74  return variant === null ? e : { ...e, subagentType: variantType(variant) }
75}
76
77const contextOf = (usage: ModelUsage) =>
78  usage.input_tokens + usage.cache_read_input_tokens + usage.cache_creation_input_tokens
79
80const stringField = (e: object, key: string): string | null => {
81  const value: unknown = (e as Record<string, unknown>)[key]
82
83  return typeof value === 'string' ? value : null
84}
85
86async function readMode($: EngineInterface): Promise<string> {
87  const stored = await $.store.get('mode')
88
89  return typeof stored === 'string' ? stored : 'ergaenzen'
90}
91
92/** The session's goal for the Lagebild: the last analysed intent and brief. */
93async function sessionGoal($: EngineInterface): Promise<string> {
94  const analysis = await read($, last)
95  if (analysis === null) return ''
96  if (analysis.isSkipped) return analysis.original
97
98  return `${analysis.intent}\n${analysis.brief}`
99}
100
101async function gitSummary($: EngineInterface): Promise<string> {
102  try {
103    const ran = await $.process.run(['git', 'status', '--short', '--branch'], { timeoutMs: GIT_TIMEOUT_MS })
104
105    return ran.exitCode === 0 ? ran.stdout : ''
106  } catch {
107    // Not a repository, or git is missing: the Lagebild goes without it.
108    return ''
109  }
110}
111
112async function lagebildText($: EngineInterface): Promise<string> {
113  const facts = { goal: await sessionGoal($), cwd: await $.session.cwd(), git: await gitSummary($) }
114
115  return renderLagebild(await read($, lagebild), facts)
116}
117
118async function recordSpawn($: EngineInterface, entry: AgentRewrite) {
119  await update($, agents, list => [...list, entry].slice(-AGENT_LOG_SIZE))
120  if (entry.agentId === '') return
121  const stat: AgentStat = {
122    agentId: entry.agentId,
123    label: entry.description,
124    type: entry.subagentType,
125    model: entry.model,
126    routedFrom: entry.routedFrom,
127    rounds: 0,
128    startContext: 0,
129    lastContext: 0,
130    totalInput: 0,
131    isDone: false,
132  }
133  await update($, agentStats, list => [...list, stat].slice(-STATS_SIZE))
134}
135
136async function recordStep($: EngineInterface, agentId: string, usage: ModelUsage, lean: LeanSettings) {
137  const context = contextOf(usage)
138  const advanced = await update($, agentStats, list =>
139    list.map(stat =>
140      stat.agentId !== agentId
141        ? stat
142        : {
143            ...stat,
144            rounds: stat.rounds + 1,
145            startContext: stat.rounds === 0 ? context : stat.startContext,
146            lastContext: context,
147            totalInput: stat.totalInput + context,
148          },
149    ),
150  )
151  const stat = advanced.find(item => item.agentId === agentId)
152  if (stat === undefined || !lean.isRoundWarningOn || !ROUND_WARNINGS.includes(stat.rounds)) return
153
154  await $.session.append({ message: { type: 'user', content: [{ type: 'text', text: roundNote(stat.rounds) }] }, agentId })
155}
156
157/**
158 * The agent half of the plugin: spawn, rounds, Lagebild tool and commands.
159 * session.start, prompt.submit and turn.complete stay in register.tsx, which
160 * owns those events.
161 */
162export function registerAgentWatch(on: On, options: PluginOptions) {
163  const lean = leanSettingsOf(options)
164  const isAgentBoostOn = options.optimizeAgents !== false
165  const optimizerModel = String(options.optimizerModel ?? 'sonnet')
166
167  // Step 4a: fork lock, brief gate, routing, rewrite, Lagebild and contract.
168  on('agent.spawn', async ($, e, next) => {
169    const planned = planSpawn(e, next.origin.plugin === 'engine', lean, await read($, forkGate))
170    if ('deny' in planned) {
171      if (planned.deny === FORK_DENIAL) await update($, forkGate, gate => ({ ...gate, isAsked: true }))
172
173      return planned
174    }
175    if (planned.fork) {
176      await update($, forkGate, gate => ({ ...gate, isApproved: false }))
177
178      return next(planned)
179    }
180    if (planned.workflow !== undefined) return next(planned)
181
182    const isBoosted = isAgentBoostOn && !planned.prompt.includes(CONTRACT_MARK) && (await readMode($)) !== 'aus'
183    const targetModel = planned.model ?? e.parentModel
184    const result =
185      isBoosted && planned.prompt.length <= MAX_INPUT_CHARS
186        ? await $.model.complete(agentRequest({ prompt: planned.prompt, targetModel, optimizerModel }))
187        : null
188    const rewritten = result?.isAnswered ? acceptAgentRewrite(planned.prompt, result.text) : null
189    const body = rewritten ?? planned.prompt
190    const section = lean.isLagebildOn && !body.includes(LAGEBILD_MARK) ? await lagebildText($) : ''
191    const withSection = section === '' ? body : `${body.trimEnd()}\n\n${section}`
192    const prompt = isBoosted ? withContract(withSection) : withSection
193    const spawned = await next({ ...planned, prompt })
194    if (spawned.deny !== undefined) return spawned
195
196    await recordSpawn($, {
197      agentId: spawned.agentId ?? '',
198      description: e.description,
199      subagentType: planned.subagentType,
200      model: spawned.model,
201      isOptimized: rewritten !== null,
202      before: e.prompt.length,
203      after: prompt.length,
204      missing: null,
205      routedFrom: planned.subagentType === e.subagentType ? null : e.subagentType,
206    })
207
208    return spawned
209  }).catch(($, e, next) => next(e))
210
211  on('tool.call', { tool: LAGEBILD_TOOL_ID }, async ($, e) => {
212    if (e.agentId !== undefined) return { deny: 'prompt-boost: Das Lagebild pflegt nur die Hauptsession.' }
213    const kind = asNoteKind(stringField(e, 'art'))
214    const text = stringField(e, 'text')?.trim() ?? ''
215    if (kind === null || text === '') {
216      return { deny: 'prompt-boost: Lagebild braucht art (befund, ausgeschlossen, entscheidung) und text.' }
217    }
218
219    const at = new Date(await $.clock.now()).toISOString()
220    await update($, lagebild, state => withNote(state, { kind, text, at }))
221
222    return { result: `Im Lagebild notiert (${kind}).` }
223  })
224
225  // The main loop's reads and edits, so agents know what is already known.
226  on('tool.call', async ($, e, next) => {
227    const ran = await next(e)
228    const isTracked = TRACKED_READS.has(e.tool) || TRACKED_CHANGES.has(e.tool)
229    if (!lean.isLagebildOn || !isTracked || e.agentId !== undefined || ran.deny !== undefined || ran.isError) return ran
230
231    const path = stringField(e, 'file_path') ?? stringField(e, 'notebook_path')
232    const kind = TRACKED_READS.has(e.tool) ? 'read' : 'changed'
233    if (path !== null) await update($, lagebild, state => withFile(state, kind, path))
234
235    return ran
236  }).catch(($, e, next) => next(e))
237
238  // One model request of a subagent is one round.
239  on('turn.step', async function* ($, e, next) {
240    const response = yield* next(e)
241    if (e.agentId === undefined || response.usage === null) return response
242    try {
243      await recordStep($, e.agentId, response.usage, lean)
244    } catch {
245      // The agent may have ended between the step and the note.
246    }
247
248    return response
249  })
250
251  on('command.run', { command: LAGEBILD_COMMAND }, async $ => ({ text: await lagebildText($) }))
252
253  on('command.run', { command: COSTS_COMMAND }, async $ => {
254    const since = await $.store.get('leanSinceAt')
255    const script = `${$.plugin.root}/scripts/agent-kosten.py`
256    const argv = ['python3', '-I', script, '--since', typeof since === 'string' ? since : FALLBACK_SINCE]
257    const ran = await $.process.run(argv, { timeoutMs: COSTS_TIMEOUT_MS })
258
259    return { text: ran.exitCode === 0 ? ran.stdout : `agent-kosten fehlgeschlagen:\n${ran.stderr.slice(0, 800)}` }
260  })
261}
262
hooks/lean-agents.ts 209 lines
1import type { PluginOptions } from 'claude-code'
2
3import type { AgentStat, ForkGate } from '../types'
4
5/** The six levers of "Sparsame Agenten", each with its own switch. */
6export type LeanSettings = {
7  isRoutingOn: boolean
8  isBriefGateOn: boolean
9  isLagebildOn: boolean
10  isForkGateOn: boolean
11  isRoundWarningOn: boolean
12  isAgentDisplayOn: boolean
13}
14
15export const leanSettingsOf = (options: PluginOptions): LeanSettings => ({
16  isRoutingOn: options.leanAgents !== false,
17  isBriefGateOn: options.briefGate !== false,
18  isLagebildOn: options.lagebild !== false,
19  isForkGateOn: options.forkGate !== false,
20  isRoundWarningOn: options.roundWarning !== false,
21  isAgentDisplayOn: options.agentDisplay !== false,
22})
23
24export type VariantName = 'suche' | 'umsetzung' | 'pruefung'
25
26export const PLUGIN = 'prompt-boost'
27export const GENERAL_PURPOSE = 'general-purpose'
28export const variantType = (name: VariantName) => `${PLUGIN}:${name}`
29
30/** Rounds at which a running agent is told to check whether it is done. */
31export const ROUND_WARNINGS: readonly number[] = [25, 40]
32
33/** What every lean variant keeps of the person's rules without CLAUDE.md. */
34const CORE_RULES = `Regeln:
35- Antworte auf Deutsch. Schreibe immer „KI“, nie „AI“. Keine Gedankenstriche als Satztrenner.
36- Recht, Steuern und Buchhaltung gelten nach deutschem und EU-Recht, nie nach US-Recht.
37- Starte keine weiteren Agenten, Subagenten oder Workflows.
38- Nutze absolute Pfade. Das Arbeitsverzeichnis steht im Lagebild.
39- Lösche oder überschreibe nichts, was der Auftrag nicht ausdrücklich nennt.
40- Behaupte nichts, was du nicht geprüft hast. Halte dich an „Fertig wenn“ und hör dann auf.`
41
42const ROLE: Record<VariantName, string> = {
43  suche:
44    'Du bist ein Such- und Leseagent. Du findest Stellen, liest gezielt und fasst zusammen. ' +
45    'Du änderst keine Dateien. Bash nutzt du nur lesend (ls, git log, git diff, rg und Ähnliches).',
46  umsetzung:
47    'Du bist ein Umsetzungsagent. Du änderst Code und Dateien im genannten Umfang, ' +
48    'liest vorher nur die nötigen Stellen und prüfst dein Ergebnis mit einem passenden Befehl.',
49  pruefung:
50    'Du bist ein Prüfagent. Du führst Tests, Messungen und Prüfbefehle aus und berichtest, was sie zeigen. ' +
51    'Du änderst keine Dateien.',
52}
53
54const DESCRIPTION: Record<VariantName, string> = {
55  suche: 'Schlanker Such- und Leseagent (haiku, nur lesende Werkzeuge). Für Suchen, Lesen, Zusammenfassen.',
56  umsetzung: 'Schlanker Umsetzungsagent (sonnet, Lesen, Bash, Edit, Write). Für Änderungen an Code und Dateien.',
57  pruefung: 'Schlanker Prüfagent (sonnet, lesend plus Bash). Für Tests, Messungen und Verifikation.',
58}
59
60const READ_TOOLS = ['Read', 'Grep', 'Glob', 'Bash'] as const
61
62const TOOLS: Record<VariantName, readonly string[]> = {
63  suche: READ_TOOLS,
64  umsetzung: [...READ_TOOLS, 'Edit', 'Write'],
65  pruefung: READ_TOOLS,
66}
67
68const MODEL: Record<VariantName, string> = { suche: 'haiku', umsetzung: 'sonnet', pruefung: 'sonnet' }
69
70export const VARIANTS: readonly VariantName[] = ['suche', 'umsetzung', 'pruefung']
71
72/** The agent definition `$.agent.register` takes for one variant. */
73export const variantSpec = (name: VariantName) => ({
74  name,
75  description: DESCRIPTION[name],
76  prompt: `${ROLE[name]}\n\n${CORE_RULES}`,
77  tools: TOOLS[name],
78  model: MODEL[name],
79  omitClaudeMd: true as const,
80})
81
82// Matched on word starts. `\b` treats umlauts as non-letters, so a start is
83// "no letter before" here, with the u flag: „Ändere“ counts, „Beschreibe“ not.
84const START = '(?<!\\p{L})'
85const words = (list: string) => new RegExp(`${START}(${list})`, 'iu')
86
87const NEEDS_FULL_TOOLS = words(
88  'browser|mcp__|webfetch|websearch|playwright|chrome|scrapling|firecrawl|internet|subagent|workflow|obsidian',
89)
90const EDIT_WORDS = words(
91  'änder|aender|implementier|schreib|fix|bau|erstell|refaktor|ergänz|ergaenz|anpass|lösch|loesch|entfern|umbenenn|edit|write|implement|create|refactor|add',
92)
93const CHECK_WORDS = words('test|prüf|pruef|miss|mess|verifizier|validier|verify|check|benchmark')
94const SEARCH_WORDS = words(
95  'such|find|lies|lese|recherchier|analysier|untersuch|zeig|list|welche|wo(?!\\p{L})|wie viele|search|read|locate|explore|überblick|übersicht|zusammenfass',
96)
97
98const LABEL_LINE = /^[\s>*#-]*\**\s*([A-Za-zÄÖÜäöü ,]{2,24}?)\s*\**\s*:/
99
100/** The `Ziel:` part of a brief when it has one, else the whole brief. */
101const goalOf = (prompt: string): string => {
102  const lines = prompt.split('\n')
103  const start = lines.findIndex(line => /^(ziel|goal)$/i.test(LABEL_LINE.exec(line)?.[1]?.trim() ?? ''))
104  if (start === -1) return prompt
105  const rest = lines.slice(start + 1)
106  const end = rest.findIndex(line => LABEL_LINE.test(line) || line.startsWith('#'))
107  const body = [lines[start] ?? '', ...(end === -1 ? rest : rest.slice(0, end))]
108
109  return body.join('\n').replace(LABEL_LINE, '').trim()
110}
111
112/**
113 * Which lean variant a general-purpose brief goes to, or null when it needs
114 * tools no variant has. Mixed or unclear briefs go to `umsetzung`: dearer,
115 * but never short of a tool halfway through.
116 */
117export const classify = (prompt: string): VariantName | null => {
118  if (NEEDS_FULL_TOOLS.test(prompt)) return null
119  const goal = goalOf(prompt)
120  if (EDIT_WORDS.test(goal)) return 'umsetzung'
121  if (CHECK_WORDS.test(goal)) return 'pruefung'
122
123  return SEARCH_WORDS.test(goal) ? 'suche' : 'umsetzung'
124}
125
126const FIELD_PATTERNS: readonly (readonly [string, RegExp])[] = [
127  ['Ziel', /(?:^|\n)[\s>*#-]*\**\s*(?:ziel|goal|zweck)\b/i],
128  ['Fertig wenn', /(?:^|\n)[\s>*#-]*\**\s*(?:fertig,? wenn|done when|endkriterium|abbruchkriterium)\b/i],
129  ['Rückgabe', /(?:^|\n)[\s>*#-]*\**\s*(?:rückgabe|rueckgabe|return|ausgabeformat|berichtsformat)\b/i],
130]
131
132/** The mandatory brief fields a prompt leaves out, by their German label. */
133export const missingBriefFields = (prompt: string): string[] =>
134  FIELD_PATTERNS.filter(([, pattern]) => !pattern.test(prompt)).map(([label]) => label)
135
136export const briefDenial = (missing: readonly string[]) =>
137  `prompt-boost: Briefing unvollständig, es fehlt ${missing.join(', ')}. ` +
138  'Ohne Endkriterium und Rückgabeformat braucht ein Agent die meisten Runden. ' +
139  'Stell den Auftrag noch einmal mit diesen Feldern:\n' +
140  'Ziel: …\nKontext: … (Pfade, bisherige Befunde, Ausgeschlossenes)\nGrenzen: …\nFertig wenn: …\nRückgabe: …\n' +
141  'Ein zweiter Versuch mit derselben Beschreibung geht in jedem Fall durch.'
142
143export const FORK_DENIAL =
144  'prompt-boost: Ein Fork erbt den kompletten Gesprächsverlauf und startet mit bis zu 180k Tokens. ' +
145  'Frag die Person im Chat, ob sie diesen Fork freigibt, und nenne kurz den Grund. ' +
146  'Stimmt sie zu, geht genau ein Fork durch. Sonst starte einen normalen Agenten mit vollständigem Briefing.'
147
148const AFFIRMATIVE = /^\s*(ja|jo|jep|ok|okay|yes|klar|gerne|passt|mach|los|fork|freigegeben)\b/i
149
150/** Whether the person's reply approves the fork the main loop asked about. */
151export const isApproval = (text: string) => AFFIRMATIVE.test(text)
152
153/** The fork gate after the person's next prompt: approved once, or closed. */
154export const settleFork = (gate: ForkGate, text: string): ForkGate => ({
155  isAsked: false,
156  isApproved: gate.isAsked && isApproval(text),
157})
158
159export const LAGEBILD_TOOL = 'Lagebild'
160export const LAGEBILD_TOOL_ID = `mcp__${PLUGIN}__${LAGEBILD_TOOL}`
161export const LAGEBILD_COMMAND = 'lagebild'
162export const COSTS_COMMAND = 'agent-kosten'
163
164/** The tool the main loop records findings with; deferred off, so it is at hand. */
165export const LAGEBILD_TOOL_SPEC = {
166  name: LAGEBILD_TOOL,
167  description:
168    'Hält einen Befund, eine ausgeschlossene Spur oder eine Entscheidung fürs Lagebild fest, ' +
169    'das jeder neue Subagent mitbekommt. Ein kurzer Satz pro Aufruf, mit Pfad oder Messwert, wenn vorhanden.',
170  inputSchema: {
171    type: 'object',
172    properties: {
173      art: { type: 'string', enum: ['befund', 'ausgeschlossen', 'entscheidung'] },
174      text: { type: 'string', description: 'Ein Satz, z. B. „Steuerlogik liegt in src/tax.ts:40“.' },
175    },
176    required: ['art', 'text'],
177  },
178  isDeferred: false,
179} as const
180
181export const LEAN_COMMANDS = [
182  { name: LAGEBILD_COMMAND, description: 'Zeigt das Lagebild, das neue Agenten bekommen' },
183  { name: COSTS_COMMAND, description: 'Misst die Tokenkosten der Subagenten vor und seit „Sparsame Agenten“' },
184] as const
185
186/** The note a running agent gets at a warning round. */
187export const roundNote = (round: number) =>
188  `[prompt-boost] Runde ${round}. Prüfe, ob „Fertig wenn“ erreicht ist. ` +
189  'Wenn ja, schließ jetzt mit dem Bericht ab. Wenn nein, nenne im Bericht, was dich aufhält, statt weiter zu suchen.'
190
191/** 67753 → "68k", 7802540 → "7,8 Mio." */
192export const formatTokens = (tokens: number): string => {
193  if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1).replace('.', ',')} Mio.`
194  if (tokens >= 1_000) return `${Math.round(tokens / 1_000)}k`
195
196  return String(tokens)
197}
198
199/** One running agent in the status band. */
200export const agentLine = (stat: AgentStat) =>
201  `${stat.type.replace(`${PLUGIN}:`, '')} · ${stat.label} · Runde ${stat.rounds} · ${formatTokens(stat.lastContext)} Kontext`
202
203/** The person's line after an agent ended. */
204export const summaryLine = (stat: AgentStat) =>
205  `prompt-boost: Agent „${stat.label}“ fertig (${stat.type}` +
206  (stat.routedFrom === null ? '' : `, umgeleitet von ${stat.routedFrom}`) +
207  `, ${stat.model}) · ${stat.rounds} Runden · Start ${formatTokens(stat.startContext)}` +
208  ` · Summe ${formatTokens(stat.totalInput)} Input`
209
hooks/model-profiles.ts 53 lines
1import type { ModelFamily } from '../types'
2
3type Profile = {
4  label: string
5  /** How a prompt for this kind of model should be written. */
6  style: string
7}
8
9const PROFILES: Record<ModelFamily, Profile> = {
10  frontier: {
11    label: 'Opus/Fable',
12    style: [
13      'Das Zielmodell ist ein Spitzenmodell mit starkem eigenem Urteil.',
14      'Beschreibe Ziel, Zweck, Kontext und Qualitätsmaßstab klar, aber schreibe keine Mikro-Schritte vor.',
15      'Nenne Randbedingungen und echte Abwägungen. Verlange Prüfung gegen das Endkriterium statt bloßer Behauptung.',
16      'Keine Großbuchstaben-Dringlichkeit, keine Drohungen, kein Rollenspiel-Füllwerk: das verschlechtert die Antwort.',
17    ].join(' '),
18  },
19  balanced: {
20    label: 'Sonnet',
21    style: [
22      'Das Zielmodell ist ein starkes Allround-Modell.',
23      'Gliedere klar: Ziel, Kontext, Anforderungen als Liste, Randbedingungen, Endkriterium, Ausgabeformat.',
24      'Gib eine sinnvolle Reihenfolge vor, wenn die Aufgabe mehrere Schritte hat.',
25    ].join(' '),
26  },
27  fast: {
28    label: 'Haiku',
29    style: [
30      'Das Zielmodell ist schnell und klein.',
31      'Eine eng umrissene Aufgabe, kurze direkte Sätze, explizite Schritte, ein festes Ausgabeformat.',
32      'Wenn das Format wichtig ist, gib ein kurzes Beispiel. Lass nichts implizit.',
33    ].join(' '),
34  },
35  unknown: {
36    label: 'unbekanntes Modell',
37    style:
38      'Gliedere klar in Ziel, Kontext, Anforderungen, Randbedingungen, Endkriterium und Ausgabeformat.',
39  },
40}
41
42/** Maps a model id or alias (`claude-opus-5-5`, `sonnet`) to its family. */
43export const familyOf = (model: string): ModelFamily => {
44  const id = model.toLowerCase()
45  if (/opus|fable/.test(id)) return 'frontier'
46  if (/sonnet/.test(id)) return 'balanced'
47  if (/haiku/.test(id)) return 'fast'
48
49  return 'unknown'
50}
51
52export const profileOf = (family: ModelFamily): Profile => PROFILES[family]
53
hooks/optimizer.ts 193 lines
1import type { ModelCompleteRequest } from 'claude-code'
2
3import type { Complexity, ModelFamily } from '../types'
4import { familyOf, profileOf } from './model-profiles'
5
6/** Past this length a prompt is mostly pasted material: left as it is. */
7export const MAX_INPUT_CHARS = 20_000
8const TIMEOUT_MS = 25_000
9/** A rewrite far shorter than the original has most likely dropped details. */
10const MIN_REWRITE_RATIO = 0.6
11
12export type Verdict = {
13  isSkipped: boolean
14  needsContext: boolean
15  reason: string
16  intent: string
17  gaps: string[]
18  questions: string[]
19  assumptions: string[]
20  complexity: Complexity
21  skill: string
22  lessons: string[]
23  brief: string
24}
25
26/** What the analyser knows beyond the prompt itself. */
27export type Extras = {
28  /** The person's prompt style, distilled from their ratings. */
29  personalStyle: string
30  /** The core table of the skill router. */
31  router: string
32}
33
34const JSON_SHAPE = `{"skip": boolean, "needsContext": boolean, "reason": "ein Satz", "intent": "ein Satz", "gaps": ["..."], "questions": ["..."], "assumptions": ["..."], "complexity": "einfach" | "mittel" | "schwer", "skill": "Name oder leer", "lessons": ["..."], "brief": "das Briefing oder leer"}`
35
36const rules = (style: string, extras: Extras) => `Zielmodell: ${style}
37
38Felder:
39- skip: true, wenn der Prompt schon klar und vollständig ist oder eine kurze Antwort, Bestätigung oder Plauderei ist.
40- gaps: Lücken, die du mit einer vernünftigen Annahme überbrücken kannst. Die Annahme gehört in assumptions, markiert mit „Annahme:“.
41- questions: nur Punkte, die das Ergebnis wesentlich verändern und sich nicht sinnvoll annehmen lassen. Höchstens drei, jede als konkrete Frage. Im Zweifel lieber eine Annahme.
42- complexity: einfach (Nachschlagen, kleine Änderung, kurze Antwort), mittel (normale Umsetzung oder Text), schwer (Architektur, schwierige Abwägung, viele Dateien, heikle Fehlersuche).
43- skill: der eine passende Skill oder das Werkzeug aus der Router-Tabelle unten, wenn ein Auslöser klar passt. Sonst leer. Bei kurzen Fragen und Erklärungen immer leer.
44- lessons: 1 bis 3 Schreibtipps an die Person, wie sie genau diesen Prompt selbst besser hätte formulieren können. Je ein kurzer Satz, konkret auf diesen Prompt bezogen (etwa „Nenne den Ordner, in dem die Rechnungen liegen“), keine allgemeinen Floskeln. Leer, wenn der Prompt schon gut war.
45- brief: ein Arbeitsbriefing für das Zielmodell, in der Sprache des Prompts.
46
47Regeln für das Briefing:
48- Übernimm jedes konkrete Detail (Pfade, Namen, Zahlen, Zitate) wörtlich. Nichts weglassen.
49- Erfinde keine Fakten und erweitere den Umfang nicht.
50- Gliedere nur so weit, wie die Aufgabe es braucht. Mögliche Abschnitte: Ziel, Kontext, Anforderungen, Randbedingungen, Fertig wenn, Ausgabe.
51- „Fertig wenn" ist ein prüfbares Endkriterium.
52${extras.personalStyle === '' ? '' : `\nVorlieben dieser Person, gelernt aus ihren Bewertungen (beachten):\n${extras.personalStyle}\n`}${extras.router === '' ? '' : `\nRouter-Tabelle für skill:\n${extras.router}\n`}
53Antworte ausschließlich mit einem JSON-Objekt, ohne Codeblock:
54${JSON_SHAPE}`
55
56const ANALYSE_SYSTEM = (style: string, extras: Extras) => `Du bist Prompt-Ingenieur für eine Coding- und Arbeitssitzung mit Claude.
57Du bekommst einen Prompt, den eine Person gerade geschrieben hat. Du siehst den bisherigen Gesprächsverlauf NICHT.
58
59Setze needsContext auf true (und skip auf true), wenn der Prompt sich auf den Gesprächsverlauf bezieht („wie oben", „mach weiter", „das", „Punkt 2") und ohne ihn nicht sinnvoll zu verstehen ist.
60Fragen danach, welches Ding, welche Datei, welcher Ort oder welche Plattform gemeint ist, gehören nicht in questions: Der Verlauf beantwortet sie meist. Setze in diesem Fall needsContext auf true.
61
62${rules(style, extras)}`
63
64const AGENT_SYSTEM = (style: string) => `Du bist Prompt-Ingenieur. Du bekommst den Auftrag, den ein Hauptagent einem Subagenten geben will.
65Der Subagent startet ohne jeden Gesprächsverlauf: Er kennt nur diesen Text.
66
67Zielmodell des Subagenten: ${style}
68
69Schreibe den Auftrag so um, dass ein fähiger Kollege ohne Vorwissen ihn sofort richtig ausführt:
70- Ziel und Zweck (warum die Aufgabe wichtig ist und wofür das Ergebnis gebraucht wird)
71- Bekannter Kontext: alle Pfade, Namen, Befunde und Einschränkungen aus dem Original, wörtlich
72- Umfang und Grenzen: was ausdrücklich nicht zu tun ist
73- Fertig wenn: ein prüfbares Endkriterium
74- Rückgabe: welches Format der Bericht haben soll
75
76Regeln: Nichts erfinden, nichts weglassen, den Umfang nicht erweitern. Sprache des Originals beibehalten.
77Antworte ausschließlich mit dem neuen Auftrag, ohne Einleitung und ohne Codeblock.`
78
79const asStrings = (value: unknown): string[] =>
80  Array.isArray(value) ? value.filter((v): v is string => typeof v === 'string' && v.trim() !== '') : []
81
82const asText = (value: unknown) => (typeof value === 'string' ? value.trim() : '')
83
84const asComplexity = (value: unknown): Complexity =>
85  value === 'einfach' || value === 'schwer' ? value : 'mittel'
86
87/** Reads the analysis JSON out of a reply, tolerating text around it. */
88export const parseVerdict = (reply: string): Verdict | null => {
89  const start = reply.indexOf('{')
90  const end = reply.lastIndexOf('}')
91  if (start === -1 || end <= start) return null
92
93  try {
94    const raw = JSON.parse(reply.slice(start, end + 1)) as Record<string, unknown>
95    const brief = asText(raw.brief)
96    const needsContext = raw.needsContext === true
97
98    return {
99      isSkipped: raw.skip === true || needsContext || brief === '',
100      needsContext,
101      reason: asText(raw.reason),
102      intent: asText(raw.intent),
103      gaps: asStrings(raw.gaps),
104      questions: asStrings(raw.questions).slice(0, 3),
105      assumptions: asStrings(raw.assumptions),
106      complexity: asComplexity(raw.complexity),
107      skill: asText(raw.skill),
108      lessons: asStrings(raw.lessons).slice(0, 3),
109      brief,
110    }
111  } catch {
112    return null
113  }
114}
115
116/** Steps 1 and 2: the quick request, without the conversation. */
117export const analyseRequest = (args: {
118  text: string
119  model: string
120  optimizerModel: string
121  extras: Extras
122}): ModelCompleteRequest => ({
123  model: args.optimizerModel,
124  system: ANALYSE_SYSTEM(profileOf(familyOf(args.model)).style, args.extras),
125  prompt: `<prompt>\n${args.text}\n</prompt>`,
126  maxTokens: 3000,
127  effort: 'low',
128  timeoutMs: TIMEOUT_MS,
129})
130
131/** The quick analysis's questions, for the fork to check against the conversation. */
132const candidatesNote = (candidates: readonly string[]) =>
133  candidates.length === 0
134    ? ''
135    : `\nEine Analyse ohne Verlauf hätte diese Fragen gestellt. Übernimm in questions nur die, die der Verlauf nicht beantwortet:\n${candidates.map(question => `- ${question}`).join('\n')}\n`
136
137/**
138 * The context request: one message appended to the session's own transcript,
139 * so the analyser reads the whole conversation from the prompt cache.
140 */
141export const forkPrompt = (args: { text: string; model: string; extras: Extras; candidates: readonly string[] }) =>
142  `[prompt-boost, interne Analyse. Führe nichts aus und rufe keine Werkzeuge auf.]
143Die Person hat gerade diesen neuen Prompt geschrieben. Analysiere ihn vor dem Hintergrund des bisherigen Gesprächs und löse Verweise wie „wie oben" oder „Punkt 2" mit dem Verlauf auf.
144
145<prompt>
146${args.text}
147</prompt>
148${candidatesNote(args.candidates)}
149${rules(profileOf(familyOf(args.model)).style, args.extras)}`
150
151/** Step 4: the request that rewrites a subagent's task as a self-contained brief. */
152export const agentRequest = (args: { prompt: string; targetModel: string; optimizerModel: string }): ModelCompleteRequest => ({
153  model: args.optimizerModel,
154  system: AGENT_SYSTEM(profileOf(familyOf(args.targetModel)).style),
155  prompt: `<auftrag>\n${args.prompt}\n</auftrag>`,
156  maxTokens: 4000,
157  effort: 'low',
158  timeoutMs: TIMEOUT_MS,
159})
160
161/** Keeps a rewrite only when it has not obviously dropped details. */
162export const acceptAgentRewrite = (original: string, reply: string): string | null => {
163  const text = reply.trim()
164
165  return text.length >= original.length * MIN_REWRITE_RATIO ? text : null
166}
167
168const FAMILY_FOR: Record<Complexity, { family: ModelFamily; alias: string; label: string }> = {
169  einfach: { family: 'fast', alias: 'haiku', label: 'Haiku' },
170  mittel: { family: 'balanced', alias: 'sonnet', label: 'Sonnet' },
171  schwer: { family: 'frontier', alias: 'opus', label: 'Opus' },
172}
173
174const RANK: Record<ModelFamily, number> = { fast: 0, balanced: 1, frontier: 2, unknown: -1 }
175
176/**
177 * Idea 4: a hint when the selected model clearly does not fit. Saving is
178 * suggested for simple tasks only, so a big model on normal work stays quiet.
179 */
180export const modelTip = (current: ModelFamily, complexity: Complexity): string => {
181  const fit = FAMILY_FOR[complexity]
182  if (current === 'unknown' || current === fit.family) return ''
183  if (RANK[current] < RANK[fit.family]) {
184    return `Tipp: Die Aufgabe ist anspruchsvoll, ${fit.label} liefert hier verlässlicher (/model ${fit.alias}).`
185  }
186
187  if (complexity !== 'einfach') return ''
188
189  return current === 'frontier'
190    ? 'Tipp: Für diese einfache Aufgabe reicht Haiku oder Sonnet, das spart Kosten (/model sonnet).'
191    : 'Tipp: Für diese einfache Aufgabe reicht Haiku, das spart Kosten (/model haiku).'
192}
193
hooks/reports.ts 16 lines
1/** The sections the work contract asks every subagent report to have. */
2const SECTIONS: readonly { label: string; pattern: RegExp }[] = [
3  { label: 'Ergebnis', pattern: /ergebnis/i },
4  { label: 'Belege', pattern: /belege?|nachweis/i },
5  { label: 'Nicht geprüft', pattern: /nicht\s+gepr(ü|ue)ft|ungepr(ü|ue)ft|unsicher/i },
6]
7
8/** Idea 5: which contract sections a subagent's final answer leaves out. */
9export const missingSections = (answer: string): string[] =>
10  SECTIONS.filter(section => !section.pattern.test(answer)).map(section => section.label)
11
12/** What the main loop reads about a report that broke the contract. */
13export const reportNote = (description: string, missing: readonly string[]) =>
14  `[prompt-boost] Der Bericht des Agenten „${description}" enthält keinen Abschnitt ${missing.join(', ')}. ` +
15  'Behandle seine Aussagen als ungeprüft und prüfe die entscheidenden selbst, bevor du darauf handelst.'
16
hooks/router.ts 21 lines
1/** Default location of the skill router; `~` is the home directory. */
2export const DEFAULT_ROUTER_PATH = '~/.claude/skill-router/ROUTER.md'
3const MAX_TABLE_CHARS = 6000
4
5export const expandHome = (path: string, home: string | undefined) =>
6  path.startsWith('~/') && home !== undefined ? `${home}${path.slice(1)}` : path
7
8/**
9 * Idea 6: the router's core list (the section headed "Kernliste") as the
10 * analyser reads it; the whole file when it has no such section.
11 */
12export const coreTable = (markdown: string): string => {
13  const start = markdown.search(/^##\s+Kernliste/m)
14  if (start === -1) return markdown.slice(0, MAX_TABLE_CHARS)
15
16  const rest = markdown.slice(start)
17  const end = rest.slice(2).search(/^##\s/m)
18
19  return (end === -1 ? rest : rest.slice(0, end + 2)).trim().slice(0, MAX_TABLE_CHARS)
20}
21
hooks/state.ts 9 lines
1import type { ForkGate, LagebildState } from '../types'
2
3// Atoms must be declared in the file that reads them (the mod scan follows
4// them only there), so register.tsx and agent-watch.ts each declare their own
5// with the same plugin and key. They share these starting values.
6
7export const EMPTY_LAGEBILD: LagebildState = { notes: [], read: [], changed: [] }
8export const CLOSED_FORK_GATE: ForkGate = { isAsked: false, isApproved: false }
9
hooks/lagebild.ts 88 lines
1import type { LagebildNote, LagebildState, NoteKind } from '../types'
2
3/** Marks a prompt the Lagebild was already appended to. */
4export const LAGEBILD_MARK = '## Lagebild (prompt-boost)'
5
6/** About 1,500 tokens at four characters per token. */
7export const MAX_LAGEBILD_CHARS = 6000
8
9const MAX_FILES = 15
10const MAX_NOTES = 30
11const MAX_GOAL_CHARS = 600
12const MAX_GIT_LINES = 15
13
14/** What the session tells about itself when an agent starts. */
15export type LagebildFacts = {
16  goal: string
17  cwd: string
18  git: string
19}
20
21export const NOTE_KINDS: readonly NoteKind[] = ['befund', 'ausgeschlossen', 'entscheidung']
22
23export const asNoteKind = (value: unknown): NoteKind | null =>
24  NOTE_KINDS.find(kind => kind === value) ?? null
25
26const pushUnique = (list: readonly string[], item: string, max: number) =>
27  [...list.filter(entry => entry !== item), item].slice(-max)
28
29/** Records a file the main loop read or changed; a changed file leaves "read". */
30export const withFile = (state: LagebildState, kind: 'read' | 'changed', path: string): LagebildState =>
31  kind === 'changed'
32    ? { ...state, changed: pushUnique(state.changed, path, MAX_FILES), read: state.read.filter(p => p !== path) }
33    : state.changed.includes(path)
34      ? state
35      : { ...state, read: pushUnique(state.read, path, MAX_FILES) }
36
37export const withNote = (state: LagebildState, note: LagebildNote): LagebildState => ({
38  ...state,
39  notes: [...state.notes, { ...note, text: note.text.trim() }].slice(-MAX_NOTES),
40})
41
42const HEADINGS: Record<NoteKind, string> = {
43  entscheidung: 'Entscheidungen',
44  befund: 'Befunde',
45  ausgeschlossen: 'Ausgeschlossen',
46}
47
48const section = (title: string, items: readonly string[]) =>
49  items.length === 0 ? '' : `\n**${title}**\n${items.map(item => `- ${item}`).join('\n')}`
50
51const clip = (text: string, max: number) => (text.length <= max ? text : `${text.slice(0, max - 1)}…`)
52
53const compose = (state: LagebildState, facts: LagebildFacts): string => {
54  const git = facts.git.trim().split('\n').filter(Boolean).slice(0, MAX_GIT_LINES)
55  const notesOf = (kind: NoteKind) => state.notes.filter(note => note.kind === kind).map(note => note.text)
56
57  return [
58    LAGEBILD_MARK,
59    'Was die Hauptsession schon weiß. Lies Bekanntes nicht neu ein, außer der Auftrag verlangt es.',
60    `\n**Arbeitsverzeichnis** ${facts.cwd}`,
61    facts.goal.trim() === '' ? '' : `\n**Ziel der Session**\n${clip(facts.goal.trim(), MAX_GOAL_CHARS)}`,
62    git.length === 0 ? '' : `\n**Git**\n${git.join('\n')}`,
63    ...NOTE_KINDS.map(kind => section(HEADINGS[kind], notesOf(kind))),
64    section('Geändert in dieser Session', state.changed),
65    section('Bereits gelesen', state.read),
66  ]
67    .filter(part => part !== '')
68    .join('\n')
69}
70
71/**
72 * The Lagebild as an agent reads it, within MAX_LAGEBILD_CHARS: the oldest
73 * notes go first, then the oldest read files, then the text is cut.
74 */
75export const renderLagebild = (state: LagebildState, facts: LagebildFacts): string => {
76  let current = state
77  let text = compose(current, facts)
78  while (text.length > MAX_LAGEBILD_CHARS && (current.notes.length > 0 || current.read.length > 0)) {
79    current =
80      current.notes.length > 0
81        ? { ...current, notes: current.notes.slice(1) }
82        : { ...current, read: current.read.slice(1) }
83    text = compose(current, facts)
84  }
85
86  return clip(text, MAX_LAGEBILD_CHARS)
87}
88