@projitive/mcp 1.0.1 → 1.0.2

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 (31) hide show
  1. package/README.md +1 -1
  2. package/output/helpers/artifacts/artifacts.js +10 -0
  3. package/output/helpers/artifacts/artifacts.test.js +18 -0
  4. package/output/helpers/artifacts/index.js +1 -0
  5. package/output/helpers/index.js +3 -0
  6. package/output/helpers/linter/codes.js +25 -0
  7. package/output/helpers/linter/index.js +2 -0
  8. package/output/helpers/linter/linter.js +6 -0
  9. package/output/helpers/linter/linter.test.js +16 -0
  10. package/output/helpers/response/index.js +1 -0
  11. package/output/helpers/response/response.js +73 -0
  12. package/output/helpers/response/response.test.js +50 -0
  13. package/output/projitive.js +137 -122
  14. package/output/rendering-input-guard.test.js +20 -0
  15. package/output/roadmap.js +106 -80
  16. package/output/roadmap.test.js +11 -0
  17. package/output/smoke-reports/2026-02-18T13-18-19-740Z/projectContext.md +48 -0
  18. package/output/smoke-reports/2026-02-18T13-18-19-740Z/projectInit.md +40 -0
  19. package/output/smoke-reports/2026-02-18T13-18-19-740Z/projectLocate.md +22 -0
  20. package/output/smoke-reports/2026-02-18T13-18-19-740Z/projectNext.md +31 -0
  21. package/output/smoke-reports/2026-02-18T13-18-19-740Z/projectScan.md +28 -0
  22. package/output/smoke-reports/2026-02-18T13-18-19-740Z/roadmapContext.md +33 -0
  23. package/output/smoke-reports/2026-02-18T13-18-19-740Z/roadmapList.md +25 -0
  24. package/output/smoke-reports/2026-02-18T13-18-19-740Z/summary.json +90 -0
  25. package/output/smoke-reports/2026-02-18T13-18-19-740Z/summary.md +17 -0
  26. package/output/smoke-reports/2026-02-18T13-18-19-740Z/taskContext.md +47 -0
  27. package/output/smoke-reports/2026-02-18T13-18-19-740Z/taskList.md +27 -0
  28. package/output/smoke-reports/2026-02-18T13-18-19-740Z/taskNext.md +64 -0
  29. package/output/tasks.js +341 -162
  30. package/output/tasks.test.js +51 -1
  31. package/package.json +1 -1
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Language: English | [简体中文](README_CN.md)
4
4
 
5
- **Current Spec Version: projitive-spec v1.0.0 | MCP Version: 1.0.1**
5
+ **Current Spec Version: projitive-spec v1.0.0 | MCP Version: 1.0.2**
6
6
 
7
7
  Projitive MCP server (semantic interface edition) helps agents discover projects, select tasks, locate evidence, and execute under governance workflows.
8
8
 
@@ -0,0 +1,10 @@
1
+ export function candidateFilesFromArtifacts(artifacts) {
2
+ return artifacts
3
+ .filter((item) => item.exists)
4
+ .flatMap((item) => {
5
+ if (item.kind === "file") {
6
+ return [item.path];
7
+ }
8
+ return (item.markdownFiles ?? []).map((entry) => entry.path);
9
+ });
10
+ }
@@ -0,0 +1,18 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { candidateFilesFromArtifacts } from "./artifacts.js";
3
+ describe("candidateFilesFromArtifacts", () => {
4
+ it("collects existing file artifacts and markdown files from existing directories", () => {
5
+ const candidates = candidateFilesFromArtifacts([
6
+ { name: "README.md", kind: "file", path: "/a/README.md", exists: true, lineCount: 3 },
7
+ { name: "tasks.md", kind: "file", path: "/a/tasks.md", exists: false },
8
+ {
9
+ name: "designs",
10
+ kind: "directory",
11
+ path: "/a/designs",
12
+ exists: true,
13
+ markdownFiles: [{ path: "/a/designs/d1.md", lineCount: 10 }],
14
+ },
15
+ ]);
16
+ expect(candidates).toEqual(["/a/README.md", "/a/designs/d1.md"]);
17
+ });
18
+ });
@@ -0,0 +1 @@
1
+ export * from "./artifacts.js";
@@ -1,3 +1,6 @@
1
+ export * from './artifacts/index.js';
1
2
  export * from './catch/index.js';
2
3
  export * from './files/index.js';
4
+ export * from './linter/index.js';
3
5
  export * from './markdown/index.js';
6
+ export * from './response/index.js';
@@ -0,0 +1,25 @@
1
+ export const TASK_LINT_CODES = {
2
+ DUPLICATE_ID: "TASK_DUPLICATE_ID",
3
+ IN_PROGRESS_OWNER_EMPTY: "TASK_IN_PROGRESS_OWNER_EMPTY",
4
+ DONE_LINKS_MISSING: "TASK_DONE_LINKS_MISSING",
5
+ BLOCKED_SUMMARY_EMPTY: "TASK_BLOCKED_SUMMARY_EMPTY",
6
+ UPDATED_AT_INVALID: "TASK_UPDATED_AT_INVALID",
7
+ ROADMAP_REFS_EMPTY: "TASK_ROADMAP_REFS_EMPTY",
8
+ OUTSIDE_MARKER: "TASK_OUTSIDE_MARKER",
9
+ LINK_TARGET_MISSING: "TASK_LINK_TARGET_MISSING",
10
+ HOOK_FILE_MISSING: "TASK_HOOK_FILE_MISSING",
11
+ FILTER_EMPTY: "TASK_FILTER_EMPTY",
12
+ CONTEXT_HOOK_HEAD_MISSING: "TASK_CONTEXT_HOOK_HEAD_MISSING",
13
+ CONTEXT_HOOK_FOOTER_MISSING: "TASK_CONTEXT_HOOK_FOOTER_MISSING",
14
+ };
15
+ export const ROADMAP_LINT_CODES = {
16
+ IDS_EMPTY: "ROADMAP_IDS_EMPTY",
17
+ TASKS_EMPTY: "ROADMAP_TASKS_EMPTY",
18
+ TASK_REFS_EMPTY: "ROADMAP_TASK_REFS_EMPTY",
19
+ UNKNOWN_REFS: "ROADMAP_UNKNOWN_REFS",
20
+ ZERO_LINKED_TASKS: "ROADMAP_ZERO_LINKED_TASKS",
21
+ CONTEXT_RELATED_TASKS_EMPTY: "ROADMAP_CONTEXT_RELATED_TASKS_EMPTY",
22
+ };
23
+ export const PROJECT_LINT_CODES = {
24
+ TASKS_FILE_MISSING: "PROJECT_TASKS_FILE_MISSING",
25
+ };
@@ -0,0 +1,2 @@
1
+ export * from './linter.js';
2
+ export * from './codes.js';
@@ -0,0 +1,6 @@
1
+ export function renderLintSuggestions(suggestions) {
2
+ return suggestions.map((item) => {
3
+ const suffix = item.fixHint ? ` ${item.fixHint}` : "";
4
+ return `- [${item.code}] ${item.message}${suffix}`;
5
+ });
6
+ }
@@ -0,0 +1,16 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { renderLintSuggestions } from "./linter.js";
3
+ describe("renderLintSuggestions", () => {
4
+ it("renders lint lines with code and message", () => {
5
+ const lines = renderLintSuggestions([
6
+ { code: "TASK_001", message: "Example lint" },
7
+ ]);
8
+ expect(lines).toEqual(["- [TASK_001] Example lint"]);
9
+ });
10
+ it("appends fixHint when provided", () => {
11
+ const lines = renderLintSuggestions([
12
+ { code: "TASK_002", message: "Missing field.", fixHint: "Set owner." },
13
+ ]);
14
+ expect(lines).toEqual(["- [TASK_002] Missing field. Set owner."]);
15
+ });
16
+ });
@@ -0,0 +1 @@
1
+ export * from "./response.js";
@@ -0,0 +1,73 @@
1
+ export function asText(markdown) {
2
+ return {
3
+ content: [{ type: "text", text: markdown }],
4
+ };
5
+ }
6
+ function withFallback(lines) {
7
+ return lines.length > 0 ? lines : ["- (none)"];
8
+ }
9
+ function shouldKeepRawLine(trimmed) {
10
+ if (trimmed.length === 0) {
11
+ return true;
12
+ }
13
+ if (trimmed.startsWith("#") || trimmed.startsWith(">") || trimmed.startsWith("```")) {
14
+ return true;
15
+ }
16
+ if (/^[-*+]\s/.test(trimmed)) {
17
+ return true;
18
+ }
19
+ if (/^\d+\.\s/.test(trimmed)) {
20
+ return true;
21
+ }
22
+ return false;
23
+ }
24
+ function normalizeLine(line) {
25
+ const trimmed = line.trim();
26
+ if (shouldKeepRawLine(trimmed)) {
27
+ return line;
28
+ }
29
+ return `- ${trimmed}`;
30
+ }
31
+ function normalizeLines(lines) {
32
+ return lines.map((line) => normalizeLine(line));
33
+ }
34
+ export function section(title, lines) {
35
+ return { title, lines: normalizeLines(lines) };
36
+ }
37
+ export function summarySection(lines) {
38
+ return section("Summary", lines);
39
+ }
40
+ export function evidenceSection(lines) {
41
+ return section("Evidence", lines);
42
+ }
43
+ export function guidanceSection(lines) {
44
+ return section("Agent Guidance", lines);
45
+ }
46
+ export function lintSection(lines) {
47
+ return section("Lint Suggestions", lines);
48
+ }
49
+ export function nextCallSection(nextCall) {
50
+ return section("Next Call", nextCall ? [nextCall] : []);
51
+ }
52
+ export function renderToolResponseMarkdown(payload) {
53
+ const body = payload.sections.flatMap((section) => [
54
+ `## ${section.title}`,
55
+ ...withFallback(section.lines),
56
+ "",
57
+ ]);
58
+ return [
59
+ `# ${payload.toolName}`,
60
+ "",
61
+ ...body,
62
+ ].join("\n").trimEnd();
63
+ }
64
+ export function renderErrorMarkdown(toolName, cause, nextSteps, retryExample) {
65
+ return renderToolResponseMarkdown({
66
+ toolName,
67
+ sections: [
68
+ section("Error", [`cause: ${cause}`]),
69
+ section("Next Step", nextSteps),
70
+ section("Retry Example", [retryExample ?? "(none)"]),
71
+ ],
72
+ });
73
+ }
@@ -0,0 +1,50 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { asText, nextCallSection, renderErrorMarkdown, renderToolResponseMarkdown, summarySection, } from "./response.js";
3
+ describe("response helpers", () => {
4
+ it("wraps markdown text as MCP text content", () => {
5
+ const result = asText("# hello");
6
+ expect(result.content).toEqual([{ type: "text", text: "# hello" }]);
7
+ });
8
+ it("renders error markdown sections", () => {
9
+ const markdown = renderErrorMarkdown("taskContext", "bad id", ["retry"], "taskContext(...)");
10
+ expect(markdown).toContain("# taskContext");
11
+ expect(markdown).toContain("## Error");
12
+ expect(markdown).toContain("- cause: bad id");
13
+ expect(markdown).toContain("- retry");
14
+ expect(markdown).toContain("## Retry Example");
15
+ });
16
+ it("renders standard tool response sections with fallback", () => {
17
+ const markdown = renderToolResponseMarkdown({
18
+ toolName: "taskList",
19
+ sections: [
20
+ { title: "Summary", lines: ["- governanceDir: /tmp/.projitive"] },
21
+ { title: "Evidence", lines: [] },
22
+ ],
23
+ });
24
+ expect(markdown).toContain("# taskList");
25
+ expect(markdown).toContain("## Summary");
26
+ expect(markdown).toContain("## Evidence");
27
+ expect(markdown).toContain("- (none)");
28
+ });
29
+ it("auto-prefixes plain lines in section helpers", () => {
30
+ const markdown = renderToolResponseMarkdown({
31
+ toolName: "taskList",
32
+ sections: [
33
+ summarySection(["governanceDir: /tmp/.projitive"]),
34
+ ],
35
+ });
36
+ expect(markdown).toContain("- governanceDir: /tmp/.projitive");
37
+ });
38
+ it("nextCallSection accepts optional call and falls back when missing", () => {
39
+ const withCall = renderToolResponseMarkdown({
40
+ toolName: "taskList",
41
+ sections: [nextCallSection("taskContext(projectPath=\"/tmp\", taskId=\"TASK-0001\")")],
42
+ });
43
+ expect(withCall).toContain("- taskContext(projectPath=\"/tmp\", taskId=\"TASK-0001\")");
44
+ const withoutCall = renderToolResponseMarkdown({
45
+ toolName: "taskList",
46
+ sections: [nextCallSection(undefined)],
47
+ });
48
+ expect(withoutCall).toContain("- (none)");
49
+ });
50
+ });
@@ -4,17 +4,14 @@ import process from "node:process";
4
4
  import { z } from "zod";
5
5
  import { discoverGovernanceArtifacts } from "./helpers/files/index.js";
6
6
  import { catchIt } from "./helpers/catch/index.js";
7
- import { loadTasks } from "./tasks.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";
8
10
  export const PROJECT_MARKER = ".projitive";
9
11
  const DEFAULT_GOVERNANCE_DIR = ".projitive";
10
12
  const ignoreNames = new Set(["node_modules", ".git", ".next", "dist", "build"]);
11
13
  const DEFAULT_SCAN_DEPTH = 3;
12
14
  const MAX_SCAN_DEPTH = 8;
13
- function asText(markdown) {
14
- return {
15
- content: [{ type: "text", text: markdown }],
16
- };
17
- }
18
15
  function normalizePath(inputPath) {
19
16
  return inputPath ? path.resolve(inputPath) : process.cwd();
20
17
  }
@@ -71,11 +68,22 @@ async function readTasksSnapshot(governanceDir) {
71
68
  const tasksPath = path.join(governanceDir, "tasks.md");
72
69
  const markdown = await fs.readFile(tasksPath, "utf-8").catch(() => undefined);
73
70
  if (typeof markdown !== "string") {
74
- return { tasksPath, exists: false, tasks: [] };
71
+ return {
72
+ tasksPath,
73
+ exists: false,
74
+ tasks: [],
75
+ lintSuggestions: renderLintSuggestions([
76
+ {
77
+ code: PROJECT_LINT_CODES.TASKS_FILE_MISSING,
78
+ message: "tasks.md is missing.",
79
+ fixHint: "Initialize governance tasks structure first.",
80
+ },
81
+ ]),
82
+ };
75
83
  }
76
84
  const { parseTasksBlock } = await import("./tasks.js");
77
85
  const tasks = await parseTasksBlock(markdown);
78
- return { tasksPath, exists: true, tasks };
86
+ return { tasksPath, exists: true, tasks, lintSuggestions: collectTaskLintSuggestions(tasks, markdown) };
79
87
  }
80
88
  function latestTaskUpdatedAt(tasks) {
81
89
  const timestamps = tasks
@@ -248,31 +256,35 @@ export function registerProjectTools(server) {
248
256
  updated: initialized.files.filter((item) => item.action === "updated"),
249
257
  skipped: initialized.files.filter((item) => item.action === "skipped"),
250
258
  };
251
- const markdown = [
252
- "# projectInit",
253
- "",
254
- "## Summary",
255
- `- rootPath: ${initialized.rootPath}`,
256
- `- governanceDir: ${initialized.governanceDir}`,
257
- `- markerPath: ${initialized.markerPath}`,
258
- `- force: ${force === true ? "true" : "false"}`,
259
- "",
260
- "## Evidence",
261
- `- createdFiles: ${filesByAction.created.length}`,
262
- `- updatedFiles: ${filesByAction.updated.length}`,
263
- `- skippedFiles: ${filesByAction.skipped.length}`,
264
- "- directories:",
265
- ...initialized.directories.map((item) => ` - ${item.action}: ${item.path}`),
266
- "- files:",
267
- ...initialized.files.map((item) => ` - ${item.action}: ${item.path}`),
268
- "",
269
- "## Agent Guidance",
270
- "- If files were skipped and you want to overwrite templates, rerun with force=true.",
271
- "- Continue with projectContext and taskList for execution.",
272
- "",
273
- "## Next Call",
274
- `- projectContext(projectPath=\"${initialized.governanceDir}\")`,
275
- ].join("\n");
259
+ const markdown = renderToolResponseMarkdown({
260
+ toolName: "projectInit",
261
+ sections: [
262
+ summarySection([
263
+ `- rootPath: ${initialized.rootPath}`,
264
+ `- governanceDir: ${initialized.governanceDir}`,
265
+ `- markerPath: ${initialized.markerPath}`,
266
+ `- force: ${force === true ? "true" : "false"}`,
267
+ ]),
268
+ evidenceSection([
269
+ `- createdFiles: ${filesByAction.created.length}`,
270
+ `- updatedFiles: ${filesByAction.updated.length}`,
271
+ `- skippedFiles: ${filesByAction.skipped.length}`,
272
+ "- directories:",
273
+ ...initialized.directories.map((item) => ` - ${item.action}: ${item.path}`),
274
+ "- files:",
275
+ ...initialized.files.map((item) => ` - ${item.action}: ${item.path}`),
276
+ ]),
277
+ guidanceSection([
278
+ "- If files were skipped and you want to overwrite templates, rerun with force=true.",
279
+ "- Continue with projectContext and taskList for execution.",
280
+ ]),
281
+ lintSection([
282
+ "- After init, fill owner/roadmapRefs/links in tasks.md before marking DONE.",
283
+ "- Keep task source-of-truth inside marker block only.",
284
+ ]),
285
+ nextCallSection(`projectContext(projectPath=\"${initialized.governanceDir}\")`),
286
+ ],
287
+ });
276
288
  return asText(markdown);
277
289
  });
278
290
  server.registerTool("projectScan", {
@@ -286,27 +298,30 @@ export function registerProjectTools(server) {
286
298
  const root = resolveScanRoot(rootPath);
287
299
  const depth = resolveScanDepth(maxDepth);
288
300
  const projects = await discoverProjects(root, depth);
289
- const markdown = [
290
- "# projectScan",
291
- "",
292
- "## Summary",
293
- `- rootPath: ${root}`,
294
- `- maxDepth: ${depth}`,
295
- `- discoveredCount: ${projects.length}`,
296
- "",
297
- "## Evidence",
298
- "- projects:",
299
- ...(projects.length > 0 ? projects.map((project, index) => `${index + 1}. ${project}`) : ["- (none)"]),
300
- "",
301
- "## Agent Guidance",
302
- "- Use one discovered project path and call `projectLocate` to lock governance root.",
303
- "- Then call `projectContext` to inspect current governance state.",
304
- "",
305
- "## Next Call",
306
- ...(projects.length > 0
307
- ? [`- projectLocate(inputPath=\"${projects[0]}\")`]
308
- : ["- (none)"]),
309
- ].join("\n");
301
+ const markdown = renderToolResponseMarkdown({
302
+ toolName: "projectScan",
303
+ sections: [
304
+ summarySection([
305
+ `- rootPath: ${root}`,
306
+ `- maxDepth: ${depth}`,
307
+ `- discoveredCount: ${projects.length}`,
308
+ ]),
309
+ evidenceSection([
310
+ "- projects:",
311
+ ...projects.map((project, index) => `${index + 1}. ${project}`),
312
+ ]),
313
+ guidanceSection([
314
+ "- Use one discovered project path and call `projectLocate` to lock governance root.",
315
+ "- Then call `projectContext` to inspect current governance state.",
316
+ ]),
317
+ lintSection(projects.length === 0
318
+ ? ["- No governance root discovered. Add `.projitive` marker and baseline artifacts before execution."]
319
+ : ["- Run `projectContext` on a discovered project to receive module-level lint suggestions."]),
320
+ nextCallSection(projects[0]
321
+ ? `projectLocate(inputPath=\"${projects[0]}\")`
322
+ : undefined),
323
+ ],
324
+ });
310
325
  return asText(markdown);
311
326
  });
312
327
  server.registerTool("projectNext", {
@@ -332,7 +347,7 @@ export function registerProjectTools(server) {
332
347
  governanceDir,
333
348
  tasksPath: snapshot.tasksPath,
334
349
  tasksExists: snapshot.exists,
335
- total: snapshot.tasks.length,
350
+ lintSuggestions: snapshot.lintSuggestions,
336
351
  inProgress,
337
352
  todo,
338
353
  blocked,
@@ -351,32 +366,31 @@ export function registerProjectTools(server) {
351
366
  return b.latestUpdatedAt.localeCompare(a.latestUpdatedAt);
352
367
  })
353
368
  .slice(0, limit ?? 10);
354
- const markdown = [
355
- "# projectNext",
356
- "",
357
- "## Summary",
358
- `- rootPath: ${root}`,
359
- `- maxDepth: ${depth}`,
360
- `- matchedProjects: ${projects.length}`,
361
- `- actionableProjects: ${ranked.length}`,
362
- `- limit: ${limit ?? 10}`,
363
- "",
364
- "## Evidence",
365
- "- rankedProjects:",
366
- ...(ranked.length > 0
367
- ? 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)"}`)
368
- : ["- (none)"]),
369
- "",
370
- "## Agent Guidance",
371
- "- Pick top 1 project and call `projectContext` with its governanceDir.",
372
- "- Then call `taskList` and `taskContext` to continue execution.",
373
- "- If `tasksPath` is missing, create tasks.md using project convention before task-level operations.",
374
- "",
375
- "## Next Call",
376
- ...(ranked.length > 0
377
- ? [`- projectContext(projectPath=\"${ranked[0].governanceDir}\")`]
378
- : ["- (none)"]),
379
- ].join("\n");
369
+ const markdown = renderToolResponseMarkdown({
370
+ toolName: "projectNext",
371
+ sections: [
372
+ summarySection([
373
+ `- rootPath: ${root}`,
374
+ `- maxDepth: ${depth}`,
375
+ `- matchedProjects: ${projects.length}`,
376
+ `- actionableProjects: ${ranked.length}`,
377
+ `- limit: ${limit ?? 10}`,
378
+ ]),
379
+ evidenceSection([
380
+ "- rankedProjects:",
381
+ ...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)"}`),
382
+ ]),
383
+ guidanceSection([
384
+ "- Pick top 1 project and call `projectContext` with its governanceDir.",
385
+ "- Then call `taskList` and `taskContext` to continue execution.",
386
+ "- If `tasksPath` is missing, create tasks.md using project convention before task-level operations.",
387
+ ]),
388
+ lintSection(ranked[0]?.lintSuggestions ?? []),
389
+ nextCallSection(ranked[0]
390
+ ? `projectContext(projectPath=\"${ranked[0].governanceDir}\")`
391
+ : undefined),
392
+ ],
393
+ });
380
394
  return asText(markdown);
381
395
  });
382
396
  server.registerTool("projectLocate", {
@@ -389,20 +403,19 @@ export function registerProjectTools(server) {
389
403
  const resolvedFrom = normalizePath(inputPath);
390
404
  const governanceDir = await resolveGovernanceDir(resolvedFrom);
391
405
  const markerPath = path.join(governanceDir, ".projitive");
392
- const markdown = [
393
- "# projectLocate",
394
- "",
395
- "## Summary",
396
- `- resolvedFrom: ${resolvedFrom}`,
397
- `- governanceDir: ${governanceDir}`,
398
- `- markerPath: ${markerPath}`,
399
- "",
400
- "## Agent Guidance",
401
- "- Call `projectContext` with this governanceDir to get task and roadmap summaries.",
402
- "",
403
- "## Next Call",
404
- `- projectContext(projectPath=\"${governanceDir}\")`,
405
- ].join("\n");
406
+ const markdown = renderToolResponseMarkdown({
407
+ toolName: "projectLocate",
408
+ sections: [
409
+ summarySection([
410
+ `- resolvedFrom: ${resolvedFrom}`,
411
+ `- governanceDir: ${governanceDir}`,
412
+ `- markerPath: ${markerPath}`,
413
+ ]),
414
+ guidanceSection(["- Call `projectContext` with this governanceDir to get task and roadmap summaries."]),
415
+ lintSection(["- Run `projectContext` to get governance/module lint suggestions for this project."]),
416
+ nextCallSection(`projectContext(projectPath=\"${governanceDir}\")`),
417
+ ],
418
+ });
406
419
  return asText(markdown);
407
420
  });
408
421
  server.registerTool("projectContext", {
@@ -414,8 +427,9 @@ export function registerProjectTools(server) {
414
427
  }, async ({ projectPath }) => {
415
428
  const governanceDir = await resolveGovernanceDir(projectPath);
416
429
  const artifacts = await discoverGovernanceArtifacts(governanceDir);
417
- const { tasksPath, tasks } = await loadTasks(governanceDir);
430
+ const { tasksPath, tasks, markdown: tasksMarkdown } = await loadTasksDocument(governanceDir);
418
431
  const roadmapIds = await readRoadmapIds(governanceDir);
432
+ const lintSuggestions = collectTaskLintSuggestions(tasks, tasksMarkdown);
419
433
  const taskSummary = {
420
434
  total: tasks.length,
421
435
  TODO: tasks.filter((task) => task.status === "TODO").length,
@@ -423,32 +437,33 @@ export function registerProjectTools(server) {
423
437
  BLOCKED: tasks.filter((task) => task.status === "BLOCKED").length,
424
438
  DONE: tasks.filter((task) => task.status === "DONE").length,
425
439
  };
426
- const markdown = [
427
- "# projectContext",
428
- "",
429
- "## Summary",
430
- `- governanceDir: ${governanceDir}`,
431
- `- tasksFile: ${tasksPath}`,
432
- `- roadmapIds: ${roadmapIds.length}`,
433
- "",
434
- "## Evidence",
435
- "### Task Summary",
436
- `- total: ${taskSummary.total}`,
437
- `- TODO: ${taskSummary.TODO}`,
438
- `- IN_PROGRESS: ${taskSummary.IN_PROGRESS}`,
439
- `- BLOCKED: ${taskSummary.BLOCKED}`,
440
- `- DONE: ${taskSummary.DONE}`,
441
- "",
442
- "### Artifacts",
443
- renderArtifactsMarkdown(artifacts),
444
- "",
445
- "## Agent Guidance",
446
- "- Start from `taskList` to choose a target task.",
447
- "- Then call `taskContext` with a task ID to retrieve evidence locations and reading order.",
448
- "",
449
- "## Next Call",
450
- `- taskList(projectPath=\"${governanceDir}\")`,
451
- ].join("\n");
440
+ const markdown = renderToolResponseMarkdown({
441
+ toolName: "projectContext",
442
+ sections: [
443
+ summarySection([
444
+ `- governanceDir: ${governanceDir}`,
445
+ `- tasksFile: ${tasksPath}`,
446
+ `- roadmapIds: ${roadmapIds.length}`,
447
+ ]),
448
+ evidenceSection([
449
+ "### Task Summary",
450
+ `- total: ${taskSummary.total}`,
451
+ `- TODO: ${taskSummary.TODO}`,
452
+ `- IN_PROGRESS: ${taskSummary.IN_PROGRESS}`,
453
+ `- BLOCKED: ${taskSummary.BLOCKED}`,
454
+ `- DONE: ${taskSummary.DONE}`,
455
+ "",
456
+ "### Artifacts",
457
+ renderArtifactsMarkdown(artifacts),
458
+ ]),
459
+ guidanceSection([
460
+ "- Start from `taskList` to choose a target task.",
461
+ "- Then call `taskContext` with a task ID to retrieve evidence locations and reading order.",
462
+ ]),
463
+ lintSection(lintSuggestions),
464
+ nextCallSection(`taskList(projectPath=\"${governanceDir}\")`),
465
+ ],
466
+ });
452
467
  return asText(markdown);
453
468
  });
454
469
  }
@@ -0,0 +1,20 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { describe, expect, it } from "vitest";
4
+ const MODULE_FILES = [
5
+ "tasks.ts",
6
+ "roadmap.ts",
7
+ "projitive.ts",
8
+ ];
9
+ const INVALID_LITERAL_PATTERN = /["'`]\s*-\s+(#{1,6}\s|>\s*|```)/g;
10
+ describe("rendering input guard", () => {
11
+ it("does not contain accidental bullet-prefixed markdown literals in module outputs", async () => {
12
+ const sourceDir = path.resolve(import.meta.dirname);
13
+ for (const file of MODULE_FILES) {
14
+ const filePath = path.join(sourceDir, file);
15
+ const content = await fs.readFile(filePath, "utf-8");
16
+ const matches = content.match(INVALID_LITERAL_PATTERN) ?? [];
17
+ expect(matches, `invalid literals in ${filePath}`).toHaveLength(0);
18
+ }
19
+ });
20
+ });