@projitive/mcp 1.1.2 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +115 -422
- package/output/package.json +4 -1
- package/output/source/common/files.js +1 -1
- package/output/source/common/index.js +1 -0
- package/output/source/common/migrations/runner.js +68 -0
- package/output/source/common/migrations/steps.js +55 -0
- package/output/source/common/migrations/types.js +1 -0
- package/output/source/common/response.js +147 -1
- package/output/source/common/store.js +623 -0
- package/output/source/common/store.test.js +164 -0
- package/output/source/index.js +1 -1
- package/output/source/prompts/quickStart.js +33 -7
- package/output/source/prompts/taskDiscovery.js +23 -9
- package/output/source/prompts/taskExecution.js +18 -8
- package/output/source/resources/governance.js +2 -2
- package/output/source/tools/project.js +254 -119
- package/output/source/tools/project.test.js +33 -11
- package/output/source/tools/roadmap.js +166 -16
- package/output/source/tools/roadmap.test.js +19 -55
- package/output/source/tools/task.js +152 -376
- package/output/source/tools/task.test.js +64 -392
- package/output/source/types.js +0 -9
- package/package.json +4 -1
|
@@ -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.
|
|
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
|
|
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
|
|
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
|
|
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("
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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("
|
|
87
|
-
const
|
|
88
|
-
{
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
});
|