@projitive/mcp 2.0.0 → 2.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.
- package/README.md +2 -2
- package/output/package.json +1 -4
- package/output/source/common/linter.js +1 -0
- package/output/source/common/store.js +4 -249
- package/output/source/prompts/quickStart.js +1 -1
- package/output/source/resources/readme.test.js +2 -2
- package/output/source/tools/task.js +63 -5
- package/output/source/tools/task.test.js +24 -0
- package/output/source/types.js +1 -0
- package/package.json +1 -4
- package/output/source/common/migrations/runner.js +0 -68
- package/output/source/common/migrations/steps.js +0 -55
- package/output/source/common/migrations/types.js +0 -1
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@ Language: English | [简体中文](README_CN.md)
|
|
|
5
5
|
## Version
|
|
6
6
|
|
|
7
7
|
- Current Spec Version: projitive-spec v1.0.0
|
|
8
|
-
- MCP Version: 2.0.
|
|
8
|
+
- MCP Version: 2.0.1
|
|
9
9
|
|
|
10
10
|
## 60-Second Start
|
|
11
11
|
|
|
@@ -157,7 +157,7 @@ sequenceDiagram
|
|
|
157
157
|
|
|
158
158
|
### 3. Evidence-First Execution
|
|
159
159
|
|
|
160
|
-
- State changes should be backed by report/
|
|
160
|
+
- State changes should be backed by report/designs/readme evidence.
|
|
161
161
|
- Tool output format is agent-friendly markdown for chained execution.
|
|
162
162
|
|
|
163
163
|
### 4. Deterministic Multi-Agent Workflow
|
package/output/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@projitive/mcp",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.2",
|
|
4
4
|
"description": "Projitive MCP Server for project and task discovery/update",
|
|
5
5
|
"license": "ISC",
|
|
6
6
|
"author": "",
|
|
@@ -25,14 +25,11 @@
|
|
|
25
25
|
"output"
|
|
26
26
|
],
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@duckdb/node-api": "1.5.0-r.1",
|
|
29
28
|
"@modelcontextprotocol/sdk": "^1.17.5",
|
|
30
|
-
"sql.js": "^1.14.1",
|
|
31
29
|
"zod": "^3.23.8"
|
|
32
30
|
},
|
|
33
31
|
"devDependencies": {
|
|
34
32
|
"@types/node": "^24.3.0",
|
|
35
|
-
"@types/sql.js": "^1.4.9",
|
|
36
33
|
"@vitest/coverage-v8": "^3.2.4",
|
|
37
34
|
"tsx": "^4.20.5",
|
|
38
35
|
"typescript": "^5.9.2",
|
|
@@ -13,6 +13,7 @@ export const TASK_LINT_CODES = {
|
|
|
13
13
|
ROADMAP_REFS_EMPTY: "TASK_ROADMAP_REFS_EMPTY",
|
|
14
14
|
OUTSIDE_MARKER: "TASK_OUTSIDE_MARKER",
|
|
15
15
|
LINK_TARGET_MISSING: "TASK_LINK_TARGET_MISSING",
|
|
16
|
+
LINK_PATH_FORMAT_INVALID: "TASK_LINK_PATH_FORMAT_INVALID",
|
|
16
17
|
HOOK_FILE_MISSING: "TASK_HOOK_FILE_MISSING",
|
|
17
18
|
FILTER_EMPTY: "TASK_FILTER_EMPTY",
|
|
18
19
|
CONTEXT_HOOK_HEAD_MISSING: "TASK_CONTEXT_HOOK_HEAD_MISSING",
|
|
@@ -1,13 +1,8 @@
|
|
|
1
1
|
import fs from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import duckdb from "@duckdb/node-api";
|
|
4
|
-
import initSqlJs from "sql.js";
|
|
5
3
|
const STORE_SCHEMA_VERSION = 3;
|
|
6
|
-
const SQL_HEADER = Buffer.from("SQLite format 3\0", "utf8");
|
|
7
|
-
const sqlRuntimePromise = initSqlJs();
|
|
8
4
|
const storeCache = new Map();
|
|
9
5
|
const storeLocks = new Map();
|
|
10
|
-
let duckdbConnectionPromise;
|
|
11
6
|
function defaultViewState(name) {
|
|
12
7
|
return {
|
|
13
8
|
name,
|
|
@@ -37,17 +32,6 @@ function defaultStore() {
|
|
|
37
32
|
function nowIso() {
|
|
38
33
|
return new Date().toISOString();
|
|
39
34
|
}
|
|
40
|
-
function parseJsonOr(raw, fallback) {
|
|
41
|
-
if (typeof raw !== "string" || raw.trim().length === 0) {
|
|
42
|
-
return fallback;
|
|
43
|
-
}
|
|
44
|
-
try {
|
|
45
|
-
return JSON.parse(raw);
|
|
46
|
-
}
|
|
47
|
-
catch {
|
|
48
|
-
return fallback;
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
35
|
function normalizeTaskStatus(status) {
|
|
52
36
|
if (status === "IN_PROGRESS" || status === "BLOCKED" || status === "DONE") {
|
|
53
37
|
return status;
|
|
@@ -57,12 +41,6 @@ function normalizeTaskStatus(status) {
|
|
|
57
41
|
function normalizeRoadmapStatus(status) {
|
|
58
42
|
return status === "done" ? "done" : "active";
|
|
59
43
|
}
|
|
60
|
-
function isSqliteBuffer(data) {
|
|
61
|
-
if (data.length < SQL_HEADER.length) {
|
|
62
|
-
return false;
|
|
63
|
-
}
|
|
64
|
-
return data.subarray(0, SQL_HEADER.length).equals(SQL_HEADER);
|
|
65
|
-
}
|
|
66
44
|
function normalizeStore(input) {
|
|
67
45
|
const base = defaultStore();
|
|
68
46
|
const meta = input.meta ?? {};
|
|
@@ -129,106 +107,6 @@ function normalizeStore(input) {
|
|
|
129
107
|
migration_history: Array.isArray(input.migration_history) ? input.migration_history : [],
|
|
130
108
|
};
|
|
131
109
|
}
|
|
132
|
-
async function migrateSqliteToJson(data) {
|
|
133
|
-
const SQL = await sqlRuntimePromise;
|
|
134
|
-
const db = new SQL.Database(new Uint8Array(data));
|
|
135
|
-
try {
|
|
136
|
-
const tasksResult = db.exec(`
|
|
137
|
-
SELECT id, title, status, owner, summary, updated_at, links_json, roadmap_refs_json, sub_state_json, blocker_json, COALESCE(record_version, 1)
|
|
138
|
-
FROM tasks
|
|
139
|
-
`);
|
|
140
|
-
const roadmapsResult = db.exec(`
|
|
141
|
-
SELECT id, title, status, time, updated_at, COALESCE(record_version, 1)
|
|
142
|
-
FROM roadmaps
|
|
143
|
-
`);
|
|
144
|
-
const metaResult = db.exec(`
|
|
145
|
-
SELECT key, value
|
|
146
|
-
FROM meta
|
|
147
|
-
WHERE key IN ('tasks_version', 'roadmaps_version', 'store_schema_version')
|
|
148
|
-
`);
|
|
149
|
-
const viewStateResult = db.exec(`
|
|
150
|
-
SELECT name, dirty, last_source_version, last_built_at, COALESCE(record_version, 1)
|
|
151
|
-
FROM view_state
|
|
152
|
-
WHERE name IN ('tasks_markdown', 'roadmaps_markdown')
|
|
153
|
-
`);
|
|
154
|
-
const tasks = tasksResult[0]?.values?.map((row) => ({
|
|
155
|
-
id: String(row[0]),
|
|
156
|
-
title: String(row[1]),
|
|
157
|
-
status: normalizeTaskStatus(String(row[2])),
|
|
158
|
-
owner: String(row[3]),
|
|
159
|
-
summary: String(row[4]),
|
|
160
|
-
updatedAt: String(row[5]),
|
|
161
|
-
links: parseJsonOr(row[6], []),
|
|
162
|
-
roadmapRefs: parseJsonOr(row[7], []),
|
|
163
|
-
subState: parseJsonOr(row[8], undefined),
|
|
164
|
-
blocker: parseJsonOr(row[9], undefined),
|
|
165
|
-
recordVersion: Number(row[10]) || 1,
|
|
166
|
-
})) ?? [];
|
|
167
|
-
const roadmaps = roadmapsResult[0]?.values?.map((row) => ({
|
|
168
|
-
id: String(row[0]),
|
|
169
|
-
title: String(row[1]),
|
|
170
|
-
status: normalizeRoadmapStatus(String(row[2])),
|
|
171
|
-
time: row[3] == null ? undefined : String(row[3]),
|
|
172
|
-
updatedAt: String(row[4]),
|
|
173
|
-
recordVersion: Number(row[5]) || 1,
|
|
174
|
-
})) ?? [];
|
|
175
|
-
const meta = defaultStore().meta;
|
|
176
|
-
const metaRows = metaResult[0]?.values ?? [];
|
|
177
|
-
for (const row of metaRows) {
|
|
178
|
-
const key = String(row[0]);
|
|
179
|
-
const value = Number.parseInt(String(row[1]), 10);
|
|
180
|
-
if (!Number.isFinite(value)) {
|
|
181
|
-
continue;
|
|
182
|
-
}
|
|
183
|
-
if (key === "tasks_version")
|
|
184
|
-
meta.tasks_version = value;
|
|
185
|
-
if (key === "roadmaps_version")
|
|
186
|
-
meta.roadmaps_version = value;
|
|
187
|
-
if (key === "store_schema_version")
|
|
188
|
-
meta.store_schema_version = value;
|
|
189
|
-
}
|
|
190
|
-
const tasksView = defaultViewState("tasks_markdown");
|
|
191
|
-
const roadmapsView = defaultViewState("roadmaps_markdown");
|
|
192
|
-
const viewRows = viewStateResult[0]?.values ?? [];
|
|
193
|
-
for (const row of viewRows) {
|
|
194
|
-
const name = String(row[0]);
|
|
195
|
-
const dirty = Number(row[1]) === 1;
|
|
196
|
-
const lastSourceVersion = Number(row[2]) || 0;
|
|
197
|
-
const lastBuiltAt = String(row[3] ?? "");
|
|
198
|
-
const recordVersion = Number(row[4]) || 1;
|
|
199
|
-
if (name === "tasks_markdown") {
|
|
200
|
-
tasksView.dirty = dirty;
|
|
201
|
-
tasksView.lastSourceVersion = lastSourceVersion;
|
|
202
|
-
tasksView.lastBuiltAt = lastBuiltAt;
|
|
203
|
-
tasksView.recordVersion = recordVersion;
|
|
204
|
-
}
|
|
205
|
-
if (name === "roadmaps_markdown") {
|
|
206
|
-
roadmapsView.dirty = dirty;
|
|
207
|
-
roadmapsView.lastSourceVersion = lastSourceVersion;
|
|
208
|
-
roadmapsView.lastBuiltAt = lastBuiltAt;
|
|
209
|
-
roadmapsView.recordVersion = recordVersion;
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
return normalizeStore({
|
|
213
|
-
schema: "projitive-json-store",
|
|
214
|
-
tasks,
|
|
215
|
-
roadmaps,
|
|
216
|
-
meta: {
|
|
217
|
-
tasks_version: meta.tasks_version,
|
|
218
|
-
roadmaps_version: meta.roadmaps_version,
|
|
219
|
-
store_schema_version: STORE_SCHEMA_VERSION,
|
|
220
|
-
},
|
|
221
|
-
view_state: {
|
|
222
|
-
tasks_markdown: tasksView,
|
|
223
|
-
roadmaps_markdown: roadmapsView,
|
|
224
|
-
},
|
|
225
|
-
migration_history: [],
|
|
226
|
-
});
|
|
227
|
-
}
|
|
228
|
-
finally {
|
|
229
|
-
db.close();
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
110
|
async function persistStore(dbPath, store) {
|
|
233
111
|
await fs.mkdir(path.dirname(dbPath), { recursive: true });
|
|
234
112
|
const tempPath = `${dbPath}.tmp-${process.pid}-${Date.now()}`;
|
|
@@ -241,10 +119,6 @@ async function loadStoreFromDisk(dbPath) {
|
|
|
241
119
|
if (!file || file.length === 0) {
|
|
242
120
|
return { store: defaultStore(), shouldPersist: true };
|
|
243
121
|
}
|
|
244
|
-
if (isSqliteBuffer(file)) {
|
|
245
|
-
const migrated = await migrateSqliteToJson(file);
|
|
246
|
-
return { store: migrated, shouldPersist: true };
|
|
247
|
-
}
|
|
248
122
|
const text = file.toString("utf8").trim();
|
|
249
123
|
if (text.length === 0) {
|
|
250
124
|
return { store: defaultStore(), shouldPersist: true };
|
|
@@ -337,119 +211,6 @@ function normalizeStatusForSort(status) {
|
|
|
337
211
|
return 1;
|
|
338
212
|
return 0;
|
|
339
213
|
}
|
|
340
|
-
async function getDuckdbConnection() {
|
|
341
|
-
if (!duckdbConnectionPromise) {
|
|
342
|
-
duckdbConnectionPromise = (async () => {
|
|
343
|
-
const instance = await duckdb.DuckDBInstance.create(":memory:");
|
|
344
|
-
return instance.connect();
|
|
345
|
-
})();
|
|
346
|
-
}
|
|
347
|
-
return duckdbConnectionPromise;
|
|
348
|
-
}
|
|
349
|
-
function normalizeStoredTaskLike(raw) {
|
|
350
|
-
if (!raw || typeof raw !== "object") {
|
|
351
|
-
return null;
|
|
352
|
-
}
|
|
353
|
-
const value = raw;
|
|
354
|
-
const id = value.id;
|
|
355
|
-
const title = value.title;
|
|
356
|
-
if (typeof id !== "string" || typeof title !== "string") {
|
|
357
|
-
return null;
|
|
358
|
-
}
|
|
359
|
-
const statusRaw = typeof value.status === "string" ? value.status : "TODO";
|
|
360
|
-
const owner = typeof value.owner === "string" ? value.owner : "";
|
|
361
|
-
const summary = typeof value.summary === "string" ? value.summary : "";
|
|
362
|
-
const updatedAt = typeof value.updatedAt === "string"
|
|
363
|
-
? value.updatedAt
|
|
364
|
-
: (typeof value.updated_at === "string" ? value.updated_at : nowIso());
|
|
365
|
-
const links = Array.isArray(value.links) ? value.links.map((item) => String(item)) : [];
|
|
366
|
-
const roadmapRefs = Array.isArray(value.roadmapRefs)
|
|
367
|
-
? value.roadmapRefs.map((item) => String(item))
|
|
368
|
-
: (Array.isArray(value.roadmap_refs) ? value.roadmap_refs.map((item) => String(item)) : []);
|
|
369
|
-
const recordVersionRaw = value.recordVersion ?? value.record_version;
|
|
370
|
-
const recordVersion = Number.isFinite(Number(recordVersionRaw)) ? Number(recordVersionRaw) : 1;
|
|
371
|
-
return {
|
|
372
|
-
id,
|
|
373
|
-
title,
|
|
374
|
-
status: normalizeTaskStatus(statusRaw),
|
|
375
|
-
owner,
|
|
376
|
-
summary,
|
|
377
|
-
updatedAt,
|
|
378
|
-
links,
|
|
379
|
-
roadmapRefs,
|
|
380
|
-
subState: value.subState,
|
|
381
|
-
blocker: value.blocker,
|
|
382
|
-
recordVersion,
|
|
383
|
-
};
|
|
384
|
-
}
|
|
385
|
-
function normalizeStoredRoadmapLike(raw) {
|
|
386
|
-
if (!raw || typeof raw !== "object") {
|
|
387
|
-
return null;
|
|
388
|
-
}
|
|
389
|
-
const value = raw;
|
|
390
|
-
const id = value.id;
|
|
391
|
-
const title = value.title;
|
|
392
|
-
if (typeof id !== "string" || typeof title !== "string") {
|
|
393
|
-
return null;
|
|
394
|
-
}
|
|
395
|
-
const statusRaw = typeof value.status === "string" ? value.status : "active";
|
|
396
|
-
const time = typeof value.time === "string" ? value.time : undefined;
|
|
397
|
-
const updatedAt = typeof value.updatedAt === "string"
|
|
398
|
-
? value.updatedAt
|
|
399
|
-
: (typeof value.updated_at === "string" ? value.updated_at : nowIso());
|
|
400
|
-
const recordVersionRaw = value.recordVersion ?? value.record_version;
|
|
401
|
-
const recordVersion = Number.isFinite(Number(recordVersionRaw)) ? Number(recordVersionRaw) : 1;
|
|
402
|
-
return {
|
|
403
|
-
id,
|
|
404
|
-
title,
|
|
405
|
-
status: normalizeRoadmapStatus(statusRaw),
|
|
406
|
-
time,
|
|
407
|
-
updatedAt,
|
|
408
|
-
recordVersion,
|
|
409
|
-
};
|
|
410
|
-
}
|
|
411
|
-
async function runDuckdbQuery(sql) {
|
|
412
|
-
try {
|
|
413
|
-
const connection = await getDuckdbConnection();
|
|
414
|
-
const result = await connection.run(sql);
|
|
415
|
-
const rows = await result.getRowObjectsJS();
|
|
416
|
-
if (!rows || rows.length === 0) {
|
|
417
|
-
return undefined;
|
|
418
|
-
}
|
|
419
|
-
return rows;
|
|
420
|
-
}
|
|
421
|
-
catch {
|
|
422
|
-
return undefined;
|
|
423
|
-
}
|
|
424
|
-
}
|
|
425
|
-
async function loadTasksFromDuckdb(dbPath) {
|
|
426
|
-
const sql = `SELECT tasks FROM read_json_auto('${dbPath.replace(/'/g, "''")}') LIMIT 1;`;
|
|
427
|
-
const rows = await runDuckdbQuery(sql);
|
|
428
|
-
if (!rows || rows.length === 0) {
|
|
429
|
-
return undefined;
|
|
430
|
-
}
|
|
431
|
-
const rawTasks = rows[0]?.tasks;
|
|
432
|
-
if (!Array.isArray(rawTasks)) {
|
|
433
|
-
return undefined;
|
|
434
|
-
}
|
|
435
|
-
return rawTasks
|
|
436
|
-
.map((item) => normalizeStoredTaskLike(item))
|
|
437
|
-
.filter((item) => item != null);
|
|
438
|
-
}
|
|
439
|
-
async function loadRoadmapsFromDuckdb(dbPath) {
|
|
440
|
-
const sql = `SELECT roadmaps FROM read_json_auto('${dbPath.replace(/'/g, "''")}') LIMIT 1;`;
|
|
441
|
-
const rows = await runDuckdbQuery(sql);
|
|
442
|
-
if (!rows || rows.length === 0) {
|
|
443
|
-
return undefined;
|
|
444
|
-
}
|
|
445
|
-
const rawRoadmaps = rows[0]?.roadmaps;
|
|
446
|
-
if (!Array.isArray(rawRoadmaps)) {
|
|
447
|
-
return undefined;
|
|
448
|
-
}
|
|
449
|
-
return rawRoadmaps
|
|
450
|
-
.map((item) => normalizeStoredRoadmapLike(item))
|
|
451
|
-
.filter((item) => item != null);
|
|
452
|
-
}
|
|
453
214
|
export async function ensureStore(dbPath) {
|
|
454
215
|
await openStore(dbPath);
|
|
455
216
|
}
|
|
@@ -487,11 +248,8 @@ export async function markMarkdownViewDirty(dbPath, viewName) {
|
|
|
487
248
|
});
|
|
488
249
|
}
|
|
489
250
|
export async function loadTasksFromStore(dbPath) {
|
|
490
|
-
const
|
|
491
|
-
|
|
492
|
-
throw new Error("DuckDB task query failed");
|
|
493
|
-
}
|
|
494
|
-
return tasksFromDuckdb.map(toPublicTask);
|
|
251
|
+
const store = await openStore(dbPath);
|
|
252
|
+
return store.tasks.map(toPublicTask);
|
|
495
253
|
}
|
|
496
254
|
export async function loadTaskStatusStatsFromStore(dbPath) {
|
|
497
255
|
const tasks = await loadTasksFromStore(dbPath);
|
|
@@ -568,11 +326,8 @@ export async function replaceTasksInStore(dbPath, tasks) {
|
|
|
568
326
|
});
|
|
569
327
|
}
|
|
570
328
|
export async function loadRoadmapsFromStore(dbPath) {
|
|
571
|
-
const
|
|
572
|
-
|
|
573
|
-
throw new Error("DuckDB roadmap query failed");
|
|
574
|
-
}
|
|
575
|
-
return roadmapsFromDuckdb.map(toPublicRoadmap);
|
|
329
|
+
const store = await openStore(dbPath);
|
|
330
|
+
return store.roadmaps.map(toPublicRoadmap);
|
|
576
331
|
}
|
|
577
332
|
export async function loadRoadmapIdsFromStore(dbPath) {
|
|
578
333
|
const roadmaps = await loadRoadmapsFromStore(dbPath);
|
|
@@ -99,7 +99,7 @@ export function registerQuickStartPrompt(server) {
|
|
|
99
99
|
"2. If roadmap has active goals, split milestones into 1-3 executable TODO tasks",
|
|
100
100
|
"3. Apply task creation gate before adding each task:",
|
|
101
101
|
" - Clear outcome: one-sentence done condition",
|
|
102
|
-
" - Verifiable evidence: at least one report/
|
|
102
|
+
" - Verifiable evidence: at least one report/designs/readme link target",
|
|
103
103
|
" - Small slice: should be completable in one focused execution cycle",
|
|
104
104
|
" - Traceability: include at least one roadmapRefs item when applicable",
|
|
105
105
|
" - Distinct scope: avoid overlap with existing DONE/BLOCKED tasks",
|
|
@@ -10,7 +10,7 @@ describe("readme module", () => {
|
|
|
10
10
|
"",
|
|
11
11
|
"## Required Reading for Agents",
|
|
12
12
|
"",
|
|
13
|
-
"- Local: ./
|
|
13
|
+
"- Local: ./designs/README.md",
|
|
14
14
|
"- Local: .projitive/tasks.md",
|
|
15
15
|
"- External: https://example.com/docs",
|
|
16
16
|
"",
|
|
@@ -22,7 +22,7 @@ describe("readme module", () => {
|
|
|
22
22
|
expect(result.length).toBe(3);
|
|
23
23
|
expect(result[0]).toEqual({
|
|
24
24
|
source: "Local",
|
|
25
|
-
value: "./
|
|
25
|
+
value: "./designs/README.md",
|
|
26
26
|
});
|
|
27
27
|
expect(result[1]).toEqual({
|
|
28
28
|
source: "Local",
|
|
@@ -42,7 +42,7 @@ const DEFAULT_NO_TASK_DISCOVERY_GUIDANCE = [
|
|
|
42
42
|
"- If all remaining tasks are BLOCKED, create one unblock task with explicit unblock condition and dependency owner.",
|
|
43
43
|
"- Start from active roadmap milestones and split into the smallest executable slices with a single done condition each.",
|
|
44
44
|
"- Prefer slices that unlock multiple downstream tasks before isolated refactors or low-impact cleanups.",
|
|
45
|
-
"- Create TODO tasks only when evidence is clear: each new task must produce at least one report/
|
|
45
|
+
"- Create TODO tasks only when evidence is clear: each new task must produce at least one report/designs/readme artifact update.",
|
|
46
46
|
"- Skip duplicate scope: do not create tasks that overlap existing TODO/IN_PROGRESS/BLOCKED task intent.",
|
|
47
47
|
"- Use quality gates for discovery candidates: user value, delivery risk reduction, or measurable throughput improvement.",
|
|
48
48
|
"- Keep each discovery round small (1-3 tasks), then rerun taskNext immediately for re-ranking and execution.",
|
|
@@ -85,11 +85,33 @@ export function renderTaskSeedTemplate(roadmapRef) {
|
|
|
85
85
|
"- updatedAt: 2026-01-01T00:00:00.000Z",
|
|
86
86
|
`- roadmapRefs: ${roadmapRef}`,
|
|
87
87
|
"- links:",
|
|
88
|
-
" -
|
|
89
|
-
" -
|
|
88
|
+
" - README.md",
|
|
89
|
+
" - .projitive/roadmap.md",
|
|
90
90
|
"```",
|
|
91
91
|
];
|
|
92
92
|
}
|
|
93
|
+
function isHttpUrl(value) {
|
|
94
|
+
return /^https?:\/\//i.test(value);
|
|
95
|
+
}
|
|
96
|
+
function isProjectRootRelativePath(value) {
|
|
97
|
+
return value.length > 0
|
|
98
|
+
&& !value.startsWith("/")
|
|
99
|
+
&& !value.startsWith("./")
|
|
100
|
+
&& !value.startsWith("../")
|
|
101
|
+
&& !/^[A-Za-z]:\//.test(value);
|
|
102
|
+
}
|
|
103
|
+
function normalizeTaskLink(link) {
|
|
104
|
+
const trimmed = link.trim();
|
|
105
|
+
if (trimmed.length === 0 || isHttpUrl(trimmed)) {
|
|
106
|
+
return trimmed;
|
|
107
|
+
}
|
|
108
|
+
const slashNormalized = trimmed.replace(/\\/g, "/");
|
|
109
|
+
const withoutDotPrefix = slashNormalized.replace(/^\.\//, "");
|
|
110
|
+
return withoutDotPrefix.replace(/^\/+/, "");
|
|
111
|
+
}
|
|
112
|
+
function resolveTaskLinkPath(projectPath, link) {
|
|
113
|
+
return path.join(projectPath, link);
|
|
114
|
+
}
|
|
93
115
|
async function readActionableTaskCandidates(governanceDirs) {
|
|
94
116
|
const snapshots = await Promise.all(governanceDirs.map(async (governanceDir) => {
|
|
95
117
|
const tasksPath = path.join(governanceDir, ".projitive");
|
|
@@ -206,7 +228,12 @@ export function normalizeTask(task) {
|
|
|
206
228
|
owner: task.owner ? String(task.owner) : "",
|
|
207
229
|
summary: task.summary ? String(task.summary) : "",
|
|
208
230
|
updatedAt: task.updatedAt ? String(task.updatedAt) : nowIso(),
|
|
209
|
-
links: Array.isArray(task.links)
|
|
231
|
+
links: Array.isArray(task.links)
|
|
232
|
+
? Array.from(new Set(task.links
|
|
233
|
+
.map(String)
|
|
234
|
+
.map((value) => normalizeTaskLink(value))
|
|
235
|
+
.filter((value) => value.length > 0)))
|
|
236
|
+
: [],
|
|
210
237
|
roadmapRefs: Array.from(new Set(normalizedRoadmapRefs)),
|
|
211
238
|
};
|
|
212
239
|
// Include optional v1.1.0 fields if present
|
|
@@ -274,6 +301,17 @@ function collectTaskLintSuggestionItems(tasks) {
|
|
|
274
301
|
fixHint: "Bind at least one ROADMAP-xxxx when applicable.",
|
|
275
302
|
});
|
|
276
303
|
}
|
|
304
|
+
const invalidLinkPathFormat = tasks.filter((task) => task.links.some((link) => {
|
|
305
|
+
const normalized = link.trim();
|
|
306
|
+
return normalized.length > 0 && !isHttpUrl(normalized) && !isProjectRootRelativePath(normalized);
|
|
307
|
+
}));
|
|
308
|
+
if (invalidLinkPathFormat.length > 0) {
|
|
309
|
+
suggestions.push({
|
|
310
|
+
code: TASK_LINT_CODES.LINK_PATH_FORMAT_INVALID,
|
|
311
|
+
message: `${invalidLinkPathFormat.length} task(s) contain invalid links path format.`,
|
|
312
|
+
fixHint: "Use project-root-relative paths without leading slash (for example reports/task-0001.md) or http(s) URL.",
|
|
313
|
+
});
|
|
314
|
+
}
|
|
277
315
|
// ============================================================================
|
|
278
316
|
// Spec v1.1.0 - Blocker Categorization Validation
|
|
279
317
|
// ============================================================================
|
|
@@ -349,6 +387,17 @@ function collectSingleTaskLintSuggestions(task) {
|
|
|
349
387
|
fixHint: "Add at least one evidence link.",
|
|
350
388
|
});
|
|
351
389
|
}
|
|
390
|
+
const invalidLinkPathFormat = task.links.some((link) => {
|
|
391
|
+
const normalized = link.trim();
|
|
392
|
+
return normalized.length > 0 && !isHttpUrl(normalized) && !isProjectRootRelativePath(normalized);
|
|
393
|
+
});
|
|
394
|
+
if (invalidLinkPathFormat) {
|
|
395
|
+
suggestions.push({
|
|
396
|
+
code: TASK_LINT_CODES.LINK_PATH_FORMAT_INVALID,
|
|
397
|
+
message: "Current task has invalid links path format.",
|
|
398
|
+
fixHint: "Use project-root-relative paths without leading slash (for example reports/task-0001.md) or http(s) URL.",
|
|
399
|
+
});
|
|
400
|
+
}
|
|
352
401
|
if (task.status === "BLOCKED" && task.summary.trim().length === 0) {
|
|
353
402
|
suggestions.push({
|
|
354
403
|
code: TASK_LINT_CODES.BLOCKED_SUMMARY_EMPTY,
|
|
@@ -422,6 +471,7 @@ function collectSingleTaskLintSuggestions(task) {
|
|
|
422
471
|
}
|
|
423
472
|
async function collectTaskFileLintSuggestions(governanceDir, task) {
|
|
424
473
|
const suggestions = [];
|
|
474
|
+
const projectPath = toProjectPath(governanceDir);
|
|
425
475
|
for (const link of task.links) {
|
|
426
476
|
const normalized = link.trim();
|
|
427
477
|
if (normalized.length === 0) {
|
|
@@ -430,7 +480,15 @@ async function collectTaskFileLintSuggestions(governanceDir, task) {
|
|
|
430
480
|
if (/^https?:\/\//i.test(normalized)) {
|
|
431
481
|
continue;
|
|
432
482
|
}
|
|
433
|
-
|
|
483
|
+
if (!isProjectRootRelativePath(normalized)) {
|
|
484
|
+
suggestions.push({
|
|
485
|
+
code: TASK_LINT_CODES.LINK_PATH_FORMAT_INVALID,
|
|
486
|
+
message: `Link path should be project-root-relative without leading slash: ${normalized}.`,
|
|
487
|
+
fixHint: "Use path/from/project/root format.",
|
|
488
|
+
});
|
|
489
|
+
continue;
|
|
490
|
+
}
|
|
491
|
+
const resolvedPath = resolveTaskLinkPath(projectPath, normalized);
|
|
434
492
|
const exists = await fs.access(resolvedPath).then(() => true).catch(() => false);
|
|
435
493
|
if (!exists) {
|
|
436
494
|
suggestions.push({
|
|
@@ -111,6 +111,30 @@ describe("tasks module", () => {
|
|
|
111
111
|
expect(lint.some((line) => line.includes("BLOCKED_WITHOUT_BLOCKER"))).toBe(true);
|
|
112
112
|
expect(lint.some((line) => line.includes("IN_PROGRESS_WITHOUT_SUBSTATE"))).toBe(true);
|
|
113
113
|
});
|
|
114
|
+
it("normalizes links to project-root-relative format without leading slash", () => {
|
|
115
|
+
const task = normalizeTask({
|
|
116
|
+
id: "TASK-0003",
|
|
117
|
+
title: "link normalize",
|
|
118
|
+
status: "TODO",
|
|
119
|
+
links: ["/reports/a.md", "./designs/b.md", "reports/c.md", "https://example.com/evidence"],
|
|
120
|
+
});
|
|
121
|
+
expect(task.links).toContain("reports/a.md");
|
|
122
|
+
expect(task.links).toContain("designs/b.md");
|
|
123
|
+
expect(task.links).toContain("reports/c.md");
|
|
124
|
+
expect(task.links).toContain("https://example.com/evidence");
|
|
125
|
+
expect(task.links.some((item) => item.startsWith("/"))).toBe(false);
|
|
126
|
+
});
|
|
127
|
+
it("lints invalid links path format", () => {
|
|
128
|
+
const task = normalizeTask({
|
|
129
|
+
id: "TASK-0004",
|
|
130
|
+
title: "invalid link",
|
|
131
|
+
status: "TODO",
|
|
132
|
+
links: ["../outside.md"],
|
|
133
|
+
roadmapRefs: ["ROADMAP-0001"],
|
|
134
|
+
});
|
|
135
|
+
const lint = collectTaskLintSuggestions([task]);
|
|
136
|
+
expect(lint.some((line) => line.includes("TASK_LINK_PATH_FORMAT_INVALID"))).toBe(true);
|
|
137
|
+
});
|
|
114
138
|
it("renders seed task template with provided roadmap ref", () => {
|
|
115
139
|
const lines = renderTaskSeedTemplate("ROADMAP-0099");
|
|
116
140
|
const markdown = lines.join("\n");
|
package/output/source/types.js
CHANGED
|
@@ -36,6 +36,7 @@ export const TASK_LINT_CODES = {
|
|
|
36
36
|
OUTSIDE_MARKER: "TASK_OUTSIDE_MARKER",
|
|
37
37
|
FILTER_EMPTY: "TASK_FILTER_EMPTY",
|
|
38
38
|
LINK_TARGET_MISSING: "TASK_LINK_TARGET_MISSING",
|
|
39
|
+
LINK_PATH_FORMAT_INVALID: "TASK_LINK_PATH_FORMAT_INVALID",
|
|
39
40
|
// Spec v1.1.0 - Blocker Categorization
|
|
40
41
|
BLOCKED_WITHOUT_BLOCKER: "TASK_BLOCKED_WITHOUT_BLOCKER",
|
|
41
42
|
BLOCKER_TYPE_INVALID: "TASK_BLOCKER_TYPE_INVALID",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@projitive/mcp",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.2",
|
|
4
4
|
"description": "Projitive MCP Server for project and task discovery/update",
|
|
5
5
|
"license": "ISC",
|
|
6
6
|
"author": "",
|
|
@@ -25,14 +25,11 @@
|
|
|
25
25
|
"output"
|
|
26
26
|
],
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@duckdb/node-api": "1.5.0-r.1",
|
|
29
28
|
"@modelcontextprotocol/sdk": "^1.17.5",
|
|
30
|
-
"sql.js": "^1.14.1",
|
|
31
29
|
"zod": "^3.23.8"
|
|
32
30
|
},
|
|
33
31
|
"devDependencies": {
|
|
34
32
|
"@types/node": "^24.3.0",
|
|
35
|
-
"@types/sql.js": "^1.4.9",
|
|
36
33
|
"@vitest/coverage-v8": "^3.2.4",
|
|
37
34
|
"tsx": "^4.20.5",
|
|
38
35
|
"typescript": "^5.9.2",
|
|
@@ -1,68 +0,0 @@
|
|
|
1
|
-
import { MIGRATION_STEPS } from "./steps.js";
|
|
2
|
-
function setStoreSchemaVersion(db, nextVersion) {
|
|
3
|
-
const statement = db.prepare(`
|
|
4
|
-
INSERT INTO meta (key, value)
|
|
5
|
-
VALUES (?, ?)
|
|
6
|
-
ON CONFLICT(key) DO UPDATE SET value=excluded.value
|
|
7
|
-
`);
|
|
8
|
-
statement.run(["store_schema_version", String(nextVersion)]);
|
|
9
|
-
statement.free();
|
|
10
|
-
}
|
|
11
|
-
function writeMigrationHistory(db, migrationId, fromVersion, toVersion, checksum, status, startedAt, finishedAt, errorMessage) {
|
|
12
|
-
const statement = db.prepare(`
|
|
13
|
-
INSERT INTO migration_history (
|
|
14
|
-
id,
|
|
15
|
-
from_version,
|
|
16
|
-
to_version,
|
|
17
|
-
checksum,
|
|
18
|
-
started_at,
|
|
19
|
-
finished_at,
|
|
20
|
-
status,
|
|
21
|
-
error_message
|
|
22
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
23
|
-
`);
|
|
24
|
-
statement.run([
|
|
25
|
-
migrationId,
|
|
26
|
-
fromVersion,
|
|
27
|
-
toVersion,
|
|
28
|
-
checksum,
|
|
29
|
-
startedAt,
|
|
30
|
-
finishedAt,
|
|
31
|
-
status,
|
|
32
|
-
errorMessage,
|
|
33
|
-
]);
|
|
34
|
-
statement.free();
|
|
35
|
-
}
|
|
36
|
-
export function runPendingMigrations(db, currentVersion, targetVersion) {
|
|
37
|
-
let versionCursor = currentVersion;
|
|
38
|
-
if (versionCursor >= targetVersion) {
|
|
39
|
-
return;
|
|
40
|
-
}
|
|
41
|
-
const steps = MIGRATION_STEPS
|
|
42
|
-
.filter((item) => item.fromVersion >= currentVersion && item.toVersion <= targetVersion)
|
|
43
|
-
.sort((a, b) => a.fromVersion - b.fromVersion);
|
|
44
|
-
for (const step of steps) {
|
|
45
|
-
if (step.fromVersion !== versionCursor) {
|
|
46
|
-
throw new Error(`Migration chain broken at version ${versionCursor}. Missing step ${versionCursor} -> ${step.toVersion}.`);
|
|
47
|
-
}
|
|
48
|
-
const startedAt = new Date().toISOString();
|
|
49
|
-
try {
|
|
50
|
-
db.exec("BEGIN TRANSACTION;");
|
|
51
|
-
step.up(db);
|
|
52
|
-
setStoreSchemaVersion(db, step.toVersion);
|
|
53
|
-
const finishedAt = new Date().toISOString();
|
|
54
|
-
writeMigrationHistory(db, step.id, step.fromVersion, step.toVersion, step.checksum, "SUCCESS", startedAt, finishedAt, null);
|
|
55
|
-
db.exec("COMMIT;");
|
|
56
|
-
versionCursor = step.toVersion;
|
|
57
|
-
}
|
|
58
|
-
catch (error) {
|
|
59
|
-
db.exec("ROLLBACK;");
|
|
60
|
-
const finishedAt = new Date().toISOString();
|
|
61
|
-
writeMigrationHistory(db, step.id, step.fromVersion, step.toVersion, step.checksum, "FAILED", startedAt, finishedAt, error instanceof Error ? error.message : String(error));
|
|
62
|
-
throw error;
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
if (versionCursor !== targetVersion) {
|
|
66
|
-
throw new Error(`Migration target not reached. expected=${targetVersion}, actual=${versionCursor}`);
|
|
67
|
-
}
|
|
68
|
-
}
|
|
@@ -1,55 +0,0 @@
|
|
|
1
|
-
function hasColumn(db, tableName, columnName) {
|
|
2
|
-
const result = db.exec(`PRAGMA table_info(${tableName});`);
|
|
3
|
-
if (result.length === 0) {
|
|
4
|
-
return false;
|
|
5
|
-
}
|
|
6
|
-
const rows = result[0].values;
|
|
7
|
-
return rows.some((row) => String(row[1]) === columnName);
|
|
8
|
-
}
|
|
9
|
-
function ensureRecordVersionColumns(db) {
|
|
10
|
-
const tables = ["tasks", "roadmaps", "meta", "view_state"];
|
|
11
|
-
for (const tableName of tables) {
|
|
12
|
-
if (!hasColumn(db, tableName, "record_version")) {
|
|
13
|
-
db.exec(`ALTER TABLE ${tableName} ADD COLUMN record_version INTEGER NOT NULL DEFAULT 1;`);
|
|
14
|
-
}
|
|
15
|
-
}
|
|
16
|
-
}
|
|
17
|
-
function ensurePerformanceIndexes(db) {
|
|
18
|
-
db.exec(`
|
|
19
|
-
CREATE INDEX IF NOT EXISTS idx_tasks_status_updated
|
|
20
|
-
ON tasks(status, updated_at DESC);
|
|
21
|
-
CREATE INDEX IF NOT EXISTS idx_tasks_updated
|
|
22
|
-
ON tasks(updated_at DESC);
|
|
23
|
-
CREATE INDEX IF NOT EXISTS idx_roadmaps_updated
|
|
24
|
-
ON roadmaps(updated_at DESC);
|
|
25
|
-
`);
|
|
26
|
-
}
|
|
27
|
-
export const MIGRATION_STEPS = [
|
|
28
|
-
{
|
|
29
|
-
id: "20260313_baseline_v1",
|
|
30
|
-
fromVersion: 0,
|
|
31
|
-
toVersion: 1,
|
|
32
|
-
checksum: "baseline-v1",
|
|
33
|
-
up: () => {
|
|
34
|
-
// Baseline marker for pre-versioned stores.
|
|
35
|
-
},
|
|
36
|
-
},
|
|
37
|
-
{
|
|
38
|
-
id: "20260313_add_record_version_v2",
|
|
39
|
-
fromVersion: 1,
|
|
40
|
-
toVersion: 2,
|
|
41
|
-
checksum: "add-record-version-v2",
|
|
42
|
-
up: (db) => {
|
|
43
|
-
ensureRecordVersionColumns(db);
|
|
44
|
-
},
|
|
45
|
-
},
|
|
46
|
-
{
|
|
47
|
-
id: "20260313_add_indexes_v3",
|
|
48
|
-
fromVersion: 2,
|
|
49
|
-
toVersion: 3,
|
|
50
|
-
checksum: "add-indexes-v3",
|
|
51
|
-
up: (db) => {
|
|
52
|
-
ensurePerformanceIndexes(db);
|
|
53
|
-
},
|
|
54
|
-
},
|
|
55
|
-
];
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|