@projitive/mcp 1.0.0-beta.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.
- package/README.md +318 -0
- package/output/designs.js +38 -0
- package/output/helpers/catch/catch.js +48 -0
- package/output/helpers/catch/catch.test.js +43 -0
- package/output/helpers/catch/index.js +1 -0
- package/output/helpers/files/files.js +62 -0
- package/output/helpers/files/files.test.js +32 -0
- package/output/helpers/files/index.js +1 -0
- package/output/helpers/index.js +3 -0
- package/output/helpers/markdown/index.js +1 -0
- package/output/helpers/markdown/markdown.js +33 -0
- package/output/helpers/markdown/markdown.test.js +36 -0
- package/output/hooks.js +62 -0
- package/output/hooks.test.js +51 -0
- package/output/index.js +562 -0
- package/output/projitive.js +55 -0
- package/output/projitive.test.js +46 -0
- package/output/readme.js +26 -0
- package/output/reports.js +36 -0
- package/output/roadmap.js +4 -0
- package/output/tasks.js +242 -0
- package/output/tasks.test.js +78 -0
- package/package.json +29 -0
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
5
|
+
import { detectHookEvent, resolveHookForEvent } from "./hooks.js";
|
|
6
|
+
import { normalizeTask } from "./tasks.js";
|
|
7
|
+
const tempPaths = [];
|
|
8
|
+
async function createTempDir() {
|
|
9
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "projitive-mcp-test-"));
|
|
10
|
+
tempPaths.push(dir);
|
|
11
|
+
return dir;
|
|
12
|
+
}
|
|
13
|
+
afterEach(async () => {
|
|
14
|
+
await Promise.all(tempPaths.splice(0).map(async (dir) => {
|
|
15
|
+
await fs.rm(dir, { recursive: true, force: true });
|
|
16
|
+
}));
|
|
17
|
+
});
|
|
18
|
+
describe("hooks module", () => {
|
|
19
|
+
it("maps transitions to hook events", () => {
|
|
20
|
+
expect(detectHookEvent("TODO", "IN_PROGRESS")).toBe("onAssigned");
|
|
21
|
+
expect(detectHookEvent("IN_PROGRESS", "DONE")).toBe("onCompleted");
|
|
22
|
+
expect(detectHookEvent("IN_PROGRESS", "BLOCKED")).toBe("onBlocked");
|
|
23
|
+
expect(detectHookEvent("BLOCKED", "TODO")).toBe(null);
|
|
24
|
+
});
|
|
25
|
+
it("prefers task-level hook over global hook", async () => {
|
|
26
|
+
const root = await createTempDir();
|
|
27
|
+
const governanceDir = path.join(root, "gov");
|
|
28
|
+
await fs.mkdir(path.join(governanceDir, "hooks"), { recursive: true });
|
|
29
|
+
await fs.writeFile(path.join(governanceDir, "hooks", "on_task_assigned.md"), "global assigned", "utf-8");
|
|
30
|
+
await fs.writeFile(path.join(governanceDir, "hooks", "custom-assigned.md"), "task assigned", "utf-8");
|
|
31
|
+
const task = normalizeTask({
|
|
32
|
+
id: "TASK-0001",
|
|
33
|
+
title: "Task",
|
|
34
|
+
status: "TODO",
|
|
35
|
+
hooks: { onAssigned: "./hooks/custom-assigned.md" },
|
|
36
|
+
});
|
|
37
|
+
const hook = await resolveHookForEvent(governanceDir, task, "onAssigned");
|
|
38
|
+
expect(hook.source).toBe("task");
|
|
39
|
+
expect(hook.content).toContain("task assigned");
|
|
40
|
+
});
|
|
41
|
+
it("falls back to global hook when task-level hook is missing", async () => {
|
|
42
|
+
const root = await createTempDir();
|
|
43
|
+
const governanceDir = path.join(root, "gov");
|
|
44
|
+
await fs.mkdir(path.join(governanceDir, "hooks"), { recursive: true });
|
|
45
|
+
await fs.writeFile(path.join(governanceDir, "hooks", "on_task_completed.md"), "global completed", "utf-8");
|
|
46
|
+
const task = normalizeTask({ id: "TASK-0001", title: "Task", status: "IN_PROGRESS" });
|
|
47
|
+
const hook = await resolveHookForEvent(governanceDir, task, "onCompleted");
|
|
48
|
+
expect(hook.source).toBe("global");
|
|
49
|
+
expect(hook.content).toContain("global completed");
|
|
50
|
+
});
|
|
51
|
+
});
|
package/output/index.js
ADDED
|
@@ -0,0 +1,562 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import process from "node:process";
|
|
3
|
+
import fs from "node:fs/promises";
|
|
4
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
5
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
import { discoverGovernanceArtifacts } from "./helpers/files/index.js";
|
|
8
|
+
import { findTextReferences } from "./helpers/markdown/index.js";
|
|
9
|
+
import { discoverProjects, resolveGovernanceDir } from "./projitive.js";
|
|
10
|
+
import { isValidRoadmapId } from "./roadmap.js";
|
|
11
|
+
import { isValidTaskId, loadTasks, parseTasksBlock, rankActionableTaskCandidates, taskPriority, toTaskUpdatedAtMs, } from "./tasks.js";
|
|
12
|
+
const PROJITIVE_SPEC_VERSION = "1.0.0";
|
|
13
|
+
const server = new McpServer({
|
|
14
|
+
name: "projitive-mcp",
|
|
15
|
+
version: PROJITIVE_SPEC_VERSION,
|
|
16
|
+
description: "Semantic Projitive MCP for project/task discovery and agent guidance with markdown-first outputs",
|
|
17
|
+
});
|
|
18
|
+
function asText(markdown) {
|
|
19
|
+
return {
|
|
20
|
+
content: [{ type: "text", text: markdown }],
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
function renderErrorMarkdown(toolName, cause, nextSteps) {
|
|
24
|
+
return [
|
|
25
|
+
`# ${toolName}`,
|
|
26
|
+
"",
|
|
27
|
+
"## Error",
|
|
28
|
+
`- cause: ${cause}`,
|
|
29
|
+
"",
|
|
30
|
+
"## Next Step",
|
|
31
|
+
...(nextSteps.length > 0 ? nextSteps : ["- (none)"]),
|
|
32
|
+
].join("\n");
|
|
33
|
+
}
|
|
34
|
+
function normalizePath(inputPath) {
|
|
35
|
+
return inputPath ? path.resolve(inputPath) : process.cwd();
|
|
36
|
+
}
|
|
37
|
+
function candidateFilesFromArtifacts(artifacts) {
|
|
38
|
+
return artifacts
|
|
39
|
+
.filter((item) => item.exists)
|
|
40
|
+
.flatMap((item) => {
|
|
41
|
+
if (item.kind === "file") {
|
|
42
|
+
return [item.path];
|
|
43
|
+
}
|
|
44
|
+
return (item.markdownFiles ?? []).map((entry) => entry.path);
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
async function readOptionalMarkdown(filePath) {
|
|
48
|
+
const content = await fs.readFile(filePath, "utf-8").catch(() => undefined);
|
|
49
|
+
if (typeof content !== "string") {
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
const trimmed = content.trim();
|
|
53
|
+
return trimmed.length > 0 ? trimmed : undefined;
|
|
54
|
+
}
|
|
55
|
+
async function readTaskGetHooks(governanceDir) {
|
|
56
|
+
const headPath = path.join(governanceDir, "hooks", "task_get_head.md");
|
|
57
|
+
const footerPath = path.join(governanceDir, "hooks", "task_get_footer.md");
|
|
58
|
+
const [head, footer] = await Promise.all([readOptionalMarkdown(headPath), readOptionalMarkdown(footerPath)]);
|
|
59
|
+
return { head, footer, headPath, footerPath };
|
|
60
|
+
}
|
|
61
|
+
async function readRoadmapIds(governanceDir) {
|
|
62
|
+
const roadmapPath = path.join(governanceDir, "roadmap.md");
|
|
63
|
+
try {
|
|
64
|
+
const markdown = await fs.readFile(roadmapPath, "utf-8");
|
|
65
|
+
const matches = markdown.match(/ROADMAP-\d{4}/g) ?? [];
|
|
66
|
+
return Array.from(new Set(matches));
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return [];
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
function renderArtifactsMarkdown(artifacts) {
|
|
73
|
+
const rows = artifacts.map((item) => {
|
|
74
|
+
if (item.kind === "file") {
|
|
75
|
+
const lineText = item.lineCount == null ? "-" : String(item.lineCount);
|
|
76
|
+
return `- ${item.exists ? "✅" : "❌"} ${item.name} \n path: ${item.path} \n lineCount: ${lineText}`;
|
|
77
|
+
}
|
|
78
|
+
const nested = (item.markdownFiles ?? [])
|
|
79
|
+
.map((entry) => ` - ${entry.path} (lines: ${entry.lineCount})`)
|
|
80
|
+
.join("\n");
|
|
81
|
+
return `- ${item.exists ? "✅" : "❌"} ${item.name}/ \n path: ${item.path}${nested ? `\n markdownFiles:\n${nested}` : ""}`;
|
|
82
|
+
});
|
|
83
|
+
return rows.join("\n");
|
|
84
|
+
}
|
|
85
|
+
async function readTasksSnapshot(governanceDir) {
|
|
86
|
+
const tasksPath = path.join(governanceDir, "tasks.md");
|
|
87
|
+
const markdown = await fs.readFile(tasksPath, "utf-8").catch(() => undefined);
|
|
88
|
+
if (typeof markdown !== "string") {
|
|
89
|
+
return { tasksPath, exists: false, tasks: [] };
|
|
90
|
+
}
|
|
91
|
+
const tasks = await parseTasksBlock(markdown);
|
|
92
|
+
return { tasksPath, exists: true, tasks };
|
|
93
|
+
}
|
|
94
|
+
function latestTaskUpdatedAt(tasks) {
|
|
95
|
+
const timestamps = tasks
|
|
96
|
+
.map((task) => new Date(task.updatedAt).getTime())
|
|
97
|
+
.filter((value) => Number.isFinite(value));
|
|
98
|
+
if (timestamps.length === 0) {
|
|
99
|
+
return "(unknown)";
|
|
100
|
+
}
|
|
101
|
+
return new Date(Math.max(...timestamps)).toISOString();
|
|
102
|
+
}
|
|
103
|
+
function actionableScore(tasks) {
|
|
104
|
+
return tasks.filter((task) => task.status === "IN_PROGRESS").length * 2
|
|
105
|
+
+ tasks.filter((task) => task.status === "TODO").length;
|
|
106
|
+
}
|
|
107
|
+
async function readActionableTaskCandidates(governanceDirs) {
|
|
108
|
+
const snapshots = await Promise.all(governanceDirs.map(async (governanceDir) => {
|
|
109
|
+
const snapshot = await readTasksSnapshot(governanceDir);
|
|
110
|
+
return {
|
|
111
|
+
governanceDir,
|
|
112
|
+
tasksPath: snapshot.tasksPath,
|
|
113
|
+
tasks: snapshot.tasks,
|
|
114
|
+
projectScore: actionableScore(snapshot.tasks),
|
|
115
|
+
projectLatestUpdatedAt: latestTaskUpdatedAt(snapshot.tasks),
|
|
116
|
+
};
|
|
117
|
+
}));
|
|
118
|
+
return snapshots.flatMap((item) => item.tasks
|
|
119
|
+
.filter((task) => task.status === "IN_PROGRESS" || task.status === "TODO")
|
|
120
|
+
.map((task) => ({
|
|
121
|
+
governanceDir: item.governanceDir,
|
|
122
|
+
tasksPath: item.tasksPath,
|
|
123
|
+
task,
|
|
124
|
+
projectScore: item.projectScore,
|
|
125
|
+
projectLatestUpdatedAt: item.projectLatestUpdatedAt,
|
|
126
|
+
taskUpdatedAtMs: toTaskUpdatedAtMs(task.updatedAt),
|
|
127
|
+
taskPriority: taskPriority(task.status),
|
|
128
|
+
})));
|
|
129
|
+
}
|
|
130
|
+
server.registerTool("project.scan", {
|
|
131
|
+
title: "Project Scan",
|
|
132
|
+
description: "Scan filesystem and discover project governance roots marked by .projitive",
|
|
133
|
+
inputSchema: {
|
|
134
|
+
rootPath: z.string().optional(),
|
|
135
|
+
maxDepth: z.number().int().min(0).max(8).optional(),
|
|
136
|
+
},
|
|
137
|
+
}, async ({ rootPath, maxDepth }) => {
|
|
138
|
+
const root = normalizePath(rootPath);
|
|
139
|
+
const projects = await discoverProjects(root, maxDepth ?? 3);
|
|
140
|
+
const markdown = [
|
|
141
|
+
"# project.scan",
|
|
142
|
+
"",
|
|
143
|
+
"## Summary",
|
|
144
|
+
`- rootPath: ${root}`,
|
|
145
|
+
`- maxDepth: ${maxDepth ?? 3}`,
|
|
146
|
+
`- discoveredCount: ${projects.length}`,
|
|
147
|
+
"",
|
|
148
|
+
"## Evidence",
|
|
149
|
+
"- projects:",
|
|
150
|
+
...(projects.length > 0 ? projects.map((project, index) => `${index + 1}. ${project}`) : ["- (none)"]),
|
|
151
|
+
"",
|
|
152
|
+
"## Agent Guidance",
|
|
153
|
+
"- Next: call `project.locate` with one target path to lock the active governance root.",
|
|
154
|
+
"- Then: call `project.overview` to view artifact and task status.",
|
|
155
|
+
].join("\n");
|
|
156
|
+
return asText(markdown);
|
|
157
|
+
});
|
|
158
|
+
server.registerTool("project.next", {
|
|
159
|
+
title: "Project Next",
|
|
160
|
+
description: "Directly list recently actionable projects for immediate agent progression",
|
|
161
|
+
inputSchema: {
|
|
162
|
+
rootPath: z.string().optional(),
|
|
163
|
+
maxDepth: z.number().int().min(0).max(8).optional(),
|
|
164
|
+
limit: z.number().int().min(1).max(50).optional(),
|
|
165
|
+
},
|
|
166
|
+
}, async ({ rootPath, maxDepth, limit }) => {
|
|
167
|
+
const root = normalizePath(rootPath);
|
|
168
|
+
const projects = await discoverProjects(root, maxDepth ?? 3);
|
|
169
|
+
const snapshots = await Promise.all(projects.map(async (governanceDir) => {
|
|
170
|
+
const snapshot = await readTasksSnapshot(governanceDir);
|
|
171
|
+
const inProgress = snapshot.tasks.filter((task) => task.status === "IN_PROGRESS").length;
|
|
172
|
+
const todo = snapshot.tasks.filter((task) => task.status === "TODO").length;
|
|
173
|
+
const blocked = snapshot.tasks.filter((task) => task.status === "BLOCKED").length;
|
|
174
|
+
const done = snapshot.tasks.filter((task) => task.status === "DONE").length;
|
|
175
|
+
const actionable = inProgress + todo;
|
|
176
|
+
return {
|
|
177
|
+
governanceDir,
|
|
178
|
+
tasksPath: snapshot.tasksPath,
|
|
179
|
+
tasksExists: snapshot.exists,
|
|
180
|
+
total: snapshot.tasks.length,
|
|
181
|
+
inProgress,
|
|
182
|
+
todo,
|
|
183
|
+
blocked,
|
|
184
|
+
done,
|
|
185
|
+
actionable,
|
|
186
|
+
latestUpdatedAt: latestTaskUpdatedAt(snapshot.tasks),
|
|
187
|
+
score: actionableScore(snapshot.tasks),
|
|
188
|
+
};
|
|
189
|
+
}));
|
|
190
|
+
const ranked = snapshots
|
|
191
|
+
.filter((item) => item.actionable > 0)
|
|
192
|
+
.sort((a, b) => {
|
|
193
|
+
if (b.score !== a.score) {
|
|
194
|
+
return b.score - a.score;
|
|
195
|
+
}
|
|
196
|
+
return b.latestUpdatedAt.localeCompare(a.latestUpdatedAt);
|
|
197
|
+
})
|
|
198
|
+
.slice(0, limit ?? 10);
|
|
199
|
+
const markdown = [
|
|
200
|
+
"# project.next",
|
|
201
|
+
"",
|
|
202
|
+
"## Summary",
|
|
203
|
+
`- rootPath: ${root}`,
|
|
204
|
+
`- maxDepth: ${maxDepth ?? 3}`,
|
|
205
|
+
`- matchedProjects: ${projects.length}`,
|
|
206
|
+
`- actionableProjects: ${ranked.length}`,
|
|
207
|
+
`- limit: ${limit ?? 10}`,
|
|
208
|
+
"",
|
|
209
|
+
"## Evidence",
|
|
210
|
+
"- rankedProjects:",
|
|
211
|
+
...(ranked.length > 0
|
|
212
|
+
? ranked.map((item, index) => `${index + 1}. ${item.governanceDir} | actionable=${item.actionable} | in_progress=${item.inProgress} | todo=${item.todo} | blocked=${item.blocked} | done=${item.done} | latest=${item.latestUpdatedAt} | tasksPath=${item.tasksPath}${item.tasksExists ? "" : " (missing)"}`)
|
|
213
|
+
: ["- (none)"]),
|
|
214
|
+
"",
|
|
215
|
+
"## Agent Guidance",
|
|
216
|
+
"- Pick top 1 project and call `project.overview` with its governanceDir.",
|
|
217
|
+
"- Then call `task.list` and `task.get` to continue execution.",
|
|
218
|
+
"- If `tasksPath` is missing, create tasks.md using project convention before task-level operations.",
|
|
219
|
+
].join("\n");
|
|
220
|
+
return asText(markdown);
|
|
221
|
+
});
|
|
222
|
+
server.registerTool("project.locate", {
|
|
223
|
+
title: "Project Locate",
|
|
224
|
+
description: "Resolve current project governance root from an in-project path by finding the nearest .projitive marker",
|
|
225
|
+
inputSchema: {
|
|
226
|
+
inputPath: z.string(),
|
|
227
|
+
},
|
|
228
|
+
}, async ({ inputPath }) => {
|
|
229
|
+
const resolvedFrom = normalizePath(inputPath);
|
|
230
|
+
const governanceDir = await resolveGovernanceDir(resolvedFrom);
|
|
231
|
+
const markerPath = path.join(governanceDir, ".projitive");
|
|
232
|
+
const markdown = [
|
|
233
|
+
"# project.locate",
|
|
234
|
+
"",
|
|
235
|
+
"## Summary",
|
|
236
|
+
`- resolvedFrom: ${resolvedFrom}`,
|
|
237
|
+
`- governanceDir: ${governanceDir}`,
|
|
238
|
+
`- markerPath: ${markerPath}`,
|
|
239
|
+
"",
|
|
240
|
+
"## Agent Guidance",
|
|
241
|
+
"- Next: call `project.overview` with this governanceDir to get task and roadmap summaries.",
|
|
242
|
+
].join("\n");
|
|
243
|
+
return asText(markdown);
|
|
244
|
+
});
|
|
245
|
+
server.registerTool("project.overview", {
|
|
246
|
+
title: "Project Overview",
|
|
247
|
+
description: "Summarize governance artifacts and task/roadmap status for agent planning",
|
|
248
|
+
inputSchema: {
|
|
249
|
+
projectPath: z.string(),
|
|
250
|
+
},
|
|
251
|
+
}, async ({ projectPath }) => {
|
|
252
|
+
const governanceDir = await resolveGovernanceDir(projectPath);
|
|
253
|
+
const artifacts = await discoverGovernanceArtifacts(governanceDir);
|
|
254
|
+
const { tasksPath, tasks } = await loadTasks(governanceDir);
|
|
255
|
+
const roadmapIds = await readRoadmapIds(governanceDir);
|
|
256
|
+
const taskSummary = {
|
|
257
|
+
total: tasks.length,
|
|
258
|
+
TODO: tasks.filter((task) => task.status === "TODO").length,
|
|
259
|
+
IN_PROGRESS: tasks.filter((task) => task.status === "IN_PROGRESS").length,
|
|
260
|
+
BLOCKED: tasks.filter((task) => task.status === "BLOCKED").length,
|
|
261
|
+
DONE: tasks.filter((task) => task.status === "DONE").length,
|
|
262
|
+
};
|
|
263
|
+
const markdown = [
|
|
264
|
+
"# project.overview",
|
|
265
|
+
"",
|
|
266
|
+
"## Summary",
|
|
267
|
+
`- governanceDir: ${governanceDir}`,
|
|
268
|
+
`- tasksFile: ${tasksPath}`,
|
|
269
|
+
`- roadmapIds: ${roadmapIds.length}`,
|
|
270
|
+
"",
|
|
271
|
+
"## Evidence",
|
|
272
|
+
"### Task Summary",
|
|
273
|
+
`- total: ${taskSummary.total}`,
|
|
274
|
+
`- TODO: ${taskSummary.TODO}`,
|
|
275
|
+
`- IN_PROGRESS: ${taskSummary.IN_PROGRESS}`,
|
|
276
|
+
`- BLOCKED: ${taskSummary.BLOCKED}`,
|
|
277
|
+
`- DONE: ${taskSummary.DONE}`,
|
|
278
|
+
"",
|
|
279
|
+
"### Artifacts",
|
|
280
|
+
renderArtifactsMarkdown(artifacts),
|
|
281
|
+
"",
|
|
282
|
+
"## Agent Guidance",
|
|
283
|
+
"- Next: call `task.list` to choose a target task.",
|
|
284
|
+
"- Then: call `task.get` with a task ID to retrieve evidence locations and reading order.",
|
|
285
|
+
].join("\n");
|
|
286
|
+
return asText(markdown);
|
|
287
|
+
});
|
|
288
|
+
server.registerTool("task.list", {
|
|
289
|
+
title: "Task List",
|
|
290
|
+
description: "List project tasks with optional status filter for agent planning",
|
|
291
|
+
inputSchema: {
|
|
292
|
+
projectPath: z.string(),
|
|
293
|
+
status: z.enum(["TODO", "IN_PROGRESS", "BLOCKED", "DONE"]).optional(),
|
|
294
|
+
limit: z.number().int().min(1).max(200).optional(),
|
|
295
|
+
},
|
|
296
|
+
}, async ({ projectPath, status, limit }) => {
|
|
297
|
+
const governanceDir = await resolveGovernanceDir(projectPath);
|
|
298
|
+
const { tasksPath, tasks } = await loadTasks(governanceDir);
|
|
299
|
+
const filtered = tasks
|
|
300
|
+
.filter((task) => (status ? task.status === status : true))
|
|
301
|
+
.slice(0, limit ?? 100);
|
|
302
|
+
const markdown = [
|
|
303
|
+
"# task.list",
|
|
304
|
+
"",
|
|
305
|
+
"## Summary",
|
|
306
|
+
`- governanceDir: ${governanceDir}`,
|
|
307
|
+
`- tasksPath: ${tasksPath}`,
|
|
308
|
+
`- filter.status: ${status ?? "(none)"}`,
|
|
309
|
+
`- returned: ${filtered.length}`,
|
|
310
|
+
"",
|
|
311
|
+
"## Evidence",
|
|
312
|
+
"- tasks:",
|
|
313
|
+
...(filtered.length > 0
|
|
314
|
+
? filtered.map((task) => `- ${task.id} | ${task.status} | ${task.title} | owner=${task.owner || ""} | updatedAt=${task.updatedAt}`)
|
|
315
|
+
: ["- (none)"]),
|
|
316
|
+
"",
|
|
317
|
+
"## Agent Guidance",
|
|
318
|
+
"- Next: pick one task ID and call `task.get`.",
|
|
319
|
+
].join("\n");
|
|
320
|
+
return asText(markdown);
|
|
321
|
+
});
|
|
322
|
+
server.registerTool("task.next", {
|
|
323
|
+
title: "Task Next",
|
|
324
|
+
description: "One-step discover and select the most actionable task with evidence and start guidance",
|
|
325
|
+
inputSchema: {
|
|
326
|
+
rootPath: z.string().optional(),
|
|
327
|
+
maxDepth: z.number().int().min(0).max(8).optional(),
|
|
328
|
+
topCandidates: z.number().int().min(1).max(20).optional(),
|
|
329
|
+
},
|
|
330
|
+
}, async ({ rootPath, maxDepth, topCandidates }) => {
|
|
331
|
+
const root = normalizePath(rootPath);
|
|
332
|
+
const projects = await discoverProjects(root, maxDepth ?? 3);
|
|
333
|
+
const rankedCandidates = rankActionableTaskCandidates(await readActionableTaskCandidates(projects));
|
|
334
|
+
if (rankedCandidates.length === 0) {
|
|
335
|
+
const markdown = [
|
|
336
|
+
"# task.next",
|
|
337
|
+
"",
|
|
338
|
+
"## Summary",
|
|
339
|
+
`- rootPath: ${root}`,
|
|
340
|
+
`- maxDepth: ${maxDepth ?? 3}`,
|
|
341
|
+
`- matchedProjects: ${projects.length}`,
|
|
342
|
+
"- actionableTasks: 0",
|
|
343
|
+
"",
|
|
344
|
+
"## Evidence",
|
|
345
|
+
"- candidates:",
|
|
346
|
+
"- (none)",
|
|
347
|
+
"",
|
|
348
|
+
"## Agent Guidance",
|
|
349
|
+
"- No TODO/IN_PROGRESS task is available.",
|
|
350
|
+
"- Create or reopen tasks in tasks.md, then rerun `task.next`.",
|
|
351
|
+
].join("\n");
|
|
352
|
+
return asText(markdown);
|
|
353
|
+
}
|
|
354
|
+
const selected = rankedCandidates[0];
|
|
355
|
+
const artifacts = await discoverGovernanceArtifacts(selected.governanceDir);
|
|
356
|
+
const fileCandidates = candidateFilesFromArtifacts(artifacts);
|
|
357
|
+
const referenceLocations = (await Promise.all(fileCandidates.map((file) => findTextReferences(file, selected.task.id)))).flat();
|
|
358
|
+
const taskLocation = (await findTextReferences(selected.tasksPath, selected.task.id))[0];
|
|
359
|
+
const relatedArtifacts = Array.from(new Set(referenceLocations.map((item) => item.filePath)));
|
|
360
|
+
const suggestedReadOrder = [selected.tasksPath, ...relatedArtifacts.filter((item) => item !== selected.tasksPath)];
|
|
361
|
+
const candidateLimit = topCandidates ?? 5;
|
|
362
|
+
const markdown = [
|
|
363
|
+
"# task.next",
|
|
364
|
+
"",
|
|
365
|
+
"## Summary",
|
|
366
|
+
`- rootPath: ${root}`,
|
|
367
|
+
`- maxDepth: ${maxDepth ?? 3}`,
|
|
368
|
+
`- matchedProjects: ${projects.length}`,
|
|
369
|
+
`- actionableTasks: ${rankedCandidates.length}`,
|
|
370
|
+
`- selectedProject: ${selected.governanceDir}`,
|
|
371
|
+
`- selectedTaskId: ${selected.task.id}`,
|
|
372
|
+
`- selectedTaskStatus: ${selected.task.status}`,
|
|
373
|
+
"",
|
|
374
|
+
"## Evidence",
|
|
375
|
+
"### Selected Task",
|
|
376
|
+
`- id: ${selected.task.id}`,
|
|
377
|
+
`- title: ${selected.task.title}`,
|
|
378
|
+
`- owner: ${selected.task.owner || "(none)"}`,
|
|
379
|
+
`- updatedAt: ${selected.task.updatedAt}`,
|
|
380
|
+
`- roadmapRefs: ${selected.task.roadmapRefs.join(", ") || "(none)"}`,
|
|
381
|
+
`- taskLocation: ${taskLocation ? `${taskLocation.filePath}#L${taskLocation.line}` : selected.tasksPath}`,
|
|
382
|
+
"",
|
|
383
|
+
"### Top Candidates",
|
|
384
|
+
...rankedCandidates
|
|
385
|
+
.slice(0, candidateLimit)
|
|
386
|
+
.map((item, index) => `${index + 1}. ${item.task.id} | ${item.task.status} | ${item.task.title} | project=${item.governanceDir} | projectScore=${item.projectScore} | latest=${item.projectLatestUpdatedAt}`),
|
|
387
|
+
"",
|
|
388
|
+
"### Related Artifacts",
|
|
389
|
+
...(relatedArtifacts.length > 0 ? relatedArtifacts.map((file) => `- ${file}`) : ["- (none)"]),
|
|
390
|
+
"",
|
|
391
|
+
"### Reference Locations",
|
|
392
|
+
...(referenceLocations.length > 0
|
|
393
|
+
? referenceLocations.map((item) => `- ${item.filePath}#L${item.line}: ${item.text}`)
|
|
394
|
+
: ["- (none)"]),
|
|
395
|
+
"",
|
|
396
|
+
"### Suggested Read Order",
|
|
397
|
+
...suggestedReadOrder.map((item, index) => `${index + 1}. ${item}`),
|
|
398
|
+
"",
|
|
399
|
+
"## Agent Guidance",
|
|
400
|
+
"- Start immediately with Suggested Read Order and execute the selected task.",
|
|
401
|
+
"- Update markdown artifacts directly while keeping TASK/ROADMAP IDs unchanged.",
|
|
402
|
+
"- Re-run `task.get` for the selectedTaskId after edits to verify evidence consistency.",
|
|
403
|
+
].join("\n");
|
|
404
|
+
return asText(markdown);
|
|
405
|
+
});
|
|
406
|
+
server.registerTool("task.get", {
|
|
407
|
+
title: "Task Get",
|
|
408
|
+
description: "Get one task with related evidence locations and a guidance prompt for the agent",
|
|
409
|
+
inputSchema: {
|
|
410
|
+
projectPath: z.string(),
|
|
411
|
+
taskId: z.string(),
|
|
412
|
+
},
|
|
413
|
+
}, async ({ projectPath, taskId }) => {
|
|
414
|
+
if (!isValidTaskId(taskId)) {
|
|
415
|
+
return {
|
|
416
|
+
...asText(renderErrorMarkdown("task.get", `Invalid task ID format: ${taskId}`, ["- expected format: TASK-0001", "- retry with a valid task ID"])),
|
|
417
|
+
isError: true,
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
const governanceDir = await resolveGovernanceDir(projectPath);
|
|
421
|
+
const { tasksPath, tasks } = await loadTasks(governanceDir);
|
|
422
|
+
const taskGetHooks = await readTaskGetHooks(governanceDir);
|
|
423
|
+
const task = tasks.find((item) => item.id === taskId);
|
|
424
|
+
if (!task) {
|
|
425
|
+
return {
|
|
426
|
+
...asText(renderErrorMarkdown("task.get", `Task not found: ${taskId}`, ["- run `task.list` to discover available IDs", "- retry with an existing task ID"])),
|
|
427
|
+
isError: true,
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
const taskLocation = (await findTextReferences(tasksPath, taskId))[0];
|
|
431
|
+
const artifacts = await discoverGovernanceArtifacts(governanceDir);
|
|
432
|
+
const fileCandidates = candidateFilesFromArtifacts(artifacts);
|
|
433
|
+
const referenceLocations = (await Promise.all(fileCandidates.map((file) => findTextReferences(file, taskId)))).flat();
|
|
434
|
+
const relatedArtifacts = Array.from(new Set(referenceLocations.map((item) => item.filePath)));
|
|
435
|
+
const suggestedReadOrder = [tasksPath, ...relatedArtifacts.filter((item) => item !== tasksPath)];
|
|
436
|
+
const hookPaths = Object.values(task.hooks)
|
|
437
|
+
.filter((value) => typeof value === "string" && value.trim().length > 0)
|
|
438
|
+
.map((value) => path.resolve(governanceDir, value));
|
|
439
|
+
const hookStatus = `head=${taskGetHooks.head ? "loaded" : "missing"}, footer=${taskGetHooks.footer ? "loaded" : "missing"}`;
|
|
440
|
+
const coreMarkdown = [
|
|
441
|
+
"# task.get",
|
|
442
|
+
"",
|
|
443
|
+
"## Summary",
|
|
444
|
+
`- governanceDir: ${governanceDir}`,
|
|
445
|
+
`- taskId: ${task.id}`,
|
|
446
|
+
`- title: ${task.title}`,
|
|
447
|
+
`- status: ${task.status}`,
|
|
448
|
+
`- owner: ${task.owner}`,
|
|
449
|
+
`- updatedAt: ${task.updatedAt}`,
|
|
450
|
+
`- roadmapRefs: ${task.roadmapRefs.join(", ") || "(none)"}`,
|
|
451
|
+
`- taskLocation: ${taskLocation ? `${taskLocation.filePath}#L${taskLocation.line}` : tasksPath}`,
|
|
452
|
+
`- hookStatus: ${hookStatus}`,
|
|
453
|
+
"",
|
|
454
|
+
"## Evidence",
|
|
455
|
+
"### Related Artifacts",
|
|
456
|
+
...(relatedArtifacts.length > 0 ? relatedArtifacts.map((file) => `- ${file}`) : ["- (none)"]),
|
|
457
|
+
"",
|
|
458
|
+
"### Reference Locations",
|
|
459
|
+
...(referenceLocations.length > 0
|
|
460
|
+
? referenceLocations.map((item) => `- ${item.filePath}#L${item.line}: ${item.text}`)
|
|
461
|
+
: ["- (none)"]),
|
|
462
|
+
"",
|
|
463
|
+
"### Hook Paths",
|
|
464
|
+
...(hookPaths.length > 0 ? hookPaths.map((item) => `- ${item}`) : ["- (none)"]),
|
|
465
|
+
"",
|
|
466
|
+
"### Suggested Read Order",
|
|
467
|
+
...suggestedReadOrder.map((item, index) => `${index + 1}. ${item}`),
|
|
468
|
+
"",
|
|
469
|
+
"## Agent Guidance",
|
|
470
|
+
"- Read the files in Suggested Read Order.",
|
|
471
|
+
"- Verify whether current status and evidence are consistent.",
|
|
472
|
+
"- If updates are needed, edit tasks/designs/reports markdown directly and keep TASK IDs unchanged.",
|
|
473
|
+
"- After editing, re-run `task.get` to verify references and context consistency.",
|
|
474
|
+
].join("\n");
|
|
475
|
+
const markdownParts = [
|
|
476
|
+
taskGetHooks.head,
|
|
477
|
+
coreMarkdown,
|
|
478
|
+
taskGetHooks.footer,
|
|
479
|
+
].filter((value) => typeof value === "string" && value.trim().length > 0);
|
|
480
|
+
const markdown = markdownParts.join("\n\n---\n\n");
|
|
481
|
+
return asText(markdown);
|
|
482
|
+
});
|
|
483
|
+
server.registerTool("roadmap.list", {
|
|
484
|
+
title: "Roadmap List",
|
|
485
|
+
description: "List roadmap IDs and related tasks for project planning",
|
|
486
|
+
inputSchema: {
|
|
487
|
+
projectPath: z.string(),
|
|
488
|
+
},
|
|
489
|
+
}, async ({ projectPath }) => {
|
|
490
|
+
const governanceDir = await resolveGovernanceDir(projectPath);
|
|
491
|
+
const roadmapIds = await readRoadmapIds(governanceDir);
|
|
492
|
+
const { tasks } = await loadTasks(governanceDir);
|
|
493
|
+
const markdown = [
|
|
494
|
+
"# roadmap.list",
|
|
495
|
+
"",
|
|
496
|
+
"## Summary",
|
|
497
|
+
`- governanceDir: ${governanceDir}`,
|
|
498
|
+
`- roadmapCount: ${roadmapIds.length}`,
|
|
499
|
+
"",
|
|
500
|
+
"## Evidence",
|
|
501
|
+
"- roadmaps:",
|
|
502
|
+
...(roadmapIds.length > 0
|
|
503
|
+
? roadmapIds.map((id) => {
|
|
504
|
+
const linkedTasks = tasks.filter((task) => task.roadmapRefs.includes(id));
|
|
505
|
+
return `- ${id} | linkedTasks=${linkedTasks.length}`;
|
|
506
|
+
})
|
|
507
|
+
: ["- (none)"]),
|
|
508
|
+
"",
|
|
509
|
+
"## Agent Guidance",
|
|
510
|
+
"- Next: call `roadmap.get` with a roadmap ID to inspect references and related tasks.",
|
|
511
|
+
].join("\n");
|
|
512
|
+
return asText(markdown);
|
|
513
|
+
});
|
|
514
|
+
server.registerTool("roadmap.get", {
|
|
515
|
+
title: "Roadmap Get",
|
|
516
|
+
description: "Get one roadmap with related task and evidence locations for agent guidance",
|
|
517
|
+
inputSchema: {
|
|
518
|
+
projectPath: z.string(),
|
|
519
|
+
roadmapId: z.string(),
|
|
520
|
+
},
|
|
521
|
+
}, async ({ projectPath, roadmapId }) => {
|
|
522
|
+
if (!isValidRoadmapId(roadmapId)) {
|
|
523
|
+
return {
|
|
524
|
+
...asText(renderErrorMarkdown("roadmap.get", `Invalid roadmap ID format: ${roadmapId}`, ["- expected format: ROADMAP-0001", "- retry with a valid roadmap ID"])),
|
|
525
|
+
isError: true,
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
const governanceDir = await resolveGovernanceDir(projectPath);
|
|
529
|
+
const artifacts = await discoverGovernanceArtifacts(governanceDir);
|
|
530
|
+
const fileCandidates = candidateFilesFromArtifacts(artifacts);
|
|
531
|
+
const referenceLocations = (await Promise.all(fileCandidates.map((file) => findTextReferences(file, roadmapId)))).flat();
|
|
532
|
+
const { tasks } = await loadTasks(governanceDir);
|
|
533
|
+
const relatedTasks = tasks.filter((task) => task.roadmapRefs.includes(roadmapId));
|
|
534
|
+
const markdown = [
|
|
535
|
+
"# roadmap.get",
|
|
536
|
+
"",
|
|
537
|
+
"## Summary",
|
|
538
|
+
`- governanceDir: ${governanceDir}`,
|
|
539
|
+
`- roadmapId: ${roadmapId}`,
|
|
540
|
+
`- relatedTasks: ${relatedTasks.length}`,
|
|
541
|
+
`- references: ${referenceLocations.length}`,
|
|
542
|
+
"",
|
|
543
|
+
"## Evidence",
|
|
544
|
+
"### Related Tasks",
|
|
545
|
+
...(relatedTasks.length > 0
|
|
546
|
+
? relatedTasks.map((task) => `- ${task.id} | ${task.status} | ${task.title}`)
|
|
547
|
+
: ["- (none)"]),
|
|
548
|
+
"",
|
|
549
|
+
"### Reference Locations",
|
|
550
|
+
...(referenceLocations.length > 0
|
|
551
|
+
? referenceLocations.map((item) => `- ${item.filePath}#L${item.line}: ${item.text}`)
|
|
552
|
+
: ["- (none)"]),
|
|
553
|
+
"",
|
|
554
|
+
"## Agent Guidance",
|
|
555
|
+
"- Read roadmap references first, then related tasks.",
|
|
556
|
+
"- Keep ROADMAP/TASK IDs unchanged while updating markdown files.",
|
|
557
|
+
"- Re-run `roadmap.get` after edits to confirm references remain consistent.",
|
|
558
|
+
].join("\n");
|
|
559
|
+
return asText(markdown);
|
|
560
|
+
});
|
|
561
|
+
const transport = new StdioServerTransport();
|
|
562
|
+
await server.connect(transport);
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { catchIt } from "./helpers/catch/index.js";
|
|
4
|
+
export const PROJECT_MARKER = ".projitive";
|
|
5
|
+
const ignoreNames = new Set(["node_modules", ".git", ".next", "dist", "build"]);
|
|
6
|
+
export async function hasProjectMarker(dirPath) {
|
|
7
|
+
const markerPath = path.join(dirPath, PROJECT_MARKER);
|
|
8
|
+
const statResult = await catchIt(fs.stat(markerPath));
|
|
9
|
+
if (statResult.isError()) {
|
|
10
|
+
return false;
|
|
11
|
+
}
|
|
12
|
+
return statResult.value.isFile();
|
|
13
|
+
}
|
|
14
|
+
function parentDir(dirPath) {
|
|
15
|
+
const parent = path.dirname(dirPath);
|
|
16
|
+
return parent === dirPath ? null : parent;
|
|
17
|
+
}
|
|
18
|
+
export async function resolveGovernanceDir(inputPath) {
|
|
19
|
+
const absolutePath = path.resolve(inputPath);
|
|
20
|
+
const statResult = await catchIt(fs.stat(absolutePath));
|
|
21
|
+
if (statResult.isError()) {
|
|
22
|
+
throw new Error(`Path not found: ${absolutePath}`);
|
|
23
|
+
}
|
|
24
|
+
const stat = statResult.value;
|
|
25
|
+
let cursor = stat.isDirectory() ? absolutePath : path.dirname(absolutePath);
|
|
26
|
+
while (cursor) {
|
|
27
|
+
if (await hasProjectMarker(cursor)) {
|
|
28
|
+
return cursor;
|
|
29
|
+
}
|
|
30
|
+
cursor = parentDir(cursor);
|
|
31
|
+
}
|
|
32
|
+
throw new Error(`No ${PROJECT_MARKER} marker found from path: ${absolutePath}`);
|
|
33
|
+
}
|
|
34
|
+
export async function discoverProjects(rootPath, maxDepth) {
|
|
35
|
+
const results = [];
|
|
36
|
+
async function walk(currentPath, depth) {
|
|
37
|
+
if (depth > maxDepth) {
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
if (await hasProjectMarker(currentPath)) {
|
|
41
|
+
results.push(currentPath);
|
|
42
|
+
}
|
|
43
|
+
const entriesResult = await catchIt(fs.readdir(currentPath, { withFileTypes: true }));
|
|
44
|
+
if (entriesResult.isError()) {
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
const entries = entriesResult.value;
|
|
48
|
+
const folders = entries.filter((entry) => entry.isDirectory() && !ignoreNames.has(entry.name));
|
|
49
|
+
for (const folder of folders) {
|
|
50
|
+
await walk(path.join(currentPath, folder.name), depth + 1);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
await walk(rootPath, 0);
|
|
54
|
+
return Array.from(new Set(results)).sort();
|
|
55
|
+
}
|