feat(prompts): add editable system/planning/agent prompt files

Introduces ~/.todo-app/prompts/{system,planning,agent}.md as the canonical
location for prompt content. The settings modal exposes "Open in editor"
shortcuts for each, and TaskRunner merges system.md (always) and agent.md
(for "agent"-tagged tasks) into the effective system prompt alongside the
existing global/list/task layers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
mika kuns
2026-04-25 10:10:50 +02:00
parent 6c54759aa0
commit 7f96ae9508
4 changed files with 124 additions and 7 deletions

View File

@@ -0,0 +1,58 @@
namespace ClaudeDo.Data;
public enum PromptKind { System, Planning, Agent }
public static class PromptFiles
{
public static string Root => Path.Combine(Paths.AppDataRoot(), "prompts");
public static string PathFor(PromptKind kind) => kind switch
{
PromptKind.System => Path.Combine(Root, "system.md"),
PromptKind.Planning => Path.Combine(Root, "planning.md"),
PromptKind.Agent => Path.Combine(Root, "agent.md"),
_ => throw new ArgumentOutOfRangeException(nameof(kind))
};
public static void EnsureExists(PromptKind kind)
{
Directory.CreateDirectory(Root);
var path = PathFor(kind);
if (File.Exists(path)) return;
File.WriteAllText(path, DefaultFor(kind));
}
public static string? ReadOrNull(PromptKind kind)
{
var path = PathFor(kind);
if (!File.Exists(path)) return null;
var content = File.ReadAllText(path).Trim();
return string.IsNullOrEmpty(content) ? null : content;
}
private static string DefaultFor(PromptKind kind) => kind switch
{
PromptKind.System =>
"# System Prompt\n\n" +
"Baseline instructions appended to every task run.\n" +
"Edit this file to inject project-wide rules (style, conventions, hard constraints).\n",
PromptKind.Planning =>
"You are a planning assistant for ClaudeDo.\n" +
"Your role is to help break down a task into smaller, actionable subtasks.\n" +
"Your final goal WILL ALWAYS be the creation of Subtasks.\n\n" +
"ALWAYS invoke the `superpowers:brainstorming` skill via the Skill tool at the\n" +
"start of every planning session, and follow its process end-to-end. It guides\n" +
"you through clarifying questions, approach exploration, and design approval\n" +
"BEFORE any subtasks are created. Do not create child tasks until the user has\n" +
"approved a design.\n\n" +
"NEVER change files yourself.\n\n" +
"ALWAYS use the available MCP tools (mcp__claudedo__*) to create child tasks once\n" +
"the design is approved. When you are done planning, finalize the session.\n\n" +
"Be concise and focused. Each subtask should be independently executable.\n",
PromptKind.Agent =>
"# Agent Prompt\n\n" +
"Appended to the system prompt for tasks tagged \"agent\" (auto-queued runs).\n" +
"Use this for autonomous-execution rules that don't apply to manual runs.\n",
_ => ""
};
}

View File

@@ -38,6 +38,10 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
public string LogsFolderPath { get; } = Path.Combine(Paths.AppDataRoot(), "logs");
public string WorkerConfigPath { get; } = Path.Combine(Paths.AppDataRoot(), "worker.config.json");
public string SystemPromptPath { get; } = PromptFiles.PathFor(PromptKind.System);
public string PlanningPromptPath { get; } = PromptFiles.PathFor(PromptKind.Planning);
public string AgentPromptPath { get; } = PromptFiles.PathFor(PromptKind.Agent);
public Action? CloseAction { get; set; }
public SettingsModalViewModel(WorkerClient worker)
@@ -199,4 +203,20 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
}
catch { /* ignore */ }
}
[RelayCommand]
private void OpenPrompt(string? kindName)
{
if (!Enum.TryParse<PromptKind>(kindName, ignoreCase: true, out var kind)) return;
try
{
PromptFiles.EnsureExists(kind);
var path = PromptFiles.PathFor(kind);
Process.Start(new ProcessStartInfo(path) { UseShellExecute = true });
}
catch (Exception ex)
{
StatusMessage = $"Open failed: {ex.Message}";
}
}
}

View File

@@ -201,6 +201,38 @@
</Border>
</StackPanel>
<!-- PROMPTS -->
<StackPanel Spacing="0">
<TextBlock Classes="section-label" Text="PROMPTS"/>
<Border Classes="section">
<Grid RowDefinitions="Auto,Auto,Auto" ColumnDefinitions="90,*,Auto" RowSpacing="8">
<TextBlock Grid.Row="0" Grid.Column="0" Classes="field-label" Text="System"
VerticalAlignment="Center"/>
<TextBlock Grid.Row="0" Grid.Column="1" Classes="path-mono"
Text="{Binding SystemPromptPath}" VerticalAlignment="Center"/>
<Button Grid.Row="0" Grid.Column="2" Content="Open in editor"
Command="{Binding OpenPromptCommand}"
CommandParameter="System"/>
<TextBlock Grid.Row="1" Grid.Column="0" Classes="field-label" Text="Planning"
VerticalAlignment="Center"/>
<TextBlock Grid.Row="1" Grid.Column="1" Classes="path-mono"
Text="{Binding PlanningPromptPath}" VerticalAlignment="Center"/>
<Button Grid.Row="1" Grid.Column="2" Content="Open in editor"
Command="{Binding OpenPromptCommand}"
CommandParameter="Planning"/>
<TextBlock Grid.Row="2" Grid.Column="0" Classes="field-label" Text="Agent"
VerticalAlignment="Center"/>
<TextBlock Grid.Row="2" Grid.Column="1" Classes="path-mono"
Text="{Binding AgentPromptPath}" VerticalAlignment="Center"/>
<Button Grid.Row="2" Grid.Column="2" Content="Open in editor"
Command="{Binding OpenPromptCommand}"
CommandParameter="Agent"/>
</Grid>
</Border>
</StackPanel>
<!-- ABOUT -->
<StackPanel Spacing="0">
<TextBlock Classes="section-label" Text="ABOUT"/>

View File

@@ -404,14 +404,22 @@ public sealed class TaskRunner
TaskEntity task, ListConfigEntity? listConfig, string? resumeSessionId, CancellationToken ct)
{
AppSettingsEntity global;
bool isAgentTask;
using (var ctx = _dbFactory.CreateDbContext())
{
var settingsRepo = new AppSettingsRepository(ctx);
global = await settingsRepo.GetAsync(ct);
var taskRepo = new TaskRepository(ctx);
var tags = await taskRepo.GetEffectiveTagsAsync(task.Id, ct);
isAgentTask = tags.Any(t => string.Equals(t.Name, "agent", StringComparison.OrdinalIgnoreCase));
}
var systemFile = PromptFiles.ReadOrNull(PromptKind.System);
var agentFile = isAgentTask ? PromptFiles.ReadOrNull(PromptKind.Agent) : null;
var instructions = MergeInstructions(
global.DefaultClaudeInstructions, listConfig?.SystemPrompt, task.SystemPrompt);
systemFile, global.DefaultClaudeInstructions, listConfig?.SystemPrompt, task.SystemPrompt, agentFile);
return new ClaudeRunConfig(
Model: task.Model ?? listConfig?.Model ?? global.DefaultModel,
@@ -422,12 +430,11 @@ public sealed class TaskRunner
PermissionMode: global.DefaultPermissionMode);
}
public static string MergeInstructions(string? global, string? list, string? task)
public static string MergeInstructions(params string?[] parts)
{
var parts = new List<string>(3);
if (!string.IsNullOrWhiteSpace(global)) parts.Add(global.Trim());
if (!string.IsNullOrWhiteSpace(list)) parts.Add(list.Trim());
if (!string.IsNullOrWhiteSpace(task)) parts.Add(task.Trim());
return string.Join("\n\n", parts);
var trimmed = parts
.Where(p => !string.IsNullOrWhiteSpace(p))
.Select(p => p!.Trim());
return string.Join("\n\n", trimmed);
}
}