@projitive/mcp 1.0.1 → 1.0.3

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 (66) hide show
  1. package/README.md +44 -20
  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/hooks.js +1 -14
  14. package/output/hooks.test.js +7 -18
  15. package/output/index.js +23 -5
  16. package/output/package.json +36 -0
  17. package/output/projitive.js +158 -124
  18. package/output/projitive.test.js +1 -0
  19. package/output/rendering-input-guard.test.js +20 -0
  20. package/output/roadmap.js +106 -80
  21. package/output/roadmap.test.js +11 -0
  22. package/output/smoke-reports/2026-02-18T13-18-19-740Z/projectContext.md +48 -0
  23. package/output/smoke-reports/2026-02-18T13-18-19-740Z/projectInit.md +40 -0
  24. package/output/smoke-reports/2026-02-18T13-18-19-740Z/projectLocate.md +22 -0
  25. package/output/smoke-reports/2026-02-18T13-18-19-740Z/projectNext.md +31 -0
  26. package/output/smoke-reports/2026-02-18T13-18-19-740Z/projectScan.md +28 -0
  27. package/output/smoke-reports/2026-02-18T13-18-19-740Z/roadmapContext.md +33 -0
  28. package/output/smoke-reports/2026-02-18T13-18-19-740Z/roadmapList.md +25 -0
  29. package/output/smoke-reports/2026-02-18T13-18-19-740Z/summary.json +90 -0
  30. package/output/smoke-reports/2026-02-18T13-18-19-740Z/summary.md +17 -0
  31. package/output/smoke-reports/2026-02-18T13-18-19-740Z/taskContext.md +47 -0
  32. package/output/smoke-reports/2026-02-18T13-18-19-740Z/taskList.md +27 -0
  33. package/output/smoke-reports/2026-02-18T13-18-19-740Z/taskNext.md +64 -0
  34. package/output/source/designs.js +38 -0
  35. package/output/source/helpers/artifacts/artifacts.js +10 -0
  36. package/output/source/helpers/artifacts/artifacts.test.js +18 -0
  37. package/output/source/helpers/artifacts/index.js +1 -0
  38. package/output/source/helpers/catch/catch.js +48 -0
  39. package/output/source/helpers/catch/catch.test.js +43 -0
  40. package/output/source/helpers/catch/index.js +1 -0
  41. package/output/source/helpers/files/files.js +62 -0
  42. package/output/source/helpers/files/files.test.js +32 -0
  43. package/output/source/helpers/files/index.js +1 -0
  44. package/output/source/helpers/index.js +6 -0
  45. package/output/source/helpers/linter/codes.js +25 -0
  46. package/output/source/helpers/linter/index.js +2 -0
  47. package/output/source/helpers/linter/linter.js +6 -0
  48. package/output/source/helpers/linter/linter.test.js +16 -0
  49. package/output/source/helpers/markdown/index.js +1 -0
  50. package/output/source/helpers/markdown/markdown.js +33 -0
  51. package/output/source/helpers/markdown/markdown.test.js +36 -0
  52. package/output/source/helpers/response/index.js +1 -0
  53. package/output/source/helpers/response/response.js +73 -0
  54. package/output/source/helpers/response/response.test.js +50 -0
  55. package/output/source/index.js +215 -0
  56. package/output/source/projitive.js +488 -0
  57. package/output/source/projitive.test.js +75 -0
  58. package/output/source/readme.js +26 -0
  59. package/output/source/reports.js +36 -0
  60. package/output/source/roadmap.js +165 -0
  61. package/output/source/roadmap.test.js +11 -0
  62. package/output/source/tasks.js +762 -0
  63. package/output/source/tasks.test.js +152 -0
  64. package/output/tasks.js +403 -204
  65. package/output/tasks.test.js +78 -4
  66. package/package.json +1 -1
@@ -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
@@ -193,12 +201,30 @@ function defaultTasksMarkdown() {
193
201
  "- owner: unassigned",
194
202
  "- summary: Create initial governance artifacts and confirm task execution loop.",
195
203
  `- updatedAt: ${updatedAt}`,
196
- "- links:",
197
204
  "- roadmapRefs: ROADMAP-0001",
198
- "- hooks:",
205
+ "- links:",
206
+ " - (none)",
199
207
  "<!-- PROJITIVE:TASKS:END -->",
200
208
  ].join("\n");
201
209
  }
210
+ function defaultNoTaskDiscoveryHookMarkdown() {
211
+ return [
212
+ "Objective:",
213
+ "- When no actionable task exists, proactively discover meaningful work and convert it into TODO tasks.",
214
+ "",
215
+ "Checklist:",
216
+ "- Check whether code violates project guides/specs; create tasks for each actionable gap.",
217
+ "- Check test coverage improvement opportunities; create tasks for high-value missing tests.",
218
+ "- Check development/testing workflow bottlenecks; create tasks for reliability and speed improvements.",
219
+ "- Check TODO/FIXME/HACK comments; turn feasible items into governed tasks.",
220
+ "- Check dependency/security hygiene and stale tooling; create tasks where upgrades are justified.",
221
+ "",
222
+ "Output Format:",
223
+ "- Candidate findings (3-10)",
224
+ "- Proposed tasks (TASK-xxxx style)",
225
+ "- Priority rationale",
226
+ ].join("\n");
227
+ }
202
228
  export async function initializeProjectStructure(inputPath, governanceDir, force = false) {
203
229
  const rootPath = normalizePath(inputPath);
204
230
  const governanceDirName = normalizeGovernanceDirName(governanceDir);
@@ -223,6 +249,7 @@ export async function initializeProjectStructure(inputPath, governanceDir, force
223
249
  writeTextFile(path.join(governancePath, "README.md"), defaultReadmeMarkdown(governanceDirName), force),
224
250
  writeTextFile(path.join(governancePath, "roadmap.md"), defaultRoadmapMarkdown(), force),
225
251
  writeTextFile(path.join(governancePath, "tasks.md"), defaultTasksMarkdown(), force),
252
+ writeTextFile(path.join(governancePath, "hooks", "task_no_actionable.md"), defaultNoTaskDiscoveryHookMarkdown(), force),
226
253
  ]);
227
254
  return {
228
255
  rootPath,
@@ -248,31 +275,35 @@ export function registerProjectTools(server) {
248
275
  updated: initialized.files.filter((item) => item.action === "updated"),
249
276
  skipped: initialized.files.filter((item) => item.action === "skipped"),
250
277
  };
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");
278
+ const markdown = renderToolResponseMarkdown({
279
+ toolName: "projectInit",
280
+ sections: [
281
+ summarySection([
282
+ `- rootPath: ${initialized.rootPath}`,
283
+ `- governanceDir: ${initialized.governanceDir}`,
284
+ `- markerPath: ${initialized.markerPath}`,
285
+ `- force: ${force === true ? "true" : "false"}`,
286
+ ]),
287
+ evidenceSection([
288
+ `- createdFiles: ${filesByAction.created.length}`,
289
+ `- updatedFiles: ${filesByAction.updated.length}`,
290
+ `- skippedFiles: ${filesByAction.skipped.length}`,
291
+ "- directories:",
292
+ ...initialized.directories.map((item) => ` - ${item.action}: ${item.path}`),
293
+ "- files:",
294
+ ...initialized.files.map((item) => ` - ${item.action}: ${item.path}`),
295
+ ]),
296
+ guidanceSection([
297
+ "- If files were skipped and you want to overwrite templates, rerun with force=true.",
298
+ "- Continue with projectContext and taskList for execution.",
299
+ ]),
300
+ lintSection([
301
+ "- After init, fill owner/roadmapRefs/links in tasks.md before marking DONE.",
302
+ "- Keep task source-of-truth inside marker block only.",
303
+ ]),
304
+ nextCallSection(`projectContext(projectPath=\"${initialized.governanceDir}\")`),
305
+ ],
306
+ });
276
307
  return asText(markdown);
277
308
  });
278
309
  server.registerTool("projectScan", {
@@ -286,27 +317,30 @@ export function registerProjectTools(server) {
286
317
  const root = resolveScanRoot(rootPath);
287
318
  const depth = resolveScanDepth(maxDepth);
288
319
  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");
320
+ const markdown = renderToolResponseMarkdown({
321
+ toolName: "projectScan",
322
+ sections: [
323
+ summarySection([
324
+ `- rootPath: ${root}`,
325
+ `- maxDepth: ${depth}`,
326
+ `- discoveredCount: ${projects.length}`,
327
+ ]),
328
+ evidenceSection([
329
+ "- projects:",
330
+ ...projects.map((project, index) => `${index + 1}. ${project}`),
331
+ ]),
332
+ guidanceSection([
333
+ "- Use one discovered project path and call `projectLocate` to lock governance root.",
334
+ "- Then call `projectContext` to inspect current governance state.",
335
+ ]),
336
+ lintSection(projects.length === 0
337
+ ? ["- No governance root discovered. Add `.projitive` marker and baseline artifacts before execution."]
338
+ : ["- Run `projectContext` on a discovered project to receive module-level lint suggestions."]),
339
+ nextCallSection(projects[0]
340
+ ? `projectLocate(inputPath=\"${projects[0]}\")`
341
+ : undefined),
342
+ ],
343
+ });
310
344
  return asText(markdown);
311
345
  });
312
346
  server.registerTool("projectNext", {
@@ -332,7 +366,7 @@ export function registerProjectTools(server) {
332
366
  governanceDir,
333
367
  tasksPath: snapshot.tasksPath,
334
368
  tasksExists: snapshot.exists,
335
- total: snapshot.tasks.length,
369
+ lintSuggestions: snapshot.lintSuggestions,
336
370
  inProgress,
337
371
  todo,
338
372
  blocked,
@@ -351,32 +385,31 @@ export function registerProjectTools(server) {
351
385
  return b.latestUpdatedAt.localeCompare(a.latestUpdatedAt);
352
386
  })
353
387
  .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");
388
+ const markdown = renderToolResponseMarkdown({
389
+ toolName: "projectNext",
390
+ sections: [
391
+ summarySection([
392
+ `- rootPath: ${root}`,
393
+ `- maxDepth: ${depth}`,
394
+ `- matchedProjects: ${projects.length}`,
395
+ `- actionableProjects: ${ranked.length}`,
396
+ `- limit: ${limit ?? 10}`,
397
+ ]),
398
+ evidenceSection([
399
+ "- rankedProjects:",
400
+ ...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)"}`),
401
+ ]),
402
+ guidanceSection([
403
+ "- Pick top 1 project and call `projectContext` with its governanceDir.",
404
+ "- Then call `taskList` and `taskContext` to continue execution.",
405
+ "- If `tasksPath` is missing, create tasks.md using project convention before task-level operations.",
406
+ ]),
407
+ lintSection(ranked[0]?.lintSuggestions ?? []),
408
+ nextCallSection(ranked[0]
409
+ ? `projectContext(projectPath=\"${ranked[0].governanceDir}\")`
410
+ : undefined),
411
+ ],
412
+ });
380
413
  return asText(markdown);
381
414
  });
382
415
  server.registerTool("projectLocate", {
@@ -389,20 +422,19 @@ export function registerProjectTools(server) {
389
422
  const resolvedFrom = normalizePath(inputPath);
390
423
  const governanceDir = await resolveGovernanceDir(resolvedFrom);
391
424
  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");
425
+ const markdown = renderToolResponseMarkdown({
426
+ toolName: "projectLocate",
427
+ sections: [
428
+ summarySection([
429
+ `- resolvedFrom: ${resolvedFrom}`,
430
+ `- governanceDir: ${governanceDir}`,
431
+ `- markerPath: ${markerPath}`,
432
+ ]),
433
+ guidanceSection(["- Call `projectContext` with this governanceDir to get task and roadmap summaries."]),
434
+ lintSection(["- Run `projectContext` to get governance/module lint suggestions for this project."]),
435
+ nextCallSection(`projectContext(projectPath=\"${governanceDir}\")`),
436
+ ],
437
+ });
406
438
  return asText(markdown);
407
439
  });
408
440
  server.registerTool("projectContext", {
@@ -414,8 +446,9 @@ export function registerProjectTools(server) {
414
446
  }, async ({ projectPath }) => {
415
447
  const governanceDir = await resolveGovernanceDir(projectPath);
416
448
  const artifacts = await discoverGovernanceArtifacts(governanceDir);
417
- const { tasksPath, tasks } = await loadTasks(governanceDir);
449
+ const { tasksPath, tasks, markdown: tasksMarkdown } = await loadTasksDocument(governanceDir);
418
450
  const roadmapIds = await readRoadmapIds(governanceDir);
451
+ const lintSuggestions = collectTaskLintSuggestions(tasks, tasksMarkdown);
419
452
  const taskSummary = {
420
453
  total: tasks.length,
421
454
  TODO: tasks.filter((task) => task.status === "TODO").length,
@@ -423,32 +456,33 @@ export function registerProjectTools(server) {
423
456
  BLOCKED: tasks.filter((task) => task.status === "BLOCKED").length,
424
457
  DONE: tasks.filter((task) => task.status === "DONE").length,
425
458
  };
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");
459
+ const markdown = renderToolResponseMarkdown({
460
+ toolName: "projectContext",
461
+ sections: [
462
+ summarySection([
463
+ `- governanceDir: ${governanceDir}`,
464
+ `- tasksFile: ${tasksPath}`,
465
+ `- roadmapIds: ${roadmapIds.length}`,
466
+ ]),
467
+ evidenceSection([
468
+ "### Task Summary",
469
+ `- total: ${taskSummary.total}`,
470
+ `- TODO: ${taskSummary.TODO}`,
471
+ `- IN_PROGRESS: ${taskSummary.IN_PROGRESS}`,
472
+ `- BLOCKED: ${taskSummary.BLOCKED}`,
473
+ `- DONE: ${taskSummary.DONE}`,
474
+ "",
475
+ "### Artifacts",
476
+ renderArtifactsMarkdown(artifacts),
477
+ ]),
478
+ guidanceSection([
479
+ "- Start from `taskList` to choose a target task.",
480
+ "- Then call `taskContext` with a task ID to retrieve evidence locations and reading order.",
481
+ ]),
482
+ lintSection(lintSuggestions),
483
+ nextCallSection(`taskList(projectPath=\"${governanceDir}\")`),
484
+ ],
485
+ });
452
486
  return asText(markdown);
453
487
  });
454
488
  }
@@ -52,6 +52,7 @@ describe("projitive module", () => {
52
52
  path.join(root, ".projitive", "README.md"),
53
53
  path.join(root, ".projitive", "roadmap.md"),
54
54
  path.join(root, ".projitive", "tasks.md"),
55
+ path.join(root, ".projitive", "hooks", "task_no_actionable.md"),
55
56
  path.join(root, ".projitive", "designs"),
56
57
  path.join(root, ".projitive", "reports"),
57
58
  path.join(root, ".projitive", "hooks"),
@@ -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
+ });
package/output/roadmap.js CHANGED
@@ -1,39 +1,60 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { z } from "zod";
4
+ import { candidateFilesFromArtifacts } from "./helpers/artifacts/index.js";
4
5
  import { discoverGovernanceArtifacts } from "./helpers/files/index.js";
6
+ import { ROADMAP_LINT_CODES, renderLintSuggestions } from "./helpers/linter/index.js";
5
7
  import { findTextReferences } from "./helpers/markdown/index.js";
8
+ import { asText, evidenceSection, guidanceSection, lintSection, nextCallSection, renderErrorMarkdown, renderToolResponseMarkdown, summarySection, } from "./helpers/response/index.js";
6
9
  import { resolveGovernanceDir } from "./projitive.js";
7
10
  import { loadTasks } from "./tasks.js";
8
11
  export const ROADMAP_ID_REGEX = /^ROADMAP-\d{4}$/;
9
- function asText(markdown) {
10
- return {
11
- content: [{ type: "text", text: markdown }],
12
- };
13
- }
14
- function renderErrorMarkdown(toolName, cause, nextSteps, retryExample) {
15
- return [
16
- `# ${toolName}`,
17
- "",
18
- "## Error",
19
- `- cause: ${cause}`,
20
- "",
21
- "## Next Step",
22
- ...(nextSteps.length > 0 ? nextSteps : ["- (none)"]),
23
- "",
24
- "## Retry Example",
25
- `- ${retryExample ?? "(none)"}`,
26
- ].join("\n");
12
+ function collectRoadmapLintSuggestionItems(roadmapIds, tasks) {
13
+ const suggestions = [];
14
+ if (roadmapIds.length === 0) {
15
+ suggestions.push({
16
+ code: ROADMAP_LINT_CODES.IDS_EMPTY,
17
+ message: "No roadmap IDs found in roadmap.md.",
18
+ fixHint: "Add at least one ROADMAP-xxxx milestone.",
19
+ });
20
+ }
21
+ if (tasks.length === 0) {
22
+ suggestions.push({
23
+ code: ROADMAP_LINT_CODES.TASKS_EMPTY,
24
+ message: "No tasks found in tasks.md.",
25
+ fixHint: "Add task cards and bind roadmapRefs for traceability.",
26
+ });
27
+ return suggestions;
28
+ }
29
+ const roadmapSet = new Set(roadmapIds);
30
+ const unboundTasks = tasks.filter((task) => task.roadmapRefs.length === 0);
31
+ if (unboundTasks.length > 0) {
32
+ suggestions.push({
33
+ code: ROADMAP_LINT_CODES.TASK_REFS_EMPTY,
34
+ message: `${unboundTasks.length} task(s) have empty roadmapRefs.`,
35
+ fixHint: "Bind ROADMAP-xxxx where applicable.",
36
+ });
37
+ }
38
+ const unknownRefs = Array.from(new Set(tasks.flatMap((task) => task.roadmapRefs).filter((id) => !roadmapSet.has(id))));
39
+ if (unknownRefs.length > 0) {
40
+ suggestions.push({
41
+ code: ROADMAP_LINT_CODES.UNKNOWN_REFS,
42
+ message: `Unknown roadmapRefs detected: ${unknownRefs.join(", ")}.`,
43
+ fixHint: "Add missing roadmap IDs or fix task references.",
44
+ });
45
+ }
46
+ const noLinkedRoadmaps = roadmapIds.filter((id) => !tasks.some((task) => task.roadmapRefs.includes(id)));
47
+ if (noLinkedRoadmaps.length > 0) {
48
+ suggestions.push({
49
+ code: ROADMAP_LINT_CODES.ZERO_LINKED_TASKS,
50
+ message: `${noLinkedRoadmaps.length} roadmap ID(s) have zero linked tasks.`,
51
+ fixHint: `Consider binding tasks to: ${noLinkedRoadmaps.slice(0, 3).join(", ")}${noLinkedRoadmaps.length > 3 ? ", ..." : ""}.`,
52
+ });
53
+ }
54
+ return suggestions;
27
55
  }
28
- function candidateFilesFromArtifacts(artifacts) {
29
- return artifacts
30
- .filter((item) => item.exists)
31
- .flatMap((item) => {
32
- if (item.kind === "file") {
33
- return [item.path];
34
- }
35
- return (item.markdownFiles ?? []).map((entry) => entry.path);
36
- });
56
+ export function collectRoadmapLintSuggestions(roadmapIds, tasks) {
57
+ return renderLintSuggestions(collectRoadmapLintSuggestionItems(roadmapIds, tasks));
37
58
  }
38
59
  export function isValidRoadmapId(id) {
39
60
  return ROADMAP_ID_REGEX.test(id);
@@ -60,30 +81,28 @@ export function registerRoadmapTools(server) {
60
81
  const governanceDir = await resolveGovernanceDir(projectPath);
61
82
  const roadmapIds = await readRoadmapIds(governanceDir);
62
83
  const { tasks } = await loadTasks(governanceDir);
63
- const markdown = [
64
- "# roadmapList",
65
- "",
66
- "## Summary",
67
- `- governanceDir: ${governanceDir}`,
68
- `- roadmapCount: ${roadmapIds.length}`,
69
- "",
70
- "## Evidence",
71
- "- roadmaps:",
72
- ...(roadmapIds.length > 0
73
- ? roadmapIds.map((id) => {
74
- const linkedTasks = tasks.filter((task) => task.roadmapRefs.includes(id));
75
- return `- ${id} | linkedTasks=${linkedTasks.length}`;
76
- })
77
- : ["- (none)"]),
78
- "",
79
- "## Agent Guidance",
80
- "- Pick one roadmap ID and call `roadmapContext`.",
81
- "",
82
- "## Next Call",
83
- ...(roadmapIds.length > 0
84
- ? [`- roadmapContext(projectPath=\"${governanceDir}\", roadmapId=\"${roadmapIds[0]}\")`]
85
- : ["- (none)"]),
86
- ].join("\n");
84
+ const lintSuggestions = collectRoadmapLintSuggestions(roadmapIds, tasks);
85
+ const markdown = renderToolResponseMarkdown({
86
+ toolName: "roadmapList",
87
+ sections: [
88
+ summarySection([
89
+ `- governanceDir: ${governanceDir}`,
90
+ `- roadmapCount: ${roadmapIds.length}`,
91
+ ]),
92
+ evidenceSection([
93
+ "- roadmaps:",
94
+ ...roadmapIds.map((id) => {
95
+ const linkedTasks = tasks.filter((task) => task.roadmapRefs.includes(id));
96
+ return `- ${id} | linkedTasks=${linkedTasks.length}`;
97
+ }),
98
+ ]),
99
+ guidanceSection(["- Pick one roadmap ID and call `roadmapContext`."]),
100
+ lintSection(lintSuggestions),
101
+ nextCallSection(roadmapIds[0]
102
+ ? `roadmapContext(projectPath=\"${governanceDir}\", roadmapId=\"${roadmapIds[0]}\")`
103
+ : undefined),
104
+ ],
105
+ });
87
106
  return asText(markdown);
88
107
  });
89
108
  server.registerTool("roadmapContext", {
@@ -96,7 +115,7 @@ export function registerRoadmapTools(server) {
96
115
  }, async ({ projectPath, roadmapId }) => {
97
116
  if (!isValidRoadmapId(roadmapId)) {
98
117
  return {
99
- ...asText(renderErrorMarkdown("roadmapContext", `Invalid roadmap ID format: ${roadmapId}`, ["- expected format: ROADMAP-0001", "- retry with a valid roadmap ID"], `roadmapContext(projectPath=\"${projectPath}\", roadmapId=\"ROADMAP-0001\")`)),
118
+ ...asText(renderErrorMarkdown("roadmapContext", `Invalid roadmap ID format: ${roadmapId}`, ["expected format: ROADMAP-0001", "retry with a valid roadmap ID"], `roadmapContext(projectPath=\"${projectPath}\", roadmapId=\"ROADMAP-0001\")`)),
100
119
  isError: true,
101
120
  };
102
121
  }
@@ -106,34 +125,41 @@ export function registerRoadmapTools(server) {
106
125
  const referenceLocations = (await Promise.all(fileCandidates.map((file) => findTextReferences(file, roadmapId)))).flat();
107
126
  const { tasks } = await loadTasks(governanceDir);
108
127
  const relatedTasks = tasks.filter((task) => task.roadmapRefs.includes(roadmapId));
109
- const markdown = [
110
- "# roadmapContext",
111
- "",
112
- "## Summary",
113
- `- governanceDir: ${governanceDir}`,
114
- `- roadmapId: ${roadmapId}`,
115
- `- relatedTasks: ${relatedTasks.length}`,
116
- `- references: ${referenceLocations.length}`,
117
- "",
118
- "## Evidence",
119
- "### Related Tasks",
120
- ...(relatedTasks.length > 0
121
- ? relatedTasks.map((task) => `- ${task.id} | ${task.status} | ${task.title}`)
122
- : ["- (none)"]),
123
- "",
124
- "### Reference Locations",
125
- ...(referenceLocations.length > 0
126
- ? referenceLocations.map((item) => `- ${item.filePath}#L${item.line}: ${item.text}`)
127
- : ["- (none)"]),
128
- "",
129
- "## Agent Guidance",
130
- "- Read roadmap references first, then related tasks.",
131
- "- Keep ROADMAP/TASK IDs unchanged while updating markdown files.",
132
- "- Re-run `roadmapContext` after edits to confirm references remain consistent.",
133
- "",
134
- "## Next Call",
135
- `- roadmapContext(projectPath=\"${governanceDir}\", roadmapId=\"${roadmapId}\")`,
136
- ].join("\n");
128
+ const roadmapIds = await readRoadmapIds(governanceDir);
129
+ const lintSuggestionItems = collectRoadmapLintSuggestionItems(roadmapIds, tasks);
130
+ if (relatedTasks.length === 0) {
131
+ lintSuggestionItems.push({
132
+ code: ROADMAP_LINT_CODES.CONTEXT_RELATED_TASKS_EMPTY,
133
+ message: `relatedTasks=0 for ${roadmapId}.`,
134
+ fixHint: "Batch bind task roadmapRefs to improve execution traceability.",
135
+ });
136
+ }
137
+ const lintSuggestions = renderLintSuggestions(lintSuggestionItems);
138
+ const markdown = renderToolResponseMarkdown({
139
+ toolName: "roadmapContext",
140
+ sections: [
141
+ summarySection([
142
+ `- governanceDir: ${governanceDir}`,
143
+ `- roadmapId: ${roadmapId}`,
144
+ `- relatedTasks: ${relatedTasks.length}`,
145
+ `- references: ${referenceLocations.length}`,
146
+ ]),
147
+ evidenceSection([
148
+ "### Related Tasks",
149
+ ...relatedTasks.map((task) => `- ${task.id} | ${task.status} | ${task.title}`),
150
+ "",
151
+ "### Reference Locations",
152
+ ...referenceLocations.map((item) => `- ${item.filePath}#L${item.line}: ${item.text}`),
153
+ ]),
154
+ guidanceSection([
155
+ "- Read roadmap references first, then related tasks.",
156
+ "- Keep ROADMAP/TASK IDs unchanged while updating markdown files.",
157
+ "- Re-run `roadmapContext` after edits to confirm references remain consistent.",
158
+ ]),
159
+ lintSection(lintSuggestions),
160
+ nextCallSection(`roadmapContext(projectPath=\"${governanceDir}\", roadmapId=\"${roadmapId}\")`),
161
+ ],
162
+ });
137
163
  return asText(markdown);
138
164
  });
139
165
  }
@@ -0,0 +1,11 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { collectRoadmapLintSuggestions } from "./roadmap.js";
3
+ import { normalizeTask } from "./tasks.js";
4
+ describe("roadmap lint rendering alignment", () => {
5
+ it("renders roadmap lint in code-prefixed markdown lines", () => {
6
+ const lint = collectRoadmapLintSuggestions(["ROADMAP-0001"], [
7
+ normalizeTask({ id: "TASK-0001", title: "x", status: "TODO", roadmapRefs: [] }),
8
+ ]);
9
+ expect(lint.some((line) => line.startsWith("- [ROADMAP_TASK_REFS_EMPTY]"))).toBe(true);
10
+ });
11
+ });