Merge claudedo/1b599d6711914658b9857b3df996f6e0
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user