diff --git a/src/ClawdDotNet.App/AppHost.cs b/src/ClawdDotNet.App/AppHost.cs
index 1cd4d9a..ee6d2c1 100644
--- a/src/ClawdDotNet.App/AppHost.cs
+++ b/src/ClawdDotNet.App/AppHost.cs
@@ -214,6 +214,9 @@ public sealed class AppHost : IAsyncDisposable
/// Agenten anlegen, entfernen, Werkzeuge zulassen und einstellen.
public AgentAdministration AgentAdmin => _agentAdmin.Value;
+ /// #683: Units, moves, tool ceilings, unit-wide values. Stateless.
+ public OrgAdministration Organisation => new(this, LoggerFactory.CreateLogger("ClawdDotNet.App.Organisation"));
+
/// Dienste der Instanz anlegen, entfernen, umschalten.
public ServiceAdministration ServiceAdmin => _serviceAdmin.Value;
@@ -565,6 +568,12 @@ public sealed class AppHost : IAsyncDisposable
return string.IsNullOrWhiteSpace(agent?.AgentDir) ? null : agent.AgentDir;
});
+ // #683: The organisation tree, fresh from the configuration on every question, so a
+ // change made through the administration applies to the next run without a restart.
+ engine.OrganisationProvider = () => Core.Organisation.OrgChart.From(instance);
+ foreach (var problem in Core.Organisation.OrgChart.From(instance).Problems())
+ logger.LogWarning("Organisation: {Problem}", problem);
+
engine.LoadPersistedChats();
var (scanner, staging, board) = BuildTaskboard(
diff --git a/src/ClawdDotNet.App/Services/AgentAdministration.cs b/src/ClawdDotNet.App/Services/AgentAdministration.cs
index 877457f..905f07e 100644
--- a/src/ClawdDotNet.App/Services/AgentAdministration.cs
+++ b/src/ClawdDotNet.App/Services/AgentAdministration.cs
@@ -1,6 +1,7 @@
using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Instances;
using ClawdDotNet.Core.Staging;
+using Microsoft.Extensions.Logging;
namespace ClawdDotNet.App.Services;
@@ -9,7 +10,8 @@ public sealed record NewAgent(
string AgentId,
string DisplayName,
string Model,
- string Description = "");
+ string Description = "",
+ string? UnitId = null);
///
/// Agenten anlegen, entfernen, ihre Werkzeuge zulassen und einstellen.
@@ -86,6 +88,10 @@ public sealed class AgentAdministration(AppHost host)
if (Find(agentId) is not null)
return AdminResult.Failure($"Ein Agent mit der ID '{agentId}' existiert bereits.");
+ var unitId = string.IsNullOrWhiteSpace(request.UnitId) ? null : request.UnitId.Trim();
+ if (unitId is not null && host.Organisation.Current().Unit(unitId) is null)
+ return AdminResult.Failure($"There is no unit '{unitId}'.");
+
string agentDir;
try
@@ -111,6 +117,14 @@ public sealed class AgentAdministration(AppHost host)
config.Model = request.Model;
config.Description = request.Description;
+ // #683: Placed into a unit, the agent starts with the unit's defaults.
+ if (unitId is not null)
+ {
+ config.UnitId = unitId;
+ config.UnitSinceUtc = DateTime.UtcNow;
+ host.Organisation.ApplyDefaultsToNewAgent(config);
+ }
+
host.Directories.SaveAgentSettings(agentDir, config);
host.Instance.Agents.Add(config);
@@ -181,6 +195,10 @@ public sealed class AgentAdministration(AppHost host)
if (enabled)
{
+ // #683: What the unit may not have, a member cannot be granted.
+ if (!agent.Tools.ContainsKey(toolName) && host.Organisation.CeilingRefusal(agent, toolName) is { } refused)
+ return AdminResult.Failure(refused);
+
if (!agent.Tools.ContainsKey(toolName))
agent.Tools[toolName] = new Dictionary();
}
@@ -278,6 +296,10 @@ public sealed class AgentAdministration(AppHost host)
if (Find(agentId) is not { } agent)
return AdminResult.Failure($"Kein Agent mit der ID '{agentId}'.");
+ // #683: Settings for a tool the agent does not have yet grant it — the ceiling applies.
+ if (!agent.Tools.ContainsKey(toolName) && host.Organisation.CeilingRefusal(agent, toolName) is { } refused)
+ return AdminResult.Failure(refused);
+
// #641: Die Einstellungsmasken kennen die Rechte nicht und schreiben ein frisches
// Woerterbuch. Ohne diese Uebernahme loeschte jedes Speichern still "erlaubt" und
// "verboten" - und damit die Rechte des Agenten.
@@ -309,6 +331,22 @@ public sealed class AgentAdministration(AppHost host)
///
public void Save()
{
+ // #683: The desktop toggles tools directly on the configuration objects and only
+ // saves here. The ceiling is a rule of writing, so it is enforced here as well: a
+ // tool beyond it does not get saved.
+ var chart = host.Organisation.Current();
+ foreach (var agent in host.Instance.Agents)
+ {
+ if (chart.ToolCeiling(agent.UnitId) is not { } ceiling)
+ continue;
+ foreach (var tool in agent.Tools.Keys.Where(t => !ceiling.Contains(t)).ToList())
+ {
+ agent.Tools.Remove(tool);
+ host.LoggerFactory.CreateLogger("ClawdDotNet.App.Organisation").LogWarning(
+ "Tool {Tool} removed from {AgentId}: beyond the tool ceiling of its unit", tool, agent.AgentId);
+ }
+ }
+
foreach (var agent in host.Instance.Agents)
PersistAgent(agent);
@@ -329,7 +367,7 @@ public sealed class AgentAdministration(AppHost host)
/// werden sie gelesen, und was in AgentSettings.json danebenstünde, überschriebe
/// der nächste Ladevorgang ohnehin.
///
- private void PersistAgent(AgentConfig agent)
+ internal void PersistAgent(AgentConfig agent)
{
// Ein Agent ohne Verzeichnis ist einer aus einer von Hand gepflegten
// Instanzdatei. Ihm hier einen Ordner anzulegen waere eine Nebenwirkung des
diff --git a/src/ClawdDotNet.App/Services/OrgAdministration.cs b/src/ClawdDotNet.App/Services/OrgAdministration.cs
new file mode 100644
index 0000000..7dd0f84
--- /dev/null
+++ b/src/ClawdDotNet.App/Services/OrgAdministration.cs
@@ -0,0 +1,598 @@
+using System.Text.Json;
+using ClawdDotNet.Core.Audit;
+using ClawdDotNet.Core.Config;
+using ClawdDotNet.Core.Organisation;
+using Microsoft.Extensions.Logging;
+
+namespace ClawdDotNet.App.Services;
+
+/// A unit-wide value that can be written into the members (#683).
+public enum UnitSetting
+{
+ DailyCostUsd,
+ DailyTokens,
+ Model,
+ WhenExhausted
+}
+
+/// How a unit-wide number is turned into per-agent values.
+public enum UnitSettingMode
+{
+ /// Every agent gets the value ("each gets 10 $").
+ PerAgent,
+
+ /// The value is a total, split in proportion to the current values ("the unit gets 100 $").
+ Distribute
+}
+
+/// One line of a preview: what would change at one agent.
+public sealed record UnitSettingChange(
+ string AgentId,
+ string DisplayName,
+ string Current,
+ string Proposed,
+ bool Outlier,
+ bool SelectedByDefault);
+
+/// What a move or a lowered ceiling would do before it is done.
+public sealed record StructurePreview(
+ IReadOnlyList AffectedAgents,
+ IReadOnlyDictionary> ToolsLost,
+ IReadOnlyList Running,
+ IReadOnlyList Problems)
+{
+ public bool CanApply => Running.Count == 0 && Problems.Count == 0;
+}
+
+///
+/// #683: Units — create, rename, lead, move, limit — and unit-wide values written into the
+/// members. The rules live here, not in a user interface, so the WebUI and the desktop
+/// apply the same ones (like ).
+///
+/// Three rules from the concept (section 3):
+///
+/// No value overrides another at run time. A unit-wide value is written into
+/// the agents after a preview; the unit keeps it only as the default for agents that join.
+/// Outliers — the specialist with a deliberately large budget — are marked and not
+/// selected by default.
+/// The tool ceiling is checked when writing. A tool beyond it cannot be
+/// granted; lowering it removes the tools from the agents, after a preview.
+/// A move never happens under a running agent. The concept says "with the
+/// instance stopped", so that no agent gets different rights in the middle of a run. The
+/// administration runs inside the instance and cannot stop it; it refuses while an affected
+/// agent runs, which serves the same purpose more narrowly.
+///
+///
+/// Every structural change is written to the audit log with its reason — a change of
+/// structure is a change of rights (#205, #641).
+///
+public sealed class OrgAdministration(AppHost host, ILogger logger)
+{
+ /// A value this far from the median of the others is an outlier.
+ public const decimal OutlierFactor = 3m;
+
+ private OrgChart Chart => OrgChart.From(host.Instance);
+
+ public OrgChart Current() => Chart;
+
+ // ─── units ───
+
+ public AdminResult CreateUnit(string id, string name, string? parentId, string? goal = null, string reason = "")
+ {
+ id = (id ?? "").Trim();
+ if (id.Length == 0 || id.Any(c => !(char.IsLetterOrDigit(c) || c is '-' or '_')))
+ return AdminResult.Failure("A unit id consists of letters, digits, '-' and '_'.");
+ if (string.IsNullOrWhiteSpace(name))
+ return AdminResult.Failure("A unit needs a name.");
+ if (Chart.Unit(id) is not null)
+ return AdminResult.Failure($"There is already a unit '{id}'.");
+ if (!string.IsNullOrWhiteSpace(parentId) && Chart.Unit(parentId) is null)
+ return AdminResult.Failure($"There is no unit '{parentId}' to put it under.");
+
+ host.Instance.Units.Add(new UnitConfig
+ {
+ Id = id,
+ Name = name.Trim(),
+ ParentId = string.IsNullOrWhiteSpace(parentId) ? null : parentId.Trim(),
+ Goal = goal?.Trim() ?? ""
+ });
+
+ Save(null, "unit_create", new { id, name, parentId, goal }, reason);
+ return AdminResult.Success($"Unit '{name.Trim()}' created.");
+ }
+
+ public AdminResult UpdateUnit(string unitId, string? name, string? goal, string reason = "")
+ {
+ if (FindUnit(unitId) is not { } unit)
+ return AdminResult.Failure($"There is no unit '{unitId}'.");
+
+ if (name is not null)
+ {
+ if (string.IsNullOrWhiteSpace(name))
+ return AdminResult.Failure("A unit needs a name.");
+ unit.Name = name.Trim();
+ }
+ if (goal is not null)
+ unit.Goal = goal.Trim();
+
+ Save(null, "unit_update", new { unitId, name, goal }, reason);
+ return AdminResult.Success($"Unit '{unit.Name}' updated.");
+ }
+
+ /// Only an empty unit can go — members and sub-units have to be moved first.
+ public AdminResult DeleteUnit(string unitId, string reason = "")
+ {
+ if (FindUnit(unitId) is not { } unit)
+ return AdminResult.Failure($"There is no unit '{unitId}'.");
+ var chart = Chart;
+ if (chart.MembersOf(unit.Id).Count > 0 || chart.Children(unit.Id).Count > 0)
+ return AdminResult.Failure($"Unit '{unit.Name}' still has members or sub-units. Move them first.");
+
+ host.Instance.Units.Remove(unit);
+ Save(null, "unit_delete", new { unitId }, reason);
+ return AdminResult.Success($"Unit '{unit.Name}' deleted.");
+ }
+
+ /// The lead must be a member of the unit. null leaves it without a lead.
+ public AdminResult SetLeader(string unitId, string? agentId, string reason = "")
+ {
+ if (FindUnit(unitId) is not { } unit)
+ return AdminResult.Failure($"There is no unit '{unitId}'.");
+
+ if (!string.IsNullOrWhiteSpace(agentId))
+ {
+ if (host.AgentAdmin.Find(agentId) is not { } agent)
+ return AdminResult.Failure($"There is no agent '{agentId}'.");
+ if (!string.Equals(agent.UnitId, unit.Id, StringComparison.OrdinalIgnoreCase))
+ return AdminResult.Failure($"'{agentId}' is not a member of '{unit.Name}'. Move it there first.");
+ }
+
+ unit.LeaderAgentId = string.IsNullOrWhiteSpace(agentId) ? null : agentId.Trim();
+ Save(null, "unit_lead", new { unitId, agentId }, reason);
+ return AdminResult.Success(unit.LeaderAgentId is null
+ ? $"'{unit.Name}' has no lead now; the lead above takes over."
+ : $"'{unit.LeaderAgentId}' leads '{unit.Name}'.");
+ }
+
+ // ─── moves ───
+
+ public StructurePreview PreviewMoveAgent(string agentId, string? targetUnitId)
+ {
+ var chart = Chart;
+ var problems = new List();
+ if (chart.Agent(agentId) is null)
+ problems.Add($"There is no agent '{agentId}'.");
+ if (!string.IsNullOrWhiteSpace(targetUnitId) && chart.Unit(targetUnitId) is null)
+ problems.Add($"There is no unit '{targetUnitId}'.");
+
+ return Preview([agentId], NullIfEmpty(targetUnitId), movingUnit: null, problems);
+ }
+
+ ///
+ /// Moves an agent. Tools beyond the new unit's ceiling are removed only with
+ /// — otherwise the move is refused and says which.
+ /// A lead leaving its unit leaves it without a lead.
+ ///
+ public AdminResult MoveAgent(string agentId, string? targetUnitId, string reason, bool dropExcessTools = false)
+ {
+ targetUnitId = NullIfEmpty(targetUnitId);
+ var preview = PreviewMoveAgent(agentId, targetUnitId);
+ if (Refusal(preview, dropExcessTools) is { } refused)
+ return refused;
+
+ var agent = host.AgentAdmin.Find(agentId)!;
+ if (string.Equals(agent.UnitId, targetUnitId, StringComparison.OrdinalIgnoreCase))
+ return AdminResult.Success($"'{agentId}' is already there.");
+
+ var from = agent.UnitId;
+ if (FindUnit(from) is { } oldUnit
+ && string.Equals(oldUnit.LeaderAgentId, agentId, StringComparison.OrdinalIgnoreCase))
+ oldUnit.LeaderAgentId = null;
+
+ DropTools(preview);
+ agent.PreviousUnitId = from;
+ agent.UnitId = targetUnitId;
+ agent.UnitSinceUtc = DateTime.UtcNow;
+ host.AgentAdmin.PersistAgent(agent);
+
+ Save(agentId, "agent_move", new { agentId, from, to = targetUnitId, toolsLost = preview.ToolsLost }, reason);
+ return AdminResult.Success($"'{agentId}' moved to {Describe(targetUnitId)}.");
+ }
+
+ public StructurePreview PreviewMoveUnit(string unitId, string? newParentId)
+ {
+ var chart = Chart;
+ var problems = new List();
+ newParentId = NullIfEmpty(newParentId);
+
+ if (chart.Unit(unitId) is null)
+ problems.Add($"There is no unit '{unitId}'.");
+ else if (newParentId is not null)
+ {
+ if (chart.Unit(newParentId) is null)
+ problems.Add($"There is no unit '{newParentId}'.");
+ else if (string.Equals(newParentId, unitId, StringComparison.OrdinalIgnoreCase)
+ || chart.Ancestors(newParentId).Any(u => string.Equals(u.Id, unitId, StringComparison.OrdinalIgnoreCase)))
+ problems.Add("A unit cannot be moved under itself or one of its own sub-units.");
+ }
+
+ var agents = Subtree(chart, unitId).SelectMany(u => chart.MembersOf(u.Id)).Select(a => a.AgentId).ToList();
+ return Preview(agents, targetUnitId: null, movingUnit: (unitId, newParentId), problems);
+ }
+
+ ///
+ /// Moves a unit with everything in it. Its members keep their unit; what changes is
+ /// what lies above — leads, reach and possibly the tool ceiling.
+ ///
+ public AdminResult MoveUnit(string unitId, string? newParentId, string reason, bool dropExcessTools = false)
+ {
+ newParentId = NullIfEmpty(newParentId);
+ var preview = PreviewMoveUnit(unitId, newParentId);
+ if (Refusal(preview, dropExcessTools) is { } refused)
+ return refused;
+
+ var unit = FindUnit(unitId)!;
+ var from = unit.ParentId;
+ DropTools(preview);
+ unit.ParentId = newParentId;
+
+ // The members changed position although not unit: tell them, like a move.
+ var now = DateTime.UtcNow;
+ foreach (var agentId in preview.AffectedAgents)
+ {
+ if (host.AgentAdmin.Find(agentId) is { } agent)
+ {
+ agent.UnitSinceUtc = now;
+ host.AgentAdmin.PersistAgent(agent);
+ }
+ }
+
+ Save(null, "unit_move", new { unitId, from, to = newParentId, toolsLost = preview.ToolsLost }, reason);
+ return AdminResult.Success($"Unit '{unit.Name}' moved under {Describe(newParentId)}.");
+ }
+
+ // ─── tool ceiling ───
+
+ public StructurePreview PreviewToolCeiling(string unitId, IReadOnlyCollection? tools)
+ {
+ var chart = Chart;
+ var problems = new List();
+ if (chart.Unit(unitId) is null)
+ problems.Add($"There is no unit '{unitId}'.");
+
+ var agents = Subtree(chart, unitId).SelectMany(u => chart.MembersOf(u.Id)).Select(a => a.AgentId).ToList();
+ return Preview(agents, targetUnitId: null, movingUnit: null, problems, ceiling: (unitId, tools));
+ }
+
+ ///
+ /// Sets the ceiling. Lowering it removes the tools beyond it from the members — shown by
+ /// first, and only with ,
+ /// like a move. null lifts the ceiling.
+ ///
+ public AdminResult SetToolCeiling(
+ string unitId, IReadOnlyCollection? tools, string reason, bool dropExcessTools = false)
+ {
+ var preview = PreviewToolCeiling(unitId, tools);
+ if (Refusal(preview, dropExcessTools) is { } refused)
+ return refused;
+
+ var unit = FindUnit(unitId)!;
+ DropTools(preview);
+ unit.ToolCeiling = tools?.Select(t => t.Trim()).Where(t => t.Length > 0)
+ .Distinct(StringComparer.OrdinalIgnoreCase).ToList();
+
+ Save(null, "unit_ceiling", new { unitId, tools = unit.ToolCeiling, toolsLost = preview.ToolsLost }, reason);
+ return AdminResult.Success(unit.ToolCeiling is null
+ ? $"'{unit.Name}' has no tool ceiling now."
+ : $"Tool ceiling of '{unit.Name}' set: {string.Join(", ", unit.ToolCeiling)}.");
+ }
+
+ ///
+ /// Whether an agent may be granted a tool — called by
+ /// when writing. null means yes; otherwise the reason.
+ ///
+ public string? CeilingRefusal(AgentConfig agent, string toolName)
+ {
+ if (Chart.ToolCeiling(agent.UnitId) is not { } ceiling || ceiling.Contains(toolName))
+ return null;
+ return $"'{toolName}' is beyond the tool ceiling of the unit '{Chart.UnitOf(agent.AgentId)?.Name}'. " +
+ "Raise the ceiling of the unit or move the agent.";
+ }
+
+ // ─── unit-wide values ───
+
+ ///
+ /// What writing a unit-wide value would change, per agent below the unit (sub-units
+ /// included). Outliers are marked and not selected by default: the specialist with a
+ /// deliberately large budget must not be overwritten by accident.
+ ///
+ public IReadOnlyList PreviewSetting(
+ string unitId, UnitSetting setting, string value, UnitSettingMode mode = UnitSettingMode.PerAgent)
+ {
+ var chart = Chart;
+ if (chart.Unit(unitId) is null)
+ return [];
+
+ var agents = Subtree(chart, unitId).SelectMany(u => chart.MembersOf(u.Id)).ToList();
+ var numeric = setting is UnitSetting.DailyCostUsd or UnitSetting.DailyTokens;
+
+ if (!numeric)
+ {
+ return agents.Select(a => new UnitSettingChange(
+ a.AgentId, a.DisplayName, CurrentText(a, setting), value.Trim(), Outlier: false,
+ SelectedByDefault: true)).ToList();
+ }
+
+ if (!decimal.TryParse(value, System.Globalization.NumberStyles.Number,
+ System.Globalization.CultureInfo.InvariantCulture, out var number) || number < 0)
+ return [];
+
+ var current = agents.Select(a => CurrentNumber(a, setting)).ToList();
+ var outliers = OutlierFlags(current);
+
+ IReadOnlyList proposed;
+ if (mode == UnitSettingMode.Distribute)
+ {
+ // In proportion to the current values, so a specialist keeps its larger share.
+ // Without any current values, evenly.
+ var sum = current.Sum();
+ proposed = current.Select(c => sum > 0 ? number * c / sum : agents.Count == 0 ? 0 : number / agents.Count).ToList();
+ }
+ else
+ {
+ proposed = current.Select(_ => number).ToList();
+ }
+
+ return agents.Select((a, i) => new UnitSettingChange(
+ a.AgentId, a.DisplayName,
+ Format(current[i], setting), Format(proposed[i], setting),
+ outliers[i], SelectedByDefault: !outliers[i])).ToList();
+ }
+
+ ///
+ /// Writes the values chosen from a preview into the agents, one audit entry each, and —
+ /// with — keeps the value as the unit's default for
+ /// agents that join.
+ ///
+ public AdminResult ApplySetting(
+ string unitId, UnitSetting setting, IReadOnlyDictionary valuesByAgent,
+ string reason, string? defaultValue = null)
+ {
+ if (FindUnit(unitId) is not { } unit)
+ return AdminResult.Failure($"There is no unit '{unitId}'.");
+
+ var chart = Chart;
+ var below = Subtree(chart, unitId).SelectMany(u => chart.MembersOf(u.Id))
+ .Select(a => a.AgentId).ToHashSet(StringComparer.OrdinalIgnoreCase);
+
+ var written = 0;
+ foreach (var (agentId, value) in valuesByAgent)
+ {
+ if (!below.Contains(agentId) || host.AgentAdmin.Find(agentId) is not { } agent)
+ return AdminResult.Failure($"'{agentId}' is not below '{unit.Name}'.");
+ if (Write(agent, setting, value) is { } invalid)
+ return AdminResult.Failure(invalid);
+
+ host.AgentAdmin.PersistAgent(agent);
+ Audit(agentId, "unit_setting", new { unitId, setting = setting.ToString(), value }, reason);
+ written++;
+ }
+
+ if (defaultValue is not null)
+ RememberDefault(unit, setting, defaultValue);
+
+ Save(null, "unit_setting_done", new { unitId, setting = setting.ToString(), written, defaultValue }, reason);
+ return AdminResult.Success($"{written} agent(s) in '{unit.Name}' updated.");
+ }
+
+ ///
+ /// Gives an agent that is new to a unit the unit's defaults — only then. A move does not
+ /// apply them: it would overwrite exactly the values an experienced agent was tuned to.
+ ///
+ public void ApplyDefaultsToNewAgent(AgentConfig agent)
+ {
+ if (FindUnit(agent.UnitId) is not { } unit)
+ return;
+ var d = unit.Defaults;
+ if (!string.IsNullOrWhiteSpace(d.Model)) agent.Model = d.Model;
+ if (d.DailyCostUsd is { } cost) agent.Budget.DailyCostUsd = cost;
+ if (d.DailyTokens is { } tokens) agent.Budget.DailyTokens = tokens;
+ if (!string.IsNullOrWhiteSpace(d.WhenExhausted)) agent.Budget.WhenExhausted = d.WhenExhausted;
+ }
+
+ // ─── helpers ───
+
+ private StructurePreview Preview(
+ IReadOnlyList agents, string? targetUnitId, (string UnitId, string? NewParent)? movingUnit,
+ List problems, (string UnitId, IReadOnlyCollection? Tools)? ceiling = null)
+ {
+ // The structure as it would be afterwards — the ceiling is computed on that.
+ var units = host.Instance.Units.Select(Clone).ToList();
+ if (movingUnit is { } m && units.FirstOrDefault(u => Same(u.Id, m.UnitId)) is { } moved)
+ moved.ParentId = m.NewParent;
+ if (ceiling is { } c && units.FirstOrDefault(u => Same(u.Id, c.UnitId)) is { } limited)
+ limited.ToolCeiling = c.Tools?.ToList();
+
+ var after = new OrgChart(units, host.Instance.Agents);
+ var lost = new Dictionary>(StringComparer.OrdinalIgnoreCase);
+ foreach (var agentId in agents)
+ {
+ if (host.AgentAdmin.Find(agentId) is not { } agent)
+ continue;
+ // Moving an agent: its new unit. Moving a unit or setting a ceiling: every agent
+ // stays in its unit — what changes is the structure above it.
+ var unitAfter = movingUnit is null && ceiling is null ? targetUnitId : agent.UnitId;
+ if (after.ToolCeiling(unitAfter) is { } limit)
+ {
+ var beyond = agent.Tools.Keys.Where(t => !limit.Contains(t)).ToList();
+ if (beyond.Count > 0)
+ lost[agentId] = beyond;
+ }
+ }
+
+ var running = (host.Engine?.Activity.ActiveRuns() ?? [])
+ .Select(r => r.AgentId)
+ .Where(id => agents.Contains(id, StringComparer.OrdinalIgnoreCase))
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .ToList();
+
+ return new StructurePreview(agents, lost, running, problems);
+ }
+
+ private static AdminResult? Refusal(StructurePreview preview, bool dropExcessTools)
+ {
+ if (preview.Problems.Count > 0)
+ return AdminResult.Failure(string.Join(" ", preview.Problems));
+ if (preview.Running.Count > 0)
+ return AdminResult.Failure(
+ $"Not while {string.Join(", ", preview.Running)} run(s): an agent must not get different rights in the middle of a run. Try again when the run is over.");
+ if (preview.ToolsLost.Count > 0 && !dropExcessTools)
+ return AdminResult.Failure(
+ "The new position has a tool ceiling these agents are beyond: " +
+ string.Join("; ", preview.ToolsLost.Select(kv => $"{kv.Key}: {string.Join(", ", kv.Value)}")) +
+ ". Confirm to remove those tools, or raise the ceiling first.");
+ return null;
+ }
+
+ private void DropTools(StructurePreview preview)
+ {
+ foreach (var (agentId, tools) in preview.ToolsLost)
+ {
+ if (host.AgentAdmin.Find(agentId) is not { } agent)
+ continue;
+ foreach (var tool in tools)
+ agent.Tools.Remove(tool);
+ host.AgentAdmin.PersistAgent(agent);
+ }
+ }
+
+ private static IEnumerable Subtree(OrgChart chart, string unitId)
+ {
+ if (chart.Unit(unitId) is not { } root)
+ yield break;
+ var stack = new Stack([root]);
+ var seen = new HashSet(StringComparer.OrdinalIgnoreCase);
+ while (stack.Count > 0)
+ {
+ var unit = stack.Pop();
+ if (!seen.Add(unit.Id))
+ continue;
+ yield return unit;
+ foreach (var child in chart.Children(unit.Id))
+ stack.Push(child);
+ }
+ }
+
+ private static bool[] OutlierFlags(IReadOnlyList values)
+ {
+ var flags = new bool[values.Count];
+ for (var i = 0; i < values.Count; i++)
+ {
+ var others = values.Where((_, j) => j != i && values[j] > 0).Order().ToList();
+ if (others.Count == 0 || values[i] <= 0)
+ continue;
+ var median = others[others.Count / 2];
+ flags[i] = values[i] >= median * OutlierFactor || values[i] <= median / OutlierFactor;
+ }
+ return flags;
+ }
+
+ private static decimal CurrentNumber(AgentConfig agent, UnitSetting setting) => setting switch
+ {
+ UnitSetting.DailyCostUsd => agent.Budget.DailyCostUsd,
+ UnitSetting.DailyTokens => agent.Budget.DailyTokens,
+ _ => 0
+ };
+
+ private static string CurrentText(AgentConfig agent, UnitSetting setting) => setting switch
+ {
+ UnitSetting.Model => agent.Model,
+ UnitSetting.WhenExhausted => agent.Budget.WhenExhausted,
+ _ => Format(CurrentNumber(agent, setting), setting)
+ };
+
+ private static string Format(decimal value, UnitSetting setting) => setting == UnitSetting.DailyTokens
+ ? decimal.Round(value).ToString(System.Globalization.CultureInfo.InvariantCulture)
+ : decimal.Round(value, 2).ToString("0.00", System.Globalization.CultureInfo.InvariantCulture);
+
+ private static string? Write(AgentConfig agent, UnitSetting setting, string value)
+ {
+ var inv = System.Globalization.CultureInfo.InvariantCulture;
+ switch (setting)
+ {
+ case UnitSetting.DailyCostUsd when decimal.TryParse(value, System.Globalization.NumberStyles.Number, inv, out var cost) && cost >= 0:
+ agent.Budget.DailyCostUsd = cost;
+ return null;
+ case UnitSetting.DailyTokens when long.TryParse(value, System.Globalization.NumberStyles.Number, inv, out var tokens) && tokens >= 0:
+ agent.Budget.DailyTokens = tokens;
+ return null;
+ case UnitSetting.Model when !string.IsNullOrWhiteSpace(value):
+ agent.Model = value.Trim();
+ return null;
+ case UnitSetting.WhenExhausted when value.Trim() is "expire" or "catchUpOnce":
+ agent.Budget.WhenExhausted = value.Trim();
+ return null;
+ default:
+ return $"'{value}' is not a valid value for {setting}.";
+ }
+ }
+
+ private static void RememberDefault(UnitConfig unit, UnitSetting setting, string value)
+ {
+ var inv = System.Globalization.CultureInfo.InvariantCulture;
+ switch (setting)
+ {
+ case UnitSetting.DailyCostUsd: unit.Defaults.DailyCostUsd = decimal.Parse(value, inv); break;
+ case UnitSetting.DailyTokens: unit.Defaults.DailyTokens = long.Parse(value, inv); break;
+ case UnitSetting.Model: unit.Defaults.Model = value.Trim(); break;
+ case UnitSetting.WhenExhausted: unit.Defaults.WhenExhausted = value.Trim(); break;
+ }
+ }
+
+ private UnitConfig? FindUnit(string? unitId)
+ => unitId is null ? null : host.Instance.Units.FirstOrDefault(u => Same(u.Id, unitId));
+
+ private string Describe(string? unitId)
+ => FindUnit(unitId) is { } unit ? $"'{unit.Name}'" : "the top level";
+
+ private static UnitConfig Clone(UnitConfig u) => new()
+ {
+ Id = u.Id, Name = u.Name, ParentId = u.ParentId, LeaderAgentId = u.LeaderAgentId,
+ Goal = u.Goal, ToolCeiling = u.ToolCeiling?.ToList(), Defaults = u.Defaults
+ };
+
+ private static bool Same(string? a, string? b) => string.Equals(a, b, StringComparison.OrdinalIgnoreCase);
+
+ private static string? NullIfEmpty(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
+
+ private void Save(string? agentId, string action, object details, string reason)
+ {
+ host.SaveInstance(new InstanceChange(InstanceChangeKind.Agents, agentId));
+ Audit(agentId ?? "", action, details, reason);
+ }
+
+ private void Audit(string agentId, string action, object details, string reason)
+ {
+ if (host.AuditRepository is not { } audit)
+ return;
+ try
+ {
+ audit.AppendAsync(new AuditEntry
+ {
+ RunId = "",
+ AgentId = agentId,
+ Source = "admin",
+ Tool = "Organisation",
+ Arguments = JsonSerializer.Serialize(new { action, details }),
+ Status = AuditStatus.Ok,
+ Summary = string.IsNullOrWhiteSpace(reason) ? action : $"{action}: {reason.Trim()}",
+ OccurredAt = DateTime.UtcNow
+ }, CancellationToken.None).GetAwaiter().GetResult();
+ }
+ catch (Exception ex)
+ {
+ // The change itself is saved; a missing audit line must not undo it.
+ logger.LogError(ex, "Organisation change {Action} could not be written to the audit log", action);
+ }
+ }
+}
diff --git a/src/ClawdDotNet.Core/Config/AgentConfig.cs b/src/ClawdDotNet.Core/Config/AgentConfig.cs
index 1bbbd92..9c2d06a 100644
--- a/src/ClawdDotNet.Core/Config/AgentConfig.cs
+++ b/src/ClawdDotNet.Core/Config/AgentConfig.cs
@@ -10,6 +10,24 @@ public sealed class AgentConfig
[JsonPropertyName("displayName")]
public string DisplayName { get; set; } = "";
+ /// #683: The unit this agent belongs to; null for the top level.
+ [JsonPropertyName("unitId")]
+ public string? UnitId { get; set; }
+
+ ///
+ /// #683: Since when the agent is in its unit (UTC) — so it can be told that it moved,
+ /// and its lead that someone joined.
+ ///
+ [JsonPropertyName("unitSinceUtc")]
+ public DateTime? UnitSinceUtc { get; set; }
+
+ ///
+ /// #683: The unit the agent left with its last move — so the lead it left is told as
+ /// well as the one it joined. null after the first placement.
+ ///
+ [JsonPropertyName("previousUnitId")]
+ public string? PreviousUnitId { get; set; }
+
[JsonPropertyName("model")]
public string Model { get; set; } = "anthropic/claude-sonnet-4-5";
diff --git a/src/ClawdDotNet.Core/Config/InstanceConfig.cs b/src/ClawdDotNet.Core/Config/InstanceConfig.cs
index 20dfa4a..054dbbd 100644
--- a/src/ClawdDotNet.Core/Config/InstanceConfig.cs
+++ b/src/ClawdDotNet.Core/Config/InstanceConfig.cs
@@ -172,6 +172,13 @@ public sealed class InstanceConfig
[JsonPropertyName("agents")]
public List Agents { get; set; } = new();
+ ///
+ /// #683: Departments, teams, groups — the organisation tree. Empty means no structure:
+ /// every agent can reach every other, as before units existed.
+ ///
+ [JsonPropertyName("units")]
+ public List Units { get; set; } = new();
+
[JsonPropertyName("services")]
public List Services { get; set; } = new();
}
diff --git a/src/ClawdDotNet.Core/Config/UnitConfig.cs b/src/ClawdDotNet.Core/Config/UnitConfig.cs
new file mode 100644
index 0000000..0d68c2b
--- /dev/null
+++ b/src/ClawdDotNet.Core/Config/UnitConfig.cs
@@ -0,0 +1,72 @@
+using System.Text.Json.Serialization;
+
+namespace ClawdDotNet.Core.Config;
+
+///
+/// #683: A unit — department, team, group — as a node in the organisation tree.
+///
+/// Stored in the instance configuration, next to the agents. An agent's tools
+/// already live there; a unit's rules belong beside them, so the truth about an agent is in
+/// one place. Every structural change is written to the audit log by the administration,
+/// which is what the concept asked the database for.
+///
+/// No value here overrides an agent at run time (Organisation-Konzept, section
+/// 3). are only the starting values for agents that join;
+/// is checked when an agent's tools are written, never laid over
+/// them while it runs. What stands at the agent is what applies.
+///
+public sealed class UnitConfig
+{
+ [JsonPropertyName("id")]
+ public string Id { get; set; } = "";
+
+ [JsonPropertyName("name")]
+ public string Name { get; set; } = "";
+
+ /// The parent unit; null for a top-level unit.
+ [JsonPropertyName("parentId")]
+ public string? ParentId { get; set; }
+
+ ///
+ /// The agent leading this unit — one of its members. May be empty: then the nearest
+ /// unit above that has a lead takes over.
+ ///
+ [JsonPropertyName("leaderAgentId")]
+ public string? LeaderAgentId { get; set; }
+
+ /// What the unit exists for — the top of every task chain in it (#714).
+ [JsonPropertyName("goal")]
+ public string Goal { get; set; } = "";
+
+ ///
+ /// The tools members of this unit may have at most; null means no ceiling. A
+ /// ceiling applies to sub-units as well — the effective ceiling is the intersection
+ /// along the path to the root.
+ ///
+ [JsonPropertyName("toolCeiling")]
+ public List? ToolCeiling { get; set; }
+
+ /// Starting values for agents that join this unit.
+ [JsonPropertyName("defaults")]
+ public UnitDefaults Defaults { get; set; } = new();
+}
+
+///
+/// #683: What an agent gets when it joins a unit. null leaves the agent's own value.
+/// Changing these does not touch existing members — writing a value into them is a
+/// separate, previewed step.
+///
+public sealed class UnitDefaults
+{
+ [JsonPropertyName("model")]
+ public string? Model { get; set; }
+
+ [JsonPropertyName("dailyCostUsd")]
+ public decimal? DailyCostUsd { get; set; }
+
+ [JsonPropertyName("dailyTokens")]
+ public long? DailyTokens { get; set; }
+
+ [JsonPropertyName("whenExhausted")]
+ public string? WhenExhausted { get; set; }
+}
diff --git a/src/ClawdDotNet.Core/Engine/AgentEngine.cs b/src/ClawdDotNet.Core/Engine/AgentEngine.cs
index 496f81a..fb378cf 100644
--- a/src/ClawdDotNet.Core/Engine/AgentEngine.cs
+++ b/src/ClawdDotNet.Core/Engine/AgentEngine.cs
@@ -44,6 +44,21 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
/// #711: Who is working right now and who reached whom — for the team overview.
public Activity.AgentActivity Activity { get; } = new();
+ ///
+ /// #683: The organisation tree, built fresh from the configuration on each call. Without
+ /// it, the instance has no structure and every agent reaches every other, as before.
+ ///
+ public Func? OrganisationProvider { get; set; }
+
+ private Organisation.OrgChart CurrentOrganisation()
+ => OrganisationProvider?.Invoke() ?? new Organisation.OrgChart([], _agentConfigProvider?.Invoke() ?? []);
+
+ /// The agent's system prompt with its organisation section (#683).
+ private string SystemPromptFor(AgentConfig agentConfig, bool hasTools)
+ => Organisation.OrgBriefing.WithSection(
+ UntrustedContent.MitRegel(agentConfig.FullSystemPrompt, hasTools),
+ Organisation.OrgBriefing.For(CurrentOrganisation(), agentConfig.AgentId, _clock.GetUtcNow().UtcDateTime));
+
///
/// Wie viele Weitergaben eine Delegationskette höchstens haben darf (B8).
/// A→B ist die Tiefe 1, A→B→C die Tiefe 2.
@@ -226,7 +241,8 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
var messages = new List();
// #193: Die Regel zu gerahmten Werkzeugergebnissen gehoert zum festen Teil.
- var systemPrompt = UntrustedContent.MitRegel(agentConfig.FullSystemPrompt, tools.Count > 0);
+ // #683: dazu der Abschnitt zur Organisation.
+ var systemPrompt = SystemPromptFor(agentConfig, tools.Count > 0);
if (!string.IsNullOrWhiteSpace(systemPrompt))
messages.Add(ChatMessage.System(systemPrompt));
@@ -556,11 +572,22 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
messages = new List();
// #193 - siehe RunCoreAsync. Bestehende Verlaeufe behalten ihren
// Systemprompt; die Regel kommt mit dem naechsten neuen Kontext.
- var systemPrompt = UntrustedContent.MitRegel(agentConfig.FullSystemPrompt, tools.Count > 0);
+ var systemPrompt = SystemPromptFor(agentConfig, tools.Count > 0);
if (!string.IsNullOrWhiteSpace(systemPrompt))
messages.Add(ChatMessage.System(systemPrompt));
_chatContexts[agentConfig.AgentId] = messages;
}
+ else if (messages.Count > 0 && messages[0].Role == "system")
+ {
+ // #683: Nur der Organisationsabschnitt folgt der Struktur — nach einem
+ // Umzug weiss der Agent sonst im laufenden Chat nichts davon. Der Rest
+ // des bestehenden Systemprompts bleibt, wie er war (#193).
+ var section = Organisation.OrgBriefing.For(
+ CurrentOrganisation(), agentConfig.AgentId, _clock.GetUtcNow().UtcDateTime);
+ var refreshed = Organisation.OrgBriefing.WithSection(messages[0].Content ?? "", section);
+ if (refreshed != messages[0].Content)
+ messages[0] = ChatMessage.System(refreshed);
+ }
}
// Routing-Hinweis: Dem Agenten mitteilen, woher die Nachricht kommt
@@ -1052,19 +1079,41 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
$"Agent '{toAgentId}' wartet in dieser Kette bereits auf eine Antwort und " +
"kann keinen zweiten Auftrag annehmen, ohne sich selbst zu blockieren.";
- if (callerChain.Depth >= MaxDelegationDepth)
+ // #683: Die Grenze folgt der Tiefe des Baums; der eingestellte Wert bleibt Untergrenze.
+ var maxDepth = CurrentOrganisation().DelegationDepth(MaxDelegationDepth);
+ if (callerChain.Depth >= maxDepth)
return $"Delegationstiefe erschöpft: {callerChain} hat die Grenze von " +
- $"{MaxDelegationDepth} Weitergaben erreicht. Erledige den Rest selbst " +
+ $"{maxDepth} Weitergaben erreicht. Erledige den Rest selbst " +
"oder gib die Aufgabe an den Auftraggeber zurück.";
return null;
}
+ ///
+ /// #683: The address book, enforced — not merely described in the prompt. A refused
+ /// contact is an answer to the caller, naming the way that is open: its lead.
+ ///
+ private string? CheckAddressBook(string fromAgentId, string toAgentId)
+ {
+ var org = CurrentOrganisation();
+ if (org.CanAddress(fromAgentId, toAgentId))
+ return null;
+
+ var lead = org.EffectiveLeaderOf(fromAgentId);
+ return $"'{toAgentId}' is not in your address book: from your position in the organisation you " +
+ "cannot reach this agent directly. " +
+ (lead is not null
+ ? $"Hand the matter to your lead '{lead.AgentId}', who can pass it on."
+ : "Create a task for @human if it needs someone outside your reach.");
+ }
+
public IReadOnlyList ListAgents(string callerAgentId)
{
var configs = _agentConfigProvider?.Invoke() ?? [];
+ // #683: Only the agent's address book — the cut of the organisation.
+ var reachable = CurrentOrganisation().AddressBook(callerAgentId);
return configs
- .Where(a => a.AgentId != callerAgentId)
+ .Where(a => a.AgentId != callerAgentId && reachable.Contains(a.AgentId))
.Select(a => new AgentInfo(a.AgentId, a.DisplayName, a.Description, a.Model, IsRunning(a.AgentId)))
.ToList();
}
@@ -1097,6 +1146,9 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
if (targetConfig is null)
return new AgentMessageResult(false, null, $"Agent '{toAgentId}' not found.");
+ if (CheckAddressBook(fromAgentId, toAgentId) is { } outOfReach)
+ return new AgentMessageResult(false, null, outOfReach);
+
if (CheckDelegation(chain, fromAgentId, toAgentId) is { } refused)
return new AgentMessageResult(false, null, refused);
@@ -1149,6 +1201,9 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
// Auch der Spawn zählt: Er startet einen vollen Lauf und kann seinerseits
// delegieren. Ohne diese Prüfung wäre die Kette über spawn zu umgehen.
+ if (CheckAddressBook(fromAgentId, targetAgentId) is { } outOfReach)
+ return new AgentSpawnResult(false, targetAgentId, null, outOfReach);
+
if (CheckDelegation(chain, fromAgentId, targetAgentId) is { } refused)
return new AgentSpawnResult(false, targetAgentId, null, refused);
@@ -1606,7 +1661,10 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
this,
_memoryRepository,
_taskRepository,
- chain ?? DelegationChain.Root(agentConfig.AgentId));
+ chain ?? DelegationChain.Root(agentConfig.AgentId))
+ {
+ Organisation = CurrentOrganisation()
+ };
}
///
diff --git a/src/ClawdDotNet.Core/Organisation/OrgBriefing.cs b/src/ClawdDotNet.Core/Organisation/OrgBriefing.cs
new file mode 100644
index 0000000..33fe1cb
--- /dev/null
+++ b/src/ClawdDotNet.Core/Organisation/OrgBriefing.cs
@@ -0,0 +1,134 @@
+using System.Text;
+
+namespace ClawdDotNet.Core.Organisation;
+
+///
+/// #683: The "Organisation" section of an agent's system prompt — its unit, its lead, whom it
+/// can reach, and what changed recently.
+///
+/// Why the code writes it and not the agent's own texts. The concept demands
+/// that agents are told when they move, and warns: an agent that has to infer its lead from
+/// a prompt escalates to the wrong place. A section generated from the tree is always true —
+/// a move changes it by itself, for the agent that moved and for both leads.
+///
+/// Without units there is no section, so instances without a structure keep
+/// exactly the prompt they had — and the prompt cache that goes with it.
+///
+public static class OrgBriefing
+{
+ /// Starts the section; also how an existing chat context finds it to replace it.
+ public const string Heading = "# Organisation";
+
+ /// How long a move or a new colleague is pointed out.
+ public static readonly TimeSpan RecentChange = TimeSpan.FromDays(7);
+
+ private const int MaxDescription = 120;
+
+ public static string? For(OrgChart org, string agentId, DateTime nowUtc)
+ {
+ if (!org.HasUnits || org.Agent(agentId) is not { } agent)
+ return null;
+
+ var sb = new StringBuilder();
+ sb.AppendLine(Heading);
+
+ var unit = org.UnitOf(agentId);
+ if (unit is null)
+ {
+ sb.AppendLine("You are on the top level of this instance, above all units.");
+ }
+ else
+ {
+ sb.Append($"You belong to the unit \"{unit.Name}\" ({org.PathOf(unit.Id)})");
+ if (agent.UnitSinceUtc is { } since)
+ {
+ sb.Append($", since {since:yyyy-MM-dd}");
+ if (nowUtc - since < RecentChange)
+ sb.Append(" — this is recent: your lead, your colleagues and possibly your tools changed");
+ }
+ sb.AppendLine(".");
+
+ if (!string.IsNullOrWhiteSpace(unit.Goal))
+ sb.AppendLine($"Goal of your unit: {unit.Goal.Trim()}");
+ }
+
+ var lead = org.EffectiveLeaderOf(agentId);
+ if (lead is not null)
+ sb.AppendLine($"Your lead: {Name(lead)}. Questions, problems and anything outside your unit go to your lead.");
+ else
+ sb.AppendLine("There is no lead agent above you. What you cannot decide goes to a human: create a task for @human.");
+
+ if (org.LedBy(agentId) is { } led)
+ {
+ sb.AppendLine($"You lead the unit \"{led.Name}\". Delegate within it, and hand what goes beyond it to your own lead.");
+ var subUnits = org.Children(led.Id);
+ if (subUnits.Count > 0)
+ {
+ sb.AppendLine("Its sub-units: " + string.Join("; ", subUnits.Select(u =>
+ org.LeaderOf(u.Id) is { } l ? $"{u.Name} (lead: {l.AgentId})" : $"{u.Name} (no lead — its members report to you)")) + ".");
+ }
+
+ var joined = org.MembersOf(led.Id)
+ .Where(m => m.AgentId != agentId && m.UnitSinceUtc is { } s && nowUtc - s < RecentChange)
+ .ToList();
+ if (joined.Count > 0)
+ sb.AppendLine("New in your unit: " + string.Join(", ", joined.Select(m => $"{m.AgentId} since {m.UnitSinceUtc:yyyy-MM-dd}")) + ".");
+
+ // The lead that was left is told as well as the one that was joined.
+ var left = org.Agents
+ .Where(a => string.Equals(a.PreviousUnitId, led.Id, StringComparison.OrdinalIgnoreCase)
+ && a.UnitSinceUtc is { } s && nowUtc - s < RecentChange)
+ .ToList();
+ if (left.Count > 0)
+ sb.AppendLine("Left your unit recently: " + string.Join(", ", left.Select(a =>
+ $"{a.AgentId} (now in {(org.UnitOf(a.AgentId) is { } u ? u.Name : "the top level")})")) +
+ ". Hand over what is still open with them.");
+ }
+
+ var contacts = org.AddressBook(agentId)
+ .Where(id => lead is null || !string.Equals(id, lead.AgentId, StringComparison.OrdinalIgnoreCase))
+ .Select(id => org.Agent(id)!)
+ .OrderBy(a => a.AgentId, StringComparer.OrdinalIgnoreCase)
+ .ToList();
+
+ if (contacts.Count > 0)
+ {
+ sb.AppendLine("You can also reach:");
+ foreach (var contact in contacts)
+ sb.AppendLine($"- {Name(contact)}{Describe(contact.Description)}");
+ }
+
+ sb.Append("Agents not named here cannot be reached from your position — go through your lead.");
+ return sb.ToString();
+ }
+
+ ///
+ /// Replaces the organisation section of a system prompt, appends it, or removes it —
+ /// leaving everything before it untouched. Existing chat contexts keep their system
+ /// prompt otherwise (#193); only this section follows the structure.
+ ///
+ public static string WithSection(string systemPrompt, string? section)
+ {
+ var start = systemPrompt.IndexOf("\n\n" + Heading + "\n", StringComparison.Ordinal);
+ var basePrompt = start >= 0 ? systemPrompt[..start] : systemPrompt;
+ if (start < 0 && systemPrompt.StartsWith(Heading + "\n", StringComparison.Ordinal))
+ basePrompt = "";
+
+ if (string.IsNullOrWhiteSpace(section))
+ return basePrompt;
+ return basePrompt.Length == 0 ? section : basePrompt + "\n\n" + section;
+ }
+
+ private static string Name(Config.AgentConfig agent)
+ => string.IsNullOrWhiteSpace(agent.DisplayName) || agent.DisplayName == agent.AgentId
+ ? agent.AgentId
+ : $"{agent.DisplayName} ({agent.AgentId})";
+
+ private static string Describe(string? description)
+ {
+ if (string.IsNullOrWhiteSpace(description))
+ return "";
+ var line = description.ReplaceLineEndings(" ").Trim();
+ return " — " + (line.Length > MaxDescription ? line[..MaxDescription] + "…" : line);
+ }
+}
diff --git a/src/ClawdDotNet.Core/Organisation/OrgChart.cs b/src/ClawdDotNet.Core/Organisation/OrgChart.cs
new file mode 100644
index 0000000..afc3060
--- /dev/null
+++ b/src/ClawdDotNet.Core/Organisation/OrgChart.cs
@@ -0,0 +1,324 @@
+using ClawdDotNet.Core.Config;
+
+namespace ClawdDotNet.Core.Organisation;
+
+///
+/// #683: The organisation tree of an instance — units, members, leads — and everything that
+/// follows from it: who an agent can reach, who its lead is, how deep delegation may go,
+/// which tools a unit's members may have at most.
+///
+/// One rule holds it together: an agent knows only its unit and its lead.
+/// The cut of the address book is the organisation (Organisation-Konzept, section 3). It
+/// is computed from the tree, never configured, so it cannot be widened by accident —
+/// whoever wants an agent to reach further moves it in the tree, visibly.
+///
+/// Pure. Built from the configuration, no I/O, no state. Every question is
+/// answered from the snapshot it was built from, so the engine, the administration and
+/// the tests all see the same organisation.
+///
+/// Without units nothing changes: every agent reaches every other, as before.
+///
+public sealed class OrgChart
+{
+ private readonly Dictionary _units;
+ private readonly Dictionary _agents;
+ private readonly Dictionary> _children;
+ private readonly Dictionary> _members;
+
+ /// The key under which top-level units and agents without a unit are grouped.
+ private const string Root = "";
+
+ public OrgChart(IEnumerable units, IEnumerable agents)
+ {
+ _units = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ foreach (var unit in units)
+ _units.TryAdd(unit.Id, unit);
+
+ _agents = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ foreach (var agent in agents)
+ _agents.TryAdd(agent.AgentId, agent);
+
+ _children = new Dictionary>(StringComparer.OrdinalIgnoreCase);
+ foreach (var unit in _units.Values)
+ {
+ var parent = KnownParent(unit) ?? Root;
+ if (!_children.TryGetValue(parent, out var list))
+ _children[parent] = list = [];
+ list.Add(unit);
+ }
+
+ _members = new Dictionary>(StringComparer.OrdinalIgnoreCase);
+ foreach (var agent in _agents.Values)
+ {
+ var key = KnownUnit(agent) ?? Root;
+ if (!_members.TryGetValue(key, out var list))
+ _members[key] = list = [];
+ list.Add(agent);
+ }
+ }
+
+ public static OrgChart From(InstanceConfig instance) => new(instance.Units, instance.Agents);
+
+ /// Whether the instance has any structure at all.
+ public bool HasUnits => _units.Count > 0;
+
+ public IReadOnlyCollection Units => _units.Values;
+
+ public IReadOnlyCollection Agents => _agents.Values;
+
+ public UnitConfig? Unit(string? unitId)
+ => unitId is not null && _units.TryGetValue(unitId, out var unit) ? unit : null;
+
+ public AgentConfig? Agent(string agentId) => _agents.GetValueOrDefault(agentId);
+
+ /// The unit an agent belongs to; null for the top level (or an unknown unit).
+ public UnitConfig? UnitOf(string agentId)
+ => _agents.TryGetValue(agentId, out var agent) ? Unit(KnownUnit(agent)) : null;
+
+ /// Direct sub-units; null asks for the top-level units.
+ public IReadOnlyList Children(string? unitId)
+ => _children.TryGetValue(unitId ?? Root, out var list) ? list : [];
+
+ /// Direct members; null asks for the agents on the top level.
+ public IReadOnlyList MembersOf(string? unitId)
+ => _members.TryGetValue(unitId ?? Root, out var list) ? list : [];
+
+ /// The units above, nearest first — not including the unit itself.
+ public IReadOnlyList Ancestors(string unitId)
+ {
+ var result = new List();
+ var seen = new HashSet(StringComparer.OrdinalIgnoreCase) { unitId };
+ var current = Unit(unitId);
+ while (current is not null && KnownParent(current) is { } parentId && seen.Add(parentId))
+ {
+ current = _units[parentId];
+ result.Add(current);
+ }
+ return result;
+ }
+
+ /// "Marketing / Marketplaces / Amazon".
+ public string PathOf(string unitId)
+ {
+ if (Unit(unitId) is not { } unit)
+ return "";
+ var names = Ancestors(unitId).Select(u => u.Name).Reverse().Append(unit.Name);
+ return string.Join(" / ", names);
+ }
+
+ /// Depth of a unit: a top-level unit is 1.
+ public int Depth(string unitId) => Unit(unitId) is null ? 0 : Ancestors(unitId).Count + 1;
+
+ /// The deepest unit's depth; 0 without units.
+ public int TreeDepth => _units.Keys.Select(Depth).DefaultIfEmpty(0).Max();
+
+ ///
+ /// How many hand-overs a delegation chain may have: enough to go from the top to the
+ /// bottom of the tree and one step further, and never less than the configured floor.
+ /// Before #683 this was a fixed 3 — lead, department, team, agent already used it up.
+ ///
+ public int DelegationDepth(int configuredFloor) => Math.Max(configuredFloor, TreeDepth + 1);
+
+ /// The lead of a unit, if it has one that is actually a member.
+ public AgentConfig? LeaderOf(string? unitId)
+ {
+ if (Unit(unitId) is not { LeaderAgentId: { Length: > 0 } leaderId } unit)
+ return null;
+ return _agents.TryGetValue(leaderId, out var leader)
+ && string.Equals(KnownUnit(leader), unit.Id, StringComparison.OrdinalIgnoreCase)
+ ? leader
+ : null;
+ }
+
+ /// The unit this agent leads, if any.
+ public UnitConfig? LedBy(string agentId)
+ {
+ var unit = UnitOf(agentId);
+ return unit is not null && LeaderOf(unit.Id) is { } leader
+ && string.Equals(leader.AgentId, agentId, StringComparison.OrdinalIgnoreCase)
+ ? unit
+ : null;
+ }
+
+ ///
+ /// Who the agent answers to: the lead of its unit — or, if it leads that unit itself or
+ /// the unit has no lead, the nearest lead above. null means: the human.
+ ///
+ public AgentConfig? EffectiveLeaderOf(string agentId)
+ {
+ var unit = UnitOf(agentId);
+ if (unit is null)
+ return null;
+
+ foreach (var candidate in new[] { unit }.Concat(Ancestors(unit.Id)))
+ {
+ if (LeaderOf(candidate.Id) is { } leader
+ && !string.Equals(leader.AgentId, agentId, StringComparison.OrdinalIgnoreCase))
+ return leader;
+ }
+ return null;
+ }
+
+ ///
+ /// Everyone the agent may address — the cut of the address book (section 3):
+ ///
+ /// the members of its own unit and its effective lead;
+ /// as a lead: the leads of its sub-units — and, where a sub-unit has no lead, that
+ /// sub-unit's members, because this lead is who they answer to — and the leads of its
+ /// sibling units;
+ /// on the top level: each other and the leads of the top-level units, both ways.
+ ///
+ /// Without units: everyone.
+ ///
+ public IReadOnlySet AddressBook(string agentId)
+ {
+ var result = new HashSet(StringComparer.OrdinalIgnoreCase);
+ if (!_agents.ContainsKey(agentId))
+ return result;
+
+ if (!HasUnits)
+ {
+ result.UnionWith(_agents.Keys);
+ result.Remove(agentId);
+ return result;
+ }
+
+ var unit = UnitOf(agentId);
+ foreach (var member in MembersOf(unit?.Id))
+ result.Add(member.AgentId);
+
+ if (EffectiveLeaderOf(agentId) is { } lead)
+ result.Add(lead.AgentId);
+
+ if (unit is null)
+ {
+ // Top level: the house management reaches the heads of the top-level units.
+ AddLeadsBelow(null, result);
+ }
+ else if (LedBy(agentId) is { } led)
+ {
+ AddLeadsBelow(led.Id, result);
+
+ foreach (var sibling in Children(KnownParent(led)))
+ {
+ if (!string.Equals(sibling.Id, led.Id, StringComparison.OrdinalIgnoreCase)
+ && LeaderOf(sibling.Id) is { } siblingLead)
+ result.Add(siblingLead.AgentId);
+ }
+
+ // A top-level lead reaches the top level — the same line, seen from below.
+ if (KnownParent(led) is null)
+ foreach (var member in MembersOf(null))
+ result.Add(member.AgentId);
+ }
+
+ result.Remove(agentId);
+ return result;
+ }
+
+ public bool CanAddress(string fromAgentId, string toAgentId)
+ => AddressBook(fromAgentId).Contains(toAgentId);
+
+ ///
+ /// The tools a member of the unit may have at most: the intersection of the ceilings
+ /// along the path to the top. null means no ceiling anywhere on the path.
+ ///
+ public IReadOnlySet? ToolCeiling(string? unitId)
+ {
+ if (Unit(unitId) is not { } unit)
+ return null;
+
+ HashSet? ceiling = null;
+ foreach (var node in new[] { unit }.Concat(Ancestors(unit.Id)))
+ {
+ if (node.ToolCeiling is null)
+ continue;
+ if (ceiling is null)
+ ceiling = new HashSet(node.ToolCeiling, StringComparer.OrdinalIgnoreCase);
+ else
+ ceiling.IntersectWith(node.ToolCeiling);
+ }
+ return ceiling;
+ }
+
+ ///
+ /// What is wrong with the structure — empty when it is sound. Checked when the
+ /// administration writes and when an instance starts; the tree is still usable with
+ /// problems (unknown references count as top level), but they are named.
+ ///
+ public IReadOnlyList Problems()
+ {
+ var problems = new List();
+
+ foreach (var unit in _units.Values)
+ {
+ if (string.IsNullOrWhiteSpace(unit.Id))
+ problems.Add("A unit has no id.");
+ if (unit.ParentId is { Length: > 0 } parentId && !_units.ContainsKey(parentId))
+ problems.Add($"Unit '{unit.Id}' names the parent '{parentId}', which does not exist.");
+ if (IsInCycle(unit.Id))
+ problems.Add($"Unit '{unit.Id}' is part of a cycle — a unit cannot contain itself.");
+ if (unit.LeaderAgentId is { Length: > 0 } leaderId && LeaderOf(unit.Id) is null)
+ problems.Add(_agents.ContainsKey(leaderId)
+ ? $"Unit '{unit.Id}' names '{leaderId}' as lead, but that agent is not a member of it."
+ : $"Unit '{unit.Id}' names the lead '{leaderId}', which does not exist.");
+ }
+
+ foreach (var agent in _agents.Values)
+ {
+ if (agent.UnitId is { Length: > 0 } unitId && !_units.ContainsKey(unitId))
+ problems.Add($"Agent '{agent.AgentId}' belongs to the unit '{unitId}', which does not exist.");
+
+ if (ToolCeiling(KnownUnit(agent)) is { } ceiling)
+ {
+ var beyond = agent.Tools.Keys.Where(t => !ceiling.Contains(t)).ToList();
+ if (beyond.Count > 0)
+ problems.Add($"Agent '{agent.AgentId}' has tools beyond its unit's ceiling: {string.Join(", ", beyond)}.");
+ }
+ }
+
+ return problems;
+ }
+
+ // ─── helpers ───
+
+ private void AddLeadsBelow(string? unitId, HashSet into)
+ {
+ foreach (var child in Children(unitId))
+ {
+ if (LeaderOf(child.Id) is { } lead)
+ {
+ into.Add(lead.AgentId);
+ continue;
+ }
+
+ // No lead there: its members answer to the lead above — so this one sees them,
+ // and looks further down for the next leads.
+ foreach (var member in MembersOf(child.Id))
+ into.Add(member.AgentId);
+ AddLeadsBelow(child.Id, into);
+ }
+ }
+
+ /// The parent id if it names an existing unit and does not close a cycle.
+ private string? KnownParent(UnitConfig unit)
+ => unit.ParentId is { Length: > 0 } parentId && _units.ContainsKey(parentId) && !IsInCycle(unit.Id)
+ ? _units[parentId].Id
+ : null;
+
+ private string? KnownUnit(AgentConfig agent)
+ => agent.UnitId is { Length: > 0 } unitId && _units.TryGetValue(unitId, out var unit) ? unit.Id : null;
+
+ private bool IsInCycle(string unitId)
+ {
+ var seen = new HashSet(StringComparer.OrdinalIgnoreCase);
+ var current = unitId;
+ while (current is not null && _units.TryGetValue(current, out var unit))
+ {
+ if (!seen.Add(current))
+ return true;
+ current = unit.ParentId is { Length: > 0 } p ? p : null;
+ }
+ return false;
+ }
+}
diff --git a/src/ClawdDotNet.Core/Tools/AgentToolContext.cs b/src/ClawdDotNet.Core/Tools/AgentToolContext.cs
index a0d53e4..3198e27 100644
--- a/src/ClawdDotNet.Core/Tools/AgentToolContext.cs
+++ b/src/ClawdDotNet.Core/Tools/AgentToolContext.cs
@@ -34,4 +34,11 @@ public sealed record AgentToolContext(
/// Ohne Freigabestelle bleibt es false, und das Werkzeug prueft selbst.
///
public bool FreigabeGeprueft { get; init; }
+
+ ///
+ /// #683: The organisation at the time of the call. Tools that let an agent address
+ /// another — a task assignment, for instance — check the address book here, so it cannot
+ /// be walked around. null in tests and outside the engine: no structure, no limit.
+ ///
+ public Organisation.OrgChart? Organisation { get; init; }
}
diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/app.css b/src/ClawdDotNet.Manager.Web/wwwroot/app.css
index 10c146d..2137930 100644
--- a/src/ClawdDotNet.Manager.Web/wwwroot/app.css
+++ b/src/ClawdDotNet.Manager.Web/wwwroot/app.css
@@ -1795,3 +1795,116 @@ td.klein {
.team-flaeche { flex-direction: column; }
.team-seitenleiste { width: auto; }
}
+
+/* ─── Organisation (#683) ─── */
+
+.org-ansicht {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+.org-raster {
+ display: grid;
+ grid-template-columns: minmax(260px, 360px) 1fr;
+ gap: 16px;
+ align-items: start;
+}
+
+.org-baum {
+ background: var(--surface);
+ border: 1px solid var(--divider);
+ padding: 12px;
+}
+
+.org-baum-kopf {
+ justify-content: space-between;
+ margin-bottom: 8px;
+}
+
+.org-knoten-liste {
+ display: flex;
+ flex-direction: column;
+}
+
+.org-ebene-titel {
+ padding: 6px 12px;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+}
+
+button.org-knoten {
+ width: 100%;
+ justify-content: space-between;
+ border: none;
+ border-left: 3px solid transparent;
+ background: transparent;
+ padding-top: 5px;
+ padding-bottom: 5px;
+ text-align: left;
+}
+
+button.org-knoten.aktiv {
+ border-left-color: var(--accent);
+ background: var(--surface-hover);
+}
+
+button.org-einheit .org-name {
+ font-weight: 600;
+}
+
+.org-detail {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+.org-detail .karte {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.org-grund input {
+ width: 100%;
+}
+
+.org-werkzeuge {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
+ gap: 4px 12px;
+}
+
+.org-werkzeug {
+ font-size: 13px;
+}
+
+.org-einstellung {
+ flex-wrap: wrap;
+}
+
+.org-liste {
+ list-style: none;
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+
+tr.org-ausreisser td {
+ background: rgba(181, 130, 42, 0.08);
+}
+
+.org-probleme {
+ border: 1px solid var(--gefahr);
+ border-left-width: 4px;
+ padding: 10px 14px;
+ font-size: 13px;
+}
+
+.org-probleme ul {
+ margin: 6px 0 0 18px;
+}
+
+@media (max-width: 900px) {
+ .org-raster { grid-template-columns: 1fr; }
+}
diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/app.js b/src/ClawdDotNet.Manager.Web/wwwroot/app.js
index efe8187..cc0fcc4 100644
--- a/src/ClawdDotNet.Manager.Web/wwwroot/app.js
+++ b/src/ClawdDotNet.Manager.Web/wwwroot/app.js
@@ -36,6 +36,7 @@ import { renderUsageTimeline } from "./views/usage-timeline.js";
import { renderRunDetail } from "./views/run-detail.js";
import { renderPromptHistory } from "./views/prompt-history.js";
import { renderTeam, leaveTeam } from "./views/team.js";
+import { renderOrganisation } from "./views/organisation.js";
// ─── 1. Routen registrieren ───
@@ -53,6 +54,7 @@ registriereRoute("/agenten", () => { verlasseAnsicht(); return rendereAgenten();
registriereRoute("/agenten/:agentId", (p) => { verlasseAnsicht(); return rendereAgenten(p); });
registriereRoute("/aufgaben", () => { verlasseAnsicht(); return rendereAufgaben(); });
registriereRoute("/team", () => { verlasseAnsicht(); return renderTeam(); });
+registriereRoute("/organisation", () => { verlasseAnsicht(); return renderOrganisation(); });
registriereRoute("/dienste", () => { verlasseAnsicht(); return rendereDienste(); });
registriereRoute("/staging", () => { verlasseAnsicht(); return rendereStaging(); });
registriereRoute("/verbrauch", () => { verlasseAnsicht(); return rendereVerbrauch(); });
diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/i18n/de.json b/src/ClawdDotNet.Manager.Web/wwwroot/i18n/de.json
index 30a58ba..15194c8 100644
--- a/src/ClawdDotNet.Manager.Web/wwwroot/i18n/de.json
+++ b/src/ClawdDotNet.Manager.Web/wwwroot/i18n/de.json
@@ -68,6 +68,65 @@
"nav.agents": "Agenten",
"nav.tasks": "Aufgaben",
"nav.team": "Teamübersicht",
+ "common.saved": "Gespeichert.",
+ "common.create": "Anlegen",
+ "nav.organisation": "Organisation",
+ "org.title": "Organisation",
+ "org.subtitle": "Einheiten, Leitungen und Mitglieder. Jeder Agent erreicht nur seine Einheit und seine Leitung — die Struktur ist das Adressbuch.",
+ "org.problems": "Probleme in der Struktur",
+ "org.structure": "Struktur",
+ "org.newUnit": "Neue Einheit",
+ "org.newSubUnit": "Neue Untereinheit",
+ "org.topLevel": "Oberste Ebene",
+ "org.leadShort": "Leitung",
+ "org.unitId": "Kennung (Buchstaben, Ziffern, - und _)",
+ "org.unitName": "Name",
+ "org.parent": "Übergeordnete Einheit",
+ "org.goal": "Ziel der Einheit",
+ "org.reason": "Begründung der Änderung (geht ins Audit)",
+ "org.reasonPlaceholder": "z. B. Marktplätze bekommen ein eigenes Team",
+ "org.selectHint": "Links eine Einheit oder einen Agenten wählen.",
+ "org.lead": "Leitung",
+ "org.noLead": "— keine Leitung (die Leitung darüber übernimmt) —",
+ "org.leadHint": "Die Leitung muss Mitglied der Einheit sein.",
+ "org.position": "Platz im Baum",
+ "org.move": "Verschieben",
+ "org.moveUnitHint": "Die Mitglieder bleiben in der Einheit; es ändert sich, was darüber liegt. Nicht möglich, solange ein betroffener Agent läuft.",
+ "org.ceiling": "Rechte-Obergrenze",
+ "org.ceilingHint": "Was die Einheit nicht darf, kann ein Mitglied nicht bekommen. Senken entzieht den Mitgliedern Werkzeuge — nach einer Bestätigung.",
+ "org.ceilingLimit": "Werkzeuge dieser Einheit begrenzen",
+ "org.ceilingInherited": "Von oben begrenzt auf: {tools}",
+ "org.settings": "Sammelwerte",
+ "org.settingsHint": "Werden nach einer Vorschau in die Agenten geschrieben. Die Einheit merkt sich den Wert nur als Vorgabe für neue Mitglieder — zur Laufzeit übertrumpft nichts einen Agenten.",
+ "org.setting.DailyCostUsd": "Tagesbudget (USD)",
+ "org.setting.DailyTokens": "Tagesbudget (Tokens)",
+ "org.setting.Model": "Modell",
+ "org.setting.WhenExhausted": "Wenn das Budget erschöpft ist",
+ "org.modePerAgent": "jeder Agent bekommt den Wert",
+ "org.modeDistribute": "Wert ist ein Gesamtbetrag, verteilt",
+ "org.preview": "Vorschau",
+ "org.apply": "Gewählte Werte schreiben",
+ "org.agent": "Agent",
+ "org.current": "Bisher",
+ "org.proposed": "Neu",
+ "org.outlier": "Ausreißer",
+ "org.outlierHint": "Ausreißer weichen stark von ihren Kollegen ab — oft ein Spezialist mit bewusst anderem Wert. Sie sind standardmäßig nicht gewählt.",
+ "org.rememberDefault": "Als Vorgabe für neue Mitglieder merken",
+ "org.settingInvalid": "Dieser Wert ist für diese Einstellung nicht gültig.",
+ "org.members": "Mitglieder",
+ "org.noMembers": "Noch keine Mitglieder.",
+ "org.deleteUnit": "Einheit löschen",
+ "org.unit": "Einheit",
+ "org.answersTo": "Berichtet an",
+ "org.human": "einen Menschen",
+ "org.since": "In dieser Einheit seit {time}",
+ "org.tools": "Werkzeuge",
+ "org.moveAgent": "Agent verschieben",
+ "org.moveAgentHint": "Adressbuch, Leitung und womöglich Werkzeuge ändern sich. Der Agent und beide Leitungen erfahren es in ihrem nächsten Lauf.",
+ "org.toolsLostTitle": "Werkzeuge würden entfernt",
+ "org.toolsLostIntro": "Der neue Platz hat eine Rechte-Obergrenze. Diese Agenten verlören Werkzeuge:",
+ "org.toolsLostConfirm": "Werkzeuge entfernen und übernehmen",
+ "org.runningRefusal": "Nicht, solange {agents} läuft — ein Agent darf nicht mitten im Lauf andere Rechte bekommen.",
"team.title": "Teamübersicht",
"team.subtitle": "Wer arbeitet, wer hat in den letzten Sekunden wen erreicht, und wo klemmt es. Ein Klick auf einen Agenten zeigt Einzelheiten.",
"team.noInstance": "Keine Instanz ausgewählt. Oben eine aktive Instanz wählen.",
diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/i18n/en.json b/src/ClawdDotNet.Manager.Web/wwwroot/i18n/en.json
index ee7f842..1e90287 100644
--- a/src/ClawdDotNet.Manager.Web/wwwroot/i18n/en.json
+++ b/src/ClawdDotNet.Manager.Web/wwwroot/i18n/en.json
@@ -68,6 +68,65 @@
"nav.agents": "Agents",
"nav.tasks": "Tasks",
"nav.team": "Team overview",
+ "common.saved": "Saved.",
+ "common.create": "Create",
+ "nav.organisation": "Organisation",
+ "org.title": "Organisation",
+ "org.subtitle": "Units, leads and members. Every agent only reaches its unit and its lead — the structure is the address book.",
+ "org.problems": "Problems in the structure",
+ "org.structure": "Structure",
+ "org.newUnit": "New unit",
+ "org.newSubUnit": "New sub-unit",
+ "org.topLevel": "Top level",
+ "org.leadShort": "lead",
+ "org.unitId": "Id (letters, digits, - and _)",
+ "org.unitName": "Name",
+ "org.parent": "Parent unit",
+ "org.goal": "Goal of the unit",
+ "org.reason": "Reason for the change (goes into the audit log)",
+ "org.reasonPlaceholder": "e.g. marketplaces get their own team",
+ "org.selectHint": "Select a unit or an agent on the left.",
+ "org.lead": "Lead",
+ "org.noLead": "— no lead (the lead above takes over) —",
+ "org.leadHint": "The lead must be a member of the unit.",
+ "org.position": "Position in the tree",
+ "org.move": "Move",
+ "org.moveUnitHint": "The members stay in the unit; what changes is what lies above it. Not possible while an affected agent is running.",
+ "org.ceiling": "Tool ceiling",
+ "org.ceilingHint": "What the unit may not have, a member cannot be granted. Lowering it removes tools from the members — after a confirmation.",
+ "org.ceilingLimit": "Limit the tools of this unit",
+ "org.ceilingInherited": "Limited from above to: {tools}",
+ "org.settings": "Unit-wide values",
+ "org.settingsHint": "Written into the agents after a preview. The unit keeps the value only as a default for new members — nothing overrides an agent at run time.",
+ "org.setting.DailyCostUsd": "Daily budget (USD)",
+ "org.setting.DailyTokens": "Daily budget (tokens)",
+ "org.setting.Model": "Model",
+ "org.setting.WhenExhausted": "When the budget is exhausted",
+ "org.modePerAgent": "each agent gets the value",
+ "org.modeDistribute": "value is a total, distributed",
+ "org.preview": "Preview",
+ "org.apply": "Write the selected values",
+ "org.agent": "Agent",
+ "org.current": "Current",
+ "org.proposed": "New",
+ "org.outlier": "outlier",
+ "org.outlierHint": "Outliers differ strongly from their colleagues — often a specialist with a deliberately different value. They are not selected by default.",
+ "org.rememberDefault": "Remember as default for new members",
+ "org.settingInvalid": "That value is not valid for this setting.",
+ "org.members": "Members",
+ "org.noMembers": "No members yet.",
+ "org.deleteUnit": "Delete unit",
+ "org.unit": "Unit",
+ "org.answersTo": "Answers to",
+ "org.human": "a human",
+ "org.since": "In this unit since {time}",
+ "org.tools": "Tools",
+ "org.moveAgent": "Move agent",
+ "org.moveAgentHint": "Its address book, its lead and possibly its tools change. The agent and both leads are told in their next run.",
+ "org.toolsLostTitle": "Tools would be removed",
+ "org.toolsLostIntro": "The new position has a tool ceiling. These agents would lose tools:",
+ "org.toolsLostConfirm": "Remove the tools and apply",
+ "org.runningRefusal": "Not while {agents} is running — an agent must not get different rights in the middle of a run.",
"team.title": "Team overview",
"team.subtitle": "Who is working, who reached whom in the last seconds, and where something is stuck. Click an agent for details.",
"team.noInstance": "No instance selected. Choose an active instance in the header.",
diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/index.html b/src/ClawdDotNet.Manager.Web/wwwroot/index.html
index 34b1ee7..8526081 100644
--- a/src/ClawdDotNet.Manager.Web/wwwroot/index.html
+++ b/src/ClawdDotNet.Manager.Web/wwwroot/index.html
@@ -61,6 +61,12 @@
Team overview
+
+
+ Organisation
+