4 Commits

Author SHA1 Message Date
1aa11c4ee8 feat(brain): @mosaic/brain structured data service (#10)
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
ci/woodpecker/pr/woodpecker Pipeline failed
ci/woodpecker/pull_request_closed/woodpecker Pipeline failed
Implement @mosaic/brain — typed structured data service with MCP + REST API,
JSON file backend, and schema validation via Zod.

Collections: tasks, projects, events, agents, tickets, appreciations,
missions, mission_tasks.

MCP tools: brain_tasks, brain_projects, brain_events, brain_agents,
brain_tickets, brain_today, brain_stale, brain_stats, brain_search,
brain_audit, brain_missions, brain_mission, brain_mission_tasks,
plus mutation tools for all collections.

REST API mirrors MCP 1:1 at /v1/*.
Bearer token auth with timing-safe comparison.
Fastify server with per-request MCP instances (stateless HTTP transport).
JSON file storage with proper-lockfile for concurrent access.

Also adds Brain* types to @mosaic/types.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 20:10:12 -05:00
5adb711a67 feat(wave3): @mosaic/quality-rails TypeScript scaffolder (#8)
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-03-07 02:24:41 +00:00
8371783587 feat(wave3): @mosaic/cli unified CLI entry point (#9)
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-03-07 02:23:03 +00:00
95eed0739d feat(wave3): @mosaic/prdy TypeScript PRD wizard (#7)
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-03-07 02:21:54 +00:00
47 changed files with 4777 additions and 0 deletions

View File

@@ -0,0 +1,40 @@
# Scratchpad: @mosaic/brain Structured Data Service
**Issue:** #10
**Branch:** feat/brain-service
**PRD:** jarvis-brain/docs/planning/MOSAIC-BRAIN-MEMORY-PRD.md
## Objective
Implement @mosaic/brain as a Fastify service exposing structured data (tasks, projects, events, missions, etc.) via MCP + REST with JSON file backend.
## Plan
1. [x] Add brain data types to @mosaic/types
2. [ ] Scaffold packages/brain/ with Fastify + MCP SDK
3. [ ] Implement JSON file storage adapter with locking
4. [ ] Implement collections: tasks, projects, events, agents, tickets, appreciations, missions, mission_tasks
5. [ ] Implement MCP tools (query + mutation + mission)
6. [ ] Implement REST API (mirrors MCP)
7. [ ] Bearer token auth middleware
8. [ ] brain_today computed endpoint
9. [ ] brain_stale, brain_stats, brain_audit, brain_search, brain_graph
10. [ ] Dockerfile + docker-compose
11. [ ] Tests
12. [ ] Code review
13. [ ] Commit, push, PR, merge
## Progress
- 2026-03-10: Issue #10 created, branch created, starting implementation
## Risks
- Types package already has `Task`/`TaskStatus`/`TaskPriority` for queue — need namespacing
- Large implementation surface — focus on core CRUD first, computed endpoints second
## Decisions
- Brain types exported from `@mosaic/types` under `Brain` prefix to avoid queue type conflicts
- JSON storage uses one file per collection
- In-memory index rebuilt on startup

18
packages/brain/Dockerfile Normal file
View File

@@ -0,0 +1,18 @@
FROM node:22-alpine AS builder
WORKDIR /app
COPY package.json tsconfig.json ./
COPY src/ src/
# In standalone mode, @mosaic/types is bundled or installed from registry
RUN npm install && npm run build
FROM node:22-alpine
WORKDIR /app
COPY --from=builder /app/dist/ dist/
COPY --from=builder /app/node_modules/ node_modules/
COPY package.json ./
ENV NODE_ENV=production
ENV PORT=8100
ENV MOSAIC_BRAIN_DATA_DIR=/data
EXPOSE 8100
VOLUME /data
CMD ["node", "dist/index.js"]

View File

@@ -0,0 +1,45 @@
{
"name": "@mosaic/brain",
"version": "0.1.0",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"bin": {
"mosaic-brain": "./dist/index.js"
},
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
}
},
"files": [
"dist"
],
"scripts": {
"build": "tsc -p tsconfig.json",
"dev": "tsx watch src/index.ts",
"start": "node dist/index.js",
"typecheck": "tsc --noEmit",
"lint": "echo 'ok'",
"test": "vitest run"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.12.1",
"@mosaic/types": "workspace:*",
"fastify": "^5.3.3",
"proper-lockfile": "^4.1.2",
"zod": "^3.24.4"
},
"devDependencies": {
"@types/node": "^22",
"@types/proper-lockfile": "^4",
"tsx": "^4",
"typescript": "^5",
"vitest": "^3"
},
"publishConfig": {
"registry": "https://git.mosaicstack.dev/api/packages/mosaic/npm",
"access": "public"
}
}

View File

@@ -0,0 +1,83 @@
#!/usr/bin/env node
import Fastify from 'fastify';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { JsonStore } from './storage/json-store.js';
import { Collections } from './storage/collections.js';
import { requireAuth } from './middleware/auth.js';
import { registerRoutes } from './routes/api.js';
import { registerMcpTools } from './mcp/tools.js';
const HOST = process.env['HOST'] ?? '0.0.0.0';
const PORT = Number(process.env['PORT'] ?? 8100);
const DATA_DIR = process.env['MOSAIC_BRAIN_DATA_DIR'] ?? './data';
/**
* Create a fresh MCP server instance with all tools registered.
* Each HTTP request gets its own instance to avoid state leaks.
*/
function createMcpServer(collections: Collections): McpServer {
const mcp = new McpServer({
name: 'mosaic-brain',
version: '0.1.0',
});
registerMcpTools(mcp, collections);
return mcp;
}
async function main(): Promise<void> {
// --- Storage ---
const store = new JsonStore({ dataDir: DATA_DIR });
await store.init();
const collections = new Collections(store);
// --- Fastify ---
const app = Fastify({ logger: true });
// Auth on all /v1 and /mcp routes
app.addHook('onRequest', async (req, reply) => {
if (req.url.startsWith('/v1/') || req.url === '/mcp') {
await requireAuth(req, reply);
}
});
// REST API
registerRoutes(app, collections);
// MCP over HTTP (Streamable HTTP transport)
// Each request gets a fresh McpServer + transport to avoid state leaks
app.all('/mcp', async (req, reply) => {
const mcp = createMcpServer(collections);
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined, // stateless
});
await mcp.connect(transport);
if (req.method === 'POST') {
await transport.handleRequest(
req.raw,
reply.raw,
req.body as Record<string, unknown>,
);
} else if (req.method === 'GET') {
await transport.handleRequest(req.raw, reply.raw);
} else if (req.method === 'DELETE') {
await transport.handleRequest(req.raw, reply.raw);
} else {
reply.code(405).send({ error: 'Method not allowed' });
}
});
// --- Start ---
await app.listen({ host: HOST, port: PORT });
console.log(`mosaic-brain listening on ${HOST}:${PORT}`);
console.log(` REST API: http://${HOST}:${PORT}/v1/`);
console.log(` MCP: http://${HOST}:${PORT}/mcp`);
console.log(` Data dir: ${DATA_DIR}`);
}
main().catch((err) => {
console.error('Failed to start mosaic-brain:', err);
process.exit(1);
});

View File

@@ -0,0 +1,347 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
import type { Collections } from '../storage/collections.js';
import {
BrainTaskSchema, BrainTaskUpdateSchema,
BrainProjectSchema, BrainProjectUpdateSchema,
BrainEventSchema, BrainEventUpdateSchema,
BrainAgentUpdateSchema,
BrainMissionSchema, BrainMissionUpdateSchema,
BrainMissionTaskSchema, BrainMissionTaskUpdateSchema,
BrainDomainSchema, TaskPrioritySchema, BrainTaskStatusSchema,
BrainProjectStatusSchema, BrainEventTypeSchema, BrainEventStatusSchema,
BrainMissionStatusSchema, BrainAgentStatusSchema,
} from '../schemas.js';
export function registerMcpTools(mcp: McpServer, collections: Collections): void {
// === Query Tools ===
mcp.tool('brain_tasks',
'Query tasks with filters. Returns sorted by priority then due date.',
{
status: BrainTaskStatusSchema.optional(),
priority: TaskPrioritySchema.optional(),
domain: BrainDomainSchema.optional(),
project: z.string().optional(),
due_before: z.string().optional(),
due_after: z.string().optional(),
assignee: z.string().optional(),
limit: z.number().int().positive().optional(),
},
async (args) => {
const tasks = await collections.getTasks(args);
return { content: [{ type: 'text' as const, text: JSON.stringify(tasks, null, 2) }] };
},
);
mcp.tool('brain_projects',
'Query projects with filters. Returns sorted by priority.',
{
status: BrainProjectStatusSchema.optional(),
domain: BrainDomainSchema.optional(),
priority_min: z.number().int().optional(),
priority_max: z.number().int().optional(),
limit: z.number().int().positive().optional(),
},
async (args) => {
const projects = await collections.getProjects(args);
return { content: [{ type: 'text' as const, text: JSON.stringify(projects, null, 2) }] };
},
);
mcp.tool('brain_events',
'Query events with filters. Returns sorted by date then time.',
{
date_from: z.string().optional(),
date_to: z.string().optional(),
domain: BrainDomainSchema.optional(),
type: BrainEventTypeSchema.optional(),
status: BrainEventStatusSchema.optional(),
limit: z.number().int().positive().optional(),
},
async (args) => {
const events = await collections.getEvents(args);
return { content: [{ type: 'text' as const, text: JSON.stringify(events, null, 2) }] };
},
);
mcp.tool('brain_agents',
'Query active agent sessions.',
{
status: BrainAgentStatusSchema.optional(),
project: z.string().optional(),
},
async (args) => {
const agents = await collections.getAgents(args);
return { content: [{ type: 'text' as const, text: JSON.stringify(agents, null, 2) }] };
},
);
mcp.tool('brain_tickets',
'Query helpdesk tickets.',
{
status: z.number().int().optional(),
priority: z.number().int().optional(),
limit: z.number().int().positive().optional(),
},
async (args) => {
const tickets = await collections.getTickets(args);
return { content: [{ type: 'text' as const, text: JSON.stringify(tickets, null, 2) }] };
},
);
mcp.tool('brain_today',
'Daily summary: events, near-term tasks, stale items, blocked items, active missions, stats.',
{ date: z.string().optional() },
async (args) => {
const summary = await collections.getToday(args.date);
return { content: [{ type: 'text' as const, text: JSON.stringify(summary, null, 2) }] };
},
);
mcp.tool('brain_stale',
'Find tasks and projects not updated in N+ days.',
{ days: z.number().int().positive().default(7) },
async (args) => {
const report = await collections.getStale(args.days);
return { content: [{ type: 'text' as const, text: JSON.stringify(report, null, 2) }] };
},
);
mcp.tool('brain_stats',
'Brain statistics: counts by collection, domain, status.',
{},
async () => {
const stats = await collections.getStats();
return { content: [{ type: 'text' as const, text: JSON.stringify(stats, null, 2) }] };
},
);
mcp.tool('brain_search',
'Full-text search across titles and notes (deterministic, not semantic).',
{
query: z.string().min(1),
collection: z.enum(['tasks', 'projects', 'events', 'missions']).optional(),
},
async (args) => {
const results = await collections.search(args.query, args.collection);
return { content: [{ type: 'text' as const, text: JSON.stringify(results, null, 2) }] };
},
);
mcp.tool('brain_audit',
'Data integrity check: orphan references, broken dependencies, missing fields, duplicate IDs.',
{},
async () => {
const result = await collections.audit();
return { content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }] };
},
);
// === Mutation Tools ===
mcp.tool('brain_add_task',
'Create a new task with schema validation.',
BrainTaskSchema.shape,
async (args) => {
try {
const task = BrainTaskSchema.parse(args);
const result = await collections.addTask(task);
return { content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }] };
} catch (e: unknown) {
return { content: [{ type: 'text' as const, text: JSON.stringify({ error: String(e) }) }], isError: true };
}
},
);
mcp.tool('brain_update_task',
'Update an existing task. Auto-sets updated timestamp.',
{
id: z.string(),
...BrainTaskUpdateSchema.shape,
},
async ({ id, ...updates }) => {
try {
const result = await collections.updateTask(id, updates);
return { content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }] };
} catch (e: unknown) {
return { content: [{ type: 'text' as const, text: JSON.stringify({ error: String(e) }) }], isError: true };
}
},
);
mcp.tool('brain_add_project',
'Create a new project with schema validation.',
BrainProjectSchema.shape,
async (args) => {
try {
const project = BrainProjectSchema.parse(args);
const result = await collections.addProject(project);
return { content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }] };
} catch (e: unknown) {
return { content: [{ type: 'text' as const, text: JSON.stringify({ error: String(e) }) }], isError: true };
}
},
);
mcp.tool('brain_update_project',
'Update an existing project. Auto-sets updated timestamp.',
{
id: z.string(),
...BrainProjectUpdateSchema.shape,
},
async ({ id, ...updates }) => {
try {
const result = await collections.updateProject(id, updates);
return { content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }] };
} catch (e: unknown) {
return { content: [{ type: 'text' as const, text: JSON.stringify({ error: String(e) }) }], isError: true };
}
},
);
mcp.tool('brain_add_event',
'Create a new event with schema validation.',
BrainEventSchema.shape,
async (args) => {
try {
const event = BrainEventSchema.parse(args);
const result = await collections.addEvent(event);
return { content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }] };
} catch (e: unknown) {
return { content: [{ type: 'text' as const, text: JSON.stringify({ error: String(e) }) }], isError: true };
}
},
);
mcp.tool('brain_update_event',
'Update an existing event.',
{
id: z.string(),
...BrainEventUpdateSchema.shape,
},
async ({ id, ...updates }) => {
try {
const result = await collections.updateEvent(id, updates);
return { content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }] };
} catch (e: unknown) {
return { content: [{ type: 'text' as const, text: JSON.stringify({ error: String(e) }) }], isError: true };
}
},
);
mcp.tool('brain_update_agent',
'Update an agent session (auto-creates if not found).',
{
id: z.string(),
...BrainAgentUpdateSchema.shape,
},
async ({ id, ...updates }) => {
try {
const result = await collections.updateAgent(id, updates);
return { content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }] };
} catch (e: unknown) {
return { content: [{ type: 'text' as const, text: JSON.stringify({ error: String(e) }) }], isError: true };
}
},
);
// === Mission Tools ===
mcp.tool('brain_missions',
'List and filter missions.',
{
status: BrainMissionStatusSchema.optional(),
project: z.string().optional(),
limit: z.number().int().positive().optional(),
},
async (args) => {
const missions = await collections.getMissions(args);
return { content: [{ type: 'text' as const, text: JSON.stringify(missions, null, 2) }] };
},
);
mcp.tool('brain_mission',
'Get, create, or update a single mission. For read: provide id only. For create: provide all required fields. For update: provide id plus fields to change.',
{
id: z.string(),
title: z.string().optional(),
project: z.string().optional(),
prd_path: z.string().nullish(),
status: BrainMissionStatusSchema.optional(),
created: z.string().optional(),
updated: z.string().optional(),
notes: z.string().nullish(),
action: z.enum(['get', 'create', 'update']).default('get'),
},
async (args) => {
try {
const { action, ...fields } = args;
if (action === 'get') {
const summary = await collections.getMissionSummary(fields.id);
if (!summary) return { content: [{ type: 'text' as const, text: JSON.stringify({ error: 'Mission not found' }) }], isError: true };
return { content: [{ type: 'text' as const, text: JSON.stringify(summary, null, 2) }] };
}
if (action === 'create') {
const mission = BrainMissionSchema.parse(fields);
const result = await collections.addMission(mission);
return { content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }] };
}
// update
const { id, ...updates } = fields;
const result = await collections.updateMission(id, updates);
return { content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }] };
} catch (e: unknown) {
return { content: [{ type: 'text' as const, text: JSON.stringify({ error: String(e) }) }], isError: true };
}
},
);
mcp.tool('brain_mission_tasks',
'List, create, or update tasks within a mission. For list: provide mission_id. For create: provide all required fields. For update: provide mission_id, id, and fields to change.',
{
mission_id: z.string(),
id: z.string().optional(),
title: z.string().optional(),
phase: z.string().nullish(),
status: BrainTaskStatusSchema.optional(),
priority: TaskPrioritySchema.optional(),
dependencies: z.array(z.string()).optional(),
assigned_to: z.string().nullish(),
pr: z.string().nullish(),
order: z.number().int().optional(),
created: z.string().optional(),
updated: z.string().optional(),
completed_at: z.string().nullish(),
notes: z.string().nullish(),
action: z.enum(['list', 'create', 'update']).default('list'),
},
async (args) => {
try {
const { action, mission_id, ...fields } = args;
if (action === 'list') {
const tasks = await collections.getMissionTasks({
mission_id,
status: fields.status,
phase: fields.phase ?? undefined,
priority: fields.priority,
});
return { content: [{ type: 'text' as const, text: JSON.stringify(tasks, null, 2) }] };
}
if (action === 'create') {
const task = BrainMissionTaskSchema.parse({ ...fields, mission_id });
const result = await collections.addMissionTask(task);
return { content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }] };
}
// update
if (!fields.id) return { content: [{ type: 'text' as const, text: JSON.stringify({ error: 'id required for update' }) }], isError: true };
const { id, ...updates } = fields;
const result = await collections.updateMissionTask(mission_id, id, updates);
return { content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }] };
} catch (e: unknown) {
return { content: [{ type: 'text' as const, text: JSON.stringify({ error: String(e) }) }], isError: true };
}
},
);
}

View File

@@ -0,0 +1,25 @@
import { timingSafeEqual } from 'node:crypto';
import type { FastifyRequest, FastifyReply } from 'fastify';
const API_KEY = process.env['MOSAIC_BRAIN_API_KEY'] ?? '';
function safeCompare(a: string, b: string): boolean {
if (a.length !== b.length) return false;
return timingSafeEqual(Buffer.from(a), Buffer.from(b));
}
export async function requireAuth(request: FastifyRequest, reply: FastifyReply): Promise<void> {
if (!API_KEY) return; // No key configured = open access (dev mode)
const authHeader = request.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
reply.code(401).send({ error: 'Missing or invalid Authorization header', code: 'UNAUTHORIZED' });
return reply.hijack();
}
const token = authHeader.slice(7);
if (!safeCompare(token, API_KEY)) {
reply.code(401).send({ error: 'Invalid API key', code: 'UNAUTHORIZED' });
return reply.hijack();
}
}

View File

@@ -0,0 +1,190 @@
import type { FastifyInstance } from 'fastify';
import type { Collections } from '../storage/collections.js';
import {
BrainTaskSchema, BrainTaskUpdateSchema,
BrainProjectSchema, BrainProjectUpdateSchema,
BrainEventSchema, BrainEventUpdateSchema,
BrainAgentUpdateSchema,
BrainMissionSchema, BrainMissionUpdateSchema,
BrainMissionTaskSchema, BrainMissionTaskUpdateSchema,
} from '../schemas.js';
function parseFilters(query: Record<string, string | undefined>): Record<string, unknown> {
const filters: Record<string, unknown> = {};
for (const [key, value] of Object.entries(query)) {
if (value === undefined) continue;
if (key === 'limit' || key === 'priority_min' || key === 'priority_max') {
filters[key] = Number(value);
} else {
filters[key] = value;
}
}
return filters;
}
export function registerRoutes(app: FastifyInstance, collections: Collections): void {
// === Health ===
app.get('/health', async () => ({ status: 'ok', service: 'mosaic-brain', version: '0.1.0' }));
// === Tasks ===
app.get('/v1/tasks', async (req) => collections.getTasks(parseFilters(req.query as Record<string, string>)));
app.get<{ Params: { id: string } }>('/v1/tasks/:id', async (req, reply) => {
const task = await collections.getTask(req.params.id);
if (!task) return reply.code(404).send({ error: 'Task not found', code: 'NOT_FOUND' });
return task;
});
app.post('/v1/tasks', async (req, reply) => {
try {
const parsed = BrainTaskSchema.parse(req.body);
return await collections.addTask(parsed);
} catch (e: unknown) {
return reply.code(400).send({ error: String(e), code: 'VALIDATION_ERROR' });
}
});
app.patch<{ Params: { id: string } }>('/v1/tasks/:id', async (req, reply) => {
try {
const parsed = BrainTaskUpdateSchema.parse(req.body);
return await collections.updateTask(req.params.id, parsed);
} catch (e: unknown) {
return reply.code(400).send({ error: String(e), code: 'VALIDATION_ERROR' });
}
});
// === Projects ===
app.get('/v1/projects', async (req) => collections.getProjects(parseFilters(req.query as Record<string, string>)));
app.get<{ Params: { id: string } }>('/v1/projects/:id', async (req, reply) => {
const project = await collections.getProject(req.params.id);
if (!project) return reply.code(404).send({ error: 'Project not found', code: 'NOT_FOUND' });
return project;
});
app.post('/v1/projects', async (req, reply) => {
try {
const parsed = BrainProjectSchema.parse(req.body);
return await collections.addProject(parsed);
} catch (e: unknown) {
return reply.code(400).send({ error: String(e), code: 'VALIDATION_ERROR' });
}
});
app.patch<{ Params: { id: string } }>('/v1/projects/:id', async (req, reply) => {
try {
const parsed = BrainProjectUpdateSchema.parse(req.body);
return await collections.updateProject(req.params.id, parsed);
} catch (e: unknown) {
return reply.code(400).send({ error: String(e), code: 'VALIDATION_ERROR' });
}
});
// === Events ===
app.get('/v1/events', async (req) => collections.getEvents(parseFilters(req.query as Record<string, string>)));
app.get<{ Params: { id: string } }>('/v1/events/:id', async (req, reply) => {
const event = await collections.getEvent(req.params.id);
if (!event) return reply.code(404).send({ error: 'Event not found', code: 'NOT_FOUND' });
return event;
});
app.post('/v1/events', async (req, reply) => {
try {
const parsed = BrainEventSchema.parse(req.body);
return await collections.addEvent(parsed);
} catch (e: unknown) {
return reply.code(400).send({ error: String(e), code: 'VALIDATION_ERROR' });
}
});
app.patch<{ Params: { id: string } }>('/v1/events/:id', async (req, reply) => {
try {
const parsed = BrainEventUpdateSchema.parse(req.body);
return await collections.updateEvent(req.params.id, parsed);
} catch (e: unknown) {
return reply.code(400).send({ error: String(e), code: 'VALIDATION_ERROR' });
}
});
// === Agents ===
app.get('/v1/agents', async (req) => collections.getAgents(parseFilters(req.query as Record<string, string>)));
app.patch<{ Params: { id: string } }>('/v1/agents/:id', async (req, reply) => {
try {
const parsed = BrainAgentUpdateSchema.parse(req.body);
return await collections.updateAgent(req.params.id, parsed);
} catch (e: unknown) {
return reply.code(400).send({ error: String(e), code: 'VALIDATION_ERROR' });
}
});
// === Tickets ===
app.get('/v1/tickets', async (req) => {
const q = req.query as Record<string, string>;
return collections.getTickets({
status: q['status'] ? Number(q['status']) : undefined,
priority: q['priority'] ? Number(q['priority']) : undefined,
limit: q['limit'] ? Number(q['limit']) : undefined,
});
});
// === Missions ===
app.get('/v1/missions', async (req) => collections.getMissions(parseFilters(req.query as Record<string, string>)));
app.get<{ Params: { id: string } }>('/v1/missions/:id', async (req, reply) => {
const summary = await collections.getMissionSummary(req.params.id);
if (!summary) return reply.code(404).send({ error: 'Mission not found', code: 'NOT_FOUND' });
return summary;
});
app.post('/v1/missions', async (req, reply) => {
try {
const parsed = BrainMissionSchema.parse(req.body);
return await collections.addMission(parsed);
} catch (e: unknown) {
return reply.code(400).send({ error: String(e), code: 'VALIDATION_ERROR' });
}
});
app.patch<{ Params: { id: string } }>('/v1/missions/:id', async (req, reply) => {
try {
const parsed = BrainMissionUpdateSchema.parse(req.body);
return await collections.updateMission(req.params.id, parsed);
} catch (e: unknown) {
return reply.code(400).send({ error: String(e), code: 'VALIDATION_ERROR' });
}
});
// === Mission Tasks ===
app.get<{ Params: { id: string } }>('/v1/missions/:id/tasks', async (req) => {
const q = req.query as Record<string, string>;
return collections.getMissionTasks({
mission_id: req.params.id,
status: q['status'] as any,
phase: q['phase'],
priority: q['priority'] as any,
});
});
app.post<{ Params: { id: string } }>('/v1/missions/:id/tasks', async (req, reply) => {
try {
const parsed = BrainMissionTaskSchema.parse({ ...(req.body as object), mission_id: req.params.id });
return await collections.addMissionTask(parsed);
} catch (e: unknown) {
return reply.code(400).send({ error: String(e), code: 'VALIDATION_ERROR' });
}
});
app.patch<{ Params: { id: string; taskId: string } }>('/v1/missions/:id/tasks/:taskId', async (req, reply) => {
try {
const parsed = BrainMissionTaskUpdateSchema.parse(req.body);
return await collections.updateMissionTask(req.params.id, req.params.taskId, parsed);
} catch (e: unknown) {
return reply.code(400).send({ error: String(e), code: 'VALIDATION_ERROR' });
}
});
// === Computed Endpoints ===
app.get('/v1/today', async (req) => {
const q = req.query as Record<string, string>;
return collections.getToday(q['date']);
});
app.get('/v1/stale', async (req) => {
const q = req.query as Record<string, string>;
return collections.getStale(q['days'] ? Number(q['days']) : undefined);
});
app.get('/v1/stats', async () => collections.getStats());
app.get('/v1/search', async (req) => {
const q = req.query as Record<string, string>;
if (!q['q']) return [];
return collections.search(q['q'], q['collection']);
});
app.get('/v1/audit', async () => collections.audit());
}

View File

@@ -0,0 +1,222 @@
import { z } from 'zod';
// === Enums ===
export const BrainDomainSchema = z.enum([
'work', 'software-dev', 'homelab', 'family', 'marriage',
'finances', 'fitness', 'music', 'home-improvement', 'woodworking',
'home', 'consulting', 'personal',
]);
export const TaskPrioritySchema = z.enum(['critical', 'high', 'medium', 'low']);
export const BrainTaskStatusSchema = z.enum([
'backlog', 'scheduled', 'in-progress', 'blocked', 'done', 'cancelled',
]);
export const BrainProjectStatusSchema = z.enum([
'planning', 'active', 'paused', 'blocked', 'completed', 'archived',
]);
export const BrainAgentStatusSchema = z.enum(['active', 'idle', 'blocked', 'completed']);
export const BrainEventTypeSchema = z.enum([
'meeting', 'deadline', 'maintenance', 'event', 'recurring',
'milestone', 'task', 'constraint', 'client-work', 'appointment',
'reminder', 'conflict', 'time-off',
]);
export const BrainEventStatusSchema = z.enum([
'scheduled', 'confirmed', 'tentative', 'completed', 'cancelled',
'done', 'blocked', 'postponed', 'deferred', 'in-progress',
'pending-approval', 'canceled', 'needs-resolution',
]);
export const BrainMissionStatusSchema = z.enum([
'planning', 'active', 'blocked', 'completed', 'cancelled',
]);
// === Record Schemas ===
export const BrainTaskSchema = z.object({
id: z.string().regex(/^[a-z0-9-]+$/),
title: z.string().min(1),
domain: BrainDomainSchema,
project: z.string().nullish(),
priority: TaskPrioritySchema,
status: BrainTaskStatusSchema,
progress: z.number().int().min(0).max(100).nullish(),
due: z.string().nullish(),
blocks: z.array(z.string()).optional().default([]),
blocked_by: z.array(z.string()).optional().default([]),
related: z.array(z.string()).optional().default([]),
canonical_source: z.string().nullish(),
assignee: z.string().nullish(),
created: z.string(),
updated: z.string(),
notes: z.string().nullish(),
notes_nontechnical: z.string().nullish(),
});
export const BrainProjectSchema = z.object({
id: z.string().regex(/^[a-z0-9-]+$/),
name: z.string().min(1),
description: z.string().nullish(),
domain: BrainDomainSchema,
status: BrainProjectStatusSchema,
priority: z.number().int().min(1).max(10),
progress: z.number().int().min(0).max(100).nullish(),
repo: z.string().nullish(),
branch: z.string().nullish(),
current_milestone: z.string().nullish(),
next_milestone: z.string().nullish(),
blocker: z.string().nullish(),
owner: z.string().nullish(),
docs_path: z.string().nullish(),
created: z.string(),
updated: z.string(),
notes: z.string().nullish(),
notes_nontechnical: z.string().nullish(),
});
export const BrainEventSchema = z.object({
id: z.string().regex(/^[a-z0-9-]+$/),
title: z.string().min(1),
date: z.string(),
end_date: z.string().nullish(),
time: z.string().nullish(),
end_time: z.string().nullish(),
domain: BrainDomainSchema,
type: BrainEventTypeSchema,
status: BrainEventStatusSchema.optional().default('scheduled'),
priority: TaskPrioritySchema.nullish(),
recur: z.boolean().nullish(),
recur_rate: z.string().nullish(),
recur_start: z.string().nullish(),
recur_end: z.string().nullish(),
location: z.string().nullish(),
project: z.string().nullish(),
related_task: z.string().nullish(),
related_tasks: z.array(z.string()).optional().default([]),
notes: z.string().nullish(),
gcal_id: z.string().nullish(),
ics_uid: z.string().nullish(),
});
export const BrainAgentSchema = z.object({
id: z.string(),
project: z.string(),
focus: z.string().nullish(),
repo: z.string().nullish(),
branch: z.string().nullish(),
status: BrainAgentStatusSchema,
workload: z.enum(['light', 'medium', 'heavy']).nullish(),
next_step: z.string().nullish(),
blocker: z.string().nullish(),
updated: z.string(),
});
export const BrainTicketSchema = z.object({
id: z.string(),
title: z.string().min(1),
status: z.number().int(),
priority: z.number().int(),
urgency: z.number().int(),
impact: z.number().int(),
date_creation: z.string(),
date_mod: z.string(),
content: z.string().nullish(),
assigned_to: z.string().nullish(),
});
export const BrainAppreciationSchema = z.object({
date: z.string(),
from: z.enum(['jason', 'melanie']),
to: z.enum(['jason', 'melanie']),
text: z.string().min(1),
});
export const BrainMissionSchema = z.object({
id: z.string(),
title: z.string().min(1),
project: z.string(),
prd_path: z.string().nullish(),
status: BrainMissionStatusSchema,
created: z.string(),
updated: z.string(),
notes: z.string().nullish(),
});
export const BrainMissionTaskSchema = z.object({
id: z.string(),
mission_id: z.string(),
title: z.string().min(1),
phase: z.string().nullish(),
status: BrainTaskStatusSchema,
priority: TaskPrioritySchema,
dependencies: z.array(z.string()).default([]),
assigned_to: z.string().nullish(),
pr: z.string().nullish(),
order: z.number().int(),
created: z.string(),
updated: z.string(),
completed_at: z.string().nullish(),
notes: z.string().nullish(),
});
// === Partial schemas for updates ===
export const BrainTaskUpdateSchema = BrainTaskSchema.partial().omit({ id: true });
export const BrainProjectUpdateSchema = BrainProjectSchema.partial().omit({ id: true });
export const BrainEventUpdateSchema = BrainEventSchema.partial().omit({ id: true });
export const BrainAgentUpdateSchema = BrainAgentSchema.partial().omit({ id: true });
export const BrainMissionUpdateSchema = BrainMissionSchema.partial().omit({ id: true });
export const BrainMissionTaskUpdateSchema = BrainMissionTaskSchema.partial().omit({ id: true, mission_id: true });
// === Collection file schemas ===
export const TasksFileSchema = z.object({
version: z.string(),
tasks: z.array(BrainTaskSchema),
});
export const ProjectsFileSchema = z.object({
version: z.string(),
projects: z.array(BrainProjectSchema),
});
export const EventsFileSchema = z.object({
version: z.string(),
domain: BrainDomainSchema,
events: z.array(BrainEventSchema),
overdue: z.array(z.object({
id: z.string(),
title: z.string(),
original_due: z.string().optional(),
domain: z.string(),
notes: z.string().nullish(),
})).default([]),
});
export const AgentsFileSchema = z.object({
version: z.string(),
agents: z.array(BrainAgentSchema),
});
export const MissionsFileSchema = z.object({
version: z.string(),
missions: z.array(BrainMissionSchema),
});
export const MissionTasksFileSchema = z.object({
version: z.string(),
mission_tasks: z.array(BrainMissionTaskSchema),
});
// Inferred types for convenience
export type BrainTaskInput = z.input<typeof BrainTaskSchema>;
export type BrainProjectInput = z.input<typeof BrainProjectSchema>;
export type BrainEventInput = z.input<typeof BrainEventSchema>;
export type BrainAgentInput = z.input<typeof BrainAgentSchema>;
export type BrainMissionInput = z.input<typeof BrainMissionSchema>;
export type BrainMissionTaskInput = z.input<typeof BrainMissionTaskSchema>;

View File

@@ -0,0 +1,536 @@
import type {
BrainTask, BrainProject, BrainEvent, BrainAgent,
BrainTicket, BrainAppreciation, BrainMission, BrainMissionTask,
BrainTaskFilters, BrainProjectFilters, BrainEventFilters,
BrainMissionFilters, BrainMissionTaskFilters,
BrainMissionSummary, BrainTodaySummary, BrainStats,
BrainStaleReport, BrainAuditResult, BrainSearchResult,
TaskPriority,
} from '@mosaic/types';
import { JsonStore } from './json-store.js';
function today(): string {
return new Date().toISOString().slice(0, 10);
}
interface TasksFile { version: string; tasks: BrainTask[] }
interface ProjectsFile { version: string; projects: BrainProject[] }
interface EventsFile { version: string; events: BrainEvent[] }
interface AgentsFile { version: string; agents: BrainAgent[] }
interface TicketsFile { version: string; tickets: BrainTicket[] }
interface AppreciationsFile { version: string; appreciations: BrainAppreciation[] }
interface MissionsFile { version: string; missions: BrainMission[] }
interface MissionTasksFile { version: string; mission_tasks: BrainMissionTask[] }
const DEFAULT_TASKS: TasksFile = { version: '2.0', tasks: [] };
const DEFAULT_PROJECTS: ProjectsFile = { version: '2.0', projects: [] };
const DEFAULT_EVENTS: EventsFile = { version: '2.0', events: [] };
const DEFAULT_AGENTS: AgentsFile = { version: '2.0', agents: [] };
const DEFAULT_TICKETS: TicketsFile = { version: '2.0', tickets: [] };
const DEFAULT_APPRECIATIONS: AppreciationsFile = { version: '2.0', appreciations: [] };
const DEFAULT_MISSIONS: MissionsFile = { version: '2.0', missions: [] };
const DEFAULT_MISSION_TASKS: MissionTasksFile = { version: '2.0', mission_tasks: [] };
const PRIORITY_ORDER: Record<string, number> = { critical: 0, high: 1, medium: 2, low: 3 };
function matchesFilter<T>(item: T, filters: object): boolean {
for (const [key, value] of Object.entries(filters as Record<string, unknown>)) {
if (value === undefined || value === null) continue;
if (key === 'limit') continue;
if (key === 'due_before') {
if (!(item as Record<string, unknown>)['due'] || (item as Record<string, unknown>)['due']! > value) return false;
continue;
}
if (key === 'due_after') {
if (!(item as Record<string, unknown>)['due'] || (item as Record<string, unknown>)['due']! < value) return false;
continue;
}
if (key === 'date_from') {
if ((item as Record<string, unknown>)['date']! < value) return false;
continue;
}
if (key === 'date_to') {
if ((item as Record<string, unknown>)['date']! > value) return false;
continue;
}
if (key === 'priority_min') {
if (((item as Record<string, unknown>)['priority'] as number) < (value as number)) return false;
continue;
}
if (key === 'priority_max') {
if (((item as Record<string, unknown>)['priority'] as number) > (value as number)) return false;
continue;
}
if ((item as Record<string, unknown>)[key] !== value) return false;
}
return true;
}
function applyLimit<T>(items: T[], limit?: number): T[] {
return limit != null && limit > 0 ? items.slice(0, limit) : items;
}
export class Collections {
constructor(private readonly store: JsonStore) {}
// === Tasks ===
async getTasks(filters: BrainTaskFilters = {}): Promise<BrainTask[]> {
const file = await this.store.read('tasks', DEFAULT_TASKS);
const filtered = file.tasks.filter(t => matchesFilter(t, filters));
filtered.sort((a, b) =>
(PRIORITY_ORDER[a.priority] ?? 9) - (PRIORITY_ORDER[b.priority] ?? 9)
|| (a.due ?? '9999').localeCompare(b.due ?? '9999'),
);
return applyLimit(filtered, filters.limit);
}
async getTask(id: string): Promise<BrainTask | null> {
const file = await this.store.read('tasks', DEFAULT_TASKS);
return file.tasks.find(t => t.id === id) ?? null;
}
async addTask(task: BrainTask): Promise<BrainTask> {
const result = await this.store.modify('tasks', DEFAULT_TASKS, (file) => {
if (file.tasks.some(t => t.id === task.id)) {
throw new Error(`Task '${task.id}' already exists`);
}
return { ...file, tasks: [...file.tasks, task] };
});
return result.tasks.find(t => t.id === task.id)!;
}
async updateTask(id: string, updates: Partial<BrainTask>): Promise<BrainTask> {
let updated: BrainTask | undefined;
await this.store.modify('tasks', DEFAULT_TASKS, (file) => {
const idx = file.tasks.findIndex(t => t.id === id);
if (idx === -1) throw new Error(`Task '${id}' not found`);
const tasks = [...file.tasks];
updated = { ...tasks[idx]!, ...updates, id, updated: today() } as BrainTask;
tasks[idx] = updated;
return { ...file, tasks };
});
return updated!;
}
// === Projects ===
async getProjects(filters: BrainProjectFilters = {}): Promise<BrainProject[]> {
const file = await this.store.read('projects', DEFAULT_PROJECTS);
const filtered = file.projects.filter(p => matchesFilter(p, filters));
filtered.sort((a, b) => a.priority - b.priority);
return applyLimit(filtered, filters.limit);
}
async getProject(id: string): Promise<BrainProject | null> {
const file = await this.store.read('projects', DEFAULT_PROJECTS);
return file.projects.find(p => p.id === id) ?? null;
}
async addProject(project: BrainProject): Promise<BrainProject> {
const result = await this.store.modify('projects', DEFAULT_PROJECTS, (file) => {
if (file.projects.some(p => p.id === project.id)) {
throw new Error(`Project '${project.id}' already exists`);
}
return { ...file, projects: [...file.projects, project] };
});
return result.projects.find(p => p.id === project.id)!;
}
async updateProject(id: string, updates: Partial<BrainProject>): Promise<BrainProject> {
let updated: BrainProject | undefined;
await this.store.modify('projects', DEFAULT_PROJECTS, (file) => {
const idx = file.projects.findIndex(p => p.id === id);
if (idx === -1) throw new Error(`Project '${id}' not found`);
const projects = [...file.projects];
updated = { ...projects[idx]!, ...updates, id, updated: today() } as BrainProject;
projects[idx] = updated;
return { ...file, projects };
});
return updated!;
}
// === Events ===
async getEvents(filters: BrainEventFilters = {}): Promise<BrainEvent[]> {
const file = await this.store.read('events', DEFAULT_EVENTS);
const filtered = file.events.filter(e => matchesFilter(e, filters));
filtered.sort((a, b) => a.date.localeCompare(b.date) || (a.time ?? '').localeCompare(b.time ?? ''));
return applyLimit(filtered, filters.limit);
}
async getEvent(id: string): Promise<BrainEvent | null> {
const file = await this.store.read('events', DEFAULT_EVENTS);
return file.events.find(e => e.id === id) ?? null;
}
async addEvent(event: BrainEvent): Promise<BrainEvent> {
const result = await this.store.modify('events', DEFAULT_EVENTS, (file) => {
if (file.events.some(e => e.id === event.id)) {
throw new Error(`Event '${event.id}' already exists`);
}
return { ...file, events: [...file.events, event] };
});
return result.events.find(e => e.id === event.id)!;
}
async updateEvent(id: string, updates: Partial<BrainEvent>): Promise<BrainEvent> {
let updated: BrainEvent | undefined;
await this.store.modify('events', DEFAULT_EVENTS, (file) => {
const idx = file.events.findIndex(e => e.id === id);
if (idx === -1) throw new Error(`Event '${id}' not found`);
const events = [...file.events];
updated = { ...events[idx]!, ...updates, id } as BrainEvent;
events[idx] = updated;
return { ...file, events };
});
return updated!;
}
// === Agents ===
async getAgents(filters: { status?: string; project?: string } = {}): Promise<BrainAgent[]> {
const file = await this.store.read('agents', DEFAULT_AGENTS);
return file.agents.filter(a => matchesFilter(a, filters));
}
async updateAgent(id: string, updates: Partial<BrainAgent>): Promise<BrainAgent> {
let updated: BrainAgent | undefined;
await this.store.modify('agents', DEFAULT_AGENTS, (file) => {
const idx = file.agents.findIndex(a => a.id === id);
if (idx === -1) {
// Auto-create agent entries
updated = { id, project: '', status: 'active', updated: new Date().toISOString(), ...updates } as BrainAgent;
return { ...file, agents: [...file.agents, updated] };
}
const agents = [...file.agents];
updated = { ...agents[idx]!, ...updates, id, updated: new Date().toISOString() } as BrainAgent;
agents[idx] = updated;
return { ...file, agents };
});
return updated!;
}
// === Tickets ===
async getTickets(filters: { status?: number; priority?: number; limit?: number } = {}): Promise<BrainTicket[]> {
const file = await this.store.read('tickets', DEFAULT_TICKETS);
let filtered = file.tickets;
if (filters.status !== undefined) filtered = filtered.filter(t => t.status === filters.status);
if (filters.priority !== undefined) filtered = filtered.filter(t => t.priority === filters.priority);
return applyLimit(filtered, filters.limit);
}
// === Appreciations ===
async getAppreciations(): Promise<BrainAppreciation[]> {
const file = await this.store.read('appreciations', DEFAULT_APPRECIATIONS);
return file.appreciations;
}
async addAppreciation(appreciation: BrainAppreciation): Promise<BrainAppreciation> {
await this.store.modify('appreciations', DEFAULT_APPRECIATIONS, (file) => ({
...file,
appreciations: [...file.appreciations, appreciation],
}));
return appreciation;
}
// === Missions ===
async getMissions(filters: BrainMissionFilters = {}): Promise<BrainMission[]> {
const file = await this.store.read('missions', DEFAULT_MISSIONS);
const filtered = file.missions.filter(m => matchesFilter(m, filters));
return applyLimit(filtered, filters.limit);
}
async getMission(id: string): Promise<BrainMission | null> {
const file = await this.store.read('missions', DEFAULT_MISSIONS);
return file.missions.find(m => m.id === id) ?? null;
}
async addMission(mission: BrainMission): Promise<BrainMission> {
const result = await this.store.modify('missions', DEFAULT_MISSIONS, (file) => {
if (file.missions.some(m => m.id === mission.id)) {
throw new Error(`Mission '${mission.id}' already exists`);
}
return { ...file, missions: [...file.missions, mission] };
});
return result.missions.find(m => m.id === mission.id)!;
}
async updateMission(id: string, updates: Partial<BrainMission>): Promise<BrainMission> {
let updated: BrainMission | undefined;
await this.store.modify('missions', DEFAULT_MISSIONS, (file) => {
const idx = file.missions.findIndex(m => m.id === id);
if (idx === -1) throw new Error(`Mission '${id}' not found`);
const missions = [...file.missions];
updated = { ...missions[idx]!, ...updates, id, updated: today() } as BrainMission;
missions[idx] = updated;
return { ...file, missions };
});
return updated!;
}
async getMissionSummary(id: string): Promise<BrainMissionSummary | null> {
const mission = await this.getMission(id);
if (!mission) return null;
const tasks = await this.getMissionTasks({ mission_id: id });
const completed = tasks.filter(t => t.status === 'done');
const completedIds = new Set(completed.map(t => t.id));
const nextAvailable = tasks.filter(t =>
t.status !== 'done' && t.status !== 'cancelled' && t.status !== 'blocked'
&& t.dependencies.every(dep => completedIds.has(dep))
&& !t.assigned_to,
);
const blockedTasks = tasks.filter(t =>
t.status === 'blocked'
|| (t.status !== 'done' && t.status !== 'cancelled'
&& !t.dependencies.every(dep => completedIds.has(dep))),
);
return {
...mission,
task_count: tasks.length,
completed_count: completed.length,
progress: tasks.length > 0 ? Math.round((completed.length / tasks.length) * 100) : 0,
next_available: nextAvailable,
blocked_tasks: blockedTasks,
};
}
// === Mission Tasks ===
async getMissionTasks(filters: BrainMissionTaskFilters): Promise<BrainMissionTask[]> {
const file = await this.store.read('mission_tasks', DEFAULT_MISSION_TASKS);
const filtered = file.mission_tasks.filter(t => matchesFilter(t, filters));
filtered.sort((a, b) => a.order - b.order);
return filtered;
}
async addMissionTask(task: BrainMissionTask): Promise<BrainMissionTask> {
const result = await this.store.modify('mission_tasks', DEFAULT_MISSION_TASKS, (file) => {
if (file.mission_tasks.some(t => t.id === task.id && t.mission_id === task.mission_id)) {
throw new Error(`Mission task '${task.id}' already exists in mission '${task.mission_id}'`);
}
return { ...file, mission_tasks: [...file.mission_tasks, task] };
});
return result.mission_tasks.find(t => t.id === task.id && t.mission_id === task.mission_id)!;
}
async updateMissionTask(missionId: string, taskId: string, updates: Partial<BrainMissionTask>): Promise<BrainMissionTask> {
let updated: BrainMissionTask | undefined;
await this.store.modify('mission_tasks', DEFAULT_MISSION_TASKS, (file) => {
const idx = file.mission_tasks.findIndex(t => t.id === taskId && t.mission_id === missionId);
if (idx === -1) throw new Error(`Mission task '${taskId}' not found in mission '${missionId}'`);
const tasks = [...file.mission_tasks];
updated = { ...tasks[idx]!, ...updates, id: taskId, mission_id: missionId, updated: today() } as BrainMissionTask;
if (updates.status === 'done' && !updated.completed_at) {
updated = { ...updated, completed_at: new Date().toISOString() };
}
tasks[idx] = updated;
return { ...file, mission_tasks: tasks };
});
return updated!;
}
// === Computed: Today ===
async getToday(date?: string): Promise<BrainTodaySummary> {
const d = date ?? today();
const weekFromNow = new Date(Date.now() + 7 * 86400000).toISOString().slice(0, 10);
const staleCutoff = new Date(Date.now() - 7 * 86400000).toISOString().slice(0, 10);
const [tasks, projects, events, agents, missions] = await Promise.all([
this.getTasks(),
this.getProjects(),
this.getEvents(),
this.getAgents(),
this.getMissions({ status: 'active' }),
]);
const activeTasks = tasks.filter(t => t.status !== 'done' && t.status !== 'cancelled');
const eventsToday = events.filter(e => e.date === d);
const eventsUpcoming = events.filter(e => e.date > d && e.date <= weekFromNow);
const tasksNearTerm = activeTasks.filter(t => t.due && t.due >= d && t.due <= weekFromNow);
const tasksBlocked = activeTasks.filter(t => t.status === 'blocked');
const tasksStale = activeTasks.filter(t => t.status === 'in-progress' && t.updated < staleCutoff);
const tasksAlmostDone = activeTasks.filter(t => t.progress != null && t.progress >= 80);
const missionSummaries: BrainMissionSummary[] = [];
for (const m of missions) {
const summary = await this.getMissionSummary(m.id);
if (summary) missionSummaries.push(summary);
}
const tasksByStatus: Record<string, number> = {};
const tasksByDomain: Record<string, number> = {};
for (const t of tasks) {
tasksByStatus[t.status] = (tasksByStatus[t.status] ?? 0) + 1;
tasksByDomain[t.domain] = (tasksByDomain[t.domain] ?? 0) + 1;
}
const projectsByStatus: Record<string, number> = {};
for (const p of projects) {
projectsByStatus[p.status] = (projectsByStatus[p.status] ?? 0) + 1;
}
return {
date: d,
events_today: eventsToday,
events_upcoming: eventsUpcoming,
tasks_near_term: tasksNearTerm,
tasks_blocked: tasksBlocked,
tasks_stale: tasksStale,
tasks_almost_done: tasksAlmostDone,
active_missions: missionSummaries,
stats: {
tasks: tasks.length,
projects: projects.length,
events: events.length,
agents: agents.length,
tickets: (await this.getTickets()).length,
missions: (await this.getMissions()).length,
tasks_by_status: tasksByStatus,
tasks_by_domain: tasksByDomain,
projects_by_status: projectsByStatus,
},
};
}
// === Computed: Stale ===
async getStale(days: number = 7): Promise<BrainStaleReport> {
const cutoff = new Date(Date.now() - days * 86400000).toISOString().slice(0, 10);
const tasks = await this.getTasks();
const projects = await this.getProjects();
return {
days,
tasks: tasks.filter(t => t.status === 'in-progress' && t.updated < cutoff),
projects: projects.filter(p => p.status === 'active' && p.updated < cutoff),
};
}
// === Computed: Stats ===
async getStats(): Promise<BrainStats> {
const [tasks, projects, events, agents, tickets, missions] = await Promise.all([
this.getTasks(),
this.getProjects(),
this.getEvents(),
this.getAgents(),
this.getTickets(),
this.getMissions(),
]);
const tasksByStatus: Record<string, number> = {};
const tasksByDomain: Record<string, number> = {};
for (const t of tasks) {
tasksByStatus[t.status] = (tasksByStatus[t.status] ?? 0) + 1;
tasksByDomain[t.domain] = (tasksByDomain[t.domain] ?? 0) + 1;
}
const projectsByStatus: Record<string, number> = {};
for (const p of projects) {
projectsByStatus[p.status] = (projectsByStatus[p.status] ?? 0) + 1;
}
return {
tasks: tasks.length,
projects: projects.length,
events: events.length,
agents: agents.length,
tickets: tickets.length,
missions: missions.length,
tasks_by_status: tasksByStatus,
tasks_by_domain: tasksByDomain,
projects_by_status: projectsByStatus,
};
}
// === Computed: Search ===
async search(query: string, collection?: string): Promise<BrainSearchResult[]> {
const q = query.toLowerCase();
const results: BrainSearchResult[] = [];
const searchIn = (items: Array<{ id: string; title: string; notes?: string | null }>, collectionName: string) => {
for (const item of items) {
const titleMatch = item.title.toLowerCase().includes(q);
const notesMatch = item.notes?.toLowerCase().includes(q);
if (titleMatch || notesMatch) {
results.push({
collection: collectionName,
id: item.id,
title: item.title,
match_context: titleMatch ? item.title : (item.notes?.substring(0, 200) ?? ''),
score: titleMatch ? 1.0 : 0.5,
});
}
}
};
if (!collection || collection === 'tasks') searchIn(await this.getTasks(), 'tasks');
if (!collection || collection === 'projects') {
const projects = await this.getProjects();
for (const p of projects) {
const titleMatch = p.name.toLowerCase().includes(q);
const notesMatch = p.notes?.toLowerCase().includes(q);
if (titleMatch || notesMatch) {
results.push({
collection: 'projects',
id: p.id,
title: p.name,
match_context: titleMatch ? p.name : (p.notes?.substring(0, 200) ?? ''),
score: titleMatch ? 1.0 : 0.5,
});
}
}
}
if (!collection || collection === 'events') searchIn(await this.getEvents(), 'events');
if (!collection || collection === 'missions') searchIn(await this.getMissions(), 'missions');
results.sort((a, b) => b.score - a.score);
return results;
}
// === Computed: Audit ===
async audit(): Promise<BrainAuditResult> {
const tasks = await this.getTasks();
const projects = await this.getProjects();
const taskIds = new Set(tasks.map(t => t.id));
const projectIds = new Set(projects.map(p => p.id));
const allIds = new Set([...taskIds, ...projectIds]);
const orphanRefs: string[] = [];
const brokenDependencies: string[] = [];
const missingRequiredFields: string[] = [];
const duplicateIds: string[] = [];
// Check for duplicate task IDs
const seenTaskIds = new Set<string>();
for (const t of tasks) {
if (seenTaskIds.has(t.id)) duplicateIds.push(`task:${t.id}`);
seenTaskIds.add(t.id);
}
// Check task references
for (const t of tasks) {
if (t.project && !projectIds.has(t.project)) {
orphanRefs.push(`task:${t.id} -> project:${t.project}`);
}
for (const dep of t.blocked_by ?? []) {
if (!allIds.has(dep)) brokenDependencies.push(`task:${t.id} blocked_by:${dep}`);
}
for (const dep of t.blocks ?? []) {
if (!allIds.has(dep)) brokenDependencies.push(`task:${t.id} blocks:${dep}`);
}
if (!t.title) missingRequiredFields.push(`task:${t.id} missing title`);
}
return { orphan_refs: orphanRefs, broken_dependencies: brokenDependencies, missing_required_fields: missingRequiredFields, duplicate_ids: duplicateIds };
}
}

View File

@@ -0,0 +1,156 @@
import { readFile, writeFile, mkdir } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { join, dirname } from 'node:path';
import * as lockfile from 'proper-lockfile';
export interface JsonStoreOptions {
dataDir: string;
}
const LOCK_OPTIONS = { retries: { retries: 5, minTimeout: 100 } };
/**
* JSON file-backed storage with file locking for concurrent access.
* One file per collection. In-memory cache for fast reads.
*
* Known constraint: Cache TTL means multi-instance deployments against
* the same data directory may see stale reads for up to CACHE_TTL_MS.
* Single-instance is the supported topology for Phase 1.
*/
export class JsonStore {
private readonly dataDir: string;
private readonly cache = new Map<string, { data: unknown; loadedAt: number }>();
private static readonly CACHE_TTL_MS = 5_000;
constructor(options: JsonStoreOptions) {
this.dataDir = options.dataDir;
}
async init(): Promise<void> {
if (!existsSync(this.dataDir)) {
await mkdir(this.dataDir, { recursive: true });
}
}
private filePath(collection: string): string {
return join(this.dataDir, `${collection}.json`);
}
private isCacheValid(collection: string): boolean {
const cached = this.cache.get(collection);
if (!cached) return false;
return Date.now() - cached.loadedAt < JsonStore.CACHE_TTL_MS;
}
/**
* Ensure a file exists for locking. Uses a separate lock on the
* directory to prevent race conditions during file creation.
*/
private async ensureFile(path: string, defaultContent: string): Promise<void> {
const dir = dirname(path);
if (!existsSync(dir)) {
await mkdir(dir, { recursive: true });
}
if (!existsSync(path)) {
// Lock the directory to serialize file creation
let releaseDir: (() => Promise<void>) | undefined;
try {
releaseDir = await lockfile.lock(dir, LOCK_OPTIONS);
// Double-check after acquiring lock
if (!existsSync(path)) {
await writeFile(path, defaultContent, 'utf-8');
}
} finally {
if (releaseDir) await releaseDir();
}
}
}
/**
* Read a collection file. Returns parsed JSON or default if file doesn't exist.
*/
async read<T>(collection: string, defaultValue: T): Promise<T> {
if (this.isCacheValid(collection)) {
return this.cache.get(collection)!.data as T;
}
const path = this.filePath(collection);
if (!existsSync(path)) {
this.cache.set(collection, { data: defaultValue, loadedAt: Date.now() });
return defaultValue;
}
try {
const raw = await readFile(path, 'utf-8');
const data = JSON.parse(raw) as T;
this.cache.set(collection, { data, loadedAt: Date.now() });
return data;
} catch {
return defaultValue;
}
}
/**
* Write a collection file with file locking.
*/
async write(collection: string, data: unknown): Promise<void> {
const path = this.filePath(collection);
await this.ensureFile(path, '{}');
let release: (() => Promise<void>) | undefined;
try {
release = await lockfile.lock(path, LOCK_OPTIONS);
await writeFile(path, JSON.stringify(data, null, 2) + '\n', 'utf-8');
this.cache.set(collection, { data, loadedAt: Date.now() });
} finally {
if (release) await release();
}
}
/**
* Read-modify-write with file locking. The modifier function receives
* the current data and returns the updated data.
*/
async modify<T>(
collection: string,
defaultValue: T,
modifier: (current: T) => T | Promise<T>,
): Promise<T> {
const path = this.filePath(collection);
await this.ensureFile(path, JSON.stringify(defaultValue, null, 2) + '\n');
let release: (() => Promise<void>) | undefined;
try {
release = await lockfile.lock(path, LOCK_OPTIONS);
let current: T;
try {
const raw = await readFile(path, 'utf-8');
current = JSON.parse(raw) as T;
} catch {
current = defaultValue;
}
const updated = await modifier(current);
await writeFile(path, JSON.stringify(updated, null, 2) + '\n', 'utf-8');
this.cache.set(collection, { data: updated, loadedAt: Date.now() });
return updated;
} finally {
if (release) await release();
}
}
/**
* Invalidate cache for a collection.
*/
invalidate(collection: string): void {
this.cache.delete(collection);
}
/**
* Invalidate all cached data.
*/
invalidateAll(): void {
this.cache.clear();
}
}

View File

@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist"
},
"include": ["src"]
}

View File

@@ -0,0 +1,5 @@
#!/usr/bin/env node
import { rootCommand } from '../src/root-command.js';
rootCommand.parseAsync(process.argv);

View File

@@ -0,0 +1,15 @@
import tsParser from '@typescript-eslint/parser';
export default [
{
files: ['src/**/*.ts', 'bin/**/*.ts', 'tests/**/*.ts'],
languageOptions: {
parser: tsParser,
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
},
},
rules: {},
},
];

33
packages/cli/package.json Normal file
View File

@@ -0,0 +1,33 @@
{
"name": "@mosaic/cli",
"version": "0.1.0",
"type": "module",
"description": "Mosaic unified CLI — the mosaic command",
"bin": {
"mosaic": "./dist/bin/mosaic.js"
},
"scripts": {
"build": "tsc -p tsconfig.json",
"typecheck": "tsc --noEmit",
"lint": "eslint src/",
"test": "vitest run"
},
"dependencies": {
"@mosaic/types": "workspace:*",
"@mosaic/coord": "workspace:*",
"@mosaic/queue": "workspace:*",
"commander": "^13",
"picocolors": "^1.1"
},
"devDependencies": {
"@types/node": "^22",
"@typescript-eslint/parser": "^8",
"eslint": "^9",
"typescript": "^5",
"vitest": "^2"
},
"publishConfig": {
"registry": "https://git.mosaicstack.dev/api/packages/mosaic/npm",
"access": "public"
}
}

View File

@@ -0,0 +1,15 @@
import { Command } from 'commander';
import { buildCoordCli } from '@mosaic/coord/dist/cli.js';
const COMMAND_NAME = 'coord';
export function registerCoordCommand(program: Command): void {
const coordCommand = buildCoordCli().commands.find((command) => command.name() === COMMAND_NAME);
if (coordCommand === undefined) {
throw new Error('Expected @mosaic/coord to expose a "coord" command.');
}
program.addCommand(coordCommand);
}

View File

@@ -0,0 +1,13 @@
import { Command } from 'commander';
import pc from 'picocolors';
// TODO(wave3): Replace this temporary shim once @mosaic/prdy lands in main.
export function registerPrdyCommand(program: Command): void {
program
.command('prdy')
.description('PRD workflow commands')
.action(() => {
console.error(pc.yellow('@mosaic/prdy CLI is not available in this workspace yet.'));
process.exitCode = 1;
});
}

View File

@@ -0,0 +1,15 @@
import { Command } from 'commander';
import pc from 'picocolors';
// TODO(wave3): Replace this temporary shim once @mosaic/quality-rails lands in main.
export function registerQualityRailsCommand(program: Command): void {
program
.command('quality-rails')
.description('Quality rail commands')
.action(() => {
console.error(
pc.yellow('@mosaic/quality-rails CLI is not available in this workspace yet.'),
);
process.exitCode = 1;
});
}

View File

@@ -0,0 +1,15 @@
import { Command } from 'commander';
import { buildQueueCli } from '@mosaic/queue';
const COMMAND_NAME = 'queue';
export function registerQueueCommand(program: Command): void {
const queueCommand = buildQueueCli().commands.find((command) => command.name() === COMMAND_NAME);
if (queueCommand === undefined) {
throw new Error('Expected @mosaic/queue to expose a "queue" command.');
}
program.addCommand(queueCommand);
}

View File

@@ -0,0 +1 @@
export { rootCommand } from './root-command.js';

View File

@@ -0,0 +1,17 @@
import { Command } from 'commander';
import { registerCoordCommand } from './commands/coord.js';
import { registerPrdyCommand } from './commands/prdy.js';
import { registerQualityRailsCommand } from './commands/quality-rails.js';
import { registerQueueCommand } from './commands/queue.js';
import { VERSION } from './version.js';
export const rootCommand = new Command()
.name('mosaic')
.version(VERSION)
.description('Mosaic — AI agent orchestration platform');
registerCoordCommand(rootCommand);
registerPrdyCommand(rootCommand);
registerQueueCommand(rootCommand);
registerQualityRailsCommand(rootCommand);

View File

@@ -0,0 +1 @@
export const VERSION = '0.1.0';

View File

@@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest';
import { rootCommand } from '../src/root-command.js';
describe('rootCommand', () => {
it('registers all top-level subcommand groups', () => {
const registeredSubcommands = rootCommand.commands
.map((command) => command.name())
.sort((left, right) => left.localeCompare(right));
expect(registeredSubcommands).toEqual([
'coord',
'prdy',
'quality-rails',
'queue',
]);
});
});

View File

@@ -0,0 +1,5 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": { "outDir": "dist", "rootDir": "." },
"include": ["src", "bin"]
}

View File

@@ -0,0 +1,29 @@
{
"name": "@mosaic/prdy",
"version": "0.1.0",
"type": "module",
"description": "Mosaic PRD wizard — TypeScript rewrite",
"scripts": {
"build": "tsc -p tsconfig.json",
"typecheck": "tsc --noEmit",
"lint": "eslint src/",
"test": "vitest run"
},
"dependencies": {
"@mosaic/types": "workspace:*",
"@clack/prompts": "^0.9",
"commander": "^13",
"js-yaml": "^4",
"zod": "^3.24"
},
"devDependencies": {
"@types/node": "^22",
"@types/js-yaml": "^4",
"typescript": "^5",
"vitest": "^2"
},
"publishConfig": {
"registry": "https://git.mosaicstack.dev/api/packages/mosaic/npm",
"access": "public"
}
}

103
packages/prdy/src/cli.ts Normal file
View File

@@ -0,0 +1,103 @@
import { Command } from 'commander';
import { createPrd, listPrds, loadPrd } from './prd.js';
import { runPrdWizard } from './wizard.js';
interface InitCommandOptions {
readonly name: string;
readonly project: string;
readonly template?: 'software' | 'feature' | 'spike';
}
interface ListCommandOptions {
readonly project: string;
}
interface ShowCommandOptions {
readonly project: string;
readonly id?: string;
}
export function buildPrdyCli(): Command {
const program = new Command();
program
.name('mosaic')
.description('Mosaic CLI')
.exitOverride();
const prdy = program.command('prdy').description('PRD wizard commands');
prdy
.command('init')
.description('Create a PRD document')
.requiredOption('--name <name>', 'PRD name')
.requiredOption('--project <path>', 'Project path')
.option('--template <template>', 'Template (software|feature|spike)')
.action(async (options: InitCommandOptions) => {
const doc = process.stdout.isTTY
? await runPrdWizard({
name: options.name,
projectPath: options.project,
template: options.template,
interactive: true,
})
: await createPrd({
name: options.name,
projectPath: options.project,
template: options.template,
interactive: false,
});
console.log(
JSON.stringify(
{
ok: true,
id: doc.id,
title: doc.title,
status: doc.status,
projectPath: doc.projectPath,
},
null,
2,
),
);
});
prdy
.command('list')
.description('List PRD documents for a project')
.requiredOption('--project <path>', 'Project path')
.action(async (options: ListCommandOptions) => {
const docs = await listPrds(options.project);
console.log(JSON.stringify(docs, null, 2));
});
prdy
.command('show')
.description('Show a PRD document')
.requiredOption('--project <path>', 'Project path')
.option('--id <id>', 'PRD document id')
.action(async (options: ShowCommandOptions) => {
if (options.id !== undefined) {
const docs = await listPrds(options.project);
const match = docs.find((doc) => doc.id === options.id);
if (match === undefined) {
throw new Error(`PRD id not found: ${options.id}`);
}
console.log(JSON.stringify(match, null, 2));
return;
}
const doc = await loadPrd(options.project);
console.log(JSON.stringify(doc, null, 2));
});
return program;
}
export async function runPrdyCli(argv: readonly string[] = process.argv): Promise<void> {
const program = buildPrdyCli();
await program.parseAsync(argv);
}

View File

@@ -0,0 +1,20 @@
export {
createPrd,
loadPrd,
savePrd,
listPrds,
} from './prd.js';
export { runPrdWizard } from './wizard.js';
export { buildPrdyCli, runPrdyCli } from './cli.js';
export {
BUILTIN_PRD_TEMPLATES,
resolveTemplate,
} from './templates.js';
export type {
PrdStatus,
PrdTemplate,
PrdTemplateSection,
PrdSection,
PrdDocument,
CreatePrdOptions,
} from './types.js';

199
packages/prdy/src/prd.ts Normal file
View File

@@ -0,0 +1,199 @@
import { promises as fs } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import yaml from 'js-yaml';
import { z } from 'zod';
import { resolveTemplate } from './templates.js';
import type { CreatePrdOptions, PrdDocument } from './types.js';
const PRD_DIRECTORY = path.join('docs', 'prdy');
const PRD_FILE_EXTENSIONS = new Set(['.yaml', '.yml']);
const prdSectionSchema = z.object({
id: z.string().min(1),
title: z.string().min(1),
fields: z.record(z.string(), z.string()),
});
const prdDocumentSchema = z.object({
id: z.string().min(1),
title: z.string().min(1),
status: z.enum(['draft', 'review', 'approved', 'archived']),
projectPath: z.string().min(1),
template: z.string().min(1),
sections: z.array(prdSectionSchema),
createdAt: z.string().datetime(),
updatedAt: z.string().datetime(),
});
function expandHome(projectPath: string): string {
if (!projectPath.startsWith('~')) {
return projectPath;
}
if (projectPath === '~') {
return os.homedir();
}
if (projectPath.startsWith('~/')) {
return path.join(os.homedir(), projectPath.slice(2));
}
return projectPath;
}
function resolveProjectPath(projectPath: string): string {
return path.resolve(expandHome(projectPath));
}
function toSlug(value: string): string {
return value
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.replace(/-{2,}/g, '-');
}
function buildTimestamp(date: Date): { datePart: string; timePart: string } {
const iso = date.toISOString();
return {
datePart: iso.slice(0, 10).replace(/-/g, ''),
timePart: iso.slice(11, 19).replace(/:/g, ''),
};
}
function buildPrdId(name: string): string {
const slug = toSlug(name);
const { datePart, timePart } = buildTimestamp(new Date());
return `${slug || 'prd'}-${datePart}-${timePart}`;
}
function prdDirectory(projectPath: string): string {
return path.join(projectPath, PRD_DIRECTORY);
}
function prdFilePath(projectPath: string, id: string): string {
return path.join(prdDirectory(projectPath), `${id}.yaml`);
}
function isNodeErrorWithCode(error: unknown, code: string): boolean {
return (
typeof error === 'object' &&
error !== null &&
'code' in error &&
(error as { code?: string }).code === code
);
}
async function writeFileAtomic(filePath: string, content: string): Promise<void> {
const directory = path.dirname(filePath);
await fs.mkdir(directory, { recursive: true });
const tempPath = path.join(
directory,
`.${path.basename(filePath)}.tmp-${process.pid}-${Date.now()}-${Math.random()
.toString(16)
.slice(2)}`,
);
await fs.writeFile(tempPath, content, 'utf8');
await fs.rename(tempPath, filePath);
}
export async function createPrd(options: CreatePrdOptions): Promise<PrdDocument> {
const resolvedProjectPath = resolveProjectPath(options.projectPath);
const template = resolveTemplate(options.template);
const now = new Date().toISOString();
const document: PrdDocument = {
id: buildPrdId(options.name),
title: options.name.trim(),
status: 'draft',
projectPath: resolvedProjectPath,
template: template.id,
sections: template.sections.map((section) => ({
id: section.id,
title: section.title,
fields: Object.fromEntries(section.fields.map((field) => [field, ''])),
})),
createdAt: now,
updatedAt: now,
};
await savePrd(document);
return document;
}
export async function loadPrd(projectPath: string): Promise<PrdDocument> {
const documents = await listPrds(projectPath);
if (documents.length === 0) {
const resolvedProjectPath = resolveProjectPath(projectPath);
throw new Error(`No PRD documents found in ${prdDirectory(resolvedProjectPath)}`);
}
return documents[0]!;
}
export async function savePrd(doc: PrdDocument): Promise<void> {
const normalized = prdDocumentSchema.parse({
...doc,
projectPath: resolveProjectPath(doc.projectPath),
});
const filePath = prdFilePath(normalized.projectPath, normalized.id);
const serialized = yaml.dump(normalized, {
noRefs: true,
sortKeys: false,
lineWidth: 120,
});
const content = serialized.endsWith('\n') ? serialized : `${serialized}\n`;
await writeFileAtomic(filePath, content);
}
export async function listPrds(projectPath: string): Promise<PrdDocument[]> {
const resolvedProjectPath = resolveProjectPath(projectPath);
const directory = prdDirectory(resolvedProjectPath);
let entries: import('node:fs').Dirent[];
try {
entries = await fs.readdir(directory, { withFileTypes: true, encoding: 'utf8' });
} catch (error) {
if (isNodeErrorWithCode(error, 'ENOENT')) {
return [];
}
throw error;
}
const documents: PrdDocument[] = [];
for (const entry of entries) {
if (!entry.isFile()) {
continue;
}
const ext = path.extname(entry.name);
if (!PRD_FILE_EXTENSIONS.has(ext)) {
continue;
}
const filePath = path.join(directory, entry.name);
const raw = await fs.readFile(filePath, 'utf8');
let parsed: unknown;
try {
parsed = yaml.load(raw);
} catch (error) {
throw new Error(`Failed to parse PRD file ${filePath}: ${String(error)}`);
}
const document = prdDocumentSchema.parse(parsed);
documents.push(document);
}
documents.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
return documents;
}

View File

@@ -0,0 +1,86 @@
import type { PrdTemplate } from './types.js';
export const BUILTIN_PRD_TEMPLATES: Record<string, PrdTemplate> = {
software: {
id: 'software',
name: 'Software Project',
fields: ['owner', 'status', 'scopeVersion', 'successMetrics'],
sections: [
{ id: 'introduction', title: 'Introduction', fields: ['context', 'objective'] },
{ id: 'problem-statement', title: 'Problem Statement', fields: ['painPoints'] },
{ id: 'scope-non-goals', title: 'Scope / Non-Goals', fields: ['inScope', 'outOfScope'] },
{ id: 'user-stories', title: 'User Stories / Requirements', fields: ['stories'] },
{ id: 'functional-requirements', title: 'Functional Requirements', fields: ['requirements'] },
{
id: 'non-functional-requirements',
title: 'Non-Functional Requirements',
fields: ['performance', 'reliability', 'security'],
},
{ id: 'acceptance-criteria', title: 'Acceptance Criteria', fields: ['criteria'] },
{
id: 'technical-considerations',
title: 'Technical Considerations',
fields: ['constraints', 'dependencies'],
},
{
id: 'risks-open-questions',
title: 'Risks / Open Questions',
fields: ['risks', 'openQuestions'],
},
{
id: 'milestones-delivery',
title: 'Milestones / Delivery',
fields: ['milestones', 'timeline'],
},
],
},
feature: {
id: 'feature',
name: 'Feature PRD',
fields: ['owner', 'status', 'releaseTarget'],
sections: [
{ id: 'problem-statement', title: 'Problem Statement', fields: ['problem'] },
{ id: 'goals', title: 'Goals', fields: ['goals'] },
{ id: 'scope', title: 'Scope', fields: ['inScope', 'outOfScope'] },
{ id: 'user-stories', title: 'User Stories', fields: ['stories'] },
{ id: 'requirements', title: 'Requirements', fields: ['functional', 'nonFunctional'] },
{ id: 'acceptance-criteria', title: 'Acceptance Criteria', fields: ['criteria'] },
{ id: 'technical-considerations', title: 'Technical Considerations', fields: ['constraints'] },
{ id: 'risks-open-questions', title: 'Risks / Open Questions', fields: ['risks', 'questions'] },
{ id: 'milestones', title: 'Milestones', fields: ['milestones'] },
{ id: 'success-metrics', title: 'Success Metrics / Testing', fields: ['metrics', 'testing'] },
],
},
spike: {
id: 'spike',
name: 'Research Spike',
fields: ['owner', 'status', 'decisionDeadline'],
sections: [
{ id: 'background', title: 'Background', fields: ['context'] },
{ id: 'research-questions', title: 'Research Questions', fields: ['questions'] },
{ id: 'constraints', title: 'Constraints', fields: ['constraints'] },
{ id: 'options', title: 'Options Considered', fields: ['options'] },
{ id: 'evaluation', title: 'Evaluation Criteria', fields: ['criteria'] },
{ id: 'findings', title: 'Findings', fields: ['findings'] },
{ id: 'recommendation', title: 'Recommendation', fields: ['recommendation'] },
{ id: 'risks', title: 'Risks / Unknowns', fields: ['risks', 'unknowns'] },
{ id: 'next-steps', title: 'Next Steps', fields: ['nextSteps'] },
{ id: 'milestones', title: 'Milestones / Delivery', fields: ['milestones'] },
],
},
};
export function resolveTemplate(templateName?: string): PrdTemplate {
if (templateName === undefined || templateName.trim().length === 0) {
return BUILTIN_PRD_TEMPLATES.software;
}
const template = BUILTIN_PRD_TEMPLATES[templateName];
if (template === undefined) {
throw new Error(
`Unknown PRD template: ${templateName}. Expected one of: ${Object.keys(BUILTIN_PRD_TEMPLATES).join(', ')}`,
);
}
return template;
}

View File

@@ -0,0 +1,38 @@
export type PrdStatus = 'draft' | 'review' | 'approved' | 'archived';
export interface PrdTemplateSection {
id: string;
title: string;
fields: string[];
}
export interface PrdTemplate {
id: string;
name: string;
sections: PrdTemplateSection[];
fields: string[];
}
export interface PrdSection {
id: string;
title: string;
fields: Record<string, string>;
}
export interface PrdDocument {
id: string;
title: string;
status: PrdStatus;
projectPath: string;
template: string;
sections: PrdSection[];
createdAt: string;
updatedAt: string;
}
export interface CreatePrdOptions {
name: string;
projectPath: string;
template?: string;
interactive?: boolean;
}

121
packages/prdy/src/wizard.ts Normal file
View File

@@ -0,0 +1,121 @@
import path from 'node:path';
import {
cancel,
intro,
isCancel,
outro,
select,
text,
} from '@clack/prompts';
import { createPrd, savePrd } from './prd.js';
import type { CreatePrdOptions, PrdDocument } from './types.js';
interface WizardAnswers {
goals: string;
constraints: string;
milestones: string;
}
function updateSectionField(
doc: PrdDocument,
sectionKeyword: string,
value: string,
): void {
const section = doc.sections.find((candidate) =>
candidate.id.includes(sectionKeyword),
);
if (section === undefined) {
return;
}
const fieldName =
Object.keys(section.fields).find((field) =>
field.toLowerCase().includes(sectionKeyword),
) ?? Object.keys(section.fields)[0];
if (fieldName !== undefined) {
section.fields[fieldName] = value;
}
}
async function promptText(message: string, initialValue = ''): Promise<string> {
const response = await text({
message,
initialValue,
});
if (isCancel(response)) {
cancel('PRD wizard cancelled.');
throw new Error('PRD wizard cancelled');
}
return response.trim();
}
async function promptTemplate(template?: string): Promise<string> {
if (template !== undefined && template.trim().length > 0) {
return template;
}
const choice = await select({
message: 'PRD type',
options: [
{ value: 'software', label: 'Software project' },
{ value: 'feature', label: 'Feature' },
{ value: 'spike', label: 'Research spike' },
],
});
if (isCancel(choice)) {
cancel('PRD wizard cancelled.');
throw new Error('PRD wizard cancelled');
}
return choice;
}
function applyWizardAnswers(doc: PrdDocument, answers: WizardAnswers): PrdDocument {
updateSectionField(doc, 'goal', answers.goals);
updateSectionField(doc, 'constraint', answers.constraints);
updateSectionField(doc, 'milestone', answers.milestones);
doc.updatedAt = new Date().toISOString();
return doc;
}
export async function runPrdWizard(options: CreatePrdOptions): Promise<PrdDocument> {
intro('Mosaic PRD wizard');
const name =
options.name.trim().length > 0
? options.name.trim()
: await promptText('Project name');
const template = await promptTemplate(options.template);
const goals = await promptText('Primary goals');
const constraints = await promptText('Key constraints');
const milestones = await promptText('Planned milestones');
const doc = await createPrd({
...options,
name,
template,
interactive: true,
});
const updated = applyWizardAnswers(doc, {
goals,
constraints,
milestones,
});
await savePrd(updated);
outro(
`PRD created: ${path.join(updated.projectPath, 'docs', 'prdy', `${updated.id}.yaml`)}`,
);
return updated;
}

View File

@@ -0,0 +1,36 @@
import { promises as fs } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { describe, expect, it } from 'vitest';
import { createPrd, listPrds, loadPrd } from '../src/prd.js';
describe('prd document lifecycle', () => {
it('creates and loads PRD documents', async () => {
const projectDir = await fs.mkdtemp(path.join(os.tmpdir(), 'prdy-project-'));
try {
const created = await createPrd({
name: 'User Authentication',
projectPath: projectDir,
template: 'feature',
});
expect(created.title).toBe('User Authentication');
expect(created.status).toBe('draft');
expect(created.id).toMatch(/^user-authentication-\d{8}-\d{6}$/);
const loaded = await loadPrd(projectDir);
expect(loaded.id).toBe(created.id);
expect(loaded.title).toBe(created.title);
expect(loaded.sections.length).toBeGreaterThan(0);
const listed = await listPrds(projectDir);
expect(listed).toHaveLength(1);
expect(listed[0]?.id).toBe(created.id);
} finally {
await fs.rm(projectDir, { recursive: true, force: true });
}
});
});

View File

@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest';
import { BUILTIN_PRD_TEMPLATES } from '../src/templates.js';
describe('built-in PRD templates', () => {
it('includes software, feature, and spike templates with required fields', () => {
expect(BUILTIN_PRD_TEMPLATES.software).toBeDefined();
expect(BUILTIN_PRD_TEMPLATES.feature).toBeDefined();
expect(BUILTIN_PRD_TEMPLATES.spike).toBeDefined();
for (const template of Object.values(BUILTIN_PRD_TEMPLATES)) {
expect(template.sections.length).toBeGreaterThan(0);
expect(template.fields.length).toBeGreaterThan(0);
for (const section of template.sections) {
expect(section.id.length).toBeGreaterThan(0);
expect(section.title.length).toBeGreaterThan(0);
expect(section.fields.length).toBeGreaterThan(0);
}
}
});
});

View File

@@ -0,0 +1,5 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": { "outDir": "dist", "rootDir": "src" },
"include": ["src"]
}

View File

@@ -0,0 +1,20 @@
import tsEslintPlugin from '@typescript-eslint/eslint-plugin';
import tsEslintParser from '@typescript-eslint/parser';
export default [
{
files: ['src/**/*.ts'],
languageOptions: {
parser: tsEslintParser,
sourceType: 'module',
ecmaVersion: 'latest',
},
plugins: {
'@typescript-eslint': tsEslintPlugin,
},
rules: {
'no-console': 'off',
'@typescript-eslint/no-explicit-any': 'off',
},
},
];

View File

@@ -0,0 +1,30 @@
{
"name": "@mosaic/quality-rails",
"version": "0.1.0",
"type": "module",
"description": "Mosaic quality rails - TypeScript code quality scaffolder",
"scripts": {
"build": "tsc -p tsconfig.json",
"typecheck": "tsc --noEmit",
"lint": "eslint src/",
"test": "vitest run"
},
"dependencies": {
"@mosaic/types": "workspace:*",
"commander": "^13",
"js-yaml": "^4"
},
"devDependencies": {
"@types/node": "^22",
"@types/js-yaml": "^4",
"@typescript-eslint/eslint-plugin": "^8",
"@typescript-eslint/parser": "^8",
"eslint": "^9",
"typescript": "^5",
"vitest": "^2"
},
"publishConfig": {
"registry": "https://git.mosaicstack.dev/api/packages/mosaic/npm",
"access": "public"
}
}

View File

@@ -0,0 +1,193 @@
import { constants } from 'node:fs';
import { access } from 'node:fs/promises';
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { Command } from 'commander';
import { detectProjectKind } from './detect.js';
import { scaffoldQualityRails } from './scaffolder.js';
import type { ProjectKind, QualityProfile, RailsConfig } from './types.js';
const VALID_PROFILES: readonly QualityProfile[] = ['strict', 'standard', 'minimal'];
async function fileExists(filePath: string): Promise<boolean> {
try {
await access(filePath, constants.F_OK);
return true;
} catch {
return false;
}
}
function parseProfile(rawProfile: string): QualityProfile {
if (VALID_PROFILES.includes(rawProfile as QualityProfile)) {
return rawProfile as QualityProfile;
}
throw new Error(`Invalid profile: ${rawProfile}. Use one of ${VALID_PROFILES.join(', ')}.`);
}
function defaultLinters(kind: ProjectKind): string[] {
if (kind === 'node') {
return ['eslint', 'biome'];
}
if (kind === 'python') {
return ['ruff'];
}
if (kind === 'rust') {
return ['clippy'];
}
return [];
}
function defaultFormatters(kind: ProjectKind): string[] {
if (kind === 'node') {
return ['prettier'];
}
if (kind === 'python') {
return ['black'];
}
if (kind === 'rust') {
return ['rustfmt'];
}
return [];
}
function expectedFilesForKind(kind: ProjectKind): string[] {
if (kind === 'node') {
return ['.eslintrc', 'biome.json', '.githooks/pre-commit', 'PR-CHECKLIST.md'];
}
if (kind === 'python') {
return ['pyproject.toml', '.githooks/pre-commit', 'PR-CHECKLIST.md'];
}
if (kind === 'rust') {
return ['rustfmt.toml', '.githooks/pre-commit', 'PR-CHECKLIST.md'];
}
return ['.githooks/pre-commit', 'PR-CHECKLIST.md'];
}
function printScaffoldResult(config: RailsConfig, filesWritten: string[], warnings: string[], commandsToRun: string[]): void {
console.log(`[quality-rails] initialized at ${config.projectPath}`);
console.log(`kind=${config.kind} profile=${config.profile}`);
if (filesWritten.length > 0) {
console.log('files written:');
for (const filePath of filesWritten) {
console.log(` - ${filePath}`);
}
}
if (commandsToRun.length > 0) {
console.log('run next:');
for (const command of commandsToRun) {
console.log(` - ${command}`);
}
}
if (warnings.length > 0) {
console.log('warnings:');
for (const warning of warnings) {
console.log(` - ${warning}`);
}
}
}
export function createQualityRailsCli(): Command {
const program = new Command('mosaic');
const qualityRails = program.command('quality-rails').description('Manage quality rails scaffolding');
qualityRails
.command('init')
.requiredOption('--project <path>', 'Project path')
.option('--profile <profile>', 'strict|standard|minimal', 'standard')
.action(async (options: { project: string; profile: string }) => {
const profile = parseProfile(options.profile);
const projectPath = resolve(options.project);
const kind = await detectProjectKind(projectPath);
const config: RailsConfig = {
projectPath,
kind,
profile,
linters: defaultLinters(kind),
formatters: defaultFormatters(kind),
hooks: true,
};
const result = await scaffoldQualityRails(config);
printScaffoldResult(config, result.filesWritten, result.warnings, result.commandsToRun);
});
qualityRails
.command('check')
.requiredOption('--project <path>', 'Project path')
.action(async (options: { project: string }) => {
const projectPath = resolve(options.project);
const kind = await detectProjectKind(projectPath);
const expected = expectedFilesForKind(kind);
const missing: string[] = [];
for (const relativePath of expected) {
const exists = await fileExists(resolve(projectPath, relativePath));
if (!exists) {
missing.push(relativePath);
}
}
if (missing.length > 0) {
console.error('[quality-rails] missing files:');
for (const relativePath of missing) {
console.error(` - ${relativePath}`);
}
process.exitCode = 1;
return;
}
console.log(`[quality-rails] all expected files present for ${kind} project`);
});
qualityRails
.command('doctor')
.requiredOption('--project <path>', 'Project path')
.action(async (options: { project: string }) => {
const projectPath = resolve(options.project);
const kind = await detectProjectKind(projectPath);
const expected = expectedFilesForKind(kind);
console.log(`[quality-rails] doctor for ${projectPath}`);
console.log(`detected project kind: ${kind}`);
for (const relativePath of expected) {
const exists = await fileExists(resolve(projectPath, relativePath));
console.log(` - ${exists ? 'ok' : 'missing'}: ${relativePath}`);
}
if (kind === 'unknown') {
console.log('recommendation: add package.json, pyproject.toml, or Cargo.toml for better defaults.');
}
});
return program;
}
export async function runQualityRailsCli(argv: string[] = process.argv): Promise<void> {
const program = createQualityRailsCli();
await program.parseAsync(argv);
}
const entryPath = process.argv[1] ? resolve(process.argv[1]) : '';
if (entryPath.length > 0 && entryPath === fileURLToPath(import.meta.url)) {
runQualityRailsCli().catch((error: unknown) => {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
});
}

View File

@@ -0,0 +1,30 @@
import { constants } from 'node:fs';
import { access } from 'node:fs/promises';
import { join } from 'node:path';
import type { ProjectKind } from './types.js';
async function fileExists(filePath: string): Promise<boolean> {
try {
await access(filePath, constants.F_OK);
return true;
} catch {
return false;
}
}
export async function detectProjectKind(projectPath: string): Promise<ProjectKind> {
if (await fileExists(join(projectPath, 'package.json'))) {
return 'node';
}
if (await fileExists(join(projectPath, 'pyproject.toml'))) {
return 'python';
}
if (await fileExists(join(projectPath, 'Cargo.toml'))) {
return 'rust';
}
return 'unknown';
}

View File

@@ -0,0 +1,5 @@
export * from './cli.js';
export * from './detect.js';
export * from './scaffolder.js';
export * from './templates.js';
export * from './types.js';

View File

@@ -0,0 +1,201 @@
import { spawn } from 'node:child_process';
import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import {
biomeTemplate,
eslintTemplate,
prChecklistTemplate,
preCommitHookTemplate,
pyprojectSection,
rustfmtTemplate,
} from './templates.js';
import type { RailsConfig, ScaffoldResult } from './types.js';
const PYPROJECT_START_MARKER = '# >>> mosaic-quality-rails >>>';
const PYPROJECT_END_MARKER = '# <<< mosaic-quality-rails <<<';
async function ensureDirectory(filePath: string): Promise<void> {
await mkdir(dirname(filePath), { recursive: true });
}
async function writeRelativeFile(
projectPath: string,
relativePath: string,
contents: string,
result: ScaffoldResult,
): Promise<void> {
const absolutePath = join(projectPath, relativePath);
await ensureDirectory(absolutePath);
await writeFile(absolutePath, contents, { encoding: 'utf8', mode: 0o644 });
result.filesWritten.push(relativePath);
}
async function upsertPyproject(
projectPath: string,
profile: RailsConfig['profile'],
result: ScaffoldResult,
): Promise<void> {
const pyprojectPath = join(projectPath, 'pyproject.toml');
const nextSection = pyprojectSection(profile);
let previous = '';
try {
previous = await readFile(pyprojectPath, 'utf8');
} catch {
previous = '';
}
const existingStart = previous.indexOf(PYPROJECT_START_MARKER);
const existingEnd = previous.indexOf(PYPROJECT_END_MARKER);
if (existingStart >= 0 && existingEnd > existingStart) {
const before = previous.slice(0, existingStart).trimEnd();
const after = previous.slice(existingEnd + PYPROJECT_END_MARKER.length).trimStart();
const rebuilt = [before, nextSection.trim(), after]
.filter((segment) => segment.length > 0)
.join('\n\n');
await writeRelativeFile(projectPath, 'pyproject.toml', `${rebuilt}\n`, result);
return;
}
const separator = previous.trim().length > 0 ? '\n\n' : '';
await writeRelativeFile(projectPath, 'pyproject.toml', `${previous.trimEnd()}${separator}${nextSection}`, result);
}
function runCommand(command: string, args: string[], cwd: string): Promise<void> {
return new Promise((resolve, reject) => {
const child = spawn(command, args, {
cwd,
stdio: 'ignore',
env: process.env,
});
child.on('error', reject);
child.on('exit', (code) => {
if (code === 0) {
resolve();
return;
}
reject(new Error(`Command failed (${code ?? 'unknown'}): ${command} ${args.join(' ')}`));
});
});
}
function buildNodeDevDependencies(config: RailsConfig): string[] {
const dependencies = new Set<string>();
if (config.linters.some((linter) => linter.toLowerCase() === 'eslint')) {
dependencies.add('eslint');
dependencies.add('@typescript-eslint/parser');
dependencies.add('@typescript-eslint/eslint-plugin');
}
if (config.linters.some((linter) => linter.toLowerCase() === 'biome')) {
dependencies.add('@biomejs/biome');
}
if (config.formatters.some((formatter) => formatter.toLowerCase() === 'prettier')) {
dependencies.add('prettier');
}
if (config.hooks) {
dependencies.add('husky');
}
return [...dependencies];
}
async function installNodeDependencies(config: RailsConfig, result: ScaffoldResult): Promise<void> {
const dependencies = buildNodeDevDependencies(config);
if (dependencies.length === 0) {
return;
}
const commandLine = `pnpm add -D ${dependencies.join(' ')}`;
if (process.env.MOSAIC_QUALITY_RAILS_SKIP_INSTALL === '1') {
result.commandsToRun.push(commandLine);
return;
}
try {
await runCommand('pnpm', ['add', '-D', ...dependencies], config.projectPath);
} catch (error) {
result.warnings.push(
`Failed to auto-install Node dependencies: ${error instanceof Error ? error.message : String(error)}`,
);
result.commandsToRun.push(commandLine);
}
}
export async function scaffoldQualityRails(config: RailsConfig): Promise<ScaffoldResult> {
const result: ScaffoldResult = {
filesWritten: [],
commandsToRun: [],
warnings: [],
};
const normalizedLinters = new Set(config.linters.map((linter) => linter.toLowerCase()));
if (config.kind === 'node') {
if (normalizedLinters.has('eslint')) {
await writeRelativeFile(
config.projectPath,
'.eslintrc',
eslintTemplate(config.profile),
result,
);
}
if (normalizedLinters.has('biome')) {
await writeRelativeFile(
config.projectPath,
'biome.json',
biomeTemplate(config.profile),
result,
);
}
await installNodeDependencies(config, result);
}
if (config.kind === 'python') {
await upsertPyproject(config.projectPath, config.profile, result);
}
if (config.kind === 'rust') {
await writeRelativeFile(
config.projectPath,
'rustfmt.toml',
rustfmtTemplate(config.profile),
result,
);
}
if (config.hooks) {
await writeRelativeFile(
config.projectPath,
'.githooks/pre-commit',
preCommitHookTemplate(config),
result,
);
await chmod(join(config.projectPath, '.githooks/pre-commit'), 0o755);
result.commandsToRun.push('git config core.hooksPath .githooks');
}
await writeRelativeFile(
config.projectPath,
'PR-CHECKLIST.md',
prChecklistTemplate(config.profile),
result,
);
if (config.kind === 'unknown') {
result.warnings.push(
'Unable to detect project kind. Generated generic rails only (hooks + PR checklist).',
);
}
return result;
}

View File

@@ -0,0 +1,182 @@
import type { QualityProfile, RailsConfig } from './types.js';
const PROFILE_TO_MAX_WARNINGS: Record<QualityProfile, number> = {
strict: 0,
standard: 10,
minimal: 50,
};
const PROFILE_TO_LINE_LENGTH: Record<QualityProfile, number> = {
strict: 100,
standard: 110,
minimal: 120,
};
export function eslintTemplate(profile: QualityProfile): string {
return `${JSON.stringify(
{
root: true,
parser: '@typescript-eslint/parser',
plugins: ['@typescript-eslint'],
extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended'],
env: {
node: true,
es2022: true,
},
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
},
rules: {
'no-console': profile === 'strict' ? 'error' : 'warn',
'@typescript-eslint/no-explicit-any':
profile === 'minimal' ? 'off' : profile === 'strict' ? 'error' : 'warn',
'@typescript-eslint/explicit-function-return-type':
profile === 'strict' ? 'warn' : 'off',
'max-lines-per-function': [
profile === 'minimal' ? 'off' : 'warn',
{
max: profile === 'strict' ? 60 : 100,
skipBlankLines: true,
skipComments: true,
},
],
},
},
null,
2,
)}\n`;
}
export function biomeTemplate(profile: QualityProfile): string {
return `${JSON.stringify(
{
'$schema': 'https://biomejs.dev/schemas/1.8.3/schema.json',
formatter: {
enabled: true,
indentStyle: 'space',
indentWidth: 2,
lineWidth: PROFILE_TO_LINE_LENGTH[profile],
},
linter: {
enabled: true,
rules: {
recommended: true,
suspicious: {
noConsole: profile === 'strict' ? 'error' : 'warn',
},
complexity: {
noExcessiveCognitiveComplexity:
profile === 'strict' ? 'warn' : profile === 'standard' ? 'info' : 'off',
},
},
},
javascript: {
formatter: {
quoteStyle: 'single',
trailingCommas: 'all',
},
},
},
null,
2,
)}\n`;
}
export function pyprojectSection(profile: QualityProfile): string {
const lineLength = PROFILE_TO_LINE_LENGTH[profile];
return [
'# >>> mosaic-quality-rails >>>',
'[tool.ruff]',
`line-length = ${lineLength}`,
'target-version = "py311"',
'',
'[tool.ruff.lint]',
'select = ["E", "F", "I", "UP", "B"]',
`ignore = ${profile === 'minimal' ? '[]' : '["E501"]'}`,
'',
'[tool.black]',
`line-length = ${lineLength}`,
'',
'# <<< mosaic-quality-rails <<<',
'',
].join('\n');
}
export function rustfmtTemplate(profile: QualityProfile): string {
const maxWidth = PROFILE_TO_LINE_LENGTH[profile];
const useSmallHeuristics = profile === 'strict' ? 'Max' : 'Default';
return [
`max_width = ${maxWidth}`,
`use_small_heuristics = "${useSmallHeuristics}"`,
`imports_granularity = "${profile === 'minimal' ? 'Crate' : 'Module'}"`,
`group_imports = "${profile === 'strict' ? 'StdExternalCrate' : 'Preserve'}"`,
'',
].join('\n');
}
function resolveHookCommands(config: RailsConfig): string[] {
const commands: string[] = [];
if (config.kind === 'node') {
if (config.linters.some((linter) => linter.toLowerCase() === 'eslint')) {
commands.push('pnpm lint');
}
if (config.linters.some((linter) => linter.toLowerCase() === 'biome')) {
commands.push('pnpm biome check .');
}
if (config.formatters.some((formatter) => formatter.toLowerCase() === 'prettier')) {
commands.push('pnpm prettier --check .');
}
commands.push('pnpm test --if-present');
}
if (config.kind === 'python') {
commands.push('ruff check .');
commands.push('black --check .');
}
if (config.kind === 'rust') {
commands.push('cargo fmt --check');
commands.push('cargo clippy --all-targets --all-features -- -D warnings');
}
if (commands.length === 0) {
commands.push('echo "No quality commands configured for this project kind"');
}
return commands;
}
export function preCommitHookTemplate(config: RailsConfig): string {
const commands = resolveHookCommands(config)
.map((command) => `${command} || exit 1`)
.join('\n');
return [
'#!/usr/bin/env sh',
'set -eu',
'',
'echo "[quality-rails] Running pre-commit checks..."',
commands,
'echo "[quality-rails] Checks passed."',
'',
].join('\n');
}
export function prChecklistTemplate(profile: QualityProfile): string {
return [
'# Code Review Checklist',
'',
`Profile: **${profile}**`,
'',
'- [ ] Requirements mapped to tests',
'- [ ] Error handling covers unhappy paths',
'- [ ] Lint and typecheck are clean',
'- [ ] Test suite passes',
'- [ ] Security-sensitive paths reviewed',
`- [ ] Warnings count <= ${PROFILE_TO_MAX_WARNINGS[profile]}`,
'',
].join('\n');
}

View File

@@ -0,0 +1,18 @@
export type ProjectKind = 'node' | 'python' | 'rust' | 'unknown';
export type QualityProfile = 'strict' | 'standard' | 'minimal';
export interface RailsConfig {
projectPath: string;
kind: ProjectKind;
profile: QualityProfile;
linters: string[];
formatters: string[];
hooks: boolean;
}
export interface ScaffoldResult {
filesWritten: string[];
commandsToRun: string[];
warnings: string[];
}

View File

@@ -0,0 +1,40 @@
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
import { detectProjectKind } from '../src/detect.js';
async function withTempDir(run: (directory: string) => Promise<void>): Promise<void> {
const directory = await mkdtemp(join(tmpdir(), 'quality-rails-detect-'));
try {
await run(directory);
} finally {
await rm(directory, { recursive: true, force: true });
}
}
describe('detectProjectKind', () => {
it('returns node when package.json exists', async () => {
await withTempDir(async (directory) => {
await writeFile(join(directory, 'package.json'), '{"name":"fixture"}\n', 'utf8');
await expect(detectProjectKind(directory)).resolves.toBe('node');
});
});
it('returns python when pyproject.toml exists and package.json does not', async () => {
await withTempDir(async (directory) => {
await writeFile(join(directory, 'pyproject.toml'), '[project]\nname = "fixture"\n', 'utf8');
await expect(detectProjectKind(directory)).resolves.toBe('python');
});
});
it('returns unknown when no known project files exist', async () => {
await withTempDir(async (directory) => {
await expect(detectProjectKind(directory)).resolves.toBe('unknown');
});
});
});

View File

@@ -0,0 +1,57 @@
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
import { scaffoldQualityRails } from '../src/scaffolder.js';
import type { RailsConfig } from '../src/types.js';
async function withTempDir(run: (directory: string) => Promise<void>): Promise<void> {
const directory = await mkdtemp(join(tmpdir(), 'quality-rails-scaffold-'));
try {
await run(directory);
} finally {
await rm(directory, { recursive: true, force: true });
}
}
describe('scaffoldQualityRails', () => {
it('writes expected node quality rails files', async () => {
await withTempDir(async (directory) => {
await writeFile(join(directory, 'package.json'), '{"name":"fixture"}\n', 'utf8');
const previous = process.env.MOSAIC_QUALITY_RAILS_SKIP_INSTALL;
process.env.MOSAIC_QUALITY_RAILS_SKIP_INSTALL = '1';
const config: RailsConfig = {
projectPath: directory,
kind: 'node',
profile: 'strict',
linters: ['eslint', 'biome'],
formatters: ['prettier'],
hooks: true,
};
const result = await scaffoldQualityRails(config);
process.env.MOSAIC_QUALITY_RAILS_SKIP_INSTALL = previous;
await expect(readFile(join(directory, '.eslintrc'), 'utf8')).resolves.toContain('parser');
await expect(readFile(join(directory, 'biome.json'), 'utf8')).resolves.toContain('"formatter"');
await expect(readFile(join(directory, '.githooks', 'pre-commit'), 'utf8')).resolves.toContain('pnpm lint');
await expect(readFile(join(directory, 'PR-CHECKLIST.md'), 'utf8')).resolves.toContain('Code Review Checklist');
expect(result.filesWritten).toEqual(
expect.arrayContaining([
'.eslintrc',
'biome.json',
'.githooks/pre-commit',
'PR-CHECKLIST.md',
]),
);
expect(result.commandsToRun).toContain('git config core.hooksPath .githooks');
expect(result.warnings).toHaveLength(0);
});
});
});

View File

@@ -0,0 +1,5 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": { "outDir": "dist", "rootDir": "src" },
"include": ["src"]
}

View File

@@ -340,3 +340,315 @@ export interface WizardState {
readonly runtimes: RuntimeState;
readonly selectedSkills: readonly string[];
}
// === Brain (Structured Data) ===
export type BrainDomain =
| 'work'
| 'software-dev'
| 'homelab'
| 'family'
| 'marriage'
| 'finances'
| 'fitness'
| 'music'
| 'home-improvement'
| 'woodworking'
| 'home'
| 'consulting'
| 'personal';
export type BrainTaskStatus =
| 'backlog'
| 'scheduled'
| 'in-progress'
| 'blocked'
| 'done'
| 'cancelled';
export type BrainProjectStatus =
| 'planning'
| 'active'
| 'paused'
| 'blocked'
| 'completed'
| 'archived';
export type BrainAgentStatus = 'active' | 'idle' | 'blocked' | 'completed';
export type BrainEventType =
| 'meeting'
| 'deadline'
| 'maintenance'
| 'event'
| 'recurring'
| 'milestone'
| 'task'
| 'constraint'
| 'client-work'
| 'appointment'
| 'reminder'
| 'conflict'
| 'time-off';
export type BrainEventStatus =
| 'scheduled'
| 'confirmed'
| 'tentative'
| 'completed'
| 'cancelled'
| 'done'
| 'blocked'
| 'postponed'
| 'deferred'
| 'in-progress'
| 'pending-approval'
| 'canceled'
| 'needs-resolution';
export type BrainMissionStatus =
| 'planning'
| 'active'
| 'blocked'
| 'completed'
| 'cancelled';
// --- Brain: Task ---
export interface BrainTask {
readonly id: string;
readonly title: string;
readonly domain: BrainDomain;
readonly project?: string | null;
readonly priority: TaskPriority;
readonly status: BrainTaskStatus;
readonly progress?: number | null;
readonly due?: string | null;
readonly blocks?: readonly string[];
readonly blocked_by?: readonly string[];
readonly related?: readonly string[];
readonly canonical_source?: string | null;
readonly assignee?: string | null;
readonly created: string;
readonly updated: string;
readonly notes?: string | null;
readonly notes_nontechnical?: string | null;
}
// --- Brain: Project ---
export interface BrainProject {
readonly id: string;
readonly name: string;
readonly description?: string | null;
readonly domain: BrainDomain;
readonly status: BrainProjectStatus;
readonly priority: number;
readonly progress?: number | null;
readonly repo?: string | null;
readonly branch?: string | null;
readonly current_milestone?: string | null;
readonly next_milestone?: string | null;
readonly blocker?: string | null;
readonly owner?: string | null;
readonly docs_path?: string | null;
readonly created: string;
readonly updated: string;
readonly notes?: string | null;
readonly notes_nontechnical?: string | null;
}
// --- Brain: Event ---
export interface BrainEvent {
readonly id: string;
readonly title: string;
readonly date: string;
readonly end_date?: string | null;
readonly time?: string | null;
readonly end_time?: string | null;
readonly domain: BrainDomain;
readonly type: BrainEventType;
readonly status?: BrainEventStatus;
readonly priority?: TaskPriority | null;
readonly recur?: boolean | null;
readonly recur_rate?: string | null;
readonly recur_start?: string | null;
readonly recur_end?: string | null;
readonly location?: string | null;
readonly project?: string | null;
readonly related_task?: string | null;
readonly related_tasks?: readonly string[];
readonly notes?: string | null;
readonly gcal_id?: string | null;
readonly ics_uid?: string | null;
}
// --- Brain: Agent ---
export interface BrainAgent {
readonly id: string;
readonly project: string;
readonly focus?: string | null;
readonly repo?: string | null;
readonly branch?: string | null;
readonly status: BrainAgentStatus;
readonly workload?: 'light' | 'medium' | 'heavy' | null;
readonly next_step?: string | null;
readonly blocker?: string | null;
readonly updated: string;
}
// --- Brain: Ticket ---
export interface BrainTicket {
readonly id: string;
readonly title: string;
readonly status: number;
readonly priority: number;
readonly urgency: number;
readonly impact: number;
readonly date_creation: string;
readonly date_mod: string;
readonly content?: string | null;
readonly assigned_to?: string | null;
}
// --- Brain: Appreciation ---
export interface BrainAppreciation {
readonly date: string;
readonly from: 'jason' | 'melanie';
readonly to: 'jason' | 'melanie';
readonly text: string;
}
// --- Brain: Mission ---
export interface BrainMission {
readonly id: string;
readonly title: string;
readonly project: string;
readonly prd_path?: string | null;
readonly status: BrainMissionStatus;
readonly created: string;
readonly updated: string;
readonly notes?: string | null;
}
// --- Brain: Mission Task ---
export interface BrainMissionTask {
readonly id: string;
readonly mission_id: string;
readonly title: string;
readonly phase?: string | null;
readonly status: BrainTaskStatus;
readonly priority: TaskPriority;
readonly dependencies: readonly string[];
readonly assigned_to?: string | null;
readonly pr?: string | null;
readonly order: number;
readonly created: string;
readonly updated: string;
readonly completed_at?: string | null;
readonly notes?: string | null;
}
// --- Brain: Filter/Query Types ---
export interface BrainTaskFilters {
readonly status?: BrainTaskStatus;
readonly priority?: TaskPriority;
readonly domain?: BrainDomain;
readonly project?: string;
readonly due_before?: string;
readonly due_after?: string;
readonly assignee?: string;
readonly limit?: number;
}
export interface BrainProjectFilters {
readonly status?: BrainProjectStatus;
readonly domain?: BrainDomain;
readonly priority_min?: number;
readonly priority_max?: number;
readonly limit?: number;
}
export interface BrainEventFilters {
readonly date_from?: string;
readonly date_to?: string;
readonly domain?: BrainDomain;
readonly type?: BrainEventType;
readonly status?: BrainEventStatus;
readonly limit?: number;
}
export interface BrainMissionFilters {
readonly status?: BrainMissionStatus;
readonly project?: string;
readonly limit?: number;
}
export interface BrainMissionTaskFilters {
readonly mission_id: string;
readonly status?: BrainTaskStatus;
readonly phase?: string;
readonly priority?: TaskPriority;
}
// --- Brain: Computed Response Types ---
export interface BrainMissionSummary extends BrainMission {
readonly task_count: number;
readonly completed_count: number;
readonly progress: number;
readonly next_available: readonly BrainMissionTask[];
readonly blocked_tasks: readonly BrainMissionTask[];
}
export interface BrainTodaySummary {
readonly date: string;
readonly events_today: readonly BrainEvent[];
readonly events_upcoming: readonly BrainEvent[];
readonly tasks_near_term: readonly BrainTask[];
readonly tasks_blocked: readonly BrainTask[];
readonly tasks_stale: readonly BrainTask[];
readonly tasks_almost_done: readonly BrainTask[];
readonly active_missions: readonly BrainMissionSummary[];
readonly stats: BrainStats;
}
export interface BrainStats {
readonly tasks: number;
readonly projects: number;
readonly events: number;
readonly agents: number;
readonly tickets: number;
readonly missions: number;
readonly tasks_by_status: Readonly<Record<string, number>>;
readonly tasks_by_domain: Readonly<Record<string, number>>;
readonly projects_by_status: Readonly<Record<string, number>>;
}
export interface BrainStaleReport {
readonly days: number;
readonly tasks: readonly BrainTask[];
readonly projects: readonly BrainProject[];
}
export interface BrainAuditResult {
readonly orphan_refs: readonly string[];
readonly broken_dependencies: readonly string[];
readonly missing_required_fields: readonly string[];
readonly duplicate_ids: readonly string[];
}
export interface BrainSearchResult {
readonly collection: string;
readonly id: string;
readonly title: string;
readonly match_context: string;
readonly score: number;
}

1202
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff