@projitive/mcp 1.2.0 → 2.0.1

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.
@@ -1,24 +1,120 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { z } from "zod";
4
- import { candidateFilesFromArtifacts, discoverGovernanceArtifacts, ROADMAP_LINT_CODES, renderLintSuggestions, findTextReferences } from "../common/index.js";
4
+ import { candidateFilesFromArtifacts, discoverGovernanceArtifacts, ROADMAP_LINT_CODES, renderLintSuggestions, findTextReferences, ensureStore, loadRoadmapsFromStore, upsertRoadmapInStore, getStoreVersion, getMarkdownViewState, markMarkdownViewBuilt, } from "../common/index.js";
5
5
  import { asText, evidenceSection, guidanceSection, lintSection, nextCallSection, renderErrorMarkdown, renderToolResponseMarkdown, summarySection, } from "../common/index.js";
6
6
  import { resolveGovernanceDir, toProjectPath } from "./project.js";
7
7
  import { loadTasks } from "./task.js";
8
8
  export const ROADMAP_ID_REGEX = /^ROADMAP-\d{4}$/;
9
+ export const ROADMAP_MARKDOWN_FILE = "roadmap.md";
10
+ function nowIso() {
11
+ return new Date().toISOString();
12
+ }
13
+ function toRoadmapIdNumericSuffix(roadmapId) {
14
+ const match = roadmapId.match(/^(?:ROADMAP-)(\d{4})$/);
15
+ if (!match) {
16
+ return -1;
17
+ }
18
+ return Number.parseInt(match[1], 10);
19
+ }
20
+ function sortMilestonesNewestFirst(milestones) {
21
+ return [...milestones].sort((a, b) => {
22
+ const updatedAtDelta = new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime();
23
+ if (Number.isFinite(updatedAtDelta) && updatedAtDelta !== 0) {
24
+ return updatedAtDelta;
25
+ }
26
+ const idDelta = toRoadmapIdNumericSuffix(b.id) - toRoadmapIdNumericSuffix(a.id);
27
+ if (idDelta !== 0) {
28
+ return idDelta;
29
+ }
30
+ return b.id.localeCompare(a.id);
31
+ });
32
+ }
33
+ function normalizeMilestone(raw) {
34
+ return {
35
+ id: String(raw.id),
36
+ 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(),
40
+ };
41
+ }
42
+ function normalizeAndSortMilestones(milestones) {
43
+ return sortMilestonesNewestFirst(milestones
44
+ .filter((item) => isValidRoadmapId(item.id))
45
+ .map((item) => normalizeMilestone(item)));
46
+ }
47
+ export function renderRoadmapMarkdown(milestones) {
48
+ const lines = sortMilestonesNewestFirst(milestones).map((item) => {
49
+ const checkbox = item.status === "done" ? "x" : " ";
50
+ const timeText = item.time ? ` (time: ${item.time})` : "";
51
+ return `- [${checkbox}] ${item.id}: ${item.title}${timeText}`;
52
+ });
53
+ 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");
62
+ }
63
+ function resolveRoadmapArtifactPaths(governanceDir) {
64
+ return {
65
+ roadmapPath: path.join(governanceDir, ".projitive"),
66
+ markdownPath: path.join(governanceDir, ROADMAP_MARKDOWN_FILE),
67
+ };
68
+ }
69
+ async function syncRoadmapMarkdownView(roadmapPath, markdownPath, markdown, force = false) {
70
+ const sourceVersion = await getStoreVersion(roadmapPath, "roadmaps");
71
+ const viewState = await getMarkdownViewState(roadmapPath, "roadmaps_markdown");
72
+ const markdownExists = await fs.access(markdownPath).then(() => true).catch(() => false);
73
+ const shouldWrite = force
74
+ || !markdownExists
75
+ || viewState.dirty
76
+ || viewState.lastSourceVersion !== sourceVersion;
77
+ if (!shouldWrite) {
78
+ return;
79
+ }
80
+ await fs.writeFile(markdownPath, markdown, "utf-8");
81
+ await markMarkdownViewBuilt(roadmapPath, "roadmaps_markdown", sourceVersion);
82
+ }
83
+ export async function loadRoadmapDocument(inputPath) {
84
+ return loadRoadmapDocumentWithOptions(inputPath, false);
85
+ }
86
+ export async function loadRoadmapDocumentWithOptions(inputPath, forceViewSync) {
87
+ const governanceDir = await resolveGovernanceDir(inputPath);
88
+ const { roadmapPath, markdownPath } = resolveRoadmapArtifactPaths(governanceDir);
89
+ await ensureStore(roadmapPath);
90
+ const milestones = normalizeAndSortMilestones(await loadRoadmapsFromStore(roadmapPath));
91
+ const normalizedMilestones = normalizeAndSortMilestones(milestones);
92
+ const markdown = renderRoadmapMarkdown(normalizedMilestones);
93
+ await syncRoadmapMarkdownView(roadmapPath, markdownPath, markdown, forceViewSync);
94
+ return {
95
+ roadmapPath,
96
+ markdownPath,
97
+ markdown,
98
+ milestones: normalizedMilestones,
99
+ };
100
+ }
101
+ export async function loadRoadmapIds(inputPath) {
102
+ const { milestones } = await loadRoadmapDocument(inputPath);
103
+ return milestones.map((item) => item.id);
104
+ }
9
105
  function collectRoadmapLintSuggestionItems(roadmapIds, tasks) {
10
106
  const suggestions = [];
11
107
  if (roadmapIds.length === 0) {
12
108
  suggestions.push({
13
109
  code: ROADMAP_LINT_CODES.IDS_EMPTY,
14
- message: "No roadmap IDs found in roadmap.md.",
110
+ message: "No roadmap IDs found in .projitive roadmap table.",
15
111
  fixHint: "Add at least one ROADMAP-xxxx milestone.",
16
112
  });
17
113
  }
18
114
  if (tasks.length === 0) {
19
115
  suggestions.push({
20
116
  code: ROADMAP_LINT_CODES.TASKS_EMPTY,
21
- message: "No tasks found in tasks.md.",
117
+ message: "No tasks found in .projitive task table.",
22
118
  fixHint: "Add task cards and bind roadmapRefs for traceability.",
23
119
  });
24
120
  return suggestions;
@@ -56,17 +152,6 @@ export function collectRoadmapLintSuggestions(roadmapIds, tasks) {
56
152
  export function isValidRoadmapId(id) {
57
153
  return ROADMAP_ID_REGEX.test(id);
58
154
  }
59
- async function readRoadmapIds(governanceDir) {
60
- const roadmapPath = path.join(governanceDir, "roadmap.md");
61
- try {
62
- const markdown = await fs.readFile(roadmapPath, "utf-8");
63
- const matches = markdown.match(/ROADMAP-\d{4}/g) ?? [];
64
- return Array.from(new Set(matches));
65
- }
66
- catch {
67
- return [];
68
- }
69
- }
70
155
  export function registerRoadmapTools(server) {
71
156
  server.registerTool("roadmapList", {
72
157
  title: "Roadmap List",
@@ -76,7 +161,7 @@ export function registerRoadmapTools(server) {
76
161
  },
77
162
  }, async ({ projectPath }) => {
78
163
  const governanceDir = await resolveGovernanceDir(projectPath);
79
- const roadmapIds = await readRoadmapIds(governanceDir);
164
+ const roadmapIds = await loadRoadmapIds(governanceDir);
80
165
  const { tasks } = await loadTasks(governanceDir);
81
166
  const lintSuggestions = collectRoadmapLintSuggestions(roadmapIds, tasks);
82
167
  const markdown = renderToolResponseMarkdown({
@@ -122,7 +207,7 @@ export function registerRoadmapTools(server) {
122
207
  const referenceLocations = (await Promise.all(fileCandidates.map((file) => findTextReferences(file, roadmapId)))).flat();
123
208
  const { tasks } = await loadTasks(governanceDir);
124
209
  const relatedTasks = tasks.filter((task) => task.roadmapRefs.includes(roadmapId));
125
- const roadmapIds = await readRoadmapIds(governanceDir);
210
+ const roadmapIds = await loadRoadmapIds(governanceDir);
126
211
  const lintSuggestionItems = collectRoadmapLintSuggestionItems(roadmapIds, tasks);
127
212
  if (relatedTasks.length === 0) {
128
213
  lintSuggestionItems.push({
@@ -159,4 +244,69 @@ export function registerRoadmapTools(server) {
159
244
  });
160
245
  return asText(markdown);
161
246
  });
247
+ server.registerTool("roadmapUpdate", {
248
+ title: "Roadmap Update",
249
+ description: "Update one roadmap milestone fields incrementally in sqlite table",
250
+ inputSchema: {
251
+ projectPath: z.string(),
252
+ roadmapId: z.string(),
253
+ updates: z.object({
254
+ title: z.string().optional(),
255
+ status: z.enum(["active", "done"]).optional(),
256
+ time: z.string().optional(),
257
+ }),
258
+ },
259
+ }, async ({ projectPath, roadmapId, updates }) => {
260
+ if (!isValidRoadmapId(roadmapId)) {
261
+ 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={...})`)),
263
+ isError: true,
264
+ };
265
+ }
266
+ const governanceDir = await resolveGovernanceDir(projectPath);
267
+ const doc = await loadRoadmapDocument(governanceDir);
268
+ const existing = doc.milestones.find((item) => item.id === roadmapId);
269
+ if (!existing) {
270
+ 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)}")`)),
272
+ isError: true,
273
+ };
274
+ }
275
+ const updated = {
276
+ ...existing,
277
+ title: updates.title ?? existing.title,
278
+ status: updates.status ?? existing.status,
279
+ time: updates.time ?? existing.time,
280
+ updatedAt: nowIso(),
281
+ };
282
+ await upsertRoadmapInStore(doc.roadmapPath, updated);
283
+ const refreshed = await loadRoadmapDocumentWithOptions(governanceDir, true);
284
+ const markdown = renderToolResponseMarkdown({
285
+ toolName: "roadmapUpdate",
286
+ sections: [
287
+ summarySection([
288
+ `- governanceDir: ${governanceDir}`,
289
+ `- roadmapId: ${roadmapId}`,
290
+ `- newStatus: ${updated.status}`,
291
+ `- updatedAt: ${updated.updatedAt}`,
292
+ ]),
293
+ evidenceSection([
294
+ "### Updated Milestone",
295
+ `- ${updated.id} | ${updated.status} | ${updated.title}${updated.time ? ` | time=${updated.time}` : ""}`,
296
+ "",
297
+ `### Roadmap Count`,
298
+ `- total: ${refreshed.milestones.length}`,
299
+ ]),
300
+ 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.",
305
+ ]),
306
+ lintSection([]),
307
+ nextCallSection(`roadmapContext(projectPath="${toProjectPath(governanceDir)}", roadmapId="${roadmapId}")`),
308
+ ],
309
+ });
310
+ return asText(markdown);
311
+ });
162
312
  }
@@ -2,7 +2,7 @@ import { describe, it, expect, beforeAll, afterAll } from "vitest";
2
2
  import fs from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import os from "node:os";
5
- import { isValidRoadmapId, collectRoadmapLintSuggestions } from "./roadmap.js";
5
+ import { isValidRoadmapId, collectRoadmapLintSuggestions, loadRoadmapDocument, renderRoadmapMarkdown } from "./roadmap.js";
6
6
  describe("roadmap module", () => {
7
7
  let tempDir;
8
8
  beforeAll(async () => {
@@ -15,14 +15,10 @@ describe("roadmap module", () => {
15
15
  it("should validate correct roadmap IDs", () => {
16
16
  expect(isValidRoadmapId("ROADMAP-0001")).toBe(true);
17
17
  expect(isValidRoadmapId("ROADMAP-1234")).toBe(true);
18
- expect(isValidRoadmapId("ROADMAP-9999")).toBe(true);
19
18
  });
20
19
  it("should reject invalid roadmap IDs", () => {
21
20
  expect(isValidRoadmapId("roadmap-0001")).toBe(false);
22
- expect(isValidRoadmapId("ROADMAP-001")).toBe(false);
23
- expect(isValidRoadmapId("ROADMAP-00001")).toBe(false);
24
21
  expect(isValidRoadmapId("TASK-0001")).toBe(false);
25
- expect(isValidRoadmapId("")).toBe(false);
26
22
  expect(isValidRoadmapId("invalid")).toBe(false);
27
23
  });
28
24
  });
@@ -36,8 +32,7 @@ describe("roadmap module", () => {
36
32
  expect(suggestions.some(s => s.includes("TASKS_EMPTY"))).toBe(true);
37
33
  });
38
34
  it("should return lint suggestion for tasks without roadmap refs", () => {
39
- const tasks = [
40
- {
35
+ const tasks = [{
41
36
  id: "TASK-0001",
42
37
  title: "Test Task",
43
38
  status: "TODO",
@@ -46,58 +41,27 @@ describe("roadmap module", () => {
46
41
  updatedAt: "2026-01-01T00:00:00.000Z",
47
42
  links: [],
48
43
  roadmapRefs: [],
49
- },
50
- ];
44
+ }];
51
45
  const suggestions = collectRoadmapLintSuggestions(["ROADMAP-0001"], tasks);
52
46
  expect(suggestions.some(s => s.includes("TASK_REFS_EMPTY"))).toBe(true);
53
47
  });
54
- it("should return lint suggestion for unknown roadmap refs", () => {
55
- const tasks = [
56
- {
57
- id: "TASK-0001",
58
- title: "Test Task",
59
- status: "TODO",
60
- owner: "ai-copilot",
61
- summary: "Test",
62
- updatedAt: "2026-01-01T00:00:00.000Z",
63
- links: [],
64
- roadmapRefs: ["ROADMAP-9999"],
65
- },
66
- ];
67
- const suggestions = collectRoadmapLintSuggestions(["ROADMAP-0001"], tasks);
68
- expect(suggestions.some(s => s.includes("UNKNOWN_REFS"))).toBe(true);
69
- });
70
- it("should return lint suggestion for roadmaps with no linked tasks", () => {
71
- const tasks = [
72
- {
73
- id: "TASK-0001",
74
- title: "Test Task",
75
- status: "TODO",
76
- owner: "ai-copilot",
77
- summary: "Test",
78
- updatedAt: "2026-01-01T00:00:00.000Z",
79
- links: [],
80
- roadmapRefs: ["ROADMAP-0001"],
81
- },
82
- ];
83
- const suggestions = collectRoadmapLintSuggestions(["ROADMAP-0001", "ROADMAP-0002"], tasks);
84
- expect(suggestions.some(s => s.includes("ZERO_LINKED_TASKS"))).toBe(true);
48
+ it("loads from sqlite and rewrites roadmap markdown view", async () => {
49
+ const governanceDir = path.join(tempDir, ".projitive-db");
50
+ await fs.mkdir(governanceDir, { recursive: true });
51
+ await fs.writeFile(path.join(governanceDir, ".projitive"), "", "utf-8");
52
+ 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");
85
57
  });
86
- it("should return no lint suggestions for valid setup", () => {
87
- const tasks = [
88
- {
89
- id: "TASK-0001",
90
- title: "Test Task",
91
- status: "TODO",
92
- owner: "ai-copilot",
93
- summary: "Test",
94
- updatedAt: "2026-01-01T00:00:00.000Z",
95
- links: [],
96
- roadmapRefs: ["ROADMAP-0001"],
97
- },
98
- ];
99
- const suggestions = collectRoadmapLintSuggestions(["ROADMAP-0001"], tasks);
100
- expect(suggestions.length).toBe(0);
58
+ it("renders milestones in newest-first order", () => {
59
+ 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" },
62
+ ]);
63
+ expect(markdown.indexOf("ROADMAP-0002")).toBeLessThan(markdown.indexOf("ROADMAP-0001"));
64
+ expect(markdown).toContain("[x] ROADMAP-0002");
101
65
  });
102
66
  });
103
67
  });