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

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
| Schritt | Was passiert |
|---|---|
| 1. Analyse | Ein 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. Optimierung | Das Briefing wird auf das aktive Modell zugeschnitten (siehe Modellprofile). |
| 3. Arbeiten | Dein Originaltext bleibt unverändert. Das Briefing geht als unsichtbarer Zusatzkontext ans Modell mit, und bei Widersprüchen gilt dein Original. |
| 4. Agenten | Jeder 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:
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"
| Befehl | Wirkung |
|---|---|
/prompt-boost oder /prompt-boost status | Zeigt Modus, aktives Modell, Einstellungen und die letzte Analyse. |
/prompt-boost an | Modus „ergänzen" (Standard). Das Original bleibt, das Briefing geht als Zusatzkontext mit. |
/prompt-boost ersetzen | Das Briefing ersetzt den Prompt, dein Wortlaut hängt darunter. |
/prompt-boost aus | Schaltet 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 stil | Zeigt deinen gelernten Prompt-Stil. |
/prompt-boost stil zurücksetzen | Löscht Stil und alle Bewertungen. |
/lagebild | Zeigt das Lagebild, das der nächste Agent mitbekommt. |
/agent-kosten | Misst die Tokenkosten deiner Subagenten vor und seit „Sparsame Agenten“ und prüft die Ziele. |
Modus und Stil gelten sitzungsübergreifend.
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).
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]
| Zeichen | Bedeutung |
|---|---|
… | 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.
Zu ändern im Terminal mit /plugin configure prompt-boost@willis-mods oder beim Installieren mit --config schluessel=wert.
| Schlüssel | Typ | Standard | Bedeutung |
|---|---|---|---|
optimizerModel | haiku · sonnet · opus | sonnet | Welches Modell analysiert und umschreibt. haiku ist am schnellsten. |
minChars | Zahl | 40 | Kürzere Prompts werden nicht analysiert. |
optimizeAgents | ja/nein | ja | Subagent-Aufträge umschreiben und Arbeitsvertrag anhängen. |
contextAnalysis | ja/nein | ja | Zweiter Durchlauf mit Gesprächsverlauf, wenn der Prompt darauf verweist. |
askRating | ja/nein | ja | Bewertungsband nach optimierten Antworten. |
agentPolicy | fragen · frei | fragen | fragen: Claude fragt vor jedem Agenten um Erlaubnis. frei: Claude entscheidet selbst. |
routerPath | Pfad | ~/.claude/skill-router/ROUTER.md | Skill-Router mit Abschnitt „Kernliste". Leer schaltet Skill-Vorschläge ab. |
showBrief | ja/nein | ja | Original, verbesserte Fassung und Schreibtipps im Verlauf zeigen. |
showProgress | ja/nein | ja | Statusleiste über der Eingabe. |
leanAgents | ja/nein | ja | Schlanke Varianten registrieren und general-purpose passend umleiten. |
briefGate | ja/nein | ja | Briefing ohne Ziel, Fertig wenn oder Rückgabe einmal zurückweisen. |
lagebild | ja/nein | ja | Jedem neuen Agenten das Lagebild mitgeben, Werkzeug Lagebild anbieten. |
forkGate | ja/nein | ja | Forks erst nach deiner Zustimmung im Chat. |
roundWarning | ja/nein | ja | Notiz an den Agenten bei Runde 25 und 40. |
agentDisplay | ja/nein | ja | Laufende Agenten in der Statusleiste, Kostenzeile nach dem Ende. |
Die Mod liest das aktive Modell und schreibt das Briefing passend dazu.
| Profil | Modelle | So wird das Briefing geschrieben |
|---|---|---|
| Opus/Fable | opus, fable | Ziel, Zweck, Kontext und Qualitätsmaßstab. Keine Mikro-Schritte, dafür Abwägungen und Prüfung gegen das Endkriterium. |
| Sonnet | sonnet | Klare Gliederung mit Anforderungen als Liste, Endkriterium und Ausgabeformat. Bei mehreren Schritten eine Reihenfolge. |
| Haiku | haiku | Eine 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.
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:
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).
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.
| Baustein | Was passiert |
|---|---|
| Lagebild | Jeder 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 Varianten | prompt-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. |
| Umleitung | Ein 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. |
| Modell | Ein ausdrücklich gesetztes Modell bleibt. Sonst gilt das Modell der Variante, opus wird nie automatisch gewählt. |
| Briefing-Gate | Pflicht 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-Sperre | Ein 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. |
| Rundenwarnung | Bei Runde 25 und 40 bekommt der Agent eine Notiz, mit dem Bericht abzuschließen, wenn „Fertig wenn“ erreicht ist. |
| Anzeige | Laufende 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.
| Wann | Zusätzlicher Aufruf |
|---|---|
| Jeder analysierte Prompt | 1 Aufruf des Optimierer-Modells (Standard Sonnet, geringer Denkaufwand, höchstens 25 s) |
| Prompt mit Verweis auf den Verlauf | zusätzlich 1 Durchlauf über das Gespräch mit dem Hauptmodell, der größte Teil kommt aus dem Prompt-Cache |
| Jeder Subagent | 1 Aufruf des Optimierer-Modells, dazu ein git status für das Lagebild |
| Alle 5 Bewertungen | 1 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.
| Was | Wo | Umfang |
|---|---|---|
| Modus | Plugin-Speicher von Claude Code (mode) | ein Wert |
| Bewertungen | Plugin-Speicher (feedback) | höchstens 60, je Absicht, Modell, Bewertung, Notiz, Zeitpunkt |
| Gelernter Stil | Plugin-Speicher (style) | höchstens 10 Stichpunkte |
| Letzte Analyse, letzte 20 Agenten, Statusleiste | Sitzungszustand | nur für die laufende Session |
| Lagebild, Agentenzähler, Fork-Freigabe | Sitzungszustand | nur für die laufende Session, nichts landet im Projekt |
Startzeitpunkt für /agent-kosten | Plugin-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.
contextAnalysis aus, bleiben die Fragen ungeprüft.claude -p, SDK), Benachrichtigungen und anderen Sessions bleiben unberührt.umsetzung, also auf die Variante mit den meisten Werkzeugen.steuerberater bleiben deshalb unverändert.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.
$ darf nur an Funktionen gehen, die in derselben Datei stehen, nie über einen Import.register.tsx reicht on an registerAgentWatch in agent-watch.ts weiter, das ist erlaubt.session.start, prompt.submit und turn.complete gehören deshalb register.tsx.state.ts liefert nur die Startwerte.Alle übrigen Module sind reine Funktionen ohne Zugriff auf $ und dadurch ohne Engine testbar.
| Hook | Zweck |
|---|---|
prompt.submit | Analyse, Optimierung, Kontext, Statusleiste, Verlaufseintrag |
session.start | Befehle, Varianten und Werkzeug Lagebild registrieren |
agent.spawn | Fork-Sperre, Gate, Umleitung, Umschreiben, Lagebild, Vertrag |
turn.step | Runden und Kontext je Subagent zählen, Rundenwarnung |
turn.complete | Bewertungsband 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.compose | Agenten-Leitfaden in den Systemprompt |
ui.render (AbovePrompt, Pane) | Statusleiste, Bewertungsband, Panel |
command.run | /prompt-boost, /lagebild, /agent-kosten |
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.
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.
hooks/register.tsx 639 lines1import { 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}
639hooks/agent-guide.ts 57 lines1/** 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}`
57hooks/display.ts 113 lines1import 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')
113hooks/feedback.ts 36 lines1import 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})
36hooks/agent-watch.ts 262 lines1import { 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}
262hooks/lean-agents.ts 209 lines1import 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`
209hooks/model-profiles.ts 53 lines1import 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]
53hooks/optimizer.ts 193 lines1import 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}
193hooks/reports.ts 16 lines1/** 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.'
16hooks/router.ts 21 lines1/** 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}
21hooks/state.ts 9 lines1import 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 }
9hooks/lagebild.ts 88 lines1import 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