@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.
Files changed (52) hide show
  1. package/output/package.json +8 -2
  2. package/output/source/common/artifacts.js +1 -1
  3. package/output/source/common/artifacts.test.js +11 -11
  4. package/output/source/common/errors.js +19 -19
  5. package/output/source/common/errors.test.js +59 -0
  6. package/output/source/common/files.js +30 -19
  7. package/output/source/common/files.test.js +14 -14
  8. package/output/source/common/index.js +11 -10
  9. package/output/source/common/linter.js +29 -27
  10. package/output/source/common/linter.test.js +9 -9
  11. package/output/source/common/markdown.js +3 -3
  12. package/output/source/common/markdown.test.js +15 -15
  13. package/output/source/common/response.js +91 -107
  14. package/output/source/common/response.test.js +30 -30
  15. package/output/source/common/store.js +40 -40
  16. package/output/source/common/store.test.js +72 -72
  17. package/output/source/common/tool.js +43 -0
  18. package/output/source/common/types.js +3 -3
  19. package/output/source/common/utils.js +8 -8
  20. package/output/source/common/utils.test.js +48 -0
  21. package/output/source/index.js +16 -16
  22. package/output/source/index.runtime.test.js +57 -0
  23. package/output/source/index.test.js +64 -64
  24. package/output/source/prompts/index.js +3 -3
  25. package/output/source/prompts/index.test.js +23 -0
  26. package/output/source/prompts/quickStart.js +96 -96
  27. package/output/source/prompts/quickStart.test.js +24 -0
  28. package/output/source/prompts/taskDiscovery.js +184 -184
  29. package/output/source/prompts/taskDiscovery.test.js +24 -0
  30. package/output/source/prompts/taskExecution.js +164 -148
  31. package/output/source/prompts/taskExecution.test.js +27 -0
  32. package/output/source/resources/designs.js +26 -26
  33. package/output/source/resources/designs.resources.test.js +52 -0
  34. package/output/source/resources/designs.test.js +88 -88
  35. package/output/source/resources/governance.js +19 -19
  36. package/output/source/resources/governance.test.js +35 -0
  37. package/output/source/resources/index.js +2 -2
  38. package/output/source/resources/index.test.js +18 -0
  39. package/output/source/resources/readme.js +7 -7
  40. package/output/source/resources/readme.test.js +113 -113
  41. package/output/source/resources/reports.js +10 -10
  42. package/output/source/resources/reports.test.js +83 -83
  43. package/output/source/tools/index.js +3 -3
  44. package/output/source/tools/index.test.js +23 -0
  45. package/output/source/tools/project.js +330 -377
  46. package/output/source/tools/project.test.js +308 -175
  47. package/output/source/tools/roadmap.js +236 -255
  48. package/output/source/tools/roadmap.test.js +241 -46
  49. package/output/source/tools/task.js +770 -652
  50. package/output/source/tools/task.test.js +433 -105
  51. package/output/source/types.js +28 -22
  52. package/package.json +8 -2
@@ -1,169 +1,497 @@
1
- import { describe, expect, it } from "vitest";
2
- import { collectTaskLintSuggestions, isValidTaskId, normalizeTask, rankActionableTaskCandidates, resolveNoTaskDiscoveryGuidance, renderTaskSeedTemplate, renderTasksMarkdown, loadTasksDocument, 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";
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 ?? "2026-01-01T00:00:00.000Z",
25
+ updatedAt: partial.task?.updatedAt ?? '2026-01-01T00:00:00.000Z',
12
26
  });
13
27
  return {
14
- governanceDir: partial.governanceDir ?? "/workspace/a",
28
+ governanceDir: partial.governanceDir ?? '/workspace/a',
15
29
  task,
16
30
  projectScore: partial.projectScore ?? 1,
17
- projectLatestUpdatedAt: partial.projectLatestUpdatedAt ?? "2026-01-01T00:00:00.000Z",
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("tasks module", () => {
23
- it("validates task IDs", () => {
24
- expect(isValidTaskId("TASK-0001")).toBe(true);
25
- expect(isValidTaskId("TASK-001")).toBe(false);
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("allows and rejects expected transitions", () => {
28
- expect(validateTransition("TODO", "IN_PROGRESS")).toBe(true);
29
- expect(validateTransition("IN_PROGRESS", "DONE")).toBe(true);
30
- expect(validateTransition("DONE", "IN_PROGRESS")).toBe(false);
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("assigns priority for actionable statuses", () => {
33
- expect(taskPriority("IN_PROGRESS")).toBe(2);
34
- expect(taskPriority("TODO")).toBe(1);
35
- expect(taskPriority("BLOCKED")).toBe(0);
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("returns zero timestamp for invalid date", () => {
38
- expect(toTaskUpdatedAtMs("invalid")).toBe(0);
53
+ it('returns zero timestamp for invalid date', () => {
54
+ expect(toTaskUpdatedAtMs('invalid')).toBe(0);
39
55
  });
40
- it("ranks by project score, then task priority, then recency", () => {
56
+ it('ranks by project score, then task priority, then recency', () => {
41
57
  const candidates = [
42
- buildCandidate({ id: "TASK-0001", title: "A", status: "TODO", projectScore: 2 }),
43
- buildCandidate({ id: "TASK-0002", title: "B", status: "IN_PROGRESS", projectScore: 2 }),
44
- buildCandidate({ id: "TASK-0003", title: "C", status: "IN_PROGRESS", projectScore: 3 }),
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("TASK-0003");
48
- expect(ranked[1].task.id).toBe("TASK-0002");
49
- expect(ranked[2].task.id).toBe("TASK-0001");
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("renders task markdown without legacy markers", () => {
52
- const task = normalizeTask({ id: "TASK-0002", title: "render", status: "IN_PROGRESS" });
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("PROJITIVE:TASKS:START")).toBe(false);
55
- expect(markdown.includes("PROJITIVE:TASKS:END")).toBe(false);
56
- expect(markdown.includes("## TASK-0002 | IN_PROGRESS | render")).toBe(true);
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("renders tasks with subState and blocker metadata", () => {
77
+ it('renders tasks with subState and blocker metadata', () => {
59
78
  const task1 = normalizeTask({
60
- id: "TASK-0001",
61
- title: "In Progress Task",
62
- status: "IN_PROGRESS",
79
+ id: 'TASK-0001',
80
+ title: 'In Progress Task',
81
+ status: 'IN_PROGRESS',
63
82
  subState: {
64
- phase: "implementation",
83
+ phase: 'implementation',
65
84
  confidence: 0.85,
66
85
  },
67
86
  });
68
87
  const task2 = normalizeTask({
69
- id: "TASK-0002",
70
- title: "Blocked Task",
71
- status: "BLOCKED",
88
+ id: 'TASK-0002',
89
+ title: 'Blocked Task',
90
+ status: 'BLOCKED',
72
91
  blocker: {
73
- type: "external_dependency",
74
- description: "Waiting for API",
92
+ type: 'external_dependency',
93
+ description: 'Waiting for API',
75
94
  },
76
95
  });
77
96
  const markdown = renderTasksMarkdown([task1, task2]);
78
- expect(markdown).toContain("phase: implementation");
79
- expect(markdown).toContain("confidence: 0.85");
80
- expect(markdown).toContain("type: external_dependency");
81
- expect(markdown).toContain("description: Waiting for API");
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("collects lint lines with stable code prefix", () => {
102
+ it('collects lint lines with stable code prefix', () => {
84
103
  const task = normalizeTask({
85
- id: "TASK-0001",
86
- title: "lint",
87
- status: "IN_PROGRESS",
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("- [TASK_IN_PROGRESS_OWNER_EMPTY]"))).toBe(true);
93
- expect(lint.some((line) => line.startsWith("- [TASK_ROADMAP_REFS_EMPTY]"))).toBe(true);
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("collects blocker/substate lint rules", () => {
114
+ it('collects blocker/substate lint rules', () => {
96
115
  const blocked = normalizeTask({
97
- id: "TASK-0001",
98
- title: "Blocked",
99
- status: "BLOCKED",
100
- summary: "blocked reason",
101
- roadmapRefs: ["ROADMAP-0001"],
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: "TASK-0002",
105
- title: "In Progress",
106
- status: "IN_PROGRESS",
107
- owner: "ai-copilot",
108
- roadmapRefs: ["ROADMAP-0001"],
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("BLOCKED_WITHOUT_BLOCKER"))).toBe(true);
112
- expect(lint.some((line) => line.includes("IN_PROGRESS_WITHOUT_SUBSTATE"))).toBe(true);
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("normalizes links to project-root-relative format without leading slash", () => {
133
+ it('normalizes links to project-root-relative format without leading slash', () => {
115
134
  const task = normalizeTask({
116
- id: "TASK-0003",
117
- title: "link normalize",
118
- status: "TODO",
119
- links: ["/reports/a.md", "./designs/b.md", "reports/c.md", "https://example.com/evidence"],
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("reports/a.md");
122
- expect(task.links).toContain("designs/b.md");
123
- expect(task.links).toContain("reports/c.md");
124
- expect(task.links).toContain("https://example.com/evidence");
125
- expect(task.links.some((item) => item.startsWith("/"))).toBe(false);
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("lints invalid links path format", () => {
146
+ it('lints invalid links path format', () => {
128
147
  const task = normalizeTask({
129
- id: "TASK-0004",
130
- title: "invalid link",
131
- status: "TODO",
132
- links: ["../outside.md"],
133
- roadmapRefs: ["ROADMAP-0001"],
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("TASK_LINK_PATH_FORMAT_INVALID"))).toBe(true);
155
+ expect(lint.some((line) => line.includes('TASK_LINK_PATH_FORMAT_INVALID'))).toBe(true);
137
156
  });
138
- it("renders seed task template with provided roadmap ref", () => {
139
- const lines = renderTaskSeedTemplate("ROADMAP-0099");
140
- const markdown = lines.join("\n");
141
- expect(markdown).toContain("- roadmapRefs: ROADMAP-0099");
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("uses default no-task guidance when hook file is absent", async () => {
144
- const guidance = await resolveNoTaskDiscoveryGuidance("/path/that/does/not/exist");
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("returns same default no-task guidance regardless of path", async () => {
148
- const guidanceA = await resolveNoTaskDiscoveryGuidance("/path/that/does/not/exist");
149
- const guidanceB = await resolveNoTaskDiscoveryGuidance("/another/path");
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("loads and saves tasks from governance store and keeps newest-first order", async () => {
153
- const root = await fs.mkdtemp(path.join(os.tmpdir(), "projitive-mcp-task-"));
154
- const governanceDir = path.join(root, ".projitive");
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, ".projitive"), "", "utf-8");
157
- const tasksPath = path.join(governanceDir, ".projitive");
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: "TASK-0001", title: "older", status: "TODO", updatedAt: "2026-01-01T00:00:00.000Z" }),
160
- normalizeTask({ id: "TASK-0002", title: "newer", status: "TODO", updatedAt: "2026-02-01T00:00:00.000Z" }),
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("TASK-0002");
164
- expect(loaded.tasks[1].id).toBe("TASK-0001");
165
- const markdown = await fs.readFile(path.join(governanceDir, "tasks.md"), "utf-8");
166
- expect(markdown).toContain("generated from .projitive governance store");
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
  });