@phren/cli 0.0.28 → 0.0.32

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.
Files changed (147) hide show
  1. package/mcp/dist/capabilities/cli.js +2 -5
  2. package/mcp/dist/capabilities/mcp.js +5 -8
  3. package/mcp/dist/capabilities/types.js +2 -5
  4. package/mcp/dist/capabilities/vscode.js +2 -5
  5. package/mcp/dist/capabilities/web-ui.js +2 -5
  6. package/mcp/dist/{cli-actions.js → cli/actions.js} +22 -21
  7. package/mcp/dist/{cli.js → cli/cli.js} +13 -13
  8. package/mcp/dist/{cli-config.js → cli/config.js} +9 -9
  9. package/mcp/dist/{cli-extract.js → cli/extract.js} +8 -8
  10. package/mcp/dist/{cli-govern.js → cli/govern.js} +10 -9
  11. package/mcp/dist/{cli-graph.js → cli/graph.js} +10 -9
  12. package/mcp/dist/{cli-hooks-citations.js → cli/hooks-citations.js} +2 -2
  13. package/mcp/dist/{cli-hooks-context.js → cli/hooks-context.js} +23 -23
  14. package/mcp/dist/{cli-hooks-globs.js → cli/hooks-globs.js} +4 -4
  15. package/mcp/dist/{cli-hooks-output.js → cli/hooks-output.js} +9 -10
  16. package/mcp/dist/{cli-hooks-session.js → cli/hooks-session.js} +42 -57
  17. package/mcp/dist/{cli-hooks.js → cli/hooks.js} +27 -26
  18. package/mcp/dist/{cli-namespaces.js → cli/namespaces.js} +25 -24
  19. package/mcp/dist/{cli-ops.js → cli/ops.js} +9 -9
  20. package/mcp/dist/{cli-search.js → cli/search.js} +8 -7
  21. package/mcp/dist/cli-hooks-git.js +243 -0
  22. package/mcp/dist/cli-hooks-prompt.js +319 -0
  23. package/mcp/dist/cli-hooks-session-handlers.js +349 -0
  24. package/mcp/dist/cli-hooks-stop.js +557 -0
  25. package/mcp/dist/{content-archive.js → content/archive.js} +8 -9
  26. package/mcp/dist/{content-citation.js → content/citation.js} +5 -5
  27. package/mcp/dist/{content-dedup.js → content/dedup.js} +9 -12
  28. package/mcp/dist/{content-learning.js → content/learning.js} +12 -12
  29. package/mcp/dist/{content-validate.js → content/validate.js} +5 -5
  30. package/mcp/dist/{core-finding.js → core/finding.js} +4 -4
  31. package/mcp/dist/{core-project.js → core/project.js} +4 -4
  32. package/mcp/dist/{core-search.js → core/search.js} +2 -2
  33. package/mcp/dist/{data-access.js → data/access.js} +131 -13
  34. package/mcp/dist/{data-tasks.js → data/tasks.js} +7 -5
  35. package/mcp/dist/embedding.js +9 -14
  36. package/mcp/dist/entrypoint.js +11 -11
  37. package/mcp/dist/{finding-context.js → finding/context.js} +2 -2
  38. package/mcp/dist/{finding-impact.js → finding/impact.js} +3 -3
  39. package/mcp/dist/{finding-journal.js → finding/journal.js} +4 -4
  40. package/mcp/dist/{finding-lifecycle.js → finding/lifecycle.js} +4 -4
  41. package/mcp/dist/{governance-audit.js → governance/audit.js} +2 -2
  42. package/mcp/dist/{governance-locks.js → governance/locks.js} +14 -9
  43. package/mcp/dist/{governance-policy.js → governance/policy.js} +10 -12
  44. package/mcp/dist/{governance-rbac.js → governance/rbac.js} +3 -3
  45. package/mcp/dist/{governance-scores.js → governance/scores.js} +8 -10
  46. package/mcp/dist/hooks.js +39 -31
  47. package/mcp/dist/index-query.js +4 -1
  48. package/mcp/dist/index.js +53 -29
  49. package/mcp/dist/{init-config.js → init/config.js} +6 -6
  50. package/mcp/dist/{init.js → init/init.js} +28 -29
  51. package/mcp/dist/{init-preferences.js → init/preferences.js} +3 -3
  52. package/mcp/dist/{init-setup.js → init/setup.js} +17 -19
  53. package/mcp/dist/{init-shared.js → init/shared.js} +3 -3
  54. package/mcp/dist/init-bootstrap.js +68 -0
  55. package/mcp/dist/init-detect.js +38 -0
  56. package/mcp/dist/init-dryrun.js +55 -0
  57. package/mcp/dist/init-env.js +114 -0
  58. package/mcp/dist/init-fresh.js +239 -0
  59. package/mcp/dist/init-hooks.js +26 -0
  60. package/mcp/dist/init-mcp.js +65 -0
  61. package/mcp/dist/init-migrate.js +51 -0
  62. package/mcp/dist/init-modes.js +135 -0
  63. package/mcp/dist/init-npm.js +37 -0
  64. package/mcp/dist/init-project-local.js +99 -0
  65. package/mcp/dist/init-semantic.js +48 -0
  66. package/mcp/dist/init-types.js +1 -0
  67. package/mcp/dist/init-uninstall.js +482 -0
  68. package/mcp/dist/init-update.js +96 -0
  69. package/mcp/dist/init-walkthrough-merge.js +90 -0
  70. package/mcp/dist/init-walkthrough.js +529 -0
  71. package/mcp/dist/{link-checksums.js → link/checksums.js} +5 -5
  72. package/mcp/dist/{link-context.js → link/context.js} +4 -4
  73. package/mcp/dist/{link-doctor.js → link/doctor.js} +20 -22
  74. package/mcp/dist/{link.js → link/link.js} +26 -31
  75. package/mcp/dist/{link-skills.js → link/skills.js} +10 -10
  76. package/mcp/dist/logger.js +11 -3
  77. package/mcp/dist/phren-art.js +0 -6
  78. package/mcp/dist/phren-paths.js +30 -12
  79. package/mcp/dist/proactivity.js +2 -2
  80. package/mcp/dist/profile-store.js +5 -6
  81. package/mcp/dist/project-config.js +2 -2
  82. package/mcp/dist/project-topics.js +1 -1
  83. package/mcp/dist/query-correlation.js +1 -1
  84. package/mcp/dist/{session-checkpoints.js → session/checkpoints.js} +3 -3
  85. package/mcp/dist/{session-utils.js → session/utils.js} +1 -1
  86. package/mcp/dist/{shared-content.js → shared/content.js} +7 -7
  87. package/mcp/dist/{shared-data-utils.js → shared/data-utils.js} +3 -3
  88. package/mcp/dist/{shared-embedding-cache.js → shared/embedding-cache.js} +3 -3
  89. package/mcp/dist/{shared-fragment-graph.js → shared/fragment-graph.js} +15 -24
  90. package/mcp/dist/shared/governance.js +4 -0
  91. package/mcp/dist/{shared-index.js → shared/index.js} +92 -123
  92. package/mcp/dist/{shared-ollama.js → shared/ollama.js} +2 -2
  93. package/mcp/dist/{shared-retrieval.js → shared/retrieval.js} +16 -21
  94. package/mcp/dist/{shared-search-fallback.js → shared/search-fallback.js} +17 -20
  95. package/mcp/dist/{shared-sqljs.js → shared/sqljs.js} +3 -3
  96. package/mcp/dist/{shared-vector-index.js → shared/vector-index.js} +3 -3
  97. package/mcp/dist/shared.js +4 -59
  98. package/mcp/dist/{shell-entry.js → shell/entry.js} +6 -6
  99. package/mcp/dist/{shell-input.js → shell/input.js} +13 -13
  100. package/mcp/dist/{shell-palette.js → shell/palette.js} +3 -3
  101. package/mcp/dist/{shell-render.js → shell/render.js} +1 -1
  102. package/mcp/dist/{shell.js → shell/shell.js} +11 -11
  103. package/mcp/dist/{shell-state-store.js → shell/state-store.js} +5 -5
  104. package/mcp/dist/{shell-view-list.js → shell/view-list.js} +1 -1
  105. package/mcp/dist/{shell-view.js → shell/view.js} +13 -13
  106. package/mcp/dist/{skill-files.js → skill/files.js} +9 -9
  107. package/mcp/dist/{skill-registry.js → skill/registry.js} +4 -4
  108. package/mcp/dist/{skill-state.js → skill/state.js} +1 -1
  109. package/mcp/dist/startup-embedding.js +2 -2
  110. package/mcp/dist/status.js +15 -14
  111. package/mcp/dist/{tasks-github.js → task/github.js} +2 -2
  112. package/mcp/dist/{task-hygiene.js → task/hygiene.js} +4 -4
  113. package/mcp/dist/{task-lifecycle.js → task/lifecycle.js} +7 -7
  114. package/mcp/dist/telemetry.js +3 -4
  115. package/mcp/dist/tool-registry.js +29 -17
  116. package/mcp/dist/tools/config.js +515 -0
  117. package/mcp/dist/{mcp-data.js → tools/data.js} +8 -10
  118. package/mcp/dist/{mcp-extract-facts.js → tools/extract-facts.js} +6 -6
  119. package/mcp/dist/{mcp-extract.js → tools/extract.js} +6 -6
  120. package/mcp/dist/{mcp-finding.js → tools/finding.js} +97 -124
  121. package/mcp/dist/{mcp-graph.js → tools/graph.js} +11 -14
  122. package/mcp/dist/{mcp-hooks.js → tools/hooks.js} +6 -6
  123. package/mcp/dist/{mcp-memory.js → tools/memory.js} +5 -5
  124. package/mcp/dist/{mcp-ops.js → tools/ops.js} +169 -71
  125. package/mcp/dist/{mcp-search.js → tools/search.js} +19 -23
  126. package/mcp/dist/{mcp-session.js → tools/session.js} +48 -23
  127. package/mcp/dist/{mcp-skills.js → tools/skills.js} +33 -35
  128. package/mcp/dist/{mcp-tasks.js → tools/tasks.js} +155 -282
  129. package/mcp/dist/{memory-ui-data.js → ui/data.js} +31 -17
  130. package/mcp/dist/{memory-ui.js → ui/memory-ui.js} +3 -3
  131. package/mcp/dist/{memory-ui-page.js → ui/page.js} +4 -6
  132. package/mcp/dist/{memory-ui-server.js → ui/server.js} +30 -22
  133. package/mcp/dist/update.js +2 -2
  134. package/mcp/dist/utils.js +51 -11
  135. package/package.json +2 -2
  136. package/scripts/preuninstall.mjs +31 -0
  137. package/starter/global/CLAUDE.md +3 -2
  138. package/mcp/dist/mcp-config.js +0 -551
  139. package/mcp/dist/shared-governance.js +0 -4
  140. /package/mcp/dist/{content-metadata.js → content/metadata.js} +0 -0
  141. /package/mcp/dist/{shared-stemmer.js → shared/stemmer.js} +0 -0
  142. /package/mcp/dist/{shell-types.js → shell/types.js} +0 -0
  143. /package/mcp/dist/{mcp-types.js → tools/types.js} +0 -0
  144. /package/mcp/dist/{memory-ui-assets.js → ui/assets.js} +0 -0
  145. /package/mcp/dist/{memory-ui-graph.js → ui/graph.js} +0 -0
  146. /package/mcp/dist/{memory-ui-scripts.js → ui/scripts.js} +0 -0
  147. /package/mcp/dist/{memory-ui-styles.js → ui/styles.js} +0 -0
@@ -1,15 +1,15 @@
1
- import { mcpResponse } from "./mcp-types.js";
1
+ import { mcpResponse } from "./types.js";
2
2
  import { z } from "zod";
3
3
  import * as fs from "fs";
4
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, removeTasks as removeTasksBatch, 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
- import { permissionDeniedError } from "./governance-rbac.js";
5
+ import { isValidProjectName } from "../utils.js";
6
+ import { addTask as addTaskStore, addTasks as addTasksBatch, taskMarkdown, completeTask as completeTaskStore, completeTasks as completeTasksBatch, removeTask as removeTaskStore, removeTasks as removeTasksBatch, 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 { parseGithubIssueUrl, } from "../task/github.js";
9
+ import { clearTaskCheckpoint } from "../session/checkpoints.js";
10
+ import { incrementSessionTasksCompleted } from "./session.js";
11
+ import { normalizeMemoryScope } from "../shared.js";
12
+ import { permissionDeniedError } from "../governance/rbac.js";
13
13
  const TASK_SECTION_ORDER = ["Active", "Queue", "Done"];
14
14
  const DEFAULT_TASK_LIMIT = 20;
15
15
  /** Done items are historical — cap tightly by default to avoid large responses. */
@@ -198,10 +198,13 @@ export function register(server, ctx) {
198
198
  });
199
199
  server.registerTool("add_task", {
200
200
  title: "◆ phren · add task",
201
- description: "Append a task to a project's tasks.md file. Adds to the Queue section.",
201
+ description: "Append one or more tasks to a project's tasks.md file. Adds to the Queue section. Pass a single string or an array of strings.",
202
202
  inputSchema: z.object({
203
203
  project: z.string().describe("Project name (must match a directory in your phren)."),
204
- item: z.string().describe("The task to add."),
204
+ item: z.union([
205
+ z.string().describe("A single task to add."),
206
+ z.array(z.string()).describe("Multiple tasks to add in one call."),
207
+ ]).describe("The task(s) to add. Pass a string for one task, or an array for bulk."),
205
208
  scope: z.string().optional().describe("Optional memory scope label. Defaults to 'shared'. Example: 'researcher' or 'builder'."),
206
209
  }),
207
210
  }, async ({ project, item, scope }) => {
@@ -213,6 +216,17 @@ export function register(server, ctx) {
213
216
  const normalizedScope = normalizeMemoryScope(scope ?? "shared");
214
217
  if (!normalizedScope)
215
218
  return mcpResponse({ ok: false, error: `Invalid scope: "${scope}". Use lowercase letters/numbers with '-' or '_' (max 64 chars), e.g. "researcher".` });
219
+ if (Array.isArray(item)) {
220
+ return withWriteQueue(async () => {
221
+ const result = addTasksBatch(phrenPath, project, item, { scope: normalizedScope });
222
+ if (!result.ok)
223
+ return mcpResponse({ ok: false, error: result.error });
224
+ const { added, errors } = result.data;
225
+ if (added.length > 0)
226
+ refreshTaskIndex(updateFileInIndex, phrenPath, project);
227
+ return mcpResponse({ ok: added.length > 0, ...(added.length === 0 ? { error: `No tasks added: ${errors.join("; ")}` } : {}), message: `Added ${added.length} of ${item.length} tasks to ${project}`, data: { project, added, errors } });
228
+ });
229
+ }
216
230
  return withWriteQueue(async () => {
217
231
  const result = addTaskStore(phrenPath, project, item, { scope: normalizedScope });
218
232
  if (!result.ok)
@@ -221,35 +235,15 @@ export function register(server, ctx) {
221
235
  return mcpResponse({ ok: true, message: `Task added: ${result.data.line}`, data: { project, item, scope: normalizedScope } });
222
236
  });
223
237
  });
224
- server.registerTool("add_tasks", {
225
- title: "◆ phren · add tasks (bulk)",
226
- description: "Append multiple tasks to a project's tasks.md file in one call. Adds to the Queue section.",
227
- inputSchema: z.object({
228
- project: z.string().describe("Project name."),
229
- items: z.array(z.string()).describe("List of tasks to add."),
230
- }),
231
- }, async ({ project, items }) => {
232
- if (!isValidProjectName(project))
233
- return mcpResponse({ ok: false, error: `Invalid project name: "${project}"` });
234
- const addTasksDenied = permissionDeniedError(phrenPath, "add_task", project);
235
- if (addTasksDenied)
236
- return mcpResponse({ ok: false, error: addTasksDenied });
237
- return withWriteQueue(async () => {
238
- const result = addTasksBatch(phrenPath, project, items);
239
- if (!result.ok)
240
- return mcpResponse({ ok: false, error: result.error });
241
- const { added, errors } = result.data;
242
- if (added.length > 0)
243
- refreshTaskIndex(updateFileInIndex, phrenPath, project);
244
- return mcpResponse({ ok: added.length > 0, ...(added.length === 0 ? { error: `No tasks added: ${errors.join("; ")}` } : {}), message: `Added ${added.length} of ${items.length} tasks to ${project}`, data: { project, added, errors } });
245
- });
246
- });
247
238
  server.registerTool("complete_task", {
248
239
  title: "◆ phren · done",
249
- description: "Move a task to the Done section by matching text.",
240
+ description: "Move one or more tasks to the Done section by matching text. Pass a single string or an array of strings.",
250
241
  inputSchema: z.object({
251
242
  project: z.string().describe("Project name."),
252
- item: z.string().describe("Exact or partial text of the item to complete."),
243
+ item: z.union([
244
+ z.string().describe("Exact or partial text of the item to complete."),
245
+ z.array(z.string()).describe("List of partial item texts to complete."),
246
+ ]).describe("The task(s) to complete. Pass a string for one, or an array for bulk."),
253
247
  sessionId: z.string().optional().describe("Optional session ID from session_start. Pass this to track per-session task completion metrics."),
254
248
  }),
255
249
  }, async ({ project, item, sessionId }) => {
@@ -258,6 +252,38 @@ export function register(server, ctx) {
258
252
  const completeTaskDenied = permissionDeniedError(phrenPath, "complete_task", project);
259
253
  if (completeTaskDenied)
260
254
  return mcpResponse({ ok: false, error: completeTaskDenied });
255
+ if (Array.isArray(item)) {
256
+ return withWriteQueue(async () => {
257
+ const resolvedItems = item
258
+ .map((match) => {
259
+ const resolved = resolveTaskItem(phrenPath, project, match);
260
+ return resolved.ok ? resolved.data : null;
261
+ })
262
+ .filter((task) => task !== null);
263
+ const result = completeTasksBatch(phrenPath, project, item);
264
+ if (!result.ok)
265
+ return mcpResponse({ ok: false, error: result.error });
266
+ const { completed, errors } = result.data;
267
+ if (completed.length > 0) {
268
+ const completedSet = new Set(completed);
269
+ for (const task of resolvedItems) {
270
+ if (!completedSet.has(task.line))
271
+ continue;
272
+ clearTaskCheckpoint(phrenPath, {
273
+ project,
274
+ taskId: task.stableId ?? task.id,
275
+ stableId: task.stableId,
276
+ positionalId: task.id,
277
+ taskLine: task.line,
278
+ });
279
+ }
280
+ incrementSessionTasksCompleted(phrenPath, completed.length, sessionId, project);
281
+ }
282
+ if (completed.length > 0)
283
+ refreshTaskIndex(updateFileInIndex, phrenPath, project);
284
+ return mcpResponse({ ok: completed.length > 0, ...(completed.length === 0 ? { error: `No tasks completed: ${errors.join("; ")}` } : {}), message: `Completed ${completed.length}/${item.length} items`, data: { project, completed, errors } });
285
+ });
286
+ }
261
287
  return withWriteQueue(async () => {
262
288
  const before = resolveTaskItem(phrenPath, project, item);
263
289
  const result = completeTaskStore(phrenPath, project, item);
@@ -277,57 +303,15 @@ export function register(server, ctx) {
277
303
  return mcpResponse({ ok: true, message: result.data, data: { project, item } });
278
304
  });
279
305
  });
280
- server.registerTool("complete_tasks", {
281
- title: "◆ phren · done (bulk)",
282
- description: "Move multiple tasks to Done in one call. Pass an array of partial item texts.",
283
- inputSchema: z.object({
284
- project: z.string().describe("Project name."),
285
- items: z.array(z.string()).describe("List of partial item texts to complete."),
286
- sessionId: z.string().optional().describe("Optional session ID from session_start. Pass this to track per-session task completion metrics."),
287
- }),
288
- }, async ({ project, items, sessionId }) => {
289
- if (!isValidProjectName(project))
290
- return mcpResponse({ ok: false, error: `Invalid project name: "${project}"` });
291
- const completeTasksDenied = permissionDeniedError(phrenPath, "complete_task", project);
292
- if (completeTasksDenied)
293
- return mcpResponse({ ok: false, error: completeTasksDenied });
294
- return withWriteQueue(async () => {
295
- const resolvedItems = items
296
- .map((match) => {
297
- const resolved = resolveTaskItem(phrenPath, project, match);
298
- return resolved.ok ? resolved.data : null;
299
- })
300
- .filter((task) => task !== null);
301
- const result = completeTasksBatch(phrenPath, project, items);
302
- if (!result.ok)
303
- return mcpResponse({ ok: false, error: result.error });
304
- const { completed, errors } = result.data;
305
- if (completed.length > 0) {
306
- const completedSet = new Set(completed);
307
- for (const task of resolvedItems) {
308
- if (!completedSet.has(task.line))
309
- continue;
310
- clearTaskCheckpoint(phrenPath, {
311
- project,
312
- taskId: task.stableId ?? task.id,
313
- stableId: task.stableId,
314
- positionalId: task.id,
315
- taskLine: task.line,
316
- });
317
- }
318
- incrementSessionTasksCompleted(phrenPath, completed.length, sessionId, project);
319
- }
320
- if (completed.length > 0)
321
- refreshTaskIndex(updateFileInIndex, phrenPath, project);
322
- return mcpResponse({ ok: completed.length > 0, ...(completed.length === 0 ? { error: `No tasks completed: ${errors.join("; ")}` } : {}), message: `Completed ${completed.length}/${items.length} items`, data: { project, completed, errors } });
323
- });
324
- });
325
306
  server.registerTool("remove_task", {
326
307
  title: "◆ phren · remove task",
327
- description: "Remove a task from a project's tasks.md file by matching text or ID.",
308
+ description: "Remove one or more tasks from a project's tasks.md file by matching text or ID. Pass a single string or an array of strings.",
328
309
  inputSchema: z.object({
329
310
  project: z.string().describe("Project name."),
330
- item: z.string().describe("Exact or partial text of the task, or a task ID like A1/Q3/D2."),
311
+ item: z.union([
312
+ z.string().describe("Exact or partial text of the task, or a task ID like A1/Q3/D2."),
313
+ z.array(z.string()).describe("List of partial item texts or IDs to remove."),
314
+ ]).describe("The task(s) to remove. Pass a string for one, or an array for bulk."),
331
315
  }),
332
316
  }, async ({ project, item }) => {
333
317
  if (!isValidProjectName(project))
@@ -335,6 +319,17 @@ export function register(server, ctx) {
335
319
  const removeTaskDenied = permissionDeniedError(phrenPath, "remove_task", project);
336
320
  if (removeTaskDenied)
337
321
  return mcpResponse({ ok: false, error: removeTaskDenied });
322
+ if (Array.isArray(item)) {
323
+ return withWriteQueue(async () => {
324
+ const result = removeTasksBatch(phrenPath, project, item);
325
+ if (!result.ok)
326
+ return mcpResponse({ ok: false, error: result.error });
327
+ const { removed, errors } = result.data;
328
+ if (removed.length > 0)
329
+ refreshTaskIndex(updateFileInIndex, phrenPath, project);
330
+ return mcpResponse({ ok: removed.length > 0, ...(removed.length === 0 ? { error: `No tasks removed: ${errors.join("; ")}` } : {}), message: `Removed ${removed.length}/${item.length} items`, data: { project, removed, errors } });
331
+ });
332
+ }
338
333
  return withWriteQueue(async () => {
339
334
  const result = removeTaskStore(phrenPath, project, item);
340
335
  if (!result.ok)
@@ -343,35 +338,14 @@ export function register(server, ctx) {
343
338
  return mcpResponse({ ok: true, message: result.data, data: { project, item } });
344
339
  });
345
340
  });
346
- server.registerTool("remove_tasks", {
347
- title: "◆ phren · remove tasks (bulk)",
348
- description: "Remove multiple tasks in one call. Pass an array of partial item texts or IDs.",
349
- inputSchema: z.object({
350
- project: z.string().describe("Project name."),
351
- items: z.array(z.string()).describe("List of partial item texts or IDs to remove."),
352
- }),
353
- }, async ({ project, items }) => {
354
- if (!isValidProjectName(project))
355
- return mcpResponse({ ok: false, error: `Invalid project name: "${project}"` });
356
- const removeTasksDenied = permissionDeniedError(phrenPath, "remove_task", project);
357
- if (removeTasksDenied)
358
- return mcpResponse({ ok: false, error: removeTasksDenied });
359
- return withWriteQueue(async () => {
360
- const result = removeTasksBatch(phrenPath, project, items);
361
- if (!result.ok)
362
- return mcpResponse({ ok: false, error: result.error });
363
- const { removed, errors } = result.data;
364
- if (removed.length > 0)
365
- refreshTaskIndex(updateFileInIndex, phrenPath, project);
366
- return mcpResponse({ ok: removed.length > 0, ...(removed.length === 0 ? { error: `No tasks removed: ${errors.join("; ")}` } : {}), message: `Removed ${removed.length}/${items.length} items`, data: { project, removed, errors } });
367
- });
368
- });
369
341
  server.registerTool("update_task", {
370
342
  title: "◆ phren · update task",
371
- description: "Update a task's text, priority, context, section, or GitHub metadata by matching text.",
343
+ description: "Update a task's text, priority, context, section, GitHub metadata, pin status, or promote it. " +
344
+ "Also supports work_next (pick highest-priority Queue item) and promote (clear speculative flag). " +
345
+ "When work_next is true, item is not needed.",
372
346
  inputSchema: z.object({
373
347
  project: z.string().describe("Project name."),
374
- item: z.string().describe("Partial text to match against existing tasks."),
348
+ item: z.string().optional().describe("Partial text to match against existing tasks. Required unless work_next is true."),
375
349
  updates: z.object({
376
350
  text: z.string().optional().describe("Replacement text for the task line."),
377
351
  priority: z.enum(["high", "medium", "low"]).optional().describe("New priority tag: high, medium, or low."),
@@ -381,6 +355,10 @@ export function register(server, ctx) {
381
355
  github_issue: z.union([z.number().int().positive(), z.string()]).optional().describe("GitHub issue number (for example 14 or '#14')."),
382
356
  github_url: z.string().optional().describe("GitHub issue URL to associate with the task item."),
383
357
  unlink_github: z.boolean().optional().describe("If true, remove any linked GitHub issue metadata from the item."),
358
+ pin: z.boolean().optional().describe("If true, pin the task so it floats to the top of its section."),
359
+ promote: z.boolean().optional().describe("If true, clear the speculative flag on this task (confirm the user wants it)."),
360
+ move_to_active: z.boolean().optional().describe("Used with promote: also move the task to the Active section."),
361
+ work_next: z.boolean().optional().describe("If true, pick the highest-priority Queue item and move it to Active. Ignores item param."),
384
362
  }).describe("Fields to update. All are optional."),
385
363
  }),
386
364
  }, async ({ project, item, updates }) => {
@@ -389,189 +367,84 @@ export function register(server, ctx) {
389
367
  const updateTaskDenied = permissionDeniedError(phrenPath, "update_task", project);
390
368
  if (updateTaskDenied)
391
369
  return mcpResponse({ ok: false, error: updateTaskDenied });
392
- return withWriteQueue(async () => {
393
- const result = updateTaskStore(phrenPath, project, item, updates);
394
- if (!result.ok)
395
- return mcpResponse({ ok: false, error: result.error });
396
- refreshTaskIndex(updateFileInIndex, phrenPath, project);
397
- return mcpResponse({ ok: true, message: result.data, data: { project, item, updates } });
398
- });
399
- });
400
- server.registerTool("link_task_issue", {
401
- title: "◆ phren · link task issue",
402
- description: "Link or unlink a task to an existing GitHub issue.",
403
- inputSchema: z.object({
404
- project: z.string().describe("Project name."),
405
- item: z.string().describe("Task text, ID, or stable bid to link."),
406
- issue_number: z.union([z.number().int().positive(), z.string()]).optional().describe("Existing GitHub issue number (for example 14 or '#14')."),
407
- issue_url: z.string().optional().describe("Existing GitHub issue URL."),
408
- unlink: z.boolean().optional().describe("If true, remove any linked issue from the task item."),
409
- }),
410
- }, async ({ project, item, issue_number, issue_url, unlink }) => {
411
- if (!isValidProjectName(project))
412
- return mcpResponse({ ok: false, error: `Invalid project name: "${project}"` });
413
- return withWriteQueue(async () => {
414
- if (unlink && (issue_number !== undefined || issue_url)) {
415
- return mcpResponse({ ok: false, error: "Use either unlink=true or issue_number/issue_url, not both." });
416
- }
417
- if (!unlink && issue_number === undefined && !issue_url) {
418
- return mcpResponse({ ok: false, error: "Provide issue_number or issue_url to link, or unlink=true to remove the link." });
419
- }
420
- if (issue_url) {
421
- const parsed = parseGithubIssueUrl(issue_url);
422
- if (!parsed)
423
- return mcpResponse({ ok: false, error: "issue_url must be a valid GitHub issue URL." });
424
- if (issue_number !== undefined) {
425
- const normalizedIssue = Number.parseInt(String(issue_number).replace(/^#/, ""), 10);
426
- if (normalizedIssue !== parsed.issueNumber) {
427
- return mcpResponse({ ok: false, error: "issue_number and issue_url refer to different issues." });
428
- }
370
+ // Runtime validation: item is required unless work_next is true
371
+ if (!updates.work_next && !item) {
372
+ return mcpResponse({ ok: false, error: "item is required unless updates.work_next is true." });
373
+ }
374
+ // Cross-validate github_issue and github_url
375
+ if (updates.github_url) {
376
+ const parsed = parseGithubIssueUrl(updates.github_url);
377
+ if (!parsed)
378
+ return mcpResponse({ ok: false, error: "github_url must be a valid GitHub issue URL." });
379
+ if (updates.github_issue !== undefined) {
380
+ const normalizedIssue = Number.parseInt(String(updates.github_issue).replace(/^#/, ""), 10);
381
+ if (normalizedIssue !== parsed.issueNumber) {
382
+ return mcpResponse({ ok: false, error: "github_issue and github_url refer to different issues." });
429
383
  }
430
384
  }
431
- const result = linkTaskIssue(phrenPath, project, item, {
432
- github_issue: issue_number,
433
- github_url: issue_url,
434
- unlink: unlink ?? false,
435
- });
436
- if (!result.ok)
437
- return mcpResponse({ ok: false, error: result.error, errorCode: result.code });
438
- refreshTaskIndex(updateFileInIndex, phrenPath, project);
439
- return mcpResponse({
440
- ok: true,
441
- message: unlink
442
- ? `Removed GitHub link from ${project} task.`
443
- : `Linked ${project} task to ${result.data.githubIssue ? `#${result.data.githubIssue}` : result.data.githubUrl}.`,
444
- data: {
445
- project,
446
- item,
447
- stableId: result.data.stableId || null,
448
- githubIssue: result.data.githubIssue ?? null,
449
- githubUrl: result.data.githubUrl || null,
450
- },
451
- });
452
- });
453
- });
454
- server.registerTool("promote_task_to_issue", {
455
- title: "◆ phren · promote task",
456
- description: "Create a GitHub issue from a task and link it back into the task list.",
457
- inputSchema: z.object({
458
- project: z.string().describe("Project name."),
459
- item: z.string().describe("Task text, ID, or stable bid to promote."),
460
- 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."),
461
- title: z.string().optional().describe("Optional GitHub issue title. Defaults to the task text."),
462
- body: z.string().optional().describe("Optional GitHub issue body. Defaults to a body built from the task plus context."),
463
- mark_done: z.boolean().optional().describe("If true, mark the task Done after creating and linking the issue."),
464
- }),
465
- }, async ({ project, item, repo, title, body, mark_done }) => {
466
- if (!isValidProjectName(project))
467
- return mcpResponse({ ok: false, error: `Invalid project name: "${project}"` });
385
+ }
468
386
  return withWriteQueue(async () => {
469
- const match = resolveTaskItem(phrenPath, project, item);
470
- if (!match.ok)
471
- return mcpResponse({ ok: false, error: match.error, errorCode: match.code });
472
- const targetRepo = repo || resolveProjectGithubRepo(phrenPath, project);
473
- if (!targetRepo) {
474
- 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." });
387
+ // Handle work_next: pick highest-priority Queue item, move to Active
388
+ if (updates.work_next) {
389
+ const result = workNextTask(phrenPath, project);
390
+ if (!result.ok)
391
+ return mcpResponse({ ok: false, error: result.error });
392
+ refreshTaskIndex(updateFileInIndex, phrenPath, project);
393
+ return mcpResponse({ ok: true, message: result.data, data: { project } });
475
394
  }
476
- const created = createGithubIssueForTask({
477
- repo: targetRepo,
478
- title: title?.trim() || match.data.line.replace(/\s*\[(high|medium|low)\]\s*$/i, "").trim(),
479
- body: body?.trim() || buildTaskIssueBody(project, match.data),
480
- });
481
- if (!created.ok)
482
- return mcpResponse({ ok: false, error: created.error, errorCode: created.code });
483
- const linked = linkTaskIssue(phrenPath, project, match.data.stableId ? `bid:${match.data.stableId}` : match.data.id, {
484
- github_issue: created.data.issueNumber,
485
- github_url: created.data.url,
486
- });
487
- if (!linked.ok)
488
- return mcpResponse({ ok: false, error: linked.error, errorCode: linked.code });
489
- if (mark_done) {
490
- const completionMatch = linked.data.stableId ? `bid:${linked.data.stableId}` : linked.data.id;
491
- const completed = completeTaskStore(phrenPath, project, completionMatch);
492
- if (!completed.ok)
493
- return mcpResponse({ ok: false, error: completed.error, errorCode: completed.code });
494
- clearTaskCheckpoint(phrenPath, {
495
- project,
496
- taskId: match.data.stableId ?? match.data.id,
497
- stableId: match.data.stableId,
498
- positionalId: match.data.id,
499
- taskLine: match.data.line,
395
+ // Handle pin
396
+ if (updates.pin) {
397
+ const result = pinTask(phrenPath, project, item);
398
+ if (!result.ok)
399
+ return mcpResponse({ ok: false, error: result.error });
400
+ refreshTaskIndex(updateFileInIndex, phrenPath, project);
401
+ return mcpResponse({ ok: true, message: result.data, data: { project, item } });
402
+ }
403
+ // Handle promote (clear speculative flag)
404
+ if (updates.promote) {
405
+ const result = promoteTask(phrenPath, project, item, updates.move_to_active ?? false);
406
+ if (!result.ok)
407
+ return mcpResponse({ ok: false, error: result.error });
408
+ refreshTaskIndex(updateFileInIndex, phrenPath, project);
409
+ return mcpResponse({
410
+ ok: true,
411
+ message: `Promoted task "${result.data.line}" in ${project}${updates.move_to_active ? " (moved to Active)" : ""}.`,
412
+ data: { project, item: result.data },
500
413
  });
501
414
  }
502
- refreshTaskIndex(updateFileInIndex, phrenPath, project);
503
- return mcpResponse({
504
- ok: true,
505
- message: `Created GitHub issue ${created.data.issueNumber ? `#${created.data.issueNumber}` : created.data.url} for ${project} task.`,
506
- data: {
507
- project,
508
- item,
509
- repo: targetRepo,
510
- githubIssue: created.data.issueNumber ?? null,
511
- githubUrl: created.data.url,
512
- markDone: mark_done ?? false,
513
- },
514
- });
515
- });
516
- });
517
- server.registerTool("pin_task", {
518
- title: "◆ phren · pin task",
519
- description: "Pin a task so it floats to the top of its section.",
520
- inputSchema: z.object({
521
- project: z.string().describe("Project name."),
522
- item: z.string().describe("Partial item text or ID to pin."),
523
- }),
524
- }, async ({ project, item }) => {
525
- if (!isValidProjectName(project))
526
- return mcpResponse({ ok: false, error: `Invalid project name: "${project}"` });
527
- return withWriteQueue(async () => {
528
- const result = pinTask(phrenPath, project, item);
529
- if (!result.ok)
530
- return mcpResponse({ ok: false, error: result.error });
531
- refreshTaskIndex(updateFileInIndex, phrenPath, project);
532
- return mcpResponse({ ok: true, message: result.data, data: { project, item } });
533
- });
534
- });
535
- server.registerTool("work_next_task", {
536
- title: "◆ phren · work next",
537
- description: "Move the highest-priority Queue item to Active so it becomes the next task to work on.",
538
- inputSchema: z.object({
539
- project: z.string().describe("Project name."),
540
- }),
541
- }, async ({ project }) => {
542
- if (!isValidProjectName(project))
543
- return mcpResponse({ ok: false, error: `Invalid project name: "${project}"` });
544
- return withWriteQueue(async () => {
545
- const result = workNextTask(phrenPath, project);
546
- if (!result.ok)
547
- return mcpResponse({ ok: false, error: result.error });
548
- refreshTaskIndex(updateFileInIndex, phrenPath, project);
549
- return mcpResponse({ ok: true, message: result.data, data: { project } });
550
- });
551
- });
552
- server.registerTool("promote_task", {
553
- title: "◆ phren · promote task",
554
- description: "Promote a speculative task to committed by clearing the speculative flag. " +
555
- "Use this when the user says 'yes do it', 'let's work on that', or otherwise confirms " +
556
- "they want to commit to a suggested task. Optionally moves it to Active.",
557
- inputSchema: z.object({
558
- project: z.string().describe("Project name."),
559
- item: z.string().describe("Partial text, stable ID (bid:XXXX), or positional ID of the speculative task."),
560
- move_to_active: z.boolean().optional().describe("If true, also move the task to the Active section. Default false."),
561
- }),
562
- }, async ({ project, item, move_to_active }) => {
563
- if (!isValidProjectName(project))
564
- return mcpResponse({ ok: false, error: `Invalid project name: "${project}"` });
565
- return withWriteQueue(async () => {
566
- const result = promoteTask(phrenPath, project, item, move_to_active ?? false);
415
+ // Handle github issue linking via update_task when github_issue or github_url is set (and no other field updates)
416
+ if ((updates.github_issue !== undefined || updates.github_url || updates.unlink_github) && !updates.text && !updates.priority && !updates.context && !updates.section) {
417
+ if (updates.unlink_github && (updates.github_issue !== undefined || updates.github_url)) {
418
+ return mcpResponse({ ok: false, error: "Use either unlink_github=true or github_issue/github_url, not both." });
419
+ }
420
+ const result = linkTaskIssue(phrenPath, project, item, {
421
+ github_issue: updates.github_issue,
422
+ github_url: updates.github_url,
423
+ unlink: updates.unlink_github ?? false,
424
+ });
425
+ if (!result.ok)
426
+ return mcpResponse({ ok: false, error: result.error, errorCode: result.code });
427
+ refreshTaskIndex(updateFileInIndex, phrenPath, project);
428
+ return mcpResponse({
429
+ ok: true,
430
+ message: updates.unlink_github
431
+ ? `Removed GitHub link from ${project} task.`
432
+ : `Linked ${project} task to ${result.data.githubIssue ? `#${result.data.githubIssue}` : result.data.githubUrl}.`,
433
+ data: {
434
+ project,
435
+ item,
436
+ stableId: result.data.stableId || null,
437
+ githubIssue: result.data.githubIssue ?? null,
438
+ githubUrl: result.data.githubUrl || null,
439
+ },
440
+ });
441
+ }
442
+ // Standard update path
443
+ const result = updateTaskStore(phrenPath, project, item, updates);
567
444
  if (!result.ok)
568
445
  return mcpResponse({ ok: false, error: result.error });
569
446
  refreshTaskIndex(updateFileInIndex, phrenPath, project);
570
- return mcpResponse({
571
- ok: true,
572
- message: `Promoted task "${result.data.line}" in ${project}${move_to_active ? " (moved to Active)" : ""}.`,
573
- data: { project, item: result.data },
574
- });
447
+ return mcpResponse({ ok: true, message: result.data, data: { project, item, updates } });
575
448
  });
576
449
  });
577
450
  server.registerTool("tidy_done_tasks", {
@@ -1,17 +1,18 @@
1
1
  import * as fs from "fs";
2
2
  import * as path from "path";
3
3
  import { createHash } from "crypto";
4
- import { getProjectDirs, runtimeDir, runtimeHealthFile, memoryUsageLogFile, homePath, } from "./shared.js";
5
- import { errorMessage } from "./utils.js";
6
- import { readInstallPreferences } from "./init-preferences.js";
7
- import { readCustomHooks } from "./hooks.js";
8
- import { hookConfigPaths, hookConfigRoots } from "./provider-adapters.js";
9
- import { readProjectConfig, isProjectHookEnabled, PROJECT_HOOK_EVENTS } from "./project-config.js";
10
- import { getAllSkills } from "./skill-registry.js";
11
- import { resolveTaskFilePath, readTasks, TASKS_FILENAME } from "./data-tasks.js";
12
- import { buildIndex, queryDocBySourceKey, queryRows } from "./shared-index.js";
13
- import { readProjectTopics, classifyTopicForText } from "./project-topics.js";
14
- import { entryScoreKey } from "./governance-scores.js";
4
+ import { getProjectDirs, runtimeDir, runtimeHealthFile, memoryUsageLogFile, homePath, } from "../shared.js";
5
+ import { errorMessage } from "../utils.js";
6
+ import { readInstallPreferences } from "../init/preferences.js";
7
+ import { readCustomHooks } from "../hooks.js";
8
+ import { hookConfigPaths, hookConfigRoots } from "../provider-adapters.js";
9
+ import { readProjectConfig, isProjectHookEnabled, PROJECT_HOOK_EVENTS } from "../project-config.js";
10
+ import { getAllSkills } from "../skill/registry.js";
11
+ import { resolveTaskFilePath, readTasks, TASKS_FILENAME } from "../data/tasks.js";
12
+ import { buildIndex, queryDocBySourceKey, queryRows } from "../shared/index.js";
13
+ import { readProjectTopics, classifyTopicForText } from "../project-topics.js";
14
+ import { entryScoreKey } from "../governance/scores.js";
15
+ import { logger } from "../logger.js";
15
16
  function extractGithubUrl(content) {
16
17
  const match = content.match(/https?:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+/);
17
18
  return match ? match[0] : undefined;
@@ -94,6 +95,18 @@ export function isAllowedFilePath(filePath, phrenPath) {
94
95
  });
95
96
  return allowedRealRoots.some((root) => realResolved === root || realResolved.startsWith(root + path.sep));
96
97
  }
98
+ /**
99
+ * Stricter path check for skill endpoints — only allows files under skills/ directories,
100
+ * not the entire phren store.
101
+ */
102
+ export function isAllowedSkillPath(filePath, phrenPath) {
103
+ if (!isAllowedFilePath(filePath, phrenPath))
104
+ return false;
105
+ const resolved = path.resolve(filePath);
106
+ // Must be under a "skills" directory segment
107
+ const segments = resolved.split(path.sep);
108
+ return segments.some((seg) => seg === "skills");
109
+ }
97
110
  export function collectSkillsForUI(phrenPath, profile = "") {
98
111
  return getAllSkills(phrenPath, profile).map((skill) => ({
99
112
  name: skill.name,
@@ -136,7 +149,7 @@ export function getHooksData(phrenPath, profile) {
136
149
  }
137
150
  return { globalEnabled, tools, customHooks: readCustomHooks(phrenPath), projectOverrides };
138
151
  }
139
- export async function buildGraph(phrenPath, profile, focusProject) {
152
+ export async function buildGraph(phrenPath, profile, focusProject, existingDb) {
140
153
  const projects = getProjectDirs(phrenPath, profile).map((projectDir) => path.basename(projectDir)).filter((project) => project !== "global");
141
154
  const nodes = [];
142
155
  const links = [];
@@ -287,9 +300,11 @@ export async function buildGraph(phrenPath, profile, focusProject) {
287
300
  // task loading failed — continue with other data sources
288
301
  }
289
302
  // ── Fragments (fragment graph) ──────────────────────────────────────
290
- let db = null;
303
+ const ownDb = !existingDb;
304
+ let db = existingDb ?? null;
291
305
  try {
292
- db = await buildIndex(phrenPath, profile);
306
+ if (!db)
307
+ db = await buildIndex(phrenPath, profile);
293
308
  const rows = queryRows(db, `SELECT e.id, e.name, e.type, COUNT(DISTINCT el.source_doc) as ref_count
294
309
  FROM entities e JOIN entity_links el ON el.target_id = e.id WHERE e.type != 'document'
295
310
  GROUP BY e.id, e.name, e.type ORDER BY ref_count DESC LIMIT 5000`, []);
@@ -364,7 +379,7 @@ export async function buildGraph(phrenPath, profile, focusProject) {
364
379
  // fragment loading failed — continue with other data sources
365
380
  }
366
381
  finally {
367
- if (db) {
382
+ if (ownDb && db) {
368
383
  try {
369
384
  db.close();
370
385
  }
@@ -467,8 +482,7 @@ export function collectProjectsForUI(phrenPath, profile) {
467
482
  }
468
483
  }
469
484
  catch (err) {
470
- if (process.env.PHREN_DEBUG)
471
- process.stderr.write(`[phren] memory-ui filterByProfile: ${errorMessage(err)}\n`);
485
+ logger.debug("memory-ui", `memory-ui filterByProfile: ${errorMessage(err)}`);
472
486
  }
473
487
  const results = [];
474
488
  for (const project of projects) {
@@ -1,6 +1,6 @@
1
- import { createWebUiHttpServer, startWebUiServer, } from "./memory-ui-server.js";
2
- import { renderWebUiPage } from "./memory-ui-page.js";
3
- export { renderPageForTests } from "./memory-ui-page.js";
1
+ import { createWebUiHttpServer, startWebUiServer, } from "./server.js";
2
+ import { renderWebUiPage } from "./page.js";
3
+ export { renderPageForTests } from "./page.js";
4
4
  export function createWebUiServer(phrenPath, opts, profile) {
5
5
  return createWebUiHttpServer(phrenPath, renderWebUiPage, profile, opts);
6
6
  }