@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.
@@ -2,9 +2,10 @@ import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import process from "node:process";
4
4
  import { z } from "zod";
5
- import { discoverGovernanceArtifacts, catchIt, PROJECT_LINT_CODES, renderLintSuggestions } from "../common/index.js";
6
- import { asText, evidenceSection, guidanceSection, lintSection, nextCallSection, renderToolResponseMarkdown, summarySection, } from "../common/index.js";
7
- import { collectTaskLintSuggestions, loadTasksDocument } from "./task.js";
5
+ import { discoverGovernanceArtifacts, catchIt, PROJECT_LINT_CODES, renderLintSuggestions, ensureStore, replaceRoadmapsInStore, replaceTasksInStore, markMarkdownViewDirty, loadTaskStatusStatsFromStore, loadRoadmapIdsFromStore, } from "../common/index.js";
6
+ import { asText, evidenceSection, getDefaultToolTemplateMarkdown, guidanceSection, lintSection, nextCallSection, renderToolResponseMarkdown, summarySection, } from "../common/index.js";
7
+ import { collectTaskLintSuggestions, loadTasksDocument, loadTasksDocumentWithOptions, renderTasksMarkdown } from "./task.js";
8
+ import { loadRoadmapDocumentWithOptions, renderRoadmapMarkdown } from "./roadmap.js";
8
9
  export const PROJECT_MARKER = ".projitive";
9
10
  const DEFAULT_GOVERNANCE_DIR = ".projitive";
10
11
  const ignoreNames = new Set(["node_modules", ".git", ".next", "dist", "build"]);
@@ -46,9 +47,43 @@ function requireEnvVar(name) {
46
47
  }
47
48
  return value.trim();
48
49
  }
50
+ function normalizeScanRoots(rootPaths) {
51
+ const normalized = rootPaths
52
+ .map((entry) => entry.trim())
53
+ .filter((entry) => entry.length > 0)
54
+ .map((entry) => normalizePath(entry));
55
+ return Array.from(new Set(normalized));
56
+ }
57
+ function parseScanRoots(rawValue) {
58
+ const trimmed = rawValue.trim();
59
+ if (trimmed.length === 0) {
60
+ return [];
61
+ }
62
+ return trimmed.split(path.delimiter);
63
+ }
64
+ export function resolveScanRoots(inputPaths) {
65
+ const normalizedInputPaths = normalizeScanRoots(inputPaths ?? []);
66
+ if (normalizedInputPaths.length > 0) {
67
+ return normalizedInputPaths;
68
+ }
69
+ const configuredRoots = process.env.PROJITIVE_SCAN_ROOT_PATHS;
70
+ const rootsFromMultiEnv = typeof configuredRoots === "string"
71
+ ? normalizeScanRoots(parseScanRoots(configuredRoots))
72
+ : [];
73
+ if (rootsFromMultiEnv.length > 0) {
74
+ return rootsFromMultiEnv;
75
+ }
76
+ const legacyRoot = process.env.PROJITIVE_SCAN_ROOT_PATH;
77
+ const rootsFromLegacyEnv = typeof legacyRoot === "string"
78
+ ? normalizeScanRoots([legacyRoot])
79
+ : [];
80
+ if (rootsFromLegacyEnv.length > 0) {
81
+ return rootsFromLegacyEnv;
82
+ }
83
+ throw new Error("Missing required environment variable: PROJITIVE_SCAN_ROOT_PATHS (or legacy PROJITIVE_SCAN_ROOT_PATH)");
84
+ }
49
85
  export function resolveScanRoot(inputPath) {
50
- const configuredRoot = requireEnvVar("PROJITIVE_SCAN_ROOT_PATH");
51
- return normalizePath(inputPath ?? configuredRoot);
86
+ return resolveScanRoots(inputPath ? [inputPath] : undefined)[0];
52
87
  }
53
88
  export function resolveScanDepth(inputDepth) {
54
89
  const configuredDepthRaw = requireEnvVar("PROJITIVE_SCAN_MAX_DEPTH");
@@ -75,49 +110,45 @@ function renderArtifactsMarkdown(artifacts) {
75
110
  return rows.join("\n");
76
111
  }
77
112
  async function readTasksSnapshot(governanceDir) {
78
- const tasksPath = path.join(governanceDir, "tasks.md");
79
- const markdown = await fs.readFile(tasksPath, "utf-8").catch(() => undefined);
80
- if (typeof markdown !== "string") {
113
+ const tasksPath = path.join(governanceDir, PROJECT_MARKER);
114
+ const exists = await fs.access(tasksPath).then(() => true).catch(() => false);
115
+ if (!exists) {
81
116
  return {
82
- tasksPath,
83
117
  exists: false,
84
- tasks: [],
85
118
  lintSuggestions: renderLintSuggestions([
86
119
  {
87
120
  code: PROJECT_LINT_CODES.TASKS_FILE_MISSING,
88
- message: "tasks.md is missing.",
121
+ message: "governance store is missing.",
89
122
  fixHint: "Initialize governance tasks structure first.",
90
123
  },
91
124
  ]),
125
+ todo: 0,
126
+ inProgress: 0,
127
+ blocked: 0,
128
+ done: 0,
129
+ total: 0,
130
+ latestUpdatedAt: "(unknown)",
131
+ score: 0,
92
132
  };
93
133
  }
94
- const { parseTasksBlock } = await import("./task.js");
95
- const tasks = await parseTasksBlock(markdown);
96
- return { tasksPath, exists: true, tasks, lintSuggestions: collectTaskLintSuggestions(tasks, markdown) };
97
- }
98
- function latestTaskUpdatedAt(tasks) {
99
- const timestamps = tasks
100
- .map((task) => new Date(task.updatedAt).getTime())
101
- .filter((value) => Number.isFinite(value));
102
- if (timestamps.length === 0) {
103
- return "(unknown)";
104
- }
105
- return new Date(Math.max(...timestamps)).toISOString();
106
- }
107
- function actionableScore(tasks) {
108
- return tasks.filter((task) => task.status === "IN_PROGRESS").length * 2
109
- + tasks.filter((task) => task.status === "TODO").length;
134
+ await ensureStore(tasksPath);
135
+ const stats = await loadTaskStatusStatsFromStore(tasksPath);
136
+ return {
137
+ exists: true,
138
+ lintSuggestions: [],
139
+ todo: stats.todo,
140
+ inProgress: stats.inProgress,
141
+ blocked: stats.blocked,
142
+ done: stats.done,
143
+ total: stats.total,
144
+ latestUpdatedAt: stats.latestUpdatedAt || "(unknown)",
145
+ score: stats.inProgress * 2 + stats.todo,
146
+ };
110
147
  }
111
148
  async function readRoadmapIds(governanceDir) {
112
- const roadmapPath = path.join(governanceDir, "roadmap.md");
113
- try {
114
- const markdown = await fs.readFile(roadmapPath, "utf-8");
115
- const matches = markdown.match(/ROADMAP-\d{4}/g) ?? [];
116
- return Array.from(new Set(matches));
117
- }
118
- catch {
119
- return [];
120
- }
149
+ const dbPath = path.join(governanceDir, PROJECT_MARKER);
150
+ await ensureStore(dbPath);
151
+ return loadRoadmapIdsFromStore(dbPath);
121
152
  }
122
153
  export async function hasProjectMarker(dirPath) {
123
154
  const markerPath = path.join(dirPath, PROJECT_MARKER);
@@ -210,6 +241,25 @@ export async function discoverProjects(rootPath, maxDepth) {
210
241
  await walk(rootPath, 0);
211
242
  return Array.from(new Set(results)).sort();
212
243
  }
244
+ export async function discoverProjectsAcrossRoots(rootPaths, maxDepth) {
245
+ const perRootResults = await Promise.all(rootPaths.map((rootPath) => discoverProjects(rootPath, maxDepth)));
246
+ return Array.from(new Set(perRootResults.flat())).sort();
247
+ }
248
+ const DEFAULT_TOOL_TEMPLATE_NAMES = [
249
+ "projectInit",
250
+ "projectScan",
251
+ "projectNext",
252
+ "projectLocate",
253
+ "projectContext",
254
+ "syncViews",
255
+ "taskList",
256
+ "taskNext",
257
+ "taskContext",
258
+ "taskUpdate",
259
+ "roadmapList",
260
+ "roadmapContext",
261
+ "roadmapUpdate",
262
+ ];
213
263
  async function pathExists(targetPath) {
214
264
  const accessResult = await catchIt(fs.access(targetPath));
215
265
  return !accessResult.isError();
@@ -229,51 +279,62 @@ function defaultReadmeMarkdown(governanceDirName) {
229
279
  `This directory (\`${governanceDirName}/\`) is the governance root for this project.`,
230
280
  "",
231
281
  "## Conventions",
232
- "- Keep roadmap/task/design/report files in markdown.",
282
+ "- Keep roadmap/task source of truth in .projitive sqlite tables.",
283
+ "- Treat roadmap.md/tasks.md as generated views from sqlite.",
233
284
  "- Keep IDs stable (TASK-xxxx / ROADMAP-xxxx).",
234
285
  "- Update report evidence before status transitions.",
235
286
  ].join("\n");
236
287
  }
237
- function defaultRoadmapMarkdown() {
238
- return [
239
- "# Roadmap",
240
- "",
241
- "## Active Milestones",
242
- "- [ ] ROADMAP-0001: Bootstrap governance baseline (time: 2026-Q1)",
243
- ].join("\n");
288
+ function defaultRoadmapMarkdown(milestones = defaultRoadmapMilestones()) {
289
+ return renderRoadmapMarkdown(milestones);
244
290
  }
245
- function defaultTasksMarkdown() {
246
- const updatedAt = new Date().toISOString();
247
- return [
248
- "# Tasks",
249
- "",
250
- "<!-- PROJITIVE:TASKS:START -->",
251
- "## TASK-0001 | TODO | Bootstrap governance workspace",
252
- "- owner: unassigned",
253
- "- summary: Create initial governance artifacts and confirm task execution loop.",
254
- `- updatedAt: ${updatedAt}`,
255
- "- roadmapRefs: ROADMAP-0001",
256
- "- links:",
257
- " - (none)",
258
- "<!-- PROJITIVE:TASKS:END -->",
259
- ].join("\n");
291
+ function defaultTasksMarkdown(updatedAt = new Date().toISOString()) {
292
+ return renderTasksMarkdown([
293
+ {
294
+ id: "TASK-0001",
295
+ title: "Bootstrap governance workspace",
296
+ status: "TODO",
297
+ owner: "unassigned",
298
+ summary: "Create initial governance artifacts and confirm task execution loop.",
299
+ updatedAt,
300
+ links: [],
301
+ roadmapRefs: ["ROADMAP-0001"],
302
+ },
303
+ ]);
304
+ }
305
+ function defaultRoadmapMilestones() {
306
+ return [{
307
+ id: "ROADMAP-0001",
308
+ title: "Bootstrap governance baseline",
309
+ status: "active",
310
+ time: "2026-Q1",
311
+ updatedAt: new Date().toISOString(),
312
+ }];
260
313
  }
261
- function defaultNoTaskDiscoveryHookMarkdown() {
314
+ function defaultTemplateReadmeMarkdown() {
262
315
  return [
263
- "Objective:",
264
- "- When no actionable task exists, proactively discover meaningful work and convert it into TODO tasks.",
316
+ "# Template Guide",
317
+ "",
318
+ "This directory stores response templates (one file per tool).",
265
319
  "",
266
- "Checklist:",
267
- "- Check whether code violates project guides/specs; create tasks for each actionable gap.",
268
- "- Check test coverage improvement opportunities; create tasks for high-value missing tests.",
269
- "- Check development/testing workflow bottlenecks; create tasks for reliability and speed improvements.",
270
- "- Check TODO/FIXME/HACK comments; turn feasible items into governed tasks.",
271
- "- Check dependency/security hygiene and stale tooling; create tasks where upgrades are justified.",
320
+ "How to enable:",
321
+ "- Set env `PROJITIVE_MESSAGE_TEMPLATE_PATH` to a template directory.",
322
+ "- Example: .projitive/templates/tools",
272
323
  "",
273
- "Output Format:",
274
- "- Candidate findings (3-10)",
275
- "- Proposed tasks (TASK-xxxx style)",
276
- "- Priority rationale",
324
+ "Rule:",
325
+ "- Prefer one template per tool: <toolName>.md (e.g. taskNext.md).",
326
+ "- Template directory mode only loads <toolName>.md files.",
327
+ "- If a tool template file is missing, Projitive will auto-generate that file before rendering.",
328
+ "- Include {{content}} to render original tool output.",
329
+ "- If {{content}} is missing, original output is appended after template text.",
330
+ "",
331
+ "Basic Variables:",
332
+ "- {{tool_name}}",
333
+ "- {{summary}}",
334
+ "- {{evidence}}",
335
+ "- {{guidance}}",
336
+ "- {{next_call}}",
337
+ "- {{content}}",
277
338
  ].join("\n");
278
339
  }
279
340
  export async function initializeProjectStructure(inputPath, governanceDir, force = false) {
@@ -288,24 +349,49 @@ export async function initializeProjectStructure(inputPath, governanceDir, force
288
349
  }
289
350
  const governancePath = path.join(projectPath, governanceDirName);
290
351
  const directories = [];
291
- const requiredDirectories = [governancePath, path.join(governancePath, "designs"), path.join(governancePath, "reports"), path.join(governancePath, "hooks")];
352
+ const requiredDirectories = [
353
+ governancePath,
354
+ path.join(governancePath, "designs"),
355
+ path.join(governancePath, "reports"),
356
+ path.join(governancePath, "templates"),
357
+ path.join(governancePath, "templates", "tools"),
358
+ ];
292
359
  for (const dirPath of requiredDirectories) {
293
360
  const exists = await pathExists(dirPath);
294
361
  await fs.mkdir(dirPath, { recursive: true });
295
362
  directories.push({ path: dirPath, action: exists ? "skipped" : "created" });
296
363
  }
297
364
  const markerPath = path.join(governancePath, PROJECT_MARKER);
298
- const files = await Promise.all([
299
- writeTextFile(markerPath, "", force),
365
+ const defaultRoadmapData = defaultRoadmapMilestones();
366
+ const defaultTaskUpdatedAt = new Date().toISOString();
367
+ const markerExists = await pathExists(markerPath);
368
+ await ensureStore(markerPath);
369
+ if (force || !markerExists) {
370
+ await replaceRoadmapsInStore(markerPath, defaultRoadmapData);
371
+ await replaceTasksInStore(markerPath, [
372
+ {
373
+ id: "TASK-0001",
374
+ title: "Bootstrap governance workspace",
375
+ status: "TODO",
376
+ owner: "unassigned",
377
+ summary: "Create initial governance artifacts and confirm task execution loop.",
378
+ updatedAt: defaultTaskUpdatedAt,
379
+ links: [],
380
+ roadmapRefs: ["ROADMAP-0001"],
381
+ },
382
+ ]);
383
+ }
384
+ const baseFiles = await Promise.all([
300
385
  writeTextFile(path.join(governancePath, "README.md"), defaultReadmeMarkdown(governanceDirName), force),
301
- writeTextFile(path.join(governancePath, "roadmap.md"), defaultRoadmapMarkdown(), force),
302
- writeTextFile(path.join(governancePath, "tasks.md"), defaultTasksMarkdown(), force),
303
- writeTextFile(path.join(governancePath, "hooks", "task_no_actionable.md"), defaultNoTaskDiscoveryHookMarkdown(), force),
386
+ writeTextFile(path.join(governancePath, "roadmap.md"), defaultRoadmapMarkdown(defaultRoadmapData), force),
387
+ writeTextFile(path.join(governancePath, "tasks.md"), defaultTasksMarkdown(defaultTaskUpdatedAt), force),
388
+ writeTextFile(path.join(governancePath, "templates", "README.md"), defaultTemplateReadmeMarkdown(), force),
304
389
  ]);
390
+ const toolTemplateFiles = await Promise.all(DEFAULT_TOOL_TEMPLATE_NAMES.map((toolName) => writeTextFile(path.join(governancePath, "templates", "tools", `${toolName}.md`), getDefaultToolTemplateMarkdown(toolName), force)));
391
+ const files = [...baseFiles, ...toolTemplateFiles];
305
392
  return {
306
393
  projectPath,
307
394
  governanceDir: governancePath,
308
- markerPath,
309
395
  directories,
310
396
  files,
311
397
  };
@@ -332,7 +418,6 @@ export function registerProjectTools(server) {
332
418
  summarySection([
333
419
  `- projectPath: ${initialized.projectPath}`,
334
420
  `- governanceDir: ${initialized.governanceDir}`,
335
- `- markerPath: ${initialized.markerPath}`,
336
421
  `- force: ${force === true ? "true" : "false"}`,
337
422
  ]),
338
423
  evidenceSection([
@@ -349,8 +434,8 @@ export function registerProjectTools(server) {
349
434
  "- Continue with projectContext and taskList for execution.",
350
435
  ]),
351
436
  lintSection([
352
- "- After init, fill owner/roadmapRefs/links in tasks.md before marking DONE.",
353
- "- Keep task source-of-truth inside marker block only.",
437
+ "- After init, fill owner/roadmapRefs/links in .projitive task table before marking DONE.",
438
+ "- Keep task source-of-truth inside sqlite tables.",
354
439
  ]),
355
440
  nextCallSection(`projectContext(projectPath=\"${initialized.projectPath}\")`),
356
441
  ],
@@ -362,14 +447,15 @@ export function registerProjectTools(server) {
362
447
  description: "Start here when project path is unknown; discover all governance roots",
363
448
  inputSchema: {},
364
449
  }, async () => {
365
- const root = resolveScanRoot();
450
+ const roots = resolveScanRoots();
366
451
  const depth = resolveScanDepth();
367
- const projects = await discoverProjects(root, depth);
452
+ const projects = await discoverProjectsAcrossRoots(roots, depth);
368
453
  const markdown = renderToolResponseMarkdown({
369
454
  toolName: "projectScan",
370
455
  sections: [
371
456
  summarySection([
372
- `- rootPath: ${root}`,
457
+ `- rootPaths: ${roots.join(", ")}`,
458
+ `- rootCount: ${roots.length}`,
373
459
  `- maxDepth: ${depth}`,
374
460
  `- discoveredCount: ${projects.length}`,
375
461
  ]),
@@ -398,28 +484,23 @@ export function registerProjectTools(server) {
398
484
  limit: z.number().int().min(1).max(50).optional(),
399
485
  },
400
486
  }, async ({ limit }) => {
401
- const root = resolveScanRoot();
487
+ const roots = resolveScanRoots();
402
488
  const depth = resolveScanDepth();
403
- const projects = await discoverProjects(root, depth);
489
+ const projects = await discoverProjectsAcrossRoots(roots, depth);
404
490
  const snapshots = await Promise.all(projects.map(async (governanceDir) => {
405
491
  const snapshot = await readTasksSnapshot(governanceDir);
406
- const inProgress = snapshot.tasks.filter((task) => task.status === "IN_PROGRESS").length;
407
- const todo = snapshot.tasks.filter((task) => task.status === "TODO").length;
408
- const blocked = snapshot.tasks.filter((task) => task.status === "BLOCKED").length;
409
- const done = snapshot.tasks.filter((task) => task.status === "DONE").length;
410
- const actionable = inProgress + todo;
492
+ const actionable = snapshot.inProgress + snapshot.todo;
411
493
  return {
412
494
  governanceDir,
413
- tasksPath: snapshot.tasksPath,
414
495
  tasksExists: snapshot.exists,
415
496
  lintSuggestions: snapshot.lintSuggestions,
416
- inProgress,
417
- todo,
418
- blocked,
419
- done,
497
+ inProgress: snapshot.inProgress,
498
+ todo: snapshot.todo,
499
+ blocked: snapshot.blocked,
500
+ done: snapshot.done,
420
501
  actionable,
421
- latestUpdatedAt: latestTaskUpdatedAt(snapshot.tasks),
422
- score: actionableScore(snapshot.tasks),
502
+ latestUpdatedAt: snapshot.latestUpdatedAt,
503
+ score: snapshot.score,
423
504
  };
424
505
  }));
425
506
  const ranked = snapshots
@@ -431,11 +512,16 @@ export function registerProjectTools(server) {
431
512
  return b.latestUpdatedAt.localeCompare(a.latestUpdatedAt);
432
513
  })
433
514
  .slice(0, limit ?? 10);
515
+ if (ranked[0]) {
516
+ const topDoc = await loadTasksDocument(ranked[0].governanceDir);
517
+ ranked[0].lintSuggestions = collectTaskLintSuggestions(topDoc.tasks);
518
+ }
434
519
  const markdown = renderToolResponseMarkdown({
435
520
  toolName: "projectNext",
436
521
  sections: [
437
522
  summarySection([
438
- `- rootPath: ${root}`,
523
+ `- rootPaths: ${roots.join(", ")}`,
524
+ `- rootCount: ${roots.length}`,
439
525
  `- maxDepth: ${depth}`,
440
526
  `- matchedProjects: ${projects.length}`,
441
527
  `- actionableProjects: ${ranked.length}`,
@@ -443,12 +529,12 @@ export function registerProjectTools(server) {
443
529
  ]),
444
530
  evidenceSection([
445
531
  "- rankedProjects:",
446
- ...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)"}`),
532
+ ...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}${item.tasksExists ? "" : " | store=missing"}`),
447
533
  ]),
448
534
  guidanceSection([
449
535
  "- Pick top 1 project and call `projectContext` with its projectPath.",
450
536
  "- Then call `taskList` and `taskContext` to continue execution.",
451
- "- If `tasksPath` is missing, create tasks.md using project convention before task-level operations.",
537
+ "- If governance store is missing, initialize governance before task-level operations.",
452
538
  ]),
453
539
  lintSection(ranked[0]?.lintSuggestions ?? []),
454
540
  nextCallSection(ranked[0]
@@ -468,7 +554,6 @@ export function registerProjectTools(server) {
468
554
  const resolvedFrom = normalizePath(inputPath);
469
555
  const governanceDir = await resolveGovernanceDir(resolvedFrom);
470
556
  const projectPath = toProjectPath(governanceDir);
471
- const markerPath = path.join(governanceDir, ".projitive");
472
557
  const markdown = renderToolResponseMarkdown({
473
558
  toolName: "projectLocate",
474
559
  sections: [
@@ -476,7 +561,6 @@ export function registerProjectTools(server) {
476
561
  `- resolvedFrom: ${resolvedFrom}`,
477
562
  `- projectPath: ${projectPath}`,
478
563
  `- governanceDir: ${governanceDir}`,
479
- `- markerPath: ${markerPath}`,
480
564
  ]),
481
565
  guidanceSection(["- Call `projectContext` with this projectPath to get task and roadmap summaries."]),
482
566
  lintSection(["- Run `projectContext` to get governance/module lint suggestions for this project."]),
@@ -485,6 +569,61 @@ export function registerProjectTools(server) {
485
569
  });
486
570
  return asText(markdown);
487
571
  });
572
+ server.registerTool("syncViews", {
573
+ title: "Sync Views",
574
+ description: "Materialize markdown views from .projitive sqlite tables (tasks.md / roadmap.md)",
575
+ inputSchema: {
576
+ projectPath: z.string(),
577
+ views: z.array(z.enum(["tasks", "roadmap"])).optional(),
578
+ force: z.boolean().optional(),
579
+ },
580
+ }, async ({ projectPath, views, force }) => {
581
+ const governanceDir = await resolveGovernanceDir(projectPath);
582
+ const dbPath = path.join(governanceDir, PROJECT_MARKER);
583
+ const selectedViews = views && views.length > 0
584
+ ? Array.from(new Set(views))
585
+ : ["tasks", "roadmap"];
586
+ const forceSync = force === true;
587
+ if (forceSync) {
588
+ if (selectedViews.includes("tasks")) {
589
+ await markMarkdownViewDirty(dbPath, "tasks_markdown");
590
+ }
591
+ if (selectedViews.includes("roadmap")) {
592
+ await markMarkdownViewDirty(dbPath, "roadmaps_markdown");
593
+ }
594
+ }
595
+ let taskCount;
596
+ let roadmapCount;
597
+ if (selectedViews.includes("tasks")) {
598
+ const taskDoc = await loadTasksDocumentWithOptions(governanceDir, forceSync);
599
+ taskCount = taskDoc.tasks.length;
600
+ }
601
+ if (selectedViews.includes("roadmap")) {
602
+ const roadmapDoc = await loadRoadmapDocumentWithOptions(governanceDir, forceSync);
603
+ roadmapCount = roadmapDoc.milestones.length;
604
+ }
605
+ const markdown = renderToolResponseMarkdown({
606
+ toolName: "syncViews",
607
+ sections: [
608
+ summarySection([
609
+ `- governanceDir: ${governanceDir}`,
610
+ `- views: ${selectedViews.join(", ")}`,
611
+ `- force: ${forceSync ? "true" : "false"}`,
612
+ ]),
613
+ evidenceSection([
614
+ ...(typeof taskCount === "number" ? [`- tasks.md synced | taskCount=${taskCount}`] : []),
615
+ ...(typeof roadmapCount === "number" ? [`- roadmap.md synced | roadmapCount=${roadmapCount}`] : []),
616
+ ]),
617
+ guidanceSection([
618
+ "Use this tool after batch updates when you need immediate markdown materialization.",
619
+ "Routine workflows can rely on lazy sync and usually do not require force=true.",
620
+ ]),
621
+ lintSection([]),
622
+ nextCallSection(`projectContext(projectPath="${toProjectPath(governanceDir)}")`),
623
+ ],
624
+ });
625
+ return asText(markdown);
626
+ });
488
627
  server.registerTool("projectContext", {
489
628
  title: "Project Context",
490
629
  description: "Get project-level summary before selecting or executing a task",
@@ -495,32 +634,28 @@ export function registerProjectTools(server) {
495
634
  const governanceDir = await resolveGovernanceDir(projectPath);
496
635
  const normalizedProjectPath = toProjectPath(governanceDir);
497
636
  const artifacts = await discoverGovernanceArtifacts(governanceDir);
498
- const { tasksPath, tasks, markdown: tasksMarkdown } = await loadTasksDocument(governanceDir);
637
+ const dbPath = path.join(governanceDir, PROJECT_MARKER);
638
+ await ensureStore(dbPath);
639
+ const taskStats = await loadTaskStatusStatsFromStore(dbPath);
640
+ const { markdownPath: tasksMarkdownPath, tasks } = await loadTasksDocument(governanceDir);
499
641
  const roadmapIds = await readRoadmapIds(governanceDir);
500
- const lintSuggestions = collectTaskLintSuggestions(tasks, tasksMarkdown);
501
- const taskSummary = {
502
- total: tasks.length,
503
- TODO: tasks.filter((task) => task.status === "TODO").length,
504
- IN_PROGRESS: tasks.filter((task) => task.status === "IN_PROGRESS").length,
505
- BLOCKED: tasks.filter((task) => task.status === "BLOCKED").length,
506
- DONE: tasks.filter((task) => task.status === "DONE").length,
507
- };
642
+ const lintSuggestions = collectTaskLintSuggestions(tasks);
508
643
  const markdown = renderToolResponseMarkdown({
509
644
  toolName: "projectContext",
510
645
  sections: [
511
646
  summarySection([
512
647
  `- projectPath: ${normalizedProjectPath}`,
513
648
  `- governanceDir: ${governanceDir}`,
514
- `- tasksFile: ${tasksPath}`,
649
+ `- tasksView: ${tasksMarkdownPath}`,
515
650
  `- roadmapIds: ${roadmapIds.length}`,
516
651
  ]),
517
652
  evidenceSection([
518
653
  "### Task Summary",
519
- `- total: ${taskSummary.total}`,
520
- `- TODO: ${taskSummary.TODO}`,
521
- `- IN_PROGRESS: ${taskSummary.IN_PROGRESS}`,
522
- `- BLOCKED: ${taskSummary.BLOCKED}`,
523
- `- DONE: ${taskSummary.DONE}`,
654
+ `- total: ${taskStats.total}`,
655
+ `- TODO: ${taskStats.todo}`,
656
+ `- IN_PROGRESS: ${taskStats.inProgress}`,
657
+ `- BLOCKED: ${taskStats.blocked}`,
658
+ `- DONE: ${taskStats.done}`,
524
659
  "",
525
660
  "### Artifacts",
526
661
  renderArtifactsMarkdown(artifacts),
@@ -2,7 +2,7 @@ import fs from "node:fs/promises";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
4
  import { afterEach, describe, expect, it, vi } from "vitest";
5
- import { discoverProjects, hasProjectMarker, initializeProjectStructure, resolveGovernanceDir, resolveScanRoot, resolveScanDepth, toProjectPath, registerProjectTools } from "./project.js";
5
+ import { discoverProjects, discoverProjectsAcrossRoots, hasProjectMarker, initializeProjectStructure, resolveGovernanceDir, resolveScanRoots, resolveScanDepth, toProjectPath, registerProjectTools } from "./project.js";
6
6
  const tempPaths = [];
7
7
  async function createTempDir() {
8
8
  const dir = await fs.mkdtemp(path.join(os.tmpdir(), "projitive-mcp-test-"));
@@ -186,6 +186,15 @@ describe("projitive module", () => {
186
186
  const projects = await discoverProjects(root, 3);
187
187
  expect(projects).toEqual([]);
188
188
  });
189
+ it("ignores non-existent roots when scanning across multiple roots", async () => {
190
+ const validRoot = await createTempDir();
191
+ const validProject = path.join(validRoot, "project-a");
192
+ const missingRoot = path.join(validRoot, "__missing_root__");
193
+ await fs.mkdir(validProject, { recursive: true });
194
+ await fs.writeFile(path.join(validProject, ".projitive"), "", "utf-8");
195
+ const projects = await discoverProjectsAcrossRoots([missingRoot, validRoot], 3);
196
+ expect(projects).toContain(validProject);
197
+ });
189
198
  });
190
199
  describe("initializeProjectStructure", () => {
191
200
  it("initializes governance structure under default .projitive directory", async () => {
@@ -197,10 +206,13 @@ describe("projitive module", () => {
197
206
  path.join(root, ".projitive", "README.md"),
198
207
  path.join(root, ".projitive", "roadmap.md"),
199
208
  path.join(root, ".projitive", "tasks.md"),
200
- path.join(root, ".projitive", "hooks", "task_no_actionable.md"),
209
+ path.join(root, ".projitive", "templates", "README.md"),
210
+ path.join(root, ".projitive", "templates", "tools", "taskNext.md"),
211
+ path.join(root, ".projitive", "templates", "tools", "taskUpdate.md"),
201
212
  path.join(root, ".projitive", "designs"),
202
213
  path.join(root, ".projitive", "reports"),
203
- path.join(root, ".projitive", "hooks"),
214
+ path.join(root, ".projitive", "templates"),
215
+ path.join(root, ".projitive", "templates", "tools"),
204
216
  ];
205
217
  await Promise.all(expectedPaths.map(async (targetPath) => {
206
218
  await expect(fs.access(targetPath)).resolves.toBeUndefined();
@@ -257,7 +269,7 @@ describe("projitive module", () => {
257
269
  const initialized = await initializeProjectStructure(root);
258
270
  expect(initialized.directories.some(d => d.path.includes("designs"))).toBe(true);
259
271
  expect(initialized.directories.some(d => d.path.includes("reports"))).toBe(true);
260
- expect(initialized.directories.some(d => d.path.includes("hooks"))).toBe(true);
272
+ expect(initialized.directories.some(d => d.path.includes("templates"))).toBe(true);
261
273
  });
262
274
  });
263
275
  describe("utility functions", () => {
@@ -267,20 +279,30 @@ describe("projitive module", () => {
267
279
  expect(toProjectPath("/a/b/c")).toBe("/a/b");
268
280
  });
269
281
  });
270
- describe("resolveScanRoot", () => {
271
- it("uses environment variable when no input path", () => {
282
+ describe("resolveScanRoots", () => {
283
+ it("uses legacy environment variable when no multi-root env is provided", () => {
272
284
  vi.stubEnv("PROJITIVE_SCAN_ROOT_PATH", "/test/root");
273
- expect(resolveScanRoot()).toBe("/test/root");
285
+ expect(resolveScanRoots()).toEqual(["/test/root"]);
274
286
  vi.unstubAllEnvs();
275
287
  });
276
- it("uses input path when provided", () => {
288
+ it("uses input paths when provided", () => {
277
289
  vi.stubEnv("PROJITIVE_SCAN_ROOT_PATH", "/test/root");
278
- expect(resolveScanRoot("/custom/path")).toBe("/custom/path");
290
+ expect(resolveScanRoots(["/custom/path", " /custom/path ", "/second/path"])).toEqual(["/custom/path", "/second/path"]);
291
+ vi.unstubAllEnvs();
292
+ });
293
+ it("uses PROJITIVE_SCAN_ROOT_PATHS with platform delimiter", () => {
294
+ vi.stubEnv("PROJITIVE_SCAN_ROOT_PATHS", ["/root/a", "/root/b", "", " /root/c "].join(path.delimiter));
295
+ expect(resolveScanRoots()).toEqual(["/root/a", "/root/b", "/root/c"]);
296
+ vi.unstubAllEnvs();
297
+ });
298
+ it("treats JSON-like string as plain delimiter input", () => {
299
+ vi.stubEnv("PROJITIVE_SCAN_ROOT_PATHS", JSON.stringify(["/json/a", "/json/b"]));
300
+ expect(resolveScanRoots()).toHaveLength(1);
279
301
  vi.unstubAllEnvs();
280
302
  });
281
- it("throws error when required environment variable missing", () => {
303
+ it("throws error when no root environment variables are configured", () => {
282
304
  vi.unstubAllEnvs();
283
- expect(() => resolveScanRoot()).toThrow("Missing required environment variable: PROJITIVE_SCAN_ROOT_PATH");
305
+ expect(() => resolveScanRoots()).toThrow("Missing required environment variable: PROJITIVE_SCAN_ROOT_PATHS");
284
306
  });
285
307
  });
286
308
  describe("resolveScanDepth", () => {