diff --git a/src/ClaudeDo.Data/ClaudeDo.Data.csproj b/src/ClaudeDo.Data/ClaudeDo.Data.csproj
index 81866bff..5c32d60f 100644
--- a/src/ClaudeDo.Data/ClaudeDo.Data.csproj
+++ b/src/ClaudeDo.Data/ClaudeDo.Data.csproj
@@ -17,6 +17,7 @@
+
diff --git a/src/ClaudeDo.Data/PromptFiles.cs b/src/ClaudeDo.Data/PromptFiles.cs
index 84f17e87..e60fccdc 100644
--- a/src/ClaudeDo.Data/PromptFiles.cs
+++ b/src/ClaudeDo.Data/PromptFiles.cs
@@ -1,39 +1,184 @@
+using System.Security.Cryptography;
using System.Text;
+using System.Text.Json;
namespace ClaudeDo.Data;
public enum PromptKind { System, Planning, PlanningInitial, Retry, DailyPrep, WeeklyReport, ImprovementChild, Refine, MergeHelper, MergeHelperInitial }
+///
+/// How a prompt kind's on-disk override (if any) relates to the bundled default.
+///
+public enum PromptFileState
+{
+ /// No override file — the bundled default is in effect.
+ Missing,
+ /// File exists and is byte-for-byte (normalized) the current default.
+ MatchesCurrentDefault,
+ /// File exists, differs from today's default, but was recorded as an unedited copy of a past default — safe to reconcile away.
+ MatchesKnownPastDefault,
+ /// File exists and diverges from the default with no matching recorded hash — a real user customization.
+ Edited
+}
+
public static class PromptFiles
{
public static string Root => Path.Combine(Paths.AppDataRoot(), "prompts");
- public static string PathFor(PromptKind kind) => kind switch
+ public static string PathFor(PromptKind kind, string? root = null) =>
+ Path.Combine(root ?? Root, FileNameFor(kind));
+
+ private static string FileNameFor(PromptKind kind) => kind switch
{
- PromptKind.System => Path.Combine(Root, "system.md"),
- PromptKind.Planning => Path.Combine(Root, "planning-system.md"),
- PromptKind.PlanningInitial => Path.Combine(Root, "planning-initial.md"),
- PromptKind.Retry => Path.Combine(Root, "retry.md"),
- PromptKind.DailyPrep => Path.Combine(Root, "daily-prep.md"),
- PromptKind.WeeklyReport => Path.Combine(Root, "weekly-report.md"),
- PromptKind.ImprovementChild => Path.Combine(Root, "improvement-child.md"),
- PromptKind.Refine => Path.Combine(Root, "refine.md"),
- PromptKind.MergeHelper => Path.Combine(Root, "merge-helper-system.md"),
- PromptKind.MergeHelperInitial => Path.Combine(Root, "merge-helper-initial.md"),
+ PromptKind.System => "system.md",
+ PromptKind.Planning => "planning-system.md",
+ PromptKind.PlanningInitial => "planning-initial.md",
+ PromptKind.Retry => "retry.md",
+ PromptKind.DailyPrep => "daily-prep.md",
+ PromptKind.WeeklyReport => "weekly-report.md",
+ PromptKind.ImprovementChild => "improvement-child.md",
+ PromptKind.Refine => "refine.md",
+ PromptKind.MergeHelper => "merge-helper-system.md",
+ PromptKind.MergeHelperInitial => "merge-helper-initial.md",
_ => throw new ArgumentOutOfRangeException(nameof(kind))
};
- public static void EnsureExists(PromptKind kind)
+ /// Classify an override file against the bundled default and the recorded default-hash log.
+ public static PromptFileState Classify(PromptKind kind, string? root = null)
{
- Directory.CreateDirectory(Root);
- var path = PathFor(kind);
- if (File.Exists(path)) return;
- File.WriteAllText(path, DefaultFor(kind));
+ var effectiveRoot = root ?? Root;
+ var path = PathFor(kind, effectiveRoot);
+ if (!File.Exists(path)) return PromptFileState.Missing;
+
+ var normalized = Normalize(File.ReadAllText(path));
+ if (normalized == Normalize(DefaultFor(kind))) return PromptFileState.MatchesCurrentDefault;
+
+ var hashes = LoadDefaultHashes(effectiveRoot);
+ if (hashes.TryGetValue(kind.ToString(), out var recorded) && recorded == HashOf(normalized))
+ return PromptFileState.MatchesKnownPastDefault;
+
+ return PromptFileState.Edited;
}
- public static string? ReadOrNull(PromptKind kind)
+ /// Write an explicit override. If the content matches today's default, its hash is recorded so a
+ /// later default change can reconcile it away automatically instead of freezing it forever.
+ public static void Save(PromptKind kind, string content, string? root = null)
{
- var path = PathFor(kind);
+ var effectiveRoot = root ?? Root;
+ Directory.CreateDirectory(effectiveRoot);
+ File.WriteAllText(PathFor(kind, effectiveRoot), content);
+
+ var normalized = Normalize(content);
+ var hashes = LoadDefaultHashes(effectiveRoot);
+ if (normalized == Normalize(DefaultFor(kind)))
+ hashes[kind.ToString()] = HashOf(normalized);
+ else
+ hashes.Remove(kind.ToString());
+ SaveDefaultHashes(effectiveRoot, hashes);
+ }
+
+ /// Delete the override file (if any) so the bundled default takes effect again.
+ public static void ResetToDefault(PromptKind kind, string? root = null)
+ {
+ var effectiveRoot = root ?? Root;
+ var path = PathFor(kind, effectiveRoot);
+ if (File.Exists(path)) File.Delete(path);
+
+ var hashes = LoadDefaultHashes(effectiveRoot);
+ if (hashes.Remove(kind.ToString())) SaveDefaultHashes(effectiveRoot, hashes);
+ }
+
+ /// Startup sweep: any override file that only ever matched a past (now superseded) default,
+ /// and was never actually edited, is dropped so the current default reaches real runs again.
+ public static void ReconcileStaleDefaults(string? root = null)
+ {
+ var effectiveRoot = root ?? Root;
+ foreach (var kind in Enum.GetValues())
+ if (Classify(kind, effectiveRoot) == PromptFileState.MatchesKnownPastDefault)
+ ResetToDefault(kind, effectiveRoot);
+ }
+
+ /// Startup sweep: moves any *.md file under the prompts root that doesn't match a known
+ /// path (leftovers from a retired naming scheme) into a "_orphans" subfolder.
+ /// Never deletes them outright. Returns the destination paths of files it moved.
+ public static IReadOnlyList QuarantineOrphans(string? root = null)
+ {
+ var effectiveRoot = root ?? Root;
+ if (!Directory.Exists(effectiveRoot)) return Array.Empty();
+
+ var known = Enum.GetValues()
+ .Select(k => PathFor(k, effectiveRoot))
+ .ToHashSet(StringComparer.OrdinalIgnoreCase);
+
+ var moved = new List();
+ foreach (var file in Directory.EnumerateFiles(effectiveRoot, "*.md", SearchOption.TopDirectoryOnly))
+ {
+ if (known.Contains(file)) continue;
+
+ var orphansDir = Path.Combine(effectiveRoot, "_orphans");
+ Directory.CreateDirectory(orphansDir);
+ var dest = Path.Combine(orphansDir, Path.GetFileName(file));
+ if (File.Exists(dest))
+ dest = Path.Combine(orphansDir,
+ $"{Path.GetFileNameWithoutExtension(file)}-{HashOf(file)[..8]}{Path.GetExtension(file)}");
+ File.Move(file, dest);
+ moved.Add(dest);
+ }
+ return moved;
+ }
+
+ /// Compact, non-LCS diff (common prefix/suffix trimmed, differing middle shown +/-) between the
+ /// bundled default and the on-disk override, for surfacing a customization in the Files settings tab.
+ public static string DiffAgainstDefault(PromptKind kind, string? root = null)
+ {
+ var effectiveRoot = root ?? Root;
+ var defaultLines = Normalize(DefaultFor(kind)).Split('\n');
+ var fileLines = Normalize(ReadOrNull(kind, effectiveRoot) ?? "").Split('\n');
+
+ var prefix = 0;
+ while (prefix < defaultLines.Length && prefix < fileLines.Length && defaultLines[prefix] == fileLines[prefix])
+ prefix++;
+ var suffix = 0;
+ while (suffix < defaultLines.Length - prefix && suffix < fileLines.Length - prefix &&
+ defaultLines[^(suffix + 1)] == fileLines[^(suffix + 1)])
+ suffix++;
+
+ var sb = new StringBuilder();
+ for (var i = prefix; i < defaultLines.Length - suffix; i++) sb.AppendLine("- " + defaultLines[i]);
+ for (var i = prefix; i < fileLines.Length - suffix; i++) sb.AppendLine("+ " + fileLines[i]);
+ return sb.ToString().TrimEnd();
+ }
+
+ internal static string Normalize(string s) => s.Replace("\r\n", "\n").Trim();
+
+ internal static string HashOf(string content) =>
+ Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(content)));
+
+ private static string DefaultsHashPath(string root) => Path.Combine(root, ".defaults.json");
+
+ private static Dictionary LoadDefaultHashes(string root)
+ {
+ var path = DefaultsHashPath(root);
+ if (!File.Exists(path)) return new();
+ try
+ {
+ return JsonSerializer.Deserialize>(File.ReadAllText(path)) ?? new();
+ }
+ catch (JsonException)
+ {
+ return new();
+ }
+ }
+
+ private static void SaveDefaultHashes(string root, Dictionary hashes)
+ {
+ Directory.CreateDirectory(root);
+ File.WriteAllText(DefaultsHashPath(root), JsonSerializer.Serialize(hashes));
+ }
+
+ public static string? ReadOrNull(PromptKind kind, string? root = null)
+ {
+ var path = PathFor(kind, root ?? Root);
if (!File.Exists(path)) return null;
var content = File.ReadAllText(path).Trim();
return string.IsNullOrEmpty(content) ? null : content;
diff --git a/src/ClaudeDo.Localization/locales/de.json b/src/ClaudeDo.Localization/locales/de.json
index 7a8bfdec..f4df7993 100644
--- a/src/ClaudeDo.Localization/locales/de.json
+++ b/src/ClaudeDo.Localization/locales/de.json
@@ -62,7 +62,10 @@
"retryPrompt": "Wiederholung",
"dailyPrepPrompt": "Tagesplanung",
"weeklyReportPrompt": "Wochenbericht",
- "openInEditor": "Im Editor öffnen"
+ "openInEditor": "Im Editor öffnen",
+ "customizedSection": "ANGEPASST",
+ "customizedHint": "Diese Prompts weichen vom mitgelieferten Standard ab und werden unverändert beibehalten. Zurücksetzen, um künftige Standard-Verbesserungen zu erhalten.",
+ "resetToDefault": "Auf Standard zurücksetzen"
},
"prime": {
"description": "Bereite dein Claude-Nutzungsfenster vor, indem an den von dir gewählten Tagen zu einer bestimmten Zeit ein einzelner nicht-interaktiver Ping ausgelöst wird. Läuft nur, solange ClaudeDo geöffnet ist. Wenn die App innerhalb von 30 Minuten vor der Zielzeit startet, wird der Ping sofort ausgelöst.",
@@ -594,7 +597,7 @@
"onlineInbox": { "workerOffline": "Worker offline — Konfiguration kann nicht geladen werden.", "saved": "Konfiguration gespeichert.", "saveFailed": "Speichern fehlgeschlagen: {0}", "signedIn": "Erfolgreich angemeldet.", "signedInNoRole": "Angemeldet, aber diesem Konto fehlt die Rolle 'user' in Zitadel — die Online-Synchronisierung wird abgelehnt, bis die Rolle im ClaudeDo-Projekt zugewiesen wird.", "signInFailed": "Anmeldung fehlgeschlagen: {0}", "signedOut": "Abgemeldet.", "signOutFailed": "Abmeldung fehlgeschlagen: {0}" },
"weeklyReport": { "invalidRange": "Ungültiger Datumsbereich.", "generating": "Bericht wird erstellt…", "error": "Fehler: {0}" },
"usageMonitor": { "loadFailed": "Nutzungsdaten konnten nicht geladen werden: {0}" },
- "filesTab": { "workerOffline": "Worker offline.", "noneBundled": "Keine Standard-Agenten mitgeliefert.", "allPresent": "Alle Standard-Agenten bereits vorhanden.", "restored": "{0} Standard-Agent(en) wiederhergestellt.", "restoreFailed": "Wiederherstellung fehlgeschlagen: {0}", "openFailed": "Öffnen fehlgeschlagen: {0}" },
+ "filesTab": { "workerOffline": "Worker offline.", "noneBundled": "Keine Standard-Agenten mitgeliefert.", "allPresent": "Alle Standard-Agenten bereits vorhanden.", "restored": "{0} Standard-Agent(en) wiederhergestellt.", "restoreFailed": "Wiederherstellung fehlgeschlagen: {0}", "openFailed": "Öffnen fehlgeschlagen: {0}", "resetToDefault": "Auf den mitgelieferten Standard zurückgesetzt." },
"sessionSkillsTab": { "installed": "Installiert: {0}", "installFailed": "Installation fehlgeschlagen: {0}", "updated": "Aktualisiert.", "updateFailed": "Aktualisierung fehlgeschlagen: {0}", "removed": "Entfernt.", "removeFailed": "Entfernen fehlgeschlagen: {0}" },
"worktreesTab": { "workerOffline": "Worker offline.", "removed": "{0} Worktree(s) entfernt.", "blocked": "Zwangsentfernung nicht möglich: {0} Aufgabe(n) laufen noch. Brich sie zuerst ab.", "removedFrom": "{0} Worktree(s) von {1} Aufgabe(n) entfernt.", "cleanupFailed": "Aufräumen fehlgeschlagen: {0}", "resetFailed": "Zurücksetzen fehlgeschlagen: {0}" },
"worktreesOverview": { "titleAll": "Worktrees", "titleList": "Worktrees — {0}", "listFallback": "Liste", "cleanupFailed": "Aufräumen fehlgeschlagen.", "cleanupFailedDetailed": "Aufräumen fehlgeschlagen: {0}", "removed": "{0} Worktree(s) entfernt.", "discardFailed": "Worktree konnte nicht verworfen werden.", "keepFailed": "Worktree konnte nicht behalten werden.", "cannotForceRunning": "Eine laufende Aufgabe kann nicht zwangsweise entfernt werden.", "forceRemoveFailed": "Zwangsentfernung fehlgeschlagen.", "forceRemoveFailedDetailed": "Zwangsentfernung fehlgeschlagen: {0}", "batchProgress": "Merge {0}/{1}…", "batchDone": "{0} gemergt, {1} zu lösen." },
diff --git a/src/ClaudeDo.Localization/locales/en.json b/src/ClaudeDo.Localization/locales/en.json
index 3df5d58c..257d27a0 100644
--- a/src/ClaudeDo.Localization/locales/en.json
+++ b/src/ClaudeDo.Localization/locales/en.json
@@ -62,7 +62,10 @@
"retryPrompt": "Retry",
"dailyPrepPrompt": "Daily prep",
"weeklyReportPrompt": "Weekly report",
- "openInEditor": "Open in editor"
+ "openInEditor": "Open in editor",
+ "customizedSection": "CUSTOMIZED",
+ "customizedHint": "These prompts differ from the bundled default and are kept as-is. Reset to pick up future default improvements.",
+ "resetToDefault": "Reset to default"
},
"prime": {
"description": "Prime your Claude usage window by firing a single non-interactive ping on the days you choose, at a chosen time. Only runs while ClaudeDo is open. If the app starts within 30 minutes of the target time, the ping fires immediately.",
@@ -594,7 +597,7 @@
"onlineInbox": { "workerOffline": "Worker offline — cannot load config.", "saved": "Config saved.", "saveFailed": "Save failed: {0}", "signedIn": "Signed in successfully.", "signedInNoRole": "Signed in, but this account is missing the 'user' role in Zitadel — online sync will be rejected until the role is granted in the ClaudeDo project.", "signInFailed": "Sign-in failed: {0}", "signedOut": "Signed out.", "signOutFailed": "Sign-out failed: {0}" },
"weeklyReport": { "invalidRange": "Invalid date range.", "generating": "Generating report…", "error": "Error: {0}" },
"usageMonitor": { "loadFailed": "Couldn't load usage data: {0}" },
- "filesTab": { "workerOffline": "Worker offline.", "noneBundled": "No default agents bundled.", "allPresent": "All default agents already present.", "restored": "Restored {0} default agent(s).", "restoreFailed": "Restore failed: {0}", "openFailed": "Open failed: {0}" },
+ "filesTab": { "workerOffline": "Worker offline.", "noneBundled": "No default agents bundled.", "allPresent": "All default agents already present.", "restored": "Restored {0} default agent(s).", "restoreFailed": "Restore failed: {0}", "openFailed": "Open failed: {0}", "resetToDefault": "Reset to the bundled default." },
"sessionSkillsTab": { "installed": "Installed: {0}", "installFailed": "Install failed: {0}", "updated": "Updated.", "updateFailed": "Update failed: {0}", "removed": "Removed.", "removeFailed": "Remove failed: {0}" },
"worktreesTab": { "workerOffline": "Worker offline.", "removed": "Removed {0} worktree(s).", "blocked": "Cannot force-remove: {0} task(s) still running. Cancel them first.", "removedFrom": "Removed {0} worktree(s) from {1} task(s).", "cleanupFailed": "Cleanup failed: {0}", "resetFailed": "Reset failed: {0}" },
"worktreesOverview": { "titleAll": "Worktrees", "titleList": "Worktrees — {0}", "listFallback": "list", "cleanupFailed": "Cleanup failed.", "cleanupFailedDetailed": "Cleanup failed: {0}", "removed": "Removed {0} worktree(s).", "discardFailed": "Failed to discard worktree.", "keepFailed": "Failed to keep worktree.", "cannotForceRunning": "Cannot force-remove a running task.", "forceRemoveFailed": "Force remove failed.", "forceRemoveFailedDetailed": "Force remove failed: {0}", "batchProgress": "Merging {0}/{1}…", "batchDone": "Merged {0}, {1} need resolution." },
diff --git a/src/ClaudeDo.Ui/ViewModels/Modals/Settings/FilesSettingsTabViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Modals/Settings/FilesSettingsTabViewModel.cs
index 13576abe..adc01d94 100644
--- a/src/ClaudeDo.Ui/ViewModels/Modals/Settings/FilesSettingsTabViewModel.cs
+++ b/src/ClaudeDo.Ui/ViewModels/Modals/Settings/FilesSettingsTabViewModel.cs
@@ -1,3 +1,4 @@
+using System.Collections.ObjectModel;
using System.Diagnostics;
using ClaudeDo.Data;
using ClaudeDo.Ui.Localization;
@@ -13,6 +14,7 @@ public sealed partial class FilesSettingsTabViewModel : ViewModelBase
[ObservableProperty] private string _statusMessage = "";
[ObservableProperty] private bool _isBusy;
+ [ObservableProperty] private bool _hasCustomizedPrompts;
public string SystemPromptPath { get; } = PromptFiles.PathFor(PromptKind.System);
public string PlanningPromptPath { get; } = PromptFiles.PathFor(PromptKind.Planning);
@@ -21,7 +23,13 @@ public sealed partial class FilesSettingsTabViewModel : ViewModelBase
public string DailyPrepPromptPath { get; } = PromptFiles.PathFor(PromptKind.DailyPrep);
public string WeeklyReportPromptPath { get; } = PromptFiles.PathFor(PromptKind.WeeklyReport);
- public FilesSettingsTabViewModel(IWorkerClient worker) => _worker = worker;
+ public ObservableCollection CustomizedPrompts { get; } = new();
+
+ public FilesSettingsTabViewModel(IWorkerClient worker)
+ {
+ _worker = worker;
+ RefreshCustomizedPrompts();
+ }
[RelayCommand]
private async Task RestoreDefaultAgents()
@@ -46,10 +54,51 @@ public sealed partial class FilesSettingsTabViewModel : ViewModelBase
if (!Enum.TryParse(kindName, ignoreCase: true, out var kind)) return;
try
{
- PromptFiles.EnsureExists(kind);
- var path = PromptFiles.PathFor(kind);
- Process.Start(new ProcessStartInfo(path) { UseShellExecute = true });
+ // No override file yet: seed it with today's bundled default (hash-tracked, so a later
+ // default change reconciles this file away automatically unless the user actually edits it)
+ // rather than leaving a plain file that would freeze the prompt forever.
+ if (!File.Exists(PromptFiles.PathFor(kind)))
+ PromptFiles.Save(kind, PromptFiles.DefaultFor(kind));
+ Process.Start(new ProcessStartInfo(PromptFiles.PathFor(kind)) { UseShellExecute = true });
}
catch (Exception ex) { StatusMessage = Loc.T("vm.filesTab.openFailed", ex.Message); }
+ finally { RefreshCustomizedPrompts(); }
+ }
+
+ private void RefreshCustomizedPrompts()
+ {
+ CustomizedPrompts.Clear();
+ foreach (var kind in Enum.GetValues())
+ {
+ if (PromptFiles.Classify(kind) != PromptFileState.Edited) continue;
+ CustomizedPrompts.Add(new CustomizedPromptRowViewModel(kind, PromptFiles.DiffAgainstDefault(kind), OnResetPromptToDefault));
+ }
+ HasCustomizedPrompts = CustomizedPrompts.Count > 0;
+ }
+
+ private void OnResetPromptToDefault(PromptKind kind)
+ {
+ PromptFiles.ResetToDefault(kind);
+ RefreshCustomizedPrompts();
+ StatusMessage = Loc.T("vm.filesTab.resetToDefault");
}
}
+
+public sealed partial class CustomizedPromptRowViewModel : ObservableObject
+{
+ private readonly Action _onReset;
+
+ public PromptKind Kind { get; }
+ public string KindName => Kind.ToString();
+ public string DiffPreview { get; }
+
+ public CustomizedPromptRowViewModel(PromptKind kind, string diffPreview, Action onReset)
+ {
+ Kind = kind;
+ DiffPreview = diffPreview;
+ _onReset = onReset;
+ }
+
+ [RelayCommand]
+ private void Reset() => _onReset(Kind);
+}
diff --git a/src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml b/src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml
index 08a2e9fe..6d7487ba 100644
--- a/src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml
+++ b/src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml
@@ -297,6 +297,24 @@
Command="{Binding Files.OpenPromptCommand}" CommandParameter="WeeklyReport"/>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/ClaudeDo.Worker/CLAUDE.md b/src/ClaudeDo.Worker/CLAUDE.md
index 29169cf9..11b06a67 100644
--- a/src/ClaudeDo.Worker/CLAUDE.md
+++ b/src/ClaudeDo.Worker/CLAUDE.md
@@ -8,7 +8,7 @@ ASP.NET Core hosted service that executes tasks via Claude CLI in isolated envir
Worker/
State/ — TaskStateService + TransitionResult (sole owner of Status/PlanningPhase/BlockedBy writes)
Queue/ — IQueueWaker, IQueuePicker, QueueService (BackgroundService), OverrideSlotService, RunCancellationRegistry (taskId → running-run CTS; lets TaskStateService.CancelAsync kill the process of a cancelled task/child without a DI cycle)
- Lifecycle/ — StaleTaskRecovery, TaskResetService, TaskMergeService, VerifyCommandRunner (IVerifyCommandRunner — spawns a list's optional post-merge verify command via `cmd.exe /c`), ClaudeCliPreflight, OrphanRecovery, PlanningLineageRecovery, AttachmentOrphanRecovery (startup sweep: deletes any `attachments//` dirs whose task no longer exists)
+ Lifecycle/ — StaleTaskRecovery, TaskResetService, TaskMergeService, VerifyCommandRunner (IVerifyCommandRunner — spawns a list's optional post-merge verify command via `cmd.exe /c`), ClaudeCliPreflight, OrphanRecovery, PlanningLineageRecovery, AttachmentOrphanRecovery (startup sweep: deletes any `attachments//` dirs whose task no longer exists), PromptFileRecovery (startup sweep: `PromptFiles.ReconcileStaleDefaults()` drops any prompt override that only matched a now-superseded default and was never actually edited, `QuarantineOrphans()` moves *.md files under `prompts/` with no matching `PromptKind` into `prompts/_orphans/`)
Worktrees/ — WorktreeMaintenanceService
Agents/ — AgentFileService, DefaultAgentSeeder
Runner/ — TaskRunner + Claude CLI integration; TaskRunMcpService/TaskRunMcpContext/TaskRunTokenRegistry (in-task MCP wired during execution)
diff --git a/src/ClaudeDo.Worker/Lifecycle/PromptFileRecovery.cs b/src/ClaudeDo.Worker/Lifecycle/PromptFileRecovery.cs
new file mode 100644
index 00000000..cffa8452
--- /dev/null
+++ b/src/ClaudeDo.Worker/Lifecycle/PromptFileRecovery.cs
@@ -0,0 +1,36 @@
+using ClaudeDo.Data;
+
+namespace ClaudeDo.Worker.Lifecycle;
+
+///
+/// Startup-only sweep: drops any prompt override file that only matched a now-superseded
+/// bundled default and was never actually edited (see ),
+/// and quarantines any *.md file under the prompts root that no longer maps to a known
+/// (leftovers from a retired naming scheme).
+///
+public sealed class PromptFileRecovery : IHostedService
+{
+ private readonly ILogger _logger;
+ private readonly string? _root;
+
+ public PromptFileRecovery(ILogger logger, string? root = null)
+ {
+ _logger = logger;
+ _root = root;
+ }
+
+ public Task StartAsync(CancellationToken cancellationToken)
+ {
+ PromptFiles.ReconcileStaleDefaults(_root);
+
+ var orphans = PromptFiles.QuarantineOrphans(_root);
+ if (orphans.Count > 0)
+ _logger.LogWarning("Prompt file recovery: quarantined {Count} orphaned prompt file(s) into _orphans", orphans.Count);
+ else
+ _logger.LogInformation("Prompt file recovery: no orphaned prompt files found");
+
+ return Task.CompletedTask;
+ }
+
+ public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
+}
diff --git a/src/ClaudeDo.Worker/Program.cs b/src/ClaudeDo.Worker/Program.cs
index c18b3b70..f932991c 100644
--- a/src/ClaudeDo.Worker/Program.cs
+++ b/src/ClaudeDo.Worker/Program.cs
@@ -63,6 +63,7 @@ builder.Services.AddSingleton();
builder.Services.AddHostedService();
builder.Services.AddHostedService();
builder.Services.AddHostedService();
+builder.Services.AddHostedService();
builder.Services.AddSignalR().AddJsonProtocol(options =>
{
options.PayloadSerializerOptions.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter());
diff --git a/tests/ClaudeDo.Data.Tests/PromptFilesClassifyTests.cs b/tests/ClaudeDo.Data.Tests/PromptFilesClassifyTests.cs
new file mode 100644
index 00000000..86dffbce
--- /dev/null
+++ b/tests/ClaudeDo.Data.Tests/PromptFilesClassifyTests.cs
@@ -0,0 +1,151 @@
+using System.Text.Json;
+using ClaudeDo.Data;
+
+namespace ClaudeDo.Data.Tests;
+
+public class PromptFilesClassifyTests : IDisposable
+{
+ private readonly string _root = Path.Combine(Path.GetTempPath(), "claudedo-prompt-tests-" + Guid.NewGuid());
+
+ public void Dispose()
+ {
+ if (Directory.Exists(_root)) Directory.Delete(_root, recursive: true);
+ }
+
+ [Fact]
+ public void Classify_missing_file_returns_Missing()
+ {
+ Assert.Equal(PromptFileState.Missing, PromptFiles.Classify(PromptKind.System, _root));
+ }
+
+ [Fact]
+ public void Classify_file_matching_current_default_returns_MatchesCurrentDefault()
+ {
+ Directory.CreateDirectory(_root);
+ File.WriteAllText(PromptFiles.PathFor(PromptKind.Retry, _root), PromptFiles.DefaultFor(PromptKind.Retry));
+
+ Assert.Equal(PromptFileState.MatchesCurrentDefault, PromptFiles.Classify(PromptKind.Retry, _root));
+ }
+
+ [Fact]
+ public void Classify_file_matching_a_recorded_past_default_returns_MatchesKnownPastDefault()
+ {
+ Directory.CreateDirectory(_root);
+ const string oldDefaultText = "This was the bundled default a while ago.";
+ File.WriteAllText(PromptFiles.PathFor(PromptKind.Retry, _root), oldDefaultText);
+
+ // Simulate what Save() would have written back when oldDefaultText WAS the current default.
+ var hashes = new Dictionary
+ {
+ [PromptKind.Retry.ToString()] = PromptFiles.HashOf(PromptFiles.Normalize(oldDefaultText))
+ };
+ File.WriteAllText(Path.Combine(_root, ".defaults.json"), JsonSerializer.Serialize(hashes));
+
+ Assert.Equal(PromptFileState.MatchesKnownPastDefault, PromptFiles.Classify(PromptKind.Retry, _root));
+ }
+
+ [Fact]
+ public void Classify_file_that_diverges_with_no_recorded_hash_returns_Edited()
+ {
+ Directory.CreateDirectory(_root);
+ File.WriteAllText(PromptFiles.PathFor(PromptKind.Retry, _root), "My own custom retry instructions.");
+
+ Assert.Equal(PromptFileState.Edited, PromptFiles.Classify(PromptKind.Retry, _root));
+ }
+
+ [Fact]
+ public void Save_with_content_equal_to_default_records_hash_so_it_classifies_as_current_default()
+ {
+ PromptFiles.Save(PromptKind.Retry, PromptFiles.DefaultFor(PromptKind.Retry), _root);
+
+ Assert.Equal(PromptFileState.MatchesCurrentDefault, PromptFiles.Classify(PromptKind.Retry, _root));
+ Assert.True(File.Exists(Path.Combine(_root, ".defaults.json")));
+ }
+
+ [Fact]
+ public void Save_with_edited_content_does_not_record_a_hash()
+ {
+ PromptFiles.Save(PromptKind.Retry, "Custom retry text.", _root);
+
+ Assert.Equal(PromptFileState.Edited, PromptFiles.Classify(PromptKind.Retry, _root));
+ }
+
+ [Fact]
+ public void ResetToDefault_deletes_the_override_file_and_its_hash_entry()
+ {
+ PromptFiles.Save(PromptKind.Retry, PromptFiles.DefaultFor(PromptKind.Retry), _root);
+ Assert.True(File.Exists(PromptFiles.PathFor(PromptKind.Retry, _root)));
+
+ PromptFiles.ResetToDefault(PromptKind.Retry, _root);
+
+ Assert.False(File.Exists(PromptFiles.PathFor(PromptKind.Retry, _root)));
+ Assert.Equal(PromptFileState.Missing, PromptFiles.Classify(PromptKind.Retry, _root));
+ }
+
+ [Fact]
+ public void ReconcileStaleDefaults_removes_a_file_that_only_matched_a_past_default()
+ {
+ Directory.CreateDirectory(_root);
+ const string oldDefaultText = "Old bundled default text.";
+ File.WriteAllText(PromptFiles.PathFor(PromptKind.Retry, _root), oldDefaultText);
+ var hashes = new Dictionary
+ {
+ [PromptKind.Retry.ToString()] = PromptFiles.HashOf(PromptFiles.Normalize(oldDefaultText))
+ };
+ File.WriteAllText(Path.Combine(_root, ".defaults.json"), JsonSerializer.Serialize(hashes));
+
+ PromptFiles.ReconcileStaleDefaults(_root);
+
+ Assert.False(File.Exists(PromptFiles.PathFor(PromptKind.Retry, _root)));
+ }
+
+ [Fact]
+ public void ReconcileStaleDefaults_leaves_a_real_edit_untouched()
+ {
+ PromptFiles.Save(PromptKind.Retry, "Genuinely customized retry text.", _root);
+
+ PromptFiles.ReconcileStaleDefaults(_root);
+
+ Assert.True(File.Exists(PromptFiles.PathFor(PromptKind.Retry, _root)));
+ Assert.Equal(PromptFileState.Edited, PromptFiles.Classify(PromptKind.Retry, _root));
+ }
+
+ [Fact]
+ public void QuarantineOrphans_moves_unknown_md_files_into_orphans_subfolder()
+ {
+ Directory.CreateDirectory(_root);
+ var orphanPath = Path.Combine(_root, "agent.md");
+ File.WriteAllText(orphanPath, "leftover from an old naming scheme");
+
+ var moved = PromptFiles.QuarantineOrphans(_root);
+
+ Assert.False(File.Exists(orphanPath));
+ var dest = Assert.Single(moved);
+ Assert.True(File.Exists(dest));
+ Assert.Equal("leftover from an old naming scheme", File.ReadAllText(dest));
+ }
+
+ [Fact]
+ public void QuarantineOrphans_leaves_known_prompt_files_in_place()
+ {
+ PromptFiles.Save(PromptKind.Retry, "Custom retry text.", _root);
+
+ var moved = PromptFiles.QuarantineOrphans(_root);
+
+ Assert.Empty(moved);
+ Assert.True(File.Exists(PromptFiles.PathFor(PromptKind.Retry, _root)));
+ }
+
+ [Fact]
+ public void DiffAgainstDefault_shows_only_the_changed_lines()
+ {
+ var lines = PromptFiles.DefaultFor(PromptKind.PlanningInitial).Replace("\r\n", "\n").Split('\n');
+ var edited = string.Join('\n', lines) + "\nExtra custom line.";
+ PromptFiles.Save(PromptKind.PlanningInitial, edited, _root);
+
+ var diff = PromptFiles.DiffAgainstDefault(PromptKind.PlanningInitial, _root);
+
+ Assert.Contains("+ Extra custom line.", diff);
+ Assert.DoesNotContain("- {title}", diff);
+ }
+}
diff --git a/tests/ClaudeDo.Worker.Tests/Lifecycle/PromptFileRecoveryTests.cs b/tests/ClaudeDo.Worker.Tests/Lifecycle/PromptFileRecoveryTests.cs
new file mode 100644
index 00000000..0ebf0b52
--- /dev/null
+++ b/tests/ClaudeDo.Worker.Tests/Lifecycle/PromptFileRecoveryTests.cs
@@ -0,0 +1,49 @@
+using System.Text.Json;
+using ClaudeDo.Data;
+using ClaudeDo.Worker.Lifecycle;
+using Microsoft.Extensions.Logging.Abstractions;
+
+namespace ClaudeDo.Worker.Tests.Lifecycle;
+
+public sealed class PromptFileRecoveryTests
+{
+ [Fact]
+ public async Task StartAsync_ReconcilesStaleDefaultAndQuarantinesOrphan_WithoutTouchingRealEdit()
+ {
+ var root = Path.Combine(Path.GetTempPath(), $"claudedo_prompts_{Guid.NewGuid():N}");
+ try
+ {
+ // A file that only ever matched a now-superseded default should get reconciled away.
+ Directory.CreateDirectory(root);
+ const string oldDefaultText = "This used to be the bundled retry default.";
+ File.WriteAllText(PromptFiles.PathFor(PromptKind.Retry, root), oldDefaultText);
+ var hashes = new Dictionary
+ {
+ [PromptKind.Retry.ToString()] = PromptFiles.HashOf(PromptFiles.Normalize(oldDefaultText))
+ };
+ File.WriteAllText(Path.Combine(root, ".defaults.json"), JsonSerializer.Serialize(hashes));
+
+ // A genuinely edited file should survive untouched.
+ PromptFiles.Save(PromptKind.System, "My real customization.", root);
+
+ // A leftover file from a retired naming scheme should be quarantined, not deleted.
+ var orphanPath = Path.Combine(root, "agent.md");
+ File.WriteAllText(orphanPath, "leftover");
+
+ var sut = new PromptFileRecovery(NullLogger.Instance, root);
+
+ await sut.StartAsync(CancellationToken.None);
+
+ Assert.False(File.Exists(PromptFiles.PathFor(PromptKind.Retry, root)), "Stale unedited default must be reconciled away");
+ Assert.True(File.Exists(PromptFiles.PathFor(PromptKind.System, root)), "Real edit must survive");
+ Assert.False(File.Exists(orphanPath), "Orphan must be moved out of the prompts root");
+ Assert.True(File.Exists(Path.Combine(root, "_orphans", "agent.md")), "Orphan must be quarantined, not deleted");
+
+ await sut.StopAsync(CancellationToken.None); // must not throw
+ }
+ finally
+ {
+ if (Directory.Exists(root)) Directory.Delete(root, recursive: true);
+ }
+ }
+}