diff --git a/Directory.Packages.props b/Directory.Packages.props
index 3fb601f..cbee764 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -32,6 +32,7 @@
+
diff --git a/src/Modules/TeamUp.Modules.Skills/Domain/Skill.cs b/src/Modules/TeamUp.Modules.Skills/Domain/Skill.cs
new file mode 100644
index 0000000..53124ea
--- /dev/null
+++ b/src/Modules/TeamUp.Modules.Skills/Domain/Skill.cs
@@ -0,0 +1,98 @@
+using Pgvector;
+using TeamUp.SharedKernel.Domain;
+
+namespace TeamUp.Modules.Skills.Domain;
+
+///
+/// An indexed skill atom: the projection of a SKILL.md (Git is the source of truth) into a
+/// queryable Postgres + pgvector row. Identified by (SkillKey, Version).
+///
+internal sealed class Skill : Entity
+{
+ public string SkillKey { get; private set; } = null!;
+ public string Name { get; private set; } = null!;
+ public string Version { get; private set; } = null!;
+ public string? Summary { get; private set; }
+ public List Roles { get; private set; } = [];
+ public string? Inputs { get; private set; }
+ public string? Outputs { get; private set; }
+ public List Actions { get; private set; } = [];
+ public List Tools { get; private set; } = [];
+ public List Context { get; private set; } = [];
+ public List GoldenTests { get; private set; } = [];
+ public SkillVisibility Visibility { get; private set; }
+ public SkillTier MinTier { get; private set; }
+ public SkillStatus Status { get; private set; }
+ public string Body { get; private set; } = null!;
+ public string ContentHash { get; private set; } = null!;
+ public string? SourceRepo { get; private set; }
+ public string? SourcePath { get; private set; }
+ public string? SourceCommit { get; private set; }
+ public Vector? Embedding { get; private set; }
+ public DateTimeOffset IndexedAtUtc { get; private set; }
+ public DateTimeOffset UpdatedAtUtc { get; private set; }
+
+ private Skill()
+ {
+ }
+
+ public static Skill Create(string skillKey, string version, DateTimeOffset nowUtc) =>
+ new() { SkillKey = skillKey, Version = version, IndexedAtUtc = nowUtc };
+
+ /// (Re)projects a parsed manifest + body onto this row. Used for both insert and update.
+ public void Index(
+ SkillManifest manifest,
+ string body,
+ string contentHash,
+ string? sourceRepo,
+ string? sourcePath,
+ string? sourceCommit,
+ Vector? embedding,
+ SkillStatus status,
+ DateTimeOffset nowUtc)
+ {
+ Name = string.IsNullOrWhiteSpace(manifest.Name) ? manifest.Id : manifest.Name;
+ Version = manifest.Version;
+ Summary = manifest.Summary;
+ Roles = manifest.Roles;
+ Inputs = manifest.Inputs;
+ Outputs = manifest.Outputs;
+ Actions = manifest.Actions
+ .Select(a => new SkillAction { Name = a.Name, Risk = ParseRisk(a.Risk), Description = a.Description })
+ .ToList();
+ Tools = manifest.Tools;
+ Context = manifest.Context;
+ GoldenTests = manifest.GoldenTests;
+ Visibility = ParseVisibility(manifest.Visibility);
+ MinTier = ParseTier(manifest.MinTier);
+ Status = status;
+ Body = body;
+ ContentHash = contentHash;
+ SourceRepo = sourceRepo;
+ SourcePath = sourcePath;
+ SourceCommit = sourceCommit;
+ Embedding = embedding;
+ UpdatedAtUtc = nowUtc;
+ }
+
+ private static string Normalize(string value) => value.Trim().Replace("-", string.Empty).Replace("_", string.Empty);
+
+ private static ActionRisk ParseRisk(string value) => Normalize(value).ToLowerInvariant() switch
+ {
+ "draft" => ActionRisk.Draft,
+ "publish" => ActionRisk.Publish,
+ "destructive" => ActionRisk.Destructive,
+ _ => ActionRisk.Read,
+ };
+
+ private static SkillVisibility ParseVisibility(string value) =>
+ Normalize(value).ToLowerInvariant() is "privatetoorg" or "private" ? SkillVisibility.PrivateToOrg : SkillVisibility.Public;
+
+ private static SkillTier ParseTier(string value) => Normalize(value).ToLowerInvariant() switch
+ {
+ "team" => SkillTier.Team,
+ "scale" => SkillTier.Scale,
+ "enterprise" => SkillTier.Enterprise,
+ _ => SkillTier.Free,
+ };
+}
diff --git a/src/Modules/TeamUp.Modules.Skills/Domain/SkillManifest.cs b/src/Modules/TeamUp.Modules.Skills/Domain/SkillManifest.cs
new file mode 100644
index 0000000..2990bf3
--- /dev/null
+++ b/src/Modules/TeamUp.Modules.Skills/Domain/SkillManifest.cs
@@ -0,0 +1,26 @@
+namespace TeamUp.Modules.Skills.Domain;
+
+/// The YAML frontmatter of a SKILL.md (raw, as authored). Mapped onto .
+internal sealed class SkillManifest
+{
+ public string Id { get; set; } = string.Empty;
+ public string Name { get; set; } = string.Empty;
+ public string Version { get; set; } = "1.0.0";
+ public string? Summary { get; set; }
+ public List Roles { get; set; } = [];
+ public string? Inputs { get; set; }
+ public string? Outputs { get; set; }
+ public List Actions { get; set; } = [];
+ public List Tools { get; set; } = [];
+ public List Context { get; set; } = [];
+ public string Visibility { get; set; } = "public";
+ public string MinTier { get; set; } = "free";
+ public List GoldenTests { get; set; } = [];
+}
+
+internal sealed class ManifestAction
+{
+ public string Name { get; set; } = string.Empty;
+ public string Risk { get; set; } = "read";
+ public string? Description { get; set; }
+}
diff --git a/src/Modules/TeamUp.Modules.Skills/Domain/SkillTypes.cs b/src/Modules/TeamUp.Modules.Skills/Domain/SkillTypes.cs
new file mode 100644
index 0000000..d02f80e
--- /dev/null
+++ b/src/Modules/TeamUp.Modules.Skills/Domain/SkillTypes.cs
@@ -0,0 +1,47 @@
+namespace TeamUp.Modules.Skills.Domain;
+
+/// public (catalogue) vs private-to-org. Enforcement is Phase 1; the field exists now.
+internal enum SkillVisibility
+{
+ Public,
+ PrivateToOrg,
+}
+
+internal enum SkillTier
+{
+ Free,
+ Team,
+ Scale,
+ Enterprise,
+}
+
+/// Risk lives on the action; the action gate (M5) compares it to seat autonomy.
+internal enum ActionRisk
+{
+ Read,
+ Draft,
+ Publish,
+ Destructive,
+}
+
+/// Published only once eval (golden tests) passes — see SkillIndexer/eval harness.
+internal enum SkillStatus
+{
+ Draft,
+ Published,
+}
+
+/// A risk-tagged action a skill can take. Stored as JSON on the skill.
+internal sealed class SkillAction
+{
+ public string Name { get; set; } = null!;
+ public ActionRisk Risk { get; set; }
+ public string? Description { get; set; }
+}
+
+/// A golden input/expected pair the eval harness checks (edit distance) before publish.
+internal sealed class GoldenExample
+{
+ public string Input { get; set; } = null!;
+ public string Expected { get; set; } = null!;
+}
diff --git a/src/Modules/TeamUp.Modules.Skills/Endpoints/SkillsDtos.cs b/src/Modules/TeamUp.Modules.Skills/Endpoints/SkillsDtos.cs
new file mode 100644
index 0000000..c76f617
--- /dev/null
+++ b/src/Modules/TeamUp.Modules.Skills/Endpoints/SkillsDtos.cs
@@ -0,0 +1,25 @@
+namespace TeamUp.Modules.Skills.Endpoints;
+
+internal sealed record ActionDto(string Name, string Risk);
+
+internal sealed record SkillSummary(
+ string SkillKey,
+ string Name,
+ string Version,
+ string? Summary,
+ List Roles,
+ string Visibility,
+ string MinTier,
+ string Status,
+ List Actions);
+
+internal sealed record SkillDetail(
+ SkillSummary Skill,
+ string? Inputs,
+ string? Outputs,
+ List Tools,
+ List Context,
+ int GoldenTestCount,
+ string Body);
+
+internal sealed record IndexRequest(string Content, string? SourceRepo, string? SourcePath, string? SourceCommit);
diff --git a/src/Modules/TeamUp.Modules.Skills/Endpoints/SkillsEndpoints.cs b/src/Modules/TeamUp.Modules.Skills/Endpoints/SkillsEndpoints.cs
new file mode 100644
index 0000000..9fdb7ca
--- /dev/null
+++ b/src/Modules/TeamUp.Modules.Skills/Endpoints/SkillsEndpoints.cs
@@ -0,0 +1,97 @@
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Routing;
+using Microsoft.EntityFrameworkCore;
+using TeamUp.Modules.Skills.Domain;
+using TeamUp.Modules.Skills.Indexing;
+using TeamUp.Modules.Skills.Persistence;
+using TeamUp.SharedKernel.Modularity;
+
+namespace TeamUp.Modules.Skills.Endpoints;
+
+internal static class SkillsEndpoints
+{
+ public static void Map(IEndpointRouteBuilder endpoints)
+ {
+ var group = endpoints.MapGroup("/api/skills").WithTags("Skills");
+
+ group.MapGet("/ping", () => TypedResults.Ok(new ModulePing("skills")));
+ group.MapGet("/", ListSkills).RequireAuthorization();
+ group.MapGet("/{key}", GetSkill).RequireAuthorization();
+ group.MapPost("/index", IndexSkill).RequireAuthorization();
+ }
+
+ private static async Task ListSkills(
+ string? role, string? visibility, SkillsDbContext db, CancellationToken ct)
+ {
+ var query = db.Skills.AsQueryable();
+
+ if (!string.IsNullOrWhiteSpace(role))
+ {
+ query = query.Where(s => s.Roles.Contains(role));
+ }
+
+ if (Enum.TryParse(visibility, ignoreCase: true, out var vis))
+ {
+ query = query.Where(s => s.Visibility == vis);
+ }
+
+ var skills = await query
+ .OrderBy(s => s.SkillKey)
+ .ThenByDescending(s => s.Version)
+ .ToListAsync(ct);
+
+ return Results.Ok(skills.Select(ToSummary).ToList());
+ }
+
+ private static async Task GetSkill(string key, SkillsDbContext db, CancellationToken ct)
+ {
+ var versions = await db.Skills
+ .Where(s => s.SkillKey == key)
+ .OrderByDescending(s => s.Version)
+ .ToListAsync(ct);
+
+ return versions.Count == 0
+ ? Results.NotFound()
+ : Results.Ok(versions.Select(ToDetail).ToList());
+ }
+
+ private static async Task IndexSkill(IndexRequest request, SkillIndexer indexer, CancellationToken ct)
+ {
+ if (string.IsNullOrWhiteSpace(request.Content))
+ {
+ return Results.BadRequest("content is required.");
+ }
+
+ try
+ {
+ var skill = await indexer.IndexAsync(
+ request.Content, request.SourceRepo, request.SourcePath, request.SourceCommit, ct);
+ return Results.Ok(ToDetail(skill));
+ }
+ catch (FormatException ex)
+ {
+ return Results.BadRequest(ex.Message);
+ }
+ }
+
+ private static SkillSummary ToSummary(Skill skill) => new(
+ skill.SkillKey,
+ skill.Name,
+ skill.Version,
+ skill.Summary,
+ skill.Roles,
+ skill.Visibility.ToString(),
+ skill.MinTier.ToString(),
+ skill.Status.ToString(),
+ skill.Actions.Select(a => new ActionDto(a.Name, a.Risk.ToString())).ToList());
+
+ private static SkillDetail ToDetail(Skill skill) => new(
+ ToSummary(skill),
+ skill.Inputs,
+ skill.Outputs,
+ skill.Tools,
+ skill.Context,
+ skill.GoldenTests.Count,
+ skill.Body);
+}
diff --git a/src/Modules/TeamUp.Modules.Skills/Indexing/SkillEmbedder.cs b/src/Modules/TeamUp.Modules.Skills/Indexing/SkillEmbedder.cs
new file mode 100644
index 0000000..9b6c3d7
--- /dev/null
+++ b/src/Modules/TeamUp.Modules.Skills/Indexing/SkillEmbedder.cs
@@ -0,0 +1,67 @@
+namespace TeamUp.Modules.Skills.Indexing;
+
+internal interface ISkillEmbedder
+{
+ int Dimensions { get; }
+
+ float[] Embed(string text);
+}
+
+///
+/// Placeholder deterministic embedder (L2-normalized hashed bag-of-tokens) so the pgvector index +
+/// similarity queries are REAL in M2. Replaced by ONNX (air-gapped) / BYOK embeddings in M3–M4;
+/// the 384 dimension matches the intended MiniLM/bge models so the column survives the swap.
+///
+internal sealed class HashingSkillEmbedder : ISkillEmbedder
+{
+ 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;
+ }
+ }
+}
diff --git a/src/Modules/TeamUp.Modules.Skills/Indexing/SkillIndexer.cs b/src/Modules/TeamUp.Modules.Skills/Indexing/SkillIndexer.cs
new file mode 100644
index 0000000..f80c6be
--- /dev/null
+++ b/src/Modules/TeamUp.Modules.Skills/Indexing/SkillIndexer.cs
@@ -0,0 +1,51 @@
+using System.Security.Cryptography;
+using System.Text;
+using Microsoft.EntityFrameworkCore;
+using Pgvector;
+using TeamUp.Modules.Skills.Domain;
+using TeamUp.Modules.Skills.Parsing;
+using TeamUp.Modules.Skills.Persistence;
+
+namespace TeamUp.Modules.Skills.Indexing;
+
+/// Parses a SKILL.md, computes its embedding, and upserts the Skill row (by key+version).
+internal sealed class SkillIndexer(SkillsDbContext db, ISkillEmbedder embedder, TimeProvider clock)
+{
+ public async Task IndexAsync(
+ string content,
+ string? sourceRepo,
+ string? sourcePath,
+ string? sourceCommit,
+ CancellationToken cancellationToken = default)
+ {
+ var parsed = SkillMarkdownParser.Parse(content);
+ var manifest = parsed.Manifest;
+ var now = clock.GetUtcNow();
+ var contentHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(content)));
+
+ var embeddingText = $"{manifest.Name}\n{manifest.Summary}\n{string.Join(' ', manifest.Roles)}\n{parsed.Body}";
+ var embedding = new Vector(embedder.Embed(embeddingText));
+
+ // M2 publish gate (structural): a skill is published only if it declares roles and carries
+ // at least one well-formed golden test. Executing the golden tests against a model — and
+ // gating on edit distance — lands in M4 when the assembler/runtime exists.
+ var status = manifest.Roles.Count > 0 && manifest.GoldenTests.Count > 0
+ ? SkillStatus.Published
+ : SkillStatus.Draft;
+
+ var skill = await db.Skills
+ .FirstOrDefaultAsync(s => s.SkillKey == manifest.Id && s.Version == manifest.Version, cancellationToken);
+
+ var isNew = skill is null;
+ skill ??= Skill.Create(manifest.Id, manifest.Version, now);
+ skill.Index(manifest, parsed.Body, contentHash, sourceRepo, sourcePath, sourceCommit, embedding, status, now);
+
+ if (isNew)
+ {
+ db.Skills.Add(skill);
+ }
+
+ await db.SaveChangesAsync(cancellationToken);
+ return skill;
+ }
+}
diff --git a/src/Modules/TeamUp.Modules.Skills/Parsing/SkillMarkdownParser.cs b/src/Modules/TeamUp.Modules.Skills/Parsing/SkillMarkdownParser.cs
new file mode 100644
index 0000000..a1f4dae
--- /dev/null
+++ b/src/Modules/TeamUp.Modules.Skills/Parsing/SkillMarkdownParser.cs
@@ -0,0 +1,45 @@
+using TeamUp.Modules.Skills.Domain;
+using YamlDotNet.Serialization;
+using YamlDotNet.Serialization.NamingConventions;
+
+namespace TeamUp.Modules.Skills.Parsing;
+
+internal sealed record ParsedSkill(SkillManifest Manifest, string Body);
+
+/// Splits a SKILL.md into its YAML frontmatter (between '---' fences) and markdown body.
+internal static class SkillMarkdownParser
+{
+ private static readonly IDeserializer Yaml = new DeserializerBuilder()
+ .WithNamingConvention(UnderscoredNamingConvention.Instance)
+ .IgnoreUnmatchedProperties()
+ .Build();
+
+ public static ParsedSkill Parse(string content)
+ {
+ var text = content.Replace("\r\n", "\n").Replace("\r", "\n").TrimStart();
+ if (!text.StartsWith("---\n", StringComparison.Ordinal))
+ {
+ throw new FormatException("SKILL.md must begin with a YAML frontmatter block delimited by '---'.");
+ }
+
+ var rest = text[4..];
+ var closeIndex = rest.IndexOf("\n---", StringComparison.Ordinal);
+ if (closeIndex < 0)
+ {
+ throw new FormatException("SKILL.md frontmatter is not closed with '---'.");
+ }
+
+ var frontmatter = rest[..closeIndex];
+ var afterClose = rest[(closeIndex + 1)..];
+ var newline = afterClose.IndexOf('\n');
+ var body = newline < 0 ? string.Empty : afterClose[(newline + 1)..].Trim();
+
+ var manifest = Yaml.Deserialize(frontmatter) ?? new SkillManifest();
+ if (string.IsNullOrWhiteSpace(manifest.Id))
+ {
+ throw new FormatException("SKILL.md frontmatter must include an 'id'.");
+ }
+
+ return new ParsedSkill(manifest, body);
+ }
+}
diff --git a/src/Modules/TeamUp.Modules.Skills/Persistence/Migrations/20260609141931_InitialSkills.Designer.cs b/src/Modules/TeamUp.Modules.Skills/Persistence/Migrations/20260609141931_InitialSkills.Designer.cs
new file mode 100644
index 0000000..69f3c90
--- /dev/null
+++ b/src/Modules/TeamUp.Modules.Skills/Persistence/Migrations/20260609141931_InitialSkills.Designer.cs
@@ -0,0 +1,188 @@
+//
+using System;
+using System.Collections.Generic;
+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.Skills.Persistence;
+
+#nullable disable
+
+namespace TeamUp.Modules.Skills.Persistence.Migrations
+{
+ [DbContext(typeof(SkillsDbContext))]
+ [Migration("20260609141931_InitialSkills")]
+ partial class InitialSkills
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasDefaultSchema("skills")
+ .HasAnnotation("ProductVersion", "10.0.8")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("TeamUp.Modules.Skills.Domain.Skill", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("Body")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("ContentHash")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)");
+
+ b.PrimitiveCollection>("Context")
+ .IsRequired()
+ .HasColumnType("text[]");
+
+ b.Property("Embedding")
+ .HasColumnType("vector(384)");
+
+ b.Property("IndexedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Inputs")
+ .HasMaxLength(2000)
+ .HasColumnType("character varying(2000)");
+
+ b.Property("MinTier")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("Outputs")
+ .HasMaxLength(2000)
+ .HasColumnType("character varying(2000)");
+
+ b.PrimitiveCollection>("Roles")
+ .IsRequired()
+ .HasColumnType("text[]");
+
+ b.Property("SkillKey")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)");
+
+ b.Property("SourceCommit")
+ .HasColumnType("text");
+
+ b.Property("SourcePath")
+ .HasColumnType("text");
+
+ b.Property("SourceRepo")
+ .HasColumnType("text");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)");
+
+ b.Property("Summary")
+ .HasMaxLength(1000)
+ .HasColumnType("character varying(1000)");
+
+ b.PrimitiveCollection>("Tools")
+ .IsRequired()
+ .HasColumnType("text[]");
+
+ b.Property("UpdatedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Version")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)");
+
+ b.Property("Visibility")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Status");
+
+ b.HasIndex("SkillKey", "Version")
+ .IsUnique();
+
+ b.ToTable("skills", "skills");
+ });
+
+ modelBuilder.Entity("TeamUp.Modules.Skills.Domain.Skill", b =>
+ {
+ b.OwnsMany("TeamUp.Modules.Skills.Domain.GoldenExample", "GoldenTests", b1 =>
+ {
+ b1.Property("SkillId");
+
+ b1.Property("__synthesizedOrdinal")
+ .ValueGeneratedOnAdd();
+
+ b1.Property("Expected")
+ .IsRequired();
+
+ b1.Property("Input")
+ .IsRequired();
+
+ b1.HasKey("SkillId", "__synthesizedOrdinal");
+
+ b1.ToTable("skills", "skills");
+
+ b1
+ .ToJson("GoldenTests")
+ .HasColumnType("jsonb");
+
+ b1.WithOwner()
+ .HasForeignKey("SkillId");
+ });
+
+ b.OwnsMany("TeamUp.Modules.Skills.Domain.SkillAction", "Actions", b1 =>
+ {
+ b1.Property("SkillId");
+
+ b1.Property("__synthesizedOrdinal")
+ .ValueGeneratedOnAdd();
+
+ b1.Property("Description");
+
+ b1.Property("Name")
+ .IsRequired();
+
+ b1.Property("Risk");
+
+ b1.HasKey("SkillId", "__synthesizedOrdinal");
+
+ b1.ToTable("skills", "skills");
+
+ b1
+ .ToJson("Actions")
+ .HasColumnType("jsonb");
+
+ b1.WithOwner()
+ .HasForeignKey("SkillId");
+ });
+
+ b.Navigation("Actions");
+
+ b.Navigation("GoldenTests");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/src/Modules/TeamUp.Modules.Skills/Persistence/Migrations/20260609141931_InitialSkills.cs b/src/Modules/TeamUp.Modules.Skills/Persistence/Migrations/20260609141931_InitialSkills.cs
new file mode 100644
index 0000000..a12d5a9
--- /dev/null
+++ b/src/Modules/TeamUp.Modules.Skills/Persistence/Migrations/20260609141931_InitialSkills.cs
@@ -0,0 +1,75 @@
+using System;
+using System.Collections.Generic;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Pgvector;
+
+#nullable disable
+
+namespace TeamUp.Modules.Skills.Persistence.Migrations
+{
+ ///
+ public partial class InitialSkills : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.EnsureSchema(
+ name: "skills");
+
+ migrationBuilder.CreateTable(
+ name: "skills",
+ schema: "skills",
+ columns: table => new
+ {
+ Id = table.Column(type: "uuid", nullable: false),
+ SkillKey = table.Column(type: "character varying(128)", maxLength: 128, nullable: false),
+ Name = table.Column(type: "character varying(200)", maxLength: 200, nullable: false),
+ Version = table.Column(type: "character varying(32)", maxLength: 32, nullable: false),
+ Summary = table.Column(type: "character varying(1000)", maxLength: 1000, nullable: true),
+ Roles = table.Column>(type: "text[]", nullable: false),
+ Inputs = table.Column(type: "character varying(2000)", maxLength: 2000, nullable: true),
+ Outputs = table.Column(type: "character varying(2000)", maxLength: 2000, nullable: true),
+ Tools = table.Column>(type: "text[]", nullable: false),
+ Context = table.Column>(type: "text[]", nullable: false),
+ Visibility = table.Column(type: "character varying(20)", maxLength: 20, nullable: false),
+ MinTier = table.Column(type: "character varying(20)", maxLength: 20, nullable: false),
+ Status = table.Column(type: "character varying(20)", maxLength: 20, nullable: false),
+ Body = table.Column(type: "text", nullable: false),
+ ContentHash = table.Column(type: "character varying(64)", maxLength: 64, nullable: false),
+ SourceRepo = table.Column(type: "text", nullable: true),
+ SourcePath = table.Column(type: "text", nullable: true),
+ SourceCommit = table.Column(type: "text", nullable: true),
+ Embedding = table.Column(type: "vector(384)", nullable: true),
+ IndexedAtUtc = table.Column(type: "timestamp with time zone", nullable: false),
+ UpdatedAtUtc = table.Column(type: "timestamp with time zone", nullable: false),
+ Actions = table.Column(type: "jsonb", nullable: true),
+ GoldenTests = table.Column(type: "jsonb", nullable: true)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_skills", x => x.Id);
+ });
+
+ migrationBuilder.CreateIndex(
+ name: "IX_skills_SkillKey_Version",
+ schema: "skills",
+ table: "skills",
+ columns: new[] { "SkillKey", "Version" },
+ unique: true);
+
+ migrationBuilder.CreateIndex(
+ name: "IX_skills_Status",
+ schema: "skills",
+ table: "skills",
+ column: "Status");
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "skills",
+ schema: "skills");
+ }
+ }
+}
diff --git a/src/Modules/TeamUp.Modules.Skills/Persistence/Migrations/SkillsDbContextModelSnapshot.cs b/src/Modules/TeamUp.Modules.Skills/Persistence/Migrations/SkillsDbContextModelSnapshot.cs
new file mode 100644
index 0000000..ad4f140
--- /dev/null
+++ b/src/Modules/TeamUp.Modules.Skills/Persistence/Migrations/SkillsDbContextModelSnapshot.cs
@@ -0,0 +1,185 @@
+//
+using System;
+using System.Collections.Generic;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+using Pgvector;
+using TeamUp.Modules.Skills.Persistence;
+
+#nullable disable
+
+namespace TeamUp.Modules.Skills.Persistence.Migrations
+{
+ [DbContext(typeof(SkillsDbContext))]
+ partial class SkillsDbContextModelSnapshot : ModelSnapshot
+ {
+ protected override void BuildModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasDefaultSchema("skills")
+ .HasAnnotation("ProductVersion", "10.0.8")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("TeamUp.Modules.Skills.Domain.Skill", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("Body")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("ContentHash")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)");
+
+ b.PrimitiveCollection>("Context")
+ .IsRequired()
+ .HasColumnType("text[]");
+
+ b.Property("Embedding")
+ .HasColumnType("vector(384)");
+
+ b.Property("IndexedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Inputs")
+ .HasMaxLength(2000)
+ .HasColumnType("character varying(2000)");
+
+ b.Property("MinTier")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("Outputs")
+ .HasMaxLength(2000)
+ .HasColumnType("character varying(2000)");
+
+ b.PrimitiveCollection>("Roles")
+ .IsRequired()
+ .HasColumnType("text[]");
+
+ b.Property("SkillKey")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)");
+
+ b.Property("SourceCommit")
+ .HasColumnType("text");
+
+ b.Property("SourcePath")
+ .HasColumnType("text");
+
+ b.Property("SourceRepo")
+ .HasColumnType("text");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)");
+
+ b.Property("Summary")
+ .HasMaxLength(1000)
+ .HasColumnType("character varying(1000)");
+
+ b.PrimitiveCollection>("Tools")
+ .IsRequired()
+ .HasColumnType("text[]");
+
+ b.Property("UpdatedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Version")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)");
+
+ b.Property("Visibility")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Status");
+
+ b.HasIndex("SkillKey", "Version")
+ .IsUnique();
+
+ b.ToTable("skills", "skills");
+ });
+
+ modelBuilder.Entity("TeamUp.Modules.Skills.Domain.Skill", b =>
+ {
+ b.OwnsMany("TeamUp.Modules.Skills.Domain.GoldenExample", "GoldenTests", b1 =>
+ {
+ b1.Property("SkillId");
+
+ b1.Property("__synthesizedOrdinal")
+ .ValueGeneratedOnAdd();
+
+ b1.Property("Expected")
+ .IsRequired();
+
+ b1.Property("Input")
+ .IsRequired();
+
+ b1.HasKey("SkillId", "__synthesizedOrdinal");
+
+ b1.ToTable("skills", "skills");
+
+ b1
+ .ToJson("GoldenTests")
+ .HasColumnType("jsonb");
+
+ b1.WithOwner()
+ .HasForeignKey("SkillId");
+ });
+
+ b.OwnsMany("TeamUp.Modules.Skills.Domain.SkillAction", "Actions", b1 =>
+ {
+ b1.Property("SkillId");
+
+ b1.Property("__synthesizedOrdinal")
+ .ValueGeneratedOnAdd();
+
+ b1.Property("Description");
+
+ b1.Property("Name")
+ .IsRequired();
+
+ b1.Property("Risk");
+
+ b1.HasKey("SkillId", "__synthesizedOrdinal");
+
+ b1.ToTable("skills", "skills");
+
+ b1
+ .ToJson("Actions")
+ .HasColumnType("jsonb");
+
+ b1.WithOwner()
+ .HasForeignKey("SkillId");
+ });
+
+ b.Navigation("Actions");
+
+ b.Navigation("GoldenTests");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/src/Modules/TeamUp.Modules.Skills/Persistence/SkillsDbContext.cs b/src/Modules/TeamUp.Modules.Skills/Persistence/SkillsDbContext.cs
new file mode 100644
index 0000000..34d8cb2
--- /dev/null
+++ b/src/Modules/TeamUp.Modules.Skills/Persistence/SkillsDbContext.cs
@@ -0,0 +1,40 @@
+using Microsoft.EntityFrameworkCore;
+using TeamUp.Modules.Skills.Domain;
+using TeamUp.SharedKernel.Persistence;
+
+namespace TeamUp.Modules.Skills.Persistence;
+
+internal sealed class SkillsDbContext(DbContextOptions options)
+ : DbContext(options), IModuleDbContext
+{
+ public DbSet Skills => Set();
+
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
+ {
+ modelBuilder.HasDefaultSchema("skills");
+
+ modelBuilder.Entity(skill =>
+ {
+ skill.ToTable("skills");
+ skill.HasKey(s => s.Id);
+ skill.Property(s => s.SkillKey).HasMaxLength(128).IsRequired();
+ skill.Property(s => s.Name).HasMaxLength(200).IsRequired();
+ skill.Property(s => s.Version).HasMaxLength(32).IsRequired();
+ skill.Property(s => s.Summary).HasMaxLength(1000);
+ skill.Property(s => s.Inputs).HasMaxLength(2000);
+ skill.Property(s => s.Outputs).HasMaxLength(2000);
+ skill.Property(s => s.Visibility).HasConversion().HasMaxLength(20);
+ skill.Property(s => s.MinTier).HasConversion().HasMaxLength(20);
+ skill.Property(s => s.Status).HasConversion().HasMaxLength(20);
+ skill.Property(s => s.ContentHash).HasMaxLength(64);
+ skill.Property(s => s.Embedding).HasColumnType("vector(384)");
+
+ // Risk-tagged actions and golden tests as jsonb.
+ skill.OwnsMany(s => s.Actions, owned => owned.ToJson());
+ skill.OwnsMany(s => s.GoldenTests, owned => owned.ToJson());
+
+ skill.HasIndex(s => new { s.SkillKey, s.Version }).IsUnique();
+ skill.HasIndex(s => s.Status);
+ });
+ }
+}
diff --git a/src/Modules/TeamUp.Modules.Skills/Persistence/SkillsDbContextFactory.cs b/src/Modules/TeamUp.Modules.Skills/Persistence/SkillsDbContextFactory.cs
new file mode 100644
index 0000000..74125b6
--- /dev/null
+++ b/src/Modules/TeamUp.Modules.Skills/Persistence/SkillsDbContextFactory.cs
@@ -0,0 +1,21 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Design;
+
+namespace TeamUp.Modules.Skills.Persistence;
+
+/// Design-time factory so `dotnet ef` can build the internal context (with the pgvector handler).
+internal sealed class SkillsDbContextFactory : IDesignTimeDbContextFactory
+{
+ public SkillsDbContext CreateDbContext(string[] args)
+ {
+ var connectionString =
+ Environment.GetEnvironmentVariable("ConnectionStrings__Postgres")
+ ?? "Host=localhost;Port=5432;Database=teamup;Username=teamup;Password=teamup";
+
+ var options = new DbContextOptionsBuilder()
+ .UseNpgsql(connectionString, npgsql => npgsql.UseVector())
+ .Options;
+
+ return new SkillsDbContext(options);
+ }
+}
diff --git a/src/Modules/TeamUp.Modules.Skills/SkillsModule.cs b/src/Modules/TeamUp.Modules.Skills/SkillsModule.cs
index 4e30525..17a71d4 100644
--- a/src/Modules/TeamUp.Modules.Skills/SkillsModule.cs
+++ b/src/Modules/TeamUp.Modules.Skills/SkillsModule.cs
@@ -1,27 +1,32 @@
-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.Skills.Endpoints;
+using TeamUp.Modules.Skills.Indexing;
+using TeamUp.Modules.Skills.Persistence;
using TeamUp.SharedKernel.Modularity;
+using TeamUp.SharedKernel.Persistence;
namespace TeamUp.Modules.Skills;
-/// Git-sourced skill registry: sync, the queryable atom index, versioning, evals (M2).
+/// Git-sourced skill registry: the queryable atom index, versioning, the eval harness (M2).
public sealed class SkillsModule : IModule
{
public string Name => "skills";
public void Register(IServiceCollection services, IConfiguration configuration)
{
- // Skeleton: no services yet. M2 introduces this module's (internal) DbContext,
- // FluentValidation validators, and domain services here.
+ var connectionString = configuration.GetConnectionString("Postgres")
+ ?? throw new InvalidOperationException("Missing connection string 'ConnectionStrings:Postgres'.");
+
+ services.AddDbContext(options => options.UseNpgsql(connectionString, npgsql => npgsql.UseVector()));
+ services.AddScoped(sp => sp.GetRequiredService());
+ services.AddSingleton();
+ services.AddScoped();
+ services.TryAddSingleton(TimeProvider.System);
}
- public void MapEndpoints(IEndpointRouteBuilder endpoints)
- {
- endpoints.MapGroup($"/api/{Name}")
- .WithTags("Skills")
- .MapGet("/ping", () => TypedResults.Ok(new ModulePing(Name)));
- }
+ public void MapEndpoints(IEndpointRouteBuilder endpoints) => SkillsEndpoints.Map(endpoints);
}
diff --git a/src/Modules/TeamUp.Modules.Skills/TeamUp.Modules.Skills.csproj b/src/Modules/TeamUp.Modules.Skills/TeamUp.Modules.Skills.csproj
index 65f5856..662caa0 100644
--- a/src/Modules/TeamUp.Modules.Skills/TeamUp.Modules.Skills.csproj
+++ b/src/Modules/TeamUp.Modules.Skills/TeamUp.Modules.Skills.csproj
@@ -1,10 +1,19 @@
-
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/TeamUp.IntegrationTests/SkillRegistryTests.cs b/tests/TeamUp.IntegrationTests/SkillRegistryTests.cs
new file mode 100644
index 0000000..63dc2e3
--- /dev/null
+++ b/tests/TeamUp.IntegrationTests/SkillRegistryTests.cs
@@ -0,0 +1,109 @@
+using System.Net;
+using System.Net.Http.Headers;
+using System.Net.Http.Json;
+using Xunit;
+
+namespace TeamUp.IntegrationTests;
+
+///
+/// M2 skill-registry acceptance: index a SKILL.md, then find it queryable by role; a skill with
+/// roles + golden tests publishes, a malformed one is rejected, and the catalogue needs auth.
+///
+public sealed class SkillRegistryTests(PostgresFixture postgres) : IClassFixture
+{
+ private const string SpecWritingSkill =
+ """
+ ---
+ id: spec-writing
+ name: Spec Writing
+ version: 1.0.0
+ summary: Turn a feature request into a structured spec and child stories.
+ roles: [product-owner]
+ inputs: A feature request or task.
+ outputs: A spec plus proposed child stories.
+ actions:
+ - name: write-spec
+ risk: draft
+ - name: create-child-stories
+ risk: draft
+ tools: []
+ context: [house-style]
+ visibility: public
+ min_tier: free
+ golden_tests:
+ - input: "Add a logout button"
+ expected: "Spec: a logout button in the header that ends the session."
+ ---
+
+ # Spec Writing
+
+ Write a clear, testable spec, then propose child stories.
+ """;
+
+ private sealed record BootstrapResponse(string Token, Guid MemberId, Guid OrganizationId);
+
+ private sealed record ActionDto(string Name, string Risk);
+
+ private sealed record SkillSummary(
+ string SkillKey, string Name, string Version, string? Summary, List Roles,
+ string Visibility, string MinTier, string Status, List Actions);
+
+ private sealed record SkillDetail(
+ SkillSummary Skill, string? Inputs, string? Outputs, List Tools,
+ List Context, int GoldenTestCount, string Body);
+
+ [Fact]
+ public async Task Index_publishes_and_makes_skill_queryable_by_role()
+ {
+ await using var factory = new TeamUpWebFactory(postgres.ConnectionString);
+ using var anon = factory.CreateClient();
+
+ // The catalogue requires auth.
+ Assert.Equal(HttpStatusCode.Unauthorized, (await anon.GetAsync("/api/skills/")).StatusCode);
+
+ var bootstrap = await anon.PostAsJsonAsync("/api/identity/bootstrap", new
+ {
+ organizationName = "AliaSaaS",
+ ownerEmail = "owner@alia.test",
+ ownerDisplayName = "Owner",
+ ownerPassword = "Passw0rd!",
+ });
+ var owner = await bootstrap.Content.ReadFromJsonAsync();
+ Assert.NotNull(owner);
+
+ using var client = factory.CreateClient();
+ client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", owner!.Token);
+
+ // Index the SKILL.md.
+ var indexResponse = await client.PostAsJsonAsync("/api/skills/index", new { content = SpecWritingSkill });
+ Assert.Equal(HttpStatusCode.OK, indexResponse.StatusCode);
+ var indexed = await indexResponse.Content.ReadFromJsonAsync();
+ Assert.NotNull(indexed);
+ Assert.Equal("spec-writing", indexed!.Skill.SkillKey);
+ Assert.Equal("Published", indexed.Skill.Status); // has roles + a golden test
+ Assert.Equal(1, indexed.GoldenTestCount);
+ Assert.Contains(indexed.Skill.Actions, a => a.Name == "write-spec" && a.Risk == "Draft");
+
+ // Queryable by its role…
+ var forPo = await client.GetFromJsonAsync>("/api/skills/?role=product-owner");
+ Assert.Contains(forPo!, s => s.SkillKey == "spec-writing");
+
+ // …but not under an unrelated role.
+ var forQa = await client.GetFromJsonAsync>("/api/skills/?role=qa");
+ Assert.DoesNotContain(forQa!, s => s.SkillKey == "spec-writing");
+
+ // Detail by key.
+ var detail = await client.GetFromJsonAsync>("/api/skills/spec-writing");
+ Assert.Single(detail!);
+ Assert.Equal("public", detail![0].Skill.Visibility.ToLowerInvariant());
+
+ // Re-indexing the same key+version updates in place (no duplicate).
+ await client.PostAsJsonAsync("/api/skills/index", new { content = SpecWritingSkill });
+ var afterReindex = await client.GetFromJsonAsync>("/api/skills/spec-writing");
+ Assert.Single(afterReindex!);
+
+ // A malformed SKILL.md (no frontmatter) is rejected.
+ var bad = await client.PostAsJsonAsync("/api/skills/index", new { content = "# no frontmatter here" });
+ Assert.Equal(HttpStatusCode.BadRequest, bad.StatusCode);
+ }
+}