@projitive/mcp 1.1.2 → 2.0.0

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";
7
- import { resolveGovernanceDir, resolveScanDepth, resolveScanRoot, discoverProjects, toProjectPath } from "./project.js";
6
+ import { TASK_LINT_CODES, renderLintSuggestions } from "../common/index.js";
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/design/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 [];
@@ -115,20 +92,23 @@ export function renderTaskSeedTemplate(roadmapRef) {
115
92
  }
116
93
  async function readActionableTaskCandidates(governanceDirs) {
117
94
  const snapshots = await Promise.all(governanceDirs.map(async (governanceDir) => {
118
- const snapshot = await loadTasks(governanceDir);
95
+ const tasksPath = path.join(governanceDir, ".projitive");
96
+ await ensureStore(tasksPath);
97
+ const [stats, actionableTasks] = await Promise.all([
98
+ loadTaskStatusStatsFromStore(tasksPath),
99
+ loadActionableTasksFromStore(tasksPath),
100
+ ]);
119
101
  return {
120
102
  governanceDir,
121
- tasksPath: snapshot.tasksPath,
122
- tasks: snapshot.tasks,
123
- projectScore: actionableScore(snapshot.tasks),
124
- projectLatestUpdatedAt: latestTaskUpdatedAt(snapshot.tasks),
103
+ tasks: actionableTasks,
104
+ projectScore: stats.inProgress * 2 + stats.todo,
105
+ projectLatestUpdatedAt: stats.latestUpdatedAt || "(unknown)",
125
106
  };
126
107
  }));
127
108
  return snapshots.flatMap((item) => item.tasks
128
109
  .filter((task) => task.status === "IN_PROGRESS" || task.status === "TODO")
129
110
  .map((task) => ({
130
111
  governanceDir: item.governanceDir,
131
- tasksPath: item.tasksPath,
132
112
  task,
133
113
  projectScore: item.projectScore,
134
114
  projectLatestUpdatedAt: item.projectLatestUpdatedAt,
@@ -155,6 +135,49 @@ export function toTaskUpdatedAtMs(updatedAt) {
155
135
  const timestamp = new Date(updatedAt).getTime();
156
136
  return Number.isFinite(timestamp) ? timestamp : 0;
157
137
  }
138
+ function toTaskIdNumericSuffix(taskId) {
139
+ const match = taskId.match(/^(?:TASK-)(\d{4})$/);
140
+ if (!match) {
141
+ return -1;
142
+ }
143
+ return Number.parseInt(match[1], 10);
144
+ }
145
+ export function sortTasksNewestFirst(tasks) {
146
+ return [...tasks].sort((a, b) => {
147
+ const updatedAtDelta = toTaskUpdatedAtMs(b.updatedAt) - toTaskUpdatedAtMs(a.updatedAt);
148
+ if (updatedAtDelta !== 0) {
149
+ return updatedAtDelta;
150
+ }
151
+ const idDelta = toTaskIdNumericSuffix(b.id) - toTaskIdNumericSuffix(a.id);
152
+ if (idDelta !== 0) {
153
+ return idDelta;
154
+ }
155
+ return b.id.localeCompare(a.id);
156
+ });
157
+ }
158
+ function normalizeAndSortTasks(tasks) {
159
+ return sortTasksNewestFirst(tasks.map((task) => normalizeTask(task)));
160
+ }
161
+ function resolveTaskArtifactPaths(governanceDir) {
162
+ return {
163
+ tasksPath: path.join(governanceDir, ".projitive"),
164
+ markdownPath: path.join(governanceDir, TASKS_MARKDOWN_FILE),
165
+ };
166
+ }
167
+ async function syncTasksMarkdownView(tasksPath, markdownPath, markdown, force = false) {
168
+ const sourceVersion = await getStoreVersion(tasksPath, "tasks");
169
+ const viewState = await getMarkdownViewState(tasksPath, "tasks_markdown");
170
+ const markdownExists = await fs.access(markdownPath).then(() => true).catch(() => false);
171
+ const shouldWrite = force
172
+ || !markdownExists
173
+ || viewState.dirty
174
+ || viewState.lastSourceVersion !== sourceVersion;
175
+ if (!shouldWrite) {
176
+ return;
177
+ }
178
+ await fs.writeFile(markdownPath, markdown, "utf-8");
179
+ await markMarkdownViewBuilt(tasksPath, "tasks_markdown", sourceVersion);
180
+ }
158
181
  export function rankActionableTaskCandidates(candidates) {
159
182
  return [...candidates].sort((a, b) => {
160
183
  if (b.projectScore !== a.projectScore) {
@@ -172,114 +195,6 @@ export function rankActionableTaskCandidates(candidates) {
172
195
  return a.task.id.localeCompare(b.task.id);
173
196
  });
174
197
  }
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
198
  export function normalizeTask(task) {
284
199
  const normalizedRoadmapRefs = Array.isArray(task.roadmapRefs)
285
200
  ? task.roadmapRefs.map(String).filter((value) => isValidRoadmapId(value))
@@ -303,146 +218,8 @@ export function normalizeTask(task) {
303
218
  }
304
219
  return normalized;
305
220
  }
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 = {}) {
221
+ function collectTaskLintSuggestionItems(tasks) {
444
222
  const suggestions = [];
445
- const { markdown, outsideMarkerScopeIds } = options;
446
223
  const duplicateIds = Array.from(tasks.reduce((counter, task) => {
447
224
  counter.set(task.id, (counter.get(task.id) ?? 0) + 1);
448
225
  return counter;
@@ -497,17 +274,6 @@ function collectTaskLintSuggestionItems(tasks, options = {}) {
497
274
  fixHint: "Bind at least one ROADMAP-xxxx when applicable.",
498
275
  });
499
276
  }
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
- }
510
- }
511
277
  // ============================================================================
512
278
  // Spec v1.1.0 - Blocker Categorization Validation
513
279
  // ============================================================================
@@ -564,8 +330,8 @@ function collectTaskLintSuggestionItems(tasks, options = {}) {
564
330
  }
565
331
  return suggestions;
566
332
  }
567
- export function collectTaskLintSuggestions(tasks, markdown, outsideMarkerScopeIds) {
568
- return renderLintSuggestions(collectTaskLintSuggestionItems(tasks, { markdown, outsideMarkerScopeIds }));
333
+ export function collectTaskLintSuggestions(tasks) {
334
+ return renderLintSuggestions(collectTaskLintSuggestionItems(tasks));
569
335
  }
570
336
  function collectSingleTaskLintSuggestions(task) {
571
337
  const suggestions = [];
@@ -676,7 +442,7 @@ async function collectTaskFileLintSuggestions(governanceDir, task) {
676
442
  return renderLintSuggestions(suggestions);
677
443
  }
678
444
  export function renderTasksMarkdown(tasks) {
679
- const sections = tasks.map((task) => {
445
+ const sections = sortTasksNewestFirst(tasks).map((task) => {
680
446
  const roadmapRefs = task.roadmapRefs.length > 0 ? task.roadmapRefs.join(", ") : "(none)";
681
447
  const links = task.links.length > 0
682
448
  ? ["- links:", ...task.links.map((link) => ` - ${link}`)]
@@ -722,22 +488,19 @@ export function renderTasksMarkdown(tasks) {
722
488
  return [
723
489
  "# Tasks",
724
490
  "",
725
- "This file is maintained by Projitive MCP. If editing manually, please keep the Markdown structure valid.",
491
+ "This file is generated from .projitive sqlite tables by Projitive MCP. Manual edits will be overwritten.",
726
492
  "",
727
- TASKS_START,
728
493
  ...(sections.length > 0 ? sections : ["(no tasks)"]),
729
- TASKS_END,
730
494
  "",
731
495
  ].join("\n");
732
496
  }
733
497
  export async function ensureTasksFile(inputPath) {
734
498
  const governanceDir = await resolveGovernanceDir(inputPath);
735
- const tasksPath = path.join(governanceDir, "tasks.md");
499
+ const { tasksPath, markdownPath } = resolveTaskArtifactPaths(governanceDir);
736
500
  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
- }
501
+ await ensureStore(tasksPath);
502
+ const tasks = normalizeAndSortTasks(await loadTasksFromStore(tasksPath));
503
+ await syncTasksMarkdownView(tasksPath, markdownPath, renderTasksMarkdown(tasks));
741
504
  return tasksPath;
742
505
  }
743
506
  export async function loadTasks(inputPath) {
@@ -745,13 +508,21 @@ export async function loadTasks(inputPath) {
745
508
  return { tasksPath, tasks };
746
509
  }
747
510
  export async function loadTasksDocument(inputPath) {
511
+ return loadTasksDocumentWithOptions(inputPath, false);
512
+ }
513
+ export async function loadTasksDocumentWithOptions(inputPath, forceViewSync) {
748
514
  const tasksPath = await ensureTasksFile(inputPath);
749
- const markdown = await fs.readFile(tasksPath, "utf-8");
750
- return { tasksPath, markdown, tasks: await parseTasksBlock(markdown) };
515
+ const tasks = normalizeAndSortTasks(await loadTasksFromStore(tasksPath));
516
+ const markdown = renderTasksMarkdown(tasks);
517
+ const markdownPath = path.join(path.dirname(tasksPath), TASKS_MARKDOWN_FILE);
518
+ await syncTasksMarkdownView(tasksPath, markdownPath, markdown, forceViewSync);
519
+ return { tasksPath, markdownPath, markdown, tasks };
751
520
  }
752
521
  export async function saveTasks(tasksPath, tasks) {
753
- const normalized = tasks.map((task) => normalizeTask(task));
754
- await fs.writeFile(tasksPath, renderTasksMarkdown(normalized), "utf-8");
522
+ const normalized = normalizeAndSortTasks(tasks);
523
+ const markdownPath = path.join(path.dirname(tasksPath), TASKS_MARKDOWN_FILE);
524
+ await replaceTasksInStore(tasksPath, normalized);
525
+ await syncTasksMarkdownView(tasksPath, markdownPath, renderTasksMarkdown(normalized));
755
526
  }
756
527
  export function validateTransition(from, to) {
757
528
  if (from === to) {
@@ -776,11 +547,11 @@ export function registerTaskTools(server) {
776
547
  },
777
548
  }, async ({ projectPath, status, limit }) => {
778
549
  const governanceDir = await resolveGovernanceDir(projectPath);
779
- const { tasksPath, tasks, markdown: tasksMarkdown } = await loadTasksDocument(governanceDir);
550
+ const { tasks } = await loadTasksDocument(governanceDir);
780
551
  const filtered = tasks
781
552
  .filter((task) => (status ? task.status === status : true))
782
553
  .slice(0, limit ?? 100);
783
- const lintSuggestions = collectTaskLintSuggestions(filtered, tasksMarkdown, new Set(filtered.map((task) => task.id)));
554
+ const lintSuggestions = collectTaskLintSuggestions(filtered);
784
555
  if (status && filtered.length === 0) {
785
556
  appendLintSuggestions(lintSuggestions, [
786
557
  {
@@ -796,7 +567,6 @@ export function registerTaskTools(server) {
796
567
  sections: [
797
568
  summarySection([
798
569
  `- governanceDir: ${governanceDir}`,
799
- `- tasksPath: ${tasksPath}`,
800
570
  `- filter.status: ${status ?? "(none)"}`,
801
571
  `- returned: ${filtered.length}`,
802
572
  ]),
@@ -820,27 +590,24 @@ export function registerTaskTools(server) {
820
590
  limit: z.number().int().min(1).max(20).optional(),
821
591
  },
822
592
  }, async ({ limit }) => {
823
- const root = resolveScanRoot();
593
+ const roots = resolveScanRoots();
824
594
  const depth = resolveScanDepth();
825
- const projects = await discoverProjects(root, depth);
595
+ const projects = await discoverProjectsAcrossRoots(roots, depth);
826
596
  const rankedCandidates = rankActionableTaskCandidates(await readActionableTaskCandidates(projects));
827
597
  if (rankedCandidates.length === 0) {
828
598
  const projectSnapshots = await Promise.all(projects.map(async (governanceDir) => {
829
- const { tasksPath, tasks } = await loadTasks(governanceDir);
599
+ const tasksPath = path.join(governanceDir, ".projitive");
600
+ await ensureStore(tasksPath);
601
+ const stats = await loadTaskStatusStatsFromStore(tasksPath);
830
602
  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
603
  return {
836
604
  governanceDir,
837
- tasksPath,
838
605
  roadmapIds,
839
- total: tasks.length,
840
- todo,
841
- inProgress,
842
- blocked,
843
- done,
606
+ total: stats.total,
607
+ todo: stats.todo,
608
+ inProgress: stats.inProgress,
609
+ blocked: stats.blocked,
610
+ done: stats.done,
844
611
  };
845
612
  }));
846
613
  const preferredProject = projectSnapshots[0];
@@ -850,7 +617,8 @@ export function registerTaskTools(server) {
850
617
  toolName: "taskNext",
851
618
  sections: [
852
619
  summarySection([
853
- `- rootPath: ${root}`,
620
+ `- rootPaths: ${roots.join(", ")}`,
621
+ `- rootCount: ${roots.length}`,
854
622
  `- maxDepth: ${depth}`,
855
623
  `- matchedProjects: ${projects.length}`,
856
624
  "- actionableTasks: 0",
@@ -858,7 +626,7 @@ export function registerTaskTools(server) {
858
626
  evidenceSection([
859
627
  "### Project Snapshots",
860
628
  ...(projectSnapshots.length > 0
861
- ? 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}`)
629
+ ? 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)"}`)
862
630
  : ["- (none)"]),
863
631
  "",
864
632
  "### Seed Task Template",
@@ -867,16 +635,17 @@ export function registerTaskTools(server) {
867
635
  guidanceSection([
868
636
  "- No TODO/IN_PROGRESS task is available.",
869
637
  "- Use no-task discovery checklist below to proactively find and create meaningful TODO tasks.",
638
+ "- If roadmap has active milestones, analyze milestone intent and split into 1-3 executable TODO tasks.",
870
639
  "",
871
640
  "### No-Task Discovery Checklist",
872
641
  ...noTaskDiscoveryGuidance,
873
642
  "",
874
643
  "- If no tasks exist, derive 1-3 TODO tasks from roadmap milestones, README scope, or unresolved report gaps.",
875
644
  "- If only BLOCKED/DONE tasks exist, reopen one blocked item or create a follow-up TODO task.",
876
- "- After adding tasks inside marker block, rerun `taskNext` to re-rank actionable work.",
645
+ "- After creating tasks, rerun `taskNext` to re-rank actionable work.",
877
646
  ]),
878
647
  lintSection([
879
- "- No actionable tasks found. Verify task statuses and required fields in marker block.",
648
+ "- No actionable tasks found. Verify task statuses and required fields in .projitive task table.",
880
649
  "- Ensure each new task has stable TASK-xxxx ID and at least one roadmapRefs item.",
881
650
  ]),
882
651
  nextCallSection(preferredProject
@@ -888,19 +657,20 @@ export function registerTaskTools(server) {
888
657
  }
889
658
  const selected = rankedCandidates[0];
890
659
  const selectedTaskDocument = await loadTasksDocument(selected.governanceDir);
891
- const lintSuggestions = collectTaskLintSuggestions(selectedTaskDocument.tasks, selectedTaskDocument.markdown);
660
+ const lintSuggestions = collectTaskLintSuggestions(selectedTaskDocument.tasks);
892
661
  const artifacts = await discoverGovernanceArtifacts(selected.governanceDir);
893
662
  const fileCandidates = candidateFilesFromArtifacts(artifacts);
894
663
  const referenceLocations = (await Promise.all(fileCandidates.map((file) => findTextReferences(file, selected.task.id)))).flat();
895
- const taskLocation = (await findTextReferences(selected.tasksPath, selected.task.id))[0];
664
+ const taskLocation = (await findTextReferences(selectedTaskDocument.markdownPath, selected.task.id))[0];
896
665
  const relatedArtifacts = Array.from(new Set(referenceLocations.map((item) => item.filePath)));
897
- const suggestedReadOrder = [selected.tasksPath, ...relatedArtifacts.filter((item) => item !== selected.tasksPath)];
666
+ const suggestedReadOrder = [selectedTaskDocument.markdownPath, ...relatedArtifacts.filter((item) => item !== selectedTaskDocument.markdownPath)];
898
667
  const candidateLimit = limit ?? 5;
899
668
  const markdown = renderToolResponseMarkdown({
900
669
  toolName: "taskNext",
901
670
  sections: [
902
671
  summarySection([
903
- `- rootPath: ${root}`,
672
+ `- rootPaths: ${roots.join(", ")}`,
673
+ `- rootCount: ${roots.length}`,
904
674
  `- maxDepth: ${depth}`,
905
675
  `- matchedProjects: ${projects.length}`,
906
676
  `- actionableTasks: ${rankedCandidates.length}`,
@@ -915,7 +685,7 @@ export function registerTaskTools(server) {
915
685
  `- owner: ${selected.task.owner || "(none)"}`,
916
686
  `- updatedAt: ${selected.task.updatedAt}`,
917
687
  `- roadmapRefs: ${selected.task.roadmapRefs.join(", ") || "(none)"}`,
918
- `- taskLocation: ${taskLocation ? `${taskLocation.filePath}#L${taskLocation.line}` : selected.tasksPath}`,
688
+ `- taskLocation: ${taskLocation ? `${taskLocation.filePath}#L${taskLocation.line}` : selectedTaskDocument.markdownPath}`,
919
689
  "",
920
690
  "### Top Candidates",
921
691
  ...rankedCandidates
@@ -963,7 +733,7 @@ export function registerTaskTools(server) {
963
733
  };
964
734
  }
965
735
  const governanceDir = await resolveGovernanceDir(projectPath);
966
- const { tasksPath, tasks, markdown: tasksMarkdown } = await loadTasksDocument(governanceDir);
736
+ const { markdownPath, tasks, markdown: tasksMarkdown } = await loadTasksDocument(governanceDir);
967
737
  const task = tasks.find((item) => item.id === taskId);
968
738
  if (!task) {
969
739
  return {
@@ -975,22 +745,13 @@ export function registerTaskTools(server) {
975
745
  ...collectSingleTaskLintSuggestions(task),
976
746
  ...(await collectTaskFileLintSuggestions(governanceDir, task)),
977
747
  ];
978
- const outsideMarkerTaskIds = findTaskIdsOutsideMarkers(tasksMarkdown);
979
- if (outsideMarkerTaskIds.includes(task.id)) {
980
- appendLintSuggestions(lintSuggestions, [
981
- {
982
- code: TASK_LINT_CODES.OUTSIDE_MARKER,
983
- message: `Current task ID appears outside marker block (${task.id}).`,
984
- fixHint: "Keep task source of truth inside marker region.",
985
- },
986
- ]);
987
- }
988
- const taskLocation = (await findTextReferences(tasksPath, taskId))[0];
748
+ const contextReadingGuidance = await resolveTaskContextReadingGuidance(governanceDir);
749
+ const taskLocation = (await findTextReferences(markdownPath, taskId))[0];
989
750
  const artifacts = await discoverGovernanceArtifacts(governanceDir);
990
751
  const fileCandidates = candidateFilesFromArtifacts(artifacts);
991
752
  const referenceLocations = (await Promise.all(fileCandidates.map((file) => findTextReferences(file, taskId)))).flat();
992
753
  const relatedArtifacts = Array.from(new Set(referenceLocations.map((item) => item.filePath)));
993
- const suggestedReadOrder = [tasksPath, ...relatedArtifacts.filter((item) => item !== tasksPath)];
754
+ const suggestedReadOrder = [markdownPath, ...relatedArtifacts.filter((item) => item !== markdownPath)];
994
755
  // Build summary with subState and blocker info (v1.1.0)
995
756
  const summaryLines = [
996
757
  `- governanceDir: ${governanceDir}`,
@@ -1000,7 +761,7 @@ export function registerTaskTools(server) {
1000
761
  `- owner: ${task.owner}`,
1001
762
  `- updatedAt: ${task.updatedAt}`,
1002
763
  `- roadmapRefs: ${task.roadmapRefs.join(", ") || "(none)"}`,
1003
- `- taskLocation: ${taskLocation ? `${taskLocation.filePath}#L${taskLocation.line}` : tasksPath}`,
764
+ `- taskLocation: ${taskLocation ? `${taskLocation.filePath}#L${taskLocation.line}` : markdownPath}`,
1004
765
  ];
1005
766
  // Add subState info for IN_PROGRESS tasks (v1.1.0)
1006
767
  if (task.subState && task.status === "IN_PROGRESS") {
@@ -1048,9 +809,13 @@ export function registerTaskTools(server) {
1048
809
  ]),
1049
810
  guidanceSection([
1050
811
  "- Read the files in Suggested Read Order.",
812
+ "",
813
+ "### Recommended Context Reading",
814
+ ...contextReadingGuidance,
815
+ "",
1051
816
  "- Verify whether current status and evidence are consistent.",
1052
817
  ...taskStatusGuidance(task),
1053
- "- If updates are needed, edit tasks/designs/reports markdown directly and keep TASK IDs unchanged.",
818
+ "- If updates are needed, use tool writes for sqlite source (`taskUpdate` / `roadmapUpdate`) and keep TASK IDs unchanged.",
1054
819
  "- After editing, re-run `taskContext` to verify references and context consistency.",
1055
820
  ]),
1056
821
  lintSection(lintSuggestions),
@@ -1094,7 +859,7 @@ export function registerTaskTools(server) {
1094
859
  };
1095
860
  }
1096
861
  const governanceDir = await resolveGovernanceDir(projectPath);
1097
- const { tasksPath, tasks, markdown: tasksMarkdown } = await loadTasksDocument(governanceDir);
862
+ const { tasksPath, tasks } = await loadTasksDocument(governanceDir);
1098
863
  const taskIndex = tasks.findIndex((item) => item.id === taskId);
1099
864
  if (taskIndex === -1) {
1100
865
  return {
@@ -1145,8 +910,17 @@ export function registerTaskTools(server) {
1145
910
  }
1146
911
  // Update updatedAt
1147
912
  task.updatedAt = nowIso();
1148
- // Save tasks
1149
- await saveTasks(tasksPath, tasks);
913
+ const normalizedTask = normalizeTask(task);
914
+ // Save task incrementally
915
+ await upsertTaskInStore(tasksPath, normalizedTask);
916
+ task.status = normalizedTask.status;
917
+ task.owner = normalizedTask.owner;
918
+ task.summary = normalizedTask.summary;
919
+ task.roadmapRefs = normalizedTask.roadmapRefs;
920
+ task.links = normalizedTask.links;
921
+ task.updatedAt = normalizedTask.updatedAt;
922
+ task.subState = normalizedTask.subState;
923
+ task.blocker = normalizedTask.blocker;
1150
924
  // Build response
1151
925
  const updateSummary = [
1152
926
  `- taskId: ${taskId}`,
@@ -1197,6 +971,8 @@ export function registerTaskTools(server) {
1197
971
  "Task updated successfully. Run `taskContext` to verify the changes.",
1198
972
  "If status changed to DONE, ensure evidence links are added.",
1199
973
  "If subState or blocker were updated, verify the metadata is correct.",
974
+ "SQLite is source of truth; tasks.md is a generated view and may be overwritten.",
975
+ "Call `syncViews(projectPath=..., views=[\"tasks\"], force=true)` when immediate markdown materialization is required.",
1200
976
  ]),
1201
977
  lintSection([]),
1202
978
  nextCallSection(`taskContext(projectPath=\"${toProjectPath(governanceDir)}\", taskId=\"${taskId}\")`),