@projitive/mcp 1.0.2 → 1.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 (40) hide show
  1. package/README.md +47 -23
  2. package/output/hooks.js +1 -14
  3. package/output/hooks.test.js +7 -18
  4. package/output/index.js +23 -5
  5. package/output/package.json +36 -0
  6. package/output/projitive.js +21 -2
  7. package/output/projitive.test.js +1 -0
  8. package/output/source/designs.js +38 -0
  9. package/output/source/helpers/artifacts/artifacts.js +10 -0
  10. package/output/source/helpers/artifacts/artifacts.test.js +18 -0
  11. package/output/source/helpers/artifacts/index.js +1 -0
  12. package/output/source/helpers/catch/catch.js +48 -0
  13. package/output/source/helpers/catch/catch.test.js +43 -0
  14. package/output/source/helpers/catch/index.js +1 -0
  15. package/output/source/helpers/files/files.js +62 -0
  16. package/output/source/helpers/files/files.test.js +32 -0
  17. package/output/source/helpers/files/index.js +1 -0
  18. package/output/source/helpers/index.js +6 -0
  19. package/output/source/helpers/linter/codes.js +25 -0
  20. package/output/source/helpers/linter/index.js +2 -0
  21. package/output/source/helpers/linter/linter.js +6 -0
  22. package/output/source/helpers/linter/linter.test.js +16 -0
  23. package/output/source/helpers/markdown/index.js +1 -0
  24. package/output/source/helpers/markdown/markdown.js +33 -0
  25. package/output/source/helpers/markdown/markdown.test.js +36 -0
  26. package/output/source/helpers/response/index.js +1 -0
  27. package/output/source/helpers/response/response.js +73 -0
  28. package/output/source/helpers/response/response.test.js +50 -0
  29. package/output/source/index.js +215 -0
  30. package/output/source/projitive.js +497 -0
  31. package/output/source/projitive.test.js +75 -0
  32. package/output/source/readme.js +26 -0
  33. package/output/source/reports.js +36 -0
  34. package/output/source/roadmap.js +165 -0
  35. package/output/source/roadmap.test.js +11 -0
  36. package/output/source/tasks.js +762 -0
  37. package/output/source/tasks.test.js +152 -0
  38. package/output/tasks.js +100 -80
  39. package/output/tasks.test.js +32 -8
  40. package/package.json +1 -1
@@ -0,0 +1,497 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import process from "node:process";
4
+ import { z } from "zod";
5
+ import { discoverGovernanceArtifacts } from "./helpers/files/index.js";
6
+ import { catchIt } from "./helpers/catch/index.js";
7
+ import { PROJECT_LINT_CODES, renderLintSuggestions } from "./helpers/linter/index.js";
8
+ import { asText, evidenceSection, guidanceSection, lintSection, nextCallSection, renderToolResponseMarkdown, summarySection, } from "./helpers/response/index.js";
9
+ import { collectTaskLintSuggestions, loadTasksDocument } from "./tasks.js";
10
+ export const PROJECT_MARKER = ".projitive";
11
+ const DEFAULT_GOVERNANCE_DIR = ".projitive";
12
+ const ignoreNames = new Set(["node_modules", ".git", ".next", "dist", "build"]);
13
+ const DEFAULT_SCAN_DEPTH = 3;
14
+ const MAX_SCAN_DEPTH = 8;
15
+ function normalizePath(inputPath) {
16
+ return inputPath ? path.resolve(inputPath) : process.cwd();
17
+ }
18
+ function normalizeGovernanceDirName(input) {
19
+ const name = input?.trim() || DEFAULT_GOVERNANCE_DIR;
20
+ if (!name) {
21
+ throw new Error("governanceDir cannot be empty");
22
+ }
23
+ if (path.isAbsolute(name)) {
24
+ throw new Error("governanceDir must be a relative directory name");
25
+ }
26
+ if (name.includes("/") || name.includes("\\")) {
27
+ throw new Error("governanceDir must not contain path separators");
28
+ }
29
+ if (name === "." || name === "..") {
30
+ throw new Error("governanceDir must be a normal directory name");
31
+ }
32
+ return name;
33
+ }
34
+ function parseDepthFromEnv(rawDepth) {
35
+ if (typeof rawDepth !== "string" || rawDepth.trim().length === 0) {
36
+ return undefined;
37
+ }
38
+ const parsed = Number.parseInt(rawDepth, 10);
39
+ if (!Number.isFinite(parsed)) {
40
+ return undefined;
41
+ }
42
+ return Math.min(MAX_SCAN_DEPTH, Math.max(0, parsed));
43
+ }
44
+ function requireEnvVar(name) {
45
+ const value = process.env[name];
46
+ if (typeof value !== "string" || value.trim().length === 0) {
47
+ throw new Error(`Missing required environment variable: ${name}`);
48
+ }
49
+ return value.trim();
50
+ }
51
+ export function resolveScanRoot(inputPath) {
52
+ const configuredRoot = requireEnvVar("PROJITIVE_SCAN_ROOT_PATH");
53
+ return normalizePath(inputPath ?? configuredRoot);
54
+ }
55
+ export function resolveScanDepth(inputDepth) {
56
+ const configuredDepthRaw = requireEnvVar("PROJITIVE_SCAN_MAX_DEPTH");
57
+ const configuredDepth = parseDepthFromEnv(configuredDepthRaw);
58
+ if (typeof configuredDepth !== "number") {
59
+ throw new Error("Invalid PROJITIVE_SCAN_MAX_DEPTH: expected integer in range 0-8");
60
+ }
61
+ if (typeof inputDepth === "number") {
62
+ return inputDepth;
63
+ }
64
+ return configuredDepth;
65
+ }
66
+ function renderArtifactsMarkdown(artifacts) {
67
+ const rows = artifacts.map((item) => {
68
+ if (item.kind === "file") {
69
+ const lineText = item.lineCount == null ? "-" : String(item.lineCount);
70
+ return `- ${item.exists ? "✅" : "❌"} ${item.name} \n path: ${item.path} \n lineCount: ${lineText}`;
71
+ }
72
+ const nested = (item.markdownFiles ?? [])
73
+ .map((entry) => ` - ${entry.path} (lines: ${entry.lineCount})`)
74
+ .join("\n");
75
+ return `- ${item.exists ? "✅" : "❌"} ${item.name}/ \n path: ${item.path}${nested ? `\n markdownFiles:\n${nested}` : ""}`;
76
+ });
77
+ return rows.join("\n");
78
+ }
79
+ async function readTasksSnapshot(governanceDir) {
80
+ const tasksPath = path.join(governanceDir, "tasks.md");
81
+ const markdown = await fs.readFile(tasksPath, "utf-8").catch(() => undefined);
82
+ if (typeof markdown !== "string") {
83
+ return {
84
+ tasksPath,
85
+ exists: false,
86
+ tasks: [],
87
+ lintSuggestions: renderLintSuggestions([
88
+ {
89
+ code: PROJECT_LINT_CODES.TASKS_FILE_MISSING,
90
+ message: "tasks.md is missing.",
91
+ fixHint: "Initialize governance tasks structure first.",
92
+ },
93
+ ]),
94
+ };
95
+ }
96
+ const { parseTasksBlock } = await import("./tasks.js");
97
+ const tasks = await parseTasksBlock(markdown);
98
+ return { tasksPath, exists: true, tasks, lintSuggestions: collectTaskLintSuggestions(tasks, markdown) };
99
+ }
100
+ function latestTaskUpdatedAt(tasks) {
101
+ const timestamps = tasks
102
+ .map((task) => new Date(task.updatedAt).getTime())
103
+ .filter((value) => Number.isFinite(value));
104
+ if (timestamps.length === 0) {
105
+ return "(unknown)";
106
+ }
107
+ return new Date(Math.max(...timestamps)).toISOString();
108
+ }
109
+ function actionableScore(tasks) {
110
+ return tasks.filter((task) => task.status === "IN_PROGRESS").length * 2
111
+ + tasks.filter((task) => task.status === "TODO").length;
112
+ }
113
+ async function readRoadmapIds(governanceDir) {
114
+ const roadmapPath = path.join(governanceDir, "roadmap.md");
115
+ try {
116
+ const markdown = await fs.readFile(roadmapPath, "utf-8");
117
+ const matches = markdown.match(/ROADMAP-\d{4}/g) ?? [];
118
+ return Array.from(new Set(matches));
119
+ }
120
+ catch {
121
+ return [];
122
+ }
123
+ }
124
+ export async function hasProjectMarker(dirPath) {
125
+ const markerPath = path.join(dirPath, PROJECT_MARKER);
126
+ const statResult = await catchIt(fs.stat(markerPath));
127
+ if (statResult.isError()) {
128
+ return false;
129
+ }
130
+ return statResult.value.isFile();
131
+ }
132
+ function parentDir(dirPath) {
133
+ const parent = path.dirname(dirPath);
134
+ return parent === dirPath ? null : parent;
135
+ }
136
+ export async function resolveGovernanceDir(inputPath) {
137
+ const absolutePath = path.resolve(inputPath);
138
+ const statResult = await catchIt(fs.stat(absolutePath));
139
+ if (statResult.isError()) {
140
+ throw new Error(`Path not found: ${absolutePath}`);
141
+ }
142
+ const stat = statResult.value;
143
+ let cursor = stat.isDirectory() ? absolutePath : path.dirname(absolutePath);
144
+ while (cursor) {
145
+ if (await hasProjectMarker(cursor)) {
146
+ return cursor;
147
+ }
148
+ cursor = parentDir(cursor);
149
+ }
150
+ throw new Error(`No ${PROJECT_MARKER} marker found from path: ${absolutePath}`);
151
+ }
152
+ export async function discoverProjects(rootPath, maxDepth) {
153
+ const results = [];
154
+ async function walk(currentPath, depth) {
155
+ if (depth > maxDepth) {
156
+ return;
157
+ }
158
+ if (await hasProjectMarker(currentPath)) {
159
+ results.push(currentPath);
160
+ }
161
+ const entriesResult = await catchIt(fs.readdir(currentPath, { withFileTypes: true }));
162
+ if (entriesResult.isError()) {
163
+ return;
164
+ }
165
+ const entries = entriesResult.value;
166
+ const folders = entries.filter((entry) => entry.isDirectory() && !ignoreNames.has(entry.name));
167
+ for (const folder of folders) {
168
+ await walk(path.join(currentPath, folder.name), depth + 1);
169
+ }
170
+ }
171
+ await walk(rootPath, 0);
172
+ return Array.from(new Set(results)).sort();
173
+ }
174
+ async function pathExists(targetPath) {
175
+ const accessResult = await catchIt(fs.access(targetPath));
176
+ return !accessResult.isError();
177
+ }
178
+ async function writeTextFile(targetPath, content, force) {
179
+ const exists = await pathExists(targetPath);
180
+ if (exists && !force) {
181
+ return { path: targetPath, action: "skipped" };
182
+ }
183
+ await fs.writeFile(targetPath, content, "utf-8");
184
+ return { path: targetPath, action: exists ? "updated" : "created" };
185
+ }
186
+ function defaultReadmeMarkdown(governanceDirName) {
187
+ return [
188
+ "# Projitive Governance Workspace",
189
+ "",
190
+ `This directory (\`${governanceDirName}/\`) is the governance root for this project.`,
191
+ "",
192
+ "## Conventions",
193
+ "- Keep roadmap/task/design/report files in markdown.",
194
+ "- Keep IDs stable (TASK-xxxx / ROADMAP-xxxx).",
195
+ "- Update report evidence before status transitions.",
196
+ ].join("\n");
197
+ }
198
+ function defaultRoadmapMarkdown() {
199
+ return [
200
+ "# Roadmap",
201
+ "",
202
+ "## Active Milestones",
203
+ "- [ ] ROADMAP-0001: Bootstrap governance baseline (time: 2026-Q1)",
204
+ ].join("\n");
205
+ }
206
+ function defaultTasksMarkdown() {
207
+ const updatedAt = new Date().toISOString();
208
+ return [
209
+ "# Tasks",
210
+ "",
211
+ "<!-- PROJITIVE:TASKS:START -->",
212
+ "## TASK-0001 | TODO | Bootstrap governance workspace",
213
+ "- owner: unassigned",
214
+ "- summary: Create initial governance artifacts and confirm task execution loop.",
215
+ `- updatedAt: ${updatedAt}`,
216
+ "- roadmapRefs: ROADMAP-0001",
217
+ "- links:",
218
+ " - (none)",
219
+ "<!-- PROJITIVE:TASKS:END -->",
220
+ ].join("\n");
221
+ }
222
+ function defaultNoTaskDiscoveryHookMarkdown() {
223
+ return [
224
+ "Objective:",
225
+ "- When no actionable task exists, proactively discover meaningful work and convert it into TODO tasks.",
226
+ "",
227
+ "Checklist:",
228
+ "- Check whether code violates project guides/specs; create tasks for each actionable gap.",
229
+ "- Check test coverage improvement opportunities; create tasks for high-value missing tests.",
230
+ "- Check development/testing workflow bottlenecks; create tasks for reliability and speed improvements.",
231
+ "- Check TODO/FIXME/HACK comments; turn feasible items into governed tasks.",
232
+ "- Check dependency/security hygiene and stale tooling; create tasks where upgrades are justified.",
233
+ "",
234
+ "Output Format:",
235
+ "- Candidate findings (3-10)",
236
+ "- Proposed tasks (TASK-xxxx style)",
237
+ "- Priority rationale",
238
+ ].join("\n");
239
+ }
240
+ export async function initializeProjectStructure(inputPath, governanceDir, force = false) {
241
+ const projectPath = normalizePath(inputPath);
242
+ const governanceDirName = normalizeGovernanceDirName(governanceDir);
243
+ const rootStat = await catchIt(fs.stat(projectPath));
244
+ if (rootStat.isError()) {
245
+ throw new Error(`Path not found: ${projectPath}`);
246
+ }
247
+ if (!rootStat.value.isDirectory()) {
248
+ throw new Error(`projectPath must be a directory: ${projectPath}`);
249
+ }
250
+ const governancePath = path.join(projectPath, governanceDirName);
251
+ const directories = [];
252
+ const requiredDirectories = [governancePath, path.join(governancePath, "designs"), path.join(governancePath, "reports"), path.join(governancePath, "hooks")];
253
+ for (const dirPath of requiredDirectories) {
254
+ const exists = await pathExists(dirPath);
255
+ await fs.mkdir(dirPath, { recursive: true });
256
+ directories.push({ path: dirPath, action: exists ? "skipped" : "created" });
257
+ }
258
+ const markerPath = path.join(governancePath, PROJECT_MARKER);
259
+ const files = await Promise.all([
260
+ writeTextFile(markerPath, "", force),
261
+ writeTextFile(path.join(governancePath, "README.md"), defaultReadmeMarkdown(governanceDirName), force),
262
+ writeTextFile(path.join(governancePath, "roadmap.md"), defaultRoadmapMarkdown(), force),
263
+ writeTextFile(path.join(governancePath, "tasks.md"), defaultTasksMarkdown(), force),
264
+ writeTextFile(path.join(governancePath, "hooks", "task_no_actionable.md"), defaultNoTaskDiscoveryHookMarkdown(), force),
265
+ ]);
266
+ return {
267
+ projectPath,
268
+ governanceDir: governancePath,
269
+ markerPath,
270
+ directories,
271
+ files,
272
+ };
273
+ }
274
+ export function registerProjectTools(server) {
275
+ server.registerTool("projectInit", {
276
+ title: "Project Init",
277
+ description: "Initialize Projitive governance directory structure manually (default .projitive)",
278
+ inputSchema: {
279
+ projectPath: z.string().optional(),
280
+ governanceDir: z.string().optional(),
281
+ force: z.boolean().optional(),
282
+ },
283
+ }, async ({ projectPath, governanceDir, force }) => {
284
+ const initialized = await initializeProjectStructure(projectPath, governanceDir, force ?? false);
285
+ const filesByAction = {
286
+ created: initialized.files.filter((item) => item.action === "created"),
287
+ updated: initialized.files.filter((item) => item.action === "updated"),
288
+ skipped: initialized.files.filter((item) => item.action === "skipped"),
289
+ };
290
+ const markdown = renderToolResponseMarkdown({
291
+ toolName: "projectInit",
292
+ sections: [
293
+ summarySection([
294
+ `- projectPath: ${initialized.projectPath}`,
295
+ `- governanceDir: ${initialized.governanceDir}`,
296
+ `- markerPath: ${initialized.markerPath}`,
297
+ `- force: ${force === true ? "true" : "false"}`,
298
+ ]),
299
+ evidenceSection([
300
+ `- createdFiles: ${filesByAction.created.length}`,
301
+ `- updatedFiles: ${filesByAction.updated.length}`,
302
+ `- skippedFiles: ${filesByAction.skipped.length}`,
303
+ "- directories:",
304
+ ...initialized.directories.map((item) => ` - ${item.action}: ${item.path}`),
305
+ "- files:",
306
+ ...initialized.files.map((item) => ` - ${item.action}: ${item.path}`),
307
+ ]),
308
+ guidanceSection([
309
+ "- If files were skipped and you want to overwrite templates, rerun with force=true.",
310
+ "- Continue with projectContext and taskList for execution.",
311
+ ]),
312
+ lintSection([
313
+ "- After init, fill owner/roadmapRefs/links in tasks.md before marking DONE.",
314
+ "- Keep task source-of-truth inside marker block only.",
315
+ ]),
316
+ nextCallSection(`projectContext(projectPath=\"${initialized.governanceDir}\")`),
317
+ ],
318
+ });
319
+ return asText(markdown);
320
+ });
321
+ server.registerTool("projectScan", {
322
+ title: "Project Scan",
323
+ description: "Scan filesystem and discover project governance roots marked by .projitive",
324
+ inputSchema: {},
325
+ }, async () => {
326
+ const root = resolveScanRoot();
327
+ const depth = resolveScanDepth();
328
+ const projects = await discoverProjects(root, depth);
329
+ const markdown = renderToolResponseMarkdown({
330
+ toolName: "projectScan",
331
+ sections: [
332
+ summarySection([
333
+ `- rootPath: ${root}`,
334
+ `- maxDepth: ${depth}`,
335
+ `- discoveredCount: ${projects.length}`,
336
+ ]),
337
+ evidenceSection([
338
+ "- projects:",
339
+ ...projects.map((project, index) => `${index + 1}. ${project}`),
340
+ ]),
341
+ guidanceSection([
342
+ "- Use one discovered project path and call `projectLocate` to lock governance root.",
343
+ "- Then call `projectContext` to inspect current governance state.",
344
+ ]),
345
+ lintSection(projects.length === 0
346
+ ? ["- No governance root discovered. Add `.projitive` marker and baseline artifacts before execution."]
347
+ : ["- Run `projectContext` on a discovered project to receive module-level lint suggestions."]),
348
+ nextCallSection(projects[0]
349
+ ? `projectLocate(inputPath=\"${projects[0]}\")`
350
+ : undefined),
351
+ ],
352
+ });
353
+ return asText(markdown);
354
+ });
355
+ server.registerTool("projectNext", {
356
+ title: "Project Next",
357
+ description: "Directly list recently actionable projects for immediate agent progression",
358
+ inputSchema: {
359
+ rootPath: z.string().optional(),
360
+ maxDepth: z.number().int().min(0).max(8).optional(),
361
+ limit: z.number().int().min(1).max(50).optional(),
362
+ },
363
+ }, async ({ rootPath, maxDepth, limit }) => {
364
+ const root = resolveScanRoot(rootPath);
365
+ const depth = resolveScanDepth(maxDepth);
366
+ const projects = await discoverProjects(root, depth);
367
+ const snapshots = await Promise.all(projects.map(async (governanceDir) => {
368
+ const snapshot = await readTasksSnapshot(governanceDir);
369
+ const inProgress = snapshot.tasks.filter((task) => task.status === "IN_PROGRESS").length;
370
+ const todo = snapshot.tasks.filter((task) => task.status === "TODO").length;
371
+ const blocked = snapshot.tasks.filter((task) => task.status === "BLOCKED").length;
372
+ const done = snapshot.tasks.filter((task) => task.status === "DONE").length;
373
+ const actionable = inProgress + todo;
374
+ return {
375
+ governanceDir,
376
+ tasksPath: snapshot.tasksPath,
377
+ tasksExists: snapshot.exists,
378
+ lintSuggestions: snapshot.lintSuggestions,
379
+ inProgress,
380
+ todo,
381
+ blocked,
382
+ done,
383
+ actionable,
384
+ latestUpdatedAt: latestTaskUpdatedAt(snapshot.tasks),
385
+ score: actionableScore(snapshot.tasks),
386
+ };
387
+ }));
388
+ const ranked = snapshots
389
+ .filter((item) => item.actionable > 0)
390
+ .sort((a, b) => {
391
+ if (b.score !== a.score) {
392
+ return b.score - a.score;
393
+ }
394
+ return b.latestUpdatedAt.localeCompare(a.latestUpdatedAt);
395
+ })
396
+ .slice(0, limit ?? 10);
397
+ const markdown = renderToolResponseMarkdown({
398
+ toolName: "projectNext",
399
+ sections: [
400
+ summarySection([
401
+ `- rootPath: ${root}`,
402
+ `- maxDepth: ${depth}`,
403
+ `- matchedProjects: ${projects.length}`,
404
+ `- actionableProjects: ${ranked.length}`,
405
+ `- limit: ${limit ?? 10}`,
406
+ ]),
407
+ evidenceSection([
408
+ "- rankedProjects:",
409
+ ...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)"}`),
410
+ ]),
411
+ guidanceSection([
412
+ "- Pick top 1 project and call `projectContext` with its governanceDir.",
413
+ "- Then call `taskList` and `taskContext` to continue execution.",
414
+ "- If `tasksPath` is missing, create tasks.md using project convention before task-level operations.",
415
+ ]),
416
+ lintSection(ranked[0]?.lintSuggestions ?? []),
417
+ nextCallSection(ranked[0]
418
+ ? `projectContext(projectPath=\"${ranked[0].governanceDir}\")`
419
+ : undefined),
420
+ ],
421
+ });
422
+ return asText(markdown);
423
+ });
424
+ server.registerTool("projectLocate", {
425
+ title: "Project Locate",
426
+ description: "Resolve current project governance root from an in-project path by finding the nearest .projitive marker",
427
+ inputSchema: {
428
+ inputPath: z.string(),
429
+ },
430
+ }, async ({ inputPath }) => {
431
+ const resolvedFrom = normalizePath(inputPath);
432
+ const governanceDir = await resolveGovernanceDir(resolvedFrom);
433
+ const markerPath = path.join(governanceDir, ".projitive");
434
+ const markdown = renderToolResponseMarkdown({
435
+ toolName: "projectLocate",
436
+ sections: [
437
+ summarySection([
438
+ `- resolvedFrom: ${resolvedFrom}`,
439
+ `- governanceDir: ${governanceDir}`,
440
+ `- markerPath: ${markerPath}`,
441
+ ]),
442
+ guidanceSection(["- Call `projectContext` with this governanceDir to get task and roadmap summaries."]),
443
+ lintSection(["- Run `projectContext` to get governance/module lint suggestions for this project."]),
444
+ nextCallSection(`projectContext(projectPath=\"${governanceDir}\")`),
445
+ ],
446
+ });
447
+ return asText(markdown);
448
+ });
449
+ server.registerTool("projectContext", {
450
+ title: "Project Context",
451
+ description: "Summarize project governance context for task execution planning",
452
+ inputSchema: {
453
+ projectPath: z.string(),
454
+ },
455
+ }, async ({ projectPath }) => {
456
+ const governanceDir = await resolveGovernanceDir(projectPath);
457
+ const artifacts = await discoverGovernanceArtifacts(governanceDir);
458
+ const { tasksPath, tasks, markdown: tasksMarkdown } = await loadTasksDocument(governanceDir);
459
+ const roadmapIds = await readRoadmapIds(governanceDir);
460
+ const lintSuggestions = collectTaskLintSuggestions(tasks, tasksMarkdown);
461
+ const taskSummary = {
462
+ total: tasks.length,
463
+ TODO: tasks.filter((task) => task.status === "TODO").length,
464
+ IN_PROGRESS: tasks.filter((task) => task.status === "IN_PROGRESS").length,
465
+ BLOCKED: tasks.filter((task) => task.status === "BLOCKED").length,
466
+ DONE: tasks.filter((task) => task.status === "DONE").length,
467
+ };
468
+ const markdown = renderToolResponseMarkdown({
469
+ toolName: "projectContext",
470
+ sections: [
471
+ summarySection([
472
+ `- governanceDir: ${governanceDir}`,
473
+ `- tasksFile: ${tasksPath}`,
474
+ `- roadmapIds: ${roadmapIds.length}`,
475
+ ]),
476
+ evidenceSection([
477
+ "### Task Summary",
478
+ `- total: ${taskSummary.total}`,
479
+ `- TODO: ${taskSummary.TODO}`,
480
+ `- IN_PROGRESS: ${taskSummary.IN_PROGRESS}`,
481
+ `- BLOCKED: ${taskSummary.BLOCKED}`,
482
+ `- DONE: ${taskSummary.DONE}`,
483
+ "",
484
+ "### Artifacts",
485
+ renderArtifactsMarkdown(artifacts),
486
+ ]),
487
+ guidanceSection([
488
+ "- Start from `taskList` to choose a target task.",
489
+ "- Then call `taskContext` with a task ID to retrieve evidence locations and reading order.",
490
+ ]),
491
+ lintSection(lintSuggestions),
492
+ nextCallSection(`taskList(projectPath=\"${governanceDir}\")`),
493
+ ],
494
+ });
495
+ return asText(markdown);
496
+ });
497
+ }
@@ -0,0 +1,75 @@
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 { discoverProjects, hasProjectMarker, initializeProjectStructure, resolveGovernanceDir } from "./projitive.js";
6
+ const tempPaths = [];
7
+ async function createTempDir() {
8
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), "projitive-mcp-test-"));
9
+ tempPaths.push(dir);
10
+ return dir;
11
+ }
12
+ afterEach(async () => {
13
+ await Promise.all(tempPaths.splice(0).map(async (dir) => {
14
+ await fs.rm(dir, { recursive: true, force: true });
15
+ }));
16
+ });
17
+ describe("projitive module", () => {
18
+ it("does not treat marker directory as a valid project marker", async () => {
19
+ const root = await createTempDir();
20
+ const dirMarkerPath = path.join(root, ".projitive");
21
+ await fs.mkdir(dirMarkerPath, { recursive: true });
22
+ const hasMarker = await hasProjectMarker(root);
23
+ expect(hasMarker).toBe(false);
24
+ });
25
+ it("resolves governance dir by walking upwards for .projitive", async () => {
26
+ const root = await createTempDir();
27
+ const governanceDir = path.join(root, "repo", "governance");
28
+ const deepDir = path.join(governanceDir, "nested", "module");
29
+ await fs.mkdir(deepDir, { recursive: true });
30
+ await fs.writeFile(path.join(governanceDir, ".projitive"), "", "utf-8");
31
+ const resolved = await resolveGovernanceDir(deepDir);
32
+ expect(resolved).toBe(governanceDir);
33
+ });
34
+ it("discovers projects by marker file", async () => {
35
+ const root = await createTempDir();
36
+ const p1 = path.join(root, "a");
37
+ const p2 = path.join(root, "b", "c");
38
+ await fs.mkdir(p1, { recursive: true });
39
+ await fs.mkdir(p2, { recursive: true });
40
+ await fs.writeFile(path.join(p1, ".projitive"), "", "utf-8");
41
+ await fs.writeFile(path.join(p2, ".projitive"), "", "utf-8");
42
+ const projects = await discoverProjects(root, 4);
43
+ expect(projects).toContain(p1);
44
+ expect(projects).toContain(p2);
45
+ });
46
+ it("initializes governance structure under default .projitive directory", async () => {
47
+ const root = await createTempDir();
48
+ const initialized = await initializeProjectStructure(root);
49
+ expect(initialized.governanceDir).toBe(path.join(root, ".projitive"));
50
+ const expectedPaths = [
51
+ path.join(root, ".projitive", ".projitive"),
52
+ path.join(root, ".projitive", "README.md"),
53
+ path.join(root, ".projitive", "roadmap.md"),
54
+ path.join(root, ".projitive", "tasks.md"),
55
+ path.join(root, ".projitive", "hooks", "task_no_actionable.md"),
56
+ path.join(root, ".projitive", "designs"),
57
+ path.join(root, ".projitive", "reports"),
58
+ path.join(root, ".projitive", "hooks"),
59
+ ];
60
+ await Promise.all(expectedPaths.map(async (targetPath) => {
61
+ await expect(fs.access(targetPath)).resolves.toBeUndefined();
62
+ }));
63
+ });
64
+ it("overwrites template files when force is enabled", async () => {
65
+ const root = await createTempDir();
66
+ const governanceDir = path.join(root, ".projitive");
67
+ const readmePath = path.join(governanceDir, "README.md");
68
+ await initializeProjectStructure(root);
69
+ await fs.writeFile(readmePath, "custom-content", "utf-8");
70
+ const initialized = await initializeProjectStructure(root, ".projitive", true);
71
+ const readmeContent = await fs.readFile(readmePath, "utf-8");
72
+ expect(readmeContent).toContain("Projitive Governance Workspace");
73
+ expect(initialized.files.find((item) => item.path === readmePath)?.action).toBe("updated");
74
+ });
75
+ });
@@ -0,0 +1,26 @@
1
+ export function parseRequiredReading(markdown) {
2
+ const lines = markdown.split(/\r?\n/);
3
+ const result = [];
4
+ let inSection = false;
5
+ for (const line of lines) {
6
+ const trimmed = line.trim();
7
+ if (/^##\s+(Required Reading for Agents|Agent 必读)$/i.test(trimmed)) {
8
+ inSection = true;
9
+ continue;
10
+ }
11
+ if (inSection && trimmed.startsWith("## ")) {
12
+ break;
13
+ }
14
+ if (!inSection || !trimmed.startsWith("- ")) {
15
+ continue;
16
+ }
17
+ const payload = trimmed.replace(/^-\s+/, "");
18
+ if (payload.startsWith("Local:")) {
19
+ result.push({ source: "Local", value: payload.replace("Local:", "").trim() });
20
+ }
21
+ if (payload.startsWith("External:")) {
22
+ result.push({ source: "External", value: payload.replace("External:", "").trim() });
23
+ }
24
+ }
25
+ return result;
26
+ }
@@ -0,0 +1,36 @@
1
+ import { isValidRoadmapId } from "./roadmap.js";
2
+ import { isValidTaskId } from "./tasks.js";
3
+ export function parseReportMetadata(markdown) {
4
+ const lines = markdown.split(/\r?\n/);
5
+ const metadata = {};
6
+ for (const line of lines) {
7
+ const [rawKey, ...rawValue] = line.split(":");
8
+ if (!rawKey || rawValue.length === 0) {
9
+ continue;
10
+ }
11
+ const key = rawKey.trim().toLowerCase();
12
+ const value = rawValue.join(":").trim();
13
+ if (key === "task")
14
+ metadata.task = value;
15
+ if (key === "roadmap")
16
+ metadata.roadmap = value;
17
+ if (key === "owner")
18
+ metadata.owner = value;
19
+ if (key === "date")
20
+ metadata.date = value;
21
+ }
22
+ return metadata;
23
+ }
24
+ export function validateReportMetadata(metadata) {
25
+ const errors = [];
26
+ if (!metadata.task) {
27
+ errors.push("Missing Task metadata");
28
+ }
29
+ else if (!isValidTaskId(metadata.task)) {
30
+ errors.push(`Invalid Task metadata format: ${metadata.task}`);
31
+ }
32
+ if (metadata.roadmap && !isValidRoadmapId(metadata.roadmap)) {
33
+ errors.push(`Invalid Roadmap metadata format: ${metadata.roadmap}`);
34
+ }
35
+ return { ok: errors.length === 0, errors };
36
+ }