@projitive/mcp 1.0.0-beta.4 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/output/tasks.js CHANGED
@@ -1,12 +1,118 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
+ import { z } from "zod";
4
+ import { discoverGovernanceArtifacts } from "./helpers/files/index.js";
5
+ import { findTextReferences } from "./helpers/markdown/index.js";
3
6
  import { catchIt } from "./helpers/catch/index.js";
4
- import { resolveGovernanceDir } from "./projitive.js";
7
+ import { resolveGovernanceDir, resolveScanDepth, resolveScanRoot, discoverProjects } from "./projitive.js";
5
8
  import { isValidRoadmapId } from "./roadmap.js";
6
9
  export const TASKS_START = "<!-- PROJITIVE:TASKS:START -->";
7
10
  export const TASKS_END = "<!-- PROJITIVE:TASKS:END -->";
8
11
  export const ALLOWED_STATUS = ["TODO", "IN_PROGRESS", "BLOCKED", "DONE"];
9
12
  export const TASK_ID_REGEX = /^TASK-\d{4}$/;
13
+ function asText(markdown) {
14
+ return {
15
+ content: [{ type: "text", text: markdown }],
16
+ };
17
+ }
18
+ function renderErrorMarkdown(toolName, cause, nextSteps, retryExample) {
19
+ return [
20
+ `# ${toolName}`,
21
+ "",
22
+ "## Error",
23
+ `- cause: ${cause}`,
24
+ "",
25
+ "## Next Step",
26
+ ...(nextSteps.length > 0 ? nextSteps : ["- (none)"]),
27
+ "",
28
+ "## Retry Example",
29
+ `- ${retryExample ?? "(none)"}`,
30
+ ].join("\n");
31
+ }
32
+ function taskStatusGuidance(task) {
33
+ if (task.status === "TODO") {
34
+ return [
35
+ "- This task is TODO: confirm scope and set execution plan before edits.",
36
+ "- Move to IN_PROGRESS only after owner and initial evidence are ready.",
37
+ ];
38
+ }
39
+ if (task.status === "IN_PROGRESS") {
40
+ return [
41
+ "- This task is IN_PROGRESS: prioritize finishing with report/design evidence updates.",
42
+ "- Verify references stay consistent before marking DONE.",
43
+ ];
44
+ }
45
+ if (task.status === "BLOCKED") {
46
+ return [
47
+ "- This task is BLOCKED: identify blocker and required unblock condition first.",
48
+ "- Reopen only after blocker evidence is documented.",
49
+ ];
50
+ }
51
+ return [
52
+ "- This task is DONE: only reopen when new requirement changes scope.",
53
+ "- Keep report evidence immutable unless correction is required.",
54
+ ];
55
+ }
56
+ function candidateFilesFromArtifacts(artifacts) {
57
+ return artifacts
58
+ .filter((item) => item.exists)
59
+ .flatMap((item) => {
60
+ if (item.kind === "file") {
61
+ return [item.path];
62
+ }
63
+ return (item.markdownFiles ?? []).map((entry) => entry.path);
64
+ });
65
+ }
66
+ async function readOptionalMarkdown(filePath) {
67
+ const content = await fs.readFile(filePath, "utf-8").catch(() => undefined);
68
+ if (typeof content !== "string") {
69
+ return undefined;
70
+ }
71
+ const trimmed = content.trim();
72
+ return trimmed.length > 0 ? trimmed : undefined;
73
+ }
74
+ async function readTaskContextHooks(governanceDir) {
75
+ const headPath = path.join(governanceDir, "hooks", "task_get_head.md");
76
+ const footerPath = path.join(governanceDir, "hooks", "task_get_footer.md");
77
+ const [head, footer] = await Promise.all([readOptionalMarkdown(headPath), readOptionalMarkdown(footerPath)]);
78
+ return { head, footer, headPath, footerPath };
79
+ }
80
+ function latestTaskUpdatedAt(tasks) {
81
+ const timestamps = tasks
82
+ .map((task) => new Date(task.updatedAt).getTime())
83
+ .filter((value) => Number.isFinite(value));
84
+ if (timestamps.length === 0) {
85
+ return "(unknown)";
86
+ }
87
+ return new Date(Math.max(...timestamps)).toISOString();
88
+ }
89
+ function actionableScore(tasks) {
90
+ return tasks.filter((task) => task.status === "IN_PROGRESS").length * 2
91
+ + tasks.filter((task) => task.status === "TODO").length;
92
+ }
93
+ async function readActionableTaskCandidates(governanceDirs) {
94
+ const snapshots = await Promise.all(governanceDirs.map(async (governanceDir) => {
95
+ const snapshot = await loadTasks(governanceDir);
96
+ return {
97
+ governanceDir,
98
+ tasksPath: snapshot.tasksPath,
99
+ tasks: snapshot.tasks,
100
+ projectScore: actionableScore(snapshot.tasks),
101
+ projectLatestUpdatedAt: latestTaskUpdatedAt(snapshot.tasks),
102
+ };
103
+ }));
104
+ return snapshots.flatMap((item) => item.tasks
105
+ .filter((task) => task.status === "IN_PROGRESS" || task.status === "TODO")
106
+ .map((task) => ({
107
+ governanceDir: item.governanceDir,
108
+ tasksPath: item.tasksPath,
109
+ task,
110
+ projectScore: item.projectScore,
111
+ projectLatestUpdatedAt: item.projectLatestUpdatedAt,
112
+ taskUpdatedAtMs: toTaskUpdatedAtMs(task.updatedAt),
113
+ taskPriority: taskPriority(task.status),
114
+ })));
115
+ }
10
116
  export function nowIso() {
11
117
  return new Date().toISOString();
12
118
  }
@@ -240,3 +346,218 @@ export function validateTransition(from, to) {
240
346
  };
241
347
  return allowed[from].has(to);
242
348
  }
349
+ export function registerTaskTools(server) {
350
+ server.registerTool("taskList", {
351
+ title: "Task List",
352
+ description: "List project tasks with optional status filter for agent planning",
353
+ inputSchema: {
354
+ projectPath: z.string(),
355
+ status: z.enum(["TODO", "IN_PROGRESS", "BLOCKED", "DONE"]).optional(),
356
+ limit: z.number().int().min(1).max(200).optional(),
357
+ },
358
+ }, async ({ projectPath, status, limit }) => {
359
+ const governanceDir = await resolveGovernanceDir(projectPath);
360
+ const { tasksPath, tasks } = await loadTasks(governanceDir);
361
+ const filtered = tasks
362
+ .filter((task) => (status ? task.status === status : true))
363
+ .slice(0, limit ?? 100);
364
+ const markdown = [
365
+ "# taskList",
366
+ "",
367
+ "## Summary",
368
+ `- governanceDir: ${governanceDir}`,
369
+ `- tasksPath: ${tasksPath}`,
370
+ `- filter.status: ${status ?? "(none)"}`,
371
+ `- returned: ${filtered.length}`,
372
+ "",
373
+ "## Evidence",
374
+ "- tasks:",
375
+ ...(filtered.length > 0
376
+ ? filtered.map((task) => `- ${task.id} | ${task.status} | ${task.title} | owner=${task.owner || ""} | updatedAt=${task.updatedAt}`)
377
+ : ["- (none)"]),
378
+ "",
379
+ "## Agent Guidance",
380
+ "- Pick one task ID and call `taskContext`.",
381
+ "",
382
+ "## Next Call",
383
+ `- taskContext(projectPath=\"${governanceDir}\", taskId=\"TASK-0001\")`,
384
+ ].join("\n");
385
+ return asText(markdown);
386
+ });
387
+ server.registerTool("taskNext", {
388
+ title: "Task Next",
389
+ description: "One-step discover and select the most actionable task with evidence and start guidance",
390
+ inputSchema: {
391
+ rootPath: z.string().optional(),
392
+ maxDepth: z.number().int().min(0).max(8).optional(),
393
+ topCandidates: z.number().int().min(1).max(20).optional(),
394
+ },
395
+ }, async ({ rootPath, maxDepth, topCandidates }) => {
396
+ const root = resolveScanRoot(rootPath);
397
+ const depth = resolveScanDepth(maxDepth);
398
+ const projects = await discoverProjects(root, depth);
399
+ const rankedCandidates = rankActionableTaskCandidates(await readActionableTaskCandidates(projects));
400
+ if (rankedCandidates.length === 0) {
401
+ const markdown = [
402
+ "# taskNext",
403
+ "",
404
+ "## Summary",
405
+ `- rootPath: ${root}`,
406
+ `- maxDepth: ${depth}`,
407
+ `- matchedProjects: ${projects.length}`,
408
+ "- actionableTasks: 0",
409
+ "",
410
+ "## Evidence",
411
+ "- candidates:",
412
+ "- (none)",
413
+ "",
414
+ "## Agent Guidance",
415
+ "- No TODO/IN_PROGRESS task is available.",
416
+ "- Create or reopen tasks in tasks.md, then rerun `taskNext`.",
417
+ "",
418
+ "## Next Call",
419
+ `- projectNext(rootPath=\"${root}\", maxDepth=${depth})`,
420
+ ].join("\n");
421
+ return asText(markdown);
422
+ }
423
+ const selected = rankedCandidates[0];
424
+ const artifacts = await discoverGovernanceArtifacts(selected.governanceDir);
425
+ const fileCandidates = candidateFilesFromArtifacts(artifacts);
426
+ const referenceLocations = (await Promise.all(fileCandidates.map((file) => findTextReferences(file, selected.task.id)))).flat();
427
+ const taskLocation = (await findTextReferences(selected.tasksPath, selected.task.id))[0];
428
+ const relatedArtifacts = Array.from(new Set(referenceLocations.map((item) => item.filePath)));
429
+ const suggestedReadOrder = [selected.tasksPath, ...relatedArtifacts.filter((item) => item !== selected.tasksPath)];
430
+ const candidateLimit = topCandidates ?? 5;
431
+ const markdown = [
432
+ "# taskNext",
433
+ "",
434
+ "## Summary",
435
+ `- rootPath: ${root}`,
436
+ `- maxDepth: ${depth}`,
437
+ `- matchedProjects: ${projects.length}`,
438
+ `- actionableTasks: ${rankedCandidates.length}`,
439
+ `- selectedProject: ${selected.governanceDir}`,
440
+ `- selectedTaskId: ${selected.task.id}`,
441
+ `- selectedTaskStatus: ${selected.task.status}`,
442
+ "",
443
+ "## Evidence",
444
+ "### Selected Task",
445
+ `- id: ${selected.task.id}`,
446
+ `- title: ${selected.task.title}`,
447
+ `- owner: ${selected.task.owner || "(none)"}`,
448
+ `- updatedAt: ${selected.task.updatedAt}`,
449
+ `- roadmapRefs: ${selected.task.roadmapRefs.join(", ") || "(none)"}`,
450
+ `- taskLocation: ${taskLocation ? `${taskLocation.filePath}#L${taskLocation.line}` : selected.tasksPath}`,
451
+ "",
452
+ "### Top Candidates",
453
+ ...rankedCandidates
454
+ .slice(0, candidateLimit)
455
+ .map((item, index) => `${index + 1}. ${item.task.id} | ${item.task.status} | ${item.task.title} | project=${item.governanceDir} | projectScore=${item.projectScore} | latest=${item.projectLatestUpdatedAt}`),
456
+ "",
457
+ "### Selection Reason",
458
+ "- Rank rule: projectScore DESC -> taskPriority DESC -> taskUpdatedAt DESC.",
459
+ `- Selected candidate scores: projectScore=${selected.projectScore}, taskPriority=${selected.taskPriority}, taskUpdatedAtMs=${selected.taskUpdatedAtMs}.`,
460
+ "",
461
+ "### Related Artifacts",
462
+ ...(relatedArtifacts.length > 0 ? relatedArtifacts.map((file) => `- ${file}`) : ["- (none)"]),
463
+ "",
464
+ "### Reference Locations",
465
+ ...(referenceLocations.length > 0
466
+ ? referenceLocations.map((item) => `- ${item.filePath}#L${item.line}: ${item.text}`)
467
+ : ["- (none)"]),
468
+ "",
469
+ "### Suggested Read Order",
470
+ ...suggestedReadOrder.map((item, index) => `${index + 1}. ${item}`),
471
+ "",
472
+ "## Agent Guidance",
473
+ "- Start immediately with Suggested Read Order and execute the selected task.",
474
+ "- Update markdown artifacts directly while keeping TASK/ROADMAP IDs unchanged.",
475
+ "- Re-run `taskContext` for the selectedTaskId after edits to verify evidence consistency.",
476
+ "",
477
+ "## Next Call",
478
+ `- taskContext(projectPath=\"${selected.governanceDir}\", taskId=\"${selected.task.id}\")`,
479
+ ].join("\n");
480
+ return asText(markdown);
481
+ });
482
+ server.registerTool("taskContext", {
483
+ title: "Task Context",
484
+ description: "Get one task with detail, evidence locations, and execution guidance",
485
+ inputSchema: {
486
+ projectPath: z.string(),
487
+ taskId: z.string(),
488
+ },
489
+ }, async ({ projectPath, taskId }) => {
490
+ if (!isValidTaskId(taskId)) {
491
+ return {
492
+ ...asText(renderErrorMarkdown("taskContext", `Invalid task ID format: ${taskId}`, ["- expected format: TASK-0001", "- retry with a valid task ID"], `taskContext(projectPath=\"${projectPath}\", taskId=\"TASK-0001\")`)),
493
+ isError: true,
494
+ };
495
+ }
496
+ const governanceDir = await resolveGovernanceDir(projectPath);
497
+ const { tasksPath, tasks } = await loadTasks(governanceDir);
498
+ const taskContextHooks = await readTaskContextHooks(governanceDir);
499
+ const task = tasks.find((item) => item.id === taskId);
500
+ if (!task) {
501
+ return {
502
+ ...asText(renderErrorMarkdown("taskContext", `Task not found: ${taskId}`, ["- run `taskList` to discover available IDs", "- retry with an existing task ID"], `taskList(projectPath=\"${governanceDir}\")`)),
503
+ isError: true,
504
+ };
505
+ }
506
+ const taskLocation = (await findTextReferences(tasksPath, taskId))[0];
507
+ const artifacts = await discoverGovernanceArtifacts(governanceDir);
508
+ const fileCandidates = candidateFilesFromArtifacts(artifacts);
509
+ const referenceLocations = (await Promise.all(fileCandidates.map((file) => findTextReferences(file, taskId)))).flat();
510
+ const relatedArtifacts = Array.from(new Set(referenceLocations.map((item) => item.filePath)));
511
+ const suggestedReadOrder = [tasksPath, ...relatedArtifacts.filter((item) => item !== tasksPath)];
512
+ const hookPaths = Object.values(task.hooks)
513
+ .filter((value) => typeof value === "string" && value.trim().length > 0)
514
+ .map((value) => path.resolve(governanceDir, value));
515
+ const hookStatus = `head=${taskContextHooks.head ? "loaded" : "missing"}, footer=${taskContextHooks.footer ? "loaded" : "missing"}`;
516
+ const coreMarkdown = [
517
+ "# taskContext",
518
+ "",
519
+ "## Summary",
520
+ `- governanceDir: ${governanceDir}`,
521
+ `- taskId: ${task.id}`,
522
+ `- title: ${task.title}`,
523
+ `- status: ${task.status}`,
524
+ `- owner: ${task.owner}`,
525
+ `- updatedAt: ${task.updatedAt}`,
526
+ `- roadmapRefs: ${task.roadmapRefs.join(", ") || "(none)"}`,
527
+ `- taskLocation: ${taskLocation ? `${taskLocation.filePath}#L${taskLocation.line}` : tasksPath}`,
528
+ `- hookStatus: ${hookStatus}`,
529
+ "",
530
+ "## Evidence",
531
+ "### Related Artifacts",
532
+ ...(relatedArtifacts.length > 0 ? relatedArtifacts.map((file) => `- ${file}`) : ["- (none)"]),
533
+ "",
534
+ "### Reference Locations",
535
+ ...(referenceLocations.length > 0
536
+ ? referenceLocations.map((item) => `- ${item.filePath}#L${item.line}: ${item.text}`)
537
+ : ["- (none)"]),
538
+ "",
539
+ "### Hook Paths",
540
+ ...(hookPaths.length > 0 ? hookPaths.map((item) => `- ${item}`) : ["- (none)"]),
541
+ "",
542
+ "### Suggested Read Order",
543
+ ...suggestedReadOrder.map((item, index) => `${index + 1}. ${item}`),
544
+ "",
545
+ "## Agent Guidance",
546
+ "- Read the files in Suggested Read Order.",
547
+ "- Verify whether current status and evidence are consistent.",
548
+ ...taskStatusGuidance(task),
549
+ "- If updates are needed, edit tasks/designs/reports markdown directly and keep TASK IDs unchanged.",
550
+ "- After editing, re-run `taskContext` to verify references and context consistency.",
551
+ "",
552
+ "## Next Call",
553
+ `- taskContext(projectPath=\"${governanceDir}\", taskId=\"${task.id}\")`,
554
+ ].join("\n");
555
+ const markdownParts = [
556
+ taskContextHooks.head,
557
+ coreMarkdown,
558
+ taskContextHooks.footer,
559
+ ].filter((value) => typeof value === "string" && value.trim().length > 0);
560
+ const markdown = markdownParts.join("\n\n---\n\n");
561
+ return asText(markdown);
562
+ });
563
+ }
package/package.json CHANGED
@@ -1,11 +1,13 @@
1
1
  {
2
2
  "name": "@projitive/mcp",
3
- "version": "1.0.0-beta.4",
3
+ "version": "1.0.0",
4
4
  "description": "Projitive MCP Server for project and task discovery/update",
5
5
  "license": "ISC",
6
6
  "author": "",
7
7
  "type": "module",
8
- "bin": "output/index.js",
8
+ "bin": {
9
+ "mcp": "output/index.js"
10
+ },
9
11
  "main": "./output/index.js",
10
12
  "types": "./output/index.d.ts",
11
13
  "publishConfig": {
@@ -13,6 +15,7 @@
13
15
  },
14
16
  "scripts": {
15
17
  "test": "vitest run",
18
+ "lint": "tsc -p tsconfig.json --noEmit",
16
19
  "build": "tsc -p tsconfig.json",
17
20
  "prepublishOnly": "npm run build",
18
21
  "dev": "tsc -p tsconfig.json --watch"