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:
@@ -17,6 +17,7 @@
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="ClaudeDo.Worker" />
|
||||
<InternalsVisibleTo Include="ClaudeDo.Worker.Tests" />
|
||||
<InternalsVisibleTo Include="ClaudeDo.Data.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -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 }
|
||||
|
||||
/// <summary>
|
||||
/// How a prompt kind's on-disk override (if any) relates to the bundled default.
|
||||
/// </summary>
|
||||
public enum PromptFileState
|
||||
{
|
||||
/// <summary>No override file — the bundled default is in effect.</summary>
|
||||
Missing,
|
||||
/// <summary>File exists and is byte-for-byte (normalized) the current default.</summary>
|
||||
MatchesCurrentDefault,
|
||||
/// <summary>File exists, differs from today's default, but was recorded as an unedited copy of a past default — safe to reconcile away.</summary>
|
||||
MatchesKnownPastDefault,
|
||||
/// <summary>File exists and diverges from the default with no matching recorded hash — a real user customization.</summary>
|
||||
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)
|
||||
/// <summary>Classify an override file against the bundled default and the recorded default-hash log.</summary>
|
||||
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)
|
||||
/// <summary>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.</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>Delete the override file (if any) so the bundled default takes effect again.</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>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.</summary>
|
||||
public static void ReconcileStaleDefaults(string? root = null)
|
||||
{
|
||||
var effectiveRoot = root ?? Root;
|
||||
foreach (var kind in Enum.GetValues<PromptKind>())
|
||||
if (Classify(kind, effectiveRoot) == PromptFileState.MatchesKnownPastDefault)
|
||||
ResetToDefault(kind, effectiveRoot);
|
||||
}
|
||||
|
||||
/// <summary>Startup sweep: moves any *.md file under the prompts root that doesn't match a known
|
||||
/// <see cref="PromptKind"/> path (leftovers from a retired naming scheme) into a "_orphans" subfolder.
|
||||
/// Never deletes them outright. Returns the destination paths of files it moved.</summary>
|
||||
public static IReadOnlyList<string> QuarantineOrphans(string? root = null)
|
||||
{
|
||||
var effectiveRoot = root ?? Root;
|
||||
if (!Directory.Exists(effectiveRoot)) return Array.Empty<string>();
|
||||
|
||||
var known = Enum.GetValues<PromptKind>()
|
||||
.Select(k => PathFor(k, effectiveRoot))
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var moved = new List<string>();
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>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.</summary>
|
||||
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<string, string> LoadDefaultHashes(string root)
|
||||
{
|
||||
var path = DefaultsHashPath(root);
|
||||
if (!File.Exists(path)) return new();
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<Dictionary<string, string>>(File.ReadAllText(path)) ?? new();
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return new();
|
||||
}
|
||||
}
|
||||
|
||||
private static void SaveDefaultHashes(string root, Dictionary<string, string> 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;
|
||||
|
||||
@@ -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." },
|
||||
|
||||
@@ -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." },
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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/<taskId>/` 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/<taskId>/` 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)
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
using ClaudeDo.Data;
|
||||
|
||||
namespace ClaudeDo.Worker.Lifecycle;
|
||||
|
||||
/// <summary>
|
||||
/// Startup-only sweep: drops any prompt override file that only matched a now-superseded
|
||||
/// bundled default and was never actually edited (see <see cref="PromptFiles.ReconcileStaleDefaults"/>),
|
||||
/// and quarantines any *.md file under the prompts root that no longer maps to a known
|
||||
/// <see cref="PromptKind"/> (leftovers from a retired naming scheme).
|
||||
/// </summary>
|
||||
public sealed class PromptFileRecovery : IHostedService
|
||||
{
|
||||
private readonly ILogger<PromptFileRecovery> _logger;
|
||||
private readonly string? _root;
|
||||
|
||||
public PromptFileRecovery(ILogger<PromptFileRecovery> 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;
|
||||
}
|
||||
@@ -63,6 +63,7 @@ builder.Services.AddSingleton<AttachmentStore>();
|
||||
builder.Services.AddHostedService<StaleTaskRecovery>();
|
||||
builder.Services.AddHostedService<OrphanRecovery>();
|
||||
builder.Services.AddHostedService<AttachmentOrphanRecovery>();
|
||||
builder.Services.AddHostedService<PromptFileRecovery>();
|
||||
builder.Services.AddSignalR().AddJsonProtocol(options =>
|
||||
{
|
||||
options.PayloadSerializerOptions.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter());
|
||||
|
||||
@@ -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<string, string>
|
||||
{
|
||||
[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<string, string>
|
||||
{
|
||||
[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);
|
||||
}
|
||||
}
|
||||
@@ -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<string, string>
|
||||
{
|
||||
[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<PromptFileRecovery>.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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user