diff --git a/docs/Organisation-Konzept.md b/docs/Organisation-Konzept.md
index 2d5c737..15a3af1 100644
--- a/docs/Organisation-Konzept.md
+++ b/docs/Organisation-Konzept.md
@@ -1071,6 +1071,18 @@ Ansonsten darf die Leitung sie ändern, ohne zu fragen. Das ist der Unterschied
Identity und Soul, und er ist gewollt: Eine Leitung, die für jede Anweisung an ihre
eigene Abteilung einen Menschen braucht, ist keine Leitung.
+> **Umgesetzt (#698), mit zwei Abweichungen vom Wortlaut.** Die Doctrine ist **keine
+> Datei**, sondern steht je Einheit versioniert in der Instanz-DB (`Doctrines`): eine Datei
+> im Arbeitsbereich ließe sich mit FileRW an allen Regeln vorbei ändern, und die Versionen
+> mit Autor, Lauf und Grund wären verloren. Und die Belastung wird an der **Vertrauensstufe
+> der Leitung** gemessen (#715), nicht am einzelnen Lauf — eine Leitung mit Werkzeugen für
+> Fremdinhalte legt `doctrine_set` immer zur Freigabe vor (neue Werkzeugregel
+> `IAgentTool.Vorgabe(operation, context)`). Gesetzt wird mit dem Escalation-Werkzeug
+> (`doctrine_set` mit Grund, `doctrine_read`), höchstens 2000 Zeichen, leer entfernt sie.
+> Sie steht im Organisationsabschnitt jedes Mitglieds der Einheit, nicht der Untereinheiten,
+> mit dem Satz, dass ein Widerspruch zur Aufgabe eskaliert wird. Der Mensch sieht und setzt
+> sie in der Organisationsansicht; drei und mehr Änderungen in sieben Tagen sind markiert.
+
### A/B: welches Modell macht es besser oder billiger?
Die Eignungsprobe (#681) beantwortet „kann dieses Modell dieses Werkzeug bedienen?" —
diff --git a/src/ClawdDotNet.App/AppHost.cs b/src/ClawdDotNet.App/AppHost.cs
index 4e80e25..2e1f84e 100644
--- a/src/ClawdDotNet.App/AppHost.cs
+++ b/src/ClawdDotNet.App/AppHost.cs
@@ -132,6 +132,9 @@ public sealed class AppHost : IAsyncDisposable
/// #699: Costs per unit and the alerts on them.
public Core.Accounting.CostWatch? CostWatch { get; private set; }
+ /// #698: The units' doctrines.
+ public Core.Organisation.IDoctrineStore? Doctrines { get; private set; }
+
/// Modellpreise von OpenRouter - fuer Budget und Kostenprognose (#595). Laedt im Hintergrund.
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,
diff --git a/src/ClawdDotNet.Core/Engine/AgentEngine.cs b/src/ClawdDotNet.Core/Engine/AgentEngine.cs
index 48f9a9e..fb8664d 100644
--- a/src/ClawdDotNet.Core/Engine/AgentEngine.cs
+++ b/src/ClawdDotNet.Core/Engine/AgentEngine.cs
@@ -54,6 +54,9 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
/// #689: Escalations, conflicts and decisions; set by the host. Tools reach it through their context.
public Cases.ICaseDesk? CaseDesk { get; set; }
+ /// #698: The units' doctrines; set by the host.
+ public Organisation.IDoctrineStore? Doctrines { get; set; }
+
/// #687: Approvals by leads within their unit's limits; set by the host.
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));
///
/// 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
/// #689: Whether agents can raise a case — the desk is set up and its tool registered.
private bool CanEscalate => CaseDesk is not null && _toolRegistry.Get(Cases.CaseDesk.ToolName) is not null;
+ private Organisation.Doctrine? DoctrineOf(string unitId) => Doctrines?.Current(unitId);
+
/// #690: Whether the agent is the deciding instance at the top.
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,
diff --git a/src/ClawdDotNet.Core/Organisation/Doctrine.cs b/src/ClawdDotNet.Core/Organisation/Doctrine.cs
new file mode 100644
index 0000000..3cd7150
--- /dev/null
+++ b/src/ClawdDotNet.Core/Organisation/Doctrine.cs
@@ -0,0 +1,93 @@
+using System.Collections.Concurrent;
+using System.Globalization;
+using ClawdDotNet.Core.Storage;
+
+namespace ClawdDotNet.Core.Organisation;
+
+/// One version of a unit's doctrine — who set it, when, in which run, and why.
+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
+{
+ /// The doctrine that holds now; null when the unit has none. Read on every run — kept in memory.
+ Doctrine? Current(string unitId);
+
+ Task SetAsync(string unitId, string text, string setBy, string? runId, string reason, CancellationToken ct);
+
+ /// Every version, newest first.
+ Task> HistoryAsync(string unitId, CancellationToken ct);
+}
+
+///
+/// #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).
+///
+/// One per unit, one level deep. 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.
+///
+/// Short and capped (): it goes into every run of every
+/// member. Seldom and visible: every version is kept with its author, run and reason —
+/// frequent changes are a finding, observed rather than prevented.
+///
+public sealed class SqliteDoctrineStore(SqliteStorage storage) : IDoctrineStore
+{
+ /// Five agents, forty runs a day, a thousand tokens each — two hundred thousand tokens for one text. So: short.
+ public const int MaxLength = 2_000;
+
+ private readonly ConcurrentDictionary _current = new(StringComparer.OrdinalIgnoreCase);
+
+ public Doctrine? Current(string unitId)
+ => _current.GetOrAdd(unitId, id => LatestAsync(id, CancellationToken.None).GetAwaiter().GetResult());
+
+ public async Task 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> 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();
+ 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 LatestAsync(string unitId, CancellationToken ct)
+ => (await HistoryAsync(unitId, ct)).FirstOrDefault() is { IsEmpty: false } latest ? latest : null;
+}
diff --git a/src/ClawdDotNet.Core/Organisation/OrgBriefing.cs b/src/ClawdDotNet.Core/Organisation/OrgBriefing.cs
index fe4f5e9..c7609bf 100644
--- a/src/ClawdDotNet.Core/Organisation/OrgBriefing.cs
+++ b/src/ClawdDotNet.Core/Organisation/OrgBriefing.cs
@@ -27,7 +27,9 @@ public static class OrgBriefing
/// #690: The agent is the deciding instance at the top — it gets that
/// section instead, with or without units.
- public static string? For(OrgChart org, string agentId, DateTime nowUtc, AgentTrust? trust = null, bool canEscalate = false, bool atTop = false)
+ /// #698: The doctrine of a unit, if it has one.
+ public static string? For(OrgChart org, string agentId, DateTime nowUtc, AgentTrust? trust = null, bool canEscalate = false, bool atTop = false,
+ Func? 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)} " +
diff --git a/src/ClawdDotNet.Core/Storage/SqliteStorage.cs b/src/ClawdDotNet.Core/Storage/SqliteStorage.cs
index 397e42a..6456ed9 100644
--- a/src/ClawdDotNet.Core/Storage/SqliteStorage.cs
+++ b/src/ClawdDotNet.Core/Storage/SqliteStorage.cs
@@ -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,
diff --git a/src/ClawdDotNet.Core/Tools/AgentToolContext.cs b/src/ClawdDotNet.Core/Tools/AgentToolContext.cs
index 760f5aa..7458875 100644
--- a/src/ClawdDotNet.Core/Tools/AgentToolContext.cs
+++ b/src/ClawdDotNet.Core/Tools/AgentToolContext.cs
@@ -52,6 +52,9 @@ public sealed record AgentToolContext(
/// #687: Approvals a lead decides for the agents below it. null outside the engine.
public Staging.ILeadApprovals? Approvals { get; init; }
+ /// #698: The units' doctrines. null outside the engine.
+ public Organisation.IDoctrineStore? Doctrines { get; init; }
+
/// #654: Where code runs — never in the main process. Unavailable under Windows.
public Sandbox.ISandboxRunner? Sandbox { get; init; }
diff --git a/src/ClawdDotNet.Core/Tools/IAgentTool.cs b/src/ClawdDotNet.Core/Tools/IAgentTool.cs
index 91dfb7b..ad7a5c7 100644
--- a/src/ClawdDotNet.Core/Tools/IAgentTool.cs
+++ b/src/ClawdDotNet.Core/Tools/IAgentTool.cs
@@ -90,6 +90,13 @@ public interface IAgentTool
///
Staging.StagingDecision? Vorgabe(string operation, IReadOnlyDictionary config) => null;
+ ///
+ /// #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: .
+ ///
+ Staging.StagingDecision? Vorgabe(string operation, AgentToolContext context) => Vorgabe(operation, context.ToolConfig);
+
Task ExecuteAsync(
JsonElement input,
AgentToolContext context,
diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/i18n/de.json b/src/ClawdDotNet.Manager.Web/wwwroot/i18n/de.json
index f5430e5..1186959 100644
--- a/src/ClawdDotNet.Manager.Web/wwwroot/i18n/de.json
+++ b/src/ClawdDotNet.Manager.Web/wwwroot/i18n/de.json
@@ -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.",
diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/i18n/en.json b/src/ClawdDotNet.Manager.Web/wwwroot/i18n/en.json
index 3cda882..12f83bb 100644
--- a/src/ClawdDotNet.Manager.Web/wwwroot/i18n/en.json
+++ b/src/ClawdDotNet.Manager.Web/wwwroot/i18n/en.json
@@ -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.",
diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/views/organisation.js b/src/ClawdDotNet.Manager.Web/wwwroot/views/organisation.js
index 51d3c42..c89c94b 100644
--- a/src/ClawdDotNet.Manager.Web/wwwroot/views/organisation.js
+++ b/src/ClawdDotNet.Manager.Web/wwwroot/views/organisation.js
@@ -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}`) })));
diff --git a/src/ClawdDotNet.Tools.Taskboard/EscalationTool.cs b/src/ClawdDotNet.Tools.Taskboard/EscalationTool.cs
index cd703da..3533680 100644
--- a/src/ClawdDotNet.Tools.Taskboard/EscalationTool.cs
+++ b/src/ClawdDotNet.Tools.Taskboard/EscalationTool.cs
@@ -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();
+ ///
+ /// #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.
+ ///
+ public StagingDecision? Vorgabe(string operation, AgentToolContext context)
+ => operation == "doctrine_set" && context.Trust.ReadsForeignContent ? StagingDecision.Approve : null;
+
/// #717: A request that cannot be granted is refused before it reaches a human.
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 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}");
diff --git a/src/ClawdDotNet.Web/Contracts/OrganisationDtos.cs b/src/ClawdDotNet.Web/Contracts/OrganisationDtos.cs
index c3706b8..a074ad7 100644
--- a/src/ClawdDotNet.Web/Contracts/OrganisationDtos.cs
+++ b/src/ClawdDotNet.Web/Contracts/OrganisationDtos.cs
@@ -22,6 +22,11 @@ public sealed record UnitDto(
// #687: what the lead may approve instead of the human; null: nothing.
UnitApprovalsDto? Approvals = null);
+/// #698: One version of a unit's doctrine.
+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 Operations, int PerDay, double Hours);
public sealed record ApprovalsInputDto(IReadOnlyList? Operations, int? PerDay, double? Hours, string Reason = "");
diff --git a/src/ClawdDotNet.Web/Endpoints/OrganisationEndpoints.cs b/src/ClawdDotNet.Web/Endpoints/OrganisationEndpoints.cs
index fcc0094..2a311e0 100644
--- a/src/ClawdDotNet.Web/Endpoints/OrganisationEndpoints.cs
+++ b/src/ClawdDotNet.Web/Endpoints/OrganisationEndpoints.cs
@@ -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());
+ 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());
diff --git a/tests/ClawdDotNet.Tools.Tests/Taskboard/DoctrineTests.cs b/tests/ClawdDotNet.Tools.Tests/Taskboard/DoctrineTests.cs
new file mode 100644
index 0000000..3326bdf
--- /dev/null
+++ b/tests/ClawdDotNet.Tools.Tests/Taskboard/DoctrineTests.cs
@@ -0,0 +1,111 @@
+using System.Text.Json;
+using ClawdDotNet.Core.Config;
+using ClawdDotNet.Core.Organisation;
+using ClawdDotNet.Core.Staging;
+using ClawdDotNet.Core.Storage;
+using ClawdDotNet.Core.Tools;
+using ClawdDotNet.Tools.Taskboard;
+using ClawdDotNet.Tools.Tests.FileRW;
+using Microsoft.Extensions.Logging.Abstractions;
+using Shouldly;
+
+namespace ClawdDotNet.Tools.Tests.Taskboard;
+
+///
+/// #698: The lead steers its unit without touching every task text — one level deep, short,
+/// every version kept, and not unasked from a lead that reads foreign content.
+///
+public sealed class DoctrineTests : IDisposable
+{
+ private readonly string _directory = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N"));
+ private readonly SqliteDoctrineStore _store;
+ private readonly EscalationTool _tool = new();
+
+ /// purchasing (buy-lead; buyer) › team (team-lead; clerk).
+ private readonly OrgChart _org = new(
+ [
+ new UnitConfig { Id = "purchasing", Name = "Purchasing", LeaderAgentId = "buy-lead" },
+ new UnitConfig { Id = "team", Name = "Team", ParentId = "purchasing", LeaderAgentId = "team-lead" }
+ ],
+ [
+ new AgentConfig { AgentId = "buy-lead", UnitId = "purchasing" },
+ new AgentConfig { AgentId = "buyer", UnitId = "purchasing" },
+ new AgentConfig { AgentId = "team-lead", UnitId = "team" },
+ new AgentConfig { AgentId = "clerk", UnitId = "team" }
+ ]);
+
+ public DoctrineTests() => _store = new SqliteDoctrineStore(new SqliteStorage(Path.Combine(_directory, "state.db")));
+
+ public void Dispose()
+ {
+ SqliteStorage.ReleasePooledConnections(_directory);
+ try { Directory.Delete(_directory, recursive: true); }
+ catch { /* cleanup is secondary */ }
+ }
+
+ private AgentToolContext Context(string agent, AgentTrust? trust = null)
+ => new(agent, "inst", new Dictionary(), new NullStateStore(), NullLogger.Instance, CancellationToken.None)
+ {
+ Organisation = _org,
+ Doctrines = _store,
+ RunId = "run-" + agent,
+ Trust = trust ?? AgentTrust.Standard
+ };
+
+ private Task Run(string agent, object input)
+ => _tool.ExecuteAsync(JsonSerializer.SerializeToElement(input), Context(agent), CancellationToken.None);
+
+ [Fact]
+ public async Task The_lead_sets_the_doctrine_and_its_members_read_it()
+ {
+ var set = await Run("buy-lead", new { action = "doctrine_set", text = "Every supplier contact goes into the database first.", reason = "Contacts got lost." });
+
+ set.Success.ShouldBeTrue(set.ErrorMessage);
+ (await Run("buyer", new { action = "doctrine_read" })).Content.ShouldContain("supplier contact");
+ _store.Current("purchasing")!.SetBy.ShouldBe("buy-lead");
+ }
+
+ [Fact]
+ public async Task Only_a_lead_sets_it_with_a_reason_and_within_the_length()
+ {
+ (await Run("buyer", new { action = "doctrine_set", text = "x", reason = "y" })).ErrorMessage!.ShouldContain("Only the lead");
+ (await Run("buy-lead", new { action = "doctrine_set", text = "x" })).ErrorMessage!.ShouldContain("'reason'");
+ (await Run("buy-lead", new { action = "doctrine_set", text = new string('x', 2001), reason = "y" })).ErrorMessage!.ShouldContain("2000");
+ }
+
+ [Fact]
+ public async Task It_reaches_one_level_deep_and_no_further()
+ {
+ await _store.SetAsync("purchasing", "Purchasing rule.", "buy-lead", null, "r", default);
+ await _store.SetAsync("team", "Team rule.", "team-lead", null, "r", default);
+
+ var buyer = OrgBriefing.For(_org, "buyer", DateTime.UtcNow, doctrine: _store.Current)!;
+ var clerk = OrgBriefing.For(_org, "clerk", DateTime.UtcNow, doctrine: _store.Current)!;
+
+ buyer.ShouldContain("Purchasing rule.");
+ clerk.ShouldContain("Team rule.");
+ clerk.ShouldNotContain("Purchasing rule.", customMessage: "the team has its own lead and its own doctrine");
+ }
+
+ [Fact]
+ public async Task Every_version_is_kept_and_an_empty_text_removes_it()
+ {
+ await Run("buy-lead", new { action = "doctrine_set", text = "First.", reason = "a" });
+ await Run("buy-lead", new { action = "doctrine_set", text = "", reason = "no longer needed" });
+
+ _store.Current("purchasing").ShouldBeNull();
+ var history = await _store.HistoryAsync("purchasing", default);
+ history.Select(d => d.Version).ShouldBe([2, 1]);
+ history[1].Text.ShouldBe("First.");
+ history[0].RunId.ShouldBe("run-buy-lead");
+ }
+
+ [Fact]
+ public void A_lead_that_reads_foreign_content_needs_an_approval_to_set_it()
+ {
+ _tool.Vorgabe("doctrine_set", Context("buy-lead", new AgentTrust(TrustTier.ReadsForeignContent, ["Mail"])))
+ .ShouldBe(StagingDecision.Approve);
+ _tool.Vorgabe("doctrine_set", Context("buy-lead")).ShouldBeNull("a lead of the standard tier sets it without asking");
+ _tool.Vorgabe("doctrine_read", Context("buy-lead", new AgentTrust(TrustTier.ReadsForeignContent, ["Mail"]))).ShouldBeNull();
+ }
+}