From b15386921622f25c81488d8f3f2499749412fa87 Mon Sep 17 00:00:00 2001 From: mika kuns Date: Wed, 5 Aug 2026 15:59:03 +0200 Subject: [PATCH] fix(prompts): stop on-disk prompt overrides from freezing forever EnsureExists blindly seeded ~/.todo-app/prompts/*.md with the bundled default and never revisited it, so any file created by opening the Files settings tab shadowed every later default change permanently (SuggestImprovement/AskUser sections never reached real runs since 2026-06-04). PromptFiles now hashes what a file was seeded/saved with in prompts/.defaults.json: Classify() tells missing/current-default/ known-past-default/edited apart, ReconcileStaleDefaults() drops files that only ever matched a superseded default, and real edits are left alone and surfaced in the Files tab with a diff + reset-to-default action. QuarantineOrphans() moves stale-named leftovers (agent.md, planning.md) into prompts/_orphans instead of silently deleting them. Wired as a Worker startup sweep (PromptFileRecovery) alongside the existing OrphanRecovery/AttachmentOrphanRecovery services. --- src/ClaudeDo.Data/ClaudeDo.Data.csproj | 1 + src/ClaudeDo.Data/PromptFiles.cs | 181 ++++++++++++++++-- src/ClaudeDo.Localization/locales/de.json | 7 +- src/ClaudeDo.Localization/locales/en.json | 7 +- .../Settings/FilesSettingsTabViewModel.cs | 57 +++++- .../Views/Modals/SettingsModalView.axaml | 18 ++ src/ClaudeDo.Worker/CLAUDE.md | 2 +- .../Lifecycle/PromptFileRecovery.cs | 36 ++++ src/ClaudeDo.Worker/Program.cs | 1 + .../PromptFilesClassifyTests.cs | 151 +++++++++++++++ .../Lifecycle/PromptFileRecoveryTests.cs | 49 +++++ 11 files changed, 483 insertions(+), 27 deletions(-) create mode 100644 src/ClaudeDo.Worker/Lifecycle/PromptFileRecovery.cs create mode 100644 tests/ClaudeDo.Data.Tests/PromptFilesClassifyTests.cs create mode 100644 tests/ClaudeDo.Worker.Tests/Lifecycle/PromptFileRecoveryTests.cs 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"/> + + + + + + + + + +