diff --git a/docs/Organisation-Konzept.md b/docs/Organisation-Konzept.md
index e4e3ed4..09359b2 100644
--- a/docs/Organisation-Konzept.md
+++ b/docs/Organisation-Konzept.md
@@ -59,6 +59,21 @@ weitergebbare Datei. Dieselbe Idee wie bei den Basisagenten
hier ein ganzes Haus. Wer eine Vorlage „Onlinehandel" oder „Redaktion" schreibt, hat
damit keinen Code geschrieben.
+> **Umgesetzt (#691), Stufe 1.** Das Format ist JSON (`"format": "clawddotnet-org-template"`):
+> Einheiten mit allen Regeln (Werkzeugdecke, Vorgaben, Freigaben, Stichproben,
+> Abstimmungen), Doktrinen, Agenten mit Identity, Soul, Modell, Budget und
+> Werkzeugeinstellungen. Der Export **leert jedes Zugangsdatum**, auch verschachtelte und
+> Namensvarianten (`smtpPassword`, `providers.x.apiKey`), und benennt es in `credentials`;
+> eine Vorlage, die doch eines mitbringt, wird abgewiesen. Der Import zeigt erst eine
+> Vorschau — Probleme (Kreise, fehlende Eltern, Leitung ohne Mitgliedschaft, IDs oder
+> Namen, die die Instanz schon hat, Werkzeuge über der Decke), alle Texte in voller Länge
+> — und läuft nur nach ausdrücklicher Bestätigung, dass diese Texte zu Prompts werden.
+> Modell und Budgetfaktor sind beim Import überschreibbar. Jede Einheit und jeder Agent
+> trägt die Herkunft (`templateOrigin`: Name, Version, Fingerabdruck, Datei). Offen:
+> Aufgaben und benannte Arbeitsbereiche in der Vorlage; Nachziehen einer neuen Fassung
+> ohne angepasste Einheiten zu überschreiben (Stufe 3 wie #679); die Modellprüfung beim
+> Import, sobald es die Werkzeugproben (#681) gibt.
+
---
## 2 — Was heute bei 50 Agenten bricht
diff --git a/src/ClawdDotNet.App/Services/OrgAdministration.Templates.cs b/src/ClawdDotNet.App/Services/OrgAdministration.Templates.cs
new file mode 100644
index 0000000..7fbf87f
--- /dev/null
+++ b/src/ClawdDotNet.App/Services/OrgAdministration.Templates.cs
@@ -0,0 +1,186 @@
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json;
+using ClawdDotNet.Core.Config;
+using ClawdDotNet.Core.Organisation;
+
+namespace ClawdDotNet.App.Services;
+
+/// #691: What the human may change when importing a template — models and budgets fit one house, not every house.
+public sealed record TemplateOverrides(
+ string? Model = null,
+ IReadOnlyDictionary? Models = null,
+ decimal? BudgetFactor = null);
+
+public sealed record TemplatePreviewUnit(string Id, string Name, string? ParentId, string? LeaderAgentId, string Path);
+
+public sealed record TemplatePreviewAgent(
+ string AgentId, string DisplayName, string? UnitId, string Model, decimal DailyCostUsd, long DailyTokens,
+ IReadOnlyList Tools, string Identity, string Soul);
+
+/// #691: What an import would do — and what stands in its way. Nothing is written for it.
+public sealed record TemplatePreview(
+ string Name,
+ string Version,
+ string Fingerprint,
+ IReadOnlyList Problems,
+ IReadOnlyList Warnings,
+ IReadOnlyList Units,
+ IReadOnlyList Agents,
+ IReadOnlyList Credentials)
+{
+ public bool CanImport => Problems.Count == 0;
+}
+
+public sealed partial class OrgAdministration
+{
+ // ─── templates (#691) ───
+
+ /// The organisation of this instance as a template file — never with credentials.
+ public OrgTemplate ExportTemplate(string name, string description = "")
+ => OrgTemplates.Export(host.Instance, string.IsNullOrWhiteSpace(name) ? "Organisation" : name, description,
+ unitId => host.Doctrines?.Current(unitId)?.Text, DateTime.UtcNow);
+
+ ///
+ /// What importing would create, with the models and budgets after
+ /// the overrides — and the problems that stop it: a broken template, ids or names the
+ /// instance already has, tools beyond a unit's ceiling.
+ ///
+ public TemplatePreview PreviewTemplate(OrgTemplate template, TemplateOverrides? overrides = null)
+ {
+ overrides ??= new TemplateOverrides();
+ var problems = OrgTemplates.Problems(template).ToList();
+ var sound = problems.Count == 0;
+ var warnings = new List();
+
+ foreach (var unit in template.Units)
+ {
+ if (FindUnit(unit.Id) is not null)
+ problems.Add($"This instance already has a unit '{unit.Id}'.");
+ else if (Chart.Workspace(unit.Id) is not null)
+ problems.Add($"This instance already has a workspace '{unit.Id}' — the unit's own workspace would carry that id.");
+ else if (!WorkspaceLayout.IsValidId(unit.Id))
+ problems.Add($"'{unit.Id}' is reserved and cannot be a unit id.");
+ }
+ foreach (var agent in template.Agents)
+ {
+ if (host.AgentAdmin.Find(agent.AgentId) is not null)
+ problems.Add($"This instance already has an agent '{agent.AgentId}'.");
+ else if (host.Instance.Agents.Any(a => Same(a.DisplayName.Trim(), agent.DisplayName.Trim())))
+ problems.Add($"This instance already has an agent named '{agent.DisplayName}' — the name gives the folder.");
+ }
+ if (overrides.BudgetFactor is <= 0)
+ problems.Add("The budget factor must be above 0.");
+
+ // The ceilings of the template's own units apply to the template's own agents — walked
+ // only in a sound tree: a circle would never end.
+ var chart = new OrgChart(sound ? template.Units : [], template.Agents.Select(a => new AgentConfig { AgentId = a.AgentId, UnitId = a.UnitId }));
+ foreach (var agent in template.Agents)
+ {
+ if (sound && chart.ToolCeiling(agent.UnitId) is { } ceiling)
+ foreach (var tool in agent.Tools.Keys.Where(t => !ceiling.Contains(t)))
+ problems.Add($"Agent '{agent.AgentId}' has {tool}, beyond the tool ceiling of its unit.");
+ foreach (var tool in agent.Tools.Keys.Where(t => host.Tools.Get(t) is null))
+ warnings.Add($"{tool} (agent '{agent.AgentId}') is not loaded in this instance — the setting is kept, the tool does nothing.");
+ }
+ if (template.Credentials.Count > 0)
+ warnings.Add($"{template.Credentials.Count} credential(s) must be filled in after the import — the template brings none.");
+ warnings.Add("The Identity and Soul texts of this template become prompts. Read them: whoever wrote the template writes your agents' instructions.");
+
+ var units = template.Units
+ .Select(u => new TemplatePreviewUnit(u.Id, u.Name, u.ParentId, u.LeaderAgentId, sound ? chart.PathOf(u.Id) : u.Name))
+ .OrderBy(u => u.Path, StringComparer.OrdinalIgnoreCase)
+ .ToList();
+ var agents = template.Agents.Select(a =>
+ {
+ var budget = Scaled(a.Budget, overrides.BudgetFactor);
+ return new TemplatePreviewAgent(a.AgentId, a.DisplayName, a.UnitId, ModelFor(a, overrides),
+ budget.DailyCostUsd, budget.DailyTokens, a.Tools.Keys.Order(StringComparer.OrdinalIgnoreCase).ToList(), a.Identity, a.Soul);
+ }).ToList();
+
+ return new TemplatePreview(template.Name, template.Version, Fingerprint(template), problems, warnings, units, agents, template.Credentials);
+ }
+
+ ///
+ /// Imports the template: units with their rules and workspaces, agents with their texts,
+ /// tools, models and budgets, the doctrines. Only with — the
+ /// texts are someone else's instructions — and only if the preview has no problem. Every
+ /// unit and agent keeps the template as its origin; credentials stay empty.
+ ///
+ public async Task ImportTemplateAsync(OrgTemplate template, TemplateOverrides? overrides, bool confirmed, string source, string reason, CancellationToken ct)
+ {
+ overrides ??= new TemplateOverrides();
+ var preview = PreviewTemplate(template, overrides);
+ if (!preview.CanImport)
+ return AdminResult.Failure("The template cannot be imported: " + string.Join(" ", preview.Problems.Take(5)));
+ if (!confirmed)
+ return AdminResult.Failure("Confirm the import: the template's Identity and Soul texts become your agents' prompts.");
+
+ var origin = $"{template.Name} v{template.Version} ({preview.Fingerprint})" + (string.IsNullOrWhiteSpace(source) ? "" : $" from {source.Trim()}");
+
+ // Parents before children, so every unit finds the one it hangs under.
+ var chart = new OrgChart(template.Units, []);
+ foreach (var unit in template.Units.OrderBy(u => chart.Depth(u.Id)))
+ {
+ var copy = JsonSerializer.Deserialize(JsonSerializer.Serialize(unit))!;
+ copy.TemplateOrigin = origin;
+ host.Instance.Units.Add(copy);
+ host.Instance.Workspaces.Add(OwnWorkspace(copy.Id, copy.Name));
+ }
+
+ var created = new List();
+ foreach (var agent in template.Agents)
+ {
+ var model = ModelFor(agent, overrides);
+ var added = host.AgentAdmin.Add(new NewAgent(agent.AgentId, agent.DisplayName, model, agent.Description, agent.UnitId));
+ if (!added.Ok || host.AgentAdmin.Find(agent.AgentId) is not { } config)
+ return AdminResult.Failure($"Stopped at agent '{agent.AgentId}': {added.Message} Created so far: {string.Join(", ", created)}.");
+
+ // The template's values, not the unit's defaults — the template says what it means.
+ config.Model = model;
+ config.Budget = Scaled(agent.Budget, overrides.BudgetFactor);
+ config.Identity = agent.Identity;
+ config.Soul = agent.Soul;
+ config.TemplateOrigin = origin;
+ config.Tools = agent.Tools.ToDictionary(
+ t => t.Key,
+ t => JsonSerializer.Deserialize>(t.Value.ToJsonString()) ?? new Dictionary(),
+ StringComparer.OrdinalIgnoreCase);
+ host.AgentAdmin.PersistAgent(config);
+ created.Add(agent.AgentId);
+ }
+
+ if (host.Doctrines is { } doctrines)
+ foreach (var (unitId, text) in template.Doctrines)
+ if (!string.IsNullOrWhiteSpace(text))
+ await doctrines.SetAsync(unitId, text.Length > SqliteDoctrineStore.MaxLength ? text[..SqliteDoctrineStore.MaxLength] : text,
+ "template", null, $"Imported with {origin}", ct);
+
+ Save(null, "template_import", new
+ {
+ origin, units = template.Units.Select(u => u.Id), agents = created,
+ overrides.Model, overrides.BudgetFactor, credentials = template.Credentials.Count
+ }, reason);
+
+ return AdminResult.Success(
+ $"Imported '{template.Name}': {template.Units.Count} unit(s), {created.Count} agent(s)."
+ + (template.Credentials.Count == 0 ? "" : $" Fill in {template.Credentials.Count} credential(s) in the agents' tool settings before they run."));
+ }
+
+ private static string ModelFor(TemplateAgent agent, TemplateOverrides overrides)
+ => overrides.Models?.GetValueOrDefault(agent.AgentId) is { Length: > 0 } own ? own.Trim()
+ : !string.IsNullOrWhiteSpace(overrides.Model) ? overrides.Model.Trim()
+ : string.IsNullOrWhiteSpace(agent.Model) ? new AgentConfig().Model
+ : agent.Model;
+
+ private static AgentBudget Scaled(AgentBudget budget, decimal? factor) => new()
+ {
+ DailyCostUsd = factor is { } f ? Math.Round(budget.DailyCostUsd * f, 2) : budget.DailyCostUsd,
+ DailyTokens = factor is { } g ? (long)Math.Round(budget.DailyTokens * g) : budget.DailyTokens,
+ WhenExhausted = budget.WhenExhausted
+ };
+
+ /// A short fingerprint of the template's content — which version of it came in.
+ private static string Fingerprint(OrgTemplate template)
+ => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(template, OrgTemplates.Json))))[..12].ToLowerInvariant();
+}
diff --git a/src/ClawdDotNet.App/Services/OrgAdministration.cs b/src/ClawdDotNet.App/Services/OrgAdministration.cs
index 30da681..39b51fb 100644
--- a/src/ClawdDotNet.App/Services/OrgAdministration.cs
+++ b/src/ClawdDotNet.App/Services/OrgAdministration.cs
@@ -71,7 +71,7 @@ public sealed record StructurePreview(
/// 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)
+public sealed partial class OrgAdministration(AppHost host, ILogger logger)
{
/// A value this far from the median of the others is an outlier.
public const decimal OutlierFactor = 3m;
@@ -1004,7 +1004,7 @@ public sealed class OrgAdministration(AppHost host, ILogger logger)
{
Id = u.Id, Name = u.Name, ParentId = u.ParentId, LeaderAgentId = u.LeaderAgentId,
Goal = u.Goal, ToolCeiling = u.ToolCeiling?.ToList(), Defaults = u.Defaults, Approvals = u.Approvals,
- Inspection = u.Inspection, Votes = u.Votes
+ Inspection = u.Inspection, Votes = u.Votes, TemplateOrigin = u.TemplateOrigin
};
private static bool Same(string? a, string? b) => string.Equals(a, b, StringComparison.OrdinalIgnoreCase);
diff --git a/src/ClawdDotNet.Core/Config/AgentConfig.cs b/src/ClawdDotNet.Core/Config/AgentConfig.cs
index af34618..2e0f07c 100644
--- a/src/ClawdDotNet.Core/Config/AgentConfig.cs
+++ b/src/ClawdDotNet.Core/Config/AgentConfig.cs
@@ -28,6 +28,13 @@ public sealed class AgentConfig
[JsonPropertyName("previousUnitId")]
public string? PreviousUnitId { get; set; }
+ ///
+ /// #691: The template the agent came from. Its Identity and Soul were written by someone
+ /// else — they are prompts, and where they came from must stay visible.
+ ///
+ [JsonPropertyName("templateOrigin")]
+ public string? TemplateOrigin { get; set; }
+
[JsonPropertyName("model")]
public string Model { get; set; } = "anthropic/claude-sonnet-4-5";
diff --git a/src/ClawdDotNet.Core/Config/UnitConfig.cs b/src/ClawdDotNet.Core/Config/UnitConfig.cs
index 31def91..e21c84e 100644
--- a/src/ClawdDotNet.Core/Config/UnitConfig.cs
+++ b/src/ClawdDotNet.Core/Config/UnitConfig.cs
@@ -70,6 +70,10 @@ public sealed class UnitConfig
///
[JsonPropertyName("votes")]
public UnitVotes? Votes { get; set; }
+
+ /// #691: The template the unit came from — its texts and rules are someone else's.
+ [JsonPropertyName("templateOrigin")]
+ public string? TemplateOrigin { get; set; }
}
///
diff --git a/src/ClawdDotNet.Core/Organisation/OrgTemplate.cs b/src/ClawdDotNet.Core/Organisation/OrgTemplate.cs
new file mode 100644
index 0000000..7fee933
--- /dev/null
+++ b/src/ClawdDotNet.Core/Organisation/OrgTemplate.cs
@@ -0,0 +1,277 @@
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using System.Text.Json.Serialization;
+using System.Text.RegularExpressions;
+using ClawdDotNet.Core.Config;
+using ClawdDotNet.Core.Security;
+
+namespace ClawdDotNet.Core.Organisation;
+
+///
+/// #691: A whole house as a file — units and their nesting, the agents with their texts, tools,
+/// models and budgets, the units' rules and doctrines. Exported from one instance, passed on,
+/// imported into another.
+///
+/// The test of the building kit. Nothing in the product says "purchasing": a
+/// department is a unit with a name, and whether it is purchasing, an editorial office or a
+/// lab is a line in this file. A template that cannot be written without code means a use
+/// case slipped into the product.
+///
+/// Never credentials. A secret is taken out on export and named instead
+/// (); the import says which ones are needed and leaves them empty.
+///
+public sealed class OrgTemplate
+{
+ public const string FormatName = "clawddotnet-org-template";
+ public const int CurrentFormatVersion = 1;
+
+ [JsonPropertyName("format")]
+ public string Format { get; set; } = FormatName;
+
+ [JsonPropertyName("formatVersion")]
+ public int FormatVersion { get; set; } = CurrentFormatVersion;
+
+ [JsonPropertyName("name")]
+ public string Name { get; set; } = "";
+
+ [JsonPropertyName("description")]
+ public string Description { get; set; } = "";
+
+ /// The template's own version, as its author counts.
+ [JsonPropertyName("version")]
+ public string Version { get; set; } = "1";
+
+ [JsonPropertyName("exportedAt")]
+ public DateTime? ExportedAt { get; set; }
+
+ [JsonPropertyName("units")]
+ public List Units { get; set; } = [];
+
+ [JsonPropertyName("agents")]
+ public List Agents { get; set; } = [];
+
+ /// The doctrine of each unit, by unit id.
+ [JsonPropertyName("doctrines")]
+ public Dictionary Doctrines { get; set; } = [];
+
+ /// What has to be filled in after the import — never the values.
+ [JsonPropertyName("credentials")]
+ public List Credentials { get; set; } = [];
+}
+
+/// #691: An agent as a template carries it: its role, not its state.
+public sealed class TemplateAgent
+{
+ [JsonPropertyName("agentId")]
+ public string AgentId { get; set; } = "";
+
+ [JsonPropertyName("displayName")]
+ public string DisplayName { get; set; } = "";
+
+ [JsonPropertyName("description")]
+ public string Description { get; set; } = "";
+
+ [JsonPropertyName("unitId")]
+ public string? UnitId { get; set; }
+
+ [JsonPropertyName("model")]
+ public string Model { get; set; } = "";
+
+ [JsonPropertyName("budget")]
+ public AgentBudget Budget { get; set; } = new();
+
+ /// Tool settings with every secret emptied.
+ [JsonPropertyName("tools")]
+ public Dictionary Tools { get; set; } = [];
+
+ [JsonPropertyName("identity")]
+ public string Identity { get; set; } = "";
+
+ [JsonPropertyName("soul")]
+ public string Soul { get; set; } = "";
+}
+
+/// A credential the template needs: which agent, which tool, which field.
+public sealed record CredentialNeed(
+ [property: JsonPropertyName("agentId")] string AgentId,
+ [property: JsonPropertyName("tool")] string Tool,
+ [property: JsonPropertyName("field")] string Field);
+
+public static class OrgTemplates
+{
+ /// Longest Identity or Soul text a template may bring — it goes into every prompt.
+ public const int MaxTextLength = 50_000;
+
+ private static readonly Regex IdPattern = new("^[a-z0-9][a-z0-9_-]{0,63}$", RegexOptions.IgnoreCase);
+
+ public static readonly JsonSerializerOptions Json = new()
+ {
+ WriteIndented = true,
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+ Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping
+ };
+
+ ///
+ /// The instance's organisation as a template. Secrets are emptied and named; workspaces,
+ /// histories, tasks and everything an agent learned stay behind — a template is a structure.
+ ///
+ public static OrgTemplate Export(InstanceConfig instance, string name, string description, Func doctrineOf, DateTime now)
+ {
+ var template = new OrgTemplate { Name = name.Trim(), Description = description.Trim(), ExportedAt = now };
+
+ foreach (var unit in instance.Units)
+ {
+ template.Units.Add(JsonSerializer.Deserialize(JsonSerializer.Serialize(unit))!);
+ if (doctrineOf(unit.Id) is { Length: > 0 } doctrine)
+ template.Doctrines[unit.Id] = doctrine;
+ }
+
+ foreach (var agent in instance.Agents)
+ {
+ var entry = new TemplateAgent
+ {
+ AgentId = agent.AgentId,
+ DisplayName = agent.DisplayName,
+ Description = agent.Description,
+ UnitId = agent.UnitId,
+ Model = agent.Model,
+ Budget = new AgentBudget { DailyCostUsd = agent.Budget.DailyCostUsd, DailyTokens = agent.Budget.DailyTokens, WhenExhausted = agent.Budget.WhenExhausted },
+ Identity = agent.Identity,
+ Soul = agent.Soul
+ };
+ foreach (var (tool, settings) in agent.Tools)
+ {
+ var node = JsonSerializer.SerializeToNode(settings)?.AsObject() ?? new JsonObject();
+ foreach (var field in StripSecrets(node, ""))
+ template.Credentials.Add(new CredentialNeed(agent.AgentId, tool, field));
+ entry.Tools[tool] = node;
+ }
+ template.Agents.Add(entry);
+ }
+ return template;
+ }
+
+ /// Whether a setting's name says it holds a credential — also nested and in variants (smtpPassword).
+ public static bool IsSecretName(string key)
+ {
+ if (ConfigSecrets.IsSecretKey(key))
+ return true;
+ var k = key.ToLowerInvariant();
+ return k.Contains("password") || k.Contains("passwort") || k.Contains("secret") || k.Contains("token")
+ || k.Contains("apikey") || k.Contains("api_key") || k.Contains("connectionstring") || k.Contains("credential")
+ || k.Contains("privatekey");
+ }
+
+ /// Empties every secret in the tree, returns the paths of those that held a value.
+ internal static List StripSecrets(JsonNode? node, string path)
+ {
+ var found = new List();
+ switch (node)
+ {
+ case JsonObject obj:
+ foreach (var (key, value) in obj.ToList())
+ {
+ var here = path.Length == 0 ? key : $"{path}.{key}";
+ if (IsSecretName(key) && value is JsonValue)
+ {
+ if (value.ToString().Length > 0)
+ found.Add(here);
+ obj[key] = "";
+ }
+ else
+ found.AddRange(StripSecrets(value, here));
+ }
+ break;
+ case JsonArray array:
+ for (var i = 0; i < array.Count; i++)
+ found.AddRange(StripSecrets(array[i], $"{path}[{i}]"));
+ break;
+ }
+ return found;
+ }
+
+ public static OrgTemplate Parse(string json)
+ {
+ OrgTemplate? template;
+ try
+ {
+ // The file must say what it is — the class's default would let any JSON pass.
+ using (var doc = JsonDocument.Parse(json))
+ if (doc.RootElement.ValueKind != JsonValueKind.Object
+ || !doc.RootElement.TryGetProperty("format", out var format)
+ || format.ValueKind != JsonValueKind.String || format.GetString() != OrgTemplate.FormatName)
+ template = null;
+ else
+ template = JsonSerializer.Deserialize(json, Json);
+ }
+ catch (JsonException ex) { throw new FormatException($"Not a template: {ex.Message}"); }
+ if (template is null)
+ throw new FormatException($"Not a template — the file does not say \"format\": \"{OrgTemplate.FormatName}\".");
+ if (template.FormatVersion > OrgTemplate.CurrentFormatVersion)
+ throw new FormatException($"The template is of format version {template.FormatVersion}; this version reads up to {OrgTemplate.CurrentFormatVersion}.");
+ return template;
+ }
+
+ /// What is wrong with the template itself — before it is held against an instance.
+ public static IReadOnlyList Problems(OrgTemplate template)
+ {
+ var problems = new List();
+ var units = new HashSet(StringComparer.OrdinalIgnoreCase);
+ foreach (var u in template.Units)
+ {
+ if (!IdPattern.IsMatch(u.Id ?? ""))
+ problems.Add($"Unit id '{u.Id}' is not valid.");
+ else if (!units.Add(u.Id))
+ problems.Add($"Unit '{u.Id}' appears twice.");
+ if (string.IsNullOrWhiteSpace(u.Name))
+ problems.Add($"Unit '{u.Id}' has no name.");
+ }
+ foreach (var u in template.Units.Where(u => u.ParentId is not null && !units.Contains(u.ParentId)))
+ problems.Add($"Unit '{u.Id}' hangs under '{u.ParentId}', which the template does not have.");
+ foreach (var u in template.Units)
+ {
+ var seen = new HashSet(StringComparer.OrdinalIgnoreCase);
+ for (var p = u; p?.ParentId is { } parent; p = template.Units.FirstOrDefault(x => string.Equals(x.Id, parent, StringComparison.OrdinalIgnoreCase)))
+ if (!seen.Add(parent))
+ {
+ problems.Add($"Unit '{u.Id}' lies in a circle.");
+ break;
+ }
+ }
+
+ var agents = new HashSet(StringComparer.OrdinalIgnoreCase);
+ var names = new HashSet(StringComparer.OrdinalIgnoreCase);
+ foreach (var a in template.Agents)
+ {
+ if (!IdPattern.IsMatch(a.AgentId ?? ""))
+ problems.Add($"Agent id '{a.AgentId}' is not valid.");
+ else if (!agents.Add(a.AgentId))
+ problems.Add($"Agent '{a.AgentId}' appears twice.");
+ if (string.IsNullOrWhiteSpace(a.DisplayName))
+ problems.Add($"Agent '{a.AgentId}' has no display name.");
+ else if (!names.Add(a.DisplayName.Trim()))
+ problems.Add($"Display name '{a.DisplayName}' appears twice — it names the agent's folder.");
+ if (a.UnitId is not null && !units.Contains(a.UnitId))
+ problems.Add($"Agent '{a.AgentId}' belongs to unit '{a.UnitId}', which the template does not have.");
+ if (a.Identity.Length > MaxTextLength || a.Soul.Length > MaxTextLength)
+ problems.Add($"Agent '{a.AgentId}' brings a text longer than {MaxTextLength} characters.");
+ foreach (var (tool, settings) in a.Tools)
+ if (StripSecrets(settings.DeepClone(), "").Count > 0)
+ problems.Add($"Agent '{a.AgentId}' brings a credential for {tool} — a template never carries one.");
+ }
+
+ foreach (var u in template.Units)
+ {
+ if (u.LeaderAgentId is { Length: > 0 } lead
+ && template.Agents.FirstOrDefault(a => string.Equals(a.AgentId, lead, StringComparison.OrdinalIgnoreCase)) is var leader
+ && (leader is null || !string.Equals(leader.UnitId, u.Id, StringComparison.OrdinalIgnoreCase)))
+ problems.Add($"Unit '{u.Id}' is led by '{lead}', who is not one of its members in the template.");
+ foreach (var voter in u.Votes?.Voters ?? [])
+ if (!agents.Contains(voter))
+ problems.Add($"Unit '{u.Id}' lets '{voter}' vote, whom the template does not have.");
+ }
+ foreach (var unitId in template.Doctrines.Keys.Where(k => !units.Contains(k)))
+ problems.Add($"A doctrine is given for '{unitId}', which the template does not have.");
+ return problems;
+ }
+}
diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/app.css b/src/ClawdDotNet.Manager.Web/wwwroot/app.css
index 607c1d1..790fa03 100644
--- a/src/ClawdDotNet.Manager.Web/wwwroot/app.css
+++ b/src/ClawdDotNet.Manager.Web/wwwroot/app.css
@@ -2637,6 +2637,19 @@ button.org-einheit .org-name {
gap: 4px;
}
+/* #691: a template before it comes in — every agent with its full texts. */
+.org-vorlage-agent {
+ border-left: 3px solid var(--accent);
+ padding: 6px 12px;
+ margin-bottom: 10px;
+}
+
+.org-vorlage-agent pre {
+ max-height: 240px;
+ overflow: auto;
+ white-space: pre-wrap;
+}
+
/* #702: a vote beside the call — every ballot with its reasoning and evidence. */
.staging-stimmen {
list-style: none;
diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/i18n/de.json b/src/ClawdDotNet.Manager.Web/wwwroot/i18n/de.json
index 8b8f565..dbf05e5 100644
--- a/src/ClawdDotNet.Manager.Web/wwwroot/i18n/de.json
+++ b/src/ClawdDotNet.Manager.Web/wwwroot/i18n/de.json
@@ -152,6 +152,23 @@
"org.voteTallyText": "{yes} ja · {no} nein · {missing} fehlen",
"org.voteOutcome": "Ergebnis",
"org.voteCost": "Kosten der Abstimmenden",
+ "org.templates": "Organisationsvorlagen",
+ "org.templatesHint": "Ein ganzes Haus als Datei: Einheiten, Regeln, Doktrinen und Agenten mit ihren Texten, Werkzeugen, Modellen und Budgets. Exportiert ohne Zugangsdaten — sie werden benannt, nie mitgegeben. Importiert nur in IDs und Namen, die diese Instanz noch nicht hat, und erst, nachdem du gelesen hast, was hereinkommt.",
+ "org.templateNamePlaceholder": "Name der Vorlage",
+ "org.templateExport": "Exportieren",
+ "org.templateExportedCredentials": "Exportiert. {n} Zugangsdaten wurden weggelassen und sind in der Datei benannt.",
+ "org.templateFile": "Vorlage importieren",
+ "org.templateModel": "Modell für alle Agenten (optional)",
+ "org.templateModelPlaceholder": "Modelle der Vorlage behalten",
+ "org.templateBudgetFactor": "Budgetfaktor",
+ "org.templateProblems": "Die Vorlage lässt sich nicht importieren:",
+ "org.templateUnits": "{n} Einheit(en)",
+ "org.templateAgents": "{n} Agent(en)",
+ "org.templateDay": "Tag",
+ "org.templateCredentials": "Danach einzutragende Zugangsdaten",
+ "org.templateConfirm": "Ich habe die Identity- und Soul-Texte gelesen. Sie werden zu den Prompts meiner Agenten.",
+ "org.templateImport": "Importieren",
+ "org.templateUnreadable": "Die Datei ist keine Vorlage: {error}",
"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.",
diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/i18n/en.json b/src/ClawdDotNet.Manager.Web/wwwroot/i18n/en.json
index e3671b4..3c8891c 100644
--- a/src/ClawdDotNet.Manager.Web/wwwroot/i18n/en.json
+++ b/src/ClawdDotNet.Manager.Web/wwwroot/i18n/en.json
@@ -152,6 +152,23 @@
"org.voteTallyText": "{yes} yes · {no} no · {missing} missing",
"org.voteOutcome": "Outcome",
"org.voteCost": "Cost of the voters",
+ "org.templates": "Organisation templates",
+ "org.templatesHint": "A whole house as a file: units, rules, doctrines and agents with their texts, tools, models and budgets. Exported without credentials — they are named, never carried. Imported only into ids and names this instance does not have yet, after you have read what comes in.",
+ "org.templateNamePlaceholder": "Template name",
+ "org.templateExport": "Export",
+ "org.templateExportedCredentials": "Exported. {n} credential(s) were left out and are named in the file.",
+ "org.templateFile": "Import a template",
+ "org.templateModel": "Model for all agents (optional)",
+ "org.templateModelPlaceholder": "keep the template's models",
+ "org.templateBudgetFactor": "Budget factor",
+ "org.templateProblems": "The template cannot be imported:",
+ "org.templateUnits": "{n} unit(s)",
+ "org.templateAgents": "{n} agent(s)",
+ "org.templateDay": "day",
+ "org.templateCredentials": "Credentials to fill in afterwards",
+ "org.templateConfirm": "I have read the Identity and Soul texts. They become my agents' prompts.",
+ "org.templateImport": "Import",
+ "org.templateUnreadable": "The file is no template: {error}",
"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.",
diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/views/organisation.js b/src/ClawdDotNet.Manager.Web/wwwroot/views/organisation.js
index 3d4eeb7..e008ce5 100644
--- a/src/ClawdDotNet.Manager.Web/wwwroot/views/organisation.js
+++ b/src/ClawdDotNet.Manager.Web/wwwroot/views/organisation.js
@@ -307,7 +307,10 @@ function renderDetail() {
], { klasse: "org-grund" });
if (!item) {
- detailBox.replaceChildren(erstelleElement("div", { klasse: "hinweis-zentriert", text: t("org.selectHint") }));
+ detailBox.replaceChildren(
+ erstelleElement("div", { klasse: "hinweis-zentriert", text: t("org.selectHint") }),
+ reasonCard,
+ templatesCard());
return;
}
@@ -317,6 +320,119 @@ function renderDetail() {
detailBox.replaceChildren(reasonCard, ...panels);
}
+/**
+ * #691: The organisation as a template file. Export never carries credentials; import shows
+ * everything first — units, agents, models, budgets and the full Identity and Soul texts, which
+ * become prompts — and only runs after an explicit confirmation.
+ */
+function templatesCard() {
+ const name = erstelleElement("input", { type: "text", placeholder: t("org.templateNamePlaceholder") });
+ const file = erstelleElement("input", { type: "file", accept: ".json,application/json" });
+ const model = erstelleElement("input", { type: "text", placeholder: t("org.templateModelPlaceholder") });
+ const factor = erstelleElement("input", { type: "number", min: "0.01", step: "0.1", value: "1" });
+ const result = erstelleElement("div", { klasse: "org-vorlage" });
+ let template = null;
+ let source = "";
+
+ const body = () => ({
+ template,
+ model: model.value.trim() || null,
+ budgetFactor: Number(factor.value) > 0 && Number(factor.value) !== 1 ? Number(factor.value) : null,
+ source,
+ reason: reason()
+ });
+
+ const exportTemplate = async () => {
+ try {
+ const tpl = await api(`/organisation/template?name=${encodeURIComponent(name.value.trim())}`);
+ const blob = new Blob([JSON.stringify(tpl, null, 2)], { type: "application/json" });
+ const link = erstelleElement("a", { href: URL.createObjectURL(blob) });
+ link.download = `${(name.value.trim() || "organisation").replace(/[^\w-]+/g, "_")}.org-template.json`;
+ link.click();
+ setTimeout(() => URL.revokeObjectURL(link.href), 1000);
+ if (tpl.credentials?.length) zeigeMeldung(t("org.templateExportedCredentials", { n: tpl.credentials.length }), "erfolg", 8000);
+ } catch (err) {
+ zeigeMeldung(err.message, "gefahr", 8000);
+ }
+ };
+
+ const preview = async () => {
+ if (!template) return;
+ let p;
+ try {
+ p = await api("/organisation/template/preview", { method: "POST", body: body() });
+ } catch (err) {
+ result.replaceChildren(erstelleElement("p", { klasse: "fehler", text: err.message }));
+ return;
+ }
+
+ const confirm = erstelleElement("input", { type: "checkbox" });
+ const run = erstelleElement("button", {
+ type: "button", klasse: "primaer klein", text: t("org.templateImport"), disabled: true,
+ onclick: async () => {
+ const ok = await send("/organisation/template/import", "POST", { ...body(), confirm: confirm.checked });
+ if (ok) { template = null; result.replaceChildren(); file.value = ""; }
+ }
+ });
+ confirm.addEventListener("change", () => { run.disabled = !confirm.checked || !p.canImport; });
+
+ result.replaceChildren(
+ erstelleElement("h4", { text: `${p.name} · v${p.version} · ${p.fingerprint}` }),
+ p.problems.length
+ ? erstelleElement("div", { klasse: "org-probleme" },
+ erstelleElement("strong", { text: t("org.templateProblems") }),
+ erstelleElement("ul", {}, ...p.problems.map((x) => erstelleElement("li", { text: x }))))
+ : "",
+ ...p.warnings.map((w) => erstelleElement("p", { klasse: "hinweis-box warnung text-klein", text: w })),
+ erstelleElement("h4", { text: t("org.templateUnits", { n: p.units.length }) }),
+ erstelleElement("ul", { klasse: "org-liste" }, ...p.units.map((u) => erstelleElement("li", { klasse: "text-klein",
+ text: `${u.path}${u.leaderAgentId ? ` · ${t("org.leadShort")}: ${u.leaderAgentId}` : ""}` }))),
+ erstelleElement("h4", { text: t("org.templateAgents", { n: p.agents.length }) }),
+ ...p.agents.map((a) => erstelleElement("div", { klasse: "org-vorlage-agent" },
+ erstelleElement("strong", { text: `${a.displayName} (${a.agentId})` }),
+ erstelleElement("div", { klasse: "gedaempft text-klein",
+ text: `${a.unitId ?? t("org.topLevel")} · ${a.model} · $${a.dailyCostUsd}/${t("org.templateDay")} · ${a.tools.join(", ") || "—"}` }),
+ erstelleElement("div", { klasse: "text-klein", text: "Identity" }),
+ erstelleElement("pre", { klasse: "code-block", text: a.identity || "—" }),
+ erstelleElement("div", { klasse: "text-klein", text: "Soul" }),
+ erstelleElement("pre", { klasse: "code-block", text: a.soul || "—" }))),
+ p.credentials.length
+ ? erstelleElement("div", {},
+ erstelleElement("h4", { text: t("org.templateCredentials") }),
+ erstelleElement("ul", { klasse: "org-liste" }, ...p.credentials.map((c) => erstelleElement("li", { klasse: "text-klein",
+ text: `${c.agentId} · ${c.tool} · ${c.field}` }))))
+ : "",
+ erstelleElement("label", { klasse: "gruppe-horizontal text-klein" }, confirm, erstelleElement("span", { text: t("org.templateConfirm") })),
+ run);
+ };
+
+ file.addEventListener("change", async () => {
+ const f = file.files?.[0];
+ if (!f) return;
+ try {
+ template = JSON.parse(await f.text());
+ source = f.name;
+ await preview();
+ } catch (err) {
+ template = null;
+ result.replaceChildren(erstelleElement("p", { klasse: "fehler", text: t("org.templateUnreadable", { error: err.message }) }));
+ }
+ });
+ model.addEventListener("change", preview);
+ factor.addEventListener("change", preview);
+
+ return erstelleKarte(t("org.templates"), [
+ erstelleElement("p", { klasse: "gedaempft text-klein", text: t("org.templatesHint") }),
+ erstelleElement("div", { klasse: "gruppe-horizontal" }, name,
+ erstelleElement("button", { type: "button", klasse: "klein", text: t("org.templateExport"), onclick: exportTemplate })),
+ erstelleElement("div", { klasse: "formular-raster" },
+ erstelleElement("label", { text: t("org.templateFile") }), file,
+ erstelleElement("label", { text: t("org.templateModel") }), model,
+ erstelleElement("label", { text: t("org.templateBudgetFactor") }), factor),
+ result
+ ]);
+}
+
function unitPanel(unit) {
const cards = [];
diff --git a/src/ClawdDotNet.Web/Contracts/OrganisationDtos.cs b/src/ClawdDotNet.Web/Contracts/OrganisationDtos.cs
index 6a74966..9dc17dc 100644
--- a/src/ClawdDotNet.Web/Contracts/OrganisationDtos.cs
+++ b/src/ClawdDotNet.Web/Contracts/OrganisationDtos.cs
@@ -42,6 +42,16 @@ public sealed record InspectionInputDto(bool Enabled, double? Rate, double? Dens
public sealed record FindingDto(string Kind, string Text);
+/// #691: A template to preview or import, with what the human changes on the way in.
+public sealed record TemplateImportDto(
+ System.Text.Json.JsonElement Template,
+ string? Model = null,
+ Dictionary? Models = null,
+ decimal? BudgetFactor = null,
+ bool Confirm = false,
+ string Source = "",
+ string Reason = "");
+
/// #702: What a unit puts to a blind vote, and who votes.
public sealed record UnitVotesDto(IReadOnlyList Operations, IReadOnlyList Voters, int ApproveAt, int RejectAt, int Quorum, double Hours, int PerDay);
diff --git a/src/ClawdDotNet.Web/Endpoints/OrganisationEndpoints.cs b/src/ClawdDotNet.Web/Endpoints/OrganisationEndpoints.cs
index 8239d00..eacde97 100644
--- a/src/ClawdDotNet.Web/Endpoints/OrganisationEndpoints.cs
+++ b/src/ClawdDotNet.Web/Endpoints/OrganisationEndpoints.cs
@@ -105,6 +105,26 @@ internal static class OrganisationEndpoints
return Results.Ok(new OutcomeDto(true, set.IsEmpty ? $"Doctrine of '{unit.Name}' removed." : $"Doctrine of '{unit.Name}' set (version {set.Version})."));
});
+ // #691: The organisation as a template file — exported without credentials, imported only after a preview and a confirmation.
+ org.MapGet("/template", (AppHost host, string? name, string? description) =>
+ Results.Text(System.Text.Json.JsonSerializer.Serialize(
+ host.Organisation.ExportTemplate(name ?? host.Instance.InstanceId, description ?? ""), Core.Organisation.OrgTemplates.Json),
+ "application/json"));
+
+ org.MapPost("/template/preview", (AppHost host, TemplateImportDto input) =>
+ {
+ if (ReadTemplate(input) is not { } template)
+ return Results.BadRequest(new OutcomeDto(false, TemplateError(input)));
+ return Results.Ok(host.Organisation.PreviewTemplate(template, Overrides(input)));
+ });
+
+ org.MapPost("/template/import", async (AppHost host, TemplateImportDto input, CancellationToken ct) =>
+ {
+ if (ReadTemplate(input) is not { } template)
+ return Results.BadRequest(new OutcomeDto(false, TemplateError(input)));
+ return (await host.Organisation.ImportTemplateAsync(template, Overrides(input), input.Confirm, input.Source, input.Reason, ct)).ToResponse();
+ });
+
// #702: What the unit puts to a blind vote, and the votes held.
org.MapPut("/units/{unitId}/votes", (AppHost host, string unitId, VotesInputDto input) =>
host.Organisation.SetVotes(unitId, input.Operations, input.Voters, input.ApproveAt, input.RejectAt, input.Quorum, input.Hours, input.PerDay, input.Reason).ToResponse());
@@ -212,4 +232,21 @@ internal static class OrganisationEndpoints
private static StructurePreviewDto ToDto(StructurePreview preview)
=> new(preview.AffectedAgents, preview.ToolsLost, preview.Running, preview.Problems, preview.CanApply);
+ // ─── templates (#691) ───
+
+ private static Core.Organisation.OrgTemplate? ReadTemplate(TemplateImportDto input)
+ {
+ try { return Core.Organisation.OrgTemplates.Parse(input.Template.GetRawText()); }
+ catch (FormatException) { return null; }
+ catch (InvalidOperationException) { return null; }
+ }
+
+ private static string TemplateError(TemplateImportDto input)
+ {
+ try { Core.Organisation.OrgTemplates.Parse(input.Template.GetRawText()); return "The template could not be read."; }
+ catch (Exception ex) { return ex.Message; }
+ }
+
+ private static App.Services.TemplateOverrides Overrides(TemplateImportDto input)
+ => new(input.Model, input.Models, input.BudgetFactor);
}
diff --git a/tests/ClawdDotNet.App.Tests/Services/OrgAdministrationTests.cs b/tests/ClawdDotNet.App.Tests/Services/OrgAdministrationTests.cs
index 8494952..89d594f 100644
--- a/tests/ClawdDotNet.App.Tests/Services/OrgAdministrationTests.cs
+++ b/tests/ClawdDotNet.App.Tests/Services/OrgAdministrationTests.cs
@@ -330,6 +330,82 @@ public sealed class OrgAdministrationTests : IDisposable
Unit("marketing").Votes.ShouldBeNull();
}
+ // ═══════════════════ templates (#691) ═══════════════════
+
+ private static Core.Organisation.OrgTemplate Shop() => new()
+ {
+ Name = "Shop", Version = "2",
+ Units =
+ [
+ new UnitConfig { Id = "shop", Name = "Shop", LeaderAgentId = "shop-lead", ToolCeiling = ["Taskboard", "WebFetch"] },
+ new UnitConfig { Id = "returns", Name = "Returns", ParentId = "shop" }
+ ],
+ Agents =
+ [
+ new Core.Organisation.TemplateAgent { AgentId = "shop-lead", DisplayName = "Shop Lead", UnitId = "shop", Model = "m/large",
+ Identity = "You run the shop.", Soul = "Calm.", Budget = new AgentBudget { DailyCostUsd = 4 },
+ Tools = new() { ["Taskboard"] = new System.Text.Json.Nodes.JsonObject() } },
+ new Core.Organisation.TemplateAgent { AgentId = "clerk", DisplayName = "Clerk", UnitId = "returns", Model = "m/small",
+ Tools = new() { ["WebFetch"] = new System.Text.Json.Nodes.JsonObject { ["maxKb"] = 64 } } }
+ ],
+ Credentials = [new("clerk", "WebFetch", "apiKey")]
+ };
+
+ [Fact]
+ public async Task A_template_is_imported_only_after_confirmation_with_its_origin_and_overrides()
+ {
+ (await Org.ImportTemplateAsync(Shop(), null, confirmed: false, "shop.json", "", default)).Message.ShouldContain("Confirm");
+ _test.Host.Instance.Units.ShouldBeEmpty("nothing is written before the confirmation");
+
+ var imported = await Org.ImportTemplateAsync(Shop(), new TemplateOverrides(Model: "m/cheap", BudgetFactor: 0.5m), true, "shop.json", "trial", default);
+
+ imported.Ok.ShouldBeTrue(imported.Message);
+ imported.Message.ShouldContain("1 credential");
+ Unit("returns").ParentId.ShouldBe("shop");
+ Unit("shop").TemplateOrigin!.ShouldStartWith("Shop v2");
+ _test.Host.Instance.Workspaces.ShouldContain(w => w.Id == "returns", "every unit gets its own workspace");
+ var lead = Find("shop-lead");
+ lead.Identity.ShouldBe("You run the shop.");
+ lead.Model.ShouldBe("m/cheap");
+ lead.Budget.DailyCostUsd.ShouldBe(2);
+ lead.TemplateOrigin!.ShouldContain("shop.json");
+ Org.Current().LeaderOf("shop")!.AgentId.ShouldBe("shop-lead");
+ Find("clerk").Tools.Keys.ShouldBe(["WebFetch"]);
+ }
+
+ [Fact]
+ public void A_template_that_clashes_or_breaks_a_ceiling_is_refused_in_the_preview()
+ {
+ BuildMarketing();
+ var shop = Shop();
+ shop.Agents.Add(new Core.Organisation.TemplateAgent { AgentId = "ads", DisplayName = "Ads 2", UnitId = "shop" });
+ shop.Agents[0].Tools["Database"] = new System.Text.Json.Nodes.JsonObject();
+ shop.Units.Add(new UnitConfig { Id = "marketing", Name = "Marketing again" });
+
+ var preview = Org.PreviewTemplate(shop);
+
+ preview.CanImport.ShouldBeFalse();
+ preview.Problems.ShouldContain(p => p.Contains("agent 'ads'"));
+ preview.Problems.ShouldContain(p => p.Contains("unit 'marketing'"));
+ preview.Problems.ShouldContain(p => p.Contains("Database, beyond the tool ceiling"));
+ preview.Warnings.ShouldContain(p => p.Contains("become prompts"));
+ }
+
+ [Fact]
+ public void The_export_of_an_instance_is_a_template_another_can_preview()
+ {
+ BuildMarketing();
+ Find("ads").Tools["WebFetch"]["token"] = "secret-value";
+
+ var exported = Org.ExportTemplate("Marketing");
+ var json = System.Text.Json.JsonSerializer.Serialize(exported, Core.Organisation.OrgTemplates.Json);
+
+ json.ShouldNotContain("secret-value");
+ exported.Credentials.ShouldContain(c => c.AgentId == "ads" && c.Field == "token");
+ Core.Organisation.OrgTemplates.Problems(Core.Organisation.OrgTemplates.Parse(json)).ShouldBeEmpty();
+ Org.PreviewTemplate(exported).Problems.ShouldContain(p => p.Contains("already has"), "into the same instance it clashes");
+ }
+
// ═══════════════════ the top (#690) ═══════════════════
private void MakeDecider(string id)
diff --git a/tests/ClawdDotNet.Core.Tests/Organisation/OrgTemplateTests.cs b/tests/ClawdDotNet.Core.Tests/Organisation/OrgTemplateTests.cs
new file mode 100644
index 0000000..823b2af
--- /dev/null
+++ b/tests/ClawdDotNet.Core.Tests/Organisation/OrgTemplateTests.cs
@@ -0,0 +1,102 @@
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using ClawdDotNet.Core.Config;
+using ClawdDotNet.Core.Organisation;
+using Shouldly;
+
+namespace ClawdDotNet.Core.Tests.Organisation;
+
+///
+/// #691: A whole house as a file — never with credentials, and a broken file is refused before
+/// it touches an instance.
+///
+public sealed class OrgTemplateTests
+{
+ private static InstanceConfig House()
+ {
+ var instance = new InstanceConfig();
+ instance.Units.Add(new UnitConfig { Id = "newsroom", Name = "Newsroom", LeaderAgentId = "editor", Goal = "Publish daily." });
+ instance.Units.Add(new UnitConfig { Id = "desk", Name = "Desk", ParentId = "newsroom" });
+ instance.Agents.Add(new AgentConfig
+ {
+ AgentId = "editor", DisplayName = "Editor", UnitId = "newsroom", Model = "m/large",
+ Identity = "You lead the newsroom.", Soul = "Careful.",
+ Budget = new AgentBudget { DailyCostUsd = 2 },
+ Tools = new()
+ {
+ ["Mail"] = new() { ["host"] = "mail.example.org", ["password"] = "hunter2", ["smtpPassword"] = "x" },
+ ["DirectAPI"] = new()
+ {
+ ["providers"] = JsonSerializer.Deserialize("""{"weather":{"url":"https://w.example","apiKey":"k-123"}}""")
+ }
+ }
+ });
+ instance.Agents.Add(new AgentConfig { AgentId = "writer", DisplayName = "Writer", UnitId = "desk", Model = "m/small" });
+ return instance;
+ }
+
+ private static OrgTemplate Export() => OrgTemplates.Export(House(), "Newsroom", "A small newsroom.",
+ unit => unit == "newsroom" ? "Every claim has a source." : null, DateTime.UtcNow);
+
+ [Fact]
+ public void Export_empties_every_credential_and_names_it_instead()
+ {
+ var template = Export();
+ var json = JsonSerializer.Serialize(template, OrgTemplates.Json);
+
+ json.ShouldNotContain("hunter2");
+ json.ShouldNotContain("k-123");
+ json.ShouldContain("mail.example.org", customMessage: "what is not secret stays");
+ template.Credentials.Select(c => $"{c.AgentId}:{c.Tool}:{c.Field}").ShouldBe(
+ ["editor:Mail:password", "editor:Mail:smtpPassword", "editor:DirectAPI:providers.weather.apiKey"], ignoreOrder: true);
+ }
+
+ [Fact]
+ public void Export_carries_the_structure_texts_and_doctrines()
+ {
+ var template = OrgTemplates.Parse(JsonSerializer.Serialize(Export(), OrgTemplates.Json));
+
+ template.Units.Select(u => u.Id).ShouldBe(["newsroom", "desk"]);
+ template.Agents.Single(a => a.AgentId == "editor").Identity.ShouldBe("You lead the newsroom.");
+ template.Doctrines["newsroom"].ShouldBe("Every claim has a source.");
+ OrgTemplates.Problems(template).ShouldBeEmpty();
+ }
+
+ [Fact]
+ public void A_file_that_is_no_template_is_refused()
+ {
+ Should.Throw(() => OrgTemplates.Parse("""{"units":[]}"""));
+ Should.Throw(() => OrgTemplates.Parse("""{"format":"clawddotnet-org-template","formatVersion":99}"""));
+ Should.Throw(() => OrgTemplates.Parse("not json"));
+ }
+
+ [Fact]
+ public void A_broken_structure_is_named_before_anything_is_written()
+ {
+ var template = Export();
+ template.Units.Add(new UnitConfig { Id = "a", Name = "A", ParentId = "b" });
+ template.Units.Add(new UnitConfig { Id = "b", Name = "B", ParentId = "a" });
+ template.Units.Add(new UnitConfig { Id = "orphan", Name = "Orphan", ParentId = "nowhere" });
+ template.Units[1].LeaderAgentId = "editor";
+ template.Agents.Add(new TemplateAgent { AgentId = "writer", DisplayName = "Writer 2" });
+ template.Agents[0].Tools["Mail"]["password"] = "smuggled";
+
+ var problems = OrgTemplates.Problems(template);
+
+ problems.ShouldContain(p => p.Contains("circle"));
+ problems.ShouldContain(p => p.Contains("'nowhere'"));
+ problems.ShouldContain(p => p.Contains("led by 'editor'"));
+ problems.ShouldContain(p => p.Contains("'writer' appears twice"));
+ problems.ShouldContain(p => p.Contains("never carries one"));
+ }
+
+ [Fact]
+ public void A_nested_secret_is_found_under_any_variant_of_its_name()
+ {
+ var node = JsonNode.Parse("""{"a":{"clientSecret":"s","list":[{"accessToken":"t"}],"name":"keep"}}""")!;
+
+ OrgTemplates.StripSecrets(node, "").ShouldBe(["a.clientSecret", "a.list[0].accessToken"]);
+ node.ToJsonString().ShouldContain("keep");
+ node.ToJsonString().ShouldNotContain("\"s\"");
+ }
+}