feat: gated project experience & prompt intelligence
Implements the full gated session flow and prompt intelligence system: - Prisma schema: add gated, priority, summary, chapters, linkTarget fields - Session gate: state machine (gated → begin_session → ungated) with LLM-powered tool selection based on prompt index - Tag matcher: intelligent prompt-to-tool matching with project/server/action tags - LLM selector: tiered provider selection (fast for gating, heavy for complex tasks) - Link resolver: cross-project MCP resource references (project/server:uri format) - Prompt summary service: LLM-generated summaries and chapter extraction - System project bootstrap: ensures default project exists on startup - Structural link health checks: enrichWithLinkStatus on prompt GET endpoints - CLI: create prompt --priority/--link, create project --gated/--no-gated, describe project shows prompts section, get prompts shows PRI/LINK/STATUS - Apply/edit: priority, linkTarget, gated fields supported - Shell completions: fish updated with new flags - 1,253 tests passing across all packages Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -11,6 +11,10 @@ function makePrompt(overrides: Partial<Prompt> = {}): Prompt {
|
||||
name: 'test-prompt',
|
||||
content: 'Hello world',
|
||||
projectId: null,
|
||||
priority: 5,
|
||||
summary: null,
|
||||
chapters: null,
|
||||
linkTarget: null,
|
||||
version: 1,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
@@ -24,6 +28,7 @@ function makePromptRequest(overrides: Partial<PromptRequest> = {}): PromptReques
|
||||
name: 'test-request',
|
||||
content: 'Proposed content',
|
||||
projectId: null,
|
||||
priority: 5,
|
||||
createdBySession: 'session-abc',
|
||||
createdByUserId: null,
|
||||
createdAt: new Date(),
|
||||
@@ -38,6 +43,7 @@ function makeProject(overrides: Partial<Project> = {}): Project {
|
||||
description: '',
|
||||
prompt: '',
|
||||
proxyMode: 'direct',
|
||||
gated: true,
|
||||
llmProvider: null,
|
||||
llmModel: null,
|
||||
ownerId: 'user-1',
|
||||
@@ -50,6 +56,7 @@ function makeProject(overrides: Partial<Project> = {}): Project {
|
||||
function mockPromptRepo(): IPromptRepository {
|
||||
return {
|
||||
findAll: vi.fn(async () => []),
|
||||
findGlobal: vi.fn(async () => []),
|
||||
findById: vi.fn(async () => null),
|
||||
findByNameAndProject: vi.fn(async () => null),
|
||||
create: vi.fn(async (data) => makePrompt(data)),
|
||||
@@ -61,10 +68,12 @@ function mockPromptRepo(): IPromptRepository {
|
||||
function mockPromptRequestRepo(): IPromptRequestRepository {
|
||||
return {
|
||||
findAll: vi.fn(async () => []),
|
||||
findGlobal: vi.fn(async () => []),
|
||||
findById: vi.fn(async () => null),
|
||||
findByNameAndProject: vi.fn(async () => null),
|
||||
findBySession: vi.fn(async () => []),
|
||||
create: vi.fn(async (data) => makePromptRequest(data)),
|
||||
update: vi.fn(async (id, data) => makePromptRequest({ id, ...data })),
|
||||
delete: vi.fn(async () => {}),
|
||||
};
|
||||
}
|
||||
@@ -111,6 +120,17 @@ describe('PromptService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('listGlobalPrompts', () => {
|
||||
it('should return only global prompts', async () => {
|
||||
const globalPrompts = [makePrompt({ name: 'global-rule', projectId: null })];
|
||||
vi.mocked(promptRepo.findGlobal).mockResolvedValue(globalPrompts);
|
||||
|
||||
const result = await service.listGlobalPrompts();
|
||||
expect(result).toEqual(globalPrompts);
|
||||
expect(promptRepo.findGlobal).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPrompt', () => {
|
||||
it('should return a prompt by id', async () => {
|
||||
const prompt = makePrompt();
|
||||
@@ -173,6 +193,21 @@ describe('PromptService', () => {
|
||||
it('should throw for missing prompt', async () => {
|
||||
await expect(service.deletePrompt('nope')).rejects.toThrow('Prompt not found');
|
||||
});
|
||||
|
||||
it('should reject deletion of system prompts', async () => {
|
||||
vi.mocked(promptRepo.findById).mockResolvedValue(makePrompt({ projectId: 'sys-proj' }));
|
||||
vi.mocked(projectRepo.findById).mockResolvedValue(makeProject({ id: 'sys-proj', name: 'mcpctl-system' }));
|
||||
|
||||
await expect(service.deletePrompt('prompt-1')).rejects.toThrow('Cannot delete system prompts');
|
||||
});
|
||||
|
||||
it('should allow deletion of non-system project prompts', async () => {
|
||||
vi.mocked(promptRepo.findById).mockResolvedValue(makePrompt({ projectId: 'proj-1' }));
|
||||
vi.mocked(projectRepo.findById).mockResolvedValue(makeProject({ id: 'proj-1', name: 'my-project' }));
|
||||
|
||||
await service.deletePrompt('prompt-1');
|
||||
expect(promptRepo.delete).toHaveBeenCalledWith('prompt-1');
|
||||
});
|
||||
});
|
||||
|
||||
// ── PromptRequest CRUD ──
|
||||
@@ -267,6 +302,90 @@ describe('PromptService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── Priority ──
|
||||
|
||||
describe('prompt priority', () => {
|
||||
it('creates prompt with explicit priority', async () => {
|
||||
const result = await service.createPrompt({ name: 'high-pri', content: 'x', priority: 8 });
|
||||
expect(promptRepo.create).toHaveBeenCalledWith(expect.objectContaining({ priority: 8 }));
|
||||
expect(result.priority).toBe(8);
|
||||
});
|
||||
|
||||
it('uses default priority 5 when not specified', async () => {
|
||||
const result = await service.createPrompt({ name: 'default-pri', content: 'x' });
|
||||
// Default in schema is 5 — create is called without priority
|
||||
const createArg = vi.mocked(promptRepo.create).mock.calls[0]![0];
|
||||
expect(createArg.priority).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects priority below 1', async () => {
|
||||
await expect(
|
||||
service.createPrompt({ name: 'bad-pri', content: 'x', priority: 0 }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('rejects priority above 10', async () => {
|
||||
await expect(
|
||||
service.createPrompt({ name: 'bad-pri', content: 'x', priority: 11 }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('updates prompt priority', async () => {
|
||||
vi.mocked(promptRepo.findById).mockResolvedValue(makePrompt());
|
||||
await service.updatePrompt('prompt-1', { priority: 3 });
|
||||
expect(promptRepo.update).toHaveBeenCalledWith('prompt-1', expect.objectContaining({ priority: 3 }));
|
||||
});
|
||||
});
|
||||
|
||||
// ── Link Target ──
|
||||
|
||||
describe('prompt links', () => {
|
||||
it('creates linked prompt with valid linkTarget', async () => {
|
||||
const result = await service.createPrompt({
|
||||
name: 'linked',
|
||||
content: 'link content',
|
||||
linkTarget: 'other-project/docmost-mcp:docmost://pages/abc',
|
||||
});
|
||||
expect(promptRepo.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ linkTarget: 'other-project/docmost-mcp:docmost://pages/abc' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects invalid link format', async () => {
|
||||
await expect(
|
||||
service.createPrompt({ name: 'bad-link', content: 'x', linkTarget: 'invalid-format' }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('rejects link without server part', async () => {
|
||||
await expect(
|
||||
service.createPrompt({ name: 'bad-link', content: 'x', linkTarget: 'project:uri' }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('approve carries priority from request to prompt', async () => {
|
||||
const req = makePromptRequest({ id: 'req-1', name: 'high-pri', content: 'x', projectId: 'proj-1', priority: 9 });
|
||||
vi.mocked(promptRequestRepo.findById).mockResolvedValue(req);
|
||||
|
||||
await service.approve('req-1');
|
||||
|
||||
expect(promptRepo.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ priority: 9 }),
|
||||
);
|
||||
});
|
||||
|
||||
it('propose passes priority through', async () => {
|
||||
const result = await service.propose({
|
||||
name: 'pri-req',
|
||||
content: 'x',
|
||||
priority: 7,
|
||||
});
|
||||
expect(promptRequestRepo.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ priority: 7 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Visibility ──
|
||||
|
||||
describe('getVisiblePrompts', () => {
|
||||
|
||||
Reference in New Issue
Block a user