@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.
Files changed (41) hide show
  1. package/README.md +14 -1
  2. package/output/package.json +8 -2
  3. package/output/source/common/artifacts.js +1 -1
  4. package/output/source/common/artifacts.test.js +11 -11
  5. package/output/source/common/errors.js +19 -19
  6. package/output/source/common/files.js +11 -11
  7. package/output/source/common/files.test.js +14 -14
  8. package/output/source/common/index.js +10 -10
  9. package/output/source/common/linter.js +27 -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 +74 -74
  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/types.js +3 -3
  18. package/output/source/common/utils.js +8 -8
  19. package/output/source/index.js +16 -16
  20. package/output/source/index.test.js +64 -64
  21. package/output/source/prompts/index.js +3 -3
  22. package/output/source/prompts/quickStart.js +96 -96
  23. package/output/source/prompts/taskDiscovery.js +184 -180
  24. package/output/source/prompts/taskExecution.js +148 -147
  25. package/output/source/resources/designs.js +26 -26
  26. package/output/source/resources/designs.test.js +88 -88
  27. package/output/source/resources/governance.js +19 -19
  28. package/output/source/resources/index.js +2 -2
  29. package/output/source/resources/readme.js +7 -7
  30. package/output/source/resources/readme.test.js +113 -113
  31. package/output/source/resources/reports.js +10 -10
  32. package/output/source/resources/reports.test.js +83 -83
  33. package/output/source/tools/index.js +3 -3
  34. package/output/source/tools/project.js +196 -191
  35. package/output/source/tools/project.test.js +187 -164
  36. package/output/source/tools/roadmap.js +173 -76
  37. package/output/source/tools/roadmap.test.js +58 -42
  38. package/output/source/tools/task.js +380 -255
  39. package/output/source/tools/task.test.js +117 -110
  40. package/output/source/types.js +22 -22
  41. package/package.json +8 -2
@@ -1,21 +1,31 @@
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 } from "./task.js";
8
- export const ROADMAP_ID_REGEX = /^ROADMAP-\d{4}$/;
9
- export const ROADMAP_MARKDOWN_FILE = "roadmap.md";
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(/^(?:ROADMAP-)(\d{4})$/);
23
+ const match = roadmapId.match(ROADMAP_ID_REGEX);
15
24
  if (!match) {
16
25
  return -1;
17
26
  }
18
- return Number.parseInt(match[1], 10);
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 === "done" ? "done" : "active",
38
- time: typeof raw.time === "string" && raw.time.trim().length > 0 ? raw.time.trim() : undefined,
39
- updatedAt: typeof raw.updatedAt === "string" && Number.isFinite(new Date(raw.updatedAt).getTime()) ? raw.updatedAt : nowIso(),
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 === "done" ? "x" : " ";
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
- "# Roadmap",
55
- "",
56
- "This file is generated from .projitive sqlite tables by Projitive MCP. Manual edits will be overwritten.",
57
- "",
58
- "## Active Milestones",
59
- ...(lines.length > 0 ? lines : ["- (no milestones)"]),
60
- "",
61
- ].join("\n");
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, ".projitive"),
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, "roadmaps");
71
- const viewState = await getMarkdownViewState(roadmapPath, "roadmaps_markdown");
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, "utf-8");
81
- await markMarkdownViewBuilt(roadmapPath, "roadmaps_markdown", sourceVersion);
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: "No roadmap IDs found in .projitive roadmap table.",
111
- fixHint: "Add at least one ROADMAP-xxxx milestone.",
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: "No tasks found in .projitive task table.",
118
- fixHint: "Add task cards and bind roadmapRefs for traceability.",
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: "Bind ROADMAP-xxxx where applicable.",
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: "Add missing roadmap IDs or fix task references.",
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(", ")}${noLinkedRoadmaps.length > 3 ? ", ..." : ""}.`,
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 ROADMAP_ID_REGEX.test(id);
167
+ return toRoadmapIdNumericSuffix(id) > 0;
154
168
  }
155
169
  export function registerRoadmapTools(server) {
156
- server.registerTool("roadmapList", {
157
- title: "Roadmap List",
158
- description: "List roadmap IDs and task linkage for planning or traceability",
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 roadmapIds = await loadRoadmapIds(governanceDir);
165
- const { tasks } = await loadTasks(governanceDir);
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: "roadmapList",
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
- "- roadmaps:",
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(["- Pick one roadmap ID and call `roadmapContext`."]),
200
+ guidanceSection(['- Pick one roadmap ID and call `roadmapContext`.']),
182
201
  lintSection(lintSuggestions),
183
202
  nextCallSection(roadmapIds[0]
184
- ? `roadmapContext(projectPath=\"${toProjectPath(governanceDir)}\", roadmapId=\"${roadmapIds[0]}\")`
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("roadmapContext", {
191
- title: "Roadmap Context",
192
- description: "Inspect one roadmap with linked tasks and reference locations",
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("roadmapContext", `Invalid roadmap ID format: ${roadmapId}`, ["expected format: ROADMAP-0001", "retry with a valid roadmap ID"], `roadmapContext(projectPath=\"${projectPath}\", roadmapId=\"ROADMAP-0001\")`)),
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 loadTasks(governanceDir);
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: "Batch bind task roadmapRefs to improve execution traceability.",
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: "roadmapContext",
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
- "### Related Tasks",
254
+ '### Related Tasks',
231
255
  ...relatedTasks.map((task) => `- ${task.id} | ${task.status} | ${task.title}`),
232
- "",
233
- "### Reference Locations",
256
+ '',
257
+ '### Reference Locations',
234
258
  ...referenceLocations.map((item) => `- ${item.filePath}#L${item.line}: ${item.text}`),
235
259
  ]),
236
260
  guidanceSection([
237
- "- Read roadmap references first, then related tasks.",
238
- "- Keep ROADMAP/TASK IDs unchanged while updating markdown files.",
239
- "- Re-run `roadmapContext` after edits to confirm references remain consistent.",
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=\"${toProjectPath(governanceDir)}\", roadmapId=\"${roadmapId}\")`),
335
+ nextCallSection(`roadmapContext(projectPath="${normalizedProjectPath}", roadmapId="${created.id}")`),
243
336
  ],
244
337
  });
245
338
  return asText(markdown);
246
339
  });
247
- server.registerTool("roadmapUpdate", {
248
- title: "Roadmap Update",
249
- description: "Update one roadmap milestone fields incrementally in sqlite table",
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(["active", "done"]).optional(),
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("roadmapUpdate", `Invalid roadmap ID format: ${roadmapId}`, ["expected format: ROADMAP-0001", "retry with a valid roadmap ID"], `roadmapUpdate(projectPath="${projectPath}", roadmapId="ROADMAP-0001", updates={...})`)),
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("roadmapUpdate", `Roadmap milestone not found: ${roadmapId}`, ["run roadmapList to discover existing roadmap IDs", "retry with an existing roadmap ID"], `roadmapList(projectPath="${toProjectPath(governanceDir)}")`)),
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: "roadmapUpdate",
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
- "### Updated Milestone",
295
- `- ${updated.id} | ${updated.status} | ${updated.title}${updated.time ? ` | time=${updated.time}` : ""}`,
296
- "",
297
- `### Roadmap Count`,
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
- "Milestone updated successfully.",
302
- "Re-run roadmapContext to verify linked task traceability.",
303
- "SQLite is source of truth; roadmap.md is a generated view and may be overwritten.",
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 "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 } from "./roadmap.js";
6
- describe("roadmap module", () => {
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(), "projitive-roadmap-test-"));
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("isValidRoadmapId", () => {
15
- it("should validate correct roadmap IDs", () => {
16
- expect(isValidRoadmapId("ROADMAP-0001")).toBe(true);
17
- expect(isValidRoadmapId("ROADMAP-1234")).toBe(true);
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("should reject invalid roadmap IDs", () => {
20
- expect(isValidRoadmapId("roadmap-0001")).toBe(false);
21
- expect(isValidRoadmapId("TASK-0001")).toBe(false);
22
- expect(isValidRoadmapId("invalid")).toBe(false);
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("collectRoadmapLintSuggestions", () => {
26
- it("should return lint suggestion for empty roadmap IDs", () => {
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("IDS_EMPTY"))).toBe(true);
30
+ expect(suggestions.some(s => s.includes('IDS_EMPTY'))).toBe(true);
29
31
  });
30
- it("should return lint suggestion for empty tasks", () => {
31
- const suggestions = collectRoadmapLintSuggestions(["ROADMAP-0001"], []);
32
- expect(suggestions.some(s => s.includes("TASKS_EMPTY"))).toBe(true);
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("should return lint suggestion for tasks without roadmap refs", () => {
36
+ it('should return lint suggestion for tasks without roadmap refs', () => {
35
37
  const tasks = [{
36
- id: "TASK-0001",
37
- title: "Test Task",
38
- status: "TODO",
39
- owner: "ai-copilot",
40
- summary: "Test",
41
- updatedAt: "2026-01-01T00:00:00.000Z",
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(["ROADMAP-0001"], tasks);
46
- expect(suggestions.some(s => s.includes("TASK_REFS_EMPTY"))).toBe(true);
47
+ const suggestions = collectRoadmapLintSuggestions(['ROADMAP-0001'], tasks);
48
+ expect(suggestions.some(s => s.includes('TASK_REFS_EMPTY'))).toBe(true);
47
49
  });
48
- it("loads from sqlite and rewrites roadmap markdown view", async () => {
49
- const governanceDir = path.join(tempDir, ".projitive-db");
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, ".projitive"), "", "utf-8");
53
+ await fs.writeFile(path.join(governanceDir, '.projitive'), '', 'utf-8');
52
54
  const doc = await loadRoadmapDocument(governanceDir);
53
- expect(doc.roadmapPath.endsWith(".projitive")).toBe(true);
54
- expect(doc.markdownPath.endsWith("roadmap.md")).toBe(true);
55
- const markdown = await fs.readFile(path.join(governanceDir, "roadmap.md"), "utf-8");
56
- expect(markdown).toContain("generated from .projitive sqlite tables");
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("renders milestones in newest-first order", () => {
63
+ it('renders milestones in newest-first order', () => {
59
64
  const markdown = renderRoadmapMarkdown([
60
- { id: "ROADMAP-0001", title: "Older", status: "active", updatedAt: "2026-01-01T00:00:00.000Z" },
61
- { id: "ROADMAP-0002", title: "Newer", status: "done", updatedAt: "2026-02-01T00:00:00.000Z" },
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("ROADMAP-0002")).toBeLessThan(markdown.indexOf("ROADMAP-0001"));
64
- expect(markdown).toContain("[x] ROADMAP-0002");
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
  });