diff --git a/.github/workflows/secret-scan.yml b/.github/workflows/secret-scan.yml index a04c8a0..2faa31e 100644 --- a/.github/workflows/secret-scan.yml +++ b/.github/workflows/secret-scan.yml @@ -20,8 +20,18 @@ jobs: with: fetch-depth: 0 - - uses: gitleaks/gitleaks-action@v2 - with: - args: --config=.gitleaks.toml --redact - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Install gitleaks CLI + run: | + VERSION="8.28.0" + curl -sSL "https://github.com/gitleaks/gitleaks/releases/download/v${VERSION}/gitleaks_${VERSION}_linux_x64.tar.gz" \ + | tar -xz gitleaks + sudo mv gitleaks /usr/local/bin/gitleaks + gitleaks version + + - name: Run gitleaks scan + run: | + if [ -f ".gitleaks.toml" ]; then + gitleaks git --redact --config ".gitleaks.toml" + else + gitleaks git --redact + fi diff --git a/.github/workflows/status-consistency.yml b/.github/workflows/status-consistency.yml new file mode 100644 index 0000000..7a54d9f --- /dev/null +++ b/.github/workflows/status-consistency.yml @@ -0,0 +1,23 @@ +name: Status Consistency + +on: + pull_request: + push: + branches: + - main + +jobs: + verify-status: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Verify status consistency + run: | + node scripts/verify-status-consistency.mjs diff --git a/README.md b/README.md index 3ad867f..7eae91b 100644 --- a/README.md +++ b/README.md @@ -87,17 +87,22 @@ Production deployment configuration is maintained in a separate private reposito This project uses [Spec Kitty](https://github.com/Priivacy-ai/spec-kitty) for spec-driven development. Feature specifications live in `kitty-specs/`. -Current status snapshot (source: `python scripts/pride-status.py` on 2026-02-23): - -| Spec | Description | Status | -|------|-------------|--------| -| `001` | MCP Server AWS Deployment | Complete | -| `002` | Session Context Management | Complete | -| `003` | Platform Architecture Overview | Spec-Only | -| `004` | Workflow Enforcement | Complete | -| `005` | Content Intelligence (Profile Engine) | Complete (Phases A–C, WP01–WP14) | -| `006` | Content Infrastructure | Complete (WP01–WP12) | -| `007` | Org-Scale Agentic Governance | Planning | +Status is canonicalized in `status/feature-readiness.json` and verified in CI via: +- `node scripts/verify-status-consistency.mjs` +- `.github/workflows/status-consistency.yml` + +Generated feature status table: + + +| Feature | Name | Lifecycle | Implementation | Production | +|---|---|---|---|---| +| `001` | MCP Server AWS Deployment | `execution` | `integrated` | `not_ready` | +| `002` | Session & Context Management | `done` | `validated` | `production_ready` | +| `003` | joyus-ai Platform Architecture Overview | `spec-only` | `none` | `not_ready` | +| `004` | Workflow Enforcement | `done` | `validated` | `production_ready` | +| `005` | Content Intelligence | `done` | `validated` | `production_ready` | +| `006` | Content Infrastructure | `done` | `integrated` | `not_ready` | +| `007` | Org-Scale Agentic Governance | `planning` | `none` | `not_ready` | Project-level architecture decisions, implementation plan, and constitution are in `spec/`. diff --git a/ROADMAP.md b/ROADMAP.md index 900dcc8..ea365e2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -4,6 +4,26 @@ An open-source, multi-tenant AI agent platform that encodes organizational knowl --- +## Canonical Status Snapshot + +Status source of truth: +- `status/feature-readiness.json` +- `status/generated/feature-table.md` +- `status/generated/phase-summary.md` + + +Updated: 2026-03-05T19:20:00Z + +Lifecycle counts: +- done: 4 +- execution: 1 +- planning: 1 +- spec-only: 1 + +Production-readiness counts: +- not_ready: 4 +- production_ready: 3 + ## Shipped - **MCP Server Core** - OAuth authentication, tool executors for project management, chat, code hosting, and productivity integrations. Dockerized runtime available. diff --git a/joyus-ai-mcp-server/src/content/mediation/auth.ts b/joyus-ai-mcp-server/src/content/mediation/auth.ts index 8ece965..4e4b629 100644 --- a/joyus-ai-mcp-server/src/content/mediation/auth.ts +++ b/joyus-ai-mcp-server/src/content/mediation/auth.ts @@ -42,13 +42,23 @@ export function createAuthMiddleware(db: DrizzleClient) { return; } - const keyHash = hashApiKey(apiKey); - const rows = await db - .select() - .from(contentApiKeys) - .where(eq(contentApiKeys.keyHash, keyHash)) - .limit(1); - const keyRecord = rows[0]; + let keyRecord: typeof contentApiKeys.$inferSelect | undefined; + try { + const keyHash = hashApiKey(apiKey); + const rows = await db + .select() + .from(contentApiKeys) + .where(eq(contentApiKeys.keyHash, keyHash)) + .limit(1); + keyRecord = rows[0]; + } catch { + // Fail closed: auth lookup error must never allow request flow. + res.status(503).json({ + error: 'auth_service_unavailable', + message: 'API key validation service unavailable', + }); + return; + } if (!keyRecord || !keyRecord.isActive) { res.status(401).json({ error: 'invalid_api_key', message: 'Invalid or inactive API key' }); @@ -107,6 +117,11 @@ export function createAuthMiddleware(db: DrizzleClient) { ...(audience ? { audience } : {}), }); + if (typeof payload.sub !== 'string' || payload.sub.length === 0) { + res.status(401).json({ error: 'invalid_user_token', message: 'Invalid user token subject' }); + return; + } + req.userId = payload.sub; next(); } catch (err) { diff --git a/joyus-ai-mcp-server/src/content/mediation/router.ts b/joyus-ai-mcp-server/src/content/mediation/router.ts index 5a305a5..510b670 100644 --- a/joyus-ai-mcp-server/src/content/mediation/router.ts +++ b/joyus-ai-mcp-server/src/content/mediation/router.ts @@ -15,6 +15,7 @@ import { Router, type Request, type Response } from 'express'; import { drizzle } from 'drizzle-orm/node-postgres'; +import { createId } from '@paralleldrive/cuid2'; import { createAuthMiddleware } from './auth.js'; import { MediationSessionService } from './session.js'; import type { GenerationService } from '../generation/index.js'; @@ -30,6 +31,50 @@ export interface MediationDependencies { entitlementCache: EntitlementCache; } +function requestIdFrom(req: Request): string { + const header = req.headers['x-request-id']; + if (typeof header === 'string' && header.length > 0) return header; + if (Array.isArray(header) && header[0]) return header[0]; + return createId(); +} + +export function sessionMatchesRequestContext( + session: { userId: string; tenantId: string; apiKeyId: string }, + req: { userId?: string; tenantId?: string; apiKeyRecord?: { id: string } }, +): boolean { + return ( + session.userId === req.userId && + session.tenantId === req.tenantId && + session.apiKeyId === req.apiKeyRecord?.id + ); +} + +function logMediationEvent( + level: 'info' | 'error', + event: string, + req: Request, + details: Record, +): void { + const payload = { + level, + event, + requestId: requestIdFrom(req), + tenantId: req.tenantId ?? null, + sessionId: typeof details.sessionId === 'string' ? details.sessionId : null, + profileId: typeof details.profileId === 'string' ? details.profileId : null, + userId: req.userId ?? null, + ...details, + timestamp: new Date().toISOString(), + }; + + const serialized = JSON.stringify(payload); + if (level === 'error') { + console.error(serialized); + return; + } + console.info(serialized); +} + export function createMediationRouter(deps: MediationDependencies): Router { const router = Router(); const { db, generationService, entitlementService, entitlementCache } = deps; @@ -46,6 +91,7 @@ export function createMediationRouter(deps: MediationDependencies): Router { // POST /sessions — create a new mediation session router.post('/sessions', async (req: Request, res: Response): Promise => { + const requestId = requestIdFrom(req); try { const profileId = req.body?.profileId as string | undefined; const result = await sessionService.createSession( @@ -54,20 +100,38 @@ export function createMediationRouter(deps: MediationDependencies): Router { req.userId!, profileId, ); + logMediationEvent('info', 'mediation.session.created', req, { + requestId, + sessionId: result.sessionId, + profileId: result.activeProfileId, + }); res.status(201).json(result); - } catch (err) { + } catch (err: unknown) { + logMediationEvent('error', 'mediation.session.create_failed', req, { + requestId, + profileId: req.body?.profileId ?? null, + error: err instanceof Error ? err.message : String(err), + }); res.status(500).json({ error: 'internal_error', message: 'Failed to create session' }); } }); // POST /sessions/:sessionId/messages — send a message and get a generated response router.post('/sessions/:sessionId/messages', async (req: Request, res: Response): Promise => { + const requestId = requestIdFrom(req); + const startedAt = Date.now(); try { const { sessionId } = req.params; const body = req.body as { message?: string; maxSources?: number }; const { message, maxSources } = body; if (!message) { + logMediationEvent('error', 'mediation.message.validation_failed', req, { + requestId, + sessionId, + profileId: null, + reason: 'missing_message', + }); res.status(400).json({ error: 'missing_message', message: 'message field is required' }); return; } @@ -75,10 +139,20 @@ export function createMediationRouter(deps: MediationDependencies): Router { // Validate session exists, belongs to this user, and is not closed const session = await sessionService.getSession(sessionId); if (!session || session.endedAt) { + logMediationEvent('error', 'mediation.message.session_not_found', req, { + requestId, + sessionId, + profileId: null, + }); res.status(404).json({ error: 'session_not_found', message: 'Session not found or already closed' }); return; } - if (session.userId !== req.userId) { + if (!sessionMatchesRequestContext(session, req)) { + logMediationEvent('error', 'mediation.message.session_forbidden', req, { + requestId, + sessionId, + profileId: session.activeProfileId, + }); res.status(404).json({ error: 'session_not_found', message: 'Session not found' }); return; } @@ -106,6 +180,14 @@ export function createMediationRouter(deps: MediationDependencies): Router { // Increment message counter await sessionService.incrementMessageCount(sessionId); + logMediationEvent('info', 'mediation.message.completed', req, { + requestId, + sessionId, + profileId: session.activeProfileId, + durationMs: Date.now() - startedAt, + citations: result.citations.length, + }); + res.json({ message: result.text, citations: result.citations, @@ -116,37 +198,78 @@ export function createMediationRouter(deps: MediationDependencies): Router { responseTime: result.metadata.durationMs, }, }); - } catch (err) { + } catch (err: unknown) { + logMediationEvent('error', 'mediation.message.failed', req, { + requestId, + sessionId: req.params.sessionId, + profileId: null, + error: err instanceof Error ? err.message : String(err), + durationMs: Date.now() - startedAt, + }); res.status(500).json({ error: 'internal_error', message: 'Failed to process message' }); } }); // GET /sessions/:sessionId — retrieve session details router.get('/sessions/:sessionId', async (req: Request, res: Response): Promise => { + const requestId = requestIdFrom(req); try { const session = await sessionService.getSession(req.params.sessionId); - if (!session || session.userId !== req.userId) { + if (!session || !sessionMatchesRequestContext(session, req)) { + logMediationEvent('error', 'mediation.session.lookup_forbidden', req, { + requestId, + sessionId: req.params.sessionId, + profileId: session?.activeProfileId ?? null, + }); res.status(404).json({ error: 'session_not_found', message: 'Session not found' }); return; } + logMediationEvent('info', 'mediation.session.lookup', req, { + requestId, + sessionId: session.id, + profileId: session.activeProfileId, + }); res.json(session); - } catch (err) { + } catch (err: unknown) { + logMediationEvent('error', 'mediation.session.lookup_failed', req, { + requestId, + sessionId: req.params.sessionId, + profileId: null, + error: err instanceof Error ? err.message : String(err), + }); res.status(500).json({ error: 'internal_error', message: 'Failed to retrieve session' }); } }); // DELETE /sessions/:sessionId — close a session router.delete('/sessions/:sessionId', async (req: Request, res: Response): Promise => { + const requestId = requestIdFrom(req); try { const session = await sessionService.getSession(req.params.sessionId); - if (!session || session.userId !== req.userId) { + if (!session || !sessionMatchesRequestContext(session, req)) { + logMediationEvent('error', 'mediation.session.close_forbidden', req, { + requestId, + sessionId: req.params.sessionId, + profileId: session?.activeProfileId ?? null, + }); res.status(404).json({ error: 'session_not_found', message: 'Session not found' }); return; } await sessionService.closeSession(req.params.sessionId); entitlementCache.invalidate(req.params.sessionId); + logMediationEvent('info', 'mediation.session.closed', req, { + requestId, + sessionId: req.params.sessionId, + profileId: session.activeProfileId, + }); res.status(204).send(); - } catch (err) { + } catch (err: unknown) { + logMediationEvent('error', 'mediation.session.close_failed', req, { + requestId, + sessionId: req.params.sessionId, + profileId: null, + error: err instanceof Error ? err.message : String(err), + }); res.status(500).json({ error: 'internal_error', message: 'Failed to close session' }); } }); diff --git a/joyus-ai-mcp-server/tests/content/integration/mediation-auth.test.ts b/joyus-ai-mcp-server/tests/content/integration/mediation-auth.test.ts new file mode 100644 index 0000000..1b0a584 --- /dev/null +++ b/joyus-ai-mcp-server/tests/content/integration/mediation-auth.test.ts @@ -0,0 +1,269 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Request, Response, NextFunction } from 'express'; +import * as jose from 'jose'; + +import { createAuthMiddleware } from '../../../src/content/mediation/auth.js'; +import { sessionMatchesRequestContext } from '../../../src/content/mediation/router.js'; + +vi.mock('jose', () => { + class JWTExpired extends Error {} + + return { + createRemoteJWKSet: vi.fn(() => Symbol('jwks')), + jwtVerify: vi.fn(), + errors: { JWTExpired }, + }; +}); + +type KeyRecord = { + id: string; + tenantId: string; + keyHash: string; + isActive: boolean; + jwksUri: string | null; + issuer: string | null; + audience: string | null; +}; + +function makeResponse(): Response { + const res = {} as Response; + (res as Response & { statusCode: number }).statusCode = 200; + res.status = vi.fn((code: number) => { + (res as Response & { statusCode: number }).statusCode = code; + return res; + }) as unknown as Response['status']; + res.json = vi.fn(() => res) as unknown as Response['json']; + return res; +} + +function makeDb(rows: KeyRecord[] = [], rejectSelect = false) { + const limit = vi.fn(async () => { + if (rejectSelect) throw new Error('db unavailable'); + return rows; + }); + const where = vi.fn(() => ({ limit })); + const from = vi.fn(() => ({ where })); + const select = vi.fn(() => ({ from })); + + const updateWhere = vi.fn(() => Promise.resolve(undefined)); + const set = vi.fn(() => ({ where: updateWhere })); + const update = vi.fn(() => ({ set })); + + return { select, update }; +} + +describe('Mediation auth middleware', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('rejects missing API key', async () => { + const db = makeDb(); + const { validateApiKey } = createAuthMiddleware(db as never); + const req = { headers: {} } as unknown as Request; + const res = makeResponse(); + const next = vi.fn, ReturnType>(); + + await validateApiKey(req, res, next); + + expect(res.status).toHaveBeenCalledWith(401); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ error: 'missing_api_key' }), + ); + expect(next).not.toHaveBeenCalled(); + }); + + it('rejects invalid API key', async () => { + const db = makeDb([]); + const { validateApiKey } = createAuthMiddleware(db as never); + const req = { headers: { 'x-api-key': 'invalid' } } as unknown as Request; + const res = makeResponse(); + const next = vi.fn, ReturnType>(); + + await validateApiKey(req, res, next); + + expect(res.status).toHaveBeenCalledWith(401); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ error: 'invalid_api_key' }), + ); + expect(next).not.toHaveBeenCalled(); + }); + + it('fails closed when API key lookup is unavailable', async () => { + const db = makeDb([], true); + const { validateApiKey } = createAuthMiddleware(db as never); + const req = { headers: { 'x-api-key': 'test-key' } } as unknown as Request; + const res = makeResponse(); + const next = vi.fn, ReturnType>(); + + await validateApiKey(req, res, next); + + expect(res.status).toHaveBeenCalledWith(503); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ error: 'auth_service_unavailable' }), + ); + expect(next).not.toHaveBeenCalled(); + }); + + it('accepts valid API key and sets tenant context', async () => { + const db = makeDb([ + { + id: 'key-1', + tenantId: 'tenant-1', + keyHash: 'hash', + isActive: true, + jwksUri: 'https://example.com/jwks', + issuer: null, + audience: null, + }, + ]); + const { validateApiKey } = createAuthMiddleware(db as never); + const req = { headers: { 'x-api-key': 'valid-key' } } as unknown as Request; + const res = makeResponse(); + const next = vi.fn, ReturnType>(); + + await validateApiKey(req, res, next); + + expect(next).toHaveBeenCalledOnce(); + expect((req as Request & { tenantId?: string }).tenantId).toBe('tenant-1'); + expect((req as Request & { apiKeyRecord?: { id: string } }).apiKeyRecord?.id).toBe('key-1'); + }); + + it('rejects missing bearer token', async () => { + const db = makeDb(); + const { validateUserToken } = createAuthMiddleware(db as never); + const req = { headers: {} } as unknown as Request; + const res = makeResponse(); + const next = vi.fn, ReturnType>(); + + await validateUserToken(req, res, next); + + expect(res.status).toHaveBeenCalledWith(401); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ error: 'missing_user_token' }), + ); + expect(next).not.toHaveBeenCalled(); + }); + + it('rejects token when API key context is missing', async () => { + const db = makeDb(); + const { validateUserToken } = createAuthMiddleware(db as never); + const req = { + headers: { authorization: 'Bearer token' }, + } as unknown as Request; + const res = makeResponse(); + const next = vi.fn, ReturnType>(); + + await validateUserToken(req, res, next); + + expect(res.status).toHaveBeenCalledWith(401); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ error: 'missing_api_key' }), + ); + expect(next).not.toHaveBeenCalled(); + }); + + it('rejects token when JWKS URI is not configured', async () => { + const db = makeDb(); + const { validateUserToken } = createAuthMiddleware(db as never); + const req = { + headers: { authorization: 'Bearer token' }, + apiKeyRecord: { + id: 'key-1', + tenantId: 'tenant-1', + jwksUri: null, + issuer: null, + audience: null, + }, + } as unknown as Request; + const res = makeResponse(); + const next = vi.fn, ReturnType>(); + + await validateUserToken(req, res, next); + + expect(res.status).toHaveBeenCalledWith(401); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ error: 'invalid_configuration' }), + ); + expect(next).not.toHaveBeenCalled(); + }); + + it('rejects expired token', async () => { + const db = makeDb(); + const { validateUserToken } = createAuthMiddleware(db as never); + const req = { + headers: { authorization: 'Bearer expired-token' }, + apiKeyRecord: { + id: 'key-1', + tenantId: 'tenant-1', + jwksUri: 'https://example.com/jwks', + issuer: null, + audience: null, + }, + } as unknown as Request; + const res = makeResponse(); + const next = vi.fn, ReturnType>(); + + const JWTExpired = (jose.errors as { JWTExpired: new (message?: string) => Error }).JWTExpired; + vi.mocked(jose.jwtVerify).mockRejectedValueOnce(new JWTExpired('expired')); + + await validateUserToken(req, res, next); + + expect(res.status).toHaveBeenCalledWith(401); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ error: 'token_expired' }), + ); + expect(next).not.toHaveBeenCalled(); + }); + + it('accepts valid token and sets userId', async () => { + const db = makeDb(); + const { validateUserToken } = createAuthMiddleware(db as never); + const req = { + headers: { authorization: 'Bearer ok-token' }, + apiKeyRecord: { + id: 'key-1', + tenantId: 'tenant-1', + jwksUri: 'https://example.com/jwks', + issuer: 'https://issuer.example.com', + audience: 'aud-1', + }, + } as unknown as Request; + const res = makeResponse(); + const next = vi.fn, ReturnType>(); + + vi.mocked(jose.jwtVerify).mockResolvedValueOnce({ + payload: { sub: 'user-123' }, + protectedHeader: {}, + key: {} as never, + }); + + await validateUserToken(req, res, next); + + expect(next).toHaveBeenCalledOnce(); + expect((req as Request & { userId?: string }).userId).toBe('user-123'); + }); +}); + +describe('session request scoping', () => { + it('matches only when user, tenant, and api key all match', () => { + const allowed = sessionMatchesRequestContext( + { userId: 'user-1', tenantId: 'tenant-1', apiKeyId: 'key-1' }, + { userId: 'user-1', tenantId: 'tenant-1', apiKeyRecord: { id: 'key-1' } }, + ); + + const tenantMismatch = sessionMatchesRequestContext( + { userId: 'user-1', tenantId: 'tenant-2', apiKeyId: 'key-1' }, + { userId: 'user-1', tenantId: 'tenant-1', apiKeyRecord: { id: 'key-1' } }, + ); + + const apiKeyMismatch = sessionMatchesRequestContext( + { userId: 'user-1', tenantId: 'tenant-1', apiKeyId: 'key-2' }, + { userId: 'user-1', tenantId: 'tenant-1', apiKeyRecord: { id: 'key-1' } }, + ); + + expect(allowed).toBe(true); + expect(tenantMismatch).toBe(false); + expect(apiKeyMismatch).toBe(false); + }); +}); diff --git a/joyus-ai-state/src/core/schema.ts b/joyus-ai-state/src/core/schema.ts index c51d2fd..4511f53 100644 --- a/joyus-ai-state/src/core/schema.ts +++ b/joyus-ai-state/src/core/schema.ts @@ -120,6 +120,11 @@ export const EventTriggerConfigSchema = z.object({ sessionEnd: z.boolean().default(true), }); +export const CustomTriggerSchema = z.object({ + pattern: z.string().min(1), + event: z.string().min(1), +}); + export const GlobalConfigSchema = z.object({ retentionDays: z.number().int().positive().default(7), retentionMaxBytes: z.number().int().positive().default(52_428_800), @@ -129,7 +134,7 @@ export const GlobalConfigSchema = z.object({ export const ProjectConfigSchema = z.object({ eventTriggers: EventTriggerConfigSchema.default({}), - customTriggers: z.array(z.string()).default([]), + customTriggers: z.array(CustomTriggerSchema).default([]), periodicIntervalMinutes: z.number().int().positive().default(15), }); diff --git a/joyus-ai-state/src/core/types.ts b/joyus-ai-state/src/core/types.ts index 1c7abbb..69ebb54 100644 --- a/joyus-ai-state/src/core/types.ts +++ b/joyus-ai-state/src/core/types.ts @@ -20,6 +20,7 @@ import type { GlobalConfigSchema, ProjectConfigSchema, EventTriggerConfigSchema, + CustomTriggerSchema, CanonicalDeclarationSchema, CanonicalDocumentSchema, AheadBehindSchema, @@ -57,6 +58,8 @@ export type ProjectConfig = z.infer; export type EventTriggerConfig = z.infer; +export type CustomTrigger = z.infer; + export type CanonicalDeclaration = z.infer; export type CanonicalDocument = z.infer; diff --git a/joyus-ai-state/src/index.ts b/joyus-ai-state/src/index.ts index 1f15d39..d831b9b 100644 --- a/joyus-ai-state/src/index.ts +++ b/joyus-ai-state/src/index.ts @@ -20,6 +20,7 @@ export type { GlobalConfig, ProjectConfig, EventTriggerConfig, + CustomTrigger, CanonicalDeclaration, CanonicalDocument, } from './core/types.js'; @@ -40,6 +41,7 @@ export { GlobalConfigSchema, ProjectConfigSchema, EventTriggerConfigSchema, + CustomTriggerSchema, CanonicalDeclarationSchema, CanonicalDocumentSchema, } from './core/schema.js'; @@ -104,6 +106,7 @@ export { saveStateToolDef, handleSaveState } from './mcp/tools/save-state.js'; export { verifyActionToolDef, handleVerifyAction } from './mcp/tools/verify-action.js'; export { checkCanonicalToolDef, handleCheckCanonical } from './mcp/tools/check-canonical.js'; export { shareStateToolDef, handleShareState } from './mcp/tools/share-state.js'; +export { querySnapshotsToolDef, handleQuerySnapshots } from './mcp/tools/query-snapshots.js'; // --- MCP tool utilities --- export { @@ -115,6 +118,7 @@ export { VerifyActionInputSchema, CheckCanonicalInputSchema, ShareStateInputSchema, + QuerySnapshotsInputSchema, } from './mcp/tools/utils.js'; // --- Companion service --- diff --git a/joyus-ai-state/src/mcp/server.ts b/joyus-ai-state/src/mcp/server.ts index 1cace0f..60360df 100644 --- a/joyus-ai-state/src/mcp/server.ts +++ b/joyus-ai-state/src/mcp/server.ts @@ -19,6 +19,7 @@ import { saveStateToolDef, handleSaveState } from './tools/save-state.js'; import { verifyActionToolDef, handleVerifyAction } from './tools/verify-action.js'; import { checkCanonicalToolDef, handleCheckCanonical } from './tools/check-canonical.js'; import { shareStateToolDef, handleShareState } from './tools/share-state.js'; +import { querySnapshotsToolDef, handleQuerySnapshots } from './tools/query-snapshots.js'; export async function createMcpServer(projectRoot: string): Promise { const server = new Server( @@ -33,6 +34,7 @@ export async function createMcpServer(projectRoot: string): Promise { verifyActionToolDef, checkCanonicalToolDef, shareStateToolDef, + querySnapshotsToolDef, ], })); @@ -52,6 +54,8 @@ export async function createMcpServer(projectRoot: string): Promise { return handleCheckCanonical(toolArgs, projectRoot); case 'share_state': return handleShareState(toolArgs, projectRoot); + case 'query_snapshots': + return handleQuerySnapshots(toolArgs, projectRoot); default: throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`); } diff --git a/joyus-ai-state/src/mcp/tools/query-snapshots.ts b/joyus-ai-state/src/mcp/tools/query-snapshots.ts new file mode 100644 index 0000000..a1332ac --- /dev/null +++ b/joyus-ai-state/src/mcp/tools/query-snapshots.ts @@ -0,0 +1,79 @@ +/** + * query_snapshots MCP tool — T039 + * + * Lists snapshot summaries with optional filters. + * Returns metadata only (id/timestamp/event/branch/commit message). + */ + +import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import { StateStore, getSnapshotsDir } from '../../state/store.js'; +import { + validateInput, + createSuccessResponse, + createErrorResponse, + QuerySnapshotsInputSchema, +} from './utils.js'; + +export const querySnapshotsToolDef = { + name: 'query_snapshots', + description: + 'List state snapshot summaries with optional filtering by date range, event type, and branch. Returns summaries only.', + inputSchema: { + type: 'object' as const, + properties: { + since: { + type: 'string', + description: 'Inclusive ISO-8601 lower timestamp bound', + }, + until: { + type: 'string', + description: 'Inclusive ISO-8601 upper timestamp bound', + }, + event: { + type: 'string', + description: 'Event type filter', + }, + branch: { + type: 'string', + description: 'Git branch filter', + }, + limit: { + type: 'number', + description: 'Maximum summaries to return (1-1000, default 50)', + }, + }, + }, +}; + +export async function handleQuerySnapshots( + args: Record, + projectRoot: string, +): Promise { + try { + const input = validateInput(QuerySnapshotsInputSchema, args); + const snapshotsDir = getSnapshotsDir(projectRoot); + const store = new StateStore(snapshotsDir); + + const summaries = await store.list({ + since: input.since, + until: input.until, + event: input.event, + branch: input.branch, + limit: input.limit ?? 50, + }); + + return createSuccessResponse({ + total: summaries.length, + filters: { + since: input.since ?? null, + until: input.until ?? null, + event: input.event ?? null, + branch: input.branch ?? null, + limit: input.limit ?? 50, + }, + snapshots: summaries, + }); + } catch (err) { + return createErrorResponse((err as Error).message); + } +} diff --git a/joyus-ai-state/src/mcp/tools/utils.ts b/joyus-ai-state/src/mcp/tools/utils.ts index 847b4cc..dce438d 100644 --- a/joyus-ai-state/src/mcp/tools/utils.ts +++ b/joyus-ai-state/src/mcp/tools/utils.ts @@ -54,3 +54,11 @@ export const ShareStateInputSchema = z.discriminatedUnion('action', [ z.object({ action: z.literal('export'), note: z.string() }), z.object({ action: z.literal('import'), path: z.string() }), ]); + +export const QuerySnapshotsInputSchema = z.object({ + since: z.string().optional(), + until: z.string().optional(), + event: EventTypeSchema.optional(), + branch: z.string().optional(), + limit: z.number().int().min(1).max(1000).optional(), +}); diff --git a/joyus-ai-state/src/service/daemon.ts b/joyus-ai-state/src/service/daemon.ts index 531dd05..41e3f9a 100644 --- a/joyus-ai-state/src/service/daemon.ts +++ b/joyus-ai-state/src/service/daemon.ts @@ -7,6 +7,7 @@ import { readFile, writeFile, unlink, mkdir } from 'node:fs/promises'; import path from 'node:path'; import { getStateDir } from '../state/store.js'; +import { loadProjectConfig } from '../core/config.js'; import { FileWatcher } from './watcher.js'; import { EventHandler } from './event-handler.js'; import { createIpcServer, type IpcServer } from './ipc.js'; @@ -36,14 +37,19 @@ export async function startService(options: ServiceOptions): Promise { await writeFile(getPidFilePath(stateDir), pidData, 'utf8'); // Initialize components + const projectConfig = await loadProjectConfig(projectRoot); const eventHandler = new EventHandler(projectRoot); - const watcher = new FileWatcher({ projectRoot }); + const watcher = new FileWatcher({ + projectRoot, + customTriggers: projectConfig.customTriggers, + }); const ipcServer = createIpcServer(projectRoot, eventHandler); // Wire watcher events to handler watcher.on('git-commit', () => eventHandler.handleEvent('git-commit')); watcher.on('git-branch-switch', () => eventHandler.handleEvent('git-branch-switch')); watcher.on('file-change', () => eventHandler.handleEvent('file-change')); + watcher.on('custom-event', (eventName: string) => eventHandler.handleEvent('custom-event', eventName)); // Start components await watcher.start(); diff --git a/joyus-ai-state/src/service/event-handler.ts b/joyus-ai-state/src/service/event-handler.ts index e33c1cd..273637e 100644 --- a/joyus-ai-state/src/service/event-handler.ts +++ b/joyus-ai-state/src/service/event-handler.ts @@ -31,13 +31,16 @@ export class EventHandler { this.projectRoot = projectRoot; } - async handleEvent(eventType: string, _detail?: string): Promise { + async handleEvent(eventType: string, detail?: string): Promise { // Prevent concurrent captures if (this.capturing) return; this.capturing = true; try { - const event: EventType = EVENT_MAP[eventType] ?? 'manual'; + let event: EventType = EVENT_MAP[eventType] ?? 'manual'; + if (eventType === 'custom-event' && detail && detail in EVENT_MAP) { + event = EVENT_MAP[detail]; + } const snapshotsDir = getSnapshotsDir(this.projectRoot); const store = new StateStore(snapshotsDir); @@ -75,7 +78,10 @@ export class EventHandler { await store.write(snapshot); this.lastCaptureTime = snapshot.timestamp; - console.error(`[joyus-ai-service] Snapshot captured: ${snapshot.timestamp} [${event}]`); + const eventLabel = eventType === 'custom-event' && detail + ? `${event}:${detail}` + : event; + console.error(`[joyus-ai-service] Snapshot captured: ${snapshot.timestamp} [${eventLabel}]`); } catch (err) { console.error('[joyus-ai-service] Error capturing snapshot:', (err as Error).message); } finally { diff --git a/joyus-ai-state/src/service/watcher.ts b/joyus-ai-state/src/service/watcher.ts index a802f8e..9ccc5d7 100644 --- a/joyus-ai-state/src/service/watcher.ts +++ b/joyus-ai-state/src/service/watcher.ts @@ -9,6 +9,8 @@ import { EventEmitter } from 'node:events'; import { readFile } from 'node:fs/promises'; import path from 'node:path'; import chokidar from 'chokidar'; +import picomatch from 'picomatch'; +import type { CustomTrigger } from '../core/types.js'; export interface WatcherOptions { projectRoot: string; @@ -16,6 +18,7 @@ export interface WatcherOptions { gitEvents: number; // default: 500ms fileChanges: number; // default: 5000ms }; + customTriggers?: CustomTrigger[]; usePolling?: boolean; // default: false — use true in tests or unreliable FS environments } @@ -29,14 +32,20 @@ export class FileWatcher extends EventEmitter { private debounceMs: { gitEvents: number; fileChanges: number }; private usePolling: boolean; private watcher: ReturnType | null = null; + private projectWatcher: ReturnType | null = null; private timers: Map> = new Map(); private lastHead: string | null = null; + private customMatchers: Array<{ event: string; match: (filePath: string) => boolean }> = []; constructor(options: WatcherOptions) { super(); this.projectRoot = options.projectRoot; this.debounceMs = { ...DEFAULT_DEBOUNCE, ...options.debounce }; this.usePolling = options.usePolling ?? false; + this.customMatchers = (options.customTriggers ?? []).map((trigger) => ({ + event: trigger.event, + match: picomatch(trigger.pattern, { dot: true }), + })); } async start(): Promise { @@ -72,9 +81,50 @@ export class FileWatcher extends EventEmitter { // Ignore watch errors }); - // Wait for watcher to be ready + this.projectWatcher = chokidar.watch(this.projectRoot, { + persistent: false, + ignoreInitial: true, + usePolling: this.usePolling, + interval: this.usePolling ? 50 : undefined, + ignored: [ + '**/.git/**', + '**/.joyus-ai/**', + '**/.omc/**', + '**/node_modules/**', + '**/dist/**', + ], + }); + + const onProjectFileEvent = (changedPath: string) => { + const relativePath = path.relative(this.projectRoot, changedPath); + if (!relativePath || relativePath.startsWith('..')) return; + + this.debounceEmit('file-change', this.debounceMs.fileChanges); + + for (const trigger of this.customMatchers) { + if (trigger.match(relativePath)) { + this.debounceEmit('custom-event', this.debounceMs.fileChanges, trigger.event); + } + } + }; + + this.projectWatcher.on('change', onProjectFileEvent); + this.projectWatcher.on('add', onProjectFileEvent); + this.projectWatcher.on('unlink', onProjectFileEvent); + + this.projectWatcher.on('error', () => { + // Ignore watch errors + }); + + // Wait for watchers to be ready await new Promise((resolve) => { - this.watcher!.on('ready', resolve); + let readyCount = 0; + const markReady = () => { + readyCount += 1; + if (readyCount >= 2) resolve(); + }; + this.watcher!.on('ready', markReady); + this.projectWatcher!.on('ready', markReady); }); } @@ -83,6 +133,10 @@ export class FileWatcher extends EventEmitter { await this.watcher.close(); this.watcher = null; } + if (this.projectWatcher) { + await this.projectWatcher.close(); + this.projectWatcher = null; + } for (const timer of this.timers.values()) { clearTimeout(timer); @@ -90,12 +144,13 @@ export class FileWatcher extends EventEmitter { this.timers.clear(); } - private debounceEmit(eventName: string, debounceMs: number): void { - const existing = this.timers.get(eventName); + private debounceEmit(eventName: string, debounceMs: number, detail?: string): void { + const timerKey = detail ? `${eventName}:${detail}` : eventName; + const existing = this.timers.get(timerKey); if (existing) clearTimeout(existing); const timer = setTimeout(async () => { - this.timers.delete(eventName); + this.timers.delete(timerKey); // For branch switch, verify HEAD actually changed if (eventName === 'git-branch-switch') { @@ -114,9 +169,13 @@ export class FileWatcher extends EventEmitter { } } - this.emit(eventName); + if (eventName === 'custom-event') { + this.emit(eventName, detail); + } else { + this.emit(eventName); + } }, debounceMs); - this.timers.set(eventName, timer); + this.timers.set(timerKey, timer); } } diff --git a/joyus-ai-state/tests/unit/collectors/git.test.ts b/joyus-ai-state/tests/unit/collectors/git.test.ts index 7713bcd..22faaff 100644 --- a/joyus-ai-state/tests/unit/collectors/git.test.ts +++ b/joyus-ai-state/tests/unit/collectors/git.test.ts @@ -7,7 +7,7 @@ import fs from 'node:fs'; describe('collectGitState', () => { it('returns valid GitState from the current repo', async () => { // Use the repo root (joyus-ai) which is definitely a git repo - const repoRoot = path.resolve(import.meta.dirname, '../../../../..'); + const repoRoot = path.resolve(import.meta.dirname, '../../../..'); const state = await collectGitState(repoRoot); expect(state.branch).toBeTruthy(); @@ -43,7 +43,7 @@ describe('collectGitState', () => { }); it('detects uncommitted changes', async () => { - const repoRoot = path.resolve(import.meta.dirname, '../../../../..'); + const repoRoot = path.resolve(import.meta.dirname, '../../../..'); const state = await collectGitState(repoRoot); // We have uncommitted files in the worktree, so this should be true expect(typeof state.hasUncommittedChanges).toBe('boolean'); diff --git a/joyus-ai-state/tests/unit/core/config.test.ts b/joyus-ai-state/tests/unit/core/config.test.ts index b54e1d1..7632908 100644 --- a/joyus-ai-state/tests/unit/core/config.test.ts +++ b/joyus-ai-state/tests/unit/core/config.test.ts @@ -31,12 +31,14 @@ describe('loadProjectConfig', () => { mkdirSync(configDir, { recursive: true }); writeFileSync(join(configDir, 'config.json'), JSON.stringify({ eventTriggers: { commit: false }, + customTriggers: [{ pattern: '**/Dockerfile', event: 'docker-build' }], periodicIntervalMinutes: 30, })); const config = await loadProjectConfig(projectRoot); expect(config.eventTriggers.commit).toBe(false); expect(config.eventTriggers.branchSwitch).toBe(true); + expect(config.customTriggers).toEqual([{ pattern: '**/Dockerfile', event: 'docker-build' }]); expect(config.periodicIntervalMinutes).toBe(30); }); diff --git a/joyus-ai-state/tests/unit/core/schema.test.ts b/joyus-ai-state/tests/unit/core/schema.test.ts index 790297f..740b067 100644 --- a/joyus-ai-state/tests/unit/core/schema.test.ts +++ b/joyus-ai-state/tests/unit/core/schema.test.ts @@ -134,4 +134,13 @@ describe('ProjectConfigSchema', () => { expect(result.eventTriggers.commit).toBe(false); expect(result.eventTriggers.branchSwitch).toBe(true); }); + + it('accepts structured custom triggers', () => { + const result = ProjectConfigSchema.parse({ + customTriggers: [{ pattern: '**/Dockerfile', event: 'docker-build' }], + }); + expect(result.customTriggers).toEqual([ + { pattern: '**/Dockerfile', event: 'docker-build' }, + ]); + }); }); diff --git a/joyus-ai-state/tests/unit/mcp/tools.test.ts b/joyus-ai-state/tests/unit/mcp/tools.test.ts index 9f583f9..4f16384 100644 --- a/joyus-ai-state/tests/unit/mcp/tools.test.ts +++ b/joyus-ai-state/tests/unit/mcp/tools.test.ts @@ -4,6 +4,7 @@ import { handleSaveState } from '../../../src/mcp/tools/save-state.js'; import { handleVerifyAction } from '../../../src/mcp/tools/verify-action.js'; import { handleCheckCanonical } from '../../../src/mcp/tools/check-canonical.js'; import { handleShareState } from '../../../src/mcp/tools/share-state.js'; +import { handleQuerySnapshots } from '../../../src/mcp/tools/query-snapshots.js'; import { validateInput, createErrorResponse, @@ -11,6 +12,7 @@ import { SaveStateInputSchema, CheckCanonicalInputSchema, ShareStateInputSchema, + QuerySnapshotsInputSchema, } from '../../../src/mcp/tools/utils.js'; import { StateStore, getSnapshotsDir, initStateDirectory } from '../../../src/state/store.js'; import { saveCanonical, addDeclaration } from '../../../src/state/canonical.js'; @@ -69,6 +71,12 @@ describe('utils', () => { expect(() => validateInput(CheckCanonicalInputSchema, { action: 'check' })).toThrow('Invalid input'); }); + it('validates query_snapshots input schema', () => { + const result = validateInput(QuerySnapshotsInputSchema, { event: 'manual', limit: 20 }); + expect(result.event).toBe('manual'); + expect(result.limit).toBe(20); + }); + it('includes field names in error message', () => { try { validateInput(CheckCanonicalInputSchema, { action: 'check' }); @@ -329,6 +337,64 @@ describe('share_state', () => { }); }); +// --- query_snapshots (T039) --- + +describe('query_snapshots', () => { + it('returns snapshot summaries and respects filters', async () => { + const snapshotsDir = getSnapshotsDir(tmpDir); + fs.mkdirSync(snapshotsDir, { recursive: true }); + const store = new StateStore(snapshotsDir); + + await store.write(makeSnapshot({ + id: 'snap-a', + timestamp: '2026-01-15T10:00:00.000Z', + event: 'manual', + git: { + branch: 'main', + commitHash: 'abc1234', + commitMessage: 'manual snapshot', + isDetached: false, + hasUncommittedChanges: false, + remoteBranch: null, + aheadBehind: { ahead: 0, behind: 0 }, + }, + project: { rootPath: tmpDir, hash: 'test', name: 'test' }, + })); + await store.write(makeSnapshot({ + id: 'snap-b', + timestamp: '2026-01-16T10:00:00.000Z', + event: 'commit', + git: { + branch: 'feature/x', + commitHash: 'def5678', + commitMessage: 'commit snapshot', + isDetached: false, + hasUncommittedChanges: false, + remoteBranch: null, + aheadBehind: { ahead: 0, behind: 0 }, + }, + project: { rootPath: tmpDir, hash: 'test', name: 'test' }, + })); + + const result = await handleQuerySnapshots( + { event: 'commit', branch: 'feature/x', limit: 10 }, + tmpDir, + ); + const data = JSON.parse((result.content[0] as { text: string }).text); + + expect(data.total).toBe(1); + expect(data.snapshots).toHaveLength(1); + expect(data.snapshots[0].event).toBe('commit'); + expect(data.snapshots[0].branch).toBe('feature/x'); + expect(data.snapshots[0].id).toBe('snap-b'); + }); + + it('returns validation error for invalid limit', async () => { + const result = await handleQuerySnapshots({ limit: 0 }, tmpDir); + expect(result.isError).toBe(true); + }); +}); + interface Check { name: string; passed: boolean; diff --git a/joyus-ai-state/tests/unit/service/service.test.ts b/joyus-ai-state/tests/unit/service/service.test.ts index f8113a7..26fbe6f 100644 --- a/joyus-ai-state/tests/unit/service/service.test.ts +++ b/joyus-ai-state/tests/unit/service/service.test.ts @@ -86,6 +86,33 @@ describe('FileWatcher', () => { expect(events).toContain('git-branch-switch'); }); + + it('emits custom-event when file matches configured trigger', async () => { + const gitDir = path.join(tmpDir, '.git'); + fs.mkdirSync(path.join(gitDir, 'refs', 'heads'), { recursive: true }); + fs.writeFileSync(path.join(gitDir, 'HEAD'), 'ref: refs/heads/main\n'); + + const dockerfilePath = path.join(tmpDir, 'Dockerfile'); + fs.writeFileSync(dockerfilePath, 'FROM node:20\n'); + + const watcher = new FileWatcher({ + projectRoot: tmpDir, + customTriggers: [{ pattern: '**/Dockerfile', event: 'docker-build' }], + debounce: { gitEvents: 50, fileChanges: 50 }, + usePolling: true, + }); + + const events: string[] = []; + watcher.on('custom-event', (eventName: string) => events.push(eventName)); + + await watcher.start(); + fs.writeFileSync(dockerfilePath, 'FROM node:20-alpine\n'); + + await new Promise((resolve) => setTimeout(resolve, 500)); + await watcher.stop(); + + expect(events).toContain('docker-build'); + }); }); // --- EventHandler (T031) --- diff --git a/kitty-specs/001-mcp-server-aws-deployment/tasks.md b/kitty-specs/001-mcp-server-aws-deployment/tasks.md index f370778..63a8902 100644 --- a/kitty-specs/001-mcp-server-aws-deployment/tasks.md +++ b/kitty-specs/001-mcp-server-aws-deployment/tasks.md @@ -71,7 +71,7 @@ **Parallel opportunities**: T003 and T004 can be built independently of T002. **Success criteria**: `docker compose build` succeeds. `docker compose up -d` starts all 3 containers. Containers can communicate on internal network. **Risks**: Platform container image may be large (~2GB) due to multi-runtime. Monitor build times. -**Prompt file**: [tasks/WP01-docker-compose-containers.md](tasks/WP01-docker-compose-containers.md) +**Prompt file**: `tasks/WP01-docker-compose-containers.md` --- @@ -92,7 +92,7 @@ **Parallel opportunities**: T009 (firewall) independent of T007 (nginx). **Success criteria**: Fresh Ubuntu 24.04 instance can be provisioned from scratch by running `setup-ec2.sh`. Nginx routes requests correctly. TLS works on `ai.example.com`. **Risks**: DNS propagation delay. Certbot requires domain to resolve to EC2 IP first. -**Prompt file**: [tasks/WP02-ec2-provisioning-nginx.md](tasks/WP02-ec2-provisioning-nginx.md) +**Prompt file**: `tasks/WP02-ec2-provisioning-nginx.md` --- @@ -113,7 +113,7 @@ **Parallel opportunities**: T015 (Slack notification) independent of T011-T014. **Success criteria**: Push to main triggers automated build + deploy. New version live within 10 minutes. Failed deploy triggers rollback and Slack alert. **Risks**: GitHub Actions needs `workflow` scope on gh auth. EC2 SSH key must be in GitHub secrets. -**Prompt file**: [tasks/WP03-cicd-pipeline.md](tasks/WP03-cicd-pipeline.md) +**Prompt file**: `tasks/WP03-cicd-pipeline.md` --- @@ -135,7 +135,7 @@ **Parallel opportunities**: T017, T018, T019, T039 all independent of each other. **Success criteria**: `/health` endpoint returns correct service status. Logs rotate automatically. Slack alert fires on simulated downtime. **Risks**: Health check must not create excessive load. Use lightweight checks (TCP for DB, HTTP 200 for services). -**Prompt file**: [tasks/WP04-monitoring-health.md](tasks/WP04-monitoring-health.md) +**Prompt file**: `tasks/WP04-monitoring-health.md` --- @@ -158,7 +158,7 @@ **Parallel opportunities**: T022-T027 all independent of each other (different services/runtimes). **Success criteria**: All MCP endpoints respond to tool calls. All Python packages importable. All CLI tools executable. Memory persists across restart. **Risks**: OAuth tokens may need re-auth for production environment. Playwright may need display config (Xvfb or headless flag). -**Prompt file**: [tasks/WP05-mcp-skill-verification.md](tasks/WP05-mcp-skill-verification.md) +**Prompt file**: `tasks/WP05-mcp-skill-verification.md` --- @@ -179,7 +179,7 @@ **Parallel opportunities**: T032 (Claude Desktop config) independent of T028-T031. **Success criteria**: Chat UI loads on mobile browser. Can send message and receive Claude response with tool call results. Auth prevents unauthorized access. Claude Desktop connects and lists all MCP tools. **Risks**: Claude API streaming may need CORS configuration in nginx. Mobile keyboard handling may need viewport meta tag. -**Prompt file**: [tasks/WP06-web-chat-claude-desktop.md](tasks/WP06-web-chat-claude-desktop.md) +**Prompt file**: `tasks/WP06-web-chat-claude-desktop.md` --- @@ -203,7 +203,7 @@ **Parallel opportunities**: T034 (DNS) can start while T033 (EC2) provisions. **Success criteria**: `ai.example.com` serves HTTPS. All health checks green. 2+ team members connected. Web chat works from phone. Monthly cost under $35. **Risks**: DNS propagation (up to 48h worst case, usually <1h). May need t3.medium if OOM under load. -**Prompt file**: [tasks/WP07-production-launch.md](tasks/WP07-production-launch.md) +**Prompt file**: `tasks/WP07-production-launch.md` --- diff --git a/kitty-specs/005-content-intelligence/tasks.md b/kitty-specs/005-content-intelligence/tasks.md index f595992..ad01ac3 100644 --- a/kitty-specs/005-content-intelligence/tasks.md +++ b/kitty-specs/005-content-intelligence/tasks.md @@ -96,7 +96,7 @@ **Dependencies**: None **Subtasks**: T001-T007 (7 subtasks) **Estimated prompt**: ~450 lines -**Prompt file**: [tasks/WP01-package-foundation.md](tasks/WP01-package-foundation.md) +**Prompt file**: `tasks/WP01-package-foundation.md` Initialize the `joyus-profile-engine/` Python package with all Pydantic data models, domain templates, and test infrastructure. This WP produces the type foundation that all subsequent WPs build on. @@ -120,7 +120,7 @@ Initialize the `joyus-profile-engine/` Python package with all Pydantic data mod **Dependencies**: WP01 **Subtasks**: T008-T011 (4 subtasks) **Estimated prompt**: ~350 lines -**Prompt file**: [tasks/WP02-corpus-ingestion.md](tasks/WP02-corpus-ingestion.md) +**Prompt file**: `tasks/WP02-corpus-ingestion.md` Build the document ingestion pipeline: load from multiple sources and formats, preprocess into normalized chunks ready for feature extraction. @@ -137,7 +137,7 @@ Build the document ingestion pipeline: load from multiple sources and formats, p **Dependencies**: WP02 **Subtasks**: T012-T018 (7 subtasks) **Estimated prompt**: ~500 lines -**Prompt file**: [tasks/WP03-feature-extraction.md](tasks/WP03-feature-extraction.md) +**Prompt file**: `tasks/WP03-feature-extraction.md` Implement all six analyzers that extract the 129-feature stylometric vector from processed corpora. The StylometricAnalyzer wraps faststylometry; others use spaCy and custom NLP. @@ -157,7 +157,7 @@ Implement all six analyzers that extract the 129-feature stylometric vector from **Dependencies**: WP03 **Subtasks**: T019-T025 (7 subtasks) **Estimated prompt**: ~450 lines -**Prompt file**: [tasks/WP04-profile-generation.md](tasks/WP04-profile-generation.md) +**Prompt file**: `tasks/WP04-profile-generation.md` Transform extracted features into structured 12-section AuthorProfiles and emit platform-consumable skill files (SKILL.md + markers.json + stylometrics.json). @@ -177,7 +177,7 @@ Transform extracted features into structured 12-section AuthorProfiles and emit **Dependencies**: WP04 **Subtasks**: T026-T031 (6 subtasks) **Estimated prompt**: ~400 lines -**Prompt file**: [tasks/WP05-verification-cli.md](tasks/WP05-verification-cli.md) +**Prompt file**: `tasks/WP05-verification-cli.md` Implement the two-tier verification system (Tier 1 inline <500ms, Tier 2 deep analysis) and CLI commands for building profiles and verifying content. @@ -196,7 +196,7 @@ Implement the two-tier verification system (Tier 1 inline <500ms, Tier 2 deep an **Dependencies**: WP05 **Subtasks**: T032-T035 (4 subtasks) **Estimated prompt**: ~350 lines -**Prompt file**: [tasks/WP06-mcp-server.md](tasks/WP06-mcp-server.md) +**Prompt file**: `tasks/WP06-mcp-server.md` Expose the profile engine as MCP tools using the official Python `mcp` SDK. Implements build_profile, get_profile, compare_profiles, verify_content, and check_fidelity. @@ -213,7 +213,7 @@ Expose the profile engine as MCP tools using the official Python `mcp` SDK. Impl **Dependencies**: WP06 **Subtasks**: T036-T038 (3 subtasks) **Estimated prompt**: ~300 lines -**Prompt file**: [tasks/WP07-phase-a-testing.md](tasks/WP07-phase-a-testing.md) +**Prompt file**: `tasks/WP07-phase-a-testing.md` Validate Phase A end-to-end: port PoC accuracy tests, run full corpus-to-verification pipeline, measure performance against targets. @@ -233,7 +233,7 @@ Validate Phase A end-to-end: port PoC accuracy tests, run full corpus-to-verific **Dependencies**: WP04 **Subtasks**: T039-T042 (4 subtasks) **Estimated prompt**: ~350 lines -**Prompt file**: [tasks/WP08-composite-builder.md](tasks/WP08-composite-builder.md) +**Prompt file**: `tasks/WP08-composite-builder.md` Build department-level and org-level composite profiles from member profiles using corpus-size weighted mean aggregation. @@ -250,7 +250,7 @@ Build department-level and org-level composite profiles from member profiles usi **Dependencies**: WP08 **Subtasks**: T043-T046 (4 subtasks) **Estimated prompt**: ~350 lines -**Prompt file**: [tasks/WP09-hierarchy-management.md](tasks/WP09-hierarchy-management.md) +**Prompt file**: `tasks/WP09-hierarchy-management.md` CRUD operations for the full profile hierarchy, cascade propagation, diffing, and multi-level skill file emission. @@ -266,7 +266,7 @@ CRUD operations for the full profile hierarchy, cascade propagation, diffing, an **Dependencies**: WP09 **Subtasks**: T047-T050 (4 subtasks) **Estimated prompt**: ~400 lines -**Prompt file**: [tasks/WP10-cascade-attribution.md](tasks/WP10-cascade-attribution.md) +**Prompt file**: `tasks/WP10-cascade-attribution.md` Multi-level attribution engine: person → department → organization → outsider cascade with ranked candidate lists and MCP tool exposure. @@ -282,7 +282,7 @@ Multi-level attribution engine: person → department → organization → outsi **Dependencies**: WP10 **Subtasks**: T051-T056 (6 subtasks) **Estimated prompt**: ~450 lines -**Prompt file**: [tasks/WP11-voice-context-testing.md](tasks/WP11-voice-context-testing.md) +**Prompt file**: `tasks/WP11-voice-context-testing.md` VoiceContext resolution (3-layer opt-in), access control, and comprehensive Phase B integration testing including hierarchy build and attribution accuracy. @@ -304,7 +304,7 @@ VoiceContext resolution (3-layer opt-in), access control, and comprehensive Phas **Dependencies**: WP07, WP11 **Subtasks**: T057-T062 (6 subtasks) **Estimated prompt**: ~400 lines -**Prompt file**: [tasks/WP12-monitoring-drift.md](tasks/WP12-monitoring-drift.md) +**Prompt file**: `tasks/WP12-monitoring-drift.md` Continuous monitoring pipeline with Tier 2 analysis queue, JSON-based score storage, trend aggregation, and five drift detection signals. @@ -322,7 +322,7 @@ Continuous monitoring pipeline with Tier 2 analysis queue, JSON-based score stor **Dependencies**: WP12 **Subtasks**: T063-T068 (6 subtasks) **Estimated prompt**: ~400 lines -**Prompt file**: [tasks/WP13-diagnosis-repair.md](tasks/WP13-diagnosis-repair.md) +**Prompt file**: `tasks/WP13-diagnosis-repair.md` Diagnosis engine that identifies what drifted and why, plus repair action framework with 6 repair types, verification, and revert capability. @@ -340,7 +340,7 @@ Diagnosis engine that identifies what drifted and why, plus repair action framew **Dependencies**: WP13 **Subtasks**: T069-T073 (5 subtasks) **Estimated prompt**: ~350 lines -**Prompt file**: [tasks/WP14-monitoring-mcp-testing.md](tasks/WP14-monitoring-mcp-testing.md) +**Prompt file**: `tasks/WP14-monitoring-mcp-testing.md` Expose monitoring as MCP tools, integrate with Langfuse, and run simulated drift + repair verification scenarios. diff --git a/scripts/generate-status-snippets.mjs b/scripts/generate-status-snippets.mjs new file mode 100644 index 0000000..b2f5f4a --- /dev/null +++ b/scripts/generate-status-snippets.mjs @@ -0,0 +1,88 @@ +#!/usr/bin/env node + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const repoRoot = path.resolve(__dirname, '..'); +const statusPath = path.join(repoRoot, 'status', 'feature-readiness.json'); +const generatedDir = path.join(repoRoot, 'status', 'generated'); +const featureTablePath = path.join(generatedDir, 'feature-table.md'); +const phaseSummaryPath = path.join(generatedDir, 'phase-summary.md'); + +function loadReadiness() { + return JSON.parse(fs.readFileSync(statusPath, 'utf8')); +} + +function sortFeatureEntries(featuresObj) { + return Object.entries(featuresObj).sort(([a], [b]) => Number(a) - Number(b)); +} + +export function renderFeatureTable(readiness) { + const rows = sortFeatureEntries(readiness.features).map(([id, feature]) => { + return `| \`${id}\` | ${feature.friendly_name} | \`${feature.lifecycle_state}\` | \`${feature.implementation_state}\` | \`${feature.production_readiness}\` |`; + }); + + return [ + '', + '| Feature | Name | Lifecycle | Implementation | Production |', + '|---|---|---|---|---|', + ...rows, + '', + ].join('\n'); +} + +export function renderPhaseSummary(readiness) { + const entries = sortFeatureEntries(readiness.features); + const lifecycleCounts = {}; + const productionCounts = {}; + + for (const [, feature] of entries) { + lifecycleCounts[feature.lifecycle_state] = (lifecycleCounts[feature.lifecycle_state] ?? 0) + 1; + productionCounts[feature.production_readiness] = (productionCounts[feature.production_readiness] ?? 0) + 1; + } + + const lifecycleLines = Object.entries(lifecycleCounts) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([state, count]) => `- ${state}: ${count}`); + const productionLines = Object.entries(productionCounts) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([state, count]) => `- ${state}: ${count}`); + + return [ + '', + `Updated: ${readiness.updated_at}`, + '', + 'Lifecycle counts:', + ...lifecycleLines, + '', + 'Production-readiness counts:', + ...productionLines, + '', + ].join('\n'); +} + +export function writeGeneratedSnippets(readiness) { + fs.mkdirSync(generatedDir, { recursive: true }); + fs.writeFileSync(featureTablePath, renderFeatureTable(readiness), 'utf8'); + fs.writeFileSync(phaseSummaryPath, renderPhaseSummary(readiness), 'utf8'); +} + +function runCli() { + const readiness = loadReadiness(); + writeGeneratedSnippets(readiness); + process.stdout.write( + [ + 'Generated status snippets:', + `- ${path.relative(repoRoot, featureTablePath)}`, + `- ${path.relative(repoRoot, phaseSummaryPath)}`, + '', + ].join('\n'), + ); +} + +if (import.meta.url === pathToFileURL(process.argv[1]).href) { + runCli(); +} diff --git a/scripts/verify-status-consistency.mjs b/scripts/verify-status-consistency.mjs new file mode 100644 index 0000000..b4a5f72 --- /dev/null +++ b/scripts/verify-status-consistency.mjs @@ -0,0 +1,199 @@ +#!/usr/bin/env node + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + renderFeatureTable, + renderPhaseSummary, + writeGeneratedSnippets, +} from './generate-status-snippets.mjs'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const repoRoot = path.resolve(__dirname, '..'); + +const readinessPath = path.join(repoRoot, 'status', 'feature-readiness.json'); +const featureTablePath = path.join(repoRoot, 'status', 'generated', 'feature-table.md'); +const phaseSummaryPath = path.join(repoRoot, 'status', 'generated', 'phase-summary.md'); +const kittySpecsDir = path.join(repoRoot, 'kitty-specs'); + +const lifecycleAllowed = new Set([ + 'spec-only', + 'planning', + 'execution', + 'done', + 'blocked', + 'deprecated', +]); +const implementationAllowed = new Set(['none', 'scaffolded', 'integrated', 'validated']); +const productionAllowed = new Set(['not_ready', 'pilot_ready', 'production_ready']); +const generationProviderAllowed = new Set(['placeholder', 'configured', 'validated']); +const voiceAnalyzerAllowed = new Set(['stub', 'configured', 'validated']); + +function loadJson(filePath) { + return JSON.parse(fs.readFileSync(filePath, 'utf8')); +} + +function assertFeatureShape(id, feature, errors) { + const requiredKeys = [ + 'friendly_name', + 'lifecycle_state', + 'implementation_state', + 'production_readiness', + 'provider_readiness', + 'evidence', + ]; + for (const key of requiredKeys) { + if (!(key in feature)) { + errors.push(`features.${id} missing required key: ${key}`); + } + } + + if (!lifecycleAllowed.has(feature.lifecycle_state)) { + errors.push(`features.${id}.lifecycle_state invalid: ${feature.lifecycle_state}`); + } + if (!implementationAllowed.has(feature.implementation_state)) { + errors.push(`features.${id}.implementation_state invalid: ${feature.implementation_state}`); + } + if (!productionAllowed.has(feature.production_readiness)) { + errors.push(`features.${id}.production_readiness invalid: ${feature.production_readiness}`); + } + + if (!feature.provider_readiness || typeof feature.provider_readiness !== 'object') { + errors.push(`features.${id}.provider_readiness must be an object`); + } else { + if (!generationProviderAllowed.has(feature.provider_readiness.generation_provider)) { + errors.push( + `features.${id}.provider_readiness.generation_provider invalid: ${feature.provider_readiness.generation_provider}`, + ); + } + if (!voiceAnalyzerAllowed.has(feature.provider_readiness.voice_analyzer)) { + errors.push( + `features.${id}.provider_readiness.voice_analyzer invalid: ${feature.provider_readiness.voice_analyzer}`, + ); + } + } + + if (!feature.evidence || typeof feature.evidence !== 'object') { + errors.push(`features.${id}.evidence must be an object`); + } else { + for (const key of ['spec_meta', 'runtime_wiring', 'tests']) { + if (!(key in feature.evidence)) { + errors.push(`features.${id}.evidence missing required key: ${key}`); + } + } + if (!Array.isArray(feature.evidence.runtime_wiring)) { + errors.push(`features.${id}.evidence.runtime_wiring must be an array`); + } + if (!Array.isArray(feature.evidence.tests)) { + errors.push(`features.${id}.evidence.tests must be an array`); + } + } +} + +function findMetaPath(featureId) { + const prefix = `${featureId}-`; + const dirs = fs + .readdirSync(kittySpecsDir, { withFileTypes: true }) + .filter((d) => d.isDirectory() && d.name.startsWith(prefix)) + .map((d) => d.name); + if (dirs.length === 0) return null; + return path.join(kittySpecsDir, dirs[0], 'meta.json'); +} + +function validateLifecycleSync(readiness, errors) { + for (const [featureId, feature] of Object.entries(readiness.features)) { + const metaPath = findMetaPath(featureId); + if (!metaPath) { + errors.push(`No meta.json found for feature ${featureId}`); + continue; + } + const meta = loadJson(metaPath); + if (meta.lifecycle_state !== feature.lifecycle_state) { + errors.push( + `Lifecycle mismatch for ${featureId}: readiness=${feature.lifecycle_state}, meta=${meta.lifecycle_state}`, + ); + } + } +} + +function validateProductionSafety(readiness, errors) { + for (const [featureId, feature] of Object.entries(readiness.features)) { + if (feature.production_readiness !== 'production_ready') continue; + + if (feature.provider_readiness.generation_provider === 'placeholder') { + errors.push( + `Invalid production_ready for ${featureId}: generation_provider is placeholder`, + ); + } + if (feature.provider_readiness.voice_analyzer === 'stub') { + errors.push(`Invalid production_ready for ${featureId}: voice_analyzer is stub`); + } + } +} + +function assertGeneratedSnippets(readiness, errors) { + const expectedTable = renderFeatureTable(readiness); + const expectedSummary = renderPhaseSummary(readiness); + + if (!fs.existsSync(featureTablePath)) { + errors.push(`Missing generated snippet: ${path.relative(repoRoot, featureTablePath)}`); + } else { + const actual = fs.readFileSync(featureTablePath, 'utf8'); + if (actual !== expectedTable) { + errors.push('Generated snippet out of date: status/generated/feature-table.md'); + } + } + + if (!fs.existsSync(phaseSummaryPath)) { + errors.push(`Missing generated snippet: ${path.relative(repoRoot, phaseSummaryPath)}`); + } else { + const actual = fs.readFileSync(phaseSummaryPath, 'utf8'); + if (actual !== expectedSummary) { + errors.push('Generated snippet out of date: status/generated/phase-summary.md'); + } + } +} + +function main() { + const writeGenerated = process.argv.includes('--write-generated'); + const errors = []; + + if (!fs.existsSync(readinessPath)) { + errors.push(`Missing readiness file: ${path.relative(repoRoot, readinessPath)}`); + } else { + const readiness = loadJson(readinessPath); + + if (Number.isNaN(Date.parse(readiness.updated_at))) { + errors.push(`Invalid updated_at datetime: ${readiness.updated_at}`); + } + if (!readiness.features || typeof readiness.features !== 'object') { + errors.push('features must be an object'); + } else { + for (const [featureId, feature] of Object.entries(readiness.features)) { + assertFeatureShape(featureId, feature, errors); + } + } + + validateLifecycleSync(readiness, errors); + validateProductionSafety(readiness, errors); + + if (writeGenerated) { + writeGeneratedSnippets(readiness); + } + assertGeneratedSnippets(readiness, errors); + } + + if (errors.length > 0) { + process.stderr.write('Status consistency verification failed:\n'); + for (const err of errors) { + process.stderr.write(`- ${err}\n`); + } + process.exit(1); + } + + process.stdout.write('Status consistency verification passed.\n'); +} + +main(); diff --git a/status/feature-readiness.json b/status/feature-readiness.json new file mode 100644 index 0000000..3bfce39 --- /dev/null +++ b/status/feature-readiness.json @@ -0,0 +1,141 @@ +{ + "$schema": "./feature-readiness.schema.json", + "updated_at": "2026-03-05T19:20:00Z", + "features": { + "001": { + "friendly_name": "MCP Server AWS Deployment", + "lifecycle_state": "execution", + "implementation_state": "integrated", + "production_readiness": "not_ready", + "provider_readiness": { + "generation_provider": "configured", + "voice_analyzer": "configured" + }, + "evidence": { + "spec_meta": "kitty-specs/001-mcp-server-aws-deployment/meta.json", + "runtime_wiring": [ + "deploy/docker-compose.yml", + "deploy/scripts/setup-ec2.sh", + ".github/workflows/deploy-mcp.yml" + ], + "tests": [ + "joyus-ai-mcp-server/tests/integration.test.ts" + ] + } + }, + "002": { + "friendly_name": "Session & Context Management", + "lifecycle_state": "done", + "implementation_state": "validated", + "production_readiness": "production_ready", + "provider_readiness": { + "generation_provider": "configured", + "voice_analyzer": "configured" + }, + "evidence": { + "spec_meta": "kitty-specs/002-session-context-management/meta.json", + "runtime_wiring": [ + "joyus-ai-state/src/mcp/server.ts", + "joyus-ai-state/src/state/store.ts", + "joyus-ai-state/src/service/watcher.ts" + ], + "tests": [ + "joyus-ai-state/tests/integration/mcp-tools.test.ts", + "joyus-ai-state/tests/unit/mcp/tools.test.ts" + ] + } + }, + "003": { + "friendly_name": "joyus-ai Platform Architecture Overview", + "lifecycle_state": "spec-only", + "implementation_state": "none", + "production_readiness": "not_ready", + "provider_readiness": { + "generation_provider": "configured", + "voice_analyzer": "configured" + }, + "evidence": { + "spec_meta": "kitty-specs/003-platform-architecture-overview/meta.json", + "runtime_wiring": [], + "tests": [] + } + }, + "004": { + "friendly_name": "Workflow Enforcement", + "lifecycle_state": "done", + "implementation_state": "validated", + "production_readiness": "production_ready", + "provider_readiness": { + "generation_provider": "configured", + "voice_analyzer": "configured" + }, + "evidence": { + "spec_meta": "kitty-specs/004-workflow-enforcement/meta.json", + "runtime_wiring": [ + "joyus-ai-state/src/enforcement/gates/runner.ts", + "joyus-ai-state/src/enforcement/events/router.ts" + ], + "tests": [ + "joyus-ai-state/tests/integration/gate-execution.test.ts", + "joyus-ai-state/tests/unit/enforcement/gate-runner.test.ts" + ] + } + }, + "005": { + "friendly_name": "Content Intelligence", + "lifecycle_state": "done", + "implementation_state": "validated", + "production_readiness": "production_ready", + "provider_readiness": { + "generation_provider": "validated", + "voice_analyzer": "validated" + }, + "evidence": { + "spec_meta": "kitty-specs/005-content-intelligence/meta.json", + "runtime_wiring": [ + "kitty-specs/005-content-intelligence/spec.md" + ], + "tests": [] + } + }, + "006": { + "friendly_name": "Content Infrastructure", + "lifecycle_state": "done", + "implementation_state": "integrated", + "production_readiness": "not_ready", + "provider_readiness": { + "generation_provider": "placeholder", + "voice_analyzer": "stub" + }, + "evidence": { + "spec_meta": "kitty-specs/006-content-infrastructure/meta.json", + "runtime_wiring": [ + "joyus-ai-mcp-server/src/content/runtime-config.ts", + "joyus-ai-mcp-server/src/content/mediation/router.ts", + "joyus-ai-mcp-server/src/content/mediation/auth.ts" + ], + "tests": [ + "joyus-ai-mcp-server/tests/content/integration/production-provider.test.ts", + "joyus-ai-mcp-server/tests/content/integration/mediation-auth.test.ts" + ] + } + }, + "007": { + "friendly_name": "Org-Scale Agentic Governance", + "lifecycle_state": "planning", + "implementation_state": "none", + "production_readiness": "not_ready", + "provider_readiness": { + "generation_provider": "configured", + "voice_analyzer": "configured" + }, + "evidence": { + "spec_meta": "kitty-specs/007-org-scale-agentic-governance/meta.json", + "runtime_wiring": [ + "scripts/spec-governance-check.py" + ], + "tests": [] + } + } + } +} diff --git a/status/feature-readiness.schema.json b/status/feature-readiness.schema.json new file mode 100644 index 0000000..c9d135d --- /dev/null +++ b/status/feature-readiness.schema.json @@ -0,0 +1,118 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "required": [ + "updated_at", + "features" + ], + "properties": { + "updated_at": { + "type": "string", + "format": "date-time" + }, + "features": { + "type": "object", + "patternProperties": { + "^[0-9]{3}$": { + "type": "object", + "required": [ + "friendly_name", + "lifecycle_state", + "implementation_state", + "production_readiness", + "provider_readiness", + "evidence" + ], + "properties": { + "friendly_name": { + "type": "string" + }, + "lifecycle_state": { + "type": "string", + "enum": [ + "spec-only", + "planning", + "execution", + "done", + "blocked", + "deprecated" + ] + }, + "implementation_state": { + "type": "string", + "enum": [ + "none", + "scaffolded", + "integrated", + "validated" + ] + }, + "production_readiness": { + "type": "string", + "enum": [ + "not_ready", + "pilot_ready", + "production_ready" + ] + }, + "provider_readiness": { + "type": "object", + "required": [ + "generation_provider", + "voice_analyzer" + ], + "properties": { + "generation_provider": { + "type": "string", + "enum": [ + "placeholder", + "configured", + "validated" + ] + }, + "voice_analyzer": { + "type": "string", + "enum": [ + "stub", + "configured", + "validated" + ] + } + }, + "additionalProperties": false + }, + "evidence": { + "type": "object", + "required": [ + "spec_meta", + "runtime_wiring", + "tests" + ], + "properties": { + "spec_meta": { + "type": "string" + }, + "runtime_wiring": { + "type": "array", + "items": { + "type": "string" + } + }, + "tests": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false +} diff --git a/status/generated/feature-table.md b/status/generated/feature-table.md new file mode 100644 index 0000000..20fdf8e --- /dev/null +++ b/status/generated/feature-table.md @@ -0,0 +1,10 @@ + +| Feature | Name | Lifecycle | Implementation | Production | +|---|---|---|---|---| +| `001` | MCP Server AWS Deployment | `execution` | `integrated` | `not_ready` | +| `002` | Session & Context Management | `done` | `validated` | `production_ready` | +| `003` | joyus-ai Platform Architecture Overview | `spec-only` | `none` | `not_ready` | +| `004` | Workflow Enforcement | `done` | `validated` | `production_ready` | +| `005` | Content Intelligence | `done` | `validated` | `production_ready` | +| `006` | Content Infrastructure | `done` | `integrated` | `not_ready` | +| `007` | Org-Scale Agentic Governance | `planning` | `none` | `not_ready` | diff --git a/status/generated/phase-summary.md b/status/generated/phase-summary.md new file mode 100644 index 0000000..4b2bf9d --- /dev/null +++ b/status/generated/phase-summary.md @@ -0,0 +1,12 @@ + +Updated: 2026-03-05T19:20:00Z + +Lifecycle counts: +- done: 4 +- execution: 1 +- planning: 1 +- spec-only: 1 + +Production-readiness counts: +- not_ready: 4 +- production_ready: 3