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
+163 -18
View File
@@ -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;