FileRW list sortiert und gedeckelt (#673), Taskboard task_find (#674)

Die Betriebskontrolle im Testbetrieb meldete Stundendateien als fehlend, die
es gab: 105 Eintraege in der Reihenfolge des Dateisystems, vierzeilig als
eingeruecktes JSON. list liefert jetzt die 50 zuletzt geaenderten Eintraege,
neueste zuerst, eine Zeile je Eintrag, mit Gesamtzahl im Kopf; pattern, sort,
order und limit steuern das.

Dabei aufgefallen (#675): Directory.GetFiles wertet Trennzeichen im Muster
aus - pattern "../*" fuehrte an der Pfadpruefung vorbei aus dem Verzeichnis
heraus, in list wie in read_many. Muster mit '/', '\' oder '..' werden jetzt
abgewiesen.

Und derselbe Befund ist keine zweite Aufgabe (#674): task_find beantwortet
die Frage vor dem Anlegen, task_create legt bei gleichbedeutendem Titel einer
offenen Aufgabe keine zweite an, sondern vermerkt das erneute Auftreten an
der bestehenden. force=true geht trotzdem, Erledigtes blockiert nichts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-09-21 21:19:19 +02:00
co-authored by Claude Opus 5
parent 24e457b11f
commit 060dc6a6ad
10 changed files with 815 additions and 19 deletions
@@ -0,0 +1,125 @@
using System.Globalization;
using System.Text;
namespace ClawdDotNet.Core.Tasks;
/// <summary>How closely two task titles describe the same thing.</summary>
public enum TaskTitleMatch
{
/// <summary>Different subjects.</summary>
None,
/// <summary>Related enough to be worth showing, not close enough to refuse a new task.</summary>
Similar,
/// <summary>The same finding said twice.</summary>
Duplicate
}
/// <summary>
/// Title comparison for the taskboard (#674).
///
/// <para>Observed in the test operation: an agent that runs every three hours re-reported the
/// same gap on every run. Six tasks said "Eur Kurse nicht aktuell", five said the news were
/// missing. Nobody can tell from that whether a problem is new or three days old.</para>
///
/// <para>The comparison is deliberately shallow - normalise, then compare word sets. Two titles
/// that differ only in word order, case, umlaut spelling or punctuation are the same title;
/// a title fully contained in a longer one is the same finding with more detail. Anything
/// cleverer would be guesswork a cheap model cannot audit.</para>
/// </summary>
public static class TaskSimilarity
{
/// <summary>Jaccard overlap from which two titles count as the same finding.</summary>
public const double DuplicateThreshold = 0.8;
/// <summary>Overlap from which two titles are worth showing side by side.</summary>
public const double SimilarThreshold = 0.5;
/// <summary>Statuses that still ask for attention - only these can be duplicated.</summary>
public static bool IsOpen(TaskItemStatus status) => status is
TaskItemStatus.Backlog or TaskItemStatus.Todo or TaskItemStatus.InProgress
or TaskItemStatus.InReview or TaskItemStatus.Blocked;
/// <summary>Lower case, umlauts folded, punctuation dropped, whitespace collapsed.</summary>
public static string Normalize(string? title)
{
if (string.IsNullOrWhiteSpace(title)) return "";
var folded = new StringBuilder(title.Length);
foreach (var c in title.ToLowerInvariant())
{
switch (c)
{
case 'ä': folded.Append("ae"); break;
case 'ö': folded.Append("oe"); break;
case 'ü': folded.Append("ue"); break;
case 'ß': folded.Append("ss"); break;
default: folded.Append(c); break;
}
}
// Remaining diacritics (é, ç, …) lose their marks rather than their letter.
var decomposed = folded.ToString().Normalize(NormalizationForm.FormD);
var plain = new StringBuilder(decomposed.Length);
foreach (var c in decomposed)
{
if (CharUnicodeInfo.GetUnicodeCategory(c) == UnicodeCategory.NonSpacingMark) continue;
plain.Append(char.IsLetterOrDigit(c) ? c : ' ');
}
return string.Join(' ', plain.ToString().Split(' ', StringSplitOptions.RemoveEmptyEntries));
}
/// <summary>Word set of a normalised title.</summary>
private static HashSet<string> Words(string normalized)
=> new(normalized.Split(' ', StringSplitOptions.RemoveEmptyEntries), StringComparer.Ordinal);
/// <summary>How closely two titles match.</summary>
public static TaskTitleMatch Compare(string? left, string? right)
{
var a = Normalize(left);
var b = Normalize(right);
if (a.Length == 0 || b.Length == 0) return TaskTitleMatch.None;
if (a == b) return TaskTitleMatch.Duplicate;
var wordsA = Words(a);
var wordsB = Words(b);
var shared = wordsA.Intersect(wordsB, StringComparer.Ordinal).Count();
if (shared == 0) return TaskTitleMatch.None;
// "Betriebsluecke: Eur Kurse" inside "Betriebsluecke: Eur Kurse nicht aktuell" is the
// same finding, told at different lengths. A single shared word is not - "Wetter"
// would swallow every weather task there is.
var (smaller, larger) = wordsA.Count <= wordsB.Count ? (wordsA, wordsB) : (wordsB, wordsA);
if (smaller.Count >= 2 && shared == smaller.Count) return TaskTitleMatch.Duplicate;
var overlap = (double)shared / (wordsA.Count + wordsB.Count - shared);
if (overlap >= DuplicateThreshold) return TaskTitleMatch.Duplicate;
if (overlap >= SimilarThreshold) return TaskTitleMatch.Similar;
return TaskTitleMatch.None;
}
/// <summary>
/// Open tasks whose title describes the same thing as <paramref name="title"/>,
/// closest match first. <paramref name="exceptId"/> skips the task being edited.
/// </summary>
public static List<(TaskItem Task, TaskTitleMatch Match)> FindOpen(
IEnumerable<TaskItem> tasks, string? title, string? exceptId = null)
{
var hits = new List<(TaskItem Task, TaskTitleMatch Match)>();
foreach (var task in tasks)
{
if (!IsOpen(task.Status)) continue;
if (exceptId is not null && string.Equals(task.Id, exceptId, StringComparison.OrdinalIgnoreCase)) continue;
var match = Compare(task.Title, title);
if (match != TaskTitleMatch.None) hits.Add((task, match));
}
return hits
.OrderByDescending(h => h.Match)
.ThenByDescending(h => h.Task.UpdatedAt)
.ToList();
}
}
+120 -12
View File
@@ -31,6 +31,9 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
"'read_many' liest mehrere Dateien in EINEM Aufruf - entweder 'paths' (Liste) oder 'path' " +
"(Verzeichnis) mit 'pattern' (z.B. \"berlin_2026-09-20_*.txt\"). Nimm das, statt viele " +
"Dateien einzeln zu lesen: jeder Einzelaufruf kostet einen Schritt.\n" +
"'list' zeigt standardmäßig die 50 zuletzt geänderten Einträge, neueste zuerst; " +
"'pattern', 'sort', 'order' und 'limit' steuern das. Frage nach einer bestimmten Datei " +
"lieber mit 'pattern', statt eine lange Liste zu durchsuchen.\n" +
"Bei copy kann zwischen Workspaces kopiert werden.\n" +
"'stock_add' fügt einen neuen Datenpunkt zur Aktien-Wissensdatenbank hinzu (shared:stocks/{ticker}/). " +
"Geschützte Pfade (protectedPaths) erlauben nur das Erstellen neuer Dateien — kein Überschreiben oder Löschen.";
@@ -64,7 +67,21 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
},
"pattern": {
"type": "string",
"description": "Nur für read_many: Dateimuster im Verzeichnis 'path', z.B. 'berlin_2026-09-20_*.txt'. Ohne Muster werden alle Dateien des Verzeichnisses gelesen."
"description": "Für read_many und list: Dateimuster im Verzeichnis 'path', z.B. 'berlin_2026-09-20_*.txt'. Ohne Muster gilt das ganze Verzeichnis."
},
"sort": {
"type": "string",
"enum": ["modified", "name"],
"description": "Nur für list: Sortierung. Standard 'modified' (neueste zuerst)."
},
"order": {
"type": "string",
"enum": ["desc", "asc"],
"description": "Nur für list: Richtung. Standard absteigend bei 'modified', aufsteigend bei 'name'."
},
"limit": {
"type": "integer",
"description": "Nur für list: Höchstzahl der Einträge. Standard 50, Maximum 500. Der Kopf der Antwort nennt immer die Gesamtzahl."
},
"destinationWorkspace": {
"type": "string",
@@ -520,6 +537,8 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
var muster = input.TryGetProperty("pattern", out var m) && m.ValueKind == JsonValueKind.String
? m.GetString()
: null;
if (MusterFehler(muster) is { } musterFehler)
return ([], musterFehler);
var gefunden = Directory.GetFiles(verzeichnis, string.IsNullOrWhiteSpace(muster) ? "*" : muster)
.OrderBy(f => f, StringComparer.Ordinal)
@@ -616,6 +635,25 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
}
}
/// <summary>Wie viele Eintraege ein <c>list</c> ohne ausdrueckliches <c>limit</c> zeigt.</summary>
public const int StandardEintraege = 50;
/// <summary>Obergrenze fuer <c>limit</c> - darueber wird die Antwort zur Heuhaufensuche.</summary>
public const int MaxEintraege = 500;
/// <summary>
/// #673: Verzeichnis auflisten - sortiert, gedeckelt, kompakt.
///
/// <para>Anlass: Die Betriebskontrolle im Testbetrieb meldete Stundendateien als fehlend,
/// die es gab. Das Verzeichnis lieferte 105 Eintraege in der Reihenfolge des Dateisystems,
/// vierzeilig als eingeruecktes JSON. Darin zwei bestimmte Namen zu finden ist Sucharbeit,
/// und ein guenstiges Modell erledigt sie falsch.</para>
///
/// <para>Also: neueste zuerst (umstellbar ueber <c>sort</c> und <c>order</c>), <c>pattern</c>
/// zum Eingrenzen, eine Zeile je Eintrag - und der Kopf sagt, wie viele Eintraege es
/// insgesamt gibt. Eine gedeckelte Liste, die ihre Deckelung verschweigt, waere die
/// schlimmere Falle.</para>
/// </summary>
private async Task<ToolResult> HandleListAsync(JsonElement input, string rootPath, string workspace, AgentToolContext context, CancellationToken ct)
{
var path = GetAndValidatePath(input, rootPath, workspace, false, context);
@@ -626,21 +664,91 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
+ AndererWorkspaceHinweis("list", workspace, RelativerPfad(input), context, verzeichnis: true));
}
var relativeDirPath = Path.GetRelativePath(rootPath, path);
var entries = Directory.GetFileSystemEntries(path)
.Select(e => new
{
Name = Path.GetFileName(e),
Type = Directory.Exists(e) ? "directory" : "file",
Size = Directory.Exists(e) ? 0 : new FileInfo(e).Length,
LastModified = File.GetLastWriteTime(e)
})
var muster = GetOptionalString(input, "pattern");
if (MusterFehler(muster) is { } musterFehler)
return ToolResult.Fail(musterFehler);
List<string> pfade;
try
{
pfade = Directory.GetFileSystemEntries(path, string.IsNullOrWhiteSpace(muster) ? "*" : muster).ToList();
}
catch (ArgumentException ex)
{
return ToolResult.Fail($"Ungueltiges Muster '{muster}': {ex.Message}");
}
var eintraege = pfade
.Select(e => new Verzeichniseintrag(
Path.GetFileName(e),
Directory.Exists(e),
Directory.Exists(e) ? 0 : new FileInfo(e).Length,
File.GetLastWriteTime(e)))
.ToList();
var header = $"[{workspace} Workspace] Verzeichnis: {workspace}:/{(relativeDirPath == "." ? "" : relativeDirPath)}\n";
return ToolResult.Ok(header + JsonSerializer.Serialize(entries, new JsonSerializerOptions { WriteIndented = true }));
var nachName = string.Equals(GetOptionalString(input, "sort"), "name", StringComparison.OrdinalIgnoreCase);
var absteigend = GetOptionalString(input, "order") is { } o
? string.Equals(o, "desc", StringComparison.OrdinalIgnoreCase)
: !nachName; // neueste zuerst, Namen aufsteigend
IEnumerable<Verzeichniseintrag> sortiert = nachName
? (absteigend
? eintraege.OrderByDescending(e => e.Name, StringComparer.OrdinalIgnoreCase)
: eintraege.OrderBy(e => e.Name, StringComparer.OrdinalIgnoreCase))
: (absteigend
? eintraege.OrderByDescending(e => e.Geaendert)
: eintraege.OrderBy(e => e.Geaendert));
var grenze = Math.Clamp(GetInt(input, "limit") ?? StandardEintraege, 1, MaxEintraege);
var gezeigt = sortiert.Take(grenze).ToList();
var relativeDirPath = Path.GetRelativePath(rootPath, path);
var ordnung = nachName
? (absteigend ? "Name absteigend" : "Name aufsteigend")
: (absteigend ? "neueste zuerst" : "aelteste zuerst");
var text = new StringBuilder();
text.Append($"[{workspace} Workspace] Verzeichnis: {workspace}:/{(relativeDirPath == "." ? "" : relativeDirPath)}");
text.Append($" - {gezeigt.Count} von {eintraege.Count} Eintraegen, {ordnung}.");
if (!string.IsNullOrWhiteSpace(muster))
text.Append($" Muster: {muster}.");
if (eintraege.Count > gezeigt.Count)
text.Append($"\n{eintraege.Count - gezeigt.Count} weitere nicht gezeigt - grenze mit 'pattern' ein oder setze 'limit' (max {MaxEintraege}).");
text.Append("\nGeaendert Groesse Name\n");
foreach (var e in gezeigt)
{
var groesse = e.IstVerzeichnis ? "<DIR>" : e.Groesse.ToString();
text.Append($"{e.Geaendert:yyyy-MM-dd HH:mm} {groesse,9} {e.Name}{(e.IstVerzeichnis ? "/" : "")}\n");
}
return await Task.FromResult(ToolResult.Ok(text.ToString().TrimEnd()));
}
private readonly record struct Verzeichniseintrag(string Name, bool IstVerzeichnis, long Groesse, DateTime Geaendert);
/// <summary>
/// Ein Muster ist ein Dateiname mit Platzhaltern, kein Pfad. <c>Directory.GetFiles</c>
/// wertet Trennzeichen im Muster aus — <c>pattern: "../*"</c> listet damit das
/// Elternverzeichnis, an der Pfadpruefung vorbei. Deshalb hier und nicht erst dort.
/// </summary>
private static string? MusterFehler(string? muster)
{
if (string.IsNullOrWhiteSpace(muster)) return null;
if (muster.Contains('/') || muster.Contains('\\') || muster.Contains(".."))
return $"Ungueltiges Muster '{muster}': ein Muster ist ein Dateiname mit Platzhaltern "
+ "(z.B. 'berlin_2026-09-21_*.txt'), kein Pfad. Das Verzeichnis gehoert in 'path'.";
return null;
}
private static string? GetOptionalString(JsonElement input, string name)
=> input.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null;
private static int? GetInt(JsonElement input, string name)
=> input.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.Number && v.TryGetInt32(out var i) ? i : null;
private async Task<ToolResult> HandleDeleteAsync(JsonElement input, string rootPath, string workspace, AgentToolContext context, CancellationToken ct)
{
var path = GetAndValidatePath(input, rootPath, workspace, false, context);
@@ -29,7 +29,11 @@ public sealed class TaskboardTool : IAgentTool
Termine. Eine Aufgabe an einen anderen Agenten legst du mit assignee='@<agentId>'
an — sie läuft, wenn sie fällig ist, ohne dass du wartest.
Aktionen: task_create, task_list, task_update, task_comment
Aktionen: task_create, task_find, task_list, task_update, task_comment
Bevor du etwas meldest: task_find mit dem geplanten Titel. Gibt es die Aufgabe
schon, hänge deine Beobachtung mit task_comment an, statt sie erneut anzulegen —
sonst weiß niemand, ob ein Befund neu ist oder seit Tagen unbeantwortet liegt.
Assignee:
- '@new' → frischer Lauf ohne Vorgeschichte
@@ -48,8 +52,8 @@ public sealed class TaskboardTool : IAgentTool
"properties": {
"action": {
"type": "string",
"enum": ["task_create", "task_list", "task_update", "task_comment"],
"description": "Die auszuführende Aktion."
"enum": ["task_create", "task_find", "task_list", "task_update", "task_comment"],
"description": "Die auszuführende Aktion. 'task_find' sucht offene Aufgaben mit ähnlichem Titel — immer vor task_create."
},
"id": {
"type": "string",
@@ -57,7 +61,11 @@ public sealed class TaskboardTool : IAgentTool
},
"title": {
"type": "string",
"description": "Titel der Aufgabe. Pflicht bei task_create."
"description": "Titel der Aufgabe. Pflicht bei task_create und task_find."
},
"force": {
"type": "boolean",
"description": "task_create: trotz gleichlautender offener Aufgabe wirklich eine zweite anlegen. Nur, wenn es ein anderer Sachverhalt ist."
},
"body": {
"type": "string",
@@ -147,7 +155,8 @@ public sealed class TaskboardTool : IAgentTool
{
return action switch
{
"task_create" => await CreateAsync(input, board, ct),
"task_create" => await CreateAsync(input, board, context.AgentId, ct),
"task_find" => await FindAsync(input, board, ct),
"task_list" => await ListAsync(input, board, ct),
"task_update" => await UpdateAsync(input, board, ct),
"task_comment" => await CommentAsync(input, board, context.AgentId, ct),
@@ -163,8 +172,50 @@ public sealed class TaskboardTool : IAgentTool
// ─── Aktionen ───
private static async Task<ToolResult> CreateAsync(
/// <summary>
/// #674: Offene Aufgaben mit ähnlichem Titel — die Frage vor dem Anlegen.
///
/// <para>Die Freitextsuche in task_list beantwortet sie nicht: sie findet nur, was
/// wörtlich übereinstimmt, und liefert eine Liste, die der Agent selbst deuten muss.
/// Hier steht die Antwort im ersten Satz.</para>
/// </summary>
private static async Task<ToolResult> FindAsync(
JsonElement input, TaskboardService board, CancellationToken ct)
{
var title = GetString(input, "title") ?? GetString(input, "search");
if (string.IsNullOrWhiteSpace(title))
return ToolResult.Fail("'title' ist erforderlich — wonach soll gesucht werden?");
var treffer = await SucheAehnlicheAsync(board, title, null, ct);
if (treffer.Count == 0)
return ToolResult.Ok($"Keine offene Aufgabe ähnelt \"{title.Trim()}\". Du kannst sie mit task_create anlegen.");
var sb = new StringBuilder();
sb.AppendLine($"{treffer.Count} offene Aufgabe(n) ähneln \"{title.Trim()}\":");
sb.AppendLine();
foreach (var (task, match) in treffer.Take(10))
{
sb.AppendLine($"{task.Id} [{TaskText.Of(task.Status)}] {task.Title}");
sb.AppendLine($" {(match == TaskTitleMatch.Duplicate ? "derselbe Sachverhalt" : "verwandt")}" +
$" | Assignee: {task.Assignee} | Priorität: {task.Priority}" +
$" | offen seit {task.CreatedAt:dd.MM. HH:mm}");
}
sb.AppendLine();
sb.Append("Hänge deine Beobachtung mit task_comment an die bestehende Aufgabe an, statt eine zweite anzulegen.");
return ToolResult.Ok(sb.ToString());
}
/// <summary>Offene Aufgaben mit verwandtem Titel, engste zuerst.</summary>
private static async Task<List<(TaskItem Task, TaskTitleMatch Match)>> SucheAehnlicheAsync(
TaskboardService board, string title, string? exceptId, CancellationToken ct)
{
var alle = await board.ListAsync(new TaskQuery { Limit = 500 }, ct);
return TaskSimilarity.FindOpen(alle, title, exceptId);
}
private static async Task<ToolResult> CreateAsync(
JsonElement input, TaskboardService board, string author, CancellationToken ct)
{
var title = GetString(input, "title");
if (string.IsNullOrWhiteSpace(title))
@@ -173,6 +224,27 @@ public sealed class TaskboardTool : IAgentTool
if (!TryReadWhen(input, out var when, out var whenError))
return ToolResult.Fail(whenError!);
// #674: Derselbe Befund zum zweiten Mal ist keine zweite Aufgabe. Er wird an der
// bestehenden vermerkt — damit man später sieht, wie oft und wie lange schon.
if (GetBool(input, "force") != true)
{
var dublette = (await SucheAehnlicheAsync(board, title, null, ct))
.FirstOrDefault(t => t.Match == TaskTitleMatch.Duplicate).Task;
if (dublette is not null)
{
await board.AddCommentAsync(
dublette.Id, author,
$"Erneut beobachtet — {author} wollte dieselbe Aufgabe anlegen: \"{title.Trim()}\"",
ct);
return ToolResult.Fail(
$"Nicht angelegt: {dublette.Id} \"{dublette.Title}\" ist seit {dublette.CreatedAt:dd.MM. HH:mm} " +
$"offen und meint dasselbe. Dein erneutes Auftreten wurde dort vermerkt.\n" +
$"Ergänze sie mit task_comment, oder lege bei wirklich anderem Sachverhalt mit force=true an.");
}
}
var definition = new TaskItem
{
Title = title.Trim(),