Doctrine: die Leitung steuert ihre Einheit, ohne jeden Auftragstext anzufassen (#698)

- SqliteDoctrineStore: je Einheit versioniert (Tabelle Doctrines: Text, Autor, Lauf, Grund),
  hoechstens 2000 Zeichen, leer entfernt; im Speicher gehalten fuer jeden Lauf
- OrgBriefing: die Doctrine der eigenen Einheit, nur eine Ebene tief, mit dem Satz,
  dass ein Widerspruch zur Aufgabe eskaliert wird; Leitungen erfahren doctrine_set
- Escalation: doctrine_read, doctrine_set (nur die Leitung, Grund Pflicht)
- IAgentTool.Vorgabe(operation, context): Einstufung mit dem Aufrufkontext; eine Leitung
  der Fremdinhalt-Stufe legt doctrine_set zur Freigabe vor
- API GET/PUT /organisation/units/{id}/doctrine; WebUI-Karte mit Verlauf und Markierung
  haeufiger Aenderungen; i18n en/de
- Tests: DoctrineTests (5)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-10-01 09:38:44 +02:00
co-authored by Claude Opus 5.5
parent c608af1abb
commit 679333b382
15 changed files with 395 additions and 6 deletions
+8
View File
@@ -132,6 +132,9 @@ public sealed class AppHost : IAsyncDisposable
/// <summary>#699: Costs per unit and the alerts on them.</summary>
public Core.Accounting.CostWatch? CostWatch { get; private set; }
/// <summary>#698: The units' doctrines.</summary>
public Core.Organisation.IDoctrineStore? Doctrines { get; private set; }
/// <summary>Modellpreise von OpenRouter - fuer Budget und Kostenprognose (#595). Laedt im Hintergrund.</summary>
public ModelPricingCatalog? Pricing { get; private init; }
@@ -707,6 +710,11 @@ public sealed class AppHost : IAsyncDisposable
historyKeeper.Start();
host._shutdown.Add(historyKeeper.DisposeAsync);
// #698: what holds in a unit, set by its lead.
var doctrines = new Core.Organisation.SqliteDoctrineStore(storage);
engine.Doctrines = doctrines;
host.Doctrines = doctrines;
// #699: costs per unit, and what changed in them.
var costWatch = new Core.Accounting.CostWatch(
storage, usage, () => Core.Organisation.OrgChart.From(instance), () => instance.CostWatch, board,
+9 -3
View File
@@ -54,6 +54,9 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
/// <summary>#689: Escalations, conflicts and decisions; set by the host. Tools reach it through their context.</summary>
public Cases.ICaseDesk? CaseDesk { get; set; }
/// <summary>#698: The units' doctrines; set by the host.</summary>
public Organisation.IDoctrineStore? Doctrines { get; set; }
/// <summary>#687: Approvals by leads within their unit's limits; set by the host.</summary>
public Staging.ILeadApprovals? LeadApprovals { get; set; }
@@ -86,7 +89,7 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
private string SystemPromptFor(AgentConfig agentConfig, bool hasTools)
=> Organisation.OrgBriefing.WithSection(
UntrustedContent.MitRegel(agentConfig.FullSystemPrompt, hasTools),
Organisation.OrgBriefing.For(CurrentOrganisation(), agentConfig.AgentId, _clock.GetUtcNow().UtcDateTime, TrustOf(agentConfig), CanEscalate, IsTop(agentConfig)));
Organisation.OrgBriefing.For(CurrentOrganisation(), agentConfig.AgentId, _clock.GetUtcNow().UtcDateTime, TrustOf(agentConfig), CanEscalate, IsTop(agentConfig), DoctrineOf));
/// <summary>
/// Wie viele Weitergaben eine Delegationskette höchstens haben darf (B8).
@@ -693,7 +696,7 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
// Umzug weiss der Agent sonst im laufenden Chat nichts davon. Der Rest
// des bestehenden Systemprompts bleibt, wie er war (#193).
var section = Organisation.OrgBriefing.For(
CurrentOrganisation(), agentConfig.AgentId, _clock.GetUtcNow().UtcDateTime, TrustOf(agentConfig), CanEscalate, IsTop(agentConfig));
CurrentOrganisation(), agentConfig.AgentId, _clock.GetUtcNow().UtcDateTime, TrustOf(agentConfig), CanEscalate, IsTop(agentConfig), DoctrineOf);
var refreshed = Organisation.OrgBriefing.WithSection(messages[0].Content ?? "", section);
if (refreshed != messages[0].Content)
messages[0] = ChatMessage.System(refreshed);
@@ -1227,6 +1230,8 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
/// <summary>#689: Whether agents can raise a case — the desk is set up and its tool registered.</summary>
private bool CanEscalate => CaseDesk is not null && _toolRegistry.Get(Cases.CaseDesk.ToolName) is not null;
private Organisation.Doctrine? DoctrineOf(string unitId) => Doctrines?.Current(unitId);
/// <summary>#690: Whether the agent is the deciding instance at the top.</summary>
private bool IsTop(AgentConfig agent)
=> CanEscalate && string.Equals(CaseDesk!.TopAgentId, agent.AgentId, StringComparison.OrdinalIgnoreCase);
@@ -1646,7 +1651,7 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
agentConfig.AgentId, instanceId, runId, toolName, arguments, ct,
operation,
Staging.Werkzeugrechte.Aus(context.ToolConfig),
operation is null ? null : tool.Vorgabe(operation, context.ToolConfig));
operation is null ? null : tool.Vorgabe(operation, context));
if (intercept.Outcome == Staging.StagingOutcome.Denied)
{
@@ -1868,6 +1873,7 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
Trust = ReportingLine.TrustOf(agentConfig, _toolRegistry.Get),
Cases = CaseDesk,
Approvals = LeadApprovals,
Doctrines = Doctrines,
SpecialBudgets = SpecialBudgets,
Sandbox = Sandbox,
Files = Files,
@@ -0,0 +1,93 @@
using System.Collections.Concurrent;
using System.Globalization;
using ClawdDotNet.Core.Storage;
namespace ClawdDotNet.Core.Organisation;
/// <summary>One version of a unit's doctrine — who set it, when, in which run, and why.</summary>
public sealed record Doctrine(string UnitId, int Version, string Text, string SetBy, DateTime SetAt, string? RunId, string Reason)
{
public bool IsEmpty => string.IsNullOrWhiteSpace(Text);
}
public interface IDoctrineStore
{
/// <summary>The doctrine that holds now; <c>null</c> when the unit has none. Read on every run — kept in memory.</summary>
Doctrine? Current(string unitId);
Task<Doctrine> SetAsync(string unitId, string text, string setBy, string? runId, string reason, CancellationToken ct);
/// <summary>Every version, newest first.</summary>
Task<IReadOnlyList<Doctrine>> HistoryAsync(string unitId, CancellationToken ct);
}
/// <summary>
/// #698: What holds in a unit right now — a third text beside Identity (who) and Soul (how),
/// set by the unit's lead without asking (Organisation-Konzept, section 8).
///
/// <para><b>One per unit, one level deep.</b> It reaches the members of its unit and stops
/// there: a team below has its own lead, who writes its own. Five levels would otherwise put
/// five doctrines in every prompt, and nobody could tell which rule comes from where.</para>
///
/// <para><b>Short and capped</b> (<see cref="MaxLength"/>): it goes into every run of every
/// member. <b>Seldom and visible:</b> every version is kept with its author, run and reason —
/// frequent changes are a finding, observed rather than prevented.</para>
/// </summary>
public sealed class SqliteDoctrineStore(SqliteStorage storage) : IDoctrineStore
{
/// <summary>Five agents, forty runs a day, a thousand tokens each — two hundred thousand tokens for one text. So: short.</summary>
public const int MaxLength = 2_000;
private readonly ConcurrentDictionary<string, Doctrine?> _current = new(StringComparer.OrdinalIgnoreCase);
public Doctrine? Current(string unitId)
=> _current.GetOrAdd(unitId, id => LatestAsync(id, CancellationToken.None).GetAwaiter().GetResult());
public async Task<Doctrine> SetAsync(string unitId, string text, string setBy, string? runId, string reason, CancellationToken ct)
{
text = (text ?? "").Trim();
if (text.Length > MaxLength)
throw new ArgumentException($"A doctrine has at most {MaxLength} characters; this one has {text.Length}.");
var doctrine = await storage.WriteAsync(async conn =>
{
using var cmd = conn.CreateCommand();
cmd.CommandText = """
INSERT INTO Doctrines (UnitId, Version, Text, SetBy, SetAt, RunId, Reason)
VALUES (@unit, COALESCE((SELECT MAX(Version) FROM Doctrines WHERE UnitId = @unit), 0) + 1,
@text, @by, @at, @run, @reason)
RETURNING Version
""";
var at = DateTime.UtcNow;
cmd.Parameters.AddWithValue("@unit", unitId);
cmd.Parameters.AddWithValue("@text", text);
cmd.Parameters.AddWithValue("@by", setBy);
cmd.Parameters.AddWithValue("@at", at.ToString("O"));
cmd.Parameters.AddWithValue("@run", (object?)runId ?? DBNull.Value);
cmd.Parameters.AddWithValue("@reason", reason ?? "");
var version = Convert.ToInt32(await cmd.ExecuteScalarAsync(ct));
return new Doctrine(unitId, version, text, setBy, at, runId, reason ?? "");
}, ct);
_current[unitId] = doctrine.IsEmpty ? null : doctrine;
return doctrine;
}
public async Task<IReadOnlyList<Doctrine>> HistoryAsync(string unitId, CancellationToken ct)
{
await using var conn = await storage.OpenConnectionAsync(ct);
using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT UnitId, Version, Text, SetBy, SetAt, RunId, Reason FROM Doctrines WHERE UnitId = @unit ORDER BY Version DESC";
cmd.Parameters.AddWithValue("@unit", unitId);
var result = new List<Doctrine>();
await using var reader = await cmd.ExecuteReaderAsync(ct);
while (await reader.ReadAsync(ct))
result.Add(new Doctrine(reader.GetString(0), reader.GetInt32(1), reader.GetString(2), reader.GetString(3),
DateTime.Parse(reader.GetString(4), CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind),
reader.IsDBNull(5) ? null : reader.GetString(5), reader.GetString(6)));
return result;
}
private async Task<Doctrine?> LatestAsync(string unitId, CancellationToken ct)
=> (await HistoryAsync(unitId, ct)).FirstOrDefault() is { IsEmpty: false } latest ? latest : null;
}
@@ -27,7 +27,9 @@ public static class OrgBriefing
/// <param name="atTop">#690: The agent is the deciding instance at the top — it gets that
/// section instead, with or without units.</param>
public static string? For(OrgChart org, string agentId, DateTime nowUtc, AgentTrust? trust = null, bool canEscalate = false, bool atTop = false)
/// <param name="doctrine">#698: The doctrine of a unit, if it has one.</param>
public static string? For(OrgChart org, string agentId, DateTime nowUtc, AgentTrust? trust = null, bool canEscalate = false, bool atTop = false,
Func<string, Doctrine?>? doctrine = null)
{
if (atTop && org.Agent(agentId) is not null)
return ForTop(org);
@@ -55,6 +57,14 @@ public static class OrgBriefing
if (!string.IsNullOrWhiteSpace(unit.Goal))
sb.AppendLine($"Goal of your unit: {unit.Goal.Trim()}");
// #698: Only the own unit's doctrine — one level deep, never the units above.
if (doctrine?.Invoke(unit.Id) is { IsEmpty: false } holds)
{
sb.AppendLine($"Doctrine of your unit — what holds here now (set by {holds.SetBy} on {holds.SetAt:yyyy-MM-dd}). " +
"It frames your tasks; if a task contradicts it, do not choose silently — escalate:");
sb.AppendLine(holds.Text.Trim());
}
}
var lead = org.EffectiveLeaderOf(agentId);
@@ -66,6 +76,9 @@ public static class OrgBriefing
if (org.LedBy(agentId) is { } led)
{
sb.AppendLine($"You lead the unit \"{led.Name}\". Delegate within it, and hand what goes beyond it to your own lead.");
if (canEscalate)
sb.AppendLine("You set your unit's doctrine (Escalation tool, doctrine_set): what holds in it now, short, changed seldom. " +
"It reaches the members of your unit only — sub-units have their own.");
// #687: What the unit lets its lead approve — told, so an approval task is no surprise.
if (led.Approvals is { Operations.Count: > 0 } approvals && canEscalate)
sb.AppendLine($"Your unit lets you approve, for the agents below you: {string.Join(", ", approvals.Operations)} " +
@@ -284,6 +284,18 @@ public sealed class SqliteStorage
-- Staging (A2): eingefrorene, freigabepflichtige Tool-Aufrufe. Ausgeführt wird
-- genau der gespeicherte Argument-JSON (Plan-Freeze).
-- #698: a unit's doctrine, every version kept — who, when, which run, why.
CREATE TABLE IF NOT EXISTS Doctrines (
UnitId TEXT NOT NULL,
Version INTEGER NOT NULL,
Text TEXT NOT NULL,
SetBy TEXT NOT NULL,
SetAt TEXT NOT NULL,
RunId TEXT NULL,
Reason TEXT NOT NULL DEFAULT '',
PRIMARY KEY (UnitId, Version)
);
-- #699: cost alerts, once per period (day, or month for a threshold), scope and kind.
CREATE TABLE IF NOT EXISTS CostAlerts (
Id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -52,6 +52,9 @@ public sealed record AgentToolContext(
/// <summary>#687: Approvals a lead decides for the agents below it. <c>null</c> outside the engine.</summary>
public Staging.ILeadApprovals? Approvals { get; init; }
/// <summary>#698: The units' doctrines. <c>null</c> outside the engine.</summary>
public Organisation.IDoctrineStore? Doctrines { get; init; }
/// <summary>#654: Where code runs — never in the main process. Unavailable under Windows.</summary>
public Sandbox.ISandboxRunner? Sandbox { get; init; }
+7
View File
@@ -90,6 +90,13 @@ public interface IAgentTool
/// </summary>
Staging.StagingDecision? Vorgabe(string operation, IReadOnlyDictionary<string, object?> config) => null;
/// <summary>
/// #698: The classification with the whole context of the call — for a rule that depends
/// on who calls, not only on how the tool is configured (a lead that reads foreign content
/// may not change its unit's doctrine unasked). Default: <see cref="Vorgabe(string, IReadOnlyDictionary{string, object?})"/>.
/// </summary>
Staging.StagingDecision? Vorgabe(string operation, AgentToolContext context) => Vorgabe(operation, context.ToolConfig);
Task<ToolResult> ExecuteAsync(
JsonElement input,
AgentToolContext context,
@@ -100,6 +100,11 @@
"org.approvalsOperations": "Operationen",
"org.approvalsPerDay": "Höchstens je Tag",
"org.approvalsHours": "Stunden je Freigabe",
"org.doctrine": "Doktrin",
"org.doctrineHint": "Was in dieser Einheit gerade gilt — Rahmen für jede Aufgabe ihrer Mitglieder, von ihrer Leitung ohne Rückfrage gesetzt. Sie erreicht nur die Mitglieder dieser Einheit, nicht die Untereinheiten; die haben ihre eigene. Kurz halten: Sie geht in jeden Lauf. Leer entfernt sie.",
"org.doctrinePlaceholder": "Jeder Lieferantenkontakt wird in der Datenbank erfasst, bevor er weitergegeben wird.",
"org.doctrineFrequent": "In den letzten 7 Tagen {count}-mal geändert — eine Doktrin, die oft wechselt, ist Mikromanagement mit Tokenkosten.",
"org.doctrineRemoved": "(entfernt)",
"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.",
@@ -100,6 +100,11 @@
"org.approvalsOperations": "Operations",
"org.approvalsPerDay": "Per day at most",
"org.approvalsHours": "Hours per approval",
"org.doctrine": "Doctrine",
"org.doctrineHint": "What holds in this unit now — a frame for every task of its members, set by its lead without asking. It reaches this unit's members only, not the sub-units; they have their own. Short: it goes into every run. Empty removes it.",
"org.doctrinePlaceholder": "Every supplier contact is entered in the database before it is passed on.",
"org.doctrineFrequent": "Changed {count} times in the last 7 days — a doctrine that changes often is micro-management with token costs.",
"org.doctrineRemoved": "(removed)",
"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.",
@@ -367,6 +367,7 @@ function unitPanel(unit) {
cards.push(unitWorkspaceCard(unit));
cards.push(ceilingCard(unit));
cards.push(approvalsCard(unit));
cards.push(doctrineCard(unit));
cards.push(settingsCard(unit));
// Members.
@@ -462,6 +463,46 @@ function approvalsCard(unit) {
]);
}
/**
* #698: What holds in the unit now — set by its lead, readable and settable here. It reaches
* the unit's members only, not the sub-units. Every version stays; frequent changes are marked,
* because a doctrine that changes weekly is micro-management with token costs.
*/
function doctrineCard(unit) {
const text = erstelleElement("textarea", { rows: 5, maxlength: "2000", placeholder: t("org.doctrinePlaceholder") });
const history = erstelleElement("div", { klasse: "org-doktrin-verlauf" });
const counter = erstelleElement("span", { klasse: "gedaempft text-klein" });
const sync = () => { counter.textContent = `${text.value.length} / 2000`; };
text.addEventListener("input", sync);
(async () => {
let versions = [];
try { versions = await api(`/organisation/units/${encodeURIComponent(unit.id)}/doctrine`); } catch { versions = []; }
text.value = versions[0]?.text ?? "";
sync();
const weekAgo = Date.now() - 7 * 24 * 3600 * 1000;
const recent = versions.filter((v) => new Date(v.setAt).getTime() >= weekAgo).length;
if (recent >= 3) history.append(erstelleElement("p", { klasse: "hinweis-box warnung", text: t("org.doctrineFrequent", { count: recent }) }));
for (const v of versions.slice(0, 10)) {
history.append(erstelleElement("details", { klasse: "org-doktrin-version" },
erstelleElement("summary", { text: `v${v.version} · ${v.setBy === "human" ? t("cases.humanAuthor") : v.setBy} · ${formatiereDatumZeit(v.setAt)}${v.reason ? " · " + v.reason : ""}` }),
erstelleElement("pre", { klasse: "code-block", text: v.text || t("org.doctrineRemoved") })));
}
})();
return erstelleKarte(t("org.doctrine"), [
erstelleElement("p", { klasse: "gedaempft text-klein", text: t("org.doctrineHint") }),
text,
erstelleElement("div", { klasse: "gruppe-horizontal" },
counter,
erstelleElement("button", {
type: "button", klasse: "klein", text: t("common.save"),
onclick: () => send(`/organisation/units/${encodeURIComponent(unit.id)}/doctrine`, "PUT", { text: text.value, reason: reason() })
})),
history
]);
}
function settingsCard(unit) {
const setting = erstelleElement("select", {},
...SETTINGS.map((s) => erstelleElement("option", { value: s, text: t(`org.setting.${s}`) })));
@@ -3,6 +3,8 @@ using System.Text.Json;
using System.Globalization;
using ClawdDotNet.Core.Budget;
using ClawdDotNet.Core.Cases;
using ClawdDotNet.Core.Organisation;
using ClawdDotNet.Core.Staging;
using ClawdDotNet.Core.Tasks;
using ClawdDotNet.Core.Tools;
@@ -31,6 +33,8 @@ public sealed class EscalationTool : IAgentTool
- state_position: answer a request for your position or a statement.
After the deadline a human decides.
- request_budget: a special budget for one large task and its parts (task, amount_usd, reasoning). A human always decides; it does not raise your daily limit.
- doctrine_read: the doctrine of your unit — what holds there now.
As a lead: doctrine_set (text, reason) sets your unit's doctrine — short, changed seldom; empty text removes it. Every version is kept.
As a lead whose unit may approve: list_approvals shows the calls waiting for you; approve_call (call) or reject_call (call, reason). Never your own calls; if unsure, leave it to the human.
""";
@@ -40,7 +44,7 @@ public sealed class EscalationTool : IAgentTool
"properties": {
"action": {
"type": "string",
"enum": ["find_decisions", "escalate", "list_cases", "decide", "handle", "forward", "open_conflict", "state_position", "request_budget", "list_approvals", "approve_call", "reject_call"]
"enum": ["find_decisions", "escalate", "list_cases", "decide", "handle", "forward", "open_conflict", "state_position", "request_budget", "list_approvals", "approve_call", "reject_call", "doctrine_read", "doctrine_set"]
},
"amount_usd": { "type": "number", "description": "request_budget: the amount for the task and all its parts, in USD." },
"subject": { "type": "string", "description": "What it is about, in a few words — like a title. escalate, open_conflict, find_decisions." },
@@ -57,12 +61,21 @@ public sealed class EscalationTool : IAgentTool
"with": { "type": "string", "description": "open_conflict: the agent you disagree with." },
"position": { "type": "string", "description": "open_conflict, state_position: your side, with reasons." },
"call": { "type": "integer", "description": "approve_call, reject_call: the approval number (#…)." },
"reason": { "type": "string", "description": "reject_call: why — the agent must know what to change." }
"reason": { "type": "string", "description": "reject_call: why — the agent must know what to change. doctrine_set: why the doctrine changes." },
"text": { "type": "string", "description": "doctrine_set: what holds in your unit now. At most 2000 characters; empty removes it." }
},
"required": ["action"]
}
""").RootElement.Clone();
/// <summary>
/// #698: A lead that reads content from outside may still set its unit's doctrine — but not
/// unasked: the doctrine stands in every prompt of every member, the most convenient way to
/// slip something to a whole unit from outside. A human approves it first.
/// </summary>
public StagingDecision? Vorgabe(string operation, AgentToolContext context)
=> operation == "doctrine_set" && context.Trust.ReadsForeignContent ? StagingDecision.Approve : null;
/// <summary>#717: A request that cannot be granted is refused before it reaches a human.</summary>
public string? Preflight(JsonElement input, AgentToolContext context)
=> Get(input, "action") == "request_budget" ? CheckBudgetRequest(input, context, out _, out _) : null;
@@ -73,6 +86,10 @@ public sealed class EscalationTool : IAgentTool
if (Get(input, "action") == "request_budget")
return await SetUpBudgetAsync(input, context, ct);
// #698: The unit's doctrine.
if (Get(input, "action") is "doctrine_read" or "doctrine_set")
return await DoctrineAsync(input, context, ct);
// #687: A lead deciding the approvals its unit grants it.
if (Get(input, "action") is "list_approvals" or "approve_call" or "reject_call")
return await ApprovalAsync(input, context, ct);
@@ -227,6 +244,35 @@ public sealed class EscalationTool : IAgentTool
return decided.Ok ? ToolResult.Ok(decided.Message) : ToolResult.Fail(decided.Message);
}
private static async Task<ToolResult> DoctrineAsync(JsonElement input, AgentToolContext context, CancellationToken ct)
{
if (context.Doctrines is not { } doctrines || context.Organisation is not { HasUnits: true } org)
return ToolResult.Fail("Doctrines exist only in an instance with units.");
if (Get(input, "action") == "doctrine_read")
{
var unit = org.LedBy(context.AgentId) ?? org.UnitOf(context.AgentId);
if (unit is null)
return ToolResult.Fail("You belong to no unit, so no doctrine applies to you.");
return ToolResult.Ok(doctrines.Current(unit.Id) is { } holds
? $"Doctrine of \"{unit.Name}\" (version {holds.Version}, set by {holds.SetBy} on {holds.SetAt:yyyy-MM-dd}):\n{holds.Text}"
: $"\"{unit.Name}\" has no doctrine.");
}
if (org.LedBy(context.AgentId) is not { } led)
return ToolResult.Fail("Only the lead of a unit sets its doctrine. Raise what should hold with your lead.");
var text = Get(input, "text") ?? "";
if (text.Trim().Length > SqliteDoctrineStore.MaxLength)
return ToolResult.Fail($"A doctrine has at most {SqliteDoctrineStore.MaxLength} characters — it goes into every run of every member. Shorten it.");
if (string.IsNullOrWhiteSpace(Get(input, "reason")))
return ToolResult.Fail("'reason' is required — why the doctrine changes. Every version is kept with it.");
var set = await doctrines.SetAsync(led.Id, text, context.AgentId, context.RunId, Get(input, "reason")!.Trim(), ct);
return ToolResult.Ok(set.IsEmpty
? $"The doctrine of \"{led.Name}\" is removed (version {set.Version})."
: $"Doctrine of \"{led.Name}\" set (version {set.Version}). It reaches the members of your unit from their next run on — not the sub-units.");
}
private static string Describe(Decision d)
=> CaseDesk.DecisionLine(d) + (string.IsNullOrWhiteSpace(d.Reasoning) ? "" : $"\n Reasoning: {d.Reasoning}");
@@ -22,6 +22,11 @@ public sealed record UnitDto(
// #687: what the lead may approve instead of the human; null: nothing.
UnitApprovalsDto? Approvals = null);
/// <summary>#698: One version of a unit's doctrine.</summary>
public sealed record DoctrineDto(int Version, string Text, string SetBy, DateTime SetAt, string? RunId, string Reason);
public sealed record DoctrineInputDto(string? Text, string? Reason);
public sealed record UnitApprovalsDto(IReadOnlyList<string> Operations, int PerDay, double Hours);
public sealed record ApprovalsInputDto(IReadOnlyList<string>? Operations, int? PerDay, double? Hours, string Reason = "");
@@ -81,6 +81,28 @@ internal static class OrganisationEndpoints
? Results.Ok(ToDto(host.Organisation.PreviewMoveAgent(agentId, input.Target)))
: host.Organisation.MoveAgent(agentId, input.Target, input.Reason, input.DropExcessTools).ToResponse());
// #698: A unit's doctrine — every version, and the human setting it.
org.MapGet("/units/{unitId}/doctrine", async (AppHost host, string unitId, CancellationToken ct) =>
{
if (host.Doctrines is not { } store)
return Results.Ok(Array.Empty<DoctrineDto>());
return Results.Ok((await store.HistoryAsync(unitId, ct))
.Select(d => new DoctrineDto(d.Version, d.Text, d.SetBy, d.SetAt, d.RunId, d.Reason)).ToList());
});
org.MapPut("/units/{unitId}/doctrine", async (AppHost host, string unitId, DoctrineInputDto input, CancellationToken ct) =>
{
if (host.Doctrines is not { } store)
return Results.BadRequest(new OutcomeDto(false, "Doctrines are not available in this instance."));
if (host.Organisation.Current().Unit(unitId) is not { } unit)
return Results.BadRequest(new OutcomeDto(false, $"There is no unit '{unitId}'."));
var text = (input.Text ?? "").Trim();
if (text.Length > Core.Organisation.SqliteDoctrineStore.MaxLength)
return Results.BadRequest(new OutcomeDto(false, $"A doctrine has at most {Core.Organisation.SqliteDoctrineStore.MaxLength} characters."));
var set = await store.SetAsync(unit.Id, text, "human", null, input.Reason ?? "", ct);
return Results.Ok(new OutcomeDto(true, set.IsEmpty ? $"Doctrine of '{unit.Name}' removed." : $"Doctrine of '{unit.Name}' set (version {set.Version})."));
});
// #687: What the lead may approve instead of the human.
org.MapPut("/units/{unitId}/approvals", (AppHost host, string unitId, ApprovalsInputDto input) =>
host.Organisation.SetApprovals(unitId, input.Operations, input.PerDay, input.Hours, input.Reason).ToResponse());