@projitive/mcp 1.0.2 → 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.
- package/README.md +44 -20
- package/output/hooks.js +1 -14
- package/output/hooks.test.js +7 -18
- package/output/index.js +23 -5
- package/output/package.json +36 -0
- package/output/projitive.js +21 -2
- package/output/projitive.test.js +1 -0
- package/output/source/designs.js +38 -0
- package/output/source/helpers/artifacts/artifacts.js +10 -0
- package/output/source/helpers/artifacts/artifacts.test.js +18 -0
- package/output/source/helpers/artifacts/index.js +1 -0
- package/output/source/helpers/catch/catch.js +48 -0
- package/output/source/helpers/catch/catch.test.js +43 -0
- package/output/source/helpers/catch/index.js +1 -0
- package/output/source/helpers/files/files.js +62 -0
- package/output/source/helpers/files/files.test.js +32 -0
- package/output/source/helpers/files/index.js +1 -0
- package/output/source/helpers/index.js +6 -0
- package/output/source/helpers/linter/codes.js +25 -0
- package/output/source/helpers/linter/index.js +2 -0
- package/output/source/helpers/linter/linter.js +6 -0
- package/output/source/helpers/linter/linter.test.js +16 -0
- package/output/source/helpers/markdown/index.js +1 -0
- package/output/source/helpers/markdown/markdown.js +33 -0
- package/output/source/helpers/markdown/markdown.test.js +36 -0
- package/output/source/helpers/response/index.js +1 -0
- package/output/source/helpers/response/response.js +73 -0
- package/output/source/helpers/response/response.test.js +50 -0
- package/output/source/index.js +215 -0
- package/output/source/projitive.js +488 -0
- package/output/source/projitive.test.js +75 -0
- package/output/source/readme.js +26 -0
- package/output/source/reports.js +36 -0
- package/output/source/roadmap.js +165 -0
- package/output/source/roadmap.test.js +11 -0
- package/output/source/tasks.js +762 -0
- package/output/source/tasks.test.js +152 -0
- package/output/tasks.js +100 -80
- package/output/tasks.test.js +32 -8
- package/package.json +1 -1
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { TASKS_END, TASKS_START, collectTaskLintSuggestions, isValidTaskId, normalizeTask, parseTasksBlock, rankActionableTaskCandidates, resolveNoTaskDiscoveryGuidance, renderTaskSeedTemplate, renderTasksMarkdown, taskPriority, toTaskUpdatedAtMs, validateTransition, } from "./tasks.js";
|
|
3
|
+
import fs from "node:fs/promises";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
function buildCandidate(partial) {
|
|
7
|
+
const task = normalizeTask({
|
|
8
|
+
id: partial.id,
|
|
9
|
+
title: partial.title,
|
|
10
|
+
status: partial.status,
|
|
11
|
+
updatedAt: partial.task?.updatedAt ?? "2026-01-01T00:00:00.000Z",
|
|
12
|
+
});
|
|
13
|
+
return {
|
|
14
|
+
governanceDir: partial.governanceDir ?? "/workspace/a",
|
|
15
|
+
tasksPath: partial.tasksPath ?? "/workspace/a/tasks.md",
|
|
16
|
+
task,
|
|
17
|
+
projectScore: partial.projectScore ?? 1,
|
|
18
|
+
projectLatestUpdatedAt: partial.projectLatestUpdatedAt ?? "2026-01-01T00:00:00.000Z",
|
|
19
|
+
taskUpdatedAtMs: partial.taskUpdatedAtMs ?? toTaskUpdatedAtMs(task.updatedAt),
|
|
20
|
+
taskPriority: partial.taskPriority ?? taskPriority(task.status),
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
describe("tasks module", () => {
|
|
24
|
+
it("parses markdown task block and normalizes task fields", async () => {
|
|
25
|
+
const markdown = [
|
|
26
|
+
"# Tasks",
|
|
27
|
+
TASKS_START,
|
|
28
|
+
"## TASK-0001 | TODO | hello",
|
|
29
|
+
"- owner: alice",
|
|
30
|
+
"- summary: first task",
|
|
31
|
+
"- updatedAt: 2026-02-17T00:00:00.000Z",
|
|
32
|
+
"- roadmapRefs: ROADMAP-0001",
|
|
33
|
+
"- links:",
|
|
34
|
+
" - ./designs/example.md",
|
|
35
|
+
TASKS_END,
|
|
36
|
+
].join("\n");
|
|
37
|
+
const tasks = await parseTasksBlock(markdown);
|
|
38
|
+
expect(tasks).toHaveLength(1);
|
|
39
|
+
expect(tasks[0].id).toBe("TASK-0001");
|
|
40
|
+
expect(tasks[0].status).toBe("TODO");
|
|
41
|
+
expect(tasks[0].roadmapRefs).toEqual(["ROADMAP-0001"]);
|
|
42
|
+
expect(tasks[0].links).toEqual(["./designs/example.md"]);
|
|
43
|
+
});
|
|
44
|
+
it("renders markdown containing markers", () => {
|
|
45
|
+
const task = normalizeTask({ id: "TASK-0002", title: "render", status: "IN_PROGRESS" });
|
|
46
|
+
const markdown = renderTasksMarkdown([task]);
|
|
47
|
+
expect(markdown.includes(TASKS_START)).toBe(true);
|
|
48
|
+
expect(markdown.includes(TASKS_END)).toBe(true);
|
|
49
|
+
expect(markdown.includes("## TASK-0002 | IN_PROGRESS | render")).toBe(true);
|
|
50
|
+
});
|
|
51
|
+
it("validates task IDs", () => {
|
|
52
|
+
expect(isValidTaskId("TASK-0001")).toBe(true);
|
|
53
|
+
expect(isValidTaskId("TASK-001")).toBe(false);
|
|
54
|
+
});
|
|
55
|
+
it("allows and rejects expected transitions", () => {
|
|
56
|
+
expect(validateTransition("TODO", "IN_PROGRESS")).toBe(true);
|
|
57
|
+
expect(validateTransition("IN_PROGRESS", "DONE")).toBe(true);
|
|
58
|
+
expect(validateTransition("DONE", "IN_PROGRESS")).toBe(false);
|
|
59
|
+
});
|
|
60
|
+
it("assigns priority for actionable statuses", () => {
|
|
61
|
+
expect(taskPriority("IN_PROGRESS")).toBe(2);
|
|
62
|
+
expect(taskPriority("TODO")).toBe(1);
|
|
63
|
+
expect(taskPriority("BLOCKED")).toBe(0);
|
|
64
|
+
});
|
|
65
|
+
it("returns zero timestamp for invalid date", () => {
|
|
66
|
+
expect(toTaskUpdatedAtMs("invalid")).toBe(0);
|
|
67
|
+
});
|
|
68
|
+
it("ranks by project score, then task priority, then recency", () => {
|
|
69
|
+
const candidates = [
|
|
70
|
+
buildCandidate({ id: "TASK-0001", title: "A", status: "TODO", projectScore: 2 }),
|
|
71
|
+
buildCandidate({ id: "TASK-0002", title: "B", status: "IN_PROGRESS", projectScore: 2 }),
|
|
72
|
+
buildCandidate({ id: "TASK-0003", title: "C", status: "IN_PROGRESS", projectScore: 3 }),
|
|
73
|
+
];
|
|
74
|
+
const ranked = rankActionableTaskCandidates(candidates);
|
|
75
|
+
expect(ranked[0].task.id).toBe("TASK-0003");
|
|
76
|
+
expect(ranked[1].task.id).toBe("TASK-0002");
|
|
77
|
+
expect(ranked[2].task.id).toBe("TASK-0001");
|
|
78
|
+
});
|
|
79
|
+
it("renders lint lines with stable code prefix", () => {
|
|
80
|
+
const task = normalizeTask({
|
|
81
|
+
id: "TASK-0001",
|
|
82
|
+
title: "lint",
|
|
83
|
+
status: "IN_PROGRESS",
|
|
84
|
+
owner: "",
|
|
85
|
+
roadmapRefs: [],
|
|
86
|
+
});
|
|
87
|
+
const lint = collectTaskLintSuggestions([task]);
|
|
88
|
+
expect(lint.some((line) => line.startsWith("- [TASK_IN_PROGRESS_OWNER_EMPTY]"))).toBe(true);
|
|
89
|
+
expect(lint.some((line) => line.startsWith("- [TASK_ROADMAP_REFS_EMPTY]"))).toBe(true);
|
|
90
|
+
});
|
|
91
|
+
it("scopes outside-marker lint to provided task IDs", () => {
|
|
92
|
+
const tasks = [
|
|
93
|
+
normalizeTask({ id: "TASK-0001", title: "A", status: "TODO", roadmapRefs: ["ROADMAP-0001"] }),
|
|
94
|
+
normalizeTask({ id: "TASK-0002", title: "B", status: "TODO", roadmapRefs: ["ROADMAP-0001"] }),
|
|
95
|
+
];
|
|
96
|
+
const markdown = [
|
|
97
|
+
"# Tasks",
|
|
98
|
+
"TASK-0002 outside",
|
|
99
|
+
"TASK-0003 outside",
|
|
100
|
+
TASKS_START,
|
|
101
|
+
"## TASK-0001 | TODO | A",
|
|
102
|
+
"- owner: (none)",
|
|
103
|
+
"- summary: (none)",
|
|
104
|
+
"- updatedAt: 2026-02-18T00:00:00.000Z",
|
|
105
|
+
"- roadmapRefs: ROADMAP-0001",
|
|
106
|
+
"- links:",
|
|
107
|
+
" - (none)",
|
|
108
|
+
"## TASK-0002 | TODO | B",
|
|
109
|
+
"- owner: (none)",
|
|
110
|
+
"- summary: (none)",
|
|
111
|
+
"- updatedAt: 2026-02-18T00:00:00.000Z",
|
|
112
|
+
"- roadmapRefs: ROADMAP-0001",
|
|
113
|
+
"- links:",
|
|
114
|
+
" - (none)",
|
|
115
|
+
TASKS_END,
|
|
116
|
+
].join("\n");
|
|
117
|
+
const scoped = collectTaskLintSuggestions(tasks, markdown, new Set(["TASK-0001"]));
|
|
118
|
+
const scopedOutside = scoped.find((line) => line.includes("TASK IDs found outside marker block"));
|
|
119
|
+
expect(scopedOutside).toBeUndefined();
|
|
120
|
+
const all = collectTaskLintSuggestions(tasks, markdown);
|
|
121
|
+
const allOutside = all.find((line) => line.includes("TASK IDs found outside marker block"));
|
|
122
|
+
expect(allOutside).toContain("TASK-0002");
|
|
123
|
+
expect(allOutside).toContain("TASK-0003");
|
|
124
|
+
});
|
|
125
|
+
it("renders seed task template with provided roadmap ref", () => {
|
|
126
|
+
const lines = renderTaskSeedTemplate("ROADMAP-0099");
|
|
127
|
+
const markdown = lines.join("\n");
|
|
128
|
+
expect(markdown).toContain("## TASK-0001 | TODO | Define initial executable objective");
|
|
129
|
+
expect(markdown).toContain("- roadmapRefs: ROADMAP-0099");
|
|
130
|
+
expect(markdown).toContain("- links:");
|
|
131
|
+
expect(markdown).not.toContain("- hooks:");
|
|
132
|
+
});
|
|
133
|
+
it("uses default no-task guidance when hook file is absent", async () => {
|
|
134
|
+
const guidance = await resolveNoTaskDiscoveryGuidance("/path/that/does/not/exist");
|
|
135
|
+
expect(guidance.length).toBeGreaterThan(3);
|
|
136
|
+
expect(guidance.some((line) => line.includes("TODO/FIXME/HACK"))).toBe(true);
|
|
137
|
+
});
|
|
138
|
+
it("uses hook checklist when task_no_actionable hook exists", async () => {
|
|
139
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "projitive-mcp-test-"));
|
|
140
|
+
const hooksDir = path.join(dir, "hooks");
|
|
141
|
+
await fs.mkdir(hooksDir, { recursive: true });
|
|
142
|
+
await fs.writeFile(path.join(hooksDir, "task_no_actionable.md"), [
|
|
143
|
+
"Objective:",
|
|
144
|
+
"- custom-item-1",
|
|
145
|
+
"- custom-item-2",
|
|
146
|
+
].join("\n"), "utf-8");
|
|
147
|
+
const guidance = await resolveNoTaskDiscoveryGuidance(dir);
|
|
148
|
+
expect(guidance).toContain("- custom-item-1");
|
|
149
|
+
expect(guidance).toContain("- custom-item-2");
|
|
150
|
+
await fs.rm(dir, { recursive: true, force: true });
|
|
151
|
+
});
|
|
152
|
+
});
|
package/output/tasks.js
CHANGED
|
@@ -48,11 +48,34 @@ async function readOptionalMarkdown(filePath) {
|
|
|
48
48
|
const trimmed = content.trim();
|
|
49
49
|
return trimmed.length > 0 ? trimmed : undefined;
|
|
50
50
|
}
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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;
|
|
56
79
|
}
|
|
57
80
|
function latestTaskUpdatedAt(tasks) {
|
|
58
81
|
const timestamps = tasks
|
|
@@ -67,6 +90,31 @@ function actionableScore(tasks) {
|
|
|
67
90
|
return tasks.filter((task) => task.status === "IN_PROGRESS").length * 2
|
|
68
91
|
+ tasks.filter((task) => task.status === "TODO").length;
|
|
69
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
|
+
}
|
|
70
118
|
async function readActionableTaskCandidates(governanceDirs) {
|
|
71
119
|
const snapshots = await Promise.all(governanceDirs.map(async (governanceDir) => {
|
|
72
120
|
const snapshot = await loadTasks(governanceDir);
|
|
@@ -130,14 +178,6 @@ export function normalizeTask(task) {
|
|
|
130
178
|
const normalizedRoadmapRefs = Array.isArray(task.roadmapRefs)
|
|
131
179
|
? task.roadmapRefs.map(String).filter((value) => isValidRoadmapId(value))
|
|
132
180
|
: [];
|
|
133
|
-
const inputHooks = task.hooks ?? {};
|
|
134
|
-
const normalizedHooks = {};
|
|
135
|
-
for (const key of ["onAssigned", "onCompleted", "onBlocked", "onReopened"]) {
|
|
136
|
-
const value = inputHooks[key];
|
|
137
|
-
if (typeof value === "string" && value.trim().length > 0) {
|
|
138
|
-
normalizedHooks[key] = value;
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
181
|
return {
|
|
142
182
|
id: String(task.id),
|
|
143
183
|
title: String(task.title),
|
|
@@ -147,7 +187,6 @@ export function normalizeTask(task) {
|
|
|
147
187
|
updatedAt: task.updatedAt ? String(task.updatedAt) : nowIso(),
|
|
148
188
|
links: Array.isArray(task.links) ? task.links.map(String) : [],
|
|
149
189
|
roadmapRefs: Array.from(new Set(normalizedRoadmapRefs)),
|
|
150
|
-
hooks: normalizedHooks,
|
|
151
190
|
};
|
|
152
191
|
}
|
|
153
192
|
export async function parseTasksBlock(markdown) {
|
|
@@ -182,7 +221,6 @@ export async function parseTasksBlock(markdown) {
|
|
|
182
221
|
updatedAt: nowIso(),
|
|
183
222
|
links: [],
|
|
184
223
|
roadmapRefs: [],
|
|
185
|
-
hooks: {},
|
|
186
224
|
};
|
|
187
225
|
let inLinks = false;
|
|
188
226
|
let inHooks = false;
|
|
@@ -245,14 +283,7 @@ export async function parseTasksBlock(markdown) {
|
|
|
245
283
|
continue;
|
|
246
284
|
}
|
|
247
285
|
if (inHooks) {
|
|
248
|
-
|
|
249
|
-
if (hookMatch) {
|
|
250
|
-
const [, hookKey, hookPath] = hookMatch;
|
|
251
|
-
taskDraft.hooks = {
|
|
252
|
-
...(taskDraft.hooks ?? {}),
|
|
253
|
-
[hookKey]: hookPath.trim(),
|
|
254
|
-
};
|
|
255
|
-
}
|
|
286
|
+
continue;
|
|
256
287
|
}
|
|
257
288
|
}
|
|
258
289
|
tasks.push(normalizeTask(taskDraft));
|
|
@@ -399,18 +430,6 @@ async function collectTaskFileLintSuggestions(governanceDir, task) {
|
|
|
399
430
|
});
|
|
400
431
|
}
|
|
401
432
|
}
|
|
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
433
|
return renderLintSuggestions(suggestions);
|
|
415
434
|
}
|
|
416
435
|
export function renderTasksMarkdown(tasks) {
|
|
@@ -419,12 +438,6 @@ export function renderTasksMarkdown(tasks) {
|
|
|
419
438
|
const links = task.links.length > 0
|
|
420
439
|
? ["- links:", ...task.links.map((link) => ` - ${link}`)]
|
|
421
440
|
: ["- links:", " - (none)"];
|
|
422
|
-
const hookEntries = Object.entries(task.hooks)
|
|
423
|
-
.filter(([, value]) => typeof value === "string" && value.trim().length > 0)
|
|
424
|
-
.map(([key, value]) => ` - ${key}: ${value}`);
|
|
425
|
-
const hooks = hookEntries.length > 0
|
|
426
|
-
? ["- hooks:", ...hookEntries]
|
|
427
|
-
: ["- hooks:", " - (none)"];
|
|
428
441
|
return [
|
|
429
442
|
`## ${task.id} | ${task.status} | ${task.title}`,
|
|
430
443
|
`- owner: ${task.owner || "(none)"}`,
|
|
@@ -432,7 +445,6 @@ export function renderTasksMarkdown(tasks) {
|
|
|
432
445
|
`- updatedAt: ${task.updatedAt}`,
|
|
433
446
|
`- roadmapRefs: ${roadmapRefs}`,
|
|
434
447
|
...links,
|
|
435
|
-
...hooks,
|
|
436
448
|
].join("\n");
|
|
437
449
|
});
|
|
438
450
|
return [
|
|
@@ -543,6 +555,27 @@ export function registerTaskTools(server) {
|
|
|
543
555
|
const projects = await discoverProjects(root, depth);
|
|
544
556
|
const rankedCandidates = rankActionableTaskCandidates(await readActionableTaskCandidates(projects));
|
|
545
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);
|
|
546
579
|
const markdown = renderToolResponseMarkdown({
|
|
547
580
|
toolName: "taskNext",
|
|
548
581
|
sections: [
|
|
@@ -552,13 +585,33 @@ export function registerTaskTools(server) {
|
|
|
552
585
|
`- matchedProjects: ${projects.length}`,
|
|
553
586
|
"- actionableTasks: 0",
|
|
554
587
|
]),
|
|
555
|
-
evidenceSection([
|
|
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
|
+
]),
|
|
556
597
|
guidanceSection([
|
|
557
598
|
"- No TODO/IN_PROGRESS task is available.",
|
|
558
|
-
"-
|
|
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.",
|
|
559
611
|
]),
|
|
560
|
-
|
|
561
|
-
|
|
612
|
+
nextCallSection(preferredProject
|
|
613
|
+
? `projectContext(projectPath=\"${preferredProject.governanceDir}\")`
|
|
614
|
+
: `projectScan(rootPath=\"${root}\", maxDepth=${depth})`),
|
|
562
615
|
],
|
|
563
616
|
});
|
|
564
617
|
return asText(markdown);
|
|
@@ -641,7 +694,6 @@ export function registerTaskTools(server) {
|
|
|
641
694
|
}
|
|
642
695
|
const governanceDir = await resolveGovernanceDir(projectPath);
|
|
643
696
|
const { tasksPath, tasks, markdown: tasksMarkdown } = await loadTasksDocument(governanceDir);
|
|
644
|
-
const taskContextHooks = await readTaskContextHooks(governanceDir);
|
|
645
697
|
const task = tasks.find((item) => item.id === taskId);
|
|
646
698
|
if (!task) {
|
|
647
699
|
return {
|
|
@@ -669,28 +721,6 @@ export function registerTaskTools(server) {
|
|
|
669
721
|
const referenceLocations = (await Promise.all(fileCandidates.map((file) => findTextReferences(file, taskId)))).flat();
|
|
670
722
|
const relatedArtifacts = Array.from(new Set(referenceLocations.map((item) => item.filePath)));
|
|
671
723
|
const suggestedReadOrder = [tasksPath, ...relatedArtifacts.filter((item) => item !== tasksPath)];
|
|
672
|
-
const hookPaths = Object.values(task.hooks)
|
|
673
|
-
.filter((value) => typeof value === "string" && value.trim().length > 0)
|
|
674
|
-
.map((value) => path.resolve(governanceDir, value));
|
|
675
|
-
const hookStatus = `head=${taskContextHooks.head ? "loaded" : "missing"}, footer=${taskContextHooks.footer ? "loaded" : "missing"}`;
|
|
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
724
|
const coreMarkdown = renderToolResponseMarkdown({
|
|
695
725
|
toolName: "taskContext",
|
|
696
726
|
sections: [
|
|
@@ -703,7 +733,6 @@ export function registerTaskTools(server) {
|
|
|
703
733
|
`- updatedAt: ${task.updatedAt}`,
|
|
704
734
|
`- roadmapRefs: ${task.roadmapRefs.join(", ") || "(none)"}`,
|
|
705
735
|
`- taskLocation: ${taskLocation ? `${taskLocation.filePath}#L${taskLocation.line}` : tasksPath}`,
|
|
706
|
-
`- hookStatus: ${hookStatus}`,
|
|
707
736
|
]),
|
|
708
737
|
evidenceSection([
|
|
709
738
|
"### Related Artifacts",
|
|
@@ -714,9 +743,6 @@ export function registerTaskTools(server) {
|
|
|
714
743
|
? referenceLocations.map((item) => `- ${item.filePath}#L${item.line}: ${item.text}`)
|
|
715
744
|
: ["- (none)"]),
|
|
716
745
|
"",
|
|
717
|
-
"### Hook Paths",
|
|
718
|
-
...(hookPaths.length > 0 ? hookPaths.map((item) => `- ${item}`) : ["- (none)"]),
|
|
719
|
-
"",
|
|
720
746
|
"### Suggested Read Order",
|
|
721
747
|
...suggestedReadOrder.map((item, index) => `${index + 1}. ${item}`),
|
|
722
748
|
]),
|
|
@@ -731,12 +757,6 @@ export function registerTaskTools(server) {
|
|
|
731
757
|
nextCallSection(`taskContext(projectPath=\"${governanceDir}\", taskId=\"${task.id}\")`),
|
|
732
758
|
],
|
|
733
759
|
});
|
|
734
|
-
|
|
735
|
-
taskContextHooks.head,
|
|
736
|
-
coreMarkdown,
|
|
737
|
-
taskContextHooks.footer,
|
|
738
|
-
].filter((value) => typeof value === "string" && value.trim().length > 0);
|
|
739
|
-
const markdown = markdownParts.join("\n\n---\n\n");
|
|
740
|
-
return asText(markdown);
|
|
760
|
+
return asText(coreMarkdown);
|
|
741
761
|
});
|
|
742
762
|
}
|
package/output/tasks.test.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
|
-
import { TASKS_END, TASKS_START, collectTaskLintSuggestions, isValidTaskId, normalizeTask, parseTasksBlock, rankActionableTaskCandidates, renderTasksMarkdown, taskPriority, toTaskUpdatedAtMs, validateTransition, } from "./tasks.js";
|
|
2
|
+
import { TASKS_END, TASKS_START, collectTaskLintSuggestions, isValidTaskId, normalizeTask, parseTasksBlock, rankActionableTaskCandidates, resolveNoTaskDiscoveryGuidance, renderTaskSeedTemplate, renderTasksMarkdown, taskPriority, toTaskUpdatedAtMs, validateTransition, } from "./tasks.js";
|
|
3
|
+
import fs from "node:fs/promises";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
3
6
|
function buildCandidate(partial) {
|
|
4
7
|
const task = normalizeTask({
|
|
5
8
|
id: partial.id,
|
|
@@ -29,8 +32,6 @@ describe("tasks module", () => {
|
|
|
29
32
|
"- roadmapRefs: ROADMAP-0001",
|
|
30
33
|
"- links:",
|
|
31
34
|
" - ./designs/example.md",
|
|
32
|
-
"- hooks:",
|
|
33
|
-
" - onAssigned: ./hooks/on_task_assigned.md",
|
|
34
35
|
TASKS_END,
|
|
35
36
|
].join("\n");
|
|
36
37
|
const tasks = await parseTasksBlock(markdown);
|
|
@@ -38,7 +39,7 @@ describe("tasks module", () => {
|
|
|
38
39
|
expect(tasks[0].id).toBe("TASK-0001");
|
|
39
40
|
expect(tasks[0].status).toBe("TODO");
|
|
40
41
|
expect(tasks[0].roadmapRefs).toEqual(["ROADMAP-0001"]);
|
|
41
|
-
expect(tasks[0].
|
|
42
|
+
expect(tasks[0].links).toEqual(["./designs/example.md"]);
|
|
42
43
|
});
|
|
43
44
|
it("renders markdown containing markers", () => {
|
|
44
45
|
const task = normalizeTask({ id: "TASK-0002", title: "render", status: "IN_PROGRESS" });
|
|
@@ -104,8 +105,6 @@ describe("tasks module", () => {
|
|
|
104
105
|
"- roadmapRefs: ROADMAP-0001",
|
|
105
106
|
"- links:",
|
|
106
107
|
" - (none)",
|
|
107
|
-
"- hooks:",
|
|
108
|
-
" - (none)",
|
|
109
108
|
"## TASK-0002 | TODO | B",
|
|
110
109
|
"- owner: (none)",
|
|
111
110
|
"- summary: (none)",
|
|
@@ -113,8 +112,6 @@ describe("tasks module", () => {
|
|
|
113
112
|
"- roadmapRefs: ROADMAP-0001",
|
|
114
113
|
"- links:",
|
|
115
114
|
" - (none)",
|
|
116
|
-
"- hooks:",
|
|
117
|
-
" - (none)",
|
|
118
115
|
TASKS_END,
|
|
119
116
|
].join("\n");
|
|
120
117
|
const scoped = collectTaskLintSuggestions(tasks, markdown, new Set(["TASK-0001"]));
|
|
@@ -125,4 +122,31 @@ describe("tasks module", () => {
|
|
|
125
122
|
expect(allOutside).toContain("TASK-0002");
|
|
126
123
|
expect(allOutside).toContain("TASK-0003");
|
|
127
124
|
});
|
|
125
|
+
it("renders seed task template with provided roadmap ref", () => {
|
|
126
|
+
const lines = renderTaskSeedTemplate("ROADMAP-0099");
|
|
127
|
+
const markdown = lines.join("\n");
|
|
128
|
+
expect(markdown).toContain("## TASK-0001 | TODO | Define initial executable objective");
|
|
129
|
+
expect(markdown).toContain("- roadmapRefs: ROADMAP-0099");
|
|
130
|
+
expect(markdown).toContain("- links:");
|
|
131
|
+
expect(markdown).not.toContain("- hooks:");
|
|
132
|
+
});
|
|
133
|
+
it("uses default no-task guidance when hook file is absent", async () => {
|
|
134
|
+
const guidance = await resolveNoTaskDiscoveryGuidance("/path/that/does/not/exist");
|
|
135
|
+
expect(guidance.length).toBeGreaterThan(3);
|
|
136
|
+
expect(guidance.some((line) => line.includes("TODO/FIXME/HACK"))).toBe(true);
|
|
137
|
+
});
|
|
138
|
+
it("uses hook checklist when task_no_actionable hook exists", async () => {
|
|
139
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "projitive-mcp-test-"));
|
|
140
|
+
const hooksDir = path.join(dir, "hooks");
|
|
141
|
+
await fs.mkdir(hooksDir, { recursive: true });
|
|
142
|
+
await fs.writeFile(path.join(hooksDir, "task_no_actionable.md"), [
|
|
143
|
+
"Objective:",
|
|
144
|
+
"- custom-item-1",
|
|
145
|
+
"- custom-item-2",
|
|
146
|
+
].join("\n"), "utf-8");
|
|
147
|
+
const guidance = await resolveNoTaskDiscoveryGuidance(dir);
|
|
148
|
+
expect(guidance).toContain("- custom-item-1");
|
|
149
|
+
expect(guidance).toContain("- custom-item-2");
|
|
150
|
+
await fs.rm(dir, { recursive: true, force: true });
|
|
151
|
+
});
|
|
128
152
|
});
|