M6: working memory + the PO→QA trigger + analytics — V1 complete

Working memory (Memory module's first real code):
- MemoryEntry (schema "memory", vector(384), InitialMemory migration); TeamMemory implements
  the SharedKernel ITeamMemory seam (embed-and-store on write, cosine recall on read);
  GET /api/memory/search. HashingTextEmbedder promoted to SharedKernel (pure, deterministic;
  swapped for ONNX/BYOK embedders later behind ITextEmbedder).
- Written on approval: Governance's approve stores an Approval/Correction entry per decision.
- Read at assembly: the executor recalls the team's top-3 relevant entries; the prompt gains
  a "# Team memory" section (treated as data, not instructions).

The single V1 event trigger:
- IAgentDispatcher (SharedKernel) implemented by Assembler's AgentRunDispatcher (shared by
  the API and triggers). OrgBoard's QaHandoffTrigger: a task hitting done creates a QA task
  (provenance parent, assigned to the QA agent) and dispatches a run for the team's QA AI
  seat. Guardrails: Test/Review tasks never re-trigger (no self-cascade) and a task hands
  off at most once. Audited as handoff.triggered.

Analytics — the V1 verdict view:
- IBoardStats (SharedKernel) implemented by OrgBoard; GET /api/governance/analytics returns
  approval rate, avg edit distance, per-agent metrics + edit-distance trend, tasks done.
- UI: /analytics — stat cards, per-agent table, recharts edit-distance trend per agent.

Verified: build green; ArchitectureTests 8/8; IntegrationTests 42/42 incl. the M6 acceptance
end to end — a dev marks a story done → Quill wakes via the handoff (QA task with provenance,
assigned to the agent) → drafts a test plan that waits in review → approve records the second
agent's edit distance → analytics show approval rate 100%, avg edit distance > 0, and trends
for BOTH Aria and Quill; memory written on Aria's corrected approval is recalled into her next
prompt; the guardrails hold. Client build green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-10 12:07:35 +03:30
parent 21cfc35581
commit fe7a5c481e
28 changed files with 1187 additions and 24 deletions
@@ -0,0 +1,12 @@
namespace TeamUp.SharedKernel.Ai;
/// <summary>
/// Dispatches a task to an AI seat: records a queued AgentRun and enqueues the job for the worker.
/// Implemented by the Assembler module; used by the web API and by board triggers (the PO→QA
/// handoff) without referencing the Assembler's tables.
/// </summary>
public interface IAgentDispatcher
{
/// <summary>Returns the id of the queued run.</summary>
Task<Guid> DispatchAsync(Guid seatId, Guid workItemId, CancellationToken cancellationToken = default);
}
@@ -0,0 +1,31 @@
namespace TeamUp.SharedKernel.Ai;
public enum MemoryKind
{
Decision,
Approval,
Correction,
}
public sealed record MemoryHit(Guid Id, MemoryKind Kind, string Content, DateTimeOffset CreatedAtUtc);
/// <summary>
/// Team-scoped working memory: written when a human approves (or corrects) agent work, read at
/// prompt assembly via pgvector similarity. Implemented by the Memory module. Strictly isolated
/// per team — institutional knowledge is the moat.
/// </summary>
public interface ITeamMemory
{
Task WriteAsync(
Guid teamId,
MemoryKind kind,
string content,
Guid? sourceReviewItemId = null,
CancellationToken cancellationToken = default);
Task<IReadOnlyList<MemoryHit>> SearchAsync(
Guid teamId,
string query,
int take = 3,
CancellationToken cancellationToken = default);
}
@@ -0,0 +1,69 @@
namespace TeamUp.SharedKernel.Ai;
/// <summary>Embeds text into a fixed-dimension vector for pgvector similarity search.</summary>
public interface ITextEmbedder
{
int Dimensions { get; }
float[] Embed(string text);
}
/// <summary>
/// Deterministic placeholder embedder (L2-normalized hashed bag-of-tokens) so pgvector similarity
/// is REAL before a model-based embedder lands. 384 dimensions to match the intended MiniLM/bge
/// ONNX models (air-gapped) or BYOK embedding APIs, so columns survive the swap. Pure logic —
/// safe to live in SharedKernel and share across modules.
/// </summary>
public sealed class HashingTextEmbedder : ITextEmbedder
{
private static readonly char[] Separators =
[' ', '\n', '\t', ',', '.', ':', ';', '(', ')', '[', ']', '{', '}', '/', '\\', '"', '\'', '#', '-', '_', '*', '`', '!', '?'];
public int Dimensions => 384;
public float[] Embed(string text)
{
var vector = new float[Dimensions];
if (string.IsNullOrWhiteSpace(text))
{
return vector;
}
foreach (var token in text.ToLowerInvariant().Split(Separators, StringSplitOptions.RemoveEmptyEntries))
{
vector[Hash(token) % Dimensions] += 1f;
}
var norm = 0f;
foreach (var value in vector)
{
norm += value * value;
}
norm = MathF.Sqrt(norm);
if (norm > 0f)
{
for (var i = 0; i < vector.Length; i++)
{
vector[i] /= norm;
}
}
return vector;
}
private static uint Hash(string token)
{
unchecked
{
var hash = 2166136261u;
foreach (var c in token)
{
hash ^= c;
hash *= 16777619u;
}
return hash;
}
}
}
@@ -0,0 +1,14 @@
namespace TeamUp.SharedKernel.Board;
/// <summary>
/// Read-only board statistics + agent display names for the analytics view. Implemented by
/// OrgBoard; consumed by Governance's analytics endpoint without touching OrgBoard's tables.
/// </summary>
public interface IBoardStats
{
Task<int> CountDoneTasksAsync(Guid organizationId, CancellationToken cancellationToken = default);
Task<IReadOnlyDictionary<Guid, string>> GetAgentNamesAsync(
IReadOnlyCollection<Guid> agentIds,
CancellationToken cancellationToken = default);
}