Compare commits
5 Commits
feat/wave3
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| d3d9826076 | |||
| 5adb711a67 | |||
| 8371783587 | |||
| 95eed0739d | |||
| 7f7109fc09 |
40
docs/scratchpads/brain-service.md
Normal file
40
docs/scratchpads/brain-service.md
Normal 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
18
packages/brain/Dockerfile
Normal 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"]
|
||||||
45
packages/brain/package.json
Normal file
45
packages/brain/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
83
packages/brain/src/index.ts
Normal file
83
packages/brain/src/index.ts
Normal 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);
|
||||||
|
});
|
||||||
347
packages/brain/src/mcp/tools.ts
Normal file
347
packages/brain/src/mcp/tools.ts
Normal 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 };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
25
packages/brain/src/middleware/auth.ts
Normal file
25
packages/brain/src/middleware/auth.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
190
packages/brain/src/routes/api.ts
Normal file
190
packages/brain/src/routes/api.ts
Normal 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());
|
||||||
|
}
|
||||||
222
packages/brain/src/schemas.ts
Normal file
222
packages/brain/src/schemas.ts
Normal 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>;
|
||||||
536
packages/brain/src/storage/collections.ts
Normal file
536
packages/brain/src/storage/collections.ts
Normal 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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
156
packages/brain/src/storage/json-store.ts
Normal file
156
packages/brain/src/storage/json-store.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
8
packages/brain/tsconfig.json
Normal file
8
packages/brain/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"rootDir": "src",
|
||||||
|
"outDir": "dist"
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
5
packages/cli/bin/mosaic.ts
Normal file
5
packages/cli/bin/mosaic.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import { rootCommand } from '../src/root-command.js';
|
||||||
|
|
||||||
|
rootCommand.parseAsync(process.argv);
|
||||||
15
packages/cli/eslint.config.js
Normal file
15
packages/cli/eslint.config.js
Normal 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
33
packages/cli/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
15
packages/cli/src/commands/coord.ts
Normal file
15
packages/cli/src/commands/coord.ts
Normal 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);
|
||||||
|
}
|
||||||
13
packages/cli/src/commands/prdy.ts
Normal file
13
packages/cli/src/commands/prdy.ts
Normal 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;
|
||||||
|
});
|
||||||
|
}
|
||||||
15
packages/cli/src/commands/quality-rails.ts
Normal file
15
packages/cli/src/commands/quality-rails.ts
Normal 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;
|
||||||
|
});
|
||||||
|
}
|
||||||
15
packages/cli/src/commands/queue.ts
Normal file
15
packages/cli/src/commands/queue.ts
Normal 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);
|
||||||
|
}
|
||||||
1
packages/cli/src/index.ts
Normal file
1
packages/cli/src/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { rootCommand } from './root-command.js';
|
||||||
17
packages/cli/src/root-command.ts
Normal file
17
packages/cli/src/root-command.ts
Normal 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);
|
||||||
1
packages/cli/src/version.ts
Normal file
1
packages/cli/src/version.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export const VERSION = '0.1.0';
|
||||||
18
packages/cli/tests/root-command.test.ts
Normal file
18
packages/cli/tests/root-command.test.ts
Normal 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',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
5
packages/cli/tsconfig.json
Normal file
5
packages/cli/tsconfig.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": { "outDir": "dist", "rootDir": "." },
|
||||||
|
"include": ["src", "bin"]
|
||||||
|
}
|
||||||
24
packages/coord/package.json
Normal file
24
packages/coord/package.json
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"name": "@mosaic/coord",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"description": "Mosaic mission coordination — TypeScript rewrite of mosaic coord",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc -p tsconfig.json",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"lint": "echo 'ok'",
|
||||||
|
"test": "vitest run"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@mosaic/queue": "workspace:*",
|
||||||
|
"@mosaic/types": "workspace:*",
|
||||||
|
"commander": "^13",
|
||||||
|
"js-yaml": "^4"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/js-yaml": "^4",
|
||||||
|
"@types/node": "^22",
|
||||||
|
"typescript": "^5",
|
||||||
|
"vitest": "^2"
|
||||||
|
}
|
||||||
|
}
|
||||||
155
packages/coord/src/cli.ts
Normal file
155
packages/coord/src/cli.ts
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
import { Command } from 'commander';
|
||||||
|
|
||||||
|
import { createMission, loadMission } from './mission.js';
|
||||||
|
import { runTask, resumeTask } from './runner.js';
|
||||||
|
import { getMissionStatus } from './status.js';
|
||||||
|
import type { MissionStatusSummary } from './types.js';
|
||||||
|
|
||||||
|
interface InitCommandOptions {
|
||||||
|
readonly name: string;
|
||||||
|
readonly project?: string;
|
||||||
|
readonly prefix?: string;
|
||||||
|
readonly milestones?: string;
|
||||||
|
readonly qualityGates?: string;
|
||||||
|
readonly version?: string;
|
||||||
|
readonly description?: string;
|
||||||
|
readonly force?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RunCommandOptions {
|
||||||
|
readonly project: string;
|
||||||
|
readonly task: string;
|
||||||
|
readonly runtime?: 'claude' | 'codex';
|
||||||
|
readonly print?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StatusCommandOptions {
|
||||||
|
readonly project: string;
|
||||||
|
readonly format?: 'json' | 'table';
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseMilestones(value: string | undefined): string[] {
|
||||||
|
if (value === undefined || value.trim().length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return value
|
||||||
|
.split(',')
|
||||||
|
.map((entry) => entry.trim())
|
||||||
|
.filter((entry) => entry.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderStatusTable(status: MissionStatusSummary): string {
|
||||||
|
const lines = [
|
||||||
|
`Mission: ${status.mission.name} (${status.mission.id})`,
|
||||||
|
`Status: ${status.mission.status}`,
|
||||||
|
`Project: ${status.mission.projectPath}`,
|
||||||
|
`Milestones: ${status.milestones.completed}/${status.milestones.total} completed`,
|
||||||
|
`Tasks: total=${status.tasks.total}, done=${status.tasks.done}, in-progress=${status.tasks.inProgress}, pending=${status.tasks.pending}, blocked=${status.tasks.blocked}, cancelled=${status.tasks.cancelled}`,
|
||||||
|
`Next task: ${status.nextTaskId ?? '—'}`,
|
||||||
|
`Active session: ${status.activeSession?.sessionId ?? 'none'}`,
|
||||||
|
];
|
||||||
|
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildCoordCli(): Command {
|
||||||
|
const program = new Command();
|
||||||
|
program
|
||||||
|
.name('mosaic')
|
||||||
|
.description('Mosaic CLI')
|
||||||
|
.exitOverride();
|
||||||
|
|
||||||
|
const coord = program.command('coord').description('Mission coordination commands');
|
||||||
|
|
||||||
|
coord
|
||||||
|
.command('init')
|
||||||
|
.description('Initialize orchestrator mission state')
|
||||||
|
.requiredOption('--name <name>', 'Mission name')
|
||||||
|
.option('--project <path>', 'Project path')
|
||||||
|
.option('--prefix <prefix>', 'Task prefix')
|
||||||
|
.option('--milestones <comma-separated>', 'Milestone names')
|
||||||
|
.option('--quality-gates <command>', 'Quality gate command')
|
||||||
|
.option('--version <semver>', 'Milestone version')
|
||||||
|
.option('--description <description>', 'Mission description')
|
||||||
|
.option('--force', 'Overwrite active mission')
|
||||||
|
.action(async (options: InitCommandOptions) => {
|
||||||
|
const mission = await createMission({
|
||||||
|
name: options.name,
|
||||||
|
projectPath: options.project,
|
||||||
|
prefix: options.prefix,
|
||||||
|
milestones: parseMilestones(options.milestones),
|
||||||
|
qualityGates: options.qualityGates,
|
||||||
|
version: options.version,
|
||||||
|
description: options.description,
|
||||||
|
force: options.force,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
ok: true,
|
||||||
|
missionId: mission.id,
|
||||||
|
projectPath: mission.projectPath,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
coord
|
||||||
|
.command('run')
|
||||||
|
.description('Run a mission task')
|
||||||
|
.requiredOption('--project <path>', 'Project path')
|
||||||
|
.requiredOption('--task <id>', 'Task id')
|
||||||
|
.option('--runtime <runtime>', 'Runtime (claude|codex)')
|
||||||
|
.option('--print', 'Print launch command only')
|
||||||
|
.action(async (options: RunCommandOptions) => {
|
||||||
|
const mission = await loadMission(options.project);
|
||||||
|
const run = await runTask(mission, options.task, {
|
||||||
|
runtime: options.runtime,
|
||||||
|
mode: options.print === true ? 'print-only' : 'interactive',
|
||||||
|
});
|
||||||
|
console.log(JSON.stringify(run, null, 2));
|
||||||
|
});
|
||||||
|
|
||||||
|
coord
|
||||||
|
.command('resume')
|
||||||
|
.description('Resume a mission task after stale/dead session lock')
|
||||||
|
.requiredOption('--project <path>', 'Project path')
|
||||||
|
.requiredOption('--task <id>', 'Task id')
|
||||||
|
.option('--runtime <runtime>', 'Runtime (claude|codex)')
|
||||||
|
.option('--print', 'Print launch command only')
|
||||||
|
.action(async (options: RunCommandOptions) => {
|
||||||
|
const mission = await loadMission(options.project);
|
||||||
|
const run = await resumeTask(mission, options.task, {
|
||||||
|
runtime: options.runtime,
|
||||||
|
mode: options.print === true ? 'print-only' : 'interactive',
|
||||||
|
});
|
||||||
|
console.log(JSON.stringify(run, null, 2));
|
||||||
|
});
|
||||||
|
|
||||||
|
coord
|
||||||
|
.command('status')
|
||||||
|
.description('Show mission status')
|
||||||
|
.requiredOption('--project <path>', 'Project path')
|
||||||
|
.option('--format <format>', 'Output format (table|json)', 'table')
|
||||||
|
.action(async (options: StatusCommandOptions) => {
|
||||||
|
const mission = await loadMission(options.project);
|
||||||
|
const status = await getMissionStatus(mission);
|
||||||
|
|
||||||
|
if (options.format === 'json') {
|
||||||
|
console.log(JSON.stringify(status, null, 2));
|
||||||
|
} else {
|
||||||
|
console.log(renderStatusTable(status));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return program;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runCoordCli(argv: readonly string[] = process.argv): Promise<void> {
|
||||||
|
const program = buildCoordCli();
|
||||||
|
await program.parseAsync(argv);
|
||||||
|
}
|
||||||
34
packages/coord/src/index.ts
Normal file
34
packages/coord/src/index.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
export {
|
||||||
|
createMission,
|
||||||
|
loadMission,
|
||||||
|
missionFilePath,
|
||||||
|
saveMission,
|
||||||
|
} from './mission.js';
|
||||||
|
export {
|
||||||
|
parseTasksFile,
|
||||||
|
updateTaskStatus,
|
||||||
|
writeTasksFile,
|
||||||
|
} from './tasks-file.js';
|
||||||
|
export { runTask, resumeTask } from './runner.js';
|
||||||
|
export { getMissionStatus, getTaskStatus } from './status.js';
|
||||||
|
export { buildCoordCli, runCoordCli } from './cli.js';
|
||||||
|
export type {
|
||||||
|
CreateMissionOptions,
|
||||||
|
Mission,
|
||||||
|
MissionMilestone,
|
||||||
|
MissionRuntime,
|
||||||
|
MissionSession,
|
||||||
|
MissionStatus,
|
||||||
|
MissionStatusSummary,
|
||||||
|
MissionTask,
|
||||||
|
NextTaskCapsule,
|
||||||
|
RunTaskOptions,
|
||||||
|
TaskDetail,
|
||||||
|
TaskRun,
|
||||||
|
TaskStatus,
|
||||||
|
} from './types.js';
|
||||||
|
export {
|
||||||
|
isMissionStatus,
|
||||||
|
isTaskStatus,
|
||||||
|
normalizeTaskStatus,
|
||||||
|
} from './types.js';
|
||||||
415
packages/coord/src/mission.ts
Normal file
415
packages/coord/src/mission.ts
Normal file
@@ -0,0 +1,415 @@
|
|||||||
|
import { promises as fs } from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import { writeTasksFile } from './tasks-file.js';
|
||||||
|
import type {
|
||||||
|
CreateMissionOptions,
|
||||||
|
Mission,
|
||||||
|
MissionMilestone,
|
||||||
|
MissionSession,
|
||||||
|
} from './types.js';
|
||||||
|
import { isMissionStatus } from './types.js';
|
||||||
|
|
||||||
|
const DEFAULT_ORCHESTRATOR_DIR = '.mosaic/orchestrator';
|
||||||
|
const DEFAULT_MISSION_FILE = 'mission.json';
|
||||||
|
const DEFAULT_TASKS_FILE = 'docs/TASKS.md';
|
||||||
|
const DEFAULT_MANIFEST_FILE = 'docs/MISSION-MANIFEST.md';
|
||||||
|
const DEFAULT_SCRATCHPAD_DIR = 'docs/scratchpads';
|
||||||
|
const DEFAULT_MILESTONE_VERSION = '0.0.1';
|
||||||
|
|
||||||
|
function asRecord(value: unknown): Record<string, unknown> {
|
||||||
|
if (typeof value === 'object' && value !== null) {
|
||||||
|
return value as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function readString(
|
||||||
|
source: Record<string, unknown>,
|
||||||
|
...keys: readonly string[]
|
||||||
|
): string | undefined {
|
||||||
|
for (const key of keys) {
|
||||||
|
const value = source[key];
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (trimmed.length > 0) {
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readNumber(
|
||||||
|
source: Record<string, unknown>,
|
||||||
|
...keys: readonly string[]
|
||||||
|
): number | undefined {
|
||||||
|
for (const key of keys) {
|
||||||
|
const value = source[key];
|
||||||
|
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeMilestoneStatus(status: string | undefined): MissionMilestone['status'] {
|
||||||
|
if (status === 'completed') {
|
||||||
|
return 'completed';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 'in-progress') {
|
||||||
|
return 'in-progress';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 'blocked') {
|
||||||
|
return 'blocked';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'pending';
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSessionRuntime(runtime: string | undefined): MissionSession['runtime'] {
|
||||||
|
if (runtime === 'claude' || runtime === 'codex' || runtime === 'unknown') {
|
||||||
|
return runtime;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeEndedReason(reason: string | undefined): MissionSession['endedReason'] {
|
||||||
|
if (
|
||||||
|
reason === 'completed' ||
|
||||||
|
reason === 'paused' ||
|
||||||
|
reason === 'crashed' ||
|
||||||
|
reason === 'killed' ||
|
||||||
|
reason === 'unknown'
|
||||||
|
) {
|
||||||
|
return reason;
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeMission(raw: unknown, resolvedProjectPath: string): Mission {
|
||||||
|
const source = asRecord(raw);
|
||||||
|
|
||||||
|
const id = readString(source, 'id', 'mission_id') ?? 'mission';
|
||||||
|
const name = readString(source, 'name') ?? 'Unnamed Mission';
|
||||||
|
const statusCandidate = readString(source, 'status') ?? 'inactive';
|
||||||
|
const status = isMissionStatus(statusCandidate) ? statusCandidate : 'inactive';
|
||||||
|
|
||||||
|
const mission: Mission = {
|
||||||
|
schemaVersion: 1,
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
description: readString(source, 'description'),
|
||||||
|
projectPath:
|
||||||
|
readString(source, 'projectPath', 'project_path') ?? resolvedProjectPath,
|
||||||
|
createdAt:
|
||||||
|
readString(source, 'createdAt', 'created_at') ?? new Date().toISOString(),
|
||||||
|
status,
|
||||||
|
tasksFile: readString(source, 'tasksFile', 'tasks_file') ?? DEFAULT_TASKS_FILE,
|
||||||
|
manifestFile:
|
||||||
|
readString(source, 'manifestFile', 'manifest_file') ?? DEFAULT_MANIFEST_FILE,
|
||||||
|
scratchpadFile:
|
||||||
|
readString(source, 'scratchpadFile', 'scratchpad_file') ??
|
||||||
|
`${DEFAULT_SCRATCHPAD_DIR}/${id}.md`,
|
||||||
|
orchestratorDir:
|
||||||
|
readString(source, 'orchestratorDir', 'orchestrator_dir') ??
|
||||||
|
DEFAULT_ORCHESTRATOR_DIR,
|
||||||
|
taskPrefix: readString(source, 'taskPrefix', 'task_prefix'),
|
||||||
|
qualityGates: readString(source, 'qualityGates', 'quality_gates'),
|
||||||
|
milestoneVersion: readString(source, 'milestoneVersion', 'milestone_version'),
|
||||||
|
milestones: [],
|
||||||
|
sessions: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const milestonesRaw = Array.isArray(source.milestones) ? source.milestones : [];
|
||||||
|
mission.milestones = milestonesRaw.map((milestoneValue, index) => {
|
||||||
|
const milestone = asRecord(milestoneValue);
|
||||||
|
return {
|
||||||
|
id: readString(milestone, 'id') ?? `phase-${index + 1}`,
|
||||||
|
name: readString(milestone, 'name') ?? `Phase ${index + 1}`,
|
||||||
|
status: normalizeMilestoneStatus(readString(milestone, 'status')),
|
||||||
|
branch: readString(milestone, 'branch'),
|
||||||
|
issueRef: readString(milestone, 'issueRef', 'issue_ref'),
|
||||||
|
startedAt: readString(milestone, 'startedAt', 'started_at'),
|
||||||
|
completedAt: readString(milestone, 'completedAt', 'completed_at'),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const sessionsRaw = Array.isArray(source.sessions) ? source.sessions : [];
|
||||||
|
mission.sessions = sessionsRaw.map((sessionValue, index) => {
|
||||||
|
const session = asRecord(sessionValue);
|
||||||
|
const fallbackSessionId = `sess-${String(index + 1).padStart(3, '0')}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
sessionId: readString(session, 'sessionId', 'session_id') ?? fallbackSessionId,
|
||||||
|
runtime: normalizeSessionRuntime(readString(session, 'runtime')),
|
||||||
|
pid: readNumber(session, 'pid'),
|
||||||
|
startedAt:
|
||||||
|
readString(session, 'startedAt', 'started_at') ?? mission.createdAt,
|
||||||
|
endedAt: readString(session, 'endedAt', 'ended_at'),
|
||||||
|
endedReason: normalizeEndedReason(
|
||||||
|
readString(session, 'endedReason', 'ended_reason'),
|
||||||
|
),
|
||||||
|
milestoneId: readString(session, 'milestoneId', 'milestone_id'),
|
||||||
|
lastTaskId: readString(session, 'lastTaskId', 'last_task_id'),
|
||||||
|
durationSeconds: readNumber(session, 'durationSeconds', 'duration_seconds'),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return mission;
|
||||||
|
}
|
||||||
|
|
||||||
|
function missionIdFromName(name: string): string {
|
||||||
|
const slug = name
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '')
|
||||||
|
.replace(/-{2,}/g, '-');
|
||||||
|
const date = new Date().toISOString().slice(0, 10).replace(/-/g, '');
|
||||||
|
return `${slug || 'mission'}-${date}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toAbsolutePath(basePath: string, targetPath: string): string {
|
||||||
|
if (path.isAbsolute(targetPath)) {
|
||||||
|
return targetPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
return path.join(basePath, targetPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNodeErrorWithCode(error: unknown, code: string): boolean {
|
||||||
|
return (
|
||||||
|
typeof error === 'object' &&
|
||||||
|
error !== null &&
|
||||||
|
'code' in error &&
|
||||||
|
(error as { code?: string }).code === code
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fileExists(filePath: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await fs.access(filePath);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
if (isNodeErrorWithCode(error, 'ENOENT')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderManifest(mission: Mission): string {
|
||||||
|
const milestoneRows = mission.milestones
|
||||||
|
.map((milestone, index) => {
|
||||||
|
const issue = milestone.issueRef ?? '—';
|
||||||
|
const branch = milestone.branch ?? '—';
|
||||||
|
const started = milestone.startedAt ?? '—';
|
||||||
|
const completed = milestone.completedAt ?? '—';
|
||||||
|
return `| ${index + 1} | ${milestone.id} | ${milestone.name} | ${milestone.status} | ${branch} | ${issue} | ${started} | ${completed} |`;
|
||||||
|
})
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
const body = [
|
||||||
|
`# Mission Manifest — ${mission.name}`,
|
||||||
|
'',
|
||||||
|
'> Persistent document tracking full mission scope, status, and session history.',
|
||||||
|
'',
|
||||||
|
'## Mission',
|
||||||
|
'',
|
||||||
|
`**ID:** ${mission.id}`,
|
||||||
|
`**Statement:** ${mission.description ?? ''}`,
|
||||||
|
'**Phase:** Intake',
|
||||||
|
'**Current Milestone:** —',
|
||||||
|
`**Progress:** 0 / ${mission.milestones.length} milestones`,
|
||||||
|
`**Status:** ${mission.status}`,
|
||||||
|
`**Last Updated:** ${new Date().toISOString().replace('T', ' ').replace(/\..+/, ' UTC')}`,
|
||||||
|
'',
|
||||||
|
'## Milestones',
|
||||||
|
'',
|
||||||
|
'| # | ID | Name | Status | Branch | Issue | Started | Completed |',
|
||||||
|
'|---|-----|------|--------|--------|-------|---------|-----------|',
|
||||||
|
milestoneRows,
|
||||||
|
'',
|
||||||
|
'## Session History',
|
||||||
|
'',
|
||||||
|
'| Session | Runtime | Started | Duration | Ended Reason | Last Task |',
|
||||||
|
'|---------|---------|---------|----------|--------------|-----------|',
|
||||||
|
'',
|
||||||
|
`## Scratchpad\n\nPath: \`${mission.scratchpadFile}\``,
|
||||||
|
'',
|
||||||
|
];
|
||||||
|
|
||||||
|
return body.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderScratchpad(mission: Mission): string {
|
||||||
|
return [
|
||||||
|
`# Mission Scratchpad — ${mission.name}`,
|
||||||
|
'',
|
||||||
|
'> Append-only log. NEVER delete entries. NEVER overwrite sections.',
|
||||||
|
'',
|
||||||
|
'## Original Mission Prompt',
|
||||||
|
'',
|
||||||
|
'```',
|
||||||
|
'(Paste the mission prompt here on first session)',
|
||||||
|
'```',
|
||||||
|
'',
|
||||||
|
'## Planning Decisions',
|
||||||
|
'',
|
||||||
|
'## Session Log',
|
||||||
|
'',
|
||||||
|
'| Session | Date | Milestone | Tasks Done | Outcome |',
|
||||||
|
'|---------|------|-----------|------------|---------|',
|
||||||
|
'',
|
||||||
|
'## Open Questions',
|
||||||
|
'',
|
||||||
|
'## Corrections',
|
||||||
|
'',
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMissionFromOptions(
|
||||||
|
options: CreateMissionOptions,
|
||||||
|
resolvedProjectPath: string,
|
||||||
|
): Mission {
|
||||||
|
const id = missionIdFromName(options.name);
|
||||||
|
const createdAt = new Date().toISOString();
|
||||||
|
|
||||||
|
const milestones = (options.milestones ?? []).map((name, index) => {
|
||||||
|
const cleanName = name.trim();
|
||||||
|
const milestoneName = cleanName.length > 0 ? cleanName : `Phase ${index + 1}`;
|
||||||
|
return {
|
||||||
|
id: `phase-${index + 1}`,
|
||||||
|
name: milestoneName,
|
||||||
|
status: 'pending' as const,
|
||||||
|
branch: milestoneName
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, ''),
|
||||||
|
issueRef: undefined,
|
||||||
|
startedAt: undefined,
|
||||||
|
completedAt: undefined,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
schemaVersion: 1,
|
||||||
|
id,
|
||||||
|
name: options.name,
|
||||||
|
description: options.description,
|
||||||
|
projectPath: resolvedProjectPath,
|
||||||
|
createdAt,
|
||||||
|
status: 'active',
|
||||||
|
tasksFile: DEFAULT_TASKS_FILE,
|
||||||
|
manifestFile: DEFAULT_MANIFEST_FILE,
|
||||||
|
scratchpadFile: `${DEFAULT_SCRATCHPAD_DIR}/${id}.md`,
|
||||||
|
orchestratorDir: DEFAULT_ORCHESTRATOR_DIR,
|
||||||
|
taskPrefix: options.prefix,
|
||||||
|
qualityGates: options.qualityGates,
|
||||||
|
milestoneVersion: options.version ?? DEFAULT_MILESTONE_VERSION,
|
||||||
|
milestones,
|
||||||
|
sessions: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function missionFilePath(projectPath: string, mission?: Mission): string {
|
||||||
|
const orchestratorDir = mission?.orchestratorDir ?? DEFAULT_ORCHESTRATOR_DIR;
|
||||||
|
const baseDir = path.isAbsolute(orchestratorDir)
|
||||||
|
? orchestratorDir
|
||||||
|
: path.join(projectPath, orchestratorDir);
|
||||||
|
return path.join(baseDir, DEFAULT_MISSION_FILE);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveMission(mission: Mission): Promise<void> {
|
||||||
|
const filePath = missionFilePath(mission.projectPath, mission);
|
||||||
|
const payload = `${JSON.stringify(mission, null, 2)}\n`;
|
||||||
|
await writeFileAtomic(filePath, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createMission(options: CreateMissionOptions): Promise<Mission> {
|
||||||
|
const name = options.name.trim();
|
||||||
|
if (name.length === 0) {
|
||||||
|
throw new Error('Mission name is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedProjectPath = path.resolve(options.projectPath ?? process.cwd());
|
||||||
|
const mission = buildMissionFromOptions({ ...options, name }, resolvedProjectPath);
|
||||||
|
|
||||||
|
const missionPath = missionFilePath(resolvedProjectPath, mission);
|
||||||
|
const hasExistingMission = await fileExists(missionPath);
|
||||||
|
|
||||||
|
if (hasExistingMission) {
|
||||||
|
const existingRaw = await fs.readFile(missionPath, 'utf8');
|
||||||
|
const existingMission = normalizeMission(JSON.parse(existingRaw), resolvedProjectPath);
|
||||||
|
const active = existingMission.status === 'active' || existingMission.status === 'paused';
|
||||||
|
if (active && options.force !== true) {
|
||||||
|
throw new Error(
|
||||||
|
`Active mission exists: ${existingMission.name} (${existingMission.status}). Use force to overwrite.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await saveMission(mission);
|
||||||
|
|
||||||
|
const manifestPath = toAbsolutePath(resolvedProjectPath, mission.manifestFile);
|
||||||
|
const scratchpadPath = toAbsolutePath(resolvedProjectPath, mission.scratchpadFile);
|
||||||
|
const tasksPath = toAbsolutePath(resolvedProjectPath, mission.tasksFile);
|
||||||
|
|
||||||
|
if (options.force === true || !(await fileExists(manifestPath))) {
|
||||||
|
await writeFileAtomic(manifestPath, renderManifest(mission));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(await fileExists(scratchpadPath))) {
|
||||||
|
await writeFileAtomic(scratchpadPath, renderScratchpad(mission));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(await fileExists(tasksPath))) {
|
||||||
|
await writeFileAtomic(tasksPath, writeTasksFile([]));
|
||||||
|
}
|
||||||
|
|
||||||
|
return mission;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadMission(projectPath: string): Promise<Mission> {
|
||||||
|
const resolvedProjectPath = path.resolve(projectPath);
|
||||||
|
const filePath = missionFilePath(resolvedProjectPath);
|
||||||
|
|
||||||
|
let raw: string;
|
||||||
|
try {
|
||||||
|
raw = await fs.readFile(filePath, 'utf8');
|
||||||
|
} catch (error) {
|
||||||
|
if (isNodeErrorWithCode(error, 'ENOENT')) {
|
||||||
|
throw new Error(`No mission found at ${filePath}`);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mission = normalizeMission(JSON.parse(raw), resolvedProjectPath);
|
||||||
|
if (mission.status === 'inactive') {
|
||||||
|
throw new Error('Mission exists but is inactive. Re-initialize with mosaic coord init.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return mission;
|
||||||
|
}
|
||||||
488
packages/coord/src/runner.ts
Normal file
488
packages/coord/src/runner.ts
Normal file
@@ -0,0 +1,488 @@
|
|||||||
|
import { spawn, spawnSync } from 'node:child_process';
|
||||||
|
import { promises as fs } from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import { loadMission, saveMission } from './mission.js';
|
||||||
|
import { parseTasksFile, updateTaskStatus } from './tasks-file.js';
|
||||||
|
import type {
|
||||||
|
Mission,
|
||||||
|
MissionMilestone,
|
||||||
|
MissionSession,
|
||||||
|
RunTaskOptions,
|
||||||
|
TaskRun,
|
||||||
|
} from './types.js';
|
||||||
|
|
||||||
|
const SESSION_LOCK_FILE = 'session.lock';
|
||||||
|
const NEXT_TASK_FILE = 'next-task.json';
|
||||||
|
|
||||||
|
interface SessionLockState {
|
||||||
|
session_id: string;
|
||||||
|
runtime: 'claude' | 'codex';
|
||||||
|
pid: number;
|
||||||
|
started_at: string;
|
||||||
|
project_path: string;
|
||||||
|
milestone_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function orchestratorDirPath(mission: Mission): string {
|
||||||
|
if (path.isAbsolute(mission.orchestratorDir)) {
|
||||||
|
return mission.orchestratorDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
return path.join(mission.projectPath, mission.orchestratorDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sessionLockPath(mission: Mission): string {
|
||||||
|
return path.join(orchestratorDirPath(mission), SESSION_LOCK_FILE);
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextTaskCapsulePath(mission: Mission): string {
|
||||||
|
return path.join(orchestratorDirPath(mission), NEXT_TASK_FILE);
|
||||||
|
}
|
||||||
|
|
||||||
|
function tasksFilePath(mission: Mission): string {
|
||||||
|
if (path.isAbsolute(mission.tasksFile)) {
|
||||||
|
return mission.tasksFile;
|
||||||
|
}
|
||||||
|
|
||||||
|
return path.join(mission.projectPath, mission.tasksFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSessionId(mission: Mission): string {
|
||||||
|
return `sess-${String(mission.sessions.length + 1).padStart(3, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPidAlive(pid: number): boolean {
|
||||||
|
try {
|
||||||
|
process.kill(pid, 0);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentMilestone(mission: Mission): MissionMilestone | undefined {
|
||||||
|
return mission.milestones.find((milestone) => milestone.status === 'in-progress')
|
||||||
|
?? mission.milestones.find((milestone) => milestone.status === 'pending');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readTasks(mission: Mission) {
|
||||||
|
const filePath = tasksFilePath(mission);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const content = await fs.readFile(filePath, 'utf8');
|
||||||
|
return parseTasksFile(content);
|
||||||
|
} catch (error) {
|
||||||
|
if (
|
||||||
|
typeof error === 'object' &&
|
||||||
|
error !== null &&
|
||||||
|
'code' in error &&
|
||||||
|
(error as { code?: string }).code === 'ENOENT'
|
||||||
|
) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentBranch(projectPath: string): string | undefined {
|
||||||
|
const result = spawnSync('git', ['-C', projectPath, 'branch', '--show-current'], {
|
||||||
|
encoding: 'utf8',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.status !== 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const branch = result.stdout.trim();
|
||||||
|
return branch.length > 0 ? branch : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function percentage(done: number, total: number): number {
|
||||||
|
if (total === 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.floor((done / total) * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDurationSeconds(totalSeconds: number): string {
|
||||||
|
if (totalSeconds < 60) {
|
||||||
|
return `${totalSeconds}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totalSeconds < 3600) {
|
||||||
|
const minutes = Math.floor(totalSeconds / 60);
|
||||||
|
const seconds = totalSeconds % 60;
|
||||||
|
return `${minutes}m ${seconds}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hours = Math.floor(totalSeconds / 3600);
|
||||||
|
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||||
|
return `${hours}h ${minutes}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildContinuationPrompt(params: {
|
||||||
|
mission: Mission;
|
||||||
|
taskId: string;
|
||||||
|
runtime: 'claude' | 'codex';
|
||||||
|
tasksDone: number;
|
||||||
|
tasksTotal: number;
|
||||||
|
currentMilestone?: MissionMilestone;
|
||||||
|
previousSession?: MissionSession;
|
||||||
|
branch?: string;
|
||||||
|
}): string {
|
||||||
|
const {
|
||||||
|
mission,
|
||||||
|
taskId,
|
||||||
|
runtime,
|
||||||
|
tasksDone,
|
||||||
|
tasksTotal,
|
||||||
|
currentMilestone,
|
||||||
|
previousSession,
|
||||||
|
branch,
|
||||||
|
} = params;
|
||||||
|
|
||||||
|
const pct = percentage(tasksDone, tasksTotal);
|
||||||
|
const previousDuration =
|
||||||
|
previousSession?.durationSeconds !== undefined
|
||||||
|
? formatDurationSeconds(previousSession.durationSeconds)
|
||||||
|
: '—';
|
||||||
|
|
||||||
|
return [
|
||||||
|
'## Continuation Mission',
|
||||||
|
'',
|
||||||
|
`Continue **${mission.name}** from existing state.`,
|
||||||
|
'',
|
||||||
|
'## Setup',
|
||||||
|
'',
|
||||||
|
`- **Project:** ${mission.projectPath}`,
|
||||||
|
`- **State:** ${mission.tasksFile} (${tasksDone}/${tasksTotal} tasks complete)`,
|
||||||
|
`- **Manifest:** ${mission.manifestFile}`,
|
||||||
|
`- **Scratchpad:** ${mission.scratchpadFile}`,
|
||||||
|
'- **Protocol:** ~/.config/mosaic/guides/ORCHESTRATOR.md',
|
||||||
|
`- **Quality gates:** ${mission.qualityGates ?? '—'}`,
|
||||||
|
`- **Target runtime:** ${runtime}`,
|
||||||
|
'',
|
||||||
|
'## Resume Point',
|
||||||
|
'',
|
||||||
|
`- **Current milestone:** ${currentMilestone?.name ?? '—'} (${currentMilestone?.id ?? '—'})`,
|
||||||
|
`- **Next task:** ${taskId}`,
|
||||||
|
`- **Progress:** ${tasksDone}/${tasksTotal} (${pct}%)`,
|
||||||
|
`- **Branch:** ${branch ?? '—'}`,
|
||||||
|
'',
|
||||||
|
'## Previous Session Context',
|
||||||
|
'',
|
||||||
|
`- **Session:** ${previousSession?.sessionId ?? '—'} (${previousSession?.runtime ?? '—'}, ${previousDuration})`,
|
||||||
|
`- **Ended:** ${previousSession?.endedReason ?? '—'}`,
|
||||||
|
`- **Last completed task:** ${previousSession?.lastTaskId ?? '—'}`,
|
||||||
|
'',
|
||||||
|
'## Instructions',
|
||||||
|
'',
|
||||||
|
'1. Read `~/.config/mosaic/guides/ORCHESTRATOR.md` for full protocol',
|
||||||
|
`2. Read \`${mission.manifestFile}\` for mission scope and status`,
|
||||||
|
`3. Read \`${mission.scratchpadFile}\` for session history and decisions`,
|
||||||
|
`4. Read \`${mission.tasksFile}\` for current task state`,
|
||||||
|
'5. `git pull --rebase` to sync latest changes',
|
||||||
|
`6. Launch runtime with \`${runtime} -p\``,
|
||||||
|
`7. Continue execution from task **${taskId}**`,
|
||||||
|
'8. Follow Two-Phase Completion Protocol',
|
||||||
|
`9. You are the SOLE writer of \`${mission.tasksFile}\``,
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveLaunchCommand(
|
||||||
|
runtime: 'claude' | 'codex',
|
||||||
|
prompt: string,
|
||||||
|
configuredCommand: string[] | undefined,
|
||||||
|
): string[] {
|
||||||
|
if (configuredCommand === undefined || configuredCommand.length === 0) {
|
||||||
|
return [runtime, '-p', prompt];
|
||||||
|
}
|
||||||
|
|
||||||
|
const withInterpolation = configuredCommand.map((value) =>
|
||||||
|
value === '{prompt}' ? prompt : value,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (withInterpolation.includes(prompt)) {
|
||||||
|
return withInterpolation;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...withInterpolation, prompt];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeAtomicJson(filePath: string, payload: unknown): 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, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
|
||||||
|
await fs.rename(tempPath, filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readSessionLock(mission: Mission): Promise<SessionLockState | undefined> {
|
||||||
|
const filePath = sessionLockPath(mission);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const raw = await fs.readFile(filePath, 'utf8');
|
||||||
|
const data = JSON.parse(raw) as Partial<SessionLockState>;
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof data.session_id !== 'string' ||
|
||||||
|
(data.runtime !== 'claude' && data.runtime !== 'codex') ||
|
||||||
|
typeof data.pid !== 'number' ||
|
||||||
|
typeof data.started_at !== 'string' ||
|
||||||
|
typeof data.project_path !== 'string'
|
||||||
|
) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
session_id: data.session_id,
|
||||||
|
runtime: data.runtime,
|
||||||
|
pid: data.pid,
|
||||||
|
started_at: data.started_at,
|
||||||
|
project_path: data.project_path,
|
||||||
|
milestone_id: data.milestone_id,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (
|
||||||
|
typeof error === 'object' &&
|
||||||
|
error !== null &&
|
||||||
|
'code' in error &&
|
||||||
|
(error as { code?: string }).code === 'ENOENT'
|
||||||
|
) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeSessionLock(
|
||||||
|
mission: Mission,
|
||||||
|
lock: SessionLockState,
|
||||||
|
): Promise<void> {
|
||||||
|
await writeAtomicJson(sessionLockPath(mission), lock);
|
||||||
|
}
|
||||||
|
|
||||||
|
function markSessionCrashed(
|
||||||
|
mission: Mission,
|
||||||
|
sessionId: string,
|
||||||
|
endedAt: string,
|
||||||
|
): Mission {
|
||||||
|
const sessions = mission.sessions.map((session) => {
|
||||||
|
if (session.sessionId !== sessionId) {
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.endedAt !== undefined) {
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
const startedEpoch = Date.parse(session.startedAt);
|
||||||
|
const endedEpoch = Date.parse(endedAt);
|
||||||
|
const durationSeconds =
|
||||||
|
Number.isFinite(startedEpoch) && Number.isFinite(endedEpoch)
|
||||||
|
? Math.max(0, Math.floor((endedEpoch - startedEpoch) / 1000))
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...session,
|
||||||
|
endedAt,
|
||||||
|
endedReason: 'crashed' as const,
|
||||||
|
durationSeconds,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...mission,
|
||||||
|
sessions,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runTask(
|
||||||
|
mission: Mission,
|
||||||
|
taskId: string,
|
||||||
|
options: RunTaskOptions = {},
|
||||||
|
): Promise<TaskRun> {
|
||||||
|
const runtime = options.runtime ?? 'claude';
|
||||||
|
const mode = options.mode ?? 'interactive';
|
||||||
|
|
||||||
|
const freshMission = await loadMission(mission.projectPath);
|
||||||
|
const tasks = await readTasks(freshMission);
|
||||||
|
const matches = tasks.filter((task) => task.id === taskId);
|
||||||
|
|
||||||
|
if (matches.length === 0) {
|
||||||
|
throw new Error(`Task not found: ${taskId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matches.length > 1) {
|
||||||
|
throw new Error(`Duplicate task IDs found: ${taskId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const task = matches[0];
|
||||||
|
if (task.status === 'done' || task.status === 'cancelled') {
|
||||||
|
throw new Error(`Task ${taskId} cannot be run from status ${task.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tasksTotal = tasks.length;
|
||||||
|
const tasksDone = tasks.filter((candidate) => candidate.status === 'done').length;
|
||||||
|
const selectedMilestone =
|
||||||
|
freshMission.milestones.find((milestone) => milestone.id === options.milestoneId)
|
||||||
|
?? freshMission.milestones.find((milestone) => milestone.id === task.milestone)
|
||||||
|
?? currentMilestone(freshMission);
|
||||||
|
|
||||||
|
const continuationPrompt = buildContinuationPrompt({
|
||||||
|
mission: freshMission,
|
||||||
|
taskId,
|
||||||
|
runtime,
|
||||||
|
tasksDone,
|
||||||
|
tasksTotal,
|
||||||
|
currentMilestone: selectedMilestone,
|
||||||
|
previousSession: freshMission.sessions.at(-1),
|
||||||
|
branch: currentBranch(freshMission.projectPath),
|
||||||
|
});
|
||||||
|
|
||||||
|
const launchCommand = resolveLaunchCommand(runtime, continuationPrompt, options.command);
|
||||||
|
const startedAt = new Date().toISOString();
|
||||||
|
const sessionId = buildSessionId(freshMission);
|
||||||
|
const lockFile = sessionLockPath(freshMission);
|
||||||
|
|
||||||
|
await writeAtomicJson(nextTaskCapsulePath(freshMission), {
|
||||||
|
generated_at: startedAt,
|
||||||
|
runtime,
|
||||||
|
mission_id: freshMission.id,
|
||||||
|
mission_name: freshMission.name,
|
||||||
|
project_path: freshMission.projectPath,
|
||||||
|
quality_gates: freshMission.qualityGates ?? '',
|
||||||
|
current_milestone: {
|
||||||
|
id: selectedMilestone?.id ?? '',
|
||||||
|
name: selectedMilestone?.name ?? '',
|
||||||
|
},
|
||||||
|
next_task: taskId,
|
||||||
|
progress: {
|
||||||
|
tasks_done: tasksDone,
|
||||||
|
tasks_total: tasksTotal,
|
||||||
|
pct: percentage(tasksDone, tasksTotal),
|
||||||
|
},
|
||||||
|
current_branch: currentBranch(freshMission.projectPath) ?? '',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (mode === 'print-only') {
|
||||||
|
return {
|
||||||
|
missionId: freshMission.id,
|
||||||
|
taskId,
|
||||||
|
sessionId,
|
||||||
|
runtime,
|
||||||
|
launchCommand,
|
||||||
|
startedAt,
|
||||||
|
lockFile,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateTaskStatus(freshMission, taskId, 'in-progress');
|
||||||
|
|
||||||
|
await writeSessionLock(freshMission, {
|
||||||
|
session_id: sessionId,
|
||||||
|
runtime,
|
||||||
|
pid: 0,
|
||||||
|
started_at: startedAt,
|
||||||
|
project_path: freshMission.projectPath,
|
||||||
|
milestone_id: selectedMilestone?.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const child = spawn(launchCommand[0], launchCommand.slice(1), {
|
||||||
|
cwd: freshMission.projectPath,
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
...(options.env ?? {}),
|
||||||
|
},
|
||||||
|
stdio: 'inherit',
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
child.once('spawn', () => {
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
child.once('error', (error) => {
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const pid = child.pid;
|
||||||
|
if (pid === undefined) {
|
||||||
|
throw new Error('Failed to start task runtime process (pid missing)');
|
||||||
|
}
|
||||||
|
|
||||||
|
await writeSessionLock(freshMission, {
|
||||||
|
session_id: sessionId,
|
||||||
|
runtime,
|
||||||
|
pid,
|
||||||
|
started_at: startedAt,
|
||||||
|
project_path: freshMission.projectPath,
|
||||||
|
milestone_id: selectedMilestone?.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const updatedMission: Mission = {
|
||||||
|
...freshMission,
|
||||||
|
status: 'active',
|
||||||
|
sessions: [
|
||||||
|
...freshMission.sessions,
|
||||||
|
{
|
||||||
|
sessionId,
|
||||||
|
runtime,
|
||||||
|
pid,
|
||||||
|
startedAt,
|
||||||
|
milestoneId: selectedMilestone?.id,
|
||||||
|
lastTaskId: taskId,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
await saveMission(updatedMission);
|
||||||
|
|
||||||
|
return {
|
||||||
|
missionId: updatedMission.id,
|
||||||
|
taskId,
|
||||||
|
sessionId,
|
||||||
|
runtime,
|
||||||
|
launchCommand,
|
||||||
|
startedAt,
|
||||||
|
pid,
|
||||||
|
lockFile,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resumeTask(
|
||||||
|
mission: Mission,
|
||||||
|
taskId: string,
|
||||||
|
options: Omit<RunTaskOptions, 'milestoneId'> = {},
|
||||||
|
): Promise<TaskRun> {
|
||||||
|
const freshMission = await loadMission(mission.projectPath);
|
||||||
|
const lock = await readSessionLock(freshMission);
|
||||||
|
|
||||||
|
if (lock !== undefined && lock.pid > 0 && isPidAlive(lock.pid)) {
|
||||||
|
throw new Error(
|
||||||
|
`Session ${lock.session_id} is still running (PID ${lock.pid}).`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let nextMissionState = freshMission;
|
||||||
|
|
||||||
|
if (lock !== undefined) {
|
||||||
|
const endedAt = new Date().toISOString();
|
||||||
|
nextMissionState = markSessionCrashed(freshMission, lock.session_id, endedAt);
|
||||||
|
await saveMission(nextMissionState);
|
||||||
|
await fs.rm(sessionLockPath(nextMissionState), { force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
return runTask(nextMissionState, taskId, options);
|
||||||
|
}
|
||||||
183
packages/coord/src/status.ts
Normal file
183
packages/coord/src/status.ts
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
import { promises as fs } from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import { loadMission } from './mission.js';
|
||||||
|
import { parseTasksFile } from './tasks-file.js';
|
||||||
|
import type {
|
||||||
|
Mission,
|
||||||
|
MissionSession,
|
||||||
|
MissionStatusSummary,
|
||||||
|
MissionTask,
|
||||||
|
TaskDetail,
|
||||||
|
} from './types.js';
|
||||||
|
|
||||||
|
const SESSION_LOCK_FILE = 'session.lock';
|
||||||
|
|
||||||
|
interface SessionLockState {
|
||||||
|
session_id?: string;
|
||||||
|
runtime?: string;
|
||||||
|
pid?: number;
|
||||||
|
started_at?: string;
|
||||||
|
milestone_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tasksFilePath(mission: Mission): string {
|
||||||
|
if (path.isAbsolute(mission.tasksFile)) {
|
||||||
|
return mission.tasksFile;
|
||||||
|
}
|
||||||
|
|
||||||
|
return path.join(mission.projectPath, mission.tasksFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sessionLockPath(mission: Mission): string {
|
||||||
|
const orchestratorDir = path.isAbsolute(mission.orchestratorDir)
|
||||||
|
? mission.orchestratorDir
|
||||||
|
: path.join(mission.projectPath, mission.orchestratorDir);
|
||||||
|
|
||||||
|
return path.join(orchestratorDir, SESSION_LOCK_FILE);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPidAlive(pid: number): boolean {
|
||||||
|
try {
|
||||||
|
process.kill(pid, 0);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readTasks(mission: Mission): Promise<MissionTask[]> {
|
||||||
|
try {
|
||||||
|
const content = await fs.readFile(tasksFilePath(mission), 'utf8');
|
||||||
|
return parseTasksFile(content);
|
||||||
|
} catch (error) {
|
||||||
|
if (
|
||||||
|
typeof error === 'object' &&
|
||||||
|
error !== null &&
|
||||||
|
'code' in error &&
|
||||||
|
(error as { code?: string }).code === 'ENOENT'
|
||||||
|
) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readActiveSession(mission: Mission): Promise<MissionSession | undefined> {
|
||||||
|
let lockRaw: string;
|
||||||
|
try {
|
||||||
|
lockRaw = await fs.readFile(sessionLockPath(mission), 'utf8');
|
||||||
|
} catch (error) {
|
||||||
|
if (
|
||||||
|
typeof error === 'object' &&
|
||||||
|
error !== null &&
|
||||||
|
'code' in error &&
|
||||||
|
(error as { code?: string }).code === 'ENOENT'
|
||||||
|
) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lock = JSON.parse(lockRaw) as SessionLockState;
|
||||||
|
if (
|
||||||
|
typeof lock.session_id !== 'string' ||
|
||||||
|
(lock.runtime !== 'claude' && lock.runtime !== 'codex') ||
|
||||||
|
typeof lock.started_at !== 'string'
|
||||||
|
) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pid = typeof lock.pid === 'number' ? lock.pid : undefined;
|
||||||
|
if (pid !== undefined && pid > 0 && !isPidAlive(pid)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingSession = mission.sessions.find(
|
||||||
|
(session) => session.sessionId === lock.session_id,
|
||||||
|
);
|
||||||
|
if (existingSession !== undefined) {
|
||||||
|
return existingSession;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
sessionId: lock.session_id,
|
||||||
|
runtime: lock.runtime,
|
||||||
|
pid,
|
||||||
|
startedAt: lock.started_at,
|
||||||
|
milestoneId: lock.milestone_id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMissionStatus(mission: Mission): Promise<MissionStatusSummary> {
|
||||||
|
const freshMission = await loadMission(mission.projectPath);
|
||||||
|
const tasks = await readTasks(freshMission);
|
||||||
|
|
||||||
|
const done = tasks.filter((task) => task.status === 'done').length;
|
||||||
|
const inProgress = tasks.filter((task) => task.status === 'in-progress').length;
|
||||||
|
const pending = tasks.filter((task) => task.status === 'not-started').length;
|
||||||
|
const blocked = tasks.filter((task) => task.status === 'blocked').length;
|
||||||
|
const cancelled = tasks.filter((task) => task.status === 'cancelled').length;
|
||||||
|
const nextTask = tasks.find((task) => task.status === 'not-started');
|
||||||
|
|
||||||
|
const completedMilestones = freshMission.milestones.filter(
|
||||||
|
(milestone) => milestone.status === 'completed',
|
||||||
|
).length;
|
||||||
|
const currentMilestone =
|
||||||
|
freshMission.milestones.find((milestone) => milestone.status === 'in-progress')
|
||||||
|
?? freshMission.milestones.find((milestone) => milestone.status === 'pending');
|
||||||
|
|
||||||
|
const activeSession = await readActiveSession(freshMission);
|
||||||
|
|
||||||
|
return {
|
||||||
|
mission: {
|
||||||
|
id: freshMission.id,
|
||||||
|
name: freshMission.name,
|
||||||
|
status: freshMission.status,
|
||||||
|
projectPath: freshMission.projectPath,
|
||||||
|
},
|
||||||
|
milestones: {
|
||||||
|
total: freshMission.milestones.length,
|
||||||
|
completed: completedMilestones,
|
||||||
|
current: currentMilestone,
|
||||||
|
},
|
||||||
|
tasks: {
|
||||||
|
total: tasks.length,
|
||||||
|
done,
|
||||||
|
inProgress,
|
||||||
|
pending,
|
||||||
|
blocked,
|
||||||
|
cancelled,
|
||||||
|
},
|
||||||
|
nextTaskId: nextTask?.id,
|
||||||
|
activeSession,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTaskStatus(
|
||||||
|
mission: Mission,
|
||||||
|
taskId: string,
|
||||||
|
): Promise<TaskDetail> {
|
||||||
|
const freshMission = await loadMission(mission.projectPath);
|
||||||
|
const tasks = await readTasks(freshMission);
|
||||||
|
|
||||||
|
const matches = tasks.filter((task) => task.id === taskId);
|
||||||
|
if (matches.length === 0) {
|
||||||
|
throw new Error(`Task not found: ${taskId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matches.length > 1) {
|
||||||
|
throw new Error(`Duplicate task IDs found: ${taskId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const summary = await getMissionStatus(freshMission);
|
||||||
|
|
||||||
|
return {
|
||||||
|
missionId: freshMission.id,
|
||||||
|
task: matches[0],
|
||||||
|
isNextTask: summary.nextTaskId === taskId,
|
||||||
|
activeSession: summary.activeSession,
|
||||||
|
};
|
||||||
|
}
|
||||||
378
packages/coord/src/tasks-file.ts
Normal file
378
packages/coord/src/tasks-file.ts
Normal file
@@ -0,0 +1,378 @@
|
|||||||
|
import { promises as fs } from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import type { Mission, MissionTask, TaskStatus } from './types.js';
|
||||||
|
import { normalizeTaskStatus } from './types.js';
|
||||||
|
|
||||||
|
const TASKS_LOCK_FILE = '.TASKS.md.lock';
|
||||||
|
const TASKS_LOCK_STALE_MS = 5 * 60 * 1000;
|
||||||
|
const TASKS_LOCK_WAIT_MS = 5 * 1000;
|
||||||
|
const TASKS_LOCK_RETRY_MS = 100;
|
||||||
|
|
||||||
|
const DEFAULT_TABLE_HEADER = [
|
||||||
|
'| id | status | milestone | description | pr | notes |',
|
||||||
|
'|----|--------|-----------|-------------|----|-------|',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const DEFAULT_TASKS_PREAMBLE = [
|
||||||
|
'# Tasks',
|
||||||
|
'',
|
||||||
|
'> Single-writer: orchestrator only. Workers read but never modify.',
|
||||||
|
'',
|
||||||
|
...DEFAULT_TABLE_HEADER,
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
interface ParsedTableRow {
|
||||||
|
readonly lineIndex: number;
|
||||||
|
readonly cells: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ParsedTable {
|
||||||
|
readonly headerLineIndex: number;
|
||||||
|
readonly separatorLineIndex: number;
|
||||||
|
readonly headers: string[];
|
||||||
|
readonly rows: ParsedTableRow[];
|
||||||
|
readonly idColumn: number;
|
||||||
|
readonly statusColumn: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeHeaderName(input: string): string {
|
||||||
|
return input.trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitMarkdownRow(line: string): string[] {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed.startsWith('|')) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = trimmed.split(/(?<!\\)\|/);
|
||||||
|
if (parts.length < 3) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.slice(1, -1).map((part) => part.trim().replace(/\\\|/g, '|'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSeparatorRow(cells: readonly string[]): boolean {
|
||||||
|
return (
|
||||||
|
cells.length > 0 &&
|
||||||
|
cells.every((cell) => {
|
||||||
|
const value = cell.trim();
|
||||||
|
return /^:?-{3,}:?$/.test(value);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTable(content: string): ParsedTable | undefined {
|
||||||
|
const lines = content.split(/\r?\n/);
|
||||||
|
|
||||||
|
let headerLineIndex = -1;
|
||||||
|
let separatorLineIndex = -1;
|
||||||
|
let headers: string[] = [];
|
||||||
|
|
||||||
|
for (let index = 0; index < lines.length; index += 1) {
|
||||||
|
const cells = splitMarkdownRow(lines[index]);
|
||||||
|
if (cells.length === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = cells.map(normalizeHeaderName);
|
||||||
|
if (!normalized.includes('id') || !normalized.includes('status')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (index + 1 >= lines.length) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const separatorCells = splitMarkdownRow(lines[index + 1]);
|
||||||
|
if (!isSeparatorRow(separatorCells)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
headerLineIndex = index;
|
||||||
|
separatorLineIndex = index + 1;
|
||||||
|
headers = normalized;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (headerLineIndex < 0 || separatorLineIndex < 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const idColumn = headers.indexOf('id');
|
||||||
|
const statusColumn = headers.indexOf('status');
|
||||||
|
if (idColumn < 0 || statusColumn < 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows: ParsedTableRow[] = [];
|
||||||
|
let sawData = false;
|
||||||
|
|
||||||
|
for (let index = separatorLineIndex + 1; index < lines.length; index += 1) {
|
||||||
|
const rawLine = lines[index];
|
||||||
|
const trimmed = rawLine.trim();
|
||||||
|
|
||||||
|
if (!trimmed.startsWith('|')) {
|
||||||
|
if (sawData) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cells = splitMarkdownRow(rawLine);
|
||||||
|
if (cells.length === 0) {
|
||||||
|
if (sawData) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
sawData = true;
|
||||||
|
|
||||||
|
const normalizedRow = [...cells];
|
||||||
|
while (normalizedRow.length < headers.length) {
|
||||||
|
normalizedRow.push('');
|
||||||
|
}
|
||||||
|
|
||||||
|
rows.push({ lineIndex: index, cells: normalizedRow });
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
headerLineIndex,
|
||||||
|
separatorLineIndex,
|
||||||
|
headers,
|
||||||
|
rows,
|
||||||
|
idColumn,
|
||||||
|
statusColumn,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeTableCell(value: string): string {
|
||||||
|
return value.replace(/\|/g, '\\|').replace(/\r?\n/g, ' ').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTableRow(cells: readonly string[]): string {
|
||||||
|
const escaped = cells.map((cell) => escapeTableCell(cell));
|
||||||
|
return `| ${escaped.join(' | ')} |`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDependencies(raw: string | undefined): string[] {
|
||||||
|
if (raw === undefined || raw.trim().length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return raw
|
||||||
|
.split(',')
|
||||||
|
.map((value) => value.trim())
|
||||||
|
.filter((value) => value.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveTasksFilePath(mission: Mission): string {
|
||||||
|
if (path.isAbsolute(mission.tasksFile)) {
|
||||||
|
return mission.tasksFile;
|
||||||
|
}
|
||||||
|
|
||||||
|
return path.join(mission.projectPath, mission.tasksFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNodeErrorWithCode(error: unknown, code: string): boolean {
|
||||||
|
return (
|
||||||
|
typeof error === 'object' &&
|
||||||
|
error !== null &&
|
||||||
|
'code' in error &&
|
||||||
|
(error as { code?: string }).code === code
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function delay(ms: number): Promise<void> {
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
setTimeout(resolve, ms);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function acquireLock(lockPath: string): Promise<void> {
|
||||||
|
const startedAt = Date.now();
|
||||||
|
|
||||||
|
while (Date.now() - startedAt < TASKS_LOCK_WAIT_MS) {
|
||||||
|
try {
|
||||||
|
const handle = await fs.open(lockPath, 'wx');
|
||||||
|
await handle.writeFile(
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
pid: process.pid,
|
||||||
|
acquiredAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await handle.close();
|
||||||
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
if (!isNodeErrorWithCode(error, 'EEXIST')) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stats = await fs.stat(lockPath);
|
||||||
|
if (Date.now() - stats.mtimeMs > TASKS_LOCK_STALE_MS) {
|
||||||
|
await fs.rm(lockPath, { force: true });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
} catch (statError) {
|
||||||
|
if (!isNodeErrorWithCode(statError, 'ENOENT')) {
|
||||||
|
throw statError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await delay(TASKS_LOCK_RETRY_MS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Timed out acquiring TASKS lock: ${lockPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function releaseLock(lockPath: string): Promise<void> {
|
||||||
|
await fs.rm(lockPath, { force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeAtomic(filePath: string, content: string): Promise<void> {
|
||||||
|
const directory = path.dirname(filePath);
|
||||||
|
const tempPath = path.join(
|
||||||
|
directory,
|
||||||
|
`.TASKS.md.tmp-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
await fs.writeFile(tempPath, content, 'utf8');
|
||||||
|
await fs.rename(tempPath, filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseTasksFile(content: string): MissionTask[] {
|
||||||
|
const parsedTable = parseTable(content);
|
||||||
|
if (parsedTable === undefined) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const headerToColumn = new Map<string, number>();
|
||||||
|
parsedTable.headers.forEach((header, index) => {
|
||||||
|
headerToColumn.set(header, index);
|
||||||
|
});
|
||||||
|
|
||||||
|
const descriptionColumn =
|
||||||
|
headerToColumn.get('description') ?? headerToColumn.get('title') ?? -1;
|
||||||
|
const milestoneColumn = headerToColumn.get('milestone') ?? -1;
|
||||||
|
const prColumn = headerToColumn.get('pr') ?? -1;
|
||||||
|
const notesColumn = headerToColumn.get('notes') ?? -1;
|
||||||
|
const assigneeColumn = headerToColumn.get('assignee') ?? -1;
|
||||||
|
const dependenciesColumn = headerToColumn.get('dependencies') ?? -1;
|
||||||
|
|
||||||
|
const tasks: MissionTask[] = [];
|
||||||
|
|
||||||
|
for (const row of parsedTable.rows) {
|
||||||
|
const id = row.cells[parsedTable.idColumn]?.trim();
|
||||||
|
if (id === undefined || id.length === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawStatusValue = row.cells[parsedTable.statusColumn] ?? '';
|
||||||
|
const normalized = normalizeTaskStatus(rawStatusValue);
|
||||||
|
|
||||||
|
const title = descriptionColumn >= 0 ? row.cells[descriptionColumn] ?? '' : '';
|
||||||
|
const milestone = milestoneColumn >= 0 ? row.cells[milestoneColumn] ?? '' : '';
|
||||||
|
const pr = prColumn >= 0 ? row.cells[prColumn] ?? '' : '';
|
||||||
|
const notes = notesColumn >= 0 ? row.cells[notesColumn] ?? '' : '';
|
||||||
|
const assignee = assigneeColumn >= 0 ? row.cells[assigneeColumn] ?? '' : '';
|
||||||
|
const dependenciesRaw =
|
||||||
|
dependenciesColumn >= 0 ? row.cells[dependenciesColumn] ?? '' : '';
|
||||||
|
|
||||||
|
tasks.push({
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
status: normalized.status,
|
||||||
|
dependencies: parseDependencies(dependenciesRaw),
|
||||||
|
milestone: milestone.length > 0 ? milestone : undefined,
|
||||||
|
pr: pr.length > 0 ? pr : undefined,
|
||||||
|
notes: notes.length > 0 ? notes : undefined,
|
||||||
|
assignee: assignee.length > 0 ? assignee : undefined,
|
||||||
|
rawStatus: normalized.rawStatus,
|
||||||
|
line: row.lineIndex + 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return tasks;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeTasksFile(tasks: MissionTask[]): string {
|
||||||
|
const lines: string[] = [...DEFAULT_TASKS_PREAMBLE];
|
||||||
|
|
||||||
|
for (const task of tasks) {
|
||||||
|
lines.push(
|
||||||
|
formatTableRow([
|
||||||
|
task.id,
|
||||||
|
task.status,
|
||||||
|
task.milestone ?? '',
|
||||||
|
task.title,
|
||||||
|
task.pr ?? '',
|
||||||
|
task.notes ?? '',
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${lines.join('\n')}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateTaskStatus(
|
||||||
|
mission: Mission,
|
||||||
|
taskId: string,
|
||||||
|
status: TaskStatus,
|
||||||
|
): Promise<void> {
|
||||||
|
const tasksFilePath = resolveTasksFilePath(mission);
|
||||||
|
const lockPath = path.join(path.dirname(tasksFilePath), TASKS_LOCK_FILE);
|
||||||
|
|
||||||
|
await fs.mkdir(path.dirname(tasksFilePath), { recursive: true });
|
||||||
|
await acquireLock(lockPath);
|
||||||
|
|
||||||
|
try {
|
||||||
|
let content: string;
|
||||||
|
try {
|
||||||
|
content = await fs.readFile(tasksFilePath, 'utf8');
|
||||||
|
} catch (error) {
|
||||||
|
if (isNodeErrorWithCode(error, 'ENOENT')) {
|
||||||
|
throw new Error(`TASKS file not found: ${tasksFilePath}`);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const table = parseTable(content);
|
||||||
|
if (table === undefined) {
|
||||||
|
throw new Error(`Could not parse TASKS table in ${tasksFilePath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchingRows = table.rows.filter((row) => {
|
||||||
|
const rowTaskId = row.cells[table.idColumn]?.trim();
|
||||||
|
return rowTaskId === taskId;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (matchingRows.length === 0) {
|
||||||
|
throw new Error(`Task not found in TASKS.md: ${taskId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchingRows.length > 1) {
|
||||||
|
throw new Error(`Duplicate task IDs found in TASKS.md: ${taskId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetRow = matchingRows[0];
|
||||||
|
const updatedCells = [...targetRow.cells];
|
||||||
|
updatedCells[table.statusColumn] = status;
|
||||||
|
|
||||||
|
const lines = content.split(/\r?\n/);
|
||||||
|
lines[targetRow.lineIndex] = formatTableRow(updatedCells);
|
||||||
|
|
||||||
|
const updatedContent = `${lines.join('\n').replace(/\n+$/, '')}\n`;
|
||||||
|
await writeAtomic(tasksFilePath, updatedContent);
|
||||||
|
} finally {
|
||||||
|
await releaseLock(lockPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
194
packages/coord/src/types.ts
Normal file
194
packages/coord/src/types.ts
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
export type TaskStatus =
|
||||||
|
| 'not-started'
|
||||||
|
| 'in-progress'
|
||||||
|
| 'done'
|
||||||
|
| 'blocked'
|
||||||
|
| 'cancelled';
|
||||||
|
|
||||||
|
export type MissionStatus = 'active' | 'paused' | 'completed' | 'inactive';
|
||||||
|
|
||||||
|
export type MissionRuntime = 'claude' | 'codex' | 'unknown';
|
||||||
|
|
||||||
|
export interface MissionMilestone {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
status: 'pending' | 'in-progress' | 'completed' | 'blocked';
|
||||||
|
branch?: string;
|
||||||
|
issueRef?: string;
|
||||||
|
startedAt?: string;
|
||||||
|
completedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MissionSession {
|
||||||
|
sessionId: string;
|
||||||
|
runtime: MissionRuntime;
|
||||||
|
pid?: number;
|
||||||
|
startedAt: string;
|
||||||
|
endedAt?: string;
|
||||||
|
endedReason?: 'completed' | 'paused' | 'crashed' | 'killed' | 'unknown';
|
||||||
|
milestoneId?: string;
|
||||||
|
lastTaskId?: string;
|
||||||
|
durationSeconds?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Mission {
|
||||||
|
schemaVersion: 1;
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
projectPath: string;
|
||||||
|
createdAt: string;
|
||||||
|
status: MissionStatus;
|
||||||
|
tasksFile: string;
|
||||||
|
manifestFile: string;
|
||||||
|
scratchpadFile: string;
|
||||||
|
orchestratorDir: string;
|
||||||
|
taskPrefix?: string;
|
||||||
|
qualityGates?: string;
|
||||||
|
milestoneVersion?: string;
|
||||||
|
milestones: MissionMilestone[];
|
||||||
|
sessions: MissionSession[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MissionTask {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
status: TaskStatus;
|
||||||
|
assignee?: string;
|
||||||
|
dependencies: string[];
|
||||||
|
milestone?: string;
|
||||||
|
pr?: string;
|
||||||
|
notes?: string;
|
||||||
|
rawStatus?: string;
|
||||||
|
line?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaskRun {
|
||||||
|
missionId: string;
|
||||||
|
taskId: string;
|
||||||
|
sessionId: string;
|
||||||
|
runtime: 'claude' | 'codex';
|
||||||
|
launchCommand: string[];
|
||||||
|
startedAt: string;
|
||||||
|
pid?: number;
|
||||||
|
lockFile: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MissionStatusSummary {
|
||||||
|
mission: Pick<Mission, 'id' | 'name' | 'status' | 'projectPath'>;
|
||||||
|
milestones: {
|
||||||
|
total: number;
|
||||||
|
completed: number;
|
||||||
|
current?: MissionMilestone;
|
||||||
|
};
|
||||||
|
tasks: {
|
||||||
|
total: number;
|
||||||
|
done: number;
|
||||||
|
inProgress: number;
|
||||||
|
pending: number;
|
||||||
|
blocked: number;
|
||||||
|
cancelled: number;
|
||||||
|
};
|
||||||
|
nextTaskId?: string;
|
||||||
|
activeSession?: MissionSession;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaskDetail {
|
||||||
|
missionId: string;
|
||||||
|
task: MissionTask;
|
||||||
|
isNextTask: boolean;
|
||||||
|
activeSession?: MissionSession;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateMissionOptions {
|
||||||
|
name: string;
|
||||||
|
projectPath?: string;
|
||||||
|
prefix?: string;
|
||||||
|
milestones?: string[];
|
||||||
|
qualityGates?: string;
|
||||||
|
version?: string;
|
||||||
|
description?: string;
|
||||||
|
force?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RunTaskOptions {
|
||||||
|
runtime?: 'claude' | 'codex';
|
||||||
|
mode?: 'interactive' | 'print-only';
|
||||||
|
milestoneId?: string;
|
||||||
|
launchStrategy?: 'subprocess' | 'spawn-adapter';
|
||||||
|
env?: Record<string, string>;
|
||||||
|
command?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NextTaskCapsule {
|
||||||
|
generatedAt: string;
|
||||||
|
runtime: 'claude' | 'codex';
|
||||||
|
missionId: string;
|
||||||
|
missionName: string;
|
||||||
|
projectPath: string;
|
||||||
|
qualityGates?: string;
|
||||||
|
currentMilestone: {
|
||||||
|
id?: string;
|
||||||
|
name?: string;
|
||||||
|
};
|
||||||
|
nextTask: string;
|
||||||
|
progress: {
|
||||||
|
tasksDone: number;
|
||||||
|
tasksTotal: number;
|
||||||
|
pct: number;
|
||||||
|
};
|
||||||
|
currentBranch?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LEGACY_TASK_STATUS: Readonly<Record<string, TaskStatus>> = {
|
||||||
|
'not-started': 'not-started',
|
||||||
|
pending: 'not-started',
|
||||||
|
todo: 'not-started',
|
||||||
|
'in-progress': 'in-progress',
|
||||||
|
in_progress: 'in-progress',
|
||||||
|
done: 'done',
|
||||||
|
completed: 'done',
|
||||||
|
blocked: 'blocked',
|
||||||
|
failed: 'blocked',
|
||||||
|
cancelled: 'cancelled',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function normalizeTaskStatus(input: string): {
|
||||||
|
status: TaskStatus;
|
||||||
|
rawStatus?: string;
|
||||||
|
} {
|
||||||
|
const raw = input.trim().toLowerCase();
|
||||||
|
if (raw.length === 0) {
|
||||||
|
return { status: 'not-started' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = LEGACY_TASK_STATUS[raw];
|
||||||
|
if (normalized === undefined) {
|
||||||
|
return { status: 'not-started', rawStatus: raw };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (raw !== normalized) {
|
||||||
|
return { status: normalized, rawStatus: raw };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { status: normalized };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isMissionStatus(value: string): value is MissionStatus {
|
||||||
|
return (
|
||||||
|
value === 'active' ||
|
||||||
|
value === 'paused' ||
|
||||||
|
value === 'completed' ||
|
||||||
|
value === 'inactive'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isTaskStatus(value: string): value is TaskStatus {
|
||||||
|
return (
|
||||||
|
value === 'not-started' ||
|
||||||
|
value === 'in-progress' ||
|
||||||
|
value === 'done' ||
|
||||||
|
value === 'blocked' ||
|
||||||
|
value === 'cancelled'
|
||||||
|
);
|
||||||
|
}
|
||||||
64
packages/coord/tests/mission.test.ts
Normal file
64
packages/coord/tests/mission.test.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { promises as fs } from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { createMission, loadMission, missionFilePath } from '../src/mission.js';
|
||||||
|
|
||||||
|
describe('mission lifecycle', () => {
|
||||||
|
it('creates and loads mission state files', async () => {
|
||||||
|
const projectDir = await fs.mkdtemp(path.join(os.tmpdir(), 'coord-mission-'));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const mission = await createMission({
|
||||||
|
name: 'Wave 3 Mission',
|
||||||
|
projectPath: projectDir,
|
||||||
|
milestones: ['Phase One', 'Phase Two'],
|
||||||
|
qualityGates: 'pnpm lint && pnpm typecheck && pnpm test',
|
||||||
|
description: 'Wave 3 implementation',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mission.id).toMatch(/^wave-3-mission-\d{8}$/);
|
||||||
|
expect(mission.status).toBe('active');
|
||||||
|
expect(mission.milestones).toHaveLength(2);
|
||||||
|
|
||||||
|
await expect(fs.stat(missionFilePath(projectDir, mission))).resolves.toBeDefined();
|
||||||
|
await expect(fs.stat(path.join(projectDir, 'docs/TASKS.md'))).resolves.toBeDefined();
|
||||||
|
await expect(
|
||||||
|
fs.stat(path.join(projectDir, '.mosaic/orchestrator/mission.json')),
|
||||||
|
).resolves.toBeDefined();
|
||||||
|
|
||||||
|
const loaded = await loadMission(projectDir);
|
||||||
|
expect(loaded.id).toBe(mission.id);
|
||||||
|
expect(loaded.name).toBe('Wave 3 Mission');
|
||||||
|
expect(loaded.qualityGates).toBe('pnpm lint && pnpm typecheck && pnpm test');
|
||||||
|
} finally {
|
||||||
|
await fs.rm(projectDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects inactive missions on load', async () => {
|
||||||
|
const projectDir = await fs.mkdtemp(path.join(os.tmpdir(), 'coord-mission-inactive-'));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const mission = await createMission({
|
||||||
|
name: 'Inactive Mission',
|
||||||
|
projectPath: projectDir,
|
||||||
|
});
|
||||||
|
|
||||||
|
const missionPath = missionFilePath(projectDir, mission);
|
||||||
|
const payload = JSON.parse(await fs.readFile(missionPath, 'utf8')) as {
|
||||||
|
status: string;
|
||||||
|
};
|
||||||
|
payload.status = 'inactive';
|
||||||
|
await fs.writeFile(missionPath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
|
||||||
|
|
||||||
|
await expect(loadMission(projectDir)).rejects.toThrow(
|
||||||
|
'Mission exists but is inactive',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await fs.rm(projectDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
74
packages/coord/tests/tasks-file.test.ts
Normal file
74
packages/coord/tests/tasks-file.test.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { parseTasksFile, writeTasksFile } from '../src/tasks-file.js';
|
||||||
|
import type { MissionTask } from '../src/types.js';
|
||||||
|
|
||||||
|
describe('parseTasksFile', () => {
|
||||||
|
it('normalizes legacy statuses from TASKS.md', () => {
|
||||||
|
const content = [
|
||||||
|
'# Tasks — Demo',
|
||||||
|
'',
|
||||||
|
'| id | status | milestone | description | pr | notes |',
|
||||||
|
'|----|--------|-----------|-------------|----|-------|',
|
||||||
|
'| T-1 | pending | phase-1 | First task | #10 | note a |',
|
||||||
|
'| T-2 | completed | phase-1 | Second task | #11 | note b |',
|
||||||
|
'| T-3 | in_progress | phase-2 | Third task | | |',
|
||||||
|
'| T-4 | failed | phase-2 | Fourth task | | |',
|
||||||
|
'',
|
||||||
|
'trailing text ignored',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
const tasks = parseTasksFile(content);
|
||||||
|
|
||||||
|
expect(tasks).toHaveLength(4);
|
||||||
|
expect(tasks.map((task) => task.status)).toEqual([
|
||||||
|
'not-started',
|
||||||
|
'done',
|
||||||
|
'in-progress',
|
||||||
|
'blocked',
|
||||||
|
]);
|
||||||
|
expect(tasks.map((task) => task.rawStatus)).toEqual([
|
||||||
|
'pending',
|
||||||
|
'completed',
|
||||||
|
'in_progress',
|
||||||
|
'failed',
|
||||||
|
]);
|
||||||
|
expect(tasks[0]?.line).toBe(5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('writeTasksFile', () => {
|
||||||
|
it('round-trips parse/write output', () => {
|
||||||
|
const tasks: MissionTask[] = [
|
||||||
|
{
|
||||||
|
id: 'W3-001',
|
||||||
|
title: 'Implement parser',
|
||||||
|
status: 'not-started',
|
||||||
|
milestone: 'phase-1',
|
||||||
|
pr: '#20',
|
||||||
|
notes: 'pending',
|
||||||
|
dependencies: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'W3-002',
|
||||||
|
title: 'Implement runner',
|
||||||
|
status: 'in-progress',
|
||||||
|
milestone: 'phase-2',
|
||||||
|
notes: 'active',
|
||||||
|
dependencies: [],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const content = writeTasksFile(tasks);
|
||||||
|
const reparsed = parseTasksFile(content);
|
||||||
|
|
||||||
|
expect(reparsed).toHaveLength(2);
|
||||||
|
expect(reparsed.map((task) => task.id)).toEqual(['W3-001', 'W3-002']);
|
||||||
|
expect(reparsed.map((task) => task.status)).toEqual([
|
||||||
|
'not-started',
|
||||||
|
'in-progress',
|
||||||
|
]);
|
||||||
|
expect(reparsed[0]?.title).toBe('Implement parser');
|
||||||
|
expect(reparsed[1]?.milestone).toBe('phase-2');
|
||||||
|
});
|
||||||
|
});
|
||||||
5
packages/coord/tsconfig.json
Normal file
5
packages/coord/tsconfig.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": { "outDir": "dist", "rootDir": "src" },
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
29
packages/prdy/package.json
Normal file
29
packages/prdy/package.json
Normal 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
103
packages/prdy/src/cli.ts
Normal 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);
|
||||||
|
}
|
||||||
20
packages/prdy/src/index.ts
Normal file
20
packages/prdy/src/index.ts
Normal 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
199
packages/prdy/src/prd.ts
Normal 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;
|
||||||
|
}
|
||||||
86
packages/prdy/src/templates.ts
Normal file
86
packages/prdy/src/templates.ts
Normal 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;
|
||||||
|
}
|
||||||
38
packages/prdy/src/types.ts
Normal file
38
packages/prdy/src/types.ts
Normal 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
121
packages/prdy/src/wizard.ts
Normal 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;
|
||||||
|
}
|
||||||
36
packages/prdy/tests/prd.test.ts
Normal file
36
packages/prdy/tests/prd.test.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
22
packages/prdy/tests/templates.test.ts
Normal file
22
packages/prdy/tests/templates.test.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
5
packages/prdy/tsconfig.json
Normal file
5
packages/prdy/tsconfig.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": { "outDir": "dist", "rootDir": "src" },
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
20
packages/quality-rails/eslint.config.js
Normal file
20
packages/quality-rails/eslint.config.js
Normal 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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
30
packages/quality-rails/package.json
Normal file
30
packages/quality-rails/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
193
packages/quality-rails/src/cli.ts
Normal file
193
packages/quality-rails/src/cli.ts
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
30
packages/quality-rails/src/detect.ts
Normal file
30
packages/quality-rails/src/detect.ts
Normal 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';
|
||||||
|
}
|
||||||
5
packages/quality-rails/src/index.ts
Normal file
5
packages/quality-rails/src/index.ts
Normal 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';
|
||||||
201
packages/quality-rails/src/scaffolder.ts
Normal file
201
packages/quality-rails/src/scaffolder.ts
Normal 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;
|
||||||
|
}
|
||||||
182
packages/quality-rails/src/templates.ts
Normal file
182
packages/quality-rails/src/templates.ts
Normal 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');
|
||||||
|
}
|
||||||
18
packages/quality-rails/src/types.ts
Normal file
18
packages/quality-rails/src/types.ts
Normal 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[];
|
||||||
|
}
|
||||||
0
packages/quality-rails/templates/.gitkeep
Normal file
0
packages/quality-rails/templates/.gitkeep
Normal file
40
packages/quality-rails/tests/detect.test.ts
Normal file
40
packages/quality-rails/tests/detect.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
57
packages/quality-rails/tests/scaffolder.test.ts
Normal file
57
packages/quality-rails/tests/scaffolder.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
5
packages/quality-rails/tsconfig.json
Normal file
5
packages/quality-rails/tsconfig.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": { "outDir": "dist", "rootDir": "src" },
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -340,3 +340,315 @@ export interface WizardState {
|
|||||||
readonly runtimes: RuntimeState;
|
readonly runtimes: RuntimeState;
|
||||||
readonly selectedSkills: readonly string[];
|
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;
|
||||||
|
}
|
||||||
|
|||||||
1235
pnpm-lock.yaml
generated
1235
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user