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
@@ -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;
/// <summary>
/// #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.
/// </summary>
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();
/// <summary>purchasing (buy-lead; buyer) › team (team-lead; clerk).</summary>
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<string, object?>(), new NullStateStore(), NullLogger.Instance, CancellationToken.None)
{
Organisation = _org,
Doctrines = _store,
RunId = "run-" + agent,
Trust = trust ?? AgentTrust.Standard
};
private Task<ToolResult> 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();
}
}