@projitive/mcp 1.0.0 → 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 (33) hide show
  1. package/README.md +29 -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/index.js +1 -0
  14. package/output/projitive.js +252 -97
  15. package/output/projitive.test.js +29 -1
  16. package/output/rendering-input-guard.test.js +20 -0
  17. package/output/roadmap.js +106 -80
  18. package/output/roadmap.test.js +11 -0
  19. package/output/smoke-reports/2026-02-18T13-18-19-740Z/projectContext.md +48 -0
  20. package/output/smoke-reports/2026-02-18T13-18-19-740Z/projectInit.md +40 -0
  21. package/output/smoke-reports/2026-02-18T13-18-19-740Z/projectLocate.md +22 -0
  22. package/output/smoke-reports/2026-02-18T13-18-19-740Z/projectNext.md +31 -0
  23. package/output/smoke-reports/2026-02-18T13-18-19-740Z/projectScan.md +28 -0
  24. package/output/smoke-reports/2026-02-18T13-18-19-740Z/roadmapContext.md +33 -0
  25. package/output/smoke-reports/2026-02-18T13-18-19-740Z/roadmapList.md +25 -0
  26. package/output/smoke-reports/2026-02-18T13-18-19-740Z/summary.json +90 -0
  27. package/output/smoke-reports/2026-02-18T13-18-19-740Z/summary.md +17 -0
  28. package/output/smoke-reports/2026-02-18T13-18-19-740Z/taskContext.md +47 -0
  29. package/output/smoke-reports/2026-02-18T13-18-19-740Z/taskList.md +27 -0
  30. package/output/smoke-reports/2026-02-18T13-18-19-740Z/taskNext.md +64 -0
  31. package/output/tasks.js +341 -162
  32. package/output/tasks.test.js +51 -1
  33. 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") {
@@ -282,6 +259,160 @@ export async function parseTasksBlock(markdown) {
282
259
  }
283
260
  return tasks;
284
261
  }
262
+ export function findTaskIdsOutsideMarkers(markdown) {
263
+ const start = markdown.indexOf(TASKS_START);
264
+ const end = markdown.indexOf(TASKS_END);
265
+ const outsideText = (start !== -1 && end !== -1 && end > start)
266
+ ? `${markdown.slice(0, start)}\n${markdown.slice(end + TASKS_END.length)}`
267
+ : markdown;
268
+ const ids = outsideText.match(/TASK-\d{4}/g) ?? [];
269
+ return Array.from(new Set(ids));
270
+ }
271
+ function collectTaskLintSuggestionItems(tasks, options = {}) {
272
+ const suggestions = [];
273
+ const { markdown, outsideMarkerScopeIds } = options;
274
+ const duplicateIds = Array.from(tasks.reduce((counter, task) => {
275
+ counter.set(task.id, (counter.get(task.id) ?? 0) + 1);
276
+ return counter;
277
+ }, new Map())
278
+ .entries())
279
+ .filter(([, count]) => count > 1)
280
+ .map(([id]) => id);
281
+ if (duplicateIds.length > 0) {
282
+ suggestions.push({
283
+ code: TASK_LINT_CODES.DUPLICATE_ID,
284
+ message: `Duplicate task IDs detected: ${duplicateIds.join(", ")}.`,
285
+ fixHint: "Keep task IDs unique in marker block.",
286
+ });
287
+ }
288
+ const inProgressWithoutOwner = tasks.filter((task) => task.status === "IN_PROGRESS" && task.owner.trim().length === 0);
289
+ if (inProgressWithoutOwner.length > 0) {
290
+ suggestions.push({
291
+ code: TASK_LINT_CODES.IN_PROGRESS_OWNER_EMPTY,
292
+ message: `${inProgressWithoutOwner.length} IN_PROGRESS task(s) have empty owner.`,
293
+ fixHint: "Set owner before continuing execution.",
294
+ });
295
+ }
296
+ const doneWithoutLinks = tasks.filter((task) => task.status === "DONE" && task.links.length === 0);
297
+ if (doneWithoutLinks.length > 0) {
298
+ suggestions.push({
299
+ code: TASK_LINT_CODES.DONE_LINKS_MISSING,
300
+ message: `${doneWithoutLinks.length} DONE task(s) have no links evidence.`,
301
+ fixHint: "Add at least one evidence link before keeping DONE.",
302
+ });
303
+ }
304
+ const blockedWithoutReason = tasks.filter((task) => task.status === "BLOCKED" && task.summary.trim().length === 0);
305
+ if (blockedWithoutReason.length > 0) {
306
+ suggestions.push({
307
+ code: TASK_LINT_CODES.BLOCKED_SUMMARY_EMPTY,
308
+ message: `${blockedWithoutReason.length} BLOCKED task(s) have empty summary.`,
309
+ fixHint: "Add blocker reason and unblock condition.",
310
+ });
311
+ }
312
+ const invalidUpdatedAt = tasks.filter((task) => !Number.isFinite(new Date(task.updatedAt).getTime()));
313
+ if (invalidUpdatedAt.length > 0) {
314
+ suggestions.push({
315
+ code: TASK_LINT_CODES.UPDATED_AT_INVALID,
316
+ message: `${invalidUpdatedAt.length} task(s) have invalid updatedAt format.`,
317
+ fixHint: "Use ISO8601 UTC timestamp.",
318
+ });
319
+ }
320
+ const missingRoadmapRefs = tasks.filter((task) => task.roadmapRefs.length === 0);
321
+ if (missingRoadmapRefs.length > 0) {
322
+ suggestions.push({
323
+ code: TASK_LINT_CODES.ROADMAP_REFS_EMPTY,
324
+ message: `${missingRoadmapRefs.length} task(s) have empty roadmapRefs.`,
325
+ fixHint: "Bind at least one ROADMAP-xxxx when applicable.",
326
+ });
327
+ }
328
+ if (typeof markdown === "string") {
329
+ const outsideMarkerTaskIds = findTaskIdsOutsideMarkers(markdown)
330
+ .filter((taskId) => (outsideMarkerScopeIds ? outsideMarkerScopeIds.has(taskId) : true));
331
+ if (outsideMarkerTaskIds.length > 0) {
332
+ suggestions.push({
333
+ code: TASK_LINT_CODES.OUTSIDE_MARKER,
334
+ message: `TASK IDs found outside marker block: ${outsideMarkerTaskIds.join(", ")}.`,
335
+ fixHint: "Keep task source of truth inside marker region only.",
336
+ });
337
+ }
338
+ }
339
+ return suggestions;
340
+ }
341
+ export function collectTaskLintSuggestions(tasks, markdown, outsideMarkerScopeIds) {
342
+ return renderLintSuggestions(collectTaskLintSuggestionItems(tasks, { markdown, outsideMarkerScopeIds }));
343
+ }
344
+ function collectSingleTaskLintSuggestions(task) {
345
+ const suggestions = [];
346
+ if (task.status === "IN_PROGRESS" && task.owner.trim().length === 0) {
347
+ suggestions.push({
348
+ code: TASK_LINT_CODES.IN_PROGRESS_OWNER_EMPTY,
349
+ message: "Current task is IN_PROGRESS but owner is empty.",
350
+ fixHint: "Set owner before continuing execution.",
351
+ });
352
+ }
353
+ if (task.status === "DONE" && task.links.length === 0) {
354
+ suggestions.push({
355
+ code: TASK_LINT_CODES.DONE_LINKS_MISSING,
356
+ message: "Current task is DONE but has no links evidence.",
357
+ fixHint: "Add at least one evidence link.",
358
+ });
359
+ }
360
+ if (task.status === "BLOCKED" && task.summary.trim().length === 0) {
361
+ suggestions.push({
362
+ code: TASK_LINT_CODES.BLOCKED_SUMMARY_EMPTY,
363
+ message: "Current task is BLOCKED but summary is empty.",
364
+ fixHint: "Add blocker reason and unblock condition.",
365
+ });
366
+ }
367
+ if (!Number.isFinite(new Date(task.updatedAt).getTime())) {
368
+ suggestions.push({
369
+ code: TASK_LINT_CODES.UPDATED_AT_INVALID,
370
+ message: "Current task updatedAt is invalid.",
371
+ fixHint: "Use ISO8601 UTC timestamp.",
372
+ });
373
+ }
374
+ if (task.roadmapRefs.length === 0) {
375
+ suggestions.push({
376
+ code: TASK_LINT_CODES.ROADMAP_REFS_EMPTY,
377
+ message: "Current task has empty roadmapRefs.",
378
+ fixHint: "Bind ROADMAP-xxxx where applicable.",
379
+ });
380
+ }
381
+ return renderLintSuggestions(suggestions);
382
+ }
383
+ async function collectTaskFileLintSuggestions(governanceDir, task) {
384
+ const suggestions = [];
385
+ for (const link of task.links) {
386
+ const normalized = link.trim();
387
+ if (normalized.length === 0) {
388
+ continue;
389
+ }
390
+ if (/^https?:\/\//i.test(normalized)) {
391
+ continue;
392
+ }
393
+ const resolvedPath = path.resolve(governanceDir, normalized);
394
+ const exists = await fs.access(resolvedPath).then(() => true).catch(() => false);
395
+ if (!exists) {
396
+ suggestions.push({
397
+ code: TASK_LINT_CODES.LINK_TARGET_MISSING,
398
+ message: `Link target not found: ${normalized} (resolved: ${resolvedPath}).`,
399
+ });
400
+ }
401
+ }
402
+ const hookEntries = Object.entries(task.hooks)
403
+ .filter(([, value]) => typeof value === "string" && value.trim().length > 0);
404
+ for (const [hookKey, hookPath] of hookEntries) {
405
+ const resolvedPath = path.resolve(governanceDir, hookPath);
406
+ const exists = await fs.access(resolvedPath).then(() => true).catch(() => false);
407
+ if (!exists) {
408
+ suggestions.push({
409
+ code: TASK_LINT_CODES.HOOK_FILE_MISSING,
410
+ message: `Hook file not found for ${hookKey}: ${hookPath} (resolved: ${resolvedPath}).`,
411
+ });
412
+ }
413
+ }
414
+ return renderLintSuggestions(suggestions);
415
+ }
285
416
  export function renderTasksMarkdown(tasks) {
286
417
  const sections = tasks.map((task) => {
287
418
  const roadmapRefs = task.roadmapRefs.length > 0 ? task.roadmapRefs.join(", ") : "(none)";
@@ -326,9 +457,13 @@ export async function ensureTasksFile(inputPath) {
326
457
  return tasksPath;
327
458
  }
328
459
  export async function loadTasks(inputPath) {
460
+ const { tasksPath, tasks } = await loadTasksDocument(inputPath);
461
+ return { tasksPath, tasks };
462
+ }
463
+ export async function loadTasksDocument(inputPath) {
329
464
  const tasksPath = await ensureTasksFile(inputPath);
330
465
  const markdown = await fs.readFile(tasksPath, "utf-8");
331
- return { tasksPath, tasks: await parseTasksBlock(markdown) };
466
+ return { tasksPath, markdown, tasks: await parseTasksBlock(markdown) };
332
467
  }
333
468
  export async function saveTasks(tasksPath, tasks) {
334
469
  const normalized = tasks.map((task) => normalizeTask(task));
@@ -357,31 +492,41 @@ export function registerTaskTools(server) {
357
492
  },
358
493
  }, async ({ projectPath, status, limit }) => {
359
494
  const governanceDir = await resolveGovernanceDir(projectPath);
360
- const { tasksPath, tasks } = await loadTasks(governanceDir);
495
+ const { tasksPath, tasks, markdown: tasksMarkdown } = await loadTasksDocument(governanceDir);
361
496
  const filtered = tasks
362
497
  .filter((task) => (status ? task.status === status : true))
363
498
  .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");
499
+ const lintSuggestions = collectTaskLintSuggestions(filtered, tasksMarkdown, new Set(filtered.map((task) => task.id)));
500
+ if (status && filtered.length === 0) {
501
+ appendLintSuggestions(lintSuggestions, [
502
+ {
503
+ code: TASK_LINT_CODES.FILTER_EMPTY,
504
+ message: `No tasks matched status=${status}.`,
505
+ fixHint: "Confirm status values or update task states.",
506
+ },
507
+ ]);
508
+ }
509
+ const nextTaskId = filtered[0]?.id;
510
+ const markdown = renderToolResponseMarkdown({
511
+ toolName: "taskList",
512
+ sections: [
513
+ summarySection([
514
+ `- governanceDir: ${governanceDir}`,
515
+ `- tasksPath: ${tasksPath}`,
516
+ `- filter.status: ${status ?? "(none)"}`,
517
+ `- returned: ${filtered.length}`,
518
+ ]),
519
+ evidenceSection([
520
+ "- tasks:",
521
+ ...filtered.map((task) => `- ${task.id} | ${task.status} | ${task.title} | owner=${task.owner || ""} | updatedAt=${task.updatedAt}`),
522
+ ]),
523
+ guidanceSection(["- Pick one task ID and call `taskContext`."]),
524
+ lintSection(lintSuggestions),
525
+ nextCallSection(nextTaskId
526
+ ? `taskContext(projectPath=\"${governanceDir}\", taskId=\"${nextTaskId}\")`
527
+ : undefined),
528
+ ],
529
+ });
385
530
  return asText(markdown);
386
531
  });
387
532
  server.registerTool("taskNext", {
@@ -398,29 +543,29 @@ export function registerTaskTools(server) {
398
543
  const projects = await discoverProjects(root, depth);
399
544
  const rankedCandidates = rankActionableTaskCandidates(await readActionableTaskCandidates(projects));
400
545
  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");
546
+ const markdown = renderToolResponseMarkdown({
547
+ toolName: "taskNext",
548
+ sections: [
549
+ summarySection([
550
+ `- rootPath: ${root}`,
551
+ `- maxDepth: ${depth}`,
552
+ `- matchedProjects: ${projects.length}`,
553
+ "- actionableTasks: 0",
554
+ ]),
555
+ evidenceSection(["- candidates:", "- (none)"]),
556
+ guidanceSection([
557
+ "- No TODO/IN_PROGRESS task is available.",
558
+ "- Create or reopen tasks in tasks.md, then rerun `taskNext`.",
559
+ ]),
560
+ lintSection(["- No actionable tasks found. Verify task statuses and required fields in marker block."]),
561
+ nextCallSection(`projectNext(rootPath=\"${root}\", maxDepth=${depth})`),
562
+ ],
563
+ });
421
564
  return asText(markdown);
422
565
  }
423
566
  const selected = rankedCandidates[0];
567
+ const selectedTaskDocument = await loadTasksDocument(selected.governanceDir);
568
+ const lintSuggestions = collectTaskLintSuggestions(selectedTaskDocument.tasks, selectedTaskDocument.markdown);
424
569
  const artifacts = await discoverGovernanceArtifacts(selected.governanceDir);
425
570
  const fileCandidates = candidateFilesFromArtifacts(artifacts);
426
571
  const referenceLocations = (await Promise.all(fileCandidates.map((file) => findTextReferences(file, selected.task.id)))).flat();
@@ -428,55 +573,56 @@ export function registerTaskTools(server) {
428
573
  const relatedArtifacts = Array.from(new Set(referenceLocations.map((item) => item.filePath)));
429
574
  const suggestedReadOrder = [selected.tasksPath, ...relatedArtifacts.filter((item) => item !== selected.tasksPath)];
430
575
  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");
576
+ const markdown = renderToolResponseMarkdown({
577
+ toolName: "taskNext",
578
+ sections: [
579
+ summarySection([
580
+ `- rootPath: ${root}`,
581
+ `- maxDepth: ${depth}`,
582
+ `- matchedProjects: ${projects.length}`,
583
+ `- actionableTasks: ${rankedCandidates.length}`,
584
+ `- selectedProject: ${selected.governanceDir}`,
585
+ `- selectedTaskId: ${selected.task.id}`,
586
+ `- selectedTaskStatus: ${selected.task.status}`,
587
+ ]),
588
+ evidenceSection([
589
+ "### Selected Task",
590
+ `- id: ${selected.task.id}`,
591
+ `- title: ${selected.task.title}`,
592
+ `- owner: ${selected.task.owner || "(none)"}`,
593
+ `- updatedAt: ${selected.task.updatedAt}`,
594
+ `- roadmapRefs: ${selected.task.roadmapRefs.join(", ") || "(none)"}`,
595
+ `- taskLocation: ${taskLocation ? `${taskLocation.filePath}#L${taskLocation.line}` : selected.tasksPath}`,
596
+ "",
597
+ "### Top Candidates",
598
+ ...rankedCandidates
599
+ .slice(0, candidateLimit)
600
+ .map((item, index) => `${index + 1}. ${item.task.id} | ${item.task.status} | ${item.task.title} | project=${item.governanceDir} | projectScore=${item.projectScore} | latest=${item.projectLatestUpdatedAt}`),
601
+ "",
602
+ "### Selection Reason",
603
+ "- Rank rule: projectScore DESC -> taskPriority DESC -> taskUpdatedAt DESC.",
604
+ `- Selected candidate scores: projectScore=${selected.projectScore}, taskPriority=${selected.taskPriority}, taskUpdatedAtMs=${selected.taskUpdatedAtMs}.`,
605
+ "",
606
+ "### Related Artifacts",
607
+ ...(relatedArtifacts.length > 0 ? relatedArtifacts.map((file) => `- ${file}`) : ["- (none)"]),
608
+ "",
609
+ "### Reference Locations",
610
+ ...(referenceLocations.length > 0
611
+ ? referenceLocations.map((item) => `- ${item.filePath}#L${item.line}: ${item.text}`)
612
+ : ["- (none)"]),
613
+ "",
614
+ "### Suggested Read Order",
615
+ ...suggestedReadOrder.map((item, index) => `${index + 1}. ${item}`),
616
+ ]),
617
+ guidanceSection([
618
+ "- Start immediately with Suggested Read Order and execute the selected task.",
619
+ "- Update markdown artifacts directly while keeping TASK/ROADMAP IDs unchanged.",
620
+ "- Re-run `taskContext` for the selectedTaskId after edits to verify evidence consistency.",
621
+ ]),
622
+ lintSection(lintSuggestions),
623
+ nextCallSection(`taskContext(projectPath=\"${selected.governanceDir}\", taskId=\"${selected.task.id}\")`),
624
+ ],
625
+ });
480
626
  return asText(markdown);
481
627
  });
482
628
  server.registerTool("taskContext", {
@@ -489,20 +635,34 @@ export function registerTaskTools(server) {
489
635
  }, async ({ projectPath, taskId }) => {
490
636
  if (!isValidTaskId(taskId)) {
491
637
  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\")`)),
638
+ ...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
639
  isError: true,
494
640
  };
495
641
  }
496
642
  const governanceDir = await resolveGovernanceDir(projectPath);
497
- const { tasksPath, tasks } = await loadTasks(governanceDir);
643
+ const { tasksPath, tasks, markdown: tasksMarkdown } = await loadTasksDocument(governanceDir);
498
644
  const taskContextHooks = await readTaskContextHooks(governanceDir);
499
645
  const task = tasks.find((item) => item.id === taskId);
500
646
  if (!task) {
501
647
  return {
502
- ...asText(renderErrorMarkdown("taskContext", `Task not found: ${taskId}`, ["- run `taskList` to discover available IDs", "- retry with an existing task ID"], `taskList(projectPath=\"${governanceDir}\")`)),
648
+ ...asText(renderErrorMarkdown("taskContext", `Task not found: ${taskId}`, ["run `taskList` to discover available IDs", "retry with an existing task ID"], `taskList(projectPath=\"${governanceDir}\")`)),
503
649
  isError: true,
504
650
  };
505
651
  }
652
+ const lintSuggestions = [
653
+ ...collectSingleTaskLintSuggestions(task),
654
+ ...(await collectTaskFileLintSuggestions(governanceDir, task)),
655
+ ];
656
+ const outsideMarkerTaskIds = findTaskIdsOutsideMarkers(tasksMarkdown);
657
+ if (outsideMarkerTaskIds.includes(task.id)) {
658
+ appendLintSuggestions(lintSuggestions, [
659
+ {
660
+ code: TASK_LINT_CODES.OUTSIDE_MARKER,
661
+ message: `Current task ID appears outside marker block (${task.id}).`,
662
+ fixHint: "Keep task source of truth inside marker region.",
663
+ },
664
+ ]);
665
+ }
506
666
  const taskLocation = (await findTextReferences(tasksPath, taskId))[0];
507
667
  const artifacts = await discoverGovernanceArtifacts(governanceDir);
508
668
  const fileCandidates = candidateFilesFromArtifacts(artifacts);
@@ -513,45 +673,64 @@ export function registerTaskTools(server) {
513
673
  .filter((value) => typeof value === "string" && value.trim().length > 0)
514
674
  .map((value) => path.resolve(governanceDir, value));
515
675
  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");
676
+ if (!taskContextHooks.head) {
677
+ appendLintSuggestions(lintSuggestions, [
678
+ {
679
+ code: TASK_LINT_CODES.CONTEXT_HOOK_HEAD_MISSING,
680
+ message: `Missing ${taskContextHooks.headPath}.`,
681
+ fixHint: "Add a task context head hook template if you need standardized preface.",
682
+ },
683
+ ]);
684
+ }
685
+ if (!taskContextHooks.footer) {
686
+ appendLintSuggestions(lintSuggestions, [
687
+ {
688
+ code: TASK_LINT_CODES.CONTEXT_HOOK_FOOTER_MISSING,
689
+ message: `Missing ${taskContextHooks.footerPath}.`,
690
+ fixHint: "Add a task context footer hook template for standardized close-out checks.",
691
+ },
692
+ ]);
693
+ }
694
+ const coreMarkdown = renderToolResponseMarkdown({
695
+ toolName: "taskContext",
696
+ sections: [
697
+ summarySection([
698
+ `- governanceDir: ${governanceDir}`,
699
+ `- taskId: ${task.id}`,
700
+ `- title: ${task.title}`,
701
+ `- status: ${task.status}`,
702
+ `- owner: ${task.owner}`,
703
+ `- updatedAt: ${task.updatedAt}`,
704
+ `- roadmapRefs: ${task.roadmapRefs.join(", ") || "(none)"}`,
705
+ `- taskLocation: ${taskLocation ? `${taskLocation.filePath}#L${taskLocation.line}` : tasksPath}`,
706
+ `- hookStatus: ${hookStatus}`,
707
+ ]),
708
+ evidenceSection([
709
+ "### Related Artifacts",
710
+ ...(relatedArtifacts.length > 0 ? relatedArtifacts.map((file) => `- ${file}`) : ["- (none)"]),
711
+ "",
712
+ "### Reference Locations",
713
+ ...(referenceLocations.length > 0
714
+ ? referenceLocations.map((item) => `- ${item.filePath}#L${item.line}: ${item.text}`)
715
+ : ["- (none)"]),
716
+ "",
717
+ "### Hook Paths",
718
+ ...(hookPaths.length > 0 ? hookPaths.map((item) => `- ${item}`) : ["- (none)"]),
719
+ "",
720
+ "### Suggested Read Order",
721
+ ...suggestedReadOrder.map((item, index) => `${index + 1}. ${item}`),
722
+ ]),
723
+ guidanceSection([
724
+ "- Read the files in Suggested Read Order.",
725
+ "- Verify whether current status and evidence are consistent.",
726
+ ...taskStatusGuidance(task),
727
+ "- If updates are needed, edit tasks/designs/reports markdown directly and keep TASK IDs unchanged.",
728
+ "- After editing, re-run `taskContext` to verify references and context consistency.",
729
+ ]),
730
+ lintSection(lintSuggestions),
731
+ nextCallSection(`taskContext(projectPath=\"${governanceDir}\", taskId=\"${task.id}\")`),
732
+ ],
733
+ });
555
734
  const markdownParts = [
556
735
  taskContextHooks.head,
557
736
  coreMarkdown,