@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
package/output/tasks.js CHANGED
@@ -1,33 +1,20 @@
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";
5
6
  import { findTextReferences } from "./helpers/markdown/index.js";
7
+ import { asText, evidenceSection, guidanceSection, lintSection, nextCallSection, renderErrorMarkdown, renderToolResponseMarkdown, summarySection, } from "./helpers/response/index.js";
6
8
  import { catchIt } from "./helpers/catch/index.js";
9
+ import { TASK_LINT_CODES, renderLintSuggestions } from "./helpers/linter/index.js";
7
10
  import { resolveGovernanceDir, resolveScanDepth, resolveScanRoot, discoverProjects } from "./projitive.js";
8
11
  import { isValidRoadmapId } from "./roadmap.js";
9
12
  export const TASKS_START = "<!-- PROJITIVE:TASKS:START -->";
10
13
  export const TASKS_END = "<!-- PROJITIVE:TASKS:END -->";
11
14
  export const ALLOWED_STATUS = ["TODO", "IN_PROGRESS", "BLOCKED", "DONE"];
12
15
  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");
16
+ function appendLintSuggestions(target, suggestions) {
17
+ target.push(...renderLintSuggestions(suggestions));
31
18
  }
32
19
  function taskStatusGuidance(task) {
33
20
  if (task.status === "TODO") {
@@ -53,16 +40,6 @@ function taskStatusGuidance(task) {
53
40
  "- Keep report evidence immutable unless correction is required.",
54
41
  ];
55
42
  }
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
43
  async function readOptionalMarkdown(filePath) {
67
44
  const content = await fs.readFile(filePath, "utf-8").catch(() => undefined);
68
45
  if (typeof content !== "string") {
@@ -71,11 +48,34 @@ async function readOptionalMarkdown(filePath) {
71
48
  const trimmed = content.trim();
72
49
  return trimmed.length > 0 ? trimmed : undefined;
73
50
  }
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 };
51
+ const NO_TASK_DISCOVERY_HOOK_FILE = "task_no_actionable.md";
52
+ const DEFAULT_NO_TASK_DISCOVERY_GUIDANCE = [
53
+ "- Check whether current code violates project guide/spec conventions; create TODO tasks for each actionable gap.",
54
+ "- Check unit/integration test coverage and identify high-value missing tests; create TODO tasks for meaningful coverage improvements.",
55
+ "- Check development/testing workflow for bottlenecks (slow feedback, fragile scripts, unclear runbooks); create TODO tasks to improve reliability.",
56
+ "- Scan for TODO/FIXME/HACK comments and convert feasible items into governed TODO tasks with evidence links.",
57
+ "- Check dependency freshness and security advisories; create tasks for safe upgrades when needed.",
58
+ "- Check repeated manual operations that can be automated (lint/test/release checks); create tasks to reduce operational toil.",
59
+ ];
60
+ function parseHookChecklist(markdown) {
61
+ return markdown
62
+ .split(/\r?\n/)
63
+ .map((line) => line.trim())
64
+ .filter((line) => line.length > 0)
65
+ .filter((line) => /^[-*+]\s+/.test(line))
66
+ .map((line) => (line.startsWith("*") ? `-${line.slice(1)}` : line));
67
+ }
68
+ export async function resolveNoTaskDiscoveryGuidance(governanceDir) {
69
+ if (!governanceDir) {
70
+ return DEFAULT_NO_TASK_DISCOVERY_GUIDANCE;
71
+ }
72
+ const hookPath = path.join(governanceDir, "hooks", NO_TASK_DISCOVERY_HOOK_FILE);
73
+ const markdown = await readOptionalMarkdown(hookPath);
74
+ if (typeof markdown !== "string") {
75
+ return DEFAULT_NO_TASK_DISCOVERY_GUIDANCE;
76
+ }
77
+ const checklist = parseHookChecklist(markdown);
78
+ return checklist.length > 0 ? checklist : DEFAULT_NO_TASK_DISCOVERY_GUIDANCE;
79
79
  }
80
80
  function latestTaskUpdatedAt(tasks) {
81
81
  const timestamps = tasks
@@ -90,6 +90,31 @@ function actionableScore(tasks) {
90
90
  return tasks.filter((task) => task.status === "IN_PROGRESS").length * 2
91
91
  + tasks.filter((task) => task.status === "TODO").length;
92
92
  }
93
+ async function readRoadmapIds(governanceDir) {
94
+ const roadmapPath = path.join(governanceDir, "roadmap.md");
95
+ try {
96
+ const markdown = await fs.readFile(roadmapPath, "utf-8");
97
+ const matches = markdown.match(/ROADMAP-\d{4}/g) ?? [];
98
+ return Array.from(new Set(matches));
99
+ }
100
+ catch {
101
+ return [];
102
+ }
103
+ }
104
+ export function renderTaskSeedTemplate(roadmapRef) {
105
+ return [
106
+ "```markdown",
107
+ "## TASK-0001 | TODO | Define initial executable objective",
108
+ "- owner: ai-copilot",
109
+ "- summary: Convert one roadmap milestone or report gap into an actionable task.",
110
+ "- updatedAt: 2026-01-01T00:00:00.000Z",
111
+ `- roadmapRefs: ${roadmapRef}`,
112
+ "- links:",
113
+ " - ./README.md",
114
+ " - ./roadmap.md",
115
+ "```",
116
+ ];
117
+ }
93
118
  async function readActionableTaskCandidates(governanceDirs) {
94
119
  const snapshots = await Promise.all(governanceDirs.map(async (governanceDir) => {
95
120
  const snapshot = await loadTasks(governanceDir);
@@ -153,14 +178,6 @@ export function normalizeTask(task) {
153
178
  const normalizedRoadmapRefs = Array.isArray(task.roadmapRefs)
154
179
  ? task.roadmapRefs.map(String).filter((value) => isValidRoadmapId(value))
155
180
  : [];
156
- const inputHooks = task.hooks ?? {};
157
- const normalizedHooks = {};
158
- for (const key of ["onAssigned", "onCompleted", "onBlocked", "onReopened"]) {
159
- const value = inputHooks[key];
160
- if (typeof value === "string" && value.trim().length > 0) {
161
- normalizedHooks[key] = value;
162
- }
163
- }
164
181
  return {
165
182
  id: String(task.id),
166
183
  title: String(task.title),
@@ -170,7 +187,6 @@ export function normalizeTask(task) {
170
187
  updatedAt: task.updatedAt ? String(task.updatedAt) : nowIso(),
171
188
  links: Array.isArray(task.links) ? task.links.map(String) : [],
172
189
  roadmapRefs: Array.from(new Set(normalizedRoadmapRefs)),
173
- hooks: normalizedHooks,
174
190
  };
175
191
  }
176
192
  export async function parseTasksBlock(markdown) {
@@ -205,7 +221,6 @@ export async function parseTasksBlock(markdown) {
205
221
  updatedAt: nowIso(),
206
222
  links: [],
207
223
  roadmapRefs: [],
208
- hooks: {},
209
224
  };
210
225
  let inLinks = false;
211
226
  let inHooks = false;
@@ -268,32 +283,161 @@ export async function parseTasksBlock(markdown) {
268
283
  continue;
269
284
  }
270
285
  if (inHooks) {
271
- const hookMatch = nestedValue.match(/^(onAssigned|onCompleted|onBlocked|onReopened):\s+(.+)$/);
272
- if (hookMatch) {
273
- const [, hookKey, hookPath] = hookMatch;
274
- taskDraft.hooks = {
275
- ...(taskDraft.hooks ?? {}),
276
- [hookKey]: hookPath.trim(),
277
- };
278
- }
286
+ continue;
279
287
  }
280
288
  }
281
289
  tasks.push(normalizeTask(taskDraft));
282
290
  }
283
291
  return tasks;
284
292
  }
293
+ export function findTaskIdsOutsideMarkers(markdown) {
294
+ const start = markdown.indexOf(TASKS_START);
295
+ const end = markdown.indexOf(TASKS_END);
296
+ const outsideText = (start !== -1 && end !== -1 && end > start)
297
+ ? `${markdown.slice(0, start)}\n${markdown.slice(end + TASKS_END.length)}`
298
+ : markdown;
299
+ const ids = outsideText.match(/TASK-\d{4}/g) ?? [];
300
+ return Array.from(new Set(ids));
301
+ }
302
+ function collectTaskLintSuggestionItems(tasks, options = {}) {
303
+ const suggestions = [];
304
+ const { markdown, outsideMarkerScopeIds } = options;
305
+ const duplicateIds = Array.from(tasks.reduce((counter, task) => {
306
+ counter.set(task.id, (counter.get(task.id) ?? 0) + 1);
307
+ return counter;
308
+ }, new Map())
309
+ .entries())
310
+ .filter(([, count]) => count > 1)
311
+ .map(([id]) => id);
312
+ if (duplicateIds.length > 0) {
313
+ suggestions.push({
314
+ code: TASK_LINT_CODES.DUPLICATE_ID,
315
+ message: `Duplicate task IDs detected: ${duplicateIds.join(", ")}.`,
316
+ fixHint: "Keep task IDs unique in marker block.",
317
+ });
318
+ }
319
+ const inProgressWithoutOwner = tasks.filter((task) => task.status === "IN_PROGRESS" && task.owner.trim().length === 0);
320
+ if (inProgressWithoutOwner.length > 0) {
321
+ suggestions.push({
322
+ code: TASK_LINT_CODES.IN_PROGRESS_OWNER_EMPTY,
323
+ message: `${inProgressWithoutOwner.length} IN_PROGRESS task(s) have empty owner.`,
324
+ fixHint: "Set owner before continuing execution.",
325
+ });
326
+ }
327
+ const doneWithoutLinks = tasks.filter((task) => task.status === "DONE" && task.links.length === 0);
328
+ if (doneWithoutLinks.length > 0) {
329
+ suggestions.push({
330
+ code: TASK_LINT_CODES.DONE_LINKS_MISSING,
331
+ message: `${doneWithoutLinks.length} DONE task(s) have no links evidence.`,
332
+ fixHint: "Add at least one evidence link before keeping DONE.",
333
+ });
334
+ }
335
+ const blockedWithoutReason = tasks.filter((task) => task.status === "BLOCKED" && task.summary.trim().length === 0);
336
+ if (blockedWithoutReason.length > 0) {
337
+ suggestions.push({
338
+ code: TASK_LINT_CODES.BLOCKED_SUMMARY_EMPTY,
339
+ message: `${blockedWithoutReason.length} BLOCKED task(s) have empty summary.`,
340
+ fixHint: "Add blocker reason and unblock condition.",
341
+ });
342
+ }
343
+ const invalidUpdatedAt = tasks.filter((task) => !Number.isFinite(new Date(task.updatedAt).getTime()));
344
+ if (invalidUpdatedAt.length > 0) {
345
+ suggestions.push({
346
+ code: TASK_LINT_CODES.UPDATED_AT_INVALID,
347
+ message: `${invalidUpdatedAt.length} task(s) have invalid updatedAt format.`,
348
+ fixHint: "Use ISO8601 UTC timestamp.",
349
+ });
350
+ }
351
+ const missingRoadmapRefs = tasks.filter((task) => task.roadmapRefs.length === 0);
352
+ if (missingRoadmapRefs.length > 0) {
353
+ suggestions.push({
354
+ code: TASK_LINT_CODES.ROADMAP_REFS_EMPTY,
355
+ message: `${missingRoadmapRefs.length} task(s) have empty roadmapRefs.`,
356
+ fixHint: "Bind at least one ROADMAP-xxxx when applicable.",
357
+ });
358
+ }
359
+ if (typeof markdown === "string") {
360
+ const outsideMarkerTaskIds = findTaskIdsOutsideMarkers(markdown)
361
+ .filter((taskId) => (outsideMarkerScopeIds ? outsideMarkerScopeIds.has(taskId) : true));
362
+ if (outsideMarkerTaskIds.length > 0) {
363
+ suggestions.push({
364
+ code: TASK_LINT_CODES.OUTSIDE_MARKER,
365
+ message: `TASK IDs found outside marker block: ${outsideMarkerTaskIds.join(", ")}.`,
366
+ fixHint: "Keep task source of truth inside marker region only.",
367
+ });
368
+ }
369
+ }
370
+ return suggestions;
371
+ }
372
+ export function collectTaskLintSuggestions(tasks, markdown, outsideMarkerScopeIds) {
373
+ return renderLintSuggestions(collectTaskLintSuggestionItems(tasks, { markdown, outsideMarkerScopeIds }));
374
+ }
375
+ function collectSingleTaskLintSuggestions(task) {
376
+ const suggestions = [];
377
+ if (task.status === "IN_PROGRESS" && task.owner.trim().length === 0) {
378
+ suggestions.push({
379
+ code: TASK_LINT_CODES.IN_PROGRESS_OWNER_EMPTY,
380
+ message: "Current task is IN_PROGRESS but owner is empty.",
381
+ fixHint: "Set owner before continuing execution.",
382
+ });
383
+ }
384
+ if (task.status === "DONE" && task.links.length === 0) {
385
+ suggestions.push({
386
+ code: TASK_LINT_CODES.DONE_LINKS_MISSING,
387
+ message: "Current task is DONE but has no links evidence.",
388
+ fixHint: "Add at least one evidence link.",
389
+ });
390
+ }
391
+ if (task.status === "BLOCKED" && task.summary.trim().length === 0) {
392
+ suggestions.push({
393
+ code: TASK_LINT_CODES.BLOCKED_SUMMARY_EMPTY,
394
+ message: "Current task is BLOCKED but summary is empty.",
395
+ fixHint: "Add blocker reason and unblock condition.",
396
+ });
397
+ }
398
+ if (!Number.isFinite(new Date(task.updatedAt).getTime())) {
399
+ suggestions.push({
400
+ code: TASK_LINT_CODES.UPDATED_AT_INVALID,
401
+ message: "Current task updatedAt is invalid.",
402
+ fixHint: "Use ISO8601 UTC timestamp.",
403
+ });
404
+ }
405
+ if (task.roadmapRefs.length === 0) {
406
+ suggestions.push({
407
+ code: TASK_LINT_CODES.ROADMAP_REFS_EMPTY,
408
+ message: "Current task has empty roadmapRefs.",
409
+ fixHint: "Bind ROADMAP-xxxx where applicable.",
410
+ });
411
+ }
412
+ return renderLintSuggestions(suggestions);
413
+ }
414
+ async function collectTaskFileLintSuggestions(governanceDir, task) {
415
+ const suggestions = [];
416
+ for (const link of task.links) {
417
+ const normalized = link.trim();
418
+ if (normalized.length === 0) {
419
+ continue;
420
+ }
421
+ if (/^https?:\/\//i.test(normalized)) {
422
+ continue;
423
+ }
424
+ const resolvedPath = path.resolve(governanceDir, normalized);
425
+ const exists = await fs.access(resolvedPath).then(() => true).catch(() => false);
426
+ if (!exists) {
427
+ suggestions.push({
428
+ code: TASK_LINT_CODES.LINK_TARGET_MISSING,
429
+ message: `Link target not found: ${normalized} (resolved: ${resolvedPath}).`,
430
+ });
431
+ }
432
+ }
433
+ return renderLintSuggestions(suggestions);
434
+ }
285
435
  export function renderTasksMarkdown(tasks) {
286
436
  const sections = tasks.map((task) => {
287
437
  const roadmapRefs = task.roadmapRefs.length > 0 ? task.roadmapRefs.join(", ") : "(none)";
288
438
  const links = task.links.length > 0
289
439
  ? ["- links:", ...task.links.map((link) => ` - ${link}`)]
290
440
  : ["- links:", " - (none)"];
291
- const hookEntries = Object.entries(task.hooks)
292
- .filter(([, value]) => typeof value === "string" && value.trim().length > 0)
293
- .map(([key, value]) => ` - ${key}: ${value}`);
294
- const hooks = hookEntries.length > 0
295
- ? ["- hooks:", ...hookEntries]
296
- : ["- hooks:", " - (none)"];
297
441
  return [
298
442
  `## ${task.id} | ${task.status} | ${task.title}`,
299
443
  `- owner: ${task.owner || "(none)"}`,
@@ -301,7 +445,6 @@ export function renderTasksMarkdown(tasks) {
301
445
  `- updatedAt: ${task.updatedAt}`,
302
446
  `- roadmapRefs: ${roadmapRefs}`,
303
447
  ...links,
304
- ...hooks,
305
448
  ].join("\n");
306
449
  });
307
450
  return [
@@ -326,9 +469,13 @@ export async function ensureTasksFile(inputPath) {
326
469
  return tasksPath;
327
470
  }
328
471
  export async function loadTasks(inputPath) {
472
+ const { tasksPath, tasks } = await loadTasksDocument(inputPath);
473
+ return { tasksPath, tasks };
474
+ }
475
+ export async function loadTasksDocument(inputPath) {
329
476
  const tasksPath = await ensureTasksFile(inputPath);
330
477
  const markdown = await fs.readFile(tasksPath, "utf-8");
331
- return { tasksPath, tasks: await parseTasksBlock(markdown) };
478
+ return { tasksPath, markdown, tasks: await parseTasksBlock(markdown) };
332
479
  }
333
480
  export async function saveTasks(tasksPath, tasks) {
334
481
  const normalized = tasks.map((task) => normalizeTask(task));
@@ -357,31 +504,41 @@ export function registerTaskTools(server) {
357
504
  },
358
505
  }, async ({ projectPath, status, limit }) => {
359
506
  const governanceDir = await resolveGovernanceDir(projectPath);
360
- const { tasksPath, tasks } = await loadTasks(governanceDir);
507
+ const { tasksPath, tasks, markdown: tasksMarkdown } = await loadTasksDocument(governanceDir);
361
508
  const filtered = tasks
362
509
  .filter((task) => (status ? task.status === status : true))
363
510
  .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");
511
+ const lintSuggestions = collectTaskLintSuggestions(filtered, tasksMarkdown, new Set(filtered.map((task) => task.id)));
512
+ if (status && filtered.length === 0) {
513
+ appendLintSuggestions(lintSuggestions, [
514
+ {
515
+ code: TASK_LINT_CODES.FILTER_EMPTY,
516
+ message: `No tasks matched status=${status}.`,
517
+ fixHint: "Confirm status values or update task states.",
518
+ },
519
+ ]);
520
+ }
521
+ const nextTaskId = filtered[0]?.id;
522
+ const markdown = renderToolResponseMarkdown({
523
+ toolName: "taskList",
524
+ sections: [
525
+ summarySection([
526
+ `- governanceDir: ${governanceDir}`,
527
+ `- tasksPath: ${tasksPath}`,
528
+ `- filter.status: ${status ?? "(none)"}`,
529
+ `- returned: ${filtered.length}`,
530
+ ]),
531
+ evidenceSection([
532
+ "- tasks:",
533
+ ...filtered.map((task) => `- ${task.id} | ${task.status} | ${task.title} | owner=${task.owner || ""} | updatedAt=${task.updatedAt}`),
534
+ ]),
535
+ guidanceSection(["- Pick one task ID and call `taskContext`."]),
536
+ lintSection(lintSuggestions),
537
+ nextCallSection(nextTaskId
538
+ ? `taskContext(projectPath=\"${governanceDir}\", taskId=\"${nextTaskId}\")`
539
+ : undefined),
540
+ ],
541
+ });
385
542
  return asText(markdown);
386
543
  });
387
544
  server.registerTool("taskNext", {
@@ -398,29 +555,70 @@ export function registerTaskTools(server) {
398
555
  const projects = await discoverProjects(root, depth);
399
556
  const rankedCandidates = rankActionableTaskCandidates(await readActionableTaskCandidates(projects));
400
557
  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");
558
+ const projectSnapshots = await Promise.all(projects.map(async (governanceDir) => {
559
+ const { tasksPath, tasks } = await loadTasks(governanceDir);
560
+ const roadmapIds = await readRoadmapIds(governanceDir);
561
+ const todo = tasks.filter((task) => task.status === "TODO").length;
562
+ const inProgress = tasks.filter((task) => task.status === "IN_PROGRESS").length;
563
+ const blocked = tasks.filter((task) => task.status === "BLOCKED").length;
564
+ const done = tasks.filter((task) => task.status === "DONE").length;
565
+ return {
566
+ governanceDir,
567
+ tasksPath,
568
+ roadmapIds,
569
+ total: tasks.length,
570
+ todo,
571
+ inProgress,
572
+ blocked,
573
+ done,
574
+ };
575
+ }));
576
+ const preferredProject = projectSnapshots[0];
577
+ const preferredRoadmapRef = preferredProject?.roadmapIds[0] ?? "ROADMAP-0001";
578
+ const noTaskDiscoveryGuidance = await resolveNoTaskDiscoveryGuidance(preferredProject?.governanceDir);
579
+ const markdown = renderToolResponseMarkdown({
580
+ toolName: "taskNext",
581
+ sections: [
582
+ summarySection([
583
+ `- rootPath: ${root}`,
584
+ `- maxDepth: ${depth}`,
585
+ `- matchedProjects: ${projects.length}`,
586
+ "- actionableTasks: 0",
587
+ ]),
588
+ evidenceSection([
589
+ "### Project Snapshots",
590
+ ...(projectSnapshots.length > 0
591
+ ? projectSnapshots.map((item, index) => `${index + 1}. ${item.governanceDir} | total=${item.total} | todo=${item.todo} | in_progress=${item.inProgress} | blocked=${item.blocked} | done=${item.done} | roadmapIds=${item.roadmapIds.join(", ") || "(none)"} | tasksPath=${item.tasksPath}`)
592
+ : ["- (none)"]),
593
+ "",
594
+ "### Seed Task Template",
595
+ ...renderTaskSeedTemplate(preferredRoadmapRef),
596
+ ]),
597
+ guidanceSection([
598
+ "- No TODO/IN_PROGRESS task is available.",
599
+ "- Use no-task discovery checklist below to proactively find and create meaningful TODO tasks.",
600
+ "",
601
+ "### No-Task Discovery Checklist",
602
+ ...noTaskDiscoveryGuidance,
603
+ "",
604
+ "- If no tasks exist, derive 1-3 TODO tasks from roadmap milestones, README scope, or unresolved report gaps.",
605
+ "- If only BLOCKED/DONE tasks exist, reopen one blocked item or create a follow-up TODO task.",
606
+ "- After adding tasks inside marker block, rerun `taskNext` to re-rank actionable work.",
607
+ ]),
608
+ lintSection([
609
+ "- No actionable tasks found. Verify task statuses and required fields in marker block.",
610
+ "- Ensure each new task has stable TASK-xxxx ID and at least one roadmapRefs item.",
611
+ ]),
612
+ nextCallSection(preferredProject
613
+ ? `projectContext(projectPath=\"${preferredProject.governanceDir}\")`
614
+ : `projectScan(rootPath=\"${root}\", maxDepth=${depth})`),
615
+ ],
616
+ });
421
617
  return asText(markdown);
422
618
  }
423
619
  const selected = rankedCandidates[0];
620
+ const selectedTaskDocument = await loadTasksDocument(selected.governanceDir);
621
+ const lintSuggestions = collectTaskLintSuggestions(selectedTaskDocument.tasks, selectedTaskDocument.markdown);
424
622
  const artifacts = await discoverGovernanceArtifacts(selected.governanceDir);
425
623
  const fileCandidates = candidateFilesFromArtifacts(artifacts);
426
624
  const referenceLocations = (await Promise.all(fileCandidates.map((file) => findTextReferences(file, selected.task.id)))).flat();
@@ -428,55 +626,56 @@ export function registerTaskTools(server) {
428
626
  const relatedArtifacts = Array.from(new Set(referenceLocations.map((item) => item.filePath)));
429
627
  const suggestedReadOrder = [selected.tasksPath, ...relatedArtifacts.filter((item) => item !== selected.tasksPath)];
430
628
  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");
629
+ const markdown = renderToolResponseMarkdown({
630
+ toolName: "taskNext",
631
+ sections: [
632
+ summarySection([
633
+ `- rootPath: ${root}`,
634
+ `- maxDepth: ${depth}`,
635
+ `- matchedProjects: ${projects.length}`,
636
+ `- actionableTasks: ${rankedCandidates.length}`,
637
+ `- selectedProject: ${selected.governanceDir}`,
638
+ `- selectedTaskId: ${selected.task.id}`,
639
+ `- selectedTaskStatus: ${selected.task.status}`,
640
+ ]),
641
+ evidenceSection([
642
+ "### Selected Task",
643
+ `- id: ${selected.task.id}`,
644
+ `- title: ${selected.task.title}`,
645
+ `- owner: ${selected.task.owner || "(none)"}`,
646
+ `- updatedAt: ${selected.task.updatedAt}`,
647
+ `- roadmapRefs: ${selected.task.roadmapRefs.join(", ") || "(none)"}`,
648
+ `- taskLocation: ${taskLocation ? `${taskLocation.filePath}#L${taskLocation.line}` : selected.tasksPath}`,
649
+ "",
650
+ "### Top Candidates",
651
+ ...rankedCandidates
652
+ .slice(0, candidateLimit)
653
+ .map((item, index) => `${index + 1}. ${item.task.id} | ${item.task.status} | ${item.task.title} | project=${item.governanceDir} | projectScore=${item.projectScore} | latest=${item.projectLatestUpdatedAt}`),
654
+ "",
655
+ "### Selection Reason",
656
+ "- Rank rule: projectScore DESC -> taskPriority DESC -> taskUpdatedAt DESC.",
657
+ `- Selected candidate scores: projectScore=${selected.projectScore}, taskPriority=${selected.taskPriority}, taskUpdatedAtMs=${selected.taskUpdatedAtMs}.`,
658
+ "",
659
+ "### Related Artifacts",
660
+ ...(relatedArtifacts.length > 0 ? relatedArtifacts.map((file) => `- ${file}`) : ["- (none)"]),
661
+ "",
662
+ "### Reference Locations",
663
+ ...(referenceLocations.length > 0
664
+ ? referenceLocations.map((item) => `- ${item.filePath}#L${item.line}: ${item.text}`)
665
+ : ["- (none)"]),
666
+ "",
667
+ "### Suggested Read Order",
668
+ ...suggestedReadOrder.map((item, index) => `${index + 1}. ${item}`),
669
+ ]),
670
+ guidanceSection([
671
+ "- Start immediately with Suggested Read Order and execute the selected task.",
672
+ "- Update markdown artifacts directly while keeping TASK/ROADMAP IDs unchanged.",
673
+ "- Re-run `taskContext` for the selectedTaskId after edits to verify evidence consistency.",
674
+ ]),
675
+ lintSection(lintSuggestions),
676
+ nextCallSection(`taskContext(projectPath=\"${selected.governanceDir}\", taskId=\"${selected.task.id}\")`),
677
+ ],
678
+ });
480
679
  return asText(markdown);
481
680
  });
482
681
  server.registerTool("taskContext", {
@@ -489,75 +688,75 @@ export function registerTaskTools(server) {
489
688
  }, async ({ projectPath, taskId }) => {
490
689
  if (!isValidTaskId(taskId)) {
491
690
  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\")`)),
691
+ ...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
692
  isError: true,
494
693
  };
495
694
  }
496
695
  const governanceDir = await resolveGovernanceDir(projectPath);
497
- const { tasksPath, tasks } = await loadTasks(governanceDir);
498
- const taskContextHooks = await readTaskContextHooks(governanceDir);
696
+ const { tasksPath, tasks, markdown: tasksMarkdown } = await loadTasksDocument(governanceDir);
499
697
  const task = tasks.find((item) => item.id === taskId);
500
698
  if (!task) {
501
699
  return {
502
- ...asText(renderErrorMarkdown("taskContext", `Task not found: ${taskId}`, ["- run `taskList` to discover available IDs", "- retry with an existing task ID"], `taskList(projectPath=\"${governanceDir}\")`)),
700
+ ...asText(renderErrorMarkdown("taskContext", `Task not found: ${taskId}`, ["run `taskList` to discover available IDs", "retry with an existing task ID"], `taskList(projectPath=\"${governanceDir}\")`)),
503
701
  isError: true,
504
702
  };
505
703
  }
704
+ const lintSuggestions = [
705
+ ...collectSingleTaskLintSuggestions(task),
706
+ ...(await collectTaskFileLintSuggestions(governanceDir, task)),
707
+ ];
708
+ const outsideMarkerTaskIds = findTaskIdsOutsideMarkers(tasksMarkdown);
709
+ if (outsideMarkerTaskIds.includes(task.id)) {
710
+ appendLintSuggestions(lintSuggestions, [
711
+ {
712
+ code: TASK_LINT_CODES.OUTSIDE_MARKER,
713
+ message: `Current task ID appears outside marker block (${task.id}).`,
714
+ fixHint: "Keep task source of truth inside marker region.",
715
+ },
716
+ ]);
717
+ }
506
718
  const taskLocation = (await findTextReferences(tasksPath, taskId))[0];
507
719
  const artifacts = await discoverGovernanceArtifacts(governanceDir);
508
720
  const fileCandidates = candidateFilesFromArtifacts(artifacts);
509
721
  const referenceLocations = (await Promise.all(fileCandidates.map((file) => findTextReferences(file, taskId)))).flat();
510
722
  const relatedArtifacts = Array.from(new Set(referenceLocations.map((item) => item.filePath)));
511
723
  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);
724
+ const coreMarkdown = renderToolResponseMarkdown({
725
+ toolName: "taskContext",
726
+ sections: [
727
+ summarySection([
728
+ `- governanceDir: ${governanceDir}`,
729
+ `- taskId: ${task.id}`,
730
+ `- title: ${task.title}`,
731
+ `- status: ${task.status}`,
732
+ `- owner: ${task.owner}`,
733
+ `- updatedAt: ${task.updatedAt}`,
734
+ `- roadmapRefs: ${task.roadmapRefs.join(", ") || "(none)"}`,
735
+ `- taskLocation: ${taskLocation ? `${taskLocation.filePath}#L${taskLocation.line}` : tasksPath}`,
736
+ ]),
737
+ evidenceSection([
738
+ "### Related Artifacts",
739
+ ...(relatedArtifacts.length > 0 ? relatedArtifacts.map((file) => `- ${file}`) : ["- (none)"]),
740
+ "",
741
+ "### Reference Locations",
742
+ ...(referenceLocations.length > 0
743
+ ? referenceLocations.map((item) => `- ${item.filePath}#L${item.line}: ${item.text}`)
744
+ : ["- (none)"]),
745
+ "",
746
+ "### Suggested Read Order",
747
+ ...suggestedReadOrder.map((item, index) => `${index + 1}. ${item}`),
748
+ ]),
749
+ guidanceSection([
750
+ "- Read the files in Suggested Read Order.",
751
+ "- Verify whether current status and evidence are consistent.",
752
+ ...taskStatusGuidance(task),
753
+ "- If updates are needed, edit tasks/designs/reports markdown directly and keep TASK IDs unchanged.",
754
+ "- After editing, re-run `taskContext` to verify references and context consistency.",
755
+ ]),
756
+ lintSection(lintSuggestions),
757
+ nextCallSection(`taskContext(projectPath=\"${governanceDir}\", taskId=\"${task.id}\")`),
758
+ ],
759
+ });
760
+ return asText(coreMarkdown);
562
761
  });
563
762
  }