diff --git a/apps/api/src/agent-memory/agent-memory.integration.spec.ts b/apps/api/src/agent-memory/agent-memory.integration.spec.ts new file mode 100644 index 0000000..7a7b53a --- /dev/null +++ b/apps/api/src/agent-memory/agent-memory.integration.spec.ts @@ -0,0 +1,198 @@ +import { beforeAll, beforeEach, describe, expect, it, afterAll } from "vitest"; +import { randomUUID as uuid } from "crypto"; +import { Test, TestingModule } from "@nestjs/testing"; +import { NotFoundException } from "@nestjs/common"; +import { PrismaClient } from "@prisma/client"; +import { AgentMemoryService } from "./agent-memory.service"; +import { PrismaService } from "../prisma/prisma.service"; + +const shouldRunDbIntegrationTests = + process.env.RUN_DB_TESTS === "true" && Boolean(process.env.DATABASE_URL); +const describeFn = shouldRunDbIntegrationTests ? describe : describe.skip; + +async function createWorkspace( + prisma: PrismaClient, + label: string +): Promise<{ workspaceId: string; ownerId: string }> { + const workspace = await prisma.workspace.create({ + data: { + name: `${label} ${Date.now()}`, + owner: { + create: { + email: `${label.toLowerCase().replace(/\s+/g, "-")}-${Date.now()}@example.com`, + name: `${label} Owner`, + }, + }, + }, + }); + + return { + workspaceId: workspace.id, + ownerId: workspace.ownerId, + }; +} + +describeFn("AgentMemoryService Integration", () => { + let moduleRef: TestingModule; + let prisma: PrismaClient; + let service: AgentMemoryService; + let setupComplete = false; + + let workspaceAId: string; + let workspaceAOwnerId: string; + let workspaceBId: string; + let workspaceBOwnerId: string; + + beforeAll(async () => { + prisma = new PrismaClient(); + await prisma.$connect(); + + const workspaceA = await createWorkspace(prisma, "Agent Memory Integration A"); + workspaceAId = workspaceA.workspaceId; + workspaceAOwnerId = workspaceA.ownerId; + + const workspaceB = await createWorkspace(prisma, "Agent Memory Integration B"); + workspaceBId = workspaceB.workspaceId; + workspaceBOwnerId = workspaceB.ownerId; + + moduleRef = await Test.createTestingModule({ + providers: [ + AgentMemoryService, + { + provide: PrismaService, + useValue: prisma, + }, + ], + }).compile(); + + service = moduleRef.get(AgentMemoryService); + setupComplete = true; + }); + + beforeEach(async () => { + if (!setupComplete) { + return; + } + + await prisma.agentMemory.deleteMany({ + where: { + workspaceId: { + in: [workspaceAId, workspaceBId], + }, + }, + }); + }); + + afterAll(async () => { + if (!prisma) { + return; + } + + const workspaceIds = [workspaceAId, workspaceBId].filter( + (id): id is string => typeof id === "string" + ); + const ownerIds = [workspaceAOwnerId, workspaceBOwnerId].filter( + (id): id is string => typeof id === "string" + ); + + if (workspaceIds.length > 0) { + await prisma.agentMemory.deleteMany({ + where: { + workspaceId: { + in: workspaceIds, + }, + }, + }); + await prisma.workspace.deleteMany({ where: { id: { in: workspaceIds } } }); + } + + if (ownerIds.length > 0) { + await prisma.user.deleteMany({ where: { id: { in: ownerIds } } }); + } + + if (moduleRef) { + await moduleRef.close(); + } + await prisma.$disconnect(); + }); + + it("upserts and lists memory entries", async () => { + if (!setupComplete) { + return; + } + + const agentId = `agent-${uuid()}`; + + const entry = await service.upsert(workspaceAId, agentId, "session-context", { + value: { intent: "create-tests", depth: "integration" }, + }); + + expect(entry.workspaceId).toBe(workspaceAId); + expect(entry.agentId).toBe(agentId); + expect(entry.key).toBe("session-context"); + + const listed = await service.findAll(workspaceAId, agentId); + + expect(listed).toHaveLength(1); + expect(listed[0]?.id).toBe(entry.id); + expect(listed[0]?.value).toMatchObject({ intent: "create-tests" }); + }); + + it("updates existing key via upsert without creating duplicates", async () => { + if (!setupComplete) { + return; + } + + const agentId = `agent-${uuid()}`; + + const first = await service.upsert(workspaceAId, agentId, "preferences", { + value: { model: "fast" }, + }); + + const second = await service.upsert(workspaceAId, agentId, "preferences", { + value: { model: "accurate" }, + }); + + expect(second.id).toBe(first.id); + expect(second.value).toMatchObject({ model: "accurate" }); + + const rowCount = await prisma.agentMemory.count({ + where: { + workspaceId: workspaceAId, + agentId, + key: "preferences", + }, + }); + + expect(rowCount).toBe(1); + }); + + it("lists keys in sorted order and isolates by workspace", async () => { + if (!setupComplete) { + return; + } + + const agentId = `agent-${uuid()}`; + + await service.upsert(workspaceAId, agentId, "beta", { value: { v: 2 } }); + await service.upsert(workspaceAId, agentId, "alpha", { value: { v: 1 } }); + await service.upsert(workspaceBId, agentId, "alpha", { value: { v: 99 } }); + + const workspaceAEntries = await service.findAll(workspaceAId, agentId); + const workspaceBEntries = await service.findAll(workspaceBId, agentId); + + expect(workspaceAEntries.map((row) => row.key)).toEqual(["alpha", "beta"]); + expect(workspaceBEntries).toHaveLength(1); + expect(workspaceBEntries[0]?.value).toMatchObject({ v: 99 }); + }); + + it("throws NotFoundException when requesting unknown key", async () => { + if (!setupComplete) { + return; + } + + await expect(service.findOne(workspaceAId, `agent-${uuid()}`, "missing")).rejects.toThrow( + NotFoundException + ); + }); +}); diff --git a/apps/api/src/conversation-archive/conversation-archive.integration.spec.ts b/apps/api/src/conversation-archive/conversation-archive.integration.spec.ts new file mode 100644 index 0000000..6e2b0eb --- /dev/null +++ b/apps/api/src/conversation-archive/conversation-archive.integration.spec.ts @@ -0,0 +1,239 @@ +import { beforeAll, beforeEach, describe, expect, it, afterAll, vi } from "vitest"; +import { randomUUID as uuid } from "crypto"; +import { Test, TestingModule } from "@nestjs/testing"; +import { ConflictException } from "@nestjs/common"; +import { PrismaClient, Prisma } from "@prisma/client"; +import { EMBEDDING_DIMENSION } from "@mosaic/shared"; +import { ConversationArchiveService } from "./conversation-archive.service"; +import { PrismaService } from "../prisma/prisma.service"; +import { EmbeddingService } from "../knowledge/services/embedding.service"; + +const shouldRunDbIntegrationTests = + process.env.RUN_DB_TESTS === "true" && Boolean(process.env.DATABASE_URL); +const describeFn = shouldRunDbIntegrationTests ? describe : describe.skip; + +function vector(value: number): number[] { + return Array.from({ length: EMBEDDING_DIMENSION }, () => value); +} + +function toVectorLiteral(input: number[]): string { + return `[${input.join(",")}]`; +} + +describeFn("ConversationArchiveService Integration", () => { + let moduleRef: TestingModule; + let prisma: PrismaClient; + let service: ConversationArchiveService; + let workspaceId: string; + let ownerId: string; + let setupComplete = false; + + const embeddingServiceMock = { + isConfigured: vi.fn(), + generateEmbedding: vi.fn(), + }; + + beforeAll(async () => { + prisma = new PrismaClient(); + await prisma.$connect(); + + const workspace = await prisma.workspace.create({ + data: { + name: `Conversation Archive Integration ${Date.now()}`, + owner: { + create: { + email: `conversation-archive-integration-${Date.now()}@example.com`, + name: "Conversation Archive Integration Owner", + }, + }, + }, + }); + + workspaceId = workspace.id; + ownerId = workspace.ownerId; + + moduleRef = await Test.createTestingModule({ + providers: [ + ConversationArchiveService, + { + provide: PrismaService, + useValue: prisma, + }, + { + provide: EmbeddingService, + useValue: embeddingServiceMock, + }, + ], + }).compile(); + + service = moduleRef.get(ConversationArchiveService); + setupComplete = true; + }); + + beforeEach(async () => { + vi.clearAllMocks(); + embeddingServiceMock.isConfigured.mockReturnValue(false); + + if (!setupComplete) { + return; + } + + await prisma.conversationArchive.deleteMany({ where: { workspaceId } }); + }); + + afterAll(async () => { + if (!prisma) { + return; + } + + if (workspaceId) { + await prisma.conversationArchive.deleteMany({ where: { workspaceId } }); + await prisma.workspace.deleteMany({ where: { id: workspaceId } }); + } + if (ownerId) { + await prisma.user.deleteMany({ where: { id: ownerId } }); + } + + if (moduleRef) { + await moduleRef.close(); + } + await prisma.$disconnect(); + }); + + it("ingests a conversation log", async () => { + if (!setupComplete) { + return; + } + + const sessionId = `session-${uuid()}`; + + const result = await service.ingest(workspaceId, { + sessionId, + agentId: "agent-conversation-ingest", + messages: [ + { role: "user", content: "Can you summarize deployment issues?" }, + { role: "assistant", content: "Yes, three retries timed out in staging." }, + ], + summary: "Deployment retry failures discussed", + startedAt: "2026-02-28T21:00:00.000Z", + endedAt: "2026-02-28T21:05:00.000Z", + metadata: { source: "integration-test" }, + }); + + expect(result.id).toBeDefined(); + + const stored = await prisma.conversationArchive.findUnique({ + where: { + id: result.id, + }, + }); + + expect(stored).toBeTruthy(); + expect(stored?.workspaceId).toBe(workspaceId); + expect(stored?.sessionId).toBe(sessionId); + expect(stored?.messageCount).toBe(2); + expect(stored?.summary).toBe("Deployment retry failures discussed"); + }); + + it("rejects duplicate session ingest per workspace", async () => { + if (!setupComplete) { + return; + } + + const sessionId = `session-${uuid()}`; + const dto = { + sessionId, + agentId: "agent-conversation-duplicate", + messages: [{ role: "user", content: "hello" }], + summary: "simple conversation", + startedAt: "2026-02-28T22:00:00.000Z", + }; + + await service.ingest(workspaceId, dto); + + await expect(service.ingest(workspaceId, dto)).rejects.toThrow(ConflictException); + }); + + it("rejects semantic search when embeddings are disabled", async () => { + if (!setupComplete) { + return; + } + + embeddingServiceMock.isConfigured.mockReturnValue(false); + + await expect( + service.search(workspaceId, { + query: "deployment retries", + }) + ).rejects.toThrow(ConflictException); + }); + + it("searches archived conversations by vector similarity", async () => { + if (!setupComplete) { + return; + } + + const near = vector(0.02); + const far = vector(0.85); + + const matching = await prisma.conversationArchive.create({ + data: { + workspaceId, + sessionId: `session-search-${uuid()}`, + agentId: "agent-conversation-search-a", + messages: [ + { role: "user", content: "What caused deployment retries?" }, + { role: "assistant", content: "A connection pool timeout." }, + ] as unknown as Prisma.InputJsonValue, + messageCount: 2, + summary: "Deployment retries caused by connection pool timeout", + startedAt: new Date("2026-02-28T23:00:00.000Z"), + metadata: { channel: "cli" } as Prisma.InputJsonValue, + }, + }); + + const nonMatching = await prisma.conversationArchive.create({ + data: { + workspaceId, + sessionId: `session-search-${uuid()}`, + agentId: "agent-conversation-search-b", + messages: [ + { role: "user", content: "How is billing configured?" }, + ] as unknown as Prisma.InputJsonValue, + messageCount: 1, + summary: "Billing and quotas conversation", + startedAt: new Date("2026-02-28T23:10:00.000Z"), + metadata: { channel: "cli" } as Prisma.InputJsonValue, + }, + }); + + await prisma.$executeRaw` + UPDATE conversation_archives + SET embedding = ${toVectorLiteral(near)}::vector(${EMBEDDING_DIMENSION}) + WHERE id = ${matching.id}::uuid + `; + await prisma.$executeRaw` + UPDATE conversation_archives + SET embedding = ${toVectorLiteral(far)}::vector(${EMBEDDING_DIMENSION}) + WHERE id = ${nonMatching.id}::uuid + `; + + embeddingServiceMock.isConfigured.mockReturnValue(true); + embeddingServiceMock.generateEmbedding.mockResolvedValue(near); + + const result = await service.search(workspaceId, { + query: "deployment retries timeout", + agentId: "agent-conversation-search-a", + similarityThreshold: 0, + limit: 10, + }); + + const rows = result.data as Array<{ id: string; agent_id: string; similarity: number }>; + + expect(result.pagination.total).toBe(1); + expect(rows).toHaveLength(1); + expect(rows[0]?.id).toBe(matching.id); + expect(rows[0]?.agent_id).toBe("agent-conversation-search-a"); + expect(rows[0]?.similarity).toBeGreaterThan(0); + }); +}); diff --git a/apps/api/src/findings/findings.integration.spec.ts b/apps/api/src/findings/findings.integration.spec.ts new file mode 100644 index 0000000..b5bbacd --- /dev/null +++ b/apps/api/src/findings/findings.integration.spec.ts @@ -0,0 +1,226 @@ +import { beforeAll, beforeEach, describe, expect, it, afterAll, vi } from "vitest"; +import { randomUUID as uuid } from "crypto"; +import { Test, TestingModule } from "@nestjs/testing"; +import { BadRequestException, NotFoundException } from "@nestjs/common"; +import { PrismaClient, Prisma } from "@prisma/client"; +import { FindingsService } from "./findings.service"; +import { PrismaService } from "../prisma/prisma.service"; +import { EmbeddingService } from "../knowledge/services/embedding.service"; + +const shouldRunDbIntegrationTests = + process.env.RUN_DB_TESTS === "true" && Boolean(process.env.DATABASE_URL); +const describeFn = shouldRunDbIntegrationTests ? describe : describe.skip; + +const EMBEDDING_DIMENSION = 1536; + +function vector(value: number): number[] { + return Array.from({ length: EMBEDDING_DIMENSION }, () => value); +} + +function toVectorLiteral(input: number[]): string { + return `[${input.join(",")}]`; +} + +describeFn("FindingsService Integration", () => { + let moduleRef: TestingModule; + let prisma: PrismaClient; + let service: FindingsService; + let workspaceId: string; + let ownerId: string; + let setupComplete = false; + + const embeddingServiceMock = { + isConfigured: vi.fn(), + generateEmbedding: vi.fn(), + }; + + beforeAll(async () => { + prisma = new PrismaClient(); + await prisma.$connect(); + + const workspace = await prisma.workspace.create({ + data: { + name: `Findings Integration ${Date.now()}`, + owner: { + create: { + email: `findings-integration-${Date.now()}@example.com`, + name: "Findings Integration Owner", + }, + }, + }, + }); + + workspaceId = workspace.id; + ownerId = workspace.ownerId; + + moduleRef = await Test.createTestingModule({ + providers: [ + FindingsService, + { + provide: PrismaService, + useValue: prisma, + }, + { + provide: EmbeddingService, + useValue: embeddingServiceMock, + }, + ], + }).compile(); + + service = moduleRef.get(FindingsService); + setupComplete = true; + }); + + beforeEach(() => { + vi.clearAllMocks(); + embeddingServiceMock.isConfigured.mockReturnValue(false); + }); + + afterAll(async () => { + if (!prisma) { + return; + } + + if (workspaceId) { + await prisma.finding.deleteMany({ where: { workspaceId } }); + await prisma.workspace.deleteMany({ where: { id: workspaceId } }); + } + if (ownerId) { + await prisma.user.deleteMany({ where: { id: ownerId } }); + } + + if (moduleRef) { + await moduleRef.close(); + } + await prisma.$disconnect(); + }); + + it("creates, lists, fetches, and deletes findings", async () => { + if (!setupComplete) { + return; + } + + const created = await service.create(workspaceId, { + agentId: "agent-findings-crud", + type: "security", + title: "Unescaped SQL fragment", + data: { severity: "high" }, + summary: "Potential injection risk in dynamic query path.", + }); + + expect(created.id).toBeDefined(); + expect(created.workspaceId).toBe(workspaceId); + expect(created.taskId).toBeNull(); + + const listed = await service.findAll(workspaceId, { + page: 1, + limit: 10, + agentId: "agent-findings-crud", + }); + + expect(listed.meta.total).toBeGreaterThanOrEqual(1); + expect(listed.data.some((row) => row.id === created.id)).toBe(true); + + const found = await service.findOne(created.id, workspaceId); + expect(found.id).toBe(created.id); + expect(found.title).toBe("Unescaped SQL fragment"); + + await expect(service.findOne(created.id, uuid())).rejects.toThrow(NotFoundException); + + await expect(service.remove(created.id, workspaceId)).resolves.toEqual({ + message: "Finding deleted successfully", + }); + + await expect(service.findOne(created.id, workspaceId)).rejects.toThrow(NotFoundException); + }); + + it("rejects create when taskId does not exist in workspace", async () => { + if (!setupComplete) { + return; + } + + await expect( + service.create(workspaceId, { + taskId: uuid(), + agentId: "agent-findings-missing-task", + type: "bug", + title: "Invalid task id", + data: { source: "integration-test" }, + summary: "Should fail when task relation is not found.", + }) + ).rejects.toThrow(NotFoundException); + }); + + it("rejects vector search when embeddings are disabled", async () => { + if (!setupComplete) { + return; + } + + embeddingServiceMock.isConfigured.mockReturnValue(false); + + await expect( + service.search(workspaceId, { + query: "security issue", + }) + ).rejects.toThrow(BadRequestException); + }); + + it("searches findings by vector similarity with filters", async () => { + if (!setupComplete) { + return; + } + + const near = vector(0.01); + const far = vector(0.9); + + const matchedFinding = await prisma.finding.create({ + data: { + workspaceId, + agentId: "agent-findings-search-a", + type: "incident", + title: "Authentication bypass", + data: { score: 0.9 } as Prisma.InputJsonValue, + summary: "Bypass risk found in login checks.", + }, + }); + + const otherFinding = await prisma.finding.create({ + data: { + workspaceId, + agentId: "agent-findings-search-b", + type: "incident", + title: "Retry timeout", + data: { score: 0.2 } as Prisma.InputJsonValue, + summary: "Timeout issue in downstream retries.", + }, + }); + + await prisma.$executeRaw` + UPDATE findings + SET embedding = ${toVectorLiteral(near)}::vector(1536) + WHERE id = ${matchedFinding.id}::uuid + `; + await prisma.$executeRaw` + UPDATE findings + SET embedding = ${toVectorLiteral(far)}::vector(1536) + WHERE id = ${otherFinding.id}::uuid + `; + + embeddingServiceMock.isConfigured.mockReturnValue(true); + embeddingServiceMock.generateEmbedding.mockResolvedValue(near); + + const result = await service.search(workspaceId, { + query: "authentication bypass risk", + agentId: "agent-findings-search-a", + limit: 10, + similarityThreshold: 0, + }); + + expect(result.query).toBe("authentication bypass risk"); + expect(result.meta.total).toBe(1); + expect(result.data).toHaveLength(1); + expect(result.data[0]?.id).toBe(matchedFinding.id); + expect(result.data[0]?.agentId).toBe("agent-findings-search-a"); + expect(result.data.find((row) => row.id === otherFinding.id)).toBeUndefined(); + }); +}); diff --git a/apps/api/src/tasks/tasks.assigned-agent.integration.spec.ts b/apps/api/src/tasks/tasks.assigned-agent.integration.spec.ts new file mode 100644 index 0000000..ead4b7a --- /dev/null +++ b/apps/api/src/tasks/tasks.assigned-agent.integration.spec.ts @@ -0,0 +1,162 @@ +import { beforeAll, beforeEach, describe, expect, it, afterAll, vi } from "vitest"; +import { randomUUID as uuid } from "crypto"; +import { Test, TestingModule } from "@nestjs/testing"; +import { PrismaClient } from "@prisma/client"; +import { TasksService } from "./tasks.service"; +import { PrismaService } from "../prisma/prisma.service"; +import { ActivityService } from "../activity/activity.service"; + +const shouldRunDbIntegrationTests = + process.env.RUN_DB_TESTS === "true" && Boolean(process.env.DATABASE_URL); +const describeFn = shouldRunDbIntegrationTests ? describe : describe.skip; + +describeFn("TasksService assignedAgent Integration", () => { + let moduleRef: TestingModule; + let prisma: PrismaClient; + let service: TasksService; + let workspaceId: string; + let ownerId: string; + let setupComplete = false; + + const activityServiceMock = { + logTaskCreated: vi.fn().mockResolvedValue(undefined), + logTaskUpdated: vi.fn().mockResolvedValue(undefined), + logTaskDeleted: vi.fn().mockResolvedValue(undefined), + logTaskCompleted: vi.fn().mockResolvedValue(undefined), + logTaskAssigned: vi.fn().mockResolvedValue(undefined), + }; + + beforeAll(async () => { + prisma = new PrismaClient(); + await prisma.$connect(); + + const workspace = await prisma.workspace.create({ + data: { + name: `Tasks Assigned Agent Integration ${Date.now()}`, + owner: { + create: { + email: `tasks-assigned-agent-integration-${Date.now()}@example.com`, + name: "Tasks Assigned Agent Integration Owner", + }, + }, + }, + }); + + workspaceId = workspace.id; + ownerId = workspace.ownerId; + + moduleRef = await Test.createTestingModule({ + providers: [ + TasksService, + { + provide: PrismaService, + useValue: prisma, + }, + { + provide: ActivityService, + useValue: activityServiceMock, + }, + ], + }).compile(); + + service = moduleRef.get(TasksService); + setupComplete = true; + }); + + beforeEach(async () => { + vi.clearAllMocks(); + + if (!setupComplete) { + return; + } + + await prisma.task.deleteMany({ where: { workspaceId } }); + }); + + afterAll(async () => { + if (!prisma) { + return; + } + + if (workspaceId) { + await prisma.task.deleteMany({ where: { workspaceId } }); + await prisma.workspace.deleteMany({ where: { id: workspaceId } }); + } + if (ownerId) { + await prisma.user.deleteMany({ where: { id: ownerId } }); + } + + if (moduleRef) { + await moduleRef.close(); + } + await prisma.$disconnect(); + }); + + it("persists assignedAgent on create", async () => { + if (!setupComplete) { + return; + } + + const task = await service.create(workspaceId, ownerId, { + title: `Assigned agent create ${uuid()}`, + assignedAgent: "fleet-worker-1", + }); + + expect(task.assignedAgent).toBe("fleet-worker-1"); + + const stored = await prisma.task.findUnique({ + where: { + id: task.id, + }, + select: { + id: true, + assignedAgent: true, + }, + }); + + expect(stored).toMatchObject({ + id: task.id, + assignedAgent: "fleet-worker-1", + }); + + const listed = await service.findAll({ workspaceId, page: 1, limit: 10 }, ownerId); + const listedTask = listed.data.find((row) => row.id === task.id); + + expect(listedTask?.assignedAgent).toBe("fleet-worker-1"); + }); + + it("updates and clears assignedAgent", async () => { + if (!setupComplete) { + return; + } + + const created = await service.create(workspaceId, ownerId, { + title: `Assigned agent update ${uuid()}`, + }); + + expect(created.assignedAgent).toBeNull(); + + const updated = await service.update(created.id, workspaceId, ownerId, { + assignedAgent: "fleet-worker-2", + }); + + expect(updated.assignedAgent).toBe("fleet-worker-2"); + + const cleared = await service.update(created.id, workspaceId, ownerId, { + assignedAgent: null, + }); + + expect(cleared.assignedAgent).toBeNull(); + + const stored = await prisma.task.findUnique({ + where: { + id: created.id, + }, + select: { + assignedAgent: true, + }, + }); + + expect(stored?.assignedAgent).toBeNull(); + }); +}); diff --git a/docs/TASKS.md b/docs/TASKS.md index ba9d35f..c0d8c95 100644 --- a/docs/TASKS.md +++ b/docs/TASKS.md @@ -69,5 +69,5 @@ Remaining estimate: ~143K tokens (Codex budget). | MS22-API-003 | not-started | p0-knowledge | Task API: expose assigned_agent in CRUD | TASKS:P0 | api | feat/ms22-task-agent | MS22-DB-003 | MS22-TEST-001 | — | — | — | 8K | — | Extend existing TaskModule | | MS22-TEST-001 | not-started | p0-knowledge | Integration tests: Findings + AgentMemory + ConvArchive | TASKS:P0 | api | test/ms22-integration | MS22-API-001,MS22-API-002,MS22-API-004 | MS22-VER-P0 | — | — | — | 20K | — | E2E with live postgres | | MS22-SKILL-001 | not-started | p0-knowledge | OpenClaw mosaic skill (agents read/write findings/memory) | TASKS:P0 | stack | feat/ms22-openclaw-skill | MS22-API-001,MS22-API-002 | MS22-VER-P0 | — | — | — | 15K | — | Skill in ~/.agents/skills/mosaic/ | -| MS22-INGEST-001 | not-started | p0-knowledge | Session log ingestion pipeline (OpenClaw logs → ConvArchive) | TASKS:P0 | stack | feat/ms22-ingest | MS22-API-004 | MS22-VER-P0 | — | — | — | 20K | — | Script to batch-ingest existing logs | +| MS22-INGEST-001 | done | p0-knowledge | Session log ingestion pipeline (OpenClaw logs → ConvArchive) | TASKS:P0 | stack | feat/ms22-ingest | MS22-API-004 | MS22-VER-P0 | — | — | — | 20K | — | Script to batch-ingest existing logs | | MS22-VER-P0 | not-started | p0-knowledge | Phase 0 verification: all modules deployed + smoke tested | TASKS:P0 | stack | — | MS22-TEST-001,MS22-SKILL-001,MS22-INGEST-001,MS22-API-003 | — | — | — | — | 5K | — | |