@phren/cli 0.0.5 → 0.0.7

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.
@@ -6,6 +6,7 @@ import * as path from "path";
6
6
  import * as crypto from "crypto";
7
7
  import { debugLog, installPreferencesFile } from "./phren-paths.js";
8
8
  import { errorMessage } from "./utils.js";
9
+ import { withFileLock } from "./governance-locks.js";
9
10
  function preferencesFile(phrenPath) {
10
11
  return installPreferencesFile(phrenPath);
11
12
  }
@@ -24,7 +25,7 @@ function readPreferencesFile(file) {
24
25
  return {};
25
26
  }
26
27
  }
27
- function writePreferencesFile(file, current, patch) {
28
+ function writePreferencesFileRaw(file, current, patch) {
28
29
  fs.mkdirSync(path.dirname(file), { recursive: true });
29
30
  const tmpPath = `${file}.tmp-${crypto.randomUUID()}`;
30
31
  fs.writeFileSync(tmpPath, JSON.stringify({
@@ -34,6 +35,12 @@ function writePreferencesFile(file, current, patch) {
34
35
  }, null, 2) + "\n");
35
36
  fs.renameSync(tmpPath, file);
36
37
  }
38
+ function writePreferencesFile(file, patch) {
39
+ withFileLock(file, () => {
40
+ const current = readPreferencesFile(file);
41
+ writePreferencesFileRaw(file, current, patch);
42
+ });
43
+ }
37
44
  export function readInstallPreferences(phrenPath) {
38
45
  return readPreferencesFile(preferencesFile(phrenPath));
39
46
  }
@@ -41,10 +48,18 @@ export function readGovernanceInstallPreferences(phrenPath) {
41
48
  return readPreferencesFile(governanceInstallPreferencesFile(phrenPath));
42
49
  }
43
50
  export function writeInstallPreferences(phrenPath, patch) {
44
- writePreferencesFile(preferencesFile(phrenPath), readInstallPreferences(phrenPath), patch);
51
+ writePreferencesFile(preferencesFile(phrenPath), patch);
45
52
  }
46
53
  export function writeGovernanceInstallPreferences(phrenPath, patch) {
47
- writePreferencesFile(governanceInstallPreferencesFile(phrenPath), readGovernanceInstallPreferences(phrenPath), patch);
54
+ writePreferencesFile(governanceInstallPreferencesFile(phrenPath), patch);
55
+ }
56
+ /** Atomically read-modify-write install preferences using a patcher function. */
57
+ export function updateInstallPreferences(phrenPath, patcher) {
58
+ const file = preferencesFile(phrenPath);
59
+ withFileLock(file, () => {
60
+ const current = readPreferencesFile(file);
61
+ writePreferencesFileRaw(file, current, patcher(current));
62
+ });
48
63
  }
49
64
  export function getMcpEnabledPreference(phrenPath) {
50
65
  const prefs = readInstallPreferences(phrenPath);
package/mcp/dist/init.js CHANGED
@@ -910,6 +910,17 @@ export async function runInit(opts = {}) {
910
910
  }
911
911
  let phrenPath = resolveInitPhrenPath(opts);
912
912
  const dryRun = Boolean(opts.dryRun);
913
+ // Migrate ~/.cortex → ~/.phren when upgrading from the old name.
914
+ // Only runs when the resolved phrenPath doesn't exist yet but ~/.cortex does.
915
+ if (!opts._walkthroughStoragePath && !fs.existsSync(phrenPath)) {
916
+ const cortexPath = path.resolve(homePath(".cortex"));
917
+ if (cortexPath !== phrenPath && fs.existsSync(cortexPath) && hasInstallMarkers(cortexPath)) {
918
+ if (!dryRun) {
919
+ fs.renameSync(cortexPath, phrenPath);
920
+ }
921
+ console.log(`Migrated ~/.cortex → ~/.phren`);
922
+ }
923
+ }
913
924
  let hasExistingInstall = hasInstallMarkers(phrenPath);
914
925
  // Interactive walkthrough for first-time installs (skip with --yes or non-TTY)
915
926
  if (!hasExistingInstall && !dryRun && !opts.yes && process.stdin.isTTY && process.stdout.isTTY) {
@@ -5,14 +5,6 @@ import { PROACTIVITY_LEVELS, getProactivityLevel, getProactivityLevelForFindings
5
5
  import { readGovernanceInstallPreferences, writeGovernanceInstallPreferences, } from "./init-preferences.js";
6
6
  import { FINDING_SENSITIVITY_CONFIG } from "./cli-config.js";
7
7
  // ── Helpers ─────────────────────────────────────────────────────────────────
8
- function normalizeProactivityLevel(raw) {
9
- if (!raw)
10
- return undefined;
11
- const normalized = raw.trim().toLowerCase();
12
- return PROACTIVITY_LEVELS.includes(normalized)
13
- ? normalized
14
- : undefined;
15
- }
16
8
  function proactivitySnapshot(phrenPath) {
17
9
  const prefs = readGovernanceInstallPreferences(phrenPath);
18
10
  return {
@@ -215,7 +215,14 @@ export function register(server, ctx) {
215
215
  }
216
216
  catch (indexError) {
217
217
  // Index rebuild failed — restore backup if we replaced the project dir
218
- if (overwrite) {
218
+ if (!overwrite) {
219
+ // Non-overwrite case: no backup to restore — remove the orphaned project dir
220
+ try {
221
+ fs.rmSync(projectDir, { recursive: true });
222
+ }
223
+ catch { /* best-effort */ }
224
+ }
225
+ else {
219
226
  // Find the backup dir that was created earlier
220
227
  try {
221
228
  for (const entry of fs.readdirSync(phrenPath)) {
@@ -287,7 +294,13 @@ export function register(server, ctx) {
287
294
  return mcpResponse({ ok: false, error: `Archive "${project}.archived" already exists. Unarchive or remove it first.` });
288
295
  }
289
296
  fs.renameSync(projectDir, archiveDir);
290
- await rebuildIndex();
297
+ try {
298
+ await rebuildIndex();
299
+ }
300
+ catch (err) {
301
+ fs.renameSync(archiveDir, projectDir);
302
+ return mcpResponse({ ok: false, error: `Index rebuild failed after archive rename, rolled back: ${err instanceof Error ? err.message : String(err)}` });
303
+ }
291
304
  return mcpResponse({
292
305
  ok: true,
293
306
  message: `Archived project "${project}". Data preserved at ${archiveDir}.`,
@@ -304,7 +317,13 @@ export function register(server, ctx) {
304
317
  return mcpResponse({ ok: false, error: `No archive found for "${project}".`, data: { availableArchives: available } });
305
318
  }
306
319
  fs.renameSync(archiveDir, projectDir);
307
- await rebuildIndex();
320
+ try {
321
+ await rebuildIndex();
322
+ }
323
+ catch (err) {
324
+ fs.renameSync(projectDir, archiveDir);
325
+ return mcpResponse({ ok: false, error: `Index rebuild failed after unarchive rename, rolled back: ${err instanceof Error ? err.message : String(err)}` });
326
+ }
308
327
  return mcpResponse({
309
328
  ok: true,
310
329
  message: `Unarchived project "${project}". It is now active again.`,
@@ -125,6 +125,7 @@ export function register(server, ctx) {
125
125
  }
126
126
  return mcpResponse({
127
127
  ok: added.length > 0,
128
+ ...(added.length === 0 ? { error: `All ${findings.length} finding(s) were skipped (duplicates or errors).` } : {}),
128
129
  message: `Extracted ${findings.length} finding(s): ${added.length} added, ${allSkipped.length} skipped (duplicates or errors).`,
129
130
  data: { project, extracted: findings, added, skipped: allSkipped, dryRun: false },
130
131
  });
@@ -203,7 +203,7 @@ export function register(server, ctx) {
203
203
  if (err instanceof Error && err.message.includes("Rejected:")) {
204
204
  return mcpResponse({ ok: false, error: errorMessage(err), errorCode: "VALIDATION_ERROR" });
205
205
  }
206
- throw err;
206
+ return mcpResponse({ ok: false, error: `Unexpected error saving finding: ${errorMessage(err)}` });
207
207
  }
208
208
  });
209
209
  });
@@ -2,7 +2,7 @@ import { mcpResponse } from "./mcp-types.js";
2
2
  import { z } from "zod";
3
3
  import * as fs from "fs";
4
4
  import * as path from "path";
5
- import { readInstallPreferences, writeInstallPreferences } from "./init-preferences.js";
5
+ import { readInstallPreferences, updateInstallPreferences } from "./init-preferences.js";
6
6
  import { readCustomHooks, getHookTarget, HOOK_EVENT_VALUES } from "./hooks.js";
7
7
  import { hookConfigPath } from "./shared.js";
8
8
  import { PROJECT_HOOK_EVENTS, isProjectHookEnabled, readProjectConfig, writeProjectHookConfig } from "./project-config.js";
@@ -161,16 +161,15 @@ export function register(server, ctx) {
161
161
  if (!normalized) {
162
162
  return mcpResponse({ ok: false, error: `Invalid tool "${tool}". Use: ${HOOK_TOOLS.join(", ")}` });
163
163
  }
164
- const prefs = readInstallPreferences(phrenPath);
165
- writeInstallPreferences(phrenPath, {
164
+ updateInstallPreferences(phrenPath, (prefs) => ({
166
165
  hookTools: {
167
166
  ...(prefs.hookTools && typeof prefs.hookTools === "object" ? prefs.hookTools : {}),
168
167
  [normalized]: enabled,
169
168
  },
170
- });
169
+ }));
171
170
  return mcpResponse({ ok: true, message: `${enabled ? "Enabled" : "Disabled"} hooks for ${normalized}.`, data: { tool: normalized, enabled } });
172
171
  }
173
- writeInstallPreferences(phrenPath, { hooksEnabled: enabled });
172
+ updateInstallPreferences(phrenPath, () => ({ hooksEnabled: enabled }));
174
173
  return mcpResponse({ ok: true, message: `${enabled ? "Enabled" : "Disabled"} hooks globally.`, data: { global: true, enabled } });
175
174
  });
176
175
  // ── add_custom_hook ──────────────────────────────────────────────────────
@@ -202,12 +201,26 @@ export function register(server, ctx) {
202
201
  const { hostname } = new URL(trimmed);
203
202
  const h = hostname.toLowerCase().replace(/^\[|\]$/g, ""); // strip IPv6 brackets
204
203
  const ssrfBlocked = h === "localhost" ||
205
- h === "::1" ||
204
+ // IPv4 private/loopback ranges
206
205
  /^127\./.test(h) ||
207
206
  /^10\./.test(h) ||
208
207
  /^172\.(1[6-9]|2\d|3[01])\./.test(h) ||
209
208
  /^192\.168\./.test(h) ||
210
209
  /^169\.254\./.test(h) ||
210
+ // IPv6 loopback
211
+ h === "::1" ||
212
+ // IPv6 ULA (fc00::/7 covers fc:: and fd::)
213
+ h.startsWith("fc") ||
214
+ h.startsWith("fd") ||
215
+ // IPv6 link-local (fe80::/10)
216
+ h.startsWith("fe80:") ||
217
+ // IPv4-mapped IPv6 (::ffff:10.x.x.x, ::ffff:127.x.x.x, etc.)
218
+ /^::ffff:/i.test(h) ||
219
+ // Raw numeric IPv4 forms not normalized by all URL parsers:
220
+ // decimal (2130706433), hex (0x7f000001), octal (0177.0.0.1 prefix)
221
+ /^(0x[0-9a-f]+|0\d+)$/i.test(h) ||
222
+ // Pure decimal integer that encodes an IPv4 address (8+ digits covers 0.0.0.0+)
223
+ /^\d{8,10}$/.test(h) ||
211
224
  h.endsWith(".local") ||
212
225
  h.endsWith(".internal");
213
226
  if (ssrfBlocked) {
@@ -226,10 +239,13 @@ export function register(server, ctx) {
226
239
  newHook = { event, command: command, ...(timeout !== undefined ? { timeout } : {}) };
227
240
  }
228
241
  return ctx.withWriteQueue(async () => {
229
- const prefs = readInstallPreferences(phrenPath);
230
- const existing = Array.isArray(prefs.customHooks) ? prefs.customHooks : [];
231
- writeInstallPreferences(phrenPath, { ...prefs, customHooks: [...existing, newHook] });
232
- return mcpResponse({ ok: true, message: `Added custom hook for "${event}": ${"webhook" in newHook ? "[webhook] " : ""}${getHookTarget(newHook)}`, data: { hook: newHook, total: existing.length + 1 } });
242
+ let totalAfter = 0;
243
+ updateInstallPreferences(phrenPath, (prefs) => {
244
+ const existing = Array.isArray(prefs.customHooks) ? prefs.customHooks : [];
245
+ totalAfter = existing.length + 1;
246
+ return { customHooks: [...existing, newHook] };
247
+ });
248
+ return mcpResponse({ ok: true, message: `Added custom hook for "${event}": ${"webhook" in newHook ? "[webhook] " : ""}${getHookTarget(newHook)}`, data: { hook: newHook, total: totalAfter } });
233
249
  });
234
250
  });
235
251
  // ── remove_custom_hook ───────────────────────────────────────────────────
@@ -242,15 +258,19 @@ export function register(server, ctx) {
242
258
  }),
243
259
  }, async ({ event, command }) => {
244
260
  return ctx.withWriteQueue(async () => {
245
- const prefs = readInstallPreferences(phrenPath);
246
- const existing = Array.isArray(prefs.customHooks) ? prefs.customHooks : [];
247
- const remaining = existing.filter(h => h.event !== event || (command && !getHookTarget(h).includes(command)));
248
- const removed = existing.length - remaining.length;
261
+ let removed = 0;
262
+ let remainingCount = 0;
263
+ updateInstallPreferences(phrenPath, (prefs) => {
264
+ const existing = Array.isArray(prefs.customHooks) ? prefs.customHooks : [];
265
+ const remaining = existing.filter(h => h.event !== event || (command != null && !getHookTarget(h).includes(command)));
266
+ removed = existing.length - remaining.length;
267
+ remainingCount = remaining.length;
268
+ return { customHooks: remaining };
269
+ });
249
270
  if (removed === 0) {
250
271
  return mcpResponse({ ok: false, error: `No custom hooks matched event="${event}"${command ? ` command containing "${command}"` : ""}.` });
251
272
  }
252
- writeInstallPreferences(phrenPath, { ...prefs, customHooks: remaining });
253
- return mcpResponse({ ok: true, message: `Removed ${removed} custom hook(s) for "${event}".`, data: { removed, remaining: remaining.length } });
273
+ return mcpResponse({ ok: true, message: `Removed ${removed} custom hook(s) for "${event}".`, data: { removed, remaining: remainingCount } });
254
274
  });
255
275
  });
256
276
  }
@@ -41,7 +41,6 @@ export function register(server, ctx) {
41
41
  flushEntryScores(phrenPath);
42
42
  const feedbackWeights = {
43
43
  helpful: 1.0,
44
- not_helpful: -0.3,
45
44
  reprompt: -0.5,
46
45
  regression: -1.0,
47
46
  };
@@ -11,16 +11,9 @@ import { PROJECT_OWNERSHIP_MODES, parseProjectOwnershipMode } from "./project-co
11
11
  import { resolveRuntimeProfile } from "./runtime-profile.js";
12
12
  import { getMachineName } from "./machine-identity.js";
13
13
  import { getProjectConsolidationStatus, CONSOLIDATION_ENTRY_THRESHOLD } from "./content-validate.js";
14
- /** Translate a PhrenResult<string> into a standard McpToolResult shape. */
15
- function phrenResultToMcp(result) {
16
- if (result.ok) {
17
- return { ok: true, message: result.data };
18
- }
19
- return { ok: false, error: result.error, errorCode: result.code };
20
- }
21
14
  export function register(server, ctx) {
22
- const { phrenPath, profile, withWriteQueue, updateFileInIndex } = ctx;
23
- // ── get_consolidation_status ───────────────────────────────────────────────
15
+ const { phrenPath, profile, withWriteQueue } = ctx;
16
+ // ── add_project ────────────────────────────────────────────────────────────
24
17
  server.registerTool("add_project", {
25
18
  title: "◆ phren · add project",
26
19
  description: "Bootstrap a project into phren from a repo or working directory. " +
@@ -221,11 +214,13 @@ export function register(server, ctx) {
221
214
  const { runDoctor } = await import("./link-doctor.js");
222
215
  const result = await runDoctor(phrenPath, true, check_data ?? false);
223
216
  const lines = result.checks.map((c) => `${c.ok ? "ok" : "FAIL"} ${c.name}: ${c.detail}`);
217
+ const failCount = result.checks.filter((c) => !c.ok).length;
224
218
  return mcpResponse({
225
219
  ok: result.ok,
220
+ ...(result.ok ? {} : { error: `${failCount} check(s) could not be auto-fixed: ${lines.filter((l) => l.startsWith("FAIL")).join("; ")}` }),
226
221
  message: result.ok
227
222
  ? `Doctor fix complete: all ${result.checks.length} checks passed`
228
- : `Doctor fix complete: ${result.checks.filter((c) => !c.ok).length} issue(s) remain`,
223
+ : `Doctor fix complete: ${failCount} issue(s) remain`,
229
224
  data: {
230
225
  machine: result.machine,
231
226
  profile: result.profile,
@@ -131,7 +131,13 @@ export function register(server, ctx) {
131
131
  }),
132
132
  }, async ({ id: rawId }) => {
133
133
  // Normalize ID: decode URL encoding and normalize path separators
134
- const id = normalizeMemoryId(rawId);
134
+ let id;
135
+ try {
136
+ id = normalizeMemoryId(rawId);
137
+ }
138
+ catch {
139
+ return mcpResponse({ ok: false, error: `Invalid memory ID format: "${rawId}" contains malformed URL encoding.` });
140
+ }
135
141
  const match = id.match(/^mem:([^/]+)\/(.+)$/);
136
142
  if (!match) {
137
143
  return mcpResponse({ ok: false, error: `Invalid memory ID format "${rawId}". Expected mem:project/path/to/file.md.` });
@@ -169,7 +169,7 @@ function findMostRecentSummaryWithProject(phrenPath) {
169
169
  if (fs.existsSync(fastPath)) {
170
170
  const data = JSON.parse(fs.readFileSync(fastPath, "utf-8"));
171
171
  if (data.summary)
172
- return { summary: data.summary, project: data.project };
172
+ return { summary: data.summary, project: data.project, endedAt: data.endedAt };
173
173
  }
174
174
  }
175
175
  catch (err) {
@@ -181,7 +181,7 @@ function findMostRecentSummaryWithProject(phrenPath) {
181
181
  if (results.length === 0)
182
182
  return { summary: null };
183
183
  const best = results[0]; // already sorted newest-mtime-first
184
- return { summary: best.data.summary, project: best.data.project };
184
+ return { summary: best.data.summary, project: best.data.project, endedAt: best.data.endedAt };
185
185
  }
186
186
  /** Resolve session file from an explicit sessionId or a previously-bound connectionId. */
187
187
  function resolveSessionFile(phrenPath, sessionId, connectionId) {
@@ -294,7 +294,7 @@ export function getSessionArtifacts(phrenPath, sessionId, project) {
294
294
  const shortId = sessionId.slice(0, 8);
295
295
  try {
296
296
  const projectDirs = getProjectDirs(phrenPath);
297
- const targetProjects = project ? [project] : projectDirs;
297
+ const targetProjects = project ? [project] : projectDirs.map((d) => path.basename(d));
298
298
  for (const proj of targetProjects) {
299
299
  // Findings with matching sessionId
300
300
  const findingsResult = readFindings(phrenPath, proj);
@@ -338,6 +338,46 @@ function hasCompletedTasksInSession(phrenPath, sessionId, project) {
338
338
  const artifacts = getSessionArtifacts(phrenPath, sessionId, project);
339
339
  return artifacts.tasks.some((task) => task.section === "Done" && task.checked);
340
340
  }
341
+ /** Compute what changed since the last session ended. */
342
+ export function computeSessionDiff(phrenPath, project, lastSessionEnd) {
343
+ const projectDir = path.join(phrenPath, project);
344
+ const findingsPath = path.join(projectDir, "FINDINGS.md");
345
+ if (!fs.existsSync(findingsPath))
346
+ return { newFindings: 0, superseded: 0, tasksCompleted: 0 };
347
+ const content = fs.readFileSync(findingsPath, "utf8");
348
+ const lines = content.split("\n");
349
+ let currentDate = null;
350
+ let newFindings = 0;
351
+ let superseded = 0;
352
+ const cutoff = lastSessionEnd.slice(0, 10);
353
+ for (const line of lines) {
354
+ const dateMatch = line.match(/^## (\d{4}-\d{2}-\d{2})$/);
355
+ if (dateMatch) {
356
+ currentDate = dateMatch[1];
357
+ continue;
358
+ }
359
+ if (!line.startsWith("- ") || !currentDate)
360
+ continue;
361
+ if (currentDate >= cutoff) {
362
+ newFindings++;
363
+ if (line.includes('status "superseded"'))
364
+ superseded++;
365
+ }
366
+ }
367
+ // Count tasks completed since last session by checking Done items with recent activity dates
368
+ let tasksCompleted = 0;
369
+ const tasksResult = readTasks(phrenPath, project);
370
+ if (tasksResult.ok) {
371
+ for (const item of tasksResult.data.items.Done) {
372
+ // lastActivity is updated on completion; fall back to createdAt
373
+ const itemDate = item.lastActivity?.slice(0, 10) ?? item.createdAt?.slice(0, 10);
374
+ if (itemDate && itemDate >= cutoff) {
375
+ tasksCompleted++;
376
+ }
377
+ }
378
+ }
379
+ return { newFindings, superseded, tasksCompleted };
380
+ }
341
381
  export function register(server, ctx) {
342
382
  const { phrenPath } = ctx;
343
383
  server.registerTool("session_start", {
@@ -364,6 +404,7 @@ export function register(server, ctx) {
364
404
  const priorEnded = prior ? null : findMostRecentSummaryWithProject(phrenPath);
365
405
  const priorSummary = prior?.summary ?? priorEnded?.summary ?? null;
366
406
  const priorProject = prior?.project ?? priorEnded?.project;
407
+ const priorEndedAt = prior?.endedAt ?? priorEnded?.endedAt;
367
408
  // Create new session with unique ID in its own file
368
409
  const sessionId = crypto.randomUUID();
369
410
  const next = {
@@ -403,12 +444,12 @@ export function register(server, ctx) {
403
444
  try {
404
445
  const tasks = readTasks(phrenPath, activeProject);
405
446
  if (tasks.ok) {
406
- const queueItems = tasks.data.items.Queue
447
+ const activeItems = tasks.data.items.Active
407
448
  .filter((entry) => isMemoryScopeVisible(normalizeMemoryScope(entry.scope), activeScope))
408
449
  .slice(0, 5)
409
450
  .map((entry) => `- [ ] ${entry.line}`);
410
- if (queueItems.length > 0) {
411
- parts.push(`## Active task (${activeProject})\n${queueItems.join("\n")}`);
451
+ if (activeItems.length > 0) {
452
+ parts.push(`## Active task (${activeProject})\n${activeItems.join("\n")}`);
412
453
  }
413
454
  }
414
455
  }
@@ -447,6 +488,25 @@ export function register(server, ctx) {
447
488
  debugError("session_start checkpointsRead", err);
448
489
  }
449
490
  }
491
+ // Compute context diff since last session
492
+ if (activeProject && isValidProjectName(activeProject) && priorEndedAt) {
493
+ try {
494
+ const diff = computeSessionDiff(phrenPath, activeProject, priorEndedAt);
495
+ if (diff.newFindings > 0 || diff.superseded > 0 || diff.tasksCompleted > 0) {
496
+ const diffParts = [];
497
+ if (diff.newFindings > 0)
498
+ diffParts.push(`${diff.newFindings} new finding${diff.newFindings === 1 ? "" : "s"}`);
499
+ if (diff.superseded > 0)
500
+ diffParts.push(`${diff.superseded} superseded`);
501
+ if (diff.tasksCompleted > 0)
502
+ diffParts.push(`${diff.tasksCompleted} task${diff.tasksCompleted === 1 ? "" : "s"} in done`);
503
+ parts.push(`## Since last session\n${diffParts.join(", ")}.`);
504
+ }
505
+ }
506
+ catch (err) {
507
+ debugError("session_start contextDiff", err);
508
+ }
509
+ }
450
510
  const message = parts.length > 0
451
511
  ? `Session started (${sessionId.slice(0, 8)}).\n\n${parts.join("\n\n")}`
452
512
  : `Session started (${sessionId.slice(0, 8)}). No prior context found.`;
@@ -120,9 +120,12 @@ export function register(server, ctx) {
120
120
  }
121
121
  fs.mkdirSync(destDir, { recursive: true });
122
122
  const existing = findLocalSkill(phrenPath, scope, safeName);
123
- const dest = existing
123
+ const dest = path.resolve(existing
124
124
  ? existing.path
125
- : path.join(destDir, `${safeName}.md`);
125
+ : path.join(destDir, `${safeName}.md`));
126
+ if (!dest.startsWith(phrenPath + path.sep) && dest !== phrenPath) {
127
+ return mcpResponse({ ok: false, error: "Skill path escapes phren store." });
128
+ }
126
129
  const existed = Boolean(existing) || fs.existsSync(dest);
127
130
  fs.writeFileSync(dest, content);
128
131
  updateFileInIndex(dest);
@@ -159,9 +159,12 @@ export function register(server, ctx) {
159
159
  data: { project, includedSections: view.includedSections, totalItems: view.totalItems, summary: true },
160
160
  });
161
161
  }
162
+ const sectionCounts = view.includedSections
163
+ .map((s) => `${s}: ${view.doc.items[s].length}/${doc.items[s].length}`)
164
+ .join(", ");
162
165
  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._` : "");
166
+ ? `\n\n_${sectionCounts} (offset ${offset ?? 0}). Use offset/limit to page._`
167
+ : (offset ? `\n\n_Page offset: ${offset}. ${sectionCounts}._` : "");
165
168
  return mcpResponse({
166
169
  ok: true,
167
170
  message: `## ${project}\n${taskMarkdown(view.doc)}${paginationNote}`,
@@ -231,7 +234,7 @@ export function register(server, ctx) {
231
234
  const { added, errors } = result.data;
232
235
  if (added.length > 0)
233
236
  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 } });
237
+ 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 } });
235
238
  });
236
239
  });
237
240
  server.registerTool("complete_task", {
@@ -303,7 +306,7 @@ export function register(server, ctx) {
303
306
  }
304
307
  if (completed.length > 0)
305
308
  refreshTaskIndex(updateFileInIndex, phrenPath, project);
306
- return mcpResponse({ ok: completed.length > 0, message: `Completed ${completed.length}/${items.length} items`, data: { project, completed, errors } });
309
+ 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 } });
307
310
  });
308
311
  });
309
312
  server.registerTool("remove_task", {