@projitive/mcp 2.0.2 → 2.0.4
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/README.md +14 -1
- 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/files.js +11 -11
- package/output/source/common/files.test.js +14 -14
- package/output/source/common/index.js +10 -10
- package/output/source/common/linter.js +27 -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 +74 -74
- 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/types.js +3 -3
- package/output/source/common/utils.js +8 -8
- package/output/source/index.js +16 -16
- package/output/source/index.test.js +64 -64
- package/output/source/prompts/index.js +3 -3
- package/output/source/prompts/quickStart.js +96 -96
- package/output/source/prompts/taskDiscovery.js +184 -180
- package/output/source/prompts/taskExecution.js +148 -147
- package/output/source/resources/designs.js +26 -26
- package/output/source/resources/designs.test.js +88 -88
- package/output/source/resources/governance.js +19 -19
- package/output/source/resources/index.js +2 -2
- 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/project.js +196 -191
- package/output/source/tools/project.test.js +187 -164
- package/output/source/tools/roadmap.js +173 -76
- package/output/source/tools/roadmap.test.js +58 -42
- package/output/source/tools/task.js +380 -255
- package/output/source/tools/task.test.js +117 -110
- package/output/source/types.js +22 -22
- package/package.json +8 -2
|
@@ -1,21 +1,31 @@
|
|
|
1
|
-
import fs from
|
|
2
|
-
import path from
|
|
3
|
-
import { z } from
|
|
4
|
-
import { candidateFilesFromArtifacts, discoverGovernanceArtifacts, ROADMAP_LINT_CODES, renderLintSuggestions, findTextReferences, ensureStore, loadRoadmapsFromStore, upsertRoadmapInStore, getStoreVersion, getMarkdownViewState, markMarkdownViewBuilt, } from
|
|
5
|
-
import { asText, evidenceSection, guidanceSection, lintSection, nextCallSection, renderErrorMarkdown, renderToolResponseMarkdown, summarySection, } from
|
|
6
|
-
import { resolveGovernanceDir, toProjectPath } from
|
|
7
|
-
import { loadTasks } from
|
|
8
|
-
export const ROADMAP_ID_REGEX = /^ROADMAP
|
|
9
|
-
export const ROADMAP_MARKDOWN_FILE =
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import { candidateFilesFromArtifacts, discoverGovernanceArtifacts, ROADMAP_LINT_CODES, renderLintSuggestions, findTextReferences, ensureStore, loadRoadmapsFromStore, upsertRoadmapInStore, getStoreVersion, getMarkdownViewState, markMarkdownViewBuilt, } from '../common/index.js';
|
|
5
|
+
import { asText, evidenceSection, guidanceSection, lintSection, nextCallSection, renderErrorMarkdown, renderToolResponseMarkdown, summarySection, } from '../common/index.js';
|
|
6
|
+
import { resolveGovernanceDir, toProjectPath } from './project.js';
|
|
7
|
+
import { loadTasks, loadTasksDocument } from './task.js';
|
|
8
|
+
export const ROADMAP_ID_REGEX = /^ROADMAP-(\d+)$/;
|
|
9
|
+
export const ROADMAP_MARKDOWN_FILE = 'roadmap.md';
|
|
10
10
|
function nowIso() {
|
|
11
11
|
return new Date().toISOString();
|
|
12
12
|
}
|
|
13
|
+
function nextRoadmapId(milestones) {
|
|
14
|
+
const maxSuffix = milestones
|
|
15
|
+
.map((item) => toRoadmapIdNumericSuffix(item.id))
|
|
16
|
+
.filter((value) => value > 0)
|
|
17
|
+
.reduce((max, value) => Math.max(max, value), 0);
|
|
18
|
+
const next = maxSuffix + 1;
|
|
19
|
+
const minWidth = Math.max(4, String(next).length);
|
|
20
|
+
return `ROADMAP-${String(next).padStart(minWidth, '0')}`;
|
|
21
|
+
}
|
|
13
22
|
function toRoadmapIdNumericSuffix(roadmapId) {
|
|
14
|
-
const match = roadmapId.match(
|
|
23
|
+
const match = roadmapId.match(ROADMAP_ID_REGEX);
|
|
15
24
|
if (!match) {
|
|
16
25
|
return -1;
|
|
17
26
|
}
|
|
18
|
-
|
|
27
|
+
const suffix = Number.parseInt(match[1], 10);
|
|
28
|
+
return Number.isFinite(suffix) ? suffix : -1;
|
|
19
29
|
}
|
|
20
30
|
function sortMilestonesNewestFirst(milestones) {
|
|
21
31
|
return [...milestones].sort((a, b) => {
|
|
@@ -34,9 +44,9 @@ function normalizeMilestone(raw) {
|
|
|
34
44
|
return {
|
|
35
45
|
id: String(raw.id),
|
|
36
46
|
title: String(raw.title),
|
|
37
|
-
status: raw.status ===
|
|
38
|
-
time: typeof raw.time ===
|
|
39
|
-
updatedAt: typeof raw.updatedAt ===
|
|
47
|
+
status: raw.status === 'done' ? 'done' : 'active',
|
|
48
|
+
time: typeof raw.time === 'string' && raw.time.trim().length > 0 ? raw.time.trim() : undefined,
|
|
49
|
+
updatedAt: typeof raw.updatedAt === 'string' && Number.isFinite(new Date(raw.updatedAt).getTime()) ? raw.updatedAt : nowIso(),
|
|
40
50
|
};
|
|
41
51
|
}
|
|
42
52
|
function normalizeAndSortMilestones(milestones) {
|
|
@@ -46,29 +56,33 @@ function normalizeAndSortMilestones(milestones) {
|
|
|
46
56
|
}
|
|
47
57
|
export function renderRoadmapMarkdown(milestones) {
|
|
48
58
|
const lines = sortMilestonesNewestFirst(milestones).map((item) => {
|
|
49
|
-
const checkbox = item.status ===
|
|
50
|
-
const timeText = item.time ? ` (time: ${item.time})` :
|
|
59
|
+
const checkbox = item.status === 'done' ? 'x' : ' ';
|
|
60
|
+
const timeText = item.time ? ` (time: ${item.time})` : '';
|
|
51
61
|
return `- [${checkbox}] ${item.id}: ${item.title}${timeText}`;
|
|
52
62
|
});
|
|
53
63
|
return [
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
64
|
+
'# Roadmap',
|
|
65
|
+
'',
|
|
66
|
+
'Projitive is an AI-first project governance framework for tasks, roadmaps, reports, and designs.',
|
|
67
|
+
'Author: yinxulai',
|
|
68
|
+
'Repository: https://github.com/yinxulai/projitive',
|
|
69
|
+
'Do not edit this file manually. This file is automatically generated by Projitive.',
|
|
70
|
+
'This file is generated from .projitive governance store by Projitive MCP. Manual edits will be overwritten.',
|
|
71
|
+
'',
|
|
72
|
+
'## Active Milestones',
|
|
73
|
+
...(lines.length > 0 ? lines : ['- (no milestones)']),
|
|
74
|
+
'',
|
|
75
|
+
].join('\n');
|
|
62
76
|
}
|
|
63
77
|
function resolveRoadmapArtifactPaths(governanceDir) {
|
|
64
78
|
return {
|
|
65
|
-
roadmapPath: path.join(governanceDir,
|
|
79
|
+
roadmapPath: path.join(governanceDir, '.projitive'),
|
|
66
80
|
markdownPath: path.join(governanceDir, ROADMAP_MARKDOWN_FILE),
|
|
67
81
|
};
|
|
68
82
|
}
|
|
69
83
|
async function syncRoadmapMarkdownView(roadmapPath, markdownPath, markdown, force = false) {
|
|
70
|
-
const sourceVersion = await getStoreVersion(roadmapPath,
|
|
71
|
-
const viewState = await getMarkdownViewState(roadmapPath,
|
|
84
|
+
const sourceVersion = await getStoreVersion(roadmapPath, 'roadmaps');
|
|
85
|
+
const viewState = await getMarkdownViewState(roadmapPath, 'roadmaps_markdown');
|
|
72
86
|
const markdownExists = await fs.access(markdownPath).then(() => true).catch(() => false);
|
|
73
87
|
const shouldWrite = force
|
|
74
88
|
|| !markdownExists
|
|
@@ -77,8 +91,8 @@ async function syncRoadmapMarkdownView(roadmapPath, markdownPath, markdown, forc
|
|
|
77
91
|
if (!shouldWrite) {
|
|
78
92
|
return;
|
|
79
93
|
}
|
|
80
|
-
await fs.writeFile(markdownPath, markdown,
|
|
81
|
-
await markMarkdownViewBuilt(roadmapPath,
|
|
94
|
+
await fs.writeFile(markdownPath, markdown, 'utf-8');
|
|
95
|
+
await markMarkdownViewBuilt(roadmapPath, 'roadmaps_markdown', sourceVersion);
|
|
82
96
|
}
|
|
83
97
|
export async function loadRoadmapDocument(inputPath) {
|
|
84
98
|
return loadRoadmapDocumentWithOptions(inputPath, false);
|
|
@@ -107,15 +121,15 @@ function collectRoadmapLintSuggestionItems(roadmapIds, tasks) {
|
|
|
107
121
|
if (roadmapIds.length === 0) {
|
|
108
122
|
suggestions.push({
|
|
109
123
|
code: ROADMAP_LINT_CODES.IDS_EMPTY,
|
|
110
|
-
message:
|
|
111
|
-
fixHint:
|
|
124
|
+
message: 'No roadmap IDs found in .projitive roadmap table.',
|
|
125
|
+
fixHint: 'Add at least one ROADMAP-xxxx milestone.',
|
|
112
126
|
});
|
|
113
127
|
}
|
|
114
128
|
if (tasks.length === 0) {
|
|
115
129
|
suggestions.push({
|
|
116
130
|
code: ROADMAP_LINT_CODES.TASKS_EMPTY,
|
|
117
|
-
message:
|
|
118
|
-
fixHint:
|
|
131
|
+
message: 'No tasks found in .projitive task table.',
|
|
132
|
+
fixHint: 'Add task cards and bind roadmapRefs for traceability.',
|
|
119
133
|
});
|
|
120
134
|
return suggestions;
|
|
121
135
|
}
|
|
@@ -125,15 +139,15 @@ function collectRoadmapLintSuggestionItems(roadmapIds, tasks) {
|
|
|
125
139
|
suggestions.push({
|
|
126
140
|
code: ROADMAP_LINT_CODES.TASK_REFS_EMPTY,
|
|
127
141
|
message: `${unboundTasks.length} task(s) have empty roadmapRefs.`,
|
|
128
|
-
fixHint:
|
|
142
|
+
fixHint: 'Bind ROADMAP-xxxx where applicable.',
|
|
129
143
|
});
|
|
130
144
|
}
|
|
131
145
|
const unknownRefs = Array.from(new Set(tasks.flatMap((task) => task.roadmapRefs).filter((id) => !roadmapSet.has(id))));
|
|
132
146
|
if (unknownRefs.length > 0) {
|
|
133
147
|
suggestions.push({
|
|
134
148
|
code: ROADMAP_LINT_CODES.UNKNOWN_REFS,
|
|
135
|
-
message: `Unknown roadmapRefs detected: ${unknownRefs.join(
|
|
136
|
-
fixHint:
|
|
149
|
+
message: `Unknown roadmapRefs detected: ${unknownRefs.join(', ')}.`,
|
|
150
|
+
fixHint: 'Add missing roadmap IDs or fix task references.',
|
|
137
151
|
});
|
|
138
152
|
}
|
|
139
153
|
const noLinkedRoadmaps = roadmapIds.filter((id) => !tasks.some((task) => task.roadmapRefs.includes(id)));
|
|
@@ -141,7 +155,7 @@ function collectRoadmapLintSuggestionItems(roadmapIds, tasks) {
|
|
|
141
155
|
suggestions.push({
|
|
142
156
|
code: ROADMAP_LINT_CODES.ZERO_LINKED_TASKS,
|
|
143
157
|
message: `${noLinkedRoadmaps.length} roadmap ID(s) have zero linked tasks.`,
|
|
144
|
-
fixHint: `Consider binding tasks to: ${noLinkedRoadmaps.slice(0, 3).join(
|
|
158
|
+
fixHint: `Consider binding tasks to: ${noLinkedRoadmaps.slice(0, 3).join(', ')}${noLinkedRoadmaps.length > 3 ? ', ...' : ''}.`,
|
|
145
159
|
});
|
|
146
160
|
}
|
|
147
161
|
return suggestions;
|
|
@@ -150,46 +164,51 @@ export function collectRoadmapLintSuggestions(roadmapIds, tasks) {
|
|
|
150
164
|
return renderLintSuggestions(collectRoadmapLintSuggestionItems(roadmapIds, tasks));
|
|
151
165
|
}
|
|
152
166
|
export function isValidRoadmapId(id) {
|
|
153
|
-
return
|
|
167
|
+
return toRoadmapIdNumericSuffix(id) > 0;
|
|
154
168
|
}
|
|
155
169
|
export function registerRoadmapTools(server) {
|
|
156
|
-
server.registerTool(
|
|
157
|
-
title:
|
|
158
|
-
description:
|
|
170
|
+
server.registerTool('roadmapList', {
|
|
171
|
+
title: 'Roadmap List',
|
|
172
|
+
description: 'List roadmap IDs and task linkage for planning or traceability',
|
|
159
173
|
inputSchema: {
|
|
160
174
|
projectPath: z.string(),
|
|
161
175
|
},
|
|
162
176
|
}, async ({ projectPath }) => {
|
|
163
177
|
const governanceDir = await resolveGovernanceDir(projectPath);
|
|
164
|
-
const
|
|
165
|
-
const {
|
|
178
|
+
const normalizedProjectPath = toProjectPath(governanceDir);
|
|
179
|
+
const { milestones, markdownPath: roadmapViewPath } = await loadRoadmapDocument(governanceDir);
|
|
180
|
+
const roadmapIds = milestones.map((item) => item.id);
|
|
181
|
+
const { tasks, markdownPath: tasksViewPath } = await loadTasksDocument(governanceDir);
|
|
166
182
|
const lintSuggestions = collectRoadmapLintSuggestions(roadmapIds, tasks);
|
|
167
183
|
const markdown = renderToolResponseMarkdown({
|
|
168
|
-
toolName:
|
|
184
|
+
toolName: 'roadmapList',
|
|
169
185
|
sections: [
|
|
170
186
|
summarySection([
|
|
187
|
+
`- projectPath: ${normalizedProjectPath}`,
|
|
171
188
|
`- governanceDir: ${governanceDir}`,
|
|
189
|
+
`- tasksView: ${tasksViewPath}`,
|
|
190
|
+
`- roadmapView: ${roadmapViewPath}`,
|
|
172
191
|
`- roadmapCount: ${roadmapIds.length}`,
|
|
173
192
|
]),
|
|
174
193
|
evidenceSection([
|
|
175
|
-
|
|
194
|
+
'- roadmaps:',
|
|
176
195
|
...roadmapIds.map((id) => {
|
|
177
196
|
const linkedTasks = tasks.filter((task) => task.roadmapRefs.includes(id));
|
|
178
197
|
return `- ${id} | linkedTasks=${linkedTasks.length}`;
|
|
179
198
|
}),
|
|
180
199
|
]),
|
|
181
|
-
guidanceSection([
|
|
200
|
+
guidanceSection(['- Pick one roadmap ID and call `roadmapContext`.']),
|
|
182
201
|
lintSection(lintSuggestions),
|
|
183
202
|
nextCallSection(roadmapIds[0]
|
|
184
|
-
? `roadmapContext(projectPath
|
|
203
|
+
? `roadmapContext(projectPath="${toProjectPath(governanceDir)}", roadmapId="${roadmapIds[0]}")`
|
|
185
204
|
: undefined),
|
|
186
205
|
],
|
|
187
206
|
});
|
|
188
207
|
return asText(markdown);
|
|
189
208
|
});
|
|
190
|
-
server.registerTool(
|
|
191
|
-
title:
|
|
192
|
-
description:
|
|
209
|
+
server.registerTool('roadmapContext', {
|
|
210
|
+
title: 'Roadmap Context',
|
|
211
|
+
description: 'Inspect one roadmap with linked tasks and reference locations',
|
|
193
212
|
inputSchema: {
|
|
194
213
|
projectPath: z.string(),
|
|
195
214
|
roadmapId: z.string(),
|
|
@@ -197,15 +216,17 @@ export function registerRoadmapTools(server) {
|
|
|
197
216
|
}, async ({ projectPath, roadmapId }) => {
|
|
198
217
|
if (!isValidRoadmapId(roadmapId)) {
|
|
199
218
|
return {
|
|
200
|
-
...asText(renderErrorMarkdown(
|
|
219
|
+
...asText(renderErrorMarkdown('roadmapContext', `Invalid roadmap ID format: ${roadmapId}`, ['expected format: ROADMAP-1 or ROADMAP-0001', 'retry with a valid roadmap ID'], `roadmapContext(projectPath="${projectPath}", roadmapId="ROADMAP-0001")`)),
|
|
201
220
|
isError: true,
|
|
202
221
|
};
|
|
203
222
|
}
|
|
204
223
|
const governanceDir = await resolveGovernanceDir(projectPath);
|
|
224
|
+
const normalizedProjectPath = toProjectPath(governanceDir);
|
|
225
|
+
const { markdownPath: roadmapViewPath } = resolveRoadmapArtifactPaths(governanceDir);
|
|
205
226
|
const artifacts = await discoverGovernanceArtifacts(governanceDir);
|
|
206
227
|
const fileCandidates = candidateFilesFromArtifacts(artifacts);
|
|
207
228
|
const referenceLocations = (await Promise.all(fileCandidates.map((file) => findTextReferences(file, roadmapId)))).flat();
|
|
208
|
-
const { tasks } = await
|
|
229
|
+
const { tasks, markdownPath: tasksViewPath } = await loadTasksDocument(governanceDir);
|
|
209
230
|
const relatedTasks = tasks.filter((task) => task.roadmapRefs.includes(roadmapId));
|
|
210
231
|
const roadmapIds = await loadRoadmapIds(governanceDir);
|
|
211
232
|
const lintSuggestionItems = collectRoadmapLintSuggestionItems(roadmapIds, tasks);
|
|
@@ -213,62 +234,136 @@ export function registerRoadmapTools(server) {
|
|
|
213
234
|
lintSuggestionItems.push({
|
|
214
235
|
code: ROADMAP_LINT_CODES.CONTEXT_RELATED_TASKS_EMPTY,
|
|
215
236
|
message: `relatedTasks=0 for ${roadmapId}.`,
|
|
216
|
-
fixHint:
|
|
237
|
+
fixHint: 'Batch bind task roadmapRefs to improve execution traceability.',
|
|
217
238
|
});
|
|
218
239
|
}
|
|
219
240
|
const lintSuggestions = renderLintSuggestions(lintSuggestionItems);
|
|
220
241
|
const markdown = renderToolResponseMarkdown({
|
|
221
|
-
toolName:
|
|
242
|
+
toolName: 'roadmapContext',
|
|
222
243
|
sections: [
|
|
223
244
|
summarySection([
|
|
245
|
+
`- projectPath: ${normalizedProjectPath}`,
|
|
224
246
|
`- governanceDir: ${governanceDir}`,
|
|
247
|
+
`- tasksView: ${tasksViewPath}`,
|
|
248
|
+
`- roadmapView: ${roadmapViewPath}`,
|
|
225
249
|
`- roadmapId: ${roadmapId}`,
|
|
226
250
|
`- relatedTasks: ${relatedTasks.length}`,
|
|
227
251
|
`- references: ${referenceLocations.length}`,
|
|
228
252
|
]),
|
|
229
253
|
evidenceSection([
|
|
230
|
-
|
|
254
|
+
'### Related Tasks',
|
|
231
255
|
...relatedTasks.map((task) => `- ${task.id} | ${task.status} | ${task.title}`),
|
|
232
|
-
|
|
233
|
-
|
|
256
|
+
'',
|
|
257
|
+
'### Reference Locations',
|
|
234
258
|
...referenceLocations.map((item) => `- ${item.filePath}#L${item.line}: ${item.text}`),
|
|
235
259
|
]),
|
|
236
260
|
guidanceSection([
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
261
|
+
'- Read roadmap references first, then related tasks.',
|
|
262
|
+
'- Keep ROADMAP/TASK IDs unchanged while updating markdown files.',
|
|
263
|
+
'- Re-run `roadmapContext` after edits to confirm references remain consistent.',
|
|
264
|
+
]),
|
|
265
|
+
lintSection(lintSuggestions),
|
|
266
|
+
nextCallSection(`roadmapContext(projectPath="${toProjectPath(governanceDir)}", roadmapId="${roadmapId}")`),
|
|
267
|
+
],
|
|
268
|
+
});
|
|
269
|
+
return asText(markdown);
|
|
270
|
+
});
|
|
271
|
+
server.registerTool('roadmapCreate', {
|
|
272
|
+
title: 'Roadmap Create',
|
|
273
|
+
description: 'Create one roadmap milestone in governance store',
|
|
274
|
+
inputSchema: {
|
|
275
|
+
projectPath: z.string(),
|
|
276
|
+
roadmapId: z.string().optional(),
|
|
277
|
+
title: z.string(),
|
|
278
|
+
status: z.enum(['active', 'done']).optional(),
|
|
279
|
+
time: z.string().optional(),
|
|
280
|
+
},
|
|
281
|
+
}, async ({ projectPath, roadmapId, title, status, time }) => {
|
|
282
|
+
if (roadmapId && !isValidRoadmapId(roadmapId)) {
|
|
283
|
+
return {
|
|
284
|
+
...asText(renderErrorMarkdown('roadmapCreate', `Invalid roadmap ID format: ${roadmapId}`, ['expected format: ROADMAP-1 or ROADMAP-0001', 'omit roadmapId to auto-generate next ID'], `roadmapCreate(projectPath="${projectPath}", title="Define milestone", time="2026-Q2")`)),
|
|
285
|
+
isError: true,
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
const governanceDir = await resolveGovernanceDir(projectPath);
|
|
289
|
+
const normalizedProjectPath = toProjectPath(governanceDir);
|
|
290
|
+
const doc = await loadRoadmapDocument(governanceDir);
|
|
291
|
+
const { markdownPath: tasksViewPath } = await loadTasksDocument(governanceDir);
|
|
292
|
+
const finalRoadmapId = roadmapId ?? nextRoadmapId(doc.milestones);
|
|
293
|
+
const duplicated = doc.milestones.some((item) => item.id === finalRoadmapId);
|
|
294
|
+
if (duplicated) {
|
|
295
|
+
return {
|
|
296
|
+
...asText(renderErrorMarkdown('roadmapCreate', `Roadmap milestone already exists: ${finalRoadmapId}`, ['roadmap IDs must be unique', 'use roadmapUpdate for existing milestone'], `roadmapUpdate(projectPath="${normalizedProjectPath}", roadmapId="${finalRoadmapId}", updates={...})`)),
|
|
297
|
+
isError: true,
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
const created = normalizeMilestone({
|
|
301
|
+
id: finalRoadmapId,
|
|
302
|
+
title,
|
|
303
|
+
status: status ?? 'active',
|
|
304
|
+
time,
|
|
305
|
+
updatedAt: nowIso(),
|
|
306
|
+
});
|
|
307
|
+
await upsertRoadmapInStore(doc.roadmapPath, created);
|
|
308
|
+
const refreshed = await loadRoadmapDocumentWithOptions(governanceDir, true);
|
|
309
|
+
const { tasks } = await loadTasks(governanceDir);
|
|
310
|
+
const lintSuggestions = collectRoadmapLintSuggestions(refreshed.milestones.map((item) => item.id), tasks);
|
|
311
|
+
const markdown = renderToolResponseMarkdown({
|
|
312
|
+
toolName: 'roadmapCreate',
|
|
313
|
+
sections: [
|
|
314
|
+
summarySection([
|
|
315
|
+
`- projectPath: ${normalizedProjectPath}`,
|
|
316
|
+
`- governanceDir: ${governanceDir}`,
|
|
317
|
+
`- tasksView: ${tasksViewPath}`,
|
|
318
|
+
`- roadmapView: ${refreshed.markdownPath}`,
|
|
319
|
+
`- roadmapId: ${created.id}`,
|
|
320
|
+
`- status: ${created.status}`,
|
|
321
|
+
`- updatedAt: ${created.updatedAt}`,
|
|
322
|
+
]),
|
|
323
|
+
evidenceSection([
|
|
324
|
+
'### Created Milestone',
|
|
325
|
+
`- ${created.id} | ${created.status} | ${created.title}${created.time ? ` | time=${created.time}` : ''}`,
|
|
326
|
+
'',
|
|
327
|
+
'### Roadmap Count',
|
|
328
|
+
`- total: ${refreshed.milestones.length}`,
|
|
329
|
+
]),
|
|
330
|
+
guidanceSection([
|
|
331
|
+
'Milestone created successfully and roadmap.md has been synced.',
|
|
332
|
+
'Re-run roadmapContext to verify linked task traceability.',
|
|
240
333
|
]),
|
|
241
334
|
lintSection(lintSuggestions),
|
|
242
|
-
nextCallSection(`roadmapContext(projectPath
|
|
335
|
+
nextCallSection(`roadmapContext(projectPath="${normalizedProjectPath}", roadmapId="${created.id}")`),
|
|
243
336
|
],
|
|
244
337
|
});
|
|
245
338
|
return asText(markdown);
|
|
246
339
|
});
|
|
247
|
-
server.registerTool(
|
|
248
|
-
title:
|
|
249
|
-
description:
|
|
340
|
+
server.registerTool('roadmapUpdate', {
|
|
341
|
+
title: 'Roadmap Update',
|
|
342
|
+
description: 'Update one roadmap milestone fields incrementally in governance store',
|
|
250
343
|
inputSchema: {
|
|
251
344
|
projectPath: z.string(),
|
|
252
345
|
roadmapId: z.string(),
|
|
253
346
|
updates: z.object({
|
|
254
347
|
title: z.string().optional(),
|
|
255
|
-
status: z.enum([
|
|
348
|
+
status: z.enum(['active', 'done']).optional(),
|
|
256
349
|
time: z.string().optional(),
|
|
257
350
|
}),
|
|
258
351
|
},
|
|
259
352
|
}, async ({ projectPath, roadmapId, updates }) => {
|
|
260
353
|
if (!isValidRoadmapId(roadmapId)) {
|
|
261
354
|
return {
|
|
262
|
-
...asText(renderErrorMarkdown(
|
|
355
|
+
...asText(renderErrorMarkdown('roadmapUpdate', `Invalid roadmap ID format: ${roadmapId}`, ['expected format: ROADMAP-1 or ROADMAP-0001', 'retry with a valid roadmap ID'], `roadmapUpdate(projectPath="${projectPath}", roadmapId="ROADMAP-0001", updates={...})`)),
|
|
263
356
|
isError: true,
|
|
264
357
|
};
|
|
265
358
|
}
|
|
266
359
|
const governanceDir = await resolveGovernanceDir(projectPath);
|
|
360
|
+
const normalizedProjectPath = toProjectPath(governanceDir);
|
|
267
361
|
const doc = await loadRoadmapDocument(governanceDir);
|
|
362
|
+
const { markdownPath: tasksViewPath } = await loadTasksDocument(governanceDir);
|
|
268
363
|
const existing = doc.milestones.find((item) => item.id === roadmapId);
|
|
269
364
|
if (!existing) {
|
|
270
365
|
return {
|
|
271
|
-
...asText(renderErrorMarkdown(
|
|
366
|
+
...asText(renderErrorMarkdown('roadmapUpdate', `Roadmap milestone not found: ${roadmapId}`, ['run roadmapList to discover existing roadmap IDs', 'retry with an existing roadmap ID'], `roadmapList(projectPath="${toProjectPath(governanceDir)}")`)),
|
|
272
367
|
isError: true,
|
|
273
368
|
};
|
|
274
369
|
}
|
|
@@ -282,26 +377,28 @@ export function registerRoadmapTools(server) {
|
|
|
282
377
|
await upsertRoadmapInStore(doc.roadmapPath, updated);
|
|
283
378
|
const refreshed = await loadRoadmapDocumentWithOptions(governanceDir, true);
|
|
284
379
|
const markdown = renderToolResponseMarkdown({
|
|
285
|
-
toolName:
|
|
380
|
+
toolName: 'roadmapUpdate',
|
|
286
381
|
sections: [
|
|
287
382
|
summarySection([
|
|
383
|
+
`- projectPath: ${normalizedProjectPath}`,
|
|
288
384
|
`- governanceDir: ${governanceDir}`,
|
|
385
|
+
`- tasksView: ${tasksViewPath}`,
|
|
386
|
+
`- roadmapView: ${refreshed.markdownPath}`,
|
|
289
387
|
`- roadmapId: ${roadmapId}`,
|
|
290
388
|
`- newStatus: ${updated.status}`,
|
|
291
389
|
`- updatedAt: ${updated.updatedAt}`,
|
|
292
390
|
]),
|
|
293
391
|
evidenceSection([
|
|
294
|
-
|
|
295
|
-
`- ${updated.id} | ${updated.status} | ${updated.title}${updated.time ? ` | time=${updated.time}` :
|
|
296
|
-
|
|
297
|
-
|
|
392
|
+
'### Updated Milestone',
|
|
393
|
+
`- ${updated.id} | ${updated.status} | ${updated.title}${updated.time ? ` | time=${updated.time}` : ''}`,
|
|
394
|
+
'',
|
|
395
|
+
'### Roadmap Count',
|
|
298
396
|
`- total: ${refreshed.milestones.length}`,
|
|
299
397
|
]),
|
|
300
398
|
guidanceSection([
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
"Call `syncViews(projectPath=..., views=[\"roadmap\"], force=true)` when immediate markdown materialization is required.",
|
|
399
|
+
'Milestone updated successfully and roadmap.md has been synced.',
|
|
400
|
+
'Re-run roadmapContext to verify linked task traceability.',
|
|
401
|
+
'.projitive governance store is source of truth; roadmap.md is a generated view and may be overwritten.',
|
|
305
402
|
]),
|
|
306
403
|
lintSection([]),
|
|
307
404
|
nextCallSection(`roadmapContext(projectPath="${toProjectPath(governanceDir)}", roadmapId="${roadmapId}")`),
|
|
@@ -1,67 +1,83 @@
|
|
|
1
|
-
import { describe, it, expect, beforeAll, afterAll } from
|
|
2
|
-
import fs from
|
|
3
|
-
import path from
|
|
4
|
-
import os from
|
|
5
|
-
import { isValidRoadmapId, collectRoadmapLintSuggestions, loadRoadmapDocument, renderRoadmapMarkdown } from
|
|
6
|
-
describe(
|
|
1
|
+
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import { isValidRoadmapId, collectRoadmapLintSuggestions, loadRoadmapDocument, renderRoadmapMarkdown, registerRoadmapTools } from './roadmap.js';
|
|
6
|
+
describe('roadmap module', () => {
|
|
7
7
|
let tempDir;
|
|
8
8
|
beforeAll(async () => {
|
|
9
|
-
tempDir = await fs.mkdtemp(path.join(os.tmpdir(),
|
|
9
|
+
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'projitive-roadmap-test-'));
|
|
10
10
|
});
|
|
11
11
|
afterAll(async () => {
|
|
12
12
|
await fs.rm(tempDir, { recursive: true, force: true });
|
|
13
13
|
});
|
|
14
|
-
describe(
|
|
15
|
-
it(
|
|
16
|
-
expect(isValidRoadmapId(
|
|
17
|
-
expect(isValidRoadmapId(
|
|
14
|
+
describe('isValidRoadmapId', () => {
|
|
15
|
+
it('should validate correct roadmap IDs', () => {
|
|
16
|
+
expect(isValidRoadmapId('ROADMAP-0001')).toBe(true);
|
|
17
|
+
expect(isValidRoadmapId('ROADMAP-1')).toBe(true);
|
|
18
|
+
expect(isValidRoadmapId('ROADMAP-1234')).toBe(true);
|
|
19
|
+
expect(isValidRoadmapId('ROADMAP-12345')).toBe(true);
|
|
18
20
|
});
|
|
19
|
-
it(
|
|
20
|
-
expect(isValidRoadmapId(
|
|
21
|
-
expect(isValidRoadmapId(
|
|
22
|
-
expect(isValidRoadmapId(
|
|
21
|
+
it('should reject invalid roadmap IDs', () => {
|
|
22
|
+
expect(isValidRoadmapId('roadmap-0001')).toBe(false);
|
|
23
|
+
expect(isValidRoadmapId('TASK-0001')).toBe(false);
|
|
24
|
+
expect(isValidRoadmapId('invalid')).toBe(false);
|
|
23
25
|
});
|
|
24
26
|
});
|
|
25
|
-
describe(
|
|
26
|
-
it(
|
|
27
|
+
describe('collectRoadmapLintSuggestions', () => {
|
|
28
|
+
it('should return lint suggestion for empty roadmap IDs', () => {
|
|
27
29
|
const suggestions = collectRoadmapLintSuggestions([], []);
|
|
28
|
-
expect(suggestions.some(s => s.includes(
|
|
30
|
+
expect(suggestions.some(s => s.includes('IDS_EMPTY'))).toBe(true);
|
|
29
31
|
});
|
|
30
|
-
it(
|
|
31
|
-
const suggestions = collectRoadmapLintSuggestions([
|
|
32
|
-
expect(suggestions.some(s => s.includes(
|
|
32
|
+
it('should return lint suggestion for empty tasks', () => {
|
|
33
|
+
const suggestions = collectRoadmapLintSuggestions(['ROADMAP-0001'], []);
|
|
34
|
+
expect(suggestions.some(s => s.includes('TASKS_EMPTY'))).toBe(true);
|
|
33
35
|
});
|
|
34
|
-
it(
|
|
36
|
+
it('should return lint suggestion for tasks without roadmap refs', () => {
|
|
35
37
|
const tasks = [{
|
|
36
|
-
id:
|
|
37
|
-
title:
|
|
38
|
-
status:
|
|
39
|
-
owner:
|
|
40
|
-
summary:
|
|
41
|
-
updatedAt:
|
|
38
|
+
id: 'TASK-0001',
|
|
39
|
+
title: 'Test Task',
|
|
40
|
+
status: 'TODO',
|
|
41
|
+
owner: 'ai-copilot',
|
|
42
|
+
summary: 'Test',
|
|
43
|
+
updatedAt: '2026-01-01T00:00:00.000Z',
|
|
42
44
|
links: [],
|
|
43
45
|
roadmapRefs: [],
|
|
44
46
|
}];
|
|
45
|
-
const suggestions = collectRoadmapLintSuggestions([
|
|
46
|
-
expect(suggestions.some(s => s.includes(
|
|
47
|
+
const suggestions = collectRoadmapLintSuggestions(['ROADMAP-0001'], tasks);
|
|
48
|
+
expect(suggestions.some(s => s.includes('TASK_REFS_EMPTY'))).toBe(true);
|
|
47
49
|
});
|
|
48
|
-
it(
|
|
49
|
-
const governanceDir = path.join(tempDir,
|
|
50
|
+
it('loads from governance store and rewrites roadmap markdown view', async () => {
|
|
51
|
+
const governanceDir = path.join(tempDir, '.projitive-db');
|
|
50
52
|
await fs.mkdir(governanceDir, { recursive: true });
|
|
51
|
-
await fs.writeFile(path.join(governanceDir,
|
|
53
|
+
await fs.writeFile(path.join(governanceDir, '.projitive'), '', 'utf-8');
|
|
52
54
|
const doc = await loadRoadmapDocument(governanceDir);
|
|
53
|
-
expect(doc.roadmapPath.endsWith(
|
|
54
|
-
expect(doc.markdownPath.endsWith(
|
|
55
|
-
const markdown = await fs.readFile(path.join(governanceDir,
|
|
56
|
-
expect(markdown).toContain(
|
|
55
|
+
expect(doc.roadmapPath.endsWith('.projitive')).toBe(true);
|
|
56
|
+
expect(doc.markdownPath.endsWith('roadmap.md')).toBe(true);
|
|
57
|
+
const markdown = await fs.readFile(path.join(governanceDir, 'roadmap.md'), 'utf-8');
|
|
58
|
+
expect(markdown).toContain('generated from .projitive governance store');
|
|
59
|
+
expect(markdown).toContain('Author: yinxulai');
|
|
60
|
+
expect(markdown).toContain('Repository: https://github.com/yinxulai/projitive');
|
|
61
|
+
expect(markdown).toContain('Do not edit this file manually.');
|
|
57
62
|
});
|
|
58
|
-
it(
|
|
63
|
+
it('renders milestones in newest-first order', () => {
|
|
59
64
|
const markdown = renderRoadmapMarkdown([
|
|
60
|
-
{ id:
|
|
61
|
-
{ id:
|
|
65
|
+
{ id: 'ROADMAP-0001', title: 'Older', status: 'active', updatedAt: '2026-01-01T00:00:00.000Z' },
|
|
66
|
+
{ id: 'ROADMAP-0002', title: 'Newer', status: 'done', updatedAt: '2026-02-01T00:00:00.000Z' },
|
|
62
67
|
]);
|
|
63
|
-
expect(markdown.indexOf(
|
|
64
|
-
expect(markdown).toContain(
|
|
68
|
+
expect(markdown.indexOf('ROADMAP-0002')).toBeLessThan(markdown.indexOf('ROADMAP-0001'));
|
|
69
|
+
expect(markdown).toContain('[x] ROADMAP-0002');
|
|
70
|
+
});
|
|
71
|
+
it('registers roadmapCreate tool', () => {
|
|
72
|
+
const mockServer = {
|
|
73
|
+
registerTool: (...args) => {
|
|
74
|
+
void args;
|
|
75
|
+
return undefined;
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
const spy = vi.spyOn(mockServer, 'registerTool');
|
|
79
|
+
registerRoadmapTools(mockServer);
|
|
80
|
+
expect(spy.mock.calls.some((call) => call[0] === 'roadmapCreate')).toBe(true);
|
|
65
81
|
});
|
|
66
82
|
});
|
|
67
83
|
});
|