@projitive/mcp 1.0.0-beta.1

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 ADDED
@@ -0,0 +1,318 @@
1
+ # @projitive/mcp
2
+
3
+ **Current Spec Version: projitive-spec v1.0.0 | MCP Version: 1.0.0**
4
+
5
+ Projitive MCP server(语义化接口设计版),用于帮助 Agent 发现项目、发现任务、定位证据,并按治理流程推进。
6
+
7
+ ## 规范版本
8
+
9
+ - 当前遵循规范版本:`projitive-spec v1.0.0`
10
+ - 说明:Projitive 是通用治理规范,MCP 只是该规范的一种工具实现。
11
+ - 对齐规则:MCP 的主版本必须与规范主版本一致(当前均为 `v1.x`)
12
+
13
+ ## 设计边界
14
+
15
+ - MCP 负责:发现、定位、汇总、推进指导。
16
+ - AI 负责:读取与更新 Markdown 正文内容。
17
+ - MCP 不提供:`task.update_*`、`roadmap.update_*`、`sync_*` 这类直接写入治理工件的方法。
18
+ - 所有工具返回内容:面向 Agent 的 Markdown(不是 JSON 对象)。
19
+ - 输出结构统一为:`Summary` / `Evidence` / `Agent Guidance`。
20
+ - 错误结构统一为:`Error` / `Next Step`。
21
+
22
+ ## Tools Methods
23
+
24
+ ### 发现层
25
+
26
+ #### `project.next`
27
+
28
+ - **作用**:直接拉取最近可推进的项目(按可执行任务数和最近更新时间排序)。
29
+ - **输入**:`rootPath?`、`maxDepth?`、`limit?`
30
+ - **输出示例(Markdown)**:
31
+
32
+ ```markdown
33
+ # project.next
34
+
35
+ ## Summary
36
+ - rootPath: /workspace
37
+ - maxDepth: 3
38
+ - matchedProjects: 8
39
+ - actionableProjects: 3
40
+ - limit: 10
41
+
42
+ ## Evidence
43
+ - rankedProjects:
44
+ 1. /workspace/proj-a | actionable=5 | in_progress=2 | todo=3 | blocked=1 | done=4 | latest=2026-02-17T12:00:00.000Z | tasksPath=/workspace/proj-a/tasks.md
45
+ 2. /workspace/proj-b | actionable=3 | in_progress=1 | todo=2 | blocked=0 | done=7 | latest=2026-02-16T09:00:00.000Z | tasksPath=/workspace/proj-b/tasks.md
46
+
47
+ ## Agent Guidance
48
+ - Pick top 1 project and call `project.overview` with its governanceDir.
49
+ - Then call `task.list` and `task.get` to continue execution.
50
+ ```
51
+
52
+ #### `project.scan`
53
+
54
+ - **作用**:扫描目录并发现可治理项目。
55
+ - **输入**:`rootPath?`、`maxDepth?`
56
+ - **输出示例(Markdown)**:
57
+
58
+ ```markdown
59
+ # project.scan
60
+
61
+ ## Summary
62
+ - rootPath: /workspace
63
+ - maxDepth: 3
64
+ - discoveredCount: 2
65
+
66
+ ## Evidence
67
+ - projects:
68
+ 1. /workspace/proj-a
69
+ 2. /workspace/proj-b
70
+
71
+ ## Agent Guidance
72
+ - Next: call `project.locate` with one target path to lock the active governance root.
73
+ - Then: call `project.overview` to view artifact and task status.
74
+ ```
75
+
76
+ #### `project.locate`
77
+
78
+ - **作用**:当 Agent 已经在某个项目目录内时,向上定位最近 `.projitive`,确定当前项目治理根目录。
79
+ - **输入**:`inputPath`
80
+ - **输出示例(Markdown)**:
81
+
82
+ ```markdown
83
+ # project.locate
84
+
85
+ ## Summary
86
+ - resolvedFrom: /workspace/proj-a/packages/mcp
87
+ - governanceDir: /workspace/proj-a
88
+ - markerPath: /workspace/proj-a/.projitive
89
+
90
+ ## Agent Guidance
91
+ - Next: call `project.overview` with this governanceDir to get task and roadmap summaries.
92
+ ```
93
+
94
+ #### `project.overview`
95
+
96
+ - **作用**:自动汇总治理状态,而不是只返回文件列表。
97
+ - **输入**:`projectPath`
98
+ - **输出示例(Markdown)**:
99
+
100
+ ```markdown
101
+ # project.overview
102
+
103
+ ## Summary
104
+ - governanceDir: /workspace/proj-a
105
+ - tasksFile: /workspace/proj-a/tasks.md
106
+ - roadmapIds: 3
107
+
108
+ ## Evidence
109
+ ### Task Summary
110
+ - total: 12
111
+ - TODO: 4
112
+ - IN_PROGRESS: 3
113
+ - BLOCKED: 1
114
+ - DONE: 4
115
+
116
+ ### Artifacts
117
+ - ✅ README.md
118
+ - ✅ roadmap.md
119
+ - ✅ tasks.md
120
+ - ✅ designs/
121
+ - ✅ reports/
122
+ - ✅ hooks/
123
+
124
+ ## Agent Guidance
125
+ - Next: call `task.list` to choose a target task.
126
+ - Then: call `task.get` with a task ID to retrieve evidence locations and reading order.
127
+ ```
128
+
129
+ ### 任务层
130
+
131
+ #### `task.next`
132
+
133
+ - **作用**:一步完成“发现项目 + 选择最可推进任务 + 返回证据定位与阅读顺序”,用于 Agent 直接开工。
134
+ - **输入**:`rootPath?`、`maxDepth?`、`topCandidates?`
135
+ - **输出示例(Markdown)**:
136
+
137
+ ```markdown
138
+ # task.next
139
+
140
+ ## Summary
141
+ - rootPath: /workspace
142
+ - maxDepth: 3
143
+ - matchedProjects: 8
144
+ - actionableTasks: 12
145
+ - selectedProject: /workspace/proj-a
146
+ - selectedTaskId: TASK-0003
147
+ - selectedTaskStatus: IN_PROGRESS
148
+
149
+ ## Evidence
150
+ ### Selected Task
151
+ - id: TASK-0003
152
+ - title: Build MCP tools
153
+ - taskLocation: /workspace/proj-a/tasks.md#L42
154
+
155
+ ### Top Candidates
156
+ 1. TASK-0003 | IN_PROGRESS | Build MCP tools | project=/workspace/proj-a | projectScore=6
157
+ 2. TASK-0007 | TODO | Add docs examples | project=/workspace/proj-b | projectScore=5
158
+
159
+ ### Suggested Read Order
160
+ 1. /workspace/proj-a/tasks.md
161
+ 2. /workspace/proj-a/designs/mcp-design.md
162
+ 3. /workspace/proj-a/reports/mcp-progress.md
163
+
164
+ ## Agent Guidance
165
+ - Start immediately with Suggested Read Order and execute the selected task.
166
+ - Re-run `task.get` for the selectedTaskId after edits to verify evidence consistency.
167
+ ```
168
+
169
+ - **推荐路径**:优先调用 `task.next`,避免 `project.next -> project.overview -> task.list -> task.get` 的多跳链路。
170
+
171
+ #### `task.list`
172
+
173
+ - **作用**:返回当前项目任务清单,支持按状态过滤与排序。
174
+ - **输入**:`projectPath`、`status?`、`limit?`
175
+ - **输出示例(Markdown)**:
176
+
177
+ ```markdown
178
+ # task.list
179
+
180
+ ## Summary
181
+ - governanceDir: /workspace/proj-a
182
+ - tasksPath: /workspace/proj-a/tasks.md
183
+ - filter.status: IN_PROGRESS
184
+ - returned: 2
185
+
186
+ ## Evidence
187
+ - tasks:
188
+ - TASK-0003 | IN_PROGRESS | Build MCP tools | owner=alice | updatedAt=2026-02-17T12:00:00.000Z
189
+ - TASK-0007 | IN_PROGRESS | Add docs examples | owner=bob | updatedAt=2026-02-17T13:30:00.000Z
190
+
191
+ ## Agent Guidance
192
+ - Next: pick one task ID and call `task.get`.
193
+ ```
194
+
195
+ #### `task.get`
196
+
197
+ - **作用**:基于任务 ID 一次性返回任务详情 + 关联证据位置(替代 `trace.references`)。
198
+ - **输入**:`projectPath`、`taskId`
199
+ - **HOOKS 注入**:
200
+ - 若存在 `hooks/task_get_head.md`,其内容会自动追加到返回结果最前面。
201
+ - 若存在 `hooks/task_get_footer.md`,其内容会自动追加到返回结果最后面。
202
+ - 用于给 Agent 注入项目级自定义提示,不改变 task.get 核心结构。
203
+ - **输出示例(Markdown)**:
204
+
205
+ ```markdown
206
+ [hooks/task_get_head.md 内容(如果存在)]
207
+
208
+ ---
209
+
210
+ # task.get
211
+
212
+ ## Summary
213
+ - governanceDir: /workspace/proj-a
214
+ - taskId: TASK-0003
215
+ - title: Build MCP tools
216
+ - status: IN_PROGRESS
217
+ - owner: alice
218
+ - updatedAt: 2026-02-17T12:00:00.000Z
219
+ - roadmapRefs: ROADMAP-0001
220
+ - taskLocation: /workspace/proj-a/tasks.md#L42
221
+ - hookStatus: head=loaded, footer=missing
222
+
223
+ ## Evidence
224
+ ### Related Artifacts
225
+ - /workspace/proj-a/tasks.md
226
+ - /workspace/proj-a/designs/mcp-design.md
227
+ - /workspace/proj-a/reports/mcp-progress.md
228
+
229
+ ### Reference Locations
230
+ - /workspace/proj-a/tasks.md#L42: "id": "TASK-0003"
231
+ - /workspace/proj-a/designs/mcp-design.md#L18: Ref: TASK-0003
232
+
233
+ ### Suggested Read Order
234
+ 1. /workspace/proj-a/tasks.md
235
+ 2. /workspace/proj-a/designs/mcp-design.md
236
+ 3. /workspace/proj-a/reports/mcp-progress.md
237
+
238
+ ## Agent Guidance
239
+ - Read the files in Suggested Read Order.
240
+ - Verify whether current status and evidence are consistent.
241
+
242
+ ---
243
+
244
+ [hooks/task_get_footer.md 内容(如果存在)]
245
+ ```
246
+
247
+ ### 路线层
248
+
249
+ #### `roadmap.list`
250
+
251
+ - **作用**:列出路线图项目及其关联任务概览。
252
+ - **输入**:`projectPath`
253
+ - **输出示例(Markdown)**:
254
+
255
+ ```markdown
256
+ # roadmap.list
257
+
258
+ ## Summary
259
+ - governanceDir: /workspace/proj-a
260
+ - roadmapCount: 2
261
+
262
+ ## Evidence
263
+ - roadmaps:
264
+ - ROADMAP-0001 | linkedTasks=6
265
+ - ROADMAP-0002 | linkedTasks=3
266
+
267
+ ## Agent Guidance
268
+ - Next: call `roadmap.get` with a roadmap ID to inspect references and related tasks.
269
+ ```
270
+
271
+ #### `roadmap.get`
272
+
273
+ - **作用**:获取单个路线图详情与证据定位信息。
274
+ - **输入**:`projectPath`、`roadmapId`
275
+ - **输出示例(Markdown)**:
276
+
277
+ ```markdown
278
+ # roadmap.get
279
+
280
+ ## Summary
281
+ - governanceDir: /workspace/proj-a
282
+ - roadmapId: ROADMAP-0001
283
+ - relatedTasks: 6
284
+ - references: 9
285
+
286
+ ## Evidence
287
+ ### Related Tasks
288
+ - TASK-0001 | DONE | Bootstrap governance
289
+ - TASK-0003 | IN_PROGRESS | Build MCP tools
290
+
291
+ ### Reference Locations
292
+ - /workspace/proj-a/roadmap.md#L21: ROADMAP-0001
293
+ - /workspace/proj-a/tasks.md#L42: "roadmapRefs": ["ROADMAP-0001"]
294
+
295
+ ## Agent Guidance
296
+ - Read roadmap references first, then related tasks.
297
+ - Keep ROADMAP/TASK IDs unchanged while updating markdown files.
298
+ ```
299
+
300
+ ## 统一错误输出示例
301
+
302
+ ```markdown
303
+ # task.get
304
+
305
+ ## Error
306
+ - cause: Invalid task ID format: TASK-12
307
+
308
+ ## Next Step
309
+ - expected format: TASK-0001
310
+ - retry with a valid task ID
311
+ ```
312
+
313
+ ## Agent 推荐调用流程
314
+
315
+ 1. `task.next`:一步发现并选择最可推进任务,直接开工(默认路径)。
316
+ 2. `task.get`:在需要更完整上下文时,按 taskId 拉取详细证据。
317
+ 3. `project.next`:仅在需要“先选项目、再选任务”的项目级调度场景使用(可选)。
318
+ 4. 当 Agent 已在项目内时,用 `project.locate` 快速定位当前项目治理根目录。
@@ -0,0 +1,38 @@
1
+ import { isValidRoadmapId } from "./roadmap.js";
2
+ import { isValidTaskId } from "./tasks.js";
3
+ export function parseDesignMetadata(markdown) {
4
+ const lines = markdown.split(/\r?\n/);
5
+ const metadata = {};
6
+ for (const line of lines) {
7
+ const [rawKey, ...rawValue] = line.split(":");
8
+ if (!rawKey || rawValue.length === 0) {
9
+ continue;
10
+ }
11
+ const key = rawKey.trim().toLowerCase();
12
+ const value = rawValue.join(":").trim();
13
+ if (key === "task")
14
+ metadata.task = value;
15
+ if (key === "roadmap")
16
+ metadata.roadmap = value;
17
+ if (key === "owner")
18
+ metadata.owner = value;
19
+ if (key === "status")
20
+ metadata.status = value;
21
+ if (key === "last updated")
22
+ metadata.lastUpdated = value;
23
+ }
24
+ return metadata;
25
+ }
26
+ export function validateDesignMetadata(metadata) {
27
+ const errors = [];
28
+ if (!metadata.task) {
29
+ errors.push("Missing Task metadata");
30
+ }
31
+ else if (!isValidTaskId(metadata.task)) {
32
+ errors.push(`Invalid Task metadata format: ${metadata.task}`);
33
+ }
34
+ if (metadata.roadmap && !isValidRoadmapId(metadata.roadmap)) {
35
+ errors.push(`Invalid Roadmap metadata format: ${metadata.roadmap}`);
36
+ }
37
+ return { ok: errors.length === 0, errors };
38
+ }
@@ -0,0 +1,48 @@
1
+ // 辅助函数:检查是否为 PromiseLike
2
+ function isPromiseLike(value) {
3
+ return value != null && typeof value === 'object' && 'then' in value && typeof value.then === 'function';
4
+ }
5
+ /**
6
+ * 构造成功结果对象
7
+ * isError 始终返回 false
8
+ */
9
+ function createSuccess(value) {
10
+ return {
11
+ value,
12
+ error: undefined,
13
+ isError() { return false; }
14
+ };
15
+ }
16
+ /**
17
+ * 构造失败结果对象
18
+ * isError 始终返回 true
19
+ */
20
+ function createFailure(error) {
21
+ return {
22
+ error,
23
+ value: undefined,
24
+ isError() { return true; }
25
+ };
26
+ }
27
+ export async function catchIt(input) {
28
+ try {
29
+ if (isPromiseLike(input)) {
30
+ const result = await Promise.resolve(input);
31
+ return createSuccess(result);
32
+ }
33
+ else if (typeof input === 'function') {
34
+ const result = input();
35
+ if (isPromiseLike(result)) {
36
+ return catchIt(result);
37
+ }
38
+ else {
39
+ return createSuccess(result);
40
+ }
41
+ }
42
+ }
43
+ catch (error) {
44
+ return createFailure(error);
45
+ }
46
+ // 理论上不会到达这里,兜底类型安全
47
+ return createFailure(new Error('Unexpected input type'));
48
+ }
@@ -0,0 +1,43 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { catchIt } from './catch.js';
3
+ describe('catchIt', () => {
4
+ it('同步函数返回值应为 value,error 为 undefined,isError 为 false', async () => {
5
+ const result = await catchIt(() => 123);
6
+ expect(result.value).toBe(123);
7
+ expect(result.error).toBeUndefined();
8
+ expect(result.isError()).toBe(false);
9
+ });
10
+ it('异步函数返回值应为 value,error 为 undefined,isError 为 false', async () => {
11
+ const result = await catchIt(async () => 456);
12
+ expect(result.value).toBe(456);
13
+ expect(result.error).toBeUndefined();
14
+ expect(result.isError()).toBe(false);
15
+ });
16
+ it('同步抛出异常时应返回 error,value 为 undefined,isError 为 true', async () => {
17
+ const error = new Error('fail');
18
+ const result = await catchIt(() => { throw error; });
19
+ expect(result.value).toBeUndefined();
20
+ expect(result.error).toBe(error);
21
+ expect(result.isError()).toBe(true);
22
+ });
23
+ it('异步抛出异常时应返回 error,value 为 undefined,isError 为 true', async () => {
24
+ const error = new Error('fail-async');
25
+ const result = await catchIt(() => Promise.reject(error));
26
+ expect(result.value).toBeUndefined();
27
+ expect(result.error).toBe(error);
28
+ expect(result.isError()).toBe(true);
29
+ });
30
+ it('PromiseLike resolve 时应返回 value,error 为 undefined,isError 为 false', async () => {
31
+ const result = await catchIt(Promise.resolve('ok'));
32
+ expect(result.value).toBe('ok');
33
+ expect(result.error).toBeUndefined();
34
+ expect(result.isError()).toBe(false);
35
+ });
36
+ it('PromiseLike reject 时应返回 error,value 为 undefined,isError 为 true', async () => {
37
+ const error = new Error('promise-fail');
38
+ const result = await catchIt(Promise.reject(error));
39
+ expect(result.value).toBeUndefined();
40
+ expect(result.error).toBe(error);
41
+ expect(result.isError()).toBe(true);
42
+ });
43
+ });
@@ -0,0 +1 @@
1
+ export * from './catch.js';
@@ -0,0 +1,62 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { catchIt } from "../catch/index.js";
4
+ const FILE_ARTIFACTS = ["README.md", "roadmap.md", "tasks.md"];
5
+ const DIRECTORY_ARTIFACTS = ["designs", "reports", "hooks"];
6
+ async function fileLineCount(filePath) {
7
+ const content = await fs.readFile(filePath, "utf-8");
8
+ if (!content) {
9
+ return 0;
10
+ }
11
+ return content.split(/\r?\n/).length;
12
+ }
13
+ async function listMarkdownFiles(dirPath) {
14
+ const entriesResult = await catchIt(fs.readdir(dirPath, { withFileTypes: true }));
15
+ if (entriesResult.isError()) {
16
+ return [];
17
+ }
18
+ const entries = entriesResult.value;
19
+ const files = entries.filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith(".md"));
20
+ const result = [];
21
+ for (const file of files) {
22
+ const fullPath = path.join(dirPath, file.name);
23
+ result.push({ path: fullPath, lineCount: await fileLineCount(fullPath) });
24
+ }
25
+ return result.sort((a, b) => a.path.localeCompare(b.path));
26
+ }
27
+ export async function discoverGovernanceArtifacts(governanceDir) {
28
+ const result = [];
29
+ for (const artifact of FILE_ARTIFACTS) {
30
+ const artifactPath = path.join(governanceDir, artifact);
31
+ const accessResult = await catchIt(fs.access(artifactPath));
32
+ if (!accessResult.isError()) {
33
+ result.push({
34
+ name: artifact,
35
+ kind: "file",
36
+ path: artifactPath,
37
+ exists: true,
38
+ lineCount: await fileLineCount(artifactPath),
39
+ });
40
+ }
41
+ else {
42
+ result.push({ name: artifact, kind: "file", path: artifactPath, exists: false });
43
+ }
44
+ }
45
+ for (const artifact of DIRECTORY_ARTIFACTS) {
46
+ const artifactPath = path.join(governanceDir, artifact);
47
+ const accessResult = await catchIt(fs.access(artifactPath));
48
+ if (!accessResult.isError()) {
49
+ result.push({
50
+ name: artifact,
51
+ kind: "directory",
52
+ path: artifactPath,
53
+ exists: true,
54
+ markdownFiles: await listMarkdownFiles(artifactPath),
55
+ });
56
+ }
57
+ else {
58
+ result.push({ name: artifact, kind: "directory", path: artifactPath, exists: false, markdownFiles: [] });
59
+ }
60
+ }
61
+ return result;
62
+ }
@@ -0,0 +1,32 @@
1
+ import fs from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { afterEach, describe, expect, it } from "vitest";
5
+ import { discoverGovernanceArtifacts } from "./files.js";
6
+ const tempPaths = [];
7
+ async function createTempDir() {
8
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), "projitive-mcp-test-"));
9
+ tempPaths.push(dir);
10
+ return dir;
11
+ }
12
+ afterEach(async () => {
13
+ await Promise.all(tempPaths.splice(0).map(async (dir) => {
14
+ await fs.rm(dir, { recursive: true, force: true });
15
+ }));
16
+ });
17
+ describe("files module", () => {
18
+ it("discovers governance artifacts with paths and line counts", async () => {
19
+ const root = await createTempDir();
20
+ await fs.writeFile(path.join(root, "README.md"), "# Readme\n", "utf-8");
21
+ await fs.writeFile(path.join(root, "tasks.md"), "# Tasks\n## TODO\n", "utf-8");
22
+ await fs.mkdir(path.join(root, "designs"), { recursive: true });
23
+ await fs.writeFile(path.join(root, "designs", "feature-design.md"), "# Design\n", "utf-8");
24
+ const artifacts = await discoverGovernanceArtifacts(root);
25
+ const readme = artifacts.find((item) => item.name === "README.md");
26
+ const designs = artifacts.find((item) => item.name === "designs");
27
+ expect(readme?.exists).toBe(true);
28
+ expect(readme?.lineCount).toBe(2);
29
+ expect(designs?.exists).toBe(true);
30
+ expect(designs?.markdownFiles?.[0].path.endsWith("feature-design.md")).toBe(true);
31
+ });
32
+ });
@@ -0,0 +1 @@
1
+ export * from './files.js';
@@ -0,0 +1,3 @@
1
+ export * from './catch/index.js';
2
+ export * from './files/index.js';
3
+ export * from './markdown/index.js';
@@ -0,0 +1 @@
1
+ export * from './markdown.js';
@@ -0,0 +1,33 @@
1
+ import fs from "node:fs/promises";
2
+ export async function readMarkdownSections(filePath) {
3
+ const content = await fs.readFile(filePath, "utf-8");
4
+ const lines = content.split(/\r?\n/);
5
+ const headers = [];
6
+ lines.forEach((line, index) => {
7
+ const match = line.match(/^(#{1,6})\s+(.+)$/);
8
+ if (match) {
9
+ headers.push({ level: match[1].length, heading: match[2].trim(), startLine: index + 1 });
10
+ }
11
+ });
12
+ const sections = headers.map((header, index) => {
13
+ const next = headers[index + 1];
14
+ return {
15
+ heading: header.heading,
16
+ level: header.level,
17
+ startLine: header.startLine,
18
+ endLine: next ? next.startLine - 1 : lines.length,
19
+ };
20
+ });
21
+ return { filePath, lineCount: lines.length, sections };
22
+ }
23
+ export async function findTextReferences(filePath, needle) {
24
+ const content = await fs.readFile(filePath, "utf-8");
25
+ const lines = content.split(/\r?\n/);
26
+ const result = [];
27
+ lines.forEach((line, index) => {
28
+ if (line.includes(needle)) {
29
+ result.push({ filePath, line: index + 1, text: line.trim() });
30
+ }
31
+ });
32
+ return result;
33
+ }
@@ -0,0 +1,36 @@
1
+ import fs from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { afterEach, describe, expect, it } from "vitest";
5
+ import { findTextReferences, readMarkdownSections } from "./markdown.js";
6
+ const tempPaths = [];
7
+ async function createTempDir() {
8
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), "projitive-mcp-test-"));
9
+ tempPaths.push(dir);
10
+ return dir;
11
+ }
12
+ afterEach(async () => {
13
+ await Promise.all(tempPaths.splice(0).map(async (dir) => {
14
+ await fs.rm(dir, { recursive: true, force: true });
15
+ }));
16
+ });
17
+ describe("markdown module", () => {
18
+ it("locates markdown sections with line ranges", async () => {
19
+ const root = await createTempDir();
20
+ const file = path.join(root, "tasks.md");
21
+ await fs.writeFile(file, ["# Tasks", "", "## TODO", "- TASK-0001", "## DONE", "- TASK-0002"].join("\n"), "utf-8");
22
+ const located = await readMarkdownSections(file);
23
+ expect(located.lineCount).toBe(6);
24
+ expect(located.sections[0].heading).toBe("Tasks");
25
+ expect(located.sections[1].heading).toBe("TODO");
26
+ expect(located.sections[1].startLine).toBe(3);
27
+ });
28
+ it("finds ID references with exact line number", async () => {
29
+ const root = await createTempDir();
30
+ const file = path.join(root, "reports.md");
31
+ await fs.writeFile(file, ["Task: TASK-0001", "Roadmap: ROADMAP-0001"].join("\n"), "utf-8");
32
+ const refs = await findTextReferences(file, "TASK-0001");
33
+ expect(refs).toHaveLength(1);
34
+ expect(refs[0].line).toBe(1);
35
+ });
36
+ });
@@ -0,0 +1,62 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { catchIt } from "./helpers/catch/index.js";
4
+ const GLOBAL_EVENT_FILES = {
5
+ onAssigned: "on_task_assigned.md",
6
+ onCompleted: "on_task_completed.md",
7
+ onBlocked: "on_task_blocked.md",
8
+ onReopened: "on_task_reopened.md",
9
+ };
10
+ export function detectHookEvent(from, to) {
11
+ if (to === "IN_PROGRESS") {
12
+ return from === "DONE" ? "onReopened" : "onAssigned";
13
+ }
14
+ if (to === "DONE") {
15
+ return "onCompleted";
16
+ }
17
+ if (to === "BLOCKED") {
18
+ return "onBlocked";
19
+ }
20
+ return null;
21
+ }
22
+ async function readHookFile(filePath) {
23
+ const contentResult = await catchIt(fs.readFile(filePath, "utf-8"));
24
+ if (contentResult.isError()) {
25
+ return { ok: false, warning: `Hook file not found or unreadable: ${filePath}` };
26
+ }
27
+ const content = contentResult.value;
28
+ if (content.trim().length === 0) {
29
+ return { ok: true, content: "" };
30
+ }
31
+ return { ok: true, content };
32
+ }
33
+ export async function resolveHookForEvent(governanceDir, task, event) {
34
+ const taskHookPath = task.hooks[event];
35
+ if (taskHookPath) {
36
+ const resolvedTaskPath = path.resolve(governanceDir, taskHookPath);
37
+ const taskFile = await readHookFile(resolvedTaskPath);
38
+ if (taskFile.ok) {
39
+ return {
40
+ event,
41
+ source: "task",
42
+ path: resolvedTaskPath,
43
+ content: taskFile.content,
44
+ };
45
+ }
46
+ }
47
+ const globalPath = path.join(governanceDir, "hooks", GLOBAL_EVENT_FILES[event]);
48
+ const globalFile = await readHookFile(globalPath);
49
+ if (globalFile.ok) {
50
+ return {
51
+ event,
52
+ source: "global",
53
+ path: globalPath,
54
+ content: globalFile.content,
55
+ };
56
+ }
57
+ return {
58
+ event,
59
+ source: "none",
60
+ warning: globalFile.warning,
61
+ };
62
+ }