@phren/cli 0.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.
- package/LICENSE +21 -0
- package/README.md +590 -0
- package/mcp/dist/capabilities/cli.js +61 -0
- package/mcp/dist/capabilities/index.js +15 -0
- package/mcp/dist/capabilities/mcp.js +61 -0
- package/mcp/dist/capabilities/types.js +57 -0
- package/mcp/dist/capabilities/vscode.js +61 -0
- package/mcp/dist/capabilities/web-ui.js +61 -0
- package/mcp/dist/cli-actions.js +302 -0
- package/mcp/dist/cli-config.js +580 -0
- package/mcp/dist/cli-extract.js +305 -0
- package/mcp/dist/cli-govern.js +371 -0
- package/mcp/dist/cli-graph.js +169 -0
- package/mcp/dist/cli-hooks-citations.js +44 -0
- package/mcp/dist/cli-hooks-context.js +56 -0
- package/mcp/dist/cli-hooks-globs.js +83 -0
- package/mcp/dist/cli-hooks-output.js +130 -0
- package/mcp/dist/cli-hooks-retrieval.js +2 -0
- package/mcp/dist/cli-hooks-session.js +1402 -0
- package/mcp/dist/cli-hooks.js +350 -0
- package/mcp/dist/cli-namespaces.js +989 -0
- package/mcp/dist/cli-ops.js +253 -0
- package/mcp/dist/cli-search.js +407 -0
- package/mcp/dist/cli.js +108 -0
- package/mcp/dist/content-archive.js +278 -0
- package/mcp/dist/content-citation.js +391 -0
- package/mcp/dist/content-dedup.js +622 -0
- package/mcp/dist/content-learning.js +472 -0
- package/mcp/dist/content-metadata.js +186 -0
- package/mcp/dist/content-validate.js +462 -0
- package/mcp/dist/core-finding.js +54 -0
- package/mcp/dist/core-project.js +36 -0
- package/mcp/dist/core-search.js +50 -0
- package/mcp/dist/data-access.js +400 -0
- package/mcp/dist/data-tasks.js +821 -0
- package/mcp/dist/embedding.js +344 -0
- package/mcp/dist/entrypoint.js +387 -0
- package/mcp/dist/finding-context.js +172 -0
- package/mcp/dist/finding-impact.js +181 -0
- package/mcp/dist/finding-journal.js +122 -0
- package/mcp/dist/finding-lifecycle.js +259 -0
- package/mcp/dist/governance-audit.js +22 -0
- package/mcp/dist/governance-locks.js +96 -0
- package/mcp/dist/governance-policy.js +648 -0
- package/mcp/dist/governance-scores.js +355 -0
- package/mcp/dist/hooks.js +449 -0
- package/mcp/dist/impact-scoring.js +22 -0
- package/mcp/dist/index-query.js +168 -0
- package/mcp/dist/index.js +205 -0
- package/mcp/dist/init-config.js +336 -0
- package/mcp/dist/init-preferences.js +62 -0
- package/mcp/dist/init-setup.js +1305 -0
- package/mcp/dist/init-shared.js +29 -0
- package/mcp/dist/init.js +1730 -0
- package/mcp/dist/link-checksums.js +62 -0
- package/mcp/dist/link-context.js +257 -0
- package/mcp/dist/link-doctor.js +591 -0
- package/mcp/dist/link-skills.js +212 -0
- package/mcp/dist/link.js +596 -0
- package/mcp/dist/logger.js +15 -0
- package/mcp/dist/machine-identity.js +38 -0
- package/mcp/dist/mcp-config.js +254 -0
- package/mcp/dist/mcp-data.js +315 -0
- package/mcp/dist/mcp-extract-facts.js +78 -0
- package/mcp/dist/mcp-extract.js +133 -0
- package/mcp/dist/mcp-finding.js +557 -0
- package/mcp/dist/mcp-graph.js +339 -0
- package/mcp/dist/mcp-hooks.js +256 -0
- package/mcp/dist/mcp-memory.js +58 -0
- package/mcp/dist/mcp-ops.js +328 -0
- package/mcp/dist/mcp-search.js +628 -0
- package/mcp/dist/mcp-session.js +651 -0
- package/mcp/dist/mcp-skills.js +189 -0
- package/mcp/dist/mcp-tasks.js +551 -0
- package/mcp/dist/mcp-types.js +7 -0
- package/mcp/dist/memory-ui-assets.js +6 -0
- package/mcp/dist/memory-ui-data.js +513 -0
- package/mcp/dist/memory-ui-graph.js +1910 -0
- package/mcp/dist/memory-ui-page.js +353 -0
- package/mcp/dist/memory-ui-scripts.js +1387 -0
- package/mcp/dist/memory-ui-server.js +1218 -0
- package/mcp/dist/memory-ui-styles.js +555 -0
- package/mcp/dist/memory-ui.js +9 -0
- package/mcp/dist/package-metadata.js +13 -0
- package/mcp/dist/phren-art.js +52 -0
- package/mcp/dist/phren-core.js +108 -0
- package/mcp/dist/phren-dotenv.js +67 -0
- package/mcp/dist/phren-paths.js +476 -0
- package/mcp/dist/proactivity.js +172 -0
- package/mcp/dist/profile-store.js +228 -0
- package/mcp/dist/project-config.js +85 -0
- package/mcp/dist/project-locator.js +25 -0
- package/mcp/dist/project-topics.js +1134 -0
- package/mcp/dist/provider-adapters.js +176 -0
- package/mcp/dist/runtime-profile.js +18 -0
- package/mcp/dist/session-checkpoints.js +131 -0
- package/mcp/dist/session-utils.js +68 -0
- package/mcp/dist/shared-content.js +8 -0
- package/mcp/dist/shared-embedding-cache.js +143 -0
- package/mcp/dist/shared-fragment-graph.js +456 -0
- package/mcp/dist/shared-governance.js +4 -0
- package/mcp/dist/shared-index.js +1334 -0
- package/mcp/dist/shared-ollama.js +192 -0
- package/mcp/dist/shared-paths.js +1 -0
- package/mcp/dist/shared-retrieval.js +796 -0
- package/mcp/dist/shared-search-fallback.js +375 -0
- package/mcp/dist/shared-sqljs.js +42 -0
- package/mcp/dist/shared-stemmer.js +171 -0
- package/mcp/dist/shared-vector-index.js +199 -0
- package/mcp/dist/shared.js +114 -0
- package/mcp/dist/shell-entry.js +209 -0
- package/mcp/dist/shell-input.js +943 -0
- package/mcp/dist/shell-palette.js +119 -0
- package/mcp/dist/shell-render.js +252 -0
- package/mcp/dist/shell-state-store.js +81 -0
- package/mcp/dist/shell-types.js +13 -0
- package/mcp/dist/shell-view-list.js +14 -0
- package/mcp/dist/shell-view.js +707 -0
- package/mcp/dist/shell.js +352 -0
- package/mcp/dist/skill-files.js +117 -0
- package/mcp/dist/skill-registry.js +279 -0
- package/mcp/dist/skill-state.js +28 -0
- package/mcp/dist/startup-embedding.js +57 -0
- package/mcp/dist/status.js +323 -0
- package/mcp/dist/synonyms.json +670 -0
- package/mcp/dist/task-hygiene.js +251 -0
- package/mcp/dist/task-lifecycle.js +347 -0
- package/mcp/dist/tasks-github.js +76 -0
- package/mcp/dist/telemetry.js +165 -0
- package/mcp/dist/test-global-setup.js +37 -0
- package/mcp/dist/tool-registry.js +104 -0
- package/mcp/dist/update.js +97 -0
- package/mcp/dist/utils.js +543 -0
- package/package.json +67 -0
- package/skills/README.md +7 -0
- package/skills/consolidate/SKILL.md +152 -0
- package/skills/discover/SKILL.md +175 -0
- package/skills/init/SKILL.md +216 -0
- package/skills/profiles/SKILL.md +121 -0
- package/skills/sync/SKILL.md +261 -0
- package/starter/README.md +74 -0
- package/starter/global/CLAUDE.md +89 -0
- package/starter/global/skills/humanize.md +30 -0
- package/starter/global/skills/pipeline.md +35 -0
- package/starter/global/skills/release.md +35 -0
- package/starter/machines.yaml +8 -0
- package/starter/my-api/.claude/skills/README.md +7 -0
- package/starter/my-api/CLAUDE.md +33 -0
- package/starter/my-api/FINDINGS.md +9 -0
- package/starter/my-api/summary.md +7 -0
- package/starter/my-api/tasks.md +7 -0
- package/starter/my-first-project/.claude/skills/README.md +7 -0
- package/starter/my-first-project/CLAUDE.md +49 -0
- package/starter/my-first-project/FINDINGS.md +24 -0
- package/starter/my-first-project/summary.md +11 -0
- package/starter/my-first-project/tasks.md +25 -0
- package/starter/my-frontend/.claude/skills/README.md +7 -0
- package/starter/my-frontend/CLAUDE.md +33 -0
- package/starter/my-frontend/FINDINGS.md +9 -0
- package/starter/my-frontend/summary.md +7 -0
- package/starter/my-frontend/tasks.md +7 -0
- package/starter/profiles/default.yaml +4 -0
- package/starter/profiles/personal.yaml +4 -0
- package/starter/profiles/work.yaml +4 -0
- package/starter/templates/README.md +7 -0
- package/starter/templates/frontend/CLAUDE.md +23 -0
- package/starter/templates/frontend/FINDINGS.md +7 -0
- package/starter/templates/frontend/reference/README.md +4 -0
- package/starter/templates/frontend/summary.md +7 -0
- package/starter/templates/frontend/tasks.md +11 -0
- package/starter/templates/library/CLAUDE.md +22 -0
- package/starter/templates/library/FINDINGS.md +7 -0
- package/starter/templates/library/reference/README.md +4 -0
- package/starter/templates/library/summary.md +7 -0
- package/starter/templates/library/tasks.md +11 -0
- package/starter/templates/monorepo/CLAUDE.md +21 -0
- package/starter/templates/monorepo/FINDINGS.md +7 -0
- package/starter/templates/monorepo/reference/README.md +4 -0
- package/starter/templates/monorepo/summary.md +7 -0
- package/starter/templates/monorepo/tasks.md +11 -0
- package/starter/templates/python-project/CLAUDE.md +21 -0
- package/starter/templates/python-project/FINDINGS.md +7 -0
- package/starter/templates/python-project/reference/README.md +4 -0
- package/starter/templates/python-project/summary.md +7 -0
- package/starter/templates/python-project/tasks.md +10 -0
|
@@ -0,0 +1,551 @@
|
|
|
1
|
+
import { mcpResponse } from "./mcp-types.js";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import * as fs from "fs";
|
|
4
|
+
import * as path from "path";
|
|
5
|
+
import { isValidProjectName } from "./utils.js";
|
|
6
|
+
import { addTask as addTaskStore, addTasks as addTasksBatch, taskMarkdown, completeTask as completeTaskStore, completeTasks as completeTasksBatch, removeTask as removeTaskStore, linkTaskIssue, pinTask, workNextTask, tidyDoneTasks, readTasks, readTasksAcrossProjects, resolveTaskItem, TASKS_FILENAME, updateTask as updateTaskStore, promoteTask, } from "./data-access.js";
|
|
7
|
+
import { applyGravity } from "./data-tasks.js";
|
|
8
|
+
import { buildTaskIssueBody, createGithubIssueForTask, parseGithubIssueUrl, resolveProjectGithubRepo, } from "./tasks-github.js";
|
|
9
|
+
import { clearTaskCheckpoint } from "./session-checkpoints.js";
|
|
10
|
+
import { incrementSessionTasksCompleted } from "./mcp-session.js";
|
|
11
|
+
import { normalizeMemoryScope } from "./shared.js";
|
|
12
|
+
const TASK_SECTION_ORDER = ["Active", "Queue", "Done"];
|
|
13
|
+
const DEFAULT_TASK_LIMIT = 20;
|
|
14
|
+
/** Done items are historical — cap tightly by default to avoid large responses. */
|
|
15
|
+
const DEFAULT_DONE_LIMIT = 5;
|
|
16
|
+
function refreshTaskIndex(updateFileInIndex, phrenPath, project) {
|
|
17
|
+
updateFileInIndex(path.join(phrenPath, project, TASKS_FILENAME));
|
|
18
|
+
}
|
|
19
|
+
function buildTaskView(doc, status, limit, doneLimit, offset) {
|
|
20
|
+
let includedSections;
|
|
21
|
+
if (status === "all") {
|
|
22
|
+
includedSections = TASK_SECTION_ORDER;
|
|
23
|
+
}
|
|
24
|
+
else if (status === "done") {
|
|
25
|
+
includedSections = ["Done"];
|
|
26
|
+
}
|
|
27
|
+
else if (status === "active") {
|
|
28
|
+
includedSections = ["Active"];
|
|
29
|
+
}
|
|
30
|
+
else if (status === "queue") {
|
|
31
|
+
includedSections = ["Queue"];
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
includedSections = ["Active", "Queue"];
|
|
35
|
+
}
|
|
36
|
+
const effectiveLimit = limit ?? DEFAULT_TASK_LIMIT;
|
|
37
|
+
const effectiveDoneLimit = doneLimit ?? DEFAULT_DONE_LIMIT;
|
|
38
|
+
const effectiveOffset = offset ?? 0;
|
|
39
|
+
let truncated = false;
|
|
40
|
+
const items = {
|
|
41
|
+
Active: [],
|
|
42
|
+
Queue: [],
|
|
43
|
+
Done: [],
|
|
44
|
+
};
|
|
45
|
+
let totalUnpaged = 0;
|
|
46
|
+
for (const section of includedSections) {
|
|
47
|
+
// Apply gravity to Active and Queue items so stale tasks drift down
|
|
48
|
+
const rawItems = doc.items[section];
|
|
49
|
+
const sectionItems = (section === "Active" || section === "Queue") ? applyGravity(rawItems) : rawItems;
|
|
50
|
+
const cap = section === "Done" ? effectiveDoneLimit : effectiveLimit;
|
|
51
|
+
const sliced = effectiveOffset > 0 ? sectionItems.slice(effectiveOffset) : sectionItems;
|
|
52
|
+
totalUnpaged += sectionItems.length;
|
|
53
|
+
if (sliced.length > cap) {
|
|
54
|
+
items[section] = sliced.slice(0, cap);
|
|
55
|
+
truncated = true;
|
|
56
|
+
}
|
|
57
|
+
else {
|
|
58
|
+
items[section] = sliced;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
const totalItems = TASK_SECTION_ORDER.reduce((sum, section) => sum + items[section].length, 0);
|
|
62
|
+
return {
|
|
63
|
+
doc: { ...doc, items },
|
|
64
|
+
includedSections,
|
|
65
|
+
totalItems,
|
|
66
|
+
totalUnpaged,
|
|
67
|
+
truncated,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
function buildTaskSummary(doc, includedSections) {
|
|
71
|
+
const lines = [`## ${doc.project}`];
|
|
72
|
+
for (const section of includedSections) {
|
|
73
|
+
const items = doc.items[section];
|
|
74
|
+
const highCount = items.filter(i => i.priority === "high").length;
|
|
75
|
+
const medCount = items.filter(i => i.priority === "medium").length;
|
|
76
|
+
lines.push(`**${section}**: ${items.length} items${highCount ? ` (${highCount} high` + (medCount ? `, ${medCount} medium` : "") + ")" : ""}`);
|
|
77
|
+
// Show first 3 items as preview
|
|
78
|
+
for (const item of items.slice(0, 3)) {
|
|
79
|
+
const prio = item.priority ? ` [${item.priority}]` : "";
|
|
80
|
+
const bidPrefix = item.stableId ? `bid:${item.stableId} ` : "";
|
|
81
|
+
const githubTag = item.githubIssue ? ` [gh:#${item.githubIssue}]` : item.githubUrl ? " [gh]" : "";
|
|
82
|
+
lines.push(` - ${bidPrefix}${item.line.slice(0, 80)}${item.line.length > 80 ? "\u2026" : ""}${prio}${githubTag}`);
|
|
83
|
+
}
|
|
84
|
+
if (items.length > 3)
|
|
85
|
+
lines.push(` ... and ${items.length - 3} more`);
|
|
86
|
+
}
|
|
87
|
+
return lines.join("\n");
|
|
88
|
+
}
|
|
89
|
+
export function register(server, ctx) {
|
|
90
|
+
const { phrenPath, profile, withWriteQueue, updateFileInIndex } = ctx;
|
|
91
|
+
server.registerTool("get_tasks", {
|
|
92
|
+
title: "◆ phren · tasks",
|
|
93
|
+
description: "Get tasks. Defaults to Active and Queue sections only. Pass status='all' to include Done items.",
|
|
94
|
+
inputSchema: z.object({
|
|
95
|
+
project: z.string().optional().describe("Project name. Omit to get all projects."),
|
|
96
|
+
id: z.string().optional().describe("Task ID like A1, Q3, D2. Requires project."),
|
|
97
|
+
item: z.string().optional().describe("Exact task text. Requires project."),
|
|
98
|
+
status: z.enum(["all", "active", "queue", "done", "active+queue"]).optional().describe("Which task sections to include. Defaults to 'active+queue'."),
|
|
99
|
+
limit: z.number().int().min(1).max(200).optional().describe("Max items per Active/Queue section to return. Default 20."),
|
|
100
|
+
done_limit: z.number().int().min(1).max(200).optional().describe("Max Done items to return (most recent). Default 5. Done sections are capped tightly to avoid large responses."),
|
|
101
|
+
offset: z.number().int().min(0).optional().describe("Skip the first N items in each section before applying limit. Use with limit for pagination (e.g. offset:20, limit:20 for page 2)."),
|
|
102
|
+
summary: z.boolean().optional().describe("If true, return counts and titles only (no full content). Reduces token usage."),
|
|
103
|
+
}),
|
|
104
|
+
}, async ({ project, id, item, status, limit, done_limit, offset, summary }) => {
|
|
105
|
+
// Single item lookup
|
|
106
|
+
if (id || item) {
|
|
107
|
+
if (!project)
|
|
108
|
+
return mcpResponse({ ok: false, error: "Provide `project` when looking up a single item." });
|
|
109
|
+
if (!isValidProjectName(project))
|
|
110
|
+
return mcpResponse({ ok: false, error: `Invalid project name: "${project}"` });
|
|
111
|
+
const result = readTasks(phrenPath, project);
|
|
112
|
+
if (!result.ok)
|
|
113
|
+
return mcpResponse({ ok: false, error: result.error });
|
|
114
|
+
const doc = result.data;
|
|
115
|
+
const all = [...doc.items.Active, ...doc.items.Queue, ...doc.items.Done];
|
|
116
|
+
const bidLookup = id && id.startsWith("bid:") ? id.slice(4) : null;
|
|
117
|
+
const match = all.find((entry) => (bidLookup && entry.stableId === bidLookup) ||
|
|
118
|
+
(id && !bidLookup && entry.id.toLowerCase() === id.toLowerCase()) ||
|
|
119
|
+
(item && entry.line.trim() === item.trim()));
|
|
120
|
+
if (!match)
|
|
121
|
+
return mcpResponse({ ok: false, error: `No task found in ${project} for ${id ? `id=${id}` : `item="${item}"`}.` });
|
|
122
|
+
return mcpResponse({
|
|
123
|
+
ok: true,
|
|
124
|
+
message: `${match.id}: ${match.line} (${match.section})`,
|
|
125
|
+
data: {
|
|
126
|
+
project,
|
|
127
|
+
id: match.id,
|
|
128
|
+
stableId: match.stableId || null,
|
|
129
|
+
section: match.section,
|
|
130
|
+
checked: match.checked,
|
|
131
|
+
line: match.line,
|
|
132
|
+
context: match.context || null,
|
|
133
|
+
priority: match.priority || null,
|
|
134
|
+
githubIssue: match.githubIssue ?? null,
|
|
135
|
+
githubUrl: match.githubUrl || null,
|
|
136
|
+
},
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
// Full task list for one project
|
|
140
|
+
if (project) {
|
|
141
|
+
if (!isValidProjectName(project))
|
|
142
|
+
return mcpResponse({ ok: false, error: `Invalid project name: "${project}"` });
|
|
143
|
+
const result = readTasks(phrenPath, project);
|
|
144
|
+
if (!result.ok)
|
|
145
|
+
return mcpResponse({ ok: false, error: result.error });
|
|
146
|
+
const doc = result.data;
|
|
147
|
+
const view = buildTaskView(doc, status, limit, done_limit, offset);
|
|
148
|
+
if (!fs.existsSync(doc.path)) {
|
|
149
|
+
return mcpResponse({
|
|
150
|
+
ok: true,
|
|
151
|
+
message: `No tasks found for "${project}".`,
|
|
152
|
+
data: { project, items: view.doc.items, includedSections: view.includedSections, totalItems: view.totalItems },
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
if (summary) {
|
|
156
|
+
return mcpResponse({
|
|
157
|
+
ok: true,
|
|
158
|
+
message: buildTaskSummary(view.doc, view.includedSections),
|
|
159
|
+
data: { project, includedSections: view.includedSections, totalItems: view.totalItems, summary: true },
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
const paginationNote = view.truncated
|
|
163
|
+
? `\n\n_Showing ${offset ?? 0}–${(offset ?? 0) + view.totalItems} of ${view.totalUnpaged} items. Use offset/limit to page._`
|
|
164
|
+
: (offset ? `\n\n_Page offset: ${offset}. ${view.totalItems} items returned._` : "");
|
|
165
|
+
return mcpResponse({
|
|
166
|
+
ok: true,
|
|
167
|
+
message: `## ${project}\n${taskMarkdown(view.doc)}${paginationNote}`,
|
|
168
|
+
data: { project, items: view.doc.items, issues: doc.issues, includedSections: view.includedSections, totalItems: view.totalItems, totalUnpaged: view.totalUnpaged, offset: offset ?? 0, truncated: view.truncated },
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
// All projects
|
|
172
|
+
const docs = readTasksAcrossProjects(phrenPath, profile);
|
|
173
|
+
if (!docs.length)
|
|
174
|
+
return mcpResponse({ ok: true, message: "No tasks found.", data: { projects: [] } });
|
|
175
|
+
const views = docs.map((doc) => ({ project: doc.project, doc, view: buildTaskView(doc, status, limit, done_limit, offset), issues: doc.issues }));
|
|
176
|
+
const anyTruncated = views.some(({ view }) => view.truncated);
|
|
177
|
+
let parts;
|
|
178
|
+
if (summary) {
|
|
179
|
+
parts = views.map(({ view }) => buildTaskSummary(view.doc, view.includedSections));
|
|
180
|
+
}
|
|
181
|
+
else {
|
|
182
|
+
parts = views.map(({ project, view }) => `## ${project}\n${taskMarkdown(view.doc)}`);
|
|
183
|
+
}
|
|
184
|
+
const truncationNote = anyTruncated && !summary ? `\n\n_Results capped (Active/Queue: ${limit ?? DEFAULT_TASK_LIMIT}, Done: ${done_limit ?? DEFAULT_DONE_LIMIT}). Pass limit/done_limit to see more._` : "";
|
|
185
|
+
const projectData = views.map(({ project, view, issues }) => ({
|
|
186
|
+
project,
|
|
187
|
+
items: view.doc.items,
|
|
188
|
+
issues,
|
|
189
|
+
includedSections: view.includedSections,
|
|
190
|
+
totalItems: view.totalItems,
|
|
191
|
+
truncated: view.truncated,
|
|
192
|
+
}));
|
|
193
|
+
return mcpResponse({ ok: true, message: parts.join("\n\n") + truncationNote, data: { projects: projectData, summary: summary || false } });
|
|
194
|
+
});
|
|
195
|
+
server.registerTool("add_task", {
|
|
196
|
+
title: "◆ phren · add task",
|
|
197
|
+
description: "Append a task to a project's tasks.md file. Adds to the Queue section.",
|
|
198
|
+
inputSchema: z.object({
|
|
199
|
+
project: z.string().describe("Project name (must match a directory in your phren)."),
|
|
200
|
+
item: z.string().describe("The task to add."),
|
|
201
|
+
scope: z.string().optional().describe("Optional memory scope label. Defaults to 'shared'. Example: 'researcher' or 'builder'."),
|
|
202
|
+
}),
|
|
203
|
+
}, async ({ project, item, scope }) => {
|
|
204
|
+
if (!isValidProjectName(project))
|
|
205
|
+
return mcpResponse({ ok: false, error: `Invalid project name: "${project}"` });
|
|
206
|
+
const normalizedScope = normalizeMemoryScope(scope ?? "shared");
|
|
207
|
+
if (!normalizedScope)
|
|
208
|
+
return mcpResponse({ ok: false, error: `Invalid scope: "${scope}". Use lowercase letters/numbers with '-' or '_' (max 64 chars), e.g. "researcher".` });
|
|
209
|
+
return withWriteQueue(async () => {
|
|
210
|
+
const result = addTaskStore(phrenPath, project, item, { scope: normalizedScope });
|
|
211
|
+
if (!result.ok)
|
|
212
|
+
return mcpResponse({ ok: false, error: result.error });
|
|
213
|
+
refreshTaskIndex(updateFileInIndex, phrenPath, project);
|
|
214
|
+
return mcpResponse({ ok: true, message: `Task added: ${result.data.line}`, data: { project, item, scope: normalizedScope } });
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
server.registerTool("add_tasks", {
|
|
218
|
+
title: "◆ phren · add tasks (bulk)",
|
|
219
|
+
description: "Append multiple tasks to a project's tasks.md file in one call. Adds to the Queue section.",
|
|
220
|
+
inputSchema: z.object({
|
|
221
|
+
project: z.string().describe("Project name."),
|
|
222
|
+
items: z.array(z.string()).describe("List of tasks to add."),
|
|
223
|
+
}),
|
|
224
|
+
}, async ({ project, items }) => {
|
|
225
|
+
if (!isValidProjectName(project))
|
|
226
|
+
return mcpResponse({ ok: false, error: `Invalid project name: "${project}"` });
|
|
227
|
+
return withWriteQueue(async () => {
|
|
228
|
+
const result = addTasksBatch(phrenPath, project, items);
|
|
229
|
+
if (!result.ok)
|
|
230
|
+
return mcpResponse({ ok: false, error: result.error });
|
|
231
|
+
const { added, errors } = result.data;
|
|
232
|
+
if (added.length > 0)
|
|
233
|
+
refreshTaskIndex(updateFileInIndex, phrenPath, project);
|
|
234
|
+
return mcpResponse({ ok: added.length > 0, message: `Added ${added.length} of ${items.length} tasks to ${project}`, data: { project, added, errors } });
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
server.registerTool("complete_task", {
|
|
238
|
+
title: "◆ phren · done",
|
|
239
|
+
description: "Move a task to the Done section by matching text.",
|
|
240
|
+
inputSchema: z.object({
|
|
241
|
+
project: z.string().describe("Project name."),
|
|
242
|
+
item: z.string().describe("Exact or partial text of the item to complete."),
|
|
243
|
+
sessionId: z.string().optional().describe("Optional session ID from session_start. Pass this to track per-session task completion metrics."),
|
|
244
|
+
}),
|
|
245
|
+
}, async ({ project, item, sessionId }) => {
|
|
246
|
+
if (!isValidProjectName(project))
|
|
247
|
+
return mcpResponse({ ok: false, error: `Invalid project name: "${project}"` });
|
|
248
|
+
return withWriteQueue(async () => {
|
|
249
|
+
const before = resolveTaskItem(phrenPath, project, item);
|
|
250
|
+
const result = completeTaskStore(phrenPath, project, item);
|
|
251
|
+
if (!result.ok)
|
|
252
|
+
return mcpResponse({ ok: false, error: result.error });
|
|
253
|
+
if (before.ok) {
|
|
254
|
+
clearTaskCheckpoint(phrenPath, {
|
|
255
|
+
project,
|
|
256
|
+
taskId: before.data.stableId ?? before.data.id,
|
|
257
|
+
stableId: before.data.stableId,
|
|
258
|
+
positionalId: before.data.id,
|
|
259
|
+
taskLine: before.data.line,
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
incrementSessionTasksCompleted(phrenPath, 1, sessionId, project);
|
|
263
|
+
refreshTaskIndex(updateFileInIndex, phrenPath, project);
|
|
264
|
+
return mcpResponse({ ok: true, message: result.data, data: { project, item } });
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
server.registerTool("complete_tasks", {
|
|
268
|
+
title: "◆ phren · done (bulk)",
|
|
269
|
+
description: "Move multiple tasks to Done in one call. Pass an array of partial item texts.",
|
|
270
|
+
inputSchema: z.object({
|
|
271
|
+
project: z.string().describe("Project name."),
|
|
272
|
+
items: z.array(z.string()).describe("List of partial item texts to complete."),
|
|
273
|
+
sessionId: z.string().optional().describe("Optional session ID from session_start. Pass this to track per-session task completion metrics."),
|
|
274
|
+
}),
|
|
275
|
+
}, async ({ project, items, sessionId }) => {
|
|
276
|
+
if (!isValidProjectName(project))
|
|
277
|
+
return mcpResponse({ ok: false, error: `Invalid project name: "${project}"` });
|
|
278
|
+
return withWriteQueue(async () => {
|
|
279
|
+
const resolvedItems = items
|
|
280
|
+
.map((match) => {
|
|
281
|
+
const resolved = resolveTaskItem(phrenPath, project, match);
|
|
282
|
+
return resolved.ok ? resolved.data : null;
|
|
283
|
+
})
|
|
284
|
+
.filter((task) => task !== null);
|
|
285
|
+
const result = completeTasksBatch(phrenPath, project, items);
|
|
286
|
+
if (!result.ok)
|
|
287
|
+
return mcpResponse({ ok: false, error: result.error });
|
|
288
|
+
const { completed, errors } = result.data;
|
|
289
|
+
if (completed.length > 0) {
|
|
290
|
+
const completedSet = new Set(completed);
|
|
291
|
+
for (const task of resolvedItems) {
|
|
292
|
+
if (!completedSet.has(task.line))
|
|
293
|
+
continue;
|
|
294
|
+
clearTaskCheckpoint(phrenPath, {
|
|
295
|
+
project,
|
|
296
|
+
taskId: task.stableId ?? task.id,
|
|
297
|
+
stableId: task.stableId,
|
|
298
|
+
positionalId: task.id,
|
|
299
|
+
taskLine: task.line,
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
incrementSessionTasksCompleted(phrenPath, completed.length, sessionId, project);
|
|
303
|
+
}
|
|
304
|
+
if (completed.length > 0)
|
|
305
|
+
refreshTaskIndex(updateFileInIndex, phrenPath, project);
|
|
306
|
+
return mcpResponse({ ok: completed.length > 0, message: `Completed ${completed.length}/${items.length} items`, data: { project, completed, errors } });
|
|
307
|
+
});
|
|
308
|
+
});
|
|
309
|
+
server.registerTool("remove_task", {
|
|
310
|
+
title: "◆ phren · remove task",
|
|
311
|
+
description: "Remove a task from a project's tasks.md file by matching text or ID.",
|
|
312
|
+
inputSchema: z.object({
|
|
313
|
+
project: z.string().describe("Project name."),
|
|
314
|
+
item: z.string().describe("Exact or partial text of the task, or a task ID like A1/Q3/D2."),
|
|
315
|
+
}),
|
|
316
|
+
}, async ({ project, item }) => {
|
|
317
|
+
if (!isValidProjectName(project))
|
|
318
|
+
return mcpResponse({ ok: false, error: `Invalid project name: "${project}"` });
|
|
319
|
+
return withWriteQueue(async () => {
|
|
320
|
+
const result = removeTaskStore(phrenPath, project, item);
|
|
321
|
+
if (!result.ok)
|
|
322
|
+
return mcpResponse({ ok: false, error: result.error });
|
|
323
|
+
refreshTaskIndex(updateFileInIndex, phrenPath, project);
|
|
324
|
+
return mcpResponse({ ok: true, message: result.data, data: { project, item } });
|
|
325
|
+
});
|
|
326
|
+
});
|
|
327
|
+
server.registerTool("update_task", {
|
|
328
|
+
title: "◆ phren · update task",
|
|
329
|
+
description: "Update a task's priority, context, or section by matching text.",
|
|
330
|
+
inputSchema: z.object({
|
|
331
|
+
project: z.string().describe("Project name."),
|
|
332
|
+
item: z.string().describe("Partial text to match against existing tasks."),
|
|
333
|
+
updates: z.object({
|
|
334
|
+
priority: z.enum(["high", "medium", "low"]).optional().describe("New priority tag: high, medium, or low."),
|
|
335
|
+
context: z.string().optional().describe("Text to set on the Context: line below the task."),
|
|
336
|
+
replace_context: z.boolean().optional().describe("If true, replace the existing Context: value instead of appending."),
|
|
337
|
+
section: z.enum(["queue", "active", "done", "Queue", "Active", "Done"]).optional().describe("Move item to this section: Queue, Active, or Done."),
|
|
338
|
+
github_issue: z.union([z.number().int().positive(), z.string()]).optional().describe("GitHub issue number (for example 14 or '#14')."),
|
|
339
|
+
github_url: z.string().optional().describe("GitHub issue URL to associate with the task item."),
|
|
340
|
+
unlink_github: z.boolean().optional().describe("If true, remove any linked GitHub issue metadata from the item."),
|
|
341
|
+
}).describe("Fields to update. All are optional."),
|
|
342
|
+
}),
|
|
343
|
+
}, async ({ project, item, updates }) => {
|
|
344
|
+
if (!isValidProjectName(project))
|
|
345
|
+
return mcpResponse({ ok: false, error: `Invalid project name: "${project}"` });
|
|
346
|
+
return withWriteQueue(async () => {
|
|
347
|
+
const result = updateTaskStore(phrenPath, project, item, updates);
|
|
348
|
+
if (!result.ok)
|
|
349
|
+
return mcpResponse({ ok: false, error: result.error });
|
|
350
|
+
refreshTaskIndex(updateFileInIndex, phrenPath, project);
|
|
351
|
+
return mcpResponse({ ok: true, message: result.data, data: { project, item, updates } });
|
|
352
|
+
});
|
|
353
|
+
});
|
|
354
|
+
server.registerTool("link_task_issue", {
|
|
355
|
+
title: "◆ phren · link task issue",
|
|
356
|
+
description: "Link or unlink a task to an existing GitHub issue.",
|
|
357
|
+
inputSchema: z.object({
|
|
358
|
+
project: z.string().describe("Project name."),
|
|
359
|
+
item: z.string().describe("Task text, ID, or stable bid to link."),
|
|
360
|
+
issue_number: z.union([z.number().int().positive(), z.string()]).optional().describe("Existing GitHub issue number (for example 14 or '#14')."),
|
|
361
|
+
issue_url: z.string().optional().describe("Existing GitHub issue URL."),
|
|
362
|
+
unlink: z.boolean().optional().describe("If true, remove any linked issue from the task item."),
|
|
363
|
+
}),
|
|
364
|
+
}, async ({ project, item, issue_number, issue_url, unlink }) => {
|
|
365
|
+
if (!isValidProjectName(project))
|
|
366
|
+
return mcpResponse({ ok: false, error: `Invalid project name: "${project}"` });
|
|
367
|
+
return withWriteQueue(async () => {
|
|
368
|
+
if (unlink && (issue_number !== undefined || issue_url)) {
|
|
369
|
+
return mcpResponse({ ok: false, error: "Use either unlink=true or issue_number/issue_url, not both." });
|
|
370
|
+
}
|
|
371
|
+
if (!unlink && issue_number === undefined && !issue_url) {
|
|
372
|
+
return mcpResponse({ ok: false, error: "Provide issue_number or issue_url to link, or unlink=true to remove the link." });
|
|
373
|
+
}
|
|
374
|
+
if (issue_url) {
|
|
375
|
+
const parsed = parseGithubIssueUrl(issue_url);
|
|
376
|
+
if (!parsed)
|
|
377
|
+
return mcpResponse({ ok: false, error: "issue_url must be a valid GitHub issue URL." });
|
|
378
|
+
if (issue_number !== undefined) {
|
|
379
|
+
const normalizedIssue = Number.parseInt(String(issue_number).replace(/^#/, ""), 10);
|
|
380
|
+
if (normalizedIssue !== parsed.issueNumber) {
|
|
381
|
+
return mcpResponse({ ok: false, error: "issue_number and issue_url refer to different issues." });
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
const result = linkTaskIssue(phrenPath, project, item, {
|
|
386
|
+
github_issue: issue_number,
|
|
387
|
+
github_url: issue_url,
|
|
388
|
+
unlink: unlink ?? false,
|
|
389
|
+
});
|
|
390
|
+
if (!result.ok)
|
|
391
|
+
return mcpResponse({ ok: false, error: result.error, errorCode: result.code });
|
|
392
|
+
refreshTaskIndex(updateFileInIndex, phrenPath, project);
|
|
393
|
+
return mcpResponse({
|
|
394
|
+
ok: true,
|
|
395
|
+
message: unlink
|
|
396
|
+
? `Removed GitHub link from ${project} task.`
|
|
397
|
+
: `Linked ${project} task to ${result.data.githubIssue ? `#${result.data.githubIssue}` : result.data.githubUrl}.`,
|
|
398
|
+
data: {
|
|
399
|
+
project,
|
|
400
|
+
item,
|
|
401
|
+
stableId: result.data.stableId || null,
|
|
402
|
+
githubIssue: result.data.githubIssue ?? null,
|
|
403
|
+
githubUrl: result.data.githubUrl || null,
|
|
404
|
+
},
|
|
405
|
+
});
|
|
406
|
+
});
|
|
407
|
+
});
|
|
408
|
+
server.registerTool("promote_task_to_issue", {
|
|
409
|
+
title: "◆ phren · promote task",
|
|
410
|
+
description: "Create a GitHub issue from a task and link it back into the task list.",
|
|
411
|
+
inputSchema: z.object({
|
|
412
|
+
project: z.string().describe("Project name."),
|
|
413
|
+
item: z.string().describe("Task text, ID, or stable bid to promote."),
|
|
414
|
+
repo: z.string().optional().describe("Target GitHub repo in owner/name form. If omitted, phren tries to infer it from CLAUDE.md or summary.md."),
|
|
415
|
+
title: z.string().optional().describe("Optional GitHub issue title. Defaults to the task text."),
|
|
416
|
+
body: z.string().optional().describe("Optional GitHub issue body. Defaults to a body built from the task plus context."),
|
|
417
|
+
mark_done: z.boolean().optional().describe("If true, mark the task Done after creating and linking the issue."),
|
|
418
|
+
}),
|
|
419
|
+
}, async ({ project, item, repo, title, body, mark_done }) => {
|
|
420
|
+
if (!isValidProjectName(project))
|
|
421
|
+
return mcpResponse({ ok: false, error: `Invalid project name: "${project}"` });
|
|
422
|
+
return withWriteQueue(async () => {
|
|
423
|
+
const match = resolveTaskItem(phrenPath, project, item);
|
|
424
|
+
if (!match.ok)
|
|
425
|
+
return mcpResponse({ ok: false, error: match.error, errorCode: match.code });
|
|
426
|
+
const targetRepo = repo || resolveProjectGithubRepo(phrenPath, project);
|
|
427
|
+
if (!targetRepo) {
|
|
428
|
+
return mcpResponse({ ok: false, error: "Could not infer a GitHub repo for this project. Provide repo in owner/name form or add a GitHub URL to CLAUDE.md/summary.md." });
|
|
429
|
+
}
|
|
430
|
+
const created = createGithubIssueForTask({
|
|
431
|
+
repo: targetRepo,
|
|
432
|
+
title: title?.trim() || match.data.line.replace(/\s*\[(high|medium|low)\]\s*$/i, "").trim(),
|
|
433
|
+
body: body?.trim() || buildTaskIssueBody(project, match.data),
|
|
434
|
+
});
|
|
435
|
+
if (!created.ok)
|
|
436
|
+
return mcpResponse({ ok: false, error: created.error, errorCode: created.code });
|
|
437
|
+
const linked = linkTaskIssue(phrenPath, project, match.data.stableId ? `bid:${match.data.stableId}` : match.data.id, {
|
|
438
|
+
github_issue: created.data.issueNumber,
|
|
439
|
+
github_url: created.data.url,
|
|
440
|
+
});
|
|
441
|
+
if (!linked.ok)
|
|
442
|
+
return mcpResponse({ ok: false, error: linked.error, errorCode: linked.code });
|
|
443
|
+
if (mark_done) {
|
|
444
|
+
const completionMatch = linked.data.stableId ? `bid:${linked.data.stableId}` : linked.data.id;
|
|
445
|
+
const completed = completeTaskStore(phrenPath, project, completionMatch);
|
|
446
|
+
if (!completed.ok)
|
|
447
|
+
return mcpResponse({ ok: false, error: completed.error, errorCode: completed.code });
|
|
448
|
+
clearTaskCheckpoint(phrenPath, {
|
|
449
|
+
project,
|
|
450
|
+
taskId: match.data.stableId ?? match.data.id,
|
|
451
|
+
stableId: match.data.stableId,
|
|
452
|
+
positionalId: match.data.id,
|
|
453
|
+
taskLine: match.data.line,
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
refreshTaskIndex(updateFileInIndex, phrenPath, project);
|
|
457
|
+
return mcpResponse({
|
|
458
|
+
ok: true,
|
|
459
|
+
message: `Created GitHub issue ${created.data.issueNumber ? `#${created.data.issueNumber}` : created.data.url} for ${project} task.`,
|
|
460
|
+
data: {
|
|
461
|
+
project,
|
|
462
|
+
item,
|
|
463
|
+
repo: targetRepo,
|
|
464
|
+
githubIssue: created.data.issueNumber ?? null,
|
|
465
|
+
githubUrl: created.data.url,
|
|
466
|
+
markDone: mark_done ?? false,
|
|
467
|
+
},
|
|
468
|
+
});
|
|
469
|
+
});
|
|
470
|
+
});
|
|
471
|
+
server.registerTool("pin_task", {
|
|
472
|
+
title: "◆ phren · pin task",
|
|
473
|
+
description: "Pin a task so it floats to the top of its section.",
|
|
474
|
+
inputSchema: z.object({
|
|
475
|
+
project: z.string().describe("Project name."),
|
|
476
|
+
item: z.string().describe("Partial item text or ID to pin."),
|
|
477
|
+
}),
|
|
478
|
+
}, async ({ project, item }) => {
|
|
479
|
+
if (!isValidProjectName(project))
|
|
480
|
+
return mcpResponse({ ok: false, error: `Invalid project name: "${project}"` });
|
|
481
|
+
return withWriteQueue(async () => {
|
|
482
|
+
const result = pinTask(phrenPath, project, item);
|
|
483
|
+
if (!result.ok)
|
|
484
|
+
return mcpResponse({ ok: false, error: result.error });
|
|
485
|
+
refreshTaskIndex(updateFileInIndex, phrenPath, project);
|
|
486
|
+
return mcpResponse({ ok: true, message: result.data, data: { project, item } });
|
|
487
|
+
});
|
|
488
|
+
});
|
|
489
|
+
server.registerTool("work_next_task", {
|
|
490
|
+
title: "◆ phren · work next",
|
|
491
|
+
description: "Move the highest-priority Queue item to Active so it becomes the next task to work on.",
|
|
492
|
+
inputSchema: z.object({
|
|
493
|
+
project: z.string().describe("Project name."),
|
|
494
|
+
}),
|
|
495
|
+
}, async ({ project }) => {
|
|
496
|
+
if (!isValidProjectName(project))
|
|
497
|
+
return mcpResponse({ ok: false, error: `Invalid project name: "${project}"` });
|
|
498
|
+
return withWriteQueue(async () => {
|
|
499
|
+
const result = workNextTask(phrenPath, project);
|
|
500
|
+
if (!result.ok)
|
|
501
|
+
return mcpResponse({ ok: false, error: result.error });
|
|
502
|
+
refreshTaskIndex(updateFileInIndex, phrenPath, project);
|
|
503
|
+
return mcpResponse({ ok: true, message: result.data, data: { project } });
|
|
504
|
+
});
|
|
505
|
+
});
|
|
506
|
+
server.registerTool("promote_task", {
|
|
507
|
+
title: "◆ phren · promote task",
|
|
508
|
+
description: "Promote a speculative task to committed by clearing the speculative flag. " +
|
|
509
|
+
"Use this when the user says 'yes do it', 'let's work on that', or otherwise confirms " +
|
|
510
|
+
"they want to commit to a suggested task. Optionally moves it to Active.",
|
|
511
|
+
inputSchema: z.object({
|
|
512
|
+
project: z.string().describe("Project name."),
|
|
513
|
+
item: z.string().describe("Partial text, stable ID (bid:XXXX), or positional ID of the speculative task."),
|
|
514
|
+
move_to_active: z.boolean().optional().describe("If true, also move the task to the Active section. Default false."),
|
|
515
|
+
}),
|
|
516
|
+
}, async ({ project, item, move_to_active }) => {
|
|
517
|
+
if (!isValidProjectName(project))
|
|
518
|
+
return mcpResponse({ ok: false, error: `Invalid project name: "${project}"` });
|
|
519
|
+
return withWriteQueue(async () => {
|
|
520
|
+
const result = promoteTask(phrenPath, project, item, move_to_active ?? false);
|
|
521
|
+
if (!result.ok)
|
|
522
|
+
return mcpResponse({ ok: false, error: result.error });
|
|
523
|
+
refreshTaskIndex(updateFileInIndex, phrenPath, project);
|
|
524
|
+
return mcpResponse({
|
|
525
|
+
ok: true,
|
|
526
|
+
message: `Promoted task "${result.data.line}" in ${project}${move_to_active ? " (moved to Active)" : ""}.`,
|
|
527
|
+
data: { project, item: result.data },
|
|
528
|
+
});
|
|
529
|
+
});
|
|
530
|
+
});
|
|
531
|
+
server.registerTool("tidy_done_tasks", {
|
|
532
|
+
title: "◆ phren · tidy done",
|
|
533
|
+
description: "Archive old Done items beyond the keep limit to keep the task list tidy.",
|
|
534
|
+
inputSchema: z.object({
|
|
535
|
+
project: z.string().describe("Project name."),
|
|
536
|
+
keep: z.number().optional().describe("Number of recent Done items to keep. Default 30."),
|
|
537
|
+
dry_run: z.boolean().optional().describe("If true, preview changes without writing."),
|
|
538
|
+
}),
|
|
539
|
+
}, async ({ project, keep, dry_run }) => {
|
|
540
|
+
if (!isValidProjectName(project))
|
|
541
|
+
return mcpResponse({ ok: false, error: `Invalid project name: "${project}"` });
|
|
542
|
+
return withWriteQueue(async () => {
|
|
543
|
+
const result = tidyDoneTasks(phrenPath, project, keep ?? 30, dry_run ?? false);
|
|
544
|
+
if (!result.ok)
|
|
545
|
+
return mcpResponse({ ok: false, error: result.error });
|
|
546
|
+
if (!dry_run)
|
|
547
|
+
refreshTaskIndex(updateFileInIndex, phrenPath, project);
|
|
548
|
+
return mcpResponse({ ok: true, message: result.data, data: { project, keep: keep ?? 30, dryRun: dry_run ?? false } });
|
|
549
|
+
});
|
|
550
|
+
});
|
|
551
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Convert an McpToolResult into the MCP SDK response format.
|
|
3
|
+
* Single shared implementation — replaces the per-file jsonResponse() duplicates.
|
|
4
|
+
*/
|
|
5
|
+
export function mcpResponse(payload) {
|
|
6
|
+
return { content: [{ type: "text", text: JSON.stringify(payload, null, 2) }] };
|
|
7
|
+
}
|