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,39 @@
using Pgvector;
using TeamUp.SharedKernel.Ai;
using TeamUp.SharedKernel.Domain;
namespace TeamUp.Modules.Memory.Domain;
/// <summary>
/// One unit of team working memory: a decision, approval, or correction. Embedded for pgvector
/// similarity so the assembler can recall the most relevant entries at prompt time.
/// </summary>
internal sealed class MemoryEntry : Entity
{
public Guid TeamId { get; private set; }
public MemoryKind Kind { get; private set; }
public string Content { get; private set; } = null!;
public Vector Embedding { get; private set; } = null!;
public Guid? SourceReviewItemId { get; private set; }
public DateTimeOffset CreatedAtUtc { get; private set; }
private MemoryEntry()
{
}
public MemoryEntry(
Guid teamId,
MemoryKind kind,
string content,
Vector embedding,
Guid? sourceReviewItemId,
DateTimeOffset createdAtUtc)
{
TeamId = teamId;
Kind = kind;
Content = content;
Embedding = embedding;
SourceReviewItemId = sourceReviewItemId;
CreatedAtUtc = createdAtUtc;
}
}
@@ -1,27 +1,52 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using TeamUp.Modules.Memory.Persistence;
using TeamUp.Modules.Memory.Services;
using TeamUp.SharedKernel.Ai;
using TeamUp.SharedKernel.Modularity;
using TeamUp.SharedKernel.Persistence;
namespace TeamUp.Modules.Memory;
/// <summary>Team-scoped working memory: read at assembly, written on approval (M6, pgvector).</summary>
/// <summary>Team-scoped working memory: written on approval, read at assembly (pgvector, M6).</summary>
public sealed class MemoryModule : IModule
{
public string Name => "memory";
public void Register(IServiceCollection services, IConfiguration configuration)
{
// Skeleton: no services yet. M6 introduces this module's (internal) DbContext with a
// pgvector-backed MemoryEntry table and the working-memory read/write services.
var connectionString = configuration.GetConnectionString("Postgres")
?? throw new InvalidOperationException("Missing connection string 'ConnectionStrings:Postgres'.");
services.AddDbContext<MemoryDbContext>(options => options.UseNpgsql(connectionString, npgsql => npgsql.UseVector()));
services.AddScoped<IModuleDbContext>(sp => sp.GetRequiredService<MemoryDbContext>());
services.TryAddSingleton<ITextEmbedder, HashingTextEmbedder>();
services.AddScoped<ITeamMemory, TeamMemory>();
services.TryAddSingleton(TimeProvider.System);
}
public void MapEndpoints(IEndpointRouteBuilder endpoints)
{
endpoints.MapGroup($"/api/{Name}")
.WithTags("Memory")
.MapGet("/ping", () => TypedResults.Ok(new ModulePing(Name)));
var group = endpoints.MapGroup($"/api/{Name}").WithTags("Memory");
group.MapGet("/ping", () => TypedResults.Ok(new ModulePing(Name)));
group.MapGet("/search", Search).RequireAuthorization();
}
private static async Task<IResult> Search(
Guid teamId, string q, int? take, ITeamMemory memory, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(q))
{
return Results.BadRequest("q is required.");
}
var hits = await memory.SearchAsync(teamId, q, take ?? 3, ct);
return Results.Ok(hits);
}
}
@@ -0,0 +1,26 @@
using Microsoft.EntityFrameworkCore;
using TeamUp.Modules.Memory.Domain;
using TeamUp.SharedKernel.Persistence;
namespace TeamUp.Modules.Memory.Persistence;
internal sealed class MemoryDbContext(DbContextOptions<MemoryDbContext> options)
: DbContext(options), IModuleDbContext
{
public DbSet<MemoryEntry> Entries => Set<MemoryEntry>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.HasDefaultSchema("memory");
modelBuilder.Entity<MemoryEntry>(entry =>
{
entry.ToTable("memory_entries");
entry.HasKey(e => e.Id);
entry.Property(e => e.Kind).HasConversion<string>().HasMaxLength(20);
entry.Property(e => e.Content).IsRequired();
entry.Property(e => e.Embedding).HasColumnType("vector(384)");
entry.HasIndex(e => new { e.TeamId, e.CreatedAtUtc });
});
}
}
@@ -0,0 +1,21 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
namespace TeamUp.Modules.Memory.Persistence;
/// <summary>Design-time factory so `dotnet ef` can build the internal context (with the pgvector handler).</summary>
internal sealed class MemoryDbContextFactory : IDesignTimeDbContextFactory<MemoryDbContext>
{
public MemoryDbContext CreateDbContext(string[] args)
{
var connectionString =
Environment.GetEnvironmentVariable("ConnectionStrings__Postgres")
?? "Host=localhost;Port=5432;Database=teamup;Username=teamup;Password=teamup";
var options = new DbContextOptionsBuilder<MemoryDbContext>()
.UseNpgsql(connectionString, npgsql => npgsql.UseVector())
.Options;
return new MemoryDbContext(options);
}
}
@@ -0,0 +1,67 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using Pgvector;
using TeamUp.Modules.Memory.Persistence;
#nullable disable
namespace TeamUp.Modules.Memory.Persistence.Migrations
{
[DbContext(typeof(MemoryDbContext))]
[Migration("20260610082324_InitialMemory")]
partial class InitialMemory
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("memory")
.HasAnnotation("ProductVersion", "10.0.8")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("TeamUp.Modules.Memory.Domain.MemoryEntry", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Content")
.IsRequired()
.HasColumnType("text");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<Vector>("Embedding")
.IsRequired()
.HasColumnType("vector(384)");
b.Property<string>("Kind")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<Guid?>("SourceReviewItemId")
.HasColumnType("uuid");
b.Property<Guid>("TeamId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("TeamId", "CreatedAtUtc");
b.ToTable("memory_entries", "memory");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,51 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Pgvector;
#nullable disable
namespace TeamUp.Modules.Memory.Persistence.Migrations
{
/// <inheritdoc />
public partial class InitialMemory : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.EnsureSchema(
name: "memory");
migrationBuilder.CreateTable(
name: "memory_entries",
schema: "memory",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
TeamId = table.Column<Guid>(type: "uuid", nullable: false),
Kind = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
Content = table.Column<string>(type: "text", nullable: false),
Embedding = table.Column<Vector>(type: "vector(384)", nullable: false),
SourceReviewItemId = table.Column<Guid>(type: "uuid", nullable: true),
CreatedAtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_memory_entries", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_memory_entries_TeamId_CreatedAtUtc",
schema: "memory",
table: "memory_entries",
columns: new[] { "TeamId", "CreatedAtUtc" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "memory_entries",
schema: "memory");
}
}
}
@@ -0,0 +1,64 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using Pgvector;
using TeamUp.Modules.Memory.Persistence;
#nullable disable
namespace TeamUp.Modules.Memory.Persistence.Migrations
{
[DbContext(typeof(MemoryDbContext))]
partial class MemoryDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("memory")
.HasAnnotation("ProductVersion", "10.0.8")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("TeamUp.Modules.Memory.Domain.MemoryEntry", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Content")
.IsRequired()
.HasColumnType("text");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<Vector>("Embedding")
.IsRequired()
.HasColumnType("vector(384)");
b.Property<string>("Kind")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<Guid?>("SourceReviewItemId")
.HasColumnType("uuid");
b.Property<Guid>("TeamId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("TeamId", "CreatedAtUtc");
b.ToTable("memory_entries", "memory");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,39 @@
using Microsoft.EntityFrameworkCore;
using Pgvector;
using Pgvector.EntityFrameworkCore;
using TeamUp.Modules.Memory.Domain;
using TeamUp.Modules.Memory.Persistence;
using TeamUp.SharedKernel.Ai;
namespace TeamUp.Modules.Memory.Services;
/// <summary>Working memory: embed-and-store on write; cosine-similarity recall on read.</summary>
internal sealed class TeamMemory(MemoryDbContext db, ITextEmbedder embedder, TimeProvider clock) : ITeamMemory
{
public async Task WriteAsync(
Guid teamId,
MemoryKind kind,
string content,
Guid? sourceReviewItemId = null,
CancellationToken cancellationToken = default)
{
var embedding = new Vector(embedder.Embed(content));
db.Entries.Add(new MemoryEntry(teamId, kind, content, embedding, sourceReviewItemId, clock.GetUtcNow()));
await db.SaveChangesAsync(cancellationToken);
}
public async Task<IReadOnlyList<MemoryHit>> SearchAsync(
Guid teamId,
string query,
int take = 3,
CancellationToken cancellationToken = default)
{
var probe = new Vector(embedder.Embed(query));
return await db.Entries
.Where(e => e.TeamId == teamId)
.OrderBy(e => e.Embedding.CosineDistance(probe))
.Take(Math.Clamp(take, 1, 10))
.Select(e => new MemoryHit(e.Id, e.Kind, e.Content, e.CreatedAtUtc))
.ToListAsync(cancellationToken);
}
}
@@ -1,10 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<!-- A self-contained module. References SharedKernel ONLY (ASP.NET flows transitively for the
IModule seam). M1 adds this module's EF/Npgsql/FluentValidation/Mapperly packages when it
gains an (internal) DbContext and validators. It must never reference another module. -->
<!-- Team-scoped working memory (M6): MemoryEntry rows with pgvector embeddings — written on
approval (Governance via ITeamMemory), read at prompt assembly (Assembler). References
SharedKernel only; never another module. -->
<ItemGroup>
<ProjectReference Include="..\..\Shared\TeamUp.SharedKernel\TeamUp.SharedKernel.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" PrivateAssets="all" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
<PackageReference Include="Pgvector.EntityFrameworkCore" />
</ItemGroup>
</Project>