diff --git a/src/ClawdDotNet.App/AppHost.cs b/src/ClawdDotNet.App/AppHost.cs
index 1139ee5..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;
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 39b2039..9c2d06a 100644
--- a/src/ClawdDotNet.Core/Config/AgentConfig.cs
+++ b/src/ClawdDotNet.Core/Config/AgentConfig.cs
@@ -21,6 +21,13 @@ public sealed class AgentConfig
[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/Organisation/OrgBriefing.cs b/src/ClawdDotNet.Core/Organisation/OrgBriefing.cs
index 5eda4f2..33fe1cb 100644
--- a/src/ClawdDotNet.Core/Organisation/OrgBriefing.cs
+++ b/src/ClawdDotNet.Core/Organisation/OrgBriefing.cs
@@ -73,6 +73,16 @@ public static class OrgBriefing
.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)
diff --git a/src/ClawdDotNet.Core/Organisation/OrgChart.cs b/src/ClawdDotNet.Core/Organisation/OrgChart.cs
index b11693e..afc3060 100644
--- a/src/ClawdDotNet.Core/Organisation/OrgChart.cs
+++ b/src/ClawdDotNet.Core/Organisation/OrgChart.cs
@@ -64,6 +64,8 @@ public sealed class OrgChart
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;
diff --git a/tests/ClawdDotNet.App.Tests/Services/OrgAdministrationTests.cs b/tests/ClawdDotNet.App.Tests/Services/OrgAdministrationTests.cs
new file mode 100644
index 0000000..2b4eea4
--- /dev/null
+++ b/tests/ClawdDotNet.App.Tests/Services/OrgAdministrationTests.cs
@@ -0,0 +1,252 @@
+using ClawdDotNet.App.Services;
+using ClawdDotNet.Core.Config;
+using Shouldly;
+
+namespace ClawdDotNet.App.Tests.Services;
+
+///
+/// #683: Units in the administration — create, lead, move, limit, write unit-wide values.
+///
+public sealed class OrgAdministrationTests : IDisposable
+{
+ private readonly TestHost _test;
+
+ public OrgAdministrationTests()
+ {
+ _test = new TestHost(
+ Agent("marketing-lead", "WebFetch", "AgentComm", "Taskboard"),
+ Agent("ads", "WebFetch", "Taskboard"),
+ Agent("listing", "WebFetch", "Taskboard"),
+ Agent("specialist", "WebFetch", "Taskboard", "Database"),
+ Agent("bookkeeper", "Database", "Taskboard"));
+ }
+
+ public void Dispose() => _test.Dispose();
+
+ private static AgentConfig Agent(string id, params string[] tools)
+ => new() { AgentId = id, DisplayName = id, Tools = tools.ToDictionary(t => t, _ => new Dictionary()) };
+
+ private OrgAdministration Org => _test.Host.Organisation;
+
+ private AgentConfig Find(string id) => _test.Host.AgentAdmin.Find(id)!;
+
+ private void BuildMarketing()
+ {
+ Org.CreateUnit("marketing", "Marketing", null, "Sell more.").Ok.ShouldBeTrue();
+ Org.CreateUnit("amazon", "Amazon", "marketing").Ok.ShouldBeTrue();
+ Org.MoveAgent("marketing-lead", "marketing", "setup").Ok.ShouldBeTrue();
+ Org.SetLeader("marketing", "marketing-lead").Ok.ShouldBeTrue();
+ foreach (var id in new[] { "ads", "listing", "specialist" })
+ Org.MoveAgent(id, "amazon", "setup").Ok.ShouldBeTrue();
+ }
+
+ // ═══════════════════ units ═══════════════════
+
+ [Fact]
+ public void A_unit_needs_a_valid_id_a_name_and_an_existing_parent()
+ {
+ Org.CreateUnit("bad id", "X", null).Ok.ShouldBeFalse();
+ Org.CreateUnit("x", " ", null).Ok.ShouldBeFalse();
+ Org.CreateUnit("x", "X", "nowhere").Ok.ShouldBeFalse();
+ Org.CreateUnit("x", "X", null).Ok.ShouldBeTrue();
+ Org.CreateUnit("x", "Again", null).Ok.ShouldBeFalse("ids are unique");
+ }
+
+ [Fact]
+ public void Only_a_member_can_lead_a_unit()
+ {
+ Org.CreateUnit("marketing", "Marketing", null);
+
+ Org.SetLeader("marketing", "ads").Ok.ShouldBeFalse();
+ Org.MoveAgent("ads", "marketing", "join");
+ Org.SetLeader("marketing", "ads").Ok.ShouldBeTrue();
+ }
+
+ [Fact]
+ public void Only_an_empty_unit_can_be_deleted()
+ {
+ BuildMarketing();
+
+ Org.DeleteUnit("amazon").Ok.ShouldBeFalse("it has members");
+ foreach (var id in new[] { "ads", "listing", "specialist" })
+ Org.MoveAgent(id, null, "leave");
+ Org.DeleteUnit("amazon").Ok.ShouldBeTrue();
+ }
+
+ // ═══════════════════ moves ═══════════════════
+
+ [Fact]
+ public void A_move_records_since_when_and_where_from()
+ {
+ BuildMarketing();
+
+ Org.MoveAgent("ads", "marketing", "promoted").Ok.ShouldBeTrue();
+
+ var ads = Find("ads");
+ ads.UnitId.ShouldBe("marketing");
+ ads.PreviousUnitId.ShouldBe("amazon");
+ ads.UnitSinceUtc.ShouldNotBeNull();
+ }
+
+ [Fact]
+ public void A_lead_that_moves_leaves_its_unit_without_lead()
+ {
+ BuildMarketing();
+
+ Org.MoveAgent("marketing-lead", "amazon", "reorg").Ok.ShouldBeTrue();
+
+ Org.Current().LeaderOf("marketing").ShouldBeNull();
+ }
+
+ [Fact]
+ public void A_unit_cannot_be_moved_under_itself()
+ {
+ BuildMarketing();
+
+ Org.MoveUnit("marketing", "amazon", "oops").Ok.ShouldBeFalse();
+ Org.MoveUnit("marketing", "marketing", "oops").Ok.ShouldBeFalse();
+ }
+
+ [Fact]
+ public void A_moved_unit_takes_its_members_along_and_tells_them()
+ {
+ BuildMarketing();
+ Org.CreateUnit("marketplaces", "Marketplaces", "marketing");
+ var before = Find("ads").UnitSinceUtc;
+
+ Org.MoveUnit("amazon", "marketplaces", "a new level in between").Ok.ShouldBeTrue();
+
+ Find("ads").UnitId.ShouldBe("amazon", "the unit is the same");
+ Org.Current().PathOf("amazon").ShouldBe("Marketing / Marketplaces / Amazon");
+ Find("ads").UnitSinceUtc.ShouldNotBe(before, "the position changed, so the briefing says so");
+ }
+
+ // ═══════════════════ tool ceiling ═══════════════════
+
+ [Fact]
+ public void A_move_under_a_lower_ceiling_is_refused_until_confirmed()
+ {
+ BuildMarketing();
+ Org.SetToolCeiling("amazon", ["WebFetch", "Taskboard"], "no databases in ads").Ok.ShouldBeFalse(
+ "the specialist has Database — that has to be confirmed via the preview first");
+
+ var preview = Org.PreviewToolCeiling("amazon", ["WebFetch", "Taskboard"]);
+ preview.ToolsLost.Keys.ShouldBe(["specialist"]);
+ preview.ToolsLost["specialist"].ShouldBe(["Database"]);
+ }
+
+ [Fact]
+ public void Lowering_a_ceiling_removes_the_tools_beyond_it_from_the_members()
+ {
+ BuildMarketing();
+ Org.MoveAgent("specialist", null, "out first").Ok.ShouldBeTrue();
+
+ Org.SetToolCeiling("amazon", ["WebFetch", "Taskboard"], "no databases in ads").Ok.ShouldBeTrue();
+
+ // And from now on it cannot be granted.
+ var refused = _test.Host.AgentAdmin.SetToolEnabled("ads", "Database", true);
+ refused.Ok.ShouldBeFalse();
+ refused.Message.ShouldContain("ceiling");
+ }
+
+ [Fact]
+ public void Moving_into_a_ceiling_needs_the_tools_beyond_it_dropped_explicitly()
+ {
+ Org.CreateUnit("amazon", "Amazon", null);
+ Org.SetToolCeiling("amazon", ["WebFetch", "Taskboard"], "setup").Ok.ShouldBeTrue();
+
+ var refused = Org.MoveAgent("specialist", "amazon", "join");
+ refused.Ok.ShouldBeFalse();
+ refused.Message.ShouldContain("Database");
+ Find("specialist").UnitId.ShouldBeNull("nothing happened");
+
+ Org.MoveAgent("specialist", "amazon", "join", dropExcessTools: true).Ok.ShouldBeTrue();
+ Find("specialist").Tools.Keys.ShouldNotContain("Database");
+ }
+
+ [Fact]
+ public void A_ceiling_higher_up_limits_the_units_below()
+ {
+ BuildMarketing();
+ Org.MoveAgent("specialist", null, "out first");
+ Org.SetToolCeiling("marketing", ["WebFetch", "Taskboard", "AgentComm"], "setup").Ok.ShouldBeTrue();
+
+ _test.Host.AgentAdmin.SetToolEnabled("ads", "Database", true).Ok.ShouldBeFalse(
+ "amazon has no ceiling of its own, but marketing above it has");
+ }
+
+ // ═══════════════════ unit-wide values ═══════════════════
+
+ [Fact]
+ public void A_unit_wide_budget_marks_the_specialist_as_an_outlier_and_leaves_it_unselected()
+ {
+ BuildMarketing();
+ Find("ads").Budget.DailyCostUsd = 5;
+ Find("listing").Budget.DailyCostUsd = 4;
+ Find("specialist").Budget.DailyCostUsd = 40; // deliberately large
+ Find("marketing-lead").Budget.DailyCostUsd = 6;
+
+ var preview = Org.PreviewSetting("marketing", UnitSetting.DailyCostUsd, "10");
+
+ preview.Count.ShouldBe(4, "the unit and its sub-units");
+ var specialist = preview.Single(c => c.AgentId == "specialist");
+ specialist.Outlier.ShouldBeTrue();
+ specialist.SelectedByDefault.ShouldBeFalse();
+ preview.Where(c => c.AgentId != "specialist").ShouldAllBe(c => c.SelectedByDefault && c.Proposed == "10.00");
+ }
+
+ [Fact]
+ public void A_distributed_total_keeps_the_proportions()
+ {
+ BuildMarketing();
+ Find("ads").Budget.DailyCostUsd = 10;
+ Find("listing").Budget.DailyCostUsd = 10;
+ Find("specialist").Budget.DailyCostUsd = 20;
+ Find("marketing-lead").Budget.DailyCostUsd = 0;
+
+ var preview = Org.PreviewSetting("amazon", UnitSetting.DailyCostUsd, "80", UnitSettingMode.Distribute);
+
+ preview.Single(c => c.AgentId == "specialist").Proposed.ShouldBe("40.00");
+ preview.Single(c => c.AgentId == "ads").Proposed.ShouldBe("20.00");
+ }
+
+ [Fact]
+ public void Applying_writes_only_the_chosen_agents_and_remembers_the_default()
+ {
+ BuildMarketing();
+ Find("specialist").Budget.DailyCostUsd = 40;
+
+ var result = Org.ApplySetting("amazon", UnitSetting.DailyCostUsd,
+ new Dictionary { ["ads"] = "10", ["listing"] = "10" }, "budget round", defaultValue: "10");
+
+ result.Ok.ShouldBeTrue(result.Message);
+ Find("ads").Budget.DailyCostUsd.ShouldBe(10);
+ Find("specialist").Budget.DailyCostUsd.ShouldBe(40, "not chosen, not touched");
+ Org.Current().Unit("amazon")!.Defaults.DailyCostUsd.ShouldBe(10);
+ }
+
+ [Fact]
+ public void An_agent_outside_the_unit_cannot_be_written_through_it()
+ {
+ BuildMarketing();
+
+ Org.ApplySetting("amazon", UnitSetting.DailyCostUsd,
+ new Dictionary { ["bookkeeper"] = "1" }, "sneaky").Ok.ShouldBeFalse();
+ }
+
+ [Fact]
+ public void A_new_agent_placed_in_a_unit_starts_with_its_defaults_but_a_moved_one_keeps_its_values()
+ {
+ BuildMarketing();
+ Org.ApplySetting("amazon", UnitSetting.DailyCostUsd, new Dictionary(), "defaults", defaultValue: "7");
+ Find("bookkeeper").Budget.DailyCostUsd = 30;
+
+ Org.MoveAgent("bookkeeper", "amazon", "moved").Ok.ShouldBeTrue();
+ Find("bookkeeper").Budget.DailyCostUsd.ShouldBe(30, "a move does not overwrite tuned values");
+
+ var created = _test.Host.AgentAdmin.Add(new NewAgent("newbie", "Newbie", "test/model", UnitId: "amazon"));
+ created.Ok.ShouldBeTrue(created.Message);
+ Find("newbie").Budget.DailyCostUsd.ShouldBe(7);
+ Find("newbie").UnitId.ShouldBe("amazon");
+ }
+}