Files
ClawdDotNet/src/ClawdDotNet.Core/Tasks/TaskFrontmatter.cs
T
RichardandClaude Opus 5.5 b22272345e Zielkette, Stufe 1: jede Aufgabe kann sagen, wofuer sie da ist (#714)
- TaskItem.ParentId, in der Datei als parent, in der DB als ParentId. Die
  Elternaufgabe ist der Zweck ("wofuer"), blocked_by die Voraussetzung
  ("wann").
- GoalChain: Eltern bis zu acht Stufen, Zyklen brechen den Gang ab und
  werden beim Setzen abgelehnt. Oben steht das Ziel der Einheit - der
  obersten Aufgabe, die zu einer Einheit gehoert (ein Ziel, das ein Mensch
  auf der obersten Ebene setzt, gehoert keiner), sonst der naechsten Einheit
  darueber mit Ziel.
- Der Lauf einer Aufgabe bekommt den Absatz "Why this task exists" und die
  Bitte, zu melden, wenn die Aufgabe ihrem Zweck nicht mehr dient.
- Taskboard-Tool: parent bei task_create/task_update, geprueft auf Existenz,
  Sichtbarkeit (#684) und Zyklen; task_list zeigt "Serves".
- WebUI: Feld im Aufgabendialog, Anzeige auf der Karte; die Verwaltung
  prueft wie das Tool.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 18:45:15 +02:00

320 lines
12 KiB
C#
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Text;
namespace ClawdDotNet.Core.Tasks;
/// <summary>
/// Liest und schreibt eine Task-Datei: ein YAML-artiger Frontmatter-Block zwischen
/// <c>---</c>-Zeilen, gefolgt vom Rumpf.
///
/// Bewusst ein enger, eigener Parser statt einer YAML-Bibliothek: Die Frontmatter ist
/// ein kleiner, flacher Satz aus Skalaren, einem verschachtelten <c>when</c>, einer
/// Inline-Liste und einem Block-Skalar. Dafür eine Abhängigkeit hereinzuziehen passt
/// nicht zum Stil des Projekts — und ein voller YAML-Parser würde Formen zulassen, die
/// wir gar nicht deuten wollen. Unbekanntes wird übergangen, nicht erraten.
/// </summary>
public static class TaskFrontmatter
{
/// <summary>
/// Zerlegt eine Task-Datei in ihre Definition. Gibt <c>false</c> mit einer Meldung
/// zurück, wenn kein Frontmatter-Block gefunden wird — der Ausführungszustand
/// (Marker, Claim) wird hier nicht berührt, er kommt aus der DB.
/// </summary>
public static bool TryParse(string text, out TaskItem task, out string? error)
{
task = new TaskItem();
error = null;
var normalized = (text ?? "").Replace("\r\n", "\n").Replace("\r", "\n");
// Öffnendes und schließendes "---" finden (BOM/Leerzeilen davor tolerieren).
var lines = normalized.Split('\n');
var open = -1;
for (var i = 0; i < lines.Length; i++)
{
var t = lines[i].TrimStart('').Trim();
if (t.Length == 0) continue;
if (t == "---") { open = i; break; }
break; // erste nicht-leere Zeile ist kein Fence
}
if (open < 0)
{
error = "Kein Frontmatter-Block gefunden (erwartet '---' als erste Zeile).";
return false;
}
var close = -1;
for (var i = open + 1; i < lines.Length; i++)
{
if (lines[i].Trim() == "---") { close = i; break; }
}
if (close < 0)
{
error = "Frontmatter-Block nicht geschlossen (zweites '---' fehlt).";
return false;
}
var block = lines[(open + 1)..close];
var body = string.Join('\n', lines[(close + 1)..]).Trim('\n');
var scalars = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
var when = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
var blockedBy = new List<string>();
string? acceptance = null;
var j = 0;
while (j < block.Length)
{
var raw = block[j];
var trimmed = raw.Trim();
if (trimmed.Length == 0 || trimmed.StartsWith('#')) { j++; continue; }
// Nur Zeilen ohne Einrückung sind Top-Level-Schlüssel; eingerückte Zeilen
// gehören zum jeweils vorangehenden Schlüssel und werden dort mitgelesen.
if (Indent(raw) > 0) { j++; continue; }
var colon = trimmed.IndexOf(':');
if (colon < 0) { j++; continue; }
var key = trimmed[..colon].Trim();
var rest = trimmed[(colon + 1)..].Trim();
if (key.Equals("when", StringComparison.OrdinalIgnoreCase) && rest.Length == 0)
{
j = CollectChildren(block, j + 1, child =>
{
var c = child.IndexOf(':');
if (c < 0) return;
when[child[..c].Trim()] = Unquote(child[(c + 1)..].Trim());
});
continue;
}
if (key.Equals("acceptance", StringComparison.OrdinalIgnoreCase) && IsBlockScalar(rest))
{
acceptance = ReadBlockScalar(block, ref j);
continue;
}
if (key.Equals("blocked_by", StringComparison.OrdinalIgnoreCase))
{
if (rest.StartsWith('['))
{
blockedBy.AddRange(ParseInlineList(rest));
j++;
}
else if (rest.Length == 0)
{
// Block-Liste: nachfolgende "- item"-Zeilen.
j = CollectChildren(block, j + 1, child =>
{
var item = child.TrimStart();
if (item.StartsWith('-'))
blockedBy.Add(Unquote(item[1..].Trim()));
});
}
else
{
blockedBy.AddRange(ParseInlineList(rest));
j++;
}
continue;
}
scalars[key] = Unquote(rest);
j++;
}
TaskWhen? whenValue = null;
if (TaskText.WhenKind(when.GetValueOrDefault("kind")) is { } kind)
{
whenValue = new TaskWhen
{
Kind = kind,
Value = when.GetValueOrDefault("value", ""),
TimeZone = when.GetValueOrDefault("tz", "")
};
}
task = new TaskItem
{
Id = scalars.GetValueOrDefault("id", ""),
Title = scalars.GetValueOrDefault("title", ""),
Status = TaskText.Status(scalars.GetValueOrDefault("status")),
Type = TaskText.Type(scalars.GetValueOrDefault("type")),
Priority = ParsePriority(scalars.GetValueOrDefault("priority")),
Assignee = EmptyToDefault(scalars.GetValueOrDefault("assignee"), TaskAssignee.Human),
When = whenValue,
RequireApproval = ParseBool(scalars.GetValueOrDefault("require_approval")),
Acceptance = acceptance?.Trim() ?? "",
BlockedBy = blockedBy,
OnlyWhenMarketOpen = ParseBool(scalars.GetValueOrDefault("onlyWhenMarketOpen")),
ToolName = scalars.GetValueOrDefault("tool_name", ""),
JobTypeId = scalars.GetValueOrDefault("job_type", ""),
CreatedBy = scalars.GetValueOrDefault("created_by", ""),
ParentId = scalars.GetValueOrDefault("parent", ""),
Body = body
};
return true;
}
/// <summary>Schreibt eine Definition zurück in Dateiform. Der Ausführungszustand
/// bleibt außen vor — er gehört in die DB, nicht in die Datei.</summary>
public static string Serialize(TaskItem task)
{
var sb = new StringBuilder();
sb.Append("---\n");
sb.Append($"id: {Scalar(task.Id)}\n");
sb.Append($"title: {Scalar(task.Title)}\n");
sb.Append($"status: {TaskText.Of(task.Status)}\n");
sb.Append($"type: {TaskText.Of(task.Type)}\n");
sb.Append($"priority: {Math.Clamp(task.Priority, 1, 5)}\n");
sb.Append($"assignee: {Scalar(task.Assignee)}\n");
if (!string.IsNullOrWhiteSpace(task.CreatedBy))
sb.Append($"created_by: {Scalar(task.CreatedBy)}\n");
if (!string.IsNullOrWhiteSpace(task.ParentId))
sb.Append($"parent: {Scalar(task.ParentId)}\n");
if (task.When is { } w)
{
sb.Append("when:\n");
sb.Append($" kind: {TaskText.Of(w.Kind)}\n");
sb.Append($" value: {Scalar(w.Value)}\n");
sb.Append($" tz: {Scalar(w.TimeZone)}\n");
}
// Nur für Poll-Tasks (tool_job): welches Tool mit welcher Job-Art getickt wird.
if (task.Type == TaskItemType.ToolJob || !string.IsNullOrWhiteSpace(task.ToolName))
{
sb.Append($"tool_name: {Scalar(task.ToolName)}\n");
sb.Append($"job_type: {Scalar(task.JobTypeId)}\n");
}
sb.Append($"require_approval: {(task.RequireApproval ? "true" : "false")}\n");
if (!string.IsNullOrWhiteSpace(task.Acceptance))
{
sb.Append("acceptance: |\n");
foreach (var line in task.Acceptance.Replace("\r\n", "\n").Split('\n'))
sb.Append(" ").Append(line).Append('\n');
}
if (task.BlockedBy.Count > 0)
sb.Append($"blocked_by: [{string.Join(", ", task.BlockedBy)}]\n");
sb.Append($"onlyWhenMarketOpen: {(task.OnlyWhenMarketOpen ? "true" : "false")}\n");
sb.Append("---\n");
if (!string.IsNullOrWhiteSpace(task.Body))
sb.Append('\n').Append(task.Body.Replace("\r\n", "\n").TrimEnd('\n')).Append('\n');
return sb.ToString();
}
// ─── Hilfsfunktionen ───
private static int Indent(string line)
{
var n = 0;
while (n < line.Length && line[n] == ' ') n++;
return n;
}
/// <summary>Ruft <paramref name="onChild"/> für jede eingerückte Folgezeile auf und
/// gibt den Index der ersten nicht mehr zugehörigen Zeile zurück.</summary>
private static int CollectChildren(string[] block, int start, Action<string> onChild)
{
var k = start;
while (k < block.Length)
{
var line = block[k];
if (line.Trim().Length == 0) { k++; continue; }
if (Indent(line) == 0) break;
onChild(line.Trim());
k++;
}
return k;
}
private static bool IsBlockScalar(string rest) => rest is "|" or "|-" or "|+" or ">" or ">-";
/// <summary>Liest einen eingerückten Block-Skalar und entfernt die gemeinsame
/// Einrückung. Setzt <paramref name="j"/> hinter den Block.</summary>
private static string ReadBlockScalar(string[] block, ref int j)
{
var collected = new List<string>();
var k = j + 1;
var commonIndent = int.MaxValue;
while (k < block.Length)
{
var line = block[k];
if (line.Trim().Length == 0) { collected.Add(""); k++; continue; }
if (Indent(line) == 0) break;
commonIndent = Math.Min(commonIndent, Indent(line));
collected.Add(line);
k++;
}
j = k;
if (commonIndent == int.MaxValue) commonIndent = 0;
var sb = new StringBuilder();
foreach (var line in collected)
sb.Append(line.Length >= commonIndent ? line[commonIndent..] : line).Append('\n');
return sb.ToString().TrimEnd('\n');
}
private static IEnumerable<string> ParseInlineList(string rest)
{
var inner = rest.Trim();
if (inner.StartsWith('[')) inner = inner[1..];
if (inner.EndsWith(']')) inner = inner[..^1];
return inner.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(Unquote)
.Where(s => s.Length > 0);
}
private static string Unquote(string value)
{
var v = value.Trim();
if (v.Length >= 2 && v[0] == '"' && v[^1] == '"')
return v[1..^1].Replace("\\\"", "\"").Replace("\\\\", "\\");
if (v.Length >= 2 && v[0] == '\'' && v[^1] == '\'')
return v[1..^1].Replace("''", "'");
return v;
}
/// <summary>Quotiert nur, wenn nötig — hält die Datei sonst gut lesbar. Ein führendes
/// <c>@</c> (Assignee) etwa ist in YAML ein reserviertes Zeichen und muss quotiert
/// werden.</summary>
private static string Scalar(string? value)
{
var v = value ?? "";
if (v.Length == 0) return "\"\"";
var needsQuote =
v != v.Trim()
|| "@-[]{}>|*&!%#`,\"'".Contains(v[0])
|| v.Contains(':')
|| v.Contains('#')
|| v.Contains('\n');
if (!needsQuote) return v;
return "\"" + v.Replace("\\", "\\\\").Replace("\"", "\\\"") + "\"";
}
private static bool ParseBool(string? value)
=> value?.Trim().ToLowerInvariant() is "true" or "yes" or "1" or "on";
private static int ParsePriority(string? value)
=> int.TryParse(value?.Trim(), out var p) ? Math.Clamp(p, 1, 5) : 3;
private static string EmptyToDefault(string? value, string fallback)
=> string.IsNullOrWhiteSpace(value) ? fallback : value.Trim();
}