@projitive/mcp 2.0.4 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/output/package.json +1 -1
- package/output/source/common/errors.test.js +59 -0
- package/output/source/common/files.js +20 -9
- package/output/source/common/index.js +1 -0
- package/output/source/common/linter.js +3 -1
- package/output/source/common/response.js +51 -67
- package/output/source/common/tool.js +43 -0
- package/output/source/common/utils.test.js +48 -0
- package/output/source/index.runtime.test.js +57 -0
- package/output/source/prompts/index.test.js +23 -0
- package/output/source/prompts/quickStart.test.js +24 -0
- package/output/source/prompts/taskDiscovery.test.js +24 -0
- package/output/source/prompts/taskExecution.js +17 -1
- package/output/source/prompts/taskExecution.test.js +27 -0
- package/output/source/resources/designs.resources.test.js +52 -0
- package/output/source/resources/governance.test.js +35 -0
- package/output/source/resources/index.test.js +18 -0
- package/output/source/tools/index.test.js +23 -0
- package/output/source/tools/project.js +210 -257
- package/output/source/tools/project.test.js +136 -4
- package/output/source/tools/roadmap.js +182 -216
- package/output/source/tools/roadmap.test.js +187 -0
- package/output/source/tools/task.js +598 -508
- package/output/source/tools/task.test.js +323 -2
- package/output/source/types.js +6 -0
- package/package.json +1 -1
package/output/package.json
CHANGED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { ConfidenceScoreError, FileNotFoundError, FileReadError, FileWriteError, GovernanceRootNotFoundError, InvalidTaskIdError, MCPError, ProjectError, ProjectNotFoundError, PromptNotFoundError, ProjitiveError, ResourceNotFoundError, TaskError, TaskNotFoundError, TaskValidationError, ValidationError, } from './errors.js';
|
|
3
|
+
describe('errors module', () => {
|
|
4
|
+
it('constructs base and project/task hierarchy errors with metadata', () => {
|
|
5
|
+
const base = new ProjitiveError('base failure', 'BASE', { a: 1 });
|
|
6
|
+
const project = new ProjectError('project failure', 'PROJECT', { projectPath: '/tmp/project' });
|
|
7
|
+
const task = new TaskError('task failure', 'TASK', { taskId: 'TASK-0001' });
|
|
8
|
+
expect(base.name).toBe('ProjitiveError');
|
|
9
|
+
expect(base.code).toBe('BASE');
|
|
10
|
+
expect(base.details).toEqual({ a: 1 });
|
|
11
|
+
expect(project.name).toBe('ProjectError');
|
|
12
|
+
expect(project.code).toBe('PROJECT');
|
|
13
|
+
expect(task.name).toBe('TaskError');
|
|
14
|
+
expect(task.code).toBe('TASK');
|
|
15
|
+
});
|
|
16
|
+
it('constructs specific project and task lookup errors', () => {
|
|
17
|
+
const projectNotFound = new ProjectNotFoundError('/tmp/missing');
|
|
18
|
+
const governanceNotFound = new GovernanceRootNotFoundError('/tmp/project');
|
|
19
|
+
const taskNotFound = new TaskNotFoundError('TASK-0001');
|
|
20
|
+
const invalidTaskId = new InvalidTaskIdError('BAD');
|
|
21
|
+
const taskValidation = new TaskValidationError('TASK-0001', ['owner required', 'links required']);
|
|
22
|
+
expect(projectNotFound.code).toBe('PROJECT_NOT_FOUND');
|
|
23
|
+
expect(projectNotFound.message).toContain('/tmp/missing');
|
|
24
|
+
expect(governanceNotFound.code).toBe('GOVERNANCE_ROOT_NOT_FOUND');
|
|
25
|
+
expect(taskNotFound.code).toBe('TASK_NOT_FOUND');
|
|
26
|
+
expect(invalidTaskId.code).toBe('INVALID_TASK_ID');
|
|
27
|
+
expect(taskValidation.code).toBe('TASK_VALIDATION_FAILED');
|
|
28
|
+
expect(taskValidation.errors).toEqual(['owner required', 'links required']);
|
|
29
|
+
expect(taskValidation.message).toContain('owner required, links required');
|
|
30
|
+
});
|
|
31
|
+
it('constructs file and validation related errors', () => {
|
|
32
|
+
const cause = new Error('disk offline');
|
|
33
|
+
const notFound = new FileNotFoundError('/tmp/a.md');
|
|
34
|
+
const readError = new FileReadError('/tmp/b.md', cause);
|
|
35
|
+
const writeError = new FileWriteError('/tmp/c.md', cause);
|
|
36
|
+
const validation = new ValidationError('invalid input', ['field missing']);
|
|
37
|
+
const confidence = new ConfidenceScoreError('bad confidence', 1.5, ['must be <= 1']);
|
|
38
|
+
expect(notFound.name).toBe('FileError');
|
|
39
|
+
expect(notFound.code).toBe('FILE_NOT_FOUND');
|
|
40
|
+
expect(readError.details).toMatchObject({ filePath: '/tmp/b.md', cause: 'disk offline' });
|
|
41
|
+
expect(writeError.details).toMatchObject({ filePath: '/tmp/c.md', cause: 'disk offline' });
|
|
42
|
+
expect(validation.name).toBe('ValidationError');
|
|
43
|
+
expect(validation.code).toBe('VALIDATION_FAILED');
|
|
44
|
+
expect(validation.errors).toEqual(['field missing']);
|
|
45
|
+
expect(confidence.code).toBe('CONFIDENCE_SCORE_ERROR');
|
|
46
|
+
expect(confidence.score).toBe(1.5);
|
|
47
|
+
});
|
|
48
|
+
it('constructs MCP related not-found errors', () => {
|
|
49
|
+
const mcp = new MCPError('mcp failed', 'MCP_GENERIC', { retry: true });
|
|
50
|
+
const resource = new ResourceNotFoundError('projitive://missing');
|
|
51
|
+
const prompt = new PromptNotFoundError('taskExecution');
|
|
52
|
+
expect(mcp.name).toBe('MCPError');
|
|
53
|
+
expect(mcp.details).toEqual({ retry: true });
|
|
54
|
+
expect(resource.code).toBe('RESOURCE_NOT_FOUND');
|
|
55
|
+
expect(resource.message).toContain('projitive://missing');
|
|
56
|
+
expect(prompt.code).toBe('PROMPT_NOT_FOUND');
|
|
57
|
+
expect(prompt.message).toContain('taskExecution');
|
|
58
|
+
});
|
|
59
|
+
});
|
|
@@ -11,16 +11,27 @@ async function fileLineCount(filePath) {
|
|
|
11
11
|
return content.split(/\r?\n/).length;
|
|
12
12
|
}
|
|
13
13
|
async function listMarkdownFiles(dirPath) {
|
|
14
|
-
const entriesResult = await catchIt(fs.readdir(dirPath, { withFileTypes: true }));
|
|
15
|
-
if (entriesResult.isError()) {
|
|
16
|
-
return [];
|
|
17
|
-
}
|
|
18
|
-
const entries = entriesResult.value;
|
|
19
|
-
const files = entries.filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith('.md'));
|
|
20
14
|
const result = [];
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
15
|
+
const stack = [dirPath];
|
|
16
|
+
while (stack.length > 0) {
|
|
17
|
+
const currentDir = stack.pop();
|
|
18
|
+
if (!currentDir) {
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
const entriesResult = await catchIt(fs.readdir(currentDir, { withFileTypes: true }));
|
|
22
|
+
if (entriesResult.isError()) {
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
for (const entry of entriesResult.value) {
|
|
26
|
+
const entryPath = path.join(currentDir, entry.name);
|
|
27
|
+
if (entry.isDirectory()) {
|
|
28
|
+
stack.push(entryPath);
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
if (entry.isFile() && entry.name.toLowerCase().endsWith('.md')) {
|
|
32
|
+
result.push({ path: entryPath, lineCount: await fileLineCount(entryPath) });
|
|
33
|
+
}
|
|
34
|
+
}
|
|
24
35
|
}
|
|
25
36
|
return result.sort((a, b) => a.path.localeCompare(b.path));
|
|
26
37
|
}
|
|
@@ -12,7 +12,6 @@ export const TASK_LINT_CODES = {
|
|
|
12
12
|
UPDATED_AT_INVALID: 'TASK_UPDATED_AT_INVALID',
|
|
13
13
|
ROADMAP_REFS_EMPTY: 'TASK_ROADMAP_REFS_EMPTY',
|
|
14
14
|
OUTSIDE_MARKER: 'TASK_OUTSIDE_MARKER',
|
|
15
|
-
LINK_TARGET_MISSING: 'TASK_LINK_TARGET_MISSING',
|
|
16
15
|
LINK_PATH_FORMAT_INVALID: 'TASK_LINK_PATH_FORMAT_INVALID',
|
|
17
16
|
HOOK_FILE_MISSING: 'TASK_HOOK_FILE_MISSING',
|
|
18
17
|
FILTER_EMPTY: 'TASK_FILTER_EMPTY',
|
|
@@ -25,6 +24,7 @@ export const TASK_LINT_CODES = {
|
|
|
25
24
|
IN_PROGRESS_WITHOUT_SUBSTATE: 'TASK_IN_PROGRESS_WITHOUT_SUBSTATE',
|
|
26
25
|
SUBSTATE_PHASE_INVALID: 'TASK_SUBSTATE_PHASE_INVALID',
|
|
27
26
|
SUBSTATE_CONFIDENCE_INVALID: 'TASK_SUBSTATE_CONFIDENCE_INVALID',
|
|
27
|
+
RESEARCH_BRIEF_MISSING: 'TASK_RESEARCH_BRIEF_MISSING',
|
|
28
28
|
};
|
|
29
29
|
export const ROADMAP_LINT_CODES = {
|
|
30
30
|
IDS_EMPTY: 'ROADMAP_IDS_EMPTY',
|
|
@@ -36,4 +36,6 @@ export const ROADMAP_LINT_CODES = {
|
|
|
36
36
|
};
|
|
37
37
|
export const PROJECT_LINT_CODES = {
|
|
38
38
|
TASKS_FILE_MISSING: 'PROJECT_TASKS_FILE_MISSING',
|
|
39
|
+
ARCHITECTURE_DOC_MISSING: 'PROJECT_ARCHITECTURE_DOC_MISSING',
|
|
40
|
+
STYLE_DOC_MISSING: 'PROJECT_STYLE_DOC_MISSING',
|
|
39
41
|
};
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
const MESSAGE_TEMPLATE_ENV = 'PROJITIVE_MESSAGE_TEMPLATE_PATH';
|
|
4
|
-
const CONTENT_TEMPLATE_TOKEN = '{{content}}';
|
|
5
4
|
function baseToolTemplateMarkdown() {
|
|
6
5
|
return [
|
|
7
6
|
'# {{tool_name}}',
|
|
@@ -15,58 +14,53 @@ function baseToolTemplateMarkdown() {
|
|
|
15
14
|
'## Agent Guidance',
|
|
16
15
|
'{{guidance}}',
|
|
17
16
|
'',
|
|
17
|
+
'## Lint Suggestions',
|
|
18
|
+
'{{suggestions}}',
|
|
19
|
+
'',
|
|
18
20
|
'## Next Call',
|
|
19
21
|
'{{next_call}}',
|
|
20
|
-
'',
|
|
21
|
-
'## Raw Response',
|
|
22
|
-
'{{content}}',
|
|
23
22
|
].join('\n');
|
|
24
23
|
}
|
|
25
|
-
function contextGuideTemplateExtra() {
|
|
26
|
-
return [
|
|
27
|
-
'',
|
|
28
|
-
'## Common Tool Guides To Read First',
|
|
29
|
-
'- ./CLAUDE.md',
|
|
30
|
-
'- ./AGENTS.md',
|
|
31
|
-
'- ./.github/copilot-instructions.md',
|
|
32
|
-
'- ./.cursorrules',
|
|
33
|
-
'- ./.github/instructions/*',
|
|
34
|
-
'- ./.cursor/rules/*',
|
|
35
|
-
];
|
|
36
|
-
}
|
|
37
|
-
function idleDiscoveryTemplateExtra() {
|
|
38
|
-
return [
|
|
39
|
-
'',
|
|
40
|
-
'## Idle Discovery Checklist (When No Actionable Task)',
|
|
41
|
-
'- Scan backlog comments: TODO / FIXME / HACK / XXX.',
|
|
42
|
-
'- Check lint gaps and create executable fix tasks.',
|
|
43
|
-
'- Check test quality gaps (missing tests, flaky tests, low-value coverage).',
|
|
44
|
-
'- Learn current project architecture and consolidate/update design docs in designs/.',
|
|
45
|
-
'- Re-run {{tool_name}} after creating 1-3 focused TODO tasks.',
|
|
46
|
-
];
|
|
47
|
-
}
|
|
48
|
-
function commitReminderTemplateExtra() {
|
|
49
|
-
return [
|
|
50
|
-
'',
|
|
51
|
-
'## Commit Reminder',
|
|
52
|
-
'- After this update, create a commit to keep progress auditable.',
|
|
53
|
-
'- Recommended format: type(scope): summary',
|
|
54
|
-
'- Example: feat(task): complete TASK-0007 validation flow',
|
|
55
|
-
'- Footer suggestion: Refs: TASK-0007, ROADMAP-0002',
|
|
56
|
-
];
|
|
57
|
-
}
|
|
58
24
|
export function getDefaultToolTemplateMarkdown(toolName) {
|
|
59
|
-
const base = baseToolTemplateMarkdown()
|
|
25
|
+
const base = baseToolTemplateMarkdown();
|
|
60
26
|
if (toolName === 'taskNext') {
|
|
61
|
-
return [
|
|
27
|
+
return [
|
|
28
|
+
base,
|
|
29
|
+
'',
|
|
30
|
+
'## Idle Discovery Checklist (When No Actionable Task)',
|
|
31
|
+
'- Scan backlog comments: TODO / FIXME / HACK / XXX.',
|
|
32
|
+
'- Check lint gaps and create executable fix tasks.',
|
|
33
|
+
'- Check test quality gaps (missing tests, flaky tests, low-value coverage).',
|
|
34
|
+
'- Learn current project architecture and consolidate/update design docs in designs/.',
|
|
35
|
+
'- Review and update architecture docs under designs/core/ (architecture.md, style-guide.md) if missing or outdated.',
|
|
36
|
+
'- Re-run {{tool_name}} after creating 1-3 focused TODO tasks.',
|
|
37
|
+
].join('\n');
|
|
62
38
|
}
|
|
63
39
|
if (toolName === 'projectContext' || toolName === 'taskContext' || toolName === 'roadmapContext') {
|
|
64
|
-
return [
|
|
40
|
+
return [
|
|
41
|
+
base,
|
|
42
|
+
'',
|
|
43
|
+
'## Common Tool Guides To Read First',
|
|
44
|
+
'- ./CLAUDE.md',
|
|
45
|
+
'- ./AGENTS.md',
|
|
46
|
+
'- ./.github/copilot-instructions.md',
|
|
47
|
+
'- ./.cursorrules',
|
|
48
|
+
'- ./.github/instructions/*',
|
|
49
|
+
'- ./.cursor/rules/*',
|
|
50
|
+
].join('\n');
|
|
65
51
|
}
|
|
66
52
|
if (toolName === 'taskUpdate' || toolName === 'roadmapUpdate') {
|
|
67
|
-
return [
|
|
68
|
-
|
|
69
|
-
|
|
53
|
+
return [
|
|
54
|
+
base,
|
|
55
|
+
'',
|
|
56
|
+
'## Commit Reminder',
|
|
57
|
+
'- After this update, create a commit to keep progress auditable.',
|
|
58
|
+
'- Recommended format: type(scope): summary',
|
|
59
|
+
'- Example: feat(task): complete TASK-0007 validation flow',
|
|
60
|
+
'- Footer suggestion: Refs: TASK-0007, ROADMAP-0002',
|
|
61
|
+
].join('\n');
|
|
62
|
+
}
|
|
63
|
+
return base;
|
|
70
64
|
}
|
|
71
65
|
function loadTemplateFile(templatePath) {
|
|
72
66
|
try {
|
|
@@ -172,13 +166,13 @@ function toSectionText(section) {
|
|
|
172
166
|
function resolveSection(payload, title) {
|
|
173
167
|
return payload.sections.find((item) => item.title === title);
|
|
174
168
|
}
|
|
175
|
-
function buildToolTemplateVariables(payload
|
|
169
|
+
function buildToolTemplateVariables(payload) {
|
|
176
170
|
return {
|
|
177
171
|
tool_name: payload.toolName,
|
|
178
|
-
content: classicMarkdown,
|
|
179
172
|
summary: toSectionText(resolveSection(payload, 'Summary')),
|
|
180
173
|
evidence: toSectionText(resolveSection(payload, 'Evidence')),
|
|
181
174
|
guidance: toSectionText(resolveSection(payload, 'Agent Guidance')),
|
|
175
|
+
suggestions: toSectionText(resolveSection(payload, 'Lint Suggestions')),
|
|
182
176
|
next_call: toSectionText(resolveSection(payload, 'Next Call')),
|
|
183
177
|
};
|
|
184
178
|
}
|
|
@@ -187,33 +181,23 @@ function applyTemplateVariables(template, variables) {
|
|
|
187
181
|
for (const [key, value] of Object.entries(variables)) {
|
|
188
182
|
rendered = rendered.split(`{{${key}}}`).join(value);
|
|
189
183
|
}
|
|
190
|
-
if (!rendered.includes(variables.content) && !template.includes(CONTENT_TEMPLATE_TOKEN)) {
|
|
191
|
-
rendered = `${rendered}\n\n${variables.content}`;
|
|
192
|
-
}
|
|
193
184
|
return rendered.trimEnd();
|
|
194
185
|
}
|
|
195
186
|
export function renderToolResponseMarkdown(payload) {
|
|
196
|
-
const body = payload.sections.flatMap((section) => [
|
|
197
|
-
`## ${section.title}`,
|
|
198
|
-
...withFallback(section.lines),
|
|
199
|
-
'',
|
|
200
|
-
]);
|
|
201
|
-
const classicMarkdown = [
|
|
202
|
-
`# ${payload.toolName}`,
|
|
203
|
-
'',
|
|
204
|
-
...body,
|
|
205
|
-
].join('\n').trimEnd();
|
|
206
187
|
const template = loadMessageTemplate(payload.toolName);
|
|
207
|
-
const variables = buildToolTemplateVariables(payload
|
|
188
|
+
const variables = buildToolTemplateVariables(payload);
|
|
208
189
|
return applyTemplateVariables(template, variables);
|
|
209
190
|
}
|
|
210
191
|
export function renderErrorMarkdown(toolName, cause, nextSteps, retryExample) {
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
192
|
+
const sections = [
|
|
193
|
+
section('Error', [`cause: ${cause}`]),
|
|
194
|
+
section('Next Step', nextSteps),
|
|
195
|
+
section('Retry Example', [retryExample ?? '(none)']),
|
|
196
|
+
];
|
|
197
|
+
const body = sections.flatMap((sec) => [
|
|
198
|
+
`## ${sec.title}`,
|
|
199
|
+
...withFallback(sec.lines),
|
|
200
|
+
'',
|
|
201
|
+
]);
|
|
202
|
+
return [`# ${toolName}`, '', ...body].join('\n').trimEnd();
|
|
219
203
|
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { asText, renderToolResponseMarkdown, renderErrorMarkdown, evidenceSection, guidanceSection, lintSection, nextCallSection, summarySection, } from './response.js';
|
|
2
|
+
export class ToolExecutionError extends Error {
|
|
3
|
+
nextSteps;
|
|
4
|
+
retryExample;
|
|
5
|
+
constructor(message, nextSteps = [], retryExample) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.nextSteps = nextSteps;
|
|
8
|
+
this.retryExample = retryExample;
|
|
9
|
+
this.name = 'ToolExecutionError';
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
export function createGovernedTool(spec) {
|
|
13
|
+
const cb = async (input) => {
|
|
14
|
+
try {
|
|
15
|
+
const ctx = { now: new Date().toISOString() };
|
|
16
|
+
const data = await spec.execute(input, ctx);
|
|
17
|
+
const markdown = renderToolResponseMarkdown({
|
|
18
|
+
toolName: spec.name,
|
|
19
|
+
sections: [
|
|
20
|
+
summarySection(await spec.summary(data, ctx)),
|
|
21
|
+
evidenceSection(await (spec.evidence?.(data, ctx) ?? [])),
|
|
22
|
+
guidanceSection(await spec.guidance(data, ctx)),
|
|
23
|
+
lintSection(await (spec.suggestions?.(data, ctx) ?? [])),
|
|
24
|
+
nextCallSection(await spec.nextCall(data, ctx)),
|
|
25
|
+
],
|
|
26
|
+
});
|
|
27
|
+
return asText(markdown);
|
|
28
|
+
}
|
|
29
|
+
catch (error) {
|
|
30
|
+
if (error instanceof ToolExecutionError) {
|
|
31
|
+
return {
|
|
32
|
+
...asText(renderErrorMarkdown(spec.name, error.message, error.nextSteps, error.retryExample)),
|
|
33
|
+
isError: true,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
return {
|
|
37
|
+
...asText(renderErrorMarkdown(spec.name, String(error), [])),
|
|
38
|
+
isError: true,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
return [spec.name, { title: spec.title, description: spec.description, inputSchema: spec.inputSchema }, cb];
|
|
43
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
+
import { capitalizeFirstLetter, formatTitle, readMarkdownOrFallback } from './utils.js';
|
|
6
|
+
const tempPaths = [];
|
|
7
|
+
async function createTempDir() {
|
|
8
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'projitive-utils-test-'));
|
|
9
|
+
tempPaths.push(dir);
|
|
10
|
+
return dir;
|
|
11
|
+
}
|
|
12
|
+
afterEach(async () => {
|
|
13
|
+
vi.restoreAllMocks();
|
|
14
|
+
await Promise.all(tempPaths.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })));
|
|
15
|
+
});
|
|
16
|
+
describe('utils module', () => {
|
|
17
|
+
it('reads markdown content when file exists and is non-empty', async () => {
|
|
18
|
+
const root = await createTempDir();
|
|
19
|
+
const filePath = path.join(root, 'README.md');
|
|
20
|
+
await fs.writeFile(filePath, '# Hello\n', 'utf-8');
|
|
21
|
+
const content = await readMarkdownOrFallback('README.md', 'Fallback', root);
|
|
22
|
+
expect(content).toBe('# Hello\n');
|
|
23
|
+
});
|
|
24
|
+
it('returns fallback markdown for missing or empty files', async () => {
|
|
25
|
+
const root = await createTempDir();
|
|
26
|
+
await fs.writeFile(path.join(root, 'empty.md'), ' ', 'utf-8');
|
|
27
|
+
const missing = await readMarkdownOrFallback('missing.md', 'Missing Title', root);
|
|
28
|
+
const empty = await readMarkdownOrFallback('empty.md', 'Empty Title', root);
|
|
29
|
+
expect(missing).toContain('# Missing Title');
|
|
30
|
+
expect(missing).toContain('- status: missing-or-empty');
|
|
31
|
+
expect(empty).toContain('# Empty Title');
|
|
32
|
+
expect(empty).toContain('- file: empty.md');
|
|
33
|
+
});
|
|
34
|
+
it('logs non-ENOENT read errors before falling back', async () => {
|
|
35
|
+
const root = await createTempDir();
|
|
36
|
+
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
|
|
37
|
+
vi.spyOn(fs, 'readFile').mockRejectedValueOnce({ code: 'EACCES' });
|
|
38
|
+
const result = await readMarkdownOrFallback('secret.md', 'Secret', root);
|
|
39
|
+
expect(result).toContain('# Secret');
|
|
40
|
+
expect(errorSpy).toHaveBeenCalledOnce();
|
|
41
|
+
});
|
|
42
|
+
it('formats title helpers consistently', () => {
|
|
43
|
+
expect(capitalizeFirstLetter('task_execution')).toBe('TaskExecution');
|
|
44
|
+
expect(capitalizeFirstLetter('roadmap-next')).toBe('RoadmapNext');
|
|
45
|
+
expect(formatTitle('task_execution')).toBe('Task Execution');
|
|
46
|
+
expect(formatTitle('roadmap-next')).toBe('Roadmap Next');
|
|
47
|
+
});
|
|
48
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
const hoisted = vi.hoisted(() => {
|
|
3
|
+
const connectMock = vi.fn().mockResolvedValue(undefined);
|
|
4
|
+
const serverInstance = { connect: connectMock };
|
|
5
|
+
return {
|
|
6
|
+
connectMock,
|
|
7
|
+
serverInstance,
|
|
8
|
+
registerToolsMock: vi.fn(),
|
|
9
|
+
registerPromptsMock: vi.fn(),
|
|
10
|
+
registerResourcesMock: vi.fn(),
|
|
11
|
+
mcpServerMock: vi.fn().mockImplementation(() => serverInstance),
|
|
12
|
+
stdioTransportMock: vi.fn().mockImplementation(() => ({ kind: 'stdio' })),
|
|
13
|
+
};
|
|
14
|
+
});
|
|
15
|
+
vi.mock('@modelcontextprotocol/sdk/server/mcp.js', () => ({
|
|
16
|
+
McpServer: hoisted.mcpServerMock,
|
|
17
|
+
}));
|
|
18
|
+
vi.mock('@modelcontextprotocol/sdk/server/stdio.js', () => ({
|
|
19
|
+
StdioServerTransport: hoisted.stdioTransportMock,
|
|
20
|
+
}));
|
|
21
|
+
vi.mock('./tools/index.js', () => ({
|
|
22
|
+
registerTools: hoisted.registerToolsMock,
|
|
23
|
+
}));
|
|
24
|
+
vi.mock('./prompts/index.js', () => ({
|
|
25
|
+
registerPrompts: hoisted.registerPromptsMock,
|
|
26
|
+
}));
|
|
27
|
+
vi.mock('./resources/index.js', () => ({
|
|
28
|
+
registerResources: hoisted.registerResourcesMock,
|
|
29
|
+
}));
|
|
30
|
+
describe('index runtime module', () => {
|
|
31
|
+
afterEach(() => {
|
|
32
|
+
vi.restoreAllMocks();
|
|
33
|
+
vi.resetModules();
|
|
34
|
+
hoisted.connectMock.mockClear();
|
|
35
|
+
hoisted.registerToolsMock.mockClear();
|
|
36
|
+
hoisted.registerPromptsMock.mockClear();
|
|
37
|
+
hoisted.registerResourcesMock.mockClear();
|
|
38
|
+
hoisted.mcpServerMock.mockClear();
|
|
39
|
+
hoisted.stdioTransportMock.mockClear();
|
|
40
|
+
});
|
|
41
|
+
it('boots the MCP server, registers modules, and connects stdio transport', async () => {
|
|
42
|
+
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
|
|
43
|
+
await import('./index.js');
|
|
44
|
+
await Promise.resolve();
|
|
45
|
+
expect(hoisted.mcpServerMock).toHaveBeenCalledWith(expect.objectContaining({
|
|
46
|
+
name: 'projitive',
|
|
47
|
+
description: expect.stringContaining('governance-store-first outputs'),
|
|
48
|
+
}));
|
|
49
|
+
expect(hoisted.registerToolsMock).toHaveBeenCalledWith(hoisted.serverInstance);
|
|
50
|
+
expect(hoisted.registerPromptsMock).toHaveBeenCalledWith(hoisted.serverInstance);
|
|
51
|
+
expect(hoisted.registerResourcesMock).toHaveBeenCalledWith(hoisted.serverInstance, expect.any(String));
|
|
52
|
+
expect(hoisted.stdioTransportMock).toHaveBeenCalledOnce();
|
|
53
|
+
expect(hoisted.connectMock).toHaveBeenCalledOnce();
|
|
54
|
+
expect(errorSpy).toHaveBeenCalledWith('[projitive-mcp] starting server');
|
|
55
|
+
expect(errorSpy.mock.calls.some((call) => String(call[0]).includes('transport=stdio'))).toBe(true);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
vi.mock('./quickStart.js', () => ({
|
|
3
|
+
registerQuickStartPrompt: vi.fn(),
|
|
4
|
+
}));
|
|
5
|
+
vi.mock('./taskDiscovery.js', () => ({
|
|
6
|
+
registerTaskDiscoveryPrompt: vi.fn(),
|
|
7
|
+
}));
|
|
8
|
+
vi.mock('./taskExecution.js', () => ({
|
|
9
|
+
registerTaskExecutionPrompt: vi.fn(),
|
|
10
|
+
}));
|
|
11
|
+
import { registerQuickStartPrompt } from './quickStart.js';
|
|
12
|
+
import { registerTaskDiscoveryPrompt } from './taskDiscovery.js';
|
|
13
|
+
import { registerTaskExecutionPrompt } from './taskExecution.js';
|
|
14
|
+
import { registerPrompts } from './index.js';
|
|
15
|
+
describe('prompts index module', () => {
|
|
16
|
+
it('registers all prompt categories', () => {
|
|
17
|
+
const server = {};
|
|
18
|
+
registerPrompts(server);
|
|
19
|
+
expect(registerQuickStartPrompt).toHaveBeenCalledWith(server);
|
|
20
|
+
expect(registerTaskDiscoveryPrompt).toHaveBeenCalledWith(server);
|
|
21
|
+
expect(registerTaskExecutionPrompt).toHaveBeenCalledWith(server);
|
|
22
|
+
});
|
|
23
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { registerQuickStartPrompt } from './quickStart.js';
|
|
3
|
+
describe('quickStart prompt', () => {
|
|
4
|
+
it('registers the prompt and renders known project guidance', async () => {
|
|
5
|
+
const server = { registerPrompt: vi.fn() };
|
|
6
|
+
registerQuickStartPrompt(server);
|
|
7
|
+
const handler = server.registerPrompt.mock.calls[0][2];
|
|
8
|
+
const result = await handler({ projectPath: '/workspace/app' });
|
|
9
|
+
const text = result.messages[0].content.text;
|
|
10
|
+
expect(server.registerPrompt.mock.calls[0][0]).toBe('quickStart');
|
|
11
|
+
expect(text).toContain('Known project path: "/workspace/app"');
|
|
12
|
+
expect(text).toContain('taskCreate');
|
|
13
|
+
expect(text).toContain('.projitive governance store is source of truth');
|
|
14
|
+
});
|
|
15
|
+
it('renders discovery workflow when project path is unknown', async () => {
|
|
16
|
+
const server = { registerPrompt: vi.fn() };
|
|
17
|
+
registerQuickStartPrompt(server);
|
|
18
|
+
const handler = server.registerPrompt.mock.calls[0][2];
|
|
19
|
+
const result = await handler({});
|
|
20
|
+
const text = result.messages[0].content.text;
|
|
21
|
+
expect(text).toContain('Call `projectScan()` to discover all governance roots');
|
|
22
|
+
expect(text).toContain('Call `projectLocate(inputPath="<selected-path>")` to lock governance root');
|
|
23
|
+
});
|
|
24
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { registerTaskDiscoveryPrompt } from './taskDiscovery.js';
|
|
3
|
+
describe('taskDiscovery prompt', () => {
|
|
4
|
+
it('registers the prompt and renders project-specific discovery guidance', async () => {
|
|
5
|
+
const server = { registerPrompt: vi.fn() };
|
|
6
|
+
registerTaskDiscoveryPrompt(server);
|
|
7
|
+
const handler = server.registerPrompt.mock.calls[0][2];
|
|
8
|
+
const result = await handler({ projectPath: '/workspace/app' });
|
|
9
|
+
const text = result.messages[0].content.text;
|
|
10
|
+
expect(server.registerPrompt.mock.calls[0][0]).toBe('taskDiscovery');
|
|
11
|
+
expect(text).toContain('Known project path: "/workspace/app"');
|
|
12
|
+
expect(text).toContain('Method A: Auto-select with taskNext()');
|
|
13
|
+
expect(text).toContain('roadmapCreate');
|
|
14
|
+
});
|
|
15
|
+
it('renders unknown-project discovery steps', async () => {
|
|
16
|
+
const server = { registerPrompt: vi.fn() };
|
|
17
|
+
registerTaskDiscoveryPrompt(server);
|
|
18
|
+
const handler = server.registerPrompt.mock.calls[0][2];
|
|
19
|
+
const result = await handler({});
|
|
20
|
+
const text = result.messages[0].content.text;
|
|
21
|
+
expect(text).toContain('Call `projectScan()` to discover all governance roots');
|
|
22
|
+
expect(text).toContain('Call `projectContext(projectPath="<project-path>")` to load project context');
|
|
23
|
+
});
|
|
24
|
+
});
|
|
@@ -43,6 +43,16 @@ export function registerTaskExecutionPrompt(server) {
|
|
|
43
43
|
'- Understand task background and motivation',
|
|
44
44
|
'- Identify task acceptance criteria',
|
|
45
45
|
'- Look for related design decisions',
|
|
46
|
+
'- Check Pre-Execution Research Brief status in taskContext output',
|
|
47
|
+
'- If status is MISSING/INCOMPLETE, complete it before any implementation',
|
|
48
|
+
'',
|
|
49
|
+
'### Pre-Execution Research Brief (Mandatory Gate)',
|
|
50
|
+
'- Fixed file name: `designs/research/<TASK-ID>.implementation-research.md`',
|
|
51
|
+
'- Required sections:',
|
|
52
|
+
' - `## Design Guidelines and Specs`',
|
|
53
|
+
' - `## Code Architecture and Implementation Findings`',
|
|
54
|
+
'- Must include line locations (for example `path/to/file.ts#L42`)',
|
|
55
|
+
'- You MUST read this file before progressing implementation',
|
|
46
56
|
'',
|
|
47
57
|
'### Evidence',
|
|
48
58
|
'- Candidate Files - Related file list',
|
|
@@ -65,6 +75,8 @@ export function registerTaskExecutionPrompt(server) {
|
|
|
65
75
|
'### Execution Steps',
|
|
66
76
|
'',
|
|
67
77
|
'1. **Prepare (if status is TODO)',
|
|
78
|
+
' - Ensure Pre-Execution Research Brief status is READY in `taskContext()`',
|
|
79
|
+
' - If not READY, complete research brief first and re-run `taskContext()`',
|
|
68
80
|
' - Call `taskUpdate()` to change status to IN_PROGRESS',
|
|
69
81
|
' - Set owner (if empty)',
|
|
70
82
|
' - Fill subState (optional, Spec v1.1.0)',
|
|
@@ -152,7 +164,7 @@ export function registerTaskExecutionPrompt(server) {
|
|
|
152
164
|
'2. **Every status transition must have report evidence**',
|
|
153
165
|
' - TODO \u2192 IN_PROGRESS: Report not required',
|
|
154
166
|
' - IN_PROGRESS \u2192 DONE: Report REQUIRED',
|
|
155
|
-
' - IN_PROGRESS \u2192 BLOCKED:
|
|
167
|
+
' - IN_PROGRESS \u2192 BLOCKED: Include report to explain blocker',
|
|
156
168
|
'',
|
|
157
169
|
'3. **Governance-store-first writes only**',
|
|
158
170
|
' - tasks.md/roadmap.md are generated views, not authoritative source',
|
|
@@ -166,6 +178,10 @@ export function registerTaskExecutionPrompt(server) {
|
|
|
166
178
|
'5. **Keep updatedAt in sync**',
|
|
167
179
|
' - Every time you update a task',
|
|
168
180
|
' - Must update updatedAt to current time (ISO 8601 format)',
|
|
181
|
+
'',
|
|
182
|
+
'6. **Pre-execution research brief is mandatory**',
|
|
183
|
+
' - `TODO -> IN_PROGRESS` is not allowed until research brief is READY',
|
|
184
|
+
' - Always read `designs/research/<TASK-ID>.implementation-research.md` before implementation',
|
|
169
185
|
].join('\n');
|
|
170
186
|
return asUserPrompt(text);
|
|
171
187
|
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { registerTaskExecutionPrompt } from './taskExecution.js';
|
|
3
|
+
describe('taskExecution prompt', () => {
|
|
4
|
+
it('registers the prompt and renders direct taskContext entry when task is known', async () => {
|
|
5
|
+
const server = { registerPrompt: vi.fn() };
|
|
6
|
+
registerTaskExecutionPrompt(server);
|
|
7
|
+
const handler = server.registerPrompt.mock.calls[0][2];
|
|
8
|
+
const result = await handler({ projectPath: '/workspace/app', taskId: 'TASK-0007' });
|
|
9
|
+
const text = result.messages[0].content.text;
|
|
10
|
+
expect(server.registerPrompt.mock.calls[0][0]).toBe('taskExecution');
|
|
11
|
+
expect(text).toContain('1) Run taskContext(projectPath="/workspace/app", taskId="TASK-0007").');
|
|
12
|
+
expect(text).toContain('Every status transition must have report evidence');
|
|
13
|
+
expect(text).toContain('Pre-Execution Research Brief (Mandatory Gate)');
|
|
14
|
+
expect(text).toContain('designs/research/<TASK-ID>.implementation-research.md');
|
|
15
|
+
});
|
|
16
|
+
it('falls back to taskNext when task is not provided', async () => {
|
|
17
|
+
const server = { registerPrompt: vi.fn() };
|
|
18
|
+
registerTaskExecutionPrompt(server);
|
|
19
|
+
const handler = server.registerPrompt.mock.calls[0][2];
|
|
20
|
+
const result = await handler({});
|
|
21
|
+
const text = result.messages[0].content.text;
|
|
22
|
+
expect(text).toContain('1) Run taskNext().');
|
|
23
|
+
expect(text).toContain('Governance-store-first writes only');
|
|
24
|
+
expect(text).toContain('TODO -> IN_PROGRESS');
|
|
25
|
+
expect(text).toContain('research brief is READY');
|
|
26
|
+
});
|
|
27
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
+
import { registerDesignFilesResources } from './designs.js';
|
|
6
|
+
const tempPaths = [];
|
|
7
|
+
async function createTempDir() {
|
|
8
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'projitive-design-resource-test-'));
|
|
9
|
+
tempPaths.push(dir);
|
|
10
|
+
return dir;
|
|
11
|
+
}
|
|
12
|
+
afterEach(async () => {
|
|
13
|
+
await Promise.all(tempPaths.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })));
|
|
14
|
+
vi.restoreAllMocks();
|
|
15
|
+
});
|
|
16
|
+
describe('design resources registration', () => {
|
|
17
|
+
it('registers discovered markdown design resources recursively', async () => {
|
|
18
|
+
const root = await createTempDir();
|
|
19
|
+
const designsDir = path.join(root, '.projitive', 'designs', 'mobile');
|
|
20
|
+
await fs.mkdir(designsDir, { recursive: true });
|
|
21
|
+
await fs.writeFile(path.join(root, '.projitive', 'designs', 'architecture.md'), '# Architecture\n', 'utf-8');
|
|
22
|
+
await fs.writeFile(path.join(designsDir, 'screen.md'), '# Screen\n', 'utf-8');
|
|
23
|
+
const server = { registerResource: vi.fn() };
|
|
24
|
+
await registerDesignFilesResources(server, root);
|
|
25
|
+
const calls = server.registerResource.mock.calls;
|
|
26
|
+
expect(calls).toHaveLength(2);
|
|
27
|
+
expect(calls.map((call) => call[0])).toEqual(expect.arrayContaining(['design-architecture', 'design-mobile-screen']));
|
|
28
|
+
const mobileHandler = calls.find((call) => call[0] === 'design-mobile-screen')?.[3];
|
|
29
|
+
const result = await mobileHandler();
|
|
30
|
+
expect(result.contents[0]).toEqual({
|
|
31
|
+
uri: 'projitive://designs/mobile-screen',
|
|
32
|
+
text: '# Screen\n',
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
it('registers default fallback resource when designs directory is missing', async () => {
|
|
36
|
+
const root = await createTempDir();
|
|
37
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { });
|
|
38
|
+
const server = { registerResource: vi.fn() };
|
|
39
|
+
await registerDesignFilesResources(server, root);
|
|
40
|
+
const calls = server.registerResource.mock.calls;
|
|
41
|
+
expect(calls).toHaveLength(1);
|
|
42
|
+
expect(calls[0][0]).toBe('designs');
|
|
43
|
+
expect(warnSpy).toHaveBeenCalledOnce();
|
|
44
|
+
const handler = calls[0][3];
|
|
45
|
+
await expect(handler()).resolves.toMatchObject({
|
|
46
|
+
contents: [{
|
|
47
|
+
uri: 'projitive://designs',
|
|
48
|
+
text: '# Designs Directory\n\nDesign documents not found. Please create design files in .projitive/designs/ directory.',
|
|
49
|
+
}],
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
});
|