Merge feat/691-org-templates: Organisationsvorlagen (#691)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-10-01 12:46:22 +02:00
co-authored by Claude Opus 5.5
14 changed files with 880 additions and 3 deletions
+15
View File
@@ -59,6 +59,21 @@ weitergebbare Datei. Dieselbe Idee wie bei den Basisagenten
hier ein ganzes Haus. Wer eine Vorlage „Onlinehandel" oder „Redaktion" schreibt, hat hier ein ganzes Haus. Wer eine Vorlage „Onlinehandel" oder „Redaktion" schreibt, hat
damit keinen Code geschrieben. 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 ## 2 — Was heute bei 50 Agenten bricht
@@ -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;
/// <summary>#691: What the human may change when importing a template — models and budgets fit one house, not every house.</summary>
public sealed record TemplateOverrides(
string? Model = null,
IReadOnlyDictionary<string, string>? 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<string> Tools, string Identity, string Soul);
/// <summary>#691: What an import would do — and what stands in its way. Nothing is written for it.</summary>
public sealed record TemplatePreview(
string Name,
string Version,
string Fingerprint,
IReadOnlyList<string> Problems,
IReadOnlyList<string> Warnings,
IReadOnlyList<TemplatePreviewUnit> Units,
IReadOnlyList<TemplatePreviewAgent> Agents,
IReadOnlyList<CredentialNeed> Credentials)
{
public bool CanImport => Problems.Count == 0;
}
public sealed partial class OrgAdministration
{
// ─── templates (#691) ───
/// <summary>The organisation of this instance as a template file — never with credentials.</summary>
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);
/// <summary>
/// What importing <paramref name="template"/> 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.
/// </summary>
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<string>();
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);
}
/// <summary>
/// Imports the template: units with their rules and workspaces, agents with their texts,
/// tools, models and budgets, the doctrines. Only with <paramref name="confirmed"/> — 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.
/// </summary>
public async Task<AdminResult> 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<UnitConfig>(JsonSerializer.Serialize(unit))!;
copy.TemplateOrigin = origin;
host.Instance.Units.Add(copy);
host.Instance.Workspaces.Add(OwnWorkspace(copy.Id, copy.Name));
}
var created = new List<string>();
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<Dictionary<string, object?>>(t.Value.ToJsonString()) ?? new Dictionary<string, object?>(),
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
};
/// <summary>A short fingerprint of the template's content — which version of it came in.</summary>
private static string Fingerprint(OrgTemplate template)
=> Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(template, OrgTemplates.Json))))[..12].ToLowerInvariant();
}
@@ -71,7 +71,7 @@ public sealed record StructurePreview(
/// <para>Every structural change is written to the audit log with its reason — a change of /// <para>Every structural change is written to the audit log with its reason — a change of
/// structure is a change of rights (#205, #641).</para> /// structure is a change of rights (#205, #641).</para>
/// </summary> /// </summary>
public sealed class OrgAdministration(AppHost host, ILogger logger) public sealed partial class OrgAdministration(AppHost host, ILogger logger)
{ {
/// <summary>A value this far from the median of the others is an outlier.</summary> /// <summary>A value this far from the median of the others is an outlier.</summary>
public const decimal OutlierFactor = 3m; 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, 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, 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); private static bool Same(string? a, string? b) => string.Equals(a, b, StringComparison.OrdinalIgnoreCase);
@@ -28,6 +28,13 @@ public sealed class AgentConfig
[JsonPropertyName("previousUnitId")] [JsonPropertyName("previousUnitId")]
public string? PreviousUnitId { get; set; } public string? PreviousUnitId { get; set; }
/// <summary>
/// #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.
/// </summary>
[JsonPropertyName("templateOrigin")]
public string? TemplateOrigin { get; set; }
[JsonPropertyName("model")] [JsonPropertyName("model")]
public string Model { get; set; } = "anthropic/claude-sonnet-4-5"; public string Model { get; set; } = "anthropic/claude-sonnet-4-5";
@@ -70,6 +70,10 @@ public sealed class UnitConfig
/// </summary> /// </summary>
[JsonPropertyName("votes")] [JsonPropertyName("votes")]
public UnitVotes? Votes { get; set; } public UnitVotes? Votes { get; set; }
/// <summary>#691: The template the unit came from — its texts and rules are someone else's.</summary>
[JsonPropertyName("templateOrigin")]
public string? TemplateOrigin { get; set; }
} }
/// <summary> /// <summary>
@@ -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;
/// <summary>
/// #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.
///
/// <para><b>The test of the building kit.</b> 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.</para>
///
/// <para><b>Never credentials.</b> A secret is taken out on export and named instead
/// (<see cref="Credentials"/>); the import says which ones are needed and leaves them empty.</para>
/// </summary>
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; } = "";
/// <summary>The template's own version, as its author counts.</summary>
[JsonPropertyName("version")]
public string Version { get; set; } = "1";
[JsonPropertyName("exportedAt")]
public DateTime? ExportedAt { get; set; }
[JsonPropertyName("units")]
public List<UnitConfig> Units { get; set; } = [];
[JsonPropertyName("agents")]
public List<TemplateAgent> Agents { get; set; } = [];
/// <summary>The doctrine of each unit, by unit id.</summary>
[JsonPropertyName("doctrines")]
public Dictionary<string, string> Doctrines { get; set; } = [];
/// <summary>What has to be filled in after the import — never the values.</summary>
[JsonPropertyName("credentials")]
public List<CredentialNeed> Credentials { get; set; } = [];
}
/// <summary>#691: An agent as a template carries it: its role, not its state.</summary>
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();
/// <summary>Tool settings with every secret emptied.</summary>
[JsonPropertyName("tools")]
public Dictionary<string, JsonObject> Tools { get; set; } = [];
[JsonPropertyName("identity")]
public string Identity { get; set; } = "";
[JsonPropertyName("soul")]
public string Soul { get; set; } = "";
}
/// <summary>A credential the template needs: which agent, which tool, which field.</summary>
public sealed record CredentialNeed(
[property: JsonPropertyName("agentId")] string AgentId,
[property: JsonPropertyName("tool")] string Tool,
[property: JsonPropertyName("field")] string Field);
public static class OrgTemplates
{
/// <summary>Longest Identity or Soul text a template may bring — it goes into every prompt.</summary>
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
};
/// <summary>
/// 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.
/// </summary>
public static OrgTemplate Export(InstanceConfig instance, string name, string description, Func<string, string?> 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<UnitConfig>(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;
}
/// <summary>Whether a setting's name says it holds a credential — also nested and in variants (<c>smtpPassword</c>).</summary>
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");
}
/// <summary>Empties every secret in the tree, returns the paths of those that held a value.</summary>
internal static List<string> StripSecrets(JsonNode? node, string path)
{
var found = new List<string>();
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<OrgTemplate>(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;
}
/// <summary>What is wrong with the template itself — before it is held against an instance.</summary>
public static IReadOnlyList<string> Problems(OrgTemplate template)
{
var problems = new List<string>();
var units = new HashSet<string>(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<string>(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<string>(StringComparer.OrdinalIgnoreCase);
var names = new HashSet<string>(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;
}
}
@@ -2637,6 +2637,19 @@ button.org-einheit .org-name {
gap: 4px; 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. */ /* #702: a vote beside the call — every ballot with its reasoning and evidence. */
.staging-stimmen { .staging-stimmen {
list-style: none; list-style: none;
@@ -152,6 +152,23 @@
"org.voteTallyText": "{yes} ja · {no} nein · {missing} fehlen", "org.voteTallyText": "{yes} ja · {no} nein · {missing} fehlen",
"org.voteOutcome": "Ergebnis", "org.voteOutcome": "Ergebnis",
"org.voteCost": "Kosten der Abstimmenden", "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.ceilingInherited": "Von oben begrenzt auf: {tools}",
"org.settings": "Sammelwerte", "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.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.",
@@ -152,6 +152,23 @@
"org.voteTallyText": "{yes} yes · {no} no · {missing} missing", "org.voteTallyText": "{yes} yes · {no} no · {missing} missing",
"org.voteOutcome": "Outcome", "org.voteOutcome": "Outcome",
"org.voteCost": "Cost of the voters", "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.ceilingInherited": "Limited from above to: {tools}",
"org.settings": "Unit-wide values", "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.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.",
@@ -307,7 +307,10 @@ function renderDetail() {
], { klasse: "org-grund" }); ], { klasse: "org-grund" });
if (!item) { 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; return;
} }
@@ -317,6 +320,119 @@ function renderDetail() {
detailBox.replaceChildren(reasonCard, ...panels); 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) { function unitPanel(unit) {
const cards = []; const cards = [];
@@ -42,6 +42,16 @@ public sealed record InspectionInputDto(bool Enabled, double? Rate, double? Dens
public sealed record FindingDto(string Kind, string Text); public sealed record FindingDto(string Kind, string Text);
/// <summary>#691: A template to preview or import, with what the human changes on the way in.</summary>
public sealed record TemplateImportDto(
System.Text.Json.JsonElement Template,
string? Model = null,
Dictionary<string, string>? Models = null,
decimal? BudgetFactor = null,
bool Confirm = false,
string Source = "",
string Reason = "");
/// <summary>#702: What a unit puts to a blind vote, and who votes.</summary> /// <summary>#702: What a unit puts to a blind vote, and who votes.</summary>
public sealed record UnitVotesDto(IReadOnlyList<string> Operations, IReadOnlyList<string> Voters, int ApproveAt, int RejectAt, int Quorum, double Hours, int PerDay); public sealed record UnitVotesDto(IReadOnlyList<string> Operations, IReadOnlyList<string> Voters, int ApproveAt, int RejectAt, int Quorum, double Hours, int PerDay);
@@ -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}).")); 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. // #702: What the unit puts to a blind vote, and the votes held.
org.MapPut("/units/{unitId}/votes", (AppHost host, string unitId, VotesInputDto input) => 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()); 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) private static StructurePreviewDto ToDto(StructurePreview preview)
=> new(preview.AffectedAgents, preview.ToolsLost, preview.Running, preview.Problems, preview.CanApply); => 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);
} }
@@ -330,6 +330,82 @@ public sealed class OrgAdministrationTests : IDisposable
Unit("marketing").Votes.ShouldBeNull(); 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) ═══════════════════ // ═══════════════════ the top (#690) ═══════════════════
private void MakeDecider(string id) private void MakeDecider(string id)
@@ -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;
/// <summary>
/// #691: A whole house as a file — never with credentials, and a broken file is refused before
/// it touches an instance.
/// </summary>
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<JsonElement>("""{"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<FormatException>(() => OrgTemplates.Parse("""{"units":[]}"""));
Should.Throw<FormatException>(() => OrgTemplates.Parse("""{"format":"clawddotnet-org-template","formatVersion":99}"""));
Should.Throw<FormatException>(() => 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\"");
}
}