fix(prompts): make template token substitution order-independent

RenderTemplate replaced tokens one key at a time over a StringBuilder, so a
token appearing inside an already-substituted value got substituted again on a
later pass. The prompt briefs only escaped this because their callers happen to
pass "tasks" last -- reordering the dictionary or adding a fourth token would
have started rewriting user-authored task descriptions, which after the enhance
phase carry file paths and config snippets.

Single-pass regex over the template instead; unknown tokens still pass through.
This commit is contained in:
mika kuns
2026-08-06 21:17:11 +02:00
parent a7ff1b3a2f
commit 028ac57398
2 changed files with 46 additions and 7 deletions
+10 -7
View File
@@ -1,6 +1,7 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
namespace ClaudeDo.Data;
@@ -193,14 +194,16 @@ public static class PromptFiles
public static string Render(PromptKind kind, IReadOnlyDictionary<string, string> values)
=> RenderTemplate(ReadOrDefault(kind), values);
/// <summary>Replace only the given {name} tokens; any other braces pass through untouched.</summary>
private static readonly Regex TokenPattern = new(@"\{(\w+)\}", RegexOptions.Compiled);
/// <summary>Replace only the given {name} tokens; any other braces pass through untouched.
/// Single pass over the template, so a token appearing inside a substituted VALUE is never
/// re-substituted. That matters because the values are user-authored task titles and
/// descriptions: a sharpened description mentioning a literal {repo} must survive verbatim,
/// and it must not depend on the caller happening to order its dictionary correctly.</summary>
public static string RenderTemplate(string template, IReadOnlyDictionary<string, string> values)
{
var sb = new StringBuilder(template);
foreach (var (key, val) in values)
sb.Replace("{" + key + "}", val);
return sb.ToString();
}
=> TokenPattern.Replace(template, m =>
values.TryGetValue(m.Groups[1].Value, out var val) ? val : m.Value);
public static string DefaultFor(PromptKind kind) => kind switch
{