@phren/cli 0.0.6 → 0.0.8

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.
@@ -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.` });
@@ -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);
@@ -364,15 +364,16 @@ export function computeSessionDiff(phrenPath, project, lastSessionEnd) {
364
364
  superseded++;
365
365
  }
366
366
  }
367
- // Count completed tasks since last session
368
- const tasksPath = path.join(projectDir, "TASKS.md");
367
+ // Count tasks completed since last session by checking Done items with recent activity dates
369
368
  let tasksCompleted = 0;
370
- if (fs.existsSync(tasksPath)) {
371
- const taskContent = fs.readFileSync(tasksPath, "utf8");
372
- const doneMatch = taskContent.match(/## Done[\s\S]*/);
373
- if (doneMatch) {
374
- const doneLines = doneMatch[0].split("\n").filter(l => l.startsWith("- "));
375
- tasksCompleted = doneLines.length;
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
+ }
376
377
  }
377
378
  }
378
379
  return { newFindings, superseded, tasksCompleted };
@@ -443,12 +444,12 @@ export function register(server, ctx) {
443
444
  try {
444
445
  const tasks = readTasks(phrenPath, activeProject);
445
446
  if (tasks.ok) {
446
- const queueItems = tasks.data.items.Queue
447
+ const activeItems = tasks.data.items.Active
447
448
  .filter((entry) => isMemoryScopeVisible(normalizeMemoryScope(entry.scope), activeScope))
448
449
  .slice(0, 5)
449
450
  .map((entry) => `- [ ] ${entry.line}`);
450
- if (queueItems.length > 0) {
451
- parts.push(`## Active task (${activeProject})\n${queueItems.join("\n")}`);
451
+ if (activeItems.length > 0) {
452
+ parts.push(`## Active task (${activeProject})\n${activeItems.join("\n")}`);
452
453
  }
453
454
  }
454
455
  }
@@ -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", {