@projitive/mcp 1.0.2 → 1.0.4

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 (40) hide show
  1. package/README.md +47 -23
  2. package/output/hooks.js +1 -14
  3. package/output/hooks.test.js +7 -18
  4. package/output/index.js +23 -5
  5. package/output/package.json +36 -0
  6. package/output/projitive.js +21 -2
  7. package/output/projitive.test.js +1 -0
  8. package/output/source/designs.js +38 -0
  9. package/output/source/helpers/artifacts/artifacts.js +10 -0
  10. package/output/source/helpers/artifacts/artifacts.test.js +18 -0
  11. package/output/source/helpers/artifacts/index.js +1 -0
  12. package/output/source/helpers/catch/catch.js +48 -0
  13. package/output/source/helpers/catch/catch.test.js +43 -0
  14. package/output/source/helpers/catch/index.js +1 -0
  15. package/output/source/helpers/files/files.js +62 -0
  16. package/output/source/helpers/files/files.test.js +32 -0
  17. package/output/source/helpers/files/index.js +1 -0
  18. package/output/source/helpers/index.js +6 -0
  19. package/output/source/helpers/linter/codes.js +25 -0
  20. package/output/source/helpers/linter/index.js +2 -0
  21. package/output/source/helpers/linter/linter.js +6 -0
  22. package/output/source/helpers/linter/linter.test.js +16 -0
  23. package/output/source/helpers/markdown/index.js +1 -0
  24. package/output/source/helpers/markdown/markdown.js +33 -0
  25. package/output/source/helpers/markdown/markdown.test.js +36 -0
  26. package/output/source/helpers/response/index.js +1 -0
  27. package/output/source/helpers/response/response.js +73 -0
  28. package/output/source/helpers/response/response.test.js +50 -0
  29. package/output/source/index.js +215 -0
  30. package/output/source/projitive.js +497 -0
  31. package/output/source/projitive.test.js +75 -0
  32. package/output/source/readme.js +26 -0
  33. package/output/source/reports.js +36 -0
  34. package/output/source/roadmap.js +165 -0
  35. package/output/source/roadmap.test.js +11 -0
  36. package/output/source/tasks.js +762 -0
  37. package/output/source/tasks.test.js +152 -0
  38. package/output/tasks.js +100 -80
  39. package/output/tasks.test.js +32 -8
  40. package/package.json +1 -1
@@ -0,0 +1,762 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { z } from "zod";
4
+ import { candidateFilesFromArtifacts } from "./helpers/artifacts/index.js";
5
+ import { discoverGovernanceArtifacts } from "./helpers/files/index.js";
6
+ import { findTextReferences } from "./helpers/markdown/index.js";
7
+ import { asText, evidenceSection, guidanceSection, lintSection, nextCallSection, renderErrorMarkdown, renderToolResponseMarkdown, summarySection, } from "./helpers/response/index.js";
8
+ import { catchIt } from "./helpers/catch/index.js";
9
+ import { TASK_LINT_CODES, renderLintSuggestions } from "./helpers/linter/index.js";
10
+ import { resolveGovernanceDir, resolveScanDepth, resolveScanRoot, discoverProjects } from "./projitive.js";
11
+ import { isValidRoadmapId } from "./roadmap.js";
12
+ export const TASKS_START = "<!-- PROJITIVE:TASKS:START -->";
13
+ export const TASKS_END = "<!-- PROJITIVE:TASKS:END -->";
14
+ export const ALLOWED_STATUS = ["TODO", "IN_PROGRESS", "BLOCKED", "DONE"];
15
+ export const TASK_ID_REGEX = /^TASK-\d{4}$/;
16
+ function appendLintSuggestions(target, suggestions) {
17
+ target.push(...renderLintSuggestions(suggestions));
18
+ }
19
+ function taskStatusGuidance(task) {
20
+ if (task.status === "TODO") {
21
+ return [
22
+ "- This task is TODO: confirm scope and set execution plan before edits.",
23
+ "- Move to IN_PROGRESS only after owner and initial evidence are ready.",
24
+ ];
25
+ }
26
+ if (task.status === "IN_PROGRESS") {
27
+ return [
28
+ "- This task is IN_PROGRESS: prioritize finishing with report/design evidence updates.",
29
+ "- Verify references stay consistent before marking DONE.",
30
+ ];
31
+ }
32
+ if (task.status === "BLOCKED") {
33
+ return [
34
+ "- This task is BLOCKED: identify blocker and required unblock condition first.",
35
+ "- Reopen only after blocker evidence is documented.",
36
+ ];
37
+ }
38
+ return [
39
+ "- This task is DONE: only reopen when new requirement changes scope.",
40
+ "- Keep report evidence immutable unless correction is required.",
41
+ ];
42
+ }
43
+ async function readOptionalMarkdown(filePath) {
44
+ const content = await fs.readFile(filePath, "utf-8").catch(() => undefined);
45
+ if (typeof content !== "string") {
46
+ return undefined;
47
+ }
48
+ const trimmed = content.trim();
49
+ return trimmed.length > 0 ? trimmed : undefined;
50
+ }
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
+ }
80
+ function latestTaskUpdatedAt(tasks) {
81
+ const timestamps = tasks
82
+ .map((task) => new Date(task.updatedAt).getTime())
83
+ .filter((value) => Number.isFinite(value));
84
+ if (timestamps.length === 0) {
85
+ return "(unknown)";
86
+ }
87
+ return new Date(Math.max(...timestamps)).toISOString();
88
+ }
89
+ function actionableScore(tasks) {
90
+ return tasks.filter((task) => task.status === "IN_PROGRESS").length * 2
91
+ + tasks.filter((task) => task.status === "TODO").length;
92
+ }
93
+ async function 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
+ }
118
+ async function readActionableTaskCandidates(governanceDirs) {
119
+ const snapshots = await Promise.all(governanceDirs.map(async (governanceDir) => {
120
+ const snapshot = await loadTasks(governanceDir);
121
+ return {
122
+ governanceDir,
123
+ tasksPath: snapshot.tasksPath,
124
+ tasks: snapshot.tasks,
125
+ projectScore: actionableScore(snapshot.tasks),
126
+ projectLatestUpdatedAt: latestTaskUpdatedAt(snapshot.tasks),
127
+ };
128
+ }));
129
+ return snapshots.flatMap((item) => item.tasks
130
+ .filter((task) => task.status === "IN_PROGRESS" || task.status === "TODO")
131
+ .map((task) => ({
132
+ governanceDir: item.governanceDir,
133
+ tasksPath: item.tasksPath,
134
+ task,
135
+ projectScore: item.projectScore,
136
+ projectLatestUpdatedAt: item.projectLatestUpdatedAt,
137
+ taskUpdatedAtMs: toTaskUpdatedAtMs(task.updatedAt),
138
+ taskPriority: taskPriority(task.status),
139
+ })));
140
+ }
141
+ export function nowIso() {
142
+ return new Date().toISOString();
143
+ }
144
+ export function isValidTaskId(id) {
145
+ return TASK_ID_REGEX.test(id);
146
+ }
147
+ export function taskPriority(status) {
148
+ if (status === "IN_PROGRESS") {
149
+ return 2;
150
+ }
151
+ if (status === "TODO") {
152
+ return 1;
153
+ }
154
+ return 0;
155
+ }
156
+ export function toTaskUpdatedAtMs(updatedAt) {
157
+ const timestamp = new Date(updatedAt).getTime();
158
+ return Number.isFinite(timestamp) ? timestamp : 0;
159
+ }
160
+ export function rankActionableTaskCandidates(candidates) {
161
+ return [...candidates].sort((a, b) => {
162
+ if (b.projectScore !== a.projectScore) {
163
+ return b.projectScore - a.projectScore;
164
+ }
165
+ if (b.taskPriority !== a.taskPriority) {
166
+ return b.taskPriority - a.taskPriority;
167
+ }
168
+ if (b.taskUpdatedAtMs !== a.taskUpdatedAtMs) {
169
+ return b.taskUpdatedAtMs - a.taskUpdatedAtMs;
170
+ }
171
+ if (a.governanceDir !== b.governanceDir) {
172
+ return a.governanceDir.localeCompare(b.governanceDir);
173
+ }
174
+ return a.task.id.localeCompare(b.task.id);
175
+ });
176
+ }
177
+ export function normalizeTask(task) {
178
+ const normalizedRoadmapRefs = Array.isArray(task.roadmapRefs)
179
+ ? task.roadmapRefs.map(String).filter((value) => isValidRoadmapId(value))
180
+ : [];
181
+ return {
182
+ id: String(task.id),
183
+ title: String(task.title),
184
+ status: ALLOWED_STATUS.includes(task.status) ? task.status : "TODO",
185
+ owner: task.owner ? String(task.owner) : "",
186
+ summary: task.summary ? String(task.summary) : "",
187
+ updatedAt: task.updatedAt ? String(task.updatedAt) : nowIso(),
188
+ links: Array.isArray(task.links) ? task.links.map(String) : [],
189
+ roadmapRefs: Array.from(new Set(normalizedRoadmapRefs)),
190
+ };
191
+ }
192
+ export async function parseTasksBlock(markdown) {
193
+ const start = markdown.indexOf(TASKS_START);
194
+ const end = markdown.indexOf(TASKS_END);
195
+ if (start === -1 || end === -1 || end <= start) {
196
+ return [];
197
+ }
198
+ const body = markdown.slice(start + TASKS_START.length, end).trim();
199
+ if (!body || body === "(no tasks)") {
200
+ return [];
201
+ }
202
+ const sections = body
203
+ .split(/\n(?=##\s+TASK-\d{4}\s+\|\s+(?:TODO|IN_PROGRESS|BLOCKED|DONE)\s+\|)/g)
204
+ .map((section) => section.trim())
205
+ .filter((section) => section.startsWith("## TASK-"));
206
+ const tasks = [];
207
+ for (const section of sections) {
208
+ const lines = section.split(/\r?\n/);
209
+ const header = lines[0]?.match(/^##\s+(TASK-\d{4})\s+\|\s+(TODO|IN_PROGRESS|BLOCKED|DONE)\s+\|\s+(.+)$/);
210
+ if (!header) {
211
+ continue;
212
+ }
213
+ const [, id, statusRaw, title] = header;
214
+ const status = statusRaw;
215
+ const taskDraft = {
216
+ id,
217
+ title: title.trim(),
218
+ status,
219
+ owner: "",
220
+ summary: "",
221
+ updatedAt: nowIso(),
222
+ links: [],
223
+ roadmapRefs: [],
224
+ };
225
+ let inLinks = false;
226
+ let inHooks = false;
227
+ for (const line of lines.slice(1)) {
228
+ const trimmed = line.trim();
229
+ if (!trimmed) {
230
+ continue;
231
+ }
232
+ if (trimmed.startsWith("- owner:")) {
233
+ taskDraft.owner = trimmed.replace("- owner:", "").trim();
234
+ inLinks = false;
235
+ inHooks = false;
236
+ continue;
237
+ }
238
+ if (trimmed.startsWith("- summary:")) {
239
+ taskDraft.summary = trimmed.replace("- summary:", "").trim();
240
+ inLinks = false;
241
+ inHooks = false;
242
+ continue;
243
+ }
244
+ if (trimmed.startsWith("- updatedAt:")) {
245
+ taskDraft.updatedAt = trimmed.replace("- updatedAt:", "").trim();
246
+ inLinks = false;
247
+ inHooks = false;
248
+ continue;
249
+ }
250
+ if (trimmed.startsWith("- roadmapRefs:")) {
251
+ const payload = trimmed.replace("- roadmapRefs:", "").trim();
252
+ const refs = payload === "(none)"
253
+ ? []
254
+ : payload
255
+ .split(",")
256
+ .map((value) => value.trim())
257
+ .filter((value) => value.length > 0);
258
+ taskDraft.roadmapRefs = refs;
259
+ inLinks = false;
260
+ inHooks = false;
261
+ continue;
262
+ }
263
+ if (trimmed === "- links:") {
264
+ inLinks = true;
265
+ inHooks = false;
266
+ continue;
267
+ }
268
+ if (trimmed === "- hooks:") {
269
+ inLinks = false;
270
+ inHooks = true;
271
+ continue;
272
+ }
273
+ const nestedItem = trimmed.match(/^-\s+(.+)$/);
274
+ if (!nestedItem) {
275
+ continue;
276
+ }
277
+ const nestedValue = nestedItem[1].trim();
278
+ if (nestedValue === "(none)") {
279
+ continue;
280
+ }
281
+ if (inLinks) {
282
+ taskDraft.links = [...(taskDraft.links ?? []), nestedValue];
283
+ continue;
284
+ }
285
+ if (inHooks) {
286
+ continue;
287
+ }
288
+ }
289
+ tasks.push(normalizeTask(taskDraft));
290
+ }
291
+ return tasks;
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
+ }
435
+ export function renderTasksMarkdown(tasks) {
436
+ const sections = tasks.map((task) => {
437
+ const roadmapRefs = task.roadmapRefs.length > 0 ? task.roadmapRefs.join(", ") : "(none)";
438
+ const links = task.links.length > 0
439
+ ? ["- links:", ...task.links.map((link) => ` - ${link}`)]
440
+ : ["- links:", " - (none)"];
441
+ return [
442
+ `## ${task.id} | ${task.status} | ${task.title}`,
443
+ `- owner: ${task.owner || "(none)"}`,
444
+ `- summary: ${task.summary || "(none)"}`,
445
+ `- updatedAt: ${task.updatedAt}`,
446
+ `- roadmapRefs: ${roadmapRefs}`,
447
+ ...links,
448
+ ].join("\n");
449
+ });
450
+ return [
451
+ "# Tasks",
452
+ "",
453
+ "本文件由 Projitive MCP 维护,手动编辑请保持 Markdown 结构合法。",
454
+ "",
455
+ TASKS_START,
456
+ ...(sections.length > 0 ? sections : ["(no tasks)"]),
457
+ TASKS_END,
458
+ "",
459
+ ].join("\n");
460
+ }
461
+ export async function ensureTasksFile(inputPath) {
462
+ const governanceDir = await resolveGovernanceDir(inputPath);
463
+ const tasksPath = path.join(governanceDir, "tasks.md");
464
+ await fs.mkdir(governanceDir, { recursive: true });
465
+ const accessResult = await catchIt(fs.access(tasksPath));
466
+ if (accessResult.isError()) {
467
+ await fs.writeFile(tasksPath, renderTasksMarkdown([]), "utf-8");
468
+ }
469
+ return tasksPath;
470
+ }
471
+ export async function loadTasks(inputPath) {
472
+ const { tasksPath, tasks } = await loadTasksDocument(inputPath);
473
+ return { tasksPath, tasks };
474
+ }
475
+ export async function loadTasksDocument(inputPath) {
476
+ const tasksPath = await ensureTasksFile(inputPath);
477
+ const markdown = await fs.readFile(tasksPath, "utf-8");
478
+ return { tasksPath, markdown, tasks: await parseTasksBlock(markdown) };
479
+ }
480
+ export async function saveTasks(tasksPath, tasks) {
481
+ const normalized = tasks.map((task) => normalizeTask(task));
482
+ await fs.writeFile(tasksPath, renderTasksMarkdown(normalized), "utf-8");
483
+ }
484
+ export function validateTransition(from, to) {
485
+ if (from === to) {
486
+ return true;
487
+ }
488
+ const allowed = {
489
+ TODO: new Set(["IN_PROGRESS", "BLOCKED"]),
490
+ IN_PROGRESS: new Set(["BLOCKED", "DONE"]),
491
+ BLOCKED: new Set(["IN_PROGRESS", "TODO"]),
492
+ DONE: new Set(),
493
+ };
494
+ return allowed[from].has(to);
495
+ }
496
+ export function registerTaskTools(server) {
497
+ server.registerTool("taskList", {
498
+ title: "Task List",
499
+ description: "List project tasks with optional status filter for agent planning",
500
+ inputSchema: {
501
+ projectPath: z.string(),
502
+ status: z.enum(["TODO", "IN_PROGRESS", "BLOCKED", "DONE"]).optional(),
503
+ limit: z.number().int().min(1).max(200).optional(),
504
+ },
505
+ }, async ({ projectPath, status, limit }) => {
506
+ const governanceDir = await resolveGovernanceDir(projectPath);
507
+ const { tasksPath, tasks, markdown: tasksMarkdown } = await loadTasksDocument(governanceDir);
508
+ const filtered = tasks
509
+ .filter((task) => (status ? task.status === status : true))
510
+ .slice(0, limit ?? 100);
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
+ });
542
+ return asText(markdown);
543
+ });
544
+ server.registerTool("taskNext", {
545
+ title: "Task Next",
546
+ description: "One-step discover and select the most actionable task with evidence and start guidance",
547
+ inputSchema: {
548
+ rootPath: z.string().optional(),
549
+ maxDepth: z.number().int().min(0).max(8).optional(),
550
+ topCandidates: z.number().int().min(1).max(20).optional(),
551
+ },
552
+ }, async ({ rootPath, maxDepth, topCandidates }) => {
553
+ const root = resolveScanRoot(rootPath);
554
+ const depth = resolveScanDepth(maxDepth);
555
+ const projects = await discoverProjects(root, depth);
556
+ const rankedCandidates = rankActionableTaskCandidates(await readActionableTaskCandidates(projects));
557
+ if (rankedCandidates.length === 0) {
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()"),
615
+ ],
616
+ });
617
+ return asText(markdown);
618
+ }
619
+ const selected = rankedCandidates[0];
620
+ const selectedTaskDocument = await loadTasksDocument(selected.governanceDir);
621
+ const lintSuggestions = collectTaskLintSuggestions(selectedTaskDocument.tasks, selectedTaskDocument.markdown);
622
+ const artifacts = await discoverGovernanceArtifacts(selected.governanceDir);
623
+ const fileCandidates = candidateFilesFromArtifacts(artifacts);
624
+ const referenceLocations = (await Promise.all(fileCandidates.map((file) => findTextReferences(file, selected.task.id)))).flat();
625
+ const taskLocation = (await findTextReferences(selected.tasksPath, selected.task.id))[0];
626
+ const relatedArtifacts = Array.from(new Set(referenceLocations.map((item) => item.filePath)));
627
+ const suggestedReadOrder = [selected.tasksPath, ...relatedArtifacts.filter((item) => item !== selected.tasksPath)];
628
+ const candidateLimit = topCandidates ?? 5;
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
+ });
679
+ return asText(markdown);
680
+ });
681
+ server.registerTool("taskContext", {
682
+ title: "Task Context",
683
+ description: "Get one task with detail, evidence locations, and execution guidance",
684
+ inputSchema: {
685
+ projectPath: z.string(),
686
+ taskId: z.string(),
687
+ },
688
+ }, async ({ projectPath, taskId }) => {
689
+ if (!isValidTaskId(taskId)) {
690
+ return {
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\")`)),
692
+ isError: true,
693
+ };
694
+ }
695
+ const governanceDir = await resolveGovernanceDir(projectPath);
696
+ const { tasksPath, tasks, markdown: tasksMarkdown } = await loadTasksDocument(governanceDir);
697
+ const task = tasks.find((item) => item.id === taskId);
698
+ if (!task) {
699
+ return {
700
+ ...asText(renderErrorMarkdown("taskContext", `Task not found: ${taskId}`, ["run `taskList` to discover available IDs", "retry with an existing task ID"], `taskList(projectPath=\"${governanceDir}\")`)),
701
+ isError: true,
702
+ };
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
+ }
718
+ const taskLocation = (await findTextReferences(tasksPath, taskId))[0];
719
+ const artifacts = await discoverGovernanceArtifacts(governanceDir);
720
+ const fileCandidates = candidateFilesFromArtifacts(artifacts);
721
+ const referenceLocations = (await Promise.all(fileCandidates.map((file) => findTextReferences(file, taskId)))).flat();
722
+ const relatedArtifacts = Array.from(new Set(referenceLocations.map((item) => item.filePath)));
723
+ const suggestedReadOrder = [tasksPath, ...relatedArtifacts.filter((item) => item !== tasksPath)];
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);
761
+ });
762
+ }