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.
This commit is contained in:
mika kuns
2026-08-05 15:59:03 +02:00
parent 83ea429b8a
commit b153869216
11 changed files with 483 additions and 27 deletions
@@ -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<CustomizedPromptRowViewModel> 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<PromptKind>(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<PromptKind>())
{
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<PromptKind> _onReset;
public PromptKind Kind { get; }
public string KindName => Kind.ToString();
public string DiffPreview { get; }
public CustomizedPromptRowViewModel(PromptKind kind, string diffPreview, Action<PromptKind> onReset)
{
Kind = kind;
DiffPreview = diffPreview;
_onReset = onReset;
}
[RelayCommand]
private void Reset() => _onReset(Kind);
}
@@ -297,6 +297,24 @@
Command="{Binding Files.OpenPromptCommand}" CommandParameter="WeeklyReport"/>
</Grid>
</StackPanel>
<StackPanel Spacing="6" IsVisible="{Binding Files.HasCustomizedPrompts}">
<TextBlock Classes="section-label" Text="{loc:Tr settings.files.customizedSection}"/>
<TextBlock Classes="meta" Text="{loc:Tr settings.files.customizedHint}" TextWrapping="Wrap"/>
<ItemsControl ItemsSource="{Binding Files.CustomizedPrompts}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<StackPanel Spacing="4" Margin="0,0,0,10">
<Grid ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="0" Classes="field-label" Text="{Binding KindName}" VerticalAlignment="Center"/>
<Button Grid.Column="1" Classes="btn" Content="{loc:Tr settings.files.resetToDefault}"
Command="{Binding ResetCommand}"/>
</Grid>
<TextBlock Classes="path-mono" Text="{Binding DiffPreview}" TextWrapping="Wrap"/>
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
<TextBlock Classes="meta" Text="{Binding Files.StatusMessage}"
IsVisible="{Binding Files.StatusMessage, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>