@projitive/mcp 2.0.3 → 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 +8 -2
- package/output/source/common/artifacts.js +1 -1
- package/output/source/common/artifacts.test.js +11 -11
- package/output/source/common/errors.js +19 -19
- package/output/source/common/errors.test.js +59 -0
- package/output/source/common/files.js +30 -19
- package/output/source/common/files.test.js +14 -14
- package/output/source/common/index.js +11 -10
- package/output/source/common/linter.js +29 -27
- package/output/source/common/linter.test.js +9 -9
- package/output/source/common/markdown.js +3 -3
- package/output/source/common/markdown.test.js +15 -15
- package/output/source/common/response.js +91 -107
- package/output/source/common/response.test.js +30 -30
- package/output/source/common/store.js +40 -40
- package/output/source/common/store.test.js +72 -72
- package/output/source/common/tool.js +43 -0
- package/output/source/common/types.js +3 -3
- package/output/source/common/utils.js +8 -8
- package/output/source/common/utils.test.js +48 -0
- package/output/source/index.js +16 -16
- package/output/source/index.runtime.test.js +57 -0
- package/output/source/index.test.js +64 -64
- package/output/source/prompts/index.js +3 -3
- package/output/source/prompts/index.test.js +23 -0
- package/output/source/prompts/quickStart.js +96 -96
- package/output/source/prompts/quickStart.test.js +24 -0
- package/output/source/prompts/taskDiscovery.js +184 -184
- package/output/source/prompts/taskDiscovery.test.js +24 -0
- package/output/source/prompts/taskExecution.js +164 -148
- package/output/source/prompts/taskExecution.test.js +27 -0
- package/output/source/resources/designs.js +26 -26
- package/output/source/resources/designs.resources.test.js +52 -0
- package/output/source/resources/designs.test.js +88 -88
- package/output/source/resources/governance.js +19 -19
- package/output/source/resources/governance.test.js +35 -0
- package/output/source/resources/index.js +2 -2
- package/output/source/resources/index.test.js +18 -0
- package/output/source/resources/readme.js +7 -7
- package/output/source/resources/readme.test.js +113 -113
- package/output/source/resources/reports.js +10 -10
- package/output/source/resources/reports.test.js +83 -83
- package/output/source/tools/index.js +3 -3
- package/output/source/tools/index.test.js +23 -0
- package/output/source/tools/project.js +330 -377
- package/output/source/tools/project.test.js +308 -175
- package/output/source/tools/roadmap.js +236 -255
- package/output/source/tools/roadmap.test.js +241 -46
- package/output/source/tools/task.js +770 -652
- package/output/source/tools/task.test.js +433 -105
- package/output/source/types.js +28 -22
- package/package.json +8 -2
|
@@ -1,169 +1,497 @@
|
|
|
1
|
-
import { describe, expect, it } from
|
|
2
|
-
import { collectTaskLintSuggestions, isValidTaskId, normalizeTask, rankActionableTaskCandidates, resolveNoTaskDiscoveryGuidance, renderTaskSeedTemplate, renderTasksMarkdown, loadTasksDocument, saveTasks, taskPriority, toTaskUpdatedAtMs, validateTransition, } from
|
|
3
|
-
import fs from
|
|
4
|
-
import os from
|
|
5
|
-
import path from
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { collectTaskLintSuggestions, isValidTaskId, normalizeTask, rankActionableTaskCandidates, resolveNoTaskDiscoveryGuidance, renderTaskSeedTemplate, renderTasksMarkdown, loadTasksDocument, registerTaskTools, saveTasks, taskPriority, toTaskUpdatedAtMs, validateTransition, } from './task.js';
|
|
3
|
+
import fs from 'node:fs/promises';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { replaceRoadmapsInStore } from '../common/store.js';
|
|
7
|
+
async function createGovernanceWorkspace() {
|
|
8
|
+
const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'projitive-mcp-task-workspace-'));
|
|
9
|
+
const governanceDir = path.join(projectRoot, '.projitive');
|
|
10
|
+
const dbPath = path.join(governanceDir, '.projitive');
|
|
11
|
+
await fs.mkdir(governanceDir, { recursive: true });
|
|
12
|
+
await fs.writeFile(dbPath, '', 'utf-8');
|
|
13
|
+
return { projectRoot, governanceDir, dbPath };
|
|
14
|
+
}
|
|
15
|
+
function getToolHandler(mockServer, toolName) {
|
|
16
|
+
const call = mockServer.registerTool.mock.calls.find((entry) => entry[0] === toolName);
|
|
17
|
+
expect(call).toBeTruthy();
|
|
18
|
+
return call?.[2];
|
|
19
|
+
}
|
|
6
20
|
function buildCandidate(partial) {
|
|
7
21
|
const task = normalizeTask({
|
|
8
22
|
id: partial.id,
|
|
9
23
|
title: partial.title,
|
|
10
24
|
status: partial.status,
|
|
11
|
-
updatedAt: partial.task?.updatedAt ??
|
|
25
|
+
updatedAt: partial.task?.updatedAt ?? '2026-01-01T00:00:00.000Z',
|
|
12
26
|
});
|
|
13
27
|
return {
|
|
14
|
-
governanceDir: partial.governanceDir ??
|
|
28
|
+
governanceDir: partial.governanceDir ?? '/workspace/a',
|
|
15
29
|
task,
|
|
16
30
|
projectScore: partial.projectScore ?? 1,
|
|
17
|
-
projectLatestUpdatedAt: partial.projectLatestUpdatedAt ??
|
|
31
|
+
projectLatestUpdatedAt: partial.projectLatestUpdatedAt ?? '2026-01-01T00:00:00.000Z',
|
|
18
32
|
taskUpdatedAtMs: partial.taskUpdatedAtMs ?? toTaskUpdatedAtMs(task.updatedAt),
|
|
19
33
|
taskPriority: partial.taskPriority ?? taskPriority(task.status),
|
|
20
34
|
};
|
|
21
35
|
}
|
|
22
|
-
describe(
|
|
23
|
-
it(
|
|
24
|
-
expect(isValidTaskId(
|
|
25
|
-
expect(isValidTaskId(
|
|
36
|
+
describe('tasks module', () => {
|
|
37
|
+
it('validates task IDs', () => {
|
|
38
|
+
expect(isValidTaskId('TASK-0001')).toBe(true);
|
|
39
|
+
expect(isValidTaskId('TASK-1')).toBe(true);
|
|
40
|
+
expect(isValidTaskId('TASK-12345')).toBe(true);
|
|
41
|
+
expect(isValidTaskId('TASK-ABCD')).toBe(false);
|
|
26
42
|
});
|
|
27
|
-
it(
|
|
28
|
-
expect(validateTransition(
|
|
29
|
-
expect(validateTransition(
|
|
30
|
-
expect(validateTransition(
|
|
43
|
+
it('allows and rejects expected transitions', () => {
|
|
44
|
+
expect(validateTransition('TODO', 'IN_PROGRESS')).toBe(true);
|
|
45
|
+
expect(validateTransition('IN_PROGRESS', 'DONE')).toBe(true);
|
|
46
|
+
expect(validateTransition('DONE', 'IN_PROGRESS')).toBe(false);
|
|
31
47
|
});
|
|
32
|
-
it(
|
|
33
|
-
expect(taskPriority(
|
|
34
|
-
expect(taskPriority(
|
|
35
|
-
expect(taskPriority(
|
|
48
|
+
it('assigns priority for actionable statuses', () => {
|
|
49
|
+
expect(taskPriority('IN_PROGRESS')).toBe(2);
|
|
50
|
+
expect(taskPriority('TODO')).toBe(1);
|
|
51
|
+
expect(taskPriority('BLOCKED')).toBe(0);
|
|
36
52
|
});
|
|
37
|
-
it(
|
|
38
|
-
expect(toTaskUpdatedAtMs(
|
|
53
|
+
it('returns zero timestamp for invalid date', () => {
|
|
54
|
+
expect(toTaskUpdatedAtMs('invalid')).toBe(0);
|
|
39
55
|
});
|
|
40
|
-
it(
|
|
56
|
+
it('ranks by project score, then task priority, then recency', () => {
|
|
41
57
|
const candidates = [
|
|
42
|
-
buildCandidate({ id:
|
|
43
|
-
buildCandidate({ id:
|
|
44
|
-
buildCandidate({ id:
|
|
58
|
+
buildCandidate({ id: 'TASK-0001', title: 'A', status: 'TODO', projectScore: 2 }),
|
|
59
|
+
buildCandidate({ id: 'TASK-0002', title: 'B', status: 'IN_PROGRESS', projectScore: 2 }),
|
|
60
|
+
buildCandidate({ id: 'TASK-0003', title: 'C', status: 'IN_PROGRESS', projectScore: 3 }),
|
|
45
61
|
];
|
|
46
62
|
const ranked = rankActionableTaskCandidates(candidates);
|
|
47
|
-
expect(ranked[0].task.id).toBe(
|
|
48
|
-
expect(ranked[1].task.id).toBe(
|
|
49
|
-
expect(ranked[2].task.id).toBe(
|
|
63
|
+
expect(ranked[0].task.id).toBe('TASK-0003');
|
|
64
|
+
expect(ranked[1].task.id).toBe('TASK-0002');
|
|
65
|
+
expect(ranked[2].task.id).toBe('TASK-0001');
|
|
50
66
|
});
|
|
51
|
-
it(
|
|
52
|
-
const task = normalizeTask({ id:
|
|
67
|
+
it('renders task markdown without legacy markers', () => {
|
|
68
|
+
const task = normalizeTask({ id: 'TASK-0002', title: 'render', status: 'IN_PROGRESS' });
|
|
53
69
|
const markdown = renderTasksMarkdown([task]);
|
|
54
|
-
expect(markdown.includes(
|
|
55
|
-
expect(markdown.includes(
|
|
56
|
-
expect(markdown.includes(
|
|
70
|
+
expect(markdown.includes('PROJITIVE:TASKS:START')).toBe(false);
|
|
71
|
+
expect(markdown.includes('PROJITIVE:TASKS:END')).toBe(false);
|
|
72
|
+
expect(markdown.includes('## TASK-0002 | IN_PROGRESS | render')).toBe(true);
|
|
73
|
+
expect(markdown).toContain('Author: yinxulai');
|
|
74
|
+
expect(markdown).toContain('Repository: https://github.com/yinxulai/projitive');
|
|
75
|
+
expect(markdown).toContain('Do not edit this file manually.');
|
|
57
76
|
});
|
|
58
|
-
it(
|
|
77
|
+
it('renders tasks with subState and blocker metadata', () => {
|
|
59
78
|
const task1 = normalizeTask({
|
|
60
|
-
id:
|
|
61
|
-
title:
|
|
62
|
-
status:
|
|
79
|
+
id: 'TASK-0001',
|
|
80
|
+
title: 'In Progress Task',
|
|
81
|
+
status: 'IN_PROGRESS',
|
|
63
82
|
subState: {
|
|
64
|
-
phase:
|
|
83
|
+
phase: 'implementation',
|
|
65
84
|
confidence: 0.85,
|
|
66
85
|
},
|
|
67
86
|
});
|
|
68
87
|
const task2 = normalizeTask({
|
|
69
|
-
id:
|
|
70
|
-
title:
|
|
71
|
-
status:
|
|
88
|
+
id: 'TASK-0002',
|
|
89
|
+
title: 'Blocked Task',
|
|
90
|
+
status: 'BLOCKED',
|
|
72
91
|
blocker: {
|
|
73
|
-
type:
|
|
74
|
-
description:
|
|
92
|
+
type: 'external_dependency',
|
|
93
|
+
description: 'Waiting for API',
|
|
75
94
|
},
|
|
76
95
|
});
|
|
77
96
|
const markdown = renderTasksMarkdown([task1, task2]);
|
|
78
|
-
expect(markdown).toContain(
|
|
79
|
-
expect(markdown).toContain(
|
|
80
|
-
expect(markdown).toContain(
|
|
81
|
-
expect(markdown).toContain(
|
|
97
|
+
expect(markdown).toContain('phase: implementation');
|
|
98
|
+
expect(markdown).toContain('confidence: 0.85');
|
|
99
|
+
expect(markdown).toContain('type: external_dependency');
|
|
100
|
+
expect(markdown).toContain('description: Waiting for API');
|
|
82
101
|
});
|
|
83
|
-
it(
|
|
102
|
+
it('collects lint lines with stable code prefix', () => {
|
|
84
103
|
const task = normalizeTask({
|
|
85
|
-
id:
|
|
86
|
-
title:
|
|
87
|
-
status:
|
|
88
|
-
owner:
|
|
104
|
+
id: 'TASK-0001',
|
|
105
|
+
title: 'lint',
|
|
106
|
+
status: 'IN_PROGRESS',
|
|
107
|
+
owner: '',
|
|
89
108
|
roadmapRefs: [],
|
|
90
109
|
});
|
|
91
110
|
const lint = collectTaskLintSuggestions([task]);
|
|
92
|
-
expect(lint.some((line) => line.startsWith(
|
|
93
|
-
expect(lint.some((line) => line.startsWith(
|
|
111
|
+
expect(lint.some((line) => line.startsWith('- [TASK_IN_PROGRESS_OWNER_EMPTY]'))).toBe(true);
|
|
112
|
+
expect(lint.some((line) => line.startsWith('- [TASK_ROADMAP_REFS_EMPTY]'))).toBe(true);
|
|
94
113
|
});
|
|
95
|
-
it(
|
|
114
|
+
it('collects blocker/substate lint rules', () => {
|
|
96
115
|
const blocked = normalizeTask({
|
|
97
|
-
id:
|
|
98
|
-
title:
|
|
99
|
-
status:
|
|
100
|
-
summary:
|
|
101
|
-
roadmapRefs: [
|
|
116
|
+
id: 'TASK-0001',
|
|
117
|
+
title: 'Blocked',
|
|
118
|
+
status: 'BLOCKED',
|
|
119
|
+
summary: 'blocked reason',
|
|
120
|
+
roadmapRefs: ['ROADMAP-0001'],
|
|
102
121
|
});
|
|
103
122
|
const inProgress = normalizeTask({
|
|
104
|
-
id:
|
|
105
|
-
title:
|
|
106
|
-
status:
|
|
107
|
-
owner:
|
|
108
|
-
roadmapRefs: [
|
|
123
|
+
id: 'TASK-0002',
|
|
124
|
+
title: 'In Progress',
|
|
125
|
+
status: 'IN_PROGRESS',
|
|
126
|
+
owner: 'ai-copilot',
|
|
127
|
+
roadmapRefs: ['ROADMAP-0001'],
|
|
109
128
|
});
|
|
110
129
|
const lint = collectTaskLintSuggestions([blocked, inProgress]);
|
|
111
|
-
expect(lint.some((line) => line.includes(
|
|
112
|
-
expect(lint.some((line) => line.includes(
|
|
130
|
+
expect(lint.some((line) => line.includes('BLOCKED_WITHOUT_BLOCKER'))).toBe(true);
|
|
131
|
+
expect(lint.some((line) => line.includes('IN_PROGRESS_WITHOUT_SUBSTATE'))).toBe(true);
|
|
113
132
|
});
|
|
114
|
-
it(
|
|
133
|
+
it('normalizes links to project-root-relative format without leading slash', () => {
|
|
115
134
|
const task = normalizeTask({
|
|
116
|
-
id:
|
|
117
|
-
title:
|
|
118
|
-
status:
|
|
119
|
-
links: [
|
|
135
|
+
id: 'TASK-0003',
|
|
136
|
+
title: 'link normalize',
|
|
137
|
+
status: 'TODO',
|
|
138
|
+
links: ['/reports/a.md', './designs/b.md', 'reports/c.md', 'https://example.com/evidence'],
|
|
120
139
|
});
|
|
121
|
-
expect(task.links).toContain(
|
|
122
|
-
expect(task.links).toContain(
|
|
123
|
-
expect(task.links).toContain(
|
|
124
|
-
expect(task.links).toContain(
|
|
125
|
-
expect(task.links.some((item) => item.startsWith(
|
|
140
|
+
expect(task.links).toContain('reports/a.md');
|
|
141
|
+
expect(task.links).toContain('designs/b.md');
|
|
142
|
+
expect(task.links).toContain('reports/c.md');
|
|
143
|
+
expect(task.links).toContain('https://example.com/evidence');
|
|
144
|
+
expect(task.links.some((item) => item.startsWith('/'))).toBe(false);
|
|
126
145
|
});
|
|
127
|
-
it(
|
|
146
|
+
it('lints invalid links path format', () => {
|
|
128
147
|
const task = normalizeTask({
|
|
129
|
-
id:
|
|
130
|
-
title:
|
|
131
|
-
status:
|
|
132
|
-
links: [
|
|
133
|
-
roadmapRefs: [
|
|
148
|
+
id: 'TASK-0004',
|
|
149
|
+
title: 'invalid link',
|
|
150
|
+
status: 'TODO',
|
|
151
|
+
links: ['../outside.md'],
|
|
152
|
+
roadmapRefs: ['ROADMAP-0001'],
|
|
134
153
|
});
|
|
135
154
|
const lint = collectTaskLintSuggestions([task]);
|
|
136
|
-
expect(lint.some((line) => line.includes(
|
|
155
|
+
expect(lint.some((line) => line.includes('TASK_LINK_PATH_FORMAT_INVALID'))).toBe(true);
|
|
137
156
|
});
|
|
138
|
-
it(
|
|
139
|
-
const lines = renderTaskSeedTemplate(
|
|
140
|
-
const markdown = lines.join(
|
|
141
|
-
expect(markdown).toContain(
|
|
157
|
+
it('renders seed task template with provided roadmap ref', () => {
|
|
158
|
+
const lines = renderTaskSeedTemplate('ROADMAP-0099');
|
|
159
|
+
const markdown = lines.join('\n');
|
|
160
|
+
expect(markdown).toContain('- roadmapRefs: ROADMAP-0099');
|
|
142
161
|
});
|
|
143
|
-
it(
|
|
144
|
-
const guidance = await resolveNoTaskDiscoveryGuidance(
|
|
162
|
+
it('uses default no-task guidance when hook file is absent', async () => {
|
|
163
|
+
const guidance = await resolveNoTaskDiscoveryGuidance('/path/that/does/not/exist');
|
|
145
164
|
expect(guidance.length).toBeGreaterThan(3);
|
|
146
165
|
});
|
|
147
|
-
it(
|
|
148
|
-
const guidanceA = await resolveNoTaskDiscoveryGuidance(
|
|
149
|
-
const guidanceB = await resolveNoTaskDiscoveryGuidance(
|
|
166
|
+
it('returns same default no-task guidance regardless of path', async () => {
|
|
167
|
+
const guidanceA = await resolveNoTaskDiscoveryGuidance('/path/that/does/not/exist');
|
|
168
|
+
const guidanceB = await resolveNoTaskDiscoveryGuidance('/another/path');
|
|
150
169
|
expect(guidanceA).toEqual(guidanceB);
|
|
151
170
|
});
|
|
152
|
-
it(
|
|
153
|
-
const root = await fs.mkdtemp(path.join(os.tmpdir(),
|
|
154
|
-
const governanceDir = path.join(root,
|
|
171
|
+
it('loads and saves tasks from governance store and keeps newest-first order', async () => {
|
|
172
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'projitive-mcp-task-'));
|
|
173
|
+
const governanceDir = path.join(root, '.projitive');
|
|
155
174
|
await fs.mkdir(governanceDir, { recursive: true });
|
|
156
|
-
await fs.writeFile(path.join(governanceDir,
|
|
157
|
-
const tasksPath = path.join(governanceDir,
|
|
175
|
+
await fs.writeFile(path.join(governanceDir, '.projitive'), '', 'utf-8');
|
|
176
|
+
const tasksPath = path.join(governanceDir, '.projitive');
|
|
158
177
|
await saveTasks(tasksPath, [
|
|
159
|
-
normalizeTask({ id:
|
|
160
|
-
normalizeTask({ id:
|
|
178
|
+
normalizeTask({ id: 'TASK-0001', title: 'older', status: 'TODO', updatedAt: '2026-01-01T00:00:00.000Z' }),
|
|
179
|
+
normalizeTask({ id: 'TASK-0002', title: 'newer', status: 'TODO', updatedAt: '2026-02-01T00:00:00.000Z' }),
|
|
161
180
|
]);
|
|
162
181
|
const loaded = await loadTasksDocument(governanceDir);
|
|
163
|
-
expect(loaded.tasks[0].id).toBe(
|
|
164
|
-
expect(loaded.tasks[1].id).toBe(
|
|
165
|
-
const markdown = await fs.readFile(path.join(governanceDir,
|
|
166
|
-
expect(markdown).toContain(
|
|
182
|
+
expect(loaded.tasks[0].id).toBe('TASK-0002');
|
|
183
|
+
expect(loaded.tasks[1].id).toBe('TASK-0001');
|
|
184
|
+
const markdown = await fs.readFile(path.join(governanceDir, 'tasks.md'), 'utf-8');
|
|
185
|
+
expect(markdown).toContain('generated from .projitive governance store');
|
|
186
|
+
expect(markdown).toContain('Author: yinxulai');
|
|
187
|
+
expect(markdown).toContain('Repository: https://github.com/yinxulai/projitive');
|
|
167
188
|
await fs.rm(root, { recursive: true, force: true });
|
|
168
189
|
});
|
|
190
|
+
it('taskCreate auto-generates the next task id and syncs tasks view', async () => {
|
|
191
|
+
const { projectRoot, governanceDir, dbPath } = await createGovernanceWorkspace();
|
|
192
|
+
await replaceRoadmapsInStore(dbPath, [
|
|
193
|
+
{ id: 'ROADMAP-0001', title: 'Bootstrap', status: 'active', updatedAt: '2026-03-14T00:00:00.000Z' },
|
|
194
|
+
]);
|
|
195
|
+
await saveTasks(dbPath, [
|
|
196
|
+
normalizeTask({ id: 'TASK-0009', title: 'existing', status: 'TODO', roadmapRefs: ['ROADMAP-0001'] }),
|
|
197
|
+
]);
|
|
198
|
+
const mockServer = { registerTool: vi.fn() };
|
|
199
|
+
registerTaskTools(mockServer);
|
|
200
|
+
const taskCreate = getToolHandler(mockServer, 'taskCreate');
|
|
201
|
+
const result = await taskCreate({
|
|
202
|
+
projectPath: projectRoot,
|
|
203
|
+
title: 'newly generated',
|
|
204
|
+
summary: 'auto id test',
|
|
205
|
+
roadmapRefs: ['ROADMAP-0001'],
|
|
206
|
+
});
|
|
207
|
+
expect(result.isError).toBeUndefined();
|
|
208
|
+
expect(result.content[0].text).toContain('TASK-0010');
|
|
209
|
+
const loaded = await loadTasksDocument(governanceDir);
|
|
210
|
+
expect(loaded.tasks.some((task) => task.id === 'TASK-0010' && task.title === 'newly generated')).toBe(true);
|
|
211
|
+
await fs.rm(projectRoot, { recursive: true, force: true });
|
|
212
|
+
});
|
|
213
|
+
it('taskUpdate rejects invalid status transitions', async () => {
|
|
214
|
+
const { projectRoot, dbPath } = await createGovernanceWorkspace();
|
|
215
|
+
await saveTasks(dbPath, [
|
|
216
|
+
normalizeTask({ id: 'TASK-0001', title: 'done task', status: 'DONE' }),
|
|
217
|
+
]);
|
|
218
|
+
const mockServer = { registerTool: vi.fn() };
|
|
219
|
+
registerTaskTools(mockServer);
|
|
220
|
+
const taskUpdate = getToolHandler(mockServer, 'taskUpdate');
|
|
221
|
+
const result = await taskUpdate({
|
|
222
|
+
projectPath: projectRoot,
|
|
223
|
+
taskId: 'TASK-0001',
|
|
224
|
+
updates: { status: 'IN_PROGRESS' },
|
|
225
|
+
});
|
|
226
|
+
expect(result.isError).toBe(true);
|
|
227
|
+
expect(result.content[0].text).toContain('Invalid status transition');
|
|
228
|
+
await fs.rm(projectRoot, { recursive: true, force: true });
|
|
229
|
+
});
|
|
230
|
+
it('taskNext emits no-actionable guidance when only blocked tasks exist', async () => {
|
|
231
|
+
const { projectRoot, dbPath } = await createGovernanceWorkspace();
|
|
232
|
+
await replaceRoadmapsInStore(dbPath, [
|
|
233
|
+
{ id: 'ROADMAP-0001', title: 'Blocked milestone', status: 'active', updatedAt: '2026-03-14T00:00:00.000Z' },
|
|
234
|
+
]);
|
|
235
|
+
await saveTasks(dbPath, [
|
|
236
|
+
normalizeTask({
|
|
237
|
+
id: 'TASK-0001',
|
|
238
|
+
title: 'blocked task',
|
|
239
|
+
status: 'BLOCKED',
|
|
240
|
+
summary: 'waiting',
|
|
241
|
+
roadmapRefs: ['ROADMAP-0001'],
|
|
242
|
+
}),
|
|
243
|
+
]);
|
|
244
|
+
vi.stubEnv('PROJITIVE_SCAN_ROOT_PATHS', projectRoot);
|
|
245
|
+
vi.stubEnv('PROJITIVE_SCAN_MAX_DEPTH', '3');
|
|
246
|
+
const mockServer = { registerTool: vi.fn() };
|
|
247
|
+
registerTaskTools(mockServer);
|
|
248
|
+
const taskNext = getToolHandler(mockServer, 'taskNext');
|
|
249
|
+
const result = await taskNext({});
|
|
250
|
+
expect(result.content[0].text).toContain('No TODO/IN_PROGRESS task is available.');
|
|
251
|
+
expect(result.content[0].text).toContain('No-Task Discovery Checklist');
|
|
252
|
+
expect(result.content[0].text).toContain('taskCreate(projectPath=');
|
|
253
|
+
vi.unstubAllEnvs();
|
|
254
|
+
await fs.rm(projectRoot, { recursive: true, force: true });
|
|
255
|
+
});
|
|
256
|
+
it('taskList and taskContext expose synced views and evidence for an existing task', async () => {
|
|
257
|
+
const { projectRoot, governanceDir, dbPath } = await createGovernanceWorkspace();
|
|
258
|
+
await replaceRoadmapsInStore(dbPath, [
|
|
259
|
+
{ id: 'ROADMAP-0001', title: 'Roadmap', status: 'active', updatedAt: '2026-03-14T00:00:00.000Z' },
|
|
260
|
+
]);
|
|
261
|
+
await fs.writeFile(path.join(projectRoot, 'README.md'), '# Root Readme\nTASK-0001\n', 'utf-8');
|
|
262
|
+
await saveTasks(dbPath, [
|
|
263
|
+
normalizeTask({
|
|
264
|
+
id: 'TASK-0001',
|
|
265
|
+
title: 'inspect me',
|
|
266
|
+
status: 'IN_PROGRESS',
|
|
267
|
+
owner: 'copilot',
|
|
268
|
+
summary: 'has evidence',
|
|
269
|
+
roadmapRefs: ['ROADMAP-0001'],
|
|
270
|
+
links: ['README.md'],
|
|
271
|
+
}),
|
|
272
|
+
]);
|
|
273
|
+
const mockServer = { registerTool: vi.fn() };
|
|
274
|
+
registerTaskTools(mockServer);
|
|
275
|
+
const taskList = getToolHandler(mockServer, 'taskList');
|
|
276
|
+
const listResult = await taskList({ projectPath: projectRoot, status: 'IN_PROGRESS' });
|
|
277
|
+
expect(listResult.content[0].text).toContain(`tasksView: ${path.join(governanceDir, 'tasks.md')}`);
|
|
278
|
+
expect(listResult.content[0].text).toContain('TASK-0001 | IN_PROGRESS | inspect me');
|
|
279
|
+
const taskContext = getToolHandler(mockServer, 'taskContext');
|
|
280
|
+
const contextResult = await taskContext({ projectPath: projectRoot, taskId: 'TASK-0001' });
|
|
281
|
+
expect(contextResult.content[0].text).toContain(`roadmapView: ${path.join(governanceDir, 'roadmap.md')}`);
|
|
282
|
+
expect(contextResult.content[0].text).toContain('### Reference Locations');
|
|
283
|
+
expect(contextResult.content[0].text).toContain('README.md');
|
|
284
|
+
expect(contextResult.content[0].text).toContain('### Pre-Execution Research Brief');
|
|
285
|
+
expect(contextResult.content[0].text).toContain('researchBriefStatus: MISSING');
|
|
286
|
+
expect(contextResult.content[0].text).toContain('designs/research/TASK-0001.implementation-research.md');
|
|
287
|
+
expect(contextResult.content[0].text).toContain('architectureDocsStatus: MISSING');
|
|
288
|
+
expect(contextResult.content[0].text).toContain('styleDocsStatus: MISSING');
|
|
289
|
+
expect(contextResult.content[0].text).toContain('Project context docs gate is NOT satisfied');
|
|
290
|
+
expect(contextResult.content[0].text).toContain('PROJECT_ARCHITECTURE_DOC_MISSING');
|
|
291
|
+
expect(contextResult.content[0].text).toContain('PROJECT_STYLE_DOC_MISSING');
|
|
292
|
+
await fs.rm(projectRoot, { recursive: true, force: true });
|
|
293
|
+
});
|
|
294
|
+
it('taskUpdate allows TODO -> IN_PROGRESS when research brief is missing, with lint guidance', async () => {
|
|
295
|
+
const { projectRoot, dbPath } = await createGovernanceWorkspace();
|
|
296
|
+
await replaceRoadmapsInStore(dbPath, [
|
|
297
|
+
{ id: 'ROADMAP-0001', title: 'Bootstrap', status: 'active', updatedAt: '2026-03-14T00:00:00.000Z' },
|
|
298
|
+
]);
|
|
299
|
+
await saveTasks(dbPath, [
|
|
300
|
+
normalizeTask({ id: 'TASK-0001', title: 'gate test', status: 'TODO', roadmapRefs: ['ROADMAP-0001'] }),
|
|
301
|
+
]);
|
|
302
|
+
const mockServer = { registerTool: vi.fn() };
|
|
303
|
+
registerTaskTools(mockServer);
|
|
304
|
+
const taskUpdate = getToolHandler(mockServer, 'taskUpdate');
|
|
305
|
+
const result = await taskUpdate({
|
|
306
|
+
projectPath: projectRoot,
|
|
307
|
+
taskId: 'TASK-0001',
|
|
308
|
+
updates: { status: 'IN_PROGRESS' },
|
|
309
|
+
});
|
|
310
|
+
expect(result.isError).toBeUndefined();
|
|
311
|
+
expect(result.content[0].text).toContain('newStatus: IN_PROGRESS');
|
|
312
|
+
expect(result.content[0].text).toContain('TASK_RESEARCH_BRIEF_MISSING');
|
|
313
|
+
await fs.rm(projectRoot, { recursive: true, force: true });
|
|
314
|
+
});
|
|
315
|
+
it('taskUpdate allows TODO -> IN_PROGRESS when research brief is ready', async () => {
|
|
316
|
+
const { projectRoot, dbPath } = await createGovernanceWorkspace();
|
|
317
|
+
await replaceRoadmapsInStore(dbPath, [
|
|
318
|
+
{ id: 'ROADMAP-0001', title: 'Bootstrap', status: 'active', updatedAt: '2026-03-14T00:00:00.000Z' },
|
|
319
|
+
]);
|
|
320
|
+
await saveTasks(dbPath, [
|
|
321
|
+
normalizeTask({ id: 'TASK-0001', title: 'gate test pass', status: 'TODO', roadmapRefs: ['ROADMAP-0001'] }),
|
|
322
|
+
]);
|
|
323
|
+
const researchDir = path.join(projectRoot, 'designs', 'research');
|
|
324
|
+
await fs.mkdir(researchDir, { recursive: true });
|
|
325
|
+
await fs.writeFile(path.join(researchDir, 'TASK-0001.implementation-research.md'), [
|
|
326
|
+
'# TASK-0001 Implementation Research Brief',
|
|
327
|
+
'',
|
|
328
|
+
'## Design Guidelines and Specs',
|
|
329
|
+
'- designs/PROJITIVE.md#L10-L20',
|
|
330
|
+
'',
|
|
331
|
+
'## Code Architecture and Implementation Findings',
|
|
332
|
+
'- packages/mcp/source/tools/task.ts#L1-L50',
|
|
333
|
+
].join('\n'), 'utf-8');
|
|
334
|
+
const mockServer = { registerTool: vi.fn() };
|
|
335
|
+
registerTaskTools(mockServer);
|
|
336
|
+
const taskUpdate = getToolHandler(mockServer, 'taskUpdate');
|
|
337
|
+
const result = await taskUpdate({
|
|
338
|
+
projectPath: projectRoot,
|
|
339
|
+
taskId: 'TASK-0001',
|
|
340
|
+
updates: { status: 'IN_PROGRESS' },
|
|
341
|
+
});
|
|
342
|
+
expect(result.isError).toBeUndefined();
|
|
343
|
+
expect(result.content[0].text).toContain('newStatus: IN_PROGRESS');
|
|
344
|
+
await fs.rm(projectRoot, { recursive: true, force: true });
|
|
345
|
+
});
|
|
346
|
+
it('taskContext requires project core docs under designs/core (docs outside core do not satisfy)', async () => {
|
|
347
|
+
const { projectRoot, dbPath } = await createGovernanceWorkspace();
|
|
348
|
+
await replaceRoadmapsInStore(dbPath, [
|
|
349
|
+
{ id: 'ROADMAP-0001', title: 'Roadmap', status: 'active', updatedAt: '2026-03-14T00:00:00.000Z' },
|
|
350
|
+
]);
|
|
351
|
+
await saveTasks(dbPath, [
|
|
352
|
+
normalizeTask({ id: 'TASK-0001', title: 'core docs rule', status: 'TODO', roadmapRefs: ['ROADMAP-0001'] }),
|
|
353
|
+
]);
|
|
354
|
+
await fs.mkdir(path.join(projectRoot, 'designs'), { recursive: true });
|
|
355
|
+
await fs.writeFile(path.join(projectRoot, 'designs', 'architecture.md'), '# Architecture\n', 'utf-8');
|
|
356
|
+
await fs.writeFile(path.join(projectRoot, 'designs', 'style-guide.md'), '# Style\n', 'utf-8');
|
|
357
|
+
const mockServer = { registerTool: vi.fn() };
|
|
358
|
+
registerTaskTools(mockServer);
|
|
359
|
+
const taskContext = getToolHandler(mockServer, 'taskContext');
|
|
360
|
+
const contextResult = await taskContext({ projectPath: projectRoot, taskId: 'TASK-0001' });
|
|
361
|
+
expect(contextResult.content[0].text).toContain('architectureDocsStatus: MISSING');
|
|
362
|
+
expect(contextResult.content[0].text).toContain('styleDocsStatus: MISSING');
|
|
363
|
+
expect(contextResult.content[0].text).toContain('designs/core/architecture.md');
|
|
364
|
+
expect(contextResult.content[0].text).toContain('PROJECT_ARCHITECTURE_DOC_MISSING');
|
|
365
|
+
expect(contextResult.content[0].text).toContain('PROJECT_STYLE_DOC_MISSING');
|
|
366
|
+
await fs.rm(projectRoot, { recursive: true, force: true });
|
|
367
|
+
});
|
|
368
|
+
it('taskContext marks project core docs ready when architecture/style docs exist under designs/core', async () => {
|
|
369
|
+
const { projectRoot, governanceDir, dbPath } = await createGovernanceWorkspace();
|
|
370
|
+
await replaceRoadmapsInStore(dbPath, [
|
|
371
|
+
{ id: 'ROADMAP-0001', title: 'Roadmap', status: 'active', updatedAt: '2026-03-14T00:00:00.000Z' },
|
|
372
|
+
]);
|
|
373
|
+
await saveTasks(dbPath, [
|
|
374
|
+
normalizeTask({ id: 'TASK-0001', title: 'core docs ready', status: 'TODO', roadmapRefs: ['ROADMAP-0001'] }),
|
|
375
|
+
]);
|
|
376
|
+
const coreDir = path.join(governanceDir, 'designs', 'core');
|
|
377
|
+
await fs.mkdir(coreDir, { recursive: true });
|
|
378
|
+
await fs.writeFile(path.join(coreDir, 'architecture.md'), '# Architecture\n', 'utf-8');
|
|
379
|
+
await fs.writeFile(path.join(coreDir, 'style-guide.md'), '# Style\n', 'utf-8');
|
|
380
|
+
const mockServer = { registerTool: vi.fn() };
|
|
381
|
+
registerTaskTools(mockServer);
|
|
382
|
+
const taskContext = getToolHandler(mockServer, 'taskContext');
|
|
383
|
+
const contextResult = await taskContext({ projectPath: projectRoot, taskId: 'TASK-0001' });
|
|
384
|
+
expect(contextResult.content[0].text).toContain('architectureDocsStatus: READY');
|
|
385
|
+
expect(contextResult.content[0].text).toContain('styleDocsStatus: READY');
|
|
386
|
+
expect(contextResult.content[0].text).toContain('Project context docs gate satisfied');
|
|
387
|
+
await fs.rm(projectRoot, { recursive: true, force: true });
|
|
388
|
+
});
|
|
389
|
+
it('taskContext requires fixed core doc filenames even under designs/core', async () => {
|
|
390
|
+
const { projectRoot, governanceDir, dbPath } = await createGovernanceWorkspace();
|
|
391
|
+
await replaceRoadmapsInStore(dbPath, [
|
|
392
|
+
{ id: 'ROADMAP-0001', title: 'Roadmap', status: 'active', updatedAt: '2026-03-14T00:00:00.000Z' },
|
|
393
|
+
]);
|
|
394
|
+
await saveTasks(dbPath, [
|
|
395
|
+
normalizeTask({ id: 'TASK-0001', title: 'fixed filename rule', status: 'TODO', roadmapRefs: ['ROADMAP-0001'] }),
|
|
396
|
+
]);
|
|
397
|
+
const coreDir = path.join(governanceDir, 'designs', 'core');
|
|
398
|
+
await fs.mkdir(coreDir, { recursive: true });
|
|
399
|
+
await fs.writeFile(path.join(coreDir, 'system-architecture.md'), '# Architecture\n', 'utf-8');
|
|
400
|
+
await fs.writeFile(path.join(coreDir, 'visual-style.md'), '# Style\n', 'utf-8');
|
|
401
|
+
const mockServer = { registerTool: vi.fn() };
|
|
402
|
+
registerTaskTools(mockServer);
|
|
403
|
+
const taskContext = getToolHandler(mockServer, 'taskContext');
|
|
404
|
+
const contextResult = await taskContext({ projectPath: projectRoot, taskId: 'TASK-0001' });
|
|
405
|
+
expect(contextResult.content[0].text).toContain('architectureDocsStatus: MISSING');
|
|
406
|
+
expect(contextResult.content[0].text).toContain('styleDocsStatus: MISSING');
|
|
407
|
+
expect(contextResult.content[0].text).toContain('add required file designs/core/architecture.md');
|
|
408
|
+
expect(contextResult.content[0].text).toContain('add required file designs/core/style-guide.md');
|
|
409
|
+
await fs.rm(projectRoot, { recursive: true, force: true });
|
|
410
|
+
});
|
|
411
|
+
it('taskUpdate allows IN_PROGRESS -> DONE with lint guidance when conformance fails', async () => {
|
|
412
|
+
const { projectRoot, governanceDir, dbPath } = await createGovernanceWorkspace();
|
|
413
|
+
await replaceRoadmapsInStore(dbPath, [
|
|
414
|
+
{ id: 'ROADMAP-0001', title: 'Roadmap', status: 'active', updatedAt: '2026-03-14T00:00:00.000Z' },
|
|
415
|
+
]);
|
|
416
|
+
await saveTasks(dbPath, [
|
|
417
|
+
normalizeTask({
|
|
418
|
+
id: 'TASK-0001',
|
|
419
|
+
title: 'done gate fail',
|
|
420
|
+
status: 'IN_PROGRESS',
|
|
421
|
+
owner: 'ai-copilot',
|
|
422
|
+
roadmapRefs: ['ROADMAP-0001'],
|
|
423
|
+
links: [],
|
|
424
|
+
}),
|
|
425
|
+
]);
|
|
426
|
+
const researchDir = path.join(projectRoot, 'designs', 'research');
|
|
427
|
+
await fs.mkdir(researchDir, { recursive: true });
|
|
428
|
+
await fs.writeFile(path.join(researchDir, 'TASK-0001.implementation-research.md'), [
|
|
429
|
+
'# TASK-0001 Implementation Research Brief',
|
|
430
|
+
'',
|
|
431
|
+
'## Design Guidelines and Specs',
|
|
432
|
+
'- designs/core/architecture.md#L1',
|
|
433
|
+
'',
|
|
434
|
+
'## Code Architecture and Implementation Findings',
|
|
435
|
+
'- packages/mcp/source/tools/task.ts#L1',
|
|
436
|
+
].join('\n'), 'utf-8');
|
|
437
|
+
const coreDir = path.join(governanceDir, 'designs', 'core');
|
|
438
|
+
await fs.mkdir(coreDir, { recursive: true });
|
|
439
|
+
await fs.writeFile(path.join(coreDir, 'architecture.md'), '# Architecture\n', 'utf-8');
|
|
440
|
+
await fs.writeFile(path.join(coreDir, 'style-guide.md'), '# Style\n', 'utf-8');
|
|
441
|
+
const mockServer = { registerTool: vi.fn() };
|
|
442
|
+
registerTaskTools(mockServer);
|
|
443
|
+
const taskUpdate = getToolHandler(mockServer, 'taskUpdate');
|
|
444
|
+
const result = await taskUpdate({
|
|
445
|
+
projectPath: projectRoot,
|
|
446
|
+
taskId: 'TASK-0001',
|
|
447
|
+
updates: { status: 'DONE' },
|
|
448
|
+
});
|
|
449
|
+
expect(result.isError).toBeUndefined();
|
|
450
|
+
expect(result.content[0].text).toContain('newStatus: DONE');
|
|
451
|
+
expect(result.content[0].text).toContain('TASK_DONE_LINKS_MISSING');
|
|
452
|
+
await fs.rm(projectRoot, { recursive: true, force: true });
|
|
453
|
+
});
|
|
454
|
+
it('taskUpdate allows IN_PROGRESS -> DONE when conformance re-check passes', async () => {
|
|
455
|
+
const { projectRoot, governanceDir, dbPath } = await createGovernanceWorkspace();
|
|
456
|
+
await replaceRoadmapsInStore(dbPath, [
|
|
457
|
+
{ id: 'ROADMAP-0001', title: 'Roadmap', status: 'active', updatedAt: '2026-03-14T00:00:00.000Z' },
|
|
458
|
+
]);
|
|
459
|
+
await fs.writeFile(path.join(projectRoot, 'README.md'), '# Evidence\n', 'utf-8');
|
|
460
|
+
await saveTasks(dbPath, [
|
|
461
|
+
normalizeTask({
|
|
462
|
+
id: 'TASK-0001',
|
|
463
|
+
title: 'done gate pass',
|
|
464
|
+
status: 'IN_PROGRESS',
|
|
465
|
+
owner: 'ai-copilot',
|
|
466
|
+
roadmapRefs: ['ROADMAP-0001'],
|
|
467
|
+
links: ['README.md'],
|
|
468
|
+
}),
|
|
469
|
+
]);
|
|
470
|
+
const researchDir = path.join(projectRoot, 'designs', 'research');
|
|
471
|
+
await fs.mkdir(researchDir, { recursive: true });
|
|
472
|
+
await fs.writeFile(path.join(researchDir, 'TASK-0001.implementation-research.md'), [
|
|
473
|
+
'# TASK-0001 Implementation Research Brief',
|
|
474
|
+
'',
|
|
475
|
+
'## Design Guidelines and Specs',
|
|
476
|
+
'- designs/core/architecture.md#L1',
|
|
477
|
+
'',
|
|
478
|
+
'## Code Architecture and Implementation Findings',
|
|
479
|
+
'- packages/mcp/source/tools/task.ts#L1',
|
|
480
|
+
].join('\n'), 'utf-8');
|
|
481
|
+
const coreDir = path.join(governanceDir, 'designs', 'core');
|
|
482
|
+
await fs.mkdir(coreDir, { recursive: true });
|
|
483
|
+
await fs.writeFile(path.join(coreDir, 'architecture.md'), '# Architecture\n', 'utf-8');
|
|
484
|
+
await fs.writeFile(path.join(coreDir, 'style-guide.md'), '# Style\n', 'utf-8');
|
|
485
|
+
const mockServer = { registerTool: vi.fn() };
|
|
486
|
+
registerTaskTools(mockServer);
|
|
487
|
+
const taskUpdate = getToolHandler(mockServer, 'taskUpdate');
|
|
488
|
+
const result = await taskUpdate({
|
|
489
|
+
projectPath: projectRoot,
|
|
490
|
+
taskId: 'TASK-0001',
|
|
491
|
+
updates: { status: 'DONE' },
|
|
492
|
+
});
|
|
493
|
+
expect(result.isError).toBeUndefined();
|
|
494
|
+
expect(result.content[0].text).toContain('newStatus: DONE');
|
|
495
|
+
await fs.rm(projectRoot, { recursive: true, force: true });
|
|
496
|
+
});
|
|
169
497
|
});
|