@projitive/mcp 1.2.0 → 2.0.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.
@@ -1,16 +1,15 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { z } from "zod";
4
- import { candidateFilesFromArtifacts, discoverGovernanceArtifacts, findTextReferences } from "../common/index.js";
4
+ import { candidateFilesFromArtifacts, discoverGovernanceArtifacts, findTextReferences, ensureStore, loadActionableTasksFromStore, loadRoadmapsFromStore, loadTaskStatusStatsFromStore, loadTasksFromStore, replaceTasksInStore, upsertTaskInStore, getStoreVersion, getMarkdownViewState, markMarkdownViewBuilt, } from "../common/index.js";
5
5
  import { asText, evidenceSection, guidanceSection, lintSection, nextCallSection, renderErrorMarkdown, renderToolResponseMarkdown, summarySection, } from "../common/index.js";
6
- import { catchIt, TASK_LINT_CODES, renderLintSuggestions } from "../common/index.js";
6
+ import { TASK_LINT_CODES, renderLintSuggestions } from "../common/index.js";
7
7
  import { resolveGovernanceDir, resolveScanDepth, resolveScanRoots, discoverProjectsAcrossRoots, toProjectPath } from "./project.js";
8
8
  import { isValidRoadmapId } from "./roadmap.js";
9
9
  import { SUB_STATE_PHASES, BLOCKER_TYPES } from "../types.js";
10
- export const TASKS_START = "<!-- PROJITIVE:TASKS:START -->";
11
- export const TASKS_END = "<!-- PROJITIVE:TASKS:END -->";
12
10
  export const ALLOWED_STATUS = ["TODO", "IN_PROGRESS", "BLOCKED", "DONE"];
13
11
  export const TASK_ID_REGEX = /^TASK-\d{4}$/;
12
+ export const TASKS_MARKDOWN_FILE = "tasks.md";
14
13
  function appendLintSuggestions(target, suggestions) {
15
14
  target.push(...renderLintSuggestions(suggestions));
16
15
  }
@@ -38,62 +37,40 @@ function taskStatusGuidance(task) {
38
37
  "- Keep report evidence immutable unless correction is required.",
39
38
  ];
40
39
  }
41
- async function readOptionalMarkdown(filePath) {
42
- const content = await fs.readFile(filePath, "utf-8").catch(() => undefined);
43
- if (typeof content !== "string") {
44
- return undefined;
45
- }
46
- const trimmed = content.trim();
47
- return trimmed.length > 0 ? trimmed : undefined;
48
- }
49
- const NO_TASK_DISCOVERY_HOOK_FILE = "task_no_actionable.md";
50
40
  const DEFAULT_NO_TASK_DISCOVERY_GUIDANCE = [
51
- "- Check whether current code violates project guide/spec conventions; create TODO tasks for each actionable gap.",
52
- "- Check unit/integration test coverage and identify high-value missing tests; create TODO tasks for meaningful coverage improvements.",
53
- "- Check development/testing workflow for bottlenecks (slow feedback, fragile scripts, unclear runbooks); create tasks to improve reliability.",
54
- "- Scan for TODO/FIXME/HACK comments and convert feasible items into governed TODO tasks with evidence links.",
55
- "- Check dependency freshness and security advisories; create tasks for safe upgrades when needed.",
56
- "- Check repeated manual operations that can be automated (lint/test/release checks); create tasks to reduce operational toil.",
41
+ "- Recheck project state first: run projectContext and confirm there is truly no TODO/IN_PROGRESS task to execute.",
42
+ "- If all remaining tasks are BLOCKED, create one unblock task with explicit unblock condition and dependency owner.",
43
+ "- Start from active roadmap milestones and split into the smallest executable slices with a single done condition each.",
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/designs/readme artifact update.",
46
+ "- Skip duplicate scope: do not create tasks that overlap existing TODO/IN_PROGRESS/BLOCKED task intent.",
47
+ "- Use quality gates for discovery candidates: user value, delivery risk reduction, or measurable throughput improvement.",
48
+ "- Keep each discovery round small (1-3 tasks), then rerun taskNext immediately for re-ranking and execution.",
49
+ ];
50
+ const DEFAULT_TASK_CONTEXT_READING_GUIDANCE = [
51
+ "- Read governance workspace overview first (README.md / projitive://governance/workspace).",
52
+ "- Read roadmap and active milestones (roadmap.md / projitive://governance/roadmap).",
53
+ "- Read task view and related task cards (tasks.md / projitive://governance/tasks).",
54
+ "- Read design specs and technical decisions under designs/ (architecture, API contracts, constraints).",
55
+ "- Read reports/ for latest execution evidence, regressions, and unresolved risks.",
56
+ "- Read process guides under templates/docs/project guidelines to align with local governance rules.",
57
+ "- If available, read docs/ architecture or migration guides before major structural changes.",
57
58
  ];
58
- function parseHookChecklist(markdown) {
59
- return markdown
60
- .split(/\r?\n/)
61
- .map((line) => line.trim())
62
- .filter((line) => line.length > 0)
63
- .filter((line) => /^[-*+]\s+/.test(line))
64
- .map((line) => (line.startsWith("*") ? `-${line.slice(1)}` : line));
65
- }
66
59
  export async function resolveNoTaskDiscoveryGuidance(governanceDir) {
67
- if (!governanceDir) {
68
- return DEFAULT_NO_TASK_DISCOVERY_GUIDANCE;
69
- }
70
- const hookPath = path.join(governanceDir, "hooks", NO_TASK_DISCOVERY_HOOK_FILE);
71
- const markdown = await readOptionalMarkdown(hookPath);
72
- if (typeof markdown !== "string") {
73
- return DEFAULT_NO_TASK_DISCOVERY_GUIDANCE;
74
- }
75
- const checklist = parseHookChecklist(markdown);
76
- return checklist.length > 0 ? checklist : DEFAULT_NO_TASK_DISCOVERY_GUIDANCE;
60
+ void governanceDir;
61
+ return DEFAULT_NO_TASK_DISCOVERY_GUIDANCE;
77
62
  }
78
- function latestTaskUpdatedAt(tasks) {
79
- const timestamps = tasks
80
- .map((task) => new Date(task.updatedAt).getTime())
81
- .filter((value) => Number.isFinite(value));
82
- if (timestamps.length === 0) {
83
- return "(unknown)";
84
- }
85
- return new Date(Math.max(...timestamps)).toISOString();
86
- }
87
- function actionableScore(tasks) {
88
- return tasks.filter((task) => task.status === "IN_PROGRESS").length * 2
89
- + tasks.filter((task) => task.status === "TODO").length;
63
+ export async function resolveTaskContextReadingGuidance(governanceDir) {
64
+ void governanceDir;
65
+ return DEFAULT_TASK_CONTEXT_READING_GUIDANCE;
90
66
  }
91
67
  async function readRoadmapIds(governanceDir) {
92
- const roadmapPath = path.join(governanceDir, "roadmap.md");
68
+ const dbPath = path.join(governanceDir, ".projitive");
93
69
  try {
94
- const markdown = await fs.readFile(roadmapPath, "utf-8");
95
- const matches = markdown.match(/ROADMAP-\d{4}/g) ?? [];
96
- return Array.from(new Set(matches));
70
+ await ensureStore(dbPath);
71
+ const milestones = await loadRoadmapsFromStore(dbPath);
72
+ const ids = milestones.map((item) => item.id).filter((item) => isValidRoadmapId(item));
73
+ return Array.from(new Set(ids));
97
74
  }
98
75
  catch {
99
76
  return [];
@@ -108,27 +85,52 @@ export function renderTaskSeedTemplate(roadmapRef) {
108
85
  "- updatedAt: 2026-01-01T00:00:00.000Z",
109
86
  `- roadmapRefs: ${roadmapRef}`,
110
87
  "- links:",
111
- " - ./README.md",
112
- " - ./roadmap.md",
88
+ " - README.md",
89
+ " - .projitive/roadmap.md",
113
90
  "```",
114
91
  ];
115
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
+ }
116
115
  async function readActionableTaskCandidates(governanceDirs) {
117
116
  const snapshots = await Promise.all(governanceDirs.map(async (governanceDir) => {
118
- const snapshot = await loadTasks(governanceDir);
117
+ const tasksPath = path.join(governanceDir, ".projitive");
118
+ await ensureStore(tasksPath);
119
+ const [stats, actionableTasks] = await Promise.all([
120
+ loadTaskStatusStatsFromStore(tasksPath),
121
+ loadActionableTasksFromStore(tasksPath),
122
+ ]);
119
123
  return {
120
124
  governanceDir,
121
- tasksPath: snapshot.tasksPath,
122
- tasks: snapshot.tasks,
123
- projectScore: actionableScore(snapshot.tasks),
124
- projectLatestUpdatedAt: latestTaskUpdatedAt(snapshot.tasks),
125
+ tasks: actionableTasks,
126
+ projectScore: stats.inProgress * 2 + stats.todo,
127
+ projectLatestUpdatedAt: stats.latestUpdatedAt || "(unknown)",
125
128
  };
126
129
  }));
127
130
  return snapshots.flatMap((item) => item.tasks
128
131
  .filter((task) => task.status === "IN_PROGRESS" || task.status === "TODO")
129
132
  .map((task) => ({
130
133
  governanceDir: item.governanceDir,
131
- tasksPath: item.tasksPath,
132
134
  task,
133
135
  projectScore: item.projectScore,
134
136
  projectLatestUpdatedAt: item.projectLatestUpdatedAt,
@@ -155,6 +157,49 @@ export function toTaskUpdatedAtMs(updatedAt) {
155
157
  const timestamp = new Date(updatedAt).getTime();
156
158
  return Number.isFinite(timestamp) ? timestamp : 0;
157
159
  }
160
+ function toTaskIdNumericSuffix(taskId) {
161
+ const match = taskId.match(/^(?:TASK-)(\d{4})$/);
162
+ if (!match) {
163
+ return -1;
164
+ }
165
+ return Number.parseInt(match[1], 10);
166
+ }
167
+ export function sortTasksNewestFirst(tasks) {
168
+ return [...tasks].sort((a, b) => {
169
+ const updatedAtDelta = toTaskUpdatedAtMs(b.updatedAt) - toTaskUpdatedAtMs(a.updatedAt);
170
+ if (updatedAtDelta !== 0) {
171
+ return updatedAtDelta;
172
+ }
173
+ const idDelta = toTaskIdNumericSuffix(b.id) - toTaskIdNumericSuffix(a.id);
174
+ if (idDelta !== 0) {
175
+ return idDelta;
176
+ }
177
+ return b.id.localeCompare(a.id);
178
+ });
179
+ }
180
+ function normalizeAndSortTasks(tasks) {
181
+ return sortTasksNewestFirst(tasks.map((task) => normalizeTask(task)));
182
+ }
183
+ function resolveTaskArtifactPaths(governanceDir) {
184
+ return {
185
+ tasksPath: path.join(governanceDir, ".projitive"),
186
+ markdownPath: path.join(governanceDir, TASKS_MARKDOWN_FILE),
187
+ };
188
+ }
189
+ async function syncTasksMarkdownView(tasksPath, markdownPath, markdown, force = false) {
190
+ const sourceVersion = await getStoreVersion(tasksPath, "tasks");
191
+ const viewState = await getMarkdownViewState(tasksPath, "tasks_markdown");
192
+ const markdownExists = await fs.access(markdownPath).then(() => true).catch(() => false);
193
+ const shouldWrite = force
194
+ || !markdownExists
195
+ || viewState.dirty
196
+ || viewState.lastSourceVersion !== sourceVersion;
197
+ if (!shouldWrite) {
198
+ return;
199
+ }
200
+ await fs.writeFile(markdownPath, markdown, "utf-8");
201
+ await markMarkdownViewBuilt(tasksPath, "tasks_markdown", sourceVersion);
202
+ }
158
203
  export function rankActionableTaskCandidates(candidates) {
159
204
  return [...candidates].sort((a, b) => {
160
205
  if (b.projectScore !== a.projectScore) {
@@ -172,114 +217,6 @@ export function rankActionableTaskCandidates(candidates) {
172
217
  return a.task.id.localeCompare(b.task.id);
173
218
  });
174
219
  }
175
- // Helper function to check if a line is a top-level task field
176
- function isTopLevelField(line) {
177
- const topLevelFields = [
178
- "- owner:",
179
- "- summary:",
180
- "- updatedAt:",
181
- "- roadmapRefs:",
182
- "- links:",
183
- "- hooks:",
184
- "- subState:",
185
- "- blocker:",
186
- ];
187
- return topLevelFields.some((field) => line.startsWith(field));
188
- }
189
- // Parse subState nested field
190
- function parseSubState(lines, startIndex) {
191
- const subState = {};
192
- let index = startIndex + 1;
193
- while (index < lines.length) {
194
- const line = lines[index];
195
- const trimmed = line.trim();
196
- // Check if we've reached the end of subState (new top-level field or new task)
197
- if (trimmed.startsWith("- ") && isTopLevelField(trimmed)) {
198
- break;
199
- }
200
- // Check if we've reached a new task
201
- if (trimmed.startsWith("## TASK-")) {
202
- break;
203
- }
204
- // Parse nested fields (2-space indentation expected)
205
- if (trimmed.startsWith("- phase:")) {
206
- const phase = trimmed.replace("- phase:", "").trim();
207
- if (SUB_STATE_PHASES.includes(phase)) {
208
- subState.phase = phase;
209
- }
210
- }
211
- else if (trimmed.startsWith("- confidence:")) {
212
- const confidenceStr = trimmed.replace("- confidence:", "").trim();
213
- const confidence = Number.parseFloat(confidenceStr);
214
- if (!Number.isNaN(confidence) && confidence >= 0 && confidence <= 1) {
215
- subState.confidence = confidence;
216
- }
217
- }
218
- else if (trimmed.startsWith("- estimatedCompletion:")) {
219
- const estimatedCompletion = trimmed.replace("- estimatedCompletion:", "").trim();
220
- if (estimatedCompletion && estimatedCompletion !== "(none)") {
221
- subState.estimatedCompletion = estimatedCompletion;
222
- }
223
- }
224
- index++;
225
- }
226
- return { subState, endIndex: index - 1 };
227
- }
228
- // Parse blocker nested field
229
- function parseBlocker(lines, startIndex) {
230
- const blocker = {};
231
- let index = startIndex + 1;
232
- while (index < lines.length) {
233
- const line = lines[index];
234
- const trimmed = line.trim();
235
- // Check if we've reached the end of blocker (new top-level field or new task)
236
- if (trimmed.startsWith("- ") && isTopLevelField(trimmed)) {
237
- break;
238
- }
239
- // Check if we've reached a new task
240
- if (trimmed.startsWith("## TASK-")) {
241
- break;
242
- }
243
- // Parse nested fields (2-space indentation expected)
244
- if (trimmed.startsWith("- type:")) {
245
- const type = trimmed.replace("- type:", "").trim();
246
- if (BLOCKER_TYPES.includes(type)) {
247
- blocker.type = type;
248
- }
249
- }
250
- else if (trimmed.startsWith("- description:")) {
251
- const description = trimmed.replace("- description:", "").trim();
252
- if (description && description !== "(none)") {
253
- blocker.description = description;
254
- }
255
- }
256
- else if (trimmed.startsWith("- blockingEntity:")) {
257
- const blockingEntity = trimmed.replace("- blockingEntity:", "").trim();
258
- if (blockingEntity && blockingEntity !== "(none)") {
259
- blocker.blockingEntity = blockingEntity;
260
- }
261
- }
262
- else if (trimmed.startsWith("- unblockCondition:")) {
263
- const unblockCondition = trimmed.replace("- unblockCondition:", "").trim();
264
- if (unblockCondition && unblockCondition !== "(none)") {
265
- blocker.unblockCondition = unblockCondition;
266
- }
267
- }
268
- else if (trimmed.startsWith("- escalationPath:")) {
269
- const escalationPath = trimmed.replace("- escalationPath:", "").trim();
270
- if (escalationPath && escalationPath !== "(none)") {
271
- blocker.escalationPath = escalationPath;
272
- }
273
- }
274
- index++;
275
- }
276
- // Validate required fields
277
- if (!blocker.type || !blocker.description) {
278
- // Return empty blocker if required fields are missing
279
- return { blocker: { type: "external_dependency", description: "Unknown blocker" }, endIndex: index - 1 };
280
- }
281
- return { blocker: blocker, endIndex: index - 1 };
282
- }
283
220
  export function normalizeTask(task) {
284
221
  const normalizedRoadmapRefs = Array.isArray(task.roadmapRefs)
285
222
  ? task.roadmapRefs.map(String).filter((value) => isValidRoadmapId(value))
@@ -291,7 +228,12 @@ export function normalizeTask(task) {
291
228
  owner: task.owner ? String(task.owner) : "",
292
229
  summary: task.summary ? String(task.summary) : "",
293
230
  updatedAt: task.updatedAt ? String(task.updatedAt) : nowIso(),
294
- links: Array.isArray(task.links) ? task.links.map(String) : [],
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
+ : [],
295
237
  roadmapRefs: Array.from(new Set(normalizedRoadmapRefs)),
296
238
  };
297
239
  // Include optional v1.1.0 fields if present
@@ -303,146 +245,8 @@ export function normalizeTask(task) {
303
245
  }
304
246
  return normalized;
305
247
  }
306
- export async function parseTasksBlock(markdown) {
307
- const start = markdown.indexOf(TASKS_START);
308
- const end = markdown.indexOf(TASKS_END);
309
- if (start === -1 || end === -1 || end <= start) {
310
- return [];
311
- }
312
- const body = markdown.slice(start + TASKS_START.length, end).trim();
313
- if (!body || body === "(no tasks)") {
314
- return [];
315
- }
316
- const sections = body
317
- .split(/\n(?=##\s+TASK-\d{4}\s+\|\s+(?:TODO|IN_PROGRESS|BLOCKED|DONE)\s+\|)/g)
318
- .map((section) => section.trim())
319
- .filter((section) => section.startsWith("## TASK-"));
320
- const tasks = [];
321
- for (const section of sections) {
322
- const lines = section.split(/\r?\n/);
323
- const header = lines[0]?.match(/^##\s+(TASK-\d{4})\s+\|\s+(TODO|IN_PROGRESS|BLOCKED|DONE)\s+\|\s+(.+)$/);
324
- if (!header) {
325
- continue;
326
- }
327
- const [, id, statusRaw, title] = header;
328
- const status = statusRaw;
329
- const taskDraft = {
330
- id,
331
- title: title.trim(),
332
- status,
333
- owner: "",
334
- summary: "",
335
- updatedAt: nowIso(),
336
- links: [],
337
- roadmapRefs: [],
338
- };
339
- let inLinks = false;
340
- let inHooks = false;
341
- // Convert to indexed for loop to allow skipping lines when parsing subState/blocker
342
- const sectionLines = lines.slice(1);
343
- for (let lineIndex = 0; lineIndex < sectionLines.length; lineIndex++) {
344
- const line = sectionLines[lineIndex];
345
- const trimmed = line.trim();
346
- if (!trimmed) {
347
- continue;
348
- }
349
- if (trimmed.startsWith("- owner:")) {
350
- taskDraft.owner = trimmed.replace("- owner:", "").trim();
351
- inLinks = false;
352
- inHooks = false;
353
- continue;
354
- }
355
- if (trimmed.startsWith("- summary:")) {
356
- taskDraft.summary = trimmed.replace("- summary:", "").trim();
357
- inLinks = false;
358
- inHooks = false;
359
- continue;
360
- }
361
- if (trimmed.startsWith("- updatedAt:")) {
362
- taskDraft.updatedAt = trimmed.replace("- updatedAt:", "").trim();
363
- inLinks = false;
364
- inHooks = false;
365
- continue;
366
- }
367
- if (trimmed.startsWith("- roadmapRefs:")) {
368
- const payload = trimmed.replace("- roadmapRefs:", "").trim();
369
- const refs = payload === "(none)"
370
- ? []
371
- : payload
372
- .split(",")
373
- .map((value) => value.trim())
374
- .filter((value) => value.length > 0);
375
- taskDraft.roadmapRefs = refs;
376
- inLinks = false;
377
- inHooks = false;
378
- continue;
379
- }
380
- if (trimmed === "- links:") {
381
- inLinks = true;
382
- inHooks = false;
383
- continue;
384
- }
385
- if (trimmed === "- hooks:") {
386
- inLinks = false;
387
- inHooks = true;
388
- continue;
389
- }
390
- // Handle subState nested field (Spec v1.1.0)
391
- if (trimmed.startsWith("- subState:")) {
392
- const { subState, endIndex } = parseSubState(sectionLines, lineIndex);
393
- if (Object.keys(subState).length > 0) {
394
- taskDraft.subState = subState;
395
- }
396
- // Skip to the end of subState parsing
397
- lineIndex = endIndex;
398
- inLinks = false;
399
- inHooks = false;
400
- continue;
401
- }
402
- // Handle blocker nested field (Spec v1.1.0)
403
- if (trimmed.startsWith("- blocker:")) {
404
- const { blocker, endIndex } = parseBlocker(sectionLines, lineIndex);
405
- if (blocker.type && blocker.description) {
406
- taskDraft.blocker = blocker;
407
- }
408
- // Skip to the end of blocker parsing
409
- lineIndex = endIndex;
410
- inLinks = false;
411
- inHooks = false;
412
- continue;
413
- }
414
- const nestedItem = trimmed.match(/^-\s+(.+)$/);
415
- if (!nestedItem) {
416
- continue;
417
- }
418
- const nestedValue = nestedItem[1].trim();
419
- if (nestedValue === "(none)") {
420
- continue;
421
- }
422
- if (inLinks) {
423
- taskDraft.links = [...(taskDraft.links ?? []), nestedValue];
424
- continue;
425
- }
426
- if (inHooks) {
427
- continue;
428
- }
429
- }
430
- tasks.push(normalizeTask(taskDraft));
431
- }
432
- return tasks;
433
- }
434
- export function findTaskIdsOutsideMarkers(markdown) {
435
- const start = markdown.indexOf(TASKS_START);
436
- const end = markdown.indexOf(TASKS_END);
437
- const outsideText = (start !== -1 && end !== -1 && end > start)
438
- ? `${markdown.slice(0, start)}\n${markdown.slice(end + TASKS_END.length)}`
439
- : markdown;
440
- const ids = outsideText.match(/TASK-\d{4}/g) ?? [];
441
- return Array.from(new Set(ids));
442
- }
443
- function collectTaskLintSuggestionItems(tasks, options = {}) {
248
+ function collectTaskLintSuggestionItems(tasks) {
444
249
  const suggestions = [];
445
- const { markdown, outsideMarkerScopeIds } = options;
446
250
  const duplicateIds = Array.from(tasks.reduce((counter, task) => {
447
251
  counter.set(task.id, (counter.get(task.id) ?? 0) + 1);
448
252
  return counter;
@@ -497,16 +301,16 @@ function collectTaskLintSuggestionItems(tasks, options = {}) {
497
301
  fixHint: "Bind at least one ROADMAP-xxxx when applicable.",
498
302
  });
499
303
  }
500
- if (typeof markdown === "string") {
501
- const outsideMarkerTaskIds = findTaskIdsOutsideMarkers(markdown)
502
- .filter((taskId) => (outsideMarkerScopeIds ? outsideMarkerScopeIds.has(taskId) : true));
503
- if (outsideMarkerTaskIds.length > 0) {
504
- suggestions.push({
505
- code: TASK_LINT_CODES.OUTSIDE_MARKER,
506
- message: `TASK IDs found outside marker block: ${outsideMarkerTaskIds.join(", ")}.`,
507
- fixHint: "Keep task source of truth inside marker region only.",
508
- });
509
- }
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
+ });
510
314
  }
511
315
  // ============================================================================
512
316
  // Spec v1.1.0 - Blocker Categorization Validation
@@ -564,8 +368,8 @@ function collectTaskLintSuggestionItems(tasks, options = {}) {
564
368
  }
565
369
  return suggestions;
566
370
  }
567
- export function collectTaskLintSuggestions(tasks, markdown, outsideMarkerScopeIds) {
568
- return renderLintSuggestions(collectTaskLintSuggestionItems(tasks, { markdown, outsideMarkerScopeIds }));
371
+ export function collectTaskLintSuggestions(tasks) {
372
+ return renderLintSuggestions(collectTaskLintSuggestionItems(tasks));
569
373
  }
570
374
  function collectSingleTaskLintSuggestions(task) {
571
375
  const suggestions = [];
@@ -583,6 +387,17 @@ function collectSingleTaskLintSuggestions(task) {
583
387
  fixHint: "Add at least one evidence link.",
584
388
  });
585
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
+ }
586
401
  if (task.status === "BLOCKED" && task.summary.trim().length === 0) {
587
402
  suggestions.push({
588
403
  code: TASK_LINT_CODES.BLOCKED_SUMMARY_EMPTY,
@@ -656,6 +471,7 @@ function collectSingleTaskLintSuggestions(task) {
656
471
  }
657
472
  async function collectTaskFileLintSuggestions(governanceDir, task) {
658
473
  const suggestions = [];
474
+ const projectPath = toProjectPath(governanceDir);
659
475
  for (const link of task.links) {
660
476
  const normalized = link.trim();
661
477
  if (normalized.length === 0) {
@@ -664,7 +480,15 @@ async function collectTaskFileLintSuggestions(governanceDir, task) {
664
480
  if (/^https?:\/\//i.test(normalized)) {
665
481
  continue;
666
482
  }
667
- const resolvedPath = path.resolve(governanceDir, normalized);
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);
668
492
  const exists = await fs.access(resolvedPath).then(() => true).catch(() => false);
669
493
  if (!exists) {
670
494
  suggestions.push({
@@ -676,7 +500,7 @@ async function collectTaskFileLintSuggestions(governanceDir, task) {
676
500
  return renderLintSuggestions(suggestions);
677
501
  }
678
502
  export function renderTasksMarkdown(tasks) {
679
- const sections = tasks.map((task) => {
503
+ const sections = sortTasksNewestFirst(tasks).map((task) => {
680
504
  const roadmapRefs = task.roadmapRefs.length > 0 ? task.roadmapRefs.join(", ") : "(none)";
681
505
  const links = task.links.length > 0
682
506
  ? ["- links:", ...task.links.map((link) => ` - ${link}`)]
@@ -722,22 +546,19 @@ export function renderTasksMarkdown(tasks) {
722
546
  return [
723
547
  "# Tasks",
724
548
  "",
725
- "This file is maintained by Projitive MCP. If editing manually, please keep the Markdown structure valid.",
549
+ "This file is generated from .projitive sqlite tables by Projitive MCP. Manual edits will be overwritten.",
726
550
  "",
727
- TASKS_START,
728
551
  ...(sections.length > 0 ? sections : ["(no tasks)"]),
729
- TASKS_END,
730
552
  "",
731
553
  ].join("\n");
732
554
  }
733
555
  export async function ensureTasksFile(inputPath) {
734
556
  const governanceDir = await resolveGovernanceDir(inputPath);
735
- const tasksPath = path.join(governanceDir, "tasks.md");
557
+ const { tasksPath, markdownPath } = resolveTaskArtifactPaths(governanceDir);
736
558
  await fs.mkdir(governanceDir, { recursive: true });
737
- const accessResult = await catchIt(fs.access(tasksPath));
738
- if (accessResult.isError()) {
739
- await fs.writeFile(tasksPath, renderTasksMarkdown([]), "utf-8");
740
- }
559
+ await ensureStore(tasksPath);
560
+ const tasks = normalizeAndSortTasks(await loadTasksFromStore(tasksPath));
561
+ await syncTasksMarkdownView(tasksPath, markdownPath, renderTasksMarkdown(tasks));
741
562
  return tasksPath;
742
563
  }
743
564
  export async function loadTasks(inputPath) {
@@ -745,13 +566,21 @@ export async function loadTasks(inputPath) {
745
566
  return { tasksPath, tasks };
746
567
  }
747
568
  export async function loadTasksDocument(inputPath) {
569
+ return loadTasksDocumentWithOptions(inputPath, false);
570
+ }
571
+ export async function loadTasksDocumentWithOptions(inputPath, forceViewSync) {
748
572
  const tasksPath = await ensureTasksFile(inputPath);
749
- const markdown = await fs.readFile(tasksPath, "utf-8");
750
- return { tasksPath, markdown, tasks: await parseTasksBlock(markdown) };
573
+ const tasks = normalizeAndSortTasks(await loadTasksFromStore(tasksPath));
574
+ const markdown = renderTasksMarkdown(tasks);
575
+ const markdownPath = path.join(path.dirname(tasksPath), TASKS_MARKDOWN_FILE);
576
+ await syncTasksMarkdownView(tasksPath, markdownPath, markdown, forceViewSync);
577
+ return { tasksPath, markdownPath, markdown, tasks };
751
578
  }
752
579
  export async function saveTasks(tasksPath, tasks) {
753
- const normalized = tasks.map((task) => normalizeTask(task));
754
- await fs.writeFile(tasksPath, renderTasksMarkdown(normalized), "utf-8");
580
+ const normalized = normalizeAndSortTasks(tasks);
581
+ const markdownPath = path.join(path.dirname(tasksPath), TASKS_MARKDOWN_FILE);
582
+ await replaceTasksInStore(tasksPath, normalized);
583
+ await syncTasksMarkdownView(tasksPath, markdownPath, renderTasksMarkdown(normalized));
755
584
  }
756
585
  export function validateTransition(from, to) {
757
586
  if (from === to) {
@@ -776,11 +605,11 @@ export function registerTaskTools(server) {
776
605
  },
777
606
  }, async ({ projectPath, status, limit }) => {
778
607
  const governanceDir = await resolveGovernanceDir(projectPath);
779
- const { tasksPath, tasks, markdown: tasksMarkdown } = await loadTasksDocument(governanceDir);
608
+ const { tasks } = await loadTasksDocument(governanceDir);
780
609
  const filtered = tasks
781
610
  .filter((task) => (status ? task.status === status : true))
782
611
  .slice(0, limit ?? 100);
783
- const lintSuggestions = collectTaskLintSuggestions(filtered, tasksMarkdown, new Set(filtered.map((task) => task.id)));
612
+ const lintSuggestions = collectTaskLintSuggestions(filtered);
784
613
  if (status && filtered.length === 0) {
785
614
  appendLintSuggestions(lintSuggestions, [
786
615
  {
@@ -796,7 +625,6 @@ export function registerTaskTools(server) {
796
625
  sections: [
797
626
  summarySection([
798
627
  `- governanceDir: ${governanceDir}`,
799
- `- tasksPath: ${tasksPath}`,
800
628
  `- filter.status: ${status ?? "(none)"}`,
801
629
  `- returned: ${filtered.length}`,
802
630
  ]),
@@ -826,21 +654,18 @@ export function registerTaskTools(server) {
826
654
  const rankedCandidates = rankActionableTaskCandidates(await readActionableTaskCandidates(projects));
827
655
  if (rankedCandidates.length === 0) {
828
656
  const projectSnapshots = await Promise.all(projects.map(async (governanceDir) => {
829
- const { tasksPath, tasks } = await loadTasks(governanceDir);
657
+ const tasksPath = path.join(governanceDir, ".projitive");
658
+ await ensureStore(tasksPath);
659
+ const stats = await loadTaskStatusStatsFromStore(tasksPath);
830
660
  const roadmapIds = await readRoadmapIds(governanceDir);
831
- const todo = tasks.filter((task) => task.status === "TODO").length;
832
- const inProgress = tasks.filter((task) => task.status === "IN_PROGRESS").length;
833
- const blocked = tasks.filter((task) => task.status === "BLOCKED").length;
834
- const done = tasks.filter((task) => task.status === "DONE").length;
835
661
  return {
836
662
  governanceDir,
837
- tasksPath,
838
663
  roadmapIds,
839
- total: tasks.length,
840
- todo,
841
- inProgress,
842
- blocked,
843
- done,
664
+ total: stats.total,
665
+ todo: stats.todo,
666
+ inProgress: stats.inProgress,
667
+ blocked: stats.blocked,
668
+ done: stats.done,
844
669
  };
845
670
  }));
846
671
  const preferredProject = projectSnapshots[0];
@@ -859,7 +684,7 @@ export function registerTaskTools(server) {
859
684
  evidenceSection([
860
685
  "### Project Snapshots",
861
686
  ...(projectSnapshots.length > 0
862
- ? 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}`)
687
+ ? 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)"}`)
863
688
  : ["- (none)"]),
864
689
  "",
865
690
  "### Seed Task Template",
@@ -868,16 +693,17 @@ export function registerTaskTools(server) {
868
693
  guidanceSection([
869
694
  "- No TODO/IN_PROGRESS task is available.",
870
695
  "- Use no-task discovery checklist below to proactively find and create meaningful TODO tasks.",
696
+ "- If roadmap has active milestones, analyze milestone intent and split into 1-3 executable TODO tasks.",
871
697
  "",
872
698
  "### No-Task Discovery Checklist",
873
699
  ...noTaskDiscoveryGuidance,
874
700
  "",
875
701
  "- If no tasks exist, derive 1-3 TODO tasks from roadmap milestones, README scope, or unresolved report gaps.",
876
702
  "- If only BLOCKED/DONE tasks exist, reopen one blocked item or create a follow-up TODO task.",
877
- "- After adding tasks inside marker block, rerun `taskNext` to re-rank actionable work.",
703
+ "- After creating tasks, rerun `taskNext` to re-rank actionable work.",
878
704
  ]),
879
705
  lintSection([
880
- "- No actionable tasks found. Verify task statuses and required fields in marker block.",
706
+ "- No actionable tasks found. Verify task statuses and required fields in .projitive task table.",
881
707
  "- Ensure each new task has stable TASK-xxxx ID and at least one roadmapRefs item.",
882
708
  ]),
883
709
  nextCallSection(preferredProject
@@ -889,13 +715,13 @@ export function registerTaskTools(server) {
889
715
  }
890
716
  const selected = rankedCandidates[0];
891
717
  const selectedTaskDocument = await loadTasksDocument(selected.governanceDir);
892
- const lintSuggestions = collectTaskLintSuggestions(selectedTaskDocument.tasks, selectedTaskDocument.markdown);
718
+ const lintSuggestions = collectTaskLintSuggestions(selectedTaskDocument.tasks);
893
719
  const artifacts = await discoverGovernanceArtifacts(selected.governanceDir);
894
720
  const fileCandidates = candidateFilesFromArtifacts(artifacts);
895
721
  const referenceLocations = (await Promise.all(fileCandidates.map((file) => findTextReferences(file, selected.task.id)))).flat();
896
- const taskLocation = (await findTextReferences(selected.tasksPath, selected.task.id))[0];
722
+ const taskLocation = (await findTextReferences(selectedTaskDocument.markdownPath, selected.task.id))[0];
897
723
  const relatedArtifacts = Array.from(new Set(referenceLocations.map((item) => item.filePath)));
898
- const suggestedReadOrder = [selected.tasksPath, ...relatedArtifacts.filter((item) => item !== selected.tasksPath)];
724
+ const suggestedReadOrder = [selectedTaskDocument.markdownPath, ...relatedArtifacts.filter((item) => item !== selectedTaskDocument.markdownPath)];
899
725
  const candidateLimit = limit ?? 5;
900
726
  const markdown = renderToolResponseMarkdown({
901
727
  toolName: "taskNext",
@@ -917,7 +743,7 @@ export function registerTaskTools(server) {
917
743
  `- owner: ${selected.task.owner || "(none)"}`,
918
744
  `- updatedAt: ${selected.task.updatedAt}`,
919
745
  `- roadmapRefs: ${selected.task.roadmapRefs.join(", ") || "(none)"}`,
920
- `- taskLocation: ${taskLocation ? `${taskLocation.filePath}#L${taskLocation.line}` : selected.tasksPath}`,
746
+ `- taskLocation: ${taskLocation ? `${taskLocation.filePath}#L${taskLocation.line}` : selectedTaskDocument.markdownPath}`,
921
747
  "",
922
748
  "### Top Candidates",
923
749
  ...rankedCandidates
@@ -965,7 +791,7 @@ export function registerTaskTools(server) {
965
791
  };
966
792
  }
967
793
  const governanceDir = await resolveGovernanceDir(projectPath);
968
- const { tasksPath, tasks, markdown: tasksMarkdown } = await loadTasksDocument(governanceDir);
794
+ const { markdownPath, tasks, markdown: tasksMarkdown } = await loadTasksDocument(governanceDir);
969
795
  const task = tasks.find((item) => item.id === taskId);
970
796
  if (!task) {
971
797
  return {
@@ -977,22 +803,13 @@ export function registerTaskTools(server) {
977
803
  ...collectSingleTaskLintSuggestions(task),
978
804
  ...(await collectTaskFileLintSuggestions(governanceDir, task)),
979
805
  ];
980
- const outsideMarkerTaskIds = findTaskIdsOutsideMarkers(tasksMarkdown);
981
- if (outsideMarkerTaskIds.includes(task.id)) {
982
- appendLintSuggestions(lintSuggestions, [
983
- {
984
- code: TASK_LINT_CODES.OUTSIDE_MARKER,
985
- message: `Current task ID appears outside marker block (${task.id}).`,
986
- fixHint: "Keep task source of truth inside marker region.",
987
- },
988
- ]);
989
- }
990
- const taskLocation = (await findTextReferences(tasksPath, taskId))[0];
806
+ const contextReadingGuidance = await resolveTaskContextReadingGuidance(governanceDir);
807
+ const taskLocation = (await findTextReferences(markdownPath, taskId))[0];
991
808
  const artifacts = await discoverGovernanceArtifacts(governanceDir);
992
809
  const fileCandidates = candidateFilesFromArtifacts(artifacts);
993
810
  const referenceLocations = (await Promise.all(fileCandidates.map((file) => findTextReferences(file, taskId)))).flat();
994
811
  const relatedArtifacts = Array.from(new Set(referenceLocations.map((item) => item.filePath)));
995
- const suggestedReadOrder = [tasksPath, ...relatedArtifacts.filter((item) => item !== tasksPath)];
812
+ const suggestedReadOrder = [markdownPath, ...relatedArtifacts.filter((item) => item !== markdownPath)];
996
813
  // Build summary with subState and blocker info (v1.1.0)
997
814
  const summaryLines = [
998
815
  `- governanceDir: ${governanceDir}`,
@@ -1002,7 +819,7 @@ export function registerTaskTools(server) {
1002
819
  `- owner: ${task.owner}`,
1003
820
  `- updatedAt: ${task.updatedAt}`,
1004
821
  `- roadmapRefs: ${task.roadmapRefs.join(", ") || "(none)"}`,
1005
- `- taskLocation: ${taskLocation ? `${taskLocation.filePath}#L${taskLocation.line}` : tasksPath}`,
822
+ `- taskLocation: ${taskLocation ? `${taskLocation.filePath}#L${taskLocation.line}` : markdownPath}`,
1006
823
  ];
1007
824
  // Add subState info for IN_PROGRESS tasks (v1.1.0)
1008
825
  if (task.subState && task.status === "IN_PROGRESS") {
@@ -1050,9 +867,13 @@ export function registerTaskTools(server) {
1050
867
  ]),
1051
868
  guidanceSection([
1052
869
  "- Read the files in Suggested Read Order.",
870
+ "",
871
+ "### Recommended Context Reading",
872
+ ...contextReadingGuidance,
873
+ "",
1053
874
  "- Verify whether current status and evidence are consistent.",
1054
875
  ...taskStatusGuidance(task),
1055
- "- If updates are needed, edit tasks/designs/reports markdown directly and keep TASK IDs unchanged.",
876
+ "- If updates are needed, use tool writes for sqlite source (`taskUpdate` / `roadmapUpdate`) and keep TASK IDs unchanged.",
1056
877
  "- After editing, re-run `taskContext` to verify references and context consistency.",
1057
878
  ]),
1058
879
  lintSection(lintSuggestions),
@@ -1096,7 +917,7 @@ export function registerTaskTools(server) {
1096
917
  };
1097
918
  }
1098
919
  const governanceDir = await resolveGovernanceDir(projectPath);
1099
- const { tasksPath, tasks, markdown: tasksMarkdown } = await loadTasksDocument(governanceDir);
920
+ const { tasksPath, tasks } = await loadTasksDocument(governanceDir);
1100
921
  const taskIndex = tasks.findIndex((item) => item.id === taskId);
1101
922
  if (taskIndex === -1) {
1102
923
  return {
@@ -1147,8 +968,17 @@ export function registerTaskTools(server) {
1147
968
  }
1148
969
  // Update updatedAt
1149
970
  task.updatedAt = nowIso();
1150
- // Save tasks
1151
- await saveTasks(tasksPath, tasks);
971
+ const normalizedTask = normalizeTask(task);
972
+ // Save task incrementally
973
+ await upsertTaskInStore(tasksPath, normalizedTask);
974
+ task.status = normalizedTask.status;
975
+ task.owner = normalizedTask.owner;
976
+ task.summary = normalizedTask.summary;
977
+ task.roadmapRefs = normalizedTask.roadmapRefs;
978
+ task.links = normalizedTask.links;
979
+ task.updatedAt = normalizedTask.updatedAt;
980
+ task.subState = normalizedTask.subState;
981
+ task.blocker = normalizedTask.blocker;
1152
982
  // Build response
1153
983
  const updateSummary = [
1154
984
  `- taskId: ${taskId}`,
@@ -1199,6 +1029,8 @@ export function registerTaskTools(server) {
1199
1029
  "Task updated successfully. Run `taskContext` to verify the changes.",
1200
1030
  "If status changed to DONE, ensure evidence links are added.",
1201
1031
  "If subState or blocker were updated, verify the metadata is correct.",
1032
+ "SQLite is source of truth; tasks.md is a generated view and may be overwritten.",
1033
+ "Call `syncViews(projectPath=..., views=[\"tasks\"], force=true)` when immediate markdown materialization is required.",
1202
1034
  ]),
1203
1035
  lintSection([]),
1204
1036
  nextCallSection(`taskContext(projectPath=\"${toProjectPath(governanceDir)}\", taskId=\"${taskId}\")`),