- 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>
105 lines
4.5 KiB
C#
105 lines
4.5 KiB
C#
using System.Text;
|
|
using ClawdDotNet.Core.Organisation;
|
|
|
|
namespace ClawdDotNet.Core.Tasks;
|
|
|
|
/// <summary>
|
|
/// #714: Why a task exists — its parent, that one's parent, up to the goal of the unit.
|
|
/// Taken from Paperclip (Organisation-Konzept, 9a): an agent that only sees its own order
|
|
/// cannot weigh it. "Write a product text" is a different job when it serves "launch the
|
|
/// spring collection by Friday" than when it serves "clean up old listings".
|
|
///
|
|
/// <para><b>Purpose, not precondition.</b> <see cref="TaskItem.BlockedBy"/> says when a task
|
|
/// may run; the parent says what it is for. Both can point to the same task, and mean
|
|
/// different things.</para>
|
|
///
|
|
/// <para>Walked at run time, never stored as text: a parent that is renamed or finished
|
|
/// shows up as it is now. The walk stops at a cycle and after <see cref="MaxDepth"/> steps,
|
|
/// so a broken chain cannot hang a run or flood its prompt.</para>
|
|
/// </summary>
|
|
public static class GoalChain
|
|
{
|
|
public const int MaxDepth = 8;
|
|
|
|
/// <summary>The parents of a task, nearest first. Stops at a missing parent or a cycle.</summary>
|
|
public static async Task<IReadOnlyList<TaskItem>> ParentsAsync(
|
|
TaskItem task, Func<string, CancellationToken, Task<TaskItem?>> lookup, CancellationToken ct)
|
|
{
|
|
var parents = new List<TaskItem>();
|
|
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { task.Id };
|
|
var next = task.ParentId;
|
|
|
|
while (!string.IsNullOrWhiteSpace(next) && parents.Count < MaxDepth && seen.Add(next))
|
|
{
|
|
if (await lookup(next, ct) is not { } parent)
|
|
break;
|
|
parents.Add(parent);
|
|
next = parent.ParentId;
|
|
}
|
|
return parents;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Whether making <paramref name="parentId"/> the parent of <paramref name="taskId"/>
|
|
/// would close a circle — the parent is the task itself or lies below it.
|
|
/// </summary>
|
|
public static async Task<bool> WouldCycleAsync(
|
|
string taskId, string parentId, Func<string, CancellationToken, Task<TaskItem?>> lookup, CancellationToken ct)
|
|
{
|
|
if (string.Equals(taskId, parentId, StringComparison.OrdinalIgnoreCase))
|
|
return true;
|
|
|
|
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
|
var next = parentId;
|
|
while (!string.IsNullOrWhiteSpace(next) && seen.Add(next))
|
|
{
|
|
if (string.Equals(next, taskId, StringComparison.OrdinalIgnoreCase))
|
|
return true;
|
|
next = (await lookup(next, ct))?.ParentId;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/// <summary>
|
|
/// The goal above the chain: that of the unit of the topmost task that belongs to one —
|
|
/// a goal a human set on the top level belongs to no unit, so the next one down counts —
|
|
/// or of the nearest unit above it that has a goal. <c>null</c> without units or without
|
|
/// a goal.
|
|
/// </summary>
|
|
public static (string UnitName, string Goal)? UnitGoal(IReadOnlyList<TaskItem> fromTop, OrgChart? org)
|
|
{
|
|
if (org is not { HasUnits: true })
|
|
return null;
|
|
var unitId = fromTop.Select(t => TaskBoard.UnitOf(t, org)).FirstOrDefault(u => u is not null);
|
|
if (org.Unit(unitId) is not { } unit)
|
|
return null;
|
|
|
|
foreach (var candidate in new[] { unit }.Concat(org.Ancestors(unit.Id)))
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(candidate.Goal))
|
|
return (candidate.Name, candidate.Goal.Trim());
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// The paragraph that goes into the run: "you do this because … because … because".
|
|
/// <c>null</c> when there is nothing above the task — then the run looks as before.
|
|
/// </summary>
|
|
public static string? Describe(TaskItem task, IReadOnlyList<TaskItem> parents, OrgChart? org)
|
|
{
|
|
var goal = UnitGoal([.. parents.Reverse(), task], org);
|
|
if (parents.Count == 0 && goal is null)
|
|
return null;
|
|
|
|
var sb = new StringBuilder();
|
|
sb.AppendLine("Why this task exists — it serves, from nearest to farthest:");
|
|
foreach (var parent in parents)
|
|
sb.AppendLine($"- {parent.Id} \"{parent.Title}\" ({TaskText.Of(parent.Status)})");
|
|
if (goal is { } g)
|
|
sb.AppendLine($"- the goal of the unit \"{g.UnitName}\": {g.Goal}");
|
|
sb.Append("Weigh your work against this. If the task no longer serves it — the parent is done or canceled, or the task contradicts the goal — say so in your result instead of working blindly.");
|
|
return sb.ToString();
|
|
}
|
|
}
|