@projitive/mcp 2.0.3 → 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/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 -184
- package/output/source/prompts/taskExecution.js +148 -148
- 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 +191 -191
- package/output/source/tools/project.test.js +174 -173
- package/output/source/tools/roadmap.js +110 -95
- package/output/source/tools/roadmap.test.js +54 -46
- package/output/source/tools/task.js +305 -277
- package/output/source/tools/task.test.js +117 -110
- package/output/source/types.js +22 -22
- package/package.json +8 -2
|
@@ -1,32 +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
13
|
function nextRoadmapId(milestones) {
|
|
14
14
|
const maxSuffix = milestones
|
|
15
15
|
.map((item) => toRoadmapIdNumericSuffix(item.id))
|
|
16
|
-
.filter((value) =>
|
|
16
|
+
.filter((value) => value > 0)
|
|
17
17
|
.reduce((max, value) => Math.max(max, value), 0);
|
|
18
18
|
const next = maxSuffix + 1;
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
}
|
|
22
|
-
return `ROADMAP-${String(next).padStart(4, "0")}`;
|
|
19
|
+
const minWidth = Math.max(4, String(next).length);
|
|
20
|
+
return `ROADMAP-${String(next).padStart(minWidth, '0')}`;
|
|
23
21
|
}
|
|
24
22
|
function toRoadmapIdNumericSuffix(roadmapId) {
|
|
25
|
-
const match = roadmapId.match(
|
|
23
|
+
const match = roadmapId.match(ROADMAP_ID_REGEX);
|
|
26
24
|
if (!match) {
|
|
27
25
|
return -1;
|
|
28
26
|
}
|
|
29
|
-
|
|
27
|
+
const suffix = Number.parseInt(match[1], 10);
|
|
28
|
+
return Number.isFinite(suffix) ? suffix : -1;
|
|
30
29
|
}
|
|
31
30
|
function sortMilestonesNewestFirst(milestones) {
|
|
32
31
|
return [...milestones].sort((a, b) => {
|
|
@@ -45,9 +44,9 @@ function normalizeMilestone(raw) {
|
|
|
45
44
|
return {
|
|
46
45
|
id: String(raw.id),
|
|
47
46
|
title: String(raw.title),
|
|
48
|
-
status: raw.status ===
|
|
49
|
-
time: typeof raw.time ===
|
|
50
|
-
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(),
|
|
51
50
|
};
|
|
52
51
|
}
|
|
53
52
|
function normalizeAndSortMilestones(milestones) {
|
|
@@ -57,29 +56,33 @@ function normalizeAndSortMilestones(milestones) {
|
|
|
57
56
|
}
|
|
58
57
|
export function renderRoadmapMarkdown(milestones) {
|
|
59
58
|
const lines = sortMilestonesNewestFirst(milestones).map((item) => {
|
|
60
|
-
const checkbox = item.status ===
|
|
61
|
-
const timeText = item.time ? ` (time: ${item.time})` :
|
|
59
|
+
const checkbox = item.status === 'done' ? 'x' : ' ';
|
|
60
|
+
const timeText = item.time ? ` (time: ${item.time})` : '';
|
|
62
61
|
return `- [${checkbox}] ${item.id}: ${item.title}${timeText}`;
|
|
63
62
|
});
|
|
64
63
|
return [
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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');
|
|
73
76
|
}
|
|
74
77
|
function resolveRoadmapArtifactPaths(governanceDir) {
|
|
75
78
|
return {
|
|
76
|
-
roadmapPath: path.join(governanceDir,
|
|
79
|
+
roadmapPath: path.join(governanceDir, '.projitive'),
|
|
77
80
|
markdownPath: path.join(governanceDir, ROADMAP_MARKDOWN_FILE),
|
|
78
81
|
};
|
|
79
82
|
}
|
|
80
83
|
async function syncRoadmapMarkdownView(roadmapPath, markdownPath, markdown, force = false) {
|
|
81
|
-
const sourceVersion = await getStoreVersion(roadmapPath,
|
|
82
|
-
const viewState = await getMarkdownViewState(roadmapPath,
|
|
84
|
+
const sourceVersion = await getStoreVersion(roadmapPath, 'roadmaps');
|
|
85
|
+
const viewState = await getMarkdownViewState(roadmapPath, 'roadmaps_markdown');
|
|
83
86
|
const markdownExists = await fs.access(markdownPath).then(() => true).catch(() => false);
|
|
84
87
|
const shouldWrite = force
|
|
85
88
|
|| !markdownExists
|
|
@@ -88,8 +91,8 @@ async function syncRoadmapMarkdownView(roadmapPath, markdownPath, markdown, forc
|
|
|
88
91
|
if (!shouldWrite) {
|
|
89
92
|
return;
|
|
90
93
|
}
|
|
91
|
-
await fs.writeFile(markdownPath, markdown,
|
|
92
|
-
await markMarkdownViewBuilt(roadmapPath,
|
|
94
|
+
await fs.writeFile(markdownPath, markdown, 'utf-8');
|
|
95
|
+
await markMarkdownViewBuilt(roadmapPath, 'roadmaps_markdown', sourceVersion);
|
|
93
96
|
}
|
|
94
97
|
export async function loadRoadmapDocument(inputPath) {
|
|
95
98
|
return loadRoadmapDocumentWithOptions(inputPath, false);
|
|
@@ -118,15 +121,15 @@ function collectRoadmapLintSuggestionItems(roadmapIds, tasks) {
|
|
|
118
121
|
if (roadmapIds.length === 0) {
|
|
119
122
|
suggestions.push({
|
|
120
123
|
code: ROADMAP_LINT_CODES.IDS_EMPTY,
|
|
121
|
-
message:
|
|
122
|
-
fixHint:
|
|
124
|
+
message: 'No roadmap IDs found in .projitive roadmap table.',
|
|
125
|
+
fixHint: 'Add at least one ROADMAP-xxxx milestone.',
|
|
123
126
|
});
|
|
124
127
|
}
|
|
125
128
|
if (tasks.length === 0) {
|
|
126
129
|
suggestions.push({
|
|
127
130
|
code: ROADMAP_LINT_CODES.TASKS_EMPTY,
|
|
128
|
-
message:
|
|
129
|
-
fixHint:
|
|
131
|
+
message: 'No tasks found in .projitive task table.',
|
|
132
|
+
fixHint: 'Add task cards and bind roadmapRefs for traceability.',
|
|
130
133
|
});
|
|
131
134
|
return suggestions;
|
|
132
135
|
}
|
|
@@ -136,15 +139,15 @@ function collectRoadmapLintSuggestionItems(roadmapIds, tasks) {
|
|
|
136
139
|
suggestions.push({
|
|
137
140
|
code: ROADMAP_LINT_CODES.TASK_REFS_EMPTY,
|
|
138
141
|
message: `${unboundTasks.length} task(s) have empty roadmapRefs.`,
|
|
139
|
-
fixHint:
|
|
142
|
+
fixHint: 'Bind ROADMAP-xxxx where applicable.',
|
|
140
143
|
});
|
|
141
144
|
}
|
|
142
145
|
const unknownRefs = Array.from(new Set(tasks.flatMap((task) => task.roadmapRefs).filter((id) => !roadmapSet.has(id))));
|
|
143
146
|
if (unknownRefs.length > 0) {
|
|
144
147
|
suggestions.push({
|
|
145
148
|
code: ROADMAP_LINT_CODES.UNKNOWN_REFS,
|
|
146
|
-
message: `Unknown roadmapRefs detected: ${unknownRefs.join(
|
|
147
|
-
fixHint:
|
|
149
|
+
message: `Unknown roadmapRefs detected: ${unknownRefs.join(', ')}.`,
|
|
150
|
+
fixHint: 'Add missing roadmap IDs or fix task references.',
|
|
148
151
|
});
|
|
149
152
|
}
|
|
150
153
|
const noLinkedRoadmaps = roadmapIds.filter((id) => !tasks.some((task) => task.roadmapRefs.includes(id)));
|
|
@@ -152,7 +155,7 @@ function collectRoadmapLintSuggestionItems(roadmapIds, tasks) {
|
|
|
152
155
|
suggestions.push({
|
|
153
156
|
code: ROADMAP_LINT_CODES.ZERO_LINKED_TASKS,
|
|
154
157
|
message: `${noLinkedRoadmaps.length} roadmap ID(s) have zero linked tasks.`,
|
|
155
|
-
fixHint: `Consider binding tasks to: ${noLinkedRoadmaps.slice(0, 3).join(
|
|
158
|
+
fixHint: `Consider binding tasks to: ${noLinkedRoadmaps.slice(0, 3).join(', ')}${noLinkedRoadmaps.length > 3 ? ', ...' : ''}.`,
|
|
156
159
|
});
|
|
157
160
|
}
|
|
158
161
|
return suggestions;
|
|
@@ -161,48 +164,51 @@ export function collectRoadmapLintSuggestions(roadmapIds, tasks) {
|
|
|
161
164
|
return renderLintSuggestions(collectRoadmapLintSuggestionItems(roadmapIds, tasks));
|
|
162
165
|
}
|
|
163
166
|
export function isValidRoadmapId(id) {
|
|
164
|
-
return
|
|
167
|
+
return toRoadmapIdNumericSuffix(id) > 0;
|
|
165
168
|
}
|
|
166
169
|
export function registerRoadmapTools(server) {
|
|
167
|
-
server.registerTool(
|
|
168
|
-
title:
|
|
169
|
-
description:
|
|
170
|
+
server.registerTool('roadmapList', {
|
|
171
|
+
title: 'Roadmap List',
|
|
172
|
+
description: 'List roadmap IDs and task linkage for planning or traceability',
|
|
170
173
|
inputSchema: {
|
|
171
174
|
projectPath: z.string(),
|
|
172
175
|
},
|
|
173
176
|
}, async ({ projectPath }) => {
|
|
174
177
|
const governanceDir = await resolveGovernanceDir(projectPath);
|
|
175
178
|
const normalizedProjectPath = toProjectPath(governanceDir);
|
|
176
|
-
const
|
|
177
|
-
const
|
|
179
|
+
const { milestones, markdownPath: roadmapViewPath } = await loadRoadmapDocument(governanceDir);
|
|
180
|
+
const roadmapIds = milestones.map((item) => item.id);
|
|
181
|
+
const { tasks, markdownPath: tasksViewPath } = await loadTasksDocument(governanceDir);
|
|
178
182
|
const lintSuggestions = collectRoadmapLintSuggestions(roadmapIds, tasks);
|
|
179
183
|
const markdown = renderToolResponseMarkdown({
|
|
180
|
-
toolName:
|
|
184
|
+
toolName: 'roadmapList',
|
|
181
185
|
sections: [
|
|
182
186
|
summarySection([
|
|
183
187
|
`- projectPath: ${normalizedProjectPath}`,
|
|
184
188
|
`- governanceDir: ${governanceDir}`,
|
|
189
|
+
`- tasksView: ${tasksViewPath}`,
|
|
190
|
+
`- roadmapView: ${roadmapViewPath}`,
|
|
185
191
|
`- roadmapCount: ${roadmapIds.length}`,
|
|
186
192
|
]),
|
|
187
193
|
evidenceSection([
|
|
188
|
-
|
|
194
|
+
'- roadmaps:',
|
|
189
195
|
...roadmapIds.map((id) => {
|
|
190
196
|
const linkedTasks = tasks.filter((task) => task.roadmapRefs.includes(id));
|
|
191
197
|
return `- ${id} | linkedTasks=${linkedTasks.length}`;
|
|
192
198
|
}),
|
|
193
199
|
]),
|
|
194
|
-
guidanceSection([
|
|
200
|
+
guidanceSection(['- Pick one roadmap ID and call `roadmapContext`.']),
|
|
195
201
|
lintSection(lintSuggestions),
|
|
196
202
|
nextCallSection(roadmapIds[0]
|
|
197
|
-
? `roadmapContext(projectPath
|
|
203
|
+
? `roadmapContext(projectPath="${toProjectPath(governanceDir)}", roadmapId="${roadmapIds[0]}")`
|
|
198
204
|
: undefined),
|
|
199
205
|
],
|
|
200
206
|
});
|
|
201
207
|
return asText(markdown);
|
|
202
208
|
});
|
|
203
|
-
server.registerTool(
|
|
204
|
-
title:
|
|
205
|
-
description:
|
|
209
|
+
server.registerTool('roadmapContext', {
|
|
210
|
+
title: 'Roadmap Context',
|
|
211
|
+
description: 'Inspect one roadmap with linked tasks and reference locations',
|
|
206
212
|
inputSchema: {
|
|
207
213
|
projectPath: z.string(),
|
|
208
214
|
roadmapId: z.string(),
|
|
@@ -210,16 +216,17 @@ export function registerRoadmapTools(server) {
|
|
|
210
216
|
}, async ({ projectPath, roadmapId }) => {
|
|
211
217
|
if (!isValidRoadmapId(roadmapId)) {
|
|
212
218
|
return {
|
|
213
|
-
...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")`)),
|
|
214
220
|
isError: true,
|
|
215
221
|
};
|
|
216
222
|
}
|
|
217
223
|
const governanceDir = await resolveGovernanceDir(projectPath);
|
|
218
224
|
const normalizedProjectPath = toProjectPath(governanceDir);
|
|
225
|
+
const { markdownPath: roadmapViewPath } = resolveRoadmapArtifactPaths(governanceDir);
|
|
219
226
|
const artifacts = await discoverGovernanceArtifacts(governanceDir);
|
|
220
227
|
const fileCandidates = candidateFilesFromArtifacts(artifacts);
|
|
221
228
|
const referenceLocations = (await Promise.all(fileCandidates.map((file) => findTextReferences(file, roadmapId)))).flat();
|
|
222
|
-
const { tasks } = await
|
|
229
|
+
const { tasks, markdownPath: tasksViewPath } = await loadTasksDocument(governanceDir);
|
|
223
230
|
const relatedTasks = tasks.filter((task) => task.roadmapRefs.includes(roadmapId));
|
|
224
231
|
const roadmapIds = await loadRoadmapIds(governanceDir);
|
|
225
232
|
const lintSuggestionItems = collectRoadmapLintSuggestionItems(roadmapIds, tasks);
|
|
@@ -227,70 +234,73 @@ export function registerRoadmapTools(server) {
|
|
|
227
234
|
lintSuggestionItems.push({
|
|
228
235
|
code: ROADMAP_LINT_CODES.CONTEXT_RELATED_TASKS_EMPTY,
|
|
229
236
|
message: `relatedTasks=0 for ${roadmapId}.`,
|
|
230
|
-
fixHint:
|
|
237
|
+
fixHint: 'Batch bind task roadmapRefs to improve execution traceability.',
|
|
231
238
|
});
|
|
232
239
|
}
|
|
233
240
|
const lintSuggestions = renderLintSuggestions(lintSuggestionItems);
|
|
234
241
|
const markdown = renderToolResponseMarkdown({
|
|
235
|
-
toolName:
|
|
242
|
+
toolName: 'roadmapContext',
|
|
236
243
|
sections: [
|
|
237
244
|
summarySection([
|
|
238
245
|
`- projectPath: ${normalizedProjectPath}`,
|
|
239
246
|
`- governanceDir: ${governanceDir}`,
|
|
247
|
+
`- tasksView: ${tasksViewPath}`,
|
|
248
|
+
`- roadmapView: ${roadmapViewPath}`,
|
|
240
249
|
`- roadmapId: ${roadmapId}`,
|
|
241
250
|
`- relatedTasks: ${relatedTasks.length}`,
|
|
242
251
|
`- references: ${referenceLocations.length}`,
|
|
243
252
|
]),
|
|
244
253
|
evidenceSection([
|
|
245
|
-
|
|
254
|
+
'### Related Tasks',
|
|
246
255
|
...relatedTasks.map((task) => `- ${task.id} | ${task.status} | ${task.title}`),
|
|
247
|
-
|
|
248
|
-
|
|
256
|
+
'',
|
|
257
|
+
'### Reference Locations',
|
|
249
258
|
...referenceLocations.map((item) => `- ${item.filePath}#L${item.line}: ${item.text}`),
|
|
250
259
|
]),
|
|
251
260
|
guidanceSection([
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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.',
|
|
255
264
|
]),
|
|
256
265
|
lintSection(lintSuggestions),
|
|
257
|
-
nextCallSection(`roadmapContext(projectPath
|
|
266
|
+
nextCallSection(`roadmapContext(projectPath="${toProjectPath(governanceDir)}", roadmapId="${roadmapId}")`),
|
|
258
267
|
],
|
|
259
268
|
});
|
|
260
269
|
return asText(markdown);
|
|
261
270
|
});
|
|
262
|
-
server.registerTool(
|
|
263
|
-
title:
|
|
264
|
-
description:
|
|
271
|
+
server.registerTool('roadmapCreate', {
|
|
272
|
+
title: 'Roadmap Create',
|
|
273
|
+
description: 'Create one roadmap milestone in governance store',
|
|
265
274
|
inputSchema: {
|
|
266
275
|
projectPath: z.string(),
|
|
267
276
|
roadmapId: z.string().optional(),
|
|
268
277
|
title: z.string(),
|
|
269
|
-
status: z.enum([
|
|
278
|
+
status: z.enum(['active', 'done']).optional(),
|
|
270
279
|
time: z.string().optional(),
|
|
271
280
|
},
|
|
272
281
|
}, async ({ projectPath, roadmapId, title, status, time }) => {
|
|
273
282
|
if (roadmapId && !isValidRoadmapId(roadmapId)) {
|
|
274
283
|
return {
|
|
275
|
-
...asText(renderErrorMarkdown(
|
|
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")`)),
|
|
276
285
|
isError: true,
|
|
277
286
|
};
|
|
278
287
|
}
|
|
279
288
|
const governanceDir = await resolveGovernanceDir(projectPath);
|
|
280
289
|
const normalizedProjectPath = toProjectPath(governanceDir);
|
|
281
290
|
const doc = await loadRoadmapDocument(governanceDir);
|
|
291
|
+
const { markdownPath: tasksViewPath } = await loadTasksDocument(governanceDir);
|
|
282
292
|
const finalRoadmapId = roadmapId ?? nextRoadmapId(doc.milestones);
|
|
283
293
|
const duplicated = doc.milestones.some((item) => item.id === finalRoadmapId);
|
|
284
294
|
if (duplicated) {
|
|
285
295
|
return {
|
|
286
|
-
...asText(renderErrorMarkdown(
|
|
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={...})`)),
|
|
287
297
|
isError: true,
|
|
288
298
|
};
|
|
289
299
|
}
|
|
290
300
|
const created = normalizeMilestone({
|
|
291
301
|
id: finalRoadmapId,
|
|
292
302
|
title,
|
|
293
|
-
status: status ??
|
|
303
|
+
status: status ?? 'active',
|
|
294
304
|
time,
|
|
295
305
|
updatedAt: nowIso(),
|
|
296
306
|
});
|
|
@@ -299,58 +309,61 @@ export function registerRoadmapTools(server) {
|
|
|
299
309
|
const { tasks } = await loadTasks(governanceDir);
|
|
300
310
|
const lintSuggestions = collectRoadmapLintSuggestions(refreshed.milestones.map((item) => item.id), tasks);
|
|
301
311
|
const markdown = renderToolResponseMarkdown({
|
|
302
|
-
toolName:
|
|
312
|
+
toolName: 'roadmapCreate',
|
|
303
313
|
sections: [
|
|
304
314
|
summarySection([
|
|
305
315
|
`- projectPath: ${normalizedProjectPath}`,
|
|
306
316
|
`- governanceDir: ${governanceDir}`,
|
|
317
|
+
`- tasksView: ${tasksViewPath}`,
|
|
318
|
+
`- roadmapView: ${refreshed.markdownPath}`,
|
|
307
319
|
`- roadmapId: ${created.id}`,
|
|
308
320
|
`- status: ${created.status}`,
|
|
309
321
|
`- updatedAt: ${created.updatedAt}`,
|
|
310
322
|
]),
|
|
311
323
|
evidenceSection([
|
|
312
|
-
|
|
313
|
-
`- ${created.id} | ${created.status} | ${created.title}${created.time ? ` | time=${created.time}` :
|
|
314
|
-
|
|
315
|
-
|
|
324
|
+
'### Created Milestone',
|
|
325
|
+
`- ${created.id} | ${created.status} | ${created.title}${created.time ? ` | time=${created.time}` : ''}`,
|
|
326
|
+
'',
|
|
327
|
+
'### Roadmap Count',
|
|
316
328
|
`- total: ${refreshed.milestones.length}`,
|
|
317
329
|
]),
|
|
318
330
|
guidanceSection([
|
|
319
|
-
|
|
320
|
-
|
|
331
|
+
'Milestone created successfully and roadmap.md has been synced.',
|
|
332
|
+
'Re-run roadmapContext to verify linked task traceability.',
|
|
321
333
|
]),
|
|
322
334
|
lintSection(lintSuggestions),
|
|
323
|
-
nextCallSection(`roadmapContext(projectPath
|
|
335
|
+
nextCallSection(`roadmapContext(projectPath="${normalizedProjectPath}", roadmapId="${created.id}")`),
|
|
324
336
|
],
|
|
325
337
|
});
|
|
326
338
|
return asText(markdown);
|
|
327
339
|
});
|
|
328
|
-
server.registerTool(
|
|
329
|
-
title:
|
|
330
|
-
description:
|
|
340
|
+
server.registerTool('roadmapUpdate', {
|
|
341
|
+
title: 'Roadmap Update',
|
|
342
|
+
description: 'Update one roadmap milestone fields incrementally in governance store',
|
|
331
343
|
inputSchema: {
|
|
332
344
|
projectPath: z.string(),
|
|
333
345
|
roadmapId: z.string(),
|
|
334
346
|
updates: z.object({
|
|
335
347
|
title: z.string().optional(),
|
|
336
|
-
status: z.enum([
|
|
348
|
+
status: z.enum(['active', 'done']).optional(),
|
|
337
349
|
time: z.string().optional(),
|
|
338
350
|
}),
|
|
339
351
|
},
|
|
340
352
|
}, async ({ projectPath, roadmapId, updates }) => {
|
|
341
353
|
if (!isValidRoadmapId(roadmapId)) {
|
|
342
354
|
return {
|
|
343
|
-
...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={...})`)),
|
|
344
356
|
isError: true,
|
|
345
357
|
};
|
|
346
358
|
}
|
|
347
359
|
const governanceDir = await resolveGovernanceDir(projectPath);
|
|
348
360
|
const normalizedProjectPath = toProjectPath(governanceDir);
|
|
349
361
|
const doc = await loadRoadmapDocument(governanceDir);
|
|
362
|
+
const { markdownPath: tasksViewPath } = await loadTasksDocument(governanceDir);
|
|
350
363
|
const existing = doc.milestones.find((item) => item.id === roadmapId);
|
|
351
364
|
if (!existing) {
|
|
352
365
|
return {
|
|
353
|
-
...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)}")`)),
|
|
354
367
|
isError: true,
|
|
355
368
|
};
|
|
356
369
|
}
|
|
@@ -364,26 +377,28 @@ export function registerRoadmapTools(server) {
|
|
|
364
377
|
await upsertRoadmapInStore(doc.roadmapPath, updated);
|
|
365
378
|
const refreshed = await loadRoadmapDocumentWithOptions(governanceDir, true);
|
|
366
379
|
const markdown = renderToolResponseMarkdown({
|
|
367
|
-
toolName:
|
|
380
|
+
toolName: 'roadmapUpdate',
|
|
368
381
|
sections: [
|
|
369
382
|
summarySection([
|
|
370
383
|
`- projectPath: ${normalizedProjectPath}`,
|
|
371
384
|
`- governanceDir: ${governanceDir}`,
|
|
385
|
+
`- tasksView: ${tasksViewPath}`,
|
|
386
|
+
`- roadmapView: ${refreshed.markdownPath}`,
|
|
372
387
|
`- roadmapId: ${roadmapId}`,
|
|
373
388
|
`- newStatus: ${updated.status}`,
|
|
374
389
|
`- updatedAt: ${updated.updatedAt}`,
|
|
375
390
|
]),
|
|
376
391
|
evidenceSection([
|
|
377
|
-
|
|
378
|
-
`- ${updated.id} | ${updated.status} | ${updated.title}${updated.time ? ` | time=${updated.time}` :
|
|
379
|
-
|
|
380
|
-
|
|
392
|
+
'### Updated Milestone',
|
|
393
|
+
`- ${updated.id} | ${updated.status} | ${updated.title}${updated.time ? ` | time=${updated.time}` : ''}`,
|
|
394
|
+
'',
|
|
395
|
+
'### Roadmap Count',
|
|
381
396
|
`- total: ${refreshed.milestones.length}`,
|
|
382
397
|
]),
|
|
383
398
|
guidanceSection([
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
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.',
|
|
387
402
|
]),
|
|
388
403
|
lintSection([]),
|
|
389
404
|
nextCallSection(`roadmapContext(projectPath="${toProjectPath(governanceDir)}", roadmapId="${roadmapId}")`),
|
|
@@ -1,75 +1,83 @@
|
|
|
1
|
-
import { describe, it, expect, beforeAll, afterAll, vi } from
|
|
2
|
-
import fs from
|
|
3
|
-
import path from
|
|
4
|
-
import os from
|
|
5
|
-
import { isValidRoadmapId, collectRoadmapLintSuggestions, loadRoadmapDocument, renderRoadmapMarkdown, registerRoadmapTools } 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');
|
|
65
70
|
});
|
|
66
|
-
it(
|
|
71
|
+
it('registers roadmapCreate tool', () => {
|
|
67
72
|
const mockServer = {
|
|
68
|
-
registerTool: (...
|
|
73
|
+
registerTool: (...args) => {
|
|
74
|
+
void args;
|
|
75
|
+
return undefined;
|
|
76
|
+
},
|
|
69
77
|
};
|
|
70
|
-
const spy = vi.spyOn(mockServer,
|
|
78
|
+
const spy = vi.spyOn(mockServer, 'registerTool');
|
|
71
79
|
registerRoadmapTools(mockServer);
|
|
72
|
-
expect(spy.mock.calls.some((call) => call[0] ===
|
|
80
|
+
expect(spy.mock.calls.some((call) => call[0] === 'roadmapCreate')).toBe(true);
|
|
73
81
|
});
|
|
74
82
|
});
|
|
75
83
|
});
|